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/// Borrowed view of the seven gate-command templates substituted by
397/// [`render`] into the supervisor skill.
398///
399/// Each field maps to a `{{...}}` placeholder in the skill template (see
400/// [`render`] for the full list). `None` renders as the literal
401/// `(not configured)` so the rendered prose stays readable and the
402/// supervisor agent can machine-check the value to decide whether to skip
403/// the tooling invocation for that gate.
404///
405/// `{{CHANGE_ID}}` is NOT a field here: it is a per-invocation placeholder
406/// substituted by the supervisor agent at verification time (using the
407/// change being audited), not by `render` at session boot.
408///
409/// Use [`SupervisorConfig::gate_commands`](crate::config::SupervisorConfig::gate_commands)
410/// to build one from a config.
411#[derive(Debug, Clone, Copy, Default)]
412pub struct GateCommands<'a> {
413    /// Renders into `{{TEST_COMMAND}}`. Gate 1 test runner.
414    pub test_command: Option<&'a str>,
415    /// Renders into `{{LINT_COMMAND}}`. Gate 1 lint sub-step.
416    pub lint_command: Option<&'a str>,
417    /// Renders into `{{BUILD_COMMAND}}`. Gate 1 build sub-step.
418    pub build_command: Option<&'a str>,
419    /// Renders into `{{DOC_BUILD_COMMAND}}`. Gate 4 doc builder.
420    pub doc_build_command: Option<&'a str>,
421    /// Renders into `{{SPEC_VALIDATE_COMMAND}}`. Gate 3 spec validator.
422    /// MAY contain a `{{CHANGE_ID}}` substring that the supervisor agent
423    /// expands at verification time — `render` does NOT substitute it.
424    pub spec_validate_command: Option<&'a str>,
425    /// Renders into `{{FMT_CHECK_COMMAND}}`. Gate 1 format check.
426    pub fmt_check_command: Option<&'a str>,
427    /// Renders into `{{SECURITY_AUDIT_COMMAND}}`. Gate 5 security tooling.
428    pub security_audit_command: Option<&'a str>,
429}
430
431/// Renders a skill template for a specific worktree.
432///
433/// Substitutes the following placeholders at render time:
434///
435/// - `{{BRANCH_ID}}` — the slugified branch name (`feat/foo` → `feat-foo`)
436/// - `{{PROJECT_NAME}}` — the project name (e.g. `"git-paw"`), used in the
437///   `paw-{{PROJECT_NAME}}` tmux session name
438/// - `{{GIT_PAW_BROKER_URL}}` — the fully-qualified broker URL, pre-expanded
439///   here so the agent's curl commands contain a literal URL and no shell
440///   expansion is needed at execution time. Pre-expanding at render time is
441///   important: some CLI tools gate shell-variable expansion behind extra
442///   permission prompts, which breaks the "don't ask again for `curl:*`"
443///   allowlist flow.
444/// - `{{TEST_COMMAND}}` — the supervisor's configured `test_command` (e.g.
445///   `"just check"`). When `test_command` is `None`, the placeholder
446///   substitutes to the literal `"(not configured)"` so the rendered prose
447///   stays readable.
448/// - `{{LINT_COMMAND}}`, `{{BUILD_COMMAND}}`, `{{DOC_BUILD_COMMAND}}`,
449///   `{{SPEC_VALIDATE_COMMAND}}`, `{{FMT_CHECK_COMMAND}}`,
450///   `{{SECURITY_AUDIT_COMMAND}}` — the five additional gate commands
451///   from `[supervisor]` config. `None` renders as `(not configured)`,
452///   identical to `{{TEST_COMMAND}}` behaviour.
453///
454/// `{{CHANGE_ID}}` is **not** substituted here. The spec-validate command
455/// typically embeds `{{CHANGE_ID}}` as a per-invocation placeholder that
456/// the supervisor agent expands at verification time using the change name
457/// being audited. Substituting it at render time would freeze the rendered
458/// skill to a single change, which is wrong — the supervisor verifies
459/// many changes over a session lifetime.
460///
461/// Any remaining `{{...}}` placeholder after substitution is logged as a
462/// warning to stderr but does not cause `render` to fail. The
463/// `{{CHANGE_ID}}` form is whitelisted from this warning since the spec
464/// expects it to survive intact (see the `agent-skills` spec delta).
465///
466/// For standardized skills, additional metadata placeholders may be available:
467/// - `{{SKILL_NAME}}` — the skill name from metadata
468/// - `{{SKILL_DESCRIPTION}}` — the skill description from metadata
469pub fn render(
470    template: &SkillTemplate,
471    branch: &str,
472    broker_url: &str,
473    project: &str,
474    gates: &GateCommands<'_>,
475) -> String {
476    const NOT_CONFIGURED: &str = "(not configured)";
477    let branch_id = slugify_branch(branch);
478
479    // Start with basic substitutions. Gate-command placeholders use the
480    // literal `(not configured)` when the source value is `None` so the
481    // rendered prose remains readable AND the supervisor agent can branch
482    // on it to skip the tooling invocation.
483    let mut output = template
484        .content
485        .replace("{{BRANCH_ID}}", &branch_id)
486        .replace("{{PROJECT_NAME}}", project)
487        .replace("{{GIT_PAW_BROKER_URL}}", broker_url)
488        .replace(
489            "{{TEST_COMMAND}}",
490            gates.test_command.unwrap_or(NOT_CONFIGURED),
491        )
492        .replace(
493            "{{LINT_COMMAND}}",
494            gates.lint_command.unwrap_or(NOT_CONFIGURED),
495        )
496        .replace(
497            "{{BUILD_COMMAND}}",
498            gates.build_command.unwrap_or(NOT_CONFIGURED),
499        )
500        .replace(
501            "{{DOC_BUILD_COMMAND}}",
502            gates.doc_build_command.unwrap_or(NOT_CONFIGURED),
503        )
504        .replace(
505            "{{SPEC_VALIDATE_COMMAND}}",
506            gates.spec_validate_command.unwrap_or(NOT_CONFIGURED),
507        )
508        .replace(
509            "{{FMT_CHECK_COMMAND}}",
510            gates.fmt_check_command.unwrap_or(NOT_CONFIGURED),
511        )
512        .replace(
513            "{{SECURITY_AUDIT_COMMAND}}",
514            gates.security_audit_command.unwrap_or(NOT_CONFIGURED),
515        );
516
517    // `{{CHANGE_ID}}` is intentionally NOT substituted: it is a
518    // per-invocation placeholder owned by the supervisor agent at
519    // verification time. It survives render verbatim and is expanded
520    // when the supervisor runs spec-validate against a specific change.
521
522    // Add metadata substitutions for standardized skills
523    if let Some(metadata) = &template.metadata {
524        output = output
525            .replace("{{SKILL_NAME}}", &metadata.name)
526            .replace("{{SKILL_DESCRIPTION}}", &metadata.description);
527    }
528
529    // Warn about any remaining {{...}} placeholders that were not consumed,
530    // except `{{CHANGE_ID}}` which is whitelisted (see comment above).
531    let mut start = 0;
532    while let Some(open) = output[start..].find("{{") {
533        let abs_open = start + open;
534        if let Some(close) = output[abs_open..].find("}}") {
535            let placeholder = &output[abs_open..abs_open + close + 2];
536            if placeholder != "{{CHANGE_ID}}" {
537                eprintln!(
538                    "warning: unsubstituted placeholder {placeholder} in skill '{}'",
539                    template.name
540                );
541            }
542            start = abs_open + close + 2;
543        } else {
544            break;
545        }
546    }
547
548    output
549}
550
551/// Canonical doc names for the `[governance]` paths, in the order they
552/// appear in the supervisor boot prompt: `adr`, `test_strategy`, `security`,
553/// `dod`, `constitution`. The canonical name is what shows up before the
554/// path in each bullet (`- adr: docs/adr/`).
555const GOVERNANCE_CANONICAL_NAMES: [&str; 5] =
556    ["adr", "test_strategy", "security", "dod", "constitution"];
557
558/// Renders the supervisor boot-prompt's `## Governance documents` section
559/// from the five governance path fields, in canonical order.
560///
561/// Returns an empty `String` when every path is `None`. When at least one
562/// path is set, the result is a self-contained block:
563///
564/// ```text
565/// ## Governance documents
566///
567/// The supervisor consults these documents during spec audit.
568///
569/// - adr: docs/adr/
570/// - dod: docs/dod.md
571/// ```
572///
573/// The bullet list is built from the configured paths only — fields whose
574/// value is `None` are skipped entirely (no placeholder line). The section
575/// does not include any "gates" sub-line or per-doc enforcement metadata;
576/// the `governance-config` capability dropped per-doc gate flags so there
577/// is nothing to convey here beyond the paths themselves.
578///
579/// The caller is responsible for the blank line separating the section
580/// from preceding boot-prompt content. When this function returns the
581/// empty string, the boot prompt remains byte-identical to its v0.4
582/// shape.
583pub fn governance_section_paths(
584    adr: Option<&Path>,
585    test_strategy: Option<&Path>,
586    security: Option<&Path>,
587    dod: Option<&Path>,
588    constitution: Option<&Path>,
589) -> String {
590    let bullets: [Option<&Path>; 5] = [adr, test_strategy, security, dod, constitution];
591    if bullets.iter().all(Option::is_none) {
592        return String::new();
593    }
594
595    let mut out = String::with_capacity(192);
596    out.push_str("## Governance documents\n");
597    out.push('\n');
598    out.push_str("The supervisor consults these documents during spec audit.\n");
599    out.push('\n');
600    for (name, path) in GOVERNANCE_CANONICAL_NAMES.iter().zip(bullets.iter()) {
601        if let Some(p) = path {
602            use std::fmt::Write as _;
603            // `writeln!` into a `String` never fails — formatting to a
604            // growable buffer cannot run out of capacity. The `let _ =`
605            // discards the `fmt::Result` without panicking.
606            let _ = writeln!(out, "- {name}: {}", p.display());
607        }
608    }
609    out
610}
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615    use serial_test::serial;
616
617    // 9.2: Embedded coordination skill is reachable without any user files
618    #[test]
619    fn embedded_coordination_is_reachable() {
620        let tmpl = resolve("coordination").expect("should resolve coordination");
621        assert_eq!(tmpl.source, Source::Embedded);
622        assert!(!tmpl.content.is_empty());
623    }
624
625    // 9.3: Embedded coordination skill contains all four operations
626    #[test]
627    fn embedded_coordination_contains_all_operations() {
628        let tmpl = resolve("coordination").unwrap();
629        assert!(tmpl.content.contains("agent.status"));
630        assert!(tmpl.content.contains("agent.artifact"));
631        assert!(tmpl.content.contains("agent.blocked"));
632        assert!(
633            tmpl.content
634                .contains("{{GIT_PAW_BROKER_URL}}/messages/{{BRANCH_ID}}")
635        );
636    }
637
638    #[test]
639    fn embedded_coordination_documents_supervisor_messages() {
640        let tmpl = resolve("coordination").unwrap();
641        assert!(tmpl.content.contains("agent.verified"));
642        assert!(tmpl.content.contains("agent.feedback"));
643        assert!(tmpl.content.contains("re-publish"));
644    }
645
646    // === forward-coordination: existing-scenario coverage gaps ===
647
648    #[test]
649    fn coordination_skill_documents_automatic_status_publishing() {
650        let tmpl = resolve("coordination").unwrap();
651        let lowered = tmpl.content.to_lowercase();
652        assert!(
653            lowered.contains("publishes your status automatically")
654                || lowered.contains("status publishing is automatic")
655                || lowered.contains("publishes status automatically"),
656            "coordination skill should indicate that agent.status publishing is automatic"
657        );
658        assert!(
659            !tmpl.content.contains("MUST publish agent.status"),
660            "coordination skill must not contain the legacy 'MUST publish agent.status' instruction"
661        );
662    }
663
664    #[test]
665    fn coordination_skill_contains_cherry_pick_instructions() {
666        let tmpl = resolve("coordination").unwrap();
667        assert!(
668            tmpl.content.contains("git cherry-pick"),
669            "coordination skill should contain the literal 'git cherry-pick' command"
670        );
671        assert!(
672            tmpl.content.contains("Cherry-pick peer commits"),
673            "coordination skill should contain a 'Cherry-pick peer commits' heading"
674        );
675    }
676
677    // === forward-coordination: agent.intent skill content ===
678
679    #[test]
680    fn coordination_skill_contains_before_you_start_editing_heading() {
681        let tmpl = resolve("coordination").unwrap();
682        assert!(
683            tmpl.content.contains("Before you start editing"),
684            "coordination skill should contain 'Before you start editing' heading"
685        );
686    }
687
688    #[test]
689    fn coordination_skill_contains_agent_intent_curl_example() {
690        let tmpl = resolve("coordination").unwrap();
691        let curl_pos = tmpl
692            .content
693            .find("agent.intent")
694            .expect("coordination skill should mention agent.intent");
695        // Look at a window around the intent example and assert all required
696        // payload fields appear there.
697        let window_start = curl_pos.saturating_sub(200);
698        let window_end = (curl_pos + 800).min(tmpl.content.len());
699        let window = &tmpl.content[window_start..window_end];
700        assert!(
701            window.contains("curl"),
702            "agent.intent example should be a curl invocation"
703        );
704        assert!(
705            window.contains("\"files\""),
706            "agent.intent example should include the files field"
707        );
708        assert!(
709            window.contains("\"summary\""),
710            "agent.intent example should include the summary field"
711        );
712        assert!(
713            window.contains("\"valid_for_seconds\""),
714            "agent.intent example should include valid_for_seconds"
715        );
716    }
717
718    #[test]
719    fn coordination_skill_contains_while_youre_editing_heading() {
720        let tmpl = resolve("coordination").unwrap();
721        assert!(
722            tmpl.content.contains("While you're editing"),
723            "coordination skill should contain 'While you're editing' heading"
724        );
725    }
726
727    #[test]
728    fn coordination_skill_instructs_republish_on_scope_growth() {
729        let tmpl = resolve("coordination").unwrap();
730        let lowered = tmpl.content.to_lowercase();
731        assert!(
732            lowered.contains("scope grows") || lowered.contains("scope grow"),
733            "coordination skill should instruct re-publishing when scope grows"
734        );
735        assert!(
736            lowered.contains("re-publish"),
737            "coordination skill should mention re-publishing the intent"
738        );
739    }
740
741    #[test]
742    fn coordination_skill_instructs_question_on_peer_intent_overlap() {
743        let tmpl = resolve("coordination").unwrap();
744        // The skill should tell agents to send agent.question on overlap, not
745        // race the peer.
746        assert!(
747            tmpl.content.contains("agent.question"),
748            "coordination skill should reference agent.question"
749        );
750        let lowered = tmpl.content.to_lowercase();
751        assert!(
752            lowered.contains("overlap") || lowered.contains("overlapping"),
753            "coordination skill should call out overlap as the trigger for agent.question"
754        );
755    }
756
757    #[test]
758    fn coordination_skill_contains_must_not_anti_pattern_statements() {
759        let tmpl = resolve("coordination").unwrap();
760        let lowered = tmpl.content.to_lowercase();
761        assert!(
762            lowered.contains("must not"),
763            "coordination skill should contain explicit MUST NOT statements"
764        );
765        assert!(
766            lowered.contains("pairwise"),
767            "coordination skill should reject pairwise check-ins"
768        );
769        assert!(
770            lowered.contains("go-ahead") || lowered.contains("go ahead"),
771            "coordination skill should reject waiting for go-ahead"
772        );
773        assert!(
774            lowered.contains("broker silence") || lowered.contains("silence"),
775            "coordination skill should reject blocking on broker silence"
776        );
777    }
778
779    #[test]
780    fn supervisor_skill_contains_watch_peer_intents_section() {
781        let tmpl = resolve("supervisor").unwrap();
782        assert!(
783            tmpl.content.contains("Watch peer intents"),
784            "supervisor skill should contain 'Watch peer intents' heading"
785        );
786        assert!(
787            tmpl.content.contains("agent.intent"),
788            "supervisor skill should mention agent.intent"
789        );
790        let lowered = tmpl.content.to_lowercase();
791        assert!(
792            lowered.contains("not part of this release") || lowered.contains("conflict-detection"),
793            "supervisor skill should note that automatic conflict-warning logic is not part of this release"
794        );
795    }
796
797    /// `supervisor-bugfixes-v0-5-x` §3.10: the rendered supervisor skill SHALL
798    /// invoke `.git-paw/scripts/sweep.sh` for snapshot / capture / approve /
799    /// verified / feedback-gate, and SHALL NOT include legacy multi-pane
800    /// `for p in …; do tmux capture-pane` loops.
801    #[test]
802    fn supervisor_skill_references_bundled_sweep_helper() {
803        let tmpl = resolve("supervisor").unwrap();
804        let required = [
805            ".git-paw/scripts/sweep.sh snapshot",
806            ".git-paw/scripts/sweep.sh capture",
807            ".git-paw/scripts/sweep.sh approve",
808            ".git-paw/scripts/sweep.sh verified",
809            ".git-paw/scripts/sweep.sh feedback-gate",
810        ];
811        for needle in required {
812            assert!(
813                tmpl.content.contains(needle),
814                "supervisor skill should reference {needle:?}; content does not"
815            );
816        }
817        assert!(
818            !tmpl.content.contains("for p in 2 3 4 5"),
819            "supervisor skill should not contain legacy `for p in 2 3 4 5` capture-pane loops"
820        );
821    }
822
823    // 9.4: Standard location skill loading
824    #[test]
825    #[serial(directory_changes)]
826    fn standard_location_skill_loading() {
827        let dir = tempfile::tempdir().unwrap();
828        let project_dir = dir.path().join("my-project");
829        std::fs::create_dir_all(&project_dir).unwrap();
830
831        // Create skill in standard location
832        let skill_dir = project_dir
833            .join(".agents")
834            .join("skills")
835            .join("coordination");
836        std::fs::create_dir_all(&skill_dir).unwrap();
837
838        let skill_md_content = "---\nname: coordination\ndescription: Custom coordination skill\n---\n\ncustom skill content";
839        std::fs::write(skill_dir.join("SKILL.md"), skill_md_content).unwrap();
840
841        // Change to project directory
842        let original_dir = std::env::current_dir().unwrap();
843        std::env::set_current_dir(&project_dir).unwrap();
844
845        let tmpl = resolve("coordination").expect("should resolve");
846        assert_eq!(tmpl.source, Source::AgentsStandard);
847        assert!(tmpl.content.contains("custom skill content"));
848
849        // Restore original directory
850        std::env::set_current_dir(original_dir).unwrap();
851    }
852
853    // 9.9: Unknown skill name returns error
854    #[test]
855    fn unknown_skill_returns_error() {
856        let result = resolve("nonexistent");
857        assert!(
858            matches!(result, Err(SkillError::UnknownSkill { ref name }) if name == "nonexistent"),
859            "expected UnknownSkill error, got {result:?}"
860        );
861    }
862
863    // 9.10: {{BRANCH_ID}} is substituted
864    #[test]
865    fn branch_id_is_substituted() {
866        let tmpl = SkillTemplate {
867            name: "test".into(),
868            content: "agent_id:\"{{BRANCH_ID}}\"".into(),
869            source: Source::Embedded,
870            format: SkillFormat::Standardized,
871            metadata: None,
872            resource_paths: None,
873        };
874        let output = render(
875            &tmpl,
876            "feat/http-broker",
877            "http://127.0.0.1:9119",
878            "git-paw",
879            &GateCommands::default(),
880        );
881        assert!(output.contains("feat-http-broker"));
882        assert!(!output.contains("{{BRANCH_ID}}"));
883    }
884
885    // 9.11: {{GIT_PAW_BROKER_URL}} is substituted at render time
886    #[test]
887    fn broker_url_placeholder_substituted() {
888        let tmpl = SkillTemplate {
889            name: "test".into(),
890            content: "curl {{GIT_PAW_BROKER_URL}}/status".into(),
891            source: Source::Embedded,
892            format: SkillFormat::Standardized,
893            metadata: None,
894            resource_paths: None,
895        };
896        let output = render(
897            &tmpl,
898            "feat/x",
899            "http://127.0.0.1:9119",
900            "git-paw",
901            &GateCommands::default(),
902        );
903        assert!(output.contains("http://127.0.0.1:9119/status"));
904        assert!(!output.contains("{{GIT_PAW_BROKER_URL}}"));
905    }
906
907    // 9.12: Slug substitution matches slugify_branch
908    #[test]
909    fn slug_substitution_matches_slugify_branch() {
910        let tmpl = SkillTemplate {
911            name: "test".into(),
912            content: "id={{BRANCH_ID}}".into(),
913            source: Source::Embedded,
914            format: SkillFormat::Standardized,
915            metadata: None,
916            resource_paths: None,
917        };
918        let output = render(
919            &tmpl,
920            "Feature/HTTP_Broker",
921            "http://127.0.0.1:9119",
922            "git-paw",
923            &GateCommands::default(),
924        );
925        let expected = slugify_branch("Feature/HTTP_Broker");
926        assert_eq!(output, format!("id={expected}"));
927    }
928
929    // 9.13: Render is deterministic
930    #[test]
931    fn render_is_deterministic() {
932        let tmpl = resolve("coordination").unwrap();
933        let a = render(
934            &tmpl,
935            "feat/x",
936            "http://127.0.0.1:9119",
937            "git-paw",
938            &GateCommands::default(),
939        );
940        let b = render(
941            &tmpl,
942            "feat/x",
943            "http://127.0.0.1:9119",
944            "git-paw",
945            &GateCommands::default(),
946        );
947        assert_eq!(a, b);
948    }
949
950    // 9.14: Render performs no I/O (resolve then render after "deletion")
951    #[test]
952    #[serial(directory_changes)]
953    fn render_performs_no_io() {
954        let dir = tempfile::tempdir().unwrap();
955        let project_dir = dir.path().join("my-project");
956        std::fs::create_dir_all(&project_dir).unwrap();
957
958        let skill_dir = project_dir
959            .join(".agents")
960            .join("skills")
961            .join("coordination");
962        std::fs::create_dir_all(&skill_dir).unwrap();
963
964        let skill_md_content = "---\nname: coordination\ndescription: Test coordination skill\n---\n\nuser {{BRANCH_ID}}";
965        std::fs::write(skill_dir.join("SKILL.md"), skill_md_content).unwrap();
966
967        // Change to project directory
968        let original_dir = std::env::current_dir().unwrap();
969        std::env::set_current_dir(&project_dir).unwrap();
970
971        let tmpl = resolve("coordination").unwrap();
972        assert_eq!(tmpl.source, Source::AgentsStandard);
973
974        // Delete the skill directory — render must still succeed from in-memory content
975        std::fs::remove_dir_all(skill_dir).unwrap();
976        let output = render(
977            &tmpl,
978            "feat/x",
979            "http://127.0.0.1:9119",
980            "git-paw",
981            &GateCommands::default(),
982        );
983        assert!(output.contains("feat-x"));
984
985        // Restore original directory
986        std::env::set_current_dir(original_dir).unwrap();
987    }
988
989    // 9.15: Unknown placeholder survives in output (warning is emitted to stderr)
990    #[test]
991    fn unknown_placeholder_survives() {
992        let tmpl = SkillTemplate {
993            name: "test".into(),
994            content: "url={{UNKNOWN_THING}}".into(),
995            source: Source::Embedded,
996            format: SkillFormat::Standardized,
997            metadata: None,
998            resource_paths: None,
999        };
1000        let output = render(
1001            &tmpl,
1002            "feat/x",
1003            "http://127.0.0.1:9119",
1004            "git-paw",
1005            &GateCommands::default(),
1006        );
1007        assert!(
1008            output.contains("{{UNKNOWN_THING}}"),
1009            "unknown placeholder should survive in output"
1010        );
1011    }
1012
1013    // 9.16: No {{...}} remains after rendering the embedded coordination template
1014    #[test]
1015    fn no_unknown_placeholders_after_render() {
1016        let tmpl = resolve("coordination").unwrap();
1017        let output = render(
1018            &tmpl,
1019            "feat/x",
1020            "http://127.0.0.1:9119",
1021            "git-paw",
1022            &GateCommands::default(),
1023        );
1024        assert!(
1025            !output.contains("{{"),
1026            "no double-curly placeholders should remain: {output}"
1027        );
1028    }
1029
1030    // Supervisor skill is reachable as an embedded default
1031    #[test]
1032    fn embedded_supervisor_is_reachable() {
1033        let tmpl = resolve("supervisor").expect("should resolve supervisor");
1034        assert_eq!(tmpl.source, Source::Embedded);
1035        assert!(!tmpl.content.is_empty());
1036    }
1037
1038    // Supervisor skill contains role definition
1039    #[test]
1040    fn supervisor_skill_contains_role_definition() {
1041        let tmpl = resolve("supervisor").unwrap();
1042        assert!(tmpl.content.contains("do NOT write code"));
1043    }
1044
1045    // Supervisor skill contains broker status endpoint
1046    #[test]
1047    fn supervisor_skill_contains_broker_status() {
1048        let tmpl = resolve("supervisor").unwrap();
1049        assert!(tmpl.content.contains("{{GIT_PAW_BROKER_URL}}/status"));
1050    }
1051
1052    // Supervisor skill contains verified and feedback message types
1053    #[test]
1054    fn supervisor_skill_contains_verified_and_feedback() {
1055        let tmpl = resolve("supervisor").unwrap();
1056        assert!(tmpl.content.contains("agent.verified"));
1057        assert!(tmpl.content.contains("agent.feedback"));
1058    }
1059
1060    /// Returns the substring containing the supervisor skill's `agent.verified`
1061    /// curl example body (the JSON payload region), used to scope wire-format
1062    /// assertions to the verified example without picking up other prose.
1063    fn verified_curl_example_body(content: &str) -> &str {
1064        let start = content
1065            .find("\"type\":\"agent.verified\"")
1066            .expect("supervisor skill should contain an agent.verified curl example");
1067        let rest = &content[start..];
1068        let end = rest
1069            .find("}}'")
1070            .expect("agent.verified curl example should terminate with the closing payload `}}'`");
1071        &rest[..end + 3]
1072    }
1073
1074    /// Returns the substring containing the supervisor skill's `agent.feedback`
1075    /// curl example body (the JSON payload region).
1076    fn feedback_curl_example_body(content: &str) -> &str {
1077        let start = content
1078            .find("\"type\":\"agent.feedback\"")
1079            .expect("supervisor skill should contain an agent.feedback curl example");
1080        let rest = &content[start..];
1081        let end = rest
1082            .find("}}'")
1083            .expect("agent.feedback curl example should terminate with the closing payload `}}'`");
1084        &rest[..end + 3]
1085    }
1086
1087    #[test]
1088    fn supervisor_verified_example_uses_correct_payload_fields() {
1089        let tmpl = resolve("supervisor").unwrap();
1090        let example = verified_curl_example_body(&tmpl.content);
1091        assert!(
1092            example.contains("verified_by"),
1093            "agent.verified example must use the `verified_by` payload field: {example}"
1094        );
1095        assert!(
1096            example.contains("message"),
1097            "agent.verified example must use the `message` payload field: {example}"
1098        );
1099        for wrong in ["\"target\"", "\"result\"", "\"notes\""] {
1100            assert!(
1101                !example.contains(wrong),
1102                "agent.verified example must not contain the stale field key {wrong}: {example}"
1103            );
1104        }
1105    }
1106
1107    #[test]
1108    fn supervisor_feedback_example_uses_correct_payload_fields() {
1109        let tmpl = resolve("supervisor").unwrap();
1110        let example = feedback_curl_example_body(&tmpl.content);
1111        assert!(
1112            example.contains("\"from\""),
1113            "agent.feedback example must use the `from` payload field: {example}"
1114        );
1115        assert!(
1116            example.contains("\"errors\""),
1117            "agent.feedback example must use the `errors` payload field: {example}"
1118        );
1119        assert!(
1120            example.contains('['),
1121            "agent.feedback example's errors field must be a JSON array (contains `[`): {example}"
1122        );
1123        assert!(
1124            example.contains(']'),
1125            "agent.feedback example's errors field must be a JSON array (contains `]`): {example}"
1126        );
1127        for wrong in ["\"target\"", "\"message\""] {
1128            assert!(
1129                !example.contains(wrong),
1130                "agent.feedback example must not contain the stale field key {wrong}: {example}"
1131            );
1132        }
1133    }
1134
1135    #[test]
1136    fn supervisor_examples_clarify_recipient_vs_sender() {
1137        let tmpl = resolve("supervisor").unwrap();
1138        let lowered = tmpl.content.to_lowercase();
1139
1140        // Verified-section clarification (between the verified heading and the
1141        // feedback heading).
1142        let verified_start = tmpl
1143            .content
1144            .find("### Publish verification outcome")
1145            .expect("verified heading should be present");
1146        let feedback_start = tmpl
1147            .content
1148            .find("### Publish feedback to a peer agent")
1149            .expect("feedback heading should be present");
1150        let verified_section = tmpl.content[verified_start..feedback_start].to_lowercase();
1151        assert!(
1152            verified_section.contains("recipient") && verified_section.contains("sender"),
1153            "verified section should clarify recipient-vs-sender semantics, got: {verified_section}"
1154        );
1155
1156        // Feedback-section clarification (between the feedback heading and the
1157        // next `### ` heading).
1158        let after_feedback =
1159            &tmpl.content[feedback_start + "### Publish feedback to a peer agent".len()..];
1160        let feedback_end_rel = after_feedback
1161            .find("\n### ")
1162            .unwrap_or(after_feedback.len());
1163        let feedback_section = after_feedback[..feedback_end_rel].to_lowercase();
1164        assert!(
1165            feedback_section.contains("recipient") && feedback_section.contains("sender"),
1166            "feedback section should clarify recipient-vs-sender semantics, got: {feedback_section}"
1167        );
1168
1169        // Defensive sanity: the words exist somewhere in the document.
1170        assert!(lowered.contains("recipient"));
1171        assert!(lowered.contains("sender"));
1172    }
1173
1174    #[test]
1175    fn supervisor_workflow_prose_drops_legacy_verified_fields() {
1176        let tmpl = resolve("supervisor").unwrap();
1177        // Strip whitespace inside the matches so a stray space doesn't hide a
1178        // regression like `result : "pass"` or `notes : ""`.
1179        let condensed: String = tmpl
1180            .content
1181            .chars()
1182            .filter(|c| !c.is_whitespace())
1183            .collect();
1184        assert!(
1185            !condensed.contains("result:\"pass\""),
1186            "workflow prose must not reference `result:\"pass\"` as the verified payload"
1187        );
1188        assert!(
1189            !condensed.contains("notes:\"\""),
1190            "workflow prose must not reference `notes:\"\"` as the verified payload"
1191        );
1192    }
1193
1194    // Supervisor skill contains tmux commands targeting the session name
1195    #[test]
1196    fn supervisor_skill_contains_tmux_commands() {
1197        let tmpl = resolve("supervisor").unwrap();
1198        assert!(tmpl.content.contains("tmux capture-pane"));
1199        assert!(tmpl.content.contains("tmux send-keys"));
1200        assert!(tmpl.content.contains("paw-{{PROJECT_NAME}}"));
1201    }
1202
1203    #[test]
1204    fn supervisor_skill_contains_spec_audit_procedure() {
1205        let tmpl = resolve("supervisor").unwrap();
1206        assert!(
1207            tmpl.content.contains("Spec Audit"),
1208            "supervisor skill should contain Spec Audit section"
1209        );
1210        assert!(
1211            tmpl.content.contains("openspec/changes/"),
1212            "should reference openspec/changes/ for spec file discovery"
1213        );
1214        assert!(
1215            tmpl.content.contains("grep"),
1216            "should instruct to grep for matching tests"
1217        );
1218    }
1219
1220    #[test]
1221    fn supervisor_skill_spec_audit_after_test_before_verified() {
1222        let tmpl = resolve("supervisor").unwrap();
1223        let test_pos = tmpl.content.find("Regression check").unwrap_or(0);
1224        let audit_pos = tmpl.content.find("Spec Audit").unwrap_or(0);
1225        let verify_pos = tmpl.content.find("Verify or feedback").unwrap_or(0);
1226        assert!(
1227            audit_pos > test_pos,
1228            "spec audit should appear after test/regression check"
1229        );
1230        assert!(
1231            audit_pos < verify_pos,
1232            "spec audit should appear before verify/feedback"
1233        );
1234    }
1235
1236    // Paste-buffer recovery sub-case under stall detection (prompt-submit-fix).
1237
1238    #[test]
1239    fn supervisor_skill_mentions_paste_buffer_recovery() {
1240        let tmpl = resolve("supervisor").unwrap();
1241        let lowered = tmpl.content.to_lowercase();
1242        assert!(
1243            lowered.contains("paste-buffer") || lowered.contains("paste buffer"),
1244            "supervisor skill should contain paste-buffer recovery sub-case"
1245        );
1246    }
1247
1248    #[test]
1249    fn supervisor_skill_mentions_pasted_text_indicator() {
1250        let tmpl = resolve("supervisor").unwrap();
1251        assert!(
1252            tmpl.content.contains("Pasted text"),
1253            "supervisor skill should mention the Claude Code 'Pasted text' indicator"
1254        );
1255    }
1256
1257    #[test]
1258    fn supervisor_skill_paste_buffer_recovery_uses_tmux() {
1259        let tmpl = resolve("supervisor").unwrap();
1260        let start = tmpl
1261            .content
1262            .to_lowercase()
1263            .find("paste-buffer recovery")
1264            .or_else(|| tmpl.content.to_lowercase().find("paste buffer recovery"))
1265            .expect("paste-buffer recovery sub-case heading should be present");
1266        // Take a window around the heading large enough to cover the
1267        // recovery example (a couple thousand chars now that the sub-case
1268        // also references the proactive launch-time sweep).
1269        let window_end = (start + 2200).min(tmpl.content.len());
1270        let window = &tmpl.content[start..window_end];
1271        // The inspect step now goes through `sweep.sh capture <pane>`; the
1272        // earlier shape `tmux capture-pane …` is still acceptable for
1273        // historical content. Either form satisfies the inspect contract.
1274        assert!(
1275            window.contains(".git-paw/scripts/sweep.sh capture")
1276                || window.contains("tmux capture-pane"),
1277            "paste-buffer recovery should reference a pane-capture command (sweep.sh capture or tmux capture-pane)"
1278        );
1279        assert!(
1280            window.contains("tmux send-keys"),
1281            "paste-buffer recovery should reference tmux send-keys for the Enter recovery"
1282        );
1283        assert!(
1284            window.contains("Enter"),
1285            "paste-buffer recovery should specify Enter as the recovery keystroke"
1286        );
1287    }
1288
1289    #[test]
1290    fn supervisor_skill_mentions_launch_time_sweep() {
1291        let tmpl = resolve("supervisor").unwrap();
1292        let lowered = tmpl.content.to_lowercase();
1293        assert!(
1294            lowered.contains("launch-time pane sweep")
1295                || lowered.contains("launch time pane sweep")
1296                || lowered.contains("launch sweep"),
1297            "supervisor skill should contain a launch-time pane sweep heading"
1298        );
1299    }
1300
1301    #[test]
1302    fn supervisor_skill_launch_sweep_lists_four_pane_categories() {
1303        let tmpl = resolve("supervisor").unwrap();
1304        let lowered = tmpl.content.to_lowercase();
1305        let start = lowered
1306            .find("launch-time pane sweep")
1307            .or_else(|| lowered.find("launch sweep"))
1308            .expect("launch-time pane sweep heading should be present");
1309        let window_end = (start + 2500).min(lowered.len());
1310        let window = &lowered[start..window_end];
1311        assert!(
1312            window.contains("paste-buffer") || window.contains("paste buffer"),
1313            "launch sweep should enumerate paste-buffer category"
1314        );
1315        assert!(
1316            window.contains("permission prompt"),
1317            "launch sweep should enumerate permission-prompt category"
1318        );
1319        assert!(
1320            window.contains("working"),
1321            "launch sweep should enumerate working category"
1322        );
1323        assert!(
1324            window.contains("idle"),
1325            "launch sweep should enumerate idle category"
1326        );
1327    }
1328
1329    #[test]
1330    fn supervisor_skill_launch_sweep_references_down_enter_keystroke() {
1331        let tmpl = resolve("supervisor").unwrap();
1332        let lowered = tmpl.content.to_lowercase();
1333        let start = lowered
1334            .find("launch-time pane sweep")
1335            .or_else(|| lowered.find("launch sweep"))
1336            .expect("launch-time pane sweep heading should be present");
1337        let window_end = (start + 2500).min(lowered.len());
1338        let window = &lowered[start..window_end];
1339        // Safe-command auto-approval uses Down to move to "Yes, don't ask
1340        // again", then Enter to select it. Both keystrokes must be in the
1341        // section so the supervisor agent knows the pattern.
1342        assert!(
1343            window.contains("down"),
1344            "launch sweep should reference the Down keystroke for selecting 'don't ask again'"
1345        );
1346        assert!(
1347            window.contains("enter"),
1348            "launch sweep should reference the Enter keystroke for confirming approval"
1349        );
1350        // Confirm the "don't ask again" phrasing is present so future
1351        // pattern allowlist behavior is documented in the skill.
1352        assert!(
1353            window.contains("don't ask again") || window.contains("don't ask"),
1354            "launch sweep should mention the 'don't ask again' approval option"
1355        );
1356    }
1357
1358    #[test]
1359    fn supervisor_skill_paste_buffer_recovery_is_safe_by_default() {
1360        let tmpl = resolve("supervisor").unwrap();
1361        let lowered = tmpl.content.to_lowercase();
1362        let start = lowered
1363            .find("paste-buffer recovery")
1364            .or_else(|| lowered.find("paste buffer recovery"))
1365            .expect("paste-buffer recovery sub-case heading should be present");
1366        let window_end = (start + 2200).min(lowered.len());
1367        let window = &lowered[start..window_end];
1368        let safe_phrasing = window.contains("safe-by-default")
1369            || window.contains("safe by default")
1370            || window.contains("no-op")
1371            || window.contains("no harm");
1372        assert!(
1373            safe_phrasing,
1374            "paste-buffer recovery should explicitly note the Enter is safe-by-default / no-op / no harm"
1375        );
1376    }
1377
1378    // Governance verification sub-step in the supervisor skill (governance-context §5).
1379
1380    #[test]
1381    fn supervisor_skill_contains_governance_verification() {
1382        let tmpl = resolve("supervisor").unwrap();
1383        assert!(
1384            tmpl.content.contains("Governance verification"),
1385            "supervisor skill should contain 'Governance verification' heading"
1386        );
1387    }
1388
1389    #[test]
1390    fn supervisor_skill_governance_is_substep_of_spec_audit() {
1391        let tmpl = resolve("supervisor").unwrap();
1392        let audit_pos = tmpl
1393            .content
1394            .find("### Spec Audit Procedure")
1395            .expect("Spec Audit Procedure heading must exist");
1396        let gov_pos = tmpl
1397            .content
1398            .find("Governance verification")
1399            .expect("Governance verification must exist");
1400        let conflict_pos = tmpl
1401            .content
1402            .find("### Conflict detection")
1403            .unwrap_or(tmpl.content.len());
1404        assert!(
1405            gov_pos > audit_pos,
1406            "Governance verification should appear inside Spec Audit Procedure (after its heading)"
1407        );
1408        assert!(
1409            gov_pos < conflict_pos,
1410            "Governance verification should appear before the next top-level subsection (Conflict detection), keeping it inside Spec Audit Procedure"
1411        );
1412        assert!(
1413            !tmpl.content.contains("step 7.5"),
1414            "Governance verification must not be framed as a separate 'step 7.5' flow step"
1415        );
1416    }
1417
1418    #[test]
1419    fn supervisor_skill_governance_examples_cover_all_five_docs() {
1420        let tmpl = resolve("supervisor").unwrap();
1421        let gov_pos = tmpl
1422            .content
1423            .find("Governance verification")
1424            .expect("Governance verification section must exist");
1425        // Confine the search to the governance subsection (everything between
1426        // the heading and the next `### ` top-level subsection or EOF).
1427        let after = &tmpl.content[gov_pos..];
1428        let end = after.find("\n### ").unwrap_or(after.len());
1429        let section = &after[..end];
1430        for needle in &["DoD", "ADR", "Security", "Test strategy", "Constitution"] {
1431            assert!(
1432                section.contains(needle),
1433                "governance section should mention `{needle}` as a per-doc example, got:\n{section}"
1434            );
1435        }
1436    }
1437
1438    #[test]
1439    fn supervisor_skill_governance_findings_via_agent_feedback() {
1440        let tmpl = resolve("supervisor").unwrap();
1441        let gov_pos = tmpl
1442            .content
1443            .find("Governance verification")
1444            .expect("Governance verification section must exist");
1445        let after = &tmpl.content[gov_pos..];
1446        let end = after.find("\n### ").unwrap_or(after.len());
1447        let section = &after[..end];
1448        assert!(
1449            section.contains("agent.feedback"),
1450            "governance section must state that findings flow through `agent.feedback`"
1451        );
1452    }
1453
1454    #[test]
1455    fn supervisor_skill_no_governance_gate_tag() {
1456        let tmpl = resolve("supervisor").unwrap();
1457        assert!(
1458            !tmpl.content.contains("[governance-gate:"),
1459            "supervisor skill must not contain the dropped `[governance-gate:<doc>]` tag prefix"
1460        );
1461    }
1462
1463    #[test]
1464    fn supervisor_skill_no_governance_gates_table() {
1465        let tmpl = resolve("supervisor").unwrap();
1466        assert!(
1467            !tmpl.content.contains("[governance.gates]"),
1468            "supervisor skill must not reference the dropped `[governance.gates]` table"
1469        );
1470    }
1471
1472    #[test]
1473    fn supervisor_skill_no_gating_language() {
1474        let tmpl = resolve("supervisor").unwrap();
1475        let lowered = tmpl.content.to_lowercase();
1476        assert!(
1477            !lowered.contains("gating"),
1478            "supervisor skill must not use the language of 'gating'"
1479        );
1480        assert!(
1481            !lowered.contains("blocking on governance failures"),
1482            "supervisor skill must not use the language of 'blocking on governance failures'"
1483        );
1484    }
1485
1486    #[test]
1487    fn supervisor_skill_governance_missing_doc_handling() {
1488        let tmpl = resolve("supervisor").unwrap();
1489        let gov_pos = tmpl
1490            .content
1491            .find("Governance verification")
1492            .expect("Governance verification section must exist");
1493        let after = &tmpl.content[gov_pos..];
1494        let end = after.find("\n### ").unwrap_or(after.len());
1495        let section = &after[..end];
1496        let lowered = section.to_lowercase();
1497        assert!(
1498            lowered.contains("missing"),
1499            "governance section should describe missing-doc handling"
1500        );
1501        assert!(
1502            section.contains("agent.feedback"),
1503            "missing-doc handling should reference `agent.feedback` errors list"
1504        );
1505    }
1506
1507    #[test]
1508    fn supervisor_skill_governance_missing_doc_is_not_distinct_failure_type() {
1509        let tmpl = resolve("supervisor").unwrap();
1510        let gov_pos = tmpl
1511            .content
1512            .find("Governance verification")
1513            .expect("Governance verification section must exist");
1514        let after = &tmpl.content[gov_pos..];
1515        let end = after.find("\n### ").unwrap_or(after.len());
1516        let section = &after[..end];
1517        let lowered = section.to_lowercase();
1518        assert!(
1519            lowered.contains("not a distinct failure")
1520                || lowered.contains("not a separate failure")
1521                || lowered.contains("treat it as a finding"),
1522            "governance section must state that missing files are findings, not a distinct failure type; got:\n{section}"
1523        );
1524    }
1525
1526    #[test]
1527    fn supervisor_skill_governance_states_activation_condition() {
1528        let tmpl = resolve("supervisor").unwrap();
1529        let gov_pos = tmpl
1530            .content
1531            .find("Governance verification")
1532            .expect("Governance verification section must exist");
1533        let after = &tmpl.content[gov_pos..];
1534        let end = after.find("\n### ").unwrap_or(after.len());
1535        let section = &after[..end];
1536        let lowered = section.to_lowercase();
1537        assert!(
1538            lowered.contains("skip"),
1539            "governance section must instruct the supervisor to skip the sub-step when the boot prompt has no `## Governance documents` section; got:\n{section}"
1540        );
1541        assert!(
1542            section.contains("## Governance documents"),
1543            "governance section must reference the boot-prompt heading explicitly as its activation condition; got:\n{section}"
1544        );
1545    }
1546
1547    #[test]
1548    fn supervisor_skill_governance_examples_state_they_are_illustrative() {
1549        let tmpl = resolve("supervisor").unwrap();
1550        let gov_pos = tmpl
1551            .content
1552            .find("Governance verification")
1553            .expect("Governance verification section must exist");
1554        let after = &tmpl.content[gov_pos..];
1555        let end = after.find("\n### ").unwrap_or(after.len());
1556        let section = &after[..end];
1557        let lowered = section.to_lowercase();
1558        assert!(
1559            lowered.contains("illustrative") || lowered.contains("not exhaustive"),
1560            "governance section must state per-doc examples are illustrative / not exhaustive rubrics; got:\n{section}"
1561        );
1562    }
1563
1564    #[test]
1565    fn supervisor_skill_governance_states_judgment_per_project_conventions() {
1566        let tmpl = resolve("supervisor").unwrap();
1567        let gov_pos = tmpl
1568            .content
1569            .find("Governance verification")
1570            .expect("Governance verification section must exist");
1571        let after = &tmpl.content[gov_pos..];
1572        let end = after.find("\n### ").unwrap_or(after.len());
1573        let section = &after[..end];
1574        let lowered = section.to_lowercase();
1575        assert!(
1576            lowered.contains("judgment"),
1577            "governance section must state the supervisor applies judgment; got:\n{section}"
1578        );
1579        assert!(
1580            lowered.contains("convention") || lowered.contains("project"),
1581            "governance section must reference the project's conventions / process when describing judgment; got:\n{section}"
1582        );
1583    }
1584
1585    // governance_section_paths renderer (governance-context §1, §3).
1586
1587    #[test]
1588    fn governance_section_empty_when_all_paths_none() {
1589        let out = governance_section_paths(None, None, None, None, None);
1590        assert!(
1591            out.is_empty(),
1592            "governance_section_paths should return empty string when all paths are None, got: {out:?}"
1593        );
1594    }
1595
1596    #[test]
1597    fn governance_section_one_path_only_dod() {
1598        let dod = Path::new("docs/dod.md");
1599        let out = governance_section_paths(None, None, None, Some(dod), None);
1600        assert!(
1601            out.contains("## Governance documents"),
1602            "section should include the canonical heading, got:\n{out}"
1603        );
1604        assert!(
1605            out.contains("- dod: docs/dod.md"),
1606            "section should include the dod bullet, got:\n{out}"
1607        );
1608        for unset in [
1609            "- adr:",
1610            "- test_strategy:",
1611            "- security:",
1612            "- constitution:",
1613        ] {
1614            assert!(
1615                !out.contains(unset),
1616                "section should not mention `{unset}` when its path is None, got:\n{out}"
1617            );
1618        }
1619    }
1620
1621    #[test]
1622    fn governance_section_lists_all_five_in_canonical_order() {
1623        let adr = Path::new("docs/adr/");
1624        let test_strategy = Path::new("docs/test-strategy.md");
1625        let security = Path::new("docs/security.md");
1626        let dod = Path::new("docs/dod.md");
1627        let constitution = Path::new("docs/constitution.md");
1628        let out = governance_section_paths(
1629            Some(adr),
1630            Some(test_strategy),
1631            Some(security),
1632            Some(dod),
1633            Some(constitution),
1634        );
1635
1636        let order = [
1637            "- adr: docs/adr/",
1638            "- test_strategy: docs/test-strategy.md",
1639            "- security: docs/security.md",
1640            "- dod: docs/dod.md",
1641            "- constitution: docs/constitution.md",
1642        ];
1643        let mut last_pos = 0usize;
1644        for bullet in order {
1645            let idx = out
1646                .find(bullet)
1647                .unwrap_or_else(|| panic!("bullet `{bullet}` not found in:\n{out}"));
1648            assert!(
1649                idx >= last_pos,
1650                "bullets must appear in canonical adr -> test_strategy -> security -> dod -> constitution order; `{bullet}` came before a previous bullet in:\n{out}"
1651            );
1652            last_pos = idx;
1653        }
1654    }
1655
1656    #[test]
1657    fn governance_section_has_no_gates_text() {
1658        let out = governance_section_paths(
1659            Some(Path::new("docs/adr/")),
1660            Some(Path::new("docs/test-strategy.md")),
1661            Some(Path::new("docs/security.md")),
1662            Some(Path::new("docs/dod.md")),
1663            Some(Path::new("docs/constitution.md")),
1664        );
1665        let lowered = out.to_lowercase();
1666        assert!(
1667            !lowered.contains("gated docs"),
1668            "section should not contain a 'Gated docs' line, got:\n{out}"
1669        );
1670        assert!(
1671            !lowered.contains("governance gates"),
1672            "section should not contain a 'Governance gates' sub-section, got:\n{out}"
1673        );
1674        assert!(
1675            !out.contains("[governance.gates]"),
1676            "section should not reference the dropped [governance.gates] table, got:\n{out}"
1677        );
1678        assert!(
1679            !out.contains("[governance-gate:"),
1680            "section should not introduce the dropped [governance-gate:<doc>] tag, got:\n{out}"
1681        );
1682    }
1683
1684    #[test]
1685    fn governance_section_has_preamble_line() {
1686        let out = governance_section_paths(None, None, None, Some(Path::new("docs/dod.md")), None);
1687        let preamble = "The supervisor consults these documents during spec audit.";
1688        assert!(
1689            out.contains(preamble),
1690            "section should include the preamble line; got:\n{out}"
1691        );
1692        // Preamble must come before bullets and after the heading.
1693        let heading_pos = out.find("## Governance documents").unwrap();
1694        let preamble_pos = out.find(preamble).unwrap();
1695        let bullet_pos = out.find("- dod:").unwrap();
1696        assert!(
1697            heading_pos < preamble_pos && preamble_pos < bullet_pos,
1698            "section layout should be heading -> preamble -> bullets; got:\n{out}"
1699        );
1700    }
1701
1702    // {{PROJECT_NAME}} is substituted by render
1703    #[test]
1704    fn project_name_is_substituted() {
1705        let tmpl = SkillTemplate {
1706            name: "test".into(),
1707            content: "session=paw-{{PROJECT_NAME}}".into(),
1708            source: Source::Embedded,
1709            format: SkillFormat::Standardized,
1710            metadata: None,
1711            resource_paths: None,
1712        };
1713        let output = render(
1714            &tmpl,
1715            "feat/x",
1716            "http://127.0.0.1:9119",
1717            "my-app",
1718            &GateCommands::default(),
1719        );
1720        assert!(output.contains("paw-my-app"));
1721        assert!(!output.contains("{{PROJECT_NAME}}"));
1722    }
1723
1724    // Both BRANCH_ID and PROJECT_NAME substituted in the same template
1725    #[test]
1726    fn branch_id_and_project_name_both_substituted() {
1727        let tmpl = SkillTemplate {
1728            name: "test".into(),
1729            content: "agent={{BRANCH_ID}} session=paw-{{PROJECT_NAME}}".into(),
1730            source: Source::Embedded,
1731            format: SkillFormat::Standardized,
1732            metadata: None,
1733            resource_paths: None,
1734        };
1735        let output = render(
1736            &tmpl,
1737            "feat/http-broker",
1738            "url",
1739            "git-paw",
1740            &GateCommands::default(),
1741        );
1742        assert!(output.contains("feat-http-broker"));
1743        assert!(output.contains("paw-git-paw"));
1744        assert!(!output.contains("{{BRANCH_ID}}"));
1745        assert!(!output.contains("{{PROJECT_NAME}}"));
1746    }
1747
1748    // Standardized skill format is detected and loaded
1749    #[test]
1750    #[serial(directory_changes)]
1751    fn standardized_skill_format_is_detected() {
1752        let dir = tempfile::tempdir().unwrap();
1753        let project_dir = dir.path().join("my-project");
1754        std::fs::create_dir_all(&project_dir).unwrap();
1755
1756        let skill_dir = project_dir
1757            .join(".agents")
1758            .join("skills")
1759            .join("test-standardized");
1760        std::fs::create_dir_all(&skill_dir).unwrap();
1761
1762        let skill_md_content = "---\nname: test-standardized\ndescription: A test standardized skill\n---\n\nThis is the skill content with {{BRANCH_ID}} placeholder.";
1763        std::fs::write(skill_dir.join("SKILL.md"), skill_md_content).unwrap();
1764
1765        // Change to project directory
1766        let original_dir = std::env::current_dir().unwrap();
1767        std::env::set_current_dir(&project_dir).unwrap();
1768
1769        let tmpl = resolve("test-standardized").expect("should resolve");
1770        assert_eq!(tmpl.format, SkillFormat::Standardized);
1771        assert!(tmpl.content.contains("This is the skill content"));
1772        assert!(tmpl.content.contains("{{BRANCH_ID}}"));
1773        assert!(tmpl.metadata.is_some());
1774        let metadata = tmpl.metadata.as_ref().unwrap();
1775        assert_eq!(metadata.name, "test-standardized");
1776        assert_eq!(metadata.description, "A test standardized skill");
1777
1778        // Restore original directory
1779        std::env::set_current_dir(original_dir).unwrap();
1780    }
1781
1782    // Standardized skill with resources loads resource paths
1783    #[test]
1784    fn standardized_skill_with_resources_loads_paths() {
1785        let dir = tempfile::tempdir().unwrap();
1786        let skills_parent_dir = dir.path().join("git-paw").join("agent-skills");
1787        let specific_skill_dir = skills_parent_dir.join("test-with-resources");
1788        std::fs::create_dir_all(&specific_skill_dir).unwrap();
1789
1790        // Create skill directory structure
1791        std::fs::create_dir_all(specific_skill_dir.join("scripts")).unwrap();
1792        std::fs::create_dir_all(specific_skill_dir.join("references")).unwrap();
1793        std::fs::create_dir_all(specific_skill_dir.join("assets")).unwrap();
1794
1795        let skill_md_content = "---\nname: test-with-resources\ndescription: Skill with resources\n---\n\nMain content here.";
1796        std::fs::write(specific_skill_dir.join("SKILL.md"), skill_md_content).unwrap();
1797
1798        let tmpl = resolve_with_config_dir("test-with-resources", Some(dir.path()))
1799            .expect("should resolve");
1800        assert_eq!(tmpl.format, SkillFormat::Standardized);
1801        assert!(tmpl.resource_paths.is_some());
1802        let resource_paths = tmpl.resource_paths.as_ref().unwrap();
1803        assert_eq!(resource_paths.len(), 3);
1804        assert!(resource_paths.iter().any(|p| p.ends_with("scripts")));
1805        assert!(resource_paths.iter().any(|p| p.ends_with("references")));
1806        assert!(resource_paths.iter().any(|p| p.ends_with("assets")));
1807    }
1808
1809    // Standard location (.agents/skills/) loading
1810    #[test]
1811    #[serial(directory_changes)]
1812    fn standard_location_loading() {
1813        let temp_dir = tempfile::tempdir().unwrap();
1814        let project_dir = temp_dir.path().join("my-project");
1815        std::fs::create_dir_all(&project_dir).unwrap();
1816
1817        // Create skill in standard location
1818        let standard_skill_dir = project_dir
1819            .join(".agents")
1820            .join("skills")
1821            .join("test-skill");
1822        std::fs::create_dir_all(&standard_skill_dir).unwrap();
1823        let standard_content = "---\nname: test-skill\ndescription: Standard location skill\n---\n\nContent from .agents/skills/";
1824        std::fs::write(standard_skill_dir.join("SKILL.md"), standard_content).unwrap();
1825
1826        // Change to project directory so .agents/skills/ can be found
1827        let original_dir = std::env::current_dir().unwrap();
1828        std::env::set_current_dir(&project_dir).unwrap();
1829
1830        let tmpl = resolve("test-skill").expect("should resolve");
1831
1832        // Should load from standard location
1833        assert_eq!(tmpl.source, Source::AgentsStandard);
1834        assert!(tmpl.content.contains("Content from .agents/skills/"));
1835
1836        // Restore original directory
1837        std::env::set_current_dir(original_dir).unwrap();
1838    }
1839
1840    // Standardized skill metadata placeholders are substituted
1841    #[test]
1842    fn standardized_skill_metadata_placeholders_are_substituted() {
1843        let metadata = StandardizedSkillMetadata {
1844            name: "test-skill".to_string(),
1845            description: "Test description".to_string(),
1846            license: None,
1847            compatibility: None,
1848            metadata: None,
1849        };
1850
1851        let tmpl = SkillTemplate {
1852            name: "test".into(),
1853            content: "Name: {{SKILL_NAME}}, Desc: {{SKILL_DESCRIPTION}}".into(),
1854            source: Source::Embedded,
1855            format: SkillFormat::Standardized,
1856            metadata: Some(metadata),
1857            resource_paths: None,
1858        };
1859
1860        let output = render(
1861            &tmpl,
1862            "feat/x",
1863            "http://127.0.0.1:9119",
1864            "git-paw",
1865            &GateCommands::default(),
1866        );
1867        assert!(output.contains("Name: test-skill, Desc: Test description"));
1868        assert!(!output.contains("{{SKILL_NAME}}"));
1869        assert!(!output.contains("{{SKILL_DESCRIPTION}}"));
1870    }
1871
1872    #[test]
1873    fn test_command_placeholder_substitutes_when_set() {
1874        let tmpl = SkillTemplate {
1875            name: "supervisor".into(),
1876            content: "Run `{{TEST_COMMAND}}` after each merge.".into(),
1877            source: Source::Embedded,
1878            format: SkillFormat::Standardized,
1879            metadata: None,
1880            resource_paths: None,
1881        };
1882        let output = render(
1883            &tmpl,
1884            "supervisor",
1885            "http://127.0.0.1:9119",
1886            "git-paw",
1887            &GateCommands {
1888                test_command: Some("just check"),
1889                ..Default::default()
1890            },
1891        );
1892        assert_eq!(output, "Run `just check` after each merge.");
1893        assert!(!output.contains("{{TEST_COMMAND}}"));
1894    }
1895
1896    #[test]
1897    fn test_command_placeholder_falls_back_when_unset() {
1898        let tmpl = SkillTemplate {
1899            name: "supervisor".into(),
1900            content: "Baseline: {{TEST_COMMAND}}".into(),
1901            source: Source::Embedded,
1902            format: SkillFormat::Standardized,
1903            metadata: None,
1904            resource_paths: None,
1905        };
1906        let output = render(
1907            &tmpl,
1908            "supervisor",
1909            "http://127.0.0.1:9119",
1910            "git-paw",
1911            &GateCommands::default(),
1912        );
1913        assert_eq!(output, "Baseline: (not configured)");
1914        assert!(!output.contains("{{TEST_COMMAND}}"));
1915    }
1916
1917    #[test]
1918    fn supervisor_template_no_unsubstituted_placeholders_when_test_command_set() {
1919        // Regression: rendering the embedded supervisor skill with a configured
1920        // test_command must NOT leave {{TEST_COMMAND}} in the output. Captured
1921        // during a live dogfood run that produced the warning
1922        // "unsubstituted placeholder {{TEST_COMMAND}} in skill 'supervisor'".
1923        //
1924        // `{{CHANGE_ID}}` is a per-invocation placeholder (substituted by the
1925        // supervisor agent, not by render) and is therefore expected to
1926        // survive a render pass.
1927        let tmpl = resolve("supervisor").expect("supervisor skill resolves");
1928        let output = render(
1929            &tmpl,
1930            "supervisor",
1931            "http://127.0.0.1:9119",
1932            "git-paw",
1933            &GateCommands {
1934                test_command: Some("just check"),
1935                ..Default::default()
1936            },
1937        );
1938        assert!(
1939            !output.contains("{{TEST_COMMAND}}"),
1940            "supervisor template still contains a literal {{TEST_COMMAND}} after render"
1941        );
1942        let remaining: String = output.replace("{{CHANGE_ID}}", "").chars().collect();
1943        assert!(
1944            !remaining.contains("{{"),
1945            "supervisor template has unsubstituted {{...}} placeholder (other than {{CHANGE_ID}}) after render"
1946        );
1947    }
1948
1949    // --- Gate-command placeholder substitution (supervisor-gate-templating-v0-5-x) ---
1950
1951    /// Helper: render `template` with all gate placeholders set to the same
1952    /// `Some(value)` or all `None`.
1953    fn render_with_gates_uniform(template: &str, value: Option<&str>) -> String {
1954        let tmpl = SkillTemplate {
1955            name: "supervisor".into(),
1956            content: template.into(),
1957            source: Source::Embedded,
1958            format: SkillFormat::Standardized,
1959            metadata: None,
1960            resource_paths: None,
1961        };
1962        let gates = GateCommands {
1963            test_command: value,
1964            lint_command: value,
1965            build_command: value,
1966            doc_build_command: value,
1967            spec_validate_command: value,
1968            fmt_check_command: value,
1969            security_audit_command: value,
1970        };
1971        render(
1972            &tmpl,
1973            "supervisor",
1974            "http://127.0.0.1:9119",
1975            "git-paw",
1976            &gates,
1977        )
1978    }
1979
1980    #[test]
1981    fn render_test_command_placeholder_substitutes_from_config() {
1982        let tmpl = SkillTemplate {
1983            name: "supervisor".into(),
1984            content: "Run {{TEST_COMMAND}}.".into(),
1985            source: Source::Embedded,
1986            format: SkillFormat::Standardized,
1987            metadata: None,
1988            resource_paths: None,
1989        };
1990        let gates = GateCommands {
1991            test_command: Some("just check"),
1992            ..Default::default()
1993        };
1994        let output = render(
1995            &tmpl,
1996            "supervisor",
1997            "http://127.0.0.1:9119",
1998            "git-paw",
1999            &gates,
2000        );
2001        assert!(
2002            output.contains("Run just check."),
2003            "expected 'Run just check.' in: {output}"
2004        );
2005    }
2006
2007    #[test]
2008    fn render_test_command_placeholder_none_renders_not_configured() {
2009        let output = render_with_gates_uniform("Run {{TEST_COMMAND}}.", None);
2010        assert!(
2011            output.contains("Run (not configured)."),
2012            "expected 'Run (not configured).' in: {output}"
2013        );
2014    }
2015
2016    #[test]
2017    fn render_lint_command_placeholder_substitutes_and_none_fallback() {
2018        let tmpl = SkillTemplate {
2019            name: "supervisor".into(),
2020            content: "Run {{LINT_COMMAND}}.".into(),
2021            source: Source::Embedded,
2022            format: SkillFormat::Standardized,
2023            metadata: None,
2024            resource_paths: None,
2025        };
2026        let gates = GateCommands {
2027            lint_command: Some("cargo clippy -- -D warnings"),
2028            ..Default::default()
2029        };
2030        let output = render(
2031            &tmpl,
2032            "supervisor",
2033            "http://127.0.0.1:9119",
2034            "git-paw",
2035            &gates,
2036        );
2037        assert!(
2038            output.contains("Run cargo clippy -- -D warnings."),
2039            "expected substitution in: {output}"
2040        );
2041
2042        let none_output = render_with_gates_uniform("Run {{LINT_COMMAND}}.", None);
2043        assert!(
2044            none_output.contains("Run (not configured)."),
2045            "expected '(not configured)' fallback in: {none_output}"
2046        );
2047    }
2048
2049    #[test]
2050    fn render_build_command_placeholder_substitutes_and_none_fallback() {
2051        let tmpl = SkillTemplate {
2052            name: "supervisor".into(),
2053            content: "Run {{BUILD_COMMAND}}.".into(),
2054            source: Source::Embedded,
2055            format: SkillFormat::Standardized,
2056            metadata: None,
2057            resource_paths: None,
2058        };
2059        let gates = GateCommands {
2060            build_command: Some("cargo build"),
2061            ..Default::default()
2062        };
2063        let output = render(
2064            &tmpl,
2065            "supervisor",
2066            "http://127.0.0.1:9119",
2067            "git-paw",
2068            &gates,
2069        );
2070        assert!(output.contains("Run cargo build."), "got: {output}");
2071
2072        let none_output = render_with_gates_uniform("Run {{BUILD_COMMAND}}.", None);
2073        assert!(
2074            none_output.contains("Run (not configured)."),
2075            "got: {none_output}"
2076        );
2077    }
2078
2079    #[test]
2080    fn render_doc_build_command_placeholder_substitutes_and_none_fallback() {
2081        let tmpl = SkillTemplate {
2082            name: "supervisor".into(),
2083            content: "Run {{DOC_BUILD_COMMAND}}.".into(),
2084            source: Source::Embedded,
2085            format: SkillFormat::Standardized,
2086            metadata: None,
2087            resource_paths: None,
2088        };
2089        let gates = GateCommands {
2090            doc_build_command: Some("mdbook build docs/"),
2091            ..Default::default()
2092        };
2093        let output = render(
2094            &tmpl,
2095            "supervisor",
2096            "http://127.0.0.1:9119",
2097            "git-paw",
2098            &gates,
2099        );
2100        assert!(output.contains("Run mdbook build docs/."), "got: {output}");
2101
2102        let none_output = render_with_gates_uniform("Run {{DOC_BUILD_COMMAND}}.", None);
2103        assert!(
2104            none_output.contains("Run (not configured)."),
2105            "got: {none_output}"
2106        );
2107    }
2108
2109    #[test]
2110    fn render_spec_validate_command_placeholder_substitutes_and_none_fallback() {
2111        let tmpl = SkillTemplate {
2112            name: "supervisor".into(),
2113            content: "Run {{SPEC_VALIDATE_COMMAND}}.".into(),
2114            source: Source::Embedded,
2115            format: SkillFormat::Standardized,
2116            metadata: None,
2117            resource_paths: None,
2118        };
2119        let gates = GateCommands {
2120            spec_validate_command: Some("openspec validate {{CHANGE_ID}} --strict"),
2121            ..Default::default()
2122        };
2123        let output = render(
2124            &tmpl,
2125            "supervisor",
2126            "http://127.0.0.1:9119",
2127            "git-paw",
2128            &gates,
2129        );
2130        assert!(
2131            output.contains("Run openspec validate {{CHANGE_ID}} --strict."),
2132            "got: {output}"
2133        );
2134
2135        let none_output = render_with_gates_uniform("Run {{SPEC_VALIDATE_COMMAND}}.", None);
2136        assert!(
2137            none_output.contains("Run (not configured)."),
2138            "got: {none_output}"
2139        );
2140    }
2141
2142    #[test]
2143    fn render_fmt_check_command_placeholder_substitutes_and_none_fallback() {
2144        let tmpl = SkillTemplate {
2145            name: "supervisor".into(),
2146            content: "Run {{FMT_CHECK_COMMAND}}.".into(),
2147            source: Source::Embedded,
2148            format: SkillFormat::Standardized,
2149            metadata: None,
2150            resource_paths: None,
2151        };
2152        let gates = GateCommands {
2153            fmt_check_command: Some("cargo fmt --check"),
2154            ..Default::default()
2155        };
2156        let output = render(
2157            &tmpl,
2158            "supervisor",
2159            "http://127.0.0.1:9119",
2160            "git-paw",
2161            &gates,
2162        );
2163        assert!(output.contains("Run cargo fmt --check."), "got: {output}");
2164
2165        let none_output = render_with_gates_uniform("Run {{FMT_CHECK_COMMAND}}.", None);
2166        assert!(
2167            none_output.contains("Run (not configured)."),
2168            "got: {none_output}"
2169        );
2170    }
2171
2172    #[test]
2173    fn render_security_audit_command_placeholder_substitutes_and_none_fallback() {
2174        let tmpl = SkillTemplate {
2175            name: "supervisor".into(),
2176            content: "Run {{SECURITY_AUDIT_COMMAND}}.".into(),
2177            source: Source::Embedded,
2178            format: SkillFormat::Standardized,
2179            metadata: None,
2180            resource_paths: None,
2181        };
2182        let gates = GateCommands {
2183            security_audit_command: Some("cargo audit"),
2184            ..Default::default()
2185        };
2186        let output = render(
2187            &tmpl,
2188            "supervisor",
2189            "http://127.0.0.1:9119",
2190            "git-paw",
2191            &gates,
2192        );
2193        assert!(output.contains("Run cargo audit."), "got: {output}");
2194
2195        let none_output = render_with_gates_uniform("Run {{SECURITY_AUDIT_COMMAND}}.", None);
2196        assert!(
2197            none_output.contains("Run (not configured)."),
2198            "got: {none_output}"
2199        );
2200    }
2201
2202    #[test]
2203    fn supervisor_skill_renders_with_all_six_gate_placeholders_set() {
2204        // With distinct Some("CMD-N") values, the rendered supervisor skill
2205        // contains each CMD-N value (proving the gate prose references the
2206        // placeholders, not hardcoded git-paw commands).
2207        let tmpl = resolve("supervisor").expect("supervisor skill resolves");
2208        let gates = GateCommands {
2209            test_command: Some("CMD-TEST"),
2210            lint_command: Some("CMD-LINT"),
2211            build_command: Some("CMD-BUILD"),
2212            doc_build_command: Some("CMD-DOC"),
2213            spec_validate_command: Some("CMD-SPEC"),
2214            fmt_check_command: Some("CMD-FMT"),
2215            security_audit_command: Some("CMD-SEC"),
2216        };
2217        let output = render(
2218            &tmpl,
2219            "supervisor",
2220            "http://127.0.0.1:9119",
2221            "git-paw",
2222            &gates,
2223        );
2224        for needle in [
2225            "CMD-TEST",
2226            "CMD-LINT",
2227            "CMD-BUILD",
2228            "CMD-DOC",
2229            "CMD-SPEC",
2230            "CMD-FMT",
2231            "CMD-SEC",
2232        ] {
2233            assert!(
2234                output.contains(needle),
2235                "rendered supervisor skill should contain '{needle}'; not found"
2236            );
2237        }
2238    }
2239
2240    #[test]
2241    fn supervisor_skill_renders_not_configured_in_each_gate_when_none() {
2242        // With all placeholders None, every gate section in the rendered
2243        // skill must show '(not configured)' so the supervisor agent can
2244        // recognise the gate as having no tooling-aided phase.
2245        let tmpl = resolve("supervisor").expect("supervisor skill resolves");
2246        let output = render(
2247            &tmpl,
2248            "supervisor",
2249            "http://127.0.0.1:9119",
2250            "git-paw",
2251            &GateCommands::default(),
2252        );
2253
2254        // Gate 1 (Testing) section.
2255        let testing_start = output.find("**Testing**").expect("Testing gate present");
2256        let testing_end = output[testing_start..]
2257            .find("**Regression analysis**")
2258            .map(|p| testing_start + p)
2259            .expect("Regression follows Testing");
2260        let testing_section = &output[testing_start..testing_end];
2261        assert!(
2262            testing_section.contains("(not configured)"),
2263            "Testing gate should render '(not configured)' when gate fields are None; got:\n{testing_section}"
2264        );
2265
2266        // Gate 3 (Spec audit).
2267        let spec_start = output.find("**Spec audit**").expect("Spec audit present");
2268        let spec_end = output[spec_start..]
2269            .find("**Doc audit**")
2270            .map(|p| spec_start + p)
2271            .expect("Doc audit follows Spec audit");
2272        let spec_section = &output[spec_start..spec_end];
2273        assert!(
2274            spec_section.contains("(not configured)"),
2275            "Spec audit gate should render '(not configured)' when None; got:\n{spec_section}"
2276        );
2277
2278        // Gate 4 (Doc audit).
2279        let doc_start = output.find("**Doc audit**").expect("Doc audit present");
2280        let doc_end = output[doc_start..]
2281            .find("**Security audit**")
2282            .map(|p| doc_start + p)
2283            .expect("Security audit follows Doc audit");
2284        let doc_section = &output[doc_start..doc_end];
2285        assert!(
2286            doc_section.contains("(not configured)"),
2287            "Doc audit gate should render '(not configured)' when None; got:\n{doc_section}"
2288        );
2289
2290        // Gate 5 (Security audit).
2291        let security_start = output
2292            .find("**Security audit**")
2293            .expect("Security audit present");
2294        let security_end = output[security_start..]
2295            .find("**Verify or feedback**")
2296            .map(|p| security_start + p)
2297            .expect("Verify-or-feedback follows Security audit");
2298        let security_section = &output[security_start..security_end];
2299        assert!(
2300            security_section.contains("(not configured)"),
2301            "Security audit gate should render '(not configured)' when None; got:\n{security_section}"
2302        );
2303    }
2304
2305    /// Pre-render audit: the embedded supervisor template must not hardcode
2306    /// `just check`, `cargo test`, `cargo clippy`, `cargo audit`,
2307    /// `cargo fmt --check`, `mdbook build`, or `openspec validate` in its
2308    /// gate prose. Matches inside fenced code blocks demonstrating example
2309    /// config values (e.g. `# test_command = "just check"`) are tolerated:
2310    /// the audit windows are the §4-§7 gate-prose paragraphs only.
2311    #[test]
2312    fn supervisor_template_gate_prose_has_no_hardcoded_git_paw_commands() {
2313        let tmpl = resolve("supervisor").expect("supervisor skill resolves");
2314        let content = &tmpl.content;
2315        let start = content
2316            .find("Steps 4-7 below are the **five first-class verification gates**")
2317            .expect("five-gate intro present");
2318        let end = content
2319            .find("### Spec Audit Procedure")
2320            .expect("Spec Audit Procedure heading present");
2321        let gate_prose = &content[start..end];
2322        for needle in [
2323            "just check",
2324            "cargo test",
2325            "cargo clippy",
2326            "cargo audit",
2327            "cargo fmt --check",
2328            "mdbook build",
2329        ] {
2330            // The §7 agent.feedback example body intentionally contains the
2331            // string `cargo test failed: ...` as an illustration of error
2332            // reporting. The example may be written either with brackets
2333            // (`[testing] cargo test failed`, the historical wire-format
2334            // shape) or via the helper invocation
2335            // (`feedback-gate ... testing "cargo test failed`, the v0.5.0
2336            // helper-call shape). We allow both.
2337            if needle == "cargo test"
2338                && (gate_prose.contains("[testing] cargo test failed")
2339                    || gate_prose.contains("testing \"cargo test failed"))
2340            {
2341                let cleaned = gate_prose.replace("cargo test failed", "<failure>");
2342                assert!(
2343                    !cleaned.contains("cargo test"),
2344                    "gate prose must not contain hardcoded 'cargo test' outside the §7 example"
2345                );
2346                continue;
2347            }
2348            assert!(
2349                !gate_prose.contains(needle),
2350                "gate prose must not contain hardcoded '{needle}'; replace with the matching placeholder"
2351            );
2352        }
2353    }
2354
2355    #[test]
2356    fn render_change_id_placeholder_passes_through() {
2357        let tmpl = SkillTemplate {
2358            name: "supervisor".into(),
2359            content: "Run {{SPEC_VALIDATE_COMMAND}}.".into(),
2360            source: Source::Embedded,
2361            format: SkillFormat::Standardized,
2362            metadata: None,
2363            resource_paths: None,
2364        };
2365        let gates = GateCommands {
2366            spec_validate_command: Some("openspec validate {{CHANGE_ID}} --strict"),
2367            ..Default::default()
2368        };
2369        let output = render(
2370            &tmpl,
2371            "supervisor",
2372            "http://127.0.0.1:9119",
2373            "git-paw",
2374            &gates,
2375        );
2376        assert!(
2377            output.contains("Run openspec validate {{CHANGE_ID}} --strict."),
2378            "outer placeholder substituted but inner {{CHANGE_ID}} preserved; got: {output}"
2379        );
2380        assert!(
2381            output.contains("{{CHANGE_ID}}"),
2382            "{{CHANGE_ID}} must survive verbatim (not substituted at render time); got: {output}"
2383        );
2384    }
2385
2386    // Invalid standardized skill frontmatter returns validation error
2387    #[test]
2388    fn invalid_standardized_skill_frontmatter_returns_error() {
2389        let dir = tempfile::tempdir().unwrap();
2390        let project_dir = dir.path().join("my-project");
2391        std::fs::create_dir_all(&project_dir).unwrap();
2392
2393        let skill_dir = project_dir
2394            .join(".agents")
2395            .join("skills")
2396            .join("invalid-skill");
2397        std::fs::create_dir_all(&skill_dir).unwrap();
2398
2399        // Missing required 'description' field
2400        let skill_md_content = "---\nname: invalid-skill\n---\n\nContent here.";
2401        std::fs::write(skill_dir.join("SKILL.md"), skill_md_content).unwrap();
2402
2403        // Change to project directory
2404        let original_dir = std::env::current_dir().unwrap();
2405        std::env::set_current_dir(&project_dir).unwrap();
2406
2407        let result = resolve("invalid-skill");
2408        assert!(matches!(result, Err(SkillError::ValidationError { .. })));
2409
2410        // Restore original directory
2411        std::env::set_current_dir(original_dir).unwrap();
2412    }
2413
2414    // 9.17: SkillTemplate is cloneable
2415    #[test]
2416    fn skill_template_is_cloneable() {
2417        let tmpl = resolve("coordination").unwrap();
2418        let cloned = tmpl.clone();
2419        assert_eq!(tmpl.name, cloned.name);
2420        assert_eq!(tmpl.content, cloned.content);
2421        assert_eq!(tmpl.source, cloned.source);
2422    }
2423
2424    // Boot block function tests
2425    #[test]
2426    fn boot_block_contains_all_four_essential_events() {
2427        let block = build_boot_block("feat/errors", "http://localhost:9119");
2428        assert!(
2429            block.contains("### 1. REGISTER"),
2430            "Missing REGISTER section"
2431        );
2432        assert!(block.contains("### 2. DONE"), "Missing DONE section");
2433        assert!(block.contains("### 3. BLOCKED"), "Missing BLOCKED section");
2434        assert!(
2435            block.contains("### 4. QUESTION"),
2436            "Missing QUESTION section"
2437        );
2438    }
2439
2440    #[test]
2441    fn boot_block_substitutes_branch_id_placeholder() {
2442        let block = build_boot_block("Feature/HTTP_Broker", "http://localhost:9119");
2443        assert!(
2444            block.contains("feature-http_broker"),
2445            "Branch ID not properly slugified"
2446        );
2447        assert!(
2448            !block.contains("{{BRANCH_ID}}"),
2449            "BRANCH_ID placeholder not substituted"
2450        );
2451    }
2452
2453    #[test]
2454    fn boot_block_substitutes_broker_url_placeholder() {
2455        let block = build_boot_block("feat/x", "http://127.0.0.1:9119");
2456        assert!(
2457            block.contains("http://127.0.0.1:9119/publish"),
2458            "Broker URL not substituted"
2459        );
2460        assert!(
2461            !block.contains("{{GIT_PAW_BROKER_URL}}"),
2462            "GIT_PAW_BROKER_URL placeholder not substituted"
2463        );
2464    }
2465
2466    #[test]
2467    fn boot_block_contains_paste_handling_instructions() {
2468        let block = build_boot_block("feat/x", "http://localhost:9119");
2469        assert!(
2470            block.contains("PASTE HANDLING"),
2471            "Missing paste handling section"
2472        );
2473        assert!(
2474            block.contains("additional Enter key"),
2475            "Missing Enter key instruction"
2476        );
2477        assert!(
2478            block.contains("[Pasted text #N]"),
2479            "Missing paste text reference"
2480        );
2481    }
2482
2483    #[test]
2484    fn boot_block_question_section_emphasizes_waiting() {
2485        let block = build_boot_block("feat/x", "http://localhost:9119");
2486        assert!(
2487            block.contains("DO NOT CONTINUE UNTIL YOU RECEIVE AN ANSWER!"),
2488            "Missing wait emphasis"
2489        );
2490        assert!(
2491            block.contains("WAIT for the answer before continuing"),
2492            "Missing wait instruction"
2493        );
2494    }
2495
2496    #[test]
2497    fn boot_block_is_deterministic() {
2498        let a = build_boot_block("feat/x", "http://localhost:9119");
2499        let b = build_boot_block("feat/x", "http://localhost:9119");
2500        assert_eq!(a, b, "Boot block generation should be deterministic");
2501    }
2502
2503    #[test]
2504    fn boot_block_handles_complex_branch_names() {
2505        let block = build_boot_block("fix/topological-cycle-fallback", "http://localhost:9119");
2506        assert!(
2507            block.contains("fix-topological-cycle-fallback"),
2508            "Complex branch name not properly slugified"
2509        );
2510    }
2511
2512    #[test]
2513    fn boot_block_contains_pre_expanded_curl_commands() {
2514        let block = build_boot_block("feat/test", "http://127.0.0.1:9119");
2515
2516        // Check that all curl commands have the actual URL substituted
2517        assert!(
2518            block.contains("curl -s -X POST http://127.0.0.1:9119/publish"),
2519            "Curl commands not pre-expanded"
2520        );
2521
2522        // Check that all curl commands have the actual branch ID substituted
2523        assert!(
2524            block.contains("\"agent_id\":\"feat-test\""),
2525            "Agent ID not substituted in curl commands"
2526        );
2527    }
2528
2529    fn done_section_body(block: &str) -> String {
2530        let start = block
2531            .find("### 2. DONE")
2532            .expect("rendered boot block should contain the DONE section heading");
2533        let end = block
2534            .find("### 3. BLOCKED")
2535            .expect("rendered boot block should contain the BLOCKED section heading");
2536        block[start..end].to_string()
2537    }
2538
2539    #[test]
2540    fn boot_block_done_section_leads_with_commit_instruction() {
2541        let block = build_boot_block("feat/test", "http://127.0.0.1:9119");
2542        let done_body = done_section_body(&block);
2543
2544        let commit_idx = done_body
2545            .find("commit your work")
2546            .or_else(|| done_body.find("git commit"))
2547            .expect("DONE section should lead with a commit-first instruction");
2548
2549        let manual_done_idx = done_body
2550            .find("\"status\":\"done\"")
2551            .expect("DONE section should still contain the manual done curl as a fallback");
2552
2553        assert!(
2554            commit_idx < manual_done_idx,
2555            "commit-first instruction (byte {commit_idx}) must appear before the manual done curl (byte {manual_done_idx})"
2556        );
2557    }
2558
2559    #[test]
2560    fn boot_block_done_section_names_committed_status_published_by_hook() {
2561        let block = build_boot_block("feat/test", "http://127.0.0.1:9119");
2562        let done_body = done_section_body(&block);
2563
2564        assert!(
2565            done_body.contains("status: \"committed\"")
2566                || done_body.contains("status:\"committed\""),
2567            "DONE section should name the `status: \"committed\"` event published by the hook"
2568        );
2569        assert!(
2570            done_body.contains("post-commit hook"),
2571            "DONE section should mention the post-commit hook that publishes on the agent's behalf"
2572        );
2573    }
2574
2575    #[test]
2576    fn boot_block_done_section_scopes_manual_done_to_code_less_tasks() {
2577        let block = build_boot_block("feat/test", "http://127.0.0.1:9119");
2578        let done_body = done_section_body(&block);
2579
2580        let hits = ["docs-only", "planning", "exploration"]
2581            .iter()
2582            .filter(|needle| done_body.contains(*needle))
2583            .count();
2584        assert!(
2585            hits >= 2,
2586            "DONE section should enumerate at least two code-less task examples \
2587             (docs-only / planning / exploration); only {hits} present"
2588        );
2589    }
2590
2591    #[test]
2592    fn boot_block_done_section_warns_against_manual_done_with_uncommitted_changes() {
2593        let block = build_boot_block("feat/test", "http://127.0.0.1:9119");
2594        let done_body = done_section_body(&block);
2595
2596        assert!(
2597            done_body.contains("uncommitted"),
2598            "DONE section should warn about uncommitted changes"
2599        );
2600        assert!(
2601            done_body.contains("manual `done`") || done_body.contains("manual done"),
2602            "DONE section warning should reference manual `done`"
2603        );
2604        assert!(
2605            done_body.contains("**WARNING") || done_body.contains("**DO NOT"),
2606            "DONE section warning should be emphasised with bold markers (**...**)"
2607        );
2608    }
2609
2610    #[test]
2611    fn boot_block_done_section_retains_manual_done_curl() {
2612        let block = build_boot_block("feat/test", "http://127.0.0.1:9119");
2613        let done_body = done_section_body(&block);
2614
2615        assert!(
2616            done_body.contains("curl -s -X POST http://127.0.0.1:9119/publish"),
2617            "DONE section should retain the pre-expanded broker curl"
2618        );
2619        assert!(
2620            done_body.contains("\"type\":\"agent.artifact\""),
2621            "DONE section curl should publish an agent.artifact event"
2622        );
2623        assert!(
2624            done_body.contains("\"status\":\"done\""),
2625            "DONE section curl should still publish status: done as the manual fallback"
2626        );
2627        assert!(
2628            done_body.contains("\"exports\":[]"),
2629            "DONE section curl should retain the exports field"
2630        );
2631        assert!(
2632            done_body.contains("\"modified_files\":[]"),
2633            "DONE section curl should retain the modified_files field"
2634        );
2635    }
2636
2637    // -----------------------------------------------------------------
2638    // conflict-detection skill content (v0.5.0)
2639    // -----------------------------------------------------------------
2640
2641    #[test]
2642    fn supervisor_skill_contains_conflict_detector_tag() {
2643        let tmpl = resolve("supervisor").unwrap();
2644        assert!(
2645            tmpl.content.contains("[conflict-detector]"),
2646            "supervisor skill should reference the [conflict-detector] tag"
2647        );
2648    }
2649
2650    #[test]
2651    fn supervisor_skill_documents_broker_side_detection() {
2652        let tmpl = resolve("supervisor").unwrap();
2653        let lowered = tmpl.content.to_lowercase();
2654        assert!(
2655            lowered.contains("auto-detect") || lowered.contains("auto-emit"),
2656            "skill should mention auto-detection/auto-emission by the broker"
2657        );
2658        assert!(
2659            lowered.contains("forward conflict"),
2660            "skill should mention forward conflict"
2661        );
2662        assert!(
2663            lowered.contains("in-flight conflict"),
2664            "skill should mention in-flight conflict"
2665        );
2666        assert!(
2667            lowered.contains("ownership violation"),
2668            "skill should mention ownership violation"
2669        );
2670    }
2671
2672    #[test]
2673    fn supervisor_skill_removes_v04_manual_conflict_detection() {
2674        let tmpl = resolve("supervisor").unwrap();
2675        assert!(
2676            !tmpl
2677                .content
2678                .contains("Compare the `modified_files` arrays from every `agent.artifact` event"),
2679            "supervisor skill should no longer contain the v0.4 manual conflict-comparison instructions"
2680        );
2681    }
2682
2683    #[test]
2684    fn supervisor_skill_mentions_agent_intent() {
2685        let tmpl = resolve("supervisor").unwrap();
2686        assert!(tmpl.content.contains("agent.intent"));
2687        assert!(
2688            tmpl.content.contains("Watch peer intents")
2689                || tmpl
2690                    .content
2691                    .contains("Watch peer intents and broker-side conflict detection"),
2692            "skill should contain a 'Watch peer intents' heading"
2693        );
2694    }
2695
2696    #[test]
2697    fn supervisor_skill_focuses_on_question_escalations() {
2698        let tmpl = resolve("supervisor").unwrap();
2699        let lowered = tmpl.content.to_lowercase();
2700        // The supervisor agent's role on detector output is to react to
2701        // agent.question escalations and follow up on repeat offenders.
2702        assert!(
2703            lowered.contains("agent.question")
2704                && (lowered.contains("escalation") || lowered.contains("escalat")),
2705            "skill should direct the supervisor agent at agent.question escalations"
2706        );
2707        assert!(
2708            lowered.contains("do not") && lowered.contains("manually"),
2709            "skill should tell the supervisor not to duplicate by manual comparison"
2710        );
2711    }
2712
2713    // --- Spec Kit consolidated worktree section (`spec-kit-format` change) ---
2714
2715    #[test]
2716    fn embedded_coordination_mentions_spec_kit_consolidated_worktrees() {
2717        let tmpl = resolve("coordination").unwrap();
2718        assert!(
2719            tmpl.content.contains("Spec Kit")
2720                && (tmpl.content.contains("consolidated") || tmpl.content.contains("phase/")),
2721            "coordination skill should mention Spec Kit consolidated worktrees"
2722        );
2723    }
2724
2725    #[test]
2726    fn embedded_coordination_instructs_sequential_work_and_writeback() {
2727        let tmpl = resolve("coordination").unwrap();
2728        assert!(
2729            tmpl.content.contains("sequential") || tmpl.content.contains("Sequential"),
2730            "should instruct sequential execution"
2731        );
2732        assert!(
2733            tmpl.content.contains("`- [x]`") || tmpl.content.contains("- [x]"),
2734            "should mention - [x] writeback"
2735        );
2736        assert!(
2737            tmpl.content.contains("tasks.md"),
2738            "should reference tasks.md as writeback target"
2739        );
2740    }
2741
2742    #[test]
2743    fn embedded_coordination_states_agent_done_timing_for_consolidated() {
2744        let tmpl = resolve("coordination").unwrap();
2745        assert!(
2746            tmpl.content.contains("agent.done"),
2747            "should mention agent.done"
2748        );
2749        let lower = tmpl.content.to_lowercase();
2750        assert!(
2751            lower.contains("every task")
2752                || lower.contains("all listed tasks")
2753                || lower.contains("all tasks"),
2754            "should tie agent.done to completion of all listed tasks"
2755        );
2756    }
2757
2758    #[test]
2759    fn embedded_coordination_clarifies_p_worktrees_follow_standard_pattern() {
2760        let tmpl = resolve("coordination").unwrap();
2761        assert!(
2762            tmpl.content.contains("[P]") || tmpl.content.contains("task/"),
2763            "should distinguish [P] / task/ worktrees from consolidated ones"
2764        );
2765        assert!(
2766            tmpl.content.contains("standard"),
2767            "should reference the standard before/while-editing pattern"
2768        );
2769    }
2770
2771    // -----------------------------------------------------------------------
2772    // supervisor-as-pane (v0.5.0) — interactive user input + merge orchestration
2773    // -----------------------------------------------------------------------
2774
2775    /// section heading.
2776    #[test]
2777    fn supervisor_skill_has_user_input_section() {
2778        let tmpl = resolve("supervisor").unwrap();
2779        assert!(
2780            tmpl.content.contains("When the user types in your pane"),
2781            "supervisor skill should include the 'When the user types in your pane' section"
2782        );
2783    }
2784
2785    /// 8.2 — user-input section maps directives to `agent.feedback`.
2786    #[test]
2787    fn supervisor_skill_user_input_uses_agent_feedback_for_directives() {
2788        let tmpl = resolve("supervisor").unwrap();
2789        let start = tmpl
2790            .content
2791            .find("When the user types in your pane")
2792            .expect("user-input section heading present");
2793        let window = &tmpl.content[start..];
2794        assert!(
2795            window.contains("agent.feedback"),
2796            "user-input directives section should reference agent.feedback"
2797        );
2798    }
2799
2800    /// 8.3 — user-input section maps judgment-call asks to `agent.question`.
2801    #[test]
2802    fn supervisor_skill_user_input_uses_agent_question_for_judgment_calls() {
2803        let tmpl = resolve("supervisor").unwrap();
2804        let start = tmpl
2805            .content
2806            .find("When the user types in your pane")
2807            .expect("user-input section heading present");
2808        let window = &tmpl.content[start..];
2809        assert!(
2810            window.contains("agent.question"),
2811            "user-input judgment-call section should reference agent.question"
2812        );
2813    }
2814
2815    /// 8.4 — user-input section states the autonomous loop continues.
2816    #[test]
2817    fn supervisor_skill_user_input_states_loop_continues() {
2818        let tmpl = resolve("supervisor").unwrap();
2819        let start = tmpl
2820            .content
2821            .find("When the user types in your pane")
2822            .expect("user-input section heading present");
2823        let window = &tmpl.content[start..];
2824        assert!(
2825            window.to_lowercase().contains("autonomous"),
2826            "user-input section should state the autonomous loop continues alongside user input"
2827        );
2828    }
2829
2830    /// 8.5 — supervisor skill contains the "Merge orchestration" section.
2831    #[test]
2832    fn supervisor_skill_has_merge_orchestration_section() {
2833        let tmpl = resolve("supervisor").unwrap();
2834        assert!(
2835            tmpl.content.contains("Merge orchestration"),
2836            "supervisor skill should include the 'Merge orchestration' section"
2837        );
2838    }
2839
2840    /// 8.6 — merge orchestration uses `git merge --ff-only`.
2841    #[test]
2842    fn supervisor_skill_merge_uses_ff_only() {
2843        let tmpl = resolve("supervisor").unwrap();
2844        let start = tmpl
2845            .content
2846            .find("Merge orchestration")
2847            .expect("merge orchestration section present");
2848        let window = &tmpl.content[start..];
2849        assert!(
2850            window.contains("git merge --ff-only"),
2851            "merge orchestration should specify git merge --ff-only"
2852        );
2853    }
2854
2855    /// revert.
2856    #[test]
2857    fn supervisor_skill_merge_reverts_via_reset_hard() {
2858        let tmpl = resolve("supervisor").unwrap();
2859        let start = tmpl
2860            .content
2861            .find("Merge orchestration")
2862            .expect("merge orchestration section present");
2863        let window = &tmpl.content[start..];
2864        assert!(
2865            window.contains("git reset --hard"),
2866            "merge orchestration should describe regression revert via git reset --hard"
2867        );
2868    }
2869
2870    /// `agent.question`.
2871    #[test]
2872    fn supervisor_skill_merge_cycle_uses_agent_question() {
2873        let tmpl = resolve("supervisor").unwrap();
2874        let start = tmpl
2875            .content
2876            .find("Merge orchestration")
2877            .expect("merge orchestration section present");
2878        let window = &tmpl.content[start..];
2879        assert!(
2880            window.contains("agent.question") && window.to_lowercase().contains("cycle"),
2881            "merge orchestration cycle handling should publish agent.question"
2882        );
2883    }
2884
2885    /// 8.9 — merge orchestration ends with a final `agent.status` summary.
2886    #[test]
2887    fn supervisor_skill_merge_publishes_final_status_summary() {
2888        let tmpl = resolve("supervisor").unwrap();
2889        let start = tmpl
2890            .content
2891            .find("Merge orchestration")
2892            .expect("merge orchestration section present");
2893        let window = &tmpl.content[start..];
2894        assert!(
2895            window.contains("agent.status") && window.to_lowercase().contains("summary"),
2896            "merge orchestration should end with a final agent.status summary"
2897        );
2898    }
2899
2900    // === coordination-skill-followups: drift 34, 37, 54, 55, 56, 57 ===
2901
2902    /// drift 54 — coordination skill names both `agent_id` and `slugify_branch` in a
2903    /// references/terminology section.
2904    #[test]
2905    fn coordination_skill_documents_slugify_terminology() {
2906        let tmpl = resolve("coordination").unwrap();
2907        assert!(
2908            tmpl.content.contains("agent_id"),
2909            "coordination skill should mention the agent_id identifier form"
2910        );
2911        assert!(
2912            tmpl.content.contains("slugify_branch"),
2913            "coordination skill should name slugify_branch as the canonical conversion"
2914        );
2915        let lowered = tmpl.content.to_lowercase();
2916        assert!(
2917            lowered.contains("references & terminology")
2918                || lowered.contains("references and terminology")
2919                || lowered.contains("terminology"),
2920            "coordination skill should contain a references/terminology heading"
2921        );
2922    }
2923
2924    /// drift 57 — coordination skill documents stash-hygiene rules.
2925    #[test]
2926    fn coordination_skill_documents_stash_hygiene() {
2927        let tmpl = resolve("coordination").unwrap();
2928        assert!(
2929            tmpl.content.contains("git stash list"),
2930            "stash-hygiene section should reference `git stash list`"
2931        );
2932        assert!(
2933            tmpl.content.contains("git stash show -p"),
2934            "stash-hygiene section should reference `git stash show -p`"
2935        );
2936        let lowered = tmpl.content.to_lowercase();
2937        assert!(
2938            lowered.contains("stash hygiene") || lowered.contains("stash safety"),
2939            "coordination skill should contain a stash-hygiene heading"
2940        );
2941        assert!(
2942            lowered.contains("pop only") || lowered.contains("only pop"),
2943            "coordination skill should instruct agents to pop only their own stashes"
2944        );
2945    }
2946
2947    /// drift 55 — supervisor skill documents publishing agent.intent for main-side
2948    /// work with `agent_id` = "supervisor".
2949    #[test]
2950    fn supervisor_skill_documents_main_side_intent() {
2951        let tmpl = resolve("supervisor").unwrap();
2952        let lowered = tmpl.content.to_lowercase();
2953        assert!(
2954            lowered.contains("supervisor publishes agent.intent")
2955                || lowered.contains("publish intent")
2956                || lowered.contains("main-side work"),
2957            "supervisor skill should contain a heading naming supervisor-side intent publishing"
2958        );
2959        let start = tmpl
2960            .content
2961            .find("Supervisor publishes agent.intent")
2962            .expect("supervisor-publishes-intent heading present");
2963        let window = &tmpl.content[start..];
2964        assert!(
2965            window.contains("agent.intent"),
2966            "section should mention agent.intent"
2967        );
2968        assert!(
2969            window.contains("\"supervisor\""),
2970            "section should show agent_id = \"supervisor\" in the example"
2971        );
2972        assert!(
2973            window.contains("\"files\"")
2974                && window.contains("\"summary\"")
2975                && window.contains("\"valid_for_seconds\""),
2976            "section should include a curl example with files, summary, valid_for_seconds"
2977        );
2978    }
2979
2980    /// drift 34 — supervisor skill instructs `tmux send-keys` alongside
2981    /// `agent.feedback` answers, with the "agents do not poll" rationale.
2982    #[test]
2983    fn supervisor_skill_documents_tmux_send_keys_alongside_feedback() {
2984        let tmpl = resolve("supervisor").unwrap();
2985        let start = tmpl
2986            .content
2987            .find("Send the answer to the agent pane too")
2988            .expect("drift-34 subsection should be present");
2989        let next_heading = tmpl.content[start + 1..]
2990            .find("\n### ")
2991            .map_or(tmpl.content.len(), |off| start + 1 + off);
2992        let section = &tmpl.content[start..next_heading];
2993        assert!(
2994            section.contains("tmux send-keys"),
2995            "section should contain `tmux send-keys`"
2996        );
2997        assert!(
2998            section.contains("agent.feedback"),
2999            "section should reference agent.feedback in the same section"
3000        );
3001        let lowered_section = section.to_lowercase();
3002        assert!(
3003            lowered_section.contains("do not poll") || lowered_section.contains("don't poll"),
3004            "section should state the rationale (agents do not poll their inbox)"
3005        );
3006    }
3007
3008    /// drift 37 — coordination skill documents the working-heartbeat cadence and
3009    /// the filesystem-watcher rationale.
3010    #[test]
3011    fn coordination_skill_documents_working_heartbeat() {
3012        let tmpl = resolve("coordination").unwrap();
3013        let lowered = tmpl.content.to_lowercase();
3014        assert!(
3015            lowered.contains("working heartbeat") || lowered.contains("heartbeat"),
3016            "coordination skill should contain a working-heartbeat heading"
3017        );
3018        assert!(
3019            tmpl.content.contains("every 5 tool uses"),
3020            "coordination skill should state the cadence as 'every 5 tool uses'"
3021        );
3022        assert!(
3023            tmpl.content.contains("agent.status"),
3024            "heartbeat reuses the agent.status shape — substring should be present"
3025        );
3026        let start = tmpl
3027            .content
3028            .find("Working heartbeat")
3029            .expect("Working heartbeat heading present");
3030        let next_heading = tmpl.content[start + 1..]
3031            .find("\n### ")
3032            .map_or(tmpl.content.len(), |off| start + 1 + off);
3033        let section = &tmpl.content[start..next_heading].to_lowercase();
3034        assert!(
3035            section.contains("filesystem watcher") || section.contains("watcher"),
3036            "heartbeat section should explain why the filesystem watcher is insufficient"
3037        );
3038    }
3039
3040    /// drift 56 — supervisor skill documents the accept-edits `modified_files` audit
3041    /// step with explicit non-silent-approval guidance.
3042    #[test]
3043    fn supervisor_skill_documents_accept_edits_audit() {
3044        let tmpl = resolve("supervisor").unwrap();
3045        let lowered = tmpl.content.to_lowercase();
3046        assert!(
3047            lowered.contains("accept-edits commits") || lowered.contains("accept edits"),
3048            "supervisor skill should contain an accept-edits audit heading"
3049        );
3050        assert!(
3051            tmpl.content.contains("modified_files"),
3052            "audit section should reference the modified_files payload field"
3053        );
3054        let start = tmpl
3055            .content
3056            .find("Verify accept-edits commits before merge")
3057            .expect("accept-edits audit heading present");
3058        let next_heading = tmpl.content[start + 1..]
3059            .find("\n### ")
3060            .map_or(tmpl.content.len(), |off| start + 1 + off);
3061        let section_lower = tmpl.content[start..next_heading].to_lowercase();
3062        assert!(
3063            section_lower.contains("out-of-scope"),
3064            "audit section should call out 'out-of-scope' edits"
3065        );
3066        assert!(
3067            section_lower.contains("shall not be silently")
3068                || section_lower.contains("not be silently auto-approved")
3069                || section_lower.contains("silently auto-approved"),
3070            "audit section should forbid silent auto-approval"
3071        );
3072    }
3073
3074    /// drift 54 (optional 3.5) — coordination skill describes the slugify rule's
3075    /// effect: lowercase, non-allowed-char replacement, and `agent` fallback.
3076    #[test]
3077    fn coordination_skill_describes_slugify_rule() {
3078        let tmpl = resolve("coordination").unwrap();
3079        let start = tmpl
3080            .content
3081            .find("slugify_branch")
3082            .expect("slugify_branch should be named in the references section");
3083        let next_heading = tmpl.content[start + 1..]
3084            .find("\n### ")
3085            .map_or(tmpl.content.len(), |off| start + 1 + off);
3086        let section_lower = tmpl.content[start..next_heading].to_lowercase();
3087        assert!(
3088            section_lower.contains("lowercase"),
3089            "slugify rule should mention lowercase step"
3090        );
3091        assert!(
3092            tmpl.content[start..next_heading].contains("[a-z0-9_]"),
3093            "slugify rule should describe the allowed char class"
3094        );
3095        assert!(
3096            (section_lower.contains("fallback") || section_lower.contains("fall back"))
3097                && section_lower.contains("agent"),
3098            "slugify rule should describe the empty-fallback to `agent`"
3099        );
3100    }
3101
3102    // --- test-coverage-v0-5-0 -------------------------------------------------
3103    //
3104    // The following tests close per-scenario coverage gaps from the v0.5.0
3105    // archived spec set. See `openspec/changes/test-coverage-v0-5-0/tasks.md`.
3106
3107    // Renders the supervisor skill with a representative set of substitutions.
3108    // Tests assert against the rendered output so any post-render
3109    // transformation regressions are caught.
3110    fn rendered_supervisor() -> String {
3111        let tmpl = resolve("supervisor").expect("supervisor skill resolves");
3112        render(
3113            &tmpl,
3114            "supervisor",
3115            "http://127.0.0.1:9119",
3116            "git-paw",
3117            &GateCommands::default(),
3118        )
3119    }
3120
3121    fn rendered_coordination() -> String {
3122        let tmpl = resolve("coordination").expect("coordination skill resolves");
3123        render(
3124            &tmpl,
3125            "feat/x",
3126            "http://127.0.0.1:9119",
3127            "git-paw",
3128            &GateCommands::default(),
3129        )
3130    }
3131
3132    // Maps to scenario `Supervisor skill — lenient indicator framing` from
3133    // prompt-submit-fix. (task 3.3)
3134    #[test]
3135    fn supervisor_skill_paste_buffer_framing_is_lenient() {
3136        let content = rendered_supervisor();
3137        let lowered = content.to_lowercase();
3138        assert!(
3139            lowered.contains("even if"),
3140            "supervisor skill should frame recovery as attempted even when indicator absent; got:\n{content}"
3141        );
3142        assert!(
3143            lowered.contains("judgment"),
3144            "supervisor skill should describe applying judgment; got:\n{content}"
3145        );
3146        assert!(
3147            lowered.contains("long buffered text"),
3148            "supervisor skill should mention the long-buffered-text heuristic; got:\n{content}"
3149        );
3150    }
3151
3152    // Maps to scenario `Coordination skill rejects pairwise over-coordination
3153    // patterns` from forward-coordination. (task 4.1)
3154    #[test]
3155    fn coordination_skill_rejects_pairwise_overcoordination() {
3156        let content = rendered_coordination();
3157        assert!(
3158            content.contains("pairwise"),
3159            "coordination skill should name `pairwise` under a MUST-NOT clause; got:\n{content}"
3160        );
3161        let lowered = content.to_lowercase();
3162        assert!(
3163            lowered.contains("explicit go-ahead"),
3164            "coordination skill should reject waiting for an explicit go-ahead; got:\n{content}"
3165        );
3166        assert!(
3167            lowered.contains("broker silence") || lowered.contains("block on broker silence"),
3168            "coordination skill should reject blocking on broker silence; got:\n{content}"
3169        );
3170    }
3171
3172    // Maps to scenario `Verification/feedback wording separability` from
3173    // forward-coordination. (task 4.3)
3174    //
3175    // The two message types must be separately reachable — i.e. each lives in
3176    // its own bullet or heading. We assert their distinct anchor lines:
3177    // `- **agent.verified**` and `- **agent.feedback**`.
3178    #[test]
3179    fn coordination_skill_verified_and_feedback_substrings_independent() {
3180        let content = rendered_coordination();
3181        let verified_anchor = "- **`agent.verified`**";
3182        let feedback_anchor = "- **`agent.feedback`**";
3183        assert!(
3184            content.contains(verified_anchor),
3185            "coordination skill should anchor `agent.verified` in its own bullet; got:\n{content}"
3186        );
3187        assert!(
3188            content.contains(feedback_anchor),
3189            "coordination skill should anchor `agent.feedback` in its own bullet; got:\n{content}"
3190        );
3191        // The two anchors must not be on the same line.
3192        let v = content.find(verified_anchor).unwrap();
3193        let f = content.find(feedback_anchor).unwrap();
3194        let between = if v < f {
3195            &content[v..f]
3196        } else {
3197            &content[f..v]
3198        };
3199        assert!(
3200            between.contains('\n'),
3201            "the verified and feedback bullets must be on separate lines; got slice:\n{between}"
3202        );
3203    }
3204
3205    // Maps to scenario `Supervisor skill specifies the ordering` from
3206    // governance-context. (task 10.1)
3207    //
3208    // Ordering invariant: Spec Audit Procedure < Governance verification <
3209    // the publish step that emits `agent.verified`.
3210    #[test]
3211    fn supervisor_skill_governance_after_spec_audit_before_verified() {
3212        let content = rendered_supervisor();
3213        let spec_audit = content
3214            .find("Spec Audit Procedure")
3215            .expect("Spec Audit Procedure heading present in supervisor skill");
3216        let governance = content
3217            .find("Governance verification")
3218            .expect("Governance verification heading present in supervisor skill");
3219        // The closest publish step emitting `agent.verified` after the
3220        // governance heading is the next occurrence of `agent.verified`.
3221        let verified_after = content[governance..]
3222            .find("agent.verified")
3223            .map(|o| governance + o)
3224            .expect("agent.verified mention after Governance verification");
3225
3226        assert!(
3227            spec_audit < governance,
3228            "Spec Audit Procedure should appear before Governance verification \
3229             (spec_audit={spec_audit}, governance={governance})"
3230        );
3231        assert!(
3232            governance < verified_after,
3233            "Governance verification should appear before the next agent.verified \
3234             publish step (governance={governance}, verified_after={verified_after})"
3235        );
3236    }
3237
3238    // Maps to scenario `Coordination skill states agent.done timing for
3239    // consolidated worktrees` from spec-kit-format. (task 11.6)
3240    #[test]
3241    fn coordination_skill_consolidated_agent_done_timing() {
3242        let content = rendered_coordination();
3243        let start = content
3244            .find("consolidated worktree")
3245            .or_else(|| content.find("Consolidated worktree"))
3246            .expect("coordination skill should have a consolidated-worktree section");
3247        let section = &content[start..];
3248        let lowered = section.to_lowercase();
3249        assert!(
3250            lowered.contains("agent.done") || lowered.contains("agent.artifact"),
3251            "consolidated-worktree section should describe agent.done timing; got:\n{section}"
3252        );
3253        assert!(
3254            section.contains("- [x]"),
3255            "consolidated-worktree section should require every task to show - [x]; got:\n{section}"
3256        );
3257        assert!(
3258            lowered.contains("every task") || lowered.contains("every"),
3259            "consolidated-worktree section should make the rule cover every task; got:\n{section}"
3260        );
3261    }
3262
3263    /// drift 55 (optional 3.6) — supervisor-publishes-intent section cross-references
3264    /// the agent-side `Before you start editing` flow in `coordination.md`.
3265    #[test]
3266    fn supervisor_skill_cross_references_agent_intent_flow() {
3267        let tmpl = resolve("supervisor").unwrap();
3268        let start = tmpl
3269            .content
3270            .find("Supervisor publishes agent.intent")
3271            .expect("supervisor-publishes-intent heading present");
3272        let next_heading = tmpl.content[start + 1..]
3273            .find("\n### ")
3274            .map_or(tmpl.content.len(), |off| start + 1 + off);
3275        let section = &tmpl.content[start..next_heading];
3276        assert!(
3277            section.contains("Before you start editing"),
3278            "supervisor-publishes-intent section should cross-reference the agent-side \
3279             `Before you start editing` heading"
3280        );
3281        assert!(
3282            section.contains("coordination.md"),
3283            "cross-reference should name the coordination skill file"
3284        );
3285    }
3286
3287    // ---------------------------------------------------------------------------
3288    // supervisor-as-pane-followups: skill-content tests
3289    // (tasks 8.3, 8.4, 8a.4-8a.7, 8b.7-8b.12)
3290    // ---------------------------------------------------------------------------
3291
3292    fn render_supervisor() -> String {
3293        let tmpl = resolve("supervisor").expect("resolve supervisor template");
3294        render(
3295            &tmpl,
3296            "supervisor",
3297            "http://127.0.0.1:9119",
3298            "git-paw",
3299            &GateCommands {
3300                test_command: Some("just check"),
3301                ..Default::default()
3302            },
3303        )
3304    }
3305
3306    /// 8.3 — resolved supervisor skill contains a curl publishing an
3307    /// `agent.status` for `agent_id = "supervisor"` AND including a `cli`
3308    /// field in the payload JSON.
3309    #[test]
3310    fn supervisor_skill_self_register_curl_includes_cli_field() {
3311        let rendered = render_supervisor();
3312        let start = rendered
3313            .find("Bootstrap")
3314            .expect("Bootstrap section heading present");
3315        let next = rendered[start..]
3316            .find("### Poll session status and messages")
3317            .map_or(rendered.len(), |p| start + p);
3318        let section = &rendered[start..next];
3319        assert!(
3320            section.contains("agent.status"),
3321            "bootstrap section must publish agent.status; got:\n{section}"
3322        );
3323        assert!(
3324            section.contains("\"agent_id\":\"supervisor\""),
3325            "bootstrap curl must use agent_id=\"supervisor\"; got:\n{section}"
3326        );
3327        assert!(
3328            section.contains("\"cli\""),
3329            "bootstrap payload must include a cli field; got:\n{section}"
3330        );
3331    }
3332
3333    /// 8.4 — bootstrap section names this as the FIRST action after
3334    /// reading the skill / AGENTS.md, not a "you may" suggestion.
3335    #[test]
3336    fn supervisor_skill_self_register_is_first_action() {
3337        let rendered = render_supervisor();
3338        let pos_bootstrap = rendered
3339            .find("Bootstrap")
3340            .expect("Bootstrap heading present");
3341        let section_end = rendered[pos_bootstrap..]
3342            .find("### Poll session status and messages")
3343            .map_or(rendered.len(), |p| pos_bootstrap + p);
3344        let section = &rendered[pos_bootstrap..section_end];
3345        let lower = section.to_lowercase();
3346        assert!(
3347            lower.contains("first action") || lower.contains("very first"),
3348            "bootstrap section must state this is the agent's first action; got:\n{section}"
3349        );
3350    }
3351
3352    /// 8a.4 — Watch section explicitly mentions per-iteration sweeping.
3353    #[test]
3354    fn supervisor_skill_watch_mentions_per_iteration_sweep() {
3355        let rendered = render_supervisor();
3356        let start = rendered
3357            .find("**Watch**")
3358            .expect("Watch step heading present");
3359        let end = rendered[start..]
3360            .find("Stall detection")
3361            .map_or(rendered.len(), |p| start + p);
3362        let section = &rendered[start..end];
3363        let lower = section.to_lowercase();
3364        assert!(
3365            lower.contains("every iteration")
3366                || lower.contains("every monitoring")
3367                || lower.contains("each monitoring")
3368                || lower.contains("each iteration"),
3369            "Watch section must mention per-iteration sweeping; got:\n{section}"
3370        );
3371    }
3372
3373    /// 8a.5 — Rules section bullet mentions absorbing routine approvals
3374    /// AND at least three routine command families.
3375    #[test]
3376    fn supervisor_skill_rules_bullet_mentions_routine_absorption() {
3377        let rendered = render_supervisor();
3378        let start = rendered.find("### Rules").expect("Rules section present");
3379        let end = rendered[start..]
3380            .find("### Auto-approve permission prompts")
3381            .map_or(rendered.len(), |p| start + p);
3382        let section = &rendered[start..end];
3383        let lower = section.to_lowercase();
3384        assert!(
3385            lower.contains("absorb routine approval") || lower.contains("rubber-stamp"),
3386            "Rules must include the routine-approval absorption framing; got:\n{section}"
3387        );
3388        let mut family_hits = 0;
3389        for family in ["cargo", "git commit", "mdbook", "git stash", "git restore"] {
3390            if section.contains(family) {
3391                family_hits += 1;
3392            }
3393        }
3394        assert!(
3395            family_hits >= 3,
3396            "Rules bullet must enumerate at least 3 routine families; only {family_hits} found in:\n{section}",
3397        );
3398    }
3399
3400    /// 8a.6 — Rules bullet also enumerates at least two non-routine
3401    /// escalation cases.
3402    #[test]
3403    fn supervisor_skill_rules_bullet_enumerates_escalation_cases() {
3404        let rendered = render_supervisor();
3405        let start = rendered.find("### Rules").expect("Rules section present");
3406        let end = rendered[start..]
3407            .find("### Auto-approve permission prompts")
3408            .map_or(rendered.len(), |p| start + p);
3409        let section = &rendered[start..end];
3410        let lower = section.to_lowercase();
3411        let mut hits = 0;
3412        for case in [
3413            "cross-agent conflict",
3414            "destructive",
3415            "scope",
3416            "spec decisions",
3417            "novel",
3418        ] {
3419            if lower.contains(case) {
3420                hits += 1;
3421            }
3422        }
3423        assert!(
3424            hits >= 2,
3425            "Rules bullet must enumerate at least 2 escalation cases; only {hits} found in:\n{section}",
3426        );
3427    }
3428
3429    /// 8a.7 — Watch section contains the phrase "every iteration" or
3430    /// "every monitoring" (verbatim).
3431    #[test]
3432    fn supervisor_skill_contains_every_iteration_phrase() {
3433        let rendered = render_supervisor();
3434        let lower = rendered.to_lowercase();
3435        assert!(
3436            lower.contains("every iteration") || lower.contains("every monitoring"),
3437            "skill must contain 'every iteration' or 'every monitoring' phrasing somewhere",
3438        );
3439    }
3440
3441    /// 8b.7 — supervisor skill contains the five gate names in order.
3442    #[test]
3443    fn supervisor_skill_enumerates_five_gates_in_order() {
3444        let rendered = render_supervisor();
3445        let pos = |needle: &str| {
3446            rendered
3447                .find(needle)
3448                .unwrap_or_else(|| panic!("gate '{needle}' not found in supervisor skill"))
3449        };
3450        let pos_testing = pos("**Testing**");
3451        let pos_regression = pos("**Regression analysis**");
3452        let pos_spec = pos("**Spec audit**");
3453        let pos_doc = pos("**Doc audit**");
3454        let pos_security = pos("**Security audit**");
3455        assert!(
3456            pos_testing < pos_regression
3457                && pos_regression < pos_spec
3458                && pos_spec < pos_doc
3459                && pos_doc < pos_security,
3460            "five gates must appear in order Testing < Regression < Spec < Doc < Security; \
3461             got positions Testing={pos_testing} Regression={pos_regression} \
3462             Spec={pos_spec} Doc={pos_doc} Security={pos_security}",
3463        );
3464    }
3465
3466    /// 8b.8 — §7 Verify-or-feedback's `agent.verified` example body
3467    /// mentions all five gate names.
3468    #[test]
3469    fn supervisor_skill_verified_message_enumerates_five_gates() {
3470        let rendered = render_supervisor();
3471        // Anchor on §7 specifically — the supervisor skill has an earlier
3472        // `agent.verified` example near the top of the file that pre-dates
3473        // the five-gate restructure.
3474        let verify_start = rendered
3475            .find("**Verify or feedback**")
3476            .expect("Verify or feedback step present");
3477        let window = &rendered[verify_start..];
3478        let lower = window.to_lowercase();
3479        for needle in [
3480            "testing",
3481            "regression",
3482            "spec audit",
3483            "doc audit",
3484            "security audit",
3485        ] {
3486            assert!(
3487                lower.contains(needle),
3488                "§7 Verify-or-feedback must mention '{needle}'; got window:\n{window}",
3489            );
3490        }
3491    }
3492
3493    /// 8b.9 — §7's `agent.feedback` examples mention the gate-name
3494    /// convention with at least three of the five gates shown. The
3495    /// supervisor skill now wraps feedback through
3496    /// `.git-paw/scripts/sweep.sh feedback-gate <agent> <gate> <msg>`,
3497    /// so a gate name passed as the second argument satisfies the
3498    /// convention equivalently to a bracketed `[gate]` prefix.
3499    #[test]
3500    fn supervisor_skill_feedback_example_uses_gate_name_prefixes() {
3501        let rendered = render_supervisor();
3502        let verify_start = rendered
3503            .find("**Verify or feedback**")
3504            .expect("Verify or feedback step present");
3505        // Cap the window at the next top-level section so we don't bleed
3506        // into "Spec Audit Procedure".
3507        let end = rendered[verify_start..]
3508            .find("\n### ")
3509            .map_or(rendered.len(), |p| verify_start + p);
3510        let window = &rendered[verify_start..end];
3511        let mut hits = 0;
3512        for (bracketed, helper_arg) in [
3513            ("[testing]", " testing "),
3514            ("[regression]", " regression "),
3515            ("[spec audit]", " \"spec audit\" "),
3516            ("[doc audit]", " \"doc audit\" "),
3517            ("[security audit]", " \"security audit\" "),
3518        ] {
3519            if window.contains(bracketed)
3520                || window.contains(&format!("feedback-gate __FILL_IN_AGENT_ID__{helper_arg}"))
3521            {
3522                hits += 1;
3523            }
3524        }
3525        assert!(
3526            hits >= 3,
3527            "§7 agent.feedback example must show at least 3 gates (bracketed or helper-arg); \
3528             only {hits} found in:\n{window}",
3529        );
3530    }
3531
3532    /// 8b.10 — Doc audit gate enumerates at least 4 of 5 doc surfaces.
3533    #[test]
3534    fn supervisor_skill_doc_audit_enumerates_surfaces() {
3535        let rendered = render_supervisor();
3536        let start = rendered
3537            .find("**Doc audit**")
3538            .expect("Doc audit gate present");
3539        let end = rendered[start..]
3540            .find("**Security audit**")
3541            .map(|p| start + p)
3542            .expect("Security audit follows Doc audit");
3543        let section = &rendered[start..end];
3544        let mut hits = 0;
3545        for surface in ["docs/src/", "README.md", "AGENTS.md", "--help", "rustdoc"] {
3546            if section.contains(surface) {
3547                hits += 1;
3548            }
3549        }
3550        assert!(
3551            hits >= 4,
3552            "Doc audit must enumerate at least 4 of 5 doc surfaces; only {hits} found in:\n{section}",
3553        );
3554    }
3555
3556    /// 8b.11 — Security audit gate enumerates at least 4 of 6 OWASP
3557    /// categories AND mentions the `unwrap()`/`expect()` rule.
3558    #[test]
3559    fn supervisor_skill_security_audit_enumerates_owasp_categories() {
3560        let rendered = render_supervisor();
3561        let start = rendered
3562            .find("**Security audit**")
3563            .expect("Security audit gate present");
3564        let end = rendered[start..]
3565            .find("**Verify or feedback**")
3566            .map_or(rendered.len(), |p| start + p);
3567        let section = &rendered[start..end];
3568        let lower = section.to_lowercase();
3569        let mut hits = 0;
3570        for cat in [
3571            "command injection",
3572            "xss",
3573            "sql injection",
3574            "path traversal",
3575            "unvalidated external input",
3576            "secret leakage",
3577        ] {
3578            if lower.contains(cat) {
3579                hits += 1;
3580            }
3581        }
3582        assert!(
3583            hits >= 4,
3584            "Security audit must enumerate at least 4 of 6 OWASP categories; only {hits} found in:\n{section}",
3585        );
3586        assert!(
3587            section.contains("unwrap()") || section.contains("expect()"),
3588            "Security audit must mention the unwrap()/expect() rule; got:\n{section}",
3589        );
3590    }
3591
3592    /// 8b.12 — Governance verification sub-step is preserved (`DoD`,
3593    /// ADRs, `security.md`, `test-strategy.md`, `constitution.md` still present).
3594    #[test]
3595    fn supervisor_skill_governance_verification_substep_preserved() {
3596        let rendered = render_supervisor();
3597        let start = rendered
3598            .find("Governance verification")
3599            .expect("Governance verification sub-step still present");
3600        let end = (start + 2000).min(rendered.len());
3601        let section = &rendered[start..end];
3602        for needle in [
3603            "DoD",
3604            "ADR",
3605            "security.md",
3606            "test-strategy.md",
3607            "constitution.md",
3608        ] {
3609            assert!(
3610                section.contains(needle),
3611                "governance sub-step must still reference '{needle}'; got:\n{section}",
3612            );
3613        }
3614    }
3615
3616    // ---------------------------------------------------------------------------
3617    // coordination-skill-followups-2: skill-content tests
3618    // (tasks 1.3, 2.3, 2.4, 3.3)
3619    // ---------------------------------------------------------------------------
3620
3621    /// 1.3 — coordination skill teaches a per-group commit cadence with
3622    /// conventional-commit examples.
3623    #[test]
3624    fn coordination_skill_documents_commit_cadence() {
3625        let tmpl = resolve("coordination").unwrap();
3626        let lowered = tmpl.content.to_lowercase();
3627        assert!(
3628            lowered.contains("commit cadence") || lowered.contains("per-group commit cadence"),
3629            "coordination skill should have a heading naming the commit-cadence concept; \
3630             got:\n{}",
3631            tmpl.content
3632        );
3633        assert!(
3634            lowered.contains("group") || lowered.contains("section"),
3635            "commit-cadence section should mention the GROUP/section grain"
3636        );
3637        let has_conventional_prefix = ["feat(", "fix(", "docs(", "test(", "chore("]
3638            .iter()
3639            .any(|p| tmpl.content.contains(p));
3640        assert!(
3641            has_conventional_prefix,
3642            "commit-cadence section should show at least one conventional-commit prefix example"
3643        );
3644    }
3645
3646    /// 2.3 — coordination skill explicitly forbids the coding agent from
3647    /// invoking `/opsx:verify` and `/opsx:archive`.
3648    #[test]
3649    fn coordination_skill_forbids_opsx_verify_and_archive() {
3650        let tmpl = resolve("coordination").unwrap();
3651        assert!(
3652            tmpl.content.contains("/opsx:verify"),
3653            "coordination skill should name `/opsx:verify` literally"
3654        );
3655        assert!(
3656            tmpl.content.contains("/opsx:archive"),
3657            "coordination skill should name `/opsx:archive` literally"
3658        );
3659        let lowered = tmpl.content.to_lowercase();
3660        assert!(
3661            lowered.contains("off-limits")
3662                || lowered.contains("do not invoke")
3663                || lowered.contains("shall not")
3664                || lowered.contains("supervisor's job"),
3665            "coordination skill should state both are not the coding agent's responsibility"
3666        );
3667    }
3668
3669    /// 2.4 — coordination skill names `agent.artifact` as the terminal action
3670    /// with status "done" or "committed".
3671    #[test]
3672    fn coordination_skill_names_terminal_action() {
3673        let tmpl = resolve("coordination").unwrap();
3674        assert!(
3675            tmpl.content.contains("agent.artifact"),
3676            "coordination skill should name `agent.artifact` as the terminal publish"
3677        );
3678        assert!(
3679            tmpl.content.contains("\"done\"") || tmpl.content.contains("\"committed\""),
3680            "coordination skill should reference status: \"done\" or \"committed\""
3681        );
3682    }
3683
3684    /// 3.3 — supervisor skill teaches `pane_current_path` as the canonical
3685    /// pane→agent resolution mechanism.
3686    #[test]
3687    fn supervisor_skill_documents_pane_current_path_resolution() {
3688        let tmpl = resolve("supervisor").unwrap();
3689        assert!(
3690            tmpl.content.contains("tmux display-message"),
3691            "supervisor skill should show the tmux display-message command"
3692        );
3693        assert!(
3694            tmpl.content.contains("pane_current_path"),
3695            "supervisor skill should name pane_current_path literally"
3696        );
3697        let lowered = tmpl.content.to_lowercase();
3698        assert!(
3699            lowered.contains("not alphabetical")
3700                || lowered.contains("not sorted alphabetically")
3701                || lowered.contains("are not alphabetical"),
3702            "supervisor skill should warn against alphabetical pane-index assumptions"
3703        );
3704        assert!(
3705            lowered.contains("cli-argument order")
3706                || lowered.contains("cli argument order")
3707                || lowered.contains("argument order"),
3708            "supervisor skill should warn against CLI-argument-order pane-index assumptions"
3709        );
3710    }
3711
3712    // prompt-submit-fix coverage: ensure the supervisor skill's launch-time
3713    // pane sweep section continues to teach the three timing/escalation/
3714    // cross-reference contracts that the prompt-submit-fix change locked in.
3715
3716    #[test]
3717    fn supervisor_skill_documents_proactive_launch_sweep() {
3718        let tmpl = resolve("supervisor").unwrap();
3719        let lowered = tmpl.content.to_lowercase();
3720        let start = lowered
3721            .find("launch-time pane sweep")
3722            .or_else(|| lowered.find("launch sweep"))
3723            .expect("launch-time pane sweep heading should be present");
3724        let window_end = (start + 2500).min(lowered.len());
3725        let window = &lowered[start..window_end];
3726        assert!(
3727            window.contains("immediately after attaching")
3728                || window.contains("before the poll thread")
3729                || window.contains("first-few-seconds")
3730                || window.contains("first few seconds"),
3731            "launch sweep should link the sweep to the first-few-seconds-after-attach window",
3732        );
3733    }
3734
3735    #[test]
3736    fn supervisor_skill_launch_sweep_escalates_unknown_via_agent_question() {
3737        let tmpl = resolve("supervisor").unwrap();
3738        let lowered = tmpl.content.to_lowercase();
3739        let start = lowered
3740            .find("launch-time pane sweep")
3741            .or_else(|| lowered.find("launch sweep"))
3742            .expect("launch-time pane sweep heading should be present");
3743        let window_end = (start + 2500).min(lowered.len());
3744        let window = &lowered[start..window_end];
3745        assert!(
3746            window.contains("unknown") || window.contains("wider scope"),
3747            "launch sweep should classify a third category for unknown/wider-scope prompts",
3748        );
3749        assert!(
3750            window.contains("agent.question"),
3751            "launch sweep should instruct agent.question escalation for unknown prompts",
3752        );
3753        assert!(
3754            window.contains("escalate"),
3755            "launch sweep should use the word 'escalate' alongside the agent.question instruction",
3756        );
3757    }
3758
3759    #[test]
3760    fn supervisor_skill_launch_sweep_complements_auto_approve_thread() {
3761        let tmpl = resolve("supervisor").unwrap();
3762        let lowered = tmpl.content.to_lowercase();
3763        let start = lowered
3764            .find("launch-time pane sweep")
3765            .or_else(|| lowered.find("launch sweep"))
3766            .expect("launch-time pane sweep heading should be present");
3767        let window_end = (start + 2500).min(lowered.len());
3768        let window = &lowered[start..window_end];
3769        assert!(
3770            window.contains("complements"),
3771            "launch sweep should describe itself as complementing the auto-approve thread",
3772        );
3773        assert!(
3774            window.contains("does not replace")
3775                || window.contains("not replace")
3776                || window.contains("does **not** replace"),
3777            "launch sweep should explicitly say it does NOT replace the auto-approve thread",
3778        );
3779        assert!(
3780            window.contains("[supervisor.auto_approve]") || window.contains("auto_approve"),
3781            "launch sweep should cross-reference the [supervisor.auto_approve] poll thread",
3782        );
3783    }
3784
3785    // coordination-skill-followups: when the supervisor sends an
3786    // `agent.feedback` answer to a peer's `agent.question`, it must
3787    // dual-write via `tmux send-keys` AND cross-reference the
3788    // paste-buffer recovery sub-case for long answers. The test below
3789    // asserts that cross-reference is present in the send-keys section.
3790    // v0-5-0-audit-cleanup task 8.1.
3791
3792    #[test]
3793    fn supervisor_skill_paste_buffer_cross_ref_in_send_keys_section() {
3794        let tmpl = resolve("supervisor").unwrap();
3795        let lowered = tmpl.content.to_lowercase();
3796        // Anchor on the "send the answer to the agent pane too" heading
3797        // — that's the section drift-34 owns. Fall back to a substring
3798        // unique to the section if the heading wording shifts.
3799        let start = lowered
3800            .find("send the answer to the agent pane")
3801            .or_else(|| lowered.find("agents do not poll their inbox"))
3802            .expect("send-keys-alongside-agent.feedback section should be present");
3803        let window_end = (start + 2200).min(lowered.len());
3804        let window = &lowered[start..window_end];
3805
3806        assert!(
3807            window.contains("paste-buffer")
3808                || window.contains("paste buffer")
3809                || window.contains("follow-up enter")
3810                || window.contains("follow-up `enter`"),
3811            "send-keys-alongside-feedback section must cross-reference paste-buffer recovery / follow-up Enter for long answers",
3812        );
3813    }
3814
3815    // coordination-skill-followups-2: the `pane_current_path` resolution
3816    // section must contain a warning against using `git paw status`
3817    // output order as a pane→agent mapping source. The dashboard and
3818    // status output are alphabetically sorted by the broker and have no
3819    // relationship to the launcher's pane assignment.
3820    // v0-5-0-audit-cleanup task 8.2.
3821
3822    #[test]
3823    fn supervisor_skill_warns_against_git_paw_status_ordering() {
3824        let tmpl = resolve("supervisor").unwrap();
3825        // Case-sensitive search first for the literal `git paw status`
3826        // substring, then case-insensitive for the surrounding warning.
3827        assert!(
3828            tmpl.content.contains("git paw status"),
3829            "supervisor skill should reference `git paw status` by name when warning against using its ordering as a mapping source",
3830        );
3831
3832        let lowered = tmpl.content.to_lowercase();
3833        let start = lowered
3834            .find("pane_current_path")
3835            .expect("pane_current_path resolution section should be present");
3836        let window_end = (start + 2500).min(lowered.len());
3837        let window = &lowered[start..window_end];
3838
3839        assert!(
3840            window.contains("git paw status"),
3841            "the warning against `git paw status` ordering must appear within the pane_current_path resolution section",
3842        );
3843        assert!(
3844            window.contains("shall not be inferred")
3845                || window.contains("must not")
3846                || window.contains("not be inferred")
3847                || window.contains("not used as a mapping")
3848                || window.contains("no relationship"),
3849            "section must forbid using `git paw status` order as a mapping source",
3850        );
3851    }
3852}