P0.6.1 — Step 3 Execution Packet

Grounded in ../audits/p0-6-1-skill-schema-audit.md and ../contracts/p0-6-1-skill-schema-contract.md. Executable plan for writing src/domains/skills/schema.ts + src/__tests__/skill-schema.test.ts.

Sigma pre-approved (per dispatch prompt, Wave C). T0 packet-gate waiver applies to P0.6.x. Proceeding directly to implement after this document lands.


P1. Change list (absolute paths)

P1.1 New files

Path Purpose Expected LOC
src/domains/skills/schema.ts Zod schema + parser 180-220
src/__tests__/skill-schema.test.ts Jest test suite 250-320

P1.2 Modified files

Path Change Expected delta
package.json Add gray-matter: "^4.0.3" to dependencies. Keep alphabetical ordering (between dotenv and zod). +1 line in dependencies, lockfile cascades
package-lock.json Regenerated by npm install gray-matter@^4.0.3. ~30–60 lines (transitive deps of gray-matter)

No other source changes. jest.config.ts, tsconfig.json, .eslintrc.json untouched.

P1.3 No files deleted, no files moved


P2. src/domains/skills/schema.ts skeleton

File layout (order preserved):

/**
 * Colibri — Phase 0 ε Skill Registry schema + SKILL.md parser.
 *
 * Defines the Zod shape of a skill's YAML frontmatter, plus a sync parser
 * that reads a SKILL.md file, splits frontmatter from body, and validates
 * the frontmatter against the schema.
 *
 * Pure module. No env reads, no DB, no network, no console output.
 *
 * Callers:
 *   - P0.6.2 repository.ts (DB insert + skill_list MCP tool)
 *   - P0.6.3 capabilities.ts (capability index)
 *
 * Canonical references:
 *   - docs/guides/implementation/task-breakdown.md § P0.6.1
 *   - docs/concepts/ε-skill-registry.md
 *   - docs/spec/s16-skill-taxonomy.md (heritage, for donor context only)
 */

import * as path from 'node:path';
import * as fs from 'node:fs';
import matter from 'gray-matter';
import { z } from 'zod';

// §1. Constants
export const KEBAB_CASE_NAME = /^[a-z][a-z0-9-]+$/;

export const CAPABILITIES = [
  'read', 'write', 'spawn', 'audit', 'admin',
] as const;

export const GREEK_LETTERS = [
  'α', 'β', 'γ', 'δ', 'ε',
  'ζ', 'η', 'θ', 'ι', 'κ',
  'λ', 'μ', 'ν', 'ξ', 'π',
] as const;

// §2. Zod leaf schemas
export const SkillCapabilitySchema = z.enum(CAPABILITIES);
export const GreekLetterSchema = z.enum(GREEK_LETTERS);

// §3. Frontmatter schema
export const SkillFrontmatterSchema = z
  .object({
    name: z.string().regex(KEBAB_CASE_NAME, {
      message: 'skill name must match /^[a-z][a-z0-9-]+$/',
    }),
    description: z.string().min(1, {
      message: 'description must be a non-empty string',
    }),
    version: z.string().min(1).optional(),
    entrypoint: z.string().min(1).optional(),
    capabilities: z.array(SkillCapabilitySchema).optional(),
    greekLetter: GreekLetterSchema.optional(),
  })
  .passthrough();

// §4. Derived types
export type SkillCapability = z.infer<typeof SkillCapabilitySchema>;
export type GreekLetter = z.infer<typeof GreekLetterSchema>;
export type SkillFrontmatter = z.infer<typeof SkillFrontmatterSchema>;

// §5. Parse result
export interface ParsedSkill {
  frontmatter: SkillFrontmatter;
  body: string;
  path: string;
}

// §6. Error class
export class SkillSchemaError extends Error {
  public readonly path: string;
  public readonly issues: z.ZodIssue[];

  constructor(sourcePath: string, issues: z.ZodIssue[]) {
    super(
      `SkillSchemaError: ${sourcePath}\n` +
        issues
          .map((i) => {
            const fieldPath = i.path.length === 0 ? '<root>' : i.path.join('.');
            const received =
              'received' in i && i.received !== undefined
                ? ` (received: ${JSON.stringify(i.received)})`
                : '';
            return `  [${fieldPath}] ${i.message}${received}`;
          })
          .join('\n'),
    );
    this.name = 'SkillSchemaError';
    this.path = sourcePath;
    this.issues = issues;
  }
}

// §7. Parser: pure content -> ParsedSkill
export function parseSkillContent(
  raw: string,
  sourcePath: string = '<inline>',
): ParsedSkill {
  const parsed = matter(raw);
  const result = SkillFrontmatterSchema.safeParse(parsed.data);
  if (!result.success) {
    throw new SkillSchemaError(sourcePath, result.error.issues);
  }
  return {
    frontmatter: result.data,
    body: parsed.content,
    path: sourcePath,
  };
}

// §8. Parser: file path -> ParsedSkill
export function parseSkillFile(absPath: string): ParsedSkill {
  if (typeof absPath !== 'string') {
    throw new TypeError(
      `parseSkillFile: path must be a string, got ${typeof absPath}`,
    );
  }
  if (!path.isAbsolute(absPath)) {
    throw new TypeError(
      `parseSkillFile: path must be absolute, got ${JSON.stringify(absPath)}`,
    );
  }
  const raw = fs.readFileSync(absPath, 'utf8');
  return parseSkillContent(raw, absPath);
}

Notes

  • as const on tuples then z.enum(...) is the Zod 3 idiom. The same pattern shipped in src/modes.ts.
  • .passthrough() preserves status, round, updated keys present in corpus files.
  • SkillSchemaError format: one header line + one line per issue, matches contract §C3.2.
  • ESLint consistent-type-imports: errorimport { z } from 'zod' is a value import (we call z.string()), not a type import. No import type needed for Zod.
  • matter (gray-matter default export) — CommonJS-to-ESM interop via esModuleInterop: true in tsconfig. The matter call returns { data, content, excerpt?, orig, ... }; we use data (frontmatter object) and content (body string).
  • result.error.issues returns z.ZodIssue[] in Zod 3.

TypeScript strictness compliance

  • exactOptionalPropertyTypes: trueversion?: string means the key may be absent; we never set it to explicit undefined. parsed.data from Zod never includes absent keys.
  • noUncheckedIndexedAccess: true → only the test file does array-index access; the schema module iterates with methods.
  • strictNullChecks: trueissues is non-null by construction (Zod guarantees .issues.length >= 1 when .success === false).

P3. src/__tests__/skill-schema.test.ts skeleton

File layout (order preserved):

import * as path from 'node:path';
import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
  CAPABILITIES,
  GREEK_LETTERS,
  KEBAB_CASE_NAME,
  SkillCapabilitySchema,
  SkillFrontmatterSchema,
  SkillSchemaError,
  GreekLetterSchema,
  parseSkillContent,
  parseSkillFile,
} from '../domains/skills/schema.js';

// Resolve repo root from the compiled test location:
// .worktrees/claude/p0-6-1-skill-schema/src/__tests__/skill-schema.test.ts
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, '..', '..');
const SKILLS_ROOT = path.join(REPO_ROOT, '.agents', 'skills');

function listCorpusPaths(): string[] {
  const entries = fs.readdirSync(SKILLS_ROOT, { withFileTypes: true });
  return entries
    .filter((e) => e.isDirectory() && e.name.startsWith('colibri-'))
    .map((e) => path.join(SKILLS_ROOT, e.name, 'SKILL.md'))
    .filter((p) => fs.existsSync(p))
    .sort();
}

P3.1 Test matrix (by describe block)

describe it cases Approx rows Coverage target
KEBAB_CASE_NAME matches ab, foo-bar, colibri-pm; rejects A, 123, a (too short), -foo, foo_bar, Foo 4 accept + 5 reject = 9 regex branches
CAPABILITIES tuple exact 5-element array shape 1 constants
GREEK_LETTERS tuple exact 15-element array shape 1 constants
SkillCapabilitySchema parses read/write/spawn/audit/admin; rejects sudo, '', 123, null 5 accept + 4 reject = 9 enum branches
GreekLetterSchema parses each of α β γ δ ε ζ η θ ι κ λ μ ν ξ π; rejects Α (upper), ω (unlisted), a (roman), '' 15 accept + 4 reject = 19 enum branches
SkillFrontmatterSchema – required fields accepts {name, description}; rejects missing name, missing description, empty description, whitespace description, wrong-type name 1 accept + 5 reject = 6 required branches
SkillFrontmatterSchema – optional fields accepts version, entrypoint, capabilities: [], capabilities: ['read','audit'], greekLetter: 'ε' each in isolation; rejects capabilities: ['sudo'], greekLetter: 'ω', version: '', entrypoint: '' 5 accept + 4 reject = 9 optional + leaf
SkillFrontmatterSchema – passthrough extra keys status, round, updated, model, foo preserved on parsed object 1 passthrough
parseSkillContent parses valid frontmatter+body; returns {frontmatter, body, path: '<inline>'}; preserves body verbatim including trailing newline; rejects no-frontmatter content; rejects frontmatter-with-bad-name 4 parser
parseSkillFile TypeError on relative path; TypeError on non-string; ENOENT passthrough on missing file; happy path on a written temp file 4 parser + guards
SkillSchemaError instanceof Error, carries path + issues, message contains path, message contains each field name 4 error class
Corpus compatibility it.each(listCorpusPaths()) parses each file, expects no throw; asserts corpus length === 21; asserts every parsed name matches KEBAB_CASE_NAME 21 + 2 = 23 end-to-end

Grand total: ~90 test cases (individual it rows; it.each rows count as one each). Coverage target: ≥ 90% branch, ≥ 95% line on schema.ts.

P3.2 Temp-file strategy (for parseSkillFile happy path)

import * as os from 'node:os';
import * as crypto from 'node:crypto';

function makeTempSkillFile(frontmatter: string, body: string): string {
  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'colibri-skill-'));
  const file = path.join(dir, 'SKILL.md');
  fs.writeFileSync(file, `---\n${frontmatter}---\n${body}`, 'utf8');
  return file;
}

// cleanup tracked per-test with afterEach.

The afterEach cleans every temp dir created in the test to avoid cross-test pollution and Windows file-lock warnings.

P3.3 Corpus test shape

describe('corpus compatibility', () => {
  const corpus = listCorpusPaths();

  it('finds the expected number of colibri-* skills (21 on this baseline)', () => {
    expect(corpus.length).toBe(21);
  });

  it.each(corpus.map((p) => [path.relative(REPO_ROOT, p), p] as const))(
    'parses %s without schema errors',
    (_rel, abs) => {
      expect(() => parseSkillFile(abs)).not.toThrow();
    },
  );

  it('every corpus skill name matches KEBAB_CASE_NAME', () => {
    for (const abs of corpus) {
      const parsed = parseSkillFile(abs);
      expect(parsed.frontmatter.name).toMatch(KEBAB_CASE_NAME);
    }
  });
});

If a future commit adds a 22nd skill, the first assertion will light up and the author can update the count intentionally; the it.each row list auto-extends.


P4. Spec deviations (copy-forward from audit §4 + contract §C2)

These are the deviations the implementation commits to:

  1. Corpus size: 21, not 22. Audit §2a. Tests expect 21; future skill additions flip the baseline intentionally.
  2. version, entrypoint, capabilities, greekLetter are all optional. Audit §4a. The corpus has 0/21 occurrences of each; requiring them would break the primary acceptance test.
  3. Test file path: src/__tests__/skill-schema.test.ts. Audit §4b. Wave A lock overrides the spec’s tests/domains/skills/schema.test.ts.
  4. No SKILL.md corpus edits. Audit §4e. The schema accommodates the corpus rather than mutating it.
  5. gray-matter @ ^4.0.3 added as runtime dep. Audit §4c + contract §C4.

P5. Commit plan (detail)

# Commit SHA-prefix target Message Files
1 0c450c1e audit(p0-6-1-skill-schema): inventory surface docs/audits/p0-6-1-skill-schema-audit.md
2 07f324a4 contract(p0-6-1-skill-schema): behavioral contract docs/contracts/p0-6-1-skill-schema-contract.md
3 (this) packet(p0-6-1-skill-schema): execution plan docs/packets/p0-6-1-skill-schema-packet.md
4 pending feat(p0-6-1-skill-schema): zod schema + SKILL.md parser src/domains/skills/schema.ts, src/__tests__/skill-schema.test.ts, package.json, package-lock.json
5 pending verify(p0-6-1-skill-schema): test evidence docs/verification/p0-6-1-skill-schema-verification.md

P6. Verification plan (what Step 5 runs)

Commands (in order), from worktree root:

npm ci                                 # clean install, pin lockfile
npm run lint                           # eslint src, must be warning-free
npm test -- --testPathPattern skill-schema    # new-test focused run
npm test                               # full suite, coverage floor
npm run build                          # tsc, dist/ compiles

Expected:

  • npm ci — installs gray-matter transitively. No advisories, no missing-peer warnings.
  • npm run lint — clean. No any usage in the new code.
  • npm test focused — ~90 new cases pass. Coverage on src/domains/skills/schema.ts ≥ 90% branch, ≥ 95% line.
  • npm test full — all prior tests still pass (config, modes, server, db-init, smoke).
  • npm run builddist/domains/skills/schema.js emitted with .d.ts sibling.

If any command fails, stop, fix, re-run. Do not push.


P7. Push + PR

After Step 5 lands:

unset GITHUB_TOKEN
git push -u origin feature/p0-6-1-skill-schema
gh pr create \
  --title "feat(p0-6-1): ε skill schema — Zod + SKILL.md parser + 21-file corpus test" \
  --body "…"

PR title notes 21, not 22, matching the corpus reality documented throughout the chain docs.

Stop after PR opens. Do not merge.


Packet locked. Step 4 (Implement) proceeds.


Back to top

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

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