SLOPSHOPPER

flow-board

Tableau de bord du workflow mattpocock-skills : un parcours adapté au skill de départ (phases, étapes, tickets, sous-agents, tests, livrables) en panneau et en…

panebandrowsguardcommandtoast
v0.57.0MITupdated 2026-10-26matwi5/mathis-plugins/plugins/flow-board
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · flow-board
│ ┃ Parcours ✕ › fix the failing auth test and add an audit log call │ ┃ ╭────────────────────────────────────────╮ │ ┃ │ │ ⏺ Read(src/auth.ts) │ ┃ │ ◆ Tableau de bord du workflow │ ⎿ Read 6 lines │ ┃ │ Le suivi démarre au premier skill │ ⏺ Update(src/auth.ts) │ ┃ │ mattpocock lancé dans app : phases, │ ⎿ Added 2 lines, removed 1 line │ ┃ │ étapes, tickets, sous-agents, tests │ ⏺ Bash(bun test) │ ┃ │ et livrables, le tout en direct. │ ⎿ 3 pass, 1 fail │ ┃ │ │ │ ┃ ╰────────────────────────────────────────╯ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ ▸ Parcours Activité Historique · 0 Comma ✻ Worked for 42s · done 4:20 PM │ ┃ │ ┃ Démarrer un parcours › /flow │ ┃ La commande arrive dans le prompt : ajoute ⎿ flow-board: Tableau de bord ouvert : aucun parcours en cours. │ ┃ le sujet, puis Entrée. │ ┃ │ ┃ [ ◆ Cadrer une fonctionnalité ] /grill-with │ ┃ [ ▤ Rédiger la spec de ce qu’on a discuté ] │ ┃ [ ▦ Découper un plan en tickets ] /to-ticke │ ┃ [ ⚙ Implémenter un ticket ] /implement  ·   │ ┃ [ ◎ Diagnostiquer un bug ] /diagnosing-bugs │ ┃ [ ◆ Relire une branche ou une PR ] /code-re │ ┃ [ ☰ Trier les issues ] /triage  ·  machine │ ┃ [ ⚑ Explorer un grand chantier ] /wayfinder │ ┃ [ ▲ Santé de l’architecture ] /improve-code ▍ Flow  Aucun workflow sélectionné ▸ /flow x ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▍ Flow  Aucun workflow sélectionné ▸ /flow x
Pane · Parcours
╭────────────────────────────────────────────────────────╮ │ │ │ ◆ Tableau de bord du workflow │ │ Le suivi démarre au premier skill mattpocock lancé │ │ dans app : phases, étapes, tickets, sous-agents, │ │ tests et livrables, le tout en direct. │ │ │ ╰────────────────────────────────────────────────────────╯ ▸ Parcours Activité Historique · 0 Commandes Démarrer un parcours La commande arrive dans le prompt : ajoute le sujet, puis Entrée. [ ◆ Cadrer une fonctionnalité ] /grill-with-docs  ·  interv [ ▤ Rédiger la spec de ce qu’on a discuté ] /to-spec  ·  sy [ ▦ Découper un plan en tickets ] /to-tickets  ·  tranches [ ⚙ Implémenter un ticket ] /implement  ·  TDD, revue puis [ ◎ Diagnostiquer un bug ] /diagnosing-bugs  ·  boucle de f [ ◆ Relire une branche ou une PR ] /code-review  ·  les bug [ ☰ Trier les issues ] /triage  ·  machine à états du triag [ ⚑ Explorer un grand chantier ] /wayfinder  ·  carte de ti [ ▲ Santé de l’architecture ] /improve-codebase-architectur ╭────────────────────────────────────────────────────────── │ À tout moment la commande va dans le prompt, la phase en │ │ Comprendre │ [ ? Reformulation ] [ ✧ Recherche ] [ ✦ Tester une idée ] │ │ S’orienter │ [ ✱ Et maintenant ? ] [ ◇ Challenger ] [ § Préciser un te │ │ Contexte │ [ ⇣ Compact ] [ ➜ Passation ] ╰──────────────────────────────────────────────────────────
README

flow-board

Tableau de bord en direct du workflow mattpocock-skills dans Claude Code. Il suit chaque travail sous la forme d'un parcours, de l'idée jusqu'à la pull request, et l'affiche dans un panneau et dans un bandeau au-dessus du prompt.

  • Il s'adapte au skill de départ. /grill-with-docs, /to-spec, /diagnosing-bugs, /wayfinder ou /implement n'ouvrent pas le même parcours.
  • Il se remplit tout seul. Il suit les skills chargés, les tickets Asana, les sous-agents, les tests, les commits et les livrables.
  • Il garde les parcours par projet. Plusieurs discussions peuvent travailler sur le même projet sans se marcher dessus.
  • Il suit les conventions de livraison du plugin project : /project:sync, /project:version, /project:push, /project:pr.

Version actuelle : 0.57.0. Historique des versions : CHANGELOG.md.


Sommaire

  1. Installation
  2. Concepts
  3. Utilisation
  4. Parcours par skill de départ
  5. Ce qui est suivi automatiquement
  6. Tickets, dépendances et lancement
  7. Livraison et synchronisation
  8. Plusieurs discussions sur un même projet
  9. Commandes
  10. Données et stockage
  11. Architecture
  12. Développement
  13. Limites connues

Installation

Le mod est distribué par la marketplace mathis-plugins (dépôt GitHub matwi5/mathis-plugins, source dans plugins/flow-board/). Installation d'un poste et protocole de développement : README à la racine du dépôt.

claude plugin marketplace update mathis-plugins
claude plugin install flow-board@mathis-plugins

Pour une nouvelle version : modifier la source dans le clone de travail (plugins/flow-board), monter version dans .claude-plugin/plugin.json et dans l'entrée du mod de .claude-plugin/marketplace.json à la racine du dépôt, commiter et pousser, puis :

claude plugin marketplace update mathis-plugins
claude plugin update flow-board@mathis-plugins

Une mise à jour s'applique aux discussions démarrées après elle, ou après /reload-plugins.

Prérequis

  • Claude Code avec les function hooks : mods de la version 2.1.286 ou plus récente.
  • Le plugin mattpocock-skills, fourni par cette marketplace en copie figée (plugins/mattpocock-skills, version 1.3.1 de Matt Pocock).
  • Facultatifs :
  • le plugin project, pour la phase de livraison ;
  • le connecteur Asana, pour suivre les tickets ;
  • un dépôt git avec un distant, pour la synchronisation.

Sessions liées : Desktop et CLI (depuis la 0.35.0). Les boutons « session liée » mettent dans le prompt une demande valable dans les deux ; Claude choisit selon ses outils :

App Desktop (onglet Code)CLI
Lancementstart_session (context « fresh », wake_when_done)claude --bg -n "<titre>" par le shell, avec -w <nom> dans un dépôt git
Id relevédans la réponse de start_session (local_…)dans la sortie « backgrounded · <id> · <titre> »
Note de finl'app relance la discussion principale quand la fille finit un tourla fille envoie son message final par SendMessage à la discussion principale, ce qui la réveille même inactive ; depuis la 0.56.0, le brief ne donne que la référence stable de la discussion (« [ref] ») : la fille la retrouve toujours dans ListAgents et envoie à « <nom du moment> [ref] » (avant, le nom d'abord et la référence en repli, depuis la 0.45.0) ; une session de groupe dépose son compte rendu complet au relais et n'envoie qu'une ligne qui y renvoie ; depuis la 0.56.0, plus de filet notify_when_idle (cinq réveils pour un groupe, dont quatre pour rien) : le message final de la fille réveille la discussion, et le relais rattrape un message perdu

Côté CLI, le dossier doit avoir été approuvé dans le CLI (sinon « Workspace not trusted ») ; la combinaison --bg + -w n'a pas pu être essayée (à confirmer). Les deux discussions doivent être sur la même machine : le fichier des parcours est local.


Concepts

TermeDéfinition
ParcoursUn travail suivi de bout en bout. Le skill qui l'ouvre fixe ses phases. Un parcours est en cours, en pause ou terminé. Un parcours mis de côté est en pause par ta décision : seul « Reprendre » le rouvre.
PhaseUne étape du parcours, tenue par un skill : Cadrage, Spec, Tickets, Implémentation, Version et push, Pull request… Certaines phases sont facultatives (en pointillé).
ÉtapeUne étape interne au skill de la phase. Par exemple, pour /diagnosing-bugs : boucle de feedback, reproduire, hypothèses, instrumenter, correctif, nettoyage. Une étape enjambée sans être faite est marquée sautée.
DétourUn skill lancé hors du fil du parcours (/prototype, /research, /handoff…), affiché en bulle au-dessus de la phase en cours.
TicketUne unité de travail du lot, identifiée par son GID Asana, avec son statut et ses dépendances.
Dépendance externeUne tâche hors du lot dont un ticket dépend : une décision à prendre, ou le travail de quelqu'un d'autre.
Décidé sans toiUn choix que Claude a fait sans que la personne l'ait demandé ni validé : ticket traité hors de la file, approche choisie parmi plusieurs, travail reporté, périmètre changé. Il reste « à relire » jusqu'à « ✓ Relu ».
Trouvé en routeUne tâche créée dans le tracker pendant le parcours, hors du lot : constat de revue, anomalie découverte, action pour la personne. Elle s'affiche à part, sans compter dans la progression ni retenir la livraison.

Utilisation

Dès le début d'une discussion, sans parcours actif, un bandeau d'accueil d'une ligne dit « Aucun workflow sélectionné ». Si le projet a un parcours en pause ou suivi dans une autre discussion, il propose le plus récent, avec son glyphe, son titre et un bouton « ↻ Reprendre ». Le « x » et /flow hide le masquent comme le bandeau normal.

Le bandeau s'ouvre sur l'étiquette « ▍ Flow », comme ceux de git-board et d'asana-board ; il tient sur une ligne (depuis la 0.40.0). Empilé sous le bandeau d'un autre mod, il se place juste en dessous.

  1. Lancer un skill mattpocock, par exemple /mattpocock-skills:grill-with-docs export CSV. Le parcours démarre et le panneau s'ouvre tout seul quand la place le permet ; sinon, taper /flow (ou /flow-board:flow en tout premier message, voir Commandes).
  2. Suivre l'avancement. Le bandeau au-dessus du prompt résume, sur une ligne :
  3. le parcours et sa feuille de route ;
  4. la phase en cours ;
  5. les tickets faits, et l'avancement de chaque groupe en session liée (« ↗A 2/5 ») ;
  6. les sous-agents au travail, et les choix « ⚑ à relire ». L'état de la session (« Claude travaille », « À toi de jouer ») et l'étape sont dans l'en-tête du panneau (/flow), plus dans le bandeau (depuis la 0.40.0).
  7. Passer à la suite. La carte « Prochaine étape » propose le skill suivant. Son bouton met la commande dans le prompt sans la lancer : on la complète, puis Entrée. Tant que des tickets restent ouverts, ni le compte rendu ni la livraison (version, push, pull request) n'y sont proposés. Dans l'Implémentation d'un parcours à groupes (depuis la 0.42.0), la carte « Ensuite » recommande plutôt quoi lancer : le lot de groupes indépendants en parallèle (« ⇉ B ∥ C en parallèle · sessions liées »), sinon le prochain groupe prêt (« ↗ Groupe D · session liée », ou « ▶ Lancer D ici »), le groupe commencé ici d'abord. Un groupe prêt qui partage un fichier ou une ressource avec un groupe en session liée attend : la carte dit alors quel groupe on attend. Une fois tous les tickets faits, elle dit quels groupes attendent leur revue ; les revues restent sur la ligne de chaque groupe, et le compte rendu vient en tête une fois toutes faites.

Suggestion grisée de la suite (depuis la 0.47.0). À la fin de chaque tour de la discussion principale, la commande du bouton principal de la carte (par exemple /mattpocock-skills:to-tickets, ou /flow-board:compte-rendu une fois tous les tickets faits et relus) se propose en gris dans le prompt vide : Tab (ou la flèche droite) la prend, rien ne part sans toi. Rien n'est proposé quand une question t'attend (question du grilling, réponse qui finit par « ? »), dans une session liée, quand la suite n'est pas une commande (lancer un groupe, attendre une session liée, revue sur la ligne d'un groupe) ni pour la rétro d'un parcours à sessions liées, qui part avec sa consigne. Tant qu'un parcours est actif, la suggestion /project:push de git-board est écartée (pas de push en cours de parcours) ; elle revient hors parcours.

La barre « À tout moment »

En bas de l'onglet Parcours, qu'un parcours soit en cours ou non, un cadre « À tout moment » réunit neuf outils dans l'ordre où on s'en sert, et un dixième pendant les phases de code et de revue. Chaque bouton met la commande dans le prompt sans la lancer, et aucun ne clôt la phase en cours (ce sont des détours ou des événements).

FamilleBoutonCommandeCe qu'il fait
Comprendre? Reformulation/wait-whatLe dernier message reformulé, plus simplement ; part tel quel
Comprendre✧ Recherche/researchSources primaires, un rapport Markdown ; ajouter le sujet
Comprendre✦ Tester une idée/prototypeUne question de conception qu'on ne tranche pas en discutant (une logique, un modèle d'états, une interface), réglée par du code jetable ; souvent pendant le cadrage. Sans parcours, il ouvre le sien ; ajouter la question
Comprendre≡ Questionnaire/to-questionnaireUne décision qu'on ne tranche pas seul, mise en questionnaire ; ajouter le sujet
S'orienter✱ Et maintenant ?/ask-mattQuelle suite, quand on est perdu. Pendant un parcours, la question est préremplie avec le parcours, la phase, l'étape et le détour en cours ; sans parcours : « par où commencer ? ». Part telle quelle, ou complétée
S'orienter◇ Challenger/grillingUne décision ou un plan mis à l'épreuve par des questions, sans quitter la phase ; ajouter le sujet
S'orienter§ Préciser un terme/domain-modelingUn terme flou ou une règle métier précisés par des questions (écarts avec le glossaire, cas limites, contradictions avec le code), puis écrits dans le glossaire ou, pour une décision difficile à défaire, dans les décisions du projet. Le cadrage l'appelle déjà : le bouton sert surtout en implémentation ; ajouter le sujet
Contexte⇣ Compact/compactCommande de Claude Code qui allège le contexte ; consignes facultatives
Contexte➜ Passation/handoffLa discussion résumée en document de passation pour une autre session ; ajouter son but
Déléguer🐞 Bug · session liée/diagnosing-bugsDepuis la 0.26.0, seulement pendant Implémentation, Implémentation de spec, TDD, Revue ou Compte rendu. Met dans le prompt une demande à Claude, à compléter après « Bug : ». Claude lance une session liée dans ce dossier (pas de worktree : elle doit lire le même fichier de parcours), qui reprend le parcours (resume), diagnostique le bug en détour de la phase en cours, le corrige selon le CLAUDE.md du projet, puis clôt le détour (complete). Depuis la 0.55.0, la session est lancée en effort high, et xhigh pour une deuxième tentative sur un bug toujours là après la première ; pas de troisième session : on replanifie. À sa note de fin, Claude vérifie son travail ; si le bug est toujours là, il le dit et propose la deuxième tentative, sinon il reprend le parcours

Le panneau

OngletContenu
ParcoursEn-tête du parcours choisi dans la discussion (anneau de progression, feuille de route animée) ; sans parcours choisi (aucun, mis de côté, en pause ou suivi ailleurs), l'en-tête dit « Aucun parcours sélectionné » et n'en montre aucun, carte de la phase (tickets en cours, étapes, éléments suivis, tickets, tests, revue, détour), sous-agents, prochaine étape, « ‖ Mettre de côté ». Sans parcours en cours, le lanceur « Démarrer un parcours », dans l'ordre du flux : Cadrer une fonctionnalité, Rédiger la spec de ce qu'on a discuté, Découper un plan en tickets, Implémenter un ticket ; puis Diagnostiquer un bug, Relire une branche ou une PR, Trier les issues, Explorer un grand chantier, Santé de l'architecture. En bas, toujours, la barre « À tout moment ».
ActivitéChiffres (fichiers, commits, merges, termes du glossaire, ADR, tests), livrables écrits, journal des événements.
HistoriqueLes parcours du projet, avec « Reprendre », « Reprendre ici » et « ▣ Archiver ». Les archivés sont cachés par défaut, sous « ▸ Archivés (N) », avec « ▢ Désarchiver » et « ✗ Supprimer ». La suppression demande une confirmation (« Supprimer définitivement ce parcours ? ») et ne se défait pas ; les autres discussions du projet l'oublient aussi.
CommandesLes points de départ, les 18 autres skills groupés par famille, les commandes de livraison et les commandes /flow.

Les dessins (feuille de route, anneaux, barres) sont en SVG sur le desktop, VS Code et le mobile, et en glyphes sur le terminal.


Parcours par skill de départ

Skill de départParcoursPhases
/grill-with-docsNouvelle fonctionnalitéCadrage → Spec¹ → Tickets¹ → Implémentation → Compte rendu → Version et push¹ → Pull request¹
/to-specDe la spec au codeSpec → Tickets → Implémentation → Compte rendu → Version et push¹ → Pull request¹
/to-ticketsDes tickets au codeTickets → Implémentation → Compte rendu → Version et push¹ → Pull request¹
/implement, /implement-specImplémentationImplémentation → Compte rendu → Version et push¹ → Pull request¹
/diagnosing-bugsChasse au bugDiagnostic → Revue¹ → Compte rendu → Version et push¹ → Pull request¹
/tddTDDTDD → Revue¹ → Compte rendu → Version et push¹ → Pull request¹
/triageTriageTriage → Implémentation¹ → Compte rendu² → Version et push¹ → Pull request¹
/wayfinderGrand chantierWayfinder → Spec¹ → Tickets¹ → Implémentation¹ → Compte rendu² → Version et push¹ → Pull request¹
/improve-codebase-architectureSanté du codeArchitecture → Cadrage¹ → Spec¹ → Tickets¹ → Implémentation¹ → Compte rendu² → Version et push¹ → Pull request¹
/code-review (Claude Code), sans parcours en coursRelecture de brancheRelecture → Compte rendu → Version et push¹ → Pull request¹
/mattpocock-skills:code-reviewRevue de codeRevue → Compte rendu → Version et push¹ → Pull request¹
/grill-me, /prototype, /research, /teach, /wizard…Un parcours d'une phaseLes étapes du skill

¹ Phase facultative. ² Facultatif tant qu'on n'a pas codé, obligatoire dès que l'Implémentation démarre (depuis la 0.30.0) : un triage ou une carte sans code se clôt sans compte rendu.

Compte rendu. Avant toute livraison, la phase « Compte rendu » (/flow-board:compte-rendu, lancé par toi) fait écrire à Claude un rapport à lire et à valider :

  • sections fixes : En bref, Fait, Décidé sans toi, Ce qui s'est mal passé, Bloqué par moi, Trouvé, Documentation, Ce que la livraison enverra ; une section vide dit « Rien. » ;
  • « Documentation » donne une ligne par document que le projet tient (état du projet, décisions, CLAUDE.md, specs, plans, tracker…) : à jour, complété avant le rapport, rien à changer, ou à faire. Claude complète ce qui manque avant d'écrire, et ne laisse « à faire » que ce qui demande ta décision ;
  • Claude part des faits du tableau de bord (tickets, trouvailles, choix faits sans toi, plans, tests), qu'il vérifie et complète par git et le tracker ; il copie aussi le rapport dans le fichier de suivi travail/tasks/… s'il y en a un ;
  • toi seul valides, dans le panneau : « ✓ Valider le rapport » clôt la phase, marque les choix « décidé sans toi » comme relus et propose Version et push ; « ✎ À reprendre » met « Rapport non validé : » dans le prompt. Claude ne peut pas clore la phase, mais chaque réponse de l'outil flow (et de /flow) lui donne l'état du rapport : « rapport : validé (date UTC) », « en attente de validation », « non validé (sauté) » ou « pas encore rédigé » ;
  • tant que le rapport n'est pas validé, la livraison n'est pas proposée ; lancer /project:version quand même saute le compte rendu, et le journal le dit.

Les parcours existants reçoivent la phase à leur chargement, avant leur « Version et push » à venir.

Un parcours en plusieurs groupes passe souvent par plusieurs discussions ; le compte rendu se rédige dans la dernière. Il couvre quand même tous les groupes : le tableau de bord, partagé, lui donne l'état de chaque groupe (fait, relu) et la liste des fichiers de suivi travail/tasks/…, un par discussion, enregistrés comme livrables « Suivi » avec leur groupe. Claude les relit tous, avec git log depuis le début du parcours, et écrit « Fait » groupe par groupe.

La rétro (/retro) n'est pas une phase : elle reste proposée à tout moment dans la carte « Ensuite ». Un parcours créé par une version antérieure est mis à niveau à son chargement : sa rétro à venir disparaît, et un « Corps de PR » à venir devient « Version et push » puis « Pull request ».

Rétro d'un parcours à sessions liées (depuis la 0.43.0). Quand le parcours a eu des sessions filles (un groupe confié ou rendu, des pistes confiées), le bouton Rétro, dans la carte « Ensuite » comme dans « Autres commandes », met dans le prompt /retro suivi d'une demande à Claude, dans la discussion principale (la session mère) :

  • retrouver les sessions filles par ListAgents, d'après le début de leur titre (« <parcours> › ») et les ids relevés à leur lancement ;
  • lancer un sous-agent par session fille, en parallèle et en lecture seule : chacun lit le transcript de sa session et en fait la rétro selon les étapes et les sept axes de /retro, que la mère lui transmet, puis rend ses pistes par sévérité. Le transcript se lit même quand la session fille est fermée ou compactée, et la discussion principale ne se charge pas de sa lecture ;
  • faire en même temps la rétro de la discussion principale ;
  • une fois tous les comptes rendus reçus, faire le compte rendu global : pistes fusionnées et sans doublon, par sévérité, chacune avec ses sessions d'origine. Cette liste devient les pistes de la rétro au tableau de bord.

Sans session fille, le bouton met /retro seul. Une session de diagnostic de bug ne laisse pas de trace dans le parcours : elle n'est retrouvée que par son titre, si le parcours a aussi eu une autre session fille.

Pistes de la rétro en session liée (depuis la 0.33.0). Une fois les pistes présentées, chacune a sa case (○ / ●) dans la carte de la phase, avec « Tout sélectionner » au-dessus de la liste. Avec au moins une piste choisie, « ↗ Traiter N pistes · session liée » :

  • met dans le prompt une demande à Claude (depuis la 0.51.0, les pistes ne passent « en cours » et l'étape « Modifications en cours », la dernière de la rétro, ne s'ouvre qu'au lancement de la session, pas au clic), qui rédige lui-même le brief, puisque les pistes n'ont qu'un libellé et que lui connaît la session étudiée :
  • d'abord, régler avec toi les pistes qui attendent une information ou une décision de ta part (un accès, un choix entre options), sinon la session y buterait ;
  • lancer la session par start_session avec context « fresh » (une discussion de rétro est longue : un fork la transporterait toute), wake_when_done et un worktree, ou le dossier lui-même s'il n'a pas de git ; dans le CLI, par claude --bg (voir « Sessions liées : Desktop et CLI ») ;
  • seules les pistes de ce dépôt (depuis la 0.54.0) : une piste qui touche un autre dépôt (plugins, config commune, autre projet) n'entre pas dans le prompt de la session ; Claude te la rend dans une liste de corrections prête à confier à un agent de ce dépôt et, une fois la session lancée, la rend « à faire » (elle revient à ton choix) ; si aucune piste choisie n'est de ce dépôt, il ne lance rien et te dit d'utiliser « Annuler » ;
  • un background qui ne reprend pas le texte des règles (depuis la 0.54.0) : il cite le document du projet pour les sessions déléguées, son CLAUDE.md et tes règles communes par leur chemin, et ne garde que ce qui varie (le projet, la session étudiée, la ressource unique comme une appli ouverte, les décisions par renvoi) ;
  • une section par piste : le problème constaté, la correction attendue, les fichiers touchés, un critère de fin vérifiable ;
  • un travail en séquence, les pistes qui touchent les mêmes fichiers regroupées, sans sous-agents qui écrivent (en parallèle, seulement des recherches en lecture seule) ; les contrôles du CLAUDE.md du projet à la fin ; rien sur le tableau de bord ;
  • un compte rendu final : une ligne par piste (faite, ou pas faite et pourquoi), les fichiers modifiés, les contrôles et leur résultat, ce qui t'attend ;
  • si Claude ne peut pas lancer la session (outil absent, refus, erreur), il le dit avec la raison et ne rend aucune piste ;
  • le parcours reste dans la discussion principale : la session liée ne le reprend pas. Claude la suit d'ici ; à sa note de fin, il vérifie son travail et rend le statut de chaque piste : faite (vérifiée), à faire si la session ne l'a pas faite (elle revient à ton choix et peut repartir dans un envoi suivant), écartée seulement si tu y renonces. Il ne clôt la rétro que si aucune piste n'est revenue à ton choix. Tant que des pistes sont chez la session, lancée ou non, la rétro ne se clôt pas.

La carte suit l'envoi :

  • « ↗ Session liée à lancer » tant qu'aucune session n'est lancée : la demande est dans le prompt, les pistes confiées portent ↗ (ni à choisir, ni en cours) et le journal dit « Session liée demandée », pas « confiées ». Des pistes choisies en plus rejoignent l'envoi, et la nouvelle demande les liste toutes. « Annuler » les remet à ton choix ; « ↻ Redemander », grisé, remet la demande dans le prompt si elle n'y est plus ;
  • « ⚠ Aucun lancement relevé » (depuis la 0.51.0) quand le tour qui suit ton message se finit sans session lancée (un tour ouvert par une notification ou une autre session ne compte pas, il n'a pas vu la demande) : Claude a pu te poser une question d'abord (réponds-lui, la marque s'efface au lancement), ou ne pas la lancer du tout. « ↻ Redemander » remet la demande dans le prompt, précédée d'une consigne : vérifier d'abord qu'aucune session n'est partie sans que son id soit relevé, pour ne pas en lancer une seconde. « Annuler » rend les pistes à ton choix ;
  • « ↗ Session liée au travail · 3 pistes en cours » une fois la session lancée : son id est relevé dans la réponse de start_session, ou dans la sortie de claude --bg, sauf si son titre nomme un groupe ou un bug (« … › Groupe A · », « … › Bug · »). Les pistes ne bougent qu'à sa note de fin, d'où « en cours » plutôt qu'un compteur à 0 ; chaque réponse de l'outil le rappelle à Claude, pour qu'une liste renvoyée en route les garde « en cours ». Aucune piste ne peut partir tant que la session en a : une nouvelle demande lancerait une seconde session que le tableau de bord ne suivrait pas ;
  • « ✓ Session liée terminée ».

« ↻ Reprendre les pistes », proposé dès l'envoi, met dans le prompt une demande à Claude, qui marche dans l'app Desktop comme dans le CLI (outils de session de l'app, sinon ListAgents, l'état git de son worktree, le tracker et au besoin le transcript .jsonl sous ~/.claude/projects/ ; depuis la 0.54.0, plus claude logs, dont la sortie de terminal est illisible). Avec un id relevé : vérifier l'état de la session et son travail, puis rendre

Source 6 files
hooks/register.tsx 2441 lines
1// flow-board : le tableau de bord du workflow mattpocock-skills.
2//
3// - Suit les skills du plugin dès qu'ils se chargent (commande slash, outil Skill) : le
4//   skill de départ fixe la forme du parcours, les suivants le font avancer, les autres
5//   s'y greffent en détours.
6// - Claude complète par l'outil `flow` (étapes, éléments, tickets) : une courte note
7//   ajoutée au texte de chaque skill lui donne les ids d'étapes à signaler.
8// - Le reste est relevé seul : sous-agents, tests, commits, issues et PR, fichiers du
9//   workflow (glossaire, ADR, specs, tickets locaux…), tâches Asana, questions du grilling.
10// - Les parcours sont gardés par projet dans un fichier commun, `~/.claude/flow-board/parcours/`
11//   (`$.store` en secours) : un workflow mattpocock s'étale sur plusieurs sessions (`/clear` entre
12//   deux tickets), et le desktop comme le CLI doivent voir les mêmes.
13
14import { atom, read, update } from 'claude-code'
15import type { Register } from 'claude-code'
16
17import type { Board, FlowPhase, Relay, Run, SyncState, Tab } from '../types'
18import {
19  CODE_SKILLS,
20  GROUP_REVIEWER,
21  NO_GIT_COPIES,
22  NO_GIT_GROUP_FIXED_POINT,
23  NO_GIT_TICKET_FIXED_POINT,
24  PLUGIN,
25  INSTRUCTIONS_IN_PROMPT,
26  REVIEWER_BRIEF,
27  REVIEW_RULE,
28  SECURITY_CRITERIA,
29  SENSITIVE_AREAS,
30  SENSITIVE_NO_FIX,
31  SINGLE_TICKET_GROUP,
32  SKILLS,
33  TICKET_FIXED_POINT,
34  commandLineOf,
35  hintOf,
36  reviewInstructions,
37  skillIdOf,
38  slashOf,
39} from './catalog.ts'
40import {
41  activeDetour,
42  activeRun,
43  applyAgentEnd,
44  applyAgentStart,
45  applyAnswer,
46  applyAsana,
47  applyRelay,
48  asanaChanges,
49  askCloseLinkedGroup,
50  reloadText,
51  restoreDelegate,
52  applyCommand,
53  applyCreatedTasks,
54  markDecisionsSeen,
55  reportFacts,
56  validateReport,
57  applyFileChange,
58  applyReport,
59  applySkill,
60  applySubagentStop,
61  applyAgentList,
62  applyUserAnswer,
63  backgroundLaunches,
64  commandSignals,
65  reportedReport,
66  commandText,
67  countTickets,
68  currentPhase,
69  decidedFileText,
70  isBugPass,
71  relayedReportKeys,
72  archiveRun,
73  cancelLinked,
74  cancelLinkedGroup,
75  clip,
76  dropRun,
77  linkGroupSession,
78  linkSession,
79  linkedSessionsOf,
80  noteUnlaunched,
81  retryLinked,
82  startLinkedGroups,
83  type GroupView,
84  linkedInFlight,
85  pickableItems,
86  startLinkedItems,
87  renameRun,
88  unarchiveRun,
89  EMPTY_SYNC,
90  emptyBoard,
91  finishActive,
92  flowCallLabel,
93  focusOf,
94  clampFuture,
95  groupPlan,
96  hasSpecAndTickets,
97  noActiveFrench,
98  withoutPullRequest,
99  isBoard,
100  detachOnLoad,
101  mergeBoards,
102  nextCommandSkill,
103  parkActive,
104  resumeRun,
105  setAsideActive,
106  setQueue,
107  togglePick,
108  summaryOf,
109  syncAdvice,
110  type Effects,
111  type Outcome,
112  reportedChanges,
113  reportedDecisions,
114  type RelayEvent,
115  commandArgsOf,
116} from './model.ts'
117import type { LiveAgent } from './model.ts'
118import { drawBand, drawPane, drawWelcomeBand, kitOf, type Actions, type View } from './view.tsx'
119
120const PANE = 'flow-board'
121const PANE_TITLE = 'Parcours'
122const TOOL = 'mcp__flow-board__flow'
123/** Le plugin dont la suggestion `/project:push` s'écarte pendant un parcours (pas de push en cours de route). */
124const GIT_BOARD = 'git-board'
125/** Le second essai de la suggestion de fin de tour, si le premier tombe encore dans le tour. */
126const SUGGEST_RETRY_MS = 300
127/**
128 * L'outil de l'app Desktop qui lance une session liée ; sa réponse donne l'id `local_…` de la
129 * session. Le CLI ne l'a pas : il lance la session par `claude --bg` (voir `backgroundLaunches`).
130 */
131const START_SESSION = 'mcp__ccd_session__start_session'
132/** La commande déclarée par `commands/flow.md`, préfixée du nom du plugin. */
133const STATIC_FLOW = 'flow-board:flow'
134/** Ce que la consigne de file dit de chaque ticket, selon son état. */
135const QUEUE_STATE: Record<string, string> = {
136  todo: '',
137  doing: ' (in progress: partial work may already be uncommitted in the working tree)',
138  done: ' (done and committed: skip it)',
139  blocked: ' (blocked)',
140}
141
142/** Le texte d'un appel d'outil refusé ou interrompu par la personne. */
143const CANCELLED = /doesn't want to proceed|tool use was rejected|interrupted by user/i
144
145const BOARD_REF = { plugin: 'flow-board', key: 'board' } as const
146const boardAtom = atom({ plugin: 'flow-board', key: 'board' } as const, null)
147const liveAtom = atom({ plugin: 'flow-board', key: 'live' } as const, { mode: 'idle', since: 0, ask: null })
148const uiAtom = atom({ plugin: 'flow-board', key: 'ui' } as const, {
149  tab: 'flow',
150  isBandHidden: false,
151  isPaneDismissed: false,
152  showAllTickets: false,
153  showAllLog: false,
154  showDecided: false,
155  showFound: false,
156  selection: [] as string[],
157})
158const commandsAtom = atom({ plugin: 'flow-board', key: 'commands' } as const, {})
159const syncAtom = atom({ plugin: 'flow-board', key: 'sync' } as const, EMPTY_SYNC)
160const relayAtom = atom({ plugin: 'flow-board', key: 'relay' } as const, null)
161
162const TOOL_DESCRIPTION =
163  'Live dashboard of the mattpocock-skills workflow (flow-board). When a mattpocock skill loads, its text ends ' +
164  'with the step ids to report here. Silent bookkeeping: no need to mention it to the user; batch it with your ' +
165  'other tool calls. Subagents, tests, commits, issues, PRs and workflow files are tracked automatically. ' +
166  'Ticket status: a ticket that only waits for other tickets stays "todo" with "blockedBy" (shown as waiting); ' +
167  '"blocked" is for a ticket that waits on something outside the lot (a decision or approval of the user, an access, ' +
168  'someone else\'s work), with that reason in "why". Tasks you create in the tracker outside /to-tickets (review ' +
169  'findings, anomalies, actions for the user) appear by themselves under "found on the way", outside the lot and its ' +
170  'progress: send one in "tickets" (with its blockedBy) only when the lot depends on it. When the lot has groups and ' +
171  'you add a ticket to it, send "groups" again with that ticket in the group of the work it belongs to. Each time you make a choice ' +
172  'the user neither asked for nor approved (work on a ticket outside the queue, one approach among several, something ' +
173  'skipped or deferred, a scope change, a fix beyond the ticket), report it at once in "decided": the user reviews ' +
174  'these. Not for the routine steps a skill prescribes, nor for proposals you ask the user about. After each code review, ' +
175  'report its findings in "review". The order and content of the reviews of a ticket and of a group are those of the steps the ' +
176  'skill text lists (ticket: Standards and Spec axes, plus the bug pass /code-review in the same message for a sensitive area; group: ' +
177  '/code-review high --fix, without --fix for a group with a sensitive ticket, in a reviewer subagent that applies its fixes, with the security review in the same message). With the review of a finished group, judge whether a security review is worth it ' +
178  'and send it in "security"; when it is, launch it yourself, in this discussion as in a linked session: a read-only reviewer subagent ' +
179  'limited to the group\'s diff (never /security-review), then send its findings; the user decides on their fixes. ' +
180  'A plugin reload (/reload-plugins) never requires sending tickets, groups or steps again: the dashboard keeps its state, and its first answer after a reload says what it found. ' +
181  'A group the user delegated to a linked session from the dashboard stays in this workflow here: when that session ends, ' +
182  'check its work, then send its tickets\' statuses in "set" together with {"returned": ["<group id>"]}. ' +
183  'Only when the user asks for it (a new workflow for some tickets, even while another one is active here), or to repair your own mistake: ' +
184  '"open" starts a new workflow in this discussion without any skill, and the rest of the same call (tickets, groups...) goes to it; ' +
185  '"fix" corrects the active workflow (title, current phase, step). Never open a workflow on your own initiative: ' +
186  'when nothing is active here, the answer names the workflows to resume, so ask the user. To take tickets out of the wrong ' +
187  'workflow, send "remove" in a call before the "open" one.'
188
189const TOOL_SCHEMA = {
190  type: 'object',
191  properties: {
192    title: { type: 'string', description: "What the work is about, 3 to 6 words, in the user's language; only the first one sent is kept (fix.title changes it)" },
193    step: { type: 'string', description: 'Id of the step of the current skill that starts now' },
194    items: {
195      type: 'array',
196      description: 'Full list of the items the current skill tracks (hypotheses, candidates, seams, decisions, variants)',
197      items: {
198        type: 'object',
199        properties: {
200          id: { type: 'string' },
201          label: { type: 'string' },
202          status: { type: 'string', enum: ['todo', 'doing', 'done', 'dropped'] },
203        },
204        required: ['id', 'label'],
205      },
206    },
207    tickets: {
208      type: 'array',
209      description: 'Tickets to add or update (id = tracker id, name = short title); tickets not listed are kept. To take tickets off, use "remove"',
210      items: {
211        type: 'object',
212        properties: {
213          id: { type: 'string' },
214          name: { type: 'string' },
215          status: { type: 'string', enum: ['todo', 'doing', 'done', 'blocked'] },
216          blockedBy: { type: 'array', items: { type: 'string' }, description: 'Ids of the tickets that must be done before this one' },
217          why: { type: 'string', description: "With status blocked: what it waits for outside the lot, short, in the user's language" },
218          files: {
219            type: 'array',
220            items: { type: 'string' },
221            description:
222              "Repo paths the ticket touches, from its acceptance criteria (pages, tables, shared files such as a model or a measures file); they add to its group's files. Name files rather than a whole folder, unless the ticket creates files in it",
223          },
224          sensitive: { type: 'boolean', description: `Its line "Zone sensible": true when it touches a sensitive area (${SENSITIVE_AREAS})` },
225          logic: { type: 'boolean', description: 'Its line "Logique": false for a visual asset or a document, which has no logic to review' },
226        },
227        required: ['id', 'name'],
228      },
229    },
230    review: {
231      type: 'object',
232      description: 'After each code review (/mattpocock-skills:code-review or another): its real findings per axis, not the "nothing to report" lines, and how many you fixed',
233      properties: {
234        standards: { type: 'number' },
235        spec: { type: 'number' },
236        fixed: { type: 'number' },
237      },
238    },
239    security: {
240      type: 'object',
241      description:
242        'Security review of a finished group (or of the whole run when it has no groups). In the same message as the review of a finished group (without groups: once the review of the run is done): {"group", "advised", "why"}, your judgment. After the reviewer subagent you launched: {"group", "findings", "fixed"}',
243      properties: {
244        group: { type: 'string', description: 'Group id (A, B...); omitted = the last finished group, or the whole run when it has no groups' },
245        advised: { type: 'boolean', description: 'true when the changes touch authentication, permissions or access rules, user input reaching a query, a path or HTML, secrets or tokens, network or file access, uploads or dependencies' },
246        why: { type: 'string', description: "Why, short, in the user's language" },
247        findings: { type: 'number', description: 'Real findings of the security review' },
248        fixed: { type: 'number', description: 'How many of them were fixed' },
249      },
250    },
251    decided: {
252      type: 'array',
253      description: `Choices you made without the user asking for or approving them, to be reviewed; each sent once. Never a choice that touches a sensitive area (${SENSITIVE_AREAS}): ask the user instead`,
254      items: {
255        type: 'object',
256        properties: {
257          what: { type: 'string', description: "The choice, short, in the user's language" },
258          why: { type: 'string', description: "Why, short, in the user's language" },
259        },
260        required: ['what'],
261      },
262    },
263    remove: {
264      type: 'array',
265      items: { type: 'string' },
266      description: 'Ids of tickets to take off the dashboard (sent by mistake, out of scope); also dropped from groups, the queue and blockedBy',
267    },
268    set: {
269      type: 'array',
270      description: 'Status changes of tickets already listed (in the lot or found on the way)',
271      items: {
272        type: 'object',
273        properties: {
274          id: { type: 'string' },
275          status: { type: 'string', enum: ['todo', 'doing', 'done', 'blocked'] },
276          why: { type: 'string', description: "With status blocked: what it waits for outside the lot, short, in the user's language" },
277        },
278        required: ['id', 'status'],
279      },
280    },
281    externals: {
282      type: 'array',
283      description: 'Tasks outside the ticket lot that some tickets depend on (put their ids in those tickets\' blockedBy); added or updated, never removed',
284      items: {
285        type: 'object',
286        properties: {
287          id: { type: 'string', description: 'Asana task GID' },
288          name: { type: 'string' },
289          kind: { type: 'string', enum: ['decision', 'task'], description: 'decision: a choice someone must make; task: work outside the lot' },
290          status: { type: 'string', enum: ['todo', 'done'] },
291        },
292        required: ['id', 'name'],
293      },
294    },
295    groups: {
296      type: 'array',
297      description:
298        'Execution plan: the tickets grouped by shared context. Same group = tickets that touch the same files or modules, or chain through their dependencies: one discussion does them in a row with /implement and reuses the loaded context. Different context = another group, done later in a new discussion, or at the same time as other independent groups (one linked session per group, or /implement-spec in a git repository). A group of one ticket is fine when its context is its own. Full list: it replaces the previous one; every ticket in exactly one group',
299      items: {
300        type: 'object',
301        properties: {
302          id: { type: 'string', description: 'Short id: A, B, C…' },
303          name: { type: 'string', description: "Short theme, in the user's language" },
304          tickets: { type: 'array', items: { type: 'string' }, description: 'Ticket ids, in execution order' },
305          why: { type: 'string', description: "The shared context, one short line in the user's language" },
306          files: {
307            type: 'array',
308            items: { type: 'string' },
309            description: 'Repo paths the group touches besides its tickets\' own files; groups that share one never run in parallel. A folder covers every file inside it: name files unless the group creates files there',
310          },
311          resources: {
312            type: 'array',
313            items: { type: 'string' },
314            description:
315              'Things the group cannot share with another group working at the same time, same name in every group: an application driven on screen (e.g. "PowerBI Desktop"), a single database, device or account. Groups that share one never run in parallel',
316          },
317          continuesHere: { type: 'boolean', description: 'true on at most one group: the one that benefits from the context of this discussion' },
318        },
319        required: ['id', 'name', 'tickets'],
320      },
321    },
322    reviewed: {
323      type: 'array',
324      items: { type: 'string' },
325      description:
326        'Ids of finished groups whose /code-review ran somewhere the dashboard cannot see: a group\'s linked session, whether its final report says the review ran (send it with "returned") or it was asked for it after the group came back. Send it once the session reports the review done',
327    },
328    returned: {
329      type: 'array',
330      items: { type: 'string' },
331      description:
332        'Ids of groups delegated to linked sessions that have ended: once you checked a session\'s work, send its tickets\' statuses in "set" in the same call. A group whose session stopped short goes back to the dashboard with its unfinished tickets',
333    },
334    delegate: {
335      type: 'string',
336      description:
337        'Only when a linked session\'s brief gives it: id of the workflow this discussion works for, sent first, with "group" and "main".The workflow stays in the main discussion and nothing is recorded on it from here: your progress is relayed to its dashboard on its own (Asana ticket statuses, commits, tests). Afterwards, only "set" (a ticket status that changed without an Asana update), "decided" (sent at once, shown on the main dashboard for review), "report" (your final report) and "note" are accepted',
338    },
339    group: {
340      type: 'string',
341      description: 'With "delegate": the id of the group this linked session works on (A, B...), as the brief gives it',
342    },
343    report: {
344      type: 'string',
345      description:
346        'Only in a linked session: your whole final report, also sent to the main discussion with SendMessage. It is kept in the run\'s relay file, which the main discussion rereads if the message gets lost (renamed or restarted discussion)',
347    },
348    main: {
349      type: 'string',
350      description: 'With "delegate": the main discussion as the brief names it, its "[ref]" (or "<name> [ref]"); each answer then says how to reach it',
351    },
352    resume: {
353      type: 'string',
354      description:
355        'Id of a workflow of this project to take over in this discussion before the rest of this report, as "↻ Reprendre ici" does. Only when a brief or the user asks for it: a linked session launched for a bug diagnosis sends it first; the main discussion sends it again once that session has finished',
356    },
357    open: {
358      type: 'object',
359      description:
360        "Start a new workflow in this discussion, without a skill, even if another one is active here (that one is paused as it is). Only at the user's request. Everything else in this call goes to the new workflow",
361      properties: {
362        title: { type: 'string', description: "What the work is about, 3 to 6 words, in the user's language" },
363        entry: { type: 'string', description: 'Starting skill that shapes its phases (implement, to-tickets, grill-with-docs, diagnosing-bugs...); default implement' },
364      },
365      required: ['title'],
366    },
367    fix: {
368      type: 'object',
369      description:
370        "Correct the active workflow, at the user's request or after a mistake: a title already set, the current phase (going back resets the phases after it), the step (back too). Tickets and groups are corrected with \"tickets\", \"set\", \"remove\" and \"groups\"",
371      properties: {
372        title: { type: 'string', description: 'New title, replacing the current one' },
373        phase: { type: 'string', description: 'Skill id of a phase of this workflow to make current (e.g. to-tickets, implement)' },
374        step: { type: 'string', description: 'Id of a step of the current (or fixed) phase to make current; the steps after it become pending' },
375      },
376    },
377    note: { type: 'string', description: 'One short line worth keeping in the activity log (a decision, a finding)' },
378    complete: { type: 'boolean', description: 'true when the work of the current skill is finished' },
379  },
380}
381
382// État du module, perdu au rechargement : de simples corrélations et minuteries.
383/** Appels de l'outil Skill en cours : `skill.prompt` ne dit pas quel agent charge le skill. */
384let pendingSkills: { skill: string; agentId?: string; args?: string }[] = []
385/** Dernière commande slash d'un skill : `command.run` précède `skill.prompt` et porte les arguments. */
386let lastCommand: { id: string; args: string; at: number } | null = null
387let saveTimer: { cancel: () => void } | null = null
388let ticks = 0
389/** Le mod vient d'être rechargé (le board était déjà là au démarrage) : la réponse suivante de l'outil le dit. */
390let isReloadToAnnounce = false
391/** La session précédente a fini par un /clear : le board reste en mémoire sans que le mod ait été rechargé. */
392let isAfterClear = false
393/** La délégation écrite pour cette session (JSON), pour ne pas réécrire `$.store` à chaque appel ; aucune au départ. */
394let keptDelegate = 'null'
395
396const normRoot = (root: string): string => root.replace(/\\/g, '/').replace(/\/+$/, '')
397const storeKey = (root: string): string => `board:${normRoot(root).toLowerCase()}`
398const projectName = (root: string): string => normRoot(root).split('/').pop() || root
399
400// ─── Board : chargement, écriture, persistance ────────────────────────────
401
402/** Racine lue il y a peu : chaque appel d'outil d'un parcours en a besoin, `/cd` la change rarement. */
403let rootCache: { value: string; at: number } | null = null
404
405/** La racine du projet de la session ; vide là où la session n'en dit rien (tests). */
406const sessionRoot = async ($: any): Promise<string> => {
407  const now = Date.now()
408  if (rootCache !== null && now - rootCache.at < 5000) return rootCache.value
409  let value = ''
410  for (const ask of [() => $.session.root(), () => $.session.cwd()]) {
411    try {
412      const root = await ask()
413      if (typeof root === 'string' && root !== '') {
414        value = root
415        break
416      }
417    } catch {
418      // Essai suivant.
419    }
420  }
421  rootCache = { value, at: now }
422  return value
423}
424
425/** Id de cette discussion ; une valeur tirée au hasard là où le moteur n'en donne pas (tests). */
426let sessionCache: string | null = null
427/**
428 * Dernier message de la personne (ms) : le tour qui le traite peut lancer une session liée demandée
429 * avant lui. Les tours d'une notification ou d'une autre session ne comptent pas : ils n'ont pas vu la demande.
430 */
431let submitAt: number | null = null
432const sessionId = async ($: any): Promise<string> => {
433  if (sessionCache !== null) return sessionCache
434  try {
435    const id = await $.session.id()
436    sessionCache = typeof id === 'string' && id !== '' ? id : null
437  } catch {
438    sessionCache = null
439  }
440  sessionCache ??= `local-${Math.random().toString(36).slice(2)}`
441  return sessionCache
442}
443
444// ─── Fichier commun des parcours ──────────────────────────────────────────
445//
446// `$.store` range ses données selon la façon dont le mod est chargé : `inline` dans le desktop
447// (plugins passés par l'application), `mods-locaux` dans le CLI (plugin installé). Les deux ne se
448// voyaient pas. Les parcours vivent donc dans un fichier à nous, au même endroit pour tous.
449
450/** Le dossier de configuration de Claude Code, tiré de l'emplacement du mod, qui est toujours dessous. */
451const claudeHome = ($: any): string | null => {
452  try {
453    const root = String($.plugin.root ?? '').replace(/\\/g, '/')
454    const at = root.toLowerCase().indexOf('/.claude/')
455    return at >= 0 ? root.slice(0, at + '/.claude'.length) : null
456  } catch {
457    return null
458  }
459}
460
461/** Un fichier par projet, nommé d'après sa racine. */
462const sharedPath = ($: any, root: string): string | null => {
463  const home = claudeHome($)
464  if (!home) return null
465  const slug = normRoot(root).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'sans-projet'
466  return `${home}/flow-board/parcours/${slug}.json`
467}
468
469/** `ok` faux : le fichier commun est hors d'atteinte (pas de chemin, lecture refusée), on se rabat sur `$.store`. */
470const readShared = async ($: any, root: string): Promise<{ ok: boolean; value: unknown }> => {
471  const path = sharedPath($, root)
472  if (!path) return { ok: false, value: undefined }
473  try {
474    if (!(await $.fs.exists(path))) return { ok: true, value: undefined }
475    return { ok: true, value: JSON.parse(String(await $.fs.read(path))) }
476  } catch {
477    return { ok: false, value: undefined }
478  }
479}
480
481const writeShared = async ($: any, root: string, board: Board): Promise<boolean> => {
482  const path = sharedPath($, root)
483  if (!path) return false
484  try {
485    await $.fs.write(path, JSON.stringify(board))
486    return true
487  } catch {
488    return false
489  }
490}
491
492const legacyGet = async ($: any, root: string): Promise<unknown> => {
493  try {
494    return await $.store.get(storeKey(root))
495  } catch {
496    return undefined
497  }
498}
499
500/** Projets dont l'ancien stockage de ce mode de chargement est déjà versé dans le fichier commun. */
501const migrated = new Set<string>()
502const migrationKey = (root: string): string => `migrated:${storeKey(root)}`
503
504/**
505 * Le board enregistré du projet : le fichier commun, sinon `$.store`. Une seule fois par mode de
506 * chargement, l'ancien `$.store` est fusionné dans le fichier commun : le desktop y apporte son
507 * historique, le CLI le sien.
508 */
509const loadStored = async ($: any, root: string): Promise<unknown> => {
510  const shared = await readShared($, root)
511  if (!shared.ok) return legacyGet($, root)
512  const key = storeKey(root)
513  if (migrated.has(key)) return shared.value
514  let isDone = false
515  try {
516    isDone = (await $.store.get(migrationKey(root))) === true
517  } catch {
518    isDone = false
519  }
520  if (isDone) {
521    migrated.add(key)
522    return shared.value
523  }
524  const legacy = await legacyGet($, root)
525  if (!isBoard(legacy) || normRoot(legacy.root) !== normRoot(root)) {
526    migrated.add(key)
527    return shared.value
528  }
529  const value = isBoard(shared.value) ? mergeBoards(shared.value, legacy) : legacy
530  if (await writeShared($, root, value)) {
531    migrated.add(key)
532    try {
533      await $.store.set(migrationKey(root), true)
534    } catch {
535      // Le versement sera refait au prochain chargement ; la fusion le rend sans effet.
536    }
537  }
538  return value
539}
540
541/**
542 * Un parcours absent du board d'ici (racine `here`), lu dans celui du dépôt principal : une session
543 * lancée dans un worktree a sa propre racine, donc son propre fichier. Lecture seule (pas de
544 * versement de l'ancien stockage) ; rien quand la session n'est pas dans un worktree.
545 */
546const mainRepoRun = async ($: any, runId: string, here: string): Promise<Run | undefined> => {
547  if (runId === '') return undefined
548  let root = ''
549  await quietly(async () => {
550    root = String((await $.session.repo())?.root ?? '')
551  })
552  if (root === '' || normRoot(root).toLowerCase() === normRoot(here).toLowerCase()) return undefined
553  const shared = await readShared($, root)
554  const stored = shared.ok ? shared.value : await legacyGet($, root)
555  return isBoard(stored) ? stored.runs.find(r => r.id === runId) : undefined
556}
557
558/** Le titre d'un parcours absent du board d'ici, lu dans celui du dépôt principal (`mainRepoRun`). */
559const mainRepoTitle = async ($: any, runId: string): Promise<string | undefined> => {
560  const board = await read($, boardAtom)
561  if (!board || board.runs.some(r => r.id === runId)) return undefined
562  return (await mainRepoRun($, runId, board.root))?.title
563}
564
565/** Écrit dans le fichier commun, sinon dans `$.store`. */
566const saveStored = async ($: any, root: string, board: Board) => {
567  if (await writeShared($, root, board)) return
568  try {
569    await $.store.set(storeKey(root), board)
570  } catch {
571    // Stockage indisponible (test, quota) : l'état de la session reste juste.
572  }
573}
574
575// ─── Relais des sessions liées ────────────────────────────────────────────
576//
577// Un fichier par parcours, à côté des parcours : chaque session liée y tient son entrée, la
578// discussion principale le lit. Il ne dépend pas de la racine : une session dans un worktree
579// (autre racine, autre fichier de parcours) écrit au même endroit que si elle était dans le dossier.
580
581const runSlug = (runId: string): string => runId.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
582
583const relayPath = ($: any, runId: string): string | null => {
584  const home = claudeHome($)
585  const slug = runSlug(runId)
586  return home && slug ? `${home}/flow-board/relais/${slug}.json` : null
587}
588
589// ─── Fichier des choix déjà déclarés ──────────────────────────────────────
590//
591// Un fichier Markdown par parcours, à côté du relais, écrit par la seule discussion principale
592// d'après le parcours et le relais : les briefs et les consignes de revue citent son chemin au
593// lieu de recopier la liste des choix, qui se périmait d'une consigne à l'autre.
594
595const decidedPath = ($: any, runId: string): string | null => {
596  const home = claudeHome($)
597  const slug = runSlug(runId)
598  return home && slug ? `${home}/flow-board/choix/${slug}.md` : null
599}
600
601/** Le dernier texte écrit par fichier : rien n'est réécrit quand la liste n'a pas bougé. */
602const decidedWritten = new Map<string, string>()
603
604/**
605 * Réécrit le fichier des choix du parcours actif, s'il a changé. Rend son chemin quand il est à
606 * jour, null sinon (pas de parcours, fichier hors d'atteinte) : une consigne ne cite alors pas le chemin.
607 */
608const writeDecidedFile = async ($: any, board: Board | null): Promise<string | null> => {
609  const run = board ? activeRun(board) : null
610  const path = run ? decidedPath($, run.id) : null
611  if (!run || !path) return null
612  const relay: Relay | null = await read($, relayAtom)
613  const text = decidedFileText(run, relay?.run === run.id ? relay : null)
614  if (decidedWritten.get(path) === text) return path
615  try {
616    await $.fs.write(path, text)
617    decidedWritten.set(path, text)
618    return path
619  } catch {
620    // Fichier hors d'atteinte : les consignes donnent alors la liste des choix.
621    return null
622  }
623}
624
625/** Les comptes rendus du relais déjà cités en entier dans cette discussion (perdu au redémarrage : ils le sont de nouveau). */
626const seenReports = new Set<string>()
627
628const relayKey = (runId: string): string => `relay:${runId}`
629
630const readRelay = async ($: any, runId: string): Promise<unknown> => {
631  const path = relayPath($, runId)
632  if (path) {
633    try {
634      return (await $.fs.exists(path)) ? JSON.parse(String(await $.fs.read(path))) : undefined
635    } catch {
636      // Fichier hors d'atteinte : l'ancien stockage prend le relais.
637    }
638  }
639  try {
640    return await $.store.get(relayKey(runId))
641  } catch {
642    return undefined
643  }
644}
645
646const writeRelay = async ($: any, relay: Relay) => {
647  const path = relayPath($, relay.run)
648  if (path) {
649    try {
650      await $.fs.write(path, JSON.stringify(relay))
651      return
652    } catch {
653      // Repli sur `$.store`.
654    }
655  }
656  try {
657    await $.store.set(relayKey(relay.run), relay)
658  } catch {
659    // Rien à relayer cette fois : le signal suivant réécrit toute l'entrée.
660  }
661}
662
663/** Dans une session liée : un signal (ticket, commit, tests, fin) part au relais du parcours qu'elle sert. */
664const relaySignal = async ($: any, event: RelayEvent) => {
665  const board: Board | null = await read($, boardAtom)
666  const runId = board?.delegate?.run
667  if (!runId) return
668  const now = await $.clock.now()
669  const next = applyRelay(await readRelay($, runId), runId, await sessionId($), event, now)
670  if (next) await writeRelay($, next)
671}
672
673/**
674 * Dans une session liée, rien ne s'écrit sur le parcours : ses mises à jour Asana (tickets), ses
675 * commits et ses tests partent au relais, que la discussion principale affiche.
676 */
677const relayTool = async ($: any, e: any, next: (e: any) => Promise<any>) => {
678  const ran = await next(e)
679  if (ran.deny !== undefined) return ran
680  try {
681    const isOk = ran.isError !== true
682    if (e.tool === 'Bash' || e.tool === 'PowerShell') {
683      const record = ran.result as { stdout?: unknown; backgroundTaskId?: unknown } | undefined
684      if (typeof record?.backgroundTaskId !== 'string') {
685        const out = typeof record?.stdout === 'string' ? record.stdout : typeof ran.text === 'string' ? ran.text : ''
686        const signals = commandSignals(String(e.command ?? ''), isOk, out)
687        // Ni commit ni tests : pas de lecture du relais pour rien.
688        if (signals.commit !== undefined || signals.test !== undefined) await relaySignal($, { kind: 'command', ...signals })
689      }
690    } else if (String(e.tool).endsWith('__update_tasks') && isOk) {
691      await relaySignal($, { kind: 'tickets', changes: asanaChanges(e.tasks) })
692    }
693  } catch {
694    // Un signal mal lu ne doit jamais gêner l'outil.
695  }
696  return ran
697}
698
699/** Dans la discussion principale : relit le relais du parcours actif quand un groupe est confié. */
700const pullRelay = async ($: any, board: Board) => {
701  const run = activeRun(board)
702  const isWatching = run?.groups?.some(g => g.linked !== undefined) ?? false
703  const before: Relay | null = await read($, relayAtom)
704  const stored = run && isWatching ? await readRelay($, run.id) : undefined
705  const next = stored !== null && typeof stored === 'object' && (stored as Relay).run === run?.id ? (stored as Relay) : null
706  if (JSON.stringify(next) === JSON.stringify(before)) return
707  await update($, relayAtom, () => next)
708  await writeDecidedFile($, board)
709  $.ui.invalidate('ui.render')
710}
711
712/** Le board du projet de la session : celui de l'état, sinon celui du stockage, sinon un neuf. */
713const ensureBoard = async ($: any): Promise<Board | null> => {
714  const current = await read($, boardAtom)
715  const root = await sessionRoot($)
716  if (current && (root === '' || normRoot(current.root) === normRoot(root))) return current
717  const stored = await loadStored($, root)
718  const session = await sessionId($)
719  const loaded =
720    isBoard(stored) && normRoot(stored.root) === normRoot(root)
721      ? detachOnLoad(stored, await $.clock.now(), session)
722      : { ...emptyBoard(root, root ? projectName(root) : 'projet'), session, deleted: [] }
723  // Une session liée retrouve sa délégation, propre à elle et jamais dans le stockage partagé.
724  let kept: unknown
725  await quietly(async () => {
726    kept = await $.store.get(delegateKey(session))
727  })
728  // Dans un worktree, le parcours délégué vit dans le board du dépôt principal : fini là-bas, la délégation ne revient pas.
729  const keptRun = kept !== null && typeof kept === 'object' ? (kept as { run?: unknown }).run : undefined
730  if (typeof keptRun === 'string' && !loaded.runs.some(r => r.id === keptRun) && (await mainRepoRun($, keptRun, loaded.root))?.status === 'done') kept = null
731  const board = restoreDelegate(loaded, kept, await $.clock.now())
732  keptDelegate = JSON.stringify(board.delegate ?? null)
733  await update($, boardAtom, () => board)
734  return board
735}
736
737const delegateKey = (session: string): string => `delegate:${session}`
738
739/** Garde la délégation de cette session (ou son absence) pour la retrouver après un rechargement du mod. */
740const keepDelegate = async ($: any) => {
741  const board: Board | null = await read($, boardAtom)
742  const value = JSON.stringify(board?.delegate ?? null)
743  if (!board || value === keptDelegate) return
744  await quietly(async () => {
745    await $.store.set(delegateKey(await sessionId($)), board.delegate ?? null)
746    keptDelegate = value
747  })
748}
749
750const readStored = async ($: any, root: string): Promise<Board | null> => {
751  const stored = await loadStored($, root)
752  return isBoard(stored) && normRoot(stored.root) === normRoot(root) ? clampFuture(stored, await $.clock.now()) : null
753}
754
755/**
756 * Le stockage est partagé par toutes les discussions du projet : on y fusionne sa copie au lieu
757 * de l'écraser, et la discussion récupère au passage les parcours des autres.
758 */
759const saveNow = async ($: any) => {
760  saveTimer = null
761  const { value, version } = await $.state.get(BOARD_REF)
762  if (!value) return
763  const stored = await readStored($, value.root)
764  const merged = stored ? mergeBoards(value, stored) : value
765  await saveStored($, value.root, merged)
766  await writeDecidedFile($, merged)
767  // Si l'état a bougé entre-temps, la prochaine sauvegarde refusionnera.
768  if (merged !== value) await $.state.set(BOARD_REF, merged, { ifVersion: version })
769}
770
771/** Lit ce que les autres discussions ont enregistré, sans rien écrire dans le stockage. */
772const pull = async ($: any) => {
773  const { value, version } = await $.state.get(BOARD_REF)
774  if (!value) return
775  const stored = await readStored($, value.root)
776  if (!stored) return
777  const merged = mergeBoards(value, stored)
778  if (JSON.stringify(merged) !== JSON.stringify(value)) await $.state.set(BOARD_REF, merged, { ifVersion: version })
779}
780
781const scheduleSave = ($: any) => {
782  saveTimer?.cancel()
783  saveTimer = $.clock.after(600, () => void saveNow($))
784}
785
786/** Applique une transition pure au board, avec une écriture conditionnelle (rejouée si l'état a bougé). */
787const commit = async ($: any, change: (board: Board) => Outcome): Promise<Effects | null> => {
788  if (!(await ensureBoard($))) return null
789  for (let attempt = 0; attempt < 5; attempt++) {
790    const { value, version } = await $.state.get(BOARD_REF)
791    if (!value) return null
792    const out = change(value)
793    if (!out) return null
794    const written = await $.state.set(BOARD_REF, out.board, { ifVersion: version })
795    if (written.isSet) {
796      await afterChange($, out.fx, out.board)
797      return out.fx
798    }
799  }
800  return null
801}
802
803/** Les appels Agent pas encore rendus (clé : tool_use_id) : leur id de sous-agent n'est pas connu. */
804const agentCalls = new Set<string>()
805
806/** Confronte les sous-agents « en cours » à ceux de la session ; une liste indisponible ne change rien. */
807const reconcileAgents = async ($: any) => {
808  try {
809    const live = (await $.agent.list()) as LiveAgent[]
810    const now = await $.clock.now()
811    await commit($, board => applyAgentList(board, live, agentCalls, now))
812  } catch {
813    // Pas de liste (version de Claude Code sans `agent.list`) : on garde les lignes telles quelles.
814  }
815}
816
817/** Un effet d'interface raté (pas d'écran, surface sans panneau) ne doit jamais gêner le suivi. */
818const quietly = async (effect: () => unknown) => {
819  try {
820    await effect()
821  } catch {
822    // Rien à afficher ici : l'état, lui, est déjà écrit.
823  }
824}
825
826const afterChange = async ($: any, fx: Effects, board: Board) => {
827  scheduleSave($)
828  for (const text of fx.toasts.slice(0, 2)) await quietly(() => $.ui.toast(text))
829  if (fx.openPane) await quietly(() => openUnasked($))
830  await quietly(() => syncStatus($, board))
831}
832
833/** Ouvre le panneau de lui-même, sauf si la personne l'a fermé pendant la session. */
834const openUnasked = async ($: any) => {
835  const ui = await read($, uiAtom)
836  if (ui.isPaneDismissed) return
837  const panes = await $.ui.panes()
838  if (panes.some((p: { id: string }) => p.id === PANE)) return
839  await $.ui.open({ id: PANE, title: PANE_TITLE })
840}
841
842/** La ligne d'état sous le prompt ne sert que quand le bandeau est masqué. */
843const syncStatus = async ($: any, board: Board | null) => {
844  const ui = await read($, uiAtom)
845  const run = activeRun(board)
846  if (!run || !ui.isBandHidden) {
847    await quietly(() => $.ui.status(undefined))
848    return
849  }
850  const { skill } = focusOf(run)
851  const t = countTickets(run.tickets)
852  await quietly(() => $.ui.status(`${skill.glyph} ${skill.label}${t.total > 0 ? ` ${t.done}/${t.total}` : ''} · ${run.title}`))
853}
854
855const setLive = async ($: any, mode: 'idle' | 'working' | 'waiting' | 'agents', ask?: string | null) => {
856  const now = await $.clock.now()
857  await update($, liveAtom, live => ({
858    mode,
859    since: live.mode === mode && live.since > 0 ? live.since : now,
860    ask: ask === undefined ? (mode === 'waiting' ? live.ask : null) : ask,
861  }))
862}
863
864/** Le projet est un dépôt git (mesuré à l'appel) ; faux quand la mesure échoue. */
865const measureRepo = async ($: any): Promise<boolean> => {
866  let isRepo = false
867  await quietly(async () => {
868    isRepo = (await $.session.repo()) !== null
869  })
870  return isRepo
871}
872
873// ─── Note ajoutée au texte des skills ─────────────────────────────────────
874
875/** Les zones sensibles, dites en français dans les briefs. */
876const SENSITIVE_AREAS_FR = 'confidentialité, suppression de données, chiffrement, concurrence'
877
878/** La règle des revues, citée par les consignes des relecteurs des sessions liées. */
879const REVIEW_RULE_FR = 'règle des revues, config/rules/agents.md quand elle est installée'
880
881/** Un relecteur lancé par l'outil Agent reçoit le chemin de son fichier de consignes dans son prompt. */
882const INSTRUCTIONS_IN_PROMPT_FR = `ses consignes dans un fichier dont le chemin va dans son prompt (${REVIEW_RULE_FR})`
883
884/**
885 * La revue de sécurité d'un groupe dans sa session liée : un sous-agent relecteur au périmètre
886 * explicite, pas `/security-review`, qui calcule son diff contre la branche par défaut et relit
887 * aussi les autres groupes (il ignore la base qu'on lui donne). Les failles vont au compte rendu.
888 */
889const linkedSecurityReview = (scope: string): string =>
890  `la confier à un sous-agent relecteur (outil Agent, type general-purpose, modèle opus, effort xhigh), en lecture seule, avec pour seul périmètre ${scope} et pour critères ceux de /security-review (${SECURITY_CRITERIA}), ${INSTRUCTIONS_IN_PROMPT_FR} ; ne pas lancer /security-review, qui relit la branche face à la branche par défaut, donc aussi les autres groupes ; ne pas corriger ce qu'il trouve : les failles vont dans le compte rendu`
891
892/**
893 * Les choix déjà déclarés du parcours, à donner à une revue pour qu'elle ne les resignale pas : le
894 * chemin du fichier que tient la discussion principale, pas une liste recopiée à la main.
895 * `path` nul : le fichier est hors d'atteinte, la session donne alors ses propres choix.
896 */
897const decidedList = (path: string | null): string =>
898  path
899    ? `le chemin du fichier des choix déjà déclarés du parcours, ${path} (tenu par l'outil flow, à relire au moment de la revue), et en plus les choix que la session a déclarés et qui n'y figurent pas encore (ils n'y arrivent qu'une fois lus par la discussion principale), à ne pas resignaler ni défaire`
900    : 'la liste des choix déjà déclarés dans {"decided": …} de l\'outil flow (ceux que tu connais, ceux de la session compris), à ne pas resignaler ni défaire'
901
902/** Commit avant la revue, en français pour le brief des sessions liées (même règle que COMMIT_BEFORE_REVIEW). */
903const COMMIT_BEFORE_REVIEW_FR =
904  "un ticket dit hors zone sensible (\"sensitive\": false) se commite dès ses contrôles passés, avant sa revue, pour que le ticket suivant démarre pendant qu'elle tourne : sa cible est alors <commit d'avant le ticket>..<commit du ticket>, jamais ..HEAD (HEAD et l'arbre portent déjà le ticket suivant) ; ses correctifs arrivent dans un commit à part, ses constats partent dans \"review\" sans \"step\", et le ticket ne passe « fait » dans le tracker qu'une fois sa revue revenue et ses correctifs commités ; un ticket qui touche une zone sensible, ou ne l'a pas dit, garde sa revue avant son commit"
905
906/**
907 * La consigne que tout relecteur reçoit telle quelle : un relecteur avait rendu son rapport avant
908 * le second axe (revue Spec lancée deux fois), et une passe `--fix` avait appliqué un correctif
909 * laissé à un autre groupe. Le tri des constats suit celui de `REVIEWER_BRIEF`.
910 */
911const REVIEWER_BRIEF_FR =
912  "la consigne des relecteurs, à leur donner telle quelle : si la revue a plusieurs axes (Standards et Spec), ne rendre son rapport qu'une fois tous les axes revenus, sans relancer un axe déjà lancé ; ne relancer ni les tests ni les contrôles (la session les a lancés) ; classer à part les constats sur du code antérieur au lot (des lignes qu'il n'a pas changées) : ne pas les corriger, ils me sont proposés comme un groupe « durcissement » en fin de lot ; étiqueter chaque constat Important, Mineur ou Antérieur (le code antérieur au lot, juste avant) : seuls les Importants sont corrigés, les Mineurs (style, nommage, nettoyage qui ne change aucun comportement) sont listés sans être corrigés, pour qu'un constat mineur ne coûte ni commit ni nouveau tour de revue ; pour un défaut, nommer sa classe (le motif, pas seulement la ligne) pour que toutes ses occurrences soient corrigées ; ne signaler comme défaut ni appliquer rien de ce que la spec, le ticket ou le fichier des choix laisse à un autre groupe ou exclut ; ne pas resignaler ni défaire les choix de ce fichier ; ne jamais écrire dans l'arbre de travail, même un instant (un test qui prouve un défaut reste à la session) : les seules écritures sont les correctifs d'une revue de groupe"
913
914/**
915 * Le canal des consignes d'une revue dans une session liée : `/code-review` tourne en exécution
916 * dérivée et ne reçoit que ses arguments (forme essayée avec Claude Code 2.1.310, voir
917 * `reviewInstructions`), et un hash seul lui avait fait relire un commit au lieu du groupe.
918 */
919const reviewInstructionsFr = (isRepo: boolean): string =>
920  `écrire ses consignes dans un fichier de son dossier temporaire (le périmètre, le chemin du fichier des choix, les points qui me sont réservés, ce qu'elle ne doit pas faire, et la consigne des relecteurs) et en passer le chemin après la cible, ${isRepo ? "/code-review <niveau> <base>..HEAD consignes : <chemin> (la cible est toujours une plage <base>..HEAD ou une branche, jamais un hash seul ; avant le commit du ticket, le périmètre nomme aussi les changements non commités)" : '/code-review <niveau> consignes : <chemin> (le fichier liste les copies et les fichiers à comparer)'}, et à un sous-agent relecteur dans son prompt (${REVIEW_RULE_FR})`
921
922/** Le seuil d'un seul relecteur pour les deux axes (`SMALL_DIFF`). */
923const SMALL_DIFF_FR = 'moins de 100 lignes comptées par git diff --numstat, sans fichiers générés ni binaires, et hors zone sensible : les deux axes dans un seul sous-agent relecteur ; sinon deux en parallèle'
924
925/** Un groupe d'un seul ticket sans logique ni zone sensible (`SINGLE_TICKET_GROUP`). */
926const SINGLE_TICKET_GROUP_FR = "un groupe d'un seul ticket ni sensible ni avec logique n'a pas de revue du groupe : la revue de son ticket est la seule, et la sécurité se juge une fois ce ticket commité"
927
928/** Une revue de groupe en zone sensible n'applique rien seule : son relecteur trie les constats. */
929const SENSITIVE_NO_FIX_FR = `si un ticket du groupe touche une zone sensible (${SENSITIVE_AREAS_FR}), sans --fix : le relecteur applique lui-même les seuls constats qu'il retient`
930
931/** Ce qu'une passe `/code-review --fix` fait d'un conflit entre la spec et une consigne. */
932const NO_ARBITRATION = "elle ne tranche pas un conflit entre la spec et une consigne (CLAUDE.md, une demande de ma part) : elle ne corrige pas ce point et me le remonte"
933
934/**
935 * Le relecteur de la revue d'un groupe (`/code-review high --fix`) dans une session liée : un
936 * sous-agent Opus en effort high, et c'est lui qui écrit les correctifs de `--fix`, pour
937 * qu'ils aient son modèle et son effort. Seule exception à « sans sous-agents qui écrivent » : il
938 * tourne seul, une fois les tickets commités, et la session relit ses correctifs avant de les commiter.
939 */
940const LINKED_GROUP_REVIEWER =
941  `un sous-agent relecteur (outil Agent, type general-purpose, modèle opus, effort high ; ${REVIEW_RULE_FR}), le seul sous-agent qui écrit : il lance lui-même la revue et applique lui-même ses correctifs`
942
943/** Le même sous-agent relecteur, dit à Claude dans la discussion principale. */
944const securityReviewer = (scope: string): string =>
945  `launch it yourself: a reviewer subagent (Agent tool, general-purpose, model opus, effort xhigh), read-only, limited to ${scope}, with these criteria, ${INSTRUCTIONS_IN_PROMPT}; never /security-review, which diffs against the default branch and rereads the other groups too. The subagent fixes nothing: tell the user its findings plainly, they decide on their fixes (fix one only once they agree; create a tracker task for each real finding left unfixed)`
946
947/** La revue de sécurité après la revue d'un groupe : Claude juge, puis la lance lui-même. */
948const securityAdvice = (isRepo: boolean): string =>
949  `- Security review: in the same message as the review of a finished group (/code-review high --fix, or high for a sensitive group), since it is read-only and does not wait for the fixes, judge whether a security review is worth it (its changes touch ${SECURITY_CRITERIA}). Tell the user in one line, with the reason, and send {"security": {"group": "<id>", "advised": true|false, "why": "<short reason, in the user's language>"}}. If it is worth it, ${securityReviewer(isRepo ? "git diff <group base>..HEAD (<group base>: the commit before the group's first ticket)" : `the group's diff (${NO_GIT_GROUP_FIXED_POINT}; new files read whole)`)}. Once the group review's fixes are committed, have it (or a new reviewer) read the diff of those fixes only, with the same criteria. Then send {"security": {"group": "<id>", "findings": <real findings>, "fixed": <fixed>}}.`
950
951/** Le même avis pour un parcours sans groupes (relecture de branche, chasse au bug, TDD) : il porte sur tout le lot. */
952const LOT_SECURITY_ADVICE = `- Security review: once this review is done and its fixes are in, judge whether a security review is worth it on these changes (they touch ${SECURITY_CRITERIA}). Tell the user in one line, with the reason, and send {"security": {"advised": true|false, "why": "<short reason, in the user's language>"}}, without "group": it covers the whole run. If it is worth it, ${securityReviewer('the changes this review covered')}. Then send {"security": {"findings": <real findings>, "fixed": <fixed>}}.`
953
954/** La revue d'un groupe, lancée par la personne depuis la ligne du groupe : ce qu'elle doit savoir. */
955const groupReviewNote = (isRepo: boolean): string =>
956  `- This is the review of a finished group${isRepo ? '' : ` (${NO_GIT_GROUP_FIXED_POINT}: give it these copies and files)`}: run it in ${GROUP_REVIEWER}, not in this discussion, and give that subagent these instructions (it does not receive them). Focus it on the interactions between the group's tickets (each one had its own review), and give it the path of the file of declared choices and the reviewer brief (both under "Declared choices" below). ${SENSITIVE_NO_FIX[0]!.toUpperCase()}${SENSITIVE_NO_FIX.slice(1)}. It does not settle a conflict between the spec and an instruction (CLAUDE.md, a request of the user): leave that point unfixed and raise it to the user. Then read the diff of its fixes, rerun the checks and commit them.`
957
958/** La passe « bogues » d'un ticket en cours (zone sensible) : pas la revue du groupe. */
959const bugPassNote = (isRepo: boolean): string =>
960  `- This is the bug pass of the ticket in progress (sensitive area), not the review of a group: same scope as its /mattpocock-skills:code-review (${isRepo ? TICKET_FIXED_POINT : NO_GIT_TICKET_FIXED_POINT}); give it the path of the file of declared choices and the reviewer brief (both under "Declared choices" below). Report its findings with the ticket's review ("review"), then commit the ticket.`
961
962/** Les skills de cadrage : un choix sensible y devient une question, jamais un choix déclaré. */
963const FRAMING_SKILLS = new Set(['grilling', 'grill-with-docs', 'grill-me', 'to-questionnaire', 'to-spec', 'to-tickets', 'domain-modeling'])
964
965/** Les consignes où une revue reçoit le fichier des choix déclarés. */
966const REVIEW_NOTES = new Set(['implement', 'implement-spec', 'final-review', 'code-review', 'branch-review'])
967
968const coachingNote = (id: string, run: Run, sync: SyncState, isRepo: boolean, session?: string, decidedAt?: string | null): string => {
969  const def = SKILLS[id]!
970  const lines = [
971    '',
972    '',
973    '---',
974    `Dashboard (flow-board): report progress with the \`${TOOL}\` tool. It is silent bookkeeping: no need to mention it to the user; batch it with your other tool calls when you can.`,
975  ]
976  if (!run.isTitled) lines.push(`- First call: {"title": "<what this work is about, 3 to 6 words, in the user's language>"}.`)
977  if (def.steps.length > 0) {
978    lines.push(`- Steps of ${id}, in order; send {"step": "<id>"} as each one starts: ${def.steps.map(s => `${s.id} (${hintOf(s, isRepo)})`).join('; ')}.`)
979  }
980  if (def.items) {
981    lines.push(`- Items: ${def.items.hint}. Send the full list {"items": [{"id": "...", "label": "...", "status": "todo|doing|done|dropped"}]} whenever it changes.`)
982    if (def.items.linkedStep) {
983      lines.push(
984        `- Once the items are presented, the user picks the ones to apply in the dashboard and may send them to a linked session: the request lands in the prompt and you launch the session; the items go "doing" and the "${def.steps.find(s => s.id === def.items!.linkedStep)?.label ?? def.items.linkedStep}" step opens only once the session is launched. Do not send {"complete": true} before the user has decided; while items are with the linked session, launched or not, the dashboard refuses it.`,
985      )
986    }
987  }
988  if (def.hasTickets) {
989    lines.push('- Tickets: send the ones you know, {"tickets": [{"id": "<tracker id: the Asana task GID when the tracker is Asana>", "name": "<short title>", "blockedBy": ["<ids of the tickets that must be done first>"]}]}: they are added or updated, the others stay on the dashboard, so send only the tickets you work on. Then {"set": [{"id": "...", "status": "doing|done|blocked"}]} for changes the tracker tools do not show. A ticket sent by mistake: {"remove": ["<id>"]}.')
990  }
991  if (id === 'to-tickets') {
992    lines.push('- When the tickets are published, send them all with their blocking edges in "blockedBy" (an empty list for a ticket nothing blocks).')
993    lines.push('- Also check in the tracker whether a ticket depends on tasks outside this lot. Add the code tickets among them to the lot; send the others (decisions to make, work of someone else) as {"externals": [{"id": "<GID>", "name": "...", "kind": "decision|task", "status": "todo|done"}]} and keep their ids in the blocked tickets\' blockedBy. When one is done or decided, send it again with "status": "done".')
994    lines.push('- Give each ticket the repo paths it touches, read from its acceptance criteria: {"tickets": [{"id": "...", "files": ["<pages, tables, shared files such as a model or a measures file>"]}]}. Name files, not a whole folder, unless the ticket creates files in it: a folder blocks every group that names a file inside it. README, DECISIONS.md and the code map (carte-du-code.md, CODEMAP.md) stay out of "files": almost every ticket cites them, their additions merge without trouble, and the dashboard ignores them anyway.')
995    lines.push('- Each ticket says, on lines of its own, « Zone sensible : oui/non (pourquoi) » and « Logique : oui/non » (no logic: a visual asset, a document), and you send both: {"tickets": [{"id": "...", "sensitive": true|false, "logic": true|false}]}.')
996    lines.push('- A write to CLAUDE.md never goes into the done criteria of a group ticket: a linked session cannot make it. It is a gesture of the main discussion, done when the group is back.')
997    lines.push('- Last, group the tickets for execution: {"groups": [{"id": "A", "name": "<short theme>", "tickets": ["<ids in execution order>"], "why": "<the shared context>", "files": ["<other repo paths it touches>"], "resources": ["<what it cannot share>"], "continuesHere": true}]}. Same group = same files or modules, or a chain of dependencies: one discussion does them in a row with /implement. A different context = another group; a group of one ticket is fine when its context is its own, and a ticket that blocks nothing in its theme but could start now (an export, a query, a data check) is often better in a group of its own or with other data work. Groups that share no file, no resource and do not depend on each other can later run at the same time, one linked session per group (or /implement-spec, one subagent per group, in a git repository only). "resources": what a group cannot share with another group working at the same time, with the same name in every group: an application driven on screen and checked by screenshots (e.g. "PowerBI Desktop"), a single database, device or account; check the project\'s CLAUDE.md for such constraints. Set "continuesHere" on at most one group, the one that benefits from the context of this discussion. Every ticket in exactly one group.')
998  }
999  if (id === 'implement') {
1000    lines.push('- For each ticket, the steps above restart from "read". With "review", send the findings of the two axes: {"step": "review", "review": {"standards": <findings>, "spec": <findings>, "fixed": <fixed>}}.')
1001    lines.push('- In this discussion, /code-review (Claude Code) runs only as the bug pass of a ticket that touches a sensitive area. The review of a group, /code-review high --fix (high without --fix for a group with a sensitive ticket), is launched by the user from the dashboard once the group\'s tickets are committed: do not launch it yourself.')
1002  }
1003  if (def.groupSteps) {
1004    lines.push(`- Then, for each group once all its tickets are committed and reviewed (their review fixes committed), in this order: ${def.groupSteps.map(s => `${s.label} (${hintOf(s, isRepo)})`).join('; ')}.`)
1005    lines.push(`- ${SINGLE_TICKET_GROUP}.`)
1006  }
1007  if (id === 'implement') lines.push(securityAdvice(isRepo))
1008  // Sans git, la méthode des copies : le point fixe de chaque revue.
1009  if (!isRepo && (id === 'implement' || id === 'final-review')) lines.push(`- ${NO_GIT_COPIES}.`)
1010  if (run.queue && run.queue.length > 0 && (id === 'implement' || id === 'implement-spec')) {
1011    // L'état de chaque ticket : après une discussion interrompue, Claude ne refait pas ce qui est
1012    // commité et retrouve le travail commencé.
1013    const names = run.queue
1014      .map((q, i) => {
1015        const t = run.tickets.find(x => x.id === q)
1016        return `${i + 1}) ${t?.name ?? q} [${q}]${t ? QUEUE_STATE[t.status] : ''}`
1017      })
1018      .join('; ')
1019    lines.push(`- Ticket queue chosen by the user, in order: ${names}. Set each one to doing when you start it and to done when it is finished.`)
1020  }
1021  if (id === 'implement' || id === 'implement-spec') {
1022    const started = run.tickets.filter(t => t.status === 'doing')
1023    if (started.length > 0) {
1024      lines.push(
1025        `- Already in progress when this skill loaded: ${started.map(t => `${t.name} [${t.id}]`).join('; ')}. A previous discussion may have stopped in the middle: ${isRepo ? 'check git status and git diff' : 'compare its copies with the current files ("Without git")'} before writing anything, and continue its uncommitted work instead of starting over. Tickets marked done are committed: do not redo them.`,
1026      )
1027    }
1028  }
1029  if (id === 'implement-spec') lines.push(securityAdvice(isRepo))
1030  const hasGroups = (run.groups ?? []).length > 0
1031  if (id === 'final-review') {
1032    if (isBugPass(run, session)) lines.push(bugPassNote(isRepo))
1033    else {
1034      if (hasGroups) lines.push(groupReviewNote(isRepo))
1035      lines.push(hasGroups ? securityAdvice(isRepo) : LOT_SECURITY_ADVICE)
1036    }
1037  }
1038  if (id === 'branch-review') {
1039    lines.push('- The review target and options (a PR number, a branch, --fix, --comment) are in the command arguments; if none is given, review the current branch against its base.')
1040    lines.push(LOT_SECURITY_ADVICE)
1041  }
1042  // La phase « Revue » de Chasse au bug, TDD ou Revue de code (le /code-review de Matt), sans groupes.
1043  if (id === 'code-review' && currentPhase(run).skill === 'code-review' && !hasGroups) lines.push(LOT_SECURITY_ADVICE)
1044  // Un /security-review tapé à la main : il relit toute la branche ; son résultat se rend pareil.
1045  if (id === 'security-review') {
1046    lines.push('- When the security review is done, tell the user plainly what it found. Fix a finding only once the user agrees; create a tracker task for each real finding left unfixed. Then send {"security": {"group": "<id>", "findings": <real findings>, "fixed": <fixed>}}.')
1047  }
1048  if (id === 'compte-rendu') {
1049    lines.push('- What the dashboard knows about this run (start from it, then check and complete it with git and the tracker):')
1050    lines.push(...reportFacts(run).map(l => `  ${l}`))
1051    lines.push('- When the report is written: {"step": "validate"}. Never send {"complete": true}: the user validates the report from the dashboard (✓ Valider le rapport). If the user asks for changes, rewrite the report and send {"step": "validate"} again.')
1052  }
1053  const advice = CODE_SKILLS.has(id) ? syncAdvice(sync) : null
1054  if (advice) {
1055    lines.push(`- The remote moved (${advice.reason}). Before your first code change, ask the user to run ${advice.commands.map(commandText).join(' then ')} (user-only commands) and wait until it is done.`)
1056  }
1057  if (FRAMING_SKILLS.has(id)) {
1058    lines.push(
1059      `- A choice that touches a sensitive area (${SENSITIVE_AREAS}) is never a declared choice ({"decided": …}): ask the user while framing, with options, and wait for the answer. Before a privacy question, have a read-only subagent check the real exposure (who can read the data, from where, with what access); without that check, the question says the exposure is "à confirmer". A decision entry (DECISIONS.md, an ADR) copies the question asked and the accepted answer word for word, the user's sentence in quotes, instead of summarising them.`,
1060    )
1061  }
1062  if (id === 'implement' && (run.groups ?? []).length > 1 && !(run.groups ?? []).some(g => g.linked !== undefined || g.delegated !== undefined)) {
1063    lines.push(
1064      '- At the start of implementation, if groups of this run may go to linked sessions, ask the user once to rename this discussion to its final name (CLI: /rename <name>; app: rename the discussion), before the first linked session, and wait until it is done: the name given in the linked sessions\' briefs must not change afterwards, or their reports get lost.',
1065    )
1066  }
1067  if (id === 'retro') {
1068    lines.push(
1069      '- Improvements that touch another repository (shared plugins, common config, another project) are not applied from this discussion: keep them out of the items and give them in your message as a correction list ready to hand to an agent working in that repository, one point per improvement: the finding, then the expected correction, with the plugin or files it touches. Only the improvements of this repository go to the items, hence to a linked session.',
1070    )
1071  }
1072  if (REVIEW_NOTES.has(id)) {
1073    lines.push(
1074      decidedAt
1075        ? `- Declared choices: the file ${decidedAt}, kept by the flow tool from every "decided" of this run (main discussion and linked sessions). Give its path to every reviewer instead of pasting the list, with this reviewer brief, word for word: ${REVIEWER_BRIEF}. To that end, ${reviewInstructions(isRepo)}.`
1076        : `- Declared choices: their file is out of reach; give every reviewer the list of the choices sent in "decided" of the flow tool, with this reviewer brief, word for word: ${REVIEWER_BRIEF}. To that end, ${reviewInstructions(isRepo)}.`,
1077    )
1078  }
1079  if (id === 'implement' || id === 'implement-spec') {
1080    lines.push('- When every ticket of the lot is done: {"complete": true}. If reviews kept apart findings on code older than the lot, propose them to the user as a « durcissement » group (one ticket per finding), without creating it before they agree. A finished group is not the end: while tickets of the lot remain open, the next step is the next group, which the user launches from the dashboard (▶ Lancer). Do not suggest /flow-board:compte-rendu or the delivery before every ticket is done. Add {"note": "<one short line>"} for a decision worth keeping.')
1081  } else if (id !== 'compte-rendu' && def.role !== 'event') {
1082    lines.push(`- When the work of ${id} is finished: {"complete": true}. Add {"note": "<one short line>"} for a decision worth keeping.`)
1083  }
1084  return lines.join('\n')
1085}
1086
1087// ─── Gestes de l'interface ────────────────────────────────────────────────
1088
1089const commandFor = async ($: any, skill: string): Promise<string> => {
1090  if (skill.includes(':')) return skill
1091  const own = SKILLS[skill]?.command
1092  if (own) return own
1093  const commands = await read($, commandsAtom)
1094  return commands[skill] ?? `${PLUGIN}:${skill}`
1095}
1096
1097/** Met la commande du skill dans le prompt : la personne ajoute le sujet et valide. */
1098const prepare = async ($: any, skill: string, given = '') => {
1099  if (skill === 'retro' && given === '' && (await launchRetro($))) return
1100  const command = await commandFor($, skill)
1101  // Les arguments propres au skill (`--auto` pour /project:pr) passent avant ceux du bouton.
1102  const args = [SKILLS[skill]?.args ?? '', given].filter(Boolean).join(' ')
1103  const text = args ? `/${command} ${args}` : `/${command} `
1104  let isFilled = false
1105  await quietly(async () => {
1106    isFilled = (await $.prompt.fill({ text, mode: 'replace' })).isFilled === true
1107  })
1108  const hint = args ? 'Entrée pour lancer' : 'ajoute le sujet, puis Entrée'
1109  await quietly(() => $.ui.toast(isFilled ? `${text.trim()} est dans le prompt : ${hint}` : `Tape ${text.trim()} dans le prompt`))
1110}
1111
1112/** Une commande de Claude Code (`/compact`) dans le prompt : la personne ajoute ses consignes si elle veut, puis Entrée. */
1113const fillCommand = async ($: any, command: string) => {
1114  const text = `/${command} `
1115  let isFilled = false
1116  await quietly(async () => {
1117    isFilled = (await $.prompt.fill({ text, mode: 'replace' })).isFilled === true
1118  })
1119  await quietly(() => $.ui.toast(isFilled ? `${text.trim()} est dans le prompt : ajoute des consignes si tu veux, puis Entrée` : `Tape ${text.trim()} dans le prompt`))
1120}
1121
1122/** La suite du parcours : la commande va dans le prompt, Entrée la lance (rien ne part sans toi). */
1123const launch = async ($: any, skill: string, given?: string) => {
1124  if (skill === 'retro' && (await launchRetro($))) return
1125  const args = given ?? commandArgsOf(activeRun(await read($, boardAtom)), skill)
1126  const command = `${await commandFor($, skill)}${args ? ` ${args}` : ''}`
1127  let isFilled = false
1128  await quietly(async () => {
1129    isFilled = (await $.prompt.fill({ text: `/${command}`, mode: 'replace' })).isFilled === true
1130  })
1131  await quietly(() => $.ui.toast(isFilled ? `/${command} est dans le prompt : Entrée pour lancer` : `Tape /${command} dans le prompt`))
1132}
1133
1134/** La réponse finit sur une question : la personne a à répondre, pas à lancer la suite. */
1135const endsWithQuestion = (answer: unknown): boolean => typeof answer === 'string' && /\?[\s*_)"»]*$/.test(answer.trim())
1136
1137/**
1138 * Fin d'un tour principal : la commande de la suite (celle de la carte « Ensuite ») se propose,
1139 * grisée, dans le prompt vide ; Tab la prend, rien ne part sans la personne. Rien quand une
1140 * question attend la personne, dans une session liée, ou quand la suite n'est pas une commande.
1141 */
1142const suggestNext = async ($: any, answer: unknown, ask: string | null) => {
1143  if (ask !== null || endsWithQuestion(answer)) return
1144  const board: Board | null = await read($, boardAtom)
1145  const run = activeRun(board)
1146  if (!run || board?.delegate) return
1147  const skill = nextCommandSkill(run, { hasPullRequests: (await read($, syncAtom)).isGitHub !== false })
1148  // La rétro d'un parcours à sessions liées part avec sa consigne (bouton), pas en commande seule.
1149  if (skill === null || (skill === 'retro' && linkedSessionsOf(run).count > 0)) return
1150  const args = commandArgsOf(run, skill)
1151  const text = `/${await commandFor($, skill)}${args ? ` ${args}` : ''}`
1152  let isShown = false
1153  await quietly(async () => {
1154    isShown = (await $.prompt.suggest({ text })).isShown === true
1155  })
1156  // Le tour n'est peut-être pas encore tout à fait fini : un second essai, juste après.
1157  if (!isShown) $.clock.after(SUGGEST_RETRY_MS, () => void quietly(() => $.prompt.suggest({ text })))
1158}
1159
1160/**
1161 * La rétro d'un parcours qui a eu des sessions liées : la discussion principale (la session mère)
1162 * fait la sienne, et un sous-agent par session fille fait celle de sa session sur son transcript,
1163 * qui ne dépend pas de la session encore ouverte ; elle en tire un compte rendu global. Rend faux
1164 * sans session fille : /retro seul suffit.
1165 */
1166const launchRetro = async ($: any): Promise<boolean> => {
1167  const run = activeRun(await read($, boardAtom))
1168  const daughters = run ? linkedSessionsOf(run) : null
1169  if (!run || !daughters || daughters.count === 0) return false
1170  const prefix = linkedPrefix(run).trim()
1171  const known = daughters.ids.length > 0 ? ` (ids relevés : ${daughters.ids.join(', ')})` : ''
1172  const who = prefix ? `les sessions dont le titre commence par « ${prefix} »${known}` : `les sessions lancées pour ses groupes ou ses pistes${known}`
1173  const text =
1174    `/${await commandFor($, 'retro')} Rétro globale du parcours « ${run.title} » : cette discussion (la session mère) et ses sessions filles, ${who}, à retrouver par ListAgents. ` +
1175    `1) D'abord, lance un sous-agent par session fille, tous en parallèle et en lecture seule (general-purpose, pas Haiku) : chacun lit le transcript de sa session, son fichier .jsonl sous ~/.claude/projects/ (pas claude logs, dont la sortie de terminal est illisible), et en fait la rétro selon les étapes et les sept axes de ce skill, que tu lui transmets tels que tu les lis ici ; il ne modifie rien et rend ses pistes par sévérité, une par ligne « axe : correction », chacune avec ce qui l'a révélée dans la session. ` +
1176    `2) Pendant ce temps, fais la rétro de cette discussion. ` +
1177    `3) Une fois tous les sous-agents revenus, fais le compte rendu global : les pistes de toutes les sessions, fusionnées et sans doublon, par sévérité, chacune avec la ou les sessions où elle est apparue ; seules les pistes de ce dépôt vont dans les items de l'outil flow (donc, au choix, à une session liée) ; celles qui touchent un autre dépôt (plugins, config commune, autre projet) n'y vont pas : rends-les à part, en une liste de corrections prête à confier à un agent de ce dépôt, un point par piste, le constat puis la correction attendue.`
1178  let isFilled = false
1179  await quietly(async () => {
1180    isFilled = (await $.prompt.fill({ text, mode: 'replace' })).isFilled === true
1181  })
1182  const n = daughters.count
1183  await quietly(() =>
1184    $.ui.toast(
1185      isFilled
1186        ? `Rétro de cette discussion et de ${n > 1 ? `ses ${n} sessions filles` : 'sa session fille'} : la demande est dans le prompt, Entrée pour la lancer`
1187        : 'Demande à taper : rétro de cette discussion et de ses sessions filles',
1188    ),
1189  )
1190  return true
1191}
1192
1193/**
1194 * Lance la sélection : la file est enregistrée dans le parcours (visible des autres discussions),
1195 * et la commande va dans le prompt avec les tickets dans l'ordre choisi ; Entrée la lance.
1196 */
1197const launchPicks = async ($: any, isParallel: boolean) => {
1198  const run = activeRun(await read($, boardAtom))
1199  const selection = (await read($, uiAtom)).selection ?? []
1200  if (!run || selection.length === 0) return
hooks/catalog.ts 1072 lines
1// Catalogue du plugin mattpocock-skills (écrit sur la 1.2.3 ; les 27 noms valent pour la 1.3.1,
2// copie figée dans plugins/mattpocock-skills) : pour chaque skill, ses étapes,
3// sa couleur, le parcours qu'il ouvre et ses suites naturelles.
4//
5// Sources : les SKILL.md du plugin et la carte de routage d'ask-matt. Les libellés sont
6// pour l'interface (français) ; les `hint` sont lus par Claude (anglais, comme les skills).
7
8export const PLUGIN = 'mattpocock-skills'
9
10// Couleurs brutes (hex) mi-tons : lisibles en thème clair comme sombre, sur le terminal
11// comme sur le desktop (même famille que git-board et ticket-board).
12export const C = {
13  violet: '#9B7BEA',
14  lilac: '#B79BF5',
15  blue: '#4A9EE8',
16  cyan: '#2EB8C9',
17  teal: '#3FB68B',
18  tangerine: '#E8913A',
19  coral: '#EF7D57',
20  amber: '#D9A520',
21  green: '#4CB782',
22  red: '#E5534B',
23  pink: '#E06C9F',
24  magenta: '#C36BD9',
25  indigo: '#7C83F2',
26  sky: '#56B4E9',
27  sand: '#C9A66B',
28  slate: '#7F8EA3',
29  gray: '#8B8B93',
30  /** Texte foncé posé sur un aplat de couleur. */
31  ink: '#17171C',
32} as const
33
34export type StepDef = {
35  id: string
36  /** Libellé affiché. */
37  label: string
38  /** Ce que Claude comprend de l'étape (anglais). */
39  hint: string
40  /** Le même hint quand le projet n'est pas un dépôt git (point fixe par copies), s'il diffère. */
41  noGitHint?: string
42  /** L'étape part dans le même message que la précédente (affichée « ∥ ») : la sécurité avec la revue du groupe. */
43  isWithPrevious?: boolean
44}
45
46export type PhaseSpec = { skill: string; alts?: string[]; isOptional?: boolean }
47
48/**
49 * `flow` : ouvre un parcours quand il ne prolonge pas l'actif.
50 * `detour` : greffé sur la phase en cours quand il n'est pas dans le parcours.
51 * `event` : une simple ligne du journal.
52 */
53/**
54 * … `step` : une commande de livraison (plugin project) qui ne compte que dans un parcours en
55 * cours (phase, étape ou ligne du journal) et n'en ouvre jamais un.
56 */
57export type SkillRole = 'flow' | 'detour' | 'event' | 'step'
58
59export type SkillDef = {
60  id: string
61  /** Nom de la phase. */
62  label: string
63  /** Genre et nombre du libellé, pour accorder « terminée », « démarrés »… (masculin singulier par défaut). */
64  gender?: 'f' | 'pl'
65  /** Nom du parcours quand ce skill l'ouvre. */
66  flowLabel: string
67  /** Ce que fait le skill, en une ligne. */
68  verb: string
69  glyph: string
70  color: string
71  role: SkillRole
72  /** Réservé à la personne (disable-model-invocation) : lancé par sa commande slash. */
73  isUserOnly: boolean
74  steps: StepDef[]
75  /**
76   * Ce que la phase suit comme éléments : hypothèses, candidats, décisions… Avec `linkedStep`, le
77   * panneau permet de choisir des éléments et de les confier à une session liée, ce qui ouvre cette étape.
78   */
79  items?: { label: string; hint: string; linkedStep?: string }
80  /** Étape d'une phase combinée : lancé seul, ce skill ouvre cette phase à cette étape. */
81  partOf?: { skill: string; step: string }
82  /** Sous-skills que ce skill appelle par l'outil Skill, et l'étape que chacun ouvre. */
83  uses?: Record<string, string | null>
84  /** Les phases du parcours que ce skill ouvre (la première est lui-même). */
85  flow?: PhaseSpec[]
86  /** Phases où ce skill de rôle `flow` se greffe en détour au lieu d'ouvrir son propre parcours. */
87  detourIn?: string[]
88  /** Suites proposées quand la phase est finie. */
89  next?: string[]
90  hasTickets?: boolean
91  /** Interview en tours de questions (`❓ **Qn**`). */
92  isGrilling?: boolean
93  /** Les lancements de tests y font avancer les étapes. */
94  hasTests?: boolean
95  /** La commande slash, sans la barre, quand ce n'est pas celle du plugin mattpocock (`code-review` de Claude Code). */
96  command?: string
97  /** Arguments ajoutés quand le panneau met la commande dans le prompt (`--fix` pour la revue du groupe). */
98  args?: string
99  /**
100   * Ce qui suit, groupe par groupe, une fois ses tickets commités (revue du groupe, revue de
101   * sécurité, rendu) : affiché dans l'ordre sur la carte de la phase, et envoyé à Claude avec les
102   * étapes. Pas des étapes suivies : les revues sont des événements, marqués sur la ligne du groupe.
103   */
104  groupSteps?: StepDef[]
105}
106
107const step = (id: string, label: string, hint: string): StepDef => ({ id, label, hint })
108
109/** Le hint d'une étape selon que le projet est un dépôt git ou non. */
110export const hintOf = (s: StepDef, isRepo: boolean): string => (isRepo ? s.hint : (s.noGitHint ?? s.hint))
111
112/** Ce qui justifie une revue de sécurité (lu par Claude). */
113export const SECURITY_CRITERIA =
114  'authentication, permissions or access rules, user input reaching a query, a path or HTML, secrets or tokens, network or file access, uploads, dependencies'
115
116/** Les zones où un ticket passe aussi par la passe « bogues » (lu par Claude). */
117export const SENSITIVE_AREAS = 'privacy, data deletion, encryption, concurrency'
118
119/** Où vit la règle des revues (modèle, effort, consignes, cible, écritures), citée par les consignes des relecteurs. */
120export const REVIEW_RULE = 'review rule, config/rules/agents.md when installed'
121
122/** Un relecteur lancé par l'outil Agent reçoit le chemin de son fichier de consignes dans son prompt. */
123export const INSTRUCTIONS_IN_PROMPT = `its instructions in a file whose path goes in its prompt (${REVIEW_RULE})`
124
125/**
126 * Les choix déjà déclarés, que les revues ne doivent pas resignaler (lu par Claude) : un fichier
127 * tenu par flow-board, cité par son chemin, plutôt qu'une liste recopiée dans chaque consigne.
128 */
129export const DECIDED_HINT = 'the path of the file of the choices already declared in this run (under "Declared choices" below), not to be reported again nor undone, and the reviewer brief given there'
130
131/**
132 * Ce que tout relecteur reçoit tel quel (lu par Claude) : un relecteur avait rendu son rapport
133 * avant le second axe, d'où une revue Spec lancée deux fois, et une passe `--fix` avait appliqué
134 * un correctif laissé à un autre groupe. Le tri des constats (Important, Nit, Pre-existing) suit la revue
135 * de l'équipe Claude Code : pour un auteur agent, une remarque mineure coûte un commit et un tour de revue.
136 */
137export const REVIEWER_BRIEF =
138  'when the review has several axes (Standards and Spec), report only once all of them are back, and never launch an axis again that is already running; do not rerun the tests nor the checks (the session ran them); report apart the findings on code older than the lot (lines the lot did not change): do not fix them, they go to the user as a « durcissement » group proposed at the end of the lot; tag every finding Important, Nit or Pre-existing (the code older than the lot just above): only the Important ones get fixed, the Nits (style, naming, a cleanup that changes no behaviour) are listed and not fixed, so that a nit costs neither a commit nor another review cycle; for a defect, name its class (the pattern, not only the line) so that every occurrence of it gets fixed; do not report as a defect, nor apply, anything the spec, the ticket or the declared choices leave to another group or exclude; do not report again nor undo the declared choices; never write in the working tree, not even for a moment (a red test that proves a defect stays with the session): the only writes are the fixes of a group review'
139
140/**
141 * Le canal des consignes d'une revue (lu par Claude) : `/code-review` tourne en exécution dérivée
142 * et ne reçoit que ses arguments, et un hash seul lui avait fait relire un commit au lieu du groupe.
143 * Forme essayée avec Claude Code 2.1.310 (niveaux low et high, avec et sans --fix) : le chemin,
144 * annoncé par « consignes : » après la cible, est lu d'abord et ne devient pas la cible.
145 */
146export const reviewInstructions = (isRepo: boolean): string =>
147  `write each review's instructions in a file of your temporary folder: its scope, the path of the declared choices file, the points left to the user, what it must not do, and the reviewer brief; pass its path after the target, ${isRepo ? '/code-review <level> <base>..HEAD consignes : <path> (the target is always a range <base>..HEAD or a branch, never a commit hash alone; before a ticket\'s commit, the scope also names the uncommitted changes)' : '/code-review <level> consignes : <path> (the file lists the copies and files to compare)'}, and to a reviewer subagent in its prompt (${REVIEW_RULE})`
148
149/** Une revue de groupe en zone sensible n'applique rien seule : son relecteur trie les constats. */
150export const SENSITIVE_NO_FIX = `when a ticket of the group touches a sensitive area (${SENSITIVE_AREAS}) or did not say, without --fix: the reviewer applies itself only the findings it keeps`
151
152/**
153 * Commit avant la revue (Mathis, 26/10/2026) : un ticket hors zone sensible n'attend plus sa revue
154 * pour laisser démarrer le suivant ; un ticket sensible garde sa revue avant son commit.
155 */
156export const COMMIT_BEFORE_REVIEW =
157  'a ticket sent with "sensitive": false is committed once its checks pass, before its review, so the next ticket starts while the review runs: the review target is then <commit before the ticket>..<the ticket\'s commit>, never ..HEAD (HEAD and the working tree already hold the next ticket); its fixes land in a commit of their own, and its findings go in "review" without "step" (the next ticket\'s steps stay where they are); the ticket turns done in the tracker only once its review is back and its fixes are committed; a ticket that touches a sensitive area, or did not say, keeps its review before its commit'
158
159/** Le seuil d'un seul relecteur pour les deux axes : seul le code écrit compte (346 lignes de SVG généré avaient compté). */
160export const SMALL_DIFF = 'under 100 changed lines, counted by git diff --numstat, without generated files nor binaries, and outside a sensitive area: both axes in a single reviewer subagent; otherwise two in parallel'
161
162/**
163 * Un groupe d'un seul ticket sans logique ni zone sensible : la revue du ticket est la seule, et la
164 * sécurité se juge à son commit, sans revue du groupe à accompagner.
165 */
166export const SINGLE_TICKET_GROUP = 'A group of a single ticket sent with "sensitive": false and "logic": false has no group review: its ticket review is its only review, the dashboard shows it reviewed, and its security judgement comes once that ticket is committed'
167
168/** La consigne de la revue d'un ticket, pour un point fixe donné ; sans git, la passe « bogues » n'a pas de plage. */
169const ticketReviewHint = (fixedPoint: string, isRepo: boolean): string =>
170  `/mattpocock-skills:code-review on this ticket's changes only (Skill tool; ${fixedPoint}), its Standards and Spec axes, even for a small diff (${SMALL_DIFF}). Those axes do not hunt bugs: when the diff touches a sensitive area (${SENSITIVE_AREAS}), launch in the same message the bug pass, /code-review xhigh of Claude Code (Skill tool "code-review", arguments "xhigh ${isRepo ? '<range> ' : ''}consignes : <path>"), on the same scope (${fixedPoint}), giving it ${DECIDED_HINT}`
171
172/** Le point fixe d'une revue de ticket. */
173export const TICKET_FIXED_POINT = 'fixed point: the commit before the ticket'
174
175/** Sans git, pas de commit : la copie des fichiers du ticket prise avant lui (méthode dans `NO_GIT_COPIES`). */
176export const NO_GIT_TICKET_FIXED_POINT = "fixed point: the copy of its files taken before the ticket, diffed against the current files (\"Without git\" below)"
177
178/** Sans git, le point fixe d'un groupe : pour chaque fichier, la plus ancienne copie de ses tickets. */
179export const NO_GIT_GROUP_FIXED_POINT = "fixed point: for each file, the oldest copy among the group's tickets, diffed against the current file (\"Without git\" below)"
180
181/**
182 * La méthode des copies, quand le projet n'est pas un dépôt git (lu par Claude) : sans commit, les
183 * revues relisaient des fichiers entiers ; elles comparent la copie d'avant le ticket au fichier actuel.
184 */
185export const NO_GIT_COPIES =
186  "Without git: this project is not a git repository, so no commit marks where a ticket starts. Before touching the first file of a ticket, copy the files it will change into the session's temporary folder (its absolute path, as for the shell rule: printenv CLAUDE_JOB_DIR, or else the temporary or scratchpad folder your instructions give), one subfolder per ticket keeping the path in the project (<temporary folder>/<ticket id>/<path in the project>). A ticket's fixed point is its copy; a group's fixed point is, file by file, the oldest copy among its tickets (a file only a later ticket touched has no copy from the first one). The reviews read a unified diff of the copy against the current file (git diff --no-index <copy> <file> works outside a repository), not the whole files; a file the ticket creates has no copy: it is new and is read whole. Give the reviews the list of copies and files to compare"
187
188/**
189 * Les étapes d'un ticket dans un lot, dans l'ordre : contrôles, puis la revue du ticket (deux axes,
190 * et la passe « bogues » dans le même message pour une zone sensible), puis commit et suivi.
191 */
192const TICKET_STEPS = [
193  step('read', 'Lire spec et ticket', 'read the spec or the ticket'),
194  step('tdd', 'Boucle TDD', 'test-first at the agreed seams'),
195  step('checks', 'Contrôles', 'the project checks: lint, typecheck, the full suite'),
196  {
197    ...step('review', 'Revue du ticket', ticketReviewHint(TICKET_FIXED_POINT, true)),
198    noGitHint: ticketReviewHint(NO_GIT_TICKET_FIXED_POINT, false),
199  },
200  {
201    ...step('commit', 'Commit et suivi', `commit on the current branch (${COMMIT_BEFORE_REVIEW}), then the ticket in the tracker: done, with a comment citing the commit hash (and its review fixes commit)`),
202    // Sans git, aucun commit ne marque le ticket : la revue reste avant le suivi.
203    noGitHint: 'commit on the current branch, then the ticket in the tracker: done, with a comment citing the commit hash',
204  },
205]
206
207/**
208 * Qui fait la revue d'un groupe (lu par Claude) : un sous-agent Opus en effort high, qui lance la
209 * revue au niveau `high` et écrit aussi les correctifs de `--fix` ; la discussion les relit et les commite.
210 */
211export const GROUP_REVIEWER =
212  `a reviewer subagent (Agent tool, general-purpose, model opus, effort high; ${REVIEW_RULE}) that launches /code-review high --fix itself, the path of its instructions file after the target, and applies its fixes itself`
213
214/** La consigne de la revue d'un groupe ; le point fixe ne se précise que sans git. */
215const groupReviewHint = (fixedPoint: string): string =>
216  `/code-review high --fix of Claude Code on the group's changes${fixedPoint} (launched by the user from the dashboard; in a linked session, by the session itself), run in ${GROUP_REVIEWER}, giving it ${DECIDED_HINT}, focused on the interactions between the tickets (each one had its own review); ${SENSITIVE_NO_FIX}. It does not settle a conflict between the spec and an instruction (CLAUDE.md, a request of the user): it leaves that point unfixed and raises it to the user. Then read the diff of its fixes, rerun the checks and commit them`
217
218/** Ce qui suit, par groupe, une fois ses tickets commités : revue du groupe, revue de sécurité, rendu. */
219const GROUP_STEPS = [
220  {
221    ...step('group-review', 'Revue du groupe', groupReviewHint('')),
222    noGitHint: groupReviewHint(` (${NO_GIT_GROUP_FIXED_POINT})`),
223  },
224  {
225    ...step(
226      'security',
227      'Revue de sécurité',
228      "in the same message as the group review (it is read-only, so it does not wait for the fixes): judge whether it is worth it and say so; if it is, launch it yourself: a read-only reviewer subagent limited to the group's diff, never /security-review (details in \"Security review\" below); once the group review's fixes are committed, it reads the diff of those fixes only; it fixes nothing: its findings go to the user, who decides on their fixes",
229    ),
230    isWithPrevious: true,
231  },
232  step('handback', 'Rendu du groupe', 'the group is back on the dashboard: its tickets done, its review and security review reported to the flow tool; the user launches the next group'),
233]
234const phase = (skill: string, isOptional = false, alts?: string[]): PhaseSpec => ({ skill, isOptional, alts })
235
236/**
237 * Ce qu'un changement laisse derrière lui en zone sensible (lu par Claude) : les fichiers en clair
238 * et les journaux qui dépendaient d'un mode retiré n'étaient apparus qu'en fin de ticket.
239 */
240const SENSITIVE_STEP = step(
241  'sensitive',
242  'Conséquences sensibles',
243  `list what the change, or a removal, leaves behind in a sensitive area (${SENSITIVE_AREAS}): files left in clear, logs that depend on a mode, data kept once a feature is gone; each one becomes a question to the user`,
244)
245
246const GRILL_STEPS = [
247  step('tree', 'Arbre des décisions', 'map the design tree of decisions'),
248  step('rounds', 'Tours de questions', 'ask the frontier, round after round'),
249  SENSITIVE_STEP,
250  step('agree', 'Accord partagé', 'confirm the shared understanding'),
251]
252
253const DECISIONS = { label: 'Décisions', hint: 'the decisions of the design tree (done = settled, dropped = out of scope)' }
254
255const IMPL = phase('implement', false, ['implement-spec'])
256const IMPL_OPT = phase('implement', true, ['implement-spec'])
257/** Le compte rendu à valider avant toute livraison : jamais facultatif. */
258const REPORT = phase('compte-rendu')
259/**
260 * Le compte rendu d'un parcours dont l'Implémentation est facultative : facultatif tant qu'on n'a
261 * pas codé (le parcours peut s'arrêter à la carte ou au triage), obligatoire dès que l'Implémentation
262 * démarre (moveTo, dans model.ts).
263 */
264const REPORT_IF_CODE = phase('compte-rendu', true)
265
266export const SKILLS: Record<string, SkillDef> = {
267  // ─── Cadrage ────────────────────────────────────────────────────────────
268  'grill-with-docs': {
269    id: 'grill-with-docs',
270    label: 'Cadrage',
271    flowLabel: 'Nouvelle fonctionnalité',
272    verb: 'Interview serré du plan ; glossaire et ADR écrits au fil de l’eau',
273    glyph: '◆',
274    color: C.violet,
275    role: 'flow',
276    isUserOnly: true,
277    steps: GRILL_STEPS,
278    items: DECISIONS,
279    uses: { grilling: null, 'domain-modeling': null },
280    isGrilling: true,
281    flow: [phase('grill-with-docs'), phase('to-spec', true), phase('to-tickets', true), IMPL, REPORT, phase('project:version', true), phase('project:pr', true)],
282    next: ['to-spec', 'implement', 'prototype'],
283  },
284  'grill-me': {
285    id: 'grill-me',
286    label: 'Grilling',
287    flowLabel: 'Réflexion',
288    verb: 'Interview serré d’un plan ou d’une idée, sans rien écrire',
289    glyph: '◇',
290    color: C.violet,
291    role: 'flow',
292    isUserOnly: true,
293    steps: GRILL_STEPS,
294    items: DECISIONS,
295    uses: { grilling: null },
296    isGrilling: true,
297    flow: [phase('grill-me'), phase('to-spec', true)],
298    next: ['to-spec', 'to-questionnaire'],
299  },
300  grilling: {
301    id: 'grilling',
302    label: 'Grilling',
303    flowLabel: 'Grilling',
304    verb: 'Questions par tours sur la frontière des décisions',
305    glyph: '◇',
306    color: C.violet,
307    role: 'detour',
308    isUserOnly: false,
309    steps: GRILL_STEPS,
310    items: DECISIONS,
311    isGrilling: true,
312  },
313  'to-questionnaire': {
314    id: 'to-questionnaire',
315    label: 'Questionnaire',
316    flowLabel: 'Questionnaire',
317    verb: 'Une décision qu’on ne tranche pas seul, mise en questionnaire',
318    glyph: '≡',
319    color: C.lilac,
320    role: 'detour',
321    isUserOnly: true,
322    steps: [
323      step('recipient', 'Destinataire', 'who will answer: role, expertise, relationship'),
324      step('need', 'Besoin', 'the decisions or facts needed back'),
325      step('write', 'Questionnaire écrit', 'write to-questionnaire-<slug>.md'),
326    ],
327    flow: [phase('to-questionnaire'), phase('grill-with-docs', true)],
328    next: ['grill-with-docs', 'to-spec'],
329  },
330
331  // ─── Spec et tickets ────────────────────────────────────────────────────
332  'to-spec': {
333    id: 'to-spec',
334    label: 'Spec',
335    gender: 'f',
336    flowLabel: 'De la spec au code',
337    verb: 'Synthèse de la conversation, publiée sur le tracker',
338    glyph: '▤',
339    color: C.blue,
340    role: 'flow',
341    isUserOnly: true,
342    steps: [
343      step('config', 'Config du tracker', 'check the tracker config'),
344      step('explore', 'Explorer le dépôt', 'explore the repo with the glossary vocabulary'),
345      step('seams', 'Seams validés', 'sketch the seams and get them confirmed'),
346      SENSITIVE_STEP,
347      step('write', 'Rédiger la spec', 'write the spec from the template; before showing or publishing it, consult the advisor (advisor tool), not a reviewer subagent; without that tool, reread it yourself in this discussion'),
348      step('publish', 'Publier', 'publish it to the tracker (ready-for-agent)'),
349    ],
350    flow: [phase('to-spec'), phase('to-tickets'), IMPL, REPORT, phase('project:version', true), phase('project:pr', true)],
351    next: ['to-tickets'],
352  },
353  'to-tickets': {
354    id: 'to-tickets',
355    label: 'Tickets',
356    gender: 'pl',
357    flowLabel: 'Des tickets au code',
358    verb: 'Découpage en tranches verticales, avec leurs blocages',
359    glyph: '▦',
360    color: C.cyan,
361    role: 'flow',
362    isUserOnly: true,
363    hasTickets: true,
364    steps: [
365      step('context', 'Contexte', 'gather the spec or conversation'),
366      step('explore', 'Explorer le code', 'look for prefactoring'),
367      step('slice', 'Tranches verticales', 'draft tracer-bullet slices with blocking edges'),
368      step('approve', 'Découpage validé', 'iterate until the user approves'),
369      step('publish', 'Publier les tickets', 'publish them, blockers first'),
370    ],
371    flow: [phase('to-tickets'), IMPL, REPORT, phase('project:version', true), phase('project:pr', true)],
372    next: ['implement', 'implement-spec'],
373  },
374  triage: {
375    id: 'triage',
376    label: 'Triage',
377    flowLabel: 'Triage',
378    verb: 'Les issues passent par la machine à états du triage',
379    glyph: '☰',
380    color: C.sky,
381    role: 'flow',
382    isUserOnly: true,
383    steps: [
384      step('queue', 'File à traiter', 'list what needs attention'),
385      step('context', 'Contexte', 'read the issue, comments, labels, out-of-scope'),
386      step('recommend', 'Recommandation', 'propose category and state, then wait'),
387      step('verify', 'Vérification', 'reproduce the bug or check the PR'),
388      step('grill', 'Approfondir', 'grill the request if needed'),
389      step('apply', 'Appliquer', 'write the agent brief or triage notes, set labels'),
390    ],
391    items: { label: 'Issues', hint: 'the issues being triaged (label = "#n title", done = triaged)' },
392    uses: { grilling: 'grill', 'domain-modeling': null },
393    flow: [phase('triage'), IMPL_OPT, REPORT_IF_CODE, phase('project:version', true), phase('project:pr', true)],
394    next: ['implement'],
395  },
396  wayfinder: {
397    id: 'wayfinder',
398    label: 'Wayfinder',
399    flowLabel: 'Grand chantier',
400    verb: 'Carte de tickets de décision, résolus un à la fois',
401    glyph: '⚑',
402    color: C.indigo,
403    role: 'flow',
404    isUserOnly: true,
405    steps: [
406      step('destination', 'Destination', 'name the destination'),
407      step('frontier', 'Frontière', 'map the frontier breadth-first'),
408      step('map', 'Carte et tickets', 'create the map and its decision tickets'),
409      step('resolve', 'Résoudre un ticket', 'claim and resolve one ticket'),
410      step('record', 'Décision consignée', 'record the decision and update the map'),
411    ],
412    items: { label: 'Tickets de décision', hint: 'the decision tickets of the map (done = resolved)' },
413    uses: { grilling: null, 'domain-modeling': null, research: null, prototype: null },
414    flow: [phase('wayfinder'), phase('to-spec', true), phase('to-tickets', true), IMPL_OPT, REPORT_IF_CODE, phase('project:version', true), phase('project:pr', true)],
415    next: ['to-spec', 'wayfinder'],
416  },
417
418  // ─── Construction ───────────────────────────────────────────────────────
419  implement: {
420    id: 'implement',
421    label: 'Implémentation',
422    gender: 'f',
423    flowLabel: 'Implémentation',
424    verb: 'Le travail décrit par la spec ou le ticket, en TDD, revu puis commité',
425    glyph: '⚙',
426    color: C.tangerine,
427    role: 'flow',
428    isUserOnly: true,
429    hasTickets: true,
430    hasTests: true,
431    steps: TICKET_STEPS,
432    groupSteps: GROUP_STEPS,
433    uses: { tdd: 'tdd', 'code-review': 'review', 'codebase-design': null },
434    flow: [phase('implement', false, ['implement-spec']), REPORT, phase('project:version', true), phase('project:pr', true)],
435    next: ['compte-rendu', 'implement', 'retro'],
436  },
437  'implement-spec': {
438    id: 'implement-spec',
439    label: 'Implémentation',
440    gender: 'f',
441    flowLabel: 'Implémentation de spec',
442    verb: 'Graphe de tickets : implementers en parallèle sur la frontière',
443    glyph: '⚙',
444    color: C.tangerine,
445    role: 'flow',
446    isUserOnly: true,
447    hasTickets: true,
448    hasTests: true,
449    steps: [
450      step('graph', 'Lire le graphe', 'read the spec and the tickets as a task graph'),
451      step('explore', 'Exploration', 'optional exploration notes outside the repo'),
452      step('branch', 'Branche d’intégration', 'create the integration branch'),
453      step('implementers', 'Implementers', 'run implementers on the ready frontier'),
454      step('merge', 'Merges', 'merge each finished ticket'),
455      step('review', 'Revue globale', `code-review the integration branch, giving it ${DECIDED_HINT}`),
456      step('close', 'Clôture', 'mark the PR ready or resolve the tickets'),
457      step('cleanup', 'Nettoyage', 'remove the worktrees'),
458    ],
459    uses: { tdd: null, 'code-review': 'review' },
460    flow: [phase('implement-spec', false, ['implement']), REPORT, phase('project:version', true), phase('project:pr', true)],
461    next: ['compte-rendu', 'retro'],
462  },
463  tdd: {
464    id: 'tdd',
465    label: 'TDD',
466    flowLabel: 'TDD',
467    verb: 'Rouge puis vert, un seam et un test à la fois',
468    glyph: '◑',
469    color: C.coral,
470    role: 'detour',
471    isUserOnly: false,
472    hasTests: true,
473    steps: [
474      step('glossary', 'Glossaire et ADR', 'read the glossary and ADRs of the area'),
475      step('seams', 'Seams convenus', 'agree the seams to test with the user'),
476      step('cycles', 'Cycles rouge → vert', 'one failing test, then just enough code'),
477    ],
478    items: { label: 'Seams', hint: 'the agreed seams (done = covered by a passing test)' },
479    uses: { 'codebase-design': null },
480    flow: [phase('tdd'), phase('code-review', true), REPORT, phase('project:version', true), phase('project:pr', true)],
481    next: ['code-review', 'compte-rendu'],
482  },
483  prototype: {
484    id: 'prototype',
485    label: 'Prototype',
486    flowLabel: 'Prototype',
487    verb: 'Un prototype jetable pour trancher une question de design',
488    glyph: '✦',
489    color: C.magenta,
490    role: 'detour',
491    isUserOnly: false,
492    steps: [
493      step('mode', 'Logique ou UI', 'pick the logic or the UI branch'),
494      step('question', 'Question posée', 'state the question the prototype answers'),
495      step('build', 'Construire', 'build the throwaway prototype'),
496      step('iterate', 'Itérer', 'iterate on feedback'),
497      step('capture', 'Décision capturée', 'capture the verdict and clean up'),
498    ],
499    items: { label: 'Variantes', hint: 'the variants or scenarios (done = kept, dropped = rejected)' },
500    flow: [phase('prototype')],
501    next: ['to-spec', 'implement', 'handoff'],
502  },
503  'diagnosing-bugs': {
504    id: 'diagnosing-bugs',
505    label: 'Diagnostic',
506    flowLabel: 'Chasse au bug',
507    verb: 'Boucle de feedback, hypothèses, correctif et test de régression',
508    glyph: '◎',
509    color: C.red,
510    role: 'flow',
511    isUserOnly: false,
512    hasTests: true,
513    steps: [
514      step('loop', 'Boucle de feedback', 'phase 1: a fast, deterministic, red-capable command'),
515      step('reproduce', 'Reproduire et réduire', 'phase 2: reproduce and minimise'),
516      step('hypotheses', 'Hypothèses', 'phase 3: ranked falsifiable hypotheses'),
517      step('instrument', 'Instrumenter', 'phase 4: test them one variable at a time'),
518      step('fix', 'Correctif et test', 'phase 5: regression test, then the fix'),
519      step('cleanup', 'Nettoyage', 'phase 6: remove debug logs and leftovers'),
520    ],
521    items: { label: 'Hypothèses', hint: 'the hypotheses (doing = being tested, done = confirmed, dropped = refuted)' },
522    flow: [phase('diagnosing-bugs'), phase('code-review', true), REPORT, phase('project:version', true), phase('project:pr', true)],
523    // Un bug trouvé en codant ou en relisant reste dans le lot qui l'a fait apparaître ; ailleurs
524    // (cadrage, spec…), il ouvre sa « Chasse au bug ». Le bouton « 🐞 Bug · session liée » suit cette liste.
525    detourIn: ['implement', 'implement-spec', 'tdd', 'code-review', 'compte-rendu'],
526    next: ['code-review', 'compte-rendu', 'improve-codebase-architecture'],
527  },
528  wizard: {
529    id: 'wizard',
530    label: 'Assistant guidé',
531    flowLabel: 'Procédure guidée',
532    verb: 'Un script bash interactif pour les gestes que seul un humain peut faire',
533    glyph: '⌘',
534    color: C.sand,
535    role: 'detour',
536    isUserOnly: false,
537    steps: [
538      step('frame', 'Procédure', 'list the stages and get them confirmed'),
539      step('trace', 'Tracer les étapes', 'URL, clicks and value of each stage'),
540      step('script', 'Écrire le script', 'copy the template, one stage per step'),
541      step('verify', 'Vérifier', 'bash -n, shellcheck, chmod +x, hand over'),
542    ],
543    flow: [phase('wizard')],
544  },
545
546  // ─── Qualité et livraison ───────────────────────────────────────────────
547  'code-review': {
548    id: 'code-review',
549    label: 'Revue',
550    gender: 'f',
551    flowLabel: 'Revue de code',
552    verb: 'Deux relecteurs en parallèle : Standards et Spec',
553    glyph: '◈',
554    color: C.amber,
555    role: 'detour',
556    isUserOnly: false,
557    steps: [
558      step('point', 'Point fixe', 'pick the fixed point and read the diff'),
559      step('spec', 'Trouver la spec', 'find the spec'),
560      step('standards', 'Trouver les standards', 'find the coding standards'),
561      step('reviewers', 'Relecteurs en parallèle', 'run the Standards and Spec reviewers'),
562      step('report', 'Synthèse', 'report both axes side by side'),
563    ],
564    flow: [phase('code-review'), REPORT, phase('project:version', true), phase('project:pr', true)],
565    next: ['compte-rendu'],
566  },
567  pr: {
568    id: 'pr',
569    label: 'Corps de PR',
570    flowLabel: 'Corps de PR',
571    verb: 'Le corps de la PR : résumé visuel, preuves, danger de merge',
572    glyph: '¶',
573    color: C.green,
574    role: 'step',
575    isUserOnly: false,
576    steps: [
577      step('summary', 'Résumé visuel', 'the smallest view that tells the change'),
578      step('evidence', 'Preuves', 'screenshots or test output'),
579      step('danger', 'Danger de merge', 'one-way or two-way door, blast radius'),
580      step('open', 'PR ouverte', 'the pull request is created'),
581    ],
582    flow: [phase('pr')],
583    next: ['retro'],
584  },
585  // ─── Livraison (plugin project, commandes réservées à la personne) ─────
586  'project:sync': {
587    id: 'project:sync',
588    label: 'Synchronisation',
589    gender: 'f',
590    flowLabel: 'Synchronisation',
591    verb: 'Met la branche à jour avec le distant (fetch, rebase, autostash)',
592    glyph: '⟳',
593    color: C.cyan,
594    role: 'step',
595    isUserOnly: true,
596    steps: [
597      step('fetch', 'Fetch', 'git fetch --prune origin'),
598      step('pull', 'Rebase ou merge', 'bring in the upstream or the default branch'),
599    ],
600  },
601  'final-review': {
602    id: 'final-review',
603    label: 'Revue du groupe',
604    gender: 'f',
605    flowLabel: 'Revue du groupe',
606    verb: 'Le groupe relu par /code-review high --fix de Claude Code (sans --fix pour un groupe sensible), une fois ses tickets commités et relus, dans un sous-agent relecteur qui applique lui-même ses correctifs : les bugs entre ses tickets, y compris avec les groupes déjà livrés ; un conflit entre la spec et une consigne n’est pas tranché, il remonte',
607    glyph: '◆',
608    color: C.coral,
609    // Pas une phase : un événement de l'Implémentation, qui marque les groupes finis comme relus.
610    role: 'event',
611    isUserOnly: true,
612    command: 'code-review',
613    // `high` fixe le nombre de constats (sinon, le dernier niveau tapé) ; la revue applique ses
614    // correctifs à la copie de travail, à relire avant le commit.
615    args: 'high --fix',
616    steps: [],
617  },
618  'branch-review': {
619    id: 'branch-review',
620    label: 'Relecture',
621    gender: 'f',
622    flowLabel: 'Relecture de branche',
623    verb: 'Une branche ou une PR relue par /code-review de Claude Code : les bugs du diff, corrigés avec --fix ou commentés sur la PR avec --comment',
624    glyph: '◆',
625    color: C.coral,
626    // `/code-review` de Claude Code lancé sans parcours actif ici ouvre ce parcours (applySkill) ;
627    // pendant un parcours, la même commande reste la revue du groupe (`final-review`).
628    role: 'flow',
629    isUserOnly: true,
630    command: 'code-review',
631    steps: [],
632    flow: [phase('branch-review'), REPORT, phase('project:version', true), phase('project:pr', true)],
633    next: ['compte-rendu'],
634  },
635  'security-review': {
636    id: 'security-review',
637    label: 'Revue de sécurité',
638    gender: 'f',
639    flowLabel: 'Revue de sécurité',
640    verb: 'Après la revue du groupe, quand Claude la juge utile : un sous-agent relecteur qu’il lance lui-même, limité au diff du groupe ; les failles te reviennent, tu décides de leur correction',
641    glyph: '⛨',
642    color: C.red,
643    // Comme la revue du groupe, un événement de l'Implémentation ; elle ne bloque jamais la livraison.
644    // Plus de commande : Claude lance un sous-agent (/security-review relit tout depuis la branche par
645    // défaut). L'id reconnaît encore un /security-review tapé à la main.
646    role: 'event',
647    isUserOnly: false,
648    steps: [],
649  },
650  'compte-rendu': {
651    id: 'compte-rendu',
652    label: 'Compte rendu',
653    flowLabel: 'Compte rendu',
654    verb: 'Le rapport du travail, à lire et valider avant de livrer : fait, décidé sans toi, mal passé, bloqué, trouvé, documentation',
655    glyph: '◧',
656    color: C.pink,
657    // Une étape du parcours, jamais un point de départ : sans parcours, le skill rédige seulement.
658    role: 'step',
659    isUserOnly: true,
660    command: 'flow-board:compte-rendu',
661    steps: [
662      step('facts', 'Rassembler les faits', 'collect the facts: git log since the run started, git status, the tracking file, the tracker'),
663      step('write', 'Rédiger le rapport', 'write the report in the fixed sections, short and plain'),
664      step('validate', 'Ta validation', 'wait for the user to validate the report from the dashboard'),
665    ],
666    next: ['project:version'],
667  },
668  'project:version': {
669    id: 'project:version',
670    label: 'Version et push',
671    gender: 'f',
672    flowLabel: 'Livraison',
673    verb: 'Monte la version (SemVer), puis commite, contrôle et pousse : /project:version puis /project:push',
674    glyph: '⬆',
675    color: C.sky,
676    role: 'step',
677    isUserOnly: true,
678    steps: [
679      step('sources', 'Fichiers de version', 'find the version sources'),
680      step('bump', 'Choix du bump', 'choose the SemVer bump from the commits'),
681      step('update', 'Mise à jour', 'update every version file'),
682      step('tag', 'Commit et tag', 'commit and tag the release'),
683      step('push', 'Push : reste à commiter', '/project:push: where it goes, what is left to commit'),
684      step('checks', 'Contrôles', 'run the project checks'),
685      step('docs', 'Fichiers de fin de session', 'end-of-session files up to date'),
686      step('pushed', 'Sync et push', 'sync with the remote and push without force'),
687    ],
688    uses: { 'project:push': 'push', 'project:sync': null },
689    next: ['project:pr'],
690  },
691  'project:push': {
692    id: 'project:push',
693    label: 'Push',
694    flowLabel: 'Livraison',
695    verb: 'Commite ce qui reste, lance les contrôles et pousse sans forcer',
696    glyph: '⬆',
697    color: C.sky,
698    role: 'step',
699    isUserOnly: true,
700    partOf: { skill: 'project:version', step: 'push' },
701    steps: [],
702    next: ['project:pr'],
703  },
704  'project:pr': {
705    id: 'project:pr',
706    label: 'Pull request',
707    gender: 'f',
708    flowLabel: 'Pull request',
709    verb: 'Ouvre la PR GitHub, titre et corps rédigés depuis les commits',
710    glyph: '⇄',
711    color: C.green,
712    role: 'step',
713    isUserOnly: true,
714    // La PR se fusionne d'elle-même une fois les contrôles passés.
715    args: '--auto',
716    steps: [
717      step('preflight', 'Vérifications', 'pre-flight: branch, base, pushed'),
718      step('body', 'Titre et corps', 'write the title and the body'),
719      step('create', 'PR ouverte', 'create the pull request'),
720    ],
721    uses: { pr: 'body' },
722    next: ['retro'],
723  },
724  retro: {
725    id: 'retro',
726    label: 'Rétro',
727    gender: 'f',
728    flowLabel: 'Rétrospective',
729    verb: 'Ce que la session apprend aux prochaines',
730    glyph: '↺',
731    color: C.pink,
732    role: 'flow',
733    isUserOnly: true,
734    steps: [
735      step('style', 'Guide de style', 'load writing-for-agents'),
736      step('read', 'Lire la session', 'read the session logs'),
737      step('find', 'Pistes (7 axes)', 'look for improvements in the seven categories'),
738      step('present', 'Par sévérité', 'present them by severity, those that touch another repository apart, as a correction list ready to hand to an agent of that repository; then wait: the user picks the improvements of this repository in the dashboard and sends them to a linked session, or tells you which ones to apply'),
739      step(
740        'apply',
741        'Modifications en cours',
742        'set by the dashboard once the linked session you launch for the improvements the user sent has started; the workflow stays here: never resume it there. At its final note, check its work, send the full items list with each status (done if done and checked; todo if not done: back to the user\'s choice; dropped only when the user gives it up), then complete unless some went back to todo',
743      ),
744    ],
745    items: { label: 'Pistes', hint: 'the improvement candidates of this repository only (label = "category: fix"); those that touch another repository stay out of the items', linkedStep: 'apply' },
746    uses: { 'writing-for-agents': 'style' },
747    flow: [phase('retro')],
748  },
749
750  // ─── Santé du code et conception ────────────────────────────────────────
751  'improve-codebase-architecture': {
752    id: 'improve-codebase-architecture',
753    label: 'Architecture',
754    gender: 'f',
755    flowLabel: 'Santé du code',
756    verb: 'Repérer les modules à approfondir, rapport HTML puis grilling',
757    glyph: '▲',
758    color: C.teal,
759    role: 'flow',
760    isUserOnly: true,
761    steps: [
762      step('scope', 'Périmètre', 'pick the area (git log hotspots if none given)'),
763      step('read', 'Glossaire et ADR', 'read the glossary and ADRs'),
764      step('explore', 'Exploration', 'explore with a sub-agent, note the friction'),
765      step('report', 'Rapport HTML', 'write and open the HTML report'),
766      step('choose', 'Choix du candidat', 'ask which candidate to explore'),
767      step('grill', 'Grilling', 'grill the chosen candidate'),
768    ],
769    items: { label: 'Candidats', hint: 'the deepening candidates (label may end with Strong / Worth exploring / Speculative)' },
770    uses: { 'codebase-design': null, grilling: 'grill', 'domain-modeling': null },
771    flow: [
772      phase('improve-codebase-architecture'),
773      phase('grill-with-docs', true),
774      phase('to-spec', true),
775      phase('to-tickets', true),
776      IMPL_OPT,
777      REPORT_IF_CODE,
778      phase('project:version', true),
779      phase('project:pr', true),
780    ],
781    next: ['grill-with-docs'],
782  },
783  'codebase-design': {
784    id: 'codebase-design',
785    label: 'Design de module',
786    flowLabel: 'Design',
787    verb: 'Modules profonds : interfaces, seams et alternatives',
788    glyph: '▣',
789    color: C.teal,
790    role: 'detour',
791    isUserOnly: false,
792    steps: [
793      step('frame', 'Cadrer le problème', 'constraints, dependencies, sketch'),
794      step('alternatives', 'Alternatives', 'sub-agents design different interfaces'),
795      step('compare', 'Comparer', 'compare depth, locality and seams'),
796    ],
797    items: { label: 'Designs', hint: 'the alternative interfaces (done = recommended, dropped = discarded)' },
798    flow: [phase('codebase-design')],
799  },
800  'domain-modeling': {
801    id: 'domain-modeling',
802    label: 'Domaine',
803    flowLabel: 'Modèle du domaine',
804    verb: 'Le vocabulaire du projet : glossaire et ADR',
805    glyph: '§',
806    color: C.lilac,
807    role: 'detour',
808    isUserOnly: false,
809    steps: [
810      step('challenge', 'Confronter au glossaire', 'challenge terms against GLOSSARY.md'),
811      step('sharpen', 'Préciser les termes', 'propose canonical terms, probe edge cases'),
812      step('record', 'Glossaire et ADR', 'write the glossary and ADRs'),
813    ],
814    flow: [phase('domain-modeling')],
815  },
816
817  // ─── Productivité ───────────────────────────────────────────────────────
818  research: {
819    id: 'research',
820    label: 'Recherche',
821    gender: 'f',
822    flowLabel: 'Recherche',
823    verb: 'Sources primaires, un rapport Markdown dans le dépôt',
824    glyph: '✧',
825    color: C.indigo,
826    role: 'detour',
827    isUserOnly: false,
828    steps: [
829      step('agent', 'Agent lancé', 'launch the background agent'),
830      step('sources', 'Sources primaires', 'investigate primary sources'),
831      step('write', 'Rapport', 'write the Markdown file'),
832      step('file', 'Rangé', 'file it where the repo keeps such notes'),
833    ],
834    flow: [phase('research'), phase('grill-with-docs', true)],
835    next: ['grill-with-docs', 'to-spec'],
836  },
837  handoff: {
838    id: 'handoff',
839    label: 'Passation',
840    gender: 'f',
841    flowLabel: 'Passation',
842    verb: 'La conversation compactée pour une autre session',
843    glyph: '➜',
844    color: C.slate,
845    role: 'detour',
846    isUserOnly: true,
847    steps: [
848      step('summary', 'Résumé', 'summarise the session'),
849      step('skills', 'Skills suggérés', 'suggest the next skills'),
850      step('refs', 'Références', 'point to specs, ADRs, issues, commits'),
851      step('save', 'Document enregistré', 'save it in the temp directory'),
852    ],
853    flow: [phase('handoff')],
854  },
855  teach: {
856    id: 'teach',
857    label: 'Apprentissage',
858    flowLabel: 'Apprentissage',
859    verb: 'Une leçon dans l’espace de travail, au bon niveau',
860    glyph: '✎',
861    color: C.amber,
862    role: 'flow',
863    isUserOnly: true,
864    steps: [
865      step('mission', 'Mission', 'check MISSION.md'),
866      step('resources', 'Ressources', 'gather trusted resources'),
867      step('level', 'Niveau', 'find the zone of proximal development'),
868      step('lesson', 'Leçon', 'write and open the lesson'),
869      step('record', 'Acquis', 'write a learning record'),
870    ],
871    flow: [phase('teach')],
872    next: ['teach'],
873  },
874  'writing-for-agents': {
875    id: 'writing-for-agents',
876    label: 'Écriture pour agents',
877    gender: 'f',
878    flowLabel: 'Écriture pour agents',
879    verb: 'Skills, AGENTS.md et CLAUDE.md écrits pour un agent',
880    glyph: '¶',
881    color: C.slate,
882    role: 'detour',
883    isUserOnly: false,
884    steps: [
885      step('principles', 'Principes', 'read the principles (and SKILL-MECHANICS for a skill)'),
886      step('write', 'Rédaction', 'write or edit the document'),
887    ],
888    flow: [phase('writing-for-agents')],
889  },
890  'setup-matt-pocock-skills': {
891    id: 'setup-matt-pocock-skills',
892    label: 'Configuration',
893    gender: 'f',
894    flowLabel: 'Mise en place',
895    verb: 'Tracker, labels de triage et docs de domaine du dépôt',
896    glyph: '⊞',
897    color: C.slate,
898    role: 'flow',
899    isUserOnly: true,
900    steps: [
901      step('explore', 'Explorer le dépôt', 'remote, CLAUDE.md, glossaries, docs/agents'),
902      step('tracker', 'Tracker', 'section A: the issue tracker'),
903      step('labels', 'Labels', 'section B: triage labels'),
904      step('domain', 'Docs de domaine', 'section C: domain docs layout'),
905      step('draft', 'Brouillon', 'show the draft'),
906      step('write', 'Config écrite', 'write docs/agents and the Agent skills block'),
907    ],
908    flow: [phase('setup-matt-pocock-skills')],
909    next: ['grill-with-docs', 'triage', 'wayfinder'],
910  },
911  'ask-matt': {
912    id: 'ask-matt',
913    label: 'Orientation',
914    gender: 'f',
915    flowLabel: 'Orientation',
916    verb: 'Quel skill pour quelle situation',
917    glyph: '✱',
918    color: C.gray,
919    role: 'event',
920    isUserOnly: true,
921    steps: [],
922  },
923  'wait-what': {
924    id: 'wait-what',
925    label: 'Reformulation',
926    gender: 'f',
927    flowLabel: 'Reformulation',
928    verb: 'Le dernier message reformulé, plus simplement',
929    glyph: '?',
930    color: C.gray,
931    role: 'event',
932    isUserOnly: true,
933    steps: [],
934  },
935}
936
937/** Les ids qu'aucun autre plugin ne porte : acceptés aussi sans le préfixe du plugin. */
938const DISTINCT = new Set([
939  'grill-with-docs',
940  'grill-me',
941  'grilling',
942  'to-spec',
943  'to-tickets',
944  'implement-spec',
945  'triage',
946  'wayfinder',
947  'ask-matt',
948  'setup-matt-pocock-skills',
949  'diagnosing-bugs',
950  'improve-codebase-architecture',
951  'codebase-design',
952  'domain-modeling',
953  'to-questionnaire',
954  'wait-what',
955  'writing-for-agents',
956])
957
958/** Accorde un participe au libellé du skill : « Spec terminée », « Tickets démarrés ». */
959export const agree = (def: SkillDef, word: string): string =>
960  def.gender === 'f' ? `${word}e` : def.gender === 'pl' ? `${word}s` : word
961
962/** `mattpocock-skills:to-spec` -> `to-spec` ; un skill d'un autre plugin -> null. */
963export const skillIdOf = (name: string): string | null => {
964  // `/code-review` sans préfixe est la revue de Claude Code : la revue d'un groupe de tickets fini.
965  if (name === 'code-review') return 'final-review'
966  if (name === 'security-review') return 'security-review'
967  // Le compte rendu est un skill de flow-board, pas du plugin mattpocock.
968  if (name === 'flow-board:compte-rendu') return 'compte-rendu'
969  // Les commandes du plugin project gardent leur nom complet (`project:pr` n'est pas `pr`).
970  if (name.startsWith('project:')) return name in SKILLS ? name : null
971  const at = name.lastIndexOf(':')
972  const id = (at >= 0 ? name.slice(at + 1) : name).trim()
973  if (!(id in SKILLS)) return null
974  if (at < 0) return DISTINCT.has(id) ? id : null
975  return name.slice(0, at) === PLUGIN ? id : null
976}
977
978/** La commande slash d'un skill, sans la barre : la sienne, ou son id. */
979export const slashOf = (skill: string): string => SKILLS[skill]?.command ?? skill
980
981/** La commande telle que le panneau la met dans le prompt, arguments compris (`code-review --fix`). */
982export const commandLineOf = (skill: string): string => {
983  const args = SKILLS[skill]?.args
984  return args ? `${slashOf(skill)} ${args}` : slashOf(skill)
985}
986
987/** Les points d'entrée du lanceur, quand aucun parcours n'est en cours. */
988export const LAUNCHERS: { skill: string; title: string; hint: string }[] = [
989  // Dans l'ordre du flux : cadrer, spécifier, découper, implémenter ; puis les autres points d'entrée.
990  { skill: 'grill-with-docs', title: 'Cadrer une fonctionnalité', hint: 'interview, glossaire et ADR' },
991  { skill: 'to-spec', title: 'Rédiger la spec de ce qu’on a discuté', hint: 'synthèse de la discussion, sans interview' },
992  { skill: 'to-tickets', title: 'Découper un plan en tickets', hint: 'tranches verticales et leurs blocages' },
993  { skill: 'implement', title: 'Implémenter un ticket', hint: 'TDD, revue puis commit' },
994  { skill: 'diagnosing-bugs', title: 'Diagnostiquer un bug', hint: 'boucle de feedback et hypothèses' },
995  { skill: 'branch-review', title: 'Relire une branche ou une PR', hint: 'les bugs du diff ; ajoute la cible, --fix ou --comment' },
996  { skill: 'triage', title: 'Trier les issues', hint: 'machine à états du triage' },
997  { skill: 'wayfinder', title: 'Explorer un grand chantier', hint: 'carte de tickets de décision' },
998  { skill: 'improve-codebase-architecture', title: 'Santé de l’architecture', hint: 'modules à approfondir' },
999]
1000
1001/** Les autres skills, par famille : avec LAUNCHERS, les 27 skills mattpocock et les commandes de livraison du plugin project. */
1002export const COMMAND_GROUPS: { title: string; skills: string[] }[] = [
1003  { title: 'Cadrer et planifier', skills: ['grill-me', 'to-questionnaire', 'ask-matt', 'setup-matt-pocock-skills'] },
1004  { title: 'Construire', skills: ['implement-spec', 'tdd', 'prototype', 'research', 'wizard'] },
1005  // La revue de sécurité n'a plus de commande : Claude la lance lui-même (sous-agent relecteur).
1006  { title: 'Qualité', skills: ['code-review', 'compte-rendu', 'retro'] },
1007  { title: 'Livrer (plugin project)', skills: ['project:sync', 'project:version', 'project:push', 'project:pr'] },
1008  { title: 'Conception', skills: ['codebase-design', 'domain-modeling', 'writing-for-agents'] },
1009  { title: 'Au fil de l’eau', skills: ['grilling', 'handoff', 'wait-what', 'pr', 'teach'] },
1010]
1011
1012/**
1013 * Un outil de la barre « À tout moment » : un skill du catalogue (étiquette et glyphe repris de
1014 * SKILLS), ou une commande de Claude Code (`compact`). `asks` : la personne ajoute un texte
1015 * (sujet, consignes) avant Entrée.
1016 */
1017export type AnytimeTool =
1018  /** `label` et `glyph` remplacent ceux du skill ; `withState` préremplit la question avec l'état du parcours. */
1019  | { key: string; skill: string; asks: boolean; label?: string; glyph?: string; withState?: boolean }
1020  | { key: string; command: string; label: string; glyph: string; asks: boolean }
1021
1022/**
1023 * La barre du bas de la page Parcours, dans l'ordre d'usage : comprendre (le dernier message,
1024 * des faits, une décision qu'un tiers tranche), s'orienter (la suite, une décision mise à
1025 * l'épreuve, un terme précisé), puis gérer le contexte (l'alléger, passer la main).
1026 * Aucun de ces outils ne clôt la phase : les skills sont des détours ou des événements.
1027 */
1028export const ANYTIME_GROUPS: { title: string; tools: AnytimeTool[] }[] = [
1029  {
1030    title: 'Comprendre',
1031    tools: [
1032      { key: 'wait-what', skill: 'wait-what', asks: false },
1033      { key: 'research', skill: 'research', asks: true },
1034      // Une question qu'on ne tranche pas en discutant : du code jetable, souvent pendant le cadrage.
1035      { key: 'prototype', skill: 'prototype', asks: true, label: 'Tester une idée' },
1036      { key: 'to-questionnaire', skill: 'to-questionnaire', asks: true },
1037    ],
1038  },
1039  {
1040    title: 'S’orienter',
1041    tools: [
1042      { key: 'ask-matt', skill: 'ask-matt', asks: true, label: 'Et maintenant ?', withState: true },
1043      { key: 'grilling', skill: 'grilling', asks: true, label: 'Challenger' },
1044      // Il questionne d'abord (termes flous, cas limites, écarts avec le code), puis écrit le glossaire ou la décision.
1045      { key: 'domain-modeling', skill: 'domain-modeling', asks: true, label: 'Préciser un terme' },
1046    ],
1047  },
1048  {
1049    title: 'Contexte',
1050    tools: [
1051      { key: 'compact', command: 'compact', label: 'Compact', glyph: '⇣', asks: true },
1052      { key: 'handoff', skill: 'handoff', asks: true },
1053    ],
1054  },
1055]
1056
1057/** Les commandes du tableau de bord lui-même. */
1058export const FLOW_COMMANDS: { args: string; hint: string }[] = [
1059  { args: '', hint: 'ouvre le panneau sur le parcours en cours' },
1060  { args: 'activity', hint: 'chiffres, livrables et journal' },
1061  { args: 'history', hint: 'tous les parcours du projet' },
1062  { args: 'commands', hint: 'cet onglet' },
1063  { args: 'hide', hint: 'cache le bandeau au-dessus du prompt (show le rend)' },
1064  { args: 'pause', hint: 'met de côté le parcours en cours, sans le clore : « Reprendre » le rouvre tel quel' },
1065  { args: 'done', hint: 'clôt le parcours en cours, gardé dans l’historique' },
1066  { args: 'reset', hint: 'archive le parcours en cours' },
1067  { args: 'rename <titre>', hint: 'renomme le parcours en cours' },
1068]
1069
1070/** Les skills dont la phase modifie le code : /project:sync est demandé avant la première modification. */
1071export const CODE_SKILLS = new Set(['implement', 'implement-spec', 'tdd', 'diagnosing-bugs', 'prototype'])
1072
hooks/model.ts 3933 lines
1// Moteur du tableau de bord : des transitions pures. Chacune prend le Board courant
2// (gelé par l'hôte), le clone, applique un signal et rend le nouveau Board avec les
3// effets d'interface à jouer (toasts, ouverture du panneau, note pour Claude), ou null
4// quand le signal ne change rien.
5
6import type {
7  AgentRow,
8  Artifact,
9  ArtifactKind,
10  Board,
11  Detour,
12  ExternalBlocker,
13  FlowItem,
14  FlowPhase,
15  FlowStep,
16  FoundTask,
17  ItemStatus,
18  Relay,
19  RelayEntry,
20  Run,
21  SecurityCheck,
22  SoloDecision,
23  SyncState,
24  Ticket,
25  TicketGroup,
26  TicketStatus,
27  Tone,
28} from '../types'
29import { SKILLS, agree, skillIdOf, type PhaseSpec, type SkillDef } from './catalog.ts'
30
31/** Longueur gardée d'un texte affiché en entier (élément, ticket, raison, choix, entrée du journal). */
32const TEXT_MAX = 300
33
34export const LIMITS = {
35  runs: 15,
36  log: 80,
37  artifacts: 40,
38  tests: 40,
39  agents: 12,
40  tickets: 150,
41  items: 60,
42  touched: 300,
43  detours: 6,
44  tombstones: 50,
45  groups: 12,
46  groupFiles: 12,
47  groupResources: 6,
48  ticketFiles: 12,
49  /** Autres lots parallèles proposés, en plus du lot principal. */
50  otherBatches: 3,
51  found: 40,
52  /** Parcours archivés gardés par projet, au-delà des 15 parcours vivants. */
53  archived: 100,
54  decided: 30,
55} as const
56
57/** Ce que l'interface doit jouer après une transition. */
58export type Effects = {
59  toasts: string[]
60  openPane: boolean
61  /** Note à ajouter au texte du skill pour Claude : celle d'une phase, d'un détour, ou rien. */
62  note: 'none' | 'phase' | 'detour'
63}
64
65export type Outcome = { board: Board; fx: Effects } | null
66
67const noFx = (): Effects => ({ toasts: [], openPane: false, note: 'none' })
68
69const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
70
71export const clip = (text: string, max: number): string =>
72  max <= 0 ? '' : text.length <= max ? text : `${text.slice(0, Math.max(0, max - 1))}…`
73
74const str = (value: unknown): string =>
75  typeof value === 'string' ? value.replace(/\s+/g, ' ').trim() : ''
76
77export const emptyBoard = (root: string, name: string): Board => ({ v: 1, root, name, runs: [], activeId: null })
78
79export const isBoard = (value: unknown): value is Board =>
80  typeof value === 'object' &&
81  value !== null &&
82  (value as Board).v === 1 &&
83  typeof (value as Board).root === 'string' &&
84  Array.isArray((value as Board).runs)
85
86// ─── Lecture ──────────────────────────────────────────────────────────────
87
88export const activeRun = (board: Board | null | undefined): Run | null =>
89  board?.activeId ? (board.runs.find(r => r.id === board.activeId && r.status === 'active') ?? null) : null
90
91export const currentPhase = (run: Run): FlowPhase => run.phases[Math.min(run.cursor, run.phases.length - 1)]!
92
93export const activeDetour = (phase: FlowPhase): Detour | null => phase.detours.find(d => d.status === 'active') ?? null
94
95/** Ce sur quoi Claude travaille : le détour actif, sinon la phase. */
96export const focusOf = (run: Run): { phase: FlowPhase; detour: Detour | null; skill: SkillDef } => {
97  const phase = currentPhase(run)
98  const detour = activeDetour(phase)
99  return { phase, detour, skill: SKILLS[detour?.skill ?? phase.skill]! }
100}
101
102export const stepRatio = (steps: readonly FlowStep[]): number =>
103  steps.length === 0
104    ? 0
105    : (steps.filter(s => s.status === 'done' || s.status === 'skipped').length + (steps.some(s => s.status === 'active') ? 0.5 : 0)) / steps.length
106
107/** Avancement du parcours, de 0 à 1 : phases faites, plus la part d'étapes de la phase en cours. */
108export const runProgress = (run: Run): number => {
109  if (run.status === 'done') return 1
110  if (run.phases.length === 1) {
111    const only = run.phases[0]!
112    return only.status === 'done' ? 1 : stepRatio(only.steps)
113  }
114  const counted = run.phases.filter(p => p.status !== 'skipped' && !(p.isOptional && p.status === 'pending'))
115  if (counted.length === 0) return 0
116  const sum = counted.reduce(
117    (n, p) => n + (p.status === 'done' ? 1 : p.status === 'active' ? Math.min(0.9, stepRatio(p.steps)) : 0),
118    0,
119  )
120  return sum / counted.length
121}
122
123export const isRunComplete = (run: Run): boolean =>
124  run.phases.every(p => p.status === 'done' || p.status === 'skipped' || (p.isOptional && p.status === 'pending'))
125
126export type Suggestions = {
127  /** La suite logique : la prochaine phase du parcours, sinon la première suite du skill. */
128  primary: string | null
129  others: string[]
130  /** Toutes les phases requises sont faites : le parcours peut se clore. */
131  canFinish: boolean
132}
133
134/**
135 * Sans GitHub (distant ailleurs, ou aucun), la PR par /project:pr n'a pas de sens : le parcours
136 * montré à l'écran perd sa phase « Pull request » à venir. Le parcours stocké, lui, la garde.
137 */
138export const withoutPullRequest = (run: Run): Run => {
139  const phases = run.phases.filter((p, i) => !(i > run.cursor && p.status === 'pending' && p.skill === 'project:pr'))
140  return phases.length === run.phases.length ? run : { ...run, phases }
141}
142
143export const suggestionsFor = (run: Run, opts: { hasPullRequests?: boolean } = {}): Suggestions => {
144  const phase = currentPhase(run)
145  const next = run.phases.slice(run.cursor + 1).find(p => p.status === 'pending')
146  const list: string[] = []
147  const add = (skill: string) => {
148    if (skill in SKILLS && !list.includes(skill)) list.push(skill)
149  }
150  if (next) {
151    add(next.skill)
152    next.alts.forEach(add)
153  }
154  ;(SKILLS[phase.skill]?.next ?? []).forEach(add)
155  // Livrer (ou en rendre compte) avant d'avoir fini les tickets n'a pas de sens : tout attend le dernier.
156  const t = countTickets(run.tickets)
157  // Un groupe encore confié à une session liée n'est pas fini ici, même ses tickets relayés faits (affichage).
158  const isUnfinished = (t.total > t.done || (run.groups ?? []).some(g => g.linked !== undefined)) && SKILLS[phase.skill]?.hasTickets === true
159  // Un groupe fini attend sa revue (/code-review) ; la livraison attend qu'elles soient toutes faites.
160  const isReviewDue = SKILLS[phase.skill]?.hasTickets === true && needsReview(run)
161  // Un compte rendu ouvert attend la validation de la personne : pas de livraison avant.
162  const isReportOpen = phase.skill === 'compte-rendu' && phase.status !== 'done'
163  // Avec des groupes, la revue se lance depuis la ligne du groupe (/code-review ici, ou sa session
164  // liée pour un groupe qu'elle a fait) : la suite proposée reste le lancement des groupes.
165  const isReviewHere = isReviewDue && (run.groups ?? []).length === 0
166  const kept = [
167    ...(isReviewHere ? ['final-review'] : []),
168    ...(isUnfinished || isReviewDue ? list.filter(skill => !AFTER_TICKETS.has(skill)) : list)
169      .filter(skill => skill !== 'final-review')
170      .filter(skill => !(isReportOpen && DELIVERY.has(skill))),
171  ]
172  // Version faite, push pas encore : la suite est /project:push, avant la pull request.
173  if (phase.skill === 'project:version' && phase.status !== 'done' && !isPushed(phase)) {
174    // La pull request attend le push : elle n'est même pas proposée en second.
175    kept.splice(0, kept.length, 'project:push', ...kept.filter(skill => skill !== 'project:push' && skill !== 'project:pr' && skill !== 'pr'))
176  }
177  // La rétro n'est plus une étape du parcours, mais reste à portée de main.
178  if (opts.hasPullRequests === false) kept.splice(0, kept.length, ...kept.filter(skill => skill !== 'project:pr'))
179  const others = kept.slice(1, 4).filter(skill => skill !== 'retro')
180  if (phase.skill !== 'retro' && kept[0] !== 'retro') others.push('retro')
181  const canFinish =
182    phase.status === 'done' && !run.phases.slice(run.cursor + 1).some(p => p.status === 'pending' && !p.isOptional)
183  // Avec des groupes, une revue qui manque n'a pas de skill pour suite : elle se lance sur la ligne du groupe.
184  const primary = isReviewDue && !isUnfinished && (run.groups ?? []).length > 0 ? null : (kept[0] ?? null)
185  return { primary, others, canFinish }
186}
187
188/**
189 * La suite recommandée pendant l'Implémentation d'un parcours à groupes, à la place des skills :
190 * - `parallel` : le lot de groupes à lancer ensemble (sessions liées) ;
191 * - `group` : le prochain groupe, le groupe commencé ici d'abord, sinon le premier prêt sans
192 *   fichier ni ressource en commun avec un groupe en cours ;
193 * - `busy` : un ticket est en cours ici, rien d'autre ne part ;
194 * - `linked` : il ne reste qu'à attendre les groupes confiés à des sessions liées ;
195 * - `review` : tout est fait, il manque la revue de ces groupes (bouton sur leur ligne).
196 * Null hors d'une phase à tickets, sans groupes, ou quand la suite est un skill (compte rendu).
197 */
198export type NextLaunch =
199  | { kind: 'parallel'; groups: string[] }
200  | { kind: 'group'; group: string; isDelegable: boolean }
201  | { kind: 'busy' | 'linked' | 'review'; groups: string[] }
202
203export const nextLaunchFor = (run: Run): NextLaunch | null => {
204  const phase = currentPhase(run)
205  if (SKILLS[phase.skill]?.hasTickets !== true) return null
206  const plan = groupPlan(run)
207  if (!plan) return null
208  if (plan.parallelBatch.length >= 2) return { kind: 'parallel', groups: plan.parallelBatch }
209  const declared = plan.groups.filter(g => !g.isFallback && g.state !== 'done')
210  const doing = declared.filter(g => !g.isDelegated && g.open.some(t => t.status === 'doing'))
211  if (doing.length > 0) return { kind: 'busy', groups: doing.map(g => g.group.id) }
212  const started = declared.find(g => plan.busy.includes(g.group.id) && g.state === 'ready')
213  if (started) return { kind: 'group', group: started.group.id, isDelegable: isDelegable(started) }
214  const held = new Set([...plan.delegated, ...plan.busy])
215  const ready = declared
216    .filter(g => g.state === 'ready' && !g.isDelegated && !g.isCyclic && !g.conflicts.some(c => held.has(c.with)))
217    .sort((a, b) => a.wave - b.wave)[0]
218  if (ready) return { kind: 'group', group: ready.group.id, isDelegable: isDelegable(ready) }
219  if (plan.delegated.length > 0) return { kind: 'linked', groups: plan.delegated }
220  const unreviewed = (run.groups ?? []).filter(g => g.linked === undefined && awaitsReview(run, g))
221  if (unreviewed.length > 0 && run.tickets.every(t => t.status === 'done')) return { kind: 'review', groups: unreviewed.map(g => g.id) }
222  return null
223}
224
225/**
226 * Le skill dont la commande se propose, grisée dans le prompt vide, à la fin d'un tour : celui du
227 * bouton principal de la carte « Ensuite ». Null quand la suite n'est pas une commande (lancer un
228 * groupe, attendre une session liée, revue sur la ligne d'un groupe) ou qu'il n'y a pas de suite.
229 */
230export const nextCommandSkill = (run: Run, opts: { hasPullRequests?: boolean } = {}): string | null =>
231  nextLaunchFor(run) !== null ? null : suggestionsFor(run, opts).primary
232
233const counted = (count: number, one: string, many: string): string => `${count} ${count > 1 ? many : one}`
234
235/** Les mots d'un changement de statut de ticket, au singulier et au pluriel. */
236const SET_WORDS: Record<string, [string, string]> = {
237  done: ['ticket fait', 'tickets faits'],
238  doing: ['ticket en cours', 'tickets en cours'],
239  blocked: ['ticket bloqué', 'tickets bloqués'],
240  todo: ['ticket à faire', 'tickets à faire'],
241}
242
243/**
244 * Ce qu'un appel de l'outil `flow` enregistre, en mots courts, pour sa ligne compacte dans la
245 * conversation : « flow-board · étape review · 2 tickets faits ». Lu sur l'entrée de l'appel.
246 */
247export const flowCallLabel = (input: unknown): string => {
248  const at = (input !== null && typeof input === 'object' ? input : {}) as Record<string, unknown>
249  const list = (key: string): unknown[] => (Array.isArray(at[key]) ? (at[key] as unknown[]) : [])
250  const parts: string[] = []
251  const open = at.open as { title?: unknown } | undefined
252  if (open && typeof open === 'object') parts.push(str(open.title) ? `parcours ouvert « ${clip(str(open.title), 40)} »` : 'parcours ouvert')
253  if (str(at.resume)) parts.push('reprise du parcours')
254  if (str(at.delegate)) parts.push(str(at.group) ? `session liée · groupe ${clip(str(at.group), 12)}` : 'session liée')
255  if (at.fix && typeof at.fix === 'object') parts.push('correction')
256  if (str(at.title) && parts.length === 0 && Object.keys(at).length === 1) parts.push('titre')
257  if (str(at.step)) parts.push(`étape ${clip(str(at.step), 30)}`)
258  if (list('tickets').length > 0) parts.push(counted(list('tickets').length, 'ticket', 'tickets'))
259  const byStatus = new Map<string, number>()
260  for (const change of list('set')) {
261    const status = str((change as { status?: unknown } | null)?.status)
262    if (status in SET_WORDS) byStatus.set(status, (byStatus.get(status) ?? 0) + 1)
263  }
264  for (const [status, words] of Object.entries(SET_WORDS)) {
265    const count = byStatus.get(status)
266    if (count) parts.push(counted(count, words[0], words[1]))
267  }
268  if (list('remove').length > 0) parts.push(counted(list('remove').length, 'ticket retiré', 'tickets retirés'))
269  if (list('groups').length > 0) parts.push(counted(list('groups').length, 'groupe', 'groupes'))
270  if (list('items').length > 0) parts.push(counted(list('items').length, 'élément', 'éléments'))
271  if (list('externals').length > 0) parts.push(counted(list('externals').length, 'tâche externe', 'tâches externes'))
272  if (at.review && typeof at.review === 'object') parts.push('revue')
273  if (at.security && typeof at.security === 'object') parts.push('sécurité')
274  if (list('decided').length > 0) parts.push(counted(list('decided').length, 'décision', 'décisions'))
275  if (list('reviewed').length > 0) parts.push(`relu ${list('reviewed').map(str).filter(Boolean).join(', ')}`)
276  if (list('returned').length > 0) parts.push(`rendu ${list('returned').map(str).filter(Boolean).join(', ')}`)
277  if (str(at.note)) parts.push('note')
278  if (at.complete === true) parts.push('phase terminée')
279  return clip(['flow-board', ...parts].join(' · '), 120)
280}
281
282/** Ce qui vient après les tickets : version, push, pull request, corps de PR. */
283const DELIVERY = new Set(['project:version', 'project:push', 'project:pr', 'pr'])
284
285/** Ce qui attend que les tickets soient faits et relus : la livraison, et le compte rendu qui la précède. */
286const AFTER_TICKETS = new Set([...DELIVERY, 'compte-rendu'])
287
288/**
289 * Remet un parcours créé par une version antérieure à la forme actuelle, sans toucher à ce qui est
290 * fait ou en cours : la rétro à venir disparaît des étapes, et un « Corps de PR » à venir laisse la
291 * place à « Version et push » puis « Pull request » (/project:pr).
292 */
293export const upgradeRun = (run: Run): Run => repairRun(upgradePhases(run))
294
295/**
296 * Répare ce que des versions antérieures ont laissé de faux, sans rien changer d'autre :
297 * - une Implémentation close à la fin d'un groupe alors que le lot a encore des tickets ouverts
298 *   (avant la 0.18.3) redevient en cours, ses étapes prêtes pour le ticket suivant ;
299 * - des compteurs de revue lus dans le texte des réponses (avant la 0.19.0), faux, sont effacés.
300 */
301/** Les étapes de /project:push dans la phase « Version et push ». */
302const PUSH_STEPS = new Set(['push', 'checks', 'docs', 'pushed'])
303
304/** Le push de la phase « Version et push » a eu lieu (ou est en cours). */
305const isPushed = (phase: FlowPhase): boolean =>
306  phase.steps.some(s => s.id === 'pushed' && (s.status === 'done' || s.status === 'active'))
307
308const repairRun = (run: Run): Run => {
309  const at = run.cursor
310  const current = run.phases[at]
311  const isClosedTooSoon =
312    run.status !== 'done' &&
313    current !== undefined &&
314    (current.skill === 'implement' || current.skill === 'implement-spec') &&
315    current.status === 'done' &&
316    run.tickets.some(t => t.status !== 'done')
317  const hasGuessedReview = run.phases.some(p => p.stats.reviews === undefined && p.stats.standards >= 0)
318  const isPushSkipped =
319    run.status !== 'done' &&
320    current !== undefined &&
321    current.skill === 'project:version' &&
322    current.status === 'done' &&
323    current.steps.some(s => s.id === 'tag' && s.status === 'done') &&
324    current.steps.filter(s => PUSH_STEPS.has(s.id)).every(s => s.status === 'skipped')
325  const returnedAt = returnedWithoutTrace(run)
326  if (!isClosedTooSoon && !hasGuessedReview && !isPushSkipped && returnedAt.size === 0) return run
327  const groups = run.groups?.map(g => (returnedAt.has(g.id) ? { ...g, delegated: { at: returnedAt.get(g.id)! } } : g))
328  const phases = run.phases.map((p, i): FlowPhase => {
329    let next = p
330    if (i === at && isPushSkipped) {
331      next = { ...next, status: 'active', finishedAt: undefined, steps: next.steps.map(s => (PUSH_STEPS.has(s.id) ? { id: s.id, status: 'pending' as const } : s)) }
332    }
333    if (i === at && isClosedTooSoon) {
334      next = { ...next, status: 'active', finishedAt: undefined, steps: next.steps.map(s => ({ id: s.id, status: 'pending' as const })) }
335    }
336    if (next.stats.reviews === undefined && next.stats.standards >= 0) next = { ...next, stats: { ...next.stats, standards: -1, spec: -1 } }
337    return next
338  })
339  return { ...run, phases, ...(groups ? { groups } : {}) }
340}
341
342const SENT = /^Groupes? (.+?) confiés? à (?:une session liée|des sessions liées)$/
343const RETURNED = /^Groupes? (.+?) rendus? par (?:sa session liée|leurs sessions liées)$/
344const idsIn = (text: string, pattern: RegExp): string[] => pattern.exec(text)?.[1]!.split(', ') ?? []
345
346/**
347 * Les groupes rendus par leur session liée avant la 0.39.0, qui n'en gardaient pas la trace
348 * (`delegated`) : le journal du parcours le dit (« ↩ Groupe A rendu par sa session liée »). Sans
349 * elle, leur revue se proposait par /code-review ici, sur une branche déjà fusionnée. Par groupe
350 * fini, pas relu et pas confié : quand il a été confié (« ↗ Groupe A confié… »), sinon rendu.
351 */
352const returnedWithoutTrace = (run: Run): Map<string, number> => {
353  const found = new Map<string, number>()
354  const candidates = (run.groups ?? []).filter(
355    g => g.delegated === undefined && g.linked === undefined && awaitsReview(run, g),
356  )
357  if (candidates.length === 0) return found
358  for (const entry of run.log) {
359    for (const id of idsIn(entry.text, RETURNED)) {
360      if (!candidates.some(g => g.id === id) || found.has(id)) continue
361      const sent = run.log.filter(l => l.at <= entry.at && idsIn(l.text, SENT).includes(id)).map(l => l.at)
362      found.set(id, sent.length > 0 ? Math.max(...sent) : entry.at)
363    }
364  }
365  return found
366}
367
368const upgradePhases = (run: Run): Run => {
369  const later = run.phases.slice(run.cursor + 1)
370  const hasDelivery = run.phases.some(p => p.skill === 'project:version' || p.skill === 'project:pr')
371  // La « Revue finale » de la 0.13.0 n'est plus une phase : la revue se fait par groupe.
372  const isDropped = (p: FlowPhase) => p.status === 'pending' && (p.skill === 'retro' || p.skill === 'final-review')
373  // Le compte rendu (0.18.0) précède toute livraison encore à venir.
374  const hasReport = run.phases.some(p => p.skill === 'compte-rendu')
375  const isVersionAhead = (p: FlowPhase) => run.status !== 'done' && p.status === 'pending' && p.skill === 'project:version' && !hasReport
376  const isOld = later.some(p => isDropped(p) || isVersionAhead(p) || (p.status === 'pending' && p.skill === 'pr' && !hasDelivery))
377  if (!isOld) return run
378  const phases: FlowPhase[] = run.phases.slice(0, run.cursor + 1)
379  for (const p of later) {
380    if (isDropped(p)) continue
381    if (p.status === 'pending' && p.skill === 'pr' && !hasDelivery) {
382      if (!hasReport) phases.push(newPhase({ skill: 'compte-rendu' }))
383      phases.push(newPhase({ skill: 'project:version', isOptional: true }), newPhase({ skill: 'project:pr', isOptional: true }))
384      continue
385    }
386    if (isVersionAhead(p)) phases.push(newPhase({ skill: 'compte-rendu' }))
387    phases.push(p)
388  }
389  return { ...run, phases }
390}
391
392const isAllDone = (run: Run, ids: readonly string[]): boolean =>
393  ids.length > 0 && ids.every(id => run.tickets.find(t => sameTicket(t.id, id))?.status === 'done')
394
395/**
396 * Un groupe d'un seul ticket dit ni sensible ni avec logique (une icône, un document) : la revue de
397 * son ticket est sa seule revue. Elle avait été suivie d'une revue du groupe sur le même diff.
398 */
399export const isTicketReviewEnough = (run: Run, group: TicketGroup): boolean => {
400  if (group.tickets.length !== 1) return false
401  const ticket = run.tickets.find(t => sameTicket(t.id, group.tickets[0]!))
402  return ticket?.sensitive === false && ticket.logic === false
403}
404
405/** Un groupe relu : par /code-review, ou par la revue de son seul ticket. */
406export const isGroupReviewed = (run: Run, g: TicketGroup): boolean => g.reviewedAt !== undefined || isTicketReviewEnough(run, g)
407
408/** Un groupe fini attend encore sa revue. */
409const awaitsReview = (run: Run, g: TicketGroup): boolean => !isGroupReviewed(run, g) && isAllDone(run, g.tickets)
410
411/** Un ticket sensible, ou qui ne l'a pas dit : dans le doute, il compte comme sensible (Mathis, 26/10/2026). */
412const isSensitiveTicket = (ticket: Ticket | undefined): boolean => ticket?.sensitive !== false
413
414/** Un groupe dont un ticket touche une zone sensible, ou ne l'a pas dit : sa revue tourne sans --fix. */
415export const isSensitiveGroup = (run: Run, group: TicketGroup): boolean =>
416  group.tickets.some(id => isSensitiveTicket(run.tickets.find(t => sameTicket(t.id, id))))
417
418/** Les arguments de la revue d'un groupe, sans --fix quand elle est sensible : le relecteur applique ce qu'il retient. */
419const reviewArgs = (isSensitive: boolean): string => {
420  const args = SKILLS['final-review']?.args ?? ''
421  return isSensitive ? args.replace(/\s*--fix\b/, '') : args
422}
423
424export const groupReviewArgs = (run: Run, group: TicketGroup): string => reviewArgs(isSensitiveGroup(run, group))
425
426/**
427 * Les arguments d'une commande lancée du tableau de bord : ceux du skill, sauf la revue, sans --fix
428 * quand un groupe qui l'attend est sensible, ou, dans un lot sans groupes, un de ses tickets.
429 */
430export const commandArgsOf = (run: Run | null, skill: string): string => {
431  if (skill !== 'final-review' || run === null) return SKILLS[skill]?.args ?? ''
432  const groups = run.groups ?? []
433  const isSensitive =
434    groups.length === 0
435      ? run.tickets.some(isSensitiveTicket)
436      : groups.some(g => g.linked === undefined && awaitsReview(run, g) && isSensitiveGroup(run, g))
437  return reviewArgs(isSensitive)
438}
439
440/** Un groupe fini n'a pas encore eu sa revue ; sans groupes, le lot entier fini et pas relu. */
441export const needsReview = (run: Run): boolean => {
442  const groups = run.groups ?? []
443  // Un groupe encore confié n'est pas fini ici, même ses tickets relayés faits.
444  if (groups.length > 0) return groups.some(g => g.linked === undefined && awaitsReview(run, g))
445  return run.reviewedAt === undefined && isAllDone(run, run.tickets.map(t => t.id))
446}
447
448/**
449 * `/code-review` pendant un ticket en cours ici (Implémentation), une fois ses contrôles lancés : la
450 * passe « bogues » de ce ticket, partie avec sa revue pour une zone sensible, pas la revue d'un
451 * groupe. Plus tôt dans le ticket (lecture, TDD), c'est la revue d'un groupe fini, lancée depuis sa
452 * ligne. `session` : cette discussion ; un ticket porté par une autre discussion ne compte pas.
453 */
454export const isBugPass = (run: Run, session?: string): boolean => {
455  const phase = currentPhase(run)
456  if (SKILLS[phase.skill]?.hasTickets !== true) return false
457  const at = phase.steps.find(s => s.status === 'active')?.id
458  if (at !== 'checks' && at !== 'review') return false
459  return run.tickets.some(t => t.status === 'doing' && (t.by === undefined || session === undefined || t.by === session))
460}
461
462/**
463 * `/code-review` vient d'être lancé : les groupes finis et pas encore relus le sont, en priorité
464 * ceux de la file lancée dans cette discussion. Rend leurs noms.
465 */
466const markReviewed = (run: Run, now: number): string[] => {
467  const queue = run.queue ?? []
468  const due = (run.groups ?? []).filter(g => awaitsReview(run, g))
469  const fromQueue = due.filter(g => g.tickets.some(id => queue.includes(id)))
470  const targets = fromQueue.length > 0 ? fromQueue : due
471  for (const g of targets) g.reviewedAt = now
472  if (isAllDone(run, run.tickets.map(t => t.id))) run.reviewedAt = now
473  return targets.map(g => `${g.id} (${g.name})`)
474}
475
476/**
477 * Le groupe dont parle Claude quand il donne son avis de sécurité ou en rend le résultat : celui
478 * qu'il nomme, sinon le dernier groupe fini (en priorité ceux de la file de cette discussion).
479 * `null` sans groupes : l'avis porte alors sur le lot entier.
480 */
481const securityGroupOf = (run: Run, id: string): TicketGroup | null => {
482  const groups = run.groups ?? []
483  const named = id ? groups.find(g => g.id.toLowerCase() === id.toLowerCase()) : undefined
484  if (named) return named
485  const queue = run.queue ?? []
486  const done = groups.filter(g => isAllDone(run, g.tickets))
487  const fromQueue = done.filter(g => g.tickets.some(t => queue.includes(t)))
488  const pool = fromQueue.length > 0 ? fromQueue : done
489  return [...pool].sort((x, y) => (y.reviewedAt ?? 0) - (x.reviewedAt ?? 0))[0] ?? null
490}
491
492/**
493 * `/security-review` vient d'être lancé : les groupes finis où il n'a pas encore tourné, en priorité
494 * ceux qui sont relus et ceux de la file de cette discussion. Rend leurs noms.
495 */
496const markSecured = (run: Run, now: number): string[] => {
497  const groups = run.groups ?? []
498  if (groups.length === 0) {
499    run.security = { ...run.security, ranAt: now }
500    return []
501  }
502  const queue = run.queue ?? []
503  const due = groups.filter(g => g.security?.ranAt === undefined && isAllDone(run, g.tickets))
504  const pick = (list: TicketGroup[]) => {
505    const fromQueue = list.filter(g => g.tickets.some(id => queue.includes(id)))
506    return fromQueue.length > 0 ? fromQueue : list
507  }
508  const reviewed = due.filter(g => isGroupReviewed(run, g))
509  const targets = pick(reviewed.length > 0 ? reviewed : due)
510  for (const g of targets) g.security = { ...g.security, ranAt: now }
511  return targets.map(g => `${g.id} (${g.name})`)
512}
513
514/** L'état de la revue de sécurité, pour le compte rendu. */
515const securityText = (check: SecurityCheck | undefined): string => {
516  if (check?.ranAt !== undefined) {
517    const counts = check.findings !== undefined ? ` (${check.findings} findings, ${check.fixed ?? 0} fixed)` : ''
518    return `security review run${counts}`
519  }
520  if (check?.isAdvised === true) return `security review advised but not run${check.why ? ` (${check.why})` : ''}`
521  if (check?.isAdvised === false) return `security review judged not needed${check.why ? ` (${check.why})` : ''}`
522  return ''
523}
524
525export const countTickets = (tickets: readonly Ticket[]) => ({
526  total: tickets.length,
527  done: tickets.filter(t => t.status === 'done').length,
528  doing: tickets.filter(t => t.status === 'doing').length,
529  blocked: tickets.filter(t => t.status === 'blocked').length,
530  todo: tickets.filter(t => t.status === 'todo').length,
531})
532
533/** Bloqué sans raison derrière un ticket encore ouvert : une attente, pas un blocage. */
534export const isWaitingTicket = (run: Run, t: Ticket): boolean =>
535  (t.status === 'todo' || (t.status === 'blocked' && !t.why)) && openBlockers(run, t).length > 0
536
537/** Les tickets vraiment bloqués, par quelque chose hors du lot : ceux qui comptent en ambre. */
538export const blockedCount = (run: Run): number => run.tickets.filter(t => t.status === 'blocked' && !isWaitingTicket(run, t)).length
539
540/** Cycles rouge → vert : chaque passage d'un test en échec à un test réussi. */
541export const tddCycles = (run: Run): number => {
542  let cycles = 0
543  let wasRed = false
544  for (const t of run.tests) {
545    if (t.kind !== 'test') continue
546    if (!t.ok) wasRed = true
547    else if (wasRed) {
548      cycles += 1
549      wasRed = false
550    }
551  }
552  return cycles
553}
554
555// ─── Construction ─────────────────────────────────────────────────────────
556
557const stepsFor = (skill: string, now?: number): FlowStep[] =>
558  (SKILLS[skill]?.steps ?? []).map((s, i) =>
559    now !== undefined && i === 0 ? { id: s.id, status: 'active', at: now } : { id: s.id, status: 'pending' },
560  )
561
562const newPhase = (spec: PhaseSpec, now?: number): FlowPhase => ({
563  skill: spec.skill,
564  alts: spec.alts ?? [],
565  isOptional: spec.isOptional === true,
566  status: now !== undefined ? 'active' : 'pending',
567  steps: stepsFor(spec.skill, now),
568  items: [],
569  round: now !== undefined ? 1 : 0,
570  startedAt: now,
571  detours: [],
572  stats: { questions: 0, rounds: 0, answers: 0, standards: -1, spec: -1 },
573})
574
575export const newRun = (entry: string, now: number, title?: string): Run => {
576  const def = SKILLS[entry]!
577  const specs = def.flow ?? [{ skill: entry }]
578  return {
579    id: `${now.toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`,
580    entry,
581    title: title ? clip(title, 70) : def.flowLabel,
582    isTitled: Boolean(title),
583    status: 'active',
584    phases: specs.map((spec, i) => newPhase(spec, i === 0 ? now : undefined)),
585    cursor: 0,
586    tickets: [],
587    agents: [],
588    artifacts: [],
589    tests: [],
590    log: [],
591    counters: { commits: 0, merges: 0, worktrees: 0, terms: 0, adrs: 0, testFiles: 0, repitches: 0 },
592    touched: [],
593    startedAt: now,
594    updatedAt: now,
595  }
596}
597
598// ─── Mutations internes (sur un clone) ────────────────────────────────────
599
600const log = (run: Run, now: number, glyph: string, text: string, tone: Tone = 'info') => {
601  run.log = [{ at: now, glyph, text: clip(text, TEXT_MAX), tone }, ...run.log].slice(0, LIMITS.log)
602}
603
604/**
605 * Ouvre l'étape `id` ; une étape active d'après redevient à faire. Avant elle, l'étape qui était
606 * en cours passe faite. Les étapes jamais atteintes passent faites quand Claude a signalé l'étape
607 * lui-même (`isReported`), sautées quand le saut est déduit d'un signal (commit, tests, sous-skill).
608 */
609const setStep = (target: { steps: FlowStep[] }, id: string, now: number, isReported = false): boolean => {
610  const at = target.steps.findIndex(s => s.id === id)
611  if (at < 0) return false
612  target.steps = target.steps.map((s, i): FlowStep => {
613    if (i < at) {
614      if (s.status === 'done' || s.status === 'skipped') return s
615      return s.status === 'active' || isReported ? { id: s.id, status: 'done', at: s.at } : { id: s.id, status: 'skipped' }
616    }
617    if (i === at) return { id: s.id, status: 'active', at: s.status === 'active' ? s.at : now }
618    return s.status === 'active' ? { id: s.id, status: 'pending' } : s
619  })
620  return true
621}
622
623/**
624 * Avance vers `id` seulement si l'étape active est avant elle. Une phase finie ne bouge plus sur
625 * un signal déduit (un commit après sa clôture n'y rallume pas « Commit ») : seul Claude la rouvre.
626 */
627const reachStep = (target: { steps: FlowStep[]; status?: string }, id: string, now: number) => {
628  if (target.status === 'done') return
629  const at = target.steps.findIndex(s => s.id === id)
630  if (at < 0 || target.steps[at]!.status !== 'pending') return
631  const active = target.steps.findIndex(s => s.status === 'active')
632  if (active > at) return
633  setStep(target, id, now)
634}
635
636const activeStepId = (target: { steps: FlowStep[] }): string | undefined => target.steps.find(s => s.status === 'active')?.id
637
638/** Clôture : l'étape en cours est faite ; toute étape jamais atteinte, où qu'elle soit, est sautée. */
639const finishSteps = (steps: FlowStep[]): FlowStep[] =>
640  steps.map((step): FlowStep =>
641    step.status === 'active'
642      ? { id: step.id, status: 'done', at: step.at }
643      : step.status === 'pending'
644        ? { id: step.id, status: 'skipped' }
645        : step,
646  )
647
648const finishDetour = (detour: Detour, now: number) => {
649  detour.status = 'done'
650  detour.finishedAt = now
651  detour.steps = finishSteps(detour.steps)
652}
653
654const closeDetours = (phase: FlowPhase, now: number) => {
655  for (const d of phase.detours) if (d.status === 'active') finishDetour(d, now)
656}
657
658const finishPhase = (phase: FlowPhase, now: number) => {
659  phase.status = 'done'
660  phase.finishedAt = now
661  phase.steps = finishSteps(phase.steps)
662  closeDetours(phase, now)
663}
664
665const findPhase = (run: Run, id: string, from: number): number =>
666  run.phases.findIndex((p, i) => i >= from && (p.skill === id || p.alts.includes(id)))
667
668/** Met le parcours de côté : terminé si ses phases requises sont faites, sinon en pause. */
669const shelve = (run: Run, now: number) => {
670  closeDetours(currentPhase(run), now)
671  if (isRunComplete(run)) {
672    run.status = 'done'
673    run.finishedAt = now
674  } else {
675    run.status = 'paused'
676  }
677  run.owner = undefined
678  run.updatedAt = now
679}
680
681const prune = (board: Board) => {
682  // Les archives ne comptent pas : on ne les perd pas parce que d'autres parcours arrivent.
683  const live = board.runs.filter(r => r.archivedAt === undefined)
684  const archived = board.runs
685    .filter(r => r.archivedAt !== undefined)
686    .sort((x, y) => (y.archivedAt ?? 0) - (x.archivedAt ?? 0))
687    .slice(0, LIMITS.archived)
688  if (live.length <= LIMITS.runs && archived.length === board.runs.length - live.length) return
689  const open = live.filter(r => r.status !== 'done')
690  const done = live
691    .filter(r => r.status === 'done')
692    .sort((x, y) => (y.finishedAt ?? y.updatedAt) - (x.finishedAt ?? x.updatedAt))
693  const keep = new Set([...open, ...done].slice(0, LIMITS.runs).map(r => r.id).concat(archived.map(r => r.id)))
694  board.runs = board.runs.filter(r => keep.has(r.id))
695}
696
697/** Le compte rendu pas encore validé qu'un saut jusqu'à la phase `at` laisserait derrière lui. */
698const reportBefore = (run: Run, at: number): FlowPhase | undefined =>
699  run.phases.slice(run.cursor, at).find(p => p.skill === 'compte-rendu' && (p.status === 'pending' || p.status === 'active'))
700
701/** Livrer sans rapport validé : le compte rendu est sauté, et le panneau le dit. */
702const markReportSkipped = (run: Run, report: FlowPhase | undefined, now: number, fx: Effects) => {
703  if (!report) return
704  report.status = 'skipped'
705  log(run, now, '!', 'Compte rendu sauté : livraison sans rapport validé', 'warn')
706  fx.toasts.push('! Compte rendu non validé : livraison sans rapport')
707}
708
709/** Place le curseur sur la phase `at` : les phases sautées le sont, celle qu'on quitte est faite. */
710const moveTo = (run: Run, at: number, id: string, now: number, fx: Effects) => {
711  const def = SKILLS[id]!
712  if (at === run.cursor) {
713    const phase = run.phases[at]!
714    const isAgain = phase.status === 'active' || phase.status === 'done'
715    closeDetours(phase, now)
716    phase.skill = id
717    phase.status = 'active'
718    phase.finishedAt = undefined
719    phase.startedAt = phase.startedAt ?? now
720    phase.round = isAgain ? phase.round + 1 : 1
721    phase.steps = stepsFor(id, now)
722    log(run, now, def.glyph, isAgain ? `${def.label} ${agree(def, 'relancé')} (tour ${phase.round})` : `${def.label} ${agree(def, 'démarré')}`, 'accent')
723    fx.toasts.push(isAgain ? `${def.glyph} ${def.label} · tour ${phase.round}` : `${def.glyph} ${def.label}`)
724  } else {
725    const left = SKILLS[currentPhase(run).skill]
726    for (let i = run.cursor; i < at; i++) {
727      const p = run.phases[i]!
728      if (p.status === 'active') finishPhase(p, now)
729      else if (p.status === 'pending') p.status = 'skipped'
730    }
731    const phase = run.phases[at]!
732    phase.skill = id
733    phase.status = 'active'
734    phase.startedAt = now
735    phase.finishedAt = undefined
736    phase.round = 1
737    phase.steps = stepsFor(id, now)
738    run.cursor = at
739    log(run, now, def.glyph, `${def.label} ${agree(def, 'démarré')}`, 'accent')
740    fx.toasts.push(`${left ? `${left.glyph} ${left.label} → ` : ''}${def.glyph} ${def.label}`)
741  }
742  // Du code s'écrit : le compte rendu qui suit n'est plus facultatif (Triage, Grand chantier, Santé du code).
743  if (id === 'implement' || id === 'implement-spec') {
744    for (const p of run.phases.slice(at + 1)) if (p.skill === 'compte-rendu') p.isOptional = false
745  }
746  run.status = 'active'
747  run.finishedAt = undefined
748  run.updatedAt = now
749}
750
751/** Les phases d'après `at` redeviennent à faire, sans leurs étapes ni leurs dates. */
752const resetAfter = (run: Run, at: number, now: number) => {
753  for (const p of run.phases.slice(at + 1)) {
754    closeDetours(p, now)
755    p.status = 'pending'
756    p.steps = stepsFor(p.skill)
757    p.round = 0
758    p.startedAt = undefined
759    p.finishedAt = undefined
760  }
761}
762
763/**
764 * Revient à la phase sautée du skill `id` quand rien n'a été fait depuis, hors livraison : un
765 * `/project:push` des documents du découpage enjambe l'implémentation, qui commence ensuite.
766 * Les phases d'après redeviennent à faire. Faux quand il n'y a pas de telle phase.
767 */
768const reopenSkipped = (run: Run, id: string, now: number, fx: Effects): boolean => {
769  const at = run.phases.findIndex((p, i) => i < run.cursor && p.status === 'skipped' && (p.skill === id || p.alts.includes(id)))
770  if (at < 0) return false
771  const since = run.phases.slice(at + 1, run.cursor + 1)
772  if (!since.every(p => p.status === 'skipped' || p.status === 'pending' || DELIVERY.has(p.skill))) return false
773  resetAfter(run, at, now)
774  const def = SKILLS[id]!
775  const phase = run.phases[at]!
776  phase.skill = id
777  phase.status = 'active'
778  phase.startedAt = now
779  phase.finishedAt = undefined
780  phase.round = 1
781  phase.steps = stepsFor(id, now)
782  run.cursor = at
783  // Du code s'écrit : le compte rendu qui suit n'est plus facultatif, comme dans moveTo.
784  if (id === 'implement' || id === 'implement-spec') {
785    for (const p of run.phases.slice(at + 1)) if (p.skill === 'compte-rendu') p.isOptional = false
786  }
787  run.status = 'active'
788  run.finishedAt = undefined
789  run.updatedAt = now
790  log(run, now, def.glyph, `${def.label} ${agree(def, 'démarré')} (la livraison passée avant redevient à faire)`, 'accent')
791  fx.toasts.push(`${def.glyph} ${def.label}`)
792  return true
793}
794
795/**
796 * Des tickets partent en implémentation hors de cette discussion (sessions liées) : la phase
797 * Implémentation devient la phase en cours, si le parcours n'y est pas déjà.
798 */
799const enterImplementation = (run: Run, now: number, fx: Effects) => {
800  const at = run.phases.findIndex(p => p.skill === 'implement' || p.skill === 'implement-spec')
801  if (at < 0) return
802  const phase = run.phases[at]!
803  if (at > run.cursor || (at === run.cursor && phase.status === 'pending')) moveTo(run, at, phase.skill, now, fx)
804  else if (at < run.cursor) reopenSkipped(run, phase.skill, now, fx)
805}
806
807const startDetour = (run: Run, phase: FlowPhase, id: string, now: number, fx: Effects) => {
808  const def = SKILLS[id]!
809  closeDetours(phase, now)
810  phase.detours = [
811    ...phase.detours,
812    { skill: id, status: 'active' as const, startedAt: now, steps: stepsFor(id, now), items: [] },
813  ].slice(-LIMITS.detours)
814  log(run, now, '↳', `Détour : ${def.label}`, 'accent')
815  fx.toasts.push(`↳ Détour : ${def.label}`)
816  run.updatedAt = now
817}
818
819// ─── Skills ───────────────────────────────────────────────────────────────
820
821export type SkillContext = {
822  now: number
823  /** Chargé dans la boucle principale (un skill chargé par un sous-agent ne bouge rien). */
824  isMain: boolean
825  /** Arguments de la commande ou de l'outil Skill. */
826  args?: string
827}
828
829/**
830 * Un skill du plugin vient d'être chargé. Dans l'ordre : sous-skill de la phase en cours,
831 * phase suivante du parcours, détour, reprise d'un parcours en pause, nouveau parcours.
832 */
833export const applySkill = (board: Board, name: string, ctx: SkillContext): Outcome => {
834  const raw = skillIdOf(name)
835  // Une session liée qui travaille pour un parcours resté ailleurs n'en ouvre ni n'en reprend aucun.
836  if (!raw || !ctx.isMain || board.delegate) return null
837  // `/code-review` de Claude Code sans parcours actif ici : la relecture d'une branche, pas la revue d'un groupe.
838  const id = raw === 'final-review' && !activeRun(board) ? 'branch-review' : raw
839  const def = SKILLS[id]!
840  const b = clone(board)
841  const fx = noFx()
842  const now = ctx.now
843  const run = activeRun(b)
844
845  if (def.role === 'event') {
846    if (!run) return null
847    if (id === 'final-review') {
848      // La passe « bogues » d'un ticket ne relit aucun groupe : une ligne du journal, rien d'autre.
849      if (isBugPass(run, b.session)) {
850        log(run, now, def.glyph, 'Passe « bogues » du ticket en cours (/code-review)', 'info')
851        run.updatedAt = now
852        return { board: b, fx }
853      }
854      const names = markReviewed(run, now)
855      log(run, now, def.glyph, names.length > 0 ? `Revue du groupe : ${names.join(', ')}` : 'Revue de code (/code-review)', 'accent')
856      run.updatedAt = now
857      return { board: b, fx }
858    }
859    if (id === 'security-review') {
860      const names = markSecured(run, now)
861      log(run, now, def.glyph, names.length > 0 ? `Revue de sécurité : ${names.join(', ')}` : 'Revue de sécurité (/security-review)', 'accent')
862      run.updatedAt = now
863      return { board: b, fx }
864    }
865    if (id === 'wait-what') run.counters.repitches += 1
866    log(run, now, def.glyph, id === 'wait-what' ? 'Reformulation demandée (/wait-what)' : 'Orientation demandée à ask-matt', 'info')
867    run.updatedAt = now
868    return { board: b, fx }
869  }
870
871  if (run) {
872    const phase = currentPhase(run)
873    const detour = activeDetour(phase)
874    const owner = SKILLS[detour?.skill ?? phase.skill]
875    if (owner?.uses && Object.prototype.hasOwnProperty.call(owner.uses, id)) {
876      const stepId = owner.uses[id]
877      if (stepId) setStep(detour ?? phase, stepId, now)
878      log(run, now, '↳', `${def.label} ${agree(def, 'chargé')} par ${owner.label}`, 'info')
879      run.updatedAt = now
880      return { board: b, fx }
881    }
882    if (def.partOf) {
883      const parentAt = findPhase(run, def.partOf.skill, run.cursor)
884      if (parentAt >= 0) {
885        const report = reportBefore(run, parentAt)
886        moveTo(run, parentAt, def.partOf.skill, now, fx)
887        markReportSkipped(run, report, now, fx)
888        const parent = run.phases[parentAt]!
889        // Les étapes d'avant n'ont pas été faites : elles passent sautées, pas cochées.
890        parent.steps = parent.steps.map((s): FlowStep => ({ id: s.id, status: 'pending' }))
891        setStep(parent, def.partOf.step, now)
892        fx.note = 'phase'
893        return { board: b, fx }
894      }
895    }
896    const at = findPhase(run, id, run.cursor)
897    if (at >= 0) {
898      const report = reportBefore(run, at)
899      moveTo(run, at, id, now, fx)
900      markReportSkipped(run, report, now, fx)
901      fx.note = 'phase'
902      return { board: b, fx }
903    }
904    if (reopenSkipped(run, id, now, fx)) {
905      fx.note = 'phase'
906      return { board: b, fx }
907    }
908    if (def.role === 'detour' || (def.detourIn ?? []).includes(phase.skill)) {
909      startDetour(run, phase, id, now, fx)
910      fx.note = 'detour'
911      return { board: b, fx }
912    }
913    if (def.role === 'step') {
914      log(run, now, def.glyph, `/${id}`, 'info')
915      run.updatedAt = now
916      return { board: b, fx }
917    }
918  }
919  // Une commande de livraison n'ouvre jamais de parcours à elle seule.
920  if (def.role === 'step') return null
921
922  // Une suite proposée par la phase en cours la clôt, même quand elle ouvre un autre parcours :
923  // le parcours quitté est alors terminé, plus seulement en pause, s'il ne lui reste rien d'obligatoire.
924  if (run) {
925    const phase = currentPhase(run)
926    if (phase.status === 'active' && (SKILLS[phase.skill]?.next ?? []).includes(id)) {
927      finishPhase(phase, now)
928      log(run, now, '✓', `${SKILLS[phase.skill]!.label} ${agree(SKILLS[phase.skill]!, 'terminé')}`, 'ok')
929    }
930  }
931
932  const title = clip(str(ctx.args), 70) || undefined
933  const paused = b.runs
934    // Un parcours mis de côté ne revient qu'à la demande de la personne : un skill ouvre alors le sien.
935    .filter(r => r.archivedAt === undefined && r.asideAt === undefined)
936    .filter(r => r.status === 'paused' || (r.status === 'active' && r.id !== b.activeId && now - r.updatedAt > ORPHAN_MS))
937    .sort((x, y) => y.updatedAt - x.updatedAt)
938    .find(r => findPhase(r, id, r.cursor) >= 0)
939  if (paused) {
940    if (run) shelve(run, now)
941    paused.status = 'active'
942    paused.owner = b.session
943    b.activeId = paused.id
944    log(paused, now, '↻', 'Parcours repris', 'accent')
945    const at = findPhase(paused, id, paused.cursor)
946    const resumed = paused.phases[at]!
947    if (at === paused.cursor && resumed.status === 'active' && resumed.skill === id) {
948      // Le travail reprend où il en était : ni nouveau tour, ni étapes remises à zéro.
949      paused.updatedAt = now
950    } else {
951      moveTo(paused, at, id, now, fx)
952    }
953    fx.toasts.unshift(`↻ Reprise : ${paused.title}`)
954    fx.openPane = true
955    fx.note = 'phase'
956    return { board: b, fx }
957  }
958
959  if (run) shelve(run, now)
960  const created = newRun(id, now, title)
961  created.owner = b.session
962  b.runs.unshift(created)
963  b.activeId = created.id
964  log(created, now, def.glyph, `Parcours lancé par /${id}`, 'accent')
965  fx.toasts.push(`${def.glyph} ${def.flowLabel} : parcours lancé`)
966  fx.openPane = true
967  fx.note = 'phase'
968  prune(b)
969  return { board: b, fx }
970}
971
972// ─── Rapports de Claude (outil flow) ──────────────────────────────────────
973
974const TICKET_STATUSES: readonly TicketStatus[] = ['todo', 'doing', 'done', 'blocked']
975const ITEM_STATUSES: readonly ItemStatus[] = ['todo', 'doing', 'done', 'dropped']
976
977const asList = (value: unknown): Record<string, unknown>[] =>
978  Array.isArray(value)
979    ? value.filter((one): one is Record<string, unknown> => typeof one === 'object' && one !== null)
980    : []
981
982const parseItems = (value: unknown): FlowItem[] =>
983  asList(value)
984    .map((one, i): FlowItem => {
985      const label = str(one.label) || str(one.name) || str(one.id)
986      const status = str(one.status) as ItemStatus
987      return {
988        id: str(one.id) || `i${i + 1}`,
989        label: clip(label, TEXT_MAX),
990        status: ITEM_STATUSES.includes(status) ? status : 'todo',
991      }
992    })
993    .filter(item => item.label !== '')
994    .slice(0, LIMITS.items)
995
996type IncomingTicket = { id: string; name: string; status?: TicketStatus; blockedBy?: string[]; why?: string; files?: string[]; sensitive?: boolean; logic?: boolean }
997
998/** Une liste de chemins reçue de Claude : chaînes non vides, sans doublon, bornée. */
999const parsePaths = (value: unknown, max: number): string[] | undefined =>
1000  Array.isArray(value) ? [...new Set(value.map(str).filter(Boolean))].slice(0, max) : undefined
1001
1002const parseTickets = (value: unknown): IncomingTicket[] =>
1003  asList(value)
1004    .map(one => {
1005      const status = str(one.status) as TicketStatus
1006      return {
1007        id: str(one.id),
1008        name: clip(str(one.name) || str(one.title) || str(one.id), TEXT_MAX),
1009        status: TICKET_STATUSES.includes(status) ? status : undefined,
1010        blockedBy: Array.isArray(one.blockedBy) ? one.blockedBy.map(str).filter(id => id !== '') : undefined,
1011        why: clip(str(one.why), TEXT_MAX) || undefined,
1012        files: parsePaths(one.files, LIMITS.ticketFiles),
1013        sensitive: typeof one.sensitive === 'boolean' ? one.sensitive : undefined,
1014        logic: typeof one.logic === 'boolean' ? one.logic : undefined,
1015      }
1016    })
1017    .filter(t => t.id !== '' && t.name !== '')
1018    .slice(0, LIMITS.tickets)
1019
1020/** La raison d'un blocage ne survit pas au blocage. */
1021const keepWhy = <T extends { status: TicketStatus; why?: string }>(ticket: T): T => {
1022  if (ticket.status === 'blocked' || ticket.why === undefined) return ticket
1023  const { why: _, ...rest } = ticket
1024  return rest as T
1025}
1026
1027/**
1028 * Le ticket dans son nouvel état. `by` : la discussion qui le passe « en cours », gardée tant
1029 * qu'il l'est, pour montrer qui porte chacun des tickets en cours.
1030 */
1031const withStatus = (ticket: Ticket, status: TicketStatus, now: number, by?: string): Ticket => {
1032  const { by: was, ...rest } = ticket
1033  const owner = status === 'doing' ? (by ?? was) : undefined
1034  return keepWhy({
1035    ...rest,
1036    status,
1037    ...(owner !== undefined ? { by: owner } : {}),
1038    startedAt: ticket.startedAt ?? (status === 'doing' || status === 'done' ? now : undefined),
1039    finishedAt: status === 'done' ? (ticket.finishedAt ?? now) : undefined,
1040  })
1041}
1042
1043/**
1044 * Ajoute ou met à jour les tickets reçus ; ceux qui ne sont pas dans la liste restent. Une
1045 * discussion qui n'implémente que 2 tickets d'un lot de 10 n'efface pas les 8 autres.
1046 */
1047const mergeTickets = (current: readonly Ticket[], incoming: IncomingTicket[], now: number, by?: string): Ticket[] => {
1048  const apply = (base: Ticket, t: IncomingTicket): Ticket => {
1049    const named: Ticket = {
1050      ...base,
1051      name: t.name,
1052      ...(t.blockedBy ? { blockedBy: t.blockedBy } : {}),
1053      ...(t.why ? { why: t.why } : {}),
1054      ...(t.files ? { files: t.files } : {}),
1055      ...(t.sensitive !== undefined ? { sensitive: t.sensitive } : {}),
1056      ...(t.logic !== undefined ? { logic: t.logic } : {}),
1057    }
1058    return t.status && t.status !== named.status ? withStatus(named, t.status, now, by) : keepWhy(named)
1059  }
1060  const updated = current.map(c => {
1061    const t = incoming.find(x => sameTicket(x.id, c.id))
1062    return t ? apply(c, t) : c
1063  })
1064  const added = incoming
1065    .filter(t => !current.some(c => sameTicket(c.id, t.id)))
1066    .map(t => apply({ id: t.id, name: t.name, status: 'todo' }, t))
1067  return [...updated, ...added].slice(0, LIMITS.tickets)
1068}
1069
1070type StatusChange = { id: string; status: TicketStatus; why?: string }
1071
1072const setTickets = (run: Run, changes: StatusChange[], now: number, by?: string): number => {
1073  let moved = 0
1074  run.tickets = run.tickets.map(t => {
1075    const change = changes.find(c => sameTicket(c.id, t.id))
1076    if (!change) return t
1077    if (change.status === t.status) {
1078      // Toujours bloqué, pour une autre raison.
1079      if (change.status !== 'blocked' || change.why === undefined || change.why === t.why) return t
1080      moved += 1
1081      return { ...t, why: change.why }
1082    }
1083    moved += 1
1084    if (change.status === 'done') log(run, now, '✓', `Ticket fait : ${t.name}`, 'ok')
1085    else if (change.status === 'doing') log(run, now, '▸', `Ticket en cours : ${t.name}`, 'accent')
1086    else if (change.status === 'blocked') log(run, now, '!', `Ticket bloqué : ${t.name}${change.why ? ` (${change.why})` : ''}`, 'warn')
1087    return withStatus(change.why ? { ...t, why: change.why } : t, change.status, now, by)
1088  })
1089  return moved
1090}
1091
1092const parseChanges = (value: unknown): StatusChange[] =>
1093  asList(value)
1094    .map(c => ({ id: str(c.id), status: str(c.status) as TicketStatus, why: clip(str(c.why), TEXT_MAX) || undefined }))
1095    .filter(c => c.id !== '' && TICKET_STATUSES.includes(c.status))
1096
1097/**
1098 * Un lot s'implémente ticket par ticket : quand un ticket passe en cours après un autre, les
1099 * étapes repartent de la première (lire le ticket, TDD, vérifications, revue, commit). Un ticket
1100 * qui rejoint d'autres tickets déjà en cours (parallèle) ne remet pas leurs étapes à zéro.
1101 */
1102const restartTicketSteps = (run: Run, before: readonly Ticket[], now: number) => {
1103  const { phase, detour } = focusOf(run)
1104  if (detour || phase.skill !== 'implement' || phase.status !== 'active') return
1105  if (run.tickets.some(t => t.status === 'doing' && before.find(x => x.id === t.id)?.status === 'doing')) return
1106  const started = run.tickets.some(t => t.status === 'doing' && before.find(x => x.id === t.id)?.status !== 'doing')
1107  const hasProgress = phase.steps.some((s, i) => i > 0 && s.status !== 'pending')
1108  if (!started || !hasProgress) return
1109  phase.steps = phase.steps.map((s, i): FlowStep => (i === 0 ? { id: s.id, status: 'active', at: now } : { id: s.id, status: 'pending' }))
1110}
1111
1112/** `#12`, `12` et `012` désignent le même ticket. */
1113const sameTicket = (a: string, b: string): boolean => {
1114  if (a === b) return true
1115  const na = a.replace(/^#/, '').replace(/^0+(?=\d)/, '')
1116  const nb = b.replace(/^#/, '').replace(/^0+(?=\d)/, '')
1117  return na !== '' && na === nb
1118}
1119
1120/** Date UTC à la minute : `2026-10-06 10:40`. */
1121const isoMinute = (ms: number): string => new Date(ms).toISOString().slice(0, 16).replace('T', ' ')
1122
1123/**
1124 * Où en est le compte rendu du parcours, pour que Claude le lise au lieu de le deviner. Une phase
1125 * de compte rendu « faite » sur le parcours actif ne vient que de « ✓ Valider le rapport » : Claude
1126 * ne peut pas la clore, un saut vers la livraison la passe « sautée », et clore le parcours le
1127 * retire de la discussion. Sans phase de compte rendu, rien.
1128 */
1129const reportStateOf = (run: Run): string => {
1130  const reports = run.phases.filter(p => p.skill === 'compte-rendu')
1131  const report = reports.find(p => p.status === 'active') ?? reports.at(-1)
1132  if (!report) return ''
1133  switch (report.status) {
1134    case 'done':
1135      return `rapport : validé${report.finishedAt !== undefined ? ` (${isoMinute(report.finishedAt)} UTC)` : ''}`
1136    case 'active':
1137      return 'rapport : en attente de validation'
1138    case 'skipped':
1139      return 'rapport : non validé (sauté)'
1140    default:
1141      return 'rapport : pas encore rédigé'
1142  }
1143}
1144
1145export const summaryOf = (run: Run): string => {
1146  const { phase, detour, skill } = focusOf(run)
1147  const target = detour ?? phase
1148  const stepId = activeStepId(target)
1149  const stepLabel = skill.steps.find(s => s.id === stepId)?.label
1150  const t = countTickets(run.tickets)
1151  return [
1152    `Dashboard: ${run.title}`,
1153    `${skill.label}${stepLabel ? ` > ${stepLabel}` : ''} (${target.status === 'done' ? 'done' : 'in progress'})`,
1154    t.total > 0 ? `tickets ${t.done}/${t.total} done` : '',
1155    reportStateOf(run),
1156  ]
1157    .filter(Boolean)
1158    .join(' · ')
1159}
1160
1161/**
1162 * Des tickets ajoutés au lot pendant l'exécution d'un groupe, sans que Claude dise où les ranger :
1163 * ils rejoignent ce groupe (avant le premier de ses tickets qu'ils retiennent, sinon à la fin).
1164 * Le groupe en cours est celui d'un ticket « en cours », sinon du premier ticket ouvert de la file ;
1165 * rien ne bouge si ce n'est pas un seul groupe, ni pendant les skills qui publient le lot.
1166 */
1167/**
1168 * Le groupe qui s'exécute : celui d'un ticket « en cours », sinon du premier ticket ouvert de la
1169 * file ; aucun s'il y en a plusieurs ou si le parcours n'a pas de groupes.
1170 */
1171const runningGroupOf = (run: Run, tickets: readonly Ticket[]): TicketGroup | undefined => {
1172  if (!run.groups || run.groups.length === 0) return undefined
1173  const groupOf = (id: string) => run.groups!.find(g => g.tickets.some(x => sameTicket(x, id)))
1174  const doing = tickets.filter(t => t.status === 'doing').map(t => groupOf(t.id)).filter(g => g !== undefined)
1175  const queued = (run.queue ?? []).map(id => tickets.find(t => sameTicket(t.id, id))).find(t => t !== undefined && t.status !== 'done')
1176  const running = [...new Set(doing.length > 0 ? doing : queued ? [groupOf(queued.id)].filter(g => g !== undefined) : [])]
1177  return running.length === 1 ? running[0] : undefined
1178}
1179
1180const placeInRunningGroup = (run: Run, ids: readonly string[], before: readonly Ticket[], now: number) => {
1181  if (!run.groups || run.groups.length === 0 || !isFindingTime(run)) return
1182  const groupOf = (id: string) => run.groups!.find(g => g.tickets.some(x => sameTicket(x, id)))
1183  const loose = ids.filter(id => !groupOf(id))
1184  if (loose.length === 0) return
1185  // Le groupe en cours se juge sur l'état d'avant l'appel : un ticket qui passe « bloqué » dans le même appel compte encore.
1186  const g = runningGroupOf(run, before.filter(t => !loose.includes(t.id)))
1187  if (!g) return
1188  // À la fin d'abord, puis chacun remonte avant le premier ticket qu'il retient : une chaîne de
1189  // tickets ajoutés ensemble (rattrapage, puis recalage, puis recalcul) garde son ordre.
1190  g.tickets = [...g.tickets, ...loose]
1191  for (let pass = 0; pass <= loose.length; pass++) {
1192    let isMoved = false
1193    for (const id of loose) {
1194      const at = g.tickets.indexOf(id)
1195      const held = g.tickets.findIndex(x => (ticketOf(run, x)?.blockedBy ?? []).some(b => sameTicket(b, id)))
1196      if (held >= 0 && held < at) {
1197        g.tickets = [...g.tickets.slice(0, held), id, ...g.tickets.slice(held).filter(x => x !== id)]
1198        isMoved = true
1199      }
1200    }
hooks/view.tsx 2036 lines
1// Dessin du tableau de bord : le panneau à onglets (Parcours, Activité, Historique) et le
2// bandeau au-dessus du prompt. Un seul arbre pour toutes les surfaces : le desktop, VS Code
3// et le mobile reçoivent les SVG animés, le terminal des glyphes et des barres en blocs.
4//
5// Pièges du desktop (relevés dans git-board) : les espaces ordinaires y sont fondus et
6// `gap` ignoré ; les écarts dans un texte sont donc insécables, ceux entre éléments des
7// marges. Aucune prop n'est passée à `undefined` : les arbres restent valides partout.
8
9import type { SyncState, AgentRow, Artifact, ArtifactKind, Board, Detour, FlowItem, FlowPhase, FlowStep, FoundTask, ItemStatus, Live, Relay, Run, SecurityCheck, Tab, Ticket, TicketGroup, TicketStatus, Tone, UiState } from '../types'
10import { ANYTIME_GROUPS, C, CODE_SKILLS, COMMAND_GROUPS, FLOW_COMMANDS, LAUNCHERS, SKILLS, commandLineOf, slashOf, type AnytimeTool, type SkillDef, type StepDef } from './catalog.ts'
11import {
12  relayForGroup,
13  withRelay,
14  type GroupRelay,
15  activeDetour,
16  activeRun,
17  asanaUrl,
18  blockedCount,
19  externalOf,
20  commandText,
21  countTickets,
22  currentPhase,
23  delegatedTitle,
24  doingOf,
25  linkedInFlight,
26  pickableItems,
27  focusOf,
28  groupPlan,
29  resumable,
30  type GroupPlan,
31  type GroupView,
32  hasSpecAndTickets,
33  isPickable,
34  isGroupReviewed,
35  isTicketReviewEnough,
36  groupDone,
37  isWaitingTicket,
38  openBlockers,
39  runProgress,
40  suggestionsFor,
41  nextLaunchFor,
42  type NextLaunch,
43  syncAdvice,
44  tddCycles,
45  unseenDecisions,
46  isDelegable,
47  commandArgsOf,
48  groupReviewArgs,
49} from './model.ts'
50import {
51  barSvg,
52  dotsSvg,
53  miniRoadSvg,
54  pulseSvg,
55  ringSvg,
56  roadmapSvg,
57  spinnerSvg,
58  type BarPart,
59  type NodeState,
60  type RoadNode,
61} from './svg.ts'
62
63const NB = ' '
64const SEP = `${NB}${NB}·${NB}${NB}`
65/** Largeur estimée d'une cellule sur les surfaces distantes ; un SVG trop large est réduit à sa place. */
66const PX_PER_CELL = 7.6
67
68export type Kit = {
69  Box: any
70  Text: any
71  Button: any
72  Svg: any
73  /** Lien externe ; absent d'une table qui n'en a pas. */
74  Link: any
75  /** La surface dessine les SVG (desktop, VS Code, mobile). */
76  isRich: boolean
77  /** Colonnes du corps. */
78  width: number
79}
80
81export const kitOf = (table: Record<string, unknown>, width: number): Kit => ({
82  Box: table.Box,
83  Text: table.Text,
84  Button: table.Button,
85  Svg: table.Svg ?? null,
86  Link: table.Link ?? null,
87  isRich: typeof table.Svg === 'function',
88  width: Math.max(24, Math.floor(width)),
89})
90
91export type View = {
92  board: Board | null
93  live: Live
94  ui: UiState
95  sync: SyncState
96  now: number
97  /** L'avancement relayé par les sessions liées du parcours actif. */
98  relay?: Relay | null
99}
100
101export type Actions = {
102  setTab: (tab: Tab) => unknown
103  /** Lance la commande du skill (la suite du parcours). */
104  launch: (skill: string, args?: string) => unknown
105  /** Met la commande du skill dans le prompt (lanceur : la personne ajoute le sujet). */
106  prepare: (skill: string, args?: string) => unknown
107  /** Met une commande de Claude Code (`compact`) dans le prompt, sans passer par le catalogue. */
108  fillCommand: (command: string) => unknown
109  finish: () => unknown
110  /** Met de côté le parcours actif ici, sans le clore : la discussion peut en ouvrir un autre. */
111  setAside: () => unknown
112  resume: (id: string) => unknown
113  drop: (id: string) => unknown
114  /** Range un parcours aux archives. */
115  archive: (id: string) => unknown
116  /** Sort un parcours des archives. */
117  unarchive: (id: string) => unknown
118  /** Montre ou cache les parcours archivés dans l'historique. */
119  toggleArchived: () => unknown
120  toggleTickets: () => unknown
121  /** Ajoute un ticket à la sélection, ou l'en retire. */
122  pick: (id: string) => unknown
123  clearPicks: () => unknown
124  /** Lance la sélection : /implement dans l'ordre, ou /implement-spec en parallèle. */
125  launchPicks: (isParallel: boolean) => unknown
126  /** Lance un groupe prêt : ses tickets ouverts, dans l'ordre, avec /implement. */
127  launchGroup: (id: string) => unknown
128  /** Lance seuls les tickets déjà prêts d'un groupe qui attend, avec /implement. */
129  launchReady: (id: string) => unknown
130  /** Confie un groupe prêt à une session liée ; le parcours reste dans cette discussion. */
131  launchGroupLinked: (id: string) => unknown
132  /** Annule l'envoi d'un groupe à une session liée qui n'a pas été lancée. */
133  cancelGroupLinked: (id: string) => unknown
134  /** Fait faire par sa session liée la revue d'un groupe rendu sans revue. */
135  reviewLinkedGroup: (id: string) => unknown
136  /** Demande à Claude de fermer la session liée et le worktree d'un groupe rendu. */
137  closeLinkedGroup: (id: string) => unknown
138  /** Demande à Claude la revue de sécurité d'un groupe (ou du lot, `lot`) par un sous-agent relecteur. */
139  securityReview: (id: string) => unknown
140  /** Demande à Claude un diagnostic de bug dans une session liée, en détour de la phase en cours. */
141  launchBugLinked: () => unknown
142  /** Ajoute un élément de la phase (une piste de rétro) à la sélection, ou l'en retire. */
143  pickItem: (id: string) => unknown
144  /** Choisit tous les éléments encore à confier, ou les retire tous s'ils l'étaient déjà. */
145  pickAllItems: () => unknown
146  clearItemPicks: () => unknown
147  /** Confie les éléments choisis à une session liée ; le parcours reste dans cette discussion. */
148  launchItemsLinked: () => unknown
149  /** Annule un envoi à une session liée qui n'a pas été lancée. */
150  cancelLinked: () => unknown
151  /** Demande à Claude de vérifier la session liée et de rendre ses éléments (session arrêtée sans note, ou jamais lancée). */
152  recoverLinked: () => unknown
153  /** Remet dans le prompt la demande de lancement des éléments qu'aucune session n'a pris. */
154  relaunchLinked: () => unknown
155  /** Demande (ou retire) la confirmation de suppression d'un parcours archivé. */
156  askDrop: (id: string) => unknown
157  /** /ask-matt dans le prompt, prérempli avec l'endroit du parcours où l'on est. */
158  askNext: () => unknown
159  /** Lance un lot avec /implement-spec, un sous-agent par groupe (0 : le lot principal, puis les autres lots). */
160  launchParallel: (lot?: number) => unknown
161  /** Confie chaque groupe d'un lot à sa session liée (0 : le lot principal, puis les autres lots). */
162  launchParallelLinked: (lot?: number) => unknown
163  toggleLog: () => unknown
164  /** Déplie ou replie les éléments de la phase au-delà des 14 premiers. */
165  toggleItems: () => unknown
166  /** Déplie ou replie les livrables au-delà des 14 premiers. */
167  toggleArtifacts: () => unknown
168  /** Déplie ou replie les sous-agents terminés au-delà des 3 derniers. */
169  toggleAgents: () => unknown
170  /** La personne a relu les choix faits sans elle. */
171  seeDecisions: () => unknown
172  /** Déplie ou replie les choix déjà relus. */
173  toggleDecided: () => unknown
174  /** Déplie ou replie les tâches trouvées en route qui n'attendent pas la personne. */
175  toggleFound: () => unknown
176  /** Masque toute la liste « Trouvé en route », ou la réaffiche. */
177  toggleHideFound: () => unknown
178  /** La personne valide le compte rendu : la livraison peut venir. */
179  validateReport: () => unknown
180  /** Le compte rendu ne convient pas : le prompt reçoit le début de la réponse à Claude. */
181  redoReport: () => unknown
182  hideBand: () => unknown
183  openPane: () => unknown
184}
185
186// ─── Petits outils ────────────────────────────────────────────────────────
187
188export const dur = (ms: number): string => {
189  const s = Math.max(0, Math.round(ms / 1000))
190  if (s < 60) return `${s}${NB}s`
191  const m = Math.floor(s / 60)
192  if (m < 60) return `${m}${NB}min`
193  const h = Math.floor(m / 60)
194  if (h < 48) return `${h}${NB}h${NB}${String(m % 60).padStart(2, '0')}`
195  return `${Math.floor(h / 24)}${NB}j`
196}
197
198const plural = (n: number, one: string, many = `${one}s`): string => `${n}${NB}${n > 1 ? many : one}`
199const pct = (ratio: number): string => `${Math.round(Math.min(1, Math.max(0, ratio)) * 100)}${NB}%`
200const shortModel = (model: string): string => model.replace(/^claude-/, '').replace(/-\d{8}$/, '')
201const SPIN = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
202const spinFrame = (now: number): string => SPIN[Math.floor(now / 1000) % SPIN.length] ?? '⠋'
203const px = (cells: number): number => Math.max(160, Math.floor(cells * PX_PER_CELL))
204
205type Style = { color?: string; bg?: string; bold?: boolean; dim?: boolean; italic?: boolean; strike?: boolean; wrap?: string }
206
207/** Un Text sans prop indéfinie. */
208const T = (k: Kit, text: string, style: Style = {}): unknown => {
209  const props: Record<string, unknown> = {}
210  if (style.color) props.color = style.color
211  if (style.bg) props.backgroundColor = style.bg
212  if (style.bold) props.bold = true
213  if (style.dim) props.dimColor = true
214  if (style.italic) props.italic = true
215  if (style.strike) props.strikethrough = true
216  if (style.wrap) props.wrap = style.wrap
217  return h(k.Text, props, text)
218}
219
220/** Pastille pleine : texte foncé sur la couleur. */
221const chip = (k: Kit, text: string, color: string): unknown => T(k, `${NB}${text}${NB}`, { bg: color, color: C.ink, bold: true })
222
223const isShown = (item: unknown): boolean => item !== null && item !== undefined && item !== false && item !== ''
224
225/** Une rangée d'éléments séparés par une marge (le desktop ignore `gap`). */
226const line = (k: Kit, items: unknown[], wrap = false): unknown => {
227  const { Box } = k
228  const list = items.filter(isShown)
229  return (
230    <Box flexDirection="row" alignItems="center" flexWrap={wrap ? 'wrap' : 'nowrap'}>
231      {list.map((item, i) => (i < list.length - 1 ? <Box marginRight={1}>{item}</Box> : item))}
232    </Box>
233  )
234}
235
236const svgOf = (k: Kit, drawn: { source: string; width: number; height: number }, alt: string, isAnimated: boolean, isSized = true): unknown => {
237  const props: Record<string, unknown> = { source: drawn.source, alt }
238  if (isSized) {
239    props.width = drawn.width
240    props.height = drawn.height
241  }
242  // Jamais `isInteractive` : ce mode dessine dans un cadre à fond blanc, rechargé (donc
243  // clignotant) à chaque redessin. En image, le fond reste transparent et le dessin stable.
244  void isAnimated
245  return h(k.Svg, props)
246}
247
248/** Barre en blocs du terminal. */
249const blocks = (k: Kit, parts: readonly BarPart[], total: number, cells: number): unknown => {
250  const { Box } = k
251  const width = Math.max(6, cells)
252  const sum = Math.max(1, total, parts.reduce((n, p) => n + p.value, 0))
253  let used = 0
254  const segs: unknown[] = []
255  parts.forEach(part => {
256    if (part.value <= 0) return
257    const n = Math.max(1, Math.round((part.value / sum) * width))
258    const take = Math.min(n, width - used)
259    if (take <= 0) return
260    used += take
261    segs.push(T(k, '█'.repeat(take), { color: part.color }))
262  })
263  if (used < width) segs.push(T(k, '░'.repeat(width - used), { color: C.gray, dim: true }))
264  return <Box flexDirection="row">{segs}</Box>
265}
266
267const TONE: Record<Tone, string> = { info: C.gray, ok: C.green, warn: C.amber, error: C.red, accent: C.tangerine }
268
269const KIND: Record<ArtifactKind, { glyph: string; color: string }> = {
270  glossary: { glyph: '§', color: C.lilac },
271  adr: { glyph: '▲', color: C.violet },
272  spec: { glyph: '▤', color: C.blue },
273  issue: { glyph: '▦', color: C.cyan },
274  map: { glyph: '⚑', color: C.indigo },
275  report: { glyph: '▲', color: C.teal },
276  questionnaire: { glyph: '≡', color: C.lilac },
277  prototype: { glyph: '✦', color: C.magenta },
278  research: { glyph: '✧', color: C.indigo },
279  handoff: { glyph: '➜', color: C.slate },
280  lesson: { glyph: '✎', color: C.amber },
281  config: { glyph: '⊞', color: C.slate },
282  doc: { glyph: '¶', color: C.slate },
283  script: { glyph: '⌘', color: C.sand },
284  pr: { glyph: '⇄', color: C.green },
285  plan: { glyph: '▧', color: C.teal },
286  tracking: { glyph: '▥', color: C.slate },
287}
288
289const TICKET: Record<TicketStatus, { glyph: string; color: string; label: string }> = {
290  todo: { glyph: '○', color: C.gray, label: 'à faire' },
291  doing: { glyph: '◉', color: C.tangerine, label: 'en cours' },
292  done: { glyph: '✓', color: C.green, label: 'fait' },
293  blocked: { glyph: '!', color: C.amber, label: 'bloqué' },
294}
295const TICKET_ORDER: Record<TicketStatus, number> = { doing: 0, blocked: 1, todo: 2, done: 3 }
296
297const ITEM: Record<ItemStatus, { glyph: string; color: string }> = {
298  todo: { glyph: '○', color: C.gray },
299  doing: { glyph: '◉', color: C.tangerine },
300  done: { glyph: '✓', color: C.green },
301  dropped: { glyph: '✗', color: C.gray },
302}
303
304const STATE_WORD: Record<NodeState, string> = {
305  done: 'fait',
306  active: 'en cours',
307  next: 'à lancer',
308  pending: 'à venir',
309  skipped: 'sauté',
310}
311
312// ─── Feuille de route ─────────────────────────────────────────────────────
313
314const phaseSub = (run: Run, index: number): string => {
315  const p = run.phases[index]!
316  if (p.status === 'done') return p.startedAt !== undefined && p.finishedAt !== undefined ? `✓ ${dur(p.finishedAt - p.startedAt)}` : '✓'
317  if (p.status === 'skipped') return 'sautée'
318  if (p.status === 'active') {
319    const def = SKILLS[p.skill]
320    const t = countTickets(run.tickets)
321    if (def?.hasTickets && t.total > 0) return `${t.done}/${t.total} tickets`
322    const at = p.steps.findIndex(s => s.status === 'active')
323    return at >= 0 ? `étape ${at + 1}/${p.steps.length}` : 'en cours'
324  }
325  return p.isOptional ? 'facultative' : ''
326}
327
328/** Les stations : les phases, ou les étapes quand le parcours n'a qu'une phase. */
329const roadNodes = (run: Run): RoadNode[] => {
330  if (run.phases.length === 1) {
331    const only = run.phases[0]!
332    const def = SKILLS[only.skill]!
333    return only.steps.map(s => ({
334      label: def.steps.find(x => x.id === s.id)?.label ?? s.id,
335      color: def.color,
336      state: s.status === 'done' ? 'done' : s.status === 'active' ? 'active' : s.status === 'skipped' ? 'skipped' : 'pending',
337    }))
338  }
339  const nextAt =
340    currentPhase(run).status === 'done' ? run.phases.findIndex((p, i) => i > run.cursor && p.status === 'pending') : -1
341  return run.phases.map((p, i) => {
342    const def = SKILLS[p.skill]!
343    const state: NodeState =
344      p.status === 'done' ? 'done' : p.status === 'active' ? 'active' : p.status === 'skipped' ? 'skipped' : i === nextAt ? 'next' : 'pending'
345    return {
346      label: def.label,
347      sub: phaseSub(run, i),
348      color: def.color,
349      state,
350      isOptional: p.isOptional,
351      detours: p.detours.map(d => ({
352        label: SKILLS[d.skill]?.label ?? d.skill,
353        color: SKILLS[d.skill]?.color ?? C.gray,
354        isActive: d.status === 'active',
355      })),
356    }
357  })
358}
359
360const textRoad = (k: Kit, nodes: readonly RoadNode[], cells: number): unknown => {
361  const { Box } = k
362  const n = nodes.length
363  const slot = Math.max(3, Math.floor((cells - 1) / Math.max(1, n)))
364  const glyph = (x: RoadNode): string =>
365    x.state === 'done' ? '●' : x.state === 'active' ? '◉' : x.state === 'next' ? '◎' : x.state === 'skipped' ? '·' : x.isOptional ? '◌' : '○'
366  const marks: unknown[] = []
367  const labels: unknown[] = []
368  nodes.forEach((x, i) => {
369    const isLit = x.state !== 'pending' && x.state !== 'skipped'
370    marks.push(T(k, glyph(x), { color: isLit ? x.color : C.gray, bold: x.state === 'active' }))
371    if (i < n - 1) {
372      const isDone = x.state === 'done' || x.state === 'skipped'
373      marks.push(T(k, (isDone ? '━' : x.state === 'active' ? '╍' : '┄').repeat(slot - 1), { color: isDone ? x.color : C.gray, dim: !isDone }))
374    }
375    const showLabel = slot >= 6 || x.state === 'active'
376    const text = showLabel ? x.label.slice(0, Math.max(1, slot - 1)) : ''
377    labels.push(
378      T(k, i < n - 1 ? text.padEnd(slot) : text, {
379        color: x.state === 'active' || x.state === 'next' ? x.color : '',
380        bold: x.state === 'active',
381        dim: x.state !== 'active' && x.state !== 'next',
382      }),
383    )
384  })
385  return (
386    <Box flexDirection="column" marginTop={1}>
387      <Box flexDirection="row">{marks}</Box>
388      <Box flexDirection="row">{labels}</Box>
389    </Box>
390  )
391}
392
393const roadmap = (k: Kit, run: Run, cells: number): unknown => {
394  const { Box } = k
395  const nodes = roadNodes(run)
396  if (nodes.length === 0) return null
397  if (!k.isRich) return textRoad(k, nodes, cells)
398  const drawn = roadmapSvg(nodes, px(cells))
399  const alt = nodes.map(x => `${x.label} : ${STATE_WORD[x.state]}`).join(', ')
400  return <Box marginTop={1}>{svgOf(k, drawn, alt, true, false)}</Box>
401}
402
403// ─── État vivant ──────────────────────────────────────────────────────────
404
405const livePill = (k: Kit, run: Run, view: View): unknown => {
406  if (run.status === 'done') return T(k, `✓${NB}Terminé`, { color: C.green, bold: true })
407  if (run.status === 'active' && view.board?.activeId !== run.id) return T(k, `⇆${NB}Dans une autre discussion`, { color: C.blue, bold: true })
408  if (run.status === 'paused') return T(k, run.asideAt !== undefined ? `‖${NB}Mis de côté` : `‖${NB}En pause`, { color: C.gray, bold: true })
409  const running = run.agents.filter(a => a.status === 'running').length
410  const { live, now } = view
411  // Des éléments chez une session liée lancée : c'est elle qui travaille, pas la personne.
412  const phase = currentPhase(run)
413  const isGroupBusy = (run.groups ?? []).some(g => g.linked?.session !== undefined && relayForGroup(view.relay, g)?.endedAt === undefined)
414  const isLinkedBusy = (phase.linked?.session !== undefined && linkedInFlight(phase).length > 0) || isGroupBusy
415  const mode = live.mode === 'waiting' && running > 0 ? 'agents' : isLinkedBusy && live.mode !== 'working' ? 'linked' : live.mode
416  const look =
417    mode === 'working'
418      ? { color: C.tangerine, text: 'Claude travaille', isLive: true }
419      : mode === 'linked'
420        ? { color: C.blue, text: 'Session liée au travail', isLive: true }
421      : mode === 'agents'
422        ? { color: C.blue, text: `${plural(Math.max(1, running), 'agent')} au travail`, isLive: true }
423        : mode === 'waiting'
424          ? { color: C.amber, text: live.ask ? `À toi : ${live.ask}` : 'À toi de jouer', isLive: false }
425          : { color: C.gray, text: 'Prêt', isLive: false }
426  const since = live.since > 0 && mode !== 'idle' ? dur(now - live.since) : ''
427  const dot = k.isRich
428    ? svgOf(k, pulseSvg(look.color, look.isLive), look.text, look.isLive)
429    : T(k, look.isLive ? (Math.floor(now / 1000) % 2 === 0 ? '●' : '○') : '●', { color: look.color })
430  return line(k, [dot, T(k, look.text, { color: look.color, bold: true }), since && T(k, since, { dim: true })])
431}
432
433// ─── En-tête ──────────────────────────────────────────────────────────────
434
435const hero = (k: Kit, run: Run, view: View): unknown => {
436  const { Box } = k
437  const def = SKILLS[run.entry]!
438  const color = def.color
439  const ratio = runProgress(run)
440  const phase = currentPhase(run)
441  const inner = k.width - 6
442  const where =
443    run.phases.length > 1
444      ? `phase${NB}${run.cursor + 1}/${run.phases.length}`
445      : `étape${NB}${Math.max(1, phase.steps.findIndex(s => s.status === 'active') + 1 || phase.steps.length)}/${phase.steps.length}`
446  const meta = [`/${slashOf(run.entry)}`, dur((run.finishedAt ?? view.now) - run.startedAt), where].join(SEP)
447  const ring = k.isRich ? svgOf(k, ringSvg(ratio, color), `Avancement ${pct(ratio)}`, false) : T(k, pct(ratio), { color, bold: true })
448  return (
449    <Box key="hero" flexDirection="column" borderStyle="round" borderColor={color} paddingX={1}>
450      <Box flexDirection="row" justifyContent="space-between" alignItems="center">
451        <Box flexShrink={1}>{chip(k, `${def.glyph}${NB}${def.flowLabel.toUpperCase()}`, color)}</Box>
452        <Box marginLeft={1} flexShrink={0}>
453          {livePill(k, run, view)}
454        </Box>
455      </Box>
456      <Box flexDirection="row" alignItems="center" marginTop={1}>
457        <Box marginRight={2} flexShrink={0}>
458          {ring}
459        </Box>
460        <Box flexDirection="column" flexShrink={1}>
461          {T(k, run.title, { bold: true, wrap: 'truncate-end' })}
462          {T(k, meta, { dim: true, wrap: 'truncate-end' })}
463          {!k.isRich && blocks(k, [{ value: ratio, color }], 1, Math.min(32, inner - 8))}
464        </Box>
465      </Box>
466      {roadmap(k, run, inner)}
467    </Box>
468  )
469}
470
471// ─── Carte de la phase ────────────────────────────────────────────────────
472
473const stepsList = (k: Kit, steps: readonly FlowStep[], def: SkillDef, color: string, now: number): unknown => {
474  const { Box } = k
475  if (steps.length === 0) return null
476  return (
477    <Box flexDirection="column" marginTop={1}>
478      {steps.map(s => {
479        const label = def.steps.find(x => x.id === s.id)?.label ?? s.id
480        const isActive = s.status === 'active'
481        const glyph = s.status === 'done' ? '✓' : isActive ? '◉' : s.status === 'skipped' ? '–' : '○'
482        const mark = s.status === 'done' ? C.green : isActive ? color : C.gray
483        return (
484          <Box flexDirection="row">
485            <Box marginRight={1} flexShrink={0}>
486              {T(k, glyph, { color: mark, bold: isActive })}
487            </Box>
488            <Box flexShrink={1}>{T(k, label, { color: isActive ? color : '', bold: isActive, dim: s.status === 'pending' || s.status === 'skipped', strike: s.status === 'skipped', wrap: 'truncate-end' })}</Box>
489            {isActive && s.at !== undefined && <Box marginLeft={1} flexShrink={0}>{T(k, dur(now - s.at), { dim: true })}</Box>}
490          </Box>
491        )
492      })}
493    </Box>
494  )
495}
496
497/** Les éléments d'une phase qui peut les confier à une session liée (les pistes d'une rétro). */
498type Linkable = { phase: FlowPhase; view: View; act: Actions }
499
500/**
501 * Où en est l'envoi à la session liée : à lancer, ou pas lancée après un tour de Claude (avec
502 * « Redemander » et « Annuler » dans les deux cas), au travail, ou terminée.
503 */
504const linkedRow = (k: Kit, l: Linkable, label: string): unknown => {
505  const { Box, Button } = k
506  const linked = l.phase.linked
507  if (!linked) return null
508  const inFlight = linkedInFlight(l.phase).length
509  const total = linked.items.length
510  const isWaiting = inFlight > 0 && linked.session === undefined
511  const parts =
512    inFlight === 0
513      ? [T(k, `✓${NB}Session${NB}liée${NB}terminée`, { color: C.green, bold: true }), T(k, `${label}${NB}:${NB}${total}/${total}`, { dim: true })]
514      : linked.session !== undefined
515        ? [
516            T(k, `↗${NB}Session${NB}liée${NB}au${NB}travail`, { color: C.blue, bold: true }),
517            // Les éléments ne bougent qu'à la note de fin : un compteur à 0 ferait croire la session bloquée.
518            T(k, `${plural(inFlight, label.replace(/s$/, ''), label)}${NB}en${NB}cours`, { dim: true }),
519            T(k, dur(l.view.now - linked.at), { dim: true }),
520          ]
521        : linked.missedAt !== undefined
522          ? [
523              T(k, `⚠${NB}Aucun${NB}lancement${NB}relevé`, { color: C.amber, bold: true }),
524              T(k, `${plural(inFlight, label.replace(/s$/, ''), label)} en attente : Claude a rendu la main sans lancer de session. S'il te pose une question, réponds-lui ; sinon redemande, ou annule pour les remettre au choix.`, { dim: true, wrap: 'wrap' }),
525            ]
526          : [T(k, `↗${NB}Session${NB}liée${NB}à${NB}lancer`, { color: C.amber, bold: true }), T(k, `${plural(inFlight, label.replace(/s$/, ''), label)} en attente : envoie la demande à Claude, qui lance la session (plus dans le prompt : « Redemander »)`, { dim: true, wrap: 'wrap' })]
527  return (
528    <Box key="linked" flexDirection="row" flexWrap="wrap" alignItems="center">
529      <Box flexShrink={1}>{line(k, parts, true)}</Box>
530      {isWaiting && (
531        <Box marginLeft={1} flexShrink={0}>
532          <Button key="linked-retry" plain={true} dimColor={linked.missedAt === undefined} label={`↻${NB}Redemander`} onPress={() => l.act.relaunchLinked()} />
533        </Box>
534      )}
535      {isWaiting && (
536        <Box marginLeft={1} flexShrink={0}>
537          <Button key="linked-cancel" plain={true} dimColor={true} label="Annuler" onPress={() => l.act.cancelLinked()} />
538        </Box>
539      )}
540      {/* Sans id relevé, la session a pu partir quand même (lancée autrement que prévu) : la reprise la cherche. */}
541      {inFlight > 0 && (
542        <Box marginLeft={1} flexShrink={0}>
543          <Button key="linked-recover" plain={true} dimColor={true} label={`↻${NB}Reprendre${NB}les${NB}${label}`} onPress={() => l.act.recoverLinked()} />
544        </Box>
545      )}
546    </Box>
547  )
548}
549
550const ITEMS_SHOWN = 14
551
552const itemsBlock = (k: Kit, items: readonly FlowItem[], def: SkillDef, color: string, view: View, act: Actions, linkable?: Linkable): unknown => {
553  const { Box, Button } = k
554  if (items.length === 0) return null
555  const kept = items.filter(i => i.status !== 'dropped')
556  const done = items.filter(i => i.status === 'done').length
557  const isAll = view.ui.showAllItems === true
558  const shown = isAll ? items : items.slice(0, ITEMS_SHOWN)
559  const pickable = linkable ? pickableItems(linkable.phase).map(i => i.id) : []
560  // Confiés à une session pas encore lancée : ni à choisir, ni en cours.
561  const waiting = linkable && linkable.phase.linked?.session === undefined ? linkedInFlight(linkable.phase).map(i => i.id) : []
562  const selection = linkable ? (linkable.view.ui.itemSelection ?? []).filter(id => pickable.includes(id)) : []
563  const isAllPicked = pickable.length > 0 && selection.length === pickable.length
564  const label = (def.items?.label ?? 'Éléments').toLowerCase()
565  const one = label.replace(/s$/, '')
566  return (
567    <Box flexDirection="column" marginTop={1}>
568      <Box flexDirection="row" justifyContent="space-between" alignItems="center">
569        <Box flexShrink={1}>
570          {line(k, [T(k, def.items?.label ?? 'Éléments', { bold: true }), T(k, `${done}/${items.length}`, { dim: true }), items.length > kept.length && T(k, `✗${NB}${items.length - kept.length}${NB}écarté${items.length - kept.length > 1 ? 's' : ''}`, { dim: true })])}
571        </Box>
572        {pickable.length > 0 && (
573          <Box marginLeft={1} flexShrink={0}>
574            <Button key="items-all" plain={true} dimColor={true} label={isAllPicked ? 'Tout désélectionner' : 'Tout sélectionner'} onPress={() => linkable!.act.pickAllItems()} />
575          </Box>
576        )}
577      </Box>
578      {linkable && linkedRow(k, linkable, label)}
579      {pickable.length > 0 && selection.length === 0 && T(k, `Choisis les ${label} à traiter (○), puis confie-les à une session liée.`, { dim: true, wrap: 'wrap' })}
580      {shown.map(item => {
581        const look = ITEM[item.status]
582        const isPicked = selection.includes(item.id)
583        const cell = pickable.includes(item.id) ? (
584          <Button key={`item-pick-${item.id}`} plain={true} dimColor={!isPicked} label={isPicked ? '●' : '○'} onPress={() => linkable!.act.pickItem(item.id)} />
585        ) : waiting.includes(item.id) ? (
586          T(k, '↗', { color: C.amber })
587        ) : (
588          T(k, look.glyph, { color: item.status === 'doing' ? color : look.color, bold: item.status === 'doing' })
589        )
590        return (
591          <Box flexDirection="row">
592            <Box marginRight={1} flexShrink={0}>
593              {cell}
594            </Box>
595            <Box flexShrink={1}>
596              {T(k, item.label, {
597                wrap: 'wrap',
598                bold: item.status === 'doing',
599                dim: item.status === 'done' || item.status === 'dropped',
600                strike: item.status === 'dropped',
601              })}
602            </Box>
603          </Box>
604        )
605      })}
606      {items.length > ITEMS_SHOWN && (
607        <Button key="items-toggle" plain={true} dimColor={true} label={isAll ? '▾ réduire' : `▸ tout voir (${items.length})`} onPress={() => act.toggleItems()} />
608      )}
609      {selection.length > 0 && (
610        <Box key="item-picks" flexDirection="row" flexWrap="wrap" alignItems="center" marginTop={1}>
611          <Box marginRight={1}>
612            <Button
613              key="items-linked"
614              variant="primary"
615              label={`↗${NB}Traiter${NB}${selection.length}${NB}${selection.length > 1 ? label : one}${NB}·${NB}session${NB}liée`}
616              onPress={() => linkable!.act.launchItemsLinked()}
617            />
618          </Box>
619          <Button key="items-clear" plain={true} dimColor={true} label="Effacer" onPress={() => linkable!.act.clearItemPicks()} />
620        </Box>
621      )}
622    </Box>
623  )
624}
625
626const ORDINALS = ['①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩', '⑪', '⑫', '⑬', '⑭', '⑮', '⑯', '⑰', '⑱', '⑲', '⑳']
627const ordinal = (i: number): string => ORDINALS[i] ?? `${i + 1}.`
628
629const shortName = (name: string): string => (name.length > 22 ? `${name.slice(0, 21)}…` : name)
630
631/**
632 * Une ligne de ticket : le choix (numéro d'ordre, ou cadenas tant qu'un bloqueur n'est ni fait ni
633 * choisi avant), le titre, l'état, les dépendances et le lien vers Asana.
634 */
635/** Un ticket tel que sa session liée le relaie, tant que le groupe n'est pas rendu. */
636const RELAYED: Record<TicketStatus, { glyph: string; color: string; label: string }> = {
637  todo: TICKET.todo,
638  doing: { glyph: '◉', color: C.tangerine, label: 'en cours ↗' },
639  done: TICKET.done,
640  blocked: { glyph: '!', color: C.amber, label: 'bloqué ↗' },
641}
642
643const ticketRow = (k: Kit, run: Run, t: Ticket, view: View, act: Actions, relayed?: GroupRelay['tickets'][string]): unknown => {
644  const { Box, Button, Link } = k
645  const selection = view.ui.selection ?? []
646  const picked = selection.findIndex(s => s === t.id)
647  const queued = (run.queue ?? []).indexOf(t.id)
648  const blockers = openBlockers(run, t)
649  // Bloqué sans raison mais derrière un ticket ouvert : c'est une attente, pas un blocage.
650  const isWaiting = isWaitingTicket(run, t)
651  // Le statut relayé par la session liée prime à l'affichage, jamais dans le parcours.
652  const look =
653    relayed && t.status !== 'done' && relayed.status !== 'todo'
654      ? RELAYED[relayed.status]
655      : isWaiting
656        ? { glyph: '⏸', color: C.amber, label: 'en attente' }
657        : TICKET[t.status]
658  const nameOf = (id: string) => {
659    const inLot = run.tickets.find(x => x.id === id)
660    if (inLot) return shortName(inLot.name)
661    const ext = externalOf(run, id)
662    return ext ? `${shortName(ext.name)} (${ext.isDecision ? 'décision' : 'externe'})` : `${id} (inconnu)`
663  }
664  // Les décisions encore attendues : mises en avant, avec leur lien.
665  const decisions = blockers.map(id => externalOf(run, id)).filter((x): x is NonNullable<typeof x> => x !== undefined && x.isDecision)
666  const pickCell =
667    t.status === 'done'
668      ? T(k, '✓', { color: C.green })
669      : picked >= 0
670        ? <Button key={`pick-${t.id}`} plain={true} label={ordinal(picked)} onPress={() => act.pick(t.id)} />
671        : isPickable(run, selection, t)
672          ? <Button key={`pick-${t.id}`} plain={true} dimColor={true} label="○" onPress={() => act.pick(t.id)} />
673          : T(k, '⛓', { color: C.amber })
674  const url = asanaUrl(t.id)
675  return (
676    <Box key={`t-${t.id}`} flexDirection="column">
677      <Box flexDirection="row" alignItems="center">
678        <Box marginRight={1} flexShrink={0} width={3}>
679          {pickCell}
680        </Box>
681        {queued >= 0 && t.status !== 'done' && <Box marginRight={1} flexShrink={0}>{T(k, `#${queued + 1}`, { color: C.tangerine, bold: true })}</Box>}
682        {t.id.length <= 6 && <Box marginRight={1} flexShrink={0}>{T(k, t.id, { dim: true })}</Box>}
683        <Box flexShrink={1}>{T(k, t.name, { wrap: 'wrap', dim: t.status === 'done', bold: t.status === 'doing' || picked >= 0 })}</Box>
684        {/* Fait : la coche verte et le titre grisé suffisent. */}
685        {t.status !== 'done' && <Box marginLeft={1} flexShrink={0}>{T(k, `${look.glyph}${NB}${look.label}`, { color: look.color })}</Box>}
686        {t.status === 'doing' && t.by !== undefined && t.by !== view.board?.session && <Box marginLeft={1} flexShrink={0}>{T(k, `⇆${NB}autre discussion`, { color: C.blue })}</Box>}
687        {t.startedAt !== undefined && <Box marginLeft={1} flexShrink={0}>{T(k, dur((t.finishedAt ?? view.now) - t.startedAt), { dim: true })}</Box>}
688        {url !== null && Link && <Box marginLeft={1} flexShrink={0}><Link href={url} label="Asana ↗" /></Box>}
689      </Box>
690      {(t.blockedBy ?? []).length > 0 && t.status !== 'done' && (
691        <Box paddingLeft={4}>
692          {T(k, `⛓${NB}après : ${(t.blockedBy ?? []).map(id => `${nameOf(id)}${blockers.includes(id) ? '' : ' ✓'}`).join(', ')}`, { dim: true, wrap: 'wrap' })}
693        </Box>
694      )}
695      {relayed?.status === 'blocked' && t.status !== 'done' && relayed.why && (
696        <Box paddingLeft={4}>{T(k, `!${NB}attend : ${relayed.why}`, { color: C.amber, wrap: 'wrap' })}</Box>
697      )}
698      {t.status === 'blocked' && !isWaiting && (
699        <Box paddingLeft={4}>
700          {T(k, t.why ? `!${NB}attend : ${t.why}` : `!${NB}raison non précisée`, { color: C.amber, dim: !t.why, wrap: 'wrap' })}
701        </Box>
702      )}
703      {t.status !== 'done' &&
704        decisions.map(d => {
705          const href = asanaUrl(d.id)
706          return (
707            <Box key={`d-${t.id}-${d.id}`} flexDirection="row" paddingLeft={4}>
708              <Box flexShrink={1}>{T(k, `⚖${NB}Décision à prendre : ${d.name}`, { color: C.amber, bold: true, wrap: 'wrap' })}</Box>
709              {href !== null && Link && <Box marginLeft={1} flexShrink={0}><Link href={href} label="Asana ↗" /></Box>}
710            </Box>
711          )
712        })}
713    </Box>
714  )
715}
716
717const ticketParts = (run: Run): BarPart[] => {
718  const t = countTickets(run.tickets)
719  return [
720    { value: t.done, color: C.green },
721    { value: t.doing, color: C.tangerine, isLive: true },
722    // Une attente (derrière un ticket ouvert) reste grise, comme sur sa ligne : seul le vrai blocage est ambre.
723    { value: blockedCount(run), color: C.amber },
724  ]
725}
726
727const GROUP_COLORS = [C.blue, C.teal, C.magenta, C.coral, C.indigo, C.sand]
728
729/**
730 * Où faire un groupe : « ↩ cette discussion » seulement dans la discussion qui a envoyé le plan
731 * (celle dont le contexte lui sert) ; vu d'ailleurs, « ↩ discussion de découpage ».
732 */
733const whereLabel = (g: GroupView, session: string | undefined): string =>
734  !g.group.continuesHere
735    ? `↗${NB}nouvelle discussion`
736    : g.group.continuesIn === undefined || g.group.continuesIn === session
737      ? `↩${NB}cette discussion`
738      : `↩${NB}discussion de découpage`
739
740/** Où en est la session liée d'un groupe : à lancer, au travail, ou terminée avec des tickets ouverts (ambre). */
741const linkLabel = (k: Kit, link: NonNullable<TicketGroup['linked']>, relay: GroupRelay | null, open: number, now: number): unknown =>
742  link.session === undefined
743    ? T(k, `↗${NB}session liée à lancer`, { color: C.blue, bold: true })
744    : relay?.endedAt !== undefined
745      ? open > 0
746        ? T(k, `↗${NB}session terminée il y a ${dur(now - relay.endedAt)}`, { color: C.amber, bold: true })
747        : T(k, `↗${NB}session terminée · à rendre`, { color: C.blue, bold: true })
748      : T(k, `↗${NB}en cours · session liée`, { color: C.blue, bold: true })
749
750/** En-tête d'un groupe : où le faire, où il en est, avec qui il peut avancer, ce qu'il attend. */
751const groupHead = (k: Kit, run: Run, g: GroupView, color: string, view: View, act: Actions): unknown => {
752  const { Button } = k
753  const done = g.tickets.length - g.open.length
754  const nameOf = (id: string) => run.tickets.find(t => t.id === id)?.name ?? externalOf(run, id)?.name ?? id
755  const waits = [...g.waitsOn, ...g.waitsOutside.map(id => shortName(nameOf(id)))]
756  const isLaunchable = g.state === 'ready' && !g.isFallback && !g.isDelegated
757  const link = g.group.linked
758  return line(
759    k,
760    [
761      T(k, `${g.isFallback ? '' : `${g.group.id}${NB}·${NB}`}${g.group.name}`, { bold: true, color }),
762      // Confié à une session liée : c'est là qu'il se fait, pas ici.
763      g.isFallback || g.isDelegated ? null : T(k, whereLabel(g, view.board?.session), { color: g.group.continuesHere ? C.tangerine : C.violet }),
764      T(k, `${done}/${g.tickets.length}`, { dim: true }),
765      g.parallelWith.length > 0 ? T(k, `∥${NB}${g.parallelWith.join(', ')}`, { color: C.cyan, bold: true }) : null,
766      g.conflicts.length > 0 ? T(k, `∦${NB}${g.conflicts.map(c => `${c.with}${NB}:${NB}${c.on}`).join(', ')}`, { color: C.amber }) : null,
767      g.state === 'waiting' && waits.length > 0 ? T(k, `⏸${NB}attend ${waits.join(', ')}`, { color: C.amber }) : null,
768      g.isCyclic ? T(k, `⚠${NB}ordre à revoir`, { color: C.red }) : null,
769      g.isDelegated && link ? linkLabel(k, link, relayForGroup(view.relay, g.group), g.open.length, view.now) : null,
770      g.isDelegated && link?.session === undefined ? (
771        <Button key={`group-linked-cancel-${g.group.id}`} plain={true} dimColor={true} label="Annuler" onPress={() => act.cancelGroupLinked(g.group.id)} />
772      ) : null,
773      isLaunchable ? (
774        <Button key={`launch-group-${g.group.id}`} variant="primary" label={`▶${NB}Lancer ${g.group.id}`} onPress={() => act.launchGroup(g.group.id)} />
775      ) : null,
776      isDelegable(g) ? <Button key={`launch-linked-${g.group.id}`} label={`↗${NB}Session${NB}liée`} onPress={() => act.launchGroupLinked(g.group.id)} /> : null,
777      g.readyNow.length > 0 ? (
778        <Button
779          key={`launch-ready-${g.group.id}`}
780          label={`▶${NB}Lancer ${g.readyNow.length > 1 ? `les ${g.readyNow.length} prêts` : 'le prêt'}`}
781          onPress={() => act.launchReady(g.group.id)}
782        />
783      ) : null,
784    ],
785    true,
786  )
787}
788
789/** Un lot parallèle : en sessions liées (partout), ou par /implement-spec (dépôt git seulement). */
790const lotRow = (k: Kit, ids: readonly string[], at: number, view: View, act: Actions): unknown => {
791  const { Box, Button } = k
792  const suffix = at === 0 ? '' : `-${at}`
793  const lot = ids.join(' ∥ ')
794  return (
795    <Box key={`lot${suffix}`} flexDirection="row" flexWrap="wrap" alignItems="center" marginTop={at === 0 ? 1 : 0}>
796      <Box marginRight={1}>
797        <Button key={`launch-parallel-linked${suffix}`} label={`⇉${NB}${lot} en parallèle · sessions liées`} onPress={() => act.launchParallelLinked(at)} />
798      </Box>
799      {view.sync.isRepo !== false && (
800        <Box marginRight={1}>
801          <Button key={`launch-parallel${suffix}`} label={`⇉${NB}${lot} en parallèle · /implement-spec`} onPress={() => act.launchParallel(at)} />
802        </Box>
803      )}
804      {at === 0 &&
805        T(k, view.sync.isRepo === false ? 'une session liée par groupe · /implement-spec demande un dépôt git' : 'une session liée, ou un sous-agent, par groupe', { dim: true, wrap: 'wrap' })}
806    </Box>
807  )
808}
809
810/** Les tickets rangés par vague puis par groupe, comme Claude les a regroupés. */
811/**
812 * La revue de sécurité d'un groupe fini, ou du lot entier quand le parcours n'a pas de groupes,
813 * facultative : l'avis de Claude une fois la revue faite (il la lance lui-même quand il la juge
814 * utile), puis son résultat. Rien tant que Claude n'a pas donné d'avis (groupes d'avant la 0.23.0
815 * compris). Le bouton la lui demande quand il ne l'a pas lancée. `id` : le groupe, ou « lot ».
816 */
817const securityRow = (k: Kit, sec: SecurityCheck | undefined, id: string, act: Actions): unknown => {
818  const { Box, Button } = k
819  const group = { id }
820  if (!sec) return null
821  if (sec.ranAt !== undefined) {
822    const counts =
823      sec.findings !== undefined
824        ? ` · ${plural(sec.findings, 'constat')}${(sec.fixed ?? 0) > 0 ? ` · ${sec.fixed} corrigé${sec.fixed! > 1 ? 's' : ''}` : ''}`
825        : ''
826    const isOpen = (sec.findings ?? 0) > (sec.fixed ?? 0)
827    return <Box key={`sec-${group.id}`} marginLeft={2}>{T(k, `⛨${NB}sécurité relue${counts}`, { color: isOpen ? C.amber : C.green })}</Box>
828  }
829  if (sec.isAdvised === undefined) return null
830  const launch = (
831    <Button
832      key={`security-group-${group.id}`}
833      label={sec.isAdvised ? `⛨${NB}Revue de sécurité · sous-agent relecteur` : `⛨${NB}La lancer quand même`}
834      onPress={() => act.securityReview(group.id)}
835    />
836  )
837  return (
838    <Box key={`sec-${group.id}`} flexDirection="row" flexWrap="wrap" alignItems="center" marginLeft={2}>
839      {sec.isAdvised && <Box marginRight={1}>{launch}</Box>}
840      <Box marginRight={1} flexShrink={1}>
841        {T(k, `${sec.isAdvised ? 'conseillée' : `⛨${NB}sécurité jugée inutile`}${sec.why ? ` : ${sec.why}` : ''}`, { color: sec.isAdvised ? C.amber : undefined, dim: !sec.isAdvised, wrap: 'wrap' })}
842      </Box>
843      {!sec.isAdvised && launch}
844    </Box>
845  )
846}
847
848const groupsSection = (k: Kit, run: Run, plan: GroupPlan, view: View, act: Actions): unknown => {
849  const { Box, Button } = k
850  const colorOf = (g: GroupView) => (g.isFallback ? C.gray : GROUP_COLORS[plan.groups.indexOf(g) % GROUP_COLORS.length]!)
851  const done = plan.groups.filter(g => g.state === 'done')
852  const groupBox = (g: GroupView) => {
853    const resources = g.group.resources ?? []
854    // Les chemins servent au calcul du parallélisme ; la carte n'en montre que le nombre.
855    const files = g.group.files ?? []
856    const about = [g.group.why, files.length > 0 ? plural(files.length, 'fichier') : '', resources.length > 0 ? `⛁${NB}${resources.join(', ')}` : ''].filter(Boolean).join(' · ')
857    // Un groupe confié montre aussi ses tickets faits dans la session : on y suit son avancement.
858    const rows = view.ui.showAllTickets || g.isDelegated ? g.tickets : g.open
859    const relay = g.isDelegated ? relayForGroup(view.relay, g.group) : null
860    return (
861      <Box key={`g-${g.group.id}`} flexDirection="column" marginTop={1}>
862        {groupHead(k, run, g, colorOf(g), view, act)}
863        {about !== '' && <Box paddingLeft={2}>{T(k, about, { dim: true, wrap: 'wrap' })}</Box>}
864        {rows.map(ticket => ticketRow(k, run, ticket, view, act, relay?.tickets[ticket.id]))}
865      </Box>
866    )
867  }
868  const waves = Array.from({ length: plan.waves }, (_, w) => plan.groups.filter(g => g.wave === w))
869  const isNewHere = plan.groups.some(g => g.state === 'ready' && !g.isFallback && !g.isDelegated)
870  const busyNames = plan.groups.filter(g => plan.busy.includes(g.group.id)).map(g => (g.isFallback ? 'Autres' : g.group.id))
871  return (
872    <Box key="groups" flexDirection="column">
873      {waves.map((groups, w) => (
874        <Box key={`wave-${w}`} flexDirection="column" marginTop={1}>
875          {plan.waves > 1 && T(k, `Vague ${w + 1}${groups.length > 1 && groups.every(g => g.parallelWith.length > 0) ? `${NB}·${NB}en parallèle` : ''}`, { bold: true, dim: true })}
876          {groups.map(groupBox)}
877        </Box>
878      ))}
879      {done.map(g => (
880        <Box key={`g-done-${g.group.id}`} flexDirection="column" marginTop={1}>
881        <Box flexDirection="row" flexWrap="wrap" alignItems="center">
882          <Box marginRight={1} flexShrink={1}>
883            {T(k, `✓${NB}${g.isFallback ? '' : `${g.group.id}${NB}·${NB}`}${g.group.name} : ${plural(g.tickets.length, 'ticket')} ${g.tickets.length > 1 ? 'faits' : 'fait'}`, { color: C.green, dim: true, wrap: 'truncate-end' })}
884          </Box>
885          {!g.isFallback &&
886            (g.group.reviewedAt !== undefined ? (
887              T(k, `✓${NB}revu`, { color: C.green, bold: true })
888            ) : isTicketReviewEnough(run, g.group) ? (
889              // Un seul ticket, ni sensible ni avec logique : la revue du ticket suffit.
890              T(k, `✓${NB}revu par son ticket`, { color: C.green, bold: true })
891            ) : g.group.delegated !== undefined ? (
892              // Fait par une session liée : ici la branche est fusionnée, la revue se fait là-bas.
893              <Button key={`review-linked-${g.group.id}`} variant="primary" label={`◆${NB}Revue du groupe · session liée`} onPress={() => act.reviewLinkedGroup(g.group.id)} />
894            ) : (
895              <Button key={`review-group-${g.group.id}`} variant="primary" label={`◆${NB}Revue du groupe · /${slashOf('final-review')} ${groupReviewArgs(run, g.group)}`} onPress={() => act.launch('final-review', groupReviewArgs(run, g.group))} />
896            ))}
897        </Box>
898        {/* Pas avant sa revue : elle se fait dans la session et son worktree. */}
899        {!g.isFallback && g.group.delegated !== undefined && isGroupReviewed(run, g.group) && (
900          <Box key={`close-row-${g.group.id}`} flexDirection="row" flexWrap="wrap">
901            {g.group.delegated.closeAskedAt === undefined ? (
902              <Button key={`close-linked-${g.group.id}`} plain={true} dimColor={true} label={`✕${NB}Fermer la session et le worktree`} onPress={() => act.closeLinkedGroup(g.group.id)} />
903            ) : (
904              T(k, `✕${NB}fermeture demandée (session et worktree)`, { dim: true })
905            )}
906          </Box>
907        )}
908        {!g.isFallback && securityRow(k, g.group.security, g.group.id, act)}
909        {/* « tout voir » déplie aussi les groupes finis : leurs tickets restent consultables. */}
910        {view.ui.showAllTickets && g.tickets.map(ticket => ticketRow(k, run, ticket, view, act))}
911        </Box>
912      ))}
913      {plan.parallelBatch.length >= 2 && lotRow(k, plan.parallelBatch, 0, view, act)}
914      {plan.otherBatches.length > 0 && (
915        <Box key="other-lots" flexDirection="column" marginTop={plan.parallelBatch.length >= 2 ? 0 : 1}>
916          {T(k, plan.parallelBatch.length >= 2 ? 'Autres lots possibles :' : 'Lots possibles :', { dim: true })}
917          {plan.otherBatches.map((ids, i) => lotRow(k, ids, i + 1, view, act))}
918        </Box>
919      )}
920      {plan.heldBatch.length >= 2 && (
921        <Box key="parallel-held" marginTop={1}>
922          {T(k, `⇉${NB}${plan.heldBatch.join(' ∥ ')} en parallèle une fois ${busyNames.join(', ')} fini${busyNames.length > 1 ? 's' : ''}`, { dim: true, wrap: 'wrap' })}
923        </Box>
924      )}
925      {plan.delegated.length > 0 &&
926        T(k, `↗${NB}${plan.delegated.join(', ')} en session liée : son avancement (tickets d'après Asana, commits, tests) s'affiche sur le groupe, marqué ↗. Claude vérifie le travail à sa note de fin, puis rend les statuts ici.`, { dim: true, wrap: 'wrap' })}
927      {isNewHere &&
928        T(k, `↗${NB}« Session liée » : Claude lance une session qui fait le groupe, et le parcours reste ici. Ou bien une autre discussion : /flow-board:flow, « Reprendre ici », ▶${NB}Lancer.`, { dim: true, wrap: 'wrap' })}
929    </Box>
930  )
931}
932
933/**
934 * La validation du compte rendu, réservée à la personne : le rapport se lit dans la discussion,
935 * la décision se prend ici.
936 */
937const reportGate = (k: Kit, phase: FlowPhase, act: Actions): unknown => {
938  const { Box, Button } = k
939  if (phase.skill !== 'compte-rendu' || phase.status !== 'active') return null
940  const isReady = phase.steps.some(s => s.id === 'validate' && s.status === 'active')
941  return (
942    <Box key="report-gate" flexDirection="column" marginTop={1}>
943      {T(
944        k,
945        isReady
946          ? 'Le rapport est dans la discussion : lis-le, puis valide-le ou dis ce qui ne va pas. Pas de livraison avant.'
947          : 'Claude rédige le rapport. Tu le valideras ici une fois lu ; pas de livraison avant.',
948        { color: isReady ? C.pink : undefined, dim: !isReady, wrap: 'wrap' },
949      )}
950      <Box flexDirection="row" flexWrap="wrap" marginTop={1}>
951        <Box marginRight={1}>
952          <Button key="report-ok" variant="primary" label="✓ Valider le rapport" onPress={() => act.validateReport()} />
953        </Box>
954        <Button key="report-redo" label="✎ À reprendre" onPress={() => act.redoReport()} />
955      </Box>
956    </Box>
957  )
958}
959
960/**
961 * Les choix que Claude a faits sans la personne : ceux à relire, en avant avec leur raison ; les
962 * relus restent pliés en une ligne, dépliables.
963 */
964const decidedBlock = (k: Kit, run: Run, view: View, act: Actions): unknown => {
965  const { Box, Button } = k
966  const all = run.decided ?? []
967  if (all.length === 0) return null
968  const unseen = all.filter(d => d.seenAt === undefined)
969  const seen = all.filter(d => d.seenAt !== undefined)
970  const isOpen = view.ui.showDecided === true
971  const rows = [...unseen].reverse().concat(isOpen ? [...seen].reverse() : [])
972  return (
973    <Box key="decided" flexDirection="column" marginTop={1}>
974      <Box flexDirection="row" alignItems="center" justifyContent="space-between">
975        <Box flexShrink={1}>
976          {line(k, [
977            T(k, `⚑${NB}Décidé sans toi`, { color: unseen.length > 0 ? C.amber : undefined, bold: unseen.length > 0, dim: unseen.length === 0 }),
978            T(k, [unseen.length > 0 ? `${unseen.length}${NB}à relire` : '', seen.length > 0 ? `${seen.length}${NB}relu${seen.length > 1 ? 's' : ''}` : ''].filter(Boolean).join(' · '), { dim: true }),
979          ])}
980        </Box>
981        <Box flexDirection="row" flexShrink={0} marginLeft={1}>
982          {seen.length > 0 && (
983            <Box marginRight={unseen.length > 0 ? 1 : 0}>
984              <Button key="decided-toggle" plain={true} dimColor={true} label={isOpen ? '▾ replier' : '▸ voir'} onPress={() => act.toggleDecided()} />
985            </Box>
986          )}
987          {unseen.length > 0 && <Button key="decided-seen" plain={true} label="✓ Relu" onPress={() => act.seeDecisions()} />}
988        </Box>
989      </Box>
990      {rows.map((d, i) => (
991        <Box key={`d-${d.at}-${i}`} flexDirection="column">
992          <Box flexDirection="row">
993            <Box marginRight={1} flexShrink={0} width={3}>
994              {T(k, d.seenAt === undefined ? '⚑' : '✓', { color: d.seenAt === undefined ? C.amber : C.gray, dim: d.seenAt !== undefined })}
995            </Box>
996            <Box flexShrink={1}>{T(k, d.relayedFrom !== undefined ? `${d.what} (↗${NB}Groupe${NB}${d.relayedFrom})` : d.what, { wrap: 'wrap', dim: d.seenAt !== undefined })}</Box>
997          </Box>
998          {d.why !== undefined && <Box paddingLeft={4}>{T(k, `car : ${d.why}`, { dim: true, wrap: 'wrap' })}</Box>}
999        </Box>
1000      ))}
1001    </Box>
1002  )
1003}
1004
1005/** Ce qui attend la personne d'abord (décision, action à elle), puis le reste ouvert, puis le fait. */
1006const foundRank = (f: FoundTask): number => (f.status === 'done' ? 3 : f.status === 'blocked' || f.isForUser === true ? 0 : f.status === 'doing' ? 1 : 2)
1007
1008/** Lignes montrées sans dépliage : toutes celles qui attendent la personne, et le reste jusqu'à 4. */
1009const FOUND_SHOWN = 4
1010
1011/**
1012 * Les tâches créées en route (constats de revue, anomalies, actions pour la personne) : visibles,
1013 * hors du lot, sans compter dans la progression ni retenir la livraison. L'état est le glyphe de
1014 * tête ; seuls un blocage et « pour toi » se disent en toutes lettres.
1015 */
1016const foundBlock = (k: Kit, run: Run, view: View, act: Actions): unknown => {
1017  const { Box, Button, Link } = k
1018  const found = run.found ?? []
1019  if (found.length === 0) return null
1020  const ordered = [...found].sort((a, b) => foundRank(a) - foundRank(b))
1021  const open = ordered.filter(f => f.status !== 'done')
1022  const done = ordered.filter(f => f.status === 'done')
1023  const urgent = open.filter(f => foundRank(f) === 0).length
1024  const isOpen = view.ui.showFound === true
1025  const isHidden = view.ui.hideFound === true
1026  const rows = isHidden ? [] : isOpen ? ordered : open.slice(0, Math.max(urgent, FOUND_SHOWN))
1027  const hidden = ordered.length - rows.length
1028  const counts = [
1029    open.length > 0 ? `${open.length}${NB}ouverte${open.length > 1 ? 's' : ''}` : '',
1030    done.length > 0 ? `${done.length}${NB}faite${done.length > 1 ? 's' : ''}` : '',
1031    'hors du lot',
1032  ].filter(Boolean)
1033  return (
1034    <Box key="found" flexDirection="column" marginTop={1}>
1035      <Box flexDirection="row" alignItems="center" justifyContent="space-between">
1036        <Box flexShrink={1}>{line(k, [T(k, 'Trouvé en route', { bold: true }), T(k, counts.join(' · '), { dim: true })])}</Box>
1037        <Box flexDirection="row" flexShrink={0} marginLeft={1}>
1038          {!isHidden && (hidden > 0 || isOpen) && (
1039            <Box marginRight={1}>
1040              <Button key="found-toggle" plain={true} dimColor={true} label={isOpen ? '▾ replier' : `▸ ${hidden} de plus`} onPress={() => act.toggleFound()} />
1041            </Box>
1042          )}
1043          {/* Masquer tout, même ce qui attend la personne : le titre et ses comptes restent. */}
1044          <Button key="found-hide" plain={true} dimColor={true} label={isHidden ? '▸ afficher' : '× masquer'} onPress={() => act.toggleHideFound()} />
1045        </Box>
1046      </Box>
1047      {rows.map(f => {
1048        const look = TICKET[f.status]
1049        const href = f.url ?? asanaUrl(f.id)
1050        return (
1051          <Box key={`f-${f.id}`} flexDirection="column">
1052            <Box flexDirection="row">
1053              <Box marginRight={1} flexShrink={0} width={3}>
1054                {T(k, look.glyph, { color: look.color, bold: f.status === 'blocked' })}
1055              </Box>
1056              {/* Les noms Asana sont longs : ils passent à la ligne, sans pousser le lien hors du panneau. */}
1057              <Box flexShrink={1}>{T(k, f.name.replace(/^\[[^\]]{1,12}\]\s*/, ''), { wrap: 'wrap', dim: f.status === 'done' })}</Box>
1058              {f.isForUser === true && <Box marginLeft={1} flexShrink={0}>{T(k, 'pour toi', { color: C.violet })}</Box>}
1059              {href !== null && Link && (
1060                <Box marginLeft={1} flexShrink={0}>
1061                  <Link href={href} label={f.url ? 'Issue ↗' : 'Asana ↗'} />
1062                </Box>
1063              )}
1064            </Box>
1065            {f.status === 'blocked' && (
1066              <Box paddingLeft={4}>{T(k, `!${NB}attend${f.why ? ` : ${f.why}` : ' : raison non précisée'}`, { color: C.amber, wrap: 'wrap' })}</Box>
1067            )}
1068          </Box>
1069        )
1070      })}
1071    </Box>
1072  )
1073}
1074
1075const ticketsBlock = (k: Kit, run: Run, view: View, act: Actions, cells: number): unknown => {
1076  const { Box, Button } = k
1077  const t = countTickets(run.tickets)
1078  const barCells = Math.max(8, Math.min(40, cells - 18))
1079  const bar = k.isRich
1080    ? svgOf(k, barSvg(ticketParts(run), t.total, px(barCells)), `${t.done} tickets faits sur ${t.total}`, t.doing > 0)
1081    : blocks(k, ticketParts(run), t.total, barCells)
1082  // Ordre affiché : la file lancée d'abord, puis le reste dans l'ordre du lot ; les faits à la fin.
1083  const queue = run.queue ?? []
1084  const rank = (x: Ticket) => (x.status === 'done' ? 2 : queue.includes(x.id) ? 0 : 1)
1085  const ordered = [...run.tickets].sort((a, b) => rank(a) - rank(b) || (rank(a) === 0 ? queue.indexOf(a.id) - queue.indexOf(b.id) : 0))
1086  const open = ordered.filter(x => x.status !== 'done')
1087  const shown = view.ui.showAllTickets ? ordered : open.slice(0, 20)
1088  const hidden = run.tickets.length - shown.length
1089  const selection = (view.ui.selection ?? []).filter(id => run.tickets.some(x => x.id === id))
1090  const plan = groupPlan(run)
1091  // /implement-spec : après la spec et les tickets, ou dès que deux groupes prêts peuvent avancer
1092  // ensemble ; jamais hors d'un dépôt git (ses worktrees et sa branche d'intégration en ont besoin).
1093  const canParallel = view.sync.isRepo !== false && (hasSpecAndTickets(run) || (plan?.parallelBatch.length ?? 0) >= 2)
1094  const hint = plan ? 'Lance un groupe (▶), ou choisis les tickets (○) dans l’ordre voulu.' : 'Choisis les tickets à traiter (○), dans l’ordre voulu.'
1095  return (
1096    <Box flexDirection="column" marginTop={1}>
1097      {line(k, [T(k, 'Tickets', { bold: true }), bar, T(k, `${t.done}/${t.total}`, { bold: true }), T(k, pct(t.done / Math.max(1, t.total)), { dim: true })])}
1098      {open.length > 0 && selection.length === 0 && T(k, hint, { dim: true, wrap: 'wrap' })}
1099      {plan ? groupsSection(k, run, plan, view, act) : shown.map(ticket => ticketRow(k, run, ticket, view, act))}
1100      {open.length === 0 && T(k, `✓${NB}Tous les tickets sont faits`, { color: C.green })}
1101      {(plan ? run.tickets.length > open.length : hidden > 0 || view.ui.showAllTickets) && (
1102        <Button
1103          key="tickets-toggle"
1104          plain={true}
1105          dimColor={true}
1106          label={view.ui.showAllTickets ? '▾ réduire' : `▸ tout voir (${run.tickets.length})`}
1107          onPress={() => act.toggleTickets()}
1108        />
1109      )}
1110      {selection.length > 0 && (
1111        <Box key="picks" flexDirection="row" flexWrap="wrap" alignItems="center" marginTop={1}>
1112          <Box marginRight={1}>
1113            <Button key="launch-picks" variant="primary" label={`▶ Implémenter ${plural(selection.length, 'ticket')} · /implement`} onPress={() => act.launchPicks(false)} />
1114          </Box>
1115          {canParallel && (
1116            <Box marginRight={1}>
1117              <Button key="launch-picks-parallel" label="⇉ En parallèle · /implement-spec" onPress={() => act.launchPicks(true)} />
1118            </Box>
1119          )}
1120          <Button key="clear-picks" plain={true} dimColor={true} label="Effacer" onPress={() => act.clearPicks()} />
1121        </Box>
1122      )}
1123    </Box>
1124  )
1125}
1126
1127const testsBlock = (k: Kit, run: Run, cells: number): unknown => {
1128  const { Box } = k
1129  const tests = run.tests.filter(t => t.kind === 'test')
1130  const checks = run.tests.filter(t => t.kind === 'check')
1131  if (tests.length === 0 && checks.length === 0) return null
1132  const green = tests.filter(t => t.ok).length
1133  const red = tests.length - green
1134  const cycles = tddCycles(run)
1135  const last = tests[tests.length - 1]
1136  const dots =
1137    tests.length === 0
1138      ? null
1139      : k.isRich
1140        ? svgOf(k, dotsSvg(tests, px(cells), C.green, C.red), `${green} lancements verts, ${red} rouges`, true)
1141        : (
1142            <k.Box flexDirection="row">
1143              {tests.slice(-Math.max(4, cells - 2)).map(t => T(k, '●', { color: t.ok ? C.green : C.red }))}
1144            </k.Box>
1145          )
1146  return (
1147    <Box flexDirection="column" marginTop={1}>
1148      {line(k, [
1149        T(k, 'Tests', { bold: true }),
1150        last && T(k, last.ok ? `✓${NB}au vert` : `✗${NB}au rouge`, { color: last.ok ? C.green : C.red, bold: true }),
1151        cycles > 0 && T(k, `${plural(cycles, 'cycle')} rouge${NB}→${NB}vert`, { dim: true }),
1152      ])}
1153      {dots}
1154      {line(k, [
1155        tests.length > 0 && T(k, `✓${NB}${green}`, { color: C.green }),
1156        tests.length > 0 && T(k, `✗${NB}${red}`, { color: C.red }),
1157        checks.length > 0 && T(k, `vérifications${NB}${checks.filter(c => c.ok).length}/${checks.length}`, { dim: true }),
1158      ])}
1159    </Box>
1160  )
1161}
1162
1163const statsRow = (k: Kit, run: Run, def: SkillDef): unknown => {
1164  const { Box } = k
1165  const phase = currentPhase(run)
1166  const items: unknown[] = []
1167  if (def.isGrilling || phase.stats.rounds > 0) {
1168    items.push(T(k, `?${NB}${plural(phase.stats.questions, 'question')}`, { color: C.violet, bold: true }))
1169    if (phase.stats.rounds > 0) items.push(T(k, `↻${NB}${plural(phase.stats.rounds, 'tour')}`, { dim: true }))
1170    if (phase.stats.answers > 0) items.push(T(k, `✎${NB}${plural(phase.stats.answers, 'réponse')}`, { dim: true }))
1171  }
1172  if (run.counters.terms > 0) items.push(T(k, `§${NB}${plural(run.counters.terms, 'terme')}`, { color: C.lilac }))
1173  if (run.counters.adrs > 0) items.push(T(k, `▲${NB}${run.counters.adrs}${NB}ADR`, { color: C.violet }))
1174  if (items.length === 0) return null
1175  return <Box marginTop={1}>{line(k, items, true)}</Box>
1176}
1177
1178const waitCallout = (k: Kit, run: Run, view: View): unknown => {
1179  const { Box } = k
1180  const phase = currentPhase(run)
1181  if (view.live.mode !== 'waiting' || !view.live.ask || phase.stats.rounds <= phase.stats.answers) return null
1182  return (
1183    <Box marginTop={1} borderStyle="round" borderColor={C.amber} paddingX={1}>
1184      {T(k, `◐${NB}À toi : réponds à ${view.live.ask} pour continuer.`, { color: C.amber, wrap: 'wrap' })}
1185    </Box>
1186  )
1187}
1188
1189/** Phase qui modifie le code : le distant a-t-il avancé depuis le dernier sync ? */
1190const syncCallout = (k: Kit, run: Run, view: View, act: Actions): unknown => {
1191  const { Box, Button } = k
1192  const phase = currentPhase(run)
1193  if (!CODE_SKILLS.has(phase.skill) || phase.status !== 'active' || view.sync.checkedAt === null) return null
1194  const advice = syncAdvice(view.sync)
1195  if (!advice) {
1196    const ref = view.sync.main ? `origin/${view.sync.main}` : 'le distant'
1197    return <Box key="sync" marginTop={1}>{T(k, `✓${NB}À jour avec ${ref}${SEP}vérifié il y a ${dur(view.now - view.sync.checkedAt)}`, { dim: true })}</Box>
1198  }
1199  const first = advice.commands[0]!
1200  return (
hooks/svg.ts 299 lines
1// Dessins SVG des surfaces distantes (desktop, VS Code, mobile) : feuille de route,
2// anneau de progression, barre segmentée, points de tests, indicateurs vivants.
3//
4// - Les animations sont en SMIL (`isInteractive` sur l'élément Svg) : elles tournent
5//   sans redessin. Le texte qui change chaque seconde (durées) reste hors des SVG, pour
6//   qu'un SVG identique ne soit pas rechargé à chaque tic.
7// - Couleurs mi-tons et gris neutre : lisibles sur fond clair comme sombre.
8
9const FONT = `font-family="'Segoe UI', system-ui, -apple-system, sans-serif"`
10const MUTED = '#8B8B93'
11const WHITE = '#FFFFFF'
12
13export const esc = (text: string): string =>
14  text.replace(/[<>&"']/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;' })[c] ?? c)
15
16const cut = (text: string, max: number): string =>
17  max <= 1 ? '' : text.length <= max ? text : `${text.slice(0, Math.max(1, max - 1))}…`
18
19const svg = (width: number, height: number, body: string): string =>
20  `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${body}</svg>`
21
22/** Coupe un libellé en deux lignes au plus, aux espaces ; la seconde se termine par « … » s'il en reste. */
23const wrap2 = (text: string, max: number): string[] => {
24  if (text.length <= max) return [text]
25  const words = text.split(/\s+/)
26  let first = ''
27  let i = 0
28  while (i < words.length) {
29    const next = first ? `${first} ${words[i]}` : words[i]!
30    if (next.length > max && first) break
31    first = next
32    i += 1
33  }
34  const rest = words.slice(i).join(' ')
35  return rest ? [cut(first, max), cut(rest, max)] : [cut(first, max)]
36}
37
38/** Largeur moyenne d'un caractère à 11,5 px (Segoe UI, system-ui). */
39const CHAR_PX = 6.2
40
41const r1 = (n: number): number => Math.round(n * 10) / 10
42
43export type NodeState = 'done' | 'active' | 'next' | 'pending' | 'skipped'
44
45export type RoadNode = {
46  label: string
47  sub?: string
48  color: string
49  state: NodeState
50  isOptional?: boolean
51  detours?: { label: string; color: string; isActive: boolean }[]
52}
53
54export type Drawn = { source: string; width: number; height: number }
55
56const check = (x: number, y: number, size: number, color: string): string =>
57  `<path d="M${r1(x - size * 0.45)} ${r1(y + size * 0.02)} l${r1(size * 0.32)} ${r1(size * 0.32)} l${r1(size * 0.6)} ${r1(-size * 0.62)}" fill="none" stroke="${color}" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>`
58
59/**
60 * La feuille de route : une station par phase (ou par étape pour un parcours d'une
61 * phase), reliées par une ligne. Station faite pleine et cochée, station en cours
62 * pulsante, segment en cours animé vers la suite, suites facultatives en pointillé,
63 * détours en bulles au-dessus de leur station.
64 */
65export const roadmapSvg = (nodes: readonly RoadNode[], width: number): Drawn => {
66  const n = nodes.length
67  const w = Math.max(200, Math.round(width))
68  const hasDetours = nodes.some(x => (x.detours?.length ?? 0) > 0)
69  const top = hasDetours ? 34 : 8
70  const cy = top + 14
71  const pad = n > 1 ? Math.max(34, Math.min(64, w / (n * 2))) : w / 2
72  const xs = nodes.map((_, i) => (n > 1 ? pad + (i * (w - 2 * pad)) / (n - 1) : w / 2))
73  const slot = n > 1 ? (w - 2 * pad) / (n - 1) : w
74  const charsAt = (i: number): number => {
75    const isEdge = n > 1 && (i === 0 || i === n - 1)
76    const room = n > 1 ? (isEdge ? Math.min(slot, pad + slot / 2) : slot) : Math.min(w, 260)
77    return Math.max(3, Math.floor((room - 6) / CHAR_PX))
78  }
79  const labels = nodes.map((node, i) => wrap2(node.label, charsAt(i)))
80  const isTall = labels.some(l => l.length > 1)
81  const subY = cy + (isTall ? 58 : 45)
82  const height = cy + (isTall ? 64 : 50)
83  const defs: string[] = []
84  const lines: string[] = []
85  const marks: string[] = []
86  const texts: string[] = []
87  const bubbles: string[] = []
88
89  for (let i = 0; i < n - 1; i++) {
90    const a = nodes[i]!
91    const b = nodes[i + 1]!
92    const x1 = r1(xs[i]! + 15)
93    const x2 = r1(xs[i + 1]! - 15)
94    if (x2 <= x1) continue
95    const isDone = (a.state === 'done' || a.state === 'skipped') && (b.state === 'done' || b.state === 'active' || b.state === 'skipped')
96    if (isDone) {
97      defs.push(
98        `<linearGradient id="g${i}" gradientUnits="userSpaceOnUse" x1="${x1}" y1="0" x2="${x2}" y2="0"><stop offset="0" stop-color="${a.color}"/><stop offset="1" stop-color="${b.color}"/></linearGradient>`,
99      )
100      lines.push(`<line x1="${x1}" y1="${cy}" x2="${x2}" y2="${cy}" stroke="url(#g${i})" stroke-width="3" stroke-linecap="round"/>`)
101    } else if (a.state === 'active' || (a.state === 'done' && b.state === 'next')) {
102      lines.push(
103        `<line x1="${x1}" y1="${cy}" x2="${x2}" y2="${cy}" stroke="${a.color}" stroke-opacity="0.8" stroke-width="2.5" stroke-linecap="round" stroke-dasharray="6 7"><animate attributeName="stroke-dashoffset" from="13" to="0" dur="0.9s" repeatCount="indefinite"/></line>`,
104      )
105    } else {
106      lines.push(
107        `<line x1="${x1}" y1="${cy}" x2="${x2}" y2="${cy}" stroke="${MUTED}" stroke-opacity="0.5" stroke-width="2" stroke-linecap="round" stroke-dasharray="2 6"/>`,
108      )
109    }
110  }
111
112  nodes.forEach((node, i) => {
113    const x = r1(xs[i]!)
114    const c = node.color
115    switch (node.state) {
116      case 'done':
117        marks.push(`<circle cx="${x}" cy="${cy}" r="11" fill="${c}"/>${check(x, cy, 10, WHITE)}`)
118        break
119      case 'active':
120        marks.push(
121          `<circle cx="${x}" cy="${cy}" r="12" fill="${c}" opacity="0.4"><animate attributeName="r" values="12;21" dur="1.8s" repeatCount="indefinite"/><animate attributeName="opacity" values="0.45;0" dur="1.8s" repeatCount="indefinite"/></circle>` +
122            `<circle cx="${x}" cy="${cy}" r="12" fill="${c}"/><circle cx="${x}" cy="${cy}" r="4.5" fill="${WHITE}"/>`,
123        )
124        break
125      case 'next':
126        marks.push(
127          `<circle cx="${x}" cy="${cy}" r="10.5" fill="${c}" fill-opacity="0.16" stroke="${c}" stroke-width="2.4"><animate attributeName="stroke-opacity" values="1;0.35;1" dur="2.2s" repeatCount="indefinite"/></circle>` +
128            `<path d="M${r1(x - 3)} ${r1(cy - 4.5)} l7 4.5 l-7 4.5 z" fill="${c}"/>`,
129        )
130        break
131      case 'skipped':
132        marks.push(
133          `<circle cx="${x}" cy="${cy}" r="8" fill="none" stroke="${MUTED}" stroke-opacity="0.65" stroke-width="1.6" stroke-dasharray="2 3"/>` +
134            `<line x1="${x - 4}" y1="${cy + 4}" x2="${x + 4}" y2="${cy - 4}" stroke="${MUTED}" stroke-opacity="0.65" stroke-width="1.6" stroke-linecap="round"/>`,
135        )
136        break
137      default:
138        marks.push(
139          `<circle cx="${x}" cy="${cy}" r="9.5" fill="${c}" fill-opacity="0.08" stroke="${c}" stroke-opacity="${node.isOptional ? 0.5 : 0.7}" stroke-width="1.8"${node.isOptional ? ' stroke-dasharray="3 3"' : ''}/>` +
140            `<text x="${x}" y="${r1(cy + 3.5)}" text-anchor="middle" ${FONT} font-size="10" font-weight="700" fill="${c}" fill-opacity="0.85">${i + 1}</text>`,
141        )
142    }
143
144    // Libellés : centrés, sauf aux bords où ils s'alignent sur la station pour rester entiers.
145    const isEdgeLeft = n > 1 && i === 0
146    const isEdgeRight = n > 1 && i === n - 1
147    const chars = charsAt(i)
148    const anchor = isEdgeLeft && pad < 46 ? 'start' : isEdgeRight && pad < 46 ? 'end' : 'middle'
149    const tx = anchor === 'start' ? r1(x - 12) : anchor === 'end' ? r1(x + 12) : x
150    const isLit = node.state === 'active' || node.state === 'done' || node.state === 'next'
151    const weight = node.state === 'active' ? 700 : isLit ? 600 : 500
152    const lines = labels[i]!
153    texts.push(
154      `<text x="${tx}" y="${cy + 30}" text-anchor="${anchor}" ${FONT} font-size="11.5" font-weight="${weight}" fill="${isLit ? c : MUTED}"${node.state === 'skipped' ? ' fill-opacity="0.7"' : ''}>` +
155        lines.map((l, j) => `<tspan x="${tx}" dy="${j === 0 ? 0 : 13}">${esc(l)}</tspan>`).join('') +
156        '</text>',
157    )
158    if (node.sub) {
159      texts.push(
160        `<text x="${tx}" y="${subY}" text-anchor="${anchor}" ${FONT} font-size="10" font-weight="${node.state === 'active' ? 600 : 400}" fill="${node.state === 'active' ? c : MUTED}">${esc(cut(node.sub, chars + 2))}</text>`,
161      )
162    }
163
164    // Détours : la dernière bulle au-dessus de la station, avec le nombre des autres.
165    const detours = node.detours ?? []
166    if (detours.length > 0) {
167      const last = detours[detours.length - 1]!
168      const label = `↳ ${last.label}${detours.length > 1 ? ` +${detours.length - 1}` : ''}`
169      const shown = cut(label, Math.max(6, Math.floor((slot - 10) / 6)))
170      const bw = Math.round(shown.length * 6.1 + 16)
171      const bx = Math.min(Math.max(2, x - bw / 2), w - bw - 2)
172      const by = 3
173      bubbles.push(
174        `<line x1="${x}" y1="${by + 18}" x2="${x}" y2="${cy - 13}" stroke="${last.color}" stroke-opacity="0.55" stroke-width="1.4" stroke-dasharray="2 2"/>` +
175          `<rect x="${r1(bx)}" y="${by}" width="${bw}" height="18" rx="9" fill="${last.color}" fill-opacity="0.14" stroke="${last.color}" stroke-opacity="0.75" stroke-width="1.2">${last.isActive ? '<animate attributeName="stroke-opacity" values="0.9;0.3;0.9" dur="1.6s" repeatCount="indefinite"/>' : ''}</rect>` +
176          `<text x="${r1(bx + bw / 2)}" y="${by + 12.5}" text-anchor="middle" ${FONT} font-size="10" font-weight="600" fill="${last.color}">${esc(shown)}</text>`,
177      )
178    }
179  })
180
181  const body = `${defs.length > 0 ? `<defs>${defs.join('')}</defs>` : ''}${lines.join('')}${bubbles.join('')}${marks.join('')}${texts.join('')}`
182  return { source: svg(w, height, body), width: w, height }
183}
184
185/** Anneau de progression, le pourcentage au centre. */
186export const ringSvg = (ratio: number, color: string, size = 60): Drawn => {
187  const r = size / 2 - 6
188  const c = 2 * Math.PI * r
189  const value = Math.min(1, Math.max(0, ratio))
190  const len = r1(c * value)
191  const mid = size / 2
192  const body =
193    `<circle cx="${mid}" cy="${mid}" r="${r1(r)}" fill="none" stroke="${MUTED}" stroke-opacity="0.22" stroke-width="7"/>` +
194    (value > 0
195      ? `<circle cx="${mid}" cy="${mid}" r="${r1(r)}" fill="none" stroke="${color}" stroke-width="7" stroke-linecap="round" stroke-dasharray="${len} ${r1(c)}" transform="rotate(-90 ${mid} ${mid})"/>`
196      : '') +
197    `<text x="${mid}" y="${mid + 5}" text-anchor="middle" ${FONT} font-size="14" font-weight="700" fill="${color}">${Math.round(value * 100)}%</text>`
198  return { source: svg(size, size, body), width: size, height: size }
199}
200
201export type BarPart = { value: number; color: string; isLive?: boolean }
202
203/** Barre segmentée arrondie (fait, en cours, bloqué…), la part vivante qui respire. */
204export const barSvg = (parts: readonly BarPart[], total: number, width: number, height = 9): Drawn => {
205  const w = Math.max(60, Math.round(width))
206  const sum = Math.max(total, parts.reduce((n, p) => n + p.value, 0), 1)
207  let x = 0
208  const segs: string[] = []
209  for (const part of parts) {
210    if (part.value <= 0) continue
211    const sw = (part.value / sum) * w
212    segs.push(
213      `<rect x="${r1(x)}" y="0" width="${r1(sw)}" height="${height}" fill="${part.color}">${part.isLive ? '<animate attributeName="opacity" values="1;0.55;1" dur="1.6s" repeatCount="indefinite"/>' : ''}</rect>`,
214    )
215    x += sw
216  }
217  const body =
218    `<defs><clipPath id="bar"><rect width="${w}" height="${height}" rx="${height / 2}"/></clipPath></defs>` +
219    `<g clip-path="url(#bar)"><rect width="${w}" height="${height}" fill="${MUTED}" fill-opacity="0.22"/>${segs.join('')}</g>`
220  return { source: svg(w, height, body), width: w, height }
221}
222
223/** Les derniers lancements de tests : un point vert ou rouge chacun, le dernier qui pulse. */
224export const dotsSvg = (runs: readonly { ok: boolean }[], width: number, green: string, red: string): Drawn => {
225  const step = 12
226  const max = Math.max(1, Math.floor((Math.max(80, width) - 10) / step))
227  const shown = runs.slice(-max)
228  const w = Math.max(20, shown.length * step + 8)
229  const h = 16
230  const dots = shown.map((run, i) => {
231    const x = 8 + i * step
232    const c = run.ok ? green : red
233    const isLast = i === shown.length - 1
234    return (
235      (isLast
236        ? `<circle cx="${x}" cy="8" r="4" fill="${c}" opacity="0.4"><animate attributeName="r" values="4;8" dur="1.8s" repeatCount="indefinite"/><animate attributeName="opacity" values="0.45;0" dur="1.8s" repeatCount="indefinite"/></circle>`
237        : '') + `<circle cx="${x}" cy="8" r="${isLast ? 4.6 : 3.8}" fill="${c}"/>`
238    )
239  })
240  return { source: svg(w, h, dots.join('')), width: w, height: h }
241}
242
243/** Un arc qui tourne : un sous-agent au travail. */
244export const spinnerSvg = (color: string, size = 14): Drawn => {
245  const m = size / 2
246  const r = m - 2
247  const body =
248    `<circle cx="${m}" cy="${m}" r="${r}" fill="none" stroke="${color}" stroke-opacity="0.22" stroke-width="2"/>` +
249    `<circle cx="${m}" cy="${m}" r="${r}" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-dasharray="${r1(r * 2)} ${r1(r * 5)}"><animateTransform attributeName="transform" type="rotate" from="0 ${m} ${m}" to="360 ${m} ${m}" dur="0.9s" repeatCount="indefinite"/></circle>`
250  return { source: svg(size, size, body), width: size, height: size }
251}
252
253/** Point d'état ; pulsant quand quelque chose tourne. */
254export const pulseSvg = (color: string, isPulsing: boolean, size = 12): Drawn => {
255  const m = size / 2
256  const body =
257    (isPulsing
258      ? `<circle cx="${m}" cy="${m}" r="3" fill="${color}" opacity="0.5"><animate attributeName="r" values="3;${m}" dur="1.4s" repeatCount="indefinite"/><animate attributeName="opacity" values="0.55;0" dur="1.4s" repeatCount="indefinite"/></circle>`
259      : '') + `<circle cx="${m}" cy="${m}" r="3.4" fill="${color}"/>`
260  return { source: svg(size, size, body), width: size, height: size }
261}
262
263/** La feuille de route du bandeau : des pastilles, sans libellés. */
264export const miniRoadSvg = (nodes: readonly { color: string; state: NodeState; isOptional?: boolean }[]): Drawn => {
265  const gap = 17
266  const h = 16
267  const w = Math.max(16, (nodes.length - 1) * gap + 16)
268  const cy = h / 2
269  const parts: string[] = []
270  nodes.forEach((node, i) => {
271    if (i === nodes.length - 1) return
272    const next = nodes[i + 1]!
273    const x1 = 8 + i * gap + 6
274    const x2 = 8 + (i + 1) * gap - 6
275    const isDone = node.state === 'done' || node.state === 'skipped'
276    parts.push(
277      isDone && next.state !== 'pending' && next.state !== 'next'
278        ? `<line x1="${x1}" y1="${cy}" x2="${x2}" y2="${cy}" stroke="${node.color}" stroke-width="2" stroke-linecap="round"/>`
279        : `<line x1="${x1}" y1="${cy}" x2="${x2}" y2="${cy}" stroke="${MUTED}" stroke-opacity="0.5" stroke-width="1.6" stroke-dasharray="2 3"/>`,
280    )
281  })
282  nodes.forEach((node, i) => {
283    const x = 8 + i * gap
284    const c = node.color
285    if (node.state === 'done') parts.push(`<circle cx="${x}" cy="${cy}" r="4.6" fill="${c}"/>`)
286    else if (node.state === 'active')
287      parts.push(
288        `<circle cx="${x}" cy="${cy}" r="5" fill="${c}" opacity="0.4"><animate attributeName="r" values="5;8" dur="1.6s" repeatCount="indefinite"/><animate attributeName="opacity" values="0.5;0" dur="1.6s" repeatCount="indefinite"/></circle><circle cx="${x}" cy="${cy}" r="5.2" fill="${c}"/><circle cx="${x}" cy="${cy}" r="2" fill="${WHITE}"/>`,
289      )
290    else if (node.state === 'next') parts.push(`<circle cx="${x}" cy="${cy}" r="4.4" fill="none" stroke="${c}" stroke-width="2"/>`)
291    else if (node.state === 'skipped') parts.push(`<circle cx="${x}" cy="${cy}" r="3" fill="${MUTED}" fill-opacity="0.5"/>`)
292    else
293      parts.push(
294        `<circle cx="${x}" cy="${cy}" r="4.2" fill="none" stroke="${c}" stroke-opacity="${node.isOptional ? 0.45 : 0.7}" stroke-width="1.5"${node.isOptional ? ' stroke-dasharray="2 2"' : ''}/>`,
295      )
296  })
297  return { source: svg(w, h, parts.join('')), width: w, height: h }
298}
299
types/index.d.ts 498 lines
1// Contrat d'état de flow-board : le tableau de bord du workflow mattpocock-skills.
2//
3// Un projet (racine de la session) garde ses parcours ; un parcours enchaîne des
4// phases (une par skill : Cadrage, Spec, Tickets…) ; une phase déroule les étapes
5// internes de son skill. Les détours (prototype, handoff…) se greffent sur une phase.
6
7/** Actif, en pause (un autre parcours a pris la main) ou terminé. */
8export type FlowStatus = 'active' | 'paused' | 'done'
9
10export type PhaseStatus = 'pending' | 'active' | 'done' | 'skipped'
11
12export type StepStatus = 'pending' | 'active' | 'done' | 'skipped'
13
14/** Statut d'un élément suivi par une phase : hypothèse, candidat, seam, décision… */
15export type ItemStatus = 'todo' | 'doing' | 'done' | 'dropped'
16
17export type TicketStatus = 'todo' | 'doing' | 'done' | 'blocked'
18
19/**
20 * Une tâche hors du lot dont un ticket dépend : une décision à prendre, ou le travail de
21 * quelqu'un d'autre. Elle bloque ses tickets tant qu'elle n'est pas faite (cochée dans Asana).
22 */
23export type ExternalBlocker = {
24  /** GID Asana (ou id du tracker). */
25  id: string
26  name: string
27  status: 'todo' | 'done'
28  /** Une décision à trancher (statut Asana « à trancher » ou « à confirmer »). */
29  isDecision: boolean
30}
31
32/**
33 * Un groupe de tickets à faire d'affilée dans une même discussion, parce qu'ils partagent leur
34 * contexte (mêmes fichiers, chaîne de dépendances). Déclaré par Claude à la fin de /to-tickets.
35 */
36export type TicketGroup = {
37  /** Court : « A », « B »… */
38  id: string
39  name: string
40  /** Ids des tickets, dans l'ordre d'exécution. */
41  tickets: string[]
42  /** Le contexte partagé qui justifie le groupe. */
43  why?: string
44  /** Fichiers ou dossiers du dépôt touchés : deux groupes qui en partagent ne vont pas en parallèle. */
45  files?: string[]
46  /**
47   * Ressources que le groupe ne partage pas pendant qu'il travaille : une application pilotée à
48   * l'écran (« PowerBI Desktop »), une base ou un appareil unique. Deux groupes qui en partagent
49   * une ne vont pas en parallèle, même sans fichier commun.
50   */
51  resources?: string[]
52  /** À faire dans la discussion qui a créé les tickets, dont le contexte lui sert (un seul groupe). */
53  continuesHere?: boolean
54  /** Id de la discussion qui a envoyé le plan (`continuesHere`) : « cette discussion » n'a de sens que là. */
55  continuesIn?: string
56  /**
57   * Groupe confié à une session liée depuis le panneau : le parcours reste dans la discussion
58   * principale, qui suit la session et rend les statuts de ses tickets à sa note de fin.
59   */
60  linked?: GroupLink
61  /**
62   * Le groupe a été fait par une session liée, puis rendu : sa revue, si elle manque, se fait
63   * là-bas, car ici sa branche est déjà fusionnée et `/code-review` n'aurait rien à lire.
64   */
65  delegated?: GroupLink
66  /** Quand `/code-review` (Claude Code) a relu le groupe, une fois ses tickets faits. */
67  reviewedAt?: number
68  /** La revue de sécurité du groupe : l'avis de Claude, puis `/security-review` s'il a été lancé. */
69  security?: SecurityCheck
70}
71
72/** Un groupe confié à une session liée : quand, et l'id `local_…` de la session une fois lancée. */
73export type GroupLink = {
74  at: number
75  session?: string
76  /** Groupe rendu : quand la personne a demandé de fermer sa session et son worktree (« ✕ Fermer… »). */
77  closeAskedAt?: number
78}
79
80/**
81 * `/security-review` (Claude Code) sur un groupe fini : facultatif. Claude dit s'il le conseille
82 * (auth, droits, entrées, secrets…), la personne le lance ou non ; il ne bloque jamais la livraison.
83 */
84export type SecurityCheck = {
85  /** L'avis de Claude, une fois le groupe relu : la revue vaut-elle la peine ? */
86  isAdvised?: boolean
87  /** Pourquoi, court, dans la langue de la personne. */
88  why?: string
89  /** Quand `/security-review` a été lancé sur le groupe. */
90  ranAt?: number
91  /** Ce qu'il a trouvé de réel, et combien ont été corrigés. */
92  findings?: number
93  fixed?: number
94}
95
96/** `cancelled` : lancement refusé (par la personne ou un hook), ou interrompu. */
97export type AgentStatus = 'running' | 'done' | 'failed' | 'cancelled'
98
99export type Tone = 'info' | 'ok' | 'warn' | 'error' | 'accent'
100
101/** Ce que fait la session : Claude travaille, attend la personne, ou des agents tournent seuls. */
102export type LiveMode = 'idle' | 'working' | 'waiting' | 'agents'
103
104export type FlowStep = {
105  /** Id de l'étape dans le catalogue du skill. */
106  id: string
107  status: StepStatus
108  /** Début de l'étape (ms), posé quand elle devient active. */
109  at?: number
110}
111
112export type FlowItem = { id: string; label: string; status: ItemStatus }
113
114/**
115 * Éléments d'une phase confiés à une session liée depuis le panneau (les pistes d'une rétro) :
116 * le parcours reste dans la discussion principale, qui suit la session et rend les statuts.
117 */
118export type LinkedWork = {
119  /** Ids des éléments confiés, dans l'ordre de la liste. */
120  items: string[]
121  /** Envoi de la demande (ms), ou sa dernière relance. */
122  at: number
123  /** Id de la session liée (`local_…`), relevé quand Claude la lance. */
124  session?: string
125  /**
126   * Un tour de la discussion, commencé après l'envoi, s'est fini sans lancer de session (ms) : les
127   * éléments attendent encore. Effacé par le lancement ou par une relance de la demande.
128   */
129  missedAt?: number
130}
131
132/** Chiffres d'une phase relevés automatiquement. */
133export type PhaseStats = {
134  /** Questions posées (blocs `❓ **Qn**` du grilling). */
135  questions: number
136  /** Tours de questions (réponses de Claude qui en contiennent). */
137  rounds: number
138  /** Réponses de la personne pendant la phase. */
139  answers: number
140  /** Constats de revue, axe Standards, cumulés sur les revues de la phase ; -1 tant qu'aucune n'a rendu. */
141  standards: number
142  /** Constats de revue, axe Spec, cumulés ; -1 tant qu'aucune revue n'a rendu. */
143  spec: number
144  /** Revues rendues, comptées par Claude (champ `review` de l'outil). Absent : compteurs d'avant la 0.19.0. */
145  reviews?: number
146  /** Constats corrigés, cumulés. */
147  fixed?: number
148}
149
150/** Un skill lancé hors du fil du parcours, rattaché à la phase en cours. */
151export type Detour = {
152  skill: string
153  status: 'active' | 'done'
154  startedAt: number
155  finishedAt?: number
156  steps: FlowStep[]
157  items: FlowItem[]
158}
159
160export type FlowPhase = {
161  /** Skill qui mène la phase (peut basculer sur une alternative : implement-spec). */
162  skill: string
163  /** Autres skills qui peuvent tenir cette phase. */
164  alts: string[]
165  /** Facultative : sautée sans souci si le parcours passe outre. */
166  isOptional: boolean
167  status: PhaseStatus
168  steps: FlowStep[]
169  items: FlowItem[]
170  /** Nombre de lancements du skill sur cette phase (un /implement par ticket…). */
171  round: number
172  startedAt?: number
173  finishedAt?: number
174  detours: Detour[]
175  stats: PhaseStats
176  /** Éléments confiés à une session liée, tant que la phase les suit. */
177  linked?: LinkedWork
178}
179
180export type Ticket = {
181  /** Id du tracker (GID Asana, `#12` GitHub, `03` local) ou id choisi par Claude. */
182  id: string
183  name: string
184  status: TicketStatus
185  /** Ids des tickets qui doivent être faits avant celui-ci (les « Blocked by » de /to-tickets). */
186  blockedBy?: string[]
187  /** Ce qu'attend un ticket bloqué hors du lot : une décision, un accord, un accès. */
188  why?: string
189  /** Fichiers ou dossiers que le ticket touche : ils s'ajoutent à ceux de son groupe. */
190  files?: string[]
191  /** Le ticket touche une zone sensible (`SENSITIVE_AREAS` de catalog.ts), dit au découpage. */
192  sensitive?: boolean
193  /** Le ticket a de la logique à relire : faux pour une ressource visuelle ou un document, dit au découpage. */
194  logic?: boolean
195  /**
196   * Id de la discussion qui a passé le ticket « en cours » ; effacé quand il en sort. Plusieurs
197   * tickets peuvent être en cours à la fois (sous-agents de /implement-spec, autre discussion).
198   */
199  by?: string
200  startedAt?: number
201  finishedAt?: number
202  /**
203   * Affichage seulement, jamais enregistré : le groupe confié dont la session liée relaie ce
204   * statut. Fait ainsi, le ticket ne lève ses dépendances que dans ce groupe.
205   */
206  relayedFrom?: string
207}
208
209/**
210 * Une tâche créée dans le tracker pendant le parcours, hors du lot : constat de revue, anomalie
211 * découverte, action pour la personne. Visible, mais hors de la progression et de la livraison ;
212 * elle entre dans le lot quand Claude l'envoie dans `tickets`.
213 */
214export type FoundTask = {
215  /** GID Asana, `#12` GitHub. */
216  id: string
217  name: string
218  status: TicketStatus
219  /** Ce qu'elle attend, quand elle est bloquée (ligne « Statut : » d'Asana). */
220  why?: string
221  /** Assignée à la personne : c'est à elle de la faire. */
222  isForUser?: boolean
223  /** Lien de l'issue, quand l'id ne suffit pas à le construire. */
224  url?: string
225  at: number
226}
227
228/**
229 * Un choix que Claude a fait sans que la personne l'ait demandé ni validé : ticket traité hors
230 * de la file, approche choisie parmi plusieurs, travail reporté, périmètre changé.
231 */
232export type SoloDecision = {
233  at: number
234  /** Le choix, court, dans la langue de la personne. */
235  what: string
236  why?: string
237  /** Quand la personne l'a marqué comme relu. */
238  seenAt?: number
239  /** Affichage seulement, jamais enregistré : le groupe confié dont la session liée a relayé ce choix. */
240  relayedFrom?: string
241}
242
243export type AgentRow = {
244  /** tool_use_id de l'appel Agent. */
245  key: string
246  /** Id rendu par l'appel, que SubagentStop rappelle. */
247  agentId?: string
248  name: string
249  /** Rôle déduit de la description : implementer, merger, relecteur… */
250  role?: string
251  model?: string
252  isBackground: boolean
253  status: AgentStatus
254  startedAt: number
255  finishedAt?: number
256  /** Fin constatée après coup (le sous-agent n'était plus actif) : sa durée n'est pas connue. */
257  isEndUnseen?: boolean
258}
259
260export type ArtifactKind =
261  | 'glossary'
262  | 'adr'
263  | 'spec'
264  | 'issue'
265  | 'map'
266  | 'report'
267  | 'questionnaire'
268  | 'prototype'
269  | 'research'
270  | 'handoff'
271  | 'lesson'
272  | 'config'
273  | 'doc'
274  | 'script'
275  | 'pr'
276  | 'plan'
277  | 'tracking'
278
279export type Artifact = {
280  /** Chemin du fichier, ou URL pour une issue ou une PR. */
281  path: string
282  kind: ArtifactKind
283  label: string
284  detail?: string
285  at: number
286}
287
288export type LogEntry = { at: number; glyph: string; text: string; tone: Tone }
289
290export type TestRun = { at: number; ok: boolean; kind: 'test' | 'check' }
291
292export type Counters = {
293  commits: number
294  merges: number
295  worktrees: number
296  /** Termes ajoutés au glossaire. */
297  terms: number
298  adrs: number
299  testFiles: number
300  /** Questions de reformulation (/wait-what). */
301  repitches: number
302}
303
304export type Run = {
305  id: string
306  /** Skill de départ : il fixe la forme du parcours. */
307  entry: string
308  title: string
309  /** Le titre vient de Claude ou des arguments, pas du nom du flux. */
310  isTitled: boolean
311  status: FlowStatus
312  phases: FlowPhase[]
313  /** Index de la phase en cours. */
314  cursor: number
315  tickets: Ticket[]
316  /** Tickets lancés depuis le panneau, dans l'ordre choisi. */
317  queue?: string[]
318  /** Tâches hors du lot dont des tickets dépendent. */
319  externals?: ExternalBlocker[]
320  /** Tâches créées dans le tracker pendant le parcours, hors du lot (« Trouvé en route »). */
321  found?: FoundTask[]
322  /** Choix faits par Claude sans la personne, à relire (« Décidé sans toi »). */
323  decided?: SoloDecision[]
324  /** Plan d'exécution : les tickets regroupés par contexte (liste complète, remplacée d'un bloc). */
325  groups?: TicketGroup[]
326  /** Quand `/code-review` a relu le lot entier (parcours sans groupes, tickets tous faits). */
327  reviewedAt?: number
328  /** La revue de sécurité du lot entier, pour un parcours sans groupes. */
329  security?: SecurityCheck
330  agents: AgentRow[]
331  artifacts: Artifact[]
332  tests: TestRun[]
333  log: LogEntry[]
334  counters: Counters
335  /** Fichiers modifiés (chemins normalisés, sans doublon). */
336  touched: string[]
337  startedAt: number
338  updatedAt: number
339  /** Id de la discussion où le parcours est actif ; absent quand il ne l'est nulle part. */
340  owner?: string
341  /** Rangé aux archives : caché de l'historique, ni repris ni proposé, jamais élagué ; se désarchive. */
342  archivedAt?: number
343  /**
344   * Mis de côté par la personne (`/flow pause`, « ‖ Mettre de côté ») : en pause, jamais clos, et
345   * repris seulement à sa demande (« Reprendre », champ `resume`), pas au chargement d'un skill.
346   */
347  asideAt?: number
348  finishedAt?: number
349}
350
351/** Trace d'un parcours supprimé, pour qu'une autre discussion ne le fasse pas revenir. */
352export type Tombstone = { id: string; at: number }
353
354export type Board = {
355  v: 1
356  /** Racine du projet (clé du stockage). */
357  root: string
358  /** Nom court du projet. */
359  name: string
360  runs: Run[]
361  /** Parcours actif DE CETTE DISCUSSION : jamais repris du stockage, qui est partagé. */
362  activeId: string | null
363  /** Id de la discussion qui tient cette copie du board. */
364  session?: string
365  /**
366   * Cette discussion travaille pour une autre (session liée d'un groupe) : propre à la
367   * discussion, jamais repris du stockage. Ses skills et ses rapports ne touchent aucun parcours.
368   * `title` : le titre du parcours quand il n'est pas dans ce board (session dans un worktree,
369   * qui a son propre fichier), lu dans celui du dépôt principal.
370   */
371  delegate?: {
372    run: string
373    at: number
374    title?: string
375    group?: string
376    /**
377     * La discussion principale telle que le brief la désigne, « <nom> [ref] » : son nom peut changer,
378     * pas sa référence. Rappelé à chaque réponse de l'outil, pour que la session puisse la joindre.
379     */
380    main?: string
381  }
382  /** Parcours supprimés récemment (toutes discussions confondues). */
383  deleted?: Tombstone[]
384}
385
386/**
387 * L'avancement qu'une session liée relaie à la discussion principale, hors du parcours (qui n'a
388 * qu'un écrivain) : ses tickets d'après ses mises à jour Asana ou ses `set`, ses commits, ses tests.
389 */
390export type RelayEntry = {
391  /** Le groupe confié, quand la session l'a déclaré. */
392  group?: string
393  tickets: Record<string, { status: TicketStatus; why?: string; at: number }>
394  commits: number
395  lastCommit?: string
396  test?: { ok: boolean; at: number }
397  /** Quand la session a lancé `/code-review` (Claude Code) sur son groupe. */
398  reviewedAt?: number
399  /** Quand la session a lancé `/security-review` sur son groupe. */
400  securedAt?: number
401  /** Les choix faits sans la personne que la session a signalés (`decided`), dans l'ordre. */
402  decided?: { what: string; why?: string; at: number }[]
403  /**
404   * Le compte rendu final de la session (`report`), en filet de son envoi par SendMessage : la
405   * discussion principale, renommée ou redémarrée, peut ne jamais recevoir le message.
406   */
407  report?: { text: string; at: number }
408  /** Le dernier signal reçu : un ticket, un commit, des tests, une note, une revue, le compte rendu. */
409  last?: { kind: 'ticket' | 'commit' | 'test' | 'note' | 'review' | 'decided' | 'report'; text: string; at: number; ticket?: string }
410  startedAt: number
411  at: number
412  /** La session s'est terminée (fin de discussion, pas un /clear). */
413  endedAt?: number
414}
415
416/** Le relais d'un parcours : une entrée par session liée (id de la discussion), chacune écrite par elle seule. */
417export type Relay = { v: 1; run: string; sessions: Record<string, RelayEntry> }
418
419export type Live = {
420  mode: LiveMode
421  since: number
422  /** Questions en attente de réponse (`Q4–Q6`), pendant un grilling. */
423  ask: string | null
424}
425
426/**
427 * Où en est la branche par rapport au dépôt distant, d'après le dernier `git fetch` du mod.
428 * Les nombres sont null tant que rien n'a été mesuré (pas de dépôt, pas de distant, erreur).
429 */
430export type SyncState = {
431  /** Commits de la branche principale distante (origin/main) absents de la branche courante. */
432  behindMain: number | null
433  /** Commits de la branche distante suivie par la branche courante, absents d'ici. */
434  behindUpstream: number | null
435  branch: string | null
436  /** Nom de la branche principale (`main`, `master`…). */
437  main: string | null
438  isOnMain: boolean
439  /** Sommets distants mesurés, `<sha de main>|<sha suivi>` : change à chaque fusion ou push distant. */
440  remoteKey: string | null
441  /** Sommets pour lesquels le sync a déjà été demandé une fois. */
442  askedFor: string | null
443  checkedAt: number | null
444  /** Le dépôt a un distant `origin` ; null tant que ce n'est pas mesuré (ou hors d'un dépôt git). */
445  hasRemote?: boolean | null
446  /** `origin` pointe vers GitHub : la phase « Pull request » (/project:pr) n'a de sens que là. */
447  isGitHub?: boolean | null
448  /** Le projet est un dépôt git ; null tant que ce n'est pas mesuré. /implement-spec en a besoin. */
449  isRepo?: boolean | null
450}
451
452export type Tab = 'flow' | 'activity' | 'history' | 'commands'
453
454export type UiState = {
455  tab: Tab
456  isBandHidden: boolean
457  /** La personne a fermé le panneau : il ne se rouvre plus tout seul cette session. */
458  isPaneDismissed: boolean
459  showAllTickets: boolean
460  showAllLog: boolean
461  /** Tous les éléments de la phase (décisions, hypothèses…), pas seulement les 14 premiers. */
462  showAllItems?: boolean
463  /** Tous les livrables, pas seulement les 14 premiers. */
464  showAllArtifacts?: boolean
465  /** Tous les sous-agents terminés gardés, pas seulement les 3 derniers. */
466  showAllAgents?: boolean
467  /** Les choix « décidé sans toi » déjà relus, dépliés. */
468  showDecided?: boolean
469  /** Toutes les tâches trouvées en route, pas seulement celles qui attendent la personne. */
470  showFound?: boolean
471  /** L'historique montre aussi les parcours archivés. */
472  showArchived?: boolean
473  /** « Trouvé en route » réduit à son titre, tâches qui attendent la personne comprises. */
474  hideFound?: boolean
475  /** Tickets choisis dans le panneau, dans l'ordre de sélection (cette discussion seulement). */
476  selection: string[]
477  /** Éléments de la phase (pistes d'une rétro) choisis pour une session liée. */
478  itemSelection?: string[]
479  /** Parcours archivé dont la suppression attend sa confirmation. */
480  confirmDrop?: string
481}
482
483declare module 'claude-code' {
484  interface PluginState {
485    'flow-board': {
486      board: Board | null
487      live: Live
488      ui: UiState
489      /** Id de skill -> nom de la commande slash (`to-spec` -> `mattpocock-skills:to-spec`). */
490      commands: Record<string, string>
491      /** Retard de la branche sur le distant, pour savoir s'il faut demander /project:sync. */
492      sync: SyncState
493      /** L'avancement relayé par les sessions liées du parcours actif (discussion principale). */
494      relay: Relay | null
495    }
496  }
497}
498