SLOPSHOPPER

git-gates

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…

newguardstatusprompttoolmodel
v1.0.1MITupdated 2026-10-07bahaospanov/skills/git-gates
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · git-gates
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(rm -rf build && git push --force origin main) ⎿ Denied by git-gates: git-gates: blocking 'rm -rf build && git push --force origin main' — the most recent ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

bahaospanov

Bakhtiyar Ospanov's agent skills, and Claude Code mods: plugins built on function hooks.

Skills

For any agent the skills CLI supports:

npx skills add bahaospanov/skills --skill <skill>

Or in Claude Code, all of them as one plugin, invoked as /bahaospanov-skills:<skill>:

/plugin marketplace add bahaospanov/skills
/plugin install bahaospanov-skills@bahaospanov

Pick one: installing both leaves every skill twice.

User-invoked

Reachable only when you type them (Claude Code: disable-model-invocation: true; Codex: policy.allow_implicit_invocation: false in agents/openai.yaml).

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

Model-invoked

Model- or user-reachable.

Mods

Early access; the API changes between releases.

One mod per purpose.

ModPurpose
git-gatesGit work is authorized and tidy
git-cleanupMerged work is cleaned up once it shipped
lean-docsDocs worth keeping
lean-commentsComments worth keeping
lean-scriptsScripts worth keeping

Haiku reviews are gated in code first, so a call that cannot fail the review costs no model call. End-of-turn checks read the git diff of repos the turn touched, Bash edits included, and send at most two follow-up prompts a session.

git-gates

Pushing is a deploy, so the agent needs the user's word in the current turn: the prompt that opened it or one typed while it ran. If a consent check itself fails, the call is blocked.

CheckRuns onNeedsThen
consentgit commit, push; PR/MR mergecommit, push, ship, deploy, pr, mr, tag or release typed in the current turn (a message typed while it runs or a background task's report does not withdraw it); merge needs "merge"; a protected branch must be namedCall denied
grantsLater commits in the sessionA message asking for a commit per task, or the grant tool after an authorizing messageCommits spend the grant; pushes never
messagesA git commitConventional Commits subject, no reviewer pre-answers, a last line with the issue or ticket (#87, #BLK-23) when your messages or the branch name one; then Haiku: a body only when the cause is subtleCommit denied
descriptionsSetting an MR/PR descriptionFixed-label blocks at column 0Call denied
landed branchA git pushThe branch's pushed head already sits in a protected branch, and the message names no new MRPush denied
every commit worksA git push of 2 to 15 commits no remote hasSonnet: no commit removes something a later one stops using, or uses something a later one adds; skipped when the message says the order is finePush denied

Protected branches come from a repo's own push policy file. Integration branches are the protected ones; with no policy, the remote's default branch and any of dev, develop, main, master that exist. Deleting a branch on origin needs no keyword when origin's head of it already sits in an integration branch. A bare #87 counts only in a repo with a remote; with no issue tracker, nothing is asked. Issues you typed bind only commits in the session's repo and its worktrees; a branch ending in its issue number (perf/mobile-lcp-89) lets the message end with that one instead.

git-cleanup

Merged is not shipped: a branch is cleaned up and its issue filled in only once the pipeline holding the merge has passed. Closing the issue is left to you.

CheckRuns onNeedsThen
merged firstRemoving a worktree or branch, local or on originThe branch sits in an integration branch, a merged PR/MR has it as source branch, or the current turn's message says it merged (or to abandon it)Call denied
pipeline firstRemoving a worktree or branch, local or on origin; rewriting an issue's body (checklist ticks, How to test)The work (the branch, or the newest integration commit naming the issue) landed and a pipeline holding it passed; skipped when the message says not to waitCall denied while it runs or after it failed
stale workThe end of a turnA branch the session committed to or pushed that sits in an integration branch, its worktree clean, no pipeline holding it still running or failedFollow-up prompt to remove the worktree and the branch, local and on origin, once checked

Integration branches are found as git-gates finds them. A branch sits in an integration branch when its head does, or when every commit of it has a copy there (a rebase merge). Pipelines are read with gh on GitHub and, on GitLab, with the gitlab_token option: a read_api token, asked when the plugin is enabled, kept in the keychain on macOS and in ~/.claude/.credentials.json elsewhere. With no token or no pipeline holding the work, the pipeline check holds nothing back and a log line says why; merged first still applies, and on GitLab sees a squash merge only with the token.

lean-docs

CheckRuns onFlagsThen
docs-reviewA doc grown in a git checkoutHaiku: text nobody reads after the task (runbooks, setup pages, narration)Claude gets the reason
docs-no-repeat-codeA doc line being writtenIdentifiers that already appear together in one code fileWrite denied
limit-docsThe end of a turnNew or grown docs, prose outweighing code, doc lines repeating codeFollow-up prompt

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

lean-comments

No comments by default: keep the ones that record a measured number, a trap or an invariant, cut the ones that restate the code or narrate the change.

CheckRuns onFlagsThen
limit-editsA Write or EditMore than 3 added comment lines, or a comment-heavy region around the editClaude gets the guidance
limit-turnsThe end of a turnMore than 3 new comment lines per file in the turn's diffFollow-up prompt

No check reads a file installed under ~/.agents/skills, ~/.claude/skills or ~/.claude/plugins: it is someone else's code. A link from there into a checkout is followed, and the file is checked.

lean-scripts

CheckRuns onFlagsThen
scripts-reviewA script written or grown in a git checkoutHaiku: scripts you could just type again when neededClaude gets the reason

Install mods

Mods load only with function hooks enabled, so export this in your shell profile first:

export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1

Without it Claude Code skips the mods silently. Then, in Claude Code:

/plugin marketplace add bahaospanov/skills
/plugin install <mod>@bahaospanov

Develop

One folder per mod. tsconfig.json and types/ are shared. An installed mod carries only its own folder, so code two mods share is copied into each one's hooks/shared/.

CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir ./<mod> --debug

Saving a file under <mod>/hooks/ reloads the mod. Repeat --plugin-dir to load several.

Check

npm run typecheck                 # tsc over every mod and its tests
npm run check:shared              # hooks/shared/ copies are identical across mods
npm run check:version             # every plugin.json carries package.json's version
claude plugin validate ./<mod>    # what the engine sees the module hook and call
claude plugin test ./<mod>        # the mod's tests/

Types

types/ is written by /plugin-types types, run inside a session started as above. Regenerate, never edit, when:

  • Claude Code updates (head -1 types/claude-code.d.ts vs claude --version)
  • a plugin that adds to $ is enabled or disabled
  • an MCP server is connected or disconnected

Commit the result; git diff types/ shows what the update changed.

Source 11 files
hooks/register.ts 427 lines
1import 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}
427
hooks/commit-message.ts 43 lines
1const 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}
43
hooks/commit-order.ts 62 lines
1// 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.`
62
hooks/consent.ts 91 lines
1const 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.`
91
hooks/grants.ts 42 lines
1import { 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}
42
hooks/landed-branch.ts 18 lines
1// 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.`
18
hooks/mr-description.ts 122 lines
1const 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}
122
hooks/prompts.ts 22 lines
1// `$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.`
22
hooks/shared/git-commands.ts 139 lines
1export 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}
139
hooks/shared/issue-refs.ts 57 lines
1const 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}
57
hooks/shared/verdict.ts 27 lines
1export const MODEL = 'claude-haiku-4-5-20251001'
2
3export const SYSTEM =
4  'You are a hook reviewer. Reply with one JSON object and nothing else: {"ok": true} or {"ok": false, "reason": "..."}.'
5
6export type Verdict = { ok: true } | { ok: false; reason: string }
7
8export type Review = { name: string; prompt: string; status: string }
9
10export const promptFor = (review: Review, input: object) => review.prompt.replace('$ARGUMENTS', () => JSON.stringify(input))
11
12export const verdictOf = (reply: string): Verdict | undefined => {
13  const json = reply.match(/\{[\s\S]*\}/)
14  if (!json) return undefined
15  let parsed: unknown
16  try {
17    parsed = JSON.parse(json[0])
18  } catch {
19    return undefined
20  }
21  if (typeof parsed !== 'object' || parsed === null || !('ok' in parsed) || typeof parsed.ok !== 'boolean') {
22    return undefined
23  }
24  if (parsed.ok) return { ok: true }
25  return { ok: false, reason: 'reason' in parsed && typeof parsed.reason === 'string' ? parsed.reason : '' }
26}
27