SLOPSHOPPER

kwa-activity

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.

newpanebandguardcommandprompt
v0.4.1MITupdated 2026-10-15nesyouyou/kwa/mods/kwa-activity
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · kwa-activity
│ ┃ kwa-progress ✕ › fix the failing auth test and add an audit log call │ ┃ 0,42 $ coût de la session │ ┃ contexte 49 % (97 k / 200 k) ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ ○ 1. Cadrer ⏺ Update(src/auth.ts) │ ┃ ○ 2. Faire ⎿ Added 2 lines, removed 1 line │ ┃ ○ 3. Prouver ⏺ Bash(bun test) │ ┃ ○ 4. Relire ⎿ 3 pass, 1 fail │ ┃ ○ 5. Livrer │ ┃ ○ 6. Capitaliser ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ✻ Worked for 42s · done 4:20 PM │ │ › /kwa-activity │ ⎿ kwa-activity: Mod Kwa fermé. /kwa-activity pour le rouvrir. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · kwa-progress
0,42 $ coût de la session contexte 49 % (97 k / 200 k) ○ 1. Cadrer ○ 2. Faire ○ 3. Prouver ○ 4. Relire ○ 5. Livrer ○ 6. Capitaliser
README

Kwa

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.

Pourquoi ce dépôt

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).

  • En français. Presque tout ce qui existe sur le sujet est en anglais. Les skills, les règles, la documentation et les parcours sont ici en français, pour les développeuses et les développeurs que l'anglais freine.
  • Pour partager des bonnes pratiques. Ce qui est ici vient de l'usage et d'idées reprises à d'autres projets, toujours créditées. Si cela sert à d'autres, tant mieux ; les retours et les corrections sont bienvenus.
  • Sans prétendre à plus. Les garde-fous sont des filets contre la bévue, pas une frontière de sécurité. Les documentations officielles des outils font foi. Les parcours n'ont pas encore été éprouvés avec un groupe.

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.

Installer

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

Noyau + modules + politique

ModuleApporte
coregardes secrets / git / suppression / écriture / politique ; règles ; /kwa:commit, /kwa:ship
craftméthode : /kwa:brainstorm, plan, execute, agents, parallel, tdd, debug, verify, review, review-feedback + routage des skills au démarrage de session (remplace superpowers)
memoryjournal 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-flowworkflow issue → worktree → preuve → PR « Closes #N » → fusion gardée ; /kwa:start-dev, gabarits GitHub
verifyformatage 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-handoverrègle des 4 tests, kwa-hygiene, exceptions datées, job CI (jamais déduit : sur demande)
stack-prismamigrations append-only, pas de reset / db push
stack-expo/kwa:testflight, soumission EAS confirmée
stack-scalewaydé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.

Garanties

  • 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.
  • Retouche locale d'un fichier géré : sauvegardée avant d'être écrasée. Gabarits « seed » : copiés une fois.
  • Aucune action git. Les notes locales et sauvegardes sont exclues via .git/info/exclude.
  • Les gardes sont des filets contre la bévue, pas une frontière de sécurité ; leurs erreurs internes demandent à l'humain. 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.

Claude Code et Codex

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 CodexCe 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-invocationLes 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 sPas 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.

Remplacer un outillage maison

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.

Origine

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é.

Licence

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/.

Source 3 files
hooks/register.tsx 257 lines
1import { 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}
257
hooks/workflow.ts 350 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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}
350
types/index.d.ts 22 lines
1export 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