[{"data":1,"prerenderedAt":33},["ShallowReactive",2],{"doc:en:plugins\u002Fexamples":3},{"title":4,"lede":5,"html":6,"headings":7},"Example plugins","Five complete plugins to read, copy and install: a theme, a Discord webhook with no code, Slack and a team’s rules in TypeScript, and Goose, a harness described with no code. They are Escouade’s own plugins, on its store; each one passes the conformance bench on every change.","\u003Ch2 id=\"where-to-find-them\">Where to find them\u003C\u002Fh2>\n\u003Cp>They are in the \u003Ccode class=\"key\">plugins\u002F\u003C\u002Fcode> folder of Escouade’s repository, one folder per plugin (\u003Ccode class=\"key\">plugins\u002Fslack\u002F\u003C\u002Fcode>…), each readable on its own with its manifest, its README, its LICENSE (MIT), its CHANGELOG and its 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>. The store publishes them as \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> and \u003Ccode class=\"key\">dev.escouade.goose\u003C\u002Fcode>, taken from these folders at their tags \u003Ccode>plugins\u002F&lt;folder&gt;\u002Fv1.2.3\u003C\u002Fcode> (see \u003Ca href=\"\u002Fen\u002Fdocs\u002Fplugins\u002Fpublishing\u002F\">Publishing a plugin\u003C\u002Fa>).\u003C\u002Fp>\n\u003Cp>To make one yours, copy its folder and give it an id of your own (\u003Ccode>io.github.&lt;your-login&gt;.slack\u003C\u002Fcode>): \u003Ccode class=\"key\">dev.escouade.*\u003C\u002Fcode> is kept for Escouade’s own plugins, and the store takes it from Escouade’s repository alone.\u003C\u002Fp>\n\u003Cdiv class=\"table\">\u003Ctable>\n\u003Cthead>\n\u003Ctr>\n\u003Cth>Example\u003C\u002Fth>\n\u003Cth>What it shows\u003C\u002Fth>\n\u003Cth>Kind\u003C\u002Fth>\n\u003C\u002Ftr>\n\u003C\u002Fthead>\n\u003Ctbody>\u003Ctr>\n\u003Ctd>High contrast\u003C\u002Ftd>\n\u003Ctd>A theme, dark and light, and a palette of project colors\u003C\u002Ftd>\n\u003Ctd>Declarative\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Discord\u003C\u002Ftd>\n\u003Ctd>Tickets to review and agents waiting, in a Discord channel\u003C\u002Ftd>\n\u003Ctd>Declarative\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Slack\u003C\u002Ftd>\n\u003Ctd>The same in Slack, in each project’s channel, with a secret in the keychain\u003C\u002Ftd>\n\u003Ctd>TypeScript program\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Team rules\u003C\u002Ftd>\n\u003Ctd>The agents’ commands allowed or denied by a file of the repository\u003C\u002Ftd>\n\u003Ctd>TypeScript program\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Goose\u003C\u002Ftd>\n\u003Ctd>An agent that speaks ACP, added as a harness\u003C\u002Ftd>\n\u003Ctd>Declarative\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003Ctr>\n\u003Ctd>Linear\u003C\u002Ftd>\n\u003Ctd>A source of the Kanban, on the kit\u003C\u002Ftd>\n\u003Ctd>Rust module\u003C\u002Ftd>\n\u003C\u002Ftr>\n\u003C\u002Ftbody>\u003C\u002Ftable>\u003C\u002Fdiv>\n\u003Cp>The code shown here is the repository’s: a test of the site checks it has not changed since.\u003C\u002Fp>\n\u003Cdiv class=\"callout note\">\u003Cp class=\"callout-title\">Note\u003C\u002Fp>\u003Cp>Slack and Team rules are \u003Ccode class=\"key\">run.js\u003C\u002Fcode> plugins since their 2.0.0: Escouade runs their \u003Ccode class=\"key\">dist\u002Fplugin.mjs\u003C\u002Fcode> inside the app, in WASM, with no Node on the machine (see \u003Ca href=\"\u002Fen\u002Fdocs\u002Fplugins\u002Fapi\u002F#a-program-in-wasm\">the protocol\u003C\u002Fa>). They need Escouade 2.2.0 or later (\u003Ccode class=\"key\">escouade.min\u003C\u002Fcode>); their 1.x versions, started through Node, stay on the store for the versions before. Node only serves their authors, to build and test them.\u003C\u002Fp>\n\u003C\u002Fdiv>\n\u003Ch2 id=\"trying-them\">Trying them\u003C\u002Fh2>\n\u003Cp>Install them from the store, in \u003Cstrong>Plugins\u003C\u002Fstrong>. To change one: “In development”, pick an example’s folder; it is loaded in place and read again when it changes. Enter its secrets in the “Settings” tab of its page.\u003C\u002Fp>\n\u003Cp>To check them as the repository’s CI does: each one passes its tests (and, in TypeScript, its typecheck; its \u003Ccode class=\"key\">dist\u002Fplugin.mjs\u003C\u002Fcode> is compared to the build of \u003Ccode class=\"key\">src\u002F\u003C\u002Fcode>), then \u003Ccode>escouade-plugin validate\u003C\u002Fcode> and the bench, with \u003Ccode class=\"key\">--first-party\u003C\u002Fcode> for their reserved ids (see \u003Ca href=\"\u002Fen\u002Fdocs\u002Fplugins\u002Ftesting\u002F\">Testing a plugin\u003C\u002Fa>).\u003C\u002Fp>\n\u003Cpre>\u003Ccode class=\"language-sh\">npx tsc -p sdk\u002Fplugin\u002Ftsconfig.json\nESCOUADE_PLUGIN_BIN=\u002Fpath\u002Fto\u002Fescouade-plugin node plugins\u002Fci.mjs\n\u003C\u002Fcode>\u003C\u002Fpre>\n\u003Ch2 id=\"high-contrast\">High contrast\u003C\u002Fh2>\n\u003Cp>A theme with no code: values for the window’s tokens, dark and light, and a palette for project colors. Escouade filters each value before it sets it; a token left out keeps Escouade’s. See \u003Ca href=\"\u002Fen\u002Fdocs\u002Fplugins\u002Fthemes\u002F\">Themes\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>Pick it in Settings › Application, “Colors” row.\u003C\u002Fp>\n\u003Ch2 id=\"discord\">Discord\u003C\u002Fh2>\n\u003Cp>Two declared webhooks, no program: a ticket moved to “To review”, and an agent waiting for your answer. The Discord webhook’s address is a secret: it stays in the system’s keychain and never shows in the journal. See \u003Ca href=\"\u002Fen\u002Fdocs\u002Fplugins\u002Fevents\u002F#a-webhook-without-code\">Events\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>In Discord, create the webhook in the channel’s settings (Integrations › Webhooks), then paste its address in the plugin’s “Settings” tab. \u003Ccode class=\"key\">when\u003C\u002Fcode> lets through only the move to “To review” and the \u003Ccode class=\"key\">waiting\u003C\u002Fcode> status; the other moves and statuses say nothing. An empty \u003Ccode class=\"key\">allowed_mentions\u003C\u002Fcode>: an \u003Ccode class=\"key\">@everyone\u003C\u002Fcode> in the title of a ticket from elsewhere (a public issue) shows without notifying the channel.\u003C\u002Fp>\n\u003Ch2 id=\"slack\">Slack\u003C\u002Fh2>\n\u003Cp>A TypeScript program, written with the \u003Ca href=\"\u002Fen\u002Fdocs\u002Fplugins\u002Fsdk\u002F\">SDK\u003C\u002Fa>. It posts in each project’s channel: the one \u003Ccode class=\"key\">channels.json\u003C\u002Fcode> gives, else \u003Ccode>escouade-&lt;project name&gt;\u003C\u002Fcode>. The bot’s token (\u003Ccode class=\"key\">xoxb-…\u003C\u002Fcode>) is asked of Escouade on each fact (\u003Ccode class=\"key\">secrets\u002Fget\u003C\u002Fcode>), never written in a file.\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>In Slack, create an app with a bot that has the \u003Ccode class=\"key\">chat:write\u003C\u002Fcode> scope, invite it to the channels, then paste its token in the plugin’s “Settings” tab. Without a token, the journal says so and nothing is sent. A ticket’s title is escaped (\u003Ccode class=\"key\">&amp;\u003C\u002Fcode>, \u003Ccode class=\"key\">&lt;\u003C\u002Fcode>, \u003Ccode class=\"key\">&gt;\u003C\u002Fcode>) as Slack asks: a \u003Ccode class=\"key\">&lt;!channel&gt;\u003C\u002Fcode> from a public issue shows without notifying anyone.\u003C\u002Fp>\n\u003Cp>Its tests drive its handlers against the SDK’s fake Escouade and a fake Slack, with no network (\u003Ccode>npm test\u003C\u002Fcode>). \u003Ccode>npm run build\u003C\u002Fcode> bundles \u003Ccode class=\"key\">src\u002F\u003C\u002Fcode> into \u003Ccode class=\"key\">dist\u002Fplugin.mjs\u003C\u002Fcode>, which \u003Ccode class=\"key\">run.js\u003C\u002Fcode> names: committed, it is the bundle the store compiles to WASM, without \u003Ccode>npm install\u003C\u002Fcode>. \u003Ccode class=\"key\">start()\u003C\u002Fcode> speaks to Escouade’s WASM host, or on standard input and output under Node (its tests, the bench). Its \u003Ccode class=\"key\">fetch\u003C\u002Fcode> to \u003Ccode>slack.com\u002Fapi\u002Fchat.postMessage\u003C\u002Fcode> goes through Escouade, which lets it reach \u003Ccode class=\"key\">slack.com\u003C\u002Fcode> and its subdomains only, over https; \u003Ccode class=\"key\">channels.json\u003C\u002Fcode>, a JSON file at the root of its folder, is given to it at its start, and \u003Ccode class=\"key\">readFileSync\u003C\u002Fcode> reads it as under Node.\u003C\u002Fp>\n\u003Ch2 id=\"team-rules\">Team rules\u003C\u002Fh2>\n\u003Cp>A TypeScript program that decides permissions before they reach you (see \u003Ca href=\"\u002Fen\u002Fdocs\u002Fplugins\u002Fpermission-rules\u002F\">Permission rules\u003C\u002Fa>). Its rules are in \u003Ccode class=\"key\">rules.json\u003C\u002Fcode>, a file of the plugin’s repository that the team reviews like its code. A line that holds a denied command is denied, even after a \u003Ccode class=\"key\">&amp;&amp;\u003C\u002Fcode> or a \u003Ccode class=\"key\">&amp;\u003C\u002Fcode>; a line made only of allowed commands is allowed, unless it holds a substitution (\u003Ccode class=\"key\">$(…)\u003C\u002Fcode>, backquotes, \u003Ccode class=\"key\">$VAR\u003C\u002Fcode>), a redirection (\u003Ccode class=\"key\">&gt;\u003C\u002Fcode>, \u003Ccode class=\"key\">&lt;\u003C\u002Fcode>), an escape, which would slip something else behind an allowed start, or an \u003Ccode class=\"key\">--output\u003C\u002Fcode> (with it, \u003Ccode>git diff\u003C\u002Fcode> and \u003Ccode>git log\u003C\u002Fcode> write a file); the rest goes to the user. A rule compares the start of a command: \u003Ccode class=\"key\">deny\u003C\u002Fcode> is a safety net (\u003Ccode>sudo rm -rf\u003C\u002Fcode> slips past it and goes to the user), and \u003Ccode class=\"key\">allow\u003C\u002Fcode> allows any 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>The rules are read once, at start: a decision has 500 ms. In WASM, \u003Ccode class=\"key\">rules.json\u003C\u002Fcode> is given to it at its start with the other JSON files at the root of its folder; it starts nothing and reaches no network (it has no \u003Ccode class=\"key\">permissions.network\u003C\u002Fcode>). An edit or a file read is not a command, and goes to the user.\u003C\u002Fp>\n\u003Ch2 id=\"linear\">Linear\u003C\u002Fh2>\n\u003Cp>A source of the Kanban in Rust: the kit’s \u003Ccode class=\"key\">Hosts\u003C\u002Fcode> answers the protocol, and the plugin gives its kind and its client. The \u003Ccode>npm create escouade-plugin -- --kind rust\u003C\u002Fcode> template, shorter, starts from a blank page; Linear shows a real service. It is read in \u003Ca href=\"\u002Fen\u002Fdocs\u002Fplugins\u002Frust\u002F#writing-a-source\">the Rust kit\u003C\u002Fa>, and its code is in \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> (excerpt)\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>A harness without code: Goose, Block’s open source agent, described over ACP. The ACP plugin shipped with Escouade launches it and shows its conversation; the manifest says its modes, its mode that changes nothing, its model option and the names of its tools. Goose writes its files itself: no agent of a private project starts on it. See \u003Ca href=\"\u002Fen\u002Fdocs\u002Fplugins\u002Fharnesses\u002F\">A described ACP harness\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},"where-to-find-them","Where to find them",2,{"id":13,"text":14,"depth":11},"trying-them","Trying them",{"id":16,"text":17,"depth":11},"high-contrast","High contrast",{"id":19,"text":20,"depth":11},"discord","Discord",{"id":22,"text":23,"depth":11},"slack","Slack",{"id":25,"text":26,"depth":11},"team-rules","Team rules",{"id":28,"text":29,"depth":11},"linear","Linear",{"id":31,"text":32,"depth":11},"goose","Goose",1793889382557]