SLOPSHOPPER

checkout-guard

Warns when another Claude session shares this git checkout and blocks git commands that would move the tree under it

newbandguardpromptprocesstimer
★ 1v0.1.0no licenseupdated 2026-10-12rumstead/ai-agents/mods/checkout-guard
A shopper browsing a rack in a slop shop
README

ai-agents

Personal collection of AI coding subagents, skills, and Claude Code mods, kept in one place so they can be versioned, shared, and reused across AI coding harnesses.

Structure

ai-agents/
├── install.sh        # symlinks agents and skills into their live locations
├── .claude-plugin/   # marketplace.json listing the mods
├── agents/           # *.agent.md subagents
├── skills/           # one folder per skill, each containing a SKILL.md
└── mods/             # one Claude Code plugin of function hooks per folder

Contents

Agents

AgentPurpose
pr-code-qualityReviews idioms, style consistency, design patterns, architectural fit
pr-issue-alignmentVerifies PR changes align with a GitHub issue's requirements
pr-performanceFinds performance bottlenecks, scalability and concurrency risks
pr-reviewComprehensive PR review orchestrating the above

Skills

SkillDescription
code-quality-reviewEvaluate code idioms, style, design patterns, and architectural fit
issue-alignmentVerify PR changes align with a GitHub issue's requirements
performance-reviewFind performance bottlenecks, scalability and concurrency risks

Mods

Claude Code only. Each mod is a plugin whose hooks module runs inside the session.

ModDescription
checkout-guardShows a band when another Claude session is working in the same git checkout and blocks checkout, switch, rebase, pull, merge, stash, reset --hard, clean, and restore there. Subagents started in a worktree are left alone
pr-sessionRenames the session pr-<repo>-<number> after the first PR it mentions, unless you named it yourself. Adds /handoff [note], which writes ~/.claude/handoffs/<repo>/<session>.md for a fresh session to pick up, and /resume-handoff [name or number] to list or load them
session-infoAdds <repo>:<branch> · k8s:<kubectl context> · <model> <effort> to the dim hint line under the prompt, leaving out whatever is unknown. Repo and kube context refresh every 15s and around each turn, effort comes from the request the turn sends

A new mod needs a folder under mods/ and an entry in .claude-plugin/marketplace.json. To check one:

claude plugin validate mods/<name>
claude plugin test mods/<name>

Always-on instructions

A skill whose frontmatter carries applyTo: is personal context (voice, style, standing preferences) rather than a procedure to run on request. Claude Code discovers it by description at run time like any other skill, but Copilot has no on-demand skill discovery, so install.sh also generates an always-on <name>.instructions.md for it in the VS Code prompts directories (local and Remote).

Claude Code only consults such a skill when the task matches its description. Rules that must apply to every reply (no em dashes) belong in ~/.claude/CLAUDE.md, which is loaded into every session.

Portability across harnesses

The canonical source of truth is the files in this repo:

  • agents/*.agent.md — agent prompt bodies with Copilot-style frontmatter
  • skills/*/SKILL.md — reusable skill procedures

The prompt bodies and skills are plain Markdown and transfer across harnesses unchanged. Only the agent frontmatter is harness-specific:

GitHub CopilotClaude CodeWindsurf
Agent file*.agent.md*.md*.md (workflow)
tools field[read, search, web, execute, agent]Read, Grep, Glob, WebFetch, WebSearch, Bash—
Delegationagents: + skill IDsmain-thread subagents + model-invoked skills—

install.sh handles this: Copilot and Windsurf consume the canonical files via symlink, while for Claude Code it generates adapted subagents (rewriting the tools list to Claude Code's PascalCase tool names, dropping Copilot-only keys, and using <name>.md filenames). The comprehensive review workflow includes a direct skill-based fallback for harnesses without nested delegation, and a GitHub CLI fallback for harnesses without GitHub-native tools.

Install

./install.sh

This installs into each harness's live locations:

HarnessAgentsSkills
GitHub Copilot~/Library/Application Support/Code/User/prompts/ on macOS or ~/.config/Code/User/prompts/ elsewhere (VS Code), ~/.config/copilot/agents/ and ~/.copilot/agents/ (CLI) — symlinked~/.copilot/skills/ — symlinked
Windsurf~/.codeium/windsurf/global_workflows/ — symlinked(delivered as workflows)
Claude Code~/.claude/agents/ — generated~/.claude/skills/ — symlinked

For Claude Code it also adds this repo as the rumstead-ai-agents plugin marketplace and installs every mod in it at user scope. The mods are read in place from mods/, so an edit takes effect at the next session start or /reload-plugins.

Plus ~/.config/Code/User/prompts/ and ~/.vscode-server/data/User/prompts/ (generated *.instructions.md for any skill marked applyTo:).

Source 3 files
hooks/register.tsx 182 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Neighbor } from '../types'
5import { gitCallsThatChangeCheckout } from './commands'
6
7const neighbors = atom({ plugin: 'checkout-guard', key: 'neighbors' } as const, [])
8const isDismissed = atom({ plugin: 'checkout-guard', key: 'isDismissed' } as const, false)
9
10const REFRESH_MS = 30_000
11
12type SessionEntry = {
13  pid?: number
14  sessionId?: string
15  cwd?: string
16  name?: string
17  status?: string
18  startedAt?: number
19}
20
21async function home($: EngineInterface): Promise<string> {
22  return (await $.env.get('HOME')) || ''
23}
24
25async function sessionsDir($: EngineInterface): Promise<string> {
26  const configDir = (await $.env.get('CLAUDE_CONFIG_DIR')) || `${await home($)}/.claude`
27  return `${configDir}/sessions`
28}
29
30// The checkout a directory belongs to; a worktree is its own checkout.
31async function checkoutOf($: EngineInterface, dirs: string[]): Promise<string | undefined> {
32  const argv = ['git', ...dirs.flatMap(dir => ['-C', dir]), 'rev-parse', '--show-toplevel']
33  const ran = await $.process.run(argv).catch(() => undefined)
34  return ran?.exitCode === 0 ? ran.stdout.trim() : undefined
35}
36
37async function isRunning($: EngineInterface, pid: number): Promise<boolean> {
38  const ran = await $.process.run(['kill', '-0', String(pid)]).catch(() => undefined)
39  return ran?.exitCode === 0
40}
41
42// Other live Claude Code sessions whose working directory is in `checkout`.
43async function neighborsIn($: EngineInterface, checkout: string): Promise<Neighbor[]> {
44  const dir = await sessionsDir($)
45  const me = await $.session.id()
46  const entries = await $.fs.list(dir).catch(() => [])
47  const found: Neighbor[] = []
48
49  for (const entry of entries) {
50    if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
51
52    let session: SessionEntry
53    try {
54      session = JSON.parse(String(await $.fs.read(`${dir}/${entry.name}`)))
55    } catch {
56      continue
57    }
58    if (!session.cwd || !session.pid || session.sessionId === me) continue
59    if (!(await isRunning($, session.pid))) continue
60    if ((await checkoutOf($, [session.cwd])) !== checkout) continue
61
62    found.push({
63      name: session.name ?? session.sessionId?.slice(0, 8) ?? String(session.pid),
64      status: session.status ?? 'unknown',
65      startedAt: session.startedAt ?? 0,
66    })
67  }
68
69  return found
70}
71
72async function refresh($: EngineInterface): Promise<void> {
73  const checkout = await checkoutOf($, [])
74  const found = checkout === undefined ? [] : await neighborsIn($, checkout)
75  await update($, neighbors, () => found)
76}
77
78const MOVE_PROMPT =
79  'Another Claude session is working in this git checkout. Move this session into a new git ' +
80  'worktree with the EnterWorktree tool before changing anything, then tell me the worktree ' +
81  'path and branch.'
82
83export const register: Register = on => {
84  // Subagents started in a worktree or another directory run git there, not here.
85  const elsewhere = new Set<string>()
86
87  on('session.start', async ($, e, next) => {
88    const started = await next(e)
89    void refresh($)
90    $.clock.every(REFRESH_MS, () => void refresh($))
91
92    return started
93  })
94
95  on('prompt.submit', async ($, e, next) => {
96    void refresh($)
97
98    return next(e)
99  })
100
101  // The Agent tool call carries `isolation`; its spawn carries the agent's id.
102  const worktreeSpawns = new Set<string>()
103
104  on('tool.call', { tool: 'Agent' }, ($, e, next) => {
105    if (e.isolation === 'worktree') worktreeSpawns.add(e.tool_use_id)
106
107    return next(e)
108  })
109
110  on('agent.spawn', async ($, e, next) => {
111    const spawned = await next(e)
112    if (spawned.agentId === undefined) return spawned
113
114    if (worktreeSpawns.delete(e.tool_use_id)) {
115      elsewhere.add(spawned.agentId)
116    } else if (e.cwd !== undefined) {
117      const [mine, theirs] = await Promise.all([checkoutOf($, []), checkoutOf($, [e.cwd])])
118      if (theirs !== undefined && theirs !== mine) elsewhere.add(spawned.agentId)
119    }
120
121    return spawned
122  })
123
124  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
125    if (e.agentId !== undefined && elsewhere.has(e.agentId)) return next(e)
126
127    const calls = gitCallsThatChangeCheckout(e.command, await home($))
128    for (const call of calls) {
129      const checkout = await checkoutOf($, call.dirs)
130      if (checkout === undefined) continue
131
132      const found = await neighborsIn($, checkout)
133      if (found.length === 0) continue
134
135      await update($, neighbors, () => found)
136      const names = found.map(n => n.name).join(', ')
137
138      return {
139        deny:
140          `checkout-guard: another Claude session (${names}) is working in ${checkout}. ` +
141          `\`git ${call.subcommand}\` would change the branch or files under it. ` +
142          'Move this session into a worktree with EnterWorktree first, or ask the user.',
143      }
144    }
145
146    return next(e)
147  }).catch(($, e, next) =>
148    next.called
149      ? next(e)
150      : { deny: 'checkout-guard: could not check whether another session shares this checkout.' },
151  )
152
153  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
154    const found = await read($, neighbors)
155    if (e.props.hasSurvey || found.length === 0 || (await read($, isDismissed))) {
156      return next(e)
157    }
158
159    const { Box, Button, Text } = $.ui.resolve(e)
160    const who = found.map(n => `${n.name} (${n.status})`).join(', ')
161    const verb = found.length === 1 ? 'is' : 'are'
162
163    return (
164      <Box>
165        <Text color="yellow">
166          ⚠ {who} {verb} also working in this checkout; branch and file resetting git commands are
167          blocked.{' '}
168        </Text>
169        <Button
170          key="worktree"
171          label="Use a worktree"
172          onPress={async () => {
173            await update($, isDismissed, () => true)
174            void $.prompt.submit({ text: MOVE_PROMPT })
175          }}
176        />
177        <Button key="dismiss" label="Ignore" onPress={() => update($, isDismissed, () => true)} />
178      </Box>
179    )
180  })
181}
182
hooks/commands.ts 81 lines
1// A git call in a shell command that changes the branch or the files of the
2// checkout it runs in. `dirs` is the chain of `cd` and `-C` directories in
3// front of it, in order, each relative to the one before.
4export type GitCall = { dirs: string[]; subcommand: string; args: string[] }
5
6const SEPARATORS = /&&|\|\||[;|\n(){}]|\$\(/
7const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
8const PREFIXES = new Set(['command', 'exec', 'time', 'nohup', 'env'])
9
10const unquote = (word: string) => word.replace(/^(['"])(.*)\1$/, '$2')
11
12const expandHome = (path: string, home: string) =>
13  path === '~' ? home : path.startsWith('~/') ? home + path.slice(1) : path
14
15export function changesCheckout(subcommand: string, args: string[]): boolean {
16  if (args.some(a => a === '--help' || a === '-h')) return false
17
18  switch (subcommand) {
19    case 'checkout':
20    case 'switch':
21    case 'rebase':
22    case 'pull':
23    case 'merge':
24      return true
25    case 'stash':
26      return !['list', 'show'].includes(args[0] ?? '')
27    case 'reset':
28      return args.some(a => ['--hard', '--merge', '--keep'].includes(a))
29    case 'clean':
30      return !args.some(a => a === '--dry-run' || /^-[a-zA-Z]*n/.test(a))
31    case 'restore': {
32      const isStaged = args.some(a => a === '--staged' || /^-[a-zA-Z]*S/.test(a))
33      const isWorktree = args.some(a => a === '--worktree' || /^-[a-zA-Z]*W/.test(a))
34      return !isStaged || isWorktree
35    }
36    default:
37      return false
38  }
39}
40
41export function gitCallsThatChangeCheckout(command: string, home: string): GitCall[] {
42  const found: GitCall[] = []
43  const cds: string[] = []
44
45  for (const segment of command.split(SEPARATORS)) {
46    const words = segment.trim().split(/\s+/).filter(Boolean).map(unquote)
47    while (words.length > 0 && (ASSIGNMENT.test(words[0]!) || PREFIXES.has(words[0]!))) {
48      words.shift()
49    }
50
51    if (words[0] === 'cd') {
52      const target = words[1]
53      if (target === undefined) cds.push(home)
54      else if (target !== '-') cds.push(expandHome(target, home))
55      continue
56    }
57    if (words[0] !== 'git') continue
58
59    const dirs = [...cds]
60    let i = 1
61    while (i < words.length && words[i]!.startsWith('-')) {
62      const option = words[i]!
63      if (option === '-C') {
64        dirs.push(expandHome(words[i + 1] ?? '.', home))
65        i += 2
66      } else if (option === '-c') {
67        i += 2
68      } else {
69        i += 1
70      }
71    }
72    const subcommand = words[i]
73    const args = words.slice(i + 1)
74    if (subcommand !== undefined && changesCheckout(subcommand, args)) {
75      found.push({ dirs, subcommand, args })
76    }
77  }
78
79  return found
80}
81
types/index.d.ts 8 lines
1export type Neighbor = { name: string; status: string; startedAt: number }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'checkout-guard': { neighbors: Neighbor[]; isDismissed: boolean }
6  }
7}
8