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…

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.
### 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).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. Why each one and each of its rules exists: STORY.md.
| Mod | Purpose |
|---|---|
| git-gates | Git work happens on the user's word |
| git-tidy | Git history stays tidy |
| git-cleanup | Merged work is cleaned up once it shipped |
| issue-first | Feature work starts from an issue, and each commit names its own |
| lean-docs | Docs worth keeping |
| lean-comments | Comments worth keeping |
| lean-scripts | Scripts worth keeping |
| close-countdown | A Herdr pane counts down before it closes |
| traffic-light | The 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.
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, with or without git's own options (git -C dir push); opening a PR/MR; 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); 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 it | 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 |
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.
The rules' text lives in the playbook; the commit-order, follow-up and feature refusals name the part.
| Check | Runs on | Needs | Then |
|---|---|---|---|
| messages | A git commit | Conventional Commits subject, no Co-Authored-By trailer naming Claude or Anthropic (in the message or a --trailer flag) and no "Generated with Claude Code" line | Commit denied |
| message advice | A git commit | No reviewer pre-answers ("no X changed"); then Haiku: a body only when the cause is subtle | Commit runs; the user gets the advice |
| 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, or matches an earlier one's API contract change in another app; 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 |
Integration branches are found as git-gates finds them.
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 branch, local or on origin; removing a worktree keeps its branch and is not held | 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, 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 wait | Call denied while it runs or after it failed; a GitLab pipeline parked at manual or skipped holds nothing back |
| 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 | Removes 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.
| Check | Runs on | Needs | Then |
|---|---|---|---|
| feature issue | A feat commit | An 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 turn | Commit denied; Claude looks for the issue, else drafts one and asks the user before creating it |
| issue number | A git commit | The issue or ticket your messages or the branch name, at the end of the subject | Commit 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.
| 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 | Claude gets the hint; the write goes ahead |
| limit-docs | The end of a turn | New or grown docs, prose outweighing code, doc lines repeating code | A 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.
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 an edit that adds comments | Claude gets the guidance |
| limit-turns | The end of a turn | More than 3 new comment lines per file in the turn's diff | A 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.
| 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 |
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.
A Herdr subtask closing its own pane shows it first, so whoever watches can stop it.
| Part | What it does |
|---|---|
close_pane_later tool | Closes 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 |
| Cancel | A 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).
A band above the prompt says whether you can leave the session.
| Light | When |
|---|---|
| 🟢 safe to leave | The 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 PR | The PR is open, failing checks included, or merged with its pipeline still running |
| 🔴 working | Claude 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.
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: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/ 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.tsx 139 lines1import { 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}
139types/index.d.ts 13 lines1export 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