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; loadSkillsFromDisk signature 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/ and src/__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:

  1. Create src/domains/skills/capability-index.ts (per §P2).
  2. Edit src/domains/skills/repository.ts (per §P3 — three insertions).
  3. Create src/__tests__/domains/skills/capability-index.test.ts (per §P4).
  4. Run the gate:
    npm run build && npm run lint && npm test
    

    All three must pass. If lint flags anything, fix in-place and re-run.

  5. 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).
  6. Write docs/verification/p0-6-3-capability-index-verification.md, commit as verify(p0-6-3-capability-index): test evidence.
  7. Push branch with git push -u origin feature/p0-6-3-capability-index (after unset GITHUB_TOKEN).
  8. Open PR via gh pr create with 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>

Back to top

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

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