SLOPSHOPPER

pr-session

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

newguardcommandtoastpromptprocess
★ 1v0.1.0no licenseupdated 2026-10-12rumstead/ai-agents/mods/pr-session
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pr-session
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /handoff ⎿ pr-session: Writing a handoff to /Users/dev/.claude/handoffs/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0/preview-.md ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
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.ts 198 lines
1import { 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}
198
hooks/names.ts 25 lines
1// `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}
25
types/index.d.ts 8 lines
1export type SessionName = string
2
3declare module 'claude-code' {
4  interface PluginState {
5    'pr-session': { namedSession: SessionName | null; givenName: SessionName | null }
6  }
7}
8