@vibe-agent-toolkit/agent-skills 0.1.42 → 0.2.0-rc.10

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 (322) hide show
  1. package/README.md +5 -5
  2. package/dist/builder.d.ts.map +1 -1
  3. package/dist/builder.js +7 -17
  4. package/dist/builder.js.map +1 -1
  5. package/dist/content-type-routing.d.ts +32 -0
  6. package/dist/content-type-routing.d.ts.map +1 -1
  7. package/dist/content-type-routing.js +23 -0
  8. package/dist/content-type-routing.js.map +1 -1
  9. package/dist/files-config.d.ts +34 -1
  10. package/dist/files-config.d.ts.map +1 -1
  11. package/dist/files-config.js +277 -39
  12. package/dist/files-config.js.map +1 -1
  13. package/dist/fs-attribution.d.ts +45 -0
  14. package/dist/fs-attribution.d.ts.map +1 -0
  15. package/dist/fs-attribution.js +57 -0
  16. package/dist/fs-attribution.js.map +1 -0
  17. package/dist/import.d.ts.map +1 -1
  18. package/dist/import.js +0 -4
  19. package/dist/import.js.map +1 -1
  20. package/dist/index.d.ts +13 -8
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +25 -7
  23. package/dist/index.js.map +1 -1
  24. package/dist/inventory/detectors/declared-but-missing.d.ts +1 -1
  25. package/dist/inventory/detectors/declared-but-missing.d.ts.map +1 -1
  26. package/dist/inventory/detectors/marketplace-source-missing.d.ts +1 -1
  27. package/dist/inventory/detectors/marketplace-source-missing.d.ts.map +1 -1
  28. package/dist/inventory/detectors/present-but-undeclared.d.ts +1 -1
  29. package/dist/inventory/detectors/present-but-undeclared.d.ts.map +1 -1
  30. package/dist/inventory/detectors/reference-target-missing.d.ts +1 -1
  31. package/dist/inventory/detectors/reference-target-missing.d.ts.map +1 -1
  32. package/dist/inventory/index.d.ts +1 -1
  33. package/dist/inventory/index.d.ts.map +1 -1
  34. package/dist/inventory/index.js +1 -1
  35. package/dist/inventory/index.js.map +1 -1
  36. package/dist/inventory/serialize.d.ts +4 -4
  37. package/dist/inventory/serialize.d.ts.map +1 -1
  38. package/dist/inventory/serialize.js +6 -6
  39. package/dist/inventory/serialize.js.map +1 -1
  40. package/dist/inventory/types.d.ts +8 -0
  41. package/dist/inventory/types.d.ts.map +1 -1
  42. package/dist/inventory/types.js.map +1 -1
  43. package/dist/link-graph-members.d.ts +18 -0
  44. package/dist/link-graph-members.d.ts.map +1 -0
  45. package/dist/link-graph-members.js +18 -0
  46. package/dist/link-graph-members.js.map +1 -0
  47. package/dist/parsers/frontmatter-parser.d.ts.map +1 -1
  48. package/dist/parsers/frontmatter-parser.js +30 -6
  49. package/dist/parsers/frontmatter-parser.js.map +1 -1
  50. package/dist/plugin-distribution-layout.d.ts +70 -6
  51. package/dist/plugin-distribution-layout.d.ts.map +1 -1
  52. package/dist/plugin-distribution-layout.js +84 -11
  53. package/dist/plugin-distribution-layout.js.map +1 -1
  54. package/dist/post-build-checks.d.ts +38 -1
  55. package/dist/post-build-checks.d.ts.map +1 -1
  56. package/dist/post-build-checks.js +68 -17
  57. package/dist/post-build-checks.js.map +1 -1
  58. package/dist/projection/skill-extent.d.ts +247 -0
  59. package/dist/projection/skill-extent.d.ts.map +1 -0
  60. package/dist/projection/skill-extent.js +560 -0
  61. package/dist/projection/skill-extent.js.map +1 -0
  62. package/dist/schemas/agent-skill-frontmatter.js +1 -1
  63. package/dist/schemas/agent-skill-frontmatter.js.map +1 -1
  64. package/dist/schemas/allowed-tools.d.ts +31 -0
  65. package/dist/schemas/allowed-tools.d.ts.map +1 -0
  66. package/dist/schemas/allowed-tools.js +88 -0
  67. package/dist/schemas/allowed-tools.js.map +1 -0
  68. package/dist/schemas/index.d.ts +1 -0
  69. package/dist/schemas/index.d.ts.map +1 -1
  70. package/dist/schemas/index.js +1 -0
  71. package/dist/schemas/index.js.map +1 -1
  72. package/dist/schemas/installed-plugins-registry.d.ts.map +1 -1
  73. package/dist/schemas/installed-plugins-registry.js +7 -1
  74. package/dist/schemas/installed-plugins-registry.js.map +1 -1
  75. package/dist/schemas/marketplace-manifest.d.ts.map +1 -1
  76. package/dist/schemas/marketplace-manifest.js +17 -6
  77. package/dist/schemas/marketplace-manifest.js.map +1 -1
  78. package/dist/skill-identity.d.ts +18 -3
  79. package/dist/skill-identity.d.ts.map +1 -1
  80. package/dist/skill-identity.js +25 -6
  81. package/dist/skill-identity.js.map +1 -1
  82. package/dist/skill-packager.d.ts +144 -20
  83. package/dist/skill-packager.d.ts.map +1 -1
  84. package/dist/skill-packager.js +447 -105
  85. package/dist/skill-packager.js.map +1 -1
  86. package/dist/skill-source/content-hash.d.ts.map +1 -1
  87. package/dist/skill-source/content-hash.js +21 -10
  88. package/dist/skill-source/content-hash.js.map +1 -1
  89. package/dist/skill-source/fetch-cache.d.ts.map +1 -1
  90. package/dist/skill-source/fetch-cache.js +0 -5
  91. package/dist/skill-source/fetch-cache.js.map +1 -1
  92. package/dist/skill-source/git-clone.d.ts +1 -1
  93. package/dist/skill-source/git-clone.d.ts.map +1 -1
  94. package/dist/skill-source/git-clone.js +10 -10
  95. package/dist/skill-source/git-clone.js.map +1 -1
  96. package/dist/skill-source/sources/url-source.d.ts.map +1 -1
  97. package/dist/skill-source/sources/url-source.js +2 -2
  98. package/dist/skill-source/sources/url-source.js.map +1 -1
  99. package/dist/skill-source/stage.d.ts.map +1 -1
  100. package/dist/skill-source/stage.js +0 -6
  101. package/dist/skill-source/stage.js.map +1 -1
  102. package/dist/skill-test/baseline-delta.d.ts +1575 -0
  103. package/dist/skill-test/baseline-delta.d.ts.map +1 -0
  104. package/dist/skill-test/baseline-delta.js +272 -0
  105. package/dist/skill-test/baseline-delta.js.map +1 -0
  106. package/dist/skill-test/baseline-integrity.d.ts +944 -0
  107. package/dist/skill-test/baseline-integrity.d.ts.map +1 -0
  108. package/dist/skill-test/baseline-integrity.js +3090 -0
  109. package/dist/skill-test/baseline-integrity.js.map +1 -0
  110. package/dist/skill-test/build-hook.d.ts +3 -2
  111. package/dist/skill-test/build-hook.d.ts.map +1 -1
  112. package/dist/skill-test/build-hook.js +4 -5
  113. package/dist/skill-test/build-hook.js.map +1 -1
  114. package/dist/skill-test/configure-writer.js +1 -1
  115. package/dist/skill-test/configure-writer.js.map +1 -1
  116. package/dist/skill-test/declared-env.d.ts +5 -4
  117. package/dist/skill-test/declared-env.d.ts.map +1 -1
  118. package/dist/skill-test/declared-env.js +8 -9
  119. package/dist/skill-test/declared-env.js.map +1 -1
  120. package/dist/skill-test/eval-executor.d.ts +21 -5
  121. package/dist/skill-test/eval-executor.d.ts.map +1 -1
  122. package/dist/skill-test/eval-executor.js +6 -6
  123. package/dist/skill-test/eval-executor.js.map +1 -1
  124. package/dist/skill-test/eval-fragment.d.ts +106 -28
  125. package/dist/skill-test/eval-fragment.d.ts.map +1 -1
  126. package/dist/skill-test/eval-fragment.js +78 -16
  127. package/dist/skill-test/eval-fragment.js.map +1 -1
  128. package/dist/skill-test/eval-grader.d.ts +65 -7
  129. package/dist/skill-test/eval-grader.d.ts.map +1 -1
  130. package/dist/skill-test/eval-grader.js +263 -64
  131. package/dist/skill-test/eval-grader.js.map +1 -1
  132. package/dist/skill-test/eval-inputs.d.ts +110 -8
  133. package/dist/skill-test/eval-inputs.d.ts.map +1 -1
  134. package/dist/skill-test/eval-inputs.js +177 -22
  135. package/dist/skill-test/eval-inputs.js.map +1 -1
  136. package/dist/skill-test/eval-lint.d.ts +17 -4
  137. package/dist/skill-test/eval-lint.d.ts.map +1 -1
  138. package/dist/skill-test/eval-lint.js +22 -9
  139. package/dist/skill-test/eval-lint.js.map +1 -1
  140. package/dist/skill-test/eval-suite-isolation.d.ts.map +1 -1
  141. package/dist/skill-test/eval-suite-isolation.js +8 -5
  142. package/dist/skill-test/eval-suite-isolation.js.map +1 -1
  143. package/dist/skill-test/evals-template.d.ts +1 -1
  144. package/dist/skill-test/evals-template.d.ts.map +1 -1
  145. package/dist/skill-test/evals-template.js +1 -3
  146. package/dist/skill-test/evals-template.js.map +1 -1
  147. package/dist/skill-test/executor-prompt.d.ts +15 -7
  148. package/dist/skill-test/executor-prompt.d.ts.map +1 -1
  149. package/dist/skill-test/executor-prompt.js +10 -7
  150. package/dist/skill-test/executor-prompt.js.map +1 -1
  151. package/dist/skill-test/failure-reason.d.ts +113 -0
  152. package/dist/skill-test/failure-reason.d.ts.map +1 -0
  153. package/dist/skill-test/failure-reason.js +124 -0
  154. package/dist/skill-test/failure-reason.js.map +1 -0
  155. package/dist/skill-test/fragment-merge.d.ts +3 -20
  156. package/dist/skill-test/fragment-merge.d.ts.map +1 -1
  157. package/dist/skill-test/fragment-merge.js +67 -8
  158. package/dist/skill-test/fragment-merge.js.map +1 -1
  159. package/dist/skill-test/grader-prompt.d.ts +29 -9
  160. package/dist/skill-test/grader-prompt.d.ts.map +1 -1
  161. package/dist/skill-test/grader-prompt.js +264 -68
  162. package/dist/skill-test/grader-prompt.js.map +1 -1
  163. package/dist/skill-test/grader-text.d.ts +145 -0
  164. package/dist/skill-test/grader-text.d.ts.map +1 -0
  165. package/dist/skill-test/grader-text.js +410 -0
  166. package/dist/skill-test/grader-text.js.map +1 -0
  167. package/dist/skill-test/grading-adapter.d.ts +38 -2
  168. package/dist/skill-test/grading-adapter.d.ts.map +1 -1
  169. package/dist/skill-test/grading-adapter.js +57 -9
  170. package/dist/skill-test/grading-adapter.js.map +1 -1
  171. package/dist/skill-test/grading-schema.d.ts +160 -5
  172. package/dist/skill-test/grading-schema.d.ts.map +1 -1
  173. package/dist/skill-test/grading-schema.js +66 -7
  174. package/dist/skill-test/grading-schema.js.map +1 -1
  175. package/dist/skill-test/harness-location.d.ts +3 -2
  176. package/dist/skill-test/harness-location.d.ts.map +1 -1
  177. package/dist/skill-test/harness-location.js +5 -16
  178. package/dist/skill-test/harness-location.js.map +1 -1
  179. package/dist/skill-test/index.d.ts +2 -2
  180. package/dist/skill-test/index.d.ts.map +1 -1
  181. package/dist/skill-test/index.js +2 -2
  182. package/dist/skill-test/index.js.map +1 -1
  183. package/dist/skill-test/lock.d.ts +58 -1
  184. package/dist/skill-test/lock.d.ts.map +1 -1
  185. package/dist/skill-test/lock.js +78 -6
  186. package/dist/skill-test/lock.js.map +1 -1
  187. package/dist/skill-test/pipeline.d.ts +19 -1
  188. package/dist/skill-test/pipeline.d.ts.map +1 -1
  189. package/dist/skill-test/pipeline.js +21 -10
  190. package/dist/skill-test/pipeline.js.map +1 -1
  191. package/dist/skill-test/preflight.d.ts +1 -1
  192. package/dist/skill-test/preflight.d.ts.map +1 -1
  193. package/dist/skill-test/preflight.js +59 -16
  194. package/dist/skill-test/preflight.js.map +1 -1
  195. package/dist/skill-test/prompt-invariants.d.ts +4 -2
  196. package/dist/skill-test/prompt-invariants.d.ts.map +1 -1
  197. package/dist/skill-test/prompt-invariants.js +5 -4
  198. package/dist/skill-test/prompt-invariants.js.map +1 -1
  199. package/dist/skill-test/run-harness.d.ts +665 -85
  200. package/dist/skill-test/run-harness.d.ts.map +1 -1
  201. package/dist/skill-test/run-harness.js +1522 -237
  202. package/dist/skill-test/run-harness.js.map +1 -1
  203. package/dist/skill-test/staging.d.ts +20 -8
  204. package/dist/skill-test/staging.d.ts.map +1 -1
  205. package/dist/skill-test/staging.js +25 -18
  206. package/dist/skill-test/staging.js.map +1 -1
  207. package/dist/skill-test/tier-plan.d.ts +1 -1
  208. package/dist/skill-test/tier-plan.js +1 -1
  209. package/dist/skill-test/tool-eval-schema.d.ts +6 -6
  210. package/dist/skill-test/tool-eval-schema.js +2 -2
  211. package/dist/skill-test/vendor-manifest.d.ts +2 -1
  212. package/dist/skill-test/vendor-manifest.d.ts.map +1 -1
  213. package/dist/skill-test/vendor-manifest.js +10 -10
  214. package/dist/skill-test/vendor-manifest.js.map +1 -1
  215. package/dist/test-input.d.ts +59 -8
  216. package/dist/test-input.d.ts.map +1 -1
  217. package/dist/test-input.js +64 -19
  218. package/dist/test-input.js.map +1 -1
  219. package/dist/validators/agent-instruction-presence.d.ts +1 -1
  220. package/dist/validators/agent-instruction-presence.d.ts.map +1 -1
  221. package/dist/validators/agent-instruction-presence.js +45 -2
  222. package/dist/validators/agent-instruction-presence.js.map +1 -1
  223. package/dist/validators/bundled-resource-link-detection.d.ts +6 -4
  224. package/dist/validators/bundled-resource-link-detection.d.ts.map +1 -1
  225. package/dist/validators/bundled-resource-link-detection.js +39 -39
  226. package/dist/validators/bundled-resource-link-detection.js.map +1 -1
  227. package/dist/validators/compat-detectors.d.ts +18 -1
  228. package/dist/validators/compat-detectors.d.ts.map +1 -1
  229. package/dist/validators/compat-detectors.js +47 -20
  230. package/dist/validators/compat-detectors.js.map +1 -1
  231. package/dist/validators/cross-skill-dependency-detection.d.ts +1 -1
  232. package/dist/validators/cross-skill-dependency-detection.d.ts.map +1 -1
  233. package/dist/validators/cross-skill-dependency-detection.js +1 -1
  234. package/dist/validators/cross-skill-dependency-detection.js.map +1 -1
  235. package/dist/validators/description-style-detection.d.ts +1 -1
  236. package/dist/validators/description-style-detection.d.ts.map +1 -1
  237. package/dist/validators/description-style-detection.js +1 -1
  238. package/dist/validators/description-style-detection.js.map +1 -1
  239. package/dist/validators/format-detection.d.ts +3 -0
  240. package/dist/validators/format-detection.d.ts.map +1 -1
  241. package/dist/validators/format-detection.js +41 -26
  242. package/dist/validators/format-detection.js.map +1 -1
  243. package/dist/validators/frontmatter-validation.d.ts +1 -1
  244. package/dist/validators/frontmatter-validation.d.ts.map +1 -1
  245. package/dist/validators/frontmatter-validation.js +2 -2
  246. package/dist/validators/frontmatter-validation.js.map +1 -1
  247. package/dist/validators/imperative-body-detection.d.ts +1 -1
  248. package/dist/validators/imperative-body-detection.d.ts.map +1 -1
  249. package/dist/validators/imperative-body-detection.js +1 -1
  250. package/dist/validators/imperative-body-detection.js.map +1 -1
  251. package/dist/validators/index.d.ts +1 -1
  252. package/dist/validators/index.d.ts.map +1 -1
  253. package/dist/validators/kebab-case-detection.d.ts +1 -1
  254. package/dist/validators/kebab-case-detection.d.ts.map +1 -1
  255. package/dist/validators/kebab-case-detection.js +1 -1
  256. package/dist/validators/kebab-case-detection.js.map +1 -1
  257. package/dist/validators/marketplace-validator.d.ts.map +1 -1
  258. package/dist/validators/marketplace-validator.js +7 -2
  259. package/dist/validators/marketplace-validator.js.map +1 -1
  260. package/dist/validators/mcp-tool-qualification.d.ts +158 -0
  261. package/dist/validators/mcp-tool-qualification.d.ts.map +1 -0
  262. package/dist/validators/mcp-tool-qualification.js +467 -0
  263. package/dist/validators/mcp-tool-qualification.js.map +1 -0
  264. package/dist/validators/packaged-size-limit.d.ts +296 -0
  265. package/dist/validators/packaged-size-limit.d.ts.map +1 -0
  266. package/dist/validators/packaged-size-limit.js +492 -0
  267. package/dist/validators/packaged-size-limit.js.map +1 -0
  268. package/dist/validators/packaging-validator.d.ts +149 -10
  269. package/dist/validators/packaging-validator.d.ts.map +1 -1
  270. package/dist/validators/packaging-validator.js +371 -55
  271. package/dist/validators/packaging-validator.js.map +1 -1
  272. package/dist/validators/plugin-hosted-shape.d.ts +4 -2
  273. package/dist/validators/plugin-hosted-shape.d.ts.map +1 -1
  274. package/dist/validators/plugin-hosted-shape.js +6 -7
  275. package/dist/validators/plugin-hosted-shape.js.map +1 -1
  276. package/dist/validators/plugin-recommended-fields.d.ts +1 -1
  277. package/dist/validators/plugin-recommended-fields.d.ts.map +1 -1
  278. package/dist/validators/plugin-recommended-fields.js +1 -1
  279. package/dist/validators/plugin-recommended-fields.js.map +1 -1
  280. package/dist/validators/referenced-path-missing.d.ts +211 -0
  281. package/dist/validators/referenced-path-missing.d.ts.map +1 -0
  282. package/dist/validators/referenced-path-missing.js +378 -0
  283. package/dist/validators/referenced-path-missing.js.map +1 -0
  284. package/dist/validators/registry-validator.d.ts.map +1 -1
  285. package/dist/validators/registry-validator.js +1 -2
  286. package/dist/validators/registry-validator.js.map +1 -1
  287. package/dist/validators/rule-engine/index.d.ts +1 -1
  288. package/dist/validators/rule-engine/rule-context.d.ts +20 -3
  289. package/dist/validators/rule-engine/rule-context.d.ts.map +1 -1
  290. package/dist/validators/rule-engine/rule-context.js +3 -1
  291. package/dist/validators/rule-engine/rule-context.js.map +1 -1
  292. package/dist/validators/rule-engine/rule-engine.d.ts +2 -2
  293. package/dist/validators/rule-engine/rule-engine.d.ts.map +1 -1
  294. package/dist/validators/rule-engine/rule-engine.js +52 -13
  295. package/dist/validators/rule-engine/rule-engine.js.map +1 -1
  296. package/dist/validators/skill-validator.d.ts.map +1 -1
  297. package/dist/validators/skill-validator.js +91 -54
  298. package/dist/validators/skill-validator.js.map +1 -1
  299. package/dist/validators/source-only-codes.js +1 -1
  300. package/dist/validators/source-only-codes.js.map +1 -1
  301. package/dist/validators/types.d.ts +21 -1
  302. package/dist/validators/types.d.ts.map +1 -1
  303. package/dist/validators/unified-validator.d.ts.map +1 -1
  304. package/dist/validators/unified-validator.js +4 -5
  305. package/dist/validators/unified-validator.js.map +1 -1
  306. package/dist/validators/validation-rules.d.ts +4 -4
  307. package/dist/validators/validation-rules.d.ts.map +1 -1
  308. package/dist/validators/validation-rules.js +9 -38
  309. package/dist/validators/validation-rules.js.map +1 -1
  310. package/dist/validators/walker-to-issues.d.ts +9 -3
  311. package/dist/validators/walker-to-issues.d.ts.map +1 -1
  312. package/dist/validators/walker-to-issues.js +64 -19
  313. package/dist/validators/walker-to-issues.js.map +1 -1
  314. package/dist/walk-link-graph.d.ts +64 -5
  315. package/dist/walk-link-graph.d.ts.map +1 -1
  316. package/dist/walk-link-graph.js +376 -92
  317. package/dist/walk-link-graph.js.map +1 -1
  318. package/package.json +14 -22
  319. package/dist/skill-test/exit-codes.d.ts +0 -97
  320. package/dist/skill-test/exit-codes.d.ts.map +0 -1
  321. package/dist/skill-test/exit-codes.js +0 -141
  322. package/dist/skill-test/exit-codes.js.map +0 -1
@@ -17,19 +17,25 @@
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 { allowUnusedIssues, createAllowUsageLedger, runValidationFramework, } from '@vibe-agent-toolkit/agent-schema';
21
- import { DeferredArtifacts, ResourceRegistry, loadConfig, openFrontmatter, resolveLocalHref, rewriteFrontmatterUriReferencesFromSchema, rewriteHtmlLinks, transformContent, parseMarkdown, } from '@vibe-agent-toolkit/resources';
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
- import { checkBrokenPackagedLinks, checkUnreferencedFiles } from './post-build-checks.js';
20
+ import { DeferredArtifacts, DuplicateResourceIdError, ResourceRegistry, loadConfig, openFrontmatter, resolveLocalHref, rewriteFrontmatterUriReferencesFromSchema, rewriteHtmlLinks, transformContent, parseFileCached, } from '@vibe-agent-toolkit/resources';
21
+ import { allowUnusedIssues, createAllowUsageLedger, runValidationFramework, } from '@vibe-agent-toolkit/schema';
22
+ import { direntKindFollowingSync, findProjectRoot, isGlob, isSingleFsSegment, issueLocation, resolveAssetReference, safePath, toForwardSlash, toForwardSlashAnyPlatform, VatError, } from '@vibe-agent-toolkit/utils';
23
+ import { readTextContent } from '@vibe-agent-toolkit/utils/fs';
24
+ import { getResourceSubdirForFile } from './content-type-routing.js';
25
+ import { applyFilesConfig, buildArtifactHint, droppedGlobMatchesToIssues, explicitFilesConfigDests, globEntryDest, skippedGlobMatchesToIssues, } from './files-config.js';
26
+ import { READ_REMEDY, withFsAttribution } from './fs-attribution.js';
27
+ import { LINK_GRAPH_MEMBER_GLOBS } from './link-graph-members.js';
28
+ import { checkBrokenPackagedLinks, checkMissingReferencedPaths, checkUnreferencedFiles } from './post-build-checks.js';
26
29
  import { checkPackagedTestInput, partitionTestInputFileEntries, resolveTestInputDirs, testInputExcludeRules, testInputFileEntryIssues, testInputLinkIssues, } from './test-input.js';
27
30
  import { detectPackagedAgentInstructionFiles } from './validators/agent-instruction-presence.js';
31
+ import { checkPackagedSizeLimit } from './validators/packaged-size-limit.js';
28
32
  import { validateSkillForPackaging } from './validators/packaging-validator.js';
29
33
  import { materializeIssue } from './validators/rule-engine/index.js';
30
- import { deferredAssetsToIssues, walkerExclusionsToIssues } from './validators/walker-to-issues.js';
34
+ import { deferredAssetsToIssues, outsideSkillDirLinksToIssues, walkerExclusionsToIssues } from './validators/walker-to-issues.js';
31
35
  import { walkLinkGraph } from './walk-link-graph.js';
32
36
  const PACKAGE_JSON_FILENAME = 'package.json';
37
+ /** `withFsAttribution` action for a write in `copyAndRewriteFile` — never a copy, since the bytes are rewritten first. */
38
+ const WRITE_ACTION = 'written into the bundle';
33
39
  /**
34
40
  * Default template for excluded links when no explicit template is configured —
35
41
  * renders just the link text.
@@ -37,18 +43,20 @@ const PACKAGE_JSON_FILENAME = 'package.json';
37
43
  * `{{link.rawText}}`, not `{{link.text}}`, for the same two reasons the rewrite
38
44
  * branch uses it (see `bundledLinkTemplate`):
39
45
  *
40
- * - **Correlation.** `transformContent` keys parsed links by href and lets the
41
- * FIRST occurrence win, so `link.text` on a second link sharing that href is
42
- * the first link's text. `rawText` is the regex's per-occurrence capture, so it
43
- * always belongs to the link being replaced. Stripping with `link.text` swapped
44
- * one phrase for an unrelated one in shipped prose; the rewrite branch never
45
- * exposed it because it re-emits `rawText`.
46
+ * - **Per-occurrence identity.** `link.text` is a property of the parsed link,
47
+ * and two occurrences sharing an href are two links whose text may differ.
48
+ * `rawText` is always the text of the occurrence actually being replaced, so
49
+ * it cannot swap one phrase for an unrelated one in shipped prose.
46
50
  * - **Formatting.** `rawText` keeps the inline markup the author wrote, so
47
51
  * ``[`foo.yaml`](…)`` strips to ``` `foo.yaml` ``` rather than bare `foo.yaml`.
48
52
  *
49
- * Text was the ONLY field the collision could corrupt: the map key is the full
50
- * href including any `#fragment`, so two links that collide there necessarily
51
- * resolve to the same resource and carry the same fragment.
53
+ * > This used to explain the first reason as a defence against `transformContent`
54
+ * > keying parsed links by href and letting the FIRST occurrence win. That
55
+ * > correlation is gone: `transformContent` now splices each parsed link at its
56
+ * > own `[startOffset, endOffset)` span, so there is no shared-href map to collide
57
+ * > in for any link the parser located. `rawText` remains correct and remains the
58
+ * > right choice — the reasoning above no longer depends on a collision that the
59
+ * > rewriter can still have.
52
60
  */
53
61
  const DEFAULT_STRIP_TEMPLATE = '{{link.rawText}}';
54
62
  /**
@@ -105,8 +113,16 @@ const DEFAULT_PACKAGING_TARGET = 'claude-code';
105
113
  * repo has been bitten by). It is required rather than defaulted because the test-input
106
114
  * rule is project-wide: omitting it silently packages another skill's eval answer key.
107
115
  * A lane with genuinely no project to enumerate passes `[]` explicitly.
116
+ *
117
+ * `hasConventionalSuite` is the RUN's conventional-suite probe, and travels with
118
+ * `projectSkills` for the same reason and on the same schedule: this conversion runs
119
+ * once per skill and asks the filesystem about every root in `projectSkills`, so a
120
+ * per-call probe makes a build of S skills cost S² probes over S paths. Required, not
121
+ * defaulted — a `probe?` would let every existing caller keep the quadratic silently,
122
+ * which is precisely how the `projectSkills` rule above once shipped as a no-op. A
123
+ * lane packaging a single skill calls `conventionalSuiteProbe()` at its own call site.
108
124
  */
109
- export function packagingConfigToPackageOptions(config, anchors, projectSkills) {
125
+ export function packagingConfigToPackageOptions(config, anchors, projectSkills, hasConventionalSuite) {
110
126
  return {
111
127
  outputPath: anchors.outputPath,
112
128
  formats: ['directory'],
@@ -135,7 +151,7 @@ export function packagingConfigToPackageOptions(config, anchors, projectSkills)
135
151
  // excluded as well. Keyed to this skill alone, that sibling's answer key was
136
152
  // ordinary content and shipped.
137
153
  ...(() => {
138
- const testInputDirs = resolveTestInputDirs(config, dirname(anchors.skillPath), projectSkills);
154
+ const testInputDirs = resolveTestInputDirs(config, dirname(anchors.skillPath), projectSkills, hasConventionalSuite);
139
155
  return testInputDirs.length > 0 ? { testInputDirs } : {};
140
156
  })(),
141
157
  };
@@ -176,6 +192,11 @@ export function packagingConfigToPackageOptions(config, anchors, projectSkills)
176
192
  * (`vat build` also validates the SOURCE tree, whose matches count too).
177
193
  * Containment does not change that: a skill that threw may still have matched
178
194
  * allow entries before it threw, and those matches count for the run.
195
+ * @param runOptions - Registry-build options for the RUN. Named apart from the
196
+ * per-skill `options` destructured in the loop below, which it is not: it
197
+ * configures the ONE shared registry {@link createProjectRegistry} builds here.
198
+ * It does NOT reach the per-skill post-build validation, which builds a private
199
+ * registry over the BUILT tree — see {@link runPostBuildValidation}
179
200
  * @returns One outcome per input spec, in input order
180
201
  *
181
202
  * @example
@@ -189,17 +210,26 @@ export function packagingConfigToPackageOptions(config, anchors, projectSkills)
189
210
  * const runIssues = allowUnusedIssues(ledger);
190
211
  * ```
191
212
  */
192
- export async function packageSkills(skills, projectRoot, allowLedger) {
213
+ export async function packageSkills(skills, projectRoot, allowLedger, runOptions = {}) {
193
214
  // 1. Create one registry for the entire project. Through the shared builder:
194
215
  // this used to call `fromCrawl` directly and omit the config, so skills built
195
216
  // here belonged to no collection while a skill built through the single-skill
196
217
  // fallback did.
197
- const registry = await createProjectRegistry(projectRoot);
218
+ //
219
+ // `runOptions` is forwarded rather than absorbed: this is the ONLY registry the
220
+ // run builds, so a caller that wants the run on the projection lane has no
221
+ // other seam to reach. Dropping it here would leave `vat skills build`
222
+ // reaching a store it opened and never used.
223
+ const registry = await createProjectRegistry(projectRoot, runOptions);
198
224
  // 2. Package each skill against the shared registry
199
225
  const outcomes = [];
200
226
  for (const { skillPath, options } of skills) {
201
227
  try {
202
- const result = await packageSkill(skillPath, { ...options, registry, allowLedger });
228
+ const result = await packageSkill(skillPath, {
229
+ ...options,
230
+ registry,
231
+ allowLedger,
232
+ });
203
233
  outcomes.push({ status: 'built', skillPath, result });
204
234
  }
205
235
  catch (error) {
@@ -235,7 +265,7 @@ export async function packageSkills(skills, projectRoot, allowLedger) {
235
265
  export async function packageSkill(skillPath, options = {}) {
236
266
  const { formats = ['directory'], rewriteLinks = true, resourceNaming = 'basename', stripPrefix, target = DEFAULT_PACKAGING_TARGET, } = options;
237
267
  // 1. Parse SKILL.md frontmatter and links
238
- const parseResult = await parseMarkdown(skillPath);
268
+ const parseResult = await parseFileCached(skillPath, 'markdown');
239
269
  const skillMetadata = extractSkillMetadata(parseResult, skillPath);
240
270
  // 2. Find project boundary (config root -> git root -> skill dir).
241
271
  // Library callers fall back to the skill directory when canonical
@@ -266,7 +296,7 @@ export async function packageSkill(skillPath, options = {}) {
266
296
  const registry = options.registry ?? await createProjectRegistry(projectRoot);
267
297
  // 3b. Load per-collection frontmatter schemas (Gap 3: packager rewrites frontmatter URI-refs
268
298
  // against the same schemas the validator uses, with body parity).
269
- const collectionSchemas = await loadCollectionSchemas(registry.config, projectRoot);
299
+ const { schemas: collectionSchemas, issues: collectionSchemaIssues } = await loadCollectionSchemas(registry.config, projectRoot);
270
300
  // 4. Walk the link graph using registry data
271
301
  const linkFollowDepth = options.linkFollowDepth ?? 2;
272
302
  const excludeConfig = options.excludeReferencesFromBundle;
@@ -296,13 +326,13 @@ export async function packageSkill(skillPath, options = {}) {
296
326
  if (options.gitTracker !== undefined) {
297
327
  packagerWalkOptions.gitTracker = options.gitTracker;
298
328
  }
299
- const { bundledResources, bundledAssets, excludedReferences, deferredAssets } = walkLinkGraph(skillResourceId, registry, packagerWalkOptions);
329
+ const { bundledResources, bundledAssets, excludedReferences, outsideSkillDirLinks, deferredAssets } = walkLinkGraph(skillResourceId, registry, packagerWalkOptions);
300
330
  // Register non-markdown bundled assets in the source registry so link rewriting
301
331
  // can resolve them (resolvedId must be set on links pointing to YAML, JSON, etc.).
302
- // For any asset whose ID collides with a paired markdown file (e.g. config.yaml +
303
- // config.md both produce id `resources-config`), we set a synthetic resolvedId on
304
- // links pointing to it so link rewriting still works.
305
- const collidedAssets = await registerBundledAssets(registry, bundledAssets);
332
+ // For any asset whose id is already taken (a path-slug clash — `a-b/c.html`
333
+ // and `a/b-c.html` both flatten to `a-b-c-html`), we set a synthetic
334
+ // resolvedId on links pointing to it so link rewriting still works.
335
+ const { collidedAssets, collisions: assetCollisions } = await registerBundledAssets(registry, bundledAssets, projectRoot);
306
336
  resolveCollidedAssetLinks(collectResourcesWithLinks(bundledResources, skillResource), collidedAssets);
307
337
  // Combine bundled file paths: markdown resources + non-markdown assets
308
338
  const bundledFiles = [
@@ -318,7 +348,6 @@ export async function packageSkill(skillPath, options = {}) {
318
348
  // 7. Clean stale output (skip when source SKILL.md lives inside the output, e.g. builder flow)
319
349
  const resolvedOutput = safePath.resolve(outputPath);
320
350
  const sourceInOutput = safePath.resolve(skillPath).startsWith(resolvedOutput + '/');
321
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- outputPath is validated
322
351
  if (!sourceInOutput && existsSync(resolvedOutput)) {
323
352
  await rm(resolvedOutput, { recursive: true });
324
353
  }
@@ -378,12 +407,21 @@ export async function packageSkill(skillPath, options = {}) {
378
407
  // 11. Build unified rewrite rules (bundled + excluded, all via transformContent)
379
408
  const rewriteRules = buildRewriteRules(excludedIds, excludeConfig?.rules ?? [], excludeConfig?.defaultTemplate);
380
409
  // 12. Copy and rewrite files
381
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- outputPath is validated
382
- await mkdir(outputPath, { recursive: true });
410
+ // The build's own output directory. An unwritable `dist/` or a full disk failed
411
+ // here with a bare errno naming an output path the author never typed — the
412
+ // same shape as the copiers, at the step before any of them run.
413
+ await withFsAttribution(`skill '${skillMetadata.name}' output directory ${issueLocation(outputPath, projectRoot) || '.'}`, () => mkdir(outputPath, { recursive: true }), 'created');
383
414
  await copyAndRewriteFiles(skillPath, bundledFiles, {
384
415
  pathMap,
385
416
  rewriteLinks,
386
417
  fromRegistry: registry,
418
+ // Both sources, because they record disjoint populations: the registry's
419
+ // own log holds crawl-time drops made by `addResources`, while
420
+ // `registerBundledAssets` holds the per-asset drops `addResource` only ever
421
+ // signalled by throwing. An HTML file that collides with a same-named
422
+ // markdown file appears ONLY in the second.
423
+ duplicateIdDrops: new Map([...registry.getDuplicateIdCollisions(), ...assetCollisions]
424
+ .map(c => [toForwardSlash(c.conflictingPath), c.existingPath])),
387
425
  toRegistry: outputRegistry,
388
426
  rewriteRules,
389
427
  templateContext: { skill: { name: skillMetadata.name } },
@@ -425,6 +463,30 @@ export async function packageSkill(skillPath, options = {}) {
425
463
  ...collisionIssues,
426
464
  ...await checkUnreferencedFiles(outputPath, filesConfigDests),
427
465
  ...await checkBrokenPackagedLinks(outputPath, droppedGlobDests),
466
+ // The inverse of checkUnreferencedFiles: paths the docs NAME that the bundle
467
+ // does not contain. Built phase only — a `files:` dest exists here and not in
468
+ // the source tree, so the same check at source phase flags every injected
469
+ // script.
470
+ //
471
+ // SKILL-LOCAL, and this is the ONLY caller. The packager knows its own output
472
+ // directory, not the plugin the skill will be installed into, so there is no
473
+ // wider root to give it — the sibling-search parameter that measured 1.9%
474
+ // instead of 3.8% was deleted for having no caller. The shipped rate is 3.8%.
475
+ ...await checkMissingReferencedPaths(outputPath, target),
476
+ // The only byte measurement in the pipeline. Built phase for the same reason
477
+ // as its neighbour above: a `files:` entry materializes files here that the
478
+ // source tree does not have, so the bytes that ship are only knowable now.
479
+ //
480
+ // ⚠️ Runs before step 14, so it does not weigh what `generatePackageArtifacts`
481
+ // adds. Measured, so the residue is not a guess: the `zip` and `npm` formats
482
+ // write to `<outputPath>.zip` / `.tgz`, OUTSIDE the bundle and outside the
483
+ // upload; only the `npm` format's synthetic package.json and the `marketplace`
484
+ // format's manifest land inside, and both are a few hundred bytes. The order
485
+ // is not free to change — checkUnreferencedFiles two lines up would flag that
486
+ // same synthetic package.json — so the under-count is stated rather than
487
+ // fixed. If an artifact step ever writes something LARGE into outputPath, this
488
+ // call has to move after it and the framework run with it.
489
+ ...checkPackagedSizeLimit(outputPath),
428
490
  // A receipt for every file a glob matched and the never-package list refused.
429
491
  // Reported as an issue, not written to stderr: a file vanishing from a bundle
430
492
  // has to be visible in `issueCounts`, or CI reads a clean report for a build
@@ -433,6 +495,10 @@ export async function packageSkill(skillPath, options = {}) {
433
495
  // dropped file is a source file that never reached the output, so the only
434
496
  // path a reader can open is its source path.
435
497
  ...droppedGlobMatchesToIssues(appliedFiles.dropped, projectRoot),
498
+ // The same receipt, for matches that are not copyable files at all. Anchored
499
+ // identically and for the identical reason. Without a channel of its own this
500
+ // population is invisible: the build used to die on the first one it met.
501
+ ...skippedGlobMatchesToIssues(appliedFiles.skipped, projectRoot),
436
502
  // Presence-side backstop for agent-instruction files. The walker excludes
437
503
  // them from link-following, but a `files:` glob can still copy one in, and
438
504
  // a file that arrives without a link is invisible to the link lane.
@@ -446,6 +512,9 @@ export async function packageSkill(skillPath, options = {}) {
446
512
  // A receipt for each `files:` entry that was dropped for pointing into declared
447
513
  // test input — the build already produced the right artifact; this just says so.
448
514
  ...testInputFileEntryIssues(droppedTestInputFiles),
515
+ // A collection schema that could not be loaded, so its frontmatter was not
516
+ // rewritten (see loadCollectionSchemas).
517
+ ...collectionSchemaIssues,
449
518
  // Backstop: if declared test input reached the output despite both exclusions,
450
519
  // say so rather than shipping an answer key silently.
451
520
  ...checkPackagedTestInput({ pathMap, outputPath, testInputDirs }),
@@ -457,6 +526,7 @@ export async function packageSkill(skillPath, options = {}) {
457
526
  // (a pattern match is author-declared intent; this exclusion is VAT's).
458
527
  ...testInputLinkIssues(excludedReferences, testInputDirs, projectRoot),
459
528
  ...deferredAssetsToIssues(deferredAssets, projectRoot),
529
+ ...outsideSkillDirLinksToIssues(outsideSkillDirLinks, projectRoot),
460
530
  ];
461
531
  // ONE ledger for BOTH lanes below. `options.validation.allow` governs the
462
532
  // build-receipt lane AND the built-SKILL.md lane, but the two see disjoint
@@ -516,10 +586,70 @@ function withRunAllowUnused(framework, ledger) {
516
586
  *
517
587
  * `allowLedger` is required, not optional: this lane is one half of a build, so
518
588
  * it must never conclude on its own that an allow entry matched nothing.
589
+ *
590
+ * ⛔ This lane stays on the walk and is handed no `populationSource`. It validates
591
+ * the BUILT tree under `outputPath` — a different tree from the one the run
592
+ * enumerated, not tracked by git, and not covered by the packaging registry. A
593
+ * source produced by `withResourcePopulationSource` is bound to the PROJECT root
594
+ * and its git tree hash, so offering it a build-output directory asks it a
595
+ * question about a tree it does not describe. Forwarding it here was tried and
596
+ * reverted — the giveaway was a source offered `<root>/out/<skill>` instead of
597
+ * the root.
598
+ *
599
+ * The store-poisoning half of that hazard is now closed at the seam rather than by
600
+ * this comment: `ResourceRegistry.populationFrom` compares the source's own bound
601
+ * root against the crawl's base and declines a mismatch back onto the walk. So a
602
+ * future forward here would be a no-op that warns, not a corrupted extent key.
603
+ * What it would still NOT be is a speed-up, which is why nothing is forwarded:
604
+ * the built tree is a different tree, so there is no stored answer for it to hit.
605
+ *
606
+ * ## 🚨 This lane's link graph is EMPTY — and that is the NORMAL case
607
+ *
608
+ * Measured on a probe fixture (one skill, one `reference.md` reachable by
609
+ * a single relative link from `SKILL.md`), run three ways with ONE variable —
610
+ * where the packaged output lands:
611
+ *
612
+ * | lane | output location | `fileCount` | `directFileCount` | `maxLinkDepth` | `totalLines` |
613
+ * |---|---|---:|---:|---:|---:|
614
+ * | source | n/a | 2 | 1 | 1 | 13 |
615
+ * | built | INSIDE the project (`<root>/dist/skills/<name>/`) | **1** | **0** | **0** | **10** |
616
+ * | built | outside any project root (control) | 2 | 1 | 1 | 14 |
617
+ *
618
+ * The middle row is the defect; the third row PROVES the cause — the only
619
+ * difference between them is whether the output falls under the crawl's
620
+ * `**\/dist\/**` exclusion (`BUILD_OUTPUT_GLOBS`). `packageSkill`'s default output
621
+ * IS the middle row, so this is the normal case, not an edge case. The result's
622
+ * own `files.dependencies` still reads `["reference.md"]`: the packager copied it,
623
+ * and the validator invoked here — immediately afterwards — cannot see it.
624
+ *
625
+ * ⚠️ It is not only a metrics hole. `collectNonPortableAssetReferenceIssues` and
626
+ * `collectNonPortableCommandIssues` iterate `bundledFiles`, so in the built lane
627
+ * they never open a single bundled reference file. A non-portable
628
+ * `${CLAUDE_PLUGIN_ROOT}` invocation inside a bundled reference is caught at
629
+ * source and CANNOT be caught in the built artifact.
630
+ *
631
+ * 🔑 Measured vs inferred. The three rows are MEASURED (that probe).
632
+ * That the finding is still LIVE is inferred from this function's own body: it
633
+ * passes only the built `SKILL.md` path, with no registry and no population
634
+ * source rooted at the output tree, so `validateSkillForPackaging` resolves
635
+ * `findProjectRoot` back to the SOURCE project and crawls it under the default
636
+ * exclude — the built tree is never enumerated, `getResource` misses, and the
637
+ * walk starts from nothing. The probe is no longer only a probe: it is pinned as
638
+ * a committed regression test at
639
+ * `packages/cli/test/integration/built-lane-link-graph.integration.test.ts`
640
+ * (fixture `BUNDLING_SKILL_FILES`), asserting the middle row as today's
641
+ * behaviour. When this is fixed, those assertions flip in the fixing change.
642
+ *
643
+ * ⛔ Do NOT fix it by widening the crawl. That same `**\/dist\/**` exclusion is
644
+ * the stated correctness proof for `crawlAndResolveRegistry`'s process-lifetime
645
+ * memo, which the audit lane, the validate lane AND this lane all share — see the
646
+ * comment on `walkRegistryCache` in `validators/packaging-validator.ts`. Widening
647
+ * it retracts that argument for every lane sharing the memo; this is not a
648
+ * one-lane fix.
519
649
  */
520
650
  async function runPostBuildValidation(outputPath, validation, allowLedger) {
521
651
  const builtSkillPath = safePath.join(outputPath, 'SKILL.md');
522
- return validateSkillForPackaging(builtSkillPath, validation ? { validation } : undefined, 'built', { allowLedger });
652
+ return validateSkillForPackaging(builtSkillPath, validation ? { validation } : undefined, 'built', { allowLedger, unreadable: 'refuse' });
523
653
  }
524
654
  /**
525
655
  * Assemble the final PackageSkillResult from intermediate data.
@@ -546,9 +676,6 @@ function assemblePackageResult(input) {
546
676
  }
547
677
  return result;
548
678
  }
549
- // ============================================================================
550
- // Registry Creation
551
- // ============================================================================
552
679
  /**
553
680
  * Build THE project registry: every markdown file under `projectRoot`, parsed,
554
681
  * with links resolved and the project config attached.
@@ -566,12 +693,41 @@ function assemblePackageResult(input) {
566
693
  * so a lane that built its own config-less registry rewrote frontmatter
567
694
  * differently from the lane that used this one — which is why there is now only
568
695
  * one builder rather than two that happened to differ in one argument.
696
+ *
697
+ * ## Why the include set is markdown-only, unlike `crawlAndResolveRegistry`'s
698
+ *
699
+ * The difference is deliberate and is NOT a hole that leaves HTML unrewritten.
700
+ * A bundled HTML file joins this registry later, on demand, via
701
+ * {@link registerBundledAssets} — which parses it and resolves its links before
702
+ * the copy step, so `rewriteHtmlLinks` reaches it. Crawling every `.html` under
703
+ * the project up front would parse generated output the build never bundles,
704
+ * and would move `page.md` vs `page.html` id collisions from the narrow
705
+ * bundled-asset path onto the whole-project crawl.
706
+ *
707
+ * Widening this glob would NOT widen what the walker follows: routing is
708
+ * markdown-only regardless — see `isRoutable` in `walk-link-graph.ts`.
709
+ *
710
+ * ## The optional population source, and why it cannot widen that glob
711
+ *
712
+ * `populationSource` replaces the ENUMERATION only. `ResourceRegistry.crawl`
713
+ * re-applies this function's `include` — and the crawl's default `exclude` —
714
+ * to whatever the source offers, through the same compiled matcher the walk
715
+ * itself uses, so a source that enumerates a whole tree still yields exactly
716
+ * the project's markdown here. That is what makes handing this builder a
717
+ * projection a cost change rather than a scope change.
718
+ *
719
+ * Selecting the lane stays the CLI's job: this signature takes a source, never
720
+ * an environment. A library caller that passes nothing keeps the walk.
569
721
  */
570
- export async function createProjectRegistry(projectRoot) {
722
+ export async function createProjectRegistry(projectRoot, options = {}) {
571
723
  const config = await loadConfig(projectRoot);
572
724
  const registry = await ResourceRegistry.fromCrawl({
573
725
  baseDir: projectRoot,
574
- include: ['**/*.md'],
726
+ include: [...LINK_GRAPH_MEMBER_GLOBS],
727
+ // A build must not ship a shorter bundle: a directory this crawl cannot
728
+ // list refuses the run by name — see `RegistryUnreadablePolicy`.
729
+ unreadable: 'refuse',
730
+ ...(options.populationSource !== undefined && { populationSource: options.populationSource }),
575
731
  }, config === undefined ? undefined : { config });
576
732
  registry.resolveLinks();
577
733
  return registry;
@@ -582,30 +738,40 @@ export async function createProjectRegistry(projectRoot) {
582
738
  * Mirrors ResourceRegistry.validateAgainstCollectionSchema's loading flow so
583
739
  * the packager rewrites frontmatter URI-refs against the same schemas the
584
740
  * validator uses. Collections without a frontmatterSchema configured are
585
- * absent from the map. Schema file read/parse failures are silently skipped
586
- * — the validator will surface those errors elsewhere; the packager just
587
- * won't rewrite the un-routed collection's frontmatter.
741
+ * absent from the map.
742
+ *
743
+ * A schema that cannot be resolved, read or parsed is absent from the map too,
744
+ * and that is REPORTED as a `FRONTMATTER_SCHEMA_ERROR` issue in the build's own
745
+ * receipt — the same code `vat validate` gives the same config. It used to be
746
+ * swallowed on the theory that "the validator will surface it elsewhere", which
747
+ * left `vat build` shipping a bundle whose frontmatter URI-refs still pointed
748
+ * at source-tree paths, with a clean report and nothing to say why.
588
749
  */
589
750
  async function loadCollectionSchemas(config, baseDir) {
590
751
  const schemas = new Map();
752
+ const issues = [];
591
753
  const collections = config?.resources?.collections;
592
754
  if (!collections)
593
- return schemas;
755
+ return { schemas, issues };
594
756
  for (const [collectionId, collectionConfig] of Object.entries(collections)) {
595
757
  const schemaPath = collectionConfig.validation?.frontmatterSchema;
596
758
  if (schemaPath === undefined)
597
759
  continue;
598
760
  try {
599
761
  const resolvedPath = resolveAssetReference(schemaPath, baseDir);
600
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- schema path from validated config
601
762
  const content = await readFile(resolvedPath, 'utf-8');
602
763
  schemas.set(collectionId, JSON.parse(content));
603
764
  }
604
- catch {
605
- // Schema unavailable — validator will report; packager skips rewrite.
765
+ catch (error) {
766
+ issues.push(materializeIssue('FRONTMATTER_SCHEMA_ERROR', {
767
+ location: schemaPath,
768
+ message: `Collection "${collectionId}" declares frontmatterSchema "${schemaPath}", which could not ` +
769
+ `be loaded: ${error instanceof Error ? error.message : String(error)}. Frontmatter ` +
770
+ `URI-references in this collection were NOT rewritten for the packaged output.`,
771
+ }));
606
772
  }
607
773
  }
608
- return schemas;
774
+ return { schemas, issues };
609
775
  }
610
776
  /**
611
777
  * Generate a synthetic resource ID for a non-markdown asset that collides with an
@@ -636,26 +802,51 @@ function collectResourcesWithLinks(bundledResources, skillResource) {
636
802
  * and compute the output `relativePath`. Without this, non-markdown links get stripped
637
803
  * to empty `()` parentheses.
638
804
  *
639
- * Collision handling: if an asset's generated ID clashes with an existing markdown
640
- * resource (e.g. paired `config.yaml` + `config.md` both produce id `resources-config`),
641
- * `addResource` throws. We catch this, skip source-registry indexing for the asset,
642
- * and return it so the caller can synthesize a unique ID for link rewriting.
805
+ * Collision handling: if an asset's generated id is already taken, `addResource`
806
+ * throws. We catch this, skip source-registry indexing for the asset, and return
807
+ * it so the caller can synthesize a unique ID for link rewriting.
808
+ *
809
+ * The clash is a **path-slug** one, not an extension one. This used to claim the
810
+ * example was "paired `config.yaml` + `config.md`, both producing id
811
+ * `resources-config`" — {@link generateIdFromPath} appends the extension, so
812
+ * those are `config-yaml` and `config-md` and cannot collide. What does collide
813
+ * is two paths that flatten to the same slug: `a-b/c.html` and `a/b-c.html` are
814
+ * both `a-b-c-html`.
643
815
  *
644
- * @returns Paths of assets that could not be added to the source registry due to
645
- * duplicate-ID collisions. Caller must wire these up manually.
816
+ * @returns The colliding asset paths, and the collisions themselves. The caller
817
+ * must wire the paths up manually; the collisions are what let the
818
+ * verbatim-copy diagnostic name the file that actually won the id.
646
819
  */
647
- async function registerBundledAssets(registry, bundledAssets) {
820
+ async function registerBundledAssets(registry, bundledAssets, projectRoot) {
648
821
  const collidedAssets = [];
822
+ const collisions = [];
649
823
  if (bundledAssets.length === 0) {
650
- return collidedAssets;
824
+ return { collidedAssets, collisions };
651
825
  }
652
826
  for (const assetPath of bundledAssets) {
653
827
  try {
654
- await registry.addResource(assetPath);
828
+ // `addResource` parses the file, so it READS it — which makes this the first
829
+ // place a build touches a linked asset, and the place an unreadable one
830
+ // actually fails. It reported a bare `EACCES … open '/abs/path'` with no
831
+ // skill named and no remedy: the same shape as the copiers, one step
832
+ // earlier, and the step that fires first.
833
+ await withFsAttribution(`linked file ${issueLocation(assetPath, projectRoot) || '.'}`, () => registry.addResource(assetPath),
834
+ // The file is read to discover its links before anything is copied, so
835
+ // naming the copy would point past the step that actually failed.
836
+ 'read while collecting the files this skill links to', READ_REMEDY);
655
837
  }
656
838
  catch (error) {
657
- if (error instanceof Error && error.message.startsWith('Duplicate resource ID')) {
839
+ // `addResource` (singular) THROWS on a collision and — unlike
840
+ // `addResources` — records nothing in the registry's collision log. So
841
+ // this catch is the ONLY place a bundled-asset collision is ever
842
+ // observable; drop the structured error here and the fact is gone.
843
+ if (error instanceof DuplicateResourceIdError) {
658
844
  collidedAssets.push(assetPath);
845
+ collisions.push({
846
+ id: error.id,
847
+ existingPath: error.existingPath,
848
+ conflictingPath: error.conflictingPath,
849
+ });
659
850
  }
660
851
  else {
661
852
  throw error;
@@ -663,7 +854,7 @@ async function registerBundledAssets(registry, bundledAssets) {
663
854
  }
664
855
  }
665
856
  registry.resolveLinks();
666
- return collidedAssets;
857
+ return { collidedAssets, collisions };
667
858
  }
668
859
  /**
669
860
  * Manually set `resolvedId` on links pointing to collided assets.
@@ -798,9 +989,23 @@ function applyFilesEntriesToPathMap(filesConfig, projectRoot, outputPath, pathMa
798
989
  // joinUnderRoot guards against a dest escaping the output dir (zip-slip class),
799
990
  // defense-in-depth beyond the schema refine on SkillFileEntry.dest.
800
991
  const absoluteDest = safePath.joinUnderRoot(outputPath, fileEntry.dest);
801
- // Validate source exists at build time
802
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- source path from validated config
803
- if (!existsSync(absoluteSource)) {
992
+ // Validate source exists at build time.
993
+ //
994
+ // `statSync` in a guard, not `existsSync`: `existsSync` swallows EACCES and
995
+ // answers FALSE, so a source the process cannot REACH — one under a directory
996
+ // it may not traverse — was reported as "does not exist", and for a `dist/`
997
+ // path `buildArtifactHint` then told the author to run a build that would not
998
+ // have helped. A wrong diagnosis with a confident wrong remedy attached.
999
+ // Only ENOENT is absence; a refusal is reported as itself, naming the entry.
1000
+ try {
1001
+ statSync(absoluteSource);
1002
+ }
1003
+ catch (error) {
1004
+ if (error.code !== 'ENOENT') {
1005
+ const reason = error instanceof Error ? error.message : String(error);
1006
+ throw new Error(`files entry for skill '${skillName}': source '${fileEntry.source}' could not be read: ${reason}. ` +
1007
+ `Check the file's permissions and ownership, and that every directory above it is traversable.`);
1008
+ }
804
1009
  throw new Error(`files entry for skill '${skillName}': source '${fileEntry.source}' does not exist.${buildArtifactHint(fileEntry.source)}`);
805
1010
  }
806
1011
  // If this source was auto-discovered, override its destination; otherwise add it
@@ -933,21 +1138,6 @@ function stampDeferredDestResolvedId(resources, absDestTarget, id) {
933
1138
  }
934
1139
  return linked;
935
1140
  }
936
- // ============================================================================
937
- // Path Map Building
938
- // ============================================================================
939
- /**
940
- * Determine the resource subdirectory for a file.
941
- *
942
- * For claude-web target: uses the existing references directory.
943
- * For claude-code target: uses content-type routing based on file extension.
944
- */
945
- export function getResourceSubdirForFile(filePath, target) {
946
- if (target === 'claude-web') {
947
- return CLAUDE_WEB_REFERENCES_SUBDIR;
948
- }
949
- return getTargetSubdir(filePath);
950
- }
951
1141
  /**
952
1142
  * The FILENAME_COLLISION finding for one pair of sources that package to one dest.
953
1143
  *
@@ -1132,32 +1322,175 @@ async function copyAndRewriteFiles(skillPath, bundledFiles, ctx) {
1132
1322
  *
1133
1323
  * For non-markdown files, performs a plain binary copy.
1134
1324
  */
1325
+ /**
1326
+ * Re-base a resource's link spans onto the frontmatter-stripped body.
1327
+ *
1328
+ * ## 🚨 Why this exists: the span rewrite was INERT here, and could mis-splice
1329
+ *
1330
+ * `transformContent` identifies each link by its parsed `[startOffset,
1331
+ * endOffset)` span rather than by replaying a regex and correlating on href —
1332
+ * which is the whole point, because the two grammars disagree about what the
1333
+ * href IS. Its docstring states the precondition outright: *links must come from
1334
+ * the same bytes as content*.
1335
+ *
1336
+ * This call site violated it. `resource.links` are parsed from the WHOLE FILE,
1337
+ * and `editor.body` has the frontmatter block removed — so every offset was off
1338
+ * by the frontmatter's length. Measured on a four-line skill: span `[45, 77)`
1339
+ * against a body shifted by 40, where `body.slice(45, 81)` is `"ails.\n"`. Every
1340
+ * splice declined and every link fell back to the pre-fix regex path, so the
1341
+ * headline defect (`[![alt](img)](url)` silently not rewritten) was still
1342
+ * shipping. Worse, a stale span can land on a *different*, structurally valid
1343
+ * link and rewrite ITS href to the first link's target.
1344
+ *
1345
+ * ## The re-base is VERIFIED, not assumed
1346
+ *
1347
+ * Subtracting `content.length - body.length` is correct if and only if `body` is
1348
+ * a literal SUFFIX of `content` — that is exactly the statement "every body index
1349
+ * `i` is content index `i + offset`". `String.endsWith` decides it outright, so
1350
+ * the arithmetic is checked rather than argued, and a body that is not a suffix
1351
+ * comes back with its spans REMOVED instead of with coordinates nothing verified.
1352
+ * `openFrontmatter` does yield a suffix today, across CRLF, a commented block, an
1353
+ * absent block and empty input; the check is what keeps that a fact rather than a
1354
+ * comment that outlives the code it describes.
1355
+ *
1356
+ * ⛔ **This used to claim `splicableFrom` as its backstop, and that claim was
1357
+ * false.** The words were: "`splicableFrom` refuses any span whose destination is
1358
+ * not exactly the parser's href, so a misalignment that survives this degrades to
1359
+ * *no rewrite* rather than to a wrong one." It does not. That comparison is keyed
1360
+ * on the HREF, which two links in one document routinely share, so a stale span
1361
+ * landing on a same-href neighbour passes it and the neighbour is rewritten
1362
+ * through the first link's metadata. Constructed and pinned in
1363
+ * `packages/resources/test/content-transform.test.ts` › *two links sharing an
1364
+ * href defeat the destination comparison*, where a tuned frontmatter length makes
1365
+ * two labels swap places. A mitigation keyed on a value the two candidates have
1366
+ * in common cannot be a guarantee, and a guard resting on one is resting on
1367
+ * nothing.
1368
+ *
1369
+ * 🔑 What holds instead, in full, because the precondition has two halves:
1370
+ *
1371
+ * 1. *The spans address `content`* — discharged by the reader, not by this
1372
+ * function. `resource.links` and `content` must come from the same decode of
1373
+ * the same file, which is why the caller's read is pinned to
1374
+ * `readTextContent` with a 🚨 of its own. A second reader is the one way this
1375
+ * half fails, and it fails invisibly.
1376
+ * 2. *The subtraction maps `content` onto `body`* — discharged here, by
1377
+ * `endsWith`.
1378
+ *
1379
+ * `splicableFrom`'s destination check remains a useful mitigation for the
1380
+ * residue. It is not counted on, and nothing here degrades gracefully because of
1381
+ * it.
1382
+ *
1383
+ * @param content - The file as read, which the links were parsed from
1384
+ * @param body - The frontmatter-stripped body the rewrite will run over
1385
+ * @param links - The resource's links, carrying whole-file offsets
1386
+ * @param onUnverifiable - Called when `body` is not a suffix of `content`, so the
1387
+ * caller can say which file lost its span-driven rewrite. Never called on the
1388
+ * normal path.
1389
+ * @returns The same links with offsets stated against `body`, or with no offsets
1390
+ * at all when the re-base could not be verified
1391
+ */
1392
+ export function bodyRelativeLinks(content, body, links, onUnverifiable) {
1393
+ if (!content.endsWith(body)) {
1394
+ onUnverifiable();
1395
+ return links.map(withoutSpan);
1396
+ }
1397
+ const offset = content.length - body.length;
1398
+ if (offset === 0)
1399
+ return [...links];
1400
+ return links.map((link) => ({
1401
+ ...link,
1402
+ ...(link.startOffset === undefined ? {} : { startOffset: link.startOffset - offset }),
1403
+ ...(link.endOffset === undefined ? {} : { endOffset: link.endOffset - offset }),
1404
+ }));
1405
+ }
1406
+ /**
1407
+ * The link with its coordinates dropped and everything else kept.
1408
+ *
1409
+ * ⚠️ Dropping them is the point, and returning the link unchanged would be the
1410
+ * bug: an unrebased whole-file span still ADDRESSES the body's bytes, just the
1411
+ * wrong ones, so `splicableFrom` would happily splice at it. Without a span the
1412
+ * link is `UNRECOGNISED`, which routes it to the pre-span regex replay — the
1413
+ * behaviour this call site had before spans existed. A documented degrade, not a
1414
+ * silent one; the caller warns.
1415
+ *
1416
+ * @param link - A link whose offsets could not be re-based
1417
+ * @returns The same link with no `startOffset` and no `endOffset` key
1418
+ */
1419
+ function withoutSpan(link) {
1420
+ const stripped = { ...link };
1421
+ delete stripped.startOffset;
1422
+ delete stripped.endOffset;
1423
+ return stripped;
1424
+ }
1135
1425
  async function copyAndRewriteFile(sourcePath, targetPath, ctx) {
1426
+ // Through the SAME attribution point as the `files:` lanes. This is the default
1427
+ // path for every ordinary markdown-linked file in a build — the most-travelled
1428
+ // copier in the packager — and it was the last one still letting a raw errno be
1429
+ // the build's whole explanation: `EACCES: permission denied, open '/abs/path'`,
1430
+ // with no skill named and no remedy. Easy to believe it was covered because the
1431
+ // `files:` fix landed in this same file; it is a different function.
1432
+ const subject = `linked file ${issueLocation(sourcePath, ctx.projectRoot) || '.'}`;
1136
1433
  // Ensure target directory exists
1137
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- targetPath is constructed from validated paths
1138
- await mkdir(dirname(targetPath), { recursive: true });
1434
+ await withFsAttribution(subject, async () => {
1435
+ await mkdir(dirname(targetPath), { recursive: true });
1436
+ });
1139
1437
  const lower = sourcePath.toLowerCase();
1140
1438
  const isMarkdown = lower.endsWith('.md');
1141
1439
  const isHtml = lower.endsWith('.html') || lower.endsWith('.htm');
1142
1440
  // Non-rewritable files or rewriting disabled: plain binary copy
1143
1441
  if ((!isMarkdown && !isHtml) || !ctx.rewriteLinks) {
1144
- await copyFile(sourcePath, targetPath);
1442
+ await withFsAttribution(subject, () => copyFile(sourcePath, targetPath));
1145
1443
  return;
1146
1444
  }
1147
- // Read source file
1148
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- sourcePath is validated
1149
- const content = await readFile(sourcePath, 'utf-8');
1445
+ // Read source file.
1446
+ //
1447
+ // 🚨 `readTextContent`, never `readFile(path, 'utf-8')` — and here the reason
1448
+ // is sharper than the general one `link-parser.ts:149` gives. THE LINKS WERE
1449
+ // PARSED FROM THIS FILE BY THAT READER, and `bodyRelativeLinks` below
1450
+ // re-bases their offsets by differencing two string lengths. Two different
1451
+ // readers make that subtraction meaningless: `readTextContent` strips a BOM
1452
+ // and decodes UTF-16/32, so on a BOM-bearing file `readFile` returned a
1453
+ // string one character longer, `OPENING_FENCE` (`/^---\r?\n/`) then failed to
1454
+ // match, `openFrontmatter` reported no frontmatter, the offset came out 0,
1455
+ // and every span stayed whole-file — silently reverting the fix this call
1456
+ // site exists to deliver.
1457
+ //
1458
+ // ⛔ This comment used to add "it failed CLOSED rather than corrupting, only
1459
+ // because `splicableFrom` compares the destination to the href." That is not
1460
+ // a guarantee and must not be read as one: the destination check is keyed on
1461
+ // the href, which two links in one document routinely share, so a stale span
1462
+ // landing on a same-href neighbour passes it (constructed in
1463
+ // `content-transform.test.ts` › *two links sharing an href defeat the
1464
+ // destination comparison*). The BOM shift also survives
1465
+ // `bodyRelativeLinks`'s own `endsWith` check, because a body equal to the
1466
+ // content IS a suffix of it — nothing downstream can see that the links came
1467
+ // from a different decode. **One reader is the only thing standing between
1468
+ // this lane and a wrong rewrite.** Do not swap it.
1469
+ //
1470
+ // It also stops a UTF-16 source being written back as mojibake, which the
1471
+ // `utf-8` read did on the copy as well as the rewrite.
1472
+ const { text: content } = await withFsAttribution(subject, () => readTextContent(sourcePath),
1473
+ // Not "copied": the read is the step that failed, and this lane reads before
1474
+ // it rewrites, so naming the copy would point past the actual failure.
1475
+ 'read for link rewriting', READ_REMEDY);
1150
1476
  // Look up the resource in the "from" registry
1151
1477
  const resource = ctx.fromRegistry.getResource(safePath.resolve(sourcePath));
1152
1478
  if (!resource) {
1153
- // Resource not in registry — write content as-is. For HTML this is only
1154
- // reachable on an ID collision (e.g. page.html + page.md), where the asset
1155
- // is copied verbatim and its links are NOT rewritten (v1 limitation,
1156
- // mirrors the pre-existing asset-collision behavior).
1157
- ctx.warn(`Copied '${sourcePath}' verbatim without link rewriting: it is not in the resource registry ` +
1158
- `(typically an ID collision with a same-named markdown file). Source-relative links inside it are not rewritten.`);
1159
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- targetPath is constructed from validated paths
1160
- await writeFile(targetPath, content, 'utf-8');
1479
+ // Resource not in registry — write content as-is, links unrewritten.
1480
+ //
1481
+ // The cause used to be guessed from the basename ("typically an ID
1482
+ // collision with a same-named markdown file"), which is a plausible
1483
+ // hypothesis this function had no way to check, and which named no file the
1484
+ // author could go look at. The collision is now looked up, so the message
1485
+ // either names the winning file as an observed fact or says nothing about
1486
+ // cause at all.
1487
+ const winner = ctx.duplicateIdDrops.get(toForwardSlash(sourcePath));
1488
+ ctx.warn(winner === undefined
1489
+ ? `Copied '${sourcePath}' verbatim without link rewriting: it is not in the resource registry. ` +
1490
+ `Source-relative links inside it are not rewritten.`
1491
+ : `Copied '${sourcePath}' verbatim without link rewriting: it lost a resource-id collision to ` +
1492
+ `'${winner}', which was registered first and holds the id. Source-relative links inside it are not rewritten.`);
1493
+ await withFsAttribution(subject, () => writeFile(targetPath, content, 'utf-8'), WRITE_ACTION);
1161
1494
  return;
1162
1495
  }
1163
1496
  // HTML: offset-splice link rewrite (no frontmatter, no template body rewrite).
@@ -1167,8 +1500,7 @@ async function copyAndRewriteFile(sourcePath, targetPath, ctx) {
1167
1500
  ctx.warn(`Could not rewrite <${info.tagName} ${info.attr}="${info.from}"> in '${sourcePath}' (${info.reason}); ` +
1168
1501
  `the original value was kept.`);
1169
1502
  });
1170
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- targetPath is constructed from validated paths
1171
- await writeFile(targetPath, rewritten, 'utf-8');
1503
+ await withFsAttribution(subject, () => writeFile(targetPath, rewritten, 'utf-8'), WRITE_ACTION);
1172
1504
  return;
1173
1505
  }
1174
1506
  // Parse once via FrontmatterEditor so comments survive any frontmatter
@@ -1176,7 +1508,13 @@ async function copyAndRewriteFile(sourcePath, targetPath, ctx) {
1176
1508
  // for the existing rule/template body-link rewrite contract (unchanged).
1177
1509
  const editor = openFrontmatter(content);
1178
1510
  // Body rewrite (existing behavior, unchanged contract).
1179
- editor.body = transformContent(editor.body, resource.links, {
1511
+ const rebased = bodyRelativeLinks(content, editor.body, resource.links, () => {
1512
+ ctx.warn(`Rewrote links in '${sourcePath}' without parsed spans: the frontmatter-stripped body is not a ` +
1513
+ `suffix of the file as read, so the offsets the parser reported cannot be moved onto it. ` +
1514
+ `Links are matched by href instead, which is the pre-span behaviour — a link the two grammars ` +
1515
+ `disagree about (an image inside a link, most often) may go unrewritten.`);
1516
+ });
1517
+ editor.body = transformContent(editor.body, rebased, {
1180
1518
  linkRewriteRules: ctx.rewriteRules,
1181
1519
  resourceRegistry: ctx.toRegistry,
1182
1520
  sourceFilePath: targetPath, // Output path so relativePath is computed from output location
@@ -1196,8 +1534,7 @@ async function copyAndRewriteFile(sourcePath, targetPath, ctx) {
1196
1534
  }
1197
1535
  }
1198
1536
  }
1199
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- targetPath is constructed from validated paths
1200
- await writeFile(targetPath, editor.toString(), 'utf-8');
1537
+ await withFsAttribution(subject, () => writeFile(targetPath, editor.toString(), 'utf-8'), WRITE_ACTION);
1201
1538
  }
1202
1539
  /**
1203
1540
  * Build the per-href rewrite callback used for frontmatter URI-refs and HTML attributes.
@@ -1251,10 +1588,10 @@ function buildHrefRewriter(fromRegistry, toRegistry, sourcePath, targetPath, pro
1251
1588
  * but this check acts as a safety net in case files are introduced through other means.
1252
1589
  */
1253
1590
  async function validateNoNestedSkillMd(outputPath, skillName) {
1254
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- outputPath is validated
1255
1591
  const entries = readdirSync(outputPath, { recursive: true, withFileTypes: true });
1256
1592
  const nestedSkillMds = entries
1257
- .filter(entry => entry.isFile() && entry.name === 'SKILL.md')
1593
+ // Followed: a symlinked nested SKILL.md is a nested skill marker all the same.
1594
+ .filter(entry => entry.name === 'SKILL.md' && direntKindFollowingSync(entry.parentPath, entry) === 'file')
1258
1595
  .map(entry => safePath.relative(outputPath, safePath.join(entry.parentPath, entry.name)))
1259
1596
  .filter(relativePath => relativePath !== 'SKILL.md'); // Exclude the root SKILL.md
1260
1597
  if (nestedSkillMds.length > 0) {
@@ -1380,9 +1717,10 @@ export function generateTargetPath(filePath, basePath, strategy = 'basename', st
1380
1717
  // Strip prefix from relative path (if specified)
1381
1718
  // Works for both resource-id and preserve-path strategies
1382
1719
  if (stripPrefix) {
1383
- // Normalize separators for consistent matching
1384
- const normalizedRelPath = relPath.replaceAll('\\', '/');
1385
- const normalizedPrefix = stripPrefix.replaceAll('\\', '/').replace(/\/$/, ''); // Remove trailing slash
1720
+ // `relPath` is already forward-slashed by `safePath.relative`; `stripPrefix`
1721
+ // is author-written config, so its backslashes read as separators anywhere.
1722
+ const normalizedRelPath = relPath;
1723
+ const normalizedPrefix = toForwardSlashAnyPlatform(stripPrefix).replace(/\/$/, ''); // Remove trailing slash
1386
1724
  if (normalizedRelPath.startsWith(normalizedPrefix + '/')) {
1387
1725
  // Strip the prefix and leading slash
1388
1726
  relPath = normalizedRelPath.substring(normalizedPrefix.length + 1);
@@ -1422,14 +1760,13 @@ const ZIP_SIZE_ERROR_BYTES = 8 * 1024 * 1024;
1422
1760
  * Thrown when a claude-web ZIP exceeds the 8MB Claude.ai upload limit.
1423
1761
  * The CLI catches this and exits with code 1.
1424
1762
  */
1425
- export class ZipSizeLimitError extends Error {
1763
+ export class ZipSizeLimitError extends VatError {
1426
1764
  sizeBytes;
1427
1765
  limitBytes;
1428
1766
  constructor(sizeBytes, limitBytes) {
1429
1767
  const mb = (sizeBytes / 1024 / 1024).toFixed(1);
1430
- super(`ZIP size ${mb}MB exceeds 8MB limit for Claude.ai upload. ` +
1768
+ super('ZIP_SIZE_LIMIT', `ZIP size ${mb}MB exceeds 8MB limit for Claude.ai upload. ` +
1431
1769
  `Reduce the number of linked resources or use --target claude-code.`);
1432
- this.name = 'ZipSizeLimitError';
1433
1770
  this.sizeBytes = sizeBytes;
1434
1771
  this.limitBytes = limitBytes;
1435
1772
  }
@@ -1441,7 +1778,6 @@ export class ZipSizeLimitError extends Error {
1441
1778
  * @param zipPath - Path to the ZIP file
1442
1779
  */
1443
1780
  function validateZipSize(zipPath) {
1444
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- zipPath is constructed from validated outputPath
1445
1781
  const stats = statSync(zipPath);
1446
1782
  const bytes = stats.size;
1447
1783
  if (bytes >= ZIP_SIZE_ERROR_BYTES) {
@@ -1522,7 +1858,6 @@ async function createNpmPackage(outputPath, metadata) {
1522
1858
  files: ['**/*.md'],
1523
1859
  };
1524
1860
  const packageJsonPath = safePath.join(outputPath, PACKAGE_JSON_FILENAME);
1525
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is constructed from validated outputPath
1526
1861
  await writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2), 'utf-8');
1527
1862
  // For now, just return a placeholder path
1528
1863
  // Full npm pack implementation would require running `npm pack`
@@ -1547,7 +1882,6 @@ async function createMarketplaceManifest(outputPath, metadata) {
1547
1882
  created: new Date().toISOString(),
1548
1883
  };
1549
1884
  const manifestPath = safePath.join(dirname(outputPath), `${metadata.name}.marketplace.json`);
1550
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is constructed from validated outputPath
1551
1885
  await writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8');
1552
1886
  return manifestPath;
1553
1887
  }
@@ -1561,6 +1895,15 @@ async function createMarketplaceManifest(outputPath, metadata) {
1561
1895
  * @returns Default output path
1562
1896
  */
1563
1897
  function getDefaultSkillOutputPath(skillPath, skillName) {
1898
+ // The name may have come from the H1 title when frontmatter carries none,
1899
+ // and the directory this returns is `rm -rf`'d before the package is
1900
+ // written: a SKILL.md headed `# ../../../canary` used to delete `<root>/canary`
1901
+ // through the public `packageSkill` export, issues empty, `hasErrors` false.
1902
+ if (!isSingleFsSegment(skillName)) {
1903
+ throw new VatError('SKILL_NAME_NOT_A_SEGMENT', `Cannot derive an output directory from skill name "${skillName}": a name must be a single ` +
1904
+ `path segment (no separators, not "." or ".."). Declare \`name:\` in the SKILL.md frontmatter ` +
1905
+ `or pass an explicit outputPath.`);
1906
+ }
1564
1907
  const skillPackageRoot = findPackageRoot(skillPath);
1565
1908
  return safePath.join(skillPackageRoot, 'dist', 'skills', skillName);
1566
1909
  }
@@ -1579,7 +1922,6 @@ function findPackageRoot(skillPath, fallbackToSkillDir = false) {
1579
1922
  // Walk up until we find a package.json or hit the filesystem root
1580
1923
  while (currentDir !== dirname(currentDir)) {
1581
1924
  const packageJsonPath = safePath.join(currentDir, PACKAGE_JSON_FILENAME);
1582
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- Searching for package.json
1583
1925
  if (existsSync(packageJsonPath)) {
1584
1926
  return currentDir;
1585
1927
  }