SLOPSHOPPER

zap

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

newguardprompt
v2.0.0MITupdated 2026-10-07dphaener/flow/plugins/zap
A shopper browsing a rack in a slop shop
README

zap

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

Requirements

Claude Code 2.1.287 or later. zap's write guard is a Claude Code mod, and mods need that version.

Skill

/zap:zap

Give 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:

  1. Diagnose — trace the call chain, run the failing test, check recent commits, and settle on a root cause
  2. Write the diagnosis to .zap/diagnosis.md (root cause, location, evidence, recommended fix, verification command) before touching any source file
  3. Fix — apply exactly the changes the diagnosis prescribes. No refactoring, no adjacent cleanups
  4. Verify — run the tests named in the diagnosis (or the project's default test command)
  5. Commit — when the tests pass, stage only the files the fix changed and commit as fix(<scope>): .... .zap/ is never part of the commit

It asks a question only when the report is too vague to find anything to start from.

When it stops

zap stops without committing, and says why, in four cases. The diagnosis is written (or left in place) in each of them.

  • The diagnosis is inconclusive — the bug cannot be reproduced, or the evidence does not single out one root cause
  • The bug is in a dependency — the root cause is in vendored or installed third-party code; choosing between upgrading, patching or reporting upstream is your call
  • The fix would exceed the diagnosed scope — a prescribed change does not fit the code, or the fix needs changes the diagnosis did not prescribe
  • The tests fail after the fix — you get the files changed, the failing tests and the likely reasons. If the fix broke tests unrelated to the bug, its changes are reverted first

Files with uncommitted work

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.

The Guard

zap ships a small Claude Code mod that makes "diagnose first" a rule and not a suggestion.

  • Running /zap:zap arms it.
  • While it is armed, the main loop may write only inside the project's .zap/ directory. Any other Write, Edit or NotebookEdit call is denied.
  • A successful write of .zap/diagnosis.md disarms it. From then on the fix can be applied.
  • Every new /zap:zap run arms it again, and the previous diagnosis is overwritten.

Its limits:

  • Subagents are not guarded. Only the main loop's writes are checked.
  • Bash is not intercepted. A file changed by a shell command passes straight through; the skill instructs Claude not to modify source through the shell before the diagnosis is on disk, but nothing enforces that.
  • Sessions that never run /zap:zap are not affected at all.
  • The guard has no off switch of its own. If a run is abandoned before .zap/diagnosis.md is written, it stays armed for the rest of that session.

Installation

# Add the flow marketplace
claude plugin marketplace add <flow-marketplace-url>

# Install zap
claude plugin install zap

Author

Darin Haener

Source 2 files
hooks/register.ts 118 lines
1import 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}
118
types/index.d.ts 9 lines
1/** 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