Aller au contenu

Skill implementer-lot

Référence technique des choix internes du skill Claude Code implementer-lot, qui exécute les lots d'une étape Kirexo précédemment cadrée par analyser-etape. Là où analyser-etape crée l'issue parente, les work-items GitLab et les branches Git, implementer-lot consomme ce cadrage : il écrit le code de production d'un lot, enchaîne les vérifs préalables, délègue aux sous-agents reviewer / test-writer / doc-writer, puis lance ship-lot.sh (commit unique + push + glab mr create --auto-merge ciblant la branche parente) qui dérive branches et iids depuis output.json et délègue au template ship.sh. Le main Claude poll ensuite la MR jusqu'à merged, en s'aidant de ScheduleWakeup pour les attentes longues. En cas d'échec auto-merge, le skill catégorise automatiquement le type d'échec (impl vs runner) et tente une relance bornée (1 par catégorie, 2 au total) via fix-amend.sh ou retry-failed-jobs.sh. Une rétro opt-in (Étape F) clôture optionnellement le run : sur acceptation de l'utilisateur, le sous-agent skill-improver analyse les transcripts du run et produit des propositions d'amélioration ciblées du SKILL.md, des scripts bin/ et des agents sollicités. Cette page documente le comment : asymétries de l'API GitLab, pièges du polling, codes retour, contraintes du cache prompt côté Anthropic, mapping failure_reason → catégorie, design de la boucle d'amélioration récursive. Le quoi vit dans .claude/skills/implementer-lot/SKILL.md. Le pourquoi de la délégation Claude est décrit dans l'explication Workflow des agents Claude.

Pour qui

  • Le développeur qui maintient les scripts du skill (corrections, évolutions).
  • Le main Claude lorsqu'il diagnostique un échec d'auto-merge ou un timeout de polling.

Pour quoi

  • Comprendre pourquoi le polling est borné à 5 min avant de basculer sur ScheduleWakeup(1200s).
  • Retrouver les champs JSON consommés par wait-merge.sh et diag-mr.sh (et l'asymétrie PascalCase / snake_case avec analyser-etape).
  • Lire la table d'interprétation des detailed_merge_status GitLab sans avoir à plonger dans la doc upstream.
  • Lire la table de mapping failure_reason → catégorie utilisée par categorize-failure.sh, et les choix de conception derrière la stratégie de relance (quotas séparés, --amend --no-edit, retry job par job).
  • Éviter de réintroduire un piège déjà résolu (encodage du chemin projet, wrapper bash gl(), codes retour du wait-merge).
  • Comprendre la stratégie de boucle d'amélioration récursive bornée portée par la rétro skill-improver : pourquoi opt-in, pourquoi un sous-agent dédié, pourquoi un patch à la fois.

Scripts livrés

Tous vivent dans .claude/skills/implementer-lot/bin/ et sont invoqués depuis le SKILL.md :

Script Args Rôle Codes retour
preflight.sh <n> Vérifie l'étape (Étapes/étape<n>.md + front-matter), output.json, les specs task-NN-<slug>.md, la branche courante (main ou parente), la disponibilité de glab dans le conteneur php. 0 OK / 1 KO
fetch-issue.sh <iid> Récupère le contenu vivant d'un work-item de lot (description courante + commentaires humains, notes système filtrées) en markdown sur stdout. Source de vérité du contenu d'un lot (cf. § dédié). Mode STRICT : aucune retombée sur le .md local. 0 OK / 1 introuvable ou GitLab KO
check-lot-state.sh <n> <k> Pour le lot k, retourne sur stdout un JSON {branch, mr_iid, mr_state} permettant de vérifier que les lots antérieurs sont mergés avant de démarrer. 0 toujours
sync-parent.sh <n> [<k>] Sync de la branche parente (git checkout + git pull --ff-only, refuse une divergence). Si <k> (numéro de lot, 1-based) est fourni : dérive la branche de lot <task_iid>-<slug> depuis output.json, la crée depuis la parente si elle n'existe pas (sinon la récupère en local ou en suivi d'origin), puis la rebase sur la parente à jour (auto-stash, --abort propre sur conflit). Laisse HEAD sur la branche de lot. Sortie stdout : JSON {parent_branch, task_branch} (task_branch vide si <k> omis). 0 OK / 1 divergence parente, conflit de rebase ou lot hors bornes
ship-lot.sh <n> <k> <commit-msg-file> <mr-body-file> <files> Wrapper de ship d'un lot : dérive la branche de lot (source), la branche parente (cible), task_iid et parent_iid depuis output.json, puis appelle le template ship.sh en lui passant ETAPE=<n> LOT=<k> (marqueur Étape <n> - lot <k>). Le modèle ne fournit donc que message de commit, body MR et liste de fichiers — aucun nom de branche ni iid à construire. 0 OK / 1 lot hors bornes ou branche parente introuvable
wait-merge.sh <mr_iid> Poll glab mr view <iid> --output json toutes les 15 s pendant 5 min max. 0 merged / 2 closed / 3 toujours opened (timeout) / 4 erreur réseau ou glab KO
diag-mr.sh <mr_iid> Diagnostic markdown lisible humain : état, pipeline, liste des jobs CI échoués avec leur failure_reason, interprétation du detailed_merge_status, conflits, discussions ouvertes, et une section « Catégorisation » avec verdict impl / runner / mixed / unknown. 0 toujours, sortie sur stdout
categorize-failure.sh <mr_iid> Pendant machine-parsable de diag-mr.sh. Émet un JSON {mr_iid, pipeline_id, category, runner_jobs, impl_jobs} consommé par la stratégie de relance (étape D du SKILL). C'est ce script — pas diag-mr.sh — que le main Claude pipe dans jq pour décider de générer fix-amend.sh ou retry-failed-jobs.sh. 0 toujours, 1 si MR introuvable

Les deux scripts de diagnostic sont volontairement distincts : diag-mr.sh est destiné à être restitué tel quel dans le chat (markdown), categorize-failure.sh à être consommé par du code (JSON stable). Garder les deux évite à diag-mr.sh d'avoir un mode --json qui doublerait son code et le rendrait plus fragile.

Les scripts générés par le skill (un dossier par lot, sous var/claude/etape<n>/lot-<k>/) :

Fichier Rôle Quand généré
commit-msg.txt Sujet impératif (type(scope): …) + corps du commit unique du lot. Ne contient ni le marqueur Étape X - lot Y ni l'entête Tâche #T · Ticket #P : ces métadonnées sont injectées automatiquement par ship.sh (cf. § Conventions GitLab injectées). À la création MR (C.10)
mr-body.md Description de la MR (résumé + checklist des délégations + lien spec + lien issue parente). Ne contient pas l'entête de navigation Tâche #T · Ticket #P : le template ship.sh la préfixe automatiquement. À la création MR (C.10)
mr.json {iid, url, source, target} de la MR créée, alimenté par ship-lot.sh, relu par wait-merge.sh. Après lancement de ship-lot.sh
retry-state.json {mr_iid, impl_attempts, runner_attempts} — état persistant des tentatives de relance (cf. § Stratégie de relance). À la création MR (C.10), incrémenté à D.3 et D.4
fix-amend.sh git add <fichiers corrigés> + git commit --amend --no-edit + castor audit + git push --force-with-lease. Préserve le mono-commit du lot. À D.3 (échec impl)
retry-failed-jobs.sh Boucle glab api projects/<encoded>/jobs/<job_id>/retry --method POST sur les seuls jobs en échec runner. À D.4 (échec runner)

Template canonique .claude/skills/implementer-lot/bin/ship.sh

Le template ship.sh factorise la séquence commit → push → MR → auto-merge : ~80 lignes identiques entre lots, et concentre l'injection des conventions GitLab. Il n'est plus reproduit ni généré inline par lot — il est désormais invoqué par le wrapper ship-lot.sh, qui dérive ses arguments (branche de lot, branche parente, task_iid, parent_iid) depuis output.json à partir du seul numéro de lot. Le modèle n'a donc plus de nom de branche ni d'iid à construire. Toutes les sorties glab du template sont réduites au strict minimum (jq -r / >/dev/null) pour ne pas polluer le contexte du main Claude avec un dump d'objet MR.

Signature

La signature du template est inchangée ; seul son point d'appel a évolué (auparavant un ship.sh généré par lot, désormais ship-lot.sh) :

bash .claude/skills/implementer-lot/bin/ship.sh <branch> <target> <commit-msg-file> <mr-desc-file> <files> [etape] [lot] [task_iid] [parent_iid]
Position Argument Obligatoire Rôle
1 <branch> oui Branche source du lot (doit être la branche courante, vérifiée par le script).
2 <target> oui Branche cible de la MR (la branche parente, jamais main).
3 <commit-msg-file> oui Fichier message de commit (commit-msg.txt). Sa 1re ligne sert aussi de titre de MR.
4 <mr-desc-file> oui Fichier description de MR (mr-body.md).
5 <files> oui Liste des chemins du lot, espacés, entre guillemets. Stagés explicitement (git add --), jamais git add -A.
6 [etape] non Numéro d'étape, pour le marqueur Étape X - lot Y.
7 [lot] non Numéro de lot, pour le marqueur Étape X - lot Y.
8 [task_iid] non IID du work-item de tâche, pour l'entête Tâche #T · Ticket #P.
9 [parent_iid] non IID du ticket parent, pour l'entête Tâche #T · Ticket #P.

Les arguments 6 à 9 sont optionnels (rétrocompatibilité avec les appels antérieurs). En pratique, ship-lot.sh les renseigne systématiquement à partir du numéro de lot, activant les trois conventions ci-dessous.

Conventions GitLab injectées par ship.sh

Ces métadonnées sont injectées automatiquement par le template — à coût token nul au runtime, et jamais rédigées par le main Claude. commit-msg.txt et mr-body.md ne doivent donc pas les contenir : les y mettre à la main produirait un doublon.

1. Assignation systématique à cpereira

La MR créée est assignée via glab mr create --assignee "$ASSIGNEE", où ASSIGNEE="${GITLAB_ASSIGNEE:-cpereira}". Par défaut cpereira, surchargeable par la variable d'environnement GITLAB_ASSIGNEE. C'est la même convention que celle appliquée par analyser-etape (ticket parent, tâches) et cloturer-etape (MR parente) — cf. analyser-etape et cloturer-etape.

2. Marqueur Étape X - lot Y dans le titre

Si <etape> et <lot> sont fournis, le template insère le marqueur Étape X - lot Y dans la 1re ligne du commit, après le type(scope): conventionnel :

feat(sécurité): remember-me + rate limiter
        ↓  (ship.sh … 5 10 …)
feat(sécurité): Étape 5 - lot 10 — remember-me + rate limiter

L'insertion est faite en bash pur (pas de sed) pour éviter les soucis d'UTF-8/locale sur le tiret cadratin . Si la 1re ligne n'a pas de : (pas de préfixe conventionnel), le marqueur est mis en préfixe. Le titre enrichi sert à la fois de message de commit (git commit -F) et de titre de MR (--title), garantissant leur cohérence. Le corps du commit (lignes 2+) reste inchangé.

3. Entête de navigation Tâche #T · Ticket #P

Si <task_iid> et <parent_iid> sont fournis, le template préfixe la description de MR par une ligne :

Tâche #<task_iid> · Ticket #<parent_iid>

GitLab transforme automatiquement les #N en liens croisés vers le work-item de tâche et le ticket parent. Le corps de mr-body.md est concaténé tel quel à la suite. L'entête n'est ajoutée que si les deux IID sont fournis.

Source de vérité d'un lot : fetch-issue.sh (mode STRICT)

Avant d'implémenter un lot (Étape C.2 du SKILL.md), le main Claude ne lit pas seulement le fichier local var/claude/etape<n>/task-NN-<slug>.md : il récupère le contenu vivant du work-item GitLab via fetch-issue.sh <task-iid>. Le script imprime sur stdout, en markdown, la description courante du work-item suivie des commentaires humains (notes système filtrées : liaisons hiérarchiques, changements de label, etc. sont exclues — seules les notes rédigées par un humain portent une intention sur la tâche).

Pourquoi le live plutôt que le fichier local

Le task-NN-<slug>.md est le snapshot de cadrage posé par analyser-etape comme description du work-item. Mais entre le cadrage et l'implémentation, un humain peut avoir :

  • amendé la description du work-item directement dans l'UI GitLab ;
  • ajouté un commentaire qui précise, corrige ou réoriente un point du lot.

Coder sur le seul fichier local reviendrait à ignorer ces ajustements. fetch-issue.sh capture l'état réel au moment de l'implémentation. Le fichier local reste la référence stable lue par les sous-agents (reviewer, test-writer) ; en cas de divergence, le contenu vivant prime.

Pourquoi le mode STRICT (pas de retombée locale)

Si GitLab est injoignable ou l'iid introuvable, fetch-issue.sh sort en 1 et le skill s'arrête — il ne retombe jamais sur le .md local. C'est un choix défensif : un fallback silencieux ferait coder sur une base potentiellement périmée sans signal. Mieux vaut un arrêt net (réseau / glab à réparer) qu'une implémentation menée sur une spec obsolète.

Lecture pure, autorisée inline

fetch-issue.sh n'a aucun effet remote : glab issue view (description + titre) et glab api .../notes (commentaires) sont des GET, classés en lecture pure par CLAUDE.md (« Hors périmètre — exécution inline autorisée »). Le main Claude le lance donc directement, contrairement aux scripts à effet remote (ship-lot.sh, fix-amend.sh) réservés à l'utilisateur en mono-lot. Le wrapper gl() (cf. ci-dessous) le rend portable hôte ↔ devcontainer.

glab mr view --output json

Champs consommés par wait-merge.sh et diag-mr.sh

docker compose exec -T php glab mr view <iid> -R shaurifr/kirexo --output json renvoie un objet en snake_case (contrairement à glab work-items consommé par analyser-etape, cf. ci-dessous). Les champs utilisés par le skill :

Champ JSON Type Utilisation
state string : opened / merged / closed / locked Décide du code retour de wait-merge.sh.
detailed_merge_status string (enum, cf. table ci-dessous) Interprété par diag-mr.sh pour expliquer pourquoi une MR opened ne merge pas.
has_conflicts bool Flag direct pour le diagnostic « conflit Git non résolu ».
draft bool Repère les MR encore en brouillon (auto-merge bloqué).
user_notes_count int Présence de commentaires humains à examiner avant un retry.
head_pipeline.status string : success / failed / running / pending / canceled / skipped Statut de la pipeline de tête.
head_pipeline.web_url string Lien direct vers la pipeline dans le rapport de diagnostic.
head_pipeline.id int Sert à requêter les jobs échoués via glab api projects/.../pipelines/<id>/jobs.

Asymétrie PascalCase ↔ snake_case (déjà documentée pour analyser-etape)

glab work-items create --output json renvoie du PascalCase (IID, WebURL, ID). glab issue create --output json et glab mr view --output json renvoient du snake_case classique (iid, web_url, id). Ne pas réutiliser un jq qui marche pour l'un sur l'autre — implementer-lot ne touche que des MR et des issues, donc tout est en snake_case côté ce skill.

Encodage du chemin projet pour glab api

Quand diag-mr.sh doit basculer sur glab api pour récupérer les jobs CI échoués, le chemin de projet (shaurifr/kirexo) doit être encodé URL — le / devient %2F. Le skill utilise le bash builtin de substitution plutôt qu'un sed :

PROJECT_PATH="shaurifr/kirexo"
PROJECT_ENC="${PROJECT_PATH//\//%2F}"   # "shaurifr%2Fkirexo"
glab api "projects/${PROJECT_ENC}/pipelines/<id>/jobs?scope[]=failed"

Avantages : pas de sous-processus, pas de quoting fragile, pas de dépendance à sed (déjà installé, mais le builtin est plus rapide et lit mieux).

Wrapper bash gl() (glab natif vs docker compose exec)

Les scripts du skill définissent en tête un wrapper :

gl() {
  if command -v glab >/dev/null 2>&1; then
    glab "$@"
  else
    docker compose exec -T php glab "$@"
  fi
}

Raison : glab est installé dans le conteneur php (prérequis CLAUDE.md), pas sur l'hôte par défaut. Le wrapper rend les scripts portables hôte ↔ devcontainer. -T désactive l'allocation TTY pour rester compatible avec une exécution depuis Claude Code (sans TTY).

Polling court vs ScheduleWakeup

Pourquoi 5 min de polling max dans wait-merge.sh

Côté Anthropic, le cache prompt a un TTL de 5 minutes : tant qu'on relance un appel dans cette fenêtre, on paye un cache hit (≈ 10 % du tarif standard). Au-delà, le cache expire et le prochain appel coûte plein tarif (cache miss).

Le skill exploite cette césure :

  • Attente courte (≤ 5 min) → polling actif dans la session via wait-merge.sh (15 s d'intervalle, 20 itérations max). Le cache reste chaud, le coût marginal de chaque tour de boucle est négligeable.
  • Attente longue (> 5 min) → on ne paie pas un cache miss pour rester planté à attendre. Mieux vaut sortir de la session, libérer le cache, et reprendre frais via ScheduleWakeup(delaySeconds: 1200, reason: "...", prompt: "/implementer-lot <n> <k>-<z>"). 1200 s = 20 min : couvre une pipeline CI normale (build + tests + E2E ≈ 8-15 min) avec marge.

Au réveil, le skill re-exécute le préflight (Étape B vérifiera que les lots déjà mergés sont skip), donc le cycle est idempotent.

Codes retour normalisés de wait-merge.sh

Trois codes distincts pour les MR non-mergées, et c'est volontaire — chaque code déclenche une action différente côté main Claude :

Code Sens Action côté skill
0 state == "merged" Succès, passer au lot suivant.
2 state == "closed" MR fermée sans merge → diagnostic obligatoire (diag-mr.sh) et question utilisateur. Pas de retry auto.
3 state == "opened" toujours après 5 min Attente longue → ScheduleWakeup + sortie de session. Pas un échec, juste « pas encore ».
4 Erreur réseau, glab indisponible, JSON malformé Diagnostic + arrêt. Probablement un souci d'environnement, pas de la MR.

Fusionner 2 et 3 en un seul code « pas merged » serait une régression : un closed n'est jamais une attente, c'est un échec définitif qui demande l'attention humaine ; un opened après timeout est juste un signal pour basculer sur ScheduleWakeup. Fusionner 3 et 4 casserait aussi l'arbre de décision (un timeout normal n'est pas une erreur).

Interprétation du detailed_merge_status

Le champ detailed_merge_status de l'API GitLab v4 (/api/v4/projects/.../merge_requests/<iid>) est plus fin que merge_status et explique précisément ce qui bloque une MR opened. Table des valeurs interprétées par diag-mr.sh :

Valeur Interprétation Action conseillée
mergeable La MR est prête, GitLab va tenter le merge. Si elle reste dans cet état, c'est souvent l'auto-merge en cours. Patienter, c'est le cas nominal pendant la fenêtre d'auto-merge.
ci_must_pass La pipeline n'a pas (encore) atteint success. Vérifier head_pipeline.status. Si failed → diagnostic des jobs échoués. Si running / pending → attendre.
conflict Conflit Git avec la branche cible. has_conflicts == true. Rebase manuel sur la branche parente. Pas de force-push auto.
discussions_not_resolved Au moins un thread de review reste ouvert. Repérer via user_notes_count + lien MR. Décision humaine.
draft_status MR encore en brouillon (draft == true). glab mr update --ready côté utilisateur.
not_approved Le projet exige N approbations et le seuil n'est pas atteint. N/A sur ce projet (pas de règle d'approbation côté shaurifr/kirexo), à surveiller si on en ajoute.
blocked_status Une autre MR doit être mergée d'abord (dépendance déclarée). Pas utilisé sur ce projet, l'ordre des lots est porté par l'invariant skill, pas par GitLab.
need_rebase La branche source est en retard sur la cible et le projet exige un fast-forward. git pull --rebase origin <parent-branch> + git push.
checking GitLab est en train de calculer le statut de merge. Transitoire, repoller dans 15 s.
preparing Idem, plus tôt dans le cycle. Transitoire.
unchecked GitLab n'a pas (encore) lancé le check. Transitoire, force un check via glab mr view (effet de bord du GET).

diag-mr.sh produit un rapport markdown structuré qui cite la valeur exacte du detailed_merge_status et la ligne d'interprétation associée. C'est ce rapport que le main Claude restitue à l'utilisateur en cas d'auto-merge KO.

Stratégie de relance auto-merge

Quand wait-merge.sh retourne 3 avec une pipeline failed (ou un detailed_merge_status bloquant), le skill tente une relance bornée plutôt que d'escalader immédiatement au user. Cette page documente les choix de conception ; le déroulé fonctionnel est en § D du SKILL.md.

Mapping failure_reason → catégorie

L'API GitLab v4 expose, pour chaque job en échec listé via glab api projects/<id>/pipelines/<pid>/jobs?scope=failed, un champ failure_reason (enum). Le script categorize-failure.sh applique la table de correspondance suivante :

failure_reason Catégorie Cause typique
runner_system_failure runner Le runner GitLab a planté avant ou pendant le job (souci hyperviseur, OOM système, perte de connexion).
stuck_or_timeout_failure runner Aucun runner disponible pour prendre le job dans la fenêtre allouée (file d'attente saturée, label de runner absent).
job_execution_timeout runner Le job a dépassé son timeout global GitLab — pas une erreur de code, mais une enveloppe infra trop courte.
script_failure (et tout le reste) impl Le script du job a renvoyé un code non nul. C'est le cas nominal d'un test qui plante, d'un phpstan qui sort en erreur, d'un cs:fix qui détecte un fichier non conforme.

Quand runner_jobs et impl_jobs sont tous deux non vides sur la même pipeline, la catégorie devient mixed et l'impl est traitée en priorité — un bug de code reste un bug de code, même si un runner a aussi flanché à côté. Le runner pourra être retenté ensuite si l'amend ne suffit pas.

unknown est réservé au cas dégénéré où la pipeline est failed mais qu'aucun job n'a de failure_reason exploitable (situation rare : on escalade au user sans tenter de relance).

none signifie « pas de jobs échoués sur la pipeline de tête » : la MR ne merge pas pour une autre raison (conflit Git, discussions_not_resolved, brouillon, etc.), et la relance CI ne servirait à rien.

Pourquoi des quotas séparés impl / runner plutôt qu'un compteur global

Le skill plafonne impl_attempts ≤ 1, runner_attempts ≤ 1 et impl + runner ≤ 2. Un compteur global unique aurait été plus simple à coder, mais aurait deux défauts :

  • Une rafale de problèmes d'infra peut masquer un vrai bug. Si trois runner_system_failure consécutifs consomment un quota global de 3, le skill pourrait ne plus avoir le droit de tenter un fix-amend.sh quand un script_failure apparaît au quatrième tour. À l'inverse, isoler le runner garantit qu'un problème infra n'épuise jamais la capacité de réagir à un bug.
  • L'inverse est aussi vrai. Si on a déjà tenté un fix-amend.sh (impl), et qu'un runner plante ensuite, c'est un signal infra indépendant qui mérite sa propre tentative — pas un signe que « la MR est mauvaise ».

Les deux quotas restent à 1 chacun (et la somme à 2) : au-delà, le coût de continuer à essayer automatiquement dépasse la valeur d'attendre une décision humaine.

Pourquoi git commit --amend --no-edit + git push --force-with-lease

Plutôt qu'un nouveau commit « fix: …» ajouté par-dessus.

  • Préserver le mono-commit du lot. L'invariant projet est « un lot = un commit de production ». Un fix poussé en commit séparé casserait l'invariant et obligerait le merge à faire un squash, ce que la mémoire utilisateur interdit (Jamais de squash automatique GitLab). --amend --no-edit réécrit le commit du lot en gardant son sujet et son corps initiaux : le titre de MR, le contenu de commit-msg.txt et le commit poussé restent cohérents.
  • --force-with-lease plutôt que --force. Un --force brut écraserait n'importe quel état distant, y compris une branche qui aurait reçu des commits entre-temps (par ex. via un push concurrent, ou si la MR a été mergée pendant qu'on préparait l'amend). --force-with-lease compare la ref locale connue à celle du remote et refuse le push si elles divergent — le skill échoue alors proprement plutôt que de détruire silencieusement du travail.

Pourquoi retry job par job (et pas la pipeline entière)

Sur catégorie runner, retry-failed-jobs.sh itère sur les id listés dans categorize-failure.sh > .runner_jobs[].id et appelle glab api projects/<encoded>/jobs/<id>/retry --method POST pour chacun. Plusieurs raisons d'éviter un retry de toute la pipeline (glab api projects/<id>/pipelines/<pid>/retry) :

  • Ne pas re-déclencher les jobs déjà OK. Une pipeline Kirexo enchaîne cs:fix, phpstan, lint, schema:validate, test:unit, test:e2e. Si seul test:e2e a sauté sur un stuck_or_timeout_failure, retenter test:unit et tous les jobs amont est du gâchis CI (temps, coût, file d'attente runners).
  • Ne compter qu'une seule tentative runner même si plusieurs jobs ont sauté. L'incrément runner_attempts += 1 est unique par appel à D.4, quel que soit le nombre de jobs retentés en lot. Un retry de pipeline brouillerait ce compte.
  • Garder une trace fine côté GitLab. Un retry de pipeline crée une nouvelle pipeline complète et masque l'historique du job d'origine. Un retry par job conserve l'identité du job dans l'historique de la pipeline et facilite l'analyse post-mortem (« quel job a sauté combien de fois, sur quel runner »).

Pourquoi pas de relance sur wait-merge.sh codes 2 et 4

Le mapping est volontairement asymétrique :

  • Code 2 (state == "closed") — la MR a été fermée. Cela peut venir d'un humain (revue qui refuse le lot, décision de redécouper), de GitLab (rare, mais possible sur certaines configurations de protection), ou d'une action externe. Le CI n'a rien à voir avec ce signal : retenter quoi que ce soit reviendrait soit à rouvrir la MR sans comprendre pourquoi elle a été fermée, soit à ouvrir une nouvelle MR identique. Les deux sont des décisions de produit qui demandent l'attention humaine.
  • Code 4 (erreur réseau ou glab KO) — on ne sait même pas dans quel état est la MR. Toute action automatique (relance CI, amend, force-push) serait prise en aveugle. La seule action sûre est de remonter le diagnostic et d'attendre un environnement réseau / glab fonctionnel.

Codes 0 et 3 sont, eux, des signaux exploitables : 0 est un succès, 3 est une attente où l'on a accès à un état complet de la MR (detailed_merge_status, head_pipeline.status, liste des jobs) pour décider intelligemment.

Rétro opt-in et skill-improver

Après la clôture du lot (Étape E), avant de rendre la main, le main Claude pose une question fermée : « Tu veux une rétro pour améliorer ce skill (SKILL.md, scripts bin/, agents sollicités) sur la base du run ? (oui / non) ». Sur oui, délégation au sous-agent skill-improver ; sur non, fin de session immédiate. Cette section documente les choix de design de cette boucle d'amélioration récursive bornée.

Pourquoi opt-in et pas auto

La rétro a un coût non négligeable : lecture des transcripts .jsonl du run (potentiellement plusieurs Mo), analyse des cycles de délégation, génération du rapport. Sur un run sans surprise — pas de cycle reviewer / test-writer > 1, pas de relance auto-merge, pas d'escalade utilisateur, pas de correction inline — l'analyse renvoie peu de propositions à fort impact et la dépense de tokens n'est pas justifiée.

Imposer la rétro à chaque run reviendrait à payer ce coût même pour les lots triviaux qui passent en ligne droite. À l'inverse, désactiver la rétro par défaut prive le skill du retour d'expérience le plus précieux : celui pris « à chaud », juste après le run, sans rejouer mentalement les frictions des jours plus tard.

L'opt-in en fin de session est le bon compromis : l'utilisateur sait à ce moment-là s'il y a eu friction (relances, corrections, escalades) ou si le run s'est déroulé proprement, et décide en connaissance de cause.

Pourquoi un sous-agent dédié et pas inline

Trois raisons motivent la délégation à skill-improver plutôt qu'une analyse inline par le main Claude :

  1. Isolation du contexte main. Après plusieurs lots, le contexte du main Claude est déjà chargé (specs, code édité, sorties de vérifs, échanges utilisateur). Y empiler la lecture des transcripts .jsonl et l'analyse statistique grèverait le budget restant — alors que la rétro n'a pas besoin de ce contexte applicatif.
  2. Lecture de fichiers volumineux sans pollution. Les transcripts .jsonl peuvent dépasser plusieurs Mo et contenir des extraits de code copiés-collés au fil du run. Un sous-agent peut les lire dans son propre contexte, isoler les signaux pertinents (frictions, répartition tokens), et restituer un rapport condensé.
  3. Éviter le biais d'auto-jugement. Le main Claude qui vient de produire le run n'est pas le mieux placé pour critiquer son propre déroulé : il aurait tendance à rationaliser les frictions plutôt qu'à les pointer froidement. Un agent dédié, instruit pour repérer corrections user, cycles reviewer / test-writer > 1, refus hook et escalades, applique le même filtre quel que soit le contenu du run.

Le pattern est symétrique à celui utilisé par cloturer-etape, qui délègue lui aussi son introspection à un sous-agent dédié plutôt que de la faire faire au main.

Format du rapport

Le skill-improver produit un seul fichier markdown : var/claude/skill-improvements/<YYYY-MM-DD>-<skill-name>-etape<n>.md (suffixe -NN si un rapport existe déjà pour la même date et la même étape, p. ex. -02). Structure imposée :

  1. Synthèse — 2 à 5 lignes : verdict global du run (fluide / quelques frictions / nombreuses frictions), nombre de propositions, gain estimé cumulé.
  2. Répartition tokens — postes consommateurs (contexte initial, lectures de fichiers, recherches Grep / Glob, sous-agents avec noms, commandes shell, génération des livrables), en estimation grossière à ~100 %.
  3. Frictions observées — liste factuelle, chaque ligne renvoyant à un horodatage ou un message des transcripts (corrections user, refus hook, cycles reviewer répétés, escalades, etc.).
  4. Propositions classées par impact — 3 à 7 patches concrets, ordre d'impact décroissant. Chaque proposition contient : cible fichier (chemin + lignes), diff unified propre, gain estimé (tokens / latence / frictions évitées), raison (lien explicite avec la friction observée au § 3), risque.

Le rapport est restitué tel quel par le main Claude qui présente ensuite chaque proposition une par une.

Périmètre strict des cibles

Le skill-improver peut proposer des patches uniquement sur :

  • .claude/skills/<skill>/SKILL.md
  • .claude/skills/<skill>/bin/*.sh
  • .claude/agents/<agent>.md — uniquement pour les agents réellement sollicités pendant le run (lus dans les transcripts), pas les autres.

Hors périmètre : CLAUDE.md (global et projet), projet.md, .claude/settings.json, les hooks .claude/hooks/, le code de production sous src/, les tests sous tests/, la doc Diátaxis sous docs/. La raison est défensive : ces fichiers concentrent les invariants structurants du projet (règles d'architecture, permissions de tools, garde-fous de délégation). Une proposition automatique qui les modifierait sans contrôle humain renforcé pourrait casser les patterns de tous les autres skills et sous-agents. Pour amender un de ces fichiers, l'utilisateur passe par une demande explicite séparée, pas par la rétro d'un skill.

Pourquoi patches un par un (et pas un diff global)

Le main Claude présente les propositions une par une dans l'ordre du rapport (impact décroissant), avec pour chacune le choix appliquer / passer / amender. Trois raisons :

  • Alignement avec la mémoire utilisateur « découverte itérative > cadrage initial exhaustif ». L'utilisateur préfère filtrer en cycles courts plutôt que valider un gros bloc en bloc. Un diff global force un choix tout-ou-rien qui ne laisse pas la place à l'amendement.
  • Permettre d'amender à mi-parcours sans tout rejeter. Une proposition peut être pertinente sur l'intention mais imparfaite sur le diff (libellé maladroit, condition trop large, oubli d'un cas). Le mode amender permet d'ajuster puis de re-présenter sans perdre la proposition entière.
  • Détecter les effets de bord entre patches. Deux propositions peuvent toucher la même section et se contredire si elles sont appliquées ensemble en aveugle. Le mode un-par-un permet au main Claude de signaler les collisions au fur et à mesure.

Mise à jour du rapport post-validation

Une fois la boucle terminée, le main Claude met à jour le fichier rapport : chaque proposition est annotée selon le verdict de l'utilisateur :

  • (appliquée) — patch écrit tel quel.
  • (amendée et appliquée) — patch ajusté avant écriture, le diff final remplace le diff initial dans le rapport.
  • (passée) — proposition refusée, conservée dans le rapport avec la mention.

Le fichier reste sous var/claude/skill-improvements/ comme historique de décisions. Utilité concrète : lors d'un futur run de rétro sur le même skill, le skill-improver peut lire les rapports précédents (mêmes chemins) et éviter de re-proposer un patch déjà passé pour une raison qui n'a pas changé — ou au contraire revenir dessus si le contexte a évolué.

Garde-fous

Deux garde-fous bornent ce que le skill-improver peut proposer :

  • Pas de patch qui contredit CLAUDE.md. Si une proposition lèverait un invariant projet (mono-commit par lot, lot ≤ 5 fichiers PHP de prod, ordre des vérifs cs:fixphpstanlintschema:validate), l'agent doit la signaler comme telle et ne pas la proposer. Le rapport peut mentionner la friction observée mais sans patch automatique.
  • Signalement des changements de signature inter-fichiers. Si une proposition modifie la signature d'un script bin/<x>.sh (nom, ordre d'arguments, code retour), elle doit aussi proposer la mise à jour du SKILL.md qui l'appelle — un patch isolé sur un script casserait la cohérence avec le déroulé du SKILL.md. L'agent émet alors deux propositions liées dans le rapport, à appliquer ensemble.

Invariants préservés par le skill

Le skill garantit un petit nombre d'invariants fonctionnels, dont chacun a une mécanique technique qui le porte :

Ordre strict des lots — vérité côté GitLab, pas local

Avant de démarrer un lot k, le skill lance check-lot-state.sh <n> <j> pour chaque j < k. Le script interroge la branche distante et la MR distante via glab : mr_state != "merged" → arrêt. Aucun fichier de state local n'est consulté. Conséquence : on peut interrompre / reprendre / changer de machine, l'ordre est porté par GitLab.

Pas de merge vers main

Le glab mr create du template ship.sh (invoqué par ship-lot.sh) cible systématiquement la branche parente, jamais main. La fusion <parent-branch>main est réservée à la clôture d'étape (/cloturer-etape, hors périmètre de ce skill). Côté permissions, .claude/settings.json dénie déjà git push origin main au main Claude — double tour.

Mono-commit par lot, préservé même après relance

Chaque lot fusionne sur la branche parente sous la forme d'un seul commit, dont le sujet et le corps sont ceux écrits dans commit-msg.txt. Cet invariant est porté techniquement par :

  • le template ship.sh (invoqué par ship-lot.sh) qui fait un unique git commit -F commit-msg.txt (jamais une suite de commits).
  • fix-amend.sh qui, en cas de relance impl, utilise git commit --amend --no-edit — le message original est préservé, l'historique de la branche tâche reste à un commit.
  • git push --force-with-lease qui propage l'amend sans tolérer une divergence distante (cf. section « Stratégie de relance auto-merge »).

Conséquence : pas besoin de squash au merge (mémoire utilisateur : Jamais de squash automatique GitLab), et l'historique de <parent-branch> reste lisible — un commit par lot, dans l'ordre.

Pas d'écriture de tests ni de doc par le main Claude

Le hook guard-delegation.sh refuse tout Write du main Claude dans tests/ ou docs/. La seule porte d'entrée légitime est la délégation à test-writer (obligatoire après tout fichier PHP de prod modifié) ou doc-writer (obligatoire si impact UX). Voir hooks-claude.md pour les détails.

Effets remote uniquement via ship-lot.sh lancé par l'utilisateur

En mono-lot, le main Claude ne lance jamais ship-lot.sh lui-même. Il propose un lien cliquable et attend la confirmation utilisateur. C'est la règle CLAUDE.md « Actions à effet remote = script obligatoire » : git push, glab mr create, tout payload publié doit pouvoir être relu avant publication (les commit-msg.txt / mr-body.md sont écrits et visibles avant le lancement). La conséquence pratique : aucun push ni MR n'est créé sans une action humaine explicite, même quand l'outil est techniquement autorisé dans .claude/settings.json. Exception — en run multi-lots (range <x>-<z> ou set complet), CLAUDE.md autorise le main Claude à lancer lui-même ship-lot.sh pour enchaîner les lots sans interruption manuelle ; la relecture du payload reste assurée et castor audit tourne avant chaque push.

skill-improver n'écrit qu'à un seul endroit

Le sous-agent skill-improver n'écrit jamais en dehors de var/claude/skill-improvements/. Les patches sur le SKILL.md, les scripts bin/ ou les agents sollicités sont appliqués par le main Claude après validation utilisateur, jamais par l'agent lui-même. Techniquement, le sous-agent ne reçoit que les tools Read, Glob, Grep, Bash et Write — et le Write est restreint par convention au seul dossier var/claude/skill-improvements/. Conséquence : aucune modification du skill, des scripts ou des agents ne peut survenir sans relecture humaine du diff, ce qui protège la cohérence du framework de skills même quand la rétro propose une refonte large.

Voir aussi

  • .claude/skills/implementer-lot/SKILL.md — description fonctionnelle du skill (invocation, déroulé étape par étape, erreurs à signaler).
  • ../../.claude/agents/skill-improver.md — sous-agent invoqué par l'Étape F (rétro opt-in) : périmètre, tools, format du rapport, garde-fous.
  • Skill analyser-etape — le pendant amont : cadrage d'étape, création des work-items, asymétries glab work-itemsglab issue.
  • Hooks Claude Codeguard-delegation.sh (qui peut écrire où), count-prod-php.sh (quota 5 fichiers PHP de prod), stop-checklist.sh (checklist de fin de tâche).
  • Workflow des agents Claude — pourquoi le main Claude délègue à reviewer, test-writer, doc-writer, et pourquoi implementer-lot enchaîne ces délégations dans un ordre fixe.