feat(domain,app,infra): injection « État du projet » + outils MCP workstate (LS4)

Clôt le cold-start de coordination du programme live-state :
- domain : WorkStatus::parse/label, commands ReadWorkState/SetWorkState
  (request status/intent/progress/lastDelegation), erreur UnknownWorkStatus + validate.
- application : section bornée « # État du projet » injectée au lancement
  (port LiveStateLeanProvider, InjectedLiveRow, LIVE_STATE_INJECT_MAX, resolve_live_state) ;
  LiveStateReadProvider + read_workstate/set_workstate câblés au dispatch.
- infrastructure : 2 ToolDef idea_workstate_read / idea_workstate_set (mapping,
  tool_returns_reply), compteur d'outils MCP 12→14.
- app-tauri : providers câblés, garde du nombre d'outils 12→14.
- tests (QA, verts) : domain/orchestrator, application lifecycle + orchestrator_service,
  infrastructure mcp_server, app-tauri state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 08:09:57 +02:00
parent 9d46e6cd21
commit 533a9c57f3
12 changed files with 1503 additions and 68 deletions

View File

@ -30,10 +30,13 @@ use domain::{
SessionId, SessionKind, SessionStatus, Skill, TerminalSession,
};
use domain::live_state::WorkStatus;
use crate::error::AppError;
use crate::layout::{persist_doc, resolve_doc};
use crate::project::project_context_path;
use crate::terminal::{StructuredSessions, TerminalSessions};
use crate::workstate::GetLiveStateLean;
/// Directory (relative to `.ideai/`) under which agent contexts are written.
const AGENTS_SUBDIR: &str = "agents";
@ -85,6 +88,45 @@ pub trait ProviderSessionProvider: Send + Sync {
) -> Option<Arc<dyn ProviderSessionStore>>;
}
/// Cap on the number of live-state rows injected into the convention file at
/// activation (lot LS4). A project runs a handful of agents; `16` bounds the
/// injected section so a transient fan-out never bloats every agent's context.
pub const LIVE_STATE_INJECT_MAX: usize = 16;
/// One distilled live-state row ready to inject into the convention file's
/// `# État du projet` section (lot LS4).
///
/// Deliberately minimal: the **other** agent's display name, its coarse status and
/// a short intent — never the ticket/lastDelegation UUIDs, `progress`, or any
/// transcript. Built by [`LaunchAgent::resolve_live_state`] (which owns the manifest
/// join + ordering + self-exclusion) so [`compose_convention_file`] stays pure.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InjectedLiveRow {
/// The other agent's display name.
pub name: String,
/// Coarse status at a glance (rendered as its [`WorkStatus::label`]).
pub status: WorkStatus,
/// Short intent free-text; empty ⇒ the intent suffix is omitted.
pub intent: String,
}
/// Fournit le [`GetLiveStateLean`] **lié au project root** du lancement en cours
/// (lot LS4), pour injecter l'aperçu `# État du projet` dans le contexte composé.
///
/// Même tension/réponse que [`HandoffProvider`] : [`LaunchAgent`] est unique pour tous
/// les projets, alors que le live-state est **par project root**
/// (`<root>/.ideai/live-state.json`) et le port [`domain::ports::LiveStateStore`] fixe
/// sa racine à la construction. On matérialise donc un `GetLiveStateLean` ciblant le
/// **bon** dossier à chaque résolution (prune-on-read + snapshot lean).
///
/// `None` ⇒ aucune injection d'état (best-effort absente) : zéro régression pour les
/// call sites/tests qui ne le branchent pas. Implémenté dans `app-tauri`.
pub trait LiveStateLeanProvider: Send + Sync {
/// Construit le [`GetLiveStateLean`] dont le store cible `root`. Appelé une fois par
/// lancement best-effort ; `None` ⇒ on saute silencieusement l'injection.
fn live_state_lean_for(&self, root: &ProjectPath) -> Option<Arc<GetLiveStateLean>>;
}
// ---------------------------------------------------------------------------
// CreateAgentFromScratch
// ---------------------------------------------------------------------------
@ -1080,6 +1122,12 @@ pub struct LaunchAgent {
/// câblage via [`Self::with_permission_projectors`] ; `None` ⇒ aucune projection
/// (comportement historique préservé pour les call sites/tests legacy).
projectors: Option<Arc<PermissionProjectorRegistry>>,
/// Provider du [`GetLiveStateLean`] **par project root** (lot LS4), pour injecter
/// l'aperçu `# État du projet` (que font les autres agents) dans le convention file
/// au lancement. Injecté via [`Self::with_live_state_lean`] ; `None` ⇒ aucune
/// injection (zéro régression). Best-effort strict : provider absent / erreur /
/// parse ⇒ section omise, jamais d'échec de lancement.
live_state_lean: Option<Arc<dyn LiveStateLeanProvider>>,
}
impl LaunchAgent {
@ -1117,9 +1165,20 @@ impl LaunchAgent {
handoffs: None,
provider_sessions: None,
projectors: None,
live_state_lean: None,
}
}
/// Branche le provider de **live-state lean (lot LS4)** : au lancement, l'aperçu
/// `# État du projet` (status + intent des autres agents) est injecté dans le
/// convention file. Sans cet appel (cas legacy / tests existants), aucune section
/// n'est injectée — signature de [`Self::new`] **inchangée**. Builder additif.
#[must_use]
pub fn with_live_state_lean(mut self, provider: Arc<dyn LiveStateLeanProvider>) -> Self {
self.live_state_lean = Some(provider);
self
}
/// Injects the per-CLI permission **projector registry** (lot LP3-3) used to
/// translate the resolved [`EffectivePermissions`] into the launched CLI's
/// concrete permission config. Without this call (legacy call sites / tests),
@ -1262,6 +1321,58 @@ impl LaunchAgent {
store.load(conversation).await.ok().flatten()
}
/// Résout **best-effort** l'aperçu live-state des **autres** agents (lot LS4) à
/// injecter dans la section `# État du projet`, calqué sur [`Self::resolve_handoff`].
///
/// Ordre **du manifeste** (déterministe). L'agent lancé (`self_agent_id`) est
/// **exclu** (il ne se voit pas lui-même). Les lignes du live-state qui ne
/// correspondent à **aucun** agent du manifeste sont droppées (la frontière du
/// manifeste prime). Le résultat est borné à [`LIVE_STATE_INJECT_MAX`].
///
/// **Jamais bloquant** : provider absent, erreur du store, ou parse en échec
/// dégradent vers un `Vec` vide (section omise), exactement comme une mémoire/reprise
/// absente — un lancement n'est jamais cassé par cet aperçu.
async fn resolve_live_state(
&self,
root: &ProjectPath,
manifest: &AgentManifest,
self_agent_id: AgentId,
) -> Vec<InjectedLiveRow> {
let Some(provider) = self.live_state_lean.as_ref() else {
return Vec::new();
};
let Some(getter) = provider.live_state_lean_for(root) else {
return Vec::new();
};
// Toute erreur de lecture/prune ⇒ pas d'aperçu (best-effort).
let Ok(lean) = getter.execute().await else {
return Vec::new();
};
let by_agent: HashMap<AgentId, &crate::workstate::LeanLiveEntry> =
lean.entries.iter().map(|e| (e.agent_id, e)).collect();
let mut rows = Vec::new();
for entry in &manifest.entries {
if rows.len() >= LIVE_STATE_INJECT_MAX {
break;
}
// Exclure la ligne de l'agent lancé : il ne s'observe pas lui-même.
if entry.agent_id == self_agent_id {
continue;
}
// Droppe les lignes live-state hors manifeste (on n'itère que le manifeste).
let Some(live) = by_agent.get(&entry.agent_id) else {
continue;
};
rows.push(InjectedLiveRow {
name: entry.name.clone(),
status: live.status,
intent: live.intent.clone(),
});
}
rows
}
/// Reads the shared project context from `.ideai/CONTEXT.md`.
///
/// A missing file is normal for existing projects and simply omits the
@ -1472,6 +1583,12 @@ impl LaunchAgent {
let handoff = self
.resolve_handoff(&input.project.root, input.conversation_id.as_deref())
.await;
// Aperçu live-state des autres agents (lot LS4) : best-effort, additif. Injecté
// comme section `# État du projet`. Ordre manifeste, self exclu, borné, vide ⇒
// section omise. Indépendant du handoff/mémoire.
let live_rows = self
.resolve_live_state(&input.project.root, &manifest, agent.id)
.await;
// Best-effort contextual embedder suggestion (LOT C3, §14.5.5): the agent
// has just read the project memory, so this is the moment to check whether a
// semantic embedder would now help. Fully isolated from the launch outcome —
@ -1492,6 +1609,7 @@ impl LaunchAgent {
&skills,
&memory,
handoff.as_ref(),
&live_rows,
profile.mcp.is_some(),
&mut spec,
)
@ -1970,6 +2088,7 @@ impl LaunchAgent {
skills: &[Skill],
memory: &[MemoryIndexEntry],
handoff: Option<&Handoff>,
live_rows: &[InjectedLiveRow],
mcp_enabled: bool,
spec: &mut SpawnSpec,
) -> Result<(), AppError> {
@ -1989,6 +2108,7 @@ impl LaunchAgent {
skills,
memory,
handoff,
live_rows,
mcp_enabled,
);
let path = RemotePath::new(join(&spec.cwd, &target));
@ -2589,6 +2709,7 @@ pub(crate) fn compose_convention_file(
skills: &[Skill],
memory: &[MemoryIndexEntry],
handoff: Option<&Handoff>,
live_rows: &[InjectedLiveRow],
mcp_enabled: bool,
) -> String {
let mut out = String::new();
@ -2742,6 +2863,31 @@ pub(crate) fn compose_convention_file(
}
}
// État du projet (lot LS4) : aperçu lean de ce que font les autres agents en ce
// moment, placé APRÈS la mémoire projet et AVANT la reprise de conversation. Une
// ligne par agent (`- **Nom** — status · intent`), intent omis si vide. Jamais
// d'UUID (ticket/lastDelegation), de progress ni de transcript. `live_rows` vide ⇒
// section entièrement omise (document octet-identique à sans-section).
if !live_rows.is_empty() {
out.push_str("\n\n---\n\n# État du projet\n\n");
out.push_str(
"Aperçu de ce que font les autres agents en ce moment (last-writer-wins, \
non temps-réel). Pour le détail ou pour publier ton propre statut, utilise \
`idea_workstate_read` / `idea_workstate_set`.\n\n",
);
for row in live_rows {
out.push_str("- **");
out.push_str(&row.name);
out.push_str("** — ");
out.push_str(row.status.label());
if !row.intent.trim().is_empty() {
out.push_str(" · ");
out.push_str(row.intent.trim());
}
out.push('\n');
}
}
// Reprise conversationnelle (lot P7) : section finale, la plus situationnelle
// (« où on en était »), placée après la mémoire projet. Omise sans handoff.
if let Some(handoff) = handoff {
@ -2866,6 +3012,7 @@ mod tests {
&[],
&[],
None,
&[],
false,
);
@ -2896,6 +3043,7 @@ mod tests {
&[],
&[],
None,
&[],
false,
);
@ -2930,6 +3078,7 @@ mod tests {
],
&[],
None,
&[],
false,
);
@ -2966,6 +3115,7 @@ mod tests {
&[],
&[],
None,
&[],
false,
);
@ -2984,8 +3134,16 @@ mod tests {
// file-protocol modes, positioned right after the Skills awareness and
// before the orchestration block's closing separator / project context.
for mcp_enabled in [true, false] {
let doc =
compose_convention_file("/root", "ctx", "# Persona", &[], &[], None, mcp_enabled);
let doc = compose_convention_file(
"/root",
"ctx",
"# Persona",
&[],
&[],
None,
&[],
mcp_enabled,
);
assert!(
doc.contains("**Mémoire IdeA**"),
"memory harvest directive present (mcp_enabled={mcp_enabled})"
@ -3009,14 +3167,15 @@ mod tests {
#[test]
fn compose_convention_file_skill_awareness_uses_mcp_or_file_creation_path() {
let mcp_doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, true);
let mcp_doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true);
assert!(mcp_doc.contains("idea_create_skill"));
assert!(
!mcp_doc.contains("skill.create"),
"MCP awareness must point to the native IdeA tool"
);
let file_doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, false);
let file_doc =
compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false);
assert!(file_doc.contains("skill.create"));
assert!(
!file_doc.contains("idea_create_skill"),
@ -3051,6 +3210,7 @@ mod tests {
],
&[],
None,
&[],
true, // mcp_enabled
);
@ -3084,7 +3244,7 @@ mod tests {
// NB : l'outil `idea_skill_read` reste évoqué par le briefing « capacités IdeA »
// (toujours présent), donc on n'asserte plus son absence — seulement celle de la
// section d'affordances, qui, elle, dépend bien des skills assignés.
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, true);
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true);
assert!(!doc.contains("# Skills disponibles"));
}
@ -3102,8 +3262,16 @@ mod tests {
fn compose_convention_file_empty_memory_is_identical_to_no_memory() {
// An empty `memory` must yield exactly the previous document: no section,
// byte-for-byte identical to the no-skills/no-memory composition.
let with_empty =
compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[], None, false);
let with_empty = compose_convention_file(
"/root",
"",
"# Persona\n\nDo X.",
&[],
&[],
None,
&[],
false,
);
assert!(
!with_empty.contains("# Mémoire projet"),
"no memory ⇒ no memory section"
@ -3112,7 +3280,16 @@ mod tests {
// 4-arg call; this pins the omission contract explicitly).
assert_eq!(
with_empty,
compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[], None, false)
compose_convention_file(
"/root",
"",
"# Persona\n\nDo X.",
&[],
&[],
None,
&[],
false
)
);
}
@ -3133,6 +3310,7 @@ mod tests {
),
],
None,
&[],
false,
);
@ -3171,6 +3349,7 @@ mod tests {
std::slice::from_ref(&skill),
&[mem("note", "Note", "a hook", MemoryType::Project)],
None,
&[],
false,
);
@ -3190,7 +3369,7 @@ mod tests {
fn compose_convention_file_mcp_prose_points_to_idea_tools_and_keeps_subagent_ban() {
// mcp_enabled = true ⇒ prose exposes the native `idea_*` tools while keeping
// the ban on the provider's native subagents (cadrage v3, Décision 3).
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, true);
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true);
// Native IdeA orchestration tools surfaced.
assert!(
@ -3215,7 +3394,7 @@ mod tests {
fn compose_convention_file_mcp_prose_carries_the_idea_reply_delegation_protocol() {
// B-5 — the solicited-agent side of the protocol: a delegated task arrives as
// `[IdeA · tâche …]` and MUST be answered via `idea_reply`, never plain text.
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, true);
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true);
assert!(
doc.contains("idea_reply"),
"MCP prose must instruct answering via idea_reply"
@ -3226,7 +3405,8 @@ mod tests {
);
// Negative: the non-MCP (file-protocol) prose must NOT carry the idea_reply
// instruction (zero regression on the file path).
let file_doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, false);
let file_doc =
compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false);
assert!(!file_doc.contains("idea_reply"));
}
@ -3234,7 +3414,7 @@ mod tests {
fn compose_convention_file_non_mcp_prose_is_the_unchanged_file_protocol() {
// mcp_enabled = false ⇒ the current `.ideai/requests` wording, unchanged,
// and crucially WITHOUT the `idea_*` tools (zero regression).
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, false);
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false);
assert!(
doc.contains(".ideai/requests"),
@ -3257,7 +3437,7 @@ mod tests {
// est INCONDITIONNEL. Projet/agent neuf et vide (zéro skill, zéro mémoire,
// contexte vide) ⇒ les 3 capacités (contexte, mémoire, skills) DOIVENT quand
// même être décrites, avec les noms des outils MCP correspondants.
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, true);
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true);
// Capacité contexte projet.
assert!(
@ -3283,7 +3463,7 @@ mod tests {
// appliquée directement. On asserte sur la nuance réellement écrite par le dev :
// « single-writer » + le fait qu'une proposition sans `target` est enregistrée
// « pour validation » (et donc « n'est PAS appliquée directement »).
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, true);
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true);
assert!(
doc.contains("single-writer"),
@ -3303,7 +3483,7 @@ mod tests {
fn compose_convention_file_non_mcp_briefs_capabilities_via_ideai_files_no_tools() {
// Surface SANS MCP : mêmes 3 capacités décrites UNIQUEMENT via les fichiers
// `.ideai/` — et AUCUN nom d'outil `idea_*` (cloisonnement strict des surfaces).
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, false);
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false);
// Capacités décrites par leurs fichiers/dossiers.
assert!(
@ -3351,8 +3531,8 @@ mod tests {
fn compose_convention_file_surfaces_stay_cloistered_on_capabilities() {
// Cloisonnement : la prose MCP ne mentionne pas le protocole fichier
// `.ideai/requests`, et la prose fichier ne mentionne aucun nom d'outil.
let mcp = compose_convention_file("/root", "", "# Persona", &[], &[], None, true);
let file = compose_convention_file("/root", "", "# Persona", &[], &[], None, false);
let mcp = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true);
let file = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false);
assert!(
!mcp.contains(".ideai/requests"),
@ -3382,7 +3562,7 @@ mod tests {
fn compose_convention_file_capability_brief_precedes_the_persona_in_both_modes() {
// Ordre : le briefing capacités apparaît AVANT le persona dans les 2 modes.
// On ancre le brief sur un marqueur stable propre à chaque surface.
let mcp = compose_convention_file("/root", "", "# Persona", &[], &[], None, true);
let mcp = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], true);
let mcp_brief_at = mcp
.find("idea_context_read")
.expect("MCP capability brief present");
@ -3392,7 +3572,7 @@ mod tests {
"MCP capability brief must come before the persona"
);
let file = compose_convention_file("/root", "", "# Persona", &[], &[], None, false);
let file = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false);
let file_brief_at = file
.find(".ideai/CONTEXT.md")
.expect("file capability brief present");
@ -3478,7 +3658,8 @@ mod tests {
MemoryType::Project,
)];
let h = handoff("Résumé : on a fini l'étape 2.", Some("Livrer le lot P7"));
let doc = compose_convention_file("/root", "", "# Persona", &[], &memory, Some(&h), false);
let doc =
compose_convention_file("/root", "", "# Persona", &[], &memory, Some(&h), &[], false);
assert!(
doc.contains("# Reprise de la conversation"),
@ -3514,7 +3695,7 @@ mod tests {
// objective = None ⇒ the section and the summary are present, but NO
// `**Objectif :**` line.
let h = handoff("Juste le résumé.", None);
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], Some(&h), false);
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], Some(&h), &[], false);
assert!(
doc.contains("# Reprise de la conversation"),
@ -3532,7 +3713,7 @@ mod tests {
// objective = Some(" ") (whitespace only) ⇒ trimmed to empty ⇒ no objective
// line, but the section + summary still render.
let h = handoff("Le résumé.", Some(" "));
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], Some(&h), false);
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], Some(&h), &[], false);
assert!(doc.contains("# Reprise de la conversation"));
assert!(doc.contains("Le résumé."));
@ -3546,7 +3727,7 @@ mod tests {
fn compose_convention_file_without_handoff_omits_resume_section() {
// handoff = None ⇒ no `# Reprise de la conversation` section at all
// (zero regression for launches with no resume).
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, false);
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &[], false);
assert!(
!doc.contains("# Reprise de la conversation"),
"no handoff ⇒ no resume section: {doc}"
@ -3557,6 +3738,118 @@ mod tests {
);
}
// -----------------------------------------------------------------------
// État du projet (lot LS4) — live-state preview section in the composer.
// The composer is pure: it renders the already-distilled `InjectedLiveRow`s
// (self-exclusion/cap/ordering are `resolve_live_state`'s job, covered by the
// launch-level test). Here we pin the rendering contract.
// -----------------------------------------------------------------------
fn live_row(name: &str, status: WorkStatus, intent: &str) -> InjectedLiveRow {
InjectedLiveRow {
name: name.to_owned(),
status,
intent: intent.to_owned(),
}
}
#[test]
fn compose_convention_file_renders_live_state_rows_in_order_with_labels() {
let rows = [
live_row("Architect", WorkStatus::Working, "cadrage LS4"),
live_row("DevBackend", WorkStatus::Blocked, "attend la review"),
// Empty intent ⇒ the ` · intent` suffix is omitted (status only).
live_row("QA", WorkStatus::Idle, ""),
];
let doc = compose_convention_file("/root", "", "# Persona", &[], &[], None, &rows, true);
// Section present, after the persona.
assert!(doc.contains("# État du projet"), "section present: {doc}");
let persona_at = doc.find("# Persona").unwrap();
let section_at = doc.find("# État du projet").unwrap();
assert!(
persona_at < section_at,
"live-state comes after the persona"
);
// Exact line format `- **Name** — status[ · intent]`, status = WorkStatus::label.
assert!(
doc.contains("- **Architect** — working · cadrage LS4"),
"working row with intent: {doc}"
);
assert!(
doc.contains("- **DevBackend** — blocked · attend la review"),
"blocked row with intent: {doc}"
);
// QA has an empty intent ⇒ no ` · ` suffix, no trailing separator.
assert!(
doc.contains("- **QA** — idle\n"),
"idle row, intent omitted: {doc}"
);
assert!(
!doc.contains("**QA** — idle ·"),
"empty intent must not render a ` · ` suffix: {doc}"
);
// Manifest order is preserved: Architect before DevBackend before QA.
let a = doc.find("**Architect**").unwrap();
let b = doc.find("**DevBackend**").unwrap();
let c = doc.find("**QA**").unwrap();
assert!(a < b && b < c, "rows rendered in the given order: {doc}");
// No leakage: the section never carries UUIDs/progress/transcripts (none in the
// rows ⇒ none in the output).
assert!(!doc.contains("progress"), "progress never surfaced: {doc}");
}
#[test]
fn compose_convention_file_empty_live_state_is_identical_to_no_section() {
// No live rows ⇒ the `# État du projet` section is omitted entirely, and the
// document is byte-for-byte identical to a composition that never had one.
let baseline =
compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[], None, &[], true);
assert!(
!baseline.contains("# État du projet"),
"no live rows ⇒ no section"
);
// Strict byte equality: threading an empty slice changes nothing.
let empty =
compose_convention_file("/root", "", "# Persona\n\nDo X.", &[], &[], None, &[], true);
assert_eq!(baseline, empty, "empty live-state ⇒ identical document");
}
#[test]
fn compose_convention_file_live_state_placed_after_memory_before_resume() {
// Placement contract: État du projet sits AFTER `# Mémoire projet` and BEFORE
// `# Reprise de la conversation`.
let memory = [mem(
"git-optional",
"Git optionnel",
"git reste un simple tool",
MemoryType::Project,
)];
let h = handoff("Résumé : étape 2 finie.", Some("Livrer LS4"));
let rows = [live_row("Architect", WorkStatus::Waiting, "revue")];
let doc = compose_convention_file(
"/root",
"",
"# Persona",
&[],
&memory,
Some(&h),
&rows,
true,
);
let memory_at = doc.find("# Mémoire projet").unwrap();
let live_at = doc.find("# État du projet").unwrap();
let resume_at = doc.find("# Reprise de la conversation").unwrap();
assert!(
memory_at < live_at && live_at < resume_at,
"order must be Mémoire → État du projet → Reprise: {doc}"
);
}
// NB (lot LP3-3): the former `claude_settings_seed_*` unit tests were removed —
// that translation now lives in `infrastructure::permission::ClaudePermissionProjector`
// and is covered by its own tests (LP3-2). Likewise the Codex sandbox/approval

View File

@ -29,10 +29,11 @@ pub use inspect::{InspectConversation, InspectConversationInput, InspectConversa
pub use lifecycle::{
ChangeAgentProfile, ChangeAgentProfileInput, ChangeAgentProfileOutput, CreateAgentFromScratch,
CreateAgentInput, CreateAgentOutput, DeleteAgent, DeleteAgentInput, HandoffProvider,
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
ListAgentsOutput, McpRuntime, PermissionProjectorRegistry, ProviderSessionProvider,
ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput, StructuredSessionDescriptor,
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET,
InjectedLiveRow, LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
ListAgentsOutput, LiveStateLeanProvider, McpRuntime, PermissionProjectorRegistry,
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
StructuredSessionDescriptor, UpdateAgentContext, UpdateAgentContextInput,
AGENT_MEMORY_RECALL_BUDGET, LIVE_STATE_INJECT_MAX,
};
pub use resume::{
ListResumableAgents, ListResumableAgentsInput, ListResumableAgentsOutput, ResumableAgent,

View File

@ -37,15 +37,15 @@ pub use agent::{
ConfigureProfilesOutput, CreateAgentFromScratch, CreateAgentInput, CreateAgentOutput,
DeleteAgent, DeleteAgentInput, DeleteProfile, DeleteProfileInput, DetectProfiles,
DetectProfilesInput, DetectProfilesOutput, FirstRunState, FirstRunStateOutput, HandoffProvider,
InspectConversation, InspectConversationInput, InspectConversationOutput, LaunchAgent,
LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput, ListAgentsOutput,
ListProfiles, ListProfilesOutput, ListResumableAgents, ListResumableAgentsInput,
ListResumableAgentsOutput, McpRuntime, PermissionProjectorRegistry, ProfileAvailability,
ProviderSessionProvider, ReadAgentContext, ReadAgentContextInput, ReadAgentContextOutput,
ReferenceProfiles, ReferenceProfilesOutput, ResumableAgent, SaveProfile, SaveProfileInput,
SaveProfileOutput, SessionLimitService, StructuredSessionDescriptor, TurnOutcome,
UpdateAgentContext, UpdateAgentContextInput, AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS,
RESUME_PROMPT,
InjectedLiveRow, InspectConversation, InspectConversationInput, InspectConversationOutput,
LaunchAgent, LaunchAgentInput, LaunchAgentOutput, ListAgents, ListAgentsInput,
ListAgentsOutput, ListProfiles, ListProfilesOutput, ListResumableAgents,
ListResumableAgentsInput, ListResumableAgentsOutput, LiveStateLeanProvider, McpRuntime,
PermissionProjectorRegistry, ProfileAvailability, ProviderSessionProvider, ReadAgentContext,
ReadAgentContextInput, ReadAgentContextOutput, ReferenceProfiles, ReferenceProfilesOutput,
ResumableAgent, SaveProfile, SaveProfileInput, SaveProfileOutput, SessionLimitService,
StructuredSessionDescriptor, TurnOutcome, UpdateAgentContext, UpdateAgentContextInput,
AGENT_MEMORY_RECALL_BUDGET, CODEX_SUBMIT_DELAY_MS, LIVE_STATE_INJECT_MAX, RESUME_PROMPT,
};
pub use conversation::RecordTurn;
pub use embedder::{
@ -81,8 +81,9 @@ pub use memory::{
UpdateMemory, UpdateMemoryInput, UpdateMemoryOutput,
};
pub use orchestrator::{
ContextGuardUseCases, LiveStateProvider, McpRuntimeProvider, OrchestratorOutcome,
OrchestratorService, ProposeContext, ReadContext, ReadMemory, RecordTurnProvider, WriteMemory,
ContextGuardUseCases, LiveStateProvider, LiveStateReadProvider, McpRuntimeProvider,
OrchestratorOutcome, OrchestratorService, ProposeContext, ReadContext, ReadMemory,
RecordTurnProvider, WriteMemory,
};
pub use permission::{
GetProjectPermissions, GetProjectPermissionsInput, GetProjectPermissionsOutput,

View File

@ -12,6 +12,6 @@ pub use context_guard::{
ReadMemoryInput, WriteMemory, WriteMemoryInput,
};
pub use service::{
ContextGuardUseCases, LiveStateProvider, McpRuntimeProvider, OrchestratorOutcome,
OrchestratorService, RecordTurnProvider,
ContextGuardUseCases, LiveStateProvider, LiveStateReadProvider, McpRuntimeProvider,
OrchestratorOutcome, OrchestratorService, RecordTurnProvider,
};

View File

@ -45,7 +45,7 @@ use crate::orchestrator::{
};
use crate::skill::{CreateSkill, CreateSkillInput, ReadSkill, ReadSkillInput};
use crate::terminal::{CloseTerminal, CloseTerminalInput, StructuredSessions, TerminalSessions};
use crate::workstate::{UpdateLiveState, UpdateLiveStateInput};
use crate::workstate::{GetLiveStateLean, UpdateLiveState, UpdateLiveStateInput};
/// Default terminal geometry for an orchestrator-launched agent cell. The UI
/// resizes the PTY to the real cell size on attach; these are sane starting rows
@ -235,6 +235,23 @@ pub trait LiveStateProvider: Send + Sync {
fn live_state_for(&self, root: &ProjectPath) -> Option<Arc<UpdateLiveState>>;
}
/// Provider **par project root** d'un [`GetLiveStateLean`] (lot LS4), pour servir
/// l'outil de lecture `idea_workstate_read`.
///
/// Même tension/réponse que [`LiveStateProvider`] : l'[`OrchestratorService`] est
/// **unique** pour tous les projets, mais le live-state est **par project root** et le
/// port [`domain::ports::LiveStateStore`] fixe son root à la construction. `read_workstate`
/// connaît le `project.root` et demande au provider un `GetLiveStateLean` ciblant le
/// **bon** dossier (prune-on-read + snapshot lean).
///
/// `None` ⇒ l'outil renvoie « not configured » (call sites/tests legacy restent verts).
/// Implémenté dans app-tauri (seul détenteur des adapters `Fs*` et de l'horloge).
pub trait LiveStateReadProvider: Send + Sync {
/// Construit le [`GetLiveStateLean`] dont le store cible `root`. `None` ⇒ l'outil
/// `idea_workstate_read` n'est pas servi pour ce root.
fn live_state_lean_for(&self, root: &ProjectPath) -> Option<Arc<GetLiveStateLean>>;
}
/// Dispatches validated orchestrator commands to the agent/terminal use cases.
pub struct OrchestratorService {
create_agent: Arc<CreateAgentFromScratch>,
@ -337,6 +354,12 @@ pub struct OrchestratorService {
/// régression). **Best-effort strict** : un échec de persistance ne transforme
/// **jamais** un succès de délégation/reply en erreur.
live_state: Option<Arc<dyn LiveStateProvider>>,
/// Provider de lecture du **live-state** (lot LS4) pour l'outil
/// `idea_workstate_read`. Distinct de [`Self::live_state`] (écriture) : la lecture
/// applique le prune-on-read et renvoie le snapshot lean enrichi du nom d'agent.
/// Injecté via [`Self::with_live_state_read`] ; `None` ⇒ l'outil renvoie une erreur
/// typée « not configured » (call sites/tests legacy restent verts).
live_state_read: Option<Arc<dyn LiveStateReadProvider>>,
}
/// Bundle des quatre use cases C7 sous [`domain::fileguard::FileGuard`], injectés
@ -403,6 +426,7 @@ impl OrchestratorService {
memory_harvest: None,
read_skill: None,
live_state: None,
live_state_read: None,
}
}
@ -529,6 +553,16 @@ impl OrchestratorService {
self
}
/// Branche le provider de **lecture** du live-state (lot LS4) pour l'outil
/// `idea_workstate_read`. Builder additif (signature de [`Self::new`] inchangée) ;
/// `None` (défaut) ⇒ l'outil renvoie « not configured ». Provider **par root** car
/// le live-state est par project root et le port fixe son root à la construction.
#[must_use]
pub fn with_live_state_read(mut self, provider: Arc<dyn LiveStateReadProvider>) -> Self {
self.live_state_read = Some(provider);
self
}
/// Distille l'objectif d'un ticket en un `intent` court pour le live-state :
/// whitespace normalisé puis tronqué à [`domain::live_state::FIELD_PREVIEW_MAX_CHARS`]
/// (même mécanique que la preview du workstate). Borner **ici** garantit qu'on ne
@ -729,6 +763,28 @@ impl OrchestratorService {
content,
requester,
} => self.write_memory(project, slug, content, requester).await,
OrchestratorCommand::ReadWorkState { requester } => {
self.read_workstate(project, requester).await
}
OrchestratorCommand::SetWorkState {
agent,
status,
intent,
progress,
ticket,
last_delegation,
} => {
self.set_workstate(
project,
agent,
status,
intent,
progress,
ticket,
last_delegation,
)
.await
}
}
}
@ -843,6 +899,105 @@ impl OrchestratorService {
})
}
/// `workstate.read` / `idea_workstate_read` → the project's agent **live-state**
/// (lot LS4): the lean snapshot (prune-on-read) enriched with each agent's display
/// name, returned inline as a JSON array. Entries whose agent is no longer in the
/// manifest are dropped; `progress` is **never** exposed (the lean DTO has none).
///
/// Shape: `[{"agent","status","intent","ticket?","lastDelegation?"}]`.
async fn read_workstate(
&self,
project: &Project,
_requester: ConversationParty,
) -> Result<OrchestratorOutcome, AppError> {
let provider = self
.live_state_read
.as_deref()
.ok_or_else(|| AppError::Invalid("idea_workstate_read not configured".to_owned()))?;
let getter = provider
.live_state_lean_for(&project.root)
.ok_or_else(|| AppError::Invalid("idea_workstate_read not configured".to_owned()))?;
let lean = getter.execute().await?;
// Cross with the manifest to enrich each row with the agent's display name and
// to drop rows whose agent left the manifest (boundary preserved).
let listed = self
.list_agents
.execute(ListAgentsInput {
project: project.clone(),
})
.await?;
let names: HashMap<AgentId, String> =
listed.agents.into_iter().map(|a| (a.id, a.name)).collect();
let rows: Vec<serde_json::Value> = lean
.entries
.into_iter()
.filter_map(|entry| {
let name = names.get(&entry.agent_id)?;
let mut row = serde_json::json!({
"agent": name,
"status": entry.status,
"intent": entry.intent,
});
if let Some(ticket) = entry.ticket {
row["ticket"] = serde_json::json!(ticket);
}
if let Some(last) = entry.last_delegation {
row["lastDelegation"] = serde_json::json!(last);
}
Some(row)
})
.collect();
let reply = serde_json::to_string(&rows)
.map_err(|e| AppError::Invalid(format!("failed to serialise work-state: {e}")))?;
Ok(OrchestratorOutcome {
detail: format!("read work-state ({} agent rows)", rows.len()),
reply: Some(reply),
})
}
/// `workstate.set` / `idea_workstate_set` → publishes (insert/replace) the **current
/// agent's** live-state row (lot LS4). The key is the handshake identity (`agent`),
/// so an agent only ever writes its own row. Reuses the LS3 write provider
/// ([`Self::live_state`]); `updated_at_ms` is stamped by the use case's `Clock`, and
/// `intent`/`progress` are domain-bounded ([`UpdateLiveState`] via `LiveEntry::new`).
/// **ACK only** (no inline reply).
#[allow(clippy::too_many_arguments)]
async fn set_workstate(
&self,
project: &Project,
agent: AgentId,
status: domain::live_state::WorkStatus,
intent: Option<String>,
progress: Option<String>,
ticket: Option<TicketId>,
last_delegation: Option<TicketId>,
) -> Result<OrchestratorOutcome, AppError> {
let provider = self
.live_state
.as_deref()
.ok_or_else(|| AppError::Invalid("idea_workstate_set not configured".to_owned()))?;
let update = provider
.live_state_for(&project.root)
.ok_or_else(|| AppError::Invalid("idea_workstate_set not configured".to_owned()))?;
update
.execute(UpdateLiveStateInput {
agent_id: agent,
ticket,
intent: intent.unwrap_or_default(),
status,
progress,
last_delegation,
})
.await?;
Ok(OrchestratorOutcome {
detail: format!("set work-state for agent {agent}"),
reply: None,
})
}
/// `memory.write` → writes a note under an exclusive write-lease.
async fn write_memory(
&self,