Skip to main content

git_paw/
skills.rs

1//! Agent skill template loading and rendering.
2//!
3//! Skills follow the agentskills.io specification: each skill is a directory containing
4//! a SKILL.md file with YAML frontmatter and optional resource subdirectories
5//! (scripts/, references/, assets/).
6//!
7//! ## Resolution order (agentskills.io compliant)
8//!
9//! When a skill is requested by name, the system searches in this order:
10//!
11//! 1. **Standard location** — `.agents/skills/<name>/SKILL.md` (walking up directory tree)
12//! 2. **User override** — `<config_dir>/git-paw/agent-skills/<name>/SKILL.md`
13//! 3. **Embedded default** — compiled into the binary via `include_str!`
14//!
15//! The first match wins. If none exist, resolution fails with [`SkillError::UnknownSkill`].
16//!
17//! ## Substitution rules
18//!
19//! During [`render`], the template content undergoes placeholder substitution:
20//!
21//! - `{{BRANCH_ID}}` is replaced with the slugified branch name (`feat/foo` → `feat-foo`)
22//! - `{{PROJECT_NAME}}` is replaced with the project name (e.g. `"git-paw"`), used in the
23//!   `paw-{{PROJECT_NAME}}` tmux session name
24//! - `{{GIT_PAW_BROKER_URL}}` is substituted at render time with the actual broker URL
25//! - `{{SKILL_NAME}}` is replaced with the skill name from metadata
26//! - `{{SKILL_DESCRIPTION}}` is replaced with the skill description from metadata
27
28use schemars::JsonSchema;
29use serde::{Deserialize, Serialize};
30use serde_json;
31use std::path::{Path, PathBuf};
32
33/// The embedded coordination skill, compiled into the binary.
34///
35/// New embedded skills are added by adding a new `include_str!` constant
36/// and a corresponding match arm in [`embedded_default`].
37const COORDINATION_DEFAULT: &str = include_str!("../assets/agent-skills/coordination.md");
38
39/// The embedded supervisor skill, compiled into the binary.
40const SUPERVISOR_DEFAULT: &str = include_str!("../assets/agent-skills/supervisor.md");
41
42/// Indicates where a resolved skill's content originated.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44pub enum Source {
45    /// Content came from the binary's compiled-in default.
46    Embedded,
47    /// Content came from the agentskills.io standard location (.agents/skills/)
48    AgentsStandard,
49    /// Content came from the user's config directory override
50    User,
51}
52
53/// Represents the format of a skill (standardized only).
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
55pub enum SkillFormat {
56    /// Standardized format: directory with SKILL.md + optional subdirectories
57    Standardized,
58}
59
60/// Standardized skill metadata following agentskills.io specification.
61#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
62pub struct StandardizedSkillMetadata {
63    /// Skill name (max 64 chars, lowercase letters/numbers/hyphens only)
64    pub name: String,
65    /// Skill description (max 1024 chars)
66    pub description: String,
67    /// Optional license information
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub license: Option<String>,
70    /// Optional compatibility information
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub compatibility: Option<String>,
73    /// Optional metadata
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub metadata: Option<serde_json::Value>,
76}
77
78/// A loaded skill template ready for rendering.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct SkillTemplate {
81    /// The skill name (e.g. `"coordination"`).
82    pub name: String,
83    /// The unrendered template content with placeholders.
84    pub content: String,
85    /// Where the content was loaded from.
86    pub source: Source,
87    /// The format of the skill (legacy or standardized).
88    pub format: SkillFormat,
89    /// Optional metadata for standardized skills.
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub metadata: Option<StandardizedSkillMetadata>,
92    /// Optional resource paths for standardized skills.
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub resource_paths: Option<Vec<PathBuf>>,
95}
96
97/// Errors that can occur during skill loading.
98#[derive(Debug, thiserror::Error)]
99pub enum SkillError {
100    /// No embedded or user override found for the requested skill name.
101    #[error("unknown skill '{name}' — no embedded default or user override exists")]
102    UnknownSkill {
103        /// The skill name that was requested.
104        name: String,
105    },
106
107    /// Standardized skill validation failed.
108    #[error("skill '{name}' validation failed: {reason}")]
109    ValidationError {
110        /// The skill name that failed validation.
111        name: String,
112        /// The validation error reason.
113        reason: String,
114    },
115
116    /// Standardized skill directory cannot be read.
117    #[error("cannot read skill directory at '{}' — check directory permissions", path.display())]
118    DirectoryReadError {
119        /// The path that could not be read.
120        path: PathBuf,
121        /// The underlying I/O error.
122        source: std::io::Error,
123    },
124
125    /// User override skill file cannot be read.
126    #[error("cannot read user override skill file at '{}' — check file permissions", path.display())]
127    UserOverrideRead {
128        /// The path that could not be read.
129        path: PathBuf,
130        /// The underlying I/O error.
131        source: std::io::Error,
132    },
133}
134
135/// Looks up the embedded default for a skill by name.
136///
137/// Returns `Some(content)` if an embedded skill exists with that name,
138/// or `None` otherwise. New embedded skills are added by introducing a
139/// new `include_str!` constant and a new match arm here.
140fn 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
148/// Resolves a skill template by name.
149///
150/// Checks for a user override first, then falls back to the embedded default.
151/// Returns [`SkillError::UnknownSkill`] if neither source has the skill.
152pub fn resolve(skill_name: &str) -> Result<SkillTemplate, SkillError> {
153    resolve_with_config_dir(skill_name, None)
154}
155
156/// Attempts to load a standardized skill from .agents/skills/ directory.
157///
158/// Walks up the directory tree from current directory looking for .agents/skills/<name>/SKILL.md
159/// Also checks user override location if `config_dir_override` is provided
160fn try_load_standardized_skill(
161    skill_name: &str,
162    config_dir_override: Option<&Path>,
163) -> Result<Option<SkillTemplate>, SkillError> {
164    // First try user override if config directory is provided
165    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    // Then try standardized agents directory
172    try_load_from_agents_dir(skill_name)
173}
174
175/// Try loading from user override location in config directory
176fn 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
195/// Try loading from .agents/skills/ by walking up directory tree
196fn 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        // Limit to 5 levels up to prevent infinite loops
203        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
220/// Common loading logic for both locations
221fn 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    // Parse metadata from frontmatter if present
247    let (metadata, content_without_frontmatter) = parse_standardized_metadata(&content)?;
248
249    // Collect resource paths
250    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
272/// Parses standardized skill metadata from YAML frontmatter.
273///
274/// Extracts YAML frontmatter (between --- lines) and parses it into `StandardizedSkillMetadata`.
275fn parse_standardized_metadata(
276    content: &str,
277) -> Result<(Option<StandardizedSkillMetadata>, String), SkillError> {
278    // Check if content starts with YAML frontmatter
279    let lines: Vec<&str> = content.lines().collect();
280    if lines.len() < 2 || !lines[0].trim().starts_with("---") {
281        // No frontmatter, return None for metadata and original content
282        return Ok((None, content.to_string()));
283    }
284
285    // Find the end of frontmatter
286    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())); // No closing ---, treat as no frontmatter
296    };
297
298    // Extract frontmatter YAML
299    let frontmatter_lines = &lines[1..frontmatter_end];
300    let frontmatter_yaml = frontmatter_lines.join("\n");
301
302    // Parse YAML into metadata
303    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    // Validate required fields
314    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    // Extract content after frontmatter
329    let content_without_frontmatter = lines[frontmatter_end + 1..].join("\n");
330
331    Ok((Some(metadata), content_without_frontmatter))
332}
333
334/// Internal resolver that accepts an optional config directory override for testing.
335fn resolve_with_config_dir(
336    skill_name: &str,
337    config_dir: Option<&Path>,
338) -> Result<SkillTemplate, SkillError> {
339    // Try standardized format
340    if let Some(skill) = try_load_standardized_skill(skill_name, config_dir)? {
341        return Ok(skill);
342    }
343
344    // Try embedded default (now also uses standardized format)
345    if let Some(content) = embedded_default(skill_name) {
346        // Parse embedded content as standardized format
347        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
364/// Re-export of [`crate::broker::messages::slugify_branch`] to ensure skill
365/// template rendering uses the exact same slug algorithm as the broker.
366fn slugify_branch(branch: &str) -> String {
367    crate::broker::messages::slugify_branch(branch)
368}
369
370/// Builds the standardized boot instruction block for agent initialization.
371///
372/// The boot block contains instructions for four essential runtime events:
373/// 1. REGISTER - Initial status publication
374/// 2. DONE - Task completion reporting
375/// 3. BLOCKED - Dependency waiting notification
376/// 4. QUESTION - Uncertainty escalation with explicit wait instruction
377///
378/// # Arguments
379///
380/// * `branch_id` - The branch name (will be slugified)
381/// * `broker_url` - The fully-qualified broker URL for curl commands
382///
383/// # Returns
384///
385/// A string containing the complete boot instruction block with all placeholders
386/// substituted and curl commands pre-expanded.
387pub 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/// Renders a skill template for a specific worktree.
397///
398/// Substitutes the following placeholders at render time:
399///
400/// - `{{BRANCH_ID}}` — the slugified branch name (`feat/foo` → `feat-foo`)
401/// - `{{PROJECT_NAME}}` — the project name (e.g. `"git-paw"`), used in the
402///   `paw-{{PROJECT_NAME}}` tmux session name
403/// - `{{GIT_PAW_BROKER_URL}}` — the fully-qualified broker URL, pre-expanded
404///   here so the agent's curl commands contain a literal URL and no shell
405///   expansion is needed at execution time. Pre-expanding at render time is
406///   important: some CLI tools gate shell-variable expansion behind extra
407///   permission prompts, which breaks the "don't ask again for `curl:*`"
408///   allowlist flow.
409/// - `{{TEST_COMMAND}}` — the supervisor's configured `test_command` (e.g.
410///   `"just check"`). When `test_command` is `None`, the placeholder
411///   substitutes to the literal `"(not configured)"` so the rendered prose
412///   stays readable.
413///
414/// Any remaining `{{...}}` placeholder after substitution is logged as a
415/// warning to stderr but does not cause `render` to fail.
416///
417/// For standardized skills, additional metadata placeholders may be available:
418/// - `{{SKILL_NAME}}` — the skill name from metadata
419/// - `{{SKILL_DESCRIPTION}}` — the skill description from metadata
420pub fn render(
421    template: &SkillTemplate,
422    branch: &str,
423    broker_url: &str,
424    project: &str,
425    test_command: Option<&str>,
426) -> String {
427    let branch_id = slugify_branch(branch);
428    let test_command_value = test_command.unwrap_or("(not configured)");
429
430    // Start with basic substitutions
431    let mut output = template
432        .content
433        .replace("{{BRANCH_ID}}", &branch_id)
434        .replace("{{PROJECT_NAME}}", project)
435        .replace("{{GIT_PAW_BROKER_URL}}", broker_url)
436        .replace("{{TEST_COMMAND}}", test_command_value);
437
438    // Add metadata substitutions for standardized skills
439    if let Some(metadata) = &template.metadata {
440        output = output
441            .replace("{{SKILL_NAME}}", &metadata.name)
442            .replace("{{SKILL_DESCRIPTION}}", &metadata.description);
443    }
444
445    // Warn about any remaining {{...}} placeholders that were not consumed.
446    let mut start = 0;
447    while let Some(open) = output[start..].find("{{") {
448        let abs_open = start + open;
449        if let Some(close) = output[abs_open..].find("}}") {
450            let placeholder = &output[abs_open..abs_open + close + 2];
451            eprintln!(
452                "warning: unsubstituted placeholder {placeholder} in skill '{}'",
453                template.name
454            );
455            start = abs_open + close + 2;
456        } else {
457            break;
458        }
459    }
460
461    output
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467    use serial_test::serial;
468
469    // 9.2: Embedded coordination skill is reachable without any user files
470    #[test]
471    fn embedded_coordination_is_reachable() {
472        let tmpl = resolve("coordination").expect("should resolve coordination");
473        assert_eq!(tmpl.source, Source::Embedded);
474        assert!(!tmpl.content.is_empty());
475    }
476
477    // 9.3: Embedded coordination skill contains all four operations
478    #[test]
479    fn embedded_coordination_contains_all_operations() {
480        let tmpl = resolve("coordination").unwrap();
481        assert!(tmpl.content.contains("agent.status"));
482        assert!(tmpl.content.contains("agent.artifact"));
483        assert!(tmpl.content.contains("agent.blocked"));
484        assert!(
485            tmpl.content
486                .contains("{{GIT_PAW_BROKER_URL}}/messages/{{BRANCH_ID}}")
487        );
488    }
489
490    #[test]
491    fn embedded_coordination_documents_supervisor_messages() {
492        let tmpl = resolve("coordination").unwrap();
493        assert!(tmpl.content.contains("agent.verified"));
494        assert!(tmpl.content.contains("agent.feedback"));
495        assert!(tmpl.content.contains("re-publish"));
496    }
497
498    // 9.4: Standard location skill loading
499    #[test]
500    #[serial(directory_changes)]
501    fn standard_location_skill_loading() {
502        let dir = tempfile::tempdir().unwrap();
503        let project_dir = dir.path().join("my-project");
504        std::fs::create_dir_all(&project_dir).unwrap();
505
506        // Create skill in standard location
507        let skill_dir = project_dir
508            .join(".agents")
509            .join("skills")
510            .join("coordination");
511        std::fs::create_dir_all(&skill_dir).unwrap();
512
513        let skill_md_content = "---\nname: coordination\ndescription: Custom coordination skill\n---\n\ncustom skill content";
514        std::fs::write(skill_dir.join("SKILL.md"), skill_md_content).unwrap();
515
516        // Change to project directory
517        let original_dir = std::env::current_dir().unwrap();
518        std::env::set_current_dir(&project_dir).unwrap();
519
520        let tmpl = resolve("coordination").expect("should resolve");
521        assert_eq!(tmpl.source, Source::AgentsStandard);
522        assert!(tmpl.content.contains("custom skill content"));
523
524        // Restore original directory
525        std::env::set_current_dir(original_dir).unwrap();
526    }
527
528    // 9.9: Unknown skill name returns error
529    #[test]
530    fn unknown_skill_returns_error() {
531        let result = resolve("nonexistent");
532        assert!(
533            matches!(result, Err(SkillError::UnknownSkill { ref name }) if name == "nonexistent"),
534            "expected UnknownSkill error, got {result:?}"
535        );
536    }
537
538    // 9.10: {{BRANCH_ID}} is substituted
539    #[test]
540    fn branch_id_is_substituted() {
541        let tmpl = SkillTemplate {
542            name: "test".into(),
543            content: "agent_id:\"{{BRANCH_ID}}\"".into(),
544            source: Source::Embedded,
545            format: SkillFormat::Standardized,
546            metadata: None,
547            resource_paths: None,
548        };
549        let output = render(
550            &tmpl,
551            "feat/http-broker",
552            "http://127.0.0.1:9119",
553            "git-paw",
554            None,
555        );
556        assert!(output.contains("feat-http-broker"));
557        assert!(!output.contains("{{BRANCH_ID}}"));
558    }
559
560    // 9.11: {{GIT_PAW_BROKER_URL}} is substituted at render time
561    #[test]
562    fn broker_url_placeholder_substituted() {
563        let tmpl = SkillTemplate {
564            name: "test".into(),
565            content: "curl {{GIT_PAW_BROKER_URL}}/status".into(),
566            source: Source::Embedded,
567            format: SkillFormat::Standardized,
568            metadata: None,
569            resource_paths: None,
570        };
571        let output = render(&tmpl, "feat/x", "http://127.0.0.1:9119", "git-paw", None);
572        assert!(output.contains("http://127.0.0.1:9119/status"));
573        assert!(!output.contains("{{GIT_PAW_BROKER_URL}}"));
574    }
575
576    // 9.12: Slug substitution matches slugify_branch
577    #[test]
578    fn slug_substitution_matches_slugify_branch() {
579        let tmpl = SkillTemplate {
580            name: "test".into(),
581            content: "id={{BRANCH_ID}}".into(),
582            source: Source::Embedded,
583            format: SkillFormat::Standardized,
584            metadata: None,
585            resource_paths: None,
586        };
587        let output = render(
588            &tmpl,
589            "Feature/HTTP_Broker",
590            "http://127.0.0.1:9119",
591            "git-paw",
592            None,
593        );
594        let expected = slugify_branch("Feature/HTTP_Broker");
595        assert_eq!(output, format!("id={expected}"));
596    }
597
598    // 9.13: Render is deterministic
599    #[test]
600    fn render_is_deterministic() {
601        let tmpl = resolve("coordination").unwrap();
602        let a = render(&tmpl, "feat/x", "http://127.0.0.1:9119", "git-paw", None);
603        let b = render(&tmpl, "feat/x", "http://127.0.0.1:9119", "git-paw", None);
604        assert_eq!(a, b);
605    }
606
607    // 9.14: Render performs no I/O (resolve then render after "deletion")
608    #[test]
609    #[serial(directory_changes)]
610    fn render_performs_no_io() {
611        let dir = tempfile::tempdir().unwrap();
612        let project_dir = dir.path().join("my-project");
613        std::fs::create_dir_all(&project_dir).unwrap();
614
615        let skill_dir = project_dir
616            .join(".agents")
617            .join("skills")
618            .join("coordination");
619        std::fs::create_dir_all(&skill_dir).unwrap();
620
621        let skill_md_content = "---\nname: coordination\ndescription: Test coordination skill\n---\n\nuser {{BRANCH_ID}}";
622        std::fs::write(skill_dir.join("SKILL.md"), skill_md_content).unwrap();
623
624        // Change to project directory
625        let original_dir = std::env::current_dir().unwrap();
626        std::env::set_current_dir(&project_dir).unwrap();
627
628        let tmpl = resolve("coordination").unwrap();
629        assert_eq!(tmpl.source, Source::AgentsStandard);
630
631        // Delete the skill directory — render must still succeed from in-memory content
632        std::fs::remove_dir_all(skill_dir).unwrap();
633        let output = render(&tmpl, "feat/x", "http://127.0.0.1:9119", "git-paw", None);
634        assert!(output.contains("feat-x"));
635
636        // Restore original directory
637        std::env::set_current_dir(original_dir).unwrap();
638    }
639
640    // 9.15: Unknown placeholder survives in output (warning is emitted to stderr)
641    #[test]
642    fn unknown_placeholder_survives() {
643        let tmpl = SkillTemplate {
644            name: "test".into(),
645            content: "url={{UNKNOWN_THING}}".into(),
646            source: Source::Embedded,
647            format: SkillFormat::Standardized,
648            metadata: None,
649            resource_paths: None,
650        };
651        let output = render(&tmpl, "feat/x", "http://127.0.0.1:9119", "git-paw", None);
652        assert!(
653            output.contains("{{UNKNOWN_THING}}"),
654            "unknown placeholder should survive in output"
655        );
656    }
657
658    // 9.16: No {{...}} remains after rendering the embedded coordination template
659    #[test]
660    fn no_unknown_placeholders_after_render() {
661        let tmpl = resolve("coordination").unwrap();
662        let output = render(&tmpl, "feat/x", "http://127.0.0.1:9119", "git-paw", None);
663        assert!(
664            !output.contains("{{"),
665            "no double-curly placeholders should remain: {output}"
666        );
667    }
668
669    // Supervisor skill is reachable as an embedded default
670    #[test]
671    fn embedded_supervisor_is_reachable() {
672        let tmpl = resolve("supervisor").expect("should resolve supervisor");
673        assert_eq!(tmpl.source, Source::Embedded);
674        assert!(!tmpl.content.is_empty());
675    }
676
677    // Supervisor skill contains role definition
678    #[test]
679    fn supervisor_skill_contains_role_definition() {
680        let tmpl = resolve("supervisor").unwrap();
681        assert!(tmpl.content.contains("do NOT write code"));
682    }
683
684    // Supervisor skill contains broker status endpoint
685    #[test]
686    fn supervisor_skill_contains_broker_status() {
687        let tmpl = resolve("supervisor").unwrap();
688        assert!(tmpl.content.contains("{{GIT_PAW_BROKER_URL}}/status"));
689    }
690
691    // Supervisor skill contains verified and feedback message types
692    #[test]
693    fn supervisor_skill_contains_verified_and_feedback() {
694        let tmpl = resolve("supervisor").unwrap();
695        assert!(tmpl.content.contains("agent.verified"));
696        assert!(tmpl.content.contains("agent.feedback"));
697    }
698
699    // Supervisor skill contains tmux commands targeting the session name
700    #[test]
701    fn supervisor_skill_contains_tmux_commands() {
702        let tmpl = resolve("supervisor").unwrap();
703        assert!(tmpl.content.contains("tmux capture-pane"));
704        assert!(tmpl.content.contains("tmux send-keys"));
705        assert!(tmpl.content.contains("paw-{{PROJECT_NAME}}"));
706    }
707
708    #[test]
709    fn supervisor_skill_contains_spec_audit_procedure() {
710        let tmpl = resolve("supervisor").unwrap();
711        assert!(
712            tmpl.content.contains("Spec Audit"),
713            "supervisor skill should contain Spec Audit section"
714        );
715        assert!(
716            tmpl.content.contains("openspec/changes/"),
717            "should reference openspec/changes/ for spec file discovery"
718        );
719        assert!(
720            tmpl.content.contains("grep"),
721            "should instruct to grep for matching tests"
722        );
723    }
724
725    #[test]
726    fn supervisor_skill_spec_audit_after_test_before_verified() {
727        let tmpl = resolve("supervisor").unwrap();
728        let test_pos = tmpl.content.find("Regression check").unwrap_or(0);
729        let audit_pos = tmpl.content.find("Spec Audit").unwrap_or(0);
730        let verify_pos = tmpl.content.find("Verify or feedback").unwrap_or(0);
731        assert!(
732            audit_pos > test_pos,
733            "spec audit should appear after test/regression check"
734        );
735        assert!(
736            audit_pos < verify_pos,
737            "spec audit should appear before verify/feedback"
738        );
739    }
740
741    // {{PROJECT_NAME}} is substituted by render
742    #[test]
743    fn project_name_is_substituted() {
744        let tmpl = SkillTemplate {
745            name: "test".into(),
746            content: "session=paw-{{PROJECT_NAME}}".into(),
747            source: Source::Embedded,
748            format: SkillFormat::Standardized,
749            metadata: None,
750            resource_paths: None,
751        };
752        let output = render(&tmpl, "feat/x", "http://127.0.0.1:9119", "my-app", None);
753        assert!(output.contains("paw-my-app"));
754        assert!(!output.contains("{{PROJECT_NAME}}"));
755    }
756
757    // Both BRANCH_ID and PROJECT_NAME substituted in the same template
758    #[test]
759    fn branch_id_and_project_name_both_substituted() {
760        let tmpl = SkillTemplate {
761            name: "test".into(),
762            content: "agent={{BRANCH_ID}} session=paw-{{PROJECT_NAME}}".into(),
763            source: Source::Embedded,
764            format: SkillFormat::Standardized,
765            metadata: None,
766            resource_paths: None,
767        };
768        let output = render(&tmpl, "feat/http-broker", "url", "git-paw", None);
769        assert!(output.contains("feat-http-broker"));
770        assert!(output.contains("paw-git-paw"));
771        assert!(!output.contains("{{BRANCH_ID}}"));
772        assert!(!output.contains("{{PROJECT_NAME}}"));
773    }
774
775    // Standardized skill format is detected and loaded
776    #[test]
777    #[serial(directory_changes)]
778    fn standardized_skill_format_is_detected() {
779        let dir = tempfile::tempdir().unwrap();
780        let project_dir = dir.path().join("my-project");
781        std::fs::create_dir_all(&project_dir).unwrap();
782
783        let skill_dir = project_dir
784            .join(".agents")
785            .join("skills")
786            .join("test-standardized");
787        std::fs::create_dir_all(&skill_dir).unwrap();
788
789        let skill_md_content = "---\nname: test-standardized\ndescription: A test standardized skill\n---\n\nThis is the skill content with {{BRANCH_ID}} placeholder.";
790        std::fs::write(skill_dir.join("SKILL.md"), skill_md_content).unwrap();
791
792        // Change to project directory
793        let original_dir = std::env::current_dir().unwrap();
794        std::env::set_current_dir(&project_dir).unwrap();
795
796        let tmpl = resolve("test-standardized").expect("should resolve");
797        assert_eq!(tmpl.format, SkillFormat::Standardized);
798        assert!(tmpl.content.contains("This is the skill content"));
799        assert!(tmpl.content.contains("{{BRANCH_ID}}"));
800        assert!(tmpl.metadata.is_some());
801        let metadata = tmpl.metadata.as_ref().unwrap();
802        assert_eq!(metadata.name, "test-standardized");
803        assert_eq!(metadata.description, "A test standardized skill");
804
805        // Restore original directory
806        std::env::set_current_dir(original_dir).unwrap();
807    }
808
809    // Standardized skill with resources loads resource paths
810    #[test]
811    fn standardized_skill_with_resources_loads_paths() {
812        let dir = tempfile::tempdir().unwrap();
813        let skills_parent_dir = dir.path().join("git-paw").join("agent-skills");
814        let specific_skill_dir = skills_parent_dir.join("test-with-resources");
815        std::fs::create_dir_all(&specific_skill_dir).unwrap();
816
817        // Create skill directory structure
818        std::fs::create_dir_all(specific_skill_dir.join("scripts")).unwrap();
819        std::fs::create_dir_all(specific_skill_dir.join("references")).unwrap();
820        std::fs::create_dir_all(specific_skill_dir.join("assets")).unwrap();
821
822        let skill_md_content = "---\nname: test-with-resources\ndescription: Skill with resources\n---\n\nMain content here.";
823        std::fs::write(specific_skill_dir.join("SKILL.md"), skill_md_content).unwrap();
824
825        let tmpl = resolve_with_config_dir("test-with-resources", Some(dir.path()))
826            .expect("should resolve");
827        assert_eq!(tmpl.format, SkillFormat::Standardized);
828        assert!(tmpl.resource_paths.is_some());
829        let resource_paths = tmpl.resource_paths.as_ref().unwrap();
830        assert_eq!(resource_paths.len(), 3);
831        assert!(resource_paths.iter().any(|p| p.ends_with("scripts")));
832        assert!(resource_paths.iter().any(|p| p.ends_with("references")));
833        assert!(resource_paths.iter().any(|p| p.ends_with("assets")));
834    }
835
836    // Standard location (.agents/skills/) loading
837    #[test]
838    #[serial(directory_changes)]
839    fn standard_location_loading() {
840        let temp_dir = tempfile::tempdir().unwrap();
841        let project_dir = temp_dir.path().join("my-project");
842        std::fs::create_dir_all(&project_dir).unwrap();
843
844        // Create skill in standard location
845        let standard_skill_dir = project_dir
846            .join(".agents")
847            .join("skills")
848            .join("test-skill");
849        std::fs::create_dir_all(&standard_skill_dir).unwrap();
850        let standard_content = "---\nname: test-skill\ndescription: Standard location skill\n---\n\nContent from .agents/skills/";
851        std::fs::write(standard_skill_dir.join("SKILL.md"), standard_content).unwrap();
852
853        // Change to project directory so .agents/skills/ can be found
854        let original_dir = std::env::current_dir().unwrap();
855        std::env::set_current_dir(&project_dir).unwrap();
856
857        let tmpl = resolve("test-skill").expect("should resolve");
858
859        // Should load from standard location
860        assert_eq!(tmpl.source, Source::AgentsStandard);
861        assert!(tmpl.content.contains("Content from .agents/skills/"));
862
863        // Restore original directory
864        std::env::set_current_dir(original_dir).unwrap();
865    }
866
867    // Standardized skill metadata placeholders are substituted
868    #[test]
869    fn standardized_skill_metadata_placeholders_are_substituted() {
870        let metadata = StandardizedSkillMetadata {
871            name: "test-skill".to_string(),
872            description: "Test description".to_string(),
873            license: None,
874            compatibility: None,
875            metadata: None,
876        };
877
878        let tmpl = SkillTemplate {
879            name: "test".into(),
880            content: "Name: {{SKILL_NAME}}, Desc: {{SKILL_DESCRIPTION}}".into(),
881            source: Source::Embedded,
882            format: SkillFormat::Standardized,
883            metadata: Some(metadata),
884            resource_paths: None,
885        };
886
887        let output = render(&tmpl, "feat/x", "http://127.0.0.1:9119", "git-paw", None);
888        assert!(output.contains("Name: test-skill, Desc: Test description"));
889        assert!(!output.contains("{{SKILL_NAME}}"));
890        assert!(!output.contains("{{SKILL_DESCRIPTION}}"));
891    }
892
893    #[test]
894    fn test_command_placeholder_substitutes_when_set() {
895        let tmpl = SkillTemplate {
896            name: "supervisor".into(),
897            content: "Run `{{TEST_COMMAND}}` after each merge.".into(),
898            source: Source::Embedded,
899            format: SkillFormat::Standardized,
900            metadata: None,
901            resource_paths: None,
902        };
903        let output = render(
904            &tmpl,
905            "supervisor",
906            "http://127.0.0.1:9119",
907            "git-paw",
908            Some("just check"),
909        );
910        assert_eq!(output, "Run `just check` after each merge.");
911        assert!(!output.contains("{{TEST_COMMAND}}"));
912    }
913
914    #[test]
915    fn test_command_placeholder_falls_back_when_unset() {
916        let tmpl = SkillTemplate {
917            name: "supervisor".into(),
918            content: "Baseline: {{TEST_COMMAND}}".into(),
919            source: Source::Embedded,
920            format: SkillFormat::Standardized,
921            metadata: None,
922            resource_paths: None,
923        };
924        let output = render(
925            &tmpl,
926            "supervisor",
927            "http://127.0.0.1:9119",
928            "git-paw",
929            None,
930        );
931        assert_eq!(output, "Baseline: (not configured)");
932        assert!(!output.contains("{{TEST_COMMAND}}"));
933    }
934
935    #[test]
936    fn supervisor_template_no_unsubstituted_placeholders_when_test_command_set() {
937        // Regression: rendering the embedded supervisor skill with a configured
938        // test_command must NOT leave {{TEST_COMMAND}} in the output. Captured
939        // during a live dogfood run that produced the warning
940        // "unsubstituted placeholder {{TEST_COMMAND}} in skill 'supervisor'".
941        let tmpl = resolve("supervisor").expect("supervisor skill resolves");
942        let output = render(
943            &tmpl,
944            "supervisor",
945            "http://127.0.0.1:9119",
946            "git-paw",
947            Some("just check"),
948        );
949        assert!(
950            !output.contains("{{TEST_COMMAND}}"),
951            "supervisor template still contains a literal {{TEST_COMMAND}} after render"
952        );
953        assert!(
954            !output.contains("{{"),
955            "supervisor template has unsubstituted {{...}} placeholder after render"
956        );
957    }
958
959    // Invalid standardized skill frontmatter returns validation error
960    #[test]
961    fn invalid_standardized_skill_frontmatter_returns_error() {
962        let dir = tempfile::tempdir().unwrap();
963        let project_dir = dir.path().join("my-project");
964        std::fs::create_dir_all(&project_dir).unwrap();
965
966        let skill_dir = project_dir
967            .join(".agents")
968            .join("skills")
969            .join("invalid-skill");
970        std::fs::create_dir_all(&skill_dir).unwrap();
971
972        // Missing required 'description' field
973        let skill_md_content = "---\nname: invalid-skill\n---\n\nContent here.";
974        std::fs::write(skill_dir.join("SKILL.md"), skill_md_content).unwrap();
975
976        // Change to project directory
977        let original_dir = std::env::current_dir().unwrap();
978        std::env::set_current_dir(&project_dir).unwrap();
979
980        let result = resolve("invalid-skill");
981        assert!(matches!(result, Err(SkillError::ValidationError { .. })));
982
983        // Restore original directory
984        std::env::set_current_dir(original_dir).unwrap();
985    }
986
987    // 9.17: SkillTemplate is cloneable
988    #[test]
989    fn skill_template_is_cloneable() {
990        let tmpl = resolve("coordination").unwrap();
991        let cloned = tmpl.clone();
992        assert_eq!(tmpl.name, cloned.name);
993        assert_eq!(tmpl.content, cloned.content);
994        assert_eq!(tmpl.source, cloned.source);
995    }
996
997    // Boot block function tests
998    #[test]
999    fn boot_block_contains_all_four_essential_events() {
1000        let block = build_boot_block("feat/errors", "http://localhost:9119");
1001        assert!(
1002            block.contains("### 1. REGISTER"),
1003            "Missing REGISTER section"
1004        );
1005        assert!(block.contains("### 2. DONE"), "Missing DONE section");
1006        assert!(block.contains("### 3. BLOCKED"), "Missing BLOCKED section");
1007        assert!(
1008            block.contains("### 4. QUESTION"),
1009            "Missing QUESTION section"
1010        );
1011    }
1012
1013    #[test]
1014    fn boot_block_substitutes_branch_id_placeholder() {
1015        let block = build_boot_block("Feature/HTTP_Broker", "http://localhost:9119");
1016        assert!(
1017            block.contains("feature-http_broker"),
1018            "Branch ID not properly slugified"
1019        );
1020        assert!(
1021            !block.contains("{{BRANCH_ID}}"),
1022            "BRANCH_ID placeholder not substituted"
1023        );
1024    }
1025
1026    #[test]
1027    fn boot_block_substitutes_broker_url_placeholder() {
1028        let block = build_boot_block("feat/x", "http://127.0.0.1:9119");
1029        assert!(
1030            block.contains("http://127.0.0.1:9119/publish"),
1031            "Broker URL not substituted"
1032        );
1033        assert!(
1034            !block.contains("{{GIT_PAW_BROKER_URL}}"),
1035            "GIT_PAW_BROKER_URL placeholder not substituted"
1036        );
1037    }
1038
1039    #[test]
1040    fn boot_block_contains_paste_handling_instructions() {
1041        let block = build_boot_block("feat/x", "http://localhost:9119");
1042        assert!(
1043            block.contains("PASTE HANDLING"),
1044            "Missing paste handling section"
1045        );
1046        assert!(
1047            block.contains("additional Enter key"),
1048            "Missing Enter key instruction"
1049        );
1050        assert!(
1051            block.contains("[Pasted text #N]"),
1052            "Missing paste text reference"
1053        );
1054    }
1055
1056    #[test]
1057    fn boot_block_question_section_emphasizes_waiting() {
1058        let block = build_boot_block("feat/x", "http://localhost:9119");
1059        assert!(
1060            block.contains("DO NOT CONTINUE UNTIL YOU RECEIVE AN ANSWER!"),
1061            "Missing wait emphasis"
1062        );
1063        assert!(
1064            block.contains("WAIT for the answer before continuing"),
1065            "Missing wait instruction"
1066        );
1067    }
1068
1069    #[test]
1070    fn boot_block_is_deterministic() {
1071        let a = build_boot_block("feat/x", "http://localhost:9119");
1072        let b = build_boot_block("feat/x", "http://localhost:9119");
1073        assert_eq!(a, b, "Boot block generation should be deterministic");
1074    }
1075
1076    #[test]
1077    fn boot_block_handles_complex_branch_names() {
1078        let block = build_boot_block("fix/topological-cycle-fallback", "http://localhost:9119");
1079        assert!(
1080            block.contains("fix-topological-cycle-fallback"),
1081            "Complex branch name not properly slugified"
1082        );
1083    }
1084
1085    #[test]
1086    fn boot_block_contains_pre_expanded_curl_commands() {
1087        let block = build_boot_block("feat/test", "http://127.0.0.1:9119");
1088
1089        // Check that all curl commands have the actual URL substituted
1090        assert!(
1091            block.contains("curl -s -X POST http://127.0.0.1:9119/publish"),
1092            "Curl commands not pre-expanded"
1093        );
1094
1095        // Check that all curl commands have the actual branch ID substituted
1096        assert!(
1097            block.contains("\"agent_id\":\"feat-test\""),
1098            "Agent ID not substituted in curl commands"
1099        );
1100    }
1101}