Docs worth keeping: docs-review asks Haiku whether new text will be read again, docs-no-repeat-code blocks lines that restate code, limit-docs sends a…

Bakhtiyar Ospanov's agent skills, and Claude Code mods: plugins built on function hooks.
For any agent the skills CLI supports:
npx skills add bahaospanov/skills --skill <skill>
Or in Claude Code, all of them as one plugin, invoked as /bahaospanov-skills:<skill>:
/plugin marketplace add bahaospanov/skills
/plugin install bahaospanov-skills@bahaospanov
Pick one: installing both leaves every skill twice.
Reachable only when you type them (Claude Code: disable-model-invocation: true; Codex: policy.allow_implicit_invocation: false in agents/openai.yaml).
Model- or user-reachable.
prototype (MIT), its UI branch reworked: whole flows, options grouped by stage in a one-click panel.Early access; the API changes between releases.
One mod per purpose.
| Mod | Purpose |
|---|---|
| git-gates | Git work is authorized and tidy |
| git-cleanup | Merged work is cleaned up once it shipped |
| lean-docs | Docs worth keeping |
| lean-comments | Comments worth keeping |
| lean-scripts | Scripts worth keeping |
Haiku reviews are gated in code first, so a call that cannot fail the review costs no model call. End-of-turn checks read the git diff of repos the turn touched, Bash edits included, and send at most two follow-up prompts a session.
Pushing is a deploy, so the agent needs the user's word in the current turn: the prompt that opened it or one typed while it ran. If a consent check itself fails, the call is blocked.
| Check | Runs on | Needs | Then |
|---|---|---|---|
| consent | git commit, push; PR/MR merge | commit, push, ship, deploy, pr, mr, tag or release typed in the current turn (a message typed while it runs or a background task's report does not withdraw it); merge needs "merge"; a protected branch must be named | Call denied |
| grants | Later commits in the session | A message asking for a commit per task, or the grant tool after an authorizing message | Commits spend the grant; pushes never |
| messages | A git commit | Conventional Commits subject, no reviewer pre-answers, a last line with the issue or ticket (#87, #BLK-23) when your messages or the branch name one; then Haiku: a body only when the cause is subtle | Commit denied |
| descriptions | Setting an MR/PR description | Fixed-label blocks at column 0 | Call denied |
| landed branch | A git push | The branch's pushed head already sits in a protected branch, and the message names no new MR | Push denied |
| every commit works | A git push of 2 to 15 commits no remote has | Sonnet: no commit removes something a later one stops using, or uses something a later one adds; skipped when the message says the order is fine | Push denied |
Protected branches come from a repo's own push policy file. Integration branches are the protected ones; with no policy, the remote's default branch and any of dev, develop, main, master that exist. Deleting a branch on origin needs no keyword when origin's head of it already sits in an integration branch. A bare #87 counts only in a repo with a remote; with no issue tracker, nothing is asked. Issues you typed bind only commits in the session's repo and its worktrees; a branch ending in its issue number (perf/mobile-lcp-89) lets the message end with that one instead.
Merged is not shipped: a branch is cleaned up and its issue filled in only once the pipeline holding the merge has passed. Closing the issue is left to you.
| Check | Runs on | Needs | Then |
|---|---|---|---|
| merged first | Removing a worktree or branch, local or on origin | The branch sits in an integration branch, a merged PR/MR has it as source branch, or the current turn's message says it merged (or to abandon it) | Call denied |
| pipeline first | Removing a worktree or branch, local or on origin; rewriting an issue's body (checklist ticks, How to test) | The work (the branch, or the newest integration commit naming the issue) landed and a pipeline holding it passed; skipped when the message says not to wait | Call denied while it runs or after it failed |
| stale work | The end of a turn | A branch the session committed to or pushed that sits in an integration branch, its worktree clean, no pipeline holding it still running or failed | Follow-up prompt to remove the worktree and the branch, local and on origin, once checked |
Integration branches are found as git-gates finds them. A branch sits in an integration branch when its head does, or when every commit of it has a copy there (a rebase merge). Pipelines are read with gh on GitHub and, on GitLab, with the gitlab_token option: a read_api token, asked when the plugin is enabled, kept in the keychain on macOS and in ~/.claude/.credentials.json elsewhere. With no token or no pipeline holding the work, the pipeline check holds nothing back and a log line says why; merged first still applies, and on GitLab sees a squash merge only with the token.
| Check | Runs on | Flags | Then |
|---|---|---|---|
| docs-review | A doc grown in a git checkout | Haiku: text nobody reads after the task (runbooks, setup pages, narration) | Claude gets the reason |
| docs-no-repeat-code | A doc line being written | Identifiers that already appear together in one code file | Write denied |
| limit-docs | The end of a turn | New or grown docs, prose outweighing code, doc lines repeating code | Follow-up prompt |
No check reads a skill's folder, the one holding SKILL.md, or anything under it: a skill is read again on every use.
No comments by default: keep the ones that record a measured number, a trap or an invariant, cut the ones that restate the code or narrate the change.
| Check | Runs on | Flags | Then |
|---|---|---|---|
| limit-edits | A Write or Edit | More than 3 added comment lines, or a comment-heavy region around the edit | Claude gets the guidance |
| limit-turns | The end of a turn | More than 3 new comment lines per file in the turn's diff | Follow-up prompt |
No check reads a file installed under ~/.agents/skills, ~/.claude/skills or ~/.claude/plugins: it is someone else's code. A link from there into a checkout is followed, and the file is checked.
| Check | Runs on | Flags | Then |
|---|---|---|---|
| scripts-review | A script written or grown in a git checkout | Haiku: scripts you could just type again when needed | Claude gets the reason |
Mods load only with function hooks enabled, so export this in your shell profile first:
export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1
Without it Claude Code skips the mods silently. Then, in Claude Code:
/plugin marketplace add bahaospanov/skills
/plugin install <mod>@bahaospanov
One folder per mod. tsconfig.json and types/ are shared. An installed mod carries only its own folder, so code two mods share is copied into each one's hooks/shared/.
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir ./<mod> --debug
Saving a file under <mod>/hooks/ reloads the mod. Repeat --plugin-dir to load several.
npm run typecheck # tsc over every mod and its tests
npm run check:shared # hooks/shared/ copies are identical across mods
npm run check:version # every plugin.json carries package.json's version
claude plugin validate ./<mod> # what the engine sees the module hook and call
claude plugin test ./<mod> # the mod's tests/
types/ is written by /plugin-types types, run inside a session started as above. Regenerate, never edit, when:
head -1 types/claude-code.d.ts vs claude --version)$ is enabled or disabledCommit the result; git diff types/ shows what the update changed.
hooks/register.ts 212 lines1import type { EngineInterface, Register } from 'claude-code'
2import { addedDocLines, docBudgetOfDiff, docNotesOf, docPathsOf, MAX_BLOCKS, repeatMessage, SKILL, tokensOf, turnReport, type RepeatHint } from './docs'
3import { DOCUMENTATION } from './prompts'
4import { authorsHistory } from './shared/anchor'
5import { baseName, claimedWorktrees, isDoc, nonBlankCount, splitLines, worktreesOf } from './shared/diff'
6import { dirOf, isDotfileOrTemp, isTrim } from './shared/paths'
7import { MODEL, promptFor, SYSTEM, verdictOf, type Review, type Verdict } from './shared/verdict'
8
9const DOCS_REVIEW: Review = { name: 'docs-review', prompt: DOCUMENTATION, status: 'judging docs' }
10
11type Owned = { head: string; seen: string; status: string; touched: boolean }
12
13let worktrees: string[] | undefined
14const owned = new Map<string, Owned>()
15const reported = new Set<string>()
16let blocks = 0
17
18const git = async ($: EngineInterface, cwd: string | undefined, args: string[]) => {
19 const run = await $.process.run(cwd === undefined ? ['git', ...args] : ['git', '-C', cwd, ...args])
20 return run.exitCode === 0 ? run.stdout.trim() : undefined
21}
22
23const filesStating = async ($: EngineInterface, root: string, tokens: string[]) => {
24 const sets: Set<string>[] = []
25 for (const token of tokens.slice(0, 4)) {
26 const listing = (await git($, root, ['grep', '-l', '-F', '--', token])) ?? ''
27 sets.push(new Set(splitLines(listing).filter((file) => !isDoc(file))))
28 }
29 const [first, ...rest] = sets
30 return first === undefined ? [] : [...first].filter((file) => rest.every((set) => set.has(file))).sort()
31}
32
33const statusOf = async ($: EngineInterface, wt: string) => (await git($, wt, ['status', '--porcelain'])) ?? ''
34
35const claim = async ($: EngineInterface, blob: string) => {
36 if (!blob) return
37 if (worktrees === undefined) {
38 const root = await git($, undefined, ['rev-parse', '--show-toplevel'])
39 worktrees = root ? worktreesOf((await git($, root, ['worktree', 'list', '--porcelain'])) ?? '') : []
40 }
41 for (const wt of claimedWorktrees(blob, worktrees)) {
42 if (owned.has(wt)) continue
43 const head = await git($, wt, ['rev-parse', 'HEAD'])
44 if (head) owned.set(wt, { head, seen: head, status: await statusOf($, wt), touched: false })
45 }
46}
47
48// Between two of our own calls another agent sharing the checkout can commit, fast-forward or reset it, and every line
49// arriving that way would otherwise read as this turn's work.
50const settle = async ($: EngineInterface, command?: string) => {
51 for (const [wt, info] of owned) {
52 let next = info
53 const head = await git($, wt, ['rev-parse', 'HEAD'])
54 if (head !== undefined && head !== next.seen) {
55 next =
56 command !== undefined && authorsHistory(command)
57 ? { ...next, seen: head }
58 : { ...next, head, seen: head, status: await statusOf($, wt) }
59 }
60 if (!next.touched) {
61 const status = await statusOf($, wt)
62 if (status !== next.status) next = { ...next, status, touched: true }
63 }
64 owned.set(wt, next)
65 }
66}
67
68const markTouched = (path: string) => {
69 for (const [wt, info] of owned) {
70 if (!info.touched && path.startsWith(`${wt}/`)) owned.set(wt, { ...info, touched: true })
71 }
72}
73
74// A skill is instructions read on every use, never a one-time page. The SKILL.md being created does not exist yet when
75// the pre-write check runs, so it is matched by name; the walk stops at the checkout's root.
76const inSkillFolder = async ($: EngineInterface, path: string, root: string) => {
77 if (baseName(path) === SKILL) return true
78 for (let dir = dirOf(path); dir === root || dir.startsWith(`${root}/`); dir = dirOf(dir)) {
79 if (await $.fs.exists(`${dir}/${SKILL}`)) return true
80 if (dir === root) break
81 }
82 return false
83}
84
85const repeatedFact = async ($: EngineInterface, path: string, added: string, old: string) => {
86 if (!isDoc(path) || nonBlankCount(added) <= nonBlankCount(old)) return undefined
87 const root = await git($, dirOf(path), ['rev-parse', '--show-toplevel'])
88 if (!root || (await inSkillFolder($, path, root))) return undefined
89 for (const line of splitLines(added)) {
90 if (old.includes(line)) continue
91 const tokens = tokensOf(line)
92 if (tokens.length < 2) continue
93 const common = await filesStating($, root, tokens)
94 if (common.length > 0) return repeatMessage(tokens, common.slice(0, 2).join(', '), line)
95 }
96 return undefined
97}
98
99// The loader follows $ only into functions of this file, so the model call lives here, not in shared/verdict.ts.
100const judge = async ($: EngineInterface, review: Review, input: object): Promise<Verdict | undefined> => {
101 $.ui.status(review.status)
102 try {
103 const result = await $.model.complete({ model: MODEL, system: SYSTEM, prompt: promptFor(review, input) })
104 const reply = result.isAnswered ? result.text : `(${result.reason})`
105 const verdict = verdictOf(reply)
106 if (verdict === undefined) $.ui.log(`lean-docs/${review.name}: no verdict: ${reply.slice(0, 120)}`)
107 return verdict
108 } finally {
109 $.ui.status(undefined)
110 }
111}
112
113const docNotes = async ($: EngineInterface, wt: string, base: string) => {
114 const diff = (await git($, wt, ['diff', '--unified=0', base])) ?? ''
115 const untracked = splitLines((await git($, wt, ['ls-files', '--others', '--exclude-standard'])) ?? '').filter(isDoc)
116 const skills = new Set<string>()
117 for (const rel of new Set([...docPathsOf(diff), ...untracked])) {
118 if (await inSkillFolder($, `${wt}/${rel}`, wt)) skills.add(rel)
119 }
120 const { perDoc, code, newDocs } = docBudgetOfDiff(diff, skills)
121 for (const rel of untracked) {
122 if (skills.has(rel)) continue
123 newDocs.add(rel)
124 const text = await $.fs.read(`${wt}/${rel}`).catch(() => undefined)
125 if (text !== undefined) perDoc[rel] = (perDoc[rel] ?? 0) + nonBlankCount(text)
126 }
127 const hints: RepeatHint[] = []
128 for (const { path, body } of addedDocLines(diff, skills)) {
129 if (hints.length >= 4) break
130 const tokens = tokensOf(body)
131 if (tokens.length < 2) continue
132 const common = await filesStating($, wt, tokens)
133 if (common.length > 0) hints.push({ path, where: common.slice(0, 2).join(', '), tokens: tokens.slice(0, 3) })
134 }
135 return docNotesOf(perDoc, code, newDocs, hints)
136}
137
138const overBudget = async ($: EngineInterface) => {
139 const notes: string[] = []
140 const bases: string[] = []
141 for (const [wt, info] of owned) {
142 if (!info.touched) continue
143 const found = await docNotes($, wt, info.head)
144 if (found.length > 0) bases.push(info.head)
145 notes.push(...found)
146 }
147 if (notes.length === 0) return undefined
148 const key = JSON.stringify([...notes].sort())
149 if (reported.has(key) || blocks >= MAX_BLOCKS) return undefined
150 reported.add(key)
151 blocks++
152 return turnReport(notes, bases)
153}
154
155export const register: Register = (on) => {
156 on('tool.call', { tool: ['Bash', 'Write', 'Edit'] }, async ($, e, next) => {
157 await claim($, e.tool === 'Bash' ? e.command : e.file_path)
158 const result = await next(e)
159 if (e.tool === 'Bash') await settle($, e.command)
160 else if (result.deny === undefined && !result.isError) markTouched(e.file_path)
161 return result
162 })
163
164 on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
165 const added = e.tool === 'Write' ? e.content : e.new_string
166 const old = e.tool === 'Write' ? await $.fs.read(e.file_path).catch(() => '') : e.old_string
167 const reason = await repeatedFact($, e.file_path, added, old)
168 return reason === undefined ? next(e) : { deny: reason }
169 })
170
171 on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
172 const path = e.file_path
173 if (!isDoc(path) || isDotfileOrTemp(path, await $.env.get('HOME'), await $.env.get('TMPDIR'))) return next(e)
174 const replaced = e.tool === 'Write' ? await $.fs.read(path).catch(() => '') : ''
175 const result = await next(e)
176 if (result.deny !== undefined || result.isError) return result
177 const added = e.tool === 'Write' ? e.content : e.new_string
178 const removed = e.tool === 'Write' ? replaced : e.old_string
179 if (isTrim(added, removed)) return result
180 const root = await git($, dirOf(path), ['rev-parse', '--show-toplevel'])
181 if (root === undefined || (await inSkillFolder($, path, root))) return result
182
183 const tool_input =
184 e.tool === 'Write'
185 ? { file_path: path, content: e.content }
186 : { file_path: path, old_string: e.old_string, new_string: e.new_string, replace_all: e.replace_all }
187 const verdict = await judge($, DOCS_REVIEW, {
188 hook_event_name: 'PostToolUse',
189 tool_name: e.tool,
190 tool_input,
191 cwd: await $.session.cwd(),
192 })
193 if (verdict?.ok !== false) return result
194 $.ui.log(`lean-docs/docs-review: ${verdict.reason}`)
195 return { ...result, context: [...(result.context ?? []), `lean-docs/docs-review: ${verdict.reason}`] }
196 })
197
198 on('turn.complete', async ($, e, next) => {
199 const result = await next(e)
200 if (e.agentId !== undefined || e.reason !== 'answer' || owned.size === 0) return result
201 await settle($)
202 const report = await overBudget($)
203 if (report !== undefined) {
204 $.ui.log("lean-docs/limit-docs: this turn's docs are over budget; a follow-up prompt asks to cut")
205 $.clock.after(0, () => {
206 $.prompt.submit({ text: report }).catch(() => undefined)
207 })
208 }
209 return result
210 })
211}
212hooks/docs.ts 92 lines1import { baseNote } from './shared/anchor'
2import { addedLines, isDoc, OUTRANKS, prefixes, startsWithAny } from './shared/diff'
3
4export const DOC_RATIO = 2.0
5export const DOC_FLOOR = 40
6export const DOC_BLOCK = 2
7export const MAX_BLOCKS = 2
8
9const TOKEN = /`([^`\n]{3,60})`/g
10
11export const DOC_ASK =
12 'A document earns a file only if it stays useful AFTER the task is done.\n' +
13 'The test: could a human execute this in one sitting, with you guiding them ' +
14 'live? Then it is a conversation, not a document - guide them and write ' +
15 'nothing. Once the task is done such a page is dead weight: it clogs the ' +
16 'repo and every future context window, and it rots because nobody runs it ' +
17 'again to notice it is wrong.\n' +
18 'EARNS a file: something run repeatedly; something needed when you are NOT ' +
19 'there (recovery, on-call, onboarding); a durable why that outlives the ' +
20 'change.\n' +
21 'DOES NOT: a one-time cutover or migration you are about to run together; a ' +
22 'narration of work just completed; a procedure whose only reader is the ' +
23 'person you are already talking to.\n' +
24 'Second test, applied to every added line: does the code, a config file, or ' +
25 'another page ALREADY say this? Prose that repeats a fact is worse than no ' +
26 'prose - it is another copy to keep in sync, and it is the copy that will ' +
27 'drift and start lying. Point at the existing source instead of restating ' +
28 'it, or add nothing.'
29
30export type RepeatHint = { path: string; where: string; tokens: string[] }
31
32export const SKILL = 'SKILL.md'
33
34export const docPathsOf = (diff: string) => [...new Set(addedLines(diff).map(({ path }) => path).filter(isDoc))]
35
36export const docBudgetOfDiff = (diff: string, skip: ReadonlySet<string> = new Set()) => {
37 const perDoc: Record<string, number> = {}
38 const newDocs = new Set<string>()
39 let code = 0
40 for (const { path, body, isNewFile } of addedLines(diff)) {
41 if (skip.has(path)) continue
42 if (isNewFile && isDoc(path)) newDocs.add(path)
43 const stripped = body.trim()
44 if (stripped === '') continue
45 if (isDoc(path)) {
46 perDoc[path] = (perDoc[path] ?? 0) + 1
47 continue
48 }
49 const marks = prefixes(path)
50 if (!marks || !startsWithAny(stripped, marks)) code++
51 }
52 return { perDoc, code, newDocs }
53}
54
55export const tokensOf = (line: string) => [...line.matchAll(TOKEN)].map((m) => m[1] ?? '').filter((t) => !t.includes(' '))
56
57export const addedDocLines = (diff: string, skip: ReadonlySet<string> = new Set()) =>
58 addedLines(diff).filter(({ path }) => isDoc(path) && !skip.has(path)).map(({ path, body }) => ({ path, body }))
59
60export const repeatMessage = (tokens: string[], where: string, line: string) =>
61 `lean-docs/docs-no-repeat-code: this line repeats ${tokens.slice(0, 3).join('/')}, already stated in ${where}:\n ${line.trim().slice(0, 160)}\n` +
62 'A duplicated fact is a second copy to keep in sync, and it is ' +
63 'the copy that drifts and starts lying. Point at the source or add nothing.'
64
65export const docNotesOf = (perDoc: Record<string, number>, code: number, newDocs: Set<string>, hints: RepeatHint[]) => {
66 const notes: string[] = []
67 for (const path of Object.keys(perDoc).sort()) {
68 const n = perDoc[path] ?? 0
69 if (newDocs.has(path)) notes.push(`NEW document ${path} (${n} lines)`)
70 else if (n > DOC_BLOCK) notes.push(`${path} grew by ${n} lines`)
71 }
72 for (const hint of hints.slice(0, 4)) {
73 notes.push(`${hint.path} repeats ${hint.tokens.join('/')} - already stated in ${hint.where}; point at it instead of copying it`)
74 }
75 const docs = Object.values(perDoc).reduce((sum, n) => sum + n, 0)
76 if (docs >= DOC_FLOOR && docs > DOC_RATIO * Math.max(code, 1)) {
77 notes.push(`${docs} lines of prose against ${code} of code (${(docs / Math.max(code, 1)).toFixed(1)}:1)`)
78 }
79 return notes
80}
81
82export const turnReport = (notes: string[], bases: readonly string[] = []) => {
83 const note = baseNote(bases)
84 return (
85 `lean-docs/limit-docs: prose outweighs the change.\n ${notes.join('\n ')}\n` +
86 `${note === undefined ? '' : `${note}\n`}${DOC_ASK}\n\n` +
87 'This check reads git diff, so it sees edits made through Bash, sed and ' +
88 `heredocs that the per-write checks never see.\n${OUTRANKS}\n` +
89 'Cut what does not earn its place, then say what you kept and why.'
90 )
91}
92hooks/prompts.ts 13 lines1// Moved verbatim from the `prompt` hook in ~/.claude/settings.json; `$ARGUMENTS` is the hook input as JSON.
2
3export const DOCUMENTATION = `Reviewer for NEW OR GROWN DOCUMENTATION. $ARGUMENTS
4GATE FIRST, and this decides most calls. Judge ONLY when tool_input.file_path ends in .md, .markdown or .rst AND the path is inside a project checkout. Anything else - source code of any language, config, a path under a home-directory dotfile tree, a temp or scratch directory - return ok=true with NO reason and nothing else. Never judge code, never judge comments, never judge whether an edit should have been made.
5FIRST, decide whether this is a TRIM. If the edit removes more than it adds - an Edit whose new_string is empty or shorter than its old_string, or a Write whose content is shorter than the file already on disk - return ok=true with NO reason and nothing else. Removing documentation is always allowed. You judge what gets WRITTEN, never what gets removed, and never whether a removal was wise. A rewrap, a reshuffle or a reworded paragraph that leaves the file shorter is a trim, not a new page.
6Judge the ADDED text (new_string, or content for a Write) on one question: after the work it describes is finished, will anyone read it again? Judge an addition to an existing page as strictly as a new page.
7NO -> ok=false, reason: 'one-time procedure - guide them live; this page rots the moment the task ends'.
8YES -> ok=true. YES means run repeatedly, needed when you are absent (recovery, on-call, onboarding), or a durable why that outlives the change.
9Judge a page by the reason it EXISTS, not by its best paragraph. Setup and enablement pages - create the account, paste the credential, add the CI variable, verify it worked - are ok=false even when they also carry two or three durable facts, and even when every line is individually accurate. That mixture is the usual way a one-time procedure argues its way in. The durable facts belong where they are read: a comment at the line that would break, or the decision log. Turning something on happens once, so 'how to set up X' is never a page; 'why X is deliberately off for Y' can be a line somewhere permanent.
10Examples. ok=false: a cutover or migration runbook for a change being made now; a section narrating what was just done or measured. ok=true: a disaster-recovery procedure; a why that stops a future reader from 'cleaning up' something load-bearing.
11Also ok=false if the text restates what the code, a config file, an error string, or another page already says; if a reader could learn it from the diff; or if it records the OBVIOUS thing to do (a password on a datastore, a lock around read-modify-write, a version pin).
12Reason: quote the offending lines. Under 60 words, no preamble.`
13hooks/shared/anchor.ts 13 lines1// A checkout is shared: HEAD moving is this session's work only when one of its own commands wrote the commit.
2// A fast-forward by another agent, a reset or a rebase carries history this session never authored.
3const AUTHORS_HISTORY = /\bgit\b[^;&|]*\b(?:commit|merge|am|cherry-pick|revert)\b/
4
5export const authorsHistory = (command: string) => AUTHORS_HISTORY.test(command)
6
7export const shortSha = (sha: string) => sha.slice(0, 7)
8
9export const baseNote = (bases: readonly string[]) => {
10 const shown = [...new Set(bases.map(shortSha))]
11 return shown.length === 0 ? undefined : `Diff base: ${shown.join(', ')} - the HEAD the checkout carried when this session first touched it.`
12}
13hooks/shared/diff.ts 87 lines1const SKIP_EXT = ['.md', '.markdown', '.rst', '.txt', '.json', '.lock']
2const DOC_EXT = ['.md', '.markdown', '.rst']
3const HASH_EXT = [
4 '.sh', '.bash', '.zsh', '.fish', '.py', '.rb', '.pl', '.r',
5 '.yml', '.yaml', '.toml', '.ini', '.cfg', '.conf', '.tf', '.tfvars',
6 '.gitignore', '.dockerignore', '.env', '.example', '.properties',
7]
8const HASH_BASE = ['dockerfile', 'makefile', 'justfile', 'rakefile', 'gemfile', 'procfile']
9const SLASH_EXT = [
10 '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.go', '.java', '.c', '.h',
11 '.cc', '.cpp', '.hpp', '.cs', '.rs', '.swift', '.kt', '.kts', '.scala',
12 '.php', '.scss', '.less', '.css', '.dart', '.proto', '.gradle',
13]
14const MARKUP_EXT = ['.html', '.htm', '.vue', '.svelte', '.xml', '.svg']
15const DASH_EXT = ['.sql', '.lua', '.hs', '.elm']
16
17export const OUTRANKS =
18 "This outranks matching the file's existing comment density, and it " +
19 'outranks any skill or template instructing you to add a header or ' +
20 'rationale block.'
21
22export const baseName = (path: string) => path.slice(path.lastIndexOf('/') + 1)
23
24// As Python's os.path.splitext: leading dots are not an extension, so `.gitignore` has none.
25export const extOf = (path: string) => {
26 const rest = baseName(path).replace(/^\.+/, '')
27 const dot = rest.lastIndexOf('.')
28 return dot > 0 ? rest.slice(dot).toLowerCase() : ''
29}
30
31export const isDoc = (path: string) => DOC_EXT.includes(extOf(path))
32
33export const splitLines = (text: string) => {
34 const lines = text.split(/\r\n|\r|\n/)
35 if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop()
36 return lines
37}
38
39export const nonBlankCount = (text: string) => splitLines(text).filter((line) => line.trim() !== '').length
40
41export const prefixes = (path: string): string[] | undefined => {
42 const base = baseName(path).toLowerCase()
43 const ext = extOf(path)
44 if (SKIP_EXT.includes(ext)) return undefined
45 if (base.startsWith('dockerfile') || HASH_BASE.includes(base)) return ['#']
46 const marks: string[] = []
47 if (HASH_EXT.includes(ext)) marks.push('#')
48 if (SLASH_EXT.includes(ext)) marks.push('//', '/*', '*/', '*')
49 if (MARKUP_EXT.includes(ext)) marks.push('<!--', '-->', '//', '/*', '*/', '*')
50 if (DASH_EXT.includes(ext)) marks.push('--')
51 return marks.length > 0 ? marks : undefined
52}
53
54export const startsWithAny = (line: string, marks: readonly string[]) => marks.some((mark) => line.startsWith(mark))
55
56export type DiffLine = { path: string; body: string; isNewFile: boolean }
57
58export const addedLines = (diff: string): DiffLine[] => {
59 const out: DiffLine[] = []
60 let path: string | undefined
61 let isNewFile = false
62 for (const line of splitLines(diff)) {
63 if (line.startsWith('--- ')) {
64 isNewFile = line.slice(4).trim() === '/dev/null'
65 continue
66 }
67 if (line.startsWith('+++ ')) {
68 const raw = line.slice(4).trim()
69 path = raw === '/dev/null' ? undefined : raw.slice(2)
70 continue
71 }
72 if (path === undefined || !line.startsWith('+') || line.startsWith('+++')) continue
73 out.push({ path, body: line.slice(1), isNewFile })
74 }
75 return out
76}
77
78export const worktreesOf = (porcelain: string) =>
79 splitLines(porcelain)
80 .filter((line) => line.startsWith('worktree '))
81 .map((line) => line.slice('worktree '.length))
82
83export const claimedWorktrees = (blob: string, known: string[]) => {
84 const hits = known.filter((wt) => blob.includes(wt))
85 return hits.filter((wt) => !hits.some((other) => other !== wt && other.startsWith(wt)))
86}
87hooks/shared/paths.ts 14 lines1const TEMP_ROOTS = ['/tmp/', '/private/tmp/', '/var/folders/', '/private/var/folders/']
2
3export const dirOf = (path: string) => {
4 const slash = path.lastIndexOf('/')
5 return slash > 0 ? path.slice(0, slash) : slash === 0 ? '/' : '.'
6}
7
8export const isDotfileOrTemp = (path: string, home: string | undefined, tmpdir: string | undefined) =>
9 (home !== undefined && path.startsWith(`${home}/.`)) ||
10 TEMP_ROOTS.some((root) => path.startsWith(root)) ||
11 (tmpdir !== undefined && tmpdir !== '' && path.startsWith(tmpdir))
12
13export const isTrim = (added: string, removed: string) => added.length < removed.length
14hooks/shared/verdict.ts 27 lines1export const MODEL = 'claude-haiku-4-5-20251001'
2
3export const SYSTEM =
4 'You are a hook reviewer. Reply with one JSON object and nothing else: {"ok": true} or {"ok": false, "reason": "..."}.'
5
6export type Verdict = { ok: true } | { ok: false; reason: string }
7
8export type Review = { name: string; prompt: string; status: string }
9
10export const promptFor = (review: Review, input: object) => review.prompt.replace('$ARGUMENTS', () => JSON.stringify(input))
11
12export const verdictOf = (reply: string): Verdict | undefined => {
13 const json = reply.match(/\{[\s\S]*\}/)
14 if (!json) return undefined
15 let parsed: unknown
16 try {
17 parsed = JSON.parse(json[0])
18 } catch {
19 return undefined
20 }
21 if (typeof parsed !== 'object' || parsed === null || !('ok' in parsed) || typeof parsed.ok !== 'boolean') {
22 return undefined
23 }
24 if (parsed.ok) return { ok: true }
25 return { ok: false, reason: 'reason' in parsed && typeof parsed.reason === 'string' ? parsed.reason : '' }
26}
27