Back to 0diff
0diff

De 5 agents à la production : livrer 0diff en 20 minutes

L'histoire complète : 5 agents parallèles ont construit 0diff en 45 minutes (2 356 lignes, 44 tests), puis une session de polissage de 20 minutes l'a rendu prêt pour la production.

Thales & Claude | March 30, 2026 17 min 0diff
EN/ FR/ ES
0diffmulti-agentbuild-in-publicrustclilaunch

Le 14 février 2026, vers 14 h 15 heure de l'Afrique de l'Ouest, Juste a ouvert un terminal à Abidjan et a dit : "Construis-moi un traqueur de modifications de code en temps réel avec détection d'agents IA. Rust. CLI. On livre aujourd'hui."

Quarante-cinq minutes plus tard, 0diff existait. Huit fichiers source, 2 356 lignes, 44 tests passants, 11 dépendances, un binaire release de 2,0 Mo et cinq commandes. Trois semaines après, la préparation au lancement a pris vingt minutes.

Nous sommes Juste (CEO, ZeroSuite) et Claude (AI CTO). Ceci est le dernier article de notre série sur la construction de 0diff. Les trois articles précédents couvraient le pourquoi (la crise de l'attribution des agents), le surveillant de fichiers et le moteur de diff, et le système de détection d'agents IA. Celui-ci couvre comment l'ensemble a été construit et livré.


Session 314 : La construction

La construction s'est faite en une seule session. Non pas parce que nous étions pressés, mais parce que les agents parallèles rendent le développement séquentiel obsolète pour un outil de cette envergure.

La structure de l'équipe

Cinq agents ont travaillé simultanément, coordonnés par un chef d'équipe :

AgentModuleLignesResponsabilité
agent-configconfig.rs456Parsing TOML, valeurs par défaut, should_watch avec pattern matching glob
agent-differdiffer.rs + filter.rs176Calcul de diff avec la crate similar, hunks groupés avec contexte de 3 lignes, filtrage des espaces blancs
agent-gitgit.rs + agents.rs311Intégration git via shell, parsing de Co-Authored-By, détection d'agents à 3 niveaux
agent-historyhistory.rs + output.rs760Historique en ajout seul en JSON-lines, rotation par ancienneté et taille, sortie terminal colorée + format JSON
agent-siteindex.html1 625Page marketing avec CSS inline
Chef d'équipeCargo.toml + main.rs + lib.rs + watcher.rs--Squelette du projet, gestion des dépendances, définition CLI, boucle d'événements

Le chef d'équipe -- Claude, opérant comme coordinateur -- a défini les frontières de modules et les interfaces publiques en premier, puis a dispatché chaque agent avec un périmètre clair. Chaque agent a travaillé de manière isolée sur son module, produisant à la fois l'implémentation et les tests. Le chef d'équipe a géré l'intégration : le Cargo.toml, le lib.rs qui exporte tous les modules, le main.rs avec la définition CLI, et le watcher.rs qui relie le tout.

Ce n'est pas un workflow théorique. C'est comme cela que nous construisons chez ZeroSuite. Juste définit le produit. Claude (en tant que CTO) le décompose en modules. Les agents parallèles implémentent les modules. Le chef d'équipe intègre. Le résultat est un outil complet en moins d'une heure.

Le système de configuration

Le module de configuration mérite une attention particulière car il démontre un principe que nous suivons dans tous les outils ZeroSuite : zéro configuration par défaut avec personnalisation complète.

rust#[derive(Deserialize, Serialize, Clone, Debug)]
#[serde(default)]
pub struct Config {
    pub watch: WatchConfig,    // paths, ignore, extensions, debounce
    pub filter: FilterConfig,  // ignore_whitespace, min_lines_changed
    pub git: GitConfig,        // enabled, track_author, track_branch
    pub history: HistoryConfig, // max_size_mb, max_days
    pub agents: AgentConfig,   // detect_patterns, tag_non_human
}

Cinq sections. Chaque champ a une valeur par défaut. L'attribut #[serde(default)] sur chaque structure signifie que le parsing TOML réussit même avec un fichier de configuration complètement vide :

rust#[test]
fn test_toml_empty_config() {
    let config: Config = toml::from_str("").expect("empty config should parse");
    assert_eq!(config.watch.debounce_ms, 500);
    assert!(config.filter.ignore_whitespace);
}

Cela signifie que 0diff init && 0diff watch fonctionne immédiatement avec des valeurs par défaut sensées : surveiller les répertoires src/, app/ et entities/ ; suivre les fichiers .rs, .ts, .js, .py, .go, .java et .flin ; ignorer target/, node_modules/ et .git/ ; debounce à 500 ms ; détecter les cinq principaux agents IA.

Mais si vous avez besoin de personnaliser -- chemins de surveillance différents, patterns d'ignorance différents, chaînes de détection d'agents personnalisées -- vous éditez une section du TOML et le reste garde ses valeurs par défaut. Les configurations partielles se parsent proprement :

rust#[test]
fn test_toml_partial_config() {
    let partial = r#"
[watch]
debounce_ms = 1000
"#;
    let config: Config = toml::from_str(partial).expect("partial config should parse");
    assert_eq!(config.watch.debounce_ms, 1000);
    assert_eq!(config.watch.paths, WatchConfig::default_paths());
    assert!(config.git.enabled);
    assert_eq!(config.history.max_days, 30);
}

Ce n'est pas de l'ingénierie astucieuse. Ce sont les crates serde et toml de Rust qui font exactement ce pour quoi elles ont été conçues. La partie astucieuse est de reconnaître qu'un outil CLI qui nécessite un fichier de configuration de 50 lignes avant de faire quoi que ce soit d'utile est un outil CLI que personne n'utilisera.

Le format d'historique

agent-history a choisi JSON-lines (.jsonl) comme format de stockage. Un objet JSON par ligne, ajouté à .0diff/history.jsonl :

rustpub fn append(&self, entry: &HistoryEntry) -> Result<(), Box<dyn std::error::Error>> {
    let path = self.history_path();
    let mut file = OpenOptions::new()
        .create(true)
        .append(true)
        .open(path)?;
    let json = serde_json::to_string(entry)?;
    writeln!(file, "{}", json)?;
    Ok(())
}

Pourquoi JSON-lines au lieu de SQLite, ou un format binaire personnalisé, ou même des notes git ?

L'ajout seul est résistant aux crashes. Si 0diff plante en cours d'écriture, le pire cas est une dernière ligne tronquée. La méthode all_entries() gère cela avec élégance en sautant les lignes invalides :

rustpub fn all_entries(&self) -> Result<Vec<HistoryEntry>, Box<dyn std::error::Error>> {
    let path = self.history_path();
    if !path.exists() {
        return Ok(Vec::new());
    }

    let file = fs::File::open(path)?;
    let reader = BufReader::new(file);
    let mut entries = Vec::new();

    for line in reader.lines() {
        let line = line?;
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }
        if let Ok(entry) = serde_json::from_str::<HistoryEntry>(trimmed) {
            entries.push(entry);
        }
        // Skip invalid lines gracefully
    }

    Ok(entries)
}

Ce saut silencieux des lignes invalides n'est pas seulement de la récupération après crash -- c'est de la compatibilité ascendante. Si une future version de 0diff ajoute des champs à HistoryEntry, les anciennes entrées (manquant ces champs) se parsent quand même car serde utilise Option pour les champs nullables. Si une future version change complètement le format, les anciennes entrées sont sautées au lieu de provoquer un panic.

JSON-lines est compatible avec grep. Vous pouvez faire grep "Claude" .0diff/history.jsonl et obtenir des résultats utiles sans aucun outillage. Vous pouvez utiliser jq. Vous pouvez le piper dans d'autres outils. Cela compte plus qu'on ne le pense. Quand vous déboguez à 3 heures du matin, la capacité de faire cat sur votre fichier d'historique et de le lire à l'oeil nu vaut plus qu'une amélioration de performance de requête de 10x.

La rotation maintient les choses bornées. Le stockage d'historique effectue une rotation à l'arrêt propre (Ctrl+C), supprimant les entrées plus anciennes que max_days et réduisant par max_size_mb :

rustpub fn rotate(
    &self,
    max_size_mb: u64,
    max_days: u64,
) -> Result<(), Box<dyn std::error::Error>> {
    let entries = self.all_entries()?;
    let cutoff = Utc::now() - chrono::Duration::days(max_days as i64);

    let mut kept: Vec<HistoryEntry> = entries
        .into_iter()
        .filter(|e| {
            DateTime::parse_from_rfc3339(&e.timestamp)
                .map(|dt| dt.with_timezone(&Utc) >= cutoff)
                .unwrap_or(true) // keep entries with unparseable timestamps
        })
        .collect();

    let max_bytes = max_size_mb * 1024 * 1024;
    loop {
        let size: usize = kept
            .iter()
            .map(|e| serde_json::to_string(e).unwrap_or_default().len() + 1)
            .sum();
        if (size as u64) <= max_bytes || kept.is_empty() {
            break;
        }
        kept.remove(0); // Remove oldest entry
    }

    // Rewrite the file
    let path = self.history_path();
    let mut file = OpenOptions::new()
        .create(true)
        .write(true)
        .truncate(true)
        .open(path)?;

    for entry in &kept {
        let json = serde_json::to_string(entry)?;
        writeln!(file, "{}", json)?;
    }

    Ok(())
}

Limites par défaut : 10 Mo et 30 jours. Pour un outil qui écrit peut-être 200 octets par événement de modification de fichier, 10 Mo représente environ 50 000 entrées. C'est des mois de développement intensif sans que la rotation ne se déclenche.

Le module de sortie

Chaque sortie utilisateur dans 0diff passe par output.rs, qui supporte deux formats : terminal coloré et JSON. Cette approche double format signifie que 0diff fonctionne aussi bien pour les développeurs humains lisant la sortie terminal que pour les scripts, pipelines CI et tableaux de bord consommant des données structurées.

rustpub fn print_change_event(entry: &HistoryEntry, diff: &FileDiff, format: OutputFormat) {
    match format {
        OutputFormat::Terminal => {
            let time_str = extract_time(&entry.timestamp);

            let agent_tag = if entry.agent.is_some() {
                format!(" {}", "[AI AGENT]".yellow().bold())
            } else {
                String::new()
            };

            println!(
                "{} {}  {} {}  {}{}",
                format!("[{}]", time_str).dimmed(),
                entry.file.bold().white(),
                format!("+{}", entry.additions).green(),
                format!("-{}", entry.deletions).red(),
                author_info,
                agent_tag,
            );
            // ... diff hunks follow
        }
        OutputFormat::Json => {
            let event = ChangeEvent { entry, diff };
            if let Ok(json) = serde_json::to_string_pretty(&event) {
                println!("{}", json);
            }
        }
    }
}

Le tag [AI AGENT] en jaune gras est le signal le plus visible dans la sortie terminal. Quand vous surveillez un flux de modifications de fichiers et que l'une d'entre elles a été faite par un agent IA, cela attire immédiatement l'oeil. Pas besoin de lire les détails -- le tag jaune capte votre attention.

Le flag --json est global pour toutes les commandes. 0diff log --json, 0diff status --json, 0diff diff file.rs --json -- toutes produisent du JSON structuré. Ce n'était pas un ajout tardif. C'était une exigence dès le départ, parce que 0diff doit s'intégrer dans des pipelines et des tableaux de bord, pas seulement dans des terminaux.


Les sept bugs

Aucune session n'est sans bugs. Voici les sept problèmes que nous avons rencontrés et corrigés pendant la construction :

  1. DebouncedEventKind::AnySynthetic n'existe pas. L'API de la crate notify-debouncer-mini a changé entre les versions. La bonne variante est DebouncedEventKind::Any. L'agent-watcher a initialement utilisé le mauvais nom de variante. Corrigé en consultant la documentation de la crate.
  1. notify::Error n'est pas itérable. Une première version du watcher essayait d'itérer sur notify::Error comme si c'était une collection d'erreurs. C'est un type d'erreur unique. Corrigé en matchant directement sur Ok(Err(error)).
  1. Le pattern matching glob d'ignorance n'était pas récursif. La première implémentation de should_watch vérifiait les patterns d'ignorance uniquement sur le chemin complet. Un pattern comme target/ matchait target/debug/build.rs mais pas src/target/debug.rs. Corrigé en matchant également sur les composants individuels du chemin :

``rust let clean = pattern_str.trim_end_matches('/'); if let Ok(pattern) = glob::Pattern::new(clean) { for component in path.components() { let comp = component.as_os_str().to_string_lossy(); if pattern.matches(&comp) { return false; } } } ``

  1. Écrasement de fichier entre agents. Deux agents ont initialement écrit dans le même fichier. Le chef d'équipe l'a détecté pendant l'intégration et a réassigné la sortie d'un agent vers un fichier différent. C'est un problème de coordination propre au développement par agents parallèles -- et c'est le travail principal du chef d'équipe de le prévenir.
  1. Liens de l'organisation GitHub. Le site marketing pointait initialement vers la mauvaise organisation GitHub. Corrigé pendant la revue.
  1. URL du dépôt dans Cargo.toml. Le champ repository pointait initialement vers une URL placeholder. Mis à jour vers https://github.com/zerosuite-inc/0diff.
  1. Conflit TMPDIR dans install.sh. Le script d'installation utilisait TMPDIR comme nom de variable, ce qui entre en conflit avec la variable système macOS du même nom. Renommé en TMPD. C'est le genre de bug que vous ne découvrez que sur macOS, et nous développons sur macOS.

Sept bugs en 45 minutes de développement parallèle. Tous détectés avant la fin de la session. Tous corrigés en quelques minutes. Le surcoût de correction des bugs était inférieur au temps gagné par le parallélisme.


Session 315 : Préparation au lancement (20 minutes)

Trois semaines plus tard, le 9 mars 2026, nous avons fait la préparation au lancement. Vingt minutes.

Remplacement des 25 emojis du site marketing par des SVG Lucide inline. ZeroSuite a une politique stricte de zéro emoji dans tout contenu destiné aux utilisateurs. La page marketing originale, écrite rapidement pendant la session de construction, utilisait des emojis comme repères visuels. Chacun a été remplacé par une icône SVG correctement dimensionnée et thématisée.

Ajout de target="_blank" sur les 10 liens externes. Une page marketing ne devrait pas naviguer ailleurs quand les utilisateurs cliquent sur un lien externe. Chaque lien vers GitHub, la documentation et le script d'installation s'ouvre maintenant dans un nouvel onglet.

Correction du conflit TMPDIR dans install.sh. C'était le bug numéro 7 de la session de construction, mais il nécessitait un test sur un environnement macOS propre pour vérifier le correctif. Fonctionnement confirmé.

Amélioration du Dockerfile. Ajout d'un nginx.conf personnalisé pour les types MIME corrects. La configuration nginx par défaut ne sert pas les fichiers .wasm avec le bon Content-Type, et bien que 0diff lui-même ne soit pas une application web, le site marketing avait besoin d'un service correct des ressources statiques.

Vérification finale : cargo test -- 44 tests, tous passants. Zéro warning. Build propre.

C'est toute la préparation au lancement. Pas de changements de fonctionnalités. Pas de révisions d'architecture. Pas de "ah attendez, on a oublié de gérer ce cas limite." La session de construction a produit un outil complet, testé et fonctionnel. La préparation au lancement était du polissage.


Statistiques finales

MétriqueValeur
Fichiers source8 fichiers .rs
Total de lignes~2 356
Tests44 (tous passants)
Dépendances11
Binaire release2,0 Mo
Commandes5 (init, watch, diff, log, status)
Session de construction~45 minutes (Session 314)
Préparation au lancement~20 minutes (Session 315)
Temps humain total~65 minutes sur les deux sessions

Onze dépendances, pas plus :

toml[dependencies]
notify = "8"
notify-debouncer-mini = "0.6"
similar = "2"
clap = { version = "4", features = ["derive"] }
toml = "0.8"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
colored = "2"
chrono = { version = "0.4", features = ["serde"] }
glob = "0.3"
ctrlc = "3"

Chaque dépendance fait une seule chose. notify pour les événements du système de fichiers. similar pour le calcul de diff. clap pour le parsing CLI. toml et serde pour la configuration. colored pour la sortie terminal. chrono pour les horodatages. glob pour le pattern matching. ctrlc pour l'arrêt propre. Pas de frameworks. Pas de runtime. Pas d'async.


Ce qui distingue 0diff

Chaque conversation sur 0diff finit par produire la question : "Pourquoi ne pas simplement utiliser git diff ?" ou "Pourquoi pas watchexec ?" Voici la comparaison honnête :

Fonctionnalitégit diffwatchexecfswatch0diff
Surveillance en temps réelNonOuiOuiOui
Calcul de diffCommité uniquementNonNonOui (en direct)
Détection d'agents IANonNonNonOui
Suivi d'historiqueVia les commitsNonNonOui (JSON-lines)
Métadonnées gitN/ANonNonOui (auteur, branche)
Filtre d'espaces blancsPartielN/AN/AOui
Sortie JSONFormat patchNonNonOui
Binaire uniqueN/AOuiOuiOui
Fichier de configuration.gitconfigArguments CLIArguments CLI.0diff.toml

git diff ne vous montre que ce qui a changé par rapport au dernier commit. Il ne surveille pas. Il ne détecte pas les agents. Il ne maintient pas d'historique au-delà du propre log de commits de git.

watchexec et fswatch surveillent les changements de fichiers, mais ils vous disent seulement qu'un fichier a changé -- pas ce qui a changé, pas qui l'a changé, et pas si un agent IA était impliqué.

0diff se situe dans une position unique : il combine surveillance de fichiers, calcul de diff, enrichissement par métadonnées git et détection d'agents IA dans un seul outil. Aucune des alternatives ne fait cela. Elles n'ont pas été conçues pour, parce que le problème que 0diff résout -- l'attribution de code multi-agents -- n'existait pas quand elles ont été construites.


Comment 0diff s'inscrit dans ZeroSuite

Il y a une ironie récursive dans 0diff : il a été construit par les agents IA mêmes qu'il est conçu pour traquer.

La session 314 -- la session qui a construit 0diff -- comptait cinq agents IA modifiant du code en parallèle. Si 0diff avait existé à ce moment-là, il aurait détecté chacun de ces agents via leurs trailers Co-Authored-By et variables d'environnement. Il aurait enregistré chaque modification de fichier avec des horodatages, des tailles de diff et des tags d'agent. Il aurait produit une piste d'audit complète de sa propre création.

Ce n'est pas un accident. ZeroSuite construit des outils qui résolvent des problèmes que nous vivons au quotidien. Nous utilisons Claude Code à travers de multiples agents parallèles pour quasiment chaque produit que nous construisons. Le déficit d'attribution est quelque chose que nous rencontrons chaque jour. 0diff n'est pas un produit spéculatif pour un futur hypothétique -- c'est un outil dont nous avions besoin, construit par le workflow même qu'il est conçu pour surveiller.

0diff rejoint la gamme de produits ZeroSuite aux côtés de sh0.dev (le shell), 0cron.dev (le gestionnaire cron), 0seat.dev (la billetterie événementielle), Flin (le langage de programmation) et Deblo.ai (la plateforme éducative). Chaque produit a été construit avec la même méthodologie : Juste définit, Claude décompose, les agents implémentent, le chef d'équipe intègre. Et de plus en plus, 0diff surveille l'ensemble du processus.


Ce que nous avons appris

Les agents parallèles fonctionnent pour les problèmes décomposables. 0diff a des frontières de modules propres : config, diff, git, history, output, watcher. Chaque module a une interface bien définie. Cela le rend idéal pour le développement parallèle. Une machine à états monolithique avec des interdépendances complexes entre modules ne se décomposerait pas aussi proprement.

Le chef d'équipe est le goulot d'étranglement, et c'est correct. Les cinq agents peuvent produire du code plus vite qu'un seul coordinateur ne peut l'intégrer. Mais le travail du coordinateur -- définir les interfaces, résoudre les conflits, maintenir la cohérence architecturale -- est la partie difficile. Rendre le goulot d'étranglement de l'intégration explicite est mieux que de prétendre qu'il n'existe pas.

La couverture de tests n'est pas optionnelle en développement parallèle. Quand cinq agents produisent du code indépendamment, la seule façon de vérifier la correction au moment de l'intégration est de lancer les tests. Les 44 tests passants après intégration ne sont pas juste un signal de qualité -- c'est la preuve que les frontières de modules ont été tracées correctement.

Livrer, puis polir. La session 314 a produit un outil fonctionnel avec des aspérités (emojis dans la page marketing, conflit TMPDIR sur macOS). La session 315 a poli ces aspérités en 20 minutes. La tentation de polir pendant la session de construction est forte, mais cela casse le workflow parallèle. Les agents doivent produire du code correct et testé. Le polissage est une activité séquentielle.

Rust est le bon langage pour les outils CLI. Un binaire de 2,0 Mo sans dépendances runtime, démarrage instantané, compilation multiplateforme, et un système de types qui attrape les erreurs d'intégration à la compilation. La courbe d'apprentissage initiale est raide, mais pour un outil qui doit être rapide, petit et fiable, rien d'autre ne s'en approche.


La série

Ceci est le dernier article de la série "Comment nous avons construit 0diff". Si vous avez lu les quatre, vous savez maintenant tout sur 0diff : pourquoi il existe, comment le surveillant de fichiers et le moteur de diff fonctionnent, comment la détection d'agents opère, et comment le tout a été construit et livré.

0diff est open source sur github.com/zerosuite-inc/0diff. Installez-le et lancez 0diff init && 0diff watch. Puis ouvrez un autre terminal et laissez Claude Code faire quelques modifications. Regardez les tags [AI AGENT] apparaître.

Bienvenue dans l'ère multi-agents. Sachez qui a changé quoi.


Ceci est la partie 4 de la série "Comment nous avons construit 0diff" :

  1. Pourquoi nous avons construit un traqueur de modifications de code pour l'ère des agents IA
  2. Surveillance de fichiers en temps réel et calcul de diff en Rust
  3. Détecter les agents IA dans votre codebase
  4. De 5 agents à la production : livrer 0diff en 20 minutes (vous êtes ici)
Share this article:

Responses

Write a response
0/2000
Loading responses...

Related Articles