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
undefinedonConfig. - No default value at the schema level (we want
undefinedso the resolution fallback is explicit). min(1)— empty string is rejected at module-load time with the standardInvalid Colibri environmenterror.- 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_ROOTviaassertNoDonorNamespace.
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
loadSkillsFromDiskunchanged. - Relative paths are passed unchanged; node’s
fsresolves them againstprocess.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 valuefssees. 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
skillstable does not exist (e.g. Phase 1 pre-migration) —db.preparethrowsSqliteError: no such table: skillsand 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:
skillsResult.loaded === 0skillsResult.total_on_disk === 0ctx.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_ROOTis undefined when the env key is not set.COLIBRI_SKILLS_ROOTis the parsed string when set to an absolute path.COLIBRI_SKILLS_ROOTis 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.isFrozenon 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.skillsRootwins when bothoptions.skillsRootandconfig.COLIBRI_SKILLS_ROOTare set (existing-seam regression).- When
options.skillsRootis 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.skillsRootis undefined AND the env var is set, the loader receives the env-var value — verified via aloadSkillsFncapture and a hermetic subprocess (mirrors theeager module importpattern 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=0ANDloaded=0ANDmode=FULL⇒ WARN line emitted. total_on_disk=0ANDloaded=0ANDmode=READONLY⇒ WARN line NOT emitted.total_on_disk=5ANDloaded=0ANDmode=FULL⇒ WARN NOT emitted (parser-error scenario, not misconfiguration).total_on_disk=0ANDloaded=3ANDmode=FULLis 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, mirrorscountTables) — undefined db → 0, populated DB → matching count, missing table → 0, closed handle → 0, defensivecnon-number → 0.buildHealthPayload— payload includesskill_count. Update the existing 6-field key-set assertion at L299-306 to includeskill_count.- E2E (
InMemoryTransport) —skill_countis 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 defaultmode: 'FULL'. Two paths:- The new
startup — empty-skills WARNdescribe 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.
- The new
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_counttotask_*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.