SLOPSHOPPER

lean-comments

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

newguardprocesstimer
v1.0.1MITupdated 2026-10-07bahaospanov/skills/lean-comments
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).

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

Model-invoked

Model- or user-reachable.

Mods

Early access; the API changes between releases.

One mod per purpose.

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

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

git-gates

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

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

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

git-cleanup

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

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

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

lean-docs

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

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

lean-comments

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

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

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

lean-scripts

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

Install mods

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

export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1

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

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

Develop

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

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

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

Check

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

Types

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

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

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

Source 6 files
hooks/register.ts 160 lines
1import 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}
160
hooks/comments.ts 53 lines
1import { 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}
53
hooks/rules.ts 70 lines
1import { 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}
70
hooks/shared/anchor.ts 13 lines
1// 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}
13
hooks/shared/diff.ts 87 lines
1const 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}
87
hooks/signals.ts 54 lines
1import { 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