Mettre à jour un projet (grimoire upgrade-flow)¶
Issue #490. grimoire up régénère le tier
kit et synchronise les surfaces hôtes — une étape d'une vraie mise à niveau, pas toute la mise à niveau.
grimoire upgrade-flow conduit registry/blueprints/project-upgrade.blueprint.json à travers le moteur
de flows réel (grimoire flow run/resume) pour faire le reste : sauvegarde préalable, détection et
archivage des agents orphelins, application, puis deux nœuds de jugement qui ne font jamais qu'une
proposition — jamais une écriture — pour ce qui exige un arbitrage humain.
Nommée upgrade-flow et non upgrade : ce dernier nom est déjà pris par grimoire upgrade, la
migration de structure v2 → v3 (grimoire.cli.cmd_upgrade). Deux sens différents de « upgrade »
avaient besoin de deux commandes, pas d'une seule surchargée.
Les nœuds, dans l'ordre¶
- backup (V0, mécanique) — tarball de
_grimoire/,.claude/,.github/{agents,prompts, instructions},GEMINI.md,AGENTS.md,CLAUDE.md,.mcp.json,project-context.yamlsous_archive/<date>-pre-<version>/, plus un manifeste SHA-256 de_grimoire/_memory/. Idempotent : un second passage le même jour ne réécrit rien. - preview (V0) —
up --dry-run+host sync --dry-run, diff écrit dans_grimoire-output/upgrade/<date>/preview.md. Enregistre aussi une ligne de base de tous les contrôlesgrimoire doctor(_grimoire-output/upgrade/<date>/doctor-baseline.json) : les références_grimoire/...déjà mortes (dead_references, granularité par ligne, issue #502) et, depuis l'issue #510 (point 1), toute autre FAIL déjà présente (other_failures, une signature"<contrôle> : <détail>"par check — ex.agents_referenced). C'est cette ligne de base qui distingue, au nœudapply, un défaut préexistant d'une vraie régression, quel que soit le contrôle en cause. - orphans (V0) — agents/wrappers portant le marqueur
grimoire:managedque le kit installé ne livre plus (comparaison entre ce qui est sur disque et le plan du scaffolder pour l'archétype configuré —grimoire.cli.cmd_up.fresh_kit_agent_roster, qui ne write rien). Déplacés vers_archive/<date>-pre-<version>/orphans/, jamais supprimés. Doit tourner avantapply: la migration réelle du 2026-09-11 (Terraform-HouseServer, 3.38.0 → 3.44.2) a montré queup/host syncrefusent (garde de distinction : deux agents au même faisceau) tant qu'un orphelin partage encore le faisceau d'un agent courant.
Depuis l'issue #510 (point 2), ce nœud couvre aussi une seconde forme d'orphelin, indépendante de la
première : une projection hôte managée (.claude/agents/<nom>.md, .github/agents/<nom>.agent.md,
…) sans aucune source installée dans aucune tier — kit, overrides, ni la tier custom héritée. Ce
cas échappe à la boucle principale (partie de layout.installed_agents, donc aveugle à un nom sans
fichier installé) et à host sync (qui ne revisite que ce que son plan actuel résout — un nom sans
source n'y apparaît jamais) : c'est exactement la forme trouvée sur la Forge, deux projections
grimoire:managed survivantes d'un gabarit retiré du manifeste par une passe d'archivage antérieure,
que grimoire doctor signalait (agents_referenced) sans que rien ne les retire. Choisi ici plutôt
que dans host sync : ce nœud possède déjà le mécanisme d'archivage (jamais de suppression) et tourne
déjà avant apply, donc avant que doctor ne juge le projet mis à niveau. Un fichier hôte sans le
marqueur grimoire:managed n'est jamais un candidat, quel que soit son nom.
4. apply (V0) — grimoire up (refresh du tier kit + host sync). Acceptance : doctor -o json sans
FAIL et le hook SessionStart rejoué sans échec structuré (clé error de premier niveau, ou le
marqueur exact [Grimoire] hook <id> en erreur en tête d'un bloc rendu — jamais une recherche libre
du mot « erreur », qui faisait échouer apply sur un simple rappel de mémoire le mentionnant, issue
#502). Une référence _grimoire/... morte (check paths_resolve) ou tout autre contrôle doctor FAIL
(issue #510, point 1) ne fait échouer ce nœud que s'il est absent de la ligne de base que
preview a enregistrée — un défaut déjà présent avant la mise à niveau devient une proposition
repair (grimoire.proposals, artifact_type: "repair") au lieu de bloquer le flow :
paths_resolve nomme fichier:ligne et propose une substitution vers la tier kit quand elle est
évidente (propose_repairs) ; tout autre contrôle nomme le contrôle et son détail, toujours en
revue humaine (propose_doctor_repairs, category: "doctor-preexisting"). grimoire upgrade-flow
apply/run rapportent alors clairement « mis à niveau, N défaut(s) préexistant(s) en proposition »
plutôt qu'un simple échec.
Quand apply refuse malgré tout (une vraie régression, absente de la ligne de base), le flow a déjà
modifié le projet — up a tourné — et le dit explicitement plutôt que de laisser croire que rien n'a
bougé (issue #510, point 3) : grimoire upgrade-flow run --json rend "ok": false, "done" (les
nœuds réellement exécutés, backup/preview/orphans compris), "stopped_at": "apply",
"state": "upgraded-but-failed", "failing_checks" (le ou les contrôles en cause — "up", "hook"
ou un nom de contrôle doctor), et "backup_path" (le tarball du nœud backup, pour restaurer si
besoin). Un _grimoire-output/upgrade/<date>/report.md est écrit avant le refus, sa première ligne
étant toujours « Mis à niveau, flow en échec sur <contrôle>. » — jamais un fichier absent parce que
verify (le seul autre nœud qui en écrit un) ne tourne jamais après un apply refusé. grimoire.
tools.project_update.update_project() (l'atelier/le cockpit) porte les mêmes champs (state,
backupPath) et relit ce rapport dès qu'il existe.
5. overrides (V1, proposition) — pour chaque override plein (grimoire.core.override_drift.
project_override_drift, override_kind != "partial"), un essai agent override convert --dry-run :
corps identique au kit → proposition « conversion sûre » ; corps divergent → proposition « revue
nécessaire », diff joint. Un override partiel (extends: kit) est déjà la forme convertie —
jamais proposé, quel que soit son statut de dérive. Écrit via grimoire.proposals
(artifact_type: "override-migration") — jamais d'override réécrit ici. Visible dans grimoire
proposals list et le cockpit comme toute autre proposition.
Issue #510 (point 6) : la convertibilité (le corps de l'override correspond-il encore à celui du
kit ?) et le statut de dérive (status, le kit a-t-il bougé depuis la prise ?) répondent à deux
questions différentes — les confondre sautait tout override "fresh", y compris un override plein
dont le corps est resté une copie exacte du kit (le cas le plus simple à convertir, puisque « fresh »
veut justement dire que le kit n'a pas bougé depuis). Un homelab réel avait sept overrides pleins de
cette forme, chacun ne portant qu'un context:, et n'a jamais reçu de proposition pour aucun d'eux.
Chaque override plein reçoit désormais un essai de conversion quel que soit son statut de dérive ;
seul le type (plein vs partiel) décide s'il y a quelque chose à proposer.
6. memory (V1, proposition) — fiches _grimoire/_memory/agent-learnings/*.md,
decisions-log.md, failure-museum.md, network-topology.md qu'aucun context: d'agent ne
référence : proposition de raccordement au porteur dont le use_when couvre le sujet (mots entiers,
même principe que le porteur par catégorie du déclencheur de propositions) ; sans porteur plausible,
proposition « à placer à la main ». artifact_type: "memory-link". Jamais d'écriture de context:
à cette étape.
7. needs-hosts (V0 + proposition) — grimoire.core.execution_needs.resolve_execution_needs ; tout
besoin unresolved propose une déclaration needs.commands. hosts.enabled absent de
project-context.yaml propose une déclaration égale à la détection sur disque
(grimoire.hosts.detection.detect_enabled_hosts).
8. verify (V0) — recompare le manifeste pris par backup. Seul _grimoire/_memory/config.yaml
(en-tête de version, réécrit par l'étape identity de up) peut différer sans que ce soit une
anomalie. Rapport final : _grimoire-output/upgrade/<date>/report.md.
9. destructive (V2, kind: "checkpoint") — point de passage humain forcé, jamais auto-décidé par
la cascade (--executor dispatch s'arrête ici en waiting_host) : tout retrait réel de mémoire ou
d'override — au-delà d'un déplacement vers _archive/ — doit être décidé nommément par un humain.
Ce nœud n'exécute rien lui-même ; sa seule présence dans le blueprint est le refus tracé que
l'issue #490 demande.
Ce qui est proposé, et jamais appliqué¶
Les nœuds overrides, memory et needs-hosts n'écrivent que des fichiers _grimoire-output/
proposals/<slug>.yaml (status: pending), via grimoire.proposals.create_manual_proposal — le même
mécanisme de stockage que le déclencheur de propositions sur non-choix répété (issue #395), étendu de
deux artifact_type (override-migration, memory-link) et d'un champ générique artifact_ref.
grimoire proposals list les affiche comme n'importe quelle autre proposition ; le cockpit (Piloter)
aussi.
grimoire proposals accept <slug> est la seule porte d'écriture, et seulement sur geste humain explicite :
override-migration— accepter appelleagent override convertseulement si la proposition portait « conversion sûre » (le corps de l'override est un octet-pour-octet du kit). Une proposition « revue nécessaire » refuse toujours l'acceptation : fusionner du texte divergent n'est pas ce que cette commande fait, et faire semblant serait pire que de ne rien faire.memory-link— accepter ajoute le chemin de la fiche aucontext:de l'override du porteur proposé. Refuse si aucun porteur n'a été trouvé (« à placer à la main ») ; jamais faute d'override existant — si le porteur est un agent du kit sans override, accepter en crée un partiel (extends: kit,kit_source_hash, issue #427) qui ne porte que le champcontext:.needs-hosts—hosts-declare-enableda une valeur mécanique réelle (la détection sur disque déjà faite à la proposition) : accepter l'écrit danshosts.enableddeproject-context.yaml, par round-trip (grimoire.tools._common.load_yaml_roundtrip/save_yaml, grimoire-kit#430 : les commentaires du fichier survivent).needs-declare-commandsreste, sciemment, un refus : un besoin non résolu l'est précisément parce qu'aucune commande n'a été déclarée ni détectée — en inventer une contredirait la doctrine degrimoire.core.execution_needs(« jamais une commande inventée à partir du seul id du besoin ») ; la déclarer reste un geste manuel dansproject-context.yaml.
Ce qui est refusé, et pourquoi¶
- Suppression, jamais :
orphansdéplace (shutil.move) vers_archive/.../orphans/, avec unREADME.mdnommant le kit installé et les agents retirés. Restaurer un orphelin toujours voulu est un geste manuel, documenté sur place — jamais un second run qui le ferait disparaître pour de bon. - Le checkpoint
destructive: présent dans le blueprint précisément pour qu'aucun exécuteur — humain via--executor interactive, cascade via--executor dispatch— ne puisse faire disparaître quoi que ce soit sans une décision nommée.grimoire upgrade-flow runne soumet jamais decheckpoint_decisionà sa place. Le nœud est déclaré destructif (config.checkpoint.destructive) : unapproven'y passe qu'attesté, c'est-à-dire soumis depuis la session d'hôte qui a présenté le checkpoint, après un tour humain de cette session. Sinon le run resteblockedavec le motifawaiting_human. Le kit ne peut pas prouver davantage : une garde de chemin se contourne par du code arbitraire, l'attestation rend la falsification plus coûteuse et visible, pas impossible. - Une conversion d'override qui fusionnerait du texte : refusée nommément (voir ci-dessus), avec le diff exact dans le message de refus.
--dry-runcombiné à--executor dispatch: refusé — la cascade de dispatch n'a pas de notion générique de « n'exécuter que les deux premiers nœuds », et improviser une le rendrait moins prévisible, pas plus.--dry-runn'existe qu'avec--executor interactive(le défaut).
Comment vérifier¶
# Aperçu seul (s'arrête après `preview`, rien d'autre ne tourne)
grimoire upgrade-flow run --project-root . --dry-run
# Le flow complet, mécanique (aucun appel à un fournisseur), s'arrête au checkpoint
grimoire upgrade-flow run --project-root . --json
# Les mêmes nœuds, mais délégués à la cascade de dispatch (comme `grimoire flow run
# --executor dispatch` pour n'importe quel blueprint) — un fournisseur réel fait le travail,
# le gate vérifie l'acceptance structurée de chaque nœud
grimoire upgrade-flow run --project-root . --executor dispatch
# État d'un run, propositions écrites, rapport final
grimoire flow status <run-id> --project-root .
grimoire proposals list
cat _grimoire-output/upgrade/<date>/report.md
# Le blueprint lui-même
grimoire blueprint validate registry/blueprints/project-upgrade.blueprint.json
# Ce que chaque nœud du blueprint invoque réellement comme acceptance
grimoire upgrade-flow check <backup|preview|orphans|apply|overrides|memory|needs-hosts|verify> \
--project-root .
grimoire flow extract <run-id> reproduit un run — complet ou arrêté au checkpoint — en blueprint
brouillon, comme pour n'importe quel autre flow.
Revoir les décisions¶
Un run qui s'arrête (checkpoint destructive ou propositions V1 en attente) laisse un projet dans
un état à décider, pas cassé. Trois façons de revoir ce contexte sans le reconstituer à la main
(issues #490/#506/#510) :
- Le skill
upgrade-review(archétypemeta, attaché au concierge) : lit, explique et recommande une ligne par proposition, pose les questions par lot, n'applique qu'une réponse explicite viagrimoire proposals accept|reject/grimoire flow resume. - Le prompt
/grimoire-upgrade-review(projeté pargrimoire host sync— commande Claude Code, prompt Copilot) : le même protocole, invocable depuis un hôte avec support des commandes/prompts. grimoire upgrade-flow review [--json](lecture seule) : le contexte brut de l'étape 1 en un bloc prêt à coller, pour un hôte sans slash command. Refuse de tourner si l'outilgrimoireest plus ancien que le kit aligné du projet (contrôledoctortool_version, issue #515).
Rend tool_version (l'écart outil/kit, ou null), last_run (id, statut, nœud courant du dernier
run project-upgrade — grimoire.tools.flow_runs.list_flow_runs), checkpoint_pending (booléen) et
pending_proposals (le même contenu que grimoire proposals list -o json, filtré sur status ==
"pending"). Aucune de ces trois portes n'écrit ni ne décide à la place de l'humain — la décision
reste grimoire proposals accept|reject <slug> et grimoire flow resume <run-id> --result
<fichier.json> ({"pins": {"out": {"contract": "upgrade-complete"}}, "checkpoint_decision":
"approve"|"reject"}).
Depuis le cockpit¶
POST /api/projects/update (src/grimoire/tools/project_update.py, câblé dans cmd_cockpit.py et
forge_server.py — deux hôtes, une route) appelle grimoire upgrade-flow run plutôt que up seul,
en sous-processus comme avant (jamais un import direct, pour qu'un refus n'y remonte pas comme une
exception non attrapée). Même contrat qu'en CLI :
- aperçu par défaut (
confirmabsent oufalse) —--dry-run, s'arrête aprèspreview. La réponse portereport["preview"], le contenu exact de_grimoire-output/upgrade/<date>/preview.md, jamais un résumé reformulé ; l'espace Piloter l'affiche tel quel sous le bouton « Mettre à jour — aperçu » (spec §4), qui garde son nom malgré le changement de commande sous-jacente. confirm: true— le flow complet sous--executor interactive(mécanique, jamais un fournisseur), arrêté au checkpointdestructivecomme en CLI. La réponse portereport["report"](lereport.mdfinal) etreport["proposals"](les propositions encorepending, slug/type/ spécialité — jamais leur contenu détaillé, queGET /api/workspace/proposalsrend déjà).- projet non enregistré ou chemin hors registre : refusé (
404), comme toute autre cible du cockpit résolue par slug (_resolve_project_path) — jamais un repli silencieux sur le projet servi.
Les propositions qu'un run complet écrit apparaissent dans la section « Propositions » de Piloter dès
que la fiche projet se redessine (options.refresh(), déjà déclenché après confirmation) — le même
mécanisme de lecture/acceptation que le déclencheur de non-choix (#395), jamais une seconde vue. La
timeline par tâche (#443) suit le run comme n'importe quel autre flow : rien de spécifique à câbler,
upgrade-flow run passe par le même moteur que grimoire flow run.
À côté de « Mettre à jour », le bouton « Revoir dans l'IDE » (issue #520, lot 2) n'est présent
dans la page que s'il y a une proposition en attente ou un checkpoint destructif en attente — jamais
un bouton toujours là. Il ne fait rien lui-même : il prépare et met à disposition (presse-papiers,
repli affiché) le texte que /grimoire-upgrade-review attend (le prompt mission pack du lot 1), et
montre grimoire upgrade-flow review pour un hôte sans slash command. Aucun lien d'ouverture d'IDE :
ce dépôt ne déclare aucun mécanisme de ce type.
Portée non couverte par cette livraison¶
grimoire flow list --require-measure project-upgradene trouve une mesure qu'après au moins un run en--executor dispatch(les mesures viennent duTraceLedger, jamais d'un run purement interactif). La suite de tests de cette PR couvre le blueprint et le chemin mécanique (--executor interactive, celui quegrimoire upgrade-flow runutilise par défaut) de bout en bout, avec de vrais nœuds sur un vrai projet ; elle ne rejoue pas--executor dispatchavec des fournisseurs simulés pour ce blueprint précis — ce chemin délègue au même moteur générique quegrimoire flow run --executor dispatch <n'importe quel blueprint>, déjà couvert partests/unit/test_flows_dispatch_executor*.py, mais l'acceptance de chaque nœud de ce blueprint n'a été vérifiée gate-exécutée que par lecture (grimoire upgrade-flow check <node>appelé directement, jamais par la cascade elle-même dans un test).