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
6 changes: 3 additions & 3 deletions .claude/skills/coordinate/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ Les phases ci-dessous s'executent sous le budget defini par la section `## Budge
|---|---|---|
| **0** | dossier integre + `verdict: READY` | ouvrir body, commentaires, reviews, threads, diff ; lire B.0 ; merger si les gates du point 5 passent |
| **3** | dossier integre + `verdict: BLOCKED` | **n'ouvrir AUCUNE surface** — dispatcher a la lane auteur depuis le motif atteste par le dossier, et passer a la candidate suivante |
| **1** | pas de dossier digne de confiance (absent, malforme, perime, mauvaise lane/auteur, empreinte cassee) | router la candidate a l'adjoint `myia-po-2025:CoursIA-2`, l'exclure jusqu'a un dossier exact-head, candidate suivante |
| **1** | pas de dossier digne de confiance (absent, malforme, perime, mauvaise lane/auteur, empreinte cassee) | router la candidate vers une lane **TIERCE qualifiante** -- l'adjoint `myia-po-2025:CoursIA-2` en premier, mais toute autre lane du cluster qui **ne porte pas** la PR convient (#16906) --, l'exclure jusqu'a un dossier exact-head, candidate suivante |
| **2** | organe injoignable | refus fail-closed, identique a 1 |

**Exit 3 n'est PAS un gate plus mou** : un dossier BLOCKED doit satisfaire toutes les exigences structurelles, `surfaces-sha256` comprise. Ce qui tombe, ce sont les controles qui **refutent une claim READY** (checks verts, B.0 clear, zero thread non resolu, non-draft) — ce sont des raisons pour lesquelles une PR est bloquee, pas des raisons de se mefier du dossier qui le dit.
Expand All @@ -80,9 +80,9 @@ Les phases ci-dessous s'executent sous le budget defini par la section `## Budge

**Une seule identite est neutre** : `myia-ai-01`, et uniquement pour les surfaces qu'elle ecrit **apres** le dossier. Sans ca, le geste que le gate autorise — lire la PR, puis lever sa propre reserve — perime le dossier que le gate exige, et une PR bloquee par la seule reserve du coordinateur ne peut jamais se merger sans un aller-retour complet. Une surface de **tout autre auteur**, ou une surface `myia-ai-01` **anterieure** au dossier, perime toujours.

Le bloc `[ADJOINT PREFLIGHT]` est genere par `python scripts/check_adjoint_prevalidation.py <PR> --template`, puis complete par l'adjoint ; le compte des commentaires exclut le commentaire-dossier lui-meme. **Interdit de contourner le gate par un sous-agent, une lecture API directe ou un ancien dossier d'inbox.**
Le bloc `[ADJOINT PREFLIGHT]` est genere par `python scripts/check_adjoint_prevalidation.py <PR> --template [--lane <machine:workspace>]`, puis complete par la lane emettrice -- **qui rend son PROPRE nom** : un dossier sous un nom d'emprunt defait le refus d'auto-attestation. Le compte des commentaires exclut le commentaire-dossier lui-meme. **Interdit de contourner le gate par un sous-agent, une lecture API directe ou un ancien dossier d'inbox.**
2. **Exploiter les verdicts deja poses** : `[Hermes] COMMENT_WITH_CONCERNS` (prefixe de `reviews[].body`), `EXEC_PROVED` / `STRUCTURAL_ONLY` / `SUSPECT_REGRESSION` (body). Tout finding NanoClaw suit [audit-reassessment.md](../../rules/audit-reassessment.md) avant fix (~60 % de FP).
3. **L'adjoint fabrique, ai-01 ne re-fabrique pas** : les PRs sans dossier valide partent en lots explicites issus du sweep vers l'adjoint, pas vers des sous-agents ai-01. Dossier attendu : contrat machine-lisible exact-head, trois surfaces B.0, checks latest-wins, scope, domaine et verdict ; un changement de head ou de surface le perime. L'adjoint vise **>=20 READY oldest-first par fenetre de 4 h quand >=20 candidates sont eligibles**, et remonte chaque READY immediatement : le lot n'est pas une barriere. Un dossier insuffisant repart avec UNE question precise. Le login GitHub `jsboige` etant partage, le champ `lane` est une declaration fail-closed, pas une preuve cryptographique d'identite ; aucune autre lane declaree n'est acceptee.
3. **L'adjoint fabrique, ai-01 ne re-fabrique pas** : les PRs sans dossier valide partent en lots explicites issus du sweep vers l'adjoint, pas vers des sous-agents ai-01. Dossier attendu : contrat machine-lisible exact-head, trois surfaces B.0, checks latest-wins, scope, domaine et verdict ; un changement de head ou de surface le perime. L'adjoint vise **>=20 READY oldest-first par fenetre de 4 h quand >=20 candidates sont eligibles**, et remonte chaque READY immediatement : le lot n'est pas une barriere. Un dossier insuffisant repart avec UNE question precise. Le login GitHub `jsboige` etant partage, le champ `lane` est une declaration fail-closed, pas une preuve cryptographique d'identite. Ce que le gate exige est que la prevalidation soit **tierce**, pas qu'elle vienne d'une lane nommee : toute lane du cluster (`QUALIFYING_LANES`) peut emettre un dossier pour une PR **qu'elle ne porte pas**, et le gate refuse l'auto-attestation en comparant la lane du dossier au tag `Grain:` du body (#16906). Une lane hors de l'ensemble, ou malformee, echoue toujours ferme.
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`**.
Expand Down
98 changes: 89 additions & 9 deletions scripts/check_adjoint_prevalidation.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,15 @@
"""Fail-closed gate for the coordinator's adjoint prevalidation dossier.

The gate answers one narrow question: does this pull request have a complete,
exact-head, machine-readable READY dossier from the canonical adjoint lane?
exact-head, machine-readable READY dossier from a qualifying THIRD-PARTY lane?
It does not approve the pull request, replace B.0, or authorize a merge.

The binding constraint is third-party review, not the name of one lane. A
dossier written by the lane that carries the pull request is self-attestation
and is refused; a dossier written by any other qualifying lane carries the same
evidential weight as the adjoint's. Restricting emission to a single named lane
made that lane's throughput the merge throughput of the whole repository.

Canonical comment body (the marker must be the first line):

[ADJOINT PREFLIGHT]
Expand Down Expand Up @@ -78,7 +84,27 @@
from typing import Any

REPO = "jsboige/CoursIA"
# The adjoint remains the canonical emitter: `--template` renders its lane, and
# it is the lane the coordinator nudges first. It is no longer the only one.
ADJOINT_LANE = "myia-po-2025:CoursIA-2"
# A dossier is an act of third-party verification. Any cluster lane may emit one
# for a pull request it does not carry. The set is explicit so that an unknown or
# malformed lane string fails closed rather than passing as "some lane".
QUALIFYING_LANES = frozenset({
"myia-ai-01:CoursIA",
"myia-po-2023:CoursIA",
"myia-po-2024:CoursIA",
"myia-po-2024:CoursIA-2",
"myia-po-2025:CoursIA",
"myia-po-2025:CoursIA-2",
"myia-po-2026:CoursIA",
"myia-po-2026:CoursIA-2",
"myia-po-2027:CoursIA",
"myia-po-2027:CoursIA-2",
})
# `Grain: <genre> -- lane <machine:workspace>` in the pull request body names the
# lane that carries the work. Same grammar as scripts/check_lane_claim.py.
GRAIN_LANE_RE = re.compile(r"Grain:[^\n]*?\blane\s+([A-Za-z0-9_.-]+:[A-Za-z0-9_.-]+)")
SHARED_GITHUB_LOGIN = "jsboige"
# The gate's only consumer. Every worker lane signs SHARED_GITHUB_LOGIN, so this
# login is the one surface author the coordinator can recognise as itself.
Expand Down Expand Up @@ -152,7 +178,19 @@ def parse_dossier(
author: str,
created_at: str = "",
) -> tuple[Dossier | None, list[str]]:
"""Parse one strictly delimited dossier comment without interpreting prose."""
"""Parse one strictly delimited dossier comment without interpreting prose.

Prose FOLLOWING the closing marker is ignored, not refused. The contract is
the delimited block: `content` stops at `closing`, so trailing text can never
reach a field. Refusing it discarded dossiers whose machine-readable block
was complete and whose firsthand evidence was written below it for a human --
measured on four pull requests in one cycle (#16928).

Nothing is hidden by this. `check_unaddressed_nits.py` strips the dossier by
its two delimiters, so a reserve written after the closing marker still
reaches B.0 classification; only a reserve written INSIDE the block is
absorbed, which is the intended semantics of #16442/#16443.
"""
lines = body.strip().splitlines()
if not lines or lines[0].strip() != START:
return None, []
Expand All @@ -166,8 +204,6 @@ def parse_dossier(
content = lines[1:]
else:
content = lines[1:closing]
if closing != len(lines) - 1:
errors.append("content after closing marker")

fields: dict[str, str] = {}
for raw in content:
Expand Down Expand Up @@ -291,6 +327,21 @@ def surfaces_fingerprint(
return hashlib.sha256(encoded).hexdigest()


def carrying_lane(snapshot: dict[str, Any]) -> str | None:
"""Return the lane that carries this pull request, from its `Grain:` tag.

Returns None when the body carries no readable tag. `validate_dossier` turns
that None into a refusal: an absent tag means the self-attestation check
CANNOT be made, and a check that cannot be made has not passed. Without that
refusal, a qualifying lane carrying an untagged PR files its own dossier and
clears a control that never ran -- the exact hole the third-party rule exists
to close. Blast radius measured 2026-09-20: 4 of 221 open PRs carry no
readable tag, and the escape is to add the tag, not to weaken the gate.
"""
match = GRAIN_LANE_RE.search(snapshot.get("body") or "")
return match.group(1) if match else None


def validate_dossier(dossier: Dossier, snapshot: dict[str, Any]) -> list[str]:
"""Validate a parsed dossier against one live PR snapshot."""
f = dossier.fields
Expand All @@ -303,7 +354,6 @@ def validate_dossier(dossier: Dossier, snapshot: dict[str, Any]) -> list[str]:
# from an absent one (#16800).
expected = {
"schema": "1",
"lane": ADJOINT_LANE,
"complete": "true",
"body": "read",
}
Expand All @@ -326,6 +376,24 @@ def validate_dossier(dossier: Dossier, snapshot: dict[str, Any]) -> list[str]:
errors.append(f"{key} must be {value!r} when verdict is READY")
if f.get("domain") not in {"pass", "not-applicable"}:
errors.append("domain must be 'pass' or 'not-applicable' when verdict is READY")
dossier_lane = f.get("lane", "")
if dossier_lane not in QUALIFYING_LANES:
errors.append(
f"lane must be one of the qualifying cluster lanes, got {dossier_lane!r}"
)
else:
carrier = carrying_lane(snapshot)
if carrier is None:
errors.append(
"carrying lane cannot be established: the body carries no readable "
"'Grain: ... lane <machine:workspace>' tag, so third-party "
"prevalidation cannot be verified"
)
elif carrier == dossier_lane:
errors.append(
"self-prevalidation refused: the dossier lane "
f"{dossier_lane!r} is the lane that carries this pull request"
)
if dossier.author != SHARED_GITHUB_LOGIN:
errors.append(f"comment author must be {SHARED_GITHUB_LOGIN!r}")
if not SHA_RE.fullmatch(f.get("head", "")):
Expand Down Expand Up @@ -536,11 +604,17 @@ def load_snapshot(pr: int) -> dict[str, Any]:
return snapshot


def render_template(snapshot: dict[str, Any]) -> str:
"""Render the mechanical fields; the adjoint sets the four verdict fields."""
def render_template(snapshot: dict[str, Any], lane: str = ADJOINT_LANE) -> str:
"""Render the mechanical fields; the emitting lane sets the verdict fields.

`lane` defaults to the adjoint because it emits most dossiers, but a template
that hardcoded one lane would hand every other lane a dossier declaring a
name that is not its own -- and a borrowed name defeats the self-attestation
refusal in `validate_dossier`. A lane renders its OWN name here.
"""
fields = (
("schema", "1"),
("lane", ADJOINT_LANE),
("lane", lane),
("pr", str(snapshot["number"])),
("head", snapshot["headRefOid"]),
("complete", "REPLACE_WITH_true"),
Expand Down Expand Up @@ -572,6 +646,12 @@ def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pr", type=int, help="pull request number")
parser.add_argument("--json", action="store_true", help="emit machine-readable output")
parser.add_argument(
"--lane",
default=ADJOINT_LANE,
choices=sorted(QUALIFYING_LANES),
help="lane emitting the dossier, for --template (default: the adjoint)",
)
parser.add_argument(
"--fingerprint",
action="store_true",
Expand All @@ -586,7 +666,7 @@ def main() -> int:
try:
snapshot = load_snapshot(args.pr)
if args.template:
print(render_template(snapshot))
print(render_template(snapshot, args.lane))
return 0
if args.fingerprint:
print(surfaces_fingerprint(snapshot))
Expand Down
Loading
Loading