@vibe-agent-toolkit/agent-skills 0.2.0-rc.6 → 0.2.0-rc.8

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 (236) 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 +2 -2
  10. package/dist/files-config.d.ts.map +1 -1
  11. package/dist/files-config.js +1 -7
  12. package/dist/files-config.js.map +1 -1
  13. package/dist/import.d.ts.map +1 -1
  14. package/dist/import.js +0 -4
  15. package/dist/import.js.map +1 -1
  16. package/dist/index.d.ts +3 -2
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +5 -1
  19. package/dist/index.js.map +1 -1
  20. package/dist/inventory/types.d.ts +8 -0
  21. package/dist/inventory/types.d.ts.map +1 -1
  22. package/dist/inventory/types.js.map +1 -1
  23. package/dist/parsers/frontmatter-parser.d.ts.map +1 -1
  24. package/dist/parsers/frontmatter-parser.js +30 -6
  25. package/dist/parsers/frontmatter-parser.js.map +1 -1
  26. package/dist/plugin-distribution-layout.d.ts.map +1 -1
  27. package/dist/plugin-distribution-layout.js +13 -2
  28. package/dist/plugin-distribution-layout.js.map +1 -1
  29. package/dist/post-build-checks.d.ts +9 -1
  30. package/dist/post-build-checks.d.ts.map +1 -1
  31. package/dist/post-build-checks.js +21 -13
  32. package/dist/post-build-checks.js.map +1 -1
  33. package/dist/schemas/allowed-tools.d.ts +31 -0
  34. package/dist/schemas/allowed-tools.d.ts.map +1 -0
  35. package/dist/schemas/allowed-tools.js +88 -0
  36. package/dist/schemas/allowed-tools.js.map +1 -0
  37. package/dist/schemas/index.d.ts +1 -0
  38. package/dist/schemas/index.d.ts.map +1 -1
  39. package/dist/schemas/index.js +1 -0
  40. package/dist/schemas/index.js.map +1 -1
  41. package/dist/schemas/installed-plugins-registry.d.ts.map +1 -1
  42. package/dist/schemas/installed-plugins-registry.js +7 -1
  43. package/dist/schemas/installed-plugins-registry.js.map +1 -1
  44. package/dist/schemas/marketplace-manifest.d.ts.map +1 -1
  45. package/dist/schemas/marketplace-manifest.js +17 -6
  46. package/dist/schemas/marketplace-manifest.js.map +1 -1
  47. package/dist/skill-identity.d.ts +8 -3
  48. package/dist/skill-identity.d.ts.map +1 -1
  49. package/dist/skill-identity.js +13 -6
  50. package/dist/skill-identity.js.map +1 -1
  51. package/dist/skill-packager.d.ts +87 -14
  52. package/dist/skill-packager.d.ts.map +1 -1
  53. package/dist/skill-packager.js +200 -72
  54. package/dist/skill-packager.js.map +1 -1
  55. package/dist/skill-source/content-hash.d.ts.map +1 -1
  56. package/dist/skill-source/content-hash.js +21 -10
  57. package/dist/skill-source/content-hash.js.map +1 -1
  58. package/dist/skill-source/fetch-cache.d.ts.map +1 -1
  59. package/dist/skill-source/fetch-cache.js +0 -5
  60. package/dist/skill-source/fetch-cache.js.map +1 -1
  61. package/dist/skill-source/git-clone.d.ts.map +1 -1
  62. package/dist/skill-source/git-clone.js +0 -2
  63. package/dist/skill-source/git-clone.js.map +1 -1
  64. package/dist/skill-source/sources/url-source.d.ts.map +1 -1
  65. package/dist/skill-source/sources/url-source.js +0 -1
  66. package/dist/skill-source/sources/url-source.js.map +1 -1
  67. package/dist/skill-source/stage.d.ts.map +1 -1
  68. package/dist/skill-source/stage.js +0 -6
  69. package/dist/skill-source/stage.js.map +1 -1
  70. package/dist/skill-test/baseline-integrity.d.ts.map +1 -1
  71. package/dist/skill-test/baseline-integrity.js +8 -2
  72. package/dist/skill-test/baseline-integrity.js.map +1 -1
  73. package/dist/skill-test/build-hook.d.ts +3 -2
  74. package/dist/skill-test/build-hook.d.ts.map +1 -1
  75. package/dist/skill-test/build-hook.js +4 -4
  76. package/dist/skill-test/build-hook.js.map +1 -1
  77. package/dist/skill-test/declared-env.d.ts +5 -4
  78. package/dist/skill-test/declared-env.d.ts.map +1 -1
  79. package/dist/skill-test/declared-env.js +7 -9
  80. package/dist/skill-test/declared-env.js.map +1 -1
  81. package/dist/skill-test/eval-executor.d.ts +1 -1
  82. package/dist/skill-test/eval-executor.js +2 -2
  83. package/dist/skill-test/eval-executor.js.map +1 -1
  84. package/dist/skill-test/eval-fragment.d.ts +12 -8
  85. package/dist/skill-test/eval-fragment.d.ts.map +1 -1
  86. package/dist/skill-test/eval-fragment.js +18 -10
  87. package/dist/skill-test/eval-fragment.js.map +1 -1
  88. package/dist/skill-test/eval-grader.d.ts +64 -6
  89. package/dist/skill-test/eval-grader.d.ts.map +1 -1
  90. package/dist/skill-test/eval-grader.js +139 -77
  91. package/dist/skill-test/eval-grader.js.map +1 -1
  92. package/dist/skill-test/eval-inputs.d.ts +8 -7
  93. package/dist/skill-test/eval-inputs.d.ts.map +1 -1
  94. package/dist/skill-test/eval-inputs.js +9 -11
  95. package/dist/skill-test/eval-inputs.js.map +1 -1
  96. package/dist/skill-test/eval-lint.d.ts +4 -4
  97. package/dist/skill-test/eval-lint.js +5 -5
  98. package/dist/skill-test/eval-lint.js.map +1 -1
  99. package/dist/skill-test/eval-suite-isolation.d.ts.map +1 -1
  100. package/dist/skill-test/eval-suite-isolation.js +8 -5
  101. package/dist/skill-test/eval-suite-isolation.js.map +1 -1
  102. package/dist/skill-test/evals-template.d.ts +1 -1
  103. package/dist/skill-test/evals-template.d.ts.map +1 -1
  104. package/dist/skill-test/evals-template.js +1 -3
  105. package/dist/skill-test/evals-template.js.map +1 -1
  106. package/dist/skill-test/executor-prompt.d.ts +2 -2
  107. package/dist/skill-test/executor-prompt.js +2 -2
  108. package/dist/skill-test/failure-reason.d.ts +113 -0
  109. package/dist/skill-test/failure-reason.d.ts.map +1 -0
  110. package/dist/skill-test/failure-reason.js +124 -0
  111. package/dist/skill-test/failure-reason.js.map +1 -0
  112. package/dist/skill-test/fragment-merge.d.ts +2 -2
  113. package/dist/skill-test/fragment-merge.js +4 -4
  114. package/dist/skill-test/grader-prompt.d.ts +2 -2
  115. package/dist/skill-test/grader-prompt.d.ts.map +1 -1
  116. package/dist/skill-test/grader-prompt.js +12 -8
  117. package/dist/skill-test/grader-prompt.js.map +1 -1
  118. package/dist/skill-test/grading-adapter.d.ts +7 -3
  119. package/dist/skill-test/grading-adapter.d.ts.map +1 -1
  120. package/dist/skill-test/grading-adapter.js +10 -9
  121. package/dist/skill-test/grading-adapter.js.map +1 -1
  122. package/dist/skill-test/harness-location.d.ts +3 -2
  123. package/dist/skill-test/harness-location.d.ts.map +1 -1
  124. package/dist/skill-test/harness-location.js +5 -16
  125. package/dist/skill-test/harness-location.js.map +1 -1
  126. package/dist/skill-test/index.d.ts +1 -1
  127. package/dist/skill-test/index.d.ts.map +1 -1
  128. package/dist/skill-test/index.js +1 -1
  129. package/dist/skill-test/index.js.map +1 -1
  130. package/dist/skill-test/lock.d.ts +11 -10
  131. package/dist/skill-test/lock.d.ts.map +1 -1
  132. package/dist/skill-test/lock.js +13 -14
  133. package/dist/skill-test/lock.js.map +1 -1
  134. package/dist/skill-test/pipeline.d.ts +3 -1
  135. package/dist/skill-test/pipeline.d.ts.map +1 -1
  136. package/dist/skill-test/pipeline.js +5 -1
  137. package/dist/skill-test/pipeline.js.map +1 -1
  138. package/dist/skill-test/preflight.d.ts.map +1 -1
  139. package/dist/skill-test/preflight.js +0 -1
  140. package/dist/skill-test/preflight.js.map +1 -1
  141. package/dist/skill-test/prompt-invariants.d.ts +4 -2
  142. package/dist/skill-test/prompt-invariants.d.ts.map +1 -1
  143. package/dist/skill-test/prompt-invariants.js +5 -4
  144. package/dist/skill-test/prompt-invariants.js.map +1 -1
  145. package/dist/skill-test/run-harness.d.ts +160 -123
  146. package/dist/skill-test/run-harness.d.ts.map +1 -1
  147. package/dist/skill-test/run-harness.js +309 -173
  148. package/dist/skill-test/run-harness.js.map +1 -1
  149. package/dist/skill-test/staging.d.ts +12 -6
  150. package/dist/skill-test/staging.d.ts.map +1 -1
  151. package/dist/skill-test/staging.js +15 -12
  152. package/dist/skill-test/staging.js.map +1 -1
  153. package/dist/skill-test/tier-plan.d.ts +1 -1
  154. package/dist/skill-test/tier-plan.js +1 -1
  155. package/dist/skill-test/tool-eval-schema.js +2 -2
  156. package/dist/skill-test/vendor-manifest.d.ts +2 -1
  157. package/dist/skill-test/vendor-manifest.d.ts.map +1 -1
  158. package/dist/skill-test/vendor-manifest.js +10 -10
  159. package/dist/skill-test/vendor-manifest.js.map +1 -1
  160. package/dist/test-input.d.ts +2 -2
  161. package/dist/test-input.d.ts.map +1 -1
  162. package/dist/test-input.js +4 -5
  163. package/dist/test-input.js.map +1 -1
  164. package/dist/validators/agent-instruction-presence.d.ts.map +1 -1
  165. package/dist/validators/agent-instruction-presence.js +44 -2
  166. package/dist/validators/agent-instruction-presence.js.map +1 -1
  167. package/dist/validators/bundled-resource-link-detection.d.ts +5 -3
  168. package/dist/validators/bundled-resource-link-detection.d.ts.map +1 -1
  169. package/dist/validators/bundled-resource-link-detection.js +38 -38
  170. package/dist/validators/bundled-resource-link-detection.js.map +1 -1
  171. package/dist/validators/compat-detectors.d.ts +17 -0
  172. package/dist/validators/compat-detectors.d.ts.map +1 -1
  173. package/dist/validators/compat-detectors.js +46 -19
  174. package/dist/validators/compat-detectors.js.map +1 -1
  175. package/dist/validators/format-detection.d.ts +3 -0
  176. package/dist/validators/format-detection.d.ts.map +1 -1
  177. package/dist/validators/format-detection.js +41 -26
  178. package/dist/validators/format-detection.js.map +1 -1
  179. package/dist/validators/frontmatter-validation.js +1 -1
  180. package/dist/validators/frontmatter-validation.js.map +1 -1
  181. package/dist/validators/marketplace-validator.d.ts.map +1 -1
  182. package/dist/validators/marketplace-validator.js +6 -1
  183. package/dist/validators/marketplace-validator.js.map +1 -1
  184. package/dist/validators/mcp-tool-qualification.d.ts +1 -1
  185. package/dist/validators/mcp-tool-qualification.js +1 -1
  186. package/dist/validators/packaged-size-limit.d.ts.map +1 -1
  187. package/dist/validators/packaged-size-limit.js +0 -2
  188. package/dist/validators/packaged-size-limit.js.map +1 -1
  189. package/dist/validators/packaging-validator.d.ts +33 -4
  190. package/dist/validators/packaging-validator.d.ts.map +1 -1
  191. package/dist/validators/packaging-validator.js +95 -22
  192. package/dist/validators/packaging-validator.js.map +1 -1
  193. package/dist/validators/plugin-hosted-shape.d.ts +3 -1
  194. package/dist/validators/plugin-hosted-shape.d.ts.map +1 -1
  195. package/dist/validators/plugin-hosted-shape.js +5 -6
  196. package/dist/validators/plugin-hosted-shape.js.map +1 -1
  197. package/dist/validators/referenced-path-missing.d.ts +15 -6
  198. package/dist/validators/referenced-path-missing.d.ts.map +1 -1
  199. package/dist/validators/referenced-path-missing.js +42 -22
  200. package/dist/validators/referenced-path-missing.js.map +1 -1
  201. package/dist/validators/registry-validator.d.ts.map +1 -1
  202. package/dist/validators/registry-validator.js +0 -1
  203. package/dist/validators/registry-validator.js.map +1 -1
  204. package/dist/validators/rule-engine/index.d.ts +1 -1
  205. package/dist/validators/rule-engine/rule-context.d.ts +3 -3
  206. package/dist/validators/rule-engine/rule-context.js +1 -1
  207. package/dist/validators/rule-engine/rule-engine.d.ts +1 -1
  208. package/dist/validators/rule-engine/rule-engine.js +5 -5
  209. package/dist/validators/rule-engine/rule-engine.js.map +1 -1
  210. package/dist/validators/skill-validator.d.ts.map +1 -1
  211. package/dist/validators/skill-validator.js +41 -51
  212. package/dist/validators/skill-validator.js.map +1 -1
  213. package/dist/validators/source-only-codes.js +1 -1
  214. package/dist/validators/source-only-codes.js.map +1 -1
  215. package/dist/validators/types.d.ts +21 -1
  216. package/dist/validators/types.d.ts.map +1 -1
  217. package/dist/validators/unified-validator.d.ts.map +1 -1
  218. package/dist/validators/unified-validator.js +3 -4
  219. package/dist/validators/unified-validator.js.map +1 -1
  220. package/dist/validators/validation-rules.d.ts +3 -3
  221. package/dist/validators/validation-rules.d.ts.map +1 -1
  222. package/dist/validators/validation-rules.js +9 -38
  223. package/dist/validators/validation-rules.js.map +1 -1
  224. package/dist/validators/walker-to-issues.d.ts +2 -0
  225. package/dist/validators/walker-to-issues.d.ts.map +1 -1
  226. package/dist/validators/walker-to-issues.js +17 -12
  227. package/dist/validators/walker-to-issues.js.map +1 -1
  228. package/dist/walk-link-graph.d.ts +2 -0
  229. package/dist/walk-link-graph.d.ts.map +1 -1
  230. package/dist/walk-link-graph.js +46 -15
  231. package/dist/walk-link-graph.js.map +1 -1
  232. package/package.json +12 -20
  233. package/dist/skill-test/exit-codes.d.ts +0 -134
  234. package/dist/skill-test/exit-codes.d.ts.map +0 -1
  235. package/dist/skill-test/exit-codes.js +0 -224
  236. package/dist/skill-test/exit-codes.js.map +0 -1
@@ -19,8 +19,9 @@ import { copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
19
19
  import { basename, dirname } from 'node:path';
20
20
  import { DeferredArtifacts, DuplicateResourceIdError, ResourceRegistry, loadConfig, openFrontmatter, resolveLocalHref, rewriteFrontmatterUriReferencesFromSchema, rewriteHtmlLinks, transformContent, parseFileCached, } from '@vibe-agent-toolkit/resources';
21
21
  import { allowUnusedIssues, createAllowUsageLedger, runValidationFramework, } from '@vibe-agent-toolkit/schema';
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';
22
+ import { direntKindFollowingSync, findProjectRoot, isGlob, isSingleFsSegment, issueLocation, resolveAssetReference, safePath, toForwardSlash, VatError, } from '@vibe-agent-toolkit/utils';
23
+ import { readTextContent } from '@vibe-agent-toolkit/utils/fs';
24
+ import { getResourceSubdirForFile } from './content-type-routing.js';
24
25
  import { applyFilesConfig, buildArtifactHint, droppedGlobMatchesToIssues, explicitFilesConfigDests, globEntryDest, skippedGlobMatchesToIssues, } from './files-config.js';
25
26
  import { READ_REMEDY, withFsAttribution } from './fs-attribution.js';
26
27
  import { checkBrokenPackagedLinks, checkMissingReferencedPaths, checkUnreferencedFiles } from './post-build-checks.js';
@@ -29,7 +30,7 @@ import { detectPackagedAgentInstructionFiles } from './validators/agent-instruct
29
30
  import { checkPackagedSizeLimit } from './validators/packaged-size-limit.js';
30
31
  import { validateSkillForPackaging } from './validators/packaging-validator.js';
31
32
  import { materializeIssue } from './validators/rule-engine/index.js';
32
- import { deferredAssetsToIssues, walkerExclusionsToIssues } from './validators/walker-to-issues.js';
33
+ import { deferredAssetsToIssues, outsideSkillDirLinksToIssues, walkerExclusionsToIssues } from './validators/walker-to-issues.js';
33
34
  import { walkLinkGraph } from './walk-link-graph.js';
34
35
  const PACKAGE_JSON_FILENAME = 'package.json';
35
36
  /** `withFsAttribution` action for a write in `copyAndRewriteFile` — never a copy, since the bytes are rewritten first. */
@@ -41,18 +42,20 @@ const WRITE_ACTION = 'written into the bundle';
41
42
  * `{{link.rawText}}`, not `{{link.text}}`, for the same two reasons the rewrite
42
43
  * branch uses it (see `bundledLinkTemplate`):
43
44
  *
44
- * - **Correlation.** `transformContent` keys parsed links by href and lets the
45
- * FIRST occurrence win, so `link.text` on a second link sharing that href is
46
- * the first link's text. `rawText` is the regex's per-occurrence capture, so it
47
- * always belongs to the link being replaced. Stripping with `link.text` swapped
48
- * one phrase for an unrelated one in shipped prose; the rewrite branch never
49
- * exposed it because it re-emits `rawText`.
45
+ * - **Per-occurrence identity.** `link.text` is a property of the parsed link,
46
+ * and two occurrences sharing an href are two links whose text may differ.
47
+ * `rawText` is always the text of the occurrence actually being replaced, so
48
+ * it cannot swap one phrase for an unrelated one in shipped prose.
50
49
  * - **Formatting.** `rawText` keeps the inline markup the author wrote, so
51
50
  * ``[`foo.yaml`](…)`` strips to ``` `foo.yaml` ``` rather than bare `foo.yaml`.
52
51
  *
53
- * Text was the ONLY field the collision could corrupt: the map key is the full
54
- * href including any `#fragment`, so two links that collide there necessarily
55
- * resolve to the same resource and carry the same fragment.
52
+ * > This used to explain the first reason as a defence against `transformContent`
53
+ * > keying parsed links by href and letting the FIRST occurrence win. That
54
+ * > correlation is gone: `transformContent` now splices each parsed link at its
55
+ * > own `[startOffset, endOffset)` span, so there is no shared-href map to collide
56
+ * > in for any link the parser located. `rawText` remains correct and remains the
57
+ * > right choice — the reasoning above no longer depends on a collision that the
58
+ * > rewriter can still have.
56
59
  */
57
60
  const DEFAULT_STRIP_TEMPLATE = '{{link.rawText}}';
58
61
  /**
@@ -292,7 +295,7 @@ export async function packageSkill(skillPath, options = {}) {
292
295
  const registry = options.registry ?? await createProjectRegistry(projectRoot);
293
296
  // 3b. Load per-collection frontmatter schemas (Gap 3: packager rewrites frontmatter URI-refs
294
297
  // against the same schemas the validator uses, with body parity).
295
- const collectionSchemas = await loadCollectionSchemas(registry.config, projectRoot);
298
+ const { schemas: collectionSchemas, issues: collectionSchemaIssues } = await loadCollectionSchemas(registry.config, projectRoot);
296
299
  // 4. Walk the link graph using registry data
297
300
  const linkFollowDepth = options.linkFollowDepth ?? 2;
298
301
  const excludeConfig = options.excludeReferencesFromBundle;
@@ -322,7 +325,7 @@ export async function packageSkill(skillPath, options = {}) {
322
325
  if (options.gitTracker !== undefined) {
323
326
  packagerWalkOptions.gitTracker = options.gitTracker;
324
327
  }
325
- const { bundledResources, bundledAssets, excludedReferences, deferredAssets } = walkLinkGraph(skillResourceId, registry, packagerWalkOptions);
328
+ const { bundledResources, bundledAssets, excludedReferences, outsideSkillDirLinks, deferredAssets } = walkLinkGraph(skillResourceId, registry, packagerWalkOptions);
326
329
  // Register non-markdown bundled assets in the source registry so link rewriting
327
330
  // can resolve them (resolvedId must be set on links pointing to YAML, JSON, etc.).
328
331
  // For any asset whose id is already taken (a path-slug clash — `a-b/c.html`
@@ -344,7 +347,6 @@ export async function packageSkill(skillPath, options = {}) {
344
347
  // 7. Clean stale output (skip when source SKILL.md lives inside the output, e.g. builder flow)
345
348
  const resolvedOutput = safePath.resolve(outputPath);
346
349
  const sourceInOutput = safePath.resolve(skillPath).startsWith(resolvedOutput + '/');
347
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- outputPath is validated
348
350
  if (!sourceInOutput && existsSync(resolvedOutput)) {
349
351
  await rm(resolvedOutput, { recursive: true });
350
352
  }
@@ -407,9 +409,7 @@ export async function packageSkill(skillPath, options = {}) {
407
409
  // The build's own output directory. An unwritable `dist/` or a full disk failed
408
410
  // here with a bare errno naming an output path the author never typed — the
409
411
  // same shape as the copiers, at the step before any of them run.
410
- await withFsAttribution(`skill '${skillMetadata.name}' output directory ${issueLocation(outputPath, projectRoot) || '.'}`,
411
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- outputPath is validated
412
- () => mkdir(outputPath, { recursive: true }), 'created');
412
+ await withFsAttribution(`skill '${skillMetadata.name}' output directory ${issueLocation(outputPath, projectRoot) || '.'}`, () => mkdir(outputPath, { recursive: true }), 'created');
413
413
  await copyAndRewriteFiles(skillPath, bundledFiles, {
414
414
  pathMap,
415
415
  rewriteLinks,
@@ -471,7 +471,7 @@ export async function packageSkill(skillPath, options = {}) {
471
471
  // directory, not the plugin the skill will be installed into, so there is no
472
472
  // wider root to give it — the sibling-search parameter that measured 1.9%
473
473
  // instead of 3.8% was deleted for having no caller. The shipped rate is 3.8%.
474
- ...await checkMissingReferencedPaths(outputPath),
474
+ ...await checkMissingReferencedPaths(outputPath, target),
475
475
  // The only byte measurement in the pipeline. Built phase for the same reason
476
476
  // as its neighbour above: a `files:` entry materializes files here that the
477
477
  // source tree does not have, so the bytes that ship are only knowable now.
@@ -495,8 +495,8 @@ export async function packageSkill(skillPath, options = {}) {
495
495
  // path a reader can open is its source path.
496
496
  ...droppedGlobMatchesToIssues(appliedFiles.dropped, projectRoot),
497
497
  // The same receipt, for matches that are not copyable files at all. Anchored
498
- // identically and for the identical reason. Until issue #183 this population
499
- // had no channel because the build died on the first one it met.
498
+ // identically and for the identical reason. Without a channel of its own this
499
+ // population is invisible: the build used to die on the first one it met.
500
500
  ...skippedGlobMatchesToIssues(appliedFiles.skipped, projectRoot),
501
501
  // Presence-side backstop for agent-instruction files. The walker excludes
502
502
  // them from link-following, but a `files:` glob can still copy one in, and
@@ -511,6 +511,9 @@ export async function packageSkill(skillPath, options = {}) {
511
511
  // A receipt for each `files:` entry that was dropped for pointing into declared
512
512
  // test input — the build already produced the right artifact; this just says so.
513
513
  ...testInputFileEntryIssues(droppedTestInputFiles),
514
+ // A collection schema that could not be loaded, so its frontmatter was not
515
+ // rewritten (see loadCollectionSchemas).
516
+ ...collectionSchemaIssues,
514
517
  // Backstop: if declared test input reached the output despite both exclusions,
515
518
  // say so rather than shipping an answer key silently.
516
519
  ...checkPackagedTestInput({ pathMap, outputPath, testInputDirs }),
@@ -522,6 +525,7 @@ export async function packageSkill(skillPath, options = {}) {
522
525
  // (a pattern match is author-declared intent; this exclusion is VAT's).
523
526
  ...testInputLinkIssues(excludedReferences, testInputDirs, projectRoot),
524
527
  ...deferredAssetsToIssues(deferredAssets, projectRoot),
528
+ ...outsideSkillDirLinksToIssues(outsideSkillDirLinks, projectRoot),
525
529
  ];
526
530
  // ONE ledger for BOTH lanes below. `options.validation.allow` governs the
527
531
  // build-receipt lane AND the built-SKILL.md lane, but the two see disjoint
@@ -600,7 +604,7 @@ function withRunAllowUnused(framework, ledger) {
600
604
  *
601
605
  * ## 🚨 This lane's link graph is EMPTY — and that is the NORMAL case
602
606
  *
603
- * Measured 2026-08 on a probe fixture (one skill, one `reference.md` reachable by
607
+ * Measured on a probe fixture (one skill, one `reference.md` reachable by
604
608
  * a single relative link from `SKILL.md`), run three ways with ONE variable —
605
609
  * where the packaged output lands:
606
610
  *
@@ -623,7 +627,7 @@ function withRunAllowUnused(framework, ledger) {
623
627
  * `${CLAUDE_PLUGIN_ROOT}` invocation inside a bundled reference is caught at
624
628
  * source and CANNOT be caught in the built artifact.
625
629
  *
626
- * 🔑 Measured vs inferred. The three rows are MEASURED (2026-08, that probe).
630
+ * 🔑 Measured vs inferred. The three rows are MEASURED (that probe).
627
631
  * That the finding is still LIVE is inferred from this function's own body: it
628
632
  * passes only the built `SKILL.md` path, with no registry and no population
629
633
  * source rooted at the output tree, so `validateSkillForPackaging` resolves
@@ -644,7 +648,7 @@ function withRunAllowUnused(framework, ledger) {
644
648
  */
645
649
  async function runPostBuildValidation(outputPath, validation, allowLedger) {
646
650
  const builtSkillPath = safePath.join(outputPath, 'SKILL.md');
647
- return validateSkillForPackaging(builtSkillPath, validation ? { validation } : undefined, 'built', { allowLedger });
651
+ return validateSkillForPackaging(builtSkillPath, validation ? { validation } : undefined, 'built', { allowLedger, unreadable: 'refuse' });
648
652
  }
649
653
  /**
650
654
  * Assemble the final PackageSkillResult from intermediate data.
@@ -719,6 +723,9 @@ export async function createProjectRegistry(projectRoot, options = {}) {
719
723
  const registry = await ResourceRegistry.fromCrawl({
720
724
  baseDir: projectRoot,
721
725
  include: ['**/*.md'],
726
+ // A build must not ship a shorter bundle: a directory this crawl cannot
727
+ // list refuses the run by name — see `RegistryUnreadablePolicy`.
728
+ unreadable: 'refuse',
722
729
  ...(options.populationSource !== undefined && { populationSource: options.populationSource }),
723
730
  }, config === undefined ? undefined : { config });
724
731
  registry.resolveLinks();
@@ -730,30 +737,40 @@ export async function createProjectRegistry(projectRoot, options = {}) {
730
737
  * Mirrors ResourceRegistry.validateAgainstCollectionSchema's loading flow so
731
738
  * the packager rewrites frontmatter URI-refs against the same schemas the
732
739
  * validator uses. Collections without a frontmatterSchema configured are
733
- * absent from the map. Schema file read/parse failures are silently skipped
734
- * — the validator will surface those errors elsewhere; the packager just
735
- * won't rewrite the un-routed collection's frontmatter.
740
+ * absent from the map.
741
+ *
742
+ * A schema that cannot be resolved, read or parsed is absent from the map too,
743
+ * and that is REPORTED as a `FRONTMATTER_SCHEMA_ERROR` issue in the build's own
744
+ * receipt — the same code `vat validate` gives the same config. It used to be
745
+ * swallowed on the theory that "the validator will surface it elsewhere", which
746
+ * left `vat build` shipping a bundle whose frontmatter URI-refs still pointed
747
+ * at source-tree paths, with a clean report and nothing to say why.
736
748
  */
737
749
  async function loadCollectionSchemas(config, baseDir) {
738
750
  const schemas = new Map();
751
+ const issues = [];
739
752
  const collections = config?.resources?.collections;
740
753
  if (!collections)
741
- return schemas;
754
+ return { schemas, issues };
742
755
  for (const [collectionId, collectionConfig] of Object.entries(collections)) {
743
756
  const schemaPath = collectionConfig.validation?.frontmatterSchema;
744
757
  if (schemaPath === undefined)
745
758
  continue;
746
759
  try {
747
760
  const resolvedPath = resolveAssetReference(schemaPath, baseDir);
748
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- schema path from validated config
749
761
  const content = await readFile(resolvedPath, 'utf-8');
750
762
  schemas.set(collectionId, JSON.parse(content));
751
763
  }
752
- catch {
753
- // Schema unavailable — validator will report; packager skips rewrite.
764
+ catch (error) {
765
+ issues.push(materializeIssue('FRONTMATTER_SCHEMA_ERROR', {
766
+ location: schemaPath,
767
+ message: `Collection "${collectionId}" declares frontmatterSchema "${schemaPath}", which could not ` +
768
+ `be loaded: ${error instanceof Error ? error.message : String(error)}. Frontmatter ` +
769
+ `URI-references in this collection were NOT rewritten for the packaged output.`,
770
+ }));
754
771
  }
755
772
  }
756
- return schemas;
773
+ return { schemas, issues };
757
774
  }
758
775
  /**
759
776
  * Generate a synthetic resource ID for a non-markdown asset that collides with an
@@ -980,7 +997,6 @@ function applyFilesEntriesToPathMap(filesConfig, projectRoot, outputPath, pathMa
980
997
  // have helped. A wrong diagnosis with a confident wrong remedy attached.
981
998
  // Only ENOENT is absence; a refusal is reported as itself, naming the entry.
982
999
  try {
983
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- source path from validated config
984
1000
  statSync(absoluteSource);
985
1001
  }
986
1002
  catch (error) {
@@ -1121,21 +1137,6 @@ function stampDeferredDestResolvedId(resources, absDestTarget, id) {
1121
1137
  }
1122
1138
  return linked;
1123
1139
  }
1124
- // ============================================================================
1125
- // Path Map Building
1126
- // ============================================================================
1127
- /**
1128
- * Determine the resource subdirectory for a file.
1129
- *
1130
- * For claude-web target: uses the existing references directory.
1131
- * For claude-code target: uses content-type routing based on file extension.
1132
- */
1133
- export function getResourceSubdirForFile(filePath, target) {
1134
- if (target === 'claude-web') {
1135
- return CLAUDE_WEB_REFERENCES_SUBDIR;
1136
- }
1137
- return getTargetSubdir(filePath);
1138
- }
1139
1140
  /**
1140
1141
  * The FILENAME_COLLISION finding for one pair of sources that package to one dest.
1141
1142
  *
@@ -1320,6 +1321,106 @@ async function copyAndRewriteFiles(skillPath, bundledFiles, ctx) {
1320
1321
  *
1321
1322
  * For non-markdown files, performs a plain binary copy.
1322
1323
  */
1324
+ /**
1325
+ * Re-base a resource's link spans onto the frontmatter-stripped body.
1326
+ *
1327
+ * ## 🚨 Why this exists: the span rewrite was INERT here, and could mis-splice
1328
+ *
1329
+ * `transformContent` identifies each link by its parsed `[startOffset,
1330
+ * endOffset)` span rather than by replaying a regex and correlating on href —
1331
+ * which is the whole point, because the two grammars disagree about what the
1332
+ * href IS. Its docstring states the precondition outright: *links must come from
1333
+ * the same bytes as content*.
1334
+ *
1335
+ * This call site violated it. `resource.links` are parsed from the WHOLE FILE,
1336
+ * and `editor.body` has the frontmatter block removed — so every offset was off
1337
+ * by the frontmatter's length. Measured on a four-line skill: span `[45, 77)`
1338
+ * against a body shifted by 40, where `body.slice(45, 81)` is `"ails.\n"`. Every
1339
+ * splice declined and every link fell back to the pre-fix regex path, so the
1340
+ * headline defect (`[![alt](img)](url)` silently not rewritten) was still
1341
+ * shipping. Worse, a stale span can land on a *different*, structurally valid
1342
+ * link and rewrite ITS href to the first link's target.
1343
+ *
1344
+ * ## The re-base is VERIFIED, not assumed
1345
+ *
1346
+ * Subtracting `content.length - body.length` is correct if and only if `body` is
1347
+ * a literal SUFFIX of `content` — that is exactly the statement "every body index
1348
+ * `i` is content index `i + offset`". `String.endsWith` decides it outright, so
1349
+ * the arithmetic is checked rather than argued, and a body that is not a suffix
1350
+ * comes back with its spans REMOVED instead of with coordinates nothing verified.
1351
+ * `openFrontmatter` does yield a suffix today, across CRLF, a commented block, an
1352
+ * absent block and empty input; the check is what keeps that a fact rather than a
1353
+ * comment that outlives the code it describes.
1354
+ *
1355
+ * ⛔ **This used to claim `splicableFrom` as its backstop, and that claim was
1356
+ * false.** The words were: "`splicableFrom` refuses any span whose destination is
1357
+ * not exactly the parser's href, so a misalignment that survives this degrades to
1358
+ * *no rewrite* rather than to a wrong one." It does not. That comparison is keyed
1359
+ * on the HREF, which two links in one document routinely share, so a stale span
1360
+ * landing on a same-href neighbour passes it and the neighbour is rewritten
1361
+ * through the first link's metadata. Constructed and pinned in
1362
+ * `packages/resources/test/content-transform.test.ts` › *two links sharing an
1363
+ * href defeat the destination comparison*, where a tuned frontmatter length makes
1364
+ * two labels swap places. A mitigation keyed on a value the two candidates have
1365
+ * in common cannot be a guarantee, and a guard resting on one is resting on
1366
+ * nothing.
1367
+ *
1368
+ * 🔑 What holds instead, in full, because the precondition has two halves:
1369
+ *
1370
+ * 1. *The spans address `content`* — discharged by the reader, not by this
1371
+ * function. `resource.links` and `content` must come from the same decode of
1372
+ * the same file, which is why the caller's read is pinned to
1373
+ * `readTextContent` with a 🚨 of its own. A second reader is the one way this
1374
+ * half fails, and it fails invisibly.
1375
+ * 2. *The subtraction maps `content` onto `body`* — discharged here, by
1376
+ * `endsWith`.
1377
+ *
1378
+ * `splicableFrom`'s destination check remains a useful mitigation for the
1379
+ * residue. It is not counted on, and nothing here degrades gracefully because of
1380
+ * it.
1381
+ *
1382
+ * @param content - The file as read, which the links were parsed from
1383
+ * @param body - The frontmatter-stripped body the rewrite will run over
1384
+ * @param links - The resource's links, carrying whole-file offsets
1385
+ * @param onUnverifiable - Called when `body` is not a suffix of `content`, so the
1386
+ * caller can say which file lost its span-driven rewrite. Never called on the
1387
+ * normal path.
1388
+ * @returns The same links with offsets stated against `body`, or with no offsets
1389
+ * at all when the re-base could not be verified
1390
+ */
1391
+ export function bodyRelativeLinks(content, body, links, onUnverifiable) {
1392
+ if (!content.endsWith(body)) {
1393
+ onUnverifiable();
1394
+ return links.map(withoutSpan);
1395
+ }
1396
+ const offset = content.length - body.length;
1397
+ if (offset === 0)
1398
+ return [...links];
1399
+ return links.map((link) => ({
1400
+ ...link,
1401
+ ...(link.startOffset === undefined ? {} : { startOffset: link.startOffset - offset }),
1402
+ ...(link.endOffset === undefined ? {} : { endOffset: link.endOffset - offset }),
1403
+ }));
1404
+ }
1405
+ /**
1406
+ * The link with its coordinates dropped and everything else kept.
1407
+ *
1408
+ * ⚠️ Dropping them is the point, and returning the link unchanged would be the
1409
+ * bug: an unrebased whole-file span still ADDRESSES the body's bytes, just the
1410
+ * wrong ones, so `splicableFrom` would happily splice at it. Without a span the
1411
+ * link is `UNRECOGNISED`, which routes it to the pre-span regex replay — the
1412
+ * behaviour this call site had before spans existed. A documented degrade, not a
1413
+ * silent one; the caller warns.
1414
+ *
1415
+ * @param link - A link whose offsets could not be re-based
1416
+ * @returns The same link with no `startOffset` and no `endOffset` key
1417
+ */
1418
+ function withoutSpan(link) {
1419
+ const stripped = { ...link };
1420
+ delete stripped.startOffset;
1421
+ delete stripped.endOffset;
1422
+ return stripped;
1423
+ }
1323
1424
  async function copyAndRewriteFile(sourcePath, targetPath, ctx) {
1324
1425
  // Through the SAME attribution point as the `files:` lanes. This is the default
1325
1426
  // path for every ordinary markdown-linked file in a build — the most-travelled
@@ -1330,7 +1431,6 @@ async function copyAndRewriteFile(sourcePath, targetPath, ctx) {
1330
1431
  const subject = `linked file ${issueLocation(sourcePath, ctx.projectRoot) || '.'}`;
1331
1432
  // Ensure target directory exists
1332
1433
  await withFsAttribution(subject, async () => {
1333
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- targetPath is constructed from validated paths
1334
1434
  await mkdir(dirname(targetPath), { recursive: true });
1335
1435
  });
1336
1436
  const lower = sourcePath.toLowerCase();
@@ -1341,10 +1441,34 @@ async function copyAndRewriteFile(sourcePath, targetPath, ctx) {
1341
1441
  await withFsAttribution(subject, () => copyFile(sourcePath, targetPath));
1342
1442
  return;
1343
1443
  }
1344
- // Read source file
1345
- const content = await withFsAttribution(subject,
1346
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- sourcePath is validated
1347
- () => readFile(sourcePath, 'utf-8'),
1444
+ // Read source file.
1445
+ //
1446
+ // 🚨 `readTextContent`, never `readFile(path, 'utf-8')` — and here the reason
1447
+ // is sharper than the general one `link-parser.ts:149` gives. THE LINKS WERE
1448
+ // PARSED FROM THIS FILE BY THAT READER, and `bodyRelativeLinks` below
1449
+ // re-bases their offsets by differencing two string lengths. Two different
1450
+ // readers make that subtraction meaningless: `readTextContent` strips a BOM
1451
+ // and decodes UTF-16/32, so on a BOM-bearing file `readFile` returned a
1452
+ // string one character longer, `OPENING_FENCE` (`/^---\r?\n/`) then failed to
1453
+ // match, `openFrontmatter` reported no frontmatter, the offset came out 0,
1454
+ // and every span stayed whole-file — silently reverting the fix this call
1455
+ // site exists to deliver.
1456
+ //
1457
+ // ⛔ This comment used to add "it failed CLOSED rather than corrupting, only
1458
+ // because `splicableFrom` compares the destination to the href." That is not
1459
+ // a guarantee and must not be read as one: the destination check is keyed on
1460
+ // the href, which two links in one document routinely share, so a stale span
1461
+ // landing on a same-href neighbour passes it (constructed in
1462
+ // `content-transform.test.ts` › *two links sharing an href defeat the
1463
+ // destination comparison*). The BOM shift also survives
1464
+ // `bodyRelativeLinks`'s own `endsWith` check, because a body equal to the
1465
+ // content IS a suffix of it — nothing downstream can see that the links came
1466
+ // from a different decode. **One reader is the only thing standing between
1467
+ // this lane and a wrong rewrite.** Do not swap it.
1468
+ //
1469
+ // It also stops a UTF-16 source being written back as mojibake, which the
1470
+ // `utf-8` read did on the copy as well as the rewrite.
1471
+ const { text: content } = await withFsAttribution(subject, () => readTextContent(sourcePath),
1348
1472
  // Not "copied": the read is the step that failed, and this lane reads before
1349
1473
  // it rewrites, so naming the copy would point past the actual failure.
1350
1474
  'read for link rewriting', READ_REMEDY);
@@ -1365,9 +1489,7 @@ async function copyAndRewriteFile(sourcePath, targetPath, ctx) {
1365
1489
  `Source-relative links inside it are not rewritten.`
1366
1490
  : `Copied '${sourcePath}' verbatim without link rewriting: it lost a resource-id collision to ` +
1367
1491
  `'${winner}', which was registered first and holds the id. Source-relative links inside it are not rewritten.`);
1368
- await withFsAttribution(subject,
1369
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- targetPath is constructed from validated paths
1370
- () => writeFile(targetPath, content, 'utf-8'), WRITE_ACTION);
1492
+ await withFsAttribution(subject, () => writeFile(targetPath, content, 'utf-8'), WRITE_ACTION);
1371
1493
  return;
1372
1494
  }
1373
1495
  // HTML: offset-splice link rewrite (no frontmatter, no template body rewrite).
@@ -1377,9 +1499,7 @@ async function copyAndRewriteFile(sourcePath, targetPath, ctx) {
1377
1499
  ctx.warn(`Could not rewrite <${info.tagName} ${info.attr}="${info.from}"> in '${sourcePath}' (${info.reason}); ` +
1378
1500
  `the original value was kept.`);
1379
1501
  });
1380
- await withFsAttribution(subject,
1381
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- targetPath is constructed from validated paths
1382
- () => writeFile(targetPath, rewritten, 'utf-8'), WRITE_ACTION);
1502
+ await withFsAttribution(subject, () => writeFile(targetPath, rewritten, 'utf-8'), WRITE_ACTION);
1383
1503
  return;
1384
1504
  }
1385
1505
  // Parse once via FrontmatterEditor so comments survive any frontmatter
@@ -1387,7 +1507,13 @@ async function copyAndRewriteFile(sourcePath, targetPath, ctx) {
1387
1507
  // for the existing rule/template body-link rewrite contract (unchanged).
1388
1508
  const editor = openFrontmatter(content);
1389
1509
  // Body rewrite (existing behavior, unchanged contract).
1390
- editor.body = transformContent(editor.body, resource.links, {
1510
+ const rebased = bodyRelativeLinks(content, editor.body, resource.links, () => {
1511
+ ctx.warn(`Rewrote links in '${sourcePath}' without parsed spans: the frontmatter-stripped body is not a ` +
1512
+ `suffix of the file as read, so the offsets the parser reported cannot be moved onto it. ` +
1513
+ `Links are matched by href instead, which is the pre-span behaviour — a link the two grammars ` +
1514
+ `disagree about (an image inside a link, most often) may go unrewritten.`);
1515
+ });
1516
+ editor.body = transformContent(editor.body, rebased, {
1391
1517
  linkRewriteRules: ctx.rewriteRules,
1392
1518
  resourceRegistry: ctx.toRegistry,
1393
1519
  sourceFilePath: targetPath, // Output path so relativePath is computed from output location
@@ -1407,9 +1533,7 @@ async function copyAndRewriteFile(sourcePath, targetPath, ctx) {
1407
1533
  }
1408
1534
  }
1409
1535
  }
1410
- await withFsAttribution(subject,
1411
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- targetPath is constructed from validated paths
1412
- () => writeFile(targetPath, editor.toString(), 'utf-8'), WRITE_ACTION);
1536
+ await withFsAttribution(subject, () => writeFile(targetPath, editor.toString(), 'utf-8'), WRITE_ACTION);
1413
1537
  }
1414
1538
  /**
1415
1539
  * Build the per-href rewrite callback used for frontmatter URI-refs and HTML attributes.
@@ -1463,10 +1587,10 @@ function buildHrefRewriter(fromRegistry, toRegistry, sourcePath, targetPath, pro
1463
1587
  * but this check acts as a safety net in case files are introduced through other means.
1464
1588
  */
1465
1589
  async function validateNoNestedSkillMd(outputPath, skillName) {
1466
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- outputPath is validated
1467
1590
  const entries = readdirSync(outputPath, { recursive: true, withFileTypes: true });
1468
1591
  const nestedSkillMds = entries
1469
- .filter(entry => entry.isFile() && entry.name === 'SKILL.md')
1592
+ // Followed: a symlinked nested SKILL.md is a nested skill marker all the same.
1593
+ .filter(entry => entry.name === 'SKILL.md' && direntKindFollowingSync(entry.parentPath, entry) === 'file')
1470
1594
  .map(entry => safePath.relative(outputPath, safePath.join(entry.parentPath, entry.name)))
1471
1595
  .filter(relativePath => relativePath !== 'SKILL.md'); // Exclude the root SKILL.md
1472
1596
  if (nestedSkillMds.length > 0) {
@@ -1634,14 +1758,13 @@ const ZIP_SIZE_ERROR_BYTES = 8 * 1024 * 1024;
1634
1758
  * Thrown when a claude-web ZIP exceeds the 8MB Claude.ai upload limit.
1635
1759
  * The CLI catches this and exits with code 1.
1636
1760
  */
1637
- export class ZipSizeLimitError extends Error {
1761
+ export class ZipSizeLimitError extends VatError {
1638
1762
  sizeBytes;
1639
1763
  limitBytes;
1640
1764
  constructor(sizeBytes, limitBytes) {
1641
1765
  const mb = (sizeBytes / 1024 / 1024).toFixed(1);
1642
- super(`ZIP size ${mb}MB exceeds 8MB limit for Claude.ai upload. ` +
1766
+ super('ZIP_SIZE_LIMIT', `ZIP size ${mb}MB exceeds 8MB limit for Claude.ai upload. ` +
1643
1767
  `Reduce the number of linked resources or use --target claude-code.`);
1644
- this.name = 'ZipSizeLimitError';
1645
1768
  this.sizeBytes = sizeBytes;
1646
1769
  this.limitBytes = limitBytes;
1647
1770
  }
@@ -1653,7 +1776,6 @@ export class ZipSizeLimitError extends Error {
1653
1776
  * @param zipPath - Path to the ZIP file
1654
1777
  */
1655
1778
  function validateZipSize(zipPath) {
1656
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- zipPath is constructed from validated outputPath
1657
1779
  const stats = statSync(zipPath);
1658
1780
  const bytes = stats.size;
1659
1781
  if (bytes >= ZIP_SIZE_ERROR_BYTES) {
@@ -1734,7 +1856,6 @@ async function createNpmPackage(outputPath, metadata) {
1734
1856
  files: ['**/*.md'],
1735
1857
  };
1736
1858
  const packageJsonPath = safePath.join(outputPath, PACKAGE_JSON_FILENAME);
1737
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is constructed from validated outputPath
1738
1859
  await writeFile(packageJsonPath, JSON.stringify(packageJson, null, 2), 'utf-8');
1739
1860
  // For now, just return a placeholder path
1740
1861
  // Full npm pack implementation would require running `npm pack`
@@ -1759,7 +1880,6 @@ async function createMarketplaceManifest(outputPath, metadata) {
1759
1880
  created: new Date().toISOString(),
1760
1881
  };
1761
1882
  const manifestPath = safePath.join(dirname(outputPath), `${metadata.name}.marketplace.json`);
1762
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- Path is constructed from validated outputPath
1763
1883
  await writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf-8');
1764
1884
  return manifestPath;
1765
1885
  }
@@ -1773,6 +1893,15 @@ async function createMarketplaceManifest(outputPath, metadata) {
1773
1893
  * @returns Default output path
1774
1894
  */
1775
1895
  function getDefaultSkillOutputPath(skillPath, skillName) {
1896
+ // The name may have come from the H1 title when frontmatter carries none,
1897
+ // and the directory this returns is `rm -rf`'d before the package is
1898
+ // written: a SKILL.md headed `# ../../../canary` used to delete `<root>/canary`
1899
+ // through the public `packageSkill` export, issues empty, `hasErrors` false.
1900
+ if (!isSingleFsSegment(skillName)) {
1901
+ throw new VatError('SKILL_NAME_NOT_A_SEGMENT', `Cannot derive an output directory from skill name "${skillName}": a name must be a single ` +
1902
+ `path segment (no separators, not "." or ".."). Declare \`name:\` in the SKILL.md frontmatter ` +
1903
+ `or pass an explicit outputPath.`);
1904
+ }
1776
1905
  const skillPackageRoot = findPackageRoot(skillPath);
1777
1906
  return safePath.join(skillPackageRoot, 'dist', 'skills', skillName);
1778
1907
  }
@@ -1791,7 +1920,6 @@ function findPackageRoot(skillPath, fallbackToSkillDir = false) {
1791
1920
  // Walk up until we find a package.json or hit the filesystem root
1792
1921
  while (currentDir !== dirname(currentDir)) {
1793
1922
  const packageJsonPath = safePath.join(currentDir, PACKAGE_JSON_FILENAME);
1794
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- Searching for package.json
1795
1923
  if (existsSync(packageJsonPath)) {
1796
1924
  return currentDir;
1797
1925
  }