SLOPSHOPPER

issue-first

Feature work starts from an issue: a feat commit naming none is refused until Claude finds the issue, drafts one the user agrees to, or the user says it needs…

newguardpromptprocess
v1.14.0MITupdated 2026-10-15bahaospanov/skills/issue-first
A shopper browsing a rack in a slop shop
README

bahaospanov

Bakhtiyar Ospanov's agent skills, and Claude Code mods: plugins built on function hooks.

Skills

For any agent the skills CLI supports:

npx skills add bahaospanov/skills --skill <skill>

Or in Claude Code, all of them as one plugin, invoked as /bahaospanov-skills:<skill>:

/plugin marketplace add bahaospanov/skills
/plugin install bahaospanov-skills@bahaospanov

Pick one: installing both leaves every skill twice.

User-invoked

Reachable only when you type them (Claude Code: disable-model-invocation: true; Codex: policy.allow_implicit_invocation: false in agents/openai.yaml).

  • release: Integration branch to production: fold fixup commits into their parent, cut waves around migrations and one-time steps, ship wave by wave with a runbook issue, then tag a SemVer release, close the shipped issues and build a phone-sized release album.

Model-invoked

Model- or user-reachable.

Mods

Early access; the API changes between releases.

One mod per purpose.

ModPurpose
git-gatesGit work is authorized and tidy
git-cleanupMerged work is cleaned up once it shipped
issue-firstFeature work starts from an issue
lean-docsDocs worth keeping
lean-commentsComments worth keeping
lean-scriptsScripts worth keeping

Haiku reviews are gated in code first, so a call that cannot fail the review costs no model call. End-of-turn checks read the git diff of repos the turn touched, Bash edits included, and send at most two follow-up prompts a session.

git-gates

Pushing is a deploy, so the agent needs the user's word in the current turn: the prompt that opened it or one typed while it ran. If a consent check itself fails, the call is blocked.

CheckRuns onNeedsThen
consentgit commit, push; PR/MR mergecommit, push, ship, deploy, pr, mr, tag or release typed in the current turn (a message typed while it runs or a background task's report does not withdraw it); merge needs "merge"; a protected branch must be namedCall denied
grantsLater commits in the sessionA message asking for a commit per task, or the grant tool after an authorizing messageCommits spend the grant; pushes never
messagesA git commitConventional Commits subject, no reviewer pre-answers, no Co-Authored-By trailer naming Claude or Anthropic (in the message or a --trailer flag) and no "Generated with Claude Code" line, a last line with the issue or ticket (#87, #BLK-23) when your messages or the branch name one; then Haiku: a body only when the cause is subtleCommit denied
descriptionsSetting an MR/PR descriptionFixed-label blocks at column 0Call denied
fresh baseOpening an MR/PR: gh pr create, glab mr create, a GitLab API POST to merge_requestsThe source branch, as origin has it, contains the target's latest commit, fetched first; skipped when the message says not to rebaseCall denied
landed branchA git pushThe branch's pushed head already sits in a protected branch, and the message names no new MRPush denied
every commit worksA git push of 2 to 15 commits no remote hasSonnet: no commit removes something a later one stops using, or uses something a later one adds; skipped when the message says the order is finePush denied
one commit per changeA git push of new commits to a branch whose commits not yet in an integration branch number 2 to 15, pushed or notNo fixup!, squash! or amend! subject; then Sonnet: no later commit spends most of its diff fixing, reworking or extending lines an earlier one added; skipped when the message says to keep the commitsPush held: the agent shows the user the reason and asks; any answer lets the same commits through
one feature per MRA git push of new commits to a branch whose commits not yet in an integration branch number 1 to 15, pushed or notIts commit messages name at most one issue; then Sonnet: no piece of it could ship alone apart from the rest; skipped when the message says one MR or to keep them togetherPush held: the agent shows the user the features, recommends a branch and MR each, and asks; any answer lets the branch through for the session

Protected branches come from a repo's own push policy file. Integration branches are the protected ones; with no policy, the remote's default branch and any of dev, develop, main, master that exist. Deleting a branch on origin needs no keyword when origin's head of it already sits in an integration branch. A bare #87 counts only in a repo with a remote; with no issue tracker, nothing is asked. Issues you typed bind only commits in the session's repo and its worktrees; a branch ending in its issue number (perf/mobile-lcp-89) lets the message end with that one instead.

git-cleanup

Merged is not shipped: a branch is cleaned up and its issue filled in only once the pipeline holding the merge has passed. Closing the issue is left to you.

CheckRuns onNeedsThen
merged firstRemoving a worktree or branch, local or on originThe branch sits in an integration branch, a merged PR/MR has it as source branch, or the current turn's message says it merged (or to abandon it)Call denied
pipeline firstRemoving a worktree or branch, local or on origin; rewriting an issue's body (checklist ticks, How to test)The work (the branch, or the newest integration commit naming the issue) landed and a pipeline holding it passed; skipped when the message says not to waitCall denied while it runs or after it failed
stale workThe end of a turnA branch the session committed to or pushed that sits in an integration branch, its worktree clean, no pipeline holding it still running or failedFollow-up prompt to remove the worktree and the branch, local and on origin, once checked, and to fast-forward a local integration branch that is behind origin

Integration branches are found as git-gates finds them. A branch sits in an integration branch when its head does, or when every commit of it has a copy there (a rebase merge). Pipelines are read with gh on GitHub and, on GitLab, with the gitlab_token option: a read_api token, asked when the plugin is enabled, kept in the keychain on macOS and in ~/.claude/.credentials.json elsewhere. With no token or no pipeline holding the work, the pipeline check holds nothing back and a log line says why; merged first still applies, and on GitLab sees a squash merge only with the token.

issue-first

CheckRuns onNeedsThen
feature issueA feat commitAn issue (#107, #BLK-23) in the message or the branch name (feat/catalog-menu-107), or "no issue" typed by the user in the current turnCommit denied; Claude looks for the issue, else drafts one and asks the user before creating it

A repo with no remote is skipped. Enabled in a repo's own .claude/settings.json, it applies to everyone working there.

lean-docs

CheckRuns onFlagsThen
docs-reviewA doc grown in a git checkoutHaiku: text nobody reads after the task (runbooks, setup pages, narration)Claude gets the reason
docs-no-repeat-codeA doc line being writtenIdentifiers that already appear together in one code fileWrite denied
limit-docsThe end of a turnNew or grown docs, prose outweighing code, doc lines repeating codeFollow-up prompt

No check reads a skill's folder, the one holding SKILL.md, or anything under it: a skill is read again on every use.

lean-comments

No comments by default: keep the ones that record a measured number, a trap or an invariant, cut the ones that restate the code or narrate the change.

CheckRuns onFlagsThen
limit-editsA Write or EditMore than 3 added comment lines, or a comment-heavy region around the editClaude gets the guidance
limit-turnsThe end of a turnMore than 3 new comment lines per file in the turn's diffFollow-up prompt

No check reads a file installed under ~/.agents/skills, ~/.claude/skills or ~/.claude/plugins: it is someone else's code. A link from there into a checkout is followed, and the file is checked.

lean-scripts

CheckRuns onFlagsThen
scripts-reviewA script written or grown in a git checkoutHaiku: scripts you could just type again when neededClaude gets the reason

Install mods

Mods load only with function hooks enabled, so export this in your shell profile first:

export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1

Without it Claude Code skips the mods silently. Then, in Claude Code:

/plugin marketplace add bahaospanov/skills
/plugin install <mod>@bahaospanov

Develop

One folder per mod. tsconfig.json and types/ are shared. An installed mod carries only its own folder, so code two mods share is copied into each one's hooks/shared/.

CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir ./<mod> --debug

Saving a file under <mod>/hooks/ reloads the mod. Repeat --plugin-dir to load several.

Check

npm run typecheck                 # tsc over every mod and its tests
npm run check:shared              # hooks/shared/ copies are identical across mods
npm run check:version             # every plugin.json carries package.json's version
claude plugin validate ./<mod>    # what the engine sees the module hook and call
claude plugin test ./<mod>        # the mod's tests/

Types

types/ is written by /plugin-types types, run inside a session started as above. Regenerate, never edit, when:

  • Claude Code updates (head -1 types/claude-code.d.ts vs claude --version)
  • a plugin that adds to $ is enabled or disabled
  • an MCP server is connected or disconnected

Commit the result; git diff types/ shows what the update changed.

Source 4 files
hooks/register.ts 55 lines
1import type { EngineInterface, Register } from 'claude-code'
2import { featureRefused, isFeature, messageOf, subjectOf, waives } from './feature-commit'
3import { commandDir, currentTurn } from './shared/git-commands'
4import { refsInBranch, refsInText, tailRefInBranch } from './shared/issue-refs'
5
6const HUMAN_ORIGINS: readonly string[] = ['composer', 'bridge', 'sdk']
7const LOOKBACK = 10
8
9type Prompt = { text: string; human: boolean; turnId?: string | undefined }
10
11let prompts: Prompt[] = []
12
13const git = async ($: EngineInterface, args: string[], cwd?: string) => {
14  const run = await $.process.run(['git', ...args], cwd === undefined ? undefined : { cwd })
15  return run.exitCode === 0 ? run.stdout.trim() : undefined
16}
17
18// The loader only admits literal $.env.get names; HOME is all a `cd ~/…` needs.
19const repoOf = async ($: EngineInterface, command: string) => {
20  const dir = commandDir(command)
21  return dir?.startsWith('~') ? `${await $.env.get('HOME')}${dir.slice(1)}` : dir
22}
23
24const messageText = async ($: EngineInterface, source: { text: string } | { file: string }, cwd?: string) => {
25  if ('text' in source) return source.text
26  if (source.file.startsWith('/')) return $.fs.read(source.file).catch(() => undefined)
27  const dir = cwd?.startsWith('/') ? cwd : [await $.session.cwd(), cwd].filter(Boolean).join('/')
28  return $.fs.read(`${dir}/${source.file}`).catch(() => undefined)
29}
30
31const branchNamesIssue =async ($: EngineInterface, cwd?: string) => {
32  const branch = await git($, ['rev-parse', '--abbrev-ref', 'HEAD'], cwd)
33  return branch !== undefined && [...refsInBranch(branch, true), ...tailRefInBranch(branch, true)].length > 0
34}
35
36export const register: Register = (on) => {
37  on('prompt.submit', ($, e, next) => {
38    prompts = [...prompts, { text: e.text, human: HUMAN_ORIGINS.includes(e.origin.kind), turnId: e.turnId }].slice(-LOOKBACK)
39    return next(e)
40  })
41
42  // No .catch: a check that throws lets the commit run, which suits a nudge better than blocking every commit.
43  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
44    const source = messageOf(e.command)
45    if (source === undefined) return next(e)
46    const cwd = await repoOf($, e.command)
47    const text = await messageText($, source, cwd)
48    if (text === undefined || !isFeature(text) || refsInText(text, true).length > 0) return next(e)
49    if (!(await git($, ['remote'], cwd))) return next(e)
50    if (await branchNamesIssue($, cwd)) return next(e)
51    if (currentTurn(prompts).some((p) => p.human && waives(p.text))) return next(e)
52    return { deny: featureRefused(subjectOf(text)) }
53  })
54}
55
hooks/feature-commit.ts 36 lines
1const COMMIT = /(?:^|[;&|\n(])\s*(?:cd\s+\S+\s*&&\s*)*git(?:\s+(?:-[cC]\s+\S+|--?[\w-]+(?:=\S+)?))*\s+commit\b/
2const FEATURE = /^feat(\([^)]*\))?!?:\s/
3const WAIVER = /\b(no|without(\s+an?)?|skip(\s+the)?)\s+issue\b/i
4
5export type MessageSource = { text: string } | { file: string } | undefined
6
7// Read only after `git commit`. A heredoc covers both `-F -` and Claude Code's `-m "$(cat <<'EOF' …)"`.
8export const messageOf = (command: string): MessageSource => {
9  const at = command.search(COMMIT)
10  if (at < 0) return undefined
11  const rest = command.slice(at)
12  if (/\s--no-edit\b/.test(rest)) return undefined
13  const heredoc = rest.match(/<<-?\s*(['"]?)(\w+)\1[^\n]*\n([\s\S]*?)\n\s*\2\b/)
14  if (heredoc?.[3] !== undefined) return { text: heredoc[3] }
15  const dashM = [...rest.matchAll(/(?:\s-[a-zA-Z]*m|\s--message)(?:\s*|=)(['"])([\s\S]*?)\1/g)].map((m) => m[2] ?? '')
16  if (dashM.length > 0) return { text: dashM.join('\n\n') }
17  const file = rest.match(/\s(?:-F|--file)(?:\s+|=)(\S+)/)?.[1]
18  return file === undefined || file === '-' ? undefined : { file }
19}
20
21export const subjectOf = (message: string) => message.trim().split(/\r?\n/)[0]?.trim() ?? ''
22
23export const isFeature = (message: string) => FEATURE.test(subjectOf(message))
24
25export const waives = (text: string) => WAIVER.test(text)
26
27export const featureRefused = (subject: string) =>
28  [
29    `issue-first: "${subject.slice(0, 70)}" is a feature, and neither it nor the branch names an issue (#N).`,
30    'Before committing:',
31    "1. Search the tracker's open issues for the one this work belongs to (AGENTS.md or the repo docs say how to reach it).",
32    '2. Found: end the subject with its number, e.g. "feat(web): catalog menu #107".',
33    '3. None: draft an issue from the diff (title; what and why) and show it to the user. Create it only once they agree, then commit with its number.',
34    'Ask the user rather than skipping this: only a message from them saying "no issue" lets the commit through without one.',
35  ].join('\n')
36
hooks/shared/git-commands.ts 139 lines
1export type Verb = 'merge' | 'push' | 'commit'
2
3const MERGE = /(merge_requests\/[0-9]+\/merge|pulls\/[0-9]+\/merge|(^|[\s&;|(])(gh\s+pr|glab\s+mr)\s+merge(\s|$))/m
4const PUSH = /(^|[\s&;|(])git\s+push(\s|$)/m
5const COMMIT = /(^|[\s&;|(])git\s+commit(\s|$)/m
6
7// With no push policy a repo's integration branches are guessed: the remote's default branch, then these.
8export const FALLBACK_BASES: readonly string[] = ['dev', 'develop', 'main', 'master']
9
10// Quotes are stripped first so a message quoting `git push` is not a push.
11export const verbOf = (command: string): Verb | undefined => {
12  if (MERGE.test(command)) return 'merge'
13  const unquoted = command.replace(/'[^']*'/g, '').replace(/"[^"]*"/g, '')
14  if (PUSH.test(unquoted)) return 'push'
15  if (COMMIT.test(unquoted)) return 'commit'
16  return undefined
17}
18
19// A prompt typed over a running turn joins the one that opened that turn, so a quick follow-up cannot withdraw its word.
20export const currentTurn = <P extends { turnId?: string | undefined }>(prompts: P[]): P[] => {
21  const last = prompts.at(-1)
22  if (last === undefined) return []
23  if (last.turnId === undefined) return [last]
24  let start = prompts.length - 1
25  while (start > 0 && prompts[start - 1]?.turnId === last.turnId) start--
26  return prompts.slice(Math.max(0, start - 1))
27}
28
29export const branchesOf = (policy: string): string[] => {
30  try {
31    const parsed: unknown = JSON.parse(policy)
32    const branches = typeof parsed === 'object' && parsed !== null && 'protected_branches' in parsed ? parsed.protected_branches : []
33    return Array.isArray(branches) ? branches.filter((b): b is string => typeof b === 'string' && b !== '') : []
34  } catch {
35    return []
36  }
37}
38
39export const defaultBranchOf = (symref: string | undefined) => symref?.trim().replace(/^refs\/remotes\/origin\//, '') || undefined
40
41// Every branch the command's `git push`es write to; `*` for --all/--mirror, undefined when one cannot be known.
42export const pushTargets = (command: string, current: string | undefined): string[] | undefined => {
43  const tokens = command.replace(/\n/g, ' ; ').split(/\s+/).filter(Boolean)
44  const targets: string[] = []
45  let found = false
46  let i = 0
47  while (i < tokens.length) {
48    if (tokens[i] !== 'git' || tokens[i + 1] !== 'push') {
49      i++
50      continue
51    }
52    found = true
53    i += 2
54    let remoteSeen = false
55    let all = false
56    const refs: string[] = []
57    for (; i < tokens.length; i++) {
58      const token = tokens[i] ?? ''
59      if (['&&', '||', ';', '|'].includes(token)) break
60      if (token === '--all' || token === '--mirror') all = true
61      else if (['--repo', '--push-option', '--receive-pack', '--exec', '-o'].includes(token)) i++
62      else if (token.startsWith('-')) continue
63      else if (!remoteSeen) remoteSeen = true
64      else refs.push(token)
65    }
66    if (all) {
67      targets.push('*')
68    } else if (refs.length === 0) {
69      if (current === undefined) return undefined
70      targets.push(current)
71    } else {
72      for (const ref of refs) {
73        let branch = ref.replace(/^\+/, '')
74        branch = branch.slice(branch.lastIndexOf(':') + 1).replace(/^refs\/heads\//, '')
75        if (branch === 'HEAD') {
76          if (current === undefined) return undefined
77          branch = current
78        }
79        if (branch !== '') targets.push(branch)
80      }
81    }
82  }
83  return found ? targets : undefined
84}
85
86export const deletedBranches = (command: string): string[] | undefined => {
87  const tokens = command.replace(/\n/g, ' ; ').split(/\s+/).filter(Boolean)
88  const deleted: string[] = []
89  let found = false
90  let i = 0
91  while (i < tokens.length) {
92    if (tokens[i] !== 'git' || tokens[i + 1] !== 'push') {
93      i++
94      continue
95    }
96    found = true
97    i += 2
98    let remoteSeen = false
99    let deleting = false
100    const refs: string[] = []
101    for (; i < tokens.length; i++) {
102      const token = tokens[i] ?? ''
103      if (['&&', '||', ';', '|'].includes(token)) break
104      if (token === '--delete' || token === '-d') deleting = true
105      else if (['--repo', '--push-option', '--receive-pack', '--exec', '-o'].includes(token)) i++
106      else if (token.startsWith('-')) continue
107      else if (!remoteSeen) remoteSeen = true
108      else refs.push(token)
109    }
110    if (refs.length === 0) return undefined
111    for (const ref of refs) {
112      if (!deleting && !ref.startsWith(':')) return undefined
113      const branch = ref.replace(/^:/, '').replace(/^refs\/heads\//, '')
114      if (branch === '' || branch.includes(':')) return undefined
115      deleted.push(branch)
116    }
117  }
118  return found && deleted.length > 0 ? deleted : undefined
119}
120
121const DIR = String.raw`("[^"]*"|'[^']*'|[^\s;&|()]+)`
122const CD_STEP = new RegExp(String.raw`(?:^|[;&|\n(])\s*cd\s+${DIR}`, 'g')
123const GIT_DIR = new RegExp(String.raw`^git\s+-C\s+${DIR}`)
124
125const unquote = (value: string) => value.replace(/^(["'])([\s\S]*)\1$/, '$2')
126const joinDir = (base: string | undefined, dir: string) =>
127  base === undefined || dir.startsWith('/') || dir.startsWith('~') ? dir : `${base.replace(/\/$/, '')}/${dir}`
128
129// The directory a command's first `git` runs in, from the `cd` steps before it and its `-C`;
130// undefined when that is the session's own. `~` is left for the caller to expand.
131export const commandDir = (command: string): string | undefined => {
132  const at = command.search(/\bgit\s/)
133  if (at < 0) return undefined
134  let dir: string | undefined
135  for (const step of command.slice(0, at).matchAll(CD_STEP)) dir = joinDir(dir, unquote(step[1] ?? ''))
136  const flag = command.slice(at).match(GIT_DIR)?.[1]
137  return flag === undefined ? dir : joinDir(dir, unquote(flag))
138}
139
hooks/shared/issue-refs.ts 57 lines
1const NUMBER = /(?<![\w&#/])#([1-9]\d*)\b/g
2const ISSUE_URL = /\/(?:-\/)?(?:issues|pull|merge_requests)\/(\d+)\b/g
3const KEY = /(?<![\w-])#?([A-Z][A-Z0-9]{1,9})-(\d+)(?![\w-])/g
4const BRANCH_KEY = /^([a-z][a-z0-9]{1,9})-(\d+)(?:-|$)/i
5const BRANCH_NUMBER = /^(\d+)-[a-z]/i
6const BRANCH_TAIL = /(?:^|-)[a-z0-9]*[a-z][a-z0-9]*-(\d+)$/i
7const TRAILING = /(?:^|\s)((?:\(?#[A-Za-z0-9]+(?:-\d+)?\)?[.,]?\s*)+)$/
8const ENDED = /#(\d+|[A-Za-z][A-Za-z0-9]*-\d+)/g
9
10// Shaped like tracker keys but naming standards, so "UTF-8" or "SHA-256" in a message is not a ticket.
11const NOT_TRACKERS = new Set([
12  'AES', 'ANSI', 'ASCII', 'BASE', 'CP', 'CRC', 'CVE', 'COVID', 'ECMA', 'ES', 'GMT', 'GPT', 'HTTP', 'IEEE', 'IPV',
13  'ISO', 'KOI', 'MD', 'PEP', 'RFC', 'RSA', 'SHA', 'SSL', 'TCP', 'TLS', 'UDP', 'USB', 'UTC', 'UTF', 'WCAG', 'WIN',
14])
15
16const ticket = (key: string, n: string) => (NOT_TRACKERS.has(key.toUpperCase()) ? undefined : `#${key.toUpperCase()}-${n}`)
17
18const unique = (refs: (string | undefined)[]) => [...new Set(refs.filter((r): r is string => r !== undefined))]
19
20// `tracked` is whether the repo has a remote: without one, "#87" cannot point at an issue.
21export const refsInText = (text: string, tracked: boolean): string[] => {
22  const found: { at: number; ref: string | undefined }[] = [...text.matchAll(KEY)].map((m) => ({
23    at: m.index,
24    ref: ticket(m[1] ?? '', m[2] ?? ''),
25  }))
26  if (tracked) {
27    for (const m of [...text.matchAll(NUMBER), ...text.matchAll(ISSUE_URL)]) found.push({ at: m.index, ref: `#${m[1]}` })
28  }
29  return unique(found.sort((a, b) => a.at - b.at).map((f) => f.ref))
30}
31
32export const refsInBranch = (branch: string, tracked: boolean): string[] =>
33  unique(
34    branch.split('/').map((segment) => {
35      const key = segment.match(BRANCH_KEY)
36      if (key) return ticket(key[1] ?? '', key[2] ?? '')
37      const number = segment.match(BRANCH_NUMBER)?.[1]
38      return tracked && number !== undefined ? `#${number}` : undefined
39    }),
40  )
41
42// The issue number a slug ends with (`perf/mobile-lcp-89`). Accepted as a message's ending, never
43// demanded: in `chore/node-22` it is a version.
44export const tailRefInBranch = (branch: string, tracked: boolean): string[] => {
45  const number = tracked ? branch.split('/').at(-1)?.match(BRANCH_TAIL)?.[1] : undefined
46  return number === undefined ? [] : [`#${number}`]
47}
48
49export const missingRefViolation = (message: string, refs: string[], accepted: string[] = []): string | undefined => {
50  if (refs.length === 0) return undefined
51  const last = message.trim().split(/\r?\n/).filter((line) => line.trim() !== '').at(-1) ?? ''
52  const tail = last.match(TRAILING)?.[1] ?? ''
53  const ended = [...tail.matchAll(ENDED)].map((m) => `#${(m[1] ?? '').toUpperCase()}`)
54  if (ended.some((ref) => refs.includes(ref) || accepted.includes(ref))) return undefined
55  return `end the message with the issue it is about, e.g. a last line "${refs[0]}" (mentioned: ${refs.join(', ')})`
56}
57