L’architettura

Lunedì, nuova settimana e un nuovo progetto da avviare. Come si comincia? Per me il gesto è ormai naturale: una directory, un repository git appena inizializzato e due comandi da terminale.

 1 $ saf init basic
 2 
 3 .harness/harness.json scritto (metodo: basic)
 4 .harness/harness.schema.json scritto
 5 .saf/ creata, vuota
 6 
 7 $ saf start
 8 
 9 config    valida (versione 4, provider claude-code)
10 settings  .claude/settings.json: 6 hook fusi, nulla di tuo toccato
11 memory    saf-memory init: indice pronto, canale semantico assente
12 skills    .claude/skills/saf-*: 14 pagine riallineate
13 agents    .claude/agents/saf-*: 3 riallineati
14 project   .harness/project.md riscritto (109 righe su 120)
15 CLAUDE.md riga di import presente
16 claude    avvio con --append-system-prompt (meta-istruzione, tetto 600 caratteri)

Tutto ciò che questo capitolo descrive è quello che i due comandi hanno messo in piedi in pochi istanti.

  • saf init basic sceglie il metodo di lavoro e scrive la configurazione di base. Il metodo non ha un valore predefinito. Il consigliato è basic, che offre le primitive per gestire un progetto software (task, bug e backlog) ed è quello che segue la Parte 2. Il catalogo predefinito ne offre tre: basic, iterations e delivery. Si sceglie in base alla natura del progetto, che sia nuovo, un porting, un refactoring o un sistema legacy. Nessuna scelta implicita può farlo al posto di chi guida il lavoro: il metodo lo decide sempre o solo il Senior.
  • saf start legge la configurazione e ne deriva tutto ciò che Claude Code vedrà dall’avvio di sessione: gli hook, le skill, i subagent, il testo che l’agente legge a ogni sessione. Poi avvia Claude Code con la sua TUI, pronto per i primi task di lavoro. Da quel momento l’harness non ha più un processo suo: esiste soltanto quando qualcuno lo chiama. L’ultima riga dell’output, quella con --append-system-prompt, la spiego più avanti, parlando del livello della sessione.

Un harness non è un demone che sorveglia l’agente. È un eseguibile che il runtime invoca a ogni evento e che l’agente invoca, guidato da memoria, skill e prompt, quando gli serve un verbo per portare a termine un compito: due modi di essere chiamato, una sola cosa che risponde.

Prima una mappa dei dettagli

Prima di scendere nei singoli componenti conviene averli tutti davanti. La figura 1 è la mappa del capitolo: le sezioni che seguono la percorrono una zona alla volta.

Figura 1 — L’architettura di SAF: tre processi, nessuno permanente; il filesystem come unica fonte, git come registro della storia. Immagine prodotta con intelligenza artificiale (Claude Code) su indicazione dell’autore.
Figura 1. Figura 1 — L’architettura di SAF: tre processi, nessuno permanente; il filesystem come unica fonte, git come registro della storia. Immagine prodotta con intelligenza artificiale (Claude Code) su indicazione dell’autore.

In alto, saf start: una catena di derivazioni eseguita una volta per sessione. Al centro, i tre processi che collaborano durante il lavoro: Claude Code, che è l’unico a restare vivo per tutta la sessione; saf, che nasce e muore a ogni chiamata; saf-memory, che l’harness interroga quando gli serve contesto e ricordi. In basso, ciò che dura: i file del progetto e la storia in git. Tieni a mente due confini, perché il capitolo torna su entrambi: quello fra la configurazione e ciò che se ne deriva, e quello fra l’harness e la sua memoria.

La natura di SAF

SAF è una CLI, e il suo primo utente è l’agente stesso. I verbi del lavoro quotidiano sono status, create-card, close-card, recall, verify, release, groom, doctor, e altri ancora; li esegue l’agente dentro la sessione, dalla shell che Claude Code gli mette a disposizione. Lo stesso eseguibile, invocato come saf hook <evento>, è ciò che Claude Code lancia quando invece scatta un hook. Un solo programma con due porte d’ingresso, e la differenza sta in chi le apre.

Porta Chi la apre Quando Che cosa ottiene
saf <verbo> l’agente, per scelta quando skill, memoria o un deny gli indicano un verbo un’operazione eseguita (una scheda aperta, una verifica, un richiamo)
saf hook <evento> il runtime, senza chiedere a ogni evento configurato una decisione sull’azione in corso, o del contesto aggiunto

Qui sta il punto cruciale dell’architettura: l’harness non ha bisogno di un canale proprio verso l’agente, perché le due porte bastano. Con la seconda vede ogni passo; con la prima offre la strada giusta.

Perché C# e Native AOT come stack di sviluppo

Ho scelto di scrivere SAF in C#, compilato con Native AOT. La ragione è in parte di gusto e in parte di prestazioni. L’hook è un processo nuovo a ogni chiamata, e Claude Code ne fa decine per ogni turno di lavoro: con il runtime ordinario e la compilazione JIT, l’avvio del processo costerebbe più del lavoro che il processo deve fare. A titolo d’esempio: su una sessione di duecento chiamate a strumenti, 650 millisecondi di avvio sono oltre due minuti di attesa; 20 millisecondi sono quattro secondi. Abilitare AOT è stata la prima decisione tecnica:

1 <!-- src/Saf.Cli/Saf.Cli.csproj -->
2 <!-- saf hook è un processo nuovo a ogni tool call: con runtime e JIT
3      l'avvio domina il costo (650 ms → 20 ms passando a Native AOT).
4      AOT implica già self-contained, single-file e trimming. -->
5 <PublishAot>true</PublishAot>

Il risultato: venti millisecondi per partire, un solo file eseguibile, nessun runtime da installare sulla macchina di chi adotta l’harness.1 Il prezzo piccolo da pagare è che la compilazione non attraversa i sistemi operativi, quindi il binario per Linux si compila su Linux, e la build avviene da dentro un container.

La soluzione Visual Studio: quattro progetti, due eseguibili

1 src/
2 ├── Saf.Contracts/   vocabolario e forme condivise: ruoli, stati, relazioni,
3 │                    frontmatter, forma delle entità, tetti. Zero dipendenze.
4 ├── Saf.Core/        la logica: eventi e handler, adapter per Claude Code,
5 │                    configurazione, client della memoria, verifica, git, schede.
6 ├── Saf.Cli/         → saf         i verbi e gli hook
7 └── Saf.Memory/      → saf-memory  indice, sincronizzazione, richiamo.
8                                    Dipende SOLO da Saf.Contracts.

Il motivo per cui saf-memory è completamente disaccoppiato dall’harness sta nel modo in cui verifichiamo le sue dipendenze.

La regola dell’Allowlist: zero accoppiamento per la Memoria

Anziché definire una lista di divieti (es. “la memoria non deve dipendere dal core”), lo script di build applica un’allowlist rigorosa: Saf.Memory dipende solo ed esclusivamente da Saf.Contracts.

Se uno sviluppatore prova ad aggiungere un qualsiasi altro riferimento, la build fallisce. Questo approccio offre tre vantaggi architetturali:

  • Blindatura del confine: Saf.Memory non conosce l’harness, Claude Code, gli hook o i verbi CLI. Conosce soltanto i tipi di dati e le interfacce nude definiti nei contratti.

  • Inversione delle dipendenze garantita: L’harness e la memoria non condividono librerie o runtime, comunicano esclusivamente via CLI/processo basandosi sulla forma dei dati stabilita in Saf.Contracts.

  • Totale intercambiabilità: Puoi riscrivere la memoria (es. sostituendo l’indice locale con un Vector DB o una ricerca a grafi), cambiarne il linguaggio di programmazione o persino disattivarla del tutto, con la certezza matematica che il codice di saf e Saf.Core non subirà alcun impatto.

L’installazione è per macchina e si riduce a mettere nel PATH la cartella con i due eseguibili: serve all’agente, che chiama i verbi dalla shell, e a saf, che vi trova saf-memory. Gli hook non ne hanno bisogno, come vedremo. Da lì, il primo comando mette a un progetto il cappello di SAF; il secondo apre una sessione di Claude Code già allineata all’harness.

La configurazione che guida

Ogni funzione dell’harness si accende o si spegne con una sezione di configurazione, e tutto parte da un unico file: .harness/harness.json, con il suo schema JSON accanto. Lo schema descrive ogni chiave nel punto in cui la si scrive, così l’editor di VS Code completa e valida senza bisogno di accedere a una pagina di documentazione separata. La configurazione minimale è questa:

1 {
2   "$schema": "./harness.schema.json",
3   "version": 4,
4   "project": { "id": "…", "name": "notifiche" },
5   "agent": { "provider": "claude-code" },
6   "workflow": "basic"
7 }

Con questa configurazione l’harness osserva soltanto: registra gli eventi e non nega nulla. La regola è una: una sezione assente è una funzione spenta, sempre, senza effetti collaterali. Ogni cosa in più è una scelta esplicita:

  • le policy, ciascuna con la prosa che la spiega;
  • la verifica, con il suo oracolo (il comando che dice se il progetto è sano, cioè se la piramide dei test decisa ci manda un pass verde) e il comportamento del cancello allo stop;
  • la release, che è una dichiarazione e mai un comando che l’harness esegue da sé;
  • la memoria, con quanto entra all’avvio e a ogni prompt;
  • i topic;
  • il metodo, con le entità che formalizzano il lavoro fatto giorno dopo giorno.

Per il metodo, la forma breve "workflow": "basic" diventa un oggetto quando lo si vuole tarare. Un estratto:

1 "workflow": {
2   "name": "basic",
3   "entities": [
4     { "name": "task",    "width": 3, "max_chars": 2400, "steps": { "max": 6 } },
5     { "name": "bug",     "width": 3, "max_chars": 1900 },
6     { "name": "backlog", "width": 3, "max_chars": 1300 }
7   ]
8 }

Il progetto si limita a selezionare il nome del metodo e a tararne le entità (es. la formattazione dei codici numerici con width: 3 per ottenere task/004, la lunghezza massima delle schede o il tetto dei passi).

Tutto il resto, cioè ruoli, stati e regole di transizione, non si scrive nella configurazione: è già definito out-of-the-box nel catalogo del metodo, compilato direttamente dentro l’eseguibile.

In sintesi: il metodo possiede le sue entità e la loro logica mentre il progetto si limita a calibrarne i parametri.

Non c’era alcuna esigenza di gestire workflow dinamici o transizioni di stato personalizzabili via JSON. Applicando il principio YAGNI (You Aren’t Gonna Need It) alla lettera, nell’harness entra solo ciò che serve davvero.

Isolamento della configurazione dall’agente

Il file di configurazione e l’intera cartella .harness/ sono completamente preclusi all’agente, sia in lettura che in scrittura. Questo vincolo è applicato rigidamente dall’hook a runtime e rimane attivo anche con le policy disattivate.

Il motivo della scelta è la stabilità: la configurazione è la single source of truth del sistema, e permettere all’agente di modificarla equivarrebbe a fargli cambiare le regole del gioco mentre sta giocando.

Tutto ciò che l’agente deve sapere sulla configurazione gli viene fornito in modo derivato tramite .harness/project.md. L’agente non legge questo file usando i propri strumenti (operazione che scatenerebbe il blocco dell’hook), ma lo riceve direttamente dal runtime di Claude Code all’avvio della sessione tramite l’importazione di CLAUDE.md.

Stato persistente e registri di diagnostica

I dati operativi del sistema sono separati in due aree distinte:

  1. Stato interno (.harness/state/): Contiene i dati persistenti generati dall’harness, come l’esito delle verifiche legato ai commit, la proiezione del grafo o la copertura delle regole. Questa cartella è protetta dallo stesso divieto d’accesso applicato all’agente, non viene tracciata da Git ed è locale alla copia di lavoro.
  2. Registri ed Eventi: Il registro degli eventi dell’agente e i log di diagnostica vivono all’esterno del repository, in una directory utente dedicata per ciascun progetto.

Il punto in cui SAF entra nel ciclo di Claude

SAF non resta in esecuzione mentre Claude Code lavora. È Claude Code che, in momenti precisi, sospende per un istante il proprio flusso e chiama SAF. Quei momenti sono gli hook: l’inizio di una sessione, l’invio di un prompt, il tentativo di usare uno strumento, la fine di un turno.

Claude Code non conosce le regole di SAF. Conosce soltanto il contratto degli hook: manda un evento a un comando e interpreta la risposta. La figura 2 segue un caso concreto dall’inizio alla fine.

Figura 2 — Un giro di PreToolUse: l’evento entra su stdin, la decisione esce su stdout, e il motivo del divieto indica all’agente il verbo da usare. Immagine prodotta con intelligenza artificiale (Claude Code) su indicazione dell’autore.
Figura 2. Figura 2 — Un giro di PreToolUse: l’evento entra su stdin, la decisione esce su stdout, e il motivo del divieto indica all’agente il verbo da usare. Immagine prodotta con intelligenza artificiale (Claude Code) su indicazione dell’autore.

1. L’evento

La registrazione dell’hook vive in .claude/settings.json. Per PreToolUse, SAF dichiara:

1 "PreToolUse": [{
2   "matcher": "Edit|Write|Read|Grep|Glob|Bash|WebFetch|WebSearch",
3   "hooks": [{
4     "type": "command",
5     "command": "/usr/local/bin/saf",
6     "args": ["hook", "PreToolUse"]
7   }]
8 }]

Il percorso in command è quello dell’eseguibile che ha lanciato saf start, scritto per esteso: il runtime non deve cercarlo nel PATH, e l’hook punta sempre al binario che ha generato la configurazione. Gli argomenti stanno separati in args, e più avanti vedremo perché.

Quando l’agente sta per usare Edit, il runtime lancia saf hook PreToolUse e gli passa su stdin i dettagli dell’operazione:

 1 {
 2   "hook_event_name": "PreToolUse",
 3   "session_id": "…",
 4   "cwd": "/work/notifiche",
 5   "tool_name": "Edit",
 6   "tool_input": {
 7     "file_path": ".saf/task/004/README.md",
 8     "old_string": "status: open",
 9     "new_string": "status: done"
10   }
11 }

2. La decisione

SAF traduce il JSON nel proprio modello di evento, lo passa alle regole e, se una regola stabilisce che la riga status: non si modifica a mano, risponde su stdout:

1 {
2   "hookSpecificOutput": {
3     "hookEventName": "PreToolUse",
4     "permissionDecision": "deny",
5     "permissionDecisionReason":
6       "The status: line is written by a saf verb: saf close-card task/004"
7   }
8 }

Claude Code riceve il deny e non esegue Edit. Il processo saf intanto è già terminato, con codice d’uscita zero: la decisione sta nel JSON, non nel codice d’uscita, e più avanti vedremo perché.

3. La strada giusta

Conta soprattutto ciò che SAF non fa: non modifica il file al posto dell’agente, non prende il controllo della sessione, non decide quale sia il lavoro da fare. Dice soltanto che quell’azione, in quel punto del ciclo, non rispetta una regola, e quale sia il percorso previsto. Il motivo del deny non è un messaggio d’errore: è un’istruzione che l’agente legge e segue, chiamando saf close-card task/004 dalla prima porta.

È qui che un hook diventa uno strumento di steering. L’harness non deve ripetere di continuo all’agente che cosa fare: interviene quando l’agente sta per violare una regola, e in quel momento gli indica la strada.

Una precisazione onesta: lo steering non è un confine di sicurezza. Per Bash l’harness modella una lista chiusa di comandi, ne estrae i percorsi, compreso il redirect di un heredoc, e su quelli applica i percorsi protetti; ciò che non sa leggere, i segmenti opachi, lo osserva e per contratto non lo nega mai. Il limite è misurato, non teorico: in una sessione reale tre file sono stati creati sotto un percorso protetto, con un heredoc e con python -m, senza un solo deny. La documentazione di Claude Code dice lo stesso in termini generali: per un divieto assoluto serve il sistema dei permessi, non un hook.2 L’enforcement vero è la sandbox del runtime. Lo scopo dell’harness è rendere la strada giusta la più breve, non rendere impossibile quella sbagliata.

Il dispatcher e l’ordine dei verdetti

Dietro la risposta c’è un dispatcher che passa l’evento a tutti gli handler in ascolto, senza fermarsi al primo che lo reclama. Gli handler sono diciotto, uno per policy; fra questi, la forma dei percorsi sotto la board delle schede, la riga di stato, i percorsi protetti, i comandi negati, il ramo protetto, il confine fra copie di lavoro, la finestra di orientamento, la spinta al richiamo. Ognuno restituisce un verdetto, e i verdetti hanno un ordine che è anche la loro precedenza:

 1 // src/Saf.Core/Events/HandlerResult.cs
 2 public enum Verdict { Observe, Allow, Escalate, Require, Deny } // l'ordine È la precedenza
 3 
 4 public sealed record HandlerResult(Verdict Verdict, string? Reason, string? Rule = null);
 5 
 6 // src/Saf.Core/Events/EventDispatcher.cs
 7 if (claims.Count == 0) return HookDecision.Allowed;   // nessuno ha parlato
 8 
 9 var force   = claims.Max(c => c.Result.Verdict);
10 var winners = claims.Where(c => c.Result.Verdict == force);
11 
12 return new HookDecision(
13     Denied: force >= Verdict.Require,
14     Reason: string.Join("; ", winners.Select(w => w.Result.Reason)));

Vince il verdetto più restrittivo, e nel motivo parlano soltanto gli handler che lo hanno espresso. Un handler che lancia un’eccezione viene registrato e saltato: gli altri decidono comunque.

Verdetto Che cosa significa Che cosa riceve Claude Code
Observe l’handler registra, non ha nulla da dire nessuna decisione
Allow l’handler non ha obiezioni nessuna decisione: allow non viene mai emesso
Escalate la decisione spetta al Senior su PreToolUse, ask, con il motivo anche in systemMessage; su Stop, nessun blocco e soltanto systemMessage
Require l’azione è subordinata a un passo che manca lo stesso rifiuto di Deny (deny / block)
Deny l’azione è negata un rifiuto (deny / block) con il motivo

Require e Deny producono lo stesso effetto, e il codice lo dichiara nel commento: promettere un effetto diverso sarebbe falso. La differenza vive nel registro e nel testo del motivo, dove serve a chi rilegge la sessione. Escalate su Stop non blocca per una ragione altrettanto semplice: lasciar fermare l’agente è già restituire il controllo a una persona.

Perché Allow non esce mai? Perché un harness che concedesse un permesso da solo scavalcherebbe il flusso ordinario dei permessi di Claude Code. La regola è che l’harness può stringere, mai allargare: se tace, decide il runtime come avrebbe deciso senza di lui.

I sei eventi

Degli eventi che il runtime offre, trentatré alla data in cui scrivo, l’harness ne usa sei.[^hooks-doc]

Evento Quando Che cosa fa SAF
SessionStart avvio, ripresa, /clear, compattazione sincronizza la memoria e la attende, poi inietta i blocchi di stato e di grafo
UserPromptSubmit a ogni prompt del Senior aggiunge i puntatori alle schede pertinenti
PreToolUse prima di ogni strumento decide: osserva, lascia passare o nega
PostToolUse dopo ogni strumento controlla la grammatica di ciò che è stato scritto; invalida la verifica se serve
Stop quando l’agente vuole chiudere il turno applica il cancello di completamento
SessionEnd chiusura della sessione lancia la sincronizzazione della memoria senza attenderla

Il cancello allo stop ha due comportamenti, e li sceglie il progetto. Con on_stop: ask, il predefinito, l’harness non esegue l’oracolo: lascia fermare l’agente e dice al Senior, con un systemMessage, che il lavoro non è verificato; il PASS lo pretenderà la chiusura del lavoro. Con on_stop: run esegue l’oracolo e, se non passa, impedisce la chiusura del turno con il formato che il runtime documenta:

1 { "decision": "block", "reason": "verification is stale since the last Edit: run saf verify" }

Un cancello del genere ha un rischio evidente: il ciclo infinito. Il runtime se ne difende da sé, scavalcando uno Stop hook dopo otto blocchi consecutivi senza progresso, e passa all’hook un campo, stop_hook_active, che dice se è già in corso una continuazione.3 L’harness non aspetta di essere scavalcato: conta i propri blocchi nel registro degli eventi e, prima del tetto, cambia forza, da Deny a Escalate. L’agente si ferma, e il Senior riceve un messaggio che dice perché.

La sincronizzazione compare due volte, con due comportamenti opposti. SessionStart la attende, perché ciò che inietta dipende da essa; SessionEnd la lancia e ritorna subito, perché a fine sessione non c’è più nessuno da servire.[^hooks-doc]

La proprietà della memoria, su tre livelli

La Parte 1 ha distinto memoria, cognizione persistente e contesto. Nell’harness la distinzione diventa una domanda pratica: chi scrive che cosa, e chi lo legge.

Livello Dove sta Chi scrive Sopravvive alla compattazione?
Progetto nel repository, versionato Senior e agente nelle proprie aree; l’harness solo nei file derivati sì
Sessione nella finestra di contesto soltanto l’harness no, ma l’harness la reinietta
Storia nell’indice di saf-memory soltanto saf-memory, dai file non è nel contesto: si interroga

Il livello del progetto è ciò che sta nel repository, versionato con il codice: la board, .saf/, dove ogni lavoro, ogni bug e ogni backlog hanno una scheda; le istruzioni permanenti, instructions/, dove le regole di dominio si accumulano una riga alla volta; i documenti, documents/, con il disegno di alto livello in testa. E il testo derivato che l’agente legge a ogni sessione, .harness/project.md, che nessuno scrive a mano: lo compone saf start dalla configurazione, e CLAUDE.md contiene una sola riga che lo importa.

Il livello della sessione è ciò che entra nella finestra di contesto senza stare in un file che l’agente legga. All’avvio, il blocco [Harness state]: dove sei, su quale ramo, quali lavori sono aperti, quali copie di lavoro sono vive, se l’oracolo è verde, quante regole non hanno un test dietro. Poi il blocco [Harness graph]: il nodo corrente con i suoi archi, la distribuzione degli stati. A ogni prompt, i puntatori alle schede che parlano di ciò che il Senior ha appena chiesto, con una soglia di pertinenza sotto la quale questo canale tace.

Questo livello lo scrive soltanto l’harness, ed è derivato al momento: si ricompone ogni volta da git, dalla board e da .harness/state/. Non esiste una copia dello stato che l’agente possa leggere o modificare, e non per diffidenza: se l’agente potesse scrivere lo stato che poi rilegge, la sessione successiva si fiderebbe di un’affermazione che nessuno ha verificato.

C’è una ragione precisa per cui il metodo sta in un file importato da CLAUDE.md e lo stato sta nel contesto iniettato: il metodo vale sempre, lo stato vale soltanto nel momento in cui lo si calcola. Il CLAUDE.md della radice, con i suoi import, viene riletto da disco anche dopo una compattazione.4 Lo stato, invece, si deriva di nuovo a ogni avvio o ripresa. L’hook di SessionStart è registrato senza matcher, quindi scatta per ogni sorgente: avvio, ripresa, /clear, compattazione, fork. Dopo una compattazione, perciò, l’agente non deve chiedere nulla: l’harness ricalcola e reinietta lo stato e la proiezione del grafo, freschi.

Resta da spiegare l’ultima riga di saf start. Oltre al testo importato da CLAUDE.md, l’avvio aggiunge al prompt di sistema una meta-istruzione di al massimo due frasi, ciascuna accesa solo quando serve. La prima dice che le regole della memoria di progetto sono applicate, e compare soltanto se ci sono policy attive. La seconda dice che le coordinate del blocco di stato sono già derivate, e compare soltanto se quel blocco viene iniettato. Non duplica nulla: il contenuto resta a SessionStart, che è l’unico canale valido anche quando Claude Code non è stato avviato da saf start. Nel prompt di sistema va soltanto il peso, perché lì un’istruzione conta più della stessa istruzione in un messaggio. Il tetto di seicento caratteri è una promessa che l’harness fa a sé stesso, non un limite del runtime.

Il livello della storia è la memoria propriamente detta: tutto ciò che è stato scritto nel progetto, indicizzato per essere ritrovato. Lo possiede il secondo eseguibile.

La memoria: indice e grafo

Il filesystem è la sorgente delle informazioni, l’indice è la proiezione delle stesse mentre il processo di sincronizzazione riconcilia entrambi. L’indice è un database SQLite, uno per ogni progetto, ricostruibile da zero in qualunque momento. Se lo schema cambia, si cancella il file e si rigenera tutto in pochi secondi.

1 -- src/Saf.Memory/Adapters/Sqlite/Schema.cs (estratto)
2 CREATE TABLE documents (path, size, mtime_utc, hash, entity, local_id,
3                         kind, topic, status, header, …);
4 CREATE TABLE edges    (src, dst, type, why);   -- solo archi scritti da qualcuno
5 CREATE TABLE mentions (src, dst);              -- link in prosa, derivati
6 CREATE TABLE rules    (id, text, topic, from_ref, held_by);
7 CREATE TABLE vectors  (doc_path, ord, chunk_hash, heading, dims, vector BLOB);
8 CREATE VIRTUAL TABLE documents_fts USING fts5(title, topic, entity, body,
9                         content = '', contentless_delete = 1);

Il codice SQL fa subito emergere quattro scelte nette:

  • La ricerca testuale è contentless. Il corpo viene indicizzato ma non conservato: il testo vero sta nei file perché non ha senso averne due copie. L’indice non può restituire contenuto all’agente, soltanto dei puntatori.
  • Gli archi sono soltanto quelli scritti. In edges finiscono gli archi che l’agente ha dichiarato nel frontmatter di una scheda, con il loro perché. Al contrario gli inversi non si scrivono, si calcolano.
  • Le menzioni sono a parte. I link trovati nella prosa di una scheda o di un documento sono un indizio, non una dichiarazione forte, e vivono in una tabella dedicata.
  • I vettori sono opzionali. Se sulla macchina c’è la possibilità di servire un modello di embedding locale (vale anche per il remoto), il canale semantico si può accendere e la recall cerca per significato. In caso contrrario il richiamo è puramente lessicale, ma funziona lo stesso.

La sincronizzazione delle informaizoni avviene ai confini della sessione, e la lancia l’harness, non l’agente. Passa sempre su tutti i file, perché una cancellazione non produce alcun evento, ma scarta in fretta: dimensione e data di modifica invariate vogliono dire file invariato, e l’hash si calcola solo per gli altri. Su cinquecento documenti una passata a vuoto costa venti millisecondi.

La recall risponde a tre domande.

  1. Che cosa parla di X? Due canali, uno lessicale pesato su titolo, topic, entità e corpo, uno semantico per significato, fusi per rango da un algoritmo che si occupa del merge ed ordinamento dei risultati. Tutto ciò con un vincolo di latenza: oltre una soglia prestabilita se il primo non restituisce risultati, risponde il secondo canale (quello lessicale), perché mezza risposta è molto meglio di nessuna risposta.
  2. Che cosa sta intorno a questo nodo? Gli archi, gli inversi calcolati, le menzioni, le regole.
  3. Che cosa esiste, e a che punto è? La distribuzione per ruolo e stato.

In tutti e tre i casi la risposta è fatta di puntatori, mai di contenuto:

1 { "ref": "task/004", "title": "La coda esclusa per il riordino dei messaggi",
2   "status": "done", "topic": "architecture", "cost": 2310 }

Il campo cost è la dimensione del file in byte: chi riceve il puntatore, di solito l’agente, sa quanto gli costerà caricarne il contenuto nel contesto. Il puntatore porta anche lo stato dichiarato della scheda; il superamento di una decisione lo dicono gli archi, con --near o nella forma completa. Se il vincolo che la reggeva tenga ancora, invece, la memoria non lo decide. È il limite che la Parte 1 ha fissato al contesto, e in SAF è una regola dell’indice: restituisce puntatori e stati, mai contenuto né giudizi.

Fra i due eseguibili il contratto è uno solo: argomenti sulla riga di comando, l’ontologia del progetto su stdin come JSON, la risposta su stdout come JSON. Oltre un timeout l’harness termina il processo e tiene la proiezione precedente, e la sessione parte comunque. La memoria non ha mai il potere di fermare il lavoro.

La proprietà dei file di Claude Code

Claude Code legge una manciata di file nel repository. L’harness ne possiede alcuni per intero, altri in parte, altri per nulla.

File Proprietario Che cosa fa SAF
.claude/settings.json condiviso possiede solo le proprie entry negli hook
.claude/skills/saf-* SAF le riscrive a ogni saf start
.claude/agents/saf-* SAF li riscrive a ogni saf start, non li cancella mai
.harness/project.md SAF lo riscrive per intero
CLAUDE.md il progetto possiede una riga

settings.json: fusione non distruttiva. Per ogni evento l’harness rimuove soltanto le entry che riconosce come proprie, aggiunge le sue e lascia il resto com’è: gli hook del progetto, i permessi, le altre chiavi. Un file illeggibile non viene mai sovrascritto: l’avvio si ferma e chiede di sistemarlo.

L’entry, come si è visto nella registrazione di PreToolUse, usa la forma con argomenti separati (command più args) e non una stringa di shell. La ragione è documentata dal runtime stesso: una stringa viene eseguita da una shell, che su Windows è Git Bash, e un profilo di shell che stampa una riga di benvenuto finisce davanti al JSON e lo rende illeggibile.5 Con gli argomenti separati il runtime lancia l’eseguibile direttamente, e non c’è shell in mezzo.

Skill e subagent: generati, con prefisso riservato. Nascono da modelli incorporati nell’eseguibile, con i valori del progetto al posto dei segnaposto: la versione, le entità, gli stati, le relazioni. Ciascuno porta, subito dopo il proprio frontmatter, lo stesso avviso. Ecco il subagent esploratore:

1 ---
2 name: saf-explorer
3 description: "Read verbose material in a window of your own and come back short."
4 model: haiku
5 tools: Read, Grep, Glob
6 ---
7 
8 > Harness saf — this page is generated and realigned on every `saf start`:
9 > do not edit it, your changes will not survive.

Il prefisso saf- è riservato. Un file generato dall’harness resta governato dall’harness stesso, perché al prossimo avvio torna com’era. Al contrario un file scritto a mano con un prefisso diverso da saf- no. Per questo l’harness non tocca mai una skill o un subagent che non porti il suo prefisso. I subagent hanno modello e strumenti tarati in base al proprio ruolo. Ad esempio l’esploratore (saf-explorer) è un subagent che legge materiale voluminoso e torna con una sintesi breve all’agente principale, per questo gira su un modello piccolo con tre strumenti di sola lettura.6

CLAUDE.md: una riga, non un blocco. Il file di istruzioni è del progetto. L’harness mette solo una riga, @.harness/project.md, e la cerca saltando i blocchi scritti dall’utente. Sarà poi il runtime ad espanderlo tramite la direttiva import fissandolo nella memoria di progetto.[^memory-doc] La scelta di una riga e non di un blocco a sé ha una ragione: un blocco che occupa metà del file sarebbe un coinquilino, una riga fra le sue è un vicino. Il testo che quella riga importa ha un tetto di centoventi righe, contro le duecento che la guida di Claude Code indica come soglia di aderenza: entra in ogni sessione, e ciò che entra sempre in sessione deve essere essenziale.

Ontologia e grammatica

In SAF l’ontologia è la dichiarazione di ciò che un progetto ha: entità, identificativi, topic, ruoli, stati e relazioni. La grammatica è la forma in cui quelle parole si scrivono in una scheda. I verbi stanno fuori da tutte e due: sono i comandi dell’harness, e sono loro a far passare una scheda da uno stato all’altro. Una relazione invece è un dato della scheda, scritto nella sua intestazione con il suo perché.

L’ontologia non viaggia come classe: viaggia come dato. Le entità le nomina il metodo: il progetto le elenca nella configurazione e ne tara i parametri, ma non può aggiungerne né toglierne. Al progetto appartengono i topic, definiti nella configurazione. Ruoli, stati e relazioni sono vocabolari chiusi, scritti nel codice, perché due repository differenti restino confrontabili.

1 Ruoli      (Saf.Contracts/Roles.cs)      work | study | record
2 Stati      (Saf.Contracts/States.cs)     open, in-progress, paused,
3                                          merged, released, done, dropped
4 Relazioni  (Saf.Contracts/Relations.cs)  extends, supersedes, closes, requires

Il metodo basic ha tre entità. Il task ha ruolo work: si apre e si chiude, ha un piano a passi, vive in una cartella con i suoi step accanto. Il bug e il backlog hanno ruolo record: si annotano, e si chiudono quando un lavoro li assorbe. Un record non passa mai per in-progress, e il codice lo esprime come definizione, non come policy.

Per ogni stato è stabilito chi lo scrive. Sul percorso principale di un task bastano tre verbi; il capitolo sul ciclo dà la tabella intera:

Stato Chi lo scrive
open saf create-card
in-progress saf create-card step, all’apertura del primo step
done saf close-card

L’agente non scrive mai la riga di stato a mano, ed è esattamente il divieto che abbiamo visto scattare nella figura 2.

Senza una grammatica un collegamento resterebbe prosa, e ognuno lo scriverebbe a modo suo. Per questo l’intestazione di una scheda ha una forma fissa:

 1 ---
 2 status: done
 3 topic: architecture
 4 relations:
 5   - type: closes
 6     ref: bug/001
 7     why: "i messaggi fuori ordine che il consumatore a valle rifiuta"
 8   - type: extends
 9     ref: task/003
10     why: "il lavoro che ha aperto il canale push, dove il bug è stato visto"
11 links:
12   - to: documents/architecture/hld.md
13     why: "§4, la scrittura sincrona e il vincolo sull'ordine dei messaggi"
14 ---
15 
16 # La coda esclusa per il riordino dei messaggi

Il why è obbligatorio su ogni arco e su ogni citazione. Il parser non è un parser YAML generico: conosce queste chiavi e sul resto avverte, senza rifiutare e senza lanciare eccezioni. La ragione è nel ciclo degli hook: su una modifica, il testo finale esiste soltanto su disco dopo che lo strumento è stato eseguito. Per questo la grammatica si controlla dopo, in PostToolUse, con una nota all’agente, e non prima con un divieto.

Anche il vocabolario con cui l’harness parla all’agente è chiuso: piece of work, work copy, main copy, card, record, status line. Un test cerca i sinonimi banditi in ogni testo che l’eseguibile emette. Un solo nome per ogni cosa, perché l’agente riconosca il verbo dal nome. In queste pagine uso gli equivalenti italiani: lavoro, copia di lavoro, copia principale, scheda, record, riga di stato. Una copia di lavoro è un worktree git, uno per ogni lavoro aperto, sul ramo {entità}/{id}; il capitolo sul lavoro la racconta per intero.

I documenti

Le schede non sono abbastanza. Esse hanno una funzione importante nel tracciare e distillare quello che Senior e agente fanno durante l’esecuzione di una unità di lavoro, ma al di fuori di questa ? E’ evidente che ci sono momenti nello sviluppo di un software dove si rende necessario formalizzare altro oltre al lavoro fatto. Un sal al cliente, un runbook su come si avvia la soluzione e così via. Per queste ragioni SAF porta il concetto di “documento” all’interno dell’harness in maniera autorevole.

La cartella documents/ contiene i documenti che l’agente formalizza al di fuori delle schede di lavoro. Il primo segmento del percorso è un topic dichiarato oppure richiama un’entità con il suo identificativo. Nella radice della cartella non si scrive nulla, al di sotto il progetto si organizza come vuole rispettando solo topic o identità della scheda.

1 documents/architecture/hld.md    →  topic = architecture
2 documents/task/004/misure.md     →  about = task/004

Ogni documento dichiara la sua natura, chiuso o vivo, e le sue fonti. Esiste però un documento speciale, uno solo per progetto: il disegno di alto livello o hld.md. Le schede di lavoro lo leggono e ne citano la sezione nei propri links, ma non lo modificano mai. Ciò che un lavoro ha smentito resta nella scheda che lo ha scoperto, e una solo una skill dedicato, a lavoro concluso, riporta le modifiche a quel documento. Il capitolo sul lavoro torna su questo punto, perché è il modo in cui il disegno resta allineato al codice invece di raccontare ciò che il codice era.

La cerimonia

Tutto ciò che il capitolo ha descritto serve a un ciclo di sviluppo che, visto da fuori, è corto. Lo chiamo cerimonia perché ha dei gesti fissi e ordinati. La figura 3 lo mostra per intero.

Figura 3 — La cerimonia: una richiesta, sei gesti su due copie, due arresti in cui la decisione torna al Senior, il confine della memoria su cui il lavoro deposita, e i due giri di fine iterazione. Immagine prodotta con intelligenza artificiale (Claude Code) su indicazione dell’autore.
Figura 3. Figura 3 — La cerimonia: una richiesta, sei gesti su due copie, due arresti in cui la decisione torna al Senior, il confine della memoria su cui il lavoro deposita, e i due giri di fine iterazione. Immagine prodotta con intelligenza artificiale (Claude Code) su indicazione dell’autore.

Il metodo basic promette una cosa sola: il lavoro è atomico. Un identificativo, un ramo, una scheda concordata con il Senior, il lavoro, un merge a oracolo verde.

La richiesta parte da me, e prima di aprire qualunque cosa l’agente interroga la memoria. saf create-card apre in un gesto la scheda, il ramo e la copia di lavoro; lì l’agente scrive il piano e si ferma, perché il piano si concorda prima del codice. Poi lavora uno step alla volta, e si ferma di nuovo a ogni step chiuso. Con l’oracolo verde, e una revisione se la chiedo, saf close-card chiude la scheda e fa atterrare il lavoro in un merge solo. Il capitolo sul lavoro percorre questi gesti uno per uno. A lavoro concluso, due giri che chiedo io tengono allineato il resto. /saf-hld aggiorna il documento di disegno quando un lavoro chiuso ha scoperto che le cose stanno diversamente da come diceva. /saf-groom ripassa i bug e i backlog ancora aperti, e le regole di dominio che nessun test verifica, e di ciascuno chiede se sia ancora vero.

Il ciclo così com’è mostra dove stanno i concetti della Parte 1. Il mandato è la richiesta del Senior diventata scheda concordata; l’autonomia è quella fra un arresto e l’altro; l’arresto è scritto due volte nel metodo, al piano e a ogni step; la verifica è l’oracolo legato a un commit; la persistenza è la traccia su file, la regola distillata e il disegno riallineato; il contesto è ciò che l’harness inietta e ciò che il richiamo restituisce, perché la sessione successiva ritrovi tutto questo senza doverlo chiedere.

Principio, metodo, implementazione

Torniamo alla figura 1. Ci sono tre processi, il filesystem è la fonte e Git conserva la storia. SAF, da solo, non fa nulla: viene chiamato dall’agente o dal runtime quando serve.

Il punto di partenza è semplice: l’harness può stringere, mai allargare. La decisione passa da un JSON e non dal codice d’uscita. Se un handler si rompe, gli altri possono comunque produrre il proprio verdetto. Se invece si rompe l’intero hook, SAF esce con zero e restituisce un JSON neutro; il runtime continua come se l’harness non ci fosse.

È una scelta deliberata, ma non gratuita. In quel caso la regola non viene applicata. L’unica traccia è una riga critica nel log diagnostico, che diventa quindi il primo posto da guardare quando qualcosa è passato senza essere fermato.

Da questo principio deriva il metodo: una configurazione versionata e non modificabile dall’agente. A ogni avvio SAF la usa per ricavare ciò che il runtime deve vedere. Le informazioni che governano l’agente non vengono così duplicate a mano in più punti, con il rischio che finiscano per divergere.

Il resto è implementazione e può cambiare. C# e Native AOT servono a contenere il costo d’avvio; SQLite costruisce l’indice; un modello locale produce i vettori; sei hook di Claude Code coprono il punto di integrazione scelto, sui trentatré disponibili. Un altro harness potrebbe usare tecnologie diverse e ottenere lo stesso risultato. Quello che non dovrebbe cambiare sono i due confini: configurazione e derivati da una parte, harness e memoria dall’altra.

Il perché viene dopo. È il caso concreto che mostra da dove nasce questa architettura e che cosa mi aspetto che SAF faccia.

Ricette

  • Sezione assente, funzione spenta. Per adottare un harness senza subirlo tutto → una configurazione in cui ogni sezione accende una funzione e la sua assenza non ha effetti. Forza: facilitare. Livello: metodo. Si verifica con: la configurazione minima, che osserva e non nega.

  • Exit zero, decisione nel JSON. Per non fermare l’agente quando l’harness sbaglia → ogni hook esce con zero e, in caso di errore interno, risponde con un JSON neutro; il divieto è un campo, non un codice d’uscita. Forza: impedire, ma solo per ciò che l’harness ha capito. Livello: implementazione. Si verifica con: un test che rompe la configurazione e controlla che l’output sia {}.

  • Una riga, e niente a mano nei file generati. Per convivere con i file di Claude Code senza possederli → una riga di import nel file di istruzioni, entry riconoscibili negli hook, un prefisso riservato per skill e subagent riscritti a ogni avvio. Forza: facilitare. Livello: metodo. Si verifica con: un secondo saf start, che non deve cambiare nulla di ciò che non è suo.


  1. Misura documentata nel repository di SAF (docs/architecture.md, sezione Costo della Harness): Windows 11 (10.0.26200), Intel64 Family 6 Model 165, 12 core, saf 1.0.2 Native AOT, 21 agosto 2026; mediana di 30 iterazioni dopo 3 di riscaldamento, quindi avvio a caldo. I 650 ms sono la mediana della build single-file con runtime e JIT, misurata con la stessa procedura; i 20 ms seguono il passaggio della configurazione a JSON source-generated.↩︎

  2. Anthropic, «Hooks reference», documentazione di Claude Code, consultata il 24 settembre 2026. Alla data elenca trentatré eventi; il payload su stdin porta hook_event_name, session_id, cwd e i campi specifici dell’evento (tool_name, tool_input, prompt); l’output su stdout usa hookSpecificOutput con permissionDecision e permissionDecisionReason per PreToolUse, e additionalContext per aggiungere contesto. La stessa pagina fissa a 1,5 secondi il budget condiviso degli hook di SessionEnd (alzabile fino a 60 secondi con un timeout esplicito), e raccomanda il sistema dei permessi, non gli hook, per imporre un divieto assoluto.↩︎

  3. Anthropic, «Automate actions with hooks», sezione Stop hook hits the block cap, consultata il 24 settembre 2026: «Claude Code overrides a Stop hook after it blocks eight times in a row without progress»; il campo è stop_hook_active, il tetto si alza con la variabile CLAUDE_CODE_STOP_HOOK_BLOCK_CAP.↩︎

  4. Anthropic, «How Claude remembers your project», consultata il 24 settembre 2026: la sintassi @path/to/file, la profondità massima di quattro import, il fatto che gli import dentro blocchi di codice non vengano espansi, e la rilettura del CLAUDE.md della radice dopo la compattazione.↩︎

  5. Anthropic, «Hooks reference», sezione Exec form and shell form, e «Automate actions with hooks», sezione sulla risoluzione dei problemi, consultate il 24 settembre 2026: in forma shell il comando passa per sh -c su macOS e Linux, per Git Bash su Windows o per PowerShell se Git Bash manca; l’output di un profilo che stampa qualcosa si antepone al JSON e ne impedisce la lettura. Con args l’eseguibile viene lanciato direttamente, senza shell.↩︎

  6. Anthropic, «Create custom subagents», consultata il 24 settembre 2026: file .claude/agents/<nome>.md con frontmatter name, description, tools, model; esecuzione in una finestra di contesto separata.↩︎