Lessons that fire at the moment of action: reminders, questions or blocks attached to matching tool calls.

Lessons that fire at the moment of action.
Agent memory is usually read once, at the start of a session, and then hoped for. Three hours later the agent is about to repeat a mistake it has a note about, and the note is nowhere near the decision.
tripwire indexes each lesson by the action that should trigger it. When a coding agent is about to make a matching tool call, the lesson fires right there:
agent runs: npx wrangler deploy
│
▼ tripwire matches
⚡ TRIPWIRE [deploy-verify-content] (remind): After this deploy, verify on CONTENT,
not the status code: curl the live URL and grep for text that only the new version
contains. (learned: false 'deployed' claims, twice)
It's a Claude Code mod: a plugin with one tool.call hook.
How it works: see ARCHITECTURE.md for the design, request flow, failure model and testing.
| Severity | What happens |
|---|---|
remind | The call runs. The lesson is attached to its result, so the agent reads it right then. |
ask | You're asked first. No answer (dismissed, or no one to ask) means no. |
block | The call is refused and the agent is told why, and not to work around it. |
When several tripwires match, the strictest one wins and every lesson is shown.
{
"id": "kv-list-needs-remote",
"tool": "^Bash$",
"field": "command",
"pattern": "wrangler kv key (list|get)",
"unless": "--remote",
"severity": "remind",
"lesson": "Without --remote, wrangler reads the LOCAL dev store and returns []. A working form looks broken.",
"source": "half an hour lost on a client site"
}
| Field | ||
|---|---|---|
tool | Regex on the tool name: ^Bash$, `^(Edit\ | Write)$, claude-in-chrome__navigate$` |
pattern | Regex (case-insensitive) on the tool input | |
field | Optional. Test one input field (command, file_path, url). Default: every string in the input | |
unless | Optional. If this also matches, stay quiet (e.g. --dry-run) | |
redact | Never echo the matched text. Use it for tripwires that match secrets | |
source | Where the lesson was learned, so it can be checked later |
Twelve real mistakes, each with where it was learned, in hooks/tripwires.ts:
assets.directory pointed at the repo rootupdate, drop, alter) · scripts that email real peoplewrangler kv without --remote · Apps Script redirects need GET · only commit when asked · Google Sheets formula injection · Gmail compose swallows the first keystrokes · a paused free-tier Supabase projectAdd your own in ~/.claude/tripwires.json: an array, or { "tripwires": [...] }. Broken entries are skipped, never fatal, and listed by /tripwires.
/tripwires lists every tripwire, how often each fired and when, plus any broken entries.
git clone https://github.com/amahmood561/tripwire ~/tripwire
claude --plugin-dir ~/tripwire # one session
To load it in every session, add the folder to the env block of ~/.claude/settings.json:
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/tripwire" } }
block or a declined ask stops a call.ask fails closed. A dismissed question, or a session with no one to ask, is treated as "no".redact tripwire reports [redacted], never the match.block; one that never fires in months can be retired.claude plugin validate .
claude plugin test . # 13 tests: every built-in fires on its mistake and stays quiet on the fixhooks/register.ts 111 lines1import type { EngineInterface, Register } from 'claude-code'
2import { match, render, validate, worst, type Tripwire } from './match.ts'
3import { SEED } from './tripwires.ts'
4
5/**
6 * tripwire: lessons that fire at the moment of action.
7 *
8 * remind the call runs; the lesson is attached to its result for the agent
9 * ask the user is asked first; no answer means no
10 * block the call is refused and the agent is told why
11 *
12 * Built-in lessons live in hooks/tripwires.ts; personal ones in
13 * ~/.claude/tripwires.json. A broken file never stops work: bad entries are
14 * skipped and reported by /tripwires.
15 */
16
17const CACHE_MS = 30_000
18let cache: { at: number; wires: Tripwire[]; problems: string[] } | null = null
19
20async function loadWires($: EngineInterface) {
21 if (cache && Date.now() - cache.at < CACHE_MS) return cache
22 const seed = validate(SEED)
23 const wires: Tripwire[] = [...seed.wires]
24 const problems: string[] = seed.problems.map(p => `built-in: ${p}`)
25 const home = await $.process.run(['printenv', 'HOME']).then(r => r.stdout.trim(), () => '')
26 const files = home ? [`${home}/.claude/tripwires.json`] : []
27 for (const file of files) {
28 const text = await $.fs.read(file).catch(() => null)
29 if (typeof text !== 'string') continue
30 try {
31 const result = validate(JSON.parse(text))
32 wires.push(...result.wires)
33 problems.push(...result.problems.map(p => `${file}: ${p}`))
34 } catch (err) {
35 problems.push(`${file}: not valid JSON (${String(err)})`)
36 }
37 }
38 cache = { at: Date.now(), wires, problems }
39 return cache
40}
41
42type Stats = Record<string, { fired: number; last: string }>
43
44async function record($: EngineInterface, ids: string[]) {
45 try {
46 const stats = ((await $.store.get('stats')) ?? {}) as Stats
47 const now = new Date().toISOString()
48 for (const id of ids) stats[id] = { fired: (stats[id]?.fired ?? 0) + 1, last: now }
49 await $.store.set('stats', stats)
50 } catch {
51 // Counting is a nicety; never let it interfere with the tool call.
52 }
53}
54
55export const register: Register = on => {
56 on('session.start', async ($, e, next) => {
57 await $.command.register({ name: 'tripwires', description: 'List tripwires, how often each fired, and any broken entries' })
58 return next(e)
59 })
60
61 on('command.run', { command: 'tripwires' }, async $ => {
62 cache = null
63 const { wires, problems } = await loadWires($)
64 const stats = ((await $.store.get('stats').catch(() => ({}))) ?? {}) as Stats
65 const lines = wires.map(w => {
66 const s = stats[w.id]
67 return `${w.severity.padEnd(6)} ${w.id.padEnd(28)} ${s ? `fired ${s.fired}x, last ${s.last.slice(0, 10)}` : 'never fired'}`
68 })
69 return { text: [`${wires.length} tripwires`, ...lines, ...(problems.length ? ['', 'Problems:', ...problems] : [])].join('\n') }
70 })
71
72 on('tool.call', async ($, e, next) => {
73 let fired
74 try {
75 const { wires } = await loadWires($)
76 fired = match(wires, e.tool, e as unknown as Record<string, unknown>)
77 } catch {
78 return next(e) // tripwires are advice; a loading error must not stop work
79 }
80 if (fired.length === 0) return next(e)
81
82 const severity = worst(fired)
83 const text = render(fired)
84 void record($, fired.map(f => f.wire.id))
85
86 if (severity === 'block') {
87 $.ui.toast(`Tripwire blocked ${e.tool}: ${fired.map(f => f.wire.id).join(', ')}`)
88 return { deny: `${text}\nThis action was stopped by a tripwire. Fix the cause; do not retry it or work around it.` }
89 }
90
91 if (severity === 'ask') {
92 let answer = ''
93 try {
94 answer = await $.ui.ask(`Tripwire: ${fired.map(f => f.wire.lesson).join(' / ')} Continue?`, {
95 header: 'Tripwire',
96 options: ['Stop', 'Continue'],
97 })
98 } catch {
99 // Dismissed, or nobody to ask: an "ask" wire means not without a yes.
100 }
101 if (answer !== 'Continue') return { deny: `${text}\nThe user did not approve continuing.` }
102 }
103
104 // remind (and an approved ask): run it, then put the lesson in front of the agent.
105 const ran = await next(e)
106 if ('deny' in ran && ran.deny !== undefined) return ran
107 $.ui.toast(`Tripwire: ${fired.map(f => f.wire.id).join(', ')}`)
108 return { ...ran, context: [...(ran.context ?? []), text] } as typeof ran
109 })
110}
111hooks/match.ts 114 lines1/**
2 * Pure matching logic, kept free of the engine so it can be tested directly.
3 *
4 * A tripwire is a lesson indexed by the ACTION that should trigger it, not by
5 * topic. When an agent is about to make a matching tool call, the lesson fires
6 * at that moment instead of hoping it was remembered from session start.
7 */
8
9export type Severity = 'remind' | 'ask' | 'block'
10
11export type Tripwire = {
12 id: string
13 /** Regex on the tool name, e.g. "^Bash$" or "claude-in-chrome__navigate". */
14 tool: string
15 /** Regex tested against `field` of the tool input, or the whole input as JSON. */
16 pattern: string
17 /** Optional input field to test, e.g. "command", "file_path", "url". */
18 field?: string
19 /** Optional regex that, if it also matches, cancels the tripwire. */
20 unless?: string
21 severity: Severity
22 lesson: string
23 /** Where the lesson was learned, so it can be checked. */
24 source?: string
25 /** Never echo the matched text (for tripwires that match secrets). */
26 redact?: boolean
27}
28
29export type Fired = { wire: Tripwire; matched: string }
30
31const RANK: Record<Severity, number> = { remind: 0, ask: 1, block: 2 }
32
33/** Compiles a pattern; a bad one disables that wire instead of throwing. */
34function compile(source: string): RegExp | null {
35 try {
36 return new RegExp(source, 'i')
37 } catch {
38 return null
39 }
40}
41
42export function validate(raw: unknown): { wires: Tripwire[]; problems: string[] } {
43 const problems: string[] = []
44 const list = Array.isArray(raw) ? raw : Array.isArray((raw as { tripwires?: unknown })?.tripwires) ? (raw as { tripwires: unknown[] }).tripwires : null
45 if (!list) return { wires: [], problems: ['tripwires file must be an array or { "tripwires": [...] }'] }
46
47 const wires: Tripwire[] = []
48 for (const item of list) {
49 const w = item as Partial<Tripwire>
50 const id = typeof w.id === 'string' ? w.id : '(no id)'
51 if (!w.id || !w.tool || !w.pattern || !w.lesson) {
52 problems.push(`${id}: needs id, tool, pattern and lesson`)
53 continue
54 }
55 if (w.severity && !(w.severity in RANK)) {
56 problems.push(`${id}: severity must be remind, ask or block`)
57 continue
58 }
59 if (!compile(w.tool) || !compile(w.pattern) || (w.unless && !compile(w.unless))) {
60 problems.push(`${id}: invalid regex`)
61 continue
62 }
63 wires.push({ ...w, severity: w.severity ?? 'remind' } as Tripwire)
64 }
65 return { wires, problems }
66}
67
68/** The text a tool call is tested against. */
69export function haystack(input: Record<string, unknown>, field?: string): string {
70 if (field) {
71 const value = input[field]
72 return typeof value === 'string' ? value : value == null ? '' : JSON.stringify(value)
73 }
74 // Every string in the input, raw. JSON.stringify would escape quotes, and a
75 // pattern like "directory": "./" could then never match.
76 const { tool: _tool, ...rest } = input
77 const parts: string[] = []
78 const walk = (v: unknown) => {
79 if (typeof v === 'string') parts.push(v)
80 else if (Array.isArray(v)) v.forEach(walk)
81 else if (v && typeof v === 'object') Object.values(v).forEach(walk)
82 }
83 walk(rest)
84 return parts.join('\n')
85}
86
87export function match(wires: readonly Tripwire[], toolName: string, input: Record<string, unknown>): Fired[] {
88 const fired: Fired[] = []
89 for (const wire of wires) {
90 if (!compile(wire.tool)!.test(toolName)) continue
91 const text = haystack(input, wire.field)
92 const hit = compile(wire.pattern)!.exec(text)
93 if (!hit) continue
94 if (wire.unless && compile(wire.unless)!.test(text)) continue
95 fired.push({ wire, matched: hit[0] })
96 }
97 return fired
98}
99
100export function worst(fired: readonly Fired[]): Severity | null {
101 if (fired.length === 0) return null
102 return fired.reduce<Severity>((w, f) => (RANK[f.wire.severity] > RANK[w] ? f.wire.severity : w), 'remind')
103}
104
105/** What the agent reads. The matched text is shown but capped, never a whole secret-bearing command. */
106export function render(fired: readonly Fired[]): string {
107 return fired
108 .map(f => {
109 const shown = f.wire.redact ? '[redacted]' : f.matched.length > 40 ? `${f.matched.slice(0, 12)}…` : f.matched
110 return `⚡ TRIPWIRE [${f.wire.id}] (${f.wire.severity}) on "${shown}": ${f.wire.lesson}${f.wire.source ? ` (learned: ${f.wire.source})` : ''}`
111 })
112 .join('\n')
113}
114hooks/tripwires.ts 121 lines1import type { Tripwire } from './match.ts'
2
3/**
4 * The built-in tripwires: real mistakes, each with where it was learned.
5 * Add your own in ~/.claude/tripwires.json (same shape, as an array or
6 * { "tripwires": [...] }); they load alongside these.
7 */
8export const SEED: Tripwire[] = [
9 {
10 "id": "secret-in-command",
11 "tool": "^Bash$",
12 "field": "command",
13 "pattern": "(sk_live_[A-Za-z0-9]{8,}|sk_test_[A-Za-z0-9]{8,}|sb_secret_[A-Za-z0-9_-]{8,}|eyJhbGciOi[A-Za-z0-9_-]{10,}\\.[A-Za-z0-9_-]{10,}|AKIA[0-9A-Z]{16}|re_[A-Za-z0-9]{20,}|ghp_[A-Za-z0-9]{30,})",
14 "severity": "block",
15 "redact": true,
16 "lesson": "A secret value is in the command text, so it lands in the transcript and shell history. Pipe it from the clipboard instead: pbpaste | <command> && pbcopy < /dev/null. Treat this one as leaked and rotate it.",
17 "source": "a service_role key pasted as a wrangler secret NAME, 2026-10-04"
18 },
19 {
20 "id": "deploy-verify-content",
21 "tool": "^Bash$",
22 "field": "command",
23 "pattern": "(wrangler (pages )?deploy|npm run deploy)(?!:dry)",
24 "unless": "--dry-run",
25 "severity": "remind",
26 "lesson": "After this deploy, verify on CONTENT, not the status code: curl the live URL and grep for text that only the new version contains.",
27 "source": "false 'deployed' claims, twice, on a production app"
28 },
29 {
30 "id": "kv-list-needs-remote",
31 "tool": "^Bash$",
32 "field": "command",
33 "pattern": "wrangler kv key (list|get)",
34 "unless": "--remote",
35 "severity": "remind",
36 "lesson": "Without --remote, wrangler reads the LOCAL dev store and returns []. A working form looks broken.",
37 "source": "half an hour lost on a client site"
38 },
39 {
40 "id": "apps-script-redirect",
41 "tool": "^Bash$",
42 "field": "command",
43 "pattern": "curl(?=.*script\\.google(usercontent)?\\.com)(?=.*(-X POST|--post30[123]))",
44 "severity": "remind",
45 "lesson": "Apps Script answers 302 to an echo URL that must be fetched with GET. Use curl -sL -d '...' with no -X POST and no --post302, or you get a fake 'Page Not Found'.",
46 "source": "a sheet sync check, 2026-10-04"
47 },
48 {
49 "id": "migration-in-transaction",
50 "tool": "^Bash$",
51 "field": "command",
52 "pattern": "(psql|wrangler d1 execute|supabase db).*(drop |delete from|update |alter table)",
53 "unless": "begin",
54 "severity": "ask",
55 "lesson": "A schema or data change that isn't wrapped in begin/commit can stop half-way and leave damage (a notes move once blanked notes it hadn't copied). Wrap it.",
56 "source": "production migrations, 2026-10-03"
57 },
58 {
59 "id": "no-claude-attribution",
60 "tool": "^Bash$",
61 "field": "command",
62 "pattern": "git commit[\\s\\S]*(Co-Authored-By: Claude|Generated with \\[?Claude Code)",
63 "severity": "block",
64 "lesson": "No Claude attribution in this user's commits: no Co-Authored-By trailer, no 'Generated with Claude Code'.",
65 "source": "standing preference"
66 },
67 {
68 "id": "commit-only-when-asked",
69 "tool": "^Bash$",
70 "field": "command",
71 "pattern": "git commit",
72 "severity": "remind",
73 "lesson": "Only commit when the user asked for THIS commit. An earlier 'commit everything' does not cover later changes.",
74 "source": "a follow-up fix committed unasked, 2026-10-04"
75 },
76 {
77 "id": "outbound-email-strangers",
78 "tool": "^Bash$",
79 "field": "command",
80 "pattern": "(send-smtp\\.py|api\\.resend\\.com/emails|generate-outreach)",
81 "severity": "ask",
82 "lesson": "This path emails real strangers. Never trigger it as a side effect of another task.",
83 "source": "standing rule"
84 },
85 {
86 "id": "assets-dir-not-root",
87 "tool": "^(Edit|Write|MultiEdit)$",
88 "pattern": "\"directory\"\\s*:\\s*\"\\./?\"",
89 "severity": "block",
90 "lesson": "assets.directory must be a subfolder. Pointing it at the repo root uploads .git and serves the whole history publicly.",
91 "source": "lead-site template"
92 },
93 {
94 "id": "sheet-formula-injection",
95 "tool": "^(Edit|Write|MultiEdit)$",
96 "pattern": "appendRow\\(",
97 "unless": "asText|sheetSafe",
98 "severity": "remind",
99 "lesson": "Google Sheets runs any cell starting with = + - @ as a formula. Prefix client-supplied text with ' before appendRow.",
100 "source": "a security review, 2026-10-04"
101 },
102 {
103 "id": "gmail-compose-swallows-keys",
104 "tool": "claude-in-chrome__navigate$",
105 "field": "url",
106 "pattern": "mail\\.google\\.com.*view=cm",
107 "severity": "remind",
108 "lesson": "In Gmail compose, the first keystrokes after page load are swallowed. Type the recipient in a separate step and zoom to verify the To field before moving on.",
109 "source": "10 contractor drafts, 2026-10-04"
110 },
111 {
112 "id": "supabase-host-unreachable",
113 "tool": "^Bash$",
114 "field": "command",
115 "pattern": "[a-z0-9]{20}\\.supabase\\.co",
116 "severity": "remind",
117 "lesson": "If the project host doesn't resolve (ENOTFOUND), a free Supabase project has probably PAUSED from inactivity. Restore it in the dashboard; it isn't your code.",
118 "source": "a production outage, 2026-10-03"
119 }
120]
121