fix: better handling of dev brain

This commit is contained in:
Georg Bauer
2026-07-27 21:42:58 +02:00
parent 9a55a49b00
commit 6001e7312b
5 changed files with 421 additions and 282 deletions

View File

@@ -98,16 +98,16 @@ To set it up:
Useful requests include “Build Dev Brain for these projects,” “Refresh Dev Useful requests include “Build Dev Brain for these projects,” “Refresh Dev
Brain after my recent changes,” “What does Dev Brain know about the cache 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 invariants?”, and “Check the wiki, then verify this against the source.” The
and reads use a disposable local SQLite full-text index. The index checks vault agent gets the real vault folder from Dev Brain, then uses its normal list,
files and cited project evidence before use, so no separate indexing service or read, search, write, and edit tools on the Markdown. A dedicated validation tool
manual refresh button is needed. checks the result and refreshes the disposable local SQLite search index.
The vault remains ordinary Obsidian Markdown: The vault remains ordinary Obsidian Markdown:
- **purpose.md** records its scope, priorities, and recurring questions. - **purpose.md** records its scope, priorities, and recurring questions.
- **schema.md** is the detailed contract the agent follows when maintaining it. - **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. - **log.md** is an append-only history of material updates.
- Topic pages live under **projects/**, **subsystems/**, **concepts/**, - Topic pages live under **projects/**, **subsystems/**, **concepts/**,
**decisions/**, **invariants/**, and **workflows/** as needed. **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. Every topic cites exact project files and a Git revision or content hash.
**Verified** pages may support answers. **Stale** pages are navigation leads **Verified** pages may support answers. **Stale** pages are navigation leads
whose evidence changed, and **needs-review** pages contain visible uncertainty; whose evidence changed, and **needs-review** pages contain visible uncertainty;
the agent must recheck project sources before relying on either. Wiki updates the agent must recheck project sources before relying on either. After editing,
are validated as a complete batch before publication, so a broken link, the agent runs validation. Unsafe paths and invalid page structure or provenance
out-of-bounds path, invalid citation, or stale verified claim leaves the last are errors to repair in place. Broken links are reported as warnings: the valid
valid wiki untouched. 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 DS4Server may modify only the four contract files and topic pages explicitly
marked as Dev Brain-managed. It does not modify Obsidian settings, attachments, marked as Dev Brain-managed. Standard agent file tools can access the vault but
trash, hidden files, or unrelated notes. You can inspect, edit, link, and cannot modify Obsidian settings, hidden files, or unrelated notes. You can
version the managed Markdown normally; invalid manual edits are reported rather inspect, edit, link, and version the managed Markdown normally; invalid manual
than silently trusted. Disabling Dev Brain removes its tools and instructions edits are reported rather than silently trusted. Disabling Dev Brain removes its
from the built-in coding agent without deleting the vault. folder, search, and validation guidance from the built-in coding agent without
deleting the vault.
## Views and sidebar ## Views and sidebar

View File

@@ -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":"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":"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":"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"]}}}"#; {"type":"function","function":{"name":"list","description":"List one directory compactly.","parameters":{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]}}}"#;
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
@@ -343,9 +343,9 @@ impl Tools {
"bash_stop" => self.bash_observe(call, true, cancel), "bash_stop" => self.bash_observe(call, true, cancel),
"google_search" => self.google_search(call, cancel), "google_search" => self.google_search(call, cancel),
"visit_page" => self.visit_page(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_search" => self.dev_brain_search(call),
"dev_brain_read" => self.dev_brain_read(call), "dev_brain_validate" => self.dev_brain_validate(),
"dev_brain_publish" => self.dev_brain_publish(call),
name => Err(format!("unknown tool: {name}")), name => Err(format!("unknown tool: {name}")),
}; };
match result { match result {
@@ -373,42 +373,19 @@ impl Tools {
.search(query, limit, authoritative) .search(query, limit, authoritative)
} }
fn dev_brain_read(&mut self, call: &ToolCall) -> Result<String, String> { fn dev_brain_info(&self) -> Result<String, String> {
let path = required_string(call, "path")?; Ok(self
let authoritative = boolean(call, "authoritative", false); .dev_brain
self.dev_brain .as_ref()
.as_mut()
.ok_or_else(|| "Dev Brain is disabled for this session.".to_owned())? .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<String, String> { fn dev_brain_validate(&mut self) -> Result<String, String> {
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::<Result<Vec<_>, String>>()
})
.transpose()?
.unwrap_or_default();
self.dev_brain self.dev_brain
.as_mut() .as_mut()
.ok_or_else(|| "Dev Brain is disabled for this session.".to_owned())? .ok_or_else(|| "Dev Brain is disabled for this session.".to_owned())?
.publish(files, &remove) .validate()
} }
fn result_limit(&self) -> usize { fn result_limit(&self) -> usize {
@@ -456,7 +433,7 @@ impl Tools {
let path = path let path = path
.canonicalize() .canonicalize()
.map_err(|error| format!("open {value}: {error}"))?; .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<PathBuf, String> { fn writable_path(&self, value: &str) -> Result<PathBuf, String> {
@@ -472,7 +449,8 @@ impl Tools {
self.root.join(value) self.root.join(value)
}; };
if fs::symlink_metadata(&path).is_ok() { 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 ancestor = path.as_path();
let mut suffix = Vec::new(); let mut suffix = Vec::new();
@@ -488,21 +466,54 @@ impl Tools {
let mut resolved = ancestor let mut resolved = ancestor
.canonicalize() .canonicalize()
.map_err(|error| format!("open ancestor of {value}: {error}"))?; .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() { for name in suffix.into_iter().rev() {
resolved.push(name); resolved.push(name);
} }
Ok(resolved) self.inside_writable_root(resolved, value)
} }
fn inside_project(&self, path: PathBuf, original: &str) -> Result<PathBuf, String> { fn inside_readable_root(&self, path: PathBuf, original: &str) -> Result<PathBuf, String> {
if path.starts_with(&self.root) { 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<PathBuf, String> {
if path.starts_with(&self.root)
|| self
.dev_brain
.as_ref()
.is_some_and(|brain| brain.allows_tool_write(&path))
{
Ok(path) Ok(path)
} else { } 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 { fn default_lines(&self) -> usize {
match self.context_tokens { match self.context_tokens {
..=8192 => 120, ..=8192 => 120,
@@ -628,6 +639,16 @@ impl Tools {
return Err(format!("content exceeds {MAX_FILE_BYTES} bytes")); return Err(format!("content exceeds {MAX_FILE_BYTES} bytes"));
} }
let path = self.writable_path(display)?; 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}"))?; fs::write(&path, content).map_err(|error| format!("write {display}: {error}"))?;
Ok(format!("Wrote {} bytes to {display}\n", content.len())) Ok(format!("Wrote {} bytes to {display}\n", content.len()))
} }
@@ -640,6 +661,7 @@ impl Tools {
return Err("edit requires non-empty old text".into()); return Err("edit requires non-empty old text".into());
} }
let path = self.existing_path(display)?; 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 { if path.metadata().map_err(|error| error.to_string())?.len() > MAX_FILE_BYTES {
return Err(format!( return Err(format!(
"file too large: {display} exceeds {MAX_FILE_BYTES} bytes" "file too large: {display} exceeds {MAX_FILE_BYTES} bytes"
@@ -654,6 +676,7 @@ impl Tools {
output.push_str(&data[..start]); output.push_str(&data[..start]);
output.push_str(new); output.push_str(new);
output.push_str(&data[end..]); output.push_str(&data[end..]);
self.validate_dev_brain_content(&path, &output)?;
fs::write(&path, output).map_err(|error| format!("write {display}: {error}"))?; fs::write(&path, output).map_err(|error| format!("write {display}: {error}"))?;
Ok(format!( Ok(format!(
"Edited {display} using {} replacement\n", "Edited {display} using {} replacement\n",
@@ -676,8 +699,15 @@ impl Tools {
.filter_map(Result::ok) .filter_map(Result::ok)
.collect::<Vec<_>>(); .collect::<Vec<_>>();
entries.sort_by_key(|entry| entry.file_name()); 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"); let mut output = format!("{display}:\n");
for entry in entries.iter().take(300) { 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 metadata = fs::symlink_metadata(entry.path()).map_err(|error| error.to_string())?;
let kind = if metadata.file_type().is_symlink() { let kind = if metadata.file_type().is_symlink() {
'l' 'l'
@@ -713,7 +743,12 @@ impl Tools {
context, context,
limit, 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<String, String> { fn bash(&mut self, call: &ToolCall, cancel: &AtomicBool) -> Result<String, String> {
@@ -948,9 +983,14 @@ struct SearchOptions<'a> {
limit: usize, limit: usize,
} }
fn search_path(root: &Path, path: &Path, options: &SearchOptions<'_>) -> Result<String, String> { fn search_path(
root: &Path,
path: &Path,
options: &SearchOptions<'_>,
skip_hidden: bool,
) -> Result<String, String> {
let mut files = Vec::new(); 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 matches = 0;
let mut body = String::new(); let mut body = String::new();
for file in files { for file in files {
@@ -991,6 +1031,7 @@ fn search_path(root: &Path, path: &Path, options: &SearchOptions<'_>) -> Result<
fn collect_search_files( fn collect_search_files(
path: &Path, path: &Path,
depth: usize, depth: usize,
skip_hidden: bool,
output: &mut Vec<PathBuf>, output: &mut Vec<PathBuf>,
) -> Result<(), String> { ) -> Result<(), String> {
if depth > 24 { if depth > 24 {
@@ -1015,10 +1056,12 @@ fn collect_search_files(
.collect::<Vec<_>>(); .collect::<Vec<_>>();
entries.sort_by_key(|entry| entry.file_name()); entries.sort_by_key(|entry| entry.file_name());
for entry in entries { 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; continue;
} }
collect_search_files(&entry.path(), depth + 1, output)?; collect_search_files(&entry.path(), depth + 1, skip_hidden, output)?;
} }
Ok(()) Ok(())
} }
@@ -1748,7 +1791,11 @@ mod tests {
.contains("[System prompt reminder follows.]") .contains("[System prompt reminder follows.]")
); );
assert!(!prompt.contains("dev_brain_search")); 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!(datetime_context().starts_with("Current local date and time at session start:"));
assert!(!prompt_reminder_due(49_999, 0)); assert!(!prompt_reminder_due(49_999, 0));
assert!(prompt_reminder_due(50_000, 0)); assert!(prompt_reminder_due(50_000, 0));
@@ -1809,6 +1856,77 @@ mod tests {
fs::remove_dir_all(outside).unwrap(); 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] #[test]
fn risky_shell_commands_require_one_time_approval() { fn risky_shell_commands_require_one_time_approval() {
let root = Path::new("/tmp/project"); let root = Path::new("/tmp/project");

View File

@@ -380,6 +380,19 @@ fn title_context(messages: impl IntoIterator<Item = ChatTurn>) -> Vec<ChatTurn>
.collect() .collect()
} }
#[cfg(any(target_os = "macos", test))]
fn agents_prompt_for_turn(
opening_turn: bool,
opening_prompt: Option<String>,
stored_prompt: Option<&str>,
) -> Option<String> {
if opening_turn {
opening_prompt
} else {
stored_prompt.map(str::to_owned)
}
}
impl App { impl App {
fn chat_system_prompt(&self, model: ModelChoice, prompt: &str, agents: Option<&str>) -> String { 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); let mut prompt = crate::agent::system_prompt(model, prompt, self.config.dev_brain.enabled);
@@ -445,7 +458,7 @@ impl App {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
let opening_turn = self.selected_session.is_none(); let opening_turn = self.selected_session.is_none();
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
let agents = if opening_turn { let opening_agents = if opening_turn {
let Some(project) = self let Some(project) = self
.projects .projects
.iter() .iter()
@@ -464,6 +477,9 @@ impl App {
} else { } else {
None 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.a2ui_auto_switch_pending = true;
self.context_notice = None; self.context_notice = None;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -1860,10 +1876,10 @@ pub(super) fn session_title(reply: &str) -> Option<String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
ChatMessage, TOOL_PROTOCOL_CORRECTION, TurnSummary, chat_turn, compacted_context_start, ChatMessage, TOOL_PROTOCOL_CORRECTION, TurnSummary, agents_prompt_for_turn, chat_turn,
correction_already_sent, has_chat_after_last_compaction, has_misplaced_tool_call, compacted_context_start, correction_already_sent, has_chat_after_last_compaction,
is_empty_response, project_agents, promote_legacy_turn_summaries, queued_prompt, has_misplaced_tool_call, is_empty_response, project_agents, promote_legacy_turn_summaries,
sync_a2ui_message, title_context, queued_prompt, sync_a2ui_message, title_context,
}; };
use crate::engine::ChatTurn; use crate::engine::ChatTurn;
use crate::model::ModelChoice; use crate::model::ModelChoice;
@@ -1919,6 +1935,19 @@ mod tests {
std::fs::remove_dir_all(directory).unwrap(); 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] #[test]
fn turn_summary_accumulates_model_continuations() { fn turn_summary_accumulates_model_continuations() {
let mut summary = TurnSummary::new(); let mut summary = TurnSummary::new();

View File

@@ -164,7 +164,7 @@ impl App {
checkbox(self.preference_draft.dev_brain_enabled) checkbox(self.preference_draft.dev_brain_enabled)
.label("Enable project-backed LLM wiki") .label("Enable project-backed LLM wiki")
.on_toggle(Message::PreferenceDevBrainEnabledChanged), .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![ row![
text_input( text_input(

View File

@@ -2,7 +2,7 @@ use diesel::connection::SimpleConnection;
use diesel::prelude::*; use diesel::prelude::*;
use diesel::sql_types::{BigInt, Double, Text}; use diesel::sql_types::{BigInt, Double, Text};
use serde::Deserialize; use serde::Deserialize;
use serde_json::{Map, Value}; use serde_json::Value;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::collections::{BTreeSet, HashMap, HashSet}; use std::collections::{BTreeSet, HashMap, HashSet};
use std::fs; use std::fs;
@@ -30,11 +30,12 @@ const TOPIC_DIRS: [&str; 6] = [
// readers; use per-vault locks only if concurrent vault throughput matters. // readers; use per-vault locks only if concurrent vault throughput matters.
static VAULT_LOCK: RwLock<()> = RwLock::new(()); 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"]}}} 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_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_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_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"]}}}"#; {"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\ const DEFAULT_PURPOSE: &str = "# Dev Brain purpose\n\n\
Dev Brain compiles durable, source-backed knowledge from the registered projects.\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 ## 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)] #[derive(Clone)]
@@ -219,6 +220,7 @@ pub(crate) struct DevBrain {
projects: Vec<RegisteredProject>, projects: Vec<RegisteredProject>,
connection: SqliteConnection, connection: SqliteConnection,
fingerprint: Vec<Fingerprint>, fingerprint: Vec<Fingerprint>,
index_error: Option<String>,
} }
impl DevBrain { impl DevBrain {
@@ -259,11 +261,59 @@ impl DevBrain {
projects, projects,
connection, connection,
fingerprint: Vec::new(), fingerprint: Vec::new(),
index_error: None,
}; };
brain.rebuild()?; brain.index_error = brain.rebuild().err();
Ok(brain) 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( pub(crate) fn search(
&mut self, &mut self,
query: &str, query: &str,
@@ -273,6 +323,11 @@ impl DevBrain {
let _guard = VAULT_LOCK let _guard = VAULT_LOCK
.read() .read()
.unwrap_or_else(|poisoned| poisoned.into_inner()); .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()?; self.refresh()?;
let query = fts_query(query); let query = fts_query(query);
if query.is_empty() { if query.is_empty() {
@@ -320,14 +375,6 @@ impl DevBrain {
Ok(output) Ok(output)
} }
pub(crate) fn read(&mut self, value: &str, authoritative: bool) -> Result<String, String> {
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<String, String> { fn read_indexed(&mut self, value: &str, authoritative: bool) -> Result<String, String> {
let normalized = normalize_managed_path(value) let normalized = normalize_managed_path(value)
.map(|path| path_to_string(&path)) .map(|path| path_to_string(&path))
@@ -367,86 +414,40 @@ impl DevBrain {
Ok(output) Ok(output)
} }
pub(crate) fn publish( pub(crate) fn validate(&mut self) -> Result<String, String> {
&mut self,
files: &Map<String, Value>,
remove: &[String],
) -> Result<String, String> {
let _guard = VAULT_LOCK let _guard = VAULT_LOCK
.write() .write()
.unwrap_or_else(|poisoned| poisoned.into_inner()); .unwrap_or_else(|poisoned| poisoned.into_inner());
if files.is_empty() && remove.is_empty() { let pages = load_and_validate_pages(&self.vault, &self.vault, &self.projects, true)?;
return Err("Dev Brain publication batch is empty.".into()); 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 pages = load_and_validate_pages(&self.vault, &self.vault, &self.projects, true)?;
let old_log = fs::read_to_string(self.vault.join("log.md")).unwrap_or_default(); let (_, warnings) = validate_graph(&pages, &self.vault, &self.vault);
let staging = temporary_sibling(&self.vault, "candidate")?;
fs::create_dir(&staging).map_err(|error| error.to_string())?;
let result = (|| {
for path in &current {
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?;
self.rebuild()?; self.rebuild()?;
Ok(format!( self.index_error = None;
"Published {count} managed Dev Brain file change{} after full candidate validation.\n", let mut output = format!(
if count == 1 { "" } else { "s" } "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> { fn refresh(&mut self) -> Result<(), String> {
@@ -458,7 +459,7 @@ impl DevBrain {
fn rebuild(&mut self) -> Result<(), String> { fn rebuild(&mut self) -> Result<(), String> {
let pages = load_and_validate_pages(&self.vault, &self.vault, &self.projects, false)?; 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 self.connection
.batch_execute( .batch_execute(
"DROP TABLE IF EXISTS page_fts; "DROP TABLE IF EXISTS page_fts;
@@ -675,15 +676,13 @@ fn ensure_contract(vault: &Path) -> Result<(), String> {
.iter() .iter()
.filter(|path| vault.join(path).is_file()) .filter(|path| vault.join(path).is_file())
.count(); .count();
if present == ROOT_PAGES.len() { if present != 0 && present != ROOT_PAGES.len() {
return Ok(());
}
if present != 0 {
return Err( 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." "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(), .into(),
); );
} }
if present == 0 {
let staging = temporary_sibling(vault, "contract")?; let staging = temporary_sibling(vault, "contract")?;
fs::create_dir(&staging).map_err(|error| error.to_string())?; fs::create_dir(&staging).map_err(|error| error.to_string())?;
let result = (|| { let result = (|| {
@@ -702,7 +701,12 @@ fn ensure_contract(vault: &Path) -> Result<(), String> {
commit_batch(vault, &staging, &changed) commit_batch(vault, &staging, &changed)
})(); })();
let _ = fs::remove_dir_all(staging); let _ = fs::remove_dir_all(staging);
result 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<PathBuf, String> { pub(crate) fn validate_vault(path: &Path) -> Result<PathBuf, String> {
@@ -889,7 +893,7 @@ fn validate_graph(
pages: &[Page], pages: &[Page],
content_root: &Path, content_root: &Path,
vault_root: &Path, vault_root: &Path,
) -> Result<Vec<(String, String, String)>, String> { ) -> (Vec<(String, String, String)>, Vec<String>) {
let by_path = pages let by_path = pages
.iter() .iter()
.map(|page| (page.path.clone(), page)) .map(|page| (page.path.clone(), page))
@@ -908,10 +912,12 @@ fn validate_graph(
} }
} }
let mut edges = Vec::new(); let mut edges = Vec::new();
let mut errors = Vec::new();
for page in pages { for page in pages {
for link in &page.links { for link in &page.links {
match resolve_link(page, link, &by_path, &identities, content_root, vault_root)? { match resolve_link(page, link, &by_path, &identities, content_root, vault_root) {
Some(target) => edges.push(( Err(error) => errors.push(error),
Ok(Some(target)) => edges.push((
page.path.clone(), page.path.clone(),
target, target,
if link.kind == LinkType::Embed { if link.kind == LinkType::Embed {
@@ -920,36 +926,56 @@ fn validate_graph(
"wikilink".into() "wikilink".into()
}, },
)), )),
None if link.kind == LinkType::Anchor => {} Ok(None) => errors.push(format!(
None => {
return Err(format!(
"Broken link in {} at line {}: [[{}]]", "Broken link in {} at line {}: [[{}]]",
page.path, link.line, link.target page.path, link.line, link.target
)); )),
} }
} }
} }
} (edges, errors)
let indexed = edges }
.iter()
.filter(|(source, target, _)| source == "index.md" && is_topic_path(Path::new(target))) fn render_index(pages: &[Page]) -> String {
.map(|(_, target, _)| target.clone())
.collect::<BTreeSet<_>>();
let topics = pages let topics = pages
.iter() .iter()
.filter(|page| is_topic_path(Path::new(&page.path))) .filter(|page| is_topic_path(Path::new(&page.path)))
.map(|page| page.path.clone()) .collect::<Vec<_>>();
.collect::<BTreeSet<_>>(); if topics.is_empty() {
if indexed != topics { return DEFAULT_INDEX.to_owned();
let missing = topics.difference(&indexed).cloned().collect::<Vec<_>>(); }
let extra = indexed.difference(&topics).cloned().collect::<Vec<_>>(); let mut output = String::from(
return Err(format!( "# Dev Brain index\n\n<!-- Generated by dev_brain_validate; edit topic pages, not this list. -->\n",
"index.md does not match managed topic pages. Missing: [{}]. Extra: [{}].", );
missing.join(", "), for directory in TOPIC_DIRS {
extra.join(", ") let mut group = topics
.iter()
.filter(|page| page.path.starts_with(&format!("{directory}/")))
.copied()
.collect::<Vec<_>>();
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
)); ));
} }
Ok(edges) }
output
}
fn title_case(value: &str) -> String {
let mut characters = value.chars();
characters
.next()
.map(|first| first.to_uppercase().collect::<String>() + characters.as_str())
.unwrap_or_default()
} }
fn resolve_link( fn resolve_link(
@@ -1468,56 +1494,19 @@ mod tests {
hash_file(&fixture.project.join("source.rs")).unwrap() hash_file(&fixture.project.join("source.rs")).unwrap()
} }
fn publish_topic(brain: &mut DevBrain, topic: &str) -> Result<String, String> { fn write_topic(fixture: &Fixture, brain: &mut DevBrain, topic: &str) -> Result<String, String> {
let mut files = Map::new(); fs::write(fixture.vault.join("concepts/answer.md"), topic).unwrap();
files.insert("concepts/answer.md".into(), Value::String(topic.into())); brain.validate()
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, &[])
} }
#[test] #[test]
fn path_confinement_and_unrelated_notes_are_preserved() { fn path_confinement_and_unrelated_notes_are_preserved() {
let fixture = Fixture::new(); let fixture = Fixture::new();
let mut brain = fixture.brain(); let brain = fixture.brain();
fs::create_dir_all(fixture.vault.join("concepts")).unwrap();
fs::write(fixture.vault.join("concepts/private.md"), "# Private\n").unwrap(); fs::write(fixture.vault.join("concepts/private.md"), "# Private\n").unwrap();
let mut files = Map::new(); assert!(!brain.allows_tool_write(&fixture.vault.join("concepts/private.md")));
files.insert("../escape.md".into(), Value::String("bad".into())); assert!(brain.allows_tool_write(&brain.folder().join("concepts/new.md")));
assert!(brain.publish(&files, &[]).is_err()); assert!(!brain.allows_tool_write(&fixture.root.join("outside.md")));
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());
} }
#[test] #[test]
@@ -1529,7 +1518,7 @@ mod tests {
&source_hash(&fixture), &source_hash(&fixture),
"The durable answer is searchable.\n\n`[[Missing]]`\n\n```md\n[[Also Missing]]\n```", "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(); let result = brain.search("durable searchable", 10, true).unwrap();
assert!(result.contains("concepts/answer.md")); assert!(result.contains("concepts/answer.md"));
assert!(result.contains("Fixture:source.rs")); assert!(result.contains("Fixture:source.rs"));
@@ -1539,7 +1528,8 @@ mod tests {
fn source_hash_drift_excludes_page_from_authoritative_queries() { fn source_hash_drift_excludes_page_from_authoritative_queries() {
let fixture = Fixture::new(); let fixture = Fixture::new();
let mut brain = fixture.brain(); let mut brain = fixture.brain();
publish_topic( write_topic(
&fixture,
&mut brain, &mut brain,
&fixture.topic( &fixture.topic(
"verified", "verified",
@@ -1603,7 +1593,7 @@ mod tests {
&format!("revision: {revision}"), &format!("revision: {revision}"),
); );
let mut brain = fixture.brain(); let mut brain = fixture.brain();
publish_topic(&mut brain, &topic).unwrap(); write_topic(&fixture, &mut brain, &topic).unwrap();
assert!( assert!(
brain brain
.search("Revision-backed knowledge", 10, true) .search("Revision-backed knowledge", 10, true)
@@ -1620,46 +1610,45 @@ mod tests {
} }
#[test] #[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 fixture = Fixture::new();
let mut brain = fixture.brain(); 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( let bad_topic = fixture.topic(
"verified", "verified",
&source_hash(&fixture), &source_hash(&fixture),
"This has a [[Missing Page]].", "This has a [[Missing Page]].",
); );
assert!(publish_topic(&mut brain, &bad_topic).is_err()); let result = write_topic(&fixture, &mut brain, &bad_topic).unwrap();
assert_eq!( assert!(result.contains("Broken link"));
fs::read_to_string(fixture.vault.join("index.md")).unwrap(), assert!(fixture.vault.join("concepts/answer.md").exists());
index_before 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(), let missing = fixture
log_before .topic("verified", &source_hash(&fixture), "The linked detail.")
); .replace("# Answer", "# Missing Page");
assert!(!fixture.vault.join("concepts/answer.md").exists()); 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."); let invalid = fixture.topic("verified", &"0".repeat(64), "Wrong evidence.");
assert!(publish_topic(&mut brain, &invalid).is_err()); fs::write(fixture.vault.join("concepts/answer.md"), invalid).unwrap();
assert!(!fixture.vault.join("concepts/answer.md").exists()); 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 fs::write(
.topic("verified", &source_hash(&fixture), "Escaped evidence.") fixture.vault.join("concepts/answer.md"),
.replace("path: source.rs", "path: ../source.rs"); fixture.topic("verified", &source_hash(&fixture), "Repaired evidence."),
assert!(publish_topic(&mut brain, &escaped).is_err()); )
assert!(!fixture.vault.join("concepts/answer.md").exists()); .unwrap();
assert!(brain.validate().unwrap().contains("structurally valid"));
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
);
} }
} }