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
DocumentStoreavec 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, relancebun testaprè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 estexecute, 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
| Bras | Palier des sièges | Ordre de grandeur | Ce que vous apprenez |
|---|---|---|---|
| éco | léger partout | ~10-20 centimes | le plancher : où le jugement manque sur une refonte |
| open-weights | workhorse ouvert | ~30-50 centimes | le rapport qualité-prix sur votre payload |
| factory | frontier au plan, workhorse au build | ~0,5-1 $ | ce que le plan frontier change dans le patch |
| frontier | frontier 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 testtranche 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.pyséquence desPhaseSpec,envelopes.pyparse et valide,gates.pyvé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.pyest le port, pas l’adaptateur. Depuis le chapitre 7, aucun ADW ne sait s’il parle à pi ou à Claude Code : il construit uneHarnessRequestet 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/plumeetbun testen 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 testpasse 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_existlit ce que l’enveloppe affirme,commandlit 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’usine | Rôle hexagonal | Posé au chapitre |
|---|---|---|
runner.py, envelopes.py, gates.py | le cœur : séquencement, contrats, acceptation — sans dépendance sortante | 8, 9, 12 |
harness.py + adaptateurs pi / Claude Code | port 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 → OTel | adaptateurs de sortie : la salle de contrôle | 18-20 |
sandbox_box.py → Podman, exe.dev | adaptateur d’infrastructure : où le cœur tourne, derrière un port | 22-25 |
skills factory, factory-orchestrator | adaptateurs pilotants : un agent (ou vous) qui appuie sur les boutons | 25-26 |
apps/plume | l’adaptateur payload : la chose travaillée, déclarée, interchangeable | 2, 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.