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…

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.
| Command | What it does | ||
|---|---|---|---|
/registres | opens 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 tests | Tests réels tab | ||
/registres regles [V5.8] [refresh] | Règles tab (« Les règles marchent ? ») | ||
/registres menage [refresh] | Ménage tab | ||
/registres close | closes the pane | ||
/problemes, /tests, /regles, /menage | aliases 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.
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:
| Tab | Owner | Copied into lib/ (never edit a copy) |
|---|---|---|
| Problèmes | mods/problemes | hooks/view.mjs, lib/core.mjs, lib/export.mjs, lib/eta/* (from export-eta), scanner/problemes_scan.mjs, problemes.config.json |
| Tests réels | mods/tests-reels | hooks/view.mjs, lib/registry.mjs, lib/secrets.mjs, lib/recover.mjs, lib/io.mjs, scanner/recover.mjs, tests-reels.config.json |
| Règles | mods/metrics | hooks/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énage | mods/menage | hooks/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.
~/.claude/problemes, ~/.claude/tests-reels, ~/.claude/metrics-mod, ~/.claude/menage-mod) and their override files there. Registres writes nothing of its own.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.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.
node --test mods/registres/tests/*.test.mjs
claude plugin validate mods/registres
claude plugin test mods/registres
Invented problems, apps, accounts and folders only.
hooks/register.tsx 345 lines1// 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}
345hooks/logic.mjs 51 lines1// 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
51lib/problemes/hooks/view.mjs 126 lines1// 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}
126lib/tests-reels/hooks/view.mjs 121 lines1// 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}
121lib/tests-reels/lib/registry.mjs 245 lines1// 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}
245lib/metrics/hooks/pane.mjs 72 lines1// metrics mod - the « Les règles marchent ? » pane's drawing, pure: no `$`. `el` is the element table from
2// $.ui.resolve(e), `M` the facts the hook read, `act` the callbacks it hands in. register.tsx draws /regles with it, and
3// the registres mod draws its « Règles » tab with a byte-for-byte copy (lib/core/targets.json): change it here only.
4import { duration, fill, fmtMetric, fmtNum, latest, markerLegend, markerLine, metricRows, pickVersion, spark, when } from './view.mjs'
5
6/** h-like factory over the element table: mk(el)('Text', { dimColor: true }, 'x'). */
7export const mk = (el) => (type, props, ...kids) =>
8 el[type]({ ...(props || {}), children: kids.flat(Infinity).filter((k) => k !== null && k !== undefined && k !== false) })
9
10/** M = { cfg, model, want, busy, error }; act = { setVersion(v), rescan() }. Returns the pane's tree. */
11export function drawRegles(el, M, act) {
12 const h = mk(el)
13 const { cfg, model, want } = M
14 const l = cfg.labels ?? {}
15 const status = M.busy ? 'calcul en cours…' : ''
16 if (!model) {
17 return h('Box', { flexDirection: 'column', gap: 1 },
18 h('Text', {}, fill(l.first_scan, { days: cfg.history_days ?? 30 })),
19 M.error ? h('Text', { color: 'error' }, `scan : ${M.error}`) : null,
20 status ? h('Text', { dimColor: true }, status) : null)
21 }
22 const tz = model.tzMin ?? 0
23 const vs = (model.versions ?? []).slice(-(cfg.max_versions ?? 12))
24 const i = pickVersion(vs, want)
25 const cur = vs[i]
26 const prev = i > 0 ? vs[i - 1] : undefined
27 const metrics = (cfg.metrics ?? []).filter((m) => m.show !== false)
28 const W = Math.max(...metrics.map((m) => (m.label || m.id).length), 10) + 2
29 const n = (model.days ?? []).length
30 const meta = model.meta ?? {}
31 const verdictColor = (v) => (v === 'better' ? 'success' : v === 'worse' ? 'error' : undefined)
32 const rows = cur ? metricRows(cfg, prev, cur) : []
33 const shortH = l.short_hours ?? 12
34 const summary = (cfg.summary_metrics ?? []).map((id) => metrics.find((m) => m.id === id)).filter(Boolean)
35 const cell = (s, w = 11) => s.padStart(w)
36
37 return h('Box', { flexDirection: 'column', gap: 1 },
38 h('Box', { flexDirection: 'column' },
39 h('Text', { dimColor: true }, fill(l.subtitle, { repos: meta.repos ?? 0, github: meta.github ?? 0, gitlab: meta.gitlab ?? 0, days: n, forge_at: when(meta.forge_at, tz) }) + (status ? ` · ${status}` : '')),
40 M.error ? h('Text', { color: 'error' }, `scan : ${M.error}`) : null,
41 (meta.errors ?? []).slice(0, 4).map((x) => h('Text', { color: 'warning' }, x))),
42
43 h('Box', { flexDirection: 'column' },
44 h('Text', { bold: true }, l.versions),
45 h('Box', { flexDirection: 'row', gap: 1, flexWrap: 'wrap' },
46 vs.map((v, k) => h('Button', { key: `v-${v.version}`, ...(k !== i ? { plain: true } : {}), onPress: () => act.setVersion(v.version) }, v.version)),
47 h('Button', { key: 'refresh', plain: true, onPress: () => act.rescan() }, '↻')),
48 cur ? h('Text', { dimColor: true }, `${cur.version} · ${when(cur.start, tz)} · ${cur.sha} ${cur.subject}`) : h('Text', { dimColor: true }, 'aucune version trouvée (rules.repo dans la config)')),
49
50 h('Box', { flexDirection: 'column' },
51 h('Text', { bold: true }, `${l.curves} (${model.days?.[0] ?? ''} → ${model.days?.[n - 1] ?? ''})`),
52 metrics.map((m) => h('Text', {}, `${(m.label || m.id).padEnd(W)}${spark(model.series?.[m.id], cfg.spark, cfg.spark_gap)} ${fmtMetric(m, latest(model.series?.[m.id]))}${m.target === undefined ? '' : ` · ${l.col_target} ${fmtMetric(m, m.target)}`}`)),
53 h('Text', { color: 'claude' }, `${''.padEnd(W)}${markerLine(n, model.markers, cfg.marker)}`),
54 h('Text', { dimColor: true }, `${''.padEnd(W)}${cfg.marker ?? '│'} ${markerLegend(model.markers, model.days)}`)),
55
56 cur
57 ? h('Box', { flexDirection: 'column' },
58 h('Text', { bold: true }, `${fill(l.before_after, { version: cur.version })} : ${prev ? `${prev.version} (${duration(prev.hours)})` : '—'} → ${cur.version} (${duration(cur.hours)})`),
59 (prev && prev.hours < shortH) || cur.hours < shortH ? h('Text', { color: 'warning' }, fill(l.short, { h: shortH })) : null,
60 h('Text', { dimColor: true }, `${(l.col_metric ?? '').padEnd(W)}${cell(prev ? prev.version : 'avant')}${cell(cur.version)} ${l.col_target ?? ''}`),
61 rows.map((r) => h('Text', verdictColor(r.verdict) ? { color: verdictColor(r.verdict) } : {}, `${r.label.padEnd(W)}${cell(r.before)}${cell(r.after)} ${r.arrow.padEnd(2)} ${r.target}`)))
62 : null,
63
64 h('Box', { flexDirection: 'column' },
65 h('Text', { bold: true }, l.all_versions),
66 h('Text', { dimColor: true }, `${'Version'.padEnd(8)}${cell(l.col_days ?? 'jours', 7)}${summary.map((m) => cell(String(m.short || m.id), 12)).join('')}`),
67 vs.map((v) => h('Text', v === cur ? { bold: true } : {}, `${v.version.padEnd(8)}${cell(fmtNum(v.hours / 24, 1), 7)}${summary.map((m) => cell(fmtMetric(m, v.values?.[m.id]), 12)).join('')}`))),
68
69 h('Box', { flexDirection: 'column' },
70 (l.notes ?? []).map((x) => h('Text', { dimColor: true }, `· ${fill(x, { gap: cfg.stall?.min_gap_min ?? 60 })}`))))
71}
72lib/menage/hooks/view.mjs 76 lines1// 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}
76lib/problemes/lib/core.mjs 674 lines1// 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}
674lib/metrics/hooks/view.mjs 127 lines1// metrics mod: pure formatting for the pane and the scanner's report (sparklines, markers, before/after rows).
2// No $ and no I/O: node --test covers it directly.
3
4const SPARK = '▁▂▃▄▅▆▇█'
5
6/** Number with French separators: 1 234,5. */
7export function fmtNum(v, digits = 0) {
8 if (v === null || v === undefined || !Number.isFinite(v)) return '—'
9 const s = Math.abs(v).toFixed(digits).replace('.', ',')
10 const [i, d] = s.split(',')
11 const out = i.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') + (d ? `,${d}` : '')
12 return v < 0 ? `-${out}` : out
13}
14
15/** Tokens as 12,3 M / 450 K. */
16export function fmtTok(v) {
17 if (v === null || v === undefined || !Number.isFinite(v)) return '—'
18 if (Math.abs(v) >= 1e9) return `${fmtNum(v / 1e9, 2)} G`
19 if (Math.abs(v) >= 1e6) return `${fmtNum(v / 1e6, 1)} M`
20 if (Math.abs(v) >= 1e3) return `${fmtNum(v / 1e3, 0)} K`
21 return fmtNum(v, 0)
22}
23
24/** One metric value as text, by its config entry (format: tokens | usd | pct | number; digits). */
25export function fmtMetric(m, v) {
26 if (v === null || v === undefined || !Number.isFinite(v)) return '—'
27 const f = m.format || (m.unit === '%' ? 'pct' : 'number')
28 if (f === 'tokens') return fmtTok(v)
29 if (f === 'usd') return `${fmtNum(v, m.digits ?? 2)} $`
30 if (f === 'pct') return `${fmtNum(v, m.digits ?? 0)} %`
31 return `${fmtNum(v, m.digits ?? 1)}${m.unit && m.unit !== '%' ? ` ${m.unit}` : ''}`
32}
33
34/** Sparkline: one character per value, scaled 0..max of the series; a missing value is `gap`. */
35export function spark(values, chars = SPARK, gap = '·') {
36 const xs = (values || []).filter(v => v !== null && v !== undefined && Number.isFinite(v))
37 const max = Math.max(0, ...xs)
38 const cs = [...chars]
39 return (values || []).map(v => {
40 if (v === null || v === undefined || !Number.isFinite(v)) return gap
41 if (max <= 0) return cs[0]
42 return cs[Math.min(cs.length - 1, Math.round((v / max) * (cs.length - 1)))]
43 }).join('')
44}
45
46/** A row as wide as the sparklines with `mark` at each marker's day. */
47export function markerLine(n, markers, mark = '│') {
48 const row = Array.from({ length: n }, () => ' ')
49 for (const m of markers || []) if (m.day >= 0 && m.day < n) row[m.day] = mark
50 return row.join('')
51}
52
53/** Markers grouped by day: « 13/10 V5.1–V5.7 · 15/10 V5.8 ». */
54export function markerLegend(markers, days) {
55 const byDay = new Map()
56 for (const m of markers || []) {
57 if (!byDay.has(m.day)) byDay.set(m.day, [])
58 byDay.get(m.day).push(m.version)
59 }
60 return [...byDay.entries()].map(([d, vs]) => {
61 const key = (days || [])[d] || ''
62 const label = key ? `${key.slice(8, 10)}/${key.slice(5, 7)}` : `j${d}`
63 return `${label} ${vs.length > 2 ? `${vs[0]}–${vs[vs.length - 1]}` : vs.join(', ')}`
64 }).join(' · ')
65}
66
67/** Last measured value of a series (or null). */
68export const latest = values => {
69 for (let i = (values || []).length - 1; i >= 0; i--) if (values[i] !== null && values[i] !== undefined && Number.isFinite(values[i])) return values[i]
70 return null
71}
72
73/**
74 * Before/after rows for one version against the previous one: label, both values as text, an arrow and whether the
75 * change is better / worse / flat by the metric's `better` (up | down | none) and the config's `flat_pct`.
76 */
77export function metricRows(cfg, prev, cur) {
78 const flat = cfg.flat_pct ?? 10
79 return (cfg.metrics || []).filter(m => m.show !== false).map(m => {
80 const a = prev && prev.values ? prev.values[m.id] : null
81 const b = cur && cur.values ? cur.values[m.id] : null
82 let arrow = ''
83 let verdict = 'none'
84 if (Number.isFinite(a) && Number.isFinite(b) && a !== null && b !== null) {
85 const base = Math.max(Math.abs(a), 1e-9)
86 const pct = ((b - a) / base) * 100
87 arrow = Math.abs(pct) < flat ? '=' : b > a ? '↑' : '↓'
88 if (m.better === 'up' || m.better === 'down') verdict = arrow === '=' ? 'flat' : (arrow === '↑') === (m.better === 'up') ? 'better' : 'worse'
89 }
90 const target = m.target === undefined ? '' : fmtMetric(m, m.target)
91 return { id: m.id, label: m.label || m.id, before: fmtMetric(m, a), after: fmtMetric(m, b), arrow, verdict, target }
92 })
93}
94
95/** Index of the version to show: the one named `want` (V5.8), else the latest. */
96export function pickVersion(versions, want) {
97 const vs = versions || []
98 const i = want ? vs.findIndex(v => v.version.toLowerCase() === String(want).toLowerCase()) : -1
99 return i >= 0 ? i : vs.length - 1
100}
101
102/** Deep merge of config objects (arrays and scalars of `b` replace). */
103export function deepMerge(a, b) {
104 if (Array.isArray(b)) return b.slice()
105 if (!b || typeof b !== 'object') return b === undefined ? a : b
106 const out = { ...(a && typeof a === 'object' && !Array.isArray(a) ? a : {}) }
107 for (const [k, v] of Object.entries(b)) out[k] = deepMerge(out[k], v)
108 return out
109}
110
111/** `~` to the home folder, forward slashes. */
112export const expandHome = (p, home) => String(p || '').replace(/^~(?=[\\/]|$)/, home).replace(/\\/g, '/')
113
114/** `{name}` placeholders from `vars`. */
115export const fill = (s, vars) => String(s || '').replace(/\{(\w+)\}/g, (m, k) => (vars[k] === undefined ? m : String(vars[k])))
116
117/** Local time as dd/mm hh:mm for a UTC ms and a zone offset in minutes. */
118export function when(ms, tzMin = 0) {
119 if (!ms) return '—'
120 const d = new Date(ms + tzMin * 60000)
121 const p = n => String(n).padStart(2, '0')
122 return `${p(d.getUTCDate())}/${p(d.getUTCMonth() + 1)} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}`
123}
124
125/** A window length: « 36 h » under two days, else « 2,5 j ». */
126export const duration = h => (h >= 48 ? `${fmtNum(h / 24, 1)} j` : `${fmtNum(h, 0)} h`)
127lib/menage/lib/classify.mjs 230 lines1// 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))
230types/index.d.ts 29 lines1// 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