P0.6.3 — Step 3 Execution Packet
Grounded in ../audits/p0-6-3-capability-index-audit.md and ../contracts/p0-6-3-capability-index-contract.md. Executable plan for Step 4.
Sigma pre-approved (single-task dispatch, final non-deferred Phase 0 task). Packet-gate waiver applies.
P1. Change list
P1.1 New files
| Path | Purpose | Expected LOC |
|---|---|---|
src/domains/skills/capability-index.ts |
buildCapabilityIndex, findSkillsByCapability pure helpers |
90–140 |
src/__tests__/domains/skills/capability-index.test.ts |
Jest suite (~20 tests) | 300–400 |
P1.2 Modified files
| Path | Change | Expected delta |
|---|---|---|
src/domains/skills/repository.ts |
Import buildCapabilityIndex; add module-level _capabilityIndex; populate at end of loadSkillsFromDisk; export getCapabilityIndex + test-only reset |
+25–35 lines |
P1.3 Unmodified (deliberately)
src/server.ts— no new tool registration.src/startup.ts— no Phase 2 change;loadSkillsFromDisksignature unchanged.src/db/schema.sql+src/db/migrations/— no storage change.src/domains/skills/schema.ts— no schema change.src/tools/— no new MCP tool file.- Any other module outside
src/domains/skills/andsrc/__tests__/domains/skills/.
P1.4 No deletions, no moves
P2. src/domains/skills/capability-index.ts — skeleton
Module header doc block + exports:
/**
* Colibri — Phase 0 ε Skill Capability Index (P0.6.3).
*
* Pure in-process reverse lookup: capability string → Set of skill names.
* Built once, synchronously, from the skill set that loadSkillsFromDisk
* writes into the cache. No new MCP tool; this is an internal helper that
* Phase 1 skill_get (and any later δ/ε consumer) will query.
*
* Zero side effects at import. No DB access, no I/O, no logging.
*
* Canonical references:
* - docs/guides/implementation/task-breakdown.md §P0.6.3
* - docs/spec/s17-mcp-surface.md §1 (ε surface = skill_list only)
* - docs/audits/p0-6-3-capability-index-audit.md
* - docs/contracts/p0-6-3-capability-index-contract.md
*
* Design invariants (contract §C2):
* - INV1: multi-capability skills appear in every relevant bucket
* - INV2: Set semantics (no duplicate names within a bucket)
* - INV3: findSkillsByCapability returns sorted string[]
* - INV4: unknown capability → []
* - INV5: pure functions, no side effects
*/
export interface IndexableSkill {
readonly name: string;
readonly capabilities: readonly string[];
}
export function buildCapabilityIndex(
skills: readonly IndexableSkill[],
): Map<string, Set<string>> {
const index = new Map<string, Set<string>>();
for (const skill of skills) {
for (const capability of skill.capabilities) {
let bucket = index.get(capability);
if (bucket === undefined) {
bucket = new Set<string>();
index.set(capability, bucket);
}
bucket.add(skill.name);
}
}
return index;
}
export function findSkillsByCapability(
index: Map<string, Set<string>>,
capability: string,
): string[] {
const bucket = index.get(capability);
if (bucket === undefined) {
return [];
}
return Array.from(bucket).sort();
}
That’s the entire module file contents (minus header). ~40 LOC of code + doc.
P3. src/domains/skills/repository.ts — surgical diff
Three additions to the existing file. All inserted in-place — no block rearrangement.
P3.1 Imports (top of file, after existing imports)
import { buildCapabilityIndex, type IndexableSkill } from './capability-index.js';
(IndexableSkill import is for the return-type documentation only; SkillRow is already assignable to it structurally. We keep the import to pin the dependency.)
P3.2 Module-level state (between existing §2 public types and §3 internal types comment blocks)
/* -------------------------------------------------------------------------- */
/* Module-level capability index (P0.6.3) */
/* -------------------------------------------------------------------------- */
/**
* In-process reverse lookup populated by loadSkillsFromDisk. Starts empty at
* module import; replaced (not mutated) on every successful load. See
* docs/contracts/p0-6-3-capability-index-contract.md §C1.3 + INV6.
*/
let _capabilityIndex: Map<string, Set<string>> = new Map();
P3.3 Loader tail (inside loadSkillsFromDisk, AFTER the summary logger(...) call)
// P0.6.3: Populate the in-process capability index from the post-tx row
// set. Reading from `listSkills(db)` (rather than the in-memory `upserts`
// array) guarantees the index reflects the pruned state. INV8: called
// exactly once per loadSkillsFromDisk call.
const postLoadRows: readonly IndexableSkill[] = listSkills(db);
_capabilityIndex = buildCapabilityIndex(postLoadRows);
P3.4 New exports (appended to the file, after registerSkillTools)
/* -------------------------------------------------------------------------- */
/* getCapabilityIndex — live-state getter (P0.6.3) */
/* -------------------------------------------------------------------------- */
/**
* Return the current in-process capability index. Empty Map before the first
* loadSkillsFromDisk call. After each load, the returned reference is the
* freshly-built Map — prior callers holding the old reference still see the
* old Map (INV6: atomic replace, no in-place mutation).
*/
export function getCapabilityIndex(): Map<string, Set<string>> {
return _capabilityIndex;
}
/**
* Test-only: reset the module-level index to an empty Map. Not exported for
* production callers; imported directly by the capability-index test suite
* via the same module path. Kept internal by naming convention
* (double-underscore prefix) — not enforced by TypeScript.
*/
export function __resetCapabilityIndexForTests(): void {
_capabilityIndex = new Map();
}
Total repository.ts delta: +25–30 lines, one new imported symbol, no signature changes to existing exports.
P4. Test suite plan — src/__tests__/domains/skills/capability-index.test.ts
P4.1 Imports
import { readFileSync, mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import Database from 'better-sqlite3';
import {
buildCapabilityIndex,
findSkillsByCapability,
type IndexableSkill,
} from '../../../domains/skills/capability-index.js';
import {
loadSkillsFromDisk,
listSkills,
getCapabilityIndex,
__resetCapabilityIndexForTests,
} from '../../../domains/skills/repository.js';
P4.2 Fixture helpers
Reuse the makeSkillMd + makeTempSkillsRoot pattern from repository.test.ts. For pure-function tests (buildCapabilityIndex, findSkillsByCapability), use plain TS object literals:
function mk(name: string, caps: string[]): IndexableSkill {
return { name, capabilities: caps };
}
P4.3 describe blocks (match contract §C6.2)
describe('buildCapabilityIndex — empty', () => {
it('empty input returns empty Map', () => {
expect(buildCapabilityIndex([]).size).toBe(0);
});
});
describe('buildCapabilityIndex — single-capability', () => {
it('one skill, one capability → one bucket with one name', () => {
const idx = buildCapabilityIndex([mk('alpha', ['read'])]);
expect(idx.get('read')).toEqual(new Set(['alpha']));
expect(idx.size).toBe(1);
});
it('two skills sharing one capability → one bucket with two names', () => {
const idx = buildCapabilityIndex([
mk('alpha', ['read']),
mk('beta', ['read']),
]);
expect(idx.get('read')).toEqual(new Set(['alpha', 'beta']));
});
});
describe('buildCapabilityIndex — multi-capability (INV1)', () => {
it('skill with ["read","write"] appears in both buckets', () => {
const idx = buildCapabilityIndex([mk('alpha', ['read', 'write'])]);
expect(idx.get('read')).toEqual(new Set(['alpha']));
expect(idx.get('write')).toEqual(new Set(['alpha']));
});
it('mixed fixture of 3 skills with overlapping capabilities', () => {
const idx = buildCapabilityIndex([
mk('alpha', ['read', 'write']),
mk('beta', ['read']),
mk('gamma', ['write', 'admin']),
]);
expect(idx.get('read')).toEqual(new Set(['alpha', 'beta']));
expect(idx.get('write')).toEqual(new Set(['alpha', 'gamma']));
expect(idx.get('admin')).toEqual(new Set(['gamma']));
});
});
describe('buildCapabilityIndex — duplicate capabilities in one skill (INV2)', () => {
it('duplicate capability strings collapse via Set semantics', () => {
// Hypothetical: if a skill's frontmatter ever carries ["read", "read"],
// Set semantics prevent double-insertion. (The Zod schema rejects this
// at parse time, but defense in depth is free.)
const idx = buildCapabilityIndex([mk('alpha', ['read', 'read'])]);
expect(idx.get('read')).toEqual(new Set(['alpha']));
expect(idx.get('read')!.size).toBe(1);
});
});
describe('buildCapabilityIndex — skill with empty capabilities', () => {
it('contributes no buckets', () => {
const idx = buildCapabilityIndex([mk('alpha', [])]);
expect(idx.size).toBe(0);
});
});
describe('findSkillsByCapability — unknown (INV4)', () => {
it('returns [] for a capability not in any skill', () => {
const idx = buildCapabilityIndex([mk('alpha', ['read'])]);
expect(findSkillsByCapability(idx, 'nonexistent')).toEqual([]);
});
});
describe('findSkillsByCapability — sorted (INV3)', () => {
it('returns names in ASCII-ascending order', () => {
const idx = buildCapabilityIndex([
mk('zeta', ['read']),
mk('alpha', ['read']),
mk('mu', ['read']),
]);
expect(findSkillsByCapability(idx, 'read')).toEqual(['alpha', 'mu', 'zeta']);
});
it('single-element result is still sorted (trivially)', () => {
const idx = buildCapabilityIndex([mk('alpha', ['read'])]);
expect(findSkillsByCapability(idx, 'read')).toEqual(['alpha']);
});
});
describe('findSkillsByCapability — known capability', () => {
it('returns sorted names for an existing bucket', () => {
const idx = buildCapabilityIndex([
mk('alpha', ['read', 'write']),
mk('beta', ['read']),
]);
expect(findSkillsByCapability(idx, 'read')).toEqual(['alpha', 'beta']);
expect(findSkillsByCapability(idx, 'write')).toEqual(['alpha']);
});
});
describe('findSkillsByCapability — empty string key', () => {
it('returns [] for capability ""', () => {
const idx = buildCapabilityIndex([mk('alpha', ['read'])]);
expect(findSkillsByCapability(idx, '')).toEqual([]);
});
});
describe('findSkillsByCapability — fresh array', () => {
it('returns a fresh array each call; mutation is safe', () => {
const idx = buildCapabilityIndex([mk('alpha', ['read'])]);
const a = findSkillsByCapability(idx, 'read');
a.push('rogue');
expect(findSkillsByCapability(idx, 'read')).toEqual(['alpha']);
});
});
describe('getCapabilityIndex — before any load (INV6)', () => {
it('returns an empty Map', () => {
__resetCapabilityIndexForTests();
const idx = getCapabilityIndex();
expect(idx).toBeInstanceOf(Map);
expect(idx.size).toBe(0);
});
});
describe('getCapabilityIndex — after loadSkillsFromDisk', () => {
let db: Database.Database;
beforeEach(() => {
__resetCapabilityIndexForTests();
db = makeTestDb();
});
afterEach(() => { db.close(); });
it('index reflects loaded skills', () => {
const root = makeTempSkillsRoot([
{ dir: 's1', content: makeSkillMd({ name: 's1', description: 'one', capabilities: ['read', 'write'] }) },
{ dir: 's2', content: makeSkillMd({ name: 's2', description: 'two', capabilities: ['write'] }) },
]);
loadSkillsFromDisk(db, root, noop);
const idx = getCapabilityIndex();
expect(idx.get('read')).toEqual(new Set(['s1']));
expect(idx.get('write')).toEqual(new Set(['s1', 's2']));
});
it('load with zero skills leaves the index empty', () => {
const root = mkdtempSync(join(tmpdir(), 'empty-skills-'));
loadSkillsFromDisk(db, root, noop);
expect(getCapabilityIndex().size).toBe(0);
});
});
describe('getCapabilityIndex — rebuild after second load', () => {
let db: Database.Database;
beforeEach(() => { __resetCapabilityIndexForTests(); db = makeTestDb(); });
afterEach(() => { db.close(); });
it('second load replaces the index to reflect post-prune state', () => {
const root1 = makeTempSkillsRoot([
{ dir: 's1', content: makeSkillMd({ name: 's1', description: 'one', capabilities: ['read'] }) },
]);
loadSkillsFromDisk(db, root1, noop);
expect(getCapabilityIndex().get('read')).toEqual(new Set(['s1']));
// Second load with a different corpus — s1 is pruned, s2 appears.
const root2 = makeTempSkillsRoot([
{ dir: 's2', content: makeSkillMd({ name: 's2', description: 'two', capabilities: ['write'] }) },
]);
loadSkillsFromDisk(db, root2, noop);
const idx = getCapabilityIndex();
expect(idx.get('read')).toBeUndefined(); // pruned
expect(idx.get('write')).toEqual(new Set(['s2']));
});
});
describe('parity — listSkills({capability:C}) ≡ findSkillsByCapability(index, C) (INV7)', () => {
let db: Database.Database;
beforeEach(() => { __resetCapabilityIndexForTests(); db = makeTestDb(); });
afterEach(() => { db.close(); });
it('every capability in the fixture has identical name sets on both sides', () => {
const root = makeTempSkillsRoot([
{ dir: 's1', content: makeSkillMd({ name: 's1', description: 'one', capabilities: ['read', 'write'] }) },
{ dir: 's2', content: makeSkillMd({ name: 's2', description: 'two', capabilities: ['read'] }) },
{ dir: 's3', content: makeSkillMd({ name: 's3', description: 'three', capabilities: ['write', 'admin'] }) },
{ dir: 's4', content: makeSkillMd({ name: 's4', description: 'four', capabilities: ['spawn'] }) },
]);
loadSkillsFromDisk(db, root, noop);
const idx = getCapabilityIndex();
for (const cap of ['read', 'write', 'admin', 'spawn', 'audit', 'unknown-cap']) {
const viaIndex = findSkillsByCapability(idx, cap);
const viaSql = listSkills(db, { capability: cap }).map((r) => r.name).sort();
expect(viaIndex).toEqual(viaSql);
}
});
});
P4.4 Shared helpers
Lifted from repository.test.ts (identical signatures, local copies — acceptable duplication since the two suites are independent and kept hermetic):
const MIGRATION_SQL = (() => {
const here = dirname(fileURLToPath(import.meta.url));
const migrationPath = join(here, '..', '..', '..', 'db', 'migrations', '004_skills.sql');
return readFileSync(migrationPath, 'utf-8');
})();
function makeTestDb(): Database.Database {
const db = new Database(':memory:');
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.exec(MIGRATION_SQL);
return db;
}
function makeSkillMd(opts: { name: string; description: string; capabilities?: string[]; }): string {
const fm = [`name: ${opts.name}`, `description: "${opts.description}"`];
if (opts.capabilities !== undefined && opts.capabilities.length > 0) {
fm.push(`capabilities:\n${opts.capabilities.map((c) => ` - ${c}`).join('\n')}`);
}
return `---\n${fm.join('\n')}\n---\nbody`;
}
function makeTempSkillsRoot(skills: Array<{ dir: string; content: string }>): string {
const root = mkdtempSync(join(tmpdir(), 'colibri-capindex-test-'));
for (const s of skills) {
const skillDir = join(root, s.dir);
mkdirSync(skillDir, { recursive: true });
writeFileSync(join(skillDir, 'SKILL.md'), s.content, 'utf-8');
}
return root;
}
const noop = (): void => { /* no-op logger for tests */ };
Note: The migration-sanity test from repository.test.ts is NOT duplicated here — P0.6.2 already owns that. capability-index.test.ts focuses exclusively on the new module + integration.
P5. Step execution sequence
Run in the worktree E:/AMS/.worktrees/claude/p0-6-3-capability-index:
- Create
src/domains/skills/capability-index.ts(per §P2). - Edit
src/domains/skills/repository.ts(per §P3 — three insertions). - Create
src/__tests__/domains/skills/capability-index.test.ts(per §P4). - Run the gate:
npm run build && npm run lint && npm testAll three must pass. If lint flags anything, fix in-place and re-run.
- Commit as
feat(p0-6-3): ε skill capability index(CLAUDE.md §6 template — the trailing noun phrase uses the file-name slug, not the full title). - Write
docs/verification/p0-6-3-capability-index-verification.md, commit asverify(p0-6-3-capability-index): test evidence. - Push branch with
git push -u origin feature/p0-6-3-capability-index(afterunset GITHUB_TOKEN). - Open PR via
gh pr createwith the title + body from the dispatch prompt.
P6. Gate matrix
| Gate | Command | Pass criterion |
|---|---|---|
| TypeScript compile | npm run build |
Exit 0, zero diagnostics |
| ESLint | npm run lint |
Exit 0, zero warnings |
| Jest | npm test |
Exit 0; P0.6.3 suite: ~20 new passing tests; zero new failures; total suite count grows by exactly 1 file |
The lint gate is non-negotiable (Wave G lesson — two PRs that session shipped with eslint errors because sub-agents ran only build+test). This prompt explicitly cites the full build && lint && test sequence.
P7. Risk / forbidden check (pre-commit)
Before every commit that includes source files:
grep -rn "registerColibriTool\|skill_get\|skill_reload\|agent_spawn\|agent_status" src/domains/skills/capability-index.ts→ zero matches (INV9 / §C7)ls src/domains/agents/→ directory does not exist (CLAUDE.md §9.1)grep -n "fs.watch\|chokidar\|setInterval\|setTimeout" src/domains/skills/capability-index.ts src/__tests__/domains/skills/capability-index.test.ts→ zero matches (no hot-reload path)git diff --name-only main | grep -v '^\(src/domains/skills/\|src/__tests__/domains/skills/\|docs/\(audits\|contracts\|packets\|verification\)/p0-6-3-\)'→ empty output (scope fence)
P8. Writeback template (for main session)
- Task ID: P0.6.3
- Branch: feature/p0-6-3-capability-index
- Worktree: .worktrees/claude/p0-6-3-capability-index
- Commit SHA: <filled in at push>
- Tests run:
npm run build → PASS
npm run lint → PASS (zero warnings)
npm test → PASS (X suites / Y tests including ~20 new)
- Summary: Implemented the ε Skill Capability Index as a pure in-process
reverse lookup Map<capability, Set<skillName>> on top of the P0.6.2
registry cache. Built once at the end of loadSkillsFromDisk, exposed via
getCapabilityIndex() module-level getter. Parity with listSkills({capability})
verified. No new MCP tools, no src/domains/agents/, no file watcher. Closes
ε axis (3/3 P0.6.* tasks); last non-deferred Phase 0 code task.
- Blockers: none.
- PR URL: <filled in after gh pr create>