Names a session pr-<repo>-<number> after the first PR it mentions, and adds /handoff and /resume-handoff

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.ts 198 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { age, sessionNameForPr, today } from './names'
5
6const namedSession = atom({ plugin: 'pr-session', key: 'namedSession' } as const, null)
7const givenName = atom({ plugin: 'pr-session', key: 'givenName' } as const, null)
8
9type SessionEntry = { sessionId?: string; name?: string; nameSource?: string }
10
11async function configDir($: EngineInterface): Promise<string> {
12 return (await $.env.get('CLAUDE_CONFIG_DIR')) || `${(await $.env.get('HOME')) || ''}/.claude`
13}
14
15async function git($: EngineInterface, args: string[]): Promise<string | undefined> {
16 const ran = await $.process.run(['git', ...args]).catch(() => undefined)
17 return ran?.exitCode === 0 ? ran.stdout.trim() : undefined
18}
19
20// This session's row in the session registry, which holds its name.
21async function ownEntry($: EngineInterface): Promise<SessionEntry | undefined> {
22 const dir = `${await configDir($)}/sessions`
23 const me = await $.session.id()
24 const entries = await $.fs.list(dir).catch(() => [])
25
26 for (const entry of entries) {
27 if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
28 try {
29 const session: SessionEntry = JSON.parse(String(await $.fs.read(`${dir}/${entry.name}`)))
30 if (session.sessionId === me) return session
31 } catch {
32 continue
33 }
34 }
35
36 return undefined
37}
38
39// A session is renamed once, and only while it carries the name Claude Code
40// derived for it or the one this mod gave it before a /clear.
41async function mayRename($: EngineInterface): Promise<boolean> {
42 if ((await read($, namedSession)) === (await $.session.id())) return false
43
44 const entry = await ownEntry($)
45 if (entry === undefined) return (await read($, givenName)) === null
46
47 return entry.nameSource === 'derived' || entry.name === (await read($, givenName))
48}
49
50// The repository's name, the same from any of its worktrees.
51async function repoName($: EngineInterface): Promise<string> {
52 const common = await git($, ['rev-parse', '--path-format=absolute', '--git-common-dir'])
53 const dir = common?.replace(/\/\.git\/?$/, '') ?? (await $.session.cwd())
54 return dir.split('/').filter(Boolean).pop() ?? 'unknown'
55}
56
57async function handoffDir($: EngineInterface): Promise<string> {
58 return `${await configDir($)}/handoffs/${await repoName($)}`
59}
60
61async function sessionName($: EngineInterface): Promise<string> {
62 const entry = await ownEntry($)
63 return entry?.name ?? (await read($, givenName)) ?? (await $.session.id()).slice(0, 8)
64}
65
66// A command's own hook cannot submit a prompt (the session would wait on
67// itself), so the prompt goes once the command has finished.
68function submitAfter($: EngineInterface, text: string): void {
69 $.clock.after(0, () => void $.prompt.submit({ text }).catch(() => {}))
70}
71
72async function handoffPrompt($: EngineInterface, path: string, name: string, note: string) {
73 const branch = (await git($, ['branch', '--show-current'])) || '(detached)'
74 const base = (await git($, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])) ?? 'master'
75 const commits = (await git($, ['log', '--oneline', '-30', `${base}..HEAD`])) || '(none)'
76 const status = (await git($, ['status', '--short']))?.split('\n').slice(0, 50).join('\n') || '(clean)'
77 const date = today(await $.clock.now())
78
79 return [
80 `Write a handoff for this session to ${path} so a fresh session can continue the work`,
81 'without this conversation. Use exactly this shape:',
82 '',
83 `# Handoff: ${name} (${date})`,
84 '## Goal',
85 '## Done',
86 '## In flight',
87 '## Next steps',
88 '## Gotchas',
89 '## Resume with',
90 `/resume-handoff ${name}`,
91 '',
92 'Ground "Done" in these facts, read just now, plus the PRs opened and review comments posted',
93 'in this conversation:',
94 `- worktree: ${await $.session.cwd()}`,
95 `- branch: ${branch} (base ${base})`,
96 '- commits ahead of base:',
97 commits,
98 '- uncommitted changes:',
99 status,
100 '',
101 'Be specific: file paths, PR numbers, commands, and the decisions the user made with their',
102 'reasons. Leave out anything a new session cannot use.',
103 note ? `The user's note for this handoff: ${note}` : '',
104 'Reply with only the path once it is written.',
105 ].join('\n')
106}
107
108export const register: Register = on => {
109 let pendingName: string | undefined
110
111 on('session.start', async ($, e, next) => {
112 const started = await next(e)
113 await $.command.register({
114 name: 'handoff',
115 description: 'Write a handoff file so a new session can pick up this work',
116 argumentHint: '[note]',
117 })
118 await $.command.register({
119 name: 'resume-handoff',
120 description: "List this repo's handoffs, or load one into this session",
121 argumentHint: '[name or number]',
122 })
123
124 return started
125 })
126
127 on('prompt.submit', async ($, e, next) => {
128 const name = sessionNameForPr(e.text)
129 if (name !== undefined && pendingName === undefined && (await mayRename($))) {
130 pendingName = name
131 }
132
133 return next(e)
134 })
135
136 on('turn.complete', async ($, e, next) => {
137 const done = await next(e)
138 if (e.agentId !== undefined || pendingName === undefined) return done
139
140 const name = pendingName
141 pendingName = undefined
142 const me = await $.session.id()
143 await update($, namedSession, () => me)
144 await update($, givenName, () => name)
145 // /rename runs once the session is idle, after this turn has ended.
146 $.clock.after(0, () => void $.command.run({ command: 'rename', args: name }).catch(() => {}))
147
148 return done
149 })
150
151 on('command.run', { command: 'handoff' }, async ($, e) => {
152 const name = await sessionName($)
153 const path = `${await handoffDir($)}/${name}.md`
154 submitAfter($, await handoffPrompt($, path, name, e.args.trim()))
155
156 return { text: `Writing a handoff to ${path}` }
157 })
158
159 on('command.run', { command: 'resume-handoff' }, async ($, e) => {
160 const dir = await handoffDir($)
161 const now = await $.clock.now()
162 const files = (await $.fs.list(dir).catch(() => []))
163 .filter(f => f.kind === 'file' && f.name.endsWith('.md'))
164 .sort((a, b) => b.mtimeMs - a.mtimeMs)
165 if (files.length === 0) return { text: `No handoffs in ${dir} yet.` }
166
167 const wanted = e.args.trim()
168 if (wanted === '') {
169 const rows = files.map((f, i) => `${i + 1}. ${f.name.replace(/\.md$/, '')} (${age(now - f.mtimeMs)})`)
170 return { text: [...rows, '', 'Run /resume-handoff <name or number> to load one.'].join('\n') }
171 }
172
173 const byNumber = /^\d+$/.test(wanted) ? files[Number(wanted) - 1] : undefined
174 const file = byNumber ?? files.find(f => f.name === `${wanted}.md` || f.name === wanted)
175 if (file === undefined) return { text: `No handoff named ${wanted} in ${dir}.` }
176
177 const path = `${dir}/${file.name}`
178 submitAfter(
179 $,
180 `Read ${path} and pick up where it leaves off. Before changing anything, tell me ` +
181 'where things stand and what the next step is.',
182 )
183
184 return { text: `Loading ${path}` }
185 })
186
187 on('tool.call', { tool: 'Write' }, async ($, e, next) => {
188 const ran = await next(e)
189 const dir = `${await configDir($)}/handoffs/`
190 if (ran.deny === undefined && !ran.isError && e.file_path.startsWith(dir)) {
191 const name = e.file_path.split('/').pop()?.replace(/\.md$/, '')
192 $.ui.toast(`Handoff saved: ${e.file_path}. In a new session: /resume-handoff ${name}`)
193 }
194
195 return ran
196 })
197}
198hooks/names.ts 25 lines1// `github.com/<owner>/<repo>/pull/<n>`, or the bare `<owner>/<repo>/pull/<n>`.
2const PR = /(?:^|[\s(<"'`/])(?:https?:\/\/)?(?:www\.)?(?:github\.com\/)?([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/pull\/(\d+)/
3
4export function sessionNameForPr(text: string): string | undefined {
5 const match = PR.exec(text)
6 if (match === null) return undefined
7
8 const repo = match[2]!.toLowerCase().replace(/\.git$/, '')
9 return `pr-${repo}-${match[3]}`
10}
11
12export function age(ms: number): string {
13 const minutes = Math.floor(ms / 60_000)
14 if (minutes < 60) return `${minutes}m ago`
15 const hours = Math.floor(minutes / 60)
16 if (hours < 48) return `${hours}h ago`
17 return `${Math.floor(hours / 24)}d ago`
18}
19
20export function today(ms: number): string {
21 const date = new Date(ms)
22 const pad = (n: number) => String(n).padStart(2, '0')
23 return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
24}
25types/index.d.ts 8 lines1export type SessionName = string
2
3declare module 'claude-code' {
4 interface PluginState {
5 'pr-session': { namedSession: SessionName | null; givenName: SessionName | null }
6 }
7}
8