Les règles marchent ? PR/MR mergées par jour, tokens par PR, part Opus / Sonnet / Haiku, agents avec modèle nommé, arrêts, refus, gardes de l'app, creux CI…

Now shown inside Registres (tab Règles, /registres regles); /regles keeps working (this mod while loaded, else Registres' alias).
Read-only pane (/regles) that shows, per day and per rules version, whether the rules that steer your agents help.
Now also a tab of Registres: /registres regles [V5.8] [refresh] (or /regles when this mod is not loaded) shows the same view in the Registres pane, drawn by a copy of hooks/pane.mjs (the pane's pure drawing; hooks/register.tsx only reads the files and hands them over). This mod keeps working on its own while it is loaded.
| Metric (config id) | How | Source |
|---|---|---|
PR/MR mergées par jour (prs) | merged PRs (GitHub) and MRs (GitLab) of the tracked repos | gh search prs --merged --author @me, glab mr list -M |
M8 Tokens / coût par PR (tok_per_pr, usd_per_pr) | tokens (input + output + cache) and list-price cost of the sessions whose folder belongs to a tracked repo ÷ merged PRs/MRs | transcripts, parsed with mods/usage/lib |
Tours Opus / Sonnet / Haiku (*_share) | share of assistant turns per model family, all agents | transcripts |
M1 Appels d'agent avec modèle (m1_named) | Agent calls with model set, plus Workflow agent() calls with model: (approximation) | transcripts |
M2 Tours principaux ≥ 250 K (m2_big) | main-thread turns whose context (input + cache) is at or above detect.context_threshold | transcripts |
Arrêts manuels (stops) | [Request interrupted by user…] messages | transcripts |
M10 Refus de permission (perm) | tool results "The user doesn't want to proceed…" | transcripts |
Gardes de l'app (guard) | tool errors from the app's guards (isolated worktree, protected path, PreToolUse hook) | transcripts, patterns in config |
Creux CI (stall_h) | inside each day's CI span, gaps ≥ stall.min_gap_min with no pipeline running | gh run list, glab ci list |
Arrêts BLOCKED (blocked) | assistant texts starting with BLOCKED or saying red twice | transcripts |
M6 Réveils (wake) | Monitor / CronCreate / ScheduleWakeup calls | transcripts |
Compactages (compact) | compact_boundary entries | transcripts |
M1, M2, M6, M8 and M10 reuse the names of claude-control's docs/PRACTICES.md (section 7). Rules versions come from the git log of the rules file (rules.repo, rules.file): a version starts at the first commit whose subject carries it (V5.8, later V6) and is drawn as a │ marker under the sparklines. The « avant / après » table compares a version's window with the previous one (rates per day, shares in %); arrows are green when the change goes the way the metric's better says.
Non vérifié (approximations, written in the pane too): permission waits leave no reliable trace in the transcripts (only refusals are counted); Workflow agent() model detection reads the script text; « creux » cannot know what work was startable, it only sees CI gaps; transcripts kept on this PC bound how far back the transcript metrics go.
/regles [V5.8] [refresh] opens the pane, /regles close closes it. In the band's Mods bar, Règles lives in the « Registres » entry ({ "label": "Registres", "mod": "registres", "command": "registres" }). The command returns no text, so nothing reaches the model (mods overview, reference). No hook blocks, rewrites or delays a tool call or a prompt.refresh_min (30 min), the pane starts scanner/metrics_scan.mjs in the background ($.process.run): it reads only the bytes appended to ~/.claude/projects/**/*.jsonl since its last run (first run: ~3 GB in ~35 s on the author's PC), lists the repos (git remotes), and calls gh / glab (read-only list calls) at most once per refresh_min. Results: ~/.claude/metrics-mod/ (scan-cache.json, forge-cache.json, metrics.json).node mods/metrics/scanner/metrics_scan.mjs report prints the latest before/after table.usage mod folder beside it (../usage/lib, shared parser and prices). gh and glab are optional (logged in for the forges you use).Everything is in metrics.config.json; override any key in ~/.claude/metrics-mod/metrics.config.json (deep merge). Set at least:
{
"repos": { "roots": ["<folder that holds your repos>"], "gitlab_groups": ["<your GitLab group, for CI mirrors>"] },
"rules": { "repo": "<repo that versions your rules file>", "file": "config/claude/CLAUDE.md" }
}
Labels, targets, which metrics show (show: false), the summary columns, detection patterns, the stall gap, history length and refresh period are all settings.
node --test mods/metrics/tests/logic.test.mjs
claude plugin test mods/metrics
claude plugin validate mods/metricshooks/register.tsx 104 lines1// metrics mod ("Les règles marchent ?"): read-only view of whether the rules help, per day and per rules version.
2// /regles [V5.8] [refresh] opens the pane. Figures come from scanner/metrics_scan.mjs (Node, incremental): transcripts
3// (usage mod's parser), merged PRs/MRs and CI runs (gh / glab list calls), and the rules file's git log for the
4// version markers. The drawing is hooks/pane.mjs (pure, also drawn by the registres mod's « Règles » tab). The command shows a pane and returns no text, so nothing reaches the model; nothing here blocks,
5// rewrites or delays a tool call or a prompt.
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register } from 'claude-code'
8
9import { deepMerge, expandHome } from './view.mjs'
10import { drawRegles } from './pane.mjs'
11
12const PANE = 'regles'
13const versionAtom = atom({ plugin: 'metrics', key: 'version' } as const, '')
14
15const S: { busy: boolean; error: string; at: number; model: any } = { busy: false, error: '', at: 0, model: undefined }
16
17const home = async ($: EngineInterface) => {
18 let v: string | undefined
19 try {
20 v = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME'))
21 } catch {}
22 return (v ?? '').replace(/\\/g, '/')
23}
24const readJson = async ($: EngineInterface, path: string): Promise<any> => {
25 try {
26 return JSON.parse((await $.fs.read(path, { as: 'text' })) as string)
27 } catch {
28 return undefined
29 }
30}
31
32async function context($: EngineInterface) {
33 const root = $.plugin.root.replace(/\\/g, '/')
34 const h = await home($)
35 const base = (await readJson($, `${root}/metrics.config.json`)) || {}
36 const dataDir = expandHome(base.data_dir || '~/.claude/metrics-mod', h)
37 const cfg = deepMerge(base, (await readJson($, `${dataDir}/metrics.config.json`)) || {})
38 return { root, dataDir, cfg }
39}
40
41/** Runs the scanner in the background and redraws; never awaited by a hook the person waits on. */
42async function rescan($: EngineInterface, force = false) {
43 if (S.busy) return
44 S.busy = true
45 $.ui.invalidate('ui.render')
46 try {
47 const { root, cfg } = await context($)
48 const argv = [cfg.node || 'node', `${root}/scanner/metrics_scan.mjs`, 'scan', ...(force ? ['--force'] : [])]
49 const r = await $.process.run(argv, { timeoutMs: Math.min((cfg.scan_timeout_s ?? 600) * 1000, 600000) })
50 if (r.exitCode !== 0) throw new Error((r.stderr || '').trim().split('\n').pop() || `scanner exit ${r.exitCode}`)
51 S.error = ''
52 S.at = await $.clock.now()
53 S.model = undefined
54 } catch (err) {
55 S.error = String((err as Error)?.message ?? err).slice(0, 300)
56 } finally {
57 S.busy = false
58 }
59 $.ui.invalidate('ui.render')
60}
61
62async function load($: EngineInterface) {
63 const { cfg, dataDir } = await context($)
64 const model = S.model ?? (S.model = await readJson($, `${dataDir}/metrics.json`))
65 const now = await $.clock.now()
66 const stale = !model || now - (model.generated || 0) > (cfg.refresh_min ?? 30) * 60000
67 return { cfg, model, stale }
68}
69
70export const register: Register = on => {
71 on('session.start', async ($, e, next) => {
72 await $.command.register({
73 name: 'regles',
74 description: 'Les règles marchent ? PR mergées, tokens par PR, modèles, arrêts, gardes, creux CI, par jour et par version des règles (args: [V5.8] [refresh])',
75 argumentHint: '[V5.8] [refresh] | close',
76 })
77 return next(e)
78 })
79
80 on('command.run', { command: 'regles' }, async ($, e) => {
81 if ((e.args ?? '').trim().toLowerCase() === 'close') {
82 await $.ui.close({ id: PANE })
83 return {}
84 }
85 const words = String(e.args ?? '').split(/\s+/).filter(Boolean)
86 const v = words.find(w => /^v\d/i.test(w))
87 if (v) await update($, versionAtom, () => v.toUpperCase())
88 const { cfg, stale } = await load($)
89 const refresh = words.some(w => w.toLowerCase() === 'refresh')
90 if (stale || refresh) $.clock.after(0, () => rescan($, refresh))
91 await $.ui.open({ id: PANE, title: cfg.labels?.pane_title ?? 'regles' })
92 return {} // a pane, no transcript text: nothing for the model to read
93 })
94
95 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
96 const { cfg, model } = await load($)
97 const want = (await read($, versionAtom)) as string
98 return drawRegles($.ui.resolve(e), { cfg, model, want, busy: S.busy, error: S.error }, {
99 setVersion: (v: string) => update($, versionAtom, () => v),
100 rescan: () => { $.clock.after(0, () => rescan($, true)) },
101 }) as any
102 })
103}
104hooks/view.mjs 127 lines1// metrics mod: pure formatting for the pane and the scanner's report (sparklines, markers, before/after rows).
2// No $ and no I/O: node --test covers it directly.
3
4const SPARK = '▁▂▃▄▅▆▇█'
5
6/** Number with French separators: 1 234,5. */
7export function fmtNum(v, digits = 0) {
8 if (v === null || v === undefined || !Number.isFinite(v)) return '—'
9 const s = Math.abs(v).toFixed(digits).replace('.', ',')
10 const [i, d] = s.split(',')
11 const out = i.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') + (d ? `,${d}` : '')
12 return v < 0 ? `-${out}` : out
13}
14
15/** Tokens as 12,3 M / 450 K. */
16export function fmtTok(v) {
17 if (v === null || v === undefined || !Number.isFinite(v)) return '—'
18 if (Math.abs(v) >= 1e9) return `${fmtNum(v / 1e9, 2)} G`
19 if (Math.abs(v) >= 1e6) return `${fmtNum(v / 1e6, 1)} M`
20 if (Math.abs(v) >= 1e3) return `${fmtNum(v / 1e3, 0)} K`
21 return fmtNum(v, 0)
22}
23
24/** One metric value as text, by its config entry (format: tokens | usd | pct | number; digits). */
25export function fmtMetric(m, v) {
26 if (v === null || v === undefined || !Number.isFinite(v)) return '—'
27 const f = m.format || (m.unit === '%' ? 'pct' : 'number')
28 if (f === 'tokens') return fmtTok(v)
29 if (f === 'usd') return `${fmtNum(v, m.digits ?? 2)} $`
30 if (f === 'pct') return `${fmtNum(v, m.digits ?? 0)} %`
31 return `${fmtNum(v, m.digits ?? 1)}${m.unit && m.unit !== '%' ? ` ${m.unit}` : ''}`
32}
33
34/** Sparkline: one character per value, scaled 0..max of the series; a missing value is `gap`. */
35export function spark(values, chars = SPARK, gap = '·') {
36 const xs = (values || []).filter(v => v !== null && v !== undefined && Number.isFinite(v))
37 const max = Math.max(0, ...xs)
38 const cs = [...chars]
39 return (values || []).map(v => {
40 if (v === null || v === undefined || !Number.isFinite(v)) return gap
41 if (max <= 0) return cs[0]
42 return cs[Math.min(cs.length - 1, Math.round((v / max) * (cs.length - 1)))]
43 }).join('')
44}
45
46/** A row as wide as the sparklines with `mark` at each marker's day. */
47export function markerLine(n, markers, mark = '│') {
48 const row = Array.from({ length: n }, () => ' ')
49 for (const m of markers || []) if (m.day >= 0 && m.day < n) row[m.day] = mark
50 return row.join('')
51}
52
53/** Markers grouped by day: « 13/10 V5.1–V5.7 · 15/10 V5.8 ». */
54export function markerLegend(markers, days) {
55 const byDay = new Map()
56 for (const m of markers || []) {
57 if (!byDay.has(m.day)) byDay.set(m.day, [])
58 byDay.get(m.day).push(m.version)
59 }
60 return [...byDay.entries()].map(([d, vs]) => {
61 const key = (days || [])[d] || ''
62 const label = key ? `${key.slice(8, 10)}/${key.slice(5, 7)}` : `j${d}`
63 return `${label} ${vs.length > 2 ? `${vs[0]}–${vs[vs.length - 1]}` : vs.join(', ')}`
64 }).join(' · ')
65}
66
67/** Last measured value of a series (or null). */
68export const latest = values => {
69 for (let i = (values || []).length - 1; i >= 0; i--) if (values[i] !== null && values[i] !== undefined && Number.isFinite(values[i])) return values[i]
70 return null
71}
72
73/**
74 * Before/after rows for one version against the previous one: label, both values as text, an arrow and whether the
75 * change is better / worse / flat by the metric's `better` (up | down | none) and the config's `flat_pct`.
76 */
77export function metricRows(cfg, prev, cur) {
78 const flat = cfg.flat_pct ?? 10
79 return (cfg.metrics || []).filter(m => m.show !== false).map(m => {
80 const a = prev && prev.values ? prev.values[m.id] : null
81 const b = cur && cur.values ? cur.values[m.id] : null
82 let arrow = ''
83 let verdict = 'none'
84 if (Number.isFinite(a) && Number.isFinite(b) && a !== null && b !== null) {
85 const base = Math.max(Math.abs(a), 1e-9)
86 const pct = ((b - a) / base) * 100
87 arrow = Math.abs(pct) < flat ? '=' : b > a ? '↑' : '↓'
88 if (m.better === 'up' || m.better === 'down') verdict = arrow === '=' ? 'flat' : (arrow === '↑') === (m.better === 'up') ? 'better' : 'worse'
89 }
90 const target = m.target === undefined ? '' : fmtMetric(m, m.target)
91 return { id: m.id, label: m.label || m.id, before: fmtMetric(m, a), after: fmtMetric(m, b), arrow, verdict, target }
92 })
93}
94
95/** Index of the version to show: the one named `want` (V5.8), else the latest. */
96export function pickVersion(versions, want) {
97 const vs = versions || []
98 const i = want ? vs.findIndex(v => v.version.toLowerCase() === String(want).toLowerCase()) : -1
99 return i >= 0 ? i : vs.length - 1
100}
101
102/** Deep merge of config objects (arrays and scalars of `b` replace). */
103export function deepMerge(a, b) {
104 if (Array.isArray(b)) return b.slice()
105 if (!b || typeof b !== 'object') return b === undefined ? a : b
106 const out = { ...(a && typeof a === 'object' && !Array.isArray(a) ? a : {}) }
107 for (const [k, v] of Object.entries(b)) out[k] = deepMerge(out[k], v)
108 return out
109}
110
111/** `~` to the home folder, forward slashes. */
112export const expandHome = (p, home) => String(p || '').replace(/^~(?=[\\/]|$)/, home).replace(/\\/g, '/')
113
114/** `{name}` placeholders from `vars`. */
115export const fill = (s, vars) => String(s || '').replace(/\{(\w+)\}/g, (m, k) => (vars[k] === undefined ? m : String(vars[k])))
116
117/** Local time as dd/mm hh:mm for a UTC ms and a zone offset in minutes. */
118export function when(ms, tzMin = 0) {
119 if (!ms) return '—'
120 const d = new Date(ms + tzMin * 60000)
121 const p = n => String(n).padStart(2, '0')
122 return `${p(d.getUTCDate())}/${p(d.getUTCMonth() + 1)} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`
123}
124
125/** A window length: « 36 h » under two days, else « 2,5 j ». */
126export const duration = h => (h >= 48 ? `${fmtNum(h / 24, 1)} j` : `${fmtNum(h, 0)} h`)
127hooks/pane.mjs 72 lines1// metrics mod - the « Les règles marchent ? » pane's drawing, pure: no `$`. `el` is the element table from
2// $.ui.resolve(e), `M` the facts the hook read, `act` the callbacks it hands in. register.tsx draws /regles with it, and
3// the registres mod draws its « Règles » tab with a byte-for-byte copy (lib/core/targets.json): change it here only.
4import { duration, fill, fmtMetric, fmtNum, latest, markerLegend, markerLine, metricRows, pickVersion, spark, when } from './view.mjs'
5
6/** h-like factory over the element table: mk(el)('Text', { dimColor: true }, 'x'). */
7export const mk = (el) => (type, props, ...kids) =>
8 el[type]({ ...(props || {}), children: kids.flat(Infinity).filter((k) => k !== null && k !== undefined && k !== false) })
9
10/** M = { cfg, model, want, busy, error }; act = { setVersion(v), rescan() }. Returns the pane's tree. */
11export function drawRegles(el, M, act) {
12 const h = mk(el)
13 const { cfg, model, want } = M
14 const l = cfg.labels ?? {}
15 const status = M.busy ? 'calcul en cours…' : ''
16 if (!model) {
17 return h('Box', { flexDirection: 'column', gap: 1 },
18 h('Text', {}, fill(l.first_scan, { days: cfg.history_days ?? 30 })),
19 M.error ? h('Text', { color: 'error' }, `scan : ${M.error}`) : null,
20 status ? h('Text', { dimColor: true }, status) : null)
21 }
22 const tz = model.tzMin ?? 0
23 const vs = (model.versions ?? []).slice(-(cfg.max_versions ?? 12))
24 const i = pickVersion(vs, want)
25 const cur = vs[i]
26 const prev = i > 0 ? vs[i - 1] : undefined
27 const metrics = (cfg.metrics ?? []).filter((m) => m.show !== false)
28 const W = Math.max(...metrics.map((m) => (m.label || m.id).length), 10) + 2
29 const n = (model.days ?? []).length
30 const meta = model.meta ?? {}
31 const verdictColor = (v) => (v === 'better' ? 'success' : v === 'worse' ? 'error' : undefined)
32 const rows = cur ? metricRows(cfg, prev, cur) : []
33 const shortH = l.short_hours ?? 12
34 const summary = (cfg.summary_metrics ?? []).map((id) => metrics.find((m) => m.id === id)).filter(Boolean)
35 const cell = (s, w = 11) => s.padStart(w)
36
37 return h('Box', { flexDirection: 'column', gap: 1 },
38 h('Box', { flexDirection: 'column' },
39 h('Text', { dimColor: true }, fill(l.subtitle, { repos: meta.repos ?? 0, github: meta.github ?? 0, gitlab: meta.gitlab ?? 0, days: n, forge_at: when(meta.forge_at, tz) }) + (status ? ` · ${status}` : '')),
40 M.error ? h('Text', { color: 'error' }, `scan : ${M.error}`) : null,
41 (meta.errors ?? []).slice(0, 4).map((x) => h('Text', { color: 'warning' }, x))),
42
43 h('Box', { flexDirection: 'column' },
44 h('Text', { bold: true }, l.versions),
45 h('Box', { flexDirection: 'row', gap: 1, flexWrap: 'wrap' },
46 vs.map((v, k) => h('Button', { key: `v-${v.version}`, ...(k !== i ? { plain: true } : {}), onPress: () => act.setVersion(v.version) }, v.version)),
47 h('Button', { key: 'refresh', plain: true, onPress: () => act.rescan() }, '↻')),
48 cur ? h('Text', { dimColor: true }, `${cur.version} · ${when(cur.start, tz)} · ${cur.sha} ${cur.subject}`) : h('Text', { dimColor: true }, 'aucune version trouvée (rules.repo dans la config)')),
49
50 h('Box', { flexDirection: 'column' },
51 h('Text', { bold: true }, `${l.curves} (${model.days?.[0] ?? ''} → ${model.days?.[n - 1] ?? ''})`),
52 metrics.map((m) => h('Text', {}, `${(m.label || m.id).padEnd(W)}${spark(model.series?.[m.id], cfg.spark, cfg.spark_gap)} ${fmtMetric(m, latest(model.series?.[m.id]))}${m.target === undefined ? '' : ` · ${l.col_target} ${fmtMetric(m, m.target)}`}`)),
53 h('Text', { color: 'claude' }, `${''.padEnd(W)}${markerLine(n, model.markers, cfg.marker)}`),
54 h('Text', { dimColor: true }, `${''.padEnd(W)}${cfg.marker ?? '│'} ${markerLegend(model.markers, model.days)}`)),
55
56 cur
57 ? h('Box', { flexDirection: 'column' },
58 h('Text', { bold: true }, `${fill(l.before_after, { version: cur.version })} : ${prev ? `${prev.version} (${duration(prev.hours)})` : '—'} → ${cur.version} (${duration(cur.hours)})`),
59 (prev && prev.hours < shortH) || cur.hours < shortH ? h('Text', { color: 'warning' }, fill(l.short, { h: shortH })) : null,
60 h('Text', { dimColor: true }, `${(l.col_metric ?? '').padEnd(W)}${cell(prev ? prev.version : 'avant')}${cell(cur.version)} ${l.col_target ?? ''}`),
61 rows.map((r) => h('Text', verdictColor(r.verdict) ? { color: verdictColor(r.verdict) } : {}, `${r.label.padEnd(W)}${cell(r.before)}${cell(r.after)} ${r.arrow.padEnd(2)} ${r.target}`)))
62 : null,
63
64 h('Box', { flexDirection: 'column' },
65 h('Text', { bold: true }, l.all_versions),
66 h('Text', { dimColor: true }, `${'Version'.padEnd(8)}${cell(l.col_days ?? 'jours', 7)}${summary.map((m) => cell(String(m.short || m.id), 12)).join('')}`),
67 vs.map((v) => h('Text', v === cur ? { bold: true } : {}, `${v.version.padEnd(8)}${cell(fmtNum(v.hours / 24, 1), 7)}${summary.map((m) => cell(fmtMetric(m, v.values?.[m.id]), 12)).join('')}`))),
68
69 h('Box', { flexDirection: 'column' },
70 (l.notes ?? []).map((x) => h('Text', { dimColor: true }, `· ${fill(x, { gap: cfg.stall?.min_gap_min ?? 60 })}`))))
71}
72types/index.d.ts 14 lines1// Types of the metrics mod: the `$.state` value and the version window the scanner writes in metrics.json.
2
3/** One rules version window: from its first commit (or the start of the history) to the next version (or now). */
4export type VersionWindow = { version: string; start: number; sha: string; subject: string; from: number; to: number; hours: number }
5
6declare module 'claude-code' {
7 interface PluginState {
8 metrics: {
9 /** rules version the before/after table shows (V5.8); empty = the latest */
10 version: string
11 }
12 }
13}
14