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…

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.
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:
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.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./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/)
/plugin marketplace add bobtat/claude-plugins
/plugin install adversary@bobtat-plugins
/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.--model sets the reviewer's model. Without it the reviewer uses the session model.--focus adds a "Focus" section to the reviewer's task.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.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.
--brief gives the reviewer the intent to check the work against. The value is classified like this:
| Value | Source | Needs |
|---|---|---|
PROJ-123 (project key and number) | Jira issue summary and description | The Atlassian MCP server, signed in (the plugin bundles it) |
#123 or a https://github.com/<owner>/<repo>/issues/<n> URL | GitHub issue title and body | gh, logged in |
| anything else | the text itself | nothing |
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.
Needs gh, logged in. The reviewer is given the PR number, title, body and diff.
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.HEAD is the PR's head commit and the working tree has no uncommitted or untracked changes, the reviewer reads the project directory.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.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:
<project>/.claude/adversary/<kind>.md, for a template shared with the repo~/.claude/adversary/<kind>.md, your own copytemplates/<kind>.md in the plugin, the shipped copyOn 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.
| Placeholder | Holds |
|---|---|
{{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.
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.
| Key | Button | Does |
|---|---|---|
s | Send | Mark the selected finding to send |
d | Drop | Mark it dropped |
e | Edit | Edit 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 / k | Down / Up | Move the selection |
r | Draft | Put the sent findings in the prompt box (replacing what is there), asking the author to fix each or reject it with a reason |
p | Post | PR reviews only: post the sent findings as a GitHub review |
w | Save | Write the whole review to a file |
c | Close | Close 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.
<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.
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./adversary triage.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/adversaryhooks/register.tsx 115 lines1import 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}
115hooks/args.ts 95 lines1export 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}
95hooks/guard.ts 170 lines1import 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}
170hooks/host.ts 81 lines1import 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}
81hooks/plan.tsx 45 lines1import { 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}
45hooks/review.ts 243 lines1import { 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}
243hooks/templates.ts 61 lines1import 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}
61hooks/triage.tsx 445 lines1import { 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}
445hooks/artifact.ts 109 lines1import 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}
109hooks/brief.ts 103 lines1import 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}
103hooks/findings.ts 123 lines1import 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}
123hooks/links.ts 38 lines1import { 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