From 2391e2a68092586d1e266515f0c9b42b98f14403 Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Tue, 28 Jul 2026 18:57:08 +0200 Subject: [PATCH] Add verified Dev Brain skills --- README.md | 4 + src/app.rs | 7 + src/app/generation.rs | 19 ++ src/app/view/preferences.rs | 12 + src/dev_brain.rs | 438 +++++++++++++++++++++++++++++++----- 5 files changed, 428 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 5f92172..706aa04 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/app.rs b/src/app.rs index 14b080c..ba55c6e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -358,6 +358,7 @@ pub(crate) enum Message { PreferenceGitIgnoreBlankLinesChanged(bool), ChooseDevBrainVault, DevBrainVaultPicked(Option), + 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; diff --git a/src/app/generation.rs b/src/app/generation.rs index 1cc39c5..11ca25f 100644 --- a/src/app/generation.rs +++ b/src/app/generation.rs @@ -407,6 +407,21 @@ impl App { prompt } + fn dev_brain_skills_prompt(&self) -> Option { + self.config.dev_brain.enabled.then(|| { + let projects = self + .projects + .iter() + .map(|project| project.project.clone()) + .collect::>(); + 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()); } diff --git a/src/app/view/preferences.rs b/src/app/view/preferences.rs index 6c43d41..5139251 100644 --- a/src/app/view/preferences.rs +++ b/src/app/view/preferences.rs @@ -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), ); diff --git a/src/dev_brain.rs b/src/dev_brain.rs index 752a0f1..30f4a64 100644 --- a/src/dev_brain.rs +++ b/src/dev_brain.rs @@ -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\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 1–64 lowercase letters, digits, or hyphens, with no leading, trailing, or consecutive hyphens. Descriptions contain 1–1024 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, + #[serde(default)] + name: Option, + #[serde(default)] + description: Option, } #[derive(Clone)] @@ -140,6 +167,8 @@ struct Page { sources: Vec, body: String, content: String, + skill_name: Option, + skill_description: Option, } #[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::, 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 { + 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, 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::, 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::>(); - 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\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 { 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::>(); + 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\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::>() + .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 { 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();