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