SLOPSHOPPER

registres

Registres : un seul panneau à onglets Problèmes | Tests réels | Règles | Ménage, en lecture seule. Chaque onglet est dessiné par une copie exacte du code de…

panecommandprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · registres
│ ┃ Registres ✕ › fix the failing auth test and add an audit log call │ ┃ [ problemes ] tests regles menage │ ┃ ⏺ 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 │ │ › /registres │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Registres
[ problemes ] tests regles menage
README

registres (« Registres »)

One of the 6 entries of the band's Mods bar (Jauge, Cockpit, Veille, À toi, Registres, Référence).

One side pane with four tabs: Problèmes | Tests réels | Règles | Ménage. It replaces four separate panes in the band's Mods bar; each tab shows exactly what its owner mod shows (problemes, tests-reels, metrics, menage), from the same files in ~/.claude.

Commands

CommandWhat it does
/registresopens the pane on the last tab (first time: default_tab, Problèmes)
`/registres problemes [refresh [--force] \P-NNN \export]`Problèmes tab, with the owner's arguments (P-012 opens that problem)
/registres testsTests réels tab
/registres regles [V5.8] [refresh]Règles tab (« Les règles marchent ? »)
/registres menage [refresh]Ménage tab
/registres closecloses the pane
/problemes, /tests, /regles, /menagealiases of the lines above, registered by Registres only when no other mod serves that name in this session: while the old mods are loaded they keep their own commands and panes, so habits don't break either way

The command returns no text, so nothing reaches the model. No model call. Nothing blocks, rewrites or delays a tool call or a prompt.

How it works

A mod cannot draw in another mod's pane nor import a file outside its folder. So each owner keeps its drawing in a pure module (no $: element table, facts, callbacks in, tree out) and Registres draws its tabs with byte-for-byte copies of them:

TabOwnerCopied into lib/ (never edit a copy)
Problèmesmods/problemeshooks/view.mjs, lib/core.mjs, lib/export.mjs, lib/eta/* (from export-eta), scanner/problemes_scan.mjs, problemes.config.json
Tests réelsmods/tests-reelshooks/view.mjs, lib/registry.mjs, lib/secrets.mjs, lib/recover.mjs, lib/io.mjs, scanner/recover.mjs, tests-reels.config.json
Règlesmods/metricshooks/pane.mjs, hooks/view.mjs, lib/collect.mjs, scanner/metrics_scan.mjs, metrics.config.json (+ lib/usage/lib/transcripts.mjs, prices.json from lib/core)
Ménagemods/menagehooks/view.mjs, lib/classify.mjs, scanner/menage_scan.mjs, scanner/sizes.ps1, menage.config.json

The pairs are listed in lib/core/targets.json; node scripts/sync-libs.mjs writes them and node scripts/sync-libs.mjs --check (CI) fails when a copy drifts. Change the owner, run the sync, commit both.

  • Data: the owners' own folders (~/.claude/problemes, ~/.claude/tests-reels, ~/.claude/metrics-mod, ~/.claude/menage-mod) and their override files there. Registres writes nothing of its own.
  • Scanners: the owner's scanner (the copy in lib/<owner>/scanner/) runs in the background only on ↻ (or « Lancer la récupération » for Tests réels) or when the tab's file is older than the owner's refresh_min (on opening or switching to that tab). It writes only into the owner's data folder, as the owner would.
  • Secrets (Tests réels): logins masked until « voir » is pressed one by one; secret values are never stored, read or shown, only where they live.
  • Santé and Veille errors reach the Problèmes tab through the problemes collector (category Mods, source santé / veille).

Settings

registres.config.json, deep-merged with ~/.claude/registres/registres.config.json: tabs (order), hidden_tabs, default_tab, aliases, alias_delay_ms, labels (pane title, tab names, hint, notes). Each tab's own labels, colors and limits stay in its owner's config.

Tests

node --test mods/registres/tests/*.test.mjs
claude plugin validate mods/registres
claude plugin test mods/registres

Invented problems, apps, accounts and folders only.

Source 11 files
hooks/register.tsx 345 lines
1// registres mod (« Registres »): one pane with tabs Problèmes | Tests réels | Règles | Ménage.
2//   /registres [problemes|tests|regles|menage] [the owner's arguments] | close
3// Aliases /problemes, /tests, /regles and /menage are registered here only when no other mod serves them in this session,
4// so the old mods keep theirs while they are loaded. A mod cannot draw another mod's pane nor import outside its folder,
5// so each tab is drawn by a byte-for-byte copy of its owner's pure view (lib/<owner>/hooks/, made by
6// scripts/sync-libs.mjs from lib/core/targets.json, never edited by hand) over the owner's own files in ~/.claude.
7// The owner's scanner (copied too) runs only on ↻ or when its file is older than the owner's refresh_min. The command
8// shows a pane and returns no text, so nothing reaches the model; no model call; nothing here blocks, rewrites or
9// delays a tool call or a prompt.
10import { atom, read, update } from 'claude-code'
11import type { EngineInterface, Register } from 'claude-code'
12
13import { aliasesToRegister, deepMerge, expandHome, fill, isStale, parseArgs, tabOrder, TABS } from './logic.mjs'
14import { drawProblemes } from '../lib/problemes/hooks/view.mjs'
15import { drawTestsReels } from '../lib/tests-reels/hooks/view.mjs'
16import { buildView, parseRuns } from '../lib/tests-reels/lib/registry.mjs'
17import { drawRegles } from '../lib/metrics/hooks/pane.mjs'
18import { drawMenage } from '../lib/menage/hooks/view.mjs'
19
20const PANE = 'registres'
21const tabAtom = atom({ plugin: 'registres', key: 'tab' } as const, '')
22const pStatusAtom = atom({ plugin: 'registres', key: 'p_status' } as const, 'all')
23const pWeekAtom = atom({ plugin: 'registres', key: 'p_week' } as const, 'all')
24const pCategoryAtom = atom({ plugin: 'registres', key: 'p_category' } as const, 'all')
25const pAppAtom = atom({ plugin: 'registres', key: 'p_app' } as const, 'all')
26const pOpenAtom = atom({ plugin: 'registres', key: 'p_open' } as const, [] as string[])
27const pNoteAtom = atom({ plugin: 'registres', key: 'p_note' } as const, '')
28const tOpenAtom = atom({ plugin: 'registres', key: 't_open' } as const, null as unknown as string[] | null)
29const tRevealAtom = atom({ plugin: 'registres', key: 't_reveal' } as const, [] as string[])
30const tStatusAtom = atom({ plugin: 'registres', key: 't_status' } as const, '')
31const rVersionAtom = atom({ plugin: 'registres', key: 'r_version' } as const, '')
32const mFilterAtom = atom({ plugin: 'registres', key: 'm_filter' } as const, 'all')
33const mCopiedAtom = atom({ plugin: 'registres', key: 'm_copied' } as const, '')
34
35type Slot = { busy: boolean; error: string; data: any; exporting?: boolean; exportMsg?: string }
36const S: { aliases: Set<string>; slot: Record<string, Slot> } = {
37  aliases: new Set(),
38  slot: Object.fromEntries(TABS.map(t => [t, { busy: false, error: '', data: undefined, exporting: false, exportMsg: '' }])),
39}
40
41// Owner folder (inside lib/), config file, and data file per tab.
42const OWNER: Record<string, { dir: string; config: string; dataDir: string; userConfig: string }> = {
43  problemes: { dir: 'problemes', config: 'problemes.config.json', dataDir: '~/.claude/problemes', userConfig: 'problemes.config.json' },
44  tests: { dir: 'tests-reels', config: 'tests-reels.config.json', dataDir: '~/.claude/tests-reels', userConfig: 'config.json' },
45  regles: { dir: 'metrics', config: 'metrics.config.json', dataDir: '~/.claude/metrics-mod', userConfig: 'metrics.config.json' },
46  menage: { dir: 'menage', config: 'menage.config.json', dataDir: '~/.claude/menage-mod', userConfig: 'menage.config.json' },
47}
48
49const home = async ($: EngineInterface) => {
50  let v: string | undefined
51  try {
52    v = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME'))
53  } catch {}
54  return (v ?? '').replace(/\\/g, '/')
55}
56const readText = async ($: EngineInterface, path: string): Promise<string | undefined> => {
57  try {
58    return (await $.fs.read(path, { as: 'text' })) as string
59  } catch {
60    return undefined
61  }
62}
63const readJson = async ($: EngineInterface, path: string): Promise<any> => {
64  const t = await readText($, path)
65  try {
66    return t ? JSON.parse(t.replace(/^/, '')) : undefined
67  } catch {
68    return undefined
69  }
70}
71
72async function hubConfig($: EngineInterface) {
73  const root = $.plugin.root.replace(/\\/g, '/')
74  const h = await home($)
75  const base = (await readJson($, `${root}/registres.config.json`)) || {}
76  const dataDir = expandHome(base.data_dir || '~/.claude/registres', h)
77  return { root, h, cfg: deepMerge(base, (await readJson($, `${dataDir}/registres.config.json`)) || {}) }
78}
79
80/** The owner's config (its copy here, then the person's override in the owner's data folder), as the owner reads it. */
81async function ownerContext($: EngineInterface, tab: string) {
82  const { root, h } = await hubConfig($)
83  const o = OWNER[tab]
84  const libRoot = `${root}/lib/${o.dir}`
85  const base = (await readJson($, `${libRoot}/${o.config}`)) || {}
86  const dataDir = expandHome(base.data_dir || o.dataDir, h)
87  const localText = await readText($, `${dataDir}/${o.userConfig}`)
88  const local = await readJson($, `${dataDir}/${o.userConfig}`)
89  const cfg = deepMerge(base, local || {})
90  if (tab === 'tests') cfg._local_error = localText && !local ? `${dataDir}/config.json illisible (JSON invalide)` : ''
91  return { libRoot, dataDir, cfg }
92}
93
94/** The tab's data, cached until a scanner run or ↻; `generated` is what staleness is measured on. */
95async function loadTab($: EngineInterface, tab: string) {
96  const { cfg, dataDir, libRoot } = await ownerContext($, tab)
97  const slot = S.slot[tab]
98  if (slot.data === undefined) {
99    if (tab === 'problemes') slot.data = (await readJson($, `${dataDir}/problems.json`)) ?? null
100    else if (tab === 'regles') slot.data = (await readJson($, `${dataDir}/metrics.json`)) ?? null
101    else if (tab === 'menage') slot.data = (await readJson($, `${dataDir}/menage.json`)) ?? null
102    else {
103      const registry = (await readJson($, `${dataDir}/registry.json`)) || { apps: [] }
104      const runs = parseRuns((await readText($, `${dataDir}/runs.jsonl`)) || '')
105      const recovered = (await readJson($, `${dataDir}/recovered.json`)) || null
106      slot.data = buildView(registry, runs, recovered, cfg)
107    }
108  }
109  const generated = tab === 'problemes' ? slot.data?.collected : slot.data?.generated
110  const now = await $.clock.now()
111  const stale = tab === 'tests' ? false : isStale(generated, now, cfg.refresh_min ?? 30)
112  return { cfg, dataDir, libRoot, data: slot.data, now, stale }
113}
114
115const lastLine = (s: string) => (s || '').trim().split('\n').pop() || ''
116
117/** Runs the owner's scanner copy in the background and redraws; never awaited by a hook the person waits on. */
118async function runScanner($: EngineInterface, tab: string, force = false) {
119  const slot = S.slot[tab]
120  if (slot.busy) return
121  slot.busy = true
122  $.ui.invalidate('ui.render')
123  try {
124    const { cfg, libRoot } = await ownerContext($, tab)
125    const node = cfg.node || 'node'
126    const argv =
127      tab === 'problemes' ? [node, `${libRoot}/scanner/problemes_scan.mjs`, 'collect', ...(force ? ['--force'] : [])]
128        : tab === 'regles' ? [node, `${libRoot}/scanner/metrics_scan.mjs`, 'scan', ...(force ? ['--force'] : [])]
129          : tab === 'menage' ? [node, `${libRoot}/scanner/menage_scan.mjs`, 'scan', ...(force ? ['--force'] : [])]
130            : [node, `${libRoot}/scanner/recover.mjs`]
131    const limit = tab === 'problemes' ? cfg.collect_timeout_s ?? 300 : cfg.scan_timeout_s ?? 600
132    const r = await $.process.run(argv, { timeoutMs: Math.min(limit * 1000, 600000) })
133    if (r.exitCode !== 0) throw new Error(lastLine(r.stderr) || `scanner exit ${r.exitCode}`)
134    slot.error = ''
135    if (tab === 'tests') {
136      const out = JSON.parse(lastLine(String(r.stdout || '{}')) || '{}')
137      await update($, tStatusAtom, () => fill(cfg.labels?.recover_done ?? '', out.counts ?? {}))
138    }
139  } catch (err) {
140    slot.error = String((err as Error)?.message ?? err).slice(0, 300)
141  } finally {
142    slot.busy = false
143    slot.data = undefined
144  }
145  $.ui.invalidate('ui.render')
146}
147
148/** « Exporter vers eta » of the Problèmes tab: the owner's scanner export (masked register → eta by PR). */
149async function exportEta($: EngineInterface) {
150  const slot = S.slot.problemes
151  if (slot.exporting) return
152  slot.exporting = true
153  slot.exportMsg = ''
154  $.ui.invalidate('ui.render')
155  try {
156    const { cfg, libRoot } = await ownerContext($, 'problemes')
157    const r = await $.process.run([cfg.node || 'node', `${libRoot}/scanner/problemes_scan.mjs`, 'export'], { timeoutMs: 600000 })
158    slot.exportMsg = lastLine((r.exitCode === 0 ? r.stdout : r.stderr || r.stdout) || '').replace(/^problemes: /, '').slice(0, 300) || `exit ${r.exitCode}`
159  } catch (err) {
160    slot.exportMsg = String((err as Error)?.message ?? err).slice(0, 300)
161  } finally {
162    slot.exporting = false
163  }
164  $.ui.invalidate('ui.render')
165}
166
167/** Shows a tab: its arguments (as the owner's command takes them), then its scanner if the data is stale. */
168async function showTab($: EngineInterface, tab: string, rest = '') {
169  await update($, tabAtom, () => tab)
170  const words = rest.split(/\s+/).filter(Boolean)
171  const refresh = words.some(w => w.toLowerCase() === 'refresh')
172  const force = words.includes('--force')
173  if (tab === 'tests') {
174    S.slot.tests.data = undefined
175    return
176  }
177  if (tab === 'regles') {
178    const v = words.find(w => /^v\d/i.test(w))
179    if (v) await update($, rVersionAtom, () => v.toUpperCase())
180  }
181  if (tab === 'problemes') {
182    if (words.some(w => w.toLowerCase() === 'export')) $.clock.after(0, () => exportEta($))
183    const id = rest.match(/\bP-\d+\b/i)?.[0]?.toUpperCase()
184    if (id) {
185      const { data } = await loadTab($, 'problemes')
186      const p = (data?.problems ?? []).find((x: any) => x.id === id)
187      const nodes = p ? [`c:${p.category.split('/')[0]}`, `c:${p.category}`, `p:${id}`] : [`p:${id}`]
188      await update($, pOpenAtom, (o: string[]) => [...new Set([...(o ?? []), ...nodes])])
189    }
190  }
191  const { stale } = await loadTab($, tab)
192  if (stale || refresh) $.clock.after(0, () => runScanner($, tab, tab === 'problemes' ? refresh && force : refresh))
193}
194
195async function runCommand($: EngineInterface, args: string, alias = '') {
196  const { cfg } = await hubConfig($)
197  const current = (await read($, tabAtom)) as string
198  const a = parseArgs(args, { alias, current, cfg })
199  if (a.close) {
200    await $.ui.close({ id: PANE })
201    return {}
202  }
203  await showTab($, a.tab, a.rest)
204  await $.ui.open({ id: PANE, title: cfg.labels?.pane_title ?? 'Registres' })
205  return {} // a pane, no transcript text: nothing for the model to read
206}
207
208/** Registers the short aliases no other mod serves in this session (checked once the other mods have started). */
209async function registerAliases($: EngineInterface) {
210  try {
211    const { cfg } = await hubConfig($)
212    const list = await $.command.list()
213    const want = aliasesToRegister(list, (cfg.aliases ?? []).filter((a: string) => ['problemes', 'tests', 'regles', 'menage'].includes(a)))
214    const desc: Record<string, string> = {
215      problemes: 'Problèmes (dans Registres) : registre des erreurs et blocages, par catégorie et par application',
216      tests: 'Tests réels (dans Registres) : tests hors CI par appli, comptes masqués, lecture seule',
217      regles: 'Règles (dans Registres) : les règles marchent ? par jour et par version (args: [V5.8] [refresh])',
218      menage: 'Ménage (dans Registres) : ce qui traîne, avec la commande sûre pour chaque ligne (lecture seule)',
219    }
220    for (const name of want) {
221      if (S.aliases.has(name)) continue
222      await $.command.register({ name, description: desc[name], argumentHint: '[refresh] | close' })
223      S.aliases.add(name)
224    }
225  } catch {}
226}
227
228export const register: Register = on => {
229  on('session.start', async ($, e, next) => {
230    await $.command.register({
231      name: 'registres',
232      description: 'Registres : Problèmes, Tests réels, Règles et Ménage dans un seul panneau à onglets (lecture seule)',
233      argumentHint: '[problemes | tests | regles | menage] [args] | close',
234    })
235    const { cfg } = await hubConfig($)
236    $.clock.after(cfg.alias_delay_ms ?? 1500, () => registerAliases($))
237    return next(e)
238  })
239
240  on('command.run', { command: 'registres' }, async ($, e) => runCommand($, String(e.args ?? '')))
241  // Aliases: answered only when this mod registered them; otherwise the owner mod's own hook answers.
242  on('command.run', { command: 'problemes' }, async ($, e, next) => (S.aliases.has('problemes') ? runCommand($, String(e.args ?? ''), 'problemes').catch(() => ({})) : next(e)))
243  on('command.run', { command: 'tests' }, async ($, e, next) => (S.aliases.has('tests') ? runCommand($, String(e.args ?? ''), 'tests').catch(() => ({})) : next(e)))
244  on('command.run', { command: 'regles' }, async ($, e, next) => (S.aliases.has('regles') ? runCommand($, String(e.args ?? ''), 'regles').catch(() => ({})) : next(e)))
245  on('command.run', { command: 'menage' }, async ($, e, next) => (S.aliases.has('menage') ? runCommand($, String(e.args ?? ''), 'menage').catch(() => ({})) : next(e)))
246
247  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
248    const el = $.ui.resolve(e) as any
249    const { Box, Text, Button } = el
250    const { cfg: hub } = await hubConfig($)
251    const L = hub.labels ?? {}
252    const tabs = tabOrder(hub)
253    const want = (await read($, tabAtom)) as string
254    const tab = tabs.includes(want) ? want : tabs.includes(hub.default_tab) ? hub.default_tab : tabs[0]
255    const { cfg, data, now } = await loadTab($, tab)
256    const slot = S.slot[tab]
257    const later = (tab: string, force = false) => { $.clock.after(0, () => runScanner($, tab, force)) }
258
259    let body: any
260    if (tab === 'problemes') {
261      const pL = cfg.labels ?? {}
262      const note = async (id: string, text: string) => {
263        const v = String(text ?? '').trim()
264        if (!v) return
265        try {
266          const { libRoot } = await ownerContext($, 'problemes')
267          const r = await $.process.run([cfg.node || 'node', `${libRoot}/scanner/problemes_scan.mjs`, 'note', id, v], { timeoutMs: 30000 })
268          if (r.exitCode !== 0) throw new Error(lastLine(r.stderr) || `exit ${r.exitCode}`)
269          S.slot.problemes.data = undefined
270          await update($, pNoteAtom, () => fill(pL.note_saved, { id }))
271        } catch (err) {
272          await update($, pNoteAtom, () => fill(pL.note_failed, { error: String((err as Error)?.message ?? err).slice(0, 200) }))
273        }
274      }
275      body = drawProblemes(el, {
276        cfg, snap: data, now,
277        f: { status: await read($, pStatusAtom), week: await read($, pWeekAtom), category: await read($, pCategoryAtom), app: await read($, pAppAtom) },
278        open: ((await read($, pOpenAtom)) as string[]) ?? [], noteMsg: await read($, pNoteAtom),
279        busy: slot.busy, error: slot.error, exporting: slot.exporting, exportMsg: slot.exportMsg,
280      }, {
281        flip: (id: string) => update($, pOpenAtom, (o: string[]) => (o?.includes(id) ? o.filter(x => x !== id) : [...(o ?? []), id])),
282        setFilter: (name: string, v: string) =>
283          name === 'status' ? update($, pStatusAtom, () => v)
284            : name === 'week' ? update($, pWeekAtom, () => v)
285              : name === 'category' ? update($, pCategoryAtom, () => v)
286                : update($, pAppAtom, () => v),
287        refresh: () => later('problemes'),
288        exportEta: () => { $.clock.after(0, () => exportEta($)) },
289        note,
290      })
291    } else if (tab === 'tests') {
292      body = drawTestsReels(el, {
293        cfg, view: data, now,
294        openList: ((await read($, tOpenAtom)) as string[] | null) ?? (cfg.open ?? ['todo']),
295        reveal: (await read($, tRevealAtom)) as string[], status: await read($, tStatusAtom),
296        busy: slot.busy, error: slot.error,
297      }, {
298        toggle: (id: string) => update($, tOpenAtom, cur => {
299          const list = (cur as string[] | null) ?? (cfg.open ?? ['todo'])
300          return list.includes(id) ? list.filter(x => x !== id) : [...list, id]
301        }),
302        reveal: (rk: string) => update($, tRevealAtom, l => ((l as string[]).includes(rk) ? (l as string[]).filter(x => x !== rk) : [...(l as string[]), rk])),
303        reload: () => { S.slot.tests.data = undefined; $.ui.invalidate('ui.render') },
304        recover: () => later('tests'),
305      })
306    } else if (tab === 'regles') {
307      body = drawRegles(el, { cfg, model: data, want: await read($, rVersionAtom), busy: slot.busy, error: slot.error }, {
308        setVersion: (v: string) => update($, rVersionAtom, () => v),
309        rescan: () => later('regles', true),
310      })
311    } else {
312      const mL = cfg.labels ?? {}
313      body = drawMenage(el, { cfg, model: data, now, filter: await read($, mFilterAtom), copied: await read($, mCopiedAtom), busy: slot.busy, error: slot.error }, {
314        setFilter: (id: string) => update($, mFilterAtom, () => id),
315        rescan: () => later('menage', true),
316        copy: async (press: any, lines: string[]) => {
317          if (!lines.length) return
318          const r = await $.ui.copy({ text: lines.join('\n'), surface: press.surface })
319          await update($, mCopiedAtom, () => (r.isCopied ? fill(mL.copied, { n: lines.length }) : mL.copy_failed))
320        },
321      })
322    }
323
324    const go = async (t: string) => {
325      await update($, tabAtom, () => t)
326      const { stale } = await loadTab($, t)
327      if (stale) later(t)
328    }
329    return (
330      <Box flexDirection="column" gap={1}>
331        <Box flexDirection="row" gap={1} flexWrap="wrap">
332          {tabs.map(t => (
333            <Button key={`tab-${t}`} {...(t === tab ? {} : { plain: true as const })} onPress={() => go(t)}>{L.tabs?.[t] ?? t}</Button>
334          ))}
335        </Box>
336        {body}
337        <Box flexDirection="column">
338          {L.tab_hint ? <Text dimColor>{L.tab_hint}</Text> : null}
339          {(L.notes ?? []).map((x: string, i: number) => <Text key={`hub-n-${i}`} dimColor wrap="wrap">{`· ${x}`}</Text>)}
340        </Box>
341      </Box>
342    )
343  })
344}
345
hooks/logic.mjs 51 lines
1// registres mod - pure helpers (no `$`): command arguments, which aliases to register, staleness, config merge.
2// Tested by tests/logic.test.mjs; register.tsx does the I/O.
3
4export const TABS = ['problemes', 'tests', 'regles', 'menage']
5
6export function deepMerge(a, b) {
7  if (!b || typeof b !== 'object' || Array.isArray(b)) return b === undefined ? a : b
8  const out = { ...(a && typeof a === 'object' && !Array.isArray(a) ? a : {}) }
9  for (const [k, v] of Object.entries(b)) out[k] = v && typeof v === 'object' && !Array.isArray(v) ? deepMerge(out[k], v) : v
10  return out
11}
12
13export const fill = (s, vars = {}) => String(s ?? '').replace(/\{(\w+)\}/g, (m, k) => (vars[k] === undefined ? m : String(vars[k])))
14
15export const expandHome = (p, home) => String(p ?? '').replace(/^~(?=[\\/]|$)/, home).replace(/\\/g, '/')
16
17/** Tab ids in the configured order (unknown ids dropped, missing ones appended). */
18export function tabOrder(cfg = {}) {
19  const want = (Array.isArray(cfg.tabs) ? cfg.tabs : []).filter((t) => TABS.includes(t))
20  return [...new Set([...want, ...TABS])].filter((t) => !(cfg.hidden_tabs ?? []).includes(t))
21}
22
23/**
24 * `/registres [tab] [rest]` (or an alias, which names its tab): { close, tab, rest }. A first word that is not a tab
25 * (or a tab synonym from cfg.synonyms) keeps the current tab and is passed on as `rest`.
26 */
27export function parseArgs(args, { alias = '', current = '', cfg = {} } = {}) {
28  const words = String(args ?? '').trim().split(/\s+/).filter(Boolean)
29  if (words.length === 1 && words[0].toLowerCase() === 'close') return { close: true, tab: current, rest: '' }
30  const syn = { problemes: 'problemes', 'problèmes': 'problemes', tests: 'tests', 'tests-reels': 'tests', regles: 'regles', 'règles': 'regles', metrics: 'regles', menage: 'menage', 'ménage': 'menage', ...(cfg.synonyms ?? {}) }
31  let tab = alias ? syn[alias] ?? alias : ''
32  if (!alias && words.length && syn[words[0].toLowerCase()]) tab = syn[words.shift().toLowerCase()]
33  if (!TABS.includes(tab)) tab = TABS.includes(current) ? current : TABS.includes(cfg.default_tab) ? cfg.default_tab : TABS[0]
34  return { close: false, tab, rest: words.join(' ') }
35}
36
37/** Our own plugin name, or a marketplace install of it (registres@market). */
38export const isSelf = (plugin, self = 'registres') => !!plugin && (plugin === self || String(plugin).startsWith(`${self}@`))
39
40/**
41 * Alias names to register: those in `aliases` that no other plugin, skill or built-in already serves in this session
42 * (the old mods keep theirs while they are loaded).
43 */
44export function aliasesToRegister(list = [], aliases = [], self = 'registres') {
45  const taken = new Set(list.filter((c) => !isSelf(c.plugin, self)).map((c) => c.name))
46  return aliases.filter((a) => a && !taken.has(a))
47}
48
49/** True when a scanner's output is missing or older than refresh_min minutes. */
50export const isStale = (generated, now, refreshMin = 30) => !generated || now - generated > refreshMin * 60000
51
lib/problemes/hooks/view.mjs 126 lines
1// problemes mod - the « Problèmes » pane's drawing, pure: no `$`. `el` is the element table from $.ui.resolve(e),
2// `M` the facts the hook read, `act` the callbacks it hands in. register.tsx draws /problemes with it, and the registres
3// mod draws its « Problèmes » tab with a byte-for-byte copy (lib/core/targets.json): change it here only.
4import { applyFilters, appsOf, fill, fmtAge, fmtDate, linksOf, summaryOf, totals, tree } from '../lib/core.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/**
11 * M = { cfg, snap, now, f: { status, week, category, app }, open: string[], noteMsg, busy, error, exporting, exportMsg };
12 * act = { flip(nodeId), setFilter(name, value), refresh(), exportEta(), note(id, text) }. Returns the pane's tree.
13 */
14export function drawProblemes(el, M, act) {
15  const h = mk(el)
16  const { cfg, snap, now, f, noteMsg } = M
17  const L = cfg.labels ?? {}
18  const C = cfg.colors ?? {}
19  const status = M.busy ? L.collecting : ''
20  if (!snap) {
21    return h('Box', { flexDirection: 'column', gap: 1 },
22      h('Text', {}, fill(L.first, { days: cfg.collect?.backfill_days ?? 14 })),
23      M.error ? h('Text', { color: 'error' }, `collecte : ${M.error}`) : null,
24      status ? h('Text', { dimColor: true }, status) : null)
25  }
26  const open = new Set(M.open ?? [])
27  const all = snap.problems ?? []
28  const t = totals(all, now)
29  const shown = applyFilters(all, f, now)
30  const nodes = tree(shown, cfg.categories ?? [], L)
31  const max = cfg.max_problems_per_node ?? 40
32  const chip = (key, label, active, press) => h('Button', { key, ...(active ? {} : { plain: true }), onPress: press }, label)
33  const statusColor = (s) => C[s]
34  const stats = snap.stats
35
36  const problemRow = (p, depth, scope) => {
37    const pid = `p:${p.id}`
38    const isOpen = open.has(pid)
39    const line = fill(L.problem, { id: p.id, title: p.title, count: p.count, status: p.status })
40    const rows = [
41      h('Box', { key: `${scope}-${p.id}`, flexDirection: 'row', gap: 1, paddingLeft: depth },
42        h('Text', statusColor(p.status) ? { color: statusColor(p.status) } : {}, '●'),
43        h('Button', { key: `${scope}-${p.id}`, plain: true, onPress: () => act.flip(pid) }, `${isOpen ? '▾' : '▸'} ${line}`)),
44    ]
45    if (!isOpen) return rows
46    const field = (label, v) => h('Text', { wrap: 'wrap' }, h('Text', { bold: true }, `${label} : `), v || L.unknown)
47    const links = [...new Set((p.evidence ?? []).flatMap((ev) => linksOf(ev.text)).concat(linksOf(p.fix), linksOf(p.symptom)))].slice(0, 5)
48    rows.push(
49      h('Box', { key: `${scope}-${p.id}-d`, flexDirection: 'column', paddingLeft: depth + 2 },
50        field(L.symptom, p.symptom),
51        field(L.cause, p.cause),
52        field(L.fix, p.fix),
53        h('Text', { dimColor: true, wrap: 'wrap' }, `${L.seen} : ${fill(L.seen_line, { first: fmtDate(p.first_seen), last: fmtDate(p.last_seen), count: p.count, source: p.source })}${p.apps?.length ? ` · ${p.apps.join(', ')}` : ''}`),
54        (p.evidence ?? []).length ? h('Text', { bold: true }, L.evidence) : null,
55        (p.evidence ?? []).slice(-5).reverse().map((ev, i) => h('Text', { key: `${scope}-${p.id}-ev${i}`, dimColor: true, wrap: 'wrap' },
56          `· ${fmtDate(ev.at)}${ev.session ? ` · session ${String(ev.session).slice(0, 8)}` : ''}${ev.title ? ` · ${ev.title}` : ''}${ev.file && !ev.session ? ` · ${String(ev.file).split('/').pop()}` : ''}${ev.text ? ` · ${ev.text}` : ''}`)),
57        links.map((u, i) => h('Link', { key: `${scope}-${p.id}-l${i}`, href: u, label: u })),
58        (p.timeline ?? []).length ? h('Text', { bold: true }, L.timeline) : null,
59        (p.timeline ?? []).length ? h('Text', { dimColor: true, wrap: 'wrap' }, (p.timeline ?? []).slice(-12).map((x) => `${fmtDate(x.at)} ×${x.n}`).join(' · ')) : null,
60        (p.notes ?? []).length ? h('Text', { bold: true }, L.notes) : null,
61        (p.notes ?? []).map((n, i) => h('Text', { key: `${scope}-${p.id}-n${i}`, wrap: 'wrap' }, `· ${fmtDate(n.at)} · ${n.text}`)),
62        h('Input', { key: `note-${p.id}`, label: `${L.note_label} : `, placeholder: L.note_placeholder, submitLabel: L.note_submit, onSubmit: (v) => void act.note(p.id, v) })),
63    )
64    return rows
65  }
66
67  const nodeRows = (n, depth) => {
68    const id = `c:${n.id}`
69    const isOpen = open.has(id)
70    const rows = [
71      h('Box', { key: `n-${n.id}`, flexDirection: 'column', paddingLeft: depth },
72        h('Box', { flexDirection: 'row', gap: 1 },
73          h('Text', n.open ? { color: C['ouvert'] } : { dimColor: true }, n.open ? '●' : '○'),
74          h('Button', { key: `cat-${n.id}`, plain: true, onPress: () => act.flip(id) }, `${isOpen ? '▾' : '▸'} ${fill(L.node, { label: n.label, open: n.open, total: n.total })}`)),
75        !isOpen ? h('Text', { dimColor: true, wrap: 'truncate' }, `  ${summaryOf(n, 2, L.empty)}`) : null,
76        isOpen && n.cross ? h('Text', { dimColor: true }, `  ${L.cross}`) : null),
77    ]
78    if (!isOpen) return rows
79    for (const ch of n.children ?? []) rows.push(...nodeRows(ch, depth + 2))
80    for (const p of (n.problems ?? []).slice(0, max)) rows.push(...problemRow(p, depth + 2, n.id))
81    if ((n.problems ?? []).length > max) rows.push(h('Text', { key: `more-${n.id}`, dimColor: true }, `${' '.repeat(depth + 2)}${fill(L.more, { n: n.problems.length - max })}`))
82    return rows
83  }
84
85  const statuses = Object.entries(L.status_names ?? {})
86  const weeks = Object.entries(L.week_names ?? {})
87  const tops = (cfg.categories ?? []).filter((c) => !c.dynamic)
88  const apps = appsOf(all).slice(0, L.apps_max ?? 12)
89
90  return h('Box', { flexDirection: 'column', gap: 1 },
91    h('Box', { flexDirection: 'column' },
92      h('Text', { bold: true }, fill(L.top, t)),
93      h('Text', { dimColor: true, wrap: 'wrap' }, fill(L.subtitle, { age: fmtAge(snap.collected, now), occ: t.week_seen }) + (status ? ` · ${status}` : '')),
94      M.error ? h('Text', { color: 'error' }, `collecte : ${M.error}`) : null,
95      stats ? h('Text', { dimColor: true, wrap: 'wrap' }, fill(L.collect_stats, stats)) : null),
96
97    h('Box', { flexDirection: 'column' },
98      h('Box', { flexDirection: 'row', gap: 1, flexWrap: 'wrap' },
99        h('Text', { dimColor: true }, `${L.f_status} :`),
100        chip('f-status-all', L.all, f.status === 'all', () => act.setFilter('status', 'all')),
101        statuses.map(([k, label]) => chip(`f-status-${k}`, label, f.status === k, () => act.setFilter('status', k))),
102        h('Button', { key: 'refresh', plain: true, onPress: () => act.refresh() }, L.refresh ?? '↻'),
103        h('Button', { key: 'export-eta', plain: true, onPress: () => act.exportEta() }, M.exporting ? (L.exporting ?? 'export en cours…') : (L.export_eta ?? 'Exporter vers eta'))),
104      h('Box', { flexDirection: 'row', gap: 1, flexWrap: 'wrap' },
105        h('Text', { dimColor: true }, `${L.f_week} :`),
106        weeks.map(([k, label]) => chip(`f-week-${k}`, label, f.week === k, () => act.setFilter('week', k)))),
107      h('Box', { flexDirection: 'row', gap: 1, flexWrap: 'wrap' },
108        h('Text', { dimColor: true }, `${L.f_category} :`),
109        chip('f-cat-all', L.all, f.category === 'all', () => act.setFilter('category', 'all')),
110        tops.map((c) => chip(`f-cat-${c.id}`, c.label, f.category === c.id, () => act.setFilter('category', c.id)))),
111      apps.length
112        ? h('Box', { flexDirection: 'row', gap: 1, flexWrap: 'wrap' },
113          h('Text', { dimColor: true }, `${L.f_app} :`),
114          chip('f-app-all', L.all, f.app === 'all', () => act.setFilter('app', 'all')),
115          apps.map(([a, n]) => chip(`f-app-${a}`, `${a} (${n})`, f.app === a, () => act.setFilter('app', a))))
116        : null),
117    noteMsg ? h('Text', { dimColor: true }, noteMsg) : null,
118    M.exportMsg ? h('Text', { dimColor: true, wrap: 'wrap' }, `${L.export_eta ?? 'Exporter vers eta'} : ${M.exportMsg}`) : null,
119
120    !all.length ? h('Text', { dimColor: true }, L.empty_ledger) : null,
121    h('Box', { flexDirection: 'column' }, nodes.flatMap((n) => nodeRows(n, 0))),
122
123    h('Box', { flexDirection: 'column' },
124      (L.notes_doc ?? []).map((x, i) => h('Text', { key: `doc-${i}`, dimColor: true, wrap: 'wrap' }, `· ${x}`))))
125}
126
lib/tests-reels/hooks/view.mjs 121 lines
1// tests-reels mod - the « Tests réels » pane's drawing, pure: no `$`. `el` is the element table from $.ui.resolve(e),
2// `M` the facts the hook read, `act` the callbacks it hands in. register.tsx draws /tests with it, and the registres
3// mod draws its « Tests réels » tab with a byte-for-byte copy (lib/core/targets.json): change it here only.
4// Secrets: logins are masked unless revealed one by one; secret values are never in the view (only where they live).
5import { appSummary, fill, fmtAge, mask, resultOf, shortId, todoLine } from '../lib/registry.mjs'
6
7/** h-like factory over the element table: mk(el)('Text', { dimColor: true }, 'x'). */
8export const mk = (el) => (type, props, ...kids) =>
9  el[type]({ ...(props || {}), children: kids.flat(Infinity).filter((k) => k !== null && k !== undefined && k !== false) })
10
11/**
12 * M = { cfg, view, now, openList, reveal, status, busy, error }; act = { toggle(id), reveal(key), reload(), recover() }.
13 * Returns the pane's tree.
14 */
15export function drawTestsReels(el, M, act) {
16  const h = mk(el)
17  const { cfg, view, now, openList, reveal, status } = M
18  const L = cfg.labels ?? {}
19  const C = cfg.colors ?? {}
20  const max = cfg.max_rows ?? 12
21  const isOpen = (id) => openList.includes(id)
22  const k = (s) => s.replace(/[^A-Za-z0-9_-]/g, '_')
23  const col = (r) => (C[r] ? { color: C[r] } : {})
24  const resLabel = (r) => L[`result_${r}`] ?? r
25  const more = (n) => (n > max ? h('Text', { dimColor: true }, fill(L.more, { n: n - max })) : null)
26  const t = view.totals
27  const head = h('Box', { flexDirection: 'column' },
28    h('Text', { dimColor: true }, fill(L.subtitle, { apps: t.apps, reg: view.registryAt ? fmtAge(view.registryAt, now) : L.never_scanned, rec: view.recoveredAt ? fmtAge(view.recoveredAt, now) : L.never_scanned })),
29    h('Text', {}, fill(L.totals, t)),
30    h('Box', { flexDirection: 'row', gap: 1, flexWrap: 'wrap' },
31      h('Button', { key: 'refresh', plain: true, onPress: () => act.reload() }, L.refresh ?? '↻'),
32      h('Button', { key: 'recover', plain: true, onPress: () => act.recover() }, L.recover)),
33    M.busy ? h('Text', { dimColor: true }, L.recovering) : null,
34    M.error ? h('Text', { color: 'error' }, `récupération : ${M.error}`) : null,
35    cfg._local_error ? h('Text', { color: 'error' }, cfg._local_error) : null,
36    status ? h('Text', { dimColor: true }, status) : null)
37  if (!view.apps.length && !view.todo.length) {
38    return h('Box', { flexDirection: 'column', gap: 1 }, head, h('Text', {}, L.empty))
39  }
40
41  const fold = (id, title, summary, body, warn = false) => h('Box', { key: `b-${k(id)}`, flexDirection: 'column' },
42    h('Box', { flexDirection: 'row', gap: 1, flexWrap: 'wrap' },
43      h('Button', { key: `t-${k(id)}`, plain: true, onPress: () => act.toggle(id) }, `${isOpen(id) ? '▾' : '▸'} ${title}`),
44      h('Text', { ...(warn ? col('warn') : { dimColor: true }), wrap: 'truncate' }, summary)),
45    isOpen(id) ? h('Box', { flexDirection: 'column', paddingLeft: 2 }, body()) : null)
46  const part = (title, rows, line) => h('Box', { flexDirection: 'column' },
47    h('Text', { bold: true }, `${title} (${rows.length})`),
48    !rows.length ? h('Text', { dimColor: true }, `  ${L.none}`) : null,
49    rows.slice(0, max).map(line),
50    more(rows.length))
51
52  const todoBlock = fold(
53    'todo',
54    L.todo_title,
55    view.todo.length ? fill(L.todo_summary, { n: view.todo.length, secrets: t.secrets_seen }) : L.todo_none,
56    () => h('Box', { flexDirection: 'column' },
57      view.todo.slice(0, max).map((x, i) => h('Text', { key: `td-${i}`, ...col('todo') }, `● ${todoLine(x, L)}`)),
58      more(view.todo.length),
59      view.todo.length ? h('Text', { dimColor: true, wrap: 'wrap' }, cfg.store_instruction) : null),
60    view.todo.length > 0,
61  )
62
63  const appBlock = (a) => {
64    const id = `app:${a.name}`
65    return fold(id, a.name, appSummary(a, L), () => {
66      const rec = a.recovered
67      return h('Box', { flexDirection: 'column', gap: 1 },
68        a.notes ? h('Text', { dimColor: true }, a.notes) : null,
69        a.neverReal ? h('Text', col('warn'), `⚠ ${L.never_real}`) : null,
70        part(L.envs, a.environments, (e, i) => h('Box', { key: `e-${i}`, flexDirection: 'column', paddingLeft: 1 },
71          h('Text', {}, `● ${e.name}${e.kind ? ` · ${L[`kind_${e.kind}`] ?? e.kind}` : ''}${e.status ? ` · ${e.status}` : ''}${e.owner ? ` · ${e.owner}` : ''}${e.created ? ` · ${e.created}` : ''}`),
72          (e.urls ?? []).map((u, j) => h('Text', { key: `eu-${i}-${j}`, dimColor: true }, `  ${u}`)),
73          Object.keys(e.ids ?? {}).length ? h('Text', { dimColor: true }, `  ${Object.entries(e.ids).map(([kk, v]) => `${kk} ${shortId(v)}`).join(' · ')}`) : null)),
74        part(L.accounts, a.accounts, (ac, i) => {
75          const rk = `${a.name}|${ac.login || ac.display}`
76          const shown = reveal.includes(rk)
77          return h('Box', { key: `a-${i}`, flexDirection: 'column', paddingLeft: 1 },
78            h('Box', { flexDirection: 'row', gap: 1 },
79              h('Text', {}, `● ${shown ? ac.login || ac.display : mask(ac.login || ac.display)}${ac.env ? ` · ${ac.env}` : ''}${ac.purpose ? ` · ${ac.purpose}` : ''}`),
80              h('Button', { key: `rv-${k(rk)}`, plain: true, onPress: () => act.reveal(rk) }, shown ? L.hide : L.show)),
81            ac.secret_ref?.target
82              ? h('Text', { dimColor: true }, `  ${fill(L.secret_at, { store: cfg.secret_stores?.[ac.secret_ref.store] ?? ac.secret_ref.store, target: ac.secret_ref.target })}`)
83              : h('Text', col('todo'), `  ${L.secret_none}`))
84        }),
85        part(L.procedures, a.procedures, (p, i) => h('Box', { key: `p-${i}`, flexDirection: 'column', paddingLeft: 1 },
86          h('Text', col(p.status), `● ${p.name}${p.env ? ` · ${p.env}` : ''}${p.kind ? ` · ${p.kind}` : ''} · ${resLabel(p.status)}${p.last?.date ? ` ${String(p.last.date).slice(0, 10)}` : ''}${p.undeclared ? ` · ${L.undeclared}` : ''}`),
87          p.steps?.length ? h('Text', { dimColor: true, wrap: 'wrap' }, `  ${fill(L.steps, { n: p.steps.length })} : ${p.steps.join(' → ')}`) : null,
88          (p.commands ?? []).map((c, j) => h('Text', { key: `pc-${i}-${j}`, dimColor: true, wrap: 'wrap' }, `  $ ${c}`)),
89          p.expected ? h('Text', { dimColor: true, wrap: 'wrap' }, `  ${fill(L.expected, { text: p.expected })}`) : null,
90          p.evidence?.length ? h('Text', { dimColor: true, wrap: 'wrap' }, `  ${fill(L.evidence, { list: p.evidence.join(', ') })}`) : null)),
91        part(L.runs, a.runs, (r, i) => h('Box', { key: `r-${i}`, flexDirection: 'column', paddingLeft: 1 },
92          h('Text', col(resultOf(r)), `● ${String(r.date).slice(0, 16).replace('T', ' ')} · ${r.procedure}${r.env ? ` · ${r.env}` : ''} · ${resLabel(resultOf(r))}${r.by ? ` · ${r.by}` : ''}`),
93          r.note ? h('Text', { dimColor: true, wrap: 'wrap' }, `  ${r.note}`) : null,
94          r.evidence?.length ? h('Text', { dimColor: true, wrap: 'wrap' }, `  ${fill(L.evidence, { list: r.evidence.join(', ') })}`) : null)),
95        part(L.checks, a.checks, (c, i) => h('Box', { key: `c-${i}`, flexDirection: 'column', paddingLeft: 1 },
96          h('Text', {}, `● ${c.date ?? ''} · ${c.what}${c.status ? ` · ${c.status}` : ''}`),
97          c.how ? h('Text', { dimColor: true, wrap: 'wrap' }, `  ${c.how}`) : null)),
98        rec
99          ? fold(`${id}:rec`, L.recovered, fill(L.sum_recovered, { s: (rec.sessions ?? []).length, c: (rec.commands ?? []).length }), () => h('Box', { flexDirection: 'column', gap: 1 },
100            part(L.rec_sessions, rec.sessions ?? [], (s, i) => h('Text', { key: `rs-${i}`, dimColor: true }, `● ${String(s.date).slice(0, 10)} · ${s.title} · ${(s.cats ?? []).join(', ')} · ${s.hits}`)),
101            part(L.rec_commands, a.recCommands ?? [], (c, i) => h('Text', { key: `rc-${i}`, ...(c.errors ? col('fail') : { dimColor: true }), wrap: 'wrap' }, `● ${String(c.date).slice(0, 10)} · ${c.count}×${c.errors ? ` · ${fill(L.errors, { n: c.errors })}` : ''} · ${c.cmd}`)),
102            part(L.rec_ids, rec.ids ?? [], (x, i) => h('Text', { key: `ri-${i}`, dimColor: true }, `● ${x.kind} ${shortId(x.value)} · ${x.count}×`)),
103            part(L.rec_urls, rec.urls ?? [], (x, i) => h('Text', { key: `ru-${i}`, dimColor: true, wrap: 'wrap' }, `● ${x.url} · ${x.count}×`)),
104            part(L.rec_accounts, rec.accounts ?? [], (x, i) => h('Text', { key: `ra-${i}`, dimColor: true }, `● ${mask(x.login)} · ${x.count}×`)),
105            part(L.rec_workspaces, rec.workspaces ?? [], (x, i) => h('Text', { key: `rw-${i}`, dimColor: true }, `● ${x.name} · ${x.count}×`)),
106            part(L.rec_mentions, rec.mentions ?? [], (x, i) => h('Text', { key: `rm-${i}`, dimColor: true, wrap: 'wrap' }, `● ${String(x.date).slice(0, 10)} · ${x.title ?? ''} : ${x.text}`))))
107          : null)
108    }, a.neverReal)
109  }
110
111  return h('Box', { flexDirection: 'column', gap: 1 },
112    head,
113    todoBlock,
114    h('Box', { flexDirection: 'column' },
115      h('Text', { bold: true }, `${L.apps_title} (${view.apps.length})`),
116      view.apps.slice(0, cfg.max_apps ?? 60).map(appBlock),
117      view.apps.length > (cfg.max_apps ?? 60) ? h('Text', { dimColor: true }, fill(L.more, { n: view.apps.length - (cfg.max_apps ?? 60) })) : null),
118    h('Box', { flexDirection: 'column' },
119      (L.notes ?? []).map((x, i) => h('Text', { key: `n-${i}`, dimColor: true, wrap: 'wrap' }, `· ${x}`))))
120}
121
lib/tests-reels/lib/registry.mjs 245 lines
1// Registry logic of the tests-reels mod (« Tests réels »). Pure functions, no I/O: the pane, the CLI and the tests
2// share them. Local data only (~/.claude/tests-reels): registry.json (curated), runs.jsonl (runs appended by agents),
3// recovered.json (what the recovery scan found). Nothing here stores or returns a secret value.
4
5export function deepMerge(a, b) {
6  if (Array.isArray(b)) return b.slice()
7  if (!b || typeof b !== 'object') return b === undefined ? a : b
8  const out = { ...(a && typeof a === 'object' && !Array.isArray(a) ? a : {}) }
9  for (const [k, v] of Object.entries(b)) out[k] = deepMerge(out[k], v)
10  return out
11}
12
13/** `{name}` placeholders filled from `vars`; unknown ones kept. */
14export function fill(s, vars = {}) {
15  return String(s ?? '').replace(/\{(\w+)\}/g, (m, k) => (vars[k] === undefined ? m : String(vars[k])))
16}
17
18/** `~`, `%VAR%` and `$VAR` expanded; forward slashes. */
19export function expand(p, env = {}) {
20  let s = String(p || '')
21  s = s.replace(/^~(?=[\\/]|$)/, env.HOME || env.USERPROFILE || '')
22  s = s.replace(/%([A-Za-z_][A-Za-z0-9_]*)%/g, (m, k) => (env[k] ?? m))
23  s = s.replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (m, k) => (env[k] ?? m))
24  return s.replace(/\\/g, '/')
25}
26
27export const unset = v => v == null || v === '' || /^<.*>$/.test(String(v))
28
29export function fmtAge(then, now) {
30  if (!then) return '—'
31  const t = typeof then === 'number' ? then : Date.parse(then)
32  if (!Number.isFinite(t)) return '—'
33  const m = Math.max(0, (now - t) / 60000)
34  if (m < 60) return `${Math.round(m)} min`
35  if (m < 48 * 60) return `${Math.round(m / 60)} h`
36  return `${Math.round(m / 1440)} j`
37}
38
39/** A login shown masked: `te•••@co•••` (e-mail) or `te•••`. */
40export function mask(login, keep = 2) {
41  const s = String(login || '')
42  if (!s) return ''
43  const part = x => (x.length <= keep ? '•'.repeat(Math.max(1, x.length)) : x.slice(0, keep) + '•••')
44  const at = s.indexOf('@')
45  return at > 0 ? `${part(s.slice(0, at))}@${part(s.slice(at + 1))}` : part(s)
46}
47
48/** An id shown short: first 8 characters then … */
49export const shortId = (v, n = 8) => (String(v || '').length > n + 1 ? String(v).slice(0, n) + '…' : String(v || ''))
50
51export const norm = s => String(s || '').trim().toLowerCase().replace(/[\s_-]+/g, '-')
52
53/** Lines of runs.jsonl parsed; bad lines skipped. */
54export function parseRuns(text) {
55  const out = []
56  for (const line of String(text || '').split(/\r?\n/)) {
57    if (!line.trim()) continue
58    try {
59      const r = JSON.parse(line)
60      if (r && r.app) out.push(r)
61    } catch {}
62  }
63  return out
64}
65
66const RESULTS = ['pass', 'fail', 'partial', 'ran']
67export const resultOf = r => (RESULTS.includes(r?.result) ? r.result : 'ran')
68const dateOf = r => String(r?.date || r?.last || '')
69const byDateDesc = (a, b) => dateOf(b).localeCompare(dateOf(a))
70
71/** One app in the view: curated entry merged with its runs and what recovery found. */
72function appView(name, cur, runs, rec, cfg) {
73  const envs = (cur?.environments ?? []).map(e => ({ ...e, ids: e.ids ?? {}, urls: e.urls ?? [] }))
74  const accounts = (cur?.accounts ?? []).map(a => ({ ...a }))
75  const appRuns = runs.filter(r => norm(r.app) === norm(name)).sort(byDateDesc)
76  const procs = (cur?.procedures ?? []).map(p => {
77    const own = [...(p.runs ?? []), ...appRuns.filter(r => norm(r.procedure) === norm(p.name))].sort(byDateDesc)
78    const last = own[0]
79    return { ...p, runs: own, last, status: last ? resultOf(last) : 'never' }
80  })
81  const known = new Set(procs.map(p => norm(p.name)))
82  const orphanRuns = appRuns.filter(r => !known.has(norm(r.procedure)))
83  for (const r of orphanRuns) {
84    let p = procs.find(x => x.undeclared && norm(x.name) === norm(r.procedure || '—'))
85    if (!p) procs.push((p = { name: r.procedure || '—', env: r.env, undeclared: true, runs: [], status: 'never' }))
86    p.runs.push(r)
87    p.last = p.runs[0]
88    p.status = resultOf(p.last)
89  }
90  const recovered = rec ?? null
91  const recCommands = (recovered?.commands ?? []).slice().sort(byDateDesc)
92  const lastRuns = [...appRuns].slice(0, cfg.max_runs ?? 8)
93  const lastReal = appRuns[0] || null
94  const todo = []
95  for (const a of accounts) {
96    if (!a.secret_ref || unset(a.secret_ref.target)) todo.push({ kind: 'secret_unknown', app: name, what: a.display || a.login || a.id || '?', env: a.env })
97  }
98  for (const e of envs) {
99    const need = cfg.required_ids?.[e.kind] ?? []
100    const missing = need.filter(k => unset(e.ids?.[k]) && !(k === 'url' && e.urls?.length))
101    if (missing.length) todo.push({ kind: 'id_missing', app: name, what: `${e.name} : ${missing.join(', ')}`, env: e.name })
102  }
103  for (const s of recovered?.secrets ?? []) todo.push({ kind: 'secret_seen', app: name, what: s.kind, title: s.title, date: s.date, count: s.count ?? 1 })
104  const neverReal = !lastReal && procs.every(p => p.status === 'never') && !recCommands.length
105  return {
106    name,
107    repo: cur?.repo,
108    notes: cur?.notes,
109    curated: !!cur,
110    environments: envs,
111    accounts,
112    procedures: procs,
113    checks: [...(cur?.manual_checks ?? []), ...(recovered?.manual ?? [])].sort(byDateDesc),
114    runs: lastRuns,
115    lastRun: lastReal,
116    recovered,
117    recCommands,
118    neverReal,
119    todo,
120  }
121}
122
123/** Summary line of an app, shown when its block is folded. */
124export function appSummary(a, L = {}) {
125  const parts = []
126  parts.push(fill(L.sum_envs ?? '{n} env.', { n: a.environments.length }))
127  parts.push(fill(L.sum_accounts ?? '{n} compte(s)', { n: a.accounts.length }))
128  parts.push(fill(L.sum_procs ?? '{n} procédure(s)', { n: a.procedures.length }))
129  if (a.lastRun) parts.push(fill(L.sum_last ?? 'dernier essai {date} {result}', { date: dateOf(a.lastRun).slice(0, 10), result: L[`result_${resultOf(a.lastRun)}`] ?? resultOf(a.lastRun) }))
130  else if (a.neverReal) parts.push(L.never_real ?? 'jamais testé en réel')
131  const r = a.recovered
132  if (r) parts.push(fill(L.sum_recovered ?? 'historique : {s} session(s), {c} commande(s)', { s: (r.sessions ?? []).length, c: (r.commands ?? []).length }))
133  if (a.todo.length) parts.push(fill(L.sum_todo ?? '{n} à ranger', { n: a.todo.length }))
134  return parts.join(' · ')
135}
136
137/**
138 * The whole view: apps sorted (curated first, then by evidence), totals and the « À ranger » list.
139 * registry: { apps: [...] } · runs: parsed runs.jsonl · recovered: recovered.json ({ apps: { name: {...} } }).
140 */
141export function buildView(registry, runs, recovered, cfg = {}) {
142  const cur = new Map()
143  for (const a of registry?.apps ?? []) if (a?.name) cur.set(norm(a.name), a)
144  const rec = new Map()
145  for (const [name, r] of Object.entries(recovered?.apps ?? {})) rec.set(norm(name), { name, ...r })
146  const names = new Map()
147  for (const a of registry?.apps ?? []) if (a?.name) names.set(norm(a.name), a.name)
148  for (const r of runs ?? []) if (r?.app && !names.has(norm(r.app))) names.set(norm(r.app), r.app)
149  const minHits = cfg.recovery?.min_hits_to_list ?? 1
150  for (const [k, r] of rec) {
151    const hits = (r.sessions ?? []).length + (r.commands ?? []).length + (r.manual ?? []).length + (r.ids ?? []).length
152    if (!names.has(k) && hits >= minHits) names.set(k, r.name)
153  }
154  const apps = [...names].map(([k, name]) => appView(name, cur.get(k), runs ?? [], rec.get(k), cfg))
155  const weight = a => (a.curated ? 1e9 : 0) + (a.lastRun ? 1e6 : 0) + (a.recovered ? (a.recovered.sessions?.length ?? 0) * 10 + (a.recovered.commands?.length ?? 0) : 0)
156  apps.sort((a, b) => weight(b) - weight(a) || a.name.localeCompare(b.name))
157  const todo = apps.flatMap(a => a.todo)
158  // secrets seen in sessions of an app that is not listed (no test evidence) still go to « À ranger »
159  for (const [k, r] of rec) {
160    if (names.has(k)) continue
161    for (const s of r.secrets ?? []) todo.push({ kind: 'secret_seen', app: r.name, what: s.kind, title: s.title, date: s.date, count: s.count ?? 1 })
162  }
163  const rank = { secret_unknown: 0, id_missing: 1, secret_seen: 2 }
164  todo.sort((a, b) => rank[a.kind] - rank[b.kind] || String(b.date || '').localeCompare(String(a.date || '')))
165  const totals = {
166    apps: apps.length,
167    environments: apps.reduce((n, a) => n + a.environments.length, 0),
168    accounts: apps.reduce((n, a) => n + a.accounts.length, 0),
169    procedures: apps.reduce((n, a) => n + a.procedures.length, 0),
170    runs: (runs ?? []).length,
171    never: apps.filter(a => a.neverReal).length,
172    todo: todo.length,
173    secrets_seen: todo.filter(t => t.kind === 'secret_seen').reduce((n, t) => n + (t.count ?? 1), 0),
174  }
175  return { apps, todo, totals, recoveredAt: recovered?.generated ?? null, registryAt: registry?.updated ?? null, recoveryCounts: recovered?.counts ?? null }
176}
177
178/** One « À ranger » line in French. */
179export function todoLine(t, L = {}) {
180  if (t.kind === 'secret_seen') return fill(L.todo_secret_seen ?? '{app} · {what} vu dans la session « {title} » le {date} ({count}×) : à ranger dans le Gestionnaire d\'identification', { ...t, date: String(t.date || '').slice(0, 10) })
181  if (t.kind === 'secret_unknown') return fill(L.todo_secret_unknown ?? '{app} · {what} : secret non enregistré, à ranger dans le Gestionnaire d\'identification Windows', t)
182  return fill(L.todo_id_missing ?? '{app} · id manquant : {what}', t)
183}
184
185// ---- writes (used by cli.mjs): pure transforms of the registry object -------------------------------------------
186
187const ENV_KINDS = ['tenant', 'cloud', 'local', 'docker']
188const PROC_KINDS = ['live', 'local', 'docker', 'ui', 'manual']
189
190export function ensureApp(reg, name, extra = {}) {
191  if (!name) throw new Error('--app manquant')
192  reg.apps ??= []
193  let a = reg.apps.find(x => norm(x.name) === norm(name))
194  if (!a) reg.apps.push((a = { name, environments: [], accounts: [], procedures: [], manual_checks: [] }))
195  for (const [k, v] of Object.entries(extra)) if (v !== undefined) a[k] = v
196  a.environments ??= []
197  a.accounts ??= []
198  a.procedures ??= []
199  a.manual_checks ??= []
200  return a
201}
202
203const upsert = (list, item, key = 'name') => {
204  const i = list.findIndex(x => norm(x[key]) === norm(item[key]))
205  if (i >= 0) list[i] = { ...list[i], ...Object.fromEntries(Object.entries(item).filter(([, v]) => v !== undefined)) }
206  else list.push(item)
207  return list[i >= 0 ? i : list.length - 1]
208}
209
210export function addEnv(reg, o, today) {
211  if (!o.name) throw new Error('--name manquant')
212  if (o.kind && !ENV_KINDS.includes(o.kind)) throw new Error(`--kind : ${ENV_KINDS.join(' | ')}`)
213  const a = ensureApp(reg, o.app)
214  return upsert(a.environments, { name: o.name, kind: o.kind, urls: o.urls, ids: o.ids, owner: o.owner, status: o.status ?? 'actif', notes: o.notes, created: o.created ?? today })
215}
216
217export function addAccount(reg, o) {
218  if (!o.login && !o.display) throw new Error('--login ou --display manquant')
219  const a = ensureApp(reg, o.app)
220  const secret_ref = o.secret_store || o.secret_target ? { store: o.secret_store || 'wincred', target: o.secret_target || '' } : undefined
221  return upsert(a.accounts, { login: o.login, display: o.display, env: o.env, purpose: o.purpose, dedicated: o.dedicated ?? true, secret_ref }, o.login ? 'login' : 'display')
222}
223
224export function addProcedure(reg, o) {
225  if (!o.name) throw new Error('--name manquant')
226  if (o.kind && !PROC_KINDS.includes(o.kind)) throw new Error(`--kind : ${PROC_KINDS.join(' | ')}`)
227  const a = ensureApp(reg, o.app)
228  return upsert(a.procedures, { name: o.name, env: o.env, account: o.account, kind: o.kind, steps: o.steps, commands: o.commands, expected: o.expected, evidence: o.evidence })
229}
230
231export function addCheck(reg, o, today) {
232  if (!o.what) throw new Error('--what manquant')
233  const a = ensureApp(reg, o.app)
234  const c = { date: o.date ?? today, what: o.what, how: o.how, status: o.status ?? 'à faire' }
235  a.manual_checks.push(c)
236  return c
237}
238
239export function runRecord(o, nowIso) {
240  if (!o.app) throw new Error('--app manquant')
241  if (!o.procedure) throw new Error('--procedure manquant')
242  if (!['pass', 'fail', 'partial'].includes(o.result)) throw new Error('--result : pass | fail | partial')
243  return { date: o.date ?? nowIso, app: o.app, env: o.env, procedure: o.procedure, result: o.result, evidence: o.evidence ?? [], note: o.note, session: o.session, by: o.by }
244}
245
lib/metrics/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
lib/menage/hooks/view.mjs 76 lines
1// menage mod - the pane's drawing, pure: no `$`. `el` is the element table from $.ui.resolve(e) (Box, Text, Button),
2// `M` the facts the hook read, `act` the callbacks it hands in. register.tsx draws the « Ménage » pane with it, and the
3// registres mod draws its « Ménage » tab with a byte-for-byte copy (lib/core/targets.json): change it here only.
4import { commandsOf, fill, filterRows, fmtAge, fmtBytes } from '../lib/classify.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/**
11 * M = { cfg, model, now, filter, copied, busy, error }; act = { setFilter(id), rescan(), copy(press, lines) }.
12 * Returns the pane's tree.
13 */
14export function drawMenage(el, M, act) {
15  const h = mk(el)
16  const { cfg, model, now, filter, copied } = M
17  const L = cfg.labels ?? {}
18  const C = cfg.colors ?? {}
19  const status = M.busy ? 'inventaire en cours…' : ''
20  if (!model) {
21    return h('Box', { flexDirection: 'column', gap: 1 },
22      h('Text', {}, L.first_scan),
23      M.error ? h('Text', { color: 'error' }, `scan : ${M.error}`) : null,
24      status ? h('Text', { dimColor: true }, status) : null)
25  }
26  const t = model.totals ?? {}
27  const max = cfg.max_rows ?? 40
28  const color = (level) => (level === 'safe' ? C.safe : level === 'check' ? C.check : level === 'danger' ? C.danger : undefined)
29  const filters = [['all', L.filter_all], ['safe', L.filter_safe], ['danger', L.filter_danger]]
30  const tag = (r) => (r.level === 'danger' ? `${L.danger_tag} · ` : r.level === 'check' ? `${L.check_tag} · ` : '')
31
32  return h('Box', { flexDirection: 'column', gap: 1 },
33    h('Box', { flexDirection: 'column' },
34      h('Text', { dimColor: true }, fill(L.subtitle, { repos: model.repos ?? 0, age: fmtAge(model.generated, now) }) + (status ? ` · ${status}` : '')),
35      M.error ? h('Text', { color: 'error' }, `scan : ${M.error}`) : null,
36      (model.errors ?? []).slice(0, 5).map((x) => h('Text', { color: 'warning' }, x))),
37
38    h('Box', { flexDirection: 'column' },
39      h('Text', { bold: true, color: C.safe }, fill(L.total_sure, { bytes: fmtBytes(t.sure ?? 0) })),
40      h('Text', { color: C.check }, fill(L.total_check, { bytes: fmtBytes(t.check ?? 0) })),
41      t.danger ? h('Text', { color: C.danger }, fill(L.total_danger, { n: t.danger })) : null),
42
43    h('Box', { flexDirection: 'row', gap: 1, flexWrap: 'wrap' },
44      filters.map(([id, label]) => h('Button', { key: `f-${id}`, ...(id !== filter ? { plain: true } : {}), onPress: () => act.setFilter(id) }, label)),
45      h('Button', { key: 'refresh', plain: true, onPress: () => act.rescan() }, L.refresh ?? '↻'),
46      h('Button', { key: 'copy-all', plain: true, onPress: (press) => act.copy(press, commandsOf(model.sections ?? [])) }, L.copy_all)),
47    copied ? h('Text', { dimColor: true }, copied) : null,
48
49    (model.sections ?? []).map((s) => {
50      const rows = filterRows(s.rows ?? [], filter)
51      if (!rows.length && filter !== 'all') return null
52      const sure = (s.rows ?? []).filter((r) => r.level === 'safe').reduce((a, r) => a + (r.reclaim || 0), 0)
53      const safeCmds = commandsOf([s])
54      return h('Box', { key: `s-${s.id}`, flexDirection: 'column' },
55        h('Box', { flexDirection: 'row', gap: 1, flexWrap: 'wrap' },
56          h('Text', { bold: true }, `${s.title} (${(s.rows ?? []).length})${sure ? ` · ${fmtBytes(sure)}` : ''}`),
57          safeCmds.length ? h('Button', { key: `copy-${s.id}`, plain: true, onPress: (press) => act.copy(press, safeCmds) }, `${L.copy} (${safeCmds.length})`) : null),
58        s.summary ? h('Text', { dimColor: true }, s.summary) : null,
59        !rows.length ? h('Text', { dimColor: true }, L.empty) : null,
60        rows.slice(0, max).map((r, i) => h('Box', { key: `r-${s.id}-${i}`, flexDirection: 'column', paddingLeft: 1 },
61          h('Text', { ...(color(r.level) ? { color: color(r.level) } : {}), ...(r.level === 'danger' ? { bold: true } : {}) }, `● ${r.title}${r.meta ? ` · ${r.meta}` : ''}`),
62          r.reasons?.length || r.level === 'danger' || r.level === 'check'
63            ? h('Text', r.level === 'danger' ? { color: C.danger } : { dimColor: true }, `  ${tag(r)}${(r.reasons ?? []).join(' · ')}`)
64            : null,
65          r.command
66            ? h('Box', { flexDirection: 'row', gap: 1 },
67              h('Button', { key: `c-${s.id}-${i}`, plain: true, onPress: (press) => act.copy(press, [r.command]) }, L.copy),
68              h('Text', { dimColor: true, wrap: 'wrap' }, r.command))
69            : null)),
70        rows.length > max ? h('Text', { dimColor: true }, fill(L.more, { n: rows.length - max })) : null)
71    }),
72
73    h('Box', { flexDirection: 'column' },
74      (L.notes ?? []).map((x) => h('Text', { dimColor: true }, `· ${fill(x, { base: 'origin/main' })}`))))
75}
76
lib/problemes/lib/core.mjs 674 lines
1// problemes mod (« Problèmes »): pure logic, no `$`, no file system. Used by the pane (hooks/register.tsx), the
2// collector (scanner/problemes_scan.mjs) and the node tests.
3//
4// The ledger is append-only JSON lines (register.jsonl). Each line is one event about one problem:
5//   { op: 'add',    id, at, signature, category, scope, app, title, symptom, cause, fix, status, evidence, source }
6//   { op: 'seen',   id, at, n, evidence }          one or more occurrences found by one collection
7//   { op: 'update', id, at, fields }               status, fix, cause… changed (CLI or reopen)
8//   { op: 'note',   id, at, text }                 « Ajouter une note » from the pane
9// fold() turns the lines into one entry per problem (first seen, last seen, count, timeline, notes).
10
11export const STATUSES = ['ouvert', 'corrigé', 'contourné', 'accepté']
12
13export function deepMerge(a, b) {
14  if (Array.isArray(b) || b === null || typeof b !== 'object') return b === undefined ? a : b
15  const out = { ...(a && typeof a === 'object' && !Array.isArray(a) ? a : {}) }
16  for (const [k, v] of Object.entries(b)) out[k] = deepMerge(out[k], v)
17  return out
18}
19
20export const fill = (s, vars = {}) => String(s ?? '').replace(/\{(\w+)\}/g, (m, k) => (vars[k] === undefined ? m : String(vars[k])))
21
22export const slash = p => String(p ?? '').replace(/\\/g, '/')
23
24/** ~, %VAR% and $VAR in a configured path. */
25export function expand(p, env = {}) {
26  return slash(String(p ?? '')
27    .replace(/^~(?=$|[\\/])/, env.HOME ?? env.USERPROFILE ?? '~')
28    .replace(/%(\w+)%/g, (m, k) => env[k] ?? m)
29    .replace(/\$(\w+)/g, (m, k) => env[k] ?? m))
30}
31
32/** A placeholder such as <PROJECTS_ROOT> counts as not set. */
33export const isSet = v => typeof v === 'string' ? v.trim() !== '' && !/^<[^>]*>$/.test(v.trim()) : v !== undefined && v !== null
34
35// ---- text -------------------------------------------------------------------------------------------------------------
36
37export function oneLine(s, n = 160) {
38  const t = String(s ?? '').replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, ' ').replace(/\s+/g, ' ').trim()
39  return t.length > n ? t.slice(0, n - 1) + '…' : t
40}
41
42/** Text with paths, ids and numbers taken out: two occurrences of the same problem give the same string. */
43export function normText(s, n = 120) {
44  return oneLine(s, 4000)
45    .toLowerCase()
46    .replace(/[A-Za-z]:[\\/][^\s"'`)\]]*/g, '<chemin>')
47    .replace(/(?:^|\s)\/(?:[\w.@-]+\/)+[\w.@-]*/g, ' <chemin>')
48    .replace(/https?:\/\/\S+/g, '<url>')
49    .replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, '<id>')
50    .replace(/\b(?:wf|agent|local)_?[0-9a-f-]{5,}\b/gi, '<id>')
51    .replace(/\b[0-9a-f]{7,40}\b/gi, '<id>')
52    .replace(/\d+/g, 'N')
53    .replace(/\s+/g, ' ')
54    .trim()
55    .slice(0, n)
56}
57
58/** Repo or app name for a working folder: worktrees count as their repo; under a root, the first folder below it. */
59export function appOf(cwd, roots = [], worktreeDirs = ['.claude/worktrees', '.worktrees']) {
60  let p = slash(cwd).replace(/\/+$/, '')
61  if (!p) return ''
62  for (const w of worktreeDirs) {
63    const i = p.toLowerCase().indexOf(`/${w.toLowerCase()}/`)
64    if (i > 0) p = p.slice(0, i)
65  }
66  for (const r of roots.filter(isSet).map(x => slash(x).replace(/\/+$/, ''))) {
67    if (p.toLowerCase().startsWith(r.toLowerCase() + '/')) return p.slice(r.length + 1).split('/')[0]
68  }
69  return p.split('/').pop() || ''
70}
71
72// ---- transcript records → hits ------------------------------------------------------------------------------------------
73
74const textOf = c => typeof c === 'string' ? c : Array.isArray(c) ? c.map(b => (b && typeof b === 'object' ? b.text ?? (typeof b.content === 'string' ? b.content : '') : String(b ?? ''))).join(' ') : ''
75
76/**
77 * Hits found in one transcript record. `ctx` = { session, file, sub, cwd, tools: Map(tool_use_id → {name, command}) }
78 * (the collector keeps `tools` between the records of one file). `det` = config.detect.
79 * Kinds: ask, trailing_question, blocked, denied, tool_error, workflow_error, ci_failure, api_error.
80 */
81export function hitsOfRecord(d, ctx, det = {}) {
82  const out = []
83  if (!d || typeof d !== 'object') return out
84  if (d.cwd) ctx.cwd = d.cwd
85  const at = Date.parse(d.timestamp ?? '') || 0
86  const base = { at, session: ctx.session, file: ctx.file, sub: !!ctx.sub, cwd: ctx.cwd ?? '' }
87  const t = d.type
88  if (t === 'system' && d.subtype === 'api_error') {
89    const e = d.error
90    out.push({ ...base, kind: 'api_error', tool: '', text: oneLine(typeof e === 'string' ? e : JSON.stringify(e ?? d.content ?? 'API error'), 300) })
91  } else if (t === 'assistant') {
92    const m = d.message ?? {}
93    const blocks = Array.isArray(m.content) ? m.content : []
94    for (const b of blocks) {
95      if (!b || b.type !== 'tool_use') continue
96      const inp = b.input ?? {}
97      ctx.tools?.set(b.id, { name: b.name, command: String(inp.command ?? '').slice(0, 300) })
98      if (ctx.tools && ctx.tools.size > 400) ctx.tools.delete(ctx.tools.keys().next().value)
99      if (b.name === 'AskUserQuestion') {
100        const qs = Array.isArray(inp.questions) ? inp.questions.map(q => q?.question ?? '').filter(Boolean) : []
101        out.push({ ...base, kind: 'ask', tool: 'AskUserQuestion', text: oneLine(qs.join(' | ') || inp.question || 'AskUserQuestion', 300) })
102      }
103    }
104    if (m.stop_reason === 'end_turn' && !ctx.sub && !d.isSidechain) {
105      const texts = blocks.filter(b => b?.type === 'text').map(b => String(b.text ?? ''))
106      const last = (texts[texts.length - 1] ?? '').trim()
107      if (last) {
108        const blocked = last.match(new RegExp(det.blocked_regex ?? '\\bBLOCKED:\\s*(.+)', 'm'))
109        if (blocked) out.push({ ...base, kind: 'blocked', tool: '', text: oneLine(blocked[1] ?? blocked[0], 300) })
110        else {
111          const lines = last.split(/\n/).map(x => x.trim()).filter(Boolean)
112          const tail = lines[lines.length - 1] ?? ''
113          if (/\?\s*\**\s*$/.test(tail) && tail.length <= (det.question_max_chars ?? 400)) {
114            out.push({ ...base, kind: 'trailing_question', tool: '', text: oneLine(tail, 300) })
115          }
116        }
117      }
118    }
119  } else if (t === 'user') {
120    const c = d.message?.content
121    if (!Array.isArray(c)) return out
122    for (const b of c) {
123      if (!b || b.type !== 'tool_result') continue
124      const call = ctx.tools?.get(b.tool_use_id)
125      if (call) ctx.tools.delete(b.tool_use_id)
126      const body = typeof b.content === 'string' ? b.content : textOf(b.content)
127      const tool = call?.name ?? ''
128      if (b.is_error) {
129        const denied = new RegExp(det.denied_regex ?? "doesn't want to proceed|tool use was rejected|permission to use .* (?:has been|was) denied|user denied", 'i')
130        out.push({ ...base, kind: denied.test(body) ? 'denied' : 'tool_error', tool, text: oneLine(body, 400) })
131        continue
132      }
133      const tur = d.toolUseResult && typeof d.toolUseResult === 'object' ? d.toolUseResult : {}
134      if ((tool === 'Workflow' || /agents_error/.test(body)) && (/agents_error/.test(body) || tur.status === 'failed')) {
135        out.push({ ...base, kind: 'workflow_error', tool: tool || 'Workflow', text: oneLine(body, 400) })
136        continue
137      }
138      if (tool === 'Bash' || tool === 'PowerShell') {
139        const cmd = new RegExp(det.ci_command_regex ?? '\\b(gh|glab)\\b[^\\n]*\\b(pr checks|ci (status|view|list)|run (view|watch|list)|pipeline)', 'i')
140        const fail = new RegExp(det.ci_failure_regex ?? '\\b(fail(?:ed|ure)?|skipped|canceled|cancelled)\\b', 'i')
141        if (call && cmd.test(call.command) && fail.test(body)) {
142          out.push({ ...base, kind: 'ci_failure', tool, text: oneLine(`${call.command.split('\n')[0]} → ${body}`, 400) })
143        }
144      }
145    }
146  }
147  return out
148}
149
150/** Hits found in one desktop-app session file (`postTurnSummary.needs_action`, `sessionPermissionUpdates`). */
151export function hitsOfSession(j, file = '') {
152  const out = []
153  if (!j || typeof j !== 'object') return out
154  const base = { session: j.cliSessionId ?? j.sessionId ?? '', file, sub: false, cwd: j.cwd ?? j.originCwd ?? '', title: j.title ?? '' }
155  const na = String(j.postTurnSummary?.needs_action ?? '').trim()
156  if (na) out.push({ ...base, at: j.lastActivityAt ?? 0, kind: 'needs_action', tool: '', text: oneLine(na, 300), key: `na:${base.session}:${normText(na, 80)}` })
157  for (const u of Array.isArray(j.sessionPermissionUpdates) ? j.sessionPermissionUpdates : []) {
158    if (u?.type === 'addDirectories') {
159      for (const dir of u.directories ?? []) {
160        out.push({ ...base, at: j.lastActivityAt ?? 0, kind: 'permission_dir', tool: '', text: `dossier ajouté : ${slash(dir)}`, key: `dir:${base.session}:${slash(dir).toLowerCase()}` })
161      }
162    } else if (u?.type) {
163      out.push({ ...base, at: j.lastActivityAt ?? 0, kind: 'permission_rule', tool: '', text: oneLine(`${u.type} ${JSON.stringify(u.rules ?? u.mode ?? '')}`, 200), key: `perm:${base.session}:${normText(JSON.stringify(u), 80)}` })
164    }
165  }
166  return out
167}
168
169// ---- santé and veille errors ----------------------------------------------------------------------------------------------
170
171/** « santé » for every mod record except veille's own, which is « veille ». */
172export const healthSource = mod => (String(mod).toLowerCase() === 'veille' ? 'veille' : 'santé')
173
174/**
175 * Hits from one health record (~/.claude/mods-health/<mod>.json, the shared convention of lib/core/health.mjs): one
176 * `mod_health` hit when its last run failed, keyed by mod and run time so a failed run counts once however often the
177 * collector reads it. `rec` undefined with `invalid` set: the record itself is unreadable.
178 */
179export function hitsOfHealth(rec, mod, file = '', invalid = '') {
180  const name = String(rec?.mod || mod || '').trim()
181  if (!name) return []
182  const base = { session: '', file, sub: false, cwd: '', tool: name, source: healthSource(name), title: `santé ${name}` }
183  if (invalid) return [{ ...base, at: 0, kind: 'mod_health', text: oneLine(`[${name}] fichier santé illisible : ${invalid}`, 300), key: `health:${name}:invalid:${normText(invalid, 60)}` }]
184  if (!rec || typeof rec !== 'object' || !rec.error) return []
185  const at = Date.parse(rec.lastRun ?? '') || 0
186  return [{ ...base, at, kind: 'mod_health', text: oneLine(`[${name}] ${rec.error}`, 300), key: `health:${name}:${rec.lastRun ?? ''}:${normText(rec.error, 60)}` }]
187}
188
189/**
190 * Hits from santé's report (<sante data_dir>/report.json, mods[]): a plugin.json or hooks.json that does not load, a
191 * config file that is not valid JSON, error lines in a log, errors listed inside an output file. Keyed by mod and the
192 * normalised message, so an error that stays counts once, and a new one (another message) counts again.
193 */
194export function hitsOfSante(report, file = '') {
195  const out = []
196  const at = Number(report?.generated) || 0
197  for (const m of Array.isArray(report?.mods) ? report.mods : []) {
198    const name = String(m?.name || '').trim()
199    if (!name) continue
200    const add = text => {
201      const t = oneLine(`[${name}] ${text}`, 300)
202      out.push({ at, session: '', file, sub: false, cwd: '', tool: name, source: healthSource(name), title: `santé ${name}`, kind: 'sante_error', text: t, key: `sante:${name}:${normText(text, 80)}` })
203    }
204    if (m.manifest && m.manifest.ok === false) add(`plugin.json : ${m.manifest.error ?? 'illisible'}`)
205    if (m.hooks && m.hooks.ok === false) add(m.hooks.error ?? 'hooks.json illisible')
206    for (const c of m.configs ?? []) if (c && c.ok === false && c.error !== 'absent') add(`config ${String(c.path ?? '').split('/').pop()} : ${c.error ?? 'JSON invalide'}`)
207    for (const l of m.logs ?? []) if (l && l.errors > 0) add(`journal ${String(l.path ?? '').split('/').pop()} : ${l.last || `${l.errors} erreur(s)`}`)
208    for (const f of m.fields ?? []) if (f && f.count > 0) add(`${String(f.path ?? '').split('/').pop()} ${f.field} : ${f.last || `${f.count} erreur(s)`}`)
209  }
210  return out
211}
212
213// ---- classification -----------------------------------------------------------------------------------------------------
214
215const rx = (s, cache) => {
216  if (!s) return null
217  if (!cache.has(s)) {
218    try { cache.set(s, new RegExp(s, 'i')) } catch { cache.set(s, /$^/) }
219  }
220  return cache.get(s)
221}
222const RX = new Map()
223
224/** The first rule that matches a hit, or null. A rule: { id, kinds?, match?, tools?, sub?, category, title, … }. */
225export function classify(hit, rules = []) {
226  for (const r of rules) {
227    if (r.kinds && !r.kinds.includes(hit.kind)) continue
228    if (r.tools && !r.tools.includes(hit.tool)) continue
229    if (r.sub !== undefined && r.sub !== !!hit.sub) continue
230    const m = r.match ? rx(r.match, RX)?.exec(hit.text ?? '') : null
231    if (r.match && !m) continue
232    return r
233  }
234  return null
235}
236
237/** What makes two hits the same problem: the rule, plus its `key` capture, the app (per_app) or the text (by: text). */
238export function signatureOf(hit, rule) {
239  const parts = [rule.id]
240  if (rule.key) {
241    const m = rx(rule.key, RX)?.exec(hit.text ?? '')
242    if (m) parts.push(normText(m[1] ?? m[0], 60))
243  }
244  if (rule.per_app) parts.push(`@${hit.app || '?'}`)
245  if (rule.by === 'text') parts.push(normText(hit.text, 80))
246  return parts.join(':')
247}
248
249export function keyOf(hit, rule) {
250  if (!rule.key) return ''
251  const m = rx(rule.key, RX)?.exec(hit.text ?? '')
252  return m ? oneLine(m[1] ?? m[0], 60) : ''
253}
254
255/** Fields of a new problem made from its first hit. */
256export function problemFields(hit, rule) {
257  const vars = { app: hit.app || '?', text: oneLine(hit.text, 120), key: keyOf(hit, rule), tool: hit.tool || '' }
258  const category = rule.category === 'apps' ? `apps/${hit.app || 'inconnue'}` : rule.category
259  return {
260    category,
261    scope: rule.scope ?? (hit.app ? `app:${hit.app}` : 'global'),
262    app: hit.app || '',
263    title: oneLine(fill(rule.title ?? '{text}', vars), 140),
264    symptom: oneLine(fill(rule.symptom ?? '{text}', vars), 400),
265    cause: fill(rule.cause ?? '', vars),
266    fix: fill(rule.fix ?? '', vars),
267    status: rule.status ?? 'ouvert',
268  }
269}
270
271export function evidenceOf(hit) {
272  return { at: hit.at || 0, session: hit.session || '', file: hit.file || '', title: hit.title || '', text: oneLine(hit.text, 200) }
273}
274
275// ---- ledger ----------------------------------------------------------------------------------------------------------
276
277export function parseLines(text) {
278  const out = []
279  for (const l of String(text ?? '').split('\n')) {
280    if (!l.trim()) continue
281    try {
282      const j = JSON.parse(l)
283      if (j && typeof j === 'object' && j.op && j.id) out.push(j)
284    } catch { /* a torn last line: skipped */ }
285  }
286  return out
287}
288
289/** One entry per problem, in id order. `timelineMax` caps the occurrences kept per problem (newest kept). */
290export function fold(lines, timelineMax = 60) {
291  const map = new Map()
292  for (const l of lines) {
293    if (l.op === 'add') {
294      if (map.has(l.id)) continue
295      map.set(l.id, {
296        id: l.id, signature: l.signature ?? '', category: l.category ?? 'autres', scope: l.scope ?? 'global', app: l.app ?? '',
297        title: l.title ?? l.id, symptom: l.symptom ?? '', cause: l.cause ?? '', fix: l.fix ?? '', status: STATUSES.includes(l.status) ? l.status : 'ouvert',
298        source: l.source ?? 'auto', ref: l.ref ?? '', first_seen: l.first_seen ?? l.at ?? 0, last_seen: l.last_seen ?? l.first_seen ?? l.at ?? 0,
299        count: l.count ?? 0, evidence: [...(l.evidence ?? [])], timeline: [], notes: [], changed: l.at ?? 0, apps: l.app ? [l.app] : [],
300      })
301      continue
302    }
303    const p = map.get(l.id)
304    if (!p) continue
305    if (l.op === 'seen') {
306      const n = Math.max(1, Number(l.n) || 1)
307      p.count += n
308      const at = l.last ?? l.at ?? 0
309      if (!p.first_seen || (l.first && l.first < p.first_seen)) p.first_seen = l.first ?? at
310      if (at > p.last_seen) p.last_seen = at
311      p.timeline.push({ at, n, evidence: l.evidence ?? [] })
312      if (p.timeline.length > timelineMax) p.timeline.shift()
313      for (const a of l.apps ?? []) if (a && !p.apps.includes(a)) p.apps.push(a)
314      for (const ev of l.evidence ?? []) {
315        p.evidence.push(ev)
316        if (p.evidence.length > 12) p.evidence.shift()
317      }
318    } else if (l.op === 'update') {
319      for (const [k, v] of Object.entries(l.fields ?? {})) {
320        if (k === 'status' && !STATUSES.includes(v)) continue
321        if (['status', 'fix', 'cause', 'title', 'symptom', 'category', 'scope', 'app'].includes(k)) p[k] = v
322        if (k === 'evidence' && Array.isArray(v)) p.evidence.push(...v)
323      }
324      p.changed = l.at ?? p.changed
325      if (l.fields?.status) p.status_at = l.at ?? 0
326    } else if (l.op === 'note') {
327      p.notes.push({ at: l.at ?? 0, text: String(l.text ?? '').slice(0, 2000) })
328    }
329  }
330  return [...map.values()].sort((a, b) => idNum(a.id) - idNum(b.id))
331}
332
333const idNum = id => Number(String(id).replace(/\D/g, '')) || 0
334
335export function nextId(problems, used = 0) {
336  const max = Math.max(used, 0, ...problems.map(p => idNum(p.id)))
337  return `P-${String(max + 1).padStart(3, '0')}`
338}
339
340/**
341 * Ledger lines for a batch of hits (already tagged with `app`): one `add` per new signature, one `seen` per problem
342 * with the number of hits, first and last time and up to `maxEvidence` examples. A problem marked corrigé that shows
343 * up again after its status changed is reopened (an `update` line), unless `reopen` is false.
344 */
345export function planLines(hits, problems, rules, now, { maxEvidence = 3, reopen = true } = {}) {
346  const bySig = new Map(problems.map(p => [p.signature, p]))
347  const groups = new Map()
348  let ignored = 0
349  const unmatched = new Map()
350  for (const h of hits) {
351    const r = classify(h, rules)
352    if (!r || r.ignore) {
353      ignored++
354      if (!r) {
355        const k = `${h.kind}: ${normText(h.text, 70)}`
356        unmatched.set(k, (unmatched.get(k) ?? 0) + 1)
357      }
358      continue
359    }
360    const sig = signatureOf(h, r)
361    if (!groups.has(sig)) groups.set(sig, { rule: r, hits: [] })
362    groups.get(sig).hits.push(h)
363  }
364  const lines = []
365  let used = 0
366  const added = []
367  for (const [sig, g] of groups) {
368    g.hits.sort((a, b) => (a.at || 0) - (b.at || 0))
369    const first = g.hits[0]
370    const last = g.hits[g.hits.length - 1]
371    let p = bySig.get(sig)
372    if (!p) {
373      const id = nextId(problems, used)
374      used = idNum(id)
375      lines.push({ op: 'add', id, at: now, signature: sig, source: first.source || 'auto', rule: g.rule.id, first_seen: first.at || now, ...problemFields(first, g.rule), evidence: [] })
376      p = { id, status: g.rule.status ?? 'ouvert' }
377      added.push(id)
378    } else if (reopen && p.status === 'corrigé' && (last.at || now) > (p.status_at || p.changed || 0)) {
379      lines.push({ op: 'update', id: p.id, at: now, fields: { status: 'ouvert' }, why: 'réapparu après correction' })
380    }
381    const ev = []
382    const seen = new Set()
383    for (const h of [...g.hits].reverse()) {
384      const k = `${h.session}|${normText(h.text, 60)}`
385      if (seen.has(k)) continue
386      seen.add(k)
387      ev.push(evidenceOf(h))
388      if (ev.length >= maxEvidence) break
389    }
390    const apps = [...new Set(g.hits.map(h => h.app).filter(Boolean))]
391    lines.push({ op: 'seen', id: p.id, at: now, n: g.hits.length, first: first.at || now, last: last.at || now, apps, evidence: ev })
392  }
393  return { lines, added, ignored, unmatched: [...unmatched.entries()].sort((a, b) => b[1] - a[1]) }
394}
395
396// ---- seeds (imports from existing registers) ---------------------------------------------------------------------------
397
398/** Markdown tables with the heading above each: [{ heading, headers, rows: [[cells]] }]; bullets: [{ heading, text }]. */
399export function mdBlocks(text) {
400  const tables = []
401  const bullets = []
402  let heading = ''
403  let cur = null
404  const cells = l => l.trim().replace(/^\|/, '').replace(/\|$/, '').split(/(?<!\\)\|/).map(c => c.trim())
405  for (const raw of String(text ?? '').split(/\r?\n/)) {
406    const l = raw.trim()
407    const h = l.match(/^#{1,6}\s+(.*)$/)
408    if (h) { heading = h[1].trim(); cur = null; continue }
409    if (l.startsWith('|')) {
410      const c = cells(l)
411      if (c.every(x => /^:?-{2,}:?$/.test(x))) continue
412      if (!cur) { cur = { heading, headers: c, rows: [] }; tables.push(cur) } else cur.rows.push(c)
413      continue
414    }
415    cur = null
416    const b = l.match(/^[-*]\s+(.*)$/)
417    if (b) bullets.push({ heading, text: b[1].trim() })
418  }
419  return { tables, bullets }
420}
421
422const COLS = {
423  ref: /^(#|id|n°|no|réf|ref)$/i,
424  title: /^(what|quoi|problème|probleme|question|title|titre|blocage|defect|défaut|claim|item)\b/i,
425  symptom: /^(symptom|symptôme|symptome|effect|effet|détail|detail|evidence|preuves?)\b/i,
426  cause: /^(cause|root cause|pourquoi|why|raison)\b/i,
427  fix: /^(fix|correctif|solution|remède|remede|action)\b/i,
428  todo: /^(still|reste|todo|à faire|next)\b/i,
429  status: /^(status|statut|état|etat)\b/i,
430  when: /^(when|date|quand|first seen|depuis|time|heure)\b/i,
431  count: /^(count|nombre|fois|n|occurrences|×)$/i,
432  app: /^(project|projet|app|application|repo|dépôt)\b/i,
433  source: /^(source|sources|session|conversation|lien|link)\b/i,
434  category: /^(category|catégorie|categorie|type)$/i,
435  duration: /^(duration|durée)\b/i,
436  avoidable: /^(y\/n|évitable|evitable|avoidable)\b/i,
437}
438
439export function statusOf(s) {
440  const t = String(s ?? '').toLowerCase()
441  if (!t) return 'ouvert'
442  if (/not fixed|non corrigé|open|ouvert|todo|à faire/.test(t)) return 'ouvert'
443  if (/workaround|contourn/.test(t)) return 'contourné'
444  if (/kept off|by choice|accept|wontfix|confirmed/.test(t)) return 'accepté'
445  if (/fixed|corrig|merged|done|resolved|résolu|applied|reverted/.test(t)) return 'corrigé'
446  return 'ouvert'
447}
448
449export function dateOf(s, fallback = 0, year = new Date(fallback || Date.now()).getFullYear()) {
450  const t = String(s ?? '')
451  const iso = t.match(/\b(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}))?/)
452  if (iso) return new Date(+iso[1], +iso[2] - 1, +iso[3], +(iso[4] ?? 0), +(iso[5] ?? 0)).getTime()
453  const months = { jan: 0, janv: 0, feb: 1, févr: 1, fev: 1, mar: 2, mars: 2, apr: 3, avr: 3, may: 4, mai: 4, jun: 5, juin: 5, jul: 6, juil: 6, aug: 7, août: 7, aout: 7, sep: 8, sept: 8, oct: 9, nov: 10, dec: 11, déc: 11 }
454  const dm = t.match(/\b(\d{1,2})\s+([A-Za-zéû]{3,5})\.?(?:\s+(\d{1,2})[:h](\d{2}))?/)
455  if (dm && months[dm[2].toLowerCase()] !== undefined) return new Date(year, months[dm[2].toLowerCase()], +dm[1], +(dm[3] ?? 0), +(dm[4] ?? 0)).getTime()
456  return fallback
457}
458
459/**
460 * Problems (add lines without id) from a markdown register: every table row with a title, and bullets of sections that
461 * allow them. Category: a Category column, else the section's `category`, else the first `seed` rule that matches, else
462 * the section's `fallback`, else the source's `category`.
463 */
464export function seedFromMarkdown(text, src, rules = [], fallbackAt = 0) {
465  const { tables, bullets } = mdBlocks(text)
466  const out = []
467  const section = h => (src.sections ?? []).find(s => rx(s.match, RX)?.test(h)) ?? {}
468  for (const tb of tables) {
469    const sec = section(tb.heading)
470    if (sec.skip) continue
471    const idx = {}
472    tb.headers.forEach((hname, i) => { for (const [k, re] of Object.entries(COLS)) if (idx[k] === undefined && re.test(hname.replace(/\*/g, ''))) idx[k] = i })
473    if (idx.title === undefined && idx.symptom === undefined && sec.title_from && idx[sec.title_from] !== undefined) {
474      idx.title = idx[sec.title_from]
475      delete idx[sec.title_from]
476    }
477    if (idx.title === undefined && idx.symptom === undefined) continue
478    for (const row of tb.rows) {
479      const get = k => (idx[k] === undefined ? '' : (row[idx[k]] ?? '').replace(/\\\|/g, '|').trim())
480      const title = get('title') || get('symptom')
481      if (!title) continue
482      const ref = get('ref')
483      const extra = [get('symptom') !== title ? get('symptom') : '', get('duration') ? `durée : ${get('duration')}` : '', get('source') ? `où : ${get('source')}` : '',
484        get('avoidable') ? `évitable : ${get('avoidable')}` : ''].filter(Boolean).join(' · ')
485      const whole = [title, extra, get('cause'), get('fix')].join(' ')
486      const legit = idx.avoidable !== undefined && /^(n|non|no)$/i.test(get('avoidable'))
487      const fix = [get('fix') || (idx.status !== undefined ? get('status') : ''), get('todo') ? `reste : ${get('todo')}` : ''].filter(Boolean).join(' · ')
488      const r = classify({ kind: 'seed', text: whole, tool: '', sub: false }, rules)
489      out.push({
490        ref: `${src.id}:${normText(tb.heading, 24)}:${ref || normText(title, 50)}`,
491        title: oneLine(ref ? `${ref} · ${title}` : title, 140),
492        symptom: oneLine(extra || title, 600),
493        cause: oneLine(get('cause'), 600),
494        fix: oneLine(fix, 600),
495        status: idx.status !== undefined ? statusOf(get('status')) : legit ? 'accepté' : get('todo') ? 'ouvert' : (sec.status ?? src.status ?? 'ouvert'),
496        category: get('category') || sec.category || (r && r.category !== 'apps' ? r.category : '') || sec.fallback || src.category || 'autres',
497        app: get('app'),
498        first_seen: dateOf(get('when'), fallbackAt),
499        count: Math.max(1, Number(get('count').replace(/\D/g, '')) || 1),
500        evidence: [{ at: fallbackAt, session: '', file: src.label ?? src.id, title: '', text: oneLine(get('source') || tb.heading, 200) }],
501      })
502    }
503  }
504  for (const b of bullets) {
505    const sec = section(b.heading)
506    if (sec.skip || !sec.bullets) continue
507    const r = classify({ kind: 'seed', text: b.text, tool: '', sub: false }, rules)
508    // sec.classes: [{ match, category }] tried before the global seed rules; sec.fixed: regex that marks a bullet fixed ("" = never);
509    // sec.unconfirmed: regex that marks a bullet the source itself does not confirm, tagged with sec.unconfirmed_tag in the title.
510    const own = (sec.classes ?? []).find(c => c && rx(c.match, RX)?.test(b.text))
511    const fixedRx = sec.fixed === undefined ? /\b(fixed|corrigé|caught)\b/i : rx(sec.fixed, RX)
512    const unsure = !!(sec.unconfirmed && rx(sec.unconfirmed, RX)?.test(b.text))
513    const tag = sec.unconfirmed_tag ?? 'non confirmé'
514    const label = src.label ?? src.id
515    out.push({
516      ref: `${src.ref_id ?? src.id}:${normText(b.heading, 24)}:${normText(b.text, 50)}`, title: oneLine(unsure ? `${tag} · ${b.text}` : b.text, 140),
517      symptom: oneLine(b.text, 600), cause: unsure ? oneLine(fill(sec.unconfirmed_cause ?? '{tag} ({file})', { tag, file: label }), 600) : '',
518      fix: oneLine(sec.fix ?? '', 600),
519      status: statusOf(fixedRx?.test(b.text) ? 'fixed' : sec.status ?? 'ouvert'),
520      category: own?.category || sec.category || (r && r.category !== 'apps' ? r.category : '') || sec.fallback || src.category || 'autres', app: '',
521      first_seen: fallbackAt, count: 1, evidence: [{ at: fallbackAt, session: '', file: label, title: unsure ? tag : '', text: oneLine(b.heading, 200) }],
522    })
523  }
524  return out
525}
526
527/** Problems from an incidents JSON ({ incidents: [{ id, title, dates, evidence, root_cause, fix, fix_status, sources }] }). */
528export function seedFromIncidents(j, src, rules = [], fallbackAt = 0) {
529  return (j?.incidents ?? []).map(i => {
530    const whole = [i.title, i.evidence, i.root_cause].join(' ')
531    const r = classify({ kind: 'seed', text: whole, tool: '', sub: false }, rules)
532    return {
533      ref: `${src.id}:${i.id}`, title: oneLine(`${i.id} · ${i.title}`, 140), symptom: oneLine(i.evidence, 600), cause: oneLine(i.root_cause, 600),
534      fix: oneLine(i.fix, 600), status: statusOf(i.fix_status), category: (r && r.category !== 'apps' ? r.category : '') || src.category || 'workflow/arrets',
535      app: '', first_seen: dateOf(i.dates, fallbackAt), count: 1,
536      evidence: [{ at: fallbackAt, session: '', file: src.label ?? src.id, title: i.severity ?? '', text: oneLine((i.sources ?? []).join(', '), 200) }],
537    }
538  })
539}
540
541/** Rule changes that did not work or stayed open, from a findings JSON ({ findings: [{ id, date, claim, verdict, status, note }] }). */
542export function seedFromFindings(j, src, fallbackAt = 0) {
543  const keep = src.keep ?? { verdict: ['did not'], status: ['open', 'ignored'] }
544  return (j?.findings ?? []).filter(f => (keep.verdict ?? []).includes(f.verdict) || (keep.status ?? []).includes(f.status)).map(f => ({
545    ref: `${src.id}:${f.id}`, title: oneLine(`${f.id} · ${f.claim}`, 140), symptom: oneLine((f.metrics ?? []).join(' · ') || f.claim, 600),
546    cause: '', fix: oneLine(f.note, 600), status: f.status === 'reverted' ? 'corrigé' : 'ouvert', category: src.category || 'regles/trous', app: '',
547    first_seen: dateOf(f.date, fallbackAt), count: 1, evidence: [{ at: fallbackAt, session: '', file: src.label ?? src.id, title: `verdict : ${f.verdict} · ${f.status}`, text: '' }],
548  }))
549}
550
551/** Ledger lines for seed problems not imported yet (matched by `seed:<ref>` signature). */
552export function seedLines(seeds, problems, now) {
553  const have = new Set(problems.map(p => p.signature))
554  const lines = []
555  let used = 0
556  for (const s of seeds) {
557    const sig = `seed:${s.ref}`
558    if (have.has(sig)) continue
559    have.add(sig)
560    const id = nextId(problems, used)
561    used = idNum(id)
562    const { ref, count, first_seen, evidence, ...f } = s
563    lines.push({ op: 'add', id, at: now, signature: sig, source: 'seed', ref, scope: f.app ? `app:${f.app}` : 'global', ...f, first_seen: first_seen || now, evidence: [] })
564    lines.push({ op: 'seen', id, at: now, n: count || 1, first: first_seen || now, last: first_seen || now, apps: f.app ? [f.app] : [], evidence })
565  }
566  return lines
567}
568
569// ---- view -----------------------------------------------------------------------------------------------------------------
570
571/** Monday 00:00 local time of the week holding `now`. */
572export function weekStart(now) {
573  const d = new Date(now)
574  d.setHours(0, 0, 0, 0)
575  d.setDate(d.getDate() - ((d.getDay() + 6) % 7))
576  return d.getTime()
577}
578
579export function totals(problems, now) {
580  const w = weekStart(now)
581  return {
582    open: problems.filter(p => p.status === 'ouvert').length,
583    fixed: problems.filter(p => p.status === 'corrigé').length,
584    other: problems.filter(p => p.status !== 'ouvert' && p.status !== 'corrigé').length,
585    week: problems.filter(p => (p.first_seen || 0) >= w).length,
586    week_seen: problems.reduce((a, p) => a + p.timeline.filter(t => t.at >= w).reduce((x, t) => x + t.n, 0), 0),
587  }
588}
589
590/** Filters: status (all | a status), category (all | top or leaf id), app (all | name), week (all | this | 30d). */
591export function applyFilters(problems, f = {}, now = 0) {
592  const w = f.week === 'this' ? weekStart(now) : f.week === '30d' ? now - 30 * 86400000 : 0
593  return problems.filter(p =>
594    (!f.status || f.status === 'all' || p.status === f.status) &&
595    (!f.category || f.category === 'all' || p.category === f.category || p.category.startsWith(f.category + '/')) &&
596    (!f.app || f.app === 'all' || p.app === f.app || (p.apps ?? []).includes(f.app)) &&
597    (!w || (p.last_seen || 0) >= w))
598}
599
600export function appsOf(problems) {
601  const n = new Map()
602  for (const p of problems) for (const a of new Set([p.app, ...(p.apps ?? [])].filter(Boolean))) n.set(a, (n.get(a) ?? 0) + 1)
603  return [...n.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
604}
605
606const rank = p => [p.status === 'ouvert' ? 0 : 1, -(p.last_seen || 0)]
607const byRank = (a, b) => { const x = rank(a), y = rank(b); return x[0] - y[0] || x[1] - y[1] }
608
609/**
610 * The category tree with the problems under each node. `categories` is the configured tree; a node with
611 * `dynamic: 'app'` gets one child per app (cross view: a problem also stays in its own category). Problems whose
612 * category is not in the tree go under their top-level node, or under « autres ».
613 */
614export function tree(problems, categories = [], labels = {}) {
615  const nodes = []
616  const leafIds = new Set()
617  const walk = (c, depth) => { leafIds.add(c.id); for (const ch of c.children ?? []) walk(ch, depth + 1) }
618  for (const c of categories) walk(c, 0)
619  const own = id => problems.filter(p => p.category === id)
620  const build = (c) => {
621    if (c.dynamic === 'app') {
622      const apps = appsOf(problems)
623      const children = apps.map(([a]) => {
624        const list = problems.filter(p => p.app === a || (p.apps ?? []).includes(a) || p.category === `${c.id}/${a}`).sort(byRank)
625        return { id: `${c.id}/${a}`, label: a, problems: list, children: [], total: list.length, open: list.filter(p => p.status === 'ouvert').length }
626      })
627      const all = new Set(children.flatMap(ch => ch.problems))
628      return { id: c.id, label: c.label, cross: true, problems: [], children, total: all.size, open: [...all].filter(p => p.status === 'ouvert').length }
629    }
630    const children = (c.children ?? []).map(build)
631    const top = c.id.includes('/') ? [] : problems.filter(p => p.category !== c.id && p.category.startsWith(c.id + '/') && !leafIds.has(p.category))
632    const list = [...own(c.id), ...top].sort(byRank)
633    const all = [...list, ...children.flatMap(ch => collect(ch))]
634    return { id: c.id, label: c.label, problems: list, children, total: all.length, open: all.filter(p => p.status === 'ouvert').length }
635  }
636  const collect = n => [...n.problems, ...n.children.flatMap(collect)]
637  for (const c of categories) nodes.push(build(c))
638  const tops = new Set(categories.map(c => c.id))
639  const rest = problems.filter(p => !leafIds.has(p.category) && !tops.has(p.category.split('/')[0])).sort(byRank)
640  if (rest.length) nodes.push({ id: 'autres', label: labels.other ?? 'Autres', problems: rest, children: [], total: rest.length, open: rest.filter(p => p.status === 'ouvert').length })
641  return nodes
642}
643
644/** One-line summary of a closed node: counts and the most recent open titles. */
645export function summaryOf(node, n = 2, empty = 'rien') {
646  const all = []
647  const walk = x => { all.push(...x.problems); x.children.forEach(walk) }
648  walk(node)
649  if (!all.length) return empty
650  const uniq = [...new Set(all)].sort(byRank)
651  return uniq.slice(0, n).map(p => `${p.id} ${p.title}`).join(' · ') + (uniq.length > n ? ` · +${uniq.length - n}` : '')
652}
653
654export function fmtAge(at, now) {
655  if (!at) return '—'
656  const s = Math.max(0, Math.round((now - at) / 1000))
657  if (s < 90) return `${s} s`
658  if (s < 5400) return `${Math.round(s / 60)} min`
659  if (s < 129600) return `${Math.round(s / 3600)} h`
660  return `${Math.round(s / 86400)} j`
661}
662
663export function fmtDate(at) {
664  if (!at) return '—'
665  const d = new Date(at)
666  const p = n => String(n).padStart(2, '0')
667  return `${p(d.getDate())}/${p(d.getMonth() + 1)} ${p(d.getHours())}:${p(d.getMinutes())}`
668}
669
670/** Links worth a button: PR / MR URLs and #numbers in a text. */
671export function linksOf(text) {
672  return [...new Set(String(text ?? '').match(/https?:\/\/[^\s)\]"'>]+/g) ?? [])]
673}
674
lib/metrics/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
lib/menage/lib/classify.mjs 230 lines
1// menage mod: pure classification of what lies around (worktrees, branches, folders, sessions), the exact command
2// to show for each row and the totals. No I/O: node --test covers it directly. The mod never runs a command.
3
4/** Forward slashes, no trailing slash. */
5export const norm = p => String(p || '').replace(/\\/g, '/').replace(/\/+$/, '')
6/** Key to compare paths (case-insensitive on Windows-style drive paths). */
7export const pathKey = p => {
8  const n = norm(p)
9  return /^[A-Za-z]:\//.test(n) ? n.toLowerCase() : n
10}
11/** A path quoted for the shell (double quotes, forward slashes). */
12export const q = p => `"${norm(p)}"`
13
14/** `{name}` placeholders from `vars`. */
15export const fill = (s, vars) => String(s || '').replace(/\{(\w+)\}/g, (m, k) => (vars[k] === undefined ? m : String(vars[k])))
16
17/** Deep merge: objects merge, arrays and scalars from `b` win. */
18export function deepMerge(a, b) {
19  if (Array.isArray(b)) return b.slice()
20  if (!b || typeof b !== 'object') return b === undefined ? a : b
21  const out = { ...(a && typeof a === 'object' && !Array.isArray(a) ? a : {}) }
22  for (const [k, v] of Object.entries(b)) out[k] = deepMerge(out[k], v)
23  return out
24}
25
26/** `~` and `%VAR%` / `$VAR` expanded, forward slashes. */
27export function expand(p, env = {}) {
28  let s = String(p || '')
29  s = s.replace(/^~(?=[\\/]|$)/, env.HOME || '')
30  s = s.replace(/%([A-Za-z_][A-Za-z0-9_]*)%/g, (m, k) => (env[k] ?? m))
31  s = s.replace(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, (m, k) => (env[k] ?? m))
32  return norm(s)
33}
34
35/** A value still in <angle brackets> counts as unset. */
36export const unset = p => !p || /<[^>]+>/.test(String(p))
37
38/** `git worktree list --porcelain` → [{ path, head, branch, detached, bare, locked, prunable }]. */
39export function parseWorktrees(text) {
40  const out = []
41  let cur
42  for (const line of String(text || '').split(/\r?\n/)) {
43    if (line.startsWith('worktree ')) {
44      cur = { path: norm(line.slice(9)), head: '', branch: '', detached: false, bare: false, locked: false, prunable: false }
45      out.push(cur)
46    } else if (!cur) continue
47    else if (line.startsWith('HEAD ')) cur.head = line.slice(5)
48    else if (line.startsWith('branch ')) cur.branch = line.slice(7).replace(/^refs\/heads\//, '')
49    else if (line === 'detached') cur.detached = true
50    else if (line === 'bare') cur.bare = true
51    else if (line.startsWith('locked')) cur.locked = true
52    else if (line.startsWith('prunable')) cur.prunable = true
53  }
54  return out
55}
56
57/** `git status --porcelain` text → counts. */
58export function parseStatus(text) {
59  const lines = String(text || '').split(/\r?\n/).filter(Boolean)
60  return { changed: lines.filter(l => !l.startsWith('??')).length, untracked: lines.filter(l => l.startsWith('??')).length }
61}
62
63/** owner/name from a git remote URL (https or ssh), or ''. */
64export function slugOf(url) {
65  const m = String(url || '').trim().match(/[:/]([^/:]+(?:\/[^/:]+)*?\/[^/]+?)(?:\.git)?\/?$/)
66  if (!m) return ''
67  const parts = m[1].split('/')
68  return parts.slice(-2).join('/').toLowerCase()
69}
70
71/** Branch name → merged PR info, from the Desktop session files' `prs` lists. Key: `<slug>#<branch>` and `#<branch>`. */
72export function prIndex(sessions) {
73  const idx = {}
74  for (const s of sessions || []) {
75    for (const p of s.prs || []) {
76      if (!p || !p.branch) continue
77      const rec = { state: String(p.state || '').toUpperCase(), number: p.number, url: p.url }
78      for (const k of [`${String(p.repo || '').toLowerCase()}#${p.branch}`, `#${p.branch}`]) {
79        const old = idx[k]
80        if (!old || rec.state === 'MERGED' || (old.state !== 'MERGED' && rec.state === 'OPEN')) idx[k] = rec
81      }
82    }
83  }
84  return idx
85}
86export const prFor = (idx, slug, branch) => (branch ? idx[`${slug}#${branch}`] || idx[`#${branch}`] : undefined)
87
88/**
89 * How a branch (or detached HEAD) relates to the base branch.
90 * facts: { ancestor, empty, squash, pr: {state}, gone }. Returns { merged, how, label }.
91 *   ancestor: tip reachable from base (git branch --merged); squash: the files the branch changed are identical in base;
92 *   pr: a session file says its PR is MERGED. gone alone (remote branch deleted) is a hint, not proof.
93 */
94export function mergeState(f, labels = {}) {
95  const L = { ancestor: 'mergée', empty: 'aucun commit en plus de main', squash: 'mergée (squash : contenu identique dans main)', pr: 'PR #{n} mergée', gone: 'branche distante supprimée, non prouvée mergée', no: 'non mergée', ...labels }
96  if (f.ancestor && f.empty) return { merged: true, how: 'empty', label: L.empty }
97  if (f.ancestor) return { merged: true, how: 'ancestor', label: L.ancestor }
98  if (f.squash) return { merged: true, how: 'squash', label: L.squash }
99  if (f.pr && f.pr.state === 'MERGED') return { merged: true, how: 'pr', label: fill(L.pr, { n: f.pr.number ?? '?' }) }
100  if (f.gone) return { merged: false, how: 'gone', label: L.gone }
101  return { merged: false, how: 'no', label: L.no }
102}
103
104const DEFAULT_CMDS = {
105  worktree_remove: 'git -C {repo} worktree remove {path}',
106  worktree_prune: 'git -C {repo} worktree prune',
107  branch_delete: 'git -C {repo} branch -d {branch}',
108  branch_delete_squash: 'git -C {repo} branch -D {branch}',
109  remote_delete: 'git -C {repo} push {remote} --delete {branch}',
110  folder_delete: 'rm -rf {path}',
111}
112/** The exact command for a row, from the config's `commands` templates; paths quoted. */
113export function command(kind, vars, cmds = {}) {
114  const t = { ...DEFAULT_CMDS, ...cmds }[kind]
115  if (!t) return ''
116  return fill(t, { ...vars, repo: vars.repo ? q(vars.repo) : vars.repo, path: vars.path ? q(vars.path) : vars.path })
117}
118
119/**
120 * One worktree row: level safe (green, removable as is), check (orange), danger (red, "ne pas supprimer").
121 * w: parsed worktree plus { status: {changed, untracked}, merge: mergeState(), isMain }.
122 */
123export function worktreeRow(w, repo, cfg = {}) {
124  const L = cfg.labels || {}
125  const reasons = []
126  if (w.prunable) return { level: 'safe', reasons: [L.prunable || 'dossier disparu : git le liste encore'], command: command('worktree_prune', { repo }, cfg.commands), reclaim: 0 }
127  const dirty = w.status && w.status.changed + w.status.untracked > 0
128  if (dirty) reasons.push(fill(L.dirty || 'modifié : {changed} fichiers modifiés, {untracked} non suivis', w.status))
129  if (!w.merge?.merged) reasons.push(w.merge?.label || L.unmerged || 'non mergée')
130  if (w.locked) reasons.push(L.locked || 'verrouillé (git worktree lock)')
131  if (w.unknown) reasons.push(L.unknown || 'état illisible')
132  if (reasons.length) return { level: 'danger', reasons, command: '', reclaim: 0 }
133  const cmd = command('worktree_remove', { repo, path: w.path }, cfg.commands)
134  return { level: 'safe', reasons: [w.merge.label, L.clean || 'propre'], command: cmd, reclaim: w.size?.bytes || 0 }
135}
136
137/** A merged branch row (local or remote); squash/pr-merged local branches need -D since -d refuses them. */
138export function branchRow(b, repo, cfg = {}) {
139  const L = cfg.labels || {}
140  if (b.remote) {
141    return { level: 'safe', reasons: [b.merge.label, L.remote_note || 'supprime la branche sur le serveur'], command: command('remote_delete', { repo, remote: b.remoteName || 'origin', branch: b.name }, cfg.commands) }
142  }
143  if (b.merge.how === 'ancestor' || b.merge.how === 'empty') {
144    return { level: 'safe', reasons: [b.merge.label], command: command('branch_delete', { repo, branch: b.name }, cfg.commands) }
145  }
146  return { level: 'check', reasons: [b.merge.label, L.squash_note || '-d refuse une branche mergée par squash : -D, après vérification'], command: command('branch_delete_squash', { repo, branch: b.name }, cfg.commands) }
147}
148
149/** Keep the newest `keep` entries (by time), the rest are old. */
150export function splitOld(items, keep) {
151  const sorted = [...items].sort((a, b) => (b.time || 0) - (a.time || 0))
152  return { kept: sorted.slice(0, Math.max(0, keep)), old: sorted.slice(Math.max(0, keep)) }
153}
154
155/**
156 * A Desktop session that can be archived: not archived, idle at least `idleH` hours, every PR merged or closed and at
157 * least one merged. s: { archived, lastActivityAt, prs: [{state}] }.
158 */
159export function archivable(s, now, idleH = 12) {
160  if (s.archived || !s.prs || !s.prs.length) return false
161  if (now - (s.lastActivityAt || 0) < idleH * 3600000) return false
162  const st = s.prs.map(p => String(p.state || '').toUpperCase())
163  return st.every(x => x === 'MERGED' || x === 'CLOSED') && st.includes('MERGED')
164}
165
166/** Is a mods folder loaded by CLAUDE_CODE_PLUGIN_DIRS (paths separated by ; or , ; : only between POSIX paths)? */
167export function pluginDirs(value, env = {}) {
168  const v = String(value || '')
169  const parts = v.includes(';') || /^[A-Za-z]:/.test(v) ? v.split(/[;,]/) : v.split(/[:,]/)
170  return parts.map(s => s.trim()).filter(Boolean).map(s => pathKey(expand(s, env)))
171}
172
173/** Totals: sure = safe rows' bytes; check = check rows' bytes; counts per section and level. */
174export function totals(sections) {
175  const t = { sure: 0, check: 0, rows: 0, danger: 0, commands: 0 }
176  for (const s of sections || []) {
177    for (const r of s.rows || []) {
178      t.rows++
179      if (r.level === 'safe') t.sure += r.reclaim || 0
180      else if (r.level === 'check') t.check += r.reclaim || 0
181      else if (r.level === 'danger') t.danger++
182      if (r.command) t.commands++
183    }
184  }
185  return t
186}
187
188/** Bytes as "1,2 Go" (French units). */
189export function fmtBytes(n) {
190  if (n === null || n === undefined || !Number.isFinite(n)) return '—'
191  const u = ['o', 'Ko', 'Mo', 'Go', 'To']
192  let i = 0
193  let v = n
194  while (Math.abs(v) >= 1024 && i < u.length - 1) {
195    v /= 1024
196    i++
197  }
198  return `${(i === 0 ? String(Math.round(v)) : v.toFixed(v >= 100 ? 0 : 1)).replace('.', ',')} ${u[i]}`
199}
200
201/** Age from ms timestamps: "3 h", "2 j", "5 min". */
202export function fmtAge(then, now) {
203  if (!then) return '—'
204  const m = Math.max(0, (now - then) / 60000)
205  if (m < 60) return `${Math.round(m)} min`
206  if (m < 48 * 60) return `${Math.round(m / 60)} h`
207  return `${Math.round(m / 1440)} j`
208}
209
210/** Path shown relative to one of `roots` when inside it. */
211export function short(p, roots = []) {
212  const n = norm(p)
213  for (const r of roots) {
214    const rn = norm(r)
215    if (rn && pathKey(n).startsWith(pathKey(rn) + '/')) return n.slice(rn.length + 1)
216  }
217  return n
218}
219
220/** Rows filtered by the pane's filter: all | safe (récupérable) | danger (ne pas supprimer). */
221export function filterRows(rows, filter) {
222  if (filter === 'safe') return rows.filter(r => r.level === 'safe' || r.level === 'check')
223  if (filter === 'danger') return rows.filter(r => r.level === 'danger')
224  return rows
225}
226
227/** Every command of the rows at the given levels (safe by default), in order. */
228export const commandsOf = (sections, levels = ['safe']) =>
229  (sections || []).flatMap(s => (s.rows || []).filter(r => r.command && levels.includes(r.level)).map(r => r.command))
230
types/index.d.ts 29 lines
1// Types of the registres mod: the `$.state` values the pane reads (one set per tab, mirroring the owner mods').
2
3declare module 'claude-code' {
4  interface PluginState {
5    registres: {
6      /** shown tab: problemes | tests | regles | menage */
7      tab: string
8      /** Problèmes: status, period, category and app filters, open nodes, last note result */
9      p_status: string
10      p_week: string
11      p_category: string
12      p_app: string
13      p_open: string[]
14      p_note: string
15      /** Tests réels: open blocks (null = the config's default), revealed logins (app|login), last action line */
16      t_open: string[] | null
17      t_reveal: string[]
18      t_status: string
19      /** Règles: rules version of the before/after table (empty = latest) */
20      r_version: string
21      /** Ménage: row filter, last copy result */
22      m_filter: string
23      m_copied: string
24    }
25  }
26}
27
28export type Tab = 'problemes' | 'tests' | 'regles' | 'menage'
29