name: pr-brief
allowed-tools: Bash(gh pr view:), Bash(gh pr diff:), Bash(gh pr list:), Bash(gh api:), Bash(gh auth:), Bash(gh repo view:), Bash(python3:), Bash(open:), Bash(mkdir:), Bash(cp:), Bash(find:), Bash(lsof:), Bash(kill:*), Read, Write, Glob, Grep, Task, TodoWrite
description: Interactive PR review. Pulls a PR via gh, groups files into features (markdown descriptions), computes a suggested review sequence, and launches a local HTML UI with GitHub-style stacked diffs, inline commenting (click-and-drag multi-line), realtime posting via gh, and two parallel AI narrative layers — purple ✨ "explain pills" that tell the story of the PR alongside the code, and red ⚠ "critique pills" with severity 1–5 that surface concerns and (optionally) suggested changes. Critique pills convert to inline GitHub comments on click. Triggers on: review this pr, brief pr, pr review ui, narrate pr, annotate pr, interactive pr review.
PR Brief
Launch an interactive, browser-based review workflow for a pull request.
What the user sees: a local webpage with an ordered review sequence in the sidebar, and — when a feature is clicked — every file in that feature stacked top-to-bottom (GitHub "Files Changed"-style) with syntax-highlighted diffs, per-line inline commenting, and a parallel narrative layer of AI-authored "explain pills" (purple ✨ callouts) on the right that tell the story of the change. Each saved comment is posted to GitHub immediately (realtime — no batch submit step).
Pipeline:
- Fetch PR via
gh
- Opus agent groups every changed file into a feature, writes a markdown description, suggests a review order, and authors the narrative explain-pill set (1–3 markdown callouts per non-trivial file, planned together as one connected story across the sequence)
- Build
data.json (PR meta + features + sequence + per-file unified diff with pre-computed GitHub position values + resolved explain-pill positions)
- Copy the HTML + Python server templates to a per-PR output dir
- Launch the server; open the browser
- The user reviews, leaves inline comments, each save POSTs immediately via
gh api
Agent assumptions (applies to every agent and subagent)
- All tools are functional and will work without error. Do not test tools or make exploratory calls.
- Only call a tool when required for the task. Every call needs a clear purpose.
- Never modify the target repo's working tree. Never commit, never push.
- All generated artifacts live under
~/.claude/pr-review/pr-<num>/ — never inside the repo being reviewed.
- Always use opus for subagents in this skill. Do not use haiku or sonnet; opus makes fewer mistakes on diff analysis and feature grouping, which is load-bearing.
Inputs
The user passes a PR identifier. Accept any of:
123 — PR number, current repo (resolve via gh repo view --json nameWithOwner -q .nameWithOwner)
owner/repo#123
https://github.com/owner/repo/pull/123
If absent, use the current branch's open PR: gh pr view --json number,headRepository.
Steps
1. Preflight
gh auth status || echo "NOT_AUTHED"
If not authenticated, stop and tell the user: "Run gh auth login in a terminal, then re-invoke this skill."
mkdir -p ~/.claude/pr-review/pr-<num>
2. Fetch PR
gh pr view <num> --repo <owner>/<repo> \
--json number,title,body,author,headRefOid,headRefName,baseRefName,url,additions,deletions,changedFiles \
> ~/.claude/pr-review/pr-<num>/pr.json
gh pr diff <num> --repo <owner>/<repo> > ~/.claude/pr-review/pr-<num>/pr.diff
gh pr diff <num> --repo <owner>/<repo> --name-only > ~/.claude/pr-review/pr-<num>/files.txt
Capture headRefOid — this is the sha you'll pass to the server (every comment posted needs it).
3. Feature discovery + sequencing (opus agent)
Launch one opus agent with:
- The file list (
files.txt)
- The full PR diff (
pr.diff) if <500 KB, else the first 1500 lines
- PR title and body (author intent)
- Output path:
~/.claude/pr-review/pr-<num>/features.json
Instruct the agent to:
-
Group every file into a single feature bucket. A feature is a kebab-case change theme (1-3 words): ownership-guards, asgi-middleware, group-conversations, migrations, documentation, etc. Group by change theme, not directory. A file with multiple themes joins them with + (e.g. sse-stability + inbox-ux).
-
For each feature, produce:
tldr — one-line plain-text what + why (≤120 chars; no markdown, the UI renders this as plain text)
full_description — MARKDOWN-formatted, 2-4 short paragraphs or a bulleted list. The frontend renders this through marked (GitHub-flavored markdown). Use:
**bold** for key terms (file paths, function names, important nouns)
- item bullet points for lists of changes (preferred over long sentences when there are 3+ discrete points)
`inline code` for symbols, file paths, flags
- Short paragraphs — no walls of text
- Do NOT use h1/h2 headings (the description already lives under one) — h3 is OK if needed
blast_radius — high (schema/auth/migrations/breaking), medium (behavioral/endpoint changes/SSE), or low (docs/tests/refactors/lockfiles)
why_first — one-line plain-text rationale for sequence position
-
Order features into a sequence list. Heuristic:
- Foundation first: migrations, schemas, core modules, shared utilities, types
- Domain/services next: business logic, repositories
- Entry points: controllers, API endpoints, middleware
- UI: components, stores, pages
- Last: docs, tests, build/ci
- Within each layer: higher blast_radius first
-
Emit edges — pairs ["fromFeature", "toFeature"] for "understanding A helps read B". Examples: ["migrations","ai-enabled-flag"], ["ownership-guards","controllers-using-ownership"]. ~5-12 edges total.
-
Per-file briefs (required for non-trivial files). For every file, produce:
tldr — one-line plain-text what + why for this file specifically (≤120 chars; no markdown). Distinct from the feature-level tldr: that one summarizes the whole feature; this one summarizes the file's role within it. Example feature tldr: "Schema updates for group conversations". Example file tldr for V0079_*.py: "Adds is_group and group_jid columns to conversation_assignments".
description — markdown, 1-3 short sentences or a small bullet list. Mini-PR-description for the file: what changed + why this file in particular needs to change. Use `inline code` for symbols, **bold** for the key noun. No headings. The UI renders this through marked and shows it in a header card directly above each file's diff (so a reviewer sees the file's purpose before scanning the diff itself).
Skip both fields (or set to empty strings) only for: lockfiles, pure renames with no content changes, deleted-file shells, or auto-generated artifacts. For these, the feature-level brief is sufficient.
Per-file tldr + description are distinct from explain pills: the brief sits at the top of the file as a header; pills annotate specific line ranges inline. The brief answers "why am I about to look at this file?" — pills answer "why is this block written this way?".
-
Explain pills — the narrative layer. This is the most important output of the agent after the feature grouping. Explain pills are AI-authored callouts that tell the story of the PR. They render as floating purple ✨ boxes next to the diff, and a reviewer reading them in sequence order should leave understanding the whole change, not just isolated snippets.
Treat the pill set as one connected narrative arc, not a pile of independent annotations.
Step 6a — Plan the narrative (do this BEFORE writing any pill).
Walk through the sequence you produced. For each feature, ask:
- What is this feature introducing into the codebase? (a new column, a new dependency, a new contract, a refactor, a fix)
- What does the next feature in the sequence build on top of it?
- For each file in this feature: what role does it play? (defines / publishes / consumes / wires-up / tests / migrates)
- Where is the seam between files — where does feature A's output become feature B's input?
Hold this map in mind while writing pills. Pills should reinforce that map, not restate the diff.
Step 6b — Per file, produce 1–3 pills.
- Skip entirely (no pills): lockfiles, simple doc-only changes, single-line tweaks, pure renames, deleted-file shells.
- Pick line ranges that map to a coherent unit: a function body, a key
if/try block, a config dict, a SQL statement, a state machine. Avoid trivial 1-line spans unless that one line is the point.
Step 6c — Each pill must do at least three of these four jobs.
- What the block does — in plain language, not a re-read of the code.
- Why this approach: name the constraint, the prior failure, the tradeoff, the security/performance concern, the upstream contract that forced it.
- Where it connects — name a specific call site, consumer, or producer by
path:symbol. Examples: "Called by inbox_service.get_conversations", "Reads the ai_enabled column written in V0077_*.py", "Consumed by the frontend store at src/stores/inbox.ts:streamInbox()". This is the connective tissue that makes pills feel like a story.
- How it fits the feature — one short clause tying back to the feature's
tldr. Examples: "This is the producer side of the group-conversations seam", "Closes the SSE retry loop the middleware enabled".
Step 6d — Voice and form.
- Markdown body. 2–4 short sentences or a small bullet list. Never a wall of text.
- Use
`inline code` for symbols, files, flags, env vars.
- Use
**bold** for the key noun (the function, the column, the concept).
- First sentence should hook a reviewer reading in sequence: assume they've read previous pills.
- No fluff ("This code…", "Here we…"). Lead with the verb or the noun.
- Title: 4–8 words. Concrete and specific. Not "Migration code" — yes "Idempotent V0077 backfill".
Step 6e — Connect across the sequence.
- The first pill in a feature should briefly ground the reader: this feature's job in one clause.
- Mid-feature pills should reference earlier features by name when relevant ("uses the ownership dep added in
ownership-guards").
- The last pill in a feature should hand off forward when something downstream depends on it ("set up here, consumed by
inbox-controller next").
- Don't force these — only when the connection is real.
Fields:
title — 4–8 words, plain text.
body — markdown per Step 6d.
start_line — first line number of the range, in the new file (post-change line numbers from the diff).
end_line — last line number of the range (== start_line for single-line).
side — "RIGHT" for new/added/context lines (default); "LEFT" only when the explanation is specifically about removed code.
- Do NOT compute
position here — build_data.py resolves it from (start_line, end_line, side) against the parsed diff.
Anti-patterns to avoid:
- ❌ Restating the diff: "Adds an if check that returns 403."
- ❌ Generic description: "This function handles authorization."
- ❌ No connection: a pill that could be in any PR — make it this PR's pill.
- ❌ Long prose. If it doesn't fit in 4 sentences, it's the wrong unit.
Examples of good pills (showing connectivity):
{
"title": "ai_enabled column lands first",
"body": "Adds the **`ai_enabled`** boolean to `conversation_assignments`. This is the schema seam the rest of `ai-enabled-flag` builds on — `session_management.py` reads it via Redis cache (next pill), and the controller flips it through `PATCH /inbox/.../ai`.\n\nIdempotent because Cloud Run may rerun the migration on cold start."
}
{
"title": "Cache layer in front of Postgres",
"body": "Reads **`ai_enabled`** from Redis with a TTL fallback to the column added in the prior migration. Cuts the per-message DB hit on the hot WhatsApp path.\n\nWritten by the inbox controller's toggle endpoint; cache key is `ai:{phone_group_key}` to share state across group sessions."
}
{
"title": "Pure ASGI to unblock SSE",
"body": "Switched from Starlette's `BaseHTTPMiddleware` to raw ASGI because the former buffers `StreamingResponse` bodies through an asyncio queue.\n\n**Why now:** `inbox_service.stream_conversations` (next file) emits SSE — the buffer would have collapsed all events into a single chunk, which is the bug `sse-stability` fixes."
}
-
Critique pills — the AI review layer. This is the second narrative output. Where explain pills tell what the code does and why, critique pills tell what's wrong with it and how to fix it. They render as red ⚠ callouts in the same right-side track as explain pills, with a severity badge S1–S5. A user can click "Use as comment →" on any critique pill to open the inline editor pre-filled with the critique text (and a GitHub suggestion block when suggested_change is set), then edit and post.
Calibration is everything. The severity scale only works if it's used honestly:
- S1 (nit) — style, naming, minor readability. Reviewer might mention or might let pass.
- S2 (suggestion) — a cleaner approach exists, not a bug. Worth raising.
- S3 (issue) — real concern: subtle bug, missing edge case, technical debt that will bite. Reviewer would flag.
- S4 (problem) — a bug, security/perf concern, broken contract. Should block until addressed.
- S5 (blocker) — must-fix. Production-impacting bug, data-loss risk, security hole, breaks an API contract.
If everything is S5, nothing is. If everything is S1, you've added noise. Distribute honestly. A typical 50-file PR might have 0 S5s, 1–3 S4s, 5–10 S3s, more S1–S2s. Skip critique entirely on files that are genuinely fine — forced critiques are worse than no critiques.
Per file: 0 to N critiques (no upper bound, but be selective). Pick line ranges anchored to the actual problematic code. Same line-range rules as explain pills: coherent unit, avoid trivial 1-line spans unless that one line is the issue.
Fields:
title — 4–8 words, plain text. Concrete. Not "Issue here" — yes "Race in cache invalidation".
body — markdown. 1–4 short sentences. Lead with the issue, then the consequence, then a hint toward the fix.
severity — integer 1–5.
start_line / end_line / side — same semantics as explain pills.
suggested_change — OPTIONAL raw text replacement for the line range. When the user clicks "Use as comment →", this gets wrapped in GitHub's ```suggestion block so submitting renders the inline accept-suggestion UI. If you can't propose a clean replacement, omit the field.
Voice:
- Direct, technical. No hedging. "The lock is released before the write completes" beats "I think there might be an issue with locking".
- Don't moralize. State the concern, point to the fix.
- Cite the cause: "race with
cache.invalidate because…", "fails if user_id is null because…".
Anti-patterns to avoid:
- ❌ Severity inflation. If everything is S4–S5, the review is unusable.
- ❌ Stylistic "you should use map() instead of forEach" without rationale. Drop unless there's a real perf/clarity gain.
- ❌ Critiques on lines outside the diff. Only critique what's actually changed.
- ❌ Vague critiques: "could be cleaner". Either name the cleanup or skip the pill.
- ❌ Critiques that restate explain-pill content. Critiques are about problems; explanations are about understanding.
Example of a good critique:
{
"title": "Race in cache invalidation",
"body": "**`session_management.py`** clears the cache *before* `db.commit()`. A concurrent reader between the clear and the commit will repopulate stale data from the old transaction snapshot, defeating the invalidation.\n\nMove the `cache.delete()` call after the commit, or use a write-through cache.",
"severity": 4,
"start_line": 142,
"end_line": 147,
"side": "RIGHT",
"suggested_change": " db.commit()\n cache.delete(f\"ai:{phone_group_key}\")"
}
Output: Write ~/.claude/pr-review/pr-<num>/features.json matching exactly this schema:
{
"features": {
"<feature-name>": {
"tldr": "Plain-text one-liner",
"full_description": "**Markdown** with `code` and:\n- bullet one\n- bullet two\n\nA closing paragraph.",
"blast_radius": "high|medium|low",
"why_first": "Plain-text rationale",
"files": [
{
"path": "path/to/file.ext",
"tldr": "Plain-text one-liner about THIS file",
"description": "**Markdown** mini-brief. 1-3 short sentences or a small bullet list — what changed in this file and why it had to.",
"explanations": [
{
"title": "Short title (plain text, 4-8 words)",
"body": "**Markdown** body. 1-3 short sentences or a small bullet list explaining *why* this code is here.",
"start_line": 42,
"end_line": 56,
"side": "RIGHT"
}
],
"critiques": [
{
"title": "Short critique title (plain text, 4-8 words)",
"body": "**Markdown** body. 1-4 short sentences: the concern, the consequence, the fix direction.",
"severity": 4,
"start_line": 142,
"end_line": 147,
"side": "RIGHT",
"suggested_change": "Optional raw text replacement for the line range"
}
]
}
]
}
},
"sequence": ["<feature-name>", ...],
"edges": [["<from>", "<to>"], ...]
}
Hard rules:
- Every path in
files.txt appears in exactly one feature. No duplicates, no orphans.
sequence covers every feature.
full_description is markdown; tldr and why_first are plain text.
- Valid JSON (parseable by
json.load).
4. Build data.json
This step combines pr.json, features.json, and pr.diff into the single file the HTML reads. Do this yourself (not via agent) — it's mechanical. Use a small Python script with stdlib only:
For each file, extract its unified diff from pr.diff (split on ^diff --git a/). Parse the diff into an array of line objects with pre-computed GitHub position values.
Position rules (load-bearing — GitHub's API is strict):
position is 1-indexed. The first line after the file's header (i.e. the first @@ hunk header) is position 1.
- Every subsequent line (hunk headers, context,
+, -) increments position by 1.
- Position resets when a new file's diff starts.
\ No newline at end of file markers do NOT increment position; skip them.
Per-line objects:
{ "position": 1, "type": "hunk", "content": "@@ -1,5 +1,10 @@" }
{ "position": 2, "type": "context", "content": " foo", "old_line": 1, "new_line": 1 }
{ "position": 3, "type": "add", "content": "+bar", "new_line": 2 }
{ "position": 4, "type": "del", "content": "-baz", "old_line": 2 }
Track old_line / new_line per file: context increments both; + increments new only; - increments old only. Hunk header resets both via @@ -A,B +C,D @@.
Binary / rename-only files: lines: [].
Resolve explanations and critiques: for each entry in a file's explanations array AND each entry in its critiques array, look up its start_position and end_position from the parsed lines:
- For
side: "RIGHT": find the line where (type == "context" or type == "add") and new_line == target_line — its position is what you want.
- For
side: "LEFT": find the line where (type == "context" or type == "del") and old_line == target_line.
- If a target line isn't found in the diff (e.g. agent picked an out-of-diff line), drop that entry with a warning.
- Pass the augmented
explanations array through to the file entry in data.json.
- Pass the augmented
critiques array through to the file entry in data.json (preserve severity and suggested_change as-is).
- Pass the file-level
tldr and description (from features.json) straight through to the file entry in data.json — the frontend renders them as a header card above each file's diff.
Final data.json shape:
{
"pr": {
"number": 969,
"title": "...",
"url": "https://github.com/.../pull/969",
"head_sha": "0468e2ed...",
"base": "main",
"author": "alice",
"additions": 7220,
"deletions": 120
},
"sequence": ["migrations", "ownership-guards", ...],
"edges": [["migrations", "ownership-guards"], ...],
"features": {
"migrations": {
"tldr": "Schema updates for group conversations and ai_enabled column",
"full_description": "Adds two new columns:\n- **`is_group`** ...\n- **`group_jid`** ...",
"blast_radius": "high",
"why_first": "Schema lands before code that reads the new columns.",
"files": [
{
"path": "backend-python/src/migrations/versions/V0079_....py",
"tldr": "Adds `is_group` and `group_jid` columns to `conversation_assignments`.",
"description": "Schema migration that introduces the two columns the rest of `group-conversations` depends on. Idempotent via `migration_tracking` so a Cloud Run cold start can replay it safely.",
"additions": 45,
"deletions": 2,
"lines": [ ... ],
"explanations": [
{
"title": "Idempotent migration guard",
"body": "Checks `migration_tracking` before writing — makes the upgrade safe to re-run on Cloud Run cold starts.",
"start_line": 22,
"end_line": 35,
"side": "RIGHT",
"start_position": 5,
"end_position": 18
}
],
"critiques": [
{
"title": "Missing rollback path",
"body": "Forward migration writes both columns but `downgrade()` only drops `is_group`. If someone reverts, `group_jid` stays orphaned and the next replay collides on the unique index.",
"severity": 3,
"start_line": 60,
"end_line": 74,
"side": "RIGHT",
"suggested_change": " op.drop_column('conversation_assignments', 'group_jid')\n op.drop_column('conversation_assignments', 'is_group')",
"start_position": 22,
"end_position": 36
}
]
}
]
}
}
}
Write to ~/.claude/pr-review/pr-<num>/data.json.
4.5. Capture the current Claude Code session id
The browser UI ships with an inline "Chat about this" feature (right-click any selected diff snippet → mini chat panel on the right). The chat backend claude --resumes this very session — so when a reviewer asks "what does this do?" inside the UI, the resumed agent already has all of the PR context that was just loaded to author the brief.
Right before launching the server, capture the active session id by finding the most-recently-modified jsonl for this project specifically (Claude Code writes to it on every turn, so the newest one in the project's own dir is this session). The session id must come from the project dir that matches cwd — claude --resume <id> looks the id up scoped to whichever project the resume runs in, so a session id from a different project will resume with "No conversation found".
Claude Code encodes the project dir as the absolute cwd with / replaced by - (so /Users/me/code/foo lives at ~/.claude/projects/-Users-me-code-foo/).
python3 - <<'PY'
import os, json, glob
home = os.path.expanduser('~')
cwd = os.getcwd()
encoded = cwd.replace('/', '-')
proj_dir = f'{home}/.claude/projects/{encoded}'
files = sorted(glob.glob(f'{proj_dir}/*.jsonl'), key=os.path.getmtime, reverse=True)
sid = os.path.basename(files[0])[:-6] if files else None
out = os.path.expanduser('~/.claude/pr-review/pr-<num>/session.json')
with open(out, 'w') as f:
json.dump({'session_id': sid, 'cwd': cwd}, f)
print('captured' if sid else f'no session found under {proj_dir} — chat will be disabled')
PY
If the capture fails (empty/missing project dir, unusual cwd-to-project-dir encoding, permissions) the rest of the skill continues to work normally; the chat panel just stays hidden in the UI.
5. Copy templates + launch
The skill ships index.html and server.py under its own templates/ directory. The skill's location depends on how it was installed — standalone (~/.claude/skills/pr-brief/) or as a plugin (~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/skills/pr-brief/). Resolve the templates directory at runtime with find, then copy the files into the PR output dir each run (so improvements to the templates propagate):
TEMPLATES_DIR=$(find ~/.claude -type d -path "*/skills/pr-brief/templates" 2>/dev/null | head -1)
[ -z "$TEMPLATES_DIR" ] && { echo "pr-brief templates not found under ~/.claude"; exit 1; }
cp "$TEMPLATES_DIR/index.html" ~/.claude/pr-review/pr-<num>/index.html
cp "$TEMPLATES_DIR/server.py" ~/.claude/pr-review/pr-<num>/server.py
Pick a free port. Default 7681; if lsof -i:7681 is busy, try 7682, 7683, ... up to 7690.
cd ~/.claude/pr-review/pr-<num>
SID=$(python3 -c "import json,os,sys; p=os.path.expanduser('~/.claude/pr-review/pr-<num>/session.json'); print(json.load(open(p)).get('session_id') or '' if os.path.exists(p) else '')")
SCWD=$(python3 -c "import json,os,sys; p=os.path.expanduser('~/.claude/pr-review/pr-<num>/session.json'); print(json.load(open(p)).get('cwd') or '' if os.path.exists(p) else '')")
python3 server.py --port <port> --pr <num> --repo <owner>/<repo> --sha <head_sha> \
${SID:+--session-id "$SID"} ${SCWD:+--session-cwd "$SCWD"} &
Background the server, then open the browser:
open "http://localhost:<port>"
6. Report
Output a compact summary to the terminal:
🟢 Review UI ready → http://localhost:7681
Suggested order (N features, M files):
1. migrations (6 files, high blast) — schema lands first
2. ownership-guards (13 files, high blast) — dep for controller changes
3. ...
In the UI:
• Click a feature → all its files render stacked, scroll through them top-to-bottom
• Click "+" in the gutter → inline editor; click-and-drag the "+" or shift-click for multi-line
• Save → posted to GitHub immediately (realtime); the badge flips Pending → Posted ✓ with link
• Purple ✨ pills (right side) explain the code; red ⚠ S1–S5 pills critique it
• "Use as comment →" on any critique pill → opens the editor pre-filled with the critique + GitHub suggestion block; edit and save like any other comment
• "Viewed" checkbox per file (sticky, persists per PR)
• If a post fails (network/auth), the comment stays local; "Retry N unposted" in the sidebar resends
Stop the server: lsof -ti:<port> | xargs kill
UI behavior baked into the templates
These behaviors are part of the bundled index.html and server.py. Do not regress when modifying templates:
- Stacked files (no tabs). When a feature is selected, every file in it is rendered top-to-bottom with its own collapsible header. Scroll through them in order.
- Collapsible feature summary. The top-of-viewer summary card (tldr, full description, why_first, file count) has a "▼ Hide summary" button in its top-right corner. Click to collapse the card down to just the title row so the diff below gets the full vertical space; click "▶ Show summary" to expand. Collapsed state persists per
(repo, PR) in localStorage (pr-brief-summary-collapsed-<repo>-<num>). The expanded description card is also independently resizable: it caps at max-height: 220px with overflow: auto; resize: vertical; so the user can drag its bottom-right grip to fine-tune without fully collapsing.
- Sticky sidebar buttons. The Submit/Clear/Publish-briefs row is pinned at the bottom of the sidebar regardless of how long the feature list grows. (Flex
flex: 1; min-height: 0; overflow-y: auto on .feature-list; flex-shrink: 0 on .pending-box.)
- Per-file "Viewed" checkbox. Persisted in
localStorage keyed by pr-brief-viewed-<repo>-<num>. Marking a file viewed dims and collapses it.
- GitHub-style syntax highlighting.
highlight.js 11.9 with the github-dark stylesheet. Line prefix ( / + / -) is colored separately so add/del row tints stay correct.
- Markdown rendering of
full_description. Frontend uses marked (CDN). Bullet points, bold, inline code, and short paragraphs render as expected. The agent producing features.json MUST emit markdown for this field.
- Explain pills (AI narrative layer). Each file's diff has a 400px right-side track. For every entry in
file.explanations, a purple "✨" callout floats anchored to the start row of its range (data-start-position), measured via getBoundingClientRect() against the track. Pills auto-stack (sorted by start position) to avoid overlap. After layout, track.style.minHeight is set to lastBottom + 16px so pills near EOF are not clipped. Pills are collapsible (toggle button), the body is markdown-rendered through marked and resizable (CSS resize: vertical). Rows in the explanation's range get a left-border accent (box-shadow: inset 3px 0 0 #bb80ff). Pill content is narrative-driven — see step 6 of the agent task for the storytelling rules.
- Critique pills (AI review layer). Each entry in
file.critiques renders as a red "⚠" callout in the same right-side track as explain pills (sorted/stacked together by start position). The pill header includes a severity badge S1–S5 with a yellow→red color gradient (S1 amber, S5 deep crimson). Body is markdown. If the critique includes suggested_change, the pill shows a green-bordered "Suggested change" code block underneath. A "Use as comment →" button at the bottom opens the inline comment editor at the critique's line range, pre-filled with **title**\n\nbody\n\n```suggestion ... ```` (the GitHub suggestion block is included only when suggested_change is set). Rows in a critique's range get a red gutter accent; rows that are in BOTH an explain and critique range get a layered purple+red accent. The track is hidden (data-no-pills="1"`) only when a file has neither explanations nor critiques.
- Inline comment editor (no modal). Hover a line → blue
+ in the gutter → click expands an editor row directly below. Cmd/Ctrl+Enter saves, Esc cancels.
- Multi-line comments — three entry points:
- Click-and-drag from the gutter
+ across lines (GitHub-native UX). Live blue band highlights the range.
- Shift-click a second
+ after a previous click.
- Dropdown in the editor header: "Single line / From L42 / From L41 / ..." (last 50 commentable lines).
- Posting modes — Realtime / Batch (toggle in sidebar). Default is Realtime: each Save POSTs
/api/post-comment immediately, server shells gh api repos/.../pulls/<num>/comments. Switching to Batch makes Save just queue locally; clicking the sidebar submit button POSTs /api/submit-review once with all queued comments (single API call, sidesteps GitHub's secondary rate limit). Mode persists in localStorage per PR (pr-brief-mode-<repo>-<num>).
- Server-side throttle. All write endpoints (
/api/post-comment, /api/submit-review, /api/post-briefs) gate behind a 1.5s minimum gap (per GitHub's "≥1s between writes" guidance) via a single threading lock — even with concurrent saves, the actual gh calls are serialized.
- Secondary rate-limit detection. If
gh returns "secondary rate limit" output, the server replies HTTP 429 with {ok:false, rate_limited:true, retry_after_seconds:60, hint}. The frontend detects this, shows a "Rate limited — switching to Batch" toast, and auto-flips MODE to batch.
- Submit button. Disabled when nothing is unposted. Label depends on MODE: Realtime →
Retry N unposted / All posted; Batch → Submit N as one review / All posted.
- Inline "Chat about this". Select any text inside a diff, right-click → "Chat about
<path>:<start>-<end>" → a chat panel pops on the right (440px drawer). Each panel is a multi-turn conversation about that snippet; the panel's first message includes a short Markdown preamble naming the file + line range + the selected code, follow-ups send the user's text verbatim. All panels — across files, across reloads — share the same Claude Code session via claude --resume (the session id captured in step 4.5). That session is the one that authored this PR's brief, so it already has the diff and feature plan in context. The drawer is hidden entirely when /api/chat-context reports enabled: false (no session id captured) — the right-click menu never appears, so the feature gracefully no-ops on standalone installs that haven't run step 4.5.
- Chat streaming. Each chat turn is a
POST /api/chat whose response body is line-delimited JSON (stream-json format from claude --output-format=stream-json --verbose). The browser uses fetch + ReadableStream to parse events as they arrive: assistant events append text deltas to the current bubble; tool_use events render as a gray "✱ ran X" chip; error events surface inline. A "Stop" button while streaming POSTs /api/chat-cancel which SIGTERMs the in-flight claude subprocess. Only one chat turn can be in flight at a time (a server-side lock serializes calls); the Send button is disabled until the previous stream finishes.
Server endpoints (server.py)
Stdlib-only Python http.server:
GET / and /index.html → static
GET /data.json → static
GET /api/context → {pr, repo, sha} for the UI
POST /api/auth-status → runs gh auth status, returns {ok, message}
POST /api/post-comment (realtime path) → body {path, body, line, side, start_line?, start_side?} → throttle 1.5s → gh api repos/<repo>/pulls/<pr>/comments → returns {ok, url, id}. On secondary rate limit returns 429 with {ok:false, rate_limited:true, retry_after_seconds, hint}.
POST /api/submit-review (batch path) → body {comments: [...], summary} → throttle 1.5s → gh api repos/<repo>/pulls/<pr>/reviews (event=COMMENT) → returns {ok, url, id, count}. Used by Batch-mode submit and "Publish briefs". Same 429 contract on rate limit.
POST /api/post-briefs → posts feature briefs as position: 1 comments per file via the reviews endpoint
GET /api/chat-context → {enabled: bool, session_id: str|null} (UI uses this on boot to decide whether to wire the right-click menu)
POST /api/chat (streaming, inline chat backend) → body {message: string, snippet?: {path, start_line, end_line, side, code}}. Spawns claude --output-format=stream-json --verbose --dangerously-skip-permissions --resume <session_id> -p <body> with the snippet preamble injected when present. Response is line-delimited JSON (one stream-json event per line), Content-Type: application/x-ndjson, Connection: close. Serialized via a chat lock — concurrent requests get HTTP 409. Returns the special {type: "chat_done", code: N} event when the subprocess exits.
POST /api/chat-cancel → SIGTERMs the in-flight chat subprocess (if any)
All other POST endpoints expect/emit JSON.
Rules
- Never commit. Never push. Never modify the target repo's working tree. All artifacts go under
~/.claude/pr-review/pr-<num>/.
- Never add comment headers to source files in the repo. The UI handles briefing — do not touch the code.
- Use opus for the feature-discovery agent. Diff grouping is load-bearing.
- Every changed file must land in exactly one feature. No orphans.
full_description must be markdown. tldr and why_first are plain text.
- Position counting is per-file and starts at 1. Off-by-one = GitHub rejects the comment.
- Lockfiles (
uv.lock, package-lock.json, yarn.lock, poetry.lock, Pipfile.lock) collapse into a build or deps feature; tldr = "Auto-generated lockfile update — no manual review needed", lines: [].
- Critiques are optional, calibration is mandatory.
critiques may be an empty array. Severity inflation (everything is S4–S5) makes the review unusable; if you're tempted to mark everything blocker, downgrade. A clean file gets zero critiques, not a forced S1.
- Large PRs (>200 files): if the opus agent's response is too big, fall back to directory-based grouping (top-level dir = feature) and note this in the report.
- Port conflict: try 7681–7690; if all busy, tell the user.
- Force-push mid-session:
gh api returns 422 with stale SHA — tell the user to re-run the skill.
- Always overwrite the templates on launch (resolve
TEMPLATES_DIR via find ~/.claude -type d -path "*/skills/pr-brief/templates", then cp "$TEMPLATES_DIR"/* …) so future template improvements propagate to existing per-PR dirs.
Notes
- Output dir
~/.claude/pr-review/pr-<num>/ lets multiple PRs coexist.
- Pending comments and Viewed flags survive a browser reload (
localStorage keyed per PR).
gh CLI inherits the user's system auth — server just shells out, no token in the HTML.
- Frontend deps from CDN:
highlight.js + github-dark (syntax), marked (description / explanation markdown). No build step.