La revue humaine obligatoire : code d'abord, humain ensuite
Le SDLC de l'usine ne touche plus votre branche : il livre son travail sur une branche de run, se suspend, et rien ne merge sans une approbation humaine enregistrée sur l'empreinte exacte du diff lu. Un run vert n'est pas un run mergé.
Depuis le chapitre 13, votre chaîne SDLC rend des runs verts : gates vertes dans la phase build,
review approuvée, compte rendu écrit sous app_docs/. Et les fichiers ? Dans votre arbre de
travail, sur votre branche courante, à côté de ce que vous étiez en train de faire. Qui a lu le diff
avant qu’il n’entre dans main ? Personne. Le run a dit « vert », et vert ressemblait à fini. À la
fin de ce chapitre, plus rien de ce que l’usine produit n’entre dans une branche partagée sans
qu’un humain l’ait lu et signé : la chaîne livre son travail sur une branche de run, revient sur
votre branche et se suspend, exactement comme la pause architecturale du chapitre C2. La
reprise est un geste de revue, en deux commandes, et le merge est une gate : il refuse sans
approbation, et il refuse si le diff a bougé depuis la signature. Trois pièces : approvals.py
(nouveau, le registre), adw_merge.py (nouveau, la porte) et adw_sdlc.py (qui remplace la
version du chapitre 13).
Un run vert n’est pas un run mergé
L’idée en une phrase
Un run SDLC a trois juges, et un seul décide du merge : les gates jugent la vérité du build (code, pendant la phase), le reviewer juge la conformité à la spec (agent, il propose), et un humain juge si ce diff entre dans la branche partagée (il dispose). Dans l’usine, ce troisième verdict n’est pas une convention d’équipe : c’est un état du run, une ligne dans un registre, et une gate que le code applique au moment du merge. La pièce du jour vit tout entière du côté déterministe de la couture, entre la fin du SDLC et votre branche.
Points clés
- La branche de run est posée par le code, depuis le périmètre. La dernière phase du SDLC crée
run/<adw_id>, y commite les fichiers que les périmètres ont vus changer (la spec du planner, les fichiers du builder, le compte rendu du documenter, chapitre 10), et revient sur votre branche. Jamais ce qu’un agent a déclaré danschanged_files, toujours ce que git a vu. Aucun agent ne lancegit commit: l’agent propose des fichiers, le code en fait un commit. - Le run reste ouvert tant qu’un humain n’a pas parlé. La phase de livraison lève
PhaseHold(chapitre C2) : code retour 3, événementrun_holdavec le geste attendu, runrunningsansended_at. Dansfactory.db, un run SDLC ne devientsuccessqu’au merge etfailqu’au rejet. La trace dit la vérité du produit, pas celle de l’agent. - L’approbation signe un diff, pas un identifiant. Chaque ligne du registre porte l’empreinte
du diff (sha256 de
git diff base tip, seize caractères) au moment où vous avez signé. Si la branche bouge après, un commit de plus, une retouche, l’empreinte change et l’approbation est périmée. Vous approuvez ce que vous avez lu, rien d’autre. - Le registre est en ajout seul.
adws/adw_data/approvals.jsonl, une ligne par verdict : date, run,approveoureject, auteur, empreinte, branche, note. Rien ne s’y réécrit, et c’est le dernier verdict qui compte. Le chapitre C5 en fera une pièce du dossier d’audit. - Le reviewer ne remplace pas la lecture. Son
approved: trueest un champ d’enveloppe, donc une proposition. Il vous fait gagner du temps en nommant les exigences et leurs preuves, il ne décide pas du merge. Un run que le reviewer refuse meurt en rouge (chapitre 13) et n’atteint jamais la porte. Un run qu’il approuve attend un humain.
Exemple concret
Vous lancez le SDLC sur Plume : « Ajouter un compteur de mots dans la barre d’état ». Dix-neuf
phases, cinq agent, quelques dizaines de centimes et une dizaine de minutes sur le roster par
défaut, comme au chapitre 13. Les gates passent à la deuxième tentative du builder, le reviewer
approuve, le compte rendu est écrit. Puis la phase livraison : trois fichiers vus par les
périmètres (specs/7c1e4b02-compteur-mots.md, apps/plume/status.ts, app_docs/7c1e4b02-compteur-mots.md)
partent dans un commit sur run/7c1e4b02, l’empreinte c5f3901441… est calculée, votre branche
redevient courante et propre, et le run rend 3. Le soir, just pending liste ce run avec son
motif, just show 7c1e4b02 affiche la demande, la spec, le coût, le --stat et la commande
git diff à lancer. Vous lisez le diff, trente lignes. just approve 7c1e4b02 --note "lu : compteur et test", puis just merge 7c1e4b02 : fast-forward, run_merge dans la trace, run success. La
porte a coûté zéro jeton et deux commandes. Sans elle, les mêmes trente lignes étaient dans
main depuis dix minutes, et personne ne les avait lues.
Trois verdicts sur un run
| Verdict | Qui | Quand | Ce qu’il vérifie | S’il échoue |
|---|---|---|---|---|
| gates | code | dans la phase build, après chaque tentative | fichiers déclarés présents, commandes de vérité du contexte à zéro | correction au builder, même session, borné par --fix-loops |
| review | agent (propose) | après le build | chaque exigence de la spec, preuve à l’appui | run rouge, findings nommés, la spec se corrige à zéro jeton |
| approbation | humain (dispose) | après la livraison, hors du run | le diff lu, celui-là et pas un autre | pas de merge, run fail au rejet avec son motif |
Config — les recettes de la porte dans votre justfile
Ajoutez ces recettes à la fin du justfile du chapitre 22. Le "$@" est permis par
set positional-arguments (chapitre 5) : une note entre guillemets arrive entière au script, là où
une interpolation la découperait en mots. Une commande par ligne, identiques sous bash et PowerShell.
# la file des runs suspendus : guidance (C2) et approbations (C3)
pending:
uv run adws/adw_merge.py pending
# ce qu'un run a livre : branche, fichiers, cout, empreinte — just show <adw_id>
show ADW_ID:
uv run adws/adw_merge.py show "$@"
# approuver un run LU : just approve <adw_id> --note "lu : RAS"
approve ADW_ID *ARGS:
uv run adws/adw_merge.py approve "$@"
# rejeter avec son motif : just reject <adw_id> --note "regression sur la sauvegarde"
reject ADW_ID *ARGS:
uv run adws/adw_merge.py reject "$@"
# merger un run approuve : fast-forward, ou refus motive (zero jeton)
merge ADW_ID:
uv run adws/adw_merge.py merge "$@"
Piège courant : « le reviewer a approuvé et les gates sont vertes, la revue humaine est une formalité » est inexact. Les gates vérifient que le build dit vrai, le reviewer vérifie que le build suit la spec, et aucun des deux ne vérifie que la spec était la bonne idée, ni que ce diff a sa place dans
mainaujourd’hui. C’est précisément le jugement que l’on ne délègue pas, et c’est pour cela qu’il coûte deux commandes et zéro jeton : la porte ne vous demande rien de coûteux, elle vous demande d’avoir lu.
L’approbation comme phase code
L’idée en une phrase
L’approbation n’est pas un prompt de plus ni un commentaire de PR : c’est une phase code
de la chaîne, livraison, qui pose la branche, écrit la fiche de livraison et suspend le run,
suivie d’une reprise par le code, adw_merge.py, qui applique une gate déterministe
(approbation présente, dernière, sur l’empreinte actuelle, depuis la bonne branche, en fast-forward)
avant d’avancer votre branche et de fermer le run dans la trace.
Points clés
- La suspension du chapitre C2, réutilisée telle quelle.
PhaseHoldétait la pause architecturale du cycle TDD. Ici la même exception, levée par la phaselivraison, fait du SDLC un run qui attend un humain. Le runner ne change pas d’une ligne : un run suspendu est un runrunningavec unrun_hold, et la filependingliste les deux sortes de suspension, une guidance TDD à donner et une approbation à signer, avec le geste attendu pour chacune. - La reprise n’est pas un re-run, c’est
adw_merge.py. Au chapitre C2, la reprise relançait l’ADW avec--resume. Ici la suite du run n’a plus rien à demander à un agent : lire, approuver, merger sont des gestes humains et des commandes déterministes. C’estmergequi écritrun_finishdans la trace, avec le coût et les jetons sommés depuis les phases (chapitre 20). - La gate du merge, dans l’ordre. Fiche de livraison présente (sinon ce run n’a rien livré),
pas déjà mergé, empreinte actuelle de la branche,
approvals.require(aucune approbation, rejet, ou empreinte différente : refus avec les deux empreintes), branche courante égale à la branche de départ, puisgit merge --ff-only. Si la base a avancé depuis la livraison, le fast-forward refuse : vous rebasez la branche du run, et vous ré-approuvez, puisque le diff aura bougé. - Le rejet ferme le run, et garde la branche.
rejectexige un motif, écritrun_reject, passe le run enfailet laisserun/<adw_id>en place pour relecture. Un run rejeté ne se merge pas, il se relance avec une meilleure spec : le motif que vous avez écrit est le début de la suivante. - Un arbre propre au départ, par construction. Pendant le run, les agents écrivent dans votre arbre de travail, c’est là que les gates tournent. La livraison emporte ensuite leur travail sur la branche et vous rend un arbre propre. Pour que la branche de run ne porte que le run, la chaîne refuse de démarrer sur un arbre modifié, avant le premier jeton, en nommant les chemins.
Exemple concret
Lundi, vous approuvez le run 7c1e4b02 sur l’empreinte c5f3901441…. Mardi, un collègue trouve
le compteur mal aligné, bascule sur run/7c1e4b02, retouche status.ts et commite. Mercredi,
just merge 7c1e4b02 refuse : « approbation périmée : le diff a bougé depuis la signature de sylvain
(approuvé c5f3901441…, actuel 3d4ad35f6a…) ». Zéro jeton, rien n’a bougé dans main, et le message
dit quoi faire : relire la branche, puis just approve. Vous relisez les cinq lignes de la
retouche, vous signez à nouveau, le merge passe. Autre matin : main a avancé d’un commit de
quelqu’un d’autre. Le fast-forward refuse, vous rebasez run/7c1e4b02, l’empreinte change, vous
relisez le diff rebasé et vous signez. Dans les deux cas la règle est la même : ce qui entre dans
main est ce qu’un humain a lu, pas ce qu’il avait lu.
Ce que la porte refuse, et le geste qui lève le refus
| Refus | Motif affiché | Le geste |
|---|---|---|
| aucune livraison | ce run n’a pas atteint la phase de livraison | just obs run <adw_id> dit où il s’est arrêté |
| aucune approbation | lisez le diff, puis just approve | just show, git diff, just approve |
| rejeté | run rejeté par X, avec la note | relancer le run avec une spec corrigée |
| approbation périmée | le diff a bougé, les deux empreintes | relire la branche, just approve à nouveau |
| mauvaise branche courante | vous êtes sur X, le run a été livré depuis Y | git checkout Y, relancer |
| fast-forward impossible | la base a avancé depuis la livraison | rebaser la branche du run, ré-approuver |
| déjà mergé | mergé le … | rien, c’est fait |
Commande — un run SDLC, de la demande au merge
La séquence complète, une commande par ligne, identique sous bash et PowerShell. La chaîne rend 3
à la fin : ce n’est pas une erreur, c’est l’attente. Remplacez <adw_id> par l’identifiant que la
suspension affiche. Une seule version du port : pi ou Claude Code, c’est le roster qui le dit, la
livraison et la porte sont en Python des deux côtés.
uv run adws/adw_sdlc.py --show-chain
uv run adws/adw_sdlc.py "Ajouter un compteur de mots dans la barre d'etat de Plume." --context plume
just pending
just show <adw_id>
git diff main run/<adw_id>
just approve <adw_id> --note "lu : compteur et son test, rien d'autre"
just merge <adw_id>
Piège courant : « puisque la CI sait relancer la chaîne, autant qu’elle approuve et merge toute seule quand tout est vert » est inexact. Un
approveécrit par la CI est une ligne signée « ci » sur une empreinte que personne n’a lue : la gate passe, la règle est morte. Le chapitre C7 fera tourner l’usine depuis la CI, et la CI s’arrêtera exactement ici, run suspendu, dossier d’audit déposé, un humain au bout. La porte n’a de valeur que si la signature a un nom, et un nom n’a de valeur que s’il a lu.
Fil rouge — la pièce posée aujourd’hui
Sur le plan de l’usine, la pièce du jour s’installe à la sortie du squelette ADW, entre la
dernière phase du SDLC et votre branche partagée : la zone produit, à côté du cycle TDD du
chapitre C2 dont elle reprend la suspension. La couture ne bouge pas, elle se ferme : l’agent
propose des fichiers, une enveloppe, un approved, et le code dispose de la branche, du commit,
de l’empreinte, du registre, du merge. Ce qui traverse la frontière vers vous, c’est une fiche de
livraison et un --stat, pas un résumé d’agent. Déterministe : approvals.py en entier, la
livraison, la file des suspendus, la gate du merge. Humain : la lecture et la signature. Coût
d’usage : zéro jeton, deux commandes, et le temps de lire un diff que vous auriez dû lire de
toute façon. Ce que la pièce fait économiser : un merge non lu, qui coûte toujours plus cher que le
run qui l’a produit. Le jalon est posé : plus rien ne merge sans humain, et les chapitres C5 à C7
le supposent.
Travaux pratiques — la pièce du jour
Trois fichiers à poser dans plume-factory, qui devient, chapitre après chapitre, votre usine
logicielle agentique : le registre des approbations, la porte du merge, et la chaîne SDLC qui
livre et se suspend. Prérequis : le runner du chapitre C2 (PhaseHold), scoped et les contextes
du chapitre C1, la fabrique du chapitre A7 (adw_scout.py), et une identité git configurée dans
le dépôt (git config user.name) : la livraison commite avec elle. Lancez la chaîne depuis un
arbre propre, elle le vérifie avant le premier jeton.
Pièce — adws/adw_modules/approvals.py
Nouveau : le registre, en ajout seul. diff_hash (l’empreinte, sha256 du texte de git diff
entre deux commits, seize caractères), record (un verdict, signé, lié à une empreinte),
history et latest, require (la gate : approbation présente, dernière, sur cette empreinte,
sinon le refus avec le geste), author_from (FACTORY_APPROVER, sinon l’identité git, sinon
l’utilisateur du poste). Le module ne dépend que de git et porte sa gate à sec.
"""approvals — le registre des approbations humaines (annexe C3).
Un run vert n'est pas un run merge. Entre les deux, un humain lit le diff
et signe : ce module est le registre de ces signatures. Un fichier JSONL
sous data_dir (adws/adw_data/approvals.jsonl), une ligne par verdict,
jamais reecrit : approve ou reject, par qui, pour quel run, sur quelle
EMPREINTE du diff. C'est l'empreinte qui fait la valeur de l'approbation :
elle lie la signature au code lu, pas a un identifiant de run. Si la
branche du run bouge apres la signature, l'approbation est perimee et le
merge refuse — a zero jeton, comme toute gate.
Le module ne connait ni le roster ni le harnais : il ne depend que de git.
Lance directement, il porte sa propre gate :
uv run adws/adw_modules/approvals.py # auto-test, zero token
"""
from __future__ import annotations
import getpass
import hashlib
import json
import os
import subprocess
import sys
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from pathlib import Path
REGISTRY_NAME = "approvals.jsonl"
VERDICTS = ("approve", "reject")
APPROVER_ENV = "FACTORY_APPROVER" # le nom du signataire, quand git ne le connait pas
class ApprovalError(RuntimeError):
"""Pas d'approbation valable : le motif exact, et le geste qui le leve."""
@dataclass(frozen=True)
class Approval:
"""Une ligne du registre : un verdict humain, date, signe, lie a un diff precis."""
ts: str
adw_id: str
verdict: str # approve | reject
author: str
diff_hash: str # l'empreinte du diff lu au moment de signer
branch: str # la branche du run, pour le lecteur du registre
note: str = ""
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
def registry_path(data_dir: str | Path) -> Path:
return Path(data_dir) / REGISTRY_NAME
def author_from(env: dict[str, str] | None = None, repo_root: str | Path = ".") -> str:
"""Qui signe : FACTORY_APPROVER, sinon l'identite git du depot, sinon l'utilisateur du poste."""
source = os.environ if env is None else env
name = (source.get(APPROVER_ENV) or "").strip()
if name:
return name
result = subprocess.run(["git", "config", "user.name"], cwd=repo_root,
capture_output=True, text=True)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
return getpass.getuser()
def diff_hash(repo_root: str | Path, base_sha: str, tip_sha: str) -> str:
"""L'empreinte du diff entre deux commits : sha256 du texte de `git diff`, 16 hex.
Deterministe : meme contenu, meme empreinte, quel que soit le poste.
Un commit de plus sur la branche, un fichier retouche, et l'empreinte
change — c'est exactement ce que le merge doit detecter.
"""
result = subprocess.run(["git", "diff", "--no-color", "--no-ext-diff", base_sha, tip_sha],
cwd=repo_root, capture_output=True, text=True,
encoding="utf-8", errors="replace")
if result.returncode != 0:
raise ApprovalError(f"git diff {base_sha[:8]} {tip_sha[:8]} a echoue : {result.stderr.strip()}")
return hashlib.sha256(result.stdout.encode("utf-8")).hexdigest()[:16]
def record(data_dir: str | Path, adw_id: str, verdict: str, author: str,
diff_hash: str, branch: str, note: str = "") -> Approval:
"""Ajoute une ligne au registre — jamais une reecriture : l'historique est la preuve."""
if verdict not in VERDICTS:
raise ApprovalError(f"verdict {verdict!r} inconnu — {list(VERDICTS)}")
if not author.strip():
raise ApprovalError("une approbation sans signataire n'en est pas une")
if not diff_hash:
raise ApprovalError("une approbation sans empreinte de diff ne lie rien")
approval = Approval(ts=now_iso(), adw_id=adw_id, verdict=verdict, author=author.strip(),
diff_hash=diff_hash, branch=branch, note=note.strip())
path = registry_path(data_dir)
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(asdict(approval), ensure_ascii=False) + "\n")
return approval
def history(data_dir: str | Path, adw_id: str | None = None) -> list[Approval]:
"""Les verdicts, dans l'ordre d'ecriture — tous, ou ceux d'un run."""
path = registry_path(data_dir)
if not path.is_file():
return []
entries: list[Approval] = []
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
data = json.loads(line)
if adw_id is None or data.get("adw_id") == adw_id:
entries.append(Approval(**data))
return entries
def latest(data_dir: str | Path, adw_id: str) -> Approval | None:
"""Le dernier mot sur un run — c'est lui qui compte."""
entries = history(data_dir, adw_id)
return entries[-1] if entries else None
def require(data_dir: str | Path, adw_id: str, current_hash: str) -> Approval:
"""La gate du merge : une approbation, la derniere, sur CE diff. Sinon le refus motive."""
last = latest(data_dir, adw_id)
if last is None:
raise ApprovalError(f"aucune approbation enregistree pour {adw_id} — "
f"lisez le diff, puis : just approve {adw_id}")
if last.verdict == "reject":
raise ApprovalError(f"run {adw_id} rejete par {last.author} le {last.ts}"
+ (f" : {last.note}" if last.note else "")
+ " — un run rejete ne se merge pas, il se relance")
if last.diff_hash != current_hash:
raise ApprovalError(f"approbation perimee : le diff a bouge depuis la signature de {last.author} "
f"(approuve {last.diff_hash}, actuel {current_hash}) — relisez la branche, "
f"puis : just approve {adw_id}")
return last
if __name__ == "__main__":
# La gate du module — zero token, zero reseau : un depot git jetable, trois verdicts.
import tempfile
def git(*args: str, cwd: Path) -> str:
done = subprocess.run(["git", "-c", "user.name=usine", "-c", "user.email=usine@plume.local", *args],
cwd=cwd, capture_output=True, text=True)
assert done.returncode == 0, (args, done.stderr)
return done.stdout.strip()
with tempfile.TemporaryDirectory() as tmp:
repo = Path(tmp) / "repo"
repo.mkdir()
git("init", "-q", "-b", "main", cwd=repo)
(repo / "a.txt").write_text("un\n", encoding="utf-8")
git("add", "a.txt", cwd=repo)
git("commit", "-q", "-m", "base", cwd=repo)
base = git("rev-parse", "HEAD", cwd=repo)
(repo / "a.txt").write_text("deux\n", encoding="utf-8")
git("commit", "-q", "-am", "run", cwd=repo)
tip = git("rev-parse", "HEAD", cwd=repo)
# L'empreinte : stable pour un meme diff, differente des que le diff change.
first = diff_hash(repo, base, tip)
assert first == diff_hash(repo, base, tip) and len(first) == 16
(repo / "a.txt").write_text("trois\n", encoding="utf-8")
git("commit", "-q", "-am", "retouche", cwd=repo)
moved = diff_hash(repo, base, git("rev-parse", "HEAD", cwd=repo))
assert moved != first
data = Path(tmp) / "adw_data"
# Sans approbation : refus, avec le geste.
try:
require(data, "r1", first)
raise AssertionError("un run sans approbation doit etre refuse")
except ApprovalError as error:
assert "aucune approbation" in str(error) and "just approve r1" in str(error)
# Approuve sur le bon diff : passe. Sur un diff qui a bouge : perime.
signed = record(data, "r1", "approve", "sylvain", first, "run/r1", "lu : RAS")
assert require(data, "r1", first) == signed
try:
require(data, "r1", moved)
raise AssertionError("un diff qui a bouge doit perimer l'approbation")
except ApprovalError as error:
assert "perimee" in str(error) and first in str(error) and moved in str(error)
# Le dernier mot compte : un reject apres un approve ferme la porte.
record(data, "r1", "reject", "sylvain", first, "run/r1", "regression sur la sauvegarde")
try:
require(data, "r1", first)
raise AssertionError("un run rejete ne se merge pas")
except ApprovalError as error:
assert "rejete" in str(error) and "regression" in str(error)
assert [a.verdict for a in history(data, "r1")] == ["approve", "reject"]
assert history(data, "autre") == [] and latest(data, "autre") is None
# Les refus d'ecriture : verdict inconnu, signataire vide, empreinte vide.
for args in (("r2", "maybe", "x", first), ("r2", "approve", " ", first), ("r2", "approve", "x", "")):
try:
record(data, *args, branch="run/r2")
raise AssertionError(f"aurait du refuser : {args}")
except ApprovalError:
pass
assert author_from({APPROVER_ENV: "revue-ci"}, repo) == "revue-ci"
git("config", "user.name", "Revue Plume", cwd=repo)
assert author_from({}, repo) == "Revue Plume" # l'identite git du depot
assert author_from({}, Path(tmp)).strip() # hors depot : l'utilisateur du poste
lines = registry_path(data).read_text(encoding="utf-8").strip().splitlines()
assert len(lines) == 2 and all(json.loads(l)["adw_id"] == "r1" for l in lines)
print("approvals OK — empreinte stable et sensible, refus sans approbation, approbation sur le bon diff, "
"perimee si le diff bouge, reject = dernier mot, registre JSONL en ajout seul")
Pièce — adws/adw_merge.py
Nouveau : la porte. deliver (la phase code que le SDLC appelle : branche run/<adw_id>, commit
des fichiers vus par les périmètres, retour sur votre branche, fiche delivery.json sous le
dossier du run), pending (la file des runs suspendus lue dans factory.db, guidance et
approbation confondues), show, approve, reject (ferme le run en échec, garde la branche) et
merge (la gate, puis le fast-forward, puis run_finish). --selftest rejoue tout dans un dépôt
jetable, zéro jeton.
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["pyyaml"]
# ///
"""adw_merge — la porte du merge : code d'abord, humain ensuite (annexe C3).
Usage :
uv run adws/adw_merge.py pending # la file des runs suspendus
uv run adws/adw_merge.py show <adw_id> # ce que le run a livre : branche, fichiers, cout
uv run adws/adw_merge.py approve <adw_id> [--note "..."] [--author "..."]
uv run adws/adw_merge.py reject <adw_id> --note "le motif"
uv run adws/adw_merge.py merge <adw_id> # fast-forward de la branche du run
uv run adws/adw_merge.py --selftest # la gate a sec, zero jeton
Le SDLC (adw_sdlc.py) ne touche plus votre branche : sa derniere phase code
LIVRE le travail du run sur une branche `run/<adw_id>`, revient sur la
branche de depart, et SUSPEND le run (PhaseHold, C2) « en attente
d'approbation ». Ce script est la reprise : un humain lit, approuve ou
rejette (registre adws/adw_data/approvals.jsonl), et `merge` n'avance la
branche que si la derniere signature porte sur le diff tel qu'il est
MAINTENANT. Un run suspendu passe `success` dans la trace au merge, `fail`
au reject : dans le journal comme dans git, un run vert n'est pas un run
merge.
"""
from __future__ import annotations
import argparse
import json
import sqlite3
import subprocess
import sys
from dataclasses import asdict, dataclass, field
from pathlib import Path
from adw_modules import approvals, roster
from adw_modules.approvals import ApprovalError
from adw_modules.tracer import Tracer
DELIVERY_FILE = "delivery.json" # sous adws/adw_data/runs/<adw_id>/ — la fiche de livraison
BRANCH_PREFIX = "run/"
class MergeError(RuntimeError):
"""Un refus de la porte : le motif exact, le geste attendu."""
def git(args: list[str], cwd: str | Path) -> str:
"""git en argv, jamais en chaine shell ; un echec devient un MergeError lisible."""
done = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True,
encoding="utf-8", errors="replace")
if done.returncode != 0:
raise MergeError(f"git {' '.join(args)} : {done.stderr.strip() or done.stdout.strip()}")
return done.stdout.strip()
# ---------- la fiche de livraison : ce que le run a pose, ou, depuis quoi ----------
@dataclass
class Delivery:
adw_id: str
branch: str
base: str # la branche de depart (main, develop...)
base_sha: str
tip_sha: str
diff_hash: str # l'empreinte au moment de la livraison
files: list = field(default_factory=list)
spec: str = ""
doc: str = ""
request: str = ""
delivered_at: str = ""
merged_at: str = ""
rejected_at: str = ""
def delivery_path(data_dir: str | Path, adw_id: str) -> Path:
return Path(data_dir) / "runs" / adw_id / DELIVERY_FILE
def save_delivery(data_dir: str | Path, delivery: Delivery) -> None:
path = delivery_path(data_dir, delivery.adw_id)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(asdict(delivery), indent=2, ensure_ascii=False), encoding="utf-8")
def load_delivery(data_dir: str | Path, adw_id: str) -> Delivery:
path = delivery_path(data_dir, adw_id)
if not path.is_file():
raise MergeError(f"aucune livraison pour {adw_id} : ce run n'a pas atteint la phase de livraison "
f"(ou n'est pas un SDLC) — `just obs run {adw_id}` dit ou il s'est arrete")
return Delivery(**json.loads(path.read_text(encoding="utf-8")))
def deliver(repo_root: str | Path, data_dir: str | Path, adw_id: str, files: list[str],
spec: str, doc: str, request: str) -> Delivery:
"""La phase code du SDLC : une branche, un commit, retour a la base — l'agent n'y touche pas.
Les fichiers sont ceux que le PERIMETRE a vus changer (ch. 10), jamais
ceux que l'agent declare. Le commit porte l'identifiant du run : la
trace et git se rejoignent par lui.
"""
base = git(["rev-parse", "--abbrev-ref", "HEAD"], repo_root)
if base == "HEAD":
raise MergeError("HEAD detachee : lancez l'usine depuis une branche, c'est elle qui recevra le merge")
branch = f"{BRANCH_PREFIX}{adw_id}"
if subprocess.run(["git", "rev-parse", "--verify", "--quiet", branch], cwd=repo_root,
capture_output=True).returncode == 0:
raise MergeError(f"la branche {branch} existe deja — un run, une branche, jamais deux")
base_sha = git(["rev-parse", "HEAD"], repo_root)
git(["checkout", "-q", "-b", branch], repo_root)
try:
git(["add", "--", *files], repo_root)
title = request.strip().splitlines()[0][:60] if request.strip() else "livraison"
git(["commit", "-q", "-m", f"adw {adw_id}: {title}",
"-m", f"spec: {spec}\ndoc: {doc}\nfichiers: {len(files)}"], repo_root)
tip_sha = git(["rev-parse", "HEAD"], repo_root)
except MergeError:
# Rien de livre : retour a la base, branche vide supprimee, le travail reste dans l'arbre.
subprocess.run(["git", "reset", "-q"], cwd=repo_root, capture_output=True)
git(["checkout", "-q", base], repo_root)
subprocess.run(["git", "branch", "-D", branch], cwd=repo_root, capture_output=True)
raise
# Votre branche de depart redevient la branche courante : le travail du run est sur la sienne.
git(["checkout", "-q", base], repo_root)
delivery = Delivery(adw_id=adw_id, branch=branch, base=base, base_sha=base_sha, tip_sha=tip_sha,
diff_hash=approvals.diff_hash(repo_root, base_sha, tip_sha),
files=sorted(files), spec=spec, doc=doc, request=request,
delivered_at=approvals.now_iso())
save_delivery(data_dir, delivery)
return delivery
def current_hash(repo_root: str | Path, delivery: Delivery) -> str:
"""L'empreinte du diff tel qu'il est MAINTENANT sur la branche du run."""
tip = git(["rev-parse", delivery.branch], repo_root)
return approvals.diff_hash(repo_root, delivery.base_sha, tip)
# ---------- la trace : lire la file, fermer le run ----------
def db_path(data_dir: str | Path) -> Path:
return Path(data_dir) / "factory.db"
def tracer_for(data_dir: str | Path) -> Tracer:
return Tracer(db_path=db_path(data_dir), jsonl_dir=Path(data_dir) / "traces")
def run_totals(data_dir: str | Path, adw_id: str) -> tuple[float, int]:
"""Le cout et les jetons d'un run suspendu : la somme de ses phases (ch. 20)."""
if not db_path(data_dir).is_file():
return 0.0, 0
conn = sqlite3.connect(db_path(data_dir))
cost, tokens = conn.execute("SELECT COALESCE(SUM(cost_usd), 0), COALESCE(SUM(tokens), 0) "
"FROM phases WHERE adw_id=?", (adw_id,)).fetchone()
conn.close()
return float(cost or 0.0), int(tokens or 0)
def pending(data_dir: str | Path) -> list[tuple[str, str, str, str]]:
"""La file des runs suspendus : ouverts dans la trace, avec le motif de leur run_hold."""
if not db_path(data_dir).is_file():
return []
conn = sqlite3.connect(db_path(data_dir))
rows = conn.execute(
"SELECT r.adw_id, r.adw_name, r.started_at, "
" (SELECT e.payload_json FROM events e WHERE e.adw_id = r.adw_id AND e.type = 'run_hold' "
" ORDER BY e.ts DESC LIMIT 1) AS hold "
"FROM runs r WHERE r.status = 'running' AND r.ended_at IS NULL "
"ORDER BY r.started_at").fetchall()
conn.close()
out = []
for adw_id, adw_name, started, hold in rows:
if hold is None:
continue # en cours, ou tue net : pas une suspension
reason = json.loads(hold).get("reason", "")
out.append((adw_id, adw_name or "", started or "", reason))
return out
# ---------- les verbes ----------
def show(repo_root: str | Path, data_dir: str | Path, adw_id: str) -> None:
delivery = load_delivery(data_dir, adw_id)
cost, tokens = run_totals(data_dir, adw_id)
state = "merge" if delivery.merged_at else "rejete" if delivery.rejected_at else "en attente d'approbation"
print(f"run {adw_id} — {state}")
print(f" demande : {delivery.request or '-'}")
print(f" branche : {delivery.branch} (depuis {delivery.base} @ {delivery.base_sha[:8]})")
print(f" spec : {delivery.spec or '-'}")
print(f" doc : {delivery.doc or '-'}")
print(f" cout : ~{cost:.4f} $, {tokens} jetons")
print(f" diff : empreinte {current_hash(repo_root, delivery)} "
f"(a la livraison {delivery.diff_hash})")
print(git(["diff", "--stat", delivery.base_sha, delivery.branch], repo_root))
for signed in approvals.history(data_dir, adw_id):
print(f" {signed.verdict:7} {signed.author} le {signed.ts} sur {signed.diff_hash}"
+ (f" : {signed.note}" if signed.note else ""))
print(f" lire : git diff {delivery.base_sha[:8]} {delivery.branch}")
def approve(repo_root: str | Path, data_dir: str | Path, adw_id: str, author: str, note: str) -> None:
delivery = load_delivery(data_dir, adw_id)
if delivery.merged_at:
raise MergeError(f"run {adw_id} deja merge le {delivery.merged_at} — rien a approuver")
fingerprint = current_hash(repo_root, delivery)
signed = approvals.record(data_dir, adw_id, "approve", author, fingerprint, delivery.branch, note)
tracer_for(data_dir).event(adw_id, "run_approve", signed.author,
payload={"diff_hash": fingerprint, "note": note})
print(f"approuve : {adw_id} par {signed.author} sur {fingerprint} — suite : just merge {adw_id}")
def reject(repo_root: str | Path, data_dir: str | Path, adw_id: str, author: str, note: str) -> None:
if not note.strip():
raise MergeError("un rejet nomme son motif (--note) : c'est lui qui servira a relancer")
delivery = load_delivery(data_dir, adw_id)
if delivery.merged_at:
raise MergeError(f"run {adw_id} deja merge le {delivery.merged_at} — un merge se defait avec git, pas ici")
fingerprint = current_hash(repo_root, delivery)
signed = approvals.record(data_dir, adw_id, "reject", author, fingerprint, delivery.branch, note)
tracer = tracer_for(data_dir)
tracer.event(adw_id, "run_reject", signed.author, payload={"diff_hash": fingerprint, "note": note})
cost, tokens = run_totals(data_dir, adw_id)
tracer.run_finish(adw_id, ok=False, cost_usd=cost, tokens=tokens) # le run se ferme : rouge
delivery.rejected_at = signed.ts
save_delivery(data_dir, delivery)
print(f"rejete : {adw_id} par {signed.author} — {note.strip()} ; la branche {delivery.branch} "
"reste la pour relecture, le run est ferme en echec")
def merge(repo_root: str | Path, data_dir: str | Path, adw_id: str) -> None:
"""La gate : approbation valable sur le diff ACTUEL, puis fast-forward — ou le refus motive."""
delivery = load_delivery(data_dir, adw_id)
if delivery.merged_at:
raise MergeError(f"run {adw_id} deja merge le {delivery.merged_at}")
fingerprint = current_hash(repo_root, delivery)
signed = approvals.require(data_dir, adw_id, fingerprint) # ApprovalError = refus, zero jeton
here = git(["rev-parse", "--abbrev-ref", "HEAD"], repo_root)
if here != delivery.base:
raise MergeError(f"vous etes sur {here}, le run a ete livre depuis {delivery.base} — "
f"git checkout {delivery.base}, puis relancez")
try:
git(["merge", "--ff-only", "-q", delivery.branch], repo_root)
except MergeError as error:
raise MergeError(f"fast-forward impossible : {delivery.base} a avance depuis la livraison — "
f"rebasez {delivery.branch} (puis re-approuvez : le diff aura bouge) "
f"ou relancez le run. Detail : {error}") from None
tracer = tracer_for(data_dir)
tracer.event(adw_id, "run_merge", signed.author,
payload={"diff_hash": fingerprint, "branch": delivery.branch, "into": delivery.base})
cost, tokens = run_totals(data_dir, adw_id)
tracer.run_finish(adw_id, ok=True, cost_usd=cost, tokens=tokens) # vert, enfin : merge
delivery.merged_at = approvals.now_iso()
save_delivery(data_dir, delivery)
print(f"merge : {delivery.branch} -> {delivery.base} (fast-forward), approuve par {signed.author} "
f"sur {fingerprint} ; run {adw_id} ferme en succes — git branch -d {delivery.branch} quand vous voulez")
def print_pending(data_dir: str | Path) -> int:
rows = pending(data_dir)
if not rows:
print("aucun run suspendu — rien n'attend un humain")
return 0
for adw_id, adw_name, started, reason in rows:
print(f"{adw_id} {adw_name:<12} {started[:19]} {reason}")
return 0
# ---------- la gate a sec ----------
def selftest() -> int:
"""Un depot jetable, un run factice : livraison, refus, approbation, perime, merge, reject."""
import tempfile
with tempfile.TemporaryDirectory() as tmp:
repo, data = Path(tmp) / "repo", Path(tmp) / "adw_data"
repo.mkdir()
git(["init", "-q", "-b", "main"], repo)
git(["config", "user.name", "usine"], repo)
git(["config", "user.email", "usine@plume.local"], repo)
(repo / "README.md").write_text("plume\n", encoding="utf-8")
git(["add", "README.md"], repo)
git(["commit", "-q", "-m", "base"], repo)
# Le run factice : trois fichiers poses par « l'usine », un run ouvert dans la trace.
tracer = Tracer(db_path=db_path(data), jsonl_dir=data / "traces")
tracer.run_start("fake1", "adw_sdlc", "Ajouter un compteur de mots")
phase = tracer.phase_start("fake1", 1, "build", "agent")
tracer.phase_attempt(phase, "fake1", "build", 1, ok=True, cost_usd=0.21, tokens=12000)
for rel, text in (("specs/fake1-compteur.md", "# spec\n"), ("apps/plume/count.ts", "export const n = 1;\n"),
("app_docs/fake1-compteur.md", "# doc\n")):
(repo / rel).parent.mkdir(parents=True, exist_ok=True)
(repo / rel).write_text(text, encoding="utf-8")
delivery = deliver(repo, data, "fake1", ["specs/fake1-compteur.md", "apps/plume/count.ts",
"app_docs/fake1-compteur.md"],
spec="specs/fake1-compteur.md", doc="app_docs/fake1-compteur.md",
request="Ajouter un compteur de mots")
tracer.event("fake1", "run_hold", "livraison", payload={"reason": "en attente d'approbation"})
# Apres la livraison : on est revenu sur main, l'arbre est propre, le travail est sur la branche.
assert git(["rev-parse", "--abbrev-ref", "HEAD"], repo) == "main"
assert git(["status", "--porcelain"], repo) == ""
assert not (repo / "apps/plume/count.ts").exists()
assert git(["rev-parse", "run/fake1"], repo) == delivery.tip_sha
assert pending(data)[0][0] == "fake1" and "approbation" in pending(data)[0][3]
try:
deliver(repo, data, "fake1", ["README.md"], "", "", "doublon")
raise AssertionError("un run, une branche")
except MergeError as error:
assert "existe deja" in str(error)
# Sans approbation : refus, et rien n'a bouge.
try:
merge(repo, data, "fake1")
raise AssertionError("merge sans approbation")
except ApprovalError as error:
assert "aucune approbation" in str(error)
assert git(["rev-parse", "HEAD"], repo) == delivery.base_sha
# Approuve, puis le diff bouge : l'approbation est perimee.
approve(repo, data, "fake1", "sylvain", "lu, RAS")
git(["checkout", "-q", "run/fake1"], repo)
(repo / "apps/plume/count.ts").write_text("export const n = 2;\n", encoding="utf-8")
git(["commit", "-q", "-am", "retouche apres signature"], repo)
git(["checkout", "-q", "main"], repo)
try:
merge(repo, data, "fake1")
raise AssertionError("un diff qui a bouge ne se merge pas sur l'ancienne signature")
except ApprovalError as error:
assert "perimee" in str(error)
# Mauvaise branche courante : refus avant tout merge.
git(["checkout", "-q", "-b", "ailleurs"], repo)
approve(repo, data, "fake1", "sylvain", "relu apres retouche")
try:
merge(repo, data, "fake1")
raise AssertionError("merge depuis une autre branche")
except MergeError as error:
assert "vous etes sur ailleurs" in str(error)
git(["checkout", "-q", "main"], repo)
# Re-approuve sur le diff actuel : le merge passe, le run devient success.
merge(repo, data, "fake1")
assert (repo / "apps/plume/count.ts").read_text(encoding="utf-8") == "export const n = 2;\n"
conn = sqlite3.connect(db_path(data))
status, cost, ended = conn.execute("SELECT status, cost_usd, ended_at FROM runs WHERE adw_id='fake1'").fetchone()
kinds = [r[0] for r in conn.execute("SELECT type FROM events WHERE adw_id='fake1' ORDER BY rowid")]
conn.close()
assert status == "success" and abs(cost - 0.21) < 1e-6 and ended
assert kinds.count("run_approve") == 2 and "run_merge" in kinds and kinds[-1] == "run_end"
assert pending(data) == [] and load_delivery(data, "fake1").merged_at
try:
merge(repo, data, "fake1")
raise AssertionError("un run merge ne se merge pas deux fois")
except MergeError as error:
assert "deja merge" in str(error)
# Un second run, rejete : la branche reste, le run se ferme en echec.
tracer.run_start("fake2", "adw_sdlc", "Changer la police")
(repo / "apps/plume/style.css").write_text("body { font: serif }\n", encoding="utf-8")
deliver(repo, data, "fake2", ["apps/plume/style.css"], "", "", "Changer la police")
try:
reject(repo, data, "fake2", "sylvain", "")
raise AssertionError("un rejet sans motif")
except MergeError:
pass
reject(repo, data, "fake2", "sylvain", "la spec demandait une police sans serif")
conn = sqlite3.connect(db_path(data))
assert conn.execute("SELECT status FROM runs WHERE adw_id='fake2'").fetchone()[0] == "fail"
conn.close()
assert git(["rev-parse", "--verify", "run/fake2"], repo)
try:
merge(repo, data, "fake2")
raise AssertionError("un run rejete ne se merge pas")
except ApprovalError as error:
assert "rejete" in str(error)
try:
load_delivery(data, "inconnu")
raise AssertionError("une livraison absente est nommee")
except MergeError as error:
assert "aucune livraison" in str(error)
print("adw_merge OK — livraison sur run/<adw_id> et retour a la base, file des suspendus, refus sans "
"approbation, approbation perimee si le diff bouge, refus hors branche de base, fast-forward et run "
"success, run rejete ferme en echec, un run merge ne se merge pas deux fois")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description="La porte du merge : code d'abord, humain ensuite.")
parser.add_argument("verb", nargs="?", choices=("pending", "show", "approve", "reject", "merge"))
parser.add_argument("adw_id", nargs="?")
parser.add_argument("--config", default=str(roster.DEFAULT_PATH))
parser.add_argument("--author", default=None, help="qui signe — FACTORY_APPROVER ou l'identite git sinon")
parser.add_argument("--note", default="", help="ce que vous avez lu, ou le motif du rejet")
parser.add_argument("--selftest", action="store_true", help="la gate a sec, dans un depot jetable")
args = parser.parse_args()
if args.selftest:
return selftest()
if not args.verb:
parser.error("un verbe : pending | show | approve | reject | merge (ou --selftest)")
data_dir = roster.load(args.config).data_dir # zero token : le roster dit ou vit le runtime
if args.verb == "pending":
return print_pending(data_dir)
if not args.adw_id:
parser.error(f"{args.verb} attend un adw_id — `uv run adws/adw_merge.py pending` les liste")
author = args.author or approvals.author_from(repo_root=".")
try:
if args.verb == "show":
show(".", data_dir, args.adw_id)
elif args.verb == "approve":
approve(".", data_dir, args.adw_id, author, args.note)
elif args.verb == "reject":
reject(".", data_dir, args.adw_id, author, args.note)
elif args.verb == "merge":
merge(".", data_dir, args.adw_id)
except (MergeError, ApprovalError) as error:
print(f"refus : {error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
Pièce — adws/adw_sdlc.py
Cette version remplace celle du chapitre 13. Les cinq agents, la boucle de correction dans la
phase build et le verdict de review ne changent pas. Ce qui change : le builder est borné au
contexte (scoped, C1) et ses gates sont les commandes de vérité du contexte, la phase build passe
par la mécanique du chapitre A7 (limites, profil d’authentification, coupe-circuit, jetons), la
phase finale dispose devient verite_document puis livraison (branche, commit, suspension), la
chaîne refuse un arbre modifié avant le premier jeton, et --show-chain affiche les phases à sec.
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["pyyaml"]
# ///
"""adw_sdlc — le cycle complet : plan -> build -> test -> review -> document -> livraison.
Usage :
uv run adws/adw_sdlc.py "Votre demande" [--config ...] [--context plume] [--retries 2] [--fix-loops 3]
uv run adws/adw_sdlc.py --show-chain # les phases, a sec
La chaine maitresse de l'usine. Depuis le chapitre 13, un echec de gate ne
tue pas le run : son verdict repart vers le builder, en enveloppe, dans sa
session vivante, borne par --fix-loops. Un refus de review arrete le run en
rouge : cet arbitrage vous revient.
Version annexe C3 : un run vert n'est pas un run merge. La chaine ne touche
plus votre branche : sa derniere phase code LIVRE le travail du run (les
fichiers que le perimetre a vus changer, jamais ceux que l'agent declare)
sur une branche run/<adw_id>, revient sur votre branche, et SUSPEND le run
(PhaseHold, C2) « en attente d'approbation ». La reprise est un geste de
revue : adw_merge.py approve, puis merge. Le builder est borne au contexte
(scoped, C1), les phases agent passent par la fabrique du chapitre A7
(limites, profil d'authentification, coupe-circuit, jetons), et la chaine
exige un arbre propre au depart : une branche de run ne porte que le run.
"""
import argparse
import sys
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from adw_modules import envelopes, gates, harness, permissions, roster
from adw_modules.envelopes import Envelope, EnvelopeError
from adw_modules.runner import PhaseFailure, PhaseHold, PhaseSpec, Run
from adw_scout import (GUARD_ASK, GUARD_ENV, ScoutEnvelope, agent_action, constat, perimetre,
phase_env, phase_environment, read_guard_report, scout_ask)
from adw_plan import PlanEnvelope, plan_ask
from adw_build import BuildEnvelope, build_ask, scoped
from adw_merge import MergeError, deliver
REQUIRED_AGENTS = ("scout", "planner", "builder", "reviewer", "documenter")
AGENT_PHASES = ("scout", "plan", "build", "review", "document")
REPAIR_ASK = """Les gates ont rejete ton build — le verdict est dans l'enveloppe ci-dessous,
champ 'notes_for_next_agent'. Corrige ce qu'elle nomme, rien d'autre, relance
la suite de tests, et declare a nouveau CHAQUE fichier modifie."""
def builder_action(builder, context):
"""La phase build du SDLC : la boucle de correction vit DANS la phase.
Tentative 1 : la spec, precedee du brief du contexte (C1). Ensuite : ce
que la derniere verification a rejete — contrat OU gates — repart dans
la MEME session. Le verdict des gates voyage en enveloppe, par la meme
porte qu'un rapport d'agent : le builder ne fait pas la difference, et
c'est voulu (chapitre 9). Les gates sont celles du contexte : ses
commandes de verite, dans son dossier.
"""
next_ask = envelopes.correction("enveloppe invalide", BuildEnvelope)
truth = context.commands()
def action(run: Run, attempt: int) -> BuildEnvelope:
nonlocal next_ask
ask = build_ask(run.results["plan"].spec_path, context)(run) if attempt == 0 else next_ask
variables = phase_env("build", builder, run)
request = harness.HarnessRequest(
prompt=ask, session_id=run.sessions.get("build"),
model=builder.model, thinking=builder.thinking, tools=builder.tools,
timeout=builder.timeout, auth=builder.auth.credentials(), direct=builder.auth.direct)
try:
with phase_environment(variables):
result = harness.run(builder.harness, request)
except harness.HarnessError as error:
if error.session_id:
run.sessions["build"] = error.session_id
guard = read_guard_report(variables[GUARD_ENV])
if guard:
reason = str(guard.get("reason", "plafond atteint"))
next_ask = GUARD_ASK.format(reason=reason) + envelopes.contract(BuildEnvelope)
raise PhaseFailure(f"coupe-circuit ({guard.get('kind', '?')}) : {reason}") from None
raise PhaseFailure(str(error)) from None
# Memoriser la session AVANT de valider : un retry doit la poursuivre.
run.sessions["build"] = result.session_id
run.cost_usd += result.cost_usd
run.tokens += result.tokens
payload = result.envelope if result.envelope is not None else result.text
try:
envelope = envelopes.parse(payload, BuildEnvelope)
except EnvelopeError as error:
next_ask = envelopes.correction(str(error), BuildEnvelope)
raise PhaseFailure(f"enveloppe invalide : {error}") from None
# Les gates, DANS la phase : re-trancher apres CHAQUE tentative —
# une phase build verte signifie gates vertes sur le dernier etat.
reports = [gates.changed_files_exist(envelope), gates.artifacts_exist(envelope)]
reports += [gates.command(cmd, cwd)(envelope) for cmd, cwd in truth]
failed = gates.motif(reports)
if failed:
verdict = Envelope(status="fail", summary="gates en echec sur ton build",
notes_for_next_agent=failed)
next_ask = (REPAIR_ASK + "\n\n" + envelopes.handoff(verdict) + "\n\n"
+ envelopes.contract(BuildEnvelope))
raise PhaseFailure(f"gates en echec :\n{failed}")
return envelope
return action
@dataclass(frozen=True)
class ReviewEnvelope(Envelope):
"""Ce que le reviewer doit au code : un verdict motive, jamais une retouche."""
approved: bool = field(default=False, metadata={
"ask": "true si le build correspond a la spec, false sinon"})
findings: list = field(default_factory=list, metadata={
"ask": "liste d'objets {'requirement': l'exigence, 'met': true/false, "
"'evidence': la preuve ou le manque}, [] si rien a signaler"})
def check(self) -> None:
super().check()
for finding in self.findings:
if not isinstance(finding, dict) or not finding.get("requirement"):
raise EnvelopeError(
"chaque finding doit etre un objet avec au moins 'requirement'")
if self.status == "success" and not self.approved and not self.findings:
raise EnvelopeError(
"un refus sans findings n'aide personne : nommer chaque manque")
REVIEWER_BRIEF = """Tu es le reviewer de l'usine : confirme que ce qui est construit est ce
qui etait demande. Ce n'est PAS une seance de tests — les gates s'en chargent.
- La spec est ton unique contrat : lis-la en entier, decoupe-la en exigences.
- Juge le code sur le disque, jamais le resume du builder : pars de
'changed_files', lis les fichiers, statue sur chaque exigence.
- 'status' dit si TA revue a abouti ; le verdict, c'est 'approved' — true
seulement si CHAQUE exigence est satisfaite.
- Ne change RIEN : tes findings repartent vers l'operateur, c'est l'unique
voie de reparation."""
def review_ask(run: Run) -> str:
"""La mission du reviewer : le brief, la spec, le build a juger, le contrat."""
plan_env: PlanEnvelope = run.results["plan"]
return (REVIEWER_BRIEF
+ f"\n\n### spec\n\nLa spec a confronter au code : {plan_env.spec_path}\n\n"
+ envelopes.handoff(run.results["build"]) + "\n\n"
+ envelopes.contract(ReviewEnvelope))
@dataclass(frozen=True)
class DocumentEnvelope(Envelope):
"""Ce que le documenter doit au code : ou vit le compte rendu."""
doc_path: str = field(default="", metadata={
"ask": "chemin du compte rendu ecrit sous app_docs/, '' si status=fail"})
def check(self) -> None:
super().check()
if self.status == "success" and not self.doc_path.startswith("app_docs/"):
raise EnvelopeError("doc_path doit pointer un fichier sous app_docs/")
DOCUMENTER_BRIEF = """Tu es le documenter de l'usine : redige, pour l'ingenieur qui arrive
apres, ce que ce run a change.
- Lis la spec et les fichiers de 'changed_files' : tout ce que tu ecris doit
etre tracable au code — ne nomme JAMAIS un fichier que le run n'a pas touche.
- Ecris le compte rendu dans app_docs/{adw_id}-<deux-a-quatre-mots-kebab>.md :
ce qui a change, ou ca vit, comment le verifier. Si le nom existe deja,
suffixe -v2 : un compte rendu ne s'ecrase JAMAIS.
- Court : un lecteur doit comprendre le changement en moins de deux minutes.
- N'ecris que de la documentation — jamais dans le code."""
def document_ask(run: Run) -> str:
"""La mission du documenter : le brief, la spec, le build a decrire, le contrat."""
plan_env: PlanEnvelope = run.results["plan"]
return (DOCUMENTER_BRIEF.format(adw_id=run.adw_id)
+ f"\n\n### spec\n\nLa demande d'origine, planifiee : {plan_env.spec_path}\n\n"
+ envelopes.handoff(run.results["build"]) + "\n\n"
+ envelopes.contract(DocumentEnvelope))
def verite_plan(run: Run, attempt: int) -> PlanEnvelope:
"""Phase code : la spec declaree existe — sinon inutile de payer le build."""
envelope: PlanEnvelope = run.results["plan"]
if envelope.status != "success":
raise PhaseFailure(f"le planner declare lui-meme un echec : {envelope.summary!r}")
spec = Path(envelope.spec_path)
if not spec.is_file() or spec.stat().st_size == 0:
raise PhaseFailure(f"spec declaree mais introuvable ou vide : {envelope.spec_path}")
print(f"spec posee : {envelope.spec_path}", file=sys.stderr)
return envelope
def verdict_review(run: Run, attempt: int) -> ReviewEnvelope:
"""Phase code : appliquer le verdict — un refus arrete le run, findings nommes.
Deliberement PAS une boucle : un refus de review met en cause la
conformite a la spec, et cet arbitrage revient a l'operateur — la spec
se corrige a zero token, c'est tout le levier du chapitre 11.
"""
review: ReviewEnvelope = run.results["review"]
if review.status != "success":
raise PhaseFailure(f"le reviewer declare lui-meme un echec : {review.summary!r}")
if not review.approved:
unmet = [f for f in review.findings if not f.get("met", False)]
detail = "\n".join(f" - {f.get('requirement')} : {f.get('evidence', 'non satisfait')}"
for f in unmet) or f" - {review.summary}"
raise PhaseFailure(
"review refusee — le build ne correspond pas a la spec :\n" + detail)
return review
def verite_document(run: Run, attempt: int) -> DocumentEnvelope:
"""Phase code : la verite du compte rendu — il existe, il n'est pas vide."""
envelope: DocumentEnvelope = run.results["document"]
if envelope.status != "success":
raise PhaseFailure(f"le documenter declare lui-meme un echec : {envelope.summary!r}")
doc = Path(envelope.doc_path)
if not doc.is_file() or doc.stat().st_size == 0:
raise PhaseFailure(
f"compte rendu declare mais introuvable ou vide : {envelope.doc_path}")
return envelope
def livraison(factory, request):
"""Phase code : la branche du run, le commit, la suspension — code d'abord, humain ensuite.
Ce qui part sur la branche, c'est l'union de ce que les perimetres ont
VU changer (ch. 10) : la spec du planner, les fichiers du builder, le
compte rendu du documenter. Jamais ce qu'un agent a declare. Puis le
run se suspend : il est vert pour le code, pas encore pour vous.
"""
def action(run: Run, attempt: int):
touched = sorted({path for phase in AGENT_PHASES
for path in run.results.get(f"perimetre_{phase}", [])})
if not touched:
raise PhaseFailure("rien a livrer : aucun perimetre n'a vu de fichier changer")
try:
delivery = deliver(".", factory.data_dir, run.adw_id, touched,
spec=run.results["plan"].spec_path,
doc=run.results["document"].doc_path, request=request)
except MergeError as error:
raise PhaseFailure(f"livraison impossible : {error}") from None
run.tracer.event(run.adw_id, "run_delivery", delivery.branch,
payload={"base": delivery.base, "diff_hash": delivery.diff_hash,
"files": len(touched), "spec": delivery.spec, "doc": delivery.doc})
print(f"livre : {len(touched)} fichier(s) sur {delivery.branch}, empreinte {delivery.diff_hash}",
file=sys.stderr)
raise PhaseHold(f"en attente d'approbation : lisez `git diff {delivery.base_sha[:8]} {delivery.branch}` "
f"ou `just show {run.adw_id}`, puis `just approve {run.adw_id}` et `just merge {run.adw_id}`")
return action
def chain(factory, context, request, retries, fix_loops) -> list[PhaseSpec]:
"""Les phases du SDLC : vingt phases, cinq agent, une suspension finale."""
scout, planner = factory.agents["scout"], factory.agents["planner"]
builder = scoped(factory.agents["builder"], context) # borne au contexte, jamais elargi (C1)
reviewer, documenter = factory.agents["reviewer"], factory.agents["documenter"]
return [
PhaseSpec(name="constat_scout", kind="code", action=constat("scout", factory)),
PhaseSpec(name="scout", kind="agent",
action=agent_action("scout", scout, scout_ask(request, factory), ScoutEnvelope),
retries=retries),
PhaseSpec(name="perimetre_scout", kind="code", action=perimetre("scout", scout, factory)),
PhaseSpec(name="constat_plan", kind="code", action=constat("plan", factory)),
PhaseSpec(name="plan", kind="agent",
action=agent_action("plan", planner, plan_ask(request, factory), PlanEnvelope),
retries=retries),
PhaseSpec(name="perimetre_plan", kind="code", action=perimetre("plan", planner, factory)),
PhaseSpec(name="verite_plan", kind="code", action=verite_plan),
PhaseSpec(name="constat_build", kind="code", action=constat("build", factory)),
PhaseSpec(name="build", kind="agent", action=builder_action(builder, context),
retries=fix_loops),
PhaseSpec(name="perimetre_build", kind="code", action=perimetre("build", builder, factory)),
PhaseSpec(name="constat_review", kind="code", action=constat("review", factory)),
PhaseSpec(name="review", kind="agent",
action=agent_action("review", reviewer, review_ask, ReviewEnvelope),
retries=retries),
PhaseSpec(name="perimetre_review", kind="code", action=perimetre("review", reviewer, factory)),
PhaseSpec(name="verdict_review", kind="code", action=verdict_review),
PhaseSpec(name="constat_document", kind="code", action=constat("document", factory)),
PhaseSpec(name="document", kind="agent",
action=agent_action("document", documenter, document_ask, DocumentEnvelope),
retries=retries),
PhaseSpec(name="perimetre_document", kind="code",
action=perimetre("document", documenter, factory)),
PhaseSpec(name="verite_document", kind="code", action=verite_document),
PhaseSpec(name="livraison", kind="code", action=livraison(factory, request)),
]
def main() -> int:
parser = argparse.ArgumentParser(
description="Le SDLC complet : plan -> build -> test -> review -> document -> livraison.")
parser.add_argument("prompt", nargs="?", help="la demande, en langage naturel")
parser.add_argument("--config", default=str(roster.DEFAULT_PATH))
parser.add_argument("--context", default=None, help="le contexte du payload a travailler (C1)")
parser.add_argument("--retries", type=int, default=2,
help="reprises de contrat des phases agent, en session vivante")
parser.add_argument("--fix-loops", type=int, default=3,
help="reprises du builder quand les gates echouent")
parser.add_argument("--show-chain", action="store_true",
help="afficher les phases de la chaine et leur cote de la couture, puis sortir")
args = parser.parse_args()
factory = roster.load(args.config) # zero token : tout echec est gratuit
missing = [name for name in REQUIRED_AGENTS if name not in factory.agents]
if missing:
print(f"roster incomplet : agents manquants {missing} — "
"posez la version chapitre 13 de factory.config.yaml", file=sys.stderr)
return 1
context = factory.payload.context(args.context) # un contexte inconnu s'arrete ici
if args.show_chain:
for seq, spec in enumerate(chain(factory, context, "<demande>", args.retries, args.fix_loops), 1):
print(f"{seq:02d} {spec.name:<18} {spec.kind:<5} "
f"{'reprises ' + str(spec.retries) if spec.retries else ''}")
print(f"contexte {context.name} : {context.dir} — fin : suspension, puis adw_merge.py approve / merge")
return 0
if not args.prompt:
parser.error("demande manquante — uv run adws/adw_sdlc.py \"Votre demande\"")
# Une branche de run ne porte que le run : l'arbre doit etre propre AVANT le premier jeton.
dirty = permissions.snapshot(".")
if dirty:
print(f"arbre non propre : {len(dirty)} chemin(s) modifies ou non suivis "
f"({', '.join(sorted(dirty)[:3])}{'…' if len(dirty) > 3 else ''}) — "
"commitez ou mettez de cote (git stash) avant de lancer la chaine", file=sys.stderr)
return 1
run = Run(adw_id=uuid.uuid4().hex[:8])
return run.execute(chain(factory, context, args.prompt, args.retries, args.fix_loops))
if __name__ == "__main__":
sys.exit(main())
La gate du TP
Depuis la racine de plume-factory, dépôt git propre, identité git configurée. Une commande par
ligne, identiques dans bash et PowerShell. Les trois premières ne coûtent rien. La quatrième lance
un SDLC complet sur Plume, qui doit se terminer suspendu (code 3). Les suivantes sont la porte.
Remplacez <adw_id> par l’identifiant que la suspension a affiché.
uv run adws/adw_modules/approvals.py
uv run adws/adw_merge.py --selftest
uv run adws/adw_sdlc.py --show-chain
uv run adws/adw_sdlc.py "Ajouter un compteur de mots dans la barre d'etat de Plume." --context plume
just pending
just merge <adw_id>
just show <adw_id>
just approve <adw_id> --note "lu : compteur et son test"
just merge <adw_id>
Résultat attendu : approvals OK — empreinte stable et sensible, refus sans approbation, approbation sur le bon diff, perimee si le diff bouge, reject = dernier mot, registre JSONL en ajout seul, puis
adw_merge OK — livraison sur run/<adw_id> et retour a la base, file des suspendus, refus sans approbation, approbation perimee si le diff bouge, refus hors branche de base, fast-forward et run success, run rejete ferme en echec, un run merge ne se merge pas deux fois. --show-chain liste
dix-neuf phases, cinq agent, la dernière livraison code. Le SDLC se termine par livre : N fichier(s) sur run/<adw_id>, empreinte … puis run SUSPENDU en phase livraison — en attente d'approbation : … et un code de retour 3. Votre branche est propre, git branch montre
run/<adw_id>. just pending affiche le run et son motif. Le premier just merge refuse :
aucune approbation enregistree pour <adw_id>. just show affiche la demande, la spec, le
compte rendu, le coût, l’empreinte et le --stat. Après just approve, le second just merge
affiche merge : run/<adw_id> -> main (fast-forward), approuve par <vous> sur <empreinte> et
just obs run <adw_id> montre le run en success, avec run_hold, run_approve, run_merge
entre ses événements. Pour voir la gate périmer : avant d’approuver, commitez une retouche sur la
branche du run, approuvez, retouchez encore, puis just merge refuse avec les deux empreintes.
Coût : les gates à sec ne dépensent rien, la porte non plus, le run SDLC coûte ce qu’il
coûtait au chapitre 13, quelques dizaines de centimes et une dizaine de minutes sur le roster
par défaut. Variante éco : --config adws/adw_config/eco.config.yaml. Si la chaîne refuse de
démarrer en nommant des chemins, c’est votre arbre : commitez ou git stash, puis relancez.