feat(skills): vendor-neutral library layout — per-source skills root (trinity-enterprise#332) - #1998
Conversation
…bilityai/trinity-enterprise#332) The skills-library source layout was hardcoded to `.claude/skills/`, so the public community catalog (abilityai/trinity-skills, ent#296) — which already ships a vendor-neutral top-level `skills/` layout plus a root `catalog.yaml` declaring `skills_root: skills/` — synced to zero skills. The SKILL.md format is harness-portable; only the *discovery path* is Claude-specific. Deliberately reopens ent#237's "root not configurable per source" call: the repo is untagged with zero syncing instances today, so this is the cheap moment — after the first `v*` tag it becomes a breaking migration. Resolution is per source, in `SkillSourceClone.skills_rel_root()` (lazily cached; instances are per-operation, so no staleness across syncs): catalog.yaml `skills_root:` → evidence-gated `skills/` probe → `.claude/skills/` Every invalid tier falls THROUGH to the next rather than blanking the source: an unusable catalog (unparseable, alias-bearing, non-mapping, unknown `schema_version`, oversized, symlinked, invalid value) degrades to the probe, and a symlinked or clone-escaping declared root does too. Only the final tier escaping containment yields an empty listing — None/empty-propagated through `skill_dir`/`skill_names`, never a raise through `list_skills`. A source serving skills today cannot be blanked by a tier it does not use. The agent-side destination stays `~/.claude/skills/<name>/`, and `filter_skill_archive(source_root=…)` is the ONE point where source layout becomes destination: it rewrites tar arcnames to `.claude/skills/<name>/…` (identity for the legacy layout). That placement is required, not merely tidy — `executable_paths` → the agent-side chmod list, the restore sent-vs-restored accounting, the persisted manifest, and `_legacy_fallback`'s SKILL.md lookup all key on post-filter arcnames. Consequence: manifests, prune/removal confinement, per-skill `.gitignore` lines, the restore `paths` allowlist and the whole ent#236 removal machinery stay destination-canonical with ZERO migration, and installed packages keep pruning correctly across a repo restructure. Three guards on the author-controlled value, each catching what the others structurally cannot (see docs/memory/learnings.md): - Segment-wise validation — split on `/`, reject any segment that is empty, `.` or `..`. A whole-string charset regex admits `.`, `./skills` and `skills//x`, each of which breaks archive-prefix math into per-skill "empty package" failures. A leading `-` is refused so the value can never read as a git option (the `--` separators at every pathspec are the belt to this suspenders). - lstat-order symlink refusal on catalog.yaml and on both candidate roots, with a bounded `read(cap+1)` — a post-read length check is defeated by `catalog.yaml -> /dev/zero`, and git materializes author symlinks. A symlinked `skills/` would list fine but yield empty `git archive` output from HEAD, failing every injection. - realpath containment against the REALPATH'd base — `/data/skills-library` may itself be a symlink, so comparing a resolved target to an unresolved base would refuse every source spuriously. Parsing goes through the shared ent#314 hardened loader (`AliasPolicy.REJECT`, 64 KB cap), catching `HardenedYamlError` explicitly alongside `yaml.YAMLError` — it is a ValueError, not a YAMLError, and missing it would escape through `list_skills` and 500 the merged listing. `schema_version` gates the whole catalog (absent or `1` — int and string both tolerated); anything else degrades to the probe rather than being silently misread, which keeps a future schema bump safe. Platform parses only these two keys; `categories`/`providers` stay catalog metadata for UIs. Dual-layout guard: SKILL.md evidence under BOTH roots with no catalog to decide keeps `.claude/skills/` and flags `layout_conflict` in status. A pre-existing dual-layout source must never silently flip *which executable content* the ent#236 unattended auto-sync injects fleet-wide; switching requires the explicit declaration. Also: per-source `skills_root` + `layout_conflict` in library status (null until cloned, honest rather than guessed), honest per-source `path` provenance, and the now-stale operator copy in SkillSourcesPanel. 52 new tests. Independently reviewed by strategy + engineering agents and a second-opinion model; `/cso --diff` found zero findings introduced by this change (report under docs/security-reports/; one pre-existing inherited disclosure filed separately as Abilityai/trinity-enterprise#334). Refs Abilityai/trinity-enterprise#237, Abilityai/trinity-enterprise#296 Fixes Abilityai/trinity-enterprise#332 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
dolho
left a comment
There was a problem hiding this comment.
/review Report
Branch: feature/ent-332-per-source-skills-root → feature/ent-237-multi-source-skills (merge-base 0bd6a8c5)
Files Changed: 12 (+1162/-30)
Scope: CLEAN
Plan Completion: 5 done / 0 partial / 0 not done / 0 changed / 0 unverifiable
Verified locally rather than taken on trust (see I1 — CI did not run the suite):
tests/unit/test_ent332_skills_root.py .................... 52 passed in 3.92s
tests/unit/test_ent236_skills_lifecycle.py ............... 64 passed
tests/unit/test_ent237_skill_sources.py + test_ent183_skill_packages.py + backward_compat + user_agent — green
I also empirically checked the one silent-semantics change in the diff — git ls-tree -z HEAD -- "skills/" vs the previous no--- form — on a scratch repo. Identical output, tree entries listed as before. tree_shas() is unaffected.
Plan completion (against trinity-enterprise#332 AC)
| AC | Verdict | Evidence |
|---|---|---|
Root resolution catalog.yaml → skills/ probe → .claude/skills/, zero-config back-compat |
DONE | skill_source_clone.py:_resolve_rel_root + test_legacy_layout_needs_zero_config |
Every path honors the resolved root (walk, tree-SHA, packaging, _skill_dir containment) |
DONE | skill_names/tree_shas/archive_skill/skill_dir all derive from skills_rel_root(); service-side access funnels through clone.skill_dir (skill_service.py:735,748,840,1388) and clone.skill_names (214,693) — no residual hardcoded source root, verified by grep (remaining .claude/skills hits are all agent-side destination, which is the point) |
| Root resolved independently per source | DONE | instance-scoped _rel_root; instances are per-operation |
| Lands on the ent#237 branch before it merges | DONE | base branch is correct |
| Tests cover probe order, override, fallback | DONE | 52 tests |
Critical Findings (block merge)
None. The three-guard structure (segment-wise validation / lstat-before-open with bounded read(cap+1) / realpath containment against the realpath'd base) is the right decomposition, and I could not construct a member name under any accepted source_root that escapes dest_prefix: filter_skill_archive requires name.startswith(src_prefix), rejects "/../" on the full name before slicing, and the rewrite is dest_prefix + rel where rel is the vetted remainder (skill_packaging.py:261-282). HardenedYamlError being caught explicitly alongside yaml.YAMLError is the right call — it is a ValueError and would otherwise 500 the merged listing.
Informational Findings
[I1] Test Gaps: no Python test or CodeQL job ran on this PR at all (Confidence: 10/10)
File: .github/workflows/backend-unit-test.yml:19-22, .github/workflows/codeql.yml:10-13
Evidence:
on:
pull_request:
types: [opened, synchronize, reopened]
branches: [dev, main]Issue: both gates are branch-filtered to dev/main. This PR targets feature/ent-237-multi-source-skills, so gh pr checks 1998 shows build / e2e / gitleaks / guard / prod-image-smoke / schema-parity / verify-* and no pytest, no CodeQL — 1162 lines including a new YAML-parsing and path-resolution surface merged with zero automated Python coverage in CI. The test plan's "7179 passed" is a local claim the merge gate never reproduced.
This is structural, not your mistake — but the consequence lands on someone: the first CI run of this code happens on #1901's PR, where a failure gets attributed to ent#237. Suggestion: either note in the description that the ent#237 PR's run is the real gate (and re-check it after this merges up), or extend the two workflows' branch filters to feature/**. I'd favor the latter as a small separate PR — stacked PRs are going to keep happening.
[I2] Product Quality Bar: layout_conflict is API-only — nothing renders it (Confidence: 9/10)
File: src/backend/services/skill_service.py:900-901, src/frontend/src/components/SkillSourcesPanel.vue
Evidence:
"skills_root": clone.skills_rel_root() if cloned else None,
"layout_conflict": bool(clone.dual_layout) if cloned else False,grep -rn "layout_conflict\|skills_root" src/frontend/src/ → no matches. The Vue change in this diff is three lines of descriptive copy only.
Issue: the dual-layout case is precisely the one you argue must not resolve silently — "a pre-existing dual-layout source must never silently flip which executable content the ent#236 auto-sync injects fleet-wide". Today it resolves silently to the operator: the decision is a logger.warning plus a boolean in a status payload no UI consumes. An operator who restructures their repo and expects skills/ to win gets .claude/skills/ with no visible signal, and the fix (declare skills_root) is undiscoverable from the screen.
Suggestion: render both fields in SkillSourcesPanel — resolved root as a chip on each source row, and a warning badge when layout_conflict with the "declare skills_root in catalog.yaml to switch" remedy. Small, and it's the difference between a designed fallback and a silent one. If you'd rather not touch that file while #1877 is in flight (same reasoning you applied on #1901), a follow-up issue is fine — but please file it rather than leaving the field unread.
[I3] Consistency: layout resolution reads the working tree, packaging reads HEAD (Confidence: 7/10)
File: src/backend/services/skill_source_clone.py:_declared_root, _probe_skills_layout vs archive_skill/tree_shas
Issue: catalog.yaml and the skills/ evidence probe are filesystem reads of the checkout; git archive HEAD and ls-tree HEAD read the committed tree. For platform-managed clones these agree, and reset --hard / checkout --force keep them agreeing for tracked content. The gap is untracked content: an untracked catalog.yaml or skills/ surviving in the clone dir would redirect resolution to a root that yields an empty archive, i.e. every skill in that source injects as an empty package rather than failing loudly.
Nothing writes untracked files into these directories today, so this is a latent inconsistency, not a live bug — which is why it's informational. Worth one sentence in the skills_rel_root docstring recording that resolution is working-tree-sourced deliberately, so a future change that starts writing into clone dirs knows what it's stepping on.
Clean Categories
- SQL & data safety — no SQL, no schema, no migration in the diff
- Auth boundaries — no new endpoints;
get_library_statusis unchanged in its gating - Credential exposure — new log lines carry
source_id, an OSError, or the declared root string; no values.redact()untouched - Race conditions —
_rel_root/dual_layoutare instance state on per-operation clones (skill_service._clonesconstructs fresh ones per call); no cross-worker or cross-call sharing - Enum completeness — no new enum/status value
- Error handling — every resolution tier falls through rather than raising;
skills_root() -> Optional[str]is None-propagated at both consumers (skill_dir,skill_names), solist_skillscannot TypeError - Docs staleness — architecture, requirements §21.1.4, feature-flow and the learnings ledger are all updated in-diff; the learnings entry on path-shaped author config is a genuinely reusable one
Merge-order note (not a finding against this PR)
The base branch #1901 currently carries four unresolved change requests from my review, two of which live in skill_source_clone.py — the file this PR extends: editing a source's url never repoints origin (silently keeps syncing the old repo), and bumping ref to a new tag is refused as moved_tag because last_commit_sha is never cleared. Neither is touched or worsened here, but both need fixing in the same file, so the stack should resolve #1901 first rather than rebasing this on top of a moving fix.
Summary
- Critical: 0 — none found
- Informational: 3 — I1 (CI blind spot) is the one I'd act on before this merges upward
- Scope: clean
Nice work on the _refuse_moved_pin_after_clone interaction — the fresh-clone tier of the pin is the one the ledger entry above says gets missed, and this diff keeps it intact through the root change.
(Skill Step 7 note: no new durable bug class to add — the author's own learnings entry already covers the path-validation lesson. The stacked-PR CI gap is a process finding; I'm leaving it in this review rather than committing to learnings.md from an unrelated working branch.)
Summary
catalog.yamlskills_root:→ evidence-gatedskills/probe →.claude/skills/fallback. Existing sources keep working with zero config.abilityai/trinity-skills, ent#296), which already shipsskills/<name>/+ a rootcatalog.yamland currently syncs to zero skills.filter_skill_archiverewrites tar arcnames to the canonical.claude/skills/<name>/…, so manifests, prune/removal confinement, gitignore lines and the whole ent#236 machinery stay destination-canonical with zero migration.Design
SkillSourceClone.skills_rel_root()owns resolution (per source by construction; lazily cached — instances are per-operation, so no staleness across syncs). Every invalid tier falls through to the next rather than blanking the source; only the final tier escaping containment yields an empty listing, None/empty-propagated solist_skillsnever raises.filter_skill_archive(source_root=…)is the ONE point where source layout becomes agent destination. That placement is required, not tidy:executable_paths→ the chmod list, the restore sent-vs-restored accounting, the persisted manifest, and_legacy_fallback's SKILL.md lookup all key on post-filter arcnames.Three independent guards on the author-controlled value, each catching what the others structurally cannot:
./..segments).,./skills,skills//x— each breaks prefix math into per-skill "empty package" failuresread(cap+1)catalog.yaml -> /dev/zero; a symlinkedskills/lists fine but yields emptygit archiveoutput from HEAD/data/skills-librarymay itself be a symlink — comparing against an unresolved base refuses every source spuriouslyParsing uses the shared ent#314 hardened loader (
AliasPolicy.REJECT, 64 KB cap), catchingHardenedYamlErrorexplicitly alongsideyaml.YAMLError(it is a ValueError — missing it would 500 the merged listing).schema_versiongates the whole catalog so a future bump degrades instead of being misread.Dual-layout guard: evidence under BOTH roots with no catalog to decide keeps
.claude/skills/and flagslayout_conflict. A pre-existing dual-layout source must never silently flip which executable content the ent#236 unattended auto-sync pushes fleet-wide.Changes
services/skill_source_clone.pyskills_rel_root()+ resolution tiers,validate_declared_root,skills_root() -> Optional[str], all five path/git reads derive from the resolved root (--on every pathspec)services/skill_packaging.pyfilter_skill_archive(source_root=…)+ arcname rewrite; defensive re-validation (ValueErroron unsafe roots)services/skill_service.pypathprovenance,skills_root/layout_conflictin library statuscomponents/SkillSourcesPanel.vue.claude/skills/…was stated as the convention)docs/Test Plan
pytest tests/unit/test_ent332_skills_root.py -v— 52 new tests: resolution order, zero-config back-compat, dual-layout, every bad-catalog shape, symlink/containment tiers, cross-layout version parity, arcname rewrite + prefix-escape resistance,_legacy_fallbackwith rewritten members, end-to-end mixed-layout mergeReview
/autoplan) — both independently found the same hole in the plan's stated validation (skills_root: "."was claimed rejected and wasn't); fixed with segment-wise validation before implementation.schema_version, realpath base, None-propagation, symlink fail-safe) are all folded in./cso --diff— zero findings introduced by this change. Actively verified: no member name under anysource_rootcan escape the destination prefix; every unsafe root raises. Report:docs/security-reports/cso-diff-2026-08-04-ent332-skills-root.{json,md}.dev, unrelated to this diff) was surfaced during the audit and filed separately as trinity-enterprise#334 rather than scope-creeping this PR.Fixes abilityai/trinity-enterprise#332
Refs abilityai/trinity-enterprise#237, abilityai/trinity-enterprise#296
🤖 Generated with Claude Code