Scripts worth keeping: scripts-review asks Haiku whether a new script could just be typed again when needed.

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 |
| 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, 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 |
| 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 |
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 |
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 | 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 { hasScriptExt, isApplicationSource, isTestFile } from './gates'
3import { SCRIPT_FILE } from './prompts'
4import { dirOf, isDotfileOrTemp, isTrim } from './shared/paths'
5import { MODEL, promptFor, SYSTEM, verdictOf, type Review, type Verdict } from './shared/verdict'
6
7const SCRIPTS_REVIEW: Review = { name: 'scripts-review', prompt: SCRIPT_FILE, status: 'judging script' }
8
9const inCheckout = async ($: EngineInterface, path: string) =>
10 (await $.process.run(['git', '-C', dirOf(path), 'rev-parse', '--show-toplevel'])).exitCode === 0
11
12// The loader follows $ only into functions of this file, so the model call lives here, not in shared/verdict.ts.
13const judge = async ($: EngineInterface, review: Review, input: object): Promise<Verdict | undefined> => {
14 $.ui.status(review.status)
15 try {
16 const result = await $.model.complete({ model: MODEL, system: SYSTEM, prompt: promptFor(review, input) })
17 const reply = result.isAnswered ? result.text : `(${result.reason})`
18 const verdict = verdictOf(reply)
19 if (verdict === undefined) $.ui.log(`lean-scripts/${review.name}: no verdict: ${reply.slice(0, 120)}`)
20 return verdict
21 } finally {
22 $.ui.status(undefined)
23 }
24}
25
26export const register: Register = (on) => {
27 on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
28 const path = e.file_path
29 if (isApplicationSource(path) || isTestFile(path) || isDotfileOrTemp(path, await $.env.get('HOME'), await $.env.get('TMPDIR'))) {
30 return next(e)
31 }
32 const result = await next(e)
33 if (result.deny !== undefined || result.isError) return result
34
35 if (e.tool === 'Edit' && isTrim(e.new_string, e.old_string)) return result
36 const isScript =
37 hasScriptExt(path) || (e.tool === 'Write' ? e.content : await $.fs.read(path).catch(() => '')).startsWith('#!')
38 if (!isScript || !(await inCheckout($, path))) return result
39
40 const tool_input =
41 e.tool === 'Write'
42 ? { file_path: path, content: e.content }
43 : { file_path: path, old_string: e.old_string, new_string: e.new_string, replace_all: e.replace_all }
44 const verdict = await judge($, SCRIPTS_REVIEW, {
45 hook_event_name: 'PostToolUse',
46 tool_name: e.tool,
47 tool_input,
48 cwd: await $.session.cwd(),
49 })
50 if (verdict?.ok !== false) return result
51 $.ui.log(`lean-scripts/scripts-review: ${verdict.reason}`)
52 return { ...result, context: [...(result.context ?? []), `lean-scripts/scripts-review: ${verdict.reason}`] }
53 })
54}
55hooks/gates.ts 18 lines1// The prompt's GATE FIRST paragraph as code: a call it would pass with ok=true costs no model call.
2
3const SCRIPT_EXT = ['.sh', '.bash', '.zsh', '.py', '.cjs', '.mjs', '.js', '.rb', '.pl']
4
5const extOf = (path: string) => {
6 const base = path.slice(path.lastIndexOf('/') + 1)
7 const dot = base.lastIndexOf('.')
8 return dot > 0 ? base.slice(dot).toLowerCase() : ''
9}
10
11export const hasScriptExt = (path: string) => SCRIPT_EXT.includes(extOf(path))
12
13export const isApplicationSource = (path: string) => /\/(src|app)\//.test(path)
14
15export const isTestFile = (path: string) =>
16 /\/(tests?|__tests__)\//.test(path) ||
17 /\/(test_[^/]*|[^/]*_test\.[^/]+|[^/]*\.(test|spec)\.[^/]+)$/.test(path)
18hooks/prompts.ts 18 lines1// `$ARGUMENTS` is the hook input as JSON.
2
3export const SCRIPT_FILE = `Reviewer for a SCRIPT FILE. $ARGUMENTS
4GATE FIRST, and this decides most calls. Judge ONLY when tool_input.file_path is inside a project checkout AND is a standalone runnable program: extension .sh .bash .zsh .py .cjs .mjs .js .rb .pl, or a file whose content starts with a shebang. Anything else - application source under src/ or app/, a test file, a config, a lockfile, a path under a home-directory dotfile tree, a temp or scratch directory - return ok=true with NO reason and nothing else. Do not judge code quality, style, or correctness. Never judge whether the work should have been done.
5On a Write, judge the whole file. On an Edit, judge only the added lines (new_string): a deletion or a trim is always ok=true, and so is a fix to a script that already earns its place.
6The question, for a whole new file: when this is needed again, could it simply be written again on the spot, correctly? For added lines: is this addition itself the kind of thing someone would just type inline?
7If YES -> ok=false, reason: 'writable on demand - run it inline now instead of committing it'.
8If NO -> ok=true.
9It is NOT writable on demand, so ok=true, when ANY of these hold:
10- something other than a human invokes it: a CI job, a cron entry, a Dockerfile, a compose service, a git hook, another script.
11- it is reached for when its author is absent or under pressure - recovery, restore, on-call, lockout - where composing it fresh is exactly when it gets written wrong.
12- getting it wrong is destructive or irreversible: it deletes, rotates, migrates, or touches production data or DNS, and it encodes the reasoning for WHY an operation is safe.
13- it encodes a non-obvious fact that had to be discovered: a measured threshold, an API's undocumented requirement, a key format, an upstream quirk. Re-deriving it would take real work and would likely come out wrong.
14A ground only counts if the script will plausibly RUN AGAIN. Ask that first. A one-time operation already performed fails no matter how destructive it was or how much was learned doing it - the zone is created, the host is provisioned, the data is migrated. Re-runs bounded by one piece of work fail the same way: a before/after measurement for one change, a check run until one ticket or migration lands, a harness proving one refactor - once that work closes nobody runs it again, so it belongs in a scratch directory or attached to the ticket, not in the checkout. A header, docstring or usage line that names the one ticket, PR or change it serves (before/after #82, until the cutover) is that case: ok=false, however reusable the code looks. The fact worth keeping then belongs in a doc, or in a comment at the thing it explains, not in a runnable file nobody will run. "Touches production" is not a licence on its own: almost every ops script touches something destructive, so that ground decides nothing unless the operation actually recurs.
15ok=false covers: a one-time setup or migration already run; a tool whose re-runs end when one ticket or change is done;a wrapper around a handful of obvious commands; a convenience alias; a thing whose whole body is a documented CLI invocation with the flags spelled out; anything whose value is 'so I do not have to type it again'.
16A long file is not automatically safe and a short one is not automatically doomed - a 123-line reconciler earns its size by proving a delete is safe; a 49-line wrapper around ufw does not.
17Reason: name what makes it writable on demand or bounded to one piece of work, and where the one fact worth keeping should go instead. Under 50 words, no preamble.`
18hooks/shared/paths.ts 14 lines1const TEMP_ROOTS = ['/tmp/', '/private/tmp/', '/var/folders/', '/private/var/folders/']
2
3export const dirOf = (path: string) => {
4 const slash = path.lastIndexOf('/')
5 return slash > 0 ? path.slice(0, slash) : slash === 0 ? '/' : '.'
6}
7
8export const isDotfileOrTemp = (path: string, home: string | undefined, tmpdir: string | undefined) =>
9 (home !== undefined && path.startsWith(`${home}/.`)) ||
10 TEMP_ROOTS.some((root) => path.startsWith(root)) ||
11 (tmpdir !== undefined && tmpdir !== '' && path.startsWith(tmpdir))
12
13export const isTrim = (added: string, removed: string) => added.length < removed.length
14hooks/shared/verdict.ts 27 lines1export const MODEL = 'claude-haiku-4-5-20251001'
2
3export const SYSTEM =
4 'You are a hook reviewer. Reply with one JSON object and nothing else: {"ok": true} or {"ok": false, "reason": "..."}.'
5
6export type Verdict = { ok: true } | { ok: false; reason: string }
7
8export type Review = { name: string; prompt: string; status: string }
9
10export const promptFor = (review: Review, input: object) => review.prompt.replace('$ARGUMENTS', () => JSON.stringify(input))
11
12export const verdictOf = (reply: string): Verdict | undefined => {
13 const json = reply.match(/\{[\s\S]*\}/)
14 if (!json) return undefined
15 let parsed: unknown
16 try {
17 parsed = JSON.parse(json[0])
18 } catch {
19 return undefined
20 }
21 if (typeof parsed !== 'object' || parsed === null || !('ok' in parsed) || typeof parsed.ok !== 'boolean') {
22 return undefined
23 }
24 if (parsed.ok) return { ok: true }
25 return { ok: false, reason: 'reason' in parsed && typeof parsed.reason === 'string' ? parsed.reason : '' }
26}
27