Plugins
Les événements
Escouade dit ce qui s’y passe sous forme de faits : un agent créé, un tour fini, un ticket à tester… Un plugin s’abonne aux faits qu’il veut : son programme les reçoit, un webhook déclaré les envoie à une adresse sans code, ou une commande déclarée se lance.
Les faits sont dits par le cœur d’Escouade à l’endroit où l’état change, jamais relus dans ce qu’affiche la fenêtre : ils ne changent pas quand l’interface change.
Un fait
Chaque fait a la même forme, en JSON :
{
"version": 1,
"seq": 42,
"at": "2026-10-29T14:03:12.511Z",
"name": "ticket.column",
"data": { "from": "doing", "to": "review" },
"project": { "id": "8c1f…", "name": "Escouade", "private": false },
"ticket": { "id": "e02a…", "key": "ESC-177", "title": "Les événements" },
"agent": { "id": "5b7d…", "name": "esc-177-les-evenements" }
}
version: la version de la forme des faits. Un champ ajouté la garde ; un champ retiré ou qui change de sens passe à la suivante.seq: son numéro depuis le démarrage d’Escouade. Deux faits arrivent dans l’ordre de leurs numéros.nameetdata: ce qui s’est passé, et ce qu’il en dit (la table plus bas).project,ticket,agent: ce dont il parle, par leurs ids et leurs noms, quand il en parle. Le fait d’un agent qui travaille sur un ticket dit aussi son ticket ; le projet dit s’il est privé (private).
Un fait ne porte jamais la conversation : un plugin apprend qu’un tour est fini et comment, pas ce qui s’y est dit.
Les faits
| Fait | Quand | data |
|---|---|---|
agent.created |
Un agent est créé (à la main, pour un ticket, par un client du serveur MCP). | harness, model |
agent.renamed |
Il est nommé (après son premier message, ou à la main) ; les faits suivants disent son nouveau nom. | from, to |
agent.status |
Son statut change. | status et was : idle, running, waiting (une demande attend une réponse), done, error |
agent.stopped |
Il est archivé (removed: false) ou supprimé (removed: true). |
removed |
agent.handover |
Il continue sur un autre harness. | from, to |
turn.ended |
Un tour se termine. | end : finished, interrupted, limited (la limite d’usage), signIn (une connexion à refaire) ou error, avec error (sa première ligne) |
request.permission |
Une autorisation attend. | requestId, tool, arg (ce sur quoi l’outil agit, résumé comme sur la carte) |
request.question |
Une question attend. | requestId, questions |
request.answered |
Une demande a sa réponse. | requestId, kind (permission ou question), decision (allow, always, deny), by (le client du serveur MCP qui a répondu, sinon absent) |
ticket.created |
Un ticket est créé. | column |
ticket.column |
Il change de colonne. | from, to : todo, doing, review, done |
ticket.report |
Son agent rend le bilan de ses critères. | met, total |
ticket.approved |
Sa validation commence. | |
ticket.rejected |
Il est renvoyé à son agent. | comment |
ticket.merged |
Il est mergé dans sa branche cible. | target, commit |
ticket.conflict |
Son merge rencontre un conflit. | target, files |
pr.opened |
Une pull request est ouverte pour lui. | url, target |
tests.result |
Les tests de sa validation sont passés ou non. | passed |
project.added |
Un projet est ouvert. | path |
project.closed |
Il est fermé. | |
worktree.created |
Un worktree est créé pour un agent. | branch, path |
worktree.removed |
Il est retiré. | branch, path |
launch.started |
Un lancement de test d’un agent démarre. | run (son nom) |
launch.ready |
Il répond. | url |
launch.stopped |
Il s’arrête. | |
docs.written |
Une page des docs est écrite. | page |
docs.filed |
La copie des docs d’un ticket est versée sur la branche des docs, à sa validation. | pages |
docs.gaps |
Une page est comparée au code. | page, gaps (le nombre d’écarts) |
memory.written |
Un souvenir de la mémoire du projet est écrit. | memory, new |
memory.forgotten |
Il est oublié. | memory |
decision.proposed |
Une décision est proposée ou révisée. | id (pas encore de numéro), title |
decision.accepted |
Un humain l’accepte. | number (donné alors), id, title |
account.switched |
Un agent change de compte. | from, to |
workflow.step_entered |
Un agent entre dans une étape du workflow de son projet. | workflow, step, door (la commande tapée, s’il y en a une), pass (son n-ième passage) |
workflow.step_left |
Il en sort : durée, coût, issue. | workflow, step, durationMs, cost, outcome (done, back, failed, stopped, left) |
workflow.looped_back |
Il revient à une étape déjà passée. | workflow, from, to, reason (la ligne où l’étape s’est arrêtée) |
workflow.left |
Il sort du workflow : il fait autre chose. | workflow, doing |
workflow.changed |
Une relecture des workflows du projet donne autre chose. | workflows (les ids de ceux qui ont changé, apparu ou disparu) |
app.started |
Escouade démarre. | version |
S’abonner
Un plugin ne reçoit que les faits qu’il demande, dans permissions.events de son manifeste. Un nom finissant par * les prend tous (ticket.*, ou * pour tout) :
"permissions": { "events": ["ticket.column", "agent.status"] }
La fenêtre d’installation les montre : « Reçoit ces événements : … ».
Et seulement ceux des projets où il est activé. Un plugin est activé pour tous les projets, sauf les projets privés : un fait d’un projet privé ne va qu’à un plugin activé pour lui, un par un, dans Réglages › Plugins. Un fait sans projet (app.started) va à tout plugin activé pour l’app. Une permission events:… retirée dans l’onglet Permissions de sa fiche coupe ces faits, pour son programme comme pour ses webhooks et ses commandes.
Dans un programme
Le programme d’un plugin (run) reçoit chaque fait par la notification events/fact, le fait entier en paramètres. Il n’y répond pas : Escouade n’attend jamais un plugin.
{
"jsonrpc": "2.0",
"method": "events/fact",
"params": {
"version": 1,
"seq": 42,
"name": "ticket.column",
"data": { "from": "doing", "to": "review" },
"ticket": { "id": "e02a…", "key": "ESC-177", "title": "Les événements" }
}
}
Pour le démarrer au premier fait, mets-le dans activation : "activation": ["event:ticket.column"].
Les faits d’un plugin passent par une file à lui, de 256 faits. Le suivant part quand son programme a pris le précédent sur son entrée ; un plugin lent ne retarde rien d’autre. Quand la file est pleine, le plus vieux fait est perdu, et son journal le dit : « the queue of facts is full (256): the oldest, ticket.column #41, is dropped ».
Un webhook, sans code
Un plugin déclaratif (sans run) appelle une adresse sur un fait. Chaque webhook de contributes.webhooks dit :
| Champ | Obligatoire | Ce qu’il dit |
|---|---|---|
url |
oui | L’adresse, en http:// ou https://, vers un hôte de permissions.network. Elle peut venir d’un secret du plugin : {{secrets.webhook}}. |
events |
oui | Les faits qui l’appellent (ticket.* les prend tous). Ils doivent aussi être dans permissions.events. |
when |
non | Ce que le fait doit dire, chemin par chemin : { "data.to": "review" }, ou une liste de valeurs : { "data.status": ["waiting", "error"] }. |
body |
non | Le gabarit du corps : un objet JSON, ou un texte. Sans lui, le fait entier en JSON. |
contentType |
non | Le type du corps : application/json pour un objet ou sans gabarit, text/plain pour un texte. |
headers |
non | Des en-têtes, qui peuvent prendre un secret : { "Authorization": "Bearer {{secrets.token}}" }. |
Un gabarit remplace {{chemin}} par la valeur du fait : {{name}}, {{ticket.key}}, {{ticket.title}}, {{project.name}}, {{agent.name}}, {{data.to}}… Dans un objet, un texte qui n’est qu’un {{chemin}} prend la valeur telle quelle (une liste reste une liste) ; dans un texte JSON, les valeurs sont échappées. Un secret ne va jamais dans le corps.
Le plugin Discord, en entier : un webhook pour un ticket passé « À tester », un autre pour un agent qui attend.
plugins/discord/escouade-plugin.json
{
"$schema": "https://escouade.dev/schemas/plugin-1.json",
"id": "dev.escouade.discord",
"name": "Discord",
"version": "1.1.0",
"description": {
"fr": "Les tickets à tester et les agents qui attendent, dans un salon Discord : un webhook, sans code.",
"en": "Tickets to test and agents waiting, in a Discord channel: a webhook, no code."
},
"author": { "name": "Escouade", "url": "https://escouade.dev" },
"license": "MIT",
"repository": "https://github.com/guillaume-gagnaire/escouade/tree/main/plugins/discord",
"escouade": { "api": "1", "min": "2.1.0" },
"permissions": {
"events": ["ticket.column", "agent.status"],
"network": ["discord.com"],
"secrets": ["webhook"]
},
"contributes": {
"secrets": [{ "key": "webhook", "title": { "fr": "Adresse du webhook Discord", "en": "Discord webhook URL" } }],
"webhooks": [
{
"url": "{{secrets.webhook}}",
"events": ["ticket.column"],
"when": { "data.to": "review" },
"body": {
"content": "🧪 **{{ticket.key}}** to test: {{ticket.title}} ({{project.name}})",
"allowed_mentions": { "parse": [] }
}
},
{
"url": "{{secrets.webhook}}",
"events": ["agent.status"],
"when": { "data.status": "waiting" },
"body": {
"content": "✋ **{{agent.name}}** waits for you ({{project.name}})",
"allowed_mentions": { "parse": [] }
}
}
]
}
}
Un webhook ne part que vers un hôte de permissions.network, celui que la fenêtre d’installation montre, que son adresse soit écrite en clair, tirée d’un secret ou faite d’une valeur du fait. Escouade refuse au chargement un manifeste dont un webhook écrit en clair va ailleurs, et ne fait pas un appel dont l’adresse, une fois faite, va ailleurs (le journal le dit) : un titre de ticket comme evil.dev/x n’envoie rien. Un hôte se compare sans tenir compte de la casse, sans l’utilisateur ni le port de l’adresse. hooks.slack.com ne couvre que lui-même ; *.exemple.dev couvre ses sous-domaines à toute profondeur (hooks.exemple.dev, a.b.exemple.dev), mais pas exemple.dev lui-même, à déclarer à part, ni evilexemple.dev. Le webhook a 10 s pour répondre, sans redirection. Son journal note chaque appel par son hôte et sa réponse, jamais l’adresse entière (celle de Discord contient un jeton).
Une commande sur un fait
contributes.commands lance une ligne de commande sur un fait, dans le shell de l’utilisateur, dans le dossier du projet du fait :
"contributes": {
"commands": [
{ "id": "fin-du-tour", "events": ["turn.ended"], "when": { "data.end": "finished" }, "run": "say \"Claude a fini\"" }
]
}
- Rien ne tourne avant l’accord de l’utilisateur, comme les commandes de
.escouade.yml: l’onglet Permissions de sa fiche, dans Réglages › Plugins, les montre sous « Lance ces commandes » avec « Accepter ces commandes », et l’accord vaut pour elles telles qu’elles sont ; « Retirer l’accord » les arrête. Une commande, un fait ou une condition qui change, et il faut l’accord à nouveau. Le journal dit chaque commande refusée faute d’accord. - Le fait n’entre jamais dans la ligne de commande : un titre de ticket ne lance rien. La commande le trouve dans ses variables :
ESCOUADE_FACT(le fait entier en JSON),ESCOUADE_FACT_NAME,ESCOUADE_PROJECT,ESCOUADE_TICKET(sa clé) etESCOUADE_AGENT. - Elle a 60 s. Son journal note sa fin (
exit 0) et, si elle échoue, ses dernières lignes.
Le lecteur de workflow
Escouade lit seule les workflows d’un projet (GSD, Superpowers, Ralph…). Un plugin peut lui en apprendre un qu’elle ne connaît pas, avec contributes.workflowReaders :
"contributes": {
"workflowReaders": [
{ "id": "bmad", "name": "BMAD", "files": ["_bmad/**/*.md", "docs/*.md"] }
]
}
- Escouade appelle
workflow/readavec{ "reader": "bmad", "root": "…", "repos": [], "files": [{ "path": "_bmad/brief.md", "text": "…" }] }: seulement les fichiers que ses globs désignent, depuis le dossier du projet (et celui de chaque dépôt pour un projet de plusieurs), en texte, au plus 200 fichiers de 256 Ko. Un glob avec..ou un chemin absolu ne trouve rien. - Le plugin rend le graphe :
{ "workflows": [{ "id", "name", "steps": [{ "name", "command", "description", "path", "agents", "reads", "writes" }], "transitions": [{ "from", "to", "back", "condition", "max" }], "exit" }] }. Une étape est signalée par sacommandtapée, par les sous-agents qu’elle nomme ou les fichiers qu’elle écrit ; une transitionbackest un retour en arrière, avec saconditionpour raison quand l’étape n’en donne pas. - Le workflow se suit comme ceux qu’Escouade lit : l’onglet Workflow, la carte, les faits
workflow.*. Son id estplugin:<plugin>:<id>, donc il ne prend jamais la place d’un autre. - Un graphe qui ne tient pas (une étape sans nom, deux du même nom, une transition vers une étape absente, plus de 50 workflows) est refusé en entier, avec la raison, dans le journal. L’appel a 60 s.
Le journal
Le journal de chaque plugin (Réglages › Plugins, et plugin-logs/<id>.log dans les données d’Escouade) dit les webhooks appelés et leurs réponses, les commandes lancées et leur fin, ce qui a été refusé (un secret qui n’est pas le sien, une adresse non déclarée, une commande sans accord) et les faits perdus d’une file pleine.
Essayer
escouade-plugin dev <dossier> lance ton plugin hors d’Escouade et lui envoie les faits que tu choisis ; escouade-plugin test <dossier> vérifie qu’il reçoit un fait sans planter. Voir Tester un plugin.