Distribution Chapitre 27 / 42

Capstone & la relecture hexagonale

Le dernier chapitre lance la refonte de Plume sur quatre rosters dans quatre boîtes, chiffres à l'appui, puis relit l'usine entière comme une architecture hexagonale : ports, adaptateurs, gates et TDD. La dernière pièce rembourse la dette du stamp — le payload entre dans le roster — et ferme le plan.

Au chapitre 2, vous avez regardé un agent construire Plume en un shot, et vous n’avez pas su dire qui avait décidé que c’était fini. Vingt-cinq chapitres plus tard, votre usine sait le dire à votre place : des phases nommées, des enveloppes typées, des gates, une trace, des boîtes jetables et un stamp. Il reste à la faire travailler pour de bon sur son payload, non pas une fonctionnalité de plus mais une refonte, et à comprendre pourquoi elle tient debout. À la fin de ce chapitre, vous aurez lancé la refonte de Plume sur quatre rosters dans quatre boîtes, lu les chiffres et choisi un patch. Vous saurez aussi relire chaque pièce posée depuis le chapitre 1 comme un port ou un adaptateur d’une architecture hexagonale, avec les gates comme tests écrits avant le code. La pièce du jour rembourse la dette que le stamp d’hier a nommée, le payload entre dans le roster et les ADW ne nomment plus Plume, et elle ferme le PLAN.md posé au chapitre 1.

Capstone : la refonte de Plume en best-of-N

L’idée en une phrase

Le capstone est la première demande que vous n’auriez pas confiée à un agent seul, refondre Plume en ports & adaptateurs sans casser un test, lancée par le fan-out du chapitre 25 sur vos quatre rosters, une boîte par bras, puis moissonnée. Il vit entièrement dans la zone hors-site de l’usine, côté code déterministe, et il ne vous demande qu’une chose : disposer.

Points clés

  • Une refonte est le test qui compte. Ajouter un compteur de mots (chapitre 25) touche un fichier. Extraire un port DocumentStore avec deux adaptateurs, fichier JSON et mémoire, touche le store, le serveur et les tests. C’est exactement la demande où un agent seul « améliore » en route, casse un test, et le répare en le modifiant. Votre usine, elle, relance bun test après chaque tentative du builder, et le reviewer juge contre la spec : un test réécrit est un finding, pas une victoire.
  • Le prompt tient en quatre lignes, et chaque agent le paie. La demande, où, « fini veut dire », hors périmètre : la forme du cookbook du chapitre 26. Pour une refonte, la ligne « hors périmètre » est la plus importante : aucun test existant modifié, aucune dépendance, aucun changement d’API HTTP. Sans elle, deux bras sur quatre reviendront verts avec des tests réécrits.
  • Quatre bras, une variable. Le commit est épinglé, la demande est la même, l’ADW est adw_sdlc, la porte est execute, et seul le roster varie. Les quatre rosters du chapitre 17, éco, open-weights, factory et frontier, sont la question posée : combien coûte une refonte correcte, et quel palier de modèle y suffit ?
  • Les chiffres viennent des clés, pas des estimations. La moisson relève le coût sur la clé jetable de chaque bras, la durée dans la trace, la taille du patch contre le commit épinglé. Vous obtenez, pour votre payload et votre demande, la seule réponse qui vaille : la vôtre, datée.
  • Le vainqueur n’est pas forcément le moins cher. La moisson propose le vert le moins cher, mais une refonte se juge aussi sur la forme du patch : un port nommé proprement, des adaptateurs symétriques, un test du contrat par adaptateur. Vous ouvrez les patches. C’est la partie que personne ne peut faire à votre place, et c’est pour cela que le code propose sans disposer.

Exemple concret

Vous lancez just sandbox fanout refonte "<la demande en quatre lignes>" suivi des quatre rosters. Quatre boîtes montent, chacune vérifie son roster, chacune reçoit le même commit : ~4 minutes, moins de cinq centimes au total avant que le premier builder n’écrive une ligne. Un quart d’heure plus tard, la moisson tombe. Le bras éco est rouge : son builder a extrait le port mais a laissé le serveur importer l’adaptateur fichier, et le reviewer a refusé, pour ~15 centimes. Le bras open-weights est vert, 14 tests inchangés plus 4 nouveaux, un patch de 5 fichiers, ~35 centimes, 9 minutes. Le bras factory est vert, patch de 6 fichiers avec un test de contrat partagé par les deux adaptateurs, ~80 centimes, 11 minutes. Le bras frontier est vert lui aussi, patch de 7 fichiers, une note du documenter qui explique le choix de l’interface, pour un peu plus de deux dollars et 13 minutes. La moisson propose l’open-weights, « vert, le moins cher des verts ». Vous ouvrez les trois patches : le test de contrat partagé du bras factory est ce que vous auriez écrit vous-même. Vous gardez celui-là, discard … --keep <factory> --yes, et vous appliquez son patch. Total du capstone : entre trois et quatre dollars, une vingtaine de minutes, pour une refonte, quatre implémentations comparables, et un chiffre par palier de modèle que vous ne pouviez pas deviner.

Les quatre bras, et ce qu’ils vous apprennent

BrasPalier des siègesOrdre de grandeurCe que vous apprenez
écoléger partout~10-20 centimesle plancher : où le jugement manque sur une refonte
open-weightsworkhorse ouvert~30-50 centimesle rapport qualité-prix sur votre payload
factoryfrontier au plan, workhorse au build~0,5-1 $ce que le plan frontier change dans le patch
frontierfrontier partout~2-3 $le plafond : ce qu’il apporte en plus, s’il apporte

Commande — la demande du capstone, dans les deux harnais

Le fan-out n’a qu’une version : c’est du code qui monte des boîtes, et chaque bras passe par le port du chapitre 7 avec le harnais de son roster. Ce qui vaut la peine d’être montré, c’est la demande elle-même, la même chaîne pour les quatre bras, et sa variante sur votre machine, pour qui ne monte pas de boîte.

# la demande du capstone — quatre lignes, la forme du cookbook (ch. 26)
# (une seule chaine entre guillemets ; les retours a la ligne sont dans la chaine)
just sandbox fanout refonte "Refonds Plume en ports et adaptateurs : extrais une interface DocumentStore (createDocument, updateDocument, getDocument, listDocuments) et fournis deux adaptateurs, JsonFileStore (le comportement actuel) et MemoryStore (pour les tests) ; le serveur HTTP ne depend que de l'interface. Ou : apps/plume. Fini veut dire : bun test vert, les tests existants inchanges, un test du contrat par adaptateur. Hors perimetre : l'API HTTP, la page d'accueil, toute dependance nouvelle, toute modification d'un test existant." adws/adw_config/eco.config.yaml adws/adw_config/open-weights.config.yaml adws/adw_config/factory.config.yaml adws/adw_config/frontier.config.yaml

# la moisson, un quart d'heure plus tard — zero token, rejouable
just sandbox harvest refonte-<la-date>-<6 hex>

# sans boite : la meme demande, un roster, sur votre machine (ch. 13) — quelques dizaines de centimes
uv run adws/adw_sdlc.py "<la meme demande>" --config adws/adw_config/open-weights.config.yaml

Piège courant : « une refonte, c’est trop gros pour l’usine, il faut un agent en interactif » est inexact. C’est l’inverse : plus la demande touche de fichiers, plus le juge extérieur compte. En interactif, vous êtes la gate, à chaque tour, avec votre attention pour seule trace. Dans l’usine, bun test tranche après chaque tentative, le reviewer confronte à la spec, et quatre bras vous donnent quatre patches à comparer au lieu d’un seul à surveiller.


L’usine relue : ports & adapters, gates et TDD

L’idée en une phrase

Relue après coup, votre usine est une architecture hexagonale. Un cœur déterministe, runner, enveloppes et gates, qui ne dépend d’aucun harnais, d’aucun modèle, d’aucune machine. Des ports qui disent ce dont le cœur a besoin (harness.py, le roster, la déclaration du payload). Des adaptateurs qui le fournissent (pi, Claude Code, OpenRouter, Podman, exe.dev, SQLite). Et les gates sont les tests écrits avant que l’agent ne travaille, ce qui fait de chaque phase build un cycle rouge → vert dont le code tient le chronomètre.

Points clés

  • Le cœur ne connaît personne. runner.py séquence des PhaseSpec, envelopes.py parse et valide, gates.py vérifie des déclarations sur le disque. Aucun des trois n’importe un harnais, un client HTTP ou une base : c’est la règle de dépendance, les flèches pointent vers le cœur, jamais depuis lui. Vous pouvez le prouver : ces trois modules ont chacun leur gate qui tourne sans réseau, sans clé, sans agent.
  • harness.py est le port, pas l’adaptateur. Depuis le chapitre 7, aucun ADW ne sait s’il parle à pi ou à Claude Code : il construit une HarnessRequest et lit une réponse. Les deux adaptateurs vivent derrière, et la route OpenRouter (chapitre 15) est un détail de l’adaptateur pi. Changer de harnais est une ligne de roster, ajouter un troisième harnais est un adaptateur de plus, zéro ligne d’ADW.
  • Le payload est un adaptateur, lui aussi. C’est la relecture qui manquait. Plume est la chose que l’usine travaille, un adaptateur côté « piloté », comme une base de données l’est pour un domaine. Tant qu’un ADW écrivait apps/plume et bun test en dur, le cœur dépendait de l’adaptateur : la flèche était à l’envers, et le stamp d’hier l’a comptée comme dette. La pièce du jour retourne la flèche : le roster déclare le payload (dir, truth), le cœur le lit, et Plume redevient interchangeable.
  • Les gates sont du TDD à l’échelle d’un agent. En TDD, vous écrivez le test, il est rouge, vous écrivez le code, il passe, vous refactorez. Dans l’usine : la commande de vérité existe avant le lancement (rouge tant que le builder n’a rien fait), le builder écrit, bun test passe ou son motif repart en enveloppe dans la même session (chapitre 13), et le reviewer juge la forme. Le cycle est le même, mais le code tient la boucle et l’agent n’a que la partie « écrire ».
  • Une gate teste une déclaration, jamais une prédiction. changed_files_exist lit ce que l’enveloppe affirme, command lit un code retour. Elle ne devine pas quels fichiers l’agent aurait dû toucher. C’est ce qui la rend déterministe, gratuite et rejouable, et c’est pourquoi le jugement sur la forme reste au reviewer, puis à vous.

Exemple concret

Prenez la phase build du SDLC et suivez les dépendances. adw_sdlc.py importe runner, envelopes, gates, harness, roster : cinq modules, zéro import de pi, de Claude Code, de requests ou de sqlite3. harness.run(builder.harness, request) choisit l’adaptateur par une chaîne du roster. tracer.py écrit dans SQLite, mais c’est le runner qui l’appelle : le tracer est un adaptateur de sortie, branché au chapitre 18 sans qu’aucun ADW ne change. Maintenant comptez ce qu’il faudrait toucher pour faire tourner l’usine sur un autre produit, une API Python testée par pytest. Jusqu’à hier, trois fichiers de l’usine nommaient Plume. Aujourd’hui, une ligne de roster (truth: [[uv, run, pytest, -q]]) et le dossier. Et pour un troisième harnais : un adaptateur derrière le port, zéro ligne d’ADW. Ce comptage, combien de fichiers changent quand une dépendance change, est la mesure honnête d’une architecture hexagonale, et il coûte zéro token à faire.

L’usine relue en ports et adaptateurs

Élément de l’usineRôle hexagonalPosé au chapitre
runner.py, envelopes.py, gates.pyle cœur : séquencement, contrats, acceptation — sans dépendance sortante8, 9, 12
harness.py + adaptateurs pi / Claude Codeport piloté n° 1 : « exécute ce prompt, rends ce texte »7, 11, 15
roster + payload:port de configuration : qui tourne, sur quoi, avec quelle vérité10, 27
tracer.py → SQLite, obs_export.py → OTeladaptateurs de sortie : la salle de contrôle18-20
sandbox_box.py → Podman, exe.devadaptateur d’infrastructure : le cœur tourne, derrière un port22-25
skills factory, factory-orchestratoradaptateurs pilotants : un agent (ou vous) qui appuie sur les boutons25-26
apps/plumel’adaptateur payload : la chose travaillée, déclarée, interchangeable2, 27

Config — le payload déclaré, et ce qui le lit

Voici le bloc qui rembourse la dette : la déclaration du payload dans le roster par défaut, et la ligne d’adw_build.py qui la lit. Les trois autres rosters n’ont pas à la répéter : le payload appartient au dépôt, pas au roster choisi par --config : un roster qui ne déclare rien hérite de celui de factory.config.yaml.

# adws/adw_config/factory.config.yaml — extrait (ch. 27)
# Le payload : ou vit le produit que l'usine travaille, et ce qui dit la
# verite sur son etat. Une seule declaration pour tout le depot.
payload:
  dir: apps/plume                # relatif a la racine ; les commandes tournent ici
  truth:                         # exit 0 = vert, tout le reste = rouge (ch. 12)
    - [bun, test]                # argv en liste, jamais une chaine shell
# adws/adw_build.py — extrait (ch. 27) : plus aucun chemin de Plume dans un ADW
factory = roster.load(args.config)
truth = factory.payload.commands()     # [(argv, cwd)] — pret pour gates.command

Piège courant : « l’architecture hexagonale, c’est une couche d’interfaces en plus, donc plus de code » est inexact. Votre usine n’a pas gagné un fichier aujourd’hui : elle a déplacé trois lignes d’un script vers une déclaration. Le port existait depuis le chapitre 7, ce qui manquait, c’était de regarder Plume comme un adaptateur. L’hexagone n’est pas une couche : c’est le sens des flèches.


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

Sur le plan de l’usine, la dernière pièce ne s’ajoute pas : elle retourne une flèche. Le roster par défaut (adws/adw_config/factory.config.yaml, posé au chapitre 10, enrichi aux chapitres 13 et 15) reçoit un bloc payload:. roster.py le charge et le valide comme il valide les agents, où une erreur coûte zéro token. adw_build.py ne nomme plus Plume et lit ses commandes de vérité dans le roster, ce qui vaut aussi pour adw_sdlc.py, qui les importe et n’a pas à changer. Le PLAN.md du chapitre 1 reçoit sa version finale : l’arbre tel qu’il est, les jalons cochés, la relecture hexagonale. Quatre fichiers pour un dernier chapitre : trois remboursent la dette, un ferme le plan. La loi ne bouge pas : l’agent propose, le code dispose, et aujourd’hui le code possède en plus la déclaration de ce qui dit la vérité. Rien ne traverse le port qui n’y passait déjà. À l’usage, la pièce coûte zéro token, et le capstone qu’elle rend possible sur n’importe quel payload coûte trois à quatre dollars pour quatre refontes comparables. À comparer à un agent seul en interactif, une refonte, aucune comparaison, et vous comme unique gate.


Travaux pratiques — la pièce du jour

Une pièce complète à poser dans le repo compagnon plume-factory, qui devient, chapitre après chapitre, votre usine logicielle agentique. Aujourd’hui, la dernière : le payload entre dans le roster, le builder le lit, le plan se ferme, puis vient le capstone.

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

Cette version remplace celle du chapitre 15. Aucun agent ne change, aucun identifiant de modèle ne change (les quatre ont été revérifiés sur le registre de la passerelle le 2 septembre 2026, et les prix restent des fourchettes datées). Un seul bloc s’ajoute, payload:, en tête : la déclaration que les ADW lisent désormais à la place de leurs lignes en dur.

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

# 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.
payload:
  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

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_modules/roster.py

Cette version remplace celle du chapitre 10. Tout ce qui existait reste : mêmes dataclasses, même fusion, mêmes validations, même gate. S’ajoutent Payload, sa résolution en trois temps (le roster chargé, sinon le roster par défaut, sinon la déclaration implicite d’avant ce chapitre, pour ne casser aucune pièce), et sa validation 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.

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 :
ou vit le produit que l'usine travaille, et quelles commandes disent la
verite sur son etat. Les ADW ne nomment plus le payload : ils lisent
roster.payload. Un depot tamponne (ch. 26) change ce bloc, aucun script.

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
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).
HARNESSES = ("pi", "claude")
THINKING = ("off", "minimal", "low", "medium", "high", "xhigh", "max")
KNOWN_TOOLS = ("read", "bash", "edit", "write", "grep", "find", "ls")


# 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 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
    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 : une usine
# d'avant ce chapitre continue de tourner telle quelle.
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 _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,
    )


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 (un roster frontier n'a pas a repeter ou vit
    le produit) ; 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,
        # et le meme geste que gates.command depuis le chapitre 12.
        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)}")


if __name__ == "__main__":
    # La gate du module : le roster se charge, se fusionne, se valide — et
    # dit sur quel payload il travaille. 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("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))

    # 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_build.py

Cette version remplace celle du chapitre 12. Plus aucun chemin de Plume : les commandes de vérité viennent du roster. TRUTH_COMMANDS reste exporté, car adw_sdlc.py (chapitre 13) l’importe et n’a pas à changer, mais il se lit désormais dans le roster par défaut. La phase gates lit celui du roster passé par --config, qui hérite du défaut quand il ne déclare rien. Un --show-truth sert de gate à sec : zéro token.

#!/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 --show-truth [--config ...]     # la gate a sec

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.
"""
import argparse
import json
import sys
import uuid
from dataclasses import asdict, dataclass, field
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.
TRUTH_COMMANDS = roster.load().payload.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 build_ask(spec_path: str):
    """La mission du builder : le brief, la spec, le contrat — dans cet ordre."""
    def make_ask(run: Run) -> str:
        return (BUILDER_BRIEF
                + f"\n\n### spec\n\nImplemente la spec : {spec_path}\n\n"
                + envelopes.contract(BuildEnvelope))
    return make_ask


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("--show-truth", action="store_true",
                        help="afficher les commandes de verite lues dans le roster, puis sortir")
    args = parser.parse_args()

    factory = roster.load(args.config)       # zero token : tout echec est gratuit
    truth = factory.payload.commands()
    if args.show_truth:
        for cmd, cwd in truth:
            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 = factory.agents["builder"]
    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),
                                      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())

Pièce — PLAN.md

Cette version remplace celle du chapitre 14, et c’est la dernière. L’arbre est celui de votre dépôt tel qu’il est, avec les déviations annoncées en route : pas de just/adws.just (les ADW se lancent par uv run, leur docstring Usage fait foi), pas de adws/prompts/ (les briefs vivent dans les ADW), pas de .claude/commands/ (un dossier de skill est une commande). Tous les jalons sont cochés, et la relecture hexagonale et une sixième règle de continuité s’ajoutent.

# plume-factory — le plan de l'usine

Ce dépôt contient une **usine logicielle agentique** et le payload qu'elle travaille.

Loi fondamentale : **l'agent propose, le code dispose.** Le code déterministe possède le
séquencement, les reprises et l'acceptation. Un agent n'est qu'un nœud borné à l'intérieur d'une
phase nommée. Le contexte ne traverse une frontière que dans une enveloppe JSON typée. Les gates
définissent « fini » ; un échec de gate revient à l'agent par la même porte qu'un rapport d'agent.

## Arbre — état final (ch. 27)

plume-factory/
├── PLAN.md                          # ce fichier — ch. 1, fermé ch. 27
├── Containerfile                    # l'image de base des boîtes Podman — ch. 21
├── justfile                         # recettes racine : doctor, hello, test, serve, fleet — ch. 5-6, 19, 22
├── just/
│   ├── obs.just                     # observer les runs — ch. 19
│   └── sandbox/
│       ├── lifecycle.just           # image ; create → fill → setup → execute → observe → teardown ; egress — ch. 21-24
│       ├── keys.just                # clés jetables : mint, plafond, révocation, reap — ch. 23
│       └── orch.just                # cmd, delegate, attach, shell ; fanout, harvest, discard — ch. 24-25
├── apps/plume/                      # le payload : app d'écriture Bun + TS — ch. 2 ; refondue ch. 27
├── specs/                           # la spec baseline (ch. 2), puis celles d'adw_plan (ch. 11+)
├── adws/
│   ├── hello_factory.py             # l'embryon du runner — ch. 3, 7
│   ├── doctor.py                    # le préflight du poste — ch. 4
│   ├── fleet.py                     # le poste herdr — ch. 6
│   ├── model_stack.py               # la jauge des moteurs face aux prix du jour — ch. 14
│   ├── adw_prompt.py                # une phase agent, une enveloppe — ch. 8-9
│   ├── adw_scout.py / adw_plan.py   # reconnaissance et plan — ch. 11
│   ├── adw_build.py                 # implémentation + gates, vérité lue dans le roster — ch. 12, 27
│   ├── adw_sdlc.py                  # la chaîne complète, boucle de correction — ch. 13
│   ├── adw_bench.py                 # mini-benchs par roster — ch. 17
│   ├── obs_lanes.py / obs_export.py # couloirs de nage ; coûts, OTel — ch. 19-20
│   ├── sandbox_preflight.py         # prérequis du hors-site — ch. 21
│   ├── sandbox_box.py               # le port « boîte » : Podman (défaut), exe.dev — ch. 22
│   ├── sandbox_gate.py              # la porte d'une boîte : proxy sur liste, relais, journal — ch. 22
│   ├── sandbox_lifecycle.py         # le cycle de vie d'une boîte — ch. 22-23
│   ├── sandbox_keys.py              # la frontière des credentials — ch. 23
│   ├── sandbox_orch.py              # orchestrateurs en boîte — ch. 24
│   ├── sandbox_bestof.py            # fan-out, moisson, discard — ch. 25
│   ├── adw_modules/
│   │   ├── harness.py               # LE port harnais + adaptateurs pi / claude — ch. 7, 11, 15
│   │   ├── runner.py                # phases, séquencement, reprises, trace — ch. 8, 18, 20
│   │   ├── envelopes.py             # enveloppes JSON typées — ch. 9
│   │   ├── roster.py                # agents, permissions, payload — ch. 10, 27
│   │   ├── permissions.py           # tools, writes, fichiers protégés — ch. 10
│   │   ├── gates.py                 # la vérité des déclarations — ch. 12
│   │   └── tracer.py                # événements de phase vers SQLite — ch. 18, 20
│   ├── adw_config/                  # factory (+ payload), frontier, open-weights, eco — ch. 10-17, 27
│   └── adw_data/                    # runtime : sessions, enveloppes, factory.db — jamais commité
├── app_docs/                        # sorties du documenter — ch. 13
├── .claude/skills/
│   ├── factory/                     # SKILL.md, scripts/stamp.py, cookbooks/ — ch. 26
│   └── factory-orchestrator/        # le brief de l'orchestrateur hôte — ch. 25
├── .env.sample                      # OPENROUTER_API_KEY ; provisioning côté hôte seulement
└── .gitignore                       # posé ch. 1

Déviations assumées par rapport au plan du chapitre 1 : pas de `just/adws.just` (les ADW se
lancent par `uv run adws/adw_*.py`, la docstring `Usage` fait foi), pas de `adws/prompts/` (les
briefs vivent dans les ADW, à côté des enveloppes qu'ils exigent), pas de `.claude/commands/`
(un dossier de skill est une commande).

## L'usine relue : ports et adaptateurs

| Élément | Rôle | Dépend de |
|---|---|---|
| runner, envelopes, gates | le cœur déterministe | rien de sortant |
| harness.py (+ pi, claude) | port piloté : exécuter un prompt | le cœur ne connaît que le port |
| roster + bloc payload | port de configuration : qui, sur quoi, quelle vérité | — |
| tracer → SQLite, obs_export → OTel | adaptateurs de sortie | appelés par le runner |
| sandbox_box → Podman, exe.dev | adaptateur d'infrastructure : où le cœur tourne | la surface just |
| skills factory, factory-orchestrator | adaptateurs pilotants : un agent appuie sur les boutons | la surface just |
| apps/plume | l'adaptateur payload : déclaré dans le roster, interchangeable | — |

Les flèches pointent vers le cœur. Compter les fichiers à toucher quand une dépendance change
est la mesure : un harnais de plus = un adaptateur ; un payload de plus = un bloc de roster.

## Jalons

- [x] ch. 1 — dépôt initialisé, PLAN.md et .gitignore posés
- [x] ch. 2 — Plume générée en un shot, `bun test` vert : la baseline « sans usine »
- [x] ch. 3 — hello_factory.py : un subprocess, une sortie JSON validée
- [x] ch. 7 — plus aucun appel direct au harnais hors adw_modules/harness.py
- [x] ch. 13 — premier adw_sdlc.py vert de bout en bout sur Plume
- [x] ch. 17 — quatre rosters interchangeables, adw_bench les compare
- [x] ch. 20 — chaque run laisse une trace SQLite exploitable
- [x] ch. 25 — un best-of-N de 3 à 5 rosters lancé, moissonné, comparé
- [x] ch. 27 — l'usine s'installe dans un repo vierge via son skill ; capstone : Plume refondue en best-of-N

## Règles de continuité

1. Une pièce posée ne se casse pas. Toute évolution redonne le fichier entier et l'annonce.
2. `adws/adw_data/` et `.env` ne sont jamais commités.
3. Chaque pièce a sa gate : la commande qui prouve qu'elle fonctionne, coût et durée à l'appui.
4. Le payload reste petit : les runs restent lisibles et bon marché.
5. Les ADW nomment des agents, jamais des modèles. Le roster décide du modèle.
6. Les ADW ne nomment jamais le payload. Le bloc `payload` du roster par défaut décide où il
   vit et ce qui dit la vérité sur lui.

La gate du TP

Quatre commandes à sec, une par ligne, depuis la racine de plume-factory, puis le capstone, qui demande Podman et l’image (ou un compte exe.dev) et un arbre de travail propre :

uv run --with pyyaml python -m adws.adw_modules.roster
uv run --with pyyaml python -m adws.adw_modules.roster adws/adw_config/eco.config.yaml
uv run adws/adw_build.py --show-truth
uv run .claude/skills/factory/scripts/stamp.py --into ../stamp-essai --dry-run

Attendu, zéro token : la première imprime le roster et se termine par payload : apps/plume — bun test. La deuxième, sur l’éco qui ne déclare rien, termine par la même ligne, par héritage du roster par défaut. La troisième imprime apps/plume $ bun test : le builder lit le roster. la quatrième ne relève plus qu’une poignée d’ancrages, dont adws/adw_config/factory.config.yaml, à la place de la ligne d’adw_build.py : dans un dépôt tamponné, c’est ce bloc que vous éditez. Restent, honnêtement, les recettes test et serve du justfile, le préflight du chapitre 4 et les scripts d’installation des boîtes : ils appartiennent à Plume, et le stamp continuera de les nommer.

Puis le capstone : la commande fanout de la fiche avec ses quatre rosters, un quart d’heure d’attente, just sandbox harvest refonte-<la-date>-<6 hex>. Entre trois et quatre dollars au total, l’essentiel sur le bras frontier, une vingtaine de minutes. Variante éco : ne passez que eco.config.yaml et open-weights.config.yaml, pour quelques dizaines de centimes, et déjà une comparaison. Sans boîte : uv run adws/adw_sdlc.py "<la demande>" --config adws/adw_config/open-weights.config.yaml sur votre machine, quelques dizaines de centimes, un seul patch. Ouvrez les patches, choisissez, discard … --keep <run> --yes, appliquez le patch gardé, bun test : vert, tests existants inchangés. Votre usine vient de refondre son payload, le PLAN.md est fermé, et l’annexe qui suit vous rendra propriétaire de votre harnais.


Quiz — teste tes connaissances
Distribution 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.