Add verified Dev Brain skills

This commit is contained in:
Georg Bauer
2026-07-28 18:57:08 +02:00
parent 5ec873074b
commit 2391e2a680
5 changed files with 428 additions and 52 deletions

View File

@@ -16,6 +16,10 @@ the Metal kernels and small native integration layers are adapted from
project files; run and monitor asynchronous shell commands; search Google;
and visit rendered web pages. File access is confined to the project, output
is bounded, and risky shell or visible-browser actions require approval.
- **Project-backed Dev Brain.** An optional managed Obsidian vault provides
source-verified wiki pages, ranked search, and on-demand skills. Each session
receives the verified skill names, descriptions, and paths, then loads full
instructions only when a task matches.
- **Long-running sessions.** Transcripts and summaries are stored in SQLite.
Automatic and manual context compaction preserve the complete visible chat,
while durable KV checkpoints make follow-up turns and relaunches resumable.

View File

@@ -358,6 +358,7 @@ pub(crate) enum Message {
PreferenceGitIgnoreBlankLinesChanged(bool),
ChooseDevBrainVault,
DevBrainVaultPicked(Option<PathBuf>),
RestoreDevBrainDefaultGuides,
PreferenceContextChanged(String),
PreferenceMaxTokensChanged(String),
PreferenceSystemPromptAction(text_editor::Action),
@@ -1229,6 +1230,12 @@ impl App {
self.preference_error = None;
}
}
Message::RestoreDevBrainDefaultGuides => {
self.preference_error = crate::dev_brain::restore_default_guides(Path::new(
&self.preference_draft.dev_brain_vault_path,
))
.err();
}
Message::PreferenceContextChanged(value) => {
self.preference_draft.context_tokens = value;
self.preference_error = None;

View File

@@ -407,6 +407,21 @@ impl App {
prompt
}
fn dev_brain_skills_prompt(&self) -> Option<String> {
self.config.dev_brain.enabled.then(|| {
let projects = self
.projects
.iter()
.map(|project| project.project.clone())
.collect::<Vec<_>>();
crate::dev_brain::skills_prompt(&self.config.dev_brain, &projects).unwrap_or_else(|error| {
format!(
"# Available Dev Brain skills\n\nThe verified skill index is unavailable: {error}. Repair the vault with dev_brain_info and dev_brain_validate before relying on a skill."
)
})
})
}
fn session_agents_prompt(&self) -> Option<&str> {
self.conversation
.iter()
@@ -539,6 +554,7 @@ impl App {
#[cfg(target_os = "macos")]
if opening_turn {
injected_system.extend(agents.clone());
injected_system.extend(self.dev_brain_skills_prompt());
injected_system.push(crate::agent::datetime_context());
}
#[cfg(target_os = "macos")]
@@ -688,6 +704,9 @@ impl App {
model,
self.config.dev_brain.enabled,
)];
if let Some(skills) = self.dev_brain_skills_prompt() {
reminders.push(skills);
}
if self.config.a2ui_enabled {
reminders.push(crate::a2ui::SYSTEM_PROMPT.to_owned());
}

View File

@@ -180,6 +180,18 @@ impl App {
.align_y(Alignment::Center),
text("The folder must already contain .obsidian. DS4Server manages only its declared wiki pages and leaves settings, attachments, hidden files, and unrelated notes untouched.")
.size(12),
row![
text("Restore the current built-in Dev Brain guidance after an upgrade.")
.size(12)
.width(Length::Fill),
hint(
action_button("Recreate purpose.md and schema.md")
.on_press(Message::RestoreDevBrainDefaultGuides),
"Overwrites only purpose.md and schema.md with this version's defaults. Topic pages, generated indexes, and log.md are preserved.",
),
]
.spacing(12)
.align_y(Alignment::Center),
]
.spacing(10),
);

View File

@@ -16,25 +16,27 @@ use turbovault_parser::{LinkType, Parser, to_plain_text};
use crate::config::DevBrainConfig;
use crate::database::Project;
const ROOT_PAGES: [&str; 4] = ["purpose.md", "schema.md", "index.md", "log.md"];
const TOPIC_DIRS: [&str; 6] = [
const CONTRACT_PAGES: [&str; 4] = ["purpose.md", "schema.md", "index.md", "log.md"];
const ROOT_PAGES: [&str; 5] = ["purpose.md", "schema.md", "index.md", "skills.md", "log.md"];
const TOPIC_DIRS: [&str; 7] = [
"projects",
"subsystems",
"concepts",
"decisions",
"invariants",
"workflows",
"skills",
];
// ponytail: one process-wide lock keeps publication invisible to all in-app
// readers; use per-vault locks only if concurrent vault throughput matters.
static VAULT_LOCK: RwLock<()> = RwLock::new(());
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."#;
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 skills.md and reports broken links as repairable warnings without discarding content. Fix warnings with ordinary edits or by creating the missing page. The system prompt lists verified skills by name, description, and Markdown path. When a task matches a skill, read that complete skill file before acting and follow its instructions."#;
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":{}}}}"#;
{"type":"function","function":{"name":"dev_brain_validate","description":"Validate managed pages after ordinary file edits, deterministically rebuild index.md and skills.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\
@@ -48,6 +50,7 @@ Dev Brain compiles durable, source-backed knowledge from the registered projects
- Which project sources must be rechecked before relying on this page?\n";
const DEFAULT_INDEX: &str = "# Dev Brain index\n\nNo topic pages have been compiled yet.\n";
const DEFAULT_SKILLS: &str = "# Dev Brain skills\n\nNo verified skills are available.\n";
const DEFAULT_LOG: &str =
"# Dev Brain log\n\n<!-- Append material wiki updates and their source revisions below. -->\n";
const DEFAULT_SCHEMA: &str = r#"# Dev Brain schema
@@ -56,14 +59,14 @@ The registered projects are authoritative. This vault is a derived, human-readab
## Managed paths
DS4Server manages `purpose.md`, `schema.md`, `index.md`, `log.md`, and topic pages below `projects/`, `subsystems/`, `concepts/`, `decisions/`, `invariants/`, and `workflows/`. Topic pages must opt in with `dev_brain: true`. Other notes, hidden files, Obsidian settings, attachments, and trash are never modified.
DS4Server manages `purpose.md`, `schema.md`, `index.md`, `skills.md`, `log.md`, and topic pages below `projects/`, `subsystems/`, `concepts/`, `decisions/`, `invariants/`, `workflows/`, and `skills/`. Topic pages must opt in with `dev_brain: true`. Other notes, hidden files, Obsidian settings, attachments, and trash are never modified.
## Topic frontmatter
```yaml
---
dev_brain: true
type: subsystem # project, subsystem, concept, decision, invariant, or workflow
type: subsystem # project, subsystem, concept, decision, invariant, workflow, or skill
project: Registered project name
status: verified # verified, stale, or needs-review
verified_at: 2026-07-27T12:00:00Z
@@ -79,6 +82,26 @@ sources:
Each source has exactly one evidence version: `revision` or a lowercase SHA-256 `hash`. A revision is the current clean commit's full object ID or a unique lowercase hexadecimal prefix of at least 7 characters. Paths are project-relative and may not escape the registered project.
## Skills
Skills are on-demand instructions stored as Markdown below `skills/`. They use the same provenance and status fields as topic pages, set `type: skill`, and additionally require `name` and `description` frontmatter. Names contain 164 lowercase letters, digits, or hyphens, with no leading, trailing, or consecutive hyphens. Descriptions contain 11024 characters and explain both what the skill does and when to use it. Only skills whose status and current evidence are both `verified` appear in generated `skills.md` and the session system prompt. The prompt exposes each verified skill's name, description, and Markdown path; read the complete matching file on demand before following it. `index.md` lists all managed topic pages, while `skills.md` lists verified skills only. Do not hand-edit either generated index.
```yaml
---
dev_brain: true
type: skill
name: review-release
description: Verify a release candidate against the project checklist. Use before publishing a release.
project: Registered project name
status: verified
verified_at: 2026-07-27T12:00:00Z
sources:
- project: Registered project name
path: docs/releasing.md
revision: full-or-unique-short-clean-git-revision
---
```
## Compilation
Read the purpose first. Examine high-signal manifests, documentation, schemas, entry points, public interfaces, and tests. Create the smallest coherent topic set that answers the purpose; do not mirror every source file. Use ordinary Obsidian wikilinks and embeds to connect topics.
@@ -93,7 +116,7 @@ When evidence changes, re-read the affected code, documentation, and tests. Upda
## Publication
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.
Call `dev_brain_info`, then maintain these pages with the ordinary file tools. Append material updates to `log.md`; `index.md` and `skills.md` are 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)]
@@ -125,6 +148,10 @@ struct TopicFrontmatter {
status: String,
verified_at: String,
sources: Vec<SourceRecord>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
description: Option<String>,
}
#[derive(Clone)]
@@ -140,6 +167,8 @@ struct Page {
sources: Vec<SourceRecord>,
body: String,
content: String,
skill_name: Option<String>,
skill_description: Option<String>,
}
#[derive(Clone)]
@@ -230,30 +259,7 @@ impl DevBrain {
.unwrap_or_else(|poisoned| poisoned.into_inner());
let vault = config.vault()?;
ensure_contract(&vault)?;
let projects = projects
.iter()
.map(|project| {
Ok(RegisteredProject {
name: project.name.clone(),
root: Path::new(&project.path).canonicalize().map_err(|error| {
format!(
"Could not open registered project {}: {error}",
project.name
)
})?,
})
})
.collect::<Result<Vec<_>, String>>()?;
let mut names = HashSet::new();
if let Some(duplicate) = projects
.iter()
.map(|project| project.name.as_str())
.find(|name| !names.insert((*name).to_owned()))
{
return Err(format!(
"Registered project names must be unique for Dev Brain provenance: {duplicate}"
));
}
let projects = registered_projects(projects)?;
let connection =
SqliteConnection::establish(":memory:").map_err(|error| error.to_string())?;
let mut brain = Self {
@@ -273,7 +279,7 @@ impl DevBrain {
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",
"Dev Brain folder: {}\nManaged roots: purpose.md, schema.md, index.md, skills.md, log.md, {}/\nUse ordinary file tools with these absolute paths. Topic pages need dev_brain: true frontmatter. Skills use type: skill with name and description. Run dev_brain_validate after changes; index.md and skills.md are generated.\n",
self.vault.display(),
TOPIC_DIRS.join("/, ")
);
@@ -420,24 +426,39 @@ impl DevBrain {
.unwrap_or_else(|poisoned| poisoned.into_inner());
let pages = load_and_validate_pages(&self.vault, &self.vault, &self.projects, true)?;
let index = render_index(&pages);
let skills = render_skills(&pages);
let index_path = self.vault.join("index.md");
let skills_path = self.vault.join("skills.md");
let index_changed =
fs::read_to_string(&index_path).map_or(true, |current| current != index);
let skills_changed =
fs::read_to_string(&skills_path).map_or(true, |current| current != skills);
let mut generated = Vec::new();
if index_changed {
fs::write(&index_path, index)
.map_err(|error| format!("Could not update Dev Brain index: {error}"))?;
generated.push(("index.md", index.as_str()));
}
if skills_changed {
generated.push(("skills.md", skills.as_str()));
}
if !generated.is_empty() {
publish_files(&self.vault, "indexes", &generated)?;
}
let pages = load_and_validate_pages(&self.vault, &self.vault, &self.projects, true)?;
let (_, warnings) = validate_graph(&pages, &self.vault, &self.vault);
self.rebuild()?;
self.index_error = None;
let mut output = format!(
"Dev Brain is structurally valid ({} managed pages); index.md {}.\n",
"Dev Brain is structurally valid ({} managed pages); index.md {}, skills.md {}.\n",
pages.len(),
if index_changed {
"updated"
} else {
"unchanged"
},
if skills_changed {
"updated"
} else {
"unchanged"
}
);
if warnings.is_empty() {
@@ -670,38 +691,109 @@ impl DevBrain {
}
}
pub(crate) fn skills_prompt(
config: &DevBrainConfig,
projects: &[Project],
) -> Result<String, String> {
let _guard = VAULT_LOCK
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let vault = config.vault()?;
ensure_contract(&vault)?;
let projects = registered_projects(projects)?;
let pages = load_and_validate_pages(&vault, &vault, &projects, false)?;
let skills = render_skills(&pages);
if fs::read_to_string(vault.join("skills.md")).map_or(true, |current| current != skills) {
publish_files(&vault, "skills", &[("skills.md", &skills)])?;
}
Ok(format!(
"# Available Dev Brain skills\n\nThe following generated index contains verified skills only. Paths are relative to {}. When a task matches, read the complete Markdown file before acting.\n\n{}",
vault.display(),
skills.trim()
))
}
fn registered_projects(projects: &[Project]) -> Result<Vec<RegisteredProject>, String> {
let projects = projects
.iter()
.map(|project| {
Ok(RegisteredProject {
name: project.name.clone(),
root: Path::new(&project.path).canonicalize().map_err(|error| {
format!(
"Could not open registered project {}: {error}",
project.name
)
})?,
})
})
.collect::<Result<Vec<_>, String>>()?;
let mut names = HashSet::new();
if let Some(duplicate) = projects
.iter()
.map(|project| project.name.as_str())
.find(|name| !names.insert((*name).to_owned()))
{
return Err(format!(
"Registered project names must be unique for Dev Brain provenance: {duplicate}"
));
}
Ok(projects)
}
pub(crate) fn restore_default_guides(vault: &Path) -> Result<(), String> {
let _guard = VAULT_LOCK
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let vault = validate_vault(vault)?;
if !vault.join("index.md").is_file() || !vault.join("log.md").is_file() {
return Err(
"The selected vault has no Dev Brain index.md and log.md contract to restore.".into(),
);
}
publish_defaults(
&vault,
&[
("purpose.md", DEFAULT_PURPOSE),
("schema.md", DEFAULT_SCHEMA),
],
)
}
fn ensure_contract(vault: &Path) -> Result<(), String> {
validate_vault(vault)?;
let present = ROOT_PAGES
let present = CONTRACT_PAGES
.iter()
.filter(|path| vault.join(path).is_file())
.count();
if present != 0 && present != ROOT_PAGES.len() {
if present != 0 && present != CONTRACT_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 skills_path = vault.join("skills.md");
if skills_path.is_file()
&& !fs::read_to_string(&skills_path).is_ok_and(|content| is_generated_skills(&content))
{
return Err(
"The selected vault already contains an unrelated skills.md. Move or rename it before DS4Server creates the generated Dev Brain skill index."
.into(),
);
}
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 [
publish_defaults(
vault,
&[
("purpose.md", DEFAULT_PURPOSE),
("schema.md", DEFAULT_SCHEMA),
("index.md", DEFAULT_INDEX),
("skills.md", DEFAULT_SKILLS),
("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::<BTreeSet<_>>();
commit_batch(vault, &staging, &changed)
})();
let _ = fs::remove_dir_all(staging);
result?;
],
)?;
} else if !skills_path.is_file() {
publish_defaults(vault, &[("skills.md", DEFAULT_SKILLS)])?;
}
for directory in TOPIC_DIRS {
fs::create_dir_all(vault.join(directory)).map_err(|error| error.to_string())?;
@@ -709,6 +801,32 @@ fn ensure_contract(vault: &Path) -> Result<(), String> {
Ok(())
}
fn is_generated_skills(content: &str) -> bool {
content == DEFAULT_SKILLS
|| content.starts_with(
"# Dev Brain skills\n\n<!-- Generated by dev_brain_validate; edit skill pages, not this list. -->\n",
)
}
fn publish_defaults(vault: &Path, files: &[(&str, &str)]) -> Result<(), String> {
publish_files(vault, "contract", files)
}
fn publish_files(vault: &Path, label: &str, files: &[(&str, &str)]) -> Result<(), String> {
let staging = temporary_sibling(vault, label)?;
fs::create_dir(&staging).map_err(|error| error.to_string())?;
let result = (|| {
let mut changed = BTreeSet::new();
for (path, content) in files {
fs::write(staging.join(path), content).map_err(|error| error.to_string())?;
changed.insert(PathBuf::from(path));
}
commit_batch(vault, &staging, &changed)
})();
let _ = fs::remove_dir_all(staging);
result
}
pub(crate) fn validate_vault(path: &Path) -> Result<PathBuf, String> {
let vault = path
.canonicalize()
@@ -788,6 +906,8 @@ fn load_and_validate_pages(
sources: Vec::new(),
body: to_plain_text(&content),
content,
skill_name: None,
skill_description: None,
});
continue;
}
@@ -804,6 +924,7 @@ fn load_and_validate_pages(
));
}
validate_page_type(&path, &frontmatter.page_type)?;
validate_skill_metadata(&frontmatter, &relative)?;
if !matches!(
frontmatter.status.as_str(),
"verified" | "stale" | "needs-review"
@@ -866,6 +987,8 @@ fn load_and_validate_pages(
sources: frontmatter.sources,
body: to_plain_text(&content),
content,
skill_name: frontmatter.name,
skill_description: frontmatter.description,
});
}
let mut identities = HashMap::new();
@@ -886,6 +1009,14 @@ fn load_and_validate_pages(
}
}
}
let mut skills = HashSet::new();
if let Some(duplicate) = pages
.iter()
.filter_map(|page| page.skill_name.as_deref())
.find(|name| !skills.insert((*name).to_owned()))
{
return Err(format!("Duplicate Dev Brain skill name: {duplicate}"));
}
Ok(pages)
}
@@ -970,6 +1101,35 @@ fn render_index(pages: &[Page]) -> String {
output
}
fn render_skills(pages: &[Page]) -> String {
let mut skills = pages
.iter()
.filter(|page| page.page_type == "skill" && page.status == "verified")
.collect::<Vec<_>>();
if skills.is_empty() {
return DEFAULT_SKILLS.to_owned();
}
skills.sort_by(|left, right| left.skill_name.cmp(&right.skill_name));
let mut output = String::from(
"# Dev Brain skills\n\n<!-- Generated by dev_brain_validate; edit skill pages, not this list. -->\n",
);
for skill in skills {
output.push_str(&format!(
"\n- [[{}|{}]] — {}\n",
skill.path,
skill.skill_name.as_deref().unwrap_or_default(),
skill
.skill_description
.as_deref()
.unwrap_or_default()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
));
}
output
}
fn title_case(value: &str) -> String {
let mut characters = value.chars();
characters
@@ -1211,6 +1371,7 @@ fn validate_page_type(path: &Path, page_type: &str) -> Result<(), String> {
"decision" => expected == "decisions",
"invariant" => expected == "invariants",
"workflow" => expected == "workflows",
"skill" => expected == "skills",
_ => false,
};
valid.then_some(()).ok_or_else(|| {
@@ -1221,6 +1382,43 @@ fn validate_page_type(path: &Path, page_type: &str) -> Result<(), String> {
})
}
fn validate_skill_metadata(frontmatter: &TopicFrontmatter, path: &str) -> Result<(), String> {
if frontmatter.page_type != "skill" {
if frontmatter.name.is_some() || frontmatter.description.is_some() {
return Err(format!(
"{path} may use name and description only when type is skill."
));
}
return Ok(());
}
let name = frontmatter
.name
.as_deref()
.ok_or_else(|| format!("{path} skill frontmatter requires name."))?;
let valid_name = (1..=64).contains(&name.len())
&& !name.starts_with('-')
&& !name.ends_with('-')
&& !name.contains("--")
&& name
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-');
if !valid_name {
return Err(format!(
"{path} skill name must contain 1-64 lowercase letters, digits, or hyphens without leading, trailing, or consecutive hyphens."
));
}
let description = frontmatter
.description
.as_deref()
.ok_or_else(|| format!("{path} skill frontmatter requires description."))?;
if description.trim().is_empty() || description.chars().count() > 1024 {
return Err(format!(
"{path} skill description must contain 1-1024 characters."
));
}
Ok(())
}
fn normalize_managed_path(value: &str) -> Result<PathBuf, String> {
let path = normalize_relative_path(value)?;
if path.extension().is_none_or(|extension| extension != "md") {
@@ -1504,6 +1702,19 @@ mod tests {
"---\ndev_brain: true\ntype: concept\nproject: Fixture\nstatus: {status}\nverified_at: 2026-07-27T12:00:00Z\nsources:\n - project: Fixture\n path: source.rs\n hash: {hash}\n---\n\n# Answer\n\n{body}\n"
)
}
fn skill(
&self,
status: &str,
hash: &str,
name: &str,
description: &str,
title: &str,
) -> String {
format!(
"---\ndev_brain: true\ntype: skill\nname: {name}\ndescription: {description}\nproject: Fixture\nstatus: {status}\nverified_at: 2026-07-27T12:00:00Z\nsources:\n - project: Fixture\n path: source.rs\n hash: {hash}\n---\n\n# {title}\n\nRead the source and follow its release procedure.\n"
)
}
}
impl Drop for Fixture {
@@ -1528,9 +1739,132 @@ mod tests {
fs::write(fixture.vault.join("concepts/private.md"), "# Private\n").unwrap();
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(&brain.folder().join("skills/new.md")));
assert!(!brain.allows_tool_write(&fixture.root.join("outside.md")));
}
#[test]
fn skill_index_migration_does_not_overwrite_an_unrelated_root_note() {
let fixture = Fixture::new();
for (path, content) in [
("purpose.md", DEFAULT_PURPOSE),
("schema.md", DEFAULT_SCHEMA),
("index.md", DEFAULT_INDEX),
("log.md", DEFAULT_LOG),
] {
fs::write(fixture.vault.join(path), content).unwrap();
}
fs::write(fixture.vault.join("skills.md"), "# Personal skills\n").unwrap();
let error = DevBrain::open(&fixture.config(), &fixture.projects())
.err()
.unwrap();
assert!(error.contains("unrelated skills.md"));
assert_eq!(
fs::read_to_string(fixture.vault.join("skills.md")).unwrap(),
"# Personal skills\n"
);
}
#[test]
fn verified_skills_are_indexed_and_injected_on_demand() {
let fixture = Fixture::new();
let brain = fixture.brain();
assert!(fixture.vault.join("skills").is_dir());
assert_eq!(
fs::read_to_string(fixture.vault.join("skills.md")).unwrap(),
DEFAULT_SKILLS
);
drop(brain);
fs::remove_file(fixture.vault.join("skills.md")).unwrap();
let mut brain = fixture.brain();
assert_eq!(
fs::read_to_string(fixture.vault.join("skills.md")).unwrap(),
DEFAULT_SKILLS
);
let hash = source_hash(&fixture);
fs::write(
fixture.vault.join("skills/release.md"),
fixture.skill(
"verified",
&hash,
"review-release",
"Verify a release candidate. Use before publishing a release.",
"Review release",
),
)
.unwrap();
fs::write(
fixture.vault.join("skills/draft.md"),
fixture.skill(
"needs-review",
&hash,
"draft-release",
"Draft release notes. Use when preparing a release.",
"Draft release",
),
)
.unwrap();
brain.validate().unwrap();
let index = fs::read_to_string(fixture.vault.join("skills.md")).unwrap();
assert!(index.contains("[[skills/release.md|review-release]]"));
assert!(index.contains("Verify a release candidate"));
assert!(!index.contains("draft-release"));
let prompt = skills_prompt(&fixture.config(), &fixture.projects()).unwrap();
assert!(prompt.contains(&fixture.vault.to_string_lossy().to_string()));
assert!(prompt.contains("[[skills/release.md|review-release]]"));
fs::write(fixture.project.join("source.rs"), "changed\n").unwrap();
let prompt = skills_prompt(&fixture.config(), &fixture.projects()).unwrap();
assert!(!prompt.contains("review-release"));
assert_eq!(
fs::read_to_string(fixture.vault.join("skills.md")).unwrap(),
DEFAULT_SKILLS
);
}
#[test]
fn skill_validation_and_default_guide_restore_are_strict() {
let fixture = Fixture::new();
let mut brain = fixture.brain();
let hash = source_hash(&fixture);
fs::write(
fixture.vault.join("skills/invalid.md"),
fixture.skill(
"verified",
&hash,
"Invalid_Name",
"Invalid name should be rejected.",
"Invalid",
),
)
.unwrap();
assert!(brain.validate().unwrap_err().contains("skill name"));
fs::remove_file(fixture.vault.join("skills/invalid.md")).unwrap();
fs::write(fixture.vault.join("purpose.md"), "custom purpose\n").unwrap();
fs::write(fixture.vault.join("schema.md"), "custom schema\n").unwrap();
fs::write(fixture.vault.join("log.md"), "preserved log\n").unwrap();
fs::write(fixture.vault.join("index.md"), "preserved index\n").unwrap();
restore_default_guides(&fixture.vault).unwrap();
assert_eq!(
fs::read_to_string(fixture.vault.join("purpose.md")).unwrap(),
DEFAULT_PURPOSE
);
assert_eq!(
fs::read_to_string(fixture.vault.join("schema.md")).unwrap(),
DEFAULT_SCHEMA
);
assert_eq!(
fs::read_to_string(fixture.vault.join("log.md")).unwrap(),
"preserved log\n"
);
assert_eq!(
fs::read_to_string(fixture.vault.join("index.md")).unwrap(),
"preserved index\n"
);
}
#[test]
fn parser_and_index_rebuild_ignore_links_in_code() {
let fixture = Fixture::new();