1use std::path::{Path, PathBuf};
7
8use crate::{BvrError, Result};
9
10const BLURB_VERSION: u32 = 1;
20
21#[cfg(test)]
22const BLURB_START_MARKER: &str = "<!-- bv-agent-instructions-v1 -->";
23const BLURB_END_MARKER: &str = "<!-- end-bv-agent-instructions -->";
24
25const SUPPORTED_FILES: &[&str] = &["AGENTS.md", "CLAUDE.md", "agents.md", "claude.md"];
27
28const AGENT_BLURB: &str = r#"<!-- bv-agent-instructions-v1 -->
32
33---
34
35## Beads Workflow Integration
36
37This project uses [beads_rust](https://github.com/Dicklesworthstone/beads_rust) (`br`) for issue tracking. Issues are stored in `.beads/` and tracked in git.
38
39**Important:** `br` is non-invasive—it NEVER runs git commands automatically. After `br sync --flush-only`, you must manually commit changes.
40
41### Essential Commands
42
43```bash
44# View issues (launches TUI - avoid in automated sessions)
45bvr
46
47# CLI commands for agents (use these instead)
48br ready # Show issues ready to work (no blockers)
49br list --status=open # All open issues
50br show <id> # Full issue details with dependencies
51br create --title="..." --type=task --priority=2
52br update <id> --status=in_progress
53br close <id> --reason "Completed"
54br close <id1> <id2> # Close multiple issues at once
55br sync --flush-only # Export to JSONL (NO git operations)
56```
57
58### Workflow Pattern
59
601. **Start**: Run `br ready` to find actionable work
612. **Claim**: Use `br update <id> --status=in_progress`
623. **Work**: Implement the task
634. **Complete**: Use `br close <id>`
645. **Sync**: Run `br sync --flush-only` then manually commit
65
66### Key Concepts
67
68- **Dependencies**: Issues can block other issues. `br ready` shows only unblocked work.
69- **Priority**: P0=critical, P1=high, P2=medium, P3=low, P4=backlog (use numbers, not words)
70- **Types**: task, bug, feature, epic, question, docs
71- **Blocking**: `br dep add <issue> <depends-on>` to add dependencies
72
73### Session Protocol
74
75**Before ending any session, run this checklist:**
76
77```bash
78git status # Check what changed
79git add <files> # Stage code changes
80br sync --flush-only # Export beads to JSONL
81git add .beads/ # Stage beads changes
82git commit -m "..." # Commit everything together
83git push # Push to remote
84```
85
86### Best Practices
87
88- Check `br ready` at session start to find available work
89- Update status as you work (in_progress -> closed)
90- Create new issues with `br create` when you discover tasks
91- Use descriptive titles and set appropriate priority/type
92- Always `br sync --flush-only && git add .beads/` before ending session
93
94<!-- end-bv-agent-instructions -->"#;
95
96#[derive(Debug, Clone, Default)]
102pub struct AgentFileDetection {
103 pub file_path: Option<PathBuf>,
104 pub file_type: String,
105 pub has_blurb: bool,
106 pub has_legacy_blurb: bool,
107 pub blurb_version: u32,
108 pub content: String,
109 pub read_error: bool,
111}
112
113impl AgentFileDetection {
114 pub fn found(&self) -> bool {
115 self.file_path.is_some()
116 }
117
118 pub fn needs_blurb(&self) -> bool {
119 self.found() && !self.has_blurb
120 }
121
122 pub fn needs_upgrade(&self) -> bool {
123 if self.has_legacy_blurb {
124 return true;
125 }
126 self.has_blurb && self.blurb_version < BLURB_VERSION
127 }
128}
129
130fn contains_blurb(content: &str) -> bool {
132 content.contains("<!-- bv-agent-instructions-v")
133}
134
135fn contains_legacy_blurb(content: &str) -> bool {
137 let patterns = [
138 "### Using bv as an AI sidecar",
139 "--robot-insights",
140 "--robot-plan",
141 "bv already computes the hard parts",
142 ];
143
144 if !content.contains("Using bv as an AI sidecar") {
146 return false;
147 }
148
149 patterns.iter().all(|p| content.contains(p))
151}
152
153fn get_blurb_version(content: &str) -> u32 {
155 let marker = "<!-- bv-agent-instructions-v";
157 if let Some(pos) = content.find(marker) {
158 let after = &content[pos + marker.len()..];
159 if let Some(end) = after.find(" -->") {
160 if let Ok(v) = after[..end].parse::<u32>() {
161 return v;
162 }
163 }
164 }
165 0
166}
167
168fn detect_agent_file(work_dir: &Path) -> AgentFileDetection {
170 for &filename in SUPPORTED_FILES
172 .iter()
173 .filter(|f| f.starts_with(|c: char| c.is_uppercase()))
174 {
175 let path = work_dir.join(filename);
176 if let Some(det) = check_agent_file(&path, filename) {
177 return det;
178 }
179 }
180
181 for &filename in SUPPORTED_FILES
183 .iter()
184 .filter(|f| f.starts_with(|c: char| c.is_lowercase()))
185 {
186 let path = work_dir.join(filename);
187 if let Some(det) = check_agent_file(&path, filename) {
188 return det;
189 }
190 }
191
192 AgentFileDetection::default()
193}
194
195fn check_agent_file(path: &Path, file_type: &str) -> Option<AgentFileDetection> {
196 if !path.is_file() {
197 return None;
198 }
199
200 let content = match std::fs::read_to_string(path) {
201 Ok(text) => text,
202 Err(error) => {
203 tracing::warn!(
204 "cannot read {}: {error} — treating as unreadable",
205 path.display()
206 );
207 return Some(AgentFileDetection {
208 file_path: Some(path.to_path_buf()),
209 file_type: file_type.to_string(),
210 read_error: true,
211 ..Default::default()
212 });
213 }
214 };
215
216 let has_legacy = contains_legacy_blurb(&content);
217 let has_blurb = contains_blurb(&content) || has_legacy;
218
219 Some(AgentFileDetection {
220 file_path: Some(path.to_path_buf()),
221 file_type: file_type.to_string(),
222 has_blurb,
223 has_legacy_blurb: has_legacy,
224 blurb_version: get_blurb_version(&content),
225 content,
226 read_error: false,
227 })
228}
229
230pub fn detect_agent_file_in_parents(work_dir: &Path, max_levels: usize) -> AgentFileDetection {
232 let mut current = work_dir.to_path_buf();
233 for _ in 0..=max_levels {
234 let det = detect_agent_file(¤t);
235 if det.found() {
236 return det;
237 }
238 match current.parent() {
239 Some(parent) if parent != current => current = parent.to_path_buf(),
240 _ => break,
241 }
242 }
243 AgentFileDetection::default()
244}
245
246fn append_blurb(content: &str) -> String {
252 let mut out = content.to_string();
253 if !out.ends_with('\n') {
254 out.push('\n');
255 }
256 out.push('\n');
257 out.push_str(AGENT_BLURB);
258 out.push('\n');
259 out
260}
261
262fn remove_blurb(content: &str) -> String {
264 let Some(start_byte) = content.find("<!-- bv-agent-instructions-v") else {
265 return content.to_string();
266 };
267 let Some(end_byte) = content.find(BLURB_END_MARKER) else {
268 return content.to_string();
269 };
270
271 let mut end = end_byte + BLURB_END_MARKER.len();
272 while end < content.len() && matches!(content.as_bytes()[end], b'\n' | b'\r') {
274 end += 1;
275 }
276 let mut start = start_byte;
278 while start > 0 && matches!(content.as_bytes()[start - 1], b'\n' | b'\r') {
279 start -= 1;
280 }
281
282 let mut result = content[..start].to_string();
283 result.push_str(&content[end..]);
284 result
285}
286
287fn remove_legacy_blurb(content: &str) -> String {
289 if !contains_legacy_blurb(content) {
290 return content.to_string();
291 }
292
293 let Some(start_byte) = content.find("Using bv as an AI sidecar") else {
295 return content.to_string();
296 };
297 let start = content[..start_byte].rfind('#').unwrap_or(start_byte);
299
300 let end = content[start..]
302 .find("bv already computes the hard parts")
303 .map_or(content.len(), |pos| {
304 let mut e = start + pos;
305 if let Some(nl) = content[e..].find('\n') {
307 e += nl + 1;
308 } else {
309 e = content.len();
310 }
311 let remaining = &content[e..];
313 if remaining.starts_with("```") {
314 if let Some(nl) = remaining.find('\n') {
315 e += nl + 1;
316 }
317 }
318 while e < content.len() && matches!(content.as_bytes()[e], b'\n' | b'\r') {
320 e += 1;
321 }
322 e
323 });
324
325 let mut adj_start = start;
327 while adj_start > 0 && matches!(content.as_bytes()[adj_start - 1], b'\n' | b'\r') {
328 adj_start -= 1;
329 }
330 if adj_start > 0 {
331 adj_start += 1; }
333
334 let mut result = content[..adj_start].to_string();
335 result.push_str(&content[end..]);
336 result
337}
338
339fn update_blurb(content: &str) -> String {
341 let content = remove_legacy_blurb(content);
342 let content = remove_blurb(&content);
343 append_blurb(&content)
344}
345
346fn atomic_write(path: &Path, content: &[u8]) -> Result<()> {
352 use std::io::Write;
353
354 let dir = path.parent().unwrap_or_else(|| Path::new("."));
355
356 match tempfile::NamedTempFile::new_in(dir) {
358 Ok(mut tmp) => {
359 tmp.write_all(content).map_err(BvrError::Io)?;
360 tmp.as_file().sync_all().map_err(BvrError::Io)?;
361 tmp.persist(path).map_err(|e| BvrError::Io(e.error))?;
362 }
363 Err(_) => {
364 std::fs::write(path, content).map_err(BvrError::Io)?;
366 }
367 }
368
369 Ok(())
370}
371
372pub struct AgentsResult {
378 pub message: String,
379 pub changed: bool,
380}
381
382fn agents_check_from_detection(work_dir: &Path, det: &AgentFileDetection) -> AgentsResult {
383 if !det.found() {
384 return AgentsResult {
385 message: format!(
386 "No agent file found (searched up to 3 parent directories from {}).\n\
387 Run 'bvr --agents-add' to create AGENTS.md with beads workflow instructions.",
388 work_dir.display()
389 ),
390 changed: false,
391 };
392 }
393
394 let Some(path_buf) = det.file_path.as_ref() else {
395 return AgentsResult {
396 message: format!(
397 "Found {} but could not resolve its path; run 'bvr --agents-check' again.",
398 det.file_type
399 ),
400 changed: false,
401 };
402 };
403 let path = path_buf.display();
404
405 if det.read_error {
406 return AgentsResult {
407 message: format!(
408 "Found {file_type} at {path}, but it could not be read.\n\
409 Check file permissions before running any agents command.",
410 file_type = det.file_type,
411 ),
412 changed: false,
413 };
414 }
415
416 if det.needs_upgrade() {
417 let current_ver = if det.has_legacy_blurb {
418 "legacy".to_string()
419 } else {
420 format!("v{}", det.blurb_version)
421 };
422 return AgentsResult {
423 message: format!(
424 "Found {file_type} at {path} (blurb {current_ver}, current v{BLURB_VERSION} — needs update)\n\
425 Run 'bvr --agents-update' to update to the latest version.",
426 file_type = det.file_type,
427 ),
428 changed: false,
429 };
430 }
431
432 if det.needs_blurb() {
433 return AgentsResult {
434 message: format!(
435 "Found {file_type} at {path} (no blurb)\n\
436 Run 'bvr --agents-add' to add beads workflow instructions.",
437 file_type = det.file_type,
438 ),
439 changed: false,
440 };
441 }
442
443 AgentsResult {
444 message: format!(
445 "Found {file_type} at {path} (blurb v{BLURB_VERSION} — up to date)",
446 file_type = det.file_type,
447 ),
448 changed: false,
449 }
450}
451
452pub fn agents_check(work_dir: &Path) -> AgentsResult {
454 let det = detect_agent_file_in_parents(work_dir, 3);
455 agents_check_from_detection(work_dir, &det)
456}
457
458pub fn agents_add(work_dir: &Path, dry_run: bool) -> Result<AgentsResult> {
460 let det = detect_agent_file_in_parents(work_dir, 3);
461
462 if det.found() {
463 let Some(path) = det.file_path.as_ref() else {
464 return Err(BvrError::InvalidArgument(
465 "Agent file detected but no file path was recorded.".to_string(),
466 ));
467 };
468
469 if det.read_error {
470 return Err(BvrError::InvalidArgument(format!(
471 "Cannot read {} — check file permissions.",
472 path.display()
473 )));
474 }
475
476 if det.has_blurb && !det.needs_upgrade() {
477 return Ok(AgentsResult {
478 message: format!(
479 "{} already has blurb v{BLURB_VERSION} — nothing to do.",
480 det.file_type
481 ),
482 changed: false,
483 });
484 }
485
486 if det.needs_upgrade() {
487 return Err(BvrError::InvalidArgument(format!(
488 "{} has outdated blurb. Run 'bvr --agents-update' instead.",
489 det.file_type
490 )));
491 }
492
493 if dry_run {
495 return Ok(AgentsResult {
496 message: format!("[dry-run] Would append blurb to {}.", path.display()),
497 changed: false,
498 });
499 }
500
501 let new_content = append_blurb(&det.content);
502 atomic_write(path, new_content.as_bytes())?;
503
504 return Ok(AgentsResult {
505 message: format!("Added blurb to {}.", path.display()),
506 changed: true,
507 });
508 }
509
510 let path = work_dir.join("AGENTS.md");
512 if dry_run {
513 return Ok(AgentsResult {
514 message: format!("[dry-run] Would create {}.", path.display()),
515 changed: false,
516 });
517 }
518
519 let content = format!("# AI Agent Instructions\n\n{AGENT_BLURB}\n");
520 atomic_write(&path, content.as_bytes())?;
521
522 Ok(AgentsResult {
523 message: format!(
524 "Created {} with beads workflow instructions.",
525 path.display()
526 ),
527 changed: true,
528 })
529}
530
531pub fn agents_update(work_dir: &Path, dry_run: bool) -> Result<AgentsResult> {
533 let det = detect_agent_file_in_parents(work_dir, 3);
534
535 if !det.found() {
536 return Err(BvrError::InvalidArgument(
537 "No agent file found. Run 'bvr --agents-add' first.".to_string(),
538 ));
539 }
540
541 let Some(path) = det.file_path.as_ref() else {
542 return Err(BvrError::InvalidArgument(
543 "Agent file detected but no file path was recorded.".to_string(),
544 ));
545 };
546
547 if det.read_error {
548 return Err(BvrError::InvalidArgument(format!(
549 "Cannot read {} — check file permissions.",
550 path.display()
551 )));
552 }
553
554 if !det.has_blurb {
555 return Err(BvrError::InvalidArgument(format!(
556 "{} has no blurb to update. Run 'bvr --agents-add' instead.",
557 det.file_type,
558 )));
559 }
560
561 if !det.needs_upgrade() {
562 return Ok(AgentsResult {
563 message: format!(
564 "{} blurb is already v{BLURB_VERSION} — nothing to do.",
565 det.file_type,
566 ),
567 changed: false,
568 });
569 }
570
571 let label = if det.has_legacy_blurb {
572 "legacy blurb"
573 } else {
574 "outdated blurb"
575 };
576
577 if dry_run {
578 return Ok(AgentsResult {
579 message: format!(
580 "[dry-run] Would upgrade {label} to v{BLURB_VERSION} in {}.",
581 path.display()
582 ),
583 changed: false,
584 });
585 }
586
587 let new_content = update_blurb(&det.content);
588 atomic_write(path, new_content.as_bytes())?;
589
590 Ok(AgentsResult {
591 message: format!("Updated blurb to v{BLURB_VERSION} in {}.", path.display()),
592 changed: true,
593 })
594}
595
596pub fn agents_remove(work_dir: &Path, dry_run: bool) -> Result<AgentsResult> {
598 let det = detect_agent_file_in_parents(work_dir, 3);
599
600 if !det.found() {
601 return Ok(AgentsResult {
602 message: "No agent file found — nothing to remove.".to_string(),
603 changed: false,
604 });
605 }
606
607 let Some(path) = det.file_path.as_ref() else {
608 return Err(BvrError::InvalidArgument(
609 "Agent file detected but no file path was recorded.".to_string(),
610 ));
611 };
612
613 if det.read_error {
614 return Err(BvrError::InvalidArgument(format!(
615 "Cannot read {} — check file permissions.",
616 path.display()
617 )));
618 }
619
620 if !det.has_blurb {
621 return Ok(AgentsResult {
622 message: format!("{} has no blurb — nothing to remove.", det.file_type),
623 changed: false,
624 });
625 }
626
627 if dry_run {
628 return Ok(AgentsResult {
629 message: format!("[dry-run] Would remove blurb from {}.", path.display()),
630 changed: false,
631 });
632 }
633
634 let new_content = if det.has_legacy_blurb {
635 remove_legacy_blurb(&det.content)
636 } else {
637 remove_blurb(&det.content)
638 };
639 atomic_write(path, new_content.as_bytes())?;
640
641 Ok(AgentsResult {
642 message: format!("Removed blurb from {}.", path.display()),
643 changed: true,
644 })
645}
646
647#[cfg(test)]
652mod tests {
653 use super::*;
654
655 #[test]
656 fn contains_blurb_detects_current_marker() {
657 assert!(contains_blurb(
658 "before\n<!-- bv-agent-instructions-v1 -->\nstuff\n<!-- end-bv-agent-instructions -->\nafter"
659 ));
660 assert!(!contains_blurb("no marker here"));
661 }
662
663 #[test]
664 fn get_blurb_version_extracts_version() {
665 assert_eq!(get_blurb_version("<!-- bv-agent-instructions-v1 -->"), 1);
666 assert_eq!(get_blurb_version("<!-- bv-agent-instructions-v42 -->"), 42);
667 assert_eq!(get_blurb_version("no marker"), 0);
668 }
669
670 #[test]
671 fn agent_blurb_version_matches_constant() {
672 assert_eq!(
677 get_blurb_version(AGENT_BLURB),
678 BLURB_VERSION,
679 "AGENT_BLURB marker version must match BLURB_VERSION (currently {BLURB_VERSION})"
680 );
681 }
682
683 #[test]
684 fn append_blurb_adds_to_content() {
685 let result = append_blurb("# Existing\n");
686 assert!(result.starts_with("# Existing\n"));
687 assert!(result.contains(BLURB_START_MARKER));
688 assert!(result.contains(BLURB_END_MARKER));
689 }
690
691 #[test]
692 fn agent_blurb_uses_bvr_command_name() {
693 assert!(AGENT_BLURB.contains("\nbvr\n"));
694 assert!(!AGENT_BLURB.contains("\nbv\n"));
695 }
696
697 #[test]
698 fn remove_blurb_strips_current() {
699 let content = format!("before\n\n{AGENT_BLURB}\n\nafter");
700 let result = remove_blurb(&content);
701 assert!(result.contains("before"));
702 assert!(result.contains("after"));
703 assert!(!result.contains(BLURB_START_MARKER));
704 }
705
706 #[test]
707 fn update_blurb_replaces_existing() {
708 let old = "# File\n\n<!-- bv-agent-instructions-v0 -->\nold stuff\n<!-- end-bv-agent-instructions -->\n";
709 let result = update_blurb(old);
710 assert!(result.contains(BLURB_START_MARKER));
711 assert!(!result.contains("old stuff"));
712 assert!(result.contains("br ready"));
713 }
714
715 #[test]
716 fn detection_defaults() {
717 let det = AgentFileDetection::default();
718 assert!(!det.found());
719 assert!(!det.needs_blurb());
720 assert!(!det.needs_upgrade());
721 }
722
723 #[test]
724 fn detection_needs_upgrade_for_legacy() {
725 let det = AgentFileDetection {
726 file_path: Some(PathBuf::from("/test/AGENTS.md")),
727 has_blurb: true,
728 has_legacy_blurb: true,
729 ..Default::default()
730 };
731 assert!(det.needs_upgrade());
732 }
733
734 #[test]
735 fn detection_up_to_date() {
736 let det = AgentFileDetection {
737 file_path: Some(PathBuf::from("/test/AGENTS.md")),
738 has_blurb: true,
739 blurb_version: BLURB_VERSION,
740 ..Default::default()
741 };
742 assert!(!det.needs_upgrade());
743 assert!(!det.needs_blurb());
744 }
745
746 #[test]
747 fn agents_check_no_file() {
748 let tmp = tempfile::tempdir().unwrap();
749 let nested = tmp.path().join("a/b/c/d");
751 std::fs::create_dir_all(&nested).unwrap();
752 let result = agents_check(&nested);
753 assert!(
754 result.message.contains("No agent file found"),
755 "unexpected message: {}",
756 result.message
757 );
758 assert!(!result.changed);
759 }
760
761 #[test]
762 fn agents_check_reports_unreadable_agent_file() {
763 let work_dir = Path::new("/tmp/project");
764 let detection = AgentFileDetection {
765 file_path: Some(PathBuf::from("/tmp/project/AGENTS.md")),
766 file_type: "AGENTS.md".to_string(),
767 read_error: true,
768 ..Default::default()
769 };
770
771 let result = agents_check_from_detection(work_dir, &detection);
772 assert!(result.message.contains("could not be read"));
773 assert!(result.message.contains("Check file permissions"));
774 assert!(!result.changed);
775 }
776
777 #[test]
778 fn agents_add_creates_file() {
779 let tmp = tempfile::tempdir().unwrap();
780 let nested = tmp.path().join("a/b/c/d");
781 std::fs::create_dir_all(&nested).unwrap();
782 let result = agents_add(&nested, false).unwrap();
783 assert!(result.changed);
784 assert!(result.message.contains("Created"));
785
786 let content = std::fs::read_to_string(nested.join("AGENTS.md")).unwrap();
787 assert!(content.contains(BLURB_START_MARKER));
788 }
789
790 #[test]
791 fn agents_add_dry_run_no_write() {
792 let tmp = tempfile::tempdir().unwrap();
793 let nested = tmp.path().join("a/b/c/d");
794 std::fs::create_dir_all(&nested).unwrap();
795 let result = agents_add(&nested, true).unwrap();
796 assert!(!result.changed);
797 assert!(result.message.contains("[dry-run]"));
798 assert!(!nested.join("AGENTS.md").exists());
799 }
800
801 #[test]
802 fn agents_remove_strips_blurb() {
803 let tmp = tempfile::tempdir().unwrap();
804 let nested = tmp.path().join("a/b/c/d");
805 std::fs::create_dir_all(&nested).unwrap();
806 let path = nested.join("AGENTS.md");
807 let content = format!("# Header\n\n{AGENT_BLURB}\n\n## Other\n");
808 std::fs::write(&path, &content).unwrap();
809
810 let result = agents_remove(&nested, false).unwrap();
811 assert!(result.changed);
812
813 let updated = std::fs::read_to_string(&path).unwrap();
814 assert!(!updated.contains(BLURB_START_MARKER));
815 assert!(updated.contains("# Header"));
816 assert!(updated.contains("## Other"));
817 }
818
819 #[test]
820 fn agents_update_upgrades_old_version() {
821 let tmp = tempfile::tempdir().unwrap();
822 let nested = tmp.path().join("a/b/c/d");
823 std::fs::create_dir_all(&nested).unwrap();
824 let path = nested.join("AGENTS.md");
825 let content = "# Header\n\n<!-- bv-agent-instructions-v0 -->\nold\n<!-- end-bv-agent-instructions -->\n";
826 std::fs::write(&path, content).unwrap();
827
828 let result = agents_update(&nested, false).unwrap();
829 assert!(result.changed);
830
831 let updated = std::fs::read_to_string(&path).unwrap();
832 assert!(updated.contains("bv-agent-instructions-v1"));
833 assert!(updated.contains("br ready"));
834 }
835
836 #[test]
837 fn roundtrip_add_check_remove() {
838 let tmp = tempfile::tempdir().unwrap();
839 let nested = tmp.path().join("a/b/c/d");
840 std::fs::create_dir_all(&nested).unwrap();
841
842 let r = agents_add(&nested, false).unwrap();
844 assert!(r.changed);
845
846 let r = agents_check(&nested);
848 assert!(r.message.contains("up to date"));
849
850 let r = agents_remove(&nested, false).unwrap();
852 assert!(r.changed);
853
854 let r = agents_check(&nested);
856 assert!(r.message.contains("no blurb"));
857 }
858}