SLOPSHOPPER

lean-scripts

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

newguardstatusmodelprocess
v1.0.1MITupdated 2026-10-07bahaospanov/skills/lean-scripts
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).

  • deploy-to-prod: Integration branch to production: squash iterative commits, cut waves around migrations and one-time steps, ship wave by wave with a runbook issue.

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
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, 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
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

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

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.

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 5 files
hooks/register.ts 55 lines
1import 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}
55
hooks/gates.ts 18 lines
1// 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)
18
hooks/prompts.ts 18 lines
1// `$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.`
18
hooks/shared/paths.ts 14 lines
1const 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
14
hooks/shared/verdict.ts 27 lines
1export 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