Zap a bug in one run: diagnose, fix, verify, commit.

Zap a bug in one run: diagnose, fix, verify, commit.
Claude Code 2.1.287 or later. zap's write guard is a Claude Code mod, and mods need that version.
/zap:zapGive it an error message, a stack trace, a failing test path, or a description of the bug:
/zap:zap NoMethodError: undefined method 'total' for nil:NilClass in InvoicesController#show
/zap:zap spec/models/user_spec.rb:42
/zap:zap The login form fails silently when the email has a trailing space
In a single run, with no approval step in between, The Electrician will:
.zap/diagnosis.md (root cause, location, evidence, recommended fix, verification command) before touching any source filefix(<scope>): .... .zap/ is never part of the commitIt asks a question only when the report is too vague to find anything to start from.
zap stops without committing, and says why, in four cases. The diagnosis is written (or left in place) in each of them.
Before fixing, zap checks git status. Files that already have uncommitted changes are yours: it never stages, reverts or overwrites them. If a file the fix must change is one of them, zap still applies and verifies the fix but skips the commit, and tells you so, because the commit would sweep your uncommitted work in with the fix.
zap ships a small Claude Code mod that makes "diagnose first" a rule and not a suggestion.
/zap:zap arms it..zap/ directory. Any other Write, Edit or NotebookEdit call is denied..zap/diagnosis.md disarms it. From then on the fix can be applied./zap:zap run arms it again, and the previous diagnosis is overwritten.Its limits:
/zap:zap are not affected at all..zap/diagnosis.md is written, it stays armed for the rest of that session.# Add the flow marketplace
claude plugin marketplace add <flow-marketplace-url>
# Install zap
claude plugin install zap
Darin Haener
hooks/register.ts 118 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// The diagnosis guard: `/zap:zap` arms it, and while it is armed the main loop
4// may write only inside the project's `.zap/` directory. A completed write of
5// `.zap/diagnosis.md` disarms it, the one step from read-only to writable; a
6// later `/zap:zap` in the same session arms it again.
7
8const ARMED = { plugin: 'zap', key: 'isArmed' } as const
9
10const ZAP_DIR = '.zap'
11const DIAGNOSIS = 'diagnosis.md'
12const MAX_DEPTH = 256
13
14type Located = { zapDir: string; real: string | undefined }
15
16const refusal = (path: string, why: string): string =>
17 `zap: ${path} was not written (${why}). zap must write ${ZAP_DIR}/${DIAGNOSIS} ` +
18 `before changing source: until that write succeeds, only files inside ${ZAP_DIR}/ may be written.`
19
20const separatorOf = (root: string): '/' | '\\' => (root.includes('/') ? '/' : '\\')
21
22const segmentsOf = (path: string, sep: string): string[] =>
23 path.split(sep === '\\' ? /[\\/]/ : /\//)
24
25const lastCut = (path: string, sep: string): number =>
26 sep === '\\' ? Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) : path.lastIndexOf('/')
27
28const trimEnd = (path: string, sep: string): string =>
29 path.replace(sep === '\\' ? /[\\/]+$/ : /\/+$/, '')
30
31const realOf = async ($: EngineInterface, path: string) =>
32 $.fs.stat(path, { resolve: true }).catch(() => undefined)
33
34/**
35 * Where `path` lands once every symbolic link is followed, or undefined when
36 * that cannot be said: the path itself if it exists, else its nearest existing
37 * ancestor with the missing names appended. A `..` segment is never placed
38 * (the tool and the file system fold it differently around a link).
39 */
40const place = async ($: EngineInterface, path: string, sep: string): Promise<string | undefined> => {
41 const isPlaceable =
42 path !== '' &&
43 !/^[\\/][\\/]/.test(path) &&
44 !/^[A-Za-z]:(?![\\/])/.test(path) &&
45 !segmentsOf(path, sep).includes('..')
46 if (!isPlaceable) return undefined
47
48 const missing: string[] = []
49 let head = path
50 for (let depth = 0; depth < MAX_DEPTH; depth += 1) {
51 const stat = await realOf($, head)
52 if (stat !== undefined) {
53 if (stat.realPath === undefined) return undefined
54 return [trimEnd(stat.realPath, sep), ...missing].join(sep)
55 }
56 const trimmed = trimEnd(head, sep)
57 if (trimmed === '' || trimmed === '.') return undefined
58 const cut = lastCut(trimmed, sep)
59 const name = trimmed.slice(cut + 1)
60 if (/^[A-Za-z]:/.test(name)) return undefined
61 if (name !== '.') missing.unshift(name)
62 head = cut < 0 ? '.' : trimmed.slice(0, cut + 1)
63 }
64 return undefined
65}
66
67const locate = async ($: EngineInterface, path: string): Promise<Located> => {
68 const root = (await $.fs.stat(await $.session.root(), { resolve: true })).realPath
69 if (root === undefined) throw new Error('zap: the project root does not resolve')
70 const sep = separatorOf(root)
71 return {
72 zapDir: `${trimEnd(root, sep)}${sep}${ZAP_DIR}`,
73 real: await place($, path, sep),
74 }
75}
76
77const isArmed = async ($: EngineInterface): Promise<boolean> => {
78 try {
79 return (await $.state.get(ARMED)).value === true
80 } catch {
81 // Not known to be armed: the guard stays out of the way.
82 return false
83 }
84}
85
86export const register: Register = on => {
87 on('skill.prompt', { skill: 'zap:zap' }, async ($, e, next) => {
88 await $.state.set(ARMED, true)
89 return next(e)
90 })
91
92 on('tool.call', { tool: ['Write', 'Edit', 'NotebookEdit'] }, async ($, e, next) => {
93 if (e.agentId !== undefined) return next(e)
94 if (!(await isArmed($))) return next(e)
95
96 // Armed from here on: anything thrown below is answered by `.catch`, a deny.
97 const path = e.tool === 'NotebookEdit' ? e.notebook_path : e.file_path
98 if (typeof path !== 'string') throw new Error('zap: the call names no path')
99
100 const { zapDir, real } = await locate($, path)
101 if (real === undefined) return { deny: refusal(path, 'its location cannot be resolved') }
102
103 const sep = separatorOf(zapDir)
104 if (!real.startsWith(zapDir + sep)) {
105 return { deny: refusal(path, `it is outside ${zapDir}`) }
106 }
107
108 const ran = await next(e)
109 const isWritten = ran.deny === undefined && ran.isError !== true
110 if (isWritten && real === `${zapDir}${sep}${DIAGNOSIS}`) await $.state.set(ARMED, false)
111 return ran
112 }).catch(($, e, next) =>
113 next.called
114 ? next(e)
115 : { deny: `zap: its write guard failed, so the write was refused. zap must write ${ZAP_DIR}/${DIAGNOSIS} before changing source.` },
116 )
117}
118types/index.d.ts 9 lines1/** True from `/zap:zap` until `.zap/diagnosis.md` is written. */
2export type ZapArmed = boolean
3
4declare module 'claude-code' {
5 interface PluginState {
6 zap: { isArmed: ZapArmed }
7 }
8}
9