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…

Bakhtiyar Ospanov's agent skills, and Claude Code mods: plugins built on function hooks.
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.
Reachable only when you type them (Claude Code: disable-model-invocation: true; Codex: policy.allow_implicit_invocation: false in agents/openai.yaml).
Model- or user-reachable.
prototype (MIT), its UI branch reworked: whole flows, options grouped by stage in a one-click panel.Early access; the API changes between releases.
One mod per purpose.
| Mod | Purpose |
|---|---|
| git-gates | Git work is authorized and tidy |
| git-cleanup | Merged work is cleaned up once it shipped |
| issue-first | Feature work starts from an issue |
| lean-docs | Docs worth keeping |
| lean-comments | Comments worth keeping |
| lean-scripts | Scripts 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.
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.
| Check | Runs on | Needs | Then |
|---|---|---|---|
| consent | git commit, push; PR/MR merge | commit, 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 named | Call denied |
| grants | Later commits in the session | A message asking for a commit per task, or the grant tool after an authorizing message | Commits spend the grant; pushes never |
| messages | A git commit | Conventional 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 subtle | Commit denied |
| descriptions | Setting an MR/PR description | Fixed-label blocks at column 0 | Call denied |
| fresh base | Opening an MR/PR: gh pr create, glab mr create, a GitLab API POST to merge_requests | The source branch, as origin has it, contains the target's latest commit, fetched first; skipped when the message says not to rebase | Call denied |
| landed branch | A git push | The branch's pushed head already sits in a protected branch, and the message names no new MR | Push denied |
| every commit works | A git push of 2 to 15 commits no remote has | Sonnet: no commit removes something a later one stops using, or uses something a later one adds; skipped when the message says the order is fine | Push denied |
| one commit per change | A git push of new commits to a branch whose commits not yet in an integration branch number 2 to 15, pushed or not | No 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 commits | Push held: the agent shows the user the reason and asks; any answer lets the same commits through |
| one feature per MR | A git push of new commits to a branch whose commits not yet in an integration branch number 1 to 15, pushed or not | Its 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 together | Push 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.
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.
| Check | Runs on | Needs | Then |
|---|---|---|---|
| merged first | Removing a worktree or branch, local or on origin | The 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 first | Removing 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 wait | Call denied while it runs or after it failed |
| stale work | The end of a turn | A branch the session committed to or pushed that sits in an integration branch, its worktree clean, no pipeline holding it still running or failed | Follow-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.
| Check | Runs on | Needs | Then |
|---|---|---|---|
| feature issue | A feat commit | An 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 turn | Commit 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.
| Check | Runs on | Flags | Then |
|---|---|---|---|
| docs-review | A doc grown in a git checkout | Haiku: text nobody reads after the task (runbooks, setup pages, narration) | Claude gets the reason |
| docs-no-repeat-code | A doc line being written | Identifiers that already appear together in one code file | Write denied |
| limit-docs | The end of a turn | New or grown docs, prose outweighing code, doc lines repeating code | Follow-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.
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.
| Check | Runs on | Flags | Then |
|---|---|---|---|
| limit-edits | A Write or Edit | More than 3 added comment lines, or a comment-heavy region around the edit | Claude gets the guidance |
| limit-turns | The end of a turn | More than 3 new comment lines per file in the turn's diff | Follow-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.
| Check | Runs on | Flags | Then |
|---|---|---|---|
| scripts-review | A script written or grown in a git checkout | Haiku: scripts you could just type again when needed | Claude gets the reason |
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
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.
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/ is written by /plugin-types types, run inside a session started as above. Regenerate, never edit, when:
head -1 types/claude-code.d.ts vs claude --version)$ is enabled or disabledCommit the result; git diff types/ shows what the update changed.
hooks/register.ts 55 lines1import 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}
55hooks/feature-commit.ts 36 lines1const 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')
36hooks/shared/git-commands.ts 139 lines1export 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}
139hooks/shared/issue-refs.ts 57 lines1const 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