SLOPSHOPPER

git-tidy

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

newguardstatuspromptmodelprocess
v1.26.0MITupdated 2026-10-24bahaospanov/skills/git-tidy
A shopper browsing a rack in a slop shop
README

bahaospanov

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

Skills

For any agent the skills CLI supports:

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

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

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

Pick one: installing both leaves every skill twice.

User-invoked

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

  • release: Integration branch to production: fold fixup commits into their parent, cut waves around migrations and one-time steps, ship wave by wave with a runbook issue, then tag a SemVer release, note the shipped issues for you to close, and build a phone-sized release album.

Model-invoked

Model- or user-reachable.

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

Mods

Early access; the API changes between releases.

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

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

Haiku reviews are gated in code first, so a call that cannot fail the review costs no model call. End-of-turn checks read the git diff of repos the turn touched, Bash edits included, and show at most two one-line notices a session; the report reaches Claude with your next message and is acted on when you ask.

git-gates

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

CheckRuns onNeedsThen
consentgit commit, push, with or without git's own options (git -C dir push); opening a PR/MR; PR/MR mergecommit, push, ship, deploy, pr, mr, tag or release typed in the current turn (a message typed while it runs or a background task's report does not withdraw it); a push needs one of them other than commit; opening needs pr, mr, pull request, merge request or ship; merge needs "merge", and "merge request" is not it; a protected branch must be named. A negated keyword ("don't push") does not count, and a no to the act itself refuses it, standing grant included, unless the turn also asks for itCall denied
grantsLater commits in the sessionA message asking for a commit per task, or the grant tool after an authorizing messageCommits spend the grant; pushes never

Protected branches come from a repo's own push policy file. Integration branches are the protected ones; with no policy, the remote's default branch and any of dev, develop, main, master that exist. Deleting a branch on origin needs no keyword when origin's head of it already sits in an integration branch.

git-tidy

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

CheckRuns onNeedsThen
messagesA git commitConventional Commits subject, no Co-Authored-By trailer naming Claude or Anthropic (in the message or a --trailer flag) and no "Generated with Claude Code" lineCommit denied
message adviceA git commitNo reviewer pre-answers ("no X changed"); then Haiku: a body only when the cause is subtleCommit runs; the user gets the advice
fresh baseOpening an MR/PR: gh pr create, glab mr create, a GitLab API POST to merge_requestsThe source branch, as origin has it, contains the target's latest commit, fetched first; skipped when the message says not to rebaseCall denied
landed branchA git pushThe branch's pushed head already sits in a protected branch, and the message names no new MRPush denied
every commit worksA git push of 2 to 15 commits no remote hasSonnet: no commit removes something a later one stops using, or uses something a later one adds; skipped when the message says the order is finePush denied
one commit per changeA git push of new commits to a branch whose commits not yet in an integration branch number 2 to 15, pushed or notNo fixup!, squash! or amend! subject; then Sonnet: no later commit spends most of its diff fixing, reworking or extending lines an earlier one added, or matches an earlier one's API contract change in another app; skipped when the message says to keep the commitsPush held: the agent shows the user the reason and asks; any answer lets the same commits through
one feature per MRA git push of new commits to a branch whose commits not yet in an integration branch number 1 to 15, pushed or notIts commit messages name at most one issue; then Sonnet: no piece of it could ship alone apart from the rest; skipped when the message says one MR or to keep them togetherPush held: the agent shows the user the features, recommends a branch and MR each, and asks; any answer lets the branch through for the session

Integration branches are found as git-gates finds them.

git-cleanup

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

CheckRuns onNeedsThen
merged firstRemoving a branch, local or on origin; removing a worktree keeps its branch and is not heldThe branch sits in an integration branch, a merged PR/MR has it as source branch, or the current turn's message says it merged (or to abandon it)Call denied
pipeline firstRemoving a worktree or branch, local or on origin; rewriting an issue's body (checklist ticks, How to test)The work (the branch, the commit a squash merge landed as, or the newest integration commit naming the issue) landed and a pipeline holding it passed; skipped when the message says not to waitCall denied while it runs or after it failed; a GitLab pipeline parked at manual or skipped holds nothing back
stale workThe end of a turnA branch the session committed to or pushed that sits in an integration branch, its worktree clean, no pipeline holding it still running or failedRemoves the worktree and the branch, local and on origin, fast-forwards a local integration branch behind origin, and prints one line saying what went; the worktree the session works in stays

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

issue-first

CheckRuns onNeedsThen
feature issueA feat commitAn issue (#107, #BLK-23) at the end of the subject, or in the branch name (feat/catalog-menu-107), or "no issue" typed by the user in the current turnCommit denied; Claude looks for the issue, else drafts one and asks the user before creating it
issue numberA git commitThe issue or ticket your messages or the branch name, at the end of the subjectCommit runs; the user gets the suggestion

A repo with no remote has no issue tracker: feature issue is skipped, and only a ticket (#BLK-23) counts as an issue. Issues you typed bind only commits in the session's repo and its worktrees; a branch ending in its issue number (perf/mobile-lcp-89) lets the subject end with that one instead. Enabled in a repo's own .claude/settings.json, it applies to everyone working there.

lean-docs

CheckRuns onFlagsThen
docs-reviewA doc grown in a git checkoutHaiku: text nobody reads after the task (runbooks, setup pages, narration)Claude gets the reason
docs-no-repeat-codeA doc line being writtenIdentifiers that already appear together in one code fileClaude gets the hint; the write goes ahead
limit-docsThe end of a turnNew or grown docs, prose outweighing code, doc lines repeating codeA one-line notice; say "trim docs" to act on it

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

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

lean-comments

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

CheckRuns onFlagsThen
limit-editsA Write or EditMore than 3 added comment lines, or a comment-heavy region around an edit that adds commentsClaude gets the guidance
limit-turnsThe end of a turnMore than 3 new comment lines per file in the turn's diffA one-line notice; say "trim comments" to act on it

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

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

lean-scripts

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

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

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

close-countdown

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

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

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

traffic-light

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

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

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

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

Install mods

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

export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1

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

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

Develop

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

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

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

Check

npm run typecheck                 # tsc over every mod and its tests
npm run check:shared              # hooks/shared/ copies are identical across mods
npm run check:continuations       # every mod sending follow-up prompts is named where git-gates reads them
npm run check:version             # every plugin.json carries package.json's version
claude plugin validate ./<mod>    # what the engine sees the module hook and call
claude plugin test ./<mod>        # the mod's tests/

Types

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

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

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

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