Go 141 lines
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/<name>/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(<Verb>:<Scope>), 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, " ")
}