Gates git work: no commit, push or merge without the user's say-so in the current turn; a push to a branch that already landed is refused; a pushed series must…

Bakhtiyar Ospanov's agent skills, and Claude Code mods: plugins built on function hooks.
For any agent the skills CLI supports:
npx skills add bahaospanov/skills --skill <skill>
Or in Claude Code, all of them as one plugin, invoked as /bahaospanov-skills:<skill>:
/plugin marketplace add bahaospanov/skills
/plugin install bahaospanov-skills@bahaospanov
Pick one: installing both leaves every skill twice.
Reachable only when you type them (Claude Code: disable-model-invocation: true; Codex: policy.allow_implicit_invocation: false in agents/openai.yaml).
Model- or user-reachable.
prototype (MIT), its UI branch reworked: whole flows, options grouped by stage in a one-click panel.Early access; the API changes between releases.
One mod per purpose.
| Mod | Purpose |
|---|---|
| git-gates | Git work is authorized and tidy |
| git-cleanup | Merged work is cleaned up once it shipped |
| lean-docs | Docs worth keeping |
| lean-comments | Comments worth keeping |
| lean-scripts | Scripts worth keeping |
Haiku reviews are gated in code first, so a call that cannot fail the review costs no model call. End-of-turn checks read the git diff of repos the turn touched, Bash edits included, and send at most two follow-up prompts a session.
Pushing is a deploy, so the agent needs the user's word in the current turn: the prompt that opened it or one typed while it ran. If a consent check itself fails, the call is blocked.
| Check | Runs on | Needs | Then |
|---|---|---|---|
| consent | git commit, push; PR/MR merge | commit, push, ship, deploy, pr, mr, tag or release typed in the current turn (a message typed while it runs or a background task's report does not withdraw it); merge needs "merge"; a protected branch must be named | Call denied |
| grants | Later commits in the session | A message asking for a commit per task, or the grant tool after an authorizing message | Commits spend the grant; pushes never |
| messages | A git commit | Conventional Commits subject, no reviewer pre-answers, a last line with the issue or ticket (#87, #BLK-23) when your messages or the branch name one; then Haiku: a body only when the cause is subtle | Commit denied |
| descriptions | Setting an MR/PR description | Fixed-label blocks at column 0 | Call denied |
| landed branch | A git push | The branch's pushed head already sits in a protected branch, and the message names no new MR | Push denied |
| every commit works | A git push of 2 to 15 commits no remote has | Sonnet: no commit removes something a later one stops using, or uses something a later one adds; skipped when the message says the order is fine | Push denied |
Protected branches come from a repo's own push policy file. Integration branches are the protected ones; with no policy, the remote's default branch and any of dev, develop, main, master that exist. Deleting a branch on origin needs no keyword when origin's head of it already sits in an integration branch. A bare #87 counts only in a repo with a remote; with no issue tracker, nothing is asked. Issues you typed bind only commits in the session's repo and its worktrees; a branch ending in its issue number (perf/mobile-lcp-89) lets the message end with that one instead.
Merged is not shipped: a branch is cleaned up and its issue filled in only once the pipeline holding the merge has passed. Closing the issue is left to you.
| Check | Runs on | Needs | Then |
|---|---|---|---|
| merged first | Removing a worktree or branch, local or on origin | The branch sits in an integration branch, a merged PR/MR has it as source branch, or the current turn's message says it merged (or to abandon it) | Call denied |
| pipeline first | Removing a worktree or branch, local or on origin; rewriting an issue's body (checklist ticks, How to test) | The work (the branch, or the newest integration commit naming the issue) landed and a pipeline holding it passed; skipped when the message says not to wait | Call denied while it runs or after it failed |
| stale work | The end of a turn | A branch the session committed to or pushed that sits in an integration branch, its worktree clean, no pipeline holding it still running or failed | Follow-up prompt to remove the worktree and the branch, local and on origin, once checked |
Integration branches are found as git-gates finds them. A branch sits in an integration branch when its head does, or when every commit of it has a copy there (a rebase merge). Pipelines are read with gh on GitHub and, on GitLab, with the gitlab_token option: a read_api token, asked when the plugin is enabled, kept in the keychain on macOS and in ~/.claude/.credentials.json elsewhere. With no token or no pipeline holding the work, the pipeline check holds nothing back and a log line says why; merged first still applies, and on GitLab sees a squash merge only with the token.
| Check | Runs on | Flags | Then |
|---|---|---|---|
| docs-review | A doc grown in a git checkout | Haiku: text nobody reads after the task (runbooks, setup pages, narration) | Claude gets the reason |
| docs-no-repeat-code | A doc line being written | Identifiers that already appear together in one code file | Write denied |
| limit-docs | The end of a turn | New or grown docs, prose outweighing code, doc lines repeating code | Follow-up prompt |
No check reads a skill's folder, the one holding SKILL.md, or anything under it: a skill is read again on every use.
No comments by default: keep the ones that record a measured number, a trap or an invariant, cut the ones that restate the code or narrate the change.
| Check | Runs on | Flags | Then |
|---|---|---|---|
| limit-edits | A Write or Edit | More than 3 added comment lines, or a comment-heavy region around the edit | Claude gets the guidance |
| limit-turns | The end of a turn | More than 3 new comment lines per file in the turn's diff | Follow-up prompt |
No check reads a file installed under ~/.agents/skills, ~/.claude/skills or ~/.claude/plugins: it is someone else's code. A link from there into a checkout is followed, and the file is checked.
| Check | Runs on | Flags | Then |
|---|---|---|---|
| scripts-review | A script written or grown in a git checkout | Haiku: scripts you could just type again when needed | Claude gets the reason |
Mods load only with function hooks enabled, so export this in your shell profile first:
export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1
Without it Claude Code skips the mods silently. Then, in Claude Code:
/plugin marketplace add bahaospanov/skills
/plugin install <mod>@bahaospanov
One folder per mod. tsconfig.json and types/ are shared. An installed mod carries only its own folder, so code two mods share is copied into each one's hooks/shared/.
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir ./<mod> --debug
Saving a file under <mod>/hooks/ reloads the mod. Repeat --plugin-dir to load several.
npm run typecheck # tsc over every mod and its tests
npm run check:shared # hooks/shared/ copies are identical across mods
npm run check:version # every plugin.json carries package.json's version
claude plugin validate ./<mod> # what the engine sees the module hook and call
claude plugin test ./<mod> # the mod's tests/
types/ is written by /plugin-types types, run inside a session started as above. Regenerate, never edit, when:
head -1 types/claude-code.d.ts vs claude --version)$ is enabled or disabledCommit the result; git diff types/ shows what the update changed.
hooks/register.ts 427 lines1import type { EngineInterface, Register } from 'claude-code'
2import { commitMessageViolations, invokesCommit, messageFrom, 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 {
15 authorizes,
16 authorizesMerge,
17 deletionNote,
18 grantRefused,
19 grantRequest,
20 mergeRefused,
21 namesBranch,
22 noKeyword,
23 noUserMessage,
24 protectedHit,
25 protectedPushRefused,
26 pushUndetermined,
27} from './consent'
28import { GRANT_DEFAULT_TTL_S, grantArgsOf, isLive, openGrant, spend, type Grant } from './grants'
29import { acknowledgesLanded, landedRefused } from './landed-branch'
30import { descriptionFrom, descriptionViolations, expandVars, setsDescription } from './mr-description'
31import { COMMIT_MESSAGE, COMMIT_ORDER } from './prompts'
32import {
33 branchesOf,
34 commandDir,
35 currentTurn,
36 defaultBranchOf,
37 deletedBranches,
38 FALLBACK_BASES,
39 pushTargets,
40 verbOf,
41 type Verb,
42} from './shared/git-commands'
43import { missingRefViolation, refsInBranch, refsInText, tailRefInBranch } from './shared/issue-refs'
44import { MODEL, promptFor, SYSTEM, verdictOf, type Review, type Verdict as ReviewVerdict } from './shared/verdict'
45
46// A --plugin-dir load serves it as mcp__git-gates__grant; the registered name is kept for messages.
47const GRANT_TOOL = /^mcp__(plugin_)?git-gates__grant$/
48const HUMAN_ORIGINS: readonly string[] = ['composer', 'bridge', 'sdk']
49// These follow-ups continue the user's turn, as the Stop hook they replaced did, so they keep their authorization.
50const CONTINUATION_PLUGINS: readonly string[] = ['git-cleanup', 'lean-comments', 'lean-docs']
51// So does a background task the agent started reporting back, or the engine following up a UI action.
52const CONTINUATION_ORIGINS: readonly string[] = ['task-notification', 'auto-continuation']
53const COMMIT_REVIEW: Review = { name: 'commit message review', prompt: COMMIT_MESSAGE, status: 'judging message' }
54const ORDER_REVIEW: Review = { name: 'every commit works', prompt: COMMIT_ORDER, status: 'judging commit order' }
55const LOOKBACK = 30
56
57type Prompt = { text: string; human: boolean; turnId?: string | undefined }
58type Verdict = { reason: string } | { note?: string }
59
60let prompts: Prompt[] = []
61let grant: Grant | undefined
62let grantTool = 'mcp__git-gates__grant'
63
64// Transcript rows carry no origin, so the engine's own user-role rows are told apart by their markup.
65const ENGINE_ROW = /<task-notification>|<local-command-(caveat|stdout|stderr)>/
66const REMINDER = /<system-reminder>[\s\S]*?<\/system-reminder>/g
67
68// Tracked prompts are exact; after a reload or resume the transcript stands in, minus the engine's rows.
69const recentPrompts = async ($: EngineInterface, count: number): Promise<Prompt[]> => {
70 if (prompts.length > 0) return prompts.slice(-count)
71 const messages = await $.session.messages()
72 return messages
73 .filter((m) => m.role === 'user' && !m.toolResults?.length && !ENGINE_ROW.test(m.text))
74 .map((m) => ({ text: m.text.replace(REMINDER, '').trim(), human: true }))
75 .filter((p) => p.text !== '')
76 .slice(-count)
77}
78
79// What the user typed in the current turn: its opening prompt plus anything typed while it ran.
80const typedThisTurn = async ($: EngineInterface) => {
81 const turn = currentTurn(await recentPrompts($, LOOKBACK))
82 return turn.length === 0 ? undefined : turn.filter((p) => p.human).map((p) => p.text)
83}
84
85const git = async ($: EngineInterface, args: string[], cwd?: string) => {
86 const run = await $.process.run(['git', ...args], cwd === undefined ? undefined : { cwd })
87 return run.exitCode === 0 ? run.stdout.trim() : undefined
88}
89
90// The loader only admits literal $.env.get names; HOME is all a `cd ~/…` needs.
91const repoOf = async ($: EngineInterface, command: string) => {
92 const dir = commandDir(command)
93 return dir?.startsWith('~') ? `${await $.env.get('HOME')}${dir.slice(1)}` : dir
94}
95
96// Worktrees of one repo share its common dir, so a worktree counts as the session's repo.
97const isSessionRepo = async ($: EngineInterface, cwd: string | undefined) => {
98 if (cwd === undefined) return true
99 const args = ['rev-parse', '--path-format=absolute', '--git-common-dir']
100 const [there, here] = await Promise.all([git($, args, cwd), git($, args)])
101 return there === undefined || here === undefined || there === here
102}
103
104const protectedBranches = async ($: EngineInterface, cwd?: string) => {
105 const top = await git($, ['rev-parse', '--show-toplevel'], cwd)
106 if (!top) return []
107 const policy = await $.fs.read(`${top}/.claude/push-policy.json`).catch(() => undefined)
108 return policy === undefined ? [] : branchesOf(policy)
109}
110
111const currentBranch = async ($: EngineInterface, cwd?: string) => {
112 const branch = await git($, ['rev-parse', '--abbrev-ref', 'HEAD'], cwd)
113 return branch && branch !== 'HEAD' ? branch : undefined
114}
115
116// `git()` swallows a non-zero exit, and that exit is the answer here.
117const isAncestor = async ($: EngineInterface, commit: string, of: string, cwd?: string) => {
118 const run = await $.process.run(['git', 'merge-base', '--is-ancestor', commit, of], cwd === undefined ? undefined : { cwd })
119 return run.exitCode === 0
120}
121
122const landedTarget = async ($: EngineInterface, command: string) => {
123 const cwd = await repoOf($, command)
124 const integration = await protectedBranches($, cwd)
125 if (integration.length === 0) return undefined
126 const targets = pushTargets(command, await currentBranch($, cwd))
127 if (targets === undefined) return undefined
128 for (const branch of targets) {
129 if (branch === '*' || integration.includes(branch)) continue
130 const pushed = await git($, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${branch}`], cwd)
131 if (pushed === undefined) continue
132 for (const base of integration) {
133 if (await isAncestor($, pushed, `refs/remotes/origin/${base}`, cwd)) return { branch, base }
134 }
135 }
136 return undefined
137}
138
139// A repo without a push policy still merges somewhere; only the landed-delete exemption relies on this guess.
140const integrationBases = async ($: EngineInterface, cwd?: string) => {
141 const policy = await protectedBranches($, cwd)
142 if (policy.length > 0) return policy
143 const head = defaultBranchOf(await git($, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], cwd))
144 const bases: string[] = []
145 for (const base of new Set([...(head ? [head] : []), ...FALLBACK_BASES])) {
146 if ((await git($, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${base}`], cwd)) !== undefined) bases.push(base)
147 }
148 return bases
149}
150
151const landedBase = async ($: EngineInterface, commit: string, bases: string[], cwd?: string) => {
152 for (const base of bases) if (await isAncestor($, commit, `refs/remotes/origin/${base}`, cwd)) return base
153 return undefined
154}
155
156// Asks origin itself: a stale remote-tracking ref could hide commits pushed since, and the delete would drop them.
157const deletesOnlyLanded = async ($: EngineInterface, branches: string[], cwd?: string) => {
158 const bases = await integrationBases($, cwd)
159 for (const branch of branches) {
160 if (bases.includes(branch)) return false
161 const listed = await git($, ['ls-remote', 'origin', `refs/heads/${branch}`], cwd)
162 const head = listed?.split(/\s/)[0]
163 if (!head || (await landedBase($, head, bases, cwd)) === undefined) return false
164 }
165 return true
166}
167
168// Commits the push sends that no remote has yet, oldest first; undefined when git cannot say.
169const unpushedCommits = async ($: EngineInterface, sources: string[], cwd?: string) => {
170 const shas: string[] = []
171 for (const source of sources) {
172 const listed = await git($, ['rev-list', '--reverse', source, '--not', '--remotes'], cwd)
173 if (listed === undefined) return undefined
174 for (const sha of listed.split('\n')) if (sha !== '' && !shas.includes(sha)) shas.push(sha)
175 }
176 return shas
177}
178
179const describeCommit = async ($: EngineInterface, sha: string, cwd?: string): Promise<SeriesCommit | undefined> => {
180 const [subject, files, diff, deleted] = await Promise.all([
181 git($, ['show', '-s', '--format=%s', sha], cwd),
182 git($, ['show', '--format=', '--name-status', sha], cwd),
183 git($, ['show', '--format=', '-U0', '--no-color', '--diff-filter=AM', sha], cwd),
184 git($, ['show', '--format=', '-U0', '--no-color', '--diff-filter=D', sha], cwd),
185 ])
186 if (subject === undefined || files === undefined) return undefined
187 return {
188 subject,
189 files: files.split('\n').filter(Boolean),
190 diff: trimDiff(diff ?? ''),
191 deleted: trimDiff(deleted ?? '', DELETED_CHARS),
192 }
193}
194
195// Refs the user named or the branch carries, newest first. A git failure only drops the repo's side.
196// What the user typed is about the session's repo, so another repo's commit only answers to its branch.
197const mentionedRefs = async ($: EngineInterface, cwd?: string) => {
198 const tracked = !!(await git($, ['remote'], cwd).catch(() => undefined))
199 const branch = await currentBranch($, cwd).catch(() => undefined)
200 const typed = (await isSessionRepo($, cwd))
201 ? (await recentPrompts($, LOOKBACK)).filter((p) => p.human).reverse()
202 : []
203 return {
204 required: [
205 ...new Set([...(branch ? refsInBranch(branch, tracked) : []), ...typed.flatMap((p) => refsInText(p.text, tracked))]),
206 ],
207 accepted: branch ? tailRefInBranch(branch, tracked) : [],
208 }
209}
210
211const consent = async ($: EngineInterface, command: string, verb: Verb): Promise<Verdict> => {
212 const now = await $.clock.now()
213 if (verb === 'commit' && isLive(grant, now)) {
214 grant = spend(grant)
215 return {
216 note: `git-gates: allowed by standing commit grant — ${grant.usesRemaining} use(s) left${grant.goal ? `, goal: ${grant.goal}` : ''}`,
217 }
218 }
219
220 const typed = await typedThisTurn($)
221 if (typed === undefined) return { reason: noUserMessage() }
222
223 if (verb === 'merge') return typed.some(authorizesMerge) ? {} : { reason: mergeRefused(command) }
224
225 if (verb === 'push') {
226 const cwd = await repoOf($, command)
227 const policy = await protectedBranches($, cwd)
228 if (policy.length > 0) {
229 const targets = pushTargets(command, await currentBranch($, cwd))
230 if (targets === undefined) return { reason: pushUndetermined(command, policy) }
231 const hit = protectedHit(targets, policy)
232 if (hit !== undefined) {
233 return typed.some((text) => namesBranch(text, hit))
234 ? { note: `git-gates: direct push to '${hit}' — authorized by name in the user's message` }
235 : { reason: protectedPushRefused(command, hit) }
236 }
237 }
238 // Deleting a branch whose head already sits in an integration branch ships nothing.
239 const deleted = deletedBranches(command)
240 if (deleted !== undefined && (await deletesOnlyLanded($, deleted, cwd))) return { note: deletionNote(deleted) }
241 }
242
243 if (!typed.some(authorizes)) return { reason: noKeyword(command, grantTool) }
244
245 const asking = verb === 'commit' ? typed.find((text) => grantRequest(text) !== undefined) : undefined
246 const uses = asking === undefined ? undefined : grantRequest(asking)
247 if (asking !== undefined && uses !== undefined && !(grant?.promptText === asking && grant.expiresAt > now)) {
248 grant = spend(openGrant(uses, GRANT_DEFAULT_TTL_S, '', now, asking))
249 return {
250 note: `git-gates: user message opens a commit grant — ${grant.usesRemaining} further commit(s) allowed for ${GRANT_DEFAULT_TTL_S / 60}m`,
251 }
252 }
253 return {}
254}
255
256type Outcome = { deny?: string | undefined; isError?: boolean | undefined; text?: string | undefined }
257
258const firstLine = (text: string) => text.split('\n')[0] ?? ''
259
260// While the settings guards still run, a call this mod allowed but its settings twin blocked is a parity gap worth seeing.
261const enforce = async <R extends Outcome>(
262 $: EngineInterface,
263 verdict: Verdict,
264 twin: RegExp,
265 run: () => Promise<R>,
266): Promise<R | { deny: string }> => {
267 if ('reason' in verdict) return { deny: verdict.reason }
268 if (verdict.note) $.ui.log(verdict.note)
269 const result = await run()
270 const blocked = result.deny ?? (result.isError ? result.text : undefined)
271 if (blocked !== undefined && twin.test(blocked)) {
272 $.ui.log(`git-gates: allowed, but a settings guard blocked it: ${firstLine(blocked)}`)
273 }
274 return result
275}
276
277// The loader only admits literal $.env.get names, so HOME is the one variable a description path may use.
278const readDescriptionFile = async ($: EngineInterface, path: string) => {
279 const expanded = expandVars(path, { HOME: await $.env.get('HOME') })
280 return expanded === undefined ? undefined : $.fs.read(expanded).catch(() => undefined)
281}
282
283// The loader follows $ only into functions of this file, so the model call lives here, not in shared/verdict.ts.
284const judge = async (
285 $: EngineInterface,
286 review: Review,
287 input: object,
288 model = MODEL,
289): Promise<ReviewVerdict | undefined> => {
290 $.ui.status(review.status)
291 try {
292 const result = await $.model.complete({ model, system: SYSTEM, prompt: promptFor(review, input) })
293 const reply = result.isAnswered ? result.text : `(${result.reason})`
294 const verdict = verdictOf(reply)
295 if (verdict === undefined) $.ui.log(`git-gates (${review.name}): no verdict: ${reply.slice(0, 120)}`)
296 return verdict
297 } finally {
298 $.ui.status(undefined)
299 }
300}
301
302export const register: Register = (on) => {
303 on('prompt.submit', ($, e, next) => {
304 if (e.origin.kind === 'plugin' && CONTINUATION_PLUGINS.includes(e.origin.name)) return next(e)
305 if (CONTINUATION_ORIGINS.includes(e.origin.kind)) return next(e)
306 prompts = [...prompts, { text: e.text, human: HUMAN_ORIGINS.includes(e.origin.kind), turnId: e.turnId }].slice(-LOOKBACK)
307 return next(e)
308 })
309
310 on('session.start', async ($, e, next) => {
311 const registered = await $.tool.register({
312 name: 'grant',
313 description:
314 'Pre-authorize N `git commit` calls for this session (uses: default 5, max 20) for ttl_seconds (default 7200, max 28800), with an optional goal. ' +
315 'Refused unless one of the last 30 user messages authorizes committing. Never covers git push. ' +
316 'action: "grant" (default), "status" or "revoke".',
317 inputSchema: {
318 type: 'object',
319 properties: {
320 action: { type: 'string', enum: ['grant', 'status', 'revoke'] },
321 uses: { type: 'integer', minimum: 1 },
322 ttl_seconds: { type: 'integer', minimum: 1 },
323 goal: { type: 'string' },
324 },
325 },
326 })
327 grantTool = registered.tool
328 return next(e)
329 })
330
331 on('tool.call', { tool: GRANT_TOOL }, async ($, e) => {
332 const args = grantArgsOf(e)
333 if ('error' in args) return { deny: args.error }
334 const now = await $.clock.now()
335 if (args.action === 'revoke') {
336 grant = undefined
337 return { result: 'git-gates: grant revoked' }
338 }
339 if (args.action === 'status') {
340 if (grant === undefined) return { result: 'git-gates: no grant for this session' }
341 const secondsLeft = Math.round((grant.expiresAt - now) / 1000)
342 return { result: JSON.stringify({ ...grant, live: isLive(grant, now), seconds_left: secondsLeft }) }
343 }
344 const recent = await recentPrompts($, LOOKBACK)
345 if (!recent.some((p) => p.human && authorizes(p.text))) return { deny: grantRefused(LOOKBACK) }
346 grant = openGrant(args.uses, args.ttlSeconds, args.goal, now)
347 const seconds = Math.round((grant.expiresAt - now) / 1000)
348 return {
349 result: `git-gates: granted ${grant.usesRemaining} commit(s) for ${seconds}s${args.goal ? ` — goal: ${args.goal}` : ''}`,
350 }
351 })
352
353 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
354 const verb = verbOf(e.command)
355 if (verb === undefined) return next(e)
356 return enforce($, await consent($, e.command, verb), /git-commit-guard/, () => next(e))
357 }).catch(($, e, next) =>
358 next.called ? undefined : { deny: `git-gates: the check failed (${next.error.message ?? next.error.kind}); blocking until it works` },
359 )
360
361 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
362 if (verbOf(e.command) !== 'push' || deletedBranches(e.command) !== undefined) return next(e)
363 const landed = await landedTarget($, e.command)
364 if (landed === undefined) return next(e)
365 if ((await typedThisTurn($))?.some(acknowledgesLanded)) return next(e)
366 return { deny: landedRefused(e.command, landed.branch, landed.base) }
367 })
368
369 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
370 if (verbOf(e.command) !== 'push' || deletedBranches(e.command) !== undefined) return next(e)
371 const cwd = await repoOf($, e.command)
372 const sources = pushSources(e.command, await currentBranch($, cwd))
373 if (!sources?.length) return next(e)
374 const shas = await unpushedCommits($, sources, cwd)
375 if (shas === undefined || shas.length < MIN_SERIES) return next(e)
376 if (shas.length > MAX_SERIES) {
377 $.ui.log(`git-gates (${ORDER_REVIEW.name}): ${shas.length} commits, over ${MAX_SERIES}, not reviewed`)
378 return next(e)
379 }
380 if ((await typedThisTurn($))?.some(acknowledgesOrder)) return next(e)
381 const commits = await Promise.all(shas.map((sha) => describeCommit($, sha, cwd)))
382 if (commits.includes(undefined)) return next(e)
383 const review = await judge($, ORDER_REVIEW, { commits }, ORDER_MODEL)
384 if (review?.ok !== false) return next(e)
385 $.ui.log(`git-gates (${ORDER_REVIEW.name}): ${review.reason}`)
386 return { deny: commitOrderRefused(e.command, review.reason) }
387 })
388
389 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
390 if (!invokesCommit(e.command) && !runsGitCommit(e.command)) return next(e)
391 const source = messageFrom(e.command)
392 const text =
393 source === undefined ? undefined : 'text' in source ? source.text : await $.fs.read(source.file).catch(() => undefined)
394 const refs = text ? await mentionedRefs($, await repoOf($, e.command)) : undefined
395 const missingRef = text && refs ? missingRefViolation(text, refs.required, refs.accepted) : undefined
396 const found = text ? [...commitMessageViolations(text), ...(missingRef ? [missingRef] : [])] : []
397 const verdict: Verdict = found.length > 0 ? { reason: `git-gates (commit message): ${found.join('; ')}` } : {}
398 return enforce($, verdict, /commit-message-guard/, () => next(e))
399 })
400
401 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
402 if (!runsGitCommit(e.command)) return next(e)
403 const review = await judge($, COMMIT_REVIEW, {
404 hook_event_name: 'PreToolUse',
405 tool_name: 'Bash',
406 tool_input: { command: e.command, description: e.description },
407 cwd: await $.session.cwd(),
408 })
409 if (review?.ok !== false) return next(e)
410 $.ui.log(`git-gates (${COMMIT_REVIEW.name}): ${review.reason}`)
411 return { deny: `git-gates (${COMMIT_REVIEW.name}): ${review.reason}` }
412 })
413
414 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
415 if (!setsDescription(e.command)) return next(e)
416 const source = descriptionFrom(e.command)
417 if (source !== undefined && 'unreadable' in source) {
418 return enforce($, { reason: `git-gates (MR description): ${source.unreadable}` }, /mr-description-guard/, () => next(e))
419 }
420 const text =
421 source === undefined ? undefined : 'text' in source ? source.text : await readDescriptionFile($, source.file)
422 const found = text?.trim() ? descriptionViolations(text) : []
423 const verdict: Verdict = found.length > 0 ? { reason: `git-gates (MR description):\n - ${found.join('\n - ')}` } : {}
424 return enforce($, verdict, /mr-description-guard/, () => next(e))
425 })
426}
427hooks/commit-message.ts 43 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
8export type MessageSource = { text: string } | { file: string } | undefined
9
10export const invokesCommit = (command: string) => INVOKED.test(command)
11
12// Wider than invokesCommit (`git -C dir commit` too): it only decides whether Haiku is asked, and Haiku gates again.
13export const runsGitCommit = (command: string) =>
14 /(?:^|[;&|\n(])\s*(?:cd\s+\S+\s*&&\s*)*git\s+(?:-C\s+\S+\s+)?commit\b/.test(command)
15
16export const messageFrom = (command: string): MessageSource => {
17 if (command.includes('--no-edit')) return undefined
18 const heredoc = command.match(/-F\s*-\s*<<'?(\w+)'?\n([\s\S]*?)\n\1/)
19 if (heredoc?.[2] !== undefined) return { text: heredoc[2] }
20 const dashM = [...command.matchAll(/-m\s+(['"])([\s\S]*?)\1/g)].map((m) => m[2] ?? '')
21 if (dashM.length > 0) return { text: dashM.join('\n\n') }
22 const file = command.match(/-F\s+(\S+)/)?.[1]
23 return file === undefined ? undefined : { file }
24}
25
26const quoted = (text: string) => (text.includes("'") && !text.includes('"') ? `"${text}"` : `'${text.replace(/'/g, "\\'")}'`)
27
28export const commitMessageViolations = (text: string): string[] => {
29 const lines = text
30 .trim()
31 .split(/\r?\n/)
32 .filter((line) => line.trim() !== '')
33 const [subject, ...body] = lines
34 if (subject === undefined) return []
35 const found: string[] = []
36 if (!CONVENTIONAL.test(subject)) found.push(`first line is not Conventional Commits: ${quoted(subject.slice(0, 70))}`)
37 const hits = body.filter((line) => PREEMPT.test(line)).map((line) => line.trim().slice(0, 90))
38 if (hits.length > 0) {
39 found.push(`pre-answers a reviewer instead of saying why:\n ${hits.slice(0, 3).join('\n ')}`)
40 }
41 return found
42}
43hooks/commit-order.ts 62 lines1// Observed 2026-10-01 (scanwow !269): api-py 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 the real !269 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-gates (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. Reorder or squash the series - for a
59removal, the consumer first and the provider last - and run the checks at each
60commit. If the order is right, say so, e.g. "the order is fine", and this check
61steps aside.`
62hooks/consent.ts 91 lines1const AUTH = /(^|[^a-zA-Z])(commit|push|ship|deploy|merge|pr|mr|tag|release)([^a-zA-Z]|$)/im
2const MERGE_AUTH = /(^|[^a-zA-Z])(merge|merging|смерж[а-яё]*|влей|влить|вмерж[а-яё]*)([^a-zA-Z]|$)/imu
3
4export const GRANT_DEFAULT_USES = 5
5export const GRANT_SESSION_USES = 10
6
7const COUNTS = [
8 /.*[Cc]ommits?\s*[xX*]\s*([0-9]+)/u,
9 /.*[Кк]оммит[а-яА-Я]*\s*[xXхХ*]\s*([0-9]+)/u,
10 /.*[^0-9]([0-9]+)\s+(?:separate\s+)?[Cc]ommits/u,
11 /.*[^0-9]([0-9]+)\s+[Кк]оммит[а-яА-Я]*/u,
12]
13const SESSION_WIDE = /(rest of (the |this )?session|until I (say|tell)|keep committing|до конца сесси)/iu
14const PER_TASK =
15 /(auto-?commit|commit (after|between|per|each|every|as you go|along the way)|(after|between) each (task|step|part|item|fix)[^.]{0,40}commit|one commit per|separate commits|commit them separately|коммит[а-я]* (после|на) кажд|коммить по ходу|отдельны[емх] коммит)/iu
16
17export const authorizes = (text: string) => AUTH.test(text)
18
19export const authorizesMerge = (text: string) => MERGE_AUTH.test(text)
20
21export const grantRequest = (text: string): number | undefined => {
22 const flat = text.replace(/\n/g, ' ')
23 const counted = COUNTS.map((pattern) => flat.match(pattern)?.[1]).find((n) => n !== undefined)
24 if (counted !== undefined && Number(counted) > 0) return Number(counted)
25 if (SESSION_WIDE.test(flat)) return GRANT_SESSION_USES
26 if (PER_TASK.test(flat)) return GRANT_DEFAULT_USES
27 return undefined
28}
29
30export const protectedHit = (targets: string[], policy: string[]) =>
31 targets.map((target) => policy.find((branch) => target === '*' || target === branch)).find((hit) => hit !== undefined)
32
33// A bare "push" does not count: the branch itself must be named near a shipping verb.
34export const namesBranch = (text: string, branch: string) => {
35 const escaped = branch.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')
36 return new RegExp(
37 `(push|ship|deploy|merge|пуш|запуш[а-я]*)[^.!?]{0,40}(^|[^a-z0-9_/-])${escaped}([^a-z0-9_/-]|$)`,
38 'imu',
39 ).test(text)
40}
41
42export const noUserMessage = () => 'git-gates: no user message found in this session; blocking'
43
44export const deletionNote = (branches: string[]) =>
45 `git-gates: deleting ${branches.map((b) => `'${b}'`).join(', ')} — already landed, so no keyword needed`
46
47export const mergeRefused = (command: string) => `git-gates: blocking '${command}' — merging needs the user to say "merge" in
48their most recent message. "ship", "push", "commit", "deploy" and "pr" do NOT
49authorize it: they authorize landing work on the BRANCH, and the user expects
50to press merge themselves.
51
52Merging into an integration branch is a deploy with no approval gate behind
53it. Stop at the push, report the MR state, and let the user merge.
54
55A tool call that a permission layer happens to let through is not the user
56authorizing it. If an earlier call was blocked and a later identical one is
57not, that is the sandbox changing its mind, not consent.`
58
59export const pushUndetermined = (command: string, policy: string[]) => `git-gates: cannot determine the destination branch of '${command}', and this
60repo protects branches (${policy.join(' ')}). Re-run with an explicit
61refspec so the destination is unambiguous, e.g.
62 git push origin <branch>`
63
64export const protectedPushRefused = (command: string, branch: string) => `git-gates: blocking '${command}' — '${branch}' is a protected branch in this
65repo (.claude/push-policy.json) and the user's most recent message does not
66name it. A bare "push" authorizes pushing a feature branch, not a direct push
67to an integration branch: that bypasses the PR flow.
68
69Default path — push the feature branch and open a PR:
70 git push -u origin <feature-branch>
71 gh pr create --base ${branch}
72
73If a direct push is genuinely wanted, the user must say so by name, e.g.
74"push to ${branch}". Ask them; do not paraphrase your way around this.`
75
76export const noKeyword = (command: string, grantTool: string) => `git-gates: blocking '${command}' — the most recent user message does not
77contain an authorizing keyword (commit/push/ship/deploy/merge/pr/mr/tag/
78release) and no standing commit grant covers this call. Do NOT commit or push
79without explicit instruction in the current turn. Stop, state what is ready,
80and wait for the user to authorize.
81
82If the user already authorized repeated commits earlier in this session (e.g.
83a multi-task run or a goal-scoped skill), pre-authorize with the ${grantTool}
84tool. That covers \`git commit\` only — \`git push\` always needs a keyword in
85the current message.`
86
87export const grantRefused = (lookback: number) => `git-gates: refusing to grant — no authorizing keyword
88(commit/push/ship/deploy/merge/pr/mr/tag/release) in the last ${lookback} user
89messages. A grant widens an authorization the user gave; it cannot create one.
90Ask the user to authorize committing, then retry.`
91hooks/grants.ts 42 lines1import { GRANT_DEFAULT_USES } from './consent'
2
3export const GRANT_DEFAULT_TTL_S = 7200
4const GRANT_MAX_USES = 20
5const GRANT_MAX_TTL_S = 28800
6
7export type Grant = { usesRemaining: number; expiresAt: number; goal: string; promptText?: string }
8
9export type GrantArgs =
10 | { action: 'status' }
11 | { action: 'revoke' }
12 | { action: 'grant'; uses: number; ttlSeconds: number; goal: string }
13 | { error: string }
14
15export const openGrant = (uses: number, ttlSeconds: number, goal: string, now: number, promptText?: string): Grant => ({
16 usesRemaining: Math.min(uses, GRANT_MAX_USES),
17 expiresAt: now + Math.min(ttlSeconds, GRANT_MAX_TTL_S) * 1000,
18 goal,
19 promptText,
20})
21
22export const isLive = (grant: Grant | undefined, now: number): grant is Grant =>
23 grant !== undefined && grant.usesRemaining > 0 && grant.expiresAt > now
24
25export const spend = (grant: Grant): Grant => ({ ...grant, usesRemaining: grant.usesRemaining - 1 })
26
27const positiveInt = (value: unknown, fallback: number) =>
28 value === undefined ? fallback : Number.isInteger(value) && (value as number) > 0 ? (value as number) : undefined
29
30export const grantArgsOf = (input: Record<string, unknown>): GrantArgs => {
31 const action = input['action'] ?? 'grant'
32 if (action === 'status') return { action }
33 if (action === 'revoke') return { action }
34 if (action !== 'grant') return { error: `git-gates: unknown action '${String(action)}'` }
35 const uses = positiveInt(input['uses'], GRANT_DEFAULT_USES)
36 if (uses === undefined) return { error: 'git-gates: uses must be a positive integer' }
37 const ttlSeconds = positiveInt(input['ttl_seconds'], GRANT_DEFAULT_TTL_S)
38 if (ttlSeconds === undefined) return { error: 'git-gates: ttl_seconds must be a positive integer' }
39 const goal = typeof input['goal'] === 'string' ? input['goal'] : ''
40 return { action, uses, ttlSeconds, goal }
41}
42hooks/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-gates: 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/mr-description.ts 122 lines1const LABELS = ['Symptom', 'Cause', 'Measured', 'Scope', 'Constraint', 'Cost', 'Verified', 'Remaining']
2
3// --description and --body count only on MR/PR commands: `gh repo create --description` is not an MR body.
4const SETS_DESCRIPTION =
5 /--form\s+['"]?description=|(?<![\w.])-F\s+['"]?description=|merge_request\.description=|\.description\s*=|"description"\s*:/i
6const MR_COMMAND = /\b(gh\s+pr|glab\s+mr)\b/
7const DESCRIPTION_FLAG = /--description[= ]|--body[= ]/i
8// jq's shorthand key carries no quotes, so the JSON patterns above miss `jq -n '{description:$d}'`.
9// It needs the endpoint to stay off issues, whose bodies these labels do not describe.
10const JQ_SHORTHAND = /(?<![\w."'-])description\s*:\s*\$/
11const MR_ENDPOINT = /\/(merge_requests|pulls)\b/
12
13const FROM_FILE = /--form\s+['"]?description=<([^'"\s]+)/
14const FROM_VALUE = [
15 /--form\s+(['"])description=([\s\S]*?)\1/,
16 /merge_request\.description=(['"])([\s\S]*?)\1/,
17 /--(?:description|body)[= ]\s*(['"])([\s\S]*?)\1/,
18]
19// A JSON body built elsewhere and piped in (`jq … | curl --data @-`) reaches the API unread:
20// the command carries the key, never the prose. Unreadable is not the same as clean.
21const PIPED_JSON = /(?:--data(?:-raw|-binary|-ascii)?|(?<![\w-])-d)\s+['"]?@-/
22const FROM_JSON = /"description"\s*:\s*"((?:[^"\\]|\\[\s\S])*)"/
23
24const HEADING = /^###\s+(\w+)/
25const BARE_LABEL = new RegExp(`^(${LABELS.join('|')})\\b`)
26const PREEMPT = /\b(no|not?)\s+(\w+\s+){0,2}(change[sd]?|touched|affected|impact)\b|\bnothing (else )?(changed|touched|moved)\b/i
27const ATTRIBUTION =
28 /🤖|\bclaude(\s+code)?\b|\banthropic\b|\bco-authored-by:\s*claude|\bgenerated with\b|\bopus\b|\bsonnet\b|\bhaiku\b/i
29// A file the MR touches is content, not a byline: `CLAUDE.md` alone tripped ATTRIBUTION.
30const TOOL_PATH = /\bclaude\.(md|json|ya?ml)\b|\.claude\/\S*/gi
31
32export type DescriptionSource = { text: string } | { file: string } | { unreadable: string } | undefined
33
34export const setsDescription = (command: string) =>
35 SETS_DESCRIPTION.test(command) ||
36 (MR_COMMAND.test(command) && DESCRIPTION_FLAG.test(command)) ||
37 (JQ_SHORTHAND.test(command) && (MR_ENDPOINT.test(command) || MR_COMMAND.test(command)))
38
39export const descriptionFrom = (command: string): DescriptionSource => {
40 const file = command.match(FROM_FILE)?.[1]
41 if (file !== undefined) return { file }
42 for (const pattern of FROM_VALUE) {
43 const value = command.match(pattern)?.[2]
44 if (value !== undefined) return value.startsWith('<') ? { file: value.slice(1) } : { text: value }
45 }
46 const json = command.match(FROM_JSON)?.[1]
47 if (json !== undefined) return { text: unescapeJson(json) }
48 if (PIPED_JSON.test(command)) {
49 return {
50 unreadable:
51 'the description is piped in as JSON, so this check never sees it. Write the body to a file and send that: `--form description=<body.md`. A `--data @body.json` is not read either.',
52 }
53 }
54 return undefined
55}
56
57const unescapeJson = (value: string) =>
58 value.replace(/\\(u[0-9a-fA-F]{4}|.)/g, (whole, escape: string) => {
59 if (escape.startsWith('u')) return String.fromCharCode(parseInt(escape.slice(1), 16))
60 return { n: '\n', r: '\r', t: '\t', b: '\b', f: '\f' }[escape] ?? escape
61 })
62
63// As os.path.expandvars over the variables given: others stay, and a path still holding `$` is not read.
64export const expandVars = (path: string, env: Record<string, string | undefined>) => {
65 const expanded = path.trim().replace(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, (whole, name: string) => env[name] ?? whole)
66 return expanded.includes('$') ? undefined : expanded
67}
68
69export const descriptionViolations = (text: string): string[] => {
70 const lines = text.split(/\r?\n/)
71 const found: string[] = []
72 const unlabelled: string[] = []
73 const indented: string[] = []
74 const badLabels: string[] = []
75 let seenHeading = false
76 let fenced = false
77
78 for (const line of lines) {
79 if (line.trim().startsWith('```')) {
80 fenced = !fenced
81 continue
82 }
83 if (fenced || line.trim() === '') continue
84 const heading = line.match(HEADING)?.[1]
85 if (heading !== undefined) {
86 seenHeading = true
87 if (!LABELS.includes(heading)) badLabels.push(heading)
88 continue
89 }
90 if (BARE_LABEL.test(line)) {
91 badLabels.push(line.trim())
92 continue
93 }
94 if (!seenHeading) unlabelled.push(line.trim())
95 else if (line.startsWith(' ')) indented.push(line.trim())
96 }
97
98 if (badLabels.length > 0) {
99 found.push(
100 `labels must be \`### Name\` from: ${LABELS.join(', ')}\n got: ${badLabels
101 .slice(0, 3)
102 .map((label) => label.slice(0, 40))
103 .join('; ')}`,
104 )
105 } else if (!seenHeading && lines.some((line) => line.trim() !== '')) {
106 found.push(`no \`### Label\` headings. Use only the blocks that apply: ${LABELS.join(', ')}`)
107 }
108 if (unlabelled[0] !== undefined) found.push(`prose before any heading:\n ${unlabelled[0].slice(0, 80)}`)
109 if (indented[0] !== undefined) {
110 found.push(
111 `body indented — renders as one run-on paragraph, structure vanishes. Start at column 0; transcripts go in \`\`\` fences:\n ${indented[0].slice(0, 80)}`,
112 )
113 }
114 const preempt = lines.find((line) => PREEMPT.test(line))
115 if (preempt !== undefined) found.push(`pre-answers a reviewer:\n ${preempt.trim().slice(0, 80)}`)
116 const attribution = lines.find((line) => ATTRIBUTION.test(line.replace(TOOL_PATH, '')))
117 if (attribution !== undefined) {
118 found.push(`names the tool that wrote it — the description is the author's:\n ${attribution.trim().slice(0, 80)}`)
119 }
120 return found
121}
122hooks/prompts.ts 22 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.
10A last line of issue or ticket references such as #87 or #BLK-23 is required by another check: it is not body text, never object to it.
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 - reorder (the consumer first, the provider last) or squash. Under 60 words.`
22hooks/shared/git-commands.ts 139 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
4const PUSH = /(^|[\s&;|(])git\s+push(\s|$)/m
5const COMMIT = /(^|[\s&;|(])git\s+commit(\s|$)/m
6
7// With no push policy a repo's integration branches are guessed: the remote's default branch, then these.
8export const FALLBACK_BASES: readonly string[] = ['dev', 'develop', 'main', 'master']
9
10// Quotes are stripped first so a message quoting `git push` is not a push.
11export const verbOf = (command: string): Verb | undefined => {
12 if (MERGE.test(command)) return 'merge'
13 const unquoted = command.replace(/'[^']*'/g, '').replace(/"[^"]*"/g, '')
14 if (PUSH.test(unquoted)) return 'push'
15 if (COMMIT.test(unquoted)) return 'commit'
16 return undefined
17}
18
19// A prompt typed over a running turn joins the one that opened that turn, so a quick follow-up cannot withdraw its word.
20export const currentTurn = <P extends { turnId?: string | undefined }>(prompts: P[]): P[] => {
21 const last = prompts.at(-1)
22 if (last === undefined) return []
23 if (last.turnId === undefined) return [last]
24 let start = prompts.length - 1
25 while (start > 0 && prompts[start - 1]?.turnId === last.turnId) start--
26 return prompts.slice(Math.max(0, start - 1))
27}
28
29export const branchesOf = (policy: string): string[] => {
30 try {
31 const parsed: unknown = JSON.parse(policy)
32 const branches = typeof parsed === 'object' && parsed !== null && 'protected_branches' in parsed ? parsed.protected_branches : []
33 return Array.isArray(branches) ? branches.filter((b): b is string => typeof b === 'string' && b !== '') : []
34 } catch {
35 return []
36 }
37}
38
39export const defaultBranchOf = (symref: string | undefined) => symref?.trim().replace(/^refs\/remotes\/origin\//, '') || undefined
40
41// Every branch the command's `git push`es write to; `*` for --all/--mirror, undefined when one cannot be known.
42export const pushTargets = (command: string, current: string | undefined): string[] | undefined => {
43 const tokens = command.replace(/\n/g, ' ; ').split(/\s+/).filter(Boolean)
44 const targets: string[] = []
45 let found = false
46 let i = 0
47 while (i < tokens.length) {
48 if (tokens[i] !== 'git' || tokens[i + 1] !== 'push') {
49 i++
50 continue
51 }
52 found = true
53 i += 2
54 let remoteSeen = false
55 let all = false
56 const refs: string[] = []
57 for (; i < tokens.length; i++) {
58 const token = tokens[i] ?? ''
59 if (['&&', '||', ';', '|'].includes(token)) break
60 if (token === '--all' || token === '--mirror') all = true
61 else if (['--repo', '--push-option', '--receive-pack', '--exec', '-o'].includes(token)) i++
62 else if (token.startsWith('-')) continue
63 else if (!remoteSeen) remoteSeen = true
64 else refs.push(token)
65 }
66 if (all) {
67 targets.push('*')
68 } else if (refs.length === 0) {
69 if (current === undefined) return undefined
70 targets.push(current)
71 } else {
72 for (const ref of refs) {
73 let branch = ref.replace(/^\+/, '')
74 branch = branch.slice(branch.lastIndexOf(':') + 1).replace(/^refs\/heads\//, '')
75 if (branch === 'HEAD') {
76 if (current === undefined) return undefined
77 branch = current
78 }
79 if (branch !== '') targets.push(branch)
80 }
81 }
82 }
83 return found ? targets : undefined
84}
85
86export const deletedBranches = (command: string): string[] | undefined => {
87 const tokens = command.replace(/\n/g, ' ; ').split(/\s+/).filter(Boolean)
88 const deleted: string[] = []
89 let found = false
90 let i = 0
91 while (i < tokens.length) {
92 if (tokens[i] !== 'git' || tokens[i + 1] !== 'push') {
93 i++
94 continue
95 }
96 found = true
97 i += 2
98 let remoteSeen = false
99 let deleting = false
100 const refs: string[] = []
101 for (; i < tokens.length; i++) {
102 const token = tokens[i] ?? ''
103 if (['&&', '||', ';', '|'].includes(token)) break
104 if (token === '--delete' || token === '-d') deleting = true
105 else if (['--repo', '--push-option', '--receive-pack', '--exec', '-o'].includes(token)) i++
106 else if (token.startsWith('-')) continue
107 else if (!remoteSeen) remoteSeen = true
108 else refs.push(token)
109 }
110 if (refs.length === 0) return undefined
111 for (const ref of refs) {
112 if (!deleting && !ref.startsWith(':')) return undefined
113 const branch = ref.replace(/^:/, '').replace(/^refs\/heads\//, '')
114 if (branch === '' || branch.includes(':')) return undefined
115 deleted.push(branch)
116 }
117 }
118 return found && deleted.length > 0 ? deleted : undefined
119}
120
121const DIR = String.raw`("[^"]*"|'[^']*'|[^\s;&|()]+)`
122const CD_STEP = new RegExp(String.raw`(?:^|[;&|\n(])\s*cd\s+${DIR}`, 'g')
123const GIT_DIR = new RegExp(String.raw`^git\s+-C\s+${DIR}`)
124
125const unquote = (value: string) => value.replace(/^(["'])([\s\S]*)\1$/, '$2')
126const joinDir = (base: string | undefined, dir: string) =>
127 base === undefined || dir.startsWith('/') || dir.startsWith('~') ? dir : `${base.replace(/\/$/, '')}/${dir}`
128
129// The directory a command's first `git` runs in, from the `cd` steps before it and its `-C`;
130// undefined when that is the session's own. `~` is left for the caller to expand.
131export const commandDir = (command: string): string | undefined => {
132 const at = command.search(/\bgit\s/)
133 if (at < 0) return undefined
134 let dir: string | undefined
135 for (const step of command.slice(0, at).matchAll(CD_STEP)) dir = joinDir(dir, unquote(step[1] ?? ''))
136 const flag = command.slice(at).match(GIT_DIR)?.[1]
137 return flag === undefined ? dir : joinDir(dir, unquote(flag))
138}
139hooks/shared/issue-refs.ts 57 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
6const BRANCH_TAIL = /(?:^|-)[a-z0-9]*[a-z][a-z0-9]*-(\d+)$/i
7const TRAILING = /(?:^|\s)((?:\(?#[A-Za-z0-9]+(?:-\d+)?\)?[.,]?\s*)+)$/
8const ENDED = /#(\d+|[A-Za-z][A-Za-z0-9]*-\d+)/g
9
10// Shaped like tracker keys but naming standards, so "UTF-8" or "SHA-256" in a message is not a ticket.
11const NOT_TRACKERS = new Set([
12 'AES', 'ANSI', 'ASCII', 'BASE', 'CP', 'CRC', 'CVE', 'COVID', 'ECMA', 'ES', 'GMT', 'GPT', 'HTTP', 'IEEE', 'IPV',
13 'ISO', 'KOI', 'MD', 'PEP', 'RFC', 'RSA', 'SHA', 'SSL', 'TCP', 'TLS', 'UDP', 'USB', 'UTC', 'UTF', 'WCAG', 'WIN',
14])
15
16const ticket = (key: string, n: string) => (NOT_TRACKERS.has(key.toUpperCase()) ? undefined : `#${key.toUpperCase()}-${n}`)
17
18const unique = (refs: (string | undefined)[]) => [...new Set(refs.filter((r): r is string => r !== undefined))]
19
20// `tracked` is whether the repo has a remote: without one, "#87" cannot point at an issue.
21export const refsInText = (text: string, tracked: boolean): string[] => {
22 const found: { at: number; ref: string | undefined }[] = [...text.matchAll(KEY)].map((m) => ({
23 at: m.index,
24 ref: ticket(m[1] ?? '', m[2] ?? ''),
25 }))
26 if (tracked) {
27 for (const m of [...text.matchAll(NUMBER), ...text.matchAll(ISSUE_URL)]) found.push({ at: m.index, ref: `#${m[1]}` })
28 }
29 return unique(found.sort((a, b) => a.at - b.at).map((f) => f.ref))
30}
31
32export const refsInBranch = (branch: string, tracked: boolean): string[] =>
33 unique(
34 branch.split('/').map((segment) => {
35 const key = segment.match(BRANCH_KEY)
36 if (key) return ticket(key[1] ?? '', key[2] ?? '')
37 const number = segment.match(BRANCH_NUMBER)?.[1]
38 return tracked && number !== undefined ? `#${number}` : undefined
39 }),
40 )
41
42// The issue number a slug ends with (`perf/mobile-lcp-89`). Accepted as a message's ending, never
43// demanded: in `chore/node-22` it is a version.
44export const tailRefInBranch = (branch: string, tracked: boolean): string[] => {
45 const number = tracked ? branch.split('/').at(-1)?.match(BRANCH_TAIL)?.[1] : undefined
46 return number === undefined ? [] : [`#${number}`]
47}
48
49export const missingRefViolation = (message: string, refs: string[], accepted: string[] = []): string | undefined => {
50 if (refs.length === 0) return undefined
51 const last = message.trim().split(/\r?\n/).filter((line) => line.trim() !== '').at(-1) ?? ''
52 const tail = last.match(TRAILING)?.[1] ?? ''
53 const ended = [...tail.matchAll(ENDED)].map((m) => `#${(m[1] ?? '').toUpperCase()}`)
54 if (ended.some((ref) => refs.includes(ref) || accepted.includes(ref))) return undefined
55 return `end the message with the issue it is about, e.g. a last line "${refs[0]}" (mentioned: ${refs.join(', ')})`
56}
57hooks/shared/verdict.ts 27 lines1export const MODEL = 'claude-haiku-4-5-20251001'
2
3export const SYSTEM =
4 'You are a hook reviewer. Reply with one JSON object and nothing else: {"ok": true} or {"ok": false, "reason": "..."}.'
5
6export type Verdict = { ok: true } | { ok: false; reason: string }
7
8export type Review = { name: string; prompt: string; status: string }
9
10export const promptFor = (review: Review, input: object) => review.prompt.replace('$ARGUMENTS', () => JSON.stringify(input))
11
12export const verdictOf = (reply: string): Verdict | undefined => {
13 const json = reply.match(/\{[\s\S]*\}/)
14 if (!json) return undefined
15 let parsed: unknown
16 try {
17 parsed = JSON.parse(json[0])
18 } catch {
19 return undefined
20 }
21 if (typeof parsed !== 'object' || parsed === null || !('ok' in parsed) || typeof parsed.ok !== 'boolean') {
22 return undefined
23 }
24 if (parsed.ok) return { ok: true }
25 return { ok: false, reason: 'reason' in parsed && typeof parsed.reason === 'string' ? parsed.reason : '' }
26}
27