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…

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.
/grill-with-docs, /to-spec, /diagnosing-bugs, /wayfinder ou /implement n'ouvrent pas le même parcours.project : /project:sync, /project:version, /project:push, /project:pr.Version actuelle : 0.57.0. Historique des versions : CHANGELOG.md.
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
mattpocock-skills, fourni par cette marketplace en copie figée (plugins/mattpocock-skills, version 1.3.1 de Matt Pocock).project, pour la phase de livraison ;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 | |
|---|---|---|
| Lancement | start_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 fin | l'app relance la discussion principale quand la fille finit un tour | la 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.
| Terme | Définition |
|---|---|
| Parcours | Un 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. |
| Phase | Une étape du parcours, tenue par un skill : Cadrage, Spec, Tickets, Implémentation, Version et push, Pull request… Certaines phases sont facultatives (en pointillé). |
| Étape | Une é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étour | Un skill lancé hors du fil du parcours (/prototype, /research, /handoff…), affiché en bulle au-dessus de la phase en cours. |
| Ticket | Une unité de travail du lot, identifiée par son GID Asana, avec son statut et ses dépendances. |
| Dépendance externe | Une 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 toi | Un 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 route | Une 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. |
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.
/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)./flow), plus dans le bandeau (depuis la 0.40.0).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.
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).
| Famille | Bouton | Commande | Ce qu'il fait |
|---|---|---|---|
| Comprendre | ? Reformulation | /wait-what | Le dernier message reformulé, plus simplement ; part tel quel |
| Comprendre | ✧ Recherche | /research | Sources primaires, un rapport Markdown ; ajouter le sujet |
| Comprendre | ✦ Tester une idée | /prototype | Une 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-questionnaire | Une décision qu'on ne tranche pas seul, mise en questionnaire ; ajouter le sujet |
| S'orienter | ✱ Et maintenant ? | /ask-matt | Quelle 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 | /grilling | Une 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-modeling | Un 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 | /compact | Commande de Claude Code qui allège le contexte ; consignes facultatives |
| Contexte | ➜ Passation | /handoff | La discussion résumée en document de passation pour une autre session ; ajouter son but |
| Déléguer | 🐞 Bug · session liée | /diagnosing-bugs | Depuis 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 |
| Onglet | Contenu |
|---|---|
| Parcours | En-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. |
| Historique | Les 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. |
| Commandes | Les 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.
| Skill de départ | Parcours | Phases |
|---|---|---|
/grill-with-docs | Nouvelle fonctionnalité | Cadrage → Spec¹ → Tickets¹ → Implémentation → Compte rendu → Version et push¹ → Pull request¹ |
/to-spec | De la spec au code | Spec → Tickets → Implémentation → Compte rendu → Version et push¹ → Pull request¹ |
/to-tickets | Des tickets au code | Tickets → Implémentation → Compte rendu → Version et push¹ → Pull request¹ |
/implement, /implement-spec | Implémentation | Implémentation → Compte rendu → Version et push¹ → Pull request¹ |
/diagnosing-bugs | Chasse au bug | Diagnostic → Revue¹ → Compte rendu → Version et push¹ → Pull request¹ |
/tdd | TDD | TDD → Revue¹ → Compte rendu → Version et push¹ → Pull request¹ |
/triage | Triage | Triage → Implémentation¹ → Compte rendu² → Version et push¹ → Pull request¹ |
/wayfinder | Grand chantier | Wayfinder → Spec¹ → Tickets¹ → Implémentation¹ → Compte rendu² → Version et push¹ → Pull request¹ |
/improve-codebase-architecture | Santé du code | Architecture → Cadrage¹ → Spec¹ → Tickets¹ → Implémentation¹ → Compte rendu² → Version et push¹ → Pull request¹ |
/code-review (Claude Code), sans parcours en cours | Relecture de branche | Relecture → Compte rendu → Version et push¹ → Pull request¹ |
/mattpocock-skills:code-review | Revue de code | Revue → Compte rendu → Version et push¹ → Pull request¹ |
/grill-me, /prototype, /research, /teach, /wizard… | Un parcours d'une phase | Les é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 :
travail/tasks/… s'il y en a un ;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é » ;/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) :
/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 ;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 » :
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 ») ;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) ;CLAUDE.md du projet à la fin ; rien sur le tableau de bord ;La carte suit l'envoi :
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 ;« ↻ 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
hooks/register.tsx 2441 lines1// 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) returnhooks/catalog.ts 1072 lines1// 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'])
1072hooks/model.ts 3933 lines1// 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 lines1// 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 lines1// 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 => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' })[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}
299types/index.d.ts 498 lines1// 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