Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/skills/coordinate/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ Les phases ci-dessous s'executent sous le budget defini par la section `## Budge
4. **Lecture B.0 personnelle minimale avant chaque merge -- non delegable, seulement APRES gate vert** : body + comments + reviews + diff ("Read Body Before Any Action") ; etat A L'INSTANT-T via `gh pr view N --json state,mergedAt,mergeStateStatus,reviews` (jamais depuis le dashboard ni le cycle N-1) ; organe `python scripts/check_unaddressed_nits.py <PR>` (exit 1 = ne pas merger ; son vert ne dispense pas de la lecture). Verifier seulement le dossier, le delta et la preuve decisive ; ne pas rejouer l'audit complet. Une levee porte un auteur et une heure.
5. **Gates de merge** : un preflight READY n'autorise jamais le merge. Appliquer encore B.0, latest-wins CI, H.4 (notebooks : checkout + Papermill local OU log dans le body), catalogue byte-identique a main (`gh pr view N --json files`), scope reel = titre, ordre de stack, variation et relecture de la queue de commentaires.
6. **Merge** : sous `myia-ai-01` (droit `MergePullRequest` verifie firsthand 2026-08-08), avec `gh pr merge <N> --repo jsboige/CoursIA --squash --match-head-commit <SHA>` (`--merge` preserve-SHA pour la base d'un stack), **JAMAIS `--delete-branch`**.
7. **Approuver ce qui est lu mais pas encore mergeable** (arbitrage user 2026-09-28, Q67) : une PR hors harnais et hors DEEP dont la lecture du point 4 est faite, mais dont le dossier n'est pas encore READY (minuteur DWELL, jambe a rejouer, dossier a re-tamponner), recoit une review `APPROVED` sous `myia-ai-01` a la tete lue. `merge_ready` la merge des que le dossier est pret ; un rafraichissement de base sans conflit ne perime pas cette approbation, un commit de contenu ou une resolution de conflit la perime. Sans cette review, l'organe ne merge rien : il n'est plus une voie de merge sans lecteur.

### Phase 4bis - Passe issues, sur dossiers de fermeture (mandat user 2026-09-26)

Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ Notation étudiants : moteur générique = [GradeBookApp/configs/README.md](Grad

**Reporting dashboard** : poster au minimum début/livraison/fin de session. > 30 min sans post = signe d'isolement. Posts `[INFO]` courts > silence.

**Git** : pas de push direct sur `main`. **Force push** : interdit sur `main` (porté par `allow_force_pushes: false`), autorisé sur une branche de PR qu'une **seule** lane manipule (`--force-with-lease`, l'alternative merge d'abord) — décision user 2026-08-08. Pas de `reset --hard` sur `main` ni sur une branche partagée. Branches `feature/<sujet>` ou `fix/<sujet>`, un sujet par PR. Le coordinateur (ai-01) review et merge ; les agents ne mergent pas eux-mêmes. **Exception outillée** (mandat user 2026-09-22) : l'organe `scripts/coordination/merge_ready.py`, exécuté sous l'identité ai-01, merge sans attendre le cycle coordinateur une PR **hors harnais** (ni `.claude/`, ni `CLAUDE.md`, ni `.github/`) et **hors `DEEP`** qui passe gate rc=0 + B.0 rc=0 + `b0: clear` au dossier + `mergeable_state: clean` à la tête exacte ; dans ce périmètre, la lecture manuelle de B.0 (qui, quand, substance) est portée par le dossier de l'adjoint. Tout le reste reste au coordinateur, qui audite l'organe a posteriori. Cf [git-workflow.md](.claude/rules/git-workflow.md).
**Git** : pas de push direct sur `main`. **Force push** : interdit sur `main` (porté par `allow_force_pushes: false`), autorisé sur une branche de PR qu'une **seule** lane manipule (`--force-with-lease`, l'alternative merge d'abord) — décision user 2026-08-08. Pas de `reset --hard` sur `main` ni sur une branche partagée. Branches `feature/<sujet>` ou `fix/<sujet>`, un sujet par PR. Le coordinateur (ai-01) review et merge ; les agents ne mergent pas eux-mêmes. **Exception outillée** (mandat user 2026-09-22, resserré le 2026-09-28) : l'organe `scripts/coordination/merge_ready.py`, exécuté sous l'identité ai-01, merge sans attendre le cycle coordinateur une PR **hors harnais** (ni `.claude/`, ni `CLAUDE.md`, ni `.github/`) et **hors `DEEP`** **que le coordinateur a approuvée** — review `APPROVED` de `myia-ai-01` qui couvre le contenu de la tête (posée sur elle, ou séparée d'elle par des seuls rafraîchissements de base sans conflit) — et qui passe gate rc=0 + B.0 rc=0 + `b0: clear` au dossier + `mergeable_state: clean` à la tête exacte. L'organe ne remplace pas la lecture du coordinateur : il évite seulement qu'un dossier se périme entre cette lecture et le merge. Tout le reste reste au coordinateur. Cf [git-workflow.md](.claude/rules/git-workflow.md).

**Gouvernance des règles** : tout changement normatif substantiel du harnais (`CLAUDE.md`, `.claude/rules/**`) exige une PR et un sign-off user avant merge. Est substantiel ce qui ajoute une obligation ou une interdiction, crée une règle HARD, durcit ou élargit une prescription, change une autorité, un droit d'action ou une escalade, ou transforme une recommandation en gate. Un mandat user direct vaut sign-off. Une correction de typo ou de lien, une clarification qui ne change aucune prescription, un déplacement fidèle du détail vers `docs/`, ou un slimming qui préserve exactement la règle n'exige pas de sign-off supplémentaire ; la PR et la review restent obligatoires dans tous les cas.

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/agent-cloud-agnosticisme.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Le compte GitHub est partagé avec la flotte : une PR de cette session se reconn
La session **produit** les PRs ; **la flotte les porte jusqu'au merge**, comme les siennes (mandat du mainteneur : économie de tokens, charge mentale retirée au mainteneur) :

- la session **s'abonne aux événements** de ses PRs (CI, reviews, commentaires) : le suivi ne repose plus sur le mainteneur ;
- l'adjoint prévalide (`[ADJOINT PREFLIGHT]`), ai-01 merge ; `scripts/coordination/merge_ready.py` s'applique aux PRs hors harnais ;
- l'adjoint prévalide (`[ADJOINT PREFLIGHT]`), ai-01 lit et merge ; `scripts/coordination/merge_ready.py` merge les PRs hors harnais qu'ai-01 a approuvées ;
- **réparation déléguée** : sur un check rouge ou une réserve, la session poste le diagnostic sur la PR (ce qui échoue, pourquoi, correctif proposé) puis ouvre une **issue de réparation** qui renvoie à la PR — le pool d'issues est le canal où les lanes tirent leur travail. Elle ne corrige elle-même que lorsque le correctif est plus court que l'issue ;
- une lane peut rafraîchir la branche (`update-branch`) ou y pousser la correction, en nommant le script et le commit (règle 0 de [proactive-coordination.md](../../.claude/rules/proactive-coordination.md)) ;
- à chaque tour, la session intègre ce qui a changé — corrections de la flotte, décisions, remède meilleur que le sien — puis enchaîne sur la PR suivante.
Expand Down
29 changes: 27 additions & 2 deletions scripts/ci/merge_dwell.py
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,31 @@ def last_authoritative_committed_at(
) -> datetime:
"""#16149 : date de COMMITTER du dernier commit qui modifie le cote PR.

La remontee est celle de `last_authoritative_sha` ; seule la date du
commit atteint est rendue ici."""
current, payload = _walk_authoritative(repo, sha, base_sha, fetch, run_git)
return _committer_date(payload, current)


def last_authoritative_sha(
repo: str,
sha: str,
base_sha: str,
fetch=_gh_json,
run_git=None,
) -> str:
"""SHA du dernier commit qui modifie le cote PR (meme remontee que le
plancher DWELL). Deux tetes qui rendent le meme SHA ne different que par
des rafraichissements de base PROUVES content-free : c'est ce que lit
`merge_ready` pour savoir si l'approbation du coordinateur, posee sur une
tete anterieure, couvre encore le contenu de la tete courante."""
current, _payload = _walk_authoritative(repo, sha, base_sha, fetch, run_git)
return current


def _walk_authoritative(repo, sha, base_sha, fetch, run_git):
"""Remontee first-parent commune au plancher DWELL et a `merge_ready`.

Remonte la chaine first-parent au-dela des fusions de rafraichissement
de base PROUVEES content-free : deux parents, le SECOND ancetre de la
base, ET l'arbre du commit identique a l'auto-merge des parents (CR
Expand Down Expand Up @@ -515,8 +540,8 @@ def last_authoritative_committed_at(
if auto_tree and merge_tree and auto_tree == merge_tree:
current = first
continue
return _committer_date(payload, current)
return _committer_date(payload, current)
return current, payload
return current, payload
raise DwellError(
"chaine first-parent de plus de {} fusions de base depuis {} "
"-- etat pathologique, refus".format(_MAX_WALK, sha[:12])
Expand Down
18 changes: 12 additions & 6 deletions scripts/coordination/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,19 +255,25 @@ d'enveloppe du producteur (`data.intercom.messages`, auteur normalise).
## Organe `merge_ready` (Q40, 2026-09-22)

Fusion hors cycle coordinateur : un organe deterministe (identite myia-ai-01,
cadence ~20 min) qui merge UNIQUEMENT ce qui passe exactement les controles du
coordinateur lui-meme, en perimetre (b) uniquement -- hors harnais (`.claude/`,
`CLAUDE.md` a tout niveau, `.github/`) et hors grains `DEEP`. Motivation
mesuree : 97 merges en 24 h sur 4 creneaux, 12 heures vides, lead time median
28,5 h ; un dossier d'adjoint perit en attendant le cycle.
cadence ~20 min) qui merge UNIQUEMENT ce que le coordinateur a lu et approuve,
en perimetre (b) uniquement -- hors harnais (`.claude/`, `CLAUDE.md` a tout
niveau, `.github/`) et hors grains `DEEP`. Motivation mesuree : 97 merges en
24 h sur 4 creneaux, 12 heures vides, lead time median 28,5 h ; un dossier
d'adjoint perit en attendant le cycle. L'organe evite cette peremption, il ne
remplace pas la lecture (arbitrage user 2026-09-28, Q67 : jusque-la 6 des 211
merges du journal portaient une approbation `myia-ai-01`).

Par PR (la plus ancienne d'abord), TOUT doit tenir sinon skip avec raison
nommee au journal : pas un brouillon + un commentaire `[ADJOINT PREFLIGHT]`
(prefiltre), perimetre fail-closed (liste de fichiers complete -- `changedFiles`
superieur aux fichiers listes = skip -- et tier du tag `Grain:` lu par le parseur
partage `scripts/grain_tag.py`), pre-controle bon marche du dernier dossier
(tete perimee ou `b0:` non clear = skip sans payer le gate ; illisible = decision
laissee au gate), gate `check_adjoint_prevalidation.py` a
laissee au gate), approbation du coordinateur (derniere voix `myia-ai-01` =
`APPROVED` reel, posee sur la tete ou sur une tete dont celle-ci ne differe que
par des rafraichissements de base prouves content-free -- meme remontee que le
plancher DWELL, `merge_dwell.last_authoritative_sha` ; absente, perimee ou
illisible = skip avant le gate), gate `check_adjoint_prevalidation.py` a
`ready: true`, champ `b0:` du dossier accepte relu via la grammaire du gate
(`parse_dossier` importe), organe B.0 `check_unaddressed_nits.py` a exit 0,
`mergeable_state` REST a `clean` (retry sur `unknown` -- apres un merge les
Expand Down
144 changes: 131 additions & 13 deletions scripts/coordination/merge_ready.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,17 @@
discussion ayant bouge APRES le dossier, 0 un refus de fond. Un dossier
perit pendant qu'il attend le coordinateur.

Arbitrage user 2026-09-28 (Q67, option a) : l'organe ne merge QUE ce que le
coordinateur a LU et APPROUVE. Il ne remplace pas cette lecture ; il evite
seulement qu'un dossier perisse entre elle et le merge. Jusque-la, la
disposition de review n'etait que relevee : sur les 211 merges du journal
(23/09 -> 28/09), 6 portaient une approbation ``myia-ai-01``, et toute lane
ne portant pas la PR pouvait la faire merger par son seul dossier.

L'organe est deterministe et tourne sous l'identite coordinateur
(myia-ai-01) toutes les ~20 minutes : il ne merge QUE ce qui passe
EXACTEMENT les controles du coordinateur lui-meme, et seulement en
perimetre (b) -- hors harnais et hors grains DEEP. Tour de controle par
PR, TOUT doit tenir sinon skip avec raison nommee :
(myia-ai-01) toutes les ~20 minutes, et seulement en perimetre (b) -- hors
harnais et hors grains DEEP. Tour de controle par PR, TOUT doit tenir sinon
skip avec raison nommee :

1. pas un brouillon, et au moins un commentaire d'issue dont la premiere
ligne est ``[ADJOINT PREFLIGHT]`` (prefiltre bon marche avant le gate
Expand All @@ -31,6 +37,18 @@
2bis. pre-controle du dernier dossier ``[ADJOINT PREFLIGHT]`` : tete
perimee ou ``b0:`` different de ``clear`` -> skip SANS payer le gate
(un dossier illisible est laisse au gate, qui tranche) ;
2ter. approbation du coordinateur : la DERNIERE voix de ``myia-ai-01``
(etat reel ``APPROVED``/``CHANGES_REQUESTED``, ou verdict type en
corps -- latest-wins, ``DISMISSED`` jamais) est un ``APPROVED`` reel,
et elle couvre le CONTENU de la tete : soit elle est posee sur la tete
elle-meme, soit la tete n'en differe que par des rafraichissements de
base PROUVES content-free (``merge_dwell.last_authoritative_sha``, la
remontee du plancher DWELL : un ``update-branch`` sans conflit ne
perime pas la lecture, une resolution de conflit ou un commit de
contenu la perime). Absente -> ``no-coordinator-approval`` ; perimee
-> ``coordinator-approval-stale`` ; preuve illisible ->
``coordinator-approval-unverifiable`` (fail-closed). Controle place
AVANT le gate : une PR non lue ne paie pas le gate ;
3. gate d'entree ``check_adjoint_prevalidation.py <PR> --json`` ->
``"ready": true`` (exit 0). Les rc documents du gate (1 no-dossier,
2 unknown, 3 blocked) sont des SKIPS nommes, pas des erreurs ;
Expand Down Expand Up @@ -80,13 +98,11 @@
(``hold:<motif>``) AVANT tout appel gh. Fichier absent = aucune
retenue ; fichier illisible ou ligne malformee -> exit 2 (on ne merge
pas sans savoir ce qui est retenu).
- Review : la disposition de review est CLASSEE a la tete que la ligne
declare (``approved-exact-head`` / ``approval-not-on-head`` /
``no-approval``, point 1 de #17672) depuis les ``reviews`` de la meme
vue -- l'oid de review y figure, aucun appel supplementaire. Le
dossier hache cet oid sans le comparer a la tete ; c'est cette
comparaison qui manquait. Elle informe, elle ne bloque pas : le
dossier READY a la tete exacte reste le contrat d'entree.
- Review : deux lectures distinctes. La disposition journalisee (champ
``review``) est CLASSEE a la tete que la ligne declare
(``approved-exact-head`` / ``approval-not-on-head`` / ``no-approval``,
point 1 de #17672) sur TOUTES les voix, bots compris ; elle informe.
Ce qui BLOQUE est l'etape 2ter : la seule voix du coordinateur.
- Journal : une ligne JSON par PR evaluee (ts UTC en Z, pr, head,
verdict, reason, merged, review) dans
``%LOCALAPPDATA%/CoursIA/merge_ready/journal.jsonl`` (surchargeable
Expand Down Expand Up @@ -145,6 +161,11 @@
sys.path.insert(0, str(CI_DIR))

import pool_review_verdicts as review_canon # noqa: E402
# La remontee first-parent qui saute les rafraichissements de base PROUVES
# content-free est celle du plancher DWELL (#16149) : importee, pas recopiee,
# pour que « la lecture du coordinateur couvre-t-elle encore cette tete ? » et
# « depuis quand le contenu n'a-t-il pas bouge ? » ne divergent jamais.
import merge_dwell # noqa: E402

REPO = "jsboige/CoursIA"
COORDINATOR_USER = "myia-ai-01"
Expand Down Expand Up @@ -481,8 +502,8 @@ def list_open_prs(runner: Runner, gh_env: dict[str, str]) -> list[int]:
#: review se classe sans appel supplementaire (l'oid de review y figure, mesure
#: du 2026-09-25 : ``gh pr view --json reviews`` rend ``commit.oid``).
PR_VIEW_FIELDS = (
"number,title,isDraft,body,headRefName,headRefOid,files,changedFiles,"
"comments,reviews"
"number,title,isDraft,body,headRefName,headRefOid,baseRefOid,files,"
"changedFiles,comments,reviews"
)


Expand Down Expand Up @@ -557,6 +578,95 @@ def review_disposition(view: dict, head: str) -> str:
return NO_APPROVAL


# --- approbation du coordinateur (etape 2ter, Q67 option a) --------------------


def coordinator_approval_oid(view: dict) -> str | None:
"""Oid du commit que la DERNIERE voix de ``myia-ai-01`` approuve, ou None.

Latest-wins sur les seules voix du coordinateur, lues comme le canon (etat
reel, puis verdict type en corps) : un ``CHANGES_REQUESTED`` ou un
``VERDICT: CONCERNS`` posterieur retire l'approbation, un ``COMMENTED``
sans verdict (une phrase de levee, par exemple) ne la retire pas. Seul
l'etat REEL ``APPROVED`` vaut approbation : c'est le geste explicite de
lecture, pas un jeton de corps.
"""
voices = [
row
for row in view.get("reviews") or []
if isinstance(row, dict)
and ((row.get("author") or {}).get("login")) == COORDINATOR_USER
and _review_voice_state(row)
]
if not voices:
return None
latest = max(voices, key=lambda row: str(row.get("submittedAt") or ""))
if str(latest.get("state") or "") != "APPROVED":
return None
oid = str(((latest.get("commit") or {}).get("oid")) or "")
return oid or None


def _dwell_fetch(runner: Runner, gh_env: dict[str, str]):
"""``fetch`` de ``merge_dwell`` passe par le runner, jeton epingle."""

def fetch(path: str) -> object:
res = runner.run(["gh", "api", path], env=gh_env)
if res.returncode != 0:
raise merge_dwell.DwellError(f"gh api {path} rc={res.returncode}")
try:
return json.loads(res.stdout)
except json.JSONDecodeError as exc:
raise merge_dwell.DwellError(f"gh api {path} : reponse non-JSON") from exc

return fetch


def _dwell_git(runner: Runner):
"""``run_git`` de ``merge_dwell`` passe par le runner, dans ce depot."""

def run_git(args: list[str]) -> tuple[int, str]:
res = runner.run(["git", "-C", str(REPO_ROOT), *args])
return res.returncode, res.stdout

return run_git


def coordinator_approval_reason(
view: dict, head: str, runner: Runner, gh_env: dict[str, str]
) -> str | None:
"""Etape 2ter : ``None`` si l'approbation du coordinateur couvre ``head``.

Une approbation posee sur une tete anterieure couvre encore la tete
courante si les deux ne different que par des rafraichissements de base
prouves content-free : les deux tetes remontent alors au MEME dernier
commit de contenu. Toute autre difference perime la lecture ; une preuve
illisible vaut refus.
"""
approved = coordinator_approval_oid(view)
if approved is None:
return "no-coordinator-approval"
if approved == head:
return None
base = str(view.get("baseRefOid") or "")
if not base:
return "coordinator-approval-unverifiable:no-base"
fetch = _dwell_fetch(runner, gh_env)
run_git = _dwell_git(runner)
try:
current = merge_dwell.last_authoritative_sha(
REPO, head, base, fetch=fetch, run_git=run_git
)
read = merge_dwell.last_authoritative_sha(
REPO, approved, base, fetch=fetch, run_git=run_git
)
except merge_dwell.DwellError:
return "coordinator-approval-unverifiable"
if current == read:
return None
return "coordinator-approval-stale"


def precheck_dossier(view: dict) -> str | None:
"""Pre-controle bon marche, AVANT le gate : le dernier dossier visible dans la
vue est-il a la tete courante, et declare-t-il ``b0: clear`` ?
Expand Down Expand Up @@ -806,12 +916,20 @@ def skip(reason: str) -> PRVerdict:
if reason is not None:
return skip(reason)
reason = precheck_dossier(view)
if reason is not None:
return skip(reason)
# 2ter. approbation du coordinateur (Q67) : une PR non lue ne paie pas le gate.
reason = coordinator_approval_reason(view, view_head, runner, gh_env)
if reason is not None:
return skip(reason)
# 3. gate d'entree.
ready, gate_head, gate_reason = run_gate(runner, pr, gh_env)
if not ready:
return skip(gate_reason)
# L'approbation a ete jugee a la tete de la vue : une tete differente au
# gate n'a pas ete couverte par ce jugement.
if gate_head != view_head:
return skip("head-moved")
# 4. b0 declaratif du dossier accepte.
reason = dossier_b0_reason(runner, pr, gh_env)
if reason is not None:
Expand Down
Loading
Loading