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