SLOPSHOPPER

live

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

newpanebandspinnerguardcommand
★ 2v?MITupdated 2026-10-15guimatheus92/pr-review/mods/live
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · live
│ ┃ pr-review-live ✕ › fix the failing auth test and add an audit log call │ ┃ No pr-review run attached. /pr-review-live │ ┃ [run-id], or start a review. ● live: pr-review live: status poll: not a run id — --porcelain │ ⏺ 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 │ │ › /pr-review-live │ ⎿ live: pr-review live: no run found under /Users/dev/.pr-review/r │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · pr-review-live
No pr-review run attached. /pr-review-live [run-id], or start a review.
README

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

Highlights

  • Full retention, eligible-only publication. Every deduplicated finding remains in local evidence. Eligible findings post as GitHub review comments, Azure DevOps threads or GitLab discussions — never a top-level comment. See publication controls and posting guarantees.
  • Passes, not built-in reviewers. Every pass is one skill applied by a generic agent; the only built-in check is whether the PR's own title and description match the diff. Review knowledge lives in versioned skill packs (awesome-copilot, owasp, …) and in your repo's own skill dirs.
  • Your rules in every pass. Matched .claude/skills, .github/instructions, .agents/skills… files become authoritative project rules for every pass — no cap, never truncated. See add your own rules.
  • Stack-aware routing. GitHub Linguist languages plus manifest dependencies rank passes by evidence tier. Preview the selection with --context-only.
  • Untrusted by default. A rule file the PR itself added or changed cannot instruct its own review; a changed .pr-review.yaml or repository MCP config is ignored too.
  • Background-friendly and resumable. --detach returns a run-id, status polls it, and --resume re-dispatches only what is missing under authenticated run state. Partial findings never post.
  • Live view in Claude Code. While a review runs, a plugin mod draws a band above the prompt (phase, timer, what each reviewer delivered, counts by source), a pane with one row per reviewer and the project rules in context, and the timer beside the spinner; /pr-review-live opens the pane or answers in text. See live view.
  • Two runtimes, one command. Host the session in Copilot CLI or Claude Code (--runtime), same /pr-review.
  • Second opinions. Optional Codex sibling, plus companion plugins (pr-review-toolkit, code-review) when installed.

How it works

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

Table of contents

Quick start

1. Install

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

2. Authenticate

ProviderEnv varFallback
GitHubGITHUB_TOKEN / GH_TOKEN / COPILOT_GITHUB_TOKENgh auth token
GitHub Enterprise ServerGH_ENTERPRISE_TOKEN / GITHUB_ENTERPRISE_TOKEN (github.com tokens are never sent to a GHES host)gh auth token --hostname <host>
Azure DevOpsAZURE_DEVOPS_PAT / SYSTEM_ACCESSTOKEN / AZURE_DEVOPS_EXT_PAT (PAT), AZURE_DEVOPS_BEARER (bearer)az account get-access-token
GitLabGITLAB_TOKEN / GITLAB_ACCESS_TOKENglab 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.

3. Review a PR

/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

Usage

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

Publication controls

--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
criticalCRITICAL
highCRITICAL, HIGH
mediumCRITICAL, HIGH, MEDIUM
lowEverything except NIT
nit or omittedEverything
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.

Posting guarantees

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:

  • Never top-level. GitHub findings post as review comments (one batched review; if the batch fails, the PR is read back and the missing comments are posted one by one). Azure DevOps findings post as threads; GitLab findings post as inline discussions. There is no top-level issue-comment fallback.
  • Nothing silently dropped. Suppressed findings stay in local artifacts. Eligible findings outside the diff are snapped to the nearest valid diff line. On GitHub and GitLab, findings that can't anchor where they point (file outside the diff, or no location) are re-anchored to the first valid diff line, keeping the original 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.
  • Body-only comments. No severity prefix, title, footer, attribution, or bot chrome. Publication counts belong only in local aggregate metadata.
  • Skipping only in --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.
  • No summary comment, ever. The review does not post a verdict, a recap, or a "### Code review" banner when it finishes. The end-of-run summary is a local file (pr-review-summary.md in the run directory), never a comment on the PR.
  • Never reviewed on a partial file list. The provider's changed-file list is checked against the provider's own count and its truncation flag. On a mismatch pr-review completes the list from your checkout, or fails before caching anything — it never reviews a diff it cannot prove is whole.
  • Never pays for what it will not read. A PR too large to review is refused before its content is downloaded, and a file the diff filters exclude costs no fetch at all. On Azure DevOps, where every changed file is diffed from two full downloads, that is the difference between a thousand requests and none.

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.

Review passes & skill packs

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:

PackSourceContentBaseline passes (run when nothing better matches)
awesome-copilotgithub/awesome-copilotinstructions/*.instructions.md, skills/*/SKILL.mdcode-review-generic, security-and-owasp, performance-optimization, qa-engineering-best-practices, self-explanatory-code-commenting
owaspOWASP/CheatSheetSeriescheatsheets/*.mderror-handling, logging
anthropic-cybersecuritymukul975/Anthropic-Cybersecurity-Skillsdefensive-review skills (operational/offensive ones excluded)none — mode: index, on-demand only, never a pass

How passes are selected:

  • Your skills are context, not lenses. Every matched repo/configured/explicit/forced skill is injected into EVERY pass as authoritative project rules. The passes are pack skills.
  • Evidence tiers. Pack passes rank by: specific path glob › manifest-backed dependency/framework token › language-consistent type/manifest glob › exact stack tag. At most 6 stack passes run, plus up to 2 installed-plugin passes, plus EVERY baseline pointer. A product guide cannot qualify merely because the PR touches its language or a generic manifest.
  • Stack detection emits canonical Linguist language names, reads shallow root manifests plus the manifests owning changed files, and keeps language, ecosystem, dependency-name and dependency-token evidence separate. Legacy and canonical Azure DevOps remotes normalize to the same checkout identity.
  • Docs-only PRs run only glob-matched or --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.

Add your own rules

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
  • Every match injects into every pass as an authoritative project rule (skills-project.md — it overrides generic judgement), keeping its plain name. There is no numeric cap and skill bodies
Source 1 files
hooks/register.js 1022 lines
1// 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