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

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.
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
| Agent | Purpose |
|---|---|
pr-code-quality | Reviews idioms, style consistency, design patterns, architectural fit |
pr-issue-alignment | Verifies PR changes align with a GitHub issue's requirements |
pr-performance | Finds performance bottlenecks, scalability and concurrency risks |
pr-review | Comprehensive PR review orchestrating the above |
| Skill | Description |
|---|---|
code-quality-review | Evaluate code idioms, style, design patterns, and architectural fit |
issue-alignment | Verify PR changes align with a GitHub issue's requirements |
performance-review | Find performance bottlenecks, scalability and concurrency risks |
Claude Code only. Each mod is a plugin whose hooks module runs inside the session.
| Mod | Description |
|---|---|
checkout-guard | Shows 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-session | Renames 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-info | Adds <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>
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.
The canonical source of truth is the files in this repo:
agents/*.agent.md — agent prompt bodies with Copilot-style frontmatterskills/*/SKILL.md — reusable skill proceduresThe prompt bodies and skills are plain Markdown and transfer across harnesses unchanged. Only the agent frontmatter is harness-specific:
| GitHub Copilot | Claude Code | Windsurf | |
|---|---|---|---|
| Agent file | *.agent.md | *.md | *.md (workflow) |
tools field | [read, search, web, execute, agent] | Read, Grep, Glob, WebFetch, WebSearch, Bash | — |
| Delegation | agents: + skill IDs | main-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.sh
This installs into each harness's live locations:
| Harness | Agents | Skills |
|---|---|---|
| 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:).
hooks/register.tsx 182 lines1import { 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}
182hooks/commands.ts 81 lines1// 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}
81types/index.d.ts 8 lines1export 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