ajhahn.de
← eeco
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, " ")
}