Task — engineering-spec@1

"SKILL.md `description:` field — mandated trigger-phrase enrichment + 1024-char budget for host-portable triggering"

doneTASK-SKILL-111
module skill · class product · priority p1 · created 2026-05-19 · shipped null
depends on TASK-SKILL-103 · blocks none

§1 — Description (BCP-14 normative)

This task establishes the rules for the description: field of every SKILL.md frontmatter so that Anthropic-style hosts (and any host whose progressive-disclosure level 1 reads only frontmatter) can correctly route user requests to CyberOS skills.

  1. The description: field MUST carry three structural elements in any order: (a) WHAT the skill does (one verb phrase, present tense, third-person), (b) WHEN the host should route to it (≥2 distinct trigger-phrase forms quoted in the description prose; trigger phrases mirror natural-language fragments a user might type), and (c) KEY VALUE (what the user gets — one outcome phrase). Order is recommended WHAT + WHEN + KEY VALUE but not enforced.
  2. The description: field's character length MUST be ≥ 80 chars AND ≤ 1024 chars (raised from TASK-SKILL-103's baseline of 200; aligns with the Anthropic guide's published cap per Reference B p. 31). Below 80 chars cannot carry both WHAT and ≥2 trigger phrases; above 1024 violates the host system-prompt budget.
  3. Trigger phrases MUST be quoted in standard double-quotes ("...") inside the description prose. Two acceptable forms:
  1. The description MUST NOT contain XML angle brackets < or >. (Restates TASK-SKILL-103 §1 #2 and TASK-SKILL-113 sketch; included here so FM-112 is self-contained for the auditor — the validator runs the bracket check before the trigger-phrase check so the user gets the more diagnostic error first.)
  2. The description MAY contain negative triggers in the form Do NOT use for "<phrase>" (use <other-skill> instead). When present, the validator increments the trigger-phrase count for the positive triggers but not the negative ones (negatives are disambiguators, not triggers).
  3. The description MUST reference file types or input artefacts by their canonical name when the skill consumes a specific format. Examples: PRD/spec/SRS documents, .fig files, Postman collections. This mirrors the Anthropic guide Chapter 2 p. 10 "Mention file types if relevant".
  4. The description MUST NOT be a single trailing word like Helps with X. or Manages Y. — these were the canonical bad examples in the Anthropic guide p. 12. The validator rejects any description that fails the WHAT-detection regex (\b(generate|author|audit|review|draft|emit|build|propose|render|extract|classify|tag|score|track|enforce|validate|orchestrate|chain|select|pin|halt|resume|escalate|wrap|publish|deliver|test|simulate)\b — verb stems that indicate concrete action).
  5. The description SHOULD name the skill's principal output artefact when the skill is artefact-producing (i.e. produces.output_kind: artefact). Example: Generates a versioned task@1 markdown. This anchors trust because the user can verify the artefact appears in the output.
  6. The description MAY be a multi-line YAML block (using | or >- folding) for readability. The 80-1024 char budget applies to the flattened single-line equivalent (after YAML folding resolves whitespace).
  7. Description MUST be locale-default English. Localised variants for VN-locale skills live in description_localized.<lang> under metadata: (out of scope for this task; documented as future v0.3.0 work in §9).
  8. The auditor rule MUST be FM-112 description-format with severity error for production skills (status: accepted or higher); severity: warning for status: draft skills (so authors get feedback during authoring but the rule doesn't block draft work).
  9. The auditor rule MUST NOT auto-fix the description. Description text reflects human intent; auto-edit would silently rewrite trigger phrases the author chose deliberately. Verdict on description issues is always needs_human per _template/audit/REPORT_FORMAT.md.
  10. The CLI cyberos skill validate (TASK-SKILL-103 #10) MUST report description-format violations with a structured error message naming the missing element (missing_what | missing_triggers | missing_value_phrase | too_short | too_long | forbidden_brackets). The host shim per Part 9 of modules/skill/README.md MUST surface the message identically across hosts.
  11. Existing skills that pre-date this task MUST be backfilled lazily — the rule fires at status: accepted or higher; scaffold/status: draft skills get a grace window. The auditor rule fires only on fields that were touched by a fine-tune commit after this task ships (per human_fine_tune.signals_to_initiate — fine-tune session brings the skill into compliance as a side effect).
  12. The ## When to invoke this skill body section MUST NOT be deleted; it remains the long-form companion to the frontmatter trigger phrases. The body section serves three purposes the description cannot: (a) negative triggers with prose explanations, (b) disambiguation cross-links to sibling skills, (c) handoff guidance to other personas. The frontmatter triggers are the host's level-1 hook; the body section is level-2 detail. Both must be kept in sync — when the description's triggers are updated, the body section MUST be re-audited for consistency (auditor rule SEC-007 enforces).

§2 — Why this design (rationale for humans)

Why mandate trigger phrases in description rather than relying on the body (§1 #1)? Anthropic's progressive-disclosure model (guide Chapter 1 p. 5) loads frontmatter into every system prompt but reads the body only after a skill is matched. The host's relevance classifier sees the description and nothing else. If trigger phrases live only in the body, the classifier never sees them; the skill never matches; the body never loads. Inside CyberOS the supervisor's classify_act node reads the body (because we control both ends), but outside CyberOS — where Phase-B transpilers will ship plugins to Claude.ai, Codex, Cursor, etc. — the host follows Anthropic's contract. Trigger phrases MUST be in the description for the contract to hold.

Why raise the budget from 200 to 1024 chars (§1 #2)? The 200-char limit in TASK-SKILL-103 baseline was inherited from an earlier draft that prioritised terseness. Real CyberOS skill descriptions already exceed 200 chars (e.g. task-author/SKILL.md line 4-9 is ~470 chars in single-line equivalent). The Anthropic guide explicitly permits 1024 (Reference B p. 31). Raising the cap is a SemVer-compatible field-loosening — existing descriptions stay valid; the new cap admits enriched descriptions without forcing a v2 frontmatter schema. The 80-char floor is empirical: below 80 chars cannot fit WHAT (≥20 chars), 2 trigger phrases (≥30 chars combined), and a value phrase (≥20 chars) with quotes and separators.

Why two trigger-phrase forms rather than one (§1 #3)? Single-trigger descriptions over-fit one user phrasing. Two distinct forms increase classifier robustness — if the user says "draft a PRD" and only "author a PRD" is registered, the synonym mismatch breaks routing. Two forms cover the common paraphrase delta. More than two is welcome but not required. The Anthropic guide examples (p. 11) all carry 2-4 forms, validating the two-floor as the right minimum.

Why both Use when and Triggers on forms (§1 #3)? Different skill authors prefer different prose styles; both forms appear in the Anthropic guide examples. The validator accepts either. What matters is that the trigger phrases are quoted — the validator's regex looks for "<phrase>" to detect triggers; unquoted natural-language sentences pass freely without being recognised as triggers.

Why explicit XML-bracket rejection (§1 #4)? The Anthropic guide Reference B p. 31 forbids < and > in frontmatter for system-prompt injection reasons. Even though TASK-SKILL-103 already validates this at the frontmatter level, FM-112's own check provides the more diagnostic error ("found < in description text" vs the generic TASK-SKILL-103 "InvalidDescription") — operator UX matters.

Why allow negative triggers (§1 #5)? The Anthropic guide Chapter 5 p. 25 shows the disambiguation pattern: Do NOT use for simple data exploration (use data-viz skill instead). CyberOS body sections already do this in ## When to invoke this skill; moving the negative-trigger sentence into the description gives the host classifier the same disambiguation power. Counting them separately prevents the validator from double-counting (a negative trigger isn't a positive trigger).

Why require file-type references (§1 #6)? Anthropic guide Chapter 2 p. 10 calls this out specifically: "Mention file types if relevant". A skill that reads .fig files should say .fig files; a skill that reads PRDs should say PRD/spec/SRS documents. The classifier weights file-extension matches heavily — without them, the skill doesn't trigger on prompts like "I have a Figma file...".

Why reject single-word-action descriptions (§1 #7)? The Anthropic guide p. 12 lists "Helps with projects" as the canonical bad description. The verb-stem regex catches the same anti-pattern: descriptions that don't name an action ("Helps with X", "Manages Y", "Implements Z") never match concrete user requests. The verb list is short (29 verb stems) and represents the actual surface of CyberOS skill actions.

Why name the output artefact (§1 #8)? Anchors trust. A user reading the description sees what they'll get (task@1 markdown); the audit row's payload_hash (per audit: block) verifies the artefact exists; the round-trip is closed. Without the artefact name, the user can't tell what to expect, and the audit row carries less semantic meaning.

Why warning on draft, error on accepted (§1 #11)? Drafting is exploratory; forcing the trigger-phrase format on every draft commit would slow first-pass authoring. Production skills (status: accepted or higher) are routed by the supervisor and shipped to hosts — they MUST conform. The two-severity scheme lets authors iterate while keeping production hygienic.

Why no auto-fix (§1 #12)? Trigger phrases reflect human intent. An auto-edit might rewrite "audit this task" as "check the task" and silently miss the user-visible verb the author chose. Description issues always require human review.

Why the body ## When to invoke this skill stays (§1 #15)? Three irreducible functions: (a) negative triggers with prose explanations (e.g. "If the user wants <other skill>, route to <other skill> instead"); (b) disambiguation cross-links to sibling skills that the description's character budget can't accommodate; (c) handoff guidance describing chains. Frontmatter is for the host classifier; body is for the supervisor + human reader. Keeping both keeps the cross-host port honest while preserving the rich-body audit surface.

§3 — API contract

Rust types (added to services/skill-broker/src/frontmatter/schema.rs)

// Raised from prior 200-char cap. SemVer-compatible loosen.
pub const DESCRIPTION_MIN_LEN: usize = 80;
pub const DESCRIPTION_MAX_LEN: usize = 1024;

// Inside SkillFrontmatter (TASK-SKILL-103 §3):
//   pub description: String,    // unchanged shape — just new validator

Rust validator (services/skill-broker/src/frontmatter/description_validator.rs)

use crate::frontmatter::FrontmatterError;
use regex::Regex;
use once_cell::sync::Lazy;

/// Verb stems that indicate concrete action. Conservative list — operator
/// extends as needed via PR + RUBRIC update.
static VERB_STEMS: Lazy<Regex> = Lazy::new(|| {
    Regex::new(r"(?i)\b(generate|author|audit|review|draft|emit|build|propose|render|extract|classify|tag|score|track|enforce|validate|orchestrate|chain|select|pin|halt|resume|escalate|wrap|publish|deliver|test|simulate)\b")
        .unwrap()
});

/// Quoted trigger phrase: `"..."` with non-empty body.
static QUOTED_TRIGGER: Lazy<Regex> = Lazy::new(|| {
    Regex::new(r#""([^"]{1,80})""#).unwrap()
});

/// Negative-trigger preamble: matches "Do NOT use for ..." prefix.
static NEGATIVE_PREFIX: Lazy<Regex> = Lazy::new(|| {
    Regex::new(r"(?i)\bdo\s+not\s+use\s+(for|when|with)\b").unwrap()
});

#[derive(Debug, PartialEq)]
pub enum DescriptionViolation {
    TooShort { len: usize },
    TooLong  { len: usize },
    ForbiddenBrackets,
    MissingWhat,
    InsufficientTriggers { found: usize, needed: usize },
}

pub fn validate(description: &str) -> Result<(), DescriptionViolation> {
    // 0. Flatten YAML-folded multi-line into single line for measurement.
    let flat = description.replace('\n', " ").trim().to_string();
    let len  = flat.chars().count();
    if len < DESCRIPTION_MIN_LEN { return Err(DescriptionViolation::TooShort { len }); }
    if len > DESCRIPTION_MAX_LEN { return Err(DescriptionViolation::TooLong  { len }); }

    // 1. Bracket-free (defensive duplicate of TASK-SKILL-103 check).
    if flat.contains('<') || flat.contains('>') {
        return Err(DescriptionViolation::ForbiddenBrackets);
    }

    // 2. WHAT detection — at least one verb stem.
    if !VERB_STEMS.is_match(&flat) {
        return Err(DescriptionViolation::MissingWhat);
    }

    // 3. Trigger phrases — count quoted phrases, excluding those preceded
    //    by "Do NOT use for" (negative triggers).
    let mut positive_triggers = 0usize;
    for m in QUOTED_TRIGGER.find_iter(&flat) {
        let preceding = &flat[..m.start()];
        // If a negative-prefix appears in the 40 chars preceding the
        // quoted phrase, treat it as a negative trigger (don't count).
        let window_start = preceding.len().saturating_sub(40);
        let window       = &preceding[window_start..];
        if NEGATIVE_PREFIX.is_match(window) { continue; }
        positive_triggers += 1;
    }
    if positive_triggers < 2 {
        return Err(DescriptionViolation::InsufficientTriggers {
            found: positive_triggers, needed: 2,
        });
    }
    Ok(())
}

impl From<DescriptionViolation> for FrontmatterError {
    fn from(v: DescriptionViolation) -> Self {
        FrontmatterError::InvalidDescription(format!("{v:?}"))
    }
}

JSONSchema mirror (services/skill-broker/skill.schema.json — diff)

   "description": {
     "type": "string",
-    "maxLength": 200
+    "minLength": 80,
+    "maxLength": 1024,
+    "pattern": "(?s)^(?!.*[<>]).*$"
   }

(JSONSchema can express min/max length and the bracket-absence guard via pattern; the verb-stem + trigger-count checks live in the Rust validator only — JSONSchema regex can't express "≥2 quoted substrings excluding those after a Do-NOT preamble" cleanly. The mirror documents this in a $comment field.)

Auditor rule (added to modules/skill/task-audit/RUBRIC.md)

### FM-112 — description-format

**Statement:** SKILL.md frontmatter `description:` field MUST carry WHAT (verb-stem action) + WHEN (≥2 quoted trigger phrases) + KEY VALUE; 80-1024 chars flattened; no XML brackets; per TASK-SKILL-111 §1.

**Severity:** error on `status: accepted | building | shipped`; warning on `status: draft`.

**Auto-fix:** never (description reflects human intent — verdict `needs_human`).

**Check (deterministic):** invoke `cyberos skill validate <bundle>`; if exit code 6 with `validation_outcome: description-format`, the rule fails. Specific sub-codes: `too_short` | `too_long` | `forbidden_brackets` | `missing_what` | `insufficient_triggers`.

**Issue template:**

ISSUE id: ISS-NNN rule_id: FM-112 severity: error|warning category: description_format location: frontmatter "description:" field evidence: "<the description text, truncated to 200 chars>" description: "Description fails FM-112: <sub-code>. Detail: <validator output>." suggestion: "Rewrite description to include WHAT + WHEN (≥2 quoted triggers like \"<phrase>\") + KEY VALUE. See task-audit skill §3.13 for examples." auto_fix_applied: false resolution: null

Updated _template/author/SKILL.md description block (diff)

-description: |
-  Author a <ARTIFACT> markdown from <input artefact(s)>. Generates a
-  versioned <artefact>@1 file under output_dir, with per-claim authority
-  markers and provenance to the source. Chains naturally into
-  <artifact>-audit by default. Refuses to author when upstream artefact
-  is in non-pass state.
+description: >-
+  Generate a versioned <artefact>@1 markdown from one or more <input
+  artefact(s)>. Use when user asks to "draft a <ARTIFACT>" or "turn
+  this <input> into a <ARTIFACT>". Halts at PLAN approval and HITL
+  gates; resumable from manifest.json state. Chains into
+  <artifact>-audit by default. Outputs task@1-style
+  markdowns with per-claim authority markers and source provenance.

(The folded scalar >- flattens newlines to spaces for the validator's length count.)

Operator-facing CLI

# Validate a single skill's description (calls into TASK-SKILL-103's broker)
cyberos skill validate modules/skill/task-author/

# Validate every production skill — used by CI gate
cyberos skill validate-all --status-min accepted

# Output (human form):
✓ modules/skill/task-author/         — valid (description: 487 chars, 3 triggers)
✗ modules/skill/closure-author/                 — InvalidDescription: insufficient_triggers (found: 1, needed: 2)
✗ modules/skill/stage-gate-author/              — InvalidDescription: missing_what

# Exit code: 0 if all pass, 6 (SchemaViolation) on any failure.

§4 — Acceptance criteria

  1. Valid description with 2 triggers loads — fixture description-valid/SKILL.md with description: "Generate a task@1 markdown from PRDs. Use when user asks to \"draft a task\" or \"turn this PRD into a backlog\". Outputs versioned task-NNN-slug.md files."description_validator::validate returns Ok(()).
  2. Description too short rejected — 70-char description → Err(TooShort { len: 70 }).
  3. Description too long rejected — 1100-char description → Err(TooLong { len: 1100 }).
  4. XML brackets in description rejected — description containing <untrusted>Err(ForbiddenBrackets).
  5. Description missing verb stem rejected"Helps with tasks. Use when user says \"task\" or \"backlog\"."Err(MissingWhat).
  6. Description with only 1 trigger rejected"Generate tasks. Use when user asks to \"draft a task\"." (single quoted phrase) → Err(InsufficientTriggers { found: 1, needed: 2 }).
  7. Description with 1 positive + 1 negative trigger rejected"Generate tasks. Use when user asks to \"draft a task\". Do NOT use for \"audit existing tasks\"."Err(InsufficientTriggers { found: 1, needed: 2 }). (Negative trigger doesn't count.)
  8. Description with 2 positive + 1 negative trigger accepts"Generate tasks. Use when user asks to \"draft a task\" or \"turn this PRD into a backlog\". Do NOT use for \"audit existing tasks\"."Ok(()).
  9. Folded YAML multi-line description flattened correctlydescription: >-\n Line one with first trigger \"phrase one\".\n Line two with second trigger \"phrase two\".\n Outputs X.\n → flattened to single line and validated as if one-line; → Ok(()).
  10. JSONSchema mirror agrees on length bounds — running ajv validate -s skill.schema.json -d <fixture> against the same 4 length-test fixtures yields the same accept/reject pattern as the Rust validator.
  11. CLI exit codescyberos skill validate <valid> → 0; cyberos skill validate <missing-triggers> → 6 (SchemaViolation); cyberos skill validate <too-short> → 6.
  12. Auditor rule FM-112 fires on production skill with bad description — running task-audit against an accepted-status skill whose description has 1 trigger → audit report contains one rule_id: FM-112, severity: error, status: needs_human issue.
  13. Auditor rule FM-112 fires as warning on draft skill — same input but status: draft → audit report contains one rule_id: FM-112, severity: warning, status: needs_human issue.
  14. Auditor never auto-fixes description — even when auto_fix_applied could be true in principle, the rule's auto_fix_applied: false is preserved across all runs.
  15. Backfill exemplar — task-author — after TASK-SKILL-111 ships, modules/skill/task-author/SKILL.md description carries ≥2 trigger phrases; running cyberos skill validate on it returns Ok; running the auditor returns no FM-112 issues.
  16. Backfill exemplar — task-audit — analogous; description carries ≥2 trigger phrases including ones distinct from the author's so the classifier disambiguates.
  17. Backfill exemplar — prd-author — analogous, completing the 3 exemplars cited in the §6.1 of modules/skill/ANTHROPIC_GUIDE_DIGEST.md.
  18. README Part 2.1 row updated — the description-field row in Part 2.1 of modules/skill/README.md shows new min/max + cross-references TASK-SKILL-111.
  19. task-audit skill §3.13 entry added — new sub-rule "Description format" with example good + bad descriptions.
  20. Cross-task reciprocity preserved — TASK-SKILL-103's blocks: list updated to include TASK-SKILL-111 (since 103 is the parent frontmatter spec).
  21. OTel span emitted — every validate call emits skill.description.validate with attributes skill_id, outcome (ok | too_short | too_long | forbidden_brackets | missing_what | insufficient_triggers), length_chars, trigger_count, duration_ms.

§5 — Verification

// services/skill-broker/tests/description_validator_test.rs

use cyberos_skill_broker::frontmatter::description_validator::{validate, DescriptionViolation};

#[test]
fn valid_description_with_two_triggers() {
    let d = r#"Generate a task@1 markdown from PRDs. Use when user asks to "draft a task" or "turn this PRD into a backlog". Outputs versioned task-NNN-slug.md files with anti-fabrication discipline."#;
    assert!(validate(d).is_ok());
}

#[test]
fn too_short() {
    let d = r#"Generate tasks. Use "draft" or "audit"."#; // 38 chars
    assert_eq!(validate(d).unwrap_err(), DescriptionViolation::TooShort { len: 38 });
}

#[test]
fn too_long() {
    let d = "A".repeat(1100);
    let d = format!(r#"Generate tasks. Use when user asks to "draft" or "audit". {d}"#);
    let len = d.chars().count();
    assert_eq!(validate(&d).unwrap_err(), DescriptionViolation::TooLong { len });
}

#[test]
fn forbidden_brackets() {
    let d = r#"Generate <task> markdowns. Use when user asks to "draft" or "audit"."#;
    assert_eq!(validate(d).unwrap_err(), DescriptionViolation::ForbiddenBrackets);
}

#[test]
fn missing_what_verb() {
    let d = r#"Helps with tasks in the backlog. Useful when user says "task" or "backlog" or "story". Returns markdown."#;
    assert_eq!(validate(d).unwrap_err(), DescriptionViolation::MissingWhat);
}

#[test]
fn insufficient_triggers_single_positive() {
    let d = r#"Generate tasks from a PRD source. Use when user asks to "draft a task". Outputs versioned files in a structured backlog directory under output_dir."#;
    assert_eq!(
        validate(d).unwrap_err(),
        DescriptionViolation::InsufficientTriggers { found: 1, needed: 2 }
    );
}

#[test]
fn negative_trigger_does_not_count() {
    let d = r#"Generate tasks from a PRD source. Use when user asks to "draft a task". Do NOT use for "audit existing tasks". Outputs versioned task-NNN-slug.md files."#;
    assert_eq!(
        validate(d).unwrap_err(),
        DescriptionViolation::InsufficientTriggers { found: 1, needed: 2 }
    );
}

#[test]
fn two_positive_plus_one_negative_accepts() {
    let d = r#"Generate tasks from a PRD source. Use when user asks to "draft a task" or "turn this PRD into a backlog". Do NOT use for "audit existing tasks". Outputs versioned files."#;
    assert!(validate(d).is_ok());
}

#[test]
fn folded_yaml_multiline_flattens() {
    // Simulate what serde_yaml emits after parsing a `description: >-` block.
    let d = "Generate tasks from a PRD source. Use when user asks to\n\"draft a task\" or \"turn this PRD into a backlog\".\nOutputs versioned files.";
    assert!(validate(d).is_ok());
}

#[test]
fn exact_floor_80_chars_with_two_triggers() {
    // Smallest valid string: exactly 80 chars including 2 triggers + verb.
    let d = r#"Audit tasks. Use when user asks to "audit task" or "check task". Reports issues."#;
    assert_eq!(d.chars().count(), 74); // sanity — below floor, should fail
    assert_eq!(validate(d).unwrap_err(), DescriptionViolation::TooShort { len: 74 });

    let d = r#"Audit task@1 markdowns. Use when user asks to "audit a task" or "check rubric". Reports per-rule verdicts."#;
    assert!(d.chars().count() >= 80);
    assert!(validate(d).is_ok());
}

#[test]
fn cli_exit_code_on_violation() {
    use assert_cmd::Command;
    let bundle = tempfile::tempdir().unwrap();
    // ... write a SKILL.md with too-short description ...
    let mut cmd = Command::cargo_bin("cyberos-skill-validate").unwrap();
    cmd.arg(bundle.path()).assert().failure().code(6);
}

#[test]
fn jsonschema_mirror_agrees_on_length_bounds() {
    // Run ajv-CLI against the 4 length-test fixtures and assert
    // accept/reject pattern matches Rust validator.
    use std::process::Command;
    let outputs = ["description-valid", "description-too-short", "description-too-long", "description-missing-triggers"]
        .iter()
        .map(|fix| {
            Command::new("ajv")
                .args(["validate", "-s", "skill.schema.json", "-d", &format!("tests/fixtures/{fix}/skill.json")])
                .status()
                .unwrap()
                .success()
        })
        .collect::<Vec<_>>();
    // valid + too-long-by-pattern → ajv catches length; missing-triggers ajv ignores (rust-only).
    assert_eq!(outputs, vec![true, false, false, true /* ajv can't see triggers */]);
}

Auditor regression fixtures (added to modules/skill/task-audit/acceptance/)

# Three new golden fixtures:
acceptance/regression-2026-05-19-fm112-missing-triggers/
  golden-input.json    # a task-style fixture with frontmatter that fails FM-112
  golden-output.audit.md   # expected audit report with one FM-112 issue

§6 — Implementation skeleton

Most of the surface is in §3 (Rust types + validator + JSONSchema diff + auditor rule + template diff). The remaining orchestration is small:

  1. Validator wiringservices/skill-broker/src/frontmatter/validators.rs's top-level validate(fm, body, broker_version) calls description_validator::validate(&fm.description)? as the first check.
  2. CLI integrationcyberos skill validate already exists (TASK-SKILL-103 §3 CLI block). No change needed in the CLI itself — the new validator surfaces through the same Err path.
  3. Auditor integrationtask-audit/RUBRIC.md is the lookup table; the auditor's 8-step loop (per _template/audit/AUDIT_LOOP.md) runs every rule including FM-112.
  4. Lazy-backfill mechanic — no automation. The new RUBRIC rule fires on status: accepted skills; first audit cycle after task ship surfaces the issue; author addresses during normal fine-tune flow. No bulk sweep.

§7 — Dependencies

Depends on:

Blocks: none (independent of TASK-SKILL-112 and TASK-SKILL-113).

Related:

Cross-module:

§8 — Example payloads

Example 1 — valid description (the canonical good)

description: >-
  Generate a versioned task@1 markdown from one or more PRD/spec/SRS
  documents. Use when user asks to "draft a task", "turn this PRD into a backlog",
  or "expand this spec into tasks". Halts at PLAN approval and HITL gates;
  resumable from manifest.json. Chains into task-audit by default.
  Outputs task-NNN-slug.md files with per-claim authority markers + provenance.
  Do NOT use for "audit existing tasks" (use task-audit instead).

Flattened length: ~478 chars. Triggers detected: 3 positive ("draft a task", "turn this PRD into a backlog", "expand this spec into tasks") + 1 negative ("audit existing tasks" after Do NOT use for). Verb stems: generate, chain (multiple). Verdict: OK.

Example 2 — auditor issue block (FM-112 firing)

ISSUE
id:              ISS-007
rule_id:         FM-112
severity:        error
category:        description_format
location:        frontmatter "description:" field
evidence:        "Author a closure markdown from project state. Halts at HITL gates. Outputs versioned closure@1 files."
description:     "Description fails FM-112: insufficient_triggers (found: 0, needed: 2). The description has no quoted trigger phrases — the classifier sees only WHAT, not WHEN."
suggestion:      "Add ≥2 quoted trigger phrases. Example: 'Use when user asks to \"close the project\" or \"draft the closure report\"'. See task-audit skill §3.13."
auto_fix_applied: false
resolution:      null
opened_at:       "2026-05-19T14:00:00Z"
updated_at:      "2026-05-19T14:00:00Z"

Example 3 — OTel span

{
  "name": "skill.description.validate",
  "attributes": {
    "skill_id": "task-author",
    "outcome": "ok",
    "length_chars": 487,
    "trigger_count": 3,
    "duration_ms": 0.4
  }
}

Example 4 — CLI JSON output

{
  "status": "fail",
  "skill_id": "closure-author",
  "violation": {
    "kind": "description_format",
    "sub_code": "insufficient_triggers",
    "found": 0,
    "needed": 2,
    "description_length": 97
  }
}

§9 — Open questions

All resolved during authoring.

Deferred to follow-up tasks (out of scope here):

§10 — Failure modes inventory

FailureDetectionOutcomeRecovery
Author writes description ≤80 charscyberos skill validateTooShort; CI gate failsSkill not promoted to acceptedAuthor rewrites; re-runs validate
Author writes description >1024 charscyberos skill validateTooLong; CI gate failsSkill not promotedAuthor trims; re-runs validate
Author embeds <some-marker/> in descriptionValidator → ForbiddenBrackets; CI gate failsSkill not promotedAuthor rewrites without brackets; if a marker is needed, move it to metadata:
Author writes Helps with X (no verb stem)Validator → MissingWhat; CI gate failsSkill not promotedAuthor rewrites with concrete action verb
Author writes single quoted triggerValidator → InsufficientTriggers { found: 1, needed: 2 }Skill not promotedAuthor adds second trigger phrase
Author writes 2 negatives + 0 positivesValidator → InsufficientTriggers { found: 0, needed: 2 }Skill not promotedAuthor adds positive trigger forms
Production skill backfilled wrong — trigger phrase doesn't match classifier behaviourOBS reports acceptance_rate drop on that skill; manual fine-tune triggered (per human_fine_tune.signals_to_initiate)Auto-pause at <40% per DEC-055Fine-tune cycle: update description triggers; re-test against TRIGGER_TESTS.md (TASK-SKILL-112 when shipped)
Two skills' descriptions overlap in trigger phrasesSupervisor classifier returns ambiguous routing; surfaces Question primitive ("which workflow do you mean?")User clarifiesFine-tune both skills: differentiate triggers; cross-reference in ## When to invoke this skill body section
Description triggers stale (skill behaviour changed but description didn't)Auditor rule FM-112 still passes (triggers exist); but OBS shows acceptance_rate dropOperator-initiated fine-tuneUpdate description AND body section together (§1 #15 keeps them in sync)
Author flattens YAML multi-line wrong (e.g. `- instead of >-`)YAML parser preserves newlines; flattened length still under cap; tests still pass — but the host's prompt receives literal \n charactersCosmetic issue only; no functional breakDocumentation note in task-audit skill §3.13 prefers >- (folded)
JSONSchema mirror drifts from Rust validatorCI gate runs both validators against fixtures; mismatch on any one fixture → CI failsBuild brokenSync schema.rs ↔ skill.schema.json via cargo xtask schema (TASK-SKILL-103 §11)
OTel span attribute schema driftsDashboard panels expecting outcome attribute see validation_outcome after a rename → no dataVisible in OBS dashboard panelsRename only via MINOR-bump + dashboard migration; never silently
FM-112 rule mis-classifies a verb stem (e.g. track matches but author meant another sense)Rule auto-fix never fires (always needs_human); operator reviews and either accepts the suggestion or extends the verb-stem regex via a future PATCHNo silent damageExtend VERB_STEMS regex via PR; bump RUBRIC PATCH version
Backfill exemplar (task-author) inadvertently breaks a working chainPer-skill acceptance fixture catches it on next CI runChain works on golden fixture but fails on a real PRDRollback description change; re-author with the existing trigger phrases preserved

§11 — Implementation notes


End of TASK-SKILL-111.