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 {
392 let template = include_str!("../assets/boot-block-template.md");
393 let slugified_branch = slugify_branch(branch_id);
394
395 template
396 .replace("{{BRANCH_ID}}", &slugified_branch)
397 .replace("{{GIT_PAW_BROKER_URL}}", broker_url)
398}
399
400#[derive(Debug, Clone, Copy, Default)]
416pub struct GateCommands<'a> {
417 pub test_command: Option<&'a str>,
419 pub lint_command: Option<&'a str>,
421 pub build_command: Option<&'a str>,
423 pub doc_build_command: Option<&'a str>,
425 pub spec_validate_command: Option<&'a str>,
429 pub fmt_check_command: Option<&'a str>,
431 pub security_audit_command: Option<&'a str>,
433 pub doc_tool_command: Option<&'a str>,
439}
440
441pub fn render(
507 template: &SkillTemplate,
508 branch: &str,
509 broker_url: &str,
510 project: &str,
511 gates: &GateCommands<'_>,
512 backends: &[crate::specs::SpecBackendKind],
513) -> String {
514 const NOT_CONFIGURED: &str = "(not configured)";
515 let branch_id = slugify_branch(branch);
516
517 let allowlist_prose = render_dev_allowlist_preset();
526 let spec_doctrine = render_spec_path_doctrine(backends);
527 let mut output = template
528 .content
529 .replace("{{BRANCH_ID}}", &branch_id)
530 .replace("{{PROJECT_NAME}}", project)
531 .replace("{{GIT_PAW_BROKER_URL}}", broker_url)
532 .replace(
533 "{{TEST_COMMAND}}",
534 gates.test_command.unwrap_or(NOT_CONFIGURED),
535 )
536 .replace(
537 "{{LINT_COMMAND}}",
538 gates.lint_command.unwrap_or(NOT_CONFIGURED),
539 )
540 .replace(
541 "{{BUILD_COMMAND}}",
542 gates.build_command.unwrap_or(NOT_CONFIGURED),
543 )
544 .replace(
545 "{{DOC_BUILD_COMMAND}}",
546 gates.doc_build_command.unwrap_or(NOT_CONFIGURED),
547 )
548 .replace(
549 "{{SPEC_VALIDATE_COMMAND}}",
550 gates.spec_validate_command.unwrap_or(NOT_CONFIGURED),
551 )
552 .replace(
553 "{{FMT_CHECK_COMMAND}}",
554 gates.fmt_check_command.unwrap_or(NOT_CONFIGURED),
555 )
556 .replace(
557 "{{SECURITY_AUDIT_COMMAND}}",
558 gates.security_audit_command.unwrap_or(NOT_CONFIGURED),
559 )
560 .replace("{{DOC_TOOL_COMMAND}}", gates.doc_tool_command.unwrap_or(""))
561 .replace("{{DEV_ALLOWLIST_PRESET}}", &allowlist_prose)
562 .replace("{{SPEC_PATH_DOCTRINE}}", &spec_doctrine);
563
564 if let Some(metadata) = &template.metadata {
571 output = output
572 .replace("{{SKILL_NAME}}", &metadata.name)
573 .replace("{{SKILL_DESCRIPTION}}", &metadata.description);
574 }
575
576 let opsx_active = backends
582 .iter()
583 .any(|b| matches!(b, crate::specs::SpecBackendKind::OpenSpec));
584 output = render_opsx_regions(&output, opsx_active);
585
586 let mut start = 0;
589 while let Some(open) = output[start..].find("{{") {
590 let abs_open = start + open;
591 if let Some(close) = output[abs_open..].find("}}") {
592 let placeholder = &output[abs_open..abs_open + close + 2];
593 if placeholder != "{{CHANGE_ID}}" {
594 eprintln!(
595 "warning: unsubstituted placeholder {placeholder} in skill '{}'",
596 template.name
597 );
598 }
599 start = abs_open + close + 2;
600 } else {
601 break;
602 }
603 }
604
605 output
606}
607
608pub(crate) const OPSX_REGION_BEGIN: &str = "<!-- opsx-role-gating:begin -->";
610pub(crate) const OPSX_REGION_END: &str = "<!-- opsx-role-gating:end -->";
612
613#[must_use]
624pub(crate) fn render_opsx_regions(input: &str, keep: bool) -> String {
625 let has_trailing_newline = input.ends_with('\n');
626 let mut kept: Vec<&str> = Vec::new();
627 let mut in_region = false;
628 for line in input.split('\n') {
629 let trimmed = line.trim();
630 if trimmed == OPSX_REGION_BEGIN {
631 in_region = true;
632 continue;
633 }
634 if trimmed == OPSX_REGION_END {
635 in_region = false;
636 continue;
637 }
638 if in_region && !keep {
639 continue;
640 }
641 kept.push(line);
642 }
643 let mut out = kept.join("\n");
644 if has_trailing_newline {
645 out.push('\n');
646 }
647 out
648}
649
650pub(crate) const SPEC_DOCTRINE_NO_BACKEND_SENTINEL: &str = "(no spec backend resolved for this session — see your project's documentation for where specs live.)";
654
655#[must_use]
670pub fn render_dev_allowlist_preset() -> String {
671 use crate::supervisor::dev_allowlist::DEV_ALLOWLIST_PRESET;
672
673 let mut groups: Vec<(String, Vec<String>)> = Vec::new();
674 for entry in DEV_ALLOWLIST_PRESET {
675 let (head, tail) = match entry.split_once(' ') {
676 Some((h, t)) => (h.to_string(), Some(t.to_string())),
677 None => (entry.to_string(), None),
678 };
679 if let Some(existing) = groups.iter_mut().find(|(h, _)| h == &head) {
680 if let Some(t) = tail {
681 existing.1.push(t);
682 }
683 } else {
684 groups.push((head, tail.into_iter().collect()));
685 }
686 }
687
688 let parts: Vec<String> = groups
689 .into_iter()
690 .map(|(head, members)| {
691 if members.is_empty() {
692 head
693 } else if members.len() == 1 {
694 format!("{head} {}", members[0])
695 } else {
696 format!("{head} ({})", members.join(", "))
697 }
698 })
699 .collect();
700 parts.join("; ")
701}
702
703#[must_use]
714pub fn render_spec_path_doctrine(backends: &[crate::specs::SpecBackendKind]) -> String {
715 use crate::specs::SpecBackendKind;
716
717 let mut seen: Vec<SpecBackendKind> = Vec::new();
718 for b in backends {
719 if !seen.contains(b) {
720 seen.push(*b);
721 }
722 }
723
724 if seen.is_empty() {
725 return SPEC_DOCTRINE_NO_BACKEND_SENTINEL.to_string();
726 }
727
728 let per_backend = |kind: SpecBackendKind| -> &'static str {
729 match kind {
730 SpecBackendKind::OpenSpec => {
731 "OpenSpec specs live under `openspec/changes/<change-name>/{proposal,specs,tasks}.md` \
732 with archived deltas merged into `openspec/specs/`; run `openspec validate <change-name> --strict` \
733 to verify a change."
734 }
735 SpecBackendKind::SpecKit => {
736 "Spec Kit specs live under `.specify/specs/<feature>/{spec,plan,tasks}.md` \
737 and use the Spec Kit checklist convention; mark `- [ ]` tasks complete as each one lands."
738 }
739 SpecBackendKind::Markdown => {
740 "Markdown specs are flat `.md` files with `paw_status: pending` frontmatter; \
741 the format has no per-artifact workflow — the file itself is the contract."
742 }
743 }
744 };
745
746 if seen.len() == 1 {
747 per_backend(seen[0]).to_string()
748 } else {
749 let intro =
750 "This session spans multiple spec backends — apply the matching doctrine per spec:";
751 let sentences: Vec<String> = seen
752 .into_iter()
753 .map(|b| format!("- {}", per_backend(b)))
754 .collect();
755 format!("{intro}\n{}", sentences.join("\n"))
756 }
757}
758
759const GOVERNANCE_CANONICAL_NAMES: [&str; 5] =
764 ["adr", "test_strategy", "security", "dod", "constitution"];
765
766pub fn governance_section_paths(
792 adr: Option<&Path>,
793 test_strategy: Option<&Path>,
794 security: Option<&Path>,
795 dod: Option<&Path>,
796 constitution: Option<&Path>,
797) -> String {
798 let bullets: [Option<&Path>; 5] = [adr, test_strategy, security, dod, constitution];
799 if bullets.iter().all(Option::is_none) {
800 return String::new();
801 }
802
803 let mut out = String::with_capacity(192);
804 out.push_str("## Governance documents\n");
805 out.push('\n');
806 out.push_str("The supervisor consults these documents during spec audit.\n");
807 out.push('\n');
808 for (name, path) in GOVERNANCE_CANONICAL_NAMES.iter().zip(bullets.iter()) {
809 if let Some(p) = path {
810 use std::fmt::Write as _;
811 let _ = writeln!(out, "- {name}: {}", p.display());
815 }
816 }
817 out
818}
819
820#[cfg(test)]
821mod tests {
822 use super::*;
823 use serial_test::serial;
824
825 #[test]
827 fn embedded_coordination_is_reachable() {
828 let tmpl = resolve("coordination").expect("should resolve coordination");
829 assert_eq!(tmpl.source, Source::Embedded);
830 assert!(!tmpl.content.is_empty());
831 }
832
833 #[test]
835 fn embedded_coordination_contains_all_operations() {
836 let tmpl = resolve("coordination").unwrap();
837 assert!(tmpl.content.contains("agent.status"));
838 assert!(tmpl.content.contains("agent.artifact"));
839 assert!(tmpl.content.contains("agent.blocked"));
840 assert!(
841 tmpl.content
842 .contains("{{GIT_PAW_BROKER_URL}}/messages/{{BRANCH_ID}}")
843 );
844 }
845
846 #[test]
847 fn embedded_coordination_documents_supervisor_messages() {
848 let tmpl = resolve("coordination").unwrap();
849 assert!(tmpl.content.contains("agent.verified"));
850 assert!(tmpl.content.contains("agent.feedback"));
851 assert!(tmpl.content.contains("re-publish"));
852 }
853
854 #[test]
857 fn coordination_skill_documents_automatic_status_publishing() {
858 let tmpl = resolve("coordination").unwrap();
859 let lowered = tmpl.content.to_lowercase();
860 assert!(
861 lowered.contains("publishes your status automatically")
862 || lowered.contains("status publishing is automatic")
863 || lowered.contains("publishes status automatically"),
864 "coordination skill should indicate that agent.status publishing is automatic"
865 );
866 assert!(
867 !tmpl.content.contains("MUST publish agent.status"),
868 "coordination skill must not contain the legacy 'MUST publish agent.status' instruction"
869 );
870 }
871
872 #[test]
873 fn coordination_skill_contains_cherry_pick_instructions() {
874 let tmpl = resolve("coordination").unwrap();
875 assert!(
876 tmpl.content.contains("git cherry-pick"),
877 "coordination skill should contain the literal 'git cherry-pick' command"
878 );
879 assert!(
880 tmpl.content.contains("Cherry-pick peer commits"),
881 "coordination skill should contain a 'Cherry-pick peer commits' heading"
882 );
883 }
884
885 #[test]
891 fn coordination_skill_teaches_main_advances_discipline() {
892 let tmpl = resolve("coordination").unwrap();
893 let content = &tmpl.content;
894
895 let idx = content
896 .find("When main advances")
897 .expect("coordination skill has a 'When main advances' subsection");
898 let section = &content[idx..];
899 let lowered = section.to_lowercase();
900
901 assert!(
903 section.contains("agent.advanced-main") && section.contains("/messages/{{BRANCH_ID}}"),
904 "subsection must name the event and its delivery on the normal /messages poll"
905 );
906 assert!(
908 lowered.contains("not auto-rebase")
909 || lowered.contains("not trigger an automatic rebase"),
910 "subsection must contain an explicit do-not-auto-rebase rule"
911 );
912 assert!(
914 section.contains("git fetch origin")
915 && section.contains("git log HEAD..origin/")
916 && lowered.contains("decide"),
917 "subsection must document the fetch + inspect + decide flow"
918 );
919 assert!(
921 (lowered.contains("commit") || lowered.contains("stash"))
922 && lowered.contains("before")
923 && lowered.contains("rebase"),
924 "subsection must require a commit or stash before any rebase"
925 );
926 assert!(
928 lowered.contains("uncommitted"),
929 "subsection must include the concrete uncommitted-edits example"
930 );
931 }
932
933 #[test]
936 fn coordination_skill_contains_before_you_start_editing_heading() {
937 let tmpl = resolve("coordination").unwrap();
938 assert!(
939 tmpl.content.contains("Before you start editing"),
940 "coordination skill should contain 'Before you start editing' heading"
941 );
942 }
943
944 #[test]
945 fn coordination_skill_contains_agent_intent_curl_example() {
946 let tmpl = resolve("coordination").unwrap();
947 let curl_pos = tmpl
948 .content
949 .find("agent.intent")
950 .expect("coordination skill should mention agent.intent");
951 let window_start = curl_pos.saturating_sub(200);
954 let window_end = (curl_pos + 800).min(tmpl.content.len());
955 let window = &tmpl.content[window_start..window_end];
956 assert!(
957 window.contains("curl"),
958 "agent.intent example should be a curl invocation"
959 );
960 assert!(
961 window.contains("\"files\""),
962 "agent.intent example should include the files field"
963 );
964 assert!(
965 window.contains("\"summary\""),
966 "agent.intent example should include the summary field"
967 );
968 assert!(
969 window.contains("\"valid_for_seconds\""),
970 "agent.intent example should include valid_for_seconds"
971 );
972 }
973
974 #[test]
975 fn coordination_skill_contains_while_youre_editing_heading() {
976 let tmpl = resolve("coordination").unwrap();
977 assert!(
978 tmpl.content.contains("While you're editing"),
979 "coordination skill should contain 'While you're editing' heading"
980 );
981 }
982
983 #[test]
984 fn coordination_skill_instructs_republish_on_scope_growth() {
985 let tmpl = resolve("coordination").unwrap();
986 let lowered = tmpl.content.to_lowercase();
987 assert!(
988 lowered.contains("scope grows") || lowered.contains("scope grow"),
989 "coordination skill should instruct re-publishing when scope grows"
990 );
991 assert!(
992 lowered.contains("re-publish"),
993 "coordination skill should mention re-publishing the intent"
994 );
995 }
996
997 #[test]
998 fn coordination_skill_instructs_question_on_peer_intent_overlap() {
999 let tmpl = resolve("coordination").unwrap();
1000 assert!(
1003 tmpl.content.contains("agent.question"),
1004 "coordination skill should reference agent.question"
1005 );
1006 let lowered = tmpl.content.to_lowercase();
1007 assert!(
1008 lowered.contains("overlap") || lowered.contains("overlapping"),
1009 "coordination skill should call out overlap as the trigger for agent.question"
1010 );
1011 }
1012
1013 #[test]
1014 fn coordination_skill_contains_must_not_anti_pattern_statements() {
1015 let tmpl = resolve("coordination").unwrap();
1016 let lowered = tmpl.content.to_lowercase();
1017 assert!(
1018 lowered.contains("must not"),
1019 "coordination skill should contain explicit MUST NOT statements"
1020 );
1021 assert!(
1022 lowered.contains("pairwise"),
1023 "coordination skill should reject pairwise check-ins"
1024 );
1025 assert!(
1026 lowered.contains("go-ahead") || lowered.contains("go ahead"),
1027 "coordination skill should reject waiting for go-ahead"
1028 );
1029 assert!(
1030 lowered.contains("broker silence") || lowered.contains("silence"),
1031 "coordination skill should reject blocking on broker silence"
1032 );
1033 }
1034
1035 #[test]
1036 fn supervisor_skill_contains_watch_peer_intents_section() {
1037 let tmpl = resolve("supervisor").unwrap();
1038 assert!(
1039 tmpl.content.contains("Watch peer intents"),
1040 "supervisor skill should contain 'Watch peer intents' heading"
1041 );
1042 assert!(
1043 tmpl.content.contains("agent.intent"),
1044 "supervisor skill should mention agent.intent"
1045 );
1046 let lowered = tmpl.content.to_lowercase();
1047 assert!(
1048 lowered.contains("not part of this release") || lowered.contains("conflict-detection"),
1049 "supervisor skill should note that automatic conflict-warning logic is not part of this release"
1050 );
1051 }
1052
1053 #[test]
1058 fn supervisor_skill_references_bundled_sweep_helper() {
1059 let tmpl = resolve("supervisor").unwrap();
1060 let required = [
1061 ".git-paw/scripts/sweep.sh snapshot",
1062 ".git-paw/scripts/sweep.sh capture",
1063 ".git-paw/scripts/sweep.sh approve",
1064 ".git-paw/scripts/sweep.sh verified",
1065 ".git-paw/scripts/sweep.sh feedback-gate",
1066 ];
1067 for needle in required {
1068 assert!(
1069 tmpl.content.contains(needle),
1070 "supervisor skill should reference {needle:?}; content does not"
1071 );
1072 }
1073 assert!(
1074 !tmpl.content.contains("for p in 2 3 4 5"),
1075 "supervisor skill should not contain legacy `for p in 2 3 4 5` capture-pane loops"
1076 );
1077 }
1078
1079 #[test]
1086 fn supervisor_skill_uses_repo_local_verify_scratch_dir() {
1087 let tmpl = resolve("supervisor").unwrap();
1088 assert!(
1089 tmpl.content.contains(".git-paw/tmp/verify-"),
1090 "supervisor skill should name the repo-local verify scratch path .git-paw/tmp/verify-"
1091 );
1092 assert!(
1093 tmpl.content.contains("git worktree add --detach"),
1094 "supervisor skill should teach the `git worktree add --detach` verify recipe"
1095 );
1096 assert!(
1097 !tmpl.content.contains("/tmp/paw-verify"),
1098 "supervisor skill must not teach an OS-temp (/tmp/paw-verify) path for verify scratch"
1099 );
1100 }
1101
1102 #[test]
1108 fn supervisor_skill_has_introspection_section_with_phase_taxonomy() {
1109 let tmpl = resolve("supervisor").unwrap();
1110 assert!(
1111 tmpl.content
1112 .contains("### Introspection: what to publish and when"),
1113 "supervisor skill must include the introspection section"
1114 );
1115 for phase in [
1116 "sweep",
1117 "audit",
1118 "merge",
1119 "feedback",
1120 "intent_watch",
1121 "learnings",
1122 "idle",
1123 ] {
1124 assert!(
1125 tmpl.content.contains(phase),
1126 "the phase taxonomy must document the {phase:?} phase value"
1127 );
1128 }
1129 for field in ["agents_checked", "audit_step", "intended_targets"] {
1131 assert!(
1132 tmpl.content.contains(field),
1133 "the taxonomy must document the {field:?} detail field"
1134 );
1135 }
1136 }
1137
1138 #[test]
1142 fn supervisor_skill_audit_step_enumerates_five_gates() {
1143 let tmpl = resolve("supervisor").unwrap();
1144 assert!(
1145 tmpl.content.contains("audit_step"),
1146 "the audit phase must document the audit_step field"
1147 );
1148 for gate in ["tests", "regression", "spec", "docs", "security"] {
1149 assert!(
1150 tmpl.content.contains(gate),
1151 "audit_step must enumerate the {gate:?} gate"
1152 );
1153 }
1154 }
1155
1156 #[test]
1160 fn supervisor_skill_documents_emission_cadence() {
1161 let tmpl = resolve("supervisor").unwrap();
1162 let lowered = tmpl.content.to_lowercase();
1163 assert!(
1164 lowered.contains("phase transition"),
1165 "cadence rules must require a status on every phase transition"
1166 );
1167 assert!(
1168 lowered.contains("30 second") || tmpl.content.contains("~30 seconds"),
1169 "cadence rules must document the ~30s rate-limit within a phase"
1170 );
1171 assert!(
1172 lowered.contains("idle"),
1173 "cadence rules must document the single-emit-on-idle rule"
1174 );
1175 }
1176
1177 #[test]
1181 fn supervisor_skill_documents_checkpoint_phase() {
1182 let tmpl = resolve("supervisor").unwrap();
1183 assert!(
1184 tmpl.content.contains("checkpoint"),
1185 "the skill must document the checkpoint phase value"
1186 );
1187 assert!(
1188 tmpl.content.contains("\"phase\":\"checkpoint\""),
1189 "the checkpoint emission example must set phase = checkpoint"
1190 );
1191 }
1192
1193 #[test]
1199 fn supervisor_skill_publishes_advanced_main_after_merge() {
1200 let tmpl = resolve("supervisor").unwrap();
1201 let content = &tmpl.content;
1202
1203 let merge_idx = content
1205 .find("Merge orchestration")
1206 .expect("supervisor skill has a Merge orchestration section");
1207 let merge_section = &content[merge_idx..];
1208
1209 assert!(
1210 merge_section.contains("agent.advanced-main"),
1211 "the merge section must teach publishing an agent.advanced-main event"
1212 );
1213 assert!(
1215 merge_section.contains("/publish") && merge_section.contains("new_main_sha"),
1216 "the merge section must include a concrete curl /publish example carrying new_main_sha"
1217 );
1218 let lowered = merge_section.to_lowercase();
1220 assert!(
1221 lowered.contains("test command passes") || lowered.contains("after the merge succeeds"),
1222 "the publish step must fire after a successful merge"
1223 );
1224 assert!(
1226 merge_section.contains("$MAIN_BRANCH")
1227 && merge_section.contains("resolved default-branch"),
1228 "the example must source `base` from the resolved default branch, not a hardcoded literal"
1229 );
1230 assert!(
1231 !merge_section.contains("\"base\":\"main\"")
1232 && !merge_section.contains("\"base\": \"main\""),
1233 "the example must not hardcode base as the literal \"main\""
1234 );
1235 }
1236
1237 #[test]
1243 fn supervisor_skill_mandates_helper_and_forbids_inline_pane_loops() {
1244 let tmpl = resolve("supervisor").unwrap();
1245 assert!(
1246 tmpl.content.contains("Driving agent panes"),
1247 "supervisor skill should contain a 'Driving agent panes' section"
1248 );
1249 let lowered = tmpl.content.to_lowercase();
1250 assert!(
1251 lowered.contains("for p in") && lowered.contains("do tmux"),
1252 "the section should name the forbidden `for p in ...; do tmux ...` loop shape"
1253 );
1254 assert!(
1255 lowered.contains("simple_expansion"),
1256 "the section should cite the simple_expansion permission gate as the reason"
1257 );
1258 }
1259
1260 #[test]
1264 fn supervisor_skill_states_never_own_pane_rule() {
1265 let tmpl = resolve("supervisor").unwrap();
1266 let lowered = tmpl.content.to_lowercase();
1267 assert!(
1268 lowered.contains("never") && lowered.contains("pane 0"),
1269 "supervisor skill should state it must never send-keys to its own pane (pane 0)"
1270 );
1271 assert!(
1272 lowered.contains("interrupt"),
1273 "the never-own-pane rule should give the self-interrupt rationale"
1274 );
1275 }
1276
1277 #[test]
1281 fn supervisor_skill_mandates_git_dash_c_and_forbids_cd() {
1282 let tmpl = resolve("supervisor").unwrap();
1283 assert!(
1284 tmpl.content.contains("git -C"),
1285 "supervisor skill should mandate `git -C <path>` for cross-worktree git"
1286 );
1287 let lowered = tmpl.content.to_lowercase();
1288 assert!(
1289 lowered.contains("cd ") && lowered.contains("&& git"),
1290 "the rule should name and forbid the `cd <path> && git` shape"
1291 );
1292 assert!(
1293 lowered.contains("untrusted-hooks") || lowered.contains("untrusted hooks"),
1294 "the rule should cite the untrusted-hooks warning"
1295 );
1296 assert!(
1297 lowered.contains("wrong branch") || lowered.contains("wrong-branch"),
1298 "the rule should cite the wrong-branch (cwd-leak) risk"
1299 );
1300 }
1301
1302 #[test]
1306 fn supervisor_skill_states_commit_cadence_nudge() {
1307 let tmpl = resolve("supervisor").unwrap();
1308 let lowered = tmpl.content.to_lowercase();
1309 assert!(
1310 lowered.contains("uncommitted") && lowered.contains("10"),
1311 "supervisor skill should state the ~10-uncommitted-file commit-nudge threshold"
1312 );
1313 assert!(
1314 lowered.contains("commit-cadence") || lowered.contains("commit cadence"),
1315 "supervisor skill should label the commit-cadence nudge"
1316 );
1317 assert!(
1318 tmpl.content.contains("feedback-gate"),
1319 "the nudge should be a published agent.feedback (via the feedback-gate helper)"
1320 );
1321 }
1322
1323 #[test]
1328 fn supervisor_skill_mandates_no_fail_fast_verification() {
1329 let tmpl = resolve("supervisor").unwrap();
1333 let lowered = tmpl.content.to_lowercase();
1334 assert!(
1335 lowered.contains("never fail-fast") || lowered.contains("no-fail-fast"),
1336 "testing gate must mandate running the whole suite (no fail-fast)"
1337 );
1338 assert!(
1339 lowered.contains("guard test"),
1340 "testing gate must name the environment guard-test failure mode"
1341 );
1342 assert!(
1343 lowered.contains("incomplete, not a pass")
1344 || lowered.contains("not a pass unless every later suite"),
1345 "testing gate must state that an early-aborted (guard-only) run is not a PASS"
1346 );
1347 }
1348
1349 #[test]
1355 fn supervisor_skill_mandates_per_event_verification() {
1356 let tmpl = resolve("supervisor").unwrap();
1357 assert!(
1358 tmpl.content
1359 .contains("### Verify on each event, never batch"),
1360 "supervisor skill must contain the 'Verify on each event, never batch' subsection"
1361 );
1362 assert!(
1363 tmpl.content
1364 .contains("MUST NOT** defer a ready verification"),
1365 "subsection must state the no-batch rule in MUST-NOT terms"
1366 );
1367 assert!(
1368 tmpl.content
1369 .contains("MUST** start a branch's five-gate sweep"),
1370 "subsection must state the per-event trigger in MUST terms"
1371 );
1372 let lowered = tmpl.content.to_lowercase();
1373 assert!(
1374 lowered.contains("batching anti-pattern"),
1375 "subsection must include a worked example of the batching anti-pattern"
1376 );
1377 assert!(
1378 lowered.contains("still mid-task"),
1379 "the worked example must name the wave-1 failure: waiting for a second agent to finish"
1380 );
1381 }
1382
1383 #[test]
1386 fn supervisor_skill_permits_dependency_driven_deferral() {
1387 let tmpl = resolve("supervisor").unwrap();
1388 let lowered = tmpl.content.to_lowercase();
1389 assert!(
1390 lowered.contains("only acceptable reason to defer is a genuine dependency"),
1391 "subsection must state the genuine-dependency deferral exception"
1392 );
1393 assert!(
1394 lowered.contains("state that dependency explicitly"),
1395 "subsection must require stating the dependency explicitly when deferring"
1396 );
1397 }
1398
1399 #[test]
1402 fn supervisor_skill_permits_concurrent_verification() {
1403 let tmpl = resolve("supervisor").unwrap();
1404 let lowered = tmpl.content.to_lowercase();
1405 assert!(
1406 lowered.contains("per-branch verifications may run concurrently"),
1407 "subsection must state per-branch verifications may run concurrently"
1408 );
1409 assert!(
1410 lowered.contains("does **not** block starting agent b's verification"),
1411 "subsection must state verifying agent A does not block verifying agent B"
1412 );
1413 }
1414
1415 #[test]
1418 fn supervisor_skill_references_verify_now_nudge() {
1419 let tmpl = resolve("supervisor").unwrap();
1420 assert!(
1421 tmpl.content.contains("supervisor.verify-now"),
1422 "subsection must reference the broker's supervisor.verify-now nudge"
1423 );
1424 assert!(
1425 tmpl.content.contains("verify_on_commit_nudge"),
1426 "subsection must reference the [supervisor] verify_on_commit_nudge config gate"
1427 );
1428 }
1429
1430 #[test]
1435 fn supervisor_skill_has_detecting_stuck_agents_section() {
1436 let tmpl = resolve("supervisor").unwrap();
1437 assert!(
1438 tmpl.content.contains("### Detecting stuck agents"),
1439 "supervisor skill must include a 'Detecting stuck agents' section"
1440 );
1441 assert!(
1442 tmpl.content
1443 .contains(".git-paw/scripts/sweep.sh detect-stuck"),
1444 "the section must name the bundled detect-stuck helper command"
1445 );
1446 assert!(
1447 tmpl.content.contains("stuck-on-prompt"),
1448 "the section must document the stuck-on-prompt phase value"
1449 );
1450 assert!(
1451 tmpl.content.contains("Pasted text #N"),
1452 "the section must document the paste-buffer marker"
1453 );
1454 let lowered = tmpl.content.to_lowercase();
1456 assert!(
1457 lowered.contains("dedup") && lowered.contains("prompt-shape"),
1458 "the section must document the (agent_id, prompt-shape) dedup"
1459 );
1460 assert!(
1462 tmpl.content
1463 .contains("Do NOT hand-roll an inline-bash monitor"),
1464 "the section must forbid inline-bash signature-dedup monitors"
1465 );
1466 assert!(
1467 lowered.contains("eats repeat-pattern prompts"),
1468 "the section must give the bug-9 rationale (signature dedup eats repeat-pattern prompts)"
1469 );
1470 }
1471
1472 #[test]
1474 #[serial(directory_changes)]
1475 fn standard_location_skill_loading() {
1476 let dir = tempfile::tempdir().unwrap();
1477 let project_dir = dir.path().join("my-project");
1478 std::fs::create_dir_all(&project_dir).unwrap();
1479
1480 let skill_dir = project_dir
1482 .join(".agents")
1483 .join("skills")
1484 .join("coordination");
1485 std::fs::create_dir_all(&skill_dir).unwrap();
1486
1487 let skill_md_content = "---\nname: coordination\ndescription: Custom coordination skill\n---\n\ncustom skill content";
1488 std::fs::write(skill_dir.join("SKILL.md"), skill_md_content).unwrap();
1489
1490 let original_dir = std::env::current_dir().unwrap();
1492 std::env::set_current_dir(&project_dir).unwrap();
1493
1494 let tmpl = resolve("coordination").expect("should resolve");
1495 assert_eq!(tmpl.source, Source::AgentsStandard);
1496 assert!(tmpl.content.contains("custom skill content"));
1497
1498 std::env::set_current_dir(original_dir).unwrap();
1500 }
1501
1502 #[test]
1504 fn unknown_skill_returns_error() {
1505 let result = resolve("nonexistent");
1506 assert!(
1507 matches!(result, Err(SkillError::UnknownSkill { ref name }) if name == "nonexistent"),
1508 "expected UnknownSkill error, got {result:?}"
1509 );
1510 }
1511
1512 #[test]
1514 fn branch_id_is_substituted() {
1515 let tmpl = SkillTemplate {
1516 name: "test".into(),
1517 content: "agent_id:\"{{BRANCH_ID}}\"".into(),
1518 source: Source::Embedded,
1519 format: SkillFormat::Standardized,
1520 metadata: None,
1521 resource_paths: None,
1522 };
1523 let output = render(
1524 &tmpl,
1525 "feat/http-broker",
1526 "http://127.0.0.1:9119",
1527 "git-paw",
1528 &GateCommands::default(),
1529 &[],
1530 );
1531 assert!(output.contains("feat-http-broker"));
1532 assert!(!output.contains("{{BRANCH_ID}}"));
1533 }
1534
1535 #[test]
1537 fn broker_url_placeholder_substituted() {
1538 let tmpl = SkillTemplate {
1539 name: "test".into(),
1540 content: "curl {{GIT_PAW_BROKER_URL}}/status".into(),
1541 source: Source::Embedded,
1542 format: SkillFormat::Standardized,
1543 metadata: None,
1544 resource_paths: None,
1545 };
1546 let output = render(
1547 &tmpl,
1548 "feat/x",
1549 "http://127.0.0.1:9119",
1550 "git-paw",
1551 &GateCommands::default(),
1552 &[],
1553 );
1554 assert!(output.contains("http://127.0.0.1:9119/status"));
1555 assert!(!output.contains("{{GIT_PAW_BROKER_URL}}"));
1556 }
1557
1558 #[test]
1560 fn slug_substitution_matches_slugify_branch() {
1561 let tmpl = SkillTemplate {
1562 name: "test".into(),
1563 content: "id={{BRANCH_ID}}".into(),
1564 source: Source::Embedded,
1565 format: SkillFormat::Standardized,
1566 metadata: None,
1567 resource_paths: None,
1568 };
1569 let output = render(
1570 &tmpl,
1571 "Feature/HTTP_Broker",
1572 "http://127.0.0.1:9119",
1573 "git-paw",
1574 &GateCommands::default(),
1575 &[],
1576 );
1577 let expected = slugify_branch("Feature/HTTP_Broker");
1578 assert_eq!(output, format!("id={expected}"));
1579 }
1580
1581 #[test]
1583 fn render_is_deterministic() {
1584 let tmpl = resolve("coordination").unwrap();
1585 let a = render(
1586 &tmpl,
1587 "feat/x",
1588 "http://127.0.0.1:9119",
1589 "git-paw",
1590 &GateCommands::default(),
1591 &[],
1592 );
1593 let b = render(
1594 &tmpl,
1595 "feat/x",
1596 "http://127.0.0.1:9119",
1597 "git-paw",
1598 &GateCommands::default(),
1599 &[],
1600 );
1601 assert_eq!(a, b);
1602 }
1603
1604 #[test]
1606 #[serial(directory_changes)]
1607 fn render_performs_no_io() {
1608 let dir = tempfile::tempdir().unwrap();
1609 let project_dir = dir.path().join("my-project");
1610 std::fs::create_dir_all(&project_dir).unwrap();
1611
1612 let skill_dir = project_dir
1613 .join(".agents")
1614 .join("skills")
1615 .join("coordination");
1616 std::fs::create_dir_all(&skill_dir).unwrap();
1617
1618 let skill_md_content = "---\nname: coordination\ndescription: Test coordination skill\n---\n\nuser {{BRANCH_ID}}";
1619 std::fs::write(skill_dir.join("SKILL.md"), skill_md_content).unwrap();
1620
1621 let original_dir = std::env::current_dir().unwrap();
1623 std::env::set_current_dir(&project_dir).unwrap();
1624
1625 let tmpl = resolve("coordination").unwrap();
1626 assert_eq!(tmpl.source, Source::AgentsStandard);
1627
1628 std::fs::remove_dir_all(skill_dir).unwrap();
1630 let output = render(
1631 &tmpl,
1632 "feat/x",
1633 "http://127.0.0.1:9119",
1634 "git-paw",
1635 &GateCommands::default(),
1636 &[],
1637 );
1638 assert!(output.contains("feat-x"));
1639
1640 std::env::set_current_dir(original_dir).unwrap();
1642 }
1643
1644 #[test]
1646 fn unknown_placeholder_survives() {
1647 let tmpl = SkillTemplate {
1648 name: "test".into(),
1649 content: "url={{UNKNOWN_THING}}".into(),
1650 source: Source::Embedded,
1651 format: SkillFormat::Standardized,
1652 metadata: None,
1653 resource_paths: None,
1654 };
1655 let output = render(
1656 &tmpl,
1657 "feat/x",
1658 "http://127.0.0.1:9119",
1659 "git-paw",
1660 &GateCommands::default(),
1661 &[],
1662 );
1663 assert!(
1664 output.contains("{{UNKNOWN_THING}}"),
1665 "unknown placeholder should survive in output"
1666 );
1667 }
1668
1669 #[test]
1671 fn no_unknown_placeholders_after_render() {
1672 let tmpl = resolve("coordination").unwrap();
1673 let output = render(
1674 &tmpl,
1675 "feat/x",
1676 "http://127.0.0.1:9119",
1677 "git-paw",
1678 &GateCommands::default(),
1679 &[],
1680 );
1681 assert!(
1682 !output.contains("{{"),
1683 "no double-curly placeholders should remain: {output}"
1684 );
1685 }
1686
1687 #[test]
1689 fn embedded_supervisor_is_reachable() {
1690 let tmpl = resolve("supervisor").expect("should resolve supervisor");
1691 assert_eq!(tmpl.source, Source::Embedded);
1692 assert!(!tmpl.content.is_empty());
1693 }
1694
1695 #[test]
1697 fn supervisor_skill_contains_role_definition() {
1698 let tmpl = resolve("supervisor").unwrap();
1699 assert!(tmpl.content.contains("do NOT write code"));
1700 }
1701
1702 #[test]
1704 fn supervisor_skill_contains_broker_status() {
1705 let tmpl = resolve("supervisor").unwrap();
1706 assert!(tmpl.content.contains("{{GIT_PAW_BROKER_URL}}/status"));
1707 }
1708
1709 #[test]
1711 fn supervisor_skill_contains_verified_and_feedback() {
1712 let tmpl = resolve("supervisor").unwrap();
1713 assert!(tmpl.content.contains("agent.verified"));
1714 assert!(tmpl.content.contains("agent.feedback"));
1715 }
1716
1717 fn verified_curl_example_body(content: &str) -> &str {
1721 let start = content
1722 .find("\"type\":\"agent.verified\"")
1723 .expect("supervisor skill should contain an agent.verified curl example");
1724 let rest = &content[start..];
1725 let end = rest
1726 .find("}}'")
1727 .expect("agent.verified curl example should terminate with the closing payload `}}'`");
1728 &rest[..end + 3]
1729 }
1730
1731 fn feedback_curl_example_body(content: &str) -> &str {
1734 let start = content
1735 .find("\"type\":\"agent.feedback\"")
1736 .expect("supervisor skill should contain an agent.feedback curl example");
1737 let rest = &content[start..];
1738 let end = rest
1739 .find("}}'")
1740 .expect("agent.feedback curl example should terminate with the closing payload `}}'`");
1741 &rest[..end + 3]
1742 }
1743
1744 #[test]
1745 fn supervisor_verified_example_uses_correct_payload_fields() {
1746 let tmpl = resolve("supervisor").unwrap();
1747 let example = verified_curl_example_body(&tmpl.content);
1748 assert!(
1749 example.contains("verified_by"),
1750 "agent.verified example must use the `verified_by` payload field: {example}"
1751 );
1752 assert!(
1753 example.contains("message"),
1754 "agent.verified example must use the `message` payload field: {example}"
1755 );
1756 for wrong in ["\"target\"", "\"result\"", "\"notes\""] {
1757 assert!(
1758 !example.contains(wrong),
1759 "agent.verified example must not contain the stale field key {wrong}: {example}"
1760 );
1761 }
1762 }
1763
1764 #[test]
1765 fn supervisor_feedback_example_uses_correct_payload_fields() {
1766 let tmpl = resolve("supervisor").unwrap();
1767 let example = feedback_curl_example_body(&tmpl.content);
1768 assert!(
1769 example.contains("\"from\""),
1770 "agent.feedback example must use the `from` payload field: {example}"
1771 );
1772 assert!(
1773 example.contains("\"errors\""),
1774 "agent.feedback example must use the `errors` payload field: {example}"
1775 );
1776 assert!(
1777 example.contains('['),
1778 "agent.feedback example's errors field must be a JSON array (contains `[`): {example}"
1779 );
1780 assert!(
1781 example.contains(']'),
1782 "agent.feedback example's errors field must be a JSON array (contains `]`): {example}"
1783 );
1784 for wrong in ["\"target\"", "\"message\""] {
1785 assert!(
1786 !example.contains(wrong),
1787 "agent.feedback example must not contain the stale field key {wrong}: {example}"
1788 );
1789 }
1790 }
1791
1792 #[test]
1793 fn supervisor_examples_clarify_recipient_vs_sender() {
1794 let tmpl = resolve("supervisor").unwrap();
1795 let lowered = tmpl.content.to_lowercase();
1796
1797 let verified_start = tmpl
1800 .content
1801 .find("### Publish verification outcome")
1802 .expect("verified heading should be present");
1803 let feedback_start = tmpl
1804 .content
1805 .find("### Publish feedback to a peer agent")
1806 .expect("feedback heading should be present");
1807 let verified_section = tmpl.content[verified_start..feedback_start].to_lowercase();
1808 assert!(
1809 verified_section.contains("recipient") && verified_section.contains("sender"),
1810 "verified section should clarify recipient-vs-sender semantics, got: {verified_section}"
1811 );
1812
1813 let after_feedback =
1816 &tmpl.content[feedback_start + "### Publish feedback to a peer agent".len()..];
1817 let feedback_end_rel = after_feedback
1818 .find("\n### ")
1819 .unwrap_or(after_feedback.len());
1820 let feedback_section = after_feedback[..feedback_end_rel].to_lowercase();
1821 assert!(
1822 feedback_section.contains("recipient") && feedback_section.contains("sender"),
1823 "feedback section should clarify recipient-vs-sender semantics, got: {feedback_section}"
1824 );
1825
1826 assert!(lowered.contains("recipient"));
1828 assert!(lowered.contains("sender"));
1829 }
1830
1831 #[test]
1832 fn supervisor_workflow_prose_drops_legacy_verified_fields() {
1833 let tmpl = resolve("supervisor").unwrap();
1834 let condensed: String = tmpl
1837 .content
1838 .chars()
1839 .filter(|c| !c.is_whitespace())
1840 .collect();
1841 assert!(
1842 !condensed.contains("result:\"pass\""),
1843 "workflow prose must not reference `result:\"pass\"` as the verified payload"
1844 );
1845 assert!(
1846 !condensed.contains("notes:\"\""),
1847 "workflow prose must not reference `notes:\"\"` as the verified payload"
1848 );
1849 }
1850
1851 #[test]
1853 fn supervisor_skill_contains_tmux_commands() {
1854 let tmpl = resolve("supervisor").unwrap();
1855 assert!(tmpl.content.contains("tmux capture-pane"));
1856 assert!(tmpl.content.contains("tmux send-keys"));
1857 assert!(tmpl.content.contains("paw-{{PROJECT_NAME}}"));
1858 }
1859
1860 #[test]
1861 fn supervisor_skill_contains_spec_audit_procedure() {
1862 let tmpl = resolve("supervisor").unwrap();
1863 assert!(
1864 tmpl.content.contains("Spec Audit"),
1865 "supervisor skill should contain Spec Audit section"
1866 );
1867 assert!(
1868 tmpl.content.contains("{{SPEC_PATH_DOCTRINE}}"),
1869 "v0.6.0+ supervisor template should embed the SPEC_PATH_DOCTRINE placeholder so spec layout is rendered per backend, not hardcoded"
1870 );
1871 assert!(
1872 tmpl.content.contains("grep"),
1873 "should instruct to grep for matching tests"
1874 );
1875 let rendered = render(
1878 &tmpl,
1879 "supervisor",
1880 "http://127.0.0.1:9119",
1881 "git-paw",
1882 &GateCommands::default(),
1883 &[crate::specs::SpecBackendKind::OpenSpec],
1884 );
1885 assert!(
1886 rendered.contains("openspec/changes/"),
1887 "OpenSpec-rendered supervisor skill should reference openspec/changes/ via the SPEC_PATH_DOCTRINE substitution"
1888 );
1889 }
1890
1891 #[test]
1892 fn supervisor_skill_spec_audit_after_test_before_verified() {
1893 let tmpl = resolve("supervisor").unwrap();
1894 let test_pos = tmpl.content.find("Regression check").unwrap_or(0);
1895 let audit_pos = tmpl.content.find("Spec Audit").unwrap_or(0);
1896 let verify_pos = tmpl.content.find("Verify or feedback").unwrap_or(0);
1897 assert!(
1898 audit_pos > test_pos,
1899 "spec audit should appear after test/regression check"
1900 );
1901 assert!(
1902 audit_pos < verify_pos,
1903 "spec audit should appear before verify/feedback"
1904 );
1905 }
1906
1907 #[test]
1910 fn supervisor_skill_mentions_paste_buffer_recovery() {
1911 let tmpl = resolve("supervisor").unwrap();
1912 let lowered = tmpl.content.to_lowercase();
1913 assert!(
1914 lowered.contains("paste-buffer") || lowered.contains("paste buffer"),
1915 "supervisor skill should contain paste-buffer recovery sub-case"
1916 );
1917 }
1918
1919 #[test]
1920 fn supervisor_skill_mentions_pasted_text_indicator() {
1921 let tmpl = resolve("supervisor").unwrap();
1922 assert!(
1923 tmpl.content.contains("Pasted text"),
1924 "supervisor skill should mention the Claude Code 'Pasted text' indicator"
1925 );
1926 }
1927
1928 #[test]
1929 fn supervisor_skill_paste_buffer_recovery_uses_tmux() {
1930 let tmpl = resolve("supervisor").unwrap();
1931 let start = tmpl
1932 .content
1933 .to_lowercase()
1934 .find("paste-buffer recovery")
1935 .or_else(|| tmpl.content.to_lowercase().find("paste buffer recovery"))
1936 .expect("paste-buffer recovery sub-case heading should be present");
1937 let window_end = (start + 2200).min(tmpl.content.len());
1941 let window = &tmpl.content[start..window_end];
1942 assert!(
1946 window.contains(".git-paw/scripts/sweep.sh capture")
1947 || window.contains("tmux capture-pane"),
1948 "paste-buffer recovery should reference a pane-capture command (sweep.sh capture or tmux capture-pane)"
1949 );
1950 assert!(
1951 window.contains("tmux send-keys"),
1952 "paste-buffer recovery should reference tmux send-keys for the Enter recovery"
1953 );
1954 assert!(
1955 window.contains("Enter"),
1956 "paste-buffer recovery should specify Enter as the recovery keystroke"
1957 );
1958 }
1959
1960 #[test]
1961 fn supervisor_skill_mentions_launch_time_sweep() {
1962 let tmpl = resolve("supervisor").unwrap();
1963 let lowered = tmpl.content.to_lowercase();
1964 assert!(
1965 lowered.contains("launch-time pane sweep")
1966 || lowered.contains("launch time pane sweep")
1967 || lowered.contains("launch sweep"),
1968 "supervisor skill should contain a launch-time pane sweep heading"
1969 );
1970 }
1971
1972 #[test]
1973 fn supervisor_skill_launch_sweep_lists_four_pane_categories() {
1974 let tmpl = resolve("supervisor").unwrap();
1975 let lowered = tmpl.content.to_lowercase();
1976 let start = lowered
1977 .find("launch-time pane sweep")
1978 .or_else(|| lowered.find("launch sweep"))
1979 .expect("launch-time pane sweep heading should be present");
1980 let window_end = (start + 2500).min(lowered.len());
1981 let window = &lowered[start..window_end];
1982 assert!(
1983 window.contains("paste-buffer") || window.contains("paste buffer"),
1984 "launch sweep should enumerate paste-buffer category"
1985 );
1986 assert!(
1987 window.contains("permission prompt"),
1988 "launch sweep should enumerate permission-prompt category"
1989 );
1990 assert!(
1991 window.contains("working"),
1992 "launch sweep should enumerate working category"
1993 );
1994 assert!(
1995 window.contains("idle"),
1996 "launch sweep should enumerate idle category"
1997 );
1998 }
1999
2000 #[test]
2001 fn supervisor_skill_launch_sweep_references_down_enter_keystroke() {
2002 let tmpl = resolve("supervisor").unwrap();
2003 let lowered = tmpl.content.to_lowercase();
2004 let start = lowered
2005 .find("launch-time pane sweep")
2006 .or_else(|| lowered.find("launch sweep"))
2007 .expect("launch-time pane sweep heading should be present");
2008 let window_end = (start + 2500).min(lowered.len());
2009 let window = &lowered[start..window_end];
2010 assert!(
2014 window.contains("down"),
2015 "launch sweep should reference the Down keystroke for selecting 'don't ask again'"
2016 );
2017 assert!(
2018 window.contains("enter"),
2019 "launch sweep should reference the Enter keystroke for confirming approval"
2020 );
2021 assert!(
2024 window.contains("don't ask again") || window.contains("don't ask"),
2025 "launch sweep should mention the 'don't ask again' approval option"
2026 );
2027 }
2028
2029 #[test]
2030 fn supervisor_skill_paste_buffer_recovery_is_safe_by_default() {
2031 let tmpl = resolve("supervisor").unwrap();
2032 let lowered = tmpl.content.to_lowercase();
2033 let start = lowered
2034 .find("paste-buffer recovery")
2035 .or_else(|| lowered.find("paste buffer recovery"))
2036 .expect("paste-buffer recovery sub-case heading should be present");
2037 let window_end = (start + 2200).min(lowered.len());
2038 let window = &lowered[start..window_end];
2039 let safe_phrasing = window.contains("safe-by-default")
2040 || window.contains("safe by default")
2041 || window.contains("no-op")
2042 || window.contains("no harm");
2043 assert!(
2044 safe_phrasing,
2045 "paste-buffer recovery should explicitly note the Enter is safe-by-default / no-op / no harm"
2046 );
2047 }
2048
2049 #[test]
2052 fn supervisor_skill_contains_governance_verification() {
2053 let tmpl = resolve("supervisor").unwrap();
2054 assert!(
2055 tmpl.content.contains("Governance verification"),
2056 "supervisor skill should contain 'Governance verification' heading"
2057 );
2058 }
2059
2060 #[test]
2061 fn supervisor_skill_governance_is_substep_of_spec_audit() {
2062 let tmpl = resolve("supervisor").unwrap();
2063 let audit_pos = tmpl
2064 .content
2065 .find("### Spec Audit Procedure")
2066 .expect("Spec Audit Procedure heading must exist");
2067 let gov_pos = tmpl
2068 .content
2069 .find("Governance verification")
2070 .expect("Governance verification must exist");
2071 let conflict_pos = tmpl
2072 .content
2073 .find("### Conflict detection")
2074 .unwrap_or(tmpl.content.len());
2075 assert!(
2076 gov_pos > audit_pos,
2077 "Governance verification should appear inside Spec Audit Procedure (after its heading)"
2078 );
2079 assert!(
2080 gov_pos < conflict_pos,
2081 "Governance verification should appear before the next top-level subsection (Conflict detection), keeping it inside Spec Audit Procedure"
2082 );
2083 assert!(
2084 !tmpl.content.contains("step 7.5"),
2085 "Governance verification must not be framed as a separate 'step 7.5' flow step"
2086 );
2087 }
2088
2089 #[test]
2090 fn supervisor_skill_governance_examples_cover_all_five_docs() {
2091 let tmpl = resolve("supervisor").unwrap();
2092 let gov_pos = tmpl
2093 .content
2094 .find("Governance verification")
2095 .expect("Governance verification section must exist");
2096 let after = &tmpl.content[gov_pos..];
2099 let end = after.find("\n### ").unwrap_or(after.len());
2100 let section = &after[..end];
2101 for needle in &["DoD", "ADR", "Security", "Test strategy", "Constitution"] {
2102 assert!(
2103 section.contains(needle),
2104 "governance section should mention `{needle}` as a per-doc example, got:\n{section}"
2105 );
2106 }
2107 }
2108
2109 #[test]
2110 fn supervisor_skill_governance_findings_via_agent_feedback() {
2111 let tmpl = resolve("supervisor").unwrap();
2112 let gov_pos = tmpl
2113 .content
2114 .find("Governance verification")
2115 .expect("Governance verification section must exist");
2116 let after = &tmpl.content[gov_pos..];
2117 let end = after.find("\n### ").unwrap_or(after.len());
2118 let section = &after[..end];
2119 assert!(
2120 section.contains("agent.feedback"),
2121 "governance section must state that findings flow through `agent.feedback`"
2122 );
2123 }
2124
2125 #[test]
2126 fn supervisor_skill_no_governance_gate_tag() {
2127 let tmpl = resolve("supervisor").unwrap();
2128 assert!(
2129 !tmpl.content.contains("[governance-gate:"),
2130 "supervisor skill must not contain the dropped `[governance-gate:<doc>]` tag prefix"
2131 );
2132 }
2133
2134 #[test]
2135 fn supervisor_skill_no_governance_gates_table() {
2136 let tmpl = resolve("supervisor").unwrap();
2137 assert!(
2138 !tmpl.content.contains("[governance.gates]"),
2139 "supervisor skill must not reference the dropped `[governance.gates]` table"
2140 );
2141 }
2142
2143 #[test]
2144 fn supervisor_skill_no_gating_language() {
2145 let tmpl = resolve("supervisor").unwrap();
2146 let lowered = tmpl
2152 .content
2153 .to_lowercase()
2154 .replace("opsx-role-gating", "")
2155 .replace("role-gating", "")
2156 .replace("role_gating", "");
2157 assert!(
2158 !lowered.contains("gating"),
2159 "supervisor skill must not use the language of 'gating' (outside the opsx role-gating feature name)"
2160 );
2161 assert!(
2162 !lowered.contains("blocking on governance failures"),
2163 "supervisor skill must not use the language of 'blocking on governance failures'"
2164 );
2165 }
2166
2167 #[test]
2168 fn supervisor_skill_governance_missing_doc_handling() {
2169 let tmpl = resolve("supervisor").unwrap();
2170 let gov_pos = tmpl
2171 .content
2172 .find("Governance verification")
2173 .expect("Governance verification section must exist");
2174 let after = &tmpl.content[gov_pos..];
2175 let end = after.find("\n### ").unwrap_or(after.len());
2176 let section = &after[..end];
2177 let lowered = section.to_lowercase();
2178 assert!(
2179 lowered.contains("missing"),
2180 "governance section should describe missing-doc handling"
2181 );
2182 assert!(
2183 section.contains("agent.feedback"),
2184 "missing-doc handling should reference `agent.feedback` errors list"
2185 );
2186 }
2187
2188 #[test]
2189 fn supervisor_skill_governance_missing_doc_is_not_distinct_failure_type() {
2190 let tmpl = resolve("supervisor").unwrap();
2191 let gov_pos = tmpl
2192 .content
2193 .find("Governance verification")
2194 .expect("Governance verification section must exist");
2195 let after = &tmpl.content[gov_pos..];
2196 let end = after.find("\n### ").unwrap_or(after.len());
2197 let section = &after[..end];
2198 let lowered = section.to_lowercase();
2199 assert!(
2200 lowered.contains("not a distinct failure")
2201 || lowered.contains("not a separate failure")
2202 || lowered.contains("treat it as a finding"),
2203 "governance section must state that missing files are findings, not a distinct failure type; got:\n{section}"
2204 );
2205 }
2206
2207 #[test]
2208 fn supervisor_skill_governance_states_activation_condition() {
2209 let tmpl = resolve("supervisor").unwrap();
2210 let gov_pos = tmpl
2211 .content
2212 .find("Governance verification")
2213 .expect("Governance verification section must exist");
2214 let after = &tmpl.content[gov_pos..];
2215 let end = after.find("\n### ").unwrap_or(after.len());
2216 let section = &after[..end];
2217 let lowered = section.to_lowercase();
2218 assert!(
2219 lowered.contains("skip"),
2220 "governance section must instruct the supervisor to skip the sub-step when the boot prompt has no `## Governance documents` section; got:\n{section}"
2221 );
2222 assert!(
2223 section.contains("## Governance documents"),
2224 "governance section must reference the boot-prompt heading explicitly as its activation condition; got:\n{section}"
2225 );
2226 }
2227
2228 #[test]
2229 fn supervisor_skill_governance_examples_state_they_are_illustrative() {
2230 let tmpl = resolve("supervisor").unwrap();
2231 let gov_pos = tmpl
2232 .content
2233 .find("Governance verification")
2234 .expect("Governance verification section must exist");
2235 let after = &tmpl.content[gov_pos..];
2236 let end = after.find("\n### ").unwrap_or(after.len());
2237 let section = &after[..end];
2238 let lowered = section.to_lowercase();
2239 assert!(
2240 lowered.contains("illustrative") || lowered.contains("not exhaustive"),
2241 "governance section must state per-doc examples are illustrative / not exhaustive rubrics; got:\n{section}"
2242 );
2243 }
2244
2245 #[test]
2246 fn supervisor_skill_governance_states_judgment_per_project_conventions() {
2247 let tmpl = resolve("supervisor").unwrap();
2248 let gov_pos = tmpl
2249 .content
2250 .find("Governance verification")
2251 .expect("Governance verification section must exist");
2252 let after = &tmpl.content[gov_pos..];
2253 let end = after.find("\n### ").unwrap_or(after.len());
2254 let section = &after[..end];
2255 let lowered = section.to_lowercase();
2256 assert!(
2257 lowered.contains("judgment"),
2258 "governance section must state the supervisor applies judgment; got:\n{section}"
2259 );
2260 assert!(
2261 lowered.contains("convention") || lowered.contains("project"),
2262 "governance section must reference the project's conventions / process when describing judgment; got:\n{section}"
2263 );
2264 }
2265
2266 fn stream_timeout_section(content: &str) -> &str {
2271 let start = content
2272 .find("### Stream-timeout recovery")
2273 .expect("supervisor skill must contain the Stream-timeout recovery section");
2274 let after = &content[start..];
2275 let body_offset = "### Stream-timeout recovery".len();
2278 let end = after[body_offset..]
2279 .find("\n### ")
2280 .map_or(after.len(), |i| body_offset + i);
2281 &after[..end]
2282 }
2283
2284 #[test]
2288 fn supervisor_skill_stream_timeout_section_has_four_ordered_pieces() {
2289 let tmpl = resolve("supervisor").unwrap();
2290 let section = stream_timeout_section(&tmpl.content);
2291
2292 let error_shape = section
2293 .find("error-shape recognition")
2294 .expect("subsection 1 must name error-shape recognition");
2295 let checkpoint = section
2296 .find("pre-action checkpoint")
2297 .expect("subsection 2 must name the pre-action checkpoint");
2298 let replay = section
2299 .find("replay-missing-publishes")
2300 .expect("subsection 3 must name replay-missing-publishes");
2301 let confirmation = section
2302 .find("Confirmation rule")
2303 .expect("subsection 4 must name the Confirmation rule");
2304
2305 assert!(
2306 error_shape < checkpoint && checkpoint < replay && replay < confirmation,
2307 "the four pieces must appear in recovery order: error-shape recognition, \
2308 pre-action checkpoint, replay-missing-publishes, confirmation rule"
2309 );
2310 }
2311
2312 #[test]
2316 fn supervisor_skill_stream_timeout_names_two_generic_symptoms() {
2317 let tmpl = resolve("supervisor").unwrap();
2318 let section = stream_timeout_section(&tmpl.content);
2319 let lowered = section.to_lowercase();
2320 assert!(
2321 lowered.contains("mid-stream cutoff"),
2322 "error-shape subsection must name the mid-stream cutoff symptom"
2323 );
2324 assert!(
2325 lowered.contains("transport error") || lowered.contains("stream error"),
2326 "error-shape subsection must name a transport-error / stream-error symptom"
2327 );
2328 }
2329
2330 #[test]
2334 fn supervisor_skill_stream_timeout_documents_checkpoint_shape() {
2335 let tmpl = resolve("supervisor").unwrap();
2336 let section = stream_timeout_section(&tmpl.content);
2337 assert!(
2338 section.contains("agent.status"),
2339 "checkpoint subsection must show an agent.status publish"
2340 );
2341 assert!(
2342 section.contains("\"status\":\"checkpoint\"")
2343 || section.contains("status: \"checkpoint\""),
2344 "checkpoint subsection must show status: \"checkpoint\""
2345 );
2346 assert!(
2347 section.contains("summary"),
2348 "checkpoint subsection must show a summary enumerating intended targets"
2349 );
2350 }
2351
2352 #[test]
2355 fn supervisor_skill_stream_timeout_checkpoint_only_for_multi_publish() {
2356 let tmpl = resolve("supervisor").unwrap();
2357 let section = stream_timeout_section(&tmpl.content);
2358 let lowered = section.to_lowercase();
2359 assert!(
2360 lowered.contains("more than one"),
2361 "checkpoint subsection must state it applies only to iterations with \
2362 more than one intended downstream publish"
2363 );
2364 assert!(
2365 lowered.contains("not to every sweep") || lowered.contains("not every sweep"),
2366 "checkpoint subsection must clarify it does not apply to every sweep"
2367 );
2368 }
2369
2370 #[test]
2373 fn supervisor_skill_stream_timeout_documents_replay_loop() {
2374 let tmpl = resolve("supervisor").unwrap();
2375 let section = stream_timeout_section(&tmpl.content);
2376 assert!(
2377 section.contains("/messages/"),
2378 "replay subsection must show polling the target's /messages/ stream"
2379 );
2380 let lowered = section.to_lowercase();
2381 assert!(
2382 lowered.contains("since=") || lowered.contains("checkpoint timestamp"),
2383 "replay subsection must poll since the checkpoint timestamp"
2384 );
2385 assert!(
2386 lowered.contains("re-publish"),
2387 "replay subsection must re-publish the missing record"
2388 );
2389 assert!(
2390 lowered.contains("idempotent"),
2391 "replay subsection must state the replay is idempotent so duplicates are safe"
2392 );
2393 assert!(
2394 lowered.contains("for each"),
2395 "replay subsection must show a per-target loop"
2396 );
2397 }
2398
2399 #[test]
2403 fn supervisor_skill_stream_timeout_confirmation_rule_is_prominent() {
2404 let tmpl = resolve("supervisor").unwrap();
2405 let section = stream_timeout_section(&tmpl.content);
2406 assert!(
2407 section.contains("**Never advance to the next sub-action"),
2408 "confirmation rule must be marked prominently with bold (`**`) formatting"
2409 );
2410 let lowered = section.to_lowercase();
2411 assert!(
2412 lowered.contains("timed out mid-write") || lowered.contains("may have timed out"),
2413 "confirmation rule must pair with a one-sentence rationale referencing stream-timeout risk"
2414 );
2415 }
2416
2417 #[test]
2421 fn supervisor_skill_stream_timeout_names_recovery_learning_record() {
2422 let tmpl = resolve("supervisor").unwrap();
2423 let section = stream_timeout_section(&tmpl.content);
2424 assert!(
2425 section.contains("recovery_cycles"),
2426 "replay subsection must name the recovery_cycles learning category"
2427 );
2428 assert!(
2429 section.contains("agent.learning"),
2430 "replay subsection must state the recovery emits an agent.learning record"
2431 );
2432 for field in [
2433 "checkpoint_id",
2434 "intended_targets",
2435 "replayed_targets",
2436 "skipped_targets",
2437 ] {
2438 assert!(
2439 section.contains(field),
2440 "recovery learning body must document the `{field}` field"
2441 );
2442 }
2443 }
2444
2445 #[test]
2450 fn dev_allowlist_preset_renders_every_constant_entry() {
2451 use crate::supervisor::dev_allowlist::DEV_ALLOWLIST_PRESET;
2459 let prose = render_dev_allowlist_preset();
2460 for entry in DEV_ALLOWLIST_PRESET {
2461 let (head, tail) = match entry.split_once(' ') {
2462 Some((h, t)) => (h, Some(t)),
2463 None => (*entry, None),
2464 };
2465 assert!(
2466 prose.contains(head),
2467 "rendered preset must contain head word `{head}` from entry `{entry}`; got:\n{prose}"
2468 );
2469 if let Some(t) = tail {
2470 assert!(
2471 prose.contains(t),
2472 "rendered preset must contain tail `{t}` from entry `{entry}`; got:\n{prose}"
2473 );
2474 }
2475 }
2476 }
2477
2478 #[test]
2479 fn dev_allowlist_preset_groups_by_first_word() {
2480 let prose = render_dev_allowlist_preset();
2484 let git_groups = prose.matches("git (").count();
2485 assert_eq!(
2486 git_groups, 1,
2487 "multi-entry git prefix must collapse into a single grouped clause; got {git_groups} occurrences of `git (` in:\n{prose}"
2488 );
2489 }
2490
2491 #[test]
2492 fn dev_allowlist_preset_preserves_single_word_entries() {
2493 let prose = render_dev_allowlist_preset();
2494 for bare in ["find", "grep"] {
2495 assert!(
2496 prose.contains(bare),
2497 "bare single-word entry `{bare}` should appear verbatim in:\n{prose}"
2498 );
2499 }
2500 }
2501
2502 #[test]
2507 fn spec_doctrine_empty_backends_renders_sentinel() {
2508 let out = render_spec_path_doctrine(&[]);
2509 assert!(
2510 out.contains("no spec backend"),
2511 "empty backend slice should render the sentinel; got: {out}"
2512 );
2513 }
2514
2515 #[test]
2516 fn spec_doctrine_openspec_references_openspec_paths_and_workflow() {
2517 use crate::specs::SpecBackendKind;
2518 let out = render_spec_path_doctrine(&[SpecBackendKind::OpenSpec]);
2519 assert!(
2520 out.contains("openspec/changes/"),
2521 "OpenSpec doctrine should name the openspec/changes/ path; got: {out}"
2522 );
2523 assert!(
2524 out.contains("openspec validate"),
2525 "OpenSpec doctrine should reference the openspec validate workflow; got: {out}"
2526 );
2527 }
2528
2529 #[test]
2530 fn spec_doctrine_speckit_references_specify_paths_and_checklist() {
2531 use crate::specs::SpecBackendKind;
2532 let out = render_spec_path_doctrine(&[SpecBackendKind::SpecKit]);
2533 assert!(
2534 out.contains(".specify/specs/"),
2535 "Spec Kit doctrine should name the .specify/specs/ path; got: {out}"
2536 );
2537 assert!(
2538 out.to_lowercase().contains("checklist"),
2539 "Spec Kit doctrine should reference the checklist convention; got: {out}"
2540 );
2541 }
2542
2543 #[test]
2544 fn spec_doctrine_markdown_references_paw_status_frontmatter() {
2545 use crate::specs::SpecBackendKind;
2546 let out = render_spec_path_doctrine(&[SpecBackendKind::Markdown]);
2547 assert!(
2548 out.contains("paw_status: pending"),
2549 "Markdown doctrine should reference paw_status: pending; got: {out}"
2550 );
2551 }
2552
2553 #[test]
2554 fn spec_doctrine_multi_backend_lists_each_present_backend() {
2555 use crate::specs::SpecBackendKind;
2556 let out = render_spec_path_doctrine(&[
2557 SpecBackendKind::OpenSpec,
2558 SpecBackendKind::SpecKit,
2559 SpecBackendKind::Markdown,
2560 ]);
2561 assert!(
2562 out.contains("openspec/changes/"),
2563 "multi-backend doctrine should mention OpenSpec; got:\n{out}"
2564 );
2565 assert!(
2566 out.contains(".specify/specs/"),
2567 "multi-backend doctrine should mention Spec Kit; got:\n{out}"
2568 );
2569 assert!(
2570 out.contains("paw_status: pending"),
2571 "multi-backend doctrine should mention Markdown; got:\n{out}"
2572 );
2573 assert!(
2574 out.contains("spans multiple"),
2575 "multi-backend doctrine should introduce the multi-backend session shape; got:\n{out}"
2576 );
2577 }
2578
2579 #[test]
2580 fn spec_doctrine_dedupes_repeated_backends() {
2581 use crate::specs::SpecBackendKind;
2582 let out = render_spec_path_doctrine(&[
2583 SpecBackendKind::OpenSpec,
2584 SpecBackendKind::OpenSpec,
2585 SpecBackendKind::OpenSpec,
2586 ]);
2587 assert!(
2590 !out.contains("spans multiple"),
2591 "duplicate backends must collapse to the single-backend shape; got:\n{out}"
2592 );
2593 }
2594
2595 #[test]
2600 fn render_doc_tool_command_substitutes_from_gates() {
2601 let tmpl = SkillTemplate {
2602 name: "supervisor".into(),
2603 content: "Run {{DOC_TOOL_COMMAND}} for API docs.".into(),
2604 source: Source::Embedded,
2605 format: SkillFormat::Standardized,
2606 metadata: None,
2607 resource_paths: None,
2608 };
2609 let gates = GateCommands {
2610 doc_tool_command: Some("sphinx-build -W docs docs/_build"),
2611 ..Default::default()
2612 };
2613 let output = render(
2614 &tmpl,
2615 "supervisor",
2616 "http://127.0.0.1:9119",
2617 "git-paw",
2618 &gates,
2619 &[],
2620 );
2621 assert_eq!(output, "Run sphinx-build -W docs docs/_build for API docs.");
2622 assert!(!output.contains("{{DOC_TOOL_COMMAND}}"));
2623 }
2624
2625 #[test]
2626 fn render_doc_tool_command_empty_when_unset() {
2627 let tmpl = SkillTemplate {
2632 name: "supervisor".into(),
2633 content: "API doc tool: `{{DOC_TOOL_COMMAND}}`".into(),
2634 source: Source::Embedded,
2635 format: SkillFormat::Standardized,
2636 metadata: None,
2637 resource_paths: None,
2638 };
2639 let output = render(
2640 &tmpl,
2641 "supervisor",
2642 "http://127.0.0.1:9119",
2643 "git-paw",
2644 &GateCommands::default(),
2645 &[],
2646 );
2647 assert_eq!(output, "API doc tool: ``");
2648 assert!(!output.contains("(not configured)"));
2649 }
2650
2651 #[test]
2652 fn render_dev_allowlist_preset_placeholder_substitutes() {
2653 let tmpl = SkillTemplate {
2654 name: "supervisor".into(),
2655 content: "Allowed: {{DEV_ALLOWLIST_PRESET}}".into(),
2656 source: Source::Embedded,
2657 format: SkillFormat::Standardized,
2658 metadata: None,
2659 resource_paths: None,
2660 };
2661 let output = render(
2662 &tmpl,
2663 "supervisor",
2664 "http://127.0.0.1:9119",
2665 "git-paw",
2666 &GateCommands::default(),
2667 &[],
2668 );
2669 assert!(
2670 output.contains("git (status"),
2671 "rendered placeholder should embed the grouped preset prose; got:\n{output}"
2672 );
2673 assert!(!output.contains("{{DEV_ALLOWLIST_PRESET}}"));
2674 }
2675
2676 #[test]
2677 fn render_spec_path_doctrine_placeholder_substitutes_per_backend() {
2678 use crate::specs::SpecBackendKind;
2679 let tmpl = SkillTemplate {
2680 name: "supervisor".into(),
2681 content: "Spec layout: {{SPEC_PATH_DOCTRINE}}".into(),
2682 source: Source::Embedded,
2683 format: SkillFormat::Standardized,
2684 metadata: None,
2685 resource_paths: None,
2686 };
2687 let openspec_output = render(
2688 &tmpl,
2689 "supervisor",
2690 "http://127.0.0.1:9119",
2691 "git-paw",
2692 &GateCommands::default(),
2693 &[SpecBackendKind::OpenSpec],
2694 );
2695 assert!(openspec_output.contains("openspec/changes/"));
2696 assert!(!openspec_output.contains("{{SPEC_PATH_DOCTRINE}}"));
2697
2698 let speckit_output = render(
2699 &tmpl,
2700 "supervisor",
2701 "http://127.0.0.1:9119",
2702 "git-paw",
2703 &GateCommands::default(),
2704 &[SpecBackendKind::SpecKit],
2705 );
2706 assert!(speckit_output.contains(".specify/specs/"));
2707 }
2708
2709 #[test]
2710 fn render_spec_path_doctrine_empty_renders_sentinel() {
2711 let tmpl = SkillTemplate {
2712 name: "supervisor".into(),
2713 content: "{{SPEC_PATH_DOCTRINE}}".into(),
2714 source: Source::Embedded,
2715 format: SkillFormat::Standardized,
2716 metadata: None,
2717 resource_paths: None,
2718 };
2719 let output = render(
2720 &tmpl,
2721 "supervisor",
2722 "http://127.0.0.1:9119",
2723 "git-paw",
2724 &GateCommands::default(),
2725 &[],
2726 );
2727 assert!(output.contains("no spec backend"));
2728 }
2729
2730 #[test]
2733 fn governance_section_empty_when_all_paths_none() {
2734 let out = governance_section_paths(None, None, None, None, None);
2735 assert!(
2736 out.is_empty(),
2737 "governance_section_paths should return empty string when all paths are None, got: {out:?}"
2738 );
2739 }
2740
2741 #[test]
2742 fn governance_section_one_path_only_dod() {
2743 let dod = Path::new("docs/dod.md");
2744 let out = governance_section_paths(None, None, None, Some(dod), None);
2745 assert!(
2746 out.contains("## Governance documents"),
2747 "section should include the canonical heading, got:\n{out}"
2748 );
2749 assert!(
2750 out.contains("- dod: docs/dod.md"),
2751 "section should include the dod bullet, got:\n{out}"
2752 );
2753 for unset in [
2754 "- adr:",
2755 "- test_strategy:",
2756 "- security:",
2757 "- constitution:",
2758 ] {
2759 assert!(
2760 !out.contains(unset),
2761 "section should not mention `{unset}` when its path is None, got:\n{out}"
2762 );
2763 }
2764 }
2765
2766 #[test]
2767 fn governance_section_lists_all_five_in_canonical_order() {
2768 let adr = Path::new("docs/adr/");
2769 let test_strategy = Path::new("docs/test-strategy.md");
2770 let security = Path::new("docs/security.md");
2771 let dod = Path::new("docs/dod.md");
2772 let constitution = Path::new("docs/constitution.md");
2773 let out = governance_section_paths(
2774 Some(adr),
2775 Some(test_strategy),
2776 Some(security),
2777 Some(dod),
2778 Some(constitution),
2779 );
2780
2781 let order = [
2782 "- adr: docs/adr/",
2783 "- test_strategy: docs/test-strategy.md",
2784 "- security: docs/security.md",
2785 "- dod: docs/dod.md",
2786 "- constitution: docs/constitution.md",
2787 ];
2788 let mut last_pos = 0usize;
2789 for bullet in order {
2790 let idx = out
2791 .find(bullet)
2792 .unwrap_or_else(|| panic!("bullet `{bullet}` not found in:\n{out}"));
2793 assert!(
2794 idx >= last_pos,
2795 "bullets must appear in canonical adr -> test_strategy -> security -> dod -> constitution order; `{bullet}` came before a previous bullet in:\n{out}"
2796 );
2797 last_pos = idx;
2798 }
2799 }
2800
2801 #[test]
2802 fn governance_section_has_no_gates_text() {
2803 let out = governance_section_paths(
2804 Some(Path::new("docs/adr/")),
2805 Some(Path::new("docs/test-strategy.md")),
2806 Some(Path::new("docs/security.md")),
2807 Some(Path::new("docs/dod.md")),
2808 Some(Path::new("docs/constitution.md")),
2809 );
2810 let lowered = out.to_lowercase();
2811 assert!(
2812 !lowered.contains("gated docs"),
2813 "section should not contain a 'Gated docs' line, got:\n{out}"
2814 );
2815 assert!(
2816 !lowered.contains("governance gates"),
2817 "section should not contain a 'Governance gates' sub-section, got:\n{out}"
2818 );
2819 assert!(
2820 !out.contains("[governance.gates]"),
2821 "section should not reference the dropped [governance.gates] table, got:\n{out}"
2822 );
2823 assert!(
2824 !out.contains("[governance-gate:"),
2825 "section should not introduce the dropped [governance-gate:<doc>] tag, got:\n{out}"
2826 );
2827 }
2828
2829 #[test]
2830 fn governance_section_has_preamble_line() {
2831 let out = governance_section_paths(None, None, None, Some(Path::new("docs/dod.md")), None);
2832 let preamble = "The supervisor consults these documents during spec audit.";
2833 assert!(
2834 out.contains(preamble),
2835 "section should include the preamble line; got:\n{out}"
2836 );
2837 let heading_pos = out.find("## Governance documents").unwrap();
2839 let preamble_pos = out.find(preamble).unwrap();
2840 let bullet_pos = out.find("- dod:").unwrap();
2841 assert!(
2842 heading_pos < preamble_pos && preamble_pos < bullet_pos,
2843 "section layout should be heading -> preamble -> bullets; got:\n{out}"
2844 );
2845 }
2846
2847 #[test]
2849 fn project_name_is_substituted() {
2850 let tmpl = SkillTemplate {
2851 name: "test".into(),
2852 content: "session=paw-{{PROJECT_NAME}}".into(),
2853 source: Source::Embedded,
2854 format: SkillFormat::Standardized,
2855 metadata: None,
2856 resource_paths: None,
2857 };
2858 let output = render(
2859 &tmpl,
2860 "feat/x",
2861 "http://127.0.0.1:9119",
2862 "my-app",
2863 &GateCommands::default(),
2864 &[],
2865 );
2866 assert!(output.contains("paw-my-app"));
2867 assert!(!output.contains("{{PROJECT_NAME}}"));
2868 }
2869
2870 #[test]
2872 fn branch_id_and_project_name_both_substituted() {
2873 let tmpl = SkillTemplate {
2874 name: "test".into(),
2875 content: "agent={{BRANCH_ID}} session=paw-{{PROJECT_NAME}}".into(),
2876 source: Source::Embedded,
2877 format: SkillFormat::Standardized,
2878 metadata: None,
2879 resource_paths: None,
2880 };
2881 let output = render(
2882 &tmpl,
2883 "feat/http-broker",
2884 "url",
2885 "git-paw",
2886 &GateCommands::default(),
2887 &[],
2888 );
2889 assert!(output.contains("feat-http-broker"));
2890 assert!(output.contains("paw-git-paw"));
2891 assert!(!output.contains("{{BRANCH_ID}}"));
2892 assert!(!output.contains("{{PROJECT_NAME}}"));
2893 }
2894
2895 #[test]
2897 #[serial(directory_changes)]
2898 fn standardized_skill_format_is_detected() {
2899 let dir = tempfile::tempdir().unwrap();
2900 let project_dir = dir.path().join("my-project");
2901 std::fs::create_dir_all(&project_dir).unwrap();
2902
2903 let skill_dir = project_dir
2904 .join(".agents")
2905 .join("skills")
2906 .join("test-standardized");
2907 std::fs::create_dir_all(&skill_dir).unwrap();
2908
2909 let skill_md_content = "---\nname: test-standardized\ndescription: A test standardized skill\n---\n\nThis is the skill content with {{BRANCH_ID}} placeholder.";
2910 std::fs::write(skill_dir.join("SKILL.md"), skill_md_content).unwrap();
2911
2912 let original_dir = std::env::current_dir().unwrap();
2914 std::env::set_current_dir(&project_dir).unwrap();
2915
2916 let tmpl = resolve("test-standardized").expect("should resolve");
2917 assert_eq!(tmpl.format, SkillFormat::Standardized);
2918 assert!(tmpl.content.contains("This is the skill content"));
2919 assert!(tmpl.content.contains("{{BRANCH_ID}}"));
2920 assert!(tmpl.metadata.is_some());
2921 let metadata = tmpl.metadata.as_ref().unwrap();
2922 assert_eq!(metadata.name, "test-standardized");
2923 assert_eq!(metadata.description, "A test standardized skill");
2924
2925 std::env::set_current_dir(original_dir).unwrap();
2927 }
2928
2929 #[test]
2931 fn standardized_skill_with_resources_loads_paths() {
2932 let dir = tempfile::tempdir().unwrap();
2933 let skills_parent_dir = dir.path().join("git-paw").join("agent-skills");
2934 let specific_skill_dir = skills_parent_dir.join("test-with-resources");
2935 std::fs::create_dir_all(&specific_skill_dir).unwrap();
2936
2937 std::fs::create_dir_all(specific_skill_dir.join("scripts")).unwrap();
2939 std::fs::create_dir_all(specific_skill_dir.join("references")).unwrap();
2940 std::fs::create_dir_all(specific_skill_dir.join("assets")).unwrap();
2941
2942 let skill_md_content = "---\nname: test-with-resources\ndescription: Skill with resources\n---\n\nMain content here.";
2943 std::fs::write(specific_skill_dir.join("SKILL.md"), skill_md_content).unwrap();
2944
2945 let tmpl = resolve_with_config_dir("test-with-resources", Some(dir.path()))
2946 .expect("should resolve");
2947 assert_eq!(tmpl.format, SkillFormat::Standardized);
2948 assert!(tmpl.resource_paths.is_some());
2949 let resource_paths = tmpl.resource_paths.as_ref().unwrap();
2950 assert_eq!(resource_paths.len(), 3);
2951 assert!(resource_paths.iter().any(|p| p.ends_with("scripts")));
2952 assert!(resource_paths.iter().any(|p| p.ends_with("references")));
2953 assert!(resource_paths.iter().any(|p| p.ends_with("assets")));
2954 }
2955
2956 #[test]
2958 #[serial(directory_changes)]
2959 fn standard_location_loading() {
2960 let temp_dir = tempfile::tempdir().unwrap();
2961 let project_dir = temp_dir.path().join("my-project");
2962 std::fs::create_dir_all(&project_dir).unwrap();
2963
2964 let standard_skill_dir = project_dir
2966 .join(".agents")
2967 .join("skills")
2968 .join("test-skill");
2969 std::fs::create_dir_all(&standard_skill_dir).unwrap();
2970 let standard_content = "---\nname: test-skill\ndescription: Standard location skill\n---\n\nContent from .agents/skills/";
2971 std::fs::write(standard_skill_dir.join("SKILL.md"), standard_content).unwrap();
2972
2973 let original_dir = std::env::current_dir().unwrap();
2975 std::env::set_current_dir(&project_dir).unwrap();
2976
2977 let tmpl = resolve("test-skill").expect("should resolve");
2978
2979 assert_eq!(tmpl.source, Source::AgentsStandard);
2981 assert!(tmpl.content.contains("Content from .agents/skills/"));
2982
2983 std::env::set_current_dir(original_dir).unwrap();
2985 }
2986
2987 #[test]
2989 fn standardized_skill_metadata_placeholders_are_substituted() {
2990 let metadata = StandardizedSkillMetadata {
2991 name: "test-skill".to_string(),
2992 description: "Test description".to_string(),
2993 license: None,
2994 compatibility: None,
2995 metadata: None,
2996 };
2997
2998 let tmpl = SkillTemplate {
2999 name: "test".into(),
3000 content: "Name: {{SKILL_NAME}}, Desc: {{SKILL_DESCRIPTION}}".into(),
3001 source: Source::Embedded,
3002 format: SkillFormat::Standardized,
3003 metadata: Some(metadata),
3004 resource_paths: None,
3005 };
3006
3007 let output = render(
3008 &tmpl,
3009 "feat/x",
3010 "http://127.0.0.1:9119",
3011 "git-paw",
3012 &GateCommands::default(),
3013 &[],
3014 );
3015 assert!(output.contains("Name: test-skill, Desc: Test description"));
3016 assert!(!output.contains("{{SKILL_NAME}}"));
3017 assert!(!output.contains("{{SKILL_DESCRIPTION}}"));
3018 }
3019
3020 #[test]
3021 fn test_command_placeholder_substitutes_when_set() {
3022 let tmpl = SkillTemplate {
3023 name: "supervisor".into(),
3024 content: "Run `{{TEST_COMMAND}}` after each merge.".into(),
3025 source: Source::Embedded,
3026 format: SkillFormat::Standardized,
3027 metadata: None,
3028 resource_paths: None,
3029 };
3030 let output = render(
3031 &tmpl,
3032 "supervisor",
3033 "http://127.0.0.1:9119",
3034 "git-paw",
3035 &GateCommands {
3036 test_command: Some("just check"),
3037 ..Default::default()
3038 },
3039 &[],
3040 );
3041 assert_eq!(output, "Run `just check` after each merge.");
3042 assert!(!output.contains("{{TEST_COMMAND}}"));
3043 }
3044
3045 #[test]
3046 fn test_command_placeholder_falls_back_when_unset() {
3047 let tmpl = SkillTemplate {
3048 name: "supervisor".into(),
3049 content: "Baseline: {{TEST_COMMAND}}".into(),
3050 source: Source::Embedded,
3051 format: SkillFormat::Standardized,
3052 metadata: None,
3053 resource_paths: None,
3054 };
3055 let output = render(
3056 &tmpl,
3057 "supervisor",
3058 "http://127.0.0.1:9119",
3059 "git-paw",
3060 &GateCommands::default(),
3061 &[],
3062 );
3063 assert_eq!(output, "Baseline: (not configured)");
3064 assert!(!output.contains("{{TEST_COMMAND}}"));
3065 }
3066
3067 #[test]
3068 fn supervisor_template_no_unsubstituted_placeholders_when_test_command_set() {
3069 let tmpl = resolve("supervisor").expect("supervisor skill resolves");
3078 let output = render(
3079 &tmpl,
3080 "supervisor",
3081 "http://127.0.0.1:9119",
3082 "git-paw",
3083 &GateCommands {
3084 test_command: Some("just check"),
3085 ..Default::default()
3086 },
3087 &[],
3088 );
3089 assert!(
3090 !output.contains("{{TEST_COMMAND}}"),
3091 "supervisor template still contains a literal {{TEST_COMMAND}} after render"
3092 );
3093 let remaining: String = output.replace("{{CHANGE_ID}}", "").chars().collect();
3094 assert!(
3095 !remaining.contains("{{"),
3096 "supervisor template has unsubstituted {{...}} placeholder (other than {{CHANGE_ID}}) after render"
3097 );
3098 }
3099
3100 fn render_with_gates_uniform(template: &str, value: Option<&str>) -> String {
3105 let tmpl = SkillTemplate {
3106 name: "supervisor".into(),
3107 content: template.into(),
3108 source: Source::Embedded,
3109 format: SkillFormat::Standardized,
3110 metadata: None,
3111 resource_paths: None,
3112 };
3113 let gates = GateCommands {
3114 test_command: value,
3115 lint_command: value,
3116 build_command: value,
3117 doc_build_command: value,
3118 spec_validate_command: value,
3119 fmt_check_command: value,
3120 security_audit_command: value,
3121 doc_tool_command: value,
3122 };
3123 render(
3124 &tmpl,
3125 "supervisor",
3126 "http://127.0.0.1:9119",
3127 "git-paw",
3128 &gates,
3129 &[],
3130 )
3131 }
3132
3133 #[test]
3134 fn render_test_command_placeholder_substitutes_from_config() {
3135 let tmpl = SkillTemplate {
3136 name: "supervisor".into(),
3137 content: "Run {{TEST_COMMAND}}.".into(),
3138 source: Source::Embedded,
3139 format: SkillFormat::Standardized,
3140 metadata: None,
3141 resource_paths: None,
3142 };
3143 let gates = GateCommands {
3144 test_command: Some("just check"),
3145 ..Default::default()
3146 };
3147 let output = render(
3148 &tmpl,
3149 "supervisor",
3150 "http://127.0.0.1:9119",
3151 "git-paw",
3152 &gates,
3153 &[],
3154 );
3155 assert!(
3156 output.contains("Run just check."),
3157 "expected 'Run just check.' in: {output}"
3158 );
3159 }
3160
3161 #[test]
3162 fn render_test_command_placeholder_none_renders_not_configured() {
3163 let output = render_with_gates_uniform("Run {{TEST_COMMAND}}.", None);
3164 assert!(
3165 output.contains("Run (not configured)."),
3166 "expected 'Run (not configured).' in: {output}"
3167 );
3168 }
3169
3170 #[test]
3171 fn render_lint_command_placeholder_substitutes_and_none_fallback() {
3172 let tmpl = SkillTemplate {
3173 name: "supervisor".into(),
3174 content: "Run {{LINT_COMMAND}}.".into(),
3175 source: Source::Embedded,
3176 format: SkillFormat::Standardized,
3177 metadata: None,
3178 resource_paths: None,
3179 };
3180 let gates = GateCommands {
3181 lint_command: Some("cargo clippy -- -D warnings"),
3182 ..Default::default()
3183 };
3184 let output = render(
3185 &tmpl,
3186 "supervisor",
3187 "http://127.0.0.1:9119",
3188 "git-paw",
3189 &gates,
3190 &[],
3191 );
3192 assert!(
3193 output.contains("Run cargo clippy -- -D warnings."),
3194 "expected substitution in: {output}"
3195 );
3196
3197 let none_output = render_with_gates_uniform("Run {{LINT_COMMAND}}.", None);
3198 assert!(
3199 none_output.contains("Run (not configured)."),
3200 "expected '(not configured)' fallback in: {none_output}"
3201 );
3202 }
3203
3204 #[test]
3205 fn render_build_command_placeholder_substitutes_and_none_fallback() {
3206 let tmpl = SkillTemplate {
3207 name: "supervisor".into(),
3208 content: "Run {{BUILD_COMMAND}}.".into(),
3209 source: Source::Embedded,
3210 format: SkillFormat::Standardized,
3211 metadata: None,
3212 resource_paths: None,
3213 };
3214 let gates = GateCommands {
3215 build_command: Some("cargo build"),
3216 ..Default::default()
3217 };
3218 let output = render(
3219 &tmpl,
3220 "supervisor",
3221 "http://127.0.0.1:9119",
3222 "git-paw",
3223 &gates,
3224 &[],
3225 );
3226 assert!(output.contains("Run cargo build."), "got: {output}");
3227
3228 let none_output = render_with_gates_uniform("Run {{BUILD_COMMAND}}.", None);
3229 assert!(
3230 none_output.contains("Run (not configured)."),
3231 "got: {none_output}"
3232 );
3233 }
3234
3235 #[test]
3236 fn render_doc_build_command_placeholder_substitutes_and_none_fallback() {
3237 let tmpl = SkillTemplate {
3238 name: "supervisor".into(),
3239 content: "Run {{DOC_BUILD_COMMAND}}.".into(),
3240 source: Source::Embedded,
3241 format: SkillFormat::Standardized,
3242 metadata: None,
3243 resource_paths: None,
3244 };
3245 let gates = GateCommands {
3246 doc_build_command: Some("mdbook build docs/"),
3247 ..Default::default()
3248 };
3249 let output = render(
3250 &tmpl,
3251 "supervisor",
3252 "http://127.0.0.1:9119",
3253 "git-paw",
3254 &gates,
3255 &[],
3256 );
3257 assert!(output.contains("Run mdbook build docs/."), "got: {output}");
3258
3259 let none_output = render_with_gates_uniform("Run {{DOC_BUILD_COMMAND}}.", None);
3260 assert!(
3261 none_output.contains("Run (not configured)."),
3262 "got: {none_output}"
3263 );
3264 }
3265
3266 #[test]
3267 fn render_spec_validate_command_placeholder_substitutes_and_none_fallback() {
3268 let tmpl = SkillTemplate {
3269 name: "supervisor".into(),
3270 content: "Run {{SPEC_VALIDATE_COMMAND}}.".into(),
3271 source: Source::Embedded,
3272 format: SkillFormat::Standardized,
3273 metadata: None,
3274 resource_paths: None,
3275 };
3276 let gates = GateCommands {
3277 spec_validate_command: Some("openspec validate {{CHANGE_ID}} --strict"),
3278 ..Default::default()
3279 };
3280 let output = render(
3281 &tmpl,
3282 "supervisor",
3283 "http://127.0.0.1:9119",
3284 "git-paw",
3285 &gates,
3286 &[],
3287 );
3288 assert!(
3289 output.contains("Run openspec validate {{CHANGE_ID}} --strict."),
3290 "got: {output}"
3291 );
3292
3293 let none_output = render_with_gates_uniform("Run {{SPEC_VALIDATE_COMMAND}}.", None);
3294 assert!(
3295 none_output.contains("Run (not configured)."),
3296 "got: {none_output}"
3297 );
3298 }
3299
3300 #[test]
3301 fn render_fmt_check_command_placeholder_substitutes_and_none_fallback() {
3302 let tmpl = SkillTemplate {
3303 name: "supervisor".into(),
3304 content: "Run {{FMT_CHECK_COMMAND}}.".into(),
3305 source: Source::Embedded,
3306 format: SkillFormat::Standardized,
3307 metadata: None,
3308 resource_paths: None,
3309 };
3310 let gates = GateCommands {
3311 fmt_check_command: Some("cargo fmt --check"),
3312 ..Default::default()
3313 };
3314 let output = render(
3315 &tmpl,
3316 "supervisor",
3317 "http://127.0.0.1:9119",
3318 "git-paw",
3319 &gates,
3320 &[],
3321 );
3322 assert!(output.contains("Run cargo fmt --check."), "got: {output}");
3323
3324 let none_output = render_with_gates_uniform("Run {{FMT_CHECK_COMMAND}}.", None);
3325 assert!(
3326 none_output.contains("Run (not configured)."),
3327 "got: {none_output}"
3328 );
3329 }
3330
3331 #[test]
3332 fn render_security_audit_command_placeholder_substitutes_and_none_fallback() {
3333 let tmpl = SkillTemplate {
3334 name: "supervisor".into(),
3335 content: "Run {{SECURITY_AUDIT_COMMAND}}.".into(),
3336 source: Source::Embedded,
3337 format: SkillFormat::Standardized,
3338 metadata: None,
3339 resource_paths: None,
3340 };
3341 let gates = GateCommands {
3342 security_audit_command: Some("cargo audit"),
3343 ..Default::default()
3344 };
3345 let output = render(
3346 &tmpl,
3347 "supervisor",
3348 "http://127.0.0.1:9119",
3349 "git-paw",
3350 &gates,
3351 &[],
3352 );
3353 assert!(output.contains("Run cargo audit."), "got: {output}");
3354
3355 let none_output = render_with_gates_uniform("Run {{SECURITY_AUDIT_COMMAND}}.", None);
3356 assert!(
3357 none_output.contains("Run (not configured)."),
3358 "got: {none_output}"
3359 );
3360 }
3361
3362 #[test]
3363 fn supervisor_skill_renders_with_all_six_gate_placeholders_set() {
3364 let tmpl = resolve("supervisor").expect("supervisor skill resolves");
3368 let gates = GateCommands {
3369 test_command: Some("CMD-TEST"),
3370 lint_command: Some("CMD-LINT"),
3371 build_command: Some("CMD-BUILD"),
3372 doc_build_command: Some("CMD-DOC"),
3373 spec_validate_command: Some("CMD-SPEC"),
3374 fmt_check_command: Some("CMD-FMT"),
3375 security_audit_command: Some("CMD-SEC"),
3376 doc_tool_command: Some("CMD-DOCTOOL"),
3377 };
3378 let output = render(
3379 &tmpl,
3380 "supervisor",
3381 "http://127.0.0.1:9119",
3382 "git-paw",
3383 &gates,
3384 &[],
3385 );
3386 for needle in [
3387 "CMD-TEST",
3388 "CMD-LINT",
3389 "CMD-BUILD",
3390 "CMD-DOC",
3391 "CMD-SPEC",
3392 "CMD-FMT",
3393 "CMD-SEC",
3394 ] {
3395 assert!(
3396 output.contains(needle),
3397 "rendered supervisor skill should contain '{needle}'; not found"
3398 );
3399 }
3400 }
3401
3402 #[test]
3403 fn supervisor_skill_renders_not_configured_in_each_gate_when_none() {
3404 let tmpl = resolve("supervisor").expect("supervisor skill resolves");
3408 let output = render(
3409 &tmpl,
3410 "supervisor",
3411 "http://127.0.0.1:9119",
3412 "git-paw",
3413 &GateCommands::default(),
3414 &[],
3415 );
3416
3417 let testing_start = output.find("**Testing**").expect("Testing gate present");
3419 let testing_end = output[testing_start..]
3420 .find("**Regression analysis**")
3421 .map(|p| testing_start + p)
3422 .expect("Regression follows Testing");
3423 let testing_section = &output[testing_start..testing_end];
3424 assert!(
3425 testing_section.contains("(not configured)"),
3426 "Testing gate should render '(not configured)' when gate fields are None; got:\n{testing_section}"
3427 );
3428
3429 let spec_start = output.find("**Spec audit**").expect("Spec audit present");
3431 let spec_end = output[spec_start..]
3432 .find("**Doc audit**")
3433 .map(|p| spec_start + p)
3434 .expect("Doc audit follows Spec audit");
3435 let spec_section = &output[spec_start..spec_end];
3436 assert!(
3437 spec_section.contains("(not configured)"),
3438 "Spec audit gate should render '(not configured)' when None; got:\n{spec_section}"
3439 );
3440
3441 let doc_start = output.find("**Doc audit**").expect("Doc audit present");
3443 let doc_end = output[doc_start..]
3444 .find("**Security audit**")
3445 .map(|p| doc_start + p)
3446 .expect("Security audit follows Doc audit");
3447 let doc_section = &output[doc_start..doc_end];
3448 assert!(
3449 doc_section.contains("(not configured)"),
3450 "Doc audit gate should render '(not configured)' when None; got:\n{doc_section}"
3451 );
3452
3453 let security_start = output
3455 .find("**Security audit**")
3456 .expect("Security audit present");
3457 let security_end = output[security_start..]
3458 .find("**Verify or feedback**")
3459 .map(|p| security_start + p)
3460 .expect("Verify-or-feedback follows Security audit");
3461 let security_section = &output[security_start..security_end];
3462 assert!(
3463 security_section.contains("(not configured)"),
3464 "Security audit gate should render '(not configured)' when None; got:\n{security_section}"
3465 );
3466 }
3467
3468 #[test]
3475 fn supervisor_template_gate_prose_has_no_hardcoded_git_paw_commands() {
3476 let tmpl = resolve("supervisor").expect("supervisor skill resolves");
3477 let content = &tmpl.content;
3478 let start = content
3479 .find("Steps 4-7 below are the **five first-class verification gates**")
3480 .expect("five-gate intro present");
3481 let end = content
3482 .find("### Spec Audit Procedure")
3483 .expect("Spec Audit Procedure heading present");
3484 let gate_prose = &content[start..end];
3485 for needle in [
3486 "just check",
3487 "cargo test",
3488 "cargo clippy",
3489 "cargo audit",
3490 "cargo fmt --check",
3491 "mdbook build",
3492 ] {
3493 if needle == "cargo test"
3501 && (gate_prose.contains("[testing] cargo test failed")
3502 || gate_prose.contains("testing \"cargo test failed"))
3503 {
3504 let cleaned = gate_prose.replace("cargo test failed", "<failure>");
3505 assert!(
3506 !cleaned.contains("cargo test"),
3507 "gate prose must not contain hardcoded 'cargo test' outside the §7 example"
3508 );
3509 continue;
3510 }
3511 assert!(
3512 !gate_prose.contains(needle),
3513 "gate prose must not contain hardcoded '{needle}'; replace with the matching placeholder"
3514 );
3515 }
3516 }
3517
3518 #[test]
3519 fn render_change_id_placeholder_passes_through() {
3520 let tmpl = SkillTemplate {
3521 name: "supervisor".into(),
3522 content: "Run {{SPEC_VALIDATE_COMMAND}}.".into(),
3523 source: Source::Embedded,
3524 format: SkillFormat::Standardized,
3525 metadata: None,
3526 resource_paths: None,
3527 };
3528 let gates = GateCommands {
3529 spec_validate_command: Some("openspec validate {{CHANGE_ID}} --strict"),
3530 ..Default::default()
3531 };
3532 let output = render(
3533 &tmpl,
3534 "supervisor",
3535 "http://127.0.0.1:9119",
3536 "git-paw",
3537 &gates,
3538 &[],
3539 );
3540 assert!(
3541 output.contains("Run openspec validate {{CHANGE_ID}} --strict."),
3542 "outer placeholder substituted but inner {{CHANGE_ID}} preserved; got: {output}"
3543 );
3544 assert!(
3545 output.contains("{{CHANGE_ID}}"),
3546 "{{CHANGE_ID}} must survive verbatim (not substituted at render time); got: {output}"
3547 );
3548 }
3549
3550 #[test]
3552 fn invalid_standardized_skill_frontmatter_returns_error() {
3553 let dir = tempfile::tempdir().unwrap();
3554 let project_dir = dir.path().join("my-project");
3555 std::fs::create_dir_all(&project_dir).unwrap();
3556
3557 let skill_dir = project_dir
3558 .join(".agents")
3559 .join("skills")
3560 .join("invalid-skill");
3561 std::fs::create_dir_all(&skill_dir).unwrap();
3562
3563 let skill_md_content = "---\nname: invalid-skill\n---\n\nContent here.";
3565 std::fs::write(skill_dir.join("SKILL.md"), skill_md_content).unwrap();
3566
3567 let original_dir = std::env::current_dir().unwrap();
3569 std::env::set_current_dir(&project_dir).unwrap();
3570
3571 let result = resolve("invalid-skill");
3572 assert!(matches!(result, Err(SkillError::ValidationError { .. })));
3573
3574 std::env::set_current_dir(original_dir).unwrap();
3576 }
3577
3578 #[test]
3580 fn skill_template_is_cloneable() {
3581 let tmpl = resolve("coordination").unwrap();
3582 let cloned = tmpl.clone();
3583 assert_eq!(tmpl.name, cloned.name);
3584 assert_eq!(tmpl.content, cloned.content);
3585 assert_eq!(tmpl.source, cloned.source);
3586 }
3587
3588 #[test]
3590 fn boot_block_contains_all_four_essential_events() {
3591 let block = build_boot_block("feat/errors", "http://localhost:9119");
3592 assert!(
3593 block.contains("### 1. REGISTER"),
3594 "Missing REGISTER section"
3595 );
3596 assert!(block.contains("### 2. DONE"), "Missing DONE section");
3597 assert!(block.contains("### 3. BLOCKED"), "Missing BLOCKED section");
3598 assert!(
3599 block.contains("### 4. QUESTION"),
3600 "Missing QUESTION section"
3601 );
3602 }
3603
3604 #[test]
3608 fn boot_block_all_four_events_call_helper_no_raw_curl() {
3609 let block = build_boot_block("feat/test", "http://127.0.0.1:9119");
3610
3611 assert!(
3613 !block.contains("curl -s -X POST"),
3614 "boot block must not inline a raw broker curl for any event"
3615 );
3616 assert!(
3617 !block.contains("{{GIT_PAW_BROKER_URL}}"),
3618 "boot block must not leak the broker-URL placeholder"
3619 );
3620
3621 assert!(
3623 block.contains(".git-paw/scripts/broker.sh --agent feat-test status booting"),
3624 "REGISTER event should call broker.sh status"
3625 );
3626 assert!(
3627 block.contains(".git-paw/scripts/broker.sh --agent feat-test artifact"),
3628 "DONE-fallback event should call broker.sh artifact"
3629 );
3630 assert!(
3631 block.contains(".git-paw/scripts/broker.sh --agent feat-test blocked"),
3632 "BLOCKED event should call broker.sh blocked"
3633 );
3634 assert!(
3635 block.contains(".git-paw/scripts/broker.sh --agent feat-test question"),
3636 "QUESTION event should call broker.sh question"
3637 );
3638
3639 assert!(
3641 block.contains("agent.artifact") && block.contains("status: \"done\""),
3642 "DONE fallback should still publish agent.artifact status: done"
3643 );
3644 }
3645
3646 #[test]
3647 fn boot_block_substitutes_branch_id_placeholder() {
3648 let block = build_boot_block("Feature/HTTP_Broker", "http://localhost:9119");
3649 assert!(
3650 block.contains("feature-http_broker"),
3651 "Branch ID not properly slugified"
3652 );
3653 assert!(
3654 !block.contains("{{BRANCH_ID}}"),
3655 "BRANCH_ID placeholder not substituted"
3656 );
3657 }
3658
3659 #[test]
3660 fn boot_block_uses_helper_not_raw_broker_url() {
3661 let block = build_boot_block("feat/x", "http://127.0.0.1:9119");
3662 assert!(
3665 !block.contains("curl -s -X POST http://127.0.0.1:9119/publish"),
3666 "boot block must not inline a raw broker curl"
3667 );
3668 assert!(
3669 !block.contains("{{GIT_PAW_BROKER_URL}}"),
3670 "GIT_PAW_BROKER_URL placeholder must not leak into the rendered block"
3671 );
3672 assert!(
3673 block.contains(".git-paw/scripts/broker.sh"),
3674 "boot block must invoke the bundled broker.sh helper"
3675 );
3676 }
3677
3678 #[test]
3679 fn boot_block_contains_paste_handling_instructions() {
3680 let block = build_boot_block("feat/x", "http://localhost:9119");
3681 assert!(
3682 block.contains("PASTE HANDLING"),
3683 "Missing paste handling section"
3684 );
3685 assert!(
3686 block.contains("additional Enter key"),
3687 "Missing Enter key instruction"
3688 );
3689 assert!(
3690 block.contains("[Pasted text #N]"),
3691 "Missing paste text reference"
3692 );
3693 }
3694
3695 #[test]
3696 fn boot_block_question_section_emphasizes_waiting() {
3697 let block = build_boot_block("feat/x", "http://localhost:9119");
3698 assert!(
3699 block.contains("DO NOT CONTINUE UNTIL YOU RECEIVE AN ANSWER!"),
3700 "Missing wait emphasis"
3701 );
3702 assert!(
3703 block.contains("WAIT for the answer before continuing"),
3704 "Missing wait instruction"
3705 );
3706 }
3707
3708 #[test]
3709 fn boot_block_is_deterministic() {
3710 let a = build_boot_block("feat/x", "http://localhost:9119");
3711 let b = build_boot_block("feat/x", "http://localhost:9119");
3712 assert_eq!(a, b, "Boot block generation should be deterministic");
3713 }
3714
3715 #[test]
3716 fn boot_block_handles_complex_branch_names() {
3717 let block = build_boot_block("fix/topological-cycle-fallback", "http://localhost:9119");
3718 assert!(
3719 block.contains("fix-topological-cycle-fallback"),
3720 "Complex branch name not properly slugified"
3721 );
3722 }
3723
3724 #[test]
3725 fn boot_block_contains_pre_expanded_helper_invocations() {
3726 let block = build_boot_block("feat/test", "http://127.0.0.1:9119");
3727
3728 assert!(
3730 block.contains(".git-paw/scripts/broker.sh --agent feat-test status booting"),
3731 "REGISTER should call broker.sh with the pre-expanded agent id"
3732 );
3733 assert!(
3734 block.contains(".git-paw/scripts/broker.sh --agent feat-test"),
3735 "Agent ID not substituted in broker.sh invocations"
3736 );
3737 assert!(
3739 !block.contains("curl -s -X POST"),
3740 "boot block must not contain a raw broker curl"
3741 );
3742 }
3743
3744 fn done_section_body(block: &str) -> String {
3745 let start = block
3746 .find("### 2. DONE")
3747 .expect("rendered boot block should contain the DONE section heading");
3748 let end = block
3749 .find("### 3. BLOCKED")
3750 .expect("rendered boot block should contain the BLOCKED section heading");
3751 block[start..end].to_string()
3752 }
3753
3754 #[test]
3755 fn boot_block_done_section_leads_with_commit_instruction() {
3756 let block = build_boot_block("feat/test", "http://127.0.0.1:9119");
3757 let done_body = done_section_body(&block);
3758
3759 let commit_idx = done_body
3760 .find("commit your work")
3761 .or_else(|| done_body.find("git commit"))
3762 .expect("DONE section should lead with a commit-first instruction");
3763
3764 let manual_done_idx = done_body
3765 .find(".git-paw/scripts/broker.sh --agent feat-test artifact")
3766 .expect("DONE section should still contain the manual artifact helper as a fallback");
3767
3768 assert!(
3769 commit_idx < manual_done_idx,
3770 "commit-first instruction (byte {commit_idx}) must appear before the manual artifact helper (byte {manual_done_idx})"
3771 );
3772 }
3773
3774 #[test]
3775 fn boot_block_done_section_names_committed_status_published_by_hook() {
3776 let block = build_boot_block("feat/test", "http://127.0.0.1:9119");
3777 let done_body = done_section_body(&block);
3778
3779 assert!(
3780 done_body.contains("status: \"committed\"")
3781 || done_body.contains("status:\"committed\""),
3782 "DONE section should name the `status: \"committed\"` event published by the hook"
3783 );
3784 assert!(
3785 done_body.contains("post-commit hook"),
3786 "DONE section should mention the post-commit hook that publishes on the agent's behalf"
3787 );
3788 }
3789
3790 #[test]
3791 fn boot_block_done_section_scopes_manual_done_to_code_less_tasks() {
3792 let block = build_boot_block("feat/test", "http://127.0.0.1:9119");
3793 let done_body = done_section_body(&block);
3794
3795 let hits = ["docs-only", "planning", "exploration"]
3796 .iter()
3797 .filter(|needle| done_body.contains(*needle))
3798 .count();
3799 assert!(
3800 hits >= 2,
3801 "DONE section should enumerate at least two code-less task examples \
3802 (docs-only / planning / exploration); only {hits} present"
3803 );
3804 }
3805
3806 #[test]
3807 fn boot_block_done_section_warns_against_manual_done_with_uncommitted_changes() {
3808 let block = build_boot_block("feat/test", "http://127.0.0.1:9119");
3809 let done_body = done_section_body(&block);
3810
3811 assert!(
3812 done_body.contains("uncommitted"),
3813 "DONE section should warn about uncommitted changes"
3814 );
3815 assert!(
3816 done_body.contains("manual `done`") || done_body.contains("manual done"),
3817 "DONE section warning should reference manual `done`"
3818 );
3819 assert!(
3820 done_body.contains("**WARNING") || done_body.contains("**DO NOT"),
3821 "DONE section warning should be emphasised with bold markers (**...**)"
3822 );
3823 }
3824
3825 #[test]
3826 fn boot_block_done_section_retains_manual_done_helper() {
3827 let block = build_boot_block("feat/test", "http://127.0.0.1:9119");
3828 let done_body = done_section_body(&block);
3829
3830 assert!(
3833 done_body.contains(".git-paw/scripts/broker.sh --agent feat-test artifact"),
3834 "DONE section should retain the manual artifact helper invocation"
3835 );
3836 assert!(
3837 !done_body.contains("curl -s -X POST"),
3838 "DONE section must not retain a raw broker curl"
3839 );
3840 assert!(
3844 done_body.contains("agent.artifact"),
3845 "DONE section should name the agent.artifact event the helper publishes"
3846 );
3847 assert!(
3848 done_body.contains("status: \"done\"") || done_body.contains("status:\"done\""),
3849 "DONE section should describe the status: done manual fallback"
3850 );
3851 assert!(
3852 done_body.contains("--exports"),
3853 "DONE section should show the --exports flag mapping onto the exports field"
3854 );
3855 assert!(
3856 done_body.contains("--files"),
3857 "DONE section should show the --files flag mapping onto modified_files"
3858 );
3859 }
3860
3861 #[test]
3866 fn supervisor_skill_contains_conflict_detector_tag() {
3867 let tmpl = resolve("supervisor").unwrap();
3868 assert!(
3869 tmpl.content.contains("[conflict-detector]"),
3870 "supervisor skill should reference the [conflict-detector] tag"
3871 );
3872 }
3873
3874 #[test]
3875 fn supervisor_skill_documents_broker_side_detection() {
3876 let tmpl = resolve("supervisor").unwrap();
3877 let lowered = tmpl.content.to_lowercase();
3878 assert!(
3879 lowered.contains("auto-detect") || lowered.contains("auto-emit"),
3880 "skill should mention auto-detection/auto-emission by the broker"
3881 );
3882 assert!(
3883 lowered.contains("forward conflict"),
3884 "skill should mention forward conflict"
3885 );
3886 assert!(
3887 lowered.contains("in-flight conflict"),
3888 "skill should mention in-flight conflict"
3889 );
3890 assert!(
3891 lowered.contains("ownership violation"),
3892 "skill should mention ownership violation"
3893 );
3894 }
3895
3896 #[test]
3897 fn supervisor_skill_removes_v04_manual_conflict_detection() {
3898 let tmpl = resolve("supervisor").unwrap();
3899 assert!(
3900 !tmpl
3901 .content
3902 .contains("Compare the `modified_files` arrays from every `agent.artifact` event"),
3903 "supervisor skill should no longer contain the v0.4 manual conflict-comparison instructions"
3904 );
3905 }
3906
3907 #[test]
3908 fn supervisor_skill_mentions_agent_intent() {
3909 let tmpl = resolve("supervisor").unwrap();
3910 assert!(tmpl.content.contains("agent.intent"));
3911 assert!(
3912 tmpl.content.contains("Watch peer intents")
3913 || tmpl
3914 .content
3915 .contains("Watch peer intents and broker-side conflict detection"),
3916 "skill should contain a 'Watch peer intents' heading"
3917 );
3918 }
3919
3920 #[test]
3921 fn supervisor_skill_focuses_on_question_escalations() {
3922 let tmpl = resolve("supervisor").unwrap();
3923 let lowered = tmpl.content.to_lowercase();
3924 assert!(
3927 lowered.contains("agent.question")
3928 && (lowered.contains("escalation") || lowered.contains("escalat")),
3929 "skill should direct the supervisor agent at agent.question escalations"
3930 );
3931 assert!(
3932 lowered.contains("do not") && lowered.contains("manually"),
3933 "skill should tell the supervisor not to duplicate by manual comparison"
3934 );
3935 }
3936
3937 #[test]
3940 fn embedded_coordination_mentions_spec_kit_consolidated_worktrees() {
3941 let tmpl = resolve("coordination").unwrap();
3942 assert!(
3943 tmpl.content.contains("Spec Kit")
3944 && (tmpl.content.contains("consolidated") || tmpl.content.contains("phase/")),
3945 "coordination skill should mention Spec Kit consolidated worktrees"
3946 );
3947 }
3948
3949 #[test]
3950 fn embedded_coordination_instructs_sequential_work_and_writeback() {
3951 let tmpl = resolve("coordination").unwrap();
3952 assert!(
3953 tmpl.content.contains("sequential") || tmpl.content.contains("Sequential"),
3954 "should instruct sequential execution"
3955 );
3956 assert!(
3957 tmpl.content.contains("`- [x]`") || tmpl.content.contains("- [x]"),
3958 "should mention - [x] writeback"
3959 );
3960 assert!(
3961 tmpl.content.contains("tasks.md"),
3962 "should reference tasks.md as writeback target"
3963 );
3964 }
3965
3966 #[test]
3967 fn embedded_coordination_states_agent_done_timing_for_consolidated() {
3968 let tmpl = resolve("coordination").unwrap();
3969 assert!(
3970 tmpl.content.contains("agent.done"),
3971 "should mention agent.done"
3972 );
3973 let lower = tmpl.content.to_lowercase();
3974 assert!(
3975 lower.contains("every task")
3976 || lower.contains("all listed tasks")
3977 || lower.contains("all tasks"),
3978 "should tie agent.done to completion of all listed tasks"
3979 );
3980 }
3981
3982 #[test]
3983 fn embedded_coordination_clarifies_p_worktrees_follow_standard_pattern() {
3984 let tmpl = resolve("coordination").unwrap();
3985 assert!(
3986 tmpl.content.contains("[P]") || tmpl.content.contains("task/"),
3987 "should distinguish [P] / task/ worktrees from consolidated ones"
3988 );
3989 assert!(
3990 tmpl.content.contains("standard"),
3991 "should reference the standard before/while-editing pattern"
3992 );
3993 }
3994
3995 #[test]
4001 fn supervisor_skill_has_user_input_section() {
4002 let tmpl = resolve("supervisor").unwrap();
4003 assert!(
4004 tmpl.content.contains("When the user types in your pane"),
4005 "supervisor skill should include the 'When the user types in your pane' section"
4006 );
4007 }
4008
4009 #[test]
4011 fn supervisor_skill_user_input_uses_agent_feedback_for_directives() {
4012 let tmpl = resolve("supervisor").unwrap();
4013 let start = tmpl
4014 .content
4015 .find("When the user types in your pane")
4016 .expect("user-input section heading present");
4017 let window = &tmpl.content[start..];
4018 assert!(
4019 window.contains("agent.feedback"),
4020 "user-input directives section should reference agent.feedback"
4021 );
4022 }
4023
4024 #[test]
4026 fn supervisor_skill_user_input_uses_agent_question_for_judgment_calls() {
4027 let tmpl = resolve("supervisor").unwrap();
4028 let start = tmpl
4029 .content
4030 .find("When the user types in your pane")
4031 .expect("user-input section heading present");
4032 let window = &tmpl.content[start..];
4033 assert!(
4034 window.contains("agent.question"),
4035 "user-input judgment-call section should reference agent.question"
4036 );
4037 }
4038
4039 #[test]
4041 fn supervisor_skill_user_input_states_loop_continues() {
4042 let tmpl = resolve("supervisor").unwrap();
4043 let start = tmpl
4044 .content
4045 .find("When the user types in your pane")
4046 .expect("user-input section heading present");
4047 let window = &tmpl.content[start..];
4048 assert!(
4049 window.to_lowercase().contains("autonomous"),
4050 "user-input section should state the autonomous loop continues alongside user input"
4051 );
4052 }
4053
4054 #[test]
4056 fn supervisor_skill_has_merge_orchestration_section() {
4057 let tmpl = resolve("supervisor").unwrap();
4058 assert!(
4059 tmpl.content.contains("Merge orchestration"),
4060 "supervisor skill should include the 'Merge orchestration' section"
4061 );
4062 }
4063
4064 #[test]
4066 fn supervisor_skill_merge_uses_ff_only() {
4067 let tmpl = resolve("supervisor").unwrap();
4068 let start = tmpl
4069 .content
4070 .find("Merge orchestration")
4071 .expect("merge orchestration section present");
4072 let window = &tmpl.content[start..];
4073 assert!(
4074 window.contains("git merge --ff-only"),
4075 "merge orchestration should specify git merge --ff-only"
4076 );
4077 }
4078
4079 #[test]
4081 fn supervisor_skill_merge_reverts_via_reset_hard() {
4082 let tmpl = resolve("supervisor").unwrap();
4083 let start = tmpl
4084 .content
4085 .find("Merge orchestration")
4086 .expect("merge orchestration section present");
4087 let window = &tmpl.content[start..];
4088 assert!(
4089 window.contains("git reset --hard"),
4090 "merge orchestration should describe regression revert via git reset --hard"
4091 );
4092 }
4093
4094 #[test]
4096 fn supervisor_skill_merge_cycle_uses_agent_question() {
4097 let tmpl = resolve("supervisor").unwrap();
4098 let start = tmpl
4099 .content
4100 .find("Merge orchestration")
4101 .expect("merge orchestration section present");
4102 let window = &tmpl.content[start..];
4103 assert!(
4104 window.contains("agent.question") && window.to_lowercase().contains("cycle"),
4105 "merge orchestration cycle handling should publish agent.question"
4106 );
4107 }
4108
4109 #[test]
4111 fn supervisor_skill_merge_publishes_final_status_summary() {
4112 let tmpl = resolve("supervisor").unwrap();
4113 let start = tmpl
4114 .content
4115 .find("Merge orchestration")
4116 .expect("merge orchestration section present");
4117 let window = &tmpl.content[start..];
4118 assert!(
4119 window.contains("agent.status") && window.to_lowercase().contains("summary"),
4120 "merge orchestration should end with a final agent.status summary"
4121 );
4122 }
4123
4124 #[test]
4129 fn coordination_skill_documents_slugify_terminology() {
4130 let tmpl = resolve("coordination").unwrap();
4131 assert!(
4132 tmpl.content.contains("agent_id"),
4133 "coordination skill should mention the agent_id identifier form"
4134 );
4135 assert!(
4136 tmpl.content.contains("slugify_branch"),
4137 "coordination skill should name slugify_branch as the canonical conversion"
4138 );
4139 let lowered = tmpl.content.to_lowercase();
4140 assert!(
4141 lowered.contains("references & terminology")
4142 || lowered.contains("references and terminology")
4143 || lowered.contains("terminology"),
4144 "coordination skill should contain a references/terminology heading"
4145 );
4146 }
4147
4148 #[test]
4150 fn coordination_skill_documents_stash_hygiene() {
4151 let tmpl = resolve("coordination").unwrap();
4152 assert!(
4153 tmpl.content.contains("git stash list"),
4154 "stash-hygiene section should reference `git stash list`"
4155 );
4156 assert!(
4157 tmpl.content.contains("git stash show -p"),
4158 "stash-hygiene section should reference `git stash show -p`"
4159 );
4160 let lowered = tmpl.content.to_lowercase();
4161 assert!(
4162 lowered.contains("stash hygiene") || lowered.contains("stash safety"),
4163 "coordination skill should contain a stash-hygiene heading"
4164 );
4165 assert!(
4166 lowered.contains("pop only") || lowered.contains("only pop"),
4167 "coordination skill should instruct agents to pop only their own stashes"
4168 );
4169 }
4170
4171 #[test]
4174 fn supervisor_skill_documents_main_side_intent() {
4175 let tmpl = resolve("supervisor").unwrap();
4176 let lowered = tmpl.content.to_lowercase();
4177 assert!(
4178 lowered.contains("supervisor publishes agent.intent")
4179 || lowered.contains("publish intent")
4180 || lowered.contains("main-side work"),
4181 "supervisor skill should contain a heading naming supervisor-side intent publishing"
4182 );
4183 let start = tmpl
4184 .content
4185 .find("Supervisor publishes agent.intent")
4186 .expect("supervisor-publishes-intent heading present");
4187 let window = &tmpl.content[start..];
4188 assert!(
4189 window.contains("agent.intent"),
4190 "section should mention agent.intent"
4191 );
4192 assert!(
4193 window.contains("\"supervisor\""),
4194 "section should show agent_id = \"supervisor\" in the example"
4195 );
4196 assert!(
4197 window.contains("\"files\"")
4198 && window.contains("\"summary\"")
4199 && window.contains("\"valid_for_seconds\""),
4200 "section should include a curl example with files, summary, valid_for_seconds"
4201 );
4202 }
4203
4204 #[test]
4207 fn supervisor_skill_documents_tmux_send_keys_alongside_feedback() {
4208 let tmpl = resolve("supervisor").unwrap();
4209 let start = tmpl
4210 .content
4211 .find("Send the answer to the agent pane too")
4212 .expect("drift-34 subsection should be present");
4213 let next_heading = tmpl.content[start + 1..]
4214 .find("\n### ")
4215 .map_or(tmpl.content.len(), |off| start + 1 + off);
4216 let section = &tmpl.content[start..next_heading];
4217 assert!(
4218 section.contains("tmux send-keys"),
4219 "section should contain `tmux send-keys`"
4220 );
4221 assert!(
4222 section.contains("agent.feedback"),
4223 "section should reference agent.feedback in the same section"
4224 );
4225 let lowered_section = section.to_lowercase();
4226 assert!(
4227 lowered_section.contains("do not poll") || lowered_section.contains("don't poll"),
4228 "section should state the rationale (agents do not poll their inbox)"
4229 );
4230 }
4231
4232 #[test]
4235 fn coordination_skill_documents_working_heartbeat() {
4236 let tmpl = resolve("coordination").unwrap();
4237 let lowered = tmpl.content.to_lowercase();
4238 assert!(
4239 lowered.contains("working heartbeat") || lowered.contains("heartbeat"),
4240 "coordination skill should contain a working-heartbeat heading"
4241 );
4242 assert!(
4243 tmpl.content.contains("every 5 tool uses"),
4244 "coordination skill should state the cadence as 'every 5 tool uses'"
4245 );
4246 assert!(
4247 tmpl.content.contains("agent.status"),
4248 "heartbeat reuses the agent.status shape — substring should be present"
4249 );
4250 let start = tmpl
4251 .content
4252 .find("Working heartbeat")
4253 .expect("Working heartbeat heading present");
4254 let next_heading = tmpl.content[start + 1..]
4255 .find("\n### ")
4256 .map_or(tmpl.content.len(), |off| start + 1 + off);
4257 let section = &tmpl.content[start..next_heading].to_lowercase();
4258 assert!(
4259 section.contains("filesystem watcher") || section.contains("watcher"),
4260 "heartbeat section should explain why the filesystem watcher is insufficient"
4261 );
4262 }
4263
4264 #[test]
4267 fn supervisor_skill_documents_accept_edits_audit() {
4268 let tmpl = resolve("supervisor").unwrap();
4269 let lowered = tmpl.content.to_lowercase();
4270 assert!(
4271 lowered.contains("accept-edits commits") || lowered.contains("accept edits"),
4272 "supervisor skill should contain an accept-edits audit heading"
4273 );
4274 assert!(
4275 tmpl.content.contains("modified_files"),
4276 "audit section should reference the modified_files payload field"
4277 );
4278 let start = tmpl
4279 .content
4280 .find("Verify accept-edits commits before merge")
4281 .expect("accept-edits audit heading present");
4282 let next_heading = tmpl.content[start + 1..]
4283 .find("\n### ")
4284 .map_or(tmpl.content.len(), |off| start + 1 + off);
4285 let section_lower = tmpl.content[start..next_heading].to_lowercase();
4286 assert!(
4287 section_lower.contains("out-of-scope"),
4288 "audit section should call out 'out-of-scope' edits"
4289 );
4290 assert!(
4291 section_lower.contains("shall not be silently")
4292 || section_lower.contains("not be silently auto-approved")
4293 || section_lower.contains("silently auto-approved"),
4294 "audit section should forbid silent auto-approval"
4295 );
4296 }
4297
4298 #[test]
4301 fn coordination_skill_describes_slugify_rule() {
4302 let tmpl = resolve("coordination").unwrap();
4303 let start = tmpl
4304 .content
4305 .find("slugify_branch")
4306 .expect("slugify_branch should be named in the references section");
4307 let next_heading = tmpl.content[start + 1..]
4308 .find("\n### ")
4309 .map_or(tmpl.content.len(), |off| start + 1 + off);
4310 let section_lower = tmpl.content[start..next_heading].to_lowercase();
4311 assert!(
4312 section_lower.contains("lowercase"),
4313 "slugify rule should mention lowercase step"
4314 );
4315 assert!(
4316 tmpl.content[start..next_heading].contains("[a-z0-9_]"),
4317 "slugify rule should describe the allowed char class"
4318 );
4319 assert!(
4320 (section_lower.contains("fallback") || section_lower.contains("fall back"))
4321 && section_lower.contains("agent"),
4322 "slugify rule should describe the empty-fallback to `agent`"
4323 );
4324 }
4325
4326 fn rendered_supervisor() -> String {
4335 let tmpl = resolve("supervisor").expect("supervisor skill resolves");
4336 render(
4337 &tmpl,
4338 "supervisor",
4339 "http://127.0.0.1:9119",
4340 "git-paw",
4341 &GateCommands::default(),
4342 &[],
4343 )
4344 }
4345
4346 fn rendered_coordination() -> String {
4347 let tmpl = resolve("coordination").expect("coordination skill resolves");
4348 render(
4349 &tmpl,
4350 "feat/x",
4351 "http://127.0.0.1:9119",
4352 "git-paw",
4353 &GateCommands::default(),
4354 &[],
4355 )
4356 }
4357
4358 #[test]
4361 fn supervisor_skill_paste_buffer_framing_is_lenient() {
4362 let content = rendered_supervisor();
4363 let lowered = content.to_lowercase();
4364 assert!(
4365 lowered.contains("even if"),
4366 "supervisor skill should frame recovery as attempted even when indicator absent; got:\n{content}"
4367 );
4368 assert!(
4369 lowered.contains("judgment"),
4370 "supervisor skill should describe applying judgment; got:\n{content}"
4371 );
4372 assert!(
4373 lowered.contains("long buffered text"),
4374 "supervisor skill should mention the long-buffered-text heuristic; got:\n{content}"
4375 );
4376 }
4377
4378 #[test]
4381 fn coordination_skill_rejects_pairwise_overcoordination() {
4382 let content = rendered_coordination();
4383 assert!(
4384 content.contains("pairwise"),
4385 "coordination skill should name `pairwise` under a MUST-NOT clause; got:\n{content}"
4386 );
4387 let lowered = content.to_lowercase();
4388 assert!(
4389 lowered.contains("explicit go-ahead"),
4390 "coordination skill should reject waiting for an explicit go-ahead; got:\n{content}"
4391 );
4392 assert!(
4393 lowered.contains("broker silence") || lowered.contains("block on broker silence"),
4394 "coordination skill should reject blocking on broker silence; got:\n{content}"
4395 );
4396 }
4397
4398 #[test]
4405 fn coordination_skill_verified_and_feedback_substrings_independent() {
4406 let content = rendered_coordination();
4407 let verified_anchor = "- **`agent.verified`**";
4408 let feedback_anchor = "- **`agent.feedback`**";
4409 assert!(
4410 content.contains(verified_anchor),
4411 "coordination skill should anchor `agent.verified` in its own bullet; got:\n{content}"
4412 );
4413 assert!(
4414 content.contains(feedback_anchor),
4415 "coordination skill should anchor `agent.feedback` in its own bullet; got:\n{content}"
4416 );
4417 let v = content.find(verified_anchor).unwrap();
4419 let f = content.find(feedback_anchor).unwrap();
4420 let between = if v < f {
4421 &content[v..f]
4422 } else {
4423 &content[f..v]
4424 };
4425 assert!(
4426 between.contains('\n'),
4427 "the verified and feedback bullets must be on separate lines; got slice:\n{between}"
4428 );
4429 }
4430
4431 #[test]
4437 fn supervisor_skill_governance_after_spec_audit_before_verified() {
4438 let content = rendered_supervisor();
4439 let spec_audit = content
4440 .find("Spec Audit Procedure")
4441 .expect("Spec Audit Procedure heading present in supervisor skill");
4442 let governance = content
4443 .find("Governance verification")
4444 .expect("Governance verification heading present in supervisor skill");
4445 let verified_after = content[governance..]
4448 .find("agent.verified")
4449 .map(|o| governance + o)
4450 .expect("agent.verified mention after Governance verification");
4451
4452 assert!(
4453 spec_audit < governance,
4454 "Spec Audit Procedure should appear before Governance verification \
4455 (spec_audit={spec_audit}, governance={governance})"
4456 );
4457 assert!(
4458 governance < verified_after,
4459 "Governance verification should appear before the next agent.verified \
4460 publish step (governance={governance}, verified_after={verified_after})"
4461 );
4462 }
4463
4464 #[test]
4467 fn coordination_skill_consolidated_agent_done_timing() {
4468 let content = rendered_coordination();
4469 let start = content
4470 .find("consolidated worktree")
4471 .or_else(|| content.find("Consolidated worktree"))
4472 .expect("coordination skill should have a consolidated-worktree section");
4473 let section = &content[start..];
4474 let lowered = section.to_lowercase();
4475 assert!(
4476 lowered.contains("agent.done") || lowered.contains("agent.artifact"),
4477 "consolidated-worktree section should describe agent.done timing; got:\n{section}"
4478 );
4479 assert!(
4480 section.contains("- [x]"),
4481 "consolidated-worktree section should require every task to show - [x]; got:\n{section}"
4482 );
4483 assert!(
4484 lowered.contains("every task") || lowered.contains("every"),
4485 "consolidated-worktree section should make the rule cover every task; got:\n{section}"
4486 );
4487 }
4488
4489 #[test]
4492 fn supervisor_skill_cross_references_agent_intent_flow() {
4493 let tmpl = resolve("supervisor").unwrap();
4494 let start = tmpl
4495 .content
4496 .find("Supervisor publishes agent.intent")
4497 .expect("supervisor-publishes-intent heading present");
4498 let next_heading = tmpl.content[start + 1..]
4499 .find("\n### ")
4500 .map_or(tmpl.content.len(), |off| start + 1 + off);
4501 let section = &tmpl.content[start..next_heading];
4502 assert!(
4503 section.contains("Before you start editing"),
4504 "supervisor-publishes-intent section should cross-reference the agent-side \
4505 `Before you start editing` heading"
4506 );
4507 assert!(
4508 section.contains("coordination.md"),
4509 "cross-reference should name the coordination skill file"
4510 );
4511 }
4512
4513 fn render_supervisor() -> String {
4519 let tmpl = resolve("supervisor").expect("resolve supervisor template");
4520 render(
4521 &tmpl,
4522 "supervisor",
4523 "http://127.0.0.1:9119",
4524 "git-paw",
4525 &GateCommands {
4526 test_command: Some("just check"),
4527 ..Default::default()
4528 },
4529 &[],
4530 )
4531 }
4532
4533 #[test]
4538 fn supervisor_skill_self_register_curl_omits_cli_field() {
4539 let rendered = render_supervisor();
4540 let start = rendered
4541 .find("Bootstrap")
4542 .expect("Bootstrap section heading present");
4543 let next = rendered[start..]
4544 .find("### Poll session status and messages")
4545 .map_or(rendered.len(), |p| start + p);
4546 let section = &rendered[start..next];
4547 assert!(
4548 section.contains("agent.status"),
4549 "bootstrap section must publish agent.status; got:\n{section}"
4550 );
4551 assert!(
4552 section.contains("\"agent_id\":\"supervisor\""),
4553 "bootstrap curl must use agent_id=\"supervisor\"; got:\n{section}"
4554 );
4555 assert!(
4556 !section.contains("\"cli\""),
4557 "bootstrap payload must NOT self-report a cli field (git-paw pre-fills it); got:\n{section}"
4558 );
4559 }
4560
4561 #[test]
4564 fn supervisor_skill_self_register_is_first_action() {
4565 let rendered = render_supervisor();
4566 let pos_bootstrap = rendered
4567 .find("Bootstrap")
4568 .expect("Bootstrap heading present");
4569 let section_end = rendered[pos_bootstrap..]
4570 .find("### Poll session status and messages")
4571 .map_or(rendered.len(), |p| pos_bootstrap + p);
4572 let section = &rendered[pos_bootstrap..section_end];
4573 let lower = section.to_lowercase();
4574 assert!(
4575 lower.contains("first action") || lower.contains("very first"),
4576 "bootstrap section must state this is the agent's first action; got:\n{section}"
4577 );
4578 }
4579
4580 #[test]
4582 fn supervisor_skill_watch_mentions_per_iteration_sweep() {
4583 let rendered = render_supervisor();
4584 let start = rendered
4585 .find("**Watch**")
4586 .expect("Watch step heading present");
4587 let end = rendered[start..]
4588 .find("Stall detection")
4589 .map_or(rendered.len(), |p| start + p);
4590 let section = &rendered[start..end];
4591 let lower = section.to_lowercase();
4592 assert!(
4593 lower.contains("every iteration")
4594 || lower.contains("every monitoring")
4595 || lower.contains("each monitoring")
4596 || lower.contains("each iteration"),
4597 "Watch section must mention per-iteration sweeping; got:\n{section}"
4598 );
4599 }
4600
4601 #[test]
4605 fn supervisor_skill_rules_bullet_mentions_routine_absorption() {
4606 let rendered = render_supervisor();
4607 let start = rendered.find("### Rules").expect("Rules section present");
4608 let end = rendered[start..]
4609 .find("### Auto-approve permission prompts")
4610 .map_or(rendered.len(), |p| start + p);
4611 let section = &rendered[start..end];
4612 let lower = section.to_lowercase();
4613 assert!(
4614 lower.contains("absorb routine approval") || lower.contains("rubber-stamp"),
4615 "Rules must include the routine-approval absorption framing; got:\n{section}"
4616 );
4617 let mut family_hits = 0;
4622 for family in ["git (", "find", "grep", "sed"] {
4623 if section.contains(family) {
4624 family_hits += 1;
4625 }
4626 }
4627 assert!(
4628 family_hits >= 3,
4629 "Rules bullet must enumerate at least 3 routine families; only {family_hits} found in:\n{section}",
4630 );
4631 }
4632
4633 #[test]
4636 fn supervisor_skill_rules_bullet_enumerates_escalation_cases() {
4637 let rendered = render_supervisor();
4638 let start = rendered.find("### Rules").expect("Rules section present");
4639 let end = rendered[start..]
4640 .find("### Auto-approve permission prompts")
4641 .map_or(rendered.len(), |p| start + p);
4642 let section = &rendered[start..end];
4643 let lower = section.to_lowercase();
4644 let mut hits = 0;
4645 for case in [
4646 "cross-agent conflict",
4647 "destructive",
4648 "scope",
4649 "spec decisions",
4650 "novel",
4651 ] {
4652 if lower.contains(case) {
4653 hits += 1;
4654 }
4655 }
4656 assert!(
4657 hits >= 2,
4658 "Rules bullet must enumerate at least 2 escalation cases; only {hits} found in:\n{section}",
4659 );
4660 }
4661
4662 #[test]
4665 fn supervisor_skill_contains_every_iteration_phrase() {
4666 let rendered = render_supervisor();
4667 let lower = rendered.to_lowercase();
4668 assert!(
4669 lower.contains("every iteration") || lower.contains("every monitoring"),
4670 "skill must contain 'every iteration' or 'every monitoring' phrasing somewhere",
4671 );
4672 }
4673
4674 #[test]
4676 fn supervisor_skill_enumerates_five_gates_in_order() {
4677 let rendered = render_supervisor();
4678 let pos = |needle: &str| {
4679 rendered
4680 .find(needle)
4681 .unwrap_or_else(|| panic!("gate '{needle}' not found in supervisor skill"))
4682 };
4683 let pos_testing = pos("**Testing**");
4684 let pos_regression = pos("**Regression analysis**");
4685 let pos_spec = pos("**Spec audit**");
4686 let pos_doc = pos("**Doc audit**");
4687 let pos_security = pos("**Security audit**");
4688 assert!(
4689 pos_testing < pos_regression
4690 && pos_regression < pos_spec
4691 && pos_spec < pos_doc
4692 && pos_doc < pos_security,
4693 "five gates must appear in order Testing < Regression < Spec < Doc < Security; \
4694 got positions Testing={pos_testing} Regression={pos_regression} \
4695 Spec={pos_spec} Doc={pos_doc} Security={pos_security}",
4696 );
4697 }
4698
4699 #[test]
4702 fn supervisor_skill_verified_message_enumerates_five_gates() {
4703 let rendered = render_supervisor();
4704 let verify_start = rendered
4708 .find("**Verify or feedback**")
4709 .expect("Verify or feedback step present");
4710 let window = &rendered[verify_start..];
4711 let lower = window.to_lowercase();
4712 for needle in [
4713 "testing",
4714 "regression",
4715 "spec audit",
4716 "doc audit",
4717 "security audit",
4718 ] {
4719 assert!(
4720 lower.contains(needle),
4721 "§7 Verify-or-feedback must mention '{needle}'; got window:\n{window}",
4722 );
4723 }
4724 }
4725
4726 #[test]
4733 fn supervisor_skill_feedback_example_uses_gate_name_prefixes() {
4734 let rendered = render_supervisor();
4735 let verify_start = rendered
4736 .find("**Verify or feedback**")
4737 .expect("Verify or feedback step present");
4738 let end = rendered[verify_start..]
4741 .find("\n### ")
4742 .map_or(rendered.len(), |p| verify_start + p);
4743 let window = &rendered[verify_start..end];
4744 let mut hits = 0;
4745 for (bracketed, helper_arg) in [
4746 ("[testing]", " testing "),
4747 ("[regression]", " regression "),
4748 ("[spec audit]", " \"spec audit\" "),
4749 ("[doc audit]", " \"doc audit\" "),
4750 ("[security audit]", " \"security audit\" "),
4751 ] {
4752 if window.contains(bracketed)
4753 || window.contains(&format!("feedback-gate __FILL_IN_AGENT_ID__{helper_arg}"))
4754 {
4755 hits += 1;
4756 }
4757 }
4758 assert!(
4759 hits >= 3,
4760 "§7 agent.feedback example must show at least 3 gates (bracketed or helper-arg); \
4761 only {hits} found in:\n{window}",
4762 );
4763 }
4764
4765 #[test]
4771 fn supervisor_skill_doc_audit_enumerates_surfaces() {
4772 let rendered = render_supervisor();
4773 let start = rendered
4774 .find("**Doc audit**")
4775 .expect("Doc audit gate present");
4776 let end = rendered[start..]
4777 .find("**Security audit**")
4778 .map(|p| start + p)
4779 .expect("Security audit follows Doc audit");
4780 let section = &rendered[start..end];
4781 let mut hits = 0;
4782 for surface in [
4783 "user-guide",
4784 "README.md",
4785 "AGENTS.md",
4786 "--help",
4787 "doc_tool_command",
4788 ] {
4789 if section.contains(surface) {
4790 hits += 1;
4791 }
4792 }
4793 assert!(
4794 hits >= 4,
4795 "Doc audit must enumerate at least 4 of 5 doc-surface categories; only {hits} found in:\n{section}",
4796 );
4797 }
4798
4799 #[test]
4802 fn supervisor_skill_security_audit_enumerates_owasp_categories() {
4803 let rendered = render_supervisor();
4804 let start = rendered
4805 .find("**Security audit**")
4806 .expect("Security audit gate present");
4807 let end = rendered[start..]
4808 .find("**Verify or feedback**")
4809 .map_or(rendered.len(), |p| start + p);
4810 let section = &rendered[start..end];
4811 let lower = section.to_lowercase();
4812 let mut hits = 0;
4813 for cat in [
4814 "command injection",
4815 "xss",
4816 "sql injection",
4817 "path traversal",
4818 "unvalidated external input",
4819 "secret leakage",
4820 ] {
4821 if lower.contains(cat) {
4822 hits += 1;
4823 }
4824 }
4825 assert!(
4826 hits >= 4,
4827 "Security audit must enumerate at least 4 of 6 OWASP categories; only {hits} found in:\n{section}",
4828 );
4829 assert!(
4830 section.contains("unwrap()") || section.contains("expect()"),
4831 "Security audit must mention the unwrap()/expect() rule; got:\n{section}",
4832 );
4833 }
4834
4835 #[test]
4838 fn supervisor_skill_governance_verification_substep_preserved() {
4839 let rendered = render_supervisor();
4840 let start = rendered
4841 .find("Governance verification")
4842 .expect("Governance verification sub-step still present");
4843 let end = (start + 2000).min(rendered.len());
4844 let section = &rendered[start..end];
4845 for needle in [
4846 "DoD",
4847 "ADR",
4848 "security.md",
4849 "test-strategy.md",
4850 "constitution.md",
4851 ] {
4852 assert!(
4853 section.contains(needle),
4854 "governance sub-step must still reference '{needle}'; got:\n{section}",
4855 );
4856 }
4857 }
4858
4859 #[test]
4867 fn coordination_skill_documents_commit_cadence() {
4868 let tmpl = resolve("coordination").unwrap();
4869 let lowered = tmpl.content.to_lowercase();
4870 assert!(
4871 lowered.contains("commit cadence") || lowered.contains("per-group commit cadence"),
4872 "coordination skill should have a heading naming the commit-cadence concept; \
4873 got:\n{}",
4874 tmpl.content
4875 );
4876 assert!(
4877 lowered.contains("group") || lowered.contains("section"),
4878 "commit-cadence section should mention the GROUP/section grain"
4879 );
4880 let has_conventional_prefix = ["feat(", "fix(", "docs(", "test(", "chore("]
4881 .iter()
4882 .any(|p| tmpl.content.contains(p));
4883 assert!(
4884 has_conventional_prefix,
4885 "commit-cadence section should show at least one conventional-commit prefix example"
4886 );
4887 }
4888
4889 #[test]
4892 fn coordination_skill_forbids_opsx_verify_and_archive() {
4893 let tmpl = resolve("coordination").unwrap();
4894 assert!(
4895 tmpl.content.contains("/opsx:verify"),
4896 "coordination skill should name `/opsx:verify` literally"
4897 );
4898 assert!(
4899 tmpl.content.contains("/opsx:archive"),
4900 "coordination skill should name `/opsx:archive` literally"
4901 );
4902 let lowered = tmpl.content.to_lowercase();
4903 assert!(
4904 lowered.contains("off-limits")
4905 || lowered.contains("do not invoke")
4906 || lowered.contains("shall not")
4907 || lowered.contains("supervisor's job"),
4908 "coordination skill should state both are not the coding agent's responsibility"
4909 );
4910 }
4911
4912 #[test]
4915 fn coordination_skill_names_terminal_action() {
4916 let tmpl = resolve("coordination").unwrap();
4917 assert!(
4918 tmpl.content.contains("agent.artifact"),
4919 "coordination skill should name `agent.artifact` as the terminal publish"
4920 );
4921 assert!(
4922 tmpl.content.contains("\"done\"") || tmpl.content.contains("\"committed\""),
4923 "coordination skill should reference status: \"done\" or \"committed\""
4924 );
4925 }
4926
4927 #[test]
4930 fn supervisor_skill_documents_pane_current_path_resolution() {
4931 let tmpl = resolve("supervisor").unwrap();
4932 assert!(
4933 tmpl.content.contains("tmux display-message"),
4934 "supervisor skill should show the tmux display-message command"
4935 );
4936 assert!(
4937 tmpl.content.contains("pane_current_path"),
4938 "supervisor skill should name pane_current_path literally"
4939 );
4940 let lowered = tmpl.content.to_lowercase();
4941 assert!(
4942 lowered.contains("not alphabetical")
4943 || lowered.contains("not sorted alphabetically")
4944 || lowered.contains("are not alphabetical"),
4945 "supervisor skill should warn against alphabetical pane-index assumptions"
4946 );
4947 assert!(
4948 lowered.contains("cli-argument order")
4949 || lowered.contains("cli argument order")
4950 || lowered.contains("argument order"),
4951 "supervisor skill should warn against CLI-argument-order pane-index assumptions"
4952 );
4953 }
4954
4955 #[test]
4960 fn supervisor_skill_documents_proactive_launch_sweep() {
4961 let tmpl = resolve("supervisor").unwrap();
4962 let lowered = tmpl.content.to_lowercase();
4963 let start = lowered
4964 .find("launch-time pane sweep")
4965 .or_else(|| lowered.find("launch sweep"))
4966 .expect("launch-time pane sweep heading should be present");
4967 let window_end = (start + 2500).min(lowered.len());
4968 let window = &lowered[start..window_end];
4969 assert!(
4970 window.contains("immediately after attaching")
4971 || window.contains("before the poll thread")
4972 || window.contains("first-few-seconds")
4973 || window.contains("first few seconds"),
4974 "launch sweep should link the sweep to the first-few-seconds-after-attach window",
4975 );
4976 }
4977
4978 #[test]
4979 fn supervisor_skill_launch_sweep_escalates_unknown_via_agent_question() {
4980 let tmpl = resolve("supervisor").unwrap();
4981 let lowered = tmpl.content.to_lowercase();
4982 let start = lowered
4983 .find("launch-time pane sweep")
4984 .or_else(|| lowered.find("launch sweep"))
4985 .expect("launch-time pane sweep heading should be present");
4986 let window_end = (start + 2500).min(lowered.len());
4987 let window = &lowered[start..window_end];
4988 assert!(
4989 window.contains("unknown") || window.contains("wider scope"),
4990 "launch sweep should classify a third category for unknown/wider-scope prompts",
4991 );
4992 assert!(
4993 window.contains("agent.question"),
4994 "launch sweep should instruct agent.question escalation for unknown prompts",
4995 );
4996 assert!(
4997 window.contains("escalate"),
4998 "launch sweep should use the word 'escalate' alongside the agent.question instruction",
4999 );
5000 }
5001
5002 #[test]
5003 fn supervisor_skill_launch_sweep_complements_auto_approve_thread() {
5004 let tmpl = resolve("supervisor").unwrap();
5005 let lowered = tmpl.content.to_lowercase();
5006 let start = lowered
5007 .find("launch-time pane sweep")
5008 .or_else(|| lowered.find("launch sweep"))
5009 .expect("launch-time pane sweep heading should be present");
5010 let window_end = (start + 2500).min(lowered.len());
5011 let window = &lowered[start..window_end];
5012 assert!(
5013 window.contains("complements"),
5014 "launch sweep should describe itself as complementing the auto-approve thread",
5015 );
5016 assert!(
5017 window.contains("does not replace")
5018 || window.contains("not replace")
5019 || window.contains("does **not** replace"),
5020 "launch sweep should explicitly say it does NOT replace the auto-approve thread",
5021 );
5022 assert!(
5023 window.contains("[supervisor.auto_approve]") || window.contains("auto_approve"),
5024 "launch sweep should cross-reference the [supervisor.auto_approve] poll thread",
5025 );
5026 }
5027
5028 #[test]
5036 fn supervisor_skill_paste_buffer_cross_ref_in_send_keys_section() {
5037 let tmpl = resolve("supervisor").unwrap();
5038 let lowered = tmpl.content.to_lowercase();
5039 let start = lowered
5043 .find("send the answer to the agent pane")
5044 .or_else(|| lowered.find("agents do not poll their inbox"))
5045 .expect("send-keys-alongside-agent.feedback section should be present");
5046 let window_end = (start + 2200).min(lowered.len());
5047 let window = &lowered[start..window_end];
5048
5049 assert!(
5050 window.contains("paste-buffer")
5051 || window.contains("paste buffer")
5052 || window.contains("follow-up enter")
5053 || window.contains("follow-up `enter`"),
5054 "send-keys-alongside-feedback section must cross-reference paste-buffer recovery / follow-up Enter for long answers",
5055 );
5056 }
5057
5058 #[test]
5066 fn supervisor_skill_warns_against_git_paw_status_ordering() {
5067 let tmpl = resolve("supervisor").unwrap();
5068 assert!(
5071 tmpl.content.contains("git paw status"),
5072 "supervisor skill should reference `git paw status` by name when warning against using its ordering as a mapping source",
5073 );
5074
5075 let lowered = tmpl.content.to_lowercase();
5076 let start = lowered
5077 .find("pane_current_path")
5078 .expect("pane_current_path resolution section should be present");
5079 let window_end = (start + 2500).min(lowered.len());
5080 let window = &lowered[start..window_end];
5081
5082 assert!(
5083 window.contains("git paw status"),
5084 "the warning against `git paw status` ordering must appear within the pane_current_path resolution section",
5085 );
5086 assert!(
5087 window.contains("shall not be inferred")
5088 || window.contains("must not")
5089 || window.contains("not be inferred")
5090 || window.contains("not used as a mapping")
5091 || window.contains("no relationship"),
5092 "section must forbid using `git paw status` order as a mapping source",
5093 );
5094 }
5095
5096 #[test]
5103 fn coordination_skill_contains_context_budget_after_while_editing() {
5104 let tmpl = resolve("coordination").unwrap();
5105 let editing = tmpl
5106 .content
5107 .find("While you're editing")
5108 .expect("coordination skill should contain 'While you're editing' heading");
5109 let budget = tmpl
5110 .content
5111 .find("### Context budget")
5112 .expect("coordination skill should contain a 'Context budget' heading");
5113 assert!(
5114 budget > editing,
5115 "the 'Context budget' section must appear after the 'While you're editing' section"
5116 );
5117 }
5118
5119 #[test]
5124 fn coordination_skill_context_budget_covers_three_topics() {
5125 let tmpl = resolve("coordination").unwrap();
5126 let lowered = tmpl.content.to_lowercase();
5127 assert!(
5128 lowered.contains("residual-budget heuristic"),
5129 "context-budget section should cover the residual-budget heuristic"
5130 );
5131 assert!(
5132 lowered.contains("when to compact, clear, or summarise"),
5133 "context-budget section should cover the named compact/clear/summarise moments"
5134 );
5135 assert!(
5136 lowered.contains("commit before you compact"),
5137 "context-budget section should cover the commit-before-compact discipline"
5138 );
5139 }
5140
5141 #[test]
5145 fn coordination_skill_residual_budget_heuristic_in_prose() {
5146 let tmpl = resolve("coordination").unwrap();
5147 let start = tmpl
5148 .content
5149 .find("### Context budget")
5150 .expect("context-budget section present");
5151 let end = tmpl.content[start..]
5152 .find("### Check for messages")
5153 .map_or(tmpl.content.len(), |o| start + o);
5154 let section = &tmpl.content[start..end];
5155 let lowered = section.to_lowercase();
5156 assert!(
5157 lowered.contains("60%") && lowered.contains("free"),
5158 "residual-budget heuristic should reference keeping ~60% of the window free"
5159 );
5160 assert!(
5161 lowered.contains("heuristic"),
5162 "residual-budget guidance should be framed as a heuristic"
5163 );
5164 assert!(
5165 lowered.contains("no config field")
5166 || lowered.contains("there is no\nconfig field")
5167 || lowered.contains("there is no config field"),
5168 "the section should state there is no config field for the ratio"
5169 );
5170 }
5171
5172 #[test]
5176 fn coordination_skill_three_moments_in_priority_order() {
5177 let tmpl = resolve("coordination").unwrap();
5178 let content = &tmpl.content;
5179 let scenario = content
5180 .find("After each spec scenario completes")
5181 .expect("first moment present");
5182 let working_set = content
5183 .find("working set grows past")
5184 .expect("second moment present");
5185 let switching = content
5186 .find("switching between sub-tasks")
5187 .expect("third moment present");
5188 assert!(
5189 scenario < working_set && working_set < switching,
5190 "the three named moments must appear in the documented priority order"
5191 );
5192
5193 let first = &content[scenario..working_set];
5196 let second = &content[working_set..switching];
5197 let third = &content[switching..(switching + 300).min(content.len())];
5198 assert!(
5199 first.to_lowercase().contains("compact"),
5200 "moment 1 should be labelled with the compact action"
5201 );
5202 assert!(
5203 second.to_lowercase().contains("compact"),
5204 "moment 2 should be labelled with the compact action"
5205 );
5206 assert!(
5207 third.to_lowercase().contains("clear"),
5208 "moment 3 should be labelled with the clear action"
5209 );
5210 }
5211
5212 #[test]
5217 fn coordination_skill_states_commit_before_compact_discipline() {
5218 let tmpl = resolve("coordination").unwrap();
5219 assert!(
5220 tmpl.content
5221 .contains("**Never compact, clear, or summarise without first committing"),
5222 "commit-before-compact discipline should be a bold, explicit statement"
5223 );
5224 let lowered = tmpl.content.to_lowercase();
5225 assert!(
5226 lowered.contains("agent.artifact"),
5227 "the discipline should mention publishing an agent.artifact as the alternative to committing"
5228 );
5229 assert!(
5230 lowered.contains("can't recover") || lowered.contains("cannot recover"),
5231 "the discipline should pair the rule with a safety rationale about recoverability"
5232 );
5233 }
5234
5235 #[test]
5239 fn coordination_skill_per_cli_mechanism_table() {
5240 let tmpl = resolve("coordination").unwrap();
5241 let start = tmpl
5242 .content
5243 .find("#### Per-CLI mechanism")
5244 .expect("per-CLI mechanism subsection present");
5245 let section = &tmpl.content[start..];
5246 assert!(
5248 section.contains("| `claude` | `/compact` | `/clear` |"),
5249 "table should contain a claude row naming /compact and /clear"
5250 );
5251 assert!(
5252 section.contains("| `claude-oss` | `/compact` | `/clear` |"),
5253 "table should contain a claude-oss row naming /compact and /clear"
5254 );
5255 let other = section
5257 .find("| other |")
5258 .map(|o| §ion[o..(o + 200).min(section.len())])
5259 .expect("table should contain an 'other' fallback row");
5260 assert!(
5261 other.contains("/compact") && other.contains("/save") && other.contains("/reset"),
5262 "the 'other' row should point users at the CLI's /compact, /save, or /reset equivalent"
5263 );
5264 }
5265
5266 use crate::specs::SpecBackendKind;
5269
5270 fn render_skill(name: &str, backends: &[SpecBackendKind]) -> String {
5271 let tmpl = resolve(name).unwrap_or_else(|_| panic!("resolve {name}"));
5272 render(
5273 &tmpl,
5274 if name == "supervisor" {
5275 "supervisor"
5276 } else {
5277 "feat/x"
5278 },
5279 "http://127.0.0.1:9119",
5280 "git-paw",
5281 &GateCommands::default(),
5282 backends,
5283 )
5284 }
5285
5286 #[test]
5287 fn coordination_lists_forbidden_commands_under_openspec() {
5288 let out = render_skill("coordination", &[SpecBackendKind::OpenSpec]);
5289 assert!(
5290 out.contains("Commands you must not run"),
5291 "coordination must carry the forbidden-command section"
5292 );
5293 assert!(out.contains("/opsx:verify"), "lists /opsx:verify");
5294 assert!(out.contains("/opsx:archive"), "lists /opsx:archive");
5295 assert!(
5296 out.contains("supervisor-only"),
5297 "names the commands supervisor-only"
5298 );
5299 assert!(
5300 out.contains("role-gating guard"),
5301 "references the role-gating guard"
5302 );
5303 }
5304
5305 #[test]
5306 fn supervisor_has_must_must_not_section_under_openspec() {
5307 let out = render_skill("supervisor", &[SpecBackendKind::OpenSpec]);
5308 assert!(
5309 out.contains("Commands you must run (not coding agents)"),
5310 "supervisor must carry the supervisor-only section"
5311 );
5312 assert!(out.contains("/opsx:verify") && out.contains("/opsx:archive"));
5313 assert!(out.contains("MUST") && out.contains("MUST NOT"));
5315 let idx = out
5317 .find("Commands you must run (not coding agents)")
5318 .expect("section present");
5319 let section = &out[idx..];
5320 assert!(
5321 section.contains("agent.feedback"),
5322 "section instructs calling out violations via agent.feedback"
5323 );
5324 }
5325
5326 #[test]
5327 fn supervisor_has_revert_flow_under_openspec() {
5328 let out = render_skill("supervisor", &[SpecBackendKind::OpenSpec]);
5329 assert!(
5330 out.contains("Handling an opsx-role-gating revert request"),
5331 "merge-orchestration carries the revert-request flow"
5332 );
5333 assert!(out.contains("git revert"), "teaches git revert");
5334 assert!(
5335 out.contains("auto_revert"),
5336 "references the [supervisor] auto_revert opt-out"
5337 );
5338 }
5339
5340 #[test]
5341 fn opsx_sections_omitted_under_non_openspec_engines() {
5342 for backends in [
5343 vec![SpecBackendKind::Markdown],
5344 vec![SpecBackendKind::SpecKit],
5345 vec![],
5346 ] {
5347 let coord = render_skill("coordination", &backends);
5348 assert!(
5349 !coord.contains("Commands you must not run"),
5350 "coordination forbidden section must be omitted for {backends:?}"
5351 );
5352 let sup = render_skill("supervisor", &backends);
5353 assert!(
5354 !sup.contains("Commands you must run (not coding agents)"),
5355 "supervisor-only section must be omitted for {backends:?}"
5356 );
5357 assert!(
5358 !sup.contains("Handling an opsx-role-gating revert request"),
5359 "revert flow must be omitted for {backends:?}"
5360 );
5361 }
5362 }
5363
5364 #[test]
5365 fn opsx_region_markers_never_survive_rendering() {
5366 for name in ["coordination", "supervisor"] {
5367 for backends in [
5368 vec![SpecBackendKind::OpenSpec],
5369 vec![SpecBackendKind::Markdown],
5370 vec![],
5371 ] {
5372 let out = render_skill(name, &backends);
5373 assert!(
5374 !out.contains(OPSX_REGION_BEGIN) && !out.contains(OPSX_REGION_END),
5375 "{name} under {backends:?} must not leak region markers"
5376 );
5377 }
5378 }
5379 }
5380
5381 #[test]
5382 fn opsx_multi_backend_session_keeps_sections_when_openspec_present() {
5383 let out = render_skill(
5386 "supervisor",
5387 &[SpecBackendKind::Markdown, SpecBackendKind::OpenSpec],
5388 );
5389 assert!(out.contains("Commands you must run (not coding agents)"));
5390 }
5391
5392 #[test]
5393 fn render_opsx_regions_strips_body_when_not_kept() {
5394 let input = "before\n<!-- opsx-role-gating:begin -->\nSECRET\n<!-- opsx-role-gating:end -->\nafter\n";
5395 let kept = render_opsx_regions(input, true);
5396 assert!(kept.contains("SECRET"));
5397 assert!(!kept.contains("opsx-role-gating:begin"));
5398 let stripped = render_opsx_regions(input, false);
5399 assert!(!stripped.contains("SECRET"));
5400 assert!(stripped.contains("before") && stripped.contains("after"));
5401 }
5402
5403 #[test]
5404 fn raw_coordination_template_carries_the_forbidden_section() {
5405 let tmpl = resolve("coordination").unwrap();
5409 assert!(tmpl.content.contains("Commands you must not run"));
5410 assert!(tmpl.content.contains(OPSX_REGION_BEGIN));
5411 }
5412}