Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions crates/mcp-server/src/hook_handlers/post_tool_use.rs
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,22 @@ fn completes_session_init(normalized_tool_name: &str, tool_response: &Value) ->
matches!(normalized_tool_name, "init" | "context") && tool_call_succeeded(tool_response)
}

/// Whether the Git checkout at `cwd` lacks a verified link to the project the
/// server resolved for it. Folders outside Git and agents' task worktrees are
/// never linked automatically, so they need no audit.
fn needs_link_audit(cwd: &str, workspace_id: Uuid, project_id: Option<Uuid>) -> bool {
let Some(layout) = mcp_session::checkout_repository_layout(cwd) else {
return false;
};
let root = layout.repository_root.to_string_lossy().to_string();
if crate::link::is_agent_worktree(&root) {
return false;
}
!project_id.is_some_and(|project_id| {
mcp_session::auto_init::checkout_binding_matches(&root, Some(workspace_id), project_id)
})
}

fn contextstream_post_tool_nudge(
normalized_tool_name: &str,
action: &str,
Expand Down Expand Up @@ -892,6 +908,12 @@ pub async fn handle() -> Result<()> {
project_id,
api_session_id.as_deref(),
);
// The bridge syncs only checkouts linked on this machine.
// Recorded above, this one is now in its next audit; ask for
// that audit now rather than in up to six hours.
if needs_link_audit(&cwd, workspace_id, project_id) {
let _ = crate::watch::request_link_audit();
}
// Docs written before this init/context were promised a mirror.
if hook_feature_enabled("CONTEXTSTREAM_DOC_MIRROR_ENABLED") {
let _ = super::durable_capture::mirror_pending_docs(&host_session).await;
Expand Down Expand Up @@ -1880,6 +1902,49 @@ mod tests {
);
assert!(msg.is_none());
}

#[test]
fn a_git_checkout_without_a_verified_link_asks_the_bridge_for_an_audit() {
let directory = tempdir().unwrap();
let linked = directory.path().join("linked");
let project_id = Uuid::new_v4();
std::fs::create_dir_all(&linked).unwrap();
let workspace_id = write_repo_config(&linked, project_id);
let linked = linked.to_string_lossy();
assert!(!needs_link_audit(&linked, workspace_id, Some(project_id)));
// The server resolved another project, or none.
assert!(needs_link_audit(
&linked,
workspace_id,
Some(Uuid::new_v4())
));
assert!(needs_link_audit(&linked, workspace_id, None));

let unlinked = directory.path().join("unlinked");
std::fs::create_dir_all(unlinked.join(".git")).unwrap();
assert!(needs_link_audit(
&unlinked.to_string_lossy(),
workspace_id,
Some(project_id)
));

// Plain folders and agents' task worktrees are never linked
// automatically.
let plain = directory.path().join("plain");
std::fs::create_dir_all(&plain).unwrap();
assert!(!needs_link_audit(
&plain.to_string_lossy(),
workspace_id,
Some(project_id)
));
let task = directory.path().join("app/.claude/worktrees/agent-a1");
std::fs::create_dir_all(task.join(".git")).unwrap();
assert!(!needs_link_audit(
&task.to_string_lossy(),
workspace_id,
Some(project_id)
));
}
}

#[cfg(test)]
Expand Down
86 changes: 85 additions & 1 deletion crates/mcp-server/src/hook_handlers/session_start.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,14 @@ pub async fn handle() -> Result<()> {
let config = load_config(&cwd);

// Check index status for the current project
let index_info = check_index_status(&cwd);
let index_info = match check_index_status(&cwd) {
IndexInfo::NotIndexed => checkout_sync_status(&cwd).unwrap_or(IndexInfo::NotIndexed),
known => known,
};
if matches!(index_info, IndexInfo::NotLinked) {
// A folder an earlier session recorded is in the bridge's next audit.
let _ = crate::watch::request_link_audit();
}

// Try to fetch compact restore state and startup context from API in
// parallel so SessionStart can stay under the hook timeout budget.
Expand Down Expand Up @@ -113,6 +120,18 @@ pub async fn handle() -> Result<()> {
output_text.push_str("\n\u{2139} Project index not found yet \u{2014} use ContextStream search first (not Explore/Grep) to find code as it builds; fall back to local tools only if search itself returns nothing.\n");
output_text.push_str("Keep hosted MCP configured, re-establish the intended checkout with `mcp__contextstream__init(folder_path=\"<folder>\")`, then run `mcp__contextstream__project(action=\"index\")`; the exact-checkout sync bridge supplies local bytes. Verify with `mcp__contextstream__project(action=\"index_status\")`.\n");
}
IndexInfo::Synced => {
output_text.push_str(
"\n\u{2713} This checkout is linked and the sync bridge uploads its changes as you edit, so search reflects it.\n",
);
}
IndexInfo::NotLinked => {
output_text.push_str("\n\u{26a0} This checkout is not linked to ContextStream on this machine, so the sync bridge uploads nothing from it. Search shows what other machines uploaded, which can be another branch or days old, and can miss code that exists only here.\n");
output_text.push_str("The bridge links it by itself when its Git repository names a project. To link it now, run `contextstream-mcp link --fix` in this folder (`--project-id <id>` if no project matches), then `mcp__contextstream__project(action=\"index\")`.\n");
}
IndexInfo::BridgeStopped => {
output_text.push_str("\n\u{26a0} This checkout is linked, but the ContextStream sync bridge is not running, so its changes are not uploaded and search can be out of date. Run `contextstream-mcp doctor --only-configured --repair --scope=all` to start it.\n");
}
}

if let Some(restored) = restored_context {
Expand Down Expand Up @@ -282,6 +301,53 @@ enum IndexInfo {
Stale,
Indexing,
NotIndexed,
/// The checkout is linked here and a running sync bridge uploads it.
Synced,
/// A Git checkout with no verified link on this machine: the bridge
/// uploads nothing from it.
NotLinked,
/// Linked, but no sync bridge is running to upload it.
BridgeStopped,
}

/// What the sync bridge does for the checkout at `folder_path`. Machines on
/// hosted MCP never write `indexed-projects.json`, so this is how they know
/// whether search reflects this checkout. `None` outside Git and in agents'
/// task worktrees, which are never synced.
fn checkout_sync_status(folder_path: &str) -> Option<IndexInfo> {
let layout = mcp_session::checkout_repository_layout(folder_path)?;
let root = layout.repository_root.to_string_lossy().to_string();
if crate::link::is_agent_worktree(&root) {
return None;
}
if !checkout_linked(&root) {
return Some(IndexInfo::NotLinked);
}
let health = crate::watch::sync_bridge_health();
let running = matches!(health.state, crate::watch::SyncBridgeHealthState::Running)
&& health.target_count > 0;
Some(if running {
IndexInfo::Synced
} else {
IndexInfo::BridgeStopped
})
}

/// Whether the checkout-local config links `root` to its project with a
/// verified repository fingerprint (what the bridge requires to sync it).
fn checkout_linked(root: &str) -> bool {
std::fs::read_to_string(Path::new(root).join(".contextstream/config.json"))
.ok()
.and_then(|raw| serde_json::from_str::<Value>(&raw).ok())
.and_then(|config| {
config
.get("project_id")
.and_then(Value::as_str)
.and_then(|id| uuid::Uuid::parse_str(id.trim()).ok())
})
.is_some_and(|project_id| {
mcp_session::auto_init::checkout_binding_workspace(root, project_id).is_some()
})
}

fn check_index_status(folder_path: &str) -> IndexInfo {
Expand Down Expand Up @@ -607,6 +673,24 @@ mod tests {
);
}

#[test]
fn an_unlinked_git_checkout_is_reported_as_not_synced() {
let directory = tempfile::tempdir().unwrap();
let checkout = directory.path().join("app");
std::fs::create_dir_all(checkout.join(".git")).unwrap();
assert!(matches!(
checkout_sync_status(&checkout.to_string_lossy()),
Some(IndexInfo::NotLinked)
));
// Plain folders and agents' task worktrees keep the generic message.
let plain = directory.path().join("notes");
std::fs::create_dir_all(&plain).unwrap();
assert!(checkout_sync_status(&plain.to_string_lossy()).is_none());
let task = checkout.join(".claude/worktrees/agent-a1");
std::fs::create_dir_all(task.join(".git")).unwrap();
assert!(checkout_sync_status(&task.to_string_lossy()).is_none());
}

#[test]
fn synthetic_test_lesson_phrase_is_noise() {
let lesson = json!({ "title": "Test lesson from audit", "severity": "low" });
Expand Down
117 changes: 106 additions & 11 deletions crates/mcp-server/src/link.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,10 @@ async fn relink(root: &Path, target: &ScoredProject) -> Result<()> {
)
.await
.map_err(|error| anyhow::anyhow!("could not link {}: {error}", root.display()))?;
// The bridge uploads a watched checkout's edits as they happen. A folder
// linked just now may hold a branch or edits the server has never seen,
// so its first sync covers the whole checkout.
let _ = crate::hook_handlers::dirty_drain::mark_watch_force_full(&root.to_string_lossy());
Ok(())
}

Expand Down Expand Up @@ -248,14 +252,9 @@ pub async fn audit_folders(
outcomes
}

/// Folders this machine has linked or indexed: every existing directory in
/// `mappings.json` and `indexed-projects.json`, reduced to checkout roots.
pub fn known_folders() -> Vec<PathBuf> {
let Some(dir) = contextstream_dir() else {
return Vec::new();
};
/// Folders listed in `mappings.json` and `indexed-projects.json`.
fn registry_folders(mappings: &Value, indexed: &Value) -> Vec<String> {
let mut paths: Vec<String> = Vec::new();
let mappings = read_json(&dir.join("mappings.json"));
if let Some(entries) = mappings
.get("mappings")
.and_then(Value::as_array)
Expand All @@ -268,10 +267,51 @@ pub fn known_folders() -> Vec<PathBuf> {
.map(str::to_string),
);
}
let indexed = read_json(&dir.join("indexed-projects.json"));
if let Some(projects) = indexed.get("projects").and_then(Value::as_object) {
paths.extend(projects.keys().cloned());
}
paths
}

/// Folders an agent session worked in on this machine, from
/// `session-scopes.json` (the hooks keep a day of sessions). A machine set up
/// for hosted MCP has no other record of a checkout it never linked, so
/// without these the bridge would never sync it.
fn session_folders(session_scopes: &Value) -> Vec<String> {
session_scopes
.get("sessions")
.and_then(Value::as_object)
.map(|sessions| {
sessions
.values()
.filter_map(|scope| scope.get("cwd").and_then(Value::as_str))
.filter(|cwd| !cwd.trim().is_empty())
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}

/// Worktrees a coding agent makes for one task and removes afterwards.
pub(crate) fn is_agent_worktree(path: &str) -> bool {
path.replace('\\', "/").contains("/.claude/worktrees/")
}

fn auditable_root(root: &Path) -> bool {
root.parent().is_some() && Some(root) != dirs::home_dir().as_deref()
}

/// Folders this machine has linked or indexed (every existing directory in
/// `mappings.json` and `indexed-projects.json`) and the Git checkouts agent
/// sessions worked in, reduced to checkout roots.
pub fn known_folders() -> Vec<PathBuf> {
let Some(dir) = contextstream_dir() else {
return Vec::new();
};
let paths = registry_folders(
&read_json(&dir.join("mappings.json")),
&read_json(&dir.join("indexed-projects.json")),
);
let mut roots: Vec<PathBuf> = paths
.into_iter()
// Throwaway clones and worktrees under temporary directories are never
Expand All @@ -284,10 +324,23 @@ pub fn known_folders() -> Vec<PathBuf> {
.map(|layout| layout.repository_root)
.or_else(|| std::fs::canonicalize(&path).ok())
})
.filter(|root| {
root.parent().is_some() && Some(root.as_path()) != dirs::home_dir().as_deref()
})
.filter(|root| auditable_root(root))
.collect();
// Only Git checkouts: the automatic repair links a folder only when its
// repository names the project, which a plain folder never does.
roots.extend(
session_folders(&read_json(&dir.join("session-scopes.json")))
.into_iter()
.filter(|path| {
Path::new(path).is_dir()
&& !mcp_session::project_link::is_generated_path(path)
&& !is_agent_worktree(path)
})
.filter_map(|path| {
mcp_session::checkout_repository_layout(&path).map(|layout| layout.repository_root)
})
.filter(|root| auditable_root(root) && !is_agent_worktree(&root.to_string_lossy())),
);
roots.sort();
roots.dedup();
roots
Expand Down Expand Up @@ -472,3 +525,45 @@ pub async fn run(command: LinkCommand) -> Result<bool> {
outcome.relinked_to.is_some() || matches!(outcome.assessment.status, LinkStatus::Correct)
}))
}

#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;

#[test]
fn registry_folders_come_from_mappings_and_the_local_index() {
let mappings = json!({"mappings": [{"path": "/work/a"}, {"project_id": "x"}]});
let indexed = json!({"projects": {"/work/b": {"project_id": "y"}}});
assert_eq!(
registry_folders(&mappings, &indexed),
vec!["/work/a".to_string(), "/work/b".to_string()]
);
// The older mappings layout is a bare array.
assert_eq!(
registry_folders(&json!([{"path": "/work/c"}]), &Value::Null),
vec!["/work/c".to_string()]
);
}

#[test]
fn session_folders_are_where_agent_sessions_worked() {
let scopes = json!({"sessions": {
"a": {"cwd": "/work/app", "workspace_id": "w", "project_id": null},
"b": {"cwd": " ", "workspace_id": "w"},
"c": {"workspace_id": "w"}
}});
assert_eq!(session_folders(&scopes), vec!["/work/app".to_string()]);
assert!(session_folders(&Value::Null).is_empty());
}

#[test]
fn agent_task_worktrees_are_recognised() {
assert!(is_agent_worktree("/work/app/.claude/worktrees/agent-a1b2"));
assert!(is_agent_worktree(
"C:\\work\\app\\.claude\\worktrees\\agent-a1"
));
assert!(!is_agent_worktree("/work/app-wt/feature"));
assert!(!is_agent_worktree("/work/app/.claude"));
}
}
Loading
Loading