- 1
//! Shared capability seeds, applied by **setup** and reconciled by install/update. - 2
//! - 3
//! Lives in `vak-core` because every surface that can run setup needs it: - 4
//! the CLI (`vak setup seed`) and the web wizard (`POST /onboarding/seed`) - 5
//! must install the same seeds the same way, and a copy in the binary - 6
//! crate could only ever serve one of them. - 7
//! - 8
//! Placing binaries used to seed skills, plugins, and a disabled hook as a - 9
//! side effect (`docs/design/46-stabilization-install-and-onboarding.md` - 10
//! D6). That had two defects beyond the contract violation: it only ran - 11
//! when the prefix happened to equal the platform default, so any - 12
//! `--prefix` install silently got nothing; and it never ran on update, so - 13
//! a seed shipped in a release reached nobody who upgraded. Setup owns it - 14
//! now, against the workspace the operator actually chose. Updates run the - 15
//! same reconciliation so newly shipped standard capabilities reach existing - 16
//! workspaces without overwriting user edits. - 17
- 18
use sha2::{Digest, Sha256}; - 19
use std::collections::BTreeMap; - 20
use std::path::Path; - 21
- 22
use vak_config::HookConfig; - 23
use vak_plugin::{InstallOptions, InstallScope, PluginStore}; - 24
- 25
/// name, description, guidance body, `serves:` domains (in `Domain::parse` - 26
/// vocabulary). A skill declares what it is for itself, in its own - 27
/// frontmatter, exactly as a plugin or user-authored skill would — there is - 28
/// no name-keyed table anywhere that grants these seeds special treatment. - 29
const SKILLS: &[(&str, &str, &str, &[&str])] = &[ - 30
( - 31
"getting-started", - 32
"Explain tasks clearly and help a new user choose the simplest next step.", - 33
"Translate jargon into plain language. Ask only for information that is genuinely needed, then present a short, actionable next step before optional detail.", - 34
&["documents"], - 35
), - 36
( - 37
"research-and-sources", - 38
"Research a question with traceable sources and clearly separated evidence and inference.", - 39
"Define the question and freshness requirement, prefer primary sources, record publication dates, and distinguish sourced facts from your own synthesis. Never present an unverified assumption as a citation.", - 40
&["web", "live-data"], - 41
), - 42
( - 43
"planning-and-organizing", - 44
"Turn goals into practical plans, checklists, and prioritised next actions.", - 45
"Clarify the desired outcome, identify dependencies and decisions, then produce a plan sized to the work. Keep ownership, deadlines, and open questions explicit.", - 46
&["orchestration"], - 47
), - 48
( - 49
"debugging", - 50
"Diagnose failures from evidence before proposing or applying a fix.", - 51
"Reproduce or isolate the failure, capture the first meaningful error, trace inputs to the failing boundary, and test the smallest fix. Separate confirmed cause from hypotheses.", - 52
&["code-exec", "observability"], - 53
), - 54
( - 55
"code-review", - 56
"Review code for correctness, security, regressions, and maintainability with actionable findings.", - 57
"Read the diff in context, prioritise concrete defects over style preferences, include impact and a precise location, and say when a concern is unverified rather than overstating it.", - 58
&["vcs", "documents"], - 59
), - 60
( - 61
"data-and-spreadsheets", - 62
"Clean, analyse, and explain tabular data without silently changing its meaning.", - 63
"Inspect headers, types, missing values, and units before transforming data. Keep source data intact, make calculations reproducible, and label estimates, exclusions, and assumptions.", - 64
&["documents", "code-exec"], - 65
), - 66
]; - 67
- 68
const PLUGINS: &[(&str, &str, &str, &str)] = &[ - 69
( - 70
"developer-starter", - 71
"1.0.0", - 72
"Core developer workflows for implementation, debugging, and code review.", - 73
"software-development", - 74
), - 75
( - 76
"everyday-starter", - 77
"1.0.0", - 78
"Plain-language writing, planning, research, and data help for everyday work.", - 79
"writing-and-editing", - 80
), - 81
]; - 82
- 83
const PLUGIN_SKILLS: &[(&str, &str, &str, &[&str])] = &[ - 84
( - 85
"software-development", - 86
"Implement and explain software changes with focused verification and clear tradeoffs.", - 87
"Inspect the existing conventions first. Make the smallest coherent change, preserve public contracts, add targeted tests for changed behavior, and report exactly what was verified.", - 88
&["code-exec", "documents", "vcs", "filesystem"], - 89
), - 90
( - 91
"writing-and-editing", - 92
"Draft, rewrite, summarize, and polish documents while preserving the requested voice.", - 93
"First identify audience, purpose, and format. Preserve facts and explicit constraints, make the smallest useful edit, and call out material ambiguities instead of inventing details.", - 94
&["documents"], - 95
), - 96
]; - 97
- 98
const SEED_MANIFEST: &str = ".seed-manifest.json"; - 99
- 100
pub fn seed_shared_capabilities() -> Result<(), String> { - 101
let root = vak_config::paths::default_workspace().join(".vak"); - 102
seed_skills(&root.join("skills")) - 103
.map_err(|error| format!("Shared skill seed failed: {error}"))?; - 104
seed_plugins(&root).map_err(|error| format!("Shared plugin seed failed: {error}"))?; - 105
cleanup_retired_plugins(&root) - 106
.map_err(|error| format!("Retired plugin cleanup failed: {error}"))?; - 107
let hooks = [HookConfig { - 108
event: "session_start".into(), - 109
matcher: None, - 110
command: "/usr/bin/true".into(), - 111
timeout_ms: Some(1_000), - 112
enabled: false, - 113
failure_mode: Some("open".into()), - 114
}]; - 115
vak_config::seed_global_hooks_if_empty(&hooks) - 116
.map_err(|error| format!("Shared automation seed failed: {error}"))?; - 117
vak_config::seed_global_plugins_network_allow_if_empty() - 118
.map_err(|error| format!("Shared plugin network seed failed: {error}"))?; - 119
Ok(()) - 120
} - 121
- 122
/// Remove plugin packages whose skill descriptions reference retired tool - 123
/// names (e.g. `python_eval`, `react_preview`). These plugins were shipped - 124
/// before the tool interface was unified on `bash` and their SKILL.md files - 125
/// still instruct the model to call tools that no longer exist — which - 126
/// causes `unknown_capability` errors and model hallucinations of tool - 127
/// output (docs/design/53, AGENTS.md invariants 9 and 29). - 128
/// - 129
/// This runs during setup and update so stale plugins never survive a - 130
/// version bump. It also prunes stale `network_allow` entries so the - 131
/// retained allowlist stays consistent with the on-disk plugin store. - 132
fn cleanup_retired_plugins(root: &Path) -> Result<(), Box<dyn std::error::Error>> { - 133
let store = vak_plugin::PluginStore::new(root); - 134
let flagged = store.retired_plugins()?; - 135
for (name, retired_tools) in &flagged { - 136
eprintln!( - 137
"removing retired plugin '{name}' (references retired tools: {})", - 138
retired_tools.join(", ") - 139
); - 140
store - 141
.remove(name) - 142
.map_err(|error| format!("could not remove retired plugin '{name}': {error}"))?; - 143
// Prune stale network_allow entries for the removed plugin. Leaving - 144
// this behind would advertise a capability that no longer exists. - 145
let config = root.join("config.toml"); - 146
if config.is_file() { - 147
vak_config::prune_plugins_network_allow(&config, name) - 148
.map_err(|error| format!("could not prune network_allow for '{name}': {error}"))?; - 149
} - 150
} - 151
Ok(()) - 152
} - 153
- 154
/// Render a seeded `SKILL.md`. `serves` is the skill's own classification of - 155
/// what it is for — front-matter it writes about itself, in the same - 156
/// `serves:` field a plugin or user-authored skill would use. An empty list - 157
/// stays undeclared, exactly as an unclassified skill from any other source - 158
/// would. - 159
fn skill_markdown(name: &str, description: &str, body: &str, serves: &[&str]) -> String { - 160
if serves.is_empty() { - 161
format!("---\nname: {name}\ndescription: {description}\n---\n\n{body}\n") - 162
} else { - 163
format!( - 164
"---\nname: {name}\ndescription: {description}\nserves: {}\n---\n\n{body}\n", - 165
serves.join(", ") - 166
) - 167
} - 168
} - 169
- 170
fn seed_skills(root: &Path) -> std::io::Result<()> { - 171
std::fs::create_dir_all(root)?; - 172
let manifest_root = root.parent().unwrap_or(root); - 173
let mut shipped = load_seed_manifest(manifest_root); - 174
let previous = shipped.clone(); - 175
for (name, description, body, serves) in PLUGIN_SKILLS { - 176
let path = root.join(name).join("SKILL.md"); - 177
let expected = skill_markdown(name, description, body, serves); - 178
if path.is_file() - 179
&& matches!(std::fs::read_to_string(&path), Ok(content) if content == expected) - 180
{ - 181
std::fs::remove_file(&path)?; - 182
let _ = std::fs::remove_dir(path.parent().unwrap_or(root)); - 183
} - 184
} - 185
for (name, description, body, serves) in SKILLS { - 186
let dir = root.join(name); - 187
std::fs::create_dir_all(&dir)?; - 188
let path = dir.join("SKILL.md"); - 189
let content = skill_markdown(name, description, body, serves); - 190
let expected = content.as_bytes(); - 191
let expected_digest = digest(expected); - 192
match std::fs::read(&path) { - 193
Ok(current) if digest(¤t) == expected_digest => { - 194
shipped.insert(name.to_string(), expected_digest); - 195
} - 196
Ok(current) => { - 197
// Only advance a seed when the file still equals the last - 198
// bytes we shipped. An untracked pre-existing file is treated - 199
// as user-owned and is never overwritten. - 200
if previous - 201
.get(*name) - 202
.is_some_and(|old| *old == digest(¤t)) - 203
{ - 204
std::fs::write(&path, expected)?; - 205
shipped.insert(name.to_string(), expected_digest); - 206
} - 207
} - 208
Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - 209
std::fs::write(&path, expected)?; - 210
shipped.insert(name.to_string(), expected_digest); - 211
} - 212
Err(error) => return Err(error), - 213
} - 214
} - 215
write_seed_manifest(manifest_root, &shipped)?; - 216
Ok(()) - 217
} - 218
- 219
fn digest(bytes: &[u8]) -> String { - 220
let mut hasher = Sha256::new(); - 221
hasher.update(bytes); - 222
format!("sha256:{:x}", hasher.finalize()) - 223
} - 224
- 225
fn load_seed_manifest(root: &Path) -> BTreeMap<String, String> { - 226
std::fs::read(root.join(SEED_MANIFEST)) - 227
.ok() - 228
.and_then(|bytes| serde_json::from_slice(&bytes).ok()) - 229
.unwrap_or_default() - 230
} - 231
- 232
fn write_seed_manifest(root: &Path, shipped: &BTreeMap<String, String>) -> std::io::Result<()> { - 233
let bytes = serde_json::to_vec_pretty(shipped).map_err(std::io::Error::other)?; - 234
let manifest_path = root.join(SEED_MANIFEST); - 235
let temporary = manifest_path.with_extension("json.tmp"); - 236
std::fs::write(&temporary, bytes)?; - 237
std::fs::rename(temporary, manifest_path)?; - 238
Ok(()) - 239
} - 240
- 241
fn seed_plugins(root: &Path) -> Result<(), Box<dyn std::error::Error>> { - 242
let store = PluginStore::new(root); - 243
let mut shipped = load_seed_manifest(root); - 244
// Stage inside the plugin root rather than the system temp dir: same - 245
// filesystem as the destination, so installing is a rename and never a - 246
// cross-device copy — the same reason the installer stages inside its - 247
// own prefix. - 248
let staging = root.join(".seed-staging"); - 249
let _ = std::fs::remove_dir_all(&staging); - 250
for (name, version, description, skill_name) in PLUGINS { - 251
let package = staging.join(name); - 252
let skill_path = package.join(format!("skills/{skill_name}")); - 253
std::fs::create_dir_all(&skill_path)?; - 254
std::fs::write( - 255
package.join("vak-plugin.json"), - 256
format!( - 257
r#"{{"schema":1,"name":"{name}","version":"{version}","description":"{description}","license":"MIT","components":{{"skills":["skills"]}}}}"# - 258
), - 259
)?; - 260
let (skill_description, body, serves) = PLUGIN_SKILLS - 261
.iter() - 262
.find(|(candidate, _, _, _)| candidate == skill_name) - 263
.map(|(_, description, body, serves)| (*description, *body, *serves)) - 264
.ok_or_else(|| format!("missing seed skill {skill_name}"))?; - 265
std::fs::write( - 266
skill_path.join("SKILL.md"), - 267
skill_markdown(skill_name, skill_description, body, serves), - 268
)?; - 269
if let Some(existing) = store - 270
.list()? - 271
.into_iter() - 272
.find(|plugin| plugin.name == *name) - 273
{ - 274
let staged_digest = digest_of_directory(&package)?; - 275
let installed_digest = digest_of_directory(&existing.package_path).ok(); - 276
if existing.digest == staged_digest { - 277
shipped.insert(format!("plugin:{name}"), existing.digest); - 278
} else if installed_digest.as_deref() == Some(existing.digest.as_str()) - 279
&& shipped.get(&format!("plugin:{name}")) == Some(&existing.digest) - 280
{ - 281
let installed = store.update_local( - 282
&package, - 283
InstallOptions { - 284
scope: InstallScope::User, - 285
allow_unlicensed: false, - 286
}, - 287
)?; - 288
if !installed.enabled { - 289
let _ = store.enable(name)?; - 290
} - 291
shipped.insert(format!("plugin:{name}"), installed.digest); - 292
} - 293
continue; - 294
} - 295
let installed = store.install_local( - 296
&package, - 297
InstallOptions { - 298
scope: InstallScope::User, - 299
allow_unlicensed: false, - 300
}, - 301
)?; - 302
if !installed.enabled { - 303
let _ = store.enable(name)?; - 304
} - 305
shipped.insert(format!("plugin:{name}"), installed.digest); - 306
} - 307
let _ = std::fs::remove_dir_all(&staging); - 308
write_seed_manifest(root, &shipped)?; - 309
Ok(()) - 310
} - 311
- 312
fn digest_of_directory(root: &Path) -> Result<String, Box<dyn std::error::Error>> { - 313
let inspection = vak_plugin::inspect_package(root)?; - 314
Ok(inspection.digest) - 315
} - 316
- 317
#[cfg(test)] - 318
mod tests { - 319
#![allow(clippy::expect_used)] - 320
- 321
use super::*; - 322
- 323
#[test] - 324
fn seed_manifest_tracks_standard_skills_without_clobbering_edits() { - 325
let _home = vak_config::paths::isolate_home_for_tests(); - 326
let _ = seed_shared_capabilities(); - 327
let root = vak_config::paths::default_workspace().join(".vak"); - 328
let manifest: BTreeMap<String, String> = serde_json::from_slice( - 329
&std::fs::read(root.join(SEED_MANIFEST)).expect("seed manifest"), - 330
) - 331
.expect("valid seed manifest"); - 332
assert_eq!(manifest.len(), SKILLS.len() + PLUGINS.len()); - 333
- 334
let edited = root.join("skills/debugging/SKILL.md"); - 335
let before = std::fs::read(&edited).expect("seed skill"); - 336
std::fs::write(&edited, [before.as_slice(), b"\noperator edit\n"].concat()) - 337
.expect("edit seed skill"); - 338
let _ = seed_shared_capabilities(); - 339
let after = std::fs::read(&edited).expect("edited seed skill"); - 340
assert!(after.ends_with(b"\noperator edit\n")); - 341
- 342
let plugins = PluginStore::new(&root).list().expect("seed plugins"); - 343
let package = plugins - 344
.iter() - 345
.find(|plugin| plugin.name == "developer-starter") - 346
.expect("developer starter") - 347
.package_path - 348
.join("operator-note.txt"); - 349
std::fs::write(&package, "operator edit\n").expect("edit plugin package"); - 350
let _ = seed_shared_capabilities(); - 351
assert!(package.is_file(), "edited plugin package was overwritten"); - 352
} - 353
} - 354
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.