P0.6.1 — Step 2 Behavioral Contract
Grounded in ../audits/p0-6-1-skill-schema-audit.md. Defines the public API, invariants, and error contracts for src/domains/skills/schema.ts. Callers: P0.6.2 (src/domains/skills/repository.ts), eventually skill_list MCP tool.
C1. Public API
All exports live in src/domains/skills/schema.ts. No barrel, no side-effect on import.
C1.1 Constants (read-only)
export const KEBAB_CASE_NAME: RegExp;
// /^[a-z][a-z0-9-]+$/ — one lowercase letter followed by one+ letters/digits/hyphens.
export const CAPABILITIES: readonly ['read', 'write', 'spawn', 'audit', 'admin'];
// Frozen tuple, derived Zod enum below.
export const GREEK_LETTERS: readonly [
'α', 'β', 'γ', 'δ', 'ε',
'ζ', 'η', 'θ', 'ι', 'κ',
'λ', 'μ', 'ν', 'ξ', 'π',
];
// Exactly the 15 letters the task spec enumerates. Upper-case, Roman, and
// non-listed greek (ο, ρ, σ, τ, υ, φ, χ, ψ, ω) are rejected.
C1.2 Zod schemas
export const SkillCapabilitySchema: z.ZodEnum<typeof CAPABILITIES>;
export const GreekLetterSchema: z.ZodEnum<typeof GREEK_LETTERS>;
export const SkillFrontmatterSchema: z.ZodObject<{
name: z.ZodString; // required, KEBAB_CASE_NAME
description: z.ZodString; // required, min(1)
version: z.ZodOptional<z.ZodString>;
entrypoint: z.ZodOptional<z.ZodString>;
capabilities: z.ZodOptional<z.ZodArray<typeof SkillCapabilitySchema>>;
greekLetter: z.ZodOptional<typeof GreekLetterSchema>;
}>;
// .passthrough() — extra frontmatter keys (status, round, updated, model, …)
// are preserved on the parsed object but not type-guaranteed.
C1.3 Derived types
export type SkillCapability = z.infer<typeof SkillCapabilitySchema>;
// = 'read' | 'write' | 'spawn' | 'audit' | 'admin'
export type GreekLetter = z.infer<typeof GreekLetterSchema>;
// = 'α' | 'β' | … | 'π' (15-element literal union)
export type SkillFrontmatter = z.infer<typeof SkillFrontmatterSchema>;
// = { name: string; description: string;
// version?: string; entrypoint?: string;
// capabilities?: SkillCapability[]; greekLetter?: GreekLetter;
// [x: string]: unknown } // passthrough index signature
C1.4 Parser function
export interface ParsedSkill {
frontmatter: SkillFrontmatter; // Zod-validated, fields checked
body: string; // Markdown after the closing `---`, verbatim
path: string; // absolute path the file was read from
}
export function parseSkillFile(absPath: string): ParsedSkill;
// Reads the file synchronously via node:fs.readFileSync.
// Delegates YAML parse + delimiter split to gray-matter.
// Validates the resulting frontmatter with SkillFrontmatterSchema.parse().
// Throws on read failure OR on schema failure (see C3).
The parser is sync by design. The P0.6.2 repository caller scans tens of files at startup; sync keeps the startup path linear and matches the house style set by src/db/index.ts (initDb is sync). No async is needed or offered.
C1.5 Helper (used by the parser and reusable by P0.6.2)
export function parseSkillContent(raw: string, sourcePath?: string): ParsedSkill;
// Pure function: takes the raw file content, runs gray-matter + Zod, returns
// ParsedSkill. Exposed so P0.6.2 can build the repository index from the DB
// blob without hitting the filesystem a second time. `sourcePath` is an
// optional attribution string for error messages (defaults to '<inline>').
C2. Invariants
C2.1 Schema invariants
- Only
nameanddescriptionare required. All other documented fields (version,entrypoint,capabilities,greekLetter) are optional. This is a deliberate deviation from a literal reading of the P0.6.1 spec line; see audit §4a and packet §P4 for the reconciliation. namemust match/^[a-z][a-z0-9-]+$/. Minimum length 2 (one leading letter + one+ trailing).abis valid.ais not (regex requires at least one char after the initial letter).descriptionisz.string().min(1). Whitespace-only descriptions are rejected.capabilities, when present, is an array with at least 0 elements of the 5-member enum. Duplicates within the array are allowed at the schema level (no.refine()for uniqueness) — P0.6.3 is free to de-dupe downstream.greekLetter, when present, is exactly one code-point fromGREEK_LETTERS. Case-sensitive; upper-case (Α) and non-listed letters (ο,ρ,σ, …) are rejected.- Extra frontmatter keys are preserved via
.passthrough().status,round,updated,model, and any future key will round-trip throughparseSkillFile. This is whycolibri-audit-memory(which has astatus: heritagekey) parses without error. - No defaults. If a field is absent from the YAML, it is absent from the parsed object (not defaulted to
undefined, not defaulted to[]). This matchesexactOptionalPropertyTypes: true.
C2.2 Parser invariants
parseSkillFilereads from an absolute path. Relative paths are rejected (path.isAbsolute(absPath) === false→ throw). This stops caller-side CWD bugs (the P0.6.2 startup scan resolves absolute paths from.agents/skills/colibri-*/SKILL.mdbefore calling).- The file must have both opening and closing
---delimiters. A file with no frontmatter (or only opening---) throws.gray-matterhandles this natively — a file without frontmatter producesdata === {}, and our validator rejects empty frontmatter (nameis required). bodyis the content after the closing---verbatim, including the leading newline.gray-matterstrips the newline by default; we preserve the original-style trailing string via the default.contentfield.- UTF-8 only. Unicode Greek letters inside YAML strings are decoded natively because
readFileSync(path, 'utf8')decodes UTF-8 by default. - No network, no spawn, no DB. Pure parse + validate.
C3. Error contracts
C3.1 Error shapes
The parser throws — it does not return an error object. Error classes:
TypeError—absPathnot absolute or not a string.Errorwith a descriptive message — file-read failures propagate fromfs.readFileSyncwith no wrap (ENOENT, EACCES, EISDIR, etc.). Callers can testerr.code === 'ENOENT'.-
SkillSchemaError— thrown whengray-matterparses successfully but the frontmatter fails Zod validation. Exported class:export class SkillSchemaError extends Error { readonly path: string; // sourcePath attribution readonly issues: z.ZodIssue[]; // from ZodError.issues constructor(path: string, issues: z.ZodIssue[]); // message: `SkillSchemaError: ${path}\n${formatIssues(issues)}` } Errorraw — thrown whengray-matteritself can’t parse the YAML (e.g. structurally broken frontmatter). Message includes the source path when available.
Callers (P0.6.2) catch SkillSchemaError separately from read errors to produce per-file skip-with-warning behavior during startup scans.
C3.2 Error-message format
formatIssues(issues) produces one line per Zod issue:
[path.to.field] message (received: "…")
Example (synthetic name: "BadName"):
SkillSchemaError: /abs/path/.agents/skills/example/SKILL.md
[name] String must match the pattern /^[a-z][a-z0-9-]+$/ (received: "BadName")
C3.3 No console output
The parser never writes to console.log/console.warn/console.error. Observation discipline: src/server.ts owns stdout for MCP wire traffic; a schema-layer log to stdout would break the transport. Errors propagate only via throw. Callers decide whether to log, and they log to process.stderr (house style).
C4. Dependency additions
Adds one runtime dependency:
| Package | Version | Purpose | Bundle cost | Transitive deps |
|---|---|---|---|---|
gray-matter |
^4.0.3 |
YAML frontmatter parser wrapping js-yaml. Handles double-quoted strings with embedded quotes, em-dashes, colons, parens, and Unicode — all of which appear in the 21-file corpus per audit §2c. |
~10 kB gzipped, ~40 kB unpacked. | js-yaml, section-matter, kind-of, strip-bom-string, extend-shallow. All battle-tested, no known advisories (npm audit --omit=dev clean in P0.1.4 harness). |
Justification: audit §4c shows that a handwritten parser fails on at least colibri-pm, colibri-memory-context, and colibri-roadmap-progress. Using js-yaml directly would require a handwritten --- delimiter split (50 lines of fragile regex). gray-matter is the minimum package that wraps both concerns with first-party quality. Added to dependencies (not devDependencies) because P0.6.2 repository.ts will import the same schema module at runtime.
C5. Integration surface
C5.1 What P0.6.2 will use
SkillFrontmatterSchema,SkillFrontmatter,ParsedSkill— column shapes for theskillstable, projection forskill_listMCP tool.parseSkillFile(absPath)— startup scan that walks.agents/skills/colibri-*/SKILL.mdand inserts each parsed row.parseSkillContent(raw, sourcePath)— used when rehydrating from the DB blob column without re-reading the source file.SkillSchemaError— caught per-file; P0.6.2 decides whether to skip or fail-fast based on theCOLIBRI_MODE.
C5.2 What P0.6.3 will use
CAPABILITIES— seed for the capability index.SkillCapability— type for filter arguments.
C5.3 What the server does NOT get
- No MCP tool registration here.
skill_listis registered in P0.6.2. - No SKILL.md discovery (glob walk). That’s
src/domains/skills/repository.ts. - No
server_infochange. P0.6.1 is invisible at the transport layer. (R75 Wave H:server_infowas later struck as a phantom;server_healthcarries capability reporting.)
C6. Non-goals
The following are explicitly out of scope for this PR:
- Semantic-version validation of
version(freeform string; P0.6.2 may enforce semver at the repository layer if it chooses). - Capability drift detection (P0.6.3).
- Filesystem scan / glob walk (P0.6.2).
- Hot reload (
skill_reload),skill_get— Phase 1 per extraction heritage. agents/,references/,scripts/auxiliary subdirectory parsing — not in this task’s acceptance list; the parser reads onlySKILL.md.- Merging with
.claude/skills/colibri-*MIRROR copies — MIRROR zone is out-of-scope per CLAUDE.md §9.2. - Language-level enforcement of a capability-to-MCP-tool mapping (no
canCall(tool, skill)gate; Phase 0 has no ACL layer).
C7. Backward compatibility + forward-compat
Forward-compat:
- Schema is
.passthrough()— adding new canonical frontmatter keys in R76+ does not break existing parse. New keys can graduate from “passthrough observed” to “schema-guaranteed” without a major-version break in the parser API. CAPABILITIESandGREEK_LETTERSarereadonlytuples. Extending either requires a coordinated schema change + migration of any table that stores capability strings (P0.6.2 territory).
Backward-compat: none needed (greenfield module).
C8. Test surface (detail in packet §P3)
At minimum the packet must cover:
- Corpus sweep —
parseSkillFile× 21 real files, zero throw,it.eachfor row-level reporting. namerejection —UpperCase,123,a(too short),-foo(leading hyphen),foo_bar(underscore). 5 rejection cases.descriptionrequired — missing, empty"", whitespace-only" ".capabilitieshappy + rejection — every enum member individually, array of multiples, rejection of"sudo"or123.greekLetterhappy + rejection — one test per greek letter (15 rows), rejection ofΑ,ω,a.- Parser behaviour — absolute-path guard, ENOENT propagation, missing closing
---, passthrough preservation. SkillSchemaError— instance check,pathfield populated,issuesarray non-empty, message contains path.
Coverage target: src/domains/skills/schema.ts ≥ 90% branch, ≥ 95% statement/function/line.
C9. Commit plan
Per CLAUDE.md §6:
audit(p0-6-1-skill-schema): inventory surface— done at commit 0c450c1e.contract(p0-6-1-skill-schema): behavioral contract— this document.packet(p0-6-1-skill-schema): execution plan— next.feat(p0-6-1-skill-schema): zod schema + SKILL.md parser— implementation.verify(p0-6-1-skill-schema): test evidence— coverage + corpus log.
Push + PR after Step 5.
Contract locked. Step 3 (Packet) proceeds with the dependency-add + file-skeleton + test-matrix plan.