Mod Kwa, à droite de la conversation : le workflow détecté dessiné en vertical (briques, sous-étapes, gardes), le coût et le contexte de la session.

Site : https://nesyouyou.github.io/kwa/ (documentation illustrée et trois parcours de formation)
Kit de workflows agentiques pour le développement : mêmes règles, mêmes garde-fous, mêmes skills, partout, sous Claude Code et, à titre expérimental, sous Codex. Kwa se lit Kit de Workflows Agentiques : développer une fonctionnalité, corriger un bug, relire, livrer, capitaliser, chacun outillé par des garde-fous, des hooks et des skills.
Kwa est avant tout un support pédagogique. Il montre, avec de vrais fichiers qui tournent, comment encadrer un agent de code : règles, garde-fous, skills, mémoire, workflow. Chaque pièce est lisible, testée, et expliquée sur le site de documentation et dans trois parcours de formation (culture de l'IA générative, context engineering, harness).
Parcours d'apprentissage : docs/site/parcours.html, trois parcours qui s'enchaînent. 1) Culture IA générative (modèle, jetons et probabilités, fenêtre de contexte, agent, hallucinations, prompt engineering, risques). 2) Context engineering (session, AGENTS.md et CLAUDE.md, règles, skills, sous-agents, MCP avec Context7 et Playwright, permissions). 3) Harness (de l'histoire du processus de développement à Kwa, avec dix étapes sur un dépôt jetable dont les sorties viennent de l'exécution réelle, et le flux complet d'un développeur aujourd'hui).
Documentation illustrée : docs/site/index.html (présentation, arborescence, démarrage) et une page par sujet (carte des skills, workflow d'une demande, terminal, skills, gardes en action, mémoire, méthode, remplacement de l'existant, limites, crédits), avec une feuille de style propre (polices dans docs/site/style/, avec leur licence). Régénérer avec python3 docs/site/build.py : les exemples sont calculés en exécutant les gardes.
Il faut d'abord un agent de code (Claude Code ou Codex) : voir la page Avant de commencer.
Kwa est un plugin : skills, agent relecteur, garde-fous et mémoire s'installent une fois par machine. Le projet ne reçoit que sa configuration (politique, bloc d'AGENTS.md, plugin proposé à l'équipe), jamais de script.
1. Le plugin, une fois par machine
# Claude Code (dans une session)
/plugin marketplace add nesyouyou/kwa
/plugin install kwa@kwa
# Codex
codex plugin marketplace add nesyouyou/kwa
codex plugin add kwa@kwa
2. Le projet, une fois par dépôt : dans une session Claude Code ouverte dans le projet, lancez /kwa:init. La skill montre ce que Kwa détecte, simule, prépare le dépôt après votre accord, complète la politique avec vous et vérifie (kwa detect, kwa init --dry-run, kwa init, kwa doctor : la commande kwa vient avec le plugin).
Puis committez ce qu'elle a écrit (.claude/kwa.policy.json, .claude/settings.json, AGENTS.md, CLAUDE.md, .codex/) par une branche : l'équipe reçoit la proposition d'installer le plugin en ouvrant le projet. Sous plugin, les skills se lancent avec /kwa:commit (Claude Code). Les hooks ne s'activent que dans un projet qui a une politique Kwa ; un worktree créé avant le commit de la configuration reprend celle du dépôt principal.
Sous Codex, deux limites viennent de Codex lui-même : la ligne de commande ne charge pas les hooks d'un plugin, donc .codex/hooks.json les lance par kwa hook, et la commande kwa doit alors être dans le PATH (git clone https://github.com/nesyouyou/kwa ~/kwa, puis export PATH="$HOME/kwa/bin:$PATH" dans ~/.zshrc) ; et les noms des skills d'un plugin Codex n'ont pas encore été vérifiés en session.
kwa init sur un projet déjà installé par copie le migre : fichiers gérés retirés (une retouche locale est mise de côté dans .claude/kwa/backup/), hooks copiés débranchés, mémoire locale conservée.
Installation par copie (ancienne méthode) : kwa install <projet> copie toujours scripts et skills dans le projet, pour un usage sans plugin. Les fichiers copiés sont alors à committer, sinon les worktrees n'ont pas les garde-fous.
kwa status <projet> # version, modules, dérive locale
kwa recommend <projet> # skills tierces utiles pour la stack (rien n'est installé)
kwa build-plugin [--check] # régénère plugin/ depuis les sources (mainteneurs)
bash tests/run-safety.sh # suite hors-ligne
| Module | Apporte |
|---|---|
core | gardes secrets / git / suppression / écriture / politique ; règles ; /kwa:commit, /kwa:ship |
craft | méthode : /kwa:brainstorm, plan, execute, agents, parallel, tdd, debug, verify, review, review-feedback + routage des skills au démarrage de session (remplace superpowers) |
memory | journal des sessions et reprise au démarrage, notes et signaux (corrections, « retiens : », refus de garde), invitation unique en fin de session, /kwa:learn (docs, politique, skill de projet, mémoire agent, base de connaissance) |
issue-flow | workflow issue → worktree → preuve → PR « Closes #N » → fusion gardée ; /kwa:start-dev, gabarits GitHub |
verify | formatage des fichiers nouveaux ; garde Stop opt-in (KWA_STOP_VERIFY=1) |
deploy | /kwa:deploy : pré-vol, déploiement, vérification, pilotés par environments |
client-handover | règle des 4 tests, kwa-hygiene, exceptions datées, job CI (jamais déduit : sur demande) |
stack-prisma | migrations append-only, pas de reset / db push |
stack-expo | /kwa:testflight, soumission EAS confirmée |
stack-scaleway | déploiement direct confirmé |
Le spécifique d'un projet vit dans .claude/kwa.policy.json (créé une fois à partir de la détection, jamais écrasé) : bash.deny/ask, write.deny, write.no_code_on_main, verify.commands, start.install, environments, mobile, hygiene, memory. Exemple complet (monorepo web + API + mobile Expo) : examples/expo-monorepo.policy.json.
AGENTS.md : seul le bloc <!-- kwa:begin … --> est géré, le reste est intact (testé dans tests/test_agents_md_bytes.py : fins de ligne CRLF, absence de retour final, espaces en fin de ligne, saut de page, BOM, accents). CLAUDE.md devient un pointeur @AGENTS.md, sans jamais écraser un fichier qui porte ses propres règles.settings.json : fusion sans doublon, sauvegarde, écriture atomique, JSON invalide laissé intact..git/info/exclude.permissions.deny seul filtre un outil (Read(./.env) n'arrête pas python3 -c). Pour une vraie barrière : le bac à sable de Claude Code (/sandbox, qui applique aussi ces règles aux commandes shell), un conteneur ou une machine isolée, des identifiants séparés, la protection de branche GitHub avec CI obligatoire.Le même plugin sert les deux agents. Sous Claude Code, il apporte skills (/kwa:commit), agent relecteur (kwa:reviewer), hooks et la commande kwa ; le projet ne reçoit que sa configuration (kwa init, ou /kwa:init dans une session). Sous Codex, le plugin apporte les skills ; Codex lit AGENTS.md, les hooks dans .codex/hooks.json (lancés par kwa hook), les règles de commandes dans .codex/rules/kwa.rules et l'agent relecteur dans .codex/agents/kwa-reviewer.toml, écrits par kwa init. Les scripts des gardes sont les mêmes ; seules les différences de Codex sont traitées :
| Différence de Codex | Ce que fait Kwa |
|---|---|
Ses hooks ne savent pas demander une confirmation (ask y passerait) | Les confirmations de Kwa (push, reset --hard, git clean, suppression de branche, fusion de PR, et les motifs codex_prompt de la politique) sont écrites en règles de commandes Codex : Codex les demande lui-même. Ce qu'aucune règle ne couvre (commande composée, cible calculée, écriture d'un garde) devient un refus expliqué |
Il édite par apply_patch, sans chemin de fichier séparé | Les gardes d'écriture et le formatage lisent le patch (fichiers ajoutés, modifiés, supprimés, déplacés) |
Il ignore disable-model-invocation | Les skills à lancer à la main portent agents/openai.yaml (allow_implicit_invocation: false) |
| Ses filtres sont des expressions régulières, fin de session limitée à 3 s | Pas de filtre *, l'écriture couvre apply_patch, délai de fin de session ramené à 3 s |
Une skill se mentionne avec $ ; les sous-agents se lancent en langage naturel (worker, explorer) | Le rappel de routage écrit $kwa:… sous Codex ; les skills qui délèguent décrivent les deux façons de faire |
Une règle de projet propre à Codex s'ajoute dans la politique, sur une règle bash.ask : "codex_prompt": [["scw", "container", "container", ["update", "deploy"]]] (mots de début de commande, une liste pour plusieurs choix).
À faire une fois dans Codex : approuver le projet, puis valider ses hooks avec /hooks (à refaire après chaque mise à jour de Kwa, car la validation porte sur le contenu des hooks). Ces adaptations sont vérifiées par tests/test_codex.py, avec les charges que Codex documente ; pas encore dans une vraie session Codex.
tests/test_policy_example.py rejoue contre Kwa des cas représentatifs d'un harnais de garde-fous réel (reset de base, force-push, cibles de production, workflow issue → PR → preuve, migrations, heredocs) avec la politique d'exemple. kwa doctor signale, dans un projet existant, les hooks et skills maison qui font doublon avec Kwa.
Kwa assemble des idées de super-board, superpowers, des skills de Matt Pocock, de ponytail, humanizer, claude-reflect et Claudeception (tous MIT), réécrites et testées : voir THIRD_PARTY_NOTICES.md, credits.json et licenses/. Aucun fichier copié.
MIT, voir LICENSE. Les idées reprises d'autres projets (MIT également) sont créditées dans THIRD_PARTY_NOTICES.md. Les polices Geist et Geist Mono, vendorisées dans docs/site/style/, gardent leur licence (SIL OFL), dans docs/site/style/assets/licenses/.
hooks/register.tsx 257 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Brick, Step, Usage } from '../types'
5import { BRICKS, duration, isOver, kilo, model, money, openBrick, svg } from './workflow'
6
7// Rien de ce mod n'entre dans le contexte du modèle : il lit les événements et dessine, sans réécrire ni le prompt,
8// ni le texte des skills, ni celui des sous-agents.
9const steps = atom({ plugin: 'kwa-activity', key: 'steps' } as const, [])
10const first = atom({ plugin: 'kwa-activity', key: 'first' } as const, null)
11const usage = atom({ plugin: 'kwa-activity', key: 'usage' } as const, null)
12const frame = atom({ plugin: 'kwa-activity', key: 'frame' } as const, 0)
13const isHidden = atom({ plugin: 'kwa-activity', key: 'isHidden' } as const, false)
14
15const PANE = 'kwa-progress'
16const KEEP = 200
17// Les mêmes briques que les workflows animés du site de Kwa (docs/site/src/flows.py)
18const BRICK_OF: Record<string, Brick> = {
19 brainstorm: 'cadrer', interview: 'cadrer', plan: 'cadrer', debug: 'cadrer', prototype: 'cadrer', questionnaire: 'cadrer',
20 tickets: 'cadrer', research: 'cadrer', domain: 'cadrer', architecture: 'cadrer', rephrase: 'cadrer',
21 init: 'faire', 'start-dev': 'faire', agents: 'faire', execute: 'faire', parallel: 'faire', simple: 'faire', conflicts: 'faire',
22 tdd: 'prouver', verify: 'prouver', audit: 'prouver',
23 review: 'relire', 'review-feedback': 'relire',
24 commit: 'livrer', ship: 'livrer', deploy: 'livrer', testflight: 'livrer', humanize: 'livrer',
25 learn: 'capitaliser', handoff: 'capitaliser', 'agent-docs': 'capitaliser', teach: 'capitaliser',
26}
27const SPIN = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
28
29let tick: Timer | null = null
30let poll: Timer | null = null
31let seq = 0
32// Les agents déjà revenus : un turn.complete peut arriver avant que agent.spawn ait écrit l'étape.
33const returned = new Set<string>()
34// Par surface : la brique dessinée ouverte et l'instant où l'on y est entré, pour jouer le dépliage une fois.
35const unfold = new Map<string, { brick: Brick | null; at: number }>()
36
37const id = (prefix: string, now: number) => `${prefix}${now}-${++seq}`
38
39// Tant qu'une étape tourne, l'indicateur du terminal tourne ; il s'arrête dès que tout est revenu.
40async function animate($: EngineInterface) {
41 if (tick) return
42 tick = $.clock.every(120, async () => {
43 if (!(await read($, steps)).some(s => s.endedAt === null)) {
44 tick?.cancel()
45 tick = null
46 }
47 await update($, frame, n => (n + 1) % 1000)
48 })
49}
50
51async function refresh($: EngineInterface) {
52 const u = await $.session.usage()
53 const next: Usage = { usd: u.cost?.usd ?? null, tokens: u.context.tokens ?? null, window: u.context.window, percent: u.context.percent ?? null }
54 await update($, usage, prev => (prev && prev.usd === next.usd && prev.tokens === next.tokens ? prev : next))
55 // rattrapage : un agent qui ne reviendra jamais par turn.complete (distant, tué) est clos d'après la liste du moteur
56 const list = await read($, steps)
57 if (!list.some(s => s.kind === 'agent' && s.endedAt === null)) return
58 const live = new Map((await $.agent.list()).map(a => [a.id, a.status]))
59 const now = await $.clock.now()
60 await update($, steps, l => l.map(s => {
61 if (s.kind !== 'agent' || s.endedAt !== null) return s
62 const st = live.get(s.id)
63 if (!isOver(s, st, now)) return s
64 // un agent de workflow clos faute de nouvelles a un sort inconnu : il n'est pas montré comme réussi
65 return { ...s, endedAt: now, isAborted: st === 'killed' || st === 'failed' || (st === undefined && !!s.viaWorkflow) }
66 }))
67}
68
69const isPlaced = async ($: EngineInterface) => (await $.ui.panes()).some(p => p.id === PANE && p.isPlaced)
70
71export const register: Register = on => {
72 on('session.start', async ($, e, next) => {
73 await $.command.register({ name: 'kwa-activity', description: 'Afficher le Mod Kwa (workflow et coût), ou le masquer' })
74 if (!(await read($, isHidden))) await $.ui.open({ id: PANE, title: 'Kwa · workflow' })
75 await refresh($)
76 poll ??= $.clock.every(4000, () => refresh($))
77 if ((await read($, steps)).some(s => s.endedAt === null)) await animate($)
78 return next(e)
79 })
80
81 on('command.run', { command: 'kwa-activity' }, async $ => {
82 // un panneau en attente de place (terminal étroit) n'est pas visible : la commande l'affiche au lieu de le fermer
83 if (await isPlaced($)) {
84 await $.ui.close({ id: PANE })
85 unfold.clear() // le hook ui.close ne voit pas forcément la fermeture faite par ce mod lui-même
86 await update($, isHidden, () => true)
87 return { text: 'Mod Kwa fermé. /kwa-activity pour le rouvrir.' }
88 }
89 await update($, isHidden, () => false)
90 const r = await $.ui.open({ id: PANE, title: 'Kwa · workflow' })
91 return { text: r.isPlaced ? 'Mod Kwa ouvert.' : 'Mod Kwa ouvert, en attente de place : le bandeau le remplace.' }
92 })
93
94 // Quelle que soit la façon de fermer le Mod (commande, marque, touche) : à la réouverture, l'anneau se remplit de
95 // nouveau et rien ne se déplie pour une brique quittée entre-temps. Fermé à la main, il le reste
96 // jusqu'à /kwa-activity ou la prochaine session (isHidden est un état de session).
97 on('ui.close', async ($, e, next) => {
98 if (e.id === PANE) {
99 unfold.clear()
100 if (e.origin.kind === 'person') await update($, isHidden, () => true)
101 }
102 return next(e)
103 })
104
105 on('skill.prompt', async ($, e, next) => {
106 // seulement les skills de Kwa : /review, /init ou une skill locale « plan » ne sont pas des étapes Kwa
107 const brick = e.skill.startsWith('kwa:') ? BRICK_OF[e.skill.slice(4)] : undefined
108 if (brick) {
109 const now = await $.clock.now()
110 const name = `/${e.skill}`
111 const s: Step = { id: id('s', now), kind: 'skill', name, task: '', brick, startedAt: now, endedAt: null, isAborted: false }
112 await update($, first, f => f ?? name)
113 // une skill qui démarre clôt la précédente : on suit une seule procédure à la fois
114 await update($, steps, list => [...list.map(x => (x.kind === 'skill' && x.endedAt === null ? { ...x, endedAt: now } : x)), s].slice(-KEEP))
115 await animate($)
116 }
117 return next(e)
118 })
119
120 on('agent.spawn', async ($, e, next) => {
121 const result = await next(e)
122 if (result.agentId) {
123 const now = await $.clock.now()
124 const list = await read($, steps)
125 const last = [...list].reverse().find(x => x.kind === 'skill')
126 const brick: Brick = /review/.test(e.subagentType) ? 'relire' : last?.brick ?? 'faire'
127 const done = returned.delete(result.agentId)
128 const a: Step = { id: result.agentId, kind: 'agent', name: e.subagentType, task: e.description, brick, startedAt: now, endedAt: done ? now : null, isAborted: false, viaWorkflow: !!e.workflow }
129 await update($, steps, l => [...l.filter(x => x.id !== a.id), a].slice(-KEEP))
130 await animate($)
131 }
132 return result
133 }).catch(($, e, next) => next(e)) // ce mod observe seulement : une erreur ici ne doit jamais bloquer un sous-agent
134
135 // Un refus d'un garde de Kwa (« Kwa — … ») devient une étape rouge du schéma.
136 on('tool.call', async ($, e, next) => {
137 const r = await next(e)
138 const text = r.deny ?? (r.isError ? r.text : undefined)
139 if (text && text.includes('Kwa —')) {
140 const now = await $.clock.now()
141 const name = /code sur main/.test(text) ? 'guard-write' : /PR|Closes|preuve/.test(text) ? 'guard-github' : /push|commit/.test(text) ? 'guard-git' : 'garde'
142 const last = [...(await read($, steps))].reverse().find(x => x.kind === 'skill')
143 const brick: Brick = name === 'guard-write' ? 'faire' : name === 'garde' ? last?.brick ?? 'faire' : 'livrer'
144 await update($, steps, l => [...l, { id: id('g', now), kind: 'garde' as const, name, task: '', brick, startedAt: now, endedAt: now, isAborted: false }].slice(-KEEP))
145 }
146 return r
147 }).catch(($, e, next) => next(e)) // observer seulement : ne jamais bloquer un outil
148
149 on('turn.complete', async ($, e, next) => {
150 const now = await $.clock.now()
151 const list = await read($, steps)
152 if (e.agentId && !list.some(x => x.id === e.agentId)) {
153 returned.add(e.agentId)
154 if (returned.size > 50) returned.delete(returned.values().next().value!)
155 }
156 await update($, steps, l => l.map(x => {
157 if (x.endedAt !== null) return x
158 if (e.agentId ? x.id === e.agentId : x.kind === 'skill') return { ...x, endedAt: now, isAborted: e.isAborted }
159 return x
160 }))
161 await refresh($)
162 return next(e)
163 })
164
165 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
166 const list = await read($, steps)
167 const u = await read($, usage)
168 const reset = () => { returned.clear(); return Promise.all([update($, steps, () => []), update($, first, () => null)]) }
169 if (e.surface === 'desktop' || e.surface === 'mobile') {
170 const { Box, Svg, Button } = $.ui.resolve(e)
171 const now = await $.clock.now()
172 const started = (await $.session.usage()).startedAt
173 const m = model(list, now, (await read($, first)) ?? undefined)
174 const open = openBrick(m)
175 const seen = unfold.get(e.surface)
176 if (!seen || seen.brick !== open) unfold.set(e.surface, { brick: open, at: now })
177 const d = svg(m, u, now - started, { unfoldAgo: seen && open ? now - unfold.get(e.surface)!.at : null, intro: !seen })
178 return (
179 <Box flexDirection="column">
180 <Svg key="flow" source={d.source} width={d.width} height={d.height} isInteractive
181 alt={`${m.title} : ${m.rows.filter(r => r.status === 'done').length} étapes faites sur ${m.rows.length}${m.swarm.running.length ? `, ${m.swarm.running.length} sous-agents en cours` : ''}${u?.usd != null ? `, coût ${money(u.usd)}` : ''}`} />
182 {list.length > 0 && <Button key="reset" label="Recommencer le suivi" onPress={reset} />}
183 </Box>
184 )
185 }
186 const { Box, Text, Button } = $.ui.resolve(e)
187 const n = await read($, frame)
188 const now = await $.clock.now()
189 const current = list.length ? list[list.length - 1]!.brick : null
190 const at = current ? BRICKS.findIndex(([b]) => b === current) : -1
191 const agents = list.filter(x => x.kind === 'agent' && x.endedAt === null).length
192
193 return (
194 <Box flexDirection="column">
195 <Text>
196 <Text bold>{u?.usd != null ? money(u.usd) : '—'}</Text>
197 <Text dimColor> coût de la session</Text>
198 </Text>
199 {u?.tokens != null && (
200 <Text dimColor>
201 contexte {u.percent != null ? `${Math.round(u.percent)} %` : ''} ({kilo(u.tokens)} / {kilo(u.window)})
202 </Text>
203 )}
204 {agents > 0 && <Text color="claude">{SPIN[n % SPIN.length]} {agents} sous-agent{agents > 1 ? 's' : ''} en cours</Text>}
205 <Text> </Text>
206 {BRICKS.map(([b, label], i) => {
207 const mine = list.filter(x => x.brick === b)
208 const running = mine.some(x => x.endedAt === null)
209 const glyph = running ? SPIN[n % SPIN.length] : mine.length ? '●' : i < at ? '–' : '○'
210 const color = running ? 'claude' : mine.length ? 'success' : undefined
211 return (
212 <Box key={b} flexDirection="column">
213 <Text wrap="truncate">
214 <Text color={color}>{glyph} </Text>
215 <Text bold={b === current} dimColor={!mine.length}>{i + 1}. {label}</Text>
216 {!mine.length && i < at && <Text dimColor> passée</Text>}
217 </Text>
218 {mine.map(x => {
219 const icon = x.endedAt === null ? SPIN[n % SPIN.length] : x.isAborted || x.kind === 'garde' ? '✕' : '✓'
220 const time = duration((x.endedAt ?? now) - x.startedAt)
221 return (
222 <Text key={x.id} wrap="truncate">
223 <Text dimColor> </Text>
224 <Text color={x.endedAt === null ? 'claude' : x.isAborted || x.kind === 'garde' ? 'error' : 'success'}>{icon} </Text>
225 <Text>{x.kind === 'agent' ? `↳ ${x.name}` : x.name}</Text>
226 <Text dimColor>{x.task ? ` · ${x.task}` : ''} · {time}</Text>
227 </Text>
228 )
229 })}
230 </Box>
231 )
232 })}
233 <Text> </Text>
234 {list.length > 0 && <Button key="reset" label="Recommencer le suivi" onPress={reset} />}
235 </Box>
236 )
237 })
238
239 // Secours : quand le panneau ne peut pas s'afficher, une ligne au-dessus du prompt dit l'essentiel.
240 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
241 const list = await read($, steps)
242 if (e.props.hasSurvey || (await read($, isHidden)) || !list.length || (await isPlaced($))) return next(e)
243 const { Text } = $.ui.resolve(e)
244 const last = list[list.length - 1]!
245 const label = BRICKS.find(([b]) => b === last.brick)![1]
246 const running = list.filter(x => x.kind === 'agent' && x.endedAt === null).length
247 const u = await read($, usage)
248 return (
249 <Text wrap="truncate">
250 <Text color="claude">◆ </Text>
251 <Text bold>{label}</Text>
252 <Text dimColor> · {last.name}{running ? ` · ${running} sous-agent${running > 1 ? 's' : ''} en cours` : ''}{u?.usd != null ? ` · ${money(u.usd)}` : ''}</Text>
253 </Text>
254 )
255 })
256}
257hooks/workflow.ts 350 lines1// Le modèle du panneau : quel workflow, quelle étape, et le dessin SVG. Fonctions pures, testées sans moteur.
2import type { Brick, Step, Usage } from '../types'
3
4export type Kind = 'skill' | 'garde' | 'hook' | 'agent' | 'vous' | 'doc'
5// aborted : interrompue par l'utilisateur ; passed : ni vue ni faite, mais une étape plus loin a été atteinte
6export type Status = 'done' | 'run' | 'passed' | 'next' | 'denied' | 'aborted'
7// count / live : pour la boîte des sous-agents d'une brique, combien ont été lancés et combien tournent encore
8export type Row = { brick: Brick; kind: Kind; name: string; status: Status; ms: number | null; extra: boolean; count?: number; live?: number; aborted?: number }
9export type Agent = { name: string; task: string; ms: number }
10// running : les sous-agents qui tournent ; done : ceux qui ont fini ; peak : le plus grand nombre en même temps
11export type Swarm = { running: Agent[]; done: number; peak: number }
12export type Model = { key: string; title: string; why: string; rows: Row[]; swarm: Swarm }
13
14export const BRICKS: [Brick, string][] = [
15 ['cadrer', 'Cadrer'], ['faire', 'Faire'], ['prouver', 'Prouver'],
16 ['relire', 'Relire'], ['livrer', 'Livrer'], ['capitaliser', 'Capitaliser'],
17]
18
19// Les cinq workflows du site de Kwa (docs/site/src/flows.py), étape par étape.
20const FLOWS: Record<string, { title: string; steps: [Brick, Kind, string][] }> = {
21 feature: { title: 'Développer une fonctionnalité', steps: [
22 ['cadrer', 'skill', '/kwa:brainstorm'], ['cadrer', 'vous', 'validation'], ['cadrer', 'skill', '/kwa:plan'],
23 ['faire', 'garde', 'guard-write'], ['faire', 'skill', '/kwa:start-dev'], ['faire', 'skill', '/kwa:agents'],
24 ['prouver', 'skill', '/kwa:tdd'], ['prouver', 'skill', '/kwa:verify'],
25 ['relire', 'agent', 'kwa:reviewer'], ['relire', 'skill', '/kwa:review-feedback'],
26 ['livrer', 'vous', 'push'], ['livrer', 'skill', '/kwa:ship'], ['livrer', 'vous', 'fusion'],
27 ['capitaliser', 'skill', '/kwa:learn'], ['capitaliser', 'doc', '/kwa:handoff']] },
28 bug: { title: 'Corriger un bug', steps: [
29 ['cadrer', 'skill', '/kwa:debug'], ['cadrer', 'vous', 'cause'], ['faire', 'skill', '/kwa:start-dev'],
30 ['prouver', 'skill', '/kwa:tdd'], ['prouver', 'skill', '/kwa:simple'], ['prouver', 'hook', 'verify-stop'], ['prouver', 'skill', '/kwa:verify'],
31 ['livrer', 'skill', '/kwa:commit'], ['livrer', 'garde', 'guard-github'], ['livrer', 'skill', '/kwa:ship'],
32 ['capitaliser', 'skill', '/kwa:learn']] },
33 review: { title: 'Relire', steps: [
34 ['relire', 'skill', '/kwa:review'], ['relire', 'agent', 'kwa:reviewer'], ['relire', 'hook', 'readonly-bash'],
35 ['relire', 'skill', '/kwa:review-feedback'], ['faire', 'skill', '/kwa:tdd'], ['prouver', 'skill', '/kwa:verify'],
36 ['livrer', 'vous', 'décision']] },
37 ship: { title: 'Livrer', steps: [
38 ['prouver', 'skill', '/kwa:verify'], ['livrer', 'skill', '/kwa:commit'], ['livrer', 'garde', 'guard-git'],
39 ['livrer', 'vous', 'push'], ['livrer', 'garde', 'guard-github'], ['livrer', 'vous', 'fusion'],
40 ['livrer', 'skill', '/kwa:deploy'], ['capitaliser', 'skill', '/kwa:learn']] },
41 learn: { title: 'Capitaliser', steps: [
42 ['cadrer', 'hook', 'memory-context'], ['faire', 'hook', 'signal-prompt'], ['faire', 'garde', 'guard-write'],
43 ['capitaliser', 'hook', 'memory-nudge'], ['capitaliser', 'skill', '/kwa:learn'], ['capitaliser', 'vous', 'accord'],
44 ['capitaliser', 'doc', '/kwa:handoff']] },
45}
46// La première skill de la session dit de quel workflow il s'agit.
47const FLOW_OF: Record<string, string> = {
48 '/kwa:debug': 'bug', '/kwa:review': 'review', '/kwa:review-feedback': 'review',
49 '/kwa:commit': 'ship', '/kwa:ship': 'ship', '/kwa:deploy': 'ship', '/kwa:learn': 'learn', '/kwa:handoff': 'learn',
50}
51
52const matches = (row: [Brick, Kind, string], s: Step) =>
53 // les sous-agents ne sont pas appariés ici : ils sont comptés à part, par brique
54 (s.kind === 'skill' && row[2] === s.name) || (s.kind === 'garde' && row[1] === 'garde' && row[2] === s.name)
55
56const ORDER = (b: Brick) => BRICKS.findIndex(([x]) => x === b)
57// une ligne hors parcours va après la dernière ligne d'une brique qui ne vient pas après la sienne
58const insertAt = (rows: Row[], b: Brick) => {
59 let at = 0
60 rows.forEach((r, i) => { if (ORDER(r.brick) <= ORDER(b)) at = i + 1 })
61 return at
62}
63const stepStatus = (s: Step): Status => (s.kind === 'garde' ? 'denied' : s.endedAt === null ? 'run' : s.isAborted ? 'aborted' : 'done')
64
65// `first` : la première skill Kwa de la session, gardée à part pour que le workflow détecté ne change pas quand
66// la liste des étapes est tronquée.
67export function model(steps: Step[], now: number, first: string | null = steps.find(s => s.kind === 'skill')?.name ?? null): Model {
68 const key = (first && FLOW_OF[first]) || 'feature'
69 const flow = FLOWS[key]!
70 const why = first ? `d'après ${first}` : 'en attente d’une skill'
71 const used = new Set<Step>()
72 const rows: Row[] = flow.steps.map(def => {
73 const hit = [...steps].reverse().find(s => !used.has(s) && matches(def, s))
74 if (!hit) return { brick: def[0], kind: def[1], name: def[2], status: 'next', ms: null, extra: false }
75 used.add(hit)
76 return { brick: def[0], kind: def[1], name: def[2], status: stepStatus(hit), ms: (hit.endedAt ?? now) - hit.startedAt, extra: false }
77 })
78 // les sous-agents d'une brique tournent souvent en parallèle : une seule boîte par brique, qui les compte
79 for (const s of steps) {
80 if (s.kind !== 'agent') continue
81 let row = rows.find(r => r.brick === s.brick && r.kind === 'agent')
82 if (!row) {
83 row = { brick: s.brick, kind: 'agent', name: s.name, status: 'next', ms: null, extra: true }
84 rows.splice(insertAt(rows, s.brick), 0, row)
85 }
86 if (!row.count) row.name = s.name
87 else if (row.name !== s.name) row.name = 'sous-agents'
88 row.count = (row.count ?? 0) + 1
89 row.live = (row.live ?? 0) + (s.endedAt === null ? 1 : 0)
90 if (s.endedAt !== null && s.isAborted) row.aborted = (row.aborted ?? 0) + 1
91 // la boîte n'est « interrompue » que si tous ses agents le sont ; sinon elle est faite et compte les interrompus
92 row.status = row.live ? 'run' : row.aborted === row.count ? 'aborted' : 'done'
93 row.ms = Math.max(row.ms ?? 0, (s.endedAt ?? now) - s.startedAt)
94 used.add(s)
95 }
96 // une skill ou un refus hors du parcours type s'ajoute dans sa brique, à sa place dans l'ordre des briques
97 // une même étape répétée (un garde qui refuse dix fois, une skill relancée) se compte dans une seule boîte
98 for (const s of steps) {
99 if (used.has(s)) continue
100 const kind: Kind = s.kind === 'garde' ? 'garde' : 'skill'
101 const same = rows.find(r => r.brick === s.brick && r.kind === kind && r.name === s.name && r.status !== 'next')
102 if (same) {
103 same.count = (same.count ?? 1) + 1
104 // une ligne du parcours porte déjà l'étape la plus récente ; une ligne ajoutée prend la plus récente vue
105 if (same.extra) {
106 same.status = stepStatus(s)
107 same.ms = (s.endedAt ?? now) - s.startedAt
108 }
109 continue
110 }
111 const row: Row = { brick: s.brick, kind, name: s.name, status: stepStatus(s), ms: (s.endedAt ?? now) - s.startedAt, extra: true }
112 rows.splice(insertAt(rows, s.brick), 0, row)
113 }
114 // une étape du parcours type qui précède la dernière étape type atteinte est passée, même sans trace (une
115 // validation, un hook) ; les lignes ajoutées ne font rien passer
116 let last = -1
117 rows.forEach((r, i) => { if (!r.extra && r.status !== 'next') last = i })
118 rows.forEach((r, i) => { if (r.status === 'next' && !r.extra && i < last) r.status = 'passed' })
119 return { key, title: flow.title, why, rows, swarm: swarm(steps, now) }
120}
121
122function swarm(steps: Step[], now: number): Swarm {
123 const agents = steps.filter(s => s.kind === 'agent')
124 const edges = agents.flatMap(a => [[a.startedAt, 1], [a.endedAt ?? Infinity, -1]] as [number, number][])
125 .sort((a, b) => a[0] - b[0] || a[1] - b[1])
126 let live = 0, peak = 0
127 for (const [, d] of edges) peak = Math.max(peak, (live += d))
128 const running = agents.filter(a => a.endedAt === null).map(a => ({ name: a.name, task: a.task, ms: now - a.startedAt }))
129 return { running, done: agents.length - running.length, peak }
130}
131
132const BRICK_COLOR: Record<Brick, string> = {
133 cadrer: '#6366f1', faire: '#0ea5e9', prouver: '#10b981', relire: '#a855f7', livrer: '#f97316', capitaliser: '#eab308',
134}
135const KIND_COLOR: Record<Kind, string> = {
136 skill: '#3b82f6', garde: '#ef4444', hook: '#f59e0b', agent: '#8b5cf6', vous: '#22c55e', doc: '#14b8a6',
137}
138const KIND_LABEL: Record<Kind, string> = { skill: 'skill', garde: 'garde', hook: 'hook', agent: 'sous-agent', vous: 'vous', doc: 'relais' }
139
140// les caractères de contrôle sont interdits en XML et casseraient tout le dessin
141const esc = (t: string) => t.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
142export const duration = (ms: number) => (ms < 60000 ? `${Math.max(0, Math.round(ms / 1000))} s` : `${Math.round(ms / 60000)} min`)
143export const kilo = (n: number) => (n >= 1000 ? `${Math.round(n / 1000)} k` : `${n}`)
144export const money = (usd: number) => `${usd.toFixed(2).replace('.', ',')} $`
145
146// Un agent en cours est-il fini ? `status` est son état dans $.agent.list(), absent s'il n'y figure pas. Un agent de
147// workflow n'y figure jamais : seule sa fin de tour le clôt, sauf `staleMs` après son lancement (lancé à distance, il
148// n'en aura pas ; un agent de workflow légitime plus long que ce délai sera clos lui aussi).
149const OVER = new Set(['completed', 'failed', 'killed', 'idle'])
150export function isOver(s: Step, status: string | undefined, now: number, staleMs = 2 * 60 * 60 * 1000): boolean {
151 if (status !== undefined) return OVER.has(status)
152 return !s.viaWorkflow || now - s.startedAt >= staleMs
153}
154
155// La brique ouverte : celle de l'étape en cours, sinon celle de la dernière étape atteinte. Une seule à la fois,
156// comme sur la page d'accueil du site de Kwa.
157export function openBrick(m: Model): Brick | null {
158 const run = m.rows.find(r => r.status === 'run')
159 if (run) return run.brick
160 const reached = m.rows.filter(r => r.status !== 'next' && r.status !== 'passed')
161 return reached.length ? reached[reached.length - 1]!.brick : null
162}
163
164// Le dessin : un logigramme vertical. Chaque brique est un cadre ; fermée, elle montre une pastille par étape ;
165// ouverte, une boîte par étape, reliées par des flèches qui descendent.
166// `unfoldAgo` : depuis combien de millisecondes on est entré dans la brique ouverte. Tant que le dépliage n'est
167// pas fini (le cadre grandit, ce qui suit glisse, les étapes arrivent en cascade), il est dessiné reparti de là où
168// il en est, pour qu'un nouveau rendu en plein milieu ne le coupe pas. `intro` remplit l'anneau du contexte.
169export type Drawing = { source: string; width: number; height: number }
170export type DrawOptions = { unfoldAgo?: number | null; intro?: boolean }
171const WIDTH = 360
172const UNFOLD_MS = 1500
173const EASE = 'calcMode="spline" keyTimes="0;1" keySplines=".65 0 .25 1"'
174// le moulinet d'un sous-agent : un anneau pâle et un arc qui tourne
175const spinner = (x: number, y: number, r: number, color: string, w = 2.5, dur = 0.9) =>
176 `<circle cx="${x}" cy="${y}" r="${r}" fill="none" stroke="${color}" stroke-opacity=".2" stroke-width="${w}"/>` +
177 `<path d="M${x} ${y - r} A${r} ${r} 0 0 1 ${x + r} ${y}" fill="none" stroke="${color}" stroke-width="${w}" stroke-linecap="round">` +
178 `<animateTransform attributeName="transform" type="rotate" from="0 ${x} ${y}" to="360 ${x} ${y}" dur="${dur}s" repeatCount="indefinite"/></path>`
179const cut = (t: string, n: number) => { const c = [...t]; return c.length > n ? c.slice(0, n - 1).join('') + '…' : t }
180export function svg(m: Model, u: Usage | null, sessionMs: number, { unfoldAgo = null, intro = false }: DrawOptions = {}): Drawing {
181 const animate = unfoldAgo !== null && unfoldAgo < UNFOLD_MS
182 const begin = animate ? ` begin="${(-unfoldAgo / 1000).toFixed(2)}s"` : ''
183 const W = WIDTH, CX = W / 2, BOX = 38, GAP = 18, HEAD = 44, MORPH = 0.65
184 const open = openBrick(m)
185 const reachedRow = (r: Row) => r.status !== 'next'
186 const arrow = (x: number, y1: number, y2: number, state: 'on' | 'run' | 'off', color: string) => {
187 const tint = state === 'run' ? ` style="stroke:${color}"` : ''
188 return `<line x1="${x}" y1="${y1}" x2="${x}" y2="${y2 - 1}" class="ar ${state}"${tint}>` +
189 (state === 'run' ? `<animate attributeName="stroke-dashoffset" values="12;0" dur=".6s" repeatCount="indefinite"/>` : '') + `</line>` +
190 `<path d="M${x - 5} ${y2 - 7} L${x} ${y2} L${x + 5} ${y2 - 7}" class="tip ${state}"${tint}/>`
191 }
192 // apparition retardée d'une étape : invisible jusqu'à `delay`, puis fondu et petite montée
193 const cascade = (delay: number) => {
194 const dur = delay + 0.5, k = (delay / dur).toFixed(3)
195 return `<animate attributeName="opacity" values="0;0;1" keyTimes="0;${k};1" dur="${dur}s"${begin} fill="freeze"/>` +
196 `<animateTransform attributeName="transform" type="translate" values="0 8;0 8;0 0" keyTimes="0;${k};1" dur="${dur}s"${begin} fill="freeze"/>`
197 }
198
199 const head = `<text x="16" y="30" class="title">${esc(m.title)}</text><text x="16" y="50" class="meta">workflow détecté ${esc(m.why)}</text>`
200 let before = '' // ce qui est au-dessus de la brique ouverte et ne bouge pas
201 let after = '' // ce qui est en dessous et glisse pendant le dépliage
202 let grow = 0
203 let y = 64
204 let prevBottom = 0
205 let prevReached = false
206 let n = 0
207 for (const [b, label] of BRICKS) {
208 const rows = m.rows.filter(r => r.brick === b)
209 if (!rows.length) continue
210 n++
211 const color = BRICK_COLOR[b]
212 const isOpen = b === open
213 const live = rows.some(r => r.status === 'run')
214 const reached = rows.some(reachedRow)
215 const isDone = !isOpen && rows.some(r => r.status === 'done') && rows.every(r => r.status === 'done' || r.status === 'passed')
216 const denied = rows.reduce((n, r) => n + (r.status === 'denied' ? r.count ?? 1 : 0), 0)
217 let out = ''
218 if (prevBottom) out += arrow(CX, prevBottom, y, reached ? (live && !prevReached ? 'run' : 'on') : 'off', color)
219 const h = isOpen ? HEAD + rows.length * BOX + (rows.length - 1) * GAP + 14 : HEAD
220 const grows = isOpen && animate
221 const hAnim = grows ? `<animate attributeName="height" from="${HEAD}" to="${h}" dur="${MORPH}s"${begin} ${EASE} fill="freeze"/>` : ''
222 const frameOpacity = reached ? (isOpen ? 0.8 : 0.45) : 0.25
223 out += `<g${isDone ? ' opacity=".8"' : ''}>` +
224 `<rect x="10" y="${y}" width="${W - 20}" height="${h}" rx="16" class="frame${isOpen ? ' open' : ''}" style="stroke:${color}" stroke-opacity="${frameOpacity}"${reached ? '' : ' stroke-dasharray="5 5"'}>${hAnim}</rect>` +
225 `<rect x="10" y="${y}" width="${W - 20}" height="${h}" rx="16" fill="${color}" fill-opacity="${isOpen ? 0.09 : reached ? 0.05 : 0.02}">${hAnim}</rect>`
226 // l'en-tête : numéro (coche quand la brique est finie), nom, puis pastilles des étapes si elle est fermée
227 const nx = 32, ny = y + HEAD / 2
228 out += isDone
229 ? `<circle cx="${nx}" cy="${ny}" r="11" fill="#10b981"/><path d="M${nx - 4.5} ${ny} l3 3 l6 -6.5" class="tick"/>`
230 : isOpen
231 ? `<circle cx="${nx}" cy="${ny}" r="11" fill="${color}">${live ? `<animate attributeName="opacity" values="1;.55;1" dur="1.4s" repeatCount="indefinite"/>` : ''}</circle><text x="${nx}" y="${ny + 4}" class="num on" text-anchor="middle">${n}</text>`
232 : `<circle cx="${nx}" cy="${ny}" r="11" class="hollow" style="stroke:${color}" stroke-opacity="${reached ? 0.8 : 0.4}"/><text x="${nx}" y="${ny + 4}" class="num t-${b}" text-anchor="middle" opacity="${reached ? 1 : 0.6}">${n}</text>`
233 out += `<text x="52" y="${ny + 5}" class="bh t-${b}" opacity="${reached ? 1 : 0.55}">${label}</text>`
234 if (!isOpen) {
235 // une pastille par étape, à droite : verte faite, rouge refusée, pâle passée, vide à venir
236 rows.forEach((r, i) => {
237 const px = W - 26 - (rows.length - 1 - i) * 14
238 const fill = r.status === 'done' ? '#10b981' : r.status === 'denied' ? '#ef4444' : r.status === 'aborted' ? '#71717a' : r.status === 'run' ? color : null
239 out += fill ? `<circle cx="${px}" cy="${ny}" r="4.5" fill="${fill}"/>`
240 : r.status === 'passed' ? `<circle cx="${px}" cy="${ny}" r="4.5" fill="${KIND_COLOR[r.kind]}" opacity=".35"/>`
241 : `<circle cx="${px}" cy="${ny}" r="4" class="hollow" style="stroke:${KIND_COLOR[r.kind]}" stroke-opacity=".5"/>`
242 })
243 } else {
244 out += `<text x="${W - 24}" y="${ny + 4}" class="meta" text-anchor="end">${rows.filter(r => r.status === 'done' || r.status === 'passed').length}/${rows.length}${denied ? ` · ${denied} refus` : ''}</text>`
245 let by = y + HEAD
246 rows.forEach((r, i) => {
247 let row = ''
248 if (i > 0) row += arrow(CX, by - GAP, by, r.status === 'run' ? 'run' : reachedRow(r) && reachedRow(rows[i - 1]!) ? 'on' : 'off', color)
249 const c = KIND_COLOR[r.kind]
250 const bx = 24, bw = W - 48, cy = by + BOX / 2
251 const ring = r.status === 'denied' ? '#ef4444' : r.status === 'run' ? color : r.status === 'next' ? undefined : c
252 row += `<rect x="${bx}" y="${by}" width="${bw}" height="${BOX}" rx="10" class="box${r.status === 'next' ? ' off' : ''}"` +
253 `${ring ? ` style="stroke:${ring}"` : ''}${r.status === 'run' ? ' stroke-width="2.5"' : ''}>` +
254 (r.status === 'run' ? `<animate attributeName="stroke-opacity" values="1;.35;1" dur="1.4s" repeatCount="indefinite"/>` : '') + `</rect>`
255 if (r.status === 'run') row += `<rect x="${bx}" y="${by}" width="${bw}" height="${BOX}" rx="10" fill="${color}" fill-opacity=".14"/>`
256 if (r.status === 'denied') row += `<rect x="${bx}" y="${by}" width="${bw}" height="${BOX}" rx="10" fill="#ef4444" fill-opacity=".1"/>`
257 const ix = bx + 20
258 if (r.status === 'done') row += `<circle cx="${ix}" cy="${cy}" r="9" fill="#10b981"/><path d="M${ix - 4} ${cy} l3 3 l5 -6" class="tick"/>`
259 else if (r.status === 'run' && r.kind === 'agent') row += spinner(ix, cy, 8, color)
260 else if (r.status === 'run') row += `<circle cx="${ix}" cy="${cy}" r="8" fill="${color}"/>` +
261 `<circle cx="${ix}" cy="${cy}" r="8" fill="none" stroke="${color}" stroke-width="2"><animate attributeName="r" values="8;15" dur="1.4s" repeatCount="indefinite"/><animate attributeName="opacity" values=".8;0" dur="1.4s" repeatCount="indefinite"/></circle>`
262 else if (r.status === 'denied') row += `<circle cx="${ix}" cy="${cy}" r="9" fill="#ef4444"/><path d="M${ix - 3.5} ${cy - 3.5} l7 7 M${ix + 3.5} ${cy - 3.5} l-7 7" class="tick"/>`
263 else if (r.status === 'aborted') row += `<circle cx="${ix}" cy="${cy}" r="9" fill="#71717a"/><path d="M${ix - 3.5} ${cy - 3.5} l7 7 M${ix + 3.5} ${cy - 3.5} l-7 7" class="tick"/>`
264 else if (r.status === 'passed') row += `<circle cx="${ix}" cy="${cy}" r="5" fill="${c}" opacity=".5"/>`
265 else row += `<circle cx="${ix}" cy="${cy}" r="7" class="hollow" style="stroke:${c}" stroke-opacity=".5"/>`
266 const cls = r.status === 'next' ? 'sn dim' : r.status === 'run' ? 'sn now' : r.status === 'passed' ? 'sn soft' : 'sn'
267 const meta = r.status === 'denied' ? 'refusé' : r.status === 'aborted' ? 'interrompue' : r.status === 'run' ? `en cours · ${duration(r.ms ?? 0)}` : r.ms !== null ? duration(r.ms) : r.status === 'passed' ? 'passée' : KIND_LABEL[r.kind]
268 const label = r.count && r.count > 1 ? `${r.name} ×${r.count}` : r.name
269 const metaRow = r.kind === 'agent' && r.live && r.count && r.count > 1 ? `${r.live} en parallèle · ${duration(r.ms ?? 0)}`
270 : r.kind === 'agent' && r.status === 'done' && r.aborted ? `${r.aborted} interrompu${r.aborted > 1 ? 's' : ''} · ${meta}` : meta
271 row += `<text x="${ix + 18}" y="${cy + 5}" class="${cls}">${esc(label)}</text>` +
272 `<text x="${bx + bw - 12}" y="${cy + 4}" class="meta${r.status === 'run' ? ` live t-${b}` : ''}" text-anchor="end">${metaRow}</text>`
273 out += `<g>${grows ? cascade(0.3 + 0.12 * i) : ''}${row}</g>`
274 by += BOX + GAP
275 })
276 }
277 out += `</g>`
278 if (open && BRICKS.findIndex(([x]) => x === b) > BRICKS.findIndex(([x]) => x === open)) after += out
279 else before += out
280 if (grows) grow = h - HEAD
281 prevBottom = y + h
282 prevReached = reached
283 y += h + 26
284 }
285
286 // les sous-agents qui moulinent, au-dessus du coût
287 const sw = m.swarm
288 if (sw.running.length) {
289 const shown = sw.running.slice(0, 5)
290 const sh = 52 + shown.length * 30 + (sw.running.length > shown.length ? 18 : 0) + 6
291 let card = `<g transform="translate(10 ${y - 4})"><rect width="${W - 20}" height="${sh}" rx="18" class="swarm"/>` +
292 spinner(32, 26, 11, '#8b5cf6', 3.5, 1.1) +
293 `<text x="54" y="31" class="sw">${sw.running.length} sous-agent${sw.running.length > 1 ? 's' : ''} tourne${sw.running.length > 1 ? 'nt' : ''}</text>` +
294 `<text x="${W - 34}" y="31" class="meta" text-anchor="end">pic ${sw.peak}${sw.done ? ` · ${sw.done} fini${sw.done > 1 ? 's' : ''}` : ''}</text>`
295 shown.forEach((a, i) => {
296 const ay = 62 + i * 30
297 card += spinner(32, ay, 6, '#8b5cf6', 2, 0.8 + 0.15 * i) +
298 `<text x="48" y="${ay + 4}" class="an">${esc(cut(a.name.replace(/^kwa:/, ''), 18))}</text>` +
299 `<text x="${W - 34}" y="${ay + 4}" class="meta" text-anchor="end">${esc(cut(a.task, 22))}${a.task ? ' · ' : ''}${duration(a.ms)}</text>`
300 })
301 if (sw.running.length > shown.length) card += `<text x="48" y="${62 + shown.length * 30}" class="meta">et ${sw.running.length - shown.length} de plus</text>`
302 after += card + `</g>`
303 y += sh + 12
304 } else if (sw.peak) {
305 // tout est revenu : une ligne de bilan
306 after += `<text x="16" y="${y + 8}" class="meta">${sw.done} sous-agent${sw.done > 1 ? 's' : ''} lancé${sw.done > 1 ? 's' : ''}, jusqu'à ${sw.peak} en parallèle</text>`
307 y += 22
308 }
309
310 // la carte du coût et du contexte
311 const pct = u?.percent ?? (u?.tokens != null && u.window ? (100 * u.tokens) / u.window : null)
312 const ring = pct == null ? '#94a3b8' : pct < 50 ? '#10b981' : pct < 80 ? '#f59e0b' : '#ef4444'
313 const R = 32, C = 2 * Math.PI * R, cardY = y - 4
314 const dash = pct == null ? 0 : (C * Math.min(pct, 100)) / 100
315 after += `<g transform="translate(10 ${cardY})"><rect width="${W - 20}" height="112" rx="18" class="card"/>` +
316 `<g transform="translate(58 56) rotate(-90)"><circle r="${R}" class="track"/>` +
317 `<circle r="${R}" fill="none" stroke="${ring}" stroke-width="10" stroke-linecap="round" stroke-dasharray="${dash} ${C}">` +
318 (intro ? `<animate attributeName="stroke-dasharray" from="0 ${C}" to="${dash} ${C}" dur="1s"/>` : '') + `</circle></g>` +
319 `<text x="58" y="61" class="pct" text-anchor="middle">${pct == null ? '—' : Math.round(pct) + ' %'}</text>` +
320 `<text x="112" y="40" class="cost">${u?.usd != null ? money(u.usd) : '—'}</text>` +
321 `<text x="112" y="62" class="meta">coût de la session · ${duration(sessionMs)}</text>` +
322 `<text x="112" y="82" class="meta">contexte ${u?.tokens != null ? `${kilo(u.tokens)} / ${kilo(u.window)} jetons` : 'inconnu'}</text></g>`
323 // pendant le dépliage, ce qui suit la brique ouverte part de sa place d'avant et descend avec elle
324 const slide = grow ? `<animateTransform attributeName="transform" type="translate" from="0 ${-grow}" to="0 0" dur="${MORPH}s"${begin} ${EASE} fill="freeze"/>` : ''
325
326 const H = cardY + 124
327 const style = `<style>
328text{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;fill:#18181b}
329.title{font-size:18px;font-weight:700}.bh{font-size:13px;font-weight:700;letter-spacing:.06em;text-transform:uppercase}
330.num{font-size:12px;font-weight:700}.num.on{fill:#fff}
331.bg{fill:#ffffff}.sn{font-size:14px;font-weight:500}.now{font-weight:700}.dim{opacity:.5}.soft{opacity:.7}
332.meta{font-size:12px;fill:#71717a}.live{font-weight:600}
333.cost{font-size:26px;font-weight:750}.pct{font-size:15px;font-weight:700}
334.frame{fill:none;stroke-width:1.5}.frame.open{stroke-width:2}.box{fill:#ffffff;stroke:#d4d4d8;stroke-width:1.5}.box.off{stroke-dasharray:4 4;stroke:#d4d4d8}
335.ar{stroke-width:2}.ar.on{stroke:#10b981}.ar.off{stroke:#d4d4d8;stroke-dasharray:3 4}.ar.run{stroke-dasharray:6 6}
336.tip{fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}.tip.on{stroke:#10b981}.tip.off{stroke:#d4d4d8}
337.hollow{fill:#fff;stroke-width:2}.tick{fill:none;stroke:#fff;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round}
338.card{fill:#f4f4f5;stroke:#e4e4e7}
339.t-cadrer{fill:#4338ca}.t-faire{fill:#0369a1}.t-prouver{fill:#047857}.t-relire{fill:#7e22ce}.t-livrer{fill:#c2410c}.t-capitaliser{fill:#a16207}.swarm{fill:#8b5cf6;fill-opacity:.08;stroke:#8b5cf6;stroke-opacity:.45;stroke-width:1.5}
340.sw{font-size:15px;font-weight:700}.an{font-size:13.5px;font-weight:600}.track{fill:none;stroke:#e4e4e7;stroke-width:10}
341@media (prefers-color-scheme: dark){.bg{fill:#111113}text{fill:#fafafa}.meta{fill:#a1a1aa}.box{fill:#18181b;stroke:#3f3f46}
342.box.off{stroke:#3f3f46}.ar.off{stroke:#3f3f46}.tip.off{stroke:#3f3f46}.hollow{fill:#18181b}.card{fill:#18181b;stroke:#3f3f46}.track{stroke:#3f3f46}
343.t-cadrer{fill:#818cf8}.t-faire{fill:#38bdf8}.t-prouver{fill:#34d399}.t-relire{fill:#c084fc}.t-livrer{fill:#fb923c}.t-capitaliser{fill:#facc15}}
344</style>`
345 return {
346 width: W, height: H,
347 source: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">${style}<rect width="${W}" height="${H}" rx="18" class="bg"/>${head}${before}<g>${slide}${after}</g></svg>`,
348 }
349}
350types/index.d.ts 22 lines1export type Brick = 'cadrer' | 'faire' | 'prouver' | 'relire' | 'livrer' | 'capitaliser'
2export type Step = {
3 id: string
4 kind: 'skill' | 'agent' | 'garde'
5 name: string
6 task: string
7 brick: Brick
8 startedAt: number
9 endedAt: number | null
10 isAborted: boolean
11 // lancé par un script de workflow : $.agent.list() ne le liste pas, seule sa fin de tour le clôt
12 viaWorkflow?: boolean
13}
14export type Usage = { usd: number | null; tokens: number | null; window: number; percent: number | null }
15
16declare module 'claude-code' {
17 interface PluginState {
18 // first : la première skill Kwa de la session, qui dit le workflow ; elle survit à la troncature de steps
19 'kwa-activity': { steps: Step[]; first: string | null; usage: Usage | null; frame: number; isHidden: boolean }
20 }
21}
22