Référence : un seul panneau en lecture seule, onglets Config (toute la config de Claude Code, secrets masqués), Guide (mode d'emploi des mods), Scénarios…

One of the 6 entries of the band's Mods bar (Jauge, Cockpit, Veille, À toi, Registres, Référence).
One side pane with eight tabs: Config | Guide | Scénarios | Santé | CI | À essayer | R&D | Repos. It replaces five separate panes in the Mods bar and adds the research, audit and repo views. Nothing here blocks or rewrites a tool call or a prompt, and nothing calls a model; the buttons act only when Julian clicks them.
| Command | What it does | |||||
|---|---|---|---|---|---|---|
/reference | Opens the pane on the last tab (first time: start_tab, default Config). | |||||
/reference config [scan] | Config: everything in Claude Code, read-only, with search (control's /config-claude view). scan reruns control's scanner. | |||||
/reference guide [section] | Guide: the mods' user guide (MODE-EMPLOI.md) with Sommaire and live « Nos mods » cards, opened on a tab or section by name. | |||||
| `/reference scenarios [S1..] [prompt\ | agent\ | workflow\ | frontmatter\ | env]` | Scénarios: per-role model, effort, unit and max parallel, ready-to-paste snippets, and Appliquer ce scénario. | |
/reference sante [refresh] | Santé: every mod loaded or not, versions, commands, caches, errors, scan CPU. | |||||
| `/reference ci [diagramme [refresh] [claude\ | codex\ | inconnu\ | tout] [hote\ | app\ | conv]]` | CI: Routage and Diagramme (pipelines, Gantt, ETA, Claude/Codex split). |
/reference essais [refresh] | À essayer: every feature or idea of the research documents (one per table row), by state, with the test proposed, cost, risk and interest; « Ce qu'on doit tester » underneath. | |||||
| `/reference rd [refresh\ | audit]` | R&D: index of the research folders (date, subject, 3-line summary, sources, idea states, Ouvrir), with Exporter vers eta and Exporter pour audit. audit runs the audit export at once. | ||||
/reference repos [refresh] | Repos: « Mes repos » cards (branch, behind/ahead, last commit, open PRs) with Mettre à jour, Exporter, Ouvrir sur GitHub, Dossier. refresh asks gh again. | |||||
/audit-export | Exporter pour audit (registered only when no loaded mod has that name): writes the dated zip and prints its path and counts. | |||||
/reference close | Closes the pane. |
Old commands keep working. /config-claude, /control, /mods-guide, /scenarios, /sante and /ci are registered by this mod only when no loaded mod already has them (checked with $.command.list() after every other mod's session.start). While control, guide, scenarios, sante or ci are still loaded, their own command answers; once they are unloaded, the same command opens the matching Référence tab with the same words (/ci diagramme codex, /mods-guide claude plugin, /scenarios S2 env). /control opens the Config tab (SETUP, USAGE, PRACTICES and HISTORY stay in the control mod). /ci route and /ci clear need the ci mod loaded.
Each tab draws with a byte copy of its owner's pure view code and reads the owner's caches; nothing is drawn twice in two styles.
| Tab | View code (synced into lib/<mod>/) | Data |
|---|---|---|
| Config | control/hooks/config_draw.tsx, config_view.ts, view.ts, config.json | ~/.claude/effort-board/control/config-claude.json and its content shards, written by control's scanner/config_scan.py (secrets masked there, before anything is written). Rescan runs that scanner when the control mod sits beside this one. |
| Guide | guide/hooks/draw.tsx, cards.ts, toc.ts, MODE-EMPLOI.md, mods.json | The copy of the guide; « Nos mods » cards rebuilt live from the mods beside this one. |
| Scénarios | scenarios/hooks/draw.tsx, logic.ts, apply.ts, apply_io.ts, scenarios.json | The shipped scenarios plus paths.scenarios_file. |
| Santé | sante/hooks/draw.tsx, logic.mjs, scanner/sante_scan.mjs (with its lib/core copies), sante.config.json | ~/.claude/sante-mod/report.json; ↻ (or a report older than sante_refresh_min) runs the copied scanner, which also writes ~/.claude/mods-health/sante.json with its findings. |
| CI | ci/hooks/draw.tsx, diagram.ts, view.ts, core gantt.mjs and estimate.mjs | ci's scanner/ci.py show and pipelines (cache only; Refresh / Rafraîchir check like the ci pane) when the ci mod sits beside this one; else the Diagramme reads ~/.claude/ci-pipelines-cache.json alone. |
The copies are listed in lib/core/targets.json (files) and checked by node scripts/sync-libs.mjs --check in CI. Never edit a copy: change the owner's file, run node scripts/sync-libs.mjs, commit both.
The hooks module cannot read folders or run git, so these tabs run scanner/rd_scan.mjs (Node) with the merged config on stdin and draw its JSON answer (pure parts in lib/rd/, drawing in hooks/rd_draw.tsx).
| Tab / button | Reads | Writes (on click only) |
|---|---|---|
| À essayer | Markdown tables of essais.sources (default: claude-control's docs/v6-research, files features-a-essayer, jev-et-galerie, veille-web-*, data-goblin-idees). A table counts when it has a name column and a test, interest or « on l'utilise ? » column (header names in essais.columns). Default state from essais.state_rules (« oui » → adopté, « partiel » → en test, « non par choix » / « ne pas tester » → écarté, else jamais), then presets, then Julian's choice. Cost and risk: the column, else the words matching essais.infer. essais.extra adds items no table has (shipped: JEV, écarté, /advisor instead). « Ce qu'on doit tester »: open lines of manual-checks.md and tests-reels procedures never run / open manual checks. | The state button cycles jamais → en test → adopté → écarté and writes ~/.claude/reference-mod/essais.json. |
| R&D | rd.dirs (claude-control docs/v6-research, docs/audits, ~/.claude/effort-board/audits): date (file name, title, else mtime), subject (first heading or <title>), summary_lines lines, links, and the À essayer states of the ideas from that document. | Ouvrir opens the file (open_command, default explorer / xdg-open). Exporter vers eta: the documents, masked, into the eta repo under rd.eta.dest_dir with an INDEX.md, by PR, with export-eta's git flow (lib/eta/, synced copy) and its eta.repo from ~/.claude/export-eta/config.json. |
| Repos | repos (claude-control, claude-mod, datapass-claude-eta by default; the folders in repo_paths, kept in ~/.claude/reference-mod/reference.config.json, @self = this repo): git branch, rev-list HEAD...@{u} (no fetch), log -1, status, and gh pr list cached repos_prs_ttl_min minutes. | Mettre à jour: git pull --ff-only only on the main checkout, on its main branch and clean, then post_update in the repo (claude-control: python tools/deploy.py). Exporter: export: audit the audit zip, eta the R&D export. Ouvrir sur GitHub is a link, Dossier opens the folder. |
Exporter pour audit (R&D, Repos, /audit-export) | Every mod beside this one (tracked files, MANIFEST.md with version, commit and loaded state), ~/.claude/*.config.json and the config.json / *.config.json of audit.config_dirs, CLAUDE_CODE_PLUGIN_DIRS, settings env (secret-looking keys replaced), CLAUDE.md, agents, skills, workflows, RULES-INVENTORY.md, Veille state.json / config.json / heartbeat.json. | ~/.claude/audit-exports/audit-<date>-<time>.zip. Every file is masked with export-eta's rules then checked again: a secret left holds the file back (named in the manifest). Never smtp.json, .credentials.json, token files, transcripts (*.jsonl, projects/) or raw data folders; audit.deny adds patterns. |
The button under each scenario writes the chosen per-role model and effort, plus the env defaults it implies, to ~/.claude/scenarios/applied.json (the previous one is kept as applied.prev.json). The tab then shows, role by role, the current value next to the applied one: the lead against ~/.claude/settings.json (model, effortLevel), each subagent or Workflow role against the frontmatter of ~/.claude/agents/<agent>.md, and CLAUDE_CODE_SUBAGENT_MODEL / CLAUDE_CODE_WORKFLOW_SUBAGENT_MODEL against settings env. It never edits settings.json, an agent file or the environment: the record is a note, the diff says what to change by hand.
reference.config.json: tab order (tabs), start_tab, python / node, paths (claude_dir, control_out_dir, sante_data_dir, scenarios_file, ci_pipelines_cache, reference_dir), sante_refresh_min, repos / repo_paths / repos_prs_ttl_min, open_command, essais, rd, audit, and every label (pane title, tab names, states, sections, buttons). ~/.claude/reference-mod/reference.config.json is deep-merged on top, and userConfig config names another override file.
Add <repo>/mods/reference to CLAUDE_CODE_PLUGIN_DIRS (or claude --plugin-dir mods/reference). The owner mods (control, guide, scenarios, sante, ci) can stay loaded until they are removed: their commands then answer, and this pane shows the same views.
node scripts/sync-libs.mjs --check
node --test mods/reference/tests/*.test.mjs # nav + rd (À essayer, R&D, Repos, audit zip on an invented home)
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test mods/reference
claude plugin validate mods/reference
All test data is invented.
hooks/register.tsx 646 lines1// reference mod (« Référence »): one side pane with tabs Config | Guide | Scénarios | Santé | CI | À essayer | R&D | Repos.
2// /reference [config|guide|scenarios|sante|ci [diagramme]|essais|rd [audit]|repos|close] /audit-export
3// À essayer, R&D and Repos read what scanner/rd_scan.mjs (Node) finds in the research folders, the manual checks, the
4// tests-reels registry and the repos (git, gh); their buttons act only on Julian's click: an item's state (written to
5// ~/.claude/reference-mod/essais.json), Exporter vers eta (research docs by PR, export-eta's flow), Exporter pour audit
6// (a masked zip under ~/.claude/audit-exports), Mettre à jour (git pull --ff-only on a clean main checkout).
7// Each tab draws with a synced copy of its owner's pure view code (lib/<mod>/hooks/*, made by scripts/sync-libs.mjs
8// from mods/control, guide, scenarios, sante, ci) and reads the owner's caches: control's config-claude.json (secrets
9// masked by its scanner), MODE-EMPLOI.md (copy), scenarios.json (copy), sante's report.json, ci's caches through ci.py.
10// The old commands (/config-claude, /control, /mods-guide, /scenarios, /sante, /ci) are registered here only when no
11// loaded mod already has them, so Julian's habits keep working once the owners are unloaded.
12// Read-only: no tool call or prompt is touched and no model is called. Writes: « Appliquer ce scénario »
13// (~/.claude/scenarios/applied.json and applied.prev.json only), and what the owners' own scanners write when a
14// Rescan / Rafraîchir / ↻ button runs them (their caches and health records) or ci's Clear button (its routing file).
15import type { EngineInterface, Register } from 'claude-code'
16
17import { drawCi } from '../lib/ci/hooks/draw'
18import type { CiTab } from '../lib/ci/hooks/draw'
19import { parseDiagram, parseDiagramWords } from '../lib/ci/hooks/diagram'
20import { parseSnapshot } from '../lib/ci/hooks/view'
21import { drawConfig, shardsNeeded } from '../lib/control/hooks/config_draw'
22import { toggle } from '../lib/control/hooks/config_view'
23import { SOMMAIRE, drawGuide, findIn, liveDoc } from '../lib/guide/hooks/draw'
24import type { Where } from '../lib/guide/hooks/draw'
25import { drawSante } from '../lib/sante/hooks/draw'
26import { splitDirs } from '../lib/sante/hooks/logic.mjs'
27import { applyScenario, readApplied } from '../lib/scenarios/hooks/apply_io'
28import { drawScenarios } from '../lib/scenarios/hooks/draw'
29import { KINDS, check, merge, parseArgs as scenarioArgs } from '../lib/scenarios/hooks/logic'
30import type { Kind } from '../lib/scenarios/hooks/logic'
31import { STATES, nextState, overlay, setState } from '../lib/rd/essais.mjs'
32import { ALIASES, ACTION_COMMANDS, aliasArgs, deepMerge, expandHome, missingAliases, parseArgs, tabsOf } from './nav'
33import { drawEssais, drawRd, drawRepos } from './rd_draw'
34import type { Parsed, Tab } from './nav'
35
36const PANE = 'reference'
37type Options = { config?: string }
38let options: Options = {}
39let projectDir = ''
40
41// What the pane shows, per tab. Module state: a reload starts on the default tab again.
42const R = {
43 tab: '' as Tab | '',
44 guide: { tab: SOMMAIRE, cat: '', sub: '' } as Where,
45 scen: { selected: '', kind: 'prompt' as Kind, copied: '', applyMsg: '' },
46 cfg: { open: [] as string[], current: '', query: '', raw: false, page: 0, busy: false, problem: '' },
47 sante: { report: undefined as any, error: '', busy: false, copied: '', commands: [] as any[], engine: '' },
48 ci: { tab: 'routing' as CiTab, snapshot: '', diagram: '', error: '', checking: false, fetching: false, who: 'all' as any, group: 'host' as any, folded: [] as string[] },
49 essais: { data: undefined as any, at: 0, error: '', open: ['en test', 'jamais'] as string[] },
50 rd: { data: undefined as any, at: 0, error: '' },
51 repos: { data: undefined as any, at: 0, error: '' },
52 busy: '',
53 msg: '',
54}
55const redraw = ($: EngineInterface) => $.ui.invalidate('ui.render')
56
57// ---- files -------------------------------------------------------------------------------------------------------
58
59const text = async ($: EngineInterface, path: string): Promise<string | undefined> => {
60 try {
61 return (await $.fs.read(path, { as: 'text' })) as string
62 } catch {
63 return undefined
64 }
65}
66const readJson = async ($: EngineInterface, path: string): Promise<any> => {
67 const t = await text($, path)
68 try {
69 return t ? JSON.parse(t.replace(/^/, '')) : undefined
70 } catch {
71 return undefined
72 }
73}
74const exists = async ($: EngineInterface, path: string) => {
75 try {
76 return await $.fs.exists(path)
77 } catch {
78 return false
79 }
80}
81const io = ($: EngineInterface) => ({ read: (p: string) => text($, p), write: (p: string, t: string) => $.fs.write(p, t) })
82const root = ($: EngineInterface) => $.plugin.root.replace(/\\/g, '/').replace(/\/+$/, '')
83const modsDir = ($: EngineInterface) => root($).replace(/\/[^/]+$/, '')
84const home = async ($: EngineInterface) => {
85 let v: string | undefined
86 try {
87 v = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME'))
88 } catch {}
89 return (v ?? '').replace(/\\/g, '/')
90}
91
92async function context($: EngineInterface) {
93 const hd = await home($)
94 const base = (await readJson($, `${root($)}/reference.config.json`)) || {}
95 let cfg = deepMerge(base, (await readJson($, `${hd}/.claude/reference-mod/reference.config.json`)) || {})
96 if (options.config?.trim()) cfg = deepMerge(cfg, (await readJson($, expandHome(options.config.trim(), hd))) || {})
97 const p = cfg.paths || {}
98 const path = (k: string, d: string) => expandHome(p[k] || d, hd)
99 return {
100 cfg, home: hd, L: (cfg.labels || {}) as Record<string, string>,
101 claudeDir: path('claude_dir', '~/.claude'),
102 controlOut: path('control_out_dir', '~/.claude/effort-board/control'),
103 santeDir: path('sante_data_dir', '~/.claude/sante-mod'),
104 ciCache: path('ci_pipelines_cache', '~/.claude/ci-pipelines-cache.json'),
105 scenariosFile: p.scenarios_file ? path('scenarios_file', '') : '',
106 refDir: path('reference_dir', '~/.claude/reference-mod'),
107 }
108}
109type Ctx = Awaited<ReturnType<typeof context>>
110
111// ---- Config (control's scanner output) -----------------------------------------------------------------------------
112
113let shardCache: { generated: string; shards: Map<number, Record<string, string>> } = { generated: '', shards: new Map() }
114async function shard($: EngineInterface, out: string, data: any, n: number): Promise<Record<string, string>> {
115 if (shardCache.generated !== data.generated) shardCache = { generated: data.generated, shards: new Map() }
116 const hit = shardCache.shards.get(n)
117 if (hit) return hit
118 const got = ((await readJson($, `${out}/${data.shards[n]}`)) ?? {}) as Record<string, string>
119 shardCache.shards.set(n, got)
120 return got
121}
122
123async function configScan($: EngineInterface) {
124 if (R.cfg.busy) return
125 const scanner = `${modsDir($)}/control/scanner/control_scan.py`
126 if (!(await exists($, scanner))) {
127 R.cfg.problem = 'mod control absent : /config-claude scan dans une session où il est chargé'
128 return redraw($)
129 }
130 R.cfg.busy = true
131 redraw($)
132 try {
133 const { cfg } = await context($)
134 const r = await $.process.run([cfg.python || 'python', '-I', scanner, 'config', '--config', `${modsDir($)}/control/config.json`, ...(projectDir ? ['--project', projectDir] : [])], { timeoutMs: 600000 })
135 R.cfg.problem = r.exitCode === 0 ? '' : ((r.stderr || '').trim().split('\n').pop() || `exit ${r.exitCode}`).slice(0, 200)
136 } catch (err) {
137 R.cfg.problem = String(err).slice(0, 200)
138 }
139 R.cfg.busy = false
140 redraw($)
141}
142
143async function drawConfigTab($: EngineInterface, ui: any, c: Ctx) {
144 const ccfg = (await readJson($, `${root($)}/lib/control/config.json`)) || {}
145 const user = ccfg.paths?.user_config ? await readJson($, expandHome(ccfg.paths.user_config, c.home)) : undefined
146 const cc = deepMerge(ccfg, user || {}).config_claude || {}
147 const out = user?.paths?.out_dir ? expandHome(user.paths.out_dir, c.home) : c.controlOut
148 const data = await readJson($, `${out}/config-claude.json`)
149 const contents: Record<string, string> = {}
150 if (data) for (const n of shardsNeeded(data, R.cfg.current, R.cfg.query)) Object.assign(contents, await shard($, out, data, n))
151 return drawConfig(ui, {
152 data, out, labels: cc.labels, viewerChars: cc.viewer_chars, contents, ...R.cfg, now: Date.now(),
153 onRescan: () => { $.clock.after(0, () => configScan($)) },
154 onFlip: id => { R.cfg.open = toggle(R.cfg.open, id); redraw($) },
155 onPick: id => { R.cfg.current = R.cfg.current === id ? '' : id; R.cfg.page = 0; redraw($) },
156 onRaw: () => { R.cfg.raw = !R.cfg.raw; redraw($) },
157 onPage: d => { R.cfg.page = Math.max(0, R.cfg.page + d); redraw($) },
158 onQuery: v => { R.cfg.query = v; redraw($) },
159 })
160}
161
162// ---- Guide (MODE-EMPLOI.md copy, live cards from the mods beside this one) -----------------------------------------
163
164const guideDoc = ($: EngineInterface) =>
165 liveDoc(p => text($, p), `${root($)}/lib/guide/MODE-EMPLOI.md`, `${root($)}/lib/guide/mods.json`, modsDir($), "Mode d'emploi des mods")
166
167// ---- Scénarios (scenarios.json copy + the user's file) -------------------------------------------------------------
168
169async function scenarios($: EngineInterface, c: Ctx) {
170 const shipped = await readJson($, `${root($)}/lib/scenarios/scenarios.json`)
171 const problems: string[] = shipped ? [] : ['cannot read lib/scenarios/scenarios.json']
172 let extra
173 if (c.scenariosFile) {
174 extra = await readJson($, c.scenariosFile)
175 if (!extra) problems.push(`${c.scenariosFile}: missing or not valid JSON`)
176 }
177 const checked = check(merge(shipped, extra))
178 return { ...checked, errors: [...problems, ...checked.errors] }
179}
180
181// ---- Santé (sante's report and scanner, copied in lib/sante) -------------------------------------------------------
182
183async function santeCfg($: EngineInterface, c: Ctx) {
184 const base = (await readJson($, `${root($)}/lib/sante/sante.config.json`)) || {}
185 return deepMerge(base, (await readJson($, `${c.santeDir}/sante.config.json`)) || {})
186}
187
188async function santeFacts($: EngineInterface) {
189 try {
190 R.sante.commands = (await $.command.list()).map(x => ({ name: x.name, source: x.source, ...(x.plugin ? { plugin: x.plugin } : {}) }))
191 } catch {}
192 try {
193 R.sante.engine = (await $.session.version()).version
194 } catch {}
195}
196
197async function santeScan($: EngineInterface) {
198 if (R.sante.busy) return
199 R.sante.busy = true
200 redraw($)
201 try {
202 await santeFacts($)
203 const c = await context($)
204 const cfg = await santeCfg($, c)
205 const dirs = String((await $.env.get('CLAUDE_CODE_PLUGIN_DIRS')) ?? '')
206 const r = await $.process.run([c.cfg.node || cfg.node || 'node', `${root($)}/lib/sante/scanner/sante_scan.mjs`], {
207 stdin: JSON.stringify({
208 dirs: dirs.trim() ? (dirs.includes(';') ? dirs.split(';') : /^[A-Za-z]:[\\/]/.test(dirs.trim()) ? [dirs] : dirs.split(':')).map(x => x.trim().replace(/^"|"$/g, '')).filter(Boolean) : [],
209 home: c.home, cfg, now: await $.clock.now(), siblings: cfg.siblings === false ? [] : [modsDir($)],
210 commands: R.sante.commands, engine: R.sante.engine,
211 }),
212 timeoutMs: Math.min((cfg.scan_timeout_s ?? 60) * 1000, 600000),
213 })
214 const out = JSON.parse(r.stdout || '{}')
215 if (r.exitCode !== 0 || out.error) throw new Error(out.error || (r.stderr || '').trim().split('\n').pop() || `scanner exit ${r.exitCode}`)
216 R.sante.report = out
217 R.sante.error = ''
218 } catch (err) {
219 R.sante.error = String((err as Error)?.message ?? err).slice(0, 300)
220 } finally {
221 R.sante.busy = false
222 }
223 redraw($)
224}
225
226async function santeLoad($: EngineInterface, c: Ctx) {
227 if (!R.sante.report) R.sante.report = await readJson($, `${c.santeDir}/report.json`)
228 const now = await $.clock.now()
229 const stale = !R.sante.report || now - (R.sante.report.generated || 0) > (c.cfg.sante_refresh_min ?? 10) * 60000
230 return { now, stale }
231}
232
233// ---- CI (ci.py beside this mod, else the pipelines cache) -----------------------------------------------------------
234
235async function ciRun($: EngineInterface, args: string[], timeoutMs: number): Promise<{ ok: true; text: string } | { ok: false; error: string }> {
236 const script = `${modsDir($)}/ci/scanner/ci.py`
237 if (!(await exists($, script))) return { ok: false, error: 'mod ci absent' }
238 try {
239 const { cfg } = await context($)
240 const r = await $.process.run([cfg.python || 'python', '-I', script, ...args], { timeoutMs })
241 let json: any
242 try {
243 json = JSON.parse(r.stdout)
244 } catch {}
245 if (r.exitCode !== 0 || !json) return { ok: false, error: json?.error ?? ((r.stderr || '').trim().split('\n').pop() || `exit ${r.exitCode}`) }
246 return { ok: true, text: r.stdout }
247 } catch (err) {
248 return { ok: false, error: String(err) }
249 }
250}
251
252async function ciRouting($: EngineInterface, mode: 'cache' | 'check' | 'force') {
253 let id = ''
254 try {
255 id = await $.session.id()
256 } catch {}
257 if (mode !== 'cache') { R.ci.checking = true; redraw($) }
258 const r = await ciRun($, ['show', '--cwd', projectDir, ...(id ? ['--session', id] : []), ...(mode === 'check' ? ['--check'] : mode === 'force' ? ['--refresh'] : [])], mode === 'cache' ? 20000 : 120000)
259 R.ci.checking = false
260 if (r.ok) { R.ci.snapshot = r.text; R.ci.error = '' } else R.ci.error = r.error
261 redraw($)
262}
263
264async function ciDiagram($: EngineInterface, mode: 'cache' | 'check' | 'force') {
265 if (mode !== 'cache') { R.ci.fetching = true; redraw($) }
266 const r = await ciRun($, ['pipelines', ...(mode === 'check' ? ['--check'] : mode === 'force' ? ['--refresh'] : [])], mode === 'cache' ? 20000 : 300000)
267 R.ci.fetching = false
268 if (r.ok) { R.ci.diagram = r.text; R.ci.error = '' } else {
269 const c = await context($)
270 const cache = await readJson($, c.ciCache)
271 if (cache && Array.isArray(cache.apps)) {
272 const { history: _h, ...rest } = cache
273 R.ci.diagram = JSON.stringify(rest)
274 }
275 R.ci.error = mode === 'cache' && r.error === 'mod ci absent' ? '' : r.error
276 }
277 redraw($)
278}
279
280// ---- À essayer, R&D, Repos (scanner/rd_scan.mjs) ------------------------------------------------------------------
281
282async function pluginDirs($: EngineInterface): Promise<string[]> {
283 let dirs = ''
284 try {
285 dirs = String((await $.env.get('CLAUDE_CODE_PLUGIN_DIRS')) ?? '').trim()
286 } catch {}
287 if (!dirs) return []
288 const list = dirs.includes(';') ? dirs.split(';') : /^[A-Za-z]:[\\/]/.test(dirs) ? [dirs] : dirs.split(':')
289 return list.map(x => x.trim().replace(/^"|"$/g, '')).filter(Boolean)
290}
291
292/** Runs the scanner with the merged config on stdin; its JSON answer, or { ok: false, error }. */
293async function rdRun($: EngineInterface, args: string[], timeoutMs = 60000): Promise<any> {
294 try {
295 const c = await context($)
296 const r = await $.process.run([c.cfg.node || 'node', `${root($)}/scanner/rd_scan.mjs`, ...args], {
297 stdin: JSON.stringify({ cfg: c.cfg, home: c.home, modsDir: modsDir($), pluginDirs: await pluginDirs($), now: await $.clock.now() }),
298 timeoutMs,
299 })
300 let out: any
301 try {
302 out = JSON.parse(r.stdout || '')
303 } catch {}
304 if (!out) return { ok: false, error: ((r.stderr || '').trim().split('\n').pop() || `exit ${r.exitCode}`).slice(0, 300) }
305 return out
306 } catch (err) {
307 return { ok: false, error: String((err as Error)?.message ?? err).slice(0, 300) }
308 }
309}
310
311const STALE_MS = 5 * 60000
312async function load($: EngineInterface, which: 'essais' | 'rd' | 'repos', force = false, extra: string[] = []) {
313 const slot = R[which]
314 const now = await $.clock.now()
315 if (!force && slot.data && now - slot.at < STALE_MS) return
316 const out = await rdRun($, [which, ...extra], which === 'repos' ? 180000 : 60000)
317 if (out && out.ok === false) slot.error = out.error
318 else {
319 slot.data = out
320 slot.error = ''
321 slot.at = now
322 }
323 redraw($)
324}
325
326/** One click at a time: busy label, the scanner's one-line answer in R.msg, then a redraw. */
327async function act($: EngineInterface, label: string, run: () => Promise<string>) {
328 if (R.busy) return
329 R.busy = label
330 R.msg = ''
331 redraw($)
332 try {
333 R.msg = await run()
334 } catch (err) {
335 R.msg = String((err as Error)?.message ?? err).slice(0, 300)
336 } finally {
337 R.busy = ''
338 redraw($)
339 }
340}
341
342const auditText = (r: any) =>
343 r && r.ok !== false
344 ? `export pour audit : ${r.file} (${r.files} fichiers, ${r.masked} masqués, ${r.held} retenus, ${r.mods} mods)`
345 : `export pour audit impossible : ${r?.error ?? '?'}`
346const auditExport = ($: EngineInterface) => rdRun($, ['audit-export'], 300000).then(auditText)
347const etaExport = ($: EngineInterface) =>
348 rdRun($, ['export-rd'], 600000).then(r =>
349 r && r.ok !== false
350 ? `recherches vers eta : ${r.files} fichier(s) · ${r.url ?? 'pas de PR'}${r.merged ? ' · mergée' : ''}`
351 : `export vers eta : ${r?.error ?? '?'}${r?.url ? ` · ${r.url}` : ''}`,
352 )
353
354async function openPath($: EngineInterface, p: string) {
355 const c = await context($)
356 let argv: string[] = Array.isArray(c.cfg.open_command) && c.cfg.open_command.length ? c.cfg.open_command : []
357 if (!argv.length) {
358 let os = ''
359 try {
360 os = String((await $.env.get('OS')) ?? '')
361 } catch {}
362 argv = /windows/i.test(os) ? ['explorer.exe'] : ['xdg-open']
363 }
364 const target = /explorer/i.test(argv[0] ?? '') ? p.split('/').join('\\') : p
365 try {
366 await $.process.run([...argv, target], { timeoutMs: 15000 })
367 R.msg = `ouvert : ${p}`
368 } catch (err) {
369 R.msg = `ouverture impossible : ${String(err).slice(0, 160)}`
370 }
371 redraw($)
372}
373
374async function essaisStates($: EngineInterface, c: Ctx) {
375 const f = expandHome(c.cfg.essais?.state_file || `${c.refDir}/essais.json`, c.home)
376 return { file: f, data: ((await readJson($, f)) || { v: 1, items: {} }) as any }
377}
378
379async function essaisItems($: EngineInterface, c: Ctx) {
380 const st = await essaisStates($, c)
381 const E = c.cfg.essais || {}
382 return overlay(R.essais.data?.items || [], { presets: E.presets, extra: E.extra, states: st.data.items })
383}
384
385// ---- open ----------------------------------------------------------------------------------------------------------
386
387/** Opens the pane on a tab with that tab's own arguments (the old commands' words). Returns the transcript line. */
388async function openOn($: EngineInterface, p: Parsed, origin: string): Promise<{ text?: string }> {
389 if (p.close) {
390 await $.ui.close({ id: PANE })
391 return {}
392 }
393 const c = await context($)
394 const tabs = tabsOf(c.cfg.tabs)
395 const tab: Tab = p.tab && tabs.includes(p.tab) ? p.tab : (R.tab || (tabsOf([c.cfg.start_tab])[0] ?? tabs[0]!))
396 R.tab = tab
397 const words = p.rest.map(w => w.toLowerCase())
398 let line = ''
399 if (tab === 'config') {
400 const data = await readJson($, `${c.controlOut}/config-claude.json`)
401 if (words.includes('scan') || !data) $.clock.after(0, () => configScan($))
402 } else if (tab === 'guide') {
403 if (p.rest.length) {
404 const { doc } = await guideDoc($)
405 const to = findIn(doc, p.rest.join(' '))
406 if (to) R.guide = to
407 else line = `pas de section « ${p.rest.join(' ')} ». Onglets : ${doc.tabs.map(t => t.title).join(', ')}`
408 }
409 } else if (tab === 'scenarios') {
410 const { catalog } = await scenarios($, c)
411 const a = scenarioArgs(p.rest.join(' '), catalog.scenarios.map(s => s.id))
412 if (a.id && catalog.scenarios.some(s => s.id === a.id)) R.scen.selected = a.id
413 else if (a.id) line = `no scenario "${a.id}" (known: ${catalog.scenarios.map(s => s.id).join(', ')})`
414 if (a.kind && (KINDS as readonly string[]).includes(a.kind)) R.scen.kind = a.kind
415 } else if (tab === 'sante') {
416 await santeFacts($)
417 const { stale } = await santeLoad($, c)
418 if (stale || words.includes('refresh')) $.clock.after(0, () => santeScan($))
419 } else if (tab === 'ci') {
420 const diag = ['diagramme', 'diagram', 'diag', 'pipelines'].includes(words[0] ?? '')
421 if (diag) {
422 R.ci.tab = 'diagram'
423 const w = parseDiagramWords(p.rest.slice(1))
424 if (w.who) R.ci.who = w.who
425 if (w.group) R.ci.group = w.group
426 await ciDiagram($, 'cache')
427 const d = parseDiagram(R.ci.diagram)
428 if (w.refresh || !d || d.stale) $.clock.after(0, () => ciDiagram($, w.refresh ? 'force' : 'check'))
429 } else if (words[0] === 'route' || words[0] === 'clear' || words[0] === 'set' || words[0] === 'unset') {
430 line = 'route / clear : /ci dans une session où le mod ci est chargé (Référence ne fait que lire)'
431 } else {
432 R.ci.tab = 'routing'
433 await ciRouting($, 'cache')
434 if (words.includes('refresh') || words.includes('check')) $.clock.after(0, () => ciRouting($, 'force'))
435 }
436 } else if (tab === 'essais') {
437 $.clock.after(0, () => load($, 'essais', words.includes('refresh')))
438 } else if (tab === 'rd') {
439 $.clock.after(0, () => load($, 'rd', words.includes('refresh')))
440 $.clock.after(0, () => load($, 'essais'))
441 if (words.includes('audit')) $.clock.after(0, () => act($, 'audit', () => auditExport($)))
442 } else if (tab === 'repos') {
443 $.clock.after(0, () => load($, 'repos', words.includes('refresh'), words.includes('refresh') ? ['--fresh'] : []))
444 }
445 await $.ui.open({ id: PANE, title: c.L.pane_title ?? 'Référence' })
446 return { text: `${origin}: Référence › ${c.L[`tab_${tab}`] ?? tab}${line ? ` · ${line}` : ''}` }
447}
448
449export const register: Register = (on, raw) => {
450 options = (raw ?? {}) as Options
451
452 on('session.start', async ($, e, next) => {
453 projectDir = String((e as { cwd?: string }).cwd ?? '').replace(/\\/g, '/')
454 await $.command.register({
455 name: 'reference',
456 description: 'Référence : Config, Guide, Scénarios, Santé, CI dans un seul panneau (args: config | guide [section] | scenarios [S1..] | sante [refresh] | ci [diagramme] | close)',
457 argumentHint: '[config|guide|scenarios|sante|ci [diagramme]] | close',
458 })
459 const ran = await next(e)
460 // The old commands: only those no loaded mod registered (control, guide, scenarios, sante, ci keep their own).
461 let present: { name: string }[] = []
462 try {
463 present = await $.command.list()
464 } catch {}
465 for (const name of missingAliases(present)) {
466 await $.command.register({ name, description: `Référence › ${ALIASES[name]} (le mod d'origine n'est pas chargé)`, argumentHint: '[args] | close' })
467 }
468 for (const name of ACTION_COMMANDS) {
469 if (present.some(x => x.name === name)) continue
470 await $.command.register({ name, description: 'Exporter pour audit : zip daté des mods, configs locales, PLUGIN_DIRS, env, CLAUDE.md, agents/skills/workflows, RULES-INVENTORY et Veille, secrets masqués, sous ~/.claude/audit-exports' })
471 }
472 return ran
473 })
474
475 on('command.run', { command: 'reference' }, async ($, e) => openOn($, parseArgs(e.args ?? ''), 'reference'))
476 for (const name of Object.keys(ALIASES)) {
477 on('command.run', { command: name }, async ($, e, next) => {
478 // Answer only when this mod registered the alias (no owner loaded); else the owner's own hook answers.
479 let mine = false
480 try {
481 mine = (await $.command.list()).some(x => x.name === name && (x.plugin === 'reference' || (x.plugin ?? '').startsWith('reference@')))
482 } catch {}
483 if (!mine) return next(e)
484 return openOn($, aliasArgs(name, e.args ?? ''), name)
485 }).catch(($, e, next) => next(e)) // fail open: the owner's command (if any) still answers
486 }
487
488 on('command.run', { command: 'audit-export' }, async ($, e, next) => {
489 let mine = false
490 try {
491 mine = (await $.command.list()).some(x => x.name === 'audit-export' && (x.plugin === 'reference' || (x.plugin ?? '').startsWith('reference@')))
492 } catch {}
493 if (!mine) return next(e)
494 const line = await auditExport($)
495 R.msg = line
496 return { text: `audit-export: ${line}` }
497 }).catch(($, e, next) => next(e))
498
499 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
500 const ui = $.ui.resolve(e)
501 const { Box, Text, Button } = ui
502 const c = await context($)
503 const tabs = tabsOf(c.cfg.tabs)
504 const tab: Tab = R.tab && tabs.includes(R.tab) ? R.tab : (tabsOf([c.cfg.start_tab])[0] ?? tabs[0]!)
505 const bar = (
506 <Box flexDirection="row" gap={1} flexWrap="wrap">
507 {tabs.map(t => (
508 <Button key={`ref-tab-${t}`} {...(t === tab ? {} : { plain: true as const })} onPress={async () => {
509 R.tab = t
510 if (t === 'sante') {
511 await santeFacts($)
512 const { stale } = await santeLoad($, c)
513 if (stale) $.clock.after(0, () => santeScan($))
514 }
515 if (t === 'ci') $.clock.after(0, () => (R.ci.tab === 'diagram' ? ciDiagram($, 'cache') : ciRouting($, 'cache')))
516 if (t === 'essais' || t === 'repos') $.clock.after(0, () => load($, t))
517 if (t === 'rd') {
518 $.clock.after(0, () => load($, 'rd'))
519 $.clock.after(0, () => load($, 'essais'))
520 }
521 redraw($)
522 }}>
523 {c.L[`tab_${t}`] ?? t}
524 </Button>
525 ))}
526 </Box>
527 )
528 let body: any
529 if (tab === 'config') body = await drawConfigTab($, ui, c)
530 else if (tab === 'guide') {
531 const { doc, problem } = await guideDoc($)
532 body = drawGuide(ui, { doc, problem, ...R.guide, go: to => { R.guide = to; redraw($) } })
533 } else if (tab === 'scenarios') {
534 const { catalog, errors, warnings } = await scenarios($, c)
535 body = drawScenarios(ui, {
536 catalog, errors, warnings, ...R.scen,
537 applied: await readApplied(io($), catalog, c.claudeDir),
538 onSelect: id => { R.scen.selected = id; redraw($) },
539 onKind: k => { R.scen.kind = k; redraw($) },
540 onCopy: async (t, label, press) => {
541 const r = await $.ui.copy({ text: t, surface: press?.surface })
542 R.scen.copied = r.isCopied ? `copied: ${label}` : 'copy failed: select the text above'
543 redraw($)
544 },
545 onApply: async id => {
546 const s = catalog.scenarios.find(x => x.id === id)
547 if (!s) return
548 try {
549 R.scen.applyMsg = await applyScenario(io($), catalog, s, c.claudeDir, new Date(await $.clock.now()).toISOString())
550 } catch (err) {
551 R.scen.applyMsg = `écriture impossible : ${String(err).slice(0, 160)}`
552 }
553 redraw($)
554 },
555 })
556 } else if (tab === 'sante') {
557 const { now } = await santeLoad($, c)
558 body = drawSante(ui, {
559 ...R.sante, cfg: await santeCfg($, c), now, homeDir: c.home,
560 onRefresh: () => { $.clock.after(0, () => santeScan($)) },
561 onCopy: async (cmd, press) => {
562 const r = await $.ui.copy({ text: cmd, surface: press?.surface })
563 R.sante.copied = r?.isCopied ? 'copié' : 'copie impossible'
564 redraw($)
565 },
566 })
567 } else if (tab === 'essais') {
568 const data = R.essais.data ? { ...R.essais.data, items: await essaisItems($, c) } : undefined
569 body = drawEssais(ui, {
570 data, error: R.essais.error, busy: R.busy, msg: R.msg, open: R.essais.open, labels: c.L, max: c.cfg.essais?.max_per_section ?? 40,
571 onState: async (id, cur) => {
572 const st = await essaisStates($, c)
573 const next = nextState(cur)
574 try {
575 await $.fs.write(st.file, JSON.stringify(setState(st.data, id, next, await $.clock.now()), null, 1))
576 R.msg = `état : ${next}`
577 } catch (err) {
578 R.msg = `écriture impossible : ${String(err).slice(0, 160)}`
579 }
580 redraw($)
581 },
582 onFold: s => {
583 R.essais.open = R.essais.open.includes(s) ? R.essais.open.filter(x => x !== s) : [...R.essais.open, s]
584 redraw($)
585 },
586 onRefresh: () => { $.clock.after(0, () => load($, 'essais', true)) },
587 })
588 } else if (tab === 'rd') {
589 const ideas: Record<string, Record<string, number>> = {}
590 for (const i of R.essais.data ? await essaisItems($, c) : []) {
591 if (!i.doc || !(STATES as readonly string[]).includes(i.state)) continue
592 const d = (ideas[i.doc] ??= {})
593 d[i.state] = (d[i.state] ?? 0) + 1
594 }
595 body = drawRd(ui, {
596 data: R.rd.data, ideas, error: R.rd.error, busy: R.busy, msg: R.msg, labels: c.L,
597 onOpen: p => { $.clock.after(0, () => openPath($, p)) },
598 onExportEta: () => { $.clock.after(0, () => act($, 'eta', () => etaExport($))) },
599 onAudit: () => { $.clock.after(0, () => act($, 'audit', () => auditExport($))) },
600 onRefresh: () => {
601 $.clock.after(0, () => load($, 'rd', true))
602 $.clock.after(0, () => load($, 'essais', true))
603 },
604 })
605 } else if (tab === 'repos') {
606 body = drawRepos(ui, {
607 data: R.repos.data, error: R.repos.error, busy: R.busy, msg: R.msg, labels: c.L, now: await $.clock.now(),
608 onUpdate: name => {
609 $.clock.after(0, () =>
610 act($, `mise à jour ${name}`, async () => {
611 const r = await rdRun($, ['repo-update', name], 420000)
612 await load($, 'repos', true)
613 return r.ok === false ? r.error : r.msg
614 }),
615 )
616 },
617 onExport: repo => { $.clock.after(0, () => act($, repo.export === 'eta' ? 'eta' : 'audit', () => (repo.export === 'eta' ? etaExport($) : auditExport($)))) },
618 onFolder: p => { $.clock.after(0, () => openPath($, p)) },
619 onRefresh: () => { $.clock.after(0, () => load($, 'repos', true, ['--fresh'])) },
620 })
621 } else {
622 body = drawCi(ui, {
623 surface: e.surface, tab: R.ci.tab, snapshot: parseSnapshot(R.ci.snapshot), diagram: parseDiagram(R.ci.diagram),
624 error: R.ci.error, checking: R.ci.checking, fetching: R.ci.fetching, who: R.ci.who, group: R.ci.group, folded: R.ci.folded, now: Date.now(),
625 onTab: t => { R.ci.tab = t; $.clock.after(0, () => (t === 'diagram' ? ciDiagram($, 'cache') : ciRouting($, 'cache'))); redraw($) },
626 onCheck: force => { $.clock.after(0, () => ciRouting($, force ? 'force' : 'check')) },
627 onFetch: force => { $.clock.after(0, () => ciDiagram($, force ? 'force' : 'check')) },
628 onWho: w => { R.ci.who = w; redraw($) },
629 onGroup: g => { R.ci.group = g; redraw($) },
630 onToggle: k => { R.ci.folded = R.ci.folded.includes(k) ? R.ci.folded.filter(x => x !== k) : [...R.ci.folded, k]; redraw($) },
631 onClear: async key => {
632 await ciRun($, ['clear', key], 20000)
633 await ciRouting($, 'cache')
634 },
635 })
636 }
637 return (
638 <Box flexDirection="column" gap={1}>
639 {bar}
640 {body}
641 <Text> </Text>
642 </Box>
643 )
644 })
645}
646lib/ci/hooks/draw.tsx 299 lines1// ci mod: the pane's drawing (Routage and Diagramme), pure. No `$`: the elements come in as `ui` ($.ui.resolve(e)),
2// the data and every press through `m`, so the ci pane and the Référence hub (mods/reference, a synced copy of this
3// file) draw the same tree. Same output as the drawing that lived in register.tsx before, except the Gantt, now drawn
4// by lib/core/gantt.mjs with factory's symbols and legend.
5import type { Diagram, GroupBy, Run, Snapshot, WhoFilter } from '../types'
6import { GROUPS, MARK, WHO_FILTERS, ago, appsWithoutRuns, clock, colorOf, etaText, gantt, groupLabel, groups, headline, historySegs, hostLabel, isRed, labelsOf, laneSegs, latestRuns, liveRuns, logCommand, overviewSvg, stats, statsText, summaryLines, whoLabel } from './diagram'
7import type { Seg } from './diagram'
8import { checkLine, mark, routeLine, rowColor, statusText, usableText } from './view'
9
10export const MAX_LANES = 40
11export const GANTT_WIDTH = 24
12/** Sections folded until the viewer opens them (the overview stays short at side-panel width). */
13export const FOLDED_AT_START = ['summary']
14
15export type CiTab = 'routing' | 'diagram'
16export type CiModel = {
17 surface: string
18 tab: CiTab
19 snapshot: Snapshot | undefined
20 diagram: Diagram | undefined
21 error: string
22 checking: boolean
23 fetching: boolean
24 who: WhoFilter
25 group: GroupBy
26 folded: string[]
27 now: number
28 onTab: (t: CiTab) => void | Promise<void>
29 onCheck: (force: boolean) => void
30 onFetch: (force: boolean) => void
31 onWho: (w: WhoFilter) => void | Promise<void>
32 onGroup: (g: GroupBy) => void | Promise<void>
33 onToggle: (key: string) => void | Promise<void>
34 onClear: (key: string) => void | Promise<void>
35}
36
37export function drawCi(ui: any, m: CiModel): any {
38 const { Box, Text, Button } = ui
39 const current = m.tab
40 const dRaw = m.diagram
41 const DL = labelsOf(dRaw)
42 const tabs = (
43 <Box flexDirection="row" gap={1}>
44 {(['routing', 'diagram'] as const).map(t => (
45 <Button key={`tab-${t}`} plain bold={current === t} dimColor={current !== t} onPress={() => m.onTab(t)}>
46 {`${current === t ? '▸ ' : ''}${t === 'routing' ? DL.tab_routing : DL.tab_diagram}`}
47 </Button>
48 ))}
49 </Box>
50 )
51 const problem = m.error
52
53 if (current === 'diagram') {
54 const now = m.now
55 const w = m.who
56 const g = m.group
57 const head = (
58 <Box flexDirection="row" gap={1}>
59 <Text dimColor>
60 {m.fetching ? 'lecture gh/glab…' : `lu ${dRaw?.generated ?? 'jamais'}`}
61 {problem ? ` · ci.py: ${problem}` : ''}
62 </Text>
63 <Button key="diag-refresh" hotkey="r" plain dimColor onPress={() => m.onFetch(true)}>
64 Rafraîchir
65 </Button>
66 </Box>
67 )
68 if (!dRaw || !dRaw.generated) {
69 return (
70 <Box flexDirection="column">
71 {tabs}
72 {head}
73 <Text>Pas encore de données : Rafraîchir, ou /ci diagramme refresh (lit gh et glab, ~1-3 min).</Text>
74 </Box>
75 )
76 }
77 const d = dRaw
78 const Svg = m.surface === 'terminal' ? undefined : ui.Svg
79 const folded = new Set(m.folded)
80 const toggle = (k: string) => () => m.onToggle(k)
81 const seg = (s: Seg, i: number) => (
82 <Text key={i} color={s.color ?? (s.status ? colorOf(d, s.status) : undefined)} dimColor={s.dim} bold={s.bold}>{s.text}</Text>
83 )
84 // A section: a header button (▾ open / ▸ folded with its one-line summary), then its body when open.
85 // Sections start open except the ones listed in FOLDED_AT_START (folded keys are toggled from that default).
86 const isOpen = (k: string) => (FOLDED_AT_START.includes(k) ? folded.has(k) : !folded.has(k))
87 const section = (k: string, title: string, oneLine: string, body: () => any) => (
88 <Box key={`sec-${k}`} flexDirection="column">
89 <Button key={`fold-${k}`} plain onPress={toggle(k)}>
90 {`${isOpen(k) ? '▾ ' : '▸ '}${title}${isOpen(k) ? '' : ` · ${oneLine}`}`}
91 </Button>
92 {isOpen(k) ? body() : null}
93 </Box>
94 )
95 const ganttBlock = (r: Run, key: string) => {
96 const gt = gantt(r, now, GANTT_WIDTH)
97 const stagesSeen: string[] = []
98 return (
99 <Box key={`gantt-${key}`} flexDirection="column">
100 <Text dimColor>
101 {' '}{clock(new Date(gt.start).toISOString())} → {clock(new Date(gt.end).toISOString())} · {gt.legend}
102 </Text>
103 {gt.rows.map((row, i) => {
104 const head = !stagesSeen.includes(row.stage)
105 if (head) stagesSeen.push(row.stage)
106 return (
107 <Box key={`gr-${key}-${i}`} flexDirection="column">
108 {head ? <Text dimColor>{' '}{row.stage}</Text> : null}
109 <Text wrap="truncate">
110 <Text color={row.red ? colorOf(d, 'failed') : undefined}>{' '}{row.name.slice(0, 16).padEnd(16)} </Text>
111 {row.cells.map(seg)}
112 <Text dimColor> {row.text}</Text>
113 </Text>
114 </Box>
115 )
116 })}
117 {gt.rows.length === 0 ? <Text dimColor>{' '}jobs non lus pour ce run</Text> : null}
118 </Box>
119 )
120 }
121 const latest = latestRuns(d, w)
122 const live = liveRuns(d, w)
123 const reds = latest.filter(isRed).sort((a, b) => (b.created ?? '').localeCompare(a.created ?? ''))
124 const gs = groups(d, w, g)
125 let shown = 0
126 const empty = appsWithoutRuns(d)
127 const sum = summaryLines(d, w, now)
128 return (
129 <Box flexDirection="column">
130 {tabs}
131 {head}
132 <Text bold wrap="wrap">{headline(d, w)}</Text>
133 <Box flexDirection="row" gap={1}>
134 {WHO_FILTERS.map(f => (
135 <Button key={`who-${f}`} plain bold={w === f} dimColor={w !== f} onPress={() => m.onWho(f)}>
136 {`${w === f ? '▸ ' : ''}${whoLabel(DL, f)}`}
137 </Button>
138 ))}
139 </Box>
140 {section('summary', 'Résumé', sum[1] ?? '', () => (
141 <Box flexDirection="column">
142 {sum.map((l, i) => <Text key={`sum-${i}`} wrap="wrap">{' '}{l}</Text>)}
143 {Svg ? <Svg key="ci-overview" source={overviewSvg(d, w)} alt={`auteurs → hôtes CI → résultats (${whoLabel(DL, w)})`} /> : null}
144 </Box>
145 ))}
146 {section('live', `En cours (${live.length})`, live.length ? live.map(r => r.app).slice(0, 3).join(', ') : 'rien', () => (
147 <Box flexDirection="column">
148 {live.length === 0 ? <Text dimColor>{' '}aucun pipeline en cours</Text> : null}
149 {live.map(r => (
150 <Box key={`live-${r.app}-${r.id}`} flexDirection="column">
151 <Text wrap="wrap">
152 <Text bold>{' '}{r.app}</Text>
153 <Text dimColor> · {hostLabel(d, r.backend)} · {r.mr != null ? `MR !${r.mr}` : r.branch} · </Text>
154 <Text color={colorOf(d, 'running')}>{etaText(r, now)}</Text>
155 </Text>
156 {ganttBlock(r, `live-${r.app}-${r.id}`)}
157 </Box>
158 ))}
159 </Box>
160 ))}
161 {section('reds', `Rouges (${reds.length})`, reds.length ? reds.map(r => r.app).slice(0, 3).join(', ') : 'aucun', () => (
162 <Box flexDirection="column">
163 {reds.length === 0 ? <Text dimColor>{' '}aucun rouge sur le dernier run de chaque ligne</Text> : null}
164 {reds.map(r => {
165 const bad = (r.jobs ?? []).filter(isRed)
166 return (
167 <Box key={`red-${r.app}-${r.id}`} flexDirection="column">
168 <Text wrap="wrap">
169 <Text color={colorOf(d, 'failed')} bold>{' '}{MARK.failed} {r.app}</Text>
170 <Text dimColor> · {hostLabel(d, r.backend)} · {r.mr != null ? `MR !${r.mr}` : r.branch} · {ago(r.created, now)}</Text>
171 </Text>
172 {bad.length === 0 ? <Text dimColor wrap="wrap">{' '}log : {logCommand(r)} · {r.url}</Text> : null}
173 {bad.map(j => (
174 <Text key={`rj-${r.id}-${j.name}`} dimColor wrap="wrap">
175 {' '}job {j.name} ({j.stage}) · log : {logCommand(r, j)}{j.url ? ` · ${j.url}` : ''}
176 </Text>
177 ))}
178 </Box>
179 )
180 })}
181 </Box>
182 ))}
183 {section('lanes', `Pipelines ${groupLabel(DL, g)}`, `${gs.length} groupe(s)`, () => (
184 <Box flexDirection="column">
185 <Box flexDirection="row" gap={1}>
186 {GROUPS.map(x => (
187 <Button key={`group-${x}`} plain bold={g === x} dimColor={g !== x} onPress={() => m.onGroup(x)}>
188 {`${g === x ? '▸ ' : ''}${groupLabel(DL, x)}`}
189 </Button>
190 ))}
191 </Box>
192 {gs.length === 0 ? <Text dimColor>{DL.no_runs}</Text> : null}
193 {gs.map(gr => (
194 <Box key={`g-${gr.key}`} flexDirection="column">
195 {section(`g:${g}:${gr.key}`, gr.title, statsText(DL, stats(gr.runs)), () => (
196 <Box flexDirection="column">
197 <Text dimColor>{' '}{statsText(DL, stats(gr.runs))}</Text>
198 {gr.lanes.map(l => {
199 if (shown >= MAX_LANES) return null
200 shown += 1
201 const gk = `gantt:${l.key}`
202 return (
203 <Box key={`l-${gr.key}-${l.key}`} flexDirection="column">
204 <Text wrap="wrap">{' '}{laneSegs(DL, l, now).map(seg)}</Text>
205 <Box flexDirection="row" gap={1}>
206 <Text wrap="wrap">{historySegs(DL, l).map(seg)}</Text>
207 <Button key={`btn-${gk}-${gr.key}`} plain dimColor onPress={toggle(gk)}>
208 {folded.has(gk) ? 'Gantt ▾' : 'Gantt ▸'}
209 </Button>
210 </Box>
211 {folded.has(gk) ? ganttBlock(l.runs[0]!, `${gr.key}-${l.key}`) : null}
212 </Box>
213 )
214 })}
215 </Box>
216 ))}
217 </Box>
218 ))}
219 {shown >= MAX_LANES ? <Text dimColor>… {MAX_LANES} lignes affichées au plus ; filtre ou groupe pour voir le reste.</Text> : null}
220 </Box>
221 ))}
222 <Text> </Text>
223 <Text dimColor wrap="wrap">
224 * hôte déduit des jobs des runs récents du même projet · {empty.filter(a => a.fetched).length} app(s) sans run lu
225 {empty.some(a => !a.fetched) ? ` · ${empty.filter(a => !a.fetched).length} ${DL.not_fetched}` : ''}
226 {' '}· Claude = trailer « Co-Authored-By: Claude », branche claude/* ou worktree-agent-*, ou PR ouverte par une conversation ; Codex = branche codex/*, trailer ou auteur Codex ; le reste « {DL.unknown} ». Estimations : médiane des 3 derniers runs réussis au moins du même job.
227 </Text>
228 </Box>
229 )
230 }
231
232 const s = m.snapshot
233 const head = (
234 <Box flexDirection="row" gap={1}>
235 <Text dimColor>
236 {m.checking ? 'checking…' : `checked ${s?.generated ?? 'never'}`}
237 {problem ? ` · ci.py failed: ${problem}` : ''}
238 </Text>
239 <Button key="refresh" hotkey="r" plain dimColor onPress={() => m.onCheck(true)}>
240 Refresh
241 </Button>
242 </Box>
243 )
244 if (!s) {
245 return (
246 <Box flexDirection="column">
247 {tabs}
248 {head}
249 <Text>No data yet: run /ci or press Refresh.</Text>
250 </Box>
251 )
252 }
253 const L = s.labels
254 const cur = s.current
255 return (
256 <Box flexDirection="column">
257 {tabs}
258 {head}
259 <Text bold>
260 {L.here ?? 'This conversation'}: {cur ? `${cur.backend} (${cur.match} route)` : (L.no_route ?? 'no route')}
261 </Text>
262 <Text> </Text>
263 <Text bold>{L.backends ?? 'Backends'}</Text>
264 {s.backends.map(b => (
265 <Box flexDirection="column">
266 <Text>
267 <Text color={rowColor(L, b)}>{mark(b)} </Text>
268 <Text bold>{b.label}</Text>
269 <Text dimColor>
270 {' '}· {b.id} · {b.kind} · {b.host}
271 {b.runner_tag ? ` · tag ${b.runner_tag}` : ''} · {statusText(L, b.status)}
272 </Text>
273 {b.status === 'active' ? <Text color={rowColor(L, b)}> · {usableText(L, b.check?.usable)}</Text> : null}
274 </Text>
275 <Text dimColor wrap="wrap">{' '}{checkLine(b)}</Text>
276 {b.notes ? <Text dimColor wrap="wrap">{' '}{b.notes}</Text> : null}
277 </Box>
278 ))}
279 <Text> </Text>
280 <Text bold>
281 {L.routes ?? 'Routes'} ({s.routes.length}) · {s.routing_file}
282 </Text>
283 {s.routes.length === 0 ? <Text dimColor>none: /ci route here {'<backend-id>'}</Text> : null}
284 {s.routes.map(r => (
285 <Box flexDirection="row" gap={1}>
286 <Text wrap="wrap" bold={cur?.key === r.key}>
287 {cur?.key === r.key ? '▸ ' : ' '}
288 {routeLine(s, r)}
289 </Text>
290 <Button key={`clear-${r.key}`} plain dimColor onPress={() => m.onClear(r.key)}>
291 Clear
292 </Button>
293 </Box>
294 ))}
295 <Text dimColor>Set a route: /ci route {'<repo-path|session-id|here|session> <backend-id>'} · config {s.config}</Text>
296 </Box>
297 )
298}
299lib/ci/hooks/diagram.ts 434 lines1// Pure helpers of the ci mod's « Diagramme » view: filters, lanes, text segments and the static SVG overview.
2// No `$`, so tests call them directly. Labels and colors come from the config (pipelines.labels / colors /
3// svg_colors); the French defaults below apply only to keys the config leaves out.
4import type { App, Diagram, DiagramLabels, GroupBy, Job, Run, RunStatus, Stats, Who, WhoFilter } from '../types'
5import { MIN_SAMPLES, estimateOf, etaOf } from '../lib/core/estimate.mjs'
6import { COLORS, SYMBOLS, axis as ganttAxis, gantt as coreGantt, legend as ganttLegend } from '../lib/core/gantt.mjs'
7
8export const DEFAULT_LABELS: Required<Omit<DiagramLabels, 'status'>> & { status: Record<RunStatus, string> } = {
9 tab_routing: 'Routage',
10 tab_diagram: 'Diagramme',
11 all: 'Tout',
12 claude: 'Claude Code',
13 codex: 'Codex',
14 unknown: 'inconnu',
15 by_host: 'par hôte',
16 by_app: 'par app',
17 by_conv: 'par conversation',
18 no_conv: 'sans conversation connue',
19 today: "aujourd'hui",
20 week: '7 jours',
21 pass_rate: 'réussite',
22 minutes: 'min',
23 queue: 'file du runner local',
24 last_runs: 'derniers runs',
25 no_runs: 'aucun run lu',
26 not_fetched: "non lu (gh/glab absent, pas d'accès ou pas de CI)",
27 status: { success: 'vert', failed: 'rouge', running: 'en cours', queued: 'en attente', cancelled: 'annulé', skipped: 'sauté' },
28}
29export const DEFAULT_COLORS: Record<RunStatus, string> = {
30 success: 'success', failed: 'error', running: 'warning', queued: 'warning', cancelled: 'subtle', skipped: 'subtle',
31}
32export const DEFAULT_SVG: Record<string, string> = {
33 success: '#2da44e', failed: '#cf222e', running: '#bf8700', queued: '#bf8700', cancelled: '#8c959f', skipped: '#8c959f',
34 line: '#8c959f', text: '#8c959f', box: '#2f81f7',
35}
36export const MARK: Record<RunStatus, string> = { success: '✔', failed: '✘', running: '●', queued: '◌', cancelled: '⊘', skipped: '–' }
37
38export const labelsOf = (d: Pick<Diagram, 'labels'> | undefined) => ({
39 ...DEFAULT_LABELS,
40 ...(d?.labels ?? {}),
41 status: { ...DEFAULT_LABELS.status, ...(d?.labels?.status ?? {}) },
42})
43export type L = ReturnType<typeof labelsOf>
44export const colorOf = (d: Pick<Diagram, 'colors'> | undefined, s: RunStatus): string => d?.colors?.[s] ?? DEFAULT_COLORS[s]
45
46/** Filter buttons, Claude Code first (the default), then the Codex button, then everything and the unattributed. */
47export const WHO_FILTERS: WhoFilter[] = ['claude', 'codex', 'all', 'unknown']
48/** Claude only unless the config (pipelines.default_who) says otherwise. */
49export const DEFAULT_WHO: WhoFilter = 'claude'
50/** The filter in use: the viewer's choice, else the config default, else Claude only. */
51export const effectiveWho = (chosen: WhoFilter | '' | undefined, d: Pick<Diagram, 'default_who'> | undefined): WhoFilter =>
52 chosen && (WHO_FILTERS as string[]).includes(chosen) ? chosen
53 : d?.default_who && (WHO_FILTERS as string[]).includes(d.default_who) ? d.default_who
54 : DEFAULT_WHO
55export const GROUPS: GroupBy[] = ['host', 'app', 'conv']
56export const whoLabel = (L: L, w: WhoFilter): string => (w === 'all' ? L.all : L[w])
57export const groupLabel = (L: L, g: GroupBy): string => (g === 'host' ? L.by_host : g === 'app' ? L.by_app : L.by_conv)
58
59/** `/ci diagramme [refresh] [claude|codex|inconnu|tout] [hote|app|conv]` → which filter words were typed. */
60export const parseDiagramWords = (words: string[]): { refresh: boolean; who?: WhoFilter; group?: GroupBy } => {
61 const out: { refresh: boolean; who?: WhoFilter; group?: GroupBy } = { refresh: false }
62 for (const raw of words) {
63 const w = raw.toLowerCase()
64 if (w === 'refresh' || w === 'check') out.refresh = true
65 else if (w === 'tout' || w === 'all') out.who = 'all'
66 else if (w === 'claude') out.who = 'claude'
67 else if (w === 'codex') out.who = 'codex'
68 else if (w === 'inconnu' || w === 'unknown') out.who = 'unknown'
69 else if (w === 'hote' || w === 'hôte' || w === 'host') out.group = 'host'
70 else if (w === 'app' || w === 'apps' || w === 'repo') out.group = 'app'
71 else if (w === 'conv' || w === 'conversation' || w === 'session') out.group = 'conv'
72 }
73 return out
74}
75
76export const fmtDur = (s: number | null | undefined): string => {
77 if (s == null) return '?'
78 if (s < 60) return `${s}s`
79 const m = Math.floor(s / 60)
80 if (m < 60) return `${m}m${String(s % 60).padStart(2, '0')}`
81 return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, '0')}`
82}
83
84export const ago = (iso: string | null | undefined, now: number): string => {
85 const t = iso ? Date.parse(iso) : NaN
86 if (!Number.isFinite(t)) return '?'
87 const m = Math.max(0, Math.round((now - t) / 60000))
88 if (m < 60) return `il y a ${m} min`
89 if (m < 48 * 60) return `il y a ${Math.round(m / 60)} h`
90 return `il y a ${Math.round(m / 1440)} j`
91}
92
93export const hostLabel = (d: Diagram, id: string): string =>
94 d.hosts.find(h => h.id === id)?.label ?? (id === 'gitlab' ? 'GitLab (runner inconnu)' : id)
95
96export const runsOf = (d: Diagram, who: WhoFilter): Run[] =>
97 d.apps.flatMap(a => a.runs).filter(r => who === 'all' || r.who === who)
98
99/** One lane = one app on one CI host: its runs newest first. */
100export type Lane = { key: string; app: string; backend: string; host: string; runs: Run[] }
101export type Group = { key: string; title: string; lanes: Lane[]; runs: Run[] }
102
103const lanesOf = (d: Diagram, runs: Run[]): Lane[] => {
104 const m = new Map<string, Lane>()
105 for (const r of runs) {
106 const key = `${r.app}|${r.backend}`
107 let l = m.get(key)
108 if (!l) m.set(key, (l = { key, app: r.app, backend: r.backend, host: hostLabel(d, r.backend), runs: [] }))
109 l.runs.push(r)
110 }
111 const ls = [...m.values()]
112 for (const l of ls) l.runs.sort((a, b) => (b.created ?? '').localeCompare(a.created ?? ''))
113 return ls.sort((a, b) => (b.runs[0]?.created ?? '').localeCompare(a.runs[0]?.created ?? ''))
114}
115
116/** Lanes grouped by CI host, by app or by the conversation that opened the PR/MR. */
117export const groups = (d: Diagram, who: WhoFilter, by: GroupBy): Group[] => {
118 const L = labelsOf(d)
119 const runs = runsOf(d, who)
120 const m = new Map<string, { title: string; runs: Run[] }>()
121 for (const r of runs) {
122 const [key, title] = by === 'host'
123 ? [r.backend, hostLabel(d, r.backend)]
124 : by === 'app'
125 ? [r.app, r.app]
126 : r.conv
127 ? [r.conv.id, `${r.conv.title || r.conv.id}${r.conv.title ? ` · ${r.conv.id.slice(0, 8)}` : ''}${r.conv.project ? ` · ${r.conv.project}` : ''}`]
128 : ['', L.no_conv]
129 let g = m.get(key)
130 if (!g) m.set(key, (g = { title, runs: [] }))
131 g.runs.push(r)
132 }
133 const out = [...m.entries()].map(([key, g]) => ({ key, title: g.title, runs: g.runs, lanes: lanesOf(d, g.runs) }))
134 // the "no conversation" group last, the others by most recent run
135 return out.sort((a, b) => (a.key === '' ? 1 : b.key === '' ? -1 : 0) || (b.lanes[0]?.runs[0]?.created ?? '').localeCompare(a.lanes[0]?.runs[0]?.created ?? ''))
136}
137
138export const stats = (runs: Run[]): Stats => {
139 const done = runs.filter(r => r.status === 'success' || r.status === 'failed')
140 const ok = done.filter(r => r.status === 'success').length
141 return {
142 runs: runs.length, success: ok, failed: done.length - ok,
143 running: runs.filter(r => r.status === 'running' || r.status === 'queued').length,
144 pass_rate: done.length ? Math.round((100 * ok) / done.length) : null,
145 minutes: Math.round(runs.reduce((s, r) => s + (r.duration_s ?? 0), 0) / 6) / 10,
146 }
147}
148export const statsText = (L: L, s: Stats): string =>
149 `${s.runs} runs · ${s.pass_rate == null ? '?' : `${s.pass_rate} %`} ${L.pass_rate} · ${s.failed} ${L.status.failed}` +
150 `${s.running ? ` · ${s.running} ${L.status.running}` : ''} · ${s.minutes} ${L.minutes}`
151
152/** A piece of text with an optional run status (the pane colors it). */
153export type Seg = { text: string; status?: RunStatus; dim?: boolean; bold?: boolean; color?: string }
154
155/** app ──▶ host ──▶ [stage: job ✔ 1m02, …] ▶ [stage …] ──▶ ✔ vert 1m20 · il y a 2 h, for the lane's newest run. */
156export const laneSegs = (L: L, lane: Lane, now: number): Seg[] => {
157 const r = lane.runs[0]!
158 const segs: Seg[] = [{ text: lane.app, bold: true }, { text: ' ──▶ ', dim: true }, { text: lane.host }]
159 if (r.host_inferred) segs.push({ text: '*', dim: true })
160 segs.push({ text: ' ──▶ ', dim: true })
161 const withJobs = lane.runs.find(x => x.jobs && x.jobs.length)
162 const jobs = r.jobs ?? null
163 if (jobs && jobs.length) {
164 const stages: string[] = []
165 for (const j of jobs) if (!stages.includes(j.stage)) stages.push(j.stage)
166 stages.forEach((st, i) => {
167 if (i) segs.push({ text: ' ▶ ', dim: true })
168 segs.push({ text: `[${st}: `, dim: true })
169 jobs.filter(j => j.stage === st).forEach((j, k) => {
170 if (k) segs.push({ text: ', ', dim: true })
171 segs.push({ text: `${j.name} ` })
172 segs.push({ text: MARK[j.status], status: j.allow_failure && j.status === 'failed' ? 'running' : j.status })
173 segs.push({ text: ` ${fmtDur(j.duration_s)}`, dim: true })
174 })
175 segs.push({ text: ']', dim: true })
176 })
177 } else {
178 segs.push({ text: withJobs ? '[jobs non lus pour ce run]' : '[jobs non lus]', dim: true })
179 }
180 segs.push({ text: ' ──▶ ', dim: true })
181 segs.push({ text: `${MARK[r.status]} ${L.status[r.status]}`, status: r.status, bold: true })
182 segs.push({ text: ` ${fmtDur(r.duration_s)} · ${ago(r.created, now)}`, dim: true })
183 return segs
184}
185
186/** « derniers runs : ✔✔✘● · claude 6 · codex 2 · inconnu 1 · branche / conversation » for a lane. */
187export const historySegs = (L: L, lane: Lane, n = 10): Seg[] => {
188 const segs: Seg[] = [{ text: ` ${L.last_runs} : `, dim: true }]
189 for (const r of lane.runs.slice(0, n)) segs.push({ text: MARK[r.status], status: r.status })
190 const by = (w: Who) => lane.runs.filter(r => r.who === w).length
191 const who = (['claude', 'codex', 'unknown'] as Who[]).filter(w => by(w)).map(w => `${L[w]} ${by(w)}`).join(' · ')
192 const r = lane.runs[0]!
193 const where = r.mr != null ? `MR !${r.mr}` : r.branch || '?'
194 segs.push({ text: ` · ${who} · ${where}${r.conv ? ` · ${r.conv.title || r.conv.id}` : ''}`, dim: true })
195 return segs
196}
197
198export const segsText = (segs: Seg[]): string => segs.map(s => s.text).join('')
199
200/** The global summary as lines (also the text /ci diagramme answers with). */
201export const summaryLines = (d: Diagram, who: WhoFilter, now: number): string[] => {
202 const L = labelsOf(d)
203 const runs = runsOf(d, who)
204 const today0 = new Date(now)
205 today0.setHours(0, 0, 0, 0)
206 const week0 = now - 7 * 86400000
207 const when = (r: Run) => (r.created ? Date.parse(r.created) : NaN)
208 const today = runs.filter(r => when(r) >= today0.getTime())
209 const week = runs.filter(r => when(r) >= week0)
210 const lines = [
211 `${L.today} : ${statsText(L, stats(today))}`,
212 `${L.week} : ${statsText(L, stats(week))}`,
213 ]
214 const per = (key: (r: Run) => string, name: (k: string) => string) => {
215 const m = new Map<string, Run[]>()
216 for (const r of week) m.set(key(r), [...(m.get(key(r)) ?? []), r])
217 return [...m.entries()].sort((a, b) => b[1].length - a[1].length).map(([k, rs]) => {
218 const s = stats(rs)
219 return `${name(k)} ${s.runs} (${s.pass_rate == null ? '?' : `${s.pass_rate} %`}, ${s.minutes} ${L.minutes})`
220 })
221 }
222 lines.push(`hôtes : ${per(r => r.backend, k => hostLabel(d, k)).join(' · ') || '—'}`)
223 if (who === 'all') lines.push(`auteurs : ${per(r => r.who, k => L[k as Who] ?? k).join(' · ') || '—'}`)
224 const apps = per(r => r.app, k => k)
225 lines.push(`apps : ${apps.slice(0, 8).join(' · ') || '—'}${apps.length > 8 ? ` · +${apps.length - 8}` : ''}`)
226 const q = d.summary?.queue
227 if (q) {
228 const n = (x: number | null) => (x == null ? '?' : String(x))
229 lines.push(`${L.queue} (${q.label}, tag ${q.tag}) : ${n(q.running)} jobs en cours, ${n(q.pending)} en attente, ` +
230 `${n(q.runners_online)} runner(s) en ligne, ${q.pipelines_open} pipeline(s) ouverts lus`)
231 }
232 return lines
233}
234
235export const diagramText = (d: Diagram, who: WhoFilter, group: GroupBy, now: number): string => {
236 const L = labelsOf(d)
237 const head = `CI · ${L.tab_diagram} · lu ${d.generated ?? 'jamais'}${d.stale ? ' (rafraîchissement)' : ''} · ${whoLabel(L, who)} · ${groupLabel(L, group)}`
238 if (!d.generated) return `${head}\naucune donnée : /ci diagramme refresh`
239 return [head, headline(d, who), ...summaryLines(d, who, now)].join('\n')
240}
241
242export const appsWithoutRuns = (d: Diagram): App[] => d.apps.filter(a => !a.runs.length)
243
244const esc = (s: string) => s.replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]!)
245
246/** Static overview: authors → CI hosts → results, boxes with counts. A picture only: filters stay real buttons. */
247export const overviewSvg = (d: Diagram, who: WhoFilter, width = 560): string => {
248 const C = { ...DEFAULT_SVG, ...(d.svg_colors ?? {}) }
249 const L = labelsOf(d)
250 const runs = runsOf(d, who)
251 const hosts = [...new Set(runs.map(r => r.backend))].sort()
252 const authors = (['claude', 'codex', 'unknown'] as Who[]).filter(w => runs.some(r => r.who === w))
253 const rows = Math.max(hosts.length, authors.length, 1)
254 const rowH = 34
255 const h = rows * rowH + 30
256 const colW = Math.floor((width - 40) / 3)
257 const x = [10, 20 + colW, 30 + 2 * colW]
258 const y = (i: number, n: number) => 24 + i * rowH + ((rows - n) * rowH) / 2
259 const parts: string[] = []
260 const box = (bx: number, by: number, text: string, sub: string) =>
261 parts.push(`<rect x="${bx}" y="${by}" width="${colW}" height="${rowH - 8}" rx="5" fill="none" stroke="${C.box}" stroke-width="1.2"/>` +
262 `<text x="${bx + 8}" y="${by + 17}" font-size="11" fill="${C.text}" font-family="sans-serif">${esc(text)} <tspan font-weight="bold">${esc(sub)}</tspan></text>`)
263 const line = (x1: number, y1: number, x2: number, y2: number) =>
264 parts.push(`<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${C.line}" stroke-width="1" opacity="0.6"/>`)
265 parts.push(`<text x="${x[0]}" y="14" font-size="10" fill="${C.text}" font-family="sans-serif">auteur</text>`)
266 parts.push(`<text x="${x[1]}" y="14" font-size="10" fill="${C.text}" font-family="sans-serif">hôte CI</text>`)
267 parts.push(`<text x="${x[2]}" y="14" font-size="10" fill="${C.text}" font-family="sans-serif">résultat</text>`)
268 authors.forEach((w, i) => {
269 hosts.forEach((hId, k) => {
270 if (runs.some(r => r.who === w && r.backend === hId)) line(x[0] + colW, y(i, authors.length) + 13, x[1], y(k, hosts.length) + 13)
271 })
272 box(x[0], y(i, authors.length), L[w], String(runs.filter(r => r.who === w).length))
273 })
274 hosts.forEach((hId, k) => {
275 const rs = runs.filter(r => r.backend === hId)
276 const s = stats(rs)
277 line(x[1] + colW, y(k, hosts.length) + 13, x[2], y(k, hosts.length) + 13)
278 box(x[1], y(k, hosts.length), hostLabel(d, hId).slice(0, 28), String(rs.length))
279 // result bar: share of each status
280 const by = y(k, hosts.length)
281 let bx = x[2]
282 const order: RunStatus[] = ['success', 'failed', 'running', 'queued', 'cancelled', 'skipped']
283 for (const st of order) {
284 const n = rs.filter(r => r.status === st).length
285 if (!n) continue
286 const w = Math.max(2, Math.round(((colW - 70) * n) / rs.length))
287 parts.push(`<rect x="${bx}" y="${by + 5}" width="${w}" height="${rowH - 18}" fill="${C[st]}"/>`)
288 bx += w
289 }
290 parts.push(`<text x="${x[2] + colW - 64}" y="${by + 17}" font-size="11" fill="${C.text}" font-family="sans-serif">${s.pass_rate == null ? '?' : s.pass_rate + ' %'} · ${s.minutes} ${esc(L.minutes)}</text>`)
291 })
292 return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${h}" viewBox="0 0 ${width} ${h}">${parts.join('')}</svg>`
293}
294
295// ---- user-friendly layer: headline sentence, ETA, Gantt, log commands ---------------------------------------------
296
297/** A job or run that is red for real: failed and not allowed to fail. */
298export const isRed = (x: { status: RunStatus; allow_failure?: boolean }): boolean => x.status === 'failed' && !x.allow_failure
299
300export const clock = (iso: string | null | undefined): string => {
301 const t = iso ? new Date(iso) : null
302 if (!t || Number.isNaN(t.getTime())) return '?'
303 return `${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}`
304}
305
306/** ms of a past successful duration list in seconds. */
307const msOf = (xs: number[] | undefined) => (xs ?? []).filter(x => Number.isFinite(x)).map(x => x * 1000)
308
309/**
310 * End time (ISO) of the run, recomputed with lib/core/estimate.mjs when the scanner sent the samples (pipeline basis:
311 * the median of the lane's last successful pipelines from the run's creation, never before now), else the scanner's.
312 */
313export const etaOfRun = (r: Run, now: number): string | null => {
314 const e = r.estimate
315 if (!e) return null
316 if (e.basis === 'pipeline' && e.durations_s && r.created) {
317 const end = etaOf(estimateOf(msOf(e.durations_s)), Date.parse(r.created), now)
318 if (end) return new Date(end.mid).toISOString()
319 }
320 return e.eta
321}
322
323/** « vert ou rouge vers 22:40 (médiane de 5 pipelines) », or why there is no estimate. */
324export const etaText = (r: Run, now: number = Date.now()): string => {
325 const e = r.estimate
326 if (!e) return ''
327 const eta = etaOfRun(r, now)
328 if (!eta) return `${e.reason ?? "pas assez d'historique"} (${e.samples} run(s) connus, ${MIN_SAMPLES} au moins)`
329 const basis = e.basis === 'jobs' ? `médiane des jobs, ${e.samples} runs au moins` : `médiane de ${e.samples} pipelines`
330 return `vert ou rouge vers ${clock(eta)} (${basis})`
331}
332
333/** The command that prints the log of a red job (shown, never run by the mod). */
334export const logCommand = (r: Run, j?: Job): string =>
335 r.forge === 'github'
336 ? `gh run view ${r.id} -R ${r.slug} --log-failed`
337 : `glab ci trace ${j?.id ?? '<job>'} -R ${r.slug}`
338
339/** Newest run of each lane, filtered by who. */
340export const latestRuns = (d: Diagram, who: WhoFilter): Run[] => {
341 const m = new Map<string, Run>()
342 for (const r of runsOf(d, who)) {
343 const k = `${r.app}|${r.backend}`
344 const cur = m.get(k)
345 if (!cur || (r.created ?? '') > (cur.created ?? '')) m.set(k, r)
346 }
347 return [...m.values()]
348}
349
350/** Every running or queued pipeline (not only the newest of each lane), newest first. */
351export const liveRuns = (d: Diagram, who: WhoFilter): Run[] =>
352 runsOf(d, who).filter(r => r.status === 'running' || r.status === 'queued')
353 .sort((a, b) => (b.created ?? '').localeCompare(a.created ?? ''))
354
355/** One plain French sentence: « 2 pipelines en cours, 1 rouge dans datapass-react (job lint), runner local à 6/18 ». */
356export const headline = (d: Diagram, who: WhoFilter): string => {
357 const last = latestRuns(d, who)
358 const running = liveRuns(d, who).length
359 const reds = last.filter(isRed).sort((a, b) => (b.created ?? '').localeCompare(a.created ?? ''))
360 const parts: string[] = []
361 parts.push(running ? `${running} pipeline${running > 1 ? 's' : ''} en cours` : 'aucun pipeline en cours')
362 if (reds.length) {
363 const r = reds[0]!
364 const jobs = (r.jobs ?? []).filter(isRed).map(j => j.name)
365 const where = `${r.app}${jobs.length ? ` (job${jobs.length > 1 ? 's' : ''} ${jobs.slice(0, 2).join(', ')}${jobs.length > 2 ? '…' : ''})` : ''}`
366 const more = reds.length > 1 ? ` et ${reds.length - 1} autre${reds.length > 2 ? 's' : ''}` : ''
367 parts.push(`${reds.length} rouge${reds.length > 1 ? 's' : ''} dans ${where}${more}`)
368 } else parts.push('aucun rouge')
369 const q = d.summary?.queue
370 if (q && q.running != null) parts.push(`runner local à ${q.running}/${q.concurrent ?? '?'}`)
371 if (q && q.wait_s != null) parts.push(`attente runner ~${fmtDur(Math.round(q.wait_s))} (médiane de ${q.wait_n} jobs)`)
372 return parts.join(', ')
373}
374
375/**
376 * One Gantt row: the job, then a bar of `width` cells drawn by lib/core/gantt.mjs, the renderer factory's Workflow
377 * screen uses too (░ file, █ fini, ▓ en cours, ▒ échoué, ┄ estimation, │ maintenant), then its time. A running job's
378 * estimated end comes from lib/core/estimate.mjs over the samples the scanner sent (else the scanner's own end).
379 */
380export type GanttRow = { stage: string; name: string; cells: Seg[]; text: string; red: boolean }
381export const GANTT = SYMBOLS
382export const GANTT_LEGEND: string = ganttLegend()
383
384const ganttStatus = (j: Job): string => (j.status === 'cancelled' || j.status === 'skipped' ? 'canceled' : j.status === 'success' ? 'done' : j.status)
385
386/** Estimated end (ms) of a job: core estimate over its samples from its real start, else the scanner's end. */
387export const jobEnd = (r: Run, j: Job, now: number): number | null => {
388 const e = r.estimate?.jobs?.[j.name]
389 if (!e) return null
390 const started = j.started ? Date.parse(j.started) : NaN
391 if (j.status === 'running' && Number.isFinite(started) && e.samples_s) {
392 const end = etaOf(estimateOf(msOf(e.samples_s)), started, now)
393 if (end) return end.mid
394 }
395 return e.end ? Date.parse(e.end) : null
396}
397
398export const gantt = (r: Run, now: number, width = 28): { rows: GanttRow[]; start: number; end: number; axis: string; legend: string } => {
399 const t = (s: string | null | undefined) => (s ? Date.parse(s) : NaN)
400 const fin = (x: number) => (Number.isFinite(x) ? x : undefined)
401 const jobs = (r.jobs ?? []).filter(j => Number.isFinite(t(j.created)) || Number.isFinite(t(j.started)))
402 const g = coreGantt(jobs.map(j => ({
403 label: j.name,
404 status: ganttStatus(j),
405 queuedMs: fin(t(j.created)),
406 startMs: fin(t(j.started)),
407 endMs: fin(t(j.finished)),
408 estEndMs: jobEnd(r, j, now) ?? undefined,
409 })), now, width)
410 const rows: GanttRow[] = jobs.map((j, i) => {
411 const going = j.status === 'running' || j.status === 'queued'
412 const e = r.estimate?.jobs?.[j.name]
413 const end = jobEnd(r, j, now)
414 const cells: Seg[] = (g.rows[i]?.segs ?? []).map((sg: { text: string; kind: string }) => ({
415 text: sg.text, color: (COLORS as Record<string, string>)[sg.kind], dim: sg.kind === 'empty' || sg.kind === 'queue' || sg.kind === 'est',
416 }))
417 const tail = going
418 ? (end ? `fin ~${clock(new Date(end).toISOString())} (n=${e?.n})` : e ? `pas assez d'historique (n=${e.n})` : MARK[j.status])
419 : `${MARK[j.status]} ${fmtDur(j.duration_s)}${j.queued_s ? ` + ${fmtDur(j.queued_s)} en file` : ''}`
420 return { stage: j.stage, name: j.name, cells, text: tail, red: isRed(j) }
421 })
422 return { rows, start: g.t0, end: g.t1, axis: ganttAxis(g, 0), legend: GANTT_LEGEND }
423}
424
425export const parseDiagram = (text: string): Diagram | undefined => {
426 if (!text) return undefined
427 try {
428 const d = JSON.parse(text) as Diagram
429 return Array.isArray(d.apps) ? d : undefined
430 } catch {
431 return undefined
432 }
433}
434lib/ci/hooks/view.ts 109 lines1// Pure helpers of the ci mod: argument parsing and text. No `$`, so tests call them directly.
2import type { Backend, Labels, Route, Snapshot, Tri } from '../types'
3
4export const PANE = { id: 'ci-routing', title: 'CI routing' } as const
5
6export type Parsed =
7 | { kind: 'show'; refresh: boolean }
8 | { kind: 'route'; target: string; backend: string }
9 | { kind: 'clear'; target: string }
10 | { kind: 'diagram'; words: string[] }
11 | { kind: 'usage'; text: string }
12
13export const USAGE = 'usage: /ci [refresh] · /ci diagramme [refresh] [claude|codex|inconnu|tout] [hote|app|conv] · /ci route <repo-path|session-id|here|session> <backend-id> · /ci clear <repo-path|session-id|here|session>'
14
15/** `/ci`, `/ci refresh`, `/ci route <target> <backend>`, `/ci clear <target>`. Quotes keep a path with spaces whole. */
16export const parseArgs = (args: string): Parsed => {
17 const words = (args.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map(w => w.replace(/^["']|["']$/g, ''))
18 const [verb = '', ...rest] = words
19 const v = verb.toLowerCase()
20 if (v === '' || v === 'show' || v === 'status') return { kind: 'show', refresh: false }
21 if (v === 'refresh' || v === 'check') return { kind: 'show', refresh: true }
22 if (v === 'diagramme' || v === 'diagram' || v === 'diag' || v === 'pipelines') return { kind: 'diagram', words: rest }
23 if (v === 'route' || v === 'set') {
24 if (rest.length !== 2) return { kind: 'usage', text: USAGE }
25 return { kind: 'route', target: rest[0]!, backend: rest[1]! }
26 }
27 if (v === 'clear' || v === 'unset') {
28 if (rest.length !== 1) return { kind: 'usage', text: USAGE }
29 return { kind: 'clear', target: rest[0]! }
30 }
31 return { kind: 'usage', text: USAGE }
32}
33
34/** `here`/`repo` -> the session's directory, `session`/`this` -> the session id, anything else as typed. */
35export const resolveTarget = (target: string, cwd: string, sessionId: string): string => {
36 const t = target.toLowerCase()
37 if (t === 'here' || t === 'repo' || t === '.') return cwd
38 if (t === 'session' || t === 'this') return sessionId
39 return target
40}
41
42export const usableText = (labels: Labels, u: Tri | undefined): string => labels.usable?.[u ?? 'unknown'] ?? u ?? 'unknown'
43export const statusText = (labels: Labels, s: string): string => labels.status?.[s] ?? s
44
45/** Theme color of a backend row: its usability when active, its status otherwise. */
46export const rowColor = (labels: Labels, b: Backend): string => {
47 const key = b.status !== 'active' ? b.status : (b.check?.usable ?? 'unknown')
48 const fallback: Record<string, string> = { yes: 'success', no: 'error', unknown: 'subtle' }
49 return labels.colors?.[key] ?? fallback[key] ?? 'subtle'
50}
51
52export const mark = (b: Backend): string =>
53 b.status !== 'active' ? '·' : b.check?.usable === 'yes' ? '●' : b.check?.usable === 'no' ? '○' : '?'
54
55const n = (x: number | null | undefined) => (x == null ? '?' : String(x))
56
57/** One line of facts about a backend's check: token, runners, jobs, why. */
58export const checkLine = (b: Backend): string => {
59 const c = b.check
60 if (b.status !== 'active') return b.status
61 if (!c) return 'not checked yet'
62 const parts = [`token ${c.token.present}${c.token.source ? ` (${c.token.source})` : ''}`]
63 if (c.runners) parts.push(`runners ${c.runners.online} online / ${c.runners.total}`)
64 if (c.jobs) parts.push(`jobs ${n(c.jobs.running)} running, ${n(c.jobs.pending)} pending`)
65 for (const a of c.actions) parts.push(`${a.repo}: actions ${a.actions} (${a.why})`)
66 parts.push(c.why)
67 return parts.join(' · ')
68}
69
70export const routeLine = (s: Snapshot, r: Route): string => {
71 const b = s.backends.find(x => x.id === r.backend)
72 return `${r.kind} ${r.key} → ${b?.label ?? r.backend} (${r.backend}) · set by ${r.set_by} · ${r.set_at}`
73}
74
75/** The text /ci answers with. */
76export const summary = (s: Snapshot): string => {
77 const L = s.labels
78 const lines = [`${L.title ?? 'CI routing'} · checked ${s.generated ?? 'never'}${s.stale ? ' (refreshing)' : ''}`]
79 for (const b of s.backends) lines.push(`${mark(b)} ${b.id} · ${b.label} · ${statusText(L, b.status)} · ${b.status === 'active' ? usableText(L, b.check?.usable) : ''}${b.status === 'active' ? ' · ' : ''}${checkLine(b)}`.replace(/ · $/, ''))
80 const cur = s.current
81 lines.push(
82 cur
83 ? `${L.here ?? 'This conversation'}: ${cur.backend} (${cur.match} route ${cur.key}, set by ${cur.set_by} ${cur.set_at})`
84 : `${L.here ?? 'This conversation'}: ${L.no_route ?? 'no route'}`,
85 )
86 lines.push(`${s.routes.length} route(s) in ${s.routing_file}`)
87 return lines.join('\n')
88}
89
90/** The system-prompt note: the route as information, never an instruction to block anything. */
91export const promptNote = (s: Snapshot): string | undefined => {
92 const cur = s.current
93 if (!cur) return undefined
94 const b = s.backends.find(x => x.id === cur.backend)
95 const where = b ? `${b.label} (${b.kind}, ${b.host}${b.runner_tag ? `, runner tag ${b.runner_tag}` : ''})` : cur.backend
96 return `CI route (ci mod): this ${cur.match === 'session' ? 'conversation' : 'repo'} uses CI backend "${cur.backend}": ${where}. ` +
97 `Set by ${cur.set_by} at ${cur.set_at}. Use it for this work's pipelines unless told otherwise; /ci shows all backends and their status.`
98}
99
100export const parseSnapshot = (text: string): Snapshot | undefined => {
101 if (!text) return undefined
102 try {
103 const s = JSON.parse(text) as Snapshot
104 return Array.isArray(s.backends) ? s : undefined
105 } catch {
106 return undefined
107 }
108}
109lib/control/hooks/config_draw.tsx 193 lines1// control mod: the CONFIG pane's drawing (/config-claude), pure. No `$`: the elements come in as `ui`
2// ($.ui.resolve(e)), the index, the item texts already read and every press through `m`, so the CONFIG pane and the
3// Référence hub (mods/reference, a synced copy of this file) draw the same tree. Same output as the drawing that lived
4// in register.tsx before. The texts come from the scanner (scanner/config_scan.py), secrets already masked there.
5import type { ConfigData, ConfigItem } from '../types'
6import { languageOf, pageOf, searchConfig, sizeText } from './config_view'
7import { ageText, labelOf } from './view'
8
9export type ConfigModel = {
10 data: ConfigData | undefined
11 /** where the index is expected (shown when it is missing) */
12 out: string
13 labels?: Record<string, unknown>
14 viewerChars?: number
15 /** item id -> full text, for the open item (and every item while a search runs) */
16 contents: Record<string, string>
17 open: string[]
18 current: string
19 query: string
20 raw: boolean
21 page: number
22 busy: boolean
23 problem: string
24 now: number
25 onRescan: () => void
26 onFlip: (id: string) => void
27 onPick: (id: string) => void
28 onRaw: () => void
29 onPage: (delta: number) => void
30 onQuery: (v: string) => void
31}
32
33/** The shard numbers the drawing needs: the open item's, or all of them while a search runs. */
34export const shardsNeeded = (data: ConfigData | undefined, current: string, query: string): number[] => {
35 if (!data) return []
36 if (query.trim().length >= 2) return data.shards.map((_, i) => i)
37 for (const c of data.categories) for (const s of c.subs) for (const it of s.items) if (it.id === current && it.shard != null) return [it.shard]
38 return []
39}
40
41export function drawConfig(ui: any, m: ConfigModel): any {
42 const { Box, Text, Button, Input, Markdown, Code } = ui
43 const C = labelOf(m.labels)
44 const data = m.data
45 const header = (
46 <Box flexDirection="row" gap={1}>
47 <Text dimColor>
48 {m.busy ? 'scan en cours…' : `scan ${ageText(data?.generated, m.now)}`}
49 {m.problem ? ` · dernier scan en échec : ${m.problem}` : ''}
50 </Text>
51 <Button key="rescan" hotkey="r" plain dimColor onPress={() => m.onRescan()}>
52 Rescan
53 </Button>
54 </Box>
55 )
56 if (!data) {
57 return (
58 <Box flexDirection="column">
59 {header}
60 <Text wrap="wrap">Pas encore de données dans {m.out}/config-claude.json : appuie sur Rescan.</Text>
61 </Box>
62 )
63 }
64 const { open, current, query, raw, page } = m
65 const size = Math.max(2000, m.viewerChars ?? 60000)
66
67 const detail = (it: ConfigItem) => {
68 const text = it.shard != null ? m.contents[it.id] ?? '' : ''
69 const pg = pageOf(text, page, size)
70 const markdown = it.kind === 'md' && !raw
71 return (
72 <Box flexDirection="column" paddingLeft={2} marginBottom={1}>
73 {it.path ? <Text dimColor wrap="wrap">{`${C('path')} : ${it.path}`}</Text> : null}
74 {it.changed || it.size != null ? (
75 <Text dimColor>{[it.changed ? `${C('modified')} ${it.changed}` : '', it.size != null ? `${C('size')} ${sizeText(it.size)}` : ''].filter(Boolean).join(' · ')}</Text>
76 ) : null}
77 {it.meta.map(([k, v]) => (
78 <Text wrap="wrap">
79 <Text bold>{k} : </Text>
80 {v}
81 </Text>
82 ))}
83 {it.summary && !it.meta.some(([, v]) => v === it.summary) ? <Text wrap="wrap" dimColor>{it.summary}</Text> : null}
84 {it.flags.map(f => (
85 <Text color="warning" wrap="wrap">! {f.text}</Text>
86 ))}
87 {it.kind === 'none' || !text ? (
88 it.kind === 'none' ? null : <Text dimColor>{C('no_content')}</Text>
89 ) : (
90 <Box flexDirection="column">
91 <Box flexDirection="row" gap={1}>
92 {it.kind === 'md' ? (
93 <Button key={`raw-${it.id}`} plain dimColor onPress={() => m.onRaw()}>
94 {raw ? C('rendered') : C('raw')}
95 </Button>
96 ) : null}
97 {pg.pages > 1 ? <Text dimColor>{`${pg.page + 1}/${pg.pages}`}</Text> : null}
98 {pg.page > 0 ? (
99 <Button key={`prev-${it.id}`} plain dimColor onPress={() => m.onPage(-1)}>
100 {C('previous')}
101 </Button>
102 ) : null}
103 {pg.page < pg.pages - 1 ? (
104 <Button key={`next-${it.id}`} plain dimColor onPress={() => m.onPage(1)}>
105 {C('more')}
106 </Button>
107 ) : null}
108 </Box>
109 {markdown ? <Markdown text={pg.text} /> : <Code source={pg.text} language={languageOf(it)} />}
110 </Box>
111 )}
112 </Box>
113 )
114 }
115 const itemRow = (it: ConfigItem, label?: string) => (
116 <Box flexDirection="column">
117 <Button key={`it-${it.id}`} plain onPress={() => m.onPick(it.id)}>
118 {`${current === it.id ? '▾' : '▸'} ${label ?? it.name}${it.badge ? ` · ${it.badge}` : ''}${it.flags.length ? ' !' : ''}`}
119 </Button>
120 {current === it.id ? detail(it) : null}
121 </Box>
122 )
123 const byId = new Map<string, ConfigItem>()
124 for (const c of data.categories) for (const s of c.subs) for (const it of s.items) byId.set(it.id, it)
125
126 const body: unknown[] = []
127 if (query.trim().length >= 2) {
128 const { hits, total } = searchConfig(data, m.contents, query)
129 body.push(<Text bold>{`${total} ${C('results')}${total > hits.length ? ` (${hits.length} affichés)` : ''}`}</Text>)
130 for (const hit of hits) {
131 body.push(itemRow(hit.item, `${hit.item.name} · ${hit.where}`))
132 if (hit.snippet && current !== hit.item.id) body.push(<Text dimColor wrap="wrap">{` ${hit.snippet}`}</Text>)
133 }
134 } else {
135 const ch = open.includes('changes')
136 body.push(
137 <Button key="changes" plain onPress={() => m.onFlip('changes')}>
138 {`${ch ? '▾' : '▸'} ${C('changes')} (${data.changes_days} ${C('days')}) (${data.changes.length})`}
139 </Button>,
140 )
141 if (ch)
142 for (const x of data.changes) {
143 const it = byId.get(x.id)
144 if (it) body.push(itemRow(it, `${x.changed} · ${x.name} · ${x.where}`))
145 }
146 for (const c of data.categories) {
147 const co = open.includes(`cat:${c.id}`)
148 body.push(
149 <Button key={`cat-${c.id}`} plain onPress={() => m.onFlip(`cat:${c.id}`)}>
150 {`${co ? '▾' : '▸'} ${c.title} (${c.count})${c.badge ? ` · ${c.badge}` : ''}`}
151 </Button>,
152 )
153 if (!co) continue
154 for (const s of c.subs) {
155 const so = open.includes(`sub:${s.id}`)
156 body.push(
157 <Box paddingLeft={2}>
158 <Button key={`sub-${s.id}`} plain onPress={() => m.onFlip(`sub:${s.id}`)}>
159 {`${so ? '▾' : '▸'} ${s.title} (${s.items.length})`}
160 </Button>
161 </Box>,
162 )
163 if (!so) continue
164 for (const it of s.items) body.push(<Box paddingLeft={4}>{itemRow(it)}</Box>)
165 }
166 }
167 }
168 return (
169 <Box flexDirection="column">
170 {header}
171 <Text bold wrap="wrap">
172 {C('title')}
173 <Text dimColor>{data.project ? ` · ${C('project')} ${data.project}` : ''}</Text>
174 </Text>
175 <Box flexDirection="row">
176 <Box flexGrow={1}>
177 <Input
178 key="q"
179 label="/ "
180 placeholder={C('search')}
181 submitLabel={C('search')}
182 value={query}
183 onInput={(v: string) => m.onQuery(v)}
184 onSubmit={(v: string) => m.onQuery(v)}
185 />
186 </Box>
187 {query ? <Button key="clear" plain dimColor onPress={() => m.onQuery('')}>×</Button> : null}
188 </Box>
189 {body as never}
190 </Box>
191 )
192}
193lib/control/hooks/config_view.ts 61 lines1// Pure helpers of the CONFIG pane (/config-claude): search, paging, toggles. No `$`, so tests call them directly.
2import type { ConfigData, ConfigItem } from '../types'
3
4export type Hit = { item: ConfigItem; where: string; snippet: string }
5
6/** Open/close one id in a list of open ids. */
7export const toggle = (list: readonly string[], id: string): string[] =>
8 list.includes(id) ? list.filter(x => x !== id) : [...list, id]
9
10/** Every item with its 'category › sub-category' path. */
11export const allItems = (data: ConfigData): { item: ConfigItem; where: string }[] =>
12 data.categories.flatMap(c => c.subs.flatMap(s => s.items.map(item => ({ item, where: `${c.title} › ${s.title}` }))))
13
14const around = (text: string, at: number, len: number, span = 70): string => {
15 const a = Math.max(0, at - span)
16 const b = Math.min(text.length, at + len + span)
17 return `${a > 0 ? '…' : ''}${text.slice(a, b).replace(/\s+/g, ' ')}${b < text.length ? '…' : ''}`
18}
19
20/** Items whose name, path, summary, fields or full text contain every word of the query (case-insensitive). The
21 * snippet comes from the first place the first word appears. */
22export const searchConfig = (
23 data: ConfigData,
24 contents: Record<string, string>,
25 query: string,
26 limit = 50,
27): { hits: Hit[]; total: number } => {
28 const words = query.toLowerCase().split(/\s+/).filter(Boolean)
29 if (!words.length) return { hits: [], total: 0 }
30 const hits: Hit[] = []
31 let total = 0
32 for (const { item, where } of allItems(data)) {
33 const head = [item.name, item.path, item.summary, where, ...item.meta.map(([k, v]) => `${k} ${v}`)].join('\n')
34 const body = contents[item.id] ?? ''
35 const hay = `${head}\n${body}`.toLowerCase()
36 if (!words.every(w => hay.includes(w))) continue
37 total++
38 if (hits.length >= limit) continue
39 const inHead = head.toLowerCase().indexOf(words[0]!)
40 const inBody = body.toLowerCase().indexOf(words[0]!)
41 const snippet = inBody >= 0 && inHead < 0 ? around(body, inBody, words[0]!.length) : inHead >= 0 ? around(head, inHead, words[0]!.length) : ''
42 hits.push({ item, where, snippet })
43 }
44 return { hits, total }
45}
46
47/** One page of a long text: the pane draws at most `size` characters at once (a drawing holds 100,000). */
48export const pageOf = (text: string, page: number, size: number): { text: string; page: number; pages: number } => {
49 const pages = Math.max(1, Math.ceil(text.length / size))
50 const p = Math.min(Math.max(0, page), pages - 1)
51 return { text: text.slice(p * size, (p + 1) * size), page: p, pages }
52}
53
54/** 2048 -> '2.0 KB', 3_500_000 -> '3.3 MB'. */
55export const sizeText = (n: number | null | undefined): string =>
56 n == null ? '' : n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1048576).toFixed(1)} MB`
57
58/** The language the raw viewer highlights an item with. */
59export const languageOf = (item: ConfigItem): string | undefined =>
60 item.kind === 'json' ? 'json' : item.kind === 'md' ? 'markdown' : undefined
61lib/guide/hooks/draw.tsx 139 lines1// guide mod: the pane's drawing and the live « Nos mods » cards, pure. No `$`: the elements come in as `ui`
2// ($.ui.resolve(e)), files through a `read` callback built on $.fs in the register file, every press as `go`. The
3// guide pane and the Référence hub (mods/reference, a synced copy of this file) draw the same tree. Same output as the
4// drawing that lived in register.tsx before.
5import type { Doc, ModsCatalog } from '../types'
6import { card, modsMarkdown, modulePath } from './cards'
7import { between, locate, parse, slug } from './toc'
8
9export const SOMMAIRE = 'sommaire'
10export type Where = { tab: string; cat: string; sub: string }
11export type Read = (path: string) => Promise<string | undefined>
12
13/** The guide at `guidePath` with live cards for "Nos mods": catalog at `catalogPath`, mods under `modsDir`. */
14export async function liveDoc(read: Read, guidePath: string, catalogPath: string, modsDir: string, title: string): Promise<{ doc: Doc; problem: string }> {
15 const md = await read(guidePath)
16 if (!md) return { doc: { title, intro: '', tabs: [] }, problem: `cannot read ${guidePath}` }
17 let live = md
18 const raw = await read(catalogPath)
19 if (raw) {
20 try {
21 const catalog = JSON.parse(raw) as ModsCatalog
22 const cards = []
23 for (const entry of catalog.mods) {
24 const dir = `${modsDir}/${entry.dir}`
25 const manifest = await read(`${dir}/.claude-plugin/plugin.json`)
26 const rel = modulePath(await read(`${dir}/hooks/hooks.json`))
27 cards.push(card(entry, catalog, manifest, rel ? await read(`${dir}/${rel}`) : undefined))
28 }
29 live = between(md, 'mods', modsMarkdown(cards, catalog))
30 } catch {}
31 }
32 return { doc: parse(live), problem: '' }
33}
34
35/** `/mods-guide [onglet ou section]`: an anchor, or the start of a title. */
36export function findIn(doc: Doc, args: string): Where | undefined {
37 const want = slug(args)
38 if (!want) return undefined
39 const exact = locate(doc, want)
40 if (exact) return exact
41 for (const t of doc.tabs) {
42 if (t.id.startsWith(want)) return { tab: t.id, cat: '', sub: '' }
43 for (const c of t.cats) {
44 if (c.id.startsWith(want)) return { tab: t.id, cat: c.id, sub: '' }
45 for (const s of c.subs) if (s.id.startsWith(want)) return { tab: t.id, cat: c.id, sub: s.id }
46 }
47 }
48 return undefined
49}
50
51export type GuideModel = { doc: Doc; problem: string; tab: string; cat: string; sub: string; go: (to: Where) => void | Promise<void> }
52
53export function drawGuide(ui: any, m: GuideModel): any {
54 const { Box, Text, Button, Markdown } = ui
55 const { doc, problem, cat, sub, go } = m
56 const current = doc.tabs.find(t => t.id === m.tab)
57 const on_ = (yes: boolean) => (yes ? {} : { plain: true as const })
58
59 const tabs = (
60 <Box flexDirection="row" gap={1} flexWrap="wrap">
61 <Button key="tab-sommaire" {...on_(!current)} onPress={() => go({ tab: SOMMAIRE, cat: '', sub: '' })}>
62 Sommaire
63 </Button>
64 {doc.tabs.map(t => (
65 <Button key={`tab-${t.id}`} {...on_(t.id === current?.id)} onPress={() => go({ tab: t.id, cat: '', sub: '' })}>
66 {t.title}
67 </Button>
68 ))}
69 </Box>
70 )
71
72 if (problem) return <Text color="error">{problem}</Text>
73
74 if (!current) {
75 return (
76 <Box flexDirection="column" gap={1}>
77 {tabs}
78 <Text bold>{`Sommaire général · ${doc.title}`}</Text>
79 {doc.intro ? <Markdown text={doc.intro.replace(/^Doc officielle.*$/m, '').trim()} /> : null}
80 <Box flexDirection="column">
81 {doc.tabs.map(t => (
82 <Box flexDirection="column">
83 <Button key={`go-${t.id}`} plain bold onPress={() => go({ tab: t.id, cat: '', sub: '' })}>
84 {t.title}
85 </Button>
86 {t.cats.map(c => (
87 <Box flexDirection="column" paddingLeft={2}>
88 <Button key={`go-${c.id}`} plain onPress={() => go({ tab: t.id, cat: c.id, sub: '' })}>
89 {c.title}
90 </Button>
91 {c.subs.map(s => (
92 <Box paddingLeft={2}>
93 <Button key={`go-${s.id}`} plain dimColor onPress={() => go({ tab: t.id, cat: c.id, sub: s.id })}>
94 {s.title}
95 </Button>
96 </Box>
97 ))}
98 </Box>
99 ))}
100 </Box>
101 ))}
102 </Box>
103 </Box>
104 )
105 }
106
107 return (
108 <Box flexDirection="column" gap={1}>
109 {tabs}
110 <Text bold>{current.title}</Text>
111 {current.intro ? <Markdown text={current.intro} /> : null}
112 {current.cats.map(c => {
113 const open = c.id === cat
114 return (
115 <Box flexDirection="column">
116 <Button key={`cat-${c.id}`} plain bold onPress={() => go({ tab: current.id, cat: open ? '' : c.id, sub: '' })}>
117 {`${open ? '▾' : '▸'} ${c.title}${open ? '' : ` (${c.subs.length})`}`}
118 </Button>
119 {open && (
120 <Box flexDirection="column" paddingLeft={2} gap={1}>
121 {c.body ? <Markdown text={c.body} /> : null}
122 {c.subs.map(s => (
123 <Box flexDirection="column">
124 <Text bold {...(s.id === sub ? { color: 'success' as const } : {})}>{`${s.id === sub ? '→ ' : ''}${s.title}`}</Text>
125 {s.body ? <Markdown text={s.body} /> : null}
126 </Box>
127 ))}
128 </Box>
129 )}
130 </Box>
131 )
132 })}
133 <Button key="back" plain dimColor onPress={() => go({ tab: SOMMAIRE, cat: '', sub: '' })}>
134 ← Sommaire
135 </Button>
136 </Box>
137 )
138}
139lib/sante/hooks/draw.tsx 110 lines1// sante mod: the pane's drawing, pure. No `$`: the elements come in as `ui` ($.ui.resolve(e)) and every action as a
2// callback in `m`, so the sante pane and the Référence hub (mods/reference, a synced copy of this file) draw the same
3// tree from the same report. Same output as the drawing that lived in register.tsx before.
4import { evaluate, fill, fmtAgo, fmtBytes, fmtDur, fmtNum } from './logic.mjs'
5
6export type SanteModel = {
7 report: any
8 error: string
9 busy: boolean
10 copied: string
11 commands: any[]
12 engine: string
13 cfg: any
14 now: number
15 homeDir: string
16 onRefresh: () => void
17 onCopy: (cmd: string, press: any) => void | Promise<void>
18}
19
20export function drawSante(ui: any, m: SanteModel): any {
21 const { Box, Text, Button } = ui
22 const { cfg, now, homeDir } = m
23 const l = cfg.labels ?? {}
24 const colors = cfg.colors ?? {}
25 const icons = cfg.icons ?? {}
26 const color = (lv: string) => (colors[lv] ? { color: colors[lv] } : {})
27 const busy = m.busy ? l.busy ?? 'examen en cours…' : ''
28 if (!m.report) {
29 return (
30 <Box flexDirection="column" gap={1}>
31 <Text>{l.first_scan ?? 'Premier examen des mods en cours…'}</Text>
32 {m.error ? <Text color="error">{`scan : ${m.error}`}</Text> : null}
33 {busy ? <Text dimColor>{busy}</Text> : null}
34 </Box>
35 )
36 }
37 const { rows, summary } = evaluate(m.report, { commands: m.commands, engine: m.engine, cfg, now })
38 const active = rows.filter((r: any) => r.level !== 'off')
39 const off = rows.filter((r: any) => r.level === 'off')
40 const SRC: Record<string, string> = { env: 'CLAUDE_CODE_PLUGIN_DIRS', dir: 'dossier', marketplace: 'marketplace', health: 'fiche santé seule' }
41 const src = (s: string) => l[`source_${s}`] ?? SRC[s] ?? s
42
43 const modBlock = (r: any) => {
44 const head = [
45 `${icons[r.level] ?? '●'} ${r.name}${r.version ? ` ${r.version}` : ''}`,
46 r.loaded ? l.loaded ?? 'chargé' : l.not_loaded ?? 'non chargé',
47 src(r.source),
48 r.commands.length ? r.commands.map((c: string) => `/${c}`).join(' ') : l.no_commands ?? 'aucune commande',
49 fill(l.min_version ?? 'minimum {min}', { min: r.minVersion ?? '?' }),
50 ].join(' · ')
51 const badJson = r.configs.filter((c: any) => !c.ok).length
52 const facts = [
53 r.lastRefresh ? `${l.refresh ?? 'dernier rafraîchissement'} ${fmtAgo(r.lastRefresh, now)}` : l.no_refresh ?? 'aucun cache connu',
54 r.caches.length ? `${l.cache ?? 'cache'} ${fmtBytes(r.cacheBytes)}${r.caches.some((c: any) => c.truncated) ? '+' : ''} (${r.caches.length})` : '',
55 r.configs.length ? `${l.configs ?? 'config'} ${badJson ? `${badJson}/${r.configs.length} invalides` : fill(l.configs_ok ?? '{n} JSON valides', { n: r.configs.length })}` : '',
56 ].filter(Boolean).join(' · ')
57 const hr = r.health
58 const healthLine = hr
59 ? fill(l.health ?? 'dernier scan {ago} · {dur} · CPU {cpu}', { ago: fmtAgo(hr.lastRun, now), dur: fmtDur(hr.durationMs), cpu: fmtDur(hr.cpuMs) })
60 : l.no_health ?? 'pas de fiche santé'
61 const cost = r.cost
62 ? fill(l.cost ?? '24 h : {runs} runs ({rate}/h) · CPU {cpu} ≈ {core} % d\'un cœur', {
63 runs: fmtNum(hr.window.runs), rate: fmtNum(r.cost.runsPerHour, 1), cpu: fmtDur(r.cost.cpuMs), core: fmtNum(r.cost.corePct, 2),
64 })
65 : ''
66 return (
67 <Box key={`mod-${r.name}`} flexDirection="column">
68 <Text {...color(r.level)} bold>{head}</Text>
69 {r.reasons.map((x: any) => <Text {...color(x.level)}>{` ↳ ${x.text}`}</Text>)}
70 <Text dimColor>{` ${facts}`}</Text>
71 <Text dimColor>{` ${healthLine}${cost ? ` · ${cost}` : ''}`}</Text>
72 {r.cleanup.map((cmd: string, i: number) => (
73 <Box key={`clean-${r.name}-${i}`} flexDirection="row" gap={1} flexWrap="wrap">
74 <Text>{` ${l.run_yourself ?? 'à lancer toi-même :'} ${cmd}`}</Text>
75 <Button key={`copy-${r.name}-${i}`} plain onPress={(press: any) => m.onCopy(cmd, press)}>
76 {l.copy ?? 'Copier'}
77 </Button>
78 </Box>
79 ))}
80 </Box>
81 )
82 }
83
84 return (
85 <Box flexDirection="column" gap={1}>
86 <Text {...color(summary.level)} bold>{`${icons[summary.level] ?? '●'} ${summary.text}`}</Text>
87 <Box flexDirection="row" gap={1} flexWrap="wrap">
88 <Text dimColor>{fill(l.scanned ?? 'examiné {ago} · {n} dossiers', { ago: fmtAgo(m.report.generated, now), n: rows.length }) + (busy ? ` · ${busy}` : '') + (m.copied ? ` · ${m.copied}` : '')}</Text>
89 <Button key="refresh" plain onPress={() => m.onRefresh()}>{l.refresh_button ?? '↻'}</Button>
90 </Box>
91 {m.error ? <Text color="error">{`scan : ${m.error}`}</Text> : null}
92 <Box flexDirection="column" gap={1}>
93 <Text bold>{l.section_active ?? 'Mods actifs'}</Text>
94 {active.map(modBlock)}
95 </Box>
96 {off.length ? (
97 <Box flexDirection="column">
98 <Text bold>{l.section_off ?? 'Installés, non chargés'}</Text>
99 {off.map((r: any) => (
100 <Text {...color('off')}>{`${icons.off ?? '○'} ${r.name}${r.version ? ` ${r.version}` : ''} · ${src(r.source)}${r.dir ? ` · ${(homeDir && r.dir.startsWith(homeDir) ? `~${r.dir.slice(homeDir.length)}` : r.dir)}` : ''}`}</Text>
101 ))}
102 </Box>
103 ) : null}
104 <Box flexDirection="column">
105 {(l.notes ?? []).map((x: string) => <Text dimColor>{`· ${x}`}</Text>)}
106 </Box>
107 </Box>
108 )
109}
110lib/sante/hooks/logic.mjs 235 lines1// sante mod ("Santé des mods"): pure logic, no $ and no I/O, so node --test covers it directly.
2// The scanner (scanner/sante_scan.mjs) gathers the file facts; the hook adds the session's commands and the engine
3// version; evaluate() turns both into one row per mod with a level (ok / warn / error / off) and its reasons.
4
5/** Deep merge of config objects (arrays and scalars of `b` replace). */
6export function deepMerge(a, b) {
7 if (Array.isArray(b)) return b.slice()
8 if (!b || typeof b !== 'object') return b === undefined ? a : b
9 const out = { ...(a && typeof a === 'object' && !Array.isArray(a) ? a : {}) }
10 for (const [k, v] of Object.entries(b)) out[k] = deepMerge(out[k], v)
11 return out
12}
13
14/** `~` to the home folder, forward slashes. */
15export const expandHome = (p, home) => String(p || '').replace(/^~(?=[\\/]|$)/, home || '~').replace(/\\/g, '/')
16
17/** `{name}` placeholders from `vars`. */
18export const fill = (s, vars = {}) => String(s ?? '').replace(/\{(\w+)\}/g, (m, k) => (vars[k] === undefined ? m : String(vars[k])))
19
20/**
21 * CLAUDE_CODE_PLUGIN_DIRS as a list: `;` separates on Windows, `:` elsewhere (reference: plugins/mods/reference).
22 * A value with no `;` that starts with a drive letter is one Windows path, not two.
23 */
24export function splitDirs(value) {
25 const s = String(value || '').trim()
26 if (!s) return []
27 const parts = s.includes(';') ? s.split(';') : /^[A-Za-z]:[\\/]/.test(s) ? [s] : s.split(':')
28 return parts.map(p => p.trim().replace(/^"|"$/g, '').replace(/\\/g, '/').replace(/\/+$/, '')).filter(Boolean)
29}
30
31/** Compares two versions by their first three numbers (2.1.287); undefined when one has none. */
32export function cmpVersion(a, b) {
33 const pa = String(a || '').match(/(\d+)\.(\d+)\.(\d+)/)
34 const pb = String(b || '').match(/(\d+)\.(\d+)\.(\d+)/)
35 if (!pa || !pb) return undefined
36 for (let i = 1; i <= 3; i++) {
37 const d = Number(pa[i]) - Number(pb[i])
38 if (d) return d < 0 ? -1 : 1
39 }
40 return 0
41}
42
43/** Slash commands a hooks module registers: `$.command.register({ name: 'x' ...`. */
44export function registeredCommands(source) {
45 const out = []
46 const re = /command\.register\(\s*\{[^}]*?\bname\s*:\s*['"`]([\w-]{1,64})['"`]/g
47 const code = String(source || '').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:\\])\/\/.*$/gm, '$1')
48 for (const m of code.matchAll(re)) if (!out.includes(m[1])) out.push(m[1])
49 return out
50}
51
52/** Value at a dotted path (`meta.errors`). */
53export function getPath(obj, path) {
54 let v = obj
55 for (const k of String(path || '').split('.').filter(Boolean)) {
56 if (!v || typeof v !== 'object') return undefined
57 v = v[k]
58 }
59 return v
60}
61
62/** Number with French separators: 1 234,5. */
63export function fmtNum(v, digits = 0) {
64 if (v === null || v === undefined || !Number.isFinite(v)) return '—'
65 const s = Math.abs(v).toFixed(digits).replace('.', ',')
66 const [i, d] = s.split(',')
67 const out = i.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') + (d ? `,${d}` : '')
68 return v < 0 ? `-${out}` : out
69}
70
71/** Bytes as 12 o / 3,4 Ko / 1,2 Mo / 2,0 Go. */
72export function fmtBytes(n) {
73 if (!Number.isFinite(n)) return '—'
74 if (n < 1024) return `${n} o`
75 if (n < 1024 ** 2) return `${fmtNum(n / 1024, 1)} Ko`
76 if (n < 1024 ** 3) return `${fmtNum(n / 1024 ** 2, 1)} Mo`
77 return `${fmtNum(n / 1024 ** 3, 1)} Go`
78}
79
80/** A duration in ms as 850 ms / 2,3 s / 4 min / 3 h / 2 j. */
81export function fmtDur(ms) {
82 if (!Number.isFinite(ms) || ms < 0) return '—'
83 if (ms < 1000) return `${Math.round(ms)} ms`
84 if (ms < 60000) return `${fmtNum(ms / 1000, 1)} s`
85 if (ms < 3600000) return `${Math.round(ms / 60000)} min`
86 if (ms < 48 * 3600000) return `${Math.round(ms / 3600000)} h`
87 return `${Math.round(ms / 86400000)} j`
88}
89
90/** « il y a 4 min » from a time (ms or ISO) and now. */
91export function fmtAgo(t, now) {
92 const ms = typeof t === 'string' ? Date.parse(t) : t
93 if (!Number.isFinite(ms) || !ms) return 'jamais'
94 return `il y a ${fmtDur(Math.max(0, now - ms))}`
95}
96
97/** A time (ms or ISO) as ms, or undefined. */
98export const toMs = t => {
99 const ms = typeof t === 'string' ? Date.parse(t) : t
100 return Number.isFinite(ms) && ms > 0 ? ms : undefined
101}
102
103/**
104 * The shared health record (convention in mods/sante/README.md), as the next run writes it: last run, its duration,
105 * CPU and error, the last success, and a rolling 24 h window (runs, CPU, duration) for the approximate CPU cost.
106 * Reference implementation; band, usage, metrics, ci and machine carry their own few-line copy.
107 */
108export function nextHealth(prev, { mod, now, durationMs, cpuMs, error }, windowMs = 86400000) {
109 const p = prev && typeof prev === 'object' ? prev : {}
110 const start = toMs(p.window?.start)
111 const w = start && now - start < windowMs ? p.window : { start: new Date(now).toISOString(), runs: 0, cpuMs: 0, durationMs: 0, errors: 0 }
112 const iso = new Date(now).toISOString()
113 return {
114 mod,
115 lastRun: iso,
116 durationMs: Math.round(durationMs),
117 cpuMs: Math.round(cpuMs),
118 error: error ? String(error).slice(0, 500) : null,
119 lastOk: error ? p.lastOk ?? null : iso,
120 window: { start: w.start, runs: w.runs + 1, cpuMs: Math.round(w.cpuMs + cpuMs), durationMs: Math.round(w.durationMs + durationMs), errors: (w.errors || 0) + (error ? 1 : 0) },
121 }
122}
123
124/** CPU cost of a health record's window: ms of CPU, its share of one core over the window, runs per hour. */
125export function cpuCost(rec, now) {
126 const start = toMs(rec?.window?.start)
127 if (!start || !rec.window.runs) return undefined
128 const span = Math.max(60000, now - start)
129 return { cpuMs: rec.window.cpuMs || 0, corePct: ((rec.window.cpuMs || 0) / span) * 100, runsPerHour: (rec.window.runs / span) * 3600000, avgMs: (rec.window.durationMs || 0) / rec.window.runs, spanMs: span }
130}
131
132const RANK = { off: 0, ok: 1, warn: 2, error: 3 }
133const worst = (a, b) => (RANK[b] > RANK[a] ? b : a)
134
135/** Commands of this session that belong to the plugin `name` (or to nobody while one of its own names matches). */
136export function commandsOf(name, commands, expected = []) {
137 return (commands || [])
138 .filter(c => c.source === undefined || c.source === 'plugin')
139 .filter(c => (c.plugin ? c.plugin === name || c.plugin.startsWith(`${name}@`) : expected.includes(c.name)))
140 .map(c => c.name)
141}
142
143/**
144 * One row per mod: the scanner's facts + this session's commands + the engine version → level and reasons.
145 * report.mods[]: { key, name, dir, source: 'env' | 'dir' | 'marketplace' | 'health', exists, manifest, hooks, expected,
146 * configs[], caches[], logs[], fields[], health }
147 */
148export function evaluate(report, { commands = [], engine, cfg = {}, now = Date.now() } = {}) {
149 const t = cfg.thresholds || {}
150 const L = cfg.labels || {}
151 const lab = (k, d, v) => fill(L[k] ?? d, v)
152 const mods = (report && report.mods) || []
153 const names = mods.map(m => m.name)
154 const rows = mods.map(m => {
155 const per = (cfg.mods || {})[m.name] || {}
156 const reasons = []
157 let level = 'ok'
158 const add = (lv, text) => {
159 level = worst(level, lv)
160 reasons.push({ level: lv, text })
161 }
162 const expected = m.expected || []
163 const cmds = commandsOf(m.name, commands, expected)
164 const loaded = cmds.length > 0
165 const inEnv = m.source === 'env'
166 const minVersion = per.min_version || m.manifest?.min_version || cfg.default_min_version
167 const cmp = engine ? cmpVersion(engine, minVersion) : undefined
168
169 if (inEnv && !m.exists) add('error', lab('r_missing', 'dossier introuvable : {dir}', { dir: m.dir }))
170 if (m.exists && m.manifest && !m.manifest.ok) add('error', lab('r_manifest', 'plugin.json illisible : {error}', { error: m.manifest.error }))
171 if (m.exists && m.hooks && !m.hooks.ok) add('error', lab('r_hooks', 'hooks : {error}', { error: m.hooks.error }))
172 for (const c of m.configs || []) if (!c.ok) add('error', lab('r_json', 'JSON invalide : {path} ({error})', { path: c.path, error: c.error }))
173 if (cmp !== undefined && cmp < 0 && (inEnv || loaded)) add('error', lab('r_version', 'Claude Code {engine} < minimum {min}', { engine, min: minVersion }))
174 if (inEnv && m.exists && expected.length && !loaded) add('warn', lab('r_not_loaded', 'dans CLAUDE_CODE_PLUGIN_DIRS mais aucune commande enregistrée (/{cmds}) : session ouverte avant ? hook session.start en erreur ?', { cmds: expected.join(', /') }))
175 const missing = loaded ? expected.filter(c => !cmds.includes(c)) : []
176 if (missing.length) add('warn', lab('r_cmd_missing', 'commande non enregistrée : /{cmds}', { cmds: missing.join(', /') }))
177 if (names.filter(n => n === m.name).length > 1 && m.source !== 'health') add('warn', lab('r_dup', 'même nom chargé deux fois'))
178
179 const h = m.health
180 if (h && h.invalid) add('error', lab('r_health_json', 'fiche santé illisible : {error}', { error: h.invalid }))
181 else if (h && h.error) {
182 const age = now - (toMs(h.lastRun) || 0)
183 if (age <= (t.health_error_hours ?? 24) * 3600000) add('error', lab('r_health_error', 'dernier scan en erreur ({ago}) : {error}', { ago: fmtAgo(h.lastRun, now), error: h.error }))
184 }
185 const staleMin = per.stale_min ?? null
186 const newest = Math.max(0, ...(m.caches || []).map(c => c.newest || 0), toMs(h?.lastOk) || 0)
187 if (staleMin && (inEnv || loaded) && newest && now - newest > staleMin * 60000) add('warn', lab('r_stale', 'pas rafraîchi depuis {ago} (attendu < {min} min)', { ago: fmtDur(now - newest), min: staleMin }))
188 const warnBytes = (per.cache_warn_mb ?? t.cache_warn_mb ?? 500) * 1024 * 1024
189 const big = (m.caches || []).filter(c => c.bytes > warnBytes)
190 for (const c of big) add('warn', lab('r_big', 'cache lourd : {path} {size}', { path: c.path, size: fmtBytes(c.bytes) }))
191 for (const l of m.logs || []) if (l.errors) add(per.log_level || 'warn', lab('r_log', '{n} erreur(s) dans {path} : {last}', { n: l.errors, path: l.path, last: l.last }))
192 for (const f of m.fields || []) if (f.count) add('warn', lab('r_field', '{n} erreur(s) dans {path} → {field} : {last}', { n: f.count, path: f.path, field: f.field, last: f.last }))
193
194 if (!inEnv && !loaded && level === 'ok') level = 'off'
195 const cost = h && !h.invalid ? cpuCost(h, now) : undefined
196 return {
197 name: m.name,
198 dir: m.dir,
199 source: m.source,
200 version: m.manifest?.version,
201 loaded,
202 inEnv,
203 commands: cmds,
204 expected,
205 minVersion,
206 level,
207 reasons,
208 lastRefresh: newest || undefined,
209 health: h && !h.invalid ? h : undefined,
210 cost,
211 cacheBytes: (m.caches || []).reduce((s, c) => s + (c.bytes || 0), 0),
212 caches: m.caches || [],
213 configs: m.configs || [],
214 cleanup: big.map(c => fill(cfg.cleanup_command || 'Remove-Item -LiteralPath "{path}" -Recurse', { path: c.path })),
215 }
216 })
217 return { rows, summary: summarize(rows, { engine, report, cfg }) }
218}
219
220/** The single top line: worst level among the mods that should run, with counts and the first reasons. */
221export function summarize(rows, { engine, report, cfg = {} } = {}) {
222 const L = cfg.labels || {}
223 const active = rows.filter(r => r.level !== 'off')
224 const count = lv => active.filter(r => r.level === lv).length
225 let level = active.reduce((a, r) => worst(a, r.level), 'ok')
226 if (report && report.error) level = 'error'
227 const bad = active.filter(r => r.level === 'error' || r.level === 'warn').sort((a, b) => RANK[b.level] - RANK[a.level])
228 const first = bad.slice(0, cfg.summary_reasons ?? 2).map(r => `${r.name} : ${r.reasons.find(x => x.level === r.level)?.text ?? ''}`)
229 const word = { ok: L.level_ok ?? 'vert', warn: L.level_warn ?? 'orange', error: L.level_error ?? 'rouge' }[level]
230 const text = fill(L.summary ?? '{word} · {active} mods actifs : {ok} OK, {warn} orange, {error} rouge · {off} non chargés · Claude Code {engine}', {
231 word, active: active.length, ok: count('ok'), warn: count('warn'), error: count('error'), off: rows.length - active.length, engine: engine || '?',
232 })
233 return { level, text: [text, ...first, ...(report && report.error ? [`scan : ${report.error}`] : [])].join(' · ') }
234}
235lib/scenarios/hooks/apply_io.ts 48 lines1// « Appliquer ce scénario », file side, used by the scenarios pane and the Référence hub (synced copy). Writes only
2// ~/.claude/scenarios/applied.json and applied.prev.json; reads ~/.claude/settings.json and ~/.claude/agents/*.md.
3// Never writes settings.json, an agent file or the environment. A hooks module may not pass `$` across an import, so
4// the caller hands in `io`: its own read (text or undefined) and write, built on $.fs in its register file.
5import type { Catalog, Scenario } from '../types'
6import { agentsOf, appliedRecord, diffApplied, frontmatter } from './apply.ts'
7import type { Applied, Current, DiffLine } from './apply.ts'
8
9export type Io = { read: (path: string) => Promise<string | undefined>; write: (path: string, text: string) => Promise<void> }
10const json = (t: string | undefined): any => {
11 try {
12 return t ? JSON.parse(t.replace(/^/, '')) : undefined
13 } catch {
14 return undefined
15 }
16}
17
18/** `<claudeDir>/scenarios`, where the record lives. */
19export const appliedDir = (claudeDir: string) => `${claudeDir.replace(/\\/g, '/').replace(/\/+$/, '')}/scenarios`
20
21/** What settings.json and the agent files a record names say now (read-only). */
22export async function readCurrent(io: Io, claudeDir: string, a: Applied): Promise<Current> {
23 const base = claudeDir.replace(/\\/g, '/').replace(/\/+$/, '')
24 const s = json(await io.read(`${base}/settings.json`))
25 const agents: Current['agents'] = {}
26 for (const name of agentsOf(a)) {
27 const md = await io.read(`${base}/agents/${name}.md`)
28 agents[name] = md === undefined ? undefined : frontmatter(md)
29 }
30 return { settings: s ? { model: s.model, effortLevel: s.effortLevel, env: s.env } : undefined, agents }
31}
32
33/** applied.json with its diff, or undefined when none was written. */
34export async function readApplied(io: Io, c: Catalog, claudeDir: string): Promise<{ record: Applied; diff: DiffLine[] } | undefined> {
35 const record = json(await io.read(`${appliedDir(claudeDir)}/applied.json`)) as Applied | undefined
36 if (!record || !Array.isArray(record.roles)) return undefined
37 return { record, diff: diffApplied(c, record, await readCurrent(io, claudeDir, record)) }
38}
39
40/** Keeps the previous record as applied.prev.json, writes the new one; returns the line the pane shows. */
41export async function applyScenario(io: Io, c: Catalog, s: Scenario, claudeDir: string, at: string): Promise<string> {
42 const dir = appliedDir(claudeDir)
43 const prev = await io.read(`${dir}/applied.json`)
44 if (prev !== undefined) await io.write(`${dir}/applied.prev.json`, prev)
45 await io.write(`${dir}/applied.json`, `${JSON.stringify(appliedRecord(c, s, at), null, 1)}\n`)
46 return `${s.id} noté dans ~/.claude/scenarios/applied.json${prev !== undefined ? ' (précédent : applied.prev.json)' : ''}`
47}
48lib/scenarios/hooks/draw.tsx 148 lines1// scenarios mod: the pane's drawing, pure. No `$`: the elements come in as `ui` ($.ui.resolve(e)) and every action as a
2// callback in `m`, so the scenarios pane and the Référence hub (mods/reference, a synced copy of this file) draw the
3// same tree. Same output as the drawing that lived in register.tsx, plus « Appliquer ce scénario » and its diff.
4import type { Catalog } from '../types'
5import type { Applied, DiffLine } from './apply'
6import { diffSummary } from './apply'
7import { KINDS, KIND_LABEL, limitsFor, roleLine, settingsFor, snippet } from './logic'
8import type { Kind } from './logic'
9
10export type ScenariosModel = {
11 catalog: Catalog
12 errors: string[]
13 warnings: string[]
14 selected: string
15 kind: Kind
16 copied: string
17 /** applied.json and its diff vs the agent files and settings, when one was written */
18 applied?: { record: Applied; diff: DiffLine[] } | undefined
19 /** outcome of the last « Appliquer » press */
20 applyMsg?: string
21 onSelect: (id: string) => void | Promise<void>
22 onKind: (k: Kind) => void | Promise<void>
23 onCopy: (text: string, label: string, press: any) => void | Promise<void>
24 onApply?: (id: string) => void | Promise<void>
25}
26
27export const kindsOf = (c: Catalog): Kind[] => {
28 const listed = c.snippets.filter((k): k is Kind => (KINDS as readonly string[]).includes(k))
29 return listed.length ? listed : [...KINDS]
30}
31
32export function drawScenarios(ui: any, m: ScenariosModel): any {
33 const { Box, Text, Button, Code, Link } = ui
34 const { catalog, errors, warnings } = m
35 const s = catalog.scenarios.find(x => x.id === m.selected) ?? catalog.scenarios[0]
36 const k = m.kind || 'prompt'
37 const kinds = kindsOf(catalog)
38 const url = (key: string) => catalog.sources[key] ?? ''
39 const cite = (key: string, verified = true) => (
40 <Box flexDirection="row" gap={1}>
41 {verified ? null : <Text color="warning">non vérifié</Text>}
42 {url(key) ? <Link href={url(key)} label={url(key)} /> : <Text dimColor>no source</Text>}
43 </Box>
44 )
45 const ap = m.applied
46
47 return (
48 <Box flexDirection="column" gap={1}>
49 <Text dimColor>Read-only guide. Edit scenarios.json or your own file (userConfig "file") to add S5 and more.</Text>
50 {errors.length > 0 && (
51 <Box flexDirection="column">
52 <Text color="error" bold>{`${errors.length} error(s): those scenarios are left out`}</Text>
53 {errors.map(x => <Text color="error">{`· ${x}`}</Text>)}
54 </Box>
55 )}
56 {warnings.length > 0 && (
57 <Box flexDirection="column">
58 {warnings.map(x => <Text color="warning">{`· ${x}`}</Text>)}
59 </Box>
60 )}
61 <Box flexDirection="row" gap={1} flexWrap="wrap">
62 {catalog.scenarios.map(x => (
63 <Button key={`sc-${x.id}`} {...(x.id !== s?.id ? { plain: true as const } : {})} onPress={() => m.onSelect(x.id)}>
64 {`${x.id} ${x.name}`}
65 </Button>
66 ))}
67 </Box>
68 {!s && <Text>No valid scenario. Fix the errors above.</Text>}
69 {s && (
70 <Box flexDirection="column" gap={1}>
71 <Text bold>{`${s.id} · ${s.name}`}</Text>
72 {s.summary ? <Text>{s.summary}</Text> : null}
73 <Text dimColor>
74 {`Escalation: ${s.escalation === false ? 'no' : 'after one failure, one step up'}` +
75 (s.caps && Object.keys(s.caps).length ? ` · caps: ${Object.entries(s.caps).map(([a, b]) => `${a.replace(/_/g, ' ')} ${b}`).join(', ')}` : '')}
76 </Text>
77 <Box flexDirection="column">
78 {s.roles.map(r => (
79 <Box flexDirection="column">
80 <Text bold>{roleLine(catalog, r)}</Text>
81 {settingsFor(catalog, r).map(line => (
82 <Box flexDirection="column">
83 <Text>{` ${line.text}`}</Text>
84 <Box flexDirection="row" gap={1}>
85 <Text dimColor>{' '}</Text>
86 {cite(line.source, line.verified)}
87 </Box>
88 </Box>
89 ))}
90 </Box>
91 ))}
92 </Box>
93 <Text bold>Documented limits</Text>
94 <Box flexDirection="column">
95 {limitsFor(catalog, s).map(l => (
96 <Box flexDirection="column">
97 <Text>{`· ${l.text}`}</Text>
98 <Box flexDirection="row" gap={1}>
99 <Text dimColor>{' '}</Text>
100 {cite(l.source, l.verified)}
101 </Box>
102 </Box>
103 ))}
104 </Box>
105 <Text bold>Snippet</Text>
106 <Box flexDirection="row" gap={1} flexWrap="wrap">
107 {kinds.map(x => (
108 <Button key={`kind-${x}`} {...(x !== k ? { plain: true as const } : {})} onPress={() => m.onKind(x)}>
109 {KIND_LABEL[x]}
110 </Button>
111 ))}
112 </Box>
113 <Code source={snippet(catalog, s, k)} language={k === 'frontmatter' ? 'yaml' : k === 'prompt' ? 'text' : 'javascript'} />
114 <Box flexDirection="row" gap={1}>
115 <Button key="copy" hotkey="c" onPress={(press: any) => m.onCopy(snippet(catalog, s, k), `${s.id} ${KIND_LABEL[k]}`, press)}>
116 Copy snippet
117 </Button>
118 {m.copied ? <Text dimColor>{m.copied}</Text> : null}
119 </Box>
120 {m.onApply ? (
121 <Box flexDirection="column">
122 <Text bold>Appliquer ce scénario</Text>
123 <Text dimColor wrap="wrap">
124 Écrit les modèles et efforts de chaque rôle dans ~/.claude/scenarios/applied.json (le précédent reste dans applied.prev.json) et montre l'écart avec tes agents et settings. Ne modifie jamais settings.json, les agents ni l'environnement.
125 </Text>
126 <Box flexDirection="row" gap={1}>
127 <Button key="apply" onPress={() => m.onApply!(s.id)}>{`Appliquer ${s.id}`}</Button>
128 {m.applyMsg ? <Text dimColor>{m.applyMsg}</Text> : null}
129 </Box>
130 </Box>
131 ) : null}
132 </Box>
133 )}
134 {ap ? (
135 <Box flexDirection="column">
136 <Text bold>{diffSummary(ap.record, ap.diff)}</Text>
137 {ap.diff.map((d, i) => (
138 <Text key={`diff-${i}`} wrap="wrap" {...(d.same ? { dimColor: true } : { color: 'warning' as const })}>
139 {`${d.same ? ' =' : ' ≠'} ${d.role} · ${d.where} · ${d.field} : ${d.current}${d.same ? '' : ` → ${d.applied}`}`}
140 </Text>
141 ))}
142 <Text dimColor wrap="wrap">{ap.record.note}</Text>
143 </Box>
144 ) : null}
145 </Box>
146 )
147}
148lib/scenarios/hooks/logic.ts 184 lines1// Pure logic of the scenarios mod: merge, validate, describe and turn a scenario into ready-to-paste snippets.
2// No `$`, no imports but types: the engine loads it from register.tsx and `node --test` loads it as plain TS.
3import type { Catalog, Checked, Limit, Role, Scenario, ScenarioFile } from '../types'
4
5export const KINDS = ['prompt', 'agent', 'workflow', 'frontmatter', 'env'] as const
6export type Kind = (typeof KINDS)[number]
7export const KIND_LABEL: Record<Kind, string> = {
8 prompt: 'Prompt line',
9 agent: 'Agent calls',
10 workflow: 'Workflow agent()',
11 frontmatter: 'Agent files',
12 env: 'Env vars',
13}
14
15const EMPTY: Catalog = { sources: {}, models: {}, efforts: [], units: {}, limits: [], snippets: [], scenarios: [] }
16
17/** Shipped file first, then the user's: tables merge by key, limits and scenarios by id (the later one wins). */
18export function merge(...files: (ScenarioFile | undefined)[]): Catalog {
19 const out: Catalog = { ...EMPTY, sources: {}, models: {}, units: {}, limits: [], scenarios: [] }
20 for (const f of files) {
21 if (!f || typeof f !== 'object') continue
22 Object.assign(out.sources, f.sources ?? {})
23 Object.assign(out.models, f.models ?? {})
24 Object.assign(out.units, f.units ?? {})
25 if (Array.isArray(f.efforts) && f.efforts.length) out.efforts = [...f.efforts]
26 if (Array.isArray(f.snippets) && f.snippets.length) out.snippets = [...f.snippets]
27 for (const l of Array.isArray(f.limits) ? f.limits : []) out.limits = [...out.limits.filter(x => x.id !== l?.id), l]
28 for (const s of Array.isArray(f.scenarios) ? f.scenarios : []) out.scenarios = [...out.scenarios.filter(x => x.id !== s?.id), s]
29 }
30 return out
31}
32
33const isFullId = (m: string) => /^claude-[a-z0-9.-]+$/.test(m)
34const rank = (c: Catalog, effort: string) => c.efforts.indexOf(effort)
35const leadOf = (s: Scenario) => s.roles.find(r => r.unit === 'session')
36
37/** Checks every scenario against the catalog's tables and the documented limits; a broken scenario is dropped with its errors. */
38export function check(c: Catalog): Checked {
39 const errors: string[] = []
40 const warnings: string[] = []
41 for (const [key, url] of Object.entries(c.sources)) if (!/^https:\/\//.test(String(url))) errors.push(`source ${key}: not an https URL`)
42 for (const l of c.limits) if (!l || !l.id || !l.text || !(l.source in c.sources)) errors.push(`limit ${l?.id ?? '?'}: needs id, text and a known source`)
43 const seen = new Set<string>()
44 const kept: Scenario[] = []
45 for (const s of c.scenarios) {
46 const where = `scenario ${s?.id ?? '?'}`
47 const own: string[] = []
48 if (!s || typeof s !== 'object') { errors.push(`${where}: not an object`); continue }
49 if (!s.id || typeof s.id !== 'string') own.push(`${where}: missing id`)
50 else if (seen.has(s.id)) own.push(`${where}: duplicate id`)
51 if (!s.name || typeof s.name !== 'string') own.push(`${where}: missing name`)
52 if (!Array.isArray(s.roles) || s.roles.length === 0) own.push(`${where}: needs at least one role`)
53 for (const r of Array.isArray(s.roles) ? s.roles : []) {
54 const at = `${where} role ${r?.role ?? '?'}`
55 if (!r || !r.role) { own.push(`${at}: missing role name`); continue }
56 if (!(r.model in c.models) && !isFullId(String(r.model))) own.push(`${at}: unknown model "${r.model}"`)
57 if (rank(c, r.effort) < 0) own.push(`${at}: effort "${r.effort}" is not one of ${c.efforts.join(', ')}`)
58 if (!(r.unit in c.units)) own.push(`${at}: unit "${r.unit}" is not one of ${Object.keys(c.units).join(', ')}`)
59 if (!Number.isInteger(r.max_parallel) || r.max_parallel < 1) own.push(`${at}: max_parallel must be a whole number >= 1`)
60 for (const l of c.limits) {
61 if (l.max != null && l.unit === r.unit && r.max_parallel > l.max) own.push(`${at}: max_parallel ${r.max_parallel} is above the limit "${l.text}"`)
62 }
63 }
64 if (own.length === 0) {
65 const lead = leadOf(s)
66 for (const r of s.roles) {
67 if (lead && r.unit === 'sub-conversation' && rank(c, r.effort) > rank(c, lead.effort)) {
68 warnings.push(`${where} role ${r.role}: a sub-conversation's effort (${r.effort}) is above its parent's (${lead.effort}); expected at most the parent's (non vérifié)`)
69 }
70 }
71 for (const l of c.limits) {
72 if (l.max == null || !l.unit) continue
73 const total = s.roles.filter(r => r.unit === l.unit).reduce((n, r) => n + r.max_parallel, 0)
74 if (total > l.max) warnings.push(`${where}: ${total} ${l.unit} at once if every role peaks together; limit "${l.text}"`)
75 }
76 }
77 if (s?.id) seen.add(s.id)
78 if (own.length) errors.push(...own)
79 else kept.push(s)
80 }
81 return { catalog: { ...c, scenarios: kept }, errors, warnings }
82}
83
84export const modelLabel = (c: Catalog, m: string) => c.models[m]?.label ?? m
85const roleName = (r: Role) => r.label ?? r.role
86const unitLabel = (c: Catalog, u: string) => c.units[u]?.label ?? u
87
88/** One line per role: what it runs on and as what. */
89export function roleLine(c: Catalog, r: Role): string {
90 return `${roleName(r)}: ${modelLabel(c, r.model)} · ${r.effort} · ${unitLabel(c, r.unit)} · max ${r.max_parallel}${r.note ? ` (${r.note})` : ''}`
91}
92
93/** The exact settings for a role, by the unit it runs as. Each line ends with the doc key it rests on. */
94export function settingsFor(c: Catalog, r: Role): { text: string; source: string; verified: boolean }[] {
95 const agent = r.agent ?? r.role
96 const out: { text: string; source: string; verified: boolean }[] = []
97 switch (r.unit) {
98 case 'session':
99 out.push({ text: `start the session with --model ${r.model} and /effort ${r.effort} (or "model" / "effortLevel" in settings); never switch a running session`, source: 'model-config', verified: true })
100 break
101 case 'sub-conversation':
102 out.push({ text: `start it on ${r.model}, effort ${r.effort}; its effort is expected to be at most the parent's`, source: 'model-config', verified: false })
103 break
104 case 'subagent':
105 out.push({ text: `Agent call: model: "${r.model}", effort: "${r.effort}", subagent_type: "${agent}"`, source: 'sub-agents', verified: true })
106 out.push({ text: `or agents/${agent}.md frontmatter: model: ${r.model}, effort: ${r.effort}`, source: 'sub-agents', verified: true })
107 break
108 case 'workflow-agent':
109 out.push({ text: `the model a script names for an agent() counts as the per-invocation model: ${r.model}`, source: 'workflows', verified: true })
110 out.push({ text: `agent() opts: { model: '${r.model}' } (option spelling not shown in the docs)`, source: 'workflows', verified: false })
111 out.push({ text: `effort ${r.effort}: no documented agent() option; set it in agents/${agent}.md frontmatter`, source: 'workflows', verified: false })
112 break
113 }
114 return out
115}
116
117const envDefault = (s: Scenario, unit: string) => s.roles.find(r => r.unit === unit)?.model
118
119/** A ready-to-paste snippet of one kind for one scenario. */
120export function snippet(c: Catalog, s: Scenario, kind: Kind): string {
121 const subs = s.roles.filter(r => r.unit === 'subagent')
122 const wfs = s.roles.filter(r => r.unit === 'workflow-agent')
123 switch (kind) {
124 case 'prompt': {
125 const caps = Object.entries(s.caps ?? {}).map(([k, v]) => `${k.replace(/_/g, ' ')} <= ${v}`)
126 return (
127 `Scenario ${s.id} "${s.name}": ` +
128 s.roles.map(r => `${roleName(r)} ${modelLabel(c, r.model)} ${r.effort} as ${unitLabel(c, r.unit)} (max ${r.max_parallel})`).join('; ') +
129 `. Every Agent and Workflow agent() call names its model.` +
130 (s.escalation === false ? ' No effort escalation.' : '') +
131 (caps.length ? ` Caps: ${caps.join(', ')}.` : '')
132 )
133 }
134 case 'agent':
135 if (!subs.length) return `// ${s.id}: no role runs as an Agent subagent`
136 return subs
137 .map(r => `// ${roleName(r)}, up to ${r.max_parallel} at once\nAgent({ description: "<3-5 words>", subagent_type: "${r.agent ?? r.role}", model: "${r.model}", effort: "${r.effort}", prompt: "<task>" })`)
138 .join('\n')
139 case 'workflow': {
140 if (!wfs.length) return `// ${s.id}: no role runs as a Workflow agent`
141 const lines = wfs.map(r => ` ${JSON.stringify(r.role)}: { model: '${r.model}' }, // effort ${r.effort}: agents/${r.agent ?? r.role}.md (agent() effort undocumented); max ${r.max_parallel} at once`)
142 return `// Scenario ${s.id} "${s.name}": opts for each agent() call\nconst ROLE_OPTS = {\n${lines.join('\n')}\n}`
143 }
144 case 'frontmatter': {
145 const files = [...subs, ...wfs]
146 if (!files.length) return `# ${s.id}: no agent roles`
147 return files.map(r => `# agents/${r.agent ?? r.role}.md\n---\nname: ${r.agent ?? r.role}\ndescription: ${roleName(r)}\nmodel: ${r.model}\neffort: ${r.effort}\n---`).join('\n\n')
148 }
149 case 'env': {
150 const env: Record<string, string> = {}
151 const sub = s.defaults?.subagent_model ?? envDefault(s, 'subagent')
152 const wf = s.defaults?.workflow_model ?? envDefault(s, 'workflow-agent')
153 if (sub) env.CLAUDE_CODE_SUBAGENT_MODEL = c.models[sub]?.id ?? sub
154 if (wf) env.CLAUDE_CODE_WORKFLOW_SUBAGENT_MODEL = c.models[wf]?.id ?? wf
155 return (
156 `// ~/.claude/settings.json: the model a call that names none falls back to (after its agent file's model)\n` +
157 (wf ? `// CLAUDE_CODE_WORKFLOW_SUBAGENT_MODEL: non vérifié (not in the workflows docs; workflow agents follow the subagent order)\n` : '') +
158 JSON.stringify({ env }, null, 2)
159 )
160 }
161 }
162}
163
164/** Limits that apply to a scenario: every limit with no unit, and those whose unit a role uses. */
165export function limitsFor(c: Catalog, s: Scenario): Limit[] {
166 const units = new Set(s.roles.map(r => r.unit))
167 return c.limits.filter(l => !l.unit || units.has(l.unit))
168}
169
170/** `/scenarios [id] [kind]`, in either order, case-insensitive. */
171export function parseArgs(args: string, ids: string[]): { id?: string; kind?: Kind } {
172 const out: { id?: string; kind?: Kind } = {}
173 for (const word of args.trim().split(/\s+/).filter(Boolean)) {
174 const low = word.toLowerCase()
175 const kind = KINDS.find(k => k === low)
176 if (kind) out.kind = kind
177 else out.id = ids.find(i => i.toLowerCase() === low) ?? word
178 }
179 return out
180}
181
182/** `~` and `%USERPROFILE%`-free path for a user's file. */
183export const expandHome = (p: string, home: string) => p.replace(/^~(?=[\\/]|$)/, home).replace(/\\/g, '/')
184