merge(agents): writer unique du contexte projet global configurable par agent — #276 (QA verte: cargo fmt --check, tests cibles application/app-tauri/infrastructure/backend, cargo check backend/app-tauri, npm typecheck, 69/69 tests frontend agents/adapters; arbitrage versionnage: .ideai/CONTEXT.md ignore, mcp-tool-permissions.json de-track)

This commit is contained in:
2026-09-07 22:49:34 +02:00
49 changed files with 1474 additions and 413 deletions

5
.gitignore vendored
View File

@ -65,6 +65,11 @@ frontend/coverage/
# UI layout runtime state (active layout id + session ids) — machine-local, # UI layout runtime state (active layout id + session ids) — machine-local,
# rebuilt at runtime, last-writer-wins — not versioned (same class as live-state.json). # rebuilt at runtime, last-writer-wins — not versioned (same class as live-state.json).
/.ideai/layouts.json /.ideai/layouts.json
# Contexte projet global IdeA: réécrit au runtime en last-writer-wins par le
# writer unique réservé (idea_context_update, #276) — état de pilotage local,
# pas une source de vérité versionnée (le durable partagé vit dans CLAUDE.md
# et .ideai/memory/, tous deux versionnés).
/.ideai/CONTEXT.md
# Permissions MCP locales/outillage: état de runtime propre à la machine et aux # Permissions MCP locales/outillage: état de runtime propre à la machine et aux
# sessions locales, pas une source de vérité projet. # sessions locales, pas une source de vérité projet.
.ideai/mcp-tool-permissions.json .ideai/mcp-tool-permissions.json

View File

@ -1,367 +0,0 @@
{
"version": 1,
"projectDefault": {
"allowedTools": [
"idea_list_agents",
"idea_context_read",
"idea_memory_read",
"idea_skill_read",
"idea_workstate_read",
"idea_ticket_read",
"idea_ticket_list",
"idea_ticket_read_carnet",
"idea_sprint_list",
"idea_template_list",
"idea_template_read",
"idea_ask_agent",
"idea_run_in_background",
"idea_launch_agent",
"idea_stop_agent",
"idea_update_context",
"idea_context_propose",
"idea_memory_write",
"idea_workstate_set",
"idea_ticket_create",
"idea_ticket_update",
"idea_ticket_update_status",
"idea_ticket_update_priority",
"idea_ticket_update_carnet",
"idea_ticket_link",
"idea_ticket_unlink",
"idea_template_create",
"idea_template_update",
"idea_template_delete",
"idea_create_skill",
"idea_ask_agents"
]
},
"agents": [
{
"agentId": "73c853d1-c0fd-463b-ad17-1d24fefa371f",
"policy": {
"allowedTools": [
"idea_list_agents",
"idea_context_read",
"idea_memory_read",
"idea_skill_read",
"idea_workstate_read",
"idea_ticket_read",
"idea_ticket_list",
"idea_ticket_read_carnet",
"idea_sprint_list",
"idea_template_list",
"idea_template_read",
"idea_ask_agent",
"idea_launch_agent",
"idea_stop_agent",
"idea_update_context",
"idea_context_propose",
"idea_memory_write",
"idea_workstate_set",
"idea_create_skill",
"idea_ticket_create",
"idea_ticket_update",
"idea_ticket_update_status",
"idea_ticket_update_priority",
"idea_ticket_update_carnet",
"idea_ticket_link",
"idea_ticket_unlink",
"idea_template_create",
"idea_template_update",
"idea_template_delete"
]
}
},
{
"agentId": "af7f86da-76bc-48e1-9900-71f45a624800",
"policy": {
"allowedTools": [
"idea_list_agents",
"idea_context_read",
"idea_memory_read",
"idea_skill_read",
"idea_workstate_read",
"idea_ticket_read",
"idea_ticket_list",
"idea_ticket_read_carnet",
"idea_sprint_list",
"idea_template_list",
"idea_template_read",
"idea_ask_agent",
"idea_launch_agent",
"idea_stop_agent",
"idea_update_context",
"idea_context_propose",
"idea_memory_write",
"idea_workstate_set",
"idea_create_skill",
"idea_ticket_create",
"idea_ticket_update",
"idea_ticket_update_status",
"idea_ticket_update_priority",
"idea_ticket_update_carnet",
"idea_ticket_link",
"idea_ticket_unlink",
"idea_template_create",
"idea_template_update",
"idea_template_delete"
]
}
},
{
"agentId": "aefdbd61-e3d4-4bc1-9f42-c259446a97b5",
"policy": {
"allowedTools": [
"idea_list_agents",
"idea_context_read",
"idea_memory_read",
"idea_skill_read",
"idea_workstate_read",
"idea_ticket_read",
"idea_ticket_list",
"idea_ticket_read_carnet",
"idea_sprint_list",
"idea_template_list",
"idea_template_read",
"idea_ask_agent",
"idea_launch_agent",
"idea_stop_agent",
"idea_update_context",
"idea_context_propose",
"idea_memory_write",
"idea_workstate_set",
"idea_create_skill",
"idea_ticket_create",
"idea_ticket_update",
"idea_ticket_update_status",
"idea_ticket_update_priority",
"idea_ticket_update_carnet",
"idea_ticket_link",
"idea_ticket_unlink",
"idea_template_create",
"idea_template_update",
"idea_template_delete"
]
}
},
{
"agentId": "cd0b4cf1-1bef-4fae-ade5-f0a6b49bbaf5",
"policy": {
"allowedTools": [
"idea_list_agents",
"idea_context_read",
"idea_memory_read",
"idea_skill_read",
"idea_workstate_read",
"idea_ticket_read",
"idea_ticket_list",
"idea_ticket_read_carnet",
"idea_sprint_list",
"idea_template_list",
"idea_template_read",
"idea_ask_agent",
"idea_launch_agent",
"idea_stop_agent",
"idea_update_context",
"idea_context_propose",
"idea_memory_write",
"idea_workstate_set",
"idea_create_skill",
"idea_ticket_create",
"idea_ticket_update",
"idea_ticket_update_status",
"idea_ticket_update_priority",
"idea_ticket_update_carnet",
"idea_ticket_link",
"idea_ticket_unlink",
"idea_template_create",
"idea_template_update",
"idea_template_delete"
]
}
},
{
"agentId": "c3e0d9d8-df36-4b00-b271-62d8aa78892c",
"policy": {
"allowedTools": [
"idea_list_agents",
"idea_context_read",
"idea_memory_read",
"idea_skill_read",
"idea_workstate_read",
"idea_ticket_read",
"idea_ticket_list",
"idea_ticket_read_carnet",
"idea_sprint_list",
"idea_template_list",
"idea_template_read",
"idea_ask_agent",
"idea_launch_agent",
"idea_stop_agent",
"idea_update_context",
"idea_context_propose",
"idea_memory_write",
"idea_workstate_set",
"idea_create_skill",
"idea_ticket_create",
"idea_ticket_update",
"idea_ticket_update_status",
"idea_ticket_update_priority",
"idea_ticket_update_carnet",
"idea_ticket_link",
"idea_ticket_unlink",
"idea_template_create",
"idea_template_update",
"idea_template_delete"
]
}
},
{
"agentId": "5d07e4a7-8676-4a71-aea1-5b64caefa944",
"policy": {
"allowedTools": [
"idea_list_agents",
"idea_context_read",
"idea_memory_read",
"idea_skill_read",
"idea_workstate_read",
"idea_ticket_read",
"idea_ticket_list",
"idea_ticket_read_carnet",
"idea_sprint_list",
"idea_template_list",
"idea_template_read",
"idea_ask_agent",
"idea_launch_agent",
"idea_stop_agent",
"idea_update_context",
"idea_context_propose",
"idea_memory_write",
"idea_workstate_set",
"idea_create_skill",
"idea_ticket_create",
"idea_ticket_update",
"idea_ticket_update_status",
"idea_ticket_update_priority",
"idea_ticket_update_carnet",
"idea_ticket_link",
"idea_ticket_unlink",
"idea_template_create",
"idea_template_update",
"idea_template_delete"
]
}
},
{
"agentId": "30bf7e5f-5681-478d-813c-ac4c3957897f",
"policy": {
"allowedTools": [
"idea_list_agents",
"idea_context_read",
"idea_memory_read",
"idea_skill_read",
"idea_workstate_read",
"idea_ticket_read",
"idea_ticket_list",
"idea_ticket_read_carnet",
"idea_sprint_list",
"idea_template_list",
"idea_template_read",
"idea_ask_agent",
"idea_launch_agent",
"idea_stop_agent",
"idea_update_context",
"idea_context_propose",
"idea_memory_write",
"idea_workstate_set",
"idea_create_skill",
"idea_ticket_create",
"idea_ticket_update",
"idea_ticket_update_status",
"idea_ticket_update_priority",
"idea_ticket_update_carnet",
"idea_ticket_link",
"idea_ticket_unlink",
"idea_template_create",
"idea_template_update",
"idea_template_delete"
]
}
},
{
"agentId": "dce19c75-9669-4e45-b8de-9950025157da",
"policy": {
"allowedTools": [
"idea_list_agents",
"idea_context_read",
"idea_memory_read",
"idea_skill_read",
"idea_workstate_read",
"idea_ticket_read",
"idea_ticket_list",
"idea_ticket_read_carnet",
"idea_sprint_list",
"idea_template_list",
"idea_template_read",
"idea_ask_agent",
"idea_launch_agent",
"idea_stop_agent",
"idea_update_context",
"idea_context_propose",
"idea_memory_write",
"idea_workstate_set",
"idea_create_skill",
"idea_ticket_create",
"idea_ticket_update",
"idea_ticket_update_status",
"idea_ticket_update_priority",
"idea_ticket_update_carnet",
"idea_ticket_link",
"idea_ticket_unlink",
"idea_template_create",
"idea_template_update",
"idea_template_delete",
"idea_run_in_background"
]
}
},
{
"agentId": "a6ced819-b893-4213-b003-9e9dc79b9641",
"policy": {
"allowedTools": [
"idea_list_agents",
"idea_context_read",
"idea_memory_read",
"idea_skill_read",
"idea_workstate_read",
"idea_ticket_read",
"idea_ticket_list",
"idea_ticket_read_carnet",
"idea_sprint_list",
"idea_template_list",
"idea_template_read",
"idea_ask_agent",
"idea_launch_agent",
"idea_stop_agent",
"idea_update_context",
"idea_context_propose",
"idea_memory_write",
"idea_workstate_set",
"idea_create_skill",
"idea_ticket_create",
"idea_ticket_update",
"idea_ticket_update_status",
"idea_ticket_update_priority",
"idea_ticket_update_carnet",
"idea_ticket_link",
"idea_ticket_unlink",
"idea_template_create",
"idea_template_update",
"idea_template_delete",
"idea_run_in_background",
"idea_ask_agents"
]
}
}
]
}

View File

@ -84,3 +84,4 @@
- [opencode-streaming-progress-mcp-surfacing-157](opencode-streaming-progress-mcp-surfacing-157.md) — memory note opencode-streaming-progress-mcp-surfacing-157 - [opencode-streaming-progress-mcp-surfacing-157](opencode-streaming-progress-mcp-surfacing-157.md) — memory note opencode-streaming-progress-mcp-surfacing-157
- [toolchain-rust-partage-acces-agents](toolchain-rust-partage-acces-agents.md) — Diagnostic et solution durable pour l'accès partagé au toolchain Rust/Tauri dans les run-dirs agents isolés. - [toolchain-rust-partage-acces-agents](toolchain-rust-partage-acces-agents.md) — Diagnostic et solution durable pour l'accès partagé au toolchain Rust/Tauri dans les run-dirs agents isolés.
- [web-ui-refactor-surface-addressable-architecture](web-ui-refactor-surface-addressable-architecture.md) — memory note web-ui-refactor-surface-addressable-architecture - [web-ui-refactor-surface-addressable-architecture](web-ui-refactor-surface-addressable-architecture.md) — memory note web-ui-refactor-surface-addressable-architecture
- [plugin-hot-reload-agent-facing-delivered](plugin-hot-reload-agent-facing-delivered.md) — memory note plugin-hot-reload-agent-facing-delivered

View File

@ -0,0 +1,52 @@
---
name: plugin-hot-reload-agent-facing-delivered
description: memory note plugin-hot-reload-agent-facing-delivered
metadata:
type: project
---
# Hot reload plugins agent-facing livre
Le chantier #269 a livre le hot reload des plugins sans relancer IdeA, integre localement sur `develop` par le merge `ad2c6b4` (commit backend `805b1b4`).
## Contrat livre
- Nouveau use case backend `ReloadPlugin` pour recharger un plugin installe depuis une source directory.
- Nouveau tool agent-facing `idea_plugin_reload({ pluginId, expectedContentHash? })` expose cote MCP/OpenAI/direct selon les catalogues existants.
- Le tool est une `WriteAction` et reste refuse/masque par defaut tant que les permissions ne l'autorisent pas explicitement.
- Le chemin agent-facing ne prend pas de chemin arbitraire: la source relodable vient du registry du plugin installe.
- Le reload revalide manifest/id, recalcule le hash, met a jour le registry avec `restart_required=false`, publie un evenement `plugin_reloaded`, et lance la reconciliation des serveurs MCP de plugin.
- Le resultat structure fournit notamment `changed`, anciens/nouveaux hashes, statuts MCP et warnings/erreurs.
## UX / Frontend
Aucune nouvelle surface humaine dediee n'a ete livree en V1. Decision UX/DevFrontend: le besoin principal est agent-facing; le resultat structure du tool et le flux agent existant suffisent.
Limite assumee: les contributions UI React des plugins (menus/layouts/commandes runtime frontend) restent chargees une fois par session par `PluginRuntimeProvider` et ne sont pas rafraichies apres `PluginReloaded` en V1. Cela n'affecte pas les tools MCP agent-facing.
## QA
QA #273 vert sur `feature/269-backend` avant merge:
- `cargo +stable test -p application plugin::tests -- --nocapture` => 32 passed.
- `cargo +stable test -p infrastructure --test mcp_server -- --nocapture` => 38 passed.
- `cargo +stable test -p backend openai_tools::tests -- --nocapture` => 10 passed.
- `cargo +stable test -p infrastructure --test plugin_install_load -- --nocapture` => 5 passed.
- `cargo +stable test -p infrastructure plugin -- --nocapture` => 10 passed.
- `cargo +stable test -p backend plugin_reloaded_relays_hashes_and_changed_flag_to_wire -- --nocapture` => 1 passed.
- `cargo +stable test -p app-tauri backend_core_builds_without_desktop_runtime -- --nocapture` => app-tauri compile OK.
Risque restant: IdeA garantit que serveur/registry/prochains `tools/list` voient le nouvel etat. Certains providers/CLIs actifs peuvent garder leur propre cache de tools et devoir relister les tools ou reconnecter MCP en interne; pas de redemarrage IdeA requis par le contrat backend.
## Note submodule test
Dans les worktrees de feature, `git worktree add` n'initialise pas `sdk/IdeaSDK`. Pour les tests `plugin_install_load`, Git a utilise le submodule local du worktree principal:
```bash
WT=<worktree>; MOD=$(git -C $WT rev-parse --git-path modules/sdk/IdeaSDK)
rm -rf "$MOD" $WT/sdk/IdeaSDK
git -C $WT \
-c submodule.sdk/IdeaSDK.url=/home/anthony/Documents/Projects/IdeA/sdk/IdeaSDK \
-c protocol.file.allow=always \
submodule update --init --recursive sdk/IdeaSDK
```
Aucun rebuild AppImage/live validation n'a ete fait dans ce cycle; creer un ticket dedie si validation live installee est demandee.

View File

@ -2,5 +2,13 @@
"version": 1, "version": 1,
"projectDefault": { "projectDefault": {
"network": "allow" "network": "allow"
} },
"agents": [
{
"agentId": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d",
"permissions": {
"network": "allow"
}
}
]
} }

View File

@ -1,8 +1,8 @@
--- ---
issueRef: "#267" issueRef: "#267"
version: 4 version: 5
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedAt: 1788797485001 updatedAt: 1788797669968
--- ---
# Ticket #267 - reprise orchestrée par Main # Ticket #267 - reprise orchestrée par Main

View File

@ -2,7 +2,7 @@
id: "e68681a5-2a9e-4742-912b-9c1d726ca789" id: "e68681a5-2a9e-4742-912b-9c1d726ca789"
number: 267 number: 267
title: "Corriger permissions cross-project agents/templates et reconstruire l’AppImage" title: "Corriger permissions cross-project agents/templates et reconstruire l’AppImage"
status: "qa" status: "closed"
priority: "critical" priority: "critical"
sprint: null sprint: null
links: [{"target":"#260","kind":"relatesTo"},{"target":"#264","kind":"relatesTo"},{"target":"#265","kind":"relatesTo"}] links: [{"target":"#260","kind":"relatesTo"},{"target":"#264","kind":"relatesTo"},{"target":"#265","kind":"relatesTo"}]
@ -11,7 +11,7 @@ attachments: []
createdBy: {"kind":"agent","agent_id":"fe887179-933f-47d4-960f-c3b06827f86c"} createdBy: {"kind":"agent","agent_id":"fe887179-933f-47d4-960f-c3b06827f86c"}
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
createdAt: 1788795096659 createdAt: 1788795096659
updatedAt: 1788797485001 updatedAt: 1788797669968
version: 4 version: 5
--- ---
Cycle complet demandé par l’utilisateur. Corriger : (1) exposer les tools MCP cross-project dans l’éditeur de permissions agent/projet et dans les templates ; (2) permettre d’autoriser précisément idea_project_list/read_context/list_agents/ask_agent/cancel_agent selon le catalogue canonique ; (3) persister et réafficher correctement defaultContactable dans TemplateEditor après save/reopen ; (4) conserver la permission agent Contactable depuis d’autres projets dans Permissions ; (5) tests frontend/backend/transport/persistance, QA complète ; (6) intégration Git puis nouvelle AppImage clairement publiée à la place de l’ancienne artefact visible. Cycle complet demandé par l’utilisateur. Corriger : (1) exposer les tools MCP cross-project dans l’éditeur de permissions agent/projet et dans les templates ; (2) permettre d’autoriser précisément idea_project_list/read_context/list_agents/ask_agent/cancel_agent selon le catalogue canonique ; (3) persister et réafficher correctement defaultContactable dans TemplateEditor après save/reopen ; (4) conserver la permission agent Contactable depuis d’autres projets dans Permissions ; (5) tests frontend/backend/transport/persistance, QA complète ; (6) intégration Git puis nouvelle AppImage clairement publiée à la place de l’ancienne artefact visible.

View File

@ -0,0 +1,61 @@
---
issueRef: "#269"
version: 6
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedAt: 1788802262284
---
## Topologie git locale (décision Git, 2026-09-07)
- **Base** : `develop` @ `8d2be75` (inclut le merge cross-project #260/#267 — fondation MCP inter-projets du chantier — + chore gitignore backups AppImage `.old-*`).
- **Branches chantier** (pattern #260) :
- `feature/269-backend` → worktree `.ideai/run/fe887179-933f-47d4-960f-c3b06827f86c/worktree` (DevBackend, ticket #271).
- `feature/269-frontend` → worktree `.ideai/run/8f7da528-58df-4315-97e9-0562230ecc19/worktree` (UX + DevFrontend, ticket #272). Si #272 reste agent-facing (doc only), branche supprimée sans merge.
- **QA (#273)** : pas de branche dédiée ; tests exécutés sur `feature/269-backend` puis sur develop après merge.
- **Worktree principal** : reste sur `develop`, contient l'état runtime `.ideai/` vivant (tickets 269-273 non trackés, permissions). Ne pas stash, ne pas switcher.
- **Branches #260** (`feature/260-backend/frontend/cross-project-agents`) : fusionnées dans develop, conservées comme historique (convention repo). Worktrees nettoyés/retargetés.
- **Aucun push** : develop reste en avance de 238+ commits sur origin ; rien ne sort sans validation utilisateur.
- **Fusion vers develop** : uniquement par Git, après QA verte, en merge commit référençant #269/#271/#272.
### Garde-fous devs
- Travailler uniquement dans son worktree (checkout de sa branche déjà fait). Jamais de `git add -A` / `git add .` : `scripts/` et l'état `.ideai/` du worktree principal doivent rester hors commits de code.
- Commits convention `type(scope): ... — #27x`. Pas de push, pas de merge, pas de rebase : à Git.
## Submodule sdk/IdeaSDK dans les worktrees (fix QA #273, 2026-09-07)
`git worktree add` n'initialise pas les submodules, et `d1c3c00` (gitlink develop) n'existe que localement (jamais poussé) → `git submodule update --init` échoue sur gitea. Préparation appliquée par Git sur les DEUX worktrees (backend + frontend), sans rien toucher d'autre :
```bash
WT=<worktree> ; MOD=$(git -C $WT rev-parse --git-path modules/sdk/IdeaSDK)
rm -rf "$MOD" $WT/sdk/IdeaSDK # restes d'un clone échoué éventuels
git -C $WT \
-c submodule.sdk/IdeaSDK.url=/home/anthony/Documents/Projects/IdeA/sdk/IdeaSDK \
-c protocol.file.allow=always \
submodule update --init --recursive sdk/IdeaSDK
```
- Origine du submodule dans chaque worktree = chemin local du submodule principal (offline-safe). Config partagée du repo NON modifiée (reste gitea).
- État vérifié : `git submodule status --recursive` → ` d1c3c00... sdk/IdeaSDK` propre sur les deux worktrees ; fixture `sdk/IdeaSDK/examples/hello-plugin` présente.
- Travail non commité DevBackend vérifié intact (13 fichiers modifiés + `mcp/plugins.rs` non suivi).
- Test QA relancé par Git dans le worktree backend : `cargo +stable test -p infrastructure --test plugin_install_load` → **5 passed / 0 failed** (dont les 2 `hello-plugin` rouges). QA peut reprendre l'exécution officielle.
- Racine du blocage cargo en env agent : `RUSTUP_HOME` isolé sans default → utiliser `cargo +stable` (ou configurer le default).
## Cadrage produit/architecture initial (2026-09-07)
Besoin livré par #269: permettre a un agent du projet A de demander a un projet/plugin B d'ajouter/modifier des tools, puis de recharger le plugin dans A sans relancer IdeA.
Verdict Architect: faisable. Le backend relit deja registry/manifest a la demande et le protocole `idea-plugin://<pluginId>/current/<contentHash>/...` evite le cache ESM si le hash change. Les blocages actuels sont le runtime plugin frontend charge une fois au mount et le catalogue MCP agent-facing statique.
Limite a annoncer/tester: IdeA peut garantir que le serveur, le registry et les prochains `tools/list` voient le nouvel etat. Elle ne peut pas forcer tous les CLIs/providers actifs a refaire leur introspection interne instantanement; selon provider, le tool peut etre visible au tour suivant ou apres reconnexion MCP interne, sans redemarrage IdeA.
Decision UX: pas de nouvelle surface humaine dediee en V1. Feedback dans la cellule/flux agent: reload en cours, succes, deja a jour, echec non bloquant avec anciens tools conserves.
## Intégration locale (Git, 2026-09-07)
- **Commit branche** : `805b1b4` sur `feature/269-backend` — 14 fichiers du chantier uniquement (add explicite, pas de `.ideai`, pas d'artefacts), 863 insertions / 32 suppressions, créé `crates/infrastructure/src/orchestrator/mcp/plugins.rs`.
- **Merge local** : `ad2c6b4` `merge(plugins)` `--no-ff` vers `develop` (réf. #269/#271/#272, QA verte #273). Aucune divergence pré-merge (base `8d2be75` = HEAD develop) → arbre develop == arbre branche, résultats QA transposables tels quels.
- **Frontend** : #272 clos sans code → worktree `8f7da528…/worktree` retiré (`--force`, requis car submodule), branche `feature/269-frontend` supprimée, `git worktree prune` fait. Résidus session (AGENTS.md, patch #210-212) laissés dans le run dir.
- **Backend** : worktree `fe887179…/worktree` conservé sur `feature/269-backend` (historique + session DevBackend), submodule local propre.
- **Worktree principal** : état runtime `.ideai` + `scripts/` préservés à l'identique pendant le merge.
- **Statuts** : #271 QA→closed par Git (v8). #269 parent laissé à Main (v4 au moment de l'intégration).
- **Rien poussé** : develop avance localement uniquement.
- Note : pas de rebuild AppImage dans ce cycle ; si validation live du hot reload est voulue, créer un ticket rebuild (comme #265 l'avait été pour #260).

View File

@ -0,0 +1,17 @@
---
id: "9386e2dc-5dd1-488c-afe6-0e99ad297a0b"
number: 269
title: "Hot reload des plugins sans relancer IdeA pour les agents inter-projets"
status: "closed"
priority: "high"
sprint: null
links: []
agentRefs: []
attachments: []
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
createdAt: 1788799296392
updatedAt: 1788802262284
version: 6
---
Besoin produit: un agent d'un projet A doit pouvoir demander a un projet/plugin B de creer ou modifier des tools, puis recharger le plugin concerne et utiliser les nouveaux tools sans redemarrer IdeA. Le chantier doit etablir ce qui est techniquement faisable, livrer le chemin minimal robuste et couvrir le flux inter-projets. Contraintes: architecture hexagonale, pas de relance IdeA, pas de push/publication sans validation utilisateur, compatibilite avec les outils MCP/skills existants et le contexte injecte aux agents actifs.

View File

@ -0,0 +1,33 @@
---
issueRef: "#270"
version: 4
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedAt: 1788799729583
---
## Verdict Architect (2026-09-07)
Faisable sans relancer IdeA, avec un contrat a expliciter: IdeA garantit le refresh serveur/registry/MCP et les prochains `tools/list`; certains clients CLI actifs peuvent garder un cache interne de tools jusqu'au tour suivant ou une reconnexion MCP interne.
Points de chargement actuels:
- Backend registry/package: `crates/infrastructure/src/plugin/mod.rs`, `FsPluginPackageStore`, `registry.json`, `bundle_url()` en `idea-plugin://<pluginId>/current/<hash>/<main>`.
- Runtime catalog: `crates/application/src/plugin/mod.rs`, `ListPluginRuntimeContributions::execute()` relit le registry a chaque appel.
- MCP plugin servers: install/enable/uninstall/reconcile appellent deja `PluginMcpSupervisor::reconcile`.
- Frontend runtime: `frontend/src/features/plugins/PluginRuntimeProvider.tsx` liste une fois au mount.
- Frontend loader: `frontend/src/plugins/runtime/loader.ts` importe les bundles une fois dans un `PluginRuntimeRegistry` neuf.
- Tools IdeA agent-facing: `crates/infrastructure/src/orchestrator/mcp/tools.rs` et `crates/app-tauri/src/openai_tools.rs` sont statiques.
Design retenu V1:
- Use case `ReloadPlugin`.
- Input `ReloadPluginInput { plugin_id, expected_content_hash?: Option<String> }`.
- Output `PluginReloadResult { plugin, old_content_hash, new_content_hash, changed, runtime_catalog_entry?: PluginRuntimePlugin, mcp_statuses, warnings }`.
- Support initial: plugins installes depuis `PluginInstallSource::Directory`; pas de chemin arbitraire agent-facing.
- Refaire `install_from_directory`, valider manifest/id, recalculer hash, commit atomique, upsert registry avec `restart_required=false`, publier `PluginReloaded`, puis `reconcile(active_mcp_specs)`.
- Hash identique: `changed=false`, reconcile idempotent autorise.
- `expected_content_hash` mismatch: erreur `CONFLICT`.
- Archive sans chemin relodable: `UNSUPPORTED`/`INVALID` explicite.
- Commande agent-facing: `idea_plugin_reload({ pluginId, expectedContentHash? })`, `WriteAction`, permission refusee par defaut.
Decoupage:
- #271 backend: use case, DTO Tauri, tool MCP, event domaine, reconcile, tests backend/MCP.
- #272 frontend: gateway reload + provider reload ciblé + feedback agent/cellule uniquement si necessaire.
- #273 QA: unitaires, integration MCP/tools, supervisor, frontend si #272, scenario inter-projets representatif.

View File

@ -0,0 +1,17 @@
---
id: "3ae594f3-7bab-4931-b3ad-a684b7a5e03c"
number: 270
title: "Cadrer l'architecture du hot reload plugin et des tools dynamiques"
status: "closed"
priority: "high"
sprint: null
links: [{"target":"#269","kind":"dependsOn"}]
agentRefs: [{"agentId":"b4730d7f-c54d-4736-8a04-c6203aa2fd49","role":"assigned"}]
attachments: []
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
createdAt: 1788799296413
updatedAt: 1788799729583
version: 4
---
Analyser le systeme actuel de plugins/MCP/apps/skills et definir le contrat de hot reload: declencheur agent, port/use case, invalidation cache, redecouverte tools, scope projet/plugin, erreurs, concurrence, securite et limites. Produire un decoupage backend/frontend/QA exploitable.

View File

@ -0,0 +1,76 @@
---
issueRef: "#271"
version: 8
updatedBy: {"kind":"agent","agent_id":"1ff94b51-3e17-4a39-8543-7715d1ff0f80"}
updatedAt: 1788802202168
---
## Contrat d'implementation initial (Architect, 2026-09-07)
Implementer le backend du hot reload plugin sans relance IdeA.
Use case: `ReloadPlugin`.
Input: `ReloadPluginInput { plugin_id, expected_content_hash?: Option<String> }`.
Output: `PluginReloadResult { plugin, old_content_hash, new_content_hash, changed, runtime_catalog_entry?: PluginRuntimePlugin, mcp_statuses, warnings }`.
Perimetre V1:
- Supporter d'abord `PluginInstallSource::Directory`.
- Le tool agent-facing ne doit pas accepter de chemin arbitraire; le chemin relodable vient du registry installe.
- Refaire `install_from_directory`, valider manifest/id, recalcul hash, commit atomique, upsert registry `restart_required=false`.
- Publier `PluginReloaded { plugin_id, old_content_hash, new_content_hash, changed }`.
- Appeler `PluginMcpSupervisor::reconcile` apres reload.
- Hash identique => `changed=false`, reconcile idempotent.
- `expected_content_hash` mismatch => erreur `CONFLICT`.
- Source archive non relodable => erreur explicite `UNSUPPORTED` ou `INVALID`.
Agent-facing:
- Ajouter `idea_plugin_reload({ pluginId, expectedContentHash? })`.
- Classer en `WriteAction`, permission refusee par defaut.
- Cabler aussi le schema OpenAI/direct si le catalogue tools est duplique dans `crates/app-tauri/src/openai_tools.rs`.
Points code a inspecter:
- `crates/infrastructure/src/plugin/mod.rs`
- `crates/application/src/plugin/mod.rs`
- `crates/infrastructure/src/orchestrator/mcp/tools.rs`
- `crates/app-tauri/src/openai_tools.rs`
Limite a preserver dans le resultat: IdeA garantit registry/serveur/prochains `tools/list`; certains providers peuvent cacher leur introspection interne jusqu'au tour suivant/reconnexion MCP interne.
## Verification Main apres timeout DevBackend (2026-09-07)
DevBackend a rendu un handoff: implementation partielle, non compilee/testee, non committee.
Worktree `feature/269-backend` modifie:
- `crates/app-tauri/src/lib.rs`
- `crates/app-tauri/src/plugins.rs`
- `crates/application/src/lib.rs`
- `crates/application/src/plugin/mod.rs`
- `crates/backend/src/dto.rs`
- `crates/backend/src/events.rs`
- `crates/backend/src/lib.rs`
- `crates/backend/src/openai_tools.rs`
- `crates/domain/src/events.rs`
- `crates/infrastructure/src/orchestrator/mcp/mod.rs`
- `crates/infrastructure/src/orchestrator/mcp/server.rs`
- `crates/infrastructure/src/orchestrator/mcp/tools.rs`
- `crates/infrastructure/src/orchestrator/mcp/plugins.rs` (nouveau)
Commandes Main:
- `cargo fmt --all -- --check` => KO, formatage requis dans `crates/app-tauri/src/plugins.rs`, `crates/application/src/lib.rs`, `crates/application/src/plugin/mod.rs`, `crates/backend/src/lib.rs`, `crates/infrastructure/src/orchestrator/mcp/mod.rs`, `crates/infrastructure/src/orchestrator/mcp/server.rs`.
- `cargo check --workspace` => KO. Erreurs principales:
- `crates/backend/src/lib.rs:107`: unresolved import `infrastructure::PluginToolProvider`; suggestion `infrastructure::orchestrator::mcp::PluginToolProvider`.
- `crates/backend/src/openai_tools.rs:18`: unresolved import `infrastructure::PluginToolProvider`.
- `crates/backend/src/lib.rs:166/168/174/183/185`: `infrastructure::PluginToolError` introuvable; importer/referrer `infrastructure::orchestrator::mcp::PluginToolError` ou re-exporter proprement.
Retour DevBackend requis: corriger format + compile, ajouter/ajuster tests, executer les commandes pertinentes avant handoff. Pas de commit avant vert.
## Verification Main apres second timeout DevBackend (2026-09-07)
DevBackend a de nouveau timeout cote rendez-vous mais son workstate est `done` et le worktree a avance.
Etat git: toujours non committe sur `feature/269-backend`; ajout de `crates/infrastructure/tests/mcp_server.rs` aux fichiers modifies.
Commandes Main:
- `cargo fmt --all -- --check` => OK.
- `cargo check --workspace` => OK (`backend`, `web-server`, `app-tauri` check termines).
Suite: passage QA #273 sur l'etat non committe de la branche backend. QA doit verifier les tests reels et remonter tout ecart avant commit.

View File

@ -0,0 +1,17 @@
---
id: "bb6cdd1b-f100-4c47-b48c-58e163110508"
number: 271
title: "Expose agent-facing plugin reload command/tool"
status: "closed"
priority: "high"
sprint: null
links: [{"target":"#269","kind":"dependsOn"}]
agentRefs: [{"agentId":"fe887179-933f-47d4-960f-c3b06827f86c","role":"assigned"}]
attachments: []
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedBy: {"kind":"agent","agent_id":"1ff94b51-3e17-4a39-8543-7715d1ff0f80"}
createdAt: 1788799296464
updatedAt: 1788802202168
version: 8
---
Implementer le chemin backend permettant a un agent autorise de demander le reload d'un plugin installe/cache localement, puis de rendre les capabilities/tools rafraichies disponibles sans redemarrage d'IdeA. Le contrat final sera precise par Architect.

View File

@ -0,0 +1,41 @@
---
issueRef: "#272"
version: 6
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedAt: 1788802083238
---
## Spec UX (2026-09-07)
Decision: aucune nouvelle surface humaine dediee en V1. Le hot reload de plugin est un mecanisme agent-facing dans le flux inter-agent/projet; pas de page, panneau permanent ou modale de gestion plugins pour ce besoin.
Emplacement minimal: cellule de l'agent du projet A, dans le flux d'activite/conversation; overlay/busy existant seulement si la recharge bloque temporairement l'usage des tools.
Etats et libelles:
- Detection: `Nouveaux tools détectés pour le plugin {pluginName}.`
- En cours: `Rechargement du plugin {pluginName}...` avec spinner/progress indetermine discret.
- Succes: `Plugin {pluginName} rechargé. Nouveaux tools disponibles.`
- Aucun changement: `Plugin {pluginName} déjà à jour.`
- Echec: `Impossible de recharger le plugin {pluginName}. Les anciens tools restent disponibles.` Detail court possible: `Manifest invalide`, `Plugin introuvable`, `Erreur de chargement MCP`, `Permission refusée`.
Regle de continuite: ne jamais ressembler a un redemarrage. Conserver conversation, contexte agent, cellule, historique et anciens tools si echec.
Frontend attendu seulement si Architect/DevBackend exposent un event ou gateway utile: feedback dans surfaces existantes + eventuel refresh ciblé du runtime plugin; pas de nouvelle navigation.
## Decision DevFrontend (2026-09-07)
Aucun changement frontend requis pour la V1 de #272.
Raison:
- `idea_plugin_reload` fonctionne dans le flux agent existant.
- La reconciliation MCP est terminee avant la reponse du tool.
- Le resultat structure fournit `changed`, hashes, statuts MCP et warnings/erreurs, suffisant comme feedback agent-facing.
- Aucune surface d'administration plugin n'est necessaire selon UX.
Limite assumee:
- Les contributions UI React des plugins (menus, layouts, commandes runtime) restent chargees une fois par session par `PluginRuntimeProvider` et ne sont pas rafraichies apres `PluginReloaded` en V1.
- Cette limite n'affecte pas les tools MCP agent-facing; leur hot reload est gere cote backend.
Fichiers touches: aucun. Commit: aucun. Branche `feature/269-frontend` propre.
Tests DevFrontend:
- `npm test -- --run src/features/plugins/plugins.test.tsx src/plugins/runtime/loader.test.ts` => 31 tests reussis, 2 fichiers reussis.

View File

@ -0,0 +1,17 @@
---
id: "a5e8fed5-a516-46a4-a2ae-c9901e08e650"
number: 272
title: "Surface UX de reload plugin et feedback agent/utilisateur"
status: "closed"
priority: "medium"
sprint: null
links: [{"target":"#269","kind":"dependsOn"}]
agentRefs: [{"agentId":"757430bb-014a-40fa-b82b-f2429dab9766","role":"assigned"},{"agentId":"8f7da528-58df-4315-97e9-0562230ecc19","role":"assigned"}]
attachments: []
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
createdAt: 1788799296491
updatedAt: 1788802083238
version: 6
---
Definir puis implementer uniquement les surfaces necessaires: etat reload en cours, resultat, erreur actionnable, eventuel bouton/commande cote UI si pertinent. Si le flux doit rester agent-facing uniquement, documenter l'absence de nouvelle surface humaine.

View File

@ -0,0 +1,72 @@
---
issueRef: "#273"
version: 7
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedAt: 1788801870891
---
## Plan QA initial (Architect + UX, 2026-09-07)
Backend/application:
- reload d'un plugin directory modifie => hash change et `restart_required=false`.
- manifest id mismatch => erreur.
- `expectedContentHash` mismatch => `CONFLICT`.
- source archive/non relodable => `UNSUPPORTED`/`INVALID` explicite.
- manifest invalide: etat/erreur robuste, anciens tools conserves.
Infra/protocole:
- package commit atomique.
- ancien bundle URL hash rejete apres registry update; nouveau hash servi.
- `PluginMcpSupervisor::reconcile` stoppe les serveurs retires et demarre les nouveaux.
MCP/tools:
- `tools/list` contient `idea_plugin_reload`.
- permission refusee par defaut.
- permission autorisee => retour JSON conforme.
- non-regression des tools IdeA existants.
Frontend si #272 implemente:
- provider recharge un seul plugin, appelle `dispose`, remplace commands/layouts/tools, conserve les autres plugins.
- feedback cellule: en cours, succes, deja a jour, echec non bloquant.
- echec conserve les anciens tools.
Scenario representatif:
- projet A demande a agent projet B/plugin de modifier un plugin directory.
- A appelle `idea_plugin_reload` sans redemarrer IdeA.
- A utilise le nouveau tool ou valide qu'un nouveau `tools/list` l'expose. Noter explicitement toute limite provider si un CLI actif garde un cache interne.
## Premier verdict QA (2026-09-07)
Verdict initial: ROUGE pour validation complète, mais les tests ciblés hot reload sont verts. Bloquant identifié: fixture SDK absente dans le worktree DevBackend, pas un défaut direct de `ReloadPlugin`.
Preuves vertes QA:
- `cargo test -p application plugin::tests -- --nocapture` => 32 passed.
- `cargo test -p infrastructure --test mcp_server -- --nocapture` => 38 passed.
- `cargo test -p backend openai_tools::tests -- --nocapture` => 10 passed.
- `cargo test -p backend plugin_reloaded_relays_hashes_and_changed_flag_to_wire -- --nocapture` => 1 passed.
- `cargo test -p app-tauri backend_core_builds_without_desktop_runtime -- --nocapture` => compile app-tauri OK, tests filtres OK.
KO initial:
- `cargo test -p infrastructure --test plugin_install_load -- --nocapture` => 3 passed, 2 failed, `No such file or directory` sur `sdk/IdeaSDK/examples/hello-plugin`.
Diagnostic Main/Git:
- Worktree principal: submodule `sdk/IdeaSDK` initialise, fixture presente.
- Worktree backend: submodule non initialise (`-d1c3c00... sdk/IdeaSDK`).
- Git a initialise `sdk/IdeaSDK` dans les worktrees backend + frontend via source locale `/home/anthony/Documents/Projects/IdeA/sdk/IdeaSDK`, sans modifier les changements DevBackend, push, merge ou rebase.
- Git a relance `cargo +stable test -p infrastructure --test plugin_install_load` dans le worktree backend => 5 passed / 0 failed.
## Verdict QA officiel (2026-09-07)
VERT pour #273/#271 sur `feature/269-backend`.
Commandes relancees:
- `cargo +stable test -p application plugin::tests -- --nocapture` => 32 passed.
- `cargo +stable test -p infrastructure --test mcp_server -- --nocapture` => 38 passed.
- `cargo +stable test -p backend openai_tools::tests -- --nocapture` => 10 passed.
- `cargo +stable test -p infrastructure --test plugin_install_load -- --nocapture` => 5 passed.
- `cargo +stable test -p infrastructure plugin -- --nocapture` => 10 passed.
- `cargo +stable test -p backend plugin_reloaded_relays_hashes_and_changed_flag_to_wire -- --nocapture` => 1 passed.
- `cargo +stable test -p app-tauri backend_core_builds_without_desktop_runtime -- --nocapture` => app-tauri compile OK, filtered binaries OK.
Couverture validee: `ReloadPlugin`, hash changé/identique/conflit, source archive refusée, store plugin reel, confinement install archive/directory, MCP `tools/list`, permission write refusee par defaut et autorisee explicitement, routage OpenAI/MCP camelCase, evenement backend, non-regression tools MCP existants.
Risque restant: la validation prouve exposition/routage cote serveur/provider. Elle ne prouve pas qu'un provider CLI actif invalide toujours son cache local de `tools/list`; selon provider, l'agent peut devoir relister les tools avant d'utiliser `idea_plugin_reload` ou un tool ajoute par le plugin recharge.

View File

@ -0,0 +1,17 @@
---
id: "08564396-1229-4cf0-8a1d-b26957bbfd27"
number: 273
title: "QA hot reload plugin sans relance IdeA"
status: "closed"
priority: "high"
sprint: null
links: [{"target":"#269","kind":"dependsOn"}]
agentRefs: [{"agentId":"ab328d90-c307-4771-a3b6-6c56089c8506","role":"assigned"}]
attachments: []
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
createdAt: 1788799296523
updatedAt: 1788801870891
version: 7
---
Ecrire et executer les tests pertinents pour valider le hot reload plugin: unitaires backend, integration MCP/tools, non-regression des outils existants, et scenario representatif ou un outil ajoute/modifie devient utilisable par un agent actif sans relancer IdeA. Inclure les commandes et sorties reelles.

View File

@ -0,0 +1,41 @@
---
issueRef: "#274"
version: 3
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedAt: 1788803503214
---
## Rebuild AppImage (2026-09-07)
Skill utilise: `build-appimage`.
Objectif: reconstruire l'AppImage apres merge local du chantier #269/#271 pour rendre le hot reload plugin disponible dans le binaire apres remplacement manuel.
Commandes executees depuis `/home/anthony/Documents/Projects/IdeA`:
```bash
npm --prefix frontend run build
```
Resultat: OK (`tsc --noEmit` + `vite build`). Warning Vite existant: chunk JS > 500 kB.
Commande bundle depuis `crates/app-tauri/`:
```bash
mkdir -p /tmp/idea-cargo-home
CARGO_HOME=/tmp/idea-cargo-home APPIMAGE_EXTRACT_AND_RUN=1 NO_STRIP=1 ../../frontend/node_modules/.bin/tauri build --bundles appimage
```
Resultat: OK. Tauri a relance le beforeBuildCommand `npm --prefix ../frontend run build:bundle`, puis compile Rust release et bundle AppImage.
Artefact produit:
```text
/home/anthony/Documents/Projects/IdeA/target/release/bundle/appimage/IdeA_0.3.0_amd64.AppImage
```
Verification:
- taille: 114M
- permissions: executable `-rwxr-xr-x`
- sha256: `dca819a3419a70aea46c5f378d5e48a159fcbb940dbae0270c2d2b59f61c5716`
Note: l'AppImage installee n'a pas ete remplacee et IdeA n'a pas ete relance, conformement au skill: cette etape tuerait la session active et doit etre faite manuellement ou apres validation explicite utilisateur.

View File

@ -0,0 +1,17 @@
---
id: "9a3e30b9-28e0-406a-ba00-e49364396c18"
number: 274
title: "Rebuild AppImage apres hot reload plugins #269"
status: "closed"
priority: "high"
sprint: null
links: []
agentRefs: []
attachments: []
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
createdAt: 1788803310537
updatedAt: 1788803503214
version: 3
---
Reconstruire l'AppImage Linux apres merge local du chantier #269/#271 afin que le hot reload plugin agent-facing soit disponible dans le binaire installe apres remplacement manuel. Ne pas remplacer ni relancer IdeA automatiquement pendant la session active.

View File

@ -0,0 +1,13 @@
---
issueRef: "#275"
version: 3
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedAt: 1788811630535
---
# Reprise
Contexte projet global initialise le 2026-09-07 par Main via `idea_context_update`.
Contenu ecrit : synthese d'orientation du projet IdeA, roles des agents, cycle de developpement, acquis fonctionnels, conventions stables, points d'attention et principales notes memoire a consulter.
Verification : `idea_context_read` retourne maintenant le contexte Markdown complet avec la version `539321b724883fd49c1fed7ccfbd6a5380852bc3d8d5dc904939d66314e8eaa9`.

View File

@ -0,0 +1,17 @@
---
id: "468121bd-cc48-412f-abcc-49291d1e110c"
number: 275
title: "Mettre a jour le contexte projet global IdeA"
status: "closed"
priority: "medium"
sprint: null
links: []
agentRefs: []
attachments: []
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
createdAt: 1788811577381
updatedAt: 1788811630535
version: 3
---
Initialiser le contexte projet global visible dans Panneaux -> Context avec une synthese stable de l'etat actuel du projet, des regles de pilotage et des principaux chantiers livres/restants.

View File

@ -0,0 +1,142 @@
---
issueRef: "#276"
version: 6
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedAt: 1788814093598
---
## Décision Git — 2026-09-07
- Arbitrage acté : `isOrchestrator` conservé comme rôle single-writer du contexte global, distinct des permissions MCP ; à exposer/configurer par agent (contrats + implémentation + surface UX + tests).
- Branche locale de travail : `feature/276-orchestrator-context-writer`, créée depuis `develop` @ ad2c6b4 (pointe intégrée, QA #269/#273 verte). Aucune branche parallèle backend/frontend nécessaire — périmètre modéré, branche unique.
- Changements préexistants transportés sur la branche (non commités) : bookkeeping du store `.ideai/` (permissions, tickets, memory) — Git les traitera dans un chore séparé au moment du commit.
- Point d'attention signalé à Main : `.ideai/CONTEXT.md` (contexte global) est non suivi et non gitignoré — décider en cours de cycle s'il doit être versionné ou exclu.
- Consigne devs : travailler directement sur `feature/276-orchestrator-context-writer`, ne pas créer de branche, ne pas commiter (Git s'en charge), aucune action distante.
## Handoff DevBackend — 2026-09-07
Implémentation backend présente dans le working tree :
- use case `UpdateProjectOrchestrator` : vérifie que l'agent appartient au projet, persiste sa désignation dans le manifeste, reste idempotent si le writer est déjà effectif, puis publie `DomainEvent::OrchestratorChanged` après succès de persistance ;
- câblage dans `BackendCore` et commande Tauri `update_project_orchestrator` enregistrée ;
- DTO wire camelCase : requête `{ projectId, agentId }`, réponse `{ orchestratorAgentId }` ;
- préservation de la désignation explicite lors des mutations d'agent, skills et templates ; suppression du writer explicite avec succession automatique vers le plus ancien agent restant ;
- message de refus de `idea_context_update` enrichi avec le writer courant et l'orientation vers `idea_context_propose` ; catalogue MCP clarifié : les permissions MCP ne confèrent pas le rôle de writer ;
- tests ciblés ajoutés pour désignation, agent inconnu, préservation lors d'une mutation, suppression/succession, message de refus et désérialisation DTO.
Fichiers volontairement modifiés :
- `crates/application/src/agent/lifecycle.rs`
- `crates/application/src/agent/mod.rs`
- `crates/application/src/lib.rs`
- `crates/application/src/orchestrator/context_guard.rs`
- `crates/application/src/skill/usecases.rs`
- `crates/application/src/template/usecases.rs`
- `crates/application/tests/agent_lifecycle.rs`
- `crates/backend/src/dto.rs`
- `crates/backend/src/lib.rs`
- `crates/app-tauri/src/commands.rs`
- `crates/app-tauri/src/lib.rs`
- `crates/app-tauri/tests/dto_agents.rs`
- `crates/domain/src/fileguard.rs`
- `crates/infrastructure/src/orchestrator/mcp/tools.rs`
Validation récupérable après timeout : `git diff --check` est vert. Aucun verdict Cargo complet n'est récupérable dans la session reprise ; ne pas considérer ce lot validé avant `cargo fmt --all --check`, les tests ciblés application/app-tauri/infrastructure, puis le check workspace par QA.
Contrat frontend : invoquer `update_project_orchestrator` avec `{ projectId: string, agentId: string }`; succès `{ orchestratorAgentId: string }`. Rafraîchir la liste des agents pour lire les indicateurs `isOrchestrator` effectifs (radio/single-writer).
## Handoff DevFrontend — 2026-09-07
Implémentation frontend livrée dans le working tree, sans commit ni action distante :
- `Agent.isOrchestrator` ajouté au DTO frontend et `AgentGateway.updateProjectOrchestrator(projectId, agentId)` ajouté au port ;
- adapters Tauri et HTTP branchés sur `update_project_orchestrator` avec le payload `{ request: { projectId, agentId } }` et la réponse `{ orchestratorAgentId }` ; mock gateway aligné sur l'exclusivité single-writer ;
- `useAgents.updateProjectOrchestrator` appelle le backend, rafraîchit ensuite `listAgents` et expose les erreurs via l'alerte existante ;
- dans la ligne sélectionnée de la fiche agent, section `Rôle dans le projet` avant les autres contrôles, radio `Writer du contexte projet global` et aide UX littérale ;
- badge sobre `Writer contexte` dans l'identité de l'agent effectif ;
- confirmation avant transfert lorsque la liste expose déjà un autre writer, avec noms de l'ancien et du nouveau writer ;
- aucun changement au panneau ni aux checkboxes de permissions MCP.
Fichiers frontend modifiés :
- `frontend/src/domain/index.ts`
- `frontend/src/ports/index.ts`
- `frontend/src/adapters/agent.ts`
- `frontend/src/adapters/http/streamGateways.ts`
- `frontend/src/adapters/mock/index.ts`
- `frontend/src/features/agents/useAgents.ts`
- `frontend/src/features/agents/AgentsPanel.tsx`
- `frontend/src/adapters/agent.test.ts`
- `frontend/src/adapters/http/agentGateway.test.ts`
- `frontend/src/features/agents/agents.test.tsx`
Validation exécutée :
- `cd frontend && npm test -- --run src/features/agents/agents.test.tsx src/adapters/agent.test.ts src/adapters/http/agentGateway.test.ts` — vert, 3 fichiers / 69 tests ; avertissement jsdom connu sur `HTMLCanvasElement.getContext`, sans échec.
- `cd frontend && npm run typecheck` — vert.
- `git diff --check` — vert.
Verdict DevFrontend : lot #276 implémenté et prêt pour validation QA. Aucun ajustement backend effectué.
## Verdict QA — 2026-09-07
Verdict QA officiel : VERT sur `feature/276-orchestrator-context-writer` @ `ad2c6b4`.
Inspection rapide : les diffs pertinents correspondent au périmètre annoncé : rôle `isOrchestrator` conservé comme writer single-writer distinct des permissions MCP, use case/commande/DTO/adapters ajoutés, garde `idea_context_update` clarifiée, UI agent avec section rôle/radio/badge/confirmation.
Commandes exécutées et résultats :
```bash
git -C /home/anthony/Documents/Projects/IdeA status --short --branch
# ## feature/276-orchestrator-context-writer
# worktree sale attendu : changements feature + bookkeeping .ideai/ non suivis/modifiés
git -C /home/anthony/Documents/Projects/IdeA rev-parse --short HEAD
# ad2c6b4
git -C /home/anthony/Documents/Projects/IdeA diff --check
# exit 0
cargo fmt --all --check
# exit 0
cargo test -p application --test agent_lifecycle orchestrator -- --nocapture
# 5 passed; 0 failed; 106 filtered out
cargo test -p application context_update -- --nocapture
# 1 passed; 0 failed; 165 filtered out
cargo test -p application --test skill_usecases -- --nocapture
# 9 passed; 0 failed
cargo test -p application --test template_usecases -- --nocapture
# 11 passed; 0 failed
cargo test -p app-tauri update_project_orchestrator -- --nocapture
# 1 passed; 0 failed; autres cibles filtrées
cargo test -p app-tauri --test dto_agents orchestrator -- --nocapture
# 1 passed; 0 failed; 29 filtered out
cargo test -p infrastructure tools -- --nocapture
# lib: 28 passed; 0 failed; mcp_server: 10 passed; 0 failed; autres cibles filtrées
cargo test -p infrastructure --test context_store orchestrator -- --nocapture
# 2 passed; 0 failed; 5 filtered out
cargo check -p backend -p app-tauri
# Finished dev profile successfully
cargo test -p backend --lib -- --nocapture
# 83 passed; 0 failed; 7 ignored
cd frontend && npm test -- --run src/features/agents/agents.test.tsx src/adapters/agent.test.ts src/adapters/http/agentGateway.test.ts
# Test Files 3 passed; Tests 69 passed
# Avertissement connu jsdom: HTMLCanvasElement.getContext non implémenté, sans échec
cd frontend && npm run typecheck
# tsc --noEmit OK
```
Commandes de ciblage non comptées comme validation : un essai `cargo test -p application --test orchestrator_context_guard ...` a échoué car cette cible n'existe pas ; un filtre `cargo test -p infrastructure --test mcp_server context_update ...` a sélectionné 0 test. Les validations utiles ci-dessus remplacent ces filtres.
Risques résiduels : pas de test manuel AppImage/runtime desktop exécuté ; validation limitée aux tests ciblés backend/frontend et au check de compilation. Les fichiers `.ideai/` et `scripts/` non suivis/modifiés restent à arbitrer par Git comme prévu dans le carnet.

View File

@ -0,0 +1,17 @@
---
id: "4c4ba97b-d56f-48f0-880b-602a9dfd0ea4"
number: 276
title: "Clarifier et exposer le writer du contexte projet global"
status: "qa"
priority: "high"
sprint: null
links: []
agentRefs: []
attachments: []
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"}
createdAt: 1788812362189
updatedAt: 1788814093598
version: 6
---
Le tool `idea_context_update` refuse l'ecriture du contexte projet global si l'agent n'est pas marque `isOrchestrator`, alors que l'utilisateur s'attend a piloter les droits via le menu Permissions. Arbitrer le modele juste pour IdeA : supprimer ce garde-fou si les permissions suffisent, ou conserver un writer/orchestrateur explicite et configurable par agent dans l'UI. Mettre a jour les contrats, l'implementation, la surface UX et les tests.

View File

@ -1,3 +1,3 @@
{ {
"nextNumber": 269 "nextNumber": 277
} }

View File

@ -3623,7 +3623,7 @@
"issueRef": "#267", "issueRef": "#267",
"path": "267", "path": "267",
"title": "Corriger permissions cross-project agents/templates et reconstruire l’AppImage", "title": "Corriger permissions cross-project agents/templates et reconstruire l’AppImage",
"status": "qa", "status": "closed",
"priority": "critical", "priority": "critical",
"sprint": null, "sprint": null,
"assignedAgentIds": [ "assignedAgentIds": [
@ -3637,7 +3637,7 @@
"kind": "agent", "kind": "agent",
"agent_id": "fe887179-933f-47d4-960f-c3b06827f86c" "agent_id": "fe887179-933f-47d4-960f-c3b06827f86c"
}, },
"updatedAt": 1788797485001 "updatedAt": 1788797669968
}, },
{ {
"issueRef": "#268", "issueRef": "#268",
@ -3652,6 +3652,127 @@
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
}, },
"updatedAt": 1788796144919 "updatedAt": 1788796144919
},
{
"issueRef": "#269",
"path": "269",
"title": "Hot reload des plugins sans relancer IdeA pour les agents inter-projets",
"status": "closed",
"priority": "high",
"sprint": null,
"assignedAgentIds": [],
"createdBy": {
"kind": "agent",
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
},
"updatedAt": 1788802262284
},
{
"issueRef": "#270",
"path": "270",
"title": "Cadrer l'architecture du hot reload plugin et des tools dynamiques",
"status": "closed",
"priority": "high",
"sprint": null,
"assignedAgentIds": [
"b4730d7f-c54d-4736-8a04-c6203aa2fd49"
],
"createdBy": {
"kind": "agent",
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
},
"updatedAt": 1788799729583
},
{
"issueRef": "#271",
"path": "271",
"title": "Expose agent-facing plugin reload command/tool",
"status": "closed",
"priority": "high",
"sprint": null,
"assignedAgentIds": [
"fe887179-933f-47d4-960f-c3b06827f86c"
],
"createdBy": {
"kind": "agent",
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
},
"updatedAt": 1788802202168
},
{
"issueRef": "#272",
"path": "272",
"title": "Surface UX de reload plugin et feedback agent/utilisateur",
"status": "closed",
"priority": "medium",
"sprint": null,
"assignedAgentIds": [
"757430bb-014a-40fa-b82b-f2429dab9766",
"8f7da528-58df-4315-97e9-0562230ecc19"
],
"createdBy": {
"kind": "agent",
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
},
"updatedAt": 1788802083238
},
{
"issueRef": "#273",
"path": "273",
"title": "QA hot reload plugin sans relance IdeA",
"status": "closed",
"priority": "high",
"sprint": null,
"assignedAgentIds": [
"ab328d90-c307-4771-a3b6-6c56089c8506"
],
"createdBy": {
"kind": "agent",
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
},
"updatedAt": 1788801870891
},
{
"issueRef": "#274",
"path": "274",
"title": "Rebuild AppImage apres hot reload plugins #269",
"status": "closed",
"priority": "high",
"sprint": null,
"assignedAgentIds": [],
"createdBy": {
"kind": "agent",
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
},
"updatedAt": 1788803503214
},
{
"issueRef": "#275",
"path": "275",
"title": "Mettre a jour le contexte projet global IdeA",
"status": "closed",
"priority": "medium",
"sprint": null,
"assignedAgentIds": [],
"createdBy": {
"kind": "agent",
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
},
"updatedAt": 1788811630535
},
{
"issueRef": "#276",
"path": "276",
"title": "Clarifier et exposer le writer du contexte projet global",
"status": "qa",
"priority": "high",
"sprint": null,
"assignedAgentIds": [],
"createdBy": {
"kind": "agent",
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
},
"updatedAt": 1788814093598
} }
] ]
} }

View File

@ -32,8 +32,8 @@ use application::{
UnassignSkillFromAgentInput, UpdateAgentContactableInput, UpdateAgentContextInput, UnassignSkillFromAgentInput, UpdateAgentContactableInput, UpdateAgentContextInput,
UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput, UpdateAgentPermissionsInput,
UpdateAgentSystemPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput, UpdateAgentSystemPermissionsInput, UpdateMemoryInput, UpdateProjectContextInput,
UpdateProjectMcpToolPermissionsInput, UpdateProjectPermissionsInput, UpdateProjectMcpToolPermissionsInput, UpdateProjectOrchestratorInput,
UpdateProjectSystemPermissionsInput, UpdateSkillInput, UpdateProjectPermissionsInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput,
}; };
use backend::stream::OutputSink; use backend::stream::OutputSink;
use domain::ports::ModelServerRuntime; use domain::ports::ModelServerRuntime;
@ -81,7 +81,8 @@ use crate::dto::{
UpdateAgentContactableRequestDto, UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto, UpdateAgentContactableRequestDto, UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto,
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto, UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto, UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto, UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectOrchestratorRequestDto,
UpdateProjectOrchestratorResponseDto, UpdateProjectPermissionsRequestDto,
UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto, UpdateProjectSystemPermissionsRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
WriteTerminalRequestDto, WriteTerminalRequestDto,
}; };
@ -800,6 +801,27 @@ pub async fn update_agent_contactable(
.map_err(ErrorDto::from) .map_err(ErrorDto::from)
} }
/// `update_project_orchestrator` — select the single global project-context writer.
///
/// # Errors
/// Returns an [`ErrorDto`] when ids are invalid, the agent is unknown, or persistence fails.
#[tauri::command]
pub async fn update_project_orchestrator(
request: UpdateProjectOrchestratorRequestDto,
state: State<'_, AppState>,
) -> Result<UpdateProjectOrchestratorResponseDto, ErrorDto> {
let project = resolve_project(&request.project_id, &state).await?;
let agent_id = parse_agent_id(&request.agent_id)?;
state
.update_project_orchestrator
.execute(UpdateProjectOrchestratorInput { project, agent_id })
.await
.map(|out| UpdateProjectOrchestratorResponseDto {
orchestrator_agent_id: out.orchestrator.to_string(),
})
.map_err(ErrorDto::from)
}
/// `resolve_agent_permissions` — resolve project defaults plus agent override. /// `resolve_agent_permissions` — resolve project defaults plus agent override.
/// ///
/// # Errors /// # Errors

View File

@ -262,6 +262,7 @@ pub fn run() {
commands::update_agent_permissions, commands::update_agent_permissions,
commands::update_agent_effort, commands::update_agent_effort,
commands::update_agent_contactable, commands::update_agent_contactable,
commands::update_project_orchestrator,
commands::resolve_agent_permissions, commands::resolve_agent_permissions,
commands::get_project_system_permissions, commands::get_project_system_permissions,
commands::update_project_system_permissions, commands::update_project_system_permissions,

View File

@ -6,7 +6,7 @@ use app_tauri_lib::dto::{
parse_agent_id, AgentDto, AgentListDto, CellKind, ConversationDetailsDto, parse_agent_id, AgentDto, AgentListDto, CellKind, ConversationDetailsDto,
CreateAgentRequestDto, InspectConversationRequestDto, LaunchAgentRequestDto, LiveAgentListDto, CreateAgentRequestDto, InspectConversationRequestDto, LaunchAgentRequestDto, LiveAgentListDto,
TerminalSessionDto, UpdateAgentContactableRequestDto, UpdateAgentContextRequestDto, TerminalSessionDto, UpdateAgentContactableRequestDto, UpdateAgentContextRequestDto,
UpdateAgentEffortRequestDto, UpdateAgentEffortRequestDto, UpdateProjectOrchestratorRequestDto,
}; };
use application::AppError; use application::AppError;
use application::{ use application::{
@ -188,6 +188,17 @@ fn update_agent_contactable_request_deserialises_camelcase() {
assert_eq!(dto.agent_id, Uuid::from_u128(2).to_string()); assert_eq!(dto.agent_id, Uuid::from_u128(2).to_string());
} }
#[test]
fn update_project_orchestrator_request_deserialises_compatible_wire_fields() {
let raw = json!({
"projectId": Uuid::from_u128(1).to_string(),
"agentId": Uuid::from_u128(2).to_string()
});
let dto: UpdateProjectOrchestratorRequestDto = serde_json::from_value(raw).unwrap();
assert_eq!(dto.project_id, Uuid::from_u128(1).to_string());
assert_eq!(dto.agent_id, Uuid::from_u128(2).to_string());
}
#[test] #[test]
fn launch_agent_request_deserialises_camelcase() { fn launch_agent_request_deserialises_camelcase() {
let project_id = Uuid::from_u128(1).to_string(); let project_id = Uuid::from_u128(1).to_string();

View File

@ -255,9 +255,10 @@ impl CreateAgentFromScratch {
.map_err(|e| AppError::Invalid(e.to_string()))?; .map_err(|e| AppError::Invalid(e.to_string()))?;
// Append the entry and re-validate the whole manifest (unique md_paths). // Append the entry and re-validate the whole manifest (unique md_paths).
let orchestrator = manifest.orchestrator;
let mut entries = manifest.entries; let mut entries = manifest.entries;
entries.push(ManifestEntry::from_agent(&agent)); entries.push(ManifestEntry::from_agent(&agent));
let manifest = AgentManifest::new(manifest.version, entries) let manifest = AgentManifest::with_orchestrator(manifest.version, entries, orchestrator)
.map_err(|e| AppError::Invalid(e.to_string()))?; .map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts self.contexts
.save_manifest(&input.project, &manifest) .save_manifest(&input.project, &manifest)
@ -345,6 +346,83 @@ impl ListAgentsOutput {
} }
} }
// ---------------------------------------------------------------------------
// UpdateProjectOrchestrator
// ---------------------------------------------------------------------------
/// Input for [`UpdateProjectOrchestrator::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateProjectOrchestratorInput {
/// The project whose global-context writer changes.
pub project: Project,
/// The existing agent that becomes the single effective writer.
pub agent_id: AgentId,
}
/// Output of [`UpdateProjectOrchestrator::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateProjectOrchestratorOutput {
/// The effective writer after the update.
pub orchestrator: AgentId,
}
/// Changes the project's single global-context writer.
///
/// The manifest is validated and persisted as one document before the change event is
/// published. Selecting the already-effective writer is an idempotent no-op.
pub struct UpdateProjectOrchestrator {
contexts: Arc<dyn AgentContextStore>,
events: Arc<dyn EventBus>,
}
impl UpdateProjectOrchestrator {
/// Builds the use case.
#[must_use]
pub fn new(contexts: Arc<dyn AgentContextStore>, events: Arc<dyn EventBus>) -> Self {
Self { contexts, events }
}
/// Designates an existing project agent as the global-context writer.
///
/// # Errors
/// - [`AppError::NotFound`] if the agent is unknown to the project,
/// - [`AppError::Store`] on persistence failure.
pub async fn execute(
&self,
input: UpdateProjectOrchestratorInput,
) -> Result<UpdateProjectOrchestratorOutput, AppError> {
let mut manifest = self.contexts.load_manifest(&input.project).await?;
if !manifest
.entries
.iter()
.any(|entry| entry.agent_id == input.agent_id)
{
return Err(AppError::NotFound(format!("agent {}", input.agent_id)));
}
if manifest.effective_orchestrator() == Some(input.agent_id) {
return Ok(UpdateProjectOrchestratorOutput {
orchestrator: input.agent_id,
});
}
manifest
.designate(input.agent_id)
.map_err(|err| AppError::Invalid(err.to_string()))?;
self.contexts
.save_manifest(&input.project, &manifest)
.await?;
self.events.publish(DomainEvent::OrchestratorChanged {
project_id: input.project.id,
orchestrator: Some(input.agent_id),
});
Ok(UpdateProjectOrchestratorOutput {
orchestrator: input.agent_id,
})
}
}
/// Lists a project's agents by reconstructing them from the manifest entries. /// Lists a project's agents by reconstructing them from the manifest entries.
pub struct ListAgents { pub struct ListAgents {
contexts: Arc<dyn AgentContextStore>, contexts: Arc<dyn AgentContextStore>,
@ -579,8 +657,12 @@ impl UpdateAgentContactable {
.with_contactable(input.contactable); .with_contactable(input.contactable);
*entry = ManifestEntry::from_agent(&agent); *entry = ManifestEntry::from_agent(&agent);
let manifest = AgentManifest::new(manifest.version, manifest.entries) let manifest = AgentManifest::with_orchestrator(
.map_err(|err| AppError::Invalid(err.to_string()))?; manifest.version,
manifest.entries,
manifest.orchestrator,
)
.map_err(|err| AppError::Invalid(err.to_string()))?;
self.contexts self.contexts
.save_manifest(&input.project, &manifest) .save_manifest(&input.project, &manifest)
.await?; .await?;
@ -625,8 +707,12 @@ impl UpdateAgentEffort {
.with_effort(input.effort); .with_effort(input.effort);
*entry = ManifestEntry::from_agent(&agent); *entry = ManifestEntry::from_agent(&agent);
let manifest = AgentManifest::new(manifest.version, manifest.entries) let manifest = AgentManifest::with_orchestrator(
.map_err(|err| AppError::Invalid(err.to_string()))?; manifest.version,
manifest.entries,
manifest.orchestrator,
)
.map_err(|err| AppError::Invalid(err.to_string()))?;
self.contexts self.contexts
.save_manifest(&input.project, &manifest) .save_manifest(&input.project, &manifest)
.await?; .await?;
@ -800,6 +886,7 @@ impl ChangeAgentProfile {
// 4. Mutate the entry (new profile), re-validate (to_agent + manifest) // 4. Mutate the entry (new profile), re-validate (to_agent + manifest)
// and persist. // and persist.
let orchestrator = manifest.orchestrator;
let mut entries = manifest.entries; let mut entries = manifest.entries;
let mut mutated_agent = None; let mut mutated_agent = None;
for e in &mut entries { for e in &mut entries {
@ -813,7 +900,7 @@ impl ChangeAgentProfile {
} }
let agent = let agent =
mutated_agent.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?; mutated_agent.ok_or_else(|| AppError::NotFound(format!("agent {}", input.agent_id)))?;
let manifest = AgentManifest::new(manifest.version, entries) let manifest = AgentManifest::with_orchestrator(manifest.version, entries, orchestrator)
.map_err(|e| AppError::Invalid(e.to_string()))?; .map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts self.contexts
.save_manifest(&input.project, &manifest) .save_manifest(&input.project, &manifest)
@ -1143,14 +1230,13 @@ impl DeleteAgent {
/// - [`AppError::NotFound`] if the agent is not in the manifest, /// - [`AppError::NotFound`] if the agent is not in the manifest,
/// - [`AppError::Store`] on persistence failure. /// - [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: DeleteAgentInput) -> Result<(), AppError> { pub async fn execute(&self, input: DeleteAgentInput) -> Result<(), AppError> {
let manifest = self.contexts.load_manifest(&input.project).await?; let mut manifest = self.contexts.load_manifest(&input.project).await?;
let previous_orchestrator = manifest.effective_orchestrator();
let before = manifest.entries.len(); let before = manifest.entries.len();
let entries: Vec<ManifestEntry> = manifest manifest
.entries .entries
.into_iter() .retain(|entry| entry.agent_id != input.agent_id);
.filter(|e| e.agent_id != input.agent_id) if manifest.entries.len() == before {
.collect();
if entries.len() == before {
return Err(AppError::NotFound(format!("agent {}", input.agent_id))); return Err(AppError::NotFound(format!("agent {}", input.agent_id)));
} }
if let Some((live, close)) = &self.runtime_cleanup { if let Some((live, close)) = &self.runtime_cleanup {
@ -1158,11 +1244,17 @@ impl DeleteAgent {
stop_live_agent_runtime_in_project(&input.project, input.agent_id, live, close) stop_live_agent_runtime_in_project(&input.project, input.agent_id, live, close)
.await?; .await?;
} }
let manifest = AgentManifest::new(manifest.version, entries) manifest.on_agent_deleted(input.agent_id);
.map_err(|e| AppError::Invalid(e.to_string()))?; let next_orchestrator = manifest.effective_orchestrator();
self.contexts self.contexts
.save_manifest(&input.project, &manifest) .save_manifest(&input.project, &manifest)
.await?; .await?;
if previous_orchestrator != next_orchestrator {
self.events.publish(DomainEvent::OrchestratorChanged {
project_id: input.project.id,
orchestrator: manifest.orchestrator,
});
}
self.events.publish(DomainEvent::LayoutChanged { self.events.publish(DomainEvent::LayoutChanged {
project_id: input.project.id, project_id: input.project.id,
}); });

View File

@ -40,6 +40,7 @@ pub use lifecycle::{
StructuredRoutingMode, StructuredSessionDescriptor, UpdateAgentContactable, StructuredRoutingMode, StructuredSessionDescriptor, UpdateAgentContactable,
UpdateAgentContactableInput, UpdateAgentContactableOutput, UpdateAgentContext, UpdateAgentContactableInput, UpdateAgentContactableOutput, UpdateAgentContext,
UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentEffortInput, UpdateAgentEffortOutput, UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentEffortInput, UpdateAgentEffortOutput,
UpdateProjectOrchestrator, UpdateProjectOrchestratorInput, UpdateProjectOrchestratorOutput,
AGENT_MEMORY_RECALL_BUDGET, DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX, AGENT_MEMORY_RECALL_BUDGET, DEFAULT_OPENCODE_MCP_TIMEOUT_MS, LIVE_STATE_INJECT_MAX,
}; };
pub use model_catalogue::{ pub use model_catalogue::{

View File

@ -68,7 +68,8 @@ pub use agent::{
SaveProfileInput, SaveProfileOutput, SessionLimitService, StructuredRoutingMode, SaveProfileInput, SaveProfileOutput, SessionLimitService, StructuredRoutingMode,
StructuredSessionDescriptor, TurnOutcome, UpdateAgentContactable, UpdateAgentContactableInput, StructuredSessionDescriptor, TurnOutcome, UpdateAgentContactable, UpdateAgentContactableInput,
UpdateAgentContactableOutput, UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentContactableOutput, UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort,
UpdateAgentEffortInput, UpdateAgentEffortOutput, AGENT_MEMORY_RECALL_BUDGET, UpdateAgentEffortInput, UpdateAgentEffortOutput, UpdateProjectOrchestrator,
UpdateProjectOrchestratorInput, UpdateProjectOrchestratorOutput, AGENT_MEMORY_RECALL_BUDGET,
CODEX_SUBMIT_DELAY_MS, LIVE_STATE_INJECT_MAX, RESUME_PROMPT, CODEX_SUBMIT_DELAY_MS, LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
}; };
pub use background::{ pub use background::{

View File

@ -243,7 +243,7 @@ impl UpdateProjectContext {
let designation = manifest.orchestrator_designation(); let designation = manifest.orchestrator_designation();
let resource = GuardedResource::ProjectContext; let resource = GuardedResource::ProjectContext;
if !may_write_directly(requester, &resource, &designation) { if !may_write_directly(requester, &resource, &designation) {
return Err(map_guard_err(GuardError::Forbidden)); return Err(AppError::Invalid(context_update_denial_message(&manifest)));
} }
let _lease = self let _lease = self
@ -533,6 +533,25 @@ fn map_guard_err(e: GuardError) -> AppError {
} }
} }
fn context_update_denial_message(manifest: &domain::AgentManifest) -> String {
let writer = manifest.effective_orchestrator().and_then(|id| {
manifest
.entries
.iter()
.find(|entry| entry.agent_id == id)
.map(|entry| format!("{} ({id})", entry.name))
});
match writer {
Some(writer) => format!(
"only the global project context writer may use idea_context_update; current writer: \
{writer}. Use idea_context_propose instead"
),
None => "only the global project context writer may use idea_context_update; this project \
currently has no writer. Use idea_context_propose instead"
.to_owned(),
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -998,6 +1017,13 @@ mod tests {
.unwrap_err(); .unwrap_err();
assert_eq!(err.code(), "INVALID"); assert_eq!(err.code(), "INVALID");
let message = err.to_string();
assert!(
message.contains("only the global project context writer may use idea_context_update")
);
assert!(message.contains("Dev"));
assert!(message.contains(&AgentId::from_uuid(uuid::Uuid::from_u128(7)).to_string()));
assert!(message.contains("idea_context_propose"));
let files = fs.files.lock().unwrap(); let files = fs.files.lock().unwrap();
assert_eq!(files.get(project_context_file()).unwrap(), b"# old"); assert_eq!(files.get(project_context_file()).unwrap(), b"# old");
assert!( assert!(

View File

@ -533,8 +533,12 @@ impl AssignSkillToAgent {
skill_id: SkillId, skill_id: SkillId,
assigned: bool, assigned: bool,
) -> Result<(), AppError> { ) -> Result<(), AppError> {
let manifest = AgentManifest::new(manifest.version, manifest.entries) let manifest = AgentManifest::with_orchestrator(
.map_err(|e| AppError::Invalid(e.to_string()))?; manifest.version,
manifest.entries,
manifest.orchestrator,
)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts.save_manifest(project, &manifest).await?; self.contexts.save_manifest(project, &manifest).await?;
self.events.publish(DomainEvent::SkillAssigned { self.events.publish(DomainEvent::SkillAssigned {
agent_id, agent_id,
@ -591,8 +595,12 @@ impl UnassignSkillFromAgent {
*entry = domain::ManifestEntry::from_agent(&agent); *entry = domain::ManifestEntry::from_agent(&agent);
if changed { if changed {
let manifest = AgentManifest::new(manifest.version, manifest.entries) let manifest = AgentManifest::with_orchestrator(
.map_err(|e| AppError::Invalid(e.to_string()))?; manifest.version,
manifest.entries,
manifest.orchestrator,
)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts self.contexts
.save_manifest(&input.project, &manifest) .save_manifest(&input.project, &manifest)
.await?; .await?;

View File

@ -408,9 +408,10 @@ impl CreateAgentFromTemplate {
.map_err(|e| AppError::Invalid(e.to_string()))? .map_err(|e| AppError::Invalid(e.to_string()))?
.with_contactable(template.default_contactable); .with_contactable(template.default_contactable);
let orchestrator = manifest.orchestrator;
let mut entries = manifest.entries; let mut entries = manifest.entries;
entries.push(ManifestEntry::from_agent(&agent)); entries.push(ManifestEntry::from_agent(&agent));
let manifest = AgentManifest::new(manifest.version, entries) let manifest = AgentManifest::with_orchestrator(manifest.version, entries, orchestrator)
.map_err(|e| AppError::Invalid(e.to_string()))?; .map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts self.contexts
.save_manifest(&input.project, &manifest) .save_manifest(&input.project, &manifest)
@ -624,8 +625,12 @@ impl SyncAgentWithTemplate {
manifest.entries[idx].synced_template_version = Some(template.version); manifest.entries[idx].synced_template_version = Some(template.version);
// Persist the manifest (revalidated) and overwrite the agent context. // Persist the manifest (revalidated) and overwrite the agent context.
let manifest = AgentManifest::new(manifest.version, manifest.entries) let manifest = AgentManifest::with_orchestrator(
.map_err(|e| AppError::Invalid(e.to_string()))?; manifest.version,
manifest.entries,
manifest.orchestrator,
)
.map_err(|e| AppError::Invalid(e.to_string()))?;
self.contexts self.contexts
.save_manifest(&input.project, &manifest) .save_manifest(&input.project, &manifest)
.await?; .await?;

View File

@ -59,6 +59,7 @@ use application::{
ReadAgentContextInput, ReleaseManagedTemp, ResolveManagedTemp, StructuredRoutingMode, ReadAgentContextInput, ReleaseManagedTemp, ResolveManagedTemp, StructuredRoutingMode,
StructuredSessions, TerminalSessions, UpdateAgentContactable, UpdateAgentContactableInput, StructuredSessions, TerminalSessions, UpdateAgentContactable, UpdateAgentContactableInput,
UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentEffortInput, UpdateAgentContext, UpdateAgentContextInput, UpdateAgentEffort, UpdateAgentEffortInput,
UpdateProjectOrchestrator, UpdateProjectOrchestratorInput,
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -1069,6 +1070,63 @@ async fn list_agents_default_orchestrator_is_oldest_agent_when_none_designated()
assert_eq!(entries[1].is_orchestrator, false); assert_eq!(entries[1].is_orchestrator, false);
} }
#[tokio::test]
async fn update_project_orchestrator_persists_single_writer_and_publishes_change() {
let a1 = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
let a2 = scratch_agent(aid(2), "Frontend", "agents/frontend.md", pid(9));
let contexts = FakeContexts::with_agent(&a1, "ctx1");
contexts
.0
.lock()
.unwrap()
.manifest
.entries
.push(ManifestEntry::from_agent(&a2));
let events = SpyBus::default();
let update =
UpdateProjectOrchestrator::new(Arc::new(contexts.clone()), Arc::new(events.clone()));
let out = update
.execute(UpdateProjectOrchestratorInput {
project: project(),
agent_id: a2.id,
})
.await
.unwrap();
assert_eq!(out.orchestrator, a2.id);
assert_eq!(contexts.manifest().orchestrator, Some(a2.id));
assert_eq!(contexts.manifest().effective_orchestrator(), Some(a2.id));
assert_eq!(
events.events(),
vec![DomainEvent::OrchestratorChanged {
project_id: project().id,
orchestrator: Some(a2.id),
}]
);
}
#[tokio::test]
async fn update_project_orchestrator_rejects_unknown_agent_without_mutation_or_event() {
let a1 = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
let contexts = FakeContexts::with_agent(&a1, "ctx1");
let events = SpyBus::default();
let update =
UpdateProjectOrchestrator::new(Arc::new(contexts.clone()), Arc::new(events.clone()));
let err = update
.execute(UpdateProjectOrchestratorInput {
project: project(),
agent_id: aid(404),
})
.await
.unwrap_err();
assert_eq!(err.code(), "NOT_FOUND");
assert_eq!(contexts.manifest().orchestrator, None);
assert!(events.events().is_empty());
}
#[tokio::test] #[tokio::test]
async fn read_then_update_context_roundtrips() { async fn read_then_update_context_roundtrips() {
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9)); let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
@ -1125,6 +1183,30 @@ async fn update_agent_effort_sets_preset_selection_and_persists_manifest() {
); );
} }
#[tokio::test]
async fn agent_mutation_preserves_explicit_project_orchestrator() {
let a1 = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
let a2 = scratch_agent(aid(2), "Frontend", "agents/frontend.md", pid(9));
let contexts = FakeContexts::with_agent(&a1, "ctx1");
{
let mut inner = contexts.0.lock().unwrap();
inner.manifest.entries.push(ManifestEntry::from_agent(&a2));
inner.manifest.designate(a2.id).unwrap();
}
let update = UpdateAgentEffort::new(Arc::new(contexts.clone()));
update
.execute(UpdateAgentEffortInput {
project: project(),
agent_id: a1.id,
effort: Some(EffortSelection::Preset("high".to_owned())),
})
.await
.unwrap();
assert_eq!(contexts.manifest().orchestrator, Some(a2.id));
}
#[tokio::test] #[tokio::test]
async fn update_agent_effort_none_clears_existing_override() { async fn update_agent_effort_none_clears_existing_override() {
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9)) let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9))
@ -1236,6 +1318,35 @@ async fn delete_removes_entry_then_unknown_is_not_found() {
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}"); assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
} }
#[tokio::test]
async fn deleting_writer_clears_explicit_designation_and_falls_back_to_oldest_remaining() {
let a1 = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));
let a2 = scratch_agent(aid(2), "Frontend", "agents/frontend.md", pid(9));
let contexts = FakeContexts::with_agent(&a1, "ctx1");
{
let mut inner = contexts.0.lock().unwrap();
inner.manifest.entries.push(ManifestEntry::from_agent(&a2));
inner.manifest.designate(a2.id).unwrap();
}
let events = SpyBus::default();
let delete = DeleteAgent::new(Arc::new(contexts.clone()), Arc::new(events.clone()));
delete
.execute(DeleteAgentInput {
project: project(),
agent_id: a2.id,
})
.await
.unwrap();
assert_eq!(contexts.manifest().orchestrator, None);
assert_eq!(contexts.manifest().effective_orchestrator(), Some(a1.id));
assert!(events.events().contains(&DomainEvent::OrchestratorChanged {
project_id: project().id,
orchestrator: None,
}));
}
#[tokio::test] #[tokio::test]
async fn delete_agent_cleans_structured_then_pty_runtime_before_manifest_removal() { async fn delete_agent_cleans_structured_then_pty_runtime_before_manifest_removal() {
let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9)); let a = scratch_agent(aid(1), "Backend", "agents/backend.md", pid(9));

View File

@ -3132,6 +3132,27 @@ pub struct UpdateAgentContactableRequestDto {
pub contactable: bool, pub contactable: bool,
} }
/// Request DTO for `update_project_orchestrator`.
///
/// The legacy `orchestrator` terminology is intentionally kept on the wire for
/// compatibility; the product-facing role is the global project-context writer.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateProjectOrchestratorRequestDto {
/// Id of the owning project.
pub project_id: String,
/// Existing project agent that becomes the single effective writer.
pub agent_id: String,
}
/// Response DTO for `update_project_orchestrator`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateProjectOrchestratorResponseDto {
/// Effective writer agent id after the update.
pub orchestrator_agent_id: String,
}
/// Response DTO for `change_agent_profile`: the mutated agent plus the freshly /// Response DTO for `change_agent_profile`: the mutated agent plus the freshly
/// relaunched session when a live session was hot-swapped (absent otherwise). /// relaunched session when a live session was hot-swapped (absent otherwise).
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]

View File

@ -57,9 +57,9 @@ use application::{
UninstallPlugin, UnlinkIssues, UpdateAgentContactable, UpdateAgentContext, UpdateAgentEffort, UninstallPlugin, UnlinkIssues, UpdateAgentContactable, UpdateAgentContext, UpdateAgentEffort,
UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions, UpdateAgentMcpToolPermissions, UpdateAgentPermissions, UpdateAgentSystemPermissions,
UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, UpdateProjectContext,
UpdateProjectMcpToolPermissions, UpdateProjectPermissions, UpdateProjectSystemPermissions, UpdateProjectMcpToolPermissions, UpdateProjectOrchestrator, UpdateProjectPermissions,
UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, UpdateProjectSystemPermissions, UpdateSkill, UpdateTemplate, WakeSessionProvider, WriteMemory,
AGENT_MEMORY_RECALL_BUDGET, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET,
}; };
use async_trait::async_trait; use async_trait::async_trait;
use domain::ports::{ use domain::ports::{
@ -1338,6 +1338,8 @@ pub struct BackendCore {
pub update_agent_effort: Arc<UpdateAgentEffort>, pub update_agent_effort: Arc<UpdateAgentEffort>,
/// Update one agent's cross-project contactability. /// Update one agent's cross-project contactability.
pub update_agent_contactable: Arc<UpdateAgentContactable>, pub update_agent_contactable: Arc<UpdateAgentContactable>,
/// Change the single agent allowed to write the global project context.
pub update_project_orchestrator: Arc<UpdateProjectOrchestrator>,
/// Resolve effective permissions for one agent. /// Resolve effective permissions for one agent.
pub resolve_agent_permissions: Arc<ResolveAgentPermissions>, pub resolve_agent_permissions: Arc<ResolveAgentPermissions>,
/// Read the project system permission document. /// Read the project system permission document.
@ -2509,6 +2511,10 @@ impl BackendCore {
let update_agent_effort = Arc::new(UpdateAgentEffort::new(Arc::clone(&contexts_port))); let update_agent_effort = Arc::new(UpdateAgentEffort::new(Arc::clone(&contexts_port)));
let update_agent_contactable = let update_agent_contactable =
Arc::new(UpdateAgentContactable::new(Arc::clone(&contexts_port))); Arc::new(UpdateAgentContactable::new(Arc::clone(&contexts_port)));
let update_project_orchestrator = Arc::new(UpdateProjectOrchestrator::new(
Arc::clone(&contexts_port),
Arc::clone(&events_port),
));
let resolve_agent_permissions = Arc::new(ResolveAgentPermissions::new(Arc::clone( let resolve_agent_permissions = Arc::new(ResolveAgentPermissions::new(Arc::clone(
&permission_store_port, &permission_store_port,
))); )));
@ -3363,6 +3369,7 @@ impl BackendCore {
update_agent_permissions, update_agent_permissions,
update_agent_effort, update_agent_effort,
update_agent_contactable, update_agent_contactable,
update_project_orchestrator,
resolve_agent_permissions, resolve_agent_permissions,
get_project_system_permissions, get_project_system_permissions,
update_project_system_permissions, update_project_system_permissions,

View File

@ -65,7 +65,9 @@ pub enum GuardError {
/// A party that is **not** the orchestrator attempted to `acquire_write` the /// A party that is **not** the orchestrator attempted to `acquire_write` the
/// single-writer [`GuardedResource::ProjectContext`]. It must *propose* the /// single-writer [`GuardedResource::ProjectContext`]. It must *propose* the
/// change for validation rather than writing directly. /// change for validation rather than writing directly.
#[error("writing the global project context is reserved to the orchestrator; propose instead")] #[error(
"writing the global project context is reserved to its effective writer; propose instead"
)]
Forbidden, Forbidden,
} }

View File

@ -402,10 +402,11 @@ pub fn catalogue() -> Vec<ToolDef> {
}, },
ToolDef { ToolDef {
name: "idea_context_update", name: "idea_context_update",
description: "Directly update the global project context. Strict, orchestrator-only \ description: "Directly update the global project context. Only the project's effective \
counterpart to idea_context_propose: if you are not the project's \ global-context writer may call this tool (reported as isOrchestrator by \
current orchestrator (see isOrchestrator on idea_list_agents), this call \ idea_list_agents); MCP permissions do not grant this governance role. A \
fails and never silently files a proposal. Pass ifMatch from a prior \ non-writer call fails without writing or creating a proposal and should \
use idea_context_propose instead. Pass ifMatch from a prior \
idea_context_read to reject the write if the context changed.", idea_context_read to reject the write if the context changed.",
input_schema: json!({ input_schema: json!({
"type": "object", "type": "object",
@ -1094,9 +1095,13 @@ mod tests {
tool_access("idea_context_update"), tool_access("idea_context_update"),
Some(McpToolAccess::WriteAction) Some(McpToolAccess::WriteAction)
); );
assert!(catalogue() let tool = catalogue()
.into_iter() .into_iter()
.any(|tool| tool.name == "idea_context_update")); .find(|tool| tool.name == "idea_context_update")
.expect("idea_context_update registered");
assert!(tool.description.contains("global-context writer"));
assert!(tool.description.contains("MCP permissions do not grant"));
assert!(tool.description.contains("idea_context_propose"));
let cmd = map_tool_call( let cmd = map_tool_call(
"idea_context_update", "idea_context_update",

View File

@ -176,6 +176,18 @@ describe("TauriAgentGateway invoke payloads", () => {
}); });
}); });
it("update_project_orchestrator wraps the project and agent in the request DTO", async () => {
invoke.mockResolvedValueOnce({ orchestratorAgentId: "agent-2" });
await expect(
new TauriAgentGateway().updateProjectOrchestrator("proj-1", "agent-2"),
).resolves.toEqual({ orchestratorAgentId: "agent-2" });
expect(invoke).toHaveBeenCalledWith("update_project_orchestrator", {
request: { projectId: "proj-1", agentId: "agent-2" },
});
});
it("cancelAgentChat invokes cancel_agent_chat without closing the session", async () => { it("cancelAgentChat invokes cancel_agent_chat without closing the session", async () => {
await new TauriAgentGateway().cancelAgentChat("chat-session-1"); await new TauriAgentGateway().cancelAgentChat("chat-session-1");

View File

@ -154,6 +154,16 @@ export class TauriAgentGateway implements AgentGateway {
}); });
} }
updateProjectOrchestrator(
projectId: string,
agentId: string,
): Promise<{ orchestratorAgentId: string }> {
return invoke<{ orchestratorAgentId: string }>(
"update_project_orchestrator",
{ request: { projectId, agentId } },
);
}
readContext(projectId: string, agentId: string): Promise<string> { readContext(projectId: string, agentId: string): Promise<string> {
return invoke<AgentContextDocument>("read_agent_context", { return invoke<AgentContextDocument>("read_agent_context", {
projectId, projectId,

View File

@ -141,6 +141,26 @@ describe("HttpAgentGateway WS round-trip (B6 frames)", () => {
}); });
}); });
it("updates the project orchestrator through the dedicated request DTO", async () => {
const response = { orchestratorAgentId: "agent-2" };
const { gw, calls } = httpAgentGateway(async () => ({
ok: true,
status: 200,
json: async () => response,
text: async () => JSON.stringify(response),
}));
await expect(
gw.updateProjectOrchestrator("proj-1", "agent-2"),
).resolves.toEqual(response);
const init = calls[0].init as { body: string };
expect(JSON.parse(init.body)).toEqual({
command: "update_project_orchestrator",
args: { request: { projectId: "proj-1", agentId: "agent-2" } },
});
});
it("launch → attached: conforming agent.launch frame, assignedConversationId consumed", async () => { it("launch → attached: conforming agent.launch frame, assignedConversationId consumed", async () => {
const { gw, sockets } = gateway(); const { gw, sockets } = gateway();
const chunks: Uint8Array[] = []; const chunks: Uint8Array[] = [];

View File

@ -258,6 +258,15 @@ export class HttpAgentGateway implements AgentGateway {
request: { projectId, agentId, contactable }, request: { projectId, agentId, contactable },
}); });
} }
updateProjectOrchestrator(
projectId: string,
agentId: string,
): Promise<{ orchestratorAgentId: string }> {
return this.http.invoke<{ orchestratorAgentId: string }>(
"update_project_orchestrator",
{ request: { projectId, agentId } },
);
}
readContext(projectId: string, agentId: string): Promise<string> { readContext(projectId: string, agentId: string): Promise<string> {
return this.http return this.http
.invoke<AgentContextDocument>("read_agent_context", { projectId, agentId }) .invoke<AgentContextDocument>("read_agent_context", { projectId, agentId })

View File

@ -720,6 +720,24 @@ export class MockAgentGateway implements AgentGateway {
return structuredClone(list[idx]); return structuredClone(list[idx]);
} }
async updateProjectOrchestrator(
projectId: string,
agentId: string,
): Promise<{ orchestratorAgentId: string }> {
const list = this.getAgents(projectId);
if (!list.some((agent) => agent.id === agentId)) {
const err: GatewayError = {
code: "NOT_FOUND",
message: `agent ${agentId} not found in project ${projectId}`,
};
throw err;
}
for (let index = 0; index < list.length; index += 1) {
list[index] = { ...list[index], isOrchestrator: list[index].id === agentId };
}
return { orchestratorAgentId: agentId };
}
// ── Internal helpers for MockTemplateGateway (same-package use only) ── // ── Internal helpers for MockTemplateGateway (same-package use only) ──
/** /**

View File

@ -1317,6 +1317,8 @@ export interface Agent {
effort?: EffortSelection; effort?: EffortSelection;
/** Whether agents from other projects may contact this agent. Legacy absence means false. */ /** Whether agents from other projects may contact this agent. Legacy absence means false. */
contactable?: boolean; contactable?: boolean;
/** Whether this agent is the effective single writer of the global project context. */
isOrchestrator?: boolean;
} }
/** Response DTO returned by `read_agent_context`; adapters unwrap `content`. */ /** Response DTO returned by `read_agent_context`; adapters unwrap `content`. */

View File

@ -188,6 +188,11 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
agentId: string; agentId: string;
profileId: string; profileId: string;
} | null>(null); } | null>(null);
const [pendingWriterChange, setPendingWriterChange] = useState<{
agentId: string;
agentName: string;
currentWriterName: string;
} | null>(null);
async function confirmProfileChange() { async function confirmProfileChange() {
if (!pendingProfileChange) return; if (!pendingProfileChange) return;
@ -204,6 +209,27 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
} }
} }
function requestWriterChange(agent: Agent) {
if (agent.isOrchestrator || vm.busy) return;
const currentWriter = vm.agents.find((candidate) => candidate.isOrchestrator);
if (currentWriter && currentWriter.id !== agent.id) {
setPendingWriterChange({
agentId: agent.id,
agentName: agent.name,
currentWriterName: currentWriter.name,
});
return;
}
void vm.updateProjectOrchestrator(agent.id);
}
async function confirmWriterChange() {
if (!pendingWriterChange) return;
const agentId = pendingWriterChange.agentId;
setPendingWriterChange(null);
await vm.updateProjectOrchestrator(agentId);
}
const selectedTemplate = templates.find((t) => t.id === newTemplateId) ?? null; const selectedTemplate = templates.find((t) => t.id === newTemplateId) ?? null;
const selectedTemplateMissingProfile = const selectedTemplateMissingProfile =
selectedTemplate !== null && selectedTemplate.defaultProfileId.trim().length === 0; selectedTemplate !== null && selectedTemplate.defaultProfileId.trim().length === 0;
@ -440,6 +466,14 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
> >
<span className="flex min-w-0 max-w-full flex-wrap items-center gap-2"> <span className="flex min-w-0 max-w-full flex-wrap items-center gap-2">
<span className="font-medium text-content">{a.name}</span> <span className="font-medium text-content">{a.name}</span>
{a.isOrchestrator && (
<span
aria-label="Writer contexte"
className="rounded-full border border-border-strong bg-raised px-2 py-0.5 text-xs font-medium text-content"
>
Writer contexte
</span>
)}
{agentDrift && ( {agentDrift && (
<span <span
aria-label="update available" aria-label="update available"
@ -471,6 +505,35 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
)} )}
</button> </button>
{isSelected && (
<section
aria-label={`Rôle dans le projet — ${a.name}`}
className="rounded-md border border-border bg-surface p-3"
>
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted">
Rôle dans le projet
</h4>
<label className="mt-2 flex cursor-pointer items-start gap-2">
<input
type="radio"
name="project-context-writer"
aria-label={`Writer du contexte projet global pour ${a.name}`}
checked={a.isOrchestrator === true}
disabled={vm.busy}
onChange={() => requestWriterChange(a)}
className="mt-0.5"
/>
<span className="text-sm font-medium text-content">
Writer du contexte projet global
</span>
</label>
<p className="mt-1 pl-5 text-xs text-muted">
Peut modifier directement le contexte partagé du projet.
Un seul agent peut avoir ce rôle.
</p>
</section>
)}
{limitState && ( {limitState && (
<AgentLimitBadge <AgentLimitBadge
state={limitState} state={limitState}
@ -747,6 +810,44 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
</div> </div>
</div> </div>
)} )}
{pendingWriterChange && (
<div
role="dialog"
aria-modal="true"
aria-label="Confirmer le changement de writer contexte"
className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 p-4"
>
<div className="w-full max-w-sm rounded-lg border border-border bg-surface p-5 shadow-xl">
<h4 className="text-sm font-semibold text-content">
Changer le writer du contexte
</h4>
<p className="mt-2 text-sm text-muted">
Le rôle sera retiré de {pendingWriterChange.currentWriterName} et
attribué à {pendingWriterChange.agentName}. Continuer&nbsp;?
</p>
<div className="mt-4 flex items-center justify-end gap-2">
<Button
variant="ghost"
aria-label="Annuler le changement de writer contexte"
disabled={vm.busy}
onClick={() => setPendingWriterChange(null)}
>
Annuler
</Button>
<Button
variant="primary"
aria-label="Confirmer le changement de writer contexte"
loading={vm.busy}
disabled={vm.busy}
onClick={() => void confirmWriterChange()}
>
Continuer
</Button>
</div>
</div>
</div>
)}
</Panel> </Panel>
); );
} }

View File

@ -144,6 +144,122 @@ describe("AgentsPanel (with MockAgentGateway)", () => {
}); });
}); });
it("shows the context-writer badge and keeps the role separate from permissions", async () => {
const agent = new MockAgentGateway();
const writer = await agent.createAgent(PROJECT_ID, {
name: "Main",
profileId: "p1",
});
await agent.updateProjectOrchestrator(PROJECT_ID, writer.id);
renderPanel(agent);
expect(await screen.findByText("Writer contexte")).toBeTruthy();
const rowButton = screen
.getAllByRole("button", { name: /main/i })
.find((button) => button.hasAttribute("aria-pressed"))!;
fireEvent.click(rowButton);
expect(await screen.findByText("Rôle dans le projet")).toBeTruthy();
expect(
screen.getByText(
"Peut modifier directement le contexte partagé du projet. Un seul agent peut avoir ce rôle.",
),
).toBeTruthy();
expect(
(screen.getByRole("radio", {
name: "Writer du contexte projet global pour Main",
}) as HTMLInputElement).checked,
).toBe(true);
});
it("confirms replacement of the current writer then refreshes the agent list", async () => {
const agent = new MockAgentGateway();
const current = await agent.createAgent(PROJECT_ID, {
name: "Main",
profileId: "p1",
});
const replacement = await agent.createAgent(PROJECT_ID, {
name: "Architect",
profileId: "p1",
});
await agent.updateProjectOrchestrator(PROJECT_ID, current.id);
const updateSpy = vi.spyOn(agent, "updateProjectOrchestrator");
renderPanel(agent);
const rowButton = (
await screen.findAllByRole("button", { name: /architect/i })
).find((button) => button.hasAttribute("aria-pressed"))!;
fireEvent.click(rowButton);
fireEvent.click(
await screen.findByRole("radio", {
name: "Writer du contexte projet global pour Architect",
}),
);
expect(updateSpy).not.toHaveBeenCalled();
expect(screen.getByRole("dialog").textContent).toMatch(
/Le rôle sera retiré de Main et attribué à Architect\. Continuer/,
);
fireEvent.click(
screen.getByRole("button", {
name: "Confirmer le changement de writer contexte",
}),
);
await waitFor(() => {
expect(updateSpy).toHaveBeenCalledWith(PROJECT_ID, replacement.id);
expect(screen.queryByRole("dialog")).toBeNull();
expect(
(screen.getByRole("radio", {
name: "Writer du contexte projet global pour Architect",
}) as HTMLInputElement).checked,
).toBe(true);
});
expect(
(await agent.listAgents(PROJECT_ID)).filter(
(item) => item.isOrchestrator,
),
).toEqual([expect.objectContaining({ id: replacement.id })]);
});
it("surfaces a backend error when changing the context writer fails", async () => {
const agent = new MockAgentGateway();
const current = await agent.createAgent(PROJECT_ID, {
name: "Main",
profileId: "p1",
});
await agent.createAgent(PROJECT_ID, { name: "QA", profileId: "p1" });
await agent.updateProjectOrchestrator(PROJECT_ID, current.id);
vi.spyOn(agent, "updateProjectOrchestrator").mockRejectedValueOnce({
code: "PERSISTENCE_ERROR",
message: "Impossible de sauvegarder le writer",
});
renderPanel(agent);
await screen.findByText("QA");
const rowButton = screen.getAllByRole("button").find(
(button) =>
button.hasAttribute("aria-pressed") && button.textContent?.includes("QA"),
)!;
fireEvent.click(rowButton);
fireEvent.click(
await screen.findByRole("radio", {
name: "Writer du contexte projet global pour QA",
}),
);
fireEvent.click(
screen.getByRole("button", {
name: "Confirmer le changement de writer contexte",
}),
);
expect((await screen.findByRole("alert")).textContent).toContain(
"Impossible de sauvegarder le writer",
);
});
it("the Create button is disabled when the name is empty", async () => { it("the Create button is disabled when the name is empty", async () => {
renderPanel(); renderPanel();
await waitForIdle(); await waitForIdle();

View File

@ -130,6 +130,8 @@ export interface AgentsViewModel {
agentId: string, agentId: string,
effort: EffortSelection | null, effort: EffortSelection | null,
) => Promise<void>; ) => Promise<void>;
/** Selects this agent as the single writer of the global project context. */
updateProjectOrchestrator: (agentId: string) => Promise<void>;
/** Deletes an agent; deselects if it was selected. */ /** Deletes an agent; deselects if it was selected. */
deleteAgent: (agentId: string) => Promise<void>; deleteAgent: (agentId: string) => Promise<void>;
/** /**
@ -453,6 +455,23 @@ export function useAgents(projectId: string): AgentsViewModel {
[agent, projectId], [agent, projectId],
); );
const updateProjectOrchestrator = useCallback(
async (agentId: string): Promise<void> => {
setBusy(true);
setError(null);
try {
await agent.updateProjectOrchestrator(projectId, agentId);
const refreshed = await agent.listAgents(projectId);
setAgents(refreshed);
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
},
[agent, projectId],
);
const deleteAgent = useCallback( const deleteAgent = useCallback(
async (agentId: string) => { async (agentId: string) => {
setBusy(true); setBusy(true);
@ -571,6 +590,7 @@ export function useAgents(projectId: string): AgentsViewModel {
saveContext, saveContext,
changeAgentProfile, changeAgentProfile,
updateAgentEffort, updateAgentEffort,
updateProjectOrchestrator,
deleteAgent, deleteAgent,
launchAgent, launchAgent,
stopAgent, stopAgent,

View File

@ -277,6 +277,11 @@ export interface AgentGateway {
agentId: string, agentId: string,
contactable: boolean, contactable: boolean,
): Promise<Agent>; ): Promise<Agent>;
/** Selects the project's single writer of the global shared context. */
updateProjectOrchestrator(
projectId: string,
agentId: string,
): Promise<{ orchestratorAgentId: string }>;
/** Reads an agent's `.md` context by agent id. */ /** Reads an agent's `.md` context by agent id. */
readContext(projectId: string, agentId: string): Promise<string>; readContext(projectId: string, agentId: string): Promise<string>;
/** Overwrites an agent's `.md` context. */ /** Overwrites an agent's `.md` context. */