1use schemars::JsonSchema;
29use serde::{Deserialize, Serialize};
30use serde_json;
31use std::path::{Path, PathBuf};
32
33const COORDINATION_DEFAULT: &str = include_str!("../assets/agent-skills/coordination.md");
38
39const SUPERVISOR_DEFAULT: &str = include_str!("../assets/agent-skills/supervisor.md");
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44pub enum Source {
45 Embedded,
47 AgentsStandard,
49 User,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
55pub enum SkillFormat {
56 Standardized,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
62pub struct StandardizedSkillMetadata {
63 pub name: String,
65 pub description: String,
67 #[serde(skip_serializing_if = "Option::is_none")]
69 pub license: Option<String>,
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub compatibility: Option<String>,
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub metadata: Option<serde_json::Value>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct SkillTemplate {
81 pub name: String,
83 pub content: String,
85 pub source: Source,
87 pub format: SkillFormat,
89 #[serde(skip_serializing_if = "Option::is_none")]
91 pub metadata: Option<StandardizedSkillMetadata>,
92 #[serde(skip_serializing_if = "Option::is_none")]
94 pub resource_paths: Option<Vec<PathBuf>>,
95}
96
97#[derive(Debug, thiserror::Error)]
99pub enum SkillError {
100 #[error("unknown skill '{name}' — no embedded default or user override exists")]
102 UnknownSkill {
103 name: String,
105 },
106
107 #[error("skill '{name}' validation failed: {reason}")]
109 ValidationError {
110 name: String,
112 reason: String,
114 },
115
116 #[error("cannot read skill directory at '{}' — check directory permissions", path.display())]
118 DirectoryReadError {
119 path: PathBuf,
121 source: std::io::Error,
123 },
124
125 #[error("cannot read user override skill file at '{}' — check file permissions", path.display())]
127 UserOverrideRead {
128 path: PathBuf,
130 source: std::io::Error,
132 },
133}
134
135fn embedded_default(skill_name: &str) -> Option<&'static str> {
141 match skill_name {
142 "coordination" => Some(COORDINATION_DEFAULT),
143 "supervisor" => Some(SUPERVISOR_DEFAULT),
144 _ => None,
145 }
146}
147
148pub fn resolve(skill_name: &str) -> Result<SkillTemplate, SkillError> {
153 resolve_with_config_dir(skill_name, None)
154}
155
156fn try_load_standardized_skill(
161 skill_name: &str,
162 config_dir_override: Option<&Path>,
163) -> Result<Option<SkillTemplate>, SkillError> {
164 if let Some(config_dir) = config_dir_override
166 && let Some(skill) = try_load_user_override(skill_name, config_dir)?
167 {
168 return Ok(Some(skill));
169 }
170
171 try_load_from_agents_dir(skill_name)
173}
174
175fn try_load_user_override(
177 skill_name: &str,
178 config_dir: &Path,
179) -> Result<Option<SkillTemplate>, SkillError> {
180 let skill_dir = config_dir
181 .join("git-paw")
182 .join("agent-skills")
183 .join(skill_name);
184
185 if skill_dir.is_dir() {
186 let skill_md_path = skill_dir.join("SKILL.md");
187 if skill_md_path.exists() {
188 return load_skill_from_directory(&skill_dir, skill_name, Source::User);
189 }
190 }
191
192 Ok(None)
193}
194
195fn try_load_from_agents_dir(skill_name: &str) -> Result<Option<SkillTemplate>, SkillError> {
197 let Ok(mut current_dir) = std::env::current_dir() else {
198 return Ok(None);
199 };
200
201 for _ in 0..5 {
202 let agents_dir = current_dir.join(".agents").join("skills").join(skill_name);
204
205 if agents_dir.is_dir() {
206 let skill_md_path = agents_dir.join("SKILL.md");
207 if skill_md_path.exists() {
208 return load_skill_from_directory(&agents_dir, skill_name, Source::AgentsStandard);
209 }
210 }
211
212 if !current_dir.pop() {
213 break;
214 }
215 }
216
217 Ok(None)
218}
219
220fn load_skill_from_directory(
222 skill_dir: &Path,
223 skill_name: &str,
224 source: Source,
225) -> Result<Option<SkillTemplate>, SkillError> {
226 let skill_md_path = skill_dir.join("SKILL.md");
227
228 let content = match std::fs::read_to_string(&skill_md_path) {
229 Ok(content) => content,
230 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
231 Err(source_err) => {
232 let error = match source {
233 Source::User => SkillError::UserOverrideRead {
234 path: skill_md_path.clone(),
235 source: source_err,
236 },
237 _ => SkillError::DirectoryReadError {
238 path: skill_dir.to_path_buf(),
239 source: source_err,
240 },
241 };
242 return Err(error);
243 }
244 };
245
246 let (metadata, content_without_frontmatter) = parse_standardized_metadata(&content)?;
248
249 let mut resource_paths = Vec::new();
251 for subdir in ["scripts", "references", "assets"] {
252 let subdir_path = skill_dir.join(subdir);
253 if subdir_path.exists() && subdir_path.is_dir() {
254 resource_paths.push(subdir_path);
255 }
256 }
257
258 Ok(Some(SkillTemplate {
259 name: skill_name.to_string(),
260 content: content_without_frontmatter,
261 source,
262 format: SkillFormat::Standardized,
263 metadata,
264 resource_paths: if resource_paths.is_empty() {
265 None
266 } else {
267 Some(resource_paths)
268 },
269 }))
270}
271
272fn parse_standardized_metadata(
276 content: &str,
277) -> Result<(Option<StandardizedSkillMetadata>, String), SkillError> {
278 let lines: Vec<&str> = content.lines().collect();
280 if lines.len() < 2 || !lines[0].trim().starts_with("---") {
281 return Ok((None, content.to_string()));
283 }
284
285 let mut frontmatter_end = None;
287 for (i, line) in lines.iter().enumerate().skip(1) {
288 if line.trim().starts_with("---") {
289 frontmatter_end = Some(i);
290 break;
291 }
292 }
293
294 let Some(frontmatter_end) = frontmatter_end else {
295 return Ok((None, content.to_string())); };
297
298 let frontmatter_lines = &lines[1..frontmatter_end];
300 let frontmatter_yaml = frontmatter_lines.join("\n");
301
302 let metadata: StandardizedSkillMetadata = match serde_yaml::from_str(&frontmatter_yaml) {
304 Ok(meta) => meta,
305 Err(e) => {
306 return Err(SkillError::ValidationError {
307 name: "unknown".to_string(),
308 reason: format!("invalid YAML frontmatter: {e}"),
309 });
310 }
311 };
312
313 if metadata.name.is_empty() {
315 return Err(SkillError::ValidationError {
316 name: "unknown".to_string(),
317 reason: "missing required 'name' field in frontmatter".to_string(),
318 });
319 }
320
321 if metadata.description.is_empty() {
322 return Err(SkillError::ValidationError {
323 name: metadata.name.clone(),
324 reason: "missing required 'description' field in frontmatter".to_string(),
325 });
326 }
327
328 let content_without_frontmatter = lines[frontmatter_end + 1..].join("\n");
330
331 Ok((Some(metadata), content_without_frontmatter))
332}
333
334fn resolve_with_config_dir(
336 skill_name: &str,
337 config_dir: Option<&Path>,
338) -> Result<SkillTemplate, SkillError> {
339 if let Some(skill) = try_load_standardized_skill(skill_name, config_dir)? {
341 return Ok(skill);
342 }
343
344 if let Some(content) = embedded_default(skill_name) {
346 let (metadata, content_without_frontmatter) = parse_standardized_metadata(content)?;
348
349 return Ok(SkillTemplate {
350 name: skill_name.to_string(),
351 content: content_without_frontmatter,
352 source: Source::Embedded,
353 format: SkillFormat::Standardized,
354 metadata,
355 resource_paths: None,
356 });
357 }
358
359 Err(SkillError::UnknownSkill {
360 name: skill_name.to_string(),
361 })
362}
363
364fn slugify_branch(branch: &str) -> String {
367 crate::broker::messages::slugify_branch(branch)
368}
369
370pub fn build_boot_block(branch_id: &str, broker_url: &str) -> String {
388 let template = include_str!("../assets/boot-block-template.md");
389 let slugified_branch = slugify_branch(branch_id);
390
391 template
392 .replace("{{BRANCH_ID}}", &slugified_branch)
393 .replace("{{GIT_PAW_BROKER_URL}}", broker_url)
394}
395
396#[derive(Debug, Clone, Copy, Default)]
412pub struct GateCommands<'a> {
413 pub test_command: Option<&'a str>,
415 pub lint_command: Option<&'a str>,
417 pub build_command: Option<&'a str>,
419 pub doc_build_command: Option<&'a str>,
421 pub spec_validate_command: Option<&'a str>,
425 pub fmt_check_command: Option<&'a str>,
427 pub security_audit_command: Option<&'a str>,
429}
430
431pub fn render(
470 template: &SkillTemplate,
471 branch: &str,
472 broker_url: &str,
473 project: &str,
474 gates: &GateCommands<'_>,
475) -> String {
476 const NOT_CONFIGURED: &str = "(not configured)";
477 let branch_id = slugify_branch(branch);
478
479 let mut output = template
484 .content
485 .replace("{{BRANCH_ID}}", &branch_id)
486 .replace("{{PROJECT_NAME}}", project)
487 .replace("{{GIT_PAW_BROKER_URL}}", broker_url)
488 .replace(
489 "{{TEST_COMMAND}}",
490 gates.test_command.unwrap_or(NOT_CONFIGURED),
491 )
492 .replace(
493 "{{LINT_COMMAND}}",
494 gates.lint_command.unwrap_or(NOT_CONFIGURED),
495 )
496 .replace(
497 "{{BUILD_COMMAND}}",
498 gates.build_command.unwrap_or(NOT_CONFIGURED),
499 )
500 .replace(
501 "{{DOC_BUILD_COMMAND}}",
502 gates.doc_build_command.unwrap_or(NOT_CONFIGURED),
503 )
504 .replace(
505 "{{SPEC_VALIDATE_COMMAND}}",
506 gates.spec_validate_command.unwrap_or(NOT_CONFIGURED),
507 )
508 .replace(
509 "{{FMT_CHECK_COMMAND}}",
510 gates.fmt_check_command.unwrap_or(NOT_CONFIGURED),
511 )
512 .replace(
513 "{{SECURITY_AUDIT_COMMAND}}",
514 gates.security_audit_command.unwrap_or(NOT_CONFIGURED),
515 );
516
517 if let Some(metadata) = &template.metadata {
524 output = output
525 .replace("{{SKILL_NAME}}", &metadata.name)
526 .replace("{{SKILL_DESCRIPTION}}", &metadata.description);
527 }
528
529 let mut start = 0;
532 while let Some(open) = output[start..].find("{{") {
533 let abs_open = start + open;
534 if let Some(close) = output[abs_open..].find("}}") {
535 let placeholder = &output[abs_open..abs_open + close + 2];
536 if placeholder != "{{CHANGE_ID}}" {
537 eprintln!(
538 "warning: unsubstituted placeholder {placeholder} in skill '{}'",
539 template.name
540 );
541 }
542 start = abs_open + close + 2;
543 } else {
544 break;
545 }
546 }
547
548 output
549}
550
551const GOVERNANCE_CANONICAL_NAMES: [&str; 5] =
556 ["adr", "test_strategy", "security", "dod", "constitution"];
557
558pub fn governance_section_paths(
584 adr: Option<&Path>,
585 test_strategy: Option<&Path>,
586 security: Option<&Path>,
587 dod: Option<&Path>,
588 constitution: Option<&Path>,
589) -> String {
590 let bullets: [Option<&Path>; 5] = [adr, test_strategy, security, dod, constitution];
591 if bullets.iter().all(Option::is_none) {
592 return String::new();
593 }
594
595 let mut out = String::with_capacity(192);
596 out.push_str("## Governance documents\n");
597 out.push('\n');
598 out.push_str("The supervisor consults these documents during spec audit.\n");
599 out.push('\n');
600 for (name, path) in GOVERNANCE_CANONICAL_NAMES.iter().zip(bullets.iter()) {
601 if let Some(p) = path {
602 use std::fmt::Write as _;
603 let _ = writeln!(out, "- {name}: {}", p.display());
607 }
608 }
609 out
610}
611
612#[cfg(test)]
613mod tests {
614 use super::*;
615 use serial_test::serial;
616
617 #[test]
619 fn embedded_coordination_is_reachable() {
620 let tmpl = resolve("coordination").expect("should resolve coordination");
621 assert_eq!(tmpl.source, Source::Embedded);
622 assert!(!tmpl.content.is_empty());
623 }
624
625 #[test]
627 fn embedded_coordination_contains_all_operations() {
628 let tmpl = resolve("coordination").unwrap();
629 assert!(tmpl.content.contains("agent.status"));
630 assert!(tmpl.content.contains("agent.artifact"));
631 assert!(tmpl.content.contains("agent.blocked"));
632 assert!(
633 tmpl.content
634 .contains("{{GIT_PAW_BROKER_URL}}/messages/{{BRANCH_ID}}")
635 );
636 }
637
638 #[test]
639 fn embedded_coordination_documents_supervisor_messages() {
640 let tmpl = resolve("coordination").unwrap();
641 assert!(tmpl.content.contains("agent.verified"));
642 assert!(tmpl.content.contains("agent.feedback"));
643 assert!(tmpl.content.contains("re-publish"));
644 }
645
646 #[test]
649 fn coordination_skill_documents_automatic_status_publishing() {
650 let tmpl = resolve("coordination").unwrap();
651 let lowered = tmpl.content.to_lowercase();
652 assert!(
653 lowered.contains("publishes your status automatically")
654 || lowered.contains("status publishing is automatic")
655 || lowered.contains("publishes status automatically"),
656 "coordination skill should indicate that agent.status publishing is automatic"
657 );
658 assert!(
659 !tmpl.content.contains("MUST publish agent.status"),
660 "coordination skill must not contain the legacy 'MUST publish agent.status' instruction"
661 );
662 }
663
664 #[test]
665 fn coordination_skill_contains_cherry_pick_instructions() {
666 let tmpl = resolve("coordination").unwrap();
667 assert!(
668 tmpl.content.contains("git cherry-pick"),
669 "coordination skill should contain the literal 'git cherry-pick' command"
670 );
671 assert!(
672 tmpl.content.contains("Cherry-pick peer commits"),
673 "coordination skill should contain a 'Cherry-pick peer commits' heading"
674 );
675 }
676
677 #[test]
680 fn coordination_skill_contains_before_you_start_editing_heading() {
681 let tmpl = resolve("coordination").unwrap();
682 assert!(
683 tmpl.content.contains("Before you start editing"),
684 "coordination skill should contain 'Before you start editing' heading"
685 );
686 }
687
688 #[test]
689 fn coordination_skill_contains_agent_intent_curl_example() {
690 let tmpl = resolve("coordination").unwrap();
691 let curl_pos = tmpl
692 .content
693 .find("agent.intent")
694 .expect("coordination skill should mention agent.intent");
695 let window_start = curl_pos.saturating_sub(200);
698 let window_end = (curl_pos + 800).min(tmpl.content.len());
699 let window = &tmpl.content[window_start..window_end];
700 assert!(
701 window.contains("curl"),
702 "agent.intent example should be a curl invocation"
703 );
704 assert!(
705 window.contains("\"files\""),
706 "agent.intent example should include the files field"
707 );
708 assert!(
709 window.contains("\"summary\""),
710 "agent.intent example should include the summary field"
711 );
712 assert!(
713 window.contains("\"valid_for_seconds\""),
714 "agent.intent example should include valid_for_seconds"
715 );
716 }
717
718 #[test]
719 fn coordination_skill_contains_while_youre_editing_heading() {
720 let tmpl = resolve("coordination").unwrap();
721 assert!(
722 tmpl.content.contains("While you're editing"),
723 "coordination skill should contain 'While you're editing' heading"
724 );
725 }
726
727 #[test]
728 fn coordination_skill_instructs_republish_on_scope_growth() {
729 let tmpl = resolve("coordination").unwrap();
730 let lowered = tmpl.content.to_lowercase();
731 assert!(
732 lowered.contains("scope grows") || lowered.contains("scope grow"),
733 "coordination skill should instruct re-publishing when scope grows"
734 );
735 assert!(
736 lowered.contains("re-publish"),
737 "coordination skill should mention re-publishing the intent"
738 );
739 }
740
741 #[test]
742 fn coordination_skill_instructs_question_on_peer_intent_overlap() {
743 let tmpl = resolve("coordination").unwrap();
744 assert!(
747 tmpl.content.contains("agent.question"),
748 "coordination skill should reference agent.question"
749 );
750 let lowered = tmpl.content.to_lowercase();
751 assert!(
752 lowered.contains("overlap") || lowered.contains("overlapping"),
753 "coordination skill should call out overlap as the trigger for agent.question"
754 );
755 }
756
757 #[test]
758 fn coordination_skill_contains_must_not_anti_pattern_statements() {
759 let tmpl = resolve("coordination").unwrap();
760 let lowered = tmpl.content.to_lowercase();
761 assert!(
762 lowered.contains("must not"),
763 "coordination skill should contain explicit MUST NOT statements"
764 );
765 assert!(
766 lowered.contains("pairwise"),
767 "coordination skill should reject pairwise check-ins"
768 );
769 assert!(
770 lowered.contains("go-ahead") || lowered.contains("go ahead"),
771 "coordination skill should reject waiting for go-ahead"
772 );
773 assert!(
774 lowered.contains("broker silence") || lowered.contains("silence"),
775 "coordination skill should reject blocking on broker silence"
776 );
777 }
778
779 #[test]
780 fn supervisor_skill_contains_watch_peer_intents_section() {
781 let tmpl = resolve("supervisor").unwrap();
782 assert!(
783 tmpl.content.contains("Watch peer intents"),
784 "supervisor skill should contain 'Watch peer intents' heading"
785 );
786 assert!(
787 tmpl.content.contains("agent.intent"),
788 "supervisor skill should mention agent.intent"
789 );
790 let lowered = tmpl.content.to_lowercase();
791 assert!(
792 lowered.contains("not part of this release") || lowered.contains("conflict-detection"),
793 "supervisor skill should note that automatic conflict-warning logic is not part of this release"
794 );
795 }
796
797 #[test]
802 fn supervisor_skill_references_bundled_sweep_helper() {
803 let tmpl = resolve("supervisor").unwrap();
804 let required = [
805 ".git-paw/scripts/sweep.sh snapshot",
806 ".git-paw/scripts/sweep.sh capture",
807 ".git-paw/scripts/sweep.sh approve",
808 ".git-paw/scripts/sweep.sh verified",
809 ".git-paw/scripts/sweep.sh feedback-gate",
810 ];
811 for needle in required {
812 assert!(
813 tmpl.content.contains(needle),
814 "supervisor skill should reference {needle:?}; content does not"
815 );
816 }
817 assert!(
818 !tmpl.content.contains("for p in 2 3 4 5"),
819 "supervisor skill should not contain legacy `for p in 2 3 4 5` capture-pane loops"
820 );
821 }
822
823 #[test]
825 #[serial(directory_changes)]
826 fn standard_location_skill_loading() {
827 let dir = tempfile::tempdir().unwrap();
828 let project_dir = dir.path().join("my-project");
829 std::fs::create_dir_all(&project_dir).unwrap();
830
831 let skill_dir = project_dir
833 .join(".agents")
834 .join("skills")
835 .join("coordination");
836 std::fs::create_dir_all(&skill_dir).unwrap();
837
838 let skill_md_content = "---\nname: coordination\ndescription: Custom coordination skill\n---\n\ncustom skill content";
839 std::fs::write(skill_dir.join("SKILL.md"), skill_md_content).unwrap();
840
841 let original_dir = std::env::current_dir().unwrap();
843 std::env::set_current_dir(&project_dir).unwrap();
844
845 let tmpl = resolve("coordination").expect("should resolve");
846 assert_eq!(tmpl.source, Source::AgentsStandard);
847 assert!(tmpl.content.contains("custom skill content"));
848
849 std::env::set_current_dir(original_dir).unwrap();
851 }
852
853 #[test]
855 fn unknown_skill_returns_error() {
856 let result = resolve("nonexistent");
857 assert!(
858 matches!(result, Err(SkillError::UnknownSkill { ref name }) if name == "nonexistent"),
859 "expected UnknownSkill error, got {result:?}"
860 );
861 }
862
863 #[test]
865 fn branch_id_is_substituted() {
866 let tmpl = SkillTemplate {
867 name: "test".into(),
868 content: "agent_id:\"{{BRANCH_ID}}\"".into(),
869 source: Source::Embedded,
870 format: SkillFormat::Standardized,
871 metadata: None,
872 resource_paths: None,
873 };
874 let output = render(
875 &tmpl,
876 "feat/http-broker",
877 "http://127.0.0.1:9119",
878 "git-paw",
879 &GateCommands::default(),
880 );
881 assert!(output.contains("feat-http-broker"));
882 assert!(!output.contains("{{BRANCH_ID}}"));
883 }
884
885 #[test]
887 fn broker_url_placeholder_substituted() {
888 let tmpl = SkillTemplate {
889 name: "test".into(),
890 content: "curl {{GIT_PAW_BROKER_URL}}/status".into(),
891 source: Source::Embedded,
892 format: SkillFormat::Standardized,
893 metadata: None,
894 resource_paths: None,
895 };
896 let output = render(
897 &tmpl,
898 "feat/x",
899 "http://127.0.0.1:9119",
900 "git-paw",
901 &GateCommands::default(),
902 );
903 assert!(output.contains("http://127.0.0.1:9119/status"));
904 assert!(!output.contains("{{GIT_PAW_BROKER_URL}}"));
905 }
906
907 #[test]
909 fn slug_substitution_matches_slugify_branch() {
910 let tmpl = SkillTemplate {
911 name: "test".into(),
912 content: "id={{BRANCH_ID}}".into(),
913 source: Source::Embedded,
914 format: SkillFormat::Standardized,
915 metadata: None,
916 resource_paths: None,
917 };
918 let output = render(
919 &tmpl,
920 "Feature/HTTP_Broker",
921 "http://127.0.0.1:9119",
922 "git-paw",
923 &GateCommands::default(),
924 );
925 let expected = slugify_branch("Feature/HTTP_Broker");
926 assert_eq!(output, format!("id={expected}"));
927 }
928
929 #[test]
931 fn render_is_deterministic() {
932 let tmpl = resolve("coordination").unwrap();
933 let a = render(
934 &tmpl,
935 "feat/x",
936 "http://127.0.0.1:9119",
937 "git-paw",
938 &GateCommands::default(),
939 );
940 let b = render(
941 &tmpl,
942 "feat/x",
943 "http://127.0.0.1:9119",
944 "git-paw",
945 &GateCommands::default(),
946 );
947 assert_eq!(a, b);
948 }
949
950 #[test]
952 #[serial(directory_changes)]
953 fn render_performs_no_io() {
954 let dir = tempfile::tempdir().unwrap();
955 let project_dir = dir.path().join("my-project");
956 std::fs::create_dir_all(&project_dir).unwrap();
957
958 let skill_dir = project_dir
959 .join(".agents")
960 .join("skills")
961 .join("coordination");
962 std::fs::create_dir_all(&skill_dir).unwrap();
963
964 let skill_md_content = "---\nname: coordination\ndescription: Test coordination skill\n---\n\nuser {{BRANCH_ID}}";
965 std::fs::write(skill_dir.join("SKILL.md"), skill_md_content).unwrap();
966
967 let original_dir = std::env::current_dir().unwrap();
969 std::env::set_current_dir(&project_dir).unwrap();
970
971 let tmpl = resolve("coordination").unwrap();
972 assert_eq!(tmpl.source, Source::AgentsStandard);
973
974 std::fs::remove_dir_all(skill_dir).unwrap();
976 let output = render(
977 &tmpl,
978 "feat/x",
979 "http://127.0.0.1:9119",
980 "git-paw",
981 &GateCommands::default(),
982 );
983 assert!(output.contains("feat-x"));
984
985 std::env::set_current_dir(original_dir).unwrap();
987 }
988
989 #[test]
991 fn unknown_placeholder_survives() {
992 let tmpl = SkillTemplate {
993 name: "test".into(),
994 content: "url={{UNKNOWN_THING}}".into(),
995 source: Source::Embedded,
996 format: SkillFormat::Standardized,
997 metadata: None,
998 resource_paths: None,
999 };
1000 let output = render(
1001 &tmpl,
1002 "feat/x",
1003 "http://127.0.0.1:9119",
1004 "git-paw",
1005 &GateCommands::default(),
1006 );
1007 assert!(
1008 output.contains("{{UNKNOWN_THING}}"),
1009 "unknown placeholder should survive in output"
1010 );
1011 }
1012
1013 #[test]
1015 fn no_unknown_placeholders_after_render() {
1016 let tmpl = resolve("coordination").unwrap();
1017 let output = render(
1018 &tmpl,
1019 "feat/x",
1020 "http://127.0.0.1:9119",
1021 "git-paw",
1022 &GateCommands::default(),
1023 );
1024 assert!(
1025 !output.contains("{{"),
1026 "no double-curly placeholders should remain: {output}"
1027 );
1028 }
1029
1030 #[test]
1032 fn embedded_supervisor_is_reachable() {
1033 let tmpl = resolve("supervisor").expect("should resolve supervisor");
1034 assert_eq!(tmpl.source, Source::Embedded);
1035 assert!(!tmpl.content.is_empty());
1036 }
1037
1038 #[test]
1040 fn supervisor_skill_contains_role_definition() {
1041 let tmpl = resolve("supervisor").unwrap();
1042 assert!(tmpl.content.contains("do NOT write code"));
1043 }
1044
1045 #[test]
1047 fn supervisor_skill_contains_broker_status() {
1048 let tmpl = resolve("supervisor").unwrap();
1049 assert!(tmpl.content.contains("{{GIT_PAW_BROKER_URL}}/status"));
1050 }
1051
1052 #[test]
1054 fn supervisor_skill_contains_verified_and_feedback() {
1055 let tmpl = resolve("supervisor").unwrap();
1056 assert!(tmpl.content.contains("agent.verified"));
1057 assert!(tmpl.content.contains("agent.feedback"));
1058 }
1059
1060 fn verified_curl_example_body(content: &str) -> &str {
1064 let start = content
1065 .find("\"type\":\"agent.verified\"")
1066 .expect("supervisor skill should contain an agent.verified curl example");
1067 let rest = &content[start..];
1068 let end = rest
1069 .find("}}'")
1070 .expect("agent.verified curl example should terminate with the closing payload `}}'`");
1071 &rest[..end + 3]
1072 }
1073
1074 fn feedback_curl_example_body(content: &str) -> &str {
1077 let start = content
1078 .find("\"type\":\"agent.feedback\"")
1079 .expect("supervisor skill should contain an agent.feedback curl example");
1080 let rest = &content[start..];
1081 let end = rest
1082 .find("}}'")
1083 .expect("agent.feedback curl example should terminate with the closing payload `}}'`");
1084 &rest[..end + 3]
1085 }
1086
1087 #[test]
1088 fn supervisor_verified_example_uses_correct_payload_fields() {
1089 let tmpl = resolve("supervisor").unwrap();
1090 let example = verified_curl_example_body(&tmpl.content);
1091 assert!(
1092 example.contains("verified_by"),
1093 "agent.verified example must use the `verified_by` payload field: {example}"
1094 );
1095 assert!(
1096 example.contains("message"),
1097 "agent.verified example must use the `message` payload field: {example}"
1098 );
1099 for wrong in ["\"target\"", "\"result\"", "\"notes\""] {
1100 assert!(
1101 !example.contains(wrong),
1102 "agent.verified example must not contain the stale field key {wrong}: {example}"
1103 );
1104 }
1105 }
1106
1107 #[test]
1108 fn supervisor_feedback_example_uses_correct_payload_fields() {
1109 let tmpl = resolve("supervisor").unwrap();
1110 let example = feedback_curl_example_body(&tmpl.content);
1111 assert!(
1112 example.contains("\"from\""),
1113 "agent.feedback example must use the `from` payload field: {example}"
1114 );
1115 assert!(
1116 example.contains("\"errors\""),
1117 "agent.feedback example must use the `errors` payload field: {example}"
1118 );
1119 assert!(
1120 example.contains('['),
1121 "agent.feedback example's errors field must be a JSON array (contains `[`): {example}"
1122 );
1123 assert!(
1124 example.contains(']'),
1125 "agent.feedback example's errors field must be a JSON array (contains `]`): {example}"
1126 );
1127 for wrong in ["\"target\"", "\"message\""] {
1128 assert!(
1129 !example.contains(wrong),
1130 "agent.feedback example must not contain the stale field key {wrong}: {example}"
1131 );
1132 }
1133 }
1134
1135 #[test]
1136 fn supervisor_examples_clarify_recipient_vs_sender() {
1137 let tmpl = resolve("supervisor").unwrap();
1138 let lowered = tmpl.content.to_lowercase();
1139
1140 let verified_start = tmpl
1143 .content
1144 .find("### Publish verification outcome")
1145 .expect("verified heading should be present");
1146 let feedback_start = tmpl
1147 .content
1148 .find("### Publish feedback to a peer agent")
1149 .expect("feedback heading should be present");
1150 let verified_section = tmpl.content[verified_start..feedback_start].to_lowercase();
1151 assert!(
1152 verified_section.contains("recipient") && verified_section.contains("sender"),
1153 "verified section should clarify recipient-vs-sender semantics, got: {verified_section}"
1154 );
1155
1156 let after_feedback =
1159 &tmpl.content[feedback_start + "### Publish feedback to a peer agent".len()..];
1160 let feedback_end_rel = after_feedback
1161 .find("\n### ")
1162 .unwrap_or(after_feedback.len());
1163 let feedback_section = after_feedback[..feedback_end_rel].to_lowercase();
1164 assert!(
1165 feedback_section.contains("recipient") && feedback_section.contains("sender"),
1166 "feedback section should clarify recipient-vs-sender semantics, got: {feedback_section}"
1167 );
1168
1169 assert!(lowered.contains("recipient"));
1171 assert!(lowered.contains("sender"));
1172 }
1173
1174 #[test]
1175 fn supervisor_workflow_prose_drops_legacy_verified_fields() {
1176 let tmpl = resolve("supervisor").unwrap();
1177 let condensed: String = tmpl
1180 .content
1181 .chars()
1182 .filter(|c| !c.is_whitespace())
1183 .collect();
1184 assert!(
1185 !condensed.contains("result:\"pass\""),
1186 "workflow prose must not reference `result:\"pass\"` as the verified payload"
1187 );
1188 assert!(
1189 !condensed.contains("notes:\"\""),
1190 "workflow prose must not reference `notes:\"\"` as the verified payload"
1191 );
1192 }
1193
1194 #[test]
1196 fn supervisor_skill_contains_tmux_commands() {
1197 let tmpl = resolve("supervisor").unwrap();
1198 assert!(tmpl.content.contains("tmux capture-pane"));
1199 assert!(tmpl.content.contains("tmux send-keys"));
1200 assert!(tmpl.content.contains("paw-{{PROJECT_NAME}}"));
1201 }
1202
1203 #[test]
1204 fn supervisor_skill_contains_spec_audit_procedure() {
1205 let tmpl = resolve("supervisor").unwrap();
1206 assert!(
1207 tmpl.content.contains("Spec Audit"),
1208 "supervisor skill should contain Spec Audit section"
1209 );
1210 assert!(
1211 tmpl.content.contains("openspec/changes/"),
1212 "should reference openspec/changes/ for spec file discovery"
1213 );
1214 assert!(
1215 tmpl.content.contains("grep"),
1216 "should instruct to grep for matching tests"
1217 );
1218 }
1219
1220 #[test]
1221 fn supervisor_skill_spec_audit_after_test_before_verified() {
1222 let tmpl = resolve("supervisor").unwrap();
1223 let test_pos = tmpl.content.find("Regression check").unwrap_or(0);
1224 let audit_pos = tmpl.content.find("Spec Audit").unwrap_or(0);
1225 let verify_pos = tmpl.content.find("Verify or feedback").unwrap_or(0);
1226 assert!(
1227 audit_pos > test_pos,
1228 "spec audit should appear after test/regression check"
1229 );
1230 assert!(
1231 audit_pos < verify_pos,
1232 "spec audit should appear before verify/feedback"
1233 );
1234 }
1235
1236 #[test]
1239 fn supervisor_skill_mentions_paste_buffer_recovery() {
1240 let tmpl = resolve("supervisor").unwrap();
1241 let lowered = tmpl.content.to_lowercase();
1242 assert!(
1243 lowered.contains("paste-buffer") || lowered.contains("paste buffer"),
1244 "supervisor skill should contain paste-buffer recovery sub-case"
1245 );
1246 }
1247
1248 #[test]
1249 fn supervisor_skill_mentions_pasted_text_indicator() {
1250 let tmpl = resolve("supervisor").unwrap();
1251 assert!(
1252 tmpl.content.contains("Pasted text"),
1253 "supervisor skill should mention the Claude Code 'Pasted text' indicator"
1254 );
1255 }
1256
1257 #[test]
1258 fn supervisor_skill_paste_buffer_recovery_uses_tmux() {
1259 let tmpl = resolve("supervisor").unwrap();
1260 let start = tmpl
1261 .content
1262 .to_lowercase()
1263 .find("paste-buffer recovery")
1264 .or_else(|| tmpl.content.to_lowercase().find("paste buffer recovery"))
1265 .expect("paste-buffer recovery sub-case heading should be present");
1266 let window_end = (start + 2200).min(tmpl.content.len());
1270 let window = &tmpl.content[start..window_end];
1271 assert!(
1275 window.contains(".git-paw/scripts/sweep.sh capture")
1276 || window.contains("tmux capture-pane"),
1277 "paste-buffer recovery should reference a pane-capture command (sweep.sh capture or tmux capture-pane)"
1278 );
1279 assert!(
1280 window.contains("tmux send-keys"),
1281 "paste-buffer recovery should reference tmux send-keys for the Enter recovery"
1282 );
1283 assert!(
1284 window.contains("Enter"),
1285 "paste-buffer recovery should specify Enter as the recovery keystroke"
1286 );
1287 }
1288
1289 #[test]
1290 fn supervisor_skill_mentions_launch_time_sweep() {
1291 let tmpl = resolve("supervisor").unwrap();
1292 let lowered = tmpl.content.to_lowercase();
1293 assert!(
1294 lowered.contains("launch-time pane sweep")
1295 || lowered.contains("launch time pane sweep")
1296 || lowered.contains("launch sweep"),
1297 "supervisor skill should contain a launch-time pane sweep heading"
1298 );
1299 }
1300
1301 #[test]
1302 fn supervisor_skill_launch_sweep_lists_four_pane_categories() {
1303 let tmpl = resolve("supervisor").unwrap();
1304 let lowered = tmpl.content.to_lowercase();
1305 let start = lowered
1306 .find("launch-time pane sweep")
1307 .or_else(|| lowered.find("launch sweep"))
1308 .expect("launch-time pane sweep heading should be present");
1309 let window_end = (start + 2500).min(lowered.len());
1310 let window = &lowered[start..window_end];
1311 assert!(
1312 window.contains("paste-buffer") || window.contains("paste buffer"),
1313 "launch sweep should enumerate paste-buffer category"
1314 );
1315 assert!(
1316 window.contains("permission prompt"),
1317 "launch sweep should enumerate permission-prompt category"
1318 );
1319 assert!(
1320 window.contains("working"),
1321 "launch sweep should enumerate working category"
1322 );
1323 assert!(
1324 window.contains("idle"),
1325 "launch sweep should enumerate idle category"
1326 );
1327 }
1328
1329 #[test]
1330 fn supervisor_skill_launch_sweep_references_down_enter_keystroke() {
1331 let tmpl = resolve("supervisor").unwrap();
1332 let lowered = tmpl.content.to_lowercase();
1333 let start = lowered
1334 .find("launch-time pane sweep")
1335 .or_else(|| lowered.find("launch sweep"))
1336 .expect("launch-time pane sweep heading should be present");
1337 let window_end = (start + 2500).min(lowered.len());
1338 let window = &lowered[start..window_end];
1339 assert!(
1343 window.contains("down"),
1344 "launch sweep should reference the Down keystroke for selecting 'don't ask again'"
1345 );
1346 assert!(
1347 window.contains("enter"),
1348 "launch sweep should reference the Enter keystroke for confirming approval"
1349 );
1350 assert!(
1353 window.contains("don't ask again") || window.contains("don't ask"),
1354 "launch sweep should mention the 'don't ask again' approval option"
1355 );
1356 }
1357
1358 #[test]
1359 fn supervisor_skill_paste_buffer_recovery_is_safe_by_default() {
1360 let tmpl = resolve("supervisor").unwrap();
1361 let lowered = tmpl.content.to_lowercase();
1362 let start = lowered
1363 .find("paste-buffer recovery")
1364 .or_else(|| lowered.find("paste buffer recovery"))
1365 .expect("paste-buffer recovery sub-case heading should be present");
1366 let window_end = (start + 2200).min(lowered.len());
1367 let window = &lowered[start..window_end];
1368 let safe_phrasing = window.contains("safe-by-default")
1369 || window.contains("safe by default")
1370 || window.contains("no-op")
1371 || window.contains("no harm");
1372 assert!(
1373 safe_phrasing,
1374 "paste-buffer recovery should explicitly note the Enter is safe-by-default / no-op / no harm"
1375 );
1376 }
1377
1378 #[test]
1381 fn supervisor_skill_contains_governance_verification() {
1382 let tmpl = resolve("supervisor").unwrap();
1383 assert!(
1384 tmpl.content.contains("Governance verification"),
1385 "supervisor skill should contain 'Governance verification' heading"
1386 );
1387 }
1388
1389 #[test]
1390 fn supervisor_skill_governance_is_substep_of_spec_audit() {
1391 let tmpl = resolve("supervisor").unwrap();
1392 let audit_pos = tmpl
1393 .content
1394 .find("### Spec Audit Procedure")
1395 .expect("Spec Audit Procedure heading must exist");
1396 let gov_pos = tmpl
1397 .content
1398 .find("Governance verification")
1399 .expect("Governance verification must exist");
1400 let conflict_pos = tmpl
1401 .content
1402 .find("### Conflict detection")
1403 .unwrap_or(tmpl.content.len());
1404 assert!(
1405 gov_pos > audit_pos,
1406 "Governance verification should appear inside Spec Audit Procedure (after its heading)"
1407 );
1408 assert!(
1409 gov_pos < conflict_pos,
1410 "Governance verification should appear before the next top-level subsection (Conflict detection), keeping it inside Spec Audit Procedure"
1411 );
1412 assert!(
1413 !tmpl.content.contains("step 7.5"),
1414 "Governance verification must not be framed as a separate 'step 7.5' flow step"
1415 );
1416 }
1417
1418 #[test]
1419 fn supervisor_skill_governance_examples_cover_all_five_docs() {
1420 let tmpl = resolve("supervisor").unwrap();
1421 let gov_pos = tmpl
1422 .content
1423 .find("Governance verification")
1424 .expect("Governance verification section must exist");
1425 let after = &tmpl.content[gov_pos..];
1428 let end = after.find("\n### ").unwrap_or(after.len());
1429 let section = &after[..end];
1430 for needle in &["DoD", "ADR", "Security", "Test strategy", "Constitution"] {
1431 assert!(
1432 section.contains(needle),
1433 "governance section should mention `{needle}` as a per-doc example, got:\n{section}"
1434 );
1435 }
1436 }
1437
1438 #[test]
1439 fn supervisor_skill_governance_findings_via_agent_feedback() {
1440 let tmpl = resolve("supervisor").unwrap();
1441 let gov_pos = tmpl
1442 .content
1443 .find("Governance verification")
1444 .expect("Governance verification section must exist");
1445 let after = &tmpl.content[gov_pos..];
1446 let end = after.find("\n### ").unwrap_or(after.len());
1447 let section = &after[..end];
1448 assert!(
1449 section.contains("agent.feedback"),
1450 "governance section must state that findings flow through `agent.feedback`"
1451 );
1452 }
1453
1454 #[test]
1455 fn supervisor_skill_no_governance_gate_tag() {
1456 let tmpl = resolve("supervisor").unwrap();
1457 assert!(
1458 !tmpl.content.contains("[governance-gate:"),
1459 "supervisor skill must not contain the dropped `[governance-gate:<doc>]` tag prefix"
1460 );
1461 }
1462
1463 #[test]
1464 fn supervisor_skill_no_governance_gates_table() {
1465 let tmpl = resolve("supervisor").unwrap();
1466 assert!(
1467 !tmpl.content.contains("[governance.gates]"),
1468 "supervisor skill must not reference the dropped `[governance.gates]` table"
1469 );
1470 }
1471
1472 #[test]
1473 fn supervisor_skill_no_gating_language() {
1474 let tmpl = resolve("supervisor").unwrap();
1475 let lowered = tmpl.content.to_lowercase();
1476 assert!(
1477 !lowered.contains("gating"),
1478 "supervisor skill must not use the language of 'gating'"
1479 );
1480 assert!(
1481 !lowered.contains("blocking on governance failures"),
1482 "supervisor skill must not use the language of 'blocking on governance failures'"
1483 );
1484 }
1485
1486 #[test]
1487 fn supervisor_skill_governance_missing_doc_handling() {
1488 let tmpl = resolve("supervisor").unwrap();
1489 let gov_pos = tmpl
1490 .content
1491 .find("Governance verification")
1492 .expect("Governance verification section must exist");
1493 let after = &tmpl.content[gov_pos..];
1494 let end = after.find("\n### ").unwrap_or(after.len());
1495 let section = &after[..end];
1496 let lowered = section.to_lowercase();
1497 assert!(
1498 lowered.contains("missing"),
1499 "governance section should describe missing-doc handling"
1500 );
1501 assert!(
1502 section.contains("agent.feedback"),
1503 "missing-doc handling should reference `agent.feedback` errors list"
1504 );
1505 }
1506
1507 #[test]
1508 fn supervisor_skill_governance_missing_doc_is_not_distinct_failure_type() {
1509 let tmpl = resolve("supervisor").unwrap();
1510 let gov_pos = tmpl
1511 .content
1512 .find("Governance verification")
1513 .expect("Governance verification section must exist");
1514 let after = &tmpl.content[gov_pos..];
1515 let end = after.find("\n### ").unwrap_or(after.len());
1516 let section = &after[..end];
1517 let lowered = section.to_lowercase();
1518 assert!(
1519 lowered.contains("not a distinct failure")
1520 || lowered.contains("not a separate failure")
1521 || lowered.contains("treat it as a finding"),
1522 "governance section must state that missing files are findings, not a distinct failure type; got:\n{section}"
1523 );
1524 }
1525
1526 #[test]
1527 fn supervisor_skill_governance_states_activation_condition() {
1528 let tmpl = resolve("supervisor").unwrap();
1529 let gov_pos = tmpl
1530 .content
1531 .find("Governance verification")
1532 .expect("Governance verification section must exist");
1533 let after = &tmpl.content[gov_pos..];
1534 let end = after.find("\n### ").unwrap_or(after.len());
1535 let section = &after[..end];
1536 let lowered = section.to_lowercase();
1537 assert!(
1538 lowered.contains("skip"),
1539 "governance section must instruct the supervisor to skip the sub-step when the boot prompt has no `## Governance documents` section; got:\n{section}"
1540 );
1541 assert!(
1542 section.contains("## Governance documents"),
1543 "governance section must reference the boot-prompt heading explicitly as its activation condition; got:\n{section}"
1544 );
1545 }
1546
1547 #[test]
1548 fn supervisor_skill_governance_examples_state_they_are_illustrative() {
1549 let tmpl = resolve("supervisor").unwrap();
1550 let gov_pos = tmpl
1551 .content
1552 .find("Governance verification")
1553 .expect("Governance verification section must exist");
1554 let after = &tmpl.content[gov_pos..];
1555 let end = after.find("\n### ").unwrap_or(after.len());
1556 let section = &after[..end];
1557 let lowered = section.to_lowercase();
1558 assert!(
1559 lowered.contains("illustrative") || lowered.contains("not exhaustive"),
1560 "governance section must state per-doc examples are illustrative / not exhaustive rubrics; got:\n{section}"
1561 );
1562 }
1563
1564 #[test]
1565 fn supervisor_skill_governance_states_judgment_per_project_conventions() {
1566 let tmpl = resolve("supervisor").unwrap();
1567 let gov_pos = tmpl
1568 .content
1569 .find("Governance verification")
1570 .expect("Governance verification section must exist");
1571 let after = &tmpl.content[gov_pos..];
1572 let end = after.find("\n### ").unwrap_or(after.len());
1573 let section = &after[..end];
1574 let lowered = section.to_lowercase();
1575 assert!(
1576 lowered.contains("judgment"),
1577 "governance section must state the supervisor applies judgment; got:\n{section}"
1578 );
1579 assert!(
1580 lowered.contains("convention") || lowered.contains("project"),
1581 "governance section must reference the project's conventions / process when describing judgment; got:\n{section}"
1582 );
1583 }
1584
1585 #[test]
1588 fn governance_section_empty_when_all_paths_none() {
1589 let out = governance_section_paths(None, None, None, None, None);
1590 assert!(
1591 out.is_empty(),
1592 "governance_section_paths should return empty string when all paths are None, got: {out:?}"
1593 );
1594 }
1595
1596 #[test]
1597 fn governance_section_one_path_only_dod() {
1598 let dod = Path::new("docs/dod.md");
1599 let out = governance_section_paths(None, None, None, Some(dod), None);
1600 assert!(
1601 out.contains("## Governance documents"),
1602 "section should include the canonical heading, got:\n{out}"
1603 );
1604 assert!(
1605 out.contains("- dod: docs/dod.md"),
1606 "section should include the dod bullet, got:\n{out}"
1607 );
1608 for unset in [
1609 "- adr:",
1610 "- test_strategy:",
1611 "- security:",
1612 "- constitution:",
1613 ] {
1614 assert!(
1615 !out.contains(unset),
1616 "section should not mention `{unset}` when its path is None, got:\n{out}"
1617 );
1618 }
1619 }
1620
1621 #[test]
1622 fn governance_section_lists_all_five_in_canonical_order() {
1623 let adr = Path::new("docs/adr/");
1624 let test_strategy = Path::new("docs/test-strategy.md");
1625 let security = Path::new("docs/security.md");
1626 let dod = Path::new("docs/dod.md");
1627 let constitution = Path::new("docs/constitution.md");
1628 let out = governance_section_paths(
1629 Some(adr),
1630 Some(test_strategy),
1631 Some(security),
1632 Some(dod),
1633 Some(constitution),
1634 );
1635
1636 let order = [
1637 "- adr: docs/adr/",
1638 "- test_strategy: docs/test-strategy.md",
1639 "- security: docs/security.md",
1640 "- dod: docs/dod.md",
1641 "- constitution: docs/constitution.md",
1642 ];
1643 let mut last_pos = 0usize;
1644 for bullet in order {
1645 let idx = out
1646 .find(bullet)
1647 .unwrap_or_else(|| panic!("bullet `{bullet}` not found in:\n{out}"));
1648 assert!(
1649 idx >= last_pos,
1650 "bullets must appear in canonical adr -> test_strategy -> security -> dod -> constitution order; `{bullet}` came before a previous bullet in:\n{out}"
1651 );
1652 last_pos = idx;
1653 }
1654 }
1655
1656 #[test]
1657 fn governance_section_has_no_gates_text() {
1658 let out = governance_section_paths(
1659 Some(Path::new("docs/adr/")),
1660 Some(Path::new("docs/test-strategy.md")),
1661 Some(Path::new("docs/security.md")),
1662 Some(Path::new("docs/dod.md")),
1663 Some(Path::new("docs/constitution.md")),
1664 );
1665 let lowered = out.to_lowercase();
1666 assert!(
1667 !lowered.contains("gated docs"),
1668 "section should not contain a 'Gated docs' line, got:\n{out}"
1669 );
1670 assert!(
1671 !lowered.contains("governance gates"),
1672 "section should not contain a 'Governance gates' sub-section, got:\n{out}"
1673 );
1674 assert!(
1675 !out.contains("[governance.gates]"),
1676 "section should not reference the dropped [governance.gates] table, got:\n{out}"
1677 );
1678 assert!(
1679 !out.contains("[governance-gate:"),
1680 "section should not introduce the dropped [governance-gate:<doc>] tag, got:\n{out}"
1681 );
1682 }
1683
1684 #[test]
1685 fn governance_section_has_preamble_line() {
1686 let out = governance_section_paths(None, None, None, Some(Path::new("docs/dod.md")), None);
1687 let preamble = "The supervisor consults these documents during spec audit.";
1688 assert!(
1689 out.contains(preamble),
1690 "section should include the preamble line; got:\n{out}"
1691 );
1692 let heading_pos = out.find("## Governance documents").unwrap();
1694 let preamble_pos = out.find(preamble).unwrap();
1695 let bullet_pos = out.find("- dod:").unwrap();
1696 assert!(
1697 heading_pos < preamble_pos && preamble_pos < bullet_pos,
1698 "section layout should be heading -> preamble -> bullets; got:\n{out}"
1699 );
1700 }
1701
1702 #[test]
1704 fn project_name_is_substituted() {
1705 let tmpl = SkillTemplate {
1706 name: "test".into(),
1707 content: "session=paw-{{PROJECT_NAME}}".into(),
1708 source: Source::Embedded,
1709 format: SkillFormat::Standardized,
1710 metadata: None,
1711 resource_paths: None,
1712 };
1713 let output = render(
1714 &tmpl,
1715 "feat/x",
1716 "http://127.0.0.1:9119",
1717 "my-app",
1718 &GateCommands::default(),
1719 );
1720 assert!(output.contains("paw-my-app"));
1721 assert!(!output.contains("{{PROJECT_NAME}}"));
1722 }
1723
1724 #[test]
1726 fn branch_id_and_project_name_both_substituted() {
1727 let tmpl = SkillTemplate {
1728 name: "test".into(),
1729 content: "agent={{BRANCH_ID}} session=paw-{{PROJECT_NAME}}".into(),
1730 source: Source::Embedded,
1731 format: SkillFormat::Standardized,
1732 metadata: None,
1733 resource_paths: None,
1734 };
1735 let output = render(
1736 &tmpl,
1737 "feat/http-broker",
1738 "url",
1739 "git-paw",
1740 &GateCommands::default(),
1741 );
1742 assert!(output.contains("feat-http-broker"));
1743 assert!(output.contains("paw-git-paw"));
1744 assert!(!output.contains("{{BRANCH_ID}}"));
1745 assert!(!output.contains("{{PROJECT_NAME}}"));
1746 }
1747
1748 #[test]
1750 #[serial(directory_changes)]
1751 fn standardized_skill_format_is_detected() {
1752 let dir = tempfile::tempdir().unwrap();
1753 let project_dir = dir.path().join("my-project");
1754 std::fs::create_dir_all(&project_dir).unwrap();
1755
1756 let skill_dir = project_dir
1757 .join(".agents")
1758 .join("skills")
1759 .join("test-standardized");
1760 std::fs::create_dir_all(&skill_dir).unwrap();
1761
1762 let skill_md_content = "---\nname: test-standardized\ndescription: A test standardized skill\n---\n\nThis is the skill content with {{BRANCH_ID}} placeholder.";
1763 std::fs::write(skill_dir.join("SKILL.md"), skill_md_content).unwrap();
1764
1765 let original_dir = std::env::current_dir().unwrap();
1767 std::env::set_current_dir(&project_dir).unwrap();
1768
1769 let tmpl = resolve("test-standardized").expect("should resolve");
1770 assert_eq!(tmpl.format, SkillFormat::Standardized);
1771 assert!(tmpl.content.contains("This is the skill content"));
1772 assert!(tmpl.content.contains("{{BRANCH_ID}}"));
1773 assert!(tmpl.metadata.is_some());
1774 let metadata = tmpl.metadata.as_ref().unwrap();
1775 assert_eq!(metadata.name, "test-standardized");
1776 assert_eq!(metadata.description, "A test standardized skill");
1777
1778 std::env::set_current_dir(original_dir).unwrap();
1780 }
1781
1782 #[test]
1784 fn standardized_skill_with_resources_loads_paths() {
1785 let dir = tempfile::tempdir().unwrap();
1786 let skills_parent_dir = dir.path().join("git-paw").join("agent-skills");
1787 let specific_skill_dir = skills_parent_dir.join("test-with-resources");
1788 std::fs::create_dir_all(&specific_skill_dir).unwrap();
1789
1790 std::fs::create_dir_all(specific_skill_dir.join("scripts")).unwrap();
1792 std::fs::create_dir_all(specific_skill_dir.join("references")).unwrap();
1793 std::fs::create_dir_all(specific_skill_dir.join("assets")).unwrap();
1794
1795 let skill_md_content = "---\nname: test-with-resources\ndescription: Skill with resources\n---\n\nMain content here.";
1796 std::fs::write(specific_skill_dir.join("SKILL.md"), skill_md_content).unwrap();
1797
1798 let tmpl = resolve_with_config_dir("test-with-resources", Some(dir.path()))
1799 .expect("should resolve");
1800 assert_eq!(tmpl.format, SkillFormat::Standardized);
1801 assert!(tmpl.resource_paths.is_some());
1802 let resource_paths = tmpl.resource_paths.as_ref().unwrap();
1803 assert_eq!(resource_paths.len(), 3);
1804 assert!(resource_paths.iter().any(|p| p.ends_with("scripts")));
1805 assert!(resource_paths.iter().any(|p| p.ends_with("references")));
1806 assert!(resource_paths.iter().any(|p| p.ends_with("assets")));
1807 }
1808
1809 #[test]
1811 #[serial(directory_changes)]
1812 fn standard_location_loading() {
1813 let temp_dir = tempfile::tempdir().unwrap();
1814 let project_dir = temp_dir.path().join("my-project");
1815 std::fs::create_dir_all(&project_dir).unwrap();
1816
1817 let standard_skill_dir = project_dir
1819 .join(".agents")
1820 .join("skills")
1821 .join("test-skill");
1822 std::fs::create_dir_all(&standard_skill_dir).unwrap();
1823 let standard_content = "---\nname: test-skill\ndescription: Standard location skill\n---\n\nContent from .agents/skills/";
1824 std::fs::write(standard_skill_dir.join("SKILL.md"), standard_content).unwrap();
1825
1826 let original_dir = std::env::current_dir().unwrap();
1828 std::env::set_current_dir(&project_dir).unwrap();
1829
1830 let tmpl = resolve("test-skill").expect("should resolve");
1831
1832 assert_eq!(tmpl.source, Source::AgentsStandard);
1834 assert!(tmpl.content.contains("Content from .agents/skills/"));
1835
1836 std::env::set_current_dir(original_dir).unwrap();
1838 }
1839
1840 #[test]
1842 fn standardized_skill_metadata_placeholders_are_substituted() {
1843 let metadata = StandardizedSkillMetadata {
1844 name: "test-skill".to_string(),
1845 description: "Test description".to_string(),
1846 license: None,
1847 compatibility: None,
1848 metadata: None,
1849 };
1850
1851 let tmpl = SkillTemplate {
1852 name: "test".into(),
1853 content: "Name: {{SKILL_NAME}}, Desc: {{SKILL_DESCRIPTION}}".into(),
1854 source: Source::Embedded,
1855 format: SkillFormat::Standardized,
1856 metadata: Some(metadata),
1857 resource_paths: None,
1858 };
1859
1860 let output = render(
1861 &tmpl,
1862 "feat/x",
1863 "http://127.0.0.1:9119",
1864 "git-paw",
1865 &GateCommands::default(),
1866 );
1867 assert!(output.contains("Name: test-skill, Desc: Test description"));
1868 assert!(!output.contains("{{SKILL_NAME}}"));
1869 assert!(!output.contains("{{SKILL_DESCRIPTION}}"));
1870 }
1871
1872 #[test]
1873 fn test_command_placeholder_substitutes_when_set() {
1874 let tmpl = SkillTemplate {
1875 name: "supervisor".into(),
1876 content: "Run `{{TEST_COMMAND}}` after each merge.".into(),
1877 source: Source::Embedded,
1878 format: SkillFormat::Standardized,
1879 metadata: None,
1880 resource_paths: None,
1881 };
1882 let output = render(
1883 &tmpl,
1884 "supervisor",
1885 "http://127.0.0.1:9119",
1886 "git-paw",
1887 &GateCommands {
1888 test_command: Some("just check"),
1889 ..Default::default()
1890 },
1891 );
1892 assert_eq!(output, "Run `just check` after each merge.");
1893 assert!(!output.contains("{{TEST_COMMAND}}"));
1894 }
1895
1896 #[test]
1897 fn test_command_placeholder_falls_back_when_unset() {
1898 let tmpl = SkillTemplate {
1899 name: "supervisor".into(),
1900 content: "Baseline: {{TEST_COMMAND}}".into(),
1901 source: Source::Embedded,
1902 format: SkillFormat::Standardized,
1903 metadata: None,
1904 resource_paths: None,
1905 };
1906 let output = render(
1907 &tmpl,
1908 "supervisor",
1909 "http://127.0.0.1:9119",
1910 "git-paw",
1911 &GateCommands::default(),
1912 );
1913 assert_eq!(output, "Baseline: (not configured)");
1914 assert!(!output.contains("{{TEST_COMMAND}}"));
1915 }
1916
1917 #[test]
1918 fn supervisor_template_no_unsubstituted_placeholders_when_test_command_set() {
1919 let tmpl = resolve("supervisor").expect("supervisor skill resolves");
1928 let output = render(
1929 &tmpl,
1930 "supervisor",
1931 "http://127.0.0.1:9119",
1932 "git-paw",
1933 &GateCommands {
1934 test_command: Some("just check"),
1935 ..Default::default()
1936 },
1937 );
1938 assert!(
1939 !output.contains("{{TEST_COMMAND}}"),
1940 "supervisor template still contains a literal {{TEST_COMMAND}} after render"
1941 );
1942 let remaining: String = output.replace("{{CHANGE_ID}}", "").chars().collect();
1943 assert!(
1944 !remaining.contains("{{"),
1945 "supervisor template has unsubstituted {{...}} placeholder (other than {{CHANGE_ID}}) after render"
1946 );
1947 }
1948
1949 fn render_with_gates_uniform(template: &str, value: Option<&str>) -> String {
1954 let tmpl = SkillTemplate {
1955 name: "supervisor".into(),
1956 content: template.into(),
1957 source: Source::Embedded,
1958 format: SkillFormat::Standardized,
1959 metadata: None,
1960 resource_paths: None,
1961 };
1962 let gates = GateCommands {
1963 test_command: value,
1964 lint_command: value,
1965 build_command: value,
1966 doc_build_command: value,
1967 spec_validate_command: value,
1968 fmt_check_command: value,
1969 security_audit_command: value,
1970 };
1971 render(
1972 &tmpl,
1973 "supervisor",
1974 "http://127.0.0.1:9119",
1975 "git-paw",
1976 &gates,
1977 )
1978 }
1979
1980 #[test]
1981 fn render_test_command_placeholder_substitutes_from_config() {
1982 let tmpl = SkillTemplate {
1983 name: "supervisor".into(),
1984 content: "Run {{TEST_COMMAND}}.".into(),
1985 source: Source::Embedded,
1986 format: SkillFormat::Standardized,
1987 metadata: None,
1988 resource_paths: None,
1989 };
1990 let gates = GateCommands {
1991 test_command: Some("just check"),
1992 ..Default::default()
1993 };
1994 let output = render(
1995 &tmpl,
1996 "supervisor",
1997 "http://127.0.0.1:9119",
1998 "git-paw",
1999 &gates,
2000 );
2001 assert!(
2002 output.contains("Run just check."),
2003 "expected 'Run just check.' in: {output}"
2004 );
2005 }
2006
2007 #[test]
2008 fn render_test_command_placeholder_none_renders_not_configured() {
2009 let output = render_with_gates_uniform("Run {{TEST_COMMAND}}.", None);
2010 assert!(
2011 output.contains("Run (not configured)."),
2012 "expected 'Run (not configured).' in: {output}"
2013 );
2014 }
2015
2016 #[test]
2017 fn render_lint_command_placeholder_substitutes_and_none_fallback() {
2018 let tmpl = SkillTemplate {
2019 name: "supervisor".into(),
2020 content: "Run {{LINT_COMMAND}}.".into(),
2021 source: Source::Embedded,
2022 format: SkillFormat::Standardized,
2023 metadata: None,
2024 resource_paths: None,
2025 };
2026 let gates = GateCommands {
2027 lint_command: Some("cargo clippy -- -D warnings"),
2028 ..Default::default()
2029 };
2030 let output = render(
2031 &tmpl,
2032 "supervisor",
2033 "http://127.0.0.1:9119",
2034 "git-paw",
2035 &gates,
2036 );
2037 assert!(
2038 output.contains("Run cargo clippy -- -D warnings."),
2039 "expected substitution in: {output}"
2040 );
2041
2042 let none_output = render_with_gates_uniform("Run {{LINT_COMMAND}}.", None);
2043 assert!(
2044 none_output.contains("Run (not configured)."),
2045 "expected '(not configured)' fallback in: {none_output}"
2046 );
2047 }
2048
2049 #[test]
2050 fn render_build_command_placeholder_substitutes_and_none_fallback() {
2051 let tmpl = SkillTemplate {
2052 name: "supervisor".into(),
2053 content: "Run {{BUILD_COMMAND}}.".into(),
2054 source: Source::Embedded,
2055 format: SkillFormat::Standardized,
2056 metadata: None,
2057 resource_paths: None,
2058 };
2059 let gates = GateCommands {
2060 build_command: Some("cargo build"),
2061 ..Default::default()
2062 };
2063 let output = render(
2064 &tmpl,
2065 "supervisor",
2066 "http://127.0.0.1:9119",
2067 "git-paw",
2068 &gates,
2069 );
2070 assert!(output.contains("Run cargo build."), "got: {output}");
2071
2072 let none_output = render_with_gates_uniform("Run {{BUILD_COMMAND}}.", None);
2073 assert!(
2074 none_output.contains("Run (not configured)."),
2075 "got: {none_output}"
2076 );
2077 }
2078
2079 #[test]
2080 fn render_doc_build_command_placeholder_substitutes_and_none_fallback() {
2081 let tmpl = SkillTemplate {
2082 name: "supervisor".into(),
2083 content: "Run {{DOC_BUILD_COMMAND}}.".into(),
2084 source: Source::Embedded,
2085 format: SkillFormat::Standardized,
2086 metadata: None,
2087 resource_paths: None,
2088 };
2089 let gates = GateCommands {
2090 doc_build_command: Some("mdbook build docs/"),
2091 ..Default::default()
2092 };
2093 let output = render(
2094 &tmpl,
2095 "supervisor",
2096 "http://127.0.0.1:9119",
2097 "git-paw",
2098 &gates,
2099 );
2100 assert!(output.contains("Run mdbook build docs/."), "got: {output}");
2101
2102 let none_output = render_with_gates_uniform("Run {{DOC_BUILD_COMMAND}}.", None);
2103 assert!(
2104 none_output.contains("Run (not configured)."),
2105 "got: {none_output}"
2106 );
2107 }
2108
2109 #[test]
2110 fn render_spec_validate_command_placeholder_substitutes_and_none_fallback() {
2111 let tmpl = SkillTemplate {
2112 name: "supervisor".into(),
2113 content: "Run {{SPEC_VALIDATE_COMMAND}}.".into(),
2114 source: Source::Embedded,
2115 format: SkillFormat::Standardized,
2116 metadata: None,
2117 resource_paths: None,
2118 };
2119 let gates = GateCommands {
2120 spec_validate_command: Some("openspec validate {{CHANGE_ID}} --strict"),
2121 ..Default::default()
2122 };
2123 let output = render(
2124 &tmpl,
2125 "supervisor",
2126 "http://127.0.0.1:9119",
2127 "git-paw",
2128 &gates,
2129 );
2130 assert!(
2131 output.contains("Run openspec validate {{CHANGE_ID}} --strict."),
2132 "got: {output}"
2133 );
2134
2135 let none_output = render_with_gates_uniform("Run {{SPEC_VALIDATE_COMMAND}}.", None);
2136 assert!(
2137 none_output.contains("Run (not configured)."),
2138 "got: {none_output}"
2139 );
2140 }
2141
2142 #[test]
2143 fn render_fmt_check_command_placeholder_substitutes_and_none_fallback() {
2144 let tmpl = SkillTemplate {
2145 name: "supervisor".into(),
2146 content: "Run {{FMT_CHECK_COMMAND}}.".into(),
2147 source: Source::Embedded,
2148 format: SkillFormat::Standardized,
2149 metadata: None,
2150 resource_paths: None,
2151 };
2152 let gates = GateCommands {
2153 fmt_check_command: Some("cargo fmt --check"),
2154 ..Default::default()
2155 };
2156 let output = render(
2157 &tmpl,
2158 "supervisor",
2159 "http://127.0.0.1:9119",
2160 "git-paw",
2161 &gates,
2162 );
2163 assert!(output.contains("Run cargo fmt --check."), "got: {output}");
2164
2165 let none_output = render_with_gates_uniform("Run {{FMT_CHECK_COMMAND}}.", None);
2166 assert!(
2167 none_output.contains("Run (not configured)."),
2168 "got: {none_output}"
2169 );
2170 }
2171
2172 #[test]
2173 fn render_security_audit_command_placeholder_substitutes_and_none_fallback() {
2174 let tmpl = SkillTemplate {
2175 name: "supervisor".into(),
2176 content: "Run {{SECURITY_AUDIT_COMMAND}}.".into(),
2177 source: Source::Embedded,
2178 format: SkillFormat::Standardized,
2179 metadata: None,
2180 resource_paths: None,
2181 };
2182 let gates = GateCommands {
2183 security_audit_command: Some("cargo audit"),
2184 ..Default::default()
2185 };
2186 let output = render(
2187 &tmpl,
2188 "supervisor",
2189 "http://127.0.0.1:9119",
2190 "git-paw",
2191 &gates,
2192 );
2193 assert!(output.contains("Run cargo audit."), "got: {output}");
2194
2195 let none_output = render_with_gates_uniform("Run {{SECURITY_AUDIT_COMMAND}}.", None);
2196 assert!(
2197 none_output.contains("Run (not configured)."),
2198 "got: {none_output}"
2199 );
2200 }
2201
2202 #[test]
2203 fn supervisor_skill_renders_with_all_six_gate_placeholders_set() {
2204 let tmpl = resolve("supervisor").expect("supervisor skill resolves");
2208 let gates = GateCommands {
2209 test_command: Some("CMD-TEST"),
2210 lint_command: Some("CMD-LINT"),
2211 build_command: Some("CMD-BUILD"),
2212 doc_build_command: Some("CMD-DOC"),
2213 spec_validate_command: Some("CMD-SPEC"),
2214 fmt_check_command: Some("CMD-FMT"),
2215 security_audit_command: Some("CMD-SEC"),
2216 };
2217 let output = render(
2218 &tmpl,
2219 "supervisor",
2220 "http://127.0.0.1:9119",
2221 "git-paw",
2222 &gates,
2223 );
2224 for needle in [
2225 "CMD-TEST",
2226 "CMD-LINT",
2227 "CMD-BUILD",
2228 "CMD-DOC",
2229 "CMD-SPEC",
2230 "CMD-FMT",
2231 "CMD-SEC",
2232 ] {
2233 assert!(
2234 output.contains(needle),
2235 "rendered supervisor skill should contain '{needle}'; not found"
2236 );
2237 }
2238 }
2239
2240 #[test]
2241 fn supervisor_skill_renders_not_configured_in_each_gate_when_none() {
2242 let tmpl = resolve("supervisor").expect("supervisor skill resolves");
2246 let output = render(
2247 &tmpl,
2248 "supervisor",
2249 "http://127.0.0.1:9119",
2250 "git-paw",
2251 &GateCommands::default(),
2252 );
2253
2254 let testing_start = output.find("**Testing**").expect("Testing gate present");
2256 let testing_end = output[testing_start..]
2257 .find("**Regression analysis**")
2258 .map(|p| testing_start + p)
2259 .expect("Regression follows Testing");
2260 let testing_section = &output[testing_start..testing_end];
2261 assert!(
2262 testing_section.contains("(not configured)"),
2263 "Testing gate should render '(not configured)' when gate fields are None; got:\n{testing_section}"
2264 );
2265
2266 let spec_start = output.find("**Spec audit**").expect("Spec audit present");
2268 let spec_end = output[spec_start..]
2269 .find("**Doc audit**")
2270 .map(|p| spec_start + p)
2271 .expect("Doc audit follows Spec audit");
2272 let spec_section = &output[spec_start..spec_end];
2273 assert!(
2274 spec_section.contains("(not configured)"),
2275 "Spec audit gate should render '(not configured)' when None; got:\n{spec_section}"
2276 );
2277
2278 let doc_start = output.find("**Doc audit**").expect("Doc audit present");
2280 let doc_end = output[doc_start..]
2281 .find("**Security audit**")
2282 .map(|p| doc_start + p)
2283 .expect("Security audit follows Doc audit");
2284 let doc_section = &output[doc_start..doc_end];
2285 assert!(
2286 doc_section.contains("(not configured)"),
2287 "Doc audit gate should render '(not configured)' when None; got:\n{doc_section}"
2288 );
2289
2290 let security_start = output
2292 .find("**Security audit**")
2293 .expect("Security audit present");
2294 let security_end = output[security_start..]
2295 .find("**Verify or feedback**")
2296 .map(|p| security_start + p)
2297 .expect("Verify-or-feedback follows Security audit");
2298 let security_section = &output[security_start..security_end];
2299 assert!(
2300 security_section.contains("(not configured)"),
2301 "Security audit gate should render '(not configured)' when None; got:\n{security_section}"
2302 );
2303 }
2304
2305 #[test]
2312 fn supervisor_template_gate_prose_has_no_hardcoded_git_paw_commands() {
2313 let tmpl = resolve("supervisor").expect("supervisor skill resolves");
2314 let content = &tmpl.content;
2315 let start = content
2316 .find("Steps 4-7 below are the **five first-class verification gates**")
2317 .expect("five-gate intro present");
2318 let end = content
2319 .find("### Spec Audit Procedure")
2320 .expect("Spec Audit Procedure heading present");
2321 let gate_prose = &content[start..end];
2322 for needle in [
2323 "just check",
2324 "cargo test",
2325 "cargo clippy",
2326 "cargo audit",
2327 "cargo fmt --check",
2328 "mdbook build",
2329 ] {
2330 if needle == "cargo test"
2338 && (gate_prose.contains("[testing] cargo test failed")
2339 || gate_prose.contains("testing \"cargo test failed"))
2340 {
2341 let cleaned = gate_prose.replace("cargo test failed", "<failure>");
2342 assert!(
2343 !cleaned.contains("cargo test"),
2344 "gate prose must not contain hardcoded 'cargo test' outside the §7 example"
2345 );
2346 continue;
2347 }
2348 assert!(
2349 !gate_prose.contains(needle),
2350 "gate prose must not contain hardcoded '{needle}'; replace with the matching placeholder"
2351 );
2352 }
2353 }
2354
2355 #[test]
2356 fn render_change_id_placeholder_passes_through() {
2357 let tmpl = SkillTemplate {
2358 name: "supervisor".into(),
2359 content: "Run {{SPEC_VALIDATE_COMMAND}}.".into(),
2360 source: Source::Embedded,
2361 format: SkillFormat::Standardized,
2362 metadata: None,
2363 resource_paths: None,
2364 };
2365 let gates = GateCommands {
2366 spec_validate_command: Some("openspec validate {{CHANGE_ID}} --strict"),
2367 ..Default::default()
2368 };
2369 let output = render(
2370 &tmpl,
2371 "supervisor",
2372 "http://127.0.0.1:9119",
2373 "git-paw",
2374 &gates,
2375 );
2376 assert!(
2377 output.contains("Run openspec validate {{CHANGE_ID}} --strict."),
2378 "outer placeholder substituted but inner {{CHANGE_ID}} preserved; got: {output}"
2379 );
2380 assert!(
2381 output.contains("{{CHANGE_ID}}"),
2382 "{{CHANGE_ID}} must survive verbatim (not substituted at render time); got: {output}"
2383 );
2384 }
2385
2386 #[test]
2388 fn invalid_standardized_skill_frontmatter_returns_error() {
2389 let dir = tempfile::tempdir().unwrap();
2390 let project_dir = dir.path().join("my-project");
2391 std::fs::create_dir_all(&project_dir).unwrap();
2392
2393 let skill_dir = project_dir
2394 .join(".agents")
2395 .join("skills")
2396 .join("invalid-skill");
2397 std::fs::create_dir_all(&skill_dir).unwrap();
2398
2399 let skill_md_content = "---\nname: invalid-skill\n---\n\nContent here.";
2401 std::fs::write(skill_dir.join("SKILL.md"), skill_md_content).unwrap();
2402
2403 let original_dir = std::env::current_dir().unwrap();
2405 std::env::set_current_dir(&project_dir).unwrap();
2406
2407 let result = resolve("invalid-skill");
2408 assert!(matches!(result, Err(SkillError::ValidationError { .. })));
2409
2410 std::env::set_current_dir(original_dir).unwrap();
2412 }
2413
2414 #[test]
2416 fn skill_template_is_cloneable() {
2417 let tmpl = resolve("coordination").unwrap();
2418 let cloned = tmpl.clone();
2419 assert_eq!(tmpl.name, cloned.name);
2420 assert_eq!(tmpl.content, cloned.content);
2421 assert_eq!(tmpl.source, cloned.source);
2422 }
2423
2424 #[test]
2426 fn boot_block_contains_all_four_essential_events() {
2427 let block = build_boot_block("feat/errors", "http://localhost:9119");
2428 assert!(
2429 block.contains("### 1. REGISTER"),
2430 "Missing REGISTER section"
2431 );
2432 assert!(block.contains("### 2. DONE"), "Missing DONE section");
2433 assert!(block.contains("### 3. BLOCKED"), "Missing BLOCKED section");
2434 assert!(
2435 block.contains("### 4. QUESTION"),
2436 "Missing QUESTION section"
2437 );
2438 }
2439
2440 #[test]
2441 fn boot_block_substitutes_branch_id_placeholder() {
2442 let block = build_boot_block("Feature/HTTP_Broker", "http://localhost:9119");
2443 assert!(
2444 block.contains("feature-http_broker"),
2445 "Branch ID not properly slugified"
2446 );
2447 assert!(
2448 !block.contains("{{BRANCH_ID}}"),
2449 "BRANCH_ID placeholder not substituted"
2450 );
2451 }
2452
2453 #[test]
2454 fn boot_block_substitutes_broker_url_placeholder() {
2455 let block = build_boot_block("feat/x", "http://127.0.0.1:9119");
2456 assert!(
2457 block.contains("http://127.0.0.1:9119/publish"),
2458 "Broker URL not substituted"
2459 );
2460 assert!(
2461 !block.contains("{{GIT_PAW_BROKER_URL}}"),
2462 "GIT_PAW_BROKER_URL placeholder not substituted"
2463 );
2464 }
2465
2466 #[test]
2467 fn boot_block_contains_paste_handling_instructions() {
2468 let block = build_boot_block("feat/x", "http://localhost:9119");
2469 assert!(
2470 block.contains("PASTE HANDLING"),
2471 "Missing paste handling section"
2472 );
2473 assert!(
2474 block.contains("additional Enter key"),
2475 "Missing Enter key instruction"
2476 );
2477 assert!(
2478 block.contains("[Pasted text #N]"),
2479 "Missing paste text reference"
2480 );
2481 }
2482
2483 #[test]
2484 fn boot_block_question_section_emphasizes_waiting() {
2485 let block = build_boot_block("feat/x", "http://localhost:9119");
2486 assert!(
2487 block.contains("DO NOT CONTINUE UNTIL YOU RECEIVE AN ANSWER!"),
2488 "Missing wait emphasis"
2489 );
2490 assert!(
2491 block.contains("WAIT for the answer before continuing"),
2492 "Missing wait instruction"
2493 );
2494 }
2495
2496 #[test]
2497 fn boot_block_is_deterministic() {
2498 let a = build_boot_block("feat/x", "http://localhost:9119");
2499 let b = build_boot_block("feat/x", "http://localhost:9119");
2500 assert_eq!(a, b, "Boot block generation should be deterministic");
2501 }
2502
2503 #[test]
2504 fn boot_block_handles_complex_branch_names() {
2505 let block = build_boot_block("fix/topological-cycle-fallback", "http://localhost:9119");
2506 assert!(
2507 block.contains("fix-topological-cycle-fallback"),
2508 "Complex branch name not properly slugified"
2509 );
2510 }
2511
2512 #[test]
2513 fn boot_block_contains_pre_expanded_curl_commands() {
2514 let block = build_boot_block("feat/test", "http://127.0.0.1:9119");
2515
2516 assert!(
2518 block.contains("curl -s -X POST http://127.0.0.1:9119/publish"),
2519 "Curl commands not pre-expanded"
2520 );
2521
2522 assert!(
2524 block.contains("\"agent_id\":\"feat-test\""),
2525 "Agent ID not substituted in curl commands"
2526 );
2527 }
2528
2529 fn done_section_body(block: &str) -> String {
2530 let start = block
2531 .find("### 2. DONE")
2532 .expect("rendered boot block should contain the DONE section heading");
2533 let end = block
2534 .find("### 3. BLOCKED")
2535 .expect("rendered boot block should contain the BLOCKED section heading");
2536 block[start..end].to_string()
2537 }
2538
2539 #[test]
2540 fn boot_block_done_section_leads_with_commit_instruction() {
2541 let block = build_boot_block("feat/test", "http://127.0.0.1:9119");
2542 let done_body = done_section_body(&block);
2543
2544 let commit_idx = done_body
2545 .find("commit your work")
2546 .or_else(|| done_body.find("git commit"))
2547 .expect("DONE section should lead with a commit-first instruction");
2548
2549 let manual_done_idx = done_body
2550 .find("\"status\":\"done\"")
2551 .expect("DONE section should still contain the manual done curl as a fallback");
2552
2553 assert!(
2554 commit_idx < manual_done_idx,
2555 "commit-first instruction (byte {commit_idx}) must appear before the manual done curl (byte {manual_done_idx})"
2556 );
2557 }
2558
2559 #[test]
2560 fn boot_block_done_section_names_committed_status_published_by_hook() {
2561 let block = build_boot_block("feat/test", "http://127.0.0.1:9119");
2562 let done_body = done_section_body(&block);
2563
2564 assert!(
2565 done_body.contains("status: \"committed\"")
2566 || done_body.contains("status:\"committed\""),
2567 "DONE section should name the `status: \"committed\"` event published by the hook"
2568 );
2569 assert!(
2570 done_body.contains("post-commit hook"),
2571 "DONE section should mention the post-commit hook that publishes on the agent's behalf"
2572 );
2573 }
2574
2575 #[test]
2576 fn boot_block_done_section_scopes_manual_done_to_code_less_tasks() {
2577 let block = build_boot_block("feat/test", "http://127.0.0.1:9119");
2578 let done_body = done_section_body(&block);
2579
2580 let hits = ["docs-only", "planning", "exploration"]
2581 .iter()
2582 .filter(|needle| done_body.contains(*needle))
2583 .count();
2584 assert!(
2585 hits >= 2,
2586 "DONE section should enumerate at least two code-less task examples \
2587 (docs-only / planning / exploration); only {hits} present"
2588 );
2589 }
2590
2591 #[test]
2592 fn boot_block_done_section_warns_against_manual_done_with_uncommitted_changes() {
2593 let block = build_boot_block("feat/test", "http://127.0.0.1:9119");
2594 let done_body = done_section_body(&block);
2595
2596 assert!(
2597 done_body.contains("uncommitted"),
2598 "DONE section should warn about uncommitted changes"
2599 );
2600 assert!(
2601 done_body.contains("manual `done`") || done_body.contains("manual done"),
2602 "DONE section warning should reference manual `done`"
2603 );
2604 assert!(
2605 done_body.contains("**WARNING") || done_body.contains("**DO NOT"),
2606 "DONE section warning should be emphasised with bold markers (**...**)"
2607 );
2608 }
2609
2610 #[test]
2611 fn boot_block_done_section_retains_manual_done_curl() {
2612 let block = build_boot_block("feat/test", "http://127.0.0.1:9119");
2613 let done_body = done_section_body(&block);
2614
2615 assert!(
2616 done_body.contains("curl -s -X POST http://127.0.0.1:9119/publish"),
2617 "DONE section should retain the pre-expanded broker curl"
2618 );
2619 assert!(
2620 done_body.contains("\"type\":\"agent.artifact\""),
2621 "DONE section curl should publish an agent.artifact event"
2622 );
2623 assert!(
2624 done_body.contains("\"status\":\"done\""),
2625 "DONE section curl should still publish status: done as the manual fallback"
2626 );
2627 assert!(
2628 done_body.contains("\"exports\":[]"),
2629 "DONE section curl should retain the exports field"
2630 );
2631 assert!(
2632 done_body.contains("\"modified_files\":[]"),
2633 "DONE section curl should retain the modified_files field"
2634 );
2635 }
2636
2637 #[test]
2642 fn supervisor_skill_contains_conflict_detector_tag() {
2643 let tmpl = resolve("supervisor").unwrap();
2644 assert!(
2645 tmpl.content.contains("[conflict-detector]"),
2646 "supervisor skill should reference the [conflict-detector] tag"
2647 );
2648 }
2649
2650 #[test]
2651 fn supervisor_skill_documents_broker_side_detection() {
2652 let tmpl = resolve("supervisor").unwrap();
2653 let lowered = tmpl.content.to_lowercase();
2654 assert!(
2655 lowered.contains("auto-detect") || lowered.contains("auto-emit"),
2656 "skill should mention auto-detection/auto-emission by the broker"
2657 );
2658 assert!(
2659 lowered.contains("forward conflict"),
2660 "skill should mention forward conflict"
2661 );
2662 assert!(
2663 lowered.contains("in-flight conflict"),
2664 "skill should mention in-flight conflict"
2665 );
2666 assert!(
2667 lowered.contains("ownership violation"),
2668 "skill should mention ownership violation"
2669 );
2670 }
2671
2672 #[test]
2673 fn supervisor_skill_removes_v04_manual_conflict_detection() {
2674 let tmpl = resolve("supervisor").unwrap();
2675 assert!(
2676 !tmpl
2677 .content
2678 .contains("Compare the `modified_files` arrays from every `agent.artifact` event"),
2679 "supervisor skill should no longer contain the v0.4 manual conflict-comparison instructions"
2680 );
2681 }
2682
2683 #[test]
2684 fn supervisor_skill_mentions_agent_intent() {
2685 let tmpl = resolve("supervisor").unwrap();
2686 assert!(tmpl.content.contains("agent.intent"));
2687 assert!(
2688 tmpl.content.contains("Watch peer intents")
2689 || tmpl
2690 .content
2691 .contains("Watch peer intents and broker-side conflict detection"),
2692 "skill should contain a 'Watch peer intents' heading"
2693 );
2694 }
2695
2696 #[test]
2697 fn supervisor_skill_focuses_on_question_escalations() {
2698 let tmpl = resolve("supervisor").unwrap();
2699 let lowered = tmpl.content.to_lowercase();
2700 assert!(
2703 lowered.contains("agent.question")
2704 && (lowered.contains("escalation") || lowered.contains("escalat")),
2705 "skill should direct the supervisor agent at agent.question escalations"
2706 );
2707 assert!(
2708 lowered.contains("do not") && lowered.contains("manually"),
2709 "skill should tell the supervisor not to duplicate by manual comparison"
2710 );
2711 }
2712
2713 #[test]
2716 fn embedded_coordination_mentions_spec_kit_consolidated_worktrees() {
2717 let tmpl = resolve("coordination").unwrap();
2718 assert!(
2719 tmpl.content.contains("Spec Kit")
2720 && (tmpl.content.contains("consolidated") || tmpl.content.contains("phase/")),
2721 "coordination skill should mention Spec Kit consolidated worktrees"
2722 );
2723 }
2724
2725 #[test]
2726 fn embedded_coordination_instructs_sequential_work_and_writeback() {
2727 let tmpl = resolve("coordination").unwrap();
2728 assert!(
2729 tmpl.content.contains("sequential") || tmpl.content.contains("Sequential"),
2730 "should instruct sequential execution"
2731 );
2732 assert!(
2733 tmpl.content.contains("`- [x]`") || tmpl.content.contains("- [x]"),
2734 "should mention - [x] writeback"
2735 );
2736 assert!(
2737 tmpl.content.contains("tasks.md"),
2738 "should reference tasks.md as writeback target"
2739 );
2740 }
2741
2742 #[test]
2743 fn embedded_coordination_states_agent_done_timing_for_consolidated() {
2744 let tmpl = resolve("coordination").unwrap();
2745 assert!(
2746 tmpl.content.contains("agent.done"),
2747 "should mention agent.done"
2748 );
2749 let lower = tmpl.content.to_lowercase();
2750 assert!(
2751 lower.contains("every task")
2752 || lower.contains("all listed tasks")
2753 || lower.contains("all tasks"),
2754 "should tie agent.done to completion of all listed tasks"
2755 );
2756 }
2757
2758 #[test]
2759 fn embedded_coordination_clarifies_p_worktrees_follow_standard_pattern() {
2760 let tmpl = resolve("coordination").unwrap();
2761 assert!(
2762 tmpl.content.contains("[P]") || tmpl.content.contains("task/"),
2763 "should distinguish [P] / task/ worktrees from consolidated ones"
2764 );
2765 assert!(
2766 tmpl.content.contains("standard"),
2767 "should reference the standard before/while-editing pattern"
2768 );
2769 }
2770
2771 #[test]
2777 fn supervisor_skill_has_user_input_section() {
2778 let tmpl = resolve("supervisor").unwrap();
2779 assert!(
2780 tmpl.content.contains("When the user types in your pane"),
2781 "supervisor skill should include the 'When the user types in your pane' section"
2782 );
2783 }
2784
2785 #[test]
2787 fn supervisor_skill_user_input_uses_agent_feedback_for_directives() {
2788 let tmpl = resolve("supervisor").unwrap();
2789 let start = tmpl
2790 .content
2791 .find("When the user types in your pane")
2792 .expect("user-input section heading present");
2793 let window = &tmpl.content[start..];
2794 assert!(
2795 window.contains("agent.feedback"),
2796 "user-input directives section should reference agent.feedback"
2797 );
2798 }
2799
2800 #[test]
2802 fn supervisor_skill_user_input_uses_agent_question_for_judgment_calls() {
2803 let tmpl = resolve("supervisor").unwrap();
2804 let start = tmpl
2805 .content
2806 .find("When the user types in your pane")
2807 .expect("user-input section heading present");
2808 let window = &tmpl.content[start..];
2809 assert!(
2810 window.contains("agent.question"),
2811 "user-input judgment-call section should reference agent.question"
2812 );
2813 }
2814
2815 #[test]
2817 fn supervisor_skill_user_input_states_loop_continues() {
2818 let tmpl = resolve("supervisor").unwrap();
2819 let start = tmpl
2820 .content
2821 .find("When the user types in your pane")
2822 .expect("user-input section heading present");
2823 let window = &tmpl.content[start..];
2824 assert!(
2825 window.to_lowercase().contains("autonomous"),
2826 "user-input section should state the autonomous loop continues alongside user input"
2827 );
2828 }
2829
2830 #[test]
2832 fn supervisor_skill_has_merge_orchestration_section() {
2833 let tmpl = resolve("supervisor").unwrap();
2834 assert!(
2835 tmpl.content.contains("Merge orchestration"),
2836 "supervisor skill should include the 'Merge orchestration' section"
2837 );
2838 }
2839
2840 #[test]
2842 fn supervisor_skill_merge_uses_ff_only() {
2843 let tmpl = resolve("supervisor").unwrap();
2844 let start = tmpl
2845 .content
2846 .find("Merge orchestration")
2847 .expect("merge orchestration section present");
2848 let window = &tmpl.content[start..];
2849 assert!(
2850 window.contains("git merge --ff-only"),
2851 "merge orchestration should specify git merge --ff-only"
2852 );
2853 }
2854
2855 #[test]
2857 fn supervisor_skill_merge_reverts_via_reset_hard() {
2858 let tmpl = resolve("supervisor").unwrap();
2859 let start = tmpl
2860 .content
2861 .find("Merge orchestration")
2862 .expect("merge orchestration section present");
2863 let window = &tmpl.content[start..];
2864 assert!(
2865 window.contains("git reset --hard"),
2866 "merge orchestration should describe regression revert via git reset --hard"
2867 );
2868 }
2869
2870 #[test]
2872 fn supervisor_skill_merge_cycle_uses_agent_question() {
2873 let tmpl = resolve("supervisor").unwrap();
2874 let start = tmpl
2875 .content
2876 .find("Merge orchestration")
2877 .expect("merge orchestration section present");
2878 let window = &tmpl.content[start..];
2879 assert!(
2880 window.contains("agent.question") && window.to_lowercase().contains("cycle"),
2881 "merge orchestration cycle handling should publish agent.question"
2882 );
2883 }
2884
2885 #[test]
2887 fn supervisor_skill_merge_publishes_final_status_summary() {
2888 let tmpl = resolve("supervisor").unwrap();
2889 let start = tmpl
2890 .content
2891 .find("Merge orchestration")
2892 .expect("merge orchestration section present");
2893 let window = &tmpl.content[start..];
2894 assert!(
2895 window.contains("agent.status") && window.to_lowercase().contains("summary"),
2896 "merge orchestration should end with a final agent.status summary"
2897 );
2898 }
2899
2900 #[test]
2905 fn coordination_skill_documents_slugify_terminology() {
2906 let tmpl = resolve("coordination").unwrap();
2907 assert!(
2908 tmpl.content.contains("agent_id"),
2909 "coordination skill should mention the agent_id identifier form"
2910 );
2911 assert!(
2912 tmpl.content.contains("slugify_branch"),
2913 "coordination skill should name slugify_branch as the canonical conversion"
2914 );
2915 let lowered = tmpl.content.to_lowercase();
2916 assert!(
2917 lowered.contains("references & terminology")
2918 || lowered.contains("references and terminology")
2919 || lowered.contains("terminology"),
2920 "coordination skill should contain a references/terminology heading"
2921 );
2922 }
2923
2924 #[test]
2926 fn coordination_skill_documents_stash_hygiene() {
2927 let tmpl = resolve("coordination").unwrap();
2928 assert!(
2929 tmpl.content.contains("git stash list"),
2930 "stash-hygiene section should reference `git stash list`"
2931 );
2932 assert!(
2933 tmpl.content.contains("git stash show -p"),
2934 "stash-hygiene section should reference `git stash show -p`"
2935 );
2936 let lowered = tmpl.content.to_lowercase();
2937 assert!(
2938 lowered.contains("stash hygiene") || lowered.contains("stash safety"),
2939 "coordination skill should contain a stash-hygiene heading"
2940 );
2941 assert!(
2942 lowered.contains("pop only") || lowered.contains("only pop"),
2943 "coordination skill should instruct agents to pop only their own stashes"
2944 );
2945 }
2946
2947 #[test]
2950 fn supervisor_skill_documents_main_side_intent() {
2951 let tmpl = resolve("supervisor").unwrap();
2952 let lowered = tmpl.content.to_lowercase();
2953 assert!(
2954 lowered.contains("supervisor publishes agent.intent")
2955 || lowered.contains("publish intent")
2956 || lowered.contains("main-side work"),
2957 "supervisor skill should contain a heading naming supervisor-side intent publishing"
2958 );
2959 let start = tmpl
2960 .content
2961 .find("Supervisor publishes agent.intent")
2962 .expect("supervisor-publishes-intent heading present");
2963 let window = &tmpl.content[start..];
2964 assert!(
2965 window.contains("agent.intent"),
2966 "section should mention agent.intent"
2967 );
2968 assert!(
2969 window.contains("\"supervisor\""),
2970 "section should show agent_id = \"supervisor\" in the example"
2971 );
2972 assert!(
2973 window.contains("\"files\"")
2974 && window.contains("\"summary\"")
2975 && window.contains("\"valid_for_seconds\""),
2976 "section should include a curl example with files, summary, valid_for_seconds"
2977 );
2978 }
2979
2980 #[test]
2983 fn supervisor_skill_documents_tmux_send_keys_alongside_feedback() {
2984 let tmpl = resolve("supervisor").unwrap();
2985 let start = tmpl
2986 .content
2987 .find("Send the answer to the agent pane too")
2988 .expect("drift-34 subsection should be present");
2989 let next_heading = tmpl.content[start + 1..]
2990 .find("\n### ")
2991 .map_or(tmpl.content.len(), |off| start + 1 + off);
2992 let section = &tmpl.content[start..next_heading];
2993 assert!(
2994 section.contains("tmux send-keys"),
2995 "section should contain `tmux send-keys`"
2996 );
2997 assert!(
2998 section.contains("agent.feedback"),
2999 "section should reference agent.feedback in the same section"
3000 );
3001 let lowered_section = section.to_lowercase();
3002 assert!(
3003 lowered_section.contains("do not poll") || lowered_section.contains("don't poll"),
3004 "section should state the rationale (agents do not poll their inbox)"
3005 );
3006 }
3007
3008 #[test]
3011 fn coordination_skill_documents_working_heartbeat() {
3012 let tmpl = resolve("coordination").unwrap();
3013 let lowered = tmpl.content.to_lowercase();
3014 assert!(
3015 lowered.contains("working heartbeat") || lowered.contains("heartbeat"),
3016 "coordination skill should contain a working-heartbeat heading"
3017 );
3018 assert!(
3019 tmpl.content.contains("every 5 tool uses"),
3020 "coordination skill should state the cadence as 'every 5 tool uses'"
3021 );
3022 assert!(
3023 tmpl.content.contains("agent.status"),
3024 "heartbeat reuses the agent.status shape — substring should be present"
3025 );
3026 let start = tmpl
3027 .content
3028 .find("Working heartbeat")
3029 .expect("Working heartbeat heading present");
3030 let next_heading = tmpl.content[start + 1..]
3031 .find("\n### ")
3032 .map_or(tmpl.content.len(), |off| start + 1 + off);
3033 let section = &tmpl.content[start..next_heading].to_lowercase();
3034 assert!(
3035 section.contains("filesystem watcher") || section.contains("watcher"),
3036 "heartbeat section should explain why the filesystem watcher is insufficient"
3037 );
3038 }
3039
3040 #[test]
3043 fn supervisor_skill_documents_accept_edits_audit() {
3044 let tmpl = resolve("supervisor").unwrap();
3045 let lowered = tmpl.content.to_lowercase();
3046 assert!(
3047 lowered.contains("accept-edits commits") || lowered.contains("accept edits"),
3048 "supervisor skill should contain an accept-edits audit heading"
3049 );
3050 assert!(
3051 tmpl.content.contains("modified_files"),
3052 "audit section should reference the modified_files payload field"
3053 );
3054 let start = tmpl
3055 .content
3056 .find("Verify accept-edits commits before merge")
3057 .expect("accept-edits audit heading present");
3058 let next_heading = tmpl.content[start + 1..]
3059 .find("\n### ")
3060 .map_or(tmpl.content.len(), |off| start + 1 + off);
3061 let section_lower = tmpl.content[start..next_heading].to_lowercase();
3062 assert!(
3063 section_lower.contains("out-of-scope"),
3064 "audit section should call out 'out-of-scope' edits"
3065 );
3066 assert!(
3067 section_lower.contains("shall not be silently")
3068 || section_lower.contains("not be silently auto-approved")
3069 || section_lower.contains("silently auto-approved"),
3070 "audit section should forbid silent auto-approval"
3071 );
3072 }
3073
3074 #[test]
3077 fn coordination_skill_describes_slugify_rule() {
3078 let tmpl = resolve("coordination").unwrap();
3079 let start = tmpl
3080 .content
3081 .find("slugify_branch")
3082 .expect("slugify_branch should be named in the references section");
3083 let next_heading = tmpl.content[start + 1..]
3084 .find("\n### ")
3085 .map_or(tmpl.content.len(), |off| start + 1 + off);
3086 let section_lower = tmpl.content[start..next_heading].to_lowercase();
3087 assert!(
3088 section_lower.contains("lowercase"),
3089 "slugify rule should mention lowercase step"
3090 );
3091 assert!(
3092 tmpl.content[start..next_heading].contains("[a-z0-9_]"),
3093 "slugify rule should describe the allowed char class"
3094 );
3095 assert!(
3096 (section_lower.contains("fallback") || section_lower.contains("fall back"))
3097 && section_lower.contains("agent"),
3098 "slugify rule should describe the empty-fallback to `agent`"
3099 );
3100 }
3101
3102 fn rendered_supervisor() -> String {
3111 let tmpl = resolve("supervisor").expect("supervisor skill resolves");
3112 render(
3113 &tmpl,
3114 "supervisor",
3115 "http://127.0.0.1:9119",
3116 "git-paw",
3117 &GateCommands::default(),
3118 )
3119 }
3120
3121 fn rendered_coordination() -> String {
3122 let tmpl = resolve("coordination").expect("coordination skill resolves");
3123 render(
3124 &tmpl,
3125 "feat/x",
3126 "http://127.0.0.1:9119",
3127 "git-paw",
3128 &GateCommands::default(),
3129 )
3130 }
3131
3132 #[test]
3135 fn supervisor_skill_paste_buffer_framing_is_lenient() {
3136 let content = rendered_supervisor();
3137 let lowered = content.to_lowercase();
3138 assert!(
3139 lowered.contains("even if"),
3140 "supervisor skill should frame recovery as attempted even when indicator absent; got:\n{content}"
3141 );
3142 assert!(
3143 lowered.contains("judgment"),
3144 "supervisor skill should describe applying judgment; got:\n{content}"
3145 );
3146 assert!(
3147 lowered.contains("long buffered text"),
3148 "supervisor skill should mention the long-buffered-text heuristic; got:\n{content}"
3149 );
3150 }
3151
3152 #[test]
3155 fn coordination_skill_rejects_pairwise_overcoordination() {
3156 let content = rendered_coordination();
3157 assert!(
3158 content.contains("pairwise"),
3159 "coordination skill should name `pairwise` under a MUST-NOT clause; got:\n{content}"
3160 );
3161 let lowered = content.to_lowercase();
3162 assert!(
3163 lowered.contains("explicit go-ahead"),
3164 "coordination skill should reject waiting for an explicit go-ahead; got:\n{content}"
3165 );
3166 assert!(
3167 lowered.contains("broker silence") || lowered.contains("block on broker silence"),
3168 "coordination skill should reject blocking on broker silence; got:\n{content}"
3169 );
3170 }
3171
3172 #[test]
3179 fn coordination_skill_verified_and_feedback_substrings_independent() {
3180 let content = rendered_coordination();
3181 let verified_anchor = "- **`agent.verified`**";
3182 let feedback_anchor = "- **`agent.feedback`**";
3183 assert!(
3184 content.contains(verified_anchor),
3185 "coordination skill should anchor `agent.verified` in its own bullet; got:\n{content}"
3186 );
3187 assert!(
3188 content.contains(feedback_anchor),
3189 "coordination skill should anchor `agent.feedback` in its own bullet; got:\n{content}"
3190 );
3191 let v = content.find(verified_anchor).unwrap();
3193 let f = content.find(feedback_anchor).unwrap();
3194 let between = if v < f {
3195 &content[v..f]
3196 } else {
3197 &content[f..v]
3198 };
3199 assert!(
3200 between.contains('\n'),
3201 "the verified and feedback bullets must be on separate lines; got slice:\n{between}"
3202 );
3203 }
3204
3205 #[test]
3211 fn supervisor_skill_governance_after_spec_audit_before_verified() {
3212 let content = rendered_supervisor();
3213 let spec_audit = content
3214 .find("Spec Audit Procedure")
3215 .expect("Spec Audit Procedure heading present in supervisor skill");
3216 let governance = content
3217 .find("Governance verification")
3218 .expect("Governance verification heading present in supervisor skill");
3219 let verified_after = content[governance..]
3222 .find("agent.verified")
3223 .map(|o| governance + o)
3224 .expect("agent.verified mention after Governance verification");
3225
3226 assert!(
3227 spec_audit < governance,
3228 "Spec Audit Procedure should appear before Governance verification \
3229 (spec_audit={spec_audit}, governance={governance})"
3230 );
3231 assert!(
3232 governance < verified_after,
3233 "Governance verification should appear before the next agent.verified \
3234 publish step (governance={governance}, verified_after={verified_after})"
3235 );
3236 }
3237
3238 #[test]
3241 fn coordination_skill_consolidated_agent_done_timing() {
3242 let content = rendered_coordination();
3243 let start = content
3244 .find("consolidated worktree")
3245 .or_else(|| content.find("Consolidated worktree"))
3246 .expect("coordination skill should have a consolidated-worktree section");
3247 let section = &content[start..];
3248 let lowered = section.to_lowercase();
3249 assert!(
3250 lowered.contains("agent.done") || lowered.contains("agent.artifact"),
3251 "consolidated-worktree section should describe agent.done timing; got:\n{section}"
3252 );
3253 assert!(
3254 section.contains("- [x]"),
3255 "consolidated-worktree section should require every task to show - [x]; got:\n{section}"
3256 );
3257 assert!(
3258 lowered.contains("every task") || lowered.contains("every"),
3259 "consolidated-worktree section should make the rule cover every task; got:\n{section}"
3260 );
3261 }
3262
3263 #[test]
3266 fn supervisor_skill_cross_references_agent_intent_flow() {
3267 let tmpl = resolve("supervisor").unwrap();
3268 let start = tmpl
3269 .content
3270 .find("Supervisor publishes agent.intent")
3271 .expect("supervisor-publishes-intent heading present");
3272 let next_heading = tmpl.content[start + 1..]
3273 .find("\n### ")
3274 .map_or(tmpl.content.len(), |off| start + 1 + off);
3275 let section = &tmpl.content[start..next_heading];
3276 assert!(
3277 section.contains("Before you start editing"),
3278 "supervisor-publishes-intent section should cross-reference the agent-side \
3279 `Before you start editing` heading"
3280 );
3281 assert!(
3282 section.contains("coordination.md"),
3283 "cross-reference should name the coordination skill file"
3284 );
3285 }
3286
3287 fn render_supervisor() -> String {
3293 let tmpl = resolve("supervisor").expect("resolve supervisor template");
3294 render(
3295 &tmpl,
3296 "supervisor",
3297 "http://127.0.0.1:9119",
3298 "git-paw",
3299 &GateCommands {
3300 test_command: Some("just check"),
3301 ..Default::default()
3302 },
3303 )
3304 }
3305
3306 #[test]
3310 fn supervisor_skill_self_register_curl_includes_cli_field() {
3311 let rendered = render_supervisor();
3312 let start = rendered
3313 .find("Bootstrap")
3314 .expect("Bootstrap section heading present");
3315 let next = rendered[start..]
3316 .find("### Poll session status and messages")
3317 .map_or(rendered.len(), |p| start + p);
3318 let section = &rendered[start..next];
3319 assert!(
3320 section.contains("agent.status"),
3321 "bootstrap section must publish agent.status; got:\n{section}"
3322 );
3323 assert!(
3324 section.contains("\"agent_id\":\"supervisor\""),
3325 "bootstrap curl must use agent_id=\"supervisor\"; got:\n{section}"
3326 );
3327 assert!(
3328 section.contains("\"cli\""),
3329 "bootstrap payload must include a cli field; got:\n{section}"
3330 );
3331 }
3332
3333 #[test]
3336 fn supervisor_skill_self_register_is_first_action() {
3337 let rendered = render_supervisor();
3338 let pos_bootstrap = rendered
3339 .find("Bootstrap")
3340 .expect("Bootstrap heading present");
3341 let section_end = rendered[pos_bootstrap..]
3342 .find("### Poll session status and messages")
3343 .map_or(rendered.len(), |p| pos_bootstrap + p);
3344 let section = &rendered[pos_bootstrap..section_end];
3345 let lower = section.to_lowercase();
3346 assert!(
3347 lower.contains("first action") || lower.contains("very first"),
3348 "bootstrap section must state this is the agent's first action; got:\n{section}"
3349 );
3350 }
3351
3352 #[test]
3354 fn supervisor_skill_watch_mentions_per_iteration_sweep() {
3355 let rendered = render_supervisor();
3356 let start = rendered
3357 .find("**Watch**")
3358 .expect("Watch step heading present");
3359 let end = rendered[start..]
3360 .find("Stall detection")
3361 .map_or(rendered.len(), |p| start + p);
3362 let section = &rendered[start..end];
3363 let lower = section.to_lowercase();
3364 assert!(
3365 lower.contains("every iteration")
3366 || lower.contains("every monitoring")
3367 || lower.contains("each monitoring")
3368 || lower.contains("each iteration"),
3369 "Watch section must mention per-iteration sweeping; got:\n{section}"
3370 );
3371 }
3372
3373 #[test]
3376 fn supervisor_skill_rules_bullet_mentions_routine_absorption() {
3377 let rendered = render_supervisor();
3378 let start = rendered.find("### Rules").expect("Rules section present");
3379 let end = rendered[start..]
3380 .find("### Auto-approve permission prompts")
3381 .map_or(rendered.len(), |p| start + p);
3382 let section = &rendered[start..end];
3383 let lower = section.to_lowercase();
3384 assert!(
3385 lower.contains("absorb routine approval") || lower.contains("rubber-stamp"),
3386 "Rules must include the routine-approval absorption framing; got:\n{section}"
3387 );
3388 let mut family_hits = 0;
3389 for family in ["cargo", "git commit", "mdbook", "git stash", "git restore"] {
3390 if section.contains(family) {
3391 family_hits += 1;
3392 }
3393 }
3394 assert!(
3395 family_hits >= 3,
3396 "Rules bullet must enumerate at least 3 routine families; only {family_hits} found in:\n{section}",
3397 );
3398 }
3399
3400 #[test]
3403 fn supervisor_skill_rules_bullet_enumerates_escalation_cases() {
3404 let rendered = render_supervisor();
3405 let start = rendered.find("### Rules").expect("Rules section present");
3406 let end = rendered[start..]
3407 .find("### Auto-approve permission prompts")
3408 .map_or(rendered.len(), |p| start + p);
3409 let section = &rendered[start..end];
3410 let lower = section.to_lowercase();
3411 let mut hits = 0;
3412 for case in [
3413 "cross-agent conflict",
3414 "destructive",
3415 "scope",
3416 "spec decisions",
3417 "novel",
3418 ] {
3419 if lower.contains(case) {
3420 hits += 1;
3421 }
3422 }
3423 assert!(
3424 hits >= 2,
3425 "Rules bullet must enumerate at least 2 escalation cases; only {hits} found in:\n{section}",
3426 );
3427 }
3428
3429 #[test]
3432 fn supervisor_skill_contains_every_iteration_phrase() {
3433 let rendered = render_supervisor();
3434 let lower = rendered.to_lowercase();
3435 assert!(
3436 lower.contains("every iteration") || lower.contains("every monitoring"),
3437 "skill must contain 'every iteration' or 'every monitoring' phrasing somewhere",
3438 );
3439 }
3440
3441 #[test]
3443 fn supervisor_skill_enumerates_five_gates_in_order() {
3444 let rendered = render_supervisor();
3445 let pos = |needle: &str| {
3446 rendered
3447 .find(needle)
3448 .unwrap_or_else(|| panic!("gate '{needle}' not found in supervisor skill"))
3449 };
3450 let pos_testing = pos("**Testing**");
3451 let pos_regression = pos("**Regression analysis**");
3452 let pos_spec = pos("**Spec audit**");
3453 let pos_doc = pos("**Doc audit**");
3454 let pos_security = pos("**Security audit**");
3455 assert!(
3456 pos_testing < pos_regression
3457 && pos_regression < pos_spec
3458 && pos_spec < pos_doc
3459 && pos_doc < pos_security,
3460 "five gates must appear in order Testing < Regression < Spec < Doc < Security; \
3461 got positions Testing={pos_testing} Regression={pos_regression} \
3462 Spec={pos_spec} Doc={pos_doc} Security={pos_security}",
3463 );
3464 }
3465
3466 #[test]
3469 fn supervisor_skill_verified_message_enumerates_five_gates() {
3470 let rendered = render_supervisor();
3471 let verify_start = rendered
3475 .find("**Verify or feedback**")
3476 .expect("Verify or feedback step present");
3477 let window = &rendered[verify_start..];
3478 let lower = window.to_lowercase();
3479 for needle in [
3480 "testing",
3481 "regression",
3482 "spec audit",
3483 "doc audit",
3484 "security audit",
3485 ] {
3486 assert!(
3487 lower.contains(needle),
3488 "§7 Verify-or-feedback must mention '{needle}'; got window:\n{window}",
3489 );
3490 }
3491 }
3492
3493 #[test]
3500 fn supervisor_skill_feedback_example_uses_gate_name_prefixes() {
3501 let rendered = render_supervisor();
3502 let verify_start = rendered
3503 .find("**Verify or feedback**")
3504 .expect("Verify or feedback step present");
3505 let end = rendered[verify_start..]
3508 .find("\n### ")
3509 .map_or(rendered.len(), |p| verify_start + p);
3510 let window = &rendered[verify_start..end];
3511 let mut hits = 0;
3512 for (bracketed, helper_arg) in [
3513 ("[testing]", " testing "),
3514 ("[regression]", " regression "),
3515 ("[spec audit]", " \"spec audit\" "),
3516 ("[doc audit]", " \"doc audit\" "),
3517 ("[security audit]", " \"security audit\" "),
3518 ] {
3519 if window.contains(bracketed)
3520 || window.contains(&format!("feedback-gate __FILL_IN_AGENT_ID__{helper_arg}"))
3521 {
3522 hits += 1;
3523 }
3524 }
3525 assert!(
3526 hits >= 3,
3527 "§7 agent.feedback example must show at least 3 gates (bracketed or helper-arg); \
3528 only {hits} found in:\n{window}",
3529 );
3530 }
3531
3532 #[test]
3534 fn supervisor_skill_doc_audit_enumerates_surfaces() {
3535 let rendered = render_supervisor();
3536 let start = rendered
3537 .find("**Doc audit**")
3538 .expect("Doc audit gate present");
3539 let end = rendered[start..]
3540 .find("**Security audit**")
3541 .map(|p| start + p)
3542 .expect("Security audit follows Doc audit");
3543 let section = &rendered[start..end];
3544 let mut hits = 0;
3545 for surface in ["docs/src/", "README.md", "AGENTS.md", "--help", "rustdoc"] {
3546 if section.contains(surface) {
3547 hits += 1;
3548 }
3549 }
3550 assert!(
3551 hits >= 4,
3552 "Doc audit must enumerate at least 4 of 5 doc surfaces; only {hits} found in:\n{section}",
3553 );
3554 }
3555
3556 #[test]
3559 fn supervisor_skill_security_audit_enumerates_owasp_categories() {
3560 let rendered = render_supervisor();
3561 let start = rendered
3562 .find("**Security audit**")
3563 .expect("Security audit gate present");
3564 let end = rendered[start..]
3565 .find("**Verify or feedback**")
3566 .map_or(rendered.len(), |p| start + p);
3567 let section = &rendered[start..end];
3568 let lower = section.to_lowercase();
3569 let mut hits = 0;
3570 for cat in [
3571 "command injection",
3572 "xss",
3573 "sql injection",
3574 "path traversal",
3575 "unvalidated external input",
3576 "secret leakage",
3577 ] {
3578 if lower.contains(cat) {
3579 hits += 1;
3580 }
3581 }
3582 assert!(
3583 hits >= 4,
3584 "Security audit must enumerate at least 4 of 6 OWASP categories; only {hits} found in:\n{section}",
3585 );
3586 assert!(
3587 section.contains("unwrap()") || section.contains("expect()"),
3588 "Security audit must mention the unwrap()/expect() rule; got:\n{section}",
3589 );
3590 }
3591
3592 #[test]
3595 fn supervisor_skill_governance_verification_substep_preserved() {
3596 let rendered = render_supervisor();
3597 let start = rendered
3598 .find("Governance verification")
3599 .expect("Governance verification sub-step still present");
3600 let end = (start + 2000).min(rendered.len());
3601 let section = &rendered[start..end];
3602 for needle in [
3603 "DoD",
3604 "ADR",
3605 "security.md",
3606 "test-strategy.md",
3607 "constitution.md",
3608 ] {
3609 assert!(
3610 section.contains(needle),
3611 "governance sub-step must still reference '{needle}'; got:\n{section}",
3612 );
3613 }
3614 }
3615
3616 #[test]
3624 fn coordination_skill_documents_commit_cadence() {
3625 let tmpl = resolve("coordination").unwrap();
3626 let lowered = tmpl.content.to_lowercase();
3627 assert!(
3628 lowered.contains("commit cadence") || lowered.contains("per-group commit cadence"),
3629 "coordination skill should have a heading naming the commit-cadence concept; \
3630 got:\n{}",
3631 tmpl.content
3632 );
3633 assert!(
3634 lowered.contains("group") || lowered.contains("section"),
3635 "commit-cadence section should mention the GROUP/section grain"
3636 );
3637 let has_conventional_prefix = ["feat(", "fix(", "docs(", "test(", "chore("]
3638 .iter()
3639 .any(|p| tmpl.content.contains(p));
3640 assert!(
3641 has_conventional_prefix,
3642 "commit-cadence section should show at least one conventional-commit prefix example"
3643 );
3644 }
3645
3646 #[test]
3649 fn coordination_skill_forbids_opsx_verify_and_archive() {
3650 let tmpl = resolve("coordination").unwrap();
3651 assert!(
3652 tmpl.content.contains("/opsx:verify"),
3653 "coordination skill should name `/opsx:verify` literally"
3654 );
3655 assert!(
3656 tmpl.content.contains("/opsx:archive"),
3657 "coordination skill should name `/opsx:archive` literally"
3658 );
3659 let lowered = tmpl.content.to_lowercase();
3660 assert!(
3661 lowered.contains("off-limits")
3662 || lowered.contains("do not invoke")
3663 || lowered.contains("shall not")
3664 || lowered.contains("supervisor's job"),
3665 "coordination skill should state both are not the coding agent's responsibility"
3666 );
3667 }
3668
3669 #[test]
3672 fn coordination_skill_names_terminal_action() {
3673 let tmpl = resolve("coordination").unwrap();
3674 assert!(
3675 tmpl.content.contains("agent.artifact"),
3676 "coordination skill should name `agent.artifact` as the terminal publish"
3677 );
3678 assert!(
3679 tmpl.content.contains("\"done\"") || tmpl.content.contains("\"committed\""),
3680 "coordination skill should reference status: \"done\" or \"committed\""
3681 );
3682 }
3683
3684 #[test]
3687 fn supervisor_skill_documents_pane_current_path_resolution() {
3688 let tmpl = resolve("supervisor").unwrap();
3689 assert!(
3690 tmpl.content.contains("tmux display-message"),
3691 "supervisor skill should show the tmux display-message command"
3692 );
3693 assert!(
3694 tmpl.content.contains("pane_current_path"),
3695 "supervisor skill should name pane_current_path literally"
3696 );
3697 let lowered = tmpl.content.to_lowercase();
3698 assert!(
3699 lowered.contains("not alphabetical")
3700 || lowered.contains("not sorted alphabetically")
3701 || lowered.contains("are not alphabetical"),
3702 "supervisor skill should warn against alphabetical pane-index assumptions"
3703 );
3704 assert!(
3705 lowered.contains("cli-argument order")
3706 || lowered.contains("cli argument order")
3707 || lowered.contains("argument order"),
3708 "supervisor skill should warn against CLI-argument-order pane-index assumptions"
3709 );
3710 }
3711
3712 #[test]
3717 fn supervisor_skill_documents_proactive_launch_sweep() {
3718 let tmpl = resolve("supervisor").unwrap();
3719 let lowered = tmpl.content.to_lowercase();
3720 let start = lowered
3721 .find("launch-time pane sweep")
3722 .or_else(|| lowered.find("launch sweep"))
3723 .expect("launch-time pane sweep heading should be present");
3724 let window_end = (start + 2500).min(lowered.len());
3725 let window = &lowered[start..window_end];
3726 assert!(
3727 window.contains("immediately after attaching")
3728 || window.contains("before the poll thread")
3729 || window.contains("first-few-seconds")
3730 || window.contains("first few seconds"),
3731 "launch sweep should link the sweep to the first-few-seconds-after-attach window",
3732 );
3733 }
3734
3735 #[test]
3736 fn supervisor_skill_launch_sweep_escalates_unknown_via_agent_question() {
3737 let tmpl = resolve("supervisor").unwrap();
3738 let lowered = tmpl.content.to_lowercase();
3739 let start = lowered
3740 .find("launch-time pane sweep")
3741 .or_else(|| lowered.find("launch sweep"))
3742 .expect("launch-time pane sweep heading should be present");
3743 let window_end = (start + 2500).min(lowered.len());
3744 let window = &lowered[start..window_end];
3745 assert!(
3746 window.contains("unknown") || window.contains("wider scope"),
3747 "launch sweep should classify a third category for unknown/wider-scope prompts",
3748 );
3749 assert!(
3750 window.contains("agent.question"),
3751 "launch sweep should instruct agent.question escalation for unknown prompts",
3752 );
3753 assert!(
3754 window.contains("escalate"),
3755 "launch sweep should use the word 'escalate' alongside the agent.question instruction",
3756 );
3757 }
3758
3759 #[test]
3760 fn supervisor_skill_launch_sweep_complements_auto_approve_thread() {
3761 let tmpl = resolve("supervisor").unwrap();
3762 let lowered = tmpl.content.to_lowercase();
3763 let start = lowered
3764 .find("launch-time pane sweep")
3765 .or_else(|| lowered.find("launch sweep"))
3766 .expect("launch-time pane sweep heading should be present");
3767 let window_end = (start + 2500).min(lowered.len());
3768 let window = &lowered[start..window_end];
3769 assert!(
3770 window.contains("complements"),
3771 "launch sweep should describe itself as complementing the auto-approve thread",
3772 );
3773 assert!(
3774 window.contains("does not replace")
3775 || window.contains("not replace")
3776 || window.contains("does **not** replace"),
3777 "launch sweep should explicitly say it does NOT replace the auto-approve thread",
3778 );
3779 assert!(
3780 window.contains("[supervisor.auto_approve]") || window.contains("auto_approve"),
3781 "launch sweep should cross-reference the [supervisor.auto_approve] poll thread",
3782 );
3783 }
3784
3785 #[test]
3793 fn supervisor_skill_paste_buffer_cross_ref_in_send_keys_section() {
3794 let tmpl = resolve("supervisor").unwrap();
3795 let lowered = tmpl.content.to_lowercase();
3796 let start = lowered
3800 .find("send the answer to the agent pane")
3801 .or_else(|| lowered.find("agents do not poll their inbox"))
3802 .expect("send-keys-alongside-agent.feedback section should be present");
3803 let window_end = (start + 2200).min(lowered.len());
3804 let window = &lowered[start..window_end];
3805
3806 assert!(
3807 window.contains("paste-buffer")
3808 || window.contains("paste buffer")
3809 || window.contains("follow-up enter")
3810 || window.contains("follow-up `enter`"),
3811 "send-keys-alongside-feedback section must cross-reference paste-buffer recovery / follow-up Enter for long answers",
3812 );
3813 }
3814
3815 #[test]
3823 fn supervisor_skill_warns_against_git_paw_status_ordering() {
3824 let tmpl = resolve("supervisor").unwrap();
3825 assert!(
3828 tmpl.content.contains("git paw status"),
3829 "supervisor skill should reference `git paw status` by name when warning against using its ordering as a mapping source",
3830 );
3831
3832 let lowered = tmpl.content.to_lowercase();
3833 let start = lowered
3834 .find("pane_current_path")
3835 .expect("pane_current_path resolution section should be present");
3836 let window_end = (start + 2500).min(lowered.len());
3837 let window = &lowered[start..window_end];
3838
3839 assert!(
3840 window.contains("git paw status"),
3841 "the warning against `git paw status` ordering must appear within the pane_current_path resolution section",
3842 );
3843 assert!(
3844 window.contains("shall not be inferred")
3845 || window.contains("must not")
3846 || window.contains("not be inferred")
3847 || window.contains("not used as a mapping")
3848 || window.contains("no relationship"),
3849 "section must forbid using `git paw status` order as a mapping source",
3850 );
3851 }
3852}