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 conston tuples thenz.enum(...)is the Zod 3 idiom. The same pattern shipped insrc/modes.ts..passthrough()preservesstatus,round,updatedkeys present in corpus files.SkillSchemaErrorformat: one header line + one line per issue, matches contract §C3.2.- ESLint
consistent-type-imports: error—import { z } from 'zod'is a value import (we callz.string()), not a type import. Noimport typeneeded for Zod. matter(gray-matter default export) — CommonJS-to-ESM interop viaesModuleInterop: truein tsconfig. Themattercall returns{ data, content, excerpt?, orig, ... }; we usedata(frontmatter object) andcontent(body string).result.error.issuesreturnsz.ZodIssue[]in Zod 3.
TypeScript strictness compliance
exactOptionalPropertyTypes: true→version?: stringmeans the key may be absent; we never set it to explicitundefined.parsed.datafrom Zod never includes absent keys.noUncheckedIndexedAccess: true→ only the test file does array-index access; the schema module iterates with methods.strictNullChecks: true→issuesis non-null by construction (Zod guarantees.issues.length >= 1when.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:
- Corpus size: 21, not 22. Audit §2a. Tests expect 21; future skill additions flip the baseline intentionally.
version,entrypoint,capabilities,greekLetterare all optional. Audit §4a. The corpus has 0/21 occurrences of each; requiring them would break the primary acceptance test.- Test file path:
src/__tests__/skill-schema.test.ts. Audit §4b. Wave A lock overrides the spec’stests/domains/skills/schema.test.ts. - No SKILL.md corpus edits. Audit §4e. The schema accommodates the corpus rather than mutating it.
gray-matter@^4.0.3added 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— installsgray-mattertransitively. No advisories, no missing-peer warnings.npm run lint— clean. Noanyusage in the new code.npm testfocused — ~90 new cases pass. Coverage onsrc/domains/skills/schema.ts≥ 90% branch, ≥ 95% line.npm testfull — all prior tests still pass (config, modes, server, db-init, smoke).npm run build—dist/domains/skills/schema.jsemitted with.d.tssibling.
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.