SLOPSHOPPER

adversary

Adversarial review of a PR, plan or document by an isolated reviewer that never sees the author's context, with a triage pane deciding which findings reach the…

newpanebandguardcommandtoast
★ 2v0.1.0MITupdated 2026-10-20bobtat/claude-plugins/plugins/adversary
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · adversary
│ ┃ adversary ✕ › fix the failing auth test and add an audit log call │ ┃ No review yet │ ● adversary: Could not copy the reviewer templates to your home direc │ ⏺ 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 │ │ › /adversary │ ⎿ adversary: Missing subcommand. │ ⎿ adversary: /adversary pr <number|url> [--brief <key|#n|url|"te │ ⎿ adversary: /adversary plan [--brief ...] [--focus . │ ⎿ adversary: /adversary doc <path> [--brief ...] [--focus . │ ⎿ adversary: /adversary triage reopen the triage pane f │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · adversary
No review yet
README

adversary

A Claude Code mod that has an isolated reviewer attack a pull request, a plan or a document, then lets you decide which of its findings reach the author. Like cost-ledger and subagent-tree it is code: a TypeScript hooks module that Claude Code loads and runs in the session.

Why

A review written inside the session that produced the work shares that session's assumptions. The reviewer here is a separate agent that starts with none of them:

  • It does not see the session transcript or memory, and it is not given CLAUDE.md and cannot read it. Its only input is one message: the template, the artifact, the optional brief and focus, and the output format.
  • It has Read, Grep and Glob only, and a tool.call hook confines every call to the review root. A path that resolves outside the root (through .., a link, a drive-relative or UNC spelling) is denied. Inside the root, the contents of any CLAUDE.md, CLAUDE.local.md, .claude or .git are withheld: Read of one is denied, and so is a Grep whose path or glob names one, or that matches in one. Before a reviewer Grep runs, the same search is run once listing every file it matches, with no output limit, and if any is withheld the Grep is denied (narrow it with path or glob and it works again); if that listing fails, the Grep is denied too. A Glob with a broad pattern such as **/* still lists the names of files under .claude and .git.
  • Nothing it finds goes anywhere on its own. The findings open in a pane where you send or drop each one, and only the ones you send are drafted into your prompt, posted to the PR, or both. The reviewer's completion notice, which carries its answer, is kept out of the session, whether it arrives as a prompt or is queued into a running turn.
/adversary pr|plan|doc ──► artifact (PR text + diff, plan, or file)
                           brief    (Jira / GitHub issue / pasted text, optional)
                           template (repo, user or shipped copy)
                                  │
                                  ▼
                        reviewer agent  ◄── tool.call guard: Read/Grep/Glob inside review root only
                                  │
                                  ▼
                   findings (one ```json block, parsed, sorted by severity)
                                  │
                                  ▼
                           triage pane
                  s send · d drop · e edit · j/k move
                    │           │            │
                  Draft        Post         Save
              (prompt box)  (PR review)  (.claude/adversary/reviews/)

Installation

/plugin marketplace add bobtat/claude-plugins
/plugin install adversary@bobtat-plugins

Using it

/adversary pr <number|url>   [--brief <key|#n|url|"text">] [--focus "..."] [--model opus|sonnet|haiku]
/adversary plan              [--brief ...] [--focus ...] [--model ...]
/adversary doc <path>        [--brief ...] [--focus ...] [--model ...]
/adversary triage            reopen the triage pane for the last review
/adversary pr 482
/adversary pr https://github.com/acme/widgets/pull/482 --brief PROJ-123 --model opus
/adversary pr 482 --brief #471 --focus "the retry logic and anything that touches billing"
/adversary plan --brief "Add a read-only audit log; no schema changes to existing tables"
/adversary doc docs/specs/export-design.md --focus "claims about the existing importer"
  • pr and doc take exactly one target; plan takes none; triage takes no options.
  • Flag values with spaces go in double quotes. A quoted token is always a value, never a flag.
  • --model sets the reviewer's model. Without it the reviewer uses the session model.
  • --focus adds a "Focus" section to the reviewer's task.
  • A malformed command, including an empty target or flag value, replies with the usage above.
  • plan reviews the plan from the last ExitPlanMode in this session. With none it replies "No plan this session".
  • doc <path> is relative to the project root unless absolute. The reviewer can read the rest of the project (the review root is the project root), not just the one file.
  • Only one review runs at a time; starting another while the reviewer is alive replies "Review in progress". The pane opens by itself when the review finishes or fails. A review whose reviewer is no longer running (after a reload or a crash) shows in the pane as failed: "The reviewer is gone".

Plan-ready band

After you approve a plan, a line appears above the prompt: "Plan ready — /adversary plan to review it". It stays until you send your next message, from the terminal or through Remote Control. A background task's notification or a scheduled prompt arriving in between leaves it up.

Briefs

--brief gives the reviewer the intent to check the work against. The value is classified like this:

ValueSourceNeeds
PROJ-123 (project key and number)Jira issue summary and descriptionThe Atlassian MCP server, signed in (the plugin bundles it)
#123 or a https://github.com/<owner>/<repo>/issues/<n> URLGitHub issue title and bodygh, logged in
anything elsethe text itselfnothing

The plugin's .mcp.json declares Atlassian's remote server, https://mcp.atlassian.com/v2/mcp, so it appears in /mcp as soon as the plugin is enabled. Sign in there once. Claude Code then talks to Atlassian directly, so the ticket goes from Atlassian to your machine and then, inside the reviewer's prompt, to whichever model provider the session uses. If you already run the Atlassian server under a name of your own, the mod uses that one. Claude Code connects to the endpoint when a session starts, as it does for any listed server; the mod calls no Atlassian tool until you pass a Jira key. If you never use Jira, the server stays unauthenticated in /mcp.

If a brief cannot be fetched, the review does not start and the reply says why. For Jira that is "Atlassian needs you to sign in: run /mcp, …" when the bundled server is waiting for sign-in, and "Jira not connected; paste the ticket with --brief "..."" otherwise.

Reviewing a PR

Needs gh, logged in. The reviewer is given the PR number, title, body and diff.

  • The PR must belong to the project's repository, as gh repo view names it (host, owner and name, ignoring case). A URL of a PR in another repository is refused, since the checkout and Post would go to this repository's PR of the same number.
  • If the project's checked-out HEAD is the PR's head commit and the working tree has no uncommitted or untracked changes, the reviewer reads the project directory.
  • Otherwise the PR head is fetched from origin and checked out detached in a temporary git worktree under the system temp directory (adversary-pr-<number>-<short sha>-<session>, the last part the first eight characters of the session id), and that is the review root. If the fetch brings a different commit than the head gh reported (the PR moved, or origin is not the repository gh read), the review is refused. A worktree left at that path is removed first, which after a reload or a crash only happens if the session keeps its id; a session that comes back under a new id (after /clear, for one) uses a new path, and the old checkout stays until you remove it with git worktree remove. Another session reviewing the same PR head has its own path and is never touched. The worktree is removed when the pane closes or the session ends, and before the next review starts. While the reviewer is still running, closing the pane leaves the worktree in place until the session ends. If git cannot remove it, a toast names the path and the next close or session end tries again.
  • A diff over 200000 characters is refused; the reply gives its size. The same limit applies to a document or a plan.

Templates

The reviewer's task is a template with placeholders, one per kind: pr.md, plan.md, doc.md. For each review the first of these that exists is used:

  1. <project>/.claude/adversary/<kind>.md, for a template shared with the repo
  2. ~/.claude/adversary/<kind>.md, your own copy
  3. templates/<kind>.md in the plugin, the shipped copy

On session start any of the three user copies that do not exist are created from the shipped ones, so you can edit them in place. Your edits are never overwritten, which also means a later plugin update does not reach a copy you have already seeded; delete it to take the new one.

PlaceholderHolds
{{artifact}}The PR text and diff, the plan, or the document. Required.
{{output_contract}}The format the reviewer must answer in (below). Required.
{{root}}The review root the reviewer can read.
{{brief}}A "Brief" section, or nothing without --brief.
{{focus}}A "Focus" section, or nothing without --focus.

A template missing a required placeholder fails the review with a message naming the file. A {{brief}} or {{focus}} alone on its line leaves no blank line behind when it is empty.

The reviewer must end with one fenced json block, { "findings": [...] }, each finding having a severity (critical, high, medium, low), a location (file and optional line, or section), a claim and an evidence. The last such block is used. Findings that do not fit are kept and shown under "Unparsed" in the pane. An answer with no json block fails the review and the raw answer is shown.

The triage pane

Findings are listed worst first, each with a mark (· undecided, ✓ send, ✗ drop), a severity and a location. The selected finding shows its claim and evidence. A line under the title counts findings and decisions.

KeyButtonDoes
sSendMark the selected finding to send
dDropMark it dropped
eEditEdit the claim text; an empty edit or one equal to the original removes the edit. Not offered where the surface has no text input
j / kDown / UpMove the selection
rDraftPut the sent findings in the prompt box (replacing what is there), asking the author to fix each or reject it with a reason
pPostPR reviews only: post the sent findings as a GitHub review
wSaveWrite the whole review to a file
cCloseClose the pane

With nothing marked to send, Draft and Post show "Nothing to send — mark findings with s first".

Post posts one review with the COMMENT event, so it neither approves nor requests changes. It first compares the PR's current head with the commit that was reviewed.

  • Head unchanged: it re-fetches the PR diff. A finding with a file and line that is on an added or context line in the diff becomes an inline comment on that line, anchored to the reviewed commit. Every other sent finding (outside the diff, no line, or a section) is listed numbered in the review body.
  • Head moved, or unreadable: line numbers may now point at other code, so nothing is anchored. All sent findings go in the body, which begins "The PR has new commits since this review (reviewed <short sha>)."

A toast gives the review's URL, or the reason it failed.

Save writes <project>/.claude/adversary/reviews/<date>-<kind>-<slug>.md: 2026-10-19-pr-482.md, 2026-10-19-plan.md, 2026-10-19-doc-export-design.md. An existing file is never overwritten; a second save the same day gets -2, -3 and so on. The file holds every finding with its decision, edited claims alongside the original, and the unparsed entries. Add the directory to .gitignore if you do not want the reviews committed.

If the surface does not place the pane when a review finishes, a toast says "Review done: N findings — /adversary triage" (or that it failed); /adversary triage opens the pane for the last review. The review lives in session state, so it survives closing the pane.

Limits

  • Grep and Glob are disabled for the reviewer when the review root contains a link leading outside it, or more than 20000 entries; the scan runs once per review and counts anything it cannot settle as such a link. The reviewer gets "Grep/Glob disabled" and has Read only. Reading files by path still works and is still confined.
  • A denied Grep tells the reviewer that its pattern matched somewhere in CLAUDE.md, .claude or .git. One denial shows neither the matching text nor the file, but a reviewer that keeps probing, lengthening the pattern one character at a time or narrowing path, glob or type, can rebuild withheld text and work out which file holds it. The guard keeps the reviewer from being handed those files; it does not stop one determined to reconstruct them.
  • One review at a time, and only the last review is kept for /adversary triage.
  • The reviewer sees one artifact. A PR diff, document or plan over 200000 characters is refused rather than truncated.

Development

The reviewer's path guard and the findings parser fail silently: a guard bug lets the reviewer read outside the review root, a parser bug drops findings. After any change under plugins/adversary/, run both and commit only when both pass:

claude plugin validate plugins/adversary
claude plugin test plugins/adversary
Source 13 files
hooks/register.tsx 115 lines
1import type { Register } from 'claude-code'
2
3import { parseArgs } from './args'
4import { grepNamesWithheld, grepPreflight, isFileList, judgeCall } from './guard'
5import { hostFrom } from './host'
6import { registerPlanBand } from './plan'
7import { namesReviewer, REVIEWER, reviewerScope, reviewerTurnEnded, startReview } from './review'
8import { homeDir, seedUserTemplates } from './templates'
9import { openTriage, releaseWorktree, renderPane } from './triage'
10
11const OUTSIDE = 'outside the review root'
12// Spelled out, not imported from triage.tsx, so validate can list the pane hooks' matchers.
13const PANE_ID = 'adversary'
14const NO_SEARCH = 'Grep/Glob disabled: the review root contains links leading outside it; use Read'
15const READ_ONLY = 'The reviewer may use Read, Grep and Glob only'
16const WITHHELD_MATCH = 'Grep matched CLAUDE.md, .claude or .git, which the reviewer may not read; narrow it with path or glob'
17const CHECK_FAILED = 'Grep was not run: it could not first be checked for matches in CLAUDE.md, .claude or .git'
18const SEARCH_TOOLS = ['Read', 'Grep', 'Glob']
19// SubagentHandback is the engine's tool for a subagent to deliver its report to
20// the spawner. It reads nothing, and the report reaches the mod as
21// turn.complete's answer. The engine's tool-name type does not list it.
22const ALLOWED_TOOLS: string[] = [...SEARCH_TOOLS, 'SubagentHandback']
23const REVIEW_DELIVERED = 'adversary: the review finished; its findings are in the triage pane (/adversary triage).'
24
25export const register: Register = on => {
26  registerPlanBand(on)
27
28  on('session.start', async ($, e, next) => {
29    await $.agent.register(REVIEWER)
30    await $.command.register({ name: 'adversary', description: 'Adversarial review of a PR, plan or document by an isolated reviewer' })
31    const host = hostFrom($)
32    try {
33      await seedUserTemplates(host, await homeDir(host))
34    } catch (error) {
35      $.ui.log(`Could not copy the reviewer templates to your home directory: ${error instanceof Error ? error.message : String(error)}`)
36    }
37    return next(e)
38  })
39
40  on('agent.offer', { agent: 'adversary:reviewer' }, () => ({ isOffered: false }))
41
42  on('command.run', { command: 'adversary' }, async ($, e) => {
43    const parsed = parseArgs(e.args)
44    if (!parsed.ok) return { text: parsed.error }
45    if (parsed.args.sub === 'triage') return { text: await openTriage($, true) }
46    return { text: await startReview($, parsed.args) }
47  })
48
49  on('tool.call', { tool: ['Read', 'Grep', 'Glob'] }, async ($, e, next) => {
50    const scope = await reviewerScope($, e.agentId)
51    if (scope === undefined) return next(e)
52    if (e.tool !== 'Read' && !scope.canSearch) return { deny: NO_SEARCH }
53    const verdict = await judgeCall(hostFrom($), e, scope.root)
54    if (verdict === 'deny') return { deny: OUTSIDE }
55    // A 'scope' verdict only ever comes for Grep and Glob, the tools with a path to fill in.
56    const call = verdict === 'scope' && e.tool !== 'Read' ? { ...e, path: scope.root } : e
57    // Grep's search covers every file under its path, and a glob or a regex
58    // can reach the withheld files under spellings the checks above cannot see.
59    // The output can be cut to context lines (head_limit, offset), so the files
60    // it matched are listed in full first. That call carries no agentId: this
61    // hook is not run for it, and the handler below lets it through.
62    if (call.tool === 'Grep') {
63      const listed = await $.tool.call(grepPreflight(call))
64      if (listed.deny !== undefined || listed.isError === true || !isFileList(listed.result)) return { deny: CHECK_FAILED }
65      if (grepNamesWithheld(scope.root, listed.result)) return { deny: WITHHELD_MATCH }
66    }
67    const ran = await next(call)
68    if (call.tool === 'Grep' && ran.deny === undefined && ran.isError !== true && grepNamesWithheld(scope.root, ran.result)) {
69      return { deny: WITHHELD_MATCH }
70    }
71    return ran
72  })
73    // Past `next` a reviewer's result may hold what the check after it would
74    // have withheld, so a subagent's call is denied rather than replayed.
75    .catch(($, e, next) => (e.agentId === undefined ? next(e) : { deny: OUTSIDE }))
76
77  // The agent spec already limits the reviewer's tools; this holds if a spawn
78  // or a later engine widens them. Read, Grep and Glob are the hook above's.
79  on('tool.call', async ($, e, next) => {
80    if (ALLOWED_TOOLS.includes(e.tool) || (await reviewerScope($, e.agentId)) === undefined) return next(e)
81    return { deny: READ_ONLY }
82  }).catch(($, e, next) => (next.called || e.agentId === undefined ? next(e) : { deny: READ_ONLY }))
83
84  // The reviewer runs in the background, so its answer would also come back to
85  // the author session as a task notification, findings and all, past triage.
86  on('prompt.submit', { origin: { kind: 'task-notification' } }, async ($, e, next) => {
87    if (e.origin.kind !== 'task-notification' || !(await namesReviewer($, e.text))) return next(e)
88    return { drop: REVIEW_DELIVERED }
89  }).catch(($, e, next) => next(e))
90
91  // Delivered into a running turn, the same notification reaches the model as an attachment.
92  on('prompt.attachment', { type: 'queued_command' }, async ($, e, next) => {
93    if (!(await namesReviewer($, e.text))) return next(e)
94    return { text: null }
95  }).catch(($, e, next) => next(e))
96
97  on('turn.complete', async ($, e, next) => {
98    await reviewerTurnEnded($, e)
99    return next(e)
100  })
101
102  on('ui.render', { component: 'Pane', requestId: PANE_ID }, async ($, e) => renderPane($, e))
103
104  on('ui.close', { id: PANE_ID }, async ($, e, next) => {
105    const result = await next(e)
106    await releaseWorktree($, false)
107    return result
108  })
109
110  on('session.end', async ($, e, next) => {
111    await releaseWorktree($, true)
112    return next(e)
113  })
114}
115
hooks/args.ts 95 lines
1export type Options = { brief?: string; focus?: string; model?: 'opus' | 'sonnet' | 'haiku' }
2export type Args =
3  | ({ sub: 'pr'; target: string } & Options)
4  | ({ sub: 'plan' } & Options)
5  | ({ sub: 'doc'; target: string } & Options)
6  | { sub: 'triage' }
7
8export const USAGE = [
9  '/adversary pr <number|url>   [--brief <key|#n|url|"text">] [--focus "..."] [--model opus|sonnet|haiku]',
10  '/adversary plan              [--brief ...] [--focus ...] [--model ...]',
11  '/adversary doc <path>        [--brief ...] [--focus ...] [--model ...]',
12  '/adversary triage            reopen the triage pane for the last review',
13].join('\n')
14
15const MODELS = ['opus', 'sonnet', 'haiku'] as const
16
17type Token = { text: string; quoted: boolean }
18type Result = { ok: true; args: Args } | { ok: false; error: string }
19
20const fail = (message: string): Result => ({ ok: false, error: `${message}\n${USAGE}` })
21
22function tokenize(raw: string): Token[] | null {
23  const tokens: Token[] = []
24  let i = 0
25  while (i < raw.length) {
26    const char = raw.charAt(i)
27    if (/\s/.test(char)) {
28      i++
29    } else if (char === '"') {
30      let text = ''
31      i++
32      while (i < raw.length && raw[i] !== '"') {
33        if (raw[i] === '\\' && raw[i + 1] === '"') i++
34        text += raw[i++]
35      }
36      if (i >= raw.length) return null
37      i++
38      tokens.push({ text, quoted: true })
39    } else {
40      let text = ''
41      while (i < raw.length && !/\s/.test(raw.charAt(i))) text += raw.charAt(i++)
42      tokens.push({ text, quoted: false })
43    }
44  }
45  return tokens
46}
47
48// A quoted token is always a value, so "--force" inside quotes is never mistaken for a flag.
49const isFlag = (t: Token) => !t.quoted && t.text.startsWith('--')
50
51export function parseArgs(raw: string): Result {
52  const tokens = tokenize(raw)
53  if (!tokens) return fail('Unterminated quote.')
54
55  const options: Options = {}
56  const positional: string[] = []
57  for (let i = 0; i < tokens.length; i++) {
58    const token = tokens[i]!
59    if (!isFlag(token)) {
60      positional.push(token.text)
61      continue
62    }
63    const name = token.text.slice(2)
64    if (name !== 'brief' && name !== 'focus' && name !== 'model') return fail(`Unknown option ${token.text}.`)
65    const value = tokens[++i]
66    if (!value || isFlag(value) || value.text.trim() === '') return fail(`${token.text} needs a value.`)
67    if (name === 'model') {
68      const model = MODELS.find(m => m === value.text)
69      if (!model) return fail(`Unknown model "${value.text}"; use ${MODELS.join(', ')}.`)
70      options.model = model
71    } else {
72      options[name] = value.text
73    }
74  }
75
76  const [sub, ...rest] = positional
77  if (!sub) return fail('Missing subcommand.')
78
79  switch (sub) {
80    case 'triage':
81      if (rest.length > 0 || Object.keys(options).length > 0) return fail('triage takes no arguments or options.')
82      return { ok: true, args: { sub } }
83    case 'plan':
84      if (rest.length > 0) return fail('plan takes no target.')
85      return { ok: true, args: { sub, ...options } }
86    case 'pr':
87    case 'doc':
88      const [target] = rest
89      if (rest.length !== 1 || target === undefined || target.trim() === '') return fail(`${sub} needs exactly one target.`)
90      return { ok: true, args: { sub, target, ...options } }
91    default:
92      return fail(`Unknown subcommand "${sub}".`)
93  }
94}
95
hooks/guard.ts 170 lines
1import type { Host } from './host'
2
3export function normalise(path: string): string {
4  const slashed = path.replace(/\\/g, '/')
5  if (/^\/+$/.test(slashed)) return slashed === '/' ? '/' : ''
6  return slashed.replace(/\/+$/, '').replace(/^[A-Za-z]:/, drive => drive.toLowerCase())
7}
8
9// Only Windows paths are case-insensitive; a POSIX root compares exactly.
10const isWindowsRoot = (root: string) => /^[A-Za-z]:/.test(root) || /^[\\/]{2}/.test(root)
11
12export function isUnder(root: string, realPath: string): boolean {
13  const fold = (text: string) => (isWindowsRoot(root) ? text.toLowerCase() : text)
14  const base = fold(normalise(root))
15  const target = fold(normalise(realPath))
16  if (base === '' || target.split('/').some(segment => segment === '.' || segment === '..')) return false
17  return target === base || target.startsWith(base.endsWith('/') ? base : `${base}/`)
18}
19
20// A spelling the tool may open but `realPath` cannot place reliably.
21export function isUnplaceable(path: string): boolean {
22  const name = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
23  return (
24    path === '' ||
25    path.includes('\0') ||
26    path.startsWith('~') ||
27    /^[A-Za-z]:(?![\\/])/.test(path) ||
28    /^[\\/]{2}/.test(path) ||
29    /^[A-Za-z]:/.test(name)
30  )
31}
32
33export async function checkPath(host: Host, path: string, root: string): Promise<boolean> {
34  if (isUnplaceable(path)) return false
35  const realPath = await host.realPath(path)
36  return realPath !== undefined && isUnder(root, realPath) && !isWithheld(root, normalise(realPath).slice(normalise(root).length))
37}
38
39// The session's own instructions and settings and the repository's internals:
40// the reviewer is meant to start with none of the author session's context.
41const WITHHELD = ['.claude', '.git', 'CLAUDE.md', 'CLAUDE.local.md']
42
43// Whether any segment of `rest`, a path or pattern below the root, is withheld.
44function isWithheld(root: string, rest: string, separators = /[\\/{},]/): boolean {
45  const fold = (text: string) => (isWindowsRoot(root) ? text.toLowerCase() : text)
46  const withheld = WITHHELD.map(fold)
47  return rest.split(separators).some(segment => withheld.includes(fold(segment)))
48}
49
50// A path a search reported, absolute or relative to where the tool ran. One
51// that is not under the root cannot be told apart from the root's own part, so
52// all of it counts.
53function isWithheldPath(root: string, path: string): boolean {
54  const rest = isAbsolute(path) && isUnder(root, path) ? normalise(path).slice(normalise(root).length) : path
55  return isWithheld(root, rest, /[\\/]/)
56}
57
58// The part of `path` below the root, or all of it when it does not start there.
59function belowRoot(root: string, path: string): string {
60  const fold = (text: string) => (isWindowsRoot(root) ? text.toLowerCase() : text)
61  const base = normalise(root)
62  const target = normalise(path)
63  const rest = fold(target).startsWith(fold(base.endsWith('/') ? base : `${base}/`)) ? target.slice(base.length) : path
64  return rest.split(/[\\/]/).includes('..') ? path : rest
65}
66
67const LONGEST_WITHHELD = Math.max(...WITHHELD.map(name => name.length))
68const endsPath = (char: string | undefined) => char === ':' || char === '-'
69
70// A content line is `path:line:text` or `path-line-text`, or without -n
71// `path:text` and `path-text`, and a path may itself hold `:` or `-`. So the
72// path is taken to end at any `:` or `-`: the line names a withheld file if any
73// such prefix of it does. That can only refuse too much, never too little.
74function lineNamesWithheld(root: string, line: string): boolean {
75  const rest = belowRoot(root, line)
76  let lastEnd = -1
77  for (let i = rest.length - 1; i >= 0; i--) {
78    if (endsPath(rest[i])) {
79      lastEnd = i
80      break
81    }
82  }
83  let start = 0
84  for (const segment of rest.split(/[\\/]/)) {
85    const end = start + segment.length
86    // A whole segment, followed later by a place the path may end.
87    if (end < lastEnd && isWithheld(root, segment, /[\\/]/)) return true
88    // A segment the path ends inside: only a prefix as long as a withheld name can match.
89    for (let j = 1; j <= Math.min(segment.length, LONGEST_WITHHELD); j++) {
90      if (endsPath(segment[j]) && isWithheld(root, segment.slice(0, j), /[\\/]/)) return true
91    }
92    start = end + 1
93  }
94  return false
95}
96
97type GrepCall = { pattern: string; path?: string; glob?: string; type?: string; '-i'?: boolean; multiline?: boolean }
98
99// The same search as `call`, listing every file it matches: no context, no
100// head_limit (0 is unlimited) and no offset.
101export function grepPreflight(call: GrepCall) {
102  return {
103    tool: 'Grep' as const,
104    pattern: call.pattern,
105    ...(call.path === undefined ? {} : { path: call.path }),
106    ...(call.glob === undefined ? {} : { glob: call.glob }),
107    ...(call.type === undefined ? {} : { type: call.type }),
108    ...(call['-i'] === undefined ? {} : { '-i': call['-i'] }),
109    ...(call.multiline === undefined ? {} : { multiline: call.multiline }),
110    output_mode: 'files_with_matches' as const,
111    head_limit: 0,
112  }
113}
114
115export const isFileList = (result: unknown): boolean =>
116  typeof result === 'object' && result !== null && Array.isArray((result as { filenames?: unknown }).filenames)
117
118// Whether a Grep result names a withheld file: its `filenames`, or the path
119// leading each line of its `content`.
120export function grepNamesWithheld(root: string, result: unknown): boolean {
121  if (typeof result !== 'object' || result === null) return false
122  const { filenames, content } = result as { filenames?: unknown; content?: unknown }
123  const named = Array.isArray(filenames) ? filenames.filter((name): name is string => typeof name === 'string') : []
124  if (named.some(path => isWithheldPath(root, path))) return true
125  return typeof content === 'string' && content.split(/\r?\n/).some(line => lineNamesWithheld(root, line))
126}
127
128export type ReviewerCall = { tool: string; file_path?: unknown; path?: unknown; pattern?: unknown; glob?: unknown }
129export type Verdict = 'allow' | 'deny' | 'scope'
130
131const isAbsoluteSpelling = (pattern: string) => /^([\\/]|[A-Za-z]:|~)/.test(pattern)
132// `{src,/etc}` expands to an absolute pattern the anchor never sees.
133const hasAbsoluteAlternative = (pattern: string) => /[{,]\s*([\\/~]|[A-Za-z]:)/.test(pattern)
134const isAbsolute = (path: string) => /^([\\/]|[A-Za-z]:[\\/])/.test(path)
135
136// The directory a glob is anchored at: everything before the segment holding its first wildcard.
137function anchor(pattern: string): string {
138  const wildcard = pattern.search(/[*?[{]/)
139  if (wildcard === -1) return pattern
140  const head = pattern.slice(0, wildcard)
141  return head.slice(0, Math.max(head.lastIndexOf('/'), head.lastIndexOf('\\')) + 1)
142}
143
144// A reviewer's Read, Grep or Glob: every field naming a place must land under
145// the root. Grep's `pattern` is a regex, not a place. A relative path is
146// refused rather than resolved, since the tool and `realPath` may resolve it
147// against different directories.
148export async function judgeCall(host: Host, call: ReviewerCall, root: string): Promise<Verdict> {
149  if (normalise(root) === '') return 'deny'
150  const named = call.tool === 'Read' ? call.file_path : call.path
151  if (named !== undefined && (typeof named !== 'string' || !isAbsolute(named))) return 'deny'
152  const places: unknown[] = []
153  if (call.tool === 'Read') places.push(call.file_path)
154  else if (call.path !== undefined) places.push(call.path)
155
156  const pattern = call.tool === 'Glob' ? call.pattern : call.tool === 'Grep' ? call.glob : undefined
157  if (typeof pattern === 'string') {
158    if (pattern.includes('..') || hasAbsoluteAlternative(pattern)) return 'deny'
159    const absolute = isAbsoluteSpelling(pattern)
160    const fixed = absolute ? anchor(pattern) : ''
161    if (absolute) places.push(fixed)
162    if (isWithheld(root, pattern.slice(fixed.length))) return 'deny'
163  }
164
165  for (const place of places) {
166    if (typeof place !== 'string' || !(await checkPath(host, place, root))) return 'deny'
167  }
168  return call.tool !== 'Read' && call.path === undefined ? 'scope' : 'allow'
169}
170
hooks/host.ts 81 lines
1import type { EngineInterface } from 'claude-code'
2
3export type EnvName = 'USERPROFILE' | 'HOME' | 'TEMP' | 'TMPDIR'
4
5export type RunResult = { exitCode: number; stdout: string; stderr: string }
6
7// What a failed process said: its stderr's first line, or its exit code when it said nothing.
8export const failure = ({ exitCode, stderr }: Pick<RunResult, 'exitCode' | 'stderr'>) =>
9  stderr.trim().split(/\r?\n/)[0] || `exit ${exitCode}`
10
11export const messageOf = (error: unknown) => (error instanceof Error ? error.message : String(error))
12
13// A symbolic link is kind `other`, whatever it leads to.
14export type Entry = { name: string; kind: 'file' | 'dir' | 'other'; isLink: boolean }
15
16export type McpConnection = { isConnected: true; server: string } | { isConnected: false; reason: string }
17
18export type Host = {
19  run(argv: string[], opts?: { cwd?: string; stdin?: string }): Promise<RunResult>
20  mcpTools(): Promise<string[]>
21  mcpConnect(key: string): Promise<McpConnection>
22  mcpCall(server: string, tool: string, args: Record<string, unknown>): Promise<unknown>
23  readFile(path: string): Promise<string>
24  writeFile(path: string, text: string): Promise<void>
25  exists(path: string): Promise<boolean>
26  realPath(path: string): Promise<string | undefined>
27  list(dir: string): Promise<Entry[]>
28  env(name: EnvName): Promise<string | undefined>
29  sessionId(): Promise<string>
30  pluginRoot: string
31}
32
33export function hostFrom($: EngineInterface): Host {
34  return {
35    async run(argv, opts) {
36      try {
37        const { exitCode, stdout, stderr } = await $.process.run(argv, opts)
38        return { exitCode, stdout, stderr }
39      } catch (error) {
40        return { exitCode: 127, stdout: '', stderr: error instanceof Error ? error.message : String(error) }
41      }
42    },
43    async mcpTools() {
44      return (await $.tool.list()).filter(t => t.mcp).map(t => t.name)
45    },
46    async mcpConnect(key) {
47      const connected = await $.mcp.connect(key)
48      return connected.isConnected ? { isConnected: true, server: connected.server } : { isConnected: false, reason: connected.reason }
49    },
50    mcpCall: (server, tool, args) => $.mcp.call(server, tool, args),
51    readFile: path => $.fs.read(path),
52    writeFile: (path, text) => $.fs.write(path, text),
53    exists: path => $.fs.exists(path),
54    async realPath(path) {
55      try {
56        return (await $.fs.stat(path, { resolve: true })).realPath
57      } catch {
58        return undefined
59      }
60    },
61    async list(dir) {
62      return (await $.fs.list(dir)).map(({ name, kind, isLink }) => ({ name, kind, isLink }))
63    },
64    // The engine lists the variables a module reads, so each name is spelled out.
65    async env(name) {
66      switch (name) {
67        case 'USERPROFILE':
68          return $.env.get('USERPROFILE')
69        case 'HOME':
70          return $.env.get('HOME')
71        case 'TEMP':
72          return $.env.get('TEMP')
73        case 'TMPDIR':
74          return $.env.get('TMPDIR')
75      }
76    },
77    sessionId: () => $.session.id(),
78    pluginRoot: $.plugin.root,
79  }
80}
81
hooks/plan.tsx 45 lines
1import { atom, read, update } from 'claude-code'
2import type { On } from 'claude-code'
3
4const planState = atom({ plugin: 'adversary', key: 'plan' } as const, null)
5const pendingState = atom({ plugin: 'adversary', key: 'planPending' } as const, false)
6
7export const registerPlanBand = (on: On): void => {
8  on('tool.call', { tool: 'ExitPlanMode' }, async ($, e, next) => {
9    const ran = await next(e)
10    // The plan is in the tool's result: its input carries none.
11    if (e.agentId !== undefined || ran.deny !== undefined || ran.isError === true) return ran
12    const plan = (ran.result as { plan: string | null } | undefined)?.plan
13    if (typeof plan !== 'string' || plan === '') return ran
14    try {
15      await update($, planState, () => plan)
16      await update($, pendingState, () => true)
17    } catch {
18      // Remembering the plan must never fail the tool call.
19    }
20    return ran
21  }).catch(($, e, next) => next(e))
22
23  // Only the user's own message moves past the plan; a task notification or a
24  // scheduled prompt arriving meanwhile must leave the band up.
25  on('prompt.submit', async ($, e, next) => {
26    if (e.isMeta || (e.origin.kind !== 'composer' && e.origin.kind !== 'bridge')) return next(e)
27    try {
28      await update($, pendingState, () => false)
29    } catch {
30      // A failed write must not block the prompt.
31    }
32    return next(e)
33  }).catch(($, e, next) => next(e))
34
35  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
36    if (e.props.hasSurvey || !(await read($, pendingState))) return next(e)
37    const { Box, Text } = $.ui.resolve(e)
38    return (
39      <Box>
40        <Text>Plan ready — /adversary plan to review it</Text>
41      </Box>
42    )
43  })
44}
45
hooks/review.ts 243 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, TurnCompleteInput } from 'claude-code'
3
4import type { Review } from '../types'
5import type { Args } from './args'
6import { MAX_ARTIFACT_CHARS, docArtifact, planArtifact, prArtifact, removeWorktree } from './artifact'
7import type { Artifact } from './artifact'
8import { classifyBrief, fetchBrief } from './brief'
9import type { Brief } from './brief'
10import { parseFindings, sortBySeverity } from './findings'
11import { hostFrom, messageOf } from './host'
12import type { Host } from './host'
13import { hasOutwardLinks } from './links'
14import { fillTemplate, homeDir, resolveTemplate } from './templates'
15import { isLive, openTriage } from './triage'
16
17type Api = EngineInterface
18
19const reviewState = atom({ plugin: 'adversary', key: 'review' } as const, null)
20const planState = atom({ plugin: 'adversary', key: 'plan' } as const, null)
21const selectedState = atom({ plugin: 'adversary', key: 'selected' } as const, 0)
22type Start = Exclude<Args, { sub: 'triage' }>
23type Prepared = { ok: true; artifact: Artifact; brief?: Brief; prompt: string } | { ok: false; error: string }
24
25const REVIEWER_TYPE = 'adversary:reviewer'
26
27export const REVIEWER = {
28  name: 'reviewer',
29  description: 'Adversarial reviewer that /adversary starts itself; never delegate to it.',
30  prompt: [
31    'You are an independent adversarial reviewer.',
32    'Your task message is everything you are given: what to review, where it is, and the form your answer must take.',
33    'Work only from it and from the files under the directory it names, using Read, Grep and Glob.',
34  ].join(' '),
35  tools: ['Read', 'Grep', 'Glob'],
36  // A spawn's --model overrides it; without one the reply promises the session model.
37  model: 'inherit',
38  background: true,
39  omitClaudeMd: true,
40} as const
41
42const TURN_FAILURES = {
43  aborted: 'The reviewer was interrupted',
44  error: 'The reviewer stopped on an API error',
45  refusal: 'The reviewer refused the review',
46} as const
47
48// Called once a review settles. The engine only lets `$` reach functions it can
49// resolve statically, so the triage pane is opened from here, not through a
50// callback another module assigns.
51export async function onReviewReady($: Api): Promise<void> {
52  await openTriage($, false)
53}
54
55// A running review blocks only while its agent is alive: after a reload or a
56// crash the state can still say running for an agent that is long gone.
57export async function isBlocked($: Api): Promise<boolean> {
58  const review = await read($, reviewState)
59  if (review?.status !== 'running' || review.agentId === undefined) return false
60  const agents = await $.agent.list()
61  return agents.some(agent => agent.id === review.agentId && isLive(agent))
62}
63
64// A spawn can resolve without the started agent's id (the test kit drops it
65// from a plugin's own spawn); the agent list still names it by type.
66async function newestLiveReviewer($: Api): Promise<string | undefined> {
67  const live = (await $.agent.list()).filter(agent => agent.type === REVIEWER_TYPE && isLive(agent))
68  return live.at(-1)?.id
69}
70
71const tmpBase = async (host: Host) => (await host.env('TEMP')) ?? (await host.env('TMPDIR')) ?? '/tmp'
72
73async function acquire($: Api, host: Host, args: Start, cwd: string) {
74  switch (args.sub) {
75    case 'pr':
76      return prArtifact(host, args.target, cwd, await tmpBase(host))
77    case 'plan':
78      return planArtifact(host, await read($, planState), cwd)
79    case 'doc':
80      return docArtifact(host, args.target, cwd)
81  }
82}
83
84async function promptFor(host: Host, args: Start, cwd: string, artifact: Artifact): Promise<Prepared> {
85  let brief: Brief | undefined
86  if (args.brief !== undefined) {
87    const fetched = await fetchBrief(host, classifyBrief(args.brief))
88    if (!fetched.ok) return fetched
89    brief = fetched.brief
90  }
91  const template = await resolveTemplate(host, args.sub, cwd, await homeDir(host))
92  if (!template.ok) return template
93  const filled = fillTemplate(template.text, template.path, { artifact: artifact.text, brief, focus: args.focus, root: artifact.root })
94  if (!filled.ok) return filled
95  return { ok: true, artifact, brief, prompt: filled.prompt }
96}
97
98async function resolved(host: Host, args: Start, cwd: string, artifact: Artifact): Promise<Prepared> {
99  // The guard compares resolved targets, so the root must be resolved the same
100  // way (a short 8.3 name, a subst drive, a junction) or nothing is under it.
101  const root = await host.realPath(artifact.root)
102  if (root === undefined) return { ok: false, error: `Could not resolve the review root ${artifact.root}` }
103  return promptFor(host, args, cwd, { ...artifact, root })
104}
105
106// A PR's diff is measured, before anything is fetched, by prArtifact.
107const SIZED = { doc: 'Document', plan: 'Plan' } as const
108
109const withoutUndefined = <T extends object>(value: T): T =>
110  Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as T
111
112async function record($: Api, host: Host, args: Start, prepared: Prepared & { ok: true }): Promise<void> {
113  const { artifact, brief } = prepared
114  const review: Review = withoutUndefined({
115    kind: args.sub,
116    target: args.sub === 'plan' ? '' : args.target,
117    briefLabel: brief?.label,
118    model: args.model,
119    root: artifact.root,
120    worktree: artifact.worktree,
121    pr: artifact.pr,
122    status: 'running' as const,
123    findings: [],
124    unparsed: [],
125    raw: '',
126    startedAt: await $.clock.now(),
127    hasOutwardLinks: await hasOutwardLinks(host, artifact.root),
128  })
129  await update($, reviewState, () => review)
130}
131
132// Prepares and records the review. Nothing else knows of a temp worktree until
133// the review holding it is stored, so any failure before then removes it here.
134async function prepareAndRecord($: Api, host: Host, args: Start, cwd: string): Promise<Prepared> {
135  const got = await acquire($, host, args, cwd)
136  if (!got.ok) return got
137  const { length } = got.artifact.text
138  if (args.sub !== 'pr' && length > MAX_ARTIFACT_CHARS) {
139    return { ok: false, error: `${SIZED[args.sub]} is ${length} characters; too large for one review (limit ${MAX_ARTIFACT_CHARS})` }
140  }
141  let prepared: Prepared
142  try {
143    prepared = await resolved(host, args, cwd, got.artifact)
144    if (prepared.ok) await record($, host, args, prepared)
145  } catch (error) {
146    prepared = { ok: false, error: `Could not prepare the review: ${messageOf(error)}` }
147  }
148  if (!prepared.ok && got.artifact.worktree !== undefined) await removeWorktree(host, cwd, got.artifact.worktree)
149  return prepared
150}
151
152export async function startReview($: Api, args: Start): Promise<string> {
153  if (await isBlocked($)) return 'Review in progress'
154
155  const host = hostFrom($)
156  const cwd = await $.session.root()
157  // Removed first: a new review of the same PR head checks out to the same path.
158  const previous = await read($, reviewState)
159  if (previous?.worktree !== undefined) await removeWorktree(host, cwd, previous.worktree)
160
161  const prepared = await prepareAndRecord($, host, args, cwd)
162  if (!prepared.ok) return prepared.error
163
164  const { prompt } = prepared
165  const target = args.sub === 'plan' ? '' : args.target
166
167  let spawned: Awaited<ReturnType<Api['agent']['spawn']>>
168  try {
169    spawned = await $.agent.spawn({
170      subagentType: REVIEWER_TYPE,
171      prompt,
172      description: 'adversarial review',
173      ...(args.model === undefined ? {} : { model: args.model }),
174    })
175  } catch (error) {
176    spawned = { deny: error instanceof Error ? error.message : String(error) }
177  }
178  const agentId = spawned.deny === undefined ? (spawned.agentId ?? (await newestLiveReviewer($))) : undefined
179  if (agentId === undefined) {
180    const error = spawned.deny ?? 'no reviewer agent was started'
181    await failReview($, undefined, error, '')
182    return `The reviewer could not start: ${error}`
183  }
184  await update($, reviewState, current => (current === null ? current : { ...current, agentId }))
185
186  return `Reviewing ${[args.sub, target].filter(Boolean).join(' ')} with ${args.model ?? 'session model'}; findings will open in the triage pane.`
187}
188
189// Settles the running review whose agent is `agentId` (none for a spawn that
190// never started one); any other review, or one already settled, is left alone.
191async function settle($: Api, agentId: string | undefined, settled: (review: Review) => Review): Promise<void> {
192  const isOurs = (review: Review | null): review is Review => review?.status === 'running' && review.agentId === agentId
193  if (!isOurs(await read($, reviewState))) return
194  await update($, reviewState, current => (isOurs(current) ? settled(current) : current))
195  await update($, selectedState, () => 0)
196  await onReviewReady($)
197}
198
199export async function finishReview($: Api, agentId: string, answer: string): Promise<void> {
200  await settle($, agentId, review => {
201    const parsed = parseFindings(answer, review.root)
202    if (!parsed.ok) return { ...review, status: 'failed', error: 'The reviewer gave no json block', raw: answer }
203    return { ...review, status: 'done', findings: sortBySeverity(parsed.findings), unparsed: parsed.unparsed, raw: answer }
204  })
205}
206
207export async function failReview($: Api, agentId: string | undefined, error: string, raw: string): Promise<void> {
208  await settle($, agentId, review => ({ ...review, status: 'failed', error, raw }))
209}
210
211export async function reviewerTurnEnded($: Api, e: TurnCompleteInput): Promise<void> {
212  if (e.agentId === undefined) return
213  if (e.reason === 'answer') await finishReview($, e.agentId, e.answer)
214  else await failReview($, e.agentId, TURN_FAILURES[e.reason], e.answer)
215}
216
217const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
218const mentions = (text: string, id: string) => new RegExp(`(?<![\\w-])${escapeRegExp(id)}(?![\\w-])`).test(text)
219
220// Whether a task notification's text names a reviewer: `prompt.submit` carries
221// no task id of its own, so the id is looked for in the text the engine wrote.
222export async function namesReviewer($: Api, text: string): Promise<boolean> {
223  const stored = (await read($, reviewState))?.agentId
224  if (stored !== undefined && mentions(text, stored)) return true
225  const reviewers = (await $.agent.list()).filter(agent => agent.type === REVIEWER_TYPE)
226  return reviewers.some(agent => mentions(text, agent.id))
227}
228
229export type Confinement = { root: string; canSearch: boolean }
230
231// Where a subagent's file access is confined, or undefined for a call that is
232// not a reviewer's. Any reviewer is confined, not only the current one, so an
233// agent from an earlier review, or one whose id is not stored yet, is never
234// let loose; with no review on record it may reach nothing (root '').
235export async function reviewerScope($: Api, agentId: string | undefined): Promise<Confinement | undefined> {
236  if (agentId === undefined) return undefined
237  const review = await read($, reviewState)
238  const confined = { root: review?.root ?? '', canSearch: review?.hasOutwardLinks === false }
239  if (review?.agentId === agentId) return confined
240  const agent = (await $.agent.list()).find(a => a.id === agentId)
241  return agent?.type === REVIEWER_TYPE ? confined : undefined
242}
243
hooks/templates.ts 61 lines
1import type { Kind } from '../types'
2import type { Brief } from './brief'
3import { OUTPUT_CONTRACT } from './findings'
4import type { Host } from './host'
5
6export type Fill = { artifact: string; brief?: Brief; focus?: string; root: string }
7type Resolved = { ok: true; path: string; text: string } | { ok: false; error: string }
8type Filled = { ok: true; prompt: string } | { ok: false; error: string }
9
10const KINDS: Kind[] = ['pr', 'plan', 'doc']
11const REQUIRED = ['{{artifact}}', '{{output_contract}}']
12
13// A section placeholder alone on its line takes the line with it when it has
14// no value; every other placeholder is replaced in place. One pass keeps
15// substituted text from being scanned for placeholders.
16const PLACEHOLDER = /^[ \t]*\{\{(brief|focus)\}\}[ \t]*(\r?\n|$)|\{\{(artifact|output_contract|root|brief|focus)\}\}/gm
17
18const shippedPath = (host: Host, kind: Kind) => `${host.pluginRoot}/templates/${kind}.md`
19const userPath = (home: string, kind: Kind) => `${home}/.claude/adversary/${kind}.md`
20const repoPath = (repoRoot: string, kind: Kind) => `${repoRoot}/.claude/adversary/${kind}.md`
21
22export async function homeDir(host: Host): Promise<string> {
23  return (await host.env('USERPROFILE')) ?? (await host.env('HOME')) ?? ''
24}
25
26// An empty home would put the user level at the filesystem root.
27export async function seedUserTemplates(host: Host, home: string): Promise<void> {
28  if (home === '') return
29  for (const kind of KINDS) {
30    const target = userPath(home, kind)
31    if (await host.exists(target)) continue
32    await host.writeFile(target, await host.readFile(shippedPath(host, kind)))
33  }
34}
35
36export async function resolveTemplate(host: Host, kind: Kind, repoRoot: string, home: string): Promise<Resolved> {
37  const user = home === '' ? [] : [userPath(home, kind)]
38  for (const path of [repoPath(repoRoot, kind), ...user, shippedPath(host, kind)]) {
39    if (await host.exists(path)) return { ok: true, path, text: await host.readFile(path) }
40  }
41  return { ok: false, error: `No ${kind} template found; reinstall the adversary plugin` }
42}
43
44export function fillTemplate(text: string, path: string, fill: Fill): Filled {
45  const missing = REQUIRED.find(placeholder => !text.includes(placeholder))
46  if (missing !== undefined) return { ok: false, error: `Template ${path} is missing ${missing}` }
47
48  const sections = {
49    brief: fill.brief === undefined ? undefined : `## Brief (${fill.brief.label})\n\n${fill.brief.text}`,
50    focus: fill.focus === undefined ? undefined : `## Focus\n\n${fill.focus}`,
51  }
52  const values: Record<string, string> = { artifact: fill.artifact, output_contract: OUTPUT_CONTRACT, root: fill.root }
53
54  const prompt = text.replace(PLACEHOLDER, (_match, lineName: 'brief' | 'focus' | undefined, newline: string | undefined, inlineName: string | undefined) => {
55    if (lineName !== undefined) return sections[lineName] === undefined ? '' : `${sections[lineName]}${newline}`
56    const name = inlineName as string
57    return name === 'brief' || name === 'focus' ? (sections[name] ?? '') : (values[name] ?? '')
58  })
59  return { ok: true, prompt }
60}
61
hooks/triage.tsx 445 lines
1import { atom, read, update } from 'claude-code'
2import type { AgentInfo, EngineInterface, RenderInput } from 'claude-code'
3
4import type { Decision, Finding, Review, Severity } from '../types'
5import { removeWorktree } from './artifact'
6import { draftText, relativeTo, savedMarkdown, whereText } from './findings'
7import { failure, hostFrom } from './host'
8import type { Host } from './host'
9
10type Api = EngineInterface
11
12export const PANE = 'adversary'
13
14const reviewState = atom({ plugin: 'adversary', key: 'review' } as const, null)
15const selectedState = atom({ plugin: 'adversary', key: 'selected' } as const, 0)
16const editingState = atom({ plugin: 'adversary', key: 'editing' } as const, false)
17
18const NOTHING_TO_SEND = 'Nothing to send — mark findings with s first'
19const REVIEW_OPENING = 'An independent reviewer raised these findings.'
20const REVIEWS_DIR = '.claude/adversary/reviews'
21const REVIEWER_GONE = 'The reviewer is gone'
22const ENDED: string[] = ['completed', 'failed', 'killed']
23
24const SHORT: Record<Severity, string> = { critical: 'CRIT', high: 'HIGH', medium: 'MED', low: 'LOW' }
25const MARK: Record<Decision, string> = { send: '✓', drop: '✗', undecided: '·' }
26
27const claimOf = (finding: Finding) => finding.editedClaim ?? finding.claim
28const sends = (review: Review) => review.findings.filter(finding => finding.decision === 'send')
29
30export const isLive = (agent: AgentInfo) => !ENDED.includes(agent.status)
31
32// A running review whose agent is no longer live (after a reload or a crash)
33// will never settle. One with no agent id yet is still being started.
34async function isOrphaned($: Api, review: Review): Promise<boolean> {
35  if (review.status !== 'running' || review.agentId === undefined) return false
36  const agents = await $.agent.list()
37  return !agents.some(agent => agent.id === review.agentId && isLive(agent))
38}
39
40// The review as the pane shows it. Worked out on each read rather than
41// written back, since a render must not write state.
42async function shown($: Api, review: Review): Promise<Review> {
43  try {
44    return (await isOrphaned($, review)) ? { ...review, status: 'failed', error: REVIEWER_GONE } : review
45  } catch {
46    return review
47  }
48}
49
50export function paneTitle(review: Review): string {
51  const target = review.kind === 'pr' ? `PR #${review.pr?.number ?? review.target}` : review.kind === 'plan' ? 'plan' : review.target
52  return ['Adversary', target, review.briefLabel, review.model ?? 'session'].filter(Boolean).join(' · ')
53}
54
55export function summary(findings: Finding[]): string {
56  const count = (decision: Decision) => findings.filter(finding => finding.decision === decision).length
57  return `${findings.length} findings · ${count('send')} send · ${count('drop')} drop · ${count('undecided')} undecided`
58}
59
60// New-side line numbers per file, read from the unified diff's hunks: a
61// comment can only be placed on one of these.
62export function diffLines(diff: string): Map<string, Set<number>> {
63  const lines = new Map<string, Set<number>>()
64  let file: Set<number> | undefined
65  let line = 0
66  let oldLeft = 0
67  let newLeft = 0
68  for (const raw of diff.split('\n')) {
69    const text = raw.endsWith('\r') ? raw.slice(0, -1) : raw
70    // Inside a hunk its counts say what each line is, so a removed line that
71    // reads `--- x` or an added one reading `+++ x` is never taken for a header.
72    if (oldLeft > 0 || newLeft > 0) {
73      const mark = text[0] ?? ' '
74      if (mark === '\\') continue
75      if (mark === '-') {
76        oldLeft--
77        continue
78      }
79      if (mark !== '+') oldLeft--
80      newLeft--
81      file?.add(line)
82      line++
83      continue
84    }
85    if (text.startsWith('diff --git ')) {
86      file = undefined
87    } else if (text.startsWith('+++ ')) {
88      const path = text.slice(4).replace(/\t.*$/, '')
89      if (path === '/dev/null') {
90        file = undefined
91      } else {
92        const name = path.replace(/^b\//, '')
93        file = lines.get(name) ?? new Set<number>()
94        lines.set(name, file)
95      }
96    } else {
97      const hunk = /^@@ -\d+(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(text)
98      if (hunk) {
99        oldLeft = hunk[1] === undefined ? 1 : Number(hunk[1])
100        line = Number(hunk[2])
101        newLeft = hunk[3] === undefined ? 1 : Number(hunk[3])
102      }
103    }
104  }
105  return lines
106}
107
108export type ReviewPayload = {
109  event: 'COMMENT'
110  commit_id: string
111  body: string
112  comments: { path: string; line: number; side: 'RIGHT'; body: string }[]
113}
114
115export type StalePayload = { event: 'COMMENT'; body: string }
116
117export function reviewPayload(review: Review, headRefOid: string, lines: Map<string, Set<number>>): ReviewPayload {
118  const comments: ReviewPayload['comments'] = []
119  const rest: Finding[] = []
120  for (const finding of sends(review)) {
121    const { location } = finding
122    if ('file' in location && location.line !== undefined) {
123      const path = relativeTo(review.root, location.file).replace(/\\/g, '/')
124      if (lines.get(path)?.has(location.line)) {
125        comments.push({ path, line: location.line, side: 'RIGHT', body: `${claimOf(finding)}\n\nEvidence: ${finding.evidence}` })
126        continue
127      }
128    }
129    rest.push(finding)
130  }
131  const listed = numbered(rest)
132  const body = [REVIEW_OPENING, ...(listed.length > 0 ? ['', ...listed] : [])].join('\n')
133  return { event: 'COMMENT', commit_id: headRefOid, body, comments }
134}
135
136// The PR moved on since the review (or its head could not be read): line
137// numbers from the reviewed commit may point at other code now, so nothing is
138// anchored to a commit or a line.
139export function staleReviewPayload(review: Review, reviewedOid: string): StalePayload {
140  const opening = `The PR has new commits since this review (reviewed ${reviewedOid.slice(0, 7)}).`
141  return { event: 'COMMENT', body: [opening, '', ...numbered(sends(review))].join('\n') }
142}
143
144const numbered = (findings: Finding[]) =>
145  findings.flatMap((finding, index) => [
146    `${index + 1}. [${finding.severity.toUpperCase()}] ${whereText(finding.location)} — ${claimOf(finding)}`,
147    `   Evidence: ${finding.evidence}`,
148  ])
149
150async function currentHead(host: Host, number: number, cwd: string): Promise<string | undefined> {
151  const viewed = await host.run(['gh', 'pr', 'view', String(number), '--json', 'headRefOid'], { cwd })
152  if (viewed.exitCode !== 0) return undefined
153  try {
154    const head = (JSON.parse(viewed.stdout) as { headRefOid?: unknown }).headRefOid
155    return typeof head === 'string' ? head : undefined
156  } catch {
157    return undefined
158  }
159}
160
161export async function postReview(host: Host, review: Review, cwd: string): Promise<string> {
162  if (review.pr === undefined) return 'Post failed: not a pull request review'
163  const { number, headRefOid } = review.pr
164  try {
165    let payload: ReviewPayload | StalePayload
166    if ((await currentHead(host, number, cwd)) === headRefOid) {
167      const diff = await host.run(['gh', 'pr', 'diff', String(number)], { cwd })
168      if (diff.exitCode !== 0) return `Post failed: ${failure(diff)}`
169      payload = reviewPayload(review, headRefOid, diffLines(diff.stdout))
170    } else {
171      payload = staleReviewPayload(review, headRefOid)
172    }
173    const posted = await host.run(['gh', 'api', `repos/{owner}/{repo}/pulls/${number}/reviews`, '-X', 'POST', '--input', '-'], {
174      cwd,
175      stdin: JSON.stringify(payload),
176    })
177    if (posted.exitCode !== 0) return `Post failed: ${failure(posted)}`
178    let url: unknown
179    try {
180      url = (JSON.parse(posted.stdout) as { html_url?: unknown }).html_url
181    } catch {
182      url = undefined
183    }
184    return typeof url === 'string' ? `Review posted: ${url}` : 'Review posted'
185  } catch (error) {
186    return `Post failed: ${error instanceof Error ? error.message : String(error)}`
187  }
188}
189
190const slug = (text: string) =>
191  text
192    .toLowerCase()
193    .replace(/[^a-z0-9]+/g, '-')
194    .replace(/^-+|-+$/g, '')
195
196// The saved file's name without `.md`: <date>-pr-<n>, <date>-plan or <date>-doc-<basename>.
197export function saveName(review: Review, date: string): string {
198  switch (review.kind) {
199    case 'pr':
200      return `${date}-pr-${review.pr?.number ?? slug(review.target)}`
201    case 'plan':
202      return `${date}-plan`
203    case 'doc': {
204      const base = review.target.split(/[\\/]/).pop() ?? ''
205      return `${date}-doc-${slug(base.replace(/\.[^.]*$/, '')) || 'doc'}`
206    }
207  }
208}
209
210// Writes the review under <cwd>/.claude/adversary/reviews, never over an
211// existing file, and returns the path written relative to `cwd`.
212export async function saveReview(host: Host, review: Review, cwd: string, date: string): Promise<string> {
213  const name = saveName(review, date)
214  const dir = `${cwd.replace(/[\\/]+$/, '')}/${REVIEWS_DIR}`
215  let file = `${name}.md`
216  for (let n = 2; await host.exists(`${dir}/${file}`); n++) file = `${name}-${n}.md`
217  await host.writeFile(`${dir}/${file}`, savedMarkdown(review, date))
218  return `${REVIEWS_DIR}/${file}`
219}
220
221const localDate = (ms: number) => {
222  const d = new Date(ms)
223  const pad = (n: number) => String(n).padStart(2, '0')
224  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
225}
226
227// Opens the pane on the last review. Unasked (the review just finished) a
228// pane the surface does not place is announced with a toast instead.
229export async function openTriage($: Api, isAsked: boolean): Promise<string> {
230  const review = await read($, reviewState)
231  if (review === null) return 'No review yet'
232  await update($, editingState, () => false)
233  let opened: Awaited<ReturnType<Api['ui']['open']>>
234  try {
235    opened = await $.ui.open({ id: PANE, title: paneTitle(review), ...(isAsked ? { focus: true } : {}) })
236  } catch (error) {
237    opened = { isPlaced: false, reason: error instanceof Error ? error.message : String(error) } as typeof opened
238  }
239  if (!opened.isPlaced && !isAsked) {
240    $.ui.toast(
241      review.status === 'failed'
242        ? `Review failed: ${review.error ?? 'unknown error'} — /adversary triage`
243        : `Review done: ${review.findings.length} findings — /adversary triage`,
244    )
245  }
246  if (!opened.isPlaced) return `The triage pane could not open: ${opened.reason}`
247  return 'Triage pane opened.'
248}
249
250async function selectedIndex($: Api, review: Review): Promise<number> {
251  const selected = await read($, selectedState)
252  return Math.min(Math.max(selected, 0), Math.max(review.findings.length - 1, 0))
253}
254
255async function changeSelected($: Api, change: (finding: Finding) => Finding): Promise<void> {
256  const review = await read($, reviewState)
257  if (review === null || review.findings.length === 0) return
258  const index = await selectedIndex($, review)
259  const id = review.findings[index]!.id
260  await update($, reviewState, current =>
261    current === null ? current : { ...current, findings: current.findings.map(finding => (finding.id === id ? change(finding) : finding)) },
262  )
263}
264
265export async function decide($: Api, decision: Decision): Promise<void> {
266  await changeSelected($, finding => ({ ...finding, decision }))
267}
268
269export async function move($: Api, delta: 1 | -1): Promise<void> {
270  const review = await read($, reviewState)
271  if (review === null) return
272  const index = await selectedIndex($, review)
273  const next = Math.min(Math.max(index + delta, 0), Math.max(review.findings.length - 1, 0))
274  await update($, selectedState, () => next)
275}
276
277// An empty edit, or one back to the reviewer's words, drops the edit.
278export async function editClaim($: Api, text: string): Promise<void> {
279  const claim = text.trim()
280  await changeSelected($, ({ editedClaim: _old, ...finding }) => (claim === '' || claim === finding.claim ? finding : { ...finding, editedClaim: claim }))
281  await update($, editingState, () => false)
282}
283
284async function startEdit($: Api): Promise<void> {
285  await update($, editingState, () => true)
286}
287
288// The person's close mark raises ui.close too; register.tsx releases the
289// worktree there. Released here as well, since a plugin's own close may not
290// pass through its own hook; a second release finds nothing to do.
291async function closePane($: Api): Promise<void> {
292  await $.ui.close({ id: PANE })
293  await releaseWorktree($, false)
294}
295
296export async function draft($: Api): Promise<void> {
297  const review = await read($, reviewState)
298  if (review === null) return
299  if (sends(review).length === 0) return $.ui.toast(NOTHING_TO_SEND)
300  const filled = await $.prompt.fill({ text: draftText(review.findings), mode: 'replace' })
301  if (!filled.isFilled) $.ui.toast('The prompt box is not available; Draft did nothing')
302}
303
304export async function post($: Api): Promise<void> {
305  const review = await read($, reviewState)
306  if (review?.kind !== 'pr') return
307  if (sends(review).length === 0) return $.ui.toast(NOTHING_TO_SEND)
308  try {
309    const cwd = await $.session.root()
310    $.ui.toast(await postReview(hostFrom($), review, cwd))
311  } catch (error) {
312    $.ui.toast(`Post failed: ${error instanceof Error ? error.message : String(error)}`)
313  }
314}
315
316export async function save($: Api): Promise<void> {
317  const review = await read($, reviewState)
318  if (review === null) return
319  try {
320    const cwd = await $.session.root()
321    const path = await saveReview(hostFrom($), review, cwd, localDate(await $.clock.now()))
322    $.ui.toast(`Saved ${path}`)
323  } catch (error) {
324    $.ui.toast(`Save failed: ${error instanceof Error ? error.message : String(error)}`)
325  }
326}
327
328// Removes the review's temp worktree, unless the reviewer is still reading it
329// and `isEnding` is false. It never throws, so a close or a session end is
330// never held up by it.
331export async function releaseWorktree($: Api, isEnding: boolean): Promise<void> {
332  try {
333    const review = await read($, reviewState)
334    if (review?.worktree === undefined) return
335    if (review.status === 'running' && !isEnding && !(await isOrphaned($, review))) return
336    const host = hostFrom($)
337    const cwd = await $.session.root()
338    const removed = await removeWorktree(host, cwd, review.worktree)
339    if (removed.exitCode !== 0) {
340      // A worktree still on disk is kept on record so a later close or session
341      // end tries again; one already gone only leaves git's bookkeeping behind.
342      if (await host.exists(review.worktree)) {
343        $.ui.toast(`Could not remove the review worktree ${review.worktree}: ${failure(removed)}`)
344        return
345      }
346      await host.run(['git', 'worktree', 'prune'], { cwd })
347    }
348    await update($, reviewState, current => {
349      if (current === null || current.worktree !== review.worktree) return current
350      const { worktree: _gone, ...rest } = current
351      return rest
352    })
353  } catch (error) {
354    $.ui.log(`Could not remove the review worktree: ${error instanceof Error ? error.message : String(error)}`, { to: 'debug' })
355  }
356}
357
358export async function renderPane($: Api, e: RenderInput<'Pane'>) {
359  const elements = $.ui.resolve(e)
360  const { Box, Button, Text } = elements
361  // Mobile draws no Input, so there the claim cannot be edited.
362  const Input = 'Input' in elements ? elements.Input : undefined
363  const stored = await read($, reviewState)
364  if (stored === null) return <Text dimColor>No review yet</Text>
365  const review = await shown($, stored)
366
367  const title = <Text bold>{paneTitle(review)}</Text>
368  const close = <Button key="close" label="Close" hotkey="c" role="dismiss" onPress={() => closePane($)} />
369  if (review.status === 'running') {
370    return (
371      <Box flexDirection="column">
372        {title}
373        <Text dimColor>Review running…</Text>
374        <Box>{close}</Box>
375      </Box>
376    )
377  }
378  if (review.status === 'failed') {
379    return (
380      <Box flexDirection="column">
381        {title}
382        <Text color="red">Review failed: {review.error ?? 'unknown error'}</Text>
383        {review.raw !== '' && <Text dimColor>{review.raw}</Text>}
384        <Box>{close}</Box>
385      </Box>
386    )
387  }
388
389  const selected = await selectedIndex($, review)
390  const isEditing = await read($, editingState)
391  const current = review.findings[selected]
392  const count = sends(review).length
393  return (
394    <Box flexDirection="column">
395      {title}
396      <Text>{summary(review.findings)}</Text>
397      <Text> </Text>
398      {review.findings.length === 0 && <Text>No findings.</Text>}
399      {review.findings.map((finding, index) => (
400        <Box key={`finding-${finding.id}`} flexDirection="column">
401          <Text bold={index === selected}>
402            {index === selected ? '▸' : ' '} {MARK[finding.decision]} {SHORT[finding.severity].padEnd(4)} {whereText(finding.location)}
403            {finding.editedClaim === undefined ? '' : ' (edited)'}
404          </Text>
405          {index === selected && (
406            <Box flexDirection="column" paddingLeft={4}>
407              <Text>{claimOf(finding)}</Text>
408              <Text dimColor>Evidence: {finding.evidence}</Text>
409            </Box>
410          )}
411        </Box>
412      ))}
413      {review.unparsed.length > 0 && (
414        <Box flexDirection="column">
415          <Text> </Text>
416          <Text bold>Unparsed</Text>
417          {review.unparsed.map((entry, index) => (
418            <Text key={`unparsed-${index}`} dimColor>
419              {entry.reason}: {entry.raw}
420            </Text>
421          ))}
422        </Box>
423      )}
424      <Text> </Text>
425      {isEditing && current !== undefined && Input !== undefined ? (
426        <Input key="claim" label="Claim: " value={claimOf(current)} submitLabel="save" autoFocus onSubmit={(text: string) => editClaim($, text)} />
427      ) : (
428        <Box flexWrap="wrap">
429          {review.findings.length > 0 && <Button key="send" label="Send" hotkey="s" onPress={() => decide($, 'send')} />}
430          {review.findings.length > 0 && <Button key="drop" label="Drop" hotkey="d" onPress={() => decide($, 'drop')} />}
431          {review.findings.length > 0 && Input !== undefined && (
432            <Button key="edit" label="Edit" hotkey="e" onPress={() => startEdit($)} />
433          )}
434          {review.findings.length > 0 && <Button key="down" label="Down" hotkey="j" onPress={() => move($, 1)} />}
435          {review.findings.length > 0 && <Button key="up" label="Up" hotkey="k" onPress={() => move($, -1)} />}
436          <Button key="draft" label={`Draft (${count})`} hotkey="r" variant="primary" onPress={() => draft($)} />
437          {review.kind === 'pr' && <Button key="post" label="Post" hotkey="p" onPress={() => post($)} />}
438          <Button key="save" label="Save" hotkey="w" onPress={() => save($)} />
439          {close}
440        </Box>
441      )}
442    </Box>
443  )
444}
445
hooks/artifact.ts 109 lines
1import type { PrRef } from '../types'
2import { GH_UNAVAILABLE } from './brief'
3import { failure, messageOf } from './host'
4import type { Host, RunResult } from './host'
5
6export const MAX_ARTIFACT_CHARS = 200_000
7
8export type Artifact = { text: string; root: string; worktree?: string; pr?: PrRef }
9type Got = { ok: true; artifact: Artifact } | { ok: false; error: string }
10
11const isAuthFailure = (stderr: string) => /gh auth login|not logged in|authenticat/i.test(stderr)
12
13// `host/owner/repo` of a repository or PR web URL, lower-cased: GitHub names are
14// case-insensitive, and an enterprise host can hold the same owner/repo.
15function repositoryOf(url: string): string | undefined {
16  const match = /^https?:\/\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?(?:\/|$)/i.exec(url.trim())
17  return match ? `${match[1]}/${match[2]}/${match[3]}`.toLowerCase() : undefined
18}
19
20type Repository = { ok: true; key: string; name: string } | { ok: false; error: string }
21
22async function thisRepository(host: Host, cwd: string): Promise<Repository> {
23  const viewed = await host.run(['gh', 'repo', 'view', '--json', 'nameWithOwner,url'], { cwd })
24  if (viewed.exitCode !== 0) return { ok: false, error: `Could not tell which repository this is: ${failure(viewed)}` }
25  try {
26    const { nameWithOwner, url } = JSON.parse(viewed.stdout) as { nameWithOwner: string; url: string }
27    const key = repositoryOf(url)
28    // Never let an unreadable URL match an unreadable URL.
29    return { ok: true, key: key ?? '\0', name: nameWithOwner }
30  } catch {
31    return { ok: false, error: 'Could not tell which repository this is: unexpected gh output' }
32  }
33}
34
35export async function prArtifact(host: Host, target: string, cwd: string, tmpBase: string): Promise<Got> {
36  const view = await host.run(['gh', 'pr', 'view', target, '--json', 'number,title,body,headRefOid,baseRefName,url'], { cwd })
37  if (view.exitCode === 127 || (view.exitCode !== 0 && isAuthFailure(view.stderr))) return { ok: false, error: GH_UNAVAILABLE }
38  if (view.exitCode !== 0) return { ok: false, error: `Could not read PR ${target}: ${failure(view)}` }
39
40  let pr: { number: number; title: string; body: string; headRefOid: string; url: string }
41  try {
42    pr = JSON.parse(view.stdout)
43  } catch {
44    return { ok: false, error: `Could not read PR ${target}: unexpected gh output` }
45  }
46
47  // gh reads a PR URL from any repository, but the fetch below and Post both
48  // go to this one, under the same number.
49  const here = await thisRepository(host, cwd)
50  if (!here.ok) return here
51  if (repositoryOf(pr.url) !== here.key) return { ok: false, error: `PR ${pr.url} is not in this repository (${here.name})` }
52
53  const diff = await host.run(['gh', 'pr', 'diff', target], { cwd })
54  if (diff.exitCode !== 0) return { ok: false, error: `Could not read PR ${target}: ${failure(diff)}` }
55  if (diff.stdout.length > MAX_ARTIFACT_CHARS) {
56    return { ok: false, error: `PR diff is ${diff.stdout.length} characters; too large for one review (limit ${MAX_ARTIFACT_CHARS})` }
57  }
58
59  const ref: PrRef = { number: pr.number, url: pr.url, headRefOid: pr.headRefOid }
60  const text = `# PR #${pr.number}: ${pr.title}\n\n${pr.body}\n\n## Diff\n\n${diff.stdout}`
61
62  const head = await host.run(['git', 'rev-parse', 'HEAD'], { cwd })
63  if (head.exitCode === 0 && head.stdout.trim() === pr.headRefOid) {
64    // Edits not in the PR would be read as if they were.
65    const status = await host.run(['git', 'status', '--porcelain'], { cwd })
66    if (status.exitCode === 0 && status.stdout.trim() === '') return { ok: true, artifact: { text, root: cwd, pr: ref } }
67  }
68
69  // Per session, so the leftover cleanup below never removes a checkout another session is reviewing.
70  const session = (await host.sessionId()).replace(/[^A-Za-z0-9]/g, '').slice(0, 8)
71  const path = `${tmpBase}/adversary-pr-${pr.number}-${pr.headRefOid.slice(0, 7)}-${session}`
72  const failed = (ran: RunResult): Got => ({ ok: false, error: `Could not check out PR #${pr.number}: ${failure(ran)}` })
73  const fetch = await host.run(['git', 'fetch', 'origin', `pull/${pr.number}/head`], { cwd })
74  if (fetch.exitCode !== 0) return failed(fetch)
75  // origin need not be the repository gh read (a fork's clone), and the PR can
76  // move between gh's answer and the fetch.
77  const fetched = await host.run(['git', 'rev-parse', 'FETCH_HEAD'], { cwd })
78  if (fetched.exitCode !== 0) return failed(fetched)
79  if (fetched.stdout.trim() !== pr.headRefOid) return { ok: false, error: `PR ${pr.number} changed while it was being fetched; try again` }
80  // A crash or a lost state can leave a checkout of this same head behind.
81  if (await host.exists(path)) {
82    await removeWorktree(host, cwd, path)
83    await host.run(['git', 'worktree', 'prune'], { cwd })
84  }
85  const add = await host.run(['git', 'worktree', 'add', '--detach', path, pr.headRefOid], { cwd })
86  if (add.exitCode !== 0) return failed(add)
87
88  return { ok: true, artifact: { text, root: path, worktree: path, pr: ref } }
89}
90
91export async function planArtifact(_host: Host, plan: string | null, cwd: string): Promise<Got> {
92  if (plan === null) return { ok: false, error: 'No plan this session' }
93  return { ok: true, artifact: { text: plan, root: cwd } }
94}
95
96export async function docArtifact(host: Host, path: string, cwd: string): Promise<Got> {
97  const full = /^([A-Za-z]:)?[\\/]/.test(path) ? path : `${cwd.replace(/[\\/]$/, '')}/${path}`
98  if (!(await host.exists(full))) return { ok: false, error: `Document not found: ${path}` }
99  try {
100    return { ok: true, artifact: { text: await host.readFile(full), root: cwd } }
101  } catch (error) {
102    return { ok: false, error: `Could not read ${path}: ${messageOf(error)}` }
103  }
104}
105
106export async function removeWorktree(host: Host, cwd: string, path: string): Promise<RunResult> {
107  return host.run(['git', 'worktree', 'remove', '--force', path], { cwd })
108}
109
hooks/brief.ts 103 lines
1import type { Host } from './host'
2
3export type BriefSource = { kind: 'jira'; key: string } | { kind: 'github'; ref: string } | { kind: 'text'; text: string }
4export type Brief = { label: string; text: string }
5type Result = { ok: true; brief: Brief } | { ok: false; error: string }
6type McpResult = { content: { text?: string }[]; isError: boolean }
7
8const RESOURCES_TOOL = 'getAccessibleAtlassianResources'
9const ISSUE_TOOL = 'getJiraIssue'
10
11const JIRA_KEY = /^[A-Z][A-Z0-9]+-\d+$/
12const GITHUB_NUMBER = /^#(\d+)$/
13const GITHUB_ISSUE_URL = /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/issues\/(\d+)$/
14
15const BUNDLED_SERVER = 'atlassian'
16
17const JIRA_NOT_CONNECTED = 'Jira not connected; paste the ticket with --brief "..."'
18const JIRA_SIGN_IN = 'Atlassian needs you to sign in: run /mcp, or paste the ticket with --brief "..."'
19export const GH_UNAVAILABLE = 'gh is not available: run gh auth login'
20
21export function classifyBrief(value: string): BriefSource {
22  if (JIRA_KEY.test(value)) return { kind: 'jira', key: value }
23  const number = GITHUB_NUMBER.exec(value)
24  if (number) return { kind: 'github', ref: number[1]! }
25  if (GITHUB_ISSUE_URL.test(value)) return { kind: 'github', ref: value }
26  return { kind: 'text', text: value }
27}
28
29export async function fetchBrief(host: Host, source: BriefSource): Promise<Result> {
30  if (source.kind === 'text') return { ok: true, brief: { label: 'pasted brief', text: source.text } }
31  return source.kind === 'jira' ? fetchJira(host, source.key) : fetchGithub(host, source.ref)
32}
33
34// The plugin bundles the Atlassian server in .mcp.json; `connect` answers with
35// the name to call it by, or with the name of the same server the session
36// already runs. Anything else falls back to the tool-name search below.
37async function atlassianServer(host: Host): Promise<{ server: string } | { error: string }> {
38  const bundled = await host.mcpConnect(BUNDLED_SERVER).catch(() => undefined)
39  if (bundled?.isConnected) return { server: bundled.server }
40  const running = await runningAtlassianServer(host)
41  if (running !== undefined) return { server: running }
42  return { error: bundled?.reason === 'auth' ? JIRA_SIGN_IN : JIRA_NOT_CONNECTED }
43}
44
45// Servers cannot be listed, so one the session runs is found from the names of
46// the MCP tools it carries (`mcp__<server>__<tool>`).
47async function runningAtlassianServer(host: Host): Promise<string | undefined> {
48  const tools = await host.mcpTools().catch(() => [])
49  for (const name of tools) {
50    const match = /^mcp__(.+)__([^_]+)$/.exec(name)
51    const [, server, tool] = match ?? []
52    if (server !== undefined && /atlassian/i.test(server) && tool === ISSUE_TOOL) return server
53  }
54  return undefined
55}
56
57// A call that rejects (a server gone mid-session) reads as one that failed.
58async function callJson(host: Host, server: string, tool: string, args: Record<string, unknown>): Promise<unknown> {
59  try {
60    const { content, isError } = (await host.mcpCall(server, tool, args)) as McpResult
61    return isError ? undefined : JSON.parse(content.map(b => b.text ?? '').join(''))
62  } catch {
63    return undefined
64  }
65}
66
67const descriptionText = (description: unknown): string => {
68  if (description === undefined || description === null) return ''
69  return typeof description === 'string' ? description : JSON.stringify(description)
70}
71
72async function fetchJira(host: Host, key: string): Promise<Result> {
73  const failed: Result = { ok: false, error: JIRA_NOT_CONNECTED }
74  const found = await atlassianServer(host)
75  if ('error' in found) return { ok: false, error: found.error }
76  const { server } = found
77
78  const resources = await callJson(host, server, RESOURCES_TOOL, {})
79  const cloudId = Array.isArray(resources) ? (resources[0] as { id?: string } | undefined)?.id : undefined
80  if (cloudId === undefined) return failed
81
82  const issue = (await callJson(host, server, ISSUE_TOOL, { cloudId, issueIdOrKey: key })) as
83    | { fields?: { summary?: string; description?: unknown } }
84    | undefined
85  if (issue?.fields === undefined) return { ok: false, error: `Could not read ${key} from Jira; paste the ticket with --brief "..."` }
86
87  const { summary = '', description } = issue.fields
88  return { ok: true, brief: { label: key, text: `${key}: ${summary}\n\n${descriptionText(description)}` } }
89}
90
91async function fetchGithub(host: Host, ref: string): Promise<Result> {
92  const failed: Result = { ok: false, error: GH_UNAVAILABLE }
93  const number = /\d+$/.exec(ref)?.[0] ?? ref
94  const run = await host.run(['gh', 'issue', 'view', ref, '--json', 'title,body'])
95  if (run.exitCode !== 0) return failed
96  try {
97    const { title, body } = JSON.parse(run.stdout) as { title: string; body: string }
98    return { ok: true, brief: { label: `#${number}`, text: `#${number}: ${title}\n\n${body}` } }
99  } catch {
100    return failed
101  }
102}
103
hooks/findings.ts 123 lines
1import type { Finding, Location, Review, Severity, Unparsed } from '../types'
2
3export const OUTPUT_CONTRACT = [
4  'End your answer with exactly one fenced ```json block of the form { "findings": [...] }.',
5  'Each finding has these fields:',
6  '- "severity": one of "critical", "high", "medium", "low".',
7  '- "location": { "file": "<path>", "line": <number, optional> } for code, or { "section": "<heading>" } for a plan or document.',
8  '- "claim": what is wrong, in one or two sentences.',
9  '- "evidence": the concrete text, code or behaviour that shows it.',
10  'Do not suggest fixes. If nothing is wrong, return { "findings": [] }.',
11].join('\n')
12
13export const DRAFT_OPENING =
14  'An independent reviewer raised these findings. For each one, either fix it or reject it with a specific reason. Do not skip any.'
15
16const SEVERITIES: Severity[] = ['critical', 'high', 'medium', 'low']
17
18const slashes = (path: string) => path.replace(/\\/g, '/')
19
20export const relativeTo = (root: string, file: string): string => {
21  const prefix = slashes(root).replace(/\/+$/, '') + '/'
22  const normal = slashes(file)
23  return normal.toLowerCase().startsWith(prefix.toLowerCase()) ? normal.slice(prefix.length) : file
24}
25
26const isText = (value: unknown): value is string => typeof value === 'string' && value.trim() !== ''
27
28const readLocation = (value: unknown, root: string): Location | string => {
29  if (typeof value !== 'object' || value === null) return 'location must be an object'
30  const { file, line, section } = value as Record<string, unknown>
31  if (isText(section)) return { section }
32  if (!isText(file)) return 'location needs a file or a section'
33  if (line === undefined || line === null) return { file: relativeTo(root, file) }
34  if (typeof line !== 'number' || !Number.isInteger(line)) return 'location.line must be an integer'
35  return { file: relativeTo(root, file), line }
36}
37
38const readFinding = (value: unknown, id: number, root: string): Finding | string => {
39  if (typeof value !== 'object' || value === null) return 'finding must be an object'
40  const { severity, location, claim, evidence } = value as Record<string, unknown>
41  if (!SEVERITIES.includes(severity as Severity)) return `severity must be one of ${SEVERITIES.join(', ')}`
42  const where = readLocation(location, root)
43  if (typeof where === 'string') return where
44  if (!isText(claim)) return 'claim must be text'
45  if (!isText(evidence)) return 'evidence must be text'
46  return { id, severity: severity as Severity, location: where, claim, evidence, decision: 'undecided' }
47}
48
49const lastJsonBlock = (output: string): string | undefined => {
50  const blocks = [...output.matchAll(/```json[^\S\n]*\n([\s\S]*?)^```[^\S\n]*$/gm)]
51  return blocks.at(-1)?.[1]
52}
53
54export const parseFindings = (
55  output: string,
56  root: string,
57): { ok: true; findings: Finding[]; unparsed: Unparsed[] } | { ok: false; error: 'no json block' } => {
58  const body = lastJsonBlock(output)
59  if (body === undefined) return { ok: false, error: 'no json block' }
60
61  let parsed: unknown
62  try {
63    parsed = JSON.parse(body)
64  } catch (error) {
65    return { ok: true, findings: [], unparsed: [{ raw: body.trim(), reason: `invalid JSON: ${(error as Error).message}` }] }
66  }
67
68  const items = (parsed as { findings?: unknown } | null)?.findings
69  if (!Array.isArray(items)) {
70    return { ok: true, findings: [], unparsed: [{ raw: body.trim(), reason: 'no findings array' }] }
71  }
72
73  const findings: Finding[] = []
74  const unparsed: Unparsed[] = []
75  for (const item of items) {
76    const result = readFinding(item, findings.length + 1, root)
77    if (typeof result === 'string') unparsed.push({ raw: JSON.stringify(item), reason: result })
78    else findings.push(result)
79  }
80  return { ok: true, findings, unparsed }
81}
82
83export const sortBySeverity = (findings: Finding[]): Finding[] =>
84  findings
85    .map((finding, index) => ({ finding, index }))
86    .sort((a, b) => SEVERITIES.indexOf(a.finding.severity) - SEVERITIES.indexOf(b.finding.severity) || a.index - b.index)
87    .map(({ finding }) => finding)
88
89export const whereText = (location: Location): string => {
90  if ('section' in location) return `§ ${location.section}`
91  return location.line === undefined ? location.file : `${location.file}:${location.line}`
92}
93
94export const draftText = (findings: Finding[]): string => {
95  const lines = findings
96    .filter(finding => finding.decision === 'send')
97    .flatMap((finding, index) => [
98      `${index + 1}. [${finding.severity.toUpperCase()}] ${whereText(finding.location)} — ${finding.editedClaim ?? finding.claim}`,
99      `   Evidence: ${finding.evidence}`,
100    ])
101  return [DRAFT_OPENING, '', ...lines].join('\n')
102}
103
104export const savedMarkdown = (review: Review, date: string): string => {
105  const lines = [`# Adversarial review: ${review.kind} ${review.target}`, '', `Date: ${date}`, '']
106  for (const finding of review.findings) {
107    lines.push(
108      `## ${finding.id}. [${finding.severity.toUpperCase()}] ${whereText(finding.location)} (${finding.decision})`,
109      '',
110      finding.editedClaim === undefined ? finding.claim : `${finding.editedClaim}`,
111      '',
112    )
113    if (finding.editedClaim !== undefined) lines.push(`Original claim: ${finding.claim}`, '')
114    lines.push(`Evidence: ${finding.evidence}`, '')
115  }
116  if (review.unparsed.length > 0) {
117    lines.push('## Unparsed', '')
118    for (const entry of review.unparsed) lines.push(`- ${entry.reason}: ${entry.raw}`)
119    lines.push('')
120  }
121  return lines.join('\n')
122}
123
hooks/links.ts 38 lines
1import { isUnder } from './guard'
2import type { Host } from './host'
3
4export const MAX_SCANNED_ENTRIES = 20_000
5
6// Grep and Glob are judged by the directory they search, so a link beneath it
7// that leads out of the root would let them read past the guard. Anything the
8// walk cannot settle (an unresolvable link, an unreadable directory, a tree
9// over the cap) counts as such a link.
10export async function hasOutwardLinks(host: Host, root: string, cap = MAX_SCANNED_ENTRIES): Promise<boolean> {
11  const pending = [root]
12  let seen = 0
13  while (pending.length > 0) {
14    const dir = pending.pop() as string
15    let entries
16    try {
17      entries = await host.list(dir)
18    } catch {
19      return true
20    }
21    for (const entry of entries) {
22      if (++seen > cap) return true
23      const path = /[\\/]$/.test(dir) ? `${dir}${entry.name}` : `${dir}/${entry.name}`
24      if (entry.kind === 'dir' && !entry.isLink) {
25        if (entry.name === '.git') continue
26        // A junction or mount point can list as a plain directory.
27        const realPath = await host.realPath(path)
28        if (realPath === undefined || !isUnder(root, realPath)) return true
29        pending.push(path)
30      } else if (entry.isLink || entry.kind === 'other') {
31        const realPath = await host.realPath(path)
32        if (realPath === undefined || !isUnder(root, realPath)) return true
33      }
34    }
35  }
36  return false
37}
38