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

  1. name is unique (PK). Two skills with the same name in frontmatter produce a single row; the second upsert overwrites the first. The scan order is readdir order — the “winner” of a name collision is implementation-defined. P0.6.2 tests cover this as a warning case.
  2. capabilities is always valid JSON array text. Default is '[]'. loadSkillsFromDisk always stringifies frontmatter.capabilities ?? [] before insert. listSkills capability filter JSON-decodes the column for containment.
  3. source_path is repo-relative and uses forward slashes. Windows path separators are normalized via path.posix.normalize before insert so the column value is cross-platform.
  4. loaded_at is UTC ISO-8601. new Date().toISOString().
  5. No FK. skills.name is referenced from no other Phase 0 table.

C2.3 Load transaction

The entire scan → upsert → prune sequence runs in one db.transaction(() => { … })():

  1. Compute keepNames: Set<string> (names that will be upserted).
  2. For each valid parsed skill, run INSERT INTO skills (...) ON CONFLICT(name) DO UPDATE SET ....
  3. 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

  • loadSkillsFromDisk THROWS 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(...) or db.exec(...) throws (schema drift, migration not applied): propagate.
  • loadSkillsFromDisk DOES NOT THROW on:
    • Per-file parse error → log and skip.
    • Per-file read error (ENOENT after readdir — race) → log and skip.
    • SKILL.md missing 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 name returns null (no throw) — the SQL result is empty and the function maps that to null.
  • Returns an object matching SkillRow. capabilities is JSON-decoded from the column. path column is NOT renamed — source_path stays 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
    
  • :q is %<lowercased-search>%. Empty search not allowed at this stage (caller coerces absent → no-filter branch).
  • :caplike is %"<capability>"%. Since capabilities is 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 reserves string. 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.
  • The SQL-LIKE approach is Phase-0-cheap and correct for 22 rows. Phase 1 can migrate to json_each once 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:

  1. tool-lock — per-tool mutex. Two concurrent skill_list calls serialize.
  2. schema-validate — Zod inputSchema checked. Wrong shape → stage 3 skipped, stage 5 records exit with error.
  3. audit-enterauditSink.enter({ tool: 'skill_list', ... }).
  4. dispatch — calls the handler.
  5. audit-exitauditSink.exit({ tool: 'skill_list', durationMs, result?, error? }). Always runs in the chain’s finally.

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_get MCP tool — Phase 1.
  • skill_reload MCP 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/ or references/ auxiliary subdirectories of a skill — not in the acceptance list.
  • Capability schema enforcement at MCP boundary (using the Zod enum for capability filter) — see §4g of audit. z.string() is correct.
  • Semver validation of version — passthrough string.
  • Writing to data/colibri.db at 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

  • loadSkillsFromDisk is additive — no existing caller calls it.
  • skill_list is additive — no existing client calls it.
  • 003_skills.sql is 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 skills table. The capabilities column 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)

  1. audit(p0-6-2-skill-crud): inventory ε parser + DB + startup + corpus — shipped at c977cf5c.
  2. contract(p0-6-2-skill-crud): behavioral contract — this document.
  3. packet(p0-6-2-skill-crud): execution plan — next.
  4. feat(p0-6-2-skill-crud): ε skill repository + loader + skill_list MCP tool.
  5. verify(p0-6-2-skill-crud): test evidence.

Contract locked. Step 3 (Packet) 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.