zonza/docs/superpowers/plans/2026-08-23-zonza-etape1-contrats.md
Ralph Mayola 372df70c62 Plan : etape 1 du portage multiplateforme
Sept taches, chacune en TDD. Point de vigilance principal : app.py garde son nom
pour que le cdhash du stub reste identique et que l'autorisation d'Accessibilite
survive au refactor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-23 17:43:39 +02:00

26 KiB

Zonza multiplateforme — Étape 1 : extraire les contrats

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Nommer les trois frontières (moteur, interface, système) derrière des contrats explicites, sans changer un seul comportement.

Architecture: controller.py reçoit ses collaborateurs par injection au lieu de les importer. Le code Cocoa est déplacé sans être modifié. Une fabrique par frontière choisit l'implémentation ; sur ce Mac, elle rend exactement ce qui tourne aujourd'hui.

Tech Stack: Python 3.14, pytest, pyobjc (inchangé), sounddevice, pynput, mlx-whisper.

Critère de réussite : les 80 tests restent verts, ./build_app.sh produit un stub au cdhash identique, et Zonza dicte comme avant.


Contrainte non négociable

build_app.sh:43 grave -DAPP_PY="$HERE/app.py" dans le stub C. Renommer ou déplacer app.py change le binaire, donc son cdhash, donc révoque l'autorisation d'Accessibilité de macOS (vérifié le 2026-08-21). app.py reste donc à la racine sous ce nom — la spec parlait de lanceur.py, on s'en écarte volontairement.

Vérification à faire à la fin de chaque tâche qui touche au build :

codesign -dvvv /Applications/Zonza.app 2>&1 | grep -i '^CDHash='

Structure de fichiers

Fichier Responsabilité
contrats.py créer — les trois protocoles, et rien d'autre
systeme/__init__.py créerchoisir_systeme()
systeme/macos.py créer — son, modificateurs, journal, verrou, raccourci
moteur/__init__.py créerchoisir_transcripteur()
moteur/mlx.py créerTranscripteurMLX, extrait de engine.Transcriber
interface/__init__.py créerchoisir_interface()
interface/cocoa/overlay.py créerpulse_window.py déplacé tel quel
capture.py créerRecorder et inject_text, déjà portables
engine.py supprimer en fin de parcours
pulse_window.py supprimer en fin de parcours
controller.py modifier — injection au lieu d'imports
app.py modifier — câblage, nom inchangé
tests/test_contrats.py créer — suite rejouée contre chaque implémentation

Task 1 : Les contrats

Files:

  • Create: contrats.py

  • Test: tests/test_contrats.py

  • Step 1 : Écrire le test qui échoue

# tests/test_contrats.py
"""Une suite unique, rejouée contre TOUTES les implémentations d'un contrat.

Le jour où une implémentation oublie une méthode, c'est ici que ça rougit — y
compris pour les plateformes absentes de la machine de développement.
"""
import inspect

import pytest

from contrats import Overlay, Systeme, Transcripteur


@pytest.mark.parametrize("contrat, methodes", [
    (Transcripteur, ["warmup", "transcrire"]),
    (Overlay, ["show", "hide", "set_level", "set_status", "rearmer_garde"]),
    (Systeme, ["jouer_son", "modificateurs_enfonces", "raccourci_defaut",
               "chemin_log", "acquerir_verrou_instance"]),
])
def test_le_contrat_declare_exactement_ses_methodes(contrat, methodes):
    declarees = [n for n, _ in inspect.getmembers(contrat, inspect.isfunction)
                 if not n.startswith("_")]
    assert sorted(declarees) == sorted(methodes)


def test_les_contrats_sont_documentes():
    for contrat in (Transcripteur, Overlay, Systeme):
        assert contrat.__doc__, f"{contrat.__name__} doit dire à quoi il sert"
  • Step 2 : Lancer le test pour vérifier qu'il échoue

Run: .venv/bin/python -m pytest tests/test_contrats.py -q Expected: FAIL — ModuleNotFoundError: No module named 'contrats'

  • Step 3 : Écrire l'implémentation minimale
# contrats.py
"""Les trois frontières de Zonza.

Chaque contrat est délibérément minuscule : ce sont les seuls points par lesquels
le noyau parle au monde extérieur. Tout ce qui dépend d'un système d'exploitation,
d'une bibliothèque d'affichage ou d'un moteur de transcription vit DERRIÈRE l'un
d'eux — jamais dans `core.py`, `controller.py` ni `audio_level.py`.
"""
from typing import Protocol


class Transcripteur(Protocol):
    """Transforme un signal audio en texte."""

    def warmup(self) -> None:
        """Charge le modèle une fois, pour que la première dictée ne traîne pas."""

    def transcrire(self, audio) -> str:
        """Rend le texte d'un tableau numpy float32 mono."""


class Overlay(Protocol):
    """La fenêtre flottante affichée pendant la dictée."""

    def show(self) -> None:
        """Affiche la fenêtre et démarre l'animation. Thread principal."""

    def hide(self) -> None:
        """Masque la fenêtre et arrête l'animation. Thread principal."""

    def set_level(self, level: float) -> None:
        """Pousse un niveau audio dans [0, 1]. Appelable depuis un thread de fond."""

    def set_status(self, text: str) -> None:
        """Texte affiché dans la bulle. Appelable depuis un thread de fond."""

    def rearmer_garde(self, duree_s: float) -> None:
        """Remplace la durée de vie maximale de la fenêtre. Thread principal."""


class Systeme(Protocol):
    """Les appels qui diffèrent d'un système d'exploitation à l'autre."""

    def jouer_son(self, nom: str) -> None:
        """Joue « start », « done » ou « error » sans bloquer."""

    def modificateurs_enfonces(self) -> bool:
        """True si une touche modificatrice est physiquement enfoncée."""

    def raccourci_defaut(self) -> str:
        """Raccourci global au format pynput, propre à la plateforme."""

    def chemin_log(self) -> str:
        """Chemin absolu du journal."""

    def acquerir_verrou_instance(self, chemin: str):
        """Verrou exclusif non bloquant ; rend le fichier, ou None s'il est pris."""
  • Step 4 : Lancer les tests

Run: .venv/bin/python -m pytest tests/test_contrats.py -q Expected: PASS — 4 tests

  • Step 5 : Commit
git add contrats.py tests/test_contrats.py
git commit -m "Declare les trois contrats de Zonza"

Task 2 : La couche système macOS

Files:

  • Create: systeme/__init__.py, systeme/macos.py

  • Modify: core.py (retirer acquire_single_instance_lock et LOCK_PATH)

  • Test: tests/test_systeme.py, tests/test_single_instance.py

  • Step 1 : Écrire le test qui échoue

# tests/test_systeme.py
"""La fabrique rend une implémentation conforme au contrat Systeme."""
import os
import tempfile

import pytest

from contrats import Systeme
from systeme import choisir_systeme


def test_la_fabrique_rend_une_implementation_complete():
    s = choisir_systeme()
    for methode in ("jouer_son", "modificateurs_enfonces", "raccourci_defaut",
                    "chemin_log", "acquerir_verrou_instance"):
        assert callable(getattr(s, methode)), f"{methode} manque"


def test_une_plateforme_inconnue_est_refusee_clairement():
    with pytest.raises(NotImplementedError) as e:
        choisir_systeme(plateforme="haiku-os")
    assert "haiku-os" in str(e.value)


def test_le_raccourci_macos_est_celui_d_aujourd_hui():
    assert choisir_systeme(plateforme="darwin").raccourci_defaut() == "<cmd>+<ctrl>+d"


def test_le_verrou_refuse_une_seconde_prise():
    s = choisir_systeme()
    chemin = os.path.join(tempfile.mkdtemp(), "sous-dossier", "z.lock")
    assert s.acquerir_verrou_instance(chemin) is not None
    assert s.acquerir_verrou_instance(chemin) is None


def test_aucun_modificateur_enfonce_pendant_les_tests():
    assert choisir_systeme().modificateurs_enfonces() is False
  • Step 2 : Lancer le test pour vérifier qu'il échoue

Run: .venv/bin/python -m pytest tests/test_systeme.py -q Expected: FAIL — ModuleNotFoundError: No module named 'systeme'

  • Step 3 : Écrire l'implémentation
# systeme/macos.py
"""Appels propres à macOS. Rien ici ne doit être importé directement par le noyau."""
import fcntl
import os
import subprocess

_SONS = {
    "start": "/System/Library/Sounds/Pop.aiff",
    "done": "/System/Library/Sounds/Glass.aiff",
    "error": "/System/Library/Sounds/Basso.aiff",
}

# Masque des modificateurs (ctrl, shift, alt, cmd) dans les drapeaux Quartz.
_MASQUE_MODIFICATEURS = 0x000F0000


class SystemeMacOS:
    """Implémentation du contrat Systeme pour macOS."""

    def jouer_son(self, nom):
        chemin = _SONS.get(nom)
        if not chemin:
            return
        try:
            subprocess.Popen(["afplay", chemin])
        except Exception:
            pass  # le son ne doit jamais casser la dictée

    def modificateurs_enfonces(self):
        try:
            from Quartz import CGEventSourceFlagsState
            return bool(CGEventSourceFlagsState(1) & _MASQUE_MODIFICATEURS)
        except Exception:
            return False  # dans le doute, ne jamais bloquer le collage

    def raccourci_defaut(self):
        return "<cmd>+<ctrl>+d"

    def chemin_log(self):
        return os.path.expanduser("~/Library/Logs/Zonza.log")

    def dossier_donnees(self):
        return os.path.expanduser("~/Library/Application Support/Zonza")

    def acquerir_verrou_instance(self, chemin):
        parent = os.path.dirname(chemin)
        if parent:
            os.makedirs(parent, exist_ok=True)
        fichier = open(chemin, "a")
        try:
            fcntl.flock(fichier, fcntl.LOCK_EX | fcntl.LOCK_NB)
        except OSError:
            fichier.close()
            return None
        return fichier
# systeme/__init__.py
"""Choisit la couche système selon la plateforme."""
import sys


def choisir_systeme(plateforme=None):
    """Rend l'implémentation du contrat Systeme pour cette machine.

    `plateforme` est injectable pour tester les branches absentes de la machine
    de développement.
    """
    plateforme = plateforme or sys.platform
    if plateforme.startswith("darwin"):
        from systeme.macos import SystemeMacOS
        return SystemeMacOS()
    raise NotImplementedError(f"Plateforme non prise en charge : {plateforme}")
  • Step 4 : Retirer le verrou de core.py

Supprimer de core.py : l'import fcntl, la constante LOCK_PATH et la fonction acquire_single_instance_lock entière. Garder import os, encore utilisé par le reste du fichier.

  • Step 5 : Réécrire tests/test_single_instance.py pour viser la couche système

Remplacer la ligne d'import :

from systeme import choisir_systeme

et, dans chaque test, remplacer acquire_single_instance_lock(...) par choisir_systeme().acquerir_verrou_instance(...). Les assertions ne changent pas.

  • Step 6 : Lancer toute la suite

Run: .venv/bin/python -m pytest -q Expected: PASS — 85 tests (80 + 5 nouveaux)

  • Step 7 : Commit
git add systeme contrats.py core.py tests/test_systeme.py tests/test_single_instance.py
git commit -m "Extrait les appels macOS derriere le contrat Systeme"

Task 3 : Le moteur de transcription

Files:

  • Create: moteur/__init__.py, moteur/mlx.py

  • Test: tests/test_moteur.py

  • Step 1 : Écrire le test qui échoue

# tests/test_moteur.py
"""La fabrique choisit le moteur d'après le matériel — décision pure, testable
depuis n'importe quelle machine."""
import pytest

from moteur import choisir_transcripteur, nom_moteur_pour


@pytest.mark.parametrize("plateforme, machine, cuda, attendu", [
    ("darwin", "arm64", False, "mlx"),
    ("darwin", "x86_64", False, "faster-cpu"),
    ("win32", "AMD64", True, "faster-gpu"),
    ("win32", "AMD64", False, "faster-cpu"),
])
def test_la_decision_suit_le_materiel(plateforme, machine, cuda, attendu):
    assert nom_moteur_pour(plateforme, machine, cuda) == attendu


def test_apple_silicon_prend_mlx_meme_sans_cuda():
    assert nom_moteur_pour("darwin", "arm64", cuda_disponible=False) == "mlx"


def test_la_fabrique_rend_un_transcripteur_complet():
    t = choisir_transcripteur(nom="mlx", modele="mlx-community/whisper-medium",
                              langue="fr", amorce="")
    assert callable(t.warmup) and callable(t.transcrire)


def test_un_moteur_inconnu_est_refuse_clairement():
    with pytest.raises(NotImplementedError) as e:
        choisir_transcripteur(nom="vosk", modele="x", langue="fr", amorce="")
    assert "vosk" in str(e.value)
  • Step 2 : Lancer le test pour vérifier qu'il échoue

Run: .venv/bin/python -m pytest tests/test_moteur.py -q Expected: FAIL — ModuleNotFoundError: No module named 'moteur'

  • Step 3 : Écrire l'implémentation
# moteur/mlx.py
"""Transcription par MLX-Whisper — GPU Apple Silicon uniquement."""
import numpy as np

from core import clean_transcript


class TranscripteurMLX:
    """Implémentation du contrat Transcripteur pour Apple Silicon."""

    def __init__(self, modele, langue, amorce):
        self.modele = modele
        self.langue = langue
        self.amorce = amorce

    def warmup(self):
        """Charge le modèle une fois, sur une seconde de silence."""
        self.transcrire(np.zeros(16000, dtype="float32"))

    def transcrire(self, audio):
        import mlx_whisper
        resultat = mlx_whisper.transcribe(
            audio,
            path_or_hf_repo=self.modele,
            language=self.langue,
            initial_prompt=self.amorce,
        )
        return clean_transcript(resultat.get("text", ""))
# moteur/__init__.py
"""Choisit le moteur de transcription d'après le matériel."""
import platform
import sys


def nom_moteur_pour(plateforme, machine, cuda_disponible):
    """Décide du moteur. Fonction PURE : aucune interrogation de la machine ici,
    ce qui rend les quatre configurations testables depuis un seul poste."""
    if plateforme.startswith("darwin") and machine in ("arm64", "aarch64"):
        return "mlx"
    return "faster-gpu" if cuda_disponible else "faster-cpu"


def detecter_moteur():
    """Interroge la machine, puis délègue la décision à `nom_moteur_pour`."""
    return nom_moteur_pour(sys.platform, platform.machine(), _cuda_disponible())


def _cuda_disponible():
    try:
        import ctypes
        ctypes.CDLL("libcudart.so")
        return True
    except Exception:
        return False


def choisir_transcripteur(nom, modele, langue, amorce):
    """Rend l'implémentation du contrat Transcripteur portant ce nom."""
    if nom == "mlx":
        from moteur.mlx import TranscripteurMLX
        return TranscripteurMLX(modele, langue, amorce)
    raise NotImplementedError(f"Moteur non pris en charge à cette étape : {nom}")
  • Step 4 : Lancer les tests

Run: .venv/bin/python -m pytest tests/test_moteur.py -q Expected: PASS — 7 tests

  • Step 5 : Commit
git add moteur tests/test_moteur.py
git commit -m "Extrait la transcription derriere le contrat Transcripteur"

Task 4 : Déplacer l'interface Cocoa sans la modifier

Files:

  • Create: interface/__init__.py, interface/cocoa/__init__.py, interface/cocoa/overlay.py

  • Delete: pulse_window.py

  • Test: tests/test_interface.py

  • Step 1 : Écrire le test qui échoue

# tests/test_interface.py
"""La fabrique rend un overlay conforme au contrat, sans ouvrir de fenêtre."""
import pytest

from interface import classe_overlay_pour


def test_l_overlay_macos_declare_tout_le_contrat():
    classe = classe_overlay_pour("darwin")
    for methode in ("show", "hide", "set_level", "set_status", "rearmer_garde"):
        assert callable(getattr(classe, methode, None)), f"{methode} manque"


def test_une_plateforme_inconnue_est_refusee_clairement():
    with pytest.raises(NotImplementedError) as e:
        classe_overlay_pour("haiku-os")
    assert "haiku-os" in str(e.value)
  • Step 2 : Lancer le test pour vérifier qu'il échoue

Run: .venv/bin/python -m pytest tests/test_interface.py -q Expected: FAIL — ModuleNotFoundError: No module named 'interface'

  • Step 3 : Déplacer le fichier SANS toucher à son contenu
mkdir -p interface/cocoa
git mv pulse_window.py interface/cocoa/overlay.py
touch interface/cocoa/__init__.py

Ne modifier aucune ligne du corps de overlay.py. Ses imports from audio_level import ... et from core import ... continuent de fonctionner : conftest.py place la racine du projet sur sys.path.

  • Step 4 : Écrire la fabrique
# interface/__init__.py
"""Choisit l'implémentation d'overlay selon la plateforme."""
import sys


def classe_overlay_pour(plateforme=None):
    """Rend la CLASSE d'overlay (pas une instance : Cocoa exige `alloc().init()`)."""
    plateforme = plateforme or sys.platform
    if plateforme.startswith("darwin"):
        from interface.cocoa.overlay import PulseWindow
        return PulseWindow
    raise NotImplementedError(f"Aucune interface pour : {plateforme}")
  • Step 5 : Lancer toute la suite

Run: .venv/bin/python -m pytest -q Expected: PASS — 94 tests

  • Step 6 : Commit
git add interface tests/test_interface.py
git commit -m "Deplace l'overlay Cocoa derriere le contrat Overlay, sans le modifier"

Task 5 : La capture audio, déjà portable

Files:

  • Create: capture.py

  • Modify: controller.py:15

  • Delete: engine.py

  • Test: tests/test_liberation_flux.py

  • Step 1 : Déplacer Recorder et inject_text

git mv engine.py capture.py

Puis, dans capture.py :

  • supprimer la classe Transcriber (elle vit désormais dans moteur/mlx.py) ;
  • supprimer SOUNDS, play_sound et modificateurs_enfonces (ils vivent dans systeme/macos.py) ;
  • remplacer l'import from core import CONFIG, attendre, clean_transcript par from core import CONFIG, attendre ;
  • donner à inject_text un paramètre systeme :
def inject_text(text, systeme):
    """Copie le texte dans le presse-papier puis colle avec Cmd+V dans l'app active.

    On attend d'abord que l'utilisateur ait relâché ses touches : s'il tient encore
    la hotkey quand on envoie Cmd+V, le système reçoit une combinaison différente et
    l'application cible ne colle rien — sans la moindre erreur de notre côté
    (constaté le 2026-08-21).
    """
    if not text:
        return
    pyperclip.copy(text)
    libre = attendre(
        lambda: not systeme.modificateurs_enfonces(),
        CONFIG["attente_modificateurs_s"],
        0.03,
    )
    if not libre:
        print("[collage] modificateurs encore enfoncés — collage tenté quand même")
    time.sleep(CONFIG["delai_presse_papier_s"])
    with _kbd.pressed(Key.cmd):
        _kbd.press("v")
        _kbd.release("v")
    print("[collage] ok")
  • Step 2 : Mettre à jour l'import des tests

Dans tests/test_liberation_flux.py, remplacer :

from engine import Recorder

par :

from capture import Recorder
  • Step 3 : Lancer les tests de capture

Run: .venv/bin/python -m pytest tests/test_liberation_flux.py -q Expected: PASS — 6 tests

  • Step 4 : Commit
git add capture.py tests/test_liberation_flux.py
git commit -m "Isole la capture audio, deja portable, dans capture.py"

Task 6 : Le contrôleur reçoit ses collaborateurs

Files:

  • Modify: controller.py

  • Test: tests/test_statut_bulle.py

  • Step 1 : Écrire le test qui échoue

Ajouter à tests/test_statut_bulle.py :

def test_le_controleur_n_importe_aucun_module_de_plateforme():
    """Le noyau ne doit connaître que des contrats. Si cette assertion tombe, une
    dépendance de plateforme s'est glissée dans le chemin portable."""
    import controller
    source = open(controller.__file__, encoding="utf-8").read()
    for interdit in ("AppKit", "Foundation", "objc", "Quartz", "mlx_whisper",
                     "afplay", "from engine import"):
        assert interdit not in source, f"{interdit} n'a rien à faire dans controller.py"
  • Step 2 : Lancer le test pour vérifier qu'il échoue

Run: .venv/bin/python -m pytest tests/test_statut_bulle.py -q -k importe Expected: FAIL — from engine import n'a rien à faire dans controller.py

  • Step 3 : Injecter les collaborateurs

Dans controller.py, remplacer la ligne 15 :

from engine import Recorder, Transcriber, inject_text, play_sound

par :

from capture import Recorder, inject_text

Puis modifier la signature et le corps de __init__ :

    def __init__(self, config, pulse_window, transcripteur, systeme,
                 on_state=None, run_on_main=None, lancer_tache=None):
        self.config = config
        self.pulse = pulse_window
        self.transcriber = transcripteur
        self.systeme = systeme

Le reste de __init__ est inchangé, sauf la ligne qui construisait le transcripteur, à supprimer :

        self.transcriber = Transcriber(config["model"], config["language"])   # SUPPRIMER

Enfin, remplacer partout dans le fichier play_sound( par self.systeme.jouer_son(, et inject_text(text) par inject_text(text, self.systeme).

  • Step 4 : Adapter les doublures des tests

Dans tests/test_statut_bulle.py, ajouter une doublure de système et la passer à la fabrique locale _ctrl :

class FauxSysteme:
    def __init__(self): self.sons = []
    def jouer_son(self, nom): self.sons.append(nom)
    def modificateurs_enfonces(self): return False
    def raccourci_defaut(self): return "<cmd>+<ctrl>+d"
    def chemin_log(self): return "/tmp/zonza-test.log"
    def acquerir_verrou_instance(self, chemin): return object()


def _ctrl(recorder, taches=None):
    pulse = FauxPulse()
    faux_transcripteur = type("T", (), {
        "transcrire": staticmethod(lambda a: "bonjour"),
        "warmup": staticmethod(lambda: None),
    })()
    c = ZonzaController(CONFIG, pulse, faux_transcripteur, FauxSysteme(),
                        lancer_tache=taches)
    c.recorder = recorder
    return c, pulse

Le contrôleur appelle transcrire, non plus transcribe : renommer l'appel dans _fermer_et_transcrire.

  • Step 5 : Lancer toute la suite

Run: .venv/bin/python -m pytest -q Expected: PASS — 95 tests

  • Step 6 : Commit
git add controller.py tests/test_statut_bulle.py
git commit -m "Le controleur recoit transcripteur et systeme par injection"

Task 7 : Câbler le lanceur et prouver l'absence de régression

Files:

  • Modify: app.py

  • Delete: pulse_window.py (déjà déplacé), engine.py (déjà déplacé)

  • Step 1 : Relever le cdhash AVANT toute reconstruction

codesign -dvvv /Applications/Zonza.app 2>&1 | grep -i '^CDHash='

Noter la valeur. Elle devra être identique à la fin : app.py ne change pas de nom, donc le stub ne change pas, donc l'autorisation d'Accessibilité survit.

  • Step 2 : Câbler les fabriques dans app.py

Remplacer les imports (lignes 30-32) :

from controller import ZonzaController
from core import CONFIG, validate_config
from interface import classe_overlay_pour
from moteur import choisir_transcripteur, detecter_moteur
from systeme import choisir_systeme

Dans ZonzaApp.init, remplacer la construction du contrôleur :

        self.systeme = choisir_systeme()
        self.pulse = classe_overlay_pour().alloc().init()
        transcripteur = choisir_transcripteur(
            nom=detecter_moteur(),
            modele=CONFIG["model"],
            langue=CONFIG["language"],
            amorce=CONFIG["initial_prompt"],
        )
        self.controller = ZonzaController(
            CONFIG, self.pulse, transcripteur, self.systeme,
            on_state=self._set_state, run_on_main=AppHelper.callAfter,
        )

Dans main(), remplacer la prise du verrou :

    systeme = choisir_systeme()
    import os
    lock = systeme.acquerir_verrou_instance(
        os.path.join(systeme.dossier_donnees(), "instance.lock")
    )
    if lock is None:
        print("[garde] une autre instance de Zonza tourne deja — arret.")
        return
    main._lock = lock

Et dans _start_hotkey, lire le raccourci depuis le système :

        self._listener = keyboard.GlobalHotKeys(
            {self.systeme.raccourci_defaut(): on_activate}
        )
  • Step 3 : Lancer toute la suite

Run: .venv/bin/python -m pytest -q Expected: PASS — 95 tests

  • Step 4 : Reconstruire et vérifier le cdhash
pkill -f '/Applications/Zonza.app/Contents/MacOS/zonza'
./build_app.sh
codesign -dvvv /Applications/Zonza.app 2>&1 | grep -i '^CDHash='

Expected: la même valeur qu'à l'étape 1. Si elle diffère, l'autorisation d'Accessibilité sera révoquée — s'arrêter et comprendre pourquoi avant de continuer.

  • Step 5 : Vérifier que Zonza fonctionne réellement
open -a Zonza
sleep 10
grep 'accessibilité accordée' ~/Library/Logs/Zonza.log | tail -1

Expected: [diag] accessibilité accordée : True

Puis dicter une phrase contenant « Playwright » et « Gitea », et vérifier dans le journal :

  • une ligne [texte] avec les deux termes correctement orthographiés ;

  • une ligne [collage] ok juste après.

  • Step 6 : Commit

git add app.py
git commit -m "Cable le lanceur sur les trois fabriques"
git push origin main

Vérification finale de l'étape

  • .venv/bin/python -m pytest -q → 95 tests verts
  • codesign -dvvv /Applications/Zonza.app | grep CDHash → identique à avant l'étape
  • [diag] accessibilité accordée : True dans le journal
  • une dictée réelle produit [texte] puis [collage] ok
  • grep -rE 'AppKit|Quartz|mlx_whisper' core.py controller.py audio_level.py capture.pyaucun résultat
  • git status --porcelain → vide