SLOPSHOPPER

second-opinion

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

newpanecommandtoaststatusprompt
v0.1.0no licenseupdated 2026-10-07joeldg/claude-mods/second-opinion
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · second-opinion
│ ┃ second-opinion ✕ › fix the failing auth test and add an audit log call │ ┃ Fable is reviewing the last 12 commits on f… │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /second-opinion │ ⎿ second-opinion: Fable is reviewing 12 commits (one Fable call)… │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ second-opinion: second opinion: reviewing…

Draws

Pane · second-opinion
Fable is reviewing the last 12 commits on feat/auth-refresh…
README

claude-mods

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

dev-servers

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:
  • running servers whose working folder is in this repo: name, port, pid and uptime, with Restart, Stop and Log
  • known start commands that aren't running, with Start: package.json dev/start/serve/preview scripts (run with your lockfile's package manager), .claude/launch.json and Procfile
  • a count of other listeners on the Mac
  • Only button presses start or stop anything:
  • Start runs the command detached, logging to ~/.claude/dev-servers/<project>/.
  • Stop sends SIGTERM. If the server ignores it, pressing again within 10s force-stops it.
  • Restart stops the server, waits for the port to free up, then starts it.
  • Before any signal, it checks the pid still runs the same command.
  • When a command fails with "address already in use", Claude gets a note (and you a toast) naming the holder, e.g. Port 4000 is held by node (pid 123, up 2h, in /Users/me/other).
  • Status line: servers: :4000 :5173.
  • Makes no model calls: lsof and ps every 15s.

downloads-drop

Puts files you just downloaded into your prompt with one click.

  • Watches ~/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].
  • Attach puts @"/Users/you/Downloads/paper.pdf" mentions in your prompt. Dismiss hides those files.
  • Waits until a file has finished downloading (skips partial downloads and files still growing), and ignores hidden and zero-byte files.
  • /downloads lists the 10 newest files, numbered. /downloads attach 1 3 (or 2-4) adds those, and /downloads clear dismisses everything new.
  • Stacks with other mods' bands (repo-brief, standing-orders, secret-guard) instead of hiding them.
  • Makes no model calls.

Settings: folder (~/Downloads), extensions, pollSeconds (5), maxAgeMinutes (120).

effort-router

Sets effort per message, so you don't have to switch it by hand.

  • Git chores ("merged", "#219 merged", "commit and push", "push it", "open a PR", "close the issue") run at low effort and come back faster.
  • Deep asks (audit, review, plan, design, investigate, root cause, "why does…", "figure out") run at max.
  • Everything else, including approvals like "yes", "go ahead" and "continue" and anything that starts new work ("merged 219, go ahead with #214"), keeps your session's own effort.
  • Prompt cache: changing effort makes the whole conversation get cached again. So it never switches mid-turn, raises effort at once, and over a large, warm cache lowers it only after 2 routine turns in a row. In small contexts, or once the cache has lapsed, it switches right away.
  • Model guard: set 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.
  • Status line while a turn is routed: effort: low (routine).
  • Makes no model calls.

Settings: routineEffort (low), deepEffort (max), routinePattern, deepPattern, avoidModel, fallbackModel (opus), stickyTurns (2), freeSwitchTokens (30000), cacheTtlMinutes (60).

job-watch

A Jobs pane for long-running work: training runs, downloads, extractions.

  • Picks up background Bash tasks and detached nohup … > log & launches by itself.
  • /watch <log> [label] adds any other log file.
  • Shows progress, ETA and the last log line, and flags a job as stalled when its log goes quiet.
  • Shows free space on / and /Volumes/* (the NAS).
  • Toasts when a job finishes or stalls. The status line shows jobs: 2 running · 1 stalled.
  • /jobs opens the pane, /unwatch <label|done|all> removes jobs.
  • Makes no model calls: it reads logs with 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.

machine-guard

Memory, swap and GPU on the status line. It refuses heavy local jobs when the Mac can't take them.

  • Status line: RAM tight 12% free · swap 7.9/8G · top python 31G · GPU 87%.
  • It refuses heavy jobs (training, inference, rendering, extraction, Blender, Docker, ffmpeg) when:
  • macOS reports critical memory pressure, or
  • the Mac is reserved with /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.
  • Remote runs (modal run, ssh), tests (pytest) and installs are never treated as heavy.
  • Add your own heavy commands with the "Also heavy" setting (a regex), e.g. overnight_|nightly_run\.sh.

mod-monitor

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.

  • Failures: any mod hook that throws, times out or rejects, read from the hook chain's results (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.
  • What each mod did: its toasts ("#219 merged → …", "Blocked: …"), status-line changes, the mod commands you used (never their arguments), and failed subprocesses (a burst of 5 in 10 minutes raises a toast). Git checks run outside a repository are logged as expected, not as failures. It also records model calls (the only usage the mods cost: /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.
  • Logs: ~/.claude/mods/monitor/<date>/<session>.jsonl, flushed every minute and at session end, with secrets masked and old days removed after 30 days.
  • Makes no model calls and adds no measurable latency.
  • Error lines mods log themselves ($.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.
  • Transcript-row hooks (secret-guard masks /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).

modal-meter

Keeps an eye on Modal so idle GPU containers don't burn credits.

  • Status line while containers run: Modal: 1 running (2 containers). Deployed apps with no containers cost nothing, so they stay off it.
  • A toast when an app has had containers up longer than 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.
  • Shows today's spend and alerts on a budgetToday where the Modal CLI supports billing report (1.3.3+, Team/Enterprise workspaces). Otherwise /modal says why spend isn't shown.
  • Finds the CLI as 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.
  • Makes no model calls: only the Modal CLI, every 60s.

pr-autopilot

Does the "merged #219, clean up branches and start #214" round trip for you, and surfaces CI failures with their logs.

  • Watches your open PRs in the session's repo: it adopts them at session start, and picks up every gh pr create Claude runs. It polls gh pr view every 60s.
  • Status line: PRs: #219 ✓ · #220 CI… · #221 ✗. Toasts when CI fails (with the failing check names) or passes.
  • When a PR merges, it cleans up with plain local git, then toasts the outcome and suggests carrying on (Tab to accept):
  • git fetch --prune, switch to the default branch (only from the PR's own branch) and git pull --ff-only.
  • Never with uncommitted changes, never --force, never other branches.
  • Deletes the local branch only if it points at exactly the commit GitHub merged, so nothing local is lost. It also leaves a branch checked out in another worktree alone.
  • A merge seen mid-turn is cleaned up when the turn ends, so git never races Claude.
  • When you mention failing CI ("#258 is failing", "CI failed, fix it"), your message goes to Claude with 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.
  • Makes no model calls: only 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.

recall

Search everything you've done with coding agents, from Claude or from /recall. It replaces the broken agent-memory plugin.

  • What it searches: Claude Code sessions, Codex sessions, subagent and workflow runs, Claude's memory files, standing orders, second-opinion reviews, and your /remember notes. Routine (scheduled) runs are left out unless you add routines:include to a query.
  • What it keeps: prompts, answers, compaction summaries, session titles, commands, files touched, commits, PRs, issues, URLs, tasks and decisions (what you approved or ruled out). Read-only look-ups like grep and cat are kept but ranked low.
  • History survives cleanup: extracts stay searchable after Claude Code deletes old transcripts.
  • Claude searches it itself with four read-only tools, 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.
  • Commands:
  • /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>.
  • Bands:
  • Once per session: Last session here (2d ago): "…" · PR #99 · 3 open tasks [Recap].
  • When a prompt mentions #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.
  • Query syntax: words must all match. 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.
  • Privacy:
  • The index lives at ~/.claude/recall/index.db, readable only by you, and never goes in a repo.
  • Secrets are masked before anything is stored: known token shapes, labelled values ("password: …"), the values of secret-named exports in ~/.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.
  • Cost: no model calls except /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.
  • Requires macOS's /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).

repo-brief

Catches Claude up on the repo when a session starts, so you don't have to ask "check the recent commits/PRs and issues".

  • Gathers in the background at session start:
  • branch, ahead/behind and uncommitted files
  • the last 8 commits
  • open PRs with CI ✓/✗/…
  • issues labelled owner, todo, P0 or blocked
  • stale branches (merged, or upstream gone)
  • A one-line band above the prompt, e.g. main ↑1 · 3 changed · PRs #123 ✗ #124 ✓ · 2 owner issues · 2 stale branches · last commit 2h ago. Hide dismisses it.
  • Claude gets the same summary once, in its first message, so the prompt cache stays warm. It refreshes after compaction.
  • /brief re-gathers now and prints the full summary.
  • Makes no model calls: only git and gh. The band refreshes after a turn at most every 2 minutes.

Settings: focus labels, refresh minutes, and whether to brief Claude.

routine-watch

Keeps scheduled routines (daily digests, newsletters) from silently stalling while you're away.

  • Knows a session is a routine from its scheduled-task prompt, and does nothing in your other sessions.
  • When a routine stops to wait for your OK on a permission prompt or an AskUserQuestion, you get a Mac notification and a toast, and the status line shows routine: daily-report · waiting on you 3m.
  • When a turn ends in an error, or the routine finishes, you get a notification: Routine daily-report finished after 23m · waited on you 2 times.
  • Phone push (optional): 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.
  • Makes no model calls.

second-opinion

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.
  • Other forms:
  • /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.
  • The command returns at once, and the status line shows 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.
  • Send to Claude attaches the review to your next prompt (once) and drafts "What do you agree with, and what would you act on?".
  • Reviews are saved in ~/.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).

standing-orders

Keeps your "always / never / don't / from now on" instructions alive across compaction.

  • When you write an instruction like "never open bambu with full spectrum files", a band asks: Keep as a standing order? [Project] [This session] [No]. Nothing is saved without a click.
  • Project orders live in ~/.claude/standing-orders/<repo>.json and apply to every session in that repo. Session orders and your active /goal last for the session.
  • Claude gets them at the start of every conversation and again after each compaction or /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).
  • Makes no model calls.

secret-guard

Stops keys and passwords from going into a prompt, and so into your transcripts, and turns them into env vars instead.

  • Catches known token shapes: AWS, GitHub, Anthropic, OpenAI, Slack, Google, Hugging Face, GitLab, npm, Stripe, private keys and bearer tokens.
  • Also catches labelled values ("password: …", "api key = …", "the wifi password is …") and the two-line "Access Key ID / Secret Access Key" paste.
  • Leaves alone $NAME references, placeholders, plain URLs, paths, git SHAs and ordinary prose about passwords.
  • On a hit, the prompt isn't sent and goes back in the box. A band shows the secret masked (…vxrm) with a suggested name such as OPENDATALAB_SECRET_ACCESS_KEY, which you can edit:
  • Save as env var appends export NAME='…' to ~/.zshrc (reusing an existing identical export) and replaces the secret in your prompt with $NAME.
  • Send anyway lets exactly that text through once.
  • Edit dismisses the band.
  • The value is never shown in toasts, status, state or the transcript, and /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.
  • Makes no model calls.

Settings: enabled (on), extraPatterns (a regex), zshrcPath (~/.zshrc).

slicer-handoff

Makes Claude's open commands hand 3D files to the right slicer.

  • Full-spectrum files go to Snapmaker Orca. Bambu Studio and OrcaSlicer can't open them. A file counts as full-spectrum when:
  • its name or folder matches full.?spectrum|snapmaker-only|-fs\.3mf$|-u1[-.], or
  • its 3MF names a Full Spectrum filament profile.

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

  • Earlier windows close first. Before opening a file, it asks the running slicer to quit (a normal quit, never forced), so windows don't pile up. If one won't close, for example because it's waiting on a save prompt, it stops trying and tells Claude to leave it alone.
  • /slice <file> [bambu|snapmaker|orca] opens a file yourself, with the same rules.
  • Recognizes 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.
  • Makes no model calls.

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.

Loading

  • One session from a terminal: pass --plugin-dir once per mod, e.g. claude --plugin-dir ~/Projects/claude-mods/job-watch --plugin-dir ~/Projects/claude-mods/pr-autopilot
  • Every session, including the desktop app: add to ~/.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" } } ``

Checking

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)
Source 3 files
hooks/register.tsx 558 lines
1import { 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}
558
hooks/review.ts 403 lines
1import 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
403
types/index.d.ts 37 lines
1/** 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