Contract — Skills root resolution + diagnostic surface

C1. COLIBRI_SKILLS_ROOT env var

Schema (src/config.ts). Adds an eighth scalar to the Phase 0 floor, positioned alphabetically between COLIBRI_MCP_TIMEOUT and the existing COLIBRI_STARTUP_TIMEOUT_MS (logical grouping is “all paths together”; we place this near COLIBRI_DB_PATH).

COLIBRI_SKILLS_ROOT: z.string().min(1).optional()
  • Optional — when unset, the property is undefined on Config.
  • No default value at the schema level (we want undefined so the resolution fallback is explicit).
  • min(1) — empty string is rejected at module-load time with the standard Invalid Colibri environment error.
  • TSDoc explains the override semantics (set → use as-is; unset → fall back to process.cwd() + .agents/skills).
  • AMS_* donor-namespace guard already rejects AMS_SKILLS_ROOT via assertNoDonorNamespace.

Type impact. Config['COLIBRI_SKILLS_ROOT'] is string | undefined.

C2. Resolution chain in startup()

Site. src/startup.ts:283-284 (the single resolution line).

New chain (in priority order):

const skillsRootPath =
  options.skillsRoot ??
  config.COLIBRI_SKILLS_ROOT ??
  pathJoin(process.cwd(), '.agents', 'skills');
  • options.skillsRoot (the existing test seam) — wins if defined.
  • config.COLIBRI_SKILLS_ROOT — operator override.
  • pathJoin(process.cwd(), '.agents', 'skills') — production default, identical to today.

Path semantics.

  • Absolute paths are passed to loadSkillsFromDisk unchanged.
  • Relative paths are passed unchanged; node’s fs resolves them against process.cwd() at the call site, which matches the existing default behaviour (pathJoin(process.cwd(), '.agents', 'skills') is itself cwd-relative).
  • We do NOT call path.resolve — the value the operator sets is the value fs sees. This keeps debugging predictable.

C3. skill_count field on server_health

Schema (src/tools/health.ts:72-79). Adds a seventh field:

skill_count: z.number().int().nonnegative()

The shape follows db_tables (same Zod facets, same defensive contract).

Helper (new).

const COUNT_SKILLS_SQL = 'SELECT COUNT(*) AS c FROM skills';

export function countSkills(db: ColibriServerContext['db']): number {
  if (db === undefined) return 0;
  try {
    const row = db.prepare(COUNT_SKILLS_SQL).get() as { c?: number } | undefined;
    return typeof row?.c === 'number' ? row.c : 0;
  } catch {
    return 0;
  }
}
  • Same defensive surface as countTables: returns 0 on undefined / closed / query-throws / unexpected row shape.
  • Importantly, returns 0 if the skills table does not exist (e.g. Phase 1 pre-migration) — db.prepare throws SqliteError: no such table: skills and the catch handles it.

Payload assembly (src/tools/health.ts:130-139).

return {
  status: 'ok',
  version: ctx.version,
  uptime_ms: Math.floor(ctx.nowMs() - ctx.bootStartMs),
  db_tables: countTables(ctx.db),
  skill_count: countSkills(ctx.db),
  phase: ctx.phase ?? 'phase1',
  mode: ctx.mode,
};

SLA. Two COUNT queries instead of one. On a populated :memory: DB the delta is sub-millisecond. The 100ms SLA still holds with >100× margin.

C4. WARN diagnostic at boot

Site. src/startup.ts, immediately after the existing [Startup] Skills: loaded=N, skipped=N, pruned=N INFO line.

Trigger. All three conditions must hold:

  1. skillsResult.loaded === 0
  2. skillsResult.total_on_disk === 0
  3. ctx.mode === 'FULL'

Output. A single line via the injected logger (NOT stdout):

[Startup] WARNING: 0 skills loaded — check COLIBRI_SKILLS_ROOT or process.cwd()

Why all three? A MINIMAL / READONLY / TEST mode deploy may legitimately have no skills, and a populated DB with loaded=0, total_on_disk=N is a parser-error scenario already logged by the loader. We only fire when the disk inventory itself is empty — that’s the misconfiguration shape.

Why FULL only? Modes other than FULL are explicitly scoped-down deploys where empty skills may be intentional. The diagnostic is for the production operator path.

C5. Test contract

src/__tests__/config.test.ts

Add cases (in the loadConfig describe block):

  • COLIBRI_SKILLS_ROOT is undefined when the env key is not set.
  • COLIBRI_SKILLS_ROOT is the parsed string when set to an absolute path.
  • COLIBRI_SKILLS_ROOT is the parsed string when set to a relative path.
  • COLIBRI_SKILLS_ROOT='' (empty string) is rejected with the standard validation error.
  • The frozen-config assertion already covers Object.isFrozen on the new field — no extra test needed; existing test asserts the whole object.

src/__tests__/startup.test.ts

Add a new describe block startup — skills root resolution with three cases:

  • options.skillsRoot wins when both options.skillsRoot and config.COLIBRI_SKILLS_ROOT are set (existing-seam regression).
  • When options.skillsRoot is undefined, the loader is called with the cwd default. (Existing tests already cover this implicitly; add an explicit assertion that captures the path arg.)
  • When options.skillsRoot is undefined AND the env var is set, the loader receives the env-var value — verified via a loadSkillsFn capture and a hermetic subprocess (mirrors the eager module import pattern in config.test.ts) so the env is actually read at module-load time.

Add a separate describe block startup — empty-skills WARN:

  • Default skillsResult with total_on_disk=0 AND loaded=0 AND mode=FULL ⇒ WARN line emitted.
  • total_on_disk=0 AND loaded=0 AND mode=READONLY ⇒ WARN line NOT emitted.
  • total_on_disk=5 AND loaded=0 AND mode=FULL ⇒ WARN NOT emitted (parser-error scenario, not misconfiguration).
  • total_on_disk=0 AND loaded=3 AND mode=FULL is logically impossible (you can’t load more than exist on disk), but the test asserts WARN NOT emitted to lock the AND semantics.

src/__tests__/tools/health.test.ts

Add cases:

  • countSkills (new describe block, mirrors countTables) — undefined db → 0, populated DB → matching count, missing table → 0, closed handle → 0, defensive c non-number → 0.
  • buildHealthPayload — payload includes skill_count. Update the existing 6-field key-set assertion at L299-306 to include skill_count.
  • E2E (InMemoryTransport) — skill_count is in the response payload and reflects the table count.

C6. Backwards compatibility

  • All existing tests continue to pass without modification EXCEPT the 6-field key-set assertion at src/__tests__/tools/health.test.ts:299-306, which becomes a 7-field key-set.
  • The existing loadSkillsFn: () => ({ loaded: 0, skipped: 0, pruned: 0, total_on_disk: 0 }) stub used in startup tests will trip the new WARN under default mode: 'FULL'. Two paths:
    • The new startup — empty-skills WARN describe expects this and is the canonical place to verify the WARN.
    • Existing tests that only care about Phase 1/2 transitions pass a silent logger or are mode-agnostic; the WARN line appearing in the log stream is benign noise that does not break assertions.
  • default* test fixtures are not changed.

C7. Out-of-scope (do NOT do)

  • Do NOT change the 14-tool surface count (no new skill_* MCP tool).
  • Do NOT add a skill_count to task_* or any other tool.
  • Do NOT change loadSkillsFromDisk’s return shape.
  • Do NOT add a new SQL migration.
  • Do NOT change the WARN line text — operators may grep for it.

Back to top

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

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