You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Umbrella tracking issue for #126 and #127. This is the single design home for all skill-resource link/orphan validation work, delivered as three sequential slices rather than one PR (the three slices touch the same link-validation surface, so they share a lineage, not parallel branches):
Slice 3 = the engine refactor (pure verdict functions, RuleContext, single-source matrix, scenario harness, orphan/HTML parity) — the remaining direct scope of this issue (ACs 1–9), built on top of slices 1–2 with their local_directory classification and wired deferredPaths as inputs, not co-changes.
Sequencing rationale. The order is logically flexible (the engine does not strictly depend on #126/#127), but slices 1–2 ship first because they fix live adopter pain with small, low-risk PRs, while the engine is the largest piece and still carries open design questions (see "Open design questions" at the bottom). Don't put the least-settled, highest-risk slice in front of two ready fixes.
#126 and #127 are reopened and recast as slices 1–2 (numbers preserved — design history kept, no churn). They are not "closed into" this issue.
VAT decides "is this skill resource OK?" (orphan? broken link? outside the skill boundary?) across three code paths that have diverged, and none of them encodes the intent behind a file's inclusion. The result: vat audit and vat build disagree, source HTML is invisible to audit, the sanctioned files: mechanism doesn't actually silence the error it's meant to resolve, and several fix messages can nudge authors toward workarounds (copying files into dist, suppressing) instead of the right action.
This issue proposes refactoring skill-resource checks into pure, intent-aware verdict functions driven by an explicit context, unified behind one engine, documented by a single-source matrix (registry == docs == runtime), and proven by a DRY scenario-test harness with automatic aliasing detection.
Built — post-build-checks.ts (checkUnreferencedFiles over walkDir(dist) = only physically-copied files; HTML-aware). → PACKAGED_UNREFERENCED_FILE, PACKAGED_BROKEN_LINK.
Live source/audit — validateSkillForPackaging (packaging-validator.ts), run by vat audit / vat skills validate on configured skills. Crawls **/*.md only (:189); detects markdown broken links (LINK_MISSING_TARGET); emits no orphan code; never parses HTML.
Wild fallback — skill-validator.tsdetectUnreferencedFiles, only when no config and--warn-unreferenced-files. **/*.md only; SKILL_UNREFERENCED_FILE (info).
Genuine gaps
Orphan detection missing on the live path. A skill green under vat audit can carry orphans the build later flags.
Source HTML blind on the audit/validate paths (md-only crawl).
The build only sees what it copied (skill-packager.tsrms dist ~:400, then copies link-reachable + files: entries). A merely-unlinked source file is never copied, so path 1 never sees it either.
Deferred-artifact handling is built but unwired.computeDeferredPaths (files-config.ts:134) and walk-link-graph's deferredPaths (:98,:291) exist and are unit-tested, but neither skill-packager nor validateSkillForPackaging passes deferredPaths. So a SKILL.md link to a files:-declared-but-not-yet-built artifact still fires LINK_TO_GITIGNORED_FILE (gitignored dest) / LINK_BROKEN_FILE (no ignore match). This is where the dist-copy/suppress workaround is born — the sanctioned escape doesn't yet silence the error. (Detailed in "Deferred build artifacts (absorbed from Slice 2 of #129: wire deferredPaths + close the files: gitignore-copy leak #127)" below.)
Design
Base layer vs skill overlay
The base resources layer answers "does this link resolve?". The agent-skills layer overlays the contract that supplies intent: a single entry point (SKILL.md) read by an agent under a budget; self-containment (must ship and work for installers); a build transform (dist wiped+regenerated, links rewritten, artifacts deferred); an LLM consumer (mention-vs-link matters); a per-part role.
Intent is edge-scoped and per-skill
A resource may be included by multiple skills / a plugin. Intent attaches to the inclusion edge (the copy)(source→dest, in context C) as a single value, never on the source (which would force a lossy set that can't pair role↔context). Orphan-ness is per skill; a non-skill inbound link does not rescue a skill-internal orphan. The cross-cutting set is a derived aggregation.
FileCopyRole — call-site-derived, on the edge
Author config stays {source, dest}. The engine stamps a closed enum on each copy operation, derived from the call site (skill packaging vs marketplace). SkillFileEntrySchema is .strict() and shared with the marketplace plugin entry (project-config.ts:136,161,203), so the role must not be added there — it lives on the runtime copy record only.
Phase 1:'skill-bundled' | 'plugin-artifact' (the layer distinction — never apply skill expectations to a plugin artifact).
verdict(ctx: RuleContext) → Issue | null, with description/fix/defaultSeverity sourced from CODE_REGISTRY. A thin extraction front end derives RuleContext from real inputs; verdict functions never touch the filesystem.
RuleContext={scope: 'base-resource'|'skill'|'plugin'fileKind: 'doc'|'executable'|'asset'|'data'|'schema'|'nav'|'directory'|'unknown'reachableFromSkillMd: boolean
referencedHow: 'link' |'mention'|'none'copyRole?: FileCopyRole// single value, on the edgeinFilesConfig: boolean
existsAtSource: boolean
looksBuildProduced: boolean
insideSkillDir: boolean
}
Vocabulary note (directory links).RuleContext.fileKind: 'directory' is the post-stat realization of #126's source-level local_directory link shape: the parser assigns local_directory without I/O for refs ending in /, and once any local ref resolves to a directory the engine treats it as fileKind: 'directory'. Whether a directory target is an error is decided entirely by the edge intent — #126's "typed single-file slot" is exactly the edge whose copyRole/contract demands a single file (e.g. a packaging files: source entry); a navigational edge with no single-file contract treats a directory as a valid, existence-checked target. The two issues describe one model: local_directory / "typed single-file slot" ≡ fileKind: 'directory' / edge-scoped intent.
The matrix (spine of spec, docs, tests)
A single-source-of-truth rule catalog. CODE_REGISTRY.entry() holds {defaultSeverity, description, fix, reference} (no message field — runtime message is templated from description + per-issue context). The doc catalog's columns are asserted equal to the registry's description + fix + defaultSeverity; runtime message is treated as dynamic. Builds on the existing docs-completeness test (agent-skills/test/docs/validation-codes.test.ts), tightening it from anchor-presence to description/fix equality. The long "why / when fine / how to ignore" lives once behind reference.
Two views: (1) rule catalog — code | fires-when | means | fix-headline | severity | ships-now-vs-deferred; (2) disambiguation map — symptom (broken⇄orphan) × intent (build-artifact / runtime-asset / forgotten-doc / typo / stale / leaves-bundle) → the catalog row. View 2 names the broken⇄orphan oscillation and shows the files: edge (once deferredPaths is wired) as the resolving state.
Behavior
Orphan branches by fileKind: executable → must be linked (real signal); asset → declare in files: (we cannot infer runtime-loading without code parsing — declaration is the resolution); doc → link or remove. The uncatchable case (asset loaded by a script but neither linked nor declared) is documented as a known limit, not faked.
Broken-link headline = "copies into the bundle at build"; deferral detail lives behind reference.
Anti-workaround invariant: every error/warning fix names a sanctioned action before any "ignore/allow" — enforced by a registry meta-test.
Scenario test harness (DRY, exhaustive, alias-detecting)
A core unit harness drives the real verdict functions with constructed RuleContext (no filesystem):
Table of { intent, ctx: Partial<RuleContext>, expect }, one-line deltas, one it.each runner.
Per row asserts code === expect and description/fix/defaultSeverity === registry.
Aliasing detector: group by serialized RuleContext; if one signature maps to >1 distinct expect, fail and name both intents → resolve via compound message or a new discriminating context field.
Plus extraction integration tests proving extract(realDir) → RuleContext is faithful, including normalization parity (percent-encoding / case / leading-slash) between paths 1 and 2, which normalize differently today.
Engine unification
One orphan/link engine consumed by the built path and the livevalidateSkillForPackaging (not the rarely-run detectUnreferencedFiles); widen the **/*.md-only crawl (packaging-validator.ts:189) to .html/.htm + non-doc orphans; remove duplicated logic (duplication-check green, baseline untouched).
Originally filed as #127: "vat skills validate: computeDeferredPaths() is exported but never consumed — links to files:-declared build artifacts fail with LINK_TO_GITIGNORED_FILE." This is #129's AC #3, stated here as one reconciled requirement. The codes below are the authoritative current behavior — the body above formerly said LINK_MISSING_TARGET for this case, which was stale.
Problem
When a skill bundles a build-generated artifact via the files: packaging config and links to it from SKILL.md, vat skills validate reports a hard error (LINK_TO_GITIGNORED_FILE, or LINK_BROKEN_FILE when no ignore rule matches the path) at source-validation time — even though the files: entry declares exactly that artifact and the build will materialize it before distribution.
The helper that exists to prevent this, computeDeferredPaths(), is implemented and exported but is consumed by nothing in the validate or build link-resolution paths. It is effectively dead code, so the "deferred path" concept it encodes never takes effect.
A skill trail-conditions ships a generated lookup file produced by a sibling build step. The artifact is a build output (gitignored) and is copied into the skill at package time via files:.
vibe-agent-toolkit.config.yaml:
skills:
include: ["skills/**/SKILL.md"]config:
trail-conditions:
files:
- source: build/generated/trail-index.json # emitted by `npm run gen`; gitignored build outputdest: data/trail-index.json # materialized into the skill at build time
skills/trail-conditions/SKILL.md:
See the [trail index](data/trail-index.json) for the station-to-segment map.
build/generated/ is listed in .gitignore, and data/trail-index.json does not exist in the source tree — it is only produced when vat skills build runs the files: copy.
Run:
vat skills validate
Actual
LINK_TO_GITIGNORED_FILE — Markdown link targets a gitignored file;
risks leaking ignored data into the bundle. (link: data/trail-index.json)
(If no ignore rule matches the resolved dest, the same link fails instead as LINK_BROKEN_FILE — File not found.)
The only way to make the skill green today is a per-path validation.allow entry for every such link — which defeats the purpose of declaring the artifact in files: and silences a check that should simply understand the artifact is deferred.
Expected
A link whose target is declared in the skill's files: config — matching either the dest or the build-artifact source — should be treated as a deferred path ("will exist after build") and skip the LINK_TO_GITIGNORED_FILE / LINK_BROKEN_FILE checks. This is exactly what computeDeferredPaths() was written to express.
Root cause
computeDeferredPaths(files) is defined and exported in @vibe-agent-toolkit/agent-skills (files-config.ts:134 / dist/files-config.js), and its own doc comment states it returns "the set of paths that should be treated as 'deferred' during source-time validation … source may be a build artifact that doesn't exist at validation time; dest is the target location that won't exist until build time."
But a workspace-wide search finds no call sites — it appears only at its definition and the index re-export (index.ts:38). walk-link-graph's deferredPaths option (:98, used :291) is likewise never supplied. The skills validate flow (@vibe-agent-toolkit/cli, commands/skills/validate.js) builds the validation context and runs the link validator (@vibe-agent-toolkit/resources, link-validator.js) without ever computing or passing deferred paths. So the validator resolves the target against the working tree, finds it absent/ignored, and emits LINK_TO_GITIGNORED_FILE / LINK_BROKEN_FILE.
Suggested fix
In the skills validate flow, call computeDeferredPaths(mergedFilesConfig) per skill and thread the result into the link-validation options.
In link-validator.js, when a resolved local target is in the deferred set, suppress LINK_TO_GITIGNORED_FILE and LINK_BROKEN_FILE for that link (optionally downgrade to an info/notice so it is still visible).
Apply the same in vat skills build so build-time link rewriting and link validation agree.
Related, lower priority (separate consideration)
Plugin-local skills distributed via verbatim tree-copy (vat claude plugin build) currently have no files: surface of their own: plugin-level files[].dest rejects skills/… ("that surface is owned by the skill-stream"), and tree-copied skills never pass through packageSkill. So a skill that ships build-injected artifacts via the self-contained plugin layout has no first-class way to declare those artifacts for validation at all. Fixing (1)–(3) helps skills built through the packaging path; please also consider whether tree-copied plugin-local skills should be able to declare files: deferred paths so their injected artifacts can be validated and link-rewritten the same way.
Originally filed as #126: "Directory links should be valid targets; narrow LINK_TARGETS_DIRECTORY to typed single-file slots." A fully-specced, independently-shippable redesign of the directory-link rule, preserved in full below. Vocabulary is unified with this issue's model — see the Vocabulary note in Design above: #126's local_directory ≡ RuleContext.fileKind: 'directory', and #126's "typed single-file slot" ≡ the edge whose copyRole/contract demands a single file.
Problem
VAT's link validation treats any local link that resolves to a directory as a hard error. In adoption this produces false positives for legitimate, GitHub-renderable links and pushes authors into awkward workarounds — repointing [docs/](docs/) to dodge the error, or writing [docs/](docs/README.md) to manually perform the index resolution GitHub does automatically.
This is wrong in principle: a directory is a legitimate link target. In a filesystem a directory is a real, existing referent (in Unix, literally a file); on GitHub a directory link renders as a navigable tree. "Docs live in docs/" in a ToC is a valid statement, and if docs/ is renamed/deleted the link should fail — but that's already caught by ordinary existence checking, not a directory rule.
The rule isn't wrong in spirit — it's mis-scoped. A directory is only a problem for a reference that is contractually a single file (a typed slot opened or copied as one file). It leaked into the general link path, which only checks that links resolve and has no business judging directory-ness.
26367e8b6 (feat: canonical root model + RFC 3986 §4.2 leading-/ URI resolution #105, 2026-05-17, canonical root model + RFC 3986 §4.2 leading-/): added the isDirectory check to the resources link-validator. Commit body: "Links resolving to an existing directory … previously passed silently. They now surface as broken_file … trailing-slash hrefs and directory-ish paths were a quiet footgun."
Documented rationale (docs/validation-codes.md): "agents and renderers cannot load a directory as content." True only for a reference that is contractually a single file; false for a navigational reference.
Desired-state principle
A link resolving to a directory is a valid target. It is an error only for a reference that is contractually a single file (a typed slot) and resolves to a directory.
The discriminator is reference type, not which command runs:
Reference type
Examples
Directory target
Untyped / navigational
Markdown [..](..), HTML href/src — prose links in docs, READMEs, SKILL.md bodies
Valid. Existence-checked only; a resolved directory passes (no error, no warning), in every command (vat resources validateand the skill-bundling link walk).
Typed single-file slot
A packaging files: source entry, a schema/template path, any config field declared to be exactly one file
Resolving to a directory → LINK_TARGETS_DIRECTORY (error) — the contract demanded a file.
Corollaries:
Directory-ness is never itself the defect; the defect is a single-file contract receiving a directory.
The trailing slash (docs/ vs docs) is a diagnostic hint only — never a correctness gate. It informs classification/messages; it never changes pass/fail. Authors are not required to add a slash to make a directory link valid.
Directory determination is filesystem-only. External / server-resolved URLs are judged solely by HTTP status (or linkAuth), never by a directory rule — a folder-shaped URL (https://x.com/docs/) has no client-determinable meaning.
What ships in a bundle is a packaging-config concern, separate from link validation. A navigational link to assets/ is valid because assets/ exists; whether its contents are packaged is governed by packaging globs / files, not the prose link. Git doesn't track empty directories, so an "empty directory" doesn't exist in a clean checkout/bundle and is caught by the ordinary target-missing path.
Divergences to fix (one comprehensive change)
D1 — Wrong code (bug).packages/resources/src/link-validator.ts (~L249) emits LINK_BROKEN_FILE with message "Link target is a directory" — mislabeled (dedicated LINK_TARGETS_DIRECTORY exists) and reads to adopters like "the file doesn't exist."
D2 — Over-broad error (core). Untyped/navigational links must not error on a resolved directory at all. Remove the directory-error branch from the resources link-validator.
D3 — LINK_TARGETS_DIRECTORY over-applied. The skill-bundling link walk (walk-link-graph → walker-to-issues, where 'directory-target' → 'LINK_TARGETS_DIRECTORY' at walker-to-issues.ts:12) currently maps a directory-target prose link to LINK_TARGETS_DIRECTORY. These prose links are navigational — stop flagging them. The code must fire only for typed single-file slots (e.g. a packaging files: source entry) resolving to a directory.
D4 — Missing local_directory classification (enhancement).classifyLink (packages/resources/src/link-parser.ts) has no directory shape. Add local_directory for local refs ending in / (e.g. docs/), assigned without I/O, for clearer diagnostics and pre-stat intent. Not load-bearing: never decides pass/fail. docs (no slash, no extension) stays shape-ambiguous (local_file), resolved by stat, and once resolved to a directory is treated identically to docs/ (valid). (This is the source-level shape that becomes RuleContext.fileKind: 'directory' after stat — see the Vocabulary note above.)
D5 — HTML inheritance boundary (feat: first-class local HTML resources (#112) #116). HTML links route through the same validateLocalFileLink path. HTML must inherit the corrected rule and must not receive a filesystem-style directory error on href="dir/"; suggestion text must not leak filesystem assumptions into HTML diagnostics.
D6 — Web/external boundary. Make explicit (code + tests) that the directory rule is filesystem-only and never applied to external links.
D7 — Index resolution out of scope (decision record). GitHub-style docs/ → docs/README.md/index.html resolution is not implemented. Unnecessary once a directory is a valid target; documented to prevent re-litigation.
Design / rules
Resources validator: delete the isDirectory → error branch (link-validator.ts:249). A local link resolving to an existing path — file or directory — passes. Missing target → existing broken-link code (unchanged). (D1, D2)
Bundling link walk: stop emitting LINK_TARGETS_DIRECTORY for navigational directory links; a resolved directory is valid (existence-checked). (D3)
LINK_TARGETS_DIRECTORY: retained as error, emitted only where a typed single-file reference (e.g. a packaging files: source entry) resolves to a directory. (D3)
Parser: add local_directory to LinkType; classifyLink returns it for local refs whose path component ends in /. Messages/intent only; never gates validation. (D4)
packages/agent-skills/src/validators/walker-to-issues.ts / walk-link-graph.ts — remove the navigational directory-target → LINK_TARGETS_DIRECTORY mapping (walker-to-issues.ts:12); confirm typed single-file slot (files:) validation is the only emitter.
Tests: packages/resources/test/integration/link-validator.integration.test.ts (the two existing directory tests assert LINK_BROKEN_FILE — update to assert valid), plus new tests below.
Testing
Resources validator: [docs/](docs/) and [docs](docs) → existing directory → valid (no issue). Renamed/deleted directory → broken-link (existing code).
One comprehensive change. The D1 mislabel is the smallest standalone-shippable piece but is folded in. Keep this out of PR #116 so HTML doesn't ship the strict behavior before the rule is corrected; #116 should adopt the corrected rule. (PR #116 has since merged at HEAD 7f81588d; the corrected rule should land on top of it.)
Acceptance criteria
Rules are pure verdict(RuleContext) functions; one shared engine; duplicated orphan/link logic removed; duplication-check green.
Live vat audit/vat skills validate (configured) gain orphan + HTML-link detection at parity with build. (Markdown broken-link detection already exists via LINK_MISSING_TARGET.)
deferredPaths wired into both the packager (skill-packager.ts) and the live validator (validateSkillForPackaging) so files:-declared artifacts stop firing LINK_TO_GITIGNORED_FILE / LINK_BROKEN_FILE. (Full requirement: see "Deferred build artifacts (absorbed from Slice 2 of #129: wire deferredPaths + close the files: gitignore-copy leak #127)", ACs 10a–10d below.)
FileCopyRole stamped on each copy at the call site (coarse values); never on the shared SkillFileEntry/source; plugin artifacts exempt from skill expectations.
Single-source-of-truth: doc rows == registry description/fix/defaultSeverity, asserted by test; runtime message treated as dynamic; lecture only behind reference.
Extraction integration tests prove real dirs → correct RuleContext, including normalization parity.
Orphan branches by fileKind; broken-link headline routes to files:-copies-into-bundle; known limits documented, not faked.
No severity escalation AND no demotion without corpus evidence (docs/validation-rule-design.md); no new code shipped above info/warning without it; each catalog row marked ships-now vs deferred; only shipping codes registered.
10a. The skills validate flow calls computeDeferredPaths(mergedFilesConfig) per skill and threads the result into the link-validation options (so the helper stops being dead code).
10b. link-validator suppresses LINK_TO_GITIGNORED_FILE and LINK_BROKEN_FILE for any resolved local target in the deferred set (matching either the files:dest or the build-artifact source); optionally downgraded to an info/notice so it stays visible.
10c. vat skills build applies the same deferred-path handling so build-time link rewriting and link validation agree.
10d. Decision recorded on whether tree-copied plugin-local skills (vat claude plugin build, which today reject a skills/…files[].dest and bypass packageSkill) should be able to declare files: deferred paths so their injected artifacts can be validated/link-rewritten the same way. (Lower priority; in-scope as a tracked decision, not necessarily implemented in the first slice.)
11a. Resources validator: the isDirectory → error branch (link-validator.ts:249) is removed; a navigational/untyped local link resolving to an existing directory passes with no issue in both vat resources validate and the skill-bundling link walk. (D1, D2)
11b. LINK_TARGETS_DIRECTORY is retained as error and fires only for a typed single-file slot (e.g. a packaging files: source entry) resolving to a directory; the navigational 'directory-target' → 'LINK_TARGETS_DIRECTORY' mapping in walker-to-issues.ts is removed. (D3)
11c. classifyLink gains local_directory (local refs ending in /), assigned without I/O, never gating pass/fail; docs stays local_file and, once resolved to a directory, is treated identically to docs/. (D4)
11d. HTML links inherit the corrected rule (no filesystem-style directory error on href="dir/"; no filesystem-only phrasing in diagnostics). (D5)
11e. Directory determination is guarded to local refs only and never applied to external links, asserted by test. (D6)
11f. docs/validation-codes.md updated for the narrowed LINK_TARGETS_DIRECTORY meaning and records the GitHub-style index-resolution non-goal. (D7)
11g. The two existing directory tests in link-validator.integration.test.ts (currently asserting LINK_BROKEN_FILE) are updated to assert valid, plus the new tests enumerated under Testing above.
Out of scope
Author-declared explicit roles (Phase 2).
Parsing script code to verify runtime asset loads.
Detecting externally-copied dist files (wiped on rebuild — documented limit).
Pre-1.0: prefer clean breaking changes (no compat shims).
This issue was scoped through design review and an independent spec review; the catalog is intentionally evidence-gated — new codes ship at info/warning (or as tightened messages on existing codes) until corpus evidence justifies more.
Open design questions (resolve before the engine slice / slice 3)
Surfaced by the multi-perspective adversarial review of this issue. The ships-now slices (#126, #127) are unaffected; these gate only the engine refactor:
Extraction parity, not the verdict, is the hard part. The three paths iterate different domains (build = dist file inventory + link graph; live = **/*.md registry crawl with depth/exclude/gitignore filtering). "One engine over RuleContext" relocates that divergence into two path-specific extract() → RuleContext front-ends. Show both extract functions side-by-side and prove field-parity before calling it "one engine."
RuleContext is lossy across paths.existsAtSource is meaningless post-build (dist is rm'd + recopied); copyRole has no edge on the live path (nothing is copied at validate time); referencedHow:'mention' on the build path matches dist-relative, post-rewrite paths the source path can't reproduce. The "normalization parity" risk is understated — the real divergence is file identity (source-relative vs dist-relative-rewritten), not just percent-encoding/case. For each field, state which path(s) populate it faithfully.
The aliasing detector validates the table, not the model. Grouping hand-written Partial<RuleContext> rows catches table typos, not intent-collisions (two real intents with different fixes collapsing to one signature — the case this issue admits is "uncatchable"). Invert it: run over extract-produced contexts from a labeled-intent fixture corpus and fail on signature collision between intents with different fixes.
Phase-1 FileCopyRole ≡ scope.'skill-bundled' | 'plugin-artifact' is the same distinction as RuleContext.scope: 'skill' | 'plugin'. The closed-enum-on-the-edge machinery buys nothing in Phase 1 (it's justified only by Phase-2 roles, which are out-of-scope + evidence-gated). Consider deferring the enum entirely and using scope today. Separately, the load-bearing field fileKind (which the orphan-branch behavior rides on) is never specified — define its derivation and its evidence gate (AC feat: Implement publishing system with version management and wrapper script #9) before relying on "executable → must be linked."
Orphan parity is a new error surface. Build's orphan code PACKAGED_UNREFERENCED_FILE is error; "parity" on the live path either inherits error (breaks currently-green adopter CI on upgrade) or emits a new info/warning code (build/live disagree on severity). Name the exact live orphan code + severity, and count findings against the internal adopters via VAT_ROOT_DIR before shipping.
Summary
VAT decides "is this skill resource OK?" (orphan? broken link? outside the skill boundary?) across three code paths that have diverged, and none of them encodes the intent behind a file's inclusion. The result:
vat auditandvat builddisagree, source HTML is invisible to audit, the sanctionedfiles:mechanism doesn't actually silence the error it's meant to resolve, and several fix messages can nudge authors toward workarounds (copying files intodist, suppressing) instead of the right action.This issue proposes refactoring skill-resource checks into pure, intent-aware verdict functions driven by an explicit context, unified behind one engine, documented by a single-source matrix (registry == docs == runtime), and proven by a DRY scenario-test harness with automatic aliasing detection.
Verified against HEAD
7f81588d(PR #116 merge).The three paths today
post-build-checks.ts(checkUnreferencedFilesoverwalkDir(dist)= only physically-copied files; HTML-aware). →PACKAGED_UNREFERENCED_FILE,PACKAGED_BROKEN_LINK.validateSkillForPackaging(packaging-validator.ts), run byvat audit/vat skills validateon configured skills. Crawls**/*.mdonly (:189); detects markdown broken links (LINK_MISSING_TARGET); emits no orphan code; never parses HTML.skill-validator.tsdetectUnreferencedFiles, only when no config and--warn-unreferenced-files.**/*.mdonly;SKILL_UNREFERENCED_FILE(info).Genuine gaps
vat auditcan carry orphans the build later flags.skill-packager.tsrmsdist~:400, then copies link-reachable +files:entries). A merely-unlinked source file is never copied, so path 1 never sees it either.computeDeferredPaths(files-config.ts:134) andwalk-link-graph'sdeferredPaths(:98,:291) exist and are unit-tested, but neitherskill-packagernorvalidateSkillForPackagingpassesdeferredPaths. So a SKILL.md link to afiles:-declared-but-not-yet-built artifact still firesLINK_TO_GITIGNORED_FILE(gitignored dest) /LINK_BROKEN_FILE(no ignore match). This is where the dist-copy/suppress workaround is born — the sanctioned escape doesn't yet silence the error. (Detailed in "Deferred build artifacts (absorbed from Slice 2 of #129: wire deferredPaths + close the files: gitignore-copy leak #127)" below.)Design
Base layer vs skill overlay
The base
resourceslayer answers "does this link resolve?". Theagent-skillslayer overlays the contract that supplies intent: a single entry point (SKILL.md) read by an agent under a budget; self-containment (must ship and work for installers); a build transform (dist wiped+regenerated, links rewritten, artifacts deferred); an LLM consumer (mention-vs-link matters); a per-part role.Intent is edge-scoped and per-skill
A resource may be included by multiple skills / a plugin. Intent attaches to the inclusion edge (the copy)
(source→dest, in context C)as a single value, never on the source (which would force a lossy set that can't pair role↔context). Orphan-ness is per skill; a non-skill inbound link does not rescue a skill-internal orphan. The cross-cutting set is a derived aggregation.FileCopyRole— call-site-derived, on the edgeAuthor config stays
{source, dest}. The engine stamps a closed enum on each copy operation, derived from the call site (skill packaging vs marketplace).SkillFileEntrySchemais.strict()and shared with the marketplace plugin entry (project-config.ts:136,161,203), so the role must not be added there — it lives on the runtime copy record only.'skill-bundled' | 'plugin-artifact'(the layer distinction — never apply skill expectations to a plugin artifact).'skill-doc' | 'skill-executable' | 'skill-asset' …, derived then maybe declared.Rules as pure verdict functions
verdict(ctx: RuleContext) → Issue | null, withdescription/fix/defaultSeveritysourced fromCODE_REGISTRY. A thin extraction front end derivesRuleContextfrom real inputs; verdict functions never touch the filesystem.The matrix (spine of spec, docs, tests)
A single-source-of-truth rule catalog.
CODE_REGISTRY.entry()holds{defaultSeverity, description, fix, reference}(nomessagefield — runtimemessageis templated fromdescription+ per-issue context). The doc catalog's columns are asserted equal to the registry'sdescription+fix+defaultSeverity; runtimemessageis treated as dynamic. Builds on the existing docs-completeness test (agent-skills/test/docs/validation-codes.test.ts), tightening it from anchor-presence todescription/fixequality. The long "why / when fine / how to ignore" lives once behindreference.Two views: (1) rule catalog — code | fires-when | means | fix-headline | severity | ships-now-vs-deferred; (2) disambiguation map — symptom (broken⇄orphan) × intent (build-artifact / runtime-asset / forgotten-doc / typo / stale / leaves-bundle) → the catalog row. View 2 names the broken⇄orphan oscillation and shows the
files:edge (oncedeferredPathsis wired) as the resolving state.Behavior
fileKind: executable → must be linked (real signal); asset → declare infiles:(we cannot infer runtime-loading without code parsing — declaration is the resolution); doc → link or remove. The uncatchable case (asset loaded by a script but neither linked nor declared) is documented as a known limit, not faked.reference.fixnames a sanctioned action before any "ignore/allow" — enforced by a registry meta-test.Scenario test harness (DRY, exhaustive, alias-detecting)
A core unit harness drives the real verdict functions with constructed
RuleContext(no filesystem):{ intent, ctx: Partial<RuleContext>, expect }, one-line deltas, oneit.eachrunner.expectanddescription/fix/defaultSeverity=== registry.RuleContext; if one signature maps to >1 distinctexpect, fail and name both intents → resolve via compound message or a new discriminating context field.extract(realDir) → RuleContextis faithful, including normalization parity (percent-encoding / case / leading-slash) between paths 1 and 2, which normalize differently today.Engine unification
One orphan/link engine consumed by the built path and the live
validateSkillForPackaging(not the rarely-rundetectUnreferencedFiles); widen the**/*.md-only crawl (packaging-validator.ts:189) to.html/.htm+ non-doc orphans; remove duplicated logic (duplication-checkgreen, baseline untouched).Deferred build artifacts (absorbed from #127)
Problem
When a skill bundles a build-generated artifact via the
files:packaging config and links to it fromSKILL.md,vat skills validatereports a hard error (LINK_TO_GITIGNORED_FILE, orLINK_BROKEN_FILEwhen no ignore rule matches the path) at source-validation time — even though thefiles:entry declares exactly that artifact and the build will materialize it before distribution.The helper that exists to prevent this,
computeDeferredPaths(), is implemented and exported but is consumed by nothing in the validate or build link-resolution paths. It is effectively dead code, so the "deferred path" concept it encodes never takes effect.Environment: vibe-agent-toolkit
0.1.39-rc.1(@vibe-agent-toolkit/cli,/agent-skills,/resources).Repro (minimal, fictional)
A skill
trail-conditionsships a generated lookup file produced by a sibling build step. The artifact is a build output (gitignored) and is copied into the skill at package time viafiles:.vibe-agent-toolkit.config.yaml:skills/trail-conditions/SKILL.md:build/generated/is listed in.gitignore, anddata/trail-index.jsondoes not exist in the source tree — it is only produced whenvat skills buildruns thefiles:copy.Run:
Actual
(If no ignore rule matches the resolved dest, the same link fails instead as
LINK_BROKEN_FILE — File not found.)The only way to make the skill green today is a per-path
validation.allowentry for every such link — which defeats the purpose of declaring the artifact infiles:and silences a check that should simply understand the artifact is deferred.Expected
A link whose target is declared in the skill's
files:config — matching either thedestor the build-artifactsource— should be treated as a deferred path ("will exist after build") and skip theLINK_TO_GITIGNORED_FILE/LINK_BROKEN_FILEchecks. This is exactly whatcomputeDeferredPaths()was written to express.Root cause
computeDeferredPaths(files)is defined and exported in@vibe-agent-toolkit/agent-skills(files-config.ts:134/dist/files-config.js), and its own doc comment states it returns "the set of paths that should be treated as 'deferred' during source-time validation … source may be a build artifact that doesn't exist at validation time; dest is the target location that won't exist until build time."But a workspace-wide search finds no call sites — it appears only at its definition and the
indexre-export (index.ts:38).walk-link-graph'sdeferredPathsoption (:98, used:291) is likewise never supplied. The skills validate flow (@vibe-agent-toolkit/cli,commands/skills/validate.js) builds the validation context and runs the link validator (@vibe-agent-toolkit/resources,link-validator.js) without ever computing or passing deferred paths. So the validator resolves the target against the working tree, finds it absent/ignored, and emitsLINK_TO_GITIGNORED_FILE/LINK_BROKEN_FILE.Suggested fix
computeDeferredPaths(mergedFilesConfig)per skill and thread the result into the link-validation options.link-validator.js, when a resolved local target is in the deferred set, suppressLINK_TO_GITIGNORED_FILEandLINK_BROKEN_FILEfor that link (optionally downgrade to an info/notice so it is still visible).vat skills buildso build-time link rewriting and link validation agree.Related, lower priority (separate consideration)
Plugin-local skills distributed via verbatim tree-copy (
vat claude plugin build) currently have nofiles:surface of their own: plugin-levelfiles[].destrejectsskills/…("that surface is owned by the skill-stream"), and tree-copied skills never pass throughpackageSkill. So a skill that ships build-injected artifacts via the self-contained plugin layout has no first-class way to declare those artifacts for validation at all. Fixing (1)–(3) helps skills built through the packaging path; please also consider whether tree-copied plugin-local skills should be able to declarefiles:deferred paths so their injected artifacts can be validated and link-rewritten the same way.Directory links (absorbed from #126)
Problem
VAT's link validation treats any local link that resolves to a directory as a hard error. In adoption this produces false positives for legitimate, GitHub-renderable links and pushes authors into awkward workarounds — repointing
[docs/](docs/)to dodge the error, or writing[docs/](docs/README.md)to manually perform the index resolution GitHub does automatically.This is wrong in principle: a directory is a legitimate link target. In a filesystem a directory is a real, existing referent (in Unix, literally a file); on GitHub a directory link renders as a navigable tree. "Docs live in
docs/" in a ToC is a valid statement, and ifdocs/is renamed/deleted the link should fail — but that's already caught by ordinary existence checking, not a directory rule.The rule isn't wrong in spirit — it's mis-scoped. A directory is only a problem for a reference that is contractually a single file (a typed slot opened or copied as one file). It leaked into the general link path, which only checks that links resolve and has no business judging directory-ness.
Background — when & why this landed
a1fb9b8e(2026-04-16, unified validation framework foundation): introducedLINK_TARGETS_DIRECTORY,defaultSeverity: error.26367e8b6(feat: canonical root model + RFC 3986 §4.2 leading-/ URI resolution #105, 2026-05-17, canonical root model + RFC 3986 §4.2 leading-/): added theisDirectorycheck to the resources link-validator. Commit body: "Links resolving to an existing directory … previously passed silently. They now surface as broken_file … trailing-slash hrefs and directory-ish paths were a quiet footgun."e0b42db1(refactor(validation): consolidate the validation-code framework into agent-schema #114, 2026-06-02): consolidated codes into@vibe-agent-toolkit/agent-schema; the resources path kept emittingLINK_BROKEN_FILEfor directories.Documented rationale (
docs/validation-codes.md): "agents and renderers cannot load a directory as content." True only for a reference that is contractually a single file; false for a navigational reference.Desired-state principle
The discriminator is reference type, not which command runs:
[..](..), HTMLhref/src— prose links in docs, READMEs, SKILL.md bodiesvat resources validateand the skill-bundling link walk).files:source entry, a schema/template path, any config field declared to be exactly one fileLINK_TARGETS_DIRECTORY(error) — the contract demanded a file.Corollaries:
docs/vsdocs) is a diagnostic hint only — never a correctness gate. It informs classification/messages; it never changes pass/fail. Authors are not required to add a slash to make a directory link valid.https://x.com/docs/) has no client-determinable meaning.assets/is valid becauseassets/exists; whether its contents are packaged is governed by packaging globs /files, not the prose link. Git doesn't track empty directories, so an "empty directory" doesn't exist in a clean checkout/bundle and is caught by the ordinary target-missing path.Divergences to fix (one comprehensive change)
packages/resources/src/link-validator.ts(~L249) emitsLINK_BROKEN_FILEwith message "Link target is a directory" — mislabeled (dedicatedLINK_TARGETS_DIRECTORYexists) and reads to adopters like "the file doesn't exist."LINK_TARGETS_DIRECTORYover-applied. The skill-bundling link walk (walk-link-graph→walker-to-issues, where'directory-target' → 'LINK_TARGETS_DIRECTORY'atwalker-to-issues.ts:12) currently maps a directory-target prose link toLINK_TARGETS_DIRECTORY. These prose links are navigational — stop flagging them. The code must fire only for typed single-file slots (e.g. a packagingfiles:source entry) resolving to a directory.local_directoryclassification (enhancement).classifyLink(packages/resources/src/link-parser.ts) has no directory shape. Addlocal_directoryfor local refs ending in/(e.g.docs/), assigned without I/O, for clearer diagnostics and pre-statintent. Not load-bearing: never decides pass/fail.docs(no slash, no extension) stays shape-ambiguous (local_file), resolved bystat, and once resolved to a directory is treated identically todocs/(valid). (This is the source-level shape that becomesRuleContext.fileKind: 'directory'afterstat— see the Vocabulary note above.)validateLocalFileLinkpath. HTML must inherit the corrected rule and must not receive a filesystem-style directory error onhref="dir/"; suggestion text must not leak filesystem assumptions into HTML diagnostics.externallinks.docs/→docs/README.md/index.htmlresolution is not implemented. Unnecessary once a directory is a valid target; documented to prevent re-litigation.Design / rules
isDirectory → errorbranch (link-validator.ts:249). A local link resolving to an existing path — file or directory — passes. Missing target → existing broken-link code (unchanged). (D1, D2)LINK_TARGETS_DIRECTORYfor navigational directory links; a resolved directory is valid (existence-checked). (D3)LINK_TARGETS_DIRECTORY: retained aserror, emitted only where a typed single-file reference (e.g. a packagingfiles:source entry) resolves to a directory. (D3)local_directorytoLinkType;classifyLinkreturns it for local refs whose path component ends in/. Messages/intent only; never gates validation. (D4)docs/validation-codes.mdforLINK_TARGETS_DIRECTORY(narrowed typed-single-file-slot meaning; navigational links accept directories); record the index-resolution non-goal. (D7)Affected code (survey — confirm at implementation)
packages/resources/src/link-validator.ts— remove directory-error branch (:249); HTML phrasing.packages/resources/src/link-parser.ts—classifyLink+local_directory.packages/resources/src/types.ts—LinkTypeunion.packages/agent-schema/src/validation-codes.ts+docs/validation-codes.md— narrowedLINK_TARGETS_DIRECTORYdescription.packages/agent-skills/src/validators/walker-to-issues.ts/walk-link-graph.ts— remove the navigational directory-target →LINK_TARGETS_DIRECTORYmapping (walker-to-issues.ts:12); confirm typed single-file slot (files:) validation is the only emitter.packages/resources/test/integration/link-validator.integration.test.ts(the two existing directory tests assertLINK_BROKEN_FILE— update to assert valid), plus new tests below.Testing
[docs/](docs/)and[docs](docs)→ existing directory → valid (no issue). Renamed/deleted directory → broken-link (existing code).classifyLink:docs/→local_directory;docs,docs/x.md,https://x.com/docs/→ unchanged.files:source entry resolving to a directory →LINK_TARGETS_DIRECTORY(error).href="dir/"→ directory → valid; diagnostics free of filesystem-only phrasing.Rollout note (#126)
One comprehensive change. The D1 mislabel is the smallest standalone-shippable piece but is folded in. Keep this out of PR #116 so HTML doesn't ship the strict behavior before the rule is corrected; #116 should adopt the corrected rule. (PR #116 has since merged at HEAD
7f81588d; the corrected rule should land on top of it.)Acceptance criteria
verdict(RuleContext)functions; one shared engine; duplicated orphan/link logic removed;duplication-checkgreen.vat audit/vat skills validate(configured) gain orphan + HTML-link detection at parity with build. (Markdown broken-link detection already exists viaLINK_MISSING_TARGET.)deferredPathswired into both the packager (skill-packager.ts) and the live validator (validateSkillForPackaging) sofiles:-declared artifacts stop firingLINK_TO_GITIGNORED_FILE/LINK_BROKEN_FILE. (Full requirement: see "Deferred build artifacts (absorbed from Slice 2 of #129: wire deferredPaths + close the files: gitignore-copy leak #127)", ACs 10a–10d below.)FileCopyRolestamped on each copy at the call site (coarse values); never on the sharedSkillFileEntry/source; plugin artifacts exempt from skill expectations.description/fix/defaultSeverity, asserted by test; runtimemessagetreated as dynamic; lecture only behindreference.RuleContext, including normalization parity.fileKind; broken-link headline routes tofiles:-copies-into-bundle; known limits documented, not faked.docs/validation-rule-design.md); no new code shipped above info/warning without it; each catalog row marked ships-now vs deferred; only shipping codes registered.Additional acceptance criteria — Deferred build artifacts (from #127)
10a. The skills validate flow calls
computeDeferredPaths(mergedFilesConfig)per skill and threads the result into the link-validation options (so the helper stops being dead code).10b.
link-validatorsuppressesLINK_TO_GITIGNORED_FILEandLINK_BROKEN_FILEfor any resolved local target in the deferred set (matching either thefiles:destor the build-artifactsource); optionally downgraded to an info/notice so it stays visible.10c.
vat skills buildapplies the same deferred-path handling so build-time link rewriting and link validation agree.10d. Decision recorded on whether tree-copied plugin-local skills (
vat claude plugin build, which today reject askills/…files[].destand bypasspackageSkill) should be able to declarefiles:deferred paths so their injected artifacts can be validated/link-rewritten the same way. (Lower priority; in-scope as a tracked decision, not necessarily implemented in the first slice.)Additional acceptance criteria — Directory links (from #126)
11a. Resources validator: the
isDirectory → errorbranch (link-validator.ts:249) is removed; a navigational/untyped local link resolving to an existing directory passes with no issue in bothvat resources validateand the skill-bundling link walk. (D1, D2)11b.
LINK_TARGETS_DIRECTORYis retained aserrorand fires only for a typed single-file slot (e.g. a packagingfiles:source entry) resolving to a directory; the navigational'directory-target' → 'LINK_TARGETS_DIRECTORY'mapping inwalker-to-issues.tsis removed. (D3)11c.
classifyLinkgainslocal_directory(local refs ending in/), assigned without I/O, never gating pass/fail;docsstayslocal_fileand, once resolved to a directory, is treated identically todocs/. (D4)11d. HTML links inherit the corrected rule (no filesystem-style directory error on
href="dir/"; no filesystem-only phrasing in diagnostics). (D5)11e. Directory determination is guarded to local refs only and never applied to
externallinks, asserted by test. (D6)11f.
docs/validation-codes.mdupdated for the narrowedLINK_TARGETS_DIRECTORYmeaning and records the GitHub-style index-resolution non-goal. (D7)11g. The two existing directory tests in
link-validator.integration.test.ts(currently assertingLINK_BROKEN_FILE) are updated to assert valid, plus the new tests enumerated under Testing above.Out of scope
docs/→docs/README.md/index.html) — Slice 1 of #129: directory links are valid targets (narrow LINK_TARGETS_DIRECTORY to typed single-file slots) #126 D7 decision record; unnecessary once a directory is a valid target.Notes
Open design questions (resolve before the engine slice / slice 3)
Surfaced by the multi-perspective adversarial review of this issue. The ships-now slices (#126, #127) are unaffected; these gate only the engine refactor:
**/*.mdregistry crawl with depth/exclude/gitignore filtering). "One engine overRuleContext" relocates that divergence into two path-specificextract() → RuleContextfront-ends. Show bothextractfunctions side-by-side and prove field-parity before calling it "one engine."RuleContextis lossy across paths.existsAtSourceis meaningless post-build (dist isrm'd + recopied);copyRolehas no edge on the live path (nothing is copied at validate time);referencedHow:'mention'on the build path matches dist-relative, post-rewrite paths the source path can't reproduce. The "normalization parity" risk is understated — the real divergence is file identity (source-relative vs dist-relative-rewritten), not just percent-encoding/case. For each field, state which path(s) populate it faithfully.Partial<RuleContext>rows catches table typos, not intent-collisions (two real intents with different fixes collapsing to one signature — the case this issue admits is "uncatchable"). Invert it: run overextract-produced contexts from a labeled-intent fixture corpus and fail on signature collision between intents with different fixes.FileCopyRole≡scope.'skill-bundled' | 'plugin-artifact'is the same distinction asRuleContext.scope: 'skill' | 'plugin'. The closed-enum-on-the-edge machinery buys nothing in Phase 1 (it's justified only by Phase-2 roles, which are out-of-scope + evidence-gated). Consider deferring the enum entirely and usingscopetoday. Separately, the load-bearing fieldfileKind(which the orphan-branch behavior rides on) is never specified — define its derivation and its evidence gate (AC feat: Implement publishing system with version management and wrapper script #9) before relying on "executable → must be linked."PACKAGED_UNREFERENCED_FILEiserror; "parity" on the live path either inheritserror(breaks currently-green adopter CI on upgrade) or emits a new info/warning code (build/live disagree on severity). Name the exact live orphan code + severity, and count findings against the internal adopters viaVAT_ROOT_DIRbefore shipping.duplication-check green); decide path 3 (detectUnreferencedFiles)'s fate (delete/fold/keep); define the canonical normalization "parity" converges on (AC feat: Claude Skills audit and import tools #7); enumerate the ships-now vs deferred code lists in one place (AC feat: Implement publishing system with version management and wrapper script #9); reconcile AC feat: Implement publishing system with version management and wrapper script #9 (no demotion without evidence) with the Slice 1 of #129: directory links are valid targets (narrow LINK_TARGETS_DIRECTORY to typed single-file slots) #126 directory change and AC feat: Implementvat doctordiagnostic command #8's dependency on the deferredSKILL_UNLINKED_EXECUTABLEcode.