diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 4feafca..820be65 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -98,16 +98,16 @@ To set it up: Useful requests include “Build Dev Brain for these projects,” “Refresh Dev Brain after my recent changes,” “What does Dev Brain know about the cache -invariants?”, and “Check the wiki, then verify this against the source.” Search -and reads use a disposable local SQLite full-text index. The index checks vault -files and cited project evidence before use, so no separate indexing service or -manual refresh button is needed. +invariants?”, and “Check the wiki, then verify this against the source.” The +agent gets the real vault folder from Dev Brain, then uses its normal list, +read, search, write, and edit tools on the Markdown. A dedicated validation tool +checks the result and refreshes the disposable local SQLite search index. The vault remains ordinary Obsidian Markdown: - **purpose.md** records its scope, priorities, and recurring questions. - **schema.md** is the detailed contract the agent follows when maintaining it. -- **index.md** catalogs every managed topic page. +- **index.md** is a generated catalog of every managed topic page. - **log.md** is an append-only history of material updates. - Topic pages live under **projects/**, **subsystems/**, **concepts/**, **decisions/**, **invariants/**, and **workflows/** as needed. @@ -115,17 +115,20 @@ The vault remains ordinary Obsidian Markdown: Every topic cites exact project files and a Git revision or content hash. **Verified** pages may support answers. **Stale** pages are navigation leads whose evidence changed, and **needs-review** pages contain visible uncertainty; -the agent must recheck project sources before relying on either. Wiki updates -are validated as a complete batch before publication, so a broken link, -out-of-bounds path, invalid citation, or stale verified claim leaves the last -valid wiki untouched. +the agent must recheck project sources before relying on either. After editing, +the agent runs validation. Unsafe paths and invalid page structure or provenance +are errors to repair in place. Broken links are reported as warnings: the valid +page stays on disk, so the agent can make a small edit or create the missing +page without regenerating its work. Validation rebuilds **index.md** +deterministically instead of asking the model to keep it synchronized. DS4Server may modify only the four contract files and topic pages explicitly -marked as Dev Brain-managed. It does not modify Obsidian settings, attachments, -trash, hidden files, or unrelated notes. You can inspect, edit, link, and -version the managed Markdown normally; invalid manual edits are reported rather -than silently trusted. Disabling Dev Brain removes its tools and instructions -from the built-in coding agent without deleting the vault. +marked as Dev Brain-managed. Standard agent file tools can access the vault but +cannot modify Obsidian settings, hidden files, or unrelated notes. You can +inspect, edit, link, and version the managed Markdown normally; invalid manual +edits are reported rather than silently trusted. Disabling Dev Brain removes its +folder, search, and validation guidance from the built-in coding agent without +deleting the vault. ## Views and sidebar diff --git a/src/agent.rs b/src/agent.rs index 8741b53..127d9c0 100644 --- a/src/agent.rs +++ b/src/agent.rs @@ -190,7 +190,7 @@ const TOOL_SCHEMAS: &str = r#"{"type":"function","function":{"name":"google_sear {"type":"function","function":{"name":"more","description":"Continue the previous read-like output.","parameters":{"type":"object","properties":{"count":{"type":"number"}}}}} {"type":"function","function":{"name":"write","description":"Create or overwrite a text file.","parameters":{"type":"object","properties":{"path":{"type":"string"},"content":{"type":"string"}},"required":["path","content"]}}} {"type":"function","function":{"name":"edit","description":"Replace exactly one old text match; old may contain [upto] between unique head and tail anchors.","parameters":{"type":"object","properties":{"path":{"type":"string"},"old":{"type":"string"},"new":{"type":"string"}},"required":["path","old","new"]}}} -{"type":"function","function":{"name":"search","description":"Search files and return compact edit-friendly matches.","parameters":{"type":"object","properties":{"query":{"type":"string"},"path":{"type":"string"},"mode":{"type":"string"},"glob":{"type":"string"},"context":{"type":"number"},"max_results":{"type":"number"},"case_sensitive":{"type":"boolean"}},"required":["query"]}}} +{"type":"function","function":{"name":"search","description":"Search files and return compact edit-friendly matches. Search is literal by default; set mode to regex for patterns such as foo|bar.","parameters":{"type":"object","properties":{"query":{"type":"string"},"path":{"type":"string"},"mode":{"type":"string","enum":["literal","regex"]},"glob":{"type":"string"},"context":{"type":"number"},"max_results":{"type":"number"},"case_sensitive":{"type":"boolean"}},"required":["query"]}}} {"type":"function","function":{"name":"list","description":"List one directory compactly.","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}}}"#; #[derive(Clone, Debug, PartialEq)] @@ -343,9 +343,9 @@ impl Tools { "bash_stop" => self.bash_observe(call, true, cancel), "google_search" => self.google_search(call, cancel), "visit_page" => self.visit_page(call, cancel), + "dev_brain_info" => self.dev_brain_info(), "dev_brain_search" => self.dev_brain_search(call), - "dev_brain_read" => self.dev_brain_read(call), - "dev_brain_publish" => self.dev_brain_publish(call), + "dev_brain_validate" => self.dev_brain_validate(), name => Err(format!("unknown tool: {name}")), }; match result { @@ -373,42 +373,19 @@ impl Tools { .search(query, limit, authoritative) } - fn dev_brain_read(&mut self, call: &ToolCall) -> Result { - let path = required_string(call, "path")?; - let authoritative = boolean(call, "authoritative", false); - self.dev_brain - .as_mut() + fn dev_brain_info(&self) -> Result { + Ok(self + .dev_brain + .as_ref() .ok_or_else(|| "Dev Brain is disabled for this session.".to_owned())? - .read(path, authoritative) + .info()) } - fn dev_brain_publish(&mut self, call: &ToolCall) -> Result { - let files = call - .arguments - .get("files") - .and_then(Value::as_object) - .ok_or_else(|| "dev_brain_publish requires a files object.".to_owned())?; - let remove = call - .arguments - .get("remove") - .map(|value| { - value - .as_array() - .ok_or_else(|| "remove must be an array of paths.".to_owned())? - .iter() - .map(|path| { - path.as_str() - .map(str::to_owned) - .ok_or_else(|| "remove paths must be strings.".to_owned()) - }) - .collect::, String>>() - }) - .transpose()? - .unwrap_or_default(); + fn dev_brain_validate(&mut self) -> Result { self.dev_brain .as_mut() .ok_or_else(|| "Dev Brain is disabled for this session.".to_owned())? - .publish(files, &remove) + .validate() } fn result_limit(&self) -> usize { @@ -456,7 +433,7 @@ impl Tools { let path = path .canonicalize() .map_err(|error| format!("open {value}: {error}"))?; - self.inside_project(path, value) + self.inside_readable_root(path, value) } fn writable_path(&self, value: &str) -> Result { @@ -472,7 +449,8 @@ impl Tools { self.root.join(value) }; if fs::symlink_metadata(&path).is_ok() { - return self.existing_path(value); + let path = self.existing_path(value)?; + return self.inside_writable_root(path, value); } let mut ancestor = path.as_path(); let mut suffix = Vec::new(); @@ -488,21 +466,54 @@ impl Tools { let mut resolved = ancestor .canonicalize() .map_err(|error| format!("open ancestor of {value}: {error}"))?; - self.inside_project(resolved.clone(), value)?; + self.inside_readable_root(resolved.clone(), value)?; for name in suffix.into_iter().rev() { resolved.push(name); } - Ok(resolved) + self.inside_writable_root(resolved, value) } - fn inside_project(&self, path: PathBuf, original: &str) -> Result { + fn inside_readable_root(&self, path: PathBuf, original: &str) -> Result { if path.starts_with(&self.root) { + return Ok(path); + } + if let Some(brain) = &self.dev_brain + && let Ok(relative) = path.strip_prefix(brain.folder()) + && !relative + .components() + .any(|component| component.as_os_str().to_string_lossy().starts_with('.')) + { + return Ok(path); + } + Err(format!( + "path is outside the project and managed Dev Brain folder: {original}" + )) + } + + fn inside_writable_root(&self, path: PathBuf, original: &str) -> Result { + if path.starts_with(&self.root) + || self + .dev_brain + .as_ref() + .is_some_and(|brain| brain.allows_tool_write(&path)) + { Ok(path) } else { - Err(format!("path is outside the project: {original}")) + Err(format!( + "path is not a managed project or Dev Brain file: {original}" + )) } } + fn validate_dev_brain_content(&self, path: &Path, content: &str) -> Result<(), String> { + if let Some(brain) = &self.dev_brain + && path.starts_with(brain.folder()) + { + brain.validate_tool_content(path, content)?; + } + Ok(()) + } + fn default_lines(&self) -> usize { match self.context_tokens { ..=8192 => 120, @@ -628,6 +639,16 @@ impl Tools { return Err(format!("content exceeds {MAX_FILE_BYTES} bytes")); } let path = self.writable_path(display)?; + self.validate_dev_brain_content(&path, content)?; + if self + .dev_brain + .as_ref() + .is_some_and(|brain| path.starts_with(brain.folder())) + && let Some(parent) = path.parent() + { + fs::create_dir_all(parent) + .map_err(|error| format!("create parent for {display}: {error}"))?; + } fs::write(&path, content).map_err(|error| format!("write {display}: {error}"))?; Ok(format!("Wrote {} bytes to {display}\n", content.len())) } @@ -640,6 +661,7 @@ impl Tools { return Err("edit requires non-empty old text".into()); } let path = self.existing_path(display)?; + self.inside_writable_root(path.clone(), display)?; if path.metadata().map_err(|error| error.to_string())?.len() > MAX_FILE_BYTES { return Err(format!( "file too large: {display} exceeds {MAX_FILE_BYTES} bytes" @@ -654,6 +676,7 @@ impl Tools { output.push_str(&data[..start]); output.push_str(new); output.push_str(&data[end..]); + self.validate_dev_brain_content(&path, &output)?; fs::write(&path, output).map_err(|error| format!("write {display}: {error}"))?; Ok(format!( "Edited {display} using {} replacement\n", @@ -676,8 +699,15 @@ impl Tools { .filter_map(Result::ok) .collect::>(); entries.sort_by_key(|entry| entry.file_name()); + let dev_brain_path = self + .dev_brain + .as_ref() + .is_some_and(|brain| path.starts_with(brain.folder())); let mut output = format!("{display}:\n"); for entry in entries.iter().take(300) { + if dev_brain_path && entry.file_name().to_string_lossy().starts_with('.') { + continue; + } let metadata = fs::symlink_metadata(entry.path()).map_err(|error| error.to_string())?; let kind = if metadata.file_type().is_symlink() { 'l' @@ -713,7 +743,12 @@ impl Tools { context, limit, }; - search_path(&self.root, &path, &options) + let root = self + .dev_brain + .as_ref() + .filter(|brain| path.starts_with(brain.folder())) + .map_or(self.root.as_path(), |brain| brain.folder()); + search_path(root, &path, &options, root != self.root.as_path()) } fn bash(&mut self, call: &ToolCall, cancel: &AtomicBool) -> Result { @@ -948,9 +983,14 @@ struct SearchOptions<'a> { limit: usize, } -fn search_path(root: &Path, path: &Path, options: &SearchOptions<'_>) -> Result { +fn search_path( + root: &Path, + path: &Path, + options: &SearchOptions<'_>, + skip_hidden: bool, +) -> Result { let mut files = Vec::new(); - collect_search_files(path, 0, &mut files)?; + collect_search_files(path, 0, skip_hidden, &mut files)?; let mut matches = 0; let mut body = String::new(); for file in files { @@ -991,6 +1031,7 @@ fn search_path(root: &Path, path: &Path, options: &SearchOptions<'_>) -> Result< fn collect_search_files( path: &Path, depth: usize, + skip_hidden: bool, output: &mut Vec, ) -> Result<(), String> { if depth > 24 { @@ -1015,10 +1056,12 @@ fn collect_search_files( .collect::>(); entries.sort_by_key(|entry| entry.file_name()); for entry in entries { - if entry.file_name() == ".git" { + if entry.file_name() == ".git" + || skip_hidden && entry.file_name().to_string_lossy().starts_with('.') + { continue; } - collect_search_files(&entry.path(), depth + 1, output)?; + collect_search_files(&entry.path(), depth + 1, skip_hidden, output)?; } Ok(()) } @@ -1748,7 +1791,11 @@ mod tests { .contains("[System prompt reminder follows.]") ); assert!(!prompt.contains("dev_brain_search")); - assert!(system_prompt(ModelChoice::DeepSeekV4Flash, "", true).contains("dev_brain_search")); + let dev_brain_prompt = system_prompt(ModelChoice::DeepSeekV4Flash, "", true); + for name in ["dev_brain_info", "dev_brain_search", "dev_brain_validate"] { + assert!(dev_brain_prompt.contains(name)); + } + assert!(!dev_brain_prompt.contains("dev_brain_publish")); assert!(datetime_context().starts_with("Current local date and time at session start:")); assert!(!prompt_reminder_due(49_999, 0)); assert!(prompt_reminder_due(50_000, 0)); @@ -1809,6 +1856,77 @@ mod tests { fs::remove_dir_all(outside).unwrap(); } + #[test] + fn standard_file_tools_can_maintain_only_managed_dev_brain_pages() { + let directory = std::env::temp_dir().join(format!( + "ds4-agent-brain-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let project = directory.join("project"); + let vault = directory.join("vault"); + fs::create_dir_all(&project).unwrap(); + fs::create_dir_all(vault.join(".obsidian")).unwrap(); + fs::write(project.join("source.rs"), "source\n").unwrap(); + let mut tools = Tools::new(&project, 4096).unwrap(); + tools + .enable_dev_brain( + &crate::config::DevBrainConfig { + enabled: true, + vault_path: Some(vault.to_string_lossy().into_owned()), + }, + &[crate::database::Project { + id: 1, + name: "Fixture".into(), + path: project.to_string_lossy().into_owned(), + collapsed: false, + }], + ) + .unwrap(); + + assert!( + tools + .existing_path(vault.join("schema.md").to_str().unwrap()) + .is_ok() + ); + assert!( + tools + .writable_path(vault.join("concepts/new.md").to_str().unwrap()) + .is_ok() + ); + let nested = vault.join("subsystems/inference/modes.md"); + let mut arguments = Map::new(); + arguments.insert( + "path".into(), + Value::String(nested.to_string_lossy().into_owned()), + ); + arguments.insert( + "content".into(), + Value::String("---\ndev_brain: true\n---\n# Modes\n".into()), + ); + tools + .write(&ToolCall { + name: "write".into(), + arguments, + }) + .unwrap(); + assert!(nested.is_file()); + assert!( + tools + .existing_path(vault.join(".obsidian").to_str().unwrap()) + .is_err() + ); + fs::write(vault.join("concepts/private.md"), "# Private\n").unwrap(); + assert!( + tools + .writable_path(vault.join("concepts/private.md").to_str().unwrap()) + .is_err() + ); + fs::remove_dir_all(directory).unwrap(); + } + #[test] fn risky_shell_commands_require_one_time_approval() { let root = Path::new("/tmp/project"); diff --git a/src/app/generation.rs b/src/app/generation.rs index 61cc910..1cc39c5 100644 --- a/src/app/generation.rs +++ b/src/app/generation.rs @@ -380,6 +380,19 @@ fn title_context(messages: impl IntoIterator) -> Vec .collect() } +#[cfg(any(target_os = "macos", test))] +fn agents_prompt_for_turn( + opening_turn: bool, + opening_prompt: Option, + stored_prompt: Option<&str>, +) -> Option { + if opening_turn { + opening_prompt + } else { + stored_prompt.map(str::to_owned) + } +} + impl App { fn chat_system_prompt(&self, model: ModelChoice, prompt: &str, agents: Option<&str>) -> String { let mut prompt = crate::agent::system_prompt(model, prompt, self.config.dev_brain.enabled); @@ -445,7 +458,7 @@ impl App { #[cfg(target_os = "macos")] let opening_turn = self.selected_session.is_none(); #[cfg(target_os = "macos")] - let agents = if opening_turn { + let opening_agents = if opening_turn { let Some(project) = self .projects .iter() @@ -464,6 +477,9 @@ impl App { } else { None }; + #[cfg(target_os = "macos")] + let agents = + agents_prompt_for_turn(opening_turn, opening_agents, self.session_agents_prompt()); self.a2ui_auto_switch_pending = true; self.context_notice = None; #[cfg(target_os = "macos")] @@ -1860,10 +1876,10 @@ pub(super) fn session_title(reply: &str) -> Option { #[cfg(test)] mod tests { use super::{ - ChatMessage, TOOL_PROTOCOL_CORRECTION, TurnSummary, chat_turn, compacted_context_start, - correction_already_sent, has_chat_after_last_compaction, has_misplaced_tool_call, - is_empty_response, project_agents, promote_legacy_turn_summaries, queued_prompt, - sync_a2ui_message, title_context, + ChatMessage, TOOL_PROTOCOL_CORRECTION, TurnSummary, agents_prompt_for_turn, chat_turn, + compacted_context_start, correction_already_sent, has_chat_after_last_compaction, + has_misplaced_tool_call, is_empty_response, project_agents, promote_legacy_turn_summaries, + queued_prompt, sync_a2ui_message, title_context, }; use crate::engine::ChatTurn; use crate::model::ModelChoice; @@ -1919,6 +1935,19 @@ mod tests { std::fs::remove_dir_all(directory).unwrap(); } + #[test] + fn user_continuations_reuse_the_stored_agents_prompt() { + let stored = "Project AGENTS.md instructions:\nkeep this exact"; + assert_eq!( + agents_prompt_for_turn(false, None, Some(stored)).as_deref(), + Some(stored) + ); + assert_eq!( + agents_prompt_for_turn(true, Some("opening".into()), Some(stored)).as_deref(), + Some("opening") + ); + } + #[test] fn turn_summary_accumulates_model_continuations() { let mut summary = TurnSummary::new(); diff --git a/src/app/view/preferences.rs b/src/app/view/preferences.rs index c0fa7c7..be9ad69 100644 --- a/src/app/view/preferences.rs +++ b/src/app/view/preferences.rs @@ -164,7 +164,7 @@ impl App { checkbox(self.preference_draft.dev_brain_enabled) .label("Enable project-backed LLM wiki") .on_toggle(Message::PreferenceDevBrainEnabledChanged), - "Adds validated search, read, and batch-publication tools for a dedicated Obsidian vault. Disabled means no Dev Brain tools or prompt instructions.", + "Lets the agent use its normal file tools on managed pages in a dedicated Obsidian vault, with indexed search and validation. Disabled means no Dev Brain access or prompt instructions.", ), row![ text_input( diff --git a/src/dev_brain.rs b/src/dev_brain.rs index e3710e8..a6f42a8 100644 --- a/src/dev_brain.rs +++ b/src/dev_brain.rs @@ -2,7 +2,7 @@ use diesel::connection::SimpleConnection; use diesel::prelude::*; use diesel::sql_types::{BigInt, Double, Text}; use serde::Deserialize; -use serde_json::{Map, Value}; +use serde_json::Value; use sha2::{Digest, Sha256}; use std::collections::{BTreeSet, HashMap, HashSet}; use std::fs; @@ -30,11 +30,12 @@ const TOPIC_DIRS: [&str; 6] = [ // readers; use per-vault locks only if concurrent vault throughput matters. static VAULT_LOCK: RwLock<()> = RwLock::new(()); -pub(crate) const PROMPT: &str = "Dev Brain is enabled for this session. It is a project-backed Obsidian wiki, not generic memory. Read dev_brain_read(path=\"schema.md\") before maintaining it. Search verified knowledge with dev_brain_search before broad source exploration; stale and needs-review pages are leads only and must be checked against project sources. Publish wiki changes only through dev_brain_publish so the complete candidate is validated and applied as one batch."; +pub(crate) const PROMPT: &str = r#"# Dev Brain +Dev Brain is a managed Obsidian wiki, not a project directory or generic memory. Call dev_brain_info to get its real folder, then use the ordinary list, read, search, write, and edit tools on those Markdown files. Do not invent a .brain path or use bash for wiki maintenance. Read schema.md before maintaining pages and append material changes to log.md. Call dev_brain_validate after edits; it rebuilds index.md and reports broken links as repairable warnings without discarding content. Fix warnings with ordinary edits or by creating the missing page."#; -pub(crate) const TOOL_SCHEMAS: &str = r#"{"type":"function","function":{"name":"dev_brain_search","description":"Search the validated Dev Brain wiki and return ranked pages, graph context, and project evidence. Authoritative search excludes stale and needs-review pages.","parameters":{"type":"object","properties":{"query":{"type":"string"},"limit":{"type":"number"},"authoritative":{"type":"boolean"}},"required":["query"]}}} -{"type":"function","function":{"name":"dev_brain_read","description":"Read one indexed Dev Brain page with freshness status and project evidence.","parameters":{"type":"object","properties":{"path":{"type":"string"},"authoritative":{"type":"boolean"}},"required":["path"]}}} -{"type":"function","function":{"name":"dev_brain_publish","description":"Validate and publish one candidate batch of managed wiki changes. Files overlays the current managed snapshot; remove retires managed topic pages. The whole resulting wiki is validated before any live file changes.","parameters":{"type":"object","properties":{"files":{"type":"object","additionalProperties":{"type":"string"}},"remove":{"type":"array","items":{"type":"string"}}},"required":["files"]}}}"#; +pub(crate) const TOOL_SCHEMAS: &str = r#"{"type":"function","function":{"name":"dev_brain_info","description":"Return the Dev Brain folder and its managed layout. Use ordinary file tools on the returned paths.","parameters":{"type":"object","properties":{}}}} +{"type":"function","function":{"name":"dev_brain_search","description":"Search the validated Dev Brain index with freshness and project evidence. Use ordinary search for literal or regex file search.","parameters":{"type":"object","properties":{"query":{"type":"string"},"limit":{"type":"number"},"authoritative":{"type":"boolean"}},"required":["query"]}}} +{"type":"function","function":{"name":"dev_brain_validate","description":"Validate managed pages after ordinary file edits, deterministically rebuild index.md, refresh search, and report repairable link warnings.","parameters":{"type":"object","properties":{}}}}"#; const DEFAULT_PURPOSE: &str = "# Dev Brain purpose\n\n\ Dev Brain compiles durable, source-backed knowledge from the registered projects.\n\n\ @@ -92,7 +93,7 @@ When evidence changes, re-read the affected code, documentation, and tests. Upda ## Publication -Use `dev_brain_publish` for every managed change. It builds a candidate snapshot, checks structure, paths, graph resolution, unique identities, index coverage, provenance, and freshness, then publishes the batch. `log.md` is append-only. +Call `dev_brain_info`, then maintain these pages with the ordinary file tools. Append material updates to `log.md`; `index.md` is generated and should not be hand-maintained. Finish with `dev_brain_validate`. Structural or provenance errors must be repaired, while broken links are warnings so valid work remains editable instead of being discarded. "#; #[derive(Clone)] @@ -219,6 +220,7 @@ pub(crate) struct DevBrain { projects: Vec, connection: SqliteConnection, fingerprint: Vec, + index_error: Option, } impl DevBrain { @@ -259,11 +261,59 @@ impl DevBrain { projects, connection, fingerprint: Vec::new(), + index_error: None, }; - brain.rebuild()?; + brain.index_error = brain.rebuild().err(); Ok(brain) } + pub(crate) fn folder(&self) -> &Path { + &self.vault + } + + pub(crate) fn info(&self) -> String { + let mut info = format!( + "Dev Brain folder: {}\nManaged roots: purpose.md, schema.md, index.md, log.md, {}/\nUse ordinary file tools with these absolute paths. Topic pages need dev_brain: true frontmatter. Run dev_brain_validate after changes; index.md is generated.\n", + self.vault.display(), + TOPIC_DIRS.join("/, ") + ); + if let Some(error) = &self.index_error { + info.push_str(&format!( + "The current wiki needs repair before indexed search: {error}\n" + )); + } + info + } + + pub(crate) fn allows_tool_write(&self, path: &Path) -> bool { + let Ok(relative) = path.strip_prefix(&self.vault) else { + return false; + }; + let display = path_to_string(relative); + if ROOT_PAGES.contains(&display.as_str()) { + return true; + } + if normalize_managed_path(&display).is_err() { + return false; + } + !path.exists() + || fs::read_to_string(path) + .is_ok_and(|content| is_managed_topic(&content) || content.contains("dev_brain:")) + } + + pub(crate) fn validate_tool_content(&self, path: &Path, content: &str) -> Result<(), String> { + let relative = path + .strip_prefix(&self.vault) + .map_err(|_| "Path is outside the Dev Brain folder.".to_owned())?; + if is_topic_path(relative) && !is_managed_topic(content) { + return Err(format!( + "Dev Brain topic {} needs valid YAML frontmatter with dev_brain: true.", + relative.display() + )); + } + Ok(()) + } + pub(crate) fn search( &mut self, query: &str, @@ -273,6 +323,11 @@ impl DevBrain { let _guard = VAULT_LOCK .read() .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(error) = &self.index_error { + return Err(format!( + "Dev Brain needs repair before indexed search: {error}. Use ordinary file tools, then dev_brain_validate." + )); + } self.refresh()?; let query = fts_query(query); if query.is_empty() { @@ -320,14 +375,6 @@ impl DevBrain { Ok(output) } - pub(crate) fn read(&mut self, value: &str, authoritative: bool) -> Result { - let _guard = VAULT_LOCK - .read() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - self.refresh()?; - self.read_indexed(value, authoritative) - } - fn read_indexed(&mut self, value: &str, authoritative: bool) -> Result { let normalized = normalize_managed_path(value) .map(|path| path_to_string(&path)) @@ -367,86 +414,40 @@ impl DevBrain { Ok(output) } - pub(crate) fn publish( - &mut self, - files: &Map, - remove: &[String], - ) -> Result { + pub(crate) fn validate(&mut self) -> Result { let _guard = VAULT_LOCK .write() .unwrap_or_else(|poisoned| poisoned.into_inner()); - if files.is_empty() && remove.is_empty() { - return Err("Dev Brain publication batch is empty.".into()); + let pages = load_and_validate_pages(&self.vault, &self.vault, &self.projects, true)?; + let index = render_index(&pages); + let index_path = self.vault.join("index.md"); + let index_changed = + fs::read_to_string(&index_path).map_or(true, |current| current != index); + if index_changed { + fs::write(&index_path, index) + .map_err(|error| format!("Could not update Dev Brain index: {error}"))?; } - let current = managed_files(&self.vault)?; - let old_log = fs::read_to_string(self.vault.join("log.md")).unwrap_or_default(); - let staging = temporary_sibling(&self.vault, "candidate")?; - fs::create_dir(&staging).map_err(|error| error.to_string())?; - let result = (|| { - for path in ¤t { - let target = staging.join(path); - if let Some(parent) = target.parent() { - fs::create_dir_all(parent).map_err(|error| error.to_string())?; - } - fs::copy(self.vault.join(path), target).map_err(|error| error.to_string())?; - } - let mut changed = BTreeSet::new(); - for (raw_path, value) in files { - let path = normalize_managed_path(raw_path)?; - let content = value.as_str().ok_or_else(|| { - format!("Dev Brain file content must be a string: {raw_path}") - })?; - if content.len() > 16 * 1024 * 1024 { - return Err(format!("Dev Brain file is too large: {raw_path}")); - } - if is_topic_path(&path) && !is_managed_topic(content) { - return Err(format!( - "Managed topic {raw_path} must have YAML frontmatter with dev_brain: true." - )); - } - let target = staging.join(&path); - if let Some(parent) = target.parent() { - fs::create_dir_all(parent).map_err(|error| error.to_string())?; - } - fs::write(target, content).map_err(|error| error.to_string())?; - changed.insert(path); - } - for raw_path in remove { - let path = normalize_managed_path(raw_path)?; - if ROOT_PAGES.contains(&path_to_string(&path).as_str()) { - return Err(format!( - "Required Dev Brain page cannot be removed: {raw_path}" - )); - } - if !current.contains(&path) { - return Err(format!("Refusing to remove an unmanaged note: {raw_path}")); - } - match fs::remove_file(staging.join(&path)) { - Ok(()) => {} - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(error.to_string()), - } - changed.insert(path); - } - let new_log = fs::read_to_string(staging.join("log.md")) - .map_err(|error| format!("Could not read candidate log.md: {error}"))?; - if !new_log.starts_with(&old_log) { - return Err( - "log.md is append-only; the candidate removed or changed history.".into(), - ); - } - let pages = load_and_validate_pages(&staging, &self.vault, &self.projects, true)?; - validate_graph(&pages, &staging, &self.vault)?; - commit_batch(&self.vault, &staging, &changed)?; - Ok(changed.len()) - })(); - let _ = fs::remove_dir_all(&staging); - let count = result?; + let pages = load_and_validate_pages(&self.vault, &self.vault, &self.projects, true)?; + let (_, warnings) = validate_graph(&pages, &self.vault, &self.vault); self.rebuild()?; - Ok(format!( - "Published {count} managed Dev Brain file change{} after full candidate validation.\n", - if count == 1 { "" } else { "s" } - )) + self.index_error = None; + let mut output = format!( + "Dev Brain is structurally valid ({} managed pages); index.md {}.\n", + pages.len(), + if index_changed { + "updated" + } else { + "unchanged" + } + ); + if warnings.is_empty() { + output.push_str("No link warnings.\n"); + } else { + output.push_str("Repairable warnings (content was not discarded):\n- "); + output.push_str(&warnings.join("\n- ")); + output.push('\n'); + } + Ok(output) } fn refresh(&mut self) -> Result<(), String> { @@ -458,7 +459,7 @@ impl DevBrain { fn rebuild(&mut self) -> Result<(), String> { let pages = load_and_validate_pages(&self.vault, &self.vault, &self.projects, false)?; - let resolved = validate_graph(&pages, &self.vault, &self.vault)?; + let (resolved, _) = validate_graph(&pages, &self.vault, &self.vault); self.connection .batch_execute( "DROP TABLE IF EXISTS page_fts; @@ -675,34 +676,37 @@ fn ensure_contract(vault: &Path) -> Result<(), String> { .iter() .filter(|path| vault.join(path).is_file()) .count(); - if present == ROOT_PAGES.len() { - return Ok(()); - } - if present != 0 { + if present != 0 && present != ROOT_PAGES.len() { return Err( "The selected vault has a partial Dev Brain contract. Provide all of purpose.md, schema.md, index.md, and log.md, or remove the conflicting files." .into(), ); } - let staging = temporary_sibling(vault, "contract")?; - fs::create_dir(&staging).map_err(|error| error.to_string())?; - let result = (|| { - for (path, content) in [ - ("purpose.md", DEFAULT_PURPOSE), - ("schema.md", DEFAULT_SCHEMA), - ("index.md", DEFAULT_INDEX), - ("log.md", DEFAULT_LOG), - ] { - fs::write(staging.join(path), content).map_err(|error| error.to_string())?; - } - let changed = ROOT_PAGES - .iter() - .map(PathBuf::from) - .collect::>(); - commit_batch(vault, &staging, &changed) - })(); - let _ = fs::remove_dir_all(staging); - result + if present == 0 { + let staging = temporary_sibling(vault, "contract")?; + fs::create_dir(&staging).map_err(|error| error.to_string())?; + let result = (|| { + for (path, content) in [ + ("purpose.md", DEFAULT_PURPOSE), + ("schema.md", DEFAULT_SCHEMA), + ("index.md", DEFAULT_INDEX), + ("log.md", DEFAULT_LOG), + ] { + fs::write(staging.join(path), content).map_err(|error| error.to_string())?; + } + let changed = ROOT_PAGES + .iter() + .map(PathBuf::from) + .collect::>(); + commit_batch(vault, &staging, &changed) + })(); + let _ = fs::remove_dir_all(staging); + result?; + } + for directory in TOPIC_DIRS { + fs::create_dir_all(vault.join(directory)).map_err(|error| error.to_string())?; + } + Ok(()) } pub(crate) fn validate_vault(path: &Path) -> Result { @@ -889,7 +893,7 @@ fn validate_graph( pages: &[Page], content_root: &Path, vault_root: &Path, -) -> Result, String> { +) -> (Vec<(String, String, String)>, Vec) { let by_path = pages .iter() .map(|page| (page.path.clone(), page)) @@ -908,10 +912,12 @@ fn validate_graph( } } let mut edges = Vec::new(); + let mut errors = Vec::new(); for page in pages { for link in &page.links { - match resolve_link(page, link, &by_path, &identities, content_root, vault_root)? { - Some(target) => edges.push(( + match resolve_link(page, link, &by_path, &identities, content_root, vault_root) { + Err(error) => errors.push(error), + Ok(Some(target)) => edges.push(( page.path.clone(), target, if link.kind == LinkType::Embed { @@ -920,36 +926,56 @@ fn validate_graph( "wikilink".into() }, )), - None if link.kind == LinkType::Anchor => {} - None => { - return Err(format!( - "Broken link in {} at line {}: [[{}]]", - page.path, link.line, link.target - )); - } + Ok(None) => errors.push(format!( + "Broken link in {} at line {}: [[{}]]", + page.path, link.line, link.target + )), } } } - let indexed = edges - .iter() - .filter(|(source, target, _)| source == "index.md" && is_topic_path(Path::new(target))) - .map(|(_, target, _)| target.clone()) - .collect::>(); + (edges, errors) +} + +fn render_index(pages: &[Page]) -> String { let topics = pages .iter() .filter(|page| is_topic_path(Path::new(&page.path))) - .map(|page| page.path.clone()) - .collect::>(); - if indexed != topics { - let missing = topics.difference(&indexed).cloned().collect::>(); - let extra = indexed.difference(&topics).cloned().collect::>(); - return Err(format!( - "index.md does not match managed topic pages. Missing: [{}]. Extra: [{}].", - missing.join(", "), - extra.join(", ") - )); + .collect::>(); + if topics.is_empty() { + return DEFAULT_INDEX.to_owned(); } - Ok(edges) + let mut output = String::from( + "# Dev Brain index\n\n\n", + ); + for directory in TOPIC_DIRS { + let mut group = topics + .iter() + .filter(|page| page.path.starts_with(&format!("{directory}/"))) + .copied() + .collect::>(); + if group.is_empty() { + continue; + } + group.sort_by(|left, right| left.title.cmp(&right.title)); + output.push_str(&format!("\n## {}\n\n", title_case(directory))); + for page in group { + output.push_str(&format!( + "- [[{}|{}]] — {}\n", + page.path, + page.title.replace('|', "-"), + page.status + )); + } + } + output +} + +fn title_case(value: &str) -> String { + let mut characters = value.chars(); + characters + .next() + .map(|first| first.to_uppercase().collect::() + characters.as_str()) + .unwrap_or_default() } fn resolve_link( @@ -1468,56 +1494,19 @@ mod tests { hash_file(&fixture.project.join("source.rs")).unwrap() } - fn publish_topic(brain: &mut DevBrain, topic: &str) -> Result { - let mut files = Map::new(); - files.insert("concepts/answer.md".into(), Value::String(topic.into())); - files.insert( - "index.md".into(), - Value::String( - "# Dev Brain index\n\n- [[concepts/answer|Answer]] — Test answer.\n".into(), - ), - ); - files.insert( - "log.md".into(), - Value::String(format!("{DEFAULT_LOG}\n- Added answer.\n")), - ); - brain.publish(&files, &[]) + fn write_topic(fixture: &Fixture, brain: &mut DevBrain, topic: &str) -> Result { + fs::write(fixture.vault.join("concepts/answer.md"), topic).unwrap(); + brain.validate() } #[test] fn path_confinement_and_unrelated_notes_are_preserved() { let fixture = Fixture::new(); - let mut brain = fixture.brain(); - fs::create_dir_all(fixture.vault.join("concepts")).unwrap(); + let brain = fixture.brain(); fs::write(fixture.vault.join("concepts/private.md"), "# Private\n").unwrap(); - let mut files = Map::new(); - files.insert("../escape.md".into(), Value::String("bad".into())); - assert!(brain.publish(&files, &[]).is_err()); - assert_eq!( - fs::read_to_string(fixture.vault.join("concepts/private.md")).unwrap(), - "# Private\n" - ); - - let outside = fixture.root.join("outside"); - fs::create_dir(&outside).unwrap(); - std::os::unix::fs::symlink(&outside, fixture.vault.join("concepts/escape")).unwrap(); - let mut files = Map::new(); - files.insert( - "concepts/escape/answer.md".into(), - Value::String(fixture.topic("verified", &source_hash(&fixture), "Confined.")), - ); - files.insert( - "index.md".into(), - Value::String( - "# Dev Brain index\n\n- [[concepts/escape/answer|Answer]] — Test.\n".into(), - ), - ); - files.insert( - "log.md".into(), - Value::String(format!("{DEFAULT_LOG}\n- Added confined answer.\n")), - ); - assert!(brain.publish(&files, &[]).is_err()); - assert!(!outside.join("answer.md").exists()); + assert!(!brain.allows_tool_write(&fixture.vault.join("concepts/private.md"))); + assert!(brain.allows_tool_write(&brain.folder().join("concepts/new.md"))); + assert!(!brain.allows_tool_write(&fixture.root.join("outside.md"))); } #[test] @@ -1529,7 +1518,7 @@ mod tests { &source_hash(&fixture), "The durable answer is searchable.\n\n`[[Missing]]`\n\n```md\n[[Also Missing]]\n```", ); - publish_topic(&mut brain, &topic).unwrap(); + write_topic(&fixture, &mut brain, &topic).unwrap(); let result = brain.search("durable searchable", 10, true).unwrap(); assert!(result.contains("concepts/answer.md")); assert!(result.contains("Fixture:source.rs")); @@ -1539,7 +1528,8 @@ mod tests { fn source_hash_drift_excludes_page_from_authoritative_queries() { let fixture = Fixture::new(); let mut brain = fixture.brain(); - publish_topic( + write_topic( + &fixture, &mut brain, &fixture.topic( "verified", @@ -1603,7 +1593,7 @@ mod tests { &format!("revision: {revision}"), ); let mut brain = fixture.brain(); - publish_topic(&mut brain, &topic).unwrap(); + write_topic(&fixture, &mut brain, &topic).unwrap(); assert!( brain .search("Revision-backed knowledge", 10, true) @@ -1620,46 +1610,45 @@ mod tests { } #[test] - fn broken_links_and_invalid_provenance_leave_live_vault_untouched() { + fn validation_keeps_broken_links_editable_and_reports_provenance_errors() { let fixture = Fixture::new(); let mut brain = fixture.brain(); - let index_before = fs::read_to_string(fixture.vault.join("index.md")).unwrap(); - let log_before = fs::read_to_string(fixture.vault.join("log.md")).unwrap(); let bad_topic = fixture.topic( "verified", &source_hash(&fixture), "This has a [[Missing Page]].", ); - assert!(publish_topic(&mut brain, &bad_topic).is_err()); - assert_eq!( - fs::read_to_string(fixture.vault.join("index.md")).unwrap(), - index_before + let result = write_topic(&fixture, &mut brain, &bad_topic).unwrap(); + assert!(result.contains("Broken link")); + assert!(fixture.vault.join("concepts/answer.md").exists()); + assert!( + fs::read_to_string(fixture.vault.join("index.md")) + .unwrap() + .contains("[[concepts/answer.md|Answer]]") ); - assert_eq!( - fs::read_to_string(fixture.vault.join("log.md")).unwrap(), - log_before - ); - assert!(!fixture.vault.join("concepts/answer.md").exists()); + + let missing = fixture + .topic("verified", &source_hash(&fixture), "The linked detail.") + .replace("# Answer", "# Missing Page"); + fs::write(fixture.vault.join("concepts/missing.md"), missing).unwrap(); + assert!(brain.validate().unwrap().contains("No link warnings")); let invalid = fixture.topic("verified", &"0".repeat(64), "Wrong evidence."); - assert!(publish_topic(&mut brain, &invalid).is_err()); - assert!(!fixture.vault.join("concepts/answer.md").exists()); + fs::write(fixture.vault.join("concepts/answer.md"), invalid).unwrap(); + assert!(brain.validate().is_err()); + let malformed = fixture + .topic("verified", &source_hash(&fixture), "Malformed.") + .replace("verified_at: 2026-07-27T12:00:00Z\n", ""); + fs::write(fixture.vault.join("concepts/answer.md"), malformed).unwrap(); + drop(brain); + let mut brain = fixture.brain(); + assert!(brain.info().contains("needs repair")); - let escaped = fixture - .topic("verified", &source_hash(&fixture), "Escaped evidence.") - .replace("path: source.rs", "path: ../source.rs"); - assert!(publish_topic(&mut brain, &escaped).is_err()); - assert!(!fixture.vault.join("concepts/answer.md").exists()); - - let mut files = Map::new(); - files.insert( - "log.md".into(), - Value::String("# Dev Brain log\n\nRewritten history.\n".into()), - ); - assert!(brain.publish(&files, &[]).is_err()); - assert_eq!( - fs::read_to_string(fixture.vault.join("log.md")).unwrap(), - log_before - ); + fs::write( + fixture.vault.join("concepts/answer.md"), + fixture.topic("verified", &source_hash(&fixture), "Repaired evidence."), + ) + .unwrap(); + assert!(brain.validate().unwrap().contains("structurally valid")); } }