Aller au contenu

Skill cloturer-etape

Référence technique des choix internes du skill Claude Code cloturer-etape, dernier maillon de la chaîne analyser-etapeimplementer-lotcloturer-etape. Là où analyser-etape cadre l'étape (issue parente, work-items, branches) et implementer-lot exécute chaque lot (code + tests + MR mergée sur la branche parente), cloturer-etape ne se déclenche qu'une fois tout le travail mergé côté GitLab : il garde-fou la complétude, vérifie que les tests E2E couvrent réellement le périmètre fonctionnel de l'étape, produit trois livrables validés indépendamment, puis prépare (sans la déclencher) la fusion finale <branche-parente>main. Cette page documente le comment : récupération du state d'un work-item via GraphQL, asymétrie de code retour de glab mr list, wrapper gl(), résolution de la branche parente, le double gate complétude / cohérence E2E, et le choix de ne jamais merger ni fermer le ticket. Le quoi vit dans .claude/skills/cloturer-etape/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 de préflight, un gate de complétude KO ou un trou de cohérence E2E.

Pour quoi

  • Comprendre pourquoi check-etape-complete.sh interroge le state d'un work-item enfant via GraphQL plutôt que via l'IID seul, et pourquoi output.json stocke un gid par tâche.
  • Retrouver le pattern qui neutralise l'asymétrie de code retour de glab mr list (exit 1 quand aucune MR n'existe) sous set -o pipefail.
  • Savoir comment la branche parente est résolue sans slug dans output.json (motif <parent_iid>-*).
  • Comprendre l'articulation des deux gates (complétude scriptée + cohérence E2E par le main Claude) et lequel bloque, lequel escalade.
  • Comprendre pourquoi le skill ne ferme jamais le ticket ni ne merge la MR parente — et pourquoi c'est cohérent avec implementer-lot qui interdit tout merge vers main.

Scripts livrés

Tous vivent dans .claude/skills/cloturer-etape/bin/ et sont invoqués depuis le SKILL.md. Les deux derniers ont un effet remote et sont lancés par l'utilisateur après relecture, jamais par le main Claude (cf. CLAUDE.md « Actions à effet remote = script obligatoire »).

Script Args Rôle Effet remote ? Stdout / codes retour
preflight.sh <n> Vérifie Étapes/étape<n>.md, le front-matter ticket_gitlab:, var/claude/etape<n>/output.json (≥ 1 lot), la branche parente locale <parent_iid>-*, la branche courante (main ou parente, working tree propre, untracked OK), et la disponibilité de glab. Non JSON {parent_iid, parent_url, parent_branch} sur stdout / 0 OK, 1 KO
check-etape-complete.sh <n> Gate d'entrée : pour chaque tâche de output.json, vérifie que le work-item enfant est CLOSED et que sa MR de lot est merged. Non (lecture) Tableau lisible + ligne JSON {complete, blockers[]} / 0 complet, 1 incomplet
publish-livrables.sh <n> <livrable...> Publie un ou plusieurs livrables (désignés par leur numéro) en commentaires du ticket parent, en une seule commande (glab issue note). Déduit seul l'IID du ticket (front-matter ticket_gitlab:) et le chemin de chaque livrable (var/claude/etape<n>/livrable-<k>-*.md) ; échoue tôt si un livrable manque, avant toute publication. Oui Confirmation par livrable + total / 0 OK, 1 usage/résolution KO
ensure-parent-mr.sh <n> [body_md] Crée la MR <parent_branch>main si absente (no-op sinon), assignée à cpereira, avec Closes #<parent_iid> dans le body. Ne merge jamais. Oui Confirmation (MR existante ou créée) / 0 OK, 1 usage/résolution KO

Les livrables eux-mêmes sont écrits par le main Claude dans var/claude/etape<n>/ : livrable-0-coherence-e2e.md, livrable-1-resume.md, livrable-2-interactions.md, et optionnellement mr-parente-body.md. Aucun script ne les génère.

Récupération du state d'un work-item via GraphQL

check-etape-complete.sh doit savoir, pour chaque tâche enfant, si le work-item GitLab est ouvert ou fermé. L'IID seul ne suffit pas : l'API REST des work-items est expérimentale et le skill préfère la query GraphQL singulière par gid, qui est stable :

gl api graphql -f query="query{workItem(id:\"${GID}\"){state}}"

La réponse expose .data.workItem.stateOPEN | CLOSED. Le script l'extrait avec un fallback :

WI_STATE="$( { gl api graphql -f query="query{workItem(id:\"${GID}\"){state}}" 2>/dev/null || echo '{}'; } \
    | jq -r '.data.workItem.state // "unknown"')"

Pourquoi output.json stocke un gid par tâche

La query workItem(id:) attend un identifiant global au format gid://gitlab/WorkItem/<id>, pas un IID numérique. C'est précisément pour cette requête que analyser-etape enregistre gid à côté de iid et slug dans output.json (.tasks[] = {iid, gid, slug}). Sans le gid mémorisé en amont, le gate devrait reconstruire l'identifiant global via un aller-retour GraphQL supplémentaire (workItems(iids:), cf. la query pluriel documentée pour analyser-etape).

Le singulier workItem(id:) fonctionne ici parce qu'on l'interroge par gid global, contrairement au workItem(iid:) sur le type Project qui, lui, n'existe pas (cf. la note de la référence analyser-etape).

Asymétrie de code retour de glab mr list

Pour l'état de la MR de lot, check-etape-complete.sh (et ensure-parent-mr.sh) listent les MR par branche source :

MR_STATE="$( { gl mr list --source-branch "$BRANCH" --state all --output json 2>/dev/null || echo '[]'; } \
    | jq -r '.[0].state // "none"')"

Le || echo '[]' n'est pas cosmétique. glab mr list retourne exit 1 quand aucune MR ne correspond à la branche source — ce n'est pas une erreur fonctionnelle, juste « zéro résultat ». Or les scripts tournent sous set -euo pipefail : sans le fallback, ce exit 1 tuerait le script à la première tâche dont la MR n'existe pas encore (cas nominal d'un lot non démarré), alors que le gate doit précisément la rapporter comme bloqueur.

Le pattern { gl mr list ... || echo '[]'; } | jq transforme l'absence de MR en tableau vide, que jq -r '.[0].state // "none"' réduit à l'état none. Le verdict du lot devient alors proprement, au lieu d'un crash.

Piège déjà corrigé — ne pas réintroduire

Retirer le || echo '[]' (ou sortir le gl mr list du sous-shell { ... }) ferait échouer le gate dès qu'un seul lot n'a pas de MR, transformant un état « incomplet, à rapporter » en « script planté ». Le sous-shell groupé est volontaire : il capture le code retour avant que pipefail ne le propage.

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

Comme les autres skills de la chaîne, chaque script qui appelle glab définit en tête le même wrapper :

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

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

preflight.sh applique la même logique pour son test de disponibilité : il considère glab accessible s'il existe en natif ou s'il répond via docker compose exec -T php glab --version.

Résolution de la branche parente

output.json ne stocke pas le slug de l'issue parente — seulement les tâches enfants et parent.iid. La branche parente est donc résolue par motif sur l'IID, en prenant la première branche locale qui matche :

PARENT_BRANCH="$(git for-each-ref --format='%(refname:short)' "refs/heads/${PARENT_IID}-*" | head -1)"

C'est la même convention de nommage que celle posée par analyser-etape à la création des branches (<iid>-<slug>). preflight.sh et ensure-parent-mr.sh utilisent tous deux ce motif ; si aucune branche ne matche, ils s'arrêtent avec un diagnostic invitant à git fetch origin && git checkout ${PARENT_IID}-<slug>.

À noter : preflight.sh déduit PARENT_IID du front-matter ticket_gitlab: (dernier segment de l'URL du ticket), tandis que ensure-parent-mr.sh le relit depuis .parent.iid dans output.json. Les deux sources doivent désigner le même ticket parent — c'est le cas par construction puisque analyser-etape écrit les deux.

Le double gate : complétude puis cohérence E2E

Le skill applique deux gates successifs et de natures différentes. Les confondre serait une erreur : ils ne mesurent pas la même chose et n'ont pas le même mode d'échec.

Gate 1 — complétude (scripté, bloquant, exit code)

check-etape-complete.sh est la condition d'existence du skill : il garantit que tout le travail de l'étape est effectivement mergé côté GitLab. Pour chaque tâche, il croise deux états :

  • le work-item enfant doit être CLOSED (via GraphQL, cf. plus haut) ;
  • sa MR de lot doit être merged (via glab mr list, cf. plus haut).

Un seul manquement (wi_state != CLOSED ou mr_state != merged) fait passer complete: false et le script sort en 1. Le main Claude s'arrête alors et renvoie l'utilisateur vers /implementer-lot <n> <k> pour terminer le ou les lots restants. Ce gate est purement mécanique : il lit des états distants, ne juge rien, et son verdict est un code retour.

Gate 2 — cohérence fonctionnelle ↔ E2E (main Claude, bloquant, escalade)

L'étape B du SKILL.md est un gate non scripté, conduit par le main Claude. Objectif : garantir que chaque parcours fonctionnel décrit dans Étapes/étape<n>.md (pages, routes, critères perceptibles depuis un navigateur) est couvert par un test E2E Panther. La démarche croise trois sources : les parcours attendus extraits de l'étape, les routes réellement exposées (castor console debug:router --format=json), et les fichiers tests/E2E/*.php (convention app_foo_barAppFooBarTest.php).

Ce gate est lui aussi bloquant, mais son mode d'échec diffère : il n'y a pas de code retour, et surtout le skill n'écrit jamais de tests. Un parcours sans E2E déclenche une escalade au user avec trois options : combler le trou via /implementer-lot <n> <k> (où la délégation test-writer écrira les E2E), accepter le trou explicitement (déconseillé, consigné dans le livrable et apprentissage.md), ou annuler. Tant que l'utilisateur n'a pas tranché, le livrable 1 n'est pas produit.

Pourquoi deux gates et pas un seul

Le gate 1 vérifie que tout est mergé ; il ne dit rien de la qualité fonctionnelle de ce qui est mergé. Le gate 2 vérifie que le périmètre fonctionnel est testé E2E ; il suppose que le code est déjà là (donc que le gate 1 est passé). Fusionner les deux serait impossible : le premier est une lecture d'états GitLab automatisable, le second est une analyse de correspondance étape ↔ tests qui demande de comprendre l'intention fonctionnelle et qui, en cas de trou, ne se résout pas par un code retour mais par une décision humaine.

Publication des livrables et apprentissage.md

publish-livrables.sh <n> <livrable...> poste un ou plusieurs livrables en commentaires du ticket via glab issue note <iid> -m "$(cat ...)". L'utilisateur ne passe que le numéro d'étape et les numéros de livrables (ex. publish-livrables.sh 6 0 1 à l'étape E, publish-livrables.sh 6 2 à l'étape H) : le script déduit seul l'IID du ticket (front-matter ticket_gitlab:) et le chemin de chaque livrable (glob var/claude/etape<n>/livrable-<k>-*.md), et résout tous les fichiers avant de publier quoi que ce soit (échec atomique si l'un manque). Effet remote irréversible (un commentaire reste visible même supprimé), d'où le passage obligatoire par un script lancé par l'utilisateur après relecture des fichiers. Il sert pour les trois livrables (cohérence E2E, résumé, évaluation).

L'append dans apprentissage.md (étape H du SKILL.md) est la seule écriture locale directe du main Claude dans ce flux. CLAUDE.md interdit normalement de toucher ce fichier ; ce skill constitue la demande explicite qui lève la restriction, exclusivement en mode append, exclusivement avec le format défini. Aucune lecture du contenu existant. Le skill ne commit pas lui-même, mais apprentissage.md fait partie du commit de clôture de l'étape (script séparé lancé par l'utilisateur) — il n'est plus laissé non commité.

Pas de fermeture du ticket ni de merge

ensure-parent-mr.sh crée la MR <parent_branch>main uniquement si elle n'existe pas encore (sinon il affiche la MR existante et sort en no-op). Le body porte systématiquement Closes #<parent_iid> — qu'il provienne du fichier mr-parente-body.md fourni ou du body minimal généré par le script.

Assignation de la MR parente

La MR parente est assignée systématiquement, par défaut à cpereira, surchargeable par GITLAB_ASSIGNEE :

ASSIGNEE="${GITLAB_ASSIGNEE:-cpereira}"
gl mr create  -a "$ASSIGNEE" 

glab mr create accepte le flag court -a (alias de --assignee). C'est la même convention que celle appliquée par analyser-etape (ticket parent et tâches) et implementer-lot (MR de lot) : tout objet GitLab créé par la chaîne de skills atterrit assigné.

Le skill ne merge jamais (gl mr create sans --auto-merge, aucun gl mr merge) et ne ferme jamais le ticket explicitement. C'est le merge manuel de la MR parente par l'utilisateur qui ferme automatiquement le ticket, via le Closes #<parent_iid>. Ce merge est le « done » de l'étape.

Pourquoi ce choix de conception

  • Le merge vers main est une décision humaine. C'est l'invariant déjà porté par implementer-lot, dont les MR de lot ciblent toujours la branche parente, jamais main. cloturer-etape est le seul skill autorisé à préparer la fusion vers main, mais il s'arrête à la création de la MR : aucun skill de la chaîne ne fusionne sur main automatiquement. Côté permissions, .claude/settings.json dénie déjà git push origin main et git merge au main Claude (cf. analyser-etape).
  • La fermeture découle du merge, pas l'inverse. Lier la fermeture du ticket au Closes #N garantit qu'un ticket n'est jamais marqué « done » sans que son travail soit réellement intégré dans main. Fermer le ticket par un appel séparé (glab issue close) avant le merge créerait un état incohérent : ticket fermé, code pas dans main.

Si l'utilisateur préfère malgré tout fermer le ticket sans merger, il peut lancer manuellement glab issue close <parent_iid> — mais ce n'est pas le flux nominal.

Place dans la chaîne des skills

cloturer-etape est le troisième et dernier maillon : /analyser-etape <n> cadre l'étape et crée les artefacts GitLab, /implementer-lot <n> <k> exécute chaque lot jusqu'au merge sur la branche parente, /cloturer-etape <n> vérifie que tout est terminé et prépare la fusion finale. Le output.json produit par analyser-etape (avec parent.iid et les gid des tâches) est la source de vérité partagée par les trois skills.

Voir aussi

  • .claude/skills/cloturer-etape/SKILL.md — description fonctionnelle du skill (invocation, déroulé étape par étape, livrables, erreurs à signaler).
  • Skill analyser-etape — le premier maillon : cadrage d'étape, création des work-items et des branches, asymétries glab work-itemsglab issue, query GraphQL workItems(iids:).
  • Skill implementer-lot — le maillon intermédiaire : exécution d'un lot, délégations reviewer / test-writer / doc-writer, MR de lot ciblant la branche parente (jamais main).
  • Hooks Claude Codeguard-delegation.sh (qui peut écrire où), garde-fous du main Claude et des sous-agents.
  • Workflow des agents Claude — pourquoi le main Claude délègue, et pourquoi le merge vers main reste une décision humaine.