@vibe-agent-toolkit/cli 0.2.0-rc.1 → 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 (504) hide show
  1. package/README.md +4 -5
  2. package/dist/bin/vat +95 -28
  3. package/dist/bin/vat.js +95 -28
  4. package/dist/bin/vat.js.map +1 -1
  5. package/dist/bin.js +115 -76
  6. package/dist/bin.js.map +1 -1
  7. package/dist/command-loaders.d.ts +44 -0
  8. package/dist/command-loaders.d.ts.map +1 -0
  9. package/dist/command-loaders.js +60 -0
  10. package/dist/command-loaders.js.map +1 -0
  11. package/dist/commands/agent/build.d.ts.map +1 -1
  12. package/dist/commands/agent/build.js +2 -1
  13. package/dist/commands/agent/build.js.map +1 -1
  14. package/dist/commands/agent/import.d.ts.map +1 -1
  15. package/dist/commands/agent/import.js +3 -2
  16. package/dist/commands/agent/import.js.map +1 -1
  17. package/dist/commands/agent/index.js +1 -1
  18. package/dist/commands/agent/install-path.d.ts +36 -0
  19. package/dist/commands/agent/install-path.d.ts.map +1 -0
  20. package/dist/commands/agent/install-path.js +44 -0
  21. package/dist/commands/agent/install-path.js.map +1 -0
  22. package/dist/commands/agent/install.d.ts.map +1 -1
  23. package/dist/commands/agent/install.js +66 -13
  24. package/dist/commands/agent/install.js.map +1 -1
  25. package/dist/commands/agent/installed.d.ts.map +1 -1
  26. package/dist/commands/agent/installed.js +22 -12
  27. package/dist/commands/agent/installed.js.map +1 -1
  28. package/dist/commands/agent/list.d.ts.map +1 -1
  29. package/dist/commands/agent/list.js +2 -1
  30. package/dist/commands/agent/list.js.map +1 -1
  31. package/dist/commands/agent/run.d.ts.map +1 -1
  32. package/dist/commands/agent/run.js +2 -1
  33. package/dist/commands/agent/run.js.map +1 -1
  34. package/dist/commands/agent/uninstall.d.ts.map +1 -1
  35. package/dist/commands/agent/uninstall.js +13 -6
  36. package/dist/commands/agent/uninstall.js.map +1 -1
  37. package/dist/commands/agent/validate.d.ts.map +1 -1
  38. package/dist/commands/agent/validate.js +3 -2
  39. package/dist/commands/agent/validate.js.map +1 -1
  40. package/dist/commands/ard/emit.d.ts +153 -0
  41. package/dist/commands/ard/emit.d.ts.map +1 -0
  42. package/dist/commands/ard/emit.js +276 -0
  43. package/dist/commands/ard/emit.js.map +1 -0
  44. package/dist/commands/ard/index.d.ts +12 -0
  45. package/dist/commands/ard/index.d.ts.map +1 -0
  46. package/dist/commands/ard/index.js +80 -0
  47. package/dist/commands/ard/index.js.map +1 -0
  48. package/dist/commands/ard/surfaces.d.ts +56 -0
  49. package/dist/commands/ard/surfaces.d.ts.map +1 -0
  50. package/dist/commands/ard/surfaces.js +150 -0
  51. package/dist/commands/ard/surfaces.js.map +1 -0
  52. package/dist/commands/audit/distributed-tree.d.ts +2 -2
  53. package/dist/commands/audit/distributed-tree.d.ts.map +1 -1
  54. package/dist/commands/audit/distributed-tree.js +11 -7
  55. package/dist/commands/audit/distributed-tree.js.map +1 -1
  56. package/dist/commands/audit/git-url-clone.d.ts +1 -1
  57. package/dist/commands/audit/git-url-clone.d.ts.map +1 -1
  58. package/dist/commands/audit/git-url-clone.js.map +1 -1
  59. package/dist/commands/audit/hierarchical-output.d.ts +1 -1
  60. package/dist/commands/audit/hierarchical-output.d.ts.map +1 -1
  61. package/dist/commands/audit/hierarchical-output.js +1 -1
  62. package/dist/commands/audit/hierarchical-output.js.map +1 -1
  63. package/dist/commands/audit/provenance.d.ts.map +1 -1
  64. package/dist/commands/audit/provenance.js +4 -3
  65. package/dist/commands/audit/provenance.js.map +1 -1
  66. package/dist/commands/audit/scan-population.d.ts +158 -0
  67. package/dist/commands/audit/scan-population.d.ts.map +1 -0
  68. package/dist/commands/audit/scan-population.js +292 -0
  69. package/dist/commands/audit/scan-population.js.map +1 -0
  70. package/dist/commands/audit-settings.d.ts.map +1 -1
  71. package/dist/commands/audit-settings.js +6 -5
  72. package/dist/commands/audit-settings.js.map +1 -1
  73. package/dist/commands/audit.d.ts +98 -8
  74. package/dist/commands/audit.d.ts.map +1 -1
  75. package/dist/commands/audit.js +1126 -394
  76. package/dist/commands/audit.js.map +1 -1
  77. package/dist/commands/build.d.ts +1 -1
  78. package/dist/commands/build.d.ts.map +1 -1
  79. package/dist/commands/build.js +40 -36
  80. package/dist/commands/build.js.map +1 -1
  81. package/dist/commands/cache/cache-control.d.ts +82 -0
  82. package/dist/commands/cache/cache-control.d.ts.map +1 -0
  83. package/dist/commands/cache/cache-control.js +86 -0
  84. package/dist/commands/cache/cache-control.js.map +1 -0
  85. package/dist/commands/cache/clear.d.ts +34 -6
  86. package/dist/commands/cache/clear.d.ts.map +1 -1
  87. package/dist/commands/cache/clear.js +101 -15
  88. package/dist/commands/cache/clear.js.map +1 -1
  89. package/dist/commands/cache/index.d.ts +13 -63
  90. package/dist/commands/cache/index.d.ts.map +1 -1
  91. package/dist/commands/cache/index.js +47 -79
  92. package/dist/commands/cache/index.js.map +1 -1
  93. package/dist/commands/claude/claude-config.d.ts.map +1 -1
  94. package/dist/commands/claude/claude-config.js +6 -0
  95. package/dist/commands/claude/claude-config.js.map +1 -1
  96. package/dist/commands/claude/context.d.ts +340 -0
  97. package/dist/commands/claude/context.d.ts.map +1 -0
  98. package/dist/commands/claude/context.js +1080 -0
  99. package/dist/commands/claude/context.js.map +1 -0
  100. package/dist/commands/claude/index.d.ts +14 -0
  101. package/dist/commands/claude/index.d.ts.map +1 -1
  102. package/dist/commands/claude/index.js +22 -2
  103. package/dist/commands/claude/index.js.map +1 -1
  104. package/dist/commands/claude/marketplace/changelog-utils.d.ts +1 -1
  105. package/dist/commands/claude/marketplace/changelog-utils.d.ts.map +1 -1
  106. package/dist/commands/claude/marketplace/changelog-utils.js +1 -2
  107. package/dist/commands/claude/marketplace/changelog-utils.js.map +1 -1
  108. package/dist/commands/claude/marketplace/git-publish.d.ts.map +1 -1
  109. package/dist/commands/claude/marketplace/git-publish.js +40 -31
  110. package/dist/commands/claude/marketplace/git-publish.js.map +1 -1
  111. package/dist/commands/claude/marketplace/license-utils.d.ts.map +1 -1
  112. package/dist/commands/claude/marketplace/license-utils.js +0 -1
  113. package/dist/commands/claude/marketplace/license-utils.js.map +1 -1
  114. package/dist/commands/claude/marketplace/publish-tree.d.ts.map +1 -1
  115. package/dist/commands/claude/marketplace/publish-tree.js +0 -6
  116. package/dist/commands/claude/marketplace/publish-tree.js.map +1 -1
  117. package/dist/commands/claude/marketplace/publish.d.ts.map +1 -1
  118. package/dist/commands/claude/marketplace/publish.js +2 -1
  119. package/dist/commands/claude/marketplace/publish.js.map +1 -1
  120. package/dist/commands/claude/marketplace/validate.d.ts +73 -22
  121. package/dist/commands/claude/marketplace/validate.d.ts.map +1 -1
  122. package/dist/commands/claude/marketplace/validate.js +407 -72
  123. package/dist/commands/claude/marketplace/validate.js.map +1 -1
  124. package/dist/commands/claude/org/api-keys.d.ts +0 -3
  125. package/dist/commands/claude/org/api-keys.d.ts.map +1 -1
  126. package/dist/commands/claude/org/api-keys.js +2 -1
  127. package/dist/commands/claude/org/api-keys.js.map +1 -1
  128. package/dist/commands/claude/org/helpers.d.ts +54 -1
  129. package/dist/commands/claude/org/helpers.d.ts.map +1 -1
  130. package/dist/commands/claude/org/helpers.js +42 -7
  131. package/dist/commands/claude/org/helpers.js.map +1 -1
  132. package/dist/commands/claude/org/index.d.ts.map +1 -1
  133. package/dist/commands/claude/org/index.js +24 -16
  134. package/dist/commands/claude/org/index.js.map +1 -1
  135. package/dist/commands/claude/org/invites.d.ts +0 -3
  136. package/dist/commands/claude/org/invites.d.ts.map +1 -1
  137. package/dist/commands/claude/org/invites.js +3 -2
  138. package/dist/commands/claude/org/invites.js.map +1 -1
  139. package/dist/commands/claude/org/skills.d.ts +527 -0
  140. package/dist/commands/claude/org/skills.d.ts.map +1 -1
  141. package/dist/commands/claude/org/skills.js +1414 -108
  142. package/dist/commands/claude/org/skills.js.map +1 -1
  143. package/dist/commands/claude/org/users.d.ts +0 -3
  144. package/dist/commands/claude/org/users.d.ts.map +1 -1
  145. package/dist/commands/claude/org/users.js +6 -3
  146. package/dist/commands/claude/org/users.js.map +1 -1
  147. package/dist/commands/claude/org/workspaces.d.ts +0 -3
  148. package/dist/commands/claude/org/workspaces.d.ts.map +1 -1
  149. package/dist/commands/claude/org/workspaces.js +11 -7
  150. package/dist/commands/claude/org/workspaces.js.map +1 -1
  151. package/dist/commands/claude/plugin/build.d.ts +41 -3
  152. package/dist/commands/claude/plugin/build.d.ts.map +1 -1
  153. package/dist/commands/claude/plugin/build.js +147 -80
  154. package/dist/commands/claude/plugin/build.js.map +1 -1
  155. package/dist/commands/claude/plugin/helpers.d.ts.map +1 -1
  156. package/dist/commands/claude/plugin/helpers.js +2 -7
  157. package/dist/commands/claude/plugin/helpers.js.map +1 -1
  158. package/dist/commands/claude/plugin/install.d.ts +12 -0
  159. package/dist/commands/claude/plugin/install.d.ts.map +1 -1
  160. package/dist/commands/claude/plugin/install.js +93 -62
  161. package/dist/commands/claude/plugin/install.js.map +1 -1
  162. package/dist/commands/claude/plugin/list.d.ts.map +1 -1
  163. package/dist/commands/claude/plugin/list.js +3 -2
  164. package/dist/commands/claude/plugin/list.js.map +1 -1
  165. package/dist/commands/claude/plugin/marketplace-json.d.ts +24 -9
  166. package/dist/commands/claude/plugin/marketplace-json.d.ts.map +1 -1
  167. package/dist/commands/claude/plugin/marketplace-json.js +10 -9
  168. package/dist/commands/claude/plugin/marketplace-json.js.map +1 -1
  169. package/dist/commands/claude/plugin/plugin-changelog.d.ts.map +1 -1
  170. package/dist/commands/claude/plugin/plugin-changelog.js +0 -2
  171. package/dist/commands/claude/plugin/plugin-changelog.js.map +1 -1
  172. package/dist/commands/claude/plugin/plugin-files.d.ts.map +1 -1
  173. package/dist/commands/claude/plugin/plugin-files.js +3 -6
  174. package/dist/commands/claude/plugin/plugin-files.js.map +1 -1
  175. package/dist/commands/claude/plugin/plugin-validators.d.ts.map +1 -1
  176. package/dist/commands/claude/plugin/plugin-validators.js +3 -6
  177. package/dist/commands/claude/plugin/plugin-validators.js.map +1 -1
  178. package/dist/commands/claude/plugin/tree-copy.d.ts +73 -0
  179. package/dist/commands/claude/plugin/tree-copy.d.ts.map +1 -1
  180. package/dist/commands/claude/plugin/tree-copy.js +221 -14
  181. package/dist/commands/claude/plugin/tree-copy.js.map +1 -1
  182. package/dist/commands/claude/plugin/uninstall.d.ts.map +1 -1
  183. package/dist/commands/claude/plugin/uninstall.js +4 -4
  184. package/dist/commands/claude/plugin/uninstall.js.map +1 -1
  185. package/dist/commands/consistency-check.d.ts +17 -15
  186. package/dist/commands/consistency-check.d.ts.map +1 -1
  187. package/dist/commands/consistency-check.js +152 -106
  188. package/dist/commands/consistency-check.js.map +1 -1
  189. package/dist/commands/corpus/index.d.ts.map +1 -1
  190. package/dist/commands/corpus/index.js +3 -0
  191. package/dist/commands/corpus/index.js.map +1 -1
  192. package/dist/commands/corpus/report.d.ts +1 -2
  193. package/dist/commands/corpus/report.d.ts.map +1 -1
  194. package/dist/commands/corpus/report.js +1 -3
  195. package/dist/commands/corpus/report.js.map +1 -1
  196. package/dist/commands/corpus/runner.d.ts +58 -1
  197. package/dist/commands/corpus/runner.d.ts.map +1 -1
  198. package/dist/commands/corpus/runner.js +95 -41
  199. package/dist/commands/corpus/runner.js.map +1 -1
  200. package/dist/commands/corpus/scan.d.ts.map +1 -1
  201. package/dist/commands/corpus/scan.js +20 -12
  202. package/dist/commands/corpus/scan.js.map +1 -1
  203. package/dist/commands/corpus/seed.d.ts +4 -4
  204. package/dist/commands/corpus/seed.d.ts.map +1 -1
  205. package/dist/commands/corpus/seed.js +1 -3
  206. package/dist/commands/corpus/seed.js.map +1 -1
  207. package/dist/commands/doctor.d.ts +50 -0
  208. package/dist/commands/doctor.d.ts.map +1 -1
  209. package/dist/commands/doctor.js +245 -52
  210. package/dist/commands/doctor.js.map +1 -1
  211. package/dist/commands/inventory.d.ts +13 -1
  212. package/dist/commands/inventory.d.ts.map +1 -1
  213. package/dist/commands/inventory.js +121 -17
  214. package/dist/commands/inventory.js.map +1 -1
  215. package/dist/commands/mcp/collections.d.ts +1 -1
  216. package/dist/commands/mcp/collections.d.ts.map +1 -1
  217. package/dist/commands/mcp/list-collections.d.ts.map +1 -1
  218. package/dist/commands/mcp/list-collections.js +2 -1
  219. package/dist/commands/mcp/list-collections.js.map +1 -1
  220. package/dist/commands/mcp/serve.d.ts.map +1 -1
  221. package/dist/commands/mcp/serve.js +3 -2
  222. package/dist/commands/mcp/serve.js.map +1 -1
  223. package/dist/commands/okf/index.d.ts +11 -0
  224. package/dist/commands/okf/index.d.ts.map +1 -0
  225. package/dist/commands/okf/index.js +114 -0
  226. package/dist/commands/okf/index.js.map +1 -0
  227. package/dist/commands/okf/validate.d.ts +191 -0
  228. package/dist/commands/okf/validate.d.ts.map +1 -0
  229. package/dist/commands/okf/validate.js +190 -0
  230. package/dist/commands/okf/validate.js.map +1 -0
  231. package/dist/commands/phase-utils.d.ts +182 -114
  232. package/dist/commands/phase-utils.d.ts.map +1 -1
  233. package/dist/commands/phase-utils.js +154 -163
  234. package/dist/commands/phase-utils.js.map +1 -1
  235. package/dist/commands/positional-args.d.ts.map +1 -1
  236. package/dist/commands/positional-args.js +3 -2
  237. package/dist/commands/positional-args.js.map +1 -1
  238. package/dist/commands/rag/clear-command.d.ts.map +1 -1
  239. package/dist/commands/rag/clear-command.js +2 -1
  240. package/dist/commands/rag/clear-command.js.map +1 -1
  241. package/dist/commands/rag/index-command.d.ts +64 -0
  242. package/dist/commands/rag/index-command.d.ts.map +1 -1
  243. package/dist/commands/rag/index-command.js +72 -6
  244. package/dist/commands/rag/index-command.js.map +1 -1
  245. package/dist/commands/rag/index.d.ts.map +1 -1
  246. package/dist/commands/rag/index.js +23 -10
  247. package/dist/commands/rag/index.js.map +1 -1
  248. package/dist/commands/rag/query-command.d.ts.map +1 -1
  249. package/dist/commands/rag/query-command.js +2 -1
  250. package/dist/commands/rag/query-command.js.map +1 -1
  251. package/dist/commands/rag/stats-command.d.ts.map +1 -1
  252. package/dist/commands/rag/stats-command.js +2 -1
  253. package/dist/commands/rag/stats-command.js.map +1 -1
  254. package/dist/commands/resources/builtin-help.d.ts +27 -0
  255. package/dist/commands/resources/builtin-help.d.ts.map +1 -0
  256. package/dist/commands/resources/builtin-help.js +46 -0
  257. package/dist/commands/resources/builtin-help.js.map +1 -0
  258. package/dist/commands/resources/check-progress.d.ts +154 -0
  259. package/dist/commands/resources/check-progress.d.ts.map +1 -0
  260. package/dist/commands/resources/check-progress.js +222 -0
  261. package/dist/commands/resources/check-progress.js.map +1 -0
  262. package/dist/commands/resources/check-supervisor.d.ts +465 -0
  263. package/dist/commands/resources/check-supervisor.d.ts.map +1 -0
  264. package/dist/commands/resources/check-supervisor.js +573 -0
  265. package/dist/commands/resources/check-supervisor.js.map +1 -0
  266. package/dist/commands/resources/check.d.ts +593 -0
  267. package/dist/commands/resources/check.d.ts.map +1 -0
  268. package/dist/commands/resources/check.js +1365 -0
  269. package/dist/commands/resources/check.js.map +1 -0
  270. package/dist/commands/resources/index.d.ts.map +1 -1
  271. package/dist/commands/resources/index.js +418 -4
  272. package/dist/commands/resources/index.js.map +1 -1
  273. package/dist/commands/resources/query.d.ts +106 -0
  274. package/dist/commands/resources/query.d.ts.map +1 -0
  275. package/dist/commands/resources/query.js +177 -0
  276. package/dist/commands/resources/query.js.map +1 -0
  277. package/dist/commands/resources/scan.d.ts +24 -0
  278. package/dist/commands/resources/scan.d.ts.map +1 -1
  279. package/dist/commands/resources/scan.js +17 -8
  280. package/dist/commands/resources/scan.js.map +1 -1
  281. package/dist/commands/resources/validate.d.ts +42 -2
  282. package/dist/commands/resources/validate.d.ts.map +1 -1
  283. package/dist/commands/resources/validate.js +208 -55
  284. package/dist/commands/resources/validate.js.map +1 -1
  285. package/dist/commands/skill/review-checklist.d.ts +3 -2
  286. package/dist/commands/skill/review-checklist.d.ts.map +1 -1
  287. package/dist/commands/skill/review-checklist.js +40 -2
  288. package/dist/commands/skill/review-checklist.js.map +1 -1
  289. package/dist/commands/skill/review.d.ts +247 -0
  290. package/dist/commands/skill/review.d.ts.map +1 -1
  291. package/dist/commands/skill/review.js +99 -62
  292. package/dist/commands/skill/review.js.map +1 -1
  293. package/dist/commands/skill/test/configure.d.ts +56 -0
  294. package/dist/commands/skill/test/configure.d.ts.map +1 -1
  295. package/dist/commands/skill/test/configure.js +75 -16
  296. package/dist/commands/skill/test/configure.js.map +1 -1
  297. package/dist/commands/skill/test/run.d.ts +63 -10
  298. package/dist/commands/skill/test/run.d.ts.map +1 -1
  299. package/dist/commands/skill/test/run.js +258 -77
  300. package/dist/commands/skill/test/run.js.map +1 -1
  301. package/dist/commands/skills/build.d.ts +94 -4
  302. package/dist/commands/skills/build.d.ts.map +1 -1
  303. package/dist/commands/skills/build.js +325 -97
  304. package/dist/commands/skills/build.js.map +1 -1
  305. package/dist/commands/skills/install.d.ts +2 -1
  306. package/dist/commands/skills/install.d.ts.map +1 -1
  307. package/dist/commands/skills/install.js +20 -31
  308. package/dist/commands/skills/install.js.map +1 -1
  309. package/dist/commands/skills/list.d.ts +2 -1
  310. package/dist/commands/skills/list.d.ts.map +1 -1
  311. package/dist/commands/skills/list.js +44 -26
  312. package/dist/commands/skills/list.js.map +1 -1
  313. package/dist/commands/skills/package.d.ts +1 -1
  314. package/dist/commands/skills/package.d.ts.map +1 -1
  315. package/dist/commands/skills/package.js +6 -8
  316. package/dist/commands/skills/package.js.map +1 -1
  317. package/dist/commands/skills/scope-guard.d.ts.map +1 -1
  318. package/dist/commands/skills/scope-guard.js +3 -5
  319. package/dist/commands/skills/scope-guard.js.map +1 -1
  320. package/dist/commands/skills/shared.d.ts.map +1 -1
  321. package/dist/commands/skills/shared.js +2 -4
  322. package/dist/commands/skills/shared.js.map +1 -1
  323. package/dist/commands/skills/skill-discovery.d.ts +47 -6
  324. package/dist/commands/skills/skill-discovery.d.ts.map +1 -1
  325. package/dist/commands/skills/skill-discovery.js +61 -11
  326. package/dist/commands/skills/skill-discovery.js.map +1 -1
  327. package/dist/commands/skills/source-resolvers.d.ts +15 -0
  328. package/dist/commands/skills/source-resolvers.d.ts.map +1 -1
  329. package/dist/commands/skills/source-resolvers.js +25 -4
  330. package/dist/commands/skills/source-resolvers.js.map +1 -1
  331. package/dist/commands/skills/validate-command.d.ts.map +1 -1
  332. package/dist/commands/skills/validate-command.js +11 -3
  333. package/dist/commands/skills/validate-command.js.map +1 -1
  334. package/dist/commands/skills/validate.d.ts +140 -4
  335. package/dist/commands/skills/validate.d.ts.map +1 -1
  336. package/dist/commands/skills/validate.js +224 -67
  337. package/dist/commands/skills/validate.js.map +1 -1
  338. package/dist/commands/validate.d.ts +1 -1
  339. package/dist/commands/validate.d.ts.map +1 -1
  340. package/dist/commands/validate.js +63 -27
  341. package/dist/commands/validate.js.map +1 -1
  342. package/dist/commands/verify.d.ts +119 -15
  343. package/dist/commands/verify.d.ts.map +1 -1
  344. package/dist/commands/verify.js +357 -142
  345. package/dist/commands/verify.js.map +1 -1
  346. package/dist/index.d.ts +0 -1
  347. package/dist/index.d.ts.map +1 -1
  348. package/dist/index.js +5 -1
  349. package/dist/index.js.map +1 -1
  350. package/dist/report-schemas.d.ts +45 -0
  351. package/dist/report-schemas.d.ts.map +1 -0
  352. package/dist/report-schemas.js +100 -0
  353. package/dist/report-schemas.js.map +1 -0
  354. package/dist/skill-resolution/classify.js +2 -2
  355. package/dist/skill-resolution/classify.js.map +1 -1
  356. package/dist/skill-resolution/packaging-config.d.ts +49 -5
  357. package/dist/skill-resolution/packaging-config.d.ts.map +1 -1
  358. package/dist/skill-resolution/packaging-config.js +59 -19
  359. package/dist/skill-resolution/packaging-config.js.map +1 -1
  360. package/dist/skill-resolution/resolve-skill-reference.d.ts.map +1 -1
  361. package/dist/skill-resolution/resolve-skill-reference.js +55 -57
  362. package/dist/skill-resolution/resolve-skill-reference.js.map +1 -1
  363. package/dist/utils/agent-discovery.d.ts.map +1 -1
  364. package/dist/utils/agent-discovery.js +22 -11
  365. package/dist/utils/agent-discovery.js.map +1 -1
  366. package/dist/utils/agent-runner.d.ts.map +1 -1
  367. package/dist/utils/agent-runner.js +7 -4
  368. package/dist/utils/agent-runner.js.map +1 -1
  369. package/dist/utils/claude-context-lens.d.ts +34 -0
  370. package/dist/utils/claude-context-lens.d.ts.map +1 -0
  371. package/dist/utils/claude-context-lens.js +39 -0
  372. package/dist/utils/claude-context-lens.js.map +1 -0
  373. package/dist/utils/command-error.d.ts +138 -20
  374. package/dist/utils/command-error.d.ts.map +1 -1
  375. package/dist/utils/command-error.js +172 -36
  376. package/dist/utils/command-error.js.map +1 -1
  377. package/dist/utils/config-loader.d.ts +2 -1
  378. package/dist/utils/config-loader.d.ts.map +1 -1
  379. package/dist/utils/config-loader.js +40 -14
  380. package/dist/utils/config-loader.js.map +1 -1
  381. package/dist/utils/corpus-target.d.ts +26 -0
  382. package/dist/utils/corpus-target.d.ts.map +1 -0
  383. package/dist/utils/corpus-target.js +35 -0
  384. package/dist/utils/corpus-target.js.map +1 -0
  385. package/dist/utils/edge-lens-evaluation.d.ts +83 -0
  386. package/dist/utils/edge-lens-evaluation.d.ts.map +1 -0
  387. package/dist/utils/edge-lens-evaluation.js +110 -0
  388. package/dist/utils/edge-lens-evaluation.js.map +1 -0
  389. package/dist/utils/help-loader.d.ts.map +1 -1
  390. package/dist/utils/help-loader.js +0 -1
  391. package/dist/utils/help-loader.js.map +1 -1
  392. package/dist/utils/issue-anchor.d.ts +2 -2
  393. package/dist/utils/issue-anchor.d.ts.map +1 -1
  394. package/dist/utils/issue-anchor.js +1 -1
  395. package/dist/utils/issue-rendering.d.ts +3 -3
  396. package/dist/utils/issue-rendering.d.ts.map +1 -1
  397. package/dist/utils/issue-rendering.js +3 -3
  398. package/dist/utils/issue-rendering.js.map +1 -1
  399. package/dist/utils/issue-severity.d.ts +12 -5
  400. package/dist/utils/issue-severity.d.ts.map +1 -1
  401. package/dist/utils/issue-severity.js +1 -1
  402. package/dist/utils/issue-severity.js.map +1 -1
  403. package/dist/utils/optional-backend.d.ts +78 -0
  404. package/dist/utils/optional-backend.d.ts.map +1 -0
  405. package/dist/utils/optional-backend.js +117 -0
  406. package/dist/utils/optional-backend.js.map +1 -0
  407. package/dist/utils/output.d.ts +26 -0
  408. package/dist/utils/output.d.ts.map +1 -1
  409. package/dist/utils/output.js +35 -0
  410. package/dist/utils/output.js.map +1 -1
  411. package/dist/utils/package-json.d.ts +24 -0
  412. package/dist/utils/package-json.d.ts.map +1 -0
  413. package/dist/utils/package-json.js +49 -0
  414. package/dist/utils/package-json.js.map +1 -0
  415. package/dist/utils/population-wiring.d.ts +100 -0
  416. package/dist/utils/population-wiring.d.ts.map +1 -0
  417. package/dist/utils/population-wiring.js +98 -0
  418. package/dist/utils/population-wiring.js.map +1 -0
  419. package/dist/utils/project-root-policy.d.ts +20 -0
  420. package/dist/utils/project-root-policy.d.ts.map +1 -1
  421. package/dist/utils/project-root-policy.js +30 -0
  422. package/dist/utils/project-root-policy.js.map +1 -1
  423. package/dist/utils/projection-lenses.d.ts +98 -0
  424. package/dist/utils/projection-lenses.d.ts.map +1 -0
  425. package/dist/utils/projection-lenses.js +203 -0
  426. package/dist/utils/projection-lenses.js.map +1 -0
  427. package/dist/utils/projection-query.d.ts +324 -0
  428. package/dist/utils/projection-query.d.ts.map +1 -0
  429. package/dist/utils/projection-query.js +357 -0
  430. package/dist/utils/projection-query.js.map +1 -0
  431. package/dist/utils/projection-store.d.ts +335 -0
  432. package/dist/utils/projection-store.d.ts.map +1 -0
  433. package/dist/utils/projection-store.js +522 -0
  434. package/dist/utils/projection-store.js.map +1 -0
  435. package/dist/utils/relation-limits.d.ts +50 -0
  436. package/dist/utils/relation-limits.d.ts.map +1 -0
  437. package/dist/utils/relation-limits.js +50 -0
  438. package/dist/utils/relation-limits.js.map +1 -0
  439. package/dist/utils/repeatable-option.d.ts +37 -0
  440. package/dist/utils/repeatable-option.d.ts.map +1 -0
  441. package/dist/utils/repeatable-option.js +39 -0
  442. package/dist/utils/repeatable-option.js.map +1 -0
  443. package/dist/utils/resource-loader.d.ts +132 -2
  444. package/dist/utils/resource-loader.d.ts.map +1 -1
  445. package/dist/utils/resource-loader.js +288 -27
  446. package/dist/utils/resource-loader.js.map +1 -1
  447. package/dist/utils/root-argv.d.ts +53 -0
  448. package/dist/utils/root-argv.d.ts.map +1 -0
  449. package/dist/utils/root-argv.js +215 -0
  450. package/dist/utils/root-argv.js.map +1 -0
  451. package/dist/utils/run-integrity.d.ts +95 -0
  452. package/dist/utils/run-integrity.d.ts.map +1 -0
  453. package/dist/utils/run-integrity.js +102 -0
  454. package/dist/utils/run-integrity.js.map +1 -0
  455. package/dist/utils/skill-discovery.d.ts.map +1 -1
  456. package/dist/utils/skill-discovery.js +2 -1
  457. package/dist/utils/skill-discovery.js.map +1 -1
  458. package/dist/utils/skill-packaging-config.d.ts +63 -1
  459. package/dist/utils/skill-packaging-config.d.ts.map +1 -1
  460. package/dist/utils/skill-packaging-config.js +56 -1
  461. package/dist/utils/skill-packaging-config.js.map +1 -1
  462. package/dist/utils/sqlite-experimental-warning.d.ts +69 -0
  463. package/dist/utils/sqlite-experimental-warning.d.ts.map +1 -0
  464. package/dist/utils/sqlite-experimental-warning.js +144 -0
  465. package/dist/utils/sqlite-experimental-warning.js.map +1 -0
  466. package/dist/utils/url-redact.d.ts.map +1 -1
  467. package/dist/utils/url-redact.js +4 -6
  468. package/dist/utils/url-redact.js.map +1 -1
  469. package/dist/utils/user-context-scanner.d.ts +8 -0
  470. package/dist/utils/user-context-scanner.d.ts.map +1 -1
  471. package/dist/utils/user-context-scanner.js +4 -2
  472. package/dist/utils/user-context-scanner.js.map +1 -1
  473. package/dist/utils/validate-help-files.d.ts.map +1 -1
  474. package/dist/utils/validate-help-files.js +12 -6
  475. package/dist/utils/validate-help-files.js.map +1 -1
  476. package/dist/utils/vat-bin-path.d.ts +36 -0
  477. package/dist/utils/vat-bin-path.d.ts.map +1 -0
  478. package/dist/utils/vat-bin-path.js +48 -0
  479. package/dist/utils/vat-bin-path.js.map +1 -0
  480. package/dist/utils/verdict-helpers.d.ts +1 -1
  481. package/dist/utils/verdict-helpers.d.ts.map +1 -1
  482. package/dist/utils/verdict-helpers.js +1 -1
  483. package/dist/utils/verdict-helpers.js.map +1 -1
  484. package/dist/version.d.ts.map +1 -1
  485. package/dist/version.js +1 -3
  486. package/dist/version.js.map +1 -1
  487. package/docs/agent.md +2 -2
  488. package/docs/audit.md +141 -37
  489. package/docs/doctor.md +7 -6
  490. package/docs/index.md +19 -11
  491. package/docs/mcp.md +2 -2
  492. package/docs/rag.md +3 -2
  493. package/docs/resources.md +13 -3
  494. package/docs/skill-test.md +10 -9
  495. package/docs/skills.md +53 -69
  496. package/package.json +35 -26
  497. package/schemas/ard-emit.json +149 -0
  498. package/schemas/okf-validate.json +170 -0
  499. package/schemas/resources-check.json +229 -0
  500. package/schemas/skill-review.json +230 -0
  501. package/dist/commands/resources/command-helpers.d.ts +0 -5
  502. package/dist/commands/resources/command-helpers.d.ts.map +0 -1
  503. package/dist/commands/resources/command-helpers.js +0 -6
  504. package/dist/commands/resources/command-helpers.js.map +0 -1
@@ -5,21 +5,24 @@
5
5
  import * as fs from 'node:fs';
6
6
  import { existsSync as fsExistsSync } from 'node:fs';
7
7
  import { basename } from 'node:path';
8
- import { calculateValidationStatus, countBySeverity, } from '@vibe-agent-toolkit/agent-schema';
9
- import { crawlAndResolveRegistry, detectDeclaredButMissing, detectMarketplacePluginSourceMissing, resetPackagingRegistryCache, detectPresentButUndeclared, detectReferenceTargetMissing, detectResourceFormat, enumerateSurfaces, validate, validateMarketplace, validateSkill, validateSkillForPackaging, } from '@vibe-agent-toolkit/agent-skills';
8
+ import { conventionalSuiteProbe, crawlAndResolveRegistry, detectDeclaredButMissing, detectMarketplacePluginSourceMissing, resetPackagingRegistryCache, detectPresentButUndeclared, detectReferenceTargetMissing, detectResourceFormat, enumerateSurfaces, materializeIssue, validate, validateMarketplace, validateSkill, validateSkillForPackaging, } from '@vibe-agent-toolkit/agent-skills';
10
9
  import { analyzeCompatibility, checkSettingsCompatibility, detectSkillClaudePluginNameMismatch, crawlSkillLinkRegistry, extractClaudeMarketplaceInventory, extractClaudePluginInventory, getClaudeUserPaths, readEffectiveSettings, validatePlugin, } from '@vibe-agent-toolkit/claude-marketplace';
11
10
  import { detectFormat } from '@vibe-agent-toolkit/discovery';
12
- import { findProjectRoot, gitFindRoot, isAbsolutePath, isGitUrl, issueLocation, parseGitUrl, resetProjectRootCaches, safePath, } from '@vibe-agent-toolkit/utils';
11
+ import { listingRefusalRemedy } from '@vibe-agent-toolkit/resources';
12
+ import { calculateValidationStatus, countBySeverity, ExitCode, } from '@vibe-agent-toolkit/schema';
13
+ import { findProjectRoot, isFilesystemAccessError, isPathAbsentError, issueLocation, relativeEscapesRoot, resetProjectRootCaches, safePath, toForwardSlash, } from '@vibe-agent-toolkit/utils';
14
+ import { DirectoryListingRefusedError } from '@vibe-agent-toolkit/utils/crawl';
15
+ import { gitFindRoot, isGitUrl, parseGitUrl, } from '@vibe-agent-toolkit/utils/git';
13
16
  import { Command } from 'commander';
14
- import picomatch from 'picomatch';
15
17
  import { resetSkillDiscoveryCache, resolveProjectDeclaredEvalSuites, resolveSkillPackagingConfig, stripValidationAllowForDisplay, } from '../skill-resolution/packaging-config.js';
16
- import { handleCommandError } from '../utils/command-error.js';
18
+ import { handleCommandError, handleExpectedFailure } from '../utils/command-error.js';
17
19
  import { ConfigLoadError, loadConfig, resetLoadedConfigCache, } from '../utils/config-loader.js';
18
- import { countCollapsedFindings, formatCollapsedFindingsHint, formatIssueLines, formatSeverityBreakdown, issuesToRenderAtVerbosity, } from '../utils/issue-rendering.js';
20
+ import { countCollapsedFindings, formatCollapsedFindingsHint, formatIssueLines, formatSeverityBreakdown, issuesToRenderAtVerbosity, sumSeverityCounts, } from '../utils/issue-rendering.js';
19
21
  import { resolveIssueSeverity } from '../utils/issue-severity.js';
20
22
  import { createLogger } from '../utils/logger.js';
21
23
  import { writeYamlOutput } from '../utils/output.js';
22
24
  import { relativizePathEntries } from '../utils/relativize-paths.js';
25
+ import { nothingCheckedFinding } from '../utils/run-integrity.js';
23
26
  import { mergeSkillPackagingConfig } from '../utils/skill-packaging-config.js';
24
27
  import { renderSkillQualityFooter } from '../utils/skill-quality-footer.js';
25
28
  import { computeConfigVerdicts } from '../utils/verdict-helpers.js';
@@ -27,13 +30,13 @@ import { crawlOwnsSubtree, distributedTreeFindings, getOrCreateGitTracker, gitTr
27
30
  import { withClonedRepo } from './audit/git-url-clone.js';
28
31
  import { buildHierarchicalOutput } from './audit/hierarchical-output.js';
29
32
  import { renderProvenanceHeader, rewritePathsInResults, } from './audit/provenance.js';
33
+ import { enumerateAuditPopulation, resolveProjectExcludes, VAT_CONFIG_FILENAME, } from './audit/scan-population.js';
30
34
  import { createAuditSettingsCommand } from './audit-settings.js';
31
- import { discoverSkillsFromConfig } from './skills/skill-discovery.js';
35
+ import { discoverSkillsFromConfig, SKILLS_INCLUDE_REMEDY, } from './skills/skill-discovery.js';
32
36
  /** Resource type constant for agent skills, avoiding duplicate string literals. */
33
37
  const RESOURCE_TYPE_AGENT_SKILL = 'agent-skill';
34
38
  /** Resource type constant for Claude plugins, avoiding duplicate string literals. */
35
39
  const RESOURCE_TYPE_CLAUDE_PLUGIN = 'claude-plugin';
36
- const VAT_CONFIG_FILENAME = 'vibe-agent-toolkit.config.yaml';
37
40
  /**
38
41
  * Build config-aware context for a single VAT project at `scanRoot`.
39
42
  *
@@ -50,9 +53,8 @@ const VAT_CONFIG_FILENAME = 'vibe-agent-toolkit.config.yaml';
50
53
  *
51
54
  * Returns `null` if `scanRoot` has no config or the config has no skills.
52
55
  */
53
- async function buildVATProjectContext(scanRoot, logger) {
56
+ async function buildVATProjectContext(scanRoot, logger, locationRoot) {
54
57
  const configPath = safePath.join(scanRoot, VAT_CONFIG_FILENAME);
55
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- scanRoot is a controlled parameter
56
58
  if (!fs.existsSync(configPath)) {
57
59
  return null;
58
60
  }
@@ -68,7 +70,7 @@ async function buildVATProjectContext(scanRoot, logger) {
68
70
  return null;
69
71
  const skillConfigs = new Map();
70
72
  try {
71
- const discovered = await discoverSkillsFromConfig(config.skills, scanRoot);
73
+ const discovered = await discoverSkillsFromConfig(config.skills, scanRoot, degradingDiscovery(scanRoot, logger, locationRoot));
72
74
  const { defaults, config: perSkillConfig } = config.skills;
73
75
  for (const skill of discovered) {
74
76
  const absPath = safePath.resolve(skill.sourcePath);
@@ -78,7 +80,16 @@ async function buildVATProjectContext(scanRoot, logger) {
78
80
  logger.debug(`Config-aware audit: found ${discovered.length} skill(s) in ${scanRoot}`);
79
81
  }
80
82
  catch (error) {
81
- logger.debug(`Config-aware audit: failed to discover skills in ${scanRoot}: ${String(error)}`);
83
+ // Discovery was handed the degrade policy above, so a refused listing never
84
+ // reaches here; one that does (a caller that switched it to `'refuse'`) is
85
+ // still degraded past through the same ledger rather than swallowed. It used
86
+ // to be `logger.debug` for ANY error, which is how a `chmod 000` directory
87
+ // under `skills.include` downgraded every skill to config-free validation with
88
+ // the directory named nowhere an operator without `--debug` could see it. A
89
+ // defect in discovery is not environmental and still fails loudly.
90
+ if (!(error instanceof DirectoryListingRefusedError))
91
+ throw error;
92
+ recordRefusedDiscovery(error, scanRoot, logger, locationRoot);
82
93
  }
83
94
  if (skillConfigs.size === 0) {
84
95
  return null;
@@ -161,22 +172,287 @@ function packagingResultToValidationResult(skillPath, result, configTargets, loc
161
172
  * depth, so crawling again reports one file twice, while omitting it for a
162
173
  * standalone bundle is the blindness this parameter exists to end.
163
174
  */
164
- async function validateSingleSkill(skillPath, options, logger, locationRoot, crawlTree, isVATGenerated) {
165
- // Try config-aware validation: walk UP to the skill's nearest-ancestor
166
- // vibe-agent-toolkit.config.yaml and apply the skill's packaging block. A
167
- // broken governing config is tolerated here (audit is a bulk linter): log it
168
- // and fall back to config-free validation rather than aborting the scan.
175
+ /**
176
+ * Resolve a skill's nearest-ancestor `vibe-agent-toolkit.config.yaml`, treating
177
+ * one that cannot be LOADED as absent rather than as fatal.
178
+ *
179
+ * 🔑 Both audit lanes call this, and that is the point. They used to carry
180
+ * separate copies of the fork, and the copies disagreed: the single-target lane
181
+ * caught `ConfigLoadError` and fell back, the directory-scan lane let it throw.
182
+ * One unrecognized key in one nested config therefore aborted a whole-tree scan
183
+ * with exit 2 and zero skills audited, while auditing a single SKILL.md under
184
+ * that same config exited 0 — a verdict that depended on whether the argument
185
+ * was a file or its parent directory. Keeping the tolerance in ONE function is
186
+ * what stops the two answers drifting apart again.
187
+ *
188
+ * Degrading beats destroying, and silence is not the alternative — the policy
189
+ * `SCAN_PATH_UNREADABLE` states in its own registry docstring. So this warns
190
+ * rather than logging at debug, AND files a finding on the config path, because
191
+ * the warning goes to stderr while the report goes to stdout: a run that
192
+ * downgraded N skills to the much weaker config-free `validateSkill` and exited 0
193
+ * said nothing about it in the machine-readable artifact anyone actually diffs.
194
+ * That is the "detector silently disables itself" shape this code cites and was
195
+ * still doing.
196
+ *
197
+ * ⚠️ The filesystem question IS asked, and the answer changes what the operator
198
+ * is told. `config-loader.ts` preserves `cause` specifically so callers can ask
199
+ * `isFilesystemAccessError` and "decide whether to degrade or abort"; catching
200
+ * `ConfigLoadError` unconditionally never asked at all, so a `chmod 000` config
201
+ * and a typo produced the same sentence — and a permissions problem is not a
202
+ * broken config.
203
+ *
204
+ * 🔑 Asking it and still DEGRADING is deliberate, and was measured. Re-throwing
205
+ * so the per-entry guard anchors the finding is the obvious alternative and is
206
+ * worse: on a fixture with one unreadable config and one perfectly readable
207
+ * skill, the re-throw produced `filesScanned: 1, filesPassed: 0` with the finding
208
+ * anchored on `skills/demo/SKILL.md` and the skill never validated at all. On a
209
+ * real `--user` run that is hundreds of readable skills losing every check
210
+ * because one config is `chmod 000` — the exact "destroying" this lane exists to
211
+ * prevent, and a violation of the neighbouring promise that "a skill beside the
212
+ * unreadable path is validated exactly as if it were not there". The finding is
213
+ * filed on the CONFIG, which is the path that was actually refused, and the skill
214
+ * goes on to config-free validation.
215
+ *
216
+ * @returns the skill's display-safe packaging config, or `null` when there is
217
+ * no governing config OR when the one found could not be loaded.
218
+ */
219
+ async function resolveGoverningConfig(skillPath, logger, locationRoot) {
220
+ // The walk-up the resolver is about to do (cached), so the degrade handler can
221
+ // name the config whose include reached the directory. `null` means no config
222
+ // governs this skill; the resolver answers the same and nothing can be refused.
223
+ const configRoot = findProjectRoot(safePath.resolve(skillPath, '..'));
224
+ if (configRoot === null)
225
+ return null;
169
226
  let fullConfig;
170
227
  try {
171
- fullConfig = await resolveSkillPackagingConfig(skillPath);
228
+ fullConfig = await resolveSkillPackagingConfig(skillPath, degradingDiscovery(configRoot, logger, locationRoot));
172
229
  }
173
230
  catch (err) {
174
- if (!(err instanceof ConfigLoadError))
175
- throw err;
176
- logger.debug(` Ignoring broken governing config for ${skillPath}: ${err.message}`);
177
- fullConfig = null;
231
+ if (err instanceof ConfigLoadError) {
232
+ recordUnloadableConfig(err, logger, locationRoot);
233
+ return null;
234
+ }
235
+ // Unreachable while the resolver is handed `degradingDiscovery` above — the
236
+ // crawl then enumerates around a refused directory instead of throwing — but
237
+ // kept as the fallback for the default policy: same two channels, the skill
238
+ // goes on config-free rather than the run aborting.
239
+ if (err instanceof DirectoryListingRefusedError) {
240
+ recordRefusedDiscovery(err, configRoot, logger, locationRoot);
241
+ return null;
242
+ }
243
+ throw err;
178
244
  }
179
- const skillConfig = fullConfig === null ? null : stripValidationAllowForDisplay(fullConfig);
245
+ return fullConfig === null ? null : stripValidationAllowForDisplay(fullConfig);
246
+ }
247
+ /**
248
+ * Configs this run has already degraded past — unloadable, or loaded but with a
249
+ * `skills.include` the crawl could not fully expand — keyed by the PATH that was
250
+ * refused (the config file, or the directory the crawl could not list), each
251
+ * holding the synthetic result that carries its finding into the report.
252
+ *
253
+ * 🚨 MODULE-scoped, and cleared by {@link resetAuditCaches}, exactly like
254
+ * `config-loader.ts`'s `warnedConfigPaths`. It used to be a `Set` threaded as a
255
+ * parameter, and the threading had a hole: `handleFileEntry` passed the scan's
256
+ * real log, but `validateSingleSkill` constructed a fresh `new Set()` on EVERY
257
+ * call — and `validatePluginSkillsViaInventory` calls it in a loop over every
258
+ * skill in a plugin. A 40-skill plugin under one unloadable config printed 40
259
+ * identical warnings. The integration fixture was a bare directory, which routes
260
+ * through `handleFileEntry`, so the "warns once, not once per skill" test passed
261
+ * while the lane that actually repeated was never exercised. A run-scoped ledger
262
+ * cannot be re-created per call by mistake; a parameter can.
263
+ *
264
+ * ⚠️ Keyed on the refused PATH (derived from `ConfigLoadError.projectRoot` or
265
+ * `DirectoryListingRefusedError.refusal.directory`), NOT on `err.message`. The
266
+ * old key was the message with a comment claiming it "names the offending config
267
+ * path" — true only for a Zod failure, which is what the fixture happened to be.
268
+ * A YAML SYNTAX error is thrown out of `yaml.parse` and wrapped as `Failed to
269
+ * load config: ${message}`, and `YAMLParseError.message` carries no filename at
270
+ * all, so two nested projects with the same copy-pasted broken config collapsed
271
+ * to ONE warning naming NEITHER.
272
+ */
273
+ const unloadableConfigResults = new Map();
274
+ /**
275
+ * The RUN's conventional-suite probe — the memo behind "does
276
+ * `<skill-root>/evals/evals.json` exist?".
277
+ *
278
+ * Resolving one skill's test input asks that question of the SUBJECT and of
279
+ * every entry in `projectSkills`, so a probe minted per skill costs S questions
280
+ * per skill and S² per run over the same S paths. Measured with the lab on a
281
+ * 103-skill adopter: 10,815 probes over 103 distinct paths, half of the
282
+ * command's entire filesystem traffic.
283
+ *
284
+ * 🚨 MODULE-scoped and cleared by {@link resetAuditCaches}, for the same reason
285
+ * `unloadableConfigResults` above is: this command has TWO lanes that build a
286
+ * shared context — {@link validateSingleSkill} and {@link handleFileEntry} — and
287
+ * both build it PER SKILL, inside their own loops. A probe threaded as a
288
+ * parameter would be re-minted by whichever lane forgot to thread it, silently,
289
+ * and that is exactly how this stayed quadratic: the field existed and no caller
290
+ * set it.
291
+ *
292
+ * ⚠️ Its lifetime is the RUN, never longer. The answer is a filesystem snapshot,
293
+ * and a memo outliving the invocation would keep answering for a tree that has
294
+ * since changed — in the CLI entrypoint AND in an in-process test sharing a
295
+ * vitest worker.
296
+ */
297
+ let runSuiteProbe;
298
+ /** The run's probe, minted on first use and dropped by {@link resetAuditCaches}. */
299
+ function suiteProbeForRun() {
300
+ runSuiteProbe ??= conventionalSuiteProbe();
301
+ return runSuiteProbe;
302
+ }
303
+ /**
304
+ * Warn about — and file a finding for — a governing config this run could not
305
+ * load, once per config however many skills it governs.
306
+ *
307
+ * @param err - The load failure, carrying the project root that owns the config
308
+ * @param logger - Warnings go to stderr; the finding goes to the report
309
+ * @param locationRoot - The run's anchor base for the emitted location
310
+ */
311
+ function recordUnloadableConfig(err, logger, locationRoot) {
312
+ const configPath = safePath.join(err.projectRoot, VAT_CONFIG_FILENAME);
313
+ if (unloadableConfigResults.has(configPath))
314
+ return;
315
+ unloadableConfigResults.set(configPath, unreadablePathResult(configPath, err, locationRoot));
316
+ // The filesystem answer changes the NOUN, because the two conditions need
317
+ // different actions from the operator: `chmod`/ownership on one side, an edit
318
+ // to the file on the other. Collapsing them into one sentence was the whole
319
+ // cost of never asking.
320
+ const why = isFilesystemAccessError(err) ? 'unreadable' : 'unloadable';
321
+ logger.warn(`Ignoring ${why} config at ${configPath}; skills it governs are validated`
322
+ + ` config-free: ${err.message}`);
323
+ }
324
+ /**
325
+ * Audit's discovery policy: enumerate AROUND a directory the crawl cannot list,
326
+ * report the gap, and keep every skill the crawl could see under its config.
327
+ *
328
+ * 🔑 This is what keeps ONE `chmod 000` sibling from costing a readable,
329
+ * declared skill every config-aware check. Discovery's default is to refuse —
330
+ * right for a build that must not ship a shorter bundle — but audit already
331
+ * validates "a skill beside the unreadable path exactly as if it were not
332
+ * there", and that promise is only kept if discovery degrades the same way the
333
+ * walk does. The refusal is recorded through the same ledger as an unloadable
334
+ * config, so it is warned about once and filed once however many skills ask.
335
+ *
336
+ * @param configRoot - The project root whose `skills.include` is being expanded
337
+ * @param logger - Warnings go to stderr; the finding goes to the report
338
+ * @param locationRoot - The run's anchor base for the emitted location
339
+ */
340
+ function degradingDiscovery(configRoot, logger, locationRoot) {
341
+ return {
342
+ degrade: (refusal) => {
343
+ recordRefusedDiscovery(new DirectoryListingRefusedError(refusal, { root: configRoot, remedy: SKILLS_INCLUDE_REMEDY }), configRoot, logger, locationRoot);
344
+ },
345
+ };
346
+ }
347
+ /**
348
+ * Audit's ruling for the registry crawl — the link graph `crawlAndResolveRegistry`
349
+ * builds over the whole project root for the config-aware lane: enumerate
350
+ * AROUND a directory it cannot list, file the gap, keep every readable document.
351
+ *
352
+ * The other half of {@link degradingDiscovery}, on the other crawl the
353
+ * config-aware lane makes. Every other verb hands this crawl `'refuse'` (a build
354
+ * must not ship a shorter bundle), and while the registry carried that ruling
355
+ * INSIDE itself `vat audit` on a tree with one `chmod 000` sibling exited 2
356
+ * with `status: error` and ZERO findings — the unreadable-directory defect's
357
+ * exact shape, from the crawl its first fix never looked at. Audit's contract is the opposite:
358
+ * status describes what was found, exit describes whether the run completed.
359
+ *
360
+ * Recorded through the same per-directory ledger as a refused discovery, so a
361
+ * directory both crawls meet is warned about once and filed once; a directory
362
+ * only THIS crawl meets (the link graph covers the whole root, discovery only
363
+ * `skills.include`'s bases) gets its own sentence, naming the crawl that
364
+ * skipped it. The finding carries the projection's remedy — the same sentence
365
+ * the refuse ruling throws — because `--exclude` governs audit's own walk, not
366
+ * this crawl, and an ignore rule is the one knob that does.
367
+ *
368
+ * @param projectRoot - The root the link graph is crawled from
369
+ * @param logger - Warnings go to stderr; the finding goes to the report
370
+ * @param locationRoot - The run's anchor base for the emitted location
371
+ */
372
+ function degradingRegistry(projectRoot, logger, locationRoot) {
373
+ return {
374
+ degrade: (refusal) => {
375
+ const err = new DirectoryListingRefusedError(refusal, { root: projectRoot, remedy: listingRefusalRemedy(projectRoot) });
376
+ recordRefusedListing(err, logger, locationRoot, () => `The link graph under ${projectRoot} skipped an unreadable directory; every document beneath it`
377
+ + ` is missing from this audit's link and reference checks: ${err.message}`);
378
+ },
379
+ };
380
+ }
381
+ /**
382
+ * Warn about — and file a finding for — a directory that skill discovery under
383
+ * `configRoot` could not list, once per directory however many skills ask.
384
+ *
385
+ * The finding is anchored on the DIRECTORY, not the config: the config loaded
386
+ * fine, and the directory is the path the filesystem refused. In the directory
387
+ * lane the walk's own per-entry guard files the same code on the same location;
388
+ * {@link dedupeUnreadablePathResults} keeps one. The warning carries the crawl's
389
+ * own sentence, which already names the directory against the project root and
390
+ * the `skills.include` remedy.
391
+ *
392
+ * @param err - The refusal, naming the directory the crawl could not list
393
+ * @param configRoot - The project root whose `skills.include` reached it
394
+ * @param logger - Warnings go to stderr; the finding goes to the report
395
+ * @param locationRoot - The run's anchor base for the emitted location
396
+ */
397
+ function recordRefusedDiscovery(err, configRoot, logger, locationRoot) {
398
+ const configPath = safePath.join(configRoot, VAT_CONFIG_FILENAME);
399
+ recordRefusedListing(err, logger, locationRoot, () => `Skill discovery under ${configPath} skipped an unreadable directory; any skill beneath it`
400
+ + ` is missing from this audit: ${err.message}`);
401
+ }
402
+ /**
403
+ * The one ledger write for a refused listing, whichever crawl met it: file
404
+ * `SCAN_PATH_UNREADABLE` on the directory and warn once on stderr. Keyed on the
405
+ * refused directory, so the second crawl to meet it — discovery and the link
406
+ * graph both walk `skills/locked` in the directory lane — says nothing more.
407
+ *
408
+ * @param err - The refusal, naming the directory the crawl could not list
409
+ * @param logger - Warnings go to stderr; the finding goes to the report
410
+ * @param locationRoot - The run's anchor base for the emitted location
411
+ * @param warning - The stderr sentence, built only when this is the first record
412
+ */
413
+ function recordRefusedListing(err, logger, locationRoot, warning) {
414
+ const directory = err.refusal.directory;
415
+ if (unloadableConfigResults.has(directory))
416
+ return;
417
+ unloadableConfigResults.set(directory, unreadablePathResult(directory, err, locationRoot));
418
+ logger.warn(warning());
419
+ }
420
+ /**
421
+ * The synthetic results for every config this run degraded past, so the report
422
+ * carries what the stderr warning says.
423
+ *
424
+ * Read, never drained: `resetAuditCaches()` owns the lifetime, and a drain would
425
+ * make the answer depend on which aggregation site ran first.
426
+ */
427
+ function unloadableConfigFindings() {
428
+ return [...unloadableConfigResults.values()];
429
+ }
430
+ /**
431
+ * A run's results plus the findings for every config it degraded past, with each
432
+ * refused path reported once.
433
+ *
434
+ * Appended, not merged into a neighbour: a config the scan could not load, or a
435
+ * directory its discovery could not list, has no host result to hang off, and
436
+ * attributing it to a skill would name a file that was read fine. Without the
437
+ * append the ONLY record was a stderr warning, so a run that downgraded every
438
+ * skill under that config to the weaker config-free validator exited 0 with a
439
+ * report that said nothing about it. Deduped AFTER the append, because the
440
+ * directory lane's walk files its own `SCAN_PATH_UNREADABLE` on the very
441
+ * directory a refused discovery names — one fact, one finding, whichever lane
442
+ * met it first.
443
+ */
444
+ function withDegradedConfigFindings(results) {
445
+ return dedupeUnreadablePathResults([...results, ...unloadableConfigFindings()]);
446
+ }
447
+ async function validateSingleSkill(skillPath, options, logger, locationRoot, crawlTree, isVATGenerated) {
448
+ // Config-aware validation: walk UP to the skill's nearest-ancestor
449
+ // vibe-agent-toolkit.config.yaml and apply the skill's packaging block. A
450
+ // config that cannot be loaded is treated as absent — see
451
+ // {@link resolveGoverningConfig}, which both audit lanes share so they cannot
452
+ // disagree about that again. The warn-once ledger is run-scoped module state,
453
+ // NOT a set minted here — a fresh `new Set()` on this line is what made a
454
+ // 40-skill plugin print 40 identical warnings.
455
+ const skillConfig = await resolveGoverningConfig(skillPath, logger, locationRoot);
180
456
  if (skillConfig !== null) {
181
457
  logger.debug(` Using config-aware validation for: ${skillPath}`);
182
458
  const { gitTracker } = await resolveScanContext(safePath.resolve(skillPath), locationRoot, logger);
@@ -188,10 +464,18 @@ async function validateSingleSkill(skillPath, options, logger, locationRoot, cra
188
464
  // The project's declared eval suites, memoized per config root, so this lane
189
465
  // predicts the same bundle the packager produces: no skill's report counts a
190
466
  // SIBLING skill's eval suite as an ordinary bundled file.
467
+ // Audit's ruling for the link-graph crawl — see {@link degradingRegistry}.
468
+ // Stated on the context too, for the fallback crawl `validateSkillForPackaging`
469
+ // makes when this registry does not cover the skill: one ruling, both crawls.
470
+ const unreadable = degradingRegistry(projectRoot, logger, locationRoot);
191
471
  const sharedCtx = {
192
- registry: await crawlAndResolveRegistry(projectRoot),
472
+ registry: await crawlAndResolveRegistry(projectRoot, { unreadable }),
473
+ unreadable,
193
474
  locationRoot,
194
- projectSkills: await resolveProjectDeclaredEvalSuites(skillPath),
475
+ projectSkills: await resolveProjectDeclaredEvalSuites(skillPath, degradingDiscovery(projectRoot, logger, locationRoot)),
476
+ // The RUN's probe, not this call's — see {@link runSuiteProbe}. This lane
477
+ // is called in a loop over every skill in a plugin.
478
+ suiteProbe: suiteProbeForRun(),
195
479
  };
196
480
  if (gitTracker !== null) {
197
481
  sharedCtx.gitTracker = gitTracker;
@@ -201,8 +485,8 @@ async function validateSingleSkill(skillPath, options, logger, locationRoot, cra
201
485
  await appendDistributedTreeFindings(configAware, skillPath, locationRoot, crawlTree);
202
486
  return configAware;
203
487
  }
204
- // Fallback: basic validation
205
- const validateOptions = { skillPath, locationRoot };
488
+ // Fallback: basic validation. Severity is applied by `applySeverityFilter`.
489
+ const validateOptions = { skillPath, locationRoot, validation: {} };
206
490
  if (options.warnUnreferencedFiles) {
207
491
  validateOptions.checkUnreferencedFiles = true;
208
492
  }
@@ -248,6 +532,14 @@ function collect(value, previous) {
248
532
  /**
249
533
  * Create audit command
250
534
  * Top-level command: vat audit [path]
535
+ *
536
+ * The exit code follows the document's `status`, under the one exit-code
537
+ * contract every verb shares: `OK` when nothing is at error severity,
538
+ * `FINDINGS` when something is (or the run audited zero files), `ERROR` when
539
+ * the audit could not run at all. This command used to be the one exception —
540
+ * "advisory", exit 0 over `status: error` — which a CI author reading the
541
+ * shared contract got wrong on exactly this verb. Warnings never move the
542
+ * exit code; `validation.severity` is the dial that decides what counts.
251
543
  */
252
544
  export function createAuditCommand() {
253
545
  const audit = new Command('audit');
@@ -314,11 +606,20 @@ Description:
314
606
  done
315
607
  (See packages/cli/docs/audit.md for details.)
316
608
 
609
+ Output:
610
+ The YAML 'status' describes the findings, and the exit code follows it, as
611
+ in every other command of this CLI.
612
+
613
+ A run that audited ZERO files is refused, not passed: 'status: error' with one
614
+ non-overridable RESOURCE_CHECK_BROKEN under a top-level 'issues:' (the claim is
615
+ about the run, so it is not a files[] row), and exit 1.
616
+
317
617
  Validation Behavior:
318
- Advisory only: audit surfaces all validation issues for inspection.
618
+ Audit surfaces all validation issues for inspection.
319
619
  Unlike 'vat skills validate', audit:
320
620
  - NEVER applies validation.allow (allowed codes are always shown)
321
- - Respects validation.severity: codes set to 'ignore' are hidden
621
+ - Respects validation.severity: a code set to 'ignore' is hidden; warnings
622
+ and errors are both reported, each at its configured severity
322
623
  - ALWAYS exits 0 for validation results (never gates on errors)
323
624
 
324
625
  For gated validation (CI/CD), use: vat skills validate
@@ -338,11 +639,16 @@ Validation Checks:
338
639
 
339
640
  Warnings (should fix):
340
641
  - Skill exceeds recommended length (>5000 lines)
341
- - Compat smells — requires browser auth, local shell, or external CLI
342
- (COMPAT_REQUIRES_BROWSER_AUTH, COMPAT_REQUIRES_LOCAL_SHELL,
343
- COMPAT_REQUIRES_EXTERNAL_CLI). See docs/validation-codes.md.
642
+ - With --compat: a declared target cannot or may not run the skill
643
+ (COMPAT_TARGET_INCOMPATIBLE, COMPAT_TARGET_NEEDS_REVIEW)
344
644
  - Unreferenced files detected (with --warn-unreferenced-files)
345
645
 
646
+ Info (observations):
647
+ - Capability observations — browser auth, local shell, or external CLI
648
+ (CAPABILITY_BROWSER_AUTH, CAPABILITY_LOCAL_SHELL, CAPABILITY_EXTERNAL_CLI);
649
+ with --compat, a skill declaring no targets (COMPAT_TARGET_UNDECLARED).
650
+ See docs/validation-codes.md.
651
+
346
652
  Gitignore-Aware Scanning:
347
653
  When scanning inside a git repository, paths matched by .gitignore are
348
654
  skipped by default. This excludes build artifacts (dist/), dependencies
@@ -352,8 +658,9 @@ Gitignore-Aware Scanning:
352
658
  Use --include-artifacts to scan gitignored paths (e.g., to audit a
353
659
  bundled marketplace plugin in dist/).
354
660
 
355
- User-supplied --exclude patterns are always applied on top.
356
- Outside a git repository, no automatic exclusions apply.
661
+ User-supplied --exclude patterns are always applied on top. The
662
+ directories no VAT crawl enters (node_modules/, .git/, coverage/,
663
+ .turbo/, git worktrees) stay out under every flag, in or out of git.
357
664
 
358
665
  Config-Aware Validation:
359
666
  The governing vibe-agent-toolkit.config.yaml is found by walking UP from
@@ -363,14 +670,29 @@ Config-Aware Validation:
363
670
  which prevents false warnings for links the build pipeline resolves.
364
671
 
365
672
  Config-aware mode never applies validation.allow — audit always shows
366
- all issues. validation.severity IS applied: a code set to 'ignore' is
367
- hidden. skills.defaults.validation.severity applies project-wide (skills,
368
- plugins and marketplaces alike); skills.config.<name>.validation.severity
369
- layers on top for that skill.
673
+ all issues. validation.severity IS applied, from three scopes, least
674
+ specific first:
675
+ resources.validation.severity project-wide (the same dial
676
+ 'vat resources validate' reads)
677
+ skills.defaults.validation.severity project-wide (skills, plugins and
678
+ marketplaces alike)
679
+ skills.config.<name>.validation... that one skill
370
680
 
371
681
  Exit Codes:
372
- 0 - Always (even when validation errors are surfaced)
373
- 2 - System error (config invalid, directory not found)
682
+ 0 - The audit completed with nothing at error severity (warnings and
683
+ informational findings are in the report, not the exit code).
684
+ 1 - The audit completed and reports 'status: error': at least one
685
+ error-severity finding, or zero files audited. A path inside the tree
686
+ the scan could not read, or a governing config that cannot be loaded
687
+ or whose skills.include reaches an unreadable directory, is
688
+ SCAN_PATH_UNREADABLE (warning): the run is degraded, not failed —
689
+ readable siblings are still validated, the config's skills
690
+ config-free, and the refused path is not counted in filesScanned (so
691
+ a root with nothing readable is a zero-files refusal).
692
+ 2 - The audit could not run at all: the path does not exist or is a file
693
+ no audit lane recognises, --user with no Claude config dir, a git URL
694
+ that would not clone, an unknown flag, an internal failure. There is
695
+ no report to read.
374
696
 
375
697
  Examples:
376
698
  $ vat audit ./plugins/ # Audit recursively (default)
@@ -424,11 +746,8 @@ async function auditUserDirectories(recursive, options, startTime, logger) {
424
746
  // --user scans three sibling directories; they share ONE enclosing Claude
425
747
  // config dir, so that is the run's single stated root.
426
748
  const scanRoot = safePath.resolve(claudeDir);
427
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- safe: path constructed from os.homedir()
428
749
  const pluginsDirExists = fs.existsSync(pluginsDir);
429
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- safe: path constructed from os.homedir()
430
750
  const skillsDirExists = fs.existsSync(skillsDir);
431
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- safe: path constructed from os.homedir()
432
751
  const marketplacesDirExists = fs.existsSync(marketplacesDir);
433
752
  if (!pluginsDirExists && !skillsDirExists && !marketplacesDirExists) {
434
753
  logger.error(`No user-level Claude directories found:`);
@@ -436,9 +755,9 @@ async function auditUserDirectories(recursive, options, startTime, logger) {
436
755
  logger.error(` Skills: ${skillsDir}`);
437
756
  logger.error(` Marketplaces: ${marketplacesDir}`);
438
757
  logger.error('Claude plugins/skills/marketplaces have not been installed yet.');
439
- process.exit(2);
758
+ process.exit(ExitCode.ERROR);
440
759
  }
441
- const results = [];
760
+ const scanned = [];
442
761
  // The roots that exist, minus any one another already covers — see
443
762
  // {@link userScanTargets}. `marketplaces/` lives inside `plugins/`.
444
763
  const present = [
@@ -448,8 +767,12 @@ async function auditUserDirectories(recursive, options, startTime, logger) {
448
767
  ];
449
768
  for (const target of userScanTargets(present, recursive)) {
450
769
  logger.debug(`Auditing user-level resources at: ${target}`);
451
- results.push(...await getValidationResults(target, recursive, options, logger, scanRoot));
770
+ scanned.push(...await getValidationResults(target, recursive, options, logger, scanRoot));
452
771
  }
772
+ // The `--user` report is assembled here rather than by `buildAuditReport`, so
773
+ // it needs its own append or the config findings would reach stderr and not the
774
+ // document — see the same call in `buildAuditReport`.
775
+ const results = withDegradedConfigFindings(scanned);
453
776
  // Run compatibility analysis if --compat flag is set
454
777
  const compatMap = options.compat
455
778
  ? await runCompatAnalysis(results, logger, scanRoot) // --settings not supported in --user mode
@@ -462,7 +785,7 @@ async function auditUserDirectories(recursive, options, startTime, logger) {
462
785
  if (verbose) {
463
786
  renderVerboseEvidence(results, scanRoot, logger);
464
787
  }
465
- logHierarchicalSummary(results, logger);
788
+ logHierarchicalSummary(results, summary.issues, logger);
466
789
  }
467
790
  /** Skill resource types that can have per-skill validation config. */
468
791
  const SKILL_RESULT_TYPES = new Set([RESOURCE_TYPE_AGENT_SKILL, 'vat-agent']);
@@ -486,7 +809,6 @@ const SKILL_RESULT_TYPES = new Set([RESOURCE_TYPE_AGENT_SKILL, 'vat-agent']);
486
809
  */
487
810
  export function deriveScanRoot(targetPath) {
488
811
  const resolved = safePath.resolve(targetPath);
489
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved is the operator's own audit target
490
812
  const isDirectory = fs.existsSync(resolved) && fs.statSync(resolved).isDirectory();
491
813
  return isDirectory ? resolved : safePath.resolve(resolved, '..');
492
814
  }
@@ -534,7 +856,7 @@ function buildFilteredResult(result, filteredIssues) {
534
856
  /**
535
857
  * Apply severity resolution to validation results.
536
858
  *
537
- * Audit is advisory only: it applies `validation.severity` to decide what to
859
+ * Audit shows everything: it applies `validation.severity` to decide what to
538
860
  * show and at what severity, but deliberately ignores `validation.allow`.
539
861
  *
540
862
  * **Both directions, via the one shared resolver.** This used to compute the
@@ -546,13 +868,21 @@ function buildFilteredResult(result, filteredIssues) {
546
868
  * same config promoted correctly. Two lanes, one config key, opposite answers.
547
869
  *
548
870
  * Status and counts are re-derived from the resolved severities by
549
- * {@link buildFilteredResult}, so a promotion moves the reported status. It does
550
- * NOT move this command's exit code — `vat audit` is advisory and its exit-code
551
- * behaviour is a separate, pre-existing question tracked in its own issue. That
552
- * asymmetry with `vat verify` (whose exit code IS severity-derived) is
553
- * deliberate here rather than overlooked.
554
- *
555
- * Two scopes, both live:
871
+ * {@link buildFilteredResult}, so a promotion moves the reported STATUS — which
872
+ * describes the findings — and never the exit code, which describes whether the
873
+ * run completed. The two are different questions and audit publishes both; see
874
+ * {@link handleAuditResults} and the `Output` section of this command's help.
875
+ * `vat verify` derives its exit code from severity because it is a gate; this
876
+ * command is a report.
877
+ *
878
+ * THREE scopes, least specific first:
879
+ * - `resources.validation.severity` — the PROJECT-WIDE map `vat resources
880
+ * validate` already honours. Audit read only the two `skills.*` scopes below,
881
+ * so one dial in one config file produced opposite answers from the two
882
+ * commands: measured on a two-code fixture, `LINK_MISSING_TARGET: ignore` and
883
+ * `LINK_TO_NAVIGATION_FILE: error` written here moved neither finding in
884
+ * audit. It is applied FIRST — a `skills` scope naming the same code is the
885
+ * more specific statement and wins.
556
886
  * - `skills.defaults.validation.severity` — the PROJECT-WIDE map, applied to
557
887
  * every result this run produced, including `claude-plugin` and `marketplace`
558
888
  * ones. It used to be applied only to skill-typed results, which left the
@@ -587,18 +917,27 @@ function buildFilteredResult(result, filteredIssues) {
587
917
  * @returns New results array with ignored codes removed from issues
588
918
  */
589
919
  function applySeverityFilter(results, config) {
590
- if (config?.skills === undefined) {
591
- return results;
592
- }
593
- const skillsConfig = config.skills;
594
- const defaultSeverity = skillsConfig.defaults?.validation?.severity ?? {};
920
+ const skillsConfig = config?.skills;
921
+ // NOT gated on `config.skills` existing. It was, and that made the resources
922
+ // dial below inert for exactly the projects that have only one — a repo of
923
+ // plugins declares no skills at all.
924
+ const resourceSeverity = config?.resources?.validation?.severity ?? {};
925
+ const defaultSeverity = skillsConfig?.defaults?.validation?.severity ?? {};
926
+ const projectSeverity = { ...resourceSeverity, ...defaultSeverity };
927
+ // `.filter` after `.map`, not `.map` alone: a synthetic unreadable-path result
928
+ // exists ONLY to carry its finding, so once the adopter has set
929
+ // `severity.SCAN_PATH_UNREADABLE: ignore` the result has nothing left to say.
930
+ // Keeping it published `status: success` for a path the scan never read — the
931
+ // report positively asserting that an unread path PASSED, which is a stronger
932
+ // falsehood than the count inflation noted on `unreadablePathResult`. Dropping
933
+ // it is what "ignore" already means everywhere else.
595
934
  return results.map(result => {
596
935
  const skillName = SKILL_RESULT_TYPES.has(result.type) ? result.metadata?.name : undefined;
597
936
  const perSkillSeverity = skillName === undefined
598
937
  ? {}
599
- : (skillsConfig.config?.[skillName]?.validation?.severity ?? {});
600
- // Merge: per-skill overrides default
601
- const effectiveSeverity = { ...defaultSeverity, ...perSkillSeverity };
938
+ : (skillsConfig?.config?.[skillName]?.validation?.severity ?? {});
939
+ // Merge, least specific first: resources → skills.defaults → skills.config.<name>
940
+ const effectiveSeverity = { ...projectSeverity, ...perSkillSeverity };
602
941
  if (Object.keys(effectiveSeverity).length === 0) {
603
942
  return result;
604
943
  }
@@ -614,7 +953,21 @@ function applySeverityFilter(results, config) {
614
953
  return result;
615
954
  }
616
955
  return buildFilteredResult(result, resolvedIssues);
617
- });
956
+ }).filter(result => !isEmptiedUnreadablePathResult(result));
957
+ }
958
+ /**
959
+ * A synthetic unreadable-path result whose only finding was ignored away.
960
+ *
961
+ * Keyed on the shape this module itself produces — `type: 'unknown'`, no
962
+ * metadata, and now no issues — rather than on a marker field, because
963
+ * `ValidationResult` is a shared contract and this is the one producer that needs
964
+ * to be recognised. A genuinely-unknown resource from `unified-validator` carries
965
+ * metadata or issues and is unaffected.
966
+ */
967
+ function isEmptiedUnreadablePathResult(result) {
968
+ return result.type === 'unknown'
969
+ && result.issues.length === 0
970
+ && result.metadata === undefined;
618
971
  }
619
972
  /**
620
973
  * Resolve `--settings`-driven EffectiveSettings if both --settings and
@@ -647,17 +1000,47 @@ export async function buildAuditReport(scanPath, options, startTime, logger) {
647
1000
  const recursive = options.recursive !== false;
648
1001
  logger.debug(`Auditing resources at: ${scanPath}`);
649
1002
  const scanRoot = deriveScanRoot(scanPath);
650
- const rawResults = await getValidationResults(scanPath, recursive, options, logger, scanRoot);
1003
+ const scanned = await getValidationResults(scanPath, recursive, options, logger, scanRoot);
1004
+ // Build config-aware context scoped to the single derived config root —
1005
+ // used by compat analysis to resolve the plugin-level `targets` union.
1006
+ // VAT's design: one config per project, no composition.
1007
+ //
1008
+ // BEFORE the report is assembled, not after: this is the lane that expands
1009
+ // `skills.include` even when the scan reached no SKILL.md under the config,
1010
+ // and a refusal it meets is recorded in the same ledger the append below
1011
+ // reads. Built afterwards, that finding reached stderr and not the document.
1012
+ const vatContextForCompat = await buildVATProjectContext(deriveConfigRoot(scanPath), logger, scanRoot);
1013
+ // See {@link withDegradedConfigFindings} for why these are appended, not merged.
1014
+ const rawResults = withDegradedConfigFindings(scanned);
651
1015
  // Load config for severity filtering (audit ignores allow; only severity matters).
652
- const config = loadConfig(deriveConfigRoot(scanPath));
1016
+ //
1017
+ // Guarded, and the ordering is why: the scan above has ALREADY run and collected
1018
+ // every finding by this point. An unreadable or malformed config here threw past
1019
+ // all of it and ended the command with `status: error` and zero findings —
1020
+ // discarding completed work to report a problem with a file that only decides
1021
+ // which findings to *hide*. Every other `loadConfig` in this file is already
1022
+ // wrapped for the same reason; this one was missed.
1023
+ //
1024
+ // Falling back to no config means no severity overrides are applied, so findings
1025
+ // are reported at their default severity. That is the safe direction: it can
1026
+ // only show more than the adopter asked for, never less.
1027
+ let config;
1028
+ try {
1029
+ config = loadConfig(deriveConfigRoot(scanPath));
1030
+ }
1031
+ catch (err) {
1032
+ // `warn`, not `debug`. Falling back to no config is the safe direction — it
1033
+ // can only show more than the adopter asked for, never less — but it means
1034
+ // every `validation.severity` override they wrote is silently inert. Before
1035
+ // this guard the command failed loudly and told them; degrading without a word
1036
+ // would trade a crash for a config that quietly stopped working, which is the
1037
+ // harder problem to notice.
1038
+ logger.warn(`Config could not be read; severity overrides are not applied: ${String(err)}`);
1039
+ }
653
1040
  // Apply severity filtering: hide codes whose effective severity is 'ignore'.
654
- // Allow is deliberately NOT applied — audit is advisory only.
1041
+ // Allow is deliberately NOT applied — audit shows every finding.
655
1042
  const results = applySeverityFilter(rawResults, config);
656
1043
  const effectiveSettings = await resolveEffectiveSettings(options, scanPath, logger);
657
- // Build config-aware context scoped to the single derived config root —
658
- // used by compat analysis to resolve the plugin-level `targets` union.
659
- // VAT's design: one config per project, no composition.
660
- const vatContextForCompat = await buildVATProjectContext(deriveConfigRoot(scanPath), logger);
661
1044
  // Run compatibility analysis if --compat flag is set
662
1045
  const compatMap = options.compat
663
1046
  ? await runCompatAnalysis(results, logger, scanRoot, effectiveSettings, vatContextForCompat)
@@ -718,7 +1101,6 @@ export async function auditCommand(targetPath, options) {
718
1101
  // cloned https://github.com/<that>.git instead, reaching out to the
719
1102
  // network with a name derived from the caller's own directory layout.
720
1103
  // Shorthand is a fallback for arguments that name nothing on disk.
721
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- targetPath is the user's own audit target; existence is the question being asked
722
1104
  if (targetPath !== undefined && !fsExistsSync(targetPath) && isGitUrl(targetPath)) {
723
1105
  await runUrlAudit(targetPath, options);
724
1106
  return;
@@ -730,11 +1112,55 @@ export async function auditCommand(targetPath, options) {
730
1112
  return;
731
1113
  }
732
1114
  const scanPath = targetPath ? safePath.resolve(targetPath) : process.cwd();
1115
+ const unusable = await unusableRootReason(scanPath);
1116
+ if (unusable !== undefined) {
1117
+ // `return` the call though its type is `never`: under test `process.exit`
1118
+ // is a spy that returns, and a bare call would fall through into the scan.
1119
+ return handleExpectedFailure(unusable, ExitCode.ERROR, startTime);
1120
+ }
733
1121
  await runAuditAtPath(scanPath, options);
734
1122
  }
735
1123
  catch (error) {
736
- handleCommandError(error, logger, startTime, 'AgentAudit');
1124
+ // 'Audit', not 'AgentAudit'. The old name was copy-pasted from the agent
1125
+ // command family and named a command that does not exist, so the one line an
1126
+ // operator has to search for did not match what they typed.
1127
+ handleCommandError(error, logger, startTime, 'Audit');
1128
+ }
1129
+ }
1130
+ /**
1131
+ * Why the ROOT argument cannot be audited at all, or `undefined` when the scan
1132
+ * may start.
1133
+ *
1134
+ * The exit-code contract every verb shares: a path that does not exist, or a
1135
+ * file no audit lane recognises, is the INVOCATION's mistake — `ExitCode.ERROR`
1136
+ * (2) before any scan, the same ending `vat resources validate` and `vat skill
1137
+ * review` give the same argument. Until this check the audit ran anyway and
1138
+ * published `UNKNOWN_FORMAT` as a finding at exit 1 with `filesScanned: 1`: a
1139
+ * typo in a CI step read as "the tree failed its gate", and the denominator
1140
+ * counted a file that was never there.
1141
+ *
1142
+ * ONLY the root, and only these two conditions. A root the OS refuses to stat
1143
+ * is not answered here — the scan degrades it to `SCAN_PATH_UNREADABLE`, which
1144
+ * is not counted as a scanned file, so a tree with nothing readable ends on the
1145
+ * zero-files refusal (exit 1) like any other tree that yielded nothing. A
1146
+ * sub-path inside a readable tree is never the invocation's fault and stays a
1147
+ * finding. Pinned by the `audit → error` rows of `exit-codes.system.test.ts`.
1148
+ */
1149
+ async function unusableRootReason(scanPath) {
1150
+ let isDirectory;
1151
+ try {
1152
+ isDirectory = fs.lstatSync(scanPath).isDirectory();
1153
+ }
1154
+ catch (error) {
1155
+ if (isPathAbsentError(error))
1156
+ return `Path does not exist: ${scanPath}`;
1157
+ return undefined;
737
1158
  }
1159
+ if (isDirectory || detectFormat(scanPath) === RESOURCE_TYPE_AGENT_SKILL)
1160
+ return undefined;
1161
+ if ((await detectResourceFormat(scanPath)).type !== 'unknown')
1162
+ return undefined;
1163
+ return `Path is not a resource this command can audit (a SKILL.md, a plugin or marketplace directory, or a Claude registry file): ${scanPath}`;
738
1164
  }
739
1165
  /**
740
1166
  * Dispatch a single surface to the matching validator. Keeps the multi-surface
@@ -788,7 +1214,7 @@ async function runInventoryDetectors(scanPath, type, locationRoot, prebuiltInv)
788
1214
  ];
789
1215
  }
790
1216
  if (type === 'marketplace') {
791
- const inv = prebuiltInv?.kind === 'marketplace' ? prebuiltInv : await extractClaudeMarketplaceInventory(scanPath);
1217
+ const inv = prebuiltInv?.kind === 'marketplace' ? prebuiltInv : await extractClaudeMarketplaceInventory(scanPath, { gitTrackerSource: gitTrackerForProjectRoot });
792
1218
  return detectMarketplacePluginSourceMissing(inv, locationRoot);
793
1219
  }
794
1220
  return [];
@@ -838,22 +1264,50 @@ async function appendPluginInventoryToSurfaceResults(surfaceResults, scanPath, l
838
1264
  }
839
1265
  /**
840
1266
  * Surface `inventory.parseErrors[]` as `PLUGIN_INVALID_JSON` findings on
841
- * `result`, skipping the plugin.json manifest entry (which is already covered
842
- * by `validatePlugin`). Only hooks/hooks.json and .mcp.json parse errors reach
843
- * this helper; they are appended with error severity.
1267
+ * `result`, skipping the entries another lane already files under their own
1268
+ * code:
1269
+ *
1270
+ * - the plugin.json manifest entry, covered by `validatePlugin`;
1271
+ * - every `SKILL.md` entry. The skill extractor records a SKILL.md it could
1272
+ * not open, could not parse, or could not link-walk on the plugin's
1273
+ * `parseErrors[]` — and each of those is the SKILL's finding, filed on the
1274
+ * skill by its own validation (`SCAN_PATH_UNREADABLE`,
1275
+ * `SKILL_MISSING_FRONTMATTER`) in both the plugin-target lane
1276
+ * (`validatePluginSkillsViaInventory`, over the same inventory) and the
1277
+ * directory lane (the walk reaches every SKILL.md beneath the plugin).
1278
+ * Re-filing it here was a second copy under a code that names a file it is
1279
+ * not (plugin.json) and a format it is not (JSON), at error severity beside
1280
+ * the skill lane's warning, and carrying the build host's absolute path.
1281
+ *
1282
+ * What remains — hooks/hooks.json, .mcp.json, a directory the crawl could not
1283
+ * list — is appended with error severity when the SUBJECT is defective, and
1284
+ * as `SCAN_PATH_UNREADABLE` (warning) when the row is marked `unreadable`
1285
+ * (the OS refused the path); the message is spelled scan-root-relative
1286
+ * wherever it names the file.
844
1287
  */
845
1288
  function appendInventoryParseErrors(result, inv, locationRoot) {
846
1289
  const pluginJsonSuffix = safePath.join('.claude-plugin', 'plugin.json');
1290
+ // One refused path, one refusal: the distributed-tree detector crawls the
1291
+ // same directories and files the ones it cannot list on this same result.
1292
+ const refusalsFiled = new Set(result.issues.filter((issue) => issue.code === 'SCAN_PATH_UNREADABLE').map((issue) => issue.location));
847
1293
  const parseIssues = [];
848
1294
  for (const err of inv.parseErrors) {
849
- if (err.path.endsWith(pluginJsonSuffix))
1295
+ if (err.path.endsWith(pluginJsonSuffix) || basename(err.path) === 'SKILL.md')
850
1296
  continue;
851
- parseIssues.push({
852
- severity: 'error',
853
- code: 'PLUGIN_INVALID_JSON',
854
- message: err.message,
855
- location: issueLocation(err.path, locationRoot),
856
- });
1297
+ const message = withRelativePath(err.message, err.path, locationRoot);
1298
+ const location = issueLocation(err.path, locationRoot);
1299
+ if (err.unreadable !== true) {
1300
+ parseIssues.push({ severity: 'error', code: 'PLUGIN_INVALID_JSON', message, location });
1301
+ continue;
1302
+ }
1303
+ // A path the OS refused was not examined; that is the run degrading, not
1304
+ // a defect of the plugin, and it is filed at the severity every other lane
1305
+ // gives a refusal. Under `PLUGIN_INVALID_JSON` (error) an `EACCES` on one
1306
+ // skill directory failed the whole audit over a mode bit.
1307
+ if (refusalsFiled.has(location))
1308
+ continue;
1309
+ refusalsFiled.add(location);
1310
+ parseIssues.push(materializeIssue('SCAN_PATH_UNREADABLE', { location, detail: `${location}: ${message}` }));
857
1311
  }
858
1312
  // Status was hand-set to 'error' here while `issueCounts` and `summary` kept
859
1313
  // describing the pre-append list. One appender derives all three.
@@ -869,7 +1323,119 @@ function appendInventoryParseErrors(result, inv, locationRoot) {
869
1323
  * location reads, or a run spanning several configs emits one document in
870
1324
  * several coordinate systems. Recursion passes it through unchanged.
871
1325
  */
1326
+ /**
1327
+ * Validate ONE audit subject, degrading if the filesystem refuses it.
1328
+ *
1329
+ * THE boundary guard, and deliberately the only one of its kind. The first
1330
+ * version of this fix guarded the directory walk alone, which left every other
1331
+ * way into a validator unprotected: a `SKILL.md` named directly on the command
1332
+ * line, a plugin directory, a marketplace, each surface of a multi-surface tree.
1333
+ * All of them still ended the whole run with `status: error`, exit 2 and zero
1334
+ * findings — the exact symptom of the unreadable-directory defect, in the lanes
1335
+ * nobody had checked.
1336
+ *
1337
+ * Guarding the seven dispatch sites individually would have been the same mistake
1338
+ * a seventh time. This function is what every one of them is reached through, so
1339
+ * it is where "the environment refused this subject" becomes a finding instead of
1340
+ * an abort. It is also the recursion point — `recurseIntoMarketplacePlugins` and
1341
+ * the `--user` target loop both re-enter here — so an unreadable plugin costs its
1342
+ * own subject and nothing else.
1343
+ *
1344
+ * The walk keeps its own per-entry guard: this one would answer at whole-subject
1345
+ * granularity, discarding findings already collected from readable siblings, and
1346
+ * degrading as narrowly as possible is the entire point.
1347
+ */
872
1348
  export async function getValidationResults(scanPath, recursive, options, logger, locationRoot) {
1349
+ try {
1350
+ return dedupeUnreadablePathResults(await validateAuditSubject(scanPath, recursive, options, logger, locationRoot));
1351
+ }
1352
+ catch (error) {
1353
+ // Only the filesystem refusing the subject degrades; a defect in a validator
1354
+ // must still fail loudly rather than be reported as a permissions problem.
1355
+ if (!isFilesystemAccessError(error))
1356
+ throw error;
1357
+ logger.debug(`Unreadable audit subject: ${scanPath}`);
1358
+ return [unreadablePathResult(scanPath, error, locationRoot)];
1359
+ }
1360
+ }
1361
+ /**
1362
+ * One refused path, one `SCAN_PATH_UNREADABLE` — however many lanes met it.
1363
+ *
1364
+ * Two lanes can reach the same unreadable directory in one run: the directory
1365
+ * walk (`scanDirectory`, which files the synthetic {@link unreadablePathResult})
1366
+ * and the distributed-tree detector, which crawls a skill's own directory at any
1367
+ * depth and reports what it could not list as an issue ON the skill's result.
1368
+ * Their populations overlap, but neither contains the other: the walk skips
1369
+ * what `--exclude`, the project's `resources.exclude` and gitignore skip, and
1370
+ * does not descend at all under `--no-recursive` or when the SKILL.md is named
1371
+ * directly — the detector enters all of those. So "the walk already reports
1372
+ * everything under the scan root" is false in both directions, and a rule that
1373
+ * silenced one lane by geography would drop the refusal in exactly the cases
1374
+ * where it is the only report. The rule is instead keyed on the FACT: the
1375
+ * code and the location, which both lanes derive from the same directory and
1376
+ * the same anchor root.
1377
+ *
1378
+ * The hosted issue wins and the synthetic result is dropped, not the reverse:
1379
+ * the detector's copy says which SKILL's crawl was cut short, which the
1380
+ * synthetic result cannot, and it is the only copy the direct-target lane ever
1381
+ * produces — so keeping it makes the two lanes' reports the same shape. A
1382
+ * synthetic result that carried nothing but the duplicate is removed whole; one
1383
+ * that somehow carried more keeps its other issues (it cannot today, but the
1384
+ * filter says what it means rather than assuming).
1385
+ *
1386
+ * A THIRD lane files synthetic results too: skill discovery under a governing
1387
+ * config, which refuses a directory its `skills.include` cannot list and is
1388
+ * recorded through {@link recordRefusedDiscovery}. In the directory lane the
1389
+ * walk has already filed its own synthetic result on that same directory, so
1390
+ * two synthetic results can carry one fact. Among synthetics the FIRST wins and
1391
+ * later copies of the same location are dropped — the walk's copy carries the
1392
+ * raw errno, the discovery's copy the crawl's remedy sentence, and the stderr
1393
+ * warning carries the latter either way.
1394
+ */
1395
+ function dedupeUnreadablePathResults(results) {
1396
+ const reported = new Set();
1397
+ for (const result of results) {
1398
+ if (result.type === 'unknown')
1399
+ continue;
1400
+ for (const issue of result.issues) {
1401
+ if (issue.code === 'SCAN_PATH_UNREADABLE')
1402
+ reported.add(issue.location ?? '');
1403
+ }
1404
+ }
1405
+ return results.flatMap((result) => {
1406
+ if (result.type !== 'unknown')
1407
+ return [result];
1408
+ const kept = withoutHostedRefusals(result, reported);
1409
+ for (const survivor of kept) {
1410
+ for (const issue of survivor.issues) {
1411
+ if (issue.code === 'SCAN_PATH_UNREADABLE')
1412
+ reported.add(issue.location ?? '');
1413
+ }
1414
+ }
1415
+ return kept;
1416
+ });
1417
+ }
1418
+ /**
1419
+ * A synthetic result minus the refusals some hosted result already carries —
1420
+ * as an empty array when nothing is left, so {@link dedupeUnreadablePathResults}
1421
+ * can `flatMap` it away.
1422
+ */
1423
+ function withoutHostedRefusals(result, hosted) {
1424
+ const issues = result.issues.filter((issue) => !(issue.code === 'SCAN_PATH_UNREADABLE' && hosted.has(issue.location ?? '')));
1425
+ if (issues.length === 0)
1426
+ return [];
1427
+ if (issues.length === result.issues.length)
1428
+ return [result];
1429
+ const issueCounts = countBySeverity(issues);
1430
+ return [{
1431
+ ...result,
1432
+ status: calculateValidationStatus(issues),
1433
+ summary: formatCountsSummary(issueCounts),
1434
+ issues,
1435
+ issueCounts,
1436
+ }];
1437
+ }
1438
+ async function validateAuditSubject(scanPath, recursive, options, logger, locationRoot) {
873
1439
  const format = detectFormat(scanPath);
874
1440
  // Special handling for direct SKILL.md file
875
1441
  if (format === RESOURCE_TYPE_AGENT_SKILL) {
@@ -923,7 +1489,7 @@ export async function getValidationResults(scanPath, recursive, options, logger,
923
1489
  // recursion into co-located, path-source plugins. discovered.plugins[]
924
1490
  // is populated only for path sources that exist on disk (git/npm/unknown
925
1491
  // sources are declarations only and stay out of scope).
926
- const marketplaceInv = await extractClaudeMarketplaceInventory(scanPath);
1492
+ const marketplaceInv = await extractClaudeMarketplaceInventory(scanPath, { gitTrackerSource: gitTrackerForProjectRoot });
927
1493
  const marketplaceInventoryIssues = await runInventoryDetectors(scanPath, 'marketplace', locationRoot, marketplaceInv);
928
1494
  appendIssues(result, marketplaceInventoryIssues);
929
1495
  logger.debug(`Inventory detectors emitted ${marketplaceInventoryIssues.length.toString()} issues for marketplace at ${scanPath}`);
@@ -933,26 +1499,45 @@ export async function getValidationResults(scanPath, recursive, options, logger,
933
1499
  return [result];
934
1500
  }
935
1501
  // If unknown format, check if it's a directory we can scan
936
- const fsp = await import('node:fs/promises');
937
- try {
938
- const stat = await fsp.stat(scanPath);
939
- if (stat.isDirectory()) {
940
- logger.debug('Scanning directory for resources');
941
- // The project's `resources.exclude` is applied by the scan context,
942
- // on the project-root basis its globs were written against — NOT
943
- // merged into `options.exclude`, whose patterns are relative to the
944
- // directory the operator named. See resolveProjectExcludes.
945
- return scanDirectory(scanPath, recursive, options, logger, await resolveScanContext(scanPath, locationRoot, logger));
946
- }
947
- }
948
- catch {
949
- // Path doesn't exist or not accessible, let validate() handle it
1502
+ if (await isScannableDirectory(scanPath)) {
1503
+ logger.debug('Scanning directory for resources');
1504
+ // The project's `resources.exclude` is applied by the scan context,
1505
+ // on the project-root basis its globs were written against — NOT
1506
+ // merged into `options.exclude`, whose patterns are relative to the
1507
+ // directory the operator named. See resolveProjectExcludes.
1508
+ return scanDirectory(scanPath, recursive, options, logger, await resolveScanContext(scanPath, locationRoot, logger));
950
1509
  }
951
1510
  // Unknown resource type - use unified validator which will return appropriate error
952
1511
  logger.debug(`Unknown resource type at: ${scanPath}`);
953
1512
  const result = await validate(scanPath, { locationRoot });
954
1513
  return [result];
955
1514
  }
1515
+ /**
1516
+ * Whether `scanPath` is a directory the walk can enter.
1517
+ *
1518
+ * `false` for a file and for NOTHING — `validate()` then names the path as
1519
+ * unknown or absent, which is the answer the operator can act on. A refusal on
1520
+ * the stat is neither: it propagates to `getValidationResults`, whose own catch
1521
+ * files it as `SCAN_PATH_UNREADABLE`, so the audit still degrades (the run
1522
+ * completes, the refusal a finding) rather than refusing the run.
1523
+ *
1524
+ * 🪤 The stat used to share ONE `try` with `scanDirectory` itself, and the
1525
+ * catch was bare. Every throw from the scan — a crawl refusal, a `TypeError`
1526
+ * two frames down — was absorbed into "let validate() handle it", and
1527
+ * `validate()` then reported the directory as an unknown resource format. A
1528
+ * scan that examined nothing was reported as a directory that was nothing.
1529
+ */
1530
+ async function isScannableDirectory(scanPath) {
1531
+ const fsp = await import('node:fs/promises');
1532
+ try {
1533
+ return (await fsp.stat(scanPath)).isDirectory();
1534
+ }
1535
+ catch (error) {
1536
+ if (isPathAbsentError(error))
1537
+ return false;
1538
+ throw error;
1539
+ }
1540
+ }
956
1541
  /**
957
1542
  * Inventory-driven plugin skill walker. Builds the plugin inventory via
958
1543
  * `extractClaudePluginInventory` and dispatches `validateSingleSkill` for each
@@ -972,9 +1557,26 @@ async function validatePluginSkillsViaInventory(scanPath, options, logger, locat
972
1557
  continue;
973
1558
  }
974
1559
  logger.debug(` Validating plugin-bundled skill (inventory): ${skill.files.skillMd}`);
975
- // No crawl: `validatePlugin` already scanned the whole plugin tree, nested
976
- // skill directories included, so this lane would duplicate its findings.
977
- results.push(await validateSingleSkill(skill.files.skillMd, options, logger, locationRoot, false));
1560
+ // Guarded PER SKILL, not left to the boundary. The boundary guard answers at
1561
+ // whole-subject granularity, so one unreadable bundled `SKILL.md` discarded
1562
+ // the plugin's own manifest findings and every readable sibling skill with
1563
+ // it — 4 results down to 1 — and anchored the finding at the plugin root,
1564
+ // which is perfectly readable. That contradicted this code's own promise
1565
+ // that an unreadable path "costs its own subject and nothing else", and the
1566
+ // documented promise that "a skill beside the unreadable path is validated
1567
+ // exactly as if it were not there". Degrading as narrowly as the structure
1568
+ // allows is the whole point; here the skill is the unit.
1569
+ try {
1570
+ // No crawl: `validatePlugin` already scanned the whole plugin tree, nested
1571
+ // skill directories included, so this lane would duplicate its findings.
1572
+ results.push(await validateSingleSkill(skill.files.skillMd, options, logger, locationRoot, false));
1573
+ }
1574
+ catch (error) {
1575
+ if (!isFilesystemAccessError(error))
1576
+ throw error;
1577
+ logger.debug(` Unreadable plugin-bundled skill: ${skill.files.skillMd}`);
1578
+ results.push(unreadablePathResult(skill.files.skillMd, error, locationRoot));
1579
+ }
978
1580
  }
979
1581
  return results;
980
1582
  }
@@ -1001,9 +1603,9 @@ function resolveConfigTargetsForPlugin(pluginDir, vatContext) {
1001
1603
  const union = new Set();
1002
1604
  for (const [skillPathAbs, packagingConfig] of vatContext.skillConfigs) {
1003
1605
  const rel = safePath.relative(pluginDirAbs, skillPathAbs);
1004
- // Skill lives inside the plugin directory iff relative path does not
1005
- // start with '..' and is not absolute.
1006
- if (rel === '' || rel.startsWith('..') || isAbsolutePath(rel))
1606
+ // Skill lives inside the plugin directory iff the relative path neither
1607
+ // climbs out nor (on Windows, cross-drive) comes back absolute.
1608
+ if (rel === '' || relativeEscapesRoot(rel))
1007
1609
  continue;
1008
1610
  const skillTargets = packagingConfig.targets;
1009
1611
  if (skillTargets === undefined)
@@ -1015,7 +1617,15 @@ function resolveConfigTargetsForPlugin(pluginDir, vatContext) {
1015
1617
  return union.size === 0 ? undefined : [...union];
1016
1618
  }
1017
1619
  /**
1018
- * Run compatibility analysis on plugin results and return a map of path -> CompatibilityResult.
1620
+ * `message`, with `absolutePath` spelled root-relative wherever it appears.
1621
+ * An OS refusal names the path it refused, absolutely; every other path in
1622
+ * the document is relative to `root`, and this one leaks the build host.
1623
+ */
1624
+ function withRelativePath(message, absolutePath, locationRoot) {
1625
+ return message.replaceAll(absolutePath, issueLocation(absolutePath, locationRoot) || '.');
1626
+ }
1627
+ /**
1628
+ * Run compatibility analysis on plugin results and return a map of path -> PluginCompatEntry.
1019
1629
  * Non-plugin results are skipped silently.
1020
1630
  * When effectiveSettings is provided, also runs settings conflict detection.
1021
1631
  *
@@ -1036,67 +1646,99 @@ export async function runCompatAnalysis(results, logger, locationRoot, effective
1036
1646
  for (const result of results) {
1037
1647
  if (result.type !== RESOURCE_TYPE_CLAUDE_PLUGIN)
1038
1648
  continue;
1039
- try {
1040
- logger.debug(`Running compatibility analysis for: ${result.path}`);
1041
- const configTargets = resolveConfigTargetsForPlugin(result.path, vatContext);
1042
- const analyzeOptions = configTargets === undefined
1043
- ? undefined
1044
- : { configTargets };
1045
- const compat = await analyzeCompatibility(result.path, locationRoot, analyzeOptions);
1046
- // Settings conflict detection (when --settings flag is used)
1047
- if (effectiveSettings === undefined) {
1048
- compatMap.set(result.path, compat);
1049
- }
1050
- else {
1051
- try {
1052
- const settingsConflicts = await checkSettingsCompatibility(result.path, effectiveSettings);
1053
- compatMap.set(result.path, { ...compat, settingsConflicts });
1054
- }
1055
- catch (settingsErr) {
1056
- logger.debug(`Settings compat check skipped for ${result.path}: ${String(settingsErr)}`);
1057
- compatMap.set(result.path, compat);
1058
- }
1059
- }
1060
- }
1061
- catch (err) {
1062
- // Log but do not fail the audit — compat analysis is best-effort
1063
- logger.debug(`Compatibility analysis skipped for ${result.path}: ${String(err)}`);
1649
+ const entry = {
1650
+ compat: await analyzeOrExplain(result.path, locationRoot, vatContext, logger),
1651
+ };
1652
+ if (effectiveSettings !== undefined) {
1653
+ entry.settings = await settingsVerdict(result.path, effectiveSettings, locationRoot, logger);
1064
1654
  }
1655
+ compatMap.set(result.path, entry);
1065
1656
  }
1066
1657
  return compatMap;
1067
1658
  }
1659
+ /**
1660
+ * The analyzer's result for one plugin, or — when it threw — the reason, said
1661
+ * on stderr and carried into the document. Compat analysis is best-effort:
1662
+ * it never fails the audit, but a plugin it could not analyze is reported as
1663
+ * exactly that, never as a plugin with no `compatibility:` block.
1664
+ */
1665
+ async function analyzeOrExplain(pluginDir, locationRoot, vatContext, logger) {
1666
+ try {
1667
+ logger.debug(`Running compatibility analysis for: ${pluginDir}`);
1668
+ const configTargets = resolveConfigTargetsForPlugin(pluginDir, vatContext);
1669
+ const analyzeOptions = configTargets === undefined ? undefined : { configTargets };
1670
+ return await analyzeCompatibility(pluginDir, locationRoot, analyzeOptions);
1671
+ }
1672
+ catch (err) {
1673
+ const refused = typeof err.path === 'string' ? err.path : undefined;
1674
+ const raw = err instanceof Error ? err.message : String(err);
1675
+ const reason = refused === undefined ? raw : withRelativePath(raw, refused, locationRoot);
1676
+ logger.warn(`Compatibility analysis could not run for ${issueLocation(pluginDir, locationRoot) || '.'}: ${reason}`);
1677
+ return { analyzed: false, reason };
1678
+ }
1679
+ }
1680
+ /**
1681
+ * One plugin's settings verdict: the checker's conflicts and the paths it
1682
+ * never saw — and, when the checker itself fails, the plugin as the one thing
1683
+ * unchecked, rather than no `settings:` block at all.
1684
+ *
1685
+ * The checker failing used to be `logger.debug` and a result with no settings
1686
+ * block, so a plugin the check could not run on was indistinguishable from one
1687
+ * the operator never asked about. A run that asked for `--settings` and got no
1688
+ * answer for a plugin is told so on stderr and in the document.
1689
+ */
1690
+ async function settingsVerdict(pluginDir, effectiveSettings, locationRoot, logger) {
1691
+ try {
1692
+ return await checkSettingsCompatibility(pluginDir, effectiveSettings);
1693
+ }
1694
+ catch (settingsErr) {
1695
+ const reason = settingsErr instanceof Error ? settingsErr.message : String(settingsErr);
1696
+ logger.warn(`Settings compatibility could not be checked for ${issueLocation(pluginDir, locationRoot) || '.'}: ${reason}`);
1697
+ return { conflicts: [], unchecked: [{ path: pluginDir, reason }] };
1698
+ }
1699
+ }
1700
+ /**
1701
+ * The `settings:` block for one plugin, every path anchored at `locationRoot`.
1702
+ * `compatible` is derived from BOTH halves: zero conflicts over a skill the
1703
+ * check never read is not compatibility, it is silence.
1704
+ */
1705
+ function renderSettingsBlock(check, locationRoot) {
1706
+ const settings = {
1707
+ compatible: check.conflicts.length === 0 && check.unchecked.length === 0,
1708
+ conflicts: check.conflicts,
1709
+ };
1710
+ if (check.unchecked.length > 0) {
1711
+ settings.unchecked = check.unchecked.map(({ path, reason }) => ({
1712
+ path: issueLocation(path, locationRoot) || '.',
1713
+ reason: withRelativePath(reason, path, locationRoot),
1714
+ }));
1715
+ }
1716
+ return settings;
1717
+ }
1068
1718
  /**
1069
1719
  * Merge compatibility analysis results into validation result output objects.
1070
1720
  * Returns an array of plain objects ready for YAML serialization.
1071
- * When settingsConflicts are present, adds a `settings:` block for cleaner output.
1721
+ * A plugin the run asked `--settings` about gets a `settings:` block, whatever
1722
+ * the analyzer did.
1072
1723
  */
1073
- function mergeCompatIntoResults(results, compatMap) {
1724
+ function mergeCompatIntoResults(results, compatMap, locationRoot) {
1074
1725
  return results.map(r => {
1075
- const compat = compatMap.get(r.path);
1076
- if (compat === undefined)
1726
+ const entry = compatMap.get(r.path);
1727
+ if (entry === undefined)
1077
1728
  return r;
1078
- // Extract settingsConflicts from compat to render as a separate `settings:` block
1079
- const { settingsConflicts, ...compatWithoutSettings } = compat;
1080
- if (settingsConflicts !== undefined) {
1081
- return {
1082
- ...r,
1083
- compatibility: compatWithoutSettings,
1084
- settings: {
1085
- compatible: settingsConflicts.length === 0,
1086
- conflicts: settingsConflicts,
1087
- },
1088
- };
1089
- }
1090
- return { ...r, compatibility: compat };
1729
+ const merged = { ...r, compatibility: entry.compat };
1730
+ if (entry.settings !== undefined)
1731
+ merged.settings = renderSettingsBlock(entry.settings, locationRoot);
1732
+ return merged;
1091
1733
  });
1092
1734
  }
1093
1735
  /**
1094
1736
  * Apply compatibility data to results if a compatMap is provided and non-empty.
1095
1737
  * Returns the original results array when no compat data is available.
1096
1738
  */
1097
- function applyCompatMap(results, compatMap) {
1739
+ function applyCompatMap(results, compatMap, locationRoot) {
1098
1740
  if (compatMap !== undefined && compatMap.size > 0) {
1099
- return mergeCompatIntoResults(results, compatMap);
1741
+ return mergeCompatIntoResults(results, compatMap, locationRoot);
1100
1742
  }
1101
1743
  return results;
1102
1744
  }
@@ -1133,7 +1775,7 @@ function applyVerboseFilter(results, verbose) {
1133
1775
  const NESTED_PATH_CARRIERS = ['linkedFiles'];
1134
1776
  function calculateSummary(results, startTime, compatMap, verbose, root) {
1135
1777
  const { entries, ...base } = buildBaseSummary(results, startTime);
1136
- const withCompat = applyCompatMap(entries, compatMap);
1778
+ const withCompat = applyCompatMap(entries, compatMap, root);
1137
1779
  return {
1138
1780
  // Stated once, first, and the only absolute path in the document.
1139
1781
  root,
@@ -1236,19 +1878,75 @@ function renderVerboseEvidence(results, root, logger) {
1236
1878
  }
1237
1879
  }
1238
1880
  }
1881
+ /**
1882
+ * The stderr half of a run-level refusal, so the human channel says what the
1883
+ * document says. The document is derived in `buildBaseSummary`; this only
1884
+ * RENDERS it. Both stderr lanes call it first, because "Audit successful: 0
1885
+ * file(s) passed" over `status: error` is the exact disagreement the refusal
1886
+ * exists to end (invariant 6 of `run-integrity.ts`). The run ends on
1887
+ * `FINDINGS`, like every other `status: error` this command publishes.
1888
+ *
1889
+ * @returns Whether a refusal was rendered, so the caller skips its verdict line
1890
+ */
1891
+ function logRunIntegrity(runIssues, logger) {
1892
+ if (runIssues === undefined || runIssues.length === 0)
1893
+ return false;
1894
+ logger.error('Audit is not a verdict: it audited 0 files');
1895
+ for (const issue of runIssues) {
1896
+ for (const line of formatIssueLines(issue, ' '))
1897
+ logger.error(line);
1898
+ }
1899
+ return true;
1900
+ }
1901
+ /**
1902
+ * The stderr line for the settings lane: how many conflicts it found and how
1903
+ * many paths it never compared, across every plugin in the document.
1904
+ *
1905
+ * 🚩 This read `f.compatibility?.settingsConflicts` — a field the renderer had
1906
+ * already moved out into the `settings:` block — so the count was 0 on every
1907
+ * run and the line never printed. It reads the rendered block now, the same
1908
+ * one the YAML carries, and it counts the unchecked half too: a run that
1909
+ * skipped a skill says so without `--debug`.
1910
+ */
1911
+ function logSettingsTotals(files, logger) {
1912
+ let conflicts = 0;
1913
+ let unchecked = 0;
1914
+ // The compat lane's own unchecked paths — a plugin the analyzer could only
1915
+ // partly read is in the document under `compatibility.unchecked`, and this is
1916
+ // the one line that keeps it from being silent on the human channel while the
1917
+ // settings lane says its half. `analyzed: false` has no list: it is the
1918
+ // plugin-wide failure, and the `reason` line already says so.
1919
+ let compatUnchecked = 0;
1920
+ for (const { compatibility, settings } of files) {
1921
+ conflicts += settings?.conflicts.length ?? 0;
1922
+ unchecked += settings?.unchecked?.length ?? 0;
1923
+ if (compatibility !== undefined && 'unchecked' in compatibility)
1924
+ compatUnchecked += compatibility.unchecked.length;
1925
+ }
1926
+ if (conflicts > 0) {
1927
+ logger.error(`⚠ ${conflicts} settings conflict(s) found — see 'settings' section in YAML output`);
1928
+ }
1929
+ if (unchecked > 0) {
1930
+ logger.error(`⚠ ${unchecked} path(s) the settings check could not compare — see 'settings.unchecked' in YAML output`);
1931
+ }
1932
+ if (compatUnchecked > 0) {
1933
+ logger.error(`⚠ ${compatUnchecked} path(s) the compatibility analysis could not read — see 'compatibility.unchecked' in YAML output`);
1934
+ }
1935
+ }
1239
1936
  function handleAuditResults(results, summary, logger, verbose) {
1240
1937
  const { filesWithErrors: errorCount, filesWithWarnings: warningCount, filesPassed: successCount, } = summary.summary;
1241
- // Report settings conflicts (advisory, non-blocking)
1242
- const totalSettingsConflicts = (summary.files ?? []).reduce((sum, f) => {
1243
- return sum + (f.compatibility?.settingsConflicts?.length ?? 0);
1244
- }, 0);
1245
- if (totalSettingsConflicts > 0) {
1246
- logger.error(`\u26a0 ${totalSettingsConflicts} settings conflict(s) found — see 'settings' section in YAML output`);
1247
- }
1248
- // Audit is advisory only — always exit 0 for validation results.
1249
- // Use vat skills validate for gated validation (exit 1 on errors).
1250
- if (errorCount > 0) {
1251
- logger.error(`Audit failed: ${errorCount} file(s) with errors`);
1938
+ logSettingsTotals(summary.files ?? [], logger);
1939
+ // The exit code follows the document's `status`, as it does in every other
1940
+ // command of this CLI: `status: error` — an error-severity finding, or a run
1941
+ // that audited zero files — ends on `FINDINGS`. This used to be the one
1942
+ // command that exited 0 over `status: error` ("advisory"), and an adopter
1943
+ // wiring it into CI read "Audit failed" on stderr beside a green step.
1944
+ const refused = logRunIntegrity(summary.issues, logger);
1945
+ if (refused) {
1946
+ // Rendered above; there is no file verdict to print over zero files.
1947
+ }
1948
+ else if (errorCount > 0) {
1949
+ logger.error(`Audit found ${errorCount} file(s) with errors`);
1252
1950
  logFindingsForStatus(results, 'error', summary.root, logger.error.bind(logger), verbose);
1253
1951
  }
1254
1952
  else if (warningCount > 0) {
@@ -1264,7 +1962,7 @@ function handleAuditResults(results, summary, logger, verbose) {
1264
1962
  : `Audit successful: ${successCount} file(s) passed`);
1265
1963
  }
1266
1964
  renderAuditFooter(results, logger);
1267
- process.exit(0);
1965
+ process.exit(refused || errorCount > 0 ? ExitCode.FINDINGS : ExitCode.OK);
1268
1966
  }
1269
1967
  /**
1270
1968
  * Render the skill-quality checklist footer when audit results contain
@@ -1359,17 +2057,6 @@ function logFindingsForStatus(results, status, root, logFn, verbose) {
1359
2057
  logFn(line);
1360
2058
  }
1361
2059
  }
1362
- /**
1363
- * Check whether a path should be excluded during directory scanning.
1364
- * For directories, checks both bare path and path with trailing slash
1365
- * so patterns like "dist/**" prune the directory itself.
1366
- */
1367
- function isExcludedPath(isMatch, relativePath, isDirectory) {
1368
- if (isDirectory) {
1369
- return isMatch(relativePath) || isMatch(relativePath + '/');
1370
- }
1371
- return isMatch(relativePath);
1372
- }
1373
2060
  /**
1374
2061
  * Does the SKILL.md at `skillMdPath` own its own presence-side crawl, or has an
1375
2062
  * ancestor crawl already covered it?
@@ -1383,39 +2070,27 @@ function ownsOwnCrawl(scanCtx, skillMdPath) {
1383
2070
  return scanCtx.crawledRoot === null || !isWithin(scanCtx.crawledRoot, safePath.resolve(skillMdPath));
1384
2071
  }
1385
2072
  /**
1386
- * Hand the descending context an owner for everything below `owner`, unless one
1387
- * is already claimed. First owner wins, and `null` claims nothing.
1388
- */
1389
- function claimSubtree(scanCtx, owner) {
1390
- if (owner === null || scanCtx.crawledRoot !== null)
1391
- return scanCtx;
1392
- return { ...scanCtx, crawledRoot: safePath.resolve(owner) };
1393
- }
1394
- /**
1395
- * The context this directory's SUBDIRECTORIES are walked with.
1396
- *
1397
- * A `SKILL.md` here means this directory's own crawl reaches every descendant at
1398
- * any depth, so it — not each nested skill in turn — owns the subtree below.
1399
- *
1400
- * Resolved before the entry loop rather than inside it: `SKILL.md` and the
1401
- * subdirectories it governs are siblings in one `readdir`, and their order is
1402
- * the filesystem's to choose, so deciding this mid-loop would make the verdict
1403
- * depend on it.
1404
- *
1405
- * Conditional on the crawl actually RUNNING ({@link crawlOwnsSubtree}), not on
1406
- * the SKILL.md merely existing: a repo-source skill's crawl is skipped
1407
- * entirely, so claiming its subtree would trade a double count for a missed
1408
- * finding — and provenance is not monotone down a tree.
2073
+ * The directories whose presence-side crawl already owns everything beneath
2074
+ * them — see {@link ScanContext.crawledRoot} for why there are two owner kinds
2075
+ * and why the first owner wins.
2076
+ *
2077
+ * Kept as a list of roots rather than threaded down a recursion: the
2078
+ * population arrives flat and top-down (an ancestor's subjects before a
2079
+ * descendant's), so "who owns this path" is the first claimed root that
2080
+ * contains it, and a claim is only ever made where no ancestor holds one.
1409
2081
  */
1410
- async function contextForDescendants(dirPath, entries, scanCtx) {
1411
- if (!entries.some((entry) => entry.isFile() && entry.name === 'SKILL.md'))
1412
- return scanCtx;
1413
- const skillMd = safePath.join(dirPath, 'SKILL.md');
1414
- if (!ownsOwnCrawl(scanCtx, skillMd))
1415
- return scanCtx;
1416
- if (!await crawlOwnsSubtree(skillMd))
1417
- return scanCtx;
1418
- return claimSubtree(scanCtx, dirPath);
2082
+ class SubtreeClaims {
2083
+ roots = [];
2084
+ /** The claimed root containing `absolutePath`, or `null` while nothing does. */
2085
+ owner(absolutePath) {
2086
+ const resolved = safePath.resolve(absolutePath);
2087
+ return this.roots.find((root) => isWithin(root, resolved)) ?? null;
2088
+ }
2089
+ /** Claim everything below `dir`, unless an ancestor already did. First owner wins. */
2090
+ claim(dir) {
2091
+ if (this.owner(dir) === null)
2092
+ this.roots.push(safePath.resolve(dir));
2093
+ }
1419
2094
  }
1420
2095
  /**
1421
2096
  * Handle file entry during directory scan
@@ -1433,25 +2108,41 @@ async function handleFileEntry(entry, fullPath, options, logger, scanCtx) {
1433
2108
  // Config-aware: walk UP to the skill's nearest-ancestor config and apply
1434
2109
  // ONLY that skill's declared packaging rules. Configs do not compose
1435
2110
  // across VAT projects — audit does not merge rules across sibling configs.
1436
- const fullConfig = await resolveSkillPackagingConfig(fullPath);
1437
- const skillConfig = fullConfig === null ? null : stripValidationAllowForDisplay(fullConfig);
2111
+ // Shared with the single-target lane, so one unloadable nested config
2112
+ // cannot abort this whole scan while that lane shrugs it off. The warn-once
2113
+ // ledger lives in `unloadableConfigResults` (module scope, cleared by
2114
+ // `resetAuditCaches`) rather than being threaded here, so BOTH lanes dedupe
2115
+ // against the same state — this one used to, the other minted a fresh set
2116
+ // per skill.
2117
+ const skillConfig = await resolveGoverningConfig(fullPath, logger, locationRoot);
1438
2118
  if (skillConfig !== null) {
1439
2119
  logger.debug(` Using config-aware validation for: ${fullPath}`);
1440
2120
  // Thread the per-scan tracker into packaging validation so gitignore
1441
2121
  // checks in the link-graph walk stay O(1).
1442
2122
  // `projectSkills`: same project-wide test-input rule as the lane above,
1443
2123
  // resolved through the same per-config-root memo.
1444
- const projectSkills = await resolveProjectDeclaredEvalSuites(fullPath);
2124
+ const projectSkills = await resolveProjectDeclaredEvalSuites(fullPath, degradingDiscovery(findProjectRoot(safePath.resolve(fullPath, '..')) ?? locationRoot, logger, locationRoot));
2125
+ // `suiteProbe`: the RUN's, not this entry's — the directory walk reaches
2126
+ // this function once per SKILL.md it finds. See {@link runSuiteProbe}.
2127
+ const suiteProbe = suiteProbeForRun();
2128
+ // No registry is supplied on this lane, so `validateSkillForPackaging`
2129
+ // crawls one per project root through `crawlAndResolveRegistry` — under
2130
+ // audit's ruling, which is the ONLY reason one `chmod 000` sibling no
2131
+ // longer exits this whole run at 2 with zero findings. The root is the
2132
+ // one the validator itself derives (`findProjectRoot(dirname) ?? dirname`),
2133
+ // so the refuse sentence and the finding name the same crawl.
2134
+ const skillDir = safePath.resolve(fullPath, '..');
2135
+ const unreadable = degradingRegistry(findProjectRoot(skillDir) ?? skillDir, logger, locationRoot);
1445
2136
  const sharedCtx = scanCtx.gitTracker === null
1446
- ? { locationRoot, projectSkills }
1447
- : { gitTracker: scanCtx.gitTracker, locationRoot, projectSkills };
2137
+ ? { locationRoot, projectSkills, suiteProbe, unreadable }
2138
+ : { gitTracker: scanCtx.gitTracker, locationRoot, projectSkills, suiteProbe, unreadable };
1448
2139
  const packagingResult = await validateSkillForPackaging(fullPath, skillConfig, 'source', sharedCtx);
1449
2140
  const configAware = packagingResultToValidationResult(fullPath, packagingResult, skillConfig.targets, locationRoot);
1450
2141
  await appendDistributedTreeFindings(configAware, fullPath, locationRoot, ownsOwnCrawl(scanCtx, fullPath));
1451
2142
  return configAware;
1452
2143
  }
1453
- // Wild mode: basic validation
1454
- const validateOptions = { skillPath: fullPath, locationRoot };
2144
+ // Wild mode: basic validation. Severity is applied by `applySeverityFilter`.
2145
+ const validateOptions = { skillPath: fullPath, locationRoot, validation: {} };
1455
2146
  if (options.warnUnreferencedFiles) {
1456
2147
  validateOptions.checkUnreferencedFiles = true;
1457
2148
  }
@@ -1465,79 +2156,15 @@ async function handleFileEntry(entry, fullPath, options, logger, scanCtx) {
1465
2156
  return null;
1466
2157
  }
1467
2158
  /**
1468
- * Handle directory entry during directory scan
1469
- */
1470
- async function handleDirectoryEntry(fullPath, recursive, options, logger, baseDir, scanCtx, nestedConfigLog) {
1471
- const fs = await import('node:fs/promises');
1472
- const { locationRoot } = scanCtx;
1473
- const results = [];
1474
- // Check if directory contains a plugin or marketplace
1475
- const claudePluginDir = safePath.join(fullPath, '.claude-plugin');
1476
- const hasClaudePlugin = await fs.access(claudePluginDir).then(() => true).catch(() => false);
1477
- if (hasClaudePlugin) {
1478
- logger.debug(`Validating resource directory: ${fullPath}`);
1479
- const result = await validate(fullPath, { validatePlugin, locationRoot });
1480
- const inv = await pluginInventoryAt(fullPath);
1481
- appendInventoryParseErrors(result, inv, locationRoot);
1482
- results.push(result);
1483
- }
1484
- // Recurse into subdirectories (both plugin/marketplace dirs and regular dirs).
1485
- // Below a plugin, `validatePlugin` has already crawled the whole subtree for
1486
- // agent-instruction files, so every crawl below must stand down or one file
1487
- // is reported twice. See {@link ScanContext.crawledRoot} — first owner wins,
1488
- // and it never clears on the way down.
1489
- const childCtx = claimSubtree(scanCtx, hasClaudePlugin ? fullPath : null);
1490
- if (recursive) {
1491
- const subResults = await scanDirectory(fullPath, recursive, options, logger, childCtx, baseDir, nestedConfigLog);
1492
- results.push(...subResults);
1493
- }
1494
- return results;
1495
- }
1496
- /**
1497
- * Compile the governing project's `resources.exclude` for a scan.
1498
- *
1499
- * The config is found by walking UP from the scan directory ({@link findProjectRoot}),
1500
- * not by looking in it. Looking only in the scan directory is how a path argument
1501
- * silently voided every exclude the project had declared: `vat audit
1502
- * packages/x/resources/skills/` found no config there, so a package that excludes
1503
- * its deliberately-broken eval fixtures had them audited as production skills the
1504
- * moment anyone named a subdirectory. Commit 8a466b0c fixed the same defect for
1505
- * `vat resources validate|scan` and `vat rag index`; this is the lane it missed.
1506
- * The rule is the same one that commit established: a path argument says WHICH
1507
- * tree to audit, and `exclude` applies either way.
1508
- *
1509
- * With one deliberate exception, mirroring the gitignore rule in
1510
- * {@link resolveScanContext}: when the operator points the scan AT an excluded
1511
- * tree, their explicit intent wins and the excludes are dropped for that run.
1512
- * Otherwise naming an excluded directory would report `filesScanned: 0,
1513
- * status: success` — a green run that scanned nothing.
2159
+ * Validate a directory the population found holding `.claude-plugin/` — a
2160
+ * plugin or a marketplace, whichever the unified validator decides.
1514
2161
  */
1515
- function resolveProjectExcludes(scanDir, logger) {
1516
- const projectRoot = findProjectRoot(scanDir);
1517
- if (projectRoot === null)
1518
- return null;
1519
- let patterns;
1520
- try {
1521
- patterns = loadConfig(projectRoot)?.resources?.exclude ?? [];
1522
- }
1523
- catch (err) {
1524
- // Audit is a bulk linter over trees it does not own; a broken governing
1525
- // config must not abort the scan. Say so rather than dropping it silently.
1526
- logger.debug(`Ignoring unreadable config at ${projectRoot}: ${String(err)}`);
1527
- return null;
1528
- }
1529
- if (patterns.length === 0)
1530
- return null;
1531
- // dot:true so excludes like `**/.cache/*` match through dotfile dirs;
1532
- // without it the exclude silently never fires.
1533
- const isMatch = picomatch([...patterns], { dot: true });
1534
- const scanRelToProject = safePath.relative(projectRoot, safePath.resolve(scanDir)).replaceAll('\\', '/');
1535
- if (scanRelToProject !== '' && isExcludedPath(isMatch, scanRelToProject, true)) {
1536
- logger.debug(`Scan root ${scanRelToProject} is excluded by the config at ${projectRoot}; ` +
1537
- 'auditing it anyway because it was named explicitly');
1538
- return null;
1539
- }
1540
- return { isMatch, base: projectRoot, label: 'excluded by config' };
2162
+ async function validatePluginDirectory(dir, logger, locationRoot) {
2163
+ logger.debug(`Validating resource directory: ${dir}`);
2164
+ const result = await validate(dir, { validatePlugin, locationRoot });
2165
+ const inv = await pluginInventoryAt(dir);
2166
+ appendInventoryParseErrors(result, inv, locationRoot);
2167
+ return result;
1541
2168
  }
1542
2169
  /**
1543
2170
  * Cache of (projectRoot → registry for the *inventory* link walk).
@@ -1590,7 +2217,12 @@ async function getOrCreateInventoryRegistry(projectRoot) {
1590
2217
  */
1591
2218
  async function pluginInventoryAt(dir) {
1592
2219
  const projectRoot = findProjectRoot(dir);
1593
- return extractClaudePluginInventory(dir, projectRoot === null ? undefined : () => getOrCreateInventoryRegistry(projectRoot), gitTrackerForProjectRoot);
2220
+ return extractClaudePluginInventory(dir, {
2221
+ ...(projectRoot === null
2222
+ ? {}
2223
+ : { sharedRegistry: () => getOrCreateInventoryRegistry(projectRoot) }),
2224
+ gitTrackerSource: gitTrackerForProjectRoot,
2225
+ });
1594
2226
  }
1595
2227
  /**
1596
2228
  * Reset all module-level audit caches. Must be called at the start of every
@@ -1602,6 +2234,8 @@ export function resetAuditCaches() {
1602
2234
  resetGitTrackerCache();
1603
2235
  resetPackagingRegistryCache();
1604
2236
  inventoryRegistryCache.clear();
2237
+ unloadableConfigResults.clear();
2238
+ runSuiteProbe = undefined;
1605
2239
  resetProjectRootCaches();
1606
2240
  resetLoadedConfigCache();
1607
2241
  resetSkillDiscoveryCache();
@@ -1631,107 +2265,150 @@ async function resolveScanContext(dirPath, locationRoot, logger) {
1631
2265
  return { gitRoot: detectedRoot, gitTracker: tracker, locationRoot, projectExcludes, crawledRoot: null };
1632
2266
  }
1633
2267
  /**
1634
- * Build the gitignore exclusion map for a set of entry paths.
1635
- * Returns null when gitignore filtering is not applicable (no git root,
1636
- * --include-artifacts, or outside a git repo).
2268
+ * The one finding a path the walk could not read produces — a directory it could
2269
+ * not enter, or a file it could not open.
2270
+ *
2271
+ * A synthetic result rather than an issue appended to some neighbour: nothing
2272
+ * about the unreadable path was validated, so it has no host result to hang
2273
+ * off, and attaching it to a sibling would attribute the condition to a tree that
2274
+ * scanned fine. Typed `unknown` for the same reason — the whole point is that its
2275
+ * contents were never seen, so any more specific type would be a claim the scan
2276
+ * cannot support.
2277
+ *
2278
+ * The OS message is carried through verbatim (it names the errno and the syscall,
2279
+ * which is what distinguishes a permissions problem from a vanished mount), but it
2280
+ * is no longer the ONLY thing the run says — the code, the location and the fix
2281
+ * come from the registry.
2282
+ *
2283
+ * This result is a `files[]` row but NOT a scanned file: `countFilesByStatus`
2284
+ * recognises it ({@link isUnreadablePathResult}) and reports it under
2285
+ * `summary.pathsUnreadable`, outside `filesScanned` and the status counts. A
2286
+ * root the OS refused therefore audits zero files and ends on the zero-files
2287
+ * refusal, instead of `filesScanned: 1` over a path nothing read.
1637
2288
  */
1638
- function buildGitIgnoreMap(entryPaths, gitTracker, includeArtifacts) {
1639
- if (gitTracker === null || includeArtifacts) {
1640
- return null;
1641
- }
1642
- const map = new Map();
1643
- for (const entryPath of entryPaths) {
1644
- map.set(entryPath, gitTracker.isIgnoredByActiveSet(entryPath));
1645
- }
1646
- return map;
2289
+ function unreadablePathResult(dirPath, error, locationRoot) {
2290
+ // `issueLocation` is `path.relative`, so it answers `''` when the subject IS the
2291
+ // anchor — and for this finding that is not exotic, it is `vat audit <path>`
2292
+ // where the named path is the unreadable one. `''` reached the report as
2293
+ // `location: ""` and rendered the detail as a bare `": EACCES …"`. `.` is that
2294
+ // same path spelled as something a reader can act on. The identical fix, for the
2295
+ // identical reason, already exists on the distributed-tree finding — see
2296
+ // `anchoredTreeLocation` in ./audit/distributed-tree.ts.
2297
+ const location = issueLocation(dirPath, locationRoot) || '.';
2298
+ // The OS names the path it refused absolutely; the report speaks scan-root
2299
+ // coordinates everywhere else, and an absolute path here is the build host's
2300
+ // `$HOME` in every CI log.
2301
+ const reason = withRelativePath(error instanceof Error ? error.message : String(error), dirPath, locationRoot);
2302
+ const issues = [
2303
+ materializeIssue('SCAN_PATH_UNREADABLE', {
2304
+ location,
2305
+ detail: `${location}: ${reason}`,
2306
+ }),
2307
+ ];
2308
+ const issueCounts = countBySeverity(issues);
2309
+ return {
2310
+ path: dirPath,
2311
+ type: 'unknown',
2312
+ status: calculateValidationStatus(issues),
2313
+ summary: formatCountsSummary(issueCounts),
2314
+ issues,
2315
+ issueCounts,
2316
+ };
1647
2317
  }
1648
2318
  /**
1649
- * Check whether a directory entry should be skipped during scanning.
1650
- * Returns a reason string for debug logging, or null if the entry should be kept.
2319
+ * Validate ONE subject the population found, degrading to a finding if the
2320
+ * filesystem refuses it.
2321
+ *
2322
+ * Guarded per SUBJECT, not just per listing: an unreadable FILE (a root-owned
2323
+ * `SKILL.md`, a quarantined bundle) used to throw out of `validateSkill` and
2324
+ * abort the whole run with `status: error` and zero findings. Under
2325
+ * `~/.claude/plugins` — the flagship `vat audit --user` target, populated by sudo
2326
+ * installs and macOS quarantine — a root-owned FILE is at least as likely as a
2327
+ * root-owned directory.
2328
+ *
2329
+ * Ownership is decided here and only here, in population order: a plugin
2330
+ * directory claims its subtree unconditionally; a skill claims its own only
2331
+ * when nothing above it already has AND its crawl actually runs
2332
+ * ({@link crawlOwnsSubtree}) — a repo-source skill's crawl is skipped, so
2333
+ * claiming its subtree would trade a double count for a missed finding.
1651
2334
  */
1652
- function getSkipReason(entry, fullPath, gitIgnoredMap, excludeMatchers) {
1653
- // Always skip .git directory (not reported by git check-ignore)
1654
- if (entry.isDirectory() && entry.name === '.git') {
1655
- return '.git';
1656
- }
1657
- // Skip gitignored paths
1658
- if (gitIgnoredMap !== null && gitIgnoredMap.get(fullPath) === true) {
1659
- return `gitignored: ${entry.name}`;
1660
- }
1661
- // Each matcher answers against ITS OWN base — see {@link ExcludeMatcher}.
1662
- for (const matcher of excludeMatchers) {
1663
- const relativePath = safePath.relative(matcher.base, fullPath).replaceAll('\\', '/');
1664
- if (isExcludedPath(matcher.isMatch, relativePath, entry.isDirectory())) {
1665
- return `${matcher.label}: ${relativePath}`;
2335
+ async function validateScanSubject(subject, options, logger, scanCtx, claims) {
2336
+ const { locationRoot } = scanCtx;
2337
+ try {
2338
+ if (subject.kind === 'plugin') {
2339
+ const result = await validatePluginDirectory(subject.dir, logger, locationRoot);
2340
+ claims.claim(subject.dir);
2341
+ return [result];
2342
+ }
2343
+ const subjectCtx = { ...scanCtx, crawledRoot: claims.owner(subject.path) };
2344
+ const result = await handleFileEntry({ name: basename(subject.path) }, subject.path, options, logger, subjectCtx);
2345
+ if (subject.kind === 'skill' && ownsOwnCrawl(subjectCtx, subject.path) && await crawlOwnsSubtree(subject.path)) {
2346
+ claims.claim(subject.dir);
1666
2347
  }
2348
+ return result === null ? [] : [result];
2349
+ }
2350
+ catch (error) {
2351
+ // ONLY filesystem-access errors degrade. Anything else is rethrown, because
2352
+ // turning a genuine VAT bug into a `warning` about the file it happened on is
2353
+ // the same "detector silently disables itself" shape this guard exists to
2354
+ // prevent — it would make the tool quietest exactly when it is most wrong.
2355
+ if (!isFilesystemAccessError(error))
2356
+ throw error;
2357
+ const path = subject.kind === 'plugin' ? subject.dir : subject.path;
2358
+ logger.debug(`Unreadable entry: ${path}`);
2359
+ return [unreadablePathResult(path, error, locationRoot)];
1667
2360
  }
1668
- return null;
1669
2361
  }
1670
- async function scanDirectory(dirPath, recursive, options, logger, scanCtx, baseDir, nestedConfigLog) {
1671
- const fs = await import('node:fs/promises');
1672
- const results = [];
1673
- const userExcludes = options.exclude ?? [];
1674
- const resolvedBaseDir = baseDir ?? dirPath;
1675
- // The git context (root + tracker) and the run's anchor base are built once by
1676
- // the caller; every recursion reuses them unchanged.
1677
- const resolvedScanCtx = scanCtx;
1678
- const resolvedNestedLog = nestedConfigLog ?? new Set();
1679
- // Top-down pre-warm of findProjectRoot's walk-up cache (spec §8). The first
1680
- // call here drives a single ancestor walk; every subsequent recursion is a
1681
- // Layer-1 hit chain. By the time we reach leaf SKILL.md files,
1682
- // resolveSkillPackagingConfig's per-skill walk-up is all cache hits.
2362
+ /**
2363
+ * The remedy a refused listing names: the operator's own knob for dropping a
2364
+ * directory from the audit on purpose.
2365
+ */
2366
+ function scanRefusalRemedy() {
2367
+ return 'To leave it out deliberately, exclude it with --exclude <pattern>.';
2368
+ }
2369
+ /**
2370
+ * Audit a directory: every plugin, skill and registry file the `crawl` lane
2371
+ * finds beneath it, plus one finding per directory the lane could not list.
2372
+ *
2373
+ * The enumeration is `enumerateAuditPopulation`'s — this function owns no
2374
+ * walk of its own. What it decides is what the audit MAKES of the population:
2375
+ * validation per subject, subtree ownership across subjects (see
2376
+ * {@link validateScanSubject}), the nested-config breadcrumb, and the refusal
2377
+ * findings. A refused directory is a finding, not a thrown error: `vat audit`
2378
+ * is a bulk linter over trees it does not own, and one root-owned directory
2379
+ * under `~/.claude/plugins` used to abort the flagship `vat audit --user` run
2380
+ * outright, losing every finding already collected. The scope of the loss is
2381
+ * the subtree that refused; every readable sibling is still validated.
2382
+ */
2383
+ async function scanDirectory(dirPath, recursive, options, logger, scanCtx) {
2384
+ // Warm findProjectRoot's walk-up cache from the scan root: every per-skill
2385
+ // walk-up below lands on a cached ancestor instead of re-walking to `/`.
1683
2386
  findProjectRoot(dirPath);
1684
- // Emit a one-time info breadcrumb when we encounter a nested
1685
- // vibe-agent-toolkit.config.yaml that is NOT at the scan root. Configs do
1686
- // not compose across VAT projects; the message reminds operators that
1687
- // only per-skill packaging rules from this config still apply.
1688
- const nestedConfigPath = safePath.join(dirPath, VAT_CONFIG_FILENAME);
1689
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- dirPath is a controlled scan path
1690
- if (dirPath !== resolvedBaseDir && fsExistsSync(nestedConfigPath) && !resolvedNestedLog.has(nestedConfigPath)) {
1691
- resolvedNestedLog.add(nestedConfigPath);
1692
- const rel = safePath.relative(resolvedBaseDir, nestedConfigPath).replaceAll('\\', '/');
1693
- logger.info(`Nested vibe-agent-toolkit.config.yaml detected at ${rel} — configs do not compose across VAT projects. Per-skill packaging rules from this config still apply to skills declared in it.`);
1694
- }
1695
- // Compile picomatch for user-supplied --exclude patterns.
1696
- // dot:true so excludes like `**/private/*` match through dotfile dirs
1697
- // (`.claude/.../private/x`); without it the exclude silently never fires.
1698
- // These are relative to the scan base; the config's excludes carry their own
1699
- // (project-root) base on the matcher — see {@link ExcludeMatcher}.
1700
- const excludeMatchers = [];
1701
- if (userExcludes.length > 0) {
1702
- excludeMatchers.push({
1703
- isMatch: picomatch(userExcludes, { dot: true }),
1704
- base: resolvedBaseDir,
1705
- label: 'excluded',
1706
- });
1707
- }
1708
- if (resolvedScanCtx.projectExcludes !== null) {
1709
- excludeMatchers.push(resolvedScanCtx.projectExcludes);
1710
- }
1711
- const entries = await fs.readdir(dirPath, { withFileTypes: true });
1712
- const descendCtx = await contextForDescendants(dirPath, entries, resolvedScanCtx);
1713
- // Build full paths and batch-check gitignore status
1714
- const entryPaths = entries.map(e => safePath.join(dirPath, e.name));
1715
- const gitIgnoredMap = buildGitIgnoreMap(entryPaths, resolvedScanCtx.gitTracker, options.includeArtifacts ?? false);
1716
- for (const entry of entries) {
1717
- const fullPath = safePath.join(dirPath, entry.name);
1718
- const skipReason = getSkipReason(entry, fullPath, gitIgnoredMap, excludeMatchers);
1719
- if (skipReason !== null) {
1720
- logger.debug(`Excluding path: ${skipReason}`);
1721
- continue;
1722
- }
1723
- if (entry.isFile()) {
1724
- const result = await handleFileEntry(entry, fullPath, options, logger, resolvedScanCtx);
1725
- if (result !== null) {
1726
- results.push(result);
1727
- }
1728
- }
1729
- else if (entry.isDirectory()) {
1730
- // `descendCtx`, not `resolvedScanCtx`: this level's own skill (if any)
1731
- // owns everything below, including this subdirectory.
1732
- const dirResults = await handleDirectoryEntry(fullPath, recursive, options, logger, resolvedBaseDir, descendCtx, resolvedNestedLog);
1733
- results.push(...dirResults);
1734
- }
2387
+ const population = await enumerateAuditPopulation({
2388
+ scanDir: dirPath,
2389
+ recursive,
2390
+ // No tracker means no git root, or a scan root that is itself gitignored
2391
+ // (the operator's explicit intent wins); either way ignored files are in.
2392
+ respectGitignore: scanCtx.gitTracker !== null && !(options.includeArtifacts ?? false),
2393
+ userExcludes: options.exclude ?? [],
2394
+ projectExcludes: scanCtx.projectExcludes,
2395
+ });
2396
+ // A one-time breadcrumb per nested `vibe-agent-toolkit.config.yaml` that is
2397
+ // NOT at the scan root. Configs do not compose across VAT projects; the
2398
+ // message reminds operators that only per-skill packaging rules from this
2399
+ // config still apply.
2400
+ for (const nestedConfigPath of population.nestedConfigs) {
2401
+ const rel = toForwardSlash(safePath.relative(dirPath, nestedConfigPath));
2402
+ logger.info(`Nested ${VAT_CONFIG_FILENAME} detected at ${rel} — configs do not compose across VAT projects. Per-skill packaging rules from this config still apply to skills declared in it.`);
2403
+ }
2404
+ const results = population.refusals.map((refusal) => {
2405
+ logger.debug(`Unreadable directory: ${refusal.directory}`);
2406
+ const err = new DirectoryListingRefusedError(refusal, { root: dirPath, remedy: scanRefusalRemedy() });
2407
+ return unreadablePathResult(refusal.directory, err, scanCtx.locationRoot);
2408
+ });
2409
+ const claims = new SubtreeClaims();
2410
+ for (const subject of population.subjects) {
2411
+ results.push(...await validateScanSubject(subject, options, logger, scanCtx, claims));
1735
2412
  }
1736
2413
  return results;
1737
2414
  }
@@ -1760,13 +2437,34 @@ function calculateOverallStatus(results) {
1760
2437
  function countResultsWithFindings(results) {
1761
2438
  return results.filter((r) => r.issues.length > 0).length;
1762
2439
  }
1763
- /** Count files by their own status. */
2440
+ /**
2441
+ * Whether a `files[]` row is a refused path rather than a scanned file: the
2442
+ * synthetic result {@link unreadablePathResult} builds, carrying nothing but
2443
+ * `SCAN_PATH_UNREADABLE`. Keyed on the code, not on `type: 'unknown'` alone —
2444
+ * an unrecognised file is also `unknown`, and it WAS examined.
2445
+ */
2446
+ function isUnreadablePathResult(result) {
2447
+ return result.type === 'unknown'
2448
+ && result.issues.length > 0
2449
+ && result.issues.every((issue) => issue.code === 'SCAN_PATH_UNREADABLE');
2450
+ }
2451
+ /**
2452
+ * Count files by their own status.
2453
+ *
2454
+ * A refused path is NOT a scanned file. It used to be counted as one, which
2455
+ * made the zero-files refusal unreachable for exactly the tree it exists for:
2456
+ * `chmod 000` on the whole root gave `filesScanned: 1, filesPassed: 0`,
2457
+ * `status: warning`, exit 0 — nothing read, gate green. Kept out of every
2458
+ * status count too, or `filesWithWarnings` would exceed `filesScanned`.
2459
+ */
1764
2460
  function countFilesByStatus(results) {
2461
+ const scanned = results.filter((r) => !isUnreadablePathResult(r));
1765
2462
  return {
1766
- filesScanned: results.length,
1767
- filesPassed: results.filter((r) => r.status === 'success').length,
1768
- filesWithWarnings: results.filter((r) => r.status === 'warning').length,
1769
- filesWithErrors: results.filter((r) => r.status === 'error').length,
2463
+ filesScanned: scanned.length,
2464
+ filesPassed: scanned.filter((r) => r.status === 'success').length,
2465
+ filesWithWarnings: scanned.filter((r) => r.status === 'warning').length,
2466
+ filesWithErrors: scanned.filter((r) => r.status === 'error').length,
2467
+ pathsUnreadable: results.length - scanned.length,
1770
2468
  };
1771
2469
  }
1772
2470
  /**
@@ -1794,6 +2492,24 @@ function countFilesByStatus(results) {
1794
2492
  * also present in its owner's `issues`, so the nested list is a VIEW of records
1795
2493
  * already counted, not extra records. `audit-report-coherence.integration.test.ts`
1796
2494
  * pins that subset relation, because counting once is only correct while it holds.
2495
+ *
2496
+ * 🚨 **Zero files audited is an ERROR, not "an empty tree audits cleanly".**
2497
+ * `calculateOverallStatus([])` is `success`, so an existing directory holding
2498
+ * nothing auditable — moved plugins, a wrong subdirectory, an excluded tree, a
2499
+ * tree of files no lane recognises — published `status: success` beside
2500
+ * `filesScanned: 0`, and this command's own documentation tells CI to gate on
2501
+ * that `status`. Derived HERE, in the one builder both the directory lane
2502
+ * (`calculateSummary`) and the `--user` lane (`calculateHierarchicalSummary`)
2503
+ * pass through, so neither can publish a clean status over a zero denominator.
2504
+ * Through the shared mechanism in `run-integrity.ts`: one non-overridable
2505
+ * `RESOURCE_CHECK_BROKEN` at `error`, published under a top-level `issues` —
2506
+ * the claim is about the RUN, so it is not a `files[]` row and does not count
2507
+ * toward `filesScanned` — and counted in the header `issueCounts`, so the
2508
+ * identity becomes `issueCounts === Σ files[].issueCounts + Σ issues`.
2509
+ *
2510
+ * The exit code is NOT touched by this: `status` describes the findings and the
2511
+ * exit code describes whether the run completed, and this run completed. See
2512
+ * {@link createAuditCommand}.
1797
2513
  */
1798
2514
  function buildBaseSummary(results, startTime) {
1799
2515
  const issueCounts = { errors: 0, warnings: 0, info: 0 };
@@ -1804,11 +2520,24 @@ function buildBaseSummary(results, startTime) {
1804
2520
  issueCounts.info += counts.info;
1805
2521
  return { ...result, issueCounts: counts };
1806
2522
  });
2523
+ const summary = countFilesByStatus(entries);
2524
+ // The denominator is files READ: a tree whose every path was refused audited
2525
+ // nothing, and says so through the same refusal as an empty tree.
2526
+ const runIssues = nothingCheckedFinding(summary.filesScanned, entries.flatMap((entry) => entry.issues), () => 'The audit ran over 0 files, so this report is not a verdict: a tree with nothing'
2527
+ + ' to audit produces the same counts as a clean one. The path resolved to a tree'
2528
+ + ' holding no auditable file — usually a wrong subdirectory, plugins or skills that'
2529
+ + ' have moved, an excluded tree, files no audit lane recognises, or `--no-recursive`'
2530
+ + ' over a tree whose resources sit deeper. Point the command at the plugin,'
2531
+ + ' marketplace, registry or SKILL.md tree it should read.');
1807
2532
  return {
1808
2533
  entries,
1809
- status: calculateOverallStatus(entries),
1810
- summary: countFilesByStatus(entries),
1811
- issueCounts,
2534
+ // Every run-integrity finding is `error` by construction (invariant 2 of
2535
+ // `run-integrity.ts`), so a non-empty refusal IS the status; otherwise the
2536
+ // files decide it, as before.
2537
+ status: runIssues.length > 0 ? 'error' : calculateOverallStatus(entries),
2538
+ summary,
2539
+ issueCounts: sumSeverityCounts([issueCounts, countBySeverity(runIssues)]),
2540
+ ...(runIssues.length === 0 ? {} : { issues: [...runIssues] }),
1812
2541
  duration: `${Date.now() - startTime}ms`,
1813
2542
  };
1814
2543
  }
@@ -1817,7 +2546,7 @@ function buildBaseSummary(results, startTime) {
1817
2546
  */
1818
2547
  function calculateHierarchicalSummary(results, hierarchical, startTime, compatMap, verbose, root) {
1819
2548
  const { entries, ...base } = buildBaseSummary(results, startTime);
1820
- const withCompat = applyCompatMap(entries, compatMap);
2549
+ const withCompat = applyCompatMap(entries, compatMap, root);
1821
2550
  return {
1822
2551
  root,
1823
2552
  ...base,
@@ -1835,13 +2564,16 @@ function calculateHierarchicalSummary(results, hierarchical, startTime, compatMa
1835
2564
  /**
1836
2565
  * Log hierarchical summary to stderr
1837
2566
  */
1838
- function logHierarchicalSummary(results, logger) {
2567
+ function logHierarchicalSummary(results, runIssues, logger) {
1839
2568
  const status = calculateOverallStatus(results);
1840
2569
  const skillsWithIssues = countResultsWithFindings(results);
1841
2570
  const totalSkills = results.length;
1842
- // Audit is advisory only — always exit 0 for validation results.
1843
- // Use vat skills validate for gated validation (exit 1 on errors).
1844
- if (status === 'error') {
2571
+ // Same rule as the per-file lane: the exit code follows `status`.
2572
+ const refused = logRunIntegrity(runIssues, logger);
2573
+ if (refused) {
2574
+ // Rendered above; there is no skill verdict to print over zero skills.
2575
+ }
2576
+ else if (status === 'error') {
1845
2577
  const errorCount = results.filter((r) => r.status === 'error').length;
1846
2578
  logger.error(`Audit found ${errorCount} skill(s) with errors (${totalSkills} scanned, ${skillsWithIssues} with issues)`);
1847
2579
  }
@@ -1858,6 +2590,6 @@ function logHierarchicalSummary(results, logger) {
1858
2590
  logger.info(`Audit successful: ${totalSkills} skill(s) passed`);
1859
2591
  }
1860
2592
  renderAuditFooter(results, logger);
1861
- process.exit(0);
2593
+ process.exit(refused || status === 'error' ? ExitCode.FINDINGS : ExitCode.OK);
1862
2594
  }
1863
2595
  //# sourceMappingURL=audit.js.map