/second-opinion asks Fable (or the model you set) for a candid senior review of your recent commits, branch, uncommitted diff or a plan file, in the background…

Claude Code mods (function-hook plugins) I find helpful. Each folder is one plugin.
Requires a Claude Code build with function-hook plugins (2.1.289 or newer). machine-guard reads macOS tools (sysctl, memory_pressure, ioreg).
git clone https://github.com/joeldg/claude-mods ~/Projects/claude-mods
A pane of your project's running dev servers, so you don't have to ask Claude to restart them.
/servers opens the Servers pane:package.json dev/start/serve/preview scripts (run with your lockfile's package manager), .claude/launch.json and Procfile~/.claude/dev-servers/<project>/.Port 4000 is held by node (pid 123, up 2h, in /Users/me/other).servers: :4000 :5173.lsof and ps every 15s.Puts files you just downloaded into your prompt with one click.
~/Downloads (top level). When a new file arrives (PDF, Markdown, images, 3MF/STL/OBJ, zip, video…), a band appears above the prompt: New in Downloads: paper.pdf, model-b.3mf · 2m ago [Attach] [Dismiss].@"/Users/you/Downloads/paper.pdf" mentions in your prompt. Dismiss hides those files./downloads lists the 10 newest files, numbered. /downloads attach 1 3 (or 2-4) adds those, and /downloads clear dismisses everything new.Settings: folder (~/Downloads), extensions, pollSeconds (5), maxAgeMinutes (120).
Sets effort per message, so you don't have to switch it by hand.
avoidModel (a regex such as fable) to send those requests to fallbackModel instead, subagents included./route shows the last decision and the session's counts. /route off and /route on toggle it; /route deep and /route routine force the next turn.effort: low (routine).Settings: routineEffort (low), deepEffort (max), routinePattern, deepPattern, avoidModel, fallbackModel (opus), stickyTurns (2), freeSwitchTokens (30000), cacheTtlMinutes (60).
A Jobs pane for long-running work: training runs, downloads, extractions.
nohup … > log & launches by itself./watch <log> [label] adds any other log file./ and /Volumes/* (the NAS).jobs: 2 running · 1 stalled./jobs opens the pane, /unwatch <label|done|all> removes jobs.tail, checks processes with ps, and runs df.Settings (in /config): stall minutes (10), refresh seconds (10), how long finished jobs stay (120 min), auto-open (on), which disks to show.
Memory, swap and GPU on the status line. It refuses heavy local jobs when the Mac can't take them.
RAM tight 12% free · swap 7.9/8G · top python 31G · GPU 87%./busy.When memory is only tight, the job runs and Claude gets a note to start one heavy job at a time.
/busy 3h training a vision model reserves the Mac in every Claude session. /busy off lifts it. The reservation lives in ~/.claude/machine-guard.json, so a training script can write it too: ``bash echo '{"reason":"overnight training","until":'$(( ($(date +%s) + 8*3600) * 1000 ))'}' > ~/.claude/machine-guard.json ``/guard shows what it sees. /guard pause 15m lets heavy jobs through in this session; /guard on resumes the guard.modal run, ssh), tests (pytest) and installs are never treated as heavy.overnight_|nightly_run\.sh.Watches how the other mods behave in real use, without changing them. It is listed first in CLAUDE_CODE_PLUGIN_DIRS, so the other mods' hooks run beneath it.
next.trace), with the mod's name, the event and how long it ran. Slow hooks (over 1.5 s) are recorded too. The first failure of each mod in a session raises a toast./second-opinion, /recall ask) and file writes (folders only, never contents)./mods: a pane with one row per mod: ✓ active, ⚠ failing, ✗ not seen this session, · seen but idle. Each row shows today's counts and last activity, with Details for its recent events. It also says which mods it can't see, if any of them run above it./mods report [24h|7d|30d]: a per-mod report across all sessions, also written to ~/.claude/mods/monitor/report-latest.md for a scheduled review or Claude to read./mods failures [7d]: failures and failed subprocesses only.~/.claude/mods/monitor/<date>/<session>.jsonl, flushed every minute and at session end, with secrets masked and old days removed after 30 days.$.ui.log with wording like "failed" or "could not"): shown in Details and in /mods failures. Three in an hour mark the mod ⚠ and raise one toast. That is how effort-router's per-request hook, which runs inside the response stream where no monitor should sit, reports a failure. It also always sends the request on unchanged./secrets records there) are watched for failures and slow runs, but not counted per run.Settings: alerts (on), slowMs (1500), watchRender (on), watchCommands (on; off stops "mod-monitor" appearing beside other mods' command output), watchAppend (on), retentionDays (30), flushSeconds (60).
Keeps an eye on Modal so idle GPU containers don't burn credits.
Modal: 1 running (2 containers). Deployed apps with no containers cost nothing, so they stay off it.alertMinutes (30), repeated at most every 30 minutes./modal opens a pane of apps with state, containers and uptime. Stop asks for Confirm, then runs modal app stop. Nothing is stopped any other way.budgetToday where the Modal CLI supports billing report (1.3.3+, Team/Enterprise workspaces). Otherwise /modal says why spend isn't shown.modal or python3 -m modal. It checks PATH first rather than running a command that can only fail, and stays silent when Modal isn't set up.Does the "merged #219, clean up branches and start #214" round trip for you, and surfaces CI failures with their logs.
gh pr create Claude runs. It polls gh pr view every 60s.PRs: #219 ✓ · #220 CI… · #221 ✗. Toasts when CI fails (with the failing check names) or passes.git fetch --prune, switch to the default branch (only from the PR's own branch) and git pull --ff-only.--force, never other branches.gh pr checks and the tail of the failed log attached, so you don't paste it./prs lists watched PRs. /prs watch <n|url> and /prs forget <n|all> add and remove them.gh and git, at about one GitHub API call per open PR per minute.Settings:
pollSeconds (60)attachCiLogs (on)logLines (120)deleteRemoteBranch (off): deletes the branch on GitHub too, only while it still points at the merged commit. GitHub's own "Automatically delete head branches" setting does the same job.It never closes issues; put "Closes #N" in PR bodies for that.
Search everything you've done with coding agents, from Claude or from /recall. It replaces the broken agent-memory plugin.
/remember notes. Routine (scheduled) runs are left out unless you add routines:include to a query.grep and cat are kept but ranked low.search, expand, recap and list, which run without permission prompts. It checks them when you say "like last time" or "what did we decide", and before asking you something you already settled./recall <query> opens a pane of hits grouped by session. Open shows the conversation around a hit, Attach sends it with your next message, and Copy resume command copies claude --resume <id>./recall last [n] recaps your last session in this repo: last asks, last answer, PRs, commits, open tasks and decisions. Send to Claude attaches it./recall timeline [7d|30d|90d] [all]/recall decisions|commands|files|prs|commits|issues|urls|tasks|notes [query]/recall ask <question> answers from your history with Haiku 4.5, citing sessions. It costs a little usage and sends the matching excerpts to the model./recall stats, /recall reindex, /recall forget session <id>|project <name>|before <date> (asks you to confirm), /recall help./remember <fact>, /remember list, /remember forget <ref>.Last session here (2d ago): "…" · PR #99 · 3 open tasks [Recap].#214, ABC-12, a file name or a quoted phrase seen in past sessions, a band offers what happened then. Nothing is sent unless you click.OR gives alternatives, "quotes" an exact phrase, and -word excludes. Filters: project:name, kind:decision, since:7d, until:2026-09-30, source:codex, routines:include.~/.claude/recall/index.db, readable only by you, and never goes in a repo.~/.zshrc, ~/.zprofile, ~/.bashrc and ~/.bash_profile, and any literal strings you list in ~/.claude/recall/redact.txt (one per line). Editing that list re-masks the existing index on the next update./recall ask. The first index takes about 2 minutes in the background, with progress on the status line. After that it updates incrementally (about 1s) at session start and every 10 minutes./usr/bin/python3 (Command Line Tools), whose SQLite has FTS5. Nothing else to install.Settings: dbPath, python, sources, includeSubagents (on), includeRoutines (off), updateMinutes (10), relatedBand (on), lastSessionBand (on), maxResults (8), askModel (claude-haiku-4-5-20251001).
Catches Claude up on the repo when a session starts, so you don't have to ask "check the recent commits/PRs and issues".
owner, todo, P0 or blockedmain ↑1 · 3 changed · PRs #123 ✗ #124 ✓ · 2 owner issues · 2 stale branches · last commit 2h ago. Hide dismisses it./brief re-gathers now and prints the full summary.git and gh. The band refreshes after a turn at most every 2 minutes.Settings: focus labels, refresh minutes, and whether to brief Claude.
Keeps scheduled routines (daily digests, newsletters) from silently stalling while you're away.
AskUserQuestion, you get a Mac notification and a toast, and the status line shows routine: daily-report · waiting on you 3m.Routine daily-report finished after 23m · waited on you 2 times.notifyCommand runs a command on the same events, e.g. curl -s -d {message} ntfy.sh/your-topic. {title} and {message} are filled in as single arguments, never through a shell.allowWebReads (off by default): lets routines use WebFetch and WebSearch without asking. It only replaces a prompt; your deny rules still apply, and nothing else is ever auto-allowed./routine shows the routine's name, how long it has run, its waits, and the settings.A Fable review in the background, without switching your session's model. Each run is one Fable call against your usage.
/second-opinion: reviews recent work. On a feature branch that's the branch against the default branch; otherwise the last 12 commits, plus the diff and git status, capped at 60k characters./second-opinion commits 5/second-opinion diff (uncommitted changes)/second-opinion file docs/ADR-007.md/second-opinion <question>: adds a question for Fable to answer first.second opinion: reviewing…. When the review is ready you get a toast, and a pane opens with it, ranked: wrong assumptions, bugs and risks, what's missing, what to do next.~/.claude/second-opinions/<project>/. /second-opinion list lists them, and /second-opinion show [n] reopens one.Settings: model (claude-fable-5-1), effort (high), maxContextChars (60000).
Keeps your "always / never / don't / from now on" instructions alive across compaction.
Keep as a standing order? [Project] [This session] [No]. Nothing is saved without a click.~/.claude/standing-orders/<repo>.json and apply to every session in that repo. Session orders and your active /goal last for the session./clear, so the prompt cache isn't disturbed. A newly saved order also rides along once with your next message./orders lists them. /orders add [project|session] <text>, /orders forget <n>, /orders clear session|project, and /orders export (a Markdown block for CLAUDE.md).Stops keys and passwords from going into a prompt, and so into your transcripts, and turns them into env vars instead.
$NAME references, placeholders, plain URLs, paths, git SHAs and ordinary prose about passwords.…vxrm) with a suggested name such as OPENDATALAB_SECRET_ACCESS_KEY, which you can edit:export NAME='…' to ~/.zshrc (reusing an existing identical export) and replaces the secret in your prompt with $NAME./secrets test <text> output is masked too./secrets test <text> shows what would be caught. /secrets off and /secrets on toggle it for the session.Settings: enabled (on), extraPatterns (a regex), zshrcPath (~/.zshrc).
Makes Claude's open commands hand 3D files to the right slicer.
full.?spectrum|snapmaker-only|-fs\.3mf$|-u1[-.], orAn open -a BambuStudio … for one becomes open -b com.snapmaker.snapmaker-orca …, with the rest of the command untouched. You get a toast, and Claude gets a note so it doesn't try again.
/slice <file> [bambu|snapmaker|orca] opens a file yourself, with the same rules.open -a <app>, open -a /Applications/X.app and open -b <bundle id>, including variables set earlier in the command (S=… && open -a BambuStudio "$S/x.3mf") and files copied in the same command.Settings:
closePrevious (on): turn it off if you keep your own slicer window open, since the quit request reaches your windows too.fullSpectrumPattern (the regex above)checkContents (on)The quit request goes out when Claude issues the command, before any permission prompt for it.
--plugin-dir once per mod, e.g. claude --plugin-dir ~/Projects/claude-mods/job-watch --plugin-dir ~/Projects/claude-mods/pr-autopilot~/.claude/settings.json. Put mod-monitor first so it sees the others; CLAUDE_CODE_PLUGIN_DIR_WATCH makes desktop sessions pick up edits and show mod failures: ``json { "env": { "CLAUDE_CODE_PLUGIN_DIR_WATCH": "1", "CLAUDE_CODE_PLUGIN_DIRS": "~/Projects/claude-mods/mod-monitor:~/Projects/claude-mods/job-watch:~/Projects/claude-mods/machine-guard:~/Projects/claude-mods/repo-brief:~/Projects/claude-mods/slicer-handoff:~/Projects/claude-mods/pr-autopilot:~/Projects/claude-mods/routine-watch:~/Projects/claude-mods/modal-meter:~/Projects/claude-mods/second-opinion:~/Projects/claude-mods/downloads-drop:~/Projects/claude-mods/dev-servers:~/Projects/claude-mods/standing-orders:~/Projects/claude-mods/effort-router:~/Projects/claude-mods/secret-guard:~/Projects/claude-mods/recall" } } ``Run with Claude Code 2.1.289 or newer; older CLIs ignore per-test settings, so a few tests fall back to defaults.
claude plugin validate job-watch && claude plugin test job-watch
claude plugin validate machine-guard && claude plugin test machine-guard
claude plugin validate repo-brief && claude plugin test repo-brief
claude plugin validate slicer-handoff && claude plugin test slicer-handoff
claude plugin validate pr-autopilot && claude plugin test pr-autopilot
claude plugin validate routine-watch && claude plugin test routine-watch
claude plugin validate modal-meter && claude plugin test modal-meter
claude plugin validate second-opinion && claude plugin test second-opinion
claude plugin validate downloads-drop && claude plugin test downloads-drop
claude plugin validate dev-servers && claude plugin test dev-servers
claude plugin validate standing-orders && claude plugin test standing-orders
claude plugin validate effort-router && claude plugin test effort-router
claude plugin validate secret-guard && claude plugin test secret-guard
claude plugin validate recall && claude plugin test recall
claude plugin validate mod-monitor && claude plugin test mod-monitor
(cd recall/engine && /usr/bin/python3 -m unittest)hooks/register.tsx 558 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelCompleteResult, ModelEffort, Register } from 'claude-code'
3
4import type { Opinion, OpinionRun } from '../types'
5import {
6 DEFAULT_COUNT,
7 DEFAULT_EFFORT,
8 DEFAULT_MAX_CHARS,
9 DEFAULT_MODEL,
10 attachmentBlock,
11 baseName,
12 buildPrompt,
13 chunkMarkdown,
14 commits,
15 configFrom,
16 countFiles,
17 describeSaved,
18 failureReason,
19 fillText,
20 modelLabel,
21 parseArgs,
22 parseSaved,
23 pickDefaultBranch,
24 projectKey,
25 resolvePath,
26 savedText,
27 stampOf,
28 timeOfStamp,
29 whenOf,
30} from './review'
31import type { Config, ReviewRequest, Section } from './review'
32
33type Engine = EngineInterface
34
35const COMMAND = 'second-opinion'
36const PANE = 'second-opinion'
37const TITLE = 'Second opinion'
38const STATUS = 'second opinion: reviewing…'
39/** The reply's cap: room for the reviewer's thinking and a full review. */
40const MAX_TOKENS = 32_000
41/** A backstop on the one call; a provider ends a request this long at ten minutes anyway. */
42const TIMEOUT_MS = 12 * 60_000
43const GIT_MS = 30_000
44/** `git log` per commit: hash, date, author and subject, then the body. */
45const LOG_FORMAT = '%h %ad %an: %s%n%b'
46/** The empty tree, to diff a repository's first commit against when `git hash-object` cannot say. */
47const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
48/** Prompts the person wrote (typed, through Remote Control, or an SDK host's own turn). */
49const PERSONAL = new Set(['composer', 'bridge', 'sdk'])
50/** How many saved reviews `/second-opinion list` reads. */
51const LIST_LIMIT = 20
52
53const current = atom({ plugin: 'second-opinion', key: 'current' } as const, null)
54const running = atom({ plugin: 'second-opinion', key: 'running' } as const, null)
55const armed = atom({ plugin: 'second-opinion', key: 'armed' } as const, null)
56
57let config: Config = { model: DEFAULT_MODEL, effort: DEFAULT_EFFORT, maxChars: DEFAULT_MAX_CHARS }
58/** True while this environment's review runs; a reload drops it with the call, unlike `running`. */
59let isReviewing = false
60
61type Ran = { ok: boolean; out: string; err: string; started: boolean }
62
63/** The context gathered for one review. */
64type Gathered = {
65 root: string
66 branch: string | null
67 /** What is under review, in words, for the prompt and the saved file. */
68 subject: string
69 /** The same, short, for the command's answer: "12 commits". */
70 short: string
71 sections: Section[]
72}
73
74/** A review that could not start, and why. */
75type Failure = { error: string }
76
77/** Everything the background half of a review needs. */
78type Job = OpinionRun & {
79 effort: ModelEffort
80 focus: string
81 prompt: string
82 root: string
83 home: string | undefined
84}
85
86const usage = (): string => {
87 const label = modelLabel(config.model)
88 return [
89 'Usage: /second-opinion [what]',
90 ' (nothing) your branch against the default branch, or the last 12 commits',
91 ' commits N the last N commits',
92 ' diff the uncommitted changes',
93 ' file <path> a plan, spec or ADR',
94 ' <question> the default review, focused on your question',
95 ' show [n] reopen the last review (or the nth from list)',
96 ' list the reviews saved for this project',
97 `Each review is one ${label} call (${config.model}, effort ${config.effort}), billed to your usage.`,
98 ].join('\n')
99}
100
101const messageOf = (error: unknown): string => (error instanceof Error ? error.message : String(error))
102
103async function git($: Engine, cwd: string, args: readonly string[]): Promise<Ran> {
104 try {
105 const out = await $.process.run(['git', ...args], { cwd, timeoutMs: GIT_MS })
106 return { ok: out.exitCode === 0, out: out.stdout, err: out.stderr, started: true }
107 } catch (error) {
108 return { ok: false, out: '', err: messageOf(error), started: false }
109 }
110}
111
112/** The repository's top folder, or null outside one; `started` false when git itself could not run. */
113async function repoRoot($: Engine, cwd: string): Promise<Ran> {
114 return git($, cwd, ['rev-parse', '--show-toplevel'])
115}
116
117async function defaultBranch($: Engine, root: string): Promise<string | null> {
118 const symbolic = await git($, root, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])
119 if (symbolic.ok && symbolic.out.trim()) {
120 return pickDefaultBranch(symbolic.out, null)
121 }
122 const candidates = await git($, root, [
123 'for-each-ref',
124 '--format=%(refname:short)',
125 'refs/remotes/origin/main',
126 'refs/remotes/origin/master',
127 'refs/heads/main',
128 'refs/heads/master',
129 ])
130 return pickDefaultBranch(null, candidates.ok ? candidates.out : null)
131}
132
133const statusSection = (status: string): Section => ({
134 tag: 'git_status',
135 label: 'Working tree (git status --short; uncommitted changes are listed, not shown)',
136 body: status.trim() || '(clean)',
137})
138
139/** The default review: the branch's commits and diff against the default branch, or the last N commits. */
140async function gatherWork($: Engine, request: ReviewRequest, root: string, branch: string | null, status: string) {
141 const base = request.kind === 'work' ? await defaultBranch($, root) : null
142 const baseLocal = base?.replace(/^origin\//, '') ?? null
143 if (base && branch && branch !== baseLocal) {
144 const ahead = Number((await git($, root, ['rev-list', '--count', `${base}..HEAD`])).out.trim()) || 0
145 const mergeBase = ahead > 0 ? (await git($, root, ['merge-base', base, 'HEAD'])).out.trim() : ''
146 if (ahead > 0 && mergeBase) {
147 const [log, diff] = await Promise.all([
148 git($, root, ['log', `-${DEFAULT_COUNT}`, '--stat', '--no-color', '--date=short', `--format=${LOG_FORMAT}`, `${base}..HEAD`]),
149 git($, root, ['diff', '--no-color', '--no-ext-diff', `${mergeBase}...HEAD`]),
150 ])
151 const shown = ahead > DEFAULT_COUNT ? `the newest ${DEFAULT_COUNT} of ${ahead}` : commits(ahead)
152 return {
153 subject: `the branch ${branch}, ${commits(ahead)} ahead of ${base}`,
154 short: `${commits(ahead)} on ${branch}`,
155 sections: [
156 { tag: 'git_log', label: `Commits on ${branch}, newest first (${shown}, git log --stat)`, body: log.out },
157 { tag: 'git_diff', label: `The branch's diff against ${base} (git diff ${mergeBase.slice(0, 12)}...HEAD)`, body: diff.out },
158 statusSection(status),
159 ],
160 }
161 }
162 }
163
164 const total = Number((await git($, root, ['rev-list', '--count', 'HEAD'])).out.trim()) || 0
165 const wanted = request.kind === 'commits' ? request.count : DEFAULT_COUNT
166 const count = total > 0 ? Math.min(wanted, total) : wanted
167 let from = `HEAD~${count}`
168 const isAll = total > 0 && count >= total
169 if (isAll) {
170 const empty = await git($, root, ['hash-object', '-t', 'tree', '/dev/null'])
171 from = (empty.ok && empty.out.trim()) || EMPTY_TREE
172 }
173 const [log, diff] = await Promise.all([
174 git($, root, ['log', `-${count}`, '--stat', '--no-color', '--date=short', `--format=${LOG_FORMAT}`]),
175 git($, root, ['diff', '--no-color', '--no-ext-diff', from, 'HEAD']),
176 ])
177 const where = branch ?? 'a detached HEAD'
178 return {
179 subject: count === 1 ? `the last commit on ${where}` : `the last ${count} commits on ${where}`,
180 short: commits(count),
181 sections: [
182 { tag: 'git_log', label: `The last ${commits(count)}, newest first (git log -${count} --stat)`, body: log.out },
183 {
184 tag: 'git_diff',
185 label: `Their combined diff (git diff ${isAll ? 'from the empty tree' : from} to HEAD)`,
186 body: diff.out,
187 },
188 statusSection(status),
189 ],
190 }
191}
192
193/** Reads what the request names, in the session's folder; a Failure says why there is nothing to review. */
194async function gather($: Engine, request: ReviewRequest, cwd: string, home: string | undefined): Promise<Gathered | Failure> {
195 const top = await repoRoot($, cwd)
196 if (!top.started && request.kind !== 'file') {
197 return { error: `second-opinion: git could not run (${top.err}), so there is nothing to review.` }
198 }
199
200 if (request.kind === 'file') {
201 const path = resolvePath(request.path, cwd, home)
202 let text: string
203 try {
204 const read = await $.fs.read(path)
205 text = typeof read === 'string' ? read : ''
206 } catch (error) {
207 return { error: `second-opinion: could not read ${request.path}: ${messageOf(error)}` }
208 }
209 if (!text.trim()) {
210 return { error: `second-opinion: ${request.path} is empty, so there is nothing to review.` }
211 }
212 const sections: Section[] = [{ tag: 'document', label: `The document under review (${request.path})`, body: text }]
213 let branch: string | null = null
214 if (top.ok) {
215 const root = top.out.trim()
216 const [branchRan, log, status] = await Promise.all([
217 git($, root, ['branch', '--show-current']),
218 git($, root, ['log', '-8', '--no-color', '--date=short', '--format=%h %ad %s']),
219 git($, root, ['status', '--short']),
220 ])
221 branch = branchRan.out.trim() || null
222 if (log.ok && log.out.trim()) {
223 sections.push({ tag: 'git_log', label: 'Recent commits in the repository, for orientation (git log -8)', body: log.out })
224 }
225 sections.push(statusSection(status.out))
226 }
227 return { root: top.ok ? top.out.trim() : cwd, branch, subject: request.path, short: request.path, sections }
228 }
229
230 if (!top.ok) {
231 return {
232 error: `second-opinion: ${cwd} is not inside a git repository. /second-opinion file <path> reviews a plan or spec without one.`,
233 }
234 }
235 const root = top.out.trim() || cwd
236 const [branchRan, head, statusRan] = await Promise.all([
237 git($, root, ['branch', '--show-current']),
238 git($, root, ['rev-parse', '--verify', '--quiet', 'HEAD']),
239 git($, root, ['status', '--short']),
240 ])
241 const branch = branchRan.out.trim() || null
242 const status = statusRan.out
243
244 if (request.kind === 'diff') {
245 const diff = await git($, root, ['diff', '--no-color', '--no-ext-diff', ...(head.ok ? ['HEAD'] : ['--cached'])])
246 if (!diff.out.trim() && !status.trim()) {
247 return { error: 'second-opinion: there are no uncommitted changes to review.' }
248 }
249 const log = head.ok ? await git($, root, ['log', '-5', '--no-color', '--date=short', '--format=%h %ad %s']) : null
250 const files = countFiles(status)
251 return {
252 root,
253 branch,
254 subject: `the uncommitted changes on ${branch ?? 'a detached HEAD'} (${files} file${files === 1 ? '' : 's'})`,
255 short: 'the uncommitted changes',
256 sections: [
257 { tag: 'git_status', label: 'Changed files (git status --short; ?? files are new and not in the diff)', body: status.trim() || '(none)' },
258 { tag: 'git_diff', label: `The uncommitted diff (git diff ${head.ok ? 'HEAD' : '--cached'})`, body: diff.out },
259 ...(log?.out.trim() ? [{ tag: 'git_log', label: 'The last commits, for orientation (git log -5)', body: log.out }] : []),
260 ],
261 }
262 }
263
264 if (!head.ok) {
265 return { error: 'second-opinion: this repository has no commits yet; /second-opinion diff reviews the uncommitted changes.' }
266 }
267 return { root, branch, ...(await gatherWork($, request, root, branch, status)) }
268}
269
270/** Where this project's reviews are saved: `~/.claude/second-opinions/<project-key>`. */
271const folderOf = (home: string, root: string): string =>
272 `${home.replace(/\/+$/, '')}/.claude/second-opinions/${projectKey(root)}`
273
274/** This project's folder of saved reviews and their file names, newest first. */
275async function savedFiles($: Engine): Promise<{ dir: string | null; names: string[] }> {
276 const home = await $.env.get('HOME')
277 if (!home) {
278 return { dir: null, names: [] }
279 }
280 const cwd = await $.session.cwd()
281 const top = await repoRoot($, cwd)
282 const dir = folderOf(home, top.ok ? top.out.trim() || cwd : cwd)
283 const entries = await $.fs.list(dir).catch(() => [])
284 const names = entries
285 .filter(entry => entry.kind === 'file' && timeOfStamp(entry.name) !== null)
286 .map(entry => entry.name)
287 .sort()
288 .reverse()
289 return { dir, names }
290}
291
292async function listSaved($: Engine): Promise<string> {
293 const { dir, names } = await savedFiles($)
294 if (dir === null) {
295 return 'second-opinion: HOME is not set, so the saved reviews cannot be found.'
296 }
297 const entries: { createdAt: number; subject: string }[] = []
298 for (const name of names.slice(0, LIST_LIMIT)) {
299 const text = await $.fs.read(`${dir}/${name}`).catch(() => '')
300 const opinion = parseSaved(typeof text === 'string' ? text : '', `${dir}/${name}`)
301 entries.push({ createdAt: opinion.createdAt, subject: opinion.subject })
302 }
303 const more = names.length > LIST_LIMIT ? `\n(${names.length - LIST_LIMIT} older ones are in the folder.)` : ''
304 return describeSaved(entries, dir) + more
305}
306
307async function show($: Engine, index: number): Promise<string> {
308 let opinion = index === 1 ? await read($, current) : null
309 if (!opinion) {
310 const { dir, names } = await savedFiles($)
311 const name = names[index - 1]
312 if (dir !== null && name !== undefined) {
313 const path = `${dir}/${name}`
314 const text = await $.fs.read(path).catch(() => null)
315 if (typeof text === 'string') {
316 opinion = parseSaved(text, path)
317 const loaded = opinion
318 await update($, current, () => loaded)
319 }
320 }
321 }
322 if (!opinion) {
323 const run = await read($, running)
324 if (run && isReviewing) {
325 return `${modelLabel(run.model)} is still reviewing ${run.subject}; it opens here when it is ready.`
326 }
327 return index === 1
328 ? 'No second opinion yet for this project. /second-opinion asks for one.'
329 : `There is no saved second opinion #${index} for this project. /second-opinion list shows them.`
330 }
331 const opened = await $.ui.open({ id: PANE, title: TITLE, focus: true }).catch((error: unknown) => ({
332 isPlaced: false as const,
333 reason: messageOf(error),
334 }))
335 const where = opinion.path ? ` (${opinion.path})` : ''
336 const said = `the second opinion on ${opinion.subject} from ${whenOf(opinion.createdAt)}${where}`
337 return opened.isPlaced ? `Showing ${said}.` : `The pane could not be shown here (${opened.reason}); ${said} is saved.`
338}
339
340/** Starts a review: gathers the context now, makes the one model call in the background, answers at once. */
341async function start($: Engine, request: ReviewRequest): Promise<string> {
342 const busy = await read($, running)
343 if (busy && isReviewing) {
344 const seconds = Math.round(((await $.clock.now()) - busy.startedAt) / 1000)
345 return `${modelLabel(busy.model)} is still reviewing ${busy.subject} (started ${seconds}s ago); one review runs at a time.`
346 }
347 if (busy) {
348 // Left by an environment a reload replaced mid-review: that call ended with it.
349 await update($, running, () => null)
350 $.ui.status(undefined)
351 }
352
353 const cwd = await $.session.cwd()
354 const home = await $.env.get('HOME')
355 const gathered = await gather($, request, cwd, home)
356 if ('error' in gathered) {
357 $.ui.toast(gathered.error, { timeoutMs: 8_000 })
358 return gathered.error
359 }
360
361 const { prompt, cut } = buildPrompt({
362 project: baseName(gathered.root),
363 branch: gathered.branch,
364 subject: gathered.subject,
365 focus: request.focus,
366 sections: gathered.sections,
367 maxChars: config.maxChars,
368 })
369 const job: Job = {
370 model: config.model,
371 effort: config.effort,
372 subject: gathered.subject,
373 startedAt: await $.clock.now(),
374 focus: request.focus,
375 prompt,
376 root: gathered.root,
377 home,
378 }
379 isReviewing = true
380 await update($, running, () => ({ model: job.model, subject: job.subject, startedAt: job.startedAt }))
381 $.ui.status(STATUS)
382 // A timer, not this command's dispatch, carries the call: it runs on after the command has answered.
383 $.clock.after(0, () => {
384 void finish($, job).catch((error: unknown) => $.ui.log(`second-opinion: ${messageOf(error)}`, { to: 'debug' }))
385 })
386
387 const label = modelLabel(job.model)
388 const trimmed = cut.length > 0 ? ` (cut to fit ${config.maxChars.toLocaleString('en-US')} characters: ${cut.join('; ')})` : ''
389 return `${label} is reviewing ${gathered.short} (one ${label} call)… It opens in a pane when it is ready; /second-opinion show reopens it.${trimmed}`
390}
391
392async function save($: Engine, job: Job, opinion: Opinion, result: ModelCompleteResult): Promise<string | null> {
393 if (!job.home) {
394 return null
395 }
396 const path = `${folderOf(job.home, job.root)}/${stampOf(opinion.createdAt)}.md`
397 const tokens = { input: result.usage.input_tokens, output: result.usage.output_tokens }
398 try {
399 await $.fs.write(path, savedText(opinion, baseName(job.root), tokens))
400 return path
401 } catch (error) {
402 $.ui.log(`second-opinion: could not save ${path}: ${messageOf(error)}`, { to: 'debug' })
403 return null
404 }
405}
406
407/** The background half: the model call, then the file, the toast and the pane. */
408async function finish($: Engine, job: Job) {
409 const label = modelLabel(job.model)
410 try {
411 let result: ModelCompleteResult | null = null
412 let refusal = ''
413 try {
414 result = await $.model.complete({
415 model: job.model,
416 prompt: job.prompt,
417 effort: job.effort,
418 maxTokens: MAX_TOKENS,
419 timeoutMs: TIMEOUT_MS,
420 })
421 } catch (error) {
422 refusal = messageOf(error)
423 }
424 const text = result?.isAnswered ? result.text.trim() : ''
425 if (result === null || !text) {
426 const why = result === null ? `the request was refused: ${refusal}` : failureReason(result)
427 $.ui.toast(`Second opinion failed: ${why}`, { timeoutMs: 10_000 })
428 $.ui.log(`second-opinion: ${label} could not review ${job.subject}: ${why}. Nothing was saved.`)
429 return
430 }
431 const opinion: Opinion = {
432 text,
433 model: job.model,
434 effort: job.effort,
435 subject: job.subject,
436 focus: job.focus,
437 createdAt: await $.clock.now(),
438 path: null,
439 }
440 const path = await save($, job, opinion, result)
441 const done: Opinion = { ...opinion, path }
442 await update($, current, () => done)
443 $.ui.toast(
444 path ? 'Second opinion ready (/second-opinion show)' : 'Second opinion ready (/second-opinion show); it could not be saved',
445 { timeoutMs: 8_000 },
446 )
447 await $.ui.open({ id: PANE, title: TITLE }).catch(() => undefined)
448 } finally {
449 isReviewing = false
450 await update($, running, () => null)
451 $.ui.status(undefined)
452 }
453}
454
455/** Send to Claude: the review rides along with the person's next prompt, once, and the box is filled. */
456async function sendToClaude($: Engine) {
457 const opinion = await read($, current)
458 if (!opinion) {
459 return
460 }
461 await update($, armed, () => attachmentBlock(opinion))
462 const filled = await $.prompt.fill({ text: fillText(opinion.model) }).catch(() => null)
463 $.ui.toast(
464 filled?.isFilled
465 ? 'The review is attached to your next prompt'
466 : 'The review is attached to your next prompt; write it and press Enter',
467 )
468}
469
470export const register: Register = (on, options) => {
471 config = configFrom(options)
472 isReviewing = false
473
474 on('session.start', async ($, e, next) => {
475 const label = modelLabel(config.model)
476 await $.command.register({
477 name: COMMAND,
478 description: `Ask ${label} for a candid review of recent work, in the background (one ${label} call per run, billed to your usage)`,
479 argumentHint: '[commits N | diff | file <path> | show [n] | list | <question>]',
480 immediate: true,
481 })
482 return next(e)
483 })
484
485 on('command.run', { command: COMMAND }, async ($, e) => {
486 const request = parseArgs(e.args)
487 try {
488 switch (request.kind) {
489 case 'help':
490 return { text: usage() }
491 case 'usage':
492 return { text: `${request.message}\n\n${usage()}` }
493 case 'list':
494 return { text: await listSaved($) }
495 case 'show':
496 return { text: await show($, request.index) }
497 default:
498 return { text: await start($, request) }
499 }
500 } catch (error) {
501 const text = `second-opinion: ${messageOf(error)}`
502 $.ui.toast(text, { timeoutMs: 8_000 })
503 return { text }
504 }
505 })
506
507 // Hands Claude the review armed by Send to Claude, with the person's next prompt only.
508 on('prompt.submit', async ($, e, next) => {
509 if (!PERSONAL.has(e.origin.kind) || (await read($, armed)) === null) {
510 return next(e)
511 }
512 const taken: { block: string | null } = { block: null }
513 await update($, armed, value => {
514 taken.block = value
515 return null
516 })
517 return taken.block ? next({ ...e, context: [...(e.context ?? []), taken.block] }) : next(e)
518 })
519
520 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
521 const { Box, Button, Markdown, Text } = $.ui.resolve(e)
522 const opinion = await read($, current)
523 const run = await read($, running)
524 const isArmed = (await read($, armed)) !== null
525 const busy = run ? (
526 <Text dimColor wrap="truncate-end">
527 {modelLabel(run.model)} is reviewing {run.subject}…
528 </Text>
529 ) : null
530
531 if (!opinion) {
532 return (
533 <Box flexDirection="column">
534 {busy ?? <Text dimColor>No second opinion yet. /second-opinion asks for one.</Text>}
535 </Box>
536 )
537 }
538 const focus = opinion.focus ? ` · asked: ${opinion.focus.replace(/\s+/g, ' ')}` : ''
539 return (
540 <Box flexDirection="column" gap={1}>
541 {busy}
542 <Text dimColor wrap="truncate-end">
543 {modelLabel(opinion.model)} on {opinion.subject} · {whenOf(opinion.createdAt)}
544 {focus}
545 </Text>
546 <Box flexDirection="row" gap={1}>
547 <Button key="send" label="Send to Claude" variant="primary" onPress={() => sendToClaude($)} />
548 <Button key="close" label="Close" role="dismiss" onPress={() => $.ui.close({ id: PANE })} />
549 </Box>
550 {isArmed && <Text dimColor>Attached to your next prompt.</Text>}
551 {chunkMarkdown(opinion.text).map((chunk, i) => (
552 <Markdown key={`review-${i}`} text={chunk} />
553 ))}
554 </Box>
555 )
556 })
557}
558hooks/review.ts 403 lines1import type { ModelCompleteResult, ModelEffort, PluginOptions } from 'claude-code'
2
3import type { Opinion } from '../types'
4
5/** Claude Fable 5.1, the reviewer unless the person picks another model. */
6export const DEFAULT_MODEL = 'claude-fable-5-1'
7export const DEFAULT_EFFORT: ModelEffort = 'high'
8export const DEFAULT_MAX_CHARS = 60_000
9/** How many commits the default review and a bare `commits` look at. */
10export const DEFAULT_COUNT = 12
11export const MAX_COUNT = 100
12/** The longest focus question passed on. */
13export const FOCUS_MAX = 4_000
14/** One `Markdown` element draws at most 10000 characters; a review is drawn in pieces under that. */
15export const MARKDOWN_LIMIT = 9_000
16
17const EFFORTS: readonly ModelEffort[] = ['low', 'medium', 'high', 'xhigh', 'max']
18
19export type Config = { model: string; effort: ModelEffort; maxChars: number }
20
21/** The plugin's options as `register` receives them, with the defaults filled in and odd values set right. */
22export function configFrom(options: PluginOptions): Config {
23 const model = typeof options.model === 'string' && options.model.trim() ? options.model.trim() : DEFAULT_MODEL
24 const effort = EFFORTS.find(level => level === options.effort) ?? DEFAULT_EFFORT
25 const chars = Number(options.maxContextChars ?? DEFAULT_MAX_CHARS)
26 const maxChars = Number.isFinite(chars) ? Math.round(Math.min(1_000_000, Math.max(2_000, chars))) : DEFAULT_MAX_CHARS
27 return { model, effort, maxChars }
28}
29
30/** What `/second-opinion [what]` asks for. */
31export type Request =
32 | { kind: 'work'; focus: string }
33 | { kind: 'commits'; count: number; focus: string }
34 | { kind: 'diff'; focus: string }
35 | { kind: 'file'; path: string; focus: string }
36 | { kind: 'show'; index: number }
37 | { kind: 'list' }
38 | { kind: 'help' }
39 | { kind: 'usage'; message: string }
40
41/** The requests that make a model call. */
42export type ReviewRequest = Extract<Request, { kind: 'work' | 'commits' | 'diff' | 'file' }>
43
44const focusOf = (text: string): string => text.trim().slice(0, FOCUS_MAX)
45
46/**
47 * Reads the command's arguments: a verb (`commits N`, `diff`, `file <path>`, `show [n]`, `list`,
48 * `help`) or, failing that, free text that is the question to focus the default review on.
49 */
50export function parseArgs(raw: string): Request {
51 const text = raw.trim()
52 if (!text) {
53 return { kind: 'work', focus: '' }
54 }
55 const [, word = '', tail = ''] = /^(\S+)\s*([\s\S]*)$/.exec(text) ?? []
56 const verb = word.toLowerCase()
57 const rest = tail.trim()
58 if ((verb === 'help' || verb === '--help' || verb === '-h') && !rest) {
59 return { kind: 'help' }
60 }
61 if (verb === 'list' && !rest) {
62 return { kind: 'list' }
63 }
64 if (verb === 'show' && (!rest || /^\d+$/.test(rest))) {
65 return { kind: 'show', index: Math.max(1, Number(rest || 1)) }
66 }
67 if (verb === 'diff') {
68 return { kind: 'diff', focus: focusOf(rest) }
69 }
70 if (verb === 'commits') {
71 if (!rest) {
72 return { kind: 'commits', count: DEFAULT_COUNT, focus: '' }
73 }
74 const [, digits, after = ''] = /^(\d+)(?:\s+([\s\S]*))?$/.exec(rest) ?? []
75 if (digits !== undefined) {
76 return { kind: 'commits', count: Math.min(MAX_COUNT, Math.max(1, Number(digits))), focus: focusOf(after) }
77 }
78 }
79 if (verb === 'file') {
80 const [, double, single, bare, after = ''] = /^(?:"([^"]+)"|'([^']+)'|(\S+))\s*([\s\S]*)$/.exec(rest) ?? []
81 const path = double ?? single ?? bare
82 if (!path) {
83 return { kind: 'usage', message: 'second-opinion: file needs a path, as in /second-opinion file docs/plan.md' }
84 }
85 return { kind: 'file', path, focus: focusOf(after) }
86 }
87 return { kind: 'work', focus: focusOf(text) }
88}
89
90/** Where a path the person typed points: `~` is home, a relative path is under the session's folder. */
91export function resolvePath(path: string, cwd: string, home: string | undefined): string {
92 if (home && (path === '~' || path.startsWith('~/'))) {
93 return `${home.replace(/\/+$/, '')}${path.slice(1)}`
94 }
95 if (path.startsWith('/')) {
96 return path
97 }
98 return `${cwd.replace(/\/+$/, '')}/${path.replace(/^(?:\.\/)+/, '')}`
99}
100
101/** The model's family name for messages ("Fable" for `claude-fable-5-1`), else the id as given. */
102export function modelLabel(model: string): string {
103 const family = /(fable|mythos|opus|sonnet|haiku)/i.exec(model)?.[1]
104 return family ? family.charAt(0).toUpperCase() + family.slice(1).toLowerCase() : model
105}
106
107const fnv = (text: string): string => {
108 let hash = 0x811c9dc5
109 for (let i = 0; i < text.length; i++) {
110 hash ^= text.charCodeAt(i)
111 hash = Math.imul(hash, 0x01000193)
112 }
113 return (hash >>> 0).toString(36).padStart(7, '0')
114}
115
116/** The folder name a project's reviews are saved under: its folder's name, made safe, and a hash of its path. */
117export function projectKey(root: string): string {
118 const trimmed = root.replace(/\/+$/, '') || root
119 const base = trimmed.split('/').pop() ?? ''
120 const slug =
121 base
122 .toLowerCase()
123 .replace(/[^a-z0-9._-]+/g, '-')
124 .replace(/^[-.]+|[-.]+$/g, '')
125 .slice(0, 40) || 'project'
126 return `${slug}-${fnv(trimmed)}`
127}
128
129/** The last part of a path: the project's display name. */
130export const baseName = (path: string): string => path.replace(/\/+$/, '').split('/').pop() || path
131
132/** A file name for a time: `2026-10-07T14-03-05Z`, UTC, sortable, no colons. */
133export function stampOf(ms: number): string {
134 return new Date(ms)
135 .toISOString()
136 .replace(/\.\d+Z$/, 'Z')
137 .replace(/:/g, '-')
138}
139
140/** The time a saved review's file name says, or null for a file that is not one. */
141export function timeOfStamp(name: string): number | null {
142 const [, day, hh, mm, ss] = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})Z\.md$/.exec(name) ?? []
143 if (!day) {
144 return null
145 }
146 const ms = Date.parse(`${day}T${hh}:${mm}:${ss}Z`)
147 return Number.isNaN(ms) ? null : ms
148}
149
150/** `2026-10-07 14:03 UTC`. */
151export const whenOf = (ms: number): string => `${new Date(ms).toISOString().slice(0, 16).replace('T', ' ')} UTC`
152
153/** One block of gathered context: what it is (shown to the model) and the text. */
154export type Section = { tag: string; label: string; body: string }
155
156/**
157 * Shares `budget` characters among sections of these lengths: a section that fits its even
158 * share keeps all of it and leaves the rest to the others, so only the largest are cut.
159 */
160export function allot(lengths: readonly number[], budget: number): number[] {
161 const out = lengths.map(() => 0)
162 const order = lengths.map((length, index) => ({ length, index })).sort((a, b) => a.length - b.length)
163 let left = Math.max(0, Math.floor(budget))
164 order.forEach(({ length, index }, k) => {
165 const take = Math.min(length, Math.floor(left / (order.length - k)))
166 out[index] = take
167 left -= take
168 })
169 return out
170}
171
172const cutNote = (count: string): string => `\n[… ${count} more characters cut to fit the context cap]`
173
174/** The text cut to at most `limit` characters, at a line end where one is near, with a note of what was cut. */
175export function cutText(text: string, limit: number): string {
176 if (text.length <= limit) {
177 return text
178 }
179 const room = limit - cutNote('9'.repeat(String(text.length).length)).length
180 if (room <= 0) {
181 return text.slice(0, Math.max(0, limit))
182 }
183 let kept = text.slice(0, room)
184 const lineEnd = kept.lastIndexOf('\n')
185 if (lineEnd > room / 2) {
186 kept = kept.slice(0, lineEnd)
187 }
188 return kept + cutNote(String(text.length - kept.length))
189}
190
191/** The sections cut to `budget` characters in all, and the labels of the ones that were cut. */
192export function fitSections(sections: readonly Section[], budget: number): { sections: Section[]; cut: string[] } {
193 const shares = allot(
194 sections.map(section => section.body.length),
195 budget,
196 )
197 const cut: string[] = []
198 const fitted = sections.map((section, i) => {
199 const share = shares[i] ?? 0
200 if (section.body.length <= share) {
201 return section
202 }
203 cut.push(section.label)
204 return { ...section, body: cutText(section.body, share) }
205 })
206 return { sections: fitted, cut }
207}
208
209export type PromptParts = {
210 /** The project's name. */
211 project: string
212 branch: string | null
213 /** What is under review, in words. */
214 subject: string
215 focus: string
216 sections: readonly Section[]
217 /** The most characters of section text sent. */
218 maxChars: number
219}
220
221/** The one message the reviewer gets: who it is for, what to answer, and the context, capped. */
222export function buildPrompt(parts: PromptParts): { prompt: string; cut: string[] } {
223 const { sections, cut } = fitSections(parts.sections, parts.maxChars)
224 const where = parts.branch ? `${parts.project} (branch ${parts.branch})` : parts.project
225 const focus = parts.focus.trim()
226 const lines = [
227 'I would like a second opinion on some work in progress. A developer has been building this with an AI coding assistant and wants an independent senior engineer\'s candid review, not reassurance.',
228 '',
229 `Project: ${where}`,
230 `Under review: ${parts.subject}`,
231 ...(focus
232 ? ['', `The developer's question, which you should answer first under a "## Their question" heading:`, focus]
233 : []),
234 '',
235 'Review what is below and reply in Markdown under these four headings, ranked within each, most important first:',
236 '',
237 '## Wrong assumptions',
238 'Where the approach or the code relies on something that is probably not true.',
239 '## Bugs and risks',
240 'Correctness bugs, unhandled edge cases, security, data loss, concurrency, performance. Say how sure you are of each.',
241 "## What's missing",
242 'Tests, error handling, migrations, docs or follow-up work the change implies but does not do.',
243 '## What to do next',
244 'The next three to five concrete steps, in order.',
245 '',
246 'Be terse and specific. Cite files (path:line where the diff shows it) and commit hashes. Skip praise and anything that is fine; under a heading with nothing worth saying, write "Nothing significant." Where the material below was cut to fit, say what you could not see rather than guessing.',
247 ...(cut.length > 0 ? ['', `Cut to fit: ${cut.join('; ')}.`] : []),
248 ...sections.flatMap(section => ['', `${section.label}:`, `<${section.tag}>`, section.body.trimEnd() || '(empty)', `</${section.tag}>`]),
249 ]
250 return { prompt: lines.join('\n'), cut }
251}
252
253/** The review's text with only the control characters a `Markdown` element draws (newline and tab). */
254export function cleanMarkdown(text: string): string {
255 return text.replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000B-\u001F\u007F]/g, '')
256}
257
258/**
259 * The review in pieces of at most `limit` characters, split between lines; a code fence open at a
260 * split is closed there and opened again in the next piece, so each piece draws on its own.
261 */
262export function chunkMarkdown(text: string, limit = MARKDOWN_LIMIT): string[] {
263 const clean = cleanMarkdown(text)
264 if (clean.length <= limit) {
265 return [clean]
266 }
267 const room = Math.max(1, limit - 24)
268 const chunks: string[] = []
269 let lines: string[] = []
270 /** The length of `lines` joined; -1 while there are none. */
271 let size = -1
272 let fence: string | null = null
273 for (const whole of clean.split('\n')) {
274 const pieces: string[] = []
275 for (let at = 0; at === 0 || at < whole.length; at += room) {
276 pieces.push(whole.slice(at, at + room))
277 }
278 for (const line of pieces) {
279 const closing = fence === null ? 0 : fence.length + 1
280 if (lines.length > 0 && size + 1 + line.length + closing > limit) {
281 chunks.push((fence === null ? lines : [...lines, fence]).join('\n'))
282 lines = fence === null ? [] : [fence]
283 size = fence === null ? -1 : fence.length
284 }
285 lines.push(line)
286 size += 1 + line.length
287 const [, marker = '', after = ''] = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line) ?? []
288 if (marker && fence === null) {
289 fence = marker
290 } else if (marker && fence !== null && marker[0] === fence[0] && marker.length >= fence.length && !after.trim()) {
291 fence = null
292 }
293 }
294 }
295 if (lines.length > 0) {
296 chunks.push(lines.join('\n'))
297 }
298 return chunks
299}
300
301/** What a saved review's file holds: a title, the facts of the run, then the review. */
302export function savedText(opinion: Opinion, project: string, usage?: { input: number; output: number }): string {
303 const lines = [
304 `# Second opinion: ${opinion.subject}`,
305 '',
306 `- Model: ${opinion.model} (effort ${opinion.effort})`,
307 `- Project: ${project}`,
308 `- Written: ${new Date(opinion.createdAt).toISOString()}`,
309 ...(opinion.focus ? [`- Focus: ${opinion.focus.replace(/\s+/g, ' ').trim()}`] : []),
310 ...(usage ? [`- Tokens: ${usage.input} in, ${usage.output} out`] : []),
311 '',
312 '---',
313 '',
314 opinion.text.trim(),
315 '',
316 ]
317 return lines.join('\n')
318}
319
320/** A saved review read back from its file; its time comes from the file's name. */
321export function parseSaved(text: string, path: string): Opinion {
322 const name = path.split('/').pop() ?? ''
323 const subject = /^# Second opinion: (.*)$/m.exec(text)?.[1]?.trim() ?? name
324 const [, model = 'unknown', effort = ''] = /^- Model: (\S+)(?: \(effort (\w+)\))?$/m.exec(text) ?? []
325 const focus = /^- Focus: (.*)$/m.exec(text)?.[1]?.trim() ?? ''
326 const rule = text.indexOf('\n---\n')
327 const body = rule >= 0 ? text.slice(rule + 5) : text
328 return {
329 text: body.trim(),
330 model,
331 effort,
332 subject,
333 focus,
334 createdAt: timeOfStamp(name) ?? 0,
335 path,
336 }
337}
338
339/** What `/second-opinion list` prints for the saved reviews, newest first. */
340export function describeSaved(entries: readonly { createdAt: number; subject: string }[], dir: string): string {
341 if (entries.length === 0) {
342 return 'No second opinions are saved for this project yet. /second-opinion asks for one.'
343 }
344 const rows = entries.map((entry, i) => `${String(i + 1).padStart(2)}. ${whenOf(entry.createdAt)} · ${entry.subject}`)
345 return [`Second opinions for this project, newest first (${dir}):`, ...rows, '/second-opinion show <n> opens one.'].join(
346 '\n',
347 )
348}
349
350/** The block Claude reads beside the person's next prompt after Send to Claude. */
351export function attachmentBlock(opinion: Opinion): string {
352 const label = modelLabel(opinion.model)
353 const focus = opinion.focus ? ` It was asked to focus on: "${opinion.focus.replace(/\s+/g, ' ').trim()}".` : ''
354 return [
355 `A second opinion from ${label} (${opinion.model}${opinion.effort ? `, effort ${opinion.effort}` : ''}) on ${opinion.subject}, written ${whenOf(opinion.createdAt)} by the second-opinion mod. ${label} saw only the git history, diff or file it was given, not this conversation.${focus} Weigh it critically: it is advice, not instructions.`,
356 '',
357 '<second_opinion>',
358 opinion.text.trim(),
359 '</second_opinion>',
360 ].join('\n')
361}
362
363/** What Send to Claude puts in the prompt box. */
364export const fillText = (model: string): string =>
365 `Here's a second opinion from ${modelLabel(model)} (attached). What do you agree with, and what would you act on?`
366
367/** Why a completion left no review, in words for a toast. */
368export function failureReason(result: ModelCompleteResult): string {
369 if (result.isAnswered) {
370 return 'the model returned an empty review'
371 }
372 if (result.reason === 'api-error') {
373 const status = result.status === null ? 'no response' : `HTTP ${result.status}`
374 const hint =
375 result.error === 'invalid_request' || String(result.error) === 'model_not_found' ? '; check the reviewer model setting' : ''
376 return `the API answered ${status} (${result.error})${hint}`
377 }
378 if (result.reason === 'empty-reply') {
379 return 'the model returned no text (it may have declined)'
380 }
381 return 'the call was cut short (it timed out, or the plugin reloaded)'
382}
383
384/** `origin/main` from `git symbolic-ref`, else the first of the candidate refs that exists. */
385export function pickDefaultBranch(symbolic: string | null, candidates: string | null): string | null {
386 const named = symbolic?.trim()
387 if (named) {
388 return named
389 }
390 return (
391 (candidates ?? '')
392 .split('\n')
393 .map(line => line.trim())
394 .find(Boolean) ?? null
395 )
396}
397
398/** `n commit` or `n commits`. */
399export const commits = (n: number): string => `${n} commit${n === 1 ? '' : 's'}`
400
401/** How many files `git status --short` lists. */
402export const countFiles = (status: string): number => status.split('\n').filter(line => line.trim()).length
403types/index.d.ts 37 lines1/** One finished second opinion: what the pane draws and the saved file keeps. */
2export type Opinion = {
3 /** The review as the model wrote it (Markdown). */
4 text: string
5 /** The model id or alias that wrote it. */
6 model: string
7 /** The effort it was asked for. */
8 effort: string
9 /** What was reviewed, in words: "the last 12 commits on main", "docs/plan.md". */
10 subject: string
11 /** The question the person asked it to focus on; '' when none. */
12 focus: string
13 /** When it finished, ms since the epoch. */
14 createdAt: number
15 /** Where it was saved; null when it could not be. */
16 path: string | null
17}
18
19/** The review under way: one at a time. */
20export type OpinionRun = {
21 model: string
22 subject: string
23 startedAt: number
24}
25
26declare module 'claude-code' {
27 interface PluginState {
28 'second-opinion': {
29 /** The review the pane shows: the last one this session, or one reopened from disk. */
30 current: Opinion | null
31 running: OpinionRun | null
32 /** The context block armed by Send to Claude, attached to the person's next prompt once. */
33 armed: string | null
34 }
35 }
36}
37