L'usine comme skill & le stamp
Votre usine tient dans un manifeste : un skill qui dit quoi faire, des recettes qui savent comment, et un script qui tamponne le tout dans n'importe quel dépôt — sans le payload, sans les secrets, sans un token. La pièce du jour ouvre le module 7 : l'usine s'emballe.
Hier, votre usine a lancé trois rosters dans trois boîtes, moissonné les résultats et vous a
laissé disposer. Vingt-cinq chapitres de pièces, et une question qui se pose maintenant :
si un collègue vous demande « je peux avoir la même chose sur mon dépôt ? », que lui envoyez-vous ?
Un lien vers plume-factory avec « copie ce qu’il faut » n’est pas une réponse : c’est un
après-midi de tri, et une usine qui diverge dès le premier jour. À la fin de ce chapitre, votre
usine tiendra dans un manifeste : un skill qui explique à un agent comment s’en servir, un
script qui la tamponne dans un dépôt vierge en une commande, et un cookbook qui ne se lit que
quand on en a besoin. La pièce du jour est .claude/skills/factory/, avec SKILL.md, scripts/stamp.py
et cookbooks/lancer-un-adw.md, voisine du skill de l’orchestrateur posé hier. Elle ouvre la
zone « Distribution » du plan, la dernière.
Empaqueter l’usine en un skill installable
L’idée en une phrase
L’usine n’est pas un service : c’est un jeu de fichiers, et un jeu de fichiers voyage par
copie. Le skill est la pièce qui rend cette copie reproductible : un manifeste de ce
qui est l’usine, un script déterministe qui le pose (stamp), et un mode d’emploi qu’un agent
lit pour piloter le résultat. Le tout du côté code de la couture : tamponner ne coûte
zéro token.
Points clés
- Le manifeste dit ce qui est l’usine, et donc ce qui ne l’est pas. Voyagent :
adws/*.py,adws/adw_modules/,adws/adw_config/,justfile,just/,.env.sampleet les deux skills. Restent à la maison :apps/plume,specs/,app_docs/,PLAN.md, soit le payload et sa mémoire, et surtoutadws/adw_data/et.env, le runtime et les secrets. Un stamp qui emporterait.envserait une fuite de clé, un stamp qui emporterait Plume serait un fork, pas une installation. - Idempotent, ou rien. Un fichier déjà présent dans la cible est sauté, jamais écrasé :
le deuxième stamp est gratuit et sert de contrôle de dérive (« 0 posé, 38 sautés » veut
dire « rien n’a bougé »).
--forceécrase tout le manifeste, et se lance après un commit. - Le
.gitignorede la cible est complété, pas remplacé. Quatre lignes (.env,adws/adw_data/,__pycache__/,*.pyc) ajoutées seulement si elles manquent. Un ADW qui finit par un commit ferait sinon entrer la base SQLite et les bytecodes dans l’historique. - L’usine a des ancrages payload, et le stamp les nomme.
adw_build.pylancebun testdansapps/plume, la recettejust testaussi, etdoctor.pyattendspecs/plume-baseline.md. Le script compte ces lignes et les imprime : c’est la dette que le chapitre 27 remboursera en déplaçant le payload dans le roster. Une pièce honnête dit ce qu’elle ne fait pas encore. - Le skill se lit dans les deux harnais. Claude Code découvre
.claude/skills/factory/et l’expose comme la commande/factory(le nom vient du dossier, pas du frontmatter). pi charge le même fichier par--skill .claude/skills/factoryou la cléskillsde.pi/settings.json, comme le skill d’hier. Un seul SKILL.md, deux harnais.
Exemple concret
Votre collègue a un dépôt Bun avec une API et une suite de tests. Depuis plume-factory,
vous tapez uv run .claude/skills/factory/scripts/stamp.py --into ../son-depot --dry-run :
le script liste une quarantaine de fichiers qu’il poserait, quatre lignes de .gitignore,
et une poignée d’ancrages payload à adapter. Rien n’est écrit. Vous relancez sans --dry-run :
moins d’une seconde, zéro token. Dans son dépôt, just liste la surface complète,
doctor, hello, obs, sandbox. Il copie .env.sample en .env, colle sa clé, et
uv run adws/adw_prompt.py "Dis bonjour en une ligne" fait tourner la première phase par le
port du chapitre 7 : quelques centimes, une trace dans adws/adw_data/factory.db,
just obs runs l’affiche. Il n’a lu aucun des vingt-cinq chapitres, et il a un runner, un
roster, des gates et une salle de contrôle. Ce qu’il n’a pas encore : un bun test qui pointe
sur son dossier. Le stamp lui a dit où.
Ce qui voyage, ce qui reste
| Élément | Voyage ? | Pourquoi |
|---|---|---|
adws/, just/, justfile | oui | c’est l’usine : le graphe, les phases, la surface |
adws/adw_config/*.yaml | oui | les rosters — quatre moteurs prêts, à retoucher sur place |
.claude/skills/factory, factory-orchestrator | oui | l’usine emporte son mode d’emploi |
.env.sample | oui — vide | le gabarit ; la clé se remplit dans la cible |
apps/plume, specs/, app_docs/, PLAN.md | non | le payload et sa mémoire : le produit de cette usine |
adws/adw_data/, .env, __pycache__/ | jamais | runtime et secrets — ni copiés, ni commités |
Commande — tamponner, dans les deux harnais
Le stamp lui-même n’a qu’une version : c’est du code, il ne passe pas par le port. Ce qui a
deux versions, c’est la façon d’invoquer le skill qui l’accompagne. En Claude Code, le skill
devient une commande et reçoit ses arguments par $ARGUMENTS. En pi, vous chargez le fichier
et formulez la demande : la substitution d’arguments est propre à Claude Code, le corps du skill
n’en dépend pas.
# la forme nue — depuis plume-factory, vers un depot cible existant (git init d'abord)
uv run .claude/skills/factory/scripts/stamp.py --into ../autre-depot --dry-run
uv run .claude/skills/factory/scripts/stamp.py --into ../autre-depot
# la meme chose, decidee par un agent qui a lu le skill
claude "/factory install ../autre-depot"
pi --skill .claude/skills/factory "Lisez le skill factory, puis installez l'usine dans ../autre-depot."
# depuis un skill copie en portee utilisateur (~/.claude/skills/factory), dites d'ou vient l'usine
FACTORY_SOURCE=~/Repos/plume-factory uv run ~/.claude/skills/factory/scripts/stamp.py --into .
Piège courant : « on installe l’usine avec un
git clone, c’est la même chose » est inexact. Un clone emporte tout : Plume, les specs, l’historique, et demain votre.envsi un.gitignorefaiblit. Un stamp emporte un manifeste, rien de plus, rien de moins, et il se relance sans rien casser. Le clone est un fork, le stamp est une installation.
Thin skill, fat recipes : les cookbooks
L’idée en une phrase
Un skill mince tient le jugement (quel besoin, quelle recette, quand s’arrêter) et
délègue tout le savoir à des recettes (just, uv run adws/…) qu’un humain pourrait
taper, et à des cookbooks chargés paresseusement, un par demande. Le skill vit du côté
agent de la couture, les recettes du côté code, et la frontière entre les deux est
exactement celle que l’usine défend depuis le chapitre 3.
Points clés
- Le démarrage tient en trois pas, puis s’arrête. Lire la carte (
just), lire le menu (ls adws/adw_*.pyet la ligneUsagede chaque docstring), imprimer un tableau, attendre la demande. Tout ce qu’un agent explore avant de connaître la demande est de l’état deviné : il coûte le contexte que la vraie tâche va réclamer, et il est périmé au premier run. - Une table de routage, pas un manuel. Chaque ligne du skill associe une demande à une recette ou à un cookbook. Le savoir n’est pas dans le skill : il est derrière un lien, lu quand la demande le justifie. Un skill de 60 lignes qui route vaut mieux qu’un skill de 600 lignes que l’agent survole.
- Les fichiers sur disque sont la seule autorité. Un cookbook ne liste jamais les ADW ni
les rosters par leur nom : il dit comment les lire (
ls,head, la docstring). Les chaînes sont à vous, renommées et rewirées, et un nom mémorisé d’un document est une supposition. - L’orchestrateur ne fait jamais le travail d’un ADW. Il traduit la demande, lance, observe par la trace, rapporte. Pas de lecture des fichiers cibles « pour aider », pas de correction du code qu’un builder devait corriger : un run rouge se répare par un roster, un prompt ou un ADW modifié délibérément, puis un nouveau run.
- Le prompt qui part est celui de l’ingénieur, en plus net. Le cookbook du jour fixe la forme en quatre lignes (la demande, où, « fini veut dire », hors périmètre) et sa loi : l’intention est la sienne, la précision est la vôtre. Un prompt approximatif n’est pas une petite taxe : chaque agent de la chaîne la paie.
Exemple concret
Vous ouvrez Claude Code dans un dépôt fraîchement tamponné et tapez /factory. Le skill
charge, une centaine de lignes, ~1 000 tokens, puis l’agent lit just et les docstrings
(quelques milliers de tokens), imprime six ADW dans un tableau et attend. Vous dites : « fais
tourner un SDLC pour ajouter un endpoint de santé ». Le skill route vers
cookbooks/lancer-un-adw.md (~1 500 tokens, chargés maintenant seulement). L’agent
reformule en quatre lignes, vérifie le chemin du serveur, choisit adw_sdlc.py parce que la
demande change du code, lance avec le roster par défaut, capture l’identifiant, puis lit
just obs run <id> jusqu’au verdict. Coût de l’orchestration : quelques centimes. Coût
du SDLC : celui du chapitre 13. La variante sans cookbook, où l’agent improvise, coûte plus cher
et rend moins : c’est elle qui interroge une table qui n’existe pas, ou lance adw_prompt.py
pour un travail qui réclamait une chaîne.
Skill mince, skill épais
| Trait | Skill mince (la méthode) | Skill épais |
|---|---|---|
| Taille au chargement | ~100 lignes, une table de routage | tout le manuel, à chaque session |
| Le savoir vit dans | les recettes just, les docstrings, les cookbooks | le skill lui-même |
| Chargement | paresseux : un cookbook par demande | tout, avant de connaître la demande |
| Dérive | nulle : les fichiers font foi | dès qu’un ADW change de nom |
| Coût par session | ~1 000 tokens + ce que la demande justifie | plusieurs dizaines de milliers, périmés |
Config — la table de routage, et ce qu’elle refuse
Voici le cœur du skill du jour : la table qui envoie chaque demande vers une recette ou un cookbook, et la règle de démarrage qui interdit tout le reste. Le fichier complet est dans les travaux pratiques, et le fragment montre la forme.
## Demarrage — trois pas, puis stop
1. `just` — la surface de commandes : c'est la carte.
2. `ls adws/adw_*.py`, puis la ligne `Usage` de chaque docstring : c'est le menu.
3. Imprimez les ADW en tableau (nom, usage, quand) et ATTENDEZ la demande.
Rien d'autre : ni trace, ni roster, ni inventaire du depot. Un etat imprime avant
la demande est un etat devine, paye avec le contexte de la vraie tache.
## Routage — une demande, une porte
| Demande | Porte |
|---|---|
| installer l'usine dans un depot | `uv run <skill>/scripts/stamp.py --into <cible> --dry-run`, puis sans |
| verifier le poste | `just doctor` |
| lancer un workflow | [cookbooks/lancer-un-adw.md](cookbooks/lancer-un-adw.md) |
| lire un run | `just obs runs`, `just obs run <id>` |
| le hors-site, un best-of-N | le skill `factory-orchestrator` |
Piège courant : « un bon skill anticipe : il lit la trace et le roster au démarrage pour être prêt » est inexact. L’agent qui « se prépare » interroge des tables dont il devine le nom, résume des runs que personne n’a demandés, et arrive à la vraie demande avec un contexte déjà entamé. La préparation utile est dans les fichiers, lus à la demande. Le skill mince n’est pas paresseux : il est exact.
Fil rouge — la pièce posée aujourd’hui
Sur le plan de l’usine, la zone « Distribution » s’ouvre : .claude/skills/factory/, avec SKILL.md,
scripts/stamp.py et cookbooks/lancer-un-adw.md, à côté du factory-orchestrator posé hier.
Une déviation à annoncer : le plan du chapitre 1 prévoyait .claude/commands/ pour /install
et /prime. Claude Code a depuis fusionné les commandes dans les skills, un dossier sous
.claude/skills/ est une commande, donc /factory install et /factory prime vivent dans
le skill, et le dossier commands/ n’existera pas. Le PLAN.md de votre dépôt sera remis à jour
au chapitre 27, avec l’arbre final. La loi ne bouge pas : l’agent propose, le code dispose, et
aujourd’hui le code possède le manifeste, la copie, le .gitignore et le relevé des ancrages.
l’agent ne possède que le choix de la recette, et le skill lui interdit même de choisir avant
d’avoir entendu la demande. Rien ne traverse le port : le stamp est un subprocess sans agent, et
le skill est un fichier que l’agent lit, pas une enveloppe. À l’usage : tamponner coûte
moins d’une seconde et zéro token, charger le skill ~1 000 tokens, un cookbook
~1 500 de plus, seulement quand la demande l’exige. À comparer à un agent seul à qui l’on
demanderait « installe cette usine chez moi » et qui copierait, à sa manière et une fois sur
deux, le .env avec.
Travaux pratiques — la pièce du jour
Une pièce complète à poser dans le repo compagnon plume-factory, qui devient, chapitre après
chapitre, votre usine logicielle agentique. Aujourd’hui : le skill de l’usine, son stamp et son
premier cookbook, soit trois fichiers sous .claude/skills/factory/.
Pièce — .claude/skills/factory/SKILL.md
Le skill mince : trois pas de démarrage, une table de routage, dix règles. Un seul fichier pour
les deux harnais : Claude Code l’expose comme /factory (le nom vient du dossier), pi le charge
par --skill. Les chemins ${CLAUDE_SKILL_DIR} sont substitués par Claude Code. Sous pi,
lisez-les comme « le dossier de ce skill », et le corps du skill les explicite.
---
name: factory
description: L'usine logicielle agentique de plume-factory — l'installer dans un depot (stamp), lancer et observer ses workflows (ADW), lire ses rosters. A utiliser pour « installe l'usine », « lance un SDLC », « ou en est le run », « quel roster ».
argument-hint: "[install <cible> | prime | run <demande> | observe <id>]"
allowed-tools: Bash(${CLAUDE_SKILL_DIR}/scripts/stamp.py *) Bash(just *) Bash(uv run adws/*)
---
# L'usine — skill de pilotage
Vous pilotez une usine logicielle agentique : du Python deterministe possede le graphe
(sequencement, reprises, acceptation), les agents sont des noeuds bornes dans des phases
nommees, le contexte traverse en enveloppes JSON typees, les gates definissent « fini ».
**L'agent propose, le code dispose.** Vous etes l'orchestrateur : vous lancez, observez,
rapportez — vous ne faites jamais le travail d'un ADW.
Argument recu : `$ARGUMENTS` (vide en pi : la demande est dans le message).
## Demarrage — trois pas, puis stop
1. `just` — la surface de commandes : c'est la carte.
2. `ls adws/adw_*.py`, puis la ligne `Usage` de chaque docstring : c'est le menu.
3. Imprimez les ADW en tableau (nom, usage, quand) et ATTENDEZ la demande.
Rien d'autre : ni trace, ni roster, ni inventaire du depot. Un etat imprime avant la demande
est un etat devine, paye avec le contexte de la vraie tache. Deux exceptions : si le premier
message contient deja une demande, routez-la ; si l'usine n'est pas installee (pas de `adws/`),
dites-le en une ligne et proposez `install`.
## Routage — une demande, une porte
| Demande | Porte |
|---|---|
| `install <cible>` — installer l'usine dans un depot | `uv run ${CLAUDE_SKILL_DIR}/scripts/stamp.py --into <cible> --dry-run`, montrez le rapport, puis relancez sans `--dry-run` sur accord |
| `prime` — s'orienter dans un depot deja tamponne | les trois pas du demarrage, puis `just obs runs` UNIQUEMENT si des runs existent |
| verifier le poste | `just doctor` (dans un depot sans Plume, les pieces absentes sont les ancrages payload : dites-le, ne corrigez rien) |
| `run <demande>` — lancer un workflow | [cookbooks/lancer-un-adw.md](cookbooks/lancer-un-adw.md) — lisez-le AVANT chaque lancement |
| `observe <id>` — lire un run | `just obs runs`, `just obs run <id>`, `just obs tail <id>` |
| quel roster, quels modeles, combien | `uv run adws/model_stack.py [--config <roster>]` — zero token, reseau requis |
| comparer des rosters sur cette machine | `uv run adws/adw_bench.py "<demande>" --adw adws/<adw>.py` |
| le hors-site : boites, best-of-N, moisson | le skill `factory-orchestrator` — chargez-le, il a sa propre surface |
## Regles
1. **Jamais le travail d'un ADW.** Ne planifiez pas, ne codez pas, ne testez pas a la place
d'un agent : lancez l'ADW et regardez-le.
2. **Une commande connue est du code.** Si vous pouvez ecrire l'invocation (`bun test`,
`just doctor`), c'est une recette, pas une phase agent.
3. **Les fichiers font foi.** Les ADW et les rosters se lisent sur disque a chaque fois ;
un nom memorise d'un document est une supposition.
4. **Jamais `adws/adw_data/`.** C'est le registre du run : on le lit par `just obs`, on ne
l'edite pas.
5. **Jamais une cle.** Ni lire, ni afficher, ni copier `.env` ; `.env.sample` est le seul
fichier de secrets que vous nommez.
6. **Le stamp se montre avant d'agir.** `--dry-run` d'abord, le rapport a l'ingenieur, puis
la pose sur accord ; `--force` seulement sur ordre explicite, apres un commit.
7. **Rapportez toujours l'identifiant** du run : c'est la seule poignee de la suite.
8. **Un run rouge se repare par une modification deliberee** — roster, prompt, ADW — puis un
nouveau run ; jamais par une retouche a la main de ce que le builder a produit.
9. **Le roster nomme est le roster passe.** « sur l'eco », « avec le frontier » se traduisent
en `--config adws/adw_config/<nom>.config.yaml` ; sinon le defaut, sans deviner.
10. **Proposez, ne disposez pas.** Le verdict d'un run vient des gates et de la trace ; vous le
presentez, l'ingenieur decide de la suite.
Pièce — .claude/skills/factory/cookbooks/lancer-un-adw.md
Le premier cookbook : chargé seulement quand la demande est « lance un workflow ». Il tient la procédure que le skill refuse d’embarquer : traduire la demande, choisir la chaîne d’après les fichiers, lancer avec le bon roster, observer par la trace. Ses commandes sont celles des chapitres 8 à 19, sans exception.
# Lancer un ADW
A lire AVANT chaque lancement. Le prompt que vous passez est lu par chaque agent de la
chaine : le planner planifie dessus, le builder construit dessus, le reviewer juge contre.
Un prompt approximatif n'est pas une petite taxe — chaque agent la paie.
## Etape 1 — traduire la demande
**L'intention est celle de l'ingenieur ; la precision est la votre.** Vous traduisez, vous
ne redessinez pas.
| Vous POUVEZ | Vous NE POUVEZ PAS |
|---|---|
| reporter chaque contrainte, mot pour mot | laisser tomber une exigence parce qu'elle semble dure ou etrange |
| corriger la grammaire, couper, ordonner | adoucir une demande forte (« reecris » → « retouche un peu ») |
| verifier qu'un chemin cite existe (`ls`) | explorer le code pour decider a la place du planner |
La forme, en quatre lignes — rien d'autre ne merite ses tokens :
<la demande — une phrase a l'imperatif, ses mots quand ils etaient precis>
Ou : <fichiers ou dossiers que vous avez verifies>
Fini veut dire : <le resultat observable — une reponse, un test vert, un element rendu>
Hors perimetre : <ce que vous avez ete tente d'ajouter, nomme pour que personne ne l'ajoute>
Si vous vous surprenez a ameliorer l'idee plutot que la phrase : arretez, dites-le a
l'ingenieur dans votre message, et lancez ce qu'il a demande.
## Etape 2 — choisir la chaine
**Si l'ingenieur a nomme un ADW, lancez celui-la.** Sinon, lisez ce que le depot a :
ls adws/adw_*.py
head -20 adws/adw_<nom>.py # chaque ADW s'ouvre sur sa ligne Usage
Les chaines sont a l'ingenieur — renommees, rewirees — : **les fichiers sur disque sont la
seule autorite.** Choisissez par la forme du travail :
| Le travail | Cherchez une chaine qui |
|---|---|
| change du code et sa forme n'est pas evidente | va de bout en bout : plan, build, tests, review, documentation |
| change du code, une modification bien comprise | planifie puis construit, gates comprises |
| implemente un plan deja produit (`specs/…`) | commence a build |
| est une question, rien ne doit changer | est un seul agent en lecture seule (scout) |
**Jamais une chaine a un seul agent quand on vous demande du travail.** `adw_prompt` et
`adw_scout` repondent a des questions ; ils ne livrent pas.
## Etape 3 — lancer, avec le bon roster
uv run adws/adw_sdlc.py "<prompt en quatre lignes>"
uv run adws/adw_sdlc.py "<prompt>" --config adws/adw_config/eco.config.yaml
uv run adws/adw_build.py specs/<plan>.md
La chaine dit CE QUI tourne ; le roster dit QUI. Si l'ingenieur nomme un roster, un palier ou
un modele (« sur l'eco », « avec le frontier », « fais planifier par le gros modele »),
resolvez-le en fichier : `ls adws/adw_config/`, puis `head -12` sur le candidat — l'en-tete
dit qui y siege. Aucun roster ne correspond ? Dites-le ; n'editez pas un roster en cours de
demande. Rien sur les modeles ? Le defaut, `adws/adw_config/factory.config.yaml`.
Capturez l'identifiant imprime au demarrage : tout le reste s'y accroche.
## Etape 4 — observer, rapporter
just obs runs # les dix derniers runs : verdict, cout, date
just obs run <id> # les couloirs de nage du run
just obs tail <id> # le grain fin : les derniers evenements
Rapportez par phase : nom, proprietaire (agent ou code), statut, motif si rouge. Un run rouge
n'est pas a corriger a la main : la reparation est un roster, un prompt ou un ADW modifie
deliberement, puis un nouveau run. Terminez par le cout et la duree lus dans la trace — jamais
estimes.
Pièce — .claude/skills/factory/scripts/stamp.py
Le stamp : du code, stdlib seulement, zéro token. Le manifeste en tête dit ce qui est l’usine.
Tout le reste, payload, runtime et secrets, reste à la maison. Idempotent par construction,
--force explicite, .gitignore complété sans doublon, ancrages payload relevés. Sa gate à sec
fabrique une fausse usine dans un dossier temporaire et vérifie les quatre lois.
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# ///
"""stamp — tamponner l'usine dans un depot (ch. 26).
L'usine n'est pas un service : c'est un jeu de fichiers, et il voyage par
copie. Ce script prend l'usine LA OU ELLE VIT (le depot qui contient ce
skill, ou --from) et la pose dans un depot cible — le vôtre, un autre, un
depot vierge. Tout est deterministe : un manifeste, des copies, un rapport.
Zero token.
Trois lois :
1. Idempotent : un fichier deja present est SAUTE, jamais ecrase — sauf
--force, qui ecrase TOUT le manifeste (commitez avant).
2. Le payload ne voyage pas : apps/, specs/, app_docs/, PLAN.md restent a
la maison. L'usine se tamponne, le produit se garde.
3. Les secrets ne voyagent pas : .env n'est pas dans le manifeste, et le
.gitignore de la cible recoit les lignes qui le tiennent a l'ecart.
uv run .claude/skills/factory/scripts/stamp.py --into ../autre-depot --dry-run
uv run .claude/skills/factory/scripts/stamp.py --into ../autre-depot
uv run .claude/skills/factory/scripts/stamp.py --into ../autre-depot --force
uv run .claude/skills/factory/scripts/stamp.py --selftest # la gate, a sec
"""
from __future__ import annotations
import argparse
import os
import re
import shutil
import sys
import tempfile
from pathlib import Path
# ── le manifeste : ce qui EST l'usine ───────────────────────────────────────
# Des dossiers copies recursivement, et des motifs de fichiers a la racine.
# Tout ce qui n'est pas ici ne voyage pas : c'est le payload ou du runtime.
MANIFEST_DIRS = [
"adws/adw_modules", # le port, le runner, les enveloppes… (ch. 7-20)
"adws/adw_config", # les quatre rosters (ch. 10, 15-17)
"just", # obs et sandbox : les modules just (ch. 19-25)
".claude/skills/factory", # ce skill — l'usine emporte son mode d'emploi
".claude/skills/factory-orchestrator", # l'orchestrateur hors-boite (ch. 25)
]
MANIFEST_GLOBS = [
"adws/*.py", # les ADW et les outils du poste (ch. 3-25)
"justfile", # la surface de commandes (ch. 5)
".env.sample", # le gabarit des secrets — vide (ch. 15, 23)
]
# Jamais copie, meme si un dossier du manifeste le contient.
EXCLUDED_NAMES = {"__pycache__", "adw_data", ".DS_Store"}
# Les lignes que le .gitignore de la cible doit porter (celles du ch. 1 qui
# concernent l'usine). Ajoutees seulement si absentes : le fichier reste le sien.
GITIGNORE_ENTRIES = [".env", "adws/adw_data/", "__pycache__/", "*.pyc"]
# Les ancrages payload : les lignes de l'usine qui nomment Plume. Rapportes,
# jamais modifies — le chapitre 27 les deplace dans le roster.
ANCHOR_PATTERN = re.compile(r"apps/plume|plume-baseline")
# Ce qui prouve qu'un dossier est une usine : la piece du chapitre 7.
PROOF = "adws/adw_modules/harness.py"
def die(message: str) -> None:
print(f"stamp : {message}", file=sys.stderr)
raise SystemExit(2)
def source_root(explicit: str | None) -> Path:
"""D'ou vient l'usine : --from, puis FACTORY_SOURCE, puis le depot qui
contient ce skill (scripts/ → factory/ → skills/ → .claude/ → racine)."""
candidate = explicit or os.environ.get("FACTORY_SOURCE")
root = Path(candidate).resolve() if candidate else Path(__file__).resolve().parents[4]
if not (root / PROOF).is_file():
die(f"{root} ne contient pas d'usine ({PROOF} absent) — utilisez --from ou FACTORY_SOURCE")
return root
def manifest(source: Path) -> list[Path]:
"""Les chemins RELATIFS a copier, dans un ordre stable."""
paths: list[Path] = []
for directory in MANIFEST_DIRS:
base = source / directory
if not base.is_dir():
continue
for path in sorted(base.rglob("*")):
if path.is_file() and not (EXCLUDED_NAMES & set(path.relative_to(source).parts)):
paths.append(path.relative_to(source))
for pattern in MANIFEST_GLOBS:
paths += [p.relative_to(source) for p in sorted(source.glob(pattern)) if p.is_file()]
return sorted(set(paths))
def stamp(source: Path, target: Path, force: bool, dry_run: bool) -> tuple[list[Path], list[Path]]:
"""Copie le manifeste. Rend (poses, sautes). En dry-run, rien n'est ecrit."""
stamped: list[Path] = []
skipped: list[Path] = []
for rel in manifest(source):
dest = target / rel
if dest.exists() and not force:
skipped.append(rel)
continue
if not dry_run:
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source / rel, dest)
stamped.append(rel)
return stamped, skipped
def ensure_gitignore(target: Path, dry_run: bool) -> list[str]:
"""Ajoute les lignes manquantes — jamais de doublon, jamais de suppression."""
gitignore = target / ".gitignore"
existing = gitignore.read_text(encoding="utf-8").splitlines() if gitignore.is_file() else []
missing = [entry for entry in GITIGNORE_ENTRIES if entry not in existing]
if missing and not dry_run:
with gitignore.open("a", encoding="utf-8") as handle:
handle.write("\n# usine — runtime et secrets, jamais commites\n" + "\n".join(missing) + "\n")
return missing
def payload_anchors(source: Path, paths: list[Path]) -> list[str]:
"""Les lignes de l'usine qui nomment le payload — a adapter dans la cible."""
anchors: list[str] = []
for rel in paths:
# Les skills parlent de Plume pour l'expliquer, pas pour la tester : hors champ.
if rel.parts[0] == ".claude" or rel.suffix not in {".py", ".yaml", ".md", ""}:
continue
try:
lines = (source / rel).read_text(encoding="utf-8").splitlines()
except UnicodeDecodeError:
continue
anchors += [f"{rel.as_posix()}:{number}" for number, line in enumerate(lines, 1)
if ANCHOR_PATTERN.search(line)]
return anchors
def report(target: Path, stamped: list[Path], skipped: list[Path], missing: list[str],
anchors: list[str], dry_run: bool) -> None:
verb = "poserait" if dry_run else "pose"
print(f"stamp {verb} l'usine dans {target}")
print(f" {verb:<9}: {len(stamped)} fichier(s)")
for rel in stamped:
print(f" + {rel.as_posix()}")
if skipped:
print(f" saute : {len(skipped)} fichier(s) deja presents (--force pour ecraser)")
if missing:
print(f" gitignore: +{len(missing)} ligne(s) — {', '.join(missing)}")
if anchors:
print(f" ancrages payload : {len(anchors)} ligne(s) nomment Plume — a adapter au payload"
" de la cible (ch. 27 les deplace dans le roster)")
for anchor in anchors[:12]:
print(f" ~ {anchor}")
if not dry_run:
print("\nensuite, dans la cible :")
print(" 1. copiez .env.sample en .env et renseignez OPENROUTER_API_KEY")
print(" 2. just # la surface de commandes, zero token (ch. 5)")
print(" 3. uv run adws/adw_prompt.py \"Dis bonjour en une ligne\" # la premiere phase, quelques centimes")
print(" 4. just obs runs # la trace du run (ch. 19)")
print(" (just doctor signalera les pieces Plume absentes : ce sont les ancrages ci-dessus)")
def run(args: argparse.Namespace) -> int:
source = source_root(args.source)
target = Path(args.into).resolve()
if target == source:
die("la cible est l'usine elle-meme — l'usine ne se tamponne pas sur place")
if not target.is_dir():
die(f"cible introuvable : {target} — creez le depot (git init) d'abord")
stamped, skipped = stamp(source, target, args.force, args.dry_run)
missing = ensure_gitignore(target, args.dry_run)
anchors = payload_anchors(source, stamped + skipped)
report(target, stamped, skipped, missing, anchors, args.dry_run)
return 0
# ── la gate a sec ───────────────────────────────────────────────────────────
def selftest() -> int:
"""Zero reseau, zero token : une fausse usine, un depot vierge, trois
tampons — pose, saut idempotent, --force — et un .gitignore sans doublon."""
ok = True
with tempfile.TemporaryDirectory() as tmp:
source = Path(tmp) / "usine"
target = Path(tmp) / "vierge"
for rel, text in {
PROOF: "# port\n",
"adws/adw_modules/__pycache__/harness.cpython-313.pyc": "x",
"adws/adw_config/factory.config.yaml": "defaults: {}\n",
"adws/adw_build.py": "TRUTH_COMMANDS = [(['bun', 'test'], 'apps/plume')]\n",
"adws/adw_data/factory.db": "runtime",
"just/obs.just": "runs:\n uv run adws/obs_lanes.py --runs\n",
"justfile": "test:\n cd apps/plume && bun test\n",
".env.sample": "OPENROUTER_API_KEY=\n",
".env": "OPENROUTER_API_KEY=secret\n",
"apps/plume/server.ts": "payload",
"PLAN.md": "plan",
".claude/skills/factory/SKILL.md": "skill",
}.items():
(source / rel).parent.mkdir(parents=True, exist_ok=True)
(source / rel).write_text(text, encoding="utf-8")
target.mkdir()
(target / ".gitignore").write_text("node_modules/\n.env\n", encoding="utf-8")
stamped, skipped = stamp(source, target, force=False, dry_run=False)
posed = {p.as_posix() for p in stamped}
ok &= posed == {PROOF, "adws/adw_config/factory.config.yaml", "adws/adw_build.py",
"just/obs.just", "justfile", ".env.sample", ".claude/skills/factory/SKILL.md"}
ok &= not skipped
ok &= not (target / ".env").exists() and not (target / "apps").exists()
ok &= not (target / "adws/adw_data").exists() and not (target / "PLAN.md").exists()
ok &= not list(target.rglob("__pycache__"))
missing = ensure_gitignore(target, dry_run=False)
ok &= missing == ["adws/adw_data/", "__pycache__/", "*.pyc"] # .env y etait deja
ok &= ensure_gitignore(target, dry_run=False) == [] # pas de doublon
(target / "justfile").write_text("# edite dans la cible\n", encoding="utf-8")
again, skipped = stamp(source, target, force=False, dry_run=False)
ok &= not again and len(skipped) == 7 # idempotent
ok &= (target / "justfile").read_text(encoding="utf-8").startswith("# edite")
forced, _ = stamp(source, target, force=True, dry_run=False)
ok &= len(forced) == 7 # --force ecrase tout
ok &= "apps/plume" in (target / "justfile").read_text(encoding="utf-8")
anchors = payload_anchors(source, forced)
ok &= anchors == ["adws/adw_build.py:1", "justfile:2"]
print(f"stamp {'OK' if ok else 'KO'} — manifeste pose, payload et secrets restes a la maison,"
" idempotent, --force, gitignore sans doublon, ancrages reperes")
return 0 if ok else 1
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="tamponner l'usine dans un depot")
parser.add_argument("--into", default=".", help="le depot cible (defaut : le dossier courant)")
parser.add_argument("--from", dest="source", default=None,
help="l'usine source (defaut : FACTORY_SOURCE, sinon le depot de ce skill)")
parser.add_argument("--force", action="store_true", help="ecraser les fichiers presents — TOUS")
parser.add_argument("--dry-run", action="store_true", help="montrer sans ecrire")
parser.add_argument("--selftest", action="store_true", help="la gate a sec")
args = parser.parse_args()
raise SystemExit(selftest() if args.selftest else run(args))
La gate du TP
Quatre commandes, une par ligne, depuis la racine de plume-factory. La première tourne à sec, les
trois suivantes tamponnent un dépôt d’essai à côté du vôtre :
uv run .claude/skills/factory/scripts/stamp.py --selftest
git init ../stamp-essai
uv run .claude/skills/factory/scripts/stamp.py --into ../stamp-essai --dry-run
uv run .claude/skills/factory/scripts/stamp.py --into ../stamp-essai
Attendu : stamp OK — manifeste pose, payload et secrets restes a la maison, idempotent, --force, gitignore sans doublon, ancrages reperes (zéro réseau, zéro token, moins d’une
seconde). Puis le rapport à sec : une quarantaine de fichiers « poserait », quatre lignes de
.gitignore, une poignée d’ancrages payload. Et enfin la pose réelle, avec la liste des quatre pas
suivants. Relancez la dernière commande : pose : 0 fichier(s), tout est sauté, et l’idempotence
est votre contrôle de dérive. Pour voir le skill vivre, ouvrez le dépôt d’essai et tapez
claude "/factory prime" ou pi --skill .claude/skills/factory "Lisez le skill factory et orientez-vous."
: le tableau des ADW, puis l’attente, pour ~1 000 tokens, quelques centimes. Aucun run n’est
lancé. Effacez ../stamp-essai quand vous avez fini.