Santé des mods : une ligne verte / orange / rouge en haut, puis chaque mod chargé par CLAUDE_CODE_PLUGIN_DIRS ou installé : chargé ou non, version, commandes…

Now also inside Référence (mods/reference, tab Santé, /reference sante): same drawing (hooks/draw.tsx) and the same scanner, both synced there. /sante keeps working while this mod is loaded; once it is unloaded, Référence answers it.
/sante [refresh] | close: a read-only self-check of every mod, in French. One line on top, green / orange / red:
● vert · 10 mods actifs : 10 OK, 0 orange, 0 rouge · 2 non chargés · Claude Code 2.1.293
Then one block per mod loaded through CLAUDE_CODE_PLUGIN_DIRS (or registering commands in this session), and a short list of mods found but not loaded:
$.command.list(), matched on plugin, api); a folder in CLAUDE_CODE_PLUGIN_DIRS whose commands are missing is orange (session opened before the folder was added, or its session.start hook failed: claude --debug says which).plugin.json), commands registered vs the ones its hooks module registers ($.command.register({ name }) read from hooks/*).lastOk; stale_min per mod turns it orange.error (red while younger than health_error_hours), error lines in the logs named in the config, and error lists inside its outputs (error_fields, e.g. metrics.json → meta.errors).size_max_files); above cache_warn_mb it is orange and the pane shows a cleanup command with a Copier button. The mod never runs it: Julian or an agent does.plugin.json, hooks/hooks.json, every JSON at the mod's root (except names matching skip_json) and the user config files listed per mod; invalid JSON is red with the parser's message.$.session.version() against min_version per mod, else default_min_version (2.1.287, the minimum for function-hook mods, overview). plugin.json has no documented minimum-version field; a claudeCode.minVersion or engines["claude-code"] entry is read if a mod declares one (non vérifié: not in the docs).Also listed: mod folders under extra_dirs (default ~/.claude/mods) and beside this mod, plugins in installed_plugins_file (~/.claude/plugins/installed_plugins.json; name and format non vérifié), and health records whose mod is not found.
/sante opens the pane and returns no text: nothing reaches the model. When the last report is older than refresh_min (10 min) or with refresh, scanner/sante_scan.mjs (Node 22.18+, standard library) runs in the background through $.process.run with the folder list on stdin; ↻ in the pane runs it again. Nothing runs while the pane is not asked for.~/.claude/sante-mod/report.json) and its own health record (~/.claude/mods-health/sante.json). It never deletes anything.lib/core/mods.mjs (splitDirs, listMods; a folder whose plugin.json does not parse is kept, to show red), the health record from lib/core/health.mjs.evaluate() as the pane and adds every red and orange line to its health record as findings: [{ level: "error" | "warn", mod, text }] (at most 200). error stays the scanner's own failure, so sante does not flag itself.node mods/sante/scanner/sante_scan.mjs (folders from CLAUDE_CODE_PLUGIN_DIRS, or --dirs "a;b") prints the same report as JSON.sante.config.json: paths, per-mod caches / configs / logs / error fields / stale_min / min_version, thresholds, the cleanup command template, colours, icons and labels. ~/.claude/sante-mod/sante.config.json is deep-merged on top, and userConfig config names another override file.~/.claude/mods-health/<mod>.jsonAny mod with a scanner can tell sante how its runs go by writing one small JSON file after each run, best effort (a failure to write must never break the scan):
{
"mod": "usage",
"lastRun": "2026-10-16T12:00:00.000Z",
"durationMs": 840,
"cpuMs": 610,
"error": null,
"lastOk": "2026-10-16T12:00:00.000Z",
"window": { "start": "2026-10-16T08:00:00.000Z", "runs": 48, "cpuMs": 29000, "durationMs": 40000, "errors": 0 }
}
lastRun: ISO time of this run. durationMs: the scanner process's wall time (Node process.uptime(), Python since import). cpuMs: its CPU time (user + system; Node process.cpuUsage(), Python time.process_time()).error: null, or a short message (≤ 500 characters) when the run failed. lastOk: the last run without error (kept from the previous record on failure).window: totals since start, reset once start is 24 h old; sante divides window.cpuMs by the elapsed time for « ≈ x % d'un cœur ».<file>.<pid>.tmp, then rename. MODS_HEALTH_DIR moves the folder (tests); MODS_HEALTH=0 turns writing off.writeHealth() in lib/core/health.mjs (repo root; copies in each mod's lib/core), nextHealth() in hooks/logic.mjs (same rule, pure), and write_health() in mods/ci/scanner/ci.py and mods/machine/scanner/machine.py (Python). Mods without a scanner (scenarios, guide) have nothing to report.findings (sante writes it): the red and orange lines of a check, for the Problèmes register.Who writes it today: band, usage, metrics, ci, machine. Not yet factory and control (to add when those mods are next changed).
node --test mods/sante/tests/*.test.mjs
claude plugin test mods/sante
claude plugin validate mods/sante
Non vérifié: that $.command.list() names the plugin of every mod command (plugin is optional in the types; without it a command counts for the mod whose hooks register that name); the format of installed_plugins.json; CPU figures for mods whose scanner is a long-lived process (none here).
hooks/register.tsx 128 lines1// sante mod ("Santé des mods"): a read-only self-check of every mod. /sante [refresh] opens the pane; /sante close
2// closes it. The file facts (manifests, hooks, configs, caches, logs, health records) come from scanner/sante_scan.mjs,
3// run in the background only while the pane is asked for; this hook adds the session's slash commands
4// ($.command.list) and the engine version ($.session.version) and draws one green / orange / red line on top.
5// Nothing here blocks, rewrites or delays a tool call or a prompt, nothing reaches the model (the command returns no
6// text) and nothing is deleted: cleanup commands are only shown, with a Copy button.
7import type { EngineInterface, Register } from 'claude-code'
8
9import { drawSante } from './draw'
10import { deepMerge, expandHome, splitDirs } from './logic.mjs'
11
12const PANE = 'sante'
13type Options = { config?: string }
14let options: Options = {}
15
16const S: { busy: boolean; error: string; report: any; commands: any[]; engine: string; copied: string } = {
17 busy: false, error: '', report: undefined, commands: [], engine: '', copied: '',
18}
19
20const home = async ($: EngineInterface) => {
21 let v: string | undefined
22 try {
23 v = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME'))
24 } catch {}
25 return (v ?? '').replace(/\\/g, '/')
26}
27const readJson = async ($: EngineInterface, path: string): Promise<any> => {
28 try {
29 return JSON.parse(((await $.fs.read(path, { as: 'text' })) as string).replace(/^/, ''))
30 } catch {
31 return undefined
32 }
33}
34
35async function context($: EngineInterface) {
36 const root = $.plugin.root.replace(/\\/g, '/')
37 const hd = await home($)
38 const base = (await readJson($, `${root}/sante.config.json`)) || {}
39 const dataDir = expandHome(base.data_dir || '~/.claude/sante-mod', hd)
40 let cfg = deepMerge(base, (await readJson($, `${dataDir}/sante.config.json`)) || {})
41 if (options.config?.trim()) cfg = deepMerge(cfg, (await readJson($, expandHome(options.config.trim(), hd))) || {})
42 return { root, home: hd, dataDir, cfg }
43}
44
45/** Commands and engine version of this session: local calls, no model. */
46async function sessionFacts($: EngineInterface) {
47 try {
48 S.commands = (await $.command.list()).map(c => ({ name: c.name, source: c.source, ...(c.plugin ? { plugin: c.plugin } : {}) }))
49 } catch {}
50 try {
51 S.engine = (await $.session.version()).version
52 } catch {}
53}
54
55/** Runs the scanner and redraws; never awaited by a hook the person waits on. */
56async function rescan($: EngineInterface) {
57 if (S.busy) return
58 S.busy = true
59 $.ui.invalidate('ui.render')
60 try {
61 await sessionFacts($)
62 const { root, home: hd, cfg } = await context($)
63 const dirs = splitDirs(await $.env.get('CLAUDE_CODE_PLUGIN_DIRS'))
64 const siblings = cfg.siblings === false ? [] : [root.replace(/\/[^/]+$/, '')]
65 const now = await $.clock.now()
66 const r = await $.process.run([cfg.node || 'node', `${root}/scanner/sante_scan.mjs`], {
67 stdin: JSON.stringify({ dirs, home: hd, cfg, now, siblings, commands: S.commands, engine: S.engine }),
68 timeoutMs: Math.min((cfg.scan_timeout_s ?? 60) * 1000, 600000),
69 })
70 const out = JSON.parse(r.stdout || '{}')
71 if (r.exitCode !== 0 || out.error) throw new Error(out.error || (r.stderr || '').trim().split('\n').pop() || `scanner exit ${r.exitCode}`)
72 S.report = out
73 S.error = ''
74 } catch (err) {
75 S.error = String((err as Error)?.message ?? err).slice(0, 300)
76 } finally {
77 S.busy = false
78 }
79 $.ui.invalidate('ui.render')
80}
81
82async function load($: EngineInterface) {
83 const { cfg, dataDir, home: homeDir } = await context($)
84 if (!S.report) S.report = await readJson($, `${dataDir}/report.json`)
85 const now = await $.clock.now()
86 const stale = !S.report || now - (S.report.generated || 0) > (cfg.refresh_min ?? 10) * 60000
87 return { cfg, now, stale, homeDir }
88}
89
90export const register: Register = (on, raw) => {
91 options = (raw ?? {}) as Options
92
93 on('session.start', async ($, e, next) => {
94 await $.command.register({
95 name: 'sante',
96 description: 'Santé des mods : chargés ou non, versions, commandes, caches, erreurs, CPU des scans, JSON invalides (args: [refresh] | close)',
97 argumentHint: '[refresh] | close',
98 })
99 return next(e)
100 })
101
102 on('command.run', { command: 'sante' }, async ($, e) => {
103 const words = String(e.args ?? '').trim().toLowerCase().split(/\s+/).filter(Boolean)
104 if (words[0] === 'close') {
105 await $.ui.close({ id: PANE })
106 return {}
107 }
108 await sessionFacts($)
109 const { cfg, stale } = await load($)
110 if (stale || words.includes('refresh')) $.clock.after(0, () => rescan($))
111 await $.ui.open({ id: PANE, title: cfg.labels?.pane_title ?? 'Santé des mods' })
112 return {} // a pane, no transcript text: nothing for the model to read
113 })
114
115 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
116 const { cfg, now, homeDir } = await load($)
117 return drawSante($.ui.resolve(e), {
118 report: S.report, error: S.error, busy: S.busy, copied: S.copied, commands: S.commands, engine: S.engine, cfg, now, homeDir,
119 onRefresh: () => { $.clock.after(0, () => rescan($)) },
120 onCopy: async (cmd, press) => {
121 const c = await $.ui.copy({ text: cmd, surface: press?.surface })
122 S.copied = c?.isCopied ? cfg.labels?.copied ?? 'copié' : cfg.labels?.copy_failed ?? 'copie impossible'
123 $.ui.invalidate('ui.render')
124 },
125 })
126 })
127}
128hooks/draw.tsx 110 lines1// sante mod: the pane's drawing, pure. No `$`: the elements come in as `ui` ($.ui.resolve(e)) and every action as a
2// callback in `m`, so the sante pane and the Référence hub (mods/reference, a synced copy of this file) draw the same
3// tree from the same report. Same output as the drawing that lived in register.tsx before.
4import { evaluate, fill, fmtAgo, fmtBytes, fmtDur, fmtNum } from './logic.mjs'
5
6export type SanteModel = {
7 report: any
8 error: string
9 busy: boolean
10 copied: string
11 commands: any[]
12 engine: string
13 cfg: any
14 now: number
15 homeDir: string
16 onRefresh: () => void
17 onCopy: (cmd: string, press: any) => void | Promise<void>
18}
19
20export function drawSante(ui: any, m: SanteModel): any {
21 const { Box, Text, Button } = ui
22 const { cfg, now, homeDir } = m
23 const l = cfg.labels ?? {}
24 const colors = cfg.colors ?? {}
25 const icons = cfg.icons ?? {}
26 const color = (lv: string) => (colors[lv] ? { color: colors[lv] } : {})
27 const busy = m.busy ? l.busy ?? 'examen en cours…' : ''
28 if (!m.report) {
29 return (
30 <Box flexDirection="column" gap={1}>
31 <Text>{l.first_scan ?? 'Premier examen des mods en cours…'}</Text>
32 {m.error ? <Text color="error">{`scan : ${m.error}`}</Text> : null}
33 {busy ? <Text dimColor>{busy}</Text> : null}
34 </Box>
35 )
36 }
37 const { rows, summary } = evaluate(m.report, { commands: m.commands, engine: m.engine, cfg, now })
38 const active = rows.filter((r: any) => r.level !== 'off')
39 const off = rows.filter((r: any) => r.level === 'off')
40 const SRC: Record<string, string> = { env: 'CLAUDE_CODE_PLUGIN_DIRS', dir: 'dossier', marketplace: 'marketplace', health: 'fiche santé seule' }
41 const src = (s: string) => l[`source_${s}`] ?? SRC[s] ?? s
42
43 const modBlock = (r: any) => {
44 const head = [
45 `${icons[r.level] ?? '●'} ${r.name}${r.version ? ` ${r.version}` : ''}`,
46 r.loaded ? l.loaded ?? 'chargé' : l.not_loaded ?? 'non chargé',
47 src(r.source),
48 r.commands.length ? r.commands.map((c: string) => `/${c}`).join(' ') : l.no_commands ?? 'aucune commande',
49 fill(l.min_version ?? 'minimum {min}', { min: r.minVersion ?? '?' }),
50 ].join(' · ')
51 const badJson = r.configs.filter((c: any) => !c.ok).length
52 const facts = [
53 r.lastRefresh ? `${l.refresh ?? 'dernier rafraîchissement'} ${fmtAgo(r.lastRefresh, now)}` : l.no_refresh ?? 'aucun cache connu',
54 r.caches.length ? `${l.cache ?? 'cache'} ${fmtBytes(r.cacheBytes)}${r.caches.some((c: any) => c.truncated) ? '+' : ''} (${r.caches.length})` : '',
55 r.configs.length ? `${l.configs ?? 'config'} ${badJson ? `${badJson}/${r.configs.length} invalides` : fill(l.configs_ok ?? '{n} JSON valides', { n: r.configs.length })}` : '',
56 ].filter(Boolean).join(' · ')
57 const hr = r.health
58 const healthLine = hr
59 ? fill(l.health ?? 'dernier scan {ago} · {dur} · CPU {cpu}', { ago: fmtAgo(hr.lastRun, now), dur: fmtDur(hr.durationMs), cpu: fmtDur(hr.cpuMs) })
60 : l.no_health ?? 'pas de fiche santé'
61 const cost = r.cost
62 ? fill(l.cost ?? '24 h : {runs} runs ({rate}/h) · CPU {cpu} ≈ {core} % d\'un cœur', {
63 runs: fmtNum(hr.window.runs), rate: fmtNum(r.cost.runsPerHour, 1), cpu: fmtDur(r.cost.cpuMs), core: fmtNum(r.cost.corePct, 2),
64 })
65 : ''
66 return (
67 <Box key={`mod-${r.name}`} flexDirection="column">
68 <Text {...color(r.level)} bold>{head}</Text>
69 {r.reasons.map((x: any) => <Text {...color(x.level)}>{` ↳ ${x.text}`}</Text>)}
70 <Text dimColor>{` ${facts}`}</Text>
71 <Text dimColor>{` ${healthLine}${cost ? ` · ${cost}` : ''}`}</Text>
72 {r.cleanup.map((cmd: string, i: number) => (
73 <Box key={`clean-${r.name}-${i}`} flexDirection="row" gap={1} flexWrap="wrap">
74 <Text>{` ${l.run_yourself ?? 'à lancer toi-même :'} ${cmd}`}</Text>
75 <Button key={`copy-${r.name}-${i}`} plain onPress={(press: any) => m.onCopy(cmd, press)}>
76 {l.copy ?? 'Copier'}
77 </Button>
78 </Box>
79 ))}
80 </Box>
81 )
82 }
83
84 return (
85 <Box flexDirection="column" gap={1}>
86 <Text {...color(summary.level)} bold>{`${icons[summary.level] ?? '●'} ${summary.text}`}</Text>
87 <Box flexDirection="row" gap={1} flexWrap="wrap">
88 <Text dimColor>{fill(l.scanned ?? 'examiné {ago} · {n} dossiers', { ago: fmtAgo(m.report.generated, now), n: rows.length }) + (busy ? ` · ${busy}` : '') + (m.copied ? ` · ${m.copied}` : '')}</Text>
89 <Button key="refresh" plain onPress={() => m.onRefresh()}>{l.refresh_button ?? '↻'}</Button>
90 </Box>
91 {m.error ? <Text color="error">{`scan : ${m.error}`}</Text> : null}
92 <Box flexDirection="column" gap={1}>
93 <Text bold>{l.section_active ?? 'Mods actifs'}</Text>
94 {active.map(modBlock)}
95 </Box>
96 {off.length ? (
97 <Box flexDirection="column">
98 <Text bold>{l.section_off ?? 'Installés, non chargés'}</Text>
99 {off.map((r: any) => (
100 <Text {...color('off')}>{`${icons.off ?? '○'} ${r.name}${r.version ? ` ${r.version}` : ''} · ${src(r.source)}${r.dir ? ` · ${(homeDir && r.dir.startsWith(homeDir) ? `~${r.dir.slice(homeDir.length)}` : r.dir)}` : ''}`}</Text>
101 ))}
102 </Box>
103 ) : null}
104 <Box flexDirection="column">
105 {(l.notes ?? []).map((x: string) => <Text dimColor>{`· ${x}`}</Text>)}
106 </Box>
107 </Box>
108 )
109}
110hooks/logic.mjs 235 lines1// sante mod ("Santé des mods"): pure logic, no $ and no I/O, so node --test covers it directly.
2// The scanner (scanner/sante_scan.mjs) gathers the file facts; the hook adds the session's commands and the engine
3// version; evaluate() turns both into one row per mod with a level (ok / warn / error / off) and its reasons.
4
5/** Deep merge of config objects (arrays and scalars of `b` replace). */
6export function deepMerge(a, b) {
7 if (Array.isArray(b)) return b.slice()
8 if (!b || typeof b !== 'object') return b === undefined ? a : b
9 const out = { ...(a && typeof a === 'object' && !Array.isArray(a) ? a : {}) }
10 for (const [k, v] of Object.entries(b)) out[k] = deepMerge(out[k], v)
11 return out
12}
13
14/** `~` to the home folder, forward slashes. */
15export const expandHome = (p, home) => String(p || '').replace(/^~(?=[\\/]|$)/, home || '~').replace(/\\/g, '/')
16
17/** `{name}` placeholders from `vars`. */
18export const fill = (s, vars = {}) => String(s ?? '').replace(/\{(\w+)\}/g, (m, k) => (vars[k] === undefined ? m : String(vars[k])))
19
20/**
21 * CLAUDE_CODE_PLUGIN_DIRS as a list: `;` separates on Windows, `:` elsewhere (reference: plugins/mods/reference).
22 * A value with no `;` that starts with a drive letter is one Windows path, not two.
23 */
24export function splitDirs(value) {
25 const s = String(value || '').trim()
26 if (!s) return []
27 const parts = s.includes(';') ? s.split(';') : /^[A-Za-z]:[\\/]/.test(s) ? [s] : s.split(':')
28 return parts.map(p => p.trim().replace(/^"|"$/g, '').replace(/\\/g, '/').replace(/\/+$/, '')).filter(Boolean)
29}
30
31/** Compares two versions by their first three numbers (2.1.287); undefined when one has none. */
32export function cmpVersion(a, b) {
33 const pa = String(a || '').match(/(\d+)\.(\d+)\.(\d+)/)
34 const pb = String(b || '').match(/(\d+)\.(\d+)\.(\d+)/)
35 if (!pa || !pb) return undefined
36 for (let i = 1; i <= 3; i++) {
37 const d = Number(pa[i]) - Number(pb[i])
38 if (d) return d < 0 ? -1 : 1
39 }
40 return 0
41}
42
43/** Slash commands a hooks module registers: `$.command.register({ name: 'x' ...`. */
44export function registeredCommands(source) {
45 const out = []
46 const re = /command\.register\(\s*\{[^}]*?\bname\s*:\s*['"`]([\w-]{1,64})['"`]/g
47 const code = String(source || '').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:\\])\/\/.*$/gm, '$1')
48 for (const m of code.matchAll(re)) if (!out.includes(m[1])) out.push(m[1])
49 return out
50}
51
52/** Value at a dotted path (`meta.errors`). */
53export function getPath(obj, path) {
54 let v = obj
55 for (const k of String(path || '').split('.').filter(Boolean)) {
56 if (!v || typeof v !== 'object') return undefined
57 v = v[k]
58 }
59 return v
60}
61
62/** Number with French separators: 1 234,5. */
63export function fmtNum(v, digits = 0) {
64 if (v === null || v === undefined || !Number.isFinite(v)) return '—'
65 const s = Math.abs(v).toFixed(digits).replace('.', ',')
66 const [i, d] = s.split(',')
67 const out = i.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') + (d ? `,${d}` : '')
68 return v < 0 ? `-${out}` : out
69}
70
71/** Bytes as 12 o / 3,4 Ko / 1,2 Mo / 2,0 Go. */
72export function fmtBytes(n) {
73 if (!Number.isFinite(n)) return '—'
74 if (n < 1024) return `${n} o`
75 if (n < 1024 ** 2) return `${fmtNum(n / 1024, 1)} Ko`
76 if (n < 1024 ** 3) return `${fmtNum(n / 1024 ** 2, 1)} Mo`
77 return `${fmtNum(n / 1024 ** 3, 1)} Go`
78}
79
80/** A duration in ms as 850 ms / 2,3 s / 4 min / 3 h / 2 j. */
81export function fmtDur(ms) {
82 if (!Number.isFinite(ms) || ms < 0) return '—'
83 if (ms < 1000) return `${Math.round(ms)} ms`
84 if (ms < 60000) return `${fmtNum(ms / 1000, 1)} s`
85 if (ms < 3600000) return `${Math.round(ms / 60000)} min`
86 if (ms < 48 * 3600000) return `${Math.round(ms / 3600000)} h`
87 return `${Math.round(ms / 86400000)} j`
88}
89
90/** « il y a 4 min » from a time (ms or ISO) and now. */
91export function fmtAgo(t, now) {
92 const ms = typeof t === 'string' ? Date.parse(t) : t
93 if (!Number.isFinite(ms) || !ms) return 'jamais'
94 return `il y a ${fmtDur(Math.max(0, now - ms))}`
95}
96
97/** A time (ms or ISO) as ms, or undefined. */
98export const toMs = t => {
99 const ms = typeof t === 'string' ? Date.parse(t) : t
100 return Number.isFinite(ms) && ms > 0 ? ms : undefined
101}
102
103/**
104 * The shared health record (convention in mods/sante/README.md), as the next run writes it: last run, its duration,
105 * CPU and error, the last success, and a rolling 24 h window (runs, CPU, duration) for the approximate CPU cost.
106 * Reference implementation; band, usage, metrics, ci and machine carry their own few-line copy.
107 */
108export function nextHealth(prev, { mod, now, durationMs, cpuMs, error }, windowMs = 86400000) {
109 const p = prev && typeof prev === 'object' ? prev : {}
110 const start = toMs(p.window?.start)
111 const w = start && now - start < windowMs ? p.window : { start: new Date(now).toISOString(), runs: 0, cpuMs: 0, durationMs: 0, errors: 0 }
112 const iso = new Date(now).toISOString()
113 return {
114 mod,
115 lastRun: iso,
116 durationMs: Math.round(durationMs),
117 cpuMs: Math.round(cpuMs),
118 error: error ? String(error).slice(0, 500) : null,
119 lastOk: error ? p.lastOk ?? null : iso,
120 window: { start: w.start, runs: w.runs + 1, cpuMs: Math.round(w.cpuMs + cpuMs), durationMs: Math.round(w.durationMs + durationMs), errors: (w.errors || 0) + (error ? 1 : 0) },
121 }
122}
123
124/** CPU cost of a health record's window: ms of CPU, its share of one core over the window, runs per hour. */
125export function cpuCost(rec, now) {
126 const start = toMs(rec?.window?.start)
127 if (!start || !rec.window.runs) return undefined
128 const span = Math.max(60000, now - start)
129 return { cpuMs: rec.window.cpuMs || 0, corePct: ((rec.window.cpuMs || 0) / span) * 100, runsPerHour: (rec.window.runs / span) * 3600000, avgMs: (rec.window.durationMs || 0) / rec.window.runs, spanMs: span }
130}
131
132const RANK = { off: 0, ok: 1, warn: 2, error: 3 }
133const worst = (a, b) => (RANK[b] > RANK[a] ? b : a)
134
135/** Commands of this session that belong to the plugin `name` (or to nobody while one of its own names matches). */
136export function commandsOf(name, commands, expected = []) {
137 return (commands || [])
138 .filter(c => c.source === undefined || c.source === 'plugin')
139 .filter(c => (c.plugin ? c.plugin === name || c.plugin.startsWith(`${name}@`) : expected.includes(c.name)))
140 .map(c => c.name)
141}
142
143/**
144 * One row per mod: the scanner's facts + this session's commands + the engine version → level and reasons.
145 * report.mods[]: { key, name, dir, source: 'env' | 'dir' | 'marketplace' | 'health', exists, manifest, hooks, expected,
146 * configs[], caches[], logs[], fields[], health }
147 */
148export function evaluate(report, { commands = [], engine, cfg = {}, now = Date.now() } = {}) {
149 const t = cfg.thresholds || {}
150 const L = cfg.labels || {}
151 const lab = (k, d, v) => fill(L[k] ?? d, v)
152 const mods = (report && report.mods) || []
153 const names = mods.map(m => m.name)
154 const rows = mods.map(m => {
155 const per = (cfg.mods || {})[m.name] || {}
156 const reasons = []
157 let level = 'ok'
158 const add = (lv, text) => {
159 level = worst(level, lv)
160 reasons.push({ level: lv, text })
161 }
162 const expected = m.expected || []
163 const cmds = commandsOf(m.name, commands, expected)
164 const loaded = cmds.length > 0
165 const inEnv = m.source === 'env'
166 const minVersion = per.min_version || m.manifest?.min_version || cfg.default_min_version
167 const cmp = engine ? cmpVersion(engine, minVersion) : undefined
168
169 if (inEnv && !m.exists) add('error', lab('r_missing', 'dossier introuvable : {dir}', { dir: m.dir }))
170 if (m.exists && m.manifest && !m.manifest.ok) add('error', lab('r_manifest', 'plugin.json illisible : {error}', { error: m.manifest.error }))
171 if (m.exists && m.hooks && !m.hooks.ok) add('error', lab('r_hooks', 'hooks : {error}', { error: m.hooks.error }))
172 for (const c of m.configs || []) if (!c.ok) add('error', lab('r_json', 'JSON invalide : {path} ({error})', { path: c.path, error: c.error }))
173 if (cmp !== undefined && cmp < 0 && (inEnv || loaded)) add('error', lab('r_version', 'Claude Code {engine} < minimum {min}', { engine, min: minVersion }))
174 if (inEnv && m.exists && expected.length && !loaded) add('warn', lab('r_not_loaded', 'dans CLAUDE_CODE_PLUGIN_DIRS mais aucune commande enregistrée (/{cmds}) : session ouverte avant ? hook session.start en erreur ?', { cmds: expected.join(', /') }))
175 const missing = loaded ? expected.filter(c => !cmds.includes(c)) : []
176 if (missing.length) add('warn', lab('r_cmd_missing', 'commande non enregistrée : /{cmds}', { cmds: missing.join(', /') }))
177 if (names.filter(n => n === m.name).length > 1 && m.source !== 'health') add('warn', lab('r_dup', 'même nom chargé deux fois'))
178
179 const h = m.health
180 if (h && h.invalid) add('error', lab('r_health_json', 'fiche santé illisible : {error}', { error: h.invalid }))
181 else if (h && h.error) {
182 const age = now - (toMs(h.lastRun) || 0)
183 if (age <= (t.health_error_hours ?? 24) * 3600000) add('error', lab('r_health_error', 'dernier scan en erreur ({ago}) : {error}', { ago: fmtAgo(h.lastRun, now), error: h.error }))
184 }
185 const staleMin = per.stale_min ?? null
186 const newest = Math.max(0, ...(m.caches || []).map(c => c.newest || 0), toMs(h?.lastOk) || 0)
187 if (staleMin && (inEnv || loaded) && newest && now - newest > staleMin * 60000) add('warn', lab('r_stale', 'pas rafraîchi depuis {ago} (attendu < {min} min)', { ago: fmtDur(now - newest), min: staleMin }))
188 const warnBytes = (per.cache_warn_mb ?? t.cache_warn_mb ?? 500) * 1024 * 1024
189 const big = (m.caches || []).filter(c => c.bytes > warnBytes)
190 for (const c of big) add('warn', lab('r_big', 'cache lourd : {path} {size}', { path: c.path, size: fmtBytes(c.bytes) }))
191 for (const l of m.logs || []) if (l.errors) add(per.log_level || 'warn', lab('r_log', '{n} erreur(s) dans {path} : {last}', { n: l.errors, path: l.path, last: l.last }))
192 for (const f of m.fields || []) if (f.count) add('warn', lab('r_field', '{n} erreur(s) dans {path} → {field} : {last}', { n: f.count, path: f.path, field: f.field, last: f.last }))
193
194 if (!inEnv && !loaded && level === 'ok') level = 'off'
195 const cost = h && !h.invalid ? cpuCost(h, now) : undefined
196 return {
197 name: m.name,
198 dir: m.dir,
199 source: m.source,
200 version: m.manifest?.version,
201 loaded,
202 inEnv,
203 commands: cmds,
204 expected,
205 minVersion,
206 level,
207 reasons,
208 lastRefresh: newest || undefined,
209 health: h && !h.invalid ? h : undefined,
210 cost,
211 cacheBytes: (m.caches || []).reduce((s, c) => s + (c.bytes || 0), 0),
212 caches: m.caches || [],
213 configs: m.configs || [],
214 cleanup: big.map(c => fill(cfg.cleanup_command || 'Remove-Item -LiteralPath "{path}" -Recurse', { path: c.path })),
215 }
216 })
217 return { rows, summary: summarize(rows, { engine, report, cfg }) }
218}
219
220/** The single top line: worst level among the mods that should run, with counts and the first reasons. */
221export function summarize(rows, { engine, report, cfg = {} } = {}) {
222 const L = cfg.labels || {}
223 const active = rows.filter(r => r.level !== 'off')
224 const count = lv => active.filter(r => r.level === lv).length
225 let level = active.reduce((a, r) => worst(a, r.level), 'ok')
226 if (report && report.error) level = 'error'
227 const bad = active.filter(r => r.level === 'error' || r.level === 'warn').sort((a, b) => RANK[b.level] - RANK[a.level])
228 const first = bad.slice(0, cfg.summary_reasons ?? 2).map(r => `${r.name} : ${r.reasons.find(x => x.level === r.level)?.text ?? ''}`)
229 const word = { ok: L.level_ok ?? 'vert', warn: L.level_warn ?? 'orange', error: L.level_error ?? 'rouge' }[level]
230 const text = fill(L.summary ?? '{word} · {active} mods actifs : {ok} OK, {warn} orange, {error} rouge · {off} non chargés · Claude Code {engine}', {
231 word, active: active.length, ok: count('ok'), warn: count('warn'), error: count('error'), off: rows.length - active.length, engine: engine || '?',
232 })
233 return { level, text: [text, ...first, ...(report && report.error ? [`scan : ${report.error}`] : [])].join(' · ') }
234}
235