Annexe — De l'usine au produit Chapitre C1 / 42

Brancher l'usine sur un vrai projet : contextes, gates riches, briefs par domaine

Première pièce de l'annexe « De l'usine au produit » : le payload se découpe en contextes nommés — un dossier, ses commandes de vérité, les chemins qu'un builder a le droit d'y toucher, un brief versionné — et le builder travaille dans un contexte à la fois, borné par le code, briefé par le roster.

Au chapitre 27, vous avez tamponné l’usine dans un dépôt vierge et relu son architecture : le port, les enveloppes, les gates, le roster, les traces. Plume, son payload, tient en huit fichiers et une commande de vérité, ce qui était voulu pour que chaque run reste lisible et bon marché. Mais le projet sur lequel vous voulez la faire tourner maintenant n’a rien de cela : une solution à douze projets, trois domaines qui ne se parlent que par contrats, des tests d’intégration qu’on ne lance pas à chaque tour, et des règles tacites que tout nouvel arrivant met un mois à apprendre. Un builder lâché là-dedans avec writes: null et bun test comme seule vérité fera ce qu’un nouvel arrivant sans mentor fait : toucher partout, casser loin, et ne pas le savoir. À la fin de ce chapitre, votre payload sera découpé en contextes nommés, chacun avec son dossier, ses commandes de vérité, les chemins qu’un builder a le droit d’y toucher et un brief versionné, et adw_build travaillera dans un contexte à la fois, borné par le code et briefé par le roster. Trois pièces : roster.py (qui remplace la version du chapitre A7), le roster factory.config.yaml (qui remplace celle du chapitre 27) et adw_build.py (idem). Plume reste le contexte d’exercice, votre projet est celui de la Config.

Un payload à plusieurs contextes

L’idée en une phrase

Un contexte est l’unité de travail d’un builder sur un vrai projet : un bounded context, un service, un module, ce qu’un agent peut lire en entier et vérifier en une commande. Il se déclare dans le bloc payload.contexts: du roster avec un dossier, ses commandes de vérité, ses chemins permis et son brief. C’est une pièce du squelette ADW, entièrement côté code : le roster la charge et la valide, permissions.enforce la fait respecter, les gates la vérifient, et l’agent n’y voit qu’un cadre de plus dans son ask.

Points clés

  • Un roster sans contexts a un contexte, default. Il est formé du dir et du truth du chapitre 27, et payload.dir, payload.truth et payload.commands() rendent exactement ce qu’ils rendaient. adw_sdlc (chapitre 13), adw_bench (chapitre 17) et le stamp (chapitre 26) tournent tels quels. Le découpage est une option que vous prenez le jour où le projet l’exige.
  • Chaque contexte porte ses quatre choses, et rien d’autre. dir (où les commandes de vérité tournent), truth (des argv, jamais une chaîne shell, le contrat du chapitre 27), writes (les chemins permis au builder, absent = le dossier du contexte seul) et brief (du texte, versionné avec le roster), plus un bloc libre adw:, vide par défaut, où un ADW spécialisé rangera ses réglages propres (le cycle TDD du chapitre C2 y lira tdd:). Un doublon de nom, une truth vide, un writes qui sort du dépôt, un dir manquant ou un adw qui n’est pas un bloc sont refusés au chargement, zéro jeton, comme tout le reste du roster.
  • Le contexte ne donne pas de droits, il en retire. adw_build borne le builder au contexte : un builder libre dans le dépôt (writes absent, chapitre 27) devient borné à writes du contexte, et un builder déjà borné garde l’intersection. protected_files tient toujours. Une écriture hors contexte est une brèche de périmètre (chapitre 10) : annulée, nommée, et le run meurt, car on ne re-prompte pas une écriture déjà faite.
  • Un ADW nomme un contexte, jamais un chemin. --context api, le premier contexte par défaut, et un nom inconnu s’arrête avant tout lancement avec la liste des contextes déclarés. C’est la même discipline qu’au chapitre 10 pour les agents : l’ADW nomme, le roster décide.
  • Le contexte voyage avec le roster, pas avec le moteur. Changez de roster (--config eco.config.yaml), le bloc payload reste celui du dépôt, résolu en trois temps comme au chapitre 27. Le même contexte, les mêmes bornes, les mêmes vérités, quel que soit le moteur qui y travaille.

Exemple concret

Une solution .NET de douze projets, trois bounded contexts (Commandes, Catalogue, Facturation), un builder sur le workhorse (z-ai/glm-5.3, 1,40 $ et 4,40 $ le million de jetons au moment d’écrire). Sans contexte, la spec « ajouter une remise sur les commandes groupées » envoie le builder lire la solution entière : trente fichiers ouverts, six ou sept tours de lecture avant la première ligne écrite, plus de cent mille jetons de contexte avant d’avoir commencé, de l’ordre d’un demi-dollar de lecture, et, deux fois sur cinq, une modification « utile » dans Catalogue que personne n’a demandée et qu’une revue devra défaire. Avec --context commandes : le builder lit src/Commandes et tests/Commandes.Tests, une douzaine de fichiers, le brief lui dit que le Catalogue n’est atteint que par son contrat et que les tests d’intégration ne se lancent pas ici. La vérité, c’est dotnet build en -warnaserror puis dotnet test filtré sur les tests unitaires : une minute au lieu de dix pour la suite complète. S’il touche quand même src/Catalogue/Prix.cs, permissions.enforce annule le fichier et nomme la brèche, et le run est rouge pour la bonne raison, sans un jeton de correction. Le même build coûte deux à trois fois moins de contexte, et ce qu’il produit tient dans un lot qu’un humain relit en une fois.

Le payload d’avant, le payload par contextes

Chapitre 27 (dir + truth)Annexe C1 (contexts:)
Ce que le builder littout le dépôtun contexte : son dossier, son brief
Ce que le builder écritwrites de l’agent (souvent libre)l’intersection avec writes du contexte
La véritéune liste de commandes pour tout le produitune liste par contexte, rapide et ciblée
Le savoir tacitedans la tête de l’humainbrief, versionné avec le roster
Un chemin hors périmètrebrèche si writes le ditbrèche, toujours : le contexte retire, n’ajoute pas
Compatibilitéun roster sans contexts tourne tel quel

Config — un payload .NET à trois contextes

À adapter à votre solution : ce bloc n’est pas exécuté par la gate du jour (Plume reste l’exercice), mais il est validé par le roster dès qu’il est collé, et c’est lui que vous poserez dans votre dépôt réel après un stamp (chapitre 26). Les commandes sont celles de la CLI dotnet : -warnaserror fait des avertissements des erreurs, --no-build réutilise le build précédent, --filter écarte les tests d’intégration du chemin rapide.

# factory.config.yaml — un produit reel : trois bounded contexts, chacun ses verites et ses bornes.
payload:
  contexts:
    - name: commandes
      dir: src/Commandes
      truth:
        - [dotnet, build, ../../tests/Commandes.Tests, --nologo, -warnaserror]   # le projet de tests reference le domaine
        - [dotnet, test, ../../tests/Commandes.Tests, --no-build, --nologo, --filter, "Category!=Integration"]
      writes: [src/Commandes/, tests/Commandes.Tests/]
      brief: |
        Bounded context Commandes (DDD) : agregats Commande et LigneDeCommande, invariants dans
        le domaine, jamais dans les handlers. Le Catalogue n'est atteint que par ICatalogueLecture
        (contrat dans src/Contrats) — ne jamais referencer src/Catalogue directement.
        Les tests d'integration (Category=Integration) tournent en CI, pas ici.
    - name: catalogue
      dir: src/Catalogue
      truth:
        - [dotnet, build, ../../tests/Catalogue.Tests, --nologo, -warnaserror]
        - [dotnet, test, ../../tests/Catalogue.Tests, --no-build, --nologo]
      writes: [src/Catalogue/, tests/Catalogue.Tests/]
      brief: Lecture seule pour les autres contextes ; toute evolution d'un contrat passe par src/Contrats et une spec dediee.
    - name: facturation
      dir: src/Facturation
      truth:
        - [dotnet, build, ../../tests/Facturation.Tests, --nologo, -warnaserror]
        - [dotnet, test, ../../tests/Facturation.Tests, --no-build, --nologo]
      writes: [src/Facturation/, tests/Facturation.Tests/]

Piège courant : « un contexte par projet .csproj, c’est le découpage naturel » est inexact. Le bon grain est celui d’une vérité en une commande et d’un brief en dix lignes : ce qu’un agent peut lire en entier et vérifier vite. Un contexte par fichier est trop fin (le builder n’a plus de vue), un contexte par solution est trop gros (on revient au chapitre 27). Si votre solution suit le DDD, le bounded context est presque toujours la bonne taille, et s’il ne l’est pas, le découpage en contextes de l’usine est une bonne raison de le faire.


Des gates riches et des briefs par domaine

L’idée en une phrase

Sur un vrai projet, la vérité d’un contexte n’est plus une commande mais une suite (build strict, tests unitaires filtrés, analyseurs) que les gates du chapitre 12 exécutent telles quelles, dans l’ordre, exit 0 par exit 0. Et le brief du contexte est ce que l’usine injecte en tête de l’ask du builder, avant la spec : conventions, dépendances autorisées, ce qu’on ne touche pas, écrit par le code depuis le roster, identique pour pi et pour Claude Code, jamais réinventé par l’agent.

Points clés

  • Une gate riche est une liste, pas un script. truth du contexte accepte autant de commandes que nécessaire. gates.command (chapitre 12) les lance une à une dans dir, garde la queue de sortie comme preuve, et gates.motif assemble le rapport. Une commande rouge suffit à rendre la phase rouge, et le motif repart au builder en enveloppe, dans sa session vivante (chapitre 13). Aucun changement dans gates.py : la richesse est dans le roster.
  • Vite d’abord, complet ensuite. Le chemin rapide d’un contexte (build strict, tests unitaires, --no-build) tient en une à deux minutes. Les tests d’intégration, les migrations, les tests de charge n’y sont pas : ils appartiennent à la CI, sur la branche du run, avec un humain au bout (chapitres C3 et C7). Une gate de dix minutes par tour de correction, c’est une boucle de correction que personne n’attend.
  • Le brief est du code, pas du prompt. Il vit dans le roster, versionné, relu en revue, et adw_build l’injecte entre le brief du rôle et la spec, sous un titre ### contexte, avec le dossier, les chemins permis et les commandes de vérité, que le builder connaît donc avant de les découvrir en tâtonnant. Même texte pour tous les moteurs : un changement de roster ne change pas ce que l’usine sait de votre produit.
  • Le brief dit ce qu’on ne touche pas, plus que ce qu’on fait. Les conventions du domaine, les contrats entre contextes, les dépendances interdites, les tests qu’on ne lance pas ici. La spec dit quoi faire, le brief dit où s’arrêter. Dix lignes suffisent, au-delà c’est une documentation, et sa place est dans le dépôt, que le scout saura trouver.
  • --show-truth est la gate à sec de tout cela. Il liste les contextes, leurs bornes et leurs commandes, sans rien lancer : c’est ce que vous relisez avant de poser un roster sur un projet client, et ce que le doctor (chapitre A10) pourra vérifier demain.

Exemple concret

Reprenez la remise sur les commandes groupées, contexte commandes. L’ask du builder commence par le brief du rôle (chapitre 12), puis ### contexte : commandes : « Tu travailles dans src/Commandes. Tu n’écris que sous : src/Commandes/, tests/Commandes.Tests/. Les commandes qui disent la vérité : dotnet build … -warnaserror, dotnet test … --filter Category!=Integration. Bounded context Commandes : agrégats… Le Catalogue n’est atteint que par ICatalogueLecture… », puis la spec, puis le contrat de l’enveloppe. Le builder n’a pas eu à lire src/Contrats pour deviner la règle : elle est écrite. Il implémente, lance lui-même le build et les tests (le brief du rôle le lui demande), rend son enveloppe. Les gates rejouent la suite : dotnet build vert, dotnet test rouge sur un cas limite de remise à zéro. Le motif repart en correction, même session, quelques centimes, deuxième passage vert, et le périmètre est intact. Coût total du run : une vingtaine de centimes sur le workhorse, deux à trois minutes de gates, et un diff que la revue du chapitre C3 relira dans un seul contexte.

Où vit quoi, sur un vrai projet

Ce qui compteOù il vitQui le lit
Le découpage en contextes, les bornes, les véritéspayload.contexts du roster (commité)roster.load(), à zéro token
Le brief d’un contextebrief du contexte, dans le rosteradw_build, injecté en tête d’ask
Le savoir long (architecture, décisions)le dépôt : docs/, ADR, AGENTS.mdle scout, quand la spec l’exige
Le périmètre d’écriture effectifscoped() dans adw_build, l’intersectionpermissions.enforce, après la phase
Les vérités lentes (intégration, charge)la CI, sur la branche du runun humain, avant de merger (C3)

Commande — la gate à sec, puis un build dans un contexte

--show-truth ne lance rien. Il montre ce que le roster a compris de votre produit, contexte par contexte, l’étoile marquant celui que --context (ou son absence) a choisi. Le build est le même qu’au chapitre 12, borné et briefé. Les deux harnais sont servis par le même port : le contexte est du texte dans l’ask et des bornes dans le code, pi ou Claude Code n’y changent rien.

# les contextes, leurs bornes, leurs verites — zero jeton
uv run adws/adw_build.py --show-truth
# un build dans le contexte plume (le premier, donc aussi le defaut) — la spec vient d'adw_plan (ch. 11)
uv run adws/adw_build.py specs/<votre-spec>.md --context plume

Piège courant : « puisque le brief est injecté, je peux y mettre l’architecture entière » est inexact. Chaque ligne du brief est renvoyée à chaque tour de chaque phase qui l’utilise : mille jetons de brief sur un build de vingt tours, c’est vingt mille jetons facturés pour un texte lu une fois. Le brief dit les bornes, le savoir long reste dans le dépôt, où le scout ne le lit que si la spec l’exige. Et « le contexte remplace la revue » ne tient pas davantage : il borne ce qu’un builder peut casser, pas ce qu’il a compris, et c’est le travail du chapitre suivant.


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

Sur le plan de l’usine, la pièce du jour ouvre une nouvelle zone, le produit, à la jointure du squelette ADW (le roster du chapitre 10, les gates du chapitre 12, le builder du chapitre 27) et des permissions du chapitre 10. La couture ne bouge pas : l’agent propose un patch dans un contexte, le code dispose du découpage, des bornes, des vérités, du brief, et du verdict. Ce qui traverse la frontière : le brief et les bornes vers l’agent, dans l’ask, et l’enveloppe de build vers le code, comme avant. Déterministe : la validation des contextes, l’intersection des périmètres, l’ordre des commandes de vérité, la brèche annulée. Délégué : l’implémentation, dans un cadre plus petit. Coût d’usage : zéro jeton pour tout cela, et un builder qui lit deux à trois fois moins de contexte sur un vrai projet, la première économie qui compte à l’échelle d’un client. Le jalon de l’annexe est posé : l’usine tourne sur un projet à plusieurs contextes. Le suivant fera du cycle TDD une discipline tenue par le code, et le troisième dira qu’un run vert n’est pas un run mergé.


Travaux pratiques — la pièce du jour

Trois fichiers à poser dans plume-factory, qui devient, chapitre après chapitre, votre usine logicielle agentique : le roster qui connaît les contextes, le roster YAML qui en déclare un, et le builder qui y travaille. Prérequis : l’usine complète des chapitres 1 à 27 et de l’annexe pi (les pièces repartent de leurs dernières versions : A7 pour roster.py, 27 pour les deux autres) et le profil d’authentification du chapitre 17bis.

Pièce — adws/adw_modules/roster.py

Cette version remplace celle du chapitre A7 (profil d’authentification du chapitre 17bis compris). Tout ce qui existait reste : mêmes dataclasses, même fusion, mêmes limites, mêmes profils, même gate. S’ajoutent Context (nom, dir, truth, writes, brief, adw, scope()), Payload.contexts avec context(), commands(name) et names(). dir, truth et commands() sans argument restent ceux du premier contexte, pour que rien d’antérieur ne change. S’ajoutent enfin la lecture et la validation du bloc payload.contexts, et la gate à sec des contextes.

"""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.

Depuis l'annexe C1, le payload se decoupe en CONTEXTES (bloc
`payload.contexts:`) : un nom, un dossier, ses commandes de verite, les
chemins qu'un builder a le droit d'y toucher, et un brief — ce qu'il faut
savoir de ce contexte avant d'y ecrire. Un roster sans `contexts` a un seul
contexte, `default`, forme de `dir` et `truth` : le payload du chapitre 27
tourne tel quel. Un ADW nomme un contexte (`--context`), jamais un chemin.
"""
from __future__ import annotations

import sys
import os
from dataclasses import dataclass, field, 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)


DEFAULT_CONTEXT = "default"


@dataclass(frozen=True)
class Context:
    """Un contexte du payload (C1) : un morceau du produit, ses verites, ses bornes.

    C'est l'unite de travail d'un builder sur un vrai projet : un bounded
    context, un service, un module — ce qu'un agent peut lire en entier et
    verifier en une commande. `writes` borne le builder a ce contexte
    (permissions.enforce, ch. 10) ; `brief` est ce que l'usine lui dit de
    ce contexte avant la spec — conventions, dependances, ce qu'on ne
    touche pas — versionne dans le roster, identique pour tous les moteurs.
    """
    name: str
    dir: str                              # relatif a la racine du depot
    truth: tuple[tuple[str, ...], ...]    # des argv ; exit 0 = vert (gates.command, ch. 12)
    writes: tuple[str, ...] = ()          # chemins permis au builder — () = dir/ seul
    brief: str = ""                       # injecte en tete de l'ask du builder
    adw: dict = field(default_factory=dict)   # reglages propres a un ADW specialise (ex. tdd:), libres

    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]

    def scope(self) -> tuple[str, ...]:
        """Ce que le builder a le droit de changer ici : writes, sinon le dossier du contexte."""
        return self.writes or (self.dir.rstrip("/") + "/",)


@dataclass(frozen=True)
class Payload:
    """Le produit que l'usine travaille : ou il vit, et ce qui dit la verite sur lui.

    Depuis C1, un payload est une suite de contextes ; `dir` et `truth`
    restent ceux du premier — le contexte par defaut — pour que tout ce qui
    lisait payload.dir / payload.truth / payload.commands() tourne tel quel.
    """
    dir: str
    truth: tuple[tuple[str, ...], ...]
    contexts: tuple[Context, ...] = ()

    def commands(self, context: str | None = None) -> list[tuple[list[str], str]]:
        """Les commandes de verite du contexte nomme — du contexte par defaut sinon."""
        return self.context(context).commands()

    def context(self, name: str | None = None) -> Context:
        """Un contexte par son nom (None = le premier) — ou le refus, avec la liste."""
        known = self.contexts or (Context(DEFAULT_CONTEXT, self.dir, self.truth),)
        if name is None:
            return known[0]
        for ctx in known:
            if ctx.name == name:
                return ctx
        raise RosterError(f"contexte inconnu {name!r} — declares : {[c.name for c in known]}")

    def names(self) -> tuple[str, ...]:
        return tuple(c.name for c in (self.contexts or (self.context(),)))


# 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, ou contexts")

    # C1 : des contextes nommes. Sans bloc contexts, dir + truth font un contexte unique.
    entries = block.get("contexts")
    if entries is None:
        return Payload(dir=_payload_dir(block, path, "payload"),
                       truth=_truth(block.get("truth"), path, "payload.truth"))
    if not isinstance(entries, list) or not entries:
        raise RosterError(f"{path} : payload.contexts doit etre une liste non vide de contextes")
    contexts: list[Context] = []
    for entry in entries:
        if not isinstance(entry, dict) or not str(entry.get("name") or "").strip():
            raise RosterError(f"{path} : chaque contexte est un bloc avec un name (recu {entry!r})")
        name = str(entry["name"]).strip()
        if any(c.name == name for c in contexts):
            raise RosterError(f"{path} : contexte {name!r} declare deux fois")
        where = f"contexte {name!r}"
        writes = tuple(str(w) for w in entry.get("writes") or ())
        for w in writes:
            if PurePosixPath(w).is_absolute() or PureWindowsPath(w).is_absolute() or ".." in Path(w).parts:
                raise RosterError(f"{path} : {where} — writes {w!r} sort du depot")
        adw = entry.get("adw") or {}
        if not isinstance(adw, dict):
            raise RosterError(f"{path} : {where} — adw doit etre un bloc (un sous-bloc par ADW specialise)")
        contexts.append(Context(name=name, dir=_payload_dir(entry, path, where),
                                truth=_truth(entry.get("truth"), path, f"{where}.truth"),
                                writes=writes, brief=str(entry.get("brief") or "").strip(),
                                adw=adw))
    first = contexts[0]
    return Payload(dir=first.dir, truth=first.truth, contexts=tuple(contexts))


def _payload_dir(block: dict, path: Path, where: str) -> str:
    directory = str(block.get("dir") or "").strip()
    if not directory:
        raise RosterError(f"{path} : {where}.dir manquant — ou vit ce morceau du produit ?")
    return directory


def _truth(raw: object, path: Path, where: str) -> tuple[tuple[str, ...], ...]:
    truth: list[tuple[str, ...]] = []
    for entry in raw 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} : {where} — 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} : {where} vide — sans commande de verite, "
                          "aucune gate ne peut dire « fini »")
    return 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))
    for ctx in loaded.payload.contexts or (loaded.payload.context(),):
        print(f"{'':9} contexte {ctx.name} : {ctx.dir} — "
              f"{' ; '.join(' '.join(cmd) for cmd in ctx.truth)} — writes {list(ctx.scope())}"
              + (f" — brief {len(ctx.brief)} car." if ctx.brief else ""))
    # 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")

    # La gate a sec des contextes (C1) : sans bloc, un contexte unique ; avec, des
    # contextes nommes, chacun ses verites et ses bornes ; les refus avant tout lancement.
    single = load(roster_file(HEAD + AGENTS + AUTH), env=full)
    assert single.payload.names() == ("default",) and single.payload.context().dir == "apps/plume"
    assert single.payload.commands() == [(["bun", "test"], "apps/plume")]
    assert single.payload.context().scope() == ("apps/plume/",)
    MULTI = ("payload:\n  contexts:\n"
             "    - { name: plume, dir: apps/plume, truth: [[bun, test]], brief: 'Editeur minimal.' }\n"
             "    - { name: api, dir: services/api, truth: [[dotnet, build], [dotnet, test, --no-build]],"
             " writes: [services/api/, tests/api/] }\n"
             "defaults: { tools: [read] }\n")
    multi = load(roster_file(MULTI + AGENTS + AUTH), env=full)
    assert multi.payload.names() == ("plume", "api") and multi.payload.dir == "apps/plume"
    assert multi.payload.commands() == [(["bun", "test"], "apps/plume")]           # le premier = defaut
    assert multi.payload.commands("api") == [(["dotnet", "build"], "services/api"),
                                             (["dotnet", "test", "--no-build"], "services/api")]
    assert multi.payload.context("api").scope() == ("services/api/", "tests/api/")
    assert multi.payload.context("plume").scope() == ("apps/plume/",) and multi.payload.context("plume").brief == "Editeur minimal."
    assert multi.payload.context("api").adw == {} and single.payload.context().adw == {}
    tuned = load(roster_file(MULTI.replace("brief: 'Editeur minimal.'", "brief: 'x', adw: { tdd: { test_glob: 'apps/plume/**/*.test.ts' } }")
                             + AGENTS + AUTH), env=full)
    assert tuned.payload.context("plume").adw["tdd"]["test_glob"] == "apps/plume/**/*.test.ts"
    for text, expected in [
        (MULTI.replace("name: api", "name: plume"), "declare deux fois"),
        ("payload:\n  contexts: []\n", "liste non vide"),
        ("payload:\n  contexts:\n    - { name: x, dir: a, truth: [] }\n", "truth vide"),
        ("payload:\n  contexts:\n    - { name: x, dir: a, truth: [[bun, test]], writes: [../ailleurs/] }\n", "sort du depot"),
        ("payload:\n  contexts:\n    - { name: x, truth: [[bun, test]] }\n", "dir manquant"),
        ("payload:\n  contexts:\n    - { name: x, dir: a, truth: [[bun, test]], adw: tdd }\n", "adw doit etre un bloc"),
    ]:
        try:
            load(roster_file(text + "defaults: { tools: [read] }\n" + AGENTS + AUTH), env=full)
            raise AssertionError(f"aurait du refuser : {expected}")
        except RosterError as error:
            assert expected in str(error), (expected, str(error))
    try:
        multi.payload.context("web")
        raise AssertionError("un contexte inconnu doit etre refuse")
    except RosterError as error:
        assert "contexte inconnu 'web'" in str(error)
    print("contextes: un contexte unique sans bloc, deux contextes nommes (verites, bornes, brief, bloc adw) ; refus — "
          "doublon, liste vide, truth vide, writes hors depot, dir manquant, bloc adw invalide, contexte inconnu")

Pièce — adws/adw_config/factory.config.yaml

Cette version remplace celle du chapitre 27. Les agents, les défauts, les fichiers protégés ne changent pas, et le bloc payload devient une liste contexts d’un seul élément, plume : le même dossier, la même vérité, plus ses bornes explicites et un brief de quatre lignes. Tout ce qui lisait payload.dir et payload.truth continue de lire le premier contexte. Le profil d’authentification du chapitre 17bis reste le profil intégré gateway : aucun bloc auth: tant que vous ne changez pas de régime.

# factory.config.yaml — le roster de l'usine : un agent, un role, un modele —
# et, depuis le chapitre 27, le PAYLOAD que l'usine travaille.
# Les ADW nomment des agents, jamais des modeles : changer de moteur, c'est
# changer UNE ligne ici, aucun script. Les ADW ne nomment pas non plus le
# payload : changer de produit, c'est changer le bloc payload, aucun script.
#
# Contrat du chapitre 15 : les identifiants ci-dessous sont ceux du REGISTRE
# de la passerelle (openrouter.ai/models) — la meme chaine dans ce fichier,
# dans la jauge (model_stack.py) et sur la facture. La route (prefixe
# openrouter/) est l'affaire de l'adaptateur pi du port, jamais du roster.
# Une seule cle : OPENROUTER_API_KEY, chargee depuis .env par le port.
# Identifiants reverifies le 2026-09-02 ; prix = fourchettes datees, pas des verites.
# Profils d'authentification (17bis) : aucun bloc auth: ici — le profil integre gateway s'applique.

# Le payload (ch. 27) : ou vit le produit, et ce qui dit la verite sur lui.
# Une seule declaration pour le depot : les autres rosters (eco, frontier,
# open-weights) n'ont pas a la repeter — ils heritent de celle-ci.
# Depuis l'annexe C1, le payload est une suite de CONTEXTES : le premier est
# le contexte par defaut (celui d'adw_sdlc, du stamp et de tout ce qui ne
# nomme pas de contexte). Plume n'en a qu'un ; un vrai produit en a plusieurs.
payload:
  contexts:
    - name: plume
      dir: apps/plume            # relatif a la racine ; les commandes de verite tournent ici
      truth:                     # exit 0 = vert, tout le reste = rouge (gates.command, ch. 12)
        - [bun, test]            # argv en liste, jamais une chaine shell : pas de quoting, pas d'injection
        # un linter ou un typecheck s'ajoutent ici, une ligne chacun, le jour ou le payload en a
      writes: [apps/plume/]      # ce qu'un builder a le droit de toucher ICI — le contexte retire, n'ajoute pas
      brief: |                   # ce que l'usine sait de ce contexte, avant la spec — dix lignes, pas plus
        Plume est un editeur minimaliste : Bun + TypeScript vanilla, sans framework, 5 a 8 fichiers.
        Les tests (bun test) sont la seule verite : un test par comportement, pas de mock du systeme de fichiers.
        Aucune dependance nouvelle sans la nommer dans summary ; l'API HTTP et la page d'accueil ne bougent
        que si la spec le demande explicitement.

defaults:
  harness: pi                    # pi | claude — les deux adaptateurs du port (ch. 7)
  model: deepseek/deepseek-v4-flash-0731   # leger : ~0,03-0,08 $/M entree selon la route
  thinking: medium               # off | minimal | low | medium | high | xhigh | max
  tools: [read, bash, grep, find, ls]      # lecture + execution ; ecrire se merite
  # Interdit a tout agent qui ne le nomme pas lui-meme dans son `writes` :
  # un agent ne doit pas pouvoir editer la machinerie qui juge son travail.
  protected_files:
    - adws/adw_modules/
    - adws/adw_config/
    - adws/adw_*.py
    - PLAN.md
  data_dir: adws/adw_data        # le runtime : TOUJOURS ouvert, jamais commite

agents:
  - name: scout
    purpose: Reperer ou vivent les choses dans le repo ; ne rien changer.
    thinking: low                # il rapporte, il ne decide pas
    writes: []                   # lecture seule vis-a-vis du repo — pas muet :
                                 # son rapport atterrit sous data_dir
    # write : indispensable pour poser ce rapport — l'allowlist regle
    # l'interface, c'est writes: [] (verifie apres coup) qui protege le repo.
    tools: [read, bash, grep, find, ls, write]

  - name: planner
    purpose: Transformer une demande en plan que le builder implemente sans questions.
    model: anthropic/claude-opus-5        # frontier : ~5 $/M entree, ~25 $/M sortie
    thinking: high               # le plan porte tout le run — c'est ici qu'on paie
    writes:
      - specs/                   # le plan est la seule trace qu'il laisse au repo
    tools: [read, bash, grep, find, ls, write]

  - 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
    thinking: high
    # pas de cle writes : libre dans le repo — mais protected_files tient toujours.
    tools: [read, bash, grep, find, ls, edit, write]

  - name: reviewer
    purpose: Confirmer que ce qui est construit est ce qui etait demande ; ne rien changer.
    model: google/gemini-3.7-flash        # intermediaire : ~0,38-0,75 $/M entree (paliers)
    thinking: high               # juger demande de reflechir, pas d'ecrire
    writes: []                   # un reviewer qui ne peut pas corriger ne peut pas
                                 # corriger en douce — regle, plus promesse

  - name: documenter
    purpose: Rediger apres coup ce qui a change et pourquoi ; n'ecrire que sous app_docs/.
    # modele et thinking herites des defauts (leger, medium) : rediger
    # d'apres un diff est un travail de rapport, pas de decision.
    writes:
      - app_docs/                # la memoire de l'usine — et rien d'autre
    tools: [read, bash, grep, find, ls, write]

Pièce — adws/adw_build.py

Cette version remplace celle du chapitre 27. L’enveloppe, le brief du rôle, les gates et dispose ne changent pas, et adw_sdlc (chapitre 13) importe TRUTH_COMMANDS, build_ask et gates_phase comme avant. S’ajoutent --context, context_brief (le titre ### contexte, le dossier, les bornes, les vérités, le brief du roster, injecté avant la spec), scoped (le builder borné au contexte, par intersection, jamais par union) et un --show-truth qui liste les contextes.

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["pyyaml"]
# ///
"""adw_build — implementer le plan, rien que le plan.

Usage :
    uv run adws/adw_build.py specs/ma-spec.md [--config ...] [--retries 2]
    uv run adws/adw_build.py specs/ma-spec.md --context api  # un contexte du payload (C1)
    uv run adws/adw_build.py --show-truth [--config ...]     # la gate a sec : les contextes

Le builder du roster implemente la spec produite par adw_plan, et les
gates verifient la verite de ce qu'il declare : les fichiers modifies
existent, les commandes de verite du payload rendent exit 0. Depuis le
chapitre 27, ces commandes se lisent dans le roster (bloc payload) : ce
script ne nomme plus le produit qu'il travaille.

Depuis l'annexe C1, le payload a des CONTEXTES et le builder travaille dans
UN contexte a la fois : --context <nom> borne ses ecritures aux chemins du
contexte (permissions.enforce, ch. 10), prend les commandes de verite du
contexte, et recoit son brief en tete de l'ask — ce que l'usine sait de ce
morceau du produit, versionne dans le roster, identique pour tous les
moteurs. Sans --context : le premier contexte, ou le payload d'avant C1.
"""
import argparse
import json
import sys
import uuid
from dataclasses import asdict, dataclass, field, replace
from pathlib import Path

from adw_modules import envelopes, gates, roster
from adw_modules.envelopes import Envelope, EnvelopeError
from adw_modules.runner import PhaseFailure, PhaseSpec, Run
from adw_scout import agent_action, constat, perimetre

# Les commandes de verite du payload, lues dans le roster PAR DEFAUT.
# adw_sdlc (ch. 13) importe cette constante et n'a pas a changer : le payload
# appartient au depot, pas au roster choisi par --config — la verite est la
# meme quel que soit le moteur. Une erreur de roster echoue ici, zero token.
DEFAULT_CONTEXT = roster.load().payload.context()   # le premier contexte (C1) — ou le payload d'avant
TRUTH_COMMANDS = DEFAULT_CONTEXT.commands()


@dataclass(frozen=True)
class BuildEnvelope(Envelope):
    """Ce que le builder doit au code : la liste exacte de ce qu'il a change."""
    changed_files: list = field(default_factory=list, metadata={
        "ask": "chemins de TOUS les fichiers modifies, [] si status=fail"})

    def check(self) -> None:
        super().check()
        if not all(isinstance(path, str) and path for path in self.changed_files):
            raise EnvelopeError("chaque entree de changed_files doit etre un chemin (str)")
        if self.status == "success" and not self.changed_files:
            raise EnvelopeError("un build reussi declare au moins un fichier modifie")


BUILDER_BRIEF = """Tu es le builder de l'usine : implemente la spec, exactement, rien de plus.
- Lis la spec EN ENTIER avant d'ecrire la moindre ligne.
- Fais le plus petit changement qui la satisfait ; ne refactore pas ce qui
  n'est pas demande.
- Verifie ton travail avant de repondre (lance la suite de tests) et juge
  sur le code retour, pas sur les mots de la sortie.
- Declare CHAQUE fichier modifie dans 'changed_files' — les gates verifient."""


def context_brief(context: roster.Context) -> str:
    """Ce que l'usine dit du contexte AVANT la spec : dossier, bornes, brief du roster."""
    lines = [f"### contexte : {context.name}",
             f"Tu travailles dans {context.dir}. Tu n'ecris que sous : "
             + ", ".join(context.scope()) + ".",
             "Les commandes qui disent la verite sur ce contexte : "
             + " ; ".join(" ".join(cmd) for cmd in context.truth) + "."]
    if context.brief:
        lines.append(context.brief)
    return "\n".join(lines)


def build_ask(spec_path: str, context: roster.Context | None = None):
    """La mission du builder : le brief, le contexte, la spec, le contrat — dans cet ordre.

    Sans contexte explicite (adw_sdlc, ch. 13), le contexte par defaut :
    son brief accompagne toujours la spec.
    """
    context = context or DEFAULT_CONTEXT

    def make_ask(run: Run) -> str:
        return (BUILDER_BRIEF
                + f"\n\n{context_brief(context)}"
                + f"\n\n### spec\n\nImplemente la spec : {spec_path}\n\n"
                + envelopes.contract(BuildEnvelope))
    return make_ask


def scoped(agent: roster.AgentSpec, context: roster.Context) -> roster.AgentSpec:
    """Le builder, borne au contexte : ses ecritures ne depassent pas les chemins du contexte.

    Un builder libre dans le repo (writes=None, ch. 27) devient borne au
    contexte ; un builder deja borne garde l'intersection, jamais l'union —
    le contexte ne donne pas de droits, il en retire.
    """
    scope = context.scope()
    if agent.writes is None:
        return replace(agent, writes=scope)
    kept = tuple(w for w in agent.writes if any(w.startswith(s) for s in scope))
    return replace(agent, writes=kept)


def gates_phase(truth_commands):
    """Fabrique la phase gates : la verite des declarations — zero token.

    Les commandes viennent du roster charge par main() : le meme geste que
    TRUTH_COMMANDS, sur le roster passe par --config.
    """
    def phase(run: Run, attempt: int) -> list:
        envelope: BuildEnvelope = run.results["build"]
        reports = [gates.changed_files_exist(envelope), gates.artifacts_exist(envelope)]
        reports += [gates.command(cmd, cwd)(envelope) for cmd, cwd in truth_commands]
        for report in reports:
            for checked in report.checks:
                verdict = "OK" if checked.ok else "KO"
                first_line = checked.note.splitlines()[0] if checked.note else ""
                print(f"[gate {report.gate}] {verdict} {checked.item} {first_line}",
                      file=sys.stderr)
        failed = gates.motif(reports)
        if failed:
            # Ici, l'echec arrete le run. Dans adw_sdlc (ch. 13), ce meme motif
            # repart vers le builder, en enveloppe, dans sa session vivante.
            raise PhaseFailure(f"gates en echec :\n{failed}")
        return reports
    return phase


def dispose(run: Run, attempt: int) -> BuildEnvelope:
    """Phase code : le code dispose — l'enveloppe ne s'affiche que gates vertes."""
    envelope: BuildEnvelope = run.results["build"]
    if envelope.status != "success":
        raise PhaseFailure(f"le builder 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="Le builder implemente une spec ; les gates verifient.")
    parser.add_argument("spec", nargs="?",
                        help="chemin de la spec a implementer (sous specs/)")
    parser.add_argument("--config", default=str(roster.DEFAULT_PATH))
    parser.add_argument("--retries", type=int, default=2)
    parser.add_argument("--context", default=None,
                        help="le contexte du payload a travailler (C1) — le premier par defaut")
    parser.add_argument("--show-truth", action="store_true",
                        help="afficher les contextes et leurs commandes de verite, puis sortir")
    args = parser.parse_args()

    factory = roster.load(args.config)       # zero token : tout echec est gratuit
    context = factory.payload.context(args.context)   # un contexte inconnu s'arrete ici
    truth = context.commands()
    if args.show_truth:
        for ctx in factory.payload.contexts or (context,):
            marker = "*" if ctx.name == context.name else " "
            print(f"{marker} {ctx.name:12} writes {list(ctx.scope())}")
            for cmd, cwd in ctx.commands():
                print(f"  {cwd} $ {' '.join(cmd)}")
        return 0
    if not args.spec:
        parser.error("spec manquante — uv run adws/adw_build.py specs/ma-spec.md")

    spec = Path(args.spec)
    if not spec.is_file() or spec.stat().st_size == 0:
        print(f"spec introuvable ou vide : {args.spec} — lancez adw_plan d'abord",
              file=sys.stderr)
        return 1    # la verification precede le lancement : zero token

    builder = scoped(factory.agents["builder"], context)   # borne au contexte, jamais elargi
    run = Run(adw_id=uuid.uuid4().hex[:8])
    return run.execute([
        PhaseSpec(name="constat_build", kind="code", action=constat("build", factory)),
        PhaseSpec(name="build", kind="agent",
                  action=agent_action("build", builder, build_ask(args.spec, context),
                                      BuildEnvelope),
                  retries=args.retries),
        PhaseSpec(name="perimetre_build", kind="code",
                  action=perimetre("build", builder, factory)),
        PhaseSpec(name="gates", kind="code", action=gates_phase(truth)),
        PhaseSpec(name="dispose", kind="code", action=dispose),
    ])


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

La gate du TP

Depuis la racine de plume-factory. Une commande par ligne, identiques dans bash et PowerShell. Les deux premières ne coûtent rien, la troisième produit une spec avec le planner du chapitre 11 (quelques centimes sur le roster éco), la quatrième lance le build dans le contexte plume, borné et briefé. Remplacez <votre-spec> par le nom que le planner a rendu dans spec_path.

uv run --with pyyaml python -m adws.adw_modules.roster
uv run adws/adw_build.py --show-truth
uv run adws/adw_plan.py "Ajoute un compteur de mots dans la barre d'etat de Plume, mis a jour a chaque frappe."
uv run adws/adw_build.py specs/<votre-spec>.md --context plume

Résultat attendu : le roster affiche, après la ligne payload : apps/plume — bun test, une ligne contexte plume : apps/plume — bun test — writes ['apps/plume/'] — brief 339 car. et se termine par contextes: un contexte unique sans bloc, deux contextes nommes (verites, bornes, brief, bloc adw) ; refus — doublon, liste vide, truth vide, writes hors depot, dir manquant, bloc adw invalide, contexte inconnu. --show-truth imprime * plume writes ['apps/plume/'] puis apps/plume $ bun test. Le planner rend une enveloppe avec un spec_path sous specs/, et le build rend son enveloppe verte comme au chapitre 12, gates vertes. Dans la trace du chapitre A4, le premier prompt de la phase build contient ### contexte : plume (la commande uv run adws/adw_modules/harness_trace.py --last le montre sans client sqlite3). Pour voir la borne agir sans attendre qu’un builder déborde, relancez le build avec --context web : il s’arrête avant tout jeton sur contexte inconnu 'web' — declares : ['plume']. Coût : les deux gates à sec ne dépensent rien, le plan quelques centimes, le build une vingtaine de centimes et deux à trois minutes sur le workhorse. Variante éco : --config adws/adw_config/eco.config.yaml, le contexte est le même. Pour votre propre projet, collez le bloc Config .NET dans votre roster tamponné et relancez --show-truth : c’est la première chose que vous relirez avec le client.


Quiz — teste tes connaissances
Annexe — De l'usine au produit 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.