Surfaces hôtes¶
Un projet Grimoire décrit des personas, des compétences, des commandes et une gouvernance. Chaque hôte agentique — Claude Code, GitHub Copilot, opencode, Codex, Cursor, Gemini CLI — expose une part de cela sous forme exécutable : fichiers de sous-agents chargés dans leur propre fenêtre de contexte, compétences chargées à la demande, commandes utilisateur, hooks de cycle de vie capables de refuser une action ou une clôture, table de permissions déclarative.
Décrire un projet en prose et laisser chaque hôte la lire plafonne tout le monde au plus petit dénominateur commun. La couche « surfaces hôtes » fait l'inverse : une description host-neutre du projet, un émetteur par hôte.
projet (agents, standard, mémoire)
│
▼
ProjectSurface ← description host-neutre, construite une fois
│
├──▶ émetteur Claude Code → .claude/{agents,skills,commands}, settings.json
├──▶ émetteur Copilot → .github/{agents,skills,prompts,hooks}
├──▶ émetteur opencode → .opencode/{agent,skill,command,plugin}, opencode.json
└──▶ émetteur prose → AGENTS.md · GEMINI.md · .cursor/rules
La règle de gouvernance, elle, n'est écrite qu'une fois. Le module de décisions est du Python host-neutre ; le module de wire format le traduit dans le JSON de chaque hôte. Un refus formulé sous Claude Code est le même texte que sous Copilot, parce que c'est la même décision.
Commandes¶
| Commande | Effet |
|---|---|
grimoire host list |
Hôtes connus, ce que chacun sait exécuter, hôte détecté |
grimoire host surface |
Description host-neutre du projet |
grimoire host sync --host all |
Génère les surfaces (ajouter --dry-run pour voir sans écrire) |
grimoire host status |
Écart entre ce que le projet déclare et ce que l'hôte exécute |
grimoire host run <slug> |
Corps d'une commande, pour un hôte sans commandes natives |
grimoire init, grimoire up --fix et grimoire standard init appellent la
synchronisation automatiquement. Un appel manuel n'est nécessaire qu'après avoir
ajouté ou modifié une persona.
Ce que chaque hôte exécute¶
| Hôte | Sous-agents | Compétences | Commandes | Hooks bloquants | Permissions |
|---|---|---|---|---|---|
| Claude Code | oui | oui | oui | oui | oui |
| GitHub Copilot | oui | oui | oui | oui | non |
| opencode | oui | oui | oui | oui (plugin tool.execute.before, vérifié) |
non (non prouvé) |
| Codex | non | non | non | non | non |
| Cursor | non | non | non | non | non |
| Gemini CLI | non | non | non | non | non |
Rien n'est abandonné en silence. Ce qu'un hôte ne sait pas exécuter est déclaré
comme dégradation, avec son repli, et remonté par grimoire host status :
- Copilot n'a pas de table de permissions déclarative : les mêmes règles sont
appliquées par le hook
PreToolUse, avec la même formulation de refus. - opencode n'a ni table de permissions prouvée ni autre hook sondé que
tool.execute.before: exécution, écriture et lecture de secrets passent par le plugin.opencode/plugin/grimoire-guard.js(même décision, même formulation de refus) ; une décisionasky est refusée avec son motif, un plugin ne pouvant pas interroger l'utilisateur ; sigrimoire-hookest introuvable, le plugin refuse l'outil en profilgoverned/production(lu dans_grimoire/standard/standard-profile.yaml) et se contente d'un avertissement unique sur stderr sinon. - Les hôtes en prose n'ont pas de hooks : la gouvernance y est énoncée comme règle dans le fichier d'entrée, et n'est opposable qu'en CI. Le catalogue le dit explicitement plutôt que de laisser croire à une protection.
Ce que le kit a observé (contrats réels)¶
Le tableau ci-dessus dit ce que le kit sait écrire pour un hôte. Il ne dit pas
ce que l'hôte fait : « Hooks bloquants : oui » pour Copilot est une
documentation, pas une observation. Le fencing d'un bail (refuser les effets
d'une session périmée) ne s'appuie donc pas sur ce tableau mais sur
grimoire.hosts.capabilities.contract_for, rempli uniquement depuis des
captures versées sous tests/fixtures/hosts/<hôte>/ (un manifest.json par
hôte, une fixture par point). Règle unique : une capacité sans fixture vaut
false, et un hôte dont pretooluse_deny n'est pas vérifié est non fencé.
Une preuve vaut pour la version d'hôte sur laquelle elle a été capturée :
contract_for(host, version) et is_fenced(host, version) exigent la version
et rendent un contrat entièrement non vérifié (donc non fencé) si elle est
absente, inconnue ou différente de la version capturée. recorded_contract(host)
ne sert qu'à auditer la table.
| Capacité | Claude Code 2.1.301 | opencode 1.17.11 | Copilot, Codex, Cursor, Gemini |
|---|---|---|---|
pretooluse_deny : un refus arrête l'outil |
vérifiée (exit 2 + stderr, ou permissionDecision: deny ; l'agent reçoit le motif) |
vérifiée : un plugin lève une Error dans tool.execute.before, l'outil ne s'exécute pas, l'agent reçoit le motif (fixture) |
non vérifié |
session_end |
vérifiée, sortie propre de claude -p seulement (kill non observé) |
non vérifié | non vérifié |
quota_signal : un hook reçoit l'échec de forfait |
non vérifiée (aucun forfait épuisé observé ; rate_limit_event du flux vu en allowed, date en epoch secondes) |
non vérifié | non vérifié |
liveness_probe |
vérifiée : claude agents --json (pid, kind, sessionId, status idle/busy ; ni state ni waitingFor sur les sessions interactives) |
non vérifié | non vérifié |
subagent_model |
vérifiée par le journal du sous-agent (agent_transcript_path) : la charge utile de SubagentStop n'a aucun champ modèle |
non vérifié | non vérifié |
resume_after_quota |
non vérifiée (forfait épuisé non provoqué) | non vérifié | non vérifié |
bg_workspace_trust |
vérifiée : claude --bg refuse un dossier non approuvé (code 1), alors que claude -p ne demande rien |
non vérifié | non vérifié |
bg_cost_source, bg_wake |
non vérifiées (aucune session --bg démarrée) |
non vérifié | non vérifié |
local_token_usage |
vérifiée : usage et modelUsage du flux stream-json, message.usage du journal de session |
non vérifié | non vérifié |
systemd_user_linger |
vérifiée sur le poste de capture (Linger=yes, minuteur utilisateur déclenché, session ouverte) |
non vérifié | non vérifié |
Les captures Claude Code ont été faites sans opérateur, par des appels claude -p
d'un mot dans un projet jetable. Celles d'opencode (17 octobre 2026) l'ont été
avec un fournisseur HTTP local factice (compatible OpenAI, aucun modèle réel) qui
demande un appel d'outil bash : le refus vient de l'hôte, pas du modèle. Le
palier gratuit d'opencode exige 1.18.0 ou plus (1.17.11 installée) : aucun appel
de modèle réel n'a été fait. Ce qui est chargé par l'hôte (instructions via
opencode.json > instructions, agents, skills, commandes, MCP type: local) a
été vérifié avec opencode debug config|agent|skill et une requête réelle vers le
fournisseur factice ; ce qui ne l'est pas : le plugin sur les sous-agents, une
table permission (un refus de lecture en glob n'a pas arrêté une lecture), les
autres événements de cycle de vie, un MCP effectivement lancé. Copilot (VS Code), Codex, Cursor et Gemini n'ont pas été
lancés : leurs manifestes disent « non vérifié » et la capture reste à faire au
poste. Les fixtures remplacent les chemins personnels, l'utilisateur et les
identifiants de session par des marqueurs ; un test refuse tout chemin, courriel
ou jeton.
Hôtes activés¶
grimoire host sync émettait, par défaut, la surface des cinq hôtes connus —
une équipe qui n'utilise que Claude Code recevait quand même le catalogue
Gemini, les règles Cursor, etc. (issue #177). Une alternative « canal plugin »
pour Claude Code a été envisagée puis écartée par décision (2026-09-12) : la
solution retenue est purement déclarative, sans mécanisme d'installation
séparé.
project-context.yaml porte la déclaration :
hosts:
enabled: [claude, copilot] # sous-ensemble de : claude, copilot, codex, cursor, gemini, opencode
Absence de la clé : le comportement bascule en détection — un hôte est
émis s'il a déjà des fichiers dans le dépôt (.claude/ → claude,
.github/copilot-instructions.md ou .github/agents/ → copilot, GEMINI.md
→ gemini, .cursor/ → cursor, .opencode/ ou opencode.json → opencode, AGENTS.md et .codex → codex), et
claude seul si rien n'est détecté. Un projet existant qui n'a jamais connu
cette clé garde donc exactement les fichiers qu'il a déjà — rien ne se
supprime au premier grimoire up qui suit une mise à jour du kit.
grimoire init écrit la clé avec le résultat de cette détection, pour que le
comportement soit explicite dès le premier jour : un répertoire vierge
n'obtient que claude et son pont CLAUDE.md.
Émission : grimoire host sync (et donc grimoire up) n'écrit que les
hôtes activés.
--host all(le défaut) se filtre surhosts.enabled.--host <x>sur un hôte connu mais non activé est un refus nommé : « Hôte gemini non activé — ajoute-le àhosts.enabled… » — sauf--force-host, qui synchronise quand même sans toucher à la déclaration.- Les fichiers déjà émis pour un hôte qu'on vient de désactiver ne sont
jamais supprimés automatiquement : ce sont des « orphelins d'un hôte
désactivé », listés par
grimoire host status --host all(JSON : cléorphans).grimoire host sync --prune-disabledles retire — opt-in explicite, jamais par défaut. Les fusions JSON (.claude/settings.json,.mcp.json) ne sont jamais retirées : elles portent aussi la configuration propre du projet.
grimoire doctor complète ce tableau sans jamais faire échouer le diagnostic :
INFO pour les fichiers orphelins d'un hôte désactivé, WARN si un hôte activé
n'a encore rien émis (généralement : grimoire host sync n'a pas tourné).
Persona d'entrée¶
Chaque projet désigne une persona d'entrée — concierge par défaut — celle qui
tranche quand une demande ne désigne pas clairement un rôle. Aucun hôte ne sait
ouvrir une session à l'intérieur d'un agent : Claude Code n'instancie un
sous-agent que par son outil Agent, Copilot que par le sélecteur de chat. Ce
n'est pas un réglage manquant, c'est une propriété des hôtes, et le catalogue
la déclare : agent_autostart est faux sur les cinq profils, et
grimoire host list le remonte comme une dégradation avec son substitut.
Le substitut est fourni là où un hook session_start s'exécute : le hook remet
la persona à la boucle principale, avant la directive du standard. La session
n'est pas ouverte dans l'agent — elle en adopte la persona en gardant toute la
surface d'outils de l'hôte, et dispatcher un sous-agent reste un choix.
[Grimoire — persona d'entrée]
Cette session adopte la persona d'entrée du projet dans sa boucle principale,
sans sous-agent : **concierge** — Concierge — Triage, clarification, routage…
1. Lis `_grimoire/kit/agents/concierge.md` en entier avant de répondre…
2. Tiens sa frontière d'outils pour ce que tu fais toi-même : read, search, edit, execute.
3. Tu restes la boucle principale : dispatcher un sous-agent reste un choix.
Sur un hôte sans hook session_start, la persona d'entrée est nommée dans le
fichier d'instructions ; c'est le seul endroit qui reste.
La persona d'entrée se déclare dans project-context.yaml :
Un projet qui porte déjà son propre point d'entrée — un orchestrateur chargé
par CLAUDE.md, par exemple — déclare entry: "" et n'en reçoit pas un
second. grimoire host status montre la persona retenue, et signale une clé
qui nomme un agent que le projet n'a pas plutôt que d'en inventer un.
Gouvernance¶
Les hooks générés dépendent de l'enrôlement du projet dans le standard agentique.
| Événement | Décision | Portée |
|---|---|---|
session_start |
directive de session | toujours |
pre_tool_use |
politique d'outils (destructif, secrets) | toujours, bloquant |
user_prompt_submit |
nomme la tâche courante, ou l'absence de tâche et son remède ; rattache la session au claim actif | projet enrôlé |
post_tool_use |
rappel de preuve après écriture ; compte les écritures de la session | projet enrôlé |
pre_compact |
capsule de gouvernance avant compaction | projet enrôlé |
subagent_stop |
état des gates, sans bloquer | projet enrôlé |
stop |
gates de preuve ; clôture hors tâche après une écriture | projet enrôlé, bloquant |
Un projet non enrôlé ne reçoit aucun hook de gate : un gate inexistant ne peut pas être rouge, et bloquer sur son absence ferait du hook un piège.
Le vocabulaire host-neutre reconnaît aussi subagent_start et
post_tool_use_failure (SubagentStart, PostToolUseFailure côté Claude
Code) : un hook qui les cible est câblé par grimoire host sync comme les
sept ci-dessus, mais aucun hook du kit ne les utilise par défaut aujourd'hui.
HostHooks.subagent_start / .post_tool_use_failure disent, par hôte, si
l'événement est documenté ; l'absence d'entrée dans .claude/settings.json
n'était pas un choix mais un oubli — la table du wire format ne les
connaissait pas encore.
Le hook stop¶
C'est le seul endroit où la règle du kit — « une clôture sans gates verts est une
tâche non terminée » — devient une contrainte plutôt qu'une consigne. Sur les
profils governed et production, une tâche dont les gates sont rouges voit sa
clôture refusée, avec la liste de ce qui manque.
Trois garde-fous encadrent ce refus :
- Pas de boucle — si l'hôte relance déjà l'agent à cause d'un blocage précédent, le hook laisse passer.
- Pas de blocage à vide — un projet non enrôlé, un profil
starter, ou une tâche encore à l'étatproposedne bloquent jamais. Ce dernier cas est signalé : un gate vert parce que rien n'est encore exigé ne protège rien, et le hook le dit au lieu de laisser croire le contraire. - Pas de panne fatale — un
task-board.yamlcassé ou une exception dans une décision sortent en « autorisé », avec l'erreur en contexte. Un hook qui plante ne doit pas rendre une session inutilisable.
Travailler hors tâche (issue #638)¶
bootstrap est un repli, pas une tâche : quand rien ne désigne de tâche —
ni GRIMOIRE_TASK_ID, ni claim actif du Mission Ledger, ni carte
in_progress du board — le travail de la session n'apparaît nulle part dans
le ledger. Depuis le lot A de l'issue #638 :
session_start(profilsgovernedetproduction) etuser_prompt_submit(tout projet enrôlé) disent qu'aucune tâche n'est en cours et donnent le remède copiable :grimoire task add "<titre>" -a "<critère>" --owner <nom> --ready,grimoire task context <id>,grimoire task claim <id> --actor <nom>— ou les outils MCPtask_add,task_context,task_claim, ouGRIMOIRE_TASK_ID;post_tool_usecompte danssession-<session_id>.json(champmutations) chaque outil dont la classification n'est pas lecture seule — fichier écrit ou édité, commande Bash mutante ou destructive ; jamais le contenu ;stoprefuse la clôture (decision: block, remède dansreason) quand ce compteur est non nul et que la tâche résolue est encorebootstrap, sur les profilsgovernedetproduction; les autres profils reçoivent le même texte ensystemMessage, sans blocage. Une session qui n'a fait que lire ou répondre n'est jamais bloquée, pas plus qu'un hôte qui n'envoie pas desession_id(aucun journal, compteur à zéro).stop_hook_activelaisse toujours passer le secondStop.
Dès que la tâche résolue est un claim du ledger, user_prompt_submit (et
session_start) rattachent la session à ce claim par un événement
task.session_attached — claim.session_id et claim.session_host
apparaissent au rejeu, l'historique n'est jamais réécrit, et un claim déjà
rattaché à une autre session n'est pas volé. Le journal session-<id>.json
porte alors task_id. Le cockpit (espace Exécuter) montre sur la carte la
session et l'hôte, et dans l'inspecteur la commande de reprise quand l'hôte
en a une (claude --resume <session_id> pour Claude Code ; identifiant seul
pour les autres).
Politique d'outils¶
La décision pre_tool_use fait passer chaque appel mutant par le moteur de
politique du kit. Le nom de l'outil est lu dans le vocabulaire de n'importe quel
hôte (Bash comme run_in_terminal, Edit comme replace_string_in_file),
puis classé en famille neutre.
| Constat | Verdict |
|---|---|
Suppression récursive, force push, terraform destroy, kubectl delete… |
refus sous les profils non stricts, confirmation demandée en strict |
Lecture d'un fichier de secrets (.env, clés privées, credentials.json…) |
refus à tous les profils |
| Appel en lecture seule | autorisé sans traitement |
Politiques temporelles¶
Point 3 de l'audit de positionnement 2026-09-12
(docs/audits/positionnement-2026-09-12.md) : les règles ci-dessus jugent
une requête isolée, sans notion de session. _grimoire/standard/policies.yaml
ajoute des règles temporelles, portées par les mêmes champs
PolicyRule (voir src/grimoire/policies/schemas.py) — rétro-compatibles :
un projet qui ne crée pas ce fichier n'est pas affecté.
rules:
- id: rm-requires-approval
description: "rm/rf demande une confirmation, une fois par session"
action_kinds: []
mutation_classes: []
risk_profiles: []
verdict_on_match: warn
reason_template: "Suppression demandant une approbation explicite"
tool_pattern: "Bash(rm:*)" # clé d'outil façon permissions Claude Code — voir plus bas
require_approval: true # ask à la 1re occurrence de la session, allow ensuite
cooldown_after: {pattern: "Bash(rm:*)", count: 5, minutes: 10}
- id: session-write-budget
description: "Pas plus de 50 écritures par session, alerte au-delà"
action_kinds: []
mutation_classes: []
risk_profiles: []
verdict_on_match: warn # jamais "block" sans tool_pattern — voir le garde-fou plus bas
reason_template: "Budget d'écritures de session dépassé"
per_session: {max_writes: 50, max_tool_calls: 200, max_cost_usd: 2.0, max_duration_min: 60}
Règles. Quatre clés nouvelles, toutes optionnelles : tool_pattern,
require_approval (booléen), per_session
(max_tool_calls/max_writes/max_cost_usd/max_duration_min/subagents,
chacun optionnel) et cooldown_after (pattern/count/minutes). Une clé
inconnue sous PolicyRule, per_session ou cooldown_after échoue au
chargement avec une GrimoirePolicyError nommée (GR-POL-002) — jamais
silencieusement ignorée.
Comptage (max_tool_calls vs max_writes). Corrigé le 2026-09-12
(#463) : max_writes ne compte que les appels réellement mutants —
Write/Edit/NotebookEdit, et un Bash dont la commande n'est pas
reconnue comme lecture seule (grimoire.hosts.decisions.tool_facts.is_read_only_command :
cat, grep, find, git status, gh pr view… reconnus, tout le reste
resté conservateur — classé mutation par défaut). max_tool_calls, lui,
compte tout, lectures comprises : c'est sa raison d'être, un budget de
volume total d'appels plutôt que de mutation.
Clé d'outil (tool_pattern / cooldown_after.pattern). Corrigé le
2026-09-12 (#449) : ces motifs n'étaient comparés qu'au nom nu de l'outil
(Bash, Write…), donc un motif de la forme Tool(...) — celle utilisée
dans les exemples ci-dessus et dans les permissions Claude Code — ne
correspondait jamais. Bash(git push:*) était comparé à la seule chaîne
"Bash" et ne matchait rien ; require_approval: true répondait allow dès
le premier git push.
Un motif sans parenthèses ("*", "Bash") reste comparé au seul nom
d'outil — rétro-compatible avec toute règle écrite avant ce correctif. Un
motif Tool(corps) compare Tool au nom d'outil, puis corps à un second
terme, la clé de l'appel : la commande shell complète pour Bash (et tout
outil qui exécute une commande), le chemin cible pour Write/Edit/Read,
et le nom nu de l'outil (donc aucune clé additionnelle) pour un outil MCP
tant qu'aucune convention d'arguments n'existe. Dans corps, un : suivi
d'un * final (rm:*, git push:*) sépare le préfixe de commande du
joker — exactement la convention des permissions Claude Code : Bash(git
push:*) matche toute commande qui commence par git push (mot entier :
git pushx ne compte pas), pas une commande contenant littéralement un
:. Ailleurs, corps reste un glob simple (* seul joker) contre la clé,
comme dans Write(_grimoire/standard/*).
État de session. Les compteurs vivent dans
_grimoire-output/.runs/session-<session_id>.json (écriture atomique, comme
le cache d'état de standard #422), un fichier par session, jamais de secret ni
de contenu d'outil — seulement des compteurs et des horodatages. SessionStart
le réinitialise ; un fichier absent, tronqué ou d'une version de schéma
inconnue redevient une session neuve, jamais une erreur. Le même fichier porte
task_id (la tâche du ledger que la session travaille) et mutations (le
nombre d'écritures observées), voir « Travailler hors tâche » plus haut.
Décision et hôtes. Un budget atteint (au N+1ᵉ appel, pas avant) ou un
refroidissement actif rendent block (deny côté hôte). Une règle
require_approval non encore approuvée rend warn, traduit en
permissionDecision: "ask" sur un hôte qui sait demander (Claude Code) ou en
refus motivé sinon (docs/hosts-reference de la Forge documente cette
distinction par hôte). Le calcul lui-même (règle + état de session → verdict)
est porté à l'identique en Python
(src/grimoire/policies/temporal.py) et en Rust
(rust/grimoire-policies-core/src/lib.rs, fonction
evaluate_one_temporal_rule) — le Rust est l'oracle, testé en parité
(tests/unit/test_policies_rust_parity.py) ; la persistance de l'état reste
Python dans les deux cas.
Exemption de réparation. Ajoutée le 2026-09-12 (#463) après un incident
réel : une règle per_session en block n'atteint jamais
Read/Glob/Grep (ni un Bash classé lecture seule), ni une écriture qui
cible _grimoire/standard/policies.yaml ou
_grimoire-output/.runs/session-*.json — les deux seuls fichiers qui
permettent de lever le blocage. L'exemption est codée en dur (pas une clé de
configuration désactivable), mirée à l'identique en Rust, et ne compte pas
dans les compteurs de la règle qu'elle contourne. Sans elle, un budget global
en block pouvait refuser jusqu'à l'édition du fichier qui le portait.
S'applique aux quatre dimensions de per_session alike
(max_tool_calls/max_writes/max_cost_usd/max_duration_min) : c'est le
même appel qui les protège toutes.
Rechute constatée le 2026-09-14 sur Grimoire-Forge (#481) : l'exemption
couvrait déjà tout appel non mutant et l'édition des deux fichiers ci-dessus,
mais pas la commande de remise à zéro elle-même. grimoire policies
reset-session est un appel Bash dont le premier mot (grimoire) n'est
reconnu ni lecture seule (is_read_only_command) ni comme ciblant l'un des
deux fichiers exemptés — sa ligne de commande, pas un chemin. Résultat :
une session dont la durée avait dépassé max_duration_min refusait la
commande que son propre message de refus recommandait. Corrigé en
reconnaissant l'invocation de grimoire policies (peu importe le
sous-verbe — status ou reset-session), dans ses trois formes documentées
(grimoire policies …, <venv>/bin/grimoire policies …, python -m
grimoire policies …), sans élargir l'exemption à toute commande qui se
contente de la nommer (echo grimoire policies … reste refusé).
Sous-agents. Le payload de hook de Claude Code envoie le même
session_id pour un appel d'outil fait par un sous-agent (son outil Task)
que pour un appel de la session parente, et ne documente aucun champ
(agent_id, parent_session_id…) pour les distinguer. Tout sous-agent
lancé depuis une session partage donc le budget de sa session parente —
per_session.subagents le nomme explicitement : "shared" (défaut, seule
valeur supportée) assume ce partage ; "separate" échoue au chargement
(GrimoirePolicyError, GR-POL-003) plutôt que de laisser croire à une
isolation qui n'existe pas dans les hôtes actuels.
Garde-fou de conception. grimoire doctor signale (WARN,
policy_budget_guard) toute règle per_session sans tool_pattern (donc
"*", tous les outils) déclarée en verdict_on_match: block : une fois son
plafond atteint, une telle règle refuse tout le reste de la session, exemption
de réparation mise à part. Ce n'est jamais un FAIL — un projet peut
l'assumer — mais ce n'est plus silencieux. L'exemple ci-dessus utilise
délibérément warn plutôt que block ; un budget en block doit cibler un
tool_pattern précis (Bash(rm:*), Write…), jamais *.
max_duration_min mesure depuis SessionStart, pas depuis le dernier
appel. Le compteur part de started_at (l'horodatage de la première
initialisation de l'état de session, voir "État de session" ci-dessus) et ne
se réinitialise jamais tout seul — ni sur une pause, ni sur une nuit, ni sur
un week-end. Une session Claude Code peut rester ouverte plusieurs jours
(reprise d'une tâche en pause, session longue laissée en arrière-plan) : une
fenêtre qui paraît généreuse en heures de travail réelles (24h, 1440) est
en pratique dépassée par la seule durée écoulée, pas par l'activité. Depuis
le 2026-09-14 (#481), grimoire doctor signale (WARN,
policy_budget_duration_guard, au même endroit que policy_budget_guard
ci-dessus) toute règle per_session sans tool_pattern en
verdict_on_match: block dont max_duration_min est inférieur à 2880
(48h) — le seuil en-deçà duquel une session multi-jours franchit la fenêtre
sans qu'aucune vraie dérive ne se soit produite.
require_approval ne se marque jamais approuvée depuis PreToolUse :
demander (ask) n'est pas une preuve que l'humain a dit oui, donc rien dans
le chemin de décision de PreToolUse ne met approved à true — une
tentative refusée puis rejouée redemande, exactement comme la première fois.
Seul PostToolUse — l'événement qu'un hôte n'émet que si l'outil a
réellement tourné, donc que le ask précédent a été accordé — enregistre
l'approbation, via record_post_tool_use_approval
(grimoire.policies.temporal), appelée depuis la décision PostToolUse
existante (grimoire.hosts.decisions.evidence_trace). L'approbation porte
sur l'empreinte de l'action exécutée (outil + entrée complète de l'outil —
toutes les cibles, arguments et contenu —, la commande n'étant normalisée que
par ses blancs hors guillemets, et pas du tout dès que le shell lit lui-même
ces blancs : ${...}, $[, [, motifs extglob, ni dès qu'un caractère hors
ASCII (guillemets typographiques) apparaît ; description et timeout du
Bash natif, explanation et goal du run_in_terminal de Copilot sont
ignorés) :
seule une action identique passe en allow silencieux, toute autre action
correspondant au motif redemande. Les empreintes sont conservées dans
approved_fingerprints (64 au plus par règle, les plus anciennes sortent) ; un
état hérité sans empreinte redemande, et une session neuve aussi. Un hook peut
réécrire l'entrée de l'outil entre PreToolUse et PostToolUse (RTK :
git status devient rtk git status, et PostToolUse reçoit l'entrée
réécrite) : quand l'hôte envoie un tool_use_id, PreToolUse retient
l'empreinte et les règles concernées dans pending_approvals
(remember_pending_approval) et PostToolUse les reprend par cet identifiant
au lieu de les recalculer ; sans identifiant, il retombe sur sa propre entrée
(une entrée réécrite redemande alors, jamais plus lâche). Sans cette étape, un garde require_approval
échouait ouvert (la version initiale de ce chantier faisait cette erreur,
corrigée en revue le 2026-09-12).
Visibilité et remise à zéro. grimoire policies status [--session-id ...]
[--json] affiche les compteurs et budgets restants de la session courante
(ou la plus récente si aucune n'est précisée), et signale désormais
(ATTENTION) tout budget à moins de 10 % de son plafond restant — avant le
refus, pas seulement au moment du refus. grimoire policies reset-session
[--session-id ...] [--json] supprime l'état d'une session (la courante par
défaut), avec confirmation nommant le fichier supprimé ; un fichier d'état
absent, tronqué ou d'une version de schéma inconnue redevenant une session
neuve (voir plus haut), c'est suffisant pour lever un blocage sans attendre
une session neuve. Le message de refus d'un budget nomme systématiquement
cette commande et le fichier de règles :
"... ; grimoire policies reset-session ou relever per_session dans
_grimoire/standard/policies.yaml". Cette recommandation est elle-même
couverte par l'exemption de réparation ("Exemption de réparation"
ci-dessus) : un refus ne nomme jamais une commande qu'il refuserait
lui-même. Le cockpit n'a pas de vue équivalente pour l'instant.
Coût des hooks¶
Un hook s'exécute une fois par appel d'outil : son coût est une propriété de conception, pas un détail d'implémentation.
- Les configurations générées invoquent
grimoire-hook, un script console dédié. Passer pargrimoire host hookconstruit tout l'arbre de commandes avant d'en résoudre une seule — 391 ms par appel contre 102 ms. La sous-commande reste disponible pour un usage humain. - Le chemin de décision n'importe pas le moteur du standard au chargement : évaluer des gates en a besoin, décider d'un appel d'outil non. Un test échoue si cette frontière est franchie.
- Sur un hôte doté d'une table de permissions,
Readne figure pas dans le matcher : les fichiers de credentials y sont déjà refusés déclarativement, à coût nul. L'accès par commande shell reste couvert parBash.
Ce que la gouvernance enregistre¶
Chaque appel d'outil réellement évalué et chaque décision de clôture sont
consignés dans _grimoire-output/traces/traces.jsonl. Un refus qui ne laisse
pas de trace ne peut pas être mesuré, et un garde-fou non mesuré reste une
affirmation.
Le ledger répond à une autre question : ce qui s'est réellement passé.
TraceLedger.policy_block_rate() donne la fraction des appels évalués qui ont
été refusés.
Trois limites assumées :
- les appels en lecture seule n'écrivent rien — ils sortent avant toute évaluation, et le chemin le plus chaud reste libre ;
- les arguments sont hachés, jamais stockés tels quels : le fichier part sur disque et s'exporte en OTel, une trace qui cite une commande devient une fuite ;
- un ledger illisible n'interrompt rien — l'observabilité ne vaut jamais une session cassée.
Le choix de la persona d'entrée y laisse aussi un fait dédié : hosts.decisions
enregistre quel agent a été retenu, quand, dans quel projet — rien du contenu
échangé (#366). grimoire registry dispatches relit ce fait, agrégé par agent,
avec le nombre de choix observés et la date du dernier.
Non-choix¶
Le choix n'est que la moitié du signal : le concierge cherche parfois un spécialiste sans en trouver, ou se rabat sur un généraliste faute de mieux. C'est ce non-choix qui indique un besoin non couvert — sans lui, aucun déclencheur ne peut proposer la création d'un agent qui manque (#355).
Cette résolution se fait dans le raisonnement de la persona concierge
(archetypes/meta/agents/concierge.md), pas dans du code du kit qui pourrait
l'observer lui-même. La persona appelle donc grimoire agent-miss à chaque
non-choix :
grimoire agent-miss --category tests
grimoire agent-miss --category infra --specialty terraform \
--fallback backend-engineer --reason "aucun agent terraform déclaré"
--category et --specialty classent la demande, ils n'en portent jamais le
contenu — la persona ne transmet qu'une étiquette, jamais ce qui a été dit.
Le fait rejoint le même journal que le choix (agent.miss au lieu de
agent.dispatch), écrit best-effort de la même façon : un journal
indisponible ou illisible ne fait jamais échouer la commande.
grimoire registry dispatches montre les deux côte à côte : les choix
d'agent, et les non-choix agrégés par spécialité manquante, avec leur
compte et la date du plus récent — de quoi répondre à « quelle spécialité a
manqué, et combien de fois ».
Frontière d'outils des personas¶
Chaque persona est projetée avec une frontière d'outils. Elle vient du champ
tools: de son frontmatter quand il existe :
Sans ce champ, la frontière est déduite du texte de la persona : lecture et
recherche toujours, écriture et exécution seulement sur un signal explicite.
grimoire host status liste les personas dont la frontière est déduite —
l'ajout d'un tools: explicite est la façon de la figer.
Verbes disponibles : read, search, edit, execute, web. Chaque émetteur
les traduit dans le vocabulaire de son hôte.
Fichiers générés et fichiers à vous¶
Les fichiers générés portent un marqueur grimoire:managed. La synchronisation
ne réécrit qu'eux :
- un fichier écrit à la main à un chemin géré est préservé et signalé comme
conflit (
--forcepour l'écraser sciemment) ; .claude/settings.jsonn'est jamais réécrit en bloc : seules les entrées de hooks appartenant au kit sont remplacées, le reste de la configuration est conservé tel quel ;- une synchronisation répétée ne produit aucune écriture si rien n'a changé.
Pour personnaliser durablement, modifier la source — la persona dans
_grimoire/, la compétence ou la commande du kit — puis resynchroniser.
Chaque chemin a un seul propriétaire. .github/agents/ appartient à l'émetteur
Copilot, .github/prompts/ au scaffolder pour les workflows du kit, et
.claude/** à l'émetteur Claude Code. Deux générateurs sur un même fichier
produisent un conflit permanent, jamais un contenu stable.
Hôtes sans émetteur¶
Un hôte sans émetteur n'est pas pour autant sans surface : MCP est le seul canal que tous partagent, et le serveur Grimoire y expose les trois primitives.
| Primitive | Contenu | Ce que le client en fait |
|---|---|---|
| Prompts | les commandes du projet | des slash commands, sans émetteur |
| Resources | les compétences, sous grimoire://skill/<slug> |
un corps chargeable à la demande |
| Tools | grimoire_host_status, grimoire_skill, grimoire_command, task_list_ready, task_claim, … |
l'inventaire, l'état, et les tâches du ledger |
C'est la réponse à « peu importe l'hôte » — pour le contenu. La contrainte (hooks, permissions) reste l'affaire des émetteurs : MCP n'intercepte rien.
Ajouter un hôte se fait en écrivant un émetteur et un profil de capacités ; ni la description du projet, ni les règles de gouvernance ne changent.