P0.6.2 — Step 2 Behavioral Contract
Grounded in ../audits/p0-6-2-skill-crud-audit.md. Defines the public API, invariants, and error contracts for the ε Skill Registry runtime layer.
C1. Public API
All exports live in src/domains/skills/repository.ts. No barrel, no eager side effect on import.
C1.1 Types
import type Database from 'better-sqlite3';
import type { ColibriServerContext } from '../../server.js';
import type {
SkillFrontmatter,
ParsedSkill,
} from './schema.js';
/** The row shape stored in the `skills` table and returned by CRUD helpers. */
export interface SkillRow {
readonly name: string;
readonly description: string;
readonly version: string | null;
readonly entrypoint: string | null;
readonly capabilities: readonly string[]; // JSON-decoded; empty array if absent
readonly greek_letter: string | null;
readonly body: string;
readonly source_path: string; // repo-relative (e.g. ".agents/skills/x/SKILL.md")
readonly frontmatter_json: string; // raw JSON-encoded frontmatter
readonly loaded_at: string; // ISO-8601 UTC
}
/** Per-call filter for `listSkills`. Both fields are optional; if both are
* omitted the full list is returned. */
export interface ListSkillsFilters {
readonly search?: string; // substring match, case-insensitive, on name OR description
readonly capability?: string; // exact match against one of the skill's capabilities
}
/** Result summary returned by `loadSkillsFromDisk`. */
export interface LoadSkillsResult {
readonly loaded: number; // rows successfully written
readonly skipped: number; // files present but failed to parse
readonly pruned: number; // rows in DB that no longer exist on disk (deleted)
readonly total_on_disk: number; // SKILL.md files found on disk (loaded + skipped)
}
/** Minimal logger shape accepted by the loader. Matches `ctx.logger`. */
export type Logger = (...args: unknown[]) => void;
C1.2 Loader
/**
* Scan `skillsRoot` for `*\/SKILL.md` files, parse each via P0.6.1
* `parseSkillFile`, and upsert the results into the `skills` table.
*
* Upsert semantics:
* - `INSERT INTO skills ... ON CONFLICT(name) DO UPDATE SET ...`
* - After the upsert pass, `DELETE FROM skills WHERE name NOT IN (...)`
* prunes rows whose source directory no longer exists on disk.
* - Entire scan + upsert + prune runs in ONE sqlite transaction.
*
* Parse errors:
* - A SkillSchemaError or YAML parse error on any file logs
* `[colibri] skill skipped: <path>: <message>` via `logger`
* and increments `skipped`. The file is not inserted; startup continues.
* - A non-parser error (read failure, DB error) propagates. The caller
* (startup.ts Phase 2) catches and routes to shutdown.
*
* @param db Live better-sqlite3 handle, migrations applied.
* @param skillsRoot Absolute or resolvable-relative path to `.agents/skills/`.
* Relative paths resolve against `process.cwd()`.
* @param logger Function accepting `(...args)` and writing to a sink
* (stderr in production, spy in tests).
* @returns LoadSkillsResult — counters for loaded / skipped / pruned / total.
* @throws Error on non-recoverable failure (e.g. skillsRoot unreadable).
*/
export function loadSkillsFromDisk(
db: Database.Database,
skillsRoot: string,
logger: Logger,
): LoadSkillsResult;
C1.3 Read helpers
/** Return the row for `name` or `null` if absent. Pure read. */
export function getSkill(
db: Database.Database,
name: string,
): SkillRow | null;
/**
* List skills, optionally filtered.
*
* Filter semantics:
* - `search`: case-insensitive substring match against `name` OR
* `description`. Empty / undefined search returns all.
* - `capability`: exact (case-sensitive) match against any element of the
* skill's JSON-decoded `capabilities` array. Empty / undefined capability
* returns all.
* - When both filters are set, rows must match BOTH (AND, not OR).
*
* Rows are returned ordered by `name` ASC (stable, deterministic).
*/
export function listSkills(
db: Database.Database,
filters?: ListSkillsFilters,
): readonly SkillRow[];
C1.4 MCP tool registration
/**
* Register the `skill_list` MCP tool against `ctx.server`.
*
* Called from `startup.ts` Phase 2 after `initDb(...)` has returned a live
* handle. Registers exactly ONE tool — `skill_list` — via
* `registerColibriTool(ctx, 'skill_list', config, handler)` so the 5-stage
* α middleware chain wraps it.
*
* Handler contract:
* - Input: `{ search?: string, capability?: string }` (both optional).
* - Reads from `db` via `listSkills(db, filters)`.
* - Returns `{ skills: [...projection...], total_count: number }`.
* - Projection per s17 + mcp-tools-phase-0 §Cat 3 output:
* { name, version, description, capabilities, path }
* plus Phase 0 extension: `greek_letter` (the frontmatter field).
* - `version` and `greek_letter` nullable; `capabilities` always array.
*
* Idempotency:
* - Calling `registerSkillTools` twice throws
* `Error("tool already registered: skill_list")` (from
* `registerColibriTool`). Tests use a fresh ctx per case.
*/
export function registerSkillTools(
ctx: ColibriServerContext,
db: Database.Database,
): void;
C2. Database schema
C2.1 Migration
File: src/db/migrations/003_skills.sql.
-- 003_skills — ε Skill Registry table (P0.6.2)
--
-- Owned by ε. Populated at startup by loadSkillsFromDisk() in
-- src/domains/skills/repository.ts. Source of truth is
-- .agents/skills/*/SKILL.md; this table is a searchable index.
--
-- PK is `name` (the skill's kebab-case identifier). Synthetic IDs are
-- deferred to Phase 1 when rename-tracking becomes a requirement.
CREATE TABLE skills (
name TEXT PRIMARY KEY,
description TEXT NOT NULL,
version TEXT,
entrypoint TEXT,
capabilities TEXT NOT NULL DEFAULT '[]', -- JSON array of capability strings
greek_letter TEXT,
body TEXT NOT NULL,
source_path TEXT NOT NULL,
frontmatter_json TEXT NOT NULL,
loaded_at TEXT NOT NULL
);
CREATE INDEX idx_skills_greek ON skills(greek_letter);
C2.2 Schema invariants
nameis unique (PK). Two skills with the samenamein frontmatter produce a single row; the second upsert overwrites the first. The scan order isreaddirorder — the “winner” of a name collision is implementation-defined. P0.6.2 tests cover this as a warning case.capabilitiesis always valid JSON array text. Default is'[]'.loadSkillsFromDiskalways stringifiesfrontmatter.capabilities ?? []before insert.listSkillscapability filter JSON-decodes the column for containment.source_pathis repo-relative and uses forward slashes. Windows path separators are normalized viapath.posix.normalizebefore insert so the column value is cross-platform.loaded_atis UTC ISO-8601.new Date().toISOString().- No FK.
skills.nameis referenced from no other Phase 0 table.
C2.3 Load transaction
The entire scan → upsert → prune sequence runs in one db.transaction(() => { … })():
- Compute
keepNames: Set<string>(names that will be upserted). - For each valid parsed skill, run
INSERT INTO skills (...) ON CONFLICT(name) DO UPDATE SET .... DELETE FROM skills WHERE name NOT IN (<keepNames>).
Rollback on any step reverts the whole load (consistent view). Non-parser errors (DB errors) throw out of the transaction; SQLite rolls back.
C3. Loader contract
C3.1 Scan algorithm
function loadSkillsFromDisk(db, skillsRoot, logger):
1. Resolve skillsRoot to an absolute path.
2. If !existsSync(skillsRoot): log "[colibri] skills root missing" and
return { loaded: 0, skipped: 0, pruned: 0, total_on_disk: 0 }.
3. Enumerate directories via readdirSync(skillsRoot, { withFileTypes: true }):
- Skip entries starting with '.' (e.g. `.git`, `.DS_Store`)
- Keep only isDirectory() entries
4. For each directory, build absolute SKILL.md path.
- If not fs.existsSync(skillMd): skip silently (not a skill dir).
- total_on_disk++
5. Try parseSkillFile(abs):
- On SkillSchemaError or generic parse Error:
logger("[colibri] skill skipped:", abs + ":", err.message)
skipped++, continue
- On other Error (e.g. EACCES, EISDIR): log and skipped++.
6. Map ParsedSkill → row. Enqueue upsert.
7. After all files processed, run transaction:
- upsert each queued row
- delete rows not in keepNames
8. Return counters.
C3.2 Logger usage
The loader calls logger(...args) exactly once per skipped file and once on a “skills root missing” condition. It does NOT log a success line per file (would be 22 lines of noise). It MAY log one summary line at the end:
[colibri] skills loaded: <loaded>, skipped: <skipped>, pruned: <pruned>
— this is visible in the startup.ts Phase 2 log stream.
No writes to process.stdout. All logs go through the injected logger.
C3.3 Error envelope
loadSkillsFromDiskTHROWS on:- Non-existent skillsRoot is a warn-and-return-zeros path, NOT a throw. A fresh checkout without
.agents/skills/must still boot. readdirSync(skillsRoot)throws (permission denied on a root that exists): propagate the error.db.prepare(...)ordb.exec(...)throws (schema drift, migration not applied): propagate.
- Non-existent skillsRoot is a warn-and-return-zeros path, NOT a throw. A fresh checkout without
loadSkillsFromDiskDOES NOT THROW on:- Per-file parse error → log and skip.
- Per-file read error (ENOENT after readdir — race) → log and skip.
SKILL.mdmissing in a skill directory → silent skip (it’s not a skill).
C4. Read helper contracts
C4.1 getSkill(db, name)
- Prepared statement cached on first call:
SELECT * FROM skills WHERE name = ?. - Empty / whitespace-only
namereturnsnull(no throw) — the SQL result is empty and the function maps that tonull. - Returns an object matching
SkillRow.capabilitiesis JSON-decoded from the column.pathcolumn is NOT renamed —source_pathstays as the column name to mirror the DB schema.
C4.2 listSkills(db, filters)
-
Prepared statement variants (up to 4 prepared statements — no-filter, search-only, cap-only, both):
-- no filter SELECT * FROM skills ORDER BY name ASC -- search only (case-insensitive LIKE on name OR description) SELECT * FROM skills WHERE LOWER(name) LIKE :q OR LOWER(description) LIKE :q ORDER BY name ASC -- capability only (array containment via JSON LIKE) SELECT * FROM skills WHERE capabilities LIKE :caplike ORDER BY name ASC -- both SELECT * FROM skills WHERE (LOWER(name) LIKE :q OR LOWER(description) LIKE :q) AND capabilities LIKE :caplike ORDER BY name ASC :qis%<lowercased-search>%. Empty search not allowed at this stage (caller coerces absent → no-filter branch).:caplikeis%"<capability>"%. Sincecapabilitiesis stored as["read","write"], JSON-formatted, the substring"read"matches. Edge cases:- Capability containing
"or\— rejected at the MCP schema level (Zod.string()) in Phase 0 because the mcp-tools shape reservesstring. For robustness the loader JSON-encodes then looks up, and a pathological capability with quotes produces a mismatched pattern but not a crash. - Capability in an untypoed position (e.g.
["readwrite"]matching filter"read") — YES,LIKE '%"read"%'would match"readwrite"incorrectly. Fix: the filter pattern is"<cap>"(with surrounding quotes) to match the exact JSON token.'["readwrite"]'contains"readwrite"but NOT"read";'["read","write"]'contains both. OK.
- Capability containing
- The SQL-LIKE approach is Phase-0-cheap and correct for 22 rows. Phase 1 can migrate to
json_eachonce performance matters.
C4.3 Sort order
Both getSkill (N/A, single row) and listSkills return rows ordered by name ASC via ORDER BY name ASC. This matches the corpus test’s .sort() contract and makes result diffs stable.
C5. MCP tool contract — skill_list
C5.1 Tool config
registerColibriTool(
ctx,
'skill_list',
{
title: 'skill_list',
description: 'List all skills loaded from .agents/skills/*/SKILL.md into the ε Skill Registry, with optional substring search and capability filter.',
inputSchema: z.object({
search: z.string().optional(),
capability: z.string().optional(),
}),
},
handler,
);
Name skill_list is snake_case and matches TOOL_NAME_RE in server.ts.
C5.2 Handler
async ({ search, capability }) => {
const rows = listSkills(db, {
...(search !== undefined ? { search } : {}),
...(capability !== undefined ? { capability } : {}),
});
const projection = rows.map((r) => ({
name: r.name,
version: r.version,
description: r.description,
capabilities: r.capabilities,
greek_letter: r.greek_letter,
path: r.source_path,
}));
return { skills: projection, total_count: projection.length };
}
total_count is the count AFTER filtering (matches mcp-tools-phase-0.md example where search: “tier1” returns a single row with total_count: 1).
C5.3 Response envelope
Wrapped by registerColibriTool:
Success:
{
"ok": true,
"data": {
"skills": [ ... ],
"total_count": <number>
}
}
Validation failure (e.g. wrong-type search):
{
"ok": false,
"error": {
"code": "INVALID_PARAMS",
"message": "schema validation failed",
"details": { "issues": [...] }
}
}
Handler error (would only occur on DB failure):
{
"ok": false,
"error": {
"code": "HANDLER_ERROR",
"message": "<DB error message>"
}
}
C5.4 Middleware conformance
Per s17 §4, every call passes:
- tool-lock — per-tool mutex. Two concurrent
skill_listcalls serialize. - schema-validate — Zod
inputSchemachecked. Wrong shape → stage 3 skipped, stage 5 records exit witherror. - audit-enter —
auditSink.enter({ tool: 'skill_list', ... }). - dispatch — calls the handler.
- audit-exit —
auditSink.exit({ tool: 'skill_list', durationMs, result?, error? }). Always runs in the chain’sfinally.
Tests assert all five stages observed via a spy sink.
C6. startup.ts integration
C6.1 Phase 2 insertion
After const db = initDbFn(dbPath); and before return { ctx, db, elapsedMs };:
// Phase 2b — ε Skill Registry: load SKILL.md files and register skill_list.
const skillsRoot = options.skillsRoot ?? path.resolve(process.cwd(), '.agents', 'skills');
loadSkillsFromDisk(db, skillsRoot, logger);
registerSkillTools(ctx, db);
Two new lines (plus import) in startup.ts. One new StartupOptions.skillsRoot field (optional) for test injection.
C6.2 Test injection seam
StartupOptions gains:
/** Override the skills root directory. Default `path.resolve(process.cwd(), '.agents/skills')`. */
readonly skillsRoot?: string;
Tests pass a fixture directory with synthetic SKILL.md files. Production default is the real .agents/skills/.
C6.3 Error handling
If loadSkillsFromDisk throws (propagated DB error, not a per-file issue), the Phase 2 try/catch in startup.ts catches it, logs [Startup] Phase 2 failed:, calls shutdown('phase-2-failed'), and invokes exit(1). Same path as an initDb failure. No new behavior.
If registerSkillTools throws (e.g. duplicate tool name), same propagation.
C7. Non-goals (explicit)
The following are OUT of scope for P0.6.2:
skill_getMCP tool — Phase 1.skill_reloadMCP tool / hot-reload / file watcher — Phase 1.- Per-capability reverse index — P0.6.3 (next sub-task).
- Agent spawning (
agent_spawn,agent_status) — Phase 1.5 per ADR-005. - Reading
.claude/skills/(MIRROR zone) — never in scope. - Scanning
scripts/orreferences/auxiliary subdirectories of a skill — not in the acceptance list. - Capability schema enforcement at MCP boundary (using the Zod enum for
capabilityfilter) — see §4g of audit.z.string()is correct. - Semver validation of
version— passthrough string. - Writing to
data/colibri.dbat migration time (schema changes are migrations only; rows are written by the loader at runtime).
C8. Test surface (detail in packet §P3)
Minimum test cases (aggregated from acceptance criteria + design decisions):
| Block | Cases | Count |
|---|---|---|
loadSkillsFromDisk happy path |
fixture with N skills → loads all; returns counters | 4 |
loadSkillsFromDisk parse error |
fixture with broken frontmatter → logs + skips, others loaded | 2 |
loadSkillsFromDisk missing root |
nonexistent skillsRoot → returns all-zero counters | 1 |
loadSkillsFromDisk pruning |
pre-seed row, fixture without that name → row deleted | 1 |
loadSkillsFromDisk idempotent |
call twice → same counters, no duplicates | 1 |
loadSkillsFromDisk no SKILL.md |
directory without SKILL.md → silent skip | 1 |
getSkill happy |
seed + SELECT returns row | 2 |
getSkill miss |
not-found → null | 2 |
listSkills no-filter |
returns all, sorted | 1 |
listSkills search |
name/description substring match | 4 |
listSkills capability |
array containment | 3 |
listSkills both filters |
AND semantics | 2 |
registerSkillTools |
registers exactly skill_list, snake_case check |
1 |
skill_list handler happy |
no filter → all rows projected | 1 |
skill_list handler search |
filter narrows list | 1 |
skill_list handler capability |
filter narrows list | 1 |
skill_list handler invalid input |
wrong-type → INVALID_PARAMS envelope | 1 |
skill_list middleware conformance |
audit sink sees enter+exit | 1 |
| Corpus load test (the primary acceptance) | call loadSkillsFromDisk against real .agents/skills/ → assert loaded count matches fs.readdirSync count of directories with SKILL.md |
2 |
Total ≥ 32 test cases, exceeding the ≥ 20 requirement. Coverage target: ≥ 95% branch on src/domains/skills/repository.ts.
C9. Backward compat / forward compat
loadSkillsFromDiskis additive — no existing caller calls it.skill_listis additive — no existing client calls it.003_skills.sqlis a new migration; existing databases that were created before this PR will apply it on next boot (PRAGMA user_version progresses 2 → 3).- P0.6.3 (capability index) will read from the
skillstable. Thecapabilitiescolumn shape (JSON-encoded TEXT) is stable; P0.6.3 does NOT change this schema.
C10. Dependency additions
None. gray-matter was introduced by P0.6.1 for the parser; P0.6.2 reuses it transitively via parseSkillFile. No new runtime or dev deps.
C11. Commit plan (detail)
audit(p0-6-2-skill-crud): inventory ε parser + DB + startup + corpus— shipped atc977cf5c.contract(p0-6-2-skill-crud): behavioral contract— this document.packet(p0-6-2-skill-crud): execution plan— next.feat(p0-6-2-skill-crud): ε skill repository + loader + skill_list MCP tool.verify(p0-6-2-skill-crud): test evidence.
Contract locked. Step 3 (Packet) proceeds.