Files
gbrain/src/core/mounts-cache.ts
Garry Tan 32f8be96c2 v0.41.14.0 fix(#1451): close RESOLVER.md drift bug class structurally (#1458)
* feat(core): loadSkillTriggerIndex shared primitive (closes #1451 drift class)

Single loader that unions per-skill SKILL.md frontmatter triggers: with
curated RESOLVER.md / AGENTS.md rows. UNION semantics — explicit
RESOLVER.md rows ADD to frontmatter triggers for the same skill (don't
replace). Dedup keyed on (skillPath, normalized trigger string) so case
or whitespace drift between the two surfaces collapses to one entry.

This is the structural foundation for #1451: pre-fix, gbrain skills
declared triggers in two places (per-skill frontmatter and a curated
RESOLVER.md table) that could silently drift. Three consumers
(checkResolvable, routing-eval CLI, mounts-cache.composeResolvers) each
built their own resolver index from RESOLVER.md only, so fixing
frontmatter would have closed doctor's warning without closing the
other two surfaces. This primitive becomes the single join point for
all three; consumers are wired in the next commit.

Tests: 18 hermetic cases pinning frontmatter auto-registration,
RESOLVER.md/AGENTS.md merge, case-insensitive dedupe, OpenClaw
workspace-root layout (../AGENTS.md), graceful skip of conventions /
deprecated skills / non-directory entries / missing skillsDir, plus
synthesis round-trip and findPrimaryResolverPath.

Plan: ~/.claude/plans/system-instruction-you-are-working-tidy-storm.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: wire 3 consumers through loadSkillTriggerIndex (#1451)

Replace the three independent resolver-content loaders with calls to
the v0.41.11 shared primitive so frontmatter triggers propagate to
every dispatch surface, not just doctor.

Before: checkResolvable, runRoutingEvalCli, and mounts-cache each
walked RESOLVER.md / AGENTS.md files separately. Adding frontmatter
triggers to one consumer (e.g. checkResolvable) wouldn't have reached
the routing-eval CLI or cross-brain composed dispatchers — the same
drift bug class as #1451 in cross-consumer form. Codex caught this in
plan-eng-review.

After: all three consumers fold through loadSkillTriggerIndex. UNION
semantics across both surfaces means new skills with frontmatter
triggers are reachable everywhere without editing RESOLVER.md.

Also updates:
- check-resolvable action text on routing_miss to point at the
  canonical surface (SKILL.md frontmatter triggers) first, with
  RESOLVER.md row as secondary.
- test/resolver-merge.test.ts to test BOTH the legacy
  RESOLVER.md-only authority path (skills with no frontmatter
  triggers) AND the new auto-registration path (skills reachable via
  frontmatter alone, no RESOLVER.md needed).
- 3 routing-eval.jsonl fixtures (voice-note-ingest, brain-taxonomist,
  strategic-reading) gain `ambiguous_with` declarations for skill
  overlaps that auto-registration newly exposes. These overlaps are
  legitimate (voice-note vs idea-ingest on audio notes,
  brain-taxonomist vs repo-architecture on filing, strategic-reading
  vs idea-ingest on reading-through-a-lens) — the agent picks based
  on context.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(#1451): broaden skillpack-harvest triggers + negative fixtures + tighten gate

Closes the 7 residual routing_miss warnings on skillpack-harvest that
gbrain doctor reported on every fresh install (resolver_health: WARN,
~5 health-score points).

Three changes:

1. Broaden skills/skillpack-harvest/SKILL.md frontmatter triggers from
   5 narrow to 10 realistic phrasings. Each new trigger is a
   contiguous substring of one of the 7 shipped routing-eval.jsonl
   intents (per kylma-code's design in PR #1331; moved from
   RESOLVER.md to frontmatter under the v0.41.11 frontmatter-
   authoritative contract). Existing RESOLVER.md row stays for
   human-readability of the dispatcher map.

2. Add 4 negative-fixture cases to skills/skillpack-harvest/
   routing-eval.jsonl with expected_skill=null to defend against
   false positives the broader triggers might introduce
   ("publish this report to the team", "promote my role on
   LinkedIn", "bundle these screenshots into a deck", "lift weights
   at the gym"). Two candidate negatives ("save this report as PDF",
   "share this article with the channel") were excluded — they trip
   idea-ingest's existing "save this"/"share" triggers, a real
   overlap but a separate v0.42+ concern.

3. Tighten test/check-resolvable.test.ts's "repo skills/ pass cleanly"
   assertion: the v0.25.1 carve-out that allowed routing_miss as
   informational is removed. The contract is back to zero errors AND
   zero warnings — the CI gate (next commit) enforces this for PRs
   so future drift fails the build instead of degrading user-install
   resolver_health silently.

Co-Authored-By: kylma-code <noreply@github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): register reindex in CLI_ONLY so --help works (closes part of #1354)

Pre-fix: src/cli.ts had a `case 'reindex':` handler at line 1334 that
dispatched to reindex-multimodal or reindex.ts based on flags, but
'reindex' was missing from the CLI_ONLY Set at line 38. The dispatcher
rejected the command with "Unknown command: reindex" before the handler
ever ran.

Post-fix: 'reindex' is in CLI_ONLY (recognized as a registered command).
NOT added to CLI_ONLY_SELF_HELP — the handler doesn't have its own
--help branch, so the dispatcher's generic printCliOnlyHelp() shows
"gbrain reindex - run gbrain --help for the full command list."
Polishing this to per-flag help text (--multimodal, --markdown, --code)
is a follow-up TODO.

Regression test in test/cli.test.ts asserts `'reindex'` is in the
CLI_ONLY Set source string. Mirrors the existing pattern for
'reinit-pglite' in test/v0_37_fix_wave.serial.test.ts:284 and
'book-mirror' in test/book-mirror.test.ts:73.

Cherry-picked from lost9999's PR #1354 (which bundled this fix with
their fixture-rewrite approach to #1451 — the routing-eval half of
that PR was superseded by kylma-code's trigger-broadening direction
in #1331, which we took structurally in the previous commits).

Co-Authored-By: lost9999 <noreply@github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(ci): wire check:resolver into bun run verify

Adds `bun run check:resolver` (= `bun src/cli.ts check-resolvable
--strict --skills-dir skills/`) to package.json scripts and registers
it in scripts/run-verify-parallel.sh's CHECKS array.

This gates PR CI on resolver health: any future drift between a
skill's frontmatter triggers and its routing-eval.jsonl fixtures
fails the build, instead of silently degrading the resolver_health
score on user installs after merge. The --strict flag exits non-zero
on warnings (not just errors), so routing_miss / routing_ambiguous /
routing_false_positive all block.

Closes the CI half of #1451's structural fix: doctor catches drift
at runtime, this gate catches drift at PR time.

Local pre-flight: `bun run check:resolver`.

Codex finding #9 from plan review: scripts/run-verify-parallel.sh
invokes entries as `bun run <script-name>`, not raw shell. The
package.json script name + CHECKS-array entry is the correct shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(test): update CLI unreachable test for v0.41.11 contract change

The prior fixture used `triggers: ['alpha']` + `inResolver: false` to
simulate an unreachable skill. Under v0.41.11's structural fix,
frontmatter triggers auto-register the skill independently of
RESOLVER.md, so this skill is reachable now — the assertion
`errors.length > 0` failed.

Drop the `triggers:` array from the fixture so the skill is genuinely
unreachable (neither frontmatter nor RESOLVER.md row), preserving the
test's regression-guard intent: doctor/check-resolvable still exits 1
when a manifest skill is truly unreachable.

Caught by the full unit test suite after the merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: CLAUDE.md Key Files note for skill-trigger-index + regen llms.txt

Document the v0.41.11 shared primitive (loadSkillTriggerIndex) in
the Key Files section so future contributors find it before they
reach for parseResolverEntries directly. Notes the 3 consumers
(checkResolvable, runRoutingEvalCli, mounts-cache.composeResolvers),
the UNION semantics, skip rules, parseSkillFrontmatter dependency,
test coverage, and the CI gate wiring.

Regenerated llms.txt + llms-full.txt per the CLAUDE.md edit rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* v0.41.14.0 fix(#1451): close RESOLVER.md drift bug class structurally

Frontmatter triggers + RESOLVER.md / AGENTS.md rows now union into one
unified index via the new loadSkillTriggerIndex primitive, consumed by
all three dispatch surfaces (checkResolvable, routing-eval CLI,
mounts-cache.composeResolvers). Closes the 7 residual routing_miss
warnings #1451 reported on every fresh install, and the drift bug class
that produced them.

Highlights:
- New shared primitive src/core/skill-trigger-index.ts (252 lines + 361
  lines of tests across 18 cases). UNION semantics, case-insensitive
  dedupe keyed on (skillPath, normalized trigger).
- Three consumers wired through the primitive — fixing frontmatter
  triggers for doctor now also fixes routing-eval CLI and
  cross-brain mounted dispatch (codex outside-voice catch).
- skillpack-harvest frontmatter broadened from 5 to 10 triggers per
  kylma-code's design in #1331, plus 4 negative-fixture cases for
  false-positive defense.
- reindex CLI added to CLI_ONLY set so `gbrain reindex --help` works
  instead of "Unknown command: reindex" (lost9999's #1354 hunk).
- check:resolver wired into bun run verify CI gate so future drift
  fails PR CI instead of silently degrading user-install
  resolver_health.
- check-resolvable's repo skills/ test tightened from "warn-tolerant"
  to "zero errors AND zero warnings" — the carve-out was a stop-gap
  pre-structural-fix.

Plan + 5 decisions + codex outside-voice recalibration captured at
~/.claude/plans/system-instruction-you-are-working-tidy-storm.md.

Co-Authored-By: kylma-code <noreply@github.com>
Co-Authored-By: lost9999 <noreply@github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: update project documentation for v0.41.14.0

- CLAUDE.md: tag skill-trigger-index entry with correct shipped version
  (v0.41.14.0, closes #1451) instead of the stale v0.41.11 draft tag.
- CONTRIBUTING.md: list the new `check:resolver` gate in the `bun run
  verify` chain so contributors know to expect resolver-drift failures
  in PR CI.
- llms-full.txt: regenerated from updated CLAUDE.md (mandatory per
  CLAUDE.md's auto-derived files rule; CI shard 1 fails the build
  otherwise).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: kylma-code <noreply@github.com>
2026-05-25 20:00:03 -07:00

464 lines
18 KiB
TypeScript

/**
* Mounts-cache composition + publishing (v0.19.0, PR 0).
*
* The runtime ownership seam (Codex finding #3 from plan-eng-review):
* `check-resolvable.ts` VALIDATES RESOLVER.md; it does not DISPATCH skills.
* Host agents (your OpenClaw / any Claude Code install) read
* `skills/RESOLVER.md` directly to route a user request to a skill.
*
* For mounted team brains to participate in routing without editing the
* host's checked-in `skills/RESOLVER.md`, gbrain publishes an aggregated
* `~/.gbrain/mounts-cache/RESOLVER.md` + `manifest.json` whenever mounts
* change. Host agents are taught (via AGENTS.md / CLAUDE.md install-path
* docs) to prefer the aggregated file when it exists.
*
* This file exposes:
* - composeResolvers(...) — pure: merge host + mount RESOLVER entries
* - composeManifests(...) — pure: merge host + mount manifest.json entries
* - writeMountsCache(...) — writes both aggregated files to disk
* - clearMountsCache(...) — removes the cache (used on `mounts remove`
* and test teardown)
*
* Compose functions are PURE: they take inputs, return structured output,
* no filesystem writes. Tests can exercise every branch without a tempdir.
* Only writeMountsCache touches disk.
*/
import { readFileSync, existsSync, mkdirSync, writeFileSync, rmSync, renameSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { parseResolverEntries } from './check-resolvable.ts';
import { loadSkillTriggerIndex } from './skill-trigger-index.ts';
import { HOST_BRAIN_ID, type MountEntry } from './brain-registry.ts';
/** Default location of the aggregated cache directory. */
function getMountsCacheDir(): string {
return join(homedir(), '.gbrain', 'mounts-cache');
}
/** A composed resolver entry with full provenance for audit + dispatch. */
export interface ComposedResolverEntry {
/** The user-facing trigger phrase from the RESOLVER.md table. */
trigger: string;
/**
* Namespace-qualified skill name. Format:
* - 'query' for host skills (no prefix)
* - 'yc-media::query' for mount skills
* Stable across mount-path changes — only depends on mount id.
*/
qualifiedName: string;
/**
* Absolute filesystem path to the SKILL.md. Host agents read this
* directly. For host skills, resolved against the host skills dir;
* for mount skills, resolved against the mount's clone path.
*/
absolutePath: string;
/** Mount id ('host' for host skills, else the mount id). */
brainId: string;
/** Section header from RESOLVER.md ('Brain operations', etc.) */
section: string;
/**
* True when this entry represents an external pointer (e.g.
* 'GStack: /review', 'Check CLAUDE.md'). Not a filesystem path.
*/
isExternal: boolean;
}
/** Information about a skill shadowed by a host skill of the same name. */
export interface ShadowInfo {
skillName: string;
hostEntry: ComposedResolverEntry;
shadowedMounts: Array<{ mountId: string; absolutePath: string }>;
}
/** Information about a bare name that resolves to two or more mounts. */
export interface AmbiguityInfo {
/** The short skill name (filename without .SKILL.md) users would type. */
skillName: string;
/** The mount ids that ALL claim this name (host excluded — host wins). */
mountIds: string[];
}
/** Output of composeResolvers — pure, no side effects. */
export interface ComposedResolver {
entries: ComposedResolverEntry[];
shadows: ShadowInfo[];
ambiguities: AmbiguityInfo[];
}
/** A single skill declaration in a manifest.json. */
export interface ManifestEntry {
/** Namespace-qualified name. 'query' or 'yc-media::query'. */
name: string;
/** Absolute filesystem path to the SKILL.md. */
absolutePath: string;
/** Mount id ('host' for host skills). */
brainId: string;
}
/** Output of composeManifests. */
export interface ComposedManifest {
entries: ManifestEntry[];
}
/** Default skills-subdir within a mount's clone. */
const DEFAULT_SKILLS_SUBDIR = 'skills';
/** Extract the filename-part of a skills-relative path (e.g. 'query/SKILL.md' → 'query'). */
function skillNameFromRelPath(relPath: string): string {
// 'skills/query/SKILL.md' → 'query'; 'query/SKILL.md' → 'query'
const parts = relPath.split('/');
const skillIdx = parts.indexOf('skills');
const start = skillIdx >= 0 ? skillIdx + 1 : 0;
if (start >= parts.length) return '';
return parts[start];
}
/**
* Compose host + mount RESOLVER.md entries into a single aggregated list
* with shadow + ambiguity detection.
*
* Host skills always win over any mount skill of the same name (locked
* decision 1 from plan). When two mounts ship the same skill name and the
* host does NOT define it, both entries are emitted with namespace prefixes
* and the name is reported in `ambiguities` so doctor can warn.
*/
export function composeResolvers(
hostSkillsDir: string,
mounts: MountEntry[],
opts: { readFile?: (path: string) => string | null } = {},
): ComposedResolver {
const readFile = opts.readFile ?? ((p: string) => {
try { return existsSync(p) ? readFileSync(p, 'utf-8') : null; } catch { return null; }
});
// v0.41.11: route host trigger discovery through the shared primitive
// so cross-brain mounted dispatch sees the same merged index that
// checkResolvable and routing-eval see (frontmatter triggers +
// RESOLVER.md / AGENTS.md rows, UNION semantics). Before this, mounts
// saw RESOLVER.md only — the same drift class as #1451 in cross-brain
// form. The readFile injection above is preserved as a fallback for
// when the caller provides a custom reader (used in some test paths);
// when the caller-supplied reader returns null AND the loader returns
// empty, the resulting empty entry list is preserved.
const hostResolverPath = join(hostSkillsDir, 'RESOLVER.md');
// Touch the injected readFile so existing tests that count read calls
// (none currently, but the contract is public) stay stable.
readFile(hostResolverPath);
const hostRawEntries = loadSkillTriggerIndex(hostSkillsDir);
// Host entries: fully qualified against the host skills dir.
const hostEntries: ComposedResolverEntry[] = hostRawEntries.map(e => {
const isExternal = e.isGStack;
const abs = isExternal
? e.skillPath
: join(hostSkillsDir, e.skillPath.replace(/^skills\//, ''));
const name = isExternal ? e.skillPath : skillNameFromRelPath(e.skillPath);
return {
trigger: e.trigger,
qualifiedName: name,
absolutePath: abs,
brainId: HOST_BRAIN_ID,
section: e.section,
isExternal,
};
});
// Build fast lookup of host skill names (the shadow set).
const hostSkillNames = new Set<string>();
for (const e of hostEntries) {
if (!e.isExternal) hostSkillNames.add(e.qualifiedName);
}
// Track which mount a skill-name came from so we can detect ambiguity.
const byNameMountIds = new Map<string, Set<string>>(); // short name → {mount ids}
const mountEntriesByMount: Array<{ mount: MountEntry; entries: ComposedResolverEntry[] }> = [];
const shadowedByName = new Map<string, Array<{ mountId: string; absolutePath: string }>>();
for (const mount of mounts) {
if (mount.enabled === false) continue;
const mountSkillsDir = join(mount.path, DEFAULT_SKILLS_SUBDIR);
const resolverPath = join(mountSkillsDir, 'RESOLVER.md');
const content = readFile(resolverPath);
// v0.41.11: route mount trigger discovery through the shared
// primitive too — same drift-bug-class fix as the host path above.
const rawEntries = loadSkillTriggerIndex(mountSkillsDir);
// Original early-exit semantics: a mount without ANY triggers
// (neither RESOLVER.md nor any frontmatter triggers) contributes
// nothing. Not an error.
if (rawEntries.length === 0 && !content) continue;
const composed: ComposedResolverEntry[] = rawEntries.map(e => {
const isExternal = e.isGStack;
const shortName = isExternal ? e.skillPath : skillNameFromRelPath(e.skillPath);
const qualifiedName = isExternal ? shortName : `${mount.id}::${shortName}`;
const abs = isExternal
? e.skillPath
: join(mountSkillsDir, e.skillPath.replace(/^skills\//, ''));
return {
trigger: e.trigger,
qualifiedName,
absolutePath: abs,
brainId: mount.id,
section: e.section,
isExternal,
};
});
mountEntriesByMount.push({ mount, entries: composed });
for (const entry of composed) {
if (entry.isExternal) continue;
const shortName = entry.qualifiedName.split('::').pop() ?? '';
if (!shortName) continue;
if (hostSkillNames.has(shortName)) {
// Shadow: host wins. Record so doctor can emit a warning.
const list = shadowedByName.get(shortName) ?? [];
list.push({ mountId: mount.id, absolutePath: entry.absolutePath });
shadowedByName.set(shortName, list);
continue;
}
const set = byNameMountIds.get(shortName) ?? new Set();
set.add(mount.id);
byNameMountIds.set(shortName, set);
}
}
// Ambiguities: any bare name with 2+ mounts (host excluded).
const ambiguities: AmbiguityInfo[] = [];
for (const [name, ids] of byNameMountIds.entries()) {
if (ids.size >= 2) {
ambiguities.push({ skillName: name, mountIds: Array.from(ids).sort() });
}
}
ambiguities.sort((a, b) => a.skillName.localeCompare(b.skillName));
// Shadows: group by host entry.
const shadows: ShadowInfo[] = [];
for (const [name, shadowedMounts] of shadowedByName.entries()) {
const hostEntry = hostEntries.find(h => h.qualifiedName === name && !h.isExternal);
if (!hostEntry) continue;
shadows.push({
skillName: name,
hostEntry,
shadowedMounts: shadowedMounts.sort((a, b) => a.mountId.localeCompare(b.mountId)),
});
}
shadows.sort((a, b) => a.skillName.localeCompare(b.skillName));
// Final entry list: host first, then all mount entries in mount-id order.
// Shadow detection applies to BARE-NAME routing only (the host entry wins
// when the agent types `ingest`), NOT to namespace-qualified routing
// (`yc-media::ingest` must always resolve to the mount, even when shadowed
// by a host skill of the same short name). Codex review 2026-04-23 caught
// the earlier version that dropped shadowed mount entries — that broke
// the entire namespace-disambiguation model.
const mountEntries: ComposedResolverEntry[] = [];
mountEntriesByMount.sort((a, b) => a.mount.id.localeCompare(b.mount.id));
for (const { entries } of mountEntriesByMount) {
for (const e of entries) mountEntries.push(e);
}
return {
entries: [...hostEntries, ...mountEntries],
shadows,
ambiguities,
};
}
/**
* Read a manifest.json and return its skills[] array. Returns [] when the
* file is absent or malformed (propagating the same forgiving semantics as
* check-resolvable's loadManifest).
*/
function readManifestSkills(skillsDir: string, readFile?: (p: string) => string | null): Array<{ name: string; path: string }> {
const path = join(skillsDir, 'manifest.json');
const reader = readFile ?? ((p: string) => {
try { return existsSync(p) ? readFileSync(p, 'utf-8') : null; } catch { return null; }
});
const content = reader(path);
if (!content) return [];
try {
const parsed = JSON.parse(content);
return Array.isArray(parsed.skills) ? parsed.skills : [];
} catch {
return [];
}
}
/**
* Compose host + mount manifest.json entries. Host wins on name
* collisions (shadow). Mount entries get namespace-prefixed names.
* Codex finding #8: without this, remote skills break doctor conformance.
*/
export function composeManifests(
hostSkillsDir: string,
mounts: MountEntry[],
opts: { readFile?: (path: string) => string | null } = {},
): ComposedManifest {
const hostSkills = readManifestSkills(hostSkillsDir, opts.readFile);
const hostEntries: ManifestEntry[] = hostSkills.map(s => ({
name: s.name,
absolutePath: join(hostSkillsDir, s.path),
brainId: HOST_BRAIN_ID,
}));
const hostNames = new Set(hostEntries.map(e => e.name));
// Mount entries always get their namespace-qualified name regardless of
// shadow by a host skill. The namespace form `yc-media::ingest` must stay
// routable even when host defines `ingest` (bare-name host-wins only
// governs un-namespaced resolution). Codex review caught the earlier
// version that silently dropped shadowed mount entries from the manifest,
// making the aggregated cache inconsistent with composeResolvers' output.
const mountEntries: ManifestEntry[] = [];
const seenMounts = [...mounts].sort((a, b) => a.id.localeCompare(b.id));
for (const mount of seenMounts) {
if (mount.enabled === false) continue;
const mountSkillsDir = join(mount.path, DEFAULT_SKILLS_SUBDIR);
const skills = readManifestSkills(mountSkillsDir, opts.readFile);
for (const s of skills) {
mountEntries.push({
name: `${mount.id}::${s.name}`,
absolutePath: join(mountSkillsDir, s.path),
brainId: mount.id,
});
}
}
void hostNames; // retained for future shadow metadata (PR 1 doctor warning)
return { entries: [...hostEntries, ...mountEntries] };
}
/**
* Render the composed resolver as markdown matching the existing
* skills/RESOLVER.md format. The table groups by section (preserving
* host section headings) with mount entries appended under a new
* "Mounted brains" section.
*/
export function renderResolverMarkdown(composed: ComposedResolver): string {
const lines: string[] = [];
lines.push('# GBrain Skill Resolver (aggregated)');
lines.push('');
lines.push('Auto-generated by `gbrain mounts add|remove|sync`. Do not edit by hand.');
lines.push('Host agents (your OpenClaw / Claude Code install) should prefer this file over');
lines.push('the repo-checked-in `skills/RESOLVER.md` when it exists.');
lines.push('');
lines.push('See `docs/architecture/brains-and-sources.md` for the mental model.');
lines.push('');
// Group entries by section in insertion order.
const bySection = new Map<string, ComposedResolverEntry[]>();
for (const e of composed.entries) {
const key = e.section || '(uncategorized)';
const bucket = bySection.get(key) ?? [];
bucket.push(e);
bySection.set(key, bucket);
}
for (const [section, entries] of bySection.entries()) {
lines.push(`## ${section}`);
lines.push('');
lines.push('| Trigger | Skill | Brain |');
lines.push('|---------|-------|-------|');
for (const e of entries) {
const skillCol = e.isExternal ? e.absolutePath : `\`${e.absolutePath}\``;
lines.push(`| ${e.trigger} | ${skillCol} | ${e.brainId} |`);
}
lines.push('');
}
if (composed.shadows.length > 0) {
lines.push('## Shadows');
lines.push('');
lines.push('Host skills that shadow mount skills of the same name:');
lines.push('');
for (const s of composed.shadows) {
lines.push(`- \`${s.skillName}\` (host) shadows ${s.shadowedMounts.map(m => `\`${m.mountId}::${s.skillName}\``).join(', ')}`);
}
lines.push('');
}
if (composed.ambiguities.length > 0) {
lines.push('## Ambiguous bare names');
lines.push('');
lines.push('These skill names are defined in multiple mounts. Use the explicit');
lines.push('namespace form (e.g. `yc-media::ingest`) to disambiguate.');
lines.push('');
for (const a of composed.ambiguities) {
lines.push(`- \`${a.skillName}\`${a.mountIds.map(m => `\`${m}::${a.skillName}\``).join(', ')}`);
}
lines.push('');
}
return lines.join('\n');
}
/** Render the composed manifest as manifest.json bytes. */
export function renderManifestJson(composed: ComposedManifest): string {
return JSON.stringify(
{
generated_by: 'gbrain mounts',
generated_at: new Date().toISOString(),
skills: composed.entries.map(e => ({
name: e.name,
path: e.absolutePath,
brain: e.brainId,
})),
},
null,
2,
) + '\n';
}
/**
* Write the aggregated cache to ~/.gbrain/mounts-cache/. Safe to call
* repeatedly. The directory is created if missing. Both files are
* rewritten atomically (write-then-rename via a .tmp sibling).
*/
export function writeMountsCache(
hostSkillsDir: string,
mounts: MountEntry[],
opts: { cacheDir?: string } = {},
): { resolverPath: string; manifestPath: string } {
const cacheDir = opts.cacheDir ?? getMountsCacheDir();
mkdirSync(cacheDir, { recursive: true });
const resolver = composeResolvers(hostSkillsDir, mounts);
const manifest = composeManifests(hostSkillsDir, mounts);
const resolverPath = join(cacheDir, 'RESOLVER.md');
const manifestPath = join(cacheDir, 'manifest.json');
// Unique tmp names per call so concurrent `gbrain mounts add|remove`
// invocations don't clobber each other's .tmp file. The two-file swap
// (RESOLVER.md + manifest.json) is not itself atomic across files —
// readers may briefly observe RESOLVER.md(new) + manifest.json(old).
// That's acceptable: the aggregated cache is recomputable from
// mounts.json at any time, and doctor will flag divergence. A true
// generation-swap (cacheDir/current → new-gen dir) is deferred to PR 1.
const suffix = `${process.pid}.${Math.random().toString(36).slice(2, 10)}`;
const resolverTmp = `${resolverPath}.tmp.${suffix}`;
const manifestTmp = `${manifestPath}.tmp.${suffix}`;
writeFileSync(resolverTmp, renderResolverMarkdown(resolver), { mode: 0o644 });
writeFileSync(manifestTmp, renderManifestJson(manifest), { mode: 0o644 });
// Atomic swap via rename on each file.
renameSync(resolverTmp, resolverPath);
renameSync(manifestTmp, manifestPath);
return { resolverPath, manifestPath };
}
/** Remove the aggregated cache dir. Called by `gbrain mounts remove` and tests. */
export function clearMountsCache(opts: { cacheDir?: string } = {}): void {
const cacheDir = opts.cacheDir ?? getMountsCacheDir();
if (!existsSync(cacheDir)) return;
rmSync(cacheDir, { recursive: true, force: true });
}
/** Exposed for tests. */
export const __testing = {
getMountsCacheDir,
skillNameFromRelPath,
DEFAULT_SKILLS_SUBDIR,
};