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/→ onlyschema.ts,repository.ts(nocapability-index.ts)ls src/__tests__/domains/skills/→ onlyrepository.test.ts(nocapability-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 forbidsskill_get,skill_reload, and anyagent_*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
skillstable already has the authoritativecapabilitiesJSON column (P0.6.2). - No
src/startup.tschange. The existingloadSkillsFromDiskcall-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:
- Task spec P0.6.3 AC#2 says “Capability strings are treated as opaque tags (Phase 0 does not enforce an enum)”.
SkillRow.capabilitiesis alreadyreadonly string[]at the row level.- Keeping the key type as
stringinsulates 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.sql— unchanged. The capability index is in-process only; no storage representation.src/db/migrations/— no new migration. Highest existing migration remains006_merkle_batches.sql. Next free slot stays 007 (defensive pre-assignment from Wave F is irrelevant here).src/server.ts— unchanged.registerSkillToolssignature and call site unaffected.src/startup.ts— unchanged. Phase 2 already invokesloadSkillsFromDisk; 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
- Step 2 (
docs/contracts/p0-6-3-capability-index-contract.md): freeze the public API, invariants, and parity contract. - Step 3 (
docs/packets/p0-6-3-capability-index-packet.md): executable diff plan (file-by-file) gating Step 4. - Step 4: implement
capability-index.ts, wire intorepository.ts, add test suite. - Step 5 (
docs/verification/p0-6-3-capability-index-verification.md): record test evidence; close task. - Push branch, open PR, write back (task_update + thought_record).