@vibe-agent-toolkit/cli 0.2.0-rc.1 → 0.2.0-rc.11

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 +97 -28
  3. package/dist/bin/vat.js +97 -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 +165 -0
  41. package/dist/commands/ard/emit.d.ts.map +1 -0
  42. package/dist/commands/ard/emit.js +338 -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 +84 -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 +1150 -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 +372 -0
  97. package/dist/commands/claude/context.d.ts.map +1 -0
  98. package/dist/commands/claude/context.js +1115 -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 +116 -0
  226. package/dist/commands/okf/index.js.map +1 -0
  227. package/dist/commands/okf/validate.d.ts +195 -0
  228. package/dist/commands/okf/validate.d.ts.map +1 -0
  229. package/dist/commands/okf/validate.js +199 -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 +167 -0
  259. package/dist/commands/resources/check-progress.d.ts.map +1 -0
  260. package/dist/commands/resources/check-progress.js +250 -0
  261. package/dist/commands/resources/check-progress.js.map +1 -0
  262. package/dist/commands/resources/check-supervisor.d.ts +479 -0
  263. package/dist/commands/resources/check-supervisor.d.ts.map +1 -0
  264. package/dist/commands/resources/check-supervisor.js +587 -0
  265. package/dist/commands/resources/check-supervisor.js.map +1 -0
  266. package/dist/commands/resources/check.d.ts +620 -0
  267. package/dist/commands/resources/check.d.ts.map +1 -0
  268. package/dist/commands/resources/check.js +1452 -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 +430 -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 +100 -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 +8 -6
  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 +143 -20
  374. package/dist/utils/command-error.d.ts.map +1 -1
  375. package/dist/utils/command-error.js +179 -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 +72 -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 +134 -2
  444. package/dist/utils/resource-loader.d.ts.map +1 -1
  445. package/dist/utils/resource-loader.js +290 -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 +143 -39
  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 +36 -27
  497. package/schemas/ard-emit.json +152 -0
  498. package/schemas/okf-validate.json +170 -0
  499. package/schemas/resources-check.json +257 -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.
691
+ 2 - The audit could not run at all: the path does not exist, is a
692
+ directory the OS will not list, or is a file no audit lane
693
+ 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,10 +1112,78 @@ 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
+ * 🚨 **And a root DIRECTORY the OS refuses to list.** It used to be left to
1143
+ * the scan, which degraded it to `SCAN_PATH_UNREADABLE` and ended on the
1144
+ * zero-files refusal at exit 1 — while `vat resources validate`, `check`,
1145
+ * `query` and `scan` all end the same argument at exit 2. One outcome, two
1146
+ * codes. Nothing under an unlistable root can be examined, so the audit could
1147
+ * not do its job: that is the INVOCATION's ending, the same as a missing path.
1148
+ *
1149
+ * ONLY the root. A sub-path inside a readable tree is never the invocation's
1150
+ * fault and stays a finding. Pinned by the `audit → error` rows of
1151
+ * `exit-codes.system.test.ts` and the unreadable-root row of
1152
+ * `exit-code-matrix.system.test.ts`.
1153
+ */
1154
+ async function unusableRootReason(scanPath) {
1155
+ let isDirectory;
1156
+ try {
1157
+ isDirectory = fs.lstatSync(scanPath).isDirectory();
1158
+ }
1159
+ catch (error) {
1160
+ if (isPathAbsentError(error))
1161
+ return `Path does not exist: ${scanPath}`;
1162
+ return undefined;
1163
+ }
1164
+ if (isDirectory)
1165
+ return unlistableRootReason(scanPath);
1166
+ if (detectFormat(scanPath) === RESOURCE_TYPE_AGENT_SKILL)
1167
+ return undefined;
1168
+ if ((await detectResourceFormat(scanPath)).type !== 'unknown')
1169
+ return undefined;
1170
+ return `Path is not a resource this command can audit (a SKILL.md, a plugin or marketplace directory, or a Claude registry file): ${scanPath}`;
1171
+ }
1172
+ /**
1173
+ * Why a root DIRECTORY cannot be audited, or `undefined` when it can be listed.
1174
+ *
1175
+ * @param scanPath - The root the operator named, known to be a directory
1176
+ * @returns The refusal, or undefined
1177
+ */
1178
+ function unlistableRootReason(scanPath) {
1179
+ try {
1180
+ // A probe, not a listing: the crawl owns enumeration, this only asks whether it can start.
1181
+ fs.accessSync(scanPath, fs.constants.R_OK | fs.constants.X_OK);
1182
+ return undefined;
1183
+ }
1184
+ catch (error) {
1185
+ const code = error.code ?? 'unknown error';
1186
+ return `Path cannot be read (${code}): ${scanPath}`;
737
1187
  }
738
1188
  }
739
1189
  /**
@@ -788,7 +1238,7 @@ async function runInventoryDetectors(scanPath, type, locationRoot, prebuiltInv)
788
1238
  ];
789
1239
  }
790
1240
  if (type === 'marketplace') {
791
- const inv = prebuiltInv?.kind === 'marketplace' ? prebuiltInv : await extractClaudeMarketplaceInventory(scanPath);
1241
+ const inv = prebuiltInv?.kind === 'marketplace' ? prebuiltInv : await extractClaudeMarketplaceInventory(scanPath, { gitTrackerSource: gitTrackerForProjectRoot });
792
1242
  return detectMarketplacePluginSourceMissing(inv, locationRoot);
793
1243
  }
794
1244
  return [];
@@ -838,22 +1288,50 @@ async function appendPluginInventoryToSurfaceResults(surfaceResults, scanPath, l
838
1288
  }
839
1289
  /**
840
1290
  * 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.
1291
+ * `result`, skipping the entries another lane already files under their own
1292
+ * code:
1293
+ *
1294
+ * - the plugin.json manifest entry, covered by `validatePlugin`;
1295
+ * - every `SKILL.md` entry. The skill extractor records a SKILL.md it could
1296
+ * not open, could not parse, or could not link-walk on the plugin's
1297
+ * `parseErrors[]` — and each of those is the SKILL's finding, filed on the
1298
+ * skill by its own validation (`SCAN_PATH_UNREADABLE`,
1299
+ * `SKILL_MISSING_FRONTMATTER`) in both the plugin-target lane
1300
+ * (`validatePluginSkillsViaInventory`, over the same inventory) and the
1301
+ * directory lane (the walk reaches every SKILL.md beneath the plugin).
1302
+ * Re-filing it here was a second copy under a code that names a file it is
1303
+ * not (plugin.json) and a format it is not (JSON), at error severity beside
1304
+ * the skill lane's warning, and carrying the build host's absolute path.
1305
+ *
1306
+ * What remains — hooks/hooks.json, .mcp.json, a directory the crawl could not
1307
+ * list — is appended with error severity when the SUBJECT is defective, and
1308
+ * as `SCAN_PATH_UNREADABLE` (warning) when the row is marked `unreadable`
1309
+ * (the OS refused the path); the message is spelled scan-root-relative
1310
+ * wherever it names the file.
844
1311
  */
845
1312
  function appendInventoryParseErrors(result, inv, locationRoot) {
846
1313
  const pluginJsonSuffix = safePath.join('.claude-plugin', 'plugin.json');
1314
+ // One refused path, one refusal: the distributed-tree detector crawls the
1315
+ // same directories and files the ones it cannot list on this same result.
1316
+ const refusalsFiled = new Set(result.issues.filter((issue) => issue.code === 'SCAN_PATH_UNREADABLE').map((issue) => issue.location));
847
1317
  const parseIssues = [];
848
1318
  for (const err of inv.parseErrors) {
849
- if (err.path.endsWith(pluginJsonSuffix))
1319
+ if (err.path.endsWith(pluginJsonSuffix) || basename(err.path) === 'SKILL.md')
850
1320
  continue;
851
- parseIssues.push({
852
- severity: 'error',
853
- code: 'PLUGIN_INVALID_JSON',
854
- message: err.message,
855
- location: issueLocation(err.path, locationRoot),
856
- });
1321
+ const message = withRelativePath(err.message, err.path, locationRoot);
1322
+ const location = issueLocation(err.path, locationRoot);
1323
+ if (err.unreadable !== true) {
1324
+ parseIssues.push({ severity: 'error', code: 'PLUGIN_INVALID_JSON', message, location });
1325
+ continue;
1326
+ }
1327
+ // A path the OS refused was not examined; that is the run degrading, not
1328
+ // a defect of the plugin, and it is filed at the severity every other lane
1329
+ // gives a refusal. Under `PLUGIN_INVALID_JSON` (error) an `EACCES` on one
1330
+ // skill directory failed the whole audit over a mode bit.
1331
+ if (refusalsFiled.has(location))
1332
+ continue;
1333
+ refusalsFiled.add(location);
1334
+ parseIssues.push(materializeIssue('SCAN_PATH_UNREADABLE', { location, detail: `${location}: ${message}` }));
857
1335
  }
858
1336
  // Status was hand-set to 'error' here while `issueCounts` and `summary` kept
859
1337
  // describing the pre-append list. One appender derives all three.
@@ -869,7 +1347,119 @@ function appendInventoryParseErrors(result, inv, locationRoot) {
869
1347
  * location reads, or a run spanning several configs emits one document in
870
1348
  * several coordinate systems. Recursion passes it through unchanged.
871
1349
  */
1350
+ /**
1351
+ * Validate ONE audit subject, degrading if the filesystem refuses it.
1352
+ *
1353
+ * THE boundary guard, and deliberately the only one of its kind. The first
1354
+ * version of this fix guarded the directory walk alone, which left every other
1355
+ * way into a validator unprotected: a `SKILL.md` named directly on the command
1356
+ * line, a plugin directory, a marketplace, each surface of a multi-surface tree.
1357
+ * All of them still ended the whole run with `status: error`, exit 2 and zero
1358
+ * findings — the exact symptom of the unreadable-directory defect, in the lanes
1359
+ * nobody had checked.
1360
+ *
1361
+ * Guarding the seven dispatch sites individually would have been the same mistake
1362
+ * a seventh time. This function is what every one of them is reached through, so
1363
+ * it is where "the environment refused this subject" becomes a finding instead of
1364
+ * an abort. It is also the recursion point — `recurseIntoMarketplacePlugins` and
1365
+ * the `--user` target loop both re-enter here — so an unreadable plugin costs its
1366
+ * own subject and nothing else.
1367
+ *
1368
+ * The walk keeps its own per-entry guard: this one would answer at whole-subject
1369
+ * granularity, discarding findings already collected from readable siblings, and
1370
+ * degrading as narrowly as possible is the entire point.
1371
+ */
872
1372
  export async function getValidationResults(scanPath, recursive, options, logger, locationRoot) {
1373
+ try {
1374
+ return dedupeUnreadablePathResults(await validateAuditSubject(scanPath, recursive, options, logger, locationRoot));
1375
+ }
1376
+ catch (error) {
1377
+ // Only the filesystem refusing the subject degrades; a defect in a validator
1378
+ // must still fail loudly rather than be reported as a permissions problem.
1379
+ if (!isFilesystemAccessError(error))
1380
+ throw error;
1381
+ logger.debug(`Unreadable audit subject: ${scanPath}`);
1382
+ return [unreadablePathResult(scanPath, error, locationRoot)];
1383
+ }
1384
+ }
1385
+ /**
1386
+ * One refused path, one `SCAN_PATH_UNREADABLE` — however many lanes met it.
1387
+ *
1388
+ * Two lanes can reach the same unreadable directory in one run: the directory
1389
+ * walk (`scanDirectory`, which files the synthetic {@link unreadablePathResult})
1390
+ * and the distributed-tree detector, which crawls a skill's own directory at any
1391
+ * depth and reports what it could not list as an issue ON the skill's result.
1392
+ * Their populations overlap, but neither contains the other: the walk skips
1393
+ * what `--exclude`, the project's `resources.exclude` and gitignore skip, and
1394
+ * does not descend at all under `--no-recursive` or when the SKILL.md is named
1395
+ * directly — the detector enters all of those. So "the walk already reports
1396
+ * everything under the scan root" is false in both directions, and a rule that
1397
+ * silenced one lane by geography would drop the refusal in exactly the cases
1398
+ * where it is the only report. The rule is instead keyed on the FACT: the
1399
+ * code and the location, which both lanes derive from the same directory and
1400
+ * the same anchor root.
1401
+ *
1402
+ * The hosted issue wins and the synthetic result is dropped, not the reverse:
1403
+ * the detector's copy says which SKILL's crawl was cut short, which the
1404
+ * synthetic result cannot, and it is the only copy the direct-target lane ever
1405
+ * produces — so keeping it makes the two lanes' reports the same shape. A
1406
+ * synthetic result that carried nothing but the duplicate is removed whole; one
1407
+ * that somehow carried more keeps its other issues (it cannot today, but the
1408
+ * filter says what it means rather than assuming).
1409
+ *
1410
+ * A THIRD lane files synthetic results too: skill discovery under a governing
1411
+ * config, which refuses a directory its `skills.include` cannot list and is
1412
+ * recorded through {@link recordRefusedDiscovery}. In the directory lane the
1413
+ * walk has already filed its own synthetic result on that same directory, so
1414
+ * two synthetic results can carry one fact. Among synthetics the FIRST wins and
1415
+ * later copies of the same location are dropped — the walk's copy carries the
1416
+ * raw errno, the discovery's copy the crawl's remedy sentence, and the stderr
1417
+ * warning carries the latter either way.
1418
+ */
1419
+ function dedupeUnreadablePathResults(results) {
1420
+ const reported = new Set();
1421
+ for (const result of results) {
1422
+ if (result.type === 'unknown')
1423
+ continue;
1424
+ for (const issue of result.issues) {
1425
+ if (issue.code === 'SCAN_PATH_UNREADABLE')
1426
+ reported.add(issue.location ?? '');
1427
+ }
1428
+ }
1429
+ return results.flatMap((result) => {
1430
+ if (result.type !== 'unknown')
1431
+ return [result];
1432
+ const kept = withoutHostedRefusals(result, reported);
1433
+ for (const survivor of kept) {
1434
+ for (const issue of survivor.issues) {
1435
+ if (issue.code === 'SCAN_PATH_UNREADABLE')
1436
+ reported.add(issue.location ?? '');
1437
+ }
1438
+ }
1439
+ return kept;
1440
+ });
1441
+ }
1442
+ /**
1443
+ * A synthetic result minus the refusals some hosted result already carries —
1444
+ * as an empty array when nothing is left, so {@link dedupeUnreadablePathResults}
1445
+ * can `flatMap` it away.
1446
+ */
1447
+ function withoutHostedRefusals(result, hosted) {
1448
+ const issues = result.issues.filter((issue) => !(issue.code === 'SCAN_PATH_UNREADABLE' && hosted.has(issue.location ?? '')));
1449
+ if (issues.length === 0)
1450
+ return [];
1451
+ if (issues.length === result.issues.length)
1452
+ return [result];
1453
+ const issueCounts = countBySeverity(issues);
1454
+ return [{
1455
+ ...result,
1456
+ status: calculateValidationStatus(issues),
1457
+ summary: formatCountsSummary(issueCounts),
1458
+ issues,
1459
+ issueCounts,
1460
+ }];
1461
+ }
1462
+ async function validateAuditSubject(scanPath, recursive, options, logger, locationRoot) {
873
1463
  const format = detectFormat(scanPath);
874
1464
  // Special handling for direct SKILL.md file
875
1465
  if (format === RESOURCE_TYPE_AGENT_SKILL) {
@@ -923,7 +1513,7 @@ export async function getValidationResults(scanPath, recursive, options, logger,
923
1513
  // recursion into co-located, path-source plugins. discovered.plugins[]
924
1514
  // is populated only for path sources that exist on disk (git/npm/unknown
925
1515
  // sources are declarations only and stay out of scope).
926
- const marketplaceInv = await extractClaudeMarketplaceInventory(scanPath);
1516
+ const marketplaceInv = await extractClaudeMarketplaceInventory(scanPath, { gitTrackerSource: gitTrackerForProjectRoot });
927
1517
  const marketplaceInventoryIssues = await runInventoryDetectors(scanPath, 'marketplace', locationRoot, marketplaceInv);
928
1518
  appendIssues(result, marketplaceInventoryIssues);
929
1519
  logger.debug(`Inventory detectors emitted ${marketplaceInventoryIssues.length.toString()} issues for marketplace at ${scanPath}`);
@@ -933,26 +1523,45 @@ export async function getValidationResults(scanPath, recursive, options, logger,
933
1523
  return [result];
934
1524
  }
935
1525
  // 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
1526
+ if (await isScannableDirectory(scanPath)) {
1527
+ logger.debug('Scanning directory for resources');
1528
+ // The project's `resources.exclude` is applied by the scan context,
1529
+ // on the project-root basis its globs were written against — NOT
1530
+ // merged into `options.exclude`, whose patterns are relative to the
1531
+ // directory the operator named. See resolveProjectExcludes.
1532
+ return scanDirectory(scanPath, recursive, options, logger, await resolveScanContext(scanPath, locationRoot, logger));
950
1533
  }
951
1534
  // Unknown resource type - use unified validator which will return appropriate error
952
1535
  logger.debug(`Unknown resource type at: ${scanPath}`);
953
1536
  const result = await validate(scanPath, { locationRoot });
954
1537
  return [result];
955
1538
  }
1539
+ /**
1540
+ * Whether `scanPath` is a directory the walk can enter.
1541
+ *
1542
+ * `false` for a file and for NOTHING — `validate()` then names the path as
1543
+ * unknown or absent, which is the answer the operator can act on. A refusal on
1544
+ * the stat is neither: it propagates to `getValidationResults`, whose own catch
1545
+ * files it as `SCAN_PATH_UNREADABLE`, so the audit still degrades (the run
1546
+ * completes, the refusal a finding) rather than refusing the run.
1547
+ *
1548
+ * 🪤 The stat used to share ONE `try` with `scanDirectory` itself, and the
1549
+ * catch was bare. Every throw from the scan — a crawl refusal, a `TypeError`
1550
+ * two frames down — was absorbed into "let validate() handle it", and
1551
+ * `validate()` then reported the directory as an unknown resource format. A
1552
+ * scan that examined nothing was reported as a directory that was nothing.
1553
+ */
1554
+ async function isScannableDirectory(scanPath) {
1555
+ const fsp = await import('node:fs/promises');
1556
+ try {
1557
+ return (await fsp.stat(scanPath)).isDirectory();
1558
+ }
1559
+ catch (error) {
1560
+ if (isPathAbsentError(error))
1561
+ return false;
1562
+ throw error;
1563
+ }
1564
+ }
956
1565
  /**
957
1566
  * Inventory-driven plugin skill walker. Builds the plugin inventory via
958
1567
  * `extractClaudePluginInventory` and dispatches `validateSingleSkill` for each
@@ -972,9 +1581,26 @@ async function validatePluginSkillsViaInventory(scanPath, options, logger, locat
972
1581
  continue;
973
1582
  }
974
1583
  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));
1584
+ // Guarded PER SKILL, not left to the boundary. The boundary guard answers at
1585
+ // whole-subject granularity, so one unreadable bundled `SKILL.md` discarded
1586
+ // the plugin's own manifest findings and every readable sibling skill with
1587
+ // it — 4 results down to 1 — and anchored the finding at the plugin root,
1588
+ // which is perfectly readable. That contradicted this code's own promise
1589
+ // that an unreadable path "costs its own subject and nothing else", and the
1590
+ // documented promise that "a skill beside the unreadable path is validated
1591
+ // exactly as if it were not there". Degrading as narrowly as the structure
1592
+ // allows is the whole point; here the skill is the unit.
1593
+ try {
1594
+ // No crawl: `validatePlugin` already scanned the whole plugin tree, nested
1595
+ // skill directories included, so this lane would duplicate its findings.
1596
+ results.push(await validateSingleSkill(skill.files.skillMd, options, logger, locationRoot, false));
1597
+ }
1598
+ catch (error) {
1599
+ if (!isFilesystemAccessError(error))
1600
+ throw error;
1601
+ logger.debug(` Unreadable plugin-bundled skill: ${skill.files.skillMd}`);
1602
+ results.push(unreadablePathResult(skill.files.skillMd, error, locationRoot));
1603
+ }
978
1604
  }
979
1605
  return results;
980
1606
  }
@@ -1001,9 +1627,9 @@ function resolveConfigTargetsForPlugin(pluginDir, vatContext) {
1001
1627
  const union = new Set();
1002
1628
  for (const [skillPathAbs, packagingConfig] of vatContext.skillConfigs) {
1003
1629
  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))
1630
+ // Skill lives inside the plugin directory iff the relative path neither
1631
+ // climbs out nor (on Windows, cross-drive) comes back absolute.
1632
+ if (rel === '' || relativeEscapesRoot(rel))
1007
1633
  continue;
1008
1634
  const skillTargets = packagingConfig.targets;
1009
1635
  if (skillTargets === undefined)
@@ -1015,7 +1641,15 @@ function resolveConfigTargetsForPlugin(pluginDir, vatContext) {
1015
1641
  return union.size === 0 ? undefined : [...union];
1016
1642
  }
1017
1643
  /**
1018
- * Run compatibility analysis on plugin results and return a map of path -> CompatibilityResult.
1644
+ * `message`, with `absolutePath` spelled root-relative wherever it appears.
1645
+ * An OS refusal names the path it refused, absolutely; every other path in
1646
+ * the document is relative to `root`, and this one leaks the build host.
1647
+ */
1648
+ function withRelativePath(message, absolutePath, locationRoot) {
1649
+ return message.replaceAll(absolutePath, issueLocation(absolutePath, locationRoot) || '.');
1650
+ }
1651
+ /**
1652
+ * Run compatibility analysis on plugin results and return a map of path -> PluginCompatEntry.
1019
1653
  * Non-plugin results are skipped silently.
1020
1654
  * When effectiveSettings is provided, also runs settings conflict detection.
1021
1655
  *
@@ -1036,67 +1670,99 @@ export async function runCompatAnalysis(results, logger, locationRoot, effective
1036
1670
  for (const result of results) {
1037
1671
  if (result.type !== RESOURCE_TYPE_CLAUDE_PLUGIN)
1038
1672
  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)}`);
1673
+ const entry = {
1674
+ compat: await analyzeOrExplain(result.path, locationRoot, vatContext, logger),
1675
+ };
1676
+ if (effectiveSettings !== undefined) {
1677
+ entry.settings = await settingsVerdict(result.path, effectiveSettings, locationRoot, logger);
1064
1678
  }
1679
+ compatMap.set(result.path, entry);
1065
1680
  }
1066
1681
  return compatMap;
1067
1682
  }
1683
+ /**
1684
+ * The analyzer's result for one plugin, or — when it threw — the reason, said
1685
+ * on stderr and carried into the document. Compat analysis is best-effort:
1686
+ * it never fails the audit, but a plugin it could not analyze is reported as
1687
+ * exactly that, never as a plugin with no `compatibility:` block.
1688
+ */
1689
+ async function analyzeOrExplain(pluginDir, locationRoot, vatContext, logger) {
1690
+ try {
1691
+ logger.debug(`Running compatibility analysis for: ${pluginDir}`);
1692
+ const configTargets = resolveConfigTargetsForPlugin(pluginDir, vatContext);
1693
+ const analyzeOptions = configTargets === undefined ? undefined : { configTargets };
1694
+ return await analyzeCompatibility(pluginDir, locationRoot, analyzeOptions);
1695
+ }
1696
+ catch (err) {
1697
+ const refused = typeof err.path === 'string' ? err.path : undefined;
1698
+ const raw = err instanceof Error ? err.message : String(err);
1699
+ const reason = refused === undefined ? raw : withRelativePath(raw, refused, locationRoot);
1700
+ logger.warn(`Compatibility analysis could not run for ${issueLocation(pluginDir, locationRoot) || '.'}: ${reason}`);
1701
+ return { analyzed: false, reason };
1702
+ }
1703
+ }
1704
+ /**
1705
+ * One plugin's settings verdict: the checker's conflicts and the paths it
1706
+ * never saw — and, when the checker itself fails, the plugin as the one thing
1707
+ * unchecked, rather than no `settings:` block at all.
1708
+ *
1709
+ * The checker failing used to be `logger.debug` and a result with no settings
1710
+ * block, so a plugin the check could not run on was indistinguishable from one
1711
+ * the operator never asked about. A run that asked for `--settings` and got no
1712
+ * answer for a plugin is told so on stderr and in the document.
1713
+ */
1714
+ async function settingsVerdict(pluginDir, effectiveSettings, locationRoot, logger) {
1715
+ try {
1716
+ return await checkSettingsCompatibility(pluginDir, effectiveSettings);
1717
+ }
1718
+ catch (settingsErr) {
1719
+ const reason = settingsErr instanceof Error ? settingsErr.message : String(settingsErr);
1720
+ logger.warn(`Settings compatibility could not be checked for ${issueLocation(pluginDir, locationRoot) || '.'}: ${reason}`);
1721
+ return { conflicts: [], unchecked: [{ path: pluginDir, reason }] };
1722
+ }
1723
+ }
1724
+ /**
1725
+ * The `settings:` block for one plugin, every path anchored at `locationRoot`.
1726
+ * `compatible` is derived from BOTH halves: zero conflicts over a skill the
1727
+ * check never read is not compatibility, it is silence.
1728
+ */
1729
+ function renderSettingsBlock(check, locationRoot) {
1730
+ const settings = {
1731
+ compatible: check.conflicts.length === 0 && check.unchecked.length === 0,
1732
+ conflicts: check.conflicts,
1733
+ };
1734
+ if (check.unchecked.length > 0) {
1735
+ settings.unchecked = check.unchecked.map(({ path, reason }) => ({
1736
+ path: issueLocation(path, locationRoot) || '.',
1737
+ reason: withRelativePath(reason, path, locationRoot),
1738
+ }));
1739
+ }
1740
+ return settings;
1741
+ }
1068
1742
  /**
1069
1743
  * Merge compatibility analysis results into validation result output objects.
1070
1744
  * Returns an array of plain objects ready for YAML serialization.
1071
- * When settingsConflicts are present, adds a `settings:` block for cleaner output.
1745
+ * A plugin the run asked `--settings` about gets a `settings:` block, whatever
1746
+ * the analyzer did.
1072
1747
  */
1073
- function mergeCompatIntoResults(results, compatMap) {
1748
+ function mergeCompatIntoResults(results, compatMap, locationRoot) {
1074
1749
  return results.map(r => {
1075
- const compat = compatMap.get(r.path);
1076
- if (compat === undefined)
1750
+ const entry = compatMap.get(r.path);
1751
+ if (entry === undefined)
1077
1752
  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 };
1753
+ const merged = { ...r, compatibility: entry.compat };
1754
+ if (entry.settings !== undefined)
1755
+ merged.settings = renderSettingsBlock(entry.settings, locationRoot);
1756
+ return merged;
1091
1757
  });
1092
1758
  }
1093
1759
  /**
1094
1760
  * Apply compatibility data to results if a compatMap is provided and non-empty.
1095
1761
  * Returns the original results array when no compat data is available.
1096
1762
  */
1097
- function applyCompatMap(results, compatMap) {
1763
+ function applyCompatMap(results, compatMap, locationRoot) {
1098
1764
  if (compatMap !== undefined && compatMap.size > 0) {
1099
- return mergeCompatIntoResults(results, compatMap);
1765
+ return mergeCompatIntoResults(results, compatMap, locationRoot);
1100
1766
  }
1101
1767
  return results;
1102
1768
  }
@@ -1133,7 +1799,7 @@ function applyVerboseFilter(results, verbose) {
1133
1799
  const NESTED_PATH_CARRIERS = ['linkedFiles'];
1134
1800
  function calculateSummary(results, startTime, compatMap, verbose, root) {
1135
1801
  const { entries, ...base } = buildBaseSummary(results, startTime);
1136
- const withCompat = applyCompatMap(entries, compatMap);
1802
+ const withCompat = applyCompatMap(entries, compatMap, root);
1137
1803
  return {
1138
1804
  // Stated once, first, and the only absolute path in the document.
1139
1805
  root,
@@ -1236,19 +1902,75 @@ function renderVerboseEvidence(results, root, logger) {
1236
1902
  }
1237
1903
  }
1238
1904
  }
1905
+ /**
1906
+ * The stderr half of a run-level refusal, so the human channel says what the
1907
+ * document says. The document is derived in `buildBaseSummary`; this only
1908
+ * RENDERS it. Both stderr lanes call it first, because "Audit successful: 0
1909
+ * file(s) passed" over `status: error` is the exact disagreement the refusal
1910
+ * exists to end (invariant 6 of `run-integrity.ts`). The run ends on
1911
+ * `FINDINGS`, like every other `status: error` this command publishes.
1912
+ *
1913
+ * @returns Whether a refusal was rendered, so the caller skips its verdict line
1914
+ */
1915
+ function logRunIntegrity(runIssues, logger) {
1916
+ if (runIssues === undefined || runIssues.length === 0)
1917
+ return false;
1918
+ logger.error('Audit is not a verdict: it audited 0 files');
1919
+ for (const issue of runIssues) {
1920
+ for (const line of formatIssueLines(issue, ' '))
1921
+ logger.error(line);
1922
+ }
1923
+ return true;
1924
+ }
1925
+ /**
1926
+ * The stderr line for the settings lane: how many conflicts it found and how
1927
+ * many paths it never compared, across every plugin in the document.
1928
+ *
1929
+ * 🚩 This read `f.compatibility?.settingsConflicts` — a field the renderer had
1930
+ * already moved out into the `settings:` block — so the count was 0 on every
1931
+ * run and the line never printed. It reads the rendered block now, the same
1932
+ * one the YAML carries, and it counts the unchecked half too: a run that
1933
+ * skipped a skill says so without `--debug`.
1934
+ */
1935
+ function logSettingsTotals(files, logger) {
1936
+ let conflicts = 0;
1937
+ let unchecked = 0;
1938
+ // The compat lane's own unchecked paths — a plugin the analyzer could only
1939
+ // partly read is in the document under `compatibility.unchecked`, and this is
1940
+ // the one line that keeps it from being silent on the human channel while the
1941
+ // settings lane says its half. `analyzed: false` has no list: it is the
1942
+ // plugin-wide failure, and the `reason` line already says so.
1943
+ let compatUnchecked = 0;
1944
+ for (const { compatibility, settings } of files) {
1945
+ conflicts += settings?.conflicts.length ?? 0;
1946
+ unchecked += settings?.unchecked?.length ?? 0;
1947
+ if (compatibility !== undefined && 'unchecked' in compatibility)
1948
+ compatUnchecked += compatibility.unchecked.length;
1949
+ }
1950
+ if (conflicts > 0) {
1951
+ logger.error(`⚠ ${conflicts} settings conflict(s) found — see 'settings' section in YAML output`);
1952
+ }
1953
+ if (unchecked > 0) {
1954
+ logger.error(`⚠ ${unchecked} path(s) the settings check could not compare — see 'settings.unchecked' in YAML output`);
1955
+ }
1956
+ if (compatUnchecked > 0) {
1957
+ logger.error(`⚠ ${compatUnchecked} path(s) the compatibility analysis could not read — see 'compatibility.unchecked' in YAML output`);
1958
+ }
1959
+ }
1239
1960
  function handleAuditResults(results, summary, logger, verbose) {
1240
1961
  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`);
1962
+ logSettingsTotals(summary.files ?? [], logger);
1963
+ // The exit code follows the document's `status`, as it does in every other
1964
+ // command of this CLI: `status: error` — an error-severity finding, or a run
1965
+ // that audited zero files — ends on `FINDINGS`. This used to be the one
1966
+ // command that exited 0 over `status: error` ("advisory"), and an adopter
1967
+ // wiring it into CI read "Audit failed" on stderr beside a green step.
1968
+ const refused = logRunIntegrity(summary.issues, logger);
1969
+ if (refused) {
1970
+ // Rendered above; there is no file verdict to print over zero files.
1971
+ }
1972
+ else if (errorCount > 0) {
1973
+ logger.error(`Audit found ${errorCount} file(s) with errors`);
1252
1974
  logFindingsForStatus(results, 'error', summary.root, logger.error.bind(logger), verbose);
1253
1975
  }
1254
1976
  else if (warningCount > 0) {
@@ -1264,7 +1986,7 @@ function handleAuditResults(results, summary, logger, verbose) {
1264
1986
  : `Audit successful: ${successCount} file(s) passed`);
1265
1987
  }
1266
1988
  renderAuditFooter(results, logger);
1267
- process.exit(0);
1989
+ process.exit(refused || errorCount > 0 ? ExitCode.FINDINGS : ExitCode.OK);
1268
1990
  }
1269
1991
  /**
1270
1992
  * Render the skill-quality checklist footer when audit results contain
@@ -1359,17 +2081,6 @@ function logFindingsForStatus(results, status, root, logFn, verbose) {
1359
2081
  logFn(line);
1360
2082
  }
1361
2083
  }
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
2084
  /**
1374
2085
  * Does the SKILL.md at `skillMdPath` own its own presence-side crawl, or has an
1375
2086
  * ancestor crawl already covered it?
@@ -1383,39 +2094,27 @@ function ownsOwnCrawl(scanCtx, skillMdPath) {
1383
2094
  return scanCtx.crawledRoot === null || !isWithin(scanCtx.crawledRoot, safePath.resolve(skillMdPath));
1384
2095
  }
1385
2096
  /**
1386
- * Hand the descending context an owner for everything below `owner`, unless one
1387
- * is already claimed. First owner wins, and `null` claims nothing.
2097
+ * The directories whose presence-side crawl already owns everything beneath
2098
+ * them — see {@link ScanContext.crawledRoot} for why there are two owner kinds
2099
+ * and why the first owner wins.
2100
+ *
2101
+ * Kept as a list of roots rather than threaded down a recursion: the
2102
+ * population arrives flat and top-down (an ancestor's subjects before a
2103
+ * descendant's), so "who owns this path" is the first claimed root that
2104
+ * contains it, and a claim is only ever made where no ancestor holds one.
1388
2105
  */
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.
1409
- */
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);
2106
+ class SubtreeClaims {
2107
+ roots = [];
2108
+ /** The claimed root containing `absolutePath`, or `null` while nothing does. */
2109
+ owner(absolutePath) {
2110
+ const resolved = safePath.resolve(absolutePath);
2111
+ return this.roots.find((root) => isWithin(root, resolved)) ?? null;
2112
+ }
2113
+ /** Claim everything below `dir`, unless an ancestor already did. First owner wins. */
2114
+ claim(dir) {
2115
+ if (this.owner(dir) === null)
2116
+ this.roots.push(safePath.resolve(dir));
2117
+ }
1419
2118
  }
1420
2119
  /**
1421
2120
  * Handle file entry during directory scan
@@ -1433,25 +2132,41 @@ async function handleFileEntry(entry, fullPath, options, logger, scanCtx) {
1433
2132
  // Config-aware: walk UP to the skill's nearest-ancestor config and apply
1434
2133
  // ONLY that skill's declared packaging rules. Configs do not compose
1435
2134
  // 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);
2135
+ // Shared with the single-target lane, so one unloadable nested config
2136
+ // cannot abort this whole scan while that lane shrugs it off. The warn-once
2137
+ // ledger lives in `unloadableConfigResults` (module scope, cleared by
2138
+ // `resetAuditCaches`) rather than being threaded here, so BOTH lanes dedupe
2139
+ // against the same state — this one used to, the other minted a fresh set
2140
+ // per skill.
2141
+ const skillConfig = await resolveGoverningConfig(fullPath, logger, locationRoot);
1438
2142
  if (skillConfig !== null) {
1439
2143
  logger.debug(` Using config-aware validation for: ${fullPath}`);
1440
2144
  // Thread the per-scan tracker into packaging validation so gitignore
1441
2145
  // checks in the link-graph walk stay O(1).
1442
2146
  // `projectSkills`: same project-wide test-input rule as the lane above,
1443
2147
  // resolved through the same per-config-root memo.
1444
- const projectSkills = await resolveProjectDeclaredEvalSuites(fullPath);
2148
+ const projectSkills = await resolveProjectDeclaredEvalSuites(fullPath, degradingDiscovery(findProjectRoot(safePath.resolve(fullPath, '..')) ?? locationRoot, logger, locationRoot));
2149
+ // `suiteProbe`: the RUN's, not this entry's — the directory walk reaches
2150
+ // this function once per SKILL.md it finds. See {@link runSuiteProbe}.
2151
+ const suiteProbe = suiteProbeForRun();
2152
+ // No registry is supplied on this lane, so `validateSkillForPackaging`
2153
+ // crawls one per project root through `crawlAndResolveRegistry` — under
2154
+ // audit's ruling, which is the ONLY reason one `chmod 000` sibling no
2155
+ // longer exits this whole run at 2 with zero findings. The root is the
2156
+ // one the validator itself derives (`findProjectRoot(dirname) ?? dirname`),
2157
+ // so the refuse sentence and the finding name the same crawl.
2158
+ const skillDir = safePath.resolve(fullPath, '..');
2159
+ const unreadable = degradingRegistry(findProjectRoot(skillDir) ?? skillDir, logger, locationRoot);
1445
2160
  const sharedCtx = scanCtx.gitTracker === null
1446
- ? { locationRoot, projectSkills }
1447
- : { gitTracker: scanCtx.gitTracker, locationRoot, projectSkills };
2161
+ ? { locationRoot, projectSkills, suiteProbe, unreadable }
2162
+ : { gitTracker: scanCtx.gitTracker, locationRoot, projectSkills, suiteProbe, unreadable };
1448
2163
  const packagingResult = await validateSkillForPackaging(fullPath, skillConfig, 'source', sharedCtx);
1449
2164
  const configAware = packagingResultToValidationResult(fullPath, packagingResult, skillConfig.targets, locationRoot);
1450
2165
  await appendDistributedTreeFindings(configAware, fullPath, locationRoot, ownsOwnCrawl(scanCtx, fullPath));
1451
2166
  return configAware;
1452
2167
  }
1453
- // Wild mode: basic validation
1454
- const validateOptions = { skillPath: fullPath, locationRoot };
2168
+ // Wild mode: basic validation. Severity is applied by `applySeverityFilter`.
2169
+ const validateOptions = { skillPath: fullPath, locationRoot, validation: {} };
1455
2170
  if (options.warnUnreferencedFiles) {
1456
2171
  validateOptions.checkUnreferencedFiles = true;
1457
2172
  }
@@ -1465,79 +2180,15 @@ async function handleFileEntry(entry, fullPath, options, logger, scanCtx) {
1465
2180
  return null;
1466
2181
  }
1467
2182
  /**
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.
2183
+ * Validate a directory the population found holding `.claude-plugin/` — a
2184
+ * plugin or a marketplace, whichever the unified validator decides.
1514
2185
  */
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' };
2186
+ async function validatePluginDirectory(dir, logger, locationRoot) {
2187
+ logger.debug(`Validating resource directory: ${dir}`);
2188
+ const result = await validate(dir, { validatePlugin, locationRoot });
2189
+ const inv = await pluginInventoryAt(dir);
2190
+ appendInventoryParseErrors(result, inv, locationRoot);
2191
+ return result;
1541
2192
  }
1542
2193
  /**
1543
2194
  * Cache of (projectRoot → registry for the *inventory* link walk).
@@ -1590,7 +2241,12 @@ async function getOrCreateInventoryRegistry(projectRoot) {
1590
2241
  */
1591
2242
  async function pluginInventoryAt(dir) {
1592
2243
  const projectRoot = findProjectRoot(dir);
1593
- return extractClaudePluginInventory(dir, projectRoot === null ? undefined : () => getOrCreateInventoryRegistry(projectRoot), gitTrackerForProjectRoot);
2244
+ return extractClaudePluginInventory(dir, {
2245
+ ...(projectRoot === null
2246
+ ? {}
2247
+ : { sharedRegistry: () => getOrCreateInventoryRegistry(projectRoot) }),
2248
+ gitTrackerSource: gitTrackerForProjectRoot,
2249
+ });
1594
2250
  }
1595
2251
  /**
1596
2252
  * Reset all module-level audit caches. Must be called at the start of every
@@ -1602,6 +2258,8 @@ export function resetAuditCaches() {
1602
2258
  resetGitTrackerCache();
1603
2259
  resetPackagingRegistryCache();
1604
2260
  inventoryRegistryCache.clear();
2261
+ unloadableConfigResults.clear();
2262
+ runSuiteProbe = undefined;
1605
2263
  resetProjectRootCaches();
1606
2264
  resetLoadedConfigCache();
1607
2265
  resetSkillDiscoveryCache();
@@ -1631,107 +2289,150 @@ async function resolveScanContext(dirPath, locationRoot, logger) {
1631
2289
  return { gitRoot: detectedRoot, gitTracker: tracker, locationRoot, projectExcludes, crawledRoot: null };
1632
2290
  }
1633
2291
  /**
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).
2292
+ * The one finding a path the walk could not read produces — a directory it could
2293
+ * not enter, or a file it could not open.
2294
+ *
2295
+ * A synthetic result rather than an issue appended to some neighbour: nothing
2296
+ * about the unreadable path was validated, so it has no host result to hang
2297
+ * off, and attaching it to a sibling would attribute the condition to a tree that
2298
+ * scanned fine. Typed `unknown` for the same reason — the whole point is that its
2299
+ * contents were never seen, so any more specific type would be a claim the scan
2300
+ * cannot support.
2301
+ *
2302
+ * The OS message is carried through verbatim (it names the errno and the syscall,
2303
+ * which is what distinguishes a permissions problem from a vanished mount), but it
2304
+ * is no longer the ONLY thing the run says — the code, the location and the fix
2305
+ * come from the registry.
2306
+ *
2307
+ * This result is a `files[]` row but NOT a scanned file: `countFilesByStatus`
2308
+ * recognises it ({@link isUnreadablePathResult}) and reports it under
2309
+ * `summary.pathsUnreadable`, outside `filesScanned` and the status counts. A
2310
+ * root the OS refused therefore audits zero files and ends on the zero-files
2311
+ * refusal, instead of `filesScanned: 1` over a path nothing read.
1637
2312
  */
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;
2313
+ function unreadablePathResult(dirPath, error, locationRoot) {
2314
+ // `issueLocation` is `path.relative`, so it answers `''` when the subject IS the
2315
+ // anchor — and for this finding that is not exotic, it is `vat audit <path>`
2316
+ // where the named path is the unreadable one. `''` reached the report as
2317
+ // `location: ""` and rendered the detail as a bare `": EACCES …"`. `.` is that
2318
+ // same path spelled as something a reader can act on. The identical fix, for the
2319
+ // identical reason, already exists on the distributed-tree finding — see
2320
+ // `anchoredTreeLocation` in ./audit/distributed-tree.ts.
2321
+ const location = issueLocation(dirPath, locationRoot) || '.';
2322
+ // The OS names the path it refused absolutely; the report speaks scan-root
2323
+ // coordinates everywhere else, and an absolute path here is the build host's
2324
+ // `$HOME` in every CI log.
2325
+ const reason = withRelativePath(error instanceof Error ? error.message : String(error), dirPath, locationRoot);
2326
+ const issues = [
2327
+ materializeIssue('SCAN_PATH_UNREADABLE', {
2328
+ location,
2329
+ detail: `${location}: ${reason}`,
2330
+ }),
2331
+ ];
2332
+ const issueCounts = countBySeverity(issues);
2333
+ return {
2334
+ path: dirPath,
2335
+ type: 'unknown',
2336
+ status: calculateValidationStatus(issues),
2337
+ summary: formatCountsSummary(issueCounts),
2338
+ issues,
2339
+ issueCounts,
2340
+ };
1647
2341
  }
1648
2342
  /**
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.
2343
+ * Validate ONE subject the population found, degrading to a finding if the
2344
+ * filesystem refuses it.
2345
+ *
2346
+ * Guarded per SUBJECT, not just per listing: an unreadable FILE (a root-owned
2347
+ * `SKILL.md`, a quarantined bundle) used to throw out of `validateSkill` and
2348
+ * abort the whole run with `status: error` and zero findings. Under
2349
+ * `~/.claude/plugins` — the flagship `vat audit --user` target, populated by sudo
2350
+ * installs and macOS quarantine — a root-owned FILE is at least as likely as a
2351
+ * root-owned directory.
2352
+ *
2353
+ * Ownership is decided here and only here, in population order: a plugin
2354
+ * directory claims its subtree unconditionally; a skill claims its own only
2355
+ * when nothing above it already has AND its crawl actually runs
2356
+ * ({@link crawlOwnsSubtree}) — a repo-source skill's crawl is skipped, so
2357
+ * claiming its subtree would trade a double count for a missed finding.
1651
2358
  */
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}`;
2359
+ async function validateScanSubject(subject, options, logger, scanCtx, claims) {
2360
+ const { locationRoot } = scanCtx;
2361
+ try {
2362
+ if (subject.kind === 'plugin') {
2363
+ const result = await validatePluginDirectory(subject.dir, logger, locationRoot);
2364
+ claims.claim(subject.dir);
2365
+ return [result];
1666
2366
  }
2367
+ const subjectCtx = { ...scanCtx, crawledRoot: claims.owner(subject.path) };
2368
+ const result = await handleFileEntry({ name: basename(subject.path) }, subject.path, options, logger, subjectCtx);
2369
+ if (subject.kind === 'skill' && ownsOwnCrawl(subjectCtx, subject.path) && await crawlOwnsSubtree(subject.path)) {
2370
+ claims.claim(subject.dir);
2371
+ }
2372
+ return result === null ? [] : [result];
2373
+ }
2374
+ catch (error) {
2375
+ // ONLY filesystem-access errors degrade. Anything else is rethrown, because
2376
+ // turning a genuine VAT bug into a `warning` about the file it happened on is
2377
+ // the same "detector silently disables itself" shape this guard exists to
2378
+ // prevent — it would make the tool quietest exactly when it is most wrong.
2379
+ if (!isFilesystemAccessError(error))
2380
+ throw error;
2381
+ const path = subject.kind === 'plugin' ? subject.dir : subject.path;
2382
+ logger.debug(`Unreadable entry: ${path}`);
2383
+ return [unreadablePathResult(path, error, locationRoot)];
1667
2384
  }
1668
- return null;
1669
2385
  }
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.
2386
+ /**
2387
+ * The remedy a refused listing names: the operator's own knob for dropping a
2388
+ * directory from the audit on purpose.
2389
+ */
2390
+ function scanRefusalRemedy() {
2391
+ return 'To leave it out deliberately, exclude it with --exclude <pattern>.';
2392
+ }
2393
+ /**
2394
+ * Audit a directory: every plugin, skill and registry file the `crawl` lane
2395
+ * finds beneath it, plus one finding per directory the lane could not list.
2396
+ *
2397
+ * The enumeration is `enumerateAuditPopulation`'s — this function owns no
2398
+ * walk of its own. What it decides is what the audit MAKES of the population:
2399
+ * validation per subject, subtree ownership across subjects (see
2400
+ * {@link validateScanSubject}), the nested-config breadcrumb, and the refusal
2401
+ * findings. A refused directory is a finding, not a thrown error: `vat audit`
2402
+ * is a bulk linter over trees it does not own, and one root-owned directory
2403
+ * under `~/.claude/plugins` used to abort the flagship `vat audit --user` run
2404
+ * outright, losing every finding already collected. The scope of the loss is
2405
+ * the subtree that refused; every readable sibling is still validated.
2406
+ */
2407
+ async function scanDirectory(dirPath, recursive, options, logger, scanCtx) {
2408
+ // Warm findProjectRoot's walk-up cache from the scan root: every per-skill
2409
+ // walk-up below lands on a cached ancestor instead of re-walking to `/`.
1683
2410
  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
- }
2411
+ const population = await enumerateAuditPopulation({
2412
+ scanDir: dirPath,
2413
+ recursive,
2414
+ // No tracker means no git root, or a scan root that is itself gitignored
2415
+ // (the operator's explicit intent wins); either way ignored files are in.
2416
+ respectGitignore: scanCtx.gitTracker !== null && !(options.includeArtifacts ?? false),
2417
+ userExcludes: options.exclude ?? [],
2418
+ projectExcludes: scanCtx.projectExcludes,
2419
+ });
2420
+ // A one-time breadcrumb per nested `vibe-agent-toolkit.config.yaml` that is
2421
+ // NOT at the scan root. Configs do not compose across VAT projects; the
2422
+ // message reminds operators that only per-skill packaging rules from this
2423
+ // config still apply.
2424
+ for (const nestedConfigPath of population.nestedConfigs) {
2425
+ const rel = toForwardSlash(safePath.relative(dirPath, nestedConfigPath));
2426
+ 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.`);
2427
+ }
2428
+ const results = population.refusals.map((refusal) => {
2429
+ logger.debug(`Unreadable directory: ${refusal.directory}`);
2430
+ const err = new DirectoryListingRefusedError(refusal, { root: dirPath, remedy: scanRefusalRemedy() });
2431
+ return unreadablePathResult(refusal.directory, err, scanCtx.locationRoot);
2432
+ });
2433
+ const claims = new SubtreeClaims();
2434
+ for (const subject of population.subjects) {
2435
+ results.push(...await validateScanSubject(subject, options, logger, scanCtx, claims));
1735
2436
  }
1736
2437
  return results;
1737
2438
  }
@@ -1760,13 +2461,34 @@ function calculateOverallStatus(results) {
1760
2461
  function countResultsWithFindings(results) {
1761
2462
  return results.filter((r) => r.issues.length > 0).length;
1762
2463
  }
1763
- /** Count files by their own status. */
2464
+ /**
2465
+ * Whether a `files[]` row is a refused path rather than a scanned file: the
2466
+ * synthetic result {@link unreadablePathResult} builds, carrying nothing but
2467
+ * `SCAN_PATH_UNREADABLE`. Keyed on the code, not on `type: 'unknown'` alone —
2468
+ * an unrecognised file is also `unknown`, and it WAS examined.
2469
+ */
2470
+ function isUnreadablePathResult(result) {
2471
+ return result.type === 'unknown'
2472
+ && result.issues.length > 0
2473
+ && result.issues.every((issue) => issue.code === 'SCAN_PATH_UNREADABLE');
2474
+ }
2475
+ /**
2476
+ * Count files by their own status.
2477
+ *
2478
+ * A refused path is NOT a scanned file. It used to be counted as one, which
2479
+ * made the zero-files refusal unreachable for exactly the tree it exists for:
2480
+ * `chmod 000` on the whole root gave `filesScanned: 1, filesPassed: 0`,
2481
+ * `status: warning`, exit 0 — nothing read, gate green. Kept out of every
2482
+ * status count too, or `filesWithWarnings` would exceed `filesScanned`.
2483
+ */
1764
2484
  function countFilesByStatus(results) {
2485
+ const scanned = results.filter((r) => !isUnreadablePathResult(r));
1765
2486
  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,
2487
+ filesScanned: scanned.length,
2488
+ filesPassed: scanned.filter((r) => r.status === 'success').length,
2489
+ filesWithWarnings: scanned.filter((r) => r.status === 'warning').length,
2490
+ filesWithErrors: scanned.filter((r) => r.status === 'error').length,
2491
+ pathsUnreadable: results.length - scanned.length,
1770
2492
  };
1771
2493
  }
1772
2494
  /**
@@ -1794,6 +2516,24 @@ function countFilesByStatus(results) {
1794
2516
  * also present in its owner's `issues`, so the nested list is a VIEW of records
1795
2517
  * already counted, not extra records. `audit-report-coherence.integration.test.ts`
1796
2518
  * pins that subset relation, because counting once is only correct while it holds.
2519
+ *
2520
+ * 🚨 **Zero files audited is an ERROR, not "an empty tree audits cleanly".**
2521
+ * `calculateOverallStatus([])` is `success`, so an existing directory holding
2522
+ * nothing auditable — moved plugins, a wrong subdirectory, an excluded tree, a
2523
+ * tree of files no lane recognises — published `status: success` beside
2524
+ * `filesScanned: 0`, and this command's own documentation tells CI to gate on
2525
+ * that `status`. Derived HERE, in the one builder both the directory lane
2526
+ * (`calculateSummary`) and the `--user` lane (`calculateHierarchicalSummary`)
2527
+ * pass through, so neither can publish a clean status over a zero denominator.
2528
+ * Through the shared mechanism in `run-integrity.ts`: one non-overridable
2529
+ * `RESOURCE_CHECK_BROKEN` at `error`, published under a top-level `issues` —
2530
+ * the claim is about the RUN, so it is not a `files[]` row and does not count
2531
+ * toward `filesScanned` — and counted in the header `issueCounts`, so the
2532
+ * identity becomes `issueCounts === Σ files[].issueCounts + Σ issues`.
2533
+ *
2534
+ * The exit code is NOT touched by this: `status` describes the findings and the
2535
+ * exit code describes whether the run completed, and this run completed. See
2536
+ * {@link createAuditCommand}.
1797
2537
  */
1798
2538
  function buildBaseSummary(results, startTime) {
1799
2539
  const issueCounts = { errors: 0, warnings: 0, info: 0 };
@@ -1804,11 +2544,24 @@ function buildBaseSummary(results, startTime) {
1804
2544
  issueCounts.info += counts.info;
1805
2545
  return { ...result, issueCounts: counts };
1806
2546
  });
2547
+ const summary = countFilesByStatus(entries);
2548
+ // The denominator is files READ: a tree whose every path was refused audited
2549
+ // nothing, and says so through the same refusal as an empty tree.
2550
+ 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'
2551
+ + ' to audit produces the same counts as a clean one. The path resolved to a tree'
2552
+ + ' holding no auditable file — usually a wrong subdirectory, plugins or skills that'
2553
+ + ' have moved, an excluded tree, files no audit lane recognises, or `--no-recursive`'
2554
+ + ' over a tree whose resources sit deeper. Point the command at the plugin,'
2555
+ + ' marketplace, registry or SKILL.md tree it should read.');
1807
2556
  return {
1808
2557
  entries,
1809
- status: calculateOverallStatus(entries),
1810
- summary: countFilesByStatus(entries),
1811
- issueCounts,
2558
+ // Every run-integrity finding is `error` by construction (invariant 2 of
2559
+ // `run-integrity.ts`), so a non-empty refusal IS the status; otherwise the
2560
+ // files decide it, as before.
2561
+ status: runIssues.length > 0 ? 'error' : calculateOverallStatus(entries),
2562
+ summary,
2563
+ issueCounts: sumSeverityCounts([issueCounts, countBySeverity(runIssues)]),
2564
+ ...(runIssues.length === 0 ? {} : { issues: [...runIssues] }),
1812
2565
  duration: `${Date.now() - startTime}ms`,
1813
2566
  };
1814
2567
  }
@@ -1817,7 +2570,7 @@ function buildBaseSummary(results, startTime) {
1817
2570
  */
1818
2571
  function calculateHierarchicalSummary(results, hierarchical, startTime, compatMap, verbose, root) {
1819
2572
  const { entries, ...base } = buildBaseSummary(results, startTime);
1820
- const withCompat = applyCompatMap(entries, compatMap);
2573
+ const withCompat = applyCompatMap(entries, compatMap, root);
1821
2574
  return {
1822
2575
  root,
1823
2576
  ...base,
@@ -1835,13 +2588,16 @@ function calculateHierarchicalSummary(results, hierarchical, startTime, compatMa
1835
2588
  /**
1836
2589
  * Log hierarchical summary to stderr
1837
2590
  */
1838
- function logHierarchicalSummary(results, logger) {
2591
+ function logHierarchicalSummary(results, runIssues, logger) {
1839
2592
  const status = calculateOverallStatus(results);
1840
2593
  const skillsWithIssues = countResultsWithFindings(results);
1841
2594
  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') {
2595
+ // Same rule as the per-file lane: the exit code follows `status`.
2596
+ const refused = logRunIntegrity(runIssues, logger);
2597
+ if (refused) {
2598
+ // Rendered above; there is no skill verdict to print over zero skills.
2599
+ }
2600
+ else if (status === 'error') {
1845
2601
  const errorCount = results.filter((r) => r.status === 'error').length;
1846
2602
  logger.error(`Audit found ${errorCount} skill(s) with errors (${totalSkills} scanned, ${skillsWithIssues} with issues)`);
1847
2603
  }
@@ -1858,6 +2614,6 @@ function logHierarchicalSummary(results, logger) {
1858
2614
  logger.info(`Audit successful: ${totalSkills} skill(s) passed`);
1859
2615
  }
1860
2616
  renderAuditFooter(results, logger);
1861
- process.exit(0);
2617
+ process.exit(refused || status === 'error' ? ExitCode.FINDINGS : ExitCode.OK);
1862
2618
  }
1863
2619
  //# sourceMappingURL=audit.js.map