@vibe-agent-toolkit/agent-skills 0.1.41-rc.7 → 0.1.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/dist/content-type-routing.d.ts +18 -1
  2. package/dist/content-type-routing.d.ts.map +1 -1
  3. package/dist/content-type-routing.js +17 -0
  4. package/dist/content-type-routing.js.map +1 -1
  5. package/dist/evidence/build-evidence.d.ts +38 -0
  6. package/dist/evidence/build-evidence.d.ts.map +1 -0
  7. package/dist/evidence/build-evidence.js +60 -0
  8. package/dist/evidence/build-evidence.js.map +1 -0
  9. package/dist/evidence/index.d.ts +1 -0
  10. package/dist/evidence/index.d.ts.map +1 -1
  11. package/dist/evidence/index.js +1 -0
  12. package/dist/evidence/index.js.map +1 -1
  13. package/dist/files-config.d.ts +225 -31
  14. package/dist/files-config.d.ts.map +1 -1
  15. package/dist/files-config.js +445 -123
  16. package/dist/files-config.js.map +1 -1
  17. package/dist/index.d.ts +8 -6
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +10 -4
  20. package/dist/index.js.map +1 -1
  21. package/dist/post-build-checks.d.ts +10 -1
  22. package/dist/post-build-checks.d.ts.map +1 -1
  23. package/dist/post-build-checks.js +55 -6
  24. package/dist/post-build-checks.js.map +1 -1
  25. package/dist/schemas/index.d.ts +3 -2
  26. package/dist/schemas/index.d.ts.map +1 -1
  27. package/dist/schemas/index.js +2 -2
  28. package/dist/schemas/index.js.map +1 -1
  29. package/dist/schemas/installed-plugins-registry.d.ts +137 -63
  30. package/dist/schemas/installed-plugins-registry.d.ts.map +1 -1
  31. package/dist/schemas/installed-plugins-registry.js +94 -6
  32. package/dist/schemas/installed-plugins-registry.js.map +1 -1
  33. package/dist/schemas/known-marketplaces-registry.d.ts +165 -100
  34. package/dist/schemas/known-marketplaces-registry.d.ts.map +1 -1
  35. package/dist/schemas/known-marketplaces-registry.js +108 -21
  36. package/dist/schemas/known-marketplaces-registry.js.map +1 -1
  37. package/dist/schemas/registry-drift.d.ts +53 -0
  38. package/dist/schemas/registry-drift.d.ts.map +1 -0
  39. package/dist/schemas/registry-drift.js +50 -0
  40. package/dist/schemas/registry-drift.js.map +1 -0
  41. package/dist/skill-packager.d.ts +75 -5
  42. package/dist/skill-packager.d.ts.map +1 -1
  43. package/dist/skill-packager.js +304 -50
  44. package/dist/skill-packager.js.map +1 -1
  45. package/dist/skill-source/git-clone.d.ts.map +1 -1
  46. package/dist/skill-source/git-clone.js +32 -11
  47. package/dist/skill-source/git-clone.js.map +1 -1
  48. package/dist/skill-test/eval-inputs.js +2 -2
  49. package/dist/skill-test/eval-inputs.js.map +1 -1
  50. package/dist/skill-test/eval-lint.js +2 -2
  51. package/dist/skill-test/eval-lint.js.map +1 -1
  52. package/dist/skill-test/grader-prompt.js +2 -2
  53. package/dist/skill-test/grader-prompt.js.map +1 -1
  54. package/dist/test-input.d.ts +59 -24
  55. package/dist/test-input.d.ts.map +1 -1
  56. package/dist/test-input.js +77 -16
  57. package/dist/test-input.js.map +1 -1
  58. package/dist/validators/agent-instruction-presence.d.ts +41 -0
  59. package/dist/validators/agent-instruction-presence.d.ts.map +1 -0
  60. package/dist/validators/agent-instruction-presence.js +79 -0
  61. package/dist/validators/agent-instruction-presence.js.map +1 -0
  62. package/dist/validators/bundled-resource-link-detection.d.ts +20 -3
  63. package/dist/validators/bundled-resource-link-detection.d.ts.map +1 -1
  64. package/dist/validators/bundled-resource-link-detection.js +78 -34
  65. package/dist/validators/bundled-resource-link-detection.js.map +1 -1
  66. package/dist/validators/compat-detectors.d.ts +9 -4
  67. package/dist/validators/compat-detectors.d.ts.map +1 -1
  68. package/dist/validators/compat-detectors.js +18 -30
  69. package/dist/validators/compat-detectors.js.map +1 -1
  70. package/dist/validators/index.d.ts +1 -0
  71. package/dist/validators/index.d.ts.map +1 -1
  72. package/dist/validators/index.js +1 -0
  73. package/dist/validators/index.js.map +1 -1
  74. package/dist/validators/packaging-validator.d.ts +68 -10
  75. package/dist/validators/packaging-validator.d.ts.map +1 -1
  76. package/dist/validators/packaging-validator.js +51 -10
  77. package/dist/validators/packaging-validator.js.map +1 -1
  78. package/dist/validators/registry-validator.d.ts.map +1 -1
  79. package/dist/validators/registry-validator.js +28 -7
  80. package/dist/validators/registry-validator.js.map +1 -1
  81. package/dist/validators/rule-engine/rule-context.d.ts +4 -2
  82. package/dist/validators/rule-engine/rule-context.d.ts.map +1 -1
  83. package/dist/validators/rule-engine/rule-context.js.map +1 -1
  84. package/dist/validators/rule-engine/rule-engine.d.ts.map +1 -1
  85. package/dist/validators/rule-engine/rule-engine.js +15 -3
  86. package/dist/validators/rule-engine/rule-engine.js.map +1 -1
  87. package/dist/validators/skill-validator.d.ts.map +1 -1
  88. package/dist/validators/skill-validator.js +16 -10
  89. package/dist/validators/skill-validator.js.map +1 -1
  90. package/dist/validators/source-only-codes.d.ts.map +1 -1
  91. package/dist/validators/source-only-codes.js +1 -0
  92. package/dist/validators/source-only-codes.js.map +1 -1
  93. package/dist/validators/validation-rules.d.ts +135 -17
  94. package/dist/validators/validation-rules.d.ts.map +1 -1
  95. package/dist/validators/validation-rules.js +192 -38
  96. package/dist/validators/validation-rules.js.map +1 -1
  97. package/dist/validators/walker-to-issues.d.ts.map +1 -1
  98. package/dist/validators/walker-to-issues.js +20 -7
  99. package/dist/validators/walker-to-issues.js.map +1 -1
  100. package/dist/walk-link-graph.d.ts +10 -1
  101. package/dist/walk-link-graph.d.ts.map +1 -1
  102. package/dist/walk-link-graph.js +124 -39
  103. package/dist/walk-link-graph.js.map +1 -1
  104. package/package.json +5 -5
@@ -17,14 +17,16 @@
17
17
  import { existsSync, readdirSync, statSync } from 'node:fs';
18
18
  import { copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
19
19
  import { basename, dirname } from 'node:path';
20
- import { runValidationFramework, } from '@vibe-agent-toolkit/agent-schema';
20
+ import { allowUnusedIssues, createAllowUsageLedger, runValidationFramework, } from '@vibe-agent-toolkit/agent-schema';
21
21
  import { DeferredArtifacts, ResourceRegistry, loadConfig, openFrontmatter, resolveLocalHref, rewriteFrontmatterUriReferencesFromSchema, rewriteHtmlLinks, transformContent, parseMarkdown, } from '@vibe-agent-toolkit/resources';
22
- import { findProjectRoot, isGlob, resolveAssetReference, toForwardSlash, safePath, } from '@vibe-agent-toolkit/utils';
23
- import { getTargetSubdir } from './content-type-routing.js';
24
- import { applyFilesConfig, buildArtifactHint } from './files-config.js';
22
+ import { findProjectRoot, isGlob, issueLocation, resolveAssetReference, toForwardSlash, safePath, } from '@vibe-agent-toolkit/utils';
23
+ import { CLAUDE_WEB_REFERENCES_SUBDIR, getTargetSubdir } from './content-type-routing.js';
24
+ import { applyFilesConfig, buildArtifactHint, droppedGlobMatchesToIssues, explicitFilesConfigDests, globEntryDest, } from './files-config.js';
25
25
  import { checkBrokenPackagedLinks, checkUnreferencedFiles } from './post-build-checks.js';
26
26
  import { checkPackagedTestInput, partitionTestInputFileEntries, resolveTestInputDirs, testInputExcludeRules, testInputFileEntryIssues, testInputLinkIssues, } from './test-input.js';
27
+ import { detectPackagedAgentInstructionFiles } from './validators/agent-instruction-presence.js';
27
28
  import { validateSkillForPackaging } from './validators/packaging-validator.js';
29
+ import { materializeIssue } from './validators/rule-engine/index.js';
28
30
  import { deferredAssetsToIssues, walkerExclusionsToIssues } from './validators/walker-to-issues.js';
29
31
  import { walkLinkGraph } from './walk-link-graph.js';
30
32
  const PACKAGE_JSON_FILENAME = 'package.json';
@@ -96,8 +98,15 @@ const DEFAULT_PACKAGING_TARGET = 'claude-code';
96
98
  * The single canonical conversion used by BOTH `vat skills build` and
97
99
  * `vat skill test` (the pool build), so the dist a test exercises is byte-for-byte
98
100
  * what `vat skills build` would produce. `basePath` defaults to `dirname(skillPath)`.
101
+ *
102
+ * `projectSkills` is EVERY skill the project declares, with its effective packaging
103
+ * config — assembled ONCE per invocation by the calling lane and passed down, never
104
+ * recomputed per skill (a per-skill walk of the whole project config is an N+1 this
105
+ * repo has been bitten by). It is required rather than defaulted because the test-input
106
+ * rule is project-wide: omitting it silently packages another skill's eval answer key.
107
+ * A lane with genuinely no project to enumerate passes `[]` explicitly.
99
108
  */
100
- export function packagingConfigToPackageOptions(config, anchors) {
109
+ export function packagingConfigToPackageOptions(config, anchors, projectSkills) {
101
110
  return {
102
111
  outputPath: anchors.outputPath,
103
112
  formats: ['directory'],
@@ -106,14 +115,27 @@ export function packagingConfigToPackageOptions(config, anchors) {
106
115
  ...(config.resourceNaming && { resourceNaming: config.resourceNaming }),
107
116
  ...(config.stripPrefix && { stripPrefix: config.stripPrefix }),
108
117
  ...(config.linkFollowDepth !== undefined && { linkFollowDepth: config.linkFollowDepth }),
118
+ // `!== undefined`, not truthiness: `false` is the only value that carries
119
+ // information here (the packager defaults to `true`), so a truthiness spread
120
+ // would drop exactly the setting a user bothered to write. Dropping it made
121
+ // this conversion disagree with `packaging-validator.ts`, which reads the same
122
+ // key straight off the config — the gate predicted a README ships and the
123
+ // build stripped it, from one config, inside the conversion that promises
124
+ // byte-for-byte parity between lanes.
125
+ ...(config.excludeNavigationFiles !== undefined && {
126
+ excludeNavigationFiles: config.excludeNavigationFiles,
127
+ }),
109
128
  ...(config.excludeReferencesFromBundle && { excludeReferencesFromBundle: config.excludeReferencesFromBundle }),
110
129
  ...(config.files && { files: config.files }),
111
130
  ...(config.validation && { validation: config.validation }),
112
131
  // Declared test input never ships. Derived here — the ONE conversion both
113
132
  // `vat skills build` and the plugin build go through — so no lane can package a
114
- // skill without the rule applied.
133
+ // skill without the rule applied. PROJECT-WIDE: `projectSkills` carries every
134
+ // other skill's declaration too, so a link into a SIBLING skill's suite is
135
+ // excluded as well. Keyed to this skill alone, that sibling's answer key was
136
+ // ordinary content and shipped.
115
137
  ...(() => {
116
- const testInputDirs = resolveTestInputDirs(config, dirname(anchors.skillPath));
138
+ const testInputDirs = resolveTestInputDirs(config, dirname(anchors.skillPath), projectSkills);
117
139
  return testInputDirs.length > 0 ? { testInputDirs } : {};
118
140
  })(),
119
141
  };
@@ -125,9 +147,36 @@ export function packagingConfigToPackageOptions(config, anchors) {
125
147
  * then packages each skill against the shared registry. This eliminates
126
148
  * redundant I/O when building multiple skills from the same project.
127
149
  *
150
+ * **One skill's failure never discards the batch.** `packageSkill` reports most
151
+ * problems by RETURNING a result whose `hasErrors` is set, which callers already
152
+ * degrade gracefully on — but it also THROWS on structural packaging failures
153
+ * (an absent or unreadable `files:` source). Letting that throw escape the loop
154
+ * made the two failure paths behave in opposite ways through one contract:
155
+ * measured on a 90-skill project, one such failure discarded 89 completed builds
156
+ * and collapsed the whole report into a single string. Each iteration is
157
+ * therefore contained and reported as a `failed` outcome instead.
158
+ *
159
+ * A filename collision is NOT one of the throwing paths — it is a returned
160
+ * `FILENAME_COLLISION` finding. Do not reach for a collision as the fixture when
161
+ * testing this containment: the loop completes normally either way, so such a
162
+ * test passes whether or not the containment exists.
163
+ *
164
+ * The registry build is deliberately OUTSIDE the containment: it is the run's
165
+ * shared prerequisite, so its failure really does doom every skill and must
166
+ * still propagate. Only per-skill work is contained.
167
+ *
128
168
  * @param skills - Array of skill build specifications
129
169
  * @param projectRoot - Absolute path to the project root directory
130
- * @returns Array of package results (one per skill)
170
+ * @param allowLedger - The RUN's allow-usage ledger. Required, not
171
+ * optional-with-a-default, for the same reason `runValidationFramework`'s is:
172
+ * this function loops, so it can never honestly conclude on its own that an
173
+ * allow entry matched nothing — an entry matched while building skill A is
174
+ * USED for the run. It is never drained here; the caller drains it once with
175
+ * `allowUnusedIssues()` after everything in the invocation has been seen
176
+ * (`vat build` also validates the SOURCE tree, whose matches count too).
177
+ * Containment does not change that: a skill that threw may still have matched
178
+ * allow entries before it threw, and those matches count for the run.
179
+ * @returns One outcome per input spec, in input order
131
180
  *
132
181
  * @example
133
182
  * ```typescript
@@ -135,22 +184,35 @@ export function packagingConfigToPackageOptions(config, anchors) {
135
184
  * { skillPath: '/project/skills/SKILL.md', options: { outputPath: '/out/skill-a' } },
136
185
  * { skillPath: '/project/skills/SKILL2.md', options: { outputPath: '/out/skill-b' } },
137
186
  * ];
138
- * const results = await packageSkills(specs, '/project');
187
+ * const ledger = createAllowUsageLedger();
188
+ * const outcomes = await packageSkills(specs, '/project', ledger);
189
+ * const runIssues = allowUnusedIssues(ledger);
139
190
  * ```
140
191
  */
141
- export async function packageSkills(skills, projectRoot) {
192
+ export async function packageSkills(skills, projectRoot, allowLedger) {
142
193
  // 1. Create one registry for the entire project. Through the shared builder:
143
194
  // this used to call `fromCrawl` directly and omit the config, so skills built
144
195
  // here belonged to no collection while a skill built through the single-skill
145
196
  // fallback did.
146
197
  const registry = await createProjectRegistry(projectRoot);
147
198
  // 2. Package each skill against the shared registry
148
- const results = [];
199
+ const outcomes = [];
149
200
  for (const { skillPath, options } of skills) {
150
- const result = await packageSkill(skillPath, { ...options, registry });
151
- results.push(result);
201
+ try {
202
+ const result = await packageSkill(skillPath, { ...options, registry, allowLedger });
203
+ outcomes.push({ status: 'built', skillPath, result });
204
+ }
205
+ catch (error) {
206
+ // Not swallowed: the error is carried on the outcome so the caller reports
207
+ // WHICH skill failed and why, and gates the run's exit code on it.
208
+ outcomes.push({
209
+ status: 'failed',
210
+ skillPath,
211
+ error: error instanceof Error ? error : new Error(String(error)),
212
+ });
213
+ }
152
214
  }
153
- return results;
215
+ return outcomes;
154
216
  }
155
217
  /**
156
218
  * Package a skill with all its dependencies
@@ -179,6 +241,21 @@ export async function packageSkill(skillPath, options = {}) {
179
241
  // Library callers fall back to the skill directory when canonical
180
242
  // findProjectRoot returns null. The CLI command boundary is responsible
181
243
  // for any user-facing warning about missing project roots.
244
+ //
245
+ // COORDINATE-SYSTEM ASSUMPTION, not an enforced invariant. This root is the base
246
+ // every path this call REPORTS is stated in — `namingBasePath` at step 8, and the
247
+ // `projectRoot` handed to `walkerExclusionsToIssues` / `testInputLinkIssues` /
248
+ // `deferredAssetsToIssues` at step 13b. It is derived HERE, per skill, from the
249
+ // skill's own path. It is NOT the `projectRoot` argument `packageSkills` was called
250
+ // with (that one is used only for `createProjectRegistry`), and it is not the `cwd`
251
+ // that `vat skills build` treats as the root of the document it emits.
252
+ //
253
+ // UNVERIFIED that these can actually diverge: in every path exercised so far the two
254
+ // have coincided (`vat skills build` passes its `cwd`, and the skills it discovers
255
+ // live under it), and no run has been observed where they differ. Nothing enforces
256
+ // the equality, though — a caller packaging a skill from outside its own project
257
+ // root would get issue locations in one coordinate system and a report header in
258
+ // another, with no error anywhere.
182
259
  const projectRoot = findProjectRoot(dirname(skillPath)) ?? dirname(skillPath);
183
260
  const skillRoot = dirname(skillPath);
184
261
  // 3. Get or create the resource registry.
@@ -247,10 +324,15 @@ export async function packageSkill(skillPath, options = {}) {
247
324
  }
248
325
  // 8. Build path map for file copying and link rewriting
249
326
  const namingBasePath = projectRoot;
250
- const pathMap = buildPathMap(skillPath, bundledFiles, outputPath, resourceNaming, namingBasePath, stripPrefix, target);
251
- // 8b. Apply files config: register single-file entries in path map.
252
- // GLOB entries are skipped — late binding via applyFilesConfig owns their expansion.
253
- applyNonGlobEntriesToPathMap(filesConfig, projectRoot, outputPath, pathMap, skillMetadata.name);
327
+ const pathMapSkill = { path: skillPath, name: skillMetadata.name };
328
+ const pathMap = buildPathMap(pathMapSkill, bundledFiles, outputPath, resourceNaming, namingBasePath, stripPrefix, target);
329
+ // 8b. Apply files config to the path map: single-file entries by name, and glob
330
+ // entries by re-pointing the link-bundled files they claim at the declared dest.
331
+ applyFilesEntriesToPathMap(filesConfig, projectRoot, outputPath, pathMap, skillMetadata.name, bundledFiles);
332
+ // 8c. Collisions are judged on the FINAL destination map, so a `files:` remap is
333
+ // a real remedy and a `files:`-created collision is still caught. See
334
+ // detectDestinationCollisions for why this cannot run inside buildPathMap.
335
+ const collisionIssues = detectDestinationCollisions(pathMapSkill, pathMap, outputPath, resourceNaming, namingBasePath);
254
336
  // 9. Build "to" registry for link rewriting (maps same resource IDs to output paths)
255
337
  const outputResources = bundledResources.map(resource => ({
256
338
  ...resource,
@@ -313,9 +395,16 @@ export async function packageSkill(skillPath, options = {}) {
313
395
  // Keep the dests it reports: they are what makes the orphan check below able to
314
396
  // tell "the author forgot to document this" from "VAT put this here because the
315
397
  // config said to." Discarding them makes the build fail on its own payload.
316
- const filesConfigDests = await applyFilesConfig({
398
+ const appliedFiles = await applyFilesConfig({
317
399
  filesConfig, projectRoot, skillOutputDir: outputPath, bundledFiles,
318
400
  });
401
+ const filesConfigDests = appliedFiles.dests;
402
+ // Dests a glob matched and the never-package list refused. Two consumers, and
403
+ // both are load-bearing: the broken-link check below needs them to tell a link
404
+ // broken by policy from a link broken by the rewriter, and the finding channel
405
+ // needs them or the structured report says `warnings: 0` about a build that
406
+ // silently shipped less than the config asked for.
407
+ const droppedGlobDests = appliedFiles.dropped.map((drop) => drop.dest);
319
408
  // 13. Post-build integrity check: no SKILL.md in subdirectories
320
409
  // A SKILL.md is a skill definition marker — it must only exist at the root.
321
410
  // If another skill's SKILL.md was bundled as a resource, it creates duplicate
@@ -329,8 +418,31 @@ export async function packageSkill(skillPath, options = {}) {
329
418
  // Walker-exclusion issues (depth drops, missing targets, outside-project, etc.)
330
419
  // are combined with post-build checks and run through the validation framework.
331
420
  const rawPostBuildIssues = [
421
+ // Found back at step 8, reported here: the path map is decided before any
422
+ // file is written, but a collision is a packaging FINDING and rides the same
423
+ // channel as every other one rather than aborting the run from inside a
424
+ // helper.
425
+ ...collisionIssues,
332
426
  ...await checkUnreferencedFiles(outputPath, filesConfigDests),
333
- ...await checkBrokenPackagedLinks(outputPath),
427
+ ...await checkBrokenPackagedLinks(outputPath, droppedGlobDests),
428
+ // A receipt for every file a glob matched and the never-package list refused.
429
+ // Reported as an issue, not written to stderr: a file vanishing from a bundle
430
+ // has to be visible in `issueCounts`, or CI reads a clean report for a build
431
+ // that quietly shipped less than the config declared.
432
+ // Anchored at the PROJECT root, not `outputPath` like its neighbours here: a
433
+ // dropped file is a source file that never reached the output, so the only
434
+ // path a reader can open is its source path.
435
+ ...droppedGlobMatchesToIssues(appliedFiles.dropped, projectRoot),
436
+ // Presence-side backstop for agent-instruction files. The walker excludes
437
+ // them from link-following, but a `files:` glob can still copy one in, and
438
+ // a file that arrives without a link is invisible to the link lane.
439
+ //
440
+ // Explicitly-declared dests are exempt (§8.2 precedence): the build KNOWS the
441
+ // config here, and an explicit `files:` entry is an instruction to ship that
442
+ // exact file. Reporting it fired this warning on the very config the guide
443
+ // prescribes as the escape hatch, with a remedy ("remove the file") that says
444
+ // to undo what the author was told to write.
445
+ ...detectPackagedAgentInstructionFiles(outputPath, outputPath, explicitFilesConfigDests(filesConfig)),
334
446
  // A receipt for each `files:` entry that was dropped for pointing into declared
335
447
  // test input — the build already produced the right artifact; this just says so.
336
448
  ...testInputFileEntryIssues(droppedTestInputFiles),
@@ -346,9 +458,26 @@ export async function packageSkill(skillPath, options = {}) {
346
458
  ...testInputLinkIssues(excludedReferences, testInputDirs, projectRoot),
347
459
  ...deferredAssetsToIssues(deferredAssets, projectRoot),
348
460
  ];
349
- const framework = runValidationFramework([...rawLinkIssues, ...rawPostBuildIssues], options.validation ?? {});
461
+ // ONE ledger for BOTH lanes below. `options.validation.allow` governs the
462
+ // build-receipt lane AND the built-SKILL.md lane, but the two see disjoint
463
+ // issue populations — so a lane that drains its own ledger calls "unused" an
464
+ // entry the OTHER lane matched. (Measured before the fix: an entry that
465
+ // suppressed a real LINK_MISSING_TARGET error still emitted ALLOW_UNUSED from
466
+ // the built-output lane, and a genuinely dead entry was reported twice.) Same
467
+ // defect, same fix as the validate lane — see
468
+ // `SkillValidationSharedContext.allowLedger`.
469
+ //
470
+ // Whose run it is comes from the caller. Given a ledger, this call is one unit
471
+ // of a larger run (a batch, and/or a source-tree validation pass that matches
472
+ // entries this build's two lanes structurally cannot) and must NOT drain —
473
+ // draining here is what reported a package-scoped entry matched while building
474
+ // skill A as unused while building skill B. Given none, the caller has claimed
475
+ // this call IS the run, so the drain below is honest.
476
+ const ownsRun = options.allowLedger === undefined;
477
+ const buildRunLedger = options.allowLedger ?? createAllowUsageLedger();
478
+ const framework = runValidationFramework([...rawLinkIssues, ...rawPostBuildIssues], options.validation ?? {}, buildRunLedger);
350
479
  // 13c. Run full validation suite on built output
351
- const postBuildValidation = await runPostBuildValidation(outputPath, options.validation);
480
+ const postBuildValidation = await runPostBuildValidation(outputPath, options.validation, buildRunLedger);
352
481
  // 14. Generate distribution artifacts
353
482
  const artifacts = await generatePackageArtifacts(outputPath, skillMetadata, formats, target);
354
483
  // Get relative paths for result
@@ -360,18 +489,37 @@ export async function packageSkill(skillPath, options = {}) {
360
489
  relativeLinkedFiles,
361
490
  artifacts,
362
491
  postBuildValidation,
363
- framework,
492
+ // Drain point — but only when this call owns the run. Both lanes have now
493
+ // contributed; whether anything ELSE still will is the caller's claim.
494
+ framework: ownsRun ? withRunAllowUnused(framework, buildRunLedger) : framework,
364
495
  excludedReferences,
365
496
  skillRoot,
366
497
  });
367
498
  }
499
+ /**
500
+ * Fold the build run's ALLOW_UNUSED verdict into the build-issue lane.
501
+ *
502
+ * ALLOW_UNUSED belongs to the RUN, not to whichever lane happened to be looking
503
+ * when an entry went unmatched — so it is drained once, here, and reported on
504
+ * the single channel (`postBuildIssues`) rather than on both.
505
+ */
506
+ function withRunAllowUnused(framework, ledger) {
507
+ const unused = allowUnusedIssues(ledger);
508
+ if (unused.length === 0)
509
+ return framework;
510
+ const emitted = [...framework.emitted, ...unused];
511
+ return { ...framework, emitted, hasErrors: emitted.some(i => i.severity === 'error') };
512
+ }
368
513
  /**
369
514
  * Run full validation suite against built output (context = 'built').
370
515
  * Source-only codes are automatically filtered out by validateSkillForPackaging.
516
+ *
517
+ * `allowLedger` is required, not optional: this lane is one half of a build, so
518
+ * it must never conclude on its own that an allow entry matched nothing.
371
519
  */
372
- async function runPostBuildValidation(outputPath, validation) {
520
+ async function runPostBuildValidation(outputPath, validation, allowLedger) {
373
521
  const builtSkillPath = safePath.join(outputPath, 'SKILL.md');
374
- return validateSkillForPackaging(builtSkillPath, validation ? { validation } : undefined, 'built');
522
+ return validateSkillForPackaging(builtSkillPath, validation ? { validation } : undefined, 'built', { allowLedger });
375
523
  }
376
524
  /**
377
525
  * Assemble the final PackageSkillResult from intermediate data.
@@ -387,7 +535,7 @@ function assemblePackageResult(input) {
387
535
  },
388
536
  artifacts: input.artifacts,
389
537
  postBuildValidation: input.postBuildValidation,
390
- hasErrors: input.framework.hasErrors || input.postBuildValidation.activeErrors.length > 0,
538
+ hasErrors: input.framework.hasErrors || input.postBuildValidation.status === 'error',
391
539
  };
392
540
  if (input.framework.emitted.length > 0) {
393
541
  result.postBuildIssues = input.framework.emitted;
@@ -597,17 +745,53 @@ function buildSyntheticAssetResource(assetPath, outputFilePath) {
597
745
  };
598
746
  }
599
747
  /**
600
- * Register single-file (non-glob) files config entries in the path map.
748
+ * Re-point every LINK-BUNDLED file a GLOB `files:` entry also claims at the dest
749
+ * that entry declares.
750
+ *
751
+ * A glob's expansion is late-bound to copy time, so its matches used to be absent
752
+ * from the path map entirely — and a match that link traversal ALSO discovered was
753
+ * therefore parked at its type-derived location (`resources/GUIDE.md`) while
754
+ * `applyFilesConfig` copied the same bytes to the declared dest
755
+ * (`packs/alpha/GUIDE.md`). Two identical files shipped, the rewritten link pointed
756
+ * at traversal's copy, the declared `dest:` was dead, and nothing said so — a size
757
+ * regression for anyone shipping multi-megabyte artifacts, and a `dest:` that lies
758
+ * whenever the same file is also referenced from prose.
759
+ *
760
+ * Only files already in `bundledFiles` are considered: this is not a second
761
+ * expansion of the glob (that stays with {@link copyGlobEntry}), it is the path map
762
+ * — the authority on where a file ends up — learning what the config already said.
763
+ */
764
+ function applyGlobEntryToPathMap(entry, projectRoot, outputPath, pathMap, bundledFiles) {
765
+ for (const bundled of bundledFiles) {
766
+ const dest = globEntryDest(entry, projectRoot, bundled);
767
+ if (dest === undefined)
768
+ continue;
769
+ // joinUnderRoot mirrors the non-glob branch's zip-slip guard below.
770
+ pathMap.set(toForwardSlash(bundled), safePath.joinUnderRoot(outputPath, dest));
771
+ }
772
+ }
773
+ /**
774
+ * Register `files:` config entries in the path map.
601
775
  *
602
- * GLOB entries are skipped — their source contains glob magic, never exists as a
603
- * literal path at build time, and would wrongly throw. Late binding via
604
- * applyFilesConfig owns their expansion at copy time.
776
+ * Two passes, GLOBS FIRST, and the order is the precedence rule: an explicit entry
777
+ * naming a file outranks a glob that merely caught it, exactly as it does on the
778
+ * copy side (see `partitionNeverPackaged` — a glob is a net, not a declaration).
779
+ * Applying globs second would let one silently overwrite the dest an author spelled
780
+ * out, and the explicit entry's own copy is then skipped as "already bundled at its
781
+ * dest" — so the declared file would never be written at all.
605
782
  *
606
783
  * Called from step 8b of packageSkill to keep the main function under the
607
784
  * cognitive-complexity limit.
608
785
  */
609
- function applyNonGlobEntriesToPathMap(filesConfig, projectRoot, outputPath, pathMap, skillName) {
786
+ function applyFilesEntriesToPathMap(filesConfig, projectRoot, outputPath, pathMap, skillName, bundledFiles) {
610
787
  for (const fileEntry of filesConfig) {
788
+ if (isGlob(fileEntry.source)) {
789
+ applyGlobEntryToPathMap(fileEntry, projectRoot, outputPath, pathMap, bundledFiles);
790
+ }
791
+ }
792
+ for (const fileEntry of filesConfig) {
793
+ // A glob source contains magic, never exists as a literal path, and would
794
+ // wrongly throw the existence check below; its dests were handled above.
611
795
  if (isGlob(fileEntry.source))
612
796
  continue;
613
797
  const absoluteSource = safePath.resolve(safePath.join(projectRoot, fileEntry.source));
@@ -760,37 +944,107 @@ function stampDeferredDestResolvedId(resources, absDestTarget, id) {
760
944
  */
761
945
  export function getResourceSubdirForFile(filePath, target) {
762
946
  if (target === 'claude-web') {
763
- return 'references';
947
+ return CLAUDE_WEB_REFERENCES_SUBDIR;
764
948
  }
765
949
  return getTargetSubdir(filePath);
766
950
  }
767
951
  /**
768
- * Build a map of source paths (forward-slash normalized) to output paths.
769
- * Checks for filename collisions.
952
+ * The FILENAME_COLLISION finding for one pair of sources that package to one dest.
953
+ *
954
+ * Naming the skill is the whole point of the first line: neither colliding file
955
+ * need be referenced by SKILL.md directly (both are commonly reached by deep
956
+ * link traversal), so without it the only way to find the owner in a large
957
+ * batch is to bisect it one skill at a time.
958
+ *
959
+ * By NAME, not by absolute path: this message is published verbatim in
960
+ * `vat skills build`'s stdout payload, the name is what that payload's per-skill
961
+ * rows already key on, and a `/Users/<someone>/…` prefix answers no question the
962
+ * reader has while naming the machine the build ran on. The colliding files
963
+ * follow the same rule — stated in the project's coordinates, like every other
964
+ * "where" this package renders (`issueLocation`).
965
+ *
966
+ * `location` is the SECOND file: of the two, it is the one whose packaging the
967
+ * first pre-empted, so it is the one an author renames. The remedy that is not
968
+ * a rename (switch `resourceNaming`) comes from the registry `fix`.
770
969
  */
771
- function buildPathMap(skillPath, bundledFiles, outputPath, resourceNaming, namingBasePath, stripPrefix, target = DEFAULT_PACKAGING_TARGET) {
970
+ function filenameCollisionIssue(skill, existingSource, linkedFile, targetRelPath, resourceNaming, namingBasePath) {
971
+ const location = issueLocation(linkedFile, namingBasePath);
972
+ return materializeIssue('FILENAME_COLLISION', {
973
+ location,
974
+ message: `Filename collision detected when packaging skill: ${skill.name} — ` +
975
+ `File 1: ${issueLocation(existingSource, namingBasePath)}, ` +
976
+ `File 2: ${location}; both would be packaged as ${targetRelPath} ` +
977
+ `(current resourceNaming strategy: ${resourceNaming})`,
978
+ });
979
+ }
980
+ /**
981
+ * Build a map of source paths (forward-slash normalized) to output paths, plus
982
+ * a FILENAME_COLLISION finding for every pair of sources that land on one dest.
983
+ *
984
+ * A collision is REPORTED, not thrown. It used to throw a raw `Error`, which
985
+ * escaped the contract every other packaging finding honours — the caller got a
986
+ * bare string instead of a coded, located, fixable `ValidationIssue`, and the
987
+ * batch lane had to special-case it. The build still fails: the finding's
988
+ * registry severity is `error`, so it flips `hasErrors`.
989
+ *
990
+ * The colliding source keeps its path-map entry rather than being dropped. The
991
+ * build is failing either way, and keeping it means the link rewriter still
992
+ * resolves both links to a file that exists in the output — dropping it would
993
+ * add a second, derived PACKAGED_BROKEN_LINK on top of the real finding and
994
+ * point the surviving link at an unrewritable source path.
995
+ *
996
+ * @param namingBasePath - The project root. Resource names are generated
997
+ * relative to it AND every path this function REPORTS is stated in its
998
+ * coordinates, so no message leaves here in machine-specific terms.
999
+ */
1000
+ function buildPathMap(skill, bundledFiles, outputPath, resourceNaming, namingBasePath, stripPrefix, target = DEFAULT_PACKAGING_TARGET) {
772
1001
  const pathMap = new Map();
773
- pathMap.set(toForwardSlash(skillPath), safePath.join(outputPath, 'SKILL.md'));
1002
+ pathMap.set(toForwardSlash(skill.path), safePath.join(outputPath, 'SKILL.md'));
774
1003
  for (const linkedFile of bundledFiles) {
775
1004
  const targetRelPath = generateTargetPath(linkedFile, namingBasePath, resourceNaming, stripPrefix);
776
1005
  const fileSubdir = getResourceSubdirForFile(linkedFile, target);
777
- const targetPath = safePath.join(outputPath, fileSubdir, targetRelPath);
778
- // Check for filename collisions
779
- const existingSource = [...pathMap.entries()].find(([_src, target]) => target === targetPath)?.[0];
780
- if (existingSource !== undefined && existingSource !== toForwardSlash(linkedFile)) {
781
- throw new Error(`Filename collision detected when packaging skill:\n` +
782
- ` File 1: ${existingSource}\n` +
783
- ` File 2: ${linkedFile}\n` +
784
- ` Both would be packaged as: ${targetRelPath}\n` +
785
- `\n` +
786
- ` To resolve: Use a different resourceNaming strategy or ensure unique filenames.\n` +
787
- ` Current strategy: ${resourceNaming}\n` +
788
- ` Try 'resource-id' or 'preserve-path' for path-based naming.`);
789
- }
790
- pathMap.set(toForwardSlash(linkedFile), targetPath);
1006
+ pathMap.set(toForwardSlash(linkedFile), safePath.join(outputPath, fileSubdir, targetRelPath));
791
1007
  }
792
1008
  return pathMap;
793
1009
  }
1010
+ /**
1011
+ * Report a FILENAME_COLLISION for every destination claimed by more than one source.
1012
+ *
1013
+ * MUST run against the FINAL destination map — after `files:` single-file entries
1014
+ * have overridden the naming-strategy destinations. Detecting collisions while
1015
+ * building the map (where this logic used to live) answers the question one step
1016
+ * too early: `files:` is a legitimate remedy for a basename collision, and an
1017
+ * adopter who remapped both sides to distinct dests still had the build failed at
1018
+ * `error` severity for a collision that no longer physically occurred. It also
1019
+ * could not see the inverse — two `files:` entries pointing at ONE dest is a real
1020
+ * collision the naming-strategy map contains no trace of.
1021
+ *
1022
+ * Scope: glob `files:` entries expand later (`applyFilesConfig`) and so are not in
1023
+ * this map; their destinations are directories, which cannot collide this way.
1024
+ */
1025
+ function detectDestinationCollisions(skill, pathMap, outputPath, resourceNaming, namingBasePath) {
1026
+ const sourcesByDest = new Map();
1027
+ for (const [source, dest] of pathMap) {
1028
+ const existing = sourcesByDest.get(dest);
1029
+ if (existing)
1030
+ existing.push(source);
1031
+ else
1032
+ sourcesByDest.set(dest, [source]);
1033
+ }
1034
+ const issues = [];
1035
+ for (const [dest, sources] of sourcesByDest) {
1036
+ // Map iteration is insertion-ordered, so sources[0] is the entry that "won"
1037
+ // the destination — the same File 1 / File 2 framing the message always used.
1038
+ const [winner, ...losers] = sources;
1039
+ if (winner === undefined || losers.length === 0)
1040
+ continue;
1041
+ const targetRelPath = toForwardSlash(safePath.relative(outputPath, dest));
1042
+ for (const loser of losers) {
1043
+ issues.push(filenameCollisionIssue(skill, winner, loser, targetRelPath, resourceNaming, namingBasePath));
1044
+ }
1045
+ }
1046
+ return issues;
1047
+ }
794
1048
  // ============================================================================
795
1049
  // Rewrite Rules
796
1050
  // ============================================================================