SLOPSHOPPER

close-countdown

A Herdr pane counts down before it closes: the agent calls close_pane_later, a band above the prompt shows the seconds left, and a typed message, Keep open or…

newbandguardcommandtoastprompt
v1.26.0MITupdated 2026-10-24bahaospanov/skills/close-countdown
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · close-countdown
› 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 › /keep-open ⎿ close-countdown: No close was scheduled. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
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, note the shipped issues for you to close, and build a phone-sized release album.

Model-invoked

Model- or user-reachable.

  • playbook: How a change goes from issue to merged branch, in parts: issues, branches, commits, merge requests, cleanup. A project states its facts (base branch, issue number format, deviations) in a ### Playbook block of its AGENTS.md. Its MR description is mattpocock/skills pr (MIT), whose Summary visuals come from Dex Horthy's HumanLayer show-me (MIT).
  • presentation: Phone-sized slides of what changed for a product's users, one changed place per slide: its page URL and desktop before/after shots with the change outlined, rendered as 4:5 PNGs with a caption to post in a chat; release builds its release album with it.
  • prototype-stages: mattpocock/skills prototype (MIT), its UI branch reworked: whole flows, options grouped by stage in a one-click panel.
  • scheme: ASCII schematic of a code change: control flow, data flow, before/after, or layers.

Mods

Early access; the API changes between releases.

One mod per purpose. Why each one and each of its rules exists: STORY.md.

ModPurpose
git-gatesGit work happens on the user's word
git-tidyGit history stays tidy
git-cleanupMerged work is cleaned up once it shipped
issue-firstFeature work starts from an issue, and each commit names its own
lean-docsDocs worth keeping
lean-commentsComments worth keeping
lean-scriptsScripts worth keeping
close-countdownA Herdr pane counts down before it closes
traffic-lightThe band says whether the session's work is done

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 show at most two one-line notices a session; the report reaches Claude with your next message and is acted on when you ask.

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, with or without git's own options (git -C dir push); opening a PR/MR; 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); a push needs one of them other than commit; opening needs pr, mr, pull request, merge request or ship; merge needs "merge", and "merge request" is not it; a protected branch must be named. A negated keyword ("don't push") does not count, and a no to the act itself refuses it, standing grant included, unless the turn also asks for itCall 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

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.

git-tidy

The rules' text lives in the playbook; the commit-order, follow-up and feature refusals name the part.

CheckRuns onNeedsThen
messagesA git commitConventional Commits subject, no Co-Authored-By trailer naming Claude or Anthropic (in the message or a --trailer flag) and no "Generated with Claude Code" lineCommit denied
message adviceA git commitNo reviewer pre-answers ("no X changed"); then Haiku: a body only when the cause is subtleCommit runs; the user gets the advice
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, or matches an earlier one's API contract change in another app; 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

Integration branches are found as git-gates finds them.

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 branch, local or on origin; removing a worktree keeps its branch and is not heldThe 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, the commit a squash merge landed as, 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; a GitLab pipeline parked at manual or skipped holds nothing back
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 failedRemoves the worktree and the branch, local and on origin, fast-forwards a local integration branch behind origin, and prints one line saying what went; the worktree the session works in stays

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) at the end of the subject, or in 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
issue numberA git commitThe issue or ticket your messages or the branch name, at the end of the subjectCommit runs; the user gets the suggestion

A repo with no remote has no issue tracker: feature issue is skipped, and only a ticket (#BLK-23) counts as an issue. 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 subject end with that one instead. 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 fileClaude gets the hint; the write goes ahead
limit-docsThe end of a turnNew or grown docs, prose outweighing code, doc lines repeating codeA one-line notice; say "trim docs" to act on it

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

Media and marketing work is out of scope too: a file its own checkout ignores (a local reels/ or renders/ folder) or one under a folder holding a .lean-skip file.

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 an edit that adds commentsClaude gets the guidance
limit-turnsThe end of a turnMore than 3 new comment lines per file in the turn's diffA one-line notice; say "trim comments" to act on it

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.

Media and marketing work is out of scope too: a file its own checkout ignores (a local reels/ or renders/ folder) or one under a folder holding a .lean-skip file.

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

A script is a shell file, a file in a scripts, bin, tools or ops folder, or one starting with a shebang. A skill's folder is skipped: the skill runs its scripts on every use.

Media and marketing work is out of scope too: a file its own checkout ignores (a local reels/ or renders/ folder) or one under a folder holding a .lean-skip file.

close-countdown

A Herdr subtask closing its own pane shows it first, so whoever watches can stop it.

PartWhat it does
close_pane_later toolCloses this session's Herdr pane, or pane, after seconds (default 60) and returns at once; note is shown beside the countdown
The band⏻ Closing this pane in 42s · <note> [ ✋ Keep open ] [ ❌ Close now ] above the prompt, a draining bar under it
CancelA message typed into the session, ✋ Keep open or /keep-open; ❌ Close now closes the pane at once

It runs herdr pane close, so the session needs herdr on its PATH and a Herdr pane (HERDR_PANE_ID).

traffic-light

A band above the prompt says whether you can leave the session.

LightWhen
🟢 safe to leaveThe PR is merged into dev, develop, main or master and every GitHub Actions run of that push passed; a run a newer push cancelled is judged by the newer one
🟡 waiting on PRThe PR is open, failing checks included, or merged with its pipeline still running
🔴 workingClaude is asking you, a turn is running where the light would be green, the repo holds uncommitted or unpushed work, there is no PR, it closed unmerged or merged elsewhere, the pipeline failed, or work began after the PR merged

The repo is the one Claude last edited a file in. A feature branch is judged by its own PR; on an integration branch, or once the worktree is gone, by the PR a gh pr command last printed. A turn that changes nothing, such as a question, ends green again. A turn that edits a file git does not ignore, or creates a commit, branch, worktree, issue or PR, leaves that merged PR unable to turn the light green: green waits for the new work's PR. The light refreshes after git and gh commands, and every 30 seconds while yellow. /traffic-light prints it; /traffic-light demo shows each color.

It runs git and gh, so the session needs both on its PATH and gh signed in.

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:continuations       # every mod sending follow-up prompts is named where git-gates reads them
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 2 files
hooks/register.tsx 139 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Countdown } from '../types'
5
6const PLUGIN = 'close-countdown'
7const TOOL = 'close_pane_later'
8const BAR = 38
9
10const countdown = atom({ plugin: 'close-countdown', key: 'countdown' } as const, null, {
11  shape: 'countdown-1',
12})
13const now = atom({ plugin: 'close-countdown', key: 'now' } as const, 0)
14
15let tick: Timer | undefined
16
17function stopTicking() {
18  tick?.cancel()
19  tick = undefined
20}
21
22async function cancel($: EngineInterface, why: string): Promise<boolean> {
23  stopTicking()
24  if ((await read($, countdown)) === null) return false
25  await update($, countdown, () => null)
26  $.ui.toast(`Pane close cancelled: ${why}`)
27  return true
28}
29
30// Each tick redraws the band; the one past the deadline closes the pane.
31function startTicking($: EngineInterface) {
32  stopTicking()
33  tick = $.clock.every(1000, () => {
34    void (async () => {
35      const c = await read($, countdown)
36      if (c === null) return stopTicking()
37      const t = await $.clock.now()
38      await update($, now, () => t)
39      if (t >= c.endsAt) await closeNow($)
40    })()
41  })
42}
43
44async function closeNow($: EngineInterface) {
45  stopTicking()
46  const c = await read($, countdown)
47  if (c === null) return
48  await update($, countdown, () => null)
49  await $.process.run(['herdr', 'pane', 'close', c.pane])
50}
51
52export const register: Register = on => {
53  on('session.start', async ($, e, next) => {
54    await $.tool.register({
55      name: TOOL,
56      description:
57        "Closes a Herdr pane after a countdown drawn above this session's prompt, and returns at once. " +
58        'The last step of a Herdr subtask handing back to its parent, in place of a sleep-then-close shell line. ' +
59        'A message typed into this session, its Keep open button or /keep-open cancels it.',
60      inputSchema: {
61        type: 'object',
62        properties: {
63          seconds: { type: 'integer', minimum: 5, description: 'Seconds until the close; default 60.' },
64          note: {
65            type: 'string',
66            description: "A few words shown beside the countdown, e.g. 'report sent to w2:pZ'.",
67          },
68          pane: { type: 'string', description: "The Herdr pane id to close; default this session's own pane." },
69        },
70      },
71      isDeferred: false,
72    })
73    await $.command.register({ name: 'keep-open', description: 'Cancel the scheduled close of this Herdr pane' })
74    if ((await read($, countdown)) !== null) startTicking($)
75
76    return next(e)
77  })
78
79  on('tool.call', { tool: `mcp__${PLUGIN}__${TOOL}` }, async ($, e) => {
80    const args = e as unknown as { seconds?: unknown; note?: unknown; pane?: unknown }
81    const seconds = typeof args.seconds === 'number' && args.seconds >= 5 ? Math.round(args.seconds) : 60
82    const note = typeof args.note === 'string' ? args.note.slice(0, 80) : ''
83    const pane =
84      typeof args.pane === 'string' && args.pane !== ''
85        ? args.pane
86        : (await $.process.run(['printenv', 'HERDR_PANE_ID'])).stdout.trim()
87    if (pane === '') return { deny: 'Not inside a Herdr pane: HERDR_PANE_ID is unset.' }
88
89    const t = await $.clock.now()
90    const c: Countdown = { pane, seconds, note, endsAt: t + seconds * 1000 }
91    await update($, now, () => t)
92    await update($, countdown, () => c)
93    startTicking($)
94
95    return {
96      result: `Pane ${pane} closes in ${seconds}s. A message typed into this session, the Keep open button or /keep-open cancels it.`,
97    }
98  })
99
100  on('prompt.submit', async ($, e, next) => {
101    await cancel($, 'a message was typed')
102
103    return next(e)
104  })
105
106  on('command.run', { command: 'keep-open' }, async $ => {
107    const wasScheduled = await cancel($, '/keep-open')
108
109    return { text: wasScheduled ? 'The pane stays open.' : 'No close was scheduled.' }
110  })
111
112  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
113    const c = await read($, countdown)
114    if (c === null || e.props.hasSurvey) return next(e)
115
116    const left = Math.max(0, Math.ceil((c.endsAt - (await read($, now))) / 1000))
117    const width = Math.max(10, Math.min(BAR, e.props.bodyColumns))
118    const filled = Math.min(width, Math.round((width * left) / c.seconds))
119    const { Box, Button, Text } = $.ui.resolve(e)
120
121    return (
122      <Box flexDirection="column">
123        <Box>
124          <Text color="warning">⏻ Closing this pane in {left}s</Text>
125          {c.note !== '' && <Text dimColor> · {c.note}</Text>}
126          <Text> </Text>
127          <Button key="keep-open" label="✋ Keep open" onPress={() => void cancel($, 'Keep open pressed')} />
128          <Text> </Text>
129          <Button key="close-now" label="❌ Close now" onPress={() => void closeNow($)} />
130        </Box>
131        <Text color="warning">
132          {'█'.repeat(filled)}
133          <Text dimColor>{'░'.repeat(width - filled)}</Text>
134        </Text>
135      </Box>
136    )
137  })
138}
139
types/index.d.ts 13 lines
1export type Countdown = {
2  pane: string
3  seconds: number
4  note: string
5  endsAt: number
6}
7
8declare module 'claude-code' {
9  interface PluginState {
10    'close-countdown': { countdown: Shaped<Countdown | null>; now: number }
11  }
12}
13