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

  1. Only name and description are 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.
  2. name must match /^[a-z][a-z0-9-]+$/. Minimum length 2 (one leading letter + one+ trailing). ab is valid. a is not (regex requires at least one char after the initial letter).
  3. description is z.string().min(1). Whitespace-only descriptions are rejected.
  4. 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.
  5. greekLetter, when present, is exactly one code-point from GREEK_LETTERS. Case-sensitive; upper-case (Α) and non-listed letters (ο, ρ, σ, …) are rejected.
  6. Extra frontmatter keys are preserved via .passthrough(). status, round, updated, model, and any future key will round-trip through parseSkillFile. This is why colibri-audit-memory (which has a status: heritage key) parses without error.
  7. 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 matches exactOptionalPropertyTypes: true.

C2.2 Parser invariants

  1. parseSkillFile reads 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.md before calling).
  2. The file must have both opening and closing --- delimiters. A file with no frontmatter (or only opening ---) throws. gray-matter handles this natively — a file without frontmatter produces data === {}, and our validator rejects empty frontmatter (name is required).
  3. body is the content after the closing --- verbatim, including the leading newline. gray-matter strips the newline by default; we preserve the original-style trailing string via the default .content field.
  4. UTF-8 only. Unicode Greek letters inside YAML strings are decoded natively because readFileSync(path, 'utf8') decodes UTF-8 by default.
  5. 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:

  1. TypeErrorabsPath not absolute or not a string.
  2. Error with a descriptive message — file-read failures propagate from fs.readFileSync with no wrap (ENOENT, EACCES, EISDIR, etc.). Callers can test err.code === 'ENOENT'.
  3. SkillSchemaError — thrown when gray-matter parses 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)}`
    }
    
  4. Error raw — thrown when gray-matter itself 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 the skills table, projection for skill_list MCP tool.
  • parseSkillFile(absPath) — startup scan that walks .agents/skills/colibri-*/SKILL.md and 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 the COLIBRI_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_list is registered in P0.6.2.
  • No SKILL.md discovery (glob walk). That’s src/domains/skills/repository.ts.
  • No server_info change. P0.6.1 is invisible at the transport layer. (R75 Wave H: server_info was later struck as a phantom; server_health carries 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 only SKILL.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.
  • CAPABILITIES and GREEK_LETTERS are readonly tuples. 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:

  1. Corpus sweepparseSkillFile × 21 real files, zero throw, it.each for row-level reporting.
  2. name rejectionUpperCase, 123, a (too short), -foo (leading hyphen), foo_bar (underscore). 5 rejection cases.
  3. description required — missing, empty "", whitespace-only " ".
  4. capabilities happy + rejection — every enum member individually, array of multiples, rejection of "sudo" or 123.
  5. greekLetter happy + rejection — one test per greek letter (15 rows), rejection of Α, ω, a.
  6. Parser behaviour — absolute-path guard, ENOENT propagation, missing closing ---, passthrough preservation.
  7. SkillSchemaError — instance check, path field populated, issues array 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:

  1. audit(p0-6-1-skill-schema): inventory surface — done at commit 0c450c1e.
  2. contract(p0-6-1-skill-schema): behavioral contract — this document.
  3. packet(p0-6-1-skill-schema): execution plan — next.
  4. feat(p0-6-1-skill-schema): zod schema + SKILL.md parser — implementation.
  5. 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.


Back to top

Colibri — documentation-first MCP runtime. Apache 2.0 + Commons Clause.

This site uses Just the Docs, a documentation theme for Jekyll.