[{"data":1,"prerenderedAt":33},["ShallowReactive",2],{"doc:fr:plugins\u002Fexamples":3},{"title":4,"lede":5,"html":6,"headings":7},"Les plugins d’exemple","Cinq plugins complets à lire, copier et installer : un thème, un webhook Discord sans code, Slack et les règles d’une équipe en TypeScript, et Goose, un harness décrit sans code. Ce sont des plugins d’Escouade, sur son store ; chacun passe le banc de conformité à chaque changement.","\u003Ch2 id=\"ou-les-trouver\">Où les trouver\u003C\u002Fh2>\n\u003Cp>Ils sont dans le dossier \u003Ccode class=\"key\">plugins\u002F\u003C\u002Fcode> du dépôt d’Escouade, un sous-dossier par plugin (\u003Ccode class=\"key\">plugins\u002Fslack\u002F\u003C\u002Fcode>…), chacun lisible seul avec son manifeste, son README, sa LICENSE (MIT), son CHANGELOG et ses tests : \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fguillaume-gagnaire\u002Fescouade\u002Ftree\u002Fmain\u002Fplugins\" rel=\"noopener\">https:\u002F\u002Fgithub.com\u002Fguillaume-gagnaire\u002Fescouade\u002Ftree\u002Fmain\u002Fplugins\u003C\u002Fa>. Le store les publie sous les identifiants \u003Ccode>dev.escouade.high-contrast\u003C\u002Fcode>, \u003Ccode class=\"key\">dev.escouade.discord\u003C\u002Fcode>, \u003Ccode class=\"key\">dev.escouade.slack\u003C\u002Fcode>, \u003Ccode>dev.escouade.team-rules\u003C\u002Fcode> et \u003Ccode class=\"key\">dev.escouade.goose\u003C\u002Fcode>, pris de ces dossiers à leurs étiquettes \u003Ccode>plugins\u002F&lt;dossier&gt;\u002Fv1.2.3\u003C\u002Fcode> (voir \u003Ca href=\"\u002Fdocs\u002Fplugins\u002Fpublishing\u002F\">Publier un plugin\u003C\u002Fa>).\u003C\u002Fp>\n\u003Cp>Pour en faire le tien, copie son dossier et donne-lui un identifiant à toi (\u003Ccode>io.github.&lt;ton-login&gt;.slack\u003C\u002Fcode>) : \u003Ccode class=\"key\">dev.escouade.*\u003C\u002Fcode> est réservé aux plugins d’Escouade, et le store ne l’accepte que du dépôt d’Escouade.\u003C\u002Fp>\n\u003Cdiv class=\"table\">\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Exemple\u003C\u002Fth>\n\u003Cth>Ce qu’il montre\u003C\u002Fth>\n\u003Cth>Genre\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\u003Ctr>\n\u003Ctd>Contraste élevé\u003C\u002Ftd>\n\u003Ctd>Un thème, sombre et clair, et une palette de couleurs de projet\u003C\u002Ftd>\n\u003Ctd>Déclaratif\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Discord\u003C\u002Ftd>\n\u003Ctd>Les tickets à tester et les agents qui attendent, dans un salon Discord\u003C\u002Ftd>\n\u003Ctd>Déclaratif\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Slack\u003C\u002Ftd>\n\u003Ctd>La même chose dans Slack, dans le salon de chaque projet, avec un secret au trousseau\u003C\u002Ftd>\n\u003Ctd>Programme TypeScript\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Règles de l’équipe\u003C\u002Ftd>\n\u003Ctd>Les commandes des agents autorisées ou refusées selon un fichier du dépôt\u003C\u002Ftd>\n\u003Ctd>Programme TypeScript\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Goose\u003C\u002Ftd>\n\u003Ctd>Un agent qui parle ACP, ajouté comme harness\u003C\u002Ftd>\n\u003Ctd>Déclaratif\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Linear\u003C\u002Ftd>\n\u003Ctd>Une source du Kanban, sur le kit\u003C\u002Ftd>\n\u003Ctd>Module Rust\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\u003C\u002Ftable>\u003C\u002Fdiv>\n\u003Cp>Le code montré ici est celui du dépôt : un test du site vérifie qu’il n’a pas changé depuis.\u003C\u002Fp>\n\u003Cdiv class=\"callout note\">\u003Cp class=\"callout-title\">À noter\u003C\u002Fp>\u003Cp>Slack et Règles de l’équipe sont en \u003Ccode class=\"key\">run.js\u003C\u002Fcode> depuis leur 2.0.0 : Escouade fait tourner leur \u003Ccode class=\"key\">dist\u002Fplugin.mjs\u003C\u002Fcode> dans l’app, en WASM, sans Node sur la machine (voir \u003Ca href=\"\u002Fdocs\u002Fplugins\u002Fapi\u002F#un-programme-en-wasm\">le protocole\u003C\u002Fa>). Il leur faut Escouade 2.2.0 ou plus récent (\u003Ccode class=\"key\">escouade.min\u003C\u002Fcode>) ; leurs versions 1.x, lancées par Node, restent au store pour les versions d’avant. Node ne sert qu’à leurs auteurs, pour les construire et les tester.\u003C\u002Fp>\n\u003C\u002Fdiv>\n\u003Ch2 id=\"les-essayer\">Les essayer\u003C\u002Fh2>\n\u003Cp>Installe-les depuis le store, dans \u003Cstrong>Plugins\u003C\u002Fstrong>. Pour les modifier : « En développement », choisis le dossier d’un exemple ; il est chargé en place et relu quand il change. Ses secrets se saisissent dans l’onglet « Réglages » de sa fiche.\u003C\u002Fp>\n\u003Cp>Pour les vérifier comme la CI du dépôt : chacun passe ses tests (et, en TypeScript, sa vérification de types ; son \u003Ccode class=\"key\">dist\u002Fplugin.mjs\u003C\u002Fcode> est comparé à la compilation de \u003Ccode class=\"key\">src\u002F\u003C\u002Fcode>), puis \u003Ccode>escouade-plugin validate\u003C\u002Fcode> et le banc, avec \u003Ccode class=\"key\">--first-party\u003C\u002Fcode> pour leurs identifiants réservés (voir \u003Ca href=\"\u002Fdocs\u002Fplugins\u002Ftesting\u002F\">Tester un plugin\u003C\u002Fa>).\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-sh\">npx tsc -p sdk\u002Fplugin\u002Ftsconfig.json\nESCOUADE_PLUGIN_BIN=\u002Fchemin\u002Fvers\u002Fescouade-plugin node plugins\u002Fci.mjs\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch2 id=\"contraste-eleve\">Contraste élevé\u003C\u002Fh2>\n\u003Cp>Un thème sans code : des valeurs pour les jetons de la fenêtre, en sombre et en clair, et une palette pour les couleurs de projet. Escouade filtre chaque valeur avant de la poser ; un jeton absent garde celle d’Escouade. Voir \u003Ca href=\"\u002Fdocs\u002Fplugins\u002Fthemes\u002F\">Les thèmes\u003C\u002Fa>.\u003C\u002Fp>\n\u003Cp>\u003Ccode>plugins\u002Fhigh-contrast\u002Fescouade-plugin.json\u003C\u002Fcode>\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-json\">{\n  &quot;$schema&quot;: &quot;https:\u002F\u002Fescouade.dev\u002Fschemas\u002Fplugin-1.json&quot;,\n  &quot;id&quot;: &quot;dev.escouade.high-contrast&quot;,\n  &quot;name&quot;: { &quot;fr&quot;: &quot;Contraste élevé&quot;, &quot;en&quot;: &quot;High contrast&quot; },\n  &quot;version&quot;: &quot;1.1.0&quot;,\n  &quot;description&quot;: {\n    &quot;fr&quot;: &quot;Un thème à fort contraste, clair et sombre : texte pur, bordures nettes, couleurs d&#39;état franches.&quot;,\n    &quot;en&quot;: &quot;A high-contrast theme, light and dark: pure text, sharp borders, plain status colors.&quot;\n  },\n  &quot;author&quot;: { &quot;name&quot;: &quot;Escouade&quot;, &quot;url&quot;: &quot;https:\u002F\u002Fescouade.dev&quot; },\n  &quot;license&quot;: &quot;MIT&quot;,\n  &quot;repository&quot;: &quot;https:\u002F\u002Fgithub.com\u002Fguillaume-gagnaire\u002Fescouade\u002Ftree\u002Fmain\u002Fplugins\u002Fhigh-contrast&quot;,\n  &quot;escouade&quot;: { &quot;api&quot;: &quot;1&quot;, &quot;min&quot;: &quot;2.1.0&quot; },\n  &quot;contributes&quot;: {\n    &quot;themes&quot;: [\n      {\n        &quot;id&quot;: &quot;high-contrast&quot;,\n        &quot;name&quot;: { &quot;fr&quot;: &quot;Contraste élevé&quot;, &quot;en&quot;: &quot;High contrast&quot; },\n        &quot;dark&quot;: {\n          &quot;--frame&quot;: &quot;#000000&quot;,\n          &quot;--panel&quot;: &quot;color-mix(in oklch, var(--p) 4%, #050505)&quot;,\n          &quot;--raise&quot;: &quot;#141414&quot;,\n          &quot;--raise-2&quot;: &quot;#1f1f1f&quot;,\n          &quot;--well&quot;: &quot;#000000&quot;,\n          &quot;--line&quot;: &quot;#8a8a8a&quot;,\n          &quot;--line-2&quot;: &quot;#b0b0b0&quot;,\n          &quot;--line-3&quot;: &quot;#d8d8d8&quot;,\n          &quot;--text&quot;: &quot;#ffffff&quot;,\n          &quot;--soft&quot;: &quot;#f0f0f0&quot;,\n          &quot;--muted&quot;: &quot;#d0d0d0&quot;,\n          &quot;--dim&quot;: &quot;#b8b8b8&quot;,\n          &quot;--p-ink&quot;: &quot;#ffd400&quot;,\n          &quot;--ring&quot;: &quot;#ffd400&quot;,\n          &quot;--run&quot;: &quot;#4dff88&quot;,\n          &quot;--wait&quot;: &quot;#ffb000&quot;,\n          &quot;--done&quot;: &quot;#7ab8ff&quot;,\n          &quot;--add&quot;: &quot;#4dff88&quot;,\n          &quot;--del&quot;: &quot;#ff6b6b&quot;\n        },\n        &quot;light&quot;: {\n          &quot;--frame&quot;: &quot;#ffffff&quot;,\n          &quot;--panel&quot;: &quot;#ffffff&quot;,\n          &quot;--raise&quot;: &quot;#f2f2f2&quot;,\n          &quot;--raise-2&quot;: &quot;#e6e6e6&quot;,\n          &quot;--well&quot;: &quot;#ffffff&quot;,\n          &quot;--line&quot;: &quot;#5c5c5c&quot;,\n          &quot;--line-2&quot;: &quot;#3d3d3d&quot;,\n          &quot;--line-3&quot;: &quot;#1f1f1f&quot;,\n          &quot;--text&quot;: &quot;#000000&quot;,\n          &quot;--soft&quot;: &quot;#0a0a0a&quot;,\n          &quot;--muted&quot;: &quot;#262626&quot;,\n          &quot;--dim&quot;: &quot;#3d3d3d&quot;,\n          &quot;--p-ink&quot;: &quot;#0040c0&quot;,\n          &quot;--ring&quot;: &quot;#0040c0&quot;,\n          &quot;--run&quot;: &quot;#006b2e&quot;,\n          &quot;--wait&quot;: &quot;#8a4b00&quot;,\n          &quot;--done&quot;: &quot;#0040c0&quot;,\n          &quot;--add&quot;: &quot;#006b2e&quot;,\n          &quot;--del&quot;: &quot;#b00020&quot;\n        },\n        &quot;projectColors&quot;: [&quot;#ffd400&quot;, &quot;#00e0ff&quot;, &quot;#ff5cf0&quot;, &quot;#7cff4f&quot;, &quot;#ff8a00&quot;, &quot;#b48cff&quot;]\n      }\n    ]\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Choisis-le dans Réglages › Application, rangée « Couleurs ».\u003C\u002Fp>\n\u003Ch2 id=\"discord\">Discord\u003C\u002Fh2>\n\u003Cp>Deux webhooks déclarés, sans programme : un ticket passé dans « À tester », et un agent qui attend ta réponse. L’adresse du webhook Discord est un secret : elle reste dans le trousseau du système et n’apparaît jamais au journal. Voir \u003Ca href=\"\u002Fdocs\u002Fplugins\u002Fevents\u002F#un-webhook-sans-code\">Les événements\u003C\u002Fa>.\u003C\u002Fp>\n\u003Cp>\u003Ccode>plugins\u002Fdiscord\u002Fescouade-plugin.json\u003C\u002Fcode>\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-json\">{\n  &quot;$schema&quot;: &quot;https:\u002F\u002Fescouade.dev\u002Fschemas\u002Fplugin-1.json&quot;,\n  &quot;id&quot;: &quot;dev.escouade.discord&quot;,\n  &quot;name&quot;: &quot;Discord&quot;,\n  &quot;version&quot;: &quot;1.1.0&quot;,\n  &quot;description&quot;: {\n    &quot;fr&quot;: &quot;Les tickets à tester et les agents qui attendent, dans un salon Discord : un webhook, sans code.&quot;,\n    &quot;en&quot;: &quot;Tickets to test and agents waiting, in a Discord channel: a webhook, no code.&quot;\n  },\n  &quot;author&quot;: { &quot;name&quot;: &quot;Escouade&quot;, &quot;url&quot;: &quot;https:\u002F\u002Fescouade.dev&quot; },\n  &quot;license&quot;: &quot;MIT&quot;,\n  &quot;repository&quot;: &quot;https:\u002F\u002Fgithub.com\u002Fguillaume-gagnaire\u002Fescouade\u002Ftree\u002Fmain\u002Fplugins\u002Fdiscord&quot;,\n  &quot;escouade&quot;: { &quot;api&quot;: &quot;1&quot;, &quot;min&quot;: &quot;2.1.0&quot; },\n  &quot;permissions&quot;: {\n    &quot;events&quot;: [&quot;ticket.column&quot;, &quot;agent.status&quot;],\n    &quot;network&quot;: [&quot;discord.com&quot;],\n    &quot;secrets&quot;: [&quot;webhook&quot;]\n  },\n  &quot;contributes&quot;: {\n    &quot;secrets&quot;: [{ &quot;key&quot;: &quot;webhook&quot;, &quot;title&quot;: { &quot;fr&quot;: &quot;Adresse du webhook Discord&quot;, &quot;en&quot;: &quot;Discord webhook URL&quot; } }],\n    &quot;webhooks&quot;: [\n      {\n        &quot;url&quot;: &quot;{{secrets.webhook}}&quot;,\n        &quot;events&quot;: [&quot;ticket.column&quot;],\n        &quot;when&quot;: { &quot;data.to&quot;: &quot;review&quot; },\n        &quot;body&quot;: {\n          &quot;content&quot;: &quot;🧪 **{{ticket.key}}** to test: {{ticket.title}} ({{project.name}})&quot;,\n          &quot;allowed_mentions&quot;: { &quot;parse&quot;: [] }\n        }\n      },\n      {\n        &quot;url&quot;: &quot;{{secrets.webhook}}&quot;,\n        &quot;events&quot;: [&quot;agent.status&quot;],\n        &quot;when&quot;: { &quot;data.status&quot;: &quot;waiting&quot; },\n        &quot;body&quot;: {\n          &quot;content&quot;: &quot;✋ **{{agent.name}}** waits for you ({{project.name}})&quot;,\n          &quot;allowed_mentions&quot;: { &quot;parse&quot;: [] }\n        }\n      }\n    ]\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Dans Discord, crée le webhook dans les réglages du salon (Intégrations › Webhooks), puis colle son adresse dans l’onglet « Réglages » du plugin. \u003Ccode class=\"key\">when\u003C\u002Fcode> ne laisse passer que l’arrivée dans « À tester » et le statut \u003Ccode class=\"key\">waiting\u003C\u002Fcode> ; les autres déplacements et statuts ne disent rien. \u003Ccode class=\"key\">allowed_mentions\u003C\u002Fcode> vide : un \u003Ccode class=\"key\">@everyone\u003C\u002Fcode> dans le titre d’un ticket venu d’ailleurs (une issue publique) s’affiche sans notifier le salon.\u003C\u002Fp>\n\u003Ch2 id=\"slack\">Slack\u003C\u002Fh2>\n\u003Cp>Un programme en TypeScript, écrit avec le \u003Ca href=\"\u002Fdocs\u002Fplugins\u002Fsdk\u002F\">SDK\u003C\u002Fa>. Il poste dans le salon de chaque projet : celui que donne \u003Ccode class=\"key\">channels.json\u003C\u002Fcode>, sinon \u003Ccode>escouade-&lt;nom du projet&gt;\u003C\u002Fcode>. Le jeton du bot (\u003Ccode class=\"key\">xoxb-…\u003C\u002Fcode>) est demandé à Escouade à chaque fait (\u003Ccode class=\"key\">secrets\u002Fget\u003C\u002Fcode>), jamais écrit dans un fichier.\u003C\u002Fp>\n\u003Cp>\u003Ccode>plugins\u002Fslack\u002Fescouade-plugin.json\u003C\u002Fcode>\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-json\">{\n  &quot;$schema&quot;: &quot;https:\u002F\u002Fescouade.dev\u002Fschemas\u002Fplugin-1.json&quot;,\n  &quot;id&quot;: &quot;dev.escouade.slack&quot;,\n  &quot;name&quot;: &quot;Slack&quot;,\n  &quot;version&quot;: &quot;2.0.0&quot;,\n  &quot;description&quot;: {\n    &quot;fr&quot;: &quot;Les tickets à tester et les agents qui attendent, dans le salon Slack de chaque projet.&quot;,\n    &quot;en&quot;: &quot;Tickets to test and agents waiting, in each project&#39;s Slack channel.&quot;\n  },\n  &quot;author&quot;: { &quot;name&quot;: &quot;Escouade&quot;, &quot;url&quot;: &quot;https:\u002F\u002Fescouade.dev&quot; },\n  &quot;license&quot;: &quot;MIT&quot;,\n  &quot;repository&quot;: &quot;https:\u002F\u002Fgithub.com\u002Fguillaume-gagnaire\u002Fescouade\u002Ftree\u002Fmain\u002Fplugins\u002Fslack&quot;,\n  &quot;escouade&quot;: { &quot;api&quot;: &quot;1&quot;, &quot;min&quot;: &quot;2.2.0&quot; },\n  &quot;run&quot;: { &quot;js&quot;: &quot;dist\u002Fplugin.mjs&quot; },\n  &quot;activation&quot;: [&quot;event:ticket.column&quot;, &quot;event:agent.status&quot;],\n  &quot;permissions&quot;: {\n    &quot;events&quot;: [&quot;ticket.column&quot;, &quot;agent.status&quot;],\n    &quot;network&quot;: [&quot;slack.com&quot;],\n    &quot;secrets&quot;: [&quot;token&quot;]\n  },\n  &quot;contributes&quot;: {\n    &quot;secrets&quot;: [{ &quot;key&quot;: &quot;token&quot;, &quot;title&quot;: { &quot;fr&quot;: &quot;Jeton du bot Slack (xoxb-…)&quot;, &quot;en&quot;: &quot;Slack bot token (xoxb-…)&quot; } }]\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>\u003Ccode>plugins\u002Fslack\u002Fchannels.json\u003C\u002Fcode>\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-json\">{\n  &quot;Escouade&quot;: &quot;escouade-dev&quot;\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>\u003Ccode>plugins\u002Fslack\u002Fsrc\u002Fplugin.ts\u003C\u002Fcode>\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-ts\">\u002F\u002F Slack: the tickets to test and the agents waiting, in each project&#39;s channel. The bot&#39;s token is kept in the\n\u002F\u002F system&#39;s keychain (permissions.secrets), never in a file; the channel of a project comes from channels.json, or\n\u002F\u002F from the project&#39;s name (escouade-&lt;name&gt;).\nimport { readFileSync } from &#39;node:fs&#39;;\nimport { createPlugin, type Fact, type Plugin } from &#39;@escouade-dev\u002Fplugin&#39;;\n\nexport interface Options {\n  \u002F** The channel of each project, by the project&#39;s name. *\u002F\n  channels?: Record&lt;string, string&gt;;\n}\n\n\u002F** channels.json, beside escouade-plugin.json (src\u002F and dist\u002F are one level below it). *\u002F\nfunction readChannels(): Record&lt;string, string&gt; {\n  try {\n    return JSON.parse(readFileSync(new URL(&#39;..\u002Fchannels.json&#39;, globalThis._importMeta_.url), &#39;utf8&#39;));\n  } catch {\n    return {};\n  }\n}\n\n\u002F** The channel of a project: the one channels.json gives it, else escouade-&lt;its name&gt;, as Slack writes names. *\u002F\nexport function channelOf(project: string, channels: Record&lt;string, string&gt;): string {\n  const named = channels[project];\n  if (named) return named.replace(\u002F^#\u002F, &#39;&#39;);\n  const slug = project\n    .normalize(&#39;NFD&#39;)\n    .replace(\u002F[̀-ͯ]\u002Fg, &#39;&#39;)\n    .toLowerCase()\n    .replace(\u002F[^a-z0-9_-]+\u002Fg, &#39;-&#39;)\n    .replace(\u002F^-+|-+$\u002Fg, &#39;&#39;);\n  return `escouade-${slug || &#39;project&#39;}`.slice(0, 80);\n}\n\n\u002F** A text from elsewhere (a ticket&#39;s title may come from a public issue), as Slack asks: `&lt;!channel&gt;` or `&lt;@U…&gt;` in it\n * is shown, never a mention that notifies. *\u002F\nexport const escaped = (text: string) =&gt; text.replace(\u002F&amp;\u002Fg, &#39;&amp;amp;&#39;).replace(\u002F&lt;\u002Fg, &#39;&amp;lt;&#39;).replace(\u002F&gt;\u002Fg, &#39;&amp;gt;&#39;);\n\n\u002F** What a fact says in Slack, or nothing for the facts it keeps quiet about. *\u002F\nexport function textOf(fact: Fact): string | null {\n  if (fact.name === &#39;ticket.column&#39; &amp;&amp; fact.data.to === &#39;review&#39;) {\n    return `:test_tube: *${escaped(fact.ticket?.key ?? &#39;&#39;)}* to test: ${escaped(fact.ticket?.title ?? &#39;&#39;)}`;\n  }\n  if (fact.name === &#39;agent.status&#39; &amp;&amp; fact.data.status === &#39;waiting&#39;) {\n    return `:raised_hand: *${escaped(fact.agent?.name ?? &#39;An agent&#39;)}* waits for you`;\n  }\n  return null;\n}\n\nexport function slack(options: Options = {}): Plugin {\n  const channels = options.channels ?? readChannels();\n\n  return createPlugin().onEvent([&#39;ticket.column&#39;, &#39;agent.status&#39;], async (fact, p) =&gt; {\n    const text = textOf(fact);\n    if (!text || !fact.project) return;\n    const token = await p.secret(&#39;token&#39;);\n    if (!token) return p.log.warn(&#39;no Slack token kept: nothing sent (Plugins › Slack › Settings)&#39;);\n    \u002F\u002F A bot token, never anything else, goes to Slack (and never into the journal).\n    if (!token.startsWith(&#39;xoxb-&#39;)) return p.log.warn(&#39;the token kept is not a Slack bot token (xoxb-…): nothing sent&#39;);\n    const channel = channelOf(fact.project.name, channels);\n    try {\n      const res = await fetch(&#39;https:\u002F\u002Fslack.com\u002Fapi\u002Fchat.postMessage&#39;, {\n        method: &#39;POST&#39;,\n        headers: { authorization: `Bearer ${token}`, &#39;content-type&#39;: &#39;application\u002Fjson; charset=utf-8&#39; },\n        body: JSON.stringify({ channel, text }),\n      });\n      const answer = (await res.json()) as { ok: boolean; error?: string };\n      if (answer.ok) p.log.info(`posted in #${channel}`);\n      else p.log.warn(`Slack refused the message in #${channel}: ${answer.error ?? res.status}`);\n    } catch (e) {\n      p.log.error(`Slack could not be reached: ${(e as Error).message}`);\n    }\n  });\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>\u003Ccode>plugins\u002Fslack\u002Fsrc\u002Fmain.ts\u003C\u002Fcode>\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-ts\">\u002F\u002F The program Escouade starts (run in escouade-plugin.json): speaks the protocol on stdin and stdout.\nimport { slack } from &#39;.\u002Fplugin.ts&#39;;\n\nvoid slack().start();\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Dans Slack, crée une app avec un bot qui a le droit \u003Ccode class=\"key\">chat:write\u003C\u002Fcode>, invite-le dans les salons, puis colle son jeton dans l’onglet « Réglages » du plugin. Sans jeton, le journal le dit et rien ne part. Le titre d’un ticket est échappé (\u003Ccode class=\"key\">&amp;\u003C\u002Fcode>, \u003Ccode class=\"key\">&lt;\u003C\u002Fcode>, \u003Ccode class=\"key\">&gt;\u003C\u002Fcode>) comme Slack le demande : un \u003Ccode class=\"key\">&lt;!channel&gt;\u003C\u002Fcode> venu d’une issue publique s’affiche sans notifier personne.\u003C\u002Fp>\n\u003Cp>Ses tests jouent ses gestionnaires contre le faux Escouade du SDK et un faux Slack, sans réseau (\u003Ccode>npm test\u003C\u002Fcode>). \u003Ccode>npm run build\u003C\u002Fcode> réunit \u003Ccode class=\"key\">src\u002F\u003C\u002Fcode> dans \u003Ccode class=\"key\">dist\u002Fplugin.mjs\u003C\u002Fcode>, que nomme \u003Ccode class=\"key\">run.js\u003C\u002Fcode> : commité, c’est le bundle que le store compile en WASM, sans \u003Ccode>npm install\u003C\u002Fcode>. \u003Ccode class=\"key\">start()\u003C\u002Fcode> parle à l’hôte WASM d’Escouade, ou sur l’entrée et la sortie standard sous Node (ses tests, le banc). Son \u003Ccode class=\"key\">fetch\u003C\u002Fcode> vers \u003Ccode>slack.com\u002Fapi\u002Fchat.postMessage\u003C\u002Fcode> passe par Escouade, qui ne le laisse joindre que \u003Ccode class=\"key\">slack.com\u003C\u002Fcode> et ses sous-domaines, en https ; \u003Ccode class=\"key\">channels.json\u003C\u002Fcode>, un fichier JSON de la racine de son dossier, lui est donné au démarrage, et \u003Ccode class=\"key\">readFileSync\u003C\u002Fcode> le lit comme sous Node.\u003C\u002Fp>\n\u003Ch2 id=\"regles-de-l-equipe\">Règles de l’équipe\u003C\u002Fh2>\n\u003Cp>Un programme en TypeScript qui décide des autorisations avant qu’elles n’arrivent jusqu’à toi (voir \u003Ca href=\"\u002Fdocs\u002Fplugins\u002Fpermission-rules\u002F\">Les règles d’autorisation\u003C\u002Fa>). Ses règles sont dans \u003Ccode class=\"key\">rules.json\u003C\u002Fcode>, un fichier du dépôt du plugin que l’équipe relit comme son code. Une ligne qui contient une commande refusée est refusée, même après un \u003Ccode class=\"key\">&amp;&amp;\u003C\u002Fcode> ou un \u003Ccode class=\"key\">&amp;\u003C\u002Fcode> ; une ligne faite seulement de commandes permises est autorisée, sauf si elle contient une substitution (\u003Ccode class=\"key\">$(…)\u003C\u002Fcode>, accents graves, \u003Ccode class=\"key\">$VAR\u003C\u002Fcode>), une redirection (\u003Ccode class=\"key\">&gt;\u003C\u002Fcode>, \u003Ccode class=\"key\">&lt;\u003C\u002Fcode>), un échappement, qui feraient passer autre chose derrière un début permis, ou un \u003Ccode class=\"key\">--output\u003C\u002Fcode> (avec lui, \u003Ccode>git diff\u003C\u002Fcode> et \u003Ccode>git log\u003C\u002Fcode> écrivent un fichier) ; le reste va à l’utilisateur. Une règle compare le début d’une commande : \u003Ccode class=\"key\">deny\u003C\u002Fcode> est un filet (\u003Ccode>sudo rm -rf\u003C\u002Fcode> lui échappe et va à l’utilisateur), et \u003Ccode class=\"key\">allow\u003C\u002Fcode> permet tous les arguments.\u003C\u002Fp>\n\u003Cp>\u003Ccode>plugins\u002Fteam-rules\u002Fescouade-plugin.json\u003C\u002Fcode>\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-json\">{\n  &quot;$schema&quot;: &quot;https:\u002F\u002Fescouade.dev\u002Fschemas\u002Fplugin-1.json&quot;,\n  &quot;id&quot;: &quot;dev.escouade.team-rules&quot;,\n  &quot;name&quot;: { &quot;fr&quot;: &quot;Règles de l&#39;équipe&quot;, &quot;en&quot;: &quot;Team rules&quot; },\n  &quot;version&quot;: &quot;2.0.0&quot;,\n  &quot;description&quot;: {\n    &quot;fr&quot;: &quot;Autorise ou refuse les commandes des agents selon les règles de l&#39;équipe, écrites dans rules.json.&quot;,\n    &quot;en&quot;: &quot;Allows or denies the agents&#39; commands by the team&#39;s rules, written in rules.json.&quot;\n  },\n  &quot;author&quot;: { &quot;name&quot;: &quot;Escouade&quot;, &quot;url&quot;: &quot;https:\u002F\u002Fescouade.dev&quot; },\n  &quot;license&quot;: &quot;MIT&quot;,\n  &quot;repository&quot;: &quot;https:\u002F\u002Fgithub.com\u002Fguillaume-gagnaire\u002Fescouade\u002Ftree\u002Fmain\u002Fplugins\u002Fteam-rules&quot;,\n  &quot;escouade&quot;: { &quot;api&quot;: &quot;1&quot;, &quot;min&quot;: &quot;2.2.0&quot; },\n  &quot;run&quot;: { &quot;js&quot;: &quot;dist\u002Fplugin.mjs&quot; },\n  &quot;activation&quot;: [&quot;event:request.permission&quot;],\n  &quot;permissions&quot;: { &quot;decide&quot;: true }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>\u003Ccode>plugins\u002Fteam-rules\u002Frules.json\u003C\u002Fcode>\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-json\">{\n  &quot;deny&quot;: [\n    &quot;rm -rf&quot;,\n    &quot;git reset --hard&quot;,\n    { &quot;command&quot;: &quot;git push --force&quot;, &quot;message&quot;: &quot;No force push in this team: push a new commit instead.&quot; },\n    { &quot;command&quot;: &quot;git push -f&quot;, &quot;message&quot;: &quot;No force push in this team: push a new commit instead.&quot; }\n  ],\n  &quot;allow&quot;: [&quot;npm test&quot;, &quot;npm run lint&quot;, &quot;cargo test&quot;, &quot;cargo nextest&quot;, &quot;git status&quot;, &quot;git diff&quot;, &quot;git log&quot;]\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>\u003Ccode>plugins\u002Fteam-rules\u002Fsrc\u002Fplugin.ts\u003C\u002Fcode>\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-ts\">\u002F\u002F Team rules: the agents&#39; commands allowed or denied by rules.json, a file of the plugin&#39;s repository that the team\n\u002F\u002F keeps under review like its code. A command that holds a denied one is denied; a command made only of allowed ones is\n\u002F\u002F allowed; anything else goes to the user, as without the plugin. A refusal of any plugin wins.\nimport { readFileSync } from &#39;node:fs&#39;;\nimport { createPlugin, type Decision, type Plugin } from &#39;@escouade-dev\u002Fplugin&#39;;\n\nexport interface Rules {\n  \u002F** Commands denied, by their start: a text, or a text and the message the agent is given. *\u002F\n  deny: Array&lt;string | { command: string; message?: string }&gt;;\n  \u002F** Commands allowed, by their start. *\u002F\n  allow: string[];\n}\n\n\u002F** rules.json, beside escouade-plugin.json (src\u002F and dist\u002F are one level below it): read once, a decision has 500 ms. *\u002F\nexport function readRules(): Rules {\n  return JSON.parse(readFileSync(new URL(&#39;..\u002Frules.json&#39;, globalThis._importMeta_.url), &#39;utf8&#39;));\n}\n\n\u002F** The commands of a line: `cd app &amp;&amp; rm -rf build` is two, each judged on its own (`&amp;` too, which sends one away). *\u002F\nexport function commandsOf(line: string): string[] {\n  return line\n    .split(\u002F&amp;&amp;|\\|\\||[;|&amp;\\n]\u002F)\n    .map((c) =&gt; c.trim().replace(\u002F\\s+\u002Fg, &#39; &#39;))\n    .filter(Boolean);\n}\n\n\u002F** What makes a line more than its commands: a substitution (`$(…)`, backquotes, `$VAR`), a redirection (`&gt;`, `&lt;`,\n * `&lt;(…)`), an escape. A rule judges a command by its start, so a line that holds one of these is never allowed:\n * `git diff &gt; ~\u002F.bashrc` starts like `git diff`. *\u002F\nconst HIDDEN = \u002F[`$&lt;&gt;\\\\]\u002F;\n\n\u002F** An option that makes a command write a file of its own choosing: `git diff --output=~\u002F.bashrc`, `git log --output …`\n * (git takes no abbreviation of it). A redirection by another name: a line that holds it, its quotes taken out as the\n * shell takes them out, is never allowed. *\u002F\nconst WRITES = \u002F(^|\\s)--output(=|\\s|$)\u002F;\nconst writes = (line: string) =&gt; WRITES.test(line.replace(\u002F[&quot;&#39;]\u002Fg, &#39;&#39;));\n\n\u002F** Whether `command` starts with `rule`, word for word (`npm testing` is not `npm test`). *\u002F\nconst startsWith = (command: string, rule: string) =&gt; command === rule || command.startsWith(rule + &#39; &#39;);\n\nexport function decide(rules: Rules, line: string): Decision {\n  const commands = commandsOf(line);\n  for (const rule of rules.deny) {\n    const { command, message } = typeof rule === &#39;string&#39; ? { command: rule, message: undefined } : rule;\n    if (commands.some((c) =&gt; startsWith(c, command))) {\n      return { decision: &#39;deny&#39;, message: message ?? `The team&#39;s rules forbid \\`${command}\\`.` };\n    }\n  }\n  const plain = !HIDDEN.test(line) &amp;&amp; !writes(line);\n  if (plain &amp;&amp; commands.length &gt; 0 &amp;&amp; commands.every((c) =&gt; rules.allow.some((rule) =&gt; startsWith(c, rule)))) {\n    return { decision: &#39;allow&#39; };\n  }\n  return { decision: &#39;ask&#39; };\n}\n\nexport function teamRules(rules: Rules = readRules()): Plugin {\n  return createPlugin().decidePermission(({ request }) =&gt; {\n    const line = request.input?.command;\n    \u002F\u002F Only commands: an edit or a fetch goes to the user.\n    if (request.kind !== &#39;command&#39; || typeof line !== &#39;string&#39;) return { decision: &#39;ask&#39; };\n    return decide(rules, line);\n  });\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>\u003Ccode>plugins\u002Fteam-rules\u002Fsrc\u002Fmain.ts\u003C\u002Fcode>\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-ts\">\u002F\u002F The program Escouade starts (run in escouade-plugin.json): speaks the protocol on stdin and stdout.\nimport { teamRules } from &#39;.\u002Fplugin.ts&#39;;\n\nvoid teamRules().start();\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Cp>Les règles sont lues une fois, au démarrage : une décision a 500 ms. En WASM, \u003Ccode class=\"key\">rules.json\u003C\u002Fcode> lui est donné au démarrage avec les autres fichiers JSON de la racine de son dossier ; il ne lance rien et n’atteint aucun réseau (il n’a pas de \u003Ccode class=\"key\">permissions.network\u003C\u002Fcode>). Une modification ou une lecture de fichier n’est pas une commande, et va à l’utilisateur.\u003C\u002Fp>\n\u003Ch2 id=\"linear\">Linear\u003C\u002Fh2>\n\u003Cp>Une source du Kanban en Rust : \u003Ccode class=\"key\">Hosts\u003C\u002Fcode> du kit répond le protocole, et le plugin donne sa sorte et son client. Le modèle \u003Ccode>npm create escouade-plugin -- --kind rust\u003C\u002Fcode>, plus court, part d’une page blanche ; Linear montre un vrai service. Il est lu dans \u003Ca href=\"\u002Fdocs\u002Fplugins\u002Frust\u002F#ecrire-une-source\">le kit Rust\u003C\u002Fa>, et son code est dans \u003Ca href=\"https:\u002F\u002Fgithub.com\u002Fguillaume-gagnaire\u002Fescouade\u002Ftree\u002Fmain\u002Fplugins\u002Flinear\" rel=\"noopener\">\u003Ccode class=\"key\">plugins\u002Flinear\u003C\u002Fcode>\u003C\u002Fa>.\n\u003Ccode>plugins\u002Flinear\u002Fsrc\u002Flib.rs\u003C\u002Fcode> (extrait)\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-rust\">impl SourceKind for LinearSource {\n    fn id(&amp;self) -&gt; &amp;str {\n        ID\n    }\n    fn name(&amp;self) -&gt; &amp;str {\n        S\n    }\n    fn client(&amp;self, http: reqwest::Client, account: &amp;Credentials) -&gt; Box&lt;dyn TicketSource&gt; {\n        let base = account.api.clone().filter(|a| !a.trim().is_empty());\n        Box::new(Linear::new(http, account, base.as_deref().unwrap_or(API)))\n    }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch2 id=\"goose\">Goose\u003C\u002Fh2>\n\u003Cp>Un harness sans code : Goose, l’agent open source de Block, décrit par ACP. Le plugin ACP livré avec Escouade le lance et montre sa conversation ; le manifeste dit ses modes, son mode qui ne change rien, son option de modèle et le nom de ses outils. Goose écrit ses fichiers lui-même : aucun agent d’un projet privé ne démarre sur lui. Voir \u003Ca href=\"\u002Fdocs\u002Fplugins\u002Fharnesses\u002F\">Un harness ACP décrit\u003C\u002Fa>.\u003C\u002Fp>\n\u003Cp>\u003Ccode>plugins\u002Fgoose\u002Fescouade-plugin.json\u003C\u002Fcode>\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-json\">{\n  &quot;$schema&quot;: &quot;https:\u002F\u002Fescouade.dev\u002Fschemas\u002Fplugin-1.json&quot;,\n  &quot;id&quot;: &quot;dev.escouade.goose&quot;,\n  &quot;name&quot;: &quot;Goose&quot;,\n  &quot;version&quot;: &quot;1.0.0&quot;,\n  &quot;description&quot;: {\n    &quot;fr&quot;: &quot;Goose, l&#39;agent open source de Block, comme harness : décrit par ACP, sans code.&quot;,\n    &quot;en&quot;: &quot;Goose, Block&#39;s open source agent, as a harness: described over ACP, no code.&quot;\n  },\n  &quot;author&quot;: { &quot;name&quot;: &quot;Escouade&quot;, &quot;url&quot;: &quot;https:\u002F\u002Fescouade.dev&quot; },\n  &quot;license&quot;: &quot;MIT&quot;,\n  &quot;repository&quot;: &quot;https:\u002F\u002Fgithub.com\u002Fguillaume-gagnaire\u002Fescouade\u002Ftree\u002Fmain\u002Fplugins\u002Fgoose&quot;,\n  &quot;escouade&quot;: { &quot;api&quot;: &quot;1&quot;, &quot;min&quot;: &quot;2.1.0&quot; },\n  &quot;contributes&quot;: {\n    &quot;harnesses&quot;: [\n      {\n        &quot;id&quot;: &quot;goose&quot;,\n        &quot;name&quot;: &quot;Goose&quot;,\n        &quot;speaker&quot;: &quot;Goose&quot;,\n        &quot;writer&quot;: &quot;Goose&quot;,\n        &quot;locate&quot;: { &quot;names&quot;: [&quot;goose&quot;] },\n        &quot;tested&quot;: { &quot;oldest&quot;: &quot;1.57.0&quot;, &quot;newest&quot;: &quot;1.57.0&quot; },\n        &quot;caps&quot;: {\n          &quot;modes&quot;: [\n            {\n              &quot;id&quot;: &quot;default&quot;,\n              &quot;meaning&quot;: {\n                &quot;fr&quot;: &quot;Goose demande avant chaque outil (son mode approve). Il écrit les fichiers lui-même, sans les faire passer par Escouade.&quot;,\n                &quot;en&quot;: &quot;Goose asks before each tool (its approve mode). It writes files by itself, without passing them through Escouade.&quot;\n              }\n            },\n            {\n              &quot;id&quot;: &quot;acceptEdits&quot;,\n              &quot;meaning&quot;: {\n                &quot;fr&quot;: &quot;Goose ne demande que pour ce qu&#39;il juge sensible (son mode smart_approve) ; Escouade accepte ses écritures dans le dossier de l&#39;agent.&quot;,\n                &quot;en&quot;: &quot;Goose asks only for what it deems sensitive (its smart_approve mode); Escouade accepts its writes in the agent&#39;s folder.&quot;\n              }\n            },\n            {\n              &quot;id&quot;: &quot;bypassPermissions&quot;,\n              &quot;meaning&quot;: {\n                &quot;fr&quot;: &quot;Goose ne demande rien (son mode auto) : commandes, fichiers, tout le disque. Pas de bac à sable.&quot;,\n                &quot;en&quot;: &quot;Goose asks nothing (its auto mode): commands, files, the whole disk. No sandbox.&quot;\n              }\n            }\n          ],\n          &quot;readOnly&quot;: true\n        },\n        &quot;accountVar&quot;: &quot;GOOSE_PATH_ROOT&quot;,\n        &quot;install&quot;: {\n          &quot;windows&quot;: &quot;Invoke-WebRequest -Uri https:\u002F\u002Fraw.githubusercontent.com\u002Faaif-goose\u002Fgoose\u002Fmain\u002Fdownload_cli.ps1 -OutFile download_cli.ps1; .\u002Fdownload_cli.ps1&quot;,\n          &quot;unix&quot;: &quot;curl -fsSL https:\u002F\u002Fgithub.com\u002Faaif-goose\u002Fgoose\u002Freleases\u002Fdownload\u002Fstable\u002Fdownload_cli.sh | CONFIGURE=false bash&quot;\n        },\n        &quot;updateCommand&quot;: &quot;goose update&quot;,\n        &quot;acp&quot;: {\n          &quot;args&quot;: [&quot;acp&quot;],\n          &quot;fs&quot;: false,\n          &quot;modes&quot;: { &quot;default&quot;: &quot;approve&quot;, &quot;acceptEdits&quot;: &quot;smart_approve&quot;, &quot;bypassPermissions&quot;: &quot;auto&quot; },\n          &quot;readOnlyMode&quot;: &quot;chat&quot;,\n          &quot;modelOption&quot;: &quot;model&quot;,\n          &quot;questionsInText&quot;: true,\n          &quot;tools&quot;: { &quot;shell&quot;: &quot;Bash&quot;, &quot;write&quot;: &quot;Write&quot;, &quot;edit&quot;: &quot;Edit&quot;, &quot;text_editor&quot;: &quot;Edit&quot;, &quot;read&quot;: &quot;Read&quot;, &quot;tree&quot;: &quot;Glob&quot; }\n        }\n      }\n    ]\n  }\n}\n\u003C\u002Fcode>\u003C\u002Fpre>\n",[8,12,15,18,21,24,27,30],{"id":9,"text":10,"depth":11},"ou-les-trouver","Où les trouver",2,{"id":13,"text":14,"depth":11},"les-essayer","Les essayer",{"id":16,"text":17,"depth":11},"contraste-eleve","Contraste élevé",{"id":19,"text":20,"depth":11},"discord","Discord",{"id":22,"text":23,"depth":11},"slack","Slack",{"id":25,"text":26,"depth":11},"regles-de-l-equipe","Règles de l’équipe",{"id":28,"text":29,"depth":11},"linear","Linear",{"id":31,"text":32,"depth":11},"goose","Goose",1793889380082]