#!/usr/bin/env python3 """Demone meccanico di trigger per gemello-nave, nessun LLM qui. Legge scripts/slave-mailbox/mailbox.log dall'ultimo offset persistito, filtra solo le righe nel perimetro (vedi FILTERS sotto — deciso in archivio/Adrian/progetti/project_gemello_nave.md) e, se ne trova, invoca gemello-nave a freddo (claude -p, cwd dedicata cosi' il suo CLAUDE.md di ruolo si carica automaticamente) passandole il testo grezzo degli eventi. gemello-nave decide da sola (classificazione, eventuale segnalazione in mailbox verso Adrian) - questo script non giudica mai nulla, solo osserva e invoca. Due modalita': - `--once`: un solo ciclo poll-and-dispatch, poi esce. Pensato per il crontab utente (nessun sudo richiesto) - stesso pattern di check_job_health.py, schedulato ogni 1-2 min via `crontab -e`. Scelta finale (11/09/2026): l'alternativa systemd (Type=simple, loop persistente interno, Restart=on-failure) richiede `sudo systemctl enable`, bloccato dal classificatore Auto Mode - crontab utente la aggira senza bisogno di permessi elevati, stesso identico effetto pratico (poll a intervallo fisso) con un meccanismo diverso. - senza flag: loop persistente interno (poll ogni POLL_SECONDS) - tenuto per compatibilita'/uso manuale, non piu' il meccanismo di produzione. Zero costo LLM quando non c'e' nulla di nuovo nel perimetro. """ import os import re import subprocess import sys import time from datetime import datetime, timezone from pathlib import Path ROOT = Path("/mnt/ssd/data/Dropbox/adrian") MAILBOX = ROOT / "scripts" / "slave-mailbox" / "mailbox.log" GEMELLO_DIR = ROOT / "gemello-nave" OFFSET_FILE = Path(__file__).parent / "trigger.offset" CREDENTIALS = ROOT / "archivio" / "_secrets" / "credentials.md" LOG = Path(__file__).parent / "watch.log" POLL_SECONDS = 90 # Prefissi sender ammessi nel perimetro (11/09/2026: esteso a "logbook:" quando la cartella # logbook/ e' stata spostata dentro gemello-nave/ - possesso fisico implica possesso logico, # niente doppio binario cartella-spostata-ma-letta-da-Adrian, vedi CLAUDE.md di ruolo. Esteso di # nuovo lo stesso giorno a "gmail-check:" - secondo perimetro in parallelo, stesso metodo shadow # di check_job_health, vedi canvas. Esteso una quarta volta lo stesso giorno a "gate-watch:" - # fallimenti job gate-ufficio, prima riportati via SendMessage diretto da una slave dedicata, # ora mailbox + classificazione di gemello-nave come gli altri tre). FILTERS = ("check_job_health:", "logbook:", "gmail-check:", "gate-watch:") # Una riga mailbox inizia sempre con un timestamp ISO8601 ("2026-09-11T09:50:36+02:00 sender: ..."). # Le righe di continuazione (es. i mittenti elencati sotto un evento gmail-check multi-riga) non # iniziano cosi' - servono per non tagliare via il corpo di un evento multi-riga filtrando solo # sulla riga di intestazione (bug trovato l'11/09/2026 prima di attivare il filtro gmail-check: # senza questa correzione, gemello-nave avrebbe ricevuto solo "N messaggi nuovi:" senza mittenti). _TIMESTAMP_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2} ") MARKER = "Sei gemello-nave, invocata a freddo dal demone di trigger meccanico." def log(msg: str) -> None: # Solo file, mai anche stdout: girando via crontab con "--once >> watch.log 2>&1" (non # systemd/journald come servizio-gate-watch, da cui questo pattern era copiato), stdout e il # file sono la stessa destinazione - stampare anche a schermo duplicherebbe ogni riga. line = f"{datetime.now(timezone.utc).isoformat()} {msg}" with open(LOG, "a") as f: f.write(line + "\n") def read_oauth_token() -> str: for line in CREDENTIALS.read_text().splitlines(): if line.startswith("CLAUDE_CODE_OAUTH_TOKEN="): return line.split("=", 1)[1].strip() raise RuntimeError("CLAUDE_CODE_OAUTH_TOKEN non trovato in credentials.md") def read_offset() -> int: try: return int(OFFSET_FILE.read_text().strip()) except (FileNotFoundError, ValueError): return 0 def write_offset(value: int) -> None: OFFSET_FILE.write_text(str(value)) def invoke_gemello_nave(new_lines: str, token: str) -> None: prompt = ( f"{MARKER} Segui il CLAUDE.md di questa cartella (leggilo prima di tutto). " f"Nuovi eventi ricevuti dal demone di trigger:\n{new_lines}" ) log(f"invocazione gemello-nave, {len(new_lines.splitlines())} righe nel perimetro") env = os.environ.copy() env["CLAUDE_CODE_OAUTH_TOKEN"] = token env.pop("VSCODE_IPC_HOOK_CLI", None) result = subprocess.run( ["claude", "-p", "--output-format", "json", prompt], cwd=str(GEMELLO_DIR), env=env, capture_output=True, text=True, ) if result.returncode != 0: log(f"gemello-nave terminata con errore (exit {result.returncode}): {result.stderr[:500]}") else: log("gemello-nave completata") def poll_once(token: str) -> None: if not MAILBOX.exists(): return size = MAILBOX.stat().st_size offset = read_offset() if size < offset: write_offset(size) # mailbox più corta di quanto letto (rotazione) - riallinea return if size == offset: return # niente di nuovo, nessun costo with open(MAILBOX, "rb") as f: f.seek(offset) new_bytes = f.read() write_offset(size) kept = [] keep_current = False for line in new_bytes.decode("utf-8", errors="replace").splitlines(): if _TIMESTAMP_RE.match(line): keep_current = any(f in line for f in FILTERS) if keep_current: kept.append(line) new_lines = "\n".join(kept) if not new_lines: return # c'era roba nuova ma nulla nel perimetro - nessun costo LLM invoke_gemello_nave(new_lines, token) def main() -> None: token = read_oauth_token() if "--once" in sys.argv: try: poll_once(token) except Exception as e: log(f"errore nel ciclo di poll (ignorato): {e!r}") return log(f"avvio loop persistente, poll ogni {POLL_SECONDS}s, filtri {FILTERS}") while True: try: poll_once(token) except Exception as e: # non deve mai morire per un errore transitorio log(f"errore nel ciclo di poll (ignorato, riprovo al prossimo giro): {e!r}") time.sleep(POLL_SECONDS) if __name__ == "__main__": main()