SLOPSHOPPER

metrics

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…

panecommandprocesstimer
v0.1.0MITupdated 2026-10-16julian-passebecq/claude-mod/mods/metrics
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · metrics
│ ┃ regles ✕ › fix the failing auth test and add an audit log call │ ┃ │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /regles │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · regles
README

metrics — « Les règles marchent ? »

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.

What it measures

Metric (config id)HowSource
PR/MR mergées par jour (prs)merged PRs (GitHub) and MRs (GitLab) of the tracked reposgh 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/MRstranscripts, parsed with mods/usage/lib
Tours Opus / Sonnet / Haiku (*_share)share of assistant turns per model family, all agentstranscripts
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_thresholdtranscripts
Arrêts manuels (stops)[Request interrupted by user…] messagestranscripts
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 runninggh run list, glab ci list
Arrêts BLOCKED (blocked)assistant texts starting with BLOCKED or saying red twicetranscripts
M6 Réveils (wake)Monitor / CronCreate / ScheduleWakeup callstranscripts
Compactages (compact)compact_boundary entriestranscripts

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.

How it runs

  • /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.
  • When the data is older than 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).
  • From a shell: node mods/metrics/scanner/metrics_scan.mjs report prints the latest before/after table.
  • Needs Node 22.18+ and the usage mod folder beside it (../usage/lib, shared parser and prices). gh and glab are optional (logged in for the forges you use).

Settings

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.

Tests

node --test mods/metrics/tests/logic.test.mjs
claude plugin test mods/metrics
claude plugin validate mods/metrics
Source 4 files
hooks/register.tsx 104 lines
1// 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}
104
hooks/view.mjs 127 lines
1// 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`)
127
hooks/pane.mjs 72 lines
1// 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}
72
types/index.d.ts 14 lines
1// 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