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

Le TDD par le code : NAME, RED, GREEN, REFACTOR comme phases

Le cycle TDD d'acceptation que vous demandiez à l'agent par une commande devient un ADW : chaque règle stricte — un nom métier, un RED d'assertion, un pas TPP, des tests intacts, la pause architecturale, la carte d'acceptation — est tenue par une phase code, et l'agent garde sa session d'un bout à l'autre.

Vous avez peut-être déjà une commande /tdd dans votre harnais : une discipline en neuf phases, écrite avec soin. Nommer le critère d’acceptation comme une phrase métier, écrire le test en pensée magique, atteindre un RED sur une assertion et jamais sur une erreur de compilation, s’arrêter pour demander une guidance architecturale, passer au vert par la plus petite transformation de la liste TPP, refactorer sans rien casser, écrire une carte d’acceptation : elle marche bien, avec vous devant l’écran. Lancée dans l’usine, sans vous, que reste-t-il de ses règles strictes ? Des phrases dans un prompt. Un agent pressé écrit le test et l’implémentation dans le même souffle, prend une erreur de compilation pour un RED, saute la pause, et rend un ♻️ sur du code qui n’a jamais été rouge. À la fin de ce chapitre, votre cycle sera un ADW : chaque phase de la commande est une phase de l’usine, chaque règle stricte est vérifiée par du code après la phase agent, un manquement revient à l’agent en correction dans la même session, la pause architecturale suspend le run tant qu’un humain n’a pas parlé, et la carte d’acceptation est écrite par le code, au format fixe. Les consignes de la commande sont reprises mot pour mot, ce qui change c’est qui les fait respecter. Trois pièces : runner.py (qui remplace la version du chapitre A4), tdd.py (nouveau, les règles en fonctions pures) et adw_tdd.py (nouveau, le cycle).

Une discipline tenue par le code, pas par le prompt

L’idée en une phrase

Un cycle NAME → RED → GREEN (TPP) → REFACTOR est une suite de phases agent dont chacune est suivie d’une phase code qui vérifie sa règle stricte : le nom est une phrase métier, le RED est un échec d’assertion, le GREEN est une transformation nommée de la liste TPP avec des tests intacts, le REFACTOR laisse les tests intacts et verts. Tout manquement revient à l’agent en correction, dans la même session. C’est le squelette ADW du chapitre 8 appliqué à la discipline la plus exigeante de votre pratique : l’agent propose un test, un code, un refactor, le code dispose.

Points clés

  • Le nom d’abord, et le code juge le nom. tdd.name_problems refuse tout ce qui n’est pas une phrase métier : moins de cinq mots, pas de should, du CamelCase, un mot technique (Test_OrderService_Returns_True, Should_Work_When_Valid sont refusés avec leur motif). Si l’agent ne peut pas nommer, faute d’une spec claire, il le dit dans l’enveloppe (status: fail, clarification) et le run se suspend : « STOP — clarify the spec first » n’est plus une consigne, c’est un état du run.
  • Un RED est un échec d’assertion, vérifié par le code, pas déclaré par l’agent. Après la phase RED, l’usine relance elle-même le seul test nommé (test_one du contexte) et lit la sortie : une erreur de compilation n’est pas un RED, un test qui passe est mal écrit, un test introuvable n’a pas été écrit, et un échec sans assertion (exception, module absent) n’est pas un RED. Chaque cas repart en correction avec son motif, et seul l’échec d’assertion confirme 🔴, et c’est le code qui affiche le message.
  • GREEN = un pas de la TPP, nommé, et pas un test touché. L’enveloppe de GREEN porte transformation, et le code exige qu’elle soit l’un des sept pas de la liste, tel quel. Une empreinte des fichiers de test (test_glob), prise après le RED, est comparée après GREEN et après REFACTOR : un test modifié, ajouté ou supprimé est un manquement, pas une gate. Le test conduit, l’implémentation suit.
  • Un seul agent, une seule session. NAME, RED, GREEN et REFACTOR sont quatre phases, mais la session du harnais est poursuivie de l’une à l’autre (session_from) : l’agent qui a écrit le test implémente, puis refactore, avec tout son contexte : exactement ce que la commande obtenait dans une conversation, et ce que le port sait faire depuis le chapitre A9.
  • Les conventions sont des références, résolues par le code. Les fichiers de conventions que la commande exige (DDD, tests, write-side, read-side, structure hexagonale, code lisible) sont déclarés par phase dans le bloc adw: tdd: references: du contexte. L’usine les lit et les colle dans l’ask, ${CLAUDE_PLUGIN_ROOT} résolu. Une référence absente arrête le cycle en la nommant : le cycle n’improvise pas ses conventions.

Exemple concret

Contexte commandes d’une solution .NET (chapitre C1), builder sur le workhorse (z-ai/glm-5.3, 1,40 $ et 4,40 $ le million de jetons au moment d’écrire), spec : « une commande dont un article n’est plus disponible ne peut pas être confirmée ». NAME : l’agent propose Order_confirmation_should_fail_when_an_item_is_unavailable : six mots, un should, aucun mot technique, donc accepté, 📝 affiché par le code. RED : il écrit le test outside-in, depuis le use case, crée ConfirmOrderUseCase en stub, et déclare l’échec. Le code relance dotnet test --filter Order_confirmation_should_fail_when_an_item_is_unavailable : error CS0246, la classe Availability n’existe pas : pas un RED, correction, même session, quelques centimes. Deuxième passage : Assert.Equal() Failure, 🔴 confirmé, empreinte des tests prise. Pause : le run se suspend (code 3), vous répondez --guidance "use a Value Object for Availability". GREEN : transformation: unconditional -> conditional, dans la liste, les tests n’ont pas bougé, le test passe, 🟢. REFACTOR : « Extracted Availability value object », tests intacts, suite complète verte, Tests: 41 passed, 0 failed, ♻️. TRACE : la carte Order_confirmation_should_fail_when_an_item_is_unavailable.md est écrite par le code à côté du test, avec la spec, le fichier, le pas TPP, le refactor, les fichiers touchés (relevés par le périmètre, pas déclarés) et votre guidance. Coût du cycle : une trentaine de centimes, cinq à huit minutes, et pas une règle laissée à la bonne volonté.

Ce que la commande demandait, ce que le code vérifie

PhaseCe que l’agent faitCe que le code vérifie, puis fait
NAMEphrase un critèrename_problems : phrase métier ou refus motivé, spec ambiguë → run suspendu
REDécrit le test et les stubstest_declares + test_one relancé + red_verdict : compilation ≠ RED, test vert = mal écrit, sans assertion ≠ RED, puis empreinte des tests
guidancerienphase code : réponse au terminal, --guidance, ou suspension
GREENla plus petite transformationtpp_problem (un des 7 pas), tests intacts, test_one vert
REFACTORaméliore sans changer le comportementtests intacts, suite complète verte, sinon « undo, refactor differently »
TRACErienla carte, au format fixe, à côté du test
REPEATrienla question, au terminal, et en headless le run s’arrête

Config — le bloc adw: tdd: d’un contexte .NET

Le cycle a des défauts par famille de vérité (Bun pour Plume, dotnet pour un contexte .NET) et chaque contexte peut les préciser. Ici, la commande qui lance un seul test depuis le dossier du contexte, ce qui est un fichier de test, comment reconnaître une compilation ratée et une assertion, et les conventions de votre plugin, par phase. Les chemins ${CLAUDE_PLUGIN_ROOT} sont résolus depuis l’environnement, et un fichier absent arrête le cycle en le nommant.

# factory.config.yaml — le contexte commandes (C1), regle pour le cycle TDD (C2)
    - name: commandes
      dir: src/Commandes
      truth:
        - [dotnet, build, ../../tests/Commandes.Tests, --nologo, -warnaserror]
        - [dotnet, test, ../../tests/Commandes.Tests, --no-build, --nologo, --filter, "Category!=Integration"]
      writes: [src/Commandes/, tests/Commandes.Tests/]
      adw:
        tdd:
          test_one: [dotnet, test, ../../tests/Commandes.Tests, --nologo, --filter, "{name}"]
          test_glob: "tests/Commandes.Tests/**/*.cs"
          compile_error: "error CS\\d+|Build FAILED"
          assertion: "Assert\\.|Xunit\\.Sdk|Failed "
          references:
            red: ["${CLAUDE_PLUGIN_ROOT}/reference/essentials/ddd.md",
                  "${CLAUDE_PLUGIN_ROOT}/reference/essentials/testing.md",
                  "${CLAUDE_PLUGIN_ROOT}/reference/essentials/write-side.md",
                  "${CLAUDE_PLUGIN_ROOT}/reference/essentials/project-structure.md"]
            green: ["${CLAUDE_PLUGIN_ROOT}/reference/essentials/project-structure.md",
                    "${CLAUDE_PLUGIN_ROOT}/reference/essentials/write-side.md",
                    "${CLAUDE_PLUGIN_ROOT}/reference/essentials/read-side.md"]
            refactor: ["${CLAUDE_PLUGIN_ROOT}/skills/readable-code/SKILL.md"]

Piège courant : « l’agent lance déjà le test lui-même, le relancer côté code est redondant » est inexact. L’agent raconte ce qu’il a vu, et un modèle qui a envie d’avancer voit un RED là où il y a une erreur de compilation. Le code relance le même test et lit la sortie avec des règles écrites : c’est la seule lecture qui compte, pour la même raison qu’au chapitre 12 pour les gates. Et « le GREEN qui passe la suite entière est plus sûr » confond deux phases : le GREEN de la commande vérifie le test, c’est le REFACTOR qui relance la suite. Respecter la commande, c’est aussi respecter ses vérités, une par phase.


La pause architecturale et la carte d’acceptation

L’idée en une phrase

Deux phases de la commande n’appartiennent pas à l’agent : la pause architecturale, où un humain parle avant l’implémentation, et la carte d’acceptation, qui trace ce qui a été décidé et fait. Dans l’usine, la première devient une suspension du run (PhaseHold, un troisième état à côté du vert et du rouge) et la seconde une phase code qui écrit le fichier au format fixe depuis les enveloppes et le périmètre relevé, jamais depuis la prose de l’agent.

Points clés

  • PhaseHold : ni vert ni rouge, en attente d’un humain. Le runner du chapitre 8 connaissait deux fins : vert (0) ou rouge (1). Une phase code peut désormais lever PhaseHold : le run est suspendu, avec un événement run_hold dans la trace, un run laissé ouvert (status: running, pas de ended_at) et le code retour 3. Un agent ne peut pas suspendre l’usine : un PhaseHold levé dans une phase agent est un échec. C’est la mécanique qu’un chapitre suivant réutilisera pour l’approbation avant merge.
  • La pause, trois façons de parler. Au terminal, le cycle affiche la question de la commande (🏗️) et attend votre réponse : « WAIT for response » est littéral. En headless, --guidance "go" sur la ligne de commande répond d’avance, et sans réponse le run se suspend, la trace disant quoi relancer : --resume <adw_id> --guidance "…". La réponse est mémorisée dans l’état du cycle et transmise à GREEN mot pour mot (« Architectural guidance from the user: … »).
  • Une reprise repart du même run, avec le même agent. L’état du cycle (tdd.json sous le dossier du run) porte la spec, le critère, le test, l’empreinte des tests, la guidance, et les sessions par phase. --resume reconstruit le Run avec le même adw_id (run_start est idempotent, chapitre 18) et reprend à l’étape suspendue : GREEN poursuit la session de RED, comme si rien ne s’était arrêté. Une spec ambiguë se reprend de même : --clarify "…" complète la spec et rejoue NAME.
  • La carte est écrite par le code, au format de la commande. Titre = le critère en phrase, Specification, Test (fichier, classe), Implementation (pas TPP, refactoring, fichiers touchés), Architectural Decisions. Les fichiers touchés viennent du périmètre (permissions.enforce, chapitre 10), pas de la déclaration de l’agent. Les tests et le runtime en sont exclus, et « go » devient « None ». Le fichier vit à côté du test, nommé par le critère.
  • REPEAT est une question, pas une boucle d’agent. Au terminal, le cycle demande le critère suivant et repart sur un nouveau run. En headless, il s’arrête : un critère par invocation, c’est ce qu’une CI sait relancer et ce qu’un budget sait compter.

Exemple concret

Même cycle, en headless, lancé par une recette just, personne devant l’écran. NAME et RED passent (une dizaine de centimes, deux minutes), puis à la pause le run rend 3 et la trace porte run_hold / guidance : 🏗️ Architectural guidance before implementation? … relancez avec --resume 7c1e4b02 --guidance "go". Le lendemain matin, vous lisez le test, le moment prévu par la commande pour décider d’un Value Object ou d’un Domain Event, et vous relancez : uv run adws/adw_tdd.py --resume 7c1e4b02 --guidance "Domain Event OrderRejected for decoupling". Le Run renaît avec le même identifiant, GREEN reprend la session de RED avec votre guidance en tête d’ask, REFACTOR suit, la carte est écrite. factory.db montre un seul run, running pendant une nuit puis success, avec ses deux vagues de phases. Sans PhaseHold, ce même cycle aurait dû soit sauter la pause, la règle la plus importante de la commande, soit mourir en rouge à la première question.

Trois fins pour un run

FinCode retourTraceCe qu’on fait
vert0success, ended_at poséon lit la carte, on passe au critère suivant
rouge1fail, ended_at poséon lit le motif de la phase, on corrige la spec ou le contexte
suspendu3running, run_hold avec le motif, pas de ended_atun humain répond, par --resume <adw_id> --guidance ou --clarify

Et si vous n’avez pas de terminal, en mode SDLC ? Un run suspendu est une ligne dans factory.db et un état sur disque : le terminal n’était qu’un canal, et le --resume est exécuté par la machine qui a lancé le run, pas par vous. Le canal naturel devient alors la PR que la chaîne vient d’ouvrir : la question et le test rouge en commentaire, votre phrase en réponse, la reprise déclenchée par un poller, et la décision archivée à côté du code qu’elle a produit. Le chapitre suivant construit cette file des runs suspendus, pour la pause comme pour l’approbation avant merge.

Commande — un cycle, au terminal et en headless

Le cycle sur Plume, le seul contexte du roster, dont les défauts Bun suffisent. Au terminal, la pause vous attend, en headless (just, CI) elle suspend. Une seule version : le cycle est un ADW, il parle au port du chapitre 7. pi ou Claude Code, c’est le roster qui le dit, et la session poursuivie de phase en phase est tenue par le port des deux côtés.

# les reglages du cycle pour le contexte — zero jeton
uv run adws/adw_tdd.py --show-cycle
# un critere, au terminal : la pause attend votre reponse
uv run adws/adw_tdd.py "Sauvegarder deux fois un document garde une seule version."
# le meme, en headless : la guidance donnee d'avance, quand la decision est prise sur la spec
uv run adws/adw_tdd.py "Sauvegarder deux fois un document garde une seule version." \
  --guidance "idempotence dans le use case, pas dans le repository"
# sans reponse, le run se suspend (code 3) et se reprend quand un humain a lu le test rouge
uv run adws/adw_tdd.py --resume <adw_id> --guidance "use a Value Object"

Piège courant : « en CI, autant mettre --guidance go partout, la pause ne sert qu’aux humains » est inexact. Le code ne voit qu’une chaîne : il ne sait pas distinguer une réponse d’un champ rempli, et le critère est ailleurs. Répondre d’avance est légitime quand la décision a été prise sur la spec et vaut pour un lot entier : --guidance "pas de nouvelle abstraction, on étend le repository existant" sur vingt critères CRUD du même agrégat, décidé une fois, en connaissance de cause. C’est un contournement quand la même réponse est figée dans la recette just : le champ est rempli, la carte dira « Architectural Decisions: None », et personne n’aura lu un test rouge. go est une réponse quand un humain a lu le RED et n’a rien à ajouter, pas quand il est en dur dans la CI. Laissez le run se suspendre, et faites de la reprise un geste de revue, ce que le chapitre suivant généralise au merge.


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

Sur le plan de l’usine, la pièce du jour touche le squelette ADW (le runner du chapitre 8 gagne un troisième état, la suspension) et ouvre dans la zone produit un ADW de discipline : le cycle TDD, à côté du build du chapitre 12 et du SDLC du chapitre 13. La couture ne bouge pas : l’agent propose un nom, un test, une transformation, un refactor, le code dispose du nom, du RED, du pas TPP, de l’intégrité des tests, de la pause, de la carte. Ce qui traverse : les consignes de la commande, mot pour mot, avec les références de conventions, vers l’agent, et quatre enveloppes typées vers le code. Déterministe : tdd.py en entier, la suspension, l’état du cycle, la carte. Délégué : écrire un test qui décrit un comportement, le faire passer au plus petit pas, améliorer le design. Coût d’usage : zéro jeton pour les vérifications, et un cycle complet à quelques dizaines de centimes sur le workhorse, pour une règle stricte qui, pour la première fois, ne dépend plus de l’humeur du modèle. Le jalon est posé : le cycle TDD est tenu par le code, et le prochain chapitre fera de la suspension la porte du merge.


Travaux pratiques — la pièce du jour

Trois fichiers à poser dans plume-factory, qui devient, chapitre après chapitre, votre usine logicielle agentique : le runner qui sait suspendre, les règles du cycle en fonctions pures, et le cycle lui-même. Prérequis : le chapitre C1 (contextes, scoped, bloc adw:), le coupe-circuit du chapitre A7 (adw_scout.py, dont le cycle réutilise la fabrique d’environnement de phase) et le port du chapitre 17bis. Si vous avez un plugin de conventions, déclarez ses fichiers dans adw: tdd: references: de votre contexte, comme dans la Config. Plume tourne sans.

Pièce — adws/adw_modules/runner.py

Cette version remplace celle du chapitre A4. PhaseSpec, Run, execute, les reprises en session vivante et la clé de jointure ne changent pas. S’ajoutent PhaseHold et HOLD : une phase code qui lève PhaseHold suspend le run : événement run_hold, run laissé ouvert dans la trace, code retour 3. Levée dans une phase agent, elle est un échec. run_start étant idempotent, une reprise avec le même adw_id poursuit le même run.

"""runner — le squelette de l'usine : phases, sequencement, retries, traces.

Un ADW declare ses phases ; le runner les execute dans l'ordre, mesure,
retente les phases agent en session vivante, et rend un code retour.
Le succes se merite : toute PhaseFailure marque la tentative en echec,
et un run n'est vert que si toutes ses phases le sont.

Version annexe A4 : la cle de jointure. Apres chaque tentative, le runner
relie la phase a la session du harnais (evenement phase_session) — c'est
par elle que la trace harnais rejoint le journal. L'API ne bouge pas :
vos ADW des chapitres 8 a 27 tournent tels quels.

Version annexe C2 : la SUSPENSION. Une phase code peut lever PhaseHold :
le run n'est ni vert ni rouge, il attend un humain (une guidance, une
approbation) — le runner journalise run_hold, laisse le run ouvert dans la
trace (status 'running', pas de ended_at) et rend le code 3. Un run
suspendu se reprend avec le MEME adw_id : run_start est idempotent, les
sessions des phases passees sont dans le registre (A9) ou l'etat de l'ADW.
"""
from __future__ import annotations

import sys
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable

from .tracer import Tracer


class PhaseFailure(Exception):
    """L'echec motive d'une phase — le runner decide s'il retente."""


class PhaseHold(Exception):
    """Une phase code SUSPEND le run : un humain doit parler avant la suite (C2).

    Ni echec ni succes : le run reste ouvert dans la trace, le code retour
    vaut HOLD, et la reprise se fait avec le meme adw_id. Reservee aux
    phases code — un agent ne suspend jamais l'usine.
    """


HOLD = 3    # le code retour d'un run suspendu — distinct de 0 (vert) et 1 (rouge)


@dataclass(frozen=True)
class PhaseSpec:
    """Ce qu'un ADW declare : un nom, un cote de la couture, une action."""
    name: str
    kind: str                              # "agent" ou "code"
    action: Callable[["Run", int], Any]    # (run, tentative) -> resultat
    retries: int = 0                       # phases agent : reprises en session vivante


@dataclass
class Run:
    """L'etat partage d'un run : resultats des phases, sessions, cout, jetons."""
    adw_id: str
    results: dict[str, Any] = field(default_factory=dict)
    sessions: dict[str, str] = field(default_factory=dict)  # phase -> session_id
    cost_usd: float = 0.0
    # Le compteur de jetons voyage comme le cout : une action qui releve
    # l'usage de son harnais le credite (run.tokens += ...) ; une action
    # qui ne releve rien laisse zero — jamais un chiffre invente.
    tokens: int = 0
    tracer: Tracer | None = None    # injectable pour les tests ; None = journal standard

    def __post_init__(self) -> None:
        if self.tracer is None:
            self.tracer = Tracer()

    def execute(self, phases: list[PhaseSpec]) -> int:
        """Sequence les phases declarees. Arret a la premiere phase en echec definitif."""
        if not phases:
            print("aucune phase declaree — un ADW vide n'est pas un ADW", file=sys.stderr)
            return 1
        # Le nom de l'ADW et la demande sont releves sur la ligne de commande :
        # zero changement dans vos scripts, et la trace sait deja qui tourne.
        adw_name = Path(sys.argv[0]).stem if sys.argv and sys.argv[0] else ""
        request = " ".join(sys.argv[1:])[:500]
        self.tracer.run_start(self.adw_id, adw_name, request)
        for seq, spec in enumerate(phases, start=1):
            if spec.kind not in ("agent", "code"):
                print(f"[{self.adw_id}] {spec.name} : kind inconnu {spec.kind!r}",
                      file=sys.stderr)
                self.tracer.run_finish(self.adw_id, ok=False,
                                       cost_usd=self.cost_usd, tokens=self.tokens)
                return 1
            try:
                ok = self._run_phase(seq, spec)
            except PhaseHold as hold:
                # Suspendu, pas fini : la trace garde le run ouvert, et le
                # motif dit a l'humain ce qu'on attend de lui.
                print(f"[{self.adw_id}] run SUSPENDU en phase {spec.name}{hold}",
                      file=sys.stderr)
                self.tracer.event(self.adw_id, "run_hold", spec.name,
                                  payload={"reason": str(hold), "cost_usd": round(self.cost_usd, 4)})
                return HOLD
            if not ok:
                print(f"[{self.adw_id}] ECHEC en phase {spec.name} — arret du run",
                      file=sys.stderr)
                self.tracer.run_finish(self.adw_id, ok=False,
                                       cost_usd=self.cost_usd, tokens=self.tokens)
                return 1
        # La ligne de bilan reste l'API de l'oeil et du banc (ch. 17) —
        # la trace s'ajoute, elle ne retire rien.
        print(f"[{self.adw_id}] run vert — cout total ~{self.cost_usd:.4f} $",
              file=sys.stderr)
        self.tracer.run_finish(self.adw_id, ok=True,
                               cost_usd=self.cost_usd, tokens=self.tokens)
        return 0

    def _bind_session(self, phase_id: str, name: str, attempt: int,
                      bound: set[str]) -> None:
        """La cle de jointure de la trace harnais (annexe A4).

        L'action memorise la session dans run.sessions avant de valider ;
        le runner la declare au journal une fois par session — une reprise
        en session vivante garde la meme cle, une session neuve en ecrit une
        autre. Sans session (phase code), rien n'est ecrit.
        """
        session_id = self.sessions.get(name)
        if not session_id or session_id in bound:
            return
        bound.add(session_id)
        self.tracer.event(self.adw_id, "phase_session", name, phase_id=phase_id,
                          payload={"session_id": session_id, "attempt": attempt})

    def _run_phase(self, seq: int, spec: PhaseSpec) -> bool:
        # Retenter une phase code n'a pas de sens : meme entree, meme sortie.
        # Seules les phases agent ont droit aux reprises — en session vivante.
        attempts = 1 + (spec.retries if spec.kind == "agent" else 0)
        phase_id = self.tracer.phase_start(self.adw_id, seq, spec.name,
                                           spec.kind, retries=attempts - 1)
        bound: set[str] = set()
        for attempt in range(attempts):
            clock = time.monotonic()
            # Le grain comptable (ch. 20) : ce que CETTE tentative ajoute aux
            # compteurs du run — mesure en delta, vos actions ne changent pas.
            cost_before, tokens_before = self.cost_usd, self.tokens
            label = f"{spec.name} ({spec.kind}, tentative {attempt + 1}/{attempts})"
            try:
                # L'action recoit le run (etat partage) et le numero de tentative :
                # a la tentative 1, une phase agent envoie la demande ; ensuite,
                # elle envoie la correction dans la MEME session.
                self.results[spec.name] = spec.action(self, attempt)
            except PhaseHold:
                if spec.kind != "code":
                    raise PhaseFailure("seule une phase code peut suspendre le run") from None
                self.tracer.phase_attempt(phase_id, self.adw_id, spec.name, attempt + 1,
                                          ok=False, error="suspendu : en attente d'un humain")
                raise
            except PhaseFailure as error:
                # La session est memorisee AVANT la validation (ch. 8) : meme en
                # echec, la cle de jointure existe et la trace harnais se relie.
                self._bind_session(phase_id, spec.name, attempt + 1, bound)
                print(f"[{self.adw_id}] {label} : echec — {error}", file=sys.stderr)
                self.tracer.phase_attempt(phase_id, self.adw_id, spec.name,
                                          attempt + 1, ok=False, error=str(error),
                                          cost_usd=self.cost_usd - cost_before,
                                          tokens=self.tokens - tokens_before)
                continue
            self._bind_session(phase_id, spec.name, attempt + 1, bound)
            duration = time.monotonic() - clock
            print(f"[{self.adw_id}] {label} : OK en {duration:.1f} s", file=sys.stderr)
            self.tracer.phase_attempt(phase_id, self.adw_id, spec.name,
                                      attempt + 1, ok=True,
                                      cost_usd=self.cost_usd - cost_before,
                                      tokens=self.tokens - tokens_before)
            return True
        return False

Pièce — adws/adw_modules/tdd.py

Nouveau : les règles du cycle, en fonctions pures, testées à sec. La liste TPP telle quelle, name_problems (une phrase métier, ou les motifs), red_verdict (compilation ≠ RED, test vert = mal écrit, test absent, échec sans assertion, ou 🔴 avec la ligne d’assertion), green_verdict, tpp_problem, l’empreinte des fichiers de test (test_files, fingerprint, tests_changed), test_declares, la carte (card_path, render_card) au format fixe, les affichages de la commande à l’identique, settings_for (le bloc adw: tdd: du contexte sur les défauts Bun ou dotnet) et load_references (${CLAUDE_PLUGIN_ROOT} résolu, absents nommés).

"""tdd — les regles du cycle TDD, tenues par le code (annexe C2).

Le cycle NAME -> RED -> GREEN (TPP) -> REFACTOR -> TRACE est une discipline
que l'on demande d'ordinaire a l'agent par un prompt. Ici, chaque regle
stricte devient une fonction PURE que l'ADW (adw_tdd.py) appelle en phase
code, apres la phase agent correspondante : le nom est une phrase metier
ou il est refuse ; un RED est un echec d'ASSERTION, jamais de compilation ;
un GREEN est une transformation nommee de la liste TPP, avec des tests
intacts ; un REFACTOR laisse les tests intacts et verts ; la carte
d'acceptation est ecrite par le code, dans un format fixe. Zero token :
tout se teste a sec.

Les reglages par contexte (bloc `adw: tdd:` du roster, C1) ont des
defauts par famille de commandes de verite : Bun (Plume) et dotnet.
"""
from __future__ import annotations

import hashlib
import re
from dataclasses import dataclass
from pathlib import Path

# ---------- la Transformation Priority Premise : la liste, dans l'ordre ----------

TPP = (
    "{} -> nil/null",
    "null -> constant",
    "constant -> variable",
    "statement -> statements",
    "unconditional -> conditional",
    "scalar -> collection",
    "statement -> recursion/iteration",
)

# Les mots qui font d'un nom de test une etiquette technique, pas une phrase
# metier — les contre-exemples de la regle : Test_OrderService_Returns_True,
# Should_Work_When_Valid.
TECHNICAL_TOKENS = ("test", "returns", "return", "true", "false", "null", "work",
                    "works", "service", "repository", "handler", "controller",
                    "method", "function", "dto", "api", "ok")
MIN_WORDS = 5

NO_REFACTORING = "No refactoring needed"


@dataclass(frozen=True)
class TddSettings:
    """Ce que le cycle doit savoir d'un contexte : lancer UN test, reconnaitre un RED.

    test_one     : argv qui lance le seul test nomme — `{name}` est remplace.
    test_glob    : ce qui est un fichier de test (RED y ecrit ; GREEN et
                   REFACTOR ne le touchent pas).
    compile_error: regex — un echec qui y repond n'est PAS un RED.
    assertion    : regex — un RED est un echec qui y repond.
    references   : fichiers de conventions injectes par phase (red, green, refactor).
    """
    test_one: tuple[str, ...]
    test_glob: str
    compile_error: str
    assertion: str
    references: dict[str, tuple[str, ...]]

    def command(self, test_name: str) -> list[str]:
        return [part.replace("{name}", test_name) for part in self.test_one]


DEFAULTS = {
    "bun": TddSettings(
        test_one=("bun", "test", "--test-name-pattern", "{name}"),
        test_glob="**/*.test.ts",
        compile_error=r"SyntaxError|Cannot find module|error: Cannot|Unexpected token|error TS",
        assertion=r"expect\(|Expected|toBe|toEqual|toThrow|AssertionError",
        references={"red": (), "green": (), "refactor": ()},
    ),
    "dotnet": TddSettings(
        test_one=("dotnet", "test", "--nologo", "--filter", "{name}"),
        test_glob="**/*Tests/**/*.cs",
        compile_error=r"error CS\d+|Build FAILED|MSB\d{4}",
        assertion=r"Assert\.|Expected|Failed |Xunit\.Sdk|NUnit\.Framework\.AssertionException|Shouldly",
        references={"red": (), "green": (), "refactor": ()},
    ),
}


def settings_for(context_adw: dict, truth: tuple[tuple[str, ...], ...]) -> TddSettings:
    """Le bloc `adw: tdd:` du contexte par-dessus les defauts de sa famille. Pure.

    La famille se lit sur la premiere commande de verite (bun, dotnet) ;
    une famille inconnue exige un bloc tdd complet.
    """
    block = dict((context_adw or {}).get("tdd") or {})
    family = truth[0][0] if truth and truth[0] else ""
    base = DEFAULTS.get(family)
    if base is None and not {"test_one", "test_glob", "compile_error", "assertion"} <= set(block):
        raise ValueError(f"famille de verite inconnue {family!r} : le bloc adw.tdd doit declarer "
                         "test_one, test_glob, compile_error et assertion")
    refs = {phase: tuple(block.get("references", {}).get(phase) or (base.references[phase] if base else ()))
            for phase in ("red", "green", "refactor")}
    return TddSettings(
        test_one=tuple(block.get("test_one") or (base.test_one if base else ())),
        test_glob=str(block.get("test_glob") or (base.test_glob if base else "")),
        compile_error=str(block.get("compile_error") or (base.compile_error if base else "")),
        assertion=str(block.get("assertion") or (base.assertion if base else "")),
        references=refs,
    )


# ---------- NAME : une phrase metier, ou rien ----------

def name_problems(test_name: str) -> list[str]:
    """Ce qui empeche un nom de test d'etre un critere d'acceptation. [] = accepte.

    Regles : mots separes par des underscores, au moins MIN_WORDS, la
    premiere lettre en majuscule et le reste en minuscules (une phrase, pas
    du CamelCase), un `should` qui porte l'attente, aucun mot technique.
    """
    problems: list[str] = []
    if not test_name or not re.fullmatch(r"[A-Z][a-z0-9]*(_[a-z0-9]+)+", test_name):
        problems.append("une phrase metier : Mots_separes_par_des_underscores, initiale majuscule, "
                        "le reste en minuscules — pas de CamelCase, pas d'espace")
    words = [w for w in test_name.split("_") if w]
    if len(words) < MIN_WORDS:
        problems.append(f"au moins {MIN_WORDS} mots — un critere se lit comme une phrase")
    if "should" not in (w.lower() for w in words):
        problems.append("un « should » qui porte l'attente : A_valid_order_..._should_be_confirmed")
    technical = [w for w in words if w.lower() in TECHNICAL_TOKENS]
    if technical:
        problems.append(f"des mots techniques, pas metier : {technical} "
                        "(Test_OrderService_Returns_True n'est pas un critere)")
    return problems


# ---------- RED : un echec d'assertion, jamais de compilation ----------

@dataclass(frozen=True)
class Verdict:
    ok: bool
    kind: str        # red | compile_error | passed | no_assertion | not_found
    detail: str


def red_verdict(returncode: int, output: str, settings: TddSettings, test_name: str) -> Verdict:
    """Ce que dit la sortie de `test_one` sur la phase RED. Pure.

    Dans l'ordre : une erreur de compilation n'est PAS un RED ; un test qui
    passe est mal ecrit ; un test introuvable n'a pas ete ecrit ; un echec
    sans assertion n'est pas un RED ; sinon RED confirme, avec la premiere
    ligne d'assertion comme motif.
    """
    if re.search(settings.compile_error, output):
        line = next((l for l in output.splitlines() if re.search(settings.compile_error, l)), "")
        return Verdict(False, "compile_error",
                       "A compilation error IS NOT a RED — corrige jusqu'a ce que le test compile : "
                       + line.strip()[:200])
    if returncode == 0:
        return Verdict(False, "passed",
                       "le test passe deja : il est mal ecrit — reformule-le pour qu'il decrive un "
                       "comportement qui n'existe pas encore")
    if test_name and test_name not in output and not re.search(settings.assertion, output):
        return Verdict(False, "not_found",
                       f"aucun test nomme {test_name!r} n'a ete execute — le test n'est pas ecrit "
                       "ou son nom differe du critere")
    assertion = next((l for l in output.splitlines() if re.search(settings.assertion, l)), "")
    if not assertion:
        return Verdict(False, "no_assertion",
                       "le test echoue, mais pas sur une assertion (exception, timeout, module "
                       "absent) — cree les stubs minimaux pour que seule l'assertion echoue")
    return Verdict(True, "red", assertion.strip()[:300])


def green_verdict(returncode: int, output: str, settings: TddSettings) -> Verdict:
    """GREEN et REFACTOR : le test passe — et compile. Pure."""
    if re.search(settings.compile_error, output):
        line = next((l for l in output.splitlines() if re.search(settings.compile_error, l)), "")
        return Verdict(False, "compile_error", "ne compile pas : " + line.strip()[:200])
    if returncode != 0:
        assertion = next((l for l in output.splitlines() if re.search(settings.assertion, l)), "")
        last = (output.strip().splitlines() or [""])[-1]
        return Verdict(False, "failed", (assertion or last).strip()[:300])
    return Verdict(True, "green", "le test passe")


# ---------- GREEN : une transformation nommee de la liste TPP ----------

def tpp_problem(transformation: str) -> str | None:
    """None si la transformation est un pas de la TPP, sinon le motif."""
    normalized = re.sub(r"\s*(->||=>)\s*", " -> ", (transformation or "").strip())
    if normalized in TPP:
        return None
    return (f"transformation {transformation!r} hors de la liste TPP — une de : "
            + " ; ".join(TPP))


# ---------- les tests intacts : une empreinte avant, la meme apres ----------

def test_files(repo_root: str | Path, dir_: str, pattern: str) -> list[Path]:
    """Les fichiers de test d'un contexte, tries — sous son dossier et sous tests/ voisins."""
    root = Path(repo_root)
    roots = {root / dir_, root}
    found: set[Path] = set()
    for base in roots:
        if base.is_dir():
            found.update(p for p in base.glob(pattern) if p.is_file() and "node_modules" not in p.parts
                         and "bin" not in p.parts and "obj" not in p.parts)
    return sorted(found)


def fingerprint(paths: list[Path], repo_root: str | Path = ".") -> dict[str, str]:
    """chemin relatif -> sha256 du contenu. Pure vis-a-vis de l'etat : une photo."""
    root = Path(repo_root)
    prints: dict[str, str] = {}
    for path in paths:
        try:
            digest = hashlib.sha256(path.read_bytes()).hexdigest()[:16]
        except OSError:
            continue
        try:
            key = str(path.resolve().relative_to(root.resolve())).replace("\\", "/")
        except ValueError:
            key = str(path)
        prints[key] = digest
    return prints


def tests_changed(before: dict[str, str], after: dict[str, str]) -> list[str]:
    """Les fichiers de test apparus, disparus ou modifies entre deux photos."""
    return sorted(p for p in set(before) | set(after) if before.get(p) != after.get(p))


def test_declares(test_file: str | Path, test_name: str) -> bool:
    """Le fichier de test nomme bien le critere — quel que soit le framework."""
    try:
        return test_name in Path(test_file).read_text(encoding="utf-8", errors="replace")
    except OSError:
        return False


# ---------- TRACE : la carte d'acceptation, ecrite par le code ----------

def card_path(test_file: str | Path, test_name: str) -> Path:
    """{TestDirectory}/{Acceptance_criterion_name}.md — a cote du test."""
    return Path(test_file).parent / f"{test_name}.md"


def render_card(*, test_name: str, specification: str, test_file: str, test_class: str,
                transformation: str, refactoring: str, files_touched: list[str],
                guidance: str) -> str:
    """Le contenu de la carte, dans le format fixe du cycle. Pure."""
    title = test_name.replace("_", " ")
    files = "\n".join(f"  - `{f}`" for f in files_touched) or "  - (none)"
    return (
        f"# {title}\n\n"
        f"## Specification\n{specification.strip()}\n\n"
        f"## Test\n- File: `{test_file}`\n- Class: `{test_class}`\n\n"
        f"## Implementation\n- TPP step: {transformation}\n- Refactoring: {refactoring or 'None'}\n"
        f"- Files touched:\n{files}\n\n"
        f"## Architectural Decisions\n{guidance.strip() or 'None'}\n"
    )


# ---------- les affichages du cycle, a l'identique ----------

def display_name(test_name: str) -> str:
    return f"📝 Acceptance Criterion:\n{test_name}"


def display_red(test_name: str, failure: str) -> str:
    return f"🔴 RED confirmed\nTest: {test_name}\nFailure: {failure}"


def display_guidance() -> str:
    return ("🏗️ Architectural guidance before implementation?\n"
            "Examples: \"use a Value Object\", \"Domain Event for decoupling\", \"go\" to continue")


def display_green(transformation: str) -> str:
    return f"🟢 GREEN\nTransformation: {transformation}"


def display_refactor(summary: str, passed: int, failed: int) -> str:
    return f"♻️ REFACTOR\n{summary or NO_REFACTORING}\nTests: {passed} passed, {failed} failed"


def display_repeat() -> str:
    return "Next acceptance criterion? Or \"done\" to stop."


def count_tests(output: str) -> tuple[int, int]:
    """(passes, echecs) lus dans la sortie de bun test ou dotnet test — 0 si illisible."""
    passed = failed = 0
    for pattern, which in ((r"(\d+)\s+pass(?:ed)?\b", "p"), (r"(\d+)\s+fail(?:ed)?\b", "f"),
                           (r"Passed:\s*(\d+)", "p"), (r"Failed:\s*(\d+)", "f")):
        match = re.search(pattern, output)
        if match:
            if which == "p":
                passed = max(passed, int(match.group(1)))
            else:
                failed = max(failed, int(match.group(1)))
    return passed, failed


# ---------- les references : les conventions que l'agent doit suivre ----------

def load_references(paths: tuple[str, ...], repo_root: str | Path = ".",
                    env: dict[str, str] | None = None) -> tuple[str, list[str]]:
    """Le texte des fichiers de conventions (concatenes, titres compris) et les absents.

    `${CLAUDE_PLUGIN_ROOT}` et `~` sont resolus ; un fichier absent est nomme,
    pas invente : l'ADW decide (refus si la reference est obligatoire).
    """
    import os
    source = os.environ if env is None else env
    chunks: list[str] = []
    missing: list[str] = []
    for raw in paths:
        expanded = raw.replace("${CLAUDE_PLUGIN_ROOT}", source.get("CLAUDE_PLUGIN_ROOT", "${CLAUDE_PLUGIN_ROOT}"))
        path = Path(os.path.expanduser(expanded))
        if not path.is_absolute():
            path = Path(repo_root) / path
        if not path.is_file():
            missing.append(raw)
            continue
        chunks.append(f"### convention : {path.name}\n\n{path.read_text(encoding='utf-8', errors='replace').strip()}")
    return "\n\n".join(chunks), missing


if __name__ == "__main__":
    # La gate du module — zero token : chaque regle du cycle, a sec.
    # Lancer depuis la racine : uv run python -m adws.adw_modules.tdd
    import tempfile

    # NAME : la phrase metier passe, l'etiquette technique est refusee, avec ses motifs.
    assert name_problems("A_valid_order_with_available_items_should_be_confirmed") == []
    assert name_problems("A_dicom_instance_with_missing_sop_uid_should_be_rejected") == []
    for bad in ("Test_OrderService_Returns_True", "Should_Work_When_Valid", "OrderIsConfirmed",
                "A_valid_order_is_confirmed", "should_confirm"):
        assert name_problems(bad), bad
    assert any("techniques" in p for p in name_problems("Test_OrderService_Returns_True"))

    # RED : compilation != RED ; passe = mal ecrit ; sans assertion != RED ; assertion = RED.
    bun = DEFAULTS["bun"]
    name = "A_document_saved_twice_should_keep_one_version"
    assert red_verdict(1, "error: Cannot find module './store'\n", bun, name).kind == "compile_error"
    assert red_verdict(0, f"✓ {name}\n 1 pass\n 0 fail\n", bun, name).kind == "passed"
    assert red_verdict(1, "✗ Another_test\nerror: TypeError: x is not a function\n 0 pass\n 1 fail", bun, name).kind == "not_found"
    assert red_verdict(1, f"✗ {name}\nerror: TypeError: store.save is not a function\n 1 fail", bun, name).kind == "no_assertion"
    red = red_verdict(1, f"✗ {name}\nerror: expect(received).toBe(expected)\nExpected: 1\nReceived: 2\n 1 fail", bun, name)
    assert red.ok and red.kind == "red" and "expect(" in red.detail
    dotnet = DEFAULTS["dotnet"]
    assert red_verdict(1, "Program.cs(12,5): error CS0103: The name 'x' does not exist", dotnet, name).kind == "compile_error"
    assert red_verdict(1, f"  Failed {name} [3 ms]\n  Error Message:\n   Assert.Equal() Failure\n", dotnet, name).ok
    assert dotnet.command(name) == ["dotnet", "test", "--nologo", "--filter", name]
    assert green_verdict(0, " 1 pass", bun).ok and not green_verdict(1, "expect(received).toBe(1)", bun).ok

    # GREEN : une transformation de la liste, ou le refus.
    assert tpp_problem("null -> constant") is None and tpp_problem("unconditional → conditional") is None
    assert tpp_problem("gold plating") and tpp_problem("") and tpp_problem("if -> switch")

    # Les reglages : defauts par famille, surcharge par le bloc du contexte, famille inconnue refusee.
    tuned = settings_for({"tdd": {"test_glob": "apps/plume/**/*.test.ts", "references": {"red": ("docs/testing.md",)}}},
                         (("bun", "test"),))
    assert tuned.test_glob == "apps/plume/**/*.test.ts" and tuned.test_one == bun.test_one
    assert tuned.references["red"] == ("docs/testing.md",) and tuned.references["green"] == ()
    assert settings_for({}, (("dotnet", "test"),)).compile_error == dotnet.compile_error
    try:
        settings_for({}, (("cargo", "test"),))
        raise AssertionError("une famille inconnue sans bloc complet doit etre refusee")
    except ValueError as error:
        assert "cargo" in str(error)

    # Les tests intacts, la carte, les references — dans un depot temporaire.
    with tempfile.TemporaryDirectory() as tmp:
        root = Path(tmp)
        tests = root / "apps/plume/tests"
        tests.mkdir(parents=True)
        spec_file = tests / "store.test.ts"
        spec_file.write_text(f'test("{name}", () => {{ expect(1).toBe(2); }});\n', encoding="utf-8")
        before = fingerprint(test_files(root, "apps/plume", tuned.test_glob), root)
        assert list(before) == ["apps/plume/tests/store.test.ts"]
        assert test_declares(spec_file, name) and not test_declares(spec_file, "Another_name")
        spec_file.write_text("changed", encoding="utf-8")
        after = fingerprint(test_files(root, "apps/plume", tuned.test_glob), root)
        assert tests_changed(before, after) == ["apps/plume/tests/store.test.ts"]
        assert tests_changed(after, after) == []
        card = card_path("apps/plume/tests/store.test.ts", name)
        assert str(card).replace("\\", "/") == f"apps/plume/tests/{name}.md"
        text = render_card(test_name=name, specification="Sauvegarder deux fois garde une version.",
                           test_file="apps/plume/tests/store.test.ts", test_class="store.test.ts",
                           transformation="null -> constant", refactoring="", files_touched=["apps/plume/store.ts"],
                           guidance="go")
        assert text.startswith(f"# {name.replace('_', ' ')}\n") and "- Refactoring: None" in text
        assert "## Architectural Decisions\ngo" in text and "- TPP step: null -> constant" in text
        (root / "docs").mkdir()
        (root / "docs/testing.md").write_text("# Testing\nNo Moq.", encoding="utf-8")
        loaded, missing = load_references(("docs/testing.md", "${CLAUDE_PLUGIN_ROOT}/reference/essentials/ddd.md"),
                                          root, env={"CLAUDE_PLUGIN_ROOT": str(root / "plugin")})
        assert "No Moq." in loaded and missing == ["${CLAUDE_PLUGIN_ROOT}/reference/essentials/ddd.md"]
    assert count_tests(" 3 pass\n 1 fail\n") == (3, 1) and count_tests("Passed: 7, Failed: 0") == (7, 0)
    assert display_red(name, "expect(received).toBe(expected)").startswith("🔴 RED confirmed\nTest: ")
    print(f"tdd OK — nom metier (refus des etiquettes techniques), RED = assertion (compilation, test vert, "
          f"test absent, exception refuses), GREEN = un pas TPP parmi {len(TPP)}, tests intacts par empreinte, "
          "carte d'acceptation au format fixe, references resolues (absentes nommees)")

Pièce — adws/adw_tdd.py

Nouveau : le cycle. Quatre enveloppes (NameEnvelope, RedEnvelope, GreenEnvelope, RefactorEnvelope), les consignes de la commande mot pour mot (NAME_ASK, RED_ASK, GREEN_ASK, REFACTOR_ASK), une fabrique verified_action (agent_action du chapitre A7 plus un vérificateur déterministe et la session poursuivie de phase en phase), l’état du cycle (State, tdd.json), la classe Cycle qui assemble les phases depuis l’étape courante, la pause (guidance), la carte (trace), et main avec --context, --guidance, --resume, --clarify, --show-cycle et la boucle REPEAT au terminal.

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = ["pyyaml"]
# ///
"""adw_tdd — le cycle TDD d'acceptation, tenu par le code (annexe C2).

Usage :
    uv run adws/adw_tdd.py "<specification comportementale>" [--context plume] [--guidance go] [--headless]
    uv run adws/adw_tdd.py --resume <adw_id> --guidance "use a Value Object"   # apres une suspension
    uv run adws/adw_tdd.py --resume <adw_id> --clarify "<precision sur la spec>"
    uv run adws/adw_tdd.py --show-cycle [--context plume]                      # la gate a sec

Le cycle : NAME -> RED -> reach RED -> guidance (PAUSE OBLIGATOIRE) ->
GREEN (TPP) -> verify GREEN -> REFACTOR -> TRACE -> REPEAT. Chaque phase
agent recoit, mot pour mot, la consigne du cycle ; chaque regle stricte
est verifiee par une phase code (adw_modules/tdd.py), et un manquement
revient a l'agent en correction, dans la MEME session — le test, le code
et le refactor sont ecrits par un seul agent qui garde son fil. La pause
architecturale suspend le run (PhaseHold) tant qu'un humain n'a pas parle :
dans un terminal, il repond sur place ; ailleurs, il relance avec
--resume. La carte d'acceptation est ecrite par le code, jamais par l'agent.
"""
import argparse
import json
import subprocess
import sys
import uuid
from dataclasses import asdict, dataclass, field
from pathlib import Path

from adw_modules import envelopes, harness, permissions, roster, tdd
from adw_modules.envelopes import Envelope, EnvelopeError
from adw_modules.permissions import PermissionBreach
from adw_modules.runner import PhaseFailure, PhaseHold, PhaseSpec, Run
from adw_scout import GUARD_ASK, GUARD_ENV, phase_env, phase_environment, read_guard_report
from adw_build import scoped

STATE_FILE = "tdd.json"     # l'etat du cycle, sous adws/adw_data/runs/<adw_id>/ — la memoire d'une reprise


# ---------- les enveloppes : ce que chaque phase doit au code ----------

@dataclass(frozen=True)
class NameEnvelope(Envelope):
    """NAME : un critere d'acceptation — ou la question qui empeche de le nommer."""
    test_name: str = field(default="", metadata={
        "ask": "le critere comme nom de test, phrase metier : A_valid_order_with_available_items_should_be_confirmed"})
    clarification: str = field(default="", metadata={
        "ask": "si tu ne peux PAS nommer une phrase metier : la question a poser sur la spec (status=fail)"})


@dataclass(frozen=True)
class RedEnvelope(Envelope):
    """RED : le test ecrit, ou il vit, et l'assertion sur laquelle il echoue."""
    test_name: str = field(default="", metadata={"ask": "le nom exact du test, identique au critere"})
    test_file: str = field(default="", metadata={"ask": "chemin relatif du fichier de test"})
    test_class: str = field(default="", metadata={"ask": "classe ou bloc de test qui le contient"})
    failure: str = field(default="", metadata={"ask": "le message d'assertion observe (pas une erreur de compilation)"})

    def check(self) -> None:
        super().check()
        if self.status == "success" and not (self.test_name and self.test_file):
            raise EnvelopeError("un RED declare test_name et test_file")


@dataclass(frozen=True)
class GreenEnvelope(Envelope):
    """GREEN : la transformation TPP appliquee, et les fichiers de production touches."""
    transformation: str = field(default="", metadata={
        "ask": "le pas TPP applique, un de : " + " ; ".join(tdd.TPP)})
    changed_files: list = field(default_factory=list, metadata={
        "ask": "chemins des fichiers de PRODUCTION crees ou modifies, [] si status=fail"})


@dataclass(frozen=True)
class RefactorEnvelope(Envelope):
    """REFACTOR : une ligne sur ce qui a ete ameliore — ou « No refactoring needed »."""
    improvement: str = field(default="", metadata={
        "ask": "1 ligne : ce qui a ete ameliore, ou exactement « No refactoring needed »"})
    changed_files: list = field(default_factory=list, metadata={
        "ask": "chemins des fichiers modifies par le refactor, [] si aucun"})


# ---------- les consignes du cycle, mot pour mot ----------

NAME_ASK = """Phase 1: NAME — Phrase the Acceptance Criterion
The name comes first. Before arrange, before act, before any thought about implementation.
Analyze the specification: "{spec}"
Phrase ONE acceptance criterion as a test name.
The name must be a business sentence readable by a non-developer.
Naming rules:
- Use full business sentences: A_valid_order_with_available_items_should_be_confirmed
- NOT technical labels: Test_OrderService_Returns_True, Should_Work_When_Valid
- If you can't phrase the test name as a business sentence, STOP — clarify the spec first:
  set status=fail and put your question in 'clarification'.
If the specification is ambiguous, ask for clarification BEFORE writing any test."""

RED_ASK = """Phase 2: RED — Write the Failing Test (Wishful Thinking)
Acceptance criterion: {test_name}
Specification: "{spec}"
Write ONE test that describes this behavior.
- Outside-In: start from the use case / entry port, never from inner layers.
- Use the ideal API even if it doesn't exist yet — call methods that don't exist, manipulate objects that haven't been created.
- Create minimal stubs/classes so the test compiles.
- The test is named EXACTLY {test_name}.
MANDATORY: Follow ALL conventions from the references below (aggregate design, invariants, test naming,
stubs location, no mocking framework, Outside-In, TPP, use case naming, hexagonal architecture).
Phase 3: Reach RED
Run the test with: {command}
- The test MUST compile.
- The test MUST fail on an ASSERTION (not a compilation error).
- If compilation error -> fix until it compiles. If the test already passes -> the test is poorly written, reformulate.
{references}"""

GREEN_ASK = """Phase 5: GREEN — Minimal Transformation (TPP)
Acceptance criterion: {test_name} — test file: {test_file}
Architectural guidance from the user: {guidance}
Make the test pass with the smallest transformation possible, following the Transformation Priority Premise:
{{}}          -> nil/null
null        -> constant
constant    -> variable
statement   -> statements
unconditional -> conditional
scalar      -> collection
statement   -> recursion/iteration
Rules:
- If returning a constant is enough -> do it. If a variable is needed -> introduce it. If an if appears -> accept it.
- If you find yourself writing a switch or complex loop -> the test is too ambitious, break it down.
- Do NOT modify any test file. Domain logic in Domain layer, NEVER in adapters or Infrastructure.
- No internal mocks — only mock outbound adapters (DB, bus, external APIs).
Phase 6: Verify GREEN — run the test with: {command} — it MUST pass.
Report the TPP step you applied in 'transformation', exactly as written in the list above.
{references}"""

REFACTOR_ASK = """Phase 7: REFACTOR — Clean Up on Green
Acceptance criterion: {test_name} — the test passes.
With the test passing, improve the design without changing behavior:
- Extract Value Objects if warranted.
- Rename for clarity (full domain names, no abbreviations).
- Add Domain Events if relevant.
- No new behavior — pure structural improvement. Do NOT modify any test file.
Run the tests again — they MUST still pass. If they fail -> undo, refactor differently.
Report a 1-line summary of what was improved in 'improvement', or exactly « No refactoring needed ».
{references}"""

TDD_BRIEF = """Tu es le builder de l'usine, en cycle TDD d'acceptation : NAME -> RED -> GREEN (TPP) -> REFACTOR.
Une seule phase a la fois ; le code verifie chacune et te renvoie tout manquement. Termine par l'enveloppe demandee."""


# ---------- l'etat du cycle : la memoire d'une reprise ----------

@dataclass
class State:
    adw_id: str
    spec: str
    context: str
    stage: str = "name"             # name | red | guidance | green | refactor | trace | done
    test_name: str = ""
    test_file: str = ""
    test_class: str = ""
    failure: str = ""
    guidance: str = ""
    transformation: str = ""
    improvement: str = ""
    tests_before: dict = field(default_factory=dict)
    touched: list = field(default_factory=list)
    sessions: dict = field(default_factory=dict)


def state_path(factory: roster.Roster, adw_id: str) -> Path:
    return Path(factory.data_dir, "runs", adw_id, STATE_FILE)


def save_state(factory: roster.Roster, state: State, run: Run) -> None:
    state.sessions = dict(run.sessions)
    path = state_path(factory, state.adw_id)
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(asdict(state), indent=2, ensure_ascii=False), encoding="utf-8")


def load_state(factory: roster.Roster, adw_id: str) -> State:
    path = state_path(factory, adw_id)
    if not path.is_file():
        raise SystemExit(f"aucun cycle a reprendre : {path} absent")
    return State(**json.loads(path.read_text(encoding="utf-8")))


# ---------- une phase agent verifiee par le code, dans la MEME session ----------

def verified_action(phase_name, agent, make_ask, expected, verify, session_from=None):
    """Comme agent_action (A7), plus un verificateur deterministe apres l'enveloppe.

    verify(envelope, run) leve PhaseFailure avec le motif exact ; le runner
    retente la phase et la tentative suivante part en correction dans la
    meme session. session_from : la phase dont on poursuit la session — le
    meme agent ecrit le test, le code et le refactor.
    """
    next_ask = envelopes.correction("enveloppe invalide", expected)

    def action(run: Run, attempt: int):
        nonlocal next_ask
        if attempt == 0 and session_from and session_from in run.sessions:
            run.sessions[phase_name] = run.sessions[session_from]
        ask = make_ask(run) if attempt == 0 else next_ask
        variables = phase_env(phase_name, agent, run)
        request = harness.HarnessRequest(
            prompt=ask, session_id=run.sessions.get(phase_name),
            model=agent.model, thinking=agent.thinking, tools=agent.tools,
            timeout=agent.timeout, auth=agent.auth.credentials(), direct=agent.auth.direct)
        try:
            with phase_environment(variables):
                result = harness.run(agent.harness, request)
        except harness.HarnessError as error:
            if error.session_id:
                run.sessions[phase_name] = 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(expected)
                raise PhaseFailure(f"coupe-circuit ({guard.get('kind', '?')}) : {reason}") from None
            raise PhaseFailure(str(error)) from None
        run.sessions[phase_name] = 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, expected)
        except EnvelopeError as error:
            next_ask = envelopes.correction(str(error), expected)
            raise PhaseFailure(f"enveloppe invalide : {error}") from None
        try:
            verify(envelope, run)
        except PhaseFailure as error:
            next_ask = envelopes.correction(str(error), expected)
            raise
        return envelope
    return action


# ---------- le cycle ----------

class Cycle:
    """Les phases d'un critere, construites autour d'un etat et d'un contexte."""

    def __init__(self, factory: roster.Roster, context: roster.Context, agent: roster.AgentSpec,
                 state: State, guidance_arg: str | None, interactive: bool):
        self.factory, self.context, self.agent, self.state = factory, context, agent, state
        self.settings = tdd.settings_for(context.adw, context.truth)
        self.guidance_arg = guidance_arg
        self.interactive = interactive      # un humain au terminal : la pause attend sur place
        self.refs = {phase: tdd.load_references(self.settings.references[phase])
                     for phase in ("red", "green", "refactor")}

    # -- utilitaires
    def run_one(self) -> tuple[int, str]:
        cmd = self.settings.command(self.state.test_name)
        proc = subprocess.run(cmd, cwd=self.context.dir, capture_output=True, text=True,
                              encoding="utf-8", errors="replace", timeout=600)
        return proc.returncode, (proc.stdout or "") + (proc.stderr or "")

    def run_truth(self) -> tuple[int, str]:
        out, code = "", 0
        for cmd, cwd in self.context.commands():
            proc = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True,
                                  encoding="utf-8", errors="replace", timeout=900)
            out += (proc.stdout or "") + (proc.stderr or "")
            code = code or proc.returncode
        return code, out

    def photo(self) -> dict[str, str]:
        return tdd.fingerprint(tdd.test_files(".", self.context.dir, self.settings.test_glob), ".")

    def references(self, phase: str) -> str:
        text, missing = self.refs[phase]
        if missing:
            raise SystemExit(f"references introuvables pour la phase {phase} : {missing} — "
                             "le cycle exige ses conventions (bloc adw.tdd.references du contexte)")
        return text

    def save(self, run: Run, stage: str) -> None:
        self.state.stage = stage
        save_state(self.factory, self.state, run)

    # -- NAME
    def name_ask(self, run: Run) -> str:
        return TDD_BRIEF + "\n\n" + NAME_ASK.format(spec=self.state.spec) + "\n\n" + envelopes.contract(NameEnvelope)

    def verify_name(self, envelope: NameEnvelope, run: Run) -> None:
        if envelope.status != "success":
            return                          # la question de clarification : traitee en phase code
        problems = tdd.name_problems(envelope.test_name)
        if problems:
            raise PhaseFailure("le nom n'est pas un critere d'acceptation : " + " ; ".join(problems))

    def dispose_name(self, run: Run, attempt: int) -> str:
        envelope: NameEnvelope = run.results["name"]
        if envelope.status != "success":
            self.save(run, "name")
            raise PhaseHold("la spec doit etre clarifiee avant tout test — question de l'agent : "
                            f"{envelope.clarification or envelope.summary!r} ; relancez avec "
                            f"--resume {run.adw_id} --clarify \"<votre precision>\"")
        self.state.test_name = envelope.test_name
        print(tdd.display_name(envelope.test_name))
        self.save(run, "red")
        return envelope.test_name

    # -- RED
    def red_ask(self, run: Run) -> str:
        return (TDD_BRIEF + "\n\n" + RED_ASK.format(
            test_name=self.state.test_name, spec=self.state.spec,
            command=" ".join(self.settings.command(self.state.test_name)),
            references=self.references("red")) + "\n\n" + envelopes.contract(RedEnvelope))

    def verify_red(self, envelope: RedEnvelope, run: Run) -> None:
        if envelope.status != "success":
            raise PhaseFailure(f"RED non atteint : {envelope.summary}")
        if envelope.test_name != self.state.test_name:
            raise PhaseFailure(f"le test doit s'appeler exactement {self.state.test_name!r}")
        if not tdd.test_declares(envelope.test_file, envelope.test_name):
            raise PhaseFailure(f"{envelope.test_file} ne contient pas de test nomme {envelope.test_name!r}")
        code, output = self.run_one()
        verdict = tdd.red_verdict(code, output, self.settings, envelope.test_name)
        if not verdict.ok:
            raise PhaseFailure(f"reach RED ({verdict.kind}) : {verdict.detail}")
        self.state.failure = verdict.detail

    def dispose_red(self, run: Run, attempt: int) -> RedEnvelope:
        envelope: RedEnvelope = run.results["red"]
        self.state.test_file, self.state.test_class = envelope.test_file, envelope.test_class
        self.state.tests_before = self.photo()
        print(tdd.display_red(envelope.test_name, self.state.failure))
        self.save(run, "guidance")
        return envelope

    # -- la pause architecturale, obligatoire
    def guidance(self, run: Run, attempt: int) -> str:
        if self.state.guidance:
            return self.state.guidance
        answer = self.guidance_arg
        if answer is None and self.interactive:
            print(tdd.display_guidance())
            answer = input("> ").strip()
        if not answer:
            self.save(run, "guidance")
            raise PhaseHold(tdd.display_guidance().replace("\n", " ") + f" — relancez avec --resume {run.adw_id} --guidance \"go\"")
        self.state.guidance = answer
        print(f"🏗️ Architectural guidance: {answer}")
        self.save(run, "green")
        return answer

    # -- GREEN
    def green_ask(self, run: Run) -> str:
        return (TDD_BRIEF + "\n\n" + GREEN_ASK.format(
            test_name=self.state.test_name, test_file=self.state.test_file,
            guidance=self.state.guidance, command=" ".join(self.settings.command(self.state.test_name)),
            references=self.references("green")) + "\n\n" + envelopes.contract(GreenEnvelope))

    def verify_intact_tests(self) -> None:
        changed = tdd.tests_changed(self.state.tests_before, self.photo())
        if changed:
            raise PhaseFailure("les tests ne bougent pas apres RED — restaure : " + ", ".join(changed))

    def verify_green(self, envelope: GreenEnvelope, run: Run) -> None:
        if envelope.status != "success":
            raise PhaseFailure(f"GREEN non atteint : {envelope.summary}")
        self.verify_intact_tests()
        problem = tdd.tpp_problem(envelope.transformation)
        if problem:
            raise PhaseFailure(problem)
        code, output = self.run_one()
        verdict = tdd.green_verdict(code, output, self.settings)
        if not verdict.ok:
            raise PhaseFailure(f"verify GREEN ({verdict.kind}) : {verdict.detail}")

    def dispose_green(self, run: Run, attempt: int) -> GreenEnvelope:
        envelope: GreenEnvelope = run.results["green"]
        self.state.transformation = envelope.transformation
        print(tdd.display_green(envelope.transformation))
        self.save(run, "refactor")
        return envelope

    # -- REFACTOR
    def refactor_ask(self, run: Run) -> str:
        return (TDD_BRIEF + "\n\n" + REFACTOR_ASK.format(
            test_name=self.state.test_name, references=self.references("refactor"))
            + "\n\n" + envelopes.contract(RefactorEnvelope))

    def verify_refactor(self, envelope: RefactorEnvelope, run: Run) -> None:
        if envelope.status != "success":
            raise PhaseFailure(f"REFACTOR non termine : {envelope.summary}")
        self.verify_intact_tests()
        code, output = self.run_truth()
        verdict = tdd.green_verdict(code, output, self.settings)
        if not verdict.ok:
            raise PhaseFailure(f"les tests ne passent plus apres le refactor — undo, refactor differently : "
                               f"{verdict.detail}")
        run.results["refactor_output"] = output

    def dispose_refactor(self, run: Run, attempt: int) -> RefactorEnvelope:
        envelope: RefactorEnvelope = run.results["refactor"]
        self.state.improvement = envelope.improvement
        passed, failed = tdd.count_tests(run.results.get("refactor_output", ""))
        print(tdd.display_refactor(envelope.improvement, passed, failed))
        self.save(run, "trace")
        return envelope

    # -- le perimetre, releve
    def perimeter(self, phase: str):
        def action(run: Run, attempt: int):
            try:
                touched = permissions.enforce(".", self.agent, self.factory, run.results["constat_tdd"])
            except PermissionBreach as breach:
                raise PhaseFailure(str(breach)) from None
            self.state.touched = sorted(set(self.state.touched) | set(touched))
            return touched
        return action

    def constat(self, run: Run, attempt: int):
        Path(self.factory.data_dir, "runs", run.adw_id).mkdir(parents=True, exist_ok=True)
        return permissions.snapshot(".")

    # -- TRACE
    def trace(self, run: Run, attempt: int) -> str:
        runtime = self.factory.data_dir.rstrip("/") + "/"
        production = [p for p in self.state.touched
                      if p not in self.state.tests_before and not p.startswith(runtime)]
        card = tdd.card_path(self.state.test_file, self.state.test_name)
        card.write_text(tdd.render_card(
            test_name=self.state.test_name, specification=self.state.spec,
            test_file=self.state.test_file, test_class=self.state.test_class,
            transformation=self.state.transformation, refactoring=self.state.improvement
            if self.state.improvement != tdd.NO_REFACTORING else "", files_touched=production,
            guidance=self.state.guidance if self.state.guidance.lower() != "go" else ""),
            encoding="utf-8")
        print(f"📄 Acceptance card: {card}")
        self.save(run, "done")
        return str(card)

    # -- les phases, depuis une etape
    def phases(self, retries: int) -> list[PhaseSpec]:
        order = ["name", "red", "guidance", "green", "refactor", "trace"]
        start = order.index(self.state.stage) if self.state.stage in order else 0
        all_phases = {
            "name": [
                PhaseSpec("constat_tdd", "code", self.constat),
                PhaseSpec("name", "agent", verified_action("name", self.agent, self.name_ask, NameEnvelope,
                                                           self.verify_name), retries),
                PhaseSpec("dispose_name", "code", self.dispose_name),
            ],
            "red": [
                PhaseSpec("red", "agent", verified_action("red", self.agent, self.red_ask, RedEnvelope,
                                                          self.verify_red, session_from="name"), retries),
                PhaseSpec("perimetre_red", "code", self.perimeter("red")),
                PhaseSpec("dispose_red", "code", self.dispose_red),
            ],
            "guidance": [PhaseSpec("guidance", "code", self.guidance)],
            "green": [
                PhaseSpec("green", "agent", verified_action("green", self.agent, self.green_ask, GreenEnvelope,
                                                            self.verify_green, session_from="red"), retries),
                PhaseSpec("perimetre_green", "code", self.perimeter("green")),
                PhaseSpec("dispose_green", "code", self.dispose_green),
            ],
            "refactor": [
                PhaseSpec("refactor", "agent", verified_action("refactor", self.agent, self.refactor_ask,
                                                               RefactorEnvelope, self.verify_refactor,
                                                               session_from="green"), retries),
                PhaseSpec("perimetre_refactor", "code", self.perimeter("refactor")),
                PhaseSpec("dispose_refactor", "code", self.dispose_refactor),
            ],
            "trace": [PhaseSpec("trace", "code", self.trace)],
        }
        phases: list[PhaseSpec] = []
        if start > 0:
            phases.append(PhaseSpec("constat_tdd", "code", self.constat))   # une reprise repart d'un etat des lieux
        for stage in order[start:]:
            phases += all_phases[stage]
        return phases


def show_cycle(context: roster.Context) -> int:
    settings = tdd.settings_for(context.adw, context.truth)
    print(f"contexte {context.name} : {context.dir}")
    print("  test_one     :", " ".join(settings.command("<Nom_du_test>")))
    print("  test_glob    :", settings.test_glob)
    print("  compile_error:", settings.compile_error)
    print("  assertion    :", settings.assertion)
    for phase in ("red", "green", "refactor"):
        text, missing = tdd.load_references(settings.references[phase])
        print(f"  references {phase:8}: {len(settings.references[phase])} fichier(s)"
              + (f" — ABSENTS : {missing}" if missing else ""))
    print("  cycle        : NAME -> RED -> reach RED -> guidance (pause) -> GREEN (TPP) -> REFACTOR -> TRACE -> REPEAT")
    return 0


def main() -> int:
    parser = argparse.ArgumentParser(description="Le cycle TDD d'acceptation, tenu par le code.")
    parser.add_argument("spec", nargs="?", help="la specification comportementale, en langage naturel")
    parser.add_argument("--config", default=str(roster.DEFAULT_PATH))
    parser.add_argument("--context", default=None, help="le contexte du payload (C1)")
    parser.add_argument("--guidance", default=None, help="la guidance architecturale (\"go\" pour continuer)")
    parser.add_argument("--resume", default=None, help="reprendre un cycle suspendu par son adw_id")
    parser.add_argument("--clarify", default=None, help="une precision sur la spec, apres une demande de clarification")
    parser.add_argument("--retries", type=int, default=3)
    parser.add_argument("--headless", action="store_true",
                        help="ne jamais attendre au terminal : la pause suspend le run (CI, just, boite)")
    parser.add_argument("--show-cycle", action="store_true", help="afficher les reglages du cycle, puis sortir")
    args = parser.parse_args()

    factory = roster.load(args.config)                       # zero token
    context = factory.payload.context(args.context)
    if args.show_cycle:
        return show_cycle(context)
    agent = scoped(factory.agents["builder"], context)       # borne au contexte (C1)

    if args.resume:
        state = load_state(factory, args.resume)
        if args.clarify:
            state.spec = f"{state.spec}\nClarification: {args.clarify}"
            state.stage = "name"
        run = Run(adw_id=state.adw_id)
        run.sessions.update(state.sessions)
    else:
        if not args.spec:
            parser.error("spec manquante — uv run adws/adw_tdd.py \"<specification comportementale>\"")
        state = State(adw_id=uuid.uuid4().hex[:8], spec=args.spec, context=context.name)
        run = Run(adw_id=state.adw_id)

    interactive = not args.headless and sys.stdin.isatty()
    while True:
        cycle = Cycle(factory, context, agent, state, args.guidance, interactive)
        code = run.execute(cycle.phases(args.retries))
        if code != 0:
            return code                                      # rouge, ou suspendu (HOLD)
        print(tdd.display_repeat())
        if not interactive:
            return 0
        answer = input("> ").strip()
        if not answer or answer.lower() == "done":
            return 0
        state = State(adw_id=uuid.uuid4().hex[:8], spec=answer, context=context.name)
        run = Run(adw_id=state.adw_id)
        args.guidance = None


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

La gate du TP

Depuis la racine de plume-factory, dépôt git propre. Une commande par ligne, identiques dans bash et PowerShell. Les deux premières ne coûtent rien, la troisième lance un cycle en headless (la guidance n’est pas donnée : le run doit se suspendre), la quatrième le reprend avec la guidance. Remplacez <adw_id> par l’identifiant que la suspension a affiché.

uv run python -m adws.adw_modules.tdd
uv run adws/adw_tdd.py --show-cycle
uv run adws/adw_tdd.py "Sauvegarder deux fois le meme document ne cree qu'une seule version dans le store." --context plume --headless
uv run adws/adw_tdd.py --resume <adw_id> --guidance go

(Sans --headless, le cycle vous pose la question au terminal et attend : c’est l’autre façon de faire la gate, celle de la commande.)

Résultat attendu : tdd OK — nom metier (refus des etiquettes techniques), RED = assertion (compilation, test vert, test absent, exception refuses), GREEN = un pas TPP parmi 7, tests intacts par empreinte, carte d'acceptation au format fixe, references resolues (absentes nommees). --show-cycle affiche test_one : bun test --test-name-pattern <Nom_du_test>, le glob, les deux regex, references … 0 fichier(s) et la ligne du cycle. Le troisième run affiche 📝 avec un critère en phrase métier, 🔴 avec la ligne expect(…) lue par le code, puis se suspend : run SUSPENDU en phase guidance — 🏗️ Architectural guidance before implementation? … relancez avec --resume <adw_id> --guidance "go" et un code de retour 3. La reprise affiche 🏗️ Architectural guidance: go, 🟢 avec un pas de la liste TPP, ♻️ avec Tests: N passed, 0 failed, puis 📄 Acceptance card: apps/plume/<Le_critere>.md et run vert. Ouvrez la carte : la spec, le fichier de test, le pas TPP, les fichiers touchés relevés par le périmètre, « None » pour les décisions puisque vous avez répondu go. Dans factory.db, un seul run pour les deux invocations, run_hold entre les deux. Pour voir une règle refuser : relancez un cycle avec une spec ambiguë (« améliore le store ») : NAME rend une question et le run se suspend en demandant --clarify. Coût : les deux gates à sec ne dépensent rien, un cycle complet sur Plume quelques dizaines de centimes et cinq à huit minutes sur le workhorse. Variante éco : --config adws/adw_config/eco.config.yaml. Si un builder s’obstine (trois corrections sur un RED de compilation), le run est rouge avec le motif exact : lisez-le, c’est presque toujours la spec.


Quiz — teste tes connaissances
Annexe — De l'usine au produit 7 questions Objectif : 5/7 minimum
0/7
bonnes reponses
Objectif non atteint (minimum 5/7 requis).
Remonte relire la fiche memo en pretant attention aux points manques, puis cliquer sur « Recommencer » pour retenter.