Keeps git history tidy: commit messages in Conventional Commits with no Claude credit, then a Haiku review of the body; a push to a branch that already landed…

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.ts 362 lines1import type { EngineInterface, Register } from 'claude-code'
2import { aiAttributionViolations, commitMessageViolations, invokesCommit, messageFrom, preAnswers, runsGitCommit } from './commit-message'
3import {
4 acknowledgesOrder,
5 commitOrderRefused,
6 DELETED_CHARS,
7 MAX_SERIES,
8 MIN_SERIES,
9 ORDER_MODEL,
10 pushSources,
11 trimDiff,
12 type SeriesCommit,
13} from './commit-order'
14import { acknowledgesScope, manyIssues, SCOPE_MODEL, scopeRefused } from './feature-scope'
15import { acknowledgesSplit, FOLLOW_UP_MODEL, followUpsRefused, markedFollowUp } from './follow-ups'
16import { acknowledgesStaleBase, FETCH_TIMEOUT_MS, staleBaseRefused } from './fresh-base'
17import { acknowledgesLanded, landedRefused } from './landed-branch'
18import { COMMIT_MESSAGE, COMMIT_ORDER, FEATURE_SCOPE, FOLLOW_UPS } from './prompts'
19import {
20 branchesOf,
21 commandDir,
22 currentTurn,
23 defaultBranchOf,
24 deletedBranches,
25 FALLBACK_BASES,
26 mrOpeningOf,
27 pushTargets,
28 verbOf,
29 type MrOpening,
30} from './shared/git-commands'
31import { CONTINUATION_ORIGINS, CONTINUATION_PLUGINS } from './shared/continuations'
32import { MODEL, promptFor, SYSTEM, verdictOf, type Review, type Verdict as ReviewVerdict } from './shared/verdict'
33
34const HUMAN_ORIGINS: readonly string[] = ['composer', 'bridge', 'sdk']
35const COMMIT_REVIEW: Review = { name: 'commit message review', prompt: COMMIT_MESSAGE, status: 'judging message' }
36const ORDER_REVIEW: Review = { name: 'every commit works', prompt: COMMIT_ORDER, status: 'judging commit order' }
37const FOLLOW_UP_REVIEW: Review = { name: 'one commit per change', prompt: FOLLOW_UPS, status: 'judging follow-up commits' }
38const SCOPE_REVIEW: Review = { name: 'one feature per MR', prompt: FEATURE_SCOPE, status: 'judging features per MR' }
39const LOOKBACK = 30
40
41type Prompt = { text: string; human: boolean; turnId?: string | undefined }
42
43let prompts: Prompt[] = []
44// Prompts the user typed this session; `prompts` is capped, so it cannot tell a new one apart.
45let typedCount = 0
46// The branch commits last held for the user to choose on, and how many prompts they had typed by then.
47let held: { series: string; reason: string; typed: number } | undefined
48let scopeHeld: { series: string; reason: string; typed: number } | undefined
49// A branch stays as it is once the user chose that; its later pushes are not held again.
50const scopeKept = new Set<string>()
51
52// Transcript rows carry no origin, so the engine's own user-role rows are told apart by their markup.
53const ENGINE_ROW = /<task-notification>|<local-command-(caveat|stdout|stderr)>/
54const REMINDER = /<system-reminder>[\s\S]*?<\/system-reminder>/g
55
56// Tracked prompts are exact; after a reload or resume the transcript stands in, minus the engine's rows.
57const recentPrompts = async ($: EngineInterface, count: number): Promise<Prompt[]> => {
58 if (prompts.length > 0) return prompts.slice(-count)
59 const messages = await $.session.messages()
60 return messages
61 .filter((m) => m.role === 'user' && !m.toolResults?.length && !ENGINE_ROW.test(m.text))
62 .map((m) => ({ text: m.text.replace(REMINDER, '').trim(), human: true }))
63 .filter((p) => p.text !== '')
64 .slice(-count)
65}
66
67// What the user typed in the current turn: its opening prompt plus anything typed while it ran.
68const typedThisTurn = async ($: EngineInterface) => {
69 const turn = currentTurn(await recentPrompts($, LOOKBACK))
70 return turn.length === 0 ? undefined : turn.filter((p) => p.human).map((p) => p.text)
71}
72
73const git = async ($: EngineInterface, args: string[], cwd?: string) => {
74 const run = await $.process.run(['git', ...args], cwd === undefined ? undefined : { cwd })
75 return run.exitCode === 0 ? run.stdout.trim() : undefined
76}
77
78// The loader only admits literal $.env.get names; HOME is all a `cd ~/…` needs.
79const repoOf = async ($: EngineInterface, command: string) => {
80 const dir = commandDir(command)
81 return dir?.startsWith('~') ? `${await $.env.get('HOME')}${dir.slice(1)}` : dir
82}
83
84const protectedBranches = async ($: EngineInterface, cwd?: string) => {
85 const top = await git($, ['rev-parse', '--show-toplevel'], cwd)
86 if (!top) return []
87 const policy = await $.fs.read(`${top}/.claude/push-policy.json`).catch(() => undefined)
88 return policy === undefined ? [] : branchesOf(policy)
89}
90
91const currentBranch = async ($: EngineInterface, cwd?: string) => {
92 const branch = await git($, ['rev-parse', '--abbrev-ref', 'HEAD'], cwd)
93 return branch && branch !== 'HEAD' ? branch : undefined
94}
95
96// `git()` swallows a non-zero exit, and that exit is the answer here.
97const isAncestor = async ($: EngineInterface, commit: string, of: string, cwd?: string) => {
98 const run = await $.process.run(['git', 'merge-base', '--is-ancestor', commit, of], cwd === undefined ? undefined : { cwd })
99 return run.exitCode === 0
100}
101
102const landedTarget = async ($: EngineInterface, command: string) => {
103 const cwd = await repoOf($, command)
104 const integration = await protectedBranches($, cwd)
105 if (integration.length === 0) return undefined
106 const targets = pushTargets(command, await currentBranch($, cwd))
107 if (targets === undefined) return undefined
108 for (const branch of targets) {
109 if (branch === '*' || integration.includes(branch)) continue
110 const pushed = await git($, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${branch}`], cwd)
111 if (pushed === undefined) continue
112 for (const base of integration) {
113 if (await isAncestor($, pushed, `refs/remotes/origin/${base}`, cwd)) return { branch, base }
114 }
115 }
116 return undefined
117}
118
119// Origin's copy of the source is what the MR shows; a branch not pushed yet falls back to the local one.
120const staleBase = async ($: EngineInterface, command: string, opening: MrOpening) => {
121 const cwd = await repoOf($, command)
122 const target = opening.target ?? defaultBranchOf(await git($, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], cwd))
123 const source = opening.source ?? (await currentBranch($, cwd))
124 if (target === undefined || source === undefined) return undefined
125 await $.process
126 .run(['git', 'fetch', '--quiet', 'origin', target], { ...(cwd === undefined ? {} : { cwd }), timeoutMs: FETCH_TIMEOUT_MS })
127 .catch(() => undefined)
128 const base = await git($, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${target}`], cwd)
129 const head =
130 (await git($, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${source}`], cwd)) ??
131 (await git($, ['rev-parse', '--verify', '--quiet', `refs/heads/${source}`], cwd))
132 if (base === undefined || head === undefined || (await isAncestor($, base, head, cwd))) return undefined
133 return { source, target }
134}
135
136// A repo without a push policy still merges somewhere; only the follow-up and feature reviews rely on this guess.
137const integrationBases = async ($: EngineInterface, cwd?: string) => {
138 const policy = await protectedBranches($, cwd)
139 if (policy.length > 0) return policy
140 const head = defaultBranchOf(await git($, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], cwd))
141 const bases: string[] = []
142 for (const base of new Set([...(head ? [head] : []), ...FALLBACK_BASES])) {
143 if ((await git($, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${base}`], cwd)) !== undefined) bases.push(base)
144 }
145 return bases
146}
147
148// Commits the push sends that no remote has yet, oldest first; undefined when git cannot say.
149const unpushedCommits = async ($: EngineInterface, sources: string[], cwd?: string) => {
150 const shas: string[] = []
151 for (const source of sources) {
152 const listed = await git($, ['rev-list', '--reverse', source, '--not', '--remotes'], cwd)
153 if (listed === undefined) return undefined
154 for (const sha of listed.split('\n')) if (sha !== '' && !shas.includes(sha)) shas.push(sha)
155 }
156 return shas
157}
158
159// Every commit the branch's MR will carry, pushed or not, oldest first; undefined when no base is on origin.
160const branchCommits = async ($: EngineInterface, sources: string[], bases: string[], cwd?: string) => {
161 const refs: string[] = []
162 for (const base of bases) {
163 const ref = `refs/remotes/origin/${base}`
164 if ((await git($, ['rev-parse', '--verify', '--quiet', ref], cwd)) !== undefined) refs.push(ref)
165 }
166 if (refs.length === 0) return undefined
167 const shas: string[] = []
168 for (const source of sources) {
169 const listed = await git($, ['rev-list', '--reverse', '--no-merges', source, '--not', ...refs], cwd)
170 if (listed === undefined) return undefined
171 for (const sha of listed.split('\n')) if (sha !== '' && !shas.includes(sha)) shas.push(sha)
172 }
173 return shas
174}
175
176// The whole branch, not just this push: pushing after every commit sends one at a time.
177const pushedBranch = async ($: EngineInterface, command: string) => {
178 if (verbOf(command) !== 'push' || deletedBranches(command) !== undefined) return undefined
179 const cwd = await repoOf($, command)
180 const current = await currentBranch($, cwd)
181 const bases = await integrationBases($, cwd)
182 const targets = pushTargets(command, current)
183 // A push straight to an integration branch lands a series someone laid out, release commit included.
184 if (targets === undefined || targets.some((t) => t === '*' || bases.includes(t))) return undefined
185 const sources = pushSources(command, current)
186 if (!sources?.length || !(await unpushedCommits($, sources, cwd))?.length) return undefined
187 const shas = await branchCommits($, sources, bases, cwd)
188 return shas === undefined ? undefined : { shas, cwd, branch: sources.join(' ') }
189}
190
191const describeCommit = async ($: EngineInterface, sha: string, cwd?: string): Promise<SeriesCommit | undefined> => {
192 const [subject, files, diff, deleted] = await Promise.all([
193 git($, ['show', '-s', '--format=%s', sha], cwd),
194 git($, ['show', '--format=', '--name-status', sha], cwd),
195 git($, ['show', '--format=', '-U0', '--no-color', '--diff-filter=AM', sha], cwd),
196 git($, ['show', '--format=', '-U0', '--no-color', '--diff-filter=D', sha], cwd),
197 ])
198 if (subject === undefined || files === undefined) return undefined
199 return {
200 subject,
201 files: files.split('\n').filter(Boolean),
202 diff: trimDiff(diff ?? ''),
203 deleted: trimDiff(deleted ?? '', DELETED_CHARS),
204 }
205}
206
207// The loader follows $ only into functions of this file, so the model call lives here, not in shared/verdict.ts.
208const judge = async (
209 $: EngineInterface,
210 review: Review,
211 input: object,
212 model = MODEL,
213): Promise<ReviewVerdict | undefined> => {
214 $.ui.status(review.status)
215 try {
216 const result = await $.model.complete({ model, system: SYSTEM, prompt: promptFor(review, input) })
217 const reply = result.isAnswered ? result.text : `(${result.reason})`
218 const verdict = verdictOf(reply)
219 if (verdict === undefined) $.ui.log(`git-tidy (${review.name}): no verdict: ${reply.slice(0, 120)}`)
220 return verdict
221 } finally {
222 $.ui.status(undefined)
223 }
224}
225
226
227const relayed = (advice: string) =>
228 `${advice}\nThe commit ran. Tell the user this as a recommendation; amend it only if they ask. The playbook skill's COMMITS.md has the rule.`
229
230export const register: Register = (on) => {
231 on('prompt.submit', ($, e, next) => {
232 if (e.origin.kind === 'plugin' && CONTINUATION_PLUGINS.includes(e.origin.name)) return next(e)
233 if (CONTINUATION_ORIGINS.includes(e.origin.kind)) return next(e)
234 prompts = [...prompts, { text: e.text, human: HUMAN_ORIGINS.includes(e.origin.kind), turnId: e.turnId }].slice(-LOOKBACK)
235 if (HUMAN_ORIGINS.includes(e.origin.kind)) typedCount++
236 return next(e)
237 })
238
239 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
240 if (verbOf(e.command) !== 'push' || deletedBranches(e.command) !== undefined) return next(e)
241 const landed = await landedTarget($, e.command)
242 if (landed === undefined) return next(e)
243 if ((await typedThisTurn($))?.some(acknowledgesLanded)) return next(e)
244 return { deny: landedRefused(e.command, landed.branch, landed.base) }
245 })
246
247 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
248 const opening = mrOpeningOf(e.command)
249 if (opening === undefined) return next(e)
250 if ((await typedThisTurn($))?.some(acknowledgesStaleBase)) return next(e)
251 const stale = await staleBase($, e.command, opening)
252 return stale === undefined ? next(e) : { deny: staleBaseRefused(e.command, stale.source, stale.target) }
253 })
254
255 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
256 if (verbOf(e.command) !== 'push' || deletedBranches(e.command) !== undefined) return next(e)
257 const cwd = await repoOf($, e.command)
258 const sources = pushSources(e.command, await currentBranch($, cwd))
259 if (!sources?.length) return next(e)
260 const shas = await unpushedCommits($, sources, cwd)
261 if (shas === undefined || shas.length < MIN_SERIES) return next(e)
262 if (shas.length > MAX_SERIES) {
263 $.ui.log(`git-tidy (${ORDER_REVIEW.name}): ${shas.length} commits, over ${MAX_SERIES}, not reviewed`)
264 return next(e)
265 }
266 if ((await typedThisTurn($))?.some(acknowledgesOrder)) return next(e)
267 const commits = await Promise.all(shas.map((sha) => describeCommit($, sha, cwd)))
268 if (commits.includes(undefined)) return next(e)
269 const review = await judge($, ORDER_REVIEW, { commits }, ORDER_MODEL)
270 if (review?.ok !== false) return next(e)
271 $.ui.log(`git-tidy (${ORDER_REVIEW.name}): ${review.reason}`)
272 return { deny: commitOrderRefused(e.command, review.reason) }
273 })
274
275 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
276 const pushed = await pushedBranch($, e.command)
277 if (pushed === undefined || pushed.shas.length < MIN_SERIES) return next(e)
278 const { shas, cwd } = pushed
279 if (shas.length > MAX_SERIES) {
280 $.ui.log(`git-tidy (${FOLLOW_UP_REVIEW.name}): ${shas.length} commits, over ${MAX_SERIES}, not reviewed`)
281 return next(e)
282 }
283 if ((await typedThisTurn($))?.some(acknowledgesSplit)) return next(e)
284 // The choice is the user's: once they have typed anything after the hold, these commits go as they are.
285 const series = shas.join(' ')
286 if (held?.series === series) {
287 return typedCount > held.typed ? next(e) : { deny: followUpsRefused(e.command, held.reason) }
288 }
289 const commits = await Promise.all(shas.map((sha) => describeCommit($, sha, cwd)))
290 if (commits.includes(undefined)) return next(e)
291 let reason = markedFollowUp(commits.flatMap((c) => (c ? [c.subject] : [])))
292 if (reason === undefined) {
293 const review = await judge($, FOLLOW_UP_REVIEW, { commits }, FOLLOW_UP_MODEL)
294 if (review?.ok === false) reason = review.reason
295 }
296 if (reason === undefined) return next(e)
297 held = { series, reason, typed: typedCount }
298 $.ui.log(`git-tidy (${FOLLOW_UP_REVIEW.name}): ${reason}`)
299 return { deny: followUpsRefused(e.command, reason) }
300 })
301
302 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
303 const pushed = await pushedBranch($, e.command)
304 if (pushed === undefined || pushed.shas.length === 0 || scopeKept.has(pushed.branch)) return next(e)
305 const { shas, cwd, branch } = pushed
306 if (shas.length > MAX_SERIES) {
307 $.ui.log(`git-tidy (${SCOPE_REVIEW.name}): ${shas.length} commits, over ${MAX_SERIES}, not reviewed`)
308 return next(e)
309 }
310 const series = shas.join(' ')
311 const answered = scopeHeld?.series === series && typedCount > scopeHeld.typed
312 if (answered || (await typedThisTurn($))?.some(acknowledgesScope)) {
313 scopeKept.add(branch)
314 return next(e)
315 }
316 if (scopeHeld?.series === series) return { deny: scopeRefused(e.command, scopeHeld.reason) }
317 const messages = await Promise.all(shas.map((sha) => git($, ['show', '-s', '--format=%B', sha], cwd)))
318 let reason = manyIssues(messages.flatMap((m) => (m === undefined ? [] : [m])))
319 if (reason === undefined) {
320 const commits = await Promise.all(shas.map((sha) => describeCommit($, sha, cwd)))
321 if (commits.includes(undefined)) return next(e)
322 const review = await judge($, SCOPE_REVIEW, { commits }, SCOPE_MODEL)
323 if (review?.ok === false) reason = review.reason
324 }
325 if (reason === undefined) return next(e)
326 scopeHeld = { series, reason, typed: typedCount }
327 $.ui.log(`git-tidy (${SCOPE_REVIEW.name}): ${reason}`)
328 return { deny: scopeRefused(e.command, reason) }
329 })
330
331 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
332 if (!invokesCommit(e.command) && !runsGitCommit(e.command)) return next(e)
333 const source = messageFrom(e.command)
334 const text =
335 source === undefined ? undefined : 'text' in source ? source.text : await $.fs.read(source.file).catch(() => undefined)
336 const found = [...(text ? commitMessageViolations(text) : []), ...aiAttributionViolations(e.command, text)]
337 if (found.length > 0) return { deny: `git-tidy (commit message): ${found.join('; ')}` }
338 const advice = text === undefined ? undefined : preAnswers(text)
339 if (advice === undefined) return next(e)
340 const result = await next(e)
341 if (result.deny !== undefined) return result
342 $.ui.log(`git-tidy (commit message): ${advice}`)
343 return { ...result, context: [...(result.context ?? []), relayed(`git-tidy (commit message): ${advice}`)] }
344 })
345
346 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
347 if (!runsGitCommit(e.command)) return next(e)
348 const review = await judge($, COMMIT_REVIEW, {
349 hook_event_name: 'PreToolUse',
350 tool_name: 'Bash',
351 tool_input: { command: e.command, description: e.description },
352 cwd: await $.session.cwd(),
353 })
354 if (review?.ok !== false) return next(e)
355 // A model judgment advises: someone new to the rules cannot argue with a model's block.
356 const result = await next(e)
357 if (result.deny !== undefined) return result
358 $.ui.log(`git-tidy (${COMMIT_REVIEW.name}): ${review.reason}`)
359 return { ...result, context: [...(result.context ?? []), relayed(`git-tidy (${COMMIT_REVIEW.name}): ${review.reason}`)] }
360 })
361}
362hooks/commit-message.ts 60 lines1const INVOKED = /(?:^|[;&|\n])\s*(?:cd\s+\S+\s*&&\s*)*git\s+c(?:ommit)\b/
2
3const CONVENTIONAL = /^(feat|fix|chore|docs|refactor|test|perf|build|ci|style|revert)(\([a-z0-9._/-]+(,[a-z0-9._/-]+)*\))?!?: .+/
4
5const PREEMPT =
6 /\b(no|not?)\s+(\w+\s+){0,2}(change[sd]?|touched|affected|impact)\b|\bnothing (else )?(changed|touched|moved)\b|\b(also|additionally|for completeness|worth noting)\b.*\bunchanged\b/i
7
8// `--trailer` takes `key=value` as well as `key: value`; the noreply@anthropic.com address alone names it too.
9const AI_COAUTHOR = /co-authored-by\s*[:=][^\n]*\b(claude|anthropic)\b/i
10// Claude Code's own footer: `🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
11const AI_GENERATED = /\bgenerated\s+(with|by)\s+\[?claude\b/i
12
13export type MessageSource = { text: string } | { file: string } | undefined
14
15export const invokesCommit = (command: string) => INVOKED.test(command)
16
17// Wider than invokesCommit (`git -C dir commit` too): it only decides whether Haiku is asked, and Haiku gates again.
18export const runsGitCommit = (command: string) =>
19 /(?:^|[;&|\n(])\s*(?:cd\s+\S+\s*&&\s*)*git\s+(?:-C\s+\S+\s+)?commit\b/.test(command)
20
21export const messageFrom = (command: string): MessageSource => {
22 if (command.includes('--no-edit')) return undefined
23 const heredoc = command.match(/-F\s*-\s*<<'?(\w+)'?\n([\s\S]*?)\n\1/)
24 if (heredoc?.[2] !== undefined) return { text: heredoc[2] }
25 const dashM = [...command.matchAll(/-m\s+(['"])([\s\S]*?)\1/g)].map((m) => m[2] ?? '')
26 if (dashM.length > 0) return { text: dashM.join('\n\n') }
27 const file = command.match(/-F\s+(\S+)/)?.[1]
28 return file === undefined ? undefined : { file }
29}
30
31// The command is read too: a `--trailer` flag never shows up in the message text.
32export const aiAttributionViolations = (command: string, message: string | undefined) => {
33 const says = (pattern: RegExp) => pattern.test(command) || (message !== undefined && pattern.test(message))
34 return [
35 ...(says(AI_COAUTHOR) ? ['a Co-Authored-By trailer credits Claude; commit without it'] : []),
36 ...(says(AI_GENERATED) ? ['a "Generated with Claude" line credits Claude; commit without it'] : []),
37 ]
38}
39
40const quoted = (text: string) => (text.includes("'") && !text.includes('"') ? `"${text}"` : `'${text.replace(/'/g, "\\'")}'`)
41
42export const commitMessageViolations = (text: string): string[] => {
43 const lines = text
44 .trim()
45 .split(/\r?\n/)
46 .filter((line) => line.trim() !== '')
47 const [subject, ...body] = lines
48 if (subject === undefined) return []
49 const found: string[] = []
50 if (!CONVENTIONAL.test(subject)) found.push(`first line is not Conventional Commits: ${quoted(subject.slice(0, 70))}`)
51 return found
52}
53
54// A pattern, and real causes match it ("X was not touched by the migration"), so it advises and never denies.
55export const preAnswers = (text: string): string | undefined => {
56 const body = text.trim().split(/\r?\n/).slice(1).filter((line) => line.trim() !== '')
57 const hits = body.filter((line) => PREEMPT.test(line)).map((line) => line.trim().slice(0, 90))
58 return hits.length === 0 ? undefined : `pre-answers a reviewer instead of saying why:\n ${hits.slice(0, 3).join('\n ')}`
59}
60hooks/commit-order.ts 63 lines1// Observed 2026-10-01 in a team project: the API dropped its endpoints one commit before the
2// admin stopped calling them, so the commit between the two had a broken admin.
3
4// On that real series Haiku raised a false alarm in 2 of 6 runs of the right order; Sonnet in 0 of 6.
5export const ORDER_MODEL = 'claude-sonnet-5-5'
6export const MIN_SERIES = 2
7export const MAX_SERIES = 15
8export const DIFF_CHARS = 6000
9export const DELETED_CHARS = 3000
10
11export type SeriesCommit = { subject: string; files: string[]; diff: string; deleted: string }
12
13const SEPARATORS = ['&&', '||', ';', '|']
14const VALUED = ['--repo', '--push-option', '--receive-pack', '--exec', '-o']
15const NO_SERIES = ['--all', '--mirror', '--tags', '--delete', '-d']
16
17export const pushSources = (command: string, current: string | undefined): string[] | undefined => {
18 const tokens = command.replace(/\n/g, ' ; ').split(/\s+/).filter(Boolean)
19 const sources: string[] = []
20 let found = false
21 for (let i = 0; i < tokens.length; i++) {
22 if (tokens[i] !== 'git') continue
23 let j = i + 1
24 while (tokens[j] === '-C') j += 2
25 if (tokens[j] !== 'push') continue
26 found = true
27 let remoteSeen = false
28 const refs: string[] = []
29 for (i = j + 1; i < tokens.length; i++) {
30 const token = tokens[i] ?? ''
31 if (SEPARATORS.includes(token)) break
32 if (NO_SERIES.includes(token)) return undefined
33 if (VALUED.includes(token)) i++
34 else if (token.startsWith('-')) continue
35 else if (!remoteSeen) remoteSeen = true
36 else refs.push(token)
37 }
38 if (refs.length === 0) sources.push(current ?? 'HEAD')
39 for (const ref of refs) {
40 const source = ref.replace(/^\+/, '').split(':')[0] ?? ''
41 if (source !== '') sources.push(source)
42 }
43 }
44 return found ? sources : undefined
45}
46
47export const trimDiff = (diff: string, max = DIFF_CHARS) =>
48 diff.length > max ? `${diff.slice(0, max)}\n[... ${diff.length - max} more characters]` : diff
49
50const ACKNOWLEDGES = /\border\b[^.!?\n]*\b(fine|ok|okay|right|correct|intended)\b|\bkeep (the )?order\b/iu
51
52export const acknowledgesOrder = (text: string) => ACKNOWLEDGES.test(text)
53
54export const commitOrderRefused = (command: string, reason: string) =>
55 `git-tidy (every commit works): blocking '${command}' — ${reason}
56
57Every commit must leave the whole project working, so a checkout, a bisect or a
58revert never lands on a broken state. An API contract change and the matching
59change in another app go in one commit; otherwise reorder - for a removal, the
60consumer first and the provider last - and run the checks at each commit. If the
61order is right, say so, e.g. "the order is fine", and this check steps aside.
62The playbook skill's COMMITS.md has the rule.`
63hooks/feature-scope.ts 30 lines1// Asked for 2026-10-15 in a team project: a teammate's merge requests carried several features at once,
2// often in one commit ("asset page details, specs list and collections, model physical size, …").
3
4import { refsInText } from './shared/issue-refs'
5
6// On 9 real commits from that project, 3 runs each: the 4 bundles were held 12 of 12, the 5 single features
7// passed 14 of 15. Before the prompt said a shared page joins nothing, the bundles passed half the time.
8export const SCOPE_MODEL = 'claude-sonnet-5-5'
9
10const ACKNOWLEDGES = /\b(one|same|single) (mr|pr|merge request|pull request)\b|\bkeep (it |them |these |both |all )?together\b/iu
11
12export const acknowledgesScope = (text: string) => ACKNOWLEDGES.test(text)
13
14export const manyIssues = (messages: string[]) => {
15 const refs = [...new Set(messages.flatMap((message) => refsInText(message, true)))]
16 return refs.length > 1 ? `the branch's commits name ${refs.length} issues: ${refs.join(', ')}` : undefined
17}
18
19export const scopeRefused = (command: string, reason: string) =>
20 `git-tidy (one feature per MR): holding '${command}' for the user — ${reason}
21
22A merge request that carries one feature is reviewed, tested and reverted on its
23own, and closes its one issue. The choice is the user's: leave the branch as it
24is, show the user the features named above with this reason, recommend a branch
25and MR per feature, each from its own issue, and ask.
26- Split: per feature, a branch off the base taking its commits (\`git cherry-pick\`),
27 or its files when one commit carries several (\`git checkout <commit> -- <paths>\`).
28- Keep: push again as is. Once the user has answered, this branch goes through.
29The playbook skill's BRANCHES.md has the rule.`
30hooks/follow-ups.ts 32 lines1// Asked for 2026-10-15 in a team project: a teammate's branches grew a commit per fix and per
2// increment of the one feature they built, and nobody sat beside them to fold those in.
3
4export const FOLLOW_UP_MODEL = 'claude-sonnet-5-5'
5
6// git's own markers for a commit made to fold into an earlier one.
7const MARKED = /^(fixup|squash|amend)! (.+)$/
8
9export const markedFollowUp = (subjects: string[]) => {
10 for (const subject of subjects) {
11 const target = MARKED.exec(subject)?.[2]
12 if (target !== undefined) return `'${subject}' is marked to fold into '${target}'`
13 }
14 return undefined
15}
16
17const ACKNOWLEDGES = /\b(keep|leave) (the |these |both |all |separate )?commits\b|\bcommits are (fine|ok|okay|right|separate|intended)\b/iu
18
19export const acknowledgesSplit = (text: string) => ACKNOWLEDGES.test(text)
20
21export const followUpsRefused = (command: string, reason: string) =>
22 `git-tidy (one commit per change): holding '${command}' for the user — ${reason}
23
24A commit that fixes, reworks or extends an earlier commit of this branch usually
25belongs in that commit, so the merge request reads as one commit per change. The
26choice is the user's: leave the commits as they are, show the user the commits
27named above with this reason, recommend folding them, and ask.
28- Fold: \`git reset --soft\` when they are the last commits, otherwise a rebase
29 with GIT_SEQUENCE_EDITOR; push with --force-with-lease if the branch is on origin.
30- Keep: push again as is. Once the user has answered, these commits go through.
31The playbook skill's COMMITS.md has the rule.`
32hooks/fresh-base.ts 15 lines1export const FETCH_TIMEOUT_MS = 20_000
2
3const ACKNOWLEDGES = /\b(no|skip|without|don'?t)\s+rebas(e|ing)\b|\b(stale|old|behind)\b[^.!?\n]*\b(fine|ok|okay|intended)\b/iu
4
5export const acknowledgesStaleBase = (text: string) => ACKNOWLEDGES.test(text)
6
7export const staleBaseRefused = (command: string, source: string, target: string) =>
8 `git-tidy (fresh base): blocking '${command}' — '${source}' does not contain the latest '${target}' (origin/${target}, just fetched).
9
10The checks ran against an old '${target}', so whatever the newer commits there
11break or conflict with stays hidden until the merge. Rebase onto it
12(\`git fetch origin ${target} && git rebase origin/${target}\`), run the checks
13again, push with --force-with-lease, then open the merge request. If the old
14base is intended, say so, e.g. "no rebase", and this check steps aside.`
15hooks/landed-branch.ts 18 lines1// Observed 2026-09-21: a follow-up commit pushed minutes after its MR merged
2// recreated the deleted branch and sat there, outside any MR, looking shipped.
3
4const ACKNOWLEDGES = /(new|follow-?up|second|another)\s+(mr|merge request|pr|pull request|branch)|reopen/iu
5
6export const acknowledgesLanded = (text: string) => ACKNOWLEDGES.test(text)
7
8export const landedRefused = (command: string, branch: string, base: string) =>
9 `git-tidy (landed branch): blocking '${command}' — '${branch}' has already landed in '${base}'.
10
11Its merge request is closed, so this push does not extend it: it recreates
12the branch, and the commit sits outside any MR until someone opens a new one.
13The work looks shipped and is not.
14
15Either branch off '${base}' for the follow-up, or push and open a NEW merge
16request for it - say which, e.g. "new MR for the follow-up", and this check
17steps aside.`
18hooks/prompts.ts 39 lines1// `$ARGUMENTS` is the hook input as JSON.
2
3export const COMMIT_MESSAGE = `Reviewer for a commit message. $ARGUMENTS
4GATE FIRST, and this decides most calls. Look at tool_input.command. Unless it runs a version-control commit as an actual command - at the very start of the command, or right after ; && || | - you MUST return ok=true with NO reason and nothing else. A script that merely mentions or generates such text, a test harness, an echo, a python string, a heredoc written to a file: ok=true. Do not explain that it is not one; just pass it.
5
6BEFORE ANY OF THAT, the test that usually empties the body: could you simply TELL the person you are working with, right now, instead of recording it? A fact needed once - to run a cutover, to review this merge, to answer a question being asked today - belongs in the conversation or the merge-request description, both of which are read once and archived. A commit body is permanent. It earns text only when the CAUSE is subtle enough that a future reader hitting this code would misdiagnose it.
7If the cause is plainly stated by the subject line - 'X had no password', 'Y was never called', 'Z was off by one' - the correct body is EMPTY. ok=false on any body that exists only because the author had things to say.
8ok=false if: a block restates the diff or names the files touched; a sentence exists only to set up the next one; the message narrates the process of getting there; a block's content does not match its label.
9A one-line body with no labels is fine for a trivial change - do not demand blocks that do not exist.
10Issue or ticket numbers such as #87 or #BLK-23 are not body text: never object to them.
11Reason: name the offending block and what is wrong. Under 50 words, no preamble.`
12
13export const COMMIT_ORDER = `Reviewer for the order of commits about to be pushed, oldest first. $ARGUMENTS
14Judge one thing: a checkout of each commit in the series must build, pass its tests and run on its own.
15Work it through:
161. For each commit except the last, list what it REMOVES or RENAMES: deleted files, functions, exports, routes, endpoints, tables, columns, config keys, dependencies.
172. Look for a LATER commit that deletes or rewrites code that used one of those things - a caller, an import, a client, a test, a config or CI step naming it. If there is one, the earlier commit leaves that code broken until the later one: ok=false.
183. Look for something a commit uses that a LATER commit in the series ADDS - a "+" line in a later diff that defines it. If there is one: ok=false.
19Something no commit in the series adds ALREADY EXISTS in the project. That is never a problem: do not flag a route, name or file because you cannot see its definition.
20Otherwise ok=true. Never judge style, size, messages or whether the change is good.
21Reply with the JSON object only. Reason: the commit by its subject, what stays broken until which later commit, and the fix - squash when one is an app's API contract change and the other the matching change in another app, otherwise reorder (the consumer first, the provider last). Under 60 words.`
22
23export const FOLLOW_UPS = `Reviewer for the commits of one branch, oldest first, as its merge request will carry them. $ARGUMENTS
24Judge one thing: is a later commit a follow-up to an earlier one, so the two are one change and belong in one commit?
25A follow-up spends most of its diff on lines an earlier commit of this series added ("+" lines in that earlier diff), for the same purpose: fixing them (a bug, typo, lint, type or test failure), restyling, renaming or reworking them, the next increment of what they built, review feedback, a partial revert.
26A later commit in another app that matches an earlier commit's API contract change - the frontend using a changed endpoint, field or type - is a follow-up too: a contract change and its matching change are one commit.
27A change of its own spends most of its diff elsewhere: on code that existed before the series, even code an earlier commit also touched; on another step of the issue that stands alone - a backend change with no contract change, a migration and its use; or on a release, version bump or regenerated file. A shared issue number or file is not enough.
28Diffs may be cut short and say so; judge by what is shown and the file lists.
29Never judge order, messages, size or whether the change is good.
30Reply with one JSON object and nothing else. Write "work" first: for each later commit, the earlier commit it overlaps most and roughly how much of its diff works on that commit's "+" lines. Then "ok": false if any later commit is a follow-up, else true. Then, when false, "reason": the follow-up by its subject and the earlier commit it belongs in, by its subject, under 50 words.`
31
32export const FEATURE_SCOPE = `Reviewer for the commits of one branch, oldest first, as its merge request will carry them. $ARGUMENTS
33Judge one thing: does the merge request deliver more than one feature or fix? A piece is its own feature when it could ship alone: a reviewer could approve it, a tester check it and a revert undo it without the others.
34One feature takes only what it needs to work: the backend change and the frontend using it, a migration and its use, the matching change in another app, its tests, styles and docs, a refactor that makes room for it.
35A piece that could ship alone counts as its own feature wherever it sits, the same page, component or commit included: a page's new specs section and a ranking change behind its similar-items block are two; so are a settings form and a restyle of the table beside it. A subject listing several capabilities ("x, y and z") usually carries several.
36Diffs may be cut short and say so; judge by subjects, file lists and what is shown.
37Never judge order, messages, size or whether the change is good.
38Reply with one JSON object and nothing else. Write "work" first: the features you find, each with the commits and files carrying it. Then "ok": false if there are two or more, else true. Then, when false, "reason": the features by short name, under 50 words.`
39hooks/shared/git-commands.ts 200 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
4// git's own options before the subcommand (`git -C dir push`, `git -c k=v commit`) must not hide it.
5const VALUED_GLOBALS = ['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path', '--config-env']
6const GLOBALS = String.raw`(?:\s+(?:-[Cc]\s+\S+|--(?:git-dir|work-tree|namespace|exec-path|config-env)(?:=\S+|\s+\S+)?|--?[a-z][a-z-]*))*`
7const PUSH = new RegExp(String.raw`(^|[\s&;|(])git${GLOBALS}\s+push(\s|$)`, 'm')
8const COMMIT = new RegExp(String.raw`(^|[\s&;|(])git${GLOBALS}\s+commit(\s|$)`, 'm')
9
10// With no push policy a repo's integration branches are guessed: the remote's default branch, then these.
11export const FALLBACK_BASES: readonly string[] = ['dev', 'develop', 'main', 'master']
12
13// A quoted string becomes one placeholder word: a message quoting `git push` is not a push, and `-C "a b"` keeps its value.
14const unquoted = (command: string) => command.replace(/'[^']*'/g, 'Q').replace(/"[^"]*"/g, 'Q')
15
16export const verbOf = (command: string): Verb | undefined => {
17 if (MERGE.test(command)) return 'merge'
18 const plain = unquoted(command)
19 if (PUSH.test(plain)) return 'push'
20 if (COMMIT.test(plain)) return 'commit'
21 return undefined
22}
23
24const tokensOf = (command: string) =>
25 [...command.replace(/\n/g, ' ; ').matchAll(/(?:"[^"]*"|'[^']*'|[^\s"'])+/g)].map((m) => m[0])
26
27const pushAt = (tokens: string[], at: number): number | undefined => {
28 if (tokens[at] !== 'git') return undefined
29 let i = at + 1
30 while ((tokens[i] ?? '').startsWith('-')) i += VALUED_GLOBALS.includes(tokens[i] ?? '') ? 2 : 1
31 return tokens[i] === 'push' ? i : undefined
32}
33
34// A prompt typed over a running turn joins the one that opened that turn, so a quick follow-up cannot withdraw its word.
35export const currentTurn = <P extends { turnId?: string | undefined }>(prompts: P[]): P[] => {
36 const last = prompts.at(-1)
37 if (last === undefined) return []
38 if (last.turnId === undefined) return [last]
39 let start = prompts.length - 1
40 while (start > 0 && prompts[start - 1]?.turnId === last.turnId) start--
41 return prompts.slice(Math.max(0, start - 1))
42}
43
44export const branchesOf = (policy: string): string[] => {
45 try {
46 const parsed: unknown = JSON.parse(policy)
47 const branches = typeof parsed === 'object' && parsed !== null && 'protected_branches' in parsed ? parsed.protected_branches : []
48 return Array.isArray(branches) ? branches.filter((b): b is string => typeof b === 'string' && b !== '') : []
49 } catch {
50 return []
51 }
52}
53
54export const defaultBranchOf = (symref: string | undefined) => symref?.trim().replace(/^refs\/remotes\/origin\//, '') || undefined
55
56// Every branch the command's `git push`es write to; `*` for --all/--mirror, undefined when one cannot be known.
57export const pushTargets = (command: string, current: string | undefined): string[] | undefined => {
58 const tokens = tokensOf(command)
59 const targets: string[] = []
60 let found = false
61 let i = 0
62 while (i < tokens.length) {
63 const push = pushAt(tokens, i)
64 if (push === undefined) {
65 i++
66 continue
67 }
68 found = true
69 i = push + 1
70 let remoteSeen = false
71 let all = false
72 const refs: string[] = []
73 for (; i < tokens.length; i++) {
74 const token = tokens[i] ?? ''
75 if (['&&', '||', ';', '|'].includes(token)) break
76 if (token === '--all' || token === '--mirror') all = true
77 else if (['--repo', '--push-option', '--receive-pack', '--exec', '-o'].includes(token)) i++
78 else if (token.startsWith('-')) continue
79 else if (!remoteSeen) remoteSeen = true
80 else refs.push(token)
81 }
82 if (all) {
83 targets.push('*')
84 } else if (refs.length === 0) {
85 if (current === undefined) return undefined
86 targets.push(current)
87 } else {
88 for (const ref of refs) {
89 let branch = ref.replace(/^\+/, '')
90 branch = branch.slice(branch.lastIndexOf(':') + 1).replace(/^refs\/heads\//, '')
91 if (branch === 'HEAD') {
92 if (current === undefined) return undefined
93 branch = current
94 }
95 if (branch !== '') targets.push(branch)
96 }
97 }
98 }
99 return found ? targets : undefined
100}
101
102export const deletedBranches = (command: string): string[] | undefined => {
103 const tokens = tokensOf(command)
104 const deleted: string[] = []
105 let found = false
106 let i = 0
107 while (i < tokens.length) {
108 const push = pushAt(tokens, i)
109 if (push === undefined) {
110 i++
111 continue
112 }
113 found = true
114 i = push + 1
115 let remoteSeen = false
116 let deleting = false
117 const refs: string[] = []
118 for (; i < tokens.length; i++) {
119 const token = tokens[i] ?? ''
120 if (['&&', '||', ';', '|'].includes(token)) break
121 if (token === '--delete' || token === '-d') deleting = true
122 else if (['--repo', '--push-option', '--receive-pack', '--exec', '-o'].includes(token)) i++
123 else if (token.startsWith('-')) continue
124 else if (!remoteSeen) remoteSeen = true
125 else refs.push(token)
126 }
127 if (refs.length === 0) return undefined
128 for (const ref of refs) {
129 if (!deleting && !ref.startsWith(':')) return undefined
130 const branch = ref.replace(/^:/, '').replace(/^refs\/heads\//, '')
131 if (branch === '' || branch.includes(':')) return undefined
132 deleted.push(branch)
133 }
134 }
135 return found && deleted.length > 0 ? deleted : undefined
136}
137
138const DIR = String.raw`("[^"]*"|'[^']*'|[^\s;&|()]+)`
139const CD_STEP = new RegExp(String.raw`(?:^|[;&|\n(])\s*cd\s+${DIR}`, 'g')
140const GIT_DIR = new RegExp(String.raw`^git\s+-C\s+${DIR}`)
141
142const unquote = (value: string) => value.replace(/^(["'])([\s\S]*)\1$/, '$2')
143const joinDir = (base: string | undefined, dir: string) =>
144 base === undefined || dir.startsWith('/') || dir.startsWith('~') ? dir : `${base.replace(/\/$/, '')}/${dir}`
145
146// The directory a command's first `git` runs in, from the `cd` steps before it and its `-C`;
147// undefined when that is the session's own. `~` is left for the caller to expand.
148export const commandDir = (command: string): string | undefined => {
149 const at = command.search(/\bgit\s/)
150 if (at < 0) return undefined
151 let dir: string | undefined
152 for (const step of command.slice(0, at).matchAll(CD_STEP)) dir = joinDir(dir, unquote(step[1] ?? ''))
153 const flag = command.slice(at).match(GIT_DIR)?.[1]
154 return flag === undefined ? dir : joinDir(dir, unquote(flag))
155}
156
157export type MrOpening = { target?: string | undefined; source?: string | undefined }
158
159const GH_CREATE = /(?:^|[\s&;|(])gh\s+pr\s+create\b/
160const GLAB_CREATE = /(?:^|[\s&;|(])glab\s+mr\s+create\b/
161const CURL = /(?:^|[\s&;|(])curl\s/
162// The collection, not one MR (`/merge_requests/42/merge`): only a POST there opens one.
163const MR_COLLECTION = /\/merge_requests(?![\w/])/
164const METHOD = /(?:^|\s)(?:-X|--request)[\s=]*['"]?([A-Za-z]+)/
165const SENDS_BODY = /(?:^|\s)(?:-F|--form|-d|--data(?:-raw|-binary|-urlencode|-ascii)?)[\s=]/
166
167const escaped = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
168
169const flagValue = (command: string, flags: string[]) => {
170 for (const flag of flags) {
171 const value = command.match(new RegExp(`(?:^|\\s)${escaped(flag)}(?:=|\\s+)['"]?([^\\s'"]+)`))?.[1]
172 if (value !== undefined) return value
173 }
174 return undefined
175}
176
177const param = (command: string, name: string) =>
178 command.match(new RegExp(`\\b${name}["']?\\s*[=:]\\s*["']?([^\\s"'&,}]+)`))?.[1]
179
180// curl sends POST when it has a body and no other method is named.
181const posts = (command: string) => {
182 const method = command.match(METHOD)?.[1]
183 return method === undefined ? SENDS_BODY.test(command) : method.toUpperCase() === 'POST'
184}
185
186// Quotes are stripped to find the command, so a message quoting `gh pr create` is not one; values are read from the whole.
187export const mrOpeningOf = (command: string): MrOpening | undefined => {
188 const unquoted = command.replace(/'[^']*'/g, '').replace(/"[^"]*"/g, '')
189 if (GH_CREATE.test(unquoted)) {
190 return { target: flagValue(command, ['--base', '-B']), source: flagValue(command, ['--head', '-H'])?.replace(/^[^:]+:/, '') }
191 }
192 if (GLAB_CREATE.test(unquoted)) {
193 return { target: flagValue(command, ['--target-branch', '-b']), source: flagValue(command, ['--source-branch', '-s']) }
194 }
195 if (CURL.test(unquoted) && MR_COLLECTION.test(command) && posts(command)) {
196 return { target: param(command, 'target_branch'), source: param(command, 'source_branch') }
197 }
198 return undefined
199}
200hooks/shared/continuations.ts 7 lines1// Follow-up prompts that continue the user's turn rather than start one, so the turn keeps what the user typed in it:
2// the mods that send one at the end of a turn (scripts/check-continuations.mjs keeps this list to them; none does now,
3// each shows a one-line notice instead), a background task the agent started reporting back, and the engine following
4// up a UI action. A plugin's name is the engine's stamp, so only these can extend a turn.
5export const CONTINUATION_PLUGINS: readonly string[] = []
6export const CONTINUATION_ORIGINS: readonly string[] = ['task-notification', 'auto-continuation']
7hooks/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}
27hooks/shared/issue-refs.ts 44 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
6
7// Shaped like tracker keys but naming standards, so "UTF-8" or "SHA-256" in a message is not a ticket.
8const NOT_TRACKERS = new Set([
9 'AES', 'ANSI', 'ASCII', 'BASE', 'CP', 'CRC', 'CVE', 'COVID', 'ECMA', 'ES', 'GMT', 'GPT', 'HTTP', 'IEEE', 'IPV',
10 'ISO', 'KOI', 'MD', 'PEP', 'RFC', 'RSA', 'SHA', 'SSL', 'TCP', 'TLS', 'UDP', 'USB', 'UTC', 'UTF', 'WCAG', 'WIN',
11])
12
13const ticket = (key: string, n: string) => (NOT_TRACKERS.has(key.toUpperCase()) ? undefined : `#${key.toUpperCase()}-${n}`)
14
15const unique = (refs: (string | undefined)[]) => [...new Set(refs.filter((r): r is string => r !== undefined))]
16
17// Claude Code's labels for attachments in a prompt ("[Image #2]", "[Pasted text #1 +12 lines]"), not issues.
18const ATTACHMENT = /\[(?:Image|Pasted text)[^\]\n]*\]/g
19
20// `tracked` is whether the repo has a remote: without one, "#87" cannot point at an issue.
21export const refsInText = (prompt: string, tracked: boolean): string[] => {
22 const text = prompt.replace(ATTACHMENT, ' ')
23 const found: { at: number; ref: string | undefined }[] = [...text.matchAll(KEY)].map((m) => ({
24 at: m.index,
25 ref: ticket(m[1] ?? '', m[2] ?? ''),
26 }))
27 if (tracked) {
28 for (const m of [...text.matchAll(NUMBER), ...text.matchAll(ISSUE_URL)]) found.push({ at: m.index, ref: `#${m[1]}` })
29 }
30 return unique(found.sort((a, b) => a.at - b.at).map((f) => f.ref))
31}
32
33export const refsInBranch = (branch: string, tracked: boolean): string[] =>
34 unique(
35 branch.split('/').map((segment) => {
36 const key = segment.match(BRANCH_KEY)
37 // A key is written in capitals or leads a slug (`blk-23-chat`); a bare lowercase `react-19` is a version.
38 if (key && (segment.length > key[0].length || /[A-Z]/.test(key[1] ?? ''))) return ticket(key[1] ?? '', key[2] ?? '')
39 const number = segment.match(BRANCH_NUMBER)?.[1]
40 return tracked && number !== undefined ? `#${number}` : undefined
41 }),
42 )
43
44