Problèmes : registre persistant de chaque erreur et problème du travail de Claude, compris une fois et jamais redécouvert. Collecte sans tokens (questions à…

Now shown inside Registres (tab Problèmes, /registres problemes) and Cockpit's « Problèmes du jour » card; /problemes keeps working (this mod while loaded, else Registres' alias).
Julian's persistent register of every error and problem with Claude work: questions to Julian, end-of-turn waits, permission refusals, worktree and app guards, failed Workflows and CI, API errors, and the errors of the mods themselves (Santé and Veille). Problems are grouped by category and by application, deduped by signature, with symptom, cause, fix, evidence, timeline and notes.
Now also a tab of Registres: /registres problemes (or /problemes when this mod is not loaded) shows the same register in the Registres pane, drawn by a copy of hooks/view.mjs. This mod keeps working on its own while it is loaded.
/problemes [refresh [--force] | P-NNN | export] | close: the pane; P-012 opens that problem; export (and the « Exporter vers eta » button) sends the masked register to the eta repo by PR.node mods/problemes/scanner/problemes_scan.mjs collect | fold | import | note P-NNN "…" | set P-NNN status corrigé --fix "…" | export.~/.claude/problemes/: register.jsonl (append-only ledger), problems.json (snapshot the pane reads), cursor.json, and your overrides in problemes.config.json. The collector reads only new transcript bytes and the app's session files, with no tokens. It also reads:
~/.claude/mods-health/*.json (the shared health records, veille.json and sante.json included): a record whose last run failed becomes a hit, once per failed run; veille.json is source veille, the others source santé;~/.claude/sante-mod/report.json): plugin.json, hooks.json or config files that do not load, error lines in logs, errors listed in output files, once per message.docs/v6-research/veille-web-*.md (shipped source seed.builtin.veille-web, read from main by git in the first collect.roots folder that holds claude-control; run by import): each pitfall goes to Quota et limites (sous-agents, Projects, limites), Workflow › Succès rapporté à tort or App › Écarts de version, titled « non confirmé · … » when the doc says so; the same line in two weekly files is imported once. enabled: false or another git.repo in your override changes it.Health records and Santé's report are classified by the mod-error rule under Mods, one problem per mod and normalised message (collect.health, health_dir, sante_report in the config; MODS_HEALTH_DIR moves the folder).
lib/core.mjs: detection, classification, ledger fold, seeds, view helpers (pure).hooks/view.mjs: the pane's drawing, pure (element table in, tree out); also copied into mods/registres.hooks/register.tsx: reads the files, runs the scanner, hands the facts to the view.node --test mods/problemes/tests/*.test.mjs
claude plugin validate mods/problemes
claude plugin test mods/problemeshooks/register.tsx 162 lines1// problemes mod (« Problèmes »): Julian's persistent register of every error and problem with Claude work.
2// /problemes [refresh|close|P-NNN] opens the pane. Figures come from scanner/problemes_scan.mjs (Node, zero tokens):
3// it reads new transcript bytes and the desktop app's session files, dedupes hits into problems by signature and
4// appends to ~/.claude/problemes/register.jsonl; the pane reads the folded snapshot problems.json. The only write
5// from the pane is « Ajouter une note », which runs the scanner's `note` command (local ledger only). The command
6// shows a pane and returns no text (its drawing is hooks/view.mjs, pure, also drawn by the registres mod's tab), so nothing reaches the model; nothing here blocks, rewrites or delays anything.
7import { atom, read, update } from 'claude-code'
8import type { EngineInterface, Register } from 'claude-code'
9
10import { deepMerge, fill } from '../lib/core.mjs'
11import { drawProblemes } from './view.mjs'
12
13const PANE = 'problemes'
14const statusAtom = atom({ plugin: 'problemes', key: 'status' } as const, 'all')
15const weekAtom = atom({ plugin: 'problemes', key: 'week' } as const, 'all')
16const categoryAtom = atom({ plugin: 'problemes', key: 'category' } as const, 'all')
17const appAtom = atom({ plugin: 'problemes', key: 'app' } as const, 'all')
18const openAtom = atom({ plugin: 'problemes', key: 'open' } as const, [] as string[])
19const noteAtom = atom({ plugin: 'problemes', key: 'noteMsg' } as const, '')
20
21const S: { busy: boolean; error: string; snap: any; exporting: boolean; exportMsg: string } = { busy: false, error: '', snap: undefined, exporting: false, exportMsg: '' }
22
23const home = async ($: EngineInterface) => {
24 let v: string | undefined
25 try {
26 v = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME'))
27 } catch {}
28 return (v ?? '').replace(/\\/g, '/')
29}
30const readJson = async ($: EngineInterface, path: string): Promise<any> => {
31 try {
32 return JSON.parse((await $.fs.read(path, { as: 'text' })) as string)
33 } catch {
34 return undefined
35 }
36}
37
38async function context($: EngineInterface) {
39 const root = $.plugin.root.replace(/\\/g, '/')
40 const h = await home($)
41 const base = (await readJson($, `${root}/problemes.config.json`)) || {}
42 const dataDir = String(base.data_dir || '~/.claude/problemes').replace(/^~(?=[\\/]|$)/, h).replace(/\\/g, '/')
43 const cfg = deepMerge(base, (await readJson($, `${dataDir}/problemes.config.json`)) || {})
44 return { root, dataDir, cfg }
45}
46
47const scanner = (root: string, cfg: any, args: string[]) => [cfg.node || 'node', `${root}/scanner/problemes_scan.mjs`, ...args]
48
49/** Runs the collector in the background and redraws; never awaited by a hook the person waits on. */
50async function collect($: EngineInterface, force = false) {
51 if (S.busy) return
52 S.busy = true
53 $.ui.invalidate('ui.render')
54 try {
55 const { root, cfg } = await context($)
56 const r = await $.process.run(scanner(root, cfg, ['collect', ...(force ? ['--force'] : [])]), { timeoutMs: Math.min((cfg.collect_timeout_s ?? 300) * 1000, 600000) })
57 if (r.exitCode !== 0) throw new Error((r.stderr || '').trim().split('\n').pop() || `collect exit ${r.exitCode}`)
58 S.error = ''
59 S.snap = undefined
60 } catch (err) {
61 S.error = String((err as Error)?.message ?? err).slice(0, 300)
62 } finally {
63 S.busy = false
64 }
65 $.ui.invalidate('ui.render')
66}
67
68/** « Exporter vers eta » / `/problemes export`: the scanner's `export` (masked register → eta repo by PR), in the background. */
69async function exportEta($: EngineInterface) {
70 if (S.exporting) return
71 S.exporting = true
72 S.exportMsg = ''
73 $.ui.invalidate('ui.render')
74 try {
75 const { root, cfg } = await context($)
76 const r = await $.process.run(scanner(root, cfg, ['export']), { timeoutMs: 600000 })
77 S.exportMsg = ((r.exitCode === 0 ? r.stdout : r.stderr || r.stdout) || '').trim().split('\n').pop()?.replace(/^problemes: /, '').slice(0, 300) || `exit ${r.exitCode}`
78 } catch (err) {
79 S.exportMsg = String((err as Error)?.message ?? err).slice(0, 300)
80 } finally {
81 S.exporting = false
82 }
83 $.ui.invalidate('ui.render')
84}
85
86async function load($: EngineInterface) {
87 const { cfg, dataDir, root } = await context($)
88 const snap = S.snap ?? (S.snap = await readJson($, `${dataDir}/problems.json`))
89 const now = await $.clock.now()
90 const stale = !snap || now - (snap.collected || 0) > (cfg.refresh_min ?? 30) * 60000
91 return { cfg, snap, stale, now, root }
92}
93
94export const register: Register = on => {
95 on('session.start', async ($, e, next) => {
96 await $.command.register({
97 name: 'problemes',
98 description: 'Problèmes : registre des erreurs et blocages du travail de Claude (questions à Julian, gardes, CI, Workflows), par catégorie et par application',
99 argumentHint: '[refresh | P-NNN | export] | close',
100 })
101 return next(e)
102 })
103
104 on('command.run', { command: 'problemes' }, async ($, e) => {
105 const args = String(e.args ?? '').trim()
106 if (args.toLowerCase() === 'close') {
107 await $.ui.close({ id: PANE })
108 return {}
109 }
110 const { cfg, stale, snap } = await load($)
111 if (args.toLowerCase() === 'export') $.clock.after(0, () => exportEta($))
112 const refresh = /\brefresh\b/i.test(args)
113 const id = args.match(/\bP-\d+\b/i)?.[0]?.toUpperCase()
114 if (id) {
115 const p = (snap?.problems ?? []).find((x: any) => x.id === id)
116 const nodes = p ? [`c:${p.category.split('/')[0]}`, `c:${p.category}`, `p:${id}`] : [`p:${id}`]
117 await update($, openAtom, (o: string[]) => [...new Set([...(o ?? []), ...nodes])])
118 }
119 if (stale || refresh) $.clock.after(0, () => collect($, refresh && /--force/.test(args)))
120 await $.ui.open({ id: PANE, title: cfg.labels?.pane_title ?? 'Problèmes' })
121 return {} // a pane, no transcript text: nothing for the model to read
122 })
123
124 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
125 const { cfg, snap, now, root } = await load($)
126 const L = cfg.labels ?? {}
127 const f = {
128 status: (await read($, statusAtom)) as string,
129 week: (await read($, weekAtom)) as string,
130 category: (await read($, categoryAtom)) as string,
131 app: (await read($, appAtom)) as string,
132 }
133 const open = ((await read($, openAtom)) as string[]) ?? []
134 const noteMsg = (await read($, noteAtom)) as string
135
136 const note = async (id: string, text: string) => {
137 const v = String(text ?? '').trim()
138 if (!v) return
139 try {
140 const r = await $.process.run(scanner(root, cfg, ['note', id, v]), { timeoutMs: 30000 })
141 if (r.exitCode !== 0) throw new Error((r.stderr || '').trim().split('\n').pop() || `exit ${r.exitCode}`)
142 S.snap = undefined
143 await update($, noteAtom, () => fill(L.note_saved, { id }))
144 } catch (err) {
145 await update($, noteAtom, () => fill(L.note_failed, { error: String((err as Error)?.message ?? err).slice(0, 200) }))
146 }
147 }
148
149 return drawProblemes($.ui.resolve(e), { cfg, snap, now, f, open, noteMsg, busy: S.busy, error: S.error, exporting: S.exporting, exportMsg: S.exportMsg }, {
150 flip: (id: string) => update($, openAtom, (o: string[]) => (o?.includes(id) ? o.filter(x => x !== id) : [...(o ?? []), id])),
151 setFilter: (name: string, v: string) =>
152 name === 'status' ? update($, statusAtom, () => v)
153 : name === 'week' ? update($, weekAtom, () => v)
154 : name === 'category' ? update($, categoryAtom, () => v)
155 : update($, appAtom, () => v),
156 refresh: () => { $.clock.after(0, () => collect($)) },
157 exportEta: () => { $.clock.after(0, () => exportEta($)) },
158 note,
159 }) as any
160 })
161}
162lib/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}
674hooks/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}
126types/index.d.ts 23 lines1// Types of the problemes mod: the `$.state` values the pane reads.
2
3declare module 'claude-code' {
4 interface PluginState {
5 problemes: {
6 /** status filter: all | ouvert | corrigé | contourné | accepté */
7 status: string
8 /** period filter: all | this (this week) | 30d */
9 week: string
10 /** category filter: all | a category id */
11 category: string
12 /** application filter: all | an app name */
13 app: string
14 /** ids of the open tree nodes and problems */
15 open: string[]
16 /** last note result shown under the problem */
17 noteMsg: string
18 }
19 }
20}
21
22export type Status = string
23