Help us shape the future of Go! Take the 2026 Go Developer Survey

skilldoc

package
v0.19.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 2, 2026 License: MIT Imports: 13 Imported by: 0

Documentation

Overview

Package skilldoc is the pure half of `hadron skill` (hadron-cli#580): the contract between a Hadron task node and the skill file exported from it. It knows how a skill's NAME is derived, how its provenance HASH is computed, what the generated file looks like, how to read one back, and which corpus rules a declaring node must satisfy. It touches neither the network nor the disk, so every rule here is table-testable; the command package wires it to GraphQL and the filesystem.

The design is in docs/plans/skill-command-group.md. The two load-bearing decisions: the node is the source and the file is a build artifact (D2), and staleness is content-addressed — the file records a hash of the exact inputs it was built from, so drift is detected by recomputation and never by clock (D5).

Index

Constants

View Source
const (
	MaxNameLen        = 64
	MaxDescriptionLen = 1024
)

Host limits for a Claude Code skill, per the skill spec (verified against the official skill-creator validator, quick_validate.py), counted in CHARACTERS — the validator is Python and counts code points, so an em dash is one, not three. The host does not REFUSE an over-long description — it truncates it in the skill listing, so the trigger phrases past the cut silently stop firing. That is why the description rule is an error rather than a warning.

View Source
const (
	SevError   = "error"
	SevWarning = "warning"
)

Finding severities — the same strings `spec lint` renders, so a caller scripting both sees one vocabulary.

View Source
const (
	HostClaudeSkill = "claudeSkill"
	HostCodexSkill  = "codexSkill"
)

HostClaudeSkill and HostCodexSkill are the skill hosts, each named by its D12 key under `exports`, verbatim. There is ONE renderer: Codex reads the same SKILL.md shape as Claude (measured on codex-cli 0.153.0, hrn:node:hadronmemory.com:hadron-cli:reference:codex-skill-host), so what differs per host is data — its limits and whether the retired keys alias to it — and lives in Hosts. hadron-server's registry (src/lib/skilldoc/hosts.ts) carries the same rows; the cross-host matrix (testdata/crosshost-acceptance.json) pins both against cor:agt:030.

View Source
const ExportsKey = "exports"

ExportsKey is the property holding a node's per-host export declarations (D12): an OBJECT keyed by host, so "one export per host" is structural and discovery stays a key check rather than a containment match.

View Source
const MaxRevision = 2147483647

MaxRevision is the largest `rev=` a header may carry: Node.revSeq's signed 32-bit column, hadron-server's MAX_SKILL_REVISION (#1339).

View Source
const RuleUnknownHostKey = "skill-unknown-host-key"

RuleUnknownHostKey names the finding for an `exports` key no host owns. One constant, mirrored by hadron-server's RULE_UNKNOWN_HOST_KEY, so a rename is one line on each side.

Variables

View Source
var Hosts = []Host{
	{Key: HostClaudeSkill, MaxNameLen: MaxNameLen, MaxDescriptionLen: MaxDescriptionLen, LegacyAliases: true, ReservedNames: []string{"synced"}, RejectsDescriptionMarkup: true},
	{Key: HostCodexSkill, MaxNameLen: 64, MaxDescriptionLen: 1024, LegacyAliases: false, ReservedNames: []string{}},
}

Hosts is every host, Claude first. Both hosts cut a description at 1,024 in the listing their model sees and cap a name at 64 (cor:agt:030:05).

Functions

func Hash

func Hash(id, source, name, description, content string) string

Hash is the provenance fingerprint a generated file records: the first 16 hex characters of SHA-256 over the five inputs the file is made from — the node ID, source node URN, name, description and body, in that order — NUL-separated so a boundary shift cannot collide. It is deliberately not nodedoc.ContentHash (content only): the description is the trigger, so an edit to it must read as stale; and the source is included so a hand-edited provenance line that names a different node reads as a local edit rather than pairing the file with a node it was never rendered from (Codex on #589, round 12). Every input is present in the file itself, which is what lets a reader recompute the hash from the file alone and detect a hand edit without the server.

Inputs are hashed in their exported form (NormalizeDescription, NormalizeBody) — what Render writes and ParseFile reads back — so a node whose content is wrapped in blank lines and the file built from it fingerprint identically. Without that, every export would classify as stale against its own header. The node ID is hashed FIRST, and including it is the point of §4a (hadron-server#1235). Left out, editing ONLY the header id keeps hash(file) == headerHash, so the file reads as untouched while pairing to a DIFFERENT node — `stale` rather than `locally-edited`, and therefore overwritten despite A1. Pairing by id is what survives a `loc` rename: moveNode keeps the id while the URN is computed from the loc, so URN-only pairing turns a rename into orphaned + never-exported — two directories with near-identical trigger text, both firing.

THE ID IS ALWAYS HASHED, including when it is empty. There is no conditional and no second form of this function (Holger, 2026-09-23).

The history is worth keeping, because the resolution is not the one either side argued for. @Dara measured that hashing an empty id means a pre-§4a file does NOT recompute to its own stored header hash. That is true. Both of the answers on the table tried to fix it INSIDE the hash — skip the empty id, or keep hashing it and accept the consequence — and each puts a branch in a function that two implementations must agree on byte for byte, which is precisely the thing that then diverged across the two repos for a day.

The ruling removes the question instead of answering it: a file with no `id=` was never written by the current exporter, so there is nothing to validate and its hash is never computed at all. The CLIENT skips it and says so in the export report; `--force` overrides, exactly as it does for a hand-edited file, because both are the same promise — we will not silently replace work we cannot prove nobody did.

So the branch moves OUT of the hash and into a direct observation (is there an `id=` key?), which needs no cross-implementation convention because it reads a fact off the file rather than computing one.

func IsReservedName added in v0.17.0

func IsReservedName(h Host, name string) bool

IsReservedName reports whether name is one host h reserves. The host reserves the name "in any capitalization", so it is compared by collation at primary strength, which folds case, diacritics, width and compatibility variants (`SYNCED`, the long s in `ſynced`) — the Go counterpart of hadron-server's ICU search collator at base sensitivity (src/lib/skilldoc/hosts.ts). The two agree on every name the skill-name grammar admits; outside it they can differ on an exotic code point (x/text carries Unicode 6.2 tables, the server's ICU a newer Unicode), and any such name already fails skill-name-invalid, so no verdict changes. The name is trimmed, as the planner exports it.

func Malformed

func Malformed(props map[string]any) []string

Malformed reports the declaration property paths a node carries whose value is the wrong shape — `"exports": "yes"`, `"exports": {"claudeSkill": true}`, a non-string description, a non-boolean enable. Such a node is selected by the server-side discovery predicate (the key EXISTS), so without a finding it would be silently skipped and counted in nobody's total — an all-clear wider than the read that produced it.

func MalformedFor added in v0.15.0

func MalformedFor(props map[string]any, h Host) []string

MalformedFor is Malformed as host h's plan reports it.

func NormalizeBody

func NormalizeBody(s string) string

NormalizeBody is the node content as exported: CRLF folded to LF (a body authored on Windows is the same body), then leading and trailing newlines trimmed. Render writes this, Hash fingerprints this, every body rule reads this, and ParseFile's header-stripping is its exact inverse — so a body that begins with a blank line, or carries \r\n, does not make a fresh export read as locally edited or slip past a rule written for LF.

func NormalizeDescription

func NormalizeDescription(s string) string

NormalizeDescription is the description as exported: surrounding whitespace trimmed. Lint measures this and Render writes this, so the length lint certifies is the length the host sees — a description with a trailing space is not 1025 characters on disk.

func ParseProvenance added in v0.15.0

func ParseProvenance(data []byte) (id, source, hash string, ok bool)

ParseProvenance recovers a file's provenance WITHOUT requiring its frontmatter to be valid YAML — the one thing still knowable about a file that does not parse.

It exists because the pairing key lives INSIDE the file that will not parse, and a parse failure the client cannot attribute is reported as an ORPHAN: a claim that a file belongs to no declared node, which is false and which is what `export --prune` deletes. Measured against the live corpus, three real skills (an export wrote an unquoted description containing ": ") took exactly that path. The frontmatter DELIMITERS still match when the YAML inside them does not, so the header below them is still readable.

It answers only "whose file is this", never "is it current": no hash can be recomputed from a file whose frontmatter is unreadable, so a caller sends this alongside parseFailed and lets the server decline to classify it.

func Render

func Render(id, name, source, description, content string) (string, error)

Render composes the skill file (§4.3): YAML frontmatter with the two keys a host reads, the machine-parseable provenance line, the human note, and the node body NORMALIZED (NormalizeBody: CRLF folded, surrounding newlines trimmed — the same form Hash fingerprints and ParseFile reads back; inner content is untouched). The frontmatter goes through the real YAML encoder (nodedoc.MarshalYAML, the library nodedoc already depends on) rather than hand-quoting: a description ending in a colon, or in a space, is a plain scalar a hand check would pass and a real parser would reject or trim, and the host reading this file IS a real parser.

func UnknownHostKeys added in v0.15.0

func UnknownHostKeys(props map[string]any) []string

UnknownHostKeys returns the keys under properties.exports that no host owns, in UTF-8 byte order — the order a report of them must use, so a node carrying several gives the same report on every run and on both implementations. Nil when exports is absent or not an object (the latter is a malformed finding of its own).

func ValidName

func ValidName(name string) bool

ValidName reports whether name satisfies the host's grammar and length.

Types

type Declaration

type Declaration struct {
	Key         string
	Host        string
	Description string
	Name        string
	// NameSet records that a `name` key was present, so an explicitly EMPTY
	// stored name is judged rather than read as absent (Copilot on #589,
	// round 2 — the rule it was found on is gone, the trap is not).
	NameSet   bool
	Enable    bool
	EnableSet bool
}

Declaration is a node's opt-in to export for ONE host: an object at `properties. exports.<host>` (D12), or one of the retired top-level keys read as an alias for the claudeSkill host. Key is the property PATH it was found at, so a message can name what the author actually wrote.

Name is now AUTHORITATIVE and stored: D12 retired derivation, so there is no value to fall back to and a missing name is a finding rather than a default.

Enable is the per-host on/off switch and it DEFAULTS TO OFF (Holger, 2026-09-19). Publishing a skill is the side-effecting act, so it takes an explicit `enable: true`; a declaration that merely exists does not publish. The reason is the corpus: it holds many runnable nodes that are automation and were never meant to be skills at all, so the safe default is the one where nothing ships unless somebody said to ship it.

EnableSet records whether the key was present, which lets a report distinguish "switched off" from "never switched on". It is reporting detail, not a gate.

func Declared

func Declared(props map[string]any) (*Declaration, bool)

Declared reads a node's claudeSkill declaration out of its decoded properties. A declaration is an object at `exports.claudeSkill` or under a retired top-level alias; anything else — absent, or holding a non-object — is "not declared".

Declared is NOT the same question as "will it export": `enable` defaults to off, so most declarations are declared and not enabled. Both states are returned here on purpose — per-node lint (LintFor) judges a declaration whether or not it is enabled, because a broken name is worth reporting BEFORE somebody turns it on, and `status` has to be able to name a file whose declaration is switched off. Collisions are the exception: only an ENABLED declaration claims its name (LintCollisionsFor, cor:agt:030:06).

func DeclaredFor added in v0.15.0

func DeclaredFor(props map[string]any, h Host) (*Declaration, bool)

DeclaredFor is Declared for any host.

type File

type File struct {
	Name        string
	Description string
	// ID is the node's stable id from the header's `id=` key, or "" on a file
	// written before §4a. It is what pairs a file to its node across a `loc`
	// rename, and it is a Hash input — so a reader needs it to recompute the
	// fingerprint from the file alone.
	ID     string
	Source string // the flat v2 source node URN as written, or "" when the file is not Hadron-generated
	Hash   string // "" for a legacy (pre-#580) header
	// Revision is the source node's live revision from the header's `rev=`
	// key (#1323, hadron-server#1339), or 0 when the header carries none — a
	// file rendered before revisions existed, or a legacy header. A present
	// but malformed `rev=` voids the WHOLE header (see machineHeader), so 0
	// never stands in for a value that failed to parse.
	Revision int
	Body     string
	// Extra holds every frontmatter key other than name/description. Render
	// writes none, so on a generated file a non-empty Extra IS a local edit
	// — one the header hash cannot see, since the hash covers the three
	// rendered inputs — and status/export must treat it as `locally-edited`
	// rather than overwrite it (Codex on #589, round 11).
	Extra map[string]any
}

File is what ParseFile reads back from a skill file on disk: the frontmatter keys a host reads, the provenance the header carries, and the body — enough to recompute the hash (Hash(ID, Source, Name, Description, Body)) and pair the file to its source node without the server.

ID comes FIRST, matching Hash's argument order. The canonical description is Hash's own doc comment; this one restates the formula for a reader holding a File, so the two must agree — a stale copy here yields a hash that never matches, and every generated file then reads as locally edited, which is the opposite of what A1 is for. ID was added (§4a, server#1235) precisely so file-only recomputation remains possible.

func ParseFile

func ParseFile(data []byte) (*File, error)

ParseFile reads a skill file with the same YAML parser a host uses, so a header this reader accepts is one the host accepts. A file with no frontmatter is an error; a file with no provenance header parses with Source == "" — it is somebody else's skill, and every `hadron skill` command leaves it alone. The legacy `Generated from` header yields a Source and no Hash.

type Finding

type Finding struct {
	URN      string
	Memory   string
	Rule     string
	Severity string
	Message  string
}

Finding is one lint result. URN names the node and Memory the memory holding it, carried on every finding so a renderer never has to look it back up from the node it came from.

Every finding is now node-level. The memory-level case this used to describe was `skill-prefix-missing`, which D12 retired along with the org prefix — the command layer still synthesises one node-level finding of its own for an unreadable node (`skill-node-unavailable`), keyed on the ref it could not read.

func Lint

func Lint(n Node) []Finding

Lint applies the per-node corpus rules. A node with no declaration and no malformed key yields nothing: not declared is not a defect, it is the opt-in working. Since D12 the name is STORED rather than derived, so the rules judge what the author wrote — and note what can no longer be checked: with no prefix source, a name's PREFIX is unverifiable, so `hadon-foo` lints clean. Shape is checkable, correctness is not.

func LintCollisions

func LintCollisions(nodes []Node) []Finding

LintCollisions reports two nodes whose ENABLED declarations store the SAME skill name (a declaration that is not enabled claims no name). Since D12 the name is stored rather than derived, so a collision is two authors having typed one name — there is no prefix left to keep two orgs' identically-named tasks apart, which is the cost D12 accepts. A node with no stored name is skipped: skill-name-missing is its finding.

func LintCollisionsFor added in v0.15.0

func LintCollisionsFor(nodes []Node, h Host) []Finding

LintCollisionsFor is LintCollisions for host h. Collisions are per host: a name is a directory under ONE host's root, so one node using one name for both hosts is by design, and two nodes sharing a Codex name collide only for Codex.

Only ENABLED declarations collide (cor:agt:030:06, D-2026-09-23-C): a declaration that is not enabled is not exported, so it claims no name — and enable defaults to off, so one with no `enable` claims nothing either. Its earlier file on disk is protected by the export's own rules, not here.

func LintFor added in v0.15.0

func LintFor(n Node, h Host) []Finding

LintFor is Lint judged for host h: its declaration, its limits, and the legacy-key warning only for the host the retired keys alias to.

func LintUnknownHostKeys added in v0.15.0

func LintUnknownHostKeys(n Node) []Finding

LintUnknownHostKeys reports each `exports` key no host owns, as a WARNING attributed to the node and to NO host (cor:agt:030:06, D-2026-09-23-E): the key belongs to none of them, so reporting it under every host would claim the opposite. Never an error, never an alias, never a block — the known hosts beside it are judged by LintFor exactly as if it were absent.

type Host added in v0.15.0

type Host struct {
	Key               string
	MaxNameLen        int
	MaxDescriptionLen int
	// LegacyAliases is true for Claude only. The retired `skill` /
	// `claudeSkill` keys predate hosts and always meant Claude; reading them
	// as another host's declaration would publish somewhere nobody chose.
	LegacyAliases bool
	// ReservedNames are skill names this host will not load from the root an
	// export writes to (server#1315). Claude Code keeps skills synced from
	// claude.ai in `<root>/synced/` and skips a skill authored at that name
	// "in any capitalization" (code.claude.com/docs/en/skills), so an export
	// named `synced` would land in Claude's own folder and never load.
	//
	// Only names the HOST reserves belong here. The `anthropic`/`claude` words
	// the Claude platform bans are an UPLOAD-surface rule (Skills API,
	// claude.ai) that Claude Code does not apply — 2.1.143 loads such names —
	// so listing them would refuse a working skill.
	ReservedNames []string
	// RejectsDescriptionMarkup is true for a host whose upload validator
	// refuses a description containing what it calls an XML tag (cli#722,
	// server#1341). Measured on Cowork by Holger's upload of a 20-case probe
	// (2026-09-25): every refused case matches descriptionMarkupRE and every
	// accepted one does not. Claude only: Codex's loader has no such check
	// (rust-v0.153.0 source) and loads a `<worker>` description (team-chat
	// #1127); the probe itself was not run against Codex.
	RejectsDescriptionMarkup bool
}

Host is one skill host's row: the limits its declarations are judged against, and whether the retired top-level keys are read as its alias.

func HostFor added in v0.15.0

func HostFor(key string) (Host, bool)

HostFor returns the row for a host key, or false for a key no host owns (`codex`, a typo). A caller must not fall back to Claude for an unknown key.

type Node

type Node struct {
	// ID is the node's stable id — the §4a pairing key, which survives the
	// `loc` rename that changes URN.
	ID         string
	URN        string
	Loc        string
	MemoryURN  string
	IsRunnable bool
	Content    string
	Properties map[string]any
}

Node is the slice of a Hadron node the skill contract reads. The command package fills it from a NodeBatch projection — the batched read, because a single-ref read compiles Mustache and would blank `{{name}}`-style placeholders out of a verbatim export (D6).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL