SLOPSHOPPER

vise-skills

Skills for Claude Code development workflows.

newpanebandguardcommandtoast
v0.8.0MITupdated 2026-10-16afinzel/vise-tools/plugins/vise-skills
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · vise-skills
│ ┃ Review comments ✕ › fix the failing auth test and add an audit log call │ ┃ No PR for this branch. Waiting for Claude to │ ┃ load one, or run /review-comments <PR number ⏺ Read(src/auth.ts) │ ┃ or URL>. ⎿ 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 │ │ › /review-sidebar │ ⎿ vise-skills: Review sidebar opened. Run /review-comments to load │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Review comments
No PR for this branch. Waiting for Claude to load one, or run /review-comments <PR number or URL>.
Pane · shutdown
Ticked sessions write a handoff at their next clean point, then reply here. [ All ] [ None ] [ Refresh ]
Pane · restore-sessions
Run /restore-sessions to look for sessions.
Pane · ext-review
this branch (no PR: posting is off) 0 findings [ Tick all ]
Pane · self-review
this branch (no PR: posting is off) 0 findings [ Tick all ]
README

vise-skills

Skills for Claude Code development workflows.

Installation

claude plugin install vise-skills@vise-tools

Skills

handoff

Compacts the current conversation into a handoff document (written to the OS temp dir) so a fresh agent can pick up the work. Pass a description of what the next session will focus on to tailor the doc.

review-comments

Works through the unresolved review comments on a GitHub PR. Auto-detects the current branch's PR (or pass a PR number or URL), and triages each thread into ✅ trivial, 💬 discuss and ⚠️ push back, with an S/M/L scope tag and a behaviour-change flag. The trivial fixes that don't change behaviour go in one batch; the rest are stepped through one at a time — code, the reviewer's words raw, Claude's suggestion — and nothing changes until you decide. On a go-ahead it makes the change, replies on the thread, and resolves it.

With the review sidebar (below) the walk happens in a pane; without it, in chat, plus the Claude Review panel in VS Code. The mod reports whether the sidebar is actually visible (the VS Code extension's chat panel draws no panes), so the skill picks the route from that. In chat and VS Code the walk can be narrated, using the tour skill's voice.

thanks

An end-of-session close-out check. Run /thanks before closing a chat and it sweeps for anything left dangling — interrupted or abandoned work, things still running (background tasks, servers, crons, /loops, worktrees), uncommitted or unpushed repo state and temporary edits meant to be reverted, unanswered questions, promised loose ends, un-opened PRs, and reasoning worth persisting to memory. It's read-only: it reports, ranks findings by what's actually lost if the tab closes, asks you to acknowledge, and never fixes anything itself. Clean checks stay silent. Fires only when invoked by name.

shutdown

Run /shutdown before a restart, or before closing every Claude window. It opens a pane listing this session and every other Claude session on this machine, all ticked; untick any to leave out (All / None), and Send. Each ticked session gets a message: at your next clean stopping point, run handoff and reply with its path. This session runs its own handoff too. Nobody commits, pushes or kills anything to tidy up. Rows tick off as replies arrive, a band above the prompt counts them, and a toast says when it's safe to restart. Tell any session to ignore the message and it will.

Reach is per machine. Sessions on other machines show up only with Remote Control connected, and cloud sessions are left out because they survive a local restart and can't reply.

restore-sessions

Run /restore-sessions after a restart, planned or not. It finds the sessions active in a recent window (24h by default; pass 12h, 3d) from their JSONL transcripts, pulls the last few messages of each with find_sessions.py, and has Haiku judge which were mid-work and which had finished. A pane lists them mid-work first and pre-ticked, with any handoff document that names them; Reopen opens each in its own Windows Terminal tab with claude --resume. A session whose folder is gone is skipped and says so; where no tab can be opened, the row shows the command to run instead.

Claude Code's SessionEnd hook isn't used: a forced Windows Update restart kills the process before it fires, while the transcripts are already on disk.

Both work in chat too, where no pane is drawn (the VS Code extension's panel): the skill asks with a picker instead, and falls back further to ListAgents/SendMessage and the bundled script if the mod isn't loaded.

self-review

Run /self-review on your own branch before asking anyone else. Three reviewers go over the diff against its base:

  • the Clean Code rules in skills/self-review/clean-code-rules.md (the book's Chapter 17 smells, by their ids, plus your code-style rules as V1–V5)
  • Matt Pocock's code-review, run as it is
  • Claude Code's /code-review

Their findings land in one pane. Tick findings, then choose:

  • Fix: Claude fixes them.
  • Fix + comment: Claude fixes, commits, pushes, and posts an inline comment per fix saying what changed.
  • Add to review: puts them in a review you are building. Nothing reaches GitHub until Submit review, and pressing a queued finding takes it back out.
  • Dismiss: drops them.
  • More info: on an expanded finding, asks Claude about it.

Step through walks the open findings one at a time, as the review sidebar does. Each one shows its code and a suggested fix, with Fix, Add to review, Dismiss, More info and Next for that finding alone. Each finding is tagged 🔥 high (must fix), ⚠️ medium (should fix) or 🧹 low (tidy-up), in the pane, in chat and in posted comments alike; on GitHub a low one reads 🧹 *nit:*, and the list is ordered most severe first. Claude's /code-review gives no severity or fix of its own: the severity comes from its category, and Haiku suggests the fix the first time you open the finding. Posted comments lead with the severity and end with the suggested fix.

Every dismissed Clean Code finding counts against its rule, across sessions. A rule dismissed half the time or more, over three or more findings, is listed at the foot of the pane as one to reword or drop.

ext-review

Run /ext-review <PR> on someone else's pull request. Claude Code's /code-review reviews it, and its findings land in a pane. Tick the ones worth saying and press Add to review, then Submit review: they go out as one GitHub review under your name. A finding goes in as an inline comment where the diff shows its line; otherwise it goes in the review body. Nothing is posted until you choose.

second-mind

Lets a Claude working in any repo use the 2nd-mind knowledge vault safely. Four operations: check (search the gotcha runbooks for an already-solved failure mode before debugging — fires unprompted), ask (answer from the vault, with page citations), capture (stage a finding in the vault's gitignored inbox with a full provenance block — repo, branch, commit, transcript path), and sync (fast-forward the local clone and report pending captures).

It is capture-only: the sole writable path is knowledge/inbox/, and it never commits or pushes. That is deliberate — the vault is git-crypt encrypted, filenames and paths are not encrypted, and the codename mapping lives in a file an outside session can't read. A foreign agent writing pages directly produces unlinked duplicates at leaking paths; capturing with provenance lets a session inside the vault do the placement and linking. It also detects a locked (unkeyed) vault and says so instead of interpreting encrypted bytes.

Set SECOND_MIND_VAULT if the vault isn't at one of the default paths.

Skills are auto-discovered from the skills/ directory — add a new skills/<name>/SKILL.md to add another.

Mods

The mods register from hooks/register.tsx: the engine takes one hooks module per plugin, so each mod exports its hooks and its tools, and that module wires them in.

shutdown and restore panes

hooks/shutdown.tsx and hooks/restore-sessions.tsx are the panes behind the two skills above. The shutdown pane lists sessions with ListAgents, sends with $.session.send, and reads replies on session.receive: a reply names its sender (shutdown-reply <name>: <path>), because a delivery carries only text. While the pane shows them, replies are taken out of the conversation so they cost no turns. The restore pane runs find_sessions.py, asks Haiku through $.model.complete, and opens tabs with wt.

review findings panes

hooks/review-findings.tsx is the pane behind /self-review and /ext-review.

  • Claude's findings: it catches /code-review's findings on their way through the ReportFindings tool, while a review round is open. A re-report after fixes is skipped.
  • The other reviewers: they reach it through findings_record.
  • Posting: sends one gh api …/reviews call with the review as JSON on stdin.
  • Rule tallies: kept in the plugin's store, so they last across sessions.

review sidebar

hooks/review-comments.tsx is a mod that gives review-comments a sidebar. Running /review-comments opens a pane beside the transcript, which fills with the PR's unresolved threads: To do (grouped by bucket once Claude has triaged them), Ready to push, Skipped, and a foldable Done. A fix is made locally and waits in Ready to push; Commit & push commits and pushes them, and only then does Claude reply on and resolve each thread, citing the commit. Closed it? /review-sidebar reopens it, even while Claude is working. Click a thread to see its code, comment and suggestion, with Fix, Push back, Discuss, Skip and Next buttons (hotkeys f p d s n). A thread with several possible fixes gets one button per option instead of Fix (hotkeys 1–9, the recommended one highlighted), and shows any other code the decision depends on. Each row has a behaviour change checkbox that starts as Claude's call and is yours to flip. Fix N trivial (no behaviour change) sends the batch, and Step through opens the next thread waiting on you. A band above the prompt shows progress.

The buttons send your decision to Claude as a prompt; Claude reports back through the mod's review_triage and review_mark tools, and the sidebar re-reads GitHub after each fix so a tick means a resolved thread. It reads code from your working tree when the PR's branch is checked out, and from the PR's head commit otherwise.

Needs Claude Code 2.1.287 or later and gh signed in. The pane docks in the fullscreen terminal from 110 columns, and sits above the prompt otherwise. Tests: claude plugin test plugins/vise-skills.

Hooks

clean-code checks

hooks/clean_code_hook.py runs on every Write/Edit/MultiEdit to a source file (.cs, .ts, .tsx, .js, .jsx, .java, .go) and checks three clean-code rules:

RuleCheck
A comment is a private method waiting to be namedNon-doc comment indented inside a body
Names complete a sentence at the call siteType-prefixed (strName) and placeholder (data, temp) names
A comment carries its own contextComment referencing A1, section 3.2, a ticket key, or "see spec"

It never blocks. PreToolUse only records; PostToolUse re-reads the file that actually landed and reports back, so the fix is an edit rather than a regenerate. Everything found is appended to ~/.claude/clean-code-findings.jsonl.

Only rules listed in RULES_REPORTED_TO_CLAUDE are reported back to Claude — currently just doc-reference-comment, the one that's cleanly decidable by regex. The structural and naming heuristics log silently, so you can read the log and promote one once you trust its hit rate.

Newspaper ordering is deliberately not checked: deciding whether callers precede callees needs a real parse, and a regex that guesses would cry wolf often enough to get the whole hook ignored. That rule lives in CLAUDE.md instead.

Source 18 files
hooks/register.tsx 25 lines
1import type { Register } from 'claude-code'
2
3import { registerFindingsPanes, registerFindingsTools } from './review-findings'
4import { registerRestorePane, registerRestoreTools } from './restore-sessions'
5import { registerReviewSidebar, registerReviewSidebarCommandAndTools } from './review-comments'
6import { registerShutdownPane, registerShutdownTools } from './shutdown'
7
8/**
9 * The engine takes one hooks module per plugin, and one unmatched hook per event, so each mod's
10 * hooks register from here and their tools share the one session.start.
11 */
12export const register: Register = (on, options) => {
13  on('session.start', async ($, e, next) => {
14    await registerReviewSidebarCommandAndTools($)
15    await registerShutdownTools($)
16    await registerRestoreTools($)
17    await registerFindingsTools($)
18    return next(e)
19  })
20  registerReviewSidebar(on, options)
21  registerShutdownPane(on, options)
22  registerRestorePane(on, options)
23  registerFindingsPanes(on, options)
24}
25
hooks/review-findings.tsx 364 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { FindingSource, FindingStatus, FindingsReviewKind, FindingsRound, PullRequestRef, ReviewFinding, RuleTally } from '../types'
5import { REPOSITORY_ROOT_ARGV, fileAtHeadArgv, parsePullRequest, parsePullRequestTarget, pullRequestViewArgv } from './review-comments/github-threads'
6import {
7  bySeverity,
8  commentableLines,
9  explainPrompt,
10  listLine,
11  findingExcerpt,
12  findingsFrom,
13  fixPrompt,
14  nextOpenAfter,
15  oftenDismissedRules,
16  pullRequestDiffArgv,
17  suggestionPrompt,
18  reviewPayload,
19  reviewPostArgv,
20  tallyDismissed,
21  tallyRaised,
22  withCurrentFields,
23  withStatus,
24} from './review-findings/findings'
25import type { ReportedFinding } from './review-findings/findings'
26import { drawPane } from './review-findings/pane'
27import type { PaneActions } from './review-findings/pane'
28import { answeredOrDenied, toolArguments } from './tool-answers'
29
30const PANES: Record<FindingsReviewKind, { id: string; title: string }> = {
31  ext: { id: 'ext-review', title: 'External review' },
32  self: { id: 'self-review', title: 'Self review' },
33}
34const SOURCES_RUN: Record<FindingsReviewKind, FindingSource[]> = {
35  ext: ['claude'],
36  self: ['clean-code', 'matt', 'claude'],
37}
38const TOOL_START = 'mcp__vise-skills__findings_start'
39const TOOL_RECORD = 'mcp__vise-skills__findings_record'
40const TOOL_POST = 'mcp__vise-skills__findings_post'
41const TOOL_MARK = 'mcp__vise-skills__findings_mark'
42const STORE_RULE_TALLIES = 'cleanCodeRuleTallies'
43
44const EMPTY_ROUND: FindingsRound = { pullRequest: null, commentableLines: {}, findings: [], sourcesPending: [], expandedId: null, isPaneShown: false, error: null }
45
46const extRound = atom({ plugin: 'vise-skills', key: 'extReview' } as const, EMPTY_ROUND)
47const selfRound = atom({ plugin: 'vise-skills', key: 'selfReview' } as const, EMPTY_ROUND)
48const activeReview = atom({ plugin: 'vise-skills', key: 'activeFindingsReview' } as const, null)
49
50export const registerFindingsPanes: Register = on => {
51  on('tool.call', { tool: TOOL_START }, async ($, e) => answeredOrDenied('review findings pane', async () => {
52    const { kind, pr } = toolArguments<{ kind: FindingsReviewKind; pr?: string }>(e)
53    await startReview($, kind, pr ?? '')
54    const placement = await $.ui.open(PANES[kind])
55    await updateRound($, kind, current => ({ ...current, isPaneShown: placement.isPlaced }))
56    return { result: await roundForClaude($, kind) }
57  }))
58
59  on('tool.call', { tool: TOOL_RECORD }, async ($, e) => answeredOrDenied('review findings pane', async () => {
60    const { kind, source, findings } = toolArguments<{ kind: FindingsReviewKind; source: FindingSource; findings: ReportedFinding[] }>(e)
61    const recorded = await recordFindings($, kind, source, findings)
62    return { result: [`Recorded ${recorded.length} ${source} findings.`, ...recorded.map(listLine)].join('\n') }
63  }))
64
65  on('tool.call', { tool: TOOL_POST }, async ($, e) => answeredOrDenied('review findings pane', async () => {
66    const { kind, items } = toolArguments<{ kind: FindingsReviewKind; items: { id: string; note?: string }[] }>(e)
67    return { result: await postReview($, kind, items.map(item => item.id), Object.fromEntries(items.filter(item => item.note).map(item => [item.id, item.note!]))) }
68  }))
69
70  on('tool.call', { tool: TOOL_MARK }, async ($, e) => answeredOrDenied('review findings pane', async () => {
71    const { kind, ids, status } = toolArguments<{ kind: FindingsReviewKind; ids: string[]; status: FindingStatus }>(e)
72    await markFindings($, kind, ids, status)
73    return { result: `Marked ${ids.join(', ')} ${status}.` }
74  }))
75
76  on('tool.call', { tool: 'ReportFindings' }, async ($, e, next) => {
77    const reported = await next(e)
78    await captureClaudeReview($, e.findings as ReportedFinding[] & { outcome?: string }[])
79    return reported
80  })
81
82  on('ui.render', { component: 'Pane', requestId: 'ext-review' }, async ($, e) =>
83    drawPane($.ui.resolve(e), 'ext', await readRound($, 'ext'), oftenDismissedRules(await ruleTallies($)), paneActions($, 'ext')))
84  on('ui.render', { component: 'Pane', requestId: 'self-review' }, async ($, e) =>
85    drawPane($.ui.resolve(e), 'self', await readRound($, 'self'), oftenDismissedRules(await ruleTallies($)), paneActions($, 'self')))
86}
87
88export async function registerFindingsTools($: EngineInterface) {
89  await $.tool.register({
90    name: 'findings_start',
91    description: 'Starts an /ext-review or /self-review: loads the PR and its diff, clears the last round, and opens the findings pane. Claude\'s /code-review findings then reach the pane by themselves; record the other reviewers\' with findings_record.',
92    inputSchema: {
93      type: 'object',
94      required: ['kind'],
95      properties: {
96        kind: { enum: ['ext', 'self'] },
97        pr: { type: 'string', description: 'A PR number or URL; omit for the current branch\'s PR. Required for ext.' },
98      },
99    },
100    isDeferred: false,
101  })
102  await $.tool.register({
103    name: 'findings_record',
104    description: 'Adds one reviewer\'s findings to the findings pane. Call once per reviewer when it finishes, with an empty list when it found nothing, so the pane stops waiting on it.',
105    inputSchema: {
106      type: 'object',
107      required: ['kind', 'source', 'findings'],
108      properties: {
109        kind: { enum: ['ext', 'self'] },
110        source: { enum: ['clean-code', 'matt', 'claude'] },
111        findings: {
112          type: 'array',
113          items: {
114            type: 'object',
115            required: ['file', 'summary'],
116            properties: {
117              file: { type: 'string', description: 'Repository-relative path.' },
118              line: { type: 'number', description: 'The head-side line it anchors to.' },
119              summary: { type: 'string', description: 'One sentence: the defect.' },
120              short_summary: { type: 'string', description: 'Under 60 characters, the claim alone.' },
121              failure_scenario: { type: 'string', description: 'What goes wrong and when; for a style rule, what it costs the reader.' },
122              category: { type: 'string' },
123              rule: { type: 'string', description: 'Clean Code findings only: the rule id from clean-code-rules.md, e.g. G30.' },
124              suggested_fix: { type: 'string', description: 'The change that would settle it: at most three sentences, or a short snippet with one line on what it changes.' },
125              severity: { enum: ['high', 'medium', 'low'], description: 'high: wrong behaviour, data loss or a security hole; medium: a real cost a reviewer would ask to fix; low: style and readability.' },
126            },
127          },
128        },
129      },
130    },
131    isDeferred: false,
132  })
133  await $.tool.register({
134    name: 'findings_post',
135    description: 'Posts findings to the PR as one GitHub review under the user\'s name: inline where the diff shows the line, in the review body otherwise. A `note` leads the comment, e.g. what a fix changed. Post only what the user chose.',
136    inputSchema: {
137      type: 'object',
138      required: ['kind', 'items'],
139      properties: {
140        kind: { enum: ['ext', 'self'] },
141        items: {
142          type: 'array',
143          items: { type: 'object', required: ['id'], properties: { id: { type: 'string' }, note: { type: 'string' } } },
144        },
145      },
146    },
147    isDeferred: false,
148  })
149  await $.tool.register({
150    name: 'findings_mark',
151    description: 'Records what happened to findings: `fixed` once the change is made; `dismissed` only when the user rejects it, which counts against its Clean Code rule; `withdrawn` when you find it does not hold up, which does not.',
152    inputSchema: {
153      type: 'object',
154      required: ['kind', 'ids', 'status'],
155      properties: {
156        kind: { enum: ['ext', 'self'] },
157        ids: { type: 'array', items: { type: 'string' } },
158        status: { enum: ['fixed', 'dismissed', 'withdrawn'] },
159      },
160    },
161    isDeferred: false,
162  })
163}
164
165async function startReview($: EngineInterface, kind: FindingsReviewKind, pr: string) {
166  await update($, activeReview, () => kind)
167  await updateRound($, kind, () => ({ ...EMPTY_ROUND, sourcesPending: SOURCES_RUN[kind] }))
168  const view = await $.process.run(pullRequestViewArgv(parsePullRequestTarget(pr)), { timeoutMs: 30_000 })
169  if (view.exitCode !== 0) {
170    if (kind === 'ext') await updateRound($, kind, current => ({ ...current, error: `No PR to review: ${view.stderr.trim() || 'gh pr view failed'}` }))
171    return
172  }
173  const pullRequest = parsePullRequest(view.stdout)
174  await updateRound($, kind, current => ({ ...current, pullRequest }))
175  await loadCommentableLines($, kind, pullRequest)
176}
177
178async function loadCommentableLines($: EngineInterface, kind: FindingsReviewKind, pullRequest: PullRequestRef) {
179  const diff = await $.process.run(pullRequestDiffArgv(pullRequest), { timeoutMs: 60_000 })
180  if (diff.exitCode === 0) await updateRound($, kind, current => ({ ...current, commentableLines: commentableLines(diff.stdout) }))
181}
182
183async function recordFindings($: EngineInterface, kind: FindingsReviewKind, source: FindingSource, reported: readonly ReportedFinding[]): Promise<ReviewFinding[]> {
184  const recorded = findingsFrom(source, reported, (await readRound($, kind)).findings)
185  await updateRound($, kind, current => ({
186    ...current,
187    findings: bySeverity([...current.findings, ...recorded]),
188    sourcesPending: current.sourcesPending.filter(pending => pending !== source),
189  }))
190  await $.store.set(STORE_RULE_TALLIES, tallyRaised(await ruleTallies($), recorded))
191  return recorded
192}
193
194/** /code-review reports through ReportFindings; while a review round runs, its findings are that round's. A re-report after fixes carries outcomes and is skipped. */
195async function captureClaudeReview($: EngineInterface, reported: readonly (ReportedFinding & { outcome?: string })[]) {
196  const kind = await read($, activeReview)
197  if (kind === null || reported.some(finding => finding.outcome !== undefined)) return
198  if (!(await readRound($, kind)).sourcesPending.includes('claude')) return
199  await recordFindings($, kind, 'claude', reported)
200}
201
202function paneActions($: EngineInterface, kind: FindingsReviewKind): PaneActions {
203  const setSelected = (choose: (finding: ReviewFinding) => boolean) =>
204    void updateFindings($, kind, findings => findings.map(finding => ({ ...finding, isSelected: isActionable(finding) && choose(finding) })))
205  return {
206    toggle: id => setSelected(finding => (finding.id === id ? !finding.isSelected : finding.isSelected)),
207    expand: id => void toggleOpen($, kind, id),
208    selectAll: () => setSelected(() => true),
209    selectNone: () => setSelected(() => false),
210    queue: () => void tickedIds($, kind).then(ids => updateFindings($, kind, findings => withStatus(findings, ids, 'queued'))),
211    fix: () => void tickedIds($, kind).then(ids => submitFix($, kind, ids, { isCommenting: false })),
212    fixAndComment: () => void tickedIds($, kind).then(ids => submitFix($, kind, ids, { isCommenting: true })),
213    dismiss: () => void tickedIds($, kind).then(ids => markFindings($, kind, ids, 'dismissed')),
214    explain: id => void explain($, kind, id),
215    stepThrough: () => void openNextAfter($, kind, null),
216    next: id => void openNextAfter($, kind, id),
217    fixOne: id => void submitFix($, kind, [id], { isCommenting: false }).then(() => openNextAfter($, kind, id)),
218    queueOne: id => void updateFindings($, kind, findings => withStatus(findings, [id], 'queued')).then(() => openNextAfter($, kind, id)),
219    unqueue: id => void updateFindings($, kind, findings => withStatus(findings, [id], 'open')),
220    submitReview: () => void submitQueued($, kind),
221    dismissOne: id => void markFindings($, kind, [id], 'dismissed').then(() => openNextAfter($, kind, id)),
222  }
223}
224
225async function toggleOpen($: EngineInterface, kind: FindingsReviewKind, id: string) {
226  if ((await readRound($, kind)).expandedId === id) await updateRound($, kind, current => ({ ...current, expandedId: null }))
227  else await openFinding($, kind, id)
228}
229
230async function openNextAfter($: EngineInterface, kind: FindingsReviewKind, id: string | null) {
231  const upcoming = nextOpenAfter((await readRound($, kind)).findings, id)
232  if (upcoming === undefined) await updateRound($, kind, current => ({ ...current, expandedId: null }))
233  else await openFinding($, kind, upcoming.id)
234}
235
236async function openFinding($: EngineInterface, kind: FindingsReviewKind, id: string) {
237  await updateRound($, kind, current => ({ ...current, expandedId: id }))
238  const finding = (await readRound($, kind)).findings.find(candidate => candidate.id === id)
239  if (finding === undefined) return
240  if (finding.line === null || finding.excerpt !== null) {
241    await suggestFixIfMissing($, kind, id)
242    return
243  }
244  const fileText = await readReviewedFile($, kind, finding.file)
245  if (fileText !== null) {
246    const excerpt = findingExcerpt(fileText, finding.line)
247    await updateFinding($, kind, id, other => ({ ...other, excerpt }))
248  }
249  await suggestFixIfMissing($, kind, id)
250}
251
252/** Claude's /code-review reports no fix, so the first time such a finding is opened Haiku suggests one from the code around it. */
253async function suggestFixIfMissing($: EngineInterface, kind: FindingsReviewKind, id: string) {
254  const finding = (await readRound($, kind)).findings.find(candidate => candidate.id === id)
255  if (finding === undefined || finding.suggestedFix !== null || finding.isSuggestingFix) return
256  await updateFinding($, kind, id, other => ({ ...other, isSuggestingFix: true }))
257  const suggestedFix = await haikuSuggestion($, finding)
258  await updateFinding($, kind, id, other => ({ ...other, suggestedFix, isSuggestingFix: false }))
259}
260
261async function haikuSuggestion($: EngineInterface, finding: ReviewFinding): Promise<string | null> {
262  try {
263    const reply = await $.model.complete({ model: 'haiku', prompt: suggestionPrompt(finding) } as never)
264    return reply.isAnswered ? reply.text.trim() || null : null
265  } catch {
266    return null
267  }
268}
269
270async function updateFinding($: EngineInterface, kind: FindingsReviewKind, id: string, change: (finding: ReviewFinding) => ReviewFinding) {
271  await updateFindings($, kind, findings => findings.map(other => (other.id === id ? change(other) : other)))
272}
273
274/** Your own branch is read from the working tree, so the excerpt shows fixes as they land; someone else's from the PR head. */
275async function readReviewedFile($: EngineInterface, kind: FindingsReviewKind, path: string): Promise<string | null> {
276  try {
277    const { pullRequest } = await readRound($, kind)
278    if (kind === 'ext' && pullRequest !== null) return await stdoutOf($, fileAtHeadArgv(pullRequest, path))
279    const root = await stdoutOf($, REPOSITORY_ROOT_ARGV)
280    return root === null ? null : await $.fs.read(`${root.trim()}/${path}`)
281  } catch {
282    return null
283  }
284}
285
286async function stdoutOf($: EngineInterface, argv: string[]): Promise<string | null> {
287  const run = await $.process.run(argv, { timeoutMs: 30_000 })
288  return run.exitCode === 0 ? run.stdout : null
289}
290
291async function submitQueued($: EngineInterface, kind: FindingsReviewKind) {
292  const queuedIds = (await readRound($, kind)).findings.filter(finding => finding.status === 'queued').map(finding => finding.id)
293  $.ui.toast(await postReview($, kind, queuedIds, {}))
294}
295
296async function postReview($: EngineInterface, kind: FindingsReviewKind, ids: readonly string[], notes: Record<string, string>): Promise<string> {
297  const round = await readRound($, kind)
298  if (round.pullRequest === null) return 'Nothing posted: this branch has no PR.'
299  const chosen = round.findings.filter(finding => ids.includes(finding.id))
300  if (chosen.length === 0) return 'Nothing posted: no finding has those ids.'
301  const run = await $.process.run(reviewPostArgv(round.pullRequest), { stdin: JSON.stringify(reviewPayload(round.pullRequest, chosen, round.commentableLines, notes)), timeoutMs: 30_000 })
302  if (run.exitCode !== 0) return `GitHub refused the review: ${run.stderr.trim() || `exit ${run.exitCode}`}`
303  const reviewUrl = (JSON.parse(run.stdout) as { html_url?: string }).html_url ?? null
304  await updateFindings($, kind, findings => findings.map(finding => (ids.includes(finding.id) ? { ...finding, status: 'posted', isSelected: false, commentUrl: reviewUrl } : finding)))
305  return `Posted ${chosen.length} findings as a review${reviewUrl === null ? '' : `: ${reviewUrl}`}`
306}
307
308async function submitFix($: EngineInterface, kind: FindingsReviewKind, ids: readonly string[], options: { isCommenting: boolean }) {
309  const chosen = (await readRound($, kind)).findings.filter(finding => ids.includes(finding.id))
310  if (chosen.length === 0) return
311  await updateFindings($, kind, findings => withStatus(findings, ids, 'fixing'))
312  await $.prompt.submit({ text: fixPrompt(kind, chosen, options), asUser: true })
313}
314
315async function markFindings($: EngineInterface, kind: FindingsReviewKind, ids: readonly string[], status: FindingStatus) {
316  const marked = (await readRound($, kind)).findings.filter(finding => ids.includes(finding.id) && finding.status !== status)
317  await updateFindings($, kind, findings => withStatus(findings, ids, status))
318  if (status === 'dismissed') await $.store.set(STORE_RULE_TALLIES, tallyDismissed(await ruleTallies($), marked))
319}
320
321async function explain($: EngineInterface, kind: FindingsReviewKind, id: string) {
322  const finding = (await readRound($, kind)).findings.find(candidate => candidate.id === id)
323  if (finding !== undefined) await $.prompt.submit({ text: explainPrompt(finding), asUser: true })
324}
325
326async function tickedIds($: EngineInterface, kind: FindingsReviewKind): Promise<string[]> {
327  return (await readRound($, kind)).findings.filter(finding => finding.isSelected).map(finding => finding.id)
328}
329
330function isActionable(finding: ReviewFinding): boolean {
331  return finding.status === 'open'
332}
333
334async function ruleTallies($: EngineInterface): Promise<Record<string, RuleTally>> {
335  return ((await $.store.get(STORE_RULE_TALLIES)) as Record<string, RuleTally> | undefined) ?? {}
336}
337
338async function updateFindings($: EngineInterface, kind: FindingsReviewKind, change: (findings: ReviewFinding[]) => ReviewFinding[]) {
339  await updateRound($, kind, current => ({ ...current, findings: change(current.findings) }))
340}
341
342/** The engine lists the state a module touches by reading each atom's name at its call site, so each kind's atom is named here. */
343async function readRound($: EngineInterface, kind: FindingsReviewKind): Promise<FindingsRound> {
344  const round = kind === 'ext' ? await read($, extRound) : await read($, selfRound)
345  return { ...round, findings: round.findings.map(withCurrentFields) }
346}
347
348async function updateRound($: EngineInterface, kind: FindingsReviewKind, change: (current: FindingsRound) => FindingsRound) {
349  const changeCurrent = (current: FindingsRound) => change({ ...current, findings: current.findings.map(withCurrentFields) })
350  if (kind === 'ext') await update($, extRound, changeCurrent)
351  else await update($, selfRound, changeCurrent)
352}
353
354async function roundForClaude($: EngineInterface, kind: FindingsReviewKind): Promise<string> {
355  const round = await readRound($, kind)
356  return JSON.stringify({
357    pane: { isShown: round.isPaneShown },
358    error: round.error,
359    pullRequest: round.pullRequest,
360    waitingOn: round.sourcesPending,
361    oftenDismissedRules: kind === 'self' ? oftenDismissedRules(await ruleTallies($)) : [],
362  })
363}
364
hooks/restore-sessions.tsx 154 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ReopenOutcome, RestoreCandidate, RestoreRound } from '../types'
5import {
6  candidatesFrom,
7  findSessionsArgv,
8  hoursIn,
9  judgementPrompt,
10  parseJudgements,
11  reopenArgv,
12  resumeCommand,
13  withJudgements,
14} from './restore-sessions/candidates'
15import type { FoundSession } from './restore-sessions/candidates'
16import { drawPane } from './restore-sessions/pane'
17import type { PaneActions } from './restore-sessions/pane'
18import { answeredOrDenied, toolArguments } from './tool-answers'
19
20const PANE = 'restore-sessions'
21const PANE_TITLE = 'Restore sessions'
22const TOOL_FIND = 'mcp__vise-skills__restore_sessions'
23const TOOL_REOPEN = 'mcp__vise-skills__restore_reopen'
24const PYTHONS = ['python', 'python3']
25
26const EMPTY_ROUND: RestoreRound = { hours: 24, phase: 'idle', candidates: [], judgementProblem: null, error: null, isPaneShown: false }
27
28const round = atom({ plugin: 'vise-skills', key: 'restore' } as const, EMPTY_ROUND)
29
30export const registerRestorePane: Register = on => {
31  on('tool.call', { tool: TOOL_FIND }, async ($, e) => answeredOrDenied('restore pane', async () => {
32    const { window } = toolArguments<{ window?: string }>(e)
33    await update($, round, () => ({ ...EMPTY_ROUND, hours: hoursIn(window), phase: 'finding' }))
34    const placement = await $.ui.open({ id: PANE, title: PANE_TITLE })
35    await update($, round, current => ({ ...current, isPaneShown: placement.isPlaced }))
36    await findAndJudge($)
37    return { result: candidatesForClaude(await read($, round)) }
38  }))
39
40  on('tool.call', { tool: TOOL_REOPEN }, async ($, e) => answeredOrDenied('restore pane', async () => {
41    const { ids } = toolArguments<{ ids: string[] }>(e)
42    await updateCandidates($, candidates => candidates.map(candidate => ({ ...candidate, isSelected: ids.includes(candidate.id) })))
43    await reopenSelected($)
44    return { result: reopenReport((await read($, round)).candidates.filter(candidate => ids.includes(candidate.id))) }
45  }))
46
47  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => drawPane($.ui.resolve(e), await read($, round), paneActions($)))
48}
49
50export async function registerRestoreTools($: EngineInterface) {
51  await $.tool.register({
52    name: 'restore_sessions',
53    description: 'Finds the Claude sessions active in a recent window from their transcripts, has Haiku judge which were mid-work, and opens the restore pane where the user ticks which to reopen. Says whether the pane is shown; when it is, the user reopens from there.',
54    inputSchema: {
55      type: 'object',
56      properties: { window: { type: 'string', description: 'How far back to look: `12h`, `3d`, or hours as a number. Default 24h.' } },
57    },
58    isDeferred: false,
59  })
60  await $.tool.register({
61    name: 'restore_reopen',
62    description: 'Reopens the given sessions, by the ids restore_sessions returned, each in a new Windows Terminal tab with `claude --resume`. Use only when the restore pane is not shown and the user picked in chat.',
63    inputSchema: {
64      type: 'object',
65      required: ['ids'],
66      properties: { ids: { type: 'array', items: { type: 'string' } } },
67    },
68    isDeferred: false,
69  })
70}
71
72async function findAndJudge($: EngineInterface) {
73  const found = await findSessions($, (await read($, round)).hours)
74  if (found === null) return
75  await update($, round, (current): RestoreRound => ({ ...current, phase: found.length === 0 ? 'ready' : 'judging', candidates: candidatesFrom(found) }))
76  if (found.length > 0) await judge($, found)
77}
78
79async function findSessions($: EngineInterface, hours: number): Promise<FoundSession[] | null> {
80  const scriptPath = `${$.plugin.root}/skills/restore-sessions/find_sessions.py`
81  const ownSessionId = await $.session.id()
82  let failure = 'no Python on the path'
83  for (const python of PYTHONS) {
84    try {
85      const run = await $.process.run(findSessionsArgv(python, scriptPath, hours, ownSessionId), { timeoutMs: 60_000 })
86      if (run.exitCode === 0) return JSON.parse(run.stdout) as FoundSession[]
87      failure = run.stderr.trim() || `${python} exited ${run.exitCode}`
88    } catch (error) {
89      failure = error instanceof Error ? error.message : String(error)
90    }
91  }
92  await update($, round, (current): RestoreRound => ({ ...current, phase: 'ready', error: `Could not read the transcripts: ${failure}` }))
93  return null
94}
95
96async function judge($: EngineInterface, found: FoundSession[]) {
97  const reply = await $.model.complete({ model: 'haiku', prompt: judgementPrompt(found) } as never)
98  const judgements = reply.isAnswered ? parseJudgements(reply.text) : new Map()
99  const judgementProblem = !reply.isAnswered ? `Haiku did not answer (${reply.reason})` : judgements.size === 0 ? 'Haiku\'s answer named no session' : null
100  await update($, round, (current): RestoreRound => ({ ...current, phase: 'ready', judgementProblem, candidates: withJudgements(current.candidates, judgements) }))
101}
102
103function paneActions($: EngineInterface): PaneActions {
104  return {
105    toggle: id => void updateCandidates($, candidates => candidates.map(candidate => (candidate.id === id ? { ...candidate, isSelected: !candidate.isSelected } : candidate))),
106    selectRecommended: () => void updateCandidates($, candidates => candidates.map(candidate => ({ ...candidate, isSelected: candidate.verdict === 'resume' }))),
107    selectAll: () => void updateCandidates($, candidates => candidates.map(candidate => ({ ...candidate, isSelected: true }))),
108    selectNone: () => void updateCandidates($, candidates => candidates.map(candidate => ({ ...candidate, isSelected: false }))),
109    reopen: () => void reopenSelected($),
110  }
111}
112
113async function reopenSelected($: EngineInterface) {
114  const chosen = (await read($, round)).candidates.filter(candidate => candidate.isSelected && candidate.reopen === 'not-yet')
115  for (const candidate of chosen) {
116    const outcome = await reopen($, candidate)
117    await updateCandidates($, candidates => candidates.map(other => (other.id === candidate.id ? { ...other, reopen: outcome, isSelected: false } : other)))
118  }
119}
120
121async function reopen($: EngineInterface, candidate: RestoreCandidate): Promise<ReopenOutcome> {
122  if (!(await $.fs.exists(candidate.cwd))) return 'missing-folder'
123  try {
124    const run = await $.process.run(reopenArgv(candidate), { timeoutMs: 15_000 })
125    return run.exitCode === 0 ? 'reopened' : 'failed'
126  } catch {
127    return 'failed'
128  }
129}
130
131async function updateCandidates($: EngineInterface, change: (candidates: RestoreCandidate[]) => RestoreCandidate[]) {
132  await update($, round, current => ({ ...current, candidates: change(current.candidates) }))
133}
134
135function candidatesForClaude(current: RestoreRound): string {
136  return JSON.stringify({
137    pane: { isShown: current.isPaneShown },
138    hours: current.hours,
139    error: current.error,
140    judgementProblem: current.judgementProblem,
141    candidates: current.candidates.map(({ id, label, cwd, lastActive, verdict, summary, handoffPath, isPossiblyStillOpen }) => ({
142      id, label, cwd, lastActive, verdict, summary, handoffPath, isPossiblyStillOpen,
143    })),
144  })
145}
146
147function reopenReport(candidates: readonly RestoreCandidate[]): string {
148  return candidates.map(candidate => {
149    if (candidate.reopen === 'reopened') return `${candidate.label}: reopened`
150    if (candidate.reopen === 'missing-folder') return `${candidate.label}: skipped, ${candidate.cwd} is gone`
151    return `${candidate.label}: could not open a tab; run \`${resumeCommand(candidate)}\``
152  }).join('\n')
153}
154
hooks/review-comments.tsx 473 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, UiOpenResult } from 'claude-code'
3
4import type { CodeReference, PullRequestRef, ReferenceExcerpt, ReviewSession, ReviewThread, ThreadOutcome, TriageInput } from '../types'
5import {
6  CURRENT_BRANCH_ARGV,
7  REPOSITORY_ROOT_ARGV,
8  currentLineOf,
9  fileAtHeadArgv,
10  parsePullRequest,
11  parsePullRequestTarget,
12  parseThreads,
13  referenceExcerpt,
14  pullRequestViewArgv,
15  sortByLocation,
16  threadsQueryArgv,
17  toReviewThread,
18} from './review-comments/github-threads'
19import type { GraphQlThread, PullRequestTarget } from './review-comments/github-threads'
20import { decisionPrompt, discussionPrompt, pushPrompt, quickFixPrompt } from './review-comments/decision-prompts'
21import type { Decision } from './review-comments/decision-prompts'
22import { drawSidebar } from './review-comments/sidebar'
23import type { SidebarActions } from './review-comments/sidebar'
24import { mergeIntoWorkingSet } from './review-comments/working-set'
25import {
26  nextToStepThrough,
27  progressCounts,
28  quickFixesAwaitingDecision,
29  sectionsOf,
30  viewsInWalkOrder,
31} from './review-comments/walk-order'
32import type { ThreadView } from './review-comments/walk-order'
33import { answeredOrDenied, toolArguments } from './tool-answers'
34
35const PANE = 'review-comments'
36const PANE_TITLE = 'Review comments'
37const TOOL_THREADS = 'mcp__vise-skills__review_threads'
38const TOOL_TRIAGE = 'mcp__vise-skills__review_triage'
39const TOOL_MARK = 'mcp__vise-skills__review_mark'
40
41const EMPTY_REVIEW: ReviewSession = { pullRequest: null, threads: [], workingTreeRoot: null, error: null, isLoading: false }
42
43const review = atom({ plugin: 'vise-skills', key: 'review' } as const, EMPTY_REVIEW)
44const triage = atom({ plugin: 'vise-skills', key: 'triage' } as const, {})
45const outcomes = atom({ plugin: 'vise-skills', key: 'outcomes' } as const, {})
46const behaviourOverrides = atom({ plugin: 'vise-skills', key: 'behaviourOverrides' } as const, {})
47const expandedThreadId = atom({ plugin: 'vise-skills', key: 'expandedThreadId' } as const, null)
48const isDoneSectionOpen = atom({ plugin: 'vise-skills', key: 'isDoneSectionOpen' } as const, true)
49
50export const registerReviewSidebar: Register = on => {
51  on('command.run', { command: 'review-sidebar' }, async $ => {
52    await $.ui.open({ id: PANE, title: PANE_TITLE })
53    const loaded = await read($, review)
54    return { text: loaded.pullRequest === null ? 'Review sidebar opened. Run /review-comments to load a PR.' : 'Review sidebar opened.' }
55  })
56
57  on('skill.prompt', async ($, e, next) => {
58    if (isReviewCommentsSkill(e.skill)) {
59      await startFreshWalk($)
60    }
61    return next(e)
62  })
63
64  on('tool.call', { tool: TOOL_THREADS }, async ($, e) => answeredOrDenied('review sidebar', async () => {
65    const { pr, refresh } = toolArguments<{ pr?: string; refresh?: boolean }>(e)
66    const requested = parsePullRequestTarget(pr ?? '')
67    await loadInFlight
68    const current = await read($, review)
69    const isAnotherPullRequest = requested !== null && !isSamePullRequest(current.pullRequest, requested)
70    if (current.pullRequest === null || isAnotherPullRequest || refresh === true) {
71      const isFreshStart = current.pullRequest === null || isAnotherPullRequest
72      await loadReviewOnce($, isFreshStart ? requested : current.pullRequest!.url, { isFreshStart })
73    }
74    const placement = await $.ui.open({ id: PANE, title: PANE_TITLE })
75    const loaded = await read($, review)
76    return loaded.error === null ? { result: threadsForClaude(loaded, placement) } : { deny: loaded.error }
77  }))
78
79  on('tool.call', { tool: TOOL_TRIAGE }, async ($, e) => answeredOrDenied('review sidebar', async () => {
80    const { items } = toolArguments<{ items: TriageInput[] }>(e)
81    const unknown = await recordTriage($, items)
82    return { result: unknown.length === 0 ? `Triage recorded for ${items.length} threads.` : `Recorded; no thread numbered ${unknown.join(', ')}.` }
83  }))
84
85  on('tool.call', { tool: TOOL_MARK }, async ($, e) => answeredOrDenied('review sidebar', async () => {
86    const { number, outcome } = toolArguments<{ number: number; outcome: ThreadOutcome }>(e)
87    const thread = (await read($, review)).threads.find(candidate => candidate.number === number)
88    if (thread === undefined) {
89      return { deny: `No review thread numbered ${number}.` }
90    }
91    await update($, outcomes, all => ({ ...all, [thread.id]: outcome }))
92    if (outcome === 'fixed' || outcome === 'pushed-back') {
93      await refreshReview($)
94    }
95    return { result: `[${number}] marked ${outcome}.` }
96  }))
97
98  on('ui.close', async ($, e, next) => {
99    if (e.id === PANE && e.origin.kind === 'person') {
100      $.ui.toast('Review sidebar closed. /review-sidebar reopens it.')
101    }
102    return next(e)
103  })
104
105  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
106    const model = {
107      review: await read($, review),
108      views: await currentViews($),
109      expandedThreadId: await read($, expandedThreadId),
110      isDoneSectionOpen: await read($, isDoneSectionOpen),
111    }
112    return drawSidebar($.ui.resolve(e), model, sidebarActions($))
113  })
114
115  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
116    const loaded = await read($, review)
117    if (e.props.hasSurvey || loaded.pullRequest === null) {
118      return next(e)
119    }
120    const counts = progressCounts(await currentViews($))
121    const { Box, Button, Text } = $.ui.resolve(e)
122    return (
123      <Box>
124        <Text dimColor>
125          review #{loaded.pullRequest.number} · {counts.done}/{counts.total} done
126          {counts.readyToPush > 0 ? ` · ${counts.readyToPush} ready to push` : ''}
127          {counts.skipped > 0 ? ` · ${counts.skipped} skipped` : ''}{' '}
128        </Text>
129        <Button key="open-review-sidebar" onPress={() => $.ui.open({ id: PANE, title: PANE_TITLE })}>Sidebar</Button>
130      </Box>
131    )
132  })
133}
134
135export async function registerReviewSidebarCommandAndTools($: EngineInterface) {
136  await $.command.register({
137    name: 'review-sidebar',
138    description: 'Reopen the review sidebar',
139    immediate: true,
140  })
141  await $.tool.register({
142    name: 'review_threads',
143    description: 'The unresolved review threads of the PR in the review sidebar, numbered as the sidebar shows them, with each thread\'s comments verbatim. Loads the PR when none is loaded, or when `pr` names another; `refresh` re-reads GitHub.',
144    inputSchema: {
145      type: 'object',
146      properties: {
147        pr: { type: 'string', description: 'A PR number or GitHub PR URL; omit for the current branch\'s PR.' },
148        refresh: { type: 'boolean' },
149      },
150    },
151    isDeferred: false,
152  })
153  await $.tool.register({
154    name: 'review_triage',
155    description: 'Record your triage of review threads so the sidebar groups them and shows your suggestion. One item per thread, by its number.',
156    inputSchema: {
157      type: 'object',
158      required: ['items'],
159      properties: {
160        items: {
161          type: 'array',
162          items: {
163            type: 'object',
164            required: ['number', 'bucket', 'scope', 'scopeReason', 'summary', 'suggestion', 'isBehaviourChange'],
165            properties: {
166              number: { type: 'number' },
167              bucket: { enum: ['trivial', 'discuss', 'push-back'] },
168              scope: { enum: ['S', 'M', 'L'] },
169              scopeReason: { type: 'string', description: 'A few words on what drives the scope, e.g. "touches 3 call sites".' },
170              summary: { type: 'string', description: 'The fix in one short line (under 60 characters), e.g. "Reject Unset after TryParse"; for push-back, "No change: <why>".' },
171              suggestion: { type: 'string', description: 'Markdown: the concrete fix, the options with a recommendation, or the push-back reasoning.' },
172              isBehaviourChange: { type: 'boolean', description: 'True when the fix changes what the code does at run time, not only its shape (names, comments, formatting, dead code).' },
173              sameAs: { type: 'number', description: 'The number of a thread with the same root cause; deciding that one settles this one too.' },
174              options: {
175                type: 'array',
176                description: 'For a thread that needs a decision, the ways to settle it, each a button in the sidebar. Leave out for a single clear fix.',
177                items: {
178                  type: 'object',
179                  required: ['key', 'label'],
180                  properties: {
181                    key: { type: 'string', description: 'Short id, e.g. "a1".' },
182                    label: { type: 'string', description: 'The button text, e.g. "(a) shared check, product+variant geo".' },
183                    scope: { enum: ['S', 'M', 'L'] },
184                    isBehaviourChange: { type: 'boolean' },
185                    isRecommended: { type: 'boolean' },
186                  },
187                },
188              },
189              references: {
190                type: 'array',
191                description: 'Other code the decision depends on (another call site, the other side of a drift). The sidebar shows each excerpt under the thread\'s own code.',
192                items: {
193                  type: 'object',
194                  required: ['path', 'startLine', 'endLine'],
195                  properties: {
196                    path: { type: 'string', description: 'Repository-relative path.' },
197                    startLine: { type: 'number' },
198                    endLine: { type: 'number' },
199                    caption: { type: 'string', description: 'One line on why it matters, e.g. "The basket\'s version checks only the variant".' },
200                  },
201                },
202              },
203            },
204          },
205        },
206      },
207    },
208    isDeferred: false,
209  })
210  await $.tool.register({
211    name: 'review_mark',
212    description: 'Record what happened to a review thread. `fixed-locally` once the change is made but not yet pushed; `fixed` only after the push, the reply and the resolve; `pushed-back` after the reply is posted. On `fixed` and `pushed-back` the sidebar re-reads GitHub.',
213    inputSchema: {
214      type: 'object',
215      required: ['number', 'outcome'],
216      properties: {
217        number: { type: 'number' },
218        outcome: { enum: ['in-progress', 'fixed-locally', 'fixed', 'pushed-back', 'skipped'] },
219      },
220    },
221    isDeferred: false,
222  })
223}
224
225async function loadReview($: EngineInterface, requested: PullRequestTarget, { isFreshStart }: { isFreshStart: boolean }) {
226  await update($, review, current => ({ ...current, isLoading: true, error: null }))
227  try {
228    const fetched = await fetchReview($, requested)
229    if (isFreshStart) {
230      await resetWalk($)
231    }
232    await update($, review, current => ({
233      pullRequest: fetched.pullRequest,
234      threads: mergeIntoWorkingSet(isFreshStart ? [] : current.threads, fetched.threads),
235      workingTreeRoot: fetched.workingTreeRoot,
236      error: null,
237      isLoading: false,
238    }))
239  } catch (error) {
240    await update($, review, current => ({ ...current, isLoading: false, error: error instanceof Error ? error.message : String(error) }))
241  }
242}
243
244type FetchedReview = { pullRequest: PullRequestRef; threads: ReviewThread[]; workingTreeRoot: string | null }
245
246async function fetchReview($: EngineInterface, requested: PullRequestTarget): Promise<FetchedReview> {
247  const pullRequest = parsePullRequest(await runOrThrow($, pullRequestViewArgv(requested)))
248  const workingTreeRoot = await workingTreeOfBranch($, pullRequest.headBranch)
249  const rawThreads = parseThreads(await runOrThrow($, threadsQueryArgv(pullRequest)))
250  const threads = await Promise.all(
251    rawThreads.map(async raw => toReviewThread(raw, await readAnchoredFile($, raw, pullRequest, workingTreeRoot))),
252  )
253  return { pullRequest, threads: sortByLocation(threads), workingTreeRoot }
254}
255
256/**
257 * The repository root when the PR's branch is checked out, so excerpts show local fixes as they
258 * land; null otherwise, when the working tree holds some other branch's code.
259 */
260async function workingTreeOfBranch($: EngineInterface, headBranch: string): Promise<string | null> {
261  const currentBranch = await $.process.run(CURRENT_BRANCH_ARGV, { timeoutMs: 10_000 })
262  if (currentBranch.exitCode !== 0 || currentBranch.stdout.trim() !== headBranch) return null
263  return (await runOrThrow($, REPOSITORY_ROOT_ARGV)).trim()
264}
265
266async function readAnchoredFile(
267  $: EngineInterface,
268  raw: GraphQlThread,
269  pullRequest: PullRequestRef,
270  workingTreeRoot: string | null,
271): Promise<string | null> {
272  if (raw.isResolved || currentLineOf(raw) === null) return null
273  return readReviewedFile($, raw.path, pullRequest, workingTreeRoot)
274}
275
276async function readReviewedFile(
277  $: EngineInterface,
278  path: string,
279  pullRequest: PullRequestRef,
280  workingTreeRoot: string | null,
281): Promise<string | null> {
282  try {
283    if (workingTreeRoot !== null) return await $.fs.read(`${workingTreeRoot}/${path}`)
284    return await runOrThrow($, fileAtHeadArgv(pullRequest, path))
285  } catch {
286    return null
287  }
288}
289
290async function runOrThrow($: EngineInterface, argv: string[]): Promise<string> {
291  const run = await $.process.run(argv, { timeoutMs: 30_000 })
292  if (run.exitCode !== 0) {
293    throw new Error(`${argv.slice(0, 3).join(' ')} failed: ${run.stderr.trim() || `exit ${run.exitCode}`}`)
294  }
295  return run.stdout
296}
297
298async function refreshReview($: EngineInterface) {
299  const loaded = await read($, review)
300  if (loaded.pullRequest !== null) await loadReview($, loaded.pullRequest.url, { isFreshStart: false })
301}
302
303async function resetWalk($: EngineInterface) {
304  await update($, triage, () => ({}))
305  await update($, outcomes, () => ({}))
306  await update($, behaviourOverrides, () => ({}))
307  await update($, expandedThreadId, () => null)
308}
309
310async function recordTriage($: EngineInterface, items: TriageInput[]): Promise<number[]> {
311  const threads = (await read($, review)).threads
312  const idByNumber = new Map(threads.map(thread => [thread.number, thread.id]))
313  const unknown = items.filter(item => !idByNumber.has(item.number)).map(item => item.number)
314  const triaged = await Promise.all(items.map(async ({ number, references, ...itemTriage }) => ({
315    threadId: idByNumber.get(number),
316    threadTriage: { ...itemTriage, references: await readReferences($, references ?? []) },
317  })))
318  await update($, triage, all => {
319    const recorded = { ...all }
320    for (const { threadId, threadTriage } of triaged) {
321      if (threadId !== undefined) recorded[threadId] = threadTriage
322    }
323    return recorded
324  })
325  return unknown
326}
327
328async function readReferences($: EngineInterface, references: CodeReference[]): Promise<ReferenceExcerpt[]> {
329  const loaded = await read($, review)
330  if (loaded.pullRequest === null) return []
331  return Promise.all(references.map(async reference => {
332    const fileText = await readReviewedFile($, reference.path, loaded.pullRequest!, loaded.workingTreeRoot)
333    return {
334      path: reference.path,
335      caption: reference.caption,
336      excerpt: fileText === null ? null : referenceExcerpt(fileText, reference.startLine, reference.endLine),
337    }
338  }))
339}
340
341async function currentViews($: EngineInterface): Promise<ThreadView[]> {
342  return viewsInWalkOrder((await read($, review)).threads, await read($, triage), await read($, outcomes), await read($, behaviourOverrides))
343}
344
345function sidebarActions($: EngineInterface): SidebarActions {
346  return {
347    refresh: () => void refreshReview($),
348    fixQuickFixes: () => void submitQuickFixes($),
349    commitAndPush: () => void submitPush($),
350    stepThrough: () => void expandNextAfter($, null),
351    toggle: threadId => void update($, expandedThreadId, current => (current === threadId ? null : threadId)),
352    toggleDoneSection: () => void update($, isDoneSectionOpen, isOpen => !isOpen),
353    toggleBehaviourChange: threadId => void toggleBehaviourChange($, threadId),
354    fix: threadId => void decide($, threadId, { kind: 'fix' }),
355    choose: (threadId, optionKey) => void decide($, threadId, { kind: 'option', optionKey }),
356    pushBack: threadId => void decide($, threadId, { kind: 'push-back' }),
357    discuss: threadId => void startDiscussion($, threadId),
358    skip: threadId => void skipThread($, threadId),
359    next: threadId => void expandNextAfter($, threadId),
360  }
361}
362
363async function submitQuickFixes($: EngineInterface) {
364  const quickFixes = quickFixesAwaitingDecision(await currentViews($))
365  if (quickFixes.length === 0) return
366  await markInProgress($, quickFixes.map(view => view.thread.id))
367  await $.prompt.submit({ text: quickFixPrompt(quickFixes), asUser: true })
368  await expandNextAfter($, null)
369}
370
371async function submitPush($: EngineInterface) {
372  const readyToPush = sectionsOf(await currentViews($)).readyToPush
373  if (readyToPush.length === 0) return
374  await $.prompt.submit({ text: pushPrompt(readyToPush), asUser: true })
375}
376
377async function toggleBehaviourChange($: EngineInterface, threadId: string) {
378  const view = (await currentViews($)).find(candidate => candidate.thread.id === threadId)
379  if (view?.triage === undefined) return
380  const isBehaviourChange = !view.triage.isBehaviourChange
381  await update($, behaviourOverrides, all => ({ ...all, [threadId]: isBehaviourChange }))
382}
383
384async function decide($: EngineInterface, threadId: string, decision: Decision) {
385  const views = await currentViews($)
386  const view = views.find(candidate => candidate.thread.id === threadId)
387  if (view === undefined) return
388  const settledToo = threadsSettledBy(views, view)
389  await markInProgress($, [threadId, ...settledToo.map(linked => linked.thread.id)])
390  await $.prompt.submit({ text: decisionPrompt(view, decision, settledToo), asUser: true })
391  await expandNextAfter($, threadId)
392}
393
394/** Threads that named this one as their root cause: deciding this one settles them too. */
395function threadsSettledBy(views: readonly ThreadView[], view: ThreadView): ThreadView[] {
396  return views.filter(other => other.triage?.sameAs === view.thread.number && other.outcome === undefined && !other.thread.isResolved)
397}
398
399async function startDiscussion($: EngineInterface, threadId: string) {
400  const view = (await currentViews($)).find(candidate => candidate.thread.id === threadId)
401  if (view === undefined) return
402  await $.prompt.fill({ text: discussionPrompt(view), mode: 'insert' })
403}
404
405async function skipThread($: EngineInterface, threadId: string) {
406  await update($, outcomes, all => ({ ...all, [threadId]: 'skipped' as const }))
407  await expandNextAfter($, threadId)
408}
409
410async function markInProgress($: EngineInterface, threadIds: string[]) {
411  await update($, outcomes, all => ({ ...all, ...Object.fromEntries(threadIds.map(id => [id, 'in-progress' as const])) }))
412}
413
414async function expandNextAfter($: EngineInterface, threadId: string | null) {
415  const upcoming = nextToStepThrough(await currentViews($), threadId)
416  await update($, expandedThreadId, () => upcoming?.thread.id ?? null)
417}
418
419function isReviewCommentsSkill(skill: string): boolean {
420  return skill.split(':').pop() === 'review-comments'
421}
422
423/**
424 * Each run of the skill is a new look at the PR, so the last run's threads and outcomes are
425 * dropped and the sidebar loads GitHub afresh by itself, without waiting for Claude to call
426 * review_threads: the PR the last run had, else the current branch's. With neither, it waits for
427 * Claude to name one.
428 */
429async function startFreshWalk($: EngineInterface) {
430  const previousPullRequestUrl = (await read($, review)).pullRequest?.url ?? null
431  await resetWalk($)
432  await update($, review, () => ({ ...EMPTY_REVIEW, isLoading: true }))
433  void $.ui.open({ id: PANE, title: PANE_TITLE })
434  $.clock.after(0, () => void loadOnSkillStart($, previousPullRequestUrl))
435}
436
437async function loadOnSkillStart($: EngineInterface, previousPullRequestUrl: string | null) {
438  const hasPullRequest = previousPullRequestUrl !== null || (await $.process.run(pullRequestViewArgv(null), { timeoutMs: 15_000 })).exitCode === 0
439  if (!hasPullRequest) {
440    await update($, review, current => ({ ...current, isLoading: false }))
441    return
442  }
443  await loadReviewOnce($, previousPullRequestUrl, { isFreshStart: true })
444}
445
446/** One load at a time: a review_threads call made while the skill's own load runs waits for it. */
447let loadInFlight: Promise<void> | null = null
448
449async function loadReviewOnce($: EngineInterface, requested: PullRequestTarget, options: { isFreshStart: boolean }) {
450  loadInFlight = loadReview($, requested, options).finally(() => {
451    loadInFlight = null
452  })
453  await loadInFlight
454}
455
456
457
458/**
459 * The threads, plus whether the person can see the sidebar: the skill walks in the sidebar only
460 * when it is shown, and in chat otherwise (as in the VS Code extension's panel, which draws no panes).
461 */
462function threadsForClaude(loaded: ReviewSession, placement: UiOpenResult): string {
463  return JSON.stringify({
464    sidebar: placement.isPlaced ? { isShown: true } : { isShown: false, reason: placement.reason },
465    pullRequest: loaded.pullRequest,
466    threads: loaded.threads.map(({ excerpt, ...thread }) => thread),
467  })
468}
469
470function isSamePullRequest(loaded: PullRequestRef | null, requested: string): boolean {
471  return loaded !== null && (requested === String(loaded.number) || requested === loaded.url)
472}
473
hooks/shutdown.tsx 173 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { ShutdownRound, ShutdownSession, ShutdownStatus } from '../types'
5import { drawPane } from './shutdown/pane'
6import type { PaneActions } from './shutdown/pane'
7import { OWN_HANDOFF_PROMPT, handoffPathIn, handoffRequest, parseReply, parseRoster, progressOf, sessionsFrom } from './shutdown/roster'
8import { answeredOrDenied, toolArguments } from './tool-answers'
9
10const PANE = 'shutdown'
11const PANE_TITLE = 'Shutdown'
12const TOOL_SESSIONS = 'mcp__vise-skills__shutdown_sessions'
13const TOOL_SEND = 'mcp__vise-skills__shutdown_send'
14
15const EMPTY_ROUND: ShutdownRound = { sessions: [], isPaneShown: false, error: null }
16
17const round = atom({ plugin: 'vise-skills', key: 'shutdown' } as const, EMPTY_ROUND)
18
19export const registerShutdownPane: Register = on => {
20  on('tool.call', { tool: TOOL_SESSIONS }, async $ => answeredOrDenied('shutdown pane', async () => {
21    await loadRoster($)
22    const placement = await $.ui.open({ id: PANE, title: PANE_TITLE })
23    await update($, round, current => ({ ...current, isPaneShown: placement.isPlaced }))
24    return { result: sessionsForClaude(await read($, round)) }
25  }))
26
27  on('tool.call', { tool: TOOL_SEND }, async ($, e) => answeredOrDenied('shutdown pane', async () => {
28    const { names } = toolArguments<{ names: string[] }>(e)
29    await update($, round, current => ({
30      ...current,
31      sessions: current.sessions.map(session => ({ ...session, isSelected: names.includes(session.name) || (session.isThisSession && names.includes('this session')) })),
32    }))
33    const sent = await sendToSelected($)
34    return { result: `Sent to ${sent.join(', ') || 'nobody'}. Replies arrive as each session reaches a clean point.` }
35  }))
36
37  on('session.receive', async ($, e, next) => {
38    const reply = parseReply(e.text)
39    if (reply === null || !(await isAwaitingReplyFrom($, reply.name))) return next(e)
40    await recordAnswer($, reply.name, reply.handoffPath === null ? 'nothing-to-hand-off' : 'handed-off', reply.handoffPath)
41    await announceWhenComplete($)
42    if ((await read($, round)).isPaneShown) return { consumed: 'shown in the shutdown pane' }
43    return next(e)
44  })
45
46  on('turn.complete', async ($, e, next) => {
47    const completed = await next(e)
48    if (e.agentId === undefined && ownStatus(await read($, round)) === 'sent') {
49      await recordOwnHandoffIfWritten($)
50    }
51    return completed
52  })
53
54  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => drawPane($.ui.resolve(e), await read($, round), paneActions($)))
55
56  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
57    const progress = progressOf((await read($, round)).sessions)
58    if (e.props.hasSurvey || progress.sent === 0) return next(e)
59    const { Box, Button, Text } = $.ui.resolve(e)
60    return (
61      <Box>
62        <Text dimColor>
63          shutdown · {progress.isComplete ? 'all answered, safe to restart' : `${progress.answered}/${progress.sent} answered`}{' '}
64        </Text>
65        <Button key="open-shutdown-pane" onPress={() => $.ui.open({ id: PANE, title: PANE_TITLE })}>Pane</Button>
66      </Box>
67    )
68  })
69}
70
71export async function registerShutdownTools($: EngineInterface) {
72  await $.tool.register({
73    name: 'shutdown_sessions',
74    description: 'Lists this session and the other Claude sessions on this machine for /shutdown, and opens the shutdown pane where the user ticks which ones write a handoff. Says whether the pane is shown; when it is, the user sends from there and replies are tallied there.',
75    inputSchema: { type: 'object', properties: {} },
76    isDeferred: false,
77  })
78  await $.tool.register({
79    name: 'shutdown_send',
80    description: 'Sends the handoff request to the named sessions, as shutdown_sessions listed them; "this session" runs the handoff here too. Use only when the shutdown pane is not shown and the user picked in chat.',
81    inputSchema: {
82      type: 'object',
83      required: ['names'],
84      properties: { names: { type: 'array', items: { type: 'string' } } },
85    },
86    isDeferred: false,
87  })
88}
89
90async function loadRoster($: EngineInterface) {
91  const listing = await $.tool.call({ tool: 'ListAgents' } as never)
92  if ('deny' in listing && listing.deny !== undefined) {
93    await update($, round, current => ({ ...current, sessions: [], error: `ListAgents refused: ${listing.deny}` }))
94    return
95  }
96  await update($, round, current => ({ ...current, sessions: sessionsFrom(parseRoster(listing.text ?? '')), error: null }))
97}
98
99function paneActions($: EngineInterface): PaneActions {
100  return {
101    toggle: name => void updateSessions($, sessions => sessions.map(session => (session.name === name ? { ...session, isSelected: !session.isSelected } : session))),
102    selectAll: () => void updateSessions($, sessions => sessions.map(session => ({ ...session, isSelected: true }))),
103    selectNone: () => void updateSessions($, sessions => sessions.map(session => ({ ...session, isSelected: false }))),
104    send: () => void sendToSelected($),
105    refresh: () => void loadRoster($),
106  }
107}
108
109async function sendToSelected($: EngineInterface): Promise<string[]> {
110  const { sessions } = await read($, round)
111  const coordinator = sessions.find(session => session.isThisSession)?.name ?? 'the coordinating session'
112  const chosen = sessions.filter(session => session.isSelected)
113  for (const peer of chosen.filter(session => !session.isThisSession)) {
114    await $.session.send({ to: peer.address, text: handoffRequest(peer.name, coordinator) })
115    await setStatus($, peer.name, 'sent')
116  }
117  if (chosen.some(session => session.isThisSession)) {
118    await setOwnStatus($, 'sent')
119    await $.prompt.submit({ text: OWN_HANDOFF_PROMPT, asUser: true })
120  }
121  return chosen.map(session => session.name)
122}
123
124async function isAwaitingReplyFrom($: EngineInterface, name: string): Promise<boolean> {
125  return (await read($, round)).sessions.some(session => session.name === name && session.status === 'sent')
126}
127
128async function recordAnswer($: EngineInterface, name: string, status: ShutdownStatus, handoffPath: string | null) {
129  await updateSessions($, sessions => sessions.map(session => (session.name === name ? { ...session, status, handoffPath } : session)))
130}
131
132/**
133 * The handoff skill ends by printing its path, so a turn whose last answer names a handoff file
134 * is the one that wrote it. A turn already running when Send was pressed ends without one.
135 */
136async function recordOwnHandoffIfWritten($: EngineInterface) {
137  const lastAnswer = (await $.session.messages()).filter(message => message.role === 'assistant').at(-1)
138  const handoffPath = handoffPathIn(lastAnswer?.text ?? '')
139  const thisSession = (await read($, round)).sessions.find(session => session.isThisSession)
140  if (handoffPath === null || thisSession === undefined) return
141  await recordAnswer($, thisSession.name, 'handed-off', handoffPath)
142  await announceWhenComplete($)
143}
144
145async function announceWhenComplete($: EngineInterface) {
146  const progress = progressOf((await read($, round)).sessions)
147  if (progress.isComplete) $.ui.toast(`All ${progress.sent} sessions answered — safe to restart.`)
148}
149
150async function setStatus($: EngineInterface, name: string, status: ShutdownStatus) {
151  await updateSessions($, sessions => sessions.map(session => (session.name === name ? { ...session, status } : session)))
152}
153
154async function setOwnStatus($: EngineInterface, status: ShutdownStatus) {
155  await updateSessions($, sessions => sessions.map(session => (session.isThisSession ? { ...session, status } : session)))
156}
157
158function ownStatus(current: ShutdownRound): ShutdownStatus | undefined {
159  return current.sessions.find(session => session.isThisSession)?.status
160}
161
162async function updateSessions($: EngineInterface, change: (sessions: ShutdownSession[]) => ShutdownSession[]) {
163  await update($, round, current => ({ ...current, sessions: change(current.sessions) }))
164}
165
166function sessionsForClaude(current: ShutdownRound): string {
167  return JSON.stringify({
168    pane: { isShown: current.isPaneShown },
169    error: current.error,
170    sessions: current.sessions.map(({ name, details, isThisSession }) => ({ name: isThisSession ? 'this session' : name, details: isThisSession ? `${name}, this session` : details })),
171  })
172}
173
hooks/review-comments/github-threads.ts 134 lines
1import type { CodeExcerpt, PullRequestRef, ReviewComment, ReviewThread } from '../../types'
2
3const EXCERPT_CONTEXT_LINES = 4
4
5const THREADS_QUERY = `
6query($owner:String!, $repo:String!, $pr:Int!) {
7  repository(owner:$owner, name:$repo) {
8    pullRequest(number:$pr) {
9      reviewThreads(first:100) {
10        nodes {
11          id isResolved isOutdated path line originalLine
12          comments(first:50) { nodes { author { login } body url diffHunk } }
13        }
14      }
15    }
16  }
17}`
18
19export type GraphQlThread = {
20  id: string
21  isResolved: boolean
22  isOutdated: boolean
23  path: string
24  line: number | null
25  originalLine: number | null
26  comments: { nodes: { author: { login: string } | null; body: string; url: string; diffHunk: string }[] }
27}
28
29export const REPOSITORY_ROOT_ARGV = ['git', 'rev-parse', '--show-toplevel']
30export const CURRENT_BRANCH_ARGV = ['git', 'branch', '--show-current']
31
32/** A PR number or URL as the person gave it; null means the current branch's PR. */
33export type PullRequestTarget = string | null
34
35export function parsePullRequestTarget(text: string): PullRequestTarget {
36  const trimmed = text.trim()
37  return /^(https:\/\/github\.com\/\S+\/pull\/\d+|#?\d+)$/.test(trimmed) ? trimmed.replace(/^#/, '') : null
38}
39
40export function pullRequestViewArgv(requested: PullRequestTarget): string[] {
41  const target = requested === null ? [] : [requested]
42  return ['gh', 'pr', 'view', ...target, '--json', 'number,title,url,headRefName,headRefOid']
43}
44
45export function fileAtHeadArgv(pullRequest: PullRequestRef, path: string): string[] {
46  return [
47    'gh', 'api',
48    '-H', 'Accept: application/vnd.github.raw',
49    `repos/${pullRequest.owner}/${pullRequest.repo}/contents/${path}?ref=${pullRequest.headCommit}`,
50  ]
51}
52
53export function threadsQueryArgv(pullRequest: PullRequestRef): string[] {
54  return [
55    'gh', 'api', 'graphql',
56    '-f', `query=${THREADS_QUERY}`,
57    '-F', `owner=${pullRequest.owner}`,
58    '-F', `repo=${pullRequest.repo}`,
59    '-F', `pr=${pullRequest.number}`,
60  ]
61}
62
63export function parsePullRequest(pullRequestJson: string): PullRequestRef {
64  const pullRequest = JSON.parse(pullRequestJson)
65  const [, owner = '', repo = ''] = /github\.com\/([^/]+)\/([^/]+)\/pull\//.exec(pullRequest.url) ?? []
66  return {
67    owner,
68    repo,
69    number: pullRequest.number,
70    title: pullRequest.title,
71    url: pullRequest.url,
72    headBranch: pullRequest.headRefName,
73    headCommit: pullRequest.headRefOid,
74  }
75}
76
77export function parseThreads(responseJson: string): GraphQlThread[] {
78  return JSON.parse(responseJson).data.repository.pullRequest.reviewThreads.nodes
79}
80
81/** The line the thread anchors to now, or null when only the original diff can show it. */
82export function currentLineOf(raw: GraphQlThread): number | null {
83  return raw.isOutdated ? null : raw.line ?? raw.originalLine
84}
85
86export function toReviewThread(raw: GraphQlThread, fileText: string | null): ReviewThread {
87  const comments: ReviewComment[] = raw.comments.nodes.map(comment => ({
88    author: comment.author?.login ?? 'ghost',
89    body: comment.body,
90    url: comment.url,
91  }))
92  const line = raw.line ?? raw.originalLine
93  const currentLine = currentLineOf(raw)
94  const excerpt = fileText !== null && currentLine !== null
95    ? sourceExcerpt(fileText, currentLine)
96    : diffExcerpt(raw.comments.nodes[0]?.diffHunk ?? '')
97
98  return {
99    id: raw.id,
100    number: 0,
101    path: raw.path,
102    line,
103    isOutdated: raw.isOutdated,
104    isResolved: raw.isResolved,
105    url: comments[0]?.url ?? '',
106    comments,
107    excerpt,
108  }
109}
110
111export function sortByLocation(threads: ReviewThread[]): ReviewThread[] {
112  return [...threads].sort((a, b) => a.path.localeCompare(b.path) || (a.line ?? 0) - (b.line ?? 0))
113}
114
115const MAX_REFERENCE_LINES = 40
116
117function sourceExcerpt(fileText: string, line: number): CodeExcerpt {
118  return linesExcerpt(fileText, line - EXCERPT_CONTEXT_LINES, line + EXCERPT_CONTEXT_LINES)
119}
120
121export function referenceExcerpt(fileText: string, startLine: number, endLine: number): CodeExcerpt {
122  return linesExcerpt(fileText, startLine, Math.min(endLine, startLine + MAX_REFERENCE_LINES - 1))
123}
124
125function linesExcerpt(fileText: string, firstLine: number, lastLine: number): CodeExcerpt {
126  const startLine = Math.max(1, firstLine)
127  const source = fileText.split('\n').slice(startLine - 1, lastLine).join('\n')
128  return { source, startLine, format: 'source' }
129}
130
131function diffExcerpt(diffHunk: string): CodeExcerpt | null {
132  return diffHunk === '' ? null : { source: diffHunk, format: 'diff' }
133}
134
hooks/review-findings/findings.ts 252 lines
1import type { CodeExcerpt, FindingSeverity, FindingSource, FindingStatus, FindingsReviewKind, PullRequestRef, ReviewFinding, RuleTally } from '../../types'
2import { referenceExcerpt } from '../review-comments/github-threads'
3
4/** A finding as a reviewer reports it: ReportFindings' shape, plus the Clean Code rule when there is one. */
5export type ReportedFinding = {
6  file: string
7  line?: number
8  summary: string
9  short_summary?: string
10  failure_scenario?: string
11  category?: string
12  rule?: string
13  severity?: string
14  suggested_fix?: string
15}
16
17export type ReviewComment = { path: string; line: number; side: 'RIGHT'; body: string }
18export type ReviewPayload = { commit_id: string; event: 'COMMENT'; body: string; comments: ReviewComment[] }
19
20const SOURCE_LABELS: Record<FindingSource, string> = {
21  claude: 'Claude code-review',
22  matt: "Matt's code-review",
23  'clean-code': 'Clean Code',
24}
25
26const SHORT_SUMMARY_LENGTH = 60
27const SEVERITIES: readonly FindingSeverity[] = ['high', 'medium', 'low']
28/** One icon per severity, the same in the pane, in chat and on GitHub: must fix, should fix, tidy-up. */
29export const SEVERITY_ICONS: Record<FindingSeverity, string> = { high: '🔥', medium: '⚠️', low: '🧹' }
30
31/** A low finding goes to GitHub as a nit, the reviewers' word for take-it-or-leave-it. */
32const SEVERITY_BADGES: Record<FindingSeverity, string> = { high: '🔥 **High:**', medium: '⚠️ **Medium:**', low: '🧹 *nit:*' }
33
34/** /code-review reports no severity, only a category; these say how much a category usually costs when it is wrong. */
35const HIGH_CATEGORIES = ['correctness', 'security', 'data-loss', 'concurrency', 'bug']
36const LOW_CATEGORIES = ['simplification', 'style', 'naming', 'readability', 'nit', 'standards']
37const EXCERPT_CONTEXT_LINES = 3
38
39/** A rule that raised at least this many findings, half or more of them dismissed, needs another look. */
40const MINIMUM_RAISED_TO_JUDGE = 3
41const DISMISSED_SHARE_TO_FLAG = 0.5
42
43export function findingsFrom(source: FindingSource, reported: readonly ReportedFinding[], existing: readonly ReviewFinding[]): ReviewFinding[] {
44  const taken = existing.filter(finding => finding.source === source).length
45  return reported.map((finding, index) => ({
46    id: `${source}-${taken + index + 1}`,
47    source,
48    file: finding.file,
49    line: finding.line ?? null,
50    summary: finding.summary,
51    shortSummary: finding.short_summary ?? shortened(finding.summary),
52    severity: severityOf(source, finding),
53    suggestedFix: finding.suggested_fix?.trim() || null,
54    isSuggestingFix: false,
55    failureScenario: finding.failure_scenario?.trim() || null,
56    category: finding.category ?? null,
57    rule: finding.rule?.trim().toUpperCase() || null,
58    status: 'open',
59    isSelected: false,
60    commentUrl: null,
61    excerpt: null,
62  }))
63}
64
65/** The head-side line numbers of each file in a unified diff: context and added lines, the ones GitHub can anchor to. */
66export function commentableLines(diff: string): Record<string, number[]> {
67  const lines: Record<string, number[]> = {}
68  let file: string | null = null
69  let headLine = 0
70  for (const text of diff.split('\n')) {
71    const header = /^\+\+\+ (?:b\/)?(.+)$/.exec(text)
72    const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(text)
73    if (text.startsWith('diff --git')) file = null
74    else if (header !== null) file = header[1] === '/dev/null' ? null : (header[1] ?? null)
75    else if (hunk !== null) headLine = Number(hunk[1])
76    else if (file !== null && headLine > 0 && (text.startsWith('+') || text.startsWith(' '))) {
77      ;(lines[file] ??= []).push(headLine++)
78    }
79  }
80  return lines
81}
82
83/**
84 * One review of inline comments. A finding whose line the diff does not show cannot be an inline
85 * comment, so it goes in the review's body instead, with its file and line.
86 */
87export function reviewPayload(
88  pullRequest: PullRequestRef,
89  findings: readonly ReviewFinding[],
90  commentable: Record<string, number[]>,
91  notes: Record<string, string> = {},
92): ReviewPayload {
93  const isInline = (finding: ReviewFinding) => finding.line !== null && (commentable[finding.file] ?? []).includes(finding.line)
94  const outsideDiff = findings.filter(finding => !isInline(finding))
95  return {
96    commit_id: pullRequest.headCommit,
97    event: 'COMMENT',
98    body: outsideDiff.map(finding => `**${locationOf(finding)}**: ${commentBody(finding, notes[finding.id])}`).join('\n\n'),
99    comments: findings.filter(isInline).map(finding => ({ path: finding.file, line: finding.line!, side: 'RIGHT', body: commentBody(finding, notes[finding.id]) })),
100  }
101}
102
103export function reviewPostArgv(pullRequest: PullRequestRef): string[] {
104  return ['gh', 'api', '-X', 'POST', `repos/${pullRequest.owner}/${pullRequest.repo}/pulls/${pullRequest.number}/reviews`, '--input', '-']
105}
106
107export function pullRequestDiffArgv(pullRequest: PullRequestRef): string[] {
108  return ['gh', 'pr', 'diff', String(pullRequest.number), '--repo', `${pullRequest.owner}/${pullRequest.repo}`]
109}
110
111/** A note, when given, says what was done about the finding and leads the comment. */
112export function commentBody(finding: ReviewFinding, note?: string): string {
113  const scenario = finding.failureScenario === null ? '' : `\n\n${finding.failureScenario}`
114  const suggestion = finding.suggestedFix === null ? '' : `\n\n**Suggested fix:** ${finding.suggestedFix}`
115  const rule = finding.rule === null ? '' : ` (Clean Code ${finding.rule})`
116  const lead = note === undefined ? '' : `${note.trim()}\n\n> `
117  return `${lead}${SEVERITY_BADGES[finding.severity]} ${finding.summary}${rule}${note === undefined ? `${scenario}${suggestion}` : ''}`
118}
119
120/** A finding as one line of chat: icon, id, where, and what. */
121export function listLine(finding: ReviewFinding): string {
122  return `${SEVERITY_ICONS[finding.severity]} **${finding.id}** ${locationOf(finding)}: ${finding.summary}`
123}
124
125export function locationOf(finding: ReviewFinding): string {
126  return finding.line === null ? finding.file : `${finding.file}:${finding.line}`
127}
128
129export function sourceLabel(source: FindingSource): string {
130  return SOURCE_LABELS[source]
131}
132
133/** For Haiku: one fix for one finding, from the code around it. */
134export function suggestionPrompt(finding: ReviewFinding): string {
135  return [
136    `A code reviewer raised this at ${locationOf(finding)}: ${finding.summary}`,
137    ...(finding.failureScenario === null ? [] : [`Why it matters: ${finding.failureScenario}`]),
138    ...(finding.excerpt === null ? [] : ['', 'The code around it:', '```', finding.excerpt.source, '```']),
139    '',
140    'Suggest the fix in at most three sentences, or a short code snippet with one line saying what it changes. Answer with the fix alone: no preamble, no restating the problem.',
141  ].join('\n')
142}
143
144export function explainPrompt(finding: ReviewFinding): string {
145  return [
146    `Tell me more about this ${SEVERITY_ICONS[finding.severity]} ${finding.severity} ${sourceLabel(finding.source)} finding at ${locationOf(finding)}${finding.rule === null ? '' : ` (rule ${finding.rule})`}:`,
147    '',
148    `> ${finding.summary}`,
149    ...(finding.failureScenario === null ? [] : ['>', `> ${finding.failureScenario}`]),
150    '',
151    'Read the code around it, then say whether it holds up, what goes wrong and when, and the change you would make. Do not edit anything.',
152  ].join('\n')
153}
154
155/** With `isCommenting`, each fix is also explained on the PR, in an inline comment that says what changed. */
156export function fixPrompt(kind: FindingsReviewKind, findings: readonly ReviewFinding[], { isCommenting }: { isCommenting: boolean }): string {
157  const ids = findings.map(finding => finding.id)
158  const steps = isCommenting
159    ? [
160        'Fix each, then commit and push.',
161        `Then call \`mcp__vise-skills__findings_post\` with kind \`${kind}\` and one item per id, its \`note\` one line on what you changed and why, so each lands on the PR as an inline comment.`,
162      ]
163    : ['Fix each.']
164  return [
165    `Fix these review findings (${ids.join(', ')}):`,
166    '',
167    ...findings.flatMap(finding => [
168      `- ${listLine(finding)}`,
169      ...(finding.suggestedFix === null ? [] : [`  Suggested fix (a starting point, not a given): ${finding.suggestedFix}`]),
170    ]),
171    '',
172    ...steps,
173    `Call \`mcp__vise-skills__findings_mark\` with kind \`${kind}\`, status \`fixed\` and the ids you fixed. A finding that does not hold up on a closer look: say why and mark it \`withdrawn\` instead.`,
174  ].join('\n')
175}
176
177/**
178 * A finding recorded by an earlier version of the mod, still held in the session after a reload,
179 * lacks the fields added since; each gets the value a fresh finding would start with.
180 */
181export function withCurrentFields(finding: ReviewFinding): ReviewFinding {
182  const recorded: Partial<ReviewFinding> = finding
183  return {
184    ...finding,
185    severity: recorded.severity ?? severityOf(finding.source, { file: finding.file, summary: finding.summary, category: finding.category ?? undefined }),
186    suggestedFix: recorded.suggestedFix ?? null,
187    isSuggestingFix: recorded.isSuggestingFix ?? false,
188    excerpt: recorded.excerpt ?? null,
189  }
190}
191
192/** Most severe first; within a severity, in the order the reviewers reported them. */
193export function bySeverity(findings: readonly ReviewFinding[]): ReviewFinding[] {
194  return [...findings].sort((left, right) => SEVERITIES.indexOf(left.severity) - SEVERITIES.indexOf(right.severity))
195}
196
197/** The next finding still open after this one, wrapping round; the first open one when none is given. */
198export function nextOpenAfter(findings: readonly ReviewFinding[], afterId: string | null): ReviewFinding | undefined {
199  const start = findings.findIndex(finding => finding.id === afterId) + 1
200  const rotated = [...findings.slice(start), ...findings.slice(0, start)]
201  return rotated.find(finding => finding.id !== afterId && finding.status === 'open')
202}
203
204export function findingExcerpt(fileText: string, line: number): CodeExcerpt {
205  return referenceExcerpt(fileText, line - EXCERPT_CONTEXT_LINES, line + EXCERPT_CONTEXT_LINES)
206}
207
208export function withStatus(findings: readonly ReviewFinding[], ids: readonly string[], status: FindingStatus): ReviewFinding[] {
209  return findings.map(finding => (ids.includes(finding.id) ? { ...finding, status, isSelected: false } : finding))
210}
211
212export function tallyRaised(tallies: Record<string, RuleTally>, findings: readonly ReviewFinding[]): Record<string, RuleTally> {
213  return tallyEach(tallies, findings, tally => ({ ...tally, raised: tally.raised + 1 }))
214}
215
216export function tallyDismissed(tallies: Record<string, RuleTally>, findings: readonly ReviewFinding[]): Record<string, RuleTally> {
217  return tallyEach(tallies, findings, tally => ({ ...tally, dismissed: tally.dismissed + 1 }))
218}
219
220export type OftenDismissedRule = { rule: string; raised: number; dismissed: number }
221
222export function oftenDismissedRules(tallies: Record<string, RuleTally>): OftenDismissedRule[] {
223  return Object.entries(tallies)
224    .filter(([, tally]) => tally.raised >= MINIMUM_RAISED_TO_JUDGE && tally.dismissed / tally.raised >= DISMISSED_SHARE_TO_FLAG)
225    .map(([rule, tally]) => ({ rule, ...tally }))
226    .sort((left, right) => right.dismissed / right.raised - left.dismissed / left.raised)
227}
228
229function tallyEach(tallies: Record<string, RuleTally>, findings: readonly ReviewFinding[], change: (tally: RuleTally) => RuleTally): Record<string, RuleTally> {
230  const counted = { ...tallies }
231  for (const finding of findings) {
232    if (finding.source !== 'clean-code' || finding.rule === null) continue
233    counted[finding.rule] = change(counted[finding.rule] ?? { raised: 0, dismissed: 0 })
234  }
235  return counted
236}
237
238/** A reviewer's own severity wins; otherwise Claude's comes from its category, and a style rule is low. */
239function severityOf(source: FindingSource, finding: ReportedFinding): FindingSeverity {
240  const stated = finding.severity?.trim().toLowerCase()
241  if (SEVERITIES.includes(stated as FindingSeverity)) return stated as FindingSeverity
242  if (source === 'clean-code') return 'low'
243  const category = finding.category?.trim().toLowerCase() ?? ''
244  if (HIGH_CATEGORIES.includes(category)) return 'high'
245  if (LOW_CATEGORIES.includes(category)) return 'low'
246  return 'medium'
247}
248
249function shortened(summary: string): string {
250  return summary.length <= SHORT_SUMMARY_LENGTH ? summary : `${summary.slice(0, SHORT_SUMMARY_LENGTH - 1)}…`
251}
252
hooks/review-findings/pane.tsx 192 lines
1import type { ElementTable } from 'claude-code'
2
3import type { FindingSeverity, FindingStatus, FindingsReviewKind, FindingsRound, ReviewFinding } from '../../types'
4import { SEVERITY_ICONS, locationOf, sourceLabel } from './findings'
5import type { OftenDismissedRule } from './findings'
6
7export type PaneActions = {
8  toggle: (id: string) => void
9  expand: (id: string) => void
10  selectAll: () => void
11  selectNone: () => void
12  queue: () => void
13  fix: () => void
14  fixAndComment: () => void
15  dismiss: () => void
16  explain: (id: string) => void
17  stepThrough: () => void
18  next: (id: string) => void
19  fixOne: (id: string) => void
20  queueOne: (id: string) => void
21  dismissOne: (id: string) => void
22  unqueue: (id: string) => void
23  submitReview: () => void
24}
25
26/** Colour as well as the icon, so severity reads at a glance down the list. */
27const SEVERITY_COLOURS: Record<FindingSeverity, string> = { high: 'red', medium: 'yellow', low: 'cyan' }
28
29const STATUS_LABELS: Record<FindingStatus, string> = {
30  open: '',
31  queued: 'in the review',
32  fixing: 'fixing…',
33  fixed: 'fixed',
34  posted: 'posted',
35  dismissed: 'dismissed',
36  withdrawn: 'withdrawn by Claude',
37}
38
39export function drawPane(ui: ElementTable, kind: FindingsReviewKind, round: FindingsRound, oftenDismissed: readonly OftenDismissedRule[], actions: PaneActions) {
40  const { Box, Text } = ui
41  if (round.error !== null) {
42    return <Text color="red">{round.error}</Text>
43  }
44  const open = round.findings.filter(finding => finding.status === 'open' || finding.status === 'fixing')
45  const queued = round.findings.filter(finding => finding.status === 'queued')
46  const settled = round.findings.filter(finding => !open.includes(finding) && !queued.includes(finding))
47  return (
48    <Box flexDirection="column">
49      {drawHeader(ui, round)}
50      {drawButtons(ui, kind, round, actions)}
51      {open.map(finding => drawFinding(ui, kind, round, finding, actions))}
52      {queued.length > 0 && drawReviewToPost(ui, queued, actions)}
53      {settled.length > 0 && <Text dimColor>{`── done (${settled.length}) ──`}</Text>}
54      {settled.map(finding => drawSettled(ui, finding))}
55      {kind === 'self' && oftenDismissed.length > 0 && drawOftenDismissed(ui, oftenDismissed)}
56    </Box>
57  )
58}
59
60function drawHeader(ui: ElementTable, round: FindingsRound) {
61  const { Box, Text } = ui
62  const target = round.pullRequest === null ? 'this branch (no PR: posting is off)' : `#${round.pullRequest.number} ${round.pullRequest.title}`
63  return (
64    <Box flexDirection="column" marginBottom={1}>
65      <Text bold>{target}</Text>
66      <Text dimColor>
67        {round.findings.length} findings
68        {round.sourcesPending.length > 0 ? ` · waiting on ${round.sourcesPending.map(sourceLabel).join(', ')}` : ''}
69      </Text>
70    </Box>
71  )
72}
73
74/** One action per line, so the list fits a narrow pane. */
75function drawButtons(ui: ElementTable, kind: FindingsReviewKind, round: FindingsRound, actions: PaneActions) {
76  const { Box, Button } = ui
77  const ticked = round.findings.filter(finding => finding.isSelected).length
78  const canPost = round.pullRequest !== null
79  const queuedCount = round.findings.filter(finding => finding.status === 'queued').length
80  const hasOpen = round.findings.some(finding => finding.status === 'open')
81  return (
82    <Box flexDirection="column" marginBottom={1}>
83      {canPost && queuedCount > 0 && <Button key="submit-review" hotkey="s" variant="primary" onPress={actions.submitReview}>{`Submit review (${queuedCount})`}</Button>}
84      {hasOpen && ticked === 0 && <Button key="step-through" hotkey="t" onPress={actions.stepThrough}>Step through</Button>}
85      <Button key="select-all" hotkey="a" onPress={actions.selectAll}>Tick all</Button>
86      {ticked > 0 && <Button key="select-none" hotkey="u" onPress={actions.selectNone}>Untick all</Button>}
87      {kind === 'self' && ticked > 0 && <Button key="fix" hotkey="f" onPress={actions.fix}>{`Fix ${ticked}`}</Button>}
88      {kind === 'self' && ticked > 0 && canPost && <Button key="fix-and-comment" hotkey="c" onPress={actions.fixAndComment}>{`Fix + comment ${ticked}`}</Button>}
89      {ticked > 0 && canPost && <Button key="queue" hotkey="r" onPress={actions.queue}>{`Add ${ticked} to review`}</Button>}
90      {ticked > 0 && <Button key="dismiss" hotkey="d" onPress={actions.dismiss}>{`Dismiss ${ticked}`}</Button>}
91    </Box>
92  )
93}
94
95function drawFinding(ui: ElementTable, kind: FindingsReviewKind, round: FindingsRound, finding: ReviewFinding, actions: PaneActions) {
96  const { Box, Button, Text } = ui
97  const isExpanded = round.expandedId === finding.id
98  return (
99    <Box key={`finding-${finding.id}`} flexDirection="column">
100      <Box gap={1}>
101        <Button key={`toggle-${finding.id}`} plain onPress={() => actions.toggle(finding.id)}>
102          {finding.isSelected ? '[x]' : '[ ]'}
103        </Button>
104        <Button key={`expand-${finding.id}`} plain onPress={() => actions.expand(finding.id)}>
105          {drawSeverity(ui, finding)}{' · '}
106          {finding.shortSummary} <Text dimColor>{`${locationOf(finding)} · ${tagOf(finding)}${finding.status === 'fixing' ? ' · fixing…' : ''}`}</Text>
107        </Button>
108      </Box>
109      {isExpanded && drawDetail(ui, kind, round, finding, actions)}
110    </Box>
111  )
112}
113
114/** One finding opened, as the step-through walk shows it: its code, what is wrong, and what to do about it alone. */
115function drawDetail(ui: ElementTable, kind: FindingsReviewKind, round: FindingsRound, finding: ReviewFinding, actions: PaneActions) {
116  const { Box, Code, Text } = ui
117  const openFindings = round.findings.filter(other => other.status === 'open')
118  const position = openFindings.findIndex(other => other.id === finding.id) + 1
119  return (
120    <Box flexDirection="column" marginLeft={2} marginBottom={1}>
121      {position > 0 && <Text dimColor>{`${position} of ${openFindings.length} open`}</Text>}
122      {finding.excerpt !== null && <Code source={finding.excerpt.source} path={finding.file} startLine={finding.excerpt.startLine} format={finding.excerpt.format} />}
123      <Text>{finding.summary}</Text>
124      {finding.failureScenario !== null && <Text dimColor>{finding.failureScenario}</Text>}
125      {finding.isSuggestingFix && <Text dimColor>Suggesting a fix…</Text>}
126      {finding.suggestedFix !== null && <Text bold>Suggested fix</Text>}
127      {finding.suggestedFix !== null && <Text>{finding.suggestedFix}</Text>}
128      {finding.status === 'open' && drawFindingButtons(ui, kind, round, finding, actions)}
129    </Box>
130  )
131}
132
133function drawFindingButtons(ui: ElementTable, kind: FindingsReviewKind, round: FindingsRound, finding: ReviewFinding, actions: PaneActions) {
134  const { Box, Button } = ui
135  const id = finding.id
136  const hotkey = (key: string) => (round.findings.some(other => other.isSelected) ? undefined : key)
137  return (
138    <Box flexDirection="column" marginTop={1}>
139      {kind === 'self' && <Button key={`fix-${id}`} hotkey={hotkey('f')} onPress={() => actions.fixOne(id)}>Fix</Button>}
140      {round.pullRequest !== null && <Button key={`queue-${id}`} hotkey={hotkey('r')} onPress={() => actions.queueOne(id)}>Add to review</Button>}
141      <Button key={`dismiss-${id}`} hotkey={hotkey('d')} onPress={() => actions.dismissOne(id)}>Dismiss</Button>
142      <Button key={`explain-${id}`} hotkey={hotkey('i')} onPress={() => actions.explain(id)}>More info</Button>
143      <Button key={`next-${id}`} hotkey={hotkey('n')} onPress={() => actions.next(id)}>Next</Button>
144    </Box>
145  )
146}
147
148/** The review being built: nothing reaches GitHub until Submit, and pressing a row takes it back out. */
149function drawReviewToPost(ui: ElementTable, queued: readonly ReviewFinding[], actions: PaneActions) {
150  const { Box, Button, Text } = ui
151  return (
152    <Box flexDirection="column" marginTop={1}>
153      <Text dimColor>{`── in the review, not yet posted (${queued.length}) ──`}</Text>
154      {queued.map(finding => (
155        <Button key={`unqueue-${finding.id}`} plain onPress={() => actions.unqueue(finding.id)}>
156          {`↩ `}{drawSeverity(ui, finding)}{` · ${finding.shortSummary}`} <Text dimColor>{locationOf(finding)}</Text>
157        </Button>
158      ))}
159    </Box>
160  )
161}
162
163function drawSettled(ui: ElementTable, finding: ReviewFinding) {
164  const { Text } = ui
165  return (
166    <Text key={`settled-${finding.id}`} dimColor>
167      {finding.status === 'dismissed' || finding.status === 'withdrawn' ? '–' : '✓'} {finding.shortSummary} · {STATUS_LABELS[finding.status]}
168      {finding.commentUrl === null ? '' : ` ${finding.commentUrl}`}
169    </Text>
170  )
171}
172
173function drawOftenDismissed(ui: ElementTable, rules: readonly OftenDismissedRule[]) {
174  const { Box, Text } = ui
175  return (
176    <Box flexDirection="column" marginTop={1}>
177      <Text dimColor>Clean Code rules you often dismiss — worth rewording or dropping:</Text>
178      {rules.map(rule => <Text key={`rule-${rule.rule}`} dimColor>{`  ${rule.rule}: ${rule.dismissed} of ${rule.raised} dismissed`}</Text>)}
179    </Box>
180  )
181}
182
183function drawSeverity(ui: ElementTable, finding: ReviewFinding) {
184  const { Text } = ui
185  return <Text color={SEVERITY_COLOURS[finding.severity]} bold={finding.severity === 'high'}>{`${SEVERITY_ICONS[finding.severity]} ${finding.severity}`}</Text>
186}
187
188function tagOf(finding: ReviewFinding): string {
189  if (finding.rule !== null) return finding.rule
190  return finding.category ?? sourceLabel(finding.source)
191}
192
hooks/tool-answers.ts 17 lines
1/** A registered tool's arguments arrive as fields of the call itself, beside `tool` and `tool_use_id`. */
2export function toolArguments<Arguments>(call: object): Arguments {
3  return call as Arguments
4}
5
6/**
7 * A tool hook that throws is skipped, and Claude then sees only "no hook answered", so every
8 * failure comes back as a refusal that says what went wrong.
9 */
10export async function answeredOrDenied(paneName: string, work: () => Promise<{ result: string } | { deny: string }>) {
11  try {
12    return await work()
13  } catch (error) {
14    return { deny: `The ${paneName} failed: ${error instanceof Error ? error.message : String(error)}` }
15  }
16}
17
hooks/restore-sessions/candidates.ts 112 lines
1import type { RestoreCandidate, RestoreVerdict } from '../../types'
2
3/** One entry of find_sessions.py's output. */
4export type FoundSession = {
5  id: string
6  cwd: string | null
7  title: string | null
8  lastActive: string
9  possiblyStillOpen: boolean
10  handoff: string | null
11  messages: { role: string; text: string }[]
12}
13
14export type Judgement = { verdict: RestoreVerdict; summary: string }
15
16const DEFAULT_HOURS = 24
17const WINDOW = /^\s*(\d+(?:\.\d+)?)\s*([hd]?)\s*$/i
18const JUDGEMENT_LINE = /^\s*([0-9a-f-]{36})\s*\|\s*(resume|done|unclear)\s*\|\s*(.+?)\s*$/i
19const VERDICT_ORDER: Record<RestoreVerdict, number> = { resume: 0, unclear: 1, done: 2 }
20
21/** `12h`, `3d` or a bare number of hours; anything else is the default day. */
22export function hoursIn(window: string | undefined): number {
23  const [, amount, unit] = (window ?? '').match(WINDOW) ?? []
24  if (amount === undefined) return DEFAULT_HOURS
25  return Number(amount) * (unit?.toLowerCase() === 'd' ? 24 : 1)
26}
27
28export function findSessionsArgv(python: string, scriptPath: string, hours: number, ownSessionId: string): string[] {
29  return [python, scriptPath, '--hours', String(hours), '--exclude', ownSessionId]
30}
31
32export function candidatesFrom(found: readonly FoundSession[]): RestoreCandidate[] {
33  return found
34    .filter(session => session.cwd !== null)
35    .map(session => ({
36      id: session.id,
37      cwd: session.cwd!,
38      label: session.title ?? lastFolderOf(session.cwd!),
39      lastActive: session.lastActive,
40      isPossiblyStillOpen: session.possiblyStillOpen,
41      handoffPath: session.handoff,
42      verdict: null,
43      summary: null,
44      isSelected: false,
45      reopen: 'not-yet',
46    }))
47}
48
49function lastFolderOf(path: string): string {
50  return path.split(/[\\/]/).filter(Boolean).pop() ?? path
51}
52
53/**
54 * The tail of a conversation is a weak signal and a small model reading it a weaker one, so the
55 * verdict only orders the list and pre-ticks the likely ones; the user still picks.
56 */
57export function judgementPrompt(found: readonly FoundSession[]): string {
58  const sessions = found.map(({ id, title, cwd, handoff, messages }) => ({ id, title, cwd, hasHandoff: handoff !== null, messages }))
59  return [
60    'Each entry below is a Claude Code session that was open before a restart; `messages` is the tail of its conversation. For each session decide:',
61    '- resume: work was in flight — a task partway through, a question waiting for an answer, a plan agreed but not carried out, a handoff written for a restart.',
62    '- done: the last exchange closes the work — merged, pushed and confirmed, a close-out check that came back clean, a question fully answered.',
63    '- unclear: not enough to tell.',
64    '',
65    'Return one line per session, exactly `<id> | <resume/done/unclear> | <what it was doing, under 12 words>`, and no other text.',
66    '',
67    JSON.stringify(sessions),
68  ].join('\n')
69}
70
71export function parseJudgements(reply: string): Map<string, Judgement> {
72  const judgements = new Map<string, Judgement>()
73  for (const line of reply.split(/\r?\n/)) {
74    const [, id, verdict, summary] = line.match(JUDGEMENT_LINE) ?? []
75    if (id !== undefined && verdict !== undefined && summary !== undefined) {
76      judgements.set(id, { verdict: verdict.toLowerCase() as RestoreVerdict, summary })
77    }
78  }
79  return judgements
80}
81
82/** Resume first, then unclear, then done; within each, the most recently active first, as found. */
83export function withJudgements(candidates: readonly RestoreCandidate[], judgements: ReadonlyMap<string, Judgement>): RestoreCandidate[] {
84  const judged = candidates.map(candidate => {
85    const judgement = judgements.get(candidate.id)
86    return judgement === undefined
87      ? candidate
88      : { ...candidate, verdict: judgement.verdict, summary: judgement.summary, isSelected: judgement.verdict === 'resume' }
89  })
90  return judged
91    .map((candidate, foundOrder) => ({ candidate, foundOrder }))
92    .sort((a, b) => verdictRank(a.candidate) - verdictRank(b.candidate) || a.foundOrder - b.foundOrder)
93    .map(({ candidate }) => candidate)
94}
95
96function verdictRank(candidate: RestoreCandidate): number {
97  return candidate.verdict === null ? VERDICT_ORDER.unclear : VERDICT_ORDER[candidate.verdict]
98}
99
100/** `-w 0` opens each as a tab of the current Windows Terminal window rather than a window apiece. */
101export function reopenArgv(candidate: RestoreCandidate): string[] {
102  return ['wt', '-w', '0', 'new-tab', '-d', candidate.cwd, '--title', candidate.label, 'claude', '--resume', candidate.id]
103}
104
105export function resumeCommand(candidate: RestoreCandidate): string {
106  return `cd "${candidate.cwd}" && claude --resume ${candidate.id}`
107}
108
109export function whenActive(lastActive: string): string {
110  return lastActive.replace('T', ' ').replace(/[+-]\d\d:\d\d$|Z$/, '')
111}
112
hooks/restore-sessions/pane.tsx 92 lines
1import type { ElementTable } from 'claude-code'
2
3import type { RestoreCandidate, RestoreRound } from '../../types'
4import { resumeCommand, whenActive } from './candidates'
5
6export type PaneActions = {
7  toggle: (id: string) => void
8  selectRecommended: () => void
9  selectAll: () => void
10  selectNone: () => void
11  reopen: () => void
12}
13
14const PHASE_LABELS = {
15  idle: 'Run /restore-sessions to look for sessions.',
16  finding: 'Reading transcripts…',
17  judging: 'Asking Haiku which were mid-work…',
18  ready: '',
19}
20
21export function drawPane(ui: ElementTable, round: RestoreRound, actions: PaneActions) {
22  const { Box, Text } = ui
23  if (round.error !== null) return <Text color="red">{round.error}</Text>
24  if (round.phase === 'idle' || round.phase === 'finding') return <Text dimColor>{PHASE_LABELS[round.phase]}</Text>
25  if (round.candidates.length === 0) {
26    return <Text dimColor>No sessions were active in the last {round.hours}h. Run /restore-sessions with a longer window, e.g. 3d.</Text>
27  }
28  return (
29    <Box flexDirection="column">
30      {drawHeader(ui, round, actions)}
31      {round.candidates.map(candidate => drawRow(ui, candidate, actions))}
32    </Box>
33  )
34}
35
36function drawHeader(ui: ElementTable, round: RestoreRound, actions: PaneActions) {
37  const { Box, Button, Text } = ui
38  const selectedCount = round.candidates.filter(candidate => candidate.isSelected && candidate.reopen === 'not-yet').length
39  return (
40    <Box flexDirection="column" marginBottom={1}>
41      <Text dimColor>
42        {round.candidates.length} sessions active in the last {round.hours}h. {PHASE_LABELS[round.phase]}
43      </Text>
44      {round.judgementProblem !== null && <Text color="yellow">No verdicts: {round.judgementProblem}</Text>}
45      <Box gap={1}>
46        <Button key="select-recommended" hotkey="r" onPress={actions.selectRecommended}>Recommended</Button>
47        <Button key="select-all" hotkey="a" onPress={actions.selectAll}>All</Button>
48        <Button key="select-none" hotkey="n" onPress={actions.selectNone}>None</Button>
49        {selectedCount > 0 && <Button key="reopen" hotkey="o" variant="primary" onPress={actions.reopen}>{`Reopen ${selectedCount}`}</Button>}
50      </Box>
51    </Box>
52  )
53}
54
55function drawRow(ui: ElementTable, candidate: RestoreCandidate, actions: PaneActions) {
56  const { Box, Button, Text } = ui
57  return (
58    <Box flexDirection="column">
59      {candidate.reopen === 'not-yet'
60        ? (
61          <Button key={`toggle-${candidate.id}`} plain onPress={() => actions.toggle(candidate.id)}>
62            {candidate.isSelected ? '[x] ' : '[ ] '}
63            {candidate.label} <Text dimColor>{rowDetails(candidate)}</Text>
64          </Button>
65        )
66        : <Text>{reopenMark(candidate)} {candidate.label} <Text dimColor>{reopenLabel(candidate)}</Text></Text>}
67      {candidate.summary !== null && <Text dimColor>    {candidate.summary}</Text>}
68      {candidate.handoffPath !== null && <Text dimColor>    handoff: {candidate.handoffPath}</Text>}
69      {candidate.reopen === 'failed' && <Text dimColor>    {resumeCommand(candidate)}</Text>}
70    </Box>
71  )
72}
73
74function rowDetails(candidate: RestoreCandidate): string {
75  return [
76    candidate.verdict,
77    whenActive(candidate.lastActive),
78    candidate.cwd,
79    candidate.isPossiblyStillOpen ? 'may still be open' : null,
80  ].filter(Boolean).join(' · ')
81}
82
83function reopenMark(candidate: RestoreCandidate): string {
84  return candidate.reopen === 'reopened' ? '✓' : '✗'
85}
86
87function reopenLabel(candidate: RestoreCandidate): string {
88  if (candidate.reopen === 'reopened') return 'reopened in a new tab'
89  if (candidate.reopen === 'missing-folder') return `its folder is gone: ${candidate.cwd}`
90  return 'could not open a tab — run this instead:'
91}
92
hooks/review-comments/decision-prompts.ts 66 lines
1import type { ThreadView } from './walk-order'
2import { locationOf } from './walk-order'
3
4const HOLD_UNTIL_PUSHED = 'Then mark it fixed-locally: do not reply on or resolve the thread, and do not commit or push, until I ask for the push.'
5const HOLD_ALL_UNTIL_PUSHED = 'Then mark each fixed-locally: do not reply on or resolve the threads, and do not commit or push, until I ask for the push.'
6
7export type Decision = { kind: 'fix' } | { kind: 'option'; optionKey: string } | { kind: 'push-back' }
8
9/**
10 * The prompt a sidebar button sends. Each starts `review-comments:` so the skill knows the person
11 * decided through the sidebar, and names threads by the numbers the sidebar shows.
12 */
13export function decisionPrompt(view: ThreadView, decision: Decision, settledToo: readonly ThreadView[]): string {
14  const label = `[${view.thread.number}] (${locationOf(view.thread)})`
15  const notes = [settlesTooNote(settledToo), behaviourOverrideNote(view)].filter(Boolean).join(' ')
16  const threadCount = 1 + settledToo.length
17
18  switch (decision.kind) {
19    case 'fix':
20      return joinSentences(`review-comments: fix ${label} as suggested.`, notes, holdUntilPushed(threadCount))
21    case 'option':
22      return joinSentences(`review-comments: for ${label}, go with ${optionLabel(view, decision.optionKey)}.`, notes, 'Make the change.', holdUntilPushed(threadCount))
23    case 'push-back':
24      return joinSentences(`review-comments: push back on ${label}: reply on the thread with the reasoning and leave it unresolved.`, settlesTooNote(settledToo))
25  }
26}
27
28export function quickFixPrompt(quickFixes: readonly ThreadView[]): string {
29  const numbers = quickFixes.map(view => `[${view.thread.number}]`).join(', ')
30  return joinSentences(`review-comments: fix ${numbers} as suggested (trivial, no behaviour change).`, HOLD_ALL_UNTIL_PUSHED)
31}
32
33/** Pressing the push button is the person asking for the commit and push the fixes were held for. */
34export function pushPrompt(readyToPush: readonly ThreadView[]): string {
35  const threads = readyToPush.map(view => `[${view.thread.number}] (${locationOf(view.thread)})`).join(', ')
36  return `review-comments: commit and push the fixes for ${threads}. Once the push succeeds, reply on each of those threads with what changed and the commit it is in, resolve them, and mark them fixed.`
37}
38
39export function discussionPrompt(view: ThreadView): string {
40  return `review-comments: about [${view.thread.number}] (${locationOf(view.thread)}): `
41}
42
43function optionLabel(view: ThreadView, optionKey: string): string {
44  return view.triage?.options?.find(option => option.key === optionKey)?.label ?? `option ${optionKey}`
45}
46
47function settlesTooNote(settledToo: readonly ThreadView[]): string {
48  if (settledToo.length === 0) return ''
49  return `This also settles ${settledToo.map(view => `[${view.thread.number}]`).join(', ')}, which share the root cause.`
50}
51
52function behaviourOverrideNote(view: ThreadView): string {
53  if (!view.isBehaviourFlagOverridden || view.triage === undefined) return ''
54  return view.triage.isBehaviourChange
55    ? 'I\'ve marked it as a behaviour change, so treat it as one.'
56    : 'I\'ve marked it as not changing behaviour, so keep the fix to that.'
57}
58
59function holdUntilPushed(threadCount: number): string {
60  return threadCount === 1 ? HOLD_UNTIL_PUSHED : HOLD_ALL_UNTIL_PUSHED
61}
62
63function joinSentences(...sentences: string[]): string {
64  return sentences.filter(Boolean).join(' ')
65}
66