Companion panes for the craftsman plugin (ADR-0031): review learned-instinct candidates with their evidence and approve or reject them by hand.

Companion panes for the craftsman plugin on Claude Code (ADR-0031). A mod shows what the core knows and carries your decisions back to it. It never holds a verdict: enforcement stays in the craftsman plugin.
/instincts opens a pane with the project's candidate instincts (ADR-0020), best supported first, each with its fix and rejection counts and its evidence. Approve writes the learned skill to .claude/skills/learned-<rule>/, and Reject keeps the candidate out until new evidence builds up. Both run craftsman-helper instincts approve|reject. Only your keypress triggers them: the mod registers no tool the model could call. r refreshes.
Requires the craftsman plugin and Claude Code 2.1.291 or later (function hooks are early access).
/plugin install craftsman-cockpit@ai-craftsman-superpowers
The mod finds craftsman-helper from its helper option, the session binding the craftsman hooks write, this repository's layout, then PATH. If none works, the pane lists every place it tried.
claude --plugin-dir mods/craftsman-cockpit # load from disk
claude plugin validate mods/craftsman-cockpit
(cd mods/craftsman-cockpit && claude plugin test .)
bash tests/mods/test-mods.shhooks/register.tsx 252 lines1// The instinct review pane (ADR-0031). The mod holds no verdict and no SQL:
2// it reads the queue from `instincts review` and carries the person's press
3// back to `instincts approve|reject`, both through bin/craftsman-helper. It
4// registers no tool, so only a person can approve an instinct (ADR-0020).
5import { atom, read, update } from 'claude-code'
6import type { EngineInterface, PluginOptions, ProcessRunResult, Register } from 'claude-code'
7
8import type { CockpitCandidate, CockpitQueue, CockpitView } from '../types'
9
10const PANE = 'instincts'
11const view = atom({ plugin: 'craftsman-cockpit', key: 'view' } as const, { kind: 'loading' } as CockpitView)
12// The id of the candidate whose skill preview is open; 0 for none.
13const preview = atom({ plugin: 'craftsman-cockpit', key: 'preview' } as const, 0)
14
15type Helper = { argv0: string; env: Record<string, string> }
16type Located = { helper: Helper } | { tried: string[] }
17type Answer = { ran: ProcessRunResult } | { missing: string[] } | { error: string }
18
19// The order ADR-0031 fixes: the option, the session binding the craftsman
20// hooks write, the repository layout this mod ships in, then PATH. Each place
21// is named in the pane when none answers; nothing falls back to a guess.
22async function locate($: EngineInterface, options: PluginOptions): Promise<Located> {
23 const tried: string[] = []
24 const configured = typeof options.helper === 'string' ? options.helper.trim() : ''
25 if (configured !== '') {
26 if (await $.fs.exists(configured)) return { helper: { argv0: configured, env: {} } }
27 tried.push(`option helper: ${configured}`)
28 }
29
30 const home = await $.env.get('HOME')
31 const session = await $.session.id()
32 if (home !== undefined && session !== '') {
33 const binding = `${home}/.claude/craftsman/sessions/${session}.json`
34 const bound = await readBinding($, binding, session)
35 if (bound !== undefined) return { helper: bound }
36 tried.push(`session binding: ${binding}`)
37 }
38
39 const sibling = `${$.plugin.root}/../../bin/craftsman-helper`
40 if (await $.fs.exists(sibling)) return { helper: { argv0: sibling, env: {} } }
41 tried.push(`repository layout: ${sibling}`)
42
43 // craftsman-path prints the installation's root and writes nothing, unlike
44 // any instincts subcommand, which refreshes the candidates as it answers.
45 const onPath = await $.process.run(['craftsman-path', 'bin/craftsman-helper']).catch(() => undefined)
46 const resolved = onPath?.exitCode === 0 ? firstLine(onPath.stdout) : ''
47 if (resolved !== '' && (await $.fs.exists(resolved))) return { helper: { argv0: resolved, env: {} } }
48 tried.push('PATH: craftsman-path')
49
50 return { tried }
51}
52
53async function readBinding($: EngineInterface, path: string, session: string): Promise<Helper | undefined> {
54 if (!(await $.fs.exists(path))) return undefined
55 try {
56 const parsed: unknown = JSON.parse(String(await $.fs.read(path)))
57 if (typeof parsed !== 'object' || parsed === null) return undefined
58 const { host, session_id: id, root, data } = parsed as Record<string, unknown>
59 if (host !== 'claude-code' || id !== session || typeof root !== 'string' || typeof data !== 'string') return undefined
60 const argv0 = `${root}/bin/craftsman-helper`
61 if (!(await $.fs.exists(argv0))) return undefined
62 return {
63 argv0,
64 env: { CRAFTSMAN_SESSION_ID: session, CRAFTSMAN_SESSION_HOST: 'claude-code', CRAFTSMAN_PLUGIN_DATA: data },
65 }
66 } catch {
67 return undefined
68 }
69}
70
71async function helper($: EngineInterface, options: PluginOptions, args: string[]): Promise<Answer> {
72 const located = await locate($, options)
73 if ('tried' in located) return { missing: located.tried }
74 const ran = await $.process.run([located.helper.argv0, 'instincts', ...args], { env: located.helper.env })
75
76 return { ran }
77}
78
79async function refresh($: EngineInterface, options: PluginOptions, notice: string): Promise<void> {
80 // The same directory approve writes to, so the preview names the file it will be.
81 const skillsDir = `${await $.session.cwd()}/.claude/skills`
82 const answer = await helper($, options, ['review', skillsDir]).catch((error: unknown) => ({ error: String(error) }))
83 await update($, view, (): CockpitView => toView(answer, notice))
84}
85
86function toView(answer: Answer, notice: string): CockpitView {
87 if ('error' in answer) return { kind: 'failed', message: answer.error }
88 if ('missing' in answer) return { kind: 'missing', tried: answer.missing }
89 const { exitCode, stdout, stderr } = answer.ran
90 const queue = exitCode === 0 ? parseQueue(stdout) : undefined
91 return queue === undefined
92 ? { kind: 'failed', message: firstLine(stderr) || `instincts review exited ${exitCode}` }
93 : { kind: 'ready', queue, notice }
94}
95
96function parseQueue(stdout: string): CockpitQueue | undefined {
97 try {
98 const parsed = JSON.parse(stdout) as CockpitQueue
99 return Array.isArray(parsed.candidates) && Array.isArray(parsed.approved) ? parsed : undefined
100 } catch {
101 return undefined
102 }
103}
104
105function firstLine(text: string): string {
106 return text.trim().split('\n')[0] ?? ''
107}
108
109// A press is the person's gesture: the id comes from the queue this pane drew,
110// and the skill goes where /craftsman:metrics puts it, under the session's cwd.
111type Decision = { options: PluginOptions; verb: 'approve' | 'reject'; candidate: CockpitCandidate }
112
113// craftsman-ignore: WARN-TS001 (wrong: two parameters, the regex counts the destructured fields)
114async function decide($: EngineInterface, { options, verb: decision, candidate }: Decision) {
115 const args = decision === 'approve'
116 ? ['approve', String(candidate.id), `${await $.session.cwd()}/.claude/skills`]
117 : ['reject', String(candidate.id)]
118 const answer = await helper($, options, args).catch((error: unknown) => ({ error: String(error) }))
119 const notice = 'error' in answer
120 ? `${decision} ${candidate.rule} failed: ${answer.error}`
121 : 'missing' in answer
122 ? `${decision} ${candidate.rule}: craftsman-helper not found`
123 : answer.ran.exitCode === 0
124 ? firstLine(answer.ran.stdout)
125 : `${decision} ${candidate.rule} failed: ${firstLine(answer.ran.stderr)}`
126 await refresh($, options, notice)
127}
128
129// What a severity does to a write, in the words a reviewer decides on.
130const SEVERITY_EFFECT: Record<string, string> = {
131 block: 'blocks the write',
132 warn: 'advisory: reported, never blocks',
133 ignore: 'switched off',
134}
135
136function origin(candidate: CockpitCandidate): string {
137 const owner = candidate.rule_owner === '' ? 'unknown pack' : candidate.rule_owner === 'core' ? 'core rules' : `${candidate.rule_owner} pack`
138 const effect = SEVERITY_EFFECT[candidate.default_severity] ?? 'severity unknown'
139 return `${owner} · ${candidate.rule_group || 'no group'} · default ${candidate.default_severity || '?'} (${effect})`
140}
141
142function acceptance(candidate: CockpitCandidate): string {
143 const total = candidate.fixed + candidate.rejected
144 const rate = total === 0 ? 0 : Math.round((100 * candidate.fixed) / total)
145 return `Accepted ${candidate.fixed} of ${total} times (${rate}%). Refused ${candidate.rejected}: ` +
146 `${candidate.ignored} craftsman-ignore, ${candidate.scoped} relaxed by config.`
147}
148
149// Below four fixes in five, the refusals are part of the decision.
150function isContested(candidate: CockpitCandidate): boolean {
151 return candidate.rejected * 5 > candidate.fixed + candidate.rejected
152}
153
154function standing(candidate: CockpitCandidate): string {
155 const last = candidate.last_fixed === '' ? 'never recorded' : candidate.last_fixed.slice(0, 10)
156 return `Last fixed ${last} · ${candidate.files} files · confidence ${candidate.confidence.toFixed(2)} ` +
157 '(lower bound of the acceptance rate: an order, not a bar)'
158}
159
160export const register: Register = (on, options) => {
161 on('session.start', async ($, e, next) => {
162 await $.command.register({
163 name: 'instincts',
164 description: 'Review learned-instinct candidates (ADR-0020) and approve or reject them',
165 })
166
167 return next(e)
168 })
169
170 on('command.run', { command: 'instincts' }, async $ => {
171 await update($, view, (): CockpitView => ({ kind: 'loading' }))
172 await $.ui.open({ id: PANE, title: 'Instinct review', focus: true })
173 await refresh($, options, '')
174
175 return { text: 'Instinct review pane opened.' }
176 })
177
178 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
179 const { Box, Text, Button } = $.ui.resolve(e)
180 const current = await read($, view)
181
182 if (current.kind === 'loading') return <Text dimColor>Reading the instinct queue...</Text>
183 if (current.kind === 'failed') return <Text color="red">instincts review failed: {current.message}</Text>
184 if (current.kind === 'missing') {
185 return (
186 <Box flexDirection="column">
187 <Text color="yellow">craftsman-helper not found. Tried:</Text>
188 {current.tried.map(place => <Text dimColor> {place}</Text>)}
189 <Text dimColor>Set the helper option to the craftsman plugin's bin/craftsman-helper.</Text>
190 </Box>
191 )
192 }
193
194 const { candidates, approved } = current.queue
195 const opened = await read($, preview)
196 const room = Math.max(1, Math.floor(((e.viewport?.rows ?? 30) - 8) / 14))
197 const shown = candidates.slice(0, room)
198
199 return (
200 <Box flexDirection="column">
201 {current.notice !== '' && <Text color="green">{current.notice}</Text>}
202 <Text bold>Candidates ({candidates.length})</Text>
203 {candidates.length === 0 && <Text dimColor>Nothing awaits review.</Text>}
204 {shown.map(candidate => (
205 <Box key={`candidate-${candidate.id}`} flexDirection="column" marginTop={1}>
206 <Text>
207 <Text bold>{candidate.rule}</Text>{candidate.rule_text !== '' ? ` · ${candidate.rule_text}` : ''}
208 </Text>
209 <Text dimColor>{origin(candidate)}</Text>
210 <Text>{acceptance(candidate)}</Text>
211 <Text dimColor>{standing(candidate)}</Text>
212 {candidate.summary !== '' && <Text dimColor>Recorded fix: {candidate.summary}</Text>}
213 <Text dimColor>Fixed in:</Text>
214 {candidate.evidence.map(item => (
215 <Text dimColor> {item.file}{item.context !== '' ? `: ${item.context}` : ''}</Text>
216 ))}
217 <Text>Approve: writes {candidate.skill_path}; Claude then loads it as background knowledge and applies the fix before the gate flags it.</Text>
218 <Text>Reject: no skill; the candidate stays hidden until new fixes build up. The rule itself keeps running either way.</Text>
219 {isContested(candidate) && <Text color="yellow">About one refusal in {Math.round((candidate.fixed + candidate.rejected) / Math.max(1, candidate.rejected))}: if those were legitimate exceptions, Approve; if the rule misfits this codebase, Reject and relax it in .craft-rules.yml.</Text>}
220 <Box flexDirection="row" gap={1}>
221 <Button key={`approve-${candidate.id}`} variant="primary" onPress={() => decide($, { options, verb: 'approve', candidate })}>
222 Approve
223 </Button>
224 <Button key={`reject-${candidate.id}`} onPress={() => decide($, { options, verb: 'reject', candidate })}>
225 Reject
226 </Button>
227 <Button key={`skill-${candidate.id}`} onPress={() => update($, preview, open => (open === candidate.id ? 0 : candidate.id))}>
228 {opened === candidate.id ? 'Hide skill' : 'Show skill'}
229 </Button>
230 </Box>
231 {opened === candidate.id && (
232 <Box flexDirection="column" borderStyle="single" paddingX={1}>
233 {candidate.skill_preview.split('\n').map(line => <Text dimColor>{line === '' ? ' ' : line}</Text>)}
234 </Box>
235 )}
236 </Box>
237 ))}
238 {candidates.length > shown.length && <Text dimColor>{candidates.length - shown.length} more below the fold; decide these first.</Text>}
239 <Box marginTop={1} flexDirection="column">
240 <Text bold>Approved ({approved.length})</Text>
241 {approved.map(item => (
242 <Text dimColor>{item.rule} confidence {item.confidence.toFixed(2)} · fixed {item.fixed} · {item.files} files</Text>
243 ))}
244 </Box>
245 <Box marginTop={1}>
246 <Button key="refresh" hotkey="r" onPress={() => refresh($, options, '')}>Refresh</Button>
247 </Box>
248 </Box>
249 )
250 })
251}
252types/index.d.ts 44 lines1export type CockpitEvidence = { file: string; context: string }
2
3export type CockpitCandidate = {
4 id: number
5 rule: string
6 confidence: number
7 fixed: number
8 rejected: number
9 files: number
10 summary: string
11 evidence: CockpitEvidence[]
12 rule_text: string
13 rule_group: string
14 rule_owner: string
15 default_severity: string
16 ignored: number
17 scoped: number
18 last_fixed: string
19 skill_path: string
20 skill_preview: string
21}
22
23export type CockpitApproved = {
24 id: number
25 rule: string
26 confidence: number
27 fixed: number
28 files: number
29}
30
31export type CockpitQueue = { candidates: CockpitCandidate[]; approved: CockpitApproved[] }
32
33export type CockpitView =
34 | { kind: 'loading' }
35 | { kind: 'missing'; tried: string[] }
36 | { kind: 'failed'; message: string }
37 | { kind: 'ready'; queue: CockpitQueue; notice: string }
38
39declare module 'claude-code' {
40 interface PluginState {
41 'craftsman-cockpit': { view: CockpitView; preview: number }
42 }
43}
44