Annexe — Harnais pi Chapitre A7 / 42

Disposer pendant la phase — coupe-circuit et masquage

Quatre compteurs qui coupent une phase qui dérape (tours, appels d'outils, coût, boucle) et un filtre qui masque les secrets avant qu'ils ne partent au fournisseur : le code dispose désormais pendant la phase, pas seulement après. Le roster gagne ses limites, le runner apprend à relancer avec le motif exact.

Hier, vous avez fermé la porte typée : l’enveloppe arrive par report_phase, validée par schéma, et le runner lit stopReason et les jetons. Mais entre le premier tour et cette enveloppe, que possède le code ? Un seul mur, posé au chapitre 15 : HarnessRequest.timeout = 600. Un builder qui relance bun test six fois de suite en espérant un autre résultat, un scout qui relit le même fichier à chaque tour, un modèle qui cat un .env et l’envoie au fournisseur dans le résultat d’outil : rien ne les arrête avant la dixième minute, et quand le mur tombe, il tombe sans enveloppe, sans motif, et la facture est déjà réglée. À la fin de ce chapitre, la loi du livre s’appliquera pendant la phase : quatre compteurs (tours, appels d’outils, coût cumulé, boucle) coupent le nœud pi au moment exact où il dérape, le motif remonte au runner qui relance dans la même session avec ce motif comme consigne, et ce qui sort des outils est filtré des secrets avant de quitter la machine. La pièce du jour ajoute une extension du socle, factory-guard.ts, à côté de factory-report.ts (A6), et fait évoluer deux modules Python : le roster (chapitre 27) apprend les limites par agent, et la fabrique agent_action (chapitre 11) apprend à lire un arrêt.

Coupe-circuit : tours, outils, coût, boucle

L’idée en une phrase

Un coupe-circuit est un ensemble de compteurs tenus par le code déterministe à l’intérieur même de la phase (côté pi, dans une extension du socle, alimentés par les événements du cycle de vie), qui arrête l’agent quand un seuil posé par le roster est franchi, et qui remonte le motif au runner par la trace. La couture ne bouge pas, mais le code y dispose désormais en continu, et plus seulement quand l’agent a fini.

Points clés

  • Quatre horloges plutôt qu’un mur. turn_end compte les tours, tool_call compte les appels d’outils, message_end (rôle assistant) cumule usage.cost.total, et une fenêtre glissante de hachés (outil, arguments triés) détecte la boucle : le même appel répété cinq fois dans les vingt derniers est une boucle, pas de la persévérance. Le mur de temps du roster reste côté Python, en dernier recours.
  • Les seuils viennent du roster, par l’environnement. Chaque agent déclare limits: et timeout: dans factory.config.yaml, et le runner les transmet à pi en variables FACTORY_LIMIT_*, jamais par le prompt. L’extension lit l’environnement : sans variable (votre session interactive), aucun plafond de tours ni de coût, et seules la détection de boucle et le délai par appel restent armés.
  • Deux gestes, un seul déclenchement. Sur un plafond de tours ou de coût, ctx.abort() : le message d’assistant suivant sort en stopReason: "aborted", que l’adaptateur v4 du chapitre A6 transforme en HarnessError porteuse de la session. Sur une boucle ou un plafond d’appels, mieux : block: true, terminate: true dans tool_call, où l’appel est refusé et le tour s’arrête là, sans un appel LLM de plus. Le coupe-circuit ne se déclenche qu’une fois.
  • Le motif voyage par deux canaux, jamais par la sortie du modèle. Une ligne guard dans le journal harnais du chapitre A4 (le même fichier JSONL, donc factory.db après le miroir), et un petit fichier de rapport désigné par FACTORY_GUARD_REPORT, que agent_action lit après l’erreur pour écrire la relance : « ta tentative a appelé bash six fois avec les mêmes arguments ». La reprise repart dans la même session, motif en tête.
  • Le délai par appel manquait. L’outil bash de pi accepte un paramètre timeout (en secondes) mais n’a aucun délai par défaut : un bun test qui attend une entrée bloque la phase jusqu’au mur de 600 s. L’extension injecte timeout dans les arguments s’il est absent (FACTORY_TOOL_TIMEOUT_S, 300 par défaut) : le modèle ne renseigne jamais ce champ, le code le fait pour lui.

Exemple concret

Reprenez le builder du chapitre 13 sur le workhorse du roster (z-ai/glm-5.3, relevé ce jour sur openrouter.ai/models : 1,40 $ le million de jetons en entrée, 4,40 $ en sortie). La spec demande un export Markdown, un test échoue sur une fin de ligne. Sans coupe-circuit : le builder relance bun test, même commande et même résultat, à chaque tour, en reformulant sa surprise. Chaque tour relit un contexte de 50 à 80 k jetons : une dizaine de centimes par tour, quinze à vingt tours avant le mur de 600 s, soit un à deux dollars brûlés et une HarnessError sans motif. La reprise repart dans une session neuve, parce que l’ancien _run_pi perdait l’identifiant. Avec la pièce du jour : au cinquième bash(bun test) identique dans la fenêtre, tool_call refuse l’appel et termine le tour, pour zéro jeton de plus. Le journal porte guard / boucle, le rapport dit « bash × 5 avec les mêmes arguments », et la tentative suivante (même session, donc le builder garde ses fichiers ouverts et sa compréhension de la spec) commence par : « ta tentative a été interrompue : bash appelé 5 fois avec les mêmes arguments, lis la sortie du test au lieu de le relancer ». Sur ce run, l’économie se compte en dizaines de centimes et en minutes. Sur un best-of-N du chapitre 25, où cinq boîtes peuvent boucler en parallèle, en dollars.

Quatre compteurs, deux gestes

CompteurÉvénement piSeuil (variable)GesteCe que voit le runner
Toursturn_endFACTORY_LIMIT_TURNSctx.abort()stopReason: abortedHarnessError + session
Appels d’outilstool_callFACTORY_LIMIT_TOOL_CALLSblock + terminateidem, sans appel LLM de plus
Coût cumulémessage_end (assistant)FACTORY_LIMIT_COST_USDctx.abort()idem
Boucletool_call (haché dans une fenêtre)FACTORY_LOOP_WINDOW | FACTORY_LOOP_THRESHOLDblock + terminateidem, motif « × N mêmes arguments »
Délai par appeltool_call de bashFACTORY_TOOL_TIMEOUT_Sinjection de timeoutun bash muet rend une erreur d’outil, la phase continue

Config — les limites dans le roster

Ce bloc s’ajoute à un agent (ou aux defaults:) de factory.config.yaml. Sans lui, le roster applique les valeurs par défaut de roster.py, et votre roster du chapitre 27 tourne tel quel.

agents:
  - name: builder
    purpose: Implementer le plan, exactement ; declarer chaque fichier modifie.
    model: z-ai/glm-5.3                   # workhorse : ~1,40 $/M entree, ~4,40 $/M sortie (2026-09-04)
    thinking: high
    tools: [read, bash, grep, find, ls, edit, write]
    # Le coupe-circuit (annexe A7) : transmis a pi par variables FACTORY_LIMIT_*,
    # jamais par le prompt. Plafonnez large : ce sont des filets, pas des objectifs.
    limits:
      turns: 40                # tours LLM avant abort — un build de Plume en prend 10 a 25
      tool_calls: 120          # appels d'outils avant refus — trois par tour, en moyenne
      cost_usd: 0.80           # cout cumule (catalogue pi, pas la facture) avant abort
      loop_window: 20          # la fenetre glissante d'appels observes
      loop_threshold: 5        # le meme appel N fois dans la fenetre = boucle
      tool_timeout_s: 300      # injecte dans chaque bash sans timeout explicite
    timeout: 900               # le mur de temps, cote Python — remplace le 600 fixe

Commande — lire un arrêt dans la trace

sqlite3 adws/adw_data/factory.db "SELECT ts, name, json_extract(payload_json, '$.reason') FROM harness_by_phase WHERE type = 'guard' ORDER BY ts DESC LIMIT 5;"

Chaque ligne guard nomme le compteur qui a coupé (armed à l’ouverture, puis tours, outils, cout ou boucle) et son motif exact, et la vue harness_by_phase du chapitre A4 la relie au run et à la phase. Sans client sqlite3, uv run adws/adw_modules/harness_trace.py --last montre la même ligne.

Piège courant : « un plafond d’appels d’outils serré économise des jetons » est inexact. Ce sont les tours et le coût qui bornent la dépense, et un appel d’outil ne coûte rien au fournisseur, c’est le tour qui l’entoure qui coûte. Un plafond d’outils trop bas coupe un builder en pleine série d’edit légitimes et vous fait payer une reprise pour rien. Gardez-le large (trois appels par tour, en ordre de grandeur) : il n’est là que pour la boucle que la fenêtre n’aurait pas vue, jamais pour discipliner un agent qui travaille.


Filtrer ce qui sort des outils (tool_result)

L’idée en une phrase

Le résultat d’un outil est du contexte que le nœud pi s’apprête à envoyer au fournisseur de modèles. L’événement tool_result est un intergiciel (middleware) où le code peut le réécrire avant l’envoi : l’usine y masque les motifs de secrets et journalise chaque masquage, parce qu’un .env lu par un scout curieux ne doit jamais quitter la machine, quel que soit le modèle et quel que soit le prompt.

Points clés

  • tool_result chaîne comme un intergiciel. Les extensions s’exécutent dans l’ordre de chargement, chacune voit le résultat laissé par la précédente et peut rendre un correctif partiel (content, details, isError, usage), et un champ omis garde sa valeur. C’est le seul point du cycle de vie où le contenu est déjà produit et pas encore envoyé.
  • Masquer, pas bloquer. Un cat .env refusé par damage control (A3) est le premier rempart, mais la clé peut arriver par un grep -r, un git show, un fichier de config mal rangé. Le second rempart ne juge pas la commande : il remplace tout ce qui ressemble à un secret (sk-…, OPENROUTER_API_KEY=…, Bearer …, blocs PEM) par «redacted» et laisse le reste intact, et le modèle continue de travailler.
  • La troncature et le code retour, pi les fait déjà. Au moment d’écrire, les outils intégrés tronquent leur sortie à quelques dizaines de kilo-octets (tête conservée, fichier complet sur le disque) et bash rend une erreur d’outil dès qu’une commande sort avec un code différent de zéro : le modèle ne peut pas ignorer un bun test rouge. L’extension n’a rien à ajouter ici, et l’écrire deux fois serait présenter un pattern de la méthode comme natif, ou l’inverse.
  • Chaque masquage laisse une trace. Une ligne redact dans le journal harnais, avec le nom de l’outil et le nombre de motifs remplacés, jamais le secret. Un run qui masque trois fois signale un agent qui fouille là où il ne devrait pas : c’est une donnée pour le chapitre 19, pas un incident.
  • Deux harnais, une seule pièce. Claude Code offre des crochets comparables, mais l’usine ne filtre que son nœud pi : c’est lui qui sert les modèles ouverts via la passerelle, là où le masquage a le plus de valeur. Côté Claude Code, le roster garde les outils de lecture sur des chemins gouvernés par protected_files. Le dire une fois suffit.

Exemple concret

Un scout sur le modèle léger du roster cherche « où est configurée la clé de la passerelle ». Il lance grep -rn OPENROUTER . --include=* : la sortie contient .env.sample (inoffensif) et, si votre .gitignore a une faille, .env avec la vraie clé. Sans filtre : la clé part au fournisseur dans le résultat d’outil, puis dans chaque tour suivant tant qu’elle reste dans le contexte, et pour un scout de six tours la même ligne voyage six fois. Avec la pièce du jour : tool_result remplace OPENROUTER_API_KEY=sk-or-v1-… par OPENROUTER_API_KEY=«redacted», le scout rend son enveloppe (« la clé vit dans .env, chargée par harness._load_env() »), et le journal porte redact / bash / 1. Coût : zéro jeton (le remplacement raccourcit même le contexte d’une trentaine de caractères) et quelques microsecondes par résultat.

Trois remparts, trois moments

RempartÉvénementCe qu’il jugeCe qu’il laisse passer
Damage control (A3)tool_callla commande avant exécutionune commande sûre dont la sortie contient un secret
Masquage (A7)tool_resultle contenu avant envoi au fournisseurun secret déjà dans le contexte par un autre canal
Fichiers protégés + état des lieux (ch. 10)après la phaseles écritures dans le dépôttout ce qui n’est pas une écriture

Script — l’intergiciel, en dix lignes

// .pi/extensions/factory-guard.ts — extrait : masquer avant d'envoyer, journaliser sans le secret.
pi.on("tool_result", async (event, ctx) => {
  let hits = 0;
  const content = event.content.map((part) => {
    if (part.type !== "text") return part;                  // images, etc. : intactes
    const { text, count } = redact(part.text);               // fonction pure, testée à sec
    hits += count;
    return count > 0 ? { ...part, text } : part;
  });
  if (hits === 0) return;                                    // rien à changer : pas de correctif
  journal(ctx, "redact", event.toolName, { hits });          // le nombre, jamais le motif
  return { content };                                        // correctif partiel : isError, details inchangés
});

Piège courant : « avec damage control, le masquage est redondant » est inexact. Damage control juge une commande (cat .env est bloqué), le masquage juge un contenu (la sortie d’un grep -r légitime). Les deux remparts voient des choses différentes, à des moments différents. Le masquage ne protège pas non plus d’un secret que vous auriez collé dans un prompt ou un AGENTS.md : celui-là part au fournisseur avant tout outil.


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

Sur le plan de l’usine, la pièce du jour s’insère dans le socle pi, quatrième extension après damage-control.ts (A3), factory-obs.ts (A4) et factory-report.ts (A6), et remonte jusqu’au roster (chapitre 27) et à la fabrique de phase agent_action (chapitre 11). La couture ne bouge pas : le runner Python possède le graphe, l’agent reste un nœud borné dans une phase nommée. Ce qui change, c’est quand le code dispose : jusqu’ici, après la phase (gates, état des lieux, parse), et désormais aussi pendant, par des compteurs que l’agent ne voit pas et des seuils qu’il ne négocie pas. Déterministe : les seuils (roster), leur transport (environnement), les compteurs, le haché des appels, le masquage, la ligne de trace. Délégué : tout le travail entre deux compteurs. L’enveloppe qui traverse quand ça coupe est un rapport de garde (compteur, seuil, motif) que agent_action transforme en consigne de reprise, dans la même session. Coût d’usage : zéro jeton pour les compteurs et le masquage, et une boucle coupée par block + terminate économise tous les tours qu’elle aurait encore brûlés, soit sur un builder qui dérape, dizaines de centimes à plusieurs dollars par run. Note pour le chapitre 17 : le coût que cumule le coupe-circuit est celui du catalogue de pi (usage.cost), une estimation, et la facture de la passerelle fait foi. Note pour le chapitre 13 : la phase build de adw_sdlc.py construit encore sa requête elle-même, avec le mur de 600 s, et elle sera alignée sur la fabrique du jour avec les autres évolutions du runner, plus loin dans l’annexe. En attendant, le coupe-circuit y est armé avec les valeurs par défaut de roster.py. Note pour le chapitre A6 : l’extension d’injection de contexte annoncée là-bas n’est pas posée aujourd’hui non plus : la fabrique exporte dès maintenant FACTORY_PHASE, FACTORY_ROLE et FACTORY_ADW_ID, les variables qu’elle lira, et elle prendra place avec l’inventaire du socle.


Travaux pratiques — la pièce du jour

Trois fichiers à poser dans plume-factory, qui devient, chapitre après chapitre, votre usine logicielle agentique : une extension du socle qui porte les deux sous-thèmes (le budget de fichiers du jour impose de la livrer en un seul module, où coupe-circuit et masquage sont deux blocs de fonctions pures, exportées et testées), et deux modules Python qui remplacent leurs versions précédentes. factory.config.yaml ne change pas : les valeurs par défaut s’appliquent.

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

Le coupe-circuit et le masquage. Les seuils viennent de l’environnement (readLimits), la boucle d’un LoopDetector à fenêtre glissante, le masquage de redact : trois fonctions pures, exportées, dont le fichier porte la gate sous Bun. Les effets de bord se limitent au journal harnais du chapitre A4 (même fichier, même format de ligne) et au rapport de garde désigné par FACTORY_GUARD_REPORT. Aucune dépendance npm à l’exécution.

// .pi/extensions/factory-guard.ts — le code dispose PENDANT la phase.
// Quatre compteurs (tours, appels d'outils, coût cumulé, boucle) coupent le nœud pi au premier
// seuil franchi ; un intergiciel tool_result masque les secrets avant qu'ils ne partent au
// fournisseur. Les seuils viennent du roster par variables FACTORY_* (jamais par le prompt) ;
// le motif remonte au runner par le journal harnais (A4) et par un rapport de garde.
import { createHash, randomUUID } from "node:crypto";
import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

const HARNESS_DIR = join("adws", "adw_data", "traces", "harness"); // le journal du chapitre A4
export const REPORT_ENV = "FACTORY_GUARD_REPORT";                  // le rapport de garde lu par agent_action

// ---------- les seuils : depuis l'environnement posé par le runner, jamais depuis le prompt ----------

export interface Limits {
  turns: number;         // 0 = pas de plafond (session de poste)
  toolCalls: number;     // 0 = pas de plafond
  costUsd: number;       // 0 = pas de plafond
  loopWindow: number;    // taille de la fenêtre glissante d'appels observés
  loopThreshold: number; // le même appel N fois dans la fenêtre = boucle
  toolTimeoutS: number;  // injecté dans chaque bash sans timeout explicite
}

function num(value: string | undefined, fallback: number): number {
  const parsed = value === undefined || value === "" ? NaN : Number(value);
  return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
}

export function readLimits(env: Record<string, string | undefined>): Limits {
  return {
    turns: num(env.FACTORY_LIMIT_TURNS, 0),
    toolCalls: num(env.FACTORY_LIMIT_TOOL_CALLS, 0),
    costUsd: num(env.FACTORY_LIMIT_COST_USD, 0),
    loopWindow: Math.max(2, num(env.FACTORY_LOOP_WINDOW, 20)),
    loopThreshold: Math.max(2, num(env.FACTORY_LOOP_THRESHOLD, 5)),
    toolTimeoutS: num(env.FACTORY_TOOL_TIMEOUT_S, 300),
  };
}

// ---------- la boucle : un haché (outil, arguments triés) dans une fenêtre glissante ----------

function canonical(value: unknown): string {
  if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
  if (value && typeof value === "object") {
    const record = value as Record<string, unknown>;
    return `{${Object.keys(record).sort().map((k) => `${JSON.stringify(k)}:${canonical(record[k])}`).join(",")}}`;
  }
  return JSON.stringify(value);
}

export function fingerprint(toolName: string, input: unknown): string {
  return createHash("sha1").update(`${toolName}\n${canonical(input)}`).digest("hex").slice(0, 16);
}

export class LoopDetector {
  private readonly recent: string[] = [];
  constructor(readonly window: number, readonly threshold: number) {}

  /** Observe un appel ; rend le nombre d'occurrences de ce même appel dans la fenêtre (lui compris). */
  observe(toolName: string, input: unknown): number {
    const key = fingerprint(toolName, input);
    this.recent.push(key);
    if (this.recent.length > this.window) this.recent.shift();
    return this.recent.filter((k) => k === key).length;
  }

  looping(toolName: string, input: unknown): boolean {
    return this.observe(toolName, input) >= this.threshold;
  }
}

// ---------- le masquage : ce qui ressemble à un secret ne quitte pas la machine ----------

export const REDACTED = "«redacted»";

// Cinq motifs, volontairement larges : mieux vaut masquer un faux positif qu'envoyer une clé.
const SECRET_PATTERNS: RegExp[] = [
  /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, // blocs PEM
  /\bsk-[A-Za-z0-9_-]{16,}/g,                                                    // clés sk-…, sk-or-v1-…
  /\b(?:ghp|gho|github_pat)_[A-Za-z0-9_]{20,}/g,                                  // jetons GitHub
  /\bAKIA[0-9A-Z]{16}\b/g,                                                        // identifiants AWS
  /\b([A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD))(\s*[=:]\s*)["']?([^\s"']{8,})["']?/g, // VAR=valeur
  /\b(Bearer\s+)[A-Za-z0-9._~+/-]{20,}=*/g,                                       // en-têtes Authorization
];

export function redact(text: string): { text: string; count: number } {
  let count = 0;
  let out = text;
  for (const pattern of SECRET_PATTERNS) {
    out = out.replace(pattern, (match: string, ...groups: unknown[]) => {
      count += 1;
      // Motif VAR=valeur : garder le nom et le signe, masquer la valeur seule.
      if (typeof groups[0] === "string" && typeof groups[1] === "string" && typeof groups[2] === "string") {
        return `${groups[0]}${groups[1]}${REDACTED}`;
      }
      if (typeof groups[0] === "string" && match.startsWith(groups[0])) return `${groups[0]}${REDACTED}`;
      return REDACTED;
    });
  }
  return { text: out, count };
}

// ---------- le journal (A4) et le rapport de garde : les deux canaux du motif ----------

function journalLine(cwd: string, sessionId: string, type: string, name: string, payload: Record<string, unknown>): void {
  const dir = join(cwd, HARNESS_DIR);
  mkdirSync(dir, { recursive: true });
  const line = {
    event_id: `hev_${randomUUID().replace(/-/g, "").slice(0, 12)}`,
    ts: new Date().toISOString(),
    session_id: sessionId,
    type,
    name,
    payload,
  };
  appendFileSync(join(dir, `${sessionId}.jsonl`), `${JSON.stringify(line)}\n`, "utf8");
}

export interface GuardReport {
  kind: "tours" | "outils" | "cout" | "boucle";
  reason: string;
  session_id: string;
  counters: { turns: number; tool_calls: number; cost_usd: number };
  ts: string;
}

function writeReport(path: string | undefined, report: GuardReport): void {
  if (!path) return;
  mkdirSync(dirname(path), { recursive: true });
  writeFileSync(path, JSON.stringify(report, null, 2), "utf8");
}

// ---------- l'extension : compter, couper une fois, masquer toujours ----------

export default function (pi: ExtensionAPI) {
  const limits = readLimits(process.env);
  const counters = { turns: 0, tool_calls: 0, cost_usd: 0 };
  let detector = new LoopDetector(limits.loopWindow, limits.loopThreshold);
  let sessionId = "";
  let cwd = ".";
  let tripped = false;

  // Un seul déclenchement : journal, rapport, entrée de session — puis le geste choisi par l'appelant.
  const trip = (kind: GuardReport["kind"], reason: string): void => {
    if (tripped) return;
    tripped = true;
    const report: GuardReport = { kind, reason, session_id: sessionId, counters: { ...counters }, ts: new Date().toISOString() };
    journalLine(cwd, sessionId, "guard", kind, { reason, ...counters });
    writeReport(process.env[REPORT_ENV], report);
    pi.appendEntry("factory-guard", report); // persiste dans la session ; hors contexte LLM
  };

  pi.on("session_start", async (_event, ctx) => {
    sessionId = ctx.sessionManager.getSessionId();
    cwd = ctx.cwd;
    counters.turns = counters.tool_calls = counters.cost_usd = 0;
    detector = new LoopDetector(limits.loopWindow, limits.loopThreshold);
    tripped = false;
    journalLine(cwd, sessionId, "guard", "armed", { ...limits, headless: !ctx.hasUI });
    if (ctx.hasUI) ctx.ui.setStatus("factory-guard", "⏚ garde");
  });

  // Tours : un abort — le message suivant sort en stopReason "aborted", l'adaptateur v4 le lit.
  pi.on("turn_end", async (_event, ctx) => {
    counters.turns += 1;
    if (limits.turns > 0 && counters.turns >= limits.turns && !tripped) {
      trip("tours", `${counters.turns} tours atteints (plafond ${limits.turns})`);
      ctx.abort();
    }
  });

  // Coût cumulé : le catalogue de pi, pas la facture — une estimation suffit pour un disjoncteur.
  pi.on("message_end", async (event, ctx) => {
    const message = event.message as { role: string; usage?: { cost?: { total?: number } } };
    if (message.role !== "assistant") return;
    counters.cost_usd += message.usage?.cost?.total ?? 0;
    if (limits.costUsd > 0 && counters.cost_usd >= limits.costUsd && !tripped) {
      trip("cout", `${counters.cost_usd.toFixed(3)} $ cumulés (plafond ${limits.costUsd} $)`);
      ctx.abort();
    }
  });

  // Appels d'outils : délai injecté, boucle et plafond — refusés AVANT exécution, tour terminé.
  pi.on("tool_call", async (event) => {
    if (tripped) return { block: true, reason: "[garde] phase interrompue", terminate: true };
    counters.tool_calls += 1;
    const input = event.input as Record<string, unknown>;
    if (event.toolName === "bash" && limits.toolTimeoutS > 0 && input.timeout === undefined) {
      input.timeout = limits.toolTimeoutS; // le modèle ne le renseigne jamais ; le code le fait pour lui
    }
    if (detector.looping(event.toolName, input)) {
      const reason = `[boucle] ${event.toolName} appelé ${limits.loopThreshold} fois avec les mêmes arguments dans les ${limits.loopWindow} derniers appels`;
      trip("boucle", reason);
      return { block: true, reason, terminate: true };
    }
    if (limits.toolCalls > 0 && counters.tool_calls > limits.toolCalls) {
      const reason = `[outils] ${counters.tool_calls} appels d'outils (plafond ${limits.toolCalls})`;
      trip("outils", reason);
      return { block: true, reason, terminate: true };
    }
    return undefined;
  });

  // Masquage : ce qui ressemble à un secret ne part pas au fournisseur — le journal compte, sans le motif.
  pi.on("tool_result", async (event) => {
    let hits = 0;
    const content = event.content.map((part) => {
      if (part.type !== "text") return part;
      const { text, count } = redact(part.text);
      hits += count;
      return count > 0 ? { ...part, text } : part;
    });
    if (hits === 0) return undefined;
    journalLine(cwd, sessionId, "redact", event.toolName, { hits });
    return { content };
  });
}

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

if (import.meta.main) {
  const limits = readLimits({ FACTORY_LIMIT_TURNS: "2", FACTORY_LOOP_THRESHOLD: "3", FACTORY_LIMIT_COST_USD: "abc" });
  const okLimits = limits.turns === 2 && limits.loopThreshold === 3 && limits.costUsd === 0 && limits.toolTimeoutS === 300;

  const detector = new LoopDetector(20, 5);
  let tripAt = 0;
  for (let i = 1; i <= 6 && tripAt === 0; i++) {
    if (detector.looping("bash", { command: "bun test", timeout: 300 })) tripAt = i;
  }
  const differentArgs = new LoopDetector(20, 5);
  const noFalsePositive = [1, 2, 3, 4, 5, 6].every((i) => !differentArgs.looping("read", { path: `src/f${i}.ts` }));
  const orderBlind = fingerprint("bash", { a: 1, b: 2 }) === fingerprint("bash", { b: 2, a: 1 });

  const sample = [
    "OPENROUTER_API_KEY=sk-or-v1-abcdefghijklmnopqrstuvwxyz0123456789",
    "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBg\n-----END PRIVATE KEY-----",
    "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.payload",
    "const cfg = { theme: \"dark\" }; // rien de secret ici",
  ].join("\n");
  const masked = redact(sample);
  const okRedact = masked.count >= 3
    && !masked.text.includes("sk-or-v1-") && !masked.text.includes("MIIEvQIBADANBg") && !masked.text.includes("eyJhbGci")
    && masked.text.includes(`OPENROUTER_API_KEY=${REDACTED}`) && masked.text.includes("theme: \"dark\"")
    && redact("bun test : 12 pass, 0 fail").count === 0;

  const ok = okLimits && tripAt === 5 && noFalsePositive && orderBlind && okRedact;
  console.log(`factory-guard ${ok ? "OK" : "KO"} — seuils lus, boucle coupée au ${tripAt}e appel identique, ${masked.count} secrets masqués, texte ordinaire intact`);
  process.exit(ok ? 0 : 1);
}

Pièce — adws/adw_modules/roster.py

Cette version remplace celle du chapitre 27. Tout ce qui existait reste : mêmes dataclasses, même fusion, même résolution du payload, mêmes validations, même gate. S’ajoutent Limits (six plafonds, chacun avec sa variable d’environnement), les champs limits et timeout de AgentSpec avec leurs valeurs par défaut (votre factory.config.yaml ne change pas d’une ligne), la fusion defaults.limitslimits de l’agent, clé par clé, et leur validation à zéro jeton : un plafond nul, une fenêtre plus courte que le seuil ou un mur qui tombe avant le délai d’un seul appel d’outil échouent avant tout lancement. Le profil d’authentification du chapitre 17bis (auth:, route, coffre) est conservé.

"""roster — la feuille de distribution de l'usine : qui tourne, avec quels
moyens, sur quel payload — et jusqu'ou.

Les ADW nomment des agents, jamais des modeles. Ce module charge
factory.config.yaml, fusionne chaque agent sur les defauts (cle par cle),
et valide TOUT avant le moindre lancement : une erreur de roster coute
zero token — c'est le but.

Depuis le chapitre 27, le roster porte aussi la declaration du PAYLOAD.
Depuis l'annexe A7, chaque agent porte ses LIMITES : le coupe-circuit du
socle pi (.pi/extensions/factory-guard.ts) les lit dans l'environnement du
sous-processus (variables FACTORY_*), jamais dans le prompt ; le mur de
temps (timeout) reste cote Python. Sans bloc `limits`, les valeurs par
defaut s'appliquent : un roster d'avant ce chapitre tourne tel quel.

Depuis le chapitre 17bis, le roster porte aussi les PROFILS D'AUTHENTIFICATION
(bloc `auth:`) : par profil, le regime (cle, forfait, session), la route
(passerelle ou fournisseur direct), les NOMS des variables a injecter dans le
harnais — jamais leurs valeurs, qui vivent dans .env ou dans l'environnement
reel —, les hotes que la porte du module 6 devra ouvrir, et l'eventuel
fichier de session a monter dans une boite. Chaque agent reference un profil
(`auth:`, `gateway` par defaut). Tout est valide a zero token : profil
inconnu, variable absente (nommee dans le message), route ou regime inconnus,
montage hors du depot — avant le moindre lancement. Sans bloc `auth:`, le
profil integre `gateway` s'applique : un roster d'avant ce chapitre tourne
tel quel.
"""
from __future__ import annotations

import sys
import os
from dataclasses import dataclass, fields
from pathlib import Path, PurePosixPath, PureWindowsPath

import yaml

DEFAULT_PATH = Path("adws/adw_config/factory.config.yaml")

# Les deux dialectes du port (ch. 7), l'echelle de reflexion, et les sept
# outils integres de pi — l'adaptateur Claude Code traduit (ch. 11). L'outil
# terminal report_phase (A6) est ajoute d'office par l'adaptateur : le roster
# n'a pas a le declarer.
HARNESSES = ("pi", "claude")
THINKING = ("off", "minimal", "low", "medium", "high", "xhigh", "max")
KNOWN_TOOLS = ("read", "bash", "edit", "write", "grep", "find", "ls")

# Le mur de temps par defaut d'une phase agent, en secondes (remplace le 600
# fixe de HarnessRequest quand l'agent ne dit rien).
DEFAULT_TIMEOUT = 900


# Les trois regimes de credentials (17bis), ranges par MECANIQUE, jamais par
# marque : une cle au jeton (une variable, revocable, plafonnable) ; un
# forfait EXPOSE derriere une cle (la cle de l'abonnement, un point d'entree
# propre au fournisseur : un profil ordinaire) ; une session attachee au
# poste (rien a injecter, un fichier de session a monter — ni plafonnable ni
# revocable par run).
REGIMES = ("key", "plan", "session")
DEFAULT_AUTH_NAME = "gateway"

# Par ou passe le modele : `gateway` = la passerelle du ch. 15 (le port prefixe
# la route, l'identifiant du roster est celui du registre OpenRouter) ;
# `direct` = un fournisseur natif du harnais, l'identifiant du roster est la
# route telle quelle (zai/glm-5.3, kimi-coding/k3). La route n'est pas dans le
# nom du modele — `deepseek/...` est a la fois un auteur OpenRouter et un
# fournisseur natif de pi — elle est dans le profil.
ROUTES = ("gateway", "direct")


class RosterError(ValueError):
    """Un roster invalide — le motif exact, avant tout lancement."""


@dataclass(frozen=True)
class Limits:
    """Le coupe-circuit d'un agent : des filets larges, pas des objectifs.

    Chaque champ a sa variable d'environnement, lue par factory-guard.ts.
    0 = pas de plafond (jamais par defaut dans l'usine : un noeud sans
    plafond est une session de poste, pas une phase).
    """
    turns: int = 60             # tours LLM avant abort
    tool_calls: int = 200       # appels d'outils avant refus
    cost_usd: float = 2.0       # cout cumule (catalogue pi) avant abort
    loop_window: int = 20       # fenetre glissante d'appels observes
    loop_threshold: int = 5     # le meme appel N fois dans la fenetre = boucle
    tool_timeout_s: int = 300   # injecte dans chaque bash sans timeout explicite

    ENV = {"turns": "FACTORY_LIMIT_TURNS", "tool_calls": "FACTORY_LIMIT_TOOL_CALLS",
           "cost_usd": "FACTORY_LIMIT_COST_USD", "loop_window": "FACTORY_LOOP_WINDOW",
           "loop_threshold": "FACTORY_LOOP_THRESHOLD", "tool_timeout_s": "FACTORY_TOOL_TIMEOUT_S"}

    def env(self) -> dict[str, str]:
        """Les limites telles que le sous-processus pi les recevra."""
        return {self.ENV[f.name]: str(getattr(self, f.name)) for f in fields(self)}


DEFAULT_LIMITS = Limits()


@dataclass(frozen=True)
class AuthProfile:
    """Un profil d'authentification : ce qu'un agent a le droit d'emporter, et par ou.

    Le profil DECRIT, il n'invente pas : `env` liste des NOMS de variables,
    jamais des valeurs ; les valeurs vivent dans .env (ou l'environnement
    reel, qui gagne toujours — la preseance du ch. 15). `hosts` est ce que la
    porte du module 6 ouvrira pour ce profil : un profil sans hosts n'ouvre
    rien. `mount` est un chemin RELATIF au depot, sous data_dir de preference
    (jamais commite) : une session copiee la pour voyager dans une boite.
    """
    name: str
    regime: str                        # key | plan | session
    route: str = "gateway"             # gateway | direct — par ou passe le modele
    env: tuple[str, ...] = ()          # des noms, jamais des valeurs
    hosts: tuple[str, ...] = ()        # ce que la porte devra ouvrir (ch. 22)
    mount: str | None = None           # session a monter, relative au depot
    note: str = ""                     # une phrase pour le lecteur du roster

    @property
    def direct(self) -> bool:
        """Vrai si le modele part tel quel vers un fournisseur natif, sans passerelle."""
        return self.route == "direct"

    def credentials(self, env: dict[str, str] | None = None) -> dict[str, str]:
        """Les variables du profil, resolues — ou le NOM de celle qui manque.

        C'est le dictionnaire que le port injectera dans le noeud, a la
        place du .env global : un noeud n'emporte que ce que son profil nomme.
        """
        source = os.environ if env is None else env
        missing = [name for name in self.env if not source.get(name)]
        if missing:
            raise RosterError(f"profil {self.name!r} : variable {missing[0]} absente de .env "
                              "et de l'environnement — le harnais rendrait "
                              "« Not logged in » au premier jeton paye")
        return {name: source[name] for name in self.env}


# Le profil integre : la passerelle, une cle, tous les moteurs (ch. 15). Un
# roster sans bloc `auth:` tourne dessus ; un bloc `auth:` peut le redefinir.
GATEWAY_PROFILE = AuthProfile(name=DEFAULT_AUTH_NAME, regime="key", route="gateway",
                              env=("OPENROUTER_API_KEY",), hosts=("openrouter.ai",),
                              note="la passerelle : une cle, revocable et plafonnable par run, tous les moteurs")


@dataclass(frozen=True)
class AgentSpec:
    """Un agent du roster, defauts fusionnes : pret a etre lance tel quel."""
    name: str
    purpose: str
    harness: str
    model: str
    thinking: str
    tools: tuple[str, ...]
    writes: tuple[str, ...] | None   # None = libre · () = lecture seule · (...) = ces chemins
    limits: Limits = DEFAULT_LIMITS  # le coupe-circuit (A7)
    timeout: int = DEFAULT_TIMEOUT   # le mur de temps, cote Python (A7)
    auth: AuthProfile = GATEWAY_PROFILE  # le profil d'authentification, resolu (17bis)


@dataclass(frozen=True)
class Payload:
    """Le produit que l'usine travaille : ou il vit, et ce qui dit la verite sur lui."""
    dir: str                              # relatif a la racine du depot
    truth: tuple[tuple[str, ...], ...]    # des argv ; exit 0 = vert (gates.command, ch. 12)

    def commands(self) -> list[tuple[list[str], str]]:
        """Pret pour gates.command(cmd, cwd) : chaque commande tourne dans dir."""
        return [(list(cmd), self.dir) for cmd in self.truth]


# La declaration implicite des chapitres 12 a 26 — ce que les ADW codaient en
# dur. Elle ne sert plus que si AUCUN roster ne declare de payload.
DEFAULT_PAYLOAD = Payload(dir="apps/plume", truth=(("bun", "test"),))


@dataclass(frozen=True)
class Roster:
    """Le roster charge et valide, les regles communes, et le payload."""
    agents: dict[str, AgentSpec]
    protected_files: tuple[str, ...]
    data_dir: str
    payload: Payload = DEFAULT_PAYLOAD
    auth: dict[str, AuthProfile] | None = None   # None = le seul profil integre (17bis)

    def profile(self, name: str) -> AuthProfile:
        """Le profil par son nom — ou le refus, avec la liste des profils connus."""
        return _profile(self.auth, name)

    def credentials(self, agent: AgentSpec, env: dict[str, str] | None = None) -> dict[str, str]:
        """Ce que le port injectera dans le noeud de cet agent : son profil, resolu."""
        return agent.auth.credentials(env)

    def hosts(self) -> tuple[str, ...]:
        """L'union des hotes des profils UTILISES : ce que la porte du module 6 ouvrira."""
        return tuple(sorted({host for spec in self.agents.values() for host in spec.auth.hosts}))


def _profile(profiles: dict[str, AuthProfile] | None, name: str) -> AuthProfile:
    known = profiles or {DEFAULT_AUTH_NAME: GATEWAY_PROFILE}
    try:
        return known[name]
    except KeyError:
        raise RosterError(f"profil d'authentification inconnu {name!r} — "
                          f"declares : {sorted(known)}") from None


def load(path: str | Path = DEFAULT_PATH, env: dict[str, str] | None = None) -> Roster:
    """Charge, fusionne, valide — dans cet ordre, et tout ou rien.

    env : l'environnement dans lequel les profils sont resolus. None = .env
    puis l'environnement reel (la preseance du ch. 15) ; un dictionnaire
    explicite sert aux gates a sec.
    """
    path = Path(path)
    raw = _read(path)
    defaults = raw.get("defaults") or {}
    entries = raw.get("agents") or []
    if not entries:
        raise RosterError(f"{path} : aucun agent declare")

    profiles = _auth(raw, path)          # les profils d'abord : les agents les referencent
    agents: dict[str, AgentSpec] = {}
    for entry in entries:
        spec = _merge(defaults, entry, profiles)
        if spec.name in agents:
            raise RosterError(f"agent {spec.name!r} declare deux fois")
        agents[spec.name] = spec

    roster = Roster(
        agents=agents,
        protected_files=tuple(defaults.get("protected_files") or ()),
        data_dir=str(defaults.get("data_dir", "adws/adw_data")),
        payload=_payload(raw, path),
        auth=profiles,
    )
    for spec in agents.values():
        _validate(spec)
    # Les profils : chaque agent en reference un qui existe, et chaque
    # variable qu'il nomme est la — AVANT le premier jeton, jamais apres.
    if env is None:
        load_env()
    for spec in agents.values():
        try:
            roster.credentials(spec, env)
        except RosterError as error:
            raise RosterError(f"agent {spec.name!r} : {error}") from None
    return roster


def load_env(path: str | Path = ".env") -> None:
    """Le contrat du ch. 15 : .env dans l'environnement, sans jamais l'ecraser.

    Le port fait de meme au chargement ; le roster le refait ici pour que
    sa validation voie ce que le port verra — et rien de plus.
    """
    env_file = Path(path)
    if not env_file.is_file():
        return
    for line in env_file.read_text(encoding="utf-8").splitlines():
        line = line.strip()
        if not line or line.startswith("#") or "=" not in line:
            continue
        key, _, value = line.partition("=")
        os.environ.setdefault(key.strip(), value.strip().strip("'\""))


def _auth(raw: dict, path: Path) -> dict[str, AuthProfile] | None:
    """Le bloc `auth:` : un profil par nom, valide a zero token. Absent = gateway seul."""
    block = raw.get("auth")
    if block is None:
        return None
    if not isinstance(block, dict) or not block:
        raise RosterError(f"{path} : auth doit etre un bloc — un profil par nom")
    profiles: dict[str, AuthProfile] = {}
    for name, body in block.items():
        body = body or {}
        if not isinstance(body, dict):
            raise RosterError(f"{path} : profil {name!r} doit etre un bloc (regime, route, env, hosts, mount)")
        regime = str(body.get("regime", "key"))
        if regime not in REGIMES:
            raise RosterError(f"profil {name!r} : regime {regime!r} inconnu — {list(REGIMES)}")
        route = str(body.get("route", "gateway"))
        if route not in ROUTES:
            raise RosterError(f"profil {name!r} : route {route!r} inconnue — {list(ROUTES)}")
        names = tuple(str(v) for v in body.get("env") or ())
        for variable in names:
            if "=" in variable or not variable.isidentifier() or variable != variable.upper():
                raise RosterError(f"profil {name!r} : env attend des NOMS de variables "
                                  f"(MAJUSCULES, jamais de valeur) — recu {variable!r}")
        if regime in ("key", "plan") and not names:
            raise RosterError(f"profil {name!r} : un regime {regime!r} nomme au moins une variable")
        mount = body.get("mount")
        if mount is not None:
            mount = str(mount)
            # Relatif au depot, quel que soit l'OS : ni chemin absolu (POSIX ou
            # Windows), ni HOME, ni remontee par `..`.
            if (PurePosixPath(mount).is_absolute() or PureWindowsPath(mount).is_absolute()
                    or mount.startswith("~") or ".." in Path(mount).parts):
                raise RosterError(f"profil {name!r} : mount {mount!r} sort du depot — une session "
                                  "voyage sous un chemin relatif (data_dir de preference), jamais "
                                  "depuis votre HOME")
        if regime == "session" and mount is None:
            raise RosterError(f"profil {name!r} : un regime 'session' nomme le fichier a monter (mount)")
        profiles[str(name)] = AuthProfile(
            name=str(name), regime=regime, route=route, env=names,
            hosts=tuple(str(h) for h in body.get("hosts") or ()),
            mount=mount, note=str(body.get("note", "")),
        )
    return profiles


def _read(path: Path) -> dict:
    return yaml.safe_load(path.read_text(encoding="utf-8")) or {}


def _limits(defaults: dict, entry: dict, name: str) -> Limits:
    """Les limites : les defauts du module, puis defaults.limits, puis limits de l'agent — cle par cle."""
    merged = {**(defaults.get("limits") or {}), **(entry.get("limits") or {})}
    known = {f.name for f in fields(Limits)}
    unknown = sorted(set(merged) - known)
    if unknown:
        raise RosterError(f"agent {name!r} : limites inconnues {unknown} — connues : {sorted(known)}")
    try:
        return Limits(**{k: (float(v) if k == "cost_usd" else int(v)) for k, v in merged.items()})
    except (TypeError, ValueError) as error:
        raise RosterError(f"agent {name!r} : limits — {error}") from None


def _merge(defaults: dict, entry: dict, profiles: dict[str, AuthProfile] | None = None) -> AgentSpec:
    """L'agent par-dessus les defauts, cle par cle : il ne dit que ce qui differe."""
    writes = entry.get("writes", None)      # cle absente = None = libre dans le repo
    name = str(entry.get("name", ""))
    try:
        profile = _profile(profiles, str(entry.get("auth", defaults.get("auth", DEFAULT_AUTH_NAME))))
    except RosterError as error:
        raise RosterError(f"agent {name!r} : {error}") from None
    return AgentSpec(
        name=name,
        purpose=str(entry.get("purpose", "")),
        harness=str(entry.get("harness", defaults.get("harness", "pi"))),
        model=str(entry.get("model", defaults.get("model", ""))),
        thinking=str(entry.get("thinking", defaults.get("thinking", "medium"))),
        tools=tuple(entry.get("tools", defaults.get("tools") or ())),
        writes=None if writes is None else tuple(writes),
        auth=profile,
        limits=_limits(defaults, entry, name),
        timeout=int(entry.get("timeout", defaults.get("timeout", DEFAULT_TIMEOUT))),
    )


def _payload(raw: dict, path: Path) -> Payload:
    """Le payload appartient au DEPOT, pas au roster choisi par --config.

    Resolution en trois temps : le bloc `payload` du roster charge ; sinon
    celui du roster par defaut ; sinon la declaration implicite d'avant le
    chapitre 27.
    """
    block = raw.get("payload")
    if block is None and path.resolve() != DEFAULT_PATH.resolve() and DEFAULT_PATH.is_file():
        block = _read(DEFAULT_PATH).get("payload")
    if block is None:
        return DEFAULT_PAYLOAD
    if not isinstance(block, dict):
        raise RosterError(f"{path} : payload doit etre un bloc avec dir et truth")

    directory = str(block.get("dir") or "").strip()
    if not directory:
        raise RosterError(f"{path} : payload.dir manquant — ou vit le produit que l'usine travaille ?")

    truth: list[tuple[str, ...]] = []
    for entry in block.get("truth") or []:
        # Une liste argv, jamais une chaine shell : pas de quoting, pas d'injection.
        if (not isinstance(entry, list) or not entry
                or not all(isinstance(part, str) and part for part in entry)):
            raise RosterError(f"{path} : payload.truth — chaque commande est une liste argv "
                              f"non vide, jamais une chaine shell (recu {entry!r})")
        truth.append(tuple(entry))
    if not truth:
        raise RosterError(f"{path} : payload.truth vide — sans commande de verite, "
                          "aucune gate ne peut dire « fini »")
    return Payload(dir=directory, truth=tuple(truth))


def _validate(spec: AgentSpec) -> None:
    """Chaque miss echoue AVANT le lancement — jamais pendant, jamais en facture."""
    if not spec.name:
        raise RosterError("un agent sans nom n'est pas adressable")
    if not spec.purpose:
        raise RosterError(f"agent {spec.name!r} : purpose manquant — un agent, un role")
    if spec.harness not in HARNESSES:
        raise RosterError(f"agent {spec.name!r} : harnais inconnu {spec.harness!r} "
                          f"— disponibles : {list(HARNESSES)}")
    if "/" not in spec.model:
        raise RosterError(
            f"agent {spec.name!r} : modele {spec.model!r} — toujours provider/id : "
            "un motif nu devient ambigu des que deux fournisseurs portent le meme modele")
    if spec.thinking not in THINKING:
        raise RosterError(f"agent {spec.name!r} : thinking {spec.thinking!r} "
                          f"hors echelle {list(THINKING)}")
    if not spec.tools:
        raise RosterError(f"agent {spec.name!r} : aucun outil — "
                          "un agent sans outils ne peut rien proposer")
    unknown = [tool for tool in spec.tools if tool not in KNOWN_TOOLS]
    if unknown:
        raise RosterError(f"agent {spec.name!r} : outils inconnus {unknown} "
                          f"— integres : {list(KNOWN_TOOLS)}")
    # Le coupe-circuit : des plafonds positifs, une boucle detectable, un mur qui tient.
    lim = spec.limits
    for field_name in ("turns", "tool_calls", "cost_usd", "tool_timeout_s"):
        if getattr(lim, field_name) <= 0:
            raise RosterError(f"agent {spec.name!r} : limits.{field_name} doit etre > 0 — "
                              "un noeud d'usine a toujours un plafond")
    if lim.loop_threshold < 2 or lim.loop_window < lim.loop_threshold:
        raise RosterError(f"agent {spec.name!r} : loop_threshold >= 2 et loop_window >= loop_threshold "
                          f"(recu window={lim.loop_window}, threshold={lim.loop_threshold})")
    if spec.timeout <= 0:
        raise RosterError(f"agent {spec.name!r} : timeout doit etre > 0 (secondes)")
    if spec.timeout <= lim.tool_timeout_s:
        raise RosterError(f"agent {spec.name!r} : timeout ({spec.timeout} s) doit depasser "
                          f"limits.tool_timeout_s ({lim.tool_timeout_s} s) — sinon le mur tombe "
                          "avant le delai d'un seul appel d'outil")


if __name__ == "__main__":
    # La gate du module : le roster se charge, se fusionne, se valide — et
    # dit sur quel payload il travaille, avec quels plafonds. Lancer depuis la racine :
    #   uv run --with pyyaml python -m adws.adw_modules.roster [chemin-du-roster]
    loaded = load(sys.argv[1] if len(sys.argv) > 1 else DEFAULT_PATH)
    for agent_name in sorted(loaded.agents):
        spec = loaded.agents[agent_name]
        print(f"{spec.name:9} {spec.harness:7} {spec.model:34} "
              f"thinking={spec.thinking:7} writes={spec.writes}")
        profile = spec.auth
        print(f"{'':9} auth    : {profile.name} ({profile.regime}, route {profile.route}) — "
              f"{', '.join(profile.env) or 'aucune variable'}"
              + (f" ; mount {profile.mount}" if profile.mount else ""))
        print(f"{'':9} limites : {spec.limits.turns} tours, {spec.limits.tool_calls} outils, "
              f"{spec.limits.cost_usd} $, boucle {spec.limits.loop_threshold}/{spec.limits.loop_window}, "
              f"mur {spec.timeout} s")
    print("proteges :", ", ".join(loaded.protected_files))
    print("hotes    :", ", ".join(loaded.hosts()) or "(aucun — la porte n'ouvrira rien)")
    print("payload  :", loaded.payload.dir, "—",
          " ; ".join(" ".join(cmd) for cmd in loaded.payload.truth))
    # Les variables que recevra pi : six, nommees, jamais dans le prompt.
    sample = next(iter(loaded.agents.values())).limits.env()
    assert set(sample) == set(Limits.ENV.values()) and sample["FACTORY_LOOP_THRESHOLD"].isdigit()
    print("env      :", " ".join(sorted(sample)))

    # La gate a sec des profils (17bis) : un roster jetable, un environnement
    # explicite — profil inconnu refuse, variable absente NOMMEE, route ou
    # regime inconnus, montage hors du depot refuse, union des hotes calculee.
    # Zero token, aucun fichier .env lu.
    import tempfile

    HEAD = "payload: { dir: apps/plume, truth: [[bun, test]] }\ndefaults: { tools: [read] }\n"
    AUTH = ("auth:\n"
            "  gateway: { regime: key, env: [OPENROUTER_API_KEY], hosts: [openrouter.ai] }\n"
            "  glm-plan: { regime: plan, route: direct, env: [ZAI_API_KEY], hosts: [api.z.ai] }\n"
            "  claude-plan: { regime: plan, route: direct, env: [CLAUDE_CODE_OAUTH_TOKEN], hosts: [api.anthropic.com] }\n"
            "  claude-session: { regime: session, route: direct, env: [CLAUDE_CONFIG_DIR], mount: adws/adw_data/auth/claude, hosts: [api.anthropic.com] }\n")
    AGENTS = ("agents:\n"
              "  - { name: scout, purpose: reperer, model: a/b }\n"
              "  - { name: builder, purpose: construire, model: zai/glm-5.3, auth: glm-plan }\n"
              "  - { name: reviewer, purpose: juger, model: a/b, harness: claude, auth: claude-plan }\n"
              "  - { name: documenter, purpose: rediger, model: a/b, harness: claude, auth: claude-session }\n")
    full = {"OPENROUTER_API_KEY": "sk-or-x", "ZAI_API_KEY": "zai-x", "CLAUDE_CODE_OAUTH_TOKEN": "oat-x",
            "CLAUDE_CONFIG_DIR": "/usine/adws/adw_data/auth/claude"}

    def roster_file(text: str) -> Path:
        handle = tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False, encoding="utf-8")
        handle.write(text)
        handle.close()
        return Path(handle.name)

    # 1. Trois regimes, resolus : la cle, le forfait derriere une cle, la session sans variable.
    trio = load(roster_file(HEAD + AUTH + AGENTS), env=full)
    assert trio.credentials(trio.agents["scout"], full) == {"OPENROUTER_API_KEY": "sk-or-x"}
    assert trio.credentials(trio.agents["builder"], full) == {"ZAI_API_KEY": "zai-x"}
    assert trio.credentials(trio.agents["reviewer"], full) == {"CLAUDE_CODE_OAUTH_TOKEN": "oat-x"}
    assert trio.credentials(trio.agents["documenter"], full) == {"CLAUDE_CONFIG_DIR": "/usine/adws/adw_data/auth/claude"}
    assert not trio.agents["scout"].auth.direct and trio.agents["builder"].auth.direct
    assert trio.hosts() == ("api.anthropic.com", "api.z.ai", "openrouter.ai"), trio.hosts()
    assert trio.profile("claude-session").mount == "adws/adw_data/auth/claude"
    # 2. Sans bloc auth : gateway seul, un roster d'avant ce chapitre tourne tel quel.
    plain = load(roster_file(HEAD + "agents:\n  - { name: scout, purpose: reperer, model: a/b }\n"),
                 env={"OPENROUTER_API_KEY": "sk-or-x"})
    assert plain.auth is None and plain.hosts() == ("openrouter.ai",)
    # 3. Les refus, chacun avec son motif — avant tout lancement.
    for text, env, expected in [
        (HEAD + AUTH + "agents:\n  - { name: scout, purpose: reperer, model: a/b, auth: codex }\n",
         full, "profil d'authentification inconnu 'codex'"),
        (HEAD + AUTH + AGENTS, {"OPENROUTER_API_KEY": "sk-or-x"}, "variable ZAI_API_KEY absente"),
        (HEAD + "auth:\n  side: { regime: key, route: sideways, env: [X] }\n"
         + "agents:\n  - { name: scout, purpose: reperer, model: a/b, auth: side }\n", full, "route 'sideways' inconnue"),
        (HEAD + "auth:\n  home: { regime: session, mount: /Users/vous/.claude, hosts: [api.anthropic.com] }\n"
         + "agents:\n  - { name: scout, purpose: reperer, model: a/b, auth: home }\n", full, "sort du depot"),
        (HEAD + "auth:\n  up: { regime: session, mount: ../ailleurs/session.json }\n"
         + "agents:\n  - { name: scout, purpose: reperer, model: a/b, auth: up }\n", full, "sort du depot"),
        (HEAD + "auth:\n  leak: { regime: key, env: [OPENROUTER_API_KEY=sk-or-x] }\n"
         + "agents:\n  - { name: scout, purpose: reperer, model: a/b, auth: leak }\n", full, "jamais de valeur"),
        (HEAD + "auth:\n  odd: { regime: cookie, env: [X] }\n"
         + "agents:\n  - { name: scout, purpose: reperer, model: a/b, auth: odd }\n", full, "regime 'cookie' inconnu"),
    ]:
        try:
            load(roster_file(text), env=env)
            raise AssertionError(f"aurait du refuser : {expected}")
        except RosterError as error:
            assert expected in str(error), (expected, str(error))
    print("profils  : 3 regimes resolus (passerelle, direct, pi et claude), gateway seul sans bloc auth, union des "
          "hotes ; refus — profil inconnu, variable absente nommee, route inconnue, mount hors depot (x2), "
          "valeur dans env, regime inconnu")

Pièce — adws/adw_scout.py

Cette version remplace celle du chapitre 11. Le scout lui-même ne change pas : même brief, même enveloppe, mêmes phases. C’est la fabrique agent_action, que adw_plan.py et adw_build.py importent, qui évolue : elle transmet au nœud pi les limites et l’identité de la phase par l’environnement, applique le mur de temps de l’agent, mémorise la session même quand le port échoue, lit le rapport de garde pour rédiger la relance, crédite enfin run.tokens, et lit l’enveloppe de la porte typée avant le texte. Un --max-turns sert de bouton d’essai au coupe-circuit, sans roster de test. Le profil d’authentification du chapitre 17bis (auth:, route, coffre) est conservé.

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["pyyaml"]
# ///
"""adw_scout — la reconnaissance en lecture seule.

Usage :
    uv run adws/adw_scout.py "Ou vivent les tests de Plume ?" [--config ...] [--retries 2]
    uv run adws/adw_scout.py "..." --max-turns 2 --retries 0     # eprouver le coupe-circuit
    uv run adws/adw_scout.py "..." --auth glm-plan --model zai/glm-5.3   # un autre profil, pour CE run

Le premier ADW sous roster : le scout est declare dans factory.config.yaml,
le port traduit son profil en drapeaux, et l'etat des lieux verifie qu'il
n'a rien change. Sa sortie est une ScoutEnvelope : des findings types.

Version 17bis : la fabrique agent_action passe au port le PROFIL
D'AUTHENTIFICATION de l'agent, resolu par le roster (noms et valeurs des
variables — jamais dans le prompt, jamais dans le YAML) et sa route. Le port
ferme alors le coffre .env : le noeud ne recoit que ce que son profil nomme.
Deux boutons d'essai — --auth <profil> et --model <route> — pour essayer un
profil declare sans toucher au roster. adw_plan.py et adw_build.py heritent
du profil de leur agent sans changer d'une ligne.

Version annexe A7 : la fabrique agent_action transmet au noeud pi les
limites du roster (variables FACTORY_LIMIT_*) et l'identite de la phase
(FACTORY_PHASE, FACTORY_ROLE, FACTORY_ADW_ID), applique le mur de temps de
l'agent, lit le rapport de garde quand le coupe-circuit a coupe, et repart
dans la MEME session avec le motif exact. adw_plan.py et adw_build.py
l'importent et n'ont pas a changer.
"""
import argparse
import json
import os
import sys
import uuid
from contextlib import contextmanager
from dataclasses import asdict, dataclass, field, replace
from pathlib import Path

from adw_modules import envelopes, harness, permissions, roster
from adw_modules.envelopes import Envelope, EnvelopeError
from adw_modules.permissions import PermissionBreach
from adw_modules.runner import PhaseFailure, PhaseSpec, Run

# Le rapport de garde : ecrit par .pi/extensions/factory-guard.ts quand un
# compteur coupe la phase, lu ici pour rediger la relance. Un fichier par
# phase, sous le dossier de run cree par constat() — jamais commite.
GUARD_DIR = Path("adws/adw_data/runs")
GUARD_ENV = "FACTORY_GUARD_REPORT"


@dataclass(frozen=True)
class ScoutEnvelope(Envelope):
    """Ce que le scout doit au code : des trouvailles nommees, ou rien."""
    findings: list = field(default_factory=list, metadata={
        "ask": "liste d'objets {'file': chemin exact, 'note': pourquoi il compte}, [] sinon"})

    def check(self) -> None:
        super().check()
        for finding in self.findings:
            if not isinstance(finding, dict) or not finding.get("file"):
                raise EnvelopeError(
                    "chaque finding doit etre un objet avec au moins un champ 'file'")


SCOUT_BRIEF = """Tu es le scout de l'usine : trouve ou vivent les choses, ne change RIEN.
- Lecture seule : cherche, lis, lance des commandes de lecture — n'ecris jamais dans le repo.
- Cite des chemins exacts, avec un indice de ligne quand c'est utile.
- Ecris tes trouvailles en markdown dans {report} pour l'agent qui te suivra,
  et declare ce fichier dans 'artifacts'.
- Ne rien trouver est un resultat valide : dis-le simplement."""

# La relance apres un coupe-circuit : le motif exact, puis le contrat. Pas
# de reprise « a l'identique » : l'agent reprend ou il en etait, sans
# repeter le geste qui a coupe.
GUARD_ASK = """Ta tentative precedente a ete interrompue par l'usine : {reason}.
Reprends ou tu en etais, dans cette meme session, SANS repeter ce geste :
si une commande a deja rendu son resultat, lis-le au lieu de la relancer ;
si tu as assez d'elements, conclus. Termine par l'enveloppe demandee.

"""


def phase_env(phase_name: str, agent: roster.AgentSpec, run: Run) -> dict[str, str]:
    """Ce que le noeud pi doit savoir de la phase — par l'environnement, jamais par le prompt.

    Les limites du roster (coupe-circuit), l'identite de la phase (lue par
    les extensions du socle) et le chemin du rapport de garde.
    """
    variables = dict(agent.limits.env())
    variables.update({
        "FACTORY_PHASE": phase_name,
        "FACTORY_ROLE": agent.name,
        "FACTORY_ADW_ID": run.adw_id,
        GUARD_ENV: str((GUARD_DIR / run.adw_id / f"{phase_name}.guard.json").resolve()),
    })
    return variables


@contextmanager
def phase_environment(variables: dict[str, str]):
    """Pose les variables le temps de l'appel au port, puis restaure l'environnement.

    Le port herite de l'environnement du runner (c'est ainsi que _spawn
    transmet deja le schema de la porte) : ce contexte est le canal, borne
    a la duree de la phase — un ADW n'en lance jamais deux a la fois.
    """
    previous = {name: os.environ.get(name) for name in variables}
    os.environ.update(variables)
    try:
        yield
    finally:
        for name, value in previous.items():
            if value is None:
                os.environ.pop(name, None)
            else:
                os.environ[name] = value


def read_guard_report(path: str) -> dict | None:
    """Le rapport de garde s'il existe — consomme (supprime) pour ne jamais resservir."""
    report = Path(path)
    if not report.is_file():
        return None
    try:
        data = json.loads(report.read_text(encoding="utf-8"))
    except json.JSONDecodeError:
        data = None
    report.unlink(missing_ok=True)
    return data if isinstance(data, dict) else None


def agent_action(phase_name, agent, make_ask, expected):
    """Fabrique l'action d'une phase agent sous roster.

    L'AgentSpec fournit le harnais, le modele, le thinking, les outils, les
    limites et le mur de temps ; le port traduit les uns en drapeaux, le
    contexte d'environnement transmet les autres. La demande part a la
    tentative 1, la correction motivee ensuite — dans la MEME session, y
    compris apres un coupe-circuit : l'erreur du port porte la session.
    """
    next_ask = envelopes.correction("enveloppe invalide", expected)

    def action(run: Run, attempt: int):
        nonlocal next_ask
        ask = make_ask(run) if attempt == 0 else next_ask
        variables = phase_env(phase_name, agent, run)
        request = harness.HarnessRequest(
            prompt=ask, session_id=run.sessions.get(phase_name),
            model=agent.model, thinking=agent.thinking, tools=agent.tools,
            timeout=agent.timeout,
            # Le profil, resolu ICI, cote runner : le noeud n'emporte que ses
            # variables, le coffre .env reste ferme pour lui (17bis).
            auth=agent.auth.credentials(), direct=agent.auth.direct)
        try:
            with phase_environment(variables):
                result = harness.run(agent.harness, request)
        except harness.HarnessError as error:
            # La session existe meme en echec (A6) : la reprise la poursuit.
            if error.session_id:
                run.sessions[phase_name] = error.session_id
            guard = read_guard_report(variables[GUARD_ENV])
            if guard:
                # Le coupe-circuit a coupe : le motif exact devient la consigne.
                reason = str(guard.get("reason", "plafond atteint"))
                next_ask = GUARD_ASK.format(reason=reason) + envelopes.contract(expected)
                raise PhaseFailure(f"coupe-circuit ({guard.get('kind', '?')}) : {reason}") from None
            raise PhaseFailure(str(error)) from None
        # Memoriser la session AVANT de valider : un retry doit la poursuivre.
        run.sessions[phase_name] = result.session_id
        run.cost_usd += result.cost_usd
        run.tokens += result.tokens              # le compteur du ch. 20, enfin credite
        payload = result.envelope if result.envelope is not None else result.text
        try:
            return envelopes.parse(payload, expected)
        except EnvelopeError as error:
            next_ask = envelopes.correction(str(error), expected)
            raise PhaseFailure(f"enveloppe invalide : {error}") from None
    return action


def constat(phase_name, factory):
    """Phase code : le constat d'entree — et le dossier de run du runtime."""
    def action(run: Run, attempt: int):
        Path(factory.data_dir, "runs", run.adw_id).mkdir(parents=True, exist_ok=True)
        return permissions.snapshot(".")
    return action


def perimetre(phase_name, agent, factory):
    """Phase code : l'etat des lieux de sortie — la breche tue le run."""
    def action(run: Run, attempt: int):
        try:
            return permissions.enforce(".", agent, factory,
                                       run.results[f"constat_{phase_name}"])
        except PermissionBreach as breach:
            # Une breche n'est pas une gate : l'ecriture a deja eu lieu, on ne
            # re-prompte pas — la phase code meurt, et le run avec elle.
            raise PhaseFailure(str(breach)) from None
    return action


def report_path(factory, run: Run) -> str:
    return f"{factory.data_dir}/runs/{run.adw_id}/scout_findings.md"


def scout_ask(prompt, factory):
    """La mission du scout : le brief, votre demande, le contrat — dans cet ordre."""
    def make_ask(run: Run) -> str:
        return (SCOUT_BRIEF.format(report=report_path(factory, run))
                + f"\n\n### mission\n\n{prompt}\n\n"
                + envelopes.contract(ScoutEnvelope))
    return make_ask


def dispose(run: Run, attempt: int) -> ScoutEnvelope:
    """Phase code : le code dispose — verdict deterministe, zero token."""
    envelope: ScoutEnvelope = run.results["scout"]
    if envelope.status != "success":
        raise PhaseFailure(f"le scout declare lui-meme un echec : {envelope.summary!r}")
    print(json.dumps(asdict(envelope), indent=2, ensure_ascii=False))
    return envelope


def main() -> int:
    parser = argparse.ArgumentParser(
        description="La reconnaissance en lecture seule, sous roster.")
    parser.add_argument("prompt", help="ce que le scout doit trouver")
    parser.add_argument("--config", default=str(roster.DEFAULT_PATH))
    parser.add_argument("--retries", type=int, default=2)
    parser.add_argument("--max-turns", type=int, default=None,
                        help="abaisser le plafond de tours du scout pour CE run — "
                             "le bouton d'essai du coupe-circuit (A7)")
    parser.add_argument("--auth", default=None,
                        help="un autre profil d'authentification du roster pour CE run — "
                             "le bouton d'essai des profils (17bis)")
    parser.add_argument("--model", default=None,
                        help="le modele pour CE run — un profil a route directe attend une route "
                             "de son fournisseur (zai/glm-5.3), pas un id de la passerelle (17bis)")
    args = parser.parse_args()

    factory = roster.load(args.config)          # zero token : tout echec est gratuit
    agent = factory.agents["scout"]
    if args.max_turns is not None:
        # Le roster reste la verite ; ce run seul serre le filet — sans YAML de test.
        agent = replace(agent, limits=replace(agent.limits, turns=max(1, args.max_turns)))
    if args.auth is not None:
        # Un profil declare dans le roster, resolu avant tout jeton : un profil
        # inconnu ou une variable absente s'arretent ici, avec leur motif.
        profile = factory.profile(args.auth)
        profile.credentials()
        agent = replace(agent, auth=profile)
    if args.model is not None:
        agent = replace(agent, model=args.model)
    run = Run(adw_id=uuid.uuid4().hex[:8])
    return run.execute([
        PhaseSpec(name="constat_scout", kind="code", action=constat("scout", factory)),
        PhaseSpec(name="scout", kind="agent",
                  action=agent_action("scout", agent, scout_ask(args.prompt, factory),
                                      ScoutEnvelope),
                  retries=args.retries),
        PhaseSpec(name="perimetre_scout", kind="code",
                  action=perimetre("scout", agent, factory)),
        PhaseSpec(name="dispose", kind="code", action=dispose),
    ])


if __name__ == "__main__":
    sys.exit(main())

La gate du TP

Depuis la racine de plume-factory. Cinq commandes, une par ligne, identiques dans bash et PowerShell. Les deux premières ne coûtent rien, la troisième lance un scout avec un plafond de deux tours et aucune reprise (le run doit finir rouge, proprement), les deux dernières lisent la trace.

bun .pi/extensions/factory-guard.ts
uv run --with pyyaml python -m adws.adw_modules.roster
uv run adws/adw_scout.py "Liste les fichiers de tests de apps/plume et ce que chacun verifie." --max-turns 2 --retries 0
sqlite3 adws/adw_data/factory.db "SELECT type, name, json_extract(payload_json, '$.reason') FROM harness_by_phase WHERE adw_id = (SELECT adw_id FROM runs ORDER BY started_at DESC LIMIT 1) AND type IN ('guard', 'redact') ORDER BY ts;"
uv run adws/adw_scout.py "Liste les fichiers de tests de apps/plume et ce que chacun verifie."

Résultat attendu : factory-guard OK — seuils lus, boucle coupée au 5e appel identique, 4 secrets masqués, texte ordinaire intact. Le roster affiche pour chaque agent ses limites par défaut (60 tours, 200 outils, 2.0 $, boucle 5/20, mur 900 s) et les six variables FACTORY_*. Le scout bridé s’arrête après son deuxième tour avec, sur la sortie d’erreur, echec — coupe-circuit (tours) : 2 tours atteints (plafond 2) et un code de retour non nul, sans aucune HarnessError muette, et la session est dans run.sessions. La requête montre une ligne guard / armed puis guard / tours avec ce même motif (et une ligne redact si un résultat d’outil contenait un secret, ce qui ne devrait pas arriver). Le dernier scout, sans bride, rend son enveloppe verte comme au chapitre 11. Sans client sqlite3, uv run adws/adw_modules/harness_trace.py --last liste les mêmes événements. Coût : les deux gates à sec ne dépensent rien, le scout bridé coûte moins d’un centime (deux tours sur le modèle léger, ~20 s), et le scout complet un centime ou moins en 30 à 60 s sur le roster par défaut.


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.