SLOPSHOPPER

iris-guards

Iris repo guards: contract-drift warnings, a pre-push check gate, and a production-command guard.

guardtoaststatuspromptprocess
★ 1v0.1.0no licenseupdated 2026-10-23LosBobes/iris/.claude/skills/iris-guards
A shopper browsing a rack in a slop shop
README

iris-guards

A Claude Code mod (function-hooks plugin) that enforces three repo rules automatically. Claude Code loads it from .claude/skills/iris-guards/ in any session opened in this repo; nothing to install.

GuardHookWhat it does
Contract drifttool.call on Edit/Write, turn.completeAfter an edit to iris-api/openapi.yaml, internal/domain/{types,enums}.go or apps/web/src/types/*.ts, compares everything this branch changed (vs origin/main) and tells Claude which side of the contract-sync rule is missing; toasts a summary at the end of the turn.
Pre-push gatetool.call on BashOn git push, runs the checks for each surface the branch changed (gofmt -l, go vet, go test in iris-api/; npm run lint, npm test, npm run build in apps/web/) and refuses the push with the failing output. Docs-only pushes run nothing.
Production guardtool.check on Bashmake deploy/ssh/logs/db-pull, ssh/scp/rsync, docker compose -f docker-compose.prod.yml, git reset --hard, force pushes, pushes to main and gh workflow run deploy are denied unless the user's latest prompt asks for that kind of action; when it does, the command still needs approval.

The rules live in hooks/rules.ts (pure functions); hooks/register.ts wires them to the engine.

claude plugin validate .claude/skills/iris-guards
claude plugin test .claude/skills/iris-guards
Source 2 files
hooks/register.ts 162 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { checksFor, contractDrift, isContractFile, isGitPush, prodRulesFor, tail } from './rules'
4
5const CHECK_TIMEOUT_MS = 10 * 60 * 1000
6
7type Git = { root: string; lines: (args: string[]) => Promise<string[]> }
8
9/** The repository the session works in, or undefined outside a git checkout. */
10const openGit = async ($: EngineInterface): Promise<Git | undefined> => {
11  const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'])
12  if (top.exitCode !== 0) return undefined
13
14  const root = top.stdout.trim()
15  const lines = async (args: string[]) => {
16    const ran = await $.process.run(['git', '-C', root, ...args])
17    return ran.exitCode === 0 ? ran.stdout.split('\n').filter(Boolean) : []
18  }
19
20  return { root, lines }
21}
22
23/** Where this branch forked from main: origin/main's merge base, else HEAD. */
24const baseOf = async (git: Git): Promise<string> => {
25  const [base] = await git.lines(['merge-base', 'HEAD', 'origin/main'])
26  return base ?? 'HEAD'
27}
28
29/** Every path this branch changes, committed or not, untracked included. */
30const branchChanges = async (git: Git): Promise<string[]> => {
31  const base = await baseOf(git)
32  const diffed = await git.lines(['diff', '--name-only', base])
33  const untracked = await git.lines(['ls-files', '--others', '--exclude-standard'])
34  return [...new Set([...diffed, ...untracked])]
35}
36
37const relativeTo = (root: string, path: string): string =>
38  path.startsWith(root + '/') ? path.slice(root.length + 1) : path
39
40const driftNote = (gaps: string[]): string =>
41  [
42    'iris-guards: the contract-sync rule (CLAUDE.md) looks unbalanced on this branch:',
43    ...gaps.map(gap => `- ${gap}`),
44    'Apply the shape change across openapi.yaml, domain/types.go, the store, apps/web/src/types/ and fixtures, or say why one surface is deliberately untouched.',
45  ].join('\n')
46
47// Set when a contract file is edited, so turn.complete re-checks only then.
48let touchedContract = false
49// The person's latest prompt, which the production guard reads for consent.
50let lastPrompt = ''
51
52type ToolCallAnswer = Awaited<ReturnType<EngineInterface['tool']['call']>>
53
54/** An edit's result, with a drift note for the model when it left a gap. */
55const afterEdit = async ($: EngineInterface, filePath: string, ran: ToolCallAnswer): Promise<ToolCallAnswer> => {
56  if (ran.deny !== undefined || ran.isError === true) return ran
57
58  const git = await openGit($)
59  if (git === undefined || !isContractFile(relativeTo(git.root, filePath))) return ran
60
61  touchedContract = true
62  const gaps = contractDrift(await branchChanges(git))
63  if (gaps.length === 0) return ran
64
65  return { ...ran, context: [...(ran.context ?? []), driftNote(gaps)] }
66}
67
68export const register: Register = on => {
69
70  on('prompt.submit', ($, e, next) => {
71    if (!e.isMeta && e.command === undefined) {
72      lastPrompt = e.text
73      touchedContract = false
74    }
75    return next(e)
76  })
77
78  // 1. Contract drift: after an edit to a contract file, tell the model what
79  //    the other surfaces are missing.
80  on('tool.call', { tool: 'Edit' }, async ($, e, next) => afterEdit($, e.file_path, await next(e)))
81    .catch(($, e, next) => next(e))
82  on('tool.call', { tool: 'Write' }, async ($, e, next) => afterEdit($, e.file_path, await next(e)))
83    .catch(($, e, next) => next(e))
84
85  on('turn.complete', async ($, e, next) => {
86    const done = await next(e)
87    if (e.agentId !== undefined || !touchedContract) return done
88
89    touchedContract = false
90    const git = await openGit($)
91    const gaps = git === undefined ? [] : contractDrift(await branchChanges(git))
92    if (gaps.length > 0) {
93      $.ui.toast(`Contract drift: ${gaps.length} surface gap${gaps.length === 1 ? '' : 's'} (see openapi/domain/web types)`)
94    }
95    return done
96  })
97
98  // 2. Pre-push gate: run the changed surfaces' checks; a failure stops the push.
99  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
100    if (!isGitPush(e.command)) return next(e)
101
102    const git = await openGit($)
103    if (git === undefined) return next(e)
104
105    const base = await baseOf(git)
106    const changed = await git.lines(['diff', '--name-only', base, 'HEAD'])
107    const failures: string[] = []
108
109    for (const check of checksFor(changed)) {
110      $.ui.status(`iris-guards: ${check.label} (${check.cwd})`)
111      const cwd = `${git.root}/${check.cwd}`
112      try {
113        const ran = await $.process.run(check.argv, { cwd, timeoutMs: CHECK_TIMEOUT_MS })
114        // gofmt -l exits 0 and lists unformatted files instead of failing.
115        const listsFiles = check.argv[0] === 'gofmt' && ran.stdout.trim() !== ''
116        if (ran.exitCode !== 0 || listsFiles) {
117          failures.push(`## ${check.label} in ${check.cwd} (exit ${ran.exitCode})\n${tail(ran.stdout + '\n' + ran.stderr)}`)
118        }
119      } catch (error) {
120        failures.push(`## ${check.label} in ${check.cwd} could not run: ${String(error)}`)
121      }
122    }
123    $.ui.status(undefined)
124
125    if (failures.length > 0) {
126      return {
127        deny: [
128          'iris-guards blocked this push: there is no CI and main auto-deploys, so the changed surfaces must pass their checks first.',
129          ...failures,
130          'Fix these (run `npm install` in apps/web if dependencies are missing), then push again.',
131        ].join('\n\n'),
132      }
133    }
134    return next(e)
135  }).catch(($, e, next) =>
136    next.called ? next(e) : { deny: 'iris-guards: the pre-push check gate failed to run; run the per-surface checks by hand.' },
137  )
138
139  // 3. Production guard: commands that reach the server or discard work need
140  //    the person's own ask in their latest prompt, and then their approval.
141  on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
142    const command = (e.input as { command?: unknown }).command
143    if (typeof command !== 'string') return next(e)
144
145    const rules = prodRulesFor(command)
146    if (rules.length === 0) return next(e)
147
148    const unasked = rules.filter(rule => !rule.askedFor.test(lastPrompt))
149    if (unasked.length > 0) {
150      return {
151        decision: 'deny',
152        reason: `iris-guards: this is ${unasked.map(rule => rule.name).join('; ')}, and the user has not asked for it. Ask the user before running it.`,
153      }
154    }
155
156    const verdict = await next(e)
157    return verdict.decision === 'deny'
158      ? verdict
159      : { decision: 'ask', reason: `iris-guards: ${rules.map(rule => rule.name).join('; ')}. Confirm it should run.` }
160  }).catch(() => ({ decision: 'deny', reason: 'iris-guards: the production guard failed; refusing to be safe.' }))
161}
162
hooks/rules.ts 130 lines
1// Pure rules for the Iris guards: no `$`, so the tests can call them directly.
2
3// --- Contract drift -------------------------------------------------------
4
5const OPENAPI = 'iris-api/openapi.yaml'
6const GO_DOMAIN = /^iris-api\/internal\/domain\/(types|enums)\.go$/
7const GO_STORE = /^iris-api\/internal\/store\/.+\.go$/
8const WEB_TYPES = /^apps\/web\/src\/types\/[^/]+\.ts$/
9
10/** True when a repo-relative path is one of the shared contract files. */
11export const isContractFile = (path: string): boolean =>
12  path === OPENAPI || GO_DOMAIN.test(path) || WEB_TYPES.test(path)
13
14/**
15 * The contract-sync gaps in a set of changed repo-relative paths: one line per
16 * surface that changed without its counterpart. Empty when consistent.
17 */
18export const contractDrift = (changed: readonly string[]): string[] => {
19  const openapi = changed.includes(OPENAPI)
20  const domain = changed.some(p => GO_DOMAIN.test(p))
21  const store = changed.some(p => GO_STORE.test(p) && !p.endsWith('_test.go'))
22  const web = changed.some(p => WEB_TYPES.test(p))
23  const gaps: string[] = []
24
25  if (openapi && !domain) {
26    gaps.push(`${OPENAPI} changed but iris-api/internal/domain/types.go did not.`)
27  }
28  if (domain && !openapi) {
29    gaps.push(`iris-api/internal/domain/ changed but ${OPENAPI} did not.`)
30  }
31  if ((openapi || domain) && !web) {
32    gaps.push('The API contract changed but nothing in apps/web/src/types/ did.')
33  }
34  if (web && !openapi && !domain) {
35    gaps.push(`apps/web/src/types/ changed but neither ${OPENAPI} nor the Go domain types did.`)
36  }
37  if (domain && !store) {
38    gaps.push(
39      'Go domain types changed but iris-api/internal/store/ did not: if the field is persisted it needs the store and migrations.go (settings in app_settings need no migration).',
40    )
41  }
42
43  return gaps
44}
45
46// --- Pre-push gate --------------------------------------------------------
47
48/** True when a shell command runs `git push` (not merely mentions it). */
49export const isGitPush = (command: string): boolean =>
50  /(^|[;&|(]\s*|\bthen\s+|\bdo\s+)(\S+=\S+\s+)*git(\s+-C\s+\S+)?\s+push\b/m.test(command) &&
51  !/\bgit\s+push\b[^;&|]*--dry-run\b/.test(command)
52
53export type Check = { label: string; cwd: string; argv: string[] }
54
55/** The per-surface checks a push of these changed paths must pass first. */
56export const checksFor = (changed: readonly string[]): Check[] => {
57  const checks: Check[] = []
58
59  if (changed.some(p => p.startsWith('iris-api/'))) {
60    checks.push(
61      { label: 'gofmt -l', cwd: 'iris-api', argv: ['gofmt', '-l', '.'] },
62      { label: 'go vet ./...', cwd: 'iris-api', argv: ['go', 'vet', './...'] },
63      { label: 'go test ./...', cwd: 'iris-api', argv: ['go', 'test', './...'] },
64    )
65  }
66  if (changed.some(p => p.startsWith('apps/web/'))) {
67    checks.push(
68      { label: 'npm run lint', cwd: 'apps/web', argv: ['npm', 'run', 'lint'] },
69      { label: 'npm test', cwd: 'apps/web', argv: ['npm', 'test'] },
70      { label: 'npm run build', cwd: 'apps/web', argv: ['npm', 'run', 'build'] },
71    )
72  }
73
74  return checks
75}
76
77/** The last `lines` lines of a check's output, for the model to read. */
78export const tail = (text: string, lines = 40): string =>
79  text.trimEnd().split('\n').slice(-lines).join('\n')
80
81// --- Production guard -----------------------------------------------------
82
83export type ProdRule = { name: string; command: RegExp; askedFor: RegExp }
84
85/**
86 * Commands that reach the production server or discard work, each paired with
87 * the words that show the person asked for it in their latest prompt.
88 */
89export const PROD_RULES: readonly ProdRule[] = [
90  {
91    name: 'a production deploy or server access (make deploy/ssh/logs/db-pull)',
92    command: /\bmake\s+(\S+\s+)*(deploy|ssh|logs|db-pull)\b/,
93    askedFor: /\b(deploy|roll ?back|release|ssh|server|logs?|db-pull|backup|prod(uction)?)\b/i,
94  },
95  {
96    name: 'an ssh/scp session to a remote host',
97    command: /(^|[;&|(]\s*)(\S+=\S+\s+)*(ssh|scp|rsync)\s+\S/m,
98    askedFor: /\b(ssh|scp|rsync|server|hetzner|prod(uction)?)\b/i,
99  },
100  {
101    name: 'docker compose against docker-compose.prod.yml',
102    command: /\bdocker(-compose|\s+compose)\b[^;&|]*docker-compose\.prod\.yml/,
103    askedFor: /\b(prod(uction)?|deploy|docker)\b/i,
104  },
105  {
106    name: 'a hard reset that discards local work (git reset --hard)',
107    command: /\bgit\s+(\S+\s+)*reset\s+(\S+\s+)*--hard\b/,
108    askedFor: /\b(reset|discard|throw away|start over)\b/i,
109  },
110  {
111    name: 'a force push',
112    command: /\bgit\s+(\S+\s+)*push\b[^;&|]*(\s--force(-with-lease)?\b|\s-f\b)/,
113    askedFor: /\bforce\b/i,
114  },
115  {
116    name: 'a push straight to main (which deploys)',
117    command: /\bgit\s+(\S+\s+)*push\s+(\S+\s+)*(\S+:)?(refs\/heads\/)?main(?=$|\s|[;&|)])/m,
118    askedFor: /\b(push|merge)\b[^.]*\bmain\b|\bmain\b[^.]*\bpush\b/i,
119  },
120  {
121    name: 'a manual deploy workflow run',
122    command: /\bgh\s+workflow\s+run\b[^;&|]*deploy/,
123    askedFor: /\bdeploy\b/i,
124  },
125]
126
127/** The production rules a command trips. */
128export const prodRulesFor = (command: string): ProdRule[] =>
129  PROD_RULES.filter(rule => rule.command.test(command))
130