package definition import ( "fmt" "strings" ) // One Markdown definition → one skill. // // A port of parseSkill and validateSkillSource in src/lib/skills/registry.js, // restricted to the definition contract — see the package documentation in // definition.go for exactly where that boundary is and why the `ui:` and // `owliver:` blocks are on the other side of it. // Level is one rung of a workforce ladder, read from the body's own headings. type Level struct { Level string `json:"level"` Label string `json:"label"` Summary string `json:"summary"` } // Skill is a definition as the backend reads it. // // The five fields migration 000005 projects into columns — ID, Name, // Description, Status, Pages — are the compatibility contract; the rest is // carried because it is free once the frontmatter is parsed and because the // conformance suite compares it. type Skill struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` Status string `json:"status"` // Pages as the AUTHOR WROTE THEM, not canonicalised. // // This is not an oversight and must not be "fixed": parseSkill keeps the // declared strings, so a skill written against `Talent Pool` is registered // under `Talent Pool` and resolved through normalizeKey at every use. // Agents are the other way round — see Agent.Pages. Canonicalising here // would make the backend's projection disagree with the editor's. Pages []string `json:"pages"` Kind string `json:"kind"` Category string `json:"category"` Actions []string `json:"actions"` Triggers []string `json:"triggers"` DeclaredTriggers bool `json:"declaredTriggers"` Prompt *string `json:"prompt"` SkillID *string `json:"skillId"` Levels []Level `json:"levels"` // Body is the Markdown after the frontmatter, trimmed. The definition // itself is NEVER rewritten — see Definition.Markdown. Body string `json:"-"` // Deferred names the frontmatter blocks whose semantics this package does // not check and the frontend does. Empty for every definition the backend // can fully validate on its own. See package documentation. Deferred []string `json:"deferred,omitempty"` } // AuthoredPath is the origin an authored definition has when the caller names // none. It is a value rather than an absence for one reason: it is the // frontend's own default parameter. // // parseSkill(raw, { path = 'custom', custom = false } = {}) // parseAgent(raw, { path = 'custom', custom = false } = {}) // // validateSkillSource and validateAgentSource both call their parser with no // path, so every definition the EDITOR checks derives its last-resort id from // the literal string `custom`. That is the same call the backend is making — a // definition submitted to the API is authored, not shipped — so the backend // must derive the same id. // // The difference is reachable and it is not cosmetic. A definition with no // `id:`, no `name:` and a valid `pages:` list gets the id `custom` on the // frontend, passes the id-format check and is ACCEPTED. Deriving no id here // would refuse it with "The frontmatter needs an `id`." — a definition that // validates in the editor and fails on save, which is the exact failure mode // this package exists to prevent. Fixture case: id-omitted-unnamed. const AuthoredPath = "custom" // Options carries what the caller knows that the definition does not. type Options struct { // Path is the definition's origin, used only as the last fallback for an // id. Leave it empty for anything authored rather than shipped — which is // what the backend always has — and it becomes AuthoredPath, exactly as the // frontend's default parameter does. Path string } // path is the origin an id is derived from, with the frontend's default // applied. func (o Options) path() string { if o.Path == "" { return AuthoredPath } return o.Path } // ParseSkill reads a skill definition. // // Returns a *Error when the frontmatter cannot be read. A definition with no // frontmatter at all is not an error here: it parses to a skill carrying the // derived id and no pages, and ValidateSkill is what refuses it — the same // division of labour the frontend has. func ParseSkill(raw string, opts Options) (*Skill, error) { doc, err := ParseFrontmatter(raw) if err != nil { return nil, err } data := doc.Data declaredPages, _ := data["pages"].([]any) // `id:` always wins. An explicit id is the address other definitions and // stored preferences refer to, and deriving over the top of one would // silently rename a skill. Slugging the name is what an author means by // leaving it out; the filename is right only for a file, which is why it // is last. id := jsTrim(jsString(data["id"])) if !jsTruthy(data["id"]) { id = slugify(data["name"]) if id == "" { id = fileStem(opts.path()) } } levels := sectionLevels(doc.Body) // Two things wear the same format. A definition that names a ladder is a // workforce skill; nothing else distinguishes them, so an author declares // one by writing one rather than by setting a flag. kind := jsTrim(jsString(data["kind"])) if !jsTruthy(data["kind"]) { kind = "assistant" if len(levels) > 0 { kind = "workforce" } } skill := &Skill{ ID: id, Kind: kind, Levels: levels, Body: doc.Body, Name: "Untitled skill", Pages: stringsOf(declaredPages), } if jsTruthy(data["name"]) { skill.Name = jsString(data["name"]) } if jsTruthy(data["description"]) { skill.Description = jsString(data["description"]) } if s, ok := data["category"].(string); ok { skill.Category = jsTrim(s) } // The whole of a skill's lifecycle, and deliberately a coercion rather // than a check: the frontend reads anything that is not `inactive` as // `active`, so `status: bogus` registers as active rather than being // refused. Reproduced, not corrected — see the divergence note in // docs/phase-4d-parser-contract.md. skill.Status = "active" if s, ok := data["status"].(string); ok && s == "inactive" { skill.Status = "inactive" } if actions, ok := data["actions"].([]any); ok { skill.Actions = stringsOf(actions) } else { skill.Actions = []string{} } // A skill with no declared triggers answers to its own name, so a // definition that omits the field is still reachable by asking for it. // Explicit triggers replace the fallback rather than adding to it. triggers, hasTriggers := data["triggers"].([]any) skill.DeclaredTriggers = hasTriggers && len(triggers) > 0 skill.Triggers = []string{} if skill.DeclaredTriggers { for _, t := range triggers { skill.Triggers = append(skill.Triggers, strings.ToLower(jsString(t))) } } else if jsTruthy(data["name"]) { skill.Triggers = append(skill.Triggers, strings.ToLower(jsString(data["name"]))) } if jsTruthy(data["prompt"]) { p := jsString(data["prompt"]) skill.Prompt = &p } // The capability in the skill graph a workforce definition governs: // `skill:`, or the id with a `-training` suffix dropped and dashes swapped // for underscores. if kind == "workforce" { base := id if jsTruthy(data["skill"]) { base = jsString(data["skill"]) } else { base = strings.TrimSuffix(base, "-training") } s := strings.ReplaceAll(base, "-", "_") skill.SkillID = &s } skill.Deferred = deferredBlocks(data) return skill, nil } // sectionLevels reads the ladder a workforce definition defines, in order, from // the body's own headings. A rung with no prose is not a rung. func sectionLevels(body string) []Level { out := []Level{} for _, heading := range levelHeadings { summary := SectionText(body, heading) if summary == "" { continue } out = append(out, Level{ Level: strings.ToLower(heading), Label: heading, Summary: summary, }) } return out } // deferredBlocks names the frontmatter this package does not semantically // check. See the package documentation for why they are deferred rather than // validated or rejected. func deferredBlocks(data map[string]any) []string { out := []string{} for _, key := range []string{"ui", "owliver"} { if _, present := data[key]; present { out = append(out, key) } } if len(out) == 0 { return nil } return out } // stringsOf renders a parsed sequence as the strings the frontend would read // out of it. Non-string entries are stringified rather than dropped, because // that is what every consumer of `pages` and `actions` does with them. func stringsOf(list []any) []string { out := make([]string, 0, len(list)) for _, v := range list { out = append(out, jsString(v)) } return out } func fileStem(path string) string { if path == "" { return "" } if i := strings.LastIndexByte(path, '/'); i >= 0 { path = path[i+1:] } return strings.TrimSuffix(path, ".md") } // ValidateSkill decides whether a skill definition may be stored. // // Returns nil when it may. The order is the order an author would fix things // in, and every message below is the frontend's message character for // character — an author who sees one in the editor and a different one from // the API is being told about two different problems. // // Two rules are the backend's own and are marked as such: the size bound and // the deferred-block rule. Both are explained in // docs/phase-4d-parser-contract.md. func ValidateSkill(raw string) error { if jsTrim(raw) == "" { return &Rejection{Message: "Paste or upload a Markdown definition."} } // The backend's own rule, from migration 000005's markdown_size CHECK. The // editor does not enforce it, so a definition over the bound is one the // frontend accepts and the DATABASE refuses; refusing it here turns a // constraint violation into a message. See the contract document. if n := len([]rune(raw)); n > MaxMarkdownLength { return &Rejection{ BackendOnly: true, Message: fmt.Sprintf( "That definition is %d characters. The limit is %d.", n, MaxMarkdownLength), } } skill, err := ParseSkill(raw, Options{}) if err != nil { // The subset reports the line it failed on, which is far more useful // than "could not be parsed". return &Rejection{Message: jsTrim("That definition could not be parsed. " + err.Error())} } if skill.ID == "" { return &Rejection{Message: "The frontmatter needs an `id`."} } if !isDefinitionID(skill.ID) { return &Rejection{Message: "`id` must be lower-case letters, numbers and dashes."} } // Faithful to the frontend, where `name` has already fallen back to // `Untitled skill` and this check can therefore never fire. Kept so the // two validators have the same shape and the same order. if skill.Name == "" { return &Rejection{Message: "The frontmatter needs a `name`."} } // The backend's own rule, and it must be asked BEFORE the generic one // below. A `ui:` block declares the pages it draws on, and parseSkill falls // back to those pages when `pages:` is absent — a fallback this package // cannot compute, because it does not read the `ui:` vocabulary. Rather // than report an empty page list it never really established, say what is // actually missing. No shipped definition relies on the fallback: all // nineteen that carry a `ui:` or `owliver:` block also declare `pages:`. if len(skill.Pages) == 0 && len(skill.Deferred) > 0 { return &Rejection{ BackendOnly: true, Message: "A definition with a `ui:` block needs an explicit `pages:` list.", } } if len(skill.Pages) == 0 { return &Rejection{Message: "The frontmatter needs at least one `pages` entry."} } unknown := []string{} for _, p := range skill.Pages { if !SurfaceExists(p) { unknown = append(unknown, p) } } if len(unknown) > 0 { plural := "" if len(unknown) > 1 { plural = "s" } return &Rejection{Message: fmt.Sprintf( "Unsupported page%s: %s. Supported pages: %s.", plural, strings.Join(unknown, ", "), strings.Join(SupportedPages, ", "))} } return nil }