- 1
//! Learning loop (docs/design/26-learning.md): the `remember` tool appends - 2
//! durable notes; `propose_skill` queues skill drafts for human promotion. - 3
//! Proposals never enter discovery by themselves — promotion is an explicit - 4
//! human action over HTTP or CLI. - 5
- 6
use std::path::{Path, PathBuf}; - 7
- 8
use serde_json::Value; - 9
- 10
use crate::memory; - 11
- 12
const KINDS: [&str; 5] = ["fact", "decision", "preference", "reference", "invariant"]; - 13
- 14
// ---- remember --------------------------------------------------------------- - 15
- 16
pub struct RememberTool { - 17
pub sessions_home: PathBuf, - 18
pub cwd: PathBuf, - 19
pub session_id: String, - 20
} - 21
- 22
#[async_trait::async_trait] - 23
impl vak_tools::Tool for RememberTool { - 24
fn name(&self) -> &str { - 25
"remember" - 26
} - 27
- 28
fn serves(&self) -> &'static [&'static str] { - 29
&["memory"] - 30
} - 31
- 32
fn description(&self) -> &str { - 33
"Persist a durable note about this workspace for FUTURE sessions \ - 34
(decisions, facts, preferences, pointers). Use sparingly for things \ - 35
worth remembering after this conversation ends — not transient \ - 36
details. Notes are recalled via session_search and are visible to \ - 37
the user, who can edit them." - 38
} - 39
- 40
fn schema(&self) -> Value { - 41
serde_json::json!({ - 42
"type": "object", - 43
"properties": { - 44
"note": {"type": "string", "description": "The content to persist"}, - 45
"kind": {"type": "string", "enum": KINDS.to_vec(), - 46
"description": "One of fact/decision/preference/reference/invariant (default fact)"}, - 47
"tag": {"type": "string", "description": "Short slug for grouping, e.g. 'deploy-rollbacks'"} - 48
}, - 49
"required": ["note"] - 50
}) - 51
} - 52
- 53
async fn execute(&self, args: &Value, _ctx: &vak_tools::ToolContext) -> vak_tools::ToolOutput { - 54
let Some(note) = args.get("note").and_then(Value::as_str).map(str::trim) else { - 55
return vak_tools::ToolOutput::error("missing required argument 'note'"); - 56
}; - 57
let kind = args.get("kind").and_then(Value::as_str).unwrap_or("fact"); - 58
if !KINDS.contains(&kind) { - 59
return vak_tools::ToolOutput::error(format!( - 60
"unknown kind '{kind}'; expected one of {KINDS:?}" - 61
)); - 62
} - 63
let tag = args.get("tag").and_then(Value::as_str).unwrap_or(""); - 64
match memory::append_note( - 65
&self.sessions_home, - 66
&self.cwd, - 67
kind, - 68
tag, - 69
&self.session_id, - 70
note, - 71
) { - 72
Ok(_) => vak_tools::ToolOutput::ok(format!( - 73
"remembered ({kind}{tag_suffix}). It will surface in future session_search queries.", - 74
tag_suffix = if tag.is_empty() { - 75
String::new() - 76
} else { - 77
format!(", tag '{tag}'") - 78
} - 79
)), - 80
Err(e) => vak_tools::ToolOutput::error(format!("could not persist note: {e}")), - 81
} - 82
} - 83
- 84
fn claims(&self, _args: &Value) -> vak_tools::ResourceClaims { - 85
vak_tools::ResourceClaims { - 86
exclusive: true, - 87
read_only: false, - 88
paths: vec![], - 89
} - 90
} - 91
} - 92
- 93
// ---- propose_skill ---------------------------------------------------------- - 94
- 95
pub struct SkillProposal { - 96
pub id: String, - 97
pub name: String, - 98
pub description: String, - 99
pub path: PathBuf, - 100
} - 101
- 102
fn proposals_dir(home: &Path, cwd: &Path) -> PathBuf { - 103
home.join("skill-proposals").join(memory::hash_cwd(cwd)) - 104
} - 105
- 106
pub struct ProposeSkillTool { - 107
pub sessions_home: PathBuf, - 108
pub cwd: PathBuf, - 109
pub session_id: String, - 110
} - 111
- 112
#[async_trait::async_trait] - 113
impl vak_tools::Tool for ProposeSkillTool { - 114
fn name(&self) -> &str { - 115
"propose_skill" - 116
} - 117
- 118
fn serves(&self) -> &'static [&'static str] { - 119
&["memory"] - 120
} - 121
- 122
fn description(&self) -> &str { - 123
"Draft a reusable SKILL from something learned this session (a \ - 124
procedure that worked, a gotcha and its fix). Goes to a review \ - 125
queue — it becomes available to future runs ONLY after the user \ - 126
promotes it. Do not propose one-off steps." - 127
} - 128
- 129
fn schema(&self) -> Value { - 130
serde_json::json!({ - 131
"type": "object", - 132
"properties": { - 133
"name": {"type": "string", - 134
"description": "kebab-case identifier, e.g. 'rotate-release-tags'"}, - 135
"description": {"type": "string", - 136
"description": "One line: what it is for and when to use it"}, - 137
"instructions": {"type": "string", - 138
"description": "Markdown procedure the future agent should follow"} - 139
}, - 140
"required": ["name", "description", "instructions"] - 141
}) - 142
} - 143
- 144
async fn execute(&self, args: &Value, _ctx: &vak_tools::ToolContext) -> vak_tools::ToolOutput { - 145
let Some(name) = sanitize_name(args.get("name").and_then(Value::as_str).unwrap_or("")) - 146
else { - 147
return vak_tools::ToolOutput::error( - 148
"'name' must be kebab-case (lowercase letters, digits, dashes)", - 149
); - 150
}; - 151
let Some(description) = args - 152
.get("description") - 153
.and_then(Value::as_str) - 154
.map(str::trim) - 155
else { - 156
return vak_tools::ToolOutput::error("missing required argument 'description'"); - 157
}; - 158
let Some(instructions) = args - 159
.get("instructions") - 160
.and_then(Value::as_str) - 161
.map(str::trim) - 162
else { - 163
return vak_tools::ToolOutput::error("missing required argument 'instructions'"); - 164
}; - 165
if description.is_empty() || instructions.is_empty() { - 166
return vak_tools::ToolOutput::error( - 167
"'description' and 'instructions' must not be empty", - 168
); - 169
} - 170
- 171
let id = uuid::Uuid::now_v7().simple().to_string(); - 172
let dir = proposals_dir(&self.sessions_home, &self.cwd); - 173
if let Err(e) = std::fs::create_dir_all(&dir) { - 174
return vak_tools::ToolOutput::error(format!("create proposals dir: {e}")); - 175
} - 176
let dup_line = match duplicate_of( - 177
&name, - 178
prose(instructions), - 179
&accepted_skill_bodies(&self.sessions_home, &self.cwd), - 180
) { - 181
Some(dup) => format!("{DUPLICATE_KEY}: \"{dup}\"\n"), - 182
None => String::new(), - 183
}; - 184
let body = format!( - 185
"---\nname: \"{name}\"\ndescription: \"{desc}\"\n{dup_line}---\n\n{instr}\n\n<!-- proposed-by: {sid} at {ts}; proposal id {id} -->\n", - 186
desc = description.replace('"', "'"), - 187
instr = instructions, - 188
sid = self.session_id, - 189
ts = chrono::Utc::now().to_rfc3339(), - 190
); - 191
let path = dir.join(format!("{id}.md")); - 192
if let Err(e) = std::fs::write(&path, body) { - 193
return vak_tools::ToolOutput::error(format!("write proposal: {e}")); - 194
} - 195
vak_tools::ToolOutput::ok(format!( - 196
"skill '{name}' queued for review as proposal {id}. It will NOT be \ - 197
available until the user promotes it." - 198
)) - 199
} - 200
- 201
fn claims(&self, _args: &Value) -> vak_tools::ResourceClaims { - 202
vak_tools::ResourceClaims { - 203
exclusive: true, - 204
read_only: false, - 205
paths: vec![], - 206
} - 207
} - 208
} - 209
- 210
/// Kebab-case enforcement: lowercase letters/digits/dashes only, at least - 211
/// one letter, no leading/trailing dash. - 212
pub fn sanitize_name(raw: &str) -> Option<String> { - 213
let name = raw.trim().to_lowercase(); - 214
if name.is_empty() - 215
|| name.len() > 64 - 216
|| !name - 217
.chars() - 218
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') - 219
|| name.starts_with('-') - 220
|| name.ends_with('-') - 221
|| !name.chars().any(|c| c.is_ascii_alphabetic()) - 222
{ - 223
return None; - 224
} - 225
Some(name) - 226
} - 227
- 228
// ---- Review queue API ------------------------------------------------------- - 229
- 230
fn list_proposals_in_dir(dir: &Path, home: &Path, cwd: &Path) -> Vec<SkillProposal> { - 231
let Ok(entries) = std::fs::read_dir(dir) else { - 232
return Vec::new(); - 233
}; - 234
let mut found = Vec::new(); - 235
for entry in entries.flatten() { - 236
let path = entry.path(); - 237
let Some(id) = path.file_stem().and_then(|s| s.to_str()).map(String::from) else { - 238
continue; - 239
}; - 240
let Some(skill) = crate::skills::parse(&path) else { - 241
continue; - 242
}; - 243
found.push((id, path, skill)); - 244
} - 245
let mut out = Vec::with_capacity(found.len()); - 246
if !found.is_empty() { - 247
let accepted = accepted_skill_bodies(home, cwd); - 248
for (id, path, skill) in found { - 249
let tag = screen_proposal(&path, &skill.name, &accepted); - 250
out.push(SkillProposal { - 251
id, - 252
name: skill.name, - 253
description: with_duplicate_note(&skill.description, tag.as_deref()), - 254
path, - 255
}); - 256
} - 257
} - 258
out - 259
} - 260
- 261
/// Pending drafts, newest first. When a proposal screens as a near copy of - 262
/// an accepted skill, the returned `description` carries a - 263
/// `[duplicate-of: <name>]` suffix (every review surface renders the - 264
/// description) and the flag persists as a `duplicate-of:` frontmatter line - 265
/// so hand edits and later listings agree. - 266
pub fn list_proposals(home: &Path, cwd: &Path) -> Vec<SkillProposal> { - 267
let mut proposals = list_proposals_in_dir(&proposals_dir(home, cwd), home, cwd); - 268
if proposals.is_empty() { - 269
let agents_dir = home.join("agents"); - 270
if let Ok(entries) = std::fs::read_dir(&agents_dir) { - 271
for entry in entries.flatten() { - 272
let p = entry.path(); - 273
if p.is_dir() { - 274
let agent_props = list_proposals_in_dir(&proposals_dir(&p, cwd), home, cwd); - 275
proposals.extend(agent_props); - 276
} - 277
} - 278
} - 279
} - 280
proposals.sort_by(|a, b| b.id.cmp(&a.id)); - 281
proposals - 282
} - 283
- 284
/// Install a proposal into user-level discovery. Refuses to silently - 285
/// overwrite an existing skill of the same name. - 286
pub fn promote(home: &Path, cwd: &Path, id: &str) -> Result<String, String> { - 287
let proposals = list_proposals(home, cwd); - 288
let p = proposals - 289
.iter() - 290
.find(|p| p.id == id) - 291
.ok_or_else(|| format!("no proposal '{id}'"))?; - 292
let target_dir = home.join("skills").join(&p.name); - 293
let target = target_dir.join("SKILL.md"); - 294
if target.exists() { - 295
return Err(format!( - 296
"a skill named '{}' already exists at {}; remove or rename it first", - 297
p.name, - 298
target.display() - 299
)); - 300
} - 301
std::fs::create_dir_all(&target_dir).map_err(|e| format!("create skill dir: {e}"))?; - 302
std::fs::copy(&p.path, &target).map_err(|e| format!("install skill: {e}"))?; - 303
std::fs::remove_file(&p.path).map_err(|e| format!("remove pending file: {e}"))?; - 304
Ok(p.name.clone()) - 305
} - 306
- 307
pub fn reject(home: &Path, cwd: &Path, id: &str) -> Result<(), String> { - 308
let proposals = list_proposals(home, cwd); - 309
let p = proposals - 310
.iter() - 311
.find(|p| p.id == id) - 312
.ok_or_else(|| format!("no proposal '{id}'"))?; - 313
std::fs::remove_file(&p.path).map_err(|e| format!("remove proposal: {e}")) - 314
} - 315
- 316
// ---- duplicate screening (docs/design/29-personal-os.md P5) ----------------- - 317
- 318
/// Same bar as reflection's note dedup — skill pollution is the same failure - 319
/// mode at proposal time. - 320
const SKILL_DEDUP_THRESHOLD: f32 = 0.55; - 321
- 322
/// Name of the first existing skill (name, body) whose token-Jaccard - 323
/// similarity against the candidate's title+body reaches - 324
/// [`SKILL_DEDUP_THRESHOLD`]. Pure screening: callers tag the proposal - 325
/// `duplicate-of`; nothing is auto-deleted. - 326
pub fn duplicate_of( - 327
candidate_title: &str, - 328
candidate_body: &str, - 329
existing: &[(String, String)], - 330
) -> Option<String> { - 331
let candidate = format!("{candidate_title}\n{candidate_body}"); - 332
existing - 333
.iter() - 334
.find(|(name, body)| { - 335
crate::reflection::jaccard(&format!("{name}\n{body}"), &candidate) - 336
>= SKILL_DEDUP_THRESHOLD - 337
}) - 338
.map(|(name, _)| name.clone()) - 339
} - 340
- 341
/// Frontmatter key persisted on flagged proposals; consumers' parsers skip - 342
/// unknown keys, so the line is inert everywhere but machine-readable. - 343
const DUPLICATE_KEY: &str = "duplicate-of"; - 344
- 345
/// Instructions-only view of a markdown body; provenance comments are noise - 346
/// for similarity scoring. - 347
fn prose(body: &str) -> &str { - 348
match body.split_once("<!--") { - 349
Some((head, _)) => head, - 350
None => body, - 351
} - 352
.trim() - 353
} - 354
- 355
/// Header (between the opening and closing `---`) plus remainder of a - 356
/// house-style markdown file. - 357
fn split_header(text: &str) -> Option<(&str, &str)> { - 358
text.strip_prefix("---") - 359
.and_then(|rest| rest.split_once("---")) - 360
} - 361
- 362
fn duplicate_tag_in(frontmatter: &str) -> Option<String> { - 363
frontmatter.lines().find_map(|line| { - 364
line.trim() - 365
.strip_prefix(DUPLICATE_KEY) - 366
.and_then(|rest| rest.strip_prefix(':')) - 367
.map(|value| value.trim().trim_matches('"').to_string()) - 368
}) - 369
} - 370
- 371
/// Insert the tag as the last frontmatter line. Malformed headers are left - 372
/// untouched — screening must never corrupt a reviewable draft. - 373
fn persist_duplicate_tag(path: &Path, dup: &str) -> std::io::Result<()> { - 374
let text = std::fs::read_to_string(path)?; - 375
let Some((frontmatter, body)) = split_header(&text) else { - 376
return Ok(()); - 377
}; - 378
if duplicate_tag_in(frontmatter).is_some() { - 379
return Ok(()); - 380
} - 381
let mut out = String::with_capacity(text.len() + DUPLICATE_KEY.len() + dup.len() + 5); - 382
out.push_str("---"); - 383
out.push_str(frontmatter); - 384
if !frontmatter.ends_with('\n') { - 385
out.push('\n'); - 386
} - 387
out.push_str(DUPLICATE_KEY); - 388
out.push_str(": \""); - 389
out.push_str(dup); - 390
out.push_str("\"\n---"); - 391
out.push_str(body); - 392
std::fs::write(path, out) - 393
} - 394
- 395
/// Existing-or-newly-persisted duplicate flag for one pending proposal. - 396
fn screen_proposal(path: &Path, name: &str, accepted: &[(String, String)]) -> Option<String> { - 397
let text = std::fs::read_to_string(path).ok()?; - 398
let (frontmatter, body) = split_header(&text)?; - 399
if let Some(tag) = duplicate_tag_in(frontmatter) { - 400
return Some(tag); - 401
} - 402
let dup = duplicate_of(name, prose(body), accepted)?; - 403
persist_duplicate_tag(path, &dup).ok()?; - 404
Some(dup) - 405
} - 406
- 407
fn with_duplicate_note(description: &str, dup: Option<&str>) -> String { - 408
match dup { - 409
Some(name) => format!("{description} [duplicate-of: {name}]"), - 410
None => description.to_string(), - 411
} - 412
} - 413
- 414
/// (name, instructions) pairs for every discovered project/user skill — the - 415
/// same roots skills::discover walks, read-only from this side. - 416
fn accepted_skill_bodies(home: &Path, cwd: &Path) -> Vec<(String, String)> { - 417
let mut roots = vec![cwd.join(".vak/skills"), home.join("skills")]; - 418
let agents_dir = home.join("agents"); - 419
if let Ok(entries) = std::fs::read_dir(&agents_dir) { - 420
for entry in entries.flatten() { - 421
let p = entry.path(); - 422
if p.is_dir() { - 423
roots.push(p.join("skills")); - 424
} - 425
} - 426
} - 427
roots.dedup(); - 428
let mut out: Vec<(String, String)> = Vec::new(); - 429
for root in roots { - 430
let Ok(entries) = std::fs::read_dir(&root) else { - 431
continue; - 432
}; - 433
for entry in entries.flatten() { - 434
let path = entry.path().join("SKILL.md"); - 435
if !path.is_file() { - 436
continue; - 437
} - 438
let Ok(text) = std::fs::read_to_string(&path) else { - 439
continue; - 440
}; - 441
if let Some(skill) = crate::skills::parse(&path) { - 442
let body = split_header(&text).map(|(_, body)| body).unwrap_or(&text); - 443
out.push((skill.name, prose(body).to_string())); - 444
} - 445
} - 446
} - 447
out.sort_by(|a, b| a.0.cmp(&b.0)); - 448
out.dedup_by(|a, b| a.0 == b.0); - 449
out - 450
} - 451
- 452
#[cfg(test)] - 453
mod tests { - 454
#![allow(clippy::unwrap_used, clippy::expect_used)] - 455
use super::*; - 456
use vak_tools::Tool as _; - 457
- 458
#[test] - 459
fn duplicate_of_flags_identical_skill() { - 460
let existing = vec![( - 461
"rotate-release-tags".to_string(), - 462
"pause before rollback so the deploy script can finish".to_string(), - 463
)]; - 464
assert_eq!( - 465
duplicate_of( - 466
"rotate-release-tags", - 467
"pause before rollback so the deploy script can finish", - 468
&existing, - 469
), - 470
Some("rotate-release-tags".to_string()) - 471
); - 472
} - 473
- 474
#[test] - 475
fn duplicate_of_catches_paraphrase_over_threshold() { - 476
let existing = vec![( - 477
"rotate-release-tags".to_string(), - 478
"pause before rollback so the deploy script can finish".to_string(), - 479
)]; - 480
assert_eq!( - 481
duplicate_of( - 482
"rotate release tags", - 483
"pause before rollback lets the deploy script finish", - 484
&existing, - 485
), - 486
Some("rotate-release-tags".to_string()) - 487
); - 488
} - 489
- 490
#[test] - 491
fn duplicate_of_allows_disjoint_skills() { - 492
let existing = vec![ - 493
( - 494
"frobulate-widget-frames".to_string(), - 495
"wedge alignment for panel mounts".to_string(), - 496
), - 497
( - 498
"rotate-release-tags".to_string(), - 499
"pause before rollback so the deploy script can finish".to_string(), - 500
), - 501
]; - 502
assert_eq!( - 503
duplicate_of( - 504
"quixotic-lantern-parade", - 505
"spinning light festival route", - 506
&existing, - 507
), - 508
None - 509
); - 510
assert!( - 511
duplicate_of( - 512
"quixotic-lantern-parade", - 513
"spinning light festival route", - 514
&[], - 515
) - 516
.is_none() - 517
); - 518
} - 519
- 520
#[test] - 521
fn duplicate_of_returns_first_matching_existing() { - 522
let dup = ( - 523
"rotate-release-tags".to_string(), - 524
"pause before rollback so the deploy script can finish".to_string(), - 525
); - 526
let existing = vec![( - 527
"unrelated-thing".to_string(), - 528
"totally different domain words here".to_string(), - 529
)]; - 530
assert_eq!( - 531
duplicate_of("rotate-release-tags", dup.1.as_str(), &existing), - 532
None - 533
); - 534
- 535
let mut both = existing; - 536
both.push(dup); - 537
assert_eq!( - 538
duplicate_of( - 539
"rotate-release-tags", - 540
"pause before rollback so the deploy script can finish", - 541
&both, - 542
), - 543
Some("rotate-release-tags".to_string()) - 544
); - 545
} - 546
- 547
// ---- submission/review integration ---- - 548
- 549
fn seed_accepted_skill(home: &Path, name: &str, desc: &str, body: &str) { - 550
let dir = home.join("skills").join(name); - 551
std::fs::create_dir_all(&dir).unwrap(); - 552
std::fs::write( - 553
dir.join("SKILL.md"), - 554
format!("---\nname: \"{name}\"\ndescription: \"{desc}\"\n---\n\n{body}\n"), - 555
) - 556
.unwrap(); - 557
} - 558
- 559
fn pending_paths(home: &Path, cwd: &Path) -> Vec<PathBuf> { - 560
let mut v: Vec<PathBuf> = std::fs::read_dir(proposals_dir(home, cwd)) - 561
.unwrap() - 562
.flatten() - 563
.map(|e| e.path()) - 564
.collect(); - 565
v.sort(); - 566
v - 567
} - 568
- 569
fn temp_home_cwd() -> (tempfile::TempDir, PathBuf, PathBuf) { - 570
let dir = tempfile::tempdir().unwrap(); - 571
let home = dir.path().join("home"); - 572
let cwd = dir.path().join("ws"); - 573
std::fs::create_dir_all(&cwd).unwrap(); - 574
(dir, home, cwd) - 575
} - 576
- 577
#[tokio::test] - 578
async fn propose_submission_tags_duplicate_and_promotion_still_works() { - 579
let (_dir, home, cwd) = temp_home_cwd(); - 580
seed_accepted_skill( - 581
&home, - 582
"rotate-release-tags", - 583
"pause deploys during rollback windows", - 584
"pause before rollback so the deploy script can finish", - 585
); - 586
- 587
let tool = ProposeSkillTool { - 588
sessions_home: home.clone(), - 589
cwd: cwd.clone(), - 590
session_id: "sess-a".into(), - 591
}; - 592
let ctx = vak_tools::ToolContext::new(cwd.clone()); - 593
let args = serde_json::json!({ - 594
"name": "rotate-release-tags-v2", - 595
"description": "wait out the deploy window", - 596
"instructions": "always pause before rollback lets the deploy script finish" - 597
}); - 598
assert!(!tool.execute(&args, &ctx).await.is_error); - 599
// Re-proposal queues a fresh draft; neither may stack tag lines. - 600
assert!(!tool.execute(&args, &ctx).await.is_error); - 601
- 602
for path in pending_paths(&home, &cwd) { - 603
let text = std::fs::read_to_string(&path).unwrap(); - 604
assert!(text.contains("duplicate-of: \"rotate-release-tags\"")); - 605
assert_eq!( - 606
text.matches("duplicate-of:").count(), - 607
1, - 608
"exactly one tag line in {}", - 609
path.display() - 610
); - 611
// Stored description stays pristine; the flag lives on its own line. - 612
assert!(text.contains("description: \"wait out the deploy window\"")); - 613
assert!(!text.contains("[duplicate-of:")); - 614
} - 615
- 616
let listed = list_proposals(&home, &cwd); - 617
assert_eq!(listed.len(), 2); - 618
for p in &listed { - 619
assert!( - 620
p.description - 621
.ends_with("[duplicate-of: rotate-release-tags]"), - 622
"{}", - 623
p.description - 624
); - 625
} - 626
- 627
// A flagged proposal is still promotable by explicit human decision. - 628
let id = listed[0].id.clone(); - 629
assert_eq!(promote(&home, &cwd, &id).unwrap(), "rotate-release-tags-v2"); - 630
let installed = home.join("skills/rotate-release-tags-v2/SKILL.md"); - 631
assert!(installed.exists()); - 632
let parsed = crate::skills::parse(&installed).unwrap(); - 633
assert_eq!(parsed.description, "wait out the deploy window"); - 634
assert!(!parsed.description.contains("duplicate-of")); - 635
} - 636
- 637
#[tokio::test] - 638
async fn propose_submission_leaves_disjoint_skills_untagged() { - 639
let (_dir, home, cwd) = temp_home_cwd(); - 640
seed_accepted_skill( - 641
&home, - 642
"frobulate-widget-frames", - 643
"wedge alignment for panel mounts", - 644
"wedge alignment for panel mounts", - 645
); - 646
- 647
let tool = ProposeSkillTool { - 648
sessions_home: home.clone(), - 649
cwd: cwd.clone(), - 650
session_id: "sess-b".into(), - 651
}; - 652
let ctx = vak_tools::ToolContext::new(cwd.clone()); - 653
let out = tool - 654
.execute( - 655
&serde_json::json!({ - 656
"name": "quixotic-lantern-parade", - 657
"description": "festival logistics", - 658
"instructions": "spinning light festival route planning" - 659
}), - 660
&ctx, - 661
) - 662
.await; - 663
assert!(!out.is_error); - 664
- 665
for path in pending_paths(&home, &cwd) { - 666
let text = std::fs::read_to_string(&path).unwrap(); - 667
assert!(!text.contains("duplicate-of"), "{text}"); - 668
} - 669
let listed = list_proposals(&home, &cwd); - 670
assert_eq!(listed.len(), 1); - 671
assert_eq!(listed[0].description, "festival logistics"); - 672
} - 673
- 674
#[test] - 675
fn reflection_authored_draft_is_screened_on_review_without_stacking() { - 676
let (_dir, home, cwd) = temp_home_cwd(); - 677
seed_accepted_skill( - 678
&home, - 679
"rotate-release-tags", - 680
"pause deploys during rollback windows", - 681
"pause before rollback so the deploy script can finish", - 682
); - 683
- 684
// Exact write format of the reflection queue entry point. - 685
let dir = proposals_dir(&home, &cwd); - 686
std::fs::create_dir_all(&dir).unwrap(); - 687
let file = dir.join("aaa111.md"); - 688
std::fs::write( - 689
&file, - 690
"---\nname: \"rotate-release-tags-v3\"\ndescription: \"hold deploys at the rollback gate\"\n---\n\npause before rollback so the deploy script can finish cleanly\n\n<!-- proposed-by: sess-r at 2026-01-01T00:00:00+00:00; proposal id aaa111; source: reflection -->\n", - 691
) - 692
.unwrap(); - 693
- 694
let first = list_proposals(&home, &cwd); - 695
assert_eq!(first.len(), 1); - 696
assert!( - 697
first[0] - 698
.description - 699
.contains("[duplicate-of: rotate-release-tags]"), - 700
"{}", - 701
first[0].description - 702
); - 703
assert_eq!( - 704
std::fs::read_to_string(&file) - 705
.unwrap() - 706
.matches("duplicate-of:") - 707
.count(), - 708
1 - 709
); - 710
- 711
// Repeat listings never stack a second tag line. - 712
let second = list_proposals(&home, &cwd); - 713
assert_eq!(second[0].description, first[0].description); - 714
assert_eq!( - 715
std::fs::read_to_string(&file) - 716
.unwrap() - 717
.matches("duplicate-of:") - 718
.count(), - 719
1 - 720
); - 721
- 722
// Rejection is unchanged for flagged drafts. - 723
assert!(reject(&home, &cwd, "aaa111").is_ok()); - 724
assert!(list_proposals(&home, &cwd).is_empty()); - 725
} - 726
- 727
#[test] - 728
fn tagged_header_is_inert_to_consumer_parsers() { - 729
let (_dir, home, cwd) = temp_home_cwd(); - 730
let dir = proposals_dir(&home, &cwd); - 731
std::fs::create_dir_all(&dir).unwrap(); - 732
let file = dir.join("bbb222.md"); - 733
std::fs::write( - 734
&file, - 735
"---\nname: \"hand-tagged\"\ndescription: \"original text\"\nduplicate-of: \"rotate-release-tags\"\n---\n\nbody here\n\n<!-- proposed-by: sess-h at ts; proposal id bbb222 -->\n", - 736
) - 737
.unwrap(); - 738
- 739
// Replicates skills::parse as used by discovery and every listing: - 740
// unknown frontmatter keys are skipped, name/description untouched. - 741
let parsed = crate::skills::parse(&file).expect("tagged header still parses"); - 742
assert_eq!(parsed.name, "hand-tagged"); - 743
assert_eq!(parsed.description, "original text"); - 744
- 745
// Replicates the server payload shape and TUI/CLI row rendering, - 746
// which all show the flag via description without their own changes. - 747
let listed = list_proposals(&home, &cwd); - 748
let p = &listed[0]; - 749
let payload = serde_json::json!({"id": p.id, "name": p.name, "description": p.description}); - 750
assert_eq!(payload["name"], "hand-tagged"); - 751
assert!( - 752
payload["description"] - 753
.as_str() - 754
.unwrap() - 755
.contains("[duplicate-of: rotate-release-tags]"), - 756
"{}", - 757
payload["description"] - 758
); - 759
} - 760
} - 761
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.