P0.6.3 — Step 2 Behavioral Contract
Grounded in ../audits/p0-6-3-capability-index-audit.md. Defines the public API, invariants, and error contracts for the ε capability index — the final module that closes the ε Skill Registry axis.
C1. Public API
All exports live in src/domains/skills/capability-index.ts and (for the live-state getter) src/domains/skills/repository.ts. No barrel, no eager side effect on import.
C1.1 Types
// src/domains/skills/capability-index.ts
/**
* Minimal shape the index quantifies over. `SkillRow` (from repository.ts) is
* structurally assignable to this, as is any test fixture that produces the
* same two fields.
*/
export interface IndexableSkill {
readonly name: string;
readonly capabilities: readonly string[];
}
SkillRow (P0.6.2, unchanged) is the production value that flows into the index. The narrowed IndexableSkill is what the two pure helpers accept — this keeps the module test-friendly.
C1.2 Pure helpers
/**
* Build a reverse lookup: capability string → Set of skill names that declare
* it. Skills with multiple capabilities appear in every bucket. Skills with
* an empty `capabilities` array contribute to no bucket (the index size is
* unaffected). Duplicate entries within a single skill's `capabilities`
* collapse at the Set level.
*
* Pure, synchronous, side-effect free. Safe to call multiple times.
*
* @param skills Read-only view of skills to index. Input order does not
* affect the index — the Set membership is content-addressed
* by (capability, name).
* @returns `Map<capability, Set<skillName>>`. Keys are the capability
* strings **as they appear in the input** (no normalization).
* Values are Sets; iteration order is insertion order per JS
* spec, but callers should not rely on it — use
* `findSkillsByCapability` to get a sorted view.
*/
export function buildCapabilityIndex(
skills: readonly IndexableSkill[],
): Map<string, Set<string>>;
/**
* Query the index. Returns a **sorted** `string[]` of skill names that
* declare `capability`, or `[]` if the capability is unknown (not present in
* any skill's frontmatter).
*
* Sorting is lexicographic ASCII-ascending via `Array.prototype.sort()` with
* no comparator. This matches the SQL `ORDER BY name ASC` ordering used by
* `listSkills`, giving the parity test a clean equality assertion.
*
* Pure, synchronous, side-effect free.
*
* @param index A Map produced by `buildCapabilityIndex`.
* @param capability The capability string to look up. Empty string returns
* whatever `index.get("")` holds (normally `[]` because
* the empty string is not a valid frontmatter capability).
*/
export function findSkillsByCapability(
index: Map<string, Set<string>>,
capability: string,
): string[];
C1.3 Live-state getter (lives in repository.ts)
// src/domains/skills/repository.ts (new export)
/**
* Return the current in-process capability index. The index is populated by
* `loadSkillsFromDisk` — callers that query before any load see an empty Map
* (not `undefined`, not `null`). After the first load, the returned Map
* reflects the most recently loaded skill set; subsequent loads atomically
* replace the Map reference (no in-place mutation of a Map held by prior
* callers).
*
* Pure read; does not log, does not touch the DB.
*/
export function getCapabilityIndex(): Map<string, Set<string>>;
A non-exported __resetCapabilityIndexForTests() function is available inside the module for Jest suites that need a clean slate within a single process (e.g. any future cross-module integration test). It is not part of the public API and is not imported outside the test tree.
C1.4 No new MCP tool
capability-index.ts contains zero calls to registerColibriTool. The Phase 0 ε surface stays at exactly one tool (skill_list), per S17 §1 and ADR-004.
C2. Invariants
INV1 — Multi-capability membership
A skill whose capabilities array contains ["read", "write"] appears in both the "read" Set and the "write" Set of the resulting index. It is not placed in a combined "read+write" key, and it does not appear in exactly one of the two.
INV2 — Set semantics
Within a single capability bucket, each skill name appears at most once. Duplicate capabilities in a single skill’s frontmatter (which Zod would already reject at parse time) are silently collapsed by the Set. This makes findSkillsByCapability safe to call even on a malformed input fixture.
INV3 — Sorted return
findSkillsByCapability(index, C) returns a string[] whose elements are in ASCII-ascending order. This is asserted in every unit test that checks the return value.
INV4 — Unknown-capability → empty array
findSkillsByCapability(index, "nonexistent") returns [] (a fresh array), not undefined, not null, not a shared sentinel. Callers may mutate the returned array without affecting the index.
INV5 — Purity of index functions
buildCapabilityIndex and findSkillsByCapability do not:
- read files
- call
console.*or any logger - mutate their inputs
- open DB connections
- throw except on genuinely unrecoverable input (which in TypeScript, with the above signatures, means only environmental failures like
Setallocation — not in-scope for Phase 0).
INV6 — Index lifecycle
The module-level _capabilityIndex in repository.ts starts as new Map() on import. It is replaced (not mutated) by loadSkillsFromDisk after each successful DB transaction. getCapabilityIndex() returns the current reference.
INV7 — Parity with listSkills({capability})
For any capability string C present in the current skill set, the following must hold:
const viaIndex = findSkillsByCapability(getCapabilityIndex(), C).sort();
const viaSql = listSkills(db, { capability: C }).map(r => r.name).sort();
expect(viaIndex).toEqual(viaSql);
This is the load-bearing contract between the SQL filter (LIKE '%"C"%') and the in-process index. Both sides are case-sensitive and use exact token match. The parity test in the new test suite asserts this for every capability declared in the fixture, including unknown capabilities (both return []).
INV8 — Build-once invariant
buildCapabilityIndex is called exactly once per loadSkillsFromDisk call, and only after the upsert+prune transaction has committed. It is not called on every MCP request, not lazily on the first getCapabilityIndex call, and not from any watcher.
INV9 — No subprocess, no worktree, no agent runtime
The capability-index module does not:
- spawn subprocesses (
child_process,worker_threads) - invoke git or create worktrees
- export any
agent_*symbol - reference
src/domains/agents/(which does not exist)
This is enforced by static audit and by the diff scope — the entire module fits in ~130 LOC of pure TypeScript.
C3. Error contracts
C3.1 buildCapabilityIndex
- Valid input (empty or non-empty
readonly IndexableSkill[]): returns a Map. Empty input returns an empty Map. null/undefinedinput: not covered by the type signature; not tested. TypeScript strict mode prevents this at the call site.SkillRowwith unexpected runtime shape (capabilities: undefined): the innerfor (const cap of skill.capabilities)iteration throwsTypeError. This is acceptable — the DB column hasDEFAULT '[]'anddecodeRowguaranteesreadonly string[]. The only path where a mutated row could slip in is through a test fixture, which is the test author’s responsibility.
C3.2 findSkillsByCapability
- Valid input: returns a sorted
string[], possibly empty. capability = ""(empty string): returns whateverindex.get("")holds, which for a well-formed index is always[](the SKILL.md parser rejects empty capability strings at the Zod layer). No error.capabilitypresent in Map but Set is empty (theoretical edge case, not observable in production because we only ever add to a Set): returns[]. Sorted empty array.- No throws.
C3.3 getCapabilityIndex
- Always returns a Map, never
undefined. Empty Map ifloadSkillsFromDiskhas not yet been called in the current process. - No throws.
C4. Storage model
No storage. The capability index is in-process memory only. Rebuilt on every loadSkillsFromDisk call from the post-transaction row set. There is no persistence across process restart — the index is reconstructed from the DB at startup, which is cheap (O(N) over the skill count; N is in the 10s for Phase 0).
C5. Integration with loadSkillsFromDisk
The following sequence is appended to loadSkillsFromDisk, after the existing tx() invocation and the summary logger(...) call:
const postLoadRows = listSkills(db); // full list, no filter
_capabilityIndex = buildCapabilityIndex(postLoadRows);
That’s the entire integration. Three lines inside loadSkillsFromDisk (the comment, the read, the assign). getCapabilityIndex() is a one-line getter.
Why after the summary logger: the summary log is part of the contract-visible output of the loader. The index build does not log (INV5). Placing the index build after the log keeps logger output stable and makes the diff obvious.
Why listSkills(db) and not the upserts in-memory array: using listSkills(db) after commit ensures the index sees post-prune state. If we used the upsert array, pruned rows would remain in the index until the next process restart. Reading from the DB is O(N) over the same rows — no observable cost.
C6. Test suite contract
New file: src/__tests__/domains/skills/capability-index.test.ts.
C6.1 Fixtures
Pure in-memory fixtures — no tmpdir, no SKILL.md files, no SQLite. The fixture shape is a plain array of { name, capabilities } objects satisfying IndexableSkill. The parity test uses a real in-memory DB with 004_skills.sql applied, same fixture loaded via listSkills.
C6.2 Test suites (describe blocks)
| Suite | Tests | Property covered |
|---|---|---|
buildCapabilityIndex — empty |
1 | Empty input → empty Map |
buildCapabilityIndex — single-capability |
2 | One skill, one capability → one bucket with one name |
buildCapabilityIndex — multi-capability |
2 | One skill with ["read","write"] → appears in both buckets (INV1) |
buildCapabilityIndex — multiple skills |
2 | Three skills, mixed capabilities → each bucket holds the right names |
buildCapabilityIndex — duplicate capabilities in one skill |
1 | Set semantics (INV2) |
buildCapabilityIndex — skill with empty capabilities |
1 | No bucket contribution |
findSkillsByCapability — unknown capability |
1 | Returns [] (INV4) |
findSkillsByCapability — sorted return |
2 | Lexicographic ASCII sort (INV3) |
findSkillsByCapability — known capability |
1 | Returns the sorted names for one bucket |
findSkillsByCapability — empty string key |
1 | Returns [] |
findSkillsByCapability — returns fresh array |
1 | Mutating the result does not affect the index |
getCapabilityIndex — before any load |
1 | Empty Map (INV6) |
getCapabilityIndex — after loadSkillsFromDisk |
2 | Index reflects loaded skills |
getCapabilityIndex — after second load (rebuild) |
1 | Post-prune state reflected |
parity — listSkills({capability: C}) vs findSkillsByCapability(index, C) |
1 parameterized | Load-bearing INV7 check — iterates every capability present in the fixture + one unknown, asserts set equality with a sorted-.map(r=>r.name) projection |
Total: ~20 tests.
C6.3 Test ordering + isolation
- Each test uses a fresh in-memory DB (via
makeTestDb()helper, reused from P0.6.2 test file style). - Each test constructs its own fixture — no shared mutable state between tests.
__resetCapabilityIndexForTests()is called inbeforeEachof any suite that touches the module-level getter, to isolate test order.
C7. Non-contracts (explicit negative space)
- No
skill_getMCP tool. - No
skill_reloadMCP tool. - No
agent_spawn,agent_status,agent_listMCP tools. - No file watcher (neither
fs.watchnorchokidar). - No
src/domains/agents/directory. - No subprocess spawn.
- No worktree creation from this task (the worktree the task runs in is not code-managed).
- No normalization of capability strings (case / whitespace).
- No new migration.
- No persistence of the index.
- No change to
src/startup.ts. - No change to
src/server.ts. - No change to the
skill_listtool’s input or output shape.
C8. Acceptance criteria trace (second pass, post-contract)
| AC | Contract anchor |
|---|---|
buildCapabilityIndex returns Map<string, Set<string>> |
C1.2 signature |
| Multi-capability skills in every relevant bucket | INV1 |
findSkillsByCapability returns sorted string[], [] for unknown |
INV3, INV4 |
| Index built once in loader, not lazily | INV8 + §C5 |
Parity with listSkills({capability}) confirmed by test |
INV7 + C6.2 parity suite |
| Zero new MCP tools | C1.4, §C7 |
No src/domains/agents/ created, no subprocess spawning |
INV9, §C7 |
| No file watcher, no hot-reload path | §C7 + §C5 |