Annexe — Harnais pi Chapitre A5 / 42

Orchestration multi-agents & distribution en package

Le dernier chapitre de l'annexe : des sous-agents pi bornés par le code — chaîne plan → build livrée en JSON —, puis le nœud pi de plume-factory empaqueté en package pi, installable dans un dépôt vierge d'une seule commande. La configuration voyage désormais comme l'usine.

Au chapitre 27, vous avez tamponné l’usine dans un dépôt vierge : stamp.py a copié adws/, les rosters, le justfile et le skill, et le premier ADW a tourné dix minutes plus tard. Puis vous avez ouvert pi dans ce dépôt d’essai, et rien de l’annexe n’était là. Pas de pied de page qui parle la langue de l’usine, pas de damage control, pas de trace harnais : le manifeste du stamp ne connaît pas .pi/, et le nœud pi que vous avez équipé pendant quatre chapitres est resté à la maison. À la fin de ce chapitre, vous saurez deux choses. D’abord, comment un agent pi peut en lancer d’autres, en sous-agents, équipes, chaînes ou pair-à-pair, et pourquoi la seule forme que l’usine retient est celle où le code tient la séquence : une chaîne plan → build décrite en JSON, exécutée par une extension, un sous-processus pi -p par étape. Ensuite, comment tout ce qui vit sous .pi/ devient un package pi, installable d’une commande dans n’importe quel dépôt, le miroir côté harnais du stamp du chapitre 27. La pièce du jour ferme l’annexe : le runner Python reste le propriétaire du graphe, et le nœud pi voyage désormais avec lui.

Subagents, équipes, chaînes et pair-à-pair

L’idée en une phrase

Un sous-agent est un second processus pi -p lancé par une extension, avec son propre système prompt, sa propre allowlist d’outils, son propre modèle et sa propre fenêtre de contexte. Ce qui distingue une chaîne, une équipe et le pair-à-pair, c’est qui décide de l’ordre, et dans l’usine cette décision revient au code déterministe de l’extension, jamais au modèle qui parle.

Points clés

  • Un sous-agent, c’est un processus. L’extension appelle pi.exec("pi", args) avec -p, --mode json, --model, --tools, --append-system-prompt et le prompt. Le sous-agent démarre vide : il ne voit ni votre conversation, ni les tours précédents. C’est le prix et l’intérêt de l’isolation : un contexte propre par tâche, une facture par tâche.
  • Trois formes, une différence : qui séquence. Une chaîne est une liste d’étapes fixée dans un fichier, où la sortie de l’étape N devient $INPUT de l’étape N+1, et $ORIGINAL rappelle la demande de départ. Une équipe confie au modèle principal le choix du spécialiste à appeler, via un outil dispatch_agent. Le pair-à-pair met deux agents à égalité, qui s’envoient des messages sans orchestrateur. Seule la chaîne est déterministe.
  • L’usine garde la chaîne. Le runner du chapitre 13 est une chaîne, en Python, avec des gates. La chaîne pi du jour en est la version de poche pour la session interactive : même loi, même enveloppe, sans gate bun test, et c’est pour ça qu’elle sert à explorer, jamais à livrer.
  • Les outils du sous-agent sont bornés par --tools. Un planner reçoit read,grep,find,ls et ne peut physiquement pas écrire. L’outil run_chain lui-même n’est jamais dans la liste, donc un sous-agent ne relance pas de chaîne. Le damage control du chapitre A3 se charge en plus dans chaque sous-processus, puisque vous ne passez pas --no-extensions.
  • La sortie est une enveloppe, pas un souvenir. Le texte final de l’étape (le dernier message_end d’assistant du flux JSON) traverse vers l’étape suivante avec son coût, ses jetons et sa durée. Rien d’autre ne passe : pas de session partagée, pas de mémoire implicite.

Exemple concret

Dans une session interactive de plume-factory, vous tapez : « lance la chaîne plan-build : ajouter un compteur de mots dans la barre d’état de Plume ». Le modèle principal appelle run_chain. L’extension lit .pi/agents/factory-chain.json, trouve deux étapes, et lance un premier pi -p : le planner, sur le workhorse du roster, outils en lecture seule. Une trentaine de secondes, quelques milliers de jetons, moins d’un centime, et il rend un plan numéroté. L’extension substitue ce plan dans le prompt du builder, en $INPUT, et lance un second pi -p, outils d’écriture cette fois. Deux minutes, quelques dizaines de milliers de jetons, quelques centimes, et le builder modifie deux fichiers et lance bun test. Le résultat revient dans votre session principale sous forme d’une enveloppe : deux étapes, deux factures, le texte final. Le même travail confié à votre session principale aurait mêlé le plan, le code et votre historique dans une seule fenêtre. Ici chaque étape a démarré propre, et vous savez ce que chacune a coûté. Ce que vous n’avez pas : un verdict. Pour ça, il y a uv run adws/adw_sdlc.py.

Trois façons de faire travailler plusieurs agents

FormeQui décide de l’ordreCe qui traverseReproductiblePlace dans l’usine
Chaînele fichier de chaîne (code)$INPUT / $ORIGINAL, texte + factureoui, à la variabilité du modèle prèsla version de poche du runner, pour explorer
Équipele modèle principal, via dispatch_agentle prompt que le modèle rédigenon : le dispatch change d’un run à l’autreà éviter ; c’est l’agent qui séquence
Pair-à-pairpersonne : les deux agents négocientdes messages libresnonun outil de recherche, pas une pièce d’usine
Runner Python (ch. 13)runner.py, avec gatesenveloppes JSON typéesoui, verdict comprisla référence

Config — .pi/agents/factory-chain.json, la forme d’une chaîne

Le fichier décrit deux choses : les agents (un système prompt, une allowlist d’outils) et les chaînes (des étapes, chacune avec son agent et son gabarit de prompt). Ni YAML ni Markdown à frontmatter : du JSON, comme toutes les enveloppes du livre, lisible sans dépendance depuis TypeScript comme depuis Python.

{
  "agents": {
    "planner": {
      "tools": "read,grep,find,ls",
      "system": "Vous planifiez, vous n'ecrivez jamais de fichier. Rendez un plan numerote."
    }
  },
  "chains": {
    "plan-only": {
      "description": "Un plan, rien d'autre",
      "steps": [
        { "agent": "planner", "prompt": "Planifiez : $INPUT" }
      ]
    }
  }
}

Commande — ce que l’extension lance pour une étape

Collez mentalement ce que run_chain exécute pour l’étape planner. C’est le même pi -p que l’adaptateur _run_pi du chapitre 15, avec un système prompt en plus et sans --no-extensions.

# Un sous-agent = un processus : contexte vide, outils bornés, facture séparée.
pi -p --mode json --thinking off \
  --model openrouter/z-ai/glm-5.3 \
  --tools read,grep,find,ls \
  --append-system-prompt "Vous planifiez, vous n'ecrivez jamais de fichier. Rendez un plan numerote." \
  "Planifiez : ajouter un compteur de mots dans la barre d'etat de Plume"

Piège courant : « un sous-agent partage le contexte de l’agent qui l’a lancé » est inexact. Un sous-agent est un processus neuf qui ne sait que ce que son prompt lui dit. Tout ce que vous voulez qu’il sache doit traverser explicitement, dans le gabarit de l’étape. C’est une contrainte, et c’est exactement la loi de la couture du chapitre 8.


Empaqueter sa configuration pi en package

L’idée en une phrase

Un package pi est un dossier avec un package.json portant un manifeste pi qui dit quels dossiers contiennent des extensions, des skills, des gabarits de prompt et des thèmes. pi install l’enregistre dans settings.json et pi le charge à chaque démarrage. C’est la pièce de la zone distribution de l’usine, côté code déterministe, le pendant harnais du stamp du chapitre 27.

Points clés

  • Le manifeste dit où chercher, pas quoi copier. "pi": { "extensions": [".pi/extensions"], "skills": [".claude/skills"] } : des chemins relatifs à la racine du package. Sans manifeste, pi retombe sur des dossiers conventionnels (extensions/, skills/, prompts/, themes/) qui ne sont pas les vôtres, d’où le manifeste.
  • Trois sources, une même commande. pi install npm:@scope/pkg@1.2.3, pi install git:github.com/user/repo@v1, pi install /chemin/vers/dossier. npm et git sont copiés (et npm install est lancé). Un chemin local est référencé sans copie, parfait pour un dépôt à côté du vôtre, à réserver au poste de travail.
  • -l écrit dans le projet. Sans drapeau, pi install modifie ~/.pi/agent/settings.json. Avec -l, il écrit packages dans .pi/settings.json du dépôt courant, que vous commitez, et pi installe les packages manquants au démarrage, une fois le projet approuvé.
  • Les réglages ne voyagent pas dans le package. Un package transporte des extensions, des skills, des prompts et des thèmes. defaultModel, compaction ou retry du chapitre A1 restent des réglages du projet qui les reçoit. Le dépôt cible garde ses choix, le package lui apporte des capacités.
  • Les dépendances cœur sont des peerDependencies. @earendil-works/pi-coding-agent, typebox et les autres paquets que pi embarque se déclarent en peerDependencies avec "*", jamais en dependencies : pi les fournit au chargement. Ce que le Bun de la gate a besoin de résoudre pour tester le fichier à sec vit en devDependencies.

Exemple concret

Vous ouvrez un nouveau dépôt, ../pi-essai, vierge. Depuis lui : pi install -l /chemin/vers/plume-factory. Une seconde, zéro jeton : .pi/settings.json apparaît avec une entrée packages. Vous lancez pi -p --approve --mode json "ping", le flux JSON répond, et un fichier adws/adw_data/traces/harness/…jsonl vient d’apparaître dans pi-essai : la trace du chapitre A4 a tourné, donc les extensions du package ont été chargées, dans un dépôt qui n’a ni adws/ ni runner. Le damage control est chargé aussi, avec zéro règle puisque le fichier de règles est absent : il ne bloque rien mais ne plante pas. Ce que le package n’a pas apporté : le modèle par défaut, c’est le global qui a répondu. Vous le voyez dans le champ model de la première ligne du flux. Pour un dépôt qui doit ressembler à plume-factory, le stamp copie adws/ et le package apporte .pi/ : deux commandes, une minute, et les deux côtés de la couture sont en place.

Ce que chaque canal transporte

CanalCe qui voyageCe qui resteCommande
Stamp (ch. 27)adws/, rosters, justfile, .env.sample, skillspayload, .env, adw_data/, .pi/uv run .claude/skills/factory/scripts/stamp.py --into …
Package pi (A5)extensions, skills, prompts, thèmessettings.json, règles de damage control, secretspi install -l <source>
Extension à l’essaiun fichier ou un package, pour un runtout : rien n’est écrit dans les réglagespi -e <source>
Dépôt clonétout, y compris ce qui ne devrait pasriengit clone

Config — le manifeste minimal d’un package

Le package.json que vous poserez tout à l’heure suit exactement cette forme. Ici, la version la plus courte qui fonctionne.

{
  "name": "mon-noeud-pi",
  "version": "0.1.0",
  "private": true,
  "keywords": ["pi-package"],
  "pi": {
    "extensions": [".pi/extensions"],
    "skills": [".claude/skills"]
  },
  "peerDependencies": {
    "@earendil-works/pi-coding-agent": "*"
  }
}

Commande — installer, essayer, filtrer

# Enregistrer le package dans le projet courant (écrit .pi/settings.json, à commiter)
pi install -l /chemin/absolu/vers/plume-factory

# L'essayer sans rien écrire : chargé pour ce run seulement
pi -e ../plume-factory -p --approve "ping"

# Ne charger qu'une partie : la forme objet dans .pi/settings.json
# { "packages": [ { "source": "/chemin/vers/plume-factory", "extensions": [".pi/extensions/factory-obs.ts"], "skills": [] } ] }

Piège courant : « le package emporte ma configuration pi » est inexact. Il emporte vos capacités (extensions, skills), pas vos réglages : le dépôt qui l’installe garde son modèle par défaut, sa compaction, ses règles de damage control. Si vous voulez les mêmes réglages partout, c’est un .pi/settings.json de plus à copier, et le stamp sait le faire.


Fil rouge — la pièce posée aujourd’hui

Sur le plan de l’usine, la pièce du jour ferme la zone distribution par le côté harnais : package.json à la racine, avec son manifeste pi, à côté du skill factory du chapitre 26 et du stamp.py du chapitre 27. Elle ajoute aussi, au poste de pilotage, une quatrième extension, factory-chain.ts, et son fichier de chaînes sous .pi/agents/. La couture ne bouge pas, et c’est le message de toute l’annexe : le runner Python possède le graphe des phases, et l’extension de chaîne possède le graphe d’une exploration interactive, avec la même loi (le fichier séquence, le modèle exécute une étape bornée, une enveloppe traverse : texte, coût, jetons, durée) mais sans gate : elle propose, elle ne dispose pas. Le package, lui, est entièrement déterministe : un manifeste, une commande, zéro jeton. Coût d’usage : une chaîne plan-build sur le workhorse coûte quelques centimes et deux à trois minutes, installer le package coûte une seconde. Ce qu’ils économisent : la session principale qui gonfle en mélangeant plan, code et historique, et la demi-journée de « copie ce qu’il faut de .pi/ » chaque fois que l’usine change de dépôt. Rappel de A1, tenu jusqu’au bout : l’annexe a rendu le nœud pi plus sûr (A3), plus observable (A4) et, aujourd’hui, transportable, sans jamais déplacer la couture.


Travaux pratiques — la pièce du jour

Une pièce complète à poser dans le repo compagnon plume-factory, qui devient, chapitre après chapitre, votre usine logicielle agentique. Aujourd’hui, trois fichiers : le fichier de chaînes, l’extension qui les exécute, et le package.json qui fait voyager tout le dossier .pi/.

Pièce — .pi/agents/factory-chain.json

Deux agents et une chaîne. Le planner ne peut pas écrire, le builder peut, et doit lancer bun test avant de rendre la main. Sans gate, c’est une consigne, pas une garantie, ce que le runner du chapitre 13 corrige. Côté code déterministe : ce fichier est la séquence. Aucun outil run_chain dans les allowlists, par construction.

{
  "agents": {
    "planner": {
      "description": "Planifie sans ecrire : un plan numerote, les fichiers a toucher, les risques.",
      "tools": "read,grep,find,ls",
      "system": "Vous etes le planner de l'usine plume-factory. Vous lisez le depot, vous n'ecrivez jamais de fichier. Rendez un plan numerote, court, qui nomme chaque fichier a modifier et le test qui prouvera le resultat. Repondez en francais."
    },
    "builder": {
      "description": "Implemente un plan donne, lance bun test, rend un compte rendu.",
      "tools": "read,write,edit,bash,grep,find,ls",
      "system": "Vous etes le builder de l'usine plume-factory. Vous implementez exactement le plan recu, sans l'etendre. Le payload vit dans apps/plume ; ses tests se lancent avec bun test depuis apps/plume. Avant de rendre la main, lancez les tests et rapportez leur resultat tel quel. Repondez en francais : fichiers touches, resultat des tests, ce qui reste ouvert."
    }
  },
  "chains": {
    "plan-build": {
      "description": "Plan puis implementation, en deux sous-agents isoles",
      "steps": [
        { "agent": "planner", "prompt": "Planifiez l'implementation de : $INPUT" },
        { "agent": "builder", "prompt": "Implementez ce plan :\n\n$INPUT\n\nDemande d'origine : $ORIGINAL" }
      ]
    },
    "plan-only": {
      "description": "Un plan, rien d'autre — pour reflechir sans toucher au depot",
      "steps": [
        { "agent": "planner", "prompt": "Planifiez l'implementation de : $INPUT" }
      ]
    }
  }
}

Pièce — .pi/extensions/factory-chain.ts

L’extension enregistre l’outil run_chain et la commande /chains. Elle lit le fichier de chaînes du projet et, s’il est absent, celui du package (à côté d’elle, sous ../agents/), et c’est ce qui rend le package autonome dans un dépôt vierge. Chaque étape est un pi -p --mode json lancé par pi.exec, sur le modèle de la session courante, sans --no-extensions : le damage control du chapitre A3 et la trace du chapitre A4 se chargent dans chaque sous-agent. Le fichier porte sa propre gate sous Bun : elle vérifie la résolution des gabarits et la forme des arguments, sans lancer un seul processus. Sous Windows, si pi n’est pas trouvé par exec, exportez FACTORY_PI_BIN=pi.cmd.

// .pi/extensions/factory-chain.ts — des sous-agents bornés, une séquence tenue par un fichier.
// La loi ne change pas : le fichier de chaînes séquence, le modèle exécute une étape bornée,
// et seule une enveloppe (texte, coût, jetons, durée) traverse d'une étape à l'autre.
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";

const PROJECT_CHAINS = join(".pi", "agents", "factory-chain.json");
const PACKAGED_CHAINS = join(import.meta.dirname, "..", "agents", "factory-chain.json");
const STEP_TIMEOUT_MS = 10 * 60 * 1000; // dix minutes par étape : au-delà, on rend la main
const PI_BIN = process.env.FACTORY_PI_BIN ?? "pi";

// ---------- le contrat : ce que le fichier de chaînes doit contenir ----------

export interface AgentDef { description?: string; tools: string; system: string }
export interface ChainStep { agent: string; prompt: string }
export interface ChainDef { description?: string; steps: ChainStep[] }
export interface ChainConfig { agents: Record<string, AgentDef>; chains: Record<string, ChainDef> }

// L'enveloppe d'une étape : ce qui traverse, et rien d'autre.
export interface StepEnvelope {
  step: number;
  agent: string;
  model: string;
  output: string;
  tokens: number;
  cost_usd: number;
  elapsed_ms: number;
  exit_code: number;
}

export function loadConfig(cwd: string): { config: ChainConfig; source: string } {
  const projectFile = join(cwd, PROJECT_CHAINS);
  const file = existsSync(projectFile) ? projectFile : PACKAGED_CHAINS;
  const config = JSON.parse(readFileSync(file, "utf8")) as ChainConfig;
  for (const [name, chain] of Object.entries(config.chains)) {
    for (const step of chain.steps) {
      if (!config.agents[step.agent]) throw new Error(`chaîne ${name} : agent inconnu « ${step.agent} »`);
      if (/\brun_chain\b/.test(config.agents[step.agent].tools)) throw new Error(`agent ${step.agent} : run_chain interdit dans un sous-agent`);
    }
  }
  return { config, source: file };
}

// $INPUT = la sortie de l'étape précédente (ou la demande, à la première) ; $ORIGINAL = la demande.
export function resolvePrompt(template: string, input: string, original: string): string {
  return template.replace(/\$INPUT/g, input).replace(/\$ORIGINAL/g, original);
}

// Les arguments d'un sous-agent : headless, JSON, sans réflexion, outils bornés, prompt en dernier.
export function argsFor(agent: AgentDef, model: string, prompt: string): string[] {
  return [
    "-p", "--mode", "json", "--thinking", "off",
    "--model", model,
    "--tools", agent.tools,
    "--append-system-prompt", agent.system,
    "--", prompt,
  ];
}

// Lire le flux JSON d'un `pi -p --mode json` : le dernier message d'assistant est la réponse,
// et chaque message d'assistant porte sa facture (même source que la trace du chapitre A4).
export function parseStream(stdout: string): { output: string; tokens: number; cost_usd: number } {
  let output = "";
  let tokens = 0;
  let cost = 0;
  for (const line of stdout.split("\n")) {
    if (!line.trim()) continue;
    let event: { type?: string; message?: { role?: string; content?: unknown; usage?: { totalTokens?: number; cost?: { total?: number } } } };
    try { event = JSON.parse(line); } catch { continue; }
    if (event.type !== "message_end" || event.message?.role !== "assistant") continue;
    const parts = Array.isArray(event.message.content) ? event.message.content : [];
    const text = parts
      .filter((p) => (p as { type?: string })?.type === "text")
      .map((p) => (p as { text?: string }).text ?? "")
      .join("");
    if (text.trim()) output = text; // la dernière réponse textuelle gagne
    tokens += event.message.usage?.totalTokens ?? 0;
    cost += event.message.usage?.cost?.total ?? 0;
  }
  return { output, tokens, cost_usd: cost };
}

function summarize(envelopes: StepEnvelope[]): string {
  const cost = envelopes.reduce((s, e) => s + e.cost_usd, 0);
  const tokens = envelopes.reduce((s, e) => s + e.tokens, 0);
  const seconds = Math.round(envelopes.reduce((s, e) => s + e.elapsed_ms, 0) / 1000);
  return `${envelopes.length} étape(s) · ${tokens} jetons · ${cost.toFixed(4)} $ · ${seconds} s`;
}

// ---------- l'extension : un outil, une commande, aucune décision confiée au modèle ----------

export default function (pi: ExtensionAPI) {
  pi.registerTool({
    name: "run_chain",
    label: "Run Chain",
    description: "Exécute une chaîne d'agents définie dans .pi/agents/factory-chain.json : chaque étape est un sous-agent pi isolé, la sortie d'une étape nourrit la suivante. Sans gate : pour explorer, pas pour livrer.",
    parameters: Type.Object({
      chain: Type.String({ description: "Nom de la chaîne (ex. plan-build, plan-only)" }),
      task: Type.String({ description: "La demande à traiter" }),
    }),

    async execute(_toolCallId, params, signal, onUpdate, ctx) {
      const { chain: chainName, task } = params as { chain: string; task: string };
      const { config } = loadConfig(ctx.cwd);
      const chain = config.chains[chainName];
      // Une erreur se signale en levant : c'est ainsi que pi marque le résultat isError pour le modèle.
      if (!chain) throw new Error(`Chaîne inconnue « ${chainName} ». Disponibles : ${Object.keys(config.chains).join(", ")}`);
      const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "";
      if (!model) throw new Error("Aucun modèle actif : impossible de lancer un sous-agent.");

      const envelopes: StepEnvelope[] = [];
      let input = task;
      for (const [index, step] of chain.steps.entries()) {
        const agent = config.agents[step.agent];
        onUpdate?.({ content: [{ type: "text", text: `étape ${index + 1}/${chain.steps.length} · ${step.agent} · en cours` }], details: { envelopes } });
        const started = Date.now();
        const result = await pi.exec(PI_BIN, argsFor(agent, model, resolvePrompt(step.prompt, input, task)),
          { cwd: ctx.cwd, signal, timeout: STEP_TIMEOUT_MS });
        const parsed = parseStream(result.stdout);
        const envelope: StepEnvelope = {
          step: index + 1, agent: step.agent, model, output: parsed.output,
          tokens: parsed.tokens, cost_usd: parsed.cost_usd,
          elapsed_ms: Date.now() - started, exit_code: result.code,
        };
        envelopes.push(envelope);
        // Échec fermé : une étape rouge ou muette arrête la chaîne, l'enveloppe dit laquelle.
        if (envelope.exit_code !== 0 || !envelope.output.trim()) {
          const why = result.stderr.trim().split("\n").pop() || "sortie vide";
          throw new Error(`[chaîne ${chainName}] arrêt à l'étape ${envelope.step} (${step.agent}) : ${why} · ${summarize(envelopes)}`);
        }
        input = envelope.output;
      }

      return {
        content: [{ type: "text", text: `[chaîne ${chainName}] terminée · ${summarize(envelopes)}\n\n${input}` }],
        details: { chain: chainName, envelopes },
      };
    },
  });

  pi.registerCommand("chains", {
    description: "Lister les chaînes d'agents disponibles",
    handler: async (_args, ctx) => {
      if (!ctx.hasUI) return;
      const { config, source } = loadConfig(ctx.cwd);
      const lines = Object.entries(config.chains)
        .map(([name, c]) => `${name} : ${c.steps.map((s) => s.agent).join(" → ")}${c.description ? ` — ${c.description}` : ""}`);
      ctx.ui.notify(`${lines.join("\n")}\n(${source})`, "info");
    },
  });
}

// ---------- la gate du fichier : `bun .pi/extensions/factory-chain.ts`, zéro jeton, zéro processus ----------

if (import.meta.main) {
  const { config, source } = loadConfig(process.cwd());
  const chain = config.chains["plan-build"];
  const prompt = resolvePrompt(chain.steps[1].prompt, "1. Lire apps/plume/src/editor.ts", "un compteur de mots");
  const args = argsFor(config.agents["builder"], "openrouter/z-ai/glm-5.3", prompt);
  const stream = [
    JSON.stringify({ type: "session", id: "x" }),
    JSON.stringify({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "Plan : 1. editor.ts" }], usage: { totalTokens: 900, cost: { total: 0.001 } } } }),
    JSON.stringify({ type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "Fait. bun test : 6 pass." }], usage: { totalTokens: 2100, cost: { total: 0.003 } } } }),
  ].join("\n");
  const parsed = parseStream(stream);
  const agents = Object.keys(config.agents).length;
  const chains = Object.keys(config.chains).length;
  const ok =
    chain.steps.length === 2 &&
    prompt.includes("1. Lire apps/plume/src/editor.ts") && prompt.includes("Demande d'origine : un compteur de mots") &&
    args[0] === "-p" && args.includes("--tools") && args[args.length - 1] === prompt &&
    !config.agents["planner"].tools.includes("write") && config.agents["builder"].tools.includes("bash") &&
    parsed.output === "Fait. bun test : 6 pass." && parsed.tokens === 3000 && Math.abs(parsed.cost_usd - 0.004) < 1e-9;
  console.log(`factory-chain ${ok ? "OK" : "KO"} — ${agents} agents, ${chains} chaînes, gabarits résolus, outils bornés, flux JSON lu (${source})`);
  process.exit(ok ? 0 : 1);
}

Pièce — package.json

À la racine de plume-factory, où il n’y en avait pas encore : celui de Plume vit dans apps/plume/. Le manifeste pi pointe vers les extensions de l’annexe et les skills des chapitres 25-26. Les paquets cœur sont en peerDependencies (pi les fournit), et typebox est aussi en devDependencies pour que la gate Bun puisse importer l’extension à sec. private empêche une publication npm accidentelle. Le jour où vous voudrez publier, retirez-le et versionnez.

{
  "name": "plume-factory-pi",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "description": "Le noeud pi de l'usine plume-factory : pied de page, damage control, trace harnais, chaines d'agents et skills de l'usine.",
  "keywords": ["pi-package"],
  "pi": {
    "extensions": [".pi/extensions"],
    "skills": [".claude/skills"]
  },
  "peerDependencies": {
    "@earendil-works/pi-coding-agent": "*",
    "typebox": "*"
  },
  "peerDependenciesMeta": {
    "@earendil-works/pi-coding-agent": { "optional": true },
    "typebox": { "optional": true }
  },
  "devDependencies": {
    "typebox": "1.3.7"
  }
}

Les peerDependenciesMeta marquent les paquets cœur comme optionnels : bun install n’ira pas télécharger pi entier à la racine de votre dépôt, seulement typebox pour la gate.

La gate du TP

Depuis la racine de plume-factory, projet approuvé (chapitre A1). Une commande par ligne, identiques dans bash et PowerShell. Les trois premières ne coûtent rien, la quatrième lance la chaîne plan-only, un seul sous-agent en lecture seule, et les suivantes installent le package dans un dépôt vierge et prouvent que ses extensions s’y chargent. Remplacez le chemin absolu par celui de votre dépôt.

bun install
bun .pi/extensions/factory-chain.ts
pi -p --approve --mode json --tools run_chain "Appelle run_chain avec chain=plan-only et task=ajouter un compteur de mots dans la barre d'etat de Plume. Rends le resultat tel quel."
git init ../pi-essai
cd ../pi-essai
pi install -l /chemin/absolu/vers/plume-factory
pi -p --approve --mode json "ping"
ls adws/adw_data/traces/harness
cd ../plume-factory

Résultat attendu : factory-chain OK — 2 agents, 2 chaînes, gabarits résolus, outils bornés, flux JSON lu, puis, dans le flux JSON de la troisième commande, un tool_execution_end pour run_chain dont le texte commence par [chaîne plan-only] terminée · 1 étape(s) suivi d’un plan numéroté, pour moins d’un centime, ~30 s sur le workhorse du chapitre A1. Dans pi-essai, pi install -l écrit .pi/settings.json avec une entrée packages, et le ls final liste un fichier .jsonl : la trace du chapitre A4 a tourné dans un dépôt qui n’a ni adws/ ni runner, donc les extensions du package ont été chargées (zéro jeton pour l’installation, un ping à moins d’un centime). Variante éco : elle est déjà dans la pièce, les sous-agents prennent le modèle de la session, celui du roster. Pour voir la chaîne complète, ouvrez pi dans plume-factory, tapez /chains, puis demandez plan-build sur une petite évolution de Plume : quelques centimes, deux à trois minutes. Souvenez-vous qu’aucune gate n’a tranché, et que c’est uv run adws/adw_sdlc.py qui dispose. Effacez ../pi-essai quand vous avez fini.


Quiz — teste tes connaissances
Annexe — Harnais pi 7 questions Objectif : 5/7 minimum
0/7
bonnes reponses
Objectif non atteint (minimum 5/7 requis).
Remonte relire la fiche memo en pretant attention aux points manques, puis cliquer sur « Recommencer » pour retenter.