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:
@ -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(¤t_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(),
|
||||
|
||||
Reference in New Issue
Block a user