Build & les gates déterministes
Le builder implémente la spec d'hier, et l'usine gagne son étage de vérité : des gates déterministes qui tranchent, sur le disque et au code retour, ce que l'agent déclare avoir fait.
Hier, la chaîne scout → planner a posé sa première spec dans specs/. Relisez-la : des fichiers
à toucher, des changements à faire, comment vérifier. Aujourd’hui, l’usine tient sa promesse : le
builder du roster implémente cette spec, et vous n’aurez pas à le croire sur parole. À la fin du
chapitre, « fini » aura une définition que le code peut trancher : les fichiers déclarés existent
sur le disque, la suite de tests de Plume rend exit 0, le tout pour zéro token. Deux pièces se
posent : adws/adw_build.py, le troisième vrai workflow de l’usine, et
adws/adw_modules/gates.py, l’étage de vérité que le chapitre 9 vous avait promis. C’est le
chapitre où la loi du livre devient complète : l’agent propose, et le code, enfin outillé,
dispose vraiment.
adw_build : implémenter le plan, rien que le plan
L’idée en une phrase
Le builder est la phase agent la plus productive de l’usine : il implémente la spec
produite au chapitre 11, exactement, rien de plus. La pièce adws/adw_build.py qui
l’encadre vit côté code déterministe : cinq phases, dont une seule dépense des tokens, et une
BuildEnvelope qui exige la liste exacte des fichiers modifiés.
Points clés
- La spec est l’unique entrée. Le builder reçoit le chemin de la spec, le brief lui ordonne de la lire en entier avant d’écrire, et de faire le plus petit changement qui la satisfait. Votre demande d’origine, elle, reste dans la session du planner et ne traverse pas.
- Le profil vient du roster du chapitre 10 : un workhorse (
z-ai/glm-5.3au moment d’écrire, ~1,40 $ le million de tokens en entrée) enthinking: high, libre dans le repo, maisprotected_filestient toujours et l’état des lieux vérifie après coup. BuildEnvelopeajoutechanged_files: la liste de tous les fichiers modifiés.check()refuse un buildsuccessqui ne déclare rien : un builder qui a réussi a forcément touché quelque chose.- Déclarer n’est pas prouver. L’enveloppe reste un manifeste : le builder affirme avoir modifié ces fichiers et fait passer les tests. La vérité, c’est le deuxième sous-thème qui la vérifie.
- Le brief impose le bon réflexe : vérifier son travail avant de répondre, et juger sur le code retour de la suite de tests, jamais en cherchant le mot « error » dans la sortie.
Exemple concret
Lancez adw_build.py sur la spec du compteur de mots posée hier. Le builder lit la spec et les
fichiers de Plume qu’elle nomme (quelques dizaines de milliers de tokens sur le workhorse du
roster), écrit le code, lance bun test lui-même, puis rend son enveloppe : ~10 à
20 centimes, 2 à 5 minutes. Autour de cette unique phase agent : quatre phases code, toutes
gratuites, le constat d’entrée, l’état des lieux de sortie, les gates et le verdict. Comparez au
même travail confié en session interactive : vous auriez payé la relecture de la spec au prix
frontier, validé à l’œil, et rien ne serait rejouable demain. Ici, le run entier tient dans une
commande, et son verdict dans un code retour.
Les cinq phases d’adw_build
| Phase | Kind | Ce qu’elle fait | Coût |
|---|---|---|---|
constat_build | code | l’état des lieux d’entrée (chapitre 10) | zéro token |
build | agent | le builder implémente la spec | ~10 à 20 centimes |
perimetre_build | code | vérifie ce que le builder avait le droit de changer | zéro token |
gates | code | vérifie la vérité de ce qu’il déclare | zéro token |
dispose | code | le verdict final, enveloppe à l’appui | zéro token |
Script — la colonne vertébrale du workflow
Cette pièce ne touche pas le harnais : le roster décide qui tourne, le port du chapitre 11 traduit, une seule version suffit donc. Le cœur du fichier (complet dans les travaux pratiques) tient dans sa liste de phases :
# Quatre phases code autour d'une phase agent : la couture, lisible dans la liste.
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),
PhaseSpec(name="dispose", kind="code", action=dispose),
])
Piège courant : « donnons aussi au builder la demande d’origine, il comprendra mieux » paraît généreux, cela crée deux sources de vérité. Quand la demande et la spec divergent (et elles divergeront), le builder arbitre en silence, et vous ne saurez jamais laquelle il a suivie. La spec est le seul contrat : si elle est incomplète, c’est elle qu’on corrige, deux minutes et zéro token, c’était tout le levier du chapitre 11.
Les gates : lint, tests, typecheck en phases code
L’idée en une phrase
Une gate est une phase code qui vérifie la vérité (les fichiers déclarés existent sur le
disque, une commande du payload rend exit 0) et c’est elle qui définit « fini » dans
l’usine. La pièce adws/adw_modules/gates.py vit entièrement côté code déterministe et rend
des rapports qui disent ce qui a été vérifié, pas seulement vert ou rouge.
Points clés
- Trois étages, trois pièces : la syntaxe (
json.loads), le contrat (check(), chapitre 9), et maintenant la vérité, les gates. Chaque étage attrape ce que le précédent laisse passer, et chacun coûte zéro token. - Le code retour fait foi, jamais le texte. Une sortie qui contient « error » peut être verte,
un silence peut être rouge. La gate de commande lit
returncode, et garde la queue de la sortie comme preuve en cas d’échec. - Une gate rend un rapport, pas un booléen : la liste des vérifications faites, chacune avec son verdict et sa note. Une gate verte dit ce qu’elle a vérifié, et c’est ce qui rendra les traces du module 5 lisibles.
- Lint, tests, typecheck sont des commandes, pas des jugements : aujourd’hui Plume n’a que
bun test, mais un linter ou un typecheck s’ajouteront en une ligne chacun le jour où le payload en aura. Un agent qui redécouvrebun testà chaque run brûle des tokens pour apprendre ce qu’un subprocess sait déjà. - Gates et permissions ne répondent pas à la même question : l’état des lieux du chapitre 10 vérifie le droit de changer, la gate vérifie la qualité de ce qui a changé. Une brèche tue le run, un échec de gate est un travail que l’agent peut refaire, et le chapitre 13 le lui renverra en enveloppe, par la même porte qu’un rapport d’agent.
Exemple concret
Le builder rend une enveloppe success qui déclare trois fichiers, dont
apps/plume/wordcount.ts, sauf qu’il a écrit word_count.ts : la coquille est dans la
déclaration. La gate changed_files_exist compare au disque et échoue en quelques
millisecondes, motif nommé : « déclaré modifié mais introuvable ». Puis bun test déroule la
suite de Plume, une dizaine de tests, ~2 secondes, zéro token. Sans gates, cette enveloppe
mensongère passait : le run était vert, et vous découvriez l’écart plus tard, à la main, au prix
de votre temps. Les gates coûtent des millisecondes et achètent la seule chose qui manquait au
squelette : que « fini » soit vrai.
Les trois étages de vérification
| Étage | Question | Qui tranche | Depuis |
|---|---|---|---|
| Syntaxe | est-ce du JSON ? | json.loads | chapitre 8 |
| Contrat | les champs requis sont-ils là, admissibles ? | parse() + check() | chapitre 9 |
| Vérité | ce qui est déclaré est-il vrai sur le disque et au code retour ? | les gates | aujourd’hui |
Script — la gate de commande
Le cœur de la pièce : une fabrique, comme agent_action au chapitre 11. Chaque commande du
payload devient une gate en une ligne d’appel, et le verdict se lit là où il est fiable :
def command(cmd, cwd="."):
"""Fabrique une gate de commande : exit 0 = vert, tout le reste = rouge."""
label = " ".join(cmd)
def gate(envelope) -> GateReport:
report = GateReport(f"command({label})")
executable = shutil.which(cmd[0]) # resout bun.exe / bun.cmd sous Windows
if executable is None:
return report.check(label, False, f"{cmd[0]!r} introuvable dans le PATH")
proc = subprocess.run([executable, *cmd[1:]], cwd=cwd,
capture_output=True, text=True)
note = f"exit {proc.returncode}"
if proc.returncode != 0:
# La queue de la sortie voyage comme preuve : de quoi rediger,
# au chapitre 13, une correction que le builder comprendra.
note += "\n" + (proc.stdout + proc.stderr)[-TAIL:]
return report.check(label, proc.returncode == 0, note)
return gate
Piège courant : « avec de bonnes gates, plus besoin de reviewer » confond deux questions. La gate tranche ce qui est mécaniquement tranchable : ça compile, les tests passent, les fichiers existent. Elle ne saura jamais dire si ce qui a été construit est ce qui était demandé : un compteur de mots qui compte les caractères passe tous les tests qu’on a oublié d’écrire. La suite pose la première question, le reviewer du chapitre 13 posera la seconde.
Fil rouge — la pièce posée aujourd’hui
Deux fichiers rejoignent la zone « squelette ADW » du plan : adws/adw_modules/gates.py, posé à
côté des enveloppes qu’il vérifie, et adws/adw_build.py, troisième workflow de l’usine après le
scout et la chaîne de plan d’hier. La loi du livre s’applique désormais en entier : le builder
propose du code, une enveloppe et des déclarations, et le code dispose sur pièces. L’état des
lieux vérifie le droit (chapitre 10), les gates vérifient la vérité, et seul un run dont tout est
vrai est vert. Ce qui traverse la couture : la spec dans un sens, une BuildEnvelope dans
l’autre, et le motif d’échec des gates, déjà rédigé pour repartir vers l’agent au chapitre 13.
À l’usage : ~10 à 20 centimes et quelques minutes par build sur le workhorse du roster, gates
comprises pour zéro token. Ce que la pièce économise, c’est l’enveloppe crue sur parole, l’écart
silencieux entre « déclaré » et « vrai » qui se payait en heures de vérification manuelle. Demain,
chapitre 13 : l’échec de gate revient au builder en enveloppe, et la chaîne complète
plan → build → test → review → document tourne de bout en bout.
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, deux fichiers : l’étage de vérité
(gates.py) et le workflow de build qui s’en sert (adw_build.py). Rien de posé ne change : le
runner, les enveloppes, le roster, les permissions et les ADW d’hier tournent tels quels.
Pièce — adws/adw_modules/gates.py
L’étage de vérité, en bibliothèque standard uniquement. Le module vit entièrement côté déterministe : il compare les déclarations au disque et lit des codes retour. Il n’importe aucun autre module de l’usine, et une gate accepte toute enveloppe qui porte les champs qu’elle vérifie. Comme les autres modules, il porte sa propre gate : lancé directement, il se prouve lui-même.
"""gates — la verite des declarations, verifiee par le code.
L'enveloppe est un manifeste : l'agent AFFIRME avoir modifie tel fichier,
fait passer tels tests. La gate verifie — sur le disque, au code retour —
et rend un rapport qui dit CE qu'elle a verifie, pas seulement vert/rouge.
Une gate tranche ce qui est mecaniquement tranchable ; juger si le travail
repond a la demande restera l'affaire du reviewer (chapitre 13).
"""
from __future__ import annotations
import shutil
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
TAIL = 1000 # queue de sortie gardee comme preuve en cas d'echec
@dataclass(frozen=True)
class GateCheck:
"""Une verification : ce qui a ete regarde, le verdict, la note."""
item: str
ok: bool
note: str = ""
@dataclass
class GateReport:
"""Ce que rend toute gate : la liste des verifications qu'elle a faites.
Une gate verte dit ce qu'elle a verifie — pas seulement qu'elle a passe.
check() ajoute et rend self : ecrire une gate reste une boucle et un return.
"""
gate: str
checks: list[GateCheck] = field(default_factory=list)
def check(self, item: str, ok: bool, note: str = "") -> "GateReport":
self.checks.append(GateCheck(item=item, ok=ok, note=note))
return self
@property
def ok(self) -> bool:
return all(c.ok for c in self.checks)
def motif(reports: list[GateReport]) -> str:
"""Le motif d'echec agrege, chemin et preuve a l'appui.
Pret pour une PhaseFailure aujourd'hui — et pour une enveloppe de
correction au chapitre 13 : le meme texte repartira vers l'agent.
"""
return "\n".join(f"[{r.gate}] {c.item} : {c.note or 'echec'}"
for r in reports for c in r.checks if not c.ok)
def artifacts_exist(envelope) -> GateReport:
"""Chaque fichier declare dans artifacts existe et n'est pas vide."""
report = GateReport("artifacts_exist")
for declared in getattr(envelope, "artifacts", []):
path = Path(declared)
if not path.is_file():
report.check(declared, False, "declare mais introuvable")
elif path.stat().st_size == 0:
report.check(declared, False, "declare mais vide")
else:
report.check(declared, True, f"{path.stat().st_size} octets")
return report
def changed_files_exist(envelope) -> GateReport:
"""Chaque fichier que le builder declare avoir modifie existe sur le disque."""
report = GateReport("changed_files_exist")
for declared in getattr(envelope, "changed_files", []):
exists = Path(declared).is_file()
report.check(declared, exists, "" if exists else "declare modifie mais introuvable")
return report
def command(cmd, cwd="."):
"""Fabrique une gate de commande : exit 0 = vert, tout le reste = rouge.
Le verdict se lit sur le code retour, jamais dans le texte : une sortie
qui contient le mot 'error' peut etre verte, un silence peut etre rouge.
"""
label = " ".join(cmd)
def gate(envelope) -> GateReport:
report = GateReport(f"command({label})")
# Resoudre via le PATH : sous Windows, bun est un bun.exe que
# shutil.which trouve la ou le nom court echouerait — meme geste
# que dans harness.py au chapitre 11.
executable = shutil.which(cmd[0])
if executable is None:
return report.check(label, False, f"{cmd[0]!r} introuvable dans le PATH")
proc = subprocess.run([executable, *cmd[1:]], cwd=cwd,
capture_output=True, text=True)
note = f"exit {proc.returncode}"
if proc.returncode != 0:
note += "\n" + (proc.stdout + proc.stderr)[-TAIL:]
return report.check(label, proc.returncode == 0, note)
return gate
if __name__ == "__main__":
# La gate du module : les gates se prouvent elles-memes — zero token.
import sys
from types import SimpleNamespace
probe = Path("gate_probe.tmp")
probe.write_text("preuve", encoding="utf-8")
envelope = SimpleNamespace(artifacts=[str(probe)],
changed_files=[str(probe), "fantome.ts"])
assert artifacts_exist(envelope).ok, "le fichier ecrit devait passer"
report = changed_files_exist(envelope)
assert not report.ok, "le fichier fantome devait echouer"
print("motif rendu :", motif([report]))
assert command([sys.executable, "--version"])(envelope).ok
assert not command([sys.executable, "-c", "raise SystemExit(3)"])(envelope).ok
probe.unlink()
print("gates : OK — la verite se verifie, elle ne se declare pas")
Pièce — adws/adw_build.py
Le troisième workflow de l’usine. Il importe la mécanique posée au chapitre 11 (agent_action,
constat, perimetre) et n’ajoute que ce qui lui est propre : le sous-type BuildEnvelope, le
brief du builder, et la phase gates. La vérification de la spec précède tout lancement : une
spec introuvable coûte zéro token, comme une erreur de roster au chapitre 10.
#!/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]
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, la suite de tests de Plume rend exit 0. Aujourd'hui, un echec
de gate arrete le run ; le chapitre 13 le renverra a l'agent en enveloppe.
"""
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. Un linter ou un typecheck s'ajoutent
# ici, une ligne chacun, le jour ou Plume en aura.
TRUTH_COMMANDS = [(["bun", "test"], "apps/plume")]
@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(run: Run, attempt: int) -> list:
"""Phase code : la verite des declarations — zero token, quelques secondes."""
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:
# Aujourd'hui, l'echec arrete le run. Au chapitre 13, ce meme motif
# repartira vers le builder, en enveloppe, dans sa session vivante.
raise PhaseFailure(f"gates en echec :\n{failed}")
return reports
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", 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)
args = parser.parse_args()
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
factory = roster.load(args.config)
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),
PhaseSpec(name="dispose", kind="code", action=dispose),
])
if __name__ == "__main__":
sys.exit(main())
La gate du TP
Trois commandes, à la racine de plume-factory. Le nom de la spec est propre à votre usine : il
porte l’adw_id de votre run de plan et le slug choisi par le planner, d’où la deuxième
commande, qui le retrouve avant de le passer à la troisième :
# 1) les gates se prouvent elles-memes — zero token, instantane
uv run adws/adw_modules/gates.py
# 2) reperer le nom de VOTRE spec, posee au chapitre 11
ls specs/
# 3) le premier build sous gates — remplacez par le nom affiche ci-dessus
uv run adws/adw_build.py specs/<votre-spec>.md
Attendu : la première commande affiche le motif du fichier fantôme puis gates : OK. La dernière
déroule cinq phases : chaque gate imprime son verdict (OK sur chaque fichier déclaré, exit 0
pour bun test), puis l’enveloppe s’affiche et le run est vert : ~10 à 20 centimes, 2 à
5 minutes sur le workhorse du roster, les gates elles-mêmes coûtant zéro token. Quand les deux
passent, commitez : votre usine vient d’implémenter sa première spec, et « fini » a désormais une
définition que le code tranche.