Aller au contenu

HT002 · L'installer sur macOS et le faire tourner

2026-09-07, la tâche tient en une phrase : cloner cppide, l'installer sur ce Mac et le faire tourner. Résultat : $38.24 / 4 étapes / environ 1 heure, le programme tourne effectivement sur macOS (PID 96040, Mach-O arm64), et le verdict est inatteignable — remonté à l'humain, qui a choisi de l'accepter.

C'est le premier run doté d'un goal guard — tout ce qui avait été réécrit à partir des leçons de HT001 passait ce jour-là pour la première fois sur la vraie API. Il a évité un piège dans lequel HT001 était tombé, et a en même temps produit un échec neuf, exactement inverse : avoir transformé git clone && make && ./cppide en une heure de travail. La partie la plus utile de cette page, c'est la seconde.

L'enregistrement brut est dans human-test/HT002/, les comptes sont calculés à partir de runs/manifest.json et de runs/sessions.db :

python tools/analyze_run.py human-test/HT002/runs

La forme de ce run

Élément Valeur
Tâche cloner cppide, l'installer et le faire tourner sur ce Mac, l'humain doit pouvoir réellement ouvrir l'interface et s'en servir
Coût $38.24 — 4 étapes, détail ci-dessous
Durée environ 1 heure (total 0.97h)
Tours 70 (somme des num_turns des quatre étapes)
Contexte du thread principal 36.8K → 80.2K, chute brutale aux frontières d'étape, pas une croissance monotone
Répartition 6 subagents ; 7 appels d'outils pour le coordinateur contre 212 pour les subagents
Cache 90.8% de hits
Workbench 5 livrables, 4 notes, 11 fichiers de spill, 0 script réutilisable
Résultat ça tourne (PID 96040, Mach-O arm64) ; verdict inatteignable, l'humain accepte
Version premier run avec goal guard

Coût, tours et durée de chaque étape

Étape Coût Tours (num_turns) Durée
Clarifier les besoins $0.5306 9 0.10h
Fixer l'objectif $0.4117 12 0.05h
Travail $35.8950 12 0.73h
Travail · verdict #1 $1.4037 37 0.09h
Total $38.2409 70 0.97h

La colonne des durées est la somme des valeurs arrondies de chaque étape. En recalculant sur l'enregistrement brut, la somme exacte des quatre duration_s de manifest.json vaut 3471.53 secondes = 0.9643h ; cette page conserve 0.97h, l'écart est de 0.006h.

L'argent est presque entièrement parti dans l'étape « travail » — $35.8950, soit 93.9%. La première section explique où est passé cet argent, et pourquoi il n'aurait pas dû partir comme ça.


1. Le gros titre : git clone && make && ./cppide transformé en une heure

C'est la découverte la plus importante de ce run, plus importante que tous les « mécanismes qui ont fonctionné » ci-dessous.

À l'étape de définition de l'objectif, le juge s'est écrit 15 critères de verdict (texte original dans 目标.md, sous .flower/notes/). Par nature :

Catégorie Nombre Exemple
Vérifient réellement « que la chose marche » environ 5 build exit 0 ; processus vivant ≥30 secondes ; commande de redémarrage rejouable dans un nouveau shell
Vérifient « que le processus a respecté les règles » 6 les mtime de ~/.zshrc / .zprofile / .bash_profile sont tous antérieurs à cette session ; aucun brew install / npm -g / sudo n'a été lancé ; la mtime de .flower/ n'a pas changé, rien n'a été ajouté ni supprimé dans runs/
Invérifiables par principe 4 prendre une capture d'écran et la regarder ; console devtools ; clic manuel ; confirmation par l'intéressé

Seul un tiers vérifie « est-ce que cette chose marche vraiment ». Six critères vérifient si le système s'est bien tenu — dont un qui contrôle la mtime du répertoire .flower/, c'est-à-dire le répertoire du framework lui-même.

Le critère qui contrôle .flower/ s'est lui-même mis en échec

Ce n'est pas un problème théorique. Au tour de verdict, le juge a honnêtement écrit dans la rubrique « non validé » :

Toutes les mtime des fichiers sous .flower/ sont antérieures au début de la session — échec littéral : INDEX.md 16:14, artifacts/01–05 (15:40–16:12), notes/决策-项目形态与验收路径.md 15:43 ont tous été écrits pendant la session.

La raison : le framework exige impérativement d'écrire les longs livrables dans .flower/artifacts/ et les décisions dans .flower/notes/. Ce critère de verdict exigeait que ce répertoire ne bouge pas. Deux règles en collision frontale, et c'est la checklist de verdict elle-même qui l'a fabriquée — l'exécutant qui n'aurait écrit aucun long livrable aurait été plus conforme.

Il n'a pas essayé de passer en douce : l'étape de travail a signalé d'elle-même ce conflit dans le rapport final, et le journal de décision indique comment il a été traité — aucun fichier existant n'a été touché (需求.md, 目标.md, 问答记录.md : mtime et MD5 identiques avant et après), uniquement des ajouts. Après revérification, le juge a admis que « la falsification que ce critère devait prévenir n'a pas eu lieu », mais il l'a tout de même listé comme non validé, conformément à la règle, et laissé la décision à l'humain.

Cause racine n° 1 : les limites ont été traitées comme des critères de verdict

Les limites posées en phase de clarification préalable (« l'installation doit rester dans le répertoire du projet », « ne pas toucher au code métier ») contraignaient la façon de travailler, mais elles ont été transformées en points de contrôle vérifiant le livrable. D'où des critères du type « contrôler la mtime de .zshrc », « contrôler la mtime de .flower/ ».

Une limite ≠ un critère de verdict. La limite contraint le processus, le critère vérifie le résultat. Une fois les deux confondus, chaque limite ajoutée revient à ajouter un critère — et les limites sont précisément ce que la phase de clarification encourage à écrire en abondance : CLARIFIER_RULES dit noir sur blanc « préciser ce qui ne sera pas fait. Ce paragraphe encadrera chacun de ceux qui travailleront ensuite. »

En regardant l'enregistrement brut, on voit chaque maillon de cette amplification : la limite du brief disait « ne pas toucher aux .flower/ et runs/ déjà présents dans le répertoire courant », devenue dans la checklist de verdict « toutes les mtime des fichiers sous .flower/ sont antérieures au début de cette session » — de « ne pas toucher à l'existant » à « aucun nouveau fichier autorisé dans tout le répertoire ».

Cause racine n° 2 : JUDGE_RULES ne pousse que dans un sens — et ça datait du jour même

Plus tôt dans la journée, à partir des leçons de HT001, ces phrases avaient été écrites dans JUDGE_RULES :

  • « chaque critère doit être vérifiable sur-le-champ », « ce qui manque ou reste vague, tu le complètes »
  • « on juge le livrable, pas le code source » (avec le contre-exemple macOS)
  • « les critères invérifiables dans l'environnement courant, signale-les sur-le-champ »
  • « commence par regarder où tu es (tu peux faire uname -a) »

Chacune pousse vers plus de contrôles, plus de rigueur. Nulle part dans ces règles il n'est dit « ça doit être proportionné à la taille de la tâche ».

Autrement dit : en optimisant contre l'échec « déclarer validé trop facilement » de HT001, on a fabriqué l'échec exactement inverse — le sur-contrôle. Celui-là est de notre fait, pas de celui du modèle.

Cause racine n° 3 : cumul avec l'issue #3

Ce qui bloquait réellement, ce sont deux défauts de portabilité sur macOS :

  • src/proc.cpp utilise ::sigemptyset, or le SDK Apple le redéfinit après le prototype de fonction sous forme de macro fonctionnelle (gardée uniquement par #ifndef _ANSI_SOURCE), donc ::sigemptyset(&s) s'expanse en ::(*(&s)=0,0), erreur de syntaxe
  • src/ui.cpp utilise ::_exit sans inclure <unistd.h>

Trois lignes suffisaient. Mais l'utilisateur avait répondu « ne pas toucher au code métier » en phase de clarification, l'agent a appliqué strictement, et il est parti tester des flags de compilation, dépêcher des subagents, écrire des journaux de décision. S'il avait pu glisser à ce moment-là « les deux lignes, tu peux les changer », c'était fini en dix minutes. Une limite écrite comme une interdiction sera interprétée aussi strictement que possible, et il n'existe aucun canal en cours de route pour la relâcher.

L'enregistrement brut montre la longueur du détour : quatre points de blocage au total, dont un seul — celui de ::_exit — pouvait se résoudre par des flags de compilation ; celui de ::sigemptyset a essuyé 6 combinaisons de -D/-U, toutes en échec (la famille _POSIX_C_SOURCE casse en prime st_mtimespec et O_CLOEXEC). Cette impasse a à elle seule produit un rapport de faisabilité de 13.6K.

Mais rendons justice : la limite a forcé une meilleure solution

La solution finale n'est pas de modifier le source, mais de générer dans le Makefile un header cale qui ne fait que des #undef, puis de le forcer en tête avec -include :

$(MACSHIM):
    @printf '#include <signal.h>\n#undef sigemptyset\n#undef sigfillset\n...' > $@
CPPFLAGS += -include $(MACSHIM) -include unistd.h

C'est mieux que de modifier le source — le dépôt amont ne bouge pas d'une ligne, n'importe qui peut cloner et compiler. La cale est générée dans build/, non versionnée, clean récupère avec rm -rf build, et un make -n UNAME_S=Linux réel montre qu'après expansion il n'y a pas de -include : le chemin Linux n'est pas pollué. Au final, le git diff ne porte que sur un seul fichier, Makefile, +14 / −2.

Donc la limite a produit une meilleure solution à un coût plus élevé. Le problème n'est pas qu'elle ait mal fait, c'est que cet arbitrage n'a pas été confié à l'humain — et la raison en est précisément l'issue #3.

Pourquoi git status affiche 64 entrées alors que le source n'a pas bougé

Le dépôt cppide a committé dans le versionnement 30 .o, 30 .d et 2 binaires, en ELF Linux aarch64, avec des mtime postérieures aux .cpp — sans un make clean préalable, make saute la compilation et va linker directement les ELF, échec garanti. Donc sur les 64 entrées de git status --porcelain, 63 sont des artefacts de compilation suivis par git, plus un ?? build/. Après filtrage par extension, git diff -- 'src/*.cpp' 'src/*.h' README.md config.sample.json | wc -c donne 0. Cette formulation précise a été arrachée par l'audit de conformité — le coordinateur voulait initialement écrire vaguement « zéro modification dans src/ », l'audit a montré que la phrase ne tenait pas.


2. Ce que le goal guard a fait correctement

Tout le mécanisme a fonctionné, et les deux modifications du jour ont laissé une empreinte directe dans les livrables.

Le premier critère de sa checklist de verdict :

uname -a indique Darwin 25.2.0 (macOS)… tous les critères « tourne / s'ouvre » ci-dessous doivent être des résultats réellement obtenus sur cette machine, on n'accepte pas les déductions du type « le source contient une branche macOS donc ça devrait tourner »

C'est exactement la phrase écrite ce matin-là dans JUDGE_RULES à partir des leçons de HT001.

Il a utilisé trois fois le marqueur [此环境无法验证:…] ajouté le jour même, avec à chaque fois une raison — console devtools (aucun navigateur pilotable, et installer un navigateur headless pour ça franchirait la limite), clic manuel (nécessite un humain devant l'écran), confirmation par l'intéressé (lui seul peut la donner).

La première phrase du juge est « je ne conclus pas à partir de ce compte rendu. Je vais sur place. » Puis :

file cppide        → Mach-O 64-bit executable arm64
lsof -p 96040      → txt pointe vers /Users/hechenyu/explore/test-ide/cppide/cppide
                     démarré à 16:10, toujours vivant à 16:15

Il écrit lui-même : « le piège du "livrer un ELF Linux" des leçons méta, cette fois on ne l'a pas pris. Un ELF Linux ne peut pas donner un PID vivant sur Darwin. Ce n'est pas déduit du Makefile. »

Il a aussi vérifié que le source n'avait effectivement pas bougé : toutes les mtime des .cpp / .h sous src/ sont à 15:32 (l'instant du clone), seul le Makefile a changé (16:06), et les .o / .d (16:07) sont des artefacts de compilation suivis, reconstruits.

Il a aussi avoué honnêtement ce qu'il ne pouvait pas atteindre. Le Bash du juge est restreint au répertoire de travail : la comparaison via git ls-remote et la mtime de ~/.zshrc, il ne pouvait pas les exécuter, seulement s'en remettre à la sortie de l'audit indépendant — il a listé ces deux points à part en précisant « je n'ai pas pu les rejouer moi-même ».

3. Verdict « inatteignable », puis on demande à l'humain

Verdict à trois états plus intervention humaine : toute la chaîne a fonctionné pour la première fois en conditions réelles.

Le verdict est inatteignable, bloqué sur le critère de capture d'écran : screencapture -x et -l <window-id> renvoient tous deux could not create image from display — l'autorisation TCC d'enregistrement de l'écran n'est pas accordée, et l'accorder demande à un humain de cliquer dans les réglages système, ce qui est justement une modification système interdite par les limites.

« Dépêcher un tour de subagents de plus ne fera pas apparaître cette image. Selon les règles, on n'a pas le droit de valider, et ce n'est pas non plus un "pas encore atteint" : c'est bel et bien inatteignable, il faut s'arrêter et rendre la main à l'humain. »

Les critères « invérifiables par principe » passent donc de 3 à 4 — 4 des 15 critères sont tout simplement invérifiables dans cet environnement, et on ne s'en aperçoit qu'après avoir dépensé $35.8950 de travail et $1.4037 de verdict. L'étape de définition de l'objectif n'avait coûté que $0.4117 : c'est là qu'on aurait dû le voir.

Le framework a posé cette conclusion à l'humain (consigné en q7 du journal de questions-réponses), trois options, l'humain a choisi « accepter ce résultat », le gate a laissé passer, le workflow s'est terminé.

Il a aussi refusé un substitut. L'exécutant a relu le texte de la fenêtre de terminal via AppleScript, c'était bien une TUI réellement rendue — colonne de numéros de ligne, corps de src/main.cpp, barre de panneaux ─[编译]─[运行]─[AI*]─[输入]─, barre d'état 练习模式 │ main.cpp │ 1:1 │ C++, ni écran blanc ni écran d'erreur. Le juge dit :

« Celui qui a fait le travail n'a pas fait passer ça pour "avoir regardé l'image", cette retenue est correcte. Mais ça ne remplace pas le critère demandé par la checklist, et je ne vais pas non plus compter ça à sa place. »

« Avoir divulgué le risque » n'équivaut pas à valider

C'est exactement là que HT001 est tombé : il avait écrit « jamais exécuté une seule fois sur macOS » dans la note de livraison, puis avait quand même déclaré le critère 1 validé. Dans HT002, le juge disposait d'une preuve de substitution meilleure (le texte réel de la TUI) et a tout de même refusé de l'utiliser à la place du critère. Cette différence, c'est tout le sens du verdict à trois états — UNREACHABLE n'est pas une façon polie de dire NOT_YET, c'est « il faut s'arrêter et demander à l'humain ».

4. allowed_tools n'est pas une whitelist stricte

En analysant ce run, un fait contredisant ce qui était affirmé est apparu. Appels d'outils décomposés par session :

Session Réellement utilisé Contenu de la whitelist
Clarifier les besoins WebFetch, Glob, ask×6 clarify() ne donnait alors que ask + Read/Glob/Grep
Fixer l'objectif Bash ×11 judge() a can_run=False par défaut, pas de Bash
Travail · verdict Bash ×31, WebFetch idem ci-dessus
Travail (coordinateur) Bash ×1, Agent ×6 correct

goal_step n'a aucun chemin de code qui passe can_run, donc la ligne « Fixer l'objectif » ne peut pas être un problème de configuration.

Vérification par sonde à $0.1 : donner à un agent avec allowed_tools=["Read"] l'ordre d'écrire un fichier —

Write → couche de permissions : "requested permissions to write ... but you haven't granted it yet"
Bash  → sécurité des chemins : "Output redirection was blocked. For security, Claude Code may
        only write to files in the allowed working directories"

Le modèle peut appeler des outils absents de la whitelist, il est simplement arrêté par la couche de permissions et la sécurité des chemins. Donc :

  • allowed_tools est une liste de dispense d'approbation, pas une whitelist exclusive
  • ce qui protégeait réellement clarify() / judge() à l'époque, c'était le permission_mode="default" hérité
  • et tous deux avaient delegate_only=Falsepas de hook delegate_guard. Le coordinateur était protégé par un hook, ces deux rôles n'avaient alors aucun mécanisme propre à flower

La docstring de clarify() disait « pas d'outils d'écriture — il ne peut pas se mettre au travail », et tout le contre-test réel à $0.8908 avait été fait pour cette phrase. La justification mécanique de cette phrase est fausse.

Les deux faits de cette section ont changé depuis

whitelist_guard est désormais branché sans condition : les rôles avec delegate_only=False reçoivent automatiquement ce hook du Runtime, clarify() / judge() ne sont plus « sans aucun mécanisme ». Par ailleurs WebFetch est maintenant dans la whitelist de clarify(), donc l'exemple de la première ligne du tableau est caduc — la conclusion tient toujours, mais elle ne repose plus que sur la preuve de la sonde à $0.1. Voir la dernière section de cette page.

5. La courbe de contexte : les frontières d'étape la réinitialisent

Tour 1     36.8K
Tour 25    80.2K   ← pic
Tour 26    32.9K   ← chute : nouvelle étape = nouvelle session (resume_from=None)
Tour 121   64.8K

Trois des quatre points d'échantillonnage (tours 1 / 25 / 121, soit 36.8K / 80.2K / 64.8K) correspondent un à un à l'enregistrement brut ; le 32.9K du tour 26 ne retombe pas juste au recalcul : en rejouant les messages assistant du thread principal triés par store_key, le 26e (le premier de la nouvelle session) donne input + cache_read + cache_creation = 31,972 tokens = 32.0K, tandis que 32.9K est la deuxième valeur de la même session (31,970 + 948). Cette page conserve 32.9K — l'écart est de 0.9K, et la conclusion « chute brutale » tient avec l'une comme avec l'autre.

HT001 montait de façon monotone jusqu'à 185.9K — une seule étape qui a tourné 10 heures. HT002, découpé en quatre étapes ouvrant chacune une nouvelle session, voit son contexte réinitialisé structurellement. C'est l'effet direct du principe « le brief et l'objectif sont des pièces gelées, l'aval reçoit les documents et non la conversation » — ça se voit sur la courbe.

Hits de cache : 90.8% (HT001 : 96.1%). Le taux de hits croît avec la longueur de session, donc les sessions courtes coûtent un peu plus cher à l'unité — c'est l'autre face de la même pièce que la conclusion de la troisième section de HT001.

6. Comparaison avec HT001

HT001 HT002
Tâche écrire un IDE terminal à partir de zéro l'installer sur macOS et le faire tourner
Coût / durée $171.62 / 10.44h $38.24 / 0.97h
Étapes 2 (sans goal guard) 4 (dont définition d'objectif + verdict)
subagents 23 6
Appels d'outils du coordinateur 32 7 (dont 6 Agent)
Appels d'outils des subagents 1,893 212
Contexte du thread principal 28.7K → 185.9K, croissance monotone 36.8K → 80.2K, réinitialisé aux frontières d'étape
Hits de cache 96.1% 90.8%
Scripts du workbench 58, exécutés 331 fois, 92% réutilisés 0
Verdict aucun (auto-audit spontané du coordinateur) verdict à trois états, « inatteignable », remonté à l'humain

La dernière ligne mérite l'attention. HT002 n'a écrit aucun script réutilisable — la tâche ne durait qu'une heure, il n'y avait rien à sédimenter. Cela montre que la valeur du workbench croît avec la longueur de la tâche ; sur une tâche courte, c'est du surcoût pur.

À quoi ressemble l'enregistrement brut

.flower/INDEX.md est l'instantané du workbench à la fin de ce run, on peut le lire directement :

Répertoire Contenu
scripts/ 0
artifacts/ 5 — 01-recon.md (32.6K), 02-build-run.md (19.4K), 03-flag-only-feasibility.md (13.6K), 04-compliance-audit.md (16.4K), 05-final-build-run.md (19.8K)
notes/ 4 — 需求.md, 目标.md, 问答记录.md sont trois pièces gelées écrites par le framework, l'agent n'a écrit qu'un seul journal de décision
spill/ 11 fichiers de spill

Sous runs/ se trouvent manifest.json (4 enregistrements d'étape) et sessions.db. La base contient 10 sessions : 4 sessions de thread principal plus 6 sessions de subagents — cohérent avec les « 6 subagents ».

Le journal de questions-réponses compte 7 questions au total : q1–q6 posées en phase de clarification préalable (cohérent avec les 6 appels ask de l'étape de clarification des besoins), q7 étant le gate humain après le verdict « inatteignable ».

Ce que ce run a changé dans le framework

Constat Changement effectif
Sur 15 critères de verdict, environ 5 seulement vérifient si ça marche (section 1) ajout d'une pression inverse dans JUDGE_RULES : la longueur de la checklist est déterminée par le nombre de modes d'échec, pas par le degré de rigueur, et ce run a été inscrit comme contre-exemple dans le texte des règles (« une tâche "installer et faire tourner" écrite en 15 critères, dont 5 seulement vérifient si ça marche » — la règle arrondit à 5, la classification de la section 1 de cette page dit environ 5)
Les limites écrites comme critères de verdict JUDGE_RULES fige la phrase « une limite n'est pas un critère de verdict »
4/15 critères invérifiables dans cet environnement, découvert seulement après $35.8950 + $1.4037 dépensés l'étape de définition de l'objectif doit désormais suffixer sur-le-champ les critères invérifiables par [此环境无法验证:原因] — cette fois il l'a bien fait pour 3 critères, et a manqué celui de la capture d'écran
clarify() / judge() sans protection par hook (section 4) whitelist_guard est branché sans condition : les rôles avec delegate_only=False le reçoivent automatiquement du Runtime, plus de dépendance à l'activation du workbench
Effet de bord : le juge de la définition d'objectif n'a plus accès à Bash avec whitelist_guard, si can_run=True n'est pas passé explicitement, la règle « commence par un uname -a pour voir où tu es » de JUDGE_RULES est inexécutable — c'est un arbitrage nouveau, apparu après le correctif
La justification mécanique de la docstring de clarify() était fausse corrigé. WebFetch est aussi entré dans la whitelist de clarify(), donc cet exemple est caduc, la conclusion repose désormais sur la sonde à $0.1
Aucun canal pour glisser un mot en cours de route (cause racine n° 3 de la section 1) consigné en issue #3, non résolu — cette fois elle s'est cumulée directement avec la sur-complexification, un simple « les deux lignes, tu peux les changer » aurait économisé une heure

Ce run en une phrase : optimiser contre l'échec précédent produit très facilement un échec neuf, orienté dans l'autre sens. HT001 a appris au framework « ne valide pas à la légère », HT002 a immédiatement démontré ce qui se passe quand cette règle n'est pas accompagnée d'un « ne sur-contrôle pas ». Les deux doivent figurer dans JUDGE_RULES ; s'il en manque une, ça penche.