diff --git a/.claude/rules/coordinator-discipline.md b/.claude/rules/coordinator-discipline.md index ee98d91184..320480a96a 100644 --- a/.claude/rules/coordinator-discipline.md +++ b/.claude/rules/coordinator-discipline.md @@ -4,6 +4,63 @@ S'applique au **coordinateur ai-01** (`myia-ai-01:CoursIA`), **chef de flotte** Detail complet (workflow batch merge + commandes + audit pre-merge + incidents + verbatims + mapping lanes + listes de rollout + 4-mecanismes de chaque regle) : [docs/secrets-and-coord-detail.md §2](../../docs/reference/secrets-and-coord-detail.md#2-coordinator-discipline-ai-01). +## Garde d'identite — mesurer sa lane AVANT d'armer une cadence (HARD) + +Mandat user 2026-09-11. Cette section est **nommee, pas numerotee** : R1-R6 sont +referencees par [lane-claim-protocol.md](lane-claim-protocol.md), +[proactive-coordination.md](proactive-coordination.md), +[variation-protocol.md](variation-protocol.md) et +[submodule-maintenance.md](submodule-maintenance.md) — les renumeroter casserait +ces renvois. + +**Avant tout `CronCreate` et avant tout merge**, une session qui s'apprete a +coordonner mesure son identite. Trois mesures, dont la troisieme n'est pas +automatisable : + +| # | Ce qui se mesure | Comment | +|---|---|---| +| 1 | **machine** | hostname normalise (`COMPUTERNAME` prime sur Windows) | +| 2 | **workspace** | basename du **clone** (worktree principal), pas du worktree courant | +| 3 | **unicite de session** | `ListAgents` **puis** un aller-retour `SendMessage` par pair `coursia-*` | + +Les deux premieres sont portees par l'organe — `exit 1` = ne pas armer : + +```bash +python scripts/check_coordinator_identity.py --expect coordinator +``` + +**La troisieme ne l'est pas, et l'organe l'ecrit dans chacun de ses verdicts** +(`uniqueness_measured: false`). Un `exit 0` dit « la lane est la bonne », +**jamais** « il est sur d'armer `/coordinate` » : les noms de session +(`coursia-0f`) **n'encodent pas la lane**, seule une reponse du pair la qualifie. +Deux sessions lancees du **meme** clone rendent d'ailleurs toutes deux `exit 0` et +le role COORDINATOR — `clone_ok` ne separe que des clones *distincts*. L'organe +porte la racine canonique et **retrograde en worker** (fail-CLOSED) une session +lancee depuis un clone jumeau, mais il ne tranche jamais l'unicite : c'est +l'aller-retour de la mesure 3 qui le fait. + +### Table de decision + +| Lane mesuree | Cadence a armer | +|---|---| +| `myia-ai-01:CoursIA`, **et** unique | `/coordinate` | +| `myia-ai-01:CoursIA`, **une autre session active sur la meme lane** | arbitrer : **une seule** garde `/coordinate`. A defaut d'accord, elle revient a **celle qui detient deja un cron `/coordinate` arme** ; si aucune ne l'a ou si les deux l'ont, a **la session demarree le plus tot** (`ListAgents` horodate les demarrages). L'autre cede **et passe en `git worktree add`** — deux sessions d'un meme clone partagent HEAD, l'index et le stash, et aucune garde ne rougit sur cette corruption-la ([[concurrent-sessions-share-the-working-tree]]) | +| `myia-po-2025:CoursIA-2` | `/coordinate-adjoint` | +| toute autre lane | `/continue` (worker) | + +Les deux criteres de defaut sont choisis pour etre **lisibles des deux cotes** : +chaque session peut rendre son `CronList` et son heure de demarrage. + +**`CronList` est session-locale** ([[session-local-view-read-as-global]]) : une +liste vide ne prouve rien au-dela de la session courante — ni pour un autre +workspace de la meme machine. Enchainer avec +[[handover-must-disarm-outgoing-cron]] : **desarmer la cadence sortante d'abord**, +armer ensuite. + +Incident fondateur (2026-09-11), justification des deux criteres de defaut, mesure +`coursia-1c`/`coursia-0f` et les deux pieges que cette garde ferme : +[§2.6](../../docs/reference/secrets-and-coord-detail.md#26-garde-didentite-de-lane--recit-arbitrage-et-pieges-2026-09-11). + ## Regle 0 : production avant digestion, sans perte de qualite (HARD) La production des lanes et la digestion (CI, reviews, merges) sont **deux pipelines paralleles**. Une saturation du second est un symptome a reparer ou a capaciter ; elle ne devient jamais une politique de ralentissement du premier. diff --git a/docs/reference/secrets-and-coord-detail.md b/docs/reference/secrets-and-coord-detail.md index 41e64a46f7..798f8d2f12 100644 --- a/docs/reference/secrets-and-coord-detail.md +++ b/docs/reference/secrets-and-coord-detail.md @@ -348,6 +348,62 @@ Les 4 mecanismes correctifs (chaque cycle, par lane) : **Tell d'auto-detection** (avant de poster un steer) : « (a) ce grain est-il verifie OPEN/non-sature firsthand a l'instant ? (b) la decision atteint-elle l'inbox du worker ? (c) ai-je tranche, ou defere ? ». Trois oui requis. Sinon = phantom, le worker idlera. +### 2.6 Garde d'identite de lane — recit, arbitrage et pieges (2026-09-11) + +> Deporte de [`.claude/rules/coordinator-discipline.md`](../../.claude/rules/coordinator-discipline.md) +> le 2026-09-13 (#15204, slimming du harnais auto-charge). **La garde n'a PAS ete deportee** : la table +> des trois mesures, l'invocation de l'organe, l'avertissement sur `exit 0` et la table de decision +> restent dans la rule, ou elles sont operatoires. Ce qui suit est le recit de l'incident et la +> justification des defauts d'arbitrage — le contexte qui explique *pourquoi* la garde a cette forme, +> pas la garde elle-meme. L'organe reel est `scripts/check_coordinator_identity.py` et ses tests : ils +> portent les deux premieres mesures et **ne coutent rien au contexte**. + +**Incident fondateur (2026-09-11).** Un reboot machine a tue la session coordinateur et son cron +(`CronCreate` est session-only, L740). Deux sessions CoursIA se sont retrouvees vivantes sur +`myia-ai-01` sans qu'aucun signal ne dise laquelle devait coordonner — `ListAgents` listait des **noms +de session**, pas des lanes. L'arbitrage s'est regle **par accord** au premier aller-retour : le defaut +deterministe n'a pas eu a jouer, et il ne faut pas lire cet episode comme son precedent. Ce qui a +departage est ce que la table de decision nomme desormais — une session portait le cron arme, l'autre +avait un `CronList` vide. + +**Pourquoi les deux criteres de defaut sont asymetriques.** « Celle qui detient deja un cron arme », +puis « la session demarree le plus tot » : chaque session peut rendre son `CronList` et son heure de +demarrage, donc les deux criteres sont **lisibles des deux cotes**. « Celle qui a detecte la collision +cede » ne l'est pas — une detection **simultanee** ferait ceder les deux et ne laisserait **aucun** +coordinateur, precisement ce que le defaut existe pour empecher. + +**La session qui cede change d'arbre, pas seulement de cadence.** Deux sessions sur la meme lane **et +le meme clone** partagent HEAD, l'index et le stash. Le double-cron n'est qu'un probleme de cadence ; +l'arbre de travail partage est un probleme de **corruption silencieuse** — un `checkout` / `rebase` / +`stash` d'un cote pendant une lecture de l'autre ne fait rougir aucune garde +([[concurrent-sessions-share-the-working-tree]]). La session qui cede passe donc en +`git worktree add`, et pas seulement sous `/continue`. + +**Ce que la garde ne discrimine pas — mesure du 2026-09-11.** `clone_ok` ne separe que des clones +*distincts*. Deux sessions lancees depuis `D:/CoursIA` rendent toutes deux `exit 0` et le role +COORDINATOR : mesure faite entre `coursia-1c` et `coursia-0f`. Ce qui tranche est **l'aller-retour de +la mesure 3**, jamais le code de sortie de l'organe — lequel le dit de lui-meme en rendant +`uniqueness_measured: false`. Il documente cet angle mort, il ne le resout pas. + +**Piege 1 — deux clones partagent une lane.** Sur ai-01, `D:/CoursIA` et `D:/dev/CoursIA` rendent tous +deux `myia-ai-01:CoursIA` : la chaine de lane ne les discrimine pas, seul le chemin le fait. L'organe +porte la racine canonique et **retrograde en worker** (fail-CLOSED) une session lancee depuis le +jumeau. + +**Piege 2 — `CronList` est session-locale** ([[session-local-view-read-as-global]]). Une liste vide ne +prouve rien au-dela de la session courante — surtout pas qu'aucun cron de coordination ne tourne +ailleurs sur la machine. **La recurrence est mesuree, et elle n'a pas la forme de la premiere** : le +2026-09-13, deux cadences de coordination tournaient simultanement sur `myia-ai-01`, aveugles l'une a +l'autre — un cycle 4 h arme depuis la lane `CoursIA`, un cycle 6 h arme depuis la lane +`roo-extensions`. La collision n'opposait donc pas deux sessions d'un meme workspace, mais **deux +workspaces d'une meme machine** : la chaine `machine:workspace` les distingue correctement, et +c'est la **vue** qui manquait, pas la mesure. Resolu par une cadence unique. L'ironie vaut d'etre +inscrite : #15648 est la PR qui construit la garde contre exactement cela, et l'incident s'est +reproduit sur son auteur pendant qu'elle attendait d'etre mergee. + +Enchainer avec [[handover-must-disarm-outgoing-cron]] : **desarmer la cadence sortante d'abord**, +armer ensuite. + diff --git a/scripts/check_coordinator_identity.py b/scripts/check_coordinator_identity.py new file mode 100644 index 0000000000..d4fada3b61 --- /dev/null +++ b/scripts/check_coordinator_identity.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +"""Garde d'identite de lane — mesurer sa lane AVANT d'armer une cadence. + +Une session qui arme `/coordinate` sans avoir mesure sa lane peut coordonner +depuis la mauvaise machine, le mauvais workspace, ou un clone jumeau. Cet organe +rend la mesure mecanique et son verdict opposable : + + lane = : + +ou `machine` est le hostname normalise et `workspace` le *basename* du clone +(worktree principal : un worktree secondaire appartient a la lane de son clone, +il n'en cree pas une nouvelle). Trois roles sont definis pour le cluster CoursIA (cf. +`.claude/rules/coordinator-discipline.md`, section « Garde d'identite ») : + + myia-ai-01:CoursIA -> COORDINATOR -> /coordinate + myia-po-2025:CoursIA-2 -> ADJOINT -> /coordinate-adjoint + toute autre lane -> WORKER -> /continue + +PORTEE DE LA MESURE (lire avant d'invoquer le verdict) : cet organe mesure la +LANE, et rien d'autre. Il ne peut PAS mesurer l'unicite de session — savoir si +une autre session CoursIA tourne deja sur la meme lane exige `ListAgents` PUIS +un aller-retour `SendMessage` (les noms de session n'encodent pas la lane), deux +gestes de niveau agent, hors de portee d'un script. Un `exit 0` dit donc « la +lane est la bonne », jamais « il est sur d'armer /coordinate ». + +Codes de sortie : + 0 -- role VERIFIE conforme : la lane mesuree correspond au role exige + par `--expect ` + 1 -- role VERIFIE non conforme ; la commande de repli est imprimee + 2 -- la mesure elle-meme a echoue (hors depot git, hostname introuvable) + 3 -- rapport seul (`--expect auto`, defaut) : la conformite n'est PAS + verifiee -- sous `auto` le role attendu serait le role mesure + (tautologie), le rapport le dit (`verified: false`) au lieu de la + pretendre +""" + +from __future__ import annotations + +import argparse +import json +import os +import socket +import subprocess +import sys +from pathlib import Path +from typing import Optional + +COORDINATOR_LANE = "myia-ai-01:CoursIA" +ADJOINT_LANE = "myia-po-2025:CoursIA-2" + +ROLE_COMMANDS = { + "coordinator": "/coordinate", + "adjoint": "/coordinate-adjoint", + "worker": "/continue", +} + +# Racine canonique par lane privilegiee. Deux clones d'un meme depot portent le +# MEME basename, donc la MEME lane : sur ai-01, `D:/CoursIA` et `D:/dev/CoursIA` +# rendent tous deux `myia-ai-01:CoursIA`. La lane ne les discrimine pas — seul +# le chemin le fait, d'ou cette table. +CANONICAL_ROOTS = { + COORDINATOR_LANE: "d:/coursia", +} + +# Index de consultation insensible a la casse : sur Windows/NTFS le basename +# du clone peut arriver en casse non canonique (`coursia` vs `CoursIA`), et la +# cle brute raterait la table -> `canonical is None` -> clone_ok vrai pour un +# jumeau, silencieusement. Seul le LOOKUP se normalise ; la lane RENDUE dans +# le rapport garde la casse reelle du dossier (identite affichee). +_CANONICAL_ROOTS_NORM = { + lane.lower(): root for lane, root in CANONICAL_ROOTS.items() +} + +# Ligne de portee rendue sur TOUS les chemins de sortie, y compris l'echec de +# mesure (exit 2) : c'est le garde-fou voulu omnipresent — un chemin d'erreur +# ne doit pas le faire disparaitre. +_PORTEE_LINE = ( + "PORTEE : l'unicite de session n'est PAS mesuree ici. " + "Avant d'armer, enumerer les pairs (`ListAgents`) et qualifier chacun " + "par un aller-retour `SendMessage` — un nom de session n'encode pas la lane." +) + + +def _normalise(path: str) -> str: + return path.replace("\\", "/").rstrip("/").lower() + + +def measure_machine() -> str: + """Hostname normalise. `COMPUTERNAME` prime sur Windows quand il est pose.""" + raw = os.environ.get("COMPUTERNAME") or socket.gethostname() + return raw.split(".")[0].strip().lower() + + +def _git(args: list[str], cwd: Path) -> Optional[str]: + try: + out = subprocess.run( + ["git", *args], cwd=str(cwd), capture_output=True, + text=True, encoding="utf-8", errors="replace", + timeout=30, check=False, + ) + except (OSError, subprocess.SubprocessError): + return None + if out.returncode != 0: + return None + value = out.stdout.strip() + return value or None + + +def measure_repo_root(start: Optional[Path] = None) -> Optional[Path]: + """Racine du CLONE (worktree principal), ou None hors depot. + + `--show-toplevel` rendrait le worktree courant : depuis un worktree + secondaire il donnerait son nom de dossier comme workspace, fabriquant une + lane inexistante. Le worktree principal se deduit de `--git-common-dir`, + qui pointe le `.git` partage par tous les worktrees du clone. + """ + cwd = start or Path.cwd() + common = _git(["rev-parse", "--git-common-dir"], cwd) + if common is None: + return None + common_path = Path(common) + if not common_path.is_absolute(): + common_path = (cwd / common_path).resolve() + if common_path.name == ".git": + return common_path.parent + # Depot bare ou disposition inhabituelle : retomber sur le worktree courant. + top = _git(["rev-parse", "--show-toplevel"], cwd) + return Path(top) if top else None + + +def role_for_lane(lane: str) -> str: + # Comparaison insensible a la casse : le basename du clone garde sa casse + # reelle (NTFS), elle ne doit pas decider du role d'une lane privilegiee. + key = lane.lower() + if key == COORDINATOR_LANE.lower(): + return "coordinator" + if key == ADJOINT_LANE.lower(): + return "adjoint" + return "worker" + + +def build_report(expect: str) -> dict: + machine = measure_machine() + root = measure_repo_root() + if root is None: + # `uniqueness_measured` est une constante de FORME du rapport, pas une + # propriete du chemin nominal. Un consommateur qui PARSE le JSON — le + # mode le plus probable pour un wrapper de cadence — doit trouver le + # garde-fou sur `exit 2` aussi, exactement comme `_PORTEE_LINE` survit + # au rendu humain de l'echec. Sans cette cle ici, l'affirmation « l'organe + # l'ecrit dans chacun de ses verdicts » est fausse sur ce chemin : le + # garde-fou survivait en prose, pas en champ. + return { + "ok": False, + "verified": False, + "error": "hors depot git : le workspace ne peut pas etre mesure", + "machine": machine, + "workspace": None, + "lane": None, + "role": None, + "expect": expect, + "uniqueness_measured": False, + } + + workspace = root.name + # La lane garde la casse REELLE du dossier (identite affichee) ; seules + # les consultations de tables se font en casse normalisee. + lane = f"{machine}:{workspace}" + role = role_for_lane(lane) + + canonical = _CANONICAL_ROOTS_NORM.get(lane.lower()) + measured_root = _normalise(str(root)) + clone_ok = canonical is None or measured_root == canonical + + if not clone_ok: + # Bonne machine, bon nom de workspace, mauvais clone : la lane seule ne + # peut pas le voir. On retrograde en worker plutot que de coordonner + # depuis un jumeau (fail-CLOSED). + role = "worker" + + # Forme (a) retenue pour `auto` : le rapport cesse de pretendre verifier. + # `ok` vaudrait `role == role` (tautologie), donc il devient None, le + # rapport porte `verified: false`, la ligne rendue dit « rapporte, non + # verifie » et le code de sortie (3) se distingue de 0/1. L'unique + # appelant repertorie du depot (.claude/rules/coordinator-discipline.md) + # passe deja `--expect coordinator` explicite : aucun appelant existant + # ne consomme le mode auto, qui reste ouvert pour la decouverte de lane. + if expect == "auto": + expected_role = None + ok = None + verified = False + else: + expected_role = expect + ok = role == expected_role + verified = True + return { + "ok": ok, + "verified": verified, + "error": None, + "machine": machine, + "workspace": workspace, + "repo_root": str(root), + "canonical_root": canonical, + "clone_ok": clone_ok, + "lane": lane, + "role": role, + "expect": expect, + "expected_role": expected_role, + "command": ROLE_COMMANDS[role], + "uniqueness_measured": False, + } + + +def render(report: dict) -> str: + if report.get("error"): + # La portee survit AUSSI au chemin d'echec : c'est le garde-fou voulu + # omnipresent, un exit 2 ne doit pas le faire disparaitre. + return ( + f"MESURE IMPOSSIBLE : {report['error']}\n" + f" machine : {report['machine']}\n{_PORTEE_LINE}" + ) + + lines = [ + f"machine : {report['machine']}", + f"workspace : {report['workspace']} (racine {report['repo_root']})", + f"lane : {report['lane']}", + f"role : {report['role'].upper()} -> cadence `{report['command']}`", + ] + if not report["clone_ok"]: + lines.append( + f"CLONE : racine hors canonique ({report['canonical_root']}) — " + "lane retrogradee en WORKER (fail-CLOSED)" + ) + lines.append(_PORTEE_LINE) + if not report["verified"]: + lines.append( + f"VERDICT : role mesure `{report['role']}` — rapporte, non " + "verifie ; passer `--expect ` pour un verdict opposable." + ) + elif not report["ok"]: + lines.append( + f"VERDICT : role attendu `{report['expect']}`, role mesure " + f"`{report['role']}` — ne PAS armer `{ROLE_COMMANDS[report['expect']]}`, " + f"armer `{report['command']}`." + ) + else: + lines.append(f"VERDICT : lane conforme au role `{report['expect']}`.") + return "\n".join(lines) + + +def main(argv: Optional[list[str]] = None) -> int: + parser = argparse.ArgumentParser( + description="Mesure la lane de la session et le role qui en decoule." + ) + parser.add_argument( + "--expect", + choices=["auto", "coordinator", "adjoint", "worker"], + default="auto", + help="role attendu ; `auto` (defaut) RAPPORTE le role sans le verifier " + "(exit 3) — pour un verdict opposable, exiger un role explicite", + ) + parser.add_argument("--json", action="store_true", help="sortie JSON") + args = parser.parse_args(argv) + + report = build_report(args.expect) + print(json.dumps(report, indent=2, ensure_ascii=False) if args.json else render(report)) + + if report.get("error"): + return 2 + if not report["verified"]: + # Mode rapport : aucun verdict de conformite n'a ete rendu — code de + # sortie distinct de 0/1 pour qu'un wrapper ne le lise pas « conforme ». + return 3 + return 0 if report["ok"] else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/tests/test_check_coordinator_identity.py b/scripts/tests/test_check_coordinator_identity.py new file mode 100644 index 0000000000..d400439dee --- /dev/null +++ b/scripts/tests/test_check_coordinator_identity.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Unit tests for check_coordinator_identity.py -- the lane identity guard. + +Founding incident (2026-09-11): a machine reboot killed the coordinator session +and its cron. Two CoursIA sessions were then live on `myia-ai-01` and nothing in +`ListAgents` said which one should coordinate -- session names do not encode the +lane. Worse, ai-01 carries *two* clones of the repo (`D:/CoursIA` and +`D:/dev/CoursIA`) whose basenames are identical, so the lane string alone cannot +tell them apart either. + +The cases below are the ones the guard exists to catch; a detection pattern is +validated by its false negatives, so each one asserts the *downgrade*, not just +the happy path. + +Run: + python -m pytest scripts/tests/test_check_coordinator_identity.py +""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import check_coordinator_identity as cci # noqa: E402 + + +def _report(monkeypatch, machine, root, expect="auto"): + monkeypatch.setattr(cci, "measure_machine", lambda: machine) + monkeypatch.setattr(cci, "measure_repo_root", lambda start=None: Path(root)) + return cci.build_report(expect) + + +def test_coordinator_lane_on_canonical_clone(monkeypatch): + r = _report(monkeypatch, "myia-ai-01", "D:/CoursIA", "coordinator") + assert r["lane"] == "myia-ai-01:CoursIA" + assert r["role"] == "coordinator" + assert r["command"] == "/coordinate" + assert r["ok"] is True + + +def test_twin_clone_same_lane_string_is_downgraded(monkeypatch): + """The case the lane string cannot see: same lane, different clone.""" + r = _report(monkeypatch, "myia-ai-01", "D:/dev/CoursIA", "coordinator") + assert r["lane"] == "myia-ai-01:CoursIA" # identical to the canonical one + assert r["clone_ok"] is False + assert r["role"] == "worker" # fail-CLOSED + assert r["command"] == "/continue" + assert r["ok"] is False + + +def test_adjoint_lane_routes_to_coordinate_adjoint(monkeypatch): + r = _report(monkeypatch, "myia-po-2025", "D:/CoursIA-2") + assert r["lane"] == "myia-po-2025:CoursIA-2" + assert r["role"] == "adjoint" + assert r["command"] == "/coordinate-adjoint" + + +def test_coordinator_workspace_on_another_machine_is_a_worker(monkeypatch): + """Right workspace name, wrong machine -- the reboot-clone failure mode.""" + r = _report(monkeypatch, "myia-po-2027", "D:/CoursIA", "coordinator") + assert r["role"] == "worker" + assert r["ok"] is False + + +def test_coursia_2_on_ai01_is_a_worker_not_the_adjoint(monkeypatch): + """`CoursIA-2` alone does not make an adjoint -- the machine is half the lane.""" + r = _report(monkeypatch, "myia-ai-01", "D:/CoursIA-2") + assert r["role"] == "worker" + assert r["command"] == "/continue" + + +def test_uniqueness_is_never_claimed(monkeypatch): + """Scope is written into EVERY verdict -- the measurement-failure path included. + + This docstring already said "every verdict" while the body asserted only the + nominal path, and the early return on `root is None` shipped a dict *without* + the key: a JSON consumer on exit 2 found no guard field at all, while the + human render kept its PORTEE line. The guard survived in prose, not in field. + + A claim of omnipresence is validated by the path most likely to drop it, so + both paths are asserted here -- in field AND in prose. + """ + r = _report(monkeypatch, "myia-ai-01", "D:/CoursIA", "coordinator") + assert r["uniqueness_measured"] is False + assert "unicite" in cci.render(r).lower() + + monkeypatch.setattr(cci, "measure_repo_root", lambda start=None: None) + err = cci.build_report("coordinator") + assert err["error"] is not None # on est bien sur le chemin d'echec + assert err["uniqueness_measured"] is False + assert "unicite" in cci.render(err).lower() + + +def test_outside_a_git_repo_fails_loudly(monkeypatch): + monkeypatch.setattr(cci, "measure_machine", lambda: "myia-ai-01") + monkeypatch.setattr(cci, "measure_repo_root", lambda start=None: None) + r = cci.build_report("coordinator") + assert r["ok"] is False + assert r["error"] is not None + # Assertion reelle (l'ancienne `cci.main is not None` etait vacante : + # vraie pour n'importe quel symbole importe) : le chemin d'erreur rend + # bien l'erreur. + assert "MESURE IMPOSSIBLE" in cci.render(r) + + +def test_twin_clone_named_in_non_canonical_case_is_caught(monkeypatch): + """NTFS: a twin clone whose basename case misses the raw table key. + + Before the fix, `myia-ai-01:coursia` missed CANONICAL_ROOTS entirely + (raw key lookup), so `canonical was None` made clone_ok silently True + and the twin guard never ran. + """ + r = _report(monkeypatch, "myia-ai-01", "D:/dev/coursia", "coordinator") + assert r["lane"] == "myia-ai-01:coursia" # real case preserved (display identity) + assert r["canonical_root"] == "d:/coursia" # lookup found it despite the case + assert r["clone_ok"] is False # twin detected, not silently passed + assert r["role"] == "worker" # fail-CLOSED, exactly like D:/dev/CoursIA + assert r["ok"] is False + + +def test_canonical_clone_spelled_in_non_canonical_case_still_coordinates(monkeypatch): + """`D:/coursia` IS the canonical clone on case-insensitive NTFS: no false positive. + + Case-insensitive lookup must not downgrade the canonical clone when its + path is merely spelled with a different case. + """ + r = _report(monkeypatch, "myia-ai-01", "D:/coursia", "coordinator") + assert r["clone_ok"] is True + assert r["role"] == "coordinator" + assert r["ok"] is True + + +def test_auto_default_reports_without_claiming_conformity(monkeypatch, capsys): + """Default mode (`--expect auto`) cannot render a conformity verdict. + + Before the fix, `expected_role = role` made `ok` tautologically True and + exit code 0 unconditional -- the default invocation could never fail. + """ + r = _report(monkeypatch, "myia-ai-01", "D:/CoursIA") # expect defaults to "auto" + assert r["ok"] is None + assert r["verified"] is False + assert cci.main([]) == 3 # distinct from 0 (verified conform) and 1 (verified not) + out = capsys.readouterr().out + assert "non verifie" in out.lower() + + +def test_wrong_expect_exits_1(monkeypatch, capsys): + """Positive control: an explicit wrong --expect still yields exit code 1.""" + monkeypatch.setattr(cci, "measure_machine", lambda: "myia-ai-01") + monkeypatch.setattr(cci, "measure_repo_root", lambda start=None: Path("D:/CoursIA")) + assert cci.main(["--expect", "worker"]) == 1 + + +def test_outside_repo_exit_2_keeps_portee_line(monkeypatch, capsys): + """Exit-2 path: exit code asserted, and the PORTEE guard line survives. + + Before the fix, render() returned early on error and the PORTEE line + (the wanted-omnipresent scope guard) disappeared from the exit-2 output. + """ + monkeypatch.setattr(cci, "measure_machine", lambda: "myia-ai-01") + monkeypatch.setattr(cci, "measure_repo_root", lambda start=None: None) + assert cci.main(["--expect", "coordinator"]) == 2 + out = capsys.readouterr().out + assert "PORTEE" in out + assert "unicite" in out.lower()