P0.6.3 — Step 1 Audit

Inventory of the worktree against the task spec for P0.6.3 ε Skill Capability Index (third and final ε sub-task). Scope: build a pure in-process reverse index Map<capability, Set<skillName>> on top of the P0.6.2 skill registry cache, exposed as an internal helper.

Baseline: worktree E:/AMS/.worktrees/claude/p0-6-3-capability-index/ at commit 09d462f8 (main tip — Wave G merged: P0.9.2 Claude API, P0.7.3 ζ verify_chain, P0.9.1 ν MCP bridge).

This is the last remaining non-deferred Phase 0 code task. After it merges, Phase 0 = 26/28 with P0.5.1 + P0.5.2 parked behind ADR-005 (δ Model Router deferred to Phase 1.5).


§1. Surface being added

1a. Targets this task CREATES

Path Kind Expected LOC
src/domains/skills/capability-index.ts new module 90–140
src/__tests__/domains/skills/capability-index.test.ts new test file 250–350
docs/audits/p0-6-3-capability-index-audit.md this file
docs/contracts/p0-6-3-capability-index-contract.md Step 2
docs/packets/p0-6-3-capability-index-packet.md Step 3
docs/verification/p0-6-3-capability-index-verification.md Step 5

1b. Targets this task MODIFIES

Path Change Expected delta
src/domains/skills/repository.ts Wire capability-index build into loadSkillsFromDisk; expose a getter +20–35 lines

Worktree scan confirms absence of the new module and test file:

  • ls src/domains/skills/ → only schema.ts, repository.ts (no capability-index.ts)
  • ls src/__tests__/domains/skills/ → only repository.test.ts (no capability-index.test.ts)
  • grep -rn "buildCapabilityIndex\|findSkillsByCapability\|getCapabilityIndex\|capability-index" src/ → zero matches

Greenfield module; one existing module gets a small integration hook.

1c. Explicit non-targets

  • No new MCP tool. Phase 0 ε surface stays at exactly one tool, skill_list (S17 §1). The prompt forbids skill_get, skill_reload, and any agent_* registration.
  • No src/domains/agents/ directory. Agent runtime, worktree orchestration, subprocess spawning — all deferred to Phase 1.5 per ADR-005 and CLAUDE.md §9.1.
  • No file watcher, no hot-reload path. Phase 0 is one-shot at startup. The index is built once, from the same skill set the cache sees.
  • No migration. The capability index is a pure in-process reverse lookup; it lives in memory, not in SQLite. The skills table already has the authoritative capabilities JSON column (P0.6.2).
  • No src/startup.ts change. The existing loadSkillsFromDisk call-site in Phase 2 is unchanged; the index is built inside the loader and surfaced via a module-level getter (see §4 design).

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

2a. src/domains/skills/repository.ts — exports in scope for P0.6.3

Read in full at src/domains/skills/repository.ts (459 lines). Exports:

Export Shape P0.6.3 use
SkillRow interface { name, description, version\|null, entrypoint\|null, capabilities: readonly string[], greek_letter\|null, body, source_path, frontmatter_json, loaded_at } The shape the index quantifies over. capabilities is already JSON-decoded to a readonly string[].
ListSkillsFilters { search?, capability? } Reference only — we do not re-filter through here; we index directly from the row set.
LoadSkillsResult { loaded, skipped, pruned, total_on_disk } Unchanged return type. P0.6.3 does not alter the result shape.
Logger (...args: unknown[]) => void Reused as-is for any capability-index warnings (none planned in Phase 0).
loadSkillsFromDisk(db, skillsRoot, logger) function — scan → parse → upsert → prune Hook point. After the tx() runs, we re-read rows and populate the in-process capability index.
getSkill(db, name) SkillRow \| null Consumer of the cache, not used by index itself.
listSkills(db, filters) readonly SkillRow[] Backs the parity test in the new suite (findSkillsByCapability(C) == listSkills({capability:C}).map(s=>s.name)).
registerSkillTools(ctx) MCP registration Unchanged. No new tool is added.

2b. src/domains/skills/schema.ts — exports in scope

Read in full at src/domains/skills/schema.ts (198 lines). Exports:

Export Shape P0.6.3 use
CAPABILITIES readonly ['read','write','spawn','audit','admin'] Phase 0 closed-enum token set. The index is KEY-AGNOSTIC (treats strings as opaque tags per concept doc), so the enum is reference only — the index does not reject non-enum capabilities, it just indexes whatever SkillRow.capabilities contains.
SkillCapability inferred 'read'\|'write'\|'spawn'\|'audit'\|'admin' Not used directly; we operate on plain string per task spec (“capability strings are treated as opaque tags”).

Decision: the index keys are string, not SkillCapability. Rationale:

  1. Task spec P0.6.3 AC#2 says “Capability strings are treated as opaque tags (Phase 0 does not enforce an enum)”.
  2. SkillRow.capabilities is already readonly string[] at the row level.
  3. Keeping the key type as string insulates the index from Phase 1+ capability-set expansion.

2c. src/db/migrations/004_skills.sql

Read for context — confirms capabilities TEXT NOT NULL DEFAULT '[]' (JSON-encoded). No migration change is needed for this task.

2d. src/startup.ts Phase 2 — the call site

The existing Phase 2 handler calls loadSkillsFromDisk(db, skillsRootPath, logger) after initDb. No structural change is required in startup.ts; the capability index is built inside loadSkillsFromDisk (or as its continuation inside repository.ts) and surfaced via a module-level getter.


§3. Target API — quick reference

Per the Ready-to-paste prompt (task-prompts/p0.6-epsilon-skills.md §P0.6.3) and task-breakdown §P0.6.3:

// src/domains/skills/capability-index.ts

/** Input: a read-only view of skills. Can be SkillRow[] or anything with .name + .capabilities. */
export interface IndexableSkill {
  readonly name: string;
  readonly capabilities: readonly string[];
}

/** Build a reverse index: capability string → sorted set of skill names. */
export function buildCapabilityIndex(
  skills: readonly IndexableSkill[],
): Map<string, Set<string>>;

/** Query the index. Returns sorted string[]; empty [] for unknown capability. */
export function findSkillsByCapability(
  index: Map<string, Set<string>>,
  capability: string,
): string[];

/** Module-level getter for the live index (populated by loadSkillsFromDisk). */
// Lives on repository.ts to match the existing module-level cache pattern.
export function getCapabilityIndex(): Map<string, Set<string>>;

Design note — IndexableSkill vs SkillRow:
The task prompt says buildCapabilityIndex(skills: Skill[]). SkillRow from the existing repository is a superset of what we need (name + capabilities suffice). Accepting a narrowed interface keeps the index module test-friendly (fixtures don’t need to construct the DB-row fields like body, source_path, etc.) and declares intent. SkillRow is assignable to IndexableSkill, so the integration at the loadSkillsFromDisk call site is zero-friction.


§4. Integration shape — loadSkillsFromDisk hook

Current loadSkillsFromDisk flow:

readdir(skillsRoot) → parse each SKILL.md → collect upserts
→ db.transaction(upsert + prune)
→ logger(summary) → return LoadSkillsResult

Extension:

... (all of the above unchanged) ...
→ After the tx returns, call `listSkills(db)` once and pass the rows to
  `buildCapabilityIndex(rows)`.
→ Store the resulting Map in a module-level variable `_capabilityIndex`.
→ `getCapabilityIndex()` returns the current Map (empty Map before first load).
→ Subsequent `loadSkillsFromDisk` calls REBUILD the index from the post-tx
  row set (idempotent — same way the cache itself is idempotent).

Module-level state is chosen over a class because the existing repository is function-based (no SkillRepository class). Introducing a class now would require refactoring loadSkillsFromDisk, registerSkillTools, and three server-init call sites — out of scope for a single-Small task. The module-level getter pattern matches what the existing domain already does (the skills table itself is the singleton cache; the in-process index is analogous).

Reset semantics for tests: a non-exported __resetCapabilityIndexForTests() function is exposed so test suites that share a module can start from an empty index. Jest’s ESM module re-evaluation already isolates per-file; this is only a defensive export (kept non-public in the contract).


§5. Acceptance criteria trace (prompt → implementation)

AC from prompt How satisfied
buildCapabilityIndex returns Map<string, Set<string>> Direct implementation in capability-index.ts §buildCapabilityIndex.
Multi-capability skills in every relevant bucket Nested loop: outer for skill of skills, inner for cap of skill.capabilities adds skill.name to Set at key cap. Common bug (overwrite-vs-append) avoided by Map.get(cap) ?? new Set() pattern.
findSkillsByCapability returns sorted string[], [] for unknown Array.from(index.get(cap) ?? []).sort(). Pure, total, no throws.
Index built once, at startup, same skill set as cache Called inline at the end of loadSkillsFromDisk, using rows returned from a single listSkills(db) invocation right after the upsert tx. One-shot.
Parity test: findSkillsByCapability(index, C)listSkills({capability:C}).map(s=>s.name) Explicit parity suite at the bottom of capability-index.test.ts iterates every capability present in a fixture and asserts set equality.
Empty capability filter → empty string[] The prompt’s stronger phrasing is “unknown capability returns []”. The index’s Map-lookup pattern returns [] for any key not in the Map. Tested.
Zero new MCP tools capability-index.ts has no registerColibriTool call. registerSkillTools signature unchanged. Surface stays skill_list.
No src/domains/agents/, no subprocess spawning, no worktree creation No such code anywhere in the diff. Audit scan confirms.
No file watcher, no hot-reload path No fs.watch, no chokidar, no interval timer. Index is populated synchronously inside loadSkillsFromDisk.
npm run build && npm run lint && npm test green Gate run before push per Wave G lesson (lint gate escape).

§6. Risk inventory

6.1 Capability-string normalization

Risk: a skill declares "Read" (capitalized) while another declares "read". Should the index coalesce or not?

Decision: no normalization. The index keys use capabilities exactly as the SKILL.md frontmatter declares them. This matches listSkills({capability}) which uses LIKE '%"cap"%' against the JSON-encoded column — also case-sensitive. The parity test relies on this. If normalization is wanted later, it can be added in Phase 1 without API break.

6.2 Pruning

Risk: when loadSkillsFromDisk prunes a skill (directory disappears), does the index still reference it?

Decision: the index is rebuilt AFTER the prune transaction commits, from the post-prune row set. A pruned skill disappears from the index on the next loadSkillsFromDisk call.

6.3 Circular imports

Risk: capability-index.ts imports from repository.ts, which in turn calls buildCapabilityIndex — circular?

Decision: no. capability-index.ts imports only from ./schema.js (for the SkillCapability type reference, if any). It exports pure functions. repository.ts imports buildCapabilityIndex from ./capability-index.js. One-way.

6.4 Phase-0-style lazy side effects

Risk: populating a module-level index at load time can be misread as an “eager side effect on import”.

Decision: the module-level _capabilityIndex is initialized to new Map() at import (trivially eager), but the population happens only inside loadSkillsFromDisk, which is itself called imperatively by startup.ts Phase 2. No I/O, no DB access, no logging at import time. This matches the existing repository.ts eagerness posture.

6.5 Parity-test fragility

Risk: if listSkills({capability}) changes filter semantics (e.g. case-insensitivity), the parity test breaks.

Decision: accepted. The parity assertion is the load-bearing contract between the DB-column filter and the in-process index. If one changes, the other must change. The test surfaces this loudly, which is the desired property.


§7. Files not touched (verification)

  • src/db/schema.sqlunchanged. The capability index is in-process only; no storage representation.
  • src/db/migrations/no new migration. Highest existing migration remains 006_merkle_batches.sql. Next free slot stays 007 (defensive pre-assignment from Wave F is irrelevant here).
  • src/server.tsunchanged. registerSkillTools signature and call site unaffected.
  • src/startup.tsunchanged. Phase 2 already invokes loadSkillsFromDisk; the index is built inside that call.
  • src/tools/unchanged. No new MCP tool file.
  • src/domains/agents/does not and will not exist in Phase 0.

§8. Baseline sanity (pre-Step-4)

Check Result
git rev-parse HEAD 09d462f8ffb00640046975c52661a1dd049fff42 (main tip)
npm install 525 packages, clean
npm run build tsc clean, zero diagnostics
npm test Not run yet at baseline — will run in Step 5 verification
npm run lint Not run yet at baseline — will run in Step 5 verification

No pre-existing failures observed on this branch at baseline; the task builds on a green main.


§9. Next steps

  1. Step 2 (docs/contracts/p0-6-3-capability-index-contract.md): freeze the public API, invariants, and parity contract.
  2. Step 3 (docs/packets/p0-6-3-capability-index-packet.md): executable diff plan (file-by-file) gating Step 4.
  3. Step 4: implement capability-index.ts, wire into repository.ts, add test suite.
  4. Step 5 (docs/verification/p0-6-3-capability-index-verification.md): record test evidence; close task.
  5. Push branch, open PR, write back (task_update + thought_record).

Back to top

Colibri — documentation-first MCP runtime. Apache 2.0 + Commons Clause.

This site uses Just the Docs, a documentation theme for Jekyll.