fix(context): route idea_context_read vers .ideai/CONTEXT.md au lieu de CLAUDE.md

La lecture du contexte projet global via MCP (idea_context_read sans target)
lisait <root>/CLAUDE.md au lieu de la source de vérité canonique
<root>/.ideai/CONTEXT.md, désynchronisant la lecture MCP de
crates/application/src/project/context.rs. ReadContext, UpdateProjectContext
et ProposeContext partagent désormais project_context_path/project_context_dir,
avec création du dossier .ideai/ à l'écriture et lecture tolérante à l'absence
du fichier (FsError::NotFound -> contenu vide).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-03 14:58:22 +02:00
parent 3fa9691cd6
commit 70712a35f8

View File

@ -29,14 +29,16 @@ use domain::conversation::ConversationParty;
use domain::fileguard::{may_write_directly, FileGuard, GuardError, GuardedResource};
use domain::markdown::MarkdownDoc;
use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType};
use domain::ports::{AgentContextStore, Clock, EventBus, FileSystem, MemoryStore, RemotePath};
use domain::ports::{
AgentContextStore, Clock, EventBus, FileSystem, FsError, MemoryStore, RemotePath,
};
use domain::{AgentId, DomainEvent, Project};
use sha2::{Digest, Sha256};
use crate::error::AppError;
use crate::project::meta::IDEAI_DIR;
use crate::project::project_context_path;
/// Convention filename of the project's global context at the project root.
const PROJECT_CONTEXT_FILE: &str = "CLAUDE.md";
/// `.ideai/` subdirectory where a rejected global-context change is materialised.
const PROPOSALS_DIR: &str = ".ideai/proposals";
@ -46,6 +48,21 @@ fn join_root(project: &Project, rel: &str) -> RemotePath {
RemotePath::new(format!("{base}/{rel}"))
}
fn project_context_dir(project: &Project) -> RemotePath {
join_root(project, IDEAI_DIR)
}
async fn read_project_context_bytes(
fs: &Arc<dyn FileSystem>,
project: &Project,
) -> Result<Vec<u8>, AppError> {
match fs.read(&project_context_path(project)).await {
Ok(bytes) => Ok(bytes),
Err(FsError::NotFound(_)) => Ok(Vec::new()),
Err(err) => Err(AppError::FileSystem(err.to_string())),
}
}
pub(crate) fn hex_sha256(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
@ -126,14 +143,13 @@ impl ReadContext {
} = input;
match target {
None => {
// Global project context: shared read-lease, then read the root file.
// Global project context: shared read-lease, then read `.ideai/CONTEXT.md`.
let _lease = self
.guard
.acquire_read(requester, GuardedResource::ProjectContext)
.await
.map_err(map_guard_err)?;
let path = join_root(&project, PROJECT_CONTEXT_FILE);
let bytes = self.fs.read(&path).await?;
let bytes = read_project_context_bytes(&self.fs, &project).await?;
let version = hex_sha256(&bytes);
let text =
String::from_utf8(bytes).map_err(|e| AppError::Invalid(e.to_string()))?;
@ -236,8 +252,8 @@ impl UpdateProjectContext {
.await
.map_err(map_guard_err)?;
let path = join_root(&project, PROJECT_CONTEXT_FILE);
let current_bytes = self.fs.read(&path).await?;
let path = project_context_path(&project);
let current_bytes = read_project_context_bytes(&self.fs, &project).await?;
let current_version = hex_sha256(&current_bytes);
if let Some(expected) = if_match {
if expected != current_version {
@ -245,6 +261,9 @@ impl UpdateProjectContext {
}
}
self.fs
.create_dir_all(&project_context_dir(&project))
.await?;
self.fs.write(&path, content.as_bytes()).await?;
let new_version = hex_sha256(content.as_bytes());
self.events.publish(DomainEvent::ProjectContextUpdated {
@ -354,7 +373,10 @@ impl ProposeContext {
.acquire_write(requester, resource)
.await
.map_err(map_guard_err)?;
let path = join_root(&project, PROJECT_CONTEXT_FILE);
let path = project_context_path(&project);
self.fs
.create_dir_all(&project_context_dir(&project))
.await?;
self.fs.write(&path, content.as_bytes()).await?;
Ok(ProposeOutcome::Written)
}
@ -580,6 +602,10 @@ mod tests {
ConversationParty::agent(AgentId::from_uuid(uuid::Uuid::from_u128(n)))
}
fn project_context_file() -> &'static str {
"/tmp/demo/.ideai/CONTEXT.md"
}
// ---- Fakes -----------------------------------------------------------
#[derive(Default)]
@ -793,16 +819,16 @@ mod tests {
}
#[tokio::test]
async fn read_global_context_reads_root_file() {
async fn read_global_context_reads_canonical_context_file_without_root_claude() {
let fs = Arc::new(FakeFs::default());
fs.files
.lock()
.unwrap()
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# project".to_vec());
{
let mut files = fs.files.lock().unwrap();
files.insert(project_context_file().to_owned(), b"# project".to_vec());
}
let uc = ReadContext::new(
guard(),
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
fs,
Arc::clone(&fs) as Arc<dyn FileSystem>,
);
let out = uc
.execute(ReadContextInput {
@ -814,6 +840,7 @@ mod tests {
.unwrap();
assert_eq!(out.content.as_str(), "# project");
assert_eq!(out.version, Some(hex_sha256(b"# project")));
assert!(!fs.files.lock().unwrap().contains_key("/tmp/demo/CLAUDE.md"));
}
#[tokio::test]
@ -841,7 +868,7 @@ mod tests {
fs.files
.lock()
.unwrap()
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# original".to_vec());
.insert(project_context_file().to_owned(), b"# original".to_vec());
let uc = ProposeContext::new(
guard(),
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
@ -860,7 +887,11 @@ mod tests {
// It is a *proposal*, not a write: the live context is untouched.
assert!(matches!(outcome, ProposeOutcome::Proposed { .. }));
assert_eq!(
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
fs.files
.lock()
.unwrap()
.get(project_context_file())
.unwrap(),
b"# original",
"the live global context must NOT be overwritten by a proposal"
);
@ -891,9 +922,14 @@ mod tests {
.unwrap();
assert_eq!(outcome, ProposeOutcome::Written);
assert_eq!(
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
fs.files
.lock()
.unwrap()
.get(project_context_file())
.unwrap(),
b"# new"
);
assert!(!fs.files.lock().unwrap().contains_key("/tmp/demo/CLAUDE.md"));
}
#[tokio::test]
@ -903,7 +939,7 @@ mod tests {
fs.files
.lock()
.unwrap()
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# old".to_vec());
.insert(project_context_file().to_owned(), b"# old".to_vec());
let bus = Arc::new(SpyBus::default());
let uc = UpdateProjectContext::new(
guard(),
@ -925,9 +961,14 @@ mod tests {
assert_eq!(out.new_version, hex_sha256(b"# new"));
assert_eq!(
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
fs.files
.lock()
.unwrap()
.get(project_context_file())
.unwrap(),
b"# new"
);
assert!(!fs.files.lock().unwrap().contains_key("/tmp/demo/CLAUDE.md"));
}
#[tokio::test]
@ -936,7 +977,7 @@ mod tests {
fs.files
.lock()
.unwrap()
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# old".to_vec());
.insert(project_context_file().to_owned(), b"# old".to_vec());
let uc = UpdateProjectContext::new(
guard(),
contexts_with("Dev", AgentId::from_uuid(uuid::Uuid::from_u128(7)), "x"),
@ -957,7 +998,7 @@ mod tests {
assert_eq!(err.code(), "INVALID");
let files = fs.files.lock().unwrap();
assert_eq!(files.get("/tmp/demo/CLAUDE.md").unwrap(), b"# old");
assert_eq!(files.get(project_context_file()).unwrap(), b"# old");
assert!(
!files.keys().any(|path| path.contains("/.ideai/proposals/")),
"strict update must fail loud, not file a proposal"
@ -971,7 +1012,7 @@ mod tests {
fs.files
.lock()
.unwrap()
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# current".to_vec());
.insert(project_context_file().to_owned(), b"# current".to_vec());
let uc = UpdateProjectContext::new(
guard(),
contexts_with("Dev", agent, "x"),
@ -992,7 +1033,11 @@ mod tests {
assert_eq!(err, AppError::Conflict(hex_sha256(b"# current")));
assert_eq!(
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
fs.files
.lock()
.unwrap()
.get(project_context_file())
.unwrap(),
b"# current"
);
}
@ -1004,7 +1049,7 @@ mod tests {
fs.files
.lock()
.unwrap()
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# old".to_vec());
.insert(project_context_file().to_owned(), b"# old".to_vec());
let uc = UpdateProjectContext::new(
guard(),
contexts_with("Dev", agent, "x"),
@ -1025,7 +1070,11 @@ mod tests {
assert_eq!(out.new_version, hex_sha256(b"# new"));
assert_eq!(
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
fs.files
.lock()
.unwrap()
.get(project_context_file())
.unwrap(),
b"# new"
);
}
@ -1037,7 +1086,7 @@ mod tests {
fs.files
.lock()
.unwrap()
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# previous".to_vec());
.insert(project_context_file().to_owned(), b"# previous".to_vec());
let uc = UpdateProjectContext::new(
guard(),
contexts_with("Dev", agent, "x"),
@ -1056,7 +1105,11 @@ mod tests {
.unwrap();
assert_eq!(
fs.files.lock().unwrap().get("/tmp/demo/CLAUDE.md").unwrap(),
fs.files
.lock()
.unwrap()
.get(project_context_file())
.unwrap(),
b"# latest"
);
}
@ -1068,7 +1121,7 @@ mod tests {
fs.files
.lock()
.unwrap()
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# old".to_vec());
.insert(project_context_file().to_owned(), b"# old".to_vec());
let bus = Arc::new(SpyBus::default());
let uc = UpdateProjectContext::new(
guard(),
@ -1104,7 +1157,7 @@ mod tests {
fs.files
.lock()
.unwrap()
.insert("/tmp/demo/CLAUDE.md".to_owned(), b"# first".to_vec());
.insert(project_context_file().to_owned(), b"# first".to_vec());
let contexts = contexts_with("Dev", agent, "agent body");
let reader = ReadContext::new(
guard(),