Aller au contenu

HT001 · Écrire un IDE de terminal à partir de zéro

Le 2026-09-07, un agent a écrit sous flower, à partir de zéro, un IDE C/C++ mono-fichier tournant dans un terminal macOS. Il a tourné 10.4 heures d'affilée, dépensé $171.62, et rendu 12,212 lignes de code produit et 13,291 lignes de tests ; le réseau est tombé une fois en cours de route, il a repris seul jusqu'au bout. Cette page n'est pas une vitrine de résultats — c'est la mise à l'épreuve, à échelle réelle, de chacune des affirmations du framework, y compris les deux qui ne tiennent pas une fois mesurées : le coordinateur qui « ignore souvent les restrictions de permissions » est faux, et dans les critères d'acceptation qu'il s'est donnés à lui-même, il en a jugé un de travers.

L'enregistrement complet (avec un transcript de 19MB) est sur ChenyuHeee/cppide. Chaque chiffre ci-dessous est calculé par tools/analyze_run.py à partir de sessions.db, et reproductible :

python tools/analyze_run.py /path/to/cppide/runs

D'où viennent les chiffres de cette page

Le runs/ de HT001 est dans le dépôt cppide ci-dessus, pas dans le dépôt flower. Les chiffres de cette page sont donc repris tels quels de l'enregistrement du run de l'époque ; ils n'ont pas été recalculés contre sessions.db au moment d'écrire ces lignes — pour recalculer, il faut d'abord cloner cppide, puis lancer la commande ci-dessus. HT002 fait l'inverse : l'enregistrement brut est dans ce dépôt, les chiffres de cette page-là ont été recalculés un par un, et les deux écarts constatés sont écrits directement dans la page.

La forme de ce run

Élément Valeur
Tâche « faire un IDE C/C++ mono-fichier dans un terminal macOS, pour l'entraînement ICPC »
Coût $171.62 — clarification des besoins $0.3704 / 5 tours, travail $171.2476 / 31 tours
Durée 0.06h + 10.44h
Contexte du thread principal 28.7K → 185.9K, croissance monotone sur 70 tours de modèle, aucun compact de bout en bout
Répartition 32 appels d'outils côté coordinateur vs 1,893 côté subagent ; 94.8% des caractères de texte tombent dans les subagents
Cache 299.4M tokens en entrée au total, 96.1% de hits
Produit src/ 12,212 lignes, tests/ 13,291 lignes (environ 3,000 assertions), README 44K
Version flower avant l'ajout du gardien d'objectif, donc ce run n'a pas de verdict par tour

Cette page contient deux « tours » différents

Les 5 tours / 31 tours du tableau sont le num_turns enregistré dans runs/manifest.json, un compteur par étape ; le « tour 70 » de la courbe de contexte ci-dessous compte les tours de modèle sur le thread principal, c'est-à-dire chaque message assistant. Les deux ne comptent pas la même chose, ne les convertissez pas l'un dans l'autre.


1. Économie du contexte : 94.8% du terrain n'entre pas dans le thread principal

C'est l'affirmation centrale du framework — le coordinateur ne contient que des décisions, le terrain redescend dans les subagents et sur le disque. Pour le mécanisme lui-même, voir Économie du contexte.

Thread principal subagent Part subagent
Tours de modèle 70 3.0K 97.7%
Caractères de texte 200.1K 3.6M 94.8%
Appels d'outils 32 1,893 ——

Ce run a dépêché 23 subagents ; tokens de sortie : maximum 184,377, médiane 72,197, minimum 11,684.

Sur 1,893 appels d'outils qui mettent réellement les mains dans le cambouis (1,034 Bash / 471 Read / 258 Edit / 126 Write), seuls 32 sont entrés dans le champ de vision du coordinateur — 59:1. En moyenne, à chaque délégation, 82 appels d'outils lui restent invisibles.

Les tests précoces à petite échelle mesuraient 83%, l'échelle réelle donne 94.8%. Plus l'échelle grandit, plus la répartition rapporte — parce que le coût du brief de tâche et du compte rendu est fixe par délégation, alors que le terrain tenu à l'extérieur croît avec la complexité de la tâche.

2. Courbe de contexte : on sait pour la première fois où est le plafond de la « longue haleine »

Pour chaque message assistant, input + cache_read + cache_creation est exactement le contexte vu par le modèle à ce tour :

Tour  1   28.7K   ← plancher de démarrage (~34k mesurés auparavant, ça colle)
Tour 20   35.2K
Tour 35  108.6K
Tour 50  158.2K
Tour 70  185.9K   ← fin
  • croissance monotone sur 70 tours, pente d'environ 2.2K/tour
  • aucun compact ne s'est produit de bout en bout (DISABLE_AUTO_COMPACT=1 ; le seul repli est l'enregistrement vide du tour de la coupure réseau)
  • 18.6% de la fenêtre de 1M consommés

En extrapolant cette pente, le mur arrive vers 440 tours. Ces 70 tours ont produit 12K lignes de code, donc sous la forme actuelle, le plafond réel de la longue haleine vaut environ 6 fois ce run. C'est un chiffre sur lequel on peut décider ; avant, on ne pouvait que deviner.

Pour le dépasser, il faut rendre la croissance du thread principal sous-linéaire — davantage sur le disque, moins dans la conversation.

3. 96.1% de hits de cache — c'est tout ce qui rend $171.62 possible

Entrée totale 299.4M tokens
  hits de cache  287.5M  (96.1%)
  cache créé      11.8M
  non caché        6.2K
Sortie 2.3M (dont thinking 653.9K)

299M tokens d'entrée n'ont coûté que $171, parce que 96% passent au tarif cache.

Corollaire : l'économie d'un run de longue haleine repose sur le taux de hits de cache, et ce taux dépend de la stabilité du préfixe de contexte. Toute optimisation qui « réarrange le contexte » — déplacer d'anciens contenus, insérer un nouveau prompt système en tête — perce le cache, et les tokens économisés ne compensent probablement pas la remise de cache perdue. Cela n'avait jamais été mesuré, maintenant on a le chiffre.

C'est aussi une raison annexe pour laquelle flower utilise la passation plutôt que le compact : la passation ouvre une nouvelle session et reconstruit le cache depuis le début, le préfixe reste stable à l'intérieur d'une génération.

4. Réutilisation de l'établi : 61 scripts, aucun jeté après écriture

L'affirmation de l'établi est : « un script s'écrit une fois, ensuite on le relance, on ne le réécrit pas ». La vérification ne consiste pas à interroger l'agent, mais à compter dans le transcript combien de fois les scripts de .flower/scripts/ sont écrits/édités (Write/Edit) et exécutés (Bash) :

61 scripts : 95 écritures, 331 exécutions
nombre d'exécutions > 1 (réellement réutilisés) : 56, soit 92%
écrits mais jamais exécutés : 0
ratio exécutions/écritures 3.48

wave5-verify.sh a été écrit 5 fois et lancé 45 fois — affinage itératif puis usage répété, exactement la forme recherchée. Aucun script n'a été jeté après écriture.

5. Le coordinateur s'est fait refuser 1 fois, et c'est la faute de la liste blanche

Le soupçon de départ était : « le coordinateur ignore souvent les restrictions de permissions et déclenche une masse d'appels refusés ». La mesure démolit cette prémisse.

Sur 32 appels d'outils, 1 refus (3.1%), et c'était celui-ci :

which g++ clang++ make pkg-config 2>&1; echo ---; ls /usr/include/ncurses.h 2>&1; echo ---; ls /work/runs

Verdict segment par segment : echo passe, ls passe, which est bloqué — il n'est pas dans la table des commandes éphémères.

Toute la commande est purement en lecture, c'est un sondage d'environnement standard. Le modèle a fait exactement ce qu'il fallait, c'est la liste blanche qui était incomplète, ce qui l'a forcé à payer un démarrage de subagent (environ 4.3k tokens) pour un simple which g++.

Corrigé : ajout de which / command -v / type / whereis / uname / arch / locale / nproc / getconf / sw_vers. command et type ne passent que sous leur forme d'interrogation — un command rm -rf / tout nu est une exécution, oublier le -v revient à ouvrir une porte dérobée ; 7 échantillons adverses dans tests/glance.py montent la garde.

Ce qu'il faut retenir ici, ce n'est pas le patch, c'est la méthode. Une discussion d'architecture qui a duré longtemps avait pour prémisse un phénomène qui vaut 1/32 sur des données réelles, et l'attribution était inversée — le problème n'était pas le modèle, mais la liste blanche. On mesure d'abord.

6. Reprise après coupure réseau : validée pour la première fois par une panne réelle

01:52:40  tool_result: "Agent terminated early due to an API error"   ← un subagent interrompu
01:55:41  assistant:   "API Error: ENOTFOUND"                          ← reçu par le thread principal
          ↓  la sonde attend le retour du réseau
          resumed=True, reprise sur la même session, plus de 8 heures encore jusqu'à l'achèvement

Le manifeste enregistre attempts=2 / resumed=True / ok=True. 10 heures de travail n'ont pas été refaites depuis zéro. Avant cela, la résilience restait classée « toujours pas vérifiée » — couper le réseau demande de modifier hosts en sudo, personne n'a envie de faire ça pendant un vrai run.

Ce message « API Error » synthétique a été retiré au chargement par PruningSessionStore, le modèle ne l'a pas vu — la conception de l'élagage est elle aussi validée par une panne réelle.

Deux problèmes exposés (voir issue #2) :

  1. La reprise a lieu au niveau du framework et non au niveau du transport. Le prix, c'est le rejeu de tout le contexte, et ce contexte-là était gros.
  2. Le subagent interrompu a perdu son travail à demi terminé. Le resume sauve le thread principal ; la reprise après interruption au niveau subagent est un autre problème.

7. Son auto-vérification est-elle réelle ?

C'est l'endroit où il faut le plus douter : un agent qui écrit ses propres tests, les lance et se déclare conforme, cela dégénère facilement en formalité.

Après coup, un subagent d'audit indépendant a été dépêché ; il a lu statiquement les 58 scripts et les 22 livrables. Conclusion : c'est réel, et d'une rigueur rare :

  • Zéro assertion toujours vraie. Aucun motif du type [ 1 = 1 ] dans tout le dépôt.
  • 8 occurrences de || true, dont 7 légitimes (grep -c qui renvoie 1 sans correspondance, garde-fou ulimit, etc.).
  • Ce qui est jugé, c'est le comportement, pas l'existence d'un fichier. audit-idle-cost.sh:53 laisse l'IDE tourner à vide 60 secondes dans un pty, compte les requêtes réellement reçues par un faux serveur local, et ne passe (PASS) qu'à -le 2. fix-p1-idle-verify.sh:100-110 va plus loin : il affirme à la fois exactement 1 requête au repos et que le ghost s'affiche toujours — pour éviter qu'« un changement de limitation de débit tue la fonctionnalité au passage ».
  • De vrais sous-processus. wave5-pty-drive.cpp:316 lance réellement la TUI avec forkpty ; audit-fake-ai-server.py est un vrai serveur socket, avec cinq injections de pannes /err500 /err401 /badjson /drop /slow ; tous les chemins IA vident de force CPPIDE_API_KEY.

Deux choses ont dépassé les attentes.

Il a fait un contrôle positif et négatif sur son propre vérificateur. 验收审计.md:29-34 : il envoie d'abord ZZZ_NEVER_APPEARS_ZZZ pour confirmer que le pilote produit bien un FAIL, puis la vraie chaîne pour confirmer un PASS — pour éviter « d'auditer avec une machine truquée qui passe toujours ». C'est vérifier le vérificateur lui-même.

Il a divulgué de lui-même ses assertions faibles. Le for m in $must de wave5-b4-ai-states.sh:114 découpe "AI 错误 请求超时" sur les espaces en trois sous-chaînes grepées séparément, or AI et 错误 apparaissent forcément sur n'importe quel écran — nominalement « 3 critères passés », en réalité un seul est discriminant. Il a écrit cela lui-même dans 验收审计.md:576-582, ce n'est pas l'auditeur qui l'a déterré.

Dans le même genre : wave5-verify.sh:296-302 explique en 7 lignes de commentaire pourquoi B3b a été rétrogradé en SKIP (assertion de séquencement sur le dernier écran, sans rapport avec le code produit), en précisant que « la logique de verdict et la marge de tolérance n'ont pas bougé d'un caractère » ; :236-238 consigne qu'il a découvert et corrigé une fausse assertion qu'il avait lui-même écrite plus tôt — un if pgrep ... qui, dans une image minimale sans pgrep, part silencieusement dans le else et devient un PASS permanent.

Les preuves qualitatives de réutilisation concordent avec les chiffres de la section 4 : audit-fake-ai-server.py est réutilisé par 7 scripts ; wave5-pty-drive.cpp est réutilisé par 5+ compilations, avec mise en cache de la compilation via [ -x "$DRIVE" ] || ; le pilote wave4 → wave5 est une extension, pas une réécriture (le diff ne modifie qu'1 ligne et en ajoute 343, les 281 lignes de wave4 restent intactes).

Tous ces comportements ont eu lieu sur une version sans gardien d'objectif. La seule contrainte était les 10 critères d'acceptation jugeables du brief. Cela montre que formuler le « terminé » sous une forme jugeable suffit à induire l'auto-audit et l'auto-divulgation. La valeur marginale du gardien d'objectif doit se mesurer contre cette base, pas contre zéro.

Mais la section suivante le précise : l'auto-audit est très solide sur la technique de vérification, et pourtant il se trompe sur le verdict final du critère le plus important.

L'« auditeur » ici n'est pas le juge

L'auditeur indépendant des sections 7 et 8 est un subagent dépêché spontanément par le coordinateur, pas le juge du gardien d'objectif — ce run n'avait tout simplement pas de gardien d'objectif. Cela se ressemble à la lecture, mais ce n'est pas le même travail : l'auditeur fait un post-mortem, le juge est la porte de verdict à la fin de chaque tour.

8. La note qu'il s'est donnée est-elle juste ? — le critère d'acceptation 1 est mal jugé

Un second auditeur indépendant a été dépêché, pour juger les 10 critères d'acceptation un par un contre le code.

Les critères 2–10 sont tous exacts, avec les preuves dans le code. Le fait que le mode entraînement n'insère jamais de code est même une garantie structurelle : un unique point de décision de sink ai.h:117 + un unique switch d'application app.cpp:590-615 + quatre early-return editor.cpp:679-683, et non des if éparpillés. Les P1–P4 trouvés par son auto-audit sont eux aussi tous exacts, tous corrigés (P4 à moitié exact : cette ligne de pair bleue est du code mort, ghostAttr() passait déjà par A_DIM).

Mais le critère d'acceptation 1 est mal jugé.

Texte du critère : « compiler un exécutable autonome, lancé directement dans un terminal macOS »
Artefact livré   : ELF 64-bit LSB pie executable, ARM aarch64, GNU/Linux
Son verdict      : atteint

Ce point-là a été vérifié à la main en lançant file.

Il n'a rien caché — le titre du §4 de 交付说明.md est précisément « le plus grand risque non vérifié : jamais lancé une seule fois sur macOS », et le §6 de l'audit liste six surfaces macOS non vérifiées. Il a divulgué le risque, mais il n'a jamais reconnecté ce fait au verdict du critère 1, alors que la lettre du critère 1 porte exactement là-dessus.

Mais ce n'est pas une preuve qu'« il faut un juge indépendant »

Il est tentant de lire ce cas comme « donc il faut un gardien d'objectif ». C'est faux, et le lire ainsi revient à dessiner la cible autour de la flèche — exactement le type de raisonnement motivé critiqué à la section précédente.

Le gardien d'objectif de l'époque, en configuration par défaut, aurait très probablement raté ce point lui aussi. judge() a can_run=False par défaut, et pour outils seulement Read / Glob / Grepil ne peut pas lancer file. Il serait allé lire le source, aurait vu que Makefile:25-38 contient bien une branche Darwin, et aurait jugé « atteint ». Exactement le même échec que l'auto-audit.

Le vrai fait structurel

Le critère 1 était tout simplement invérifiable dans l'environnement de ce run.

Il affirme quelque chose sur « le comportement de l'artefact sur macOS », alors que tout le run se déroule dans un conteneur Linux. Aucun juge ne peut vérifier un binaire macOS depuis le conteneur, qu'il soit interne ou indépendant. Le problème n'est pas « qui juge », mais que ce critère a été traité comme un critère jugeable ordinaire, puis jugé.

Trois autres points déterrés par l'auditeur (aucun mentionné par l'auto-audit)

  • La sémantique de quota de doTab() : app.cpp:909 appelle noteActivity() sans condition après acceptGhost()gen_++ → rouvre le quota, si bien que chaque acceptation par Tab redéclenche une requête automatique 500ms plus tard. L'analyse de P1 (brûler de l'argent au repos) n'a examiné que les trois points d'annulation setMode / cancelInFlight / askNow, et a raté ce chemin de dépense, le plus fréquent de tous.
  • Le « gris » du critère 6 n'est littéralement vrai que sur un terminal 256 couleurs ; en 8 couleurs c'est figé sur A_DIM (assombri, pas gris).
  • Le sous-titre du §7 de 验收审计.md écrit « 9 critères d'acceptation », alors que le tableau en compte 10 lignes — une coquille, mais en en-tête du tableau de synthèse.

9. Spill et taille finale de l'établi

103 opérations de spill, 791.4K caractères convertis en pointeurs de chemin, rien de résident en contexte.

.flower/ au final : 58 scripts (250K), 22 livrables (330K), 5 notes (43K).

Risques résiduels

La cible de livraison est macOS, mais toute la vérification a eu lieu dans un conteneur Debian aarch64 ; zéro vérification sur une vraie machine macOS. C'est l'effet de bord réel de la solution d'isolation par conteneur : pour la sécurité on l'enferme dans Linux, alors qu'il doit livrer sur macOS. Le README §10.24 du projet consigne honnêtement ce point et liste 4 risques, mais la boucle n'est pas fermée.

Cette lacune a été comblée ensuite par HT002 — ce run-là consiste précisément à installer ce binaire pour de vrai sur macOS.

Bénéfice inattendu : dans le conteneur on ne voit que /work, donc dans les 19MB de transcript le nom d'utilisateur de l'hôte /Users/hechenyu ne fuit nulle part (/work apparaît 11,495 fois). L'isolation a isolé les chemins au passage.

Ce que ce run n'a pas pu vérifier

  • Le gardien d'objectif : ce run est antérieur à son ajout. Le coordinateur a organisé de lui-même un audit indépendant, très solide sur la technique de vérification, mais il s'est trompé sur le critère le plus important (section 8). Les deux conclusions vont ensemble — écrire des critères jugeables dans le brief suffit à induire l'auto-audit, donc la base du gardien d'objectif n'est pas zéro ; et le gardien d'objectif en configuration par défaut de l'époque aurait raté ce critère lui aussi, donc ce n'est pas non plus une panacée.
  • Le comportement de la fenêtre de 1M : seulement 18.6% utilisés, la limite n'a pas été touchée.
  • Le comportement du micro-compact sous DISABLE_AUTO_COMPACT=1 : aucun compact déclenché de bout en bout, cela reste une inférence.

Ce que ce run a changé dans le framework

Constat Changement effectif
which bloqué (section 5) is_ephemeral() complété avec which / command -v / type / whereis / uname / arch / locale / nproc / getconf / sw_vers ; tests/glance.py reçoit 7 échantillons adverses pour verrouiller la forme d'interrogation de command / type
Critère 1 mal jugé (section 8) La règle 1 de JUDGE_RULES devient on juge l'artefact, pas le source — si on peut le lancer, on le lance ; sinon, on inspecte quand même l'artefact (plateforme, taille, chargeable ou non)
« J'avais divulgué le risque » a valu un verdict « atteint » Le verdict doit distinguer « pas fait » de « invérifiable ici ». Verdict.parse reconnaît désormais 无法验证 / 没法验证 / 无法判定 comme un troisième état unreachableon remonte la question à l'humain, jamais de verdict « atteint »
Le critère 1 était par principe invérifiable dans cet environnement L'étape de définition des objectifs exige de marquer sur-le-champ les items invérifiables dans cet environnement, plutôt que de s'en apercevoir une fois le travail fini
Reprise réseau au niveau du framework, travail à demi terminé d'un subagent perdu (section 6) Consigné comme issue #2, boucle non fermée

Les trois premiers points sont passés pour la première fois sur une vraie API dans HT002, où « juger l'artefact et non le source » a sauvé la mise sur-le-champ — mais la même règle a aussi produit un nouvel échec.