SLOPSHOPPER

craftsman-cockpit

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

newpanecommandprocess
★ 35v?Apache-2.0updated 2026-10-07BULDEE/ai-craftsman-superpowers/mods/craftsman-cockpit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · craftsman-cockpit
│ ┃ Instinct review ✕ › fix the failing auth test and add an audit log call │ ┃ craftsman-helper not found. Tried: │ ┃ session binding: ⏺ Read(src/auth.ts) │ ┃ /Users/dev/.claude/craftsman/sessions/previe ⎿ Read 6 lines │ ┃ w-session.json ⏺ Update(src/auth.ts) │ ┃ repository layout: ⎿ Added 2 lines, removed 1 line │ ┃ /plugins/craftsman-cockpit/../../bin/craftsm ⏺ Bash(bun test) │ ┃ an-helper ⎿ 3 pass, 1 fail │ ┃ PATH: craftsman-path │ ┃ Set the helper option to the craftsman ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ plugin's bin/craftsman-helper. │ ✻ Worked for 42s · done 4:20 PM │ │ › /instincts │ ⎿ craftsman-cockpit: Instinct review pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Instinct review
craftsman-helper not found. Tried: session binding: /Users/dev/.claude/craftsman/sessions/preview-session.json repository layout: /plugins/craftsman-cockpit/../../bin/craftsman-helper PATH: craftsman-path Set the helper option to the craftsman plugin's bin/craftsman-helper.
README

craftsman-cockpit

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.

Instinct review

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

Install

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.

Develop

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.sh
Source 2 files
hooks/register.tsx 252 lines
1// 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}
252
types/index.d.ts 44 lines
1export 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