Comments worth keeping: limit-edits flags comment-heavy edits, limit-turns sends a follow-up when a turn's diff adds too many comments.

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 160 lines1import type { EngineInterface, Register } from 'claude-code'
2import { commentsInDiff, commentsInFile, MAX_BLOCKS, reportKey, turnReport, type CommentFinding } from './comments'
3import { ADD_LIMIT } from './rules'
4import { authorsHistory } from './shared/anchor'
5import { baseName, claimedWorktrees, prefixes, splitLines, worktreesOf } from './shared/diff'
6import { addedSignal, densitySignal, editGuidance } from './signals'
7
8type Owned = { head: string; seen: string; baseline: Set<string>; status: string; touched: boolean }
9
10const MAX_UNTRACKED_CHARS = 512_000
11const INSTALL_ROOTS = ['.agents/skills', '.claude/skills', '.claude/plugins']
12
13let worktrees: string[] | undefined
14const owned = new Map<string, Owned>()
15const reported = new Set<string>()
16let blocks = 0
17
18// Built rather than typed: a literal NUL in this file makes git call it binary and stop diffing it.
19const SEP = String.fromCharCode(0)
20
21const keyOf = (path: string, line: string) => `${path}${SEP}${line}`
22
23const git = async ($: EngineInterface, cwd: string | undefined, args: string[]) => {
24 const run = await $.process.run(cwd === undefined ? ['git', ...args] : ['git', '-C', cwd, ...args])
25 return run.exitCode === 0 ? run.stdout.trim() : undefined
26}
27
28const addedComments = async ($: EngineInterface, wt: string, base: string) => {
29 const diff = await git($, wt, ['diff', '--unified=0', base])
30 const found = diff ? commentsInDiff(diff) : {}
31 for (const rel of splitLines((await git($, wt, ['ls-files', '--others', '--exclude-standard'])) ?? '')) {
32 if (!prefixes(rel)) continue
33 const text = await $.fs.read(`${wt}/${rel}`).catch(() => undefined)
34 if (text === undefined || text.length > MAX_UNTRACKED_CHARS) continue
35 const comments = commentsInFile(rel, text)
36 if (comments.length > 0) (found[rel] ??= []).push(...comments)
37 }
38 return found
39}
40
41// Decided on where the file lands: ~/.claude/skills/<name> may link back into a source checkout, which stays checked.
42const isInstalled = async ($: EngineInterface, path: string) => {
43 const home = await $.env.get('HOME')
44 if (!home) return false
45 const real = (await $.fs.stat(path, { resolve: true }).catch(() => undefined))?.realPath ?? path
46 return INSTALL_ROOTS.some((root) => real.startsWith(`${home}/${root}/`))
47}
48
49const statusOf = async ($: EngineInterface, wt: string) => (await git($, wt, ['status', '--porcelain'])) ?? ''
50
51const anchor = async ($: EngineInterface, wt: string, head: string) => {
52 const baseline = new Set<string>()
53 for (const [path, lines] of Object.entries(await addedComments($, wt, head))) {
54 for (const line of lines) baseline.add(keyOf(path, line))
55 }
56 return { head, seen: head, baseline, status: await statusOf($, wt) }
57}
58
59const claim = async ($: EngineInterface, blob: string) => {
60 if (!blob) return
61 if (worktrees === undefined) {
62 const root = await git($, undefined, ['rev-parse', '--show-toplevel'])
63 worktrees = root ? worktreesOf((await git($, root, ['worktree', 'list', '--porcelain'])) ?? '') : []
64 }
65 for (const wt of claimedWorktrees(blob, worktrees)) {
66 if (owned.has(wt)) continue
67 const head = await git($, wt, ['rev-parse', 'HEAD'])
68 if (!head) continue
69 owned.set(wt, { ...(await anchor($, wt, head)), touched: false })
70 }
71}
72
73// Between two of our own calls another agent sharing the checkout can commit, fast-forward or reset it, and every line
74// arriving that way would otherwise read as this turn's work.
75const settle = async ($: EngineInterface, command?: string) => {
76 for (const [wt, info] of owned) {
77 let next = info
78 const head = await git($, wt, ['rev-parse', 'HEAD'])
79 if (head !== undefined && head !== next.seen) {
80 next =
81 command !== undefined && authorsHistory(command)
82 ? { ...next, seen: head }
83 : { ...(await anchor($, wt, head)), touched: next.touched }
84 }
85 if (!next.touched) {
86 const status = await statusOf($, wt)
87 if (status !== next.status) next = { ...next, status, touched: true }
88 }
89 owned.set(wt, next)
90 }
91}
92
93const markTouched = (path: string) => {
94 for (const [wt, info] of owned) {
95 if (!info.touched && path.startsWith(`${wt}/`)) owned.set(wt, { ...info, touched: true })
96 }
97}
98
99const overBudget = async ($: EngineInterface) => {
100 const findings: CommentFinding[] = []
101 const bases: string[] = []
102 for (const [wt, info] of owned) {
103 if (!info.touched) continue
104 const before = findings.length
105 for (const [path, lines] of Object.entries(await addedComments($, wt, info.head))) {
106 const fresh = lines.filter((line) => !info.baseline.has(keyOf(path, line)))
107 if (fresh.length > ADD_LIMIT && !(await isInstalled($, `${wt}/${path}`))) findings.push({ path, lines: fresh })
108 }
109 if (findings.length > before) bases.push(info.head)
110 }
111 if (findings.length === 0) return undefined
112 const key = reportKey(findings)
113 if (reported.has(key) || blocks >= MAX_BLOCKS) return undefined
114 reported.add(key)
115 blocks++
116 return turnReport(findings, bases)
117}
118
119export const register: Register = (on) => {
120 on('tool.call', { tool: ['Bash', 'Write', 'Edit'] }, async ($, e, next) => {
121 await claim($, e.tool === 'Bash' ? e.command : e.file_path)
122 const result = await next(e)
123 if (e.tool === 'Bash') await settle($, e.command)
124 else if (result.deny === undefined && !result.isError) markTouched(e.file_path)
125 return result
126 })
127
128 on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
129 const result = await next(e)
130 if (result.deny !== undefined || result.isError) return result
131 const path = e.file_path
132 const text = e.tool === 'Write' ? e.content : e.new_string
133 const marks = prefixes(path)
134 if (!path || !text || !marks || (await isInstalled($, path))) return result
135 const body = await $.fs.read(path).catch(() => undefined)
136 const findings = [addedSignal(text, marks, path), body === undefined ? undefined : densitySignal(path, body, text, marks)].filter(
137 (finding): finding is string => finding !== undefined,
138 )
139 if (findings.length === 0) return result
140 const name = baseName(path)
141 $.ui.log(`lean-comments/limit-edits: ${findings.length} signal(s) on ${name}`)
142 return { ...result, context: [...(result.context ?? []), editGuidance(name, findings)] }
143 })
144
145 // A mod cannot hold a turn open as a Stop hook's block did, so the findings arrive as the next prompt.
146 on('turn.complete', async ($, e, next) => {
147 const result = await next(e)
148 if (e.agentId !== undefined || e.reason !== 'answer' || owned.size === 0) return result
149 await settle($)
150 const report = await overBudget($)
151 if (report !== undefined) {
152 $.ui.log("lean-comments/limit-turns: this turn's diff is over budget; a follow-up prompt asks to prune")
153 $.clock.after(0, () => {
154 $.prompt.submit({ text: report }).catch(() => undefined)
155 })
156 }
157 return result
158 })
159}
160hooks/comments.ts 53 lines1import { ADD_LIMIT, docstringLines, KEEP } from './rules'
2import { baseNote } from './shared/anchor'
3import { addedLines, OUTRANKS, prefixes, splitLines, startsWithAny } from './shared/diff'
4
5export const MAX_BLOCKS = 2
6
7export type CommentFinding = { path: string; lines: string[] }
8
9export const commentsInDiff = (diff: string): Record<string, string[]> => {
10 const added: Record<string, string[]> = {}
11 for (const { path, body } of addedLines(diff)) {
12 if (!prefixes(path)) continue
13 const stripped = body.trim()
14 if (stripped.startsWith('#!')) continue
15 ;(added[path] ??= []).push(stripped)
16 }
17 const found: Record<string, string[]> = {}
18 for (const [path, bodies] of Object.entries(added)) {
19 const marks = prefixes(path) ?? []
20 const docs = docstringLines(bodies, path)
21 bodies.forEach((body, i) => {
22 if (startsWithAny(body, marks) || docs.has(i)) (found[path] ??= []).push(body)
23 })
24 }
25 return found
26}
27
28export const commentsInFile = (rel: string, text: string): string[] => {
29 const marks = prefixes(rel)
30 if (!marks) return []
31 const lines = splitLines(text).map((line) => line.trim())
32 const docs = docstringLines(lines, rel)
33 return lines.filter((line, i) => !line.startsWith('#!') && (startsWithAny(line, marks) || docs.has(i)))
34}
35
36export const reportKey = (findings: CommentFinding[]) => JSON.stringify(findings.map((f) => [f.path, f.lines]).sort())
37
38export const turnReport = (findings: CommentFinding[], bases: readonly string[] = []) => {
39 const parts = findings.map(({ path, lines }) => {
40 const shown = lines.slice(0, 8).map((l) => ` ${l}`).join('\n')
41 const more = lines.length <= 8 ? '' : `\n ... +${lines.length - 8} more`
42 return ` ${path} - ${lines.length} added comment lines:\n${shown}${more}`
43 })
44 const note = baseNote(bases)
45 return (
46 `lean-comments/limit-turns: this turn's diff adds more comment lines than the budget of ${ADD_LIMIT} per file.\n\n` +
47 `${parts.join('\n')}\n${note === undefined ? '' : `${note}\n`}${KEEP}\n\n` +
48 'This check reads git diff, so it sees edits made through Bash, sed and ' +
49 `heredocs that the per-edit check never sees.\n${OUTRANKS}\n` +
50 'Cut what does not earn its place, then say what you kept and why.'
51 )
52}
53hooks/rules.ts 70 lines1import { extOf, startsWithAny } from './shared/diff'
2
3export const ADD_LIMIT = 3
4export const CONTEXT = 15
5export const MAX_DENSITY = 0.3
6export const MIN_REGION_COMMENTS = 4
7export const MAX_RUN = 5
8
9const TRIPLE_EXT = ['.py', '.pyi']
10const QUOTES = ['"""', "'''"]
11
12export const KEEP =
13 'Keep a comment that records a measured number, an observed behaviour, a ' +
14 'named bug or version pin, a trap whose obvious cleanup would silently ' +
15 'break something, or an invariant spanning processes - those are the ones ' +
16 'this codebase is built on. Delete the ones that restate the code, narrate ' +
17 'the change, or argue a decision; that reasoning belongs in the commit ' +
18 'message, docs/decisions.md, or a runbook.'
19
20// Only blocks whose opening line starts with the quote: `sql = """SELECT` is a value, not prose.
21export const docstringLines = (lines: string[], path: string | undefined): Set<number> => {
22 const out = new Set<number>()
23 if (!path || !TRIPLE_EXT.includes(extOf(path))) return out
24 let quote: string | undefined
25 let pending: number[] = []
26 lines.forEach((raw, i) => {
27 const line = raw.trim()
28 if (quote === undefined) {
29 const opener = line.replace(/^[rRfFbBuU]+/, '')
30 if (!QUOTES.some((q) => opener.startsWith(q))) return
31 quote = opener.slice(0, 3)
32 pending = [i]
33 if (opener.slice(3).includes(quote)) {
34 pending.forEach((n) => out.add(n))
35 quote = undefined
36 }
37 } else {
38 pending.push(i)
39 if (line.includes(quote)) {
40 pending.forEach((n) => out.add(n))
41 quote = undefined
42 }
43 }
44 })
45 if (quote !== undefined && pending[0] !== undefined) out.add(pending[0])
46 return out
47}
48
49export const classify = (lines: string[], marks: readonly string[], firstIsFileStart: boolean, path?: string) => {
50 const docs = docstringLines(lines, path)
51 const comment: number[] = []
52 let code = 0
53 lines.forEach((raw, i) => {
54 const line = raw.trim()
55 if (line === '') return
56 if (firstIsFileStart && i === 0 && line.startsWith('#!')) return
57 if (startsWithAny(line, marks) || docs.has(i)) comment.push(i)
58 else code++
59 })
60 return { comment, code }
61}
62
63// Python's round() rounds halves to even; percentages here must print as the Python guard printed them.
64export const roundHalfEven = (value: number) => {
65 const floor = Math.floor(value)
66 const diff = value - floor
67 if (Math.abs(diff - 0.5) > 1e-9) return Math.round(value)
68 return floor % 2 === 0 ? floor : floor + 1
69}
70hooks/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/signals.ts 54 lines1import { ADD_LIMIT, classify, CONTEXT, KEEP, MAX_DENSITY, MAX_RUN, MIN_REGION_COMMENTS, roundHalfEven } from './rules'
2import { OUTRANKS, splitLines } from './shared/diff'
3
4export const addedSignal = (text: string, marks: readonly string[], path: string) => {
5 const { comment } = classify(splitLines(text), marks, true, path)
6 return comment.length > ADD_LIMIT ? `This edit adds ${comment.length} comment-only lines (soft limit ${ADD_LIMIT}).` : undefined
7}
8
9export const densitySignal = (path: string, body: string, text: string, marks: readonly string[]) => {
10 const lines = splitLines(body)
11 const index = body.indexOf(text)
12 if (index < 0) return undefined
13 const start = body.slice(0, index).split('\n').length - 1
14 const end = start + text.split('\n').length - 1
15 const lo = Math.max(0, start - CONTEXT)
16 const hi = Math.min(lines.length, end + CONTEXT + 1)
17 const { comment, code } = classify(lines.slice(lo, hi), marks, lo === 0, path)
18 const total = comment.length + code
19 if (total === 0) return undefined
20
21 let run = 0
22 let runStart = 0
23 let best = 0
24 let bestStart = 0
25 let previous: number | undefined
26 for (const i of comment) {
27 if (previous !== undefined && i === previous + 1) run++
28 else [run, runStart] = [1, i]
29 if (run > best) [best, bestStart] = [run, runStart]
30 previous = i
31 }
32
33 const density = comment.length / total
34 const dense = comment.length >= MIN_REGION_COMMENTS && density > MAX_DENSITY
35 const blocky = best >= MAX_RUN
36 if (!dense && !blocky) return undefined
37
38 const percent = roundHalfEven(density * 100)
39 const what = blocky
40 ? `a ${best}-line comment block at line ${lo + bestStart + 1} (region is ${percent}% comment)`
41 : `${percent}% comment - ${comment.length} comment lines against ${code} of code`
42 return (
43 `The region you edited (${path}:${lo + 1}-${hi}) carries ${what}. Every comment in that ` +
44 'range is in scope, including the ones you did not write: editing here ' +
45 'is what puts them in your blast radius.'
46 )
47}
48
49export const editGuidance = (name: string, findings: string[]) =>
50 `lean-comments/limit-edits on ${name}:\n${findings.map((f) => `- ${f}`).join('\n')}\n\n` +
51 "CLAUDE.md: 'Do not write comments by default. Default is no comment.'\n" +
52 `${KEEP}\n${OUTRANKS}\n` +
53 'Prune, then say in your reply which comments you kept and why.'
54