SLOPSHOPPER

sante

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…

panecommandprocesstimer
v0.1.0MITupdated 2026-10-16julian-passebecq/claude-mod/mods/sante
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sante
│ ┃ Santé des mods ✕ › fix the failing auth test and add an audit log call │ ┃ Premier examen des mods en cours… │ ⏺ 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 │ │ › /sante │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Santé des mods
Premier examen des mods en cours…
README

sante — Santé des mods

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:

  • loaded or not: a mod is « chargé » when at least one of its slash commands is in this session ($.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).
  • version (its plugin.json), commands registered vs the ones its hooks module registers ($.command.register({ name }) read from hooks/*).
  • last refresh: the newest change among its cache files and folders, or its health record's lastOk; stale_min per mod turns it orange.
  • errors: a health record's 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).
  • scan duration and approximate CPU: from health records (below): last run, its duration and CPU, and over the last 24 h the runs, runs per hour and CPU time as a share of one core.
  • cache size on disk (sum of file sizes, capped at 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.
  • config files: 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.
  • Claude Code version vs the mod's minimum: $.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.

How it runs

  • /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.
  • The scanner only reads, except its own report (~/.claude/sante-mod/report.json) and its own health record (~/.claude/mods-health/sante.json). It never deletes anything.
  • The folder lists come from 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.
  • Findings for the Problèmes register: the hook sends the session's commands and Claude Code version with the request, the scanner runs the same 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.
  • From a shell: node mods/sante/scanner/sante_scan.mjs (folders from CLAUDE_CODE_PLUGIN_DIRS, or --dirs "a;b") prints the same report as JSON.
  • Everything is in 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.

Shared convention: ~/.claude/mods-health/<mod>.json

Any 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 ».
  • Write to <file>.<pid>.tmp, then rename. MODS_HEALTH_DIR moves the folder (tests); MODS_HEALTH=0 turns writing off.
  • Reference code: 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.
  • Optional 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).

Tests

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).

Source 3 files
hooks/register.tsx 128 lines
1// 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}
128
hooks/draw.tsx 110 lines
1// 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}
110
hooks/logic.mjs 235 lines
1// 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