dossier_donnees() etait utilise par app.py sans figurer dans le contrat Systeme — l'implementation Windows aurait pu l'omettre et ne casser qu'a l'execution. Et les comptes de tests annonces ne s'additionnaient pas depuis 80. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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éer — choisir_systeme() |
systeme/macos.py |
créer — son, modificateurs, journal, verrou, raccourci |
moteur/__init__.py |
créer — choisir_transcripteur() |
moteur/mlx.py |
créer — TranscripteurMLX, extrait de engine.Transcriber |
interface/__init__.py |
créer — choisir_interface() |
interface/cocoa/overlay.py |
créer — pulse_window.py déplacé tel quel |
capture.py |
créer — Recorder 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", "dossier_donnees", "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 dossier_donnees(self) -> str:
"""Dossier des données de l'application (verrou d'instance, réglages)."""
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(retireracquire_single_instance_locketLOCK_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", "dossier_donnees", "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.pypour 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 — 89 tests (80 au depart, +4 en tache 1, +5 ici)
- 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 — 91 tests (+2 pour l'interface)
- 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
Recorderetinject_text
git mv engine.py capture.py
Puis, dans capture.py :
- supprimer la classe
Transcriber(elle vit désormais dansmoteur/mlx.py) ; - supprimer
SOUNDS,play_soundetmodificateurs_enfonces(ils vivent danssysteme/macos.py) ; - remplacer l'import
from core import CONFIG, attendre, clean_transcriptparfrom core import CONFIG, attendre; - donner à
inject_textun paramètresysteme:
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 dossier_donnees(self): return "/tmp/zonza-test"
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 — 92 tests (+1 pour la garde d'imports)
- 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 — 92 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] okjuste 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→ 92 tests verts (80 au depart, +12)codesign -dvvv /Applications/Zonza.app | grep CDHash→ identique à avant l'étape[diag] accessibilité accordée : Truedans 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.py→ aucun résultatgit status --porcelain→ vide