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