SLOPSHOPPER

reference

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…

panecommandprocesstimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · reference
│ ┃ Référence ✕ › fix the failing auth test and add an audit log call │ ┃ [ config ] guide scenarios sante ci essais r │ ┃ ⏺ Read(src/auth.ts) │ ┃ scan never r: Rescan ⎿ Read 6 lines │ ┃ Pas encore de données dans ⏺ Update(src/auth.ts) │ ┃ /Users/dev/.claude/effort-board/control/conf ⎿ Added 2 lines, removed 1 line │ ┃ ig-claude.json : appuie sur Rescan. ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /reference │ ⎿ reference: reference: Référence › config │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Référence
[ config ] guide scenarios sante ci essais rd repos scan never r: Rescan Pas encore de données dans /Users/dev/.claude/effort-board/control/config-claude.json : appuie sur Rescan.
README

reference — Référence

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.

CommandWhat it does
/referenceOpens 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-exportExporter pour audit (registered only when no loaded mod has that name): writes the dated zip and prints its path and counts.
/reference closeCloses 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.

Where each tab comes from

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.

TabView code (synced into lib/<mod>/)Data
Configcontrol/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.
Guideguide/hooks/draw.tsx, cards.ts, toc.ts, MODE-EMPLOI.md, mods.jsonThe copy of the guide; « Nos mods » cards rebuilt live from the mods beside this one.
Scénariosscenarios/hooks/draw.tsx, logic.ts, apply.ts, apply_io.ts, scenarios.jsonThe 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.
CIci/hooks/draw.tsx, diagram.ts, view.ts, core gantt.mjs and estimate.mjsci'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.

À essayer, R&D, Repos, Exporter pour audit

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 / buttonReadsWrites (on click only)
À essayerMarkdown 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&Drd.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.
Reposrepos (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.

Appliquer ce scénario

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.

Settings

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.

Load it

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.

Tests

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.

Source 23 files
hooks/register.tsx 646 lines
1// 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}
646
lib/ci/hooks/draw.tsx 299 lines
1// 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}
299
lib/ci/hooks/diagram.ts 434 lines
1// 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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}
434
lib/ci/hooks/view.ts 109 lines
1// 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}
109
lib/control/hooks/config_draw.tsx 193 lines
1// 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}
193
lib/control/hooks/config_view.ts 61 lines
1// 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
61
lib/guide/hooks/draw.tsx 139 lines
1// 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}
139
lib/sante/hooks/draw.tsx 110 lines
1// sante mod: the pane's drawing, pure. No `$`: the elements come in as `ui` ($.ui.resolve(e)) and every action as a
2// callback in `m`, so the sante pane and the Référence hub (mods/reference, a synced copy of this file) draw the same
3// tree from the same report. Same output as the drawing that lived in register.tsx before.
4import { evaluate, fill, fmtAgo, fmtBytes, fmtDur, fmtNum } from './logic.mjs'
5
6export type SanteModel = {
7  report: any
8  error: string
9  busy: boolean
10  copied: string
11  commands: any[]
12  engine: string
13  cfg: any
14  now: number
15  homeDir: string
16  onRefresh: () => void
17  onCopy: (cmd: string, press: any) => void | Promise<void>
18}
19
20export function drawSante(ui: any, m: SanteModel): any {
21  const { Box, Text, Button } = ui
22  const { cfg, now, homeDir } = m
23  const l = cfg.labels ?? {}
24  const colors = cfg.colors ?? {}
25  const icons = cfg.icons ?? {}
26  const color = (lv: string) => (colors[lv] ? { color: colors[lv] } : {})
27  const busy = m.busy ? l.busy ?? 'examen en cours…' : ''
28  if (!m.report) {
29    return (
30      <Box flexDirection="column" gap={1}>
31        <Text>{l.first_scan ?? 'Premier examen des mods en cours…'}</Text>
32        {m.error ? <Text color="error">{`scan : ${m.error}`}</Text> : null}
33        {busy ? <Text dimColor>{busy}</Text> : null}
34      </Box>
35    )
36  }
37  const { rows, summary } = evaluate(m.report, { commands: m.commands, engine: m.engine, cfg, now })
38  const active = rows.filter((r: any) => r.level !== 'off')
39  const off = rows.filter((r: any) => r.level === 'off')
40  const SRC: Record<string, string> = { env: 'CLAUDE_CODE_PLUGIN_DIRS', dir: 'dossier', marketplace: 'marketplace', health: 'fiche santé seule' }
41  const src = (s: string) => l[`source_${s}`] ?? SRC[s] ?? s
42
43  const modBlock = (r: any) => {
44    const head = [
45      `${icons[r.level] ?? '●'} ${r.name}${r.version ? ` ${r.version}` : ''}`,
46      r.loaded ? l.loaded ?? 'chargé' : l.not_loaded ?? 'non chargé',
47      src(r.source),
48      r.commands.length ? r.commands.map((c: string) => `/${c}`).join(' ') : l.no_commands ?? 'aucune commande',
49      fill(l.min_version ?? 'minimum {min}', { min: r.minVersion ?? '?' }),
50    ].join(' · ')
51    const badJson = r.configs.filter((c: any) => !c.ok).length
52    const facts = [
53      r.lastRefresh ? `${l.refresh ?? 'dernier rafraîchissement'} ${fmtAgo(r.lastRefresh, now)}` : l.no_refresh ?? 'aucun cache connu',
54      r.caches.length ? `${l.cache ?? 'cache'} ${fmtBytes(r.cacheBytes)}${r.caches.some((c: any) => c.truncated) ? '+' : ''} (${r.caches.length})` : '',
55      r.configs.length ? `${l.configs ?? 'config'} ${badJson ? `${badJson}/${r.configs.length} invalides` : fill(l.configs_ok ?? '{n} JSON valides', { n: r.configs.length })}` : '',
56    ].filter(Boolean).join(' · ')
57    const hr = r.health
58    const healthLine = hr
59      ? fill(l.health ?? 'dernier scan {ago} · {dur} · CPU {cpu}', { ago: fmtAgo(hr.lastRun, now), dur: fmtDur(hr.durationMs), cpu: fmtDur(hr.cpuMs) })
60      : l.no_health ?? 'pas de fiche santé'
61    const cost = r.cost
62      ? fill(l.cost ?? '24 h : {runs} runs ({rate}/h) · CPU {cpu} ≈ {core} % d\'un cœur', {
63          runs: fmtNum(hr.window.runs), rate: fmtNum(r.cost.runsPerHour, 1), cpu: fmtDur(r.cost.cpuMs), core: fmtNum(r.cost.corePct, 2),
64        })
65      : ''
66    return (
67      <Box key={`mod-${r.name}`} flexDirection="column">
68        <Text {...color(r.level)} bold>{head}</Text>
69        {r.reasons.map((x: any) => <Text {...color(x.level)}>{`   ↳ ${x.text}`}</Text>)}
70        <Text dimColor>{`   ${facts}`}</Text>
71        <Text dimColor>{`   ${healthLine}${cost ? ` · ${cost}` : ''}`}</Text>
72        {r.cleanup.map((cmd: string, i: number) => (
73          <Box key={`clean-${r.name}-${i}`} flexDirection="row" gap={1} flexWrap="wrap">
74            <Text>{`   ${l.run_yourself ?? 'à lancer toi-même :'} ${cmd}`}</Text>
75            <Button key={`copy-${r.name}-${i}`} plain onPress={(press: any) => m.onCopy(cmd, press)}>
76              {l.copy ?? 'Copier'}
77            </Button>
78          </Box>
79        ))}
80      </Box>
81    )
82  }
83
84  return (
85    <Box flexDirection="column" gap={1}>
86      <Text {...color(summary.level)} bold>{`${icons[summary.level] ?? '●'} ${summary.text}`}</Text>
87      <Box flexDirection="row" gap={1} flexWrap="wrap">
88        <Text dimColor>{fill(l.scanned ?? 'examiné {ago} · {n} dossiers', { ago: fmtAgo(m.report.generated, now), n: rows.length }) + (busy ? ` · ${busy}` : '') + (m.copied ? ` · ${m.copied}` : '')}</Text>
89        <Button key="refresh" plain onPress={() => m.onRefresh()}>{l.refresh_button ?? '↻'}</Button>
90      </Box>
91      {m.error ? <Text color="error">{`scan : ${m.error}`}</Text> : null}
92      <Box flexDirection="column" gap={1}>
93        <Text bold>{l.section_active ?? 'Mods actifs'}</Text>
94        {active.map(modBlock)}
95      </Box>
96      {off.length ? (
97        <Box flexDirection="column">
98          <Text bold>{l.section_off ?? 'Installés, non chargés'}</Text>
99          {off.map((r: any) => (
100            <Text {...color('off')}>{`${icons.off ?? '○'} ${r.name}${r.version ? ` ${r.version}` : ''} · ${src(r.source)}${r.dir ? ` · ${(homeDir && r.dir.startsWith(homeDir) ? `~${r.dir.slice(homeDir.length)}` : r.dir)}` : ''}`}</Text>
101          ))}
102        </Box>
103      ) : null}
104      <Box flexDirection="column">
105        {(l.notes ?? []).map((x: string) => <Text dimColor>{`· ${x}`}</Text>)}
106      </Box>
107    </Box>
108  )
109}
110
lib/sante/hooks/logic.mjs 235 lines
1// sante mod ("Santé des mods"): pure logic, no $ and no I/O, so node --test covers it directly.
2// The scanner (scanner/sante_scan.mjs) gathers the file facts; the hook adds the session's commands and the engine
3// version; evaluate() turns both into one row per mod with a level (ok / warn / error / off) and its reasons.
4
5/** Deep merge of config objects (arrays and scalars of `b` replace). */
6export function deepMerge(a, b) {
7  if (Array.isArray(b)) return b.slice()
8  if (!b || typeof b !== 'object') return b === undefined ? a : b
9  const out = { ...(a && typeof a === 'object' && !Array.isArray(a) ? a : {}) }
10  for (const [k, v] of Object.entries(b)) out[k] = deepMerge(out[k], v)
11  return out
12}
13
14/** `~` to the home folder, forward slashes. */
15export const expandHome = (p, home) => String(p || '').replace(/^~(?=[\\/]|$)/, home || '~').replace(/\\/g, '/')
16
17/** `{name}` placeholders from `vars`. */
18export const fill = (s, vars = {}) => String(s ?? '').replace(/\{(\w+)\}/g, (m, k) => (vars[k] === undefined ? m : String(vars[k])))
19
20/**
21 * CLAUDE_CODE_PLUGIN_DIRS as a list: `;` separates on Windows, `:` elsewhere (reference: plugins/mods/reference).
22 * A value with no `;` that starts with a drive letter is one Windows path, not two.
23 */
24export function splitDirs(value) {
25  const s = String(value || '').trim()
26  if (!s) return []
27  const parts = s.includes(';') ? s.split(';') : /^[A-Za-z]:[\\/]/.test(s) ? [s] : s.split(':')
28  return parts.map(p => p.trim().replace(/^"|"$/g, '').replace(/\\/g, '/').replace(/\/+$/, '')).filter(Boolean)
29}
30
31/** Compares two versions by their first three numbers (2.1.287); undefined when one has none. */
32export function cmpVersion(a, b) {
33  const pa = String(a || '').match(/(\d+)\.(\d+)\.(\d+)/)
34  const pb = String(b || '').match(/(\d+)\.(\d+)\.(\d+)/)
35  if (!pa || !pb) return undefined
36  for (let i = 1; i <= 3; i++) {
37    const d = Number(pa[i]) - Number(pb[i])
38    if (d) return d < 0 ? -1 : 1
39  }
40  return 0
41}
42
43/** Slash commands a hooks module registers: `$.command.register({ name: 'x' ...`. */
44export function registeredCommands(source) {
45  const out = []
46  const re = /command\.register\(\s*\{[^}]*?\bname\s*:\s*['"`]([\w-]{1,64})['"`]/g
47  const code = String(source || '').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:\\])\/\/.*$/gm, '$1')
48  for (const m of code.matchAll(re)) if (!out.includes(m[1])) out.push(m[1])
49  return out
50}
51
52/** Value at a dotted path (`meta.errors`). */
53export function getPath(obj, path) {
54  let v = obj
55  for (const k of String(path || '').split('.').filter(Boolean)) {
56    if (!v || typeof v !== 'object') return undefined
57    v = v[k]
58  }
59  return v
60}
61
62/** Number with French separators: 1 234,5. */
63export function fmtNum(v, digits = 0) {
64  if (v === null || v === undefined || !Number.isFinite(v)) return '—'
65  const s = Math.abs(v).toFixed(digits).replace('.', ',')
66  const [i, d] = s.split(',')
67  const out = i.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') + (d ? `,${d}` : '')
68  return v < 0 ? `-${out}` : out
69}
70
71/** Bytes as 12 o / 3,4 Ko / 1,2 Mo / 2,0 Go. */
72export function fmtBytes(n) {
73  if (!Number.isFinite(n)) return '—'
74  if (n < 1024) return `${n} o`
75  if (n < 1024 ** 2) return `${fmtNum(n / 1024, 1)} Ko`
76  if (n < 1024 ** 3) return `${fmtNum(n / 1024 ** 2, 1)} Mo`
77  return `${fmtNum(n / 1024 ** 3, 1)} Go`
78}
79
80/** A duration in ms as 850 ms / 2,3 s / 4 min / 3 h / 2 j. */
81export function fmtDur(ms) {
82  if (!Number.isFinite(ms) || ms < 0) return '—'
83  if (ms < 1000) return `${Math.round(ms)} ms`
84  if (ms < 60000) return `${fmtNum(ms / 1000, 1)} s`
85  if (ms < 3600000) return `${Math.round(ms / 60000)} min`
86  if (ms < 48 * 3600000) return `${Math.round(ms / 3600000)} h`
87  return `${Math.round(ms / 86400000)} j`
88}
89
90/** « il y a 4 min » from a time (ms or ISO) and now. */
91export function fmtAgo(t, now) {
92  const ms = typeof t === 'string' ? Date.parse(t) : t
93  if (!Number.isFinite(ms) || !ms) return 'jamais'
94  return `il y a ${fmtDur(Math.max(0, now - ms))}`
95}
96
97/** A time (ms or ISO) as ms, or undefined. */
98export const toMs = t => {
99  const ms = typeof t === 'string' ? Date.parse(t) : t
100  return Number.isFinite(ms) && ms > 0 ? ms : undefined
101}
102
103/**
104 * The shared health record (convention in mods/sante/README.md), as the next run writes it: last run, its duration,
105 * CPU and error, the last success, and a rolling 24 h window (runs, CPU, duration) for the approximate CPU cost.
106 * Reference implementation; band, usage, metrics, ci and machine carry their own few-line copy.
107 */
108export function nextHealth(prev, { mod, now, durationMs, cpuMs, error }, windowMs = 86400000) {
109  const p = prev && typeof prev === 'object' ? prev : {}
110  const start = toMs(p.window?.start)
111  const w = start && now - start < windowMs ? p.window : { start: new Date(now).toISOString(), runs: 0, cpuMs: 0, durationMs: 0, errors: 0 }
112  const iso = new Date(now).toISOString()
113  return {
114    mod,
115    lastRun: iso,
116    durationMs: Math.round(durationMs),
117    cpuMs: Math.round(cpuMs),
118    error: error ? String(error).slice(0, 500) : null,
119    lastOk: error ? p.lastOk ?? null : iso,
120    window: { start: w.start, runs: w.runs + 1, cpuMs: Math.round(w.cpuMs + cpuMs), durationMs: Math.round(w.durationMs + durationMs), errors: (w.errors || 0) + (error ? 1 : 0) },
121  }
122}
123
124/** CPU cost of a health record's window: ms of CPU, its share of one core over the window, runs per hour. */
125export function cpuCost(rec, now) {
126  const start = toMs(rec?.window?.start)
127  if (!start || !rec.window.runs) return undefined
128  const span = Math.max(60000, now - start)
129  return { cpuMs: rec.window.cpuMs || 0, corePct: ((rec.window.cpuMs || 0) / span) * 100, runsPerHour: (rec.window.runs / span) * 3600000, avgMs: (rec.window.durationMs || 0) / rec.window.runs, spanMs: span }
130}
131
132const RANK = { off: 0, ok: 1, warn: 2, error: 3 }
133const worst = (a, b) => (RANK[b] > RANK[a] ? b : a)
134
135/** Commands of this session that belong to the plugin `name` (or to nobody while one of its own names matches). */
136export function commandsOf(name, commands, expected = []) {
137  return (commands || [])
138    .filter(c => c.source === undefined || c.source === 'plugin')
139    .filter(c => (c.plugin ? c.plugin === name || c.plugin.startsWith(`${name}@`) : expected.includes(c.name)))
140    .map(c => c.name)
141}
142
143/**
144 * One row per mod: the scanner's facts + this session's commands + the engine version → level and reasons.
145 * report.mods[]: { key, name, dir, source: 'env' | 'dir' | 'marketplace' | 'health', exists, manifest, hooks, expected,
146 *   configs[], caches[], logs[], fields[], health }
147 */
148export function evaluate(report, { commands = [], engine, cfg = {}, now = Date.now() } = {}) {
149  const t = cfg.thresholds || {}
150  const L = cfg.labels || {}
151  const lab = (k, d, v) => fill(L[k] ?? d, v)
152  const mods = (report && report.mods) || []
153  const names = mods.map(m => m.name)
154  const rows = mods.map(m => {
155    const per = (cfg.mods || {})[m.name] || {}
156    const reasons = []
157    let level = 'ok'
158    const add = (lv, text) => {
159      level = worst(level, lv)
160      reasons.push({ level: lv, text })
161    }
162    const expected = m.expected || []
163    const cmds = commandsOf(m.name, commands, expected)
164    const loaded = cmds.length > 0
165    const inEnv = m.source === 'env'
166    const minVersion = per.min_version || m.manifest?.min_version || cfg.default_min_version
167    const cmp = engine ? cmpVersion(engine, minVersion) : undefined
168
169    if (inEnv && !m.exists) add('error', lab('r_missing', 'dossier introuvable : {dir}', { dir: m.dir }))
170    if (m.exists && m.manifest && !m.manifest.ok) add('error', lab('r_manifest', 'plugin.json illisible : {error}', { error: m.manifest.error }))
171    if (m.exists && m.hooks && !m.hooks.ok) add('error', lab('r_hooks', 'hooks : {error}', { error: m.hooks.error }))
172    for (const c of m.configs || []) if (!c.ok) add('error', lab('r_json', 'JSON invalide : {path} ({error})', { path: c.path, error: c.error }))
173    if (cmp !== undefined && cmp < 0 && (inEnv || loaded)) add('error', lab('r_version', 'Claude Code {engine} < minimum {min}', { engine, min: minVersion }))
174    if (inEnv && m.exists && expected.length && !loaded) add('warn', lab('r_not_loaded', 'dans CLAUDE_CODE_PLUGIN_DIRS mais aucune commande enregistrée (/{cmds}) : session ouverte avant ? hook session.start en erreur ?', { cmds: expected.join(', /') }))
175    const missing = loaded ? expected.filter(c => !cmds.includes(c)) : []
176    if (missing.length) add('warn', lab('r_cmd_missing', 'commande non enregistrée : /{cmds}', { cmds: missing.join(', /') }))
177    if (names.filter(n => n === m.name).length > 1 && m.source !== 'health') add('warn', lab('r_dup', 'même nom chargé deux fois'))
178
179    const h = m.health
180    if (h && h.invalid) add('error', lab('r_health_json', 'fiche santé illisible : {error}', { error: h.invalid }))
181    else if (h && h.error) {
182      const age = now - (toMs(h.lastRun) || 0)
183      if (age <= (t.health_error_hours ?? 24) * 3600000) add('error', lab('r_health_error', 'dernier scan en erreur ({ago}) : {error}', { ago: fmtAgo(h.lastRun, now), error: h.error }))
184    }
185    const staleMin = per.stale_min ?? null
186    const newest = Math.max(0, ...(m.caches || []).map(c => c.newest || 0), toMs(h?.lastOk) || 0)
187    if (staleMin && (inEnv || loaded) && newest && now - newest > staleMin * 60000) add('warn', lab('r_stale', 'pas rafraîchi depuis {ago} (attendu < {min} min)', { ago: fmtDur(now - newest), min: staleMin }))
188    const warnBytes = (per.cache_warn_mb ?? t.cache_warn_mb ?? 500) * 1024 * 1024
189    const big = (m.caches || []).filter(c => c.bytes > warnBytes)
190    for (const c of big) add('warn', lab('r_big', 'cache lourd : {path} {size}', { path: c.path, size: fmtBytes(c.bytes) }))
191    for (const l of m.logs || []) if (l.errors) add(per.log_level || 'warn', lab('r_log', '{n} erreur(s) dans {path} : {last}', { n: l.errors, path: l.path, last: l.last }))
192    for (const f of m.fields || []) if (f.count) add('warn', lab('r_field', '{n} erreur(s) dans {path} → {field} : {last}', { n: f.count, path: f.path, field: f.field, last: f.last }))
193
194    if (!inEnv && !loaded && level === 'ok') level = 'off'
195    const cost = h && !h.invalid ? cpuCost(h, now) : undefined
196    return {
197      name: m.name,
198      dir: m.dir,
199      source: m.source,
200      version: m.manifest?.version,
201      loaded,
202      inEnv,
203      commands: cmds,
204      expected,
205      minVersion,
206      level,
207      reasons,
208      lastRefresh: newest || undefined,
209      health: h && !h.invalid ? h : undefined,
210      cost,
211      cacheBytes: (m.caches || []).reduce((s, c) => s + (c.bytes || 0), 0),
212      caches: m.caches || [],
213      configs: m.configs || [],
214      cleanup: big.map(c => fill(cfg.cleanup_command || 'Remove-Item -LiteralPath "{path}" -Recurse', { path: c.path })),
215    }
216  })
217  return { rows, summary: summarize(rows, { engine, report, cfg }) }
218}
219
220/** The single top line: worst level among the mods that should run, with counts and the first reasons. */
221export function summarize(rows, { engine, report, cfg = {} } = {}) {
222  const L = cfg.labels || {}
223  const active = rows.filter(r => r.level !== 'off')
224  const count = lv => active.filter(r => r.level === lv).length
225  let level = active.reduce((a, r) => worst(a, r.level), 'ok')
226  if (report && report.error) level = 'error'
227  const bad = active.filter(r => r.level === 'error' || r.level === 'warn').sort((a, b) => RANK[b.level] - RANK[a.level])
228  const first = bad.slice(0, cfg.summary_reasons ?? 2).map(r => `${r.name} : ${r.reasons.find(x => x.level === r.level)?.text ?? ''}`)
229  const word = { ok: L.level_ok ?? 'vert', warn: L.level_warn ?? 'orange', error: L.level_error ?? 'rouge' }[level]
230  const text = fill(L.summary ?? '{word} · {active} mods actifs : {ok} OK, {warn} orange, {error} rouge · {off} non chargés · Claude Code {engine}', {
231    word, active: active.length, ok: count('ok'), warn: count('warn'), error: count('error'), off: rows.length - active.length, engine: engine || '?',
232  })
233  return { level, text: [text, ...first, ...(report && report.error ? [`scan : ${report.error}`] : [])].join(' · ') }
234}
235
lib/scenarios/hooks/apply_io.ts 48 lines
1// « 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}
48
lib/scenarios/hooks/draw.tsx 148 lines
1// 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}
148
lib/scenarios/hooks/logic.ts 184 lines
1// 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