Skills for Claude Code development workflows.

Skills for Claude Code development workflows.
claude plugin install vise-skills@vise-tools
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.
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.
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.
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.
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.
Run /self-review on your own branch before asking anyone else. Three reviewers go over the diff against its base:
skills/self-review/clean-code-rules.md (the book's Chapter 17 smells, by their ids, plus your code-style rules as V1–V5)code-review, run as it is/code-reviewTheir findings land in one pane. Tick findings, then choose:
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.
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.
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.
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.
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.
hooks/review-findings.tsx is the pane behind /self-review and /ext-review.
/code-review's findings on their way through the ReportFindings tool, while a review round is open. A re-report after fixes is skipped.findings_record.gh api …/reviews call with the review as JSON on stdin.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_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:
| Rule | Check |
|---|---|
| A comment is a private method waiting to be named | Non-doc comment indented inside a body |
| Names complete a sentence at the call site | Type-prefixed (strName) and placeholder (data, temp) names |
| A comment carries its own context | Comment 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.
hooks/register.tsx 25 lines1import 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}
25hooks/review-findings.tsx 364 lines1import { 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}
364hooks/restore-sessions.tsx 154 lines1import { 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}
154hooks/review-comments.tsx 473 lines1import { 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}
473hooks/shutdown.tsx 173 lines1import { 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}
173hooks/review-comments/github-threads.ts 134 lines1import 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}
134hooks/review-findings/findings.ts 252 lines1import 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}
252hooks/review-findings/pane.tsx 192 lines1import 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}
192hooks/tool-answers.ts 17 lines1/** 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}
17hooks/restore-sessions/candidates.ts 112 lines1import 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}
112hooks/restore-sessions/pane.tsx 92 lines1import 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}
92hooks/review-comments/decision-prompts.ts 66 lines1import 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