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. ExportsloadSkillsFromDisk,getSkill,listSkills, andregisterSkillTools. 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 tosrc/__tests__/domains/<concept>/<module>.test.ts. JesttestMatch: ['**/__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 oneregisterSkillTools(ctx, ctx.db)call insidebootstrap()AFTERserver_ping. Becausebootstrap()runs in Phase 1 before the DB exists (see §3.2), the actual registration happens instartup()Phase 2, NOT inbootstrap(). See §4 design decision.src/startup.ts— afterinitDbFn(dbPath)succeeds in Phase 2, callloadSkillsFromDisk(db, skillsRoot, logger)and (per §4) register theskill_listtool againstctx.src/db/schema.sql— append skills ownership comment block.schema.sqlis a documentation asset; no executable SQL.
A worktree scan confirms absence of the authoring targets:
ls src/domains/skills/→ onlyschema.ts(P0.6.1)ls src/__tests__/domains/→ “No such file or directory”ls src/db/migrations/→ only001_init.sqlgrep -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.mdand 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— NOTtests/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.tsxresolves tosrc/db/migrations/;tscoutput todist/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 like003_skills.sqlare picked up automatically. - Prefix collision → throw
Error("Migration prefix collision at N: <a> vs <b>"). - Comment-only bodies (stripped + empty) skip
db.execbut still bumpPRAGMA user_version. - Transaction boundary: per-migration (entire file is one transaction).
getDb()is non-lazy — throws ifinitDbhas not run. P0.6.2 is a downstream caller and receives the handle explicitly.closeDb()idempotent; called fromstartup.tsPhase-2 cleanup.
P0.6.2 migration 003_skills.sql must:
- Match the prefix regex (
003_skills.sql— OK). - Be idempotent or guarded — but the runner already skips via
user_version, so rawCREATE TABLE skills (...)withoutIF NOT EXISTSis fine on first run and never executes on restart. - Wrap in no explicit
BEGIN/COMMIT; the runner injects a transaction. - Must NOT fail prefix-collision because P0.3.2 owns
002_tasks.sqland P0.7.2 owns004_thought_records.sqlper 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_pingis registered. - Phase 2: after Phase 1 resolves,
activeOptionsis stashed, signal handlers installed, theninitDbFn(dbPath)runs. On success →return { ctx, db, elapsedMs }. - P0.6.2 insertion point: after
const db = initDbFn(dbPath);but before thereturn. At this momentdbis live andctxis live. This is whereloadSkillsFromDisk(db, skillsRoot, logger)must run. - On Phase 2 throw,
shutdown('phase-2-failed')runs. IfloadSkillsFromDiskthrows, it shares this failure path. Design decision (§4a):loadSkillsFromDiskcatches 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
nameagainstTOOL_NAME_RE = /^[a-z_][a-z0-9_]*$/(snake_case).skill_listpasses. - 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.
- Validates
bootstrap()inserver.ts:- Constructs
ctx. - Registers
server_ping(the only tool in Phase 1). - Calls
start(ctx)— connects transport. - Returns
ctx.
- Constructs
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_listinbootstrap()with the handler closing over a late-bounddbreference (nullable until Phase 2). Handler returns an error envelope if called before Phase 2. - Option B: register
skill_listinstartup()Phase 2 AFTER DB is open. This is cleaner — the tool only appears intools/listonce 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. Forskill_listthis 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 }matchingmcp-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)takesdbas arg 1.registerSkillTools(ctx, db)takesdbas arg 2.- Both are called from
startup.tsPhase 2 wheredbis 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_listreturns 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
idas 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.mdL291-301 — task spec.docs/spec/s17-mcp-surface.md§1 Category 3 —skill_listis the only ε Phase 0 tool.docs/reference/mcp-tools-phase-0.md§Cat 3 —skill_listinput/output schema, example.docs/3-world/execution/skill-registry.md— concept doc (in lieu of the missingdocs/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
- Migration number collision at merge. P0.3.2 and P0.7.2 are parallel worktrees that write
002_tasks.sqland004_thought_records.sqlrespectively. 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 runsls src/db/migrations/— any00{2,4}_*.sqlfile present at merge means the other worktree shipped first and P0.6.2 must rebase. Not expected; flagged for Step 5 verification. bootstrap()3-way merge conflict with P0.7.2. Both P0.6.2 and P0.7.2 add a single tool-registration call insidebootstrap()afterserver_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 editbootstrap(). The registration happens instartup.tsPhase 2. P0.7.2 still editsbootstrap()→ no conflict onbootstrap()edits from P0.6.2. Net improvement.- Conflict on
startup.tsPhase 2. P0.6.2 adds lines afterinitDbFn(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. - 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 bygit statusbefore Step 4 commit. - tsconfig excludes tests.
tsconfig.jsonexclude: ["**/*.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. - ESLint
no-consolewarn.console.error/console.warnare allowed by the rule.loadSkillsFromDiskaccepts aloggerfunction; tests inject a capturing spy. No console usage from runtime code. - Windows file locks on test temp DB.
db-init.test.tsusesfs.rmSync(..., {force: true})with a swallow. P0.6.2 tests follow the same pattern. repo-facing-polishis notcolibri-*. 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:
audit(p0-6-2-skill-crud): inventory ε parser + DB + startup + corpus— this document.contract(p0-6-2-skill-crud): behavioral contract— next.packet(p0-6-2-skill-crud): execution plan— after contract.feat(p0-6-2-skill-crud): ε skill repository + loader + skill_list MCP tool— implementation.verify(p0-6-2-skill-crud): test evidence— coverage + corpus log.
Audit locked. Step 2 (Contract) proceeds.