Shows complexity, density and hotspot score for files Claude reads or edits.

A Claude Code mod. It runs scripts/complexity_rank.py on each code file Claude reads, edits, or writes for the current message, and shows the four that most need attention above the prompt. Each new message starts the list over.
3 over limit (+1)).Each row also shows file complexity, density, recent churn, and hotspot score.
After an Edit or Write, the model also gets one extra context line when the file has a function over the limit. A toast appears when an edit adds one.
scripts/complexity_rank.py is a copy of plugins/personal-skills/skills/whittle/scripts/complexity_rank.py. python3 scripts/release_metadata.py set copies it and check fails when the two differ. Edit the whittle copy only.
Needs scc on PATH, and biome or lizard for per-function counts (see the script). Needs Claude Code 2.1.287 or later (mods are on by default). Test with claude plugin test plugins/complexity-lens.
hooks/register.tsx 156 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Row, Shown } from '../types'
5
6const rows = atom({ plugin: 'complexity-lens', key: 'rows' } as const, [])
7const problem = atom({ plugin: 'complexity-lens', key: 'problem' } as const, null)
8
9const WATCHED = ['Read', 'Edit', 'Write'] as const
10const SHOWN = 4
11const CODE = /\.(rs|ts|tsx|js|jsx|py|go)$/
12
13async function measure($: EngineInterface, path: string): Promise<Shown | undefined> {
14 try {
15 const ran = await $.process.run(
16 ['python3', `${$.plugin.root}/scripts/complexity_rank.py`, '--json', path],
17 { timeoutMs: 20000, cwd: path.slice(0, path.lastIndexOf('/')) || '/' },
18 )
19 if (ran.exitCode !== 0) {
20 await update($, problem, () => `rank exit ${ran.exitCode}`)
21 return undefined
22 }
23 const [measured] = JSON.parse(ran.stdout) as Row[]
24 if (!measured) return undefined
25 const before = (await read($, rows)).find(r => r.file === measured.file)
26 const row: Shown = { ...measured, baseline: before ? before.baseline : measured.over_threshold_fns }
27 await update($, rows, list => [row, ...list.filter(r => r.file !== row.file)].slice(0, 12))
28 if (grew(row)) {
29 $.ui.toast(`${row.file}: functions over limit ${row.baseline} → ${row.over_threshold_fns}`)
30 }
31 await update($, problem, () => null)
32 return row
33 } catch (err) {
34 await update($, problem, () => `rank failed: ${String(err).slice(0, 60)}`)
35 return undefined
36 }
37}
38
39function grew(r: Shown): boolean {
40 return r.over_threshold_fns !== null && r.baseline !== null && r.over_threshold_fns > r.baseline
41}
42
43type Level = 'grew' | 'over' | 'calm'
44
45function level(r: Shown): Level {
46 if (grew(r)) return 'grew'
47 return (r.over_threshold_fns ?? 0) > 0 ? 'over' : 'calm'
48}
49
50const LEVEL_RANK: Record<Level, number> = { grew: 0, over: 1, calm: 2 }
51const LEVEL_COLOR: Record<Level, string | undefined> = { grew: 'red', over: 'yellow', calm: undefined }
52
53function agentNote(row: Shown): string | undefined {
54 const over = row.over_threshold_fns ?? 0
55 if (over === 0 && row.score === 0 && !grew(row)) return undefined
56 const change = grew(row) ? ` (was ${row.baseline} at the start of this request)` : ''
57 const limit =
58 row.per_function_source === 'lizard'
59 ? 'cyclomatic 10, lizard'
60 : row.per_function_source === 'biome'
61 ? 'cognitive 20, Biome'
62 : 'not counted'
63 return (
64 `complexity-lens: ${row.file} has ${row.over_threshold_fns ?? 'unknown'} function(s) over the per-function limit (${limit})${change}; ` +
65 `file complexity ${row.complexity}, ${row.density} per 100 lines, recent churn ${row.churn ?? 'n/a'}, hotspot score ${row.score}` +
66 `${row.flags.length ? ` [${row.flags.join(', ')}]` : ''}. Keep new functions under the limit, or name the reason.`
67 )
68}
69
70function overText(r: Shown): string | undefined {
71 if (r.over_threshold_fns === null) return undefined
72 return grew(r) ? `${r.over_threshold_fns} over limit (+${r.over_threshold_fns - (r.baseline ?? 0)})` : `${r.over_threshold_fns} over limit`
73}
74
75function details(r: Shown): string {
76 const notes = r.flags.map(f => (f === 'scc-only' ? 'rough' : f === 'young' ? 'new file' : f))
77 const parts = [
78 `complexity ${r.complexity}`,
79 `${Math.round(r.density)} per 100 lines`,
80 `changed ${r.churn ?? '?'}×`,
81 `score ${r.score}`,
82 ]
83 return `${parts.join(' · ')}${notes.length ? ` (${notes.join(', ')})` : ''}`
84}
85
86export const register: Register = on => {
87 let scc = true
88
89 on('session.start', async ($, e, next) => {
90 try {
91 await $.process.run(['scc', '--version'], { timeoutMs: 5000 })
92 } catch (err) {
93 scc = false
94 await update($, problem, () => `scc unavailable: ${String(err).slice(0, 60)}`)
95 }
96 return next(e)
97 })
98
99 on('prompt.submit', async ($, e, next) => {
100 await update($, rows, () => [])
101 await update($, problem, () => null)
102 return next(e)
103 })
104
105 on('tool.call', { tool: WATCHED }, async ($, e, next) => {
106 const result = await next(e)
107 const path = e.file_path
108 if (!scc || !path || result.deny !== undefined || result.isError || !CODE.test(path)) return result
109 if (e.tool === 'Read') {
110 void measure($, path)
111 return result
112 }
113 const measured = await measure($, path)
114 const note = measured ? agentNote(measured) : undefined
115 return note ? { ...result, context: [...(result.context ?? []), note] } : result
116 })
117
118 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
119 const list = await read($, rows)
120 const issue = await read($, problem)
121 if (list.length === 0 && issue === null) return next(e)
122
123 const below = await next(e)
124 const { Box, Text } = $.ui.resolve(e)
125 const top = [...list]
126 .sort((a, b) => LEVEL_RANK[level(a)] - LEVEL_RANK[level(b)] || b.score - a.score || b.complexity - a.complexity)
127 .slice(0, SHOWN)
128
129 return (
130 <Box flexDirection="column">
131 {below}
132 {issue !== null ? <Text color="yellow">complexity-lens: {issue}</Text> : null}
133 {top.map(r => {
134 const lvl = level(r)
135 const over = overText(r)
136 return (
137 <Box key={r.file} flexDirection="row" columnGap={1}>
138 <Text color={LEVEL_COLOR[lvl]} bold={lvl === 'grew'} dimColor={lvl === 'calm'}>
139 {r.file.split('/').slice(-2).join('/')}
140 </Text>
141 {over === undefined ? null : (
142 <Text color={LEVEL_COLOR[lvl]} bold={lvl === 'grew'} dimColor={lvl === 'calm'}>
143 {over}
144 </Text>
145 )}
146 <Text dimColor wrap="truncate-end">
147 {details(r)}
148 </Text>
149 </Box>
150 )
151 })}
152 </Box>
153 )
154 })
155}
156types/index.d.ts 20 lines1export type Row = {
2 file: string
3 code: number
4 complexity: number
5 density: number
6 churn: number | null
7 over_threshold_fns: number | null
8 per_function_source: 'biome' | 'lizard' | null
9 score: number
10 flags: string[]
11}
12
13export type Shown = Row & { baseline: number | null }
14
15declare module 'claude-code' {
16 interface PluginState {
17 'complexity-lens': { rows: Shown[]; problem: string | null }
18 }
19}
20