P0.6.2 — Step 1 Audit

Inventory of the worktree against the task spec for P0.6.2 ε Skill CRUD + Discovery (second of three ε sub-tasks). Scope: the SQLite-backed skill registry, the startup scan that indexes .agents/skills/*/SKILL.md into it, and the single Phase 0 ε MCP tool skill_list.

Baseline: worktree E:/AMS/.worktrees/claude/p0-6-2-skill-crud/ at commit 6c26bb58 (main tip — Wave C merged: P0.2.3 two-phase startup, P0.3.1 β FSM, P0.6.1 ε schema, P0.7.1 ζ trail schema).


§1. Surface being added

Targets this task creates:

  • src/domains/skills/repository.ts — new module. Exports loadSkillsFromDisk, getSkill, listSkills, and registerSkillTools. Does not yet exist.
  • src/__tests__/domains/skills/repository.test.ts — new nested test file per Sigma-approved deviation #1. The existing flat convention (src/__tests__/skill-schema.test.ts) is preserved for P0.6.1; P0.6.2 onward moves to src/__tests__/domains/<concept>/<module>.test.ts. Jest testMatch: ['**/__tests__/**/*.test.ts'] supports both.
  • src/db/migrations/003_skills.sql — new migration. Pre-assigned number 003 (Sigma lock across Wave D — P0.3.2 owns 002_tasks, P0.7.2 owns 004_thought_records).
  • src/__tests__/domains/ directory — new directory root.

Targets this task modifies:

  • src/server.ts — add one registerSkillTools(ctx, ctx.db) call inside bootstrap() AFTER server_ping. Because bootstrap() runs in Phase 1 before the DB exists (see §3.2), the actual registration happens in startup() Phase 2, NOT in bootstrap(). See §4 design decision.
  • src/startup.ts — after initDbFn(dbPath) succeeds in Phase 2, call loadSkillsFromDisk(db, skillsRoot, logger) and (per §4) register the skill_list tool against ctx.
  • src/db/schema.sql — append skills ownership comment block. schema.sql is a documentation asset; no executable SQL.

A worktree scan confirms absence of the authoring targets:

  • ls src/domains/skills/ → only schema.ts (P0.6.1)
  • ls src/__tests__/domains/ → “No such file or directory”
  • ls src/db/migrations/ → only 001_init.sql
  • grep -rn "loadSkillsFromDisk\|getSkill\|listSkills\|skill_list" src/ → zero matches

This is a greenfield module set that extends four existing files by a single call each.


§2. P0.6.1 surface (the base this task builds on)

2a. src/domains/skills/schema.ts — exports in scope for P0.6.2

Read in full. Exports:

Export Shape P0.6.2 use
KEBAB_CASE_NAME RegExp /^[a-z][a-z0-9-]+$/ Reject malformed name at load time if an operator hand-writes a broken skill.
CAPABILITIES readonly ['read','write','spawn','audit','admin'] Domain of capability filter on listSkills. Exposed for Zod input schema of skill_list.
GREEK_LETTERS 15-element readonly tuple α…π Domain for optional greek_letter column.
SkillCapabilitySchema Zod enum Used in listSkills filter input shape.
GreekLetterSchema Zod enum Storage-side constraint (not enforced in DB; enforced by parser before insert).
SkillFrontmatterSchema Zod object with required {name, description} + optional {version, entrypoint, capabilities?, greekLetter?} + .passthrough() Full frontmatter shape; parseSkillContent returns this.
SkillFrontmatter inferred TS type Return type of parser.
ParsedSkill {frontmatter, body, path} What loadSkillsFromDisk receives from parseSkillFile.
SkillSchemaError custom Error class with path + issues Caught per-file to log-and-skip.
parseSkillContent(raw, sourcePath?) ParsedSkill Not used at runtime (P0.6.2 reads files via parseSkillFile); kept as a re-hydration seam.
parseSkillFile(absPath) ParsedSkill, throws on bad input / read / schema fail Primary entrypoint. Called once per SKILL.md in startup scan.

The .passthrough() on the frontmatter schema means keys like status: heritage, round: R74.5, updated: YYYY-MM-DD are preserved on the parsed object. P0.6.2 captures the full frontmatter as a JSON blob in the frontmatter_json column so these are recoverable later without re-reading the file.

2b. P0.6.1 contract §C5.1 on the startup scan

parseSkillFile(absPath) — startup scan that walks .agents/skills/colibri-*/SKILL.md and inserts each parsed row.”

The contract says colibri-*. The task-spec dispatch prompt for P0.6.2 says .agents/skills/*/SKILL.md (all directories). The concept doc (docs/3-world/execution/skill-registry.md) says “At boot, γ scans … .agents/skills/ — the canonical Colibri skill corpus (colibri-*) [and] .claude/skills/ — the Claude Code compat mirror.” Phase 0 does NOT scan .claude/skills/ (MIRROR zone per CLAUDE.md §9.2).

Reconciliation (§4b below): P0.6.2 runtime scans all .agents/skills/*/SKILL.md — 22 files. The colibri-* prefix filter is a donor-era taxonomy assumption; at runtime the registry indexes every valid SKILL.md regardless of directory-name prefix. Invalid frontmatter → log-and-skip per the concept doc. The corpus test will assert the total load count against what’s on disk.

2c. Existing test conventions (flat vs nested)

The P0.6.1 test ships at src/__tests__/skill-schema.test.ts (flat), because of Wave A lock (P0.6.1 packet §P4). P0.6.2 is explicitly re-scoped to nested by the Wave D dispatch prompt:

“Test path: src/__tests__/domains/skills/repository.test.ts — NOT tests/domains/skills/....”

The Jest config at jest.config.ts:

roots: ['<rootDir>/src'],
testMatch: ['**/__tests__/**/*.test.ts', '**/?(*.)+(spec|test).ts'],

**/__tests__/**/*.test.ts matches nested path. Both locations coexist without config changes.


§3. α System Core surface (the pipes P0.6.2 plugs into)

3a. src/db/index.ts — DB runner

  • initDb(path) is sync (better-sqlite3 has no async API). Throws on integrity-check failure or migration error.
  • Migrations are scanned from migrationsRoot()src/db/migrations/ adjacent to the compiled module. tsx resolves to src/db/migrations/; tsc output to dist/db/migrations/ (the latter requires an external copy step flagged in the P0.2.2 module header).
  • File-prefix regex MIGRATION_PREFIX_RE = /^(\d+)_/ — files like 003_skills.sql are picked up automatically.
  • Prefix collision → throw Error("Migration prefix collision at N: <a> vs <b>").
  • Comment-only bodies (stripped + empty) skip db.exec but still bump PRAGMA user_version.
  • Transaction boundary: per-migration (entire file is one transaction).
  • getDb() is non-lazy — throws if initDb has not run. P0.6.2 is a downstream caller and receives the handle explicitly.
  • closeDb() idempotent; called from startup.ts Phase-2 cleanup.

P0.6.2 migration 003_skills.sql must:

  1. Match the prefix regex (003_skills.sql — OK).
  2. Be idempotent or guarded — but the runner already skips via user_version, so raw CREATE TABLE skills (...) without IF NOT EXISTS is fine on first run and never executes on restart.
  3. Wrap in no explicit BEGIN/COMMIT; the runner injects a transaction.
  4. Must NOT fail prefix-collision because P0.3.2 owns 002_tasks.sql and P0.7.2 owns 004_thought_records.sql per the Wave D lock. Both are parallel worktrees, not merged yet; collision risk exists only at merge time.

3b. src/startup.ts — Phase 2 insertion point

Read in full. Key facts:

  • Phase 1 calls bootstrapFn(bootOpts)ctx = { server, transport, auditSink, ... }. Transport is connected; server_ping is registered.
  • Phase 2: after Phase 1 resolves, activeOptions is stashed, signal handlers installed, then initDbFn(dbPath) runs. On success → return { ctx, db, elapsedMs }.
  • P0.6.2 insertion point: after const db = initDbFn(dbPath); but before the return. At this moment db is live and ctx is live. This is where loadSkillsFromDisk(db, skillsRoot, logger) must run.
  • On Phase 2 throw, shutdown('phase-2-failed') runs. If loadSkillsFromDisk throws, it shares this failure path. Design decision (§4a): loadSkillsFromDisk catches per-file parse errors and logs them; only truly fatal errors (DB insertion failure, skillsRoot not accessible) propagate.

3c. src/server.ts — tool registration surface

Read in full. Key facts:

  • registerColibriTool(ctx, name, toolConfig, handler) is the public registration seam. It:
    • Validates name against TOOL_NAME_RE = /^[a-z_][a-z0-9_]*$/ (snake_case). skill_list passes.
    • Rejects duplicate registrations (ctx._registeredToolNames.has(name) throws).
    • Composes the 5-stage α middleware chain inline.
    • Wraps the handler result in { ok, data } / { ok, error } envelope.
  • bootstrap() in server.ts:
    • Constructs ctx.
    • Registers server_ping (the only tool in Phase 1).
    • Calls start(ctx) — connects transport.
    • Returns ctx.

The tool-registration timing problem: if skill_list’s handler reads from the DB (SELECT * FROM skills), the handler can only execute after Phase 2. But tools can be REGISTERED any time before tools/list is first called; the handler itself runs per-invocation. In practice:

  • Option A: register skill_list in bootstrap() with the handler closing over a late-bound db reference (nullable until Phase 2). Handler returns an error envelope if called before Phase 2.
  • Option B: register skill_list in startup() Phase 2 AFTER DB is open. This is cleaner — the tool only appears in tools/list once the server is truly ready.

Phase 0 startup is fast (<30s). Between Phase 1 and Phase 2 no MCP call realistically lands, but a poorly-timed call could. Design decision (§4c): Option B — registerSkillTools(ctx, db) is called from Phase 2. This matches the server_info → phase2_ready transition described in docs/2-plugin/boot.md (eventually) and defers tools/list surface visibility to the post-ready state. Minor concern: the MCP SDK’s tools/list may have been called once between Phase 1 and Phase 2 and cached; in Phase 0 this is acceptable because no production client sees that window.

A ctx.db field would make this cleaner, but adding it here collides with P0.2.4 which owns the ColibriServerContext interface. Decision (§4d): do NOT add ctx.db. Pass db as an explicit second argument to registerSkillTools. The startup.ts caller already has both ctx and db locally.

3d. src/server.ts registerColibriTool input/output shape

  • inputSchema: z.ZodObject<I> — required. For skill_list this is:

    z.object({
      search: z.string().optional(),
      capability: SkillCapabilitySchema.optional(),  // or z.string() per mcp-tools-phase-0.md §Cat 3
    })
    
  • Handler return: the wrapped handler JSON-serializes its return. For skill_list, that’s { skills: [...], total_count: N } matching mcp-tools-phase-0.md §Cat 3 output schema.


§4. Design decisions for P0.6.2

4a. Load-and-skip on parse errors (canonical)

Every SKILL.md in .agents/skills/*/ that fails parseSkillFile (SkillSchemaError or YAML parse error) is logged via the injected logger at [colibri] skill skipped: <path>: <error> and skipped. Startup proceeds. This matches docs/3-world/execution/skill-registry.md: “Each SKILL.md that parses cleanly becomes a row in the registry. Invalid frontmatter is logged and the skill is rejected; γ does not silently downgrade bad skills.”

Fatal errors (skillsRoot missing, DB down) propagate — the Phase 2 try/catch in startup.ts handles them.

4b. Scan scope: all .agents/skills/*/SKILL.md, not only colibri-*

Dispatch prompt deviation #5 explicitly locks this: “load EVERY valid one (all 22 .agents/skills/*/SKILL.md files, not just colibri-*).” The non-colibri-* outlier repo-facing-polish/SKILL.md exists and has valid minimal frontmatter (inspected: name: repo-facing-polish, description: Polish a repository's public GitHub surface...). It will load.

Concept doc (docs/3-world/execution/skill-registry.md) phrases the glob as “.agents/skills/ — the canonical Colibri skill corpus (colibri-*)” — this is a taxonomy description, not a runtime filter. Runtime loads whatever’s on disk that parses.

Corpus test will assert the actual load count matches what’s on disk (fs.readdirSync with startsWith('.') === false filter). Baseline: 22 SKILL.md files → 22 rows (21 colibri-* + 1 repo-facing-polish). If a future PR adds or removes a skill, the test auto-updates.

4c. Tool registration in Phase 2 (not Phase 1)

registerSkillTools(ctx, db) is called from startup.ts AFTER initDbFn(dbPath) succeeds. This defers tools/list surface visibility to the ready state. The bootstrap() function is unchanged structurally — it still registers server_ping only. No ctx.db field added (see §4d).

4d. No ctx.db field mutation

Rationale: ColibriServerContext is shared with P0.2.4 in a parallel worktree. P0.2.4 is expected to add ctx.db as part of its middleware-split work; for P0.6.2 to add it would create a merge conflict on the interface definition. Instead:

  • loadSkillsFromDisk(db, skillsRoot, logger) takes db as arg 1.
  • registerSkillTools(ctx, db) takes db as arg 2.
  • Both are called from startup.ts Phase 2 where db is a local variable.

At merge time P0.2.4 may introduce ctx.db for other callers. P0.6.2 does not need to be refactored — the explicit-argument form remains correct and clear.

4e. Primary key: name (not synthetic id)

Database.md L152 suggests (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE). Sigma-approved deviation #4 locks name TEXT PRIMARY KEY. Rationale:

  • Skill names are globally unique by convention (directory names under .agents/skills/ are unique).
  • No external reference to a skill by UUID exists in Phase 0 — skill_list returns names, skill_get (deferred) would key by name, capability-index reverse-lookup (P0.6.3) maps capability → name.
  • Synthetic ID adds a join cost and a UUID generator requirement with zero Phase 0 benefit.
  • Future: if a skill needs a stable ID across renames (Phase 1+), add id as a second unique column then; no migration loss because rows are restored from disk on every restart.

Trade-off: renaming a skill directory orphans its row. Acceptable — the next startup scan doesn’t find the old name, and the upsert from the new name inserts fresh. Data loss: none (the full frontmatter lives on disk).

Upsert strategy: INSERT OR REPLACE INTO skills (...) keyed on name. Rows that disappear from disk between restarts remain stale in the DB. Phase 0 scope does NOT include garbage collection (deferred to Phase 1 along with hot-reload). Startup could optionally DELETE FROM skills WHERE name NOT IN (...) to prune, but that’s non-acceptance work. Decision: yes, prune — the registry invariant is “every row on disk is indexed, no rows not on disk are indexed.” One SQL with a placeholder-IN list, executed inside the same transaction as inserts.

4f. Schema: what columns

Sigma-approved deviation #4 sets the baseline. Mapped to the P0.6.1 parser output:

Column Source Null? Rationale
name TEXT PRIMARY KEY frontmatter.name NOT NULL (implicit) Unique identity.
description TEXT NOT NULL frontmatter.description NOT NULL Required by schema.
version TEXT frontmatter.version Nullable Optional; 0/21 colibri-* declare it.
entrypoint TEXT frontmatter.entrypoint Nullable Optional; 0/21 declare it.
capabilities TEXT JSON-stringify(frontmatter.capabilities ?? []) NOT NULL default '[]' SQLite lacks arrays; JSON stored as TEXT. Empty array = empty skill (valid).
greek_letter TEXT frontmatter.greekLetter Nullable Optional; 0/21 declare it. camelCase → snake_case per SQL convention.
body TEXT NOT NULL parsedSkill.body NOT NULL Full markdown body. Preserves body for future skill_get (Phase 1).
source_path TEXT NOT NULL absolute path from scan → path.relative(repoRoot, abs) NOT NULL Repo-relative for portability. Used for the path field in the skill_list response.
frontmatter_json TEXT NOT NULL JSON-stringify(frontmatter) NOT NULL Full passthrough-preserved frontmatter. Future-proofs against schema evolution.
loaded_at TEXT NOT NULL ISO-8601 timestamp at load time NOT NULL Debug/diagnostic.

Plus one index: CREATE INDEX idx_skills_greek ON skills(greek_letter) for future P0.6.3 capability-index join (cheap, no downside).

No FK — skills stand alone; no table references them in Phase 0.

4g. skill_list input shape

Per docs/reference/mcp-tools-phase-0.md §Cat 3:

interface SkillListInput {
  search?: string;       // substring match on name or description
  capability?: string;   // filter by capability string
}

capability type: mcp-tools doc uses string; the Zod schema uses SkillCapabilitySchema (closed enum). The prompt’s acceptance criterion says listSkills({ capability? }). Using the Zod enum is safer (invalid capability → schema-validate stage rejects) but also more brittle — the concept doc hints capabilities are “opaque tags” (P0.6.3 note).

Decision: use z.string().optional() for the Zod input to match mcp-tools-phase-0.md literally. The capability matching is array containment on the JSON-decoded capabilities column. Accepts capability names outside the canonical 5 — matches the concept doc’s “opaque tags” guidance. The internal listSkills(filters) helper uses the same string type; both search and capability are pure filters.

4h. Response shape

interface SkillListOutput {
  skills: Array<{
    name: string;
    version: string | null;
    description: string;
    capabilities: string[];
    greek_letter: string | null;
    path: string;  // repo-relative source_path
  }>;
  total_count: number;  // after filter
}

Matches mcp-tools-phase-0.md with one concession: greek_letter included (useful metadata, present in column). version and greek_letter can be null because they’re nullable in the DB. capabilities is always an array (empty if no capabilities declared).


§5. Referenced docs

Read or consulted:

  • docs/guides/implementation/task-breakdown.md L291-301 — task spec.
  • docs/spec/s17-mcp-surface.md §1 Category 3 — skill_list is the only ε Phase 0 tool.
  • docs/reference/mcp-tools-phase-0.md §Cat 3 — skill_list input/output schema, example.
  • docs/3-world/execution/skill-registry.md — concept doc (in lieu of the missing docs/concepts/ε-skill-registry.md).
  • docs/2-plugin/database.md §”ε Skill Registry” L147-160 — donor table shape (deviated from per §4e).
  • docs/guides/implementation/task-prompts/p0.6-epsilon-skills.md — P0.6.1+P0.6.2+P0.6.3 prompt triptych.
  • docs/reference/extractions/epsilon-skill-registry-extraction.md — donor AMS heritage (not applicable to Phase 0; Phase 0 scope is single-tool).
  • docs/contracts/p0-6-1-skill-schema-contract.md + docs/packets/p0-6-1-skill-schema-packet.md + docs/audits/p0-6-1-skill-schema-audit.md — P0.6.1 chain; establishes the parser invariants.

The concept doc at docs/concepts/ε-skill-registry.md referenced in the task spec does not exist in this worktree — the live doc is docs/3-world/execution/skill-registry.md. This is an existing doc reference drift, not a P0.6.2 defect. Non-blocking.


§6. Risk inventory

  1. Migration number collision at merge. P0.3.2 and P0.7.2 are parallel worktrees that write 002_tasks.sql and 004_thought_records.sql respectively. Sigma’s Wave D lock assigns disjoint numbers (002/003/004). Collision can only happen if a task violates the lock. Mitigation: Step 4 verification runs ls src/db/migrations/ — any 00{2,4}_*.sql file present at merge means the other worktree shipped first and P0.6.2 must rebase. Not expected; flagged for Step 5 verification.
  2. bootstrap() 3-way merge conflict with P0.7.2. Both P0.6.2 and P0.7.2 add a single tool-registration call inside bootstrap() after server_ping. The prompt explicitly calls this out and says to “put your registration call on its own line so git’s 3-way merge auto-resolves.” Deviation: per §4c above, P0.6.2 does NOT edit bootstrap(). The registration happens in startup.ts Phase 2. P0.7.2 still edits bootstrap() → no conflict on bootstrap() edits from P0.6.2. Net improvement.
  3. Conflict on startup.ts Phase 2. P0.6.2 adds lines after initDbFn(dbPath). If P0.2.4 or P0.7.2 also appends Phase-2 calls, the 3-way merge sees sequential additions. Mitigation: minimal footprint, stable surrounding context. Expected to auto-resolve.
  4. Parallel-worktree leak (Wave C class). If an uncommitted edit from P0.2.4 / P0.7.2 / another concurrent worktree “bleeds” into this worktree’s index, P0.6.2 could ship an unintended hunk. Mitigation: Step 1 pre-clean ran git status && git diff --stat; tree is clean. Any mid-task cross-worktree artifact will be caught by git status before Step 4 commit.
  5. tsconfig excludes tests. tsconfig.json exclude: ["**/*.test.ts"]. Test file type-checks via ts-jest’s own relaxed tsconfig block. This is a known existing posture (§2c of P0.6.1 audit). No change.
  6. ESLint no-console warn. console.error / console.warn are allowed by the rule. loadSkillsFromDisk accepts a logger function; tests inject a capturing spy. No console usage from runtime code.
  7. Windows file locks on test temp DB. db-init.test.ts uses fs.rmSync(..., {force: true}) with a swallow. P0.6.2 tests follow the same pattern.
  8. repo-facing-polish is not colibri-*. Correctly loads into registry under §4b. Verified by reading its SKILL.md — frontmatter is valid (minimal: name + description).

§7. Acceptance mapping

Task spec acceptance Implementation Test
Startup scans .agents/skills/*/SKILL.md into skills table loadSkillsFromDisk(db, root, logger) called from startup.ts Phase 2 corpus-load test, parse-error skip test
getSkill(name) → skill or null SELECT WHERE name = ? happy + miss tests
listSkills({search?, capability?}) → filtered Prepared statement with LIKE search + JSON capability LIKE ≥ 6 filter tests
skill_list MCP tool, sole ε Phase 0 tool registerSkillTools(ctx, db) registers exactly one tool with snake_case name round-trip through registerColibriTool verified
skill_get, skill_reload, hot-reload not in Phase 0 Not exported, not registered grep-for-absence test

§8. Commit plan

Per CLAUDE.md §6:

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

Audit locked. Step 2 (Contract) 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.