package cockpit import ( "errors" "fmt" "strings" ) // claudeRenderer emits a Playbook as a Claude Code SKILL.md: YAML // frontmatter with the three keys Claude reads (name, description, // allowed-tools) followed by a Markdown body. The harness auto-discovers a // skill only at .claude/skills//SKILL.md, so RelPath is fixed to that // layout. type claudeRenderer struct{} func (claudeRenderer) Target() string { return "claude" } // Enforcement reports that Claude enforces the allowed-tools allowlist at // runtime — the one enforced target. It satisfies the Fidelity interface. func (claudeRenderer) Enforcement() Enforcement { return EnforcementEnforced } func (claudeRenderer) RelPath(p Playbook) string { return ".claude/skills/" + p.Name + "/SKILL.md" } // Render produces the SKILL.md bytes. It is deterministic and rejects a // Playbook whose single-line frontmatter fields (description, composed // allowed-tools) would span multiple lines — the frontmatter here is // line-oriented, so a stray newline would corrupt it. func (r claudeRenderer) Render(p Playbook) ([]byte, error) { desc := strings.TrimSpace(p.Description) allowed := strings.Join(composeAllowedTools(p), ", ") if strings.ContainsAny(desc, "\r\n") { return nil, errors.New("playbook description must be a single line") } if strings.ContainsAny(allowed, "\r\n") { return nil, errors.New("composed allowed-tools must be a single line") } var b strings.Builder b.WriteString("---\n") fmt.Fprintf(&b, "name: %s\n", p.Name) fmt.Fprintf(&b, "description: %s\n", desc) fmt.Fprintf(&b, "allowed-tools: %s\n", allowed) b.WriteString("---\n") fmt.Fprintf(&b, "# %s\n", deriveTitle(p.Name)) b.WriteString(deriveSafetyWarning(p.Intent)) b.WriteString("\n") for _, s := range p.Steps { fmt.Fprintf(&b, "\n## Step %d — %s\n", s.Index, s.Title) if body := strings.TrimRight(s.Body, "\n"); body != "" { b.WriteString(body) b.WriteString("\n") } if len(s.Runs) > 0 { b.WriteString("\n```\n") for _, run := range s.Runs { b.WriteString(run) b.WriteString("\n") } b.WriteString("```\n") } } if out := strings.TrimRight(p.OutputFormat, "\n"); out != "" { b.WriteString("\n## Output\n") b.WriteString(out) b.WriteString("\n") } return []byte(b.String()), nil } // composeAllowedTools walks Capabilities in declared order and renders each // to its Claude allowlist spelling: a tool becomes its Name; a bash // capability becomes Bash(:), defaulting Scope to "*". The // declared order is preserved (no reorder, no dedupe) so the JSON stays the // single source of truth and the output is deterministic. func composeAllowedTools(p Playbook) []string { out := make([]string, 0, len(p.Capabilities)) for _, c := range p.Capabilities { switch c.Kind { case "tool": if c.Name != "" { out = append(out, c.Name) } case "bash": if c.Verb == "" { continue } scope := c.Scope if scope == "" { scope = "*" } out = append(out, fmt.Sprintf("Bash(%s:%s)", c.Verb, scope)) } } return out } // deriveSafetyWarning builds the bold body warning from the structured // Intent — never hand-written in the body data. It opens with the positive // guarantee and names every Intent.Forbidden phrase verbatim, so the // rendered warning is provably in sync with the gate's denylist. func deriveSafetyWarning(in Intent) string { var b strings.Builder b.WriteString("**") if g := strings.TrimSpace(in.Guarantee); g != "" { b.WriteString(g) if !strings.HasSuffix(g, ".") { b.WriteString(".") } b.WriteString(" ") } if len(in.Forbidden) > 0 { b.WriteString("Never: ") b.WriteString(strings.Join(in.Forbidden, ", ")) b.WriteString(".") } b.WriteString("**") return b.String() } // deriveTitle turns a playbook name into a body heading: word-split on "-" // and "_", each word capitalized, joined with spaces ("handover" -> // "Handover", "doc-drift" -> "Doc Drift"). func deriveTitle(name string) string { fields := strings.FieldsFunc(name, func(r rune) bool { return r == '-' || r == '_' }) for i, w := range fields { if w == "" { continue } fields[i] = strings.ToUpper(w[:1]) + w[1:] } if len(fields) == 0 { return name } return strings.Join(fields, " ") }