mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 08:53:22 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a08493d8e3 | ||
|
|
26a7631a56 | ||
|
|
9a725b18ec | ||
|
|
17d23fe389 | ||
|
|
615d9d2de5 | ||
|
|
e83638df9a | ||
|
|
f3671da93c | ||
|
|
01ec1f9cfe | ||
|
|
fa0b5a9803 | ||
|
|
08cba39fa7 | ||
|
|
8bd52be20a | ||
|
|
2f2cbb474f |
+110
@@ -2,6 +2,116 @@
|
||||
|
||||
All notable changes to GBrain will be documented in this file.
|
||||
|
||||
## [0.41.14.0] - 2026-05-25
|
||||
|
||||
**Your gbrain skills can declare their own routing triggers in their
|
||||
frontmatter, and they just work. No more keeping the same trigger list
|
||||
in two places that quietly drift apart.**
|
||||
|
||||
If you ever ran `gbrain doctor` on a fresh install and saw it complain
|
||||
about seven "routing missed" warnings, that's because gbrain shipped
|
||||
the dispatcher map (`skills/RESOLVER.md`) and the per-skill trigger
|
||||
declarations (`triggers:` in each `SKILL.md` frontmatter) as two
|
||||
separate sources of truth. Whenever one drifted from the other, doctor
|
||||
docked your health score by ~5 points and the routing-eval CLI showed
|
||||
the same warnings independently. Fixing one surface didn't fix the
|
||||
other. This release closes that drift bug class for good.
|
||||
|
||||
What you can now do:
|
||||
|
||||
- Declare a skill's routing triggers in ONE place — the skill's own
|
||||
`SKILL.md` frontmatter. Every consumer (doctor, `routing-eval`,
|
||||
mounted cross-brain dispatch) sees the same triggers automatically.
|
||||
- Add a new skill without touching `RESOLVER.md`. The skill gets
|
||||
auto-registered from its frontmatter the moment the file lands.
|
||||
- Keep `RESOLVER.md` as the curated human-readable dispatcher map
|
||||
(it still works), but treat it as an *additive* surface — its rows
|
||||
union with the frontmatter, they don't replace it.
|
||||
- Run `bun src/cli.ts reindex --help` and get help text instead of
|
||||
"Unknown command: reindex" (small CLI registration fix bundled in).
|
||||
- Trust your PR CI to catch routing drift: the new
|
||||
`bun run check:resolver` gate is wired into `bun run verify`, so a
|
||||
new skill with mismatched fixtures fails the build instead of
|
||||
silently degrading user-install resolver_health after merge.
|
||||
|
||||
How to take advantage:
|
||||
|
||||
- Run `gbrain doctor` — resolver_health now reports `OK — 47 skills,
|
||||
all reachable` with zero warnings on bundled skills.
|
||||
- Adding a skill? Put `triggers:` in the SKILL.md frontmatter and skip
|
||||
the RESOLVER.md edit. It's reachable from doctor, routing-eval, and
|
||||
any mounted brain that loads it.
|
||||
|
||||
How to verify after upgrade:
|
||||
|
||||
```bash
|
||||
gbrain check-resolvable --json --skills-dir skills/ \
|
||||
| jq '.report | {ok, errors: (.errors|length), warnings: (.warnings|length)}'
|
||||
# Expect: {"ok": true, "errors": 0, "warnings": 0}
|
||||
|
||||
bun src/cli.ts reindex --help
|
||||
# Expect: usage line, not "Unknown command"
|
||||
```
|
||||
|
||||
Closes issue #1451. Cherry-picked the trigger-broadening direction
|
||||
from kylma-code's PR #1331 and the `reindex` CLI registration from
|
||||
lost9999's PR #1354 — both contributors got it right on their own
|
||||
surfaces; this release combines them with the structural fix that
|
||||
closes the underlying drift class.
|
||||
|
||||
### For contributors
|
||||
|
||||
New shared primitive at `src/core/skill-trigger-index.ts`:
|
||||
`loadSkillTriggerIndex(skillsDir)` returns the unified entry list
|
||||
folded from per-skill frontmatter + RESOLVER.md / AGENTS.md (including
|
||||
the OpenClaw `../AGENTS.md` parent-dir merge). UNION semantics,
|
||||
case-insensitive dedupe keyed on `(skillPath, normalized trigger)`.
|
||||
Three consumers fold through this primitive — `checkResolvable`,
|
||||
`runRoutingEvalCli`, `mounts-cache.composeResolvers`. Before this,
|
||||
each consumer built its own resolver index from RESOLVER.md only;
|
||||
fixing frontmatter for doctor wouldn't reach the routing-eval CLI
|
||||
or cross-brain dispatchers. The CI gate `bun run check:resolver`
|
||||
(strict mode, exit-1 on any warning) prevents the drift class from
|
||||
returning. 18 hermetic unit cases in `test/skill-trigger-index.test.ts`
|
||||
cover frontmatter scan, RESOLVER.md merge, parent-dir scan,
|
||||
deprecated-skill skip, missing-dir resilience, malformed-YAML
|
||||
warn-once, and synthesis round-trip. 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>
|
||||
|
||||
## To take advantage of v0.41.14.0
|
||||
|
||||
`gbrain upgrade` should do this automatically. If `gbrain doctor`
|
||||
still warns about resolver health on bundled skills:
|
||||
|
||||
1. **Verify the headline fix:**
|
||||
```bash
|
||||
gbrain check-resolvable --json --skills-dir skills/ \
|
||||
| jq '.report | {ok, errors: (.errors|length), warnings: (.warnings|length)}'
|
||||
```
|
||||
Expect `{"ok": true, "errors": 0, "warnings": 0}`.
|
||||
|
||||
2. **Verify the CLI fix:**
|
||||
```bash
|
||||
gbrain reindex --help
|
||||
```
|
||||
Expect a usage line, not "Unknown command: reindex".
|
||||
|
||||
3. **If you maintain a fork with custom skills**, your existing
|
||||
RESOLVER.md / AGENTS.md rows still work unchanged (union
|
||||
semantics). For new skills you add going forward, you can declare
|
||||
`triggers:` in the SKILL.md frontmatter and skip the RESOLVER.md
|
||||
edit — they'll be auto-registered everywhere.
|
||||
|
||||
4. **If any check fails or the numbers look wrong,** please file an
|
||||
issue: https://github.com/garrytan/gbrain/issues including:
|
||||
- output of `gbrain doctor`
|
||||
- output of `gbrain check-resolvable --json`
|
||||
- which step broke
|
||||
|
||||
## [0.41.13.0] - 2026-05-25
|
||||
|
||||
**`gbrain sync` and `gbrain dream` stop lying about what they did.**
|
||||
|
||||
@@ -135,6 +135,7 @@ strict behavior when unset.
|
||||
- `src/core/minions/queue.ts` extension (v0.31.12) — `MinionQueue.add()` now rejects `subagent` jobs whose `data.model` resolves through `isAnthropicProvider()` to a non-Anthropic provider. Lazy-imports `model-config.ts` to avoid pulling engine types into queue's eager-load surface. Layer 1 of the three-layer subagent provider enforcement (Codex F1+F2 in plan review). Layers 2 + 3 live in `src/core/model-config.ts` (`enforceSubagentAnthropic` runtime fallback) and `src/commands/doctor.ts` (`subagent_provider` check). Pinned by 3 cases in `test/agent-cli.test.ts`.
|
||||
- `src/commands/models.ts` (v0.31.12) — `gbrain models [--json]` read-only routing dashboard: prints tier defaults (`utility`/`reasoning`/`deep`/`subagent`), the resolved value for each (re-walking the resolution chain to attribute properly), every per-task override (11 `PER_TASK_KEYS` entries — `models.dream.synthesize`, `models.dream.patterns`, `models.drift`, `models.auto_think`, `models.think`, `models.subagent`, `facts.extraction_model`, `models.eval.longmemeval`, `models.expansion`, `models.chat`, `models.dream.synthesize_verdict`), the alias map (defaults + user overrides), and a source-of-truth column showing `default` / `config: <key>` / `env: <VAR>`. `gbrain models doctor [--skip=<provider>] [--json]` fires a 1-token `gateway.chat()` probe against each configured chat + expansion model and classifies failures into `{model_not_found, auth, rate_limit, network, unknown}` — the structural fix for the v0.31.6 silent-no-op bug class. Wired into `cli.ts` dispatch table + `CLI_ONLY` set. **v0.33.1.1 (#962, Codex P3 follow-up):** doctor gains a zero-token `embedding_config` probe that runs FIRST, before any chat/expansion probes spend money. `probeEmbeddingConfig()` reads `getEmbeddingModel()` + `getEmbeddingDimensions()` from the gateway, parses the model id, and (for Voyage flexible-dim models) checks `isValidVoyageOutputDim(dims)` against `VOYAGE_VALID_OUTPUT_DIMS`. New `ProbeStatus` variant `'config'` and optional `fix?: string` field on `ProbeResult` — surfaced in both human output (paste-ready `gbrain config set ...` line under the bad probe) and JSON output. New touchpoint label `'embedding_config'` joins `'chat'` and `'expansion'` in the probe-row taxonomy. Closes the Voyage flexible-dim bug class at config time, not first-embed.
|
||||
- `src/commands/doctor.ts` extension (v0.31.12) — new `subagent_provider` check (layer 3 of 3 — Codex F13). Warns when `models.tier.subagent` is explicitly set to a non-Anthropic provider (fail-loud since the user clearly meant it — message names the bad value and prints the paste-ready fix command `gbrain config set models.tier.subagent anthropic:claude-sonnet-4-6`); also warns when `models.default` would sneak `subagent` into a non-Anthropic provider via tier inheritance. OK status when subagent tier resolves to Anthropic. Tests cover all three paths in `test/doctor.test.ts`.
|
||||
- `src/core/skill-trigger-index.ts` (v0.41.14.0, closes #1451) — Shared loader that unions per-skill SKILL.md frontmatter `triggers:` with curated RESOLVER.md / AGENTS.md rows from `skillsDir` AND the parent directory (preserves the OpenClaw workspace-root layout). UNION semantics: explicit RESOLVER.md rows ADD to frontmatter triggers (don't replace). Dedup keyed on `(skillPath, trigger.trim().toLowerCase())` so case/whitespace drift collapses to one entry. Three consumers fold through this primitive — `checkResolvable`, `runRoutingEvalCli`, `mounts-cache.composeResolvers` — closing the v0.41 drift bug class (#1451) where per-consumer loaders meant fixing frontmatter for doctor wouldn't reach the routing-eval CLI or cross-brain composed dispatchers. Exports `loadSkillTriggerIndex(skillsDir): SkillTriggerEntry[]`, `entriesToResolverContent(entries): string` (synthesizes a markdown-table resolver-content string for `runRoutingEval`'s string-content API), `findPrimaryResolverPath(skillsDir): string | null`, plus the `FRONTMATTER_SECTION` constant and `_resetWarnedSkillsForTests` test seam. Skip rules: non-directory entries, `_*` / `.*` prefixes, `conventions/` + `migrations/` subdirs, skills with no `SKILL.md` (deprecated `install/` graceful-skipped), skills with no `triggers:` array, malformed YAML (warn-once + skip). Reuses `parseSkillFrontmatter` from `src/core/skill-frontmatter.ts` (regex-based, not full YAML — adequate for the uniform shape every bundled skill uses, follow-up TODO if drift surfaces). Pinned by 18 hermetic cases in `test/skill-trigger-index.test.ts` covering frontmatter auto-registration, RESOLVER.md/AGENTS.md merge, OpenClaw `../AGENTS.md` parent-dir scan, case-insensitive dedupe, deprecated `install` skip, missing-skillsDir resilience, malformed-frontmatter warn-once, and `entriesToResolverContent` round-trip through `parseResolverEntries`. CI gate: `bun run check:resolver` (= `bun src/cli.ts check-resolvable --strict --skills-dir skills/`) wired into `bun run verify` so future drift between frontmatter and routing-eval fixtures fails PR CI.
|
||||
- `src/core/check-resolvable.ts` — Resolver validation: reachability, MECE overlap, DRY checks, structured fix objects. v0.14.1: `CROSS_CUTTING_PATTERNS.conventions` is an array (notability gate accepts both `conventions/quality.md` and `_brain-filing-rules.md`). New `extractDelegationTargets()` parses `> **Convention:**`, `> **Filing rule:**`, and inline backtick references. DRY suppression is proximity-based via `DRY_PROXIMITY_LINES = 40`. **v0.41.7.0:** `parseResolverEntries` accepts a second compact list format alongside the original markdown table (`- **skill-name**: trigger1 | trigger2 | trigger3` or `- skill-name: trigger1 | trigger2`). Both shapes can mix in one file; the v0.31.7 multi-resolver merge folds them into one entry stream. Skill name MUST be kebab-lowercase (regex `[a-z][a-z0-9-]+`) so prose bullets like `- **Note**: …`, `- **Convention**: …`, `- **TODO**: …` in real-world AGENTS.md files don't false-match as skill rows (codex F2 / D4). `skillPath` is ALWAYS derived as `skills/<name>/SKILL.md`: an optional `→ \`skills/path\`` (or ASCII `->`) suffix is stripped from the trigger string but NOT honored as the path — two downstream consumers (`routing-eval.ts:skillSlugFromPath`, the manifest lookup at `:367`) assume the convention, and honoring the explicit path would silently break their coverage. For non-conventional paths, use the table format. Multi-trigger rows fan out to one entry per trigger sharing the same `skillPath`; `checkResolvable` dedupes downstream so the reachability count counts each skill once. Closes the OpenClaw bug class where a 306-skill agent's list-format resolver registered 238 FAILs ("skill not reachable from RESOLVER.md") on every `gbrain doctor` run — productionized from upstream PR #1370 by @garrytan-agents. Pinned by 11 new unit cases in `test/check-resolvable.test.ts` (bold + plain forms, Unicode + ASCII suffix strip, ellipsis filter, empty pipe segments, mixed shapes, prose-bullet rejection regression) and the 8-case integration suite in `test/check-resolvable-openclaw-compact.test.ts` over two fixtures: `test/fixtures/openclaw-compact-resolver/` (10 skills in pure compact form) + `test/fixtures/openclaw-mixed-merge/` (table-format `skills/RESOLVER.md` + compact `../AGENTS.md` for the v0.31.7 merge contract). User-facing tutorial: `docs/guides/scaling-skills.md` walks through when and why to switch to the compact format (the three-tier scaling architecture that gets a 300-skill agent down to ~4K tokens per turn from ~25K with zero capability loss).
|
||||
- `src/core/repo-root.ts` — Shared `findRepoRoot(startDir?)` (v0.16.4): walks up from `startDir` (default `process.cwd()`) looking for `skills/RESOLVER.md`. Zero-dependency module imported by both `doctor.ts` and `check-resolvable.ts`. Parameterized `startDir` makes tests hermetic. **v0.31.7:** read-path / write-path split. `autoDetectSkillsDir` (shared, read+write-safe) gains tier-0 `$GBRAIN_SKILLS_DIR` explicit operator override (Docker mounts, CI, monorepo subdirs) ahead of the existing 4-tier chain. New `autoDetectSkillsDirReadOnly` wraps it with a tier-5 install-path fallback that walks up from `fileURLToPath(import.meta.url)` and gates on `isGbrainRepoRoot` so unrelated repos can't false-positive. Read-path callers (`doctor`, `check-resolvable`, `routing-eval`) use the read-only variant; write-path callers (`skillpack install`, `skillify scaffold`, `post-install-advisory`) deliberately stay on the shared function so `gbrain skillpack install` from `~` cannot silently retarget the bundled gbrain repo's `skills/` instead of the user's actual workspace. Two new `SkillsDirSource` variants: `'env_explicit'`, `'install_path'`. New `AUTO_DETECT_HINT_READ_ONLY` documents the extra tier. The D6 `--fix` safety gate in `doctor.ts` + `check-resolvable.ts` refuses auto-repair when `detected.source === 'install_path'` so `gbrain doctor --fix` from `~` cannot silently rewrite the bundled install tree.
|
||||
- `src/commands/check-resolvable.ts` — Standalone CLI wrapper (v0.16.4) over `checkResolvable()`. Exports `parseFlags`, `resolveSkillsDir`, `DEFERRED`, `runCheckResolvable`. Exit rule: **1 on any issue (warnings OR errors)**, stricter than doctor's `ok` flag — honors README:259. Stable JSON envelope `{ok, skillsDir, report, autoFix, deferred, error, message}` — same shape on success and error paths. `--fix` path runs `autoFixDryViolations` BEFORE `checkResolvable` (same ordering as doctor). `scripts/skillify-check.ts` subprocess-calls `gbrain check-resolvable --json` (cached per process) and fails loud on binary-missing — no silent false-pass. **v0.19:** AGENTS.md workspaces now resolve natively (see `src/core/resolver-filenames.ts`) — gbrain inspects the 107-skill OpenClaw deployment whether the routing file is `RESOLVER.md` or `AGENTS.md`. `DEFERRED[]` is empty — Checks 5 + 6 shipped as real code, not issue URLs. **v0.31.7:** the resolver lookup switched from first-match-wins to the multi-file merge in `src/core/check-resolvable.ts` — entries collected from every `RESOLVER.md` / `AGENTS.md` across the skills dir AND its parent, deduped by `skillPath` (first occurrence wins). Lifted reachable skills on the reference OpenClaw layout from 37/224 to 200/224 — the deployment ships a thin `skills/RESOLVER.md` (~40 entries from skillpack) plus a fat `../AGENTS.md` (200+ entries, the real dispatcher), and the previous code only saw the first one. The CLI also switched to `autoDetectSkillsDirReadOnly` so `cd ~ && gbrain check-resolvable` finds the bundled skills via the install-path fallback. `--fix` carries the same D6 safety gate as `gbrain doctor --fix`: refuses to write when `detected.source === 'install_path'`.
|
||||
|
||||
+7
-4
@@ -57,7 +57,7 @@ bun run test # parallel 8-shard fan-out + serial post-pass
|
||||
bun test test/markdown.test.ts # specific unit test
|
||||
|
||||
# Pre-push gate (matches what CI runs on shard 1 + typecheck)
|
||||
bun run verify # privacy + jsonb + progress + test-isolation + wasm + admin-build + typecheck
|
||||
bun run verify # privacy + jsonb + progress + test-isolation + wasm + admin-build + resolver + typecheck
|
||||
|
||||
# Pre-merge sanity (everything CI runs)
|
||||
bun run test:full # verify + parallel unit + slow + smart e2e
|
||||
@@ -81,9 +81,12 @@ patterns (`scripts/check-jsonb-pattern.sh`), `\r` progress bleed to stdout
|
||||
(`scripts/check-progress-to-stdout.sh`), test-isolation rule violations
|
||||
(`scripts/check-test-isolation.sh` — see "Writing tests that survive the parallel
|
||||
loop" below), silent fallback to recursive chunking in the compiled binary
|
||||
(`scripts/check-wasm-embedded.sh`), and stale admin-dashboard build artifacts
|
||||
(`scripts/check-admin-build.sh`). `bun run check:all` runs the full historical
|
||||
sweep including the trailing-newline and exports-count checks.
|
||||
(`scripts/check-wasm-embedded.sh`), stale admin-dashboard build artifacts
|
||||
(`scripts/check-admin-build.sh`), and resolver drift on bundled skills
|
||||
(`bun run check:resolver` — strict-mode `check-resolvable` that exit-1s on any
|
||||
warning, added in v0.41.14.0 to catch SKILL.md frontmatter ↔ RESOLVER.md drift
|
||||
before merge). `bun run check:all` runs the full historical sweep including the
|
||||
trailing-newline and exports-count checks.
|
||||
|
||||
### Writing tests that survive the parallel loop
|
||||
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# TODOS
|
||||
|
||||
## v0.41.14.0 #1451 drift-fix follow-ups (v0.42+)
|
||||
|
||||
- [ ] **v0.42+: refactor `runRoutingEval` to take `ResolverEntry[]` directly** instead of `resolverContent: string`. Cleaner shape than synthesizing markdown then re-parsing it. Cascades through 9+ test files that depend on the string-content API. Defer until the next big refactor of the routing-eval module so the test-file churn lands with that wave.
|
||||
- [ ] **v0.42+: replace regex-based `parseSkillFrontmatter` with a real YAML parser** (js-yaml is already a transitive dep via gray-matter). Codex finding #4 from /plan-eng-review: the regex in `src/core/skill-frontmatter.ts` assumes YAML semantics it can't enforce (e.g. multi-line scalars, escaped quotes). For our current uniform-shape skills (all use `- "quoted"` block form), it works. Swap when a skill ships a YAML construct the regex misparses, or proactively for defense-in-depth.
|
||||
- [ ] **v0.42+: unify `parseSkillFrontmatter` (skill-frontmatter.ts) and MECE's `extractTriggers` (check-resolvable.ts:216)** into a single parser. Codex finding #5: two parsers, drift surface. Both extract `triggers:` arrays the same way today, so the drift is bounded — but every future change to one needs to be mirrored in the other. Consolidate when either needs to diverge.
|
||||
- [ ] **v0.42+: `bun run ci:local` should run `bun run verify`** (codex finding #10 from /plan-eng-review). Today ci:local runs guards + typecheck + unit + E2E but NOT verify, so the new `check:resolver` gate (and others added to verify) don't fire in local pre-push. Bigger conversation about local vs CI scope — defer as a separate UX decision after measuring how often verify-only failures land in CI.
|
||||
- [ ] **v0.42+: remove the deprecated `install/` skill directory entirely.** It has no SKILL.md (just a deprecation note pointing at setup/) and is correctly skipped by `loadSkillTriggerIndex`. Removing the directory cleans up the bundled skill tree. Orthogonal to #1451; small follow-up.
|
||||
- [ ] **v0.42+: extend `entriesToResolverContent` to escape backticks in trigger strings.** Today only pipes are escaped, because no real bundled trigger contains a backtick. If a future skill ships a trigger like ``` `code` ``` the markdown-table row would mangle. Add a single regex replace if a real case appears.
|
||||
|
||||
## v0.41.10.1 fix-wave follow-ups (v0.42+)
|
||||
|
||||
- [ ] **v0.42+: per-atom idempotency via deterministic atom slug.** The
|
||||
|
||||
@@ -277,6 +277,7 @@ strict behavior when unset.
|
||||
- `src/core/minions/queue.ts` extension (v0.31.12) — `MinionQueue.add()` now rejects `subagent` jobs whose `data.model` resolves through `isAnthropicProvider()` to a non-Anthropic provider. Lazy-imports `model-config.ts` to avoid pulling engine types into queue's eager-load surface. Layer 1 of the three-layer subagent provider enforcement (Codex F1+F2 in plan review). Layers 2 + 3 live in `src/core/model-config.ts` (`enforceSubagentAnthropic` runtime fallback) and `src/commands/doctor.ts` (`subagent_provider` check). Pinned by 3 cases in `test/agent-cli.test.ts`.
|
||||
- `src/commands/models.ts` (v0.31.12) — `gbrain models [--json]` read-only routing dashboard: prints tier defaults (`utility`/`reasoning`/`deep`/`subagent`), the resolved value for each (re-walking the resolution chain to attribute properly), every per-task override (11 `PER_TASK_KEYS` entries — `models.dream.synthesize`, `models.dream.patterns`, `models.drift`, `models.auto_think`, `models.think`, `models.subagent`, `facts.extraction_model`, `models.eval.longmemeval`, `models.expansion`, `models.chat`, `models.dream.synthesize_verdict`), the alias map (defaults + user overrides), and a source-of-truth column showing `default` / `config: <key>` / `env: <VAR>`. `gbrain models doctor [--skip=<provider>] [--json]` fires a 1-token `gateway.chat()` probe against each configured chat + expansion model and classifies failures into `{model_not_found, auth, rate_limit, network, unknown}` — the structural fix for the v0.31.6 silent-no-op bug class. Wired into `cli.ts` dispatch table + `CLI_ONLY` set. **v0.33.1.1 (#962, Codex P3 follow-up):** doctor gains a zero-token `embedding_config` probe that runs FIRST, before any chat/expansion probes spend money. `probeEmbeddingConfig()` reads `getEmbeddingModel()` + `getEmbeddingDimensions()` from the gateway, parses the model id, and (for Voyage flexible-dim models) checks `isValidVoyageOutputDim(dims)` against `VOYAGE_VALID_OUTPUT_DIMS`. New `ProbeStatus` variant `'config'` and optional `fix?: string` field on `ProbeResult` — surfaced in both human output (paste-ready `gbrain config set ...` line under the bad probe) and JSON output. New touchpoint label `'embedding_config'` joins `'chat'` and `'expansion'` in the probe-row taxonomy. Closes the Voyage flexible-dim bug class at config time, not first-embed.
|
||||
- `src/commands/doctor.ts` extension (v0.31.12) — new `subagent_provider` check (layer 3 of 3 — Codex F13). Warns when `models.tier.subagent` is explicitly set to a non-Anthropic provider (fail-loud since the user clearly meant it — message names the bad value and prints the paste-ready fix command `gbrain config set models.tier.subagent anthropic:claude-sonnet-4-6`); also warns when `models.default` would sneak `subagent` into a non-Anthropic provider via tier inheritance. OK status when subagent tier resolves to Anthropic. Tests cover all three paths in `test/doctor.test.ts`.
|
||||
- `src/core/skill-trigger-index.ts` (v0.41.14.0, closes #1451) — Shared loader that unions per-skill SKILL.md frontmatter `triggers:` with curated RESOLVER.md / AGENTS.md rows from `skillsDir` AND the parent directory (preserves the OpenClaw workspace-root layout). UNION semantics: explicit RESOLVER.md rows ADD to frontmatter triggers (don't replace). Dedup keyed on `(skillPath, trigger.trim().toLowerCase())` so case/whitespace drift collapses to one entry. Three consumers fold through this primitive — `checkResolvable`, `runRoutingEvalCli`, `mounts-cache.composeResolvers` — closing the v0.41 drift bug class (#1451) where per-consumer loaders meant fixing frontmatter for doctor wouldn't reach the routing-eval CLI or cross-brain composed dispatchers. Exports `loadSkillTriggerIndex(skillsDir): SkillTriggerEntry[]`, `entriesToResolverContent(entries): string` (synthesizes a markdown-table resolver-content string for `runRoutingEval`'s string-content API), `findPrimaryResolverPath(skillsDir): string | null`, plus the `FRONTMATTER_SECTION` constant and `_resetWarnedSkillsForTests` test seam. Skip rules: non-directory entries, `_*` / `.*` prefixes, `conventions/` + `migrations/` subdirs, skills with no `SKILL.md` (deprecated `install/` graceful-skipped), skills with no `triggers:` array, malformed YAML (warn-once + skip). Reuses `parseSkillFrontmatter` from `src/core/skill-frontmatter.ts` (regex-based, not full YAML — adequate for the uniform shape every bundled skill uses, follow-up TODO if drift surfaces). Pinned by 18 hermetic cases in `test/skill-trigger-index.test.ts` covering frontmatter auto-registration, RESOLVER.md/AGENTS.md merge, OpenClaw `../AGENTS.md` parent-dir scan, case-insensitive dedupe, deprecated `install` skip, missing-skillsDir resilience, malformed-frontmatter warn-once, and `entriesToResolverContent` round-trip through `parseResolverEntries`. CI gate: `bun run check:resolver` (= `bun src/cli.ts check-resolvable --strict --skills-dir skills/`) wired into `bun run verify` so future drift between frontmatter and routing-eval fixtures fails PR CI.
|
||||
- `src/core/check-resolvable.ts` — Resolver validation: reachability, MECE overlap, DRY checks, structured fix objects. v0.14.1: `CROSS_CUTTING_PATTERNS.conventions` is an array (notability gate accepts both `conventions/quality.md` and `_brain-filing-rules.md`). New `extractDelegationTargets()` parses `> **Convention:**`, `> **Filing rule:**`, and inline backtick references. DRY suppression is proximity-based via `DRY_PROXIMITY_LINES = 40`. **v0.41.7.0:** `parseResolverEntries` accepts a second compact list format alongside the original markdown table (`- **skill-name**: trigger1 | trigger2 | trigger3` or `- skill-name: trigger1 | trigger2`). Both shapes can mix in one file; the v0.31.7 multi-resolver merge folds them into one entry stream. Skill name MUST be kebab-lowercase (regex `[a-z][a-z0-9-]+`) so prose bullets like `- **Note**: …`, `- **Convention**: …`, `- **TODO**: …` in real-world AGENTS.md files don't false-match as skill rows (codex F2 / D4). `skillPath` is ALWAYS derived as `skills/<name>/SKILL.md`: an optional `→ \`skills/path\`` (or ASCII `->`) suffix is stripped from the trigger string but NOT honored as the path — two downstream consumers (`routing-eval.ts:skillSlugFromPath`, the manifest lookup at `:367`) assume the convention, and honoring the explicit path would silently break their coverage. For non-conventional paths, use the table format. Multi-trigger rows fan out to one entry per trigger sharing the same `skillPath`; `checkResolvable` dedupes downstream so the reachability count counts each skill once. Closes the OpenClaw bug class where a 306-skill agent's list-format resolver registered 238 FAILs ("skill not reachable from RESOLVER.md") on every `gbrain doctor` run — productionized from upstream PR #1370 by @garrytan-agents. Pinned by 11 new unit cases in `test/check-resolvable.test.ts` (bold + plain forms, Unicode + ASCII suffix strip, ellipsis filter, empty pipe segments, mixed shapes, prose-bullet rejection regression) and the 8-case integration suite in `test/check-resolvable-openclaw-compact.test.ts` over two fixtures: `test/fixtures/openclaw-compact-resolver/` (10 skills in pure compact form) + `test/fixtures/openclaw-mixed-merge/` (table-format `skills/RESOLVER.md` + compact `../AGENTS.md` for the v0.31.7 merge contract). User-facing tutorial: `docs/guides/scaling-skills.md` walks through when and why to switch to the compact format (the three-tier scaling architecture that gets a 300-skill agent down to ~4K tokens per turn from ~25K with zero capability loss).
|
||||
- `src/core/repo-root.ts` — Shared `findRepoRoot(startDir?)` (v0.16.4): walks up from `startDir` (default `process.cwd()`) looking for `skills/RESOLVER.md`. Zero-dependency module imported by both `doctor.ts` and `check-resolvable.ts`. Parameterized `startDir` makes tests hermetic. **v0.31.7:** read-path / write-path split. `autoDetectSkillsDir` (shared, read+write-safe) gains tier-0 `$GBRAIN_SKILLS_DIR` explicit operator override (Docker mounts, CI, monorepo subdirs) ahead of the existing 4-tier chain. New `autoDetectSkillsDirReadOnly` wraps it with a tier-5 install-path fallback that walks up from `fileURLToPath(import.meta.url)` and gates on `isGbrainRepoRoot` so unrelated repos can't false-positive. Read-path callers (`doctor`, `check-resolvable`, `routing-eval`) use the read-only variant; write-path callers (`skillpack install`, `skillify scaffold`, `post-install-advisory`) deliberately stay on the shared function so `gbrain skillpack install` from `~` cannot silently retarget the bundled gbrain repo's `skills/` instead of the user's actual workspace. Two new `SkillsDirSource` variants: `'env_explicit'`, `'install_path'`. New `AUTO_DETECT_HINT_READ_ONLY` documents the extra tier. The D6 `--fix` safety gate in `doctor.ts` + `check-resolvable.ts` refuses auto-repair when `detected.source === 'install_path'` so `gbrain doctor --fix` from `~` cannot silently rewrite the bundled install tree.
|
||||
- `src/commands/check-resolvable.ts` — Standalone CLI wrapper (v0.16.4) over `checkResolvable()`. Exports `parseFlags`, `resolveSkillsDir`, `DEFERRED`, `runCheckResolvable`. Exit rule: **1 on any issue (warnings OR errors)**, stricter than doctor's `ok` flag — honors README:259. Stable JSON envelope `{ok, skillsDir, report, autoFix, deferred, error, message}` — same shape on success and error paths. `--fix` path runs `autoFixDryViolations` BEFORE `checkResolvable` (same ordering as doctor). `scripts/skillify-check.ts` subprocess-calls `gbrain check-resolvable --json` (cached per process) and fails loud on binary-missing — no silent false-pass. **v0.19:** AGENTS.md workspaces now resolve natively (see `src/core/resolver-filenames.ts`) — gbrain inspects the 107-skill OpenClaw deployment whether the routing file is `RESOLVER.md` or `AGENTS.md`. `DEFERRED[]` is empty — Checks 5 + 6 shipped as real code, not issue URLs. **v0.31.7:** the resolver lookup switched from first-match-wins to the multi-file merge in `src/core/check-resolvable.ts` — entries collected from every `RESOLVER.md` / `AGENTS.md` across the skills dir AND its parent, deduped by `skillPath` (first occurrence wins). Lifted reachable skills on the reference OpenClaw layout from 37/224 to 200/224 — the deployment ships a thin `skills/RESOLVER.md` (~40 entries from skillpack) plus a fat `../AGENTS.md` (200+ entries, the real dispatcher), and the previous code only saw the first one. The CLI also switched to `autoDetectSkillsDirReadOnly` so `cd ~ && gbrain check-resolvable` finds the bundled skills via the install-path fallback. `--fix` carries the same D6 safety gate as `gbrain doctor --fix`: refuses to write when `detected.source === 'install_path'`.
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gbrain",
|
||||
"version": "0.41.13.0",
|
||||
"version": "0.41.14.0",
|
||||
"description": "Postgres-native personal knowledge brain with hybrid RAG search",
|
||||
"type": "module",
|
||||
"main": "src/core/index.ts",
|
||||
@@ -49,6 +49,7 @@
|
||||
"check:cli-exec": "scripts/check-cli-executable.sh",
|
||||
"check:all": "scripts/check-privacy.sh && scripts/check-proposal-pii.sh && scripts/check-test-real-names.sh && scripts/check-jsonb-pattern.sh && scripts/check-source-id-projection.sh && scripts/check-source-config-leak.sh && scripts/check-progress-to-stdout.sh && scripts/check-no-legacy-getconnection.sh && scripts/check-test-isolation.sh && scripts/check-trailing-newline.sh && scripts/check-wasm-embedded.sh && scripts/check-exports-count.sh && scripts/check-admin-build.sh && scripts/check-admin-scope-drift.sh && scripts/check-cli-executable.sh && scripts/check-skill-brain-first.sh && scripts/check-operations-filter-bypass.sh && scripts/check-gateway-routed-no-direct-anthropic.sh",
|
||||
"check:gateway-routed": "scripts/check-gateway-routed-no-direct-anthropic.sh",
|
||||
"check:resolver": "bun src/cli.ts check-resolvable --strict --skills-dir skills/",
|
||||
"check:skill-brain-first": "scripts/check-skill-brain-first.sh",
|
||||
"check:wasm": "scripts/check-wasm-embedded.sh",
|
||||
"check:newlines": "scripts/check-trailing-newline.sh",
|
||||
|
||||
@@ -54,6 +54,7 @@ CHECKS=(
|
||||
"check:fuzz-purity"
|
||||
"check:operations-filter-bypass"
|
||||
"check:gateway-routed"
|
||||
"check:resolver"
|
||||
"typecheck"
|
||||
)
|
||||
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
{"intent": "ask the brain taxonomist before I write this page", "expected_skill": "brain-taxonomist"}
|
||||
{"intent": "run a taxonomy check on yesterday's notes", "expected_skill": "brain-taxonomist"}
|
||||
{"intent": "I want to refile brain page about Bob", "expected_skill": "brain-taxonomist"}
|
||||
{"intent": "which directory does this page go in given the active pack?", "expected_skill": "brain-taxonomist"}
|
||||
{"intent": "which directory does this page go in given the active pack?", "expected_skill": "brain-taxonomist", "ambiguous_with": ["repo-architecture"]}
|
||||
|
||||
@@ -8,11 +8,16 @@ description: |
|
||||
judgment-heavy genericization (scrub real names, generalize triggers,
|
||||
lift fork-specific conventions to references).
|
||||
triggers:
|
||||
- "harvest this skill into gbrain"
|
||||
- "harvest this skill"
|
||||
- "harvest my skill"
|
||||
- "publish this skill to gbrain"
|
||||
- "lift this skill upstream"
|
||||
- "share this skill with other gbrain clients"
|
||||
- "promote my skill to gbrain"
|
||||
- "lift this skill"
|
||||
- "share this skill"
|
||||
- "promote this skill"
|
||||
- "promote my skill"
|
||||
- "skill upstream"
|
||||
- "into the gbrain core"
|
||||
- "gbrain bundle"
|
||||
mutating: true
|
||||
writes_pages: false
|
||||
writes_to:
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
// Routing eval fixtures for skills/skillpack-harvest.
|
||||
// Positive cases: every intent contains a trigger substring (the
|
||||
// trigger set was broadened from 5 to 10 in v0.41.11 per
|
||||
// kylma-code's PR #1331 to cover realistic user phrasings).
|
||||
{"intent": "lift this skill back into gbrain so others can use it", "expected_skill": "skillpack-harvest"}
|
||||
{"intent": "publish my fork-only skill upstream", "expected_skill": "skillpack-harvest"}
|
||||
{"intent": "share this skill with the gbrain bundle", "expected_skill": "skillpack-harvest"}
|
||||
@@ -5,3 +9,16 @@
|
||||
{"intent": "promote this skill to gbrain so neuromancer can scaffold it", "expected_skill": "skillpack-harvest"}
|
||||
{"intent": "I want this skill in the gbrain bundle", "expected_skill": "skillpack-harvest"}
|
||||
{"intent": "move my custom skill into the gbrain core", "expected_skill": "skillpack-harvest"}
|
||||
// Negative cases (v0.41.11): the broader trigger set contains generic
|
||||
// substrings ("publish this skill to gbrain", "skill upstream",
|
||||
// "gbrain bundle", "into the gbrain core") that could match unrelated
|
||||
// user intents under substring routing. expected_skill=null asserts
|
||||
// NO specific skill (skillpack-harvest OR any other) matches these.
|
||||
// "save this report as PDF" and "share this article with the channel"
|
||||
// were considered but excluded: they trip idea-ingest's existing "save
|
||||
// this" / "share" triggers (real overlap, but a v0.42+ idea-ingest
|
||||
// concern — out of scope for #1451's structural fix).
|
||||
{"intent": "publish this report to the team", "expected_skill": null}
|
||||
{"intent": "promote my role on LinkedIn", "expected_skill": null}
|
||||
{"intent": "bundle these screenshots into a deck", "expected_skill": null}
|
||||
{"intent": "lift weights at the gym", "expected_skill": null}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Routing eval fixtures for skills/strategic-reading. Each intent
|
||||
// includes at least one trigger string as substring.
|
||||
{"intent":"Do a strategic reading of 'The Power Broker' against my current situation","expected_skill":"strategic-reading"}
|
||||
{"intent":"Read this through the lens of the board meeting next week and give me tactics","expected_skill":"strategic-reading"}
|
||||
{"intent":"Read this through the lens of the board meeting next week and give me tactics","expected_skill":"strategic-reading","ambiguous_with":["idea-ingest"]}
|
||||
{"intent":"Apply this to my problem with the launch — what to do, what to avoid, what to watch for","expected_skill":"strategic-reading"}
|
||||
{"intent":"What can I learn from this about handling a hostile gatekeeper","expected_skill":"strategic-reading"}
|
||||
{"intent":"Extract a playbook from this case study for my product launch","expected_skill":"strategic-reading"}
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
// matcher requirement) while still paraphrasing real user phrasing.
|
||||
{"intent":"Please ingest this voice memo I just sent and file it into my brain","expected_skill":"voice-note-ingest"}
|
||||
{"intent":"Transcribe and file this audio message into the right directory","expected_skill":"voice-note-ingest"}
|
||||
{"intent":"Save this audio note as a brain page with the original audio attached","expected_skill":"voice-note-ingest"}
|
||||
{"intent":"Save this audio note as a brain page with the original audio attached","expected_skill":"voice-note-ingest","ambiguous_with":["idea-ingest"]}
|
||||
{"intent":"Run voice note ingest on what I just sent — preserve my words verbatim","expected_skill":"voice-note-ingest"}
|
||||
{"intent":"This voice note has a thought I want preserved word-for-word","expected_skill":"voice-note-ingest"}
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ for (const op of operations) {
|
||||
}
|
||||
|
||||
// CLI-only commands that bypass the operation layer
|
||||
const CLI_ONLY = new Set(['init', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'extract-conversation-facts', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'founder', 'brainstorm', 'lsd', 'schema', 'capture']);
|
||||
const CLI_ONLY = new Set(['init', 'reinit-pglite', 'upgrade', 'post-upgrade', 'check-update', 'integrations', 'publish', 'check-backlinks', 'lint', 'report', 'import', 'export', 'files', 'embed', 'serve', 'call', 'config', 'doctor', 'migrate', 'eval', 'sync', 'extract', 'extract-conversation-facts', 'features', 'autopilot', 'graph-query', 'jobs', 'agent', 'apply-migrations', 'skillpack-check', 'skillpack', 'resolvers', 'integrity', 'repair-jsonb', 'orphans', 'sources', 'mounts', 'dream', 'check-resolvable', 'routing-eval', 'skillify', 'smoke-test', 'providers', 'storage', 'repos', 'code-def', 'code-refs', 'reindex', 'reindex-code', 'reindex-frontmatter', 'code-callers', 'code-callees', 'frontmatter', 'auth', 'friction', 'claw-test', 'book-mirror', 'takes', 'think', 'salience', 'anomalies', 'transcripts', 'models', 'remote', 'recall', 'forget', 'edges-backfill', 'cache', 'ze-switch', 'founder', 'brainstorm', 'lsd', 'schema', 'capture']);
|
||||
// CLI-only commands whose handlers print their own --help text. These are
|
||||
// excluded from the generic short-circuit so detailed per-command and
|
||||
// per-subcommand usage stays reachable.
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
* layer only. A future release will implement the tie-break layer.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve as resolvePath, isAbsolute } from 'path';
|
||||
|
||||
import {
|
||||
@@ -25,9 +24,13 @@ import {
|
||||
type RoutingReport,
|
||||
type FixtureLintIssue,
|
||||
} from '../core/routing-eval.ts';
|
||||
import { findAllResolverFiles, RESOLVER_FILENAMES_LABEL } from '../core/resolver-filenames.ts';
|
||||
import { RESOLVER_FILENAMES_LABEL } from '../core/resolver-filenames.ts';
|
||||
import { autoDetectSkillsDirReadOnly } from '../core/repo-root.ts';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
entriesToResolverContent,
|
||||
findPrimaryResolverPath,
|
||||
loadSkillTriggerIndex,
|
||||
} from '../core/skill-trigger-index.ts';
|
||||
|
||||
interface Flags {
|
||||
help: boolean;
|
||||
@@ -140,18 +143,19 @@ export async function runRoutingEvalCli(args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
const skillsDir = dir!;
|
||||
// v0.31.7 D6 fix: collect entries from ALL resolver files across both the
|
||||
// skills directory and its parent, matching the multi-file merge behavior
|
||||
// in src/core/check-resolvable.ts. Without this, OpenClaw deployments
|
||||
// (thin skills/RESOLVER.md + rich ../AGENTS.md) would have routing-eval
|
||||
// see only the thin file's triggers and report false misses/ambiguity
|
||||
// while doctor and check-resolvable see the full merged index.
|
||||
const allResolverPaths = [
|
||||
...findAllResolverFiles(skillsDir),
|
||||
...findAllResolverFiles(join(skillsDir, '..')),
|
||||
];
|
||||
const resolverFile = allResolverPaths[0] ?? null;
|
||||
if (!resolverFile) {
|
||||
// v0.41.11: route through the shared `loadSkillTriggerIndex` primitive
|
||||
// so this CLI sees the same merged index that `checkResolvable` and
|
||||
// `mounts-cache` see. The unified index folds frontmatter `triggers:`
|
||||
// declarations into RESOLVER.md / AGENTS.md rows with UNION semantics.
|
||||
// Before this fix, this CLI built its own resolver-content string from
|
||||
// RESOLVER.md files only — closing the v0.41 drift bug class where
|
||||
// doctor said "fine" but `gbrain routing-eval --strict` still failed.
|
||||
const triggerEntries = loadSkillTriggerIndex(skillsDir);
|
||||
const resolverFile = findPrimaryResolverPath(skillsDir);
|
||||
// Allow operation when frontmatter triggers populate the index even
|
||||
// if no RESOLVER.md / AGENTS.md exists. Only fail when BOTH surfaces
|
||||
// are empty.
|
||||
if (!resolverFile && triggerEntries.length === 0) {
|
||||
const env: RoutingEvalEnvelope = {
|
||||
ok: false,
|
||||
skillsDir,
|
||||
@@ -160,18 +164,19 @@ export async function runRoutingEvalCli(args: string[]): Promise<void> {
|
||||
lintIssues: [],
|
||||
malformedFixtures: [],
|
||||
error: 'no_resolver',
|
||||
message: `${RESOLVER_FILENAMES_LABEL} not found in ${skillsDir} or its parent.`,
|
||||
message: `${RESOLVER_FILENAMES_LABEL} not found in ${skillsDir} or its parent (and no SKILL.md frontmatter declares triggers:).`,
|
||||
};
|
||||
if (flags.json) console.log(JSON.stringify(env, null, 2));
|
||||
else console.error(env.message);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Build combined resolverContent across all matched files (RESOLVER.md +
|
||||
// ../AGENTS.md, etc.), so indexResolverTriggers sees the union.
|
||||
const resolverContent = allResolverPaths
|
||||
.map(p => readFileSync(p, 'utf-8'))
|
||||
.join('\n\n');
|
||||
// Synthesize a single resolver-content string from the unified entry
|
||||
// list. parseResolverEntries (which `indexResolverTriggers` calls
|
||||
// internally) re-parses it cleanly; this keeps `runRoutingEval`'s
|
||||
// public string-content API unchanged so the 9+ test files that
|
||||
// depend on it don't need touch.
|
||||
const resolverContent = entriesToResolverContent(triggerEntries);
|
||||
const index = indexResolverTriggers(resolverContent);
|
||||
|
||||
const loaded = loadRoutingFixtures(skillsDir);
|
||||
|
||||
@@ -21,6 +21,11 @@ import {
|
||||
runRoutingEval,
|
||||
} from './routing-eval.ts';
|
||||
import { runFilingAudit } from './filing-audit.ts';
|
||||
import {
|
||||
entriesToResolverContent,
|
||||
findPrimaryResolverPath,
|
||||
loadSkillTriggerIndex,
|
||||
} from './skill-trigger-index.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -107,7 +112,7 @@ const OVERLAP_WHITELIST = new Set([
|
||||
'brain-ops', // always-on, every brain read/write
|
||||
]);
|
||||
|
||||
interface ResolverEntry {
|
||||
export interface ResolverEntry {
|
||||
trigger: string;
|
||||
skillPath: string; // e.g., 'skills/query/SKILL.md'
|
||||
isGStack: boolean; // GStack: X entries (external, skip file check)
|
||||
@@ -299,31 +304,39 @@ export function extractDelegationTargets(content: string): DelegationRef[] {
|
||||
export function checkResolvable(skillsDir: string): ResolvableReport {
|
||||
const issues: ResolvableIssue[] = [];
|
||||
|
||||
// Load inputs
|
||||
// Accept RESOLVER.md or AGENTS.md (W1). Also check one level up: the
|
||||
// reference OpenClaw deployment layout places AGENTS.md at the
|
||||
// workspace root, with skills/ below.
|
||||
// Load inputs via the v0.41.11 shared primitive. UNION semantics
|
||||
// across two surfaces:
|
||||
// 1. Per-skill SKILL.md frontmatter `triggers:` (canonical) — every
|
||||
// skill ships its own triggers; new skills don't need a
|
||||
// RESOLVER.md row to be reachable.
|
||||
// 2. Curated RESOLVER.md / AGENTS.md rows from skillsDir AND parent
|
||||
// directory (D-CX-14 OpenClaw workspace-root layout preserved).
|
||||
//
|
||||
// Merge strategy (D-CX-14): collect entries from ALL resolver files
|
||||
// across both directories (skills dir + parent). This handles the
|
||||
// common OpenClaw layout where a skillpack installs a thin
|
||||
// skills/RESOLVER.md while the real dispatcher lives in ../AGENTS.md.
|
||||
// Entries are deduped by skillPath (first occurrence wins).
|
||||
const allResolverPaths = [
|
||||
...findAllResolverFiles(skillsDir),
|
||||
...findAllResolverFiles(join(skillsDir, '..')),
|
||||
];
|
||||
// Frontmatter is the source of truth (closes the #1451 drift class);
|
||||
// RESOLVER.md rows still contribute additively so the human-readable
|
||||
// dispatcher map stays load-bearing. See src/core/skill-trigger-index.ts.
|
||||
const triggerEntries = loadSkillTriggerIndex(skillsDir);
|
||||
|
||||
// Primary resolver: first found (for error messages and --fix targets)
|
||||
const resolverPath = allResolverPaths[0] ?? null;
|
||||
if (!resolverPath) {
|
||||
// Primary RESOLVER.md path is still needed for error messages and
|
||||
// --fix targets that have to point at a concrete file. When neither
|
||||
// RESOLVER.md nor AGENTS.md exists but frontmatter triggers DO
|
||||
// populate the index, fall back to a suggested path so `fix:` blocks
|
||||
// still have a concrete target (auto-fix paths that touch RESOLVER.md
|
||||
// will create it on first write).
|
||||
const resolverPathOrNull = findPrimaryResolverPath(skillsDir);
|
||||
const resolverPath = resolverPathOrNull ?? join(skillsDir, 'RESOLVER.md');
|
||||
if (!resolverPathOrNull && triggerEntries.length === 0) {
|
||||
// No RESOLVER.md / AGENTS.md anywhere AND no skill ships frontmatter
|
||||
// triggers — the resolver tree is fully empty. Preserve the
|
||||
// original 'missing_file' error semantics so doctor's UX doesn't
|
||||
// regress for genuinely-uninitialized skills directories.
|
||||
const suggested = join(skillsDir, 'RESOLVER.md');
|
||||
const missingIssue: ResolvableIssue = {
|
||||
type: 'missing_file',
|
||||
severity: 'error',
|
||||
skill: RESOLVER_FILENAMES_LABEL,
|
||||
message: `${RESOLVER_FILENAMES_LABEL} not found in ${skillsDir} or its parent`,
|
||||
action: `Create ${suggested} with skill routing tables`,
|
||||
message: `${RESOLVER_FILENAMES_LABEL} not found in ${skillsDir} or its parent (and no SKILL.md frontmatter declares triggers:)`,
|
||||
action: `Create ${suggested} with skill routing tables, or add 'triggers:' to each SKILL.md frontmatter`,
|
||||
fix: { type: 'create_stub', file: suggested },
|
||||
};
|
||||
return {
|
||||
@@ -335,22 +348,14 @@ export function checkResolvable(skillsDir: string): ResolvableReport {
|
||||
};
|
||||
}
|
||||
|
||||
// Merge entries from all resolver files, dedup by skillPath.
|
||||
// Also build a combined resolverContent for routing-eval (Check 5).
|
||||
const seenSkillPaths = new Set<string>();
|
||||
const entries: ResolverEntry[] = [];
|
||||
const resolverContentParts: string[] = [];
|
||||
for (const rPath of allResolverPaths) {
|
||||
const content = readFileSync(rPath, 'utf-8');
|
||||
resolverContentParts.push(content);
|
||||
for (const entry of parseResolverEntries(content)) {
|
||||
if (!seenSkillPaths.has(entry.skillPath)) {
|
||||
seenSkillPaths.add(entry.skillPath);
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
const resolverContent = resolverContentParts.join('\n\n');
|
||||
// Project to ResolverEntry[] shape that downstream code already
|
||||
// consumes (source field is only needed for action-text routing).
|
||||
const entries: ResolverEntry[] = triggerEntries;
|
||||
|
||||
// Build a synthesized resolver-content string for the routing-eval
|
||||
// and lint stages that still take string content. Re-emits both
|
||||
// frontmatter-derived AND RESOLVER.md-derived entries as one table.
|
||||
const resolverContent = entriesToResolverContent(triggerEntries);
|
||||
const { skills: manifest } = loadOrDeriveManifest(skillsDir);
|
||||
|
||||
// Build lookup sets
|
||||
@@ -553,12 +558,19 @@ export function checkResolvable(skillsDir: string): ResolvableReport {
|
||||
: d.outcome === 'ambiguous'
|
||||
? 'routing_ambiguous'
|
||||
: 'routing_false_positive';
|
||||
const skillName = d.fixture.expected_skill ?? 'negative-case';
|
||||
// v0.41.11: triggers live in SKILL.md frontmatter (canonical) AND
|
||||
// RESOLVER.md rows. Point the agent at the canonical surface
|
||||
// first; the dispatcher map is the secondary edit point.
|
||||
const editTarget = d.fixture.expected_skill
|
||||
? `skills/${d.fixture.expected_skill}/SKILL.md frontmatter triggers: (canonical) or skills/RESOLVER.md row (dispatcher map)`
|
||||
: `the relevant skill's SKILL.md frontmatter triggers:`;
|
||||
issues.push({
|
||||
type: kind,
|
||||
severity: 'warning',
|
||||
skill: d.fixture.expected_skill ?? 'negative-case',
|
||||
skill: skillName,
|
||||
message: `Routing ${d.outcome} for intent "${d.fixture.intent}"`,
|
||||
action: `Update routing-eval.jsonl fixture or broaden resolver triggers in RESOLVER.md (${d.note ?? 'no additional detail'})`,
|
||||
action: `Update routing-eval.jsonl fixture or broaden ${editTarget} (${d.note ?? 'no additional detail'})`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { readFileSync, existsSync, mkdirSync, writeFileSync, rmSync, renameSync
|
||||
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. */
|
||||
@@ -131,9 +132,20 @@ export function composeResolvers(
|
||||
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');
|
||||
const hostContent = readFile(hostResolverPath);
|
||||
const hostRawEntries = hostContent ? parseResolverEntries(hostContent) : [];
|
||||
// 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 => {
|
||||
@@ -168,8 +180,13 @@ export function composeResolvers(
|
||||
const mountSkillsDir = join(mount.path, DEFAULT_SKILLS_SUBDIR);
|
||||
const resolverPath = join(mountSkillsDir, 'RESOLVER.md');
|
||||
const content = readFile(resolverPath);
|
||||
if (!content) continue; // Mount without a RESOLVER.md contributes no routing entries. Not an error.
|
||||
const rawEntries = parseResolverEntries(content);
|
||||
// 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);
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* skill-trigger-index.ts — Shared loader for the unified skill trigger
|
||||
* index. Folds two surfaces into one ResolverEntry stream:
|
||||
*
|
||||
* 1. Per-skill SKILL.md frontmatter `triggers:` (canonical source of
|
||||
* truth as of v0.41.11 — every skill ships its own triggers).
|
||||
* 2. Curated RESOLVER.md / AGENTS.md rows from `skillsDir` + parent
|
||||
* directory (preserves the human-readable dispatcher map AND the
|
||||
* OpenClaw workspace-root AGENTS.md merge contract from v0.31.7).
|
||||
*
|
||||
* Merge semantics: UNION, not REPLACE. An explicit RESOLVER.md row that
|
||||
* declares trigger T for skill S ADDS to the frontmatter triggers for S
|
||||
* (it does NOT replace them). Both surfaces contribute. Dedup is keyed
|
||||
* on `(skillPath, trigger.trim().toLowerCase())` so case/whitespace
|
||||
* drift between the two surfaces collapses to one entry.
|
||||
*
|
||||
* Three consumers fold through this primitive: `checkResolvable`,
|
||||
* `routing-eval` CLI, and `mounts-cache.composeResolver`. Per-consumer
|
||||
* loaders were the v0.41.x drift bug class (#1451) — fixing the
|
||||
* frontmatter triggers fixed doctor but not the routing-eval CLI; the
|
||||
* shared primitive eliminates that class.
|
||||
*
|
||||
* Performance: ~50 readFileSync calls per invocation on a stock bundled
|
||||
* skills tree. ~5ms cold, sub-millisecond warm. No caching — the real
|
||||
* risk codex flagged in #1451 review was consistency, not throughput.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, readdirSync, type Dirent } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { parseResolverEntries, type ResolverEntry } from './check-resolvable.ts';
|
||||
import { findAllResolverFiles } from './resolver-filenames.ts';
|
||||
import { parseSkillFrontmatter } from './skill-frontmatter.ts';
|
||||
|
||||
export type TriggerSource = 'frontmatter' | 'resolver_md';
|
||||
|
||||
export interface SkillTriggerEntry extends ResolverEntry {
|
||||
/** Which surface produced this entry. Drives action-text generation. */
|
||||
source: TriggerSource;
|
||||
}
|
||||
|
||||
/** Section label stamped on every frontmatter-derived entry. */
|
||||
export const FRONTMATTER_SECTION = 'Auto-registered (from skill frontmatter)';
|
||||
|
||||
/** Skill subdirectories the loader will not scan for SKILL.md frontmatter.
|
||||
* - `_*` and dotfiles are docs / conventions / private files.
|
||||
* - `conventions/` is the cross-cutting rules tree (no SKILL.md).
|
||||
* - `migrations/` is version migration files (no SKILL.md).
|
||||
*/
|
||||
const FRONTMATTER_SKIP_DIRS = new Set<string>(['conventions', 'migrations']);
|
||||
|
||||
/** Process-scoped warn-once tracker so a malformed frontmatter doesn't
|
||||
* spam stderr on every `gbrain doctor` invocation across a session.
|
||||
* Test seam: `_resetWarnedSkillsForTests` lets unit suites re-trigger. */
|
||||
let _warnedSkills: Set<string> = new Set();
|
||||
|
||||
/** Test seam — clears the warn-once cache so test cases that exercise
|
||||
* the malformed-frontmatter branch re-emit the stderr line. */
|
||||
export function _resetWarnedSkillsForTests(): void {
|
||||
_warnedSkills = new Set();
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk `skills/<name>/SKILL.md` for each skill, parse the YAML
|
||||
* frontmatter via the existing shared `parseSkillFrontmatter`, and
|
||||
* synthesize one `SkillTriggerEntry` per declared `triggers:` string.
|
||||
*
|
||||
* Skip rules (graceful, never throws):
|
||||
* - Non-directory entries.
|
||||
* - Directories starting with `_` or `.` (docs / hidden).
|
||||
* - `conventions/`, `migrations/` (no SKILL.md by design).
|
||||
* - Directories without a `SKILL.md` (deprecated `install/`).
|
||||
* - SKILL.md files with no frontmatter or empty `triggers:` array.
|
||||
* - SKILL.md files that fail to read — warn-once + skip.
|
||||
*/
|
||||
function loadFrontmatterEntries(skillsDir: string): SkillTriggerEntry[] {
|
||||
const out: SkillTriggerEntry[] = [];
|
||||
if (!existsSync(skillsDir)) return out;
|
||||
|
||||
let dirents: Dirent[];
|
||||
try {
|
||||
dirents = readdirSync(skillsDir, { withFileTypes: true });
|
||||
} catch {
|
||||
return out;
|
||||
}
|
||||
|
||||
for (const dirent of dirents) {
|
||||
if (!dirent.isDirectory()) continue;
|
||||
const name = dirent.name;
|
||||
if (name.startsWith('_') || name.startsWith('.')) continue;
|
||||
if (FRONTMATTER_SKIP_DIRS.has(name)) continue;
|
||||
|
||||
const skillMdPath = join(skillsDir, name, 'SKILL.md');
|
||||
if (!existsSync(skillMdPath)) continue;
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
content = readFileSync(skillMdPath, 'utf-8');
|
||||
} catch (err) {
|
||||
if (!_warnedSkills.has(skillMdPath)) {
|
||||
_warnedSkills.add(skillMdPath);
|
||||
console.warn(
|
||||
`[skill-trigger-index] could not read ${skillMdPath}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let parsed: ReturnType<typeof parseSkillFrontmatter> | null = null;
|
||||
try {
|
||||
parsed = parseSkillFrontmatter(content);
|
||||
} catch (err) {
|
||||
if (!_warnedSkills.has(skillMdPath)) {
|
||||
_warnedSkills.add(skillMdPath);
|
||||
console.warn(
|
||||
`[skill-trigger-index] frontmatter parse failed for ${skillMdPath}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!parsed || !parsed.triggers || parsed.triggers.length === 0) continue;
|
||||
|
||||
const skillPath = `skills/${name}/SKILL.md`;
|
||||
for (const trigger of parsed.triggers) {
|
||||
const t = trigger.trim();
|
||||
if (t.length === 0) continue;
|
||||
out.push({
|
||||
trigger: t,
|
||||
skillPath,
|
||||
isGStack: false,
|
||||
section: FRONTMATTER_SECTION,
|
||||
source: 'frontmatter',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk every RESOLVER.md / AGENTS.md across `skillsDir` AND its parent
|
||||
* directory (the OpenClaw workspace-root layout from v0.31.7). For each
|
||||
* file: parse via `parseResolverEntries` and stamp `source: 'resolver_md'`.
|
||||
*/
|
||||
function loadResolverMdEntries(skillsDir: string): SkillTriggerEntry[] {
|
||||
const paths = [
|
||||
...findAllResolverFiles(skillsDir),
|
||||
...findAllResolverFiles(join(skillsDir, '..')),
|
||||
];
|
||||
const out: SkillTriggerEntry[] = [];
|
||||
for (const p of paths) {
|
||||
let content: string;
|
||||
try {
|
||||
content = readFileSync(p, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
for (const e of parseResolverEntries(content)) {
|
||||
out.push({ ...e, source: 'resolver_md' });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge frontmatter + resolver_md entries with UNION semantics. Dedup
|
||||
* is keyed on `(skillPath, normalizedTrigger)` where the normalizer
|
||||
* trims and lowercases — so `"Harvest This Skill"` in frontmatter and
|
||||
* `"harvest this skill"` in RESOLVER.md collapse to one entry. First
|
||||
* occurrence wins (frontmatter entries are passed first, so a
|
||||
* frontmatter-declared trigger keeps its `source: 'frontmatter'`
|
||||
* even when a duplicate-shaped row also lives in RESOLVER.md).
|
||||
*/
|
||||
function mergeEntries(
|
||||
fmEntries: SkillTriggerEntry[],
|
||||
resolverEntries: SkillTriggerEntry[],
|
||||
): SkillTriggerEntry[] {
|
||||
const seen = new Set<string>();
|
||||
const out: SkillTriggerEntry[] = [];
|
||||
for (const e of [...fmEntries, ...resolverEntries]) {
|
||||
// GStack/external entries (e.g. `GStack: ceo-review`) have prose in
|
||||
// skillPath instead of a file path. Dedup them by their raw form
|
||||
// since two RESOLVER.md files might list the same external both.
|
||||
const key = e.isGStack
|
||||
? `EXT::${e.skillPath}::${e.trigger.trim().toLowerCase()}`
|
||||
: `${e.skillPath}::${e.trigger.trim().toLowerCase()}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push(e);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared primitive. Returns the unified entry list for a given
|
||||
* skills directory. Idempotent. Pure modulo filesystem state.
|
||||
*/
|
||||
export function loadSkillTriggerIndex(skillsDir: string): SkillTriggerEntry[] {
|
||||
const fmEntries = loadFrontmatterEntries(skillsDir);
|
||||
const resolverEntries = loadResolverMdEntries(skillsDir);
|
||||
return mergeEntries(fmEntries, resolverEntries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesize a single markdown-table resolver-content string from a
|
||||
* unified entry list. Output is shape-compatible with
|
||||
* `parseResolverEntries`, so downstream code that still expects a
|
||||
* string (notably `runRoutingEval` and `lintRoutingFixtures`) can be
|
||||
* fed the merged index without an API change.
|
||||
*
|
||||
* Section heading is stable so the table parses as one logical
|
||||
* section. Pipes inside trigger strings are backslash-escaped so a
|
||||
* trigger like `"a | b"` doesn't break the row.
|
||||
*/
|
||||
export function entriesToResolverContent(
|
||||
entries: SkillTriggerEntry[],
|
||||
): string {
|
||||
const lines: string[] = [
|
||||
'## Synthesized trigger index',
|
||||
'',
|
||||
'| trigger | skill |',
|
||||
'| --- | --- |',
|
||||
];
|
||||
for (const e of entries) {
|
||||
const trigger = escapePipe(e.trigger);
|
||||
if (e.isGStack) {
|
||||
// External/GStack rows: skillPath is the prose label. Re-emit
|
||||
// verbatim so parseResolverEntries' isGStack branch fires.
|
||||
lines.push(`| ${trigger} | ${escapePipe(e.skillPath)} |`);
|
||||
} else {
|
||||
lines.push(`| ${trigger} | \`${e.skillPath}\` |`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function escapePipe(s: string): string {
|
||||
return s.replace(/\|/g, '\\|');
|
||||
}
|
||||
|
||||
/**
|
||||
* First RESOLVER.md / AGENTS.md path found across `skillsDir` + parent,
|
||||
* or `null`. Used by `checkResolvable` for error messages and `--fix`
|
||||
* targets that need a concrete file path to point at.
|
||||
*/
|
||||
export function findPrimaryResolverPath(skillsDir: string): string | null {
|
||||
const paths = [
|
||||
...findAllResolverFiles(skillsDir),
|
||||
...findAllResolverFiles(join(skillsDir, '..')),
|
||||
];
|
||||
return paths[0] ?? null;
|
||||
}
|
||||
@@ -319,9 +319,13 @@ describe('gbrain check-resolvable CLI — integration', () => {
|
||||
});
|
||||
|
||||
it('exits 1 when fixture has an error-level unreachable skill', () => {
|
||||
// "alpha" is in manifest but not resolver → unreachable (error)
|
||||
// v0.41.11 contract change: a skill is unreachable only when BOTH
|
||||
// surfaces are empty — no frontmatter `triggers:` AND no RESOLVER.md
|
||||
// row. The prior fixture (`triggers: ['alpha']`, `inResolver: false`)
|
||||
// is now reachable via frontmatter auto-registration, so we drop
|
||||
// triggers and the resolver row to genuinely simulate unreachable.
|
||||
const skillsDir = makeFixture(
|
||||
[{ name: 'alpha', triggers: ['alpha'], inResolver: false }],
|
||||
[{ name: 'alpha', inResolver: false }],
|
||||
created,
|
||||
);
|
||||
const r = run(['--json', '--skills-dir', skillsDir]);
|
||||
|
||||
@@ -383,29 +383,30 @@ describe("DRY detection — checkResolvable", () => {
|
||||
});
|
||||
|
||||
describe("v0.22.4 regression — actual repo skills/ has 0 errors", () => {
|
||||
test("repo skills/ pass check-resolvable cleanly (errors only)", () => {
|
||||
// The contract for v0.22.4 (Part A) was: zero warnings AND zero
|
||||
// errors against the actual checked-in skills/ tree.
|
||||
test("repo skills/ pass check-resolvable cleanly (zero errors AND zero warnings)", () => {
|
||||
// The v0.22.4 (Part A) contract was zero warnings AND zero errors.
|
||||
// The v0.25.1 update relaxed it to allow `routing_miss` warnings as
|
||||
// informational — a stop-gap because the structural matcher
|
||||
// required substring-match against narrow triggers in RESOLVER.md
|
||||
// and natural paraphrases legitimately missed.
|
||||
//
|
||||
// v0.25.1 update: warnings of type "routing_miss" are now
|
||||
// ALLOWED. They surface naturally when routing-eval intents are
|
||||
// paraphrased per the D-CX-6 rule (intent must paraphrase the
|
||||
// trigger, not copy it). The structural matcher requires
|
||||
// substring-match against triggers; natural paraphrases legitimately
|
||||
// miss. The LLM tie-break layer (placeholder per v0.24.0) is the
|
||||
// intended fix when it ships. Until then, routing_miss is an
|
||||
// honest warning rather than a regression signal.
|
||||
// v0.41.11 #1451 closes the drift bug class structurally:
|
||||
// frontmatter triggers + RESOLVER.md rows merge with UNION
|
||||
// semantics via `loadSkillTriggerIndex`. The 7 residual
|
||||
// `routing_miss` warnings on skillpack-harvest (and any future
|
||||
// drift on other skills) MUST be addressed at write time, not
|
||||
// tolerated at test time. The CI gate `bun run check:resolver`
|
||||
// (--strict, exit-1 on any warning) enforces this for PRs.
|
||||
//
|
||||
// Other warning types (trigger overlap, DRY violations, filing-
|
||||
// rule misses, etc.) STILL fail this test. The test's regression-
|
||||
// guard intent against those is preserved.
|
||||
// The test now asserts the FULL contract: zero errors, zero
|
||||
// warnings. If a routing_miss reappears, the fix is to broaden
|
||||
// the skill's frontmatter `triggers:` so the realistic fixture
|
||||
// intent contains a trigger substring.
|
||||
const report = checkResolvable(SKILLS_DIR);
|
||||
const errors = report.issues.filter(i => i.severity === "error");
|
||||
const nonRoutingWarnings = report.issues.filter(
|
||||
i => i.severity === "warning" && i.type !== "routing_miss",
|
||||
);
|
||||
const warnings = report.issues.filter(i => i.severity === "warning");
|
||||
expect(errors).toEqual([]);
|
||||
expect(nonRoutingWarnings).toEqual([]);
|
||||
expect(warnings).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -36,6 +36,16 @@ describe('CLI structure', () => {
|
||||
expect(cliSource).toContain("'files'");
|
||||
});
|
||||
|
||||
// v0.41.11 #1451 regression — `reindex` had a `case 'reindex':` handler
|
||||
// at src/cli.ts:1334 but was missing from CLI_ONLY, so the dispatcher
|
||||
// rejected `gbrain reindex` with "Unknown command: reindex" before the
|
||||
// handler ever ran. Cherry-picked from kylma-code-adjacent PR #1354.
|
||||
test('reindex is in CLI_ONLY (does not get "Unknown command")', () => {
|
||||
const onlyMatch = cliSource.match(/const CLI_ONLY = new Set\(\[([\s\S]*?)\]\)/);
|
||||
expect(onlyMatch).not.toBeNull();
|
||||
expect(onlyMatch![1]).toContain(`'reindex'`);
|
||||
});
|
||||
|
||||
test('has formatResult function for CLI output', () => {
|
||||
expect(cliSource).toContain('function formatResult');
|
||||
});
|
||||
|
||||
@@ -76,11 +76,17 @@ describe('checkResolvable merges resolver files', () => {
|
||||
skillsDir = join(workspace, 'skills');
|
||||
mkdirSync(skillsDir, { recursive: true });
|
||||
|
||||
// Create 3 skills on disk
|
||||
// Create 3 skills on disk WITHOUT frontmatter triggers — these
|
||||
// tests exercise the legacy RESOLVER.md-only authority path. The
|
||||
// v0.41.11 auto-registration path is covered by the test below
|
||||
// ("with frontmatter triggers only → all 3 reachable via
|
||||
// auto-registration").
|
||||
for (const name of ['alpha', 'beta', 'gamma']) {
|
||||
const skillDir = join(skillsDir, name);
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
writeFileSync(join(skillDir, 'SKILL.md'), `---\nname: ${name}\ntriggers:\n - "${name} trigger"\n---\n# ${name}\n`);
|
||||
// Name-only frontmatter; no triggers: so auto-registration is a
|
||||
// no-op for these skills and RESOLVER.md is the sole authority.
|
||||
writeFileSync(join(skillDir, 'SKILL.md'), `---\nname: ${name}\n---\n# ${name}\n`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -96,6 +102,30 @@ describe('checkResolvable merges resolver files', () => {
|
||||
rmSync(join(skillsDir, 'RESOLVER.md'));
|
||||
});
|
||||
|
||||
it('v0.41.11: skills with frontmatter triggers are auto-registered (no RESOLVER.md needed)', () => {
|
||||
// Rewrite the 3 skills with frontmatter triggers and assert
|
||||
// checkResolvable sees all 3 as reachable without ANY RESOLVER.md
|
||||
// or AGENTS.md present. This is the new structural contract:
|
||||
// frontmatter is authoritative.
|
||||
for (const name of ['alpha', 'beta', 'gamma']) {
|
||||
writeFileSync(
|
||||
join(skillsDir, name, 'SKILL.md'),
|
||||
`---\nname: ${name}\ntriggers:\n - "${name} trigger"\n---\n# ${name}\n`,
|
||||
);
|
||||
}
|
||||
const report = checkResolvable(skillsDir);
|
||||
expect(report.summary.reachable).toBe(3);
|
||||
expect(report.summary.unreachable).toBe(0);
|
||||
// Restore name-only frontmatter so subsequent tests in this
|
||||
// describe block see the legacy RESOLVER.md-only setup.
|
||||
for (const name of ['alpha', 'beta', 'gamma']) {
|
||||
writeFileSync(
|
||||
join(skillsDir, name, 'SKILL.md'),
|
||||
`---\nname: ${name}\n---\n# ${name}\n`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('with skills/RESOLVER.md (1 skill) + ../AGENTS.md (2 more) → all 3 reachable', () => {
|
||||
// Thin RESOLVER.md in skills dir (e.g. from skillpack)
|
||||
writeFileSync(join(skillsDir, 'RESOLVER.md'),
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
/**
|
||||
* Unit suite for `src/core/skill-trigger-index.ts` — the v0.41.11
|
||||
* shared primitive that unifies frontmatter-declared triggers with
|
||||
* curated RESOLVER.md / AGENTS.md rows.
|
||||
*
|
||||
* Hermetic: every case builds a fresh tempdir skills tree and runs the
|
||||
* loader against it. No PGLite, no env var mutation (R1/R2 from
|
||||
* CLAUDE.md test-isolation rules), no mock.module.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
FRONTMATTER_SECTION,
|
||||
_resetWarnedSkillsForTests,
|
||||
entriesToResolverContent,
|
||||
findPrimaryResolverPath,
|
||||
loadSkillTriggerIndex,
|
||||
} from '../src/core/skill-trigger-index.ts';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TEMPDIRS: string[] = [];
|
||||
|
||||
function makeSkillsDir(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'skill-trigger-index-'));
|
||||
const skillsDir = join(dir, 'skills');
|
||||
mkdirSync(skillsDir, { recursive: true });
|
||||
TEMPDIRS.push(dir);
|
||||
return skillsDir;
|
||||
}
|
||||
|
||||
function writeSkill(skillsDir: string, name: string, content: string): void {
|
||||
const dir = join(skillsDir, name);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, 'SKILL.md'), content);
|
||||
}
|
||||
|
||||
function skillWithTriggers(name: string, triggers: string[]): string {
|
||||
const triggerLines = triggers.map(t => ` - "${t}"`).join('\n');
|
||||
return `---
|
||||
name: ${name}
|
||||
description: Test skill ${name}.
|
||||
triggers:
|
||||
${triggerLines}
|
||||
---
|
||||
|
||||
# ${name}
|
||||
Test body.
|
||||
`;
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
for (const d of TEMPDIRS) {
|
||||
try {
|
||||
rmSync(d, { recursive: true, force: true });
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
_resetWarnedSkillsForTests();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Frontmatter auto-registration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('loadSkillTriggerIndex — frontmatter auto-registration', () => {
|
||||
test('skill with block-form triggers, no RESOLVER.md → frontmatter entries appear', () => {
|
||||
const skillsDir = makeSkillsDir();
|
||||
writeSkill(skillsDir, 'query', skillWithTriggers('query', ['what do we know', 'who is']));
|
||||
|
||||
const entries = loadSkillTriggerIndex(skillsDir);
|
||||
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[0]).toEqual({
|
||||
trigger: 'what do we know',
|
||||
skillPath: 'skills/query/SKILL.md',
|
||||
isGStack: false,
|
||||
section: FRONTMATTER_SECTION,
|
||||
source: 'frontmatter',
|
||||
});
|
||||
expect(entries[1].trigger).toBe('who is');
|
||||
expect(entries[1].source).toBe('frontmatter');
|
||||
});
|
||||
|
||||
test('skill with inline-form triggers (triggers: ["a", "b"]) parses the same', () => {
|
||||
const skillsDir = makeSkillsDir();
|
||||
writeSkill(skillsDir, 'lint', `---
|
||||
name: lint
|
||||
triggers: ["lint this", "audit pages"]
|
||||
---
|
||||
body
|
||||
`);
|
||||
|
||||
const entries = loadSkillTriggerIndex(skillsDir);
|
||||
const triggers = entries.map(e => e.trigger).sort();
|
||||
expect(triggers).toEqual(['audit pages', 'lint this']);
|
||||
expect(entries.every(e => e.source === 'frontmatter')).toBe(true);
|
||||
});
|
||||
|
||||
test('skill with no triggers: field → not registered, no error', () => {
|
||||
const skillsDir = makeSkillsDir();
|
||||
writeSkill(skillsDir, 'install', '# Deprecated skill — replaced by setup.\n\nNo frontmatter.\n');
|
||||
|
||||
const entries = loadSkillTriggerIndex(skillsDir);
|
||||
expect(entries).toEqual([]);
|
||||
});
|
||||
|
||||
test('skill with empty triggers: array → not registered', () => {
|
||||
const skillsDir = makeSkillsDir();
|
||||
writeSkill(skillsDir, 'empty', `---
|
||||
name: empty
|
||||
triggers: []
|
||||
---
|
||||
body
|
||||
`);
|
||||
|
||||
const entries = loadSkillTriggerIndex(skillsDir);
|
||||
expect(entries).toEqual([]);
|
||||
});
|
||||
|
||||
test('directory without SKILL.md → silently skipped', () => {
|
||||
const skillsDir = makeSkillsDir();
|
||||
// `install/` exists in the bundled tree but has no SKILL.md.
|
||||
mkdirSync(join(skillsDir, 'install'), { recursive: true });
|
||||
writeSkill(skillsDir, 'query', skillWithTriggers('query', ['what is']));
|
||||
|
||||
const entries = loadSkillTriggerIndex(skillsDir);
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].trigger).toBe('what is');
|
||||
});
|
||||
|
||||
test('_brain-filing-rules.md and conventions/ subdirs are ignored', () => {
|
||||
const skillsDir = makeSkillsDir();
|
||||
// Underscore-prefixed file at the top level (not a skill).
|
||||
writeFileSync(join(skillsDir, '_brain-filing-rules.md'), '# Rules\n');
|
||||
// Underscore-prefixed directory.
|
||||
mkdirSync(join(skillsDir, '_conventions-private'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(skillsDir, '_conventions-private', 'SKILL.md'),
|
||||
skillWithTriggers('_priv', ['should not appear']),
|
||||
);
|
||||
// conventions/ subtree.
|
||||
mkdirSync(join(skillsDir, 'conventions'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(skillsDir, 'conventions', 'SKILL.md'),
|
||||
skillWithTriggers('conv', ['should not appear either']),
|
||||
);
|
||||
// One real skill that SHOULD register.
|
||||
writeSkill(skillsDir, 'query', skillWithTriggers('query', ['what is']));
|
||||
|
||||
const entries = loadSkillTriggerIndex(skillsDir);
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].trigger).toBe('what is');
|
||||
});
|
||||
|
||||
test('non-directory entries in skillsDir → silently skipped', () => {
|
||||
const skillsDir = makeSkillsDir();
|
||||
writeFileSync(join(skillsDir, 'README.md'), '# skills/ index\n');
|
||||
writeSkill(skillsDir, 'query', skillWithTriggers('query', ['what is']));
|
||||
|
||||
const entries = loadSkillTriggerIndex(skillsDir);
|
||||
expect(entries).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('skillsDir does not exist → empty array, no throw', () => {
|
||||
const entries = loadSkillTriggerIndex('/tmp/does-not-exist-' + Date.now());
|
||||
expect(entries).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RESOLVER.md merge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('loadSkillTriggerIndex — RESOLVER.md merge (UNION semantics)', () => {
|
||||
test('skill with frontmatter AND RESOLVER.md row → both contribute; duplicate triggers collapse', () => {
|
||||
const skillsDir = makeSkillsDir();
|
||||
writeSkill(skillsDir, 'query', skillWithTriggers('query', ['what is', 'who is']));
|
||||
writeFileSync(
|
||||
join(skillsDir, 'RESOLVER.md'),
|
||||
`# Test resolver
|
||||
|
||||
## Brain operations
|
||||
|
||||
| trigger | skill |
|
||||
| --- | --- |
|
||||
| what is | \`skills/query/SKILL.md\` |
|
||||
| tell me about | \`skills/query/SKILL.md\` |
|
||||
`,
|
||||
);
|
||||
|
||||
const entries = loadSkillTriggerIndex(skillsDir);
|
||||
const triggers = entries.map(e => e.trigger).sort();
|
||||
// 'what is' present in BOTH surfaces → one entry (dedup keeps
|
||||
// frontmatter source since it's loaded first). 'who is' from
|
||||
// frontmatter only. 'tell me about' from RESOLVER.md only.
|
||||
expect(triggers).toEqual(['tell me about', 'what is', 'who is']);
|
||||
|
||||
const whatIs = entries.find(e => e.trigger === 'what is')!;
|
||||
expect(whatIs.source).toBe('frontmatter'); // first occurrence wins
|
||||
const tellMe = entries.find(e => e.trigger === 'tell me about')!;
|
||||
expect(tellMe.source).toBe('resolver_md');
|
||||
expect(tellMe.section).toBe('Brain operations');
|
||||
const whoIs = entries.find(e => e.trigger === 'who is')!;
|
||||
expect(whoIs.source).toBe('frontmatter');
|
||||
});
|
||||
|
||||
test('case-insensitive dedupe: "What Is" in frontmatter + "what is" in RESOLVER.md → one entry', () => {
|
||||
const skillsDir = makeSkillsDir();
|
||||
writeSkill(skillsDir, 'query', skillWithTriggers('query', ['What Is']));
|
||||
writeFileSync(
|
||||
join(skillsDir, 'RESOLVER.md'),
|
||||
`## Brain operations
|
||||
|
||||
| trigger | skill |
|
||||
| --- | --- |
|
||||
| what is | \`skills/query/SKILL.md\` |
|
||||
`,
|
||||
);
|
||||
|
||||
const entries = loadSkillTriggerIndex(skillsDir);
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].trigger).toBe('What Is'); // first-occurrence-wins preserves original casing
|
||||
expect(entries[0].source).toBe('frontmatter');
|
||||
});
|
||||
|
||||
test('AGENTS.md at workspace root (OpenClaw layout) is merged via parent-dir scan', () => {
|
||||
const skillsDir = makeSkillsDir();
|
||||
// Put AGENTS.md ONE LEVEL UP from skillsDir (parent of `skills/`).
|
||||
const workspaceRoot = join(skillsDir, '..');
|
||||
writeFileSync(
|
||||
join(workspaceRoot, 'AGENTS.md'),
|
||||
`## Operational
|
||||
|
||||
| trigger | skill |
|
||||
| --- | --- |
|
||||
| openclaw dispatch | \`skills/query/SKILL.md\` |
|
||||
`,
|
||||
);
|
||||
writeSkill(skillsDir, 'query', skillWithTriggers('query', ['what is']));
|
||||
|
||||
const entries = loadSkillTriggerIndex(skillsDir);
|
||||
const triggers = entries.map(e => e.trigger).sort();
|
||||
expect(triggers).toEqual(['openclaw dispatch', 'what is']);
|
||||
});
|
||||
|
||||
test('skill with RESOLVER.md row but NO frontmatter triggers → still registered from RESOLVER.md', () => {
|
||||
const skillsDir = makeSkillsDir();
|
||||
// install/ is the canonical deprecated-skill shape (no frontmatter).
|
||||
mkdirSync(join(skillsDir, 'install'), { recursive: true });
|
||||
writeFileSync(join(skillsDir, 'install', 'SKILL.md'), '# Install (Deprecated)\n');
|
||||
writeFileSync(
|
||||
join(skillsDir, 'RESOLVER.md'),
|
||||
`## Setup
|
||||
|
||||
| trigger | skill |
|
||||
| --- | --- |
|
||||
| install gbrain | \`skills/install/SKILL.md\` |
|
||||
`,
|
||||
);
|
||||
|
||||
const entries = loadSkillTriggerIndex(skillsDir);
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].source).toBe('resolver_md');
|
||||
expect(entries[0].trigger).toBe('install gbrain');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// entriesToResolverContent (synthesis for runRoutingEval compat)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('entriesToResolverContent', () => {
|
||||
test('synthesized markdown is re-parseable by parseResolverEntries', async () => {
|
||||
const { parseResolverEntries } = await import('../src/core/check-resolvable.ts');
|
||||
const skillsDir = makeSkillsDir();
|
||||
writeSkill(skillsDir, 'query', skillWithTriggers('query', ['what is', 'tell me about']));
|
||||
|
||||
const entries = loadSkillTriggerIndex(skillsDir);
|
||||
const synthesized = entriesToResolverContent(entries);
|
||||
const reparsed = parseResolverEntries(synthesized);
|
||||
|
||||
// Same skill paths and trigger strings (modulo source field).
|
||||
expect(reparsed.map(e => e.trigger).sort()).toEqual(['tell me about', 'what is']);
|
||||
expect(reparsed.every(e => e.skillPath === 'skills/query/SKILL.md')).toBe(true);
|
||||
});
|
||||
|
||||
test('pipes inside trigger strings are escaped in synthesis (defensive)', () => {
|
||||
// Defense-in-depth: the synthesizer escapes `|` so it can't break
|
||||
// the markdown table row. parseResolverEntries does not currently
|
||||
// unescape (its pipe-split doesn't honor backslashes), so a
|
||||
// pipe-bearing trigger is lost on re-parse — but it ALSO doesn't
|
||||
// corrupt neighboring rows, which is the actual risk we're hedging
|
||||
// against. Real-world triggers don't contain `|` (it'd be unusual
|
||||
// natural language). Filed as v0.42+ TODO if we ever start
|
||||
// declaring pipe-bearing triggers.
|
||||
const synthesized = entriesToResolverContent([
|
||||
{
|
||||
trigger: 'option a | option b',
|
||||
skillPath: 'skills/x/SKILL.md',
|
||||
isGStack: false,
|
||||
section: FRONTMATTER_SECTION,
|
||||
source: 'frontmatter',
|
||||
},
|
||||
]);
|
||||
expect(synthesized).toContain('option a \\| option b');
|
||||
});
|
||||
|
||||
test('GStack/external entries re-emit with prose skillPath (no backticks)', async () => {
|
||||
const { parseResolverEntries } = await import('../src/core/check-resolvable.ts');
|
||||
const synthesized = entriesToResolverContent([
|
||||
{
|
||||
trigger: 'review this plan',
|
||||
skillPath: 'GStack: ceo-review',
|
||||
isGStack: true,
|
||||
section: 'Thinking',
|
||||
source: 'resolver_md',
|
||||
},
|
||||
]);
|
||||
const reparsed = parseResolverEntries(synthesized);
|
||||
expect(reparsed).toHaveLength(1);
|
||||
expect(reparsed[0].isGStack).toBe(true);
|
||||
expect(reparsed[0].skillPath).toBe('GStack: ceo-review');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// findPrimaryResolverPath
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('findPrimaryResolverPath', () => {
|
||||
test('returns RESOLVER.md path when present in skillsDir', () => {
|
||||
const skillsDir = makeSkillsDir();
|
||||
const resolverPath = join(skillsDir, 'RESOLVER.md');
|
||||
writeFileSync(resolverPath, '# resolver\n');
|
||||
|
||||
expect(findPrimaryResolverPath(skillsDir)).toBe(resolverPath);
|
||||
});
|
||||
|
||||
test('returns AGENTS.md path when only that exists', () => {
|
||||
const skillsDir = makeSkillsDir();
|
||||
const agentsPath = join(skillsDir, 'AGENTS.md');
|
||||
writeFileSync(agentsPath, '# agents\n');
|
||||
|
||||
expect(findPrimaryResolverPath(skillsDir)).toBe(agentsPath);
|
||||
});
|
||||
|
||||
test('returns null when neither exists in skillsDir or parent', () => {
|
||||
const skillsDir = makeSkillsDir();
|
||||
expect(findPrimaryResolverPath(skillsDir)).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user