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