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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (422) hide show
  1. package/README.md +4 -5
  2. package/dist/bin/vat +34 -19
  3. package/dist/bin/vat.js +34 -19
  4. package/dist/bin/vat.js.map +1 -1
  5. package/dist/bin.js +44 -158
  6. package/dist/bin.js.map +1 -1
  7. package/dist/command-loaders.d.ts +2 -2
  8. package/dist/command-loaders.d.ts.map +1 -1
  9. package/dist/command-loaders.js +4 -2
  10. package/dist/command-loaders.js.map +1 -1
  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/install-path.d.ts +36 -0
  18. package/dist/commands/agent/install-path.d.ts.map +1 -0
  19. package/dist/commands/agent/install-path.js +44 -0
  20. package/dist/commands/agent/install-path.js.map +1 -0
  21. package/dist/commands/agent/install.d.ts.map +1 -1
  22. package/dist/commands/agent/install.js +22 -10
  23. package/dist/commands/agent/install.js.map +1 -1
  24. package/dist/commands/agent/installed.d.ts.map +1 -1
  25. package/dist/commands/agent/installed.js +22 -12
  26. package/dist/commands/agent/installed.js.map +1 -1
  27. package/dist/commands/agent/list.d.ts.map +1 -1
  28. package/dist/commands/agent/list.js +2 -1
  29. package/dist/commands/agent/list.js.map +1 -1
  30. package/dist/commands/agent/run.d.ts.map +1 -1
  31. package/dist/commands/agent/run.js +2 -1
  32. package/dist/commands/agent/run.js.map +1 -1
  33. package/dist/commands/agent/uninstall.d.ts.map +1 -1
  34. package/dist/commands/agent/uninstall.js +13 -6
  35. package/dist/commands/agent/uninstall.js.map +1 -1
  36. package/dist/commands/agent/validate.d.ts.map +1 -1
  37. package/dist/commands/agent/validate.js +3 -2
  38. package/dist/commands/agent/validate.js.map +1 -1
  39. package/dist/commands/ard/emit.d.ts +153 -0
  40. package/dist/commands/ard/emit.d.ts.map +1 -0
  41. package/dist/commands/ard/emit.js +276 -0
  42. package/dist/commands/ard/emit.js.map +1 -0
  43. package/dist/commands/ard/index.d.ts +12 -0
  44. package/dist/commands/ard/index.d.ts.map +1 -0
  45. package/dist/commands/ard/index.js +80 -0
  46. package/dist/commands/ard/index.js.map +1 -0
  47. package/dist/commands/ard/surfaces.d.ts +56 -0
  48. package/dist/commands/ard/surfaces.d.ts.map +1 -0
  49. package/dist/commands/ard/surfaces.js +150 -0
  50. package/dist/commands/ard/surfaces.js.map +1 -0
  51. package/dist/commands/audit/distributed-tree.d.ts.map +1 -1
  52. package/dist/commands/audit/distributed-tree.js +9 -6
  53. package/dist/commands/audit/distributed-tree.js.map +1 -1
  54. package/dist/commands/audit/scan-population.d.ts +158 -0
  55. package/dist/commands/audit/scan-population.d.ts.map +1 -0
  56. package/dist/commands/audit/scan-population.js +292 -0
  57. package/dist/commands/audit/scan-population.js.map +1 -0
  58. package/dist/commands/audit-settings.d.ts.map +1 -1
  59. package/dist/commands/audit-settings.js +6 -5
  60. package/dist/commands/audit-settings.js.map +1 -1
  61. package/dist/commands/audit.d.ts +78 -9
  62. package/dist/commands/audit.d.ts.map +1 -1
  63. package/dist/commands/audit.js +853 -473
  64. package/dist/commands/audit.js.map +1 -1
  65. package/dist/commands/build.d.ts.map +1 -1
  66. package/dist/commands/build.js +24 -24
  67. package/dist/commands/build.js.map +1 -1
  68. package/dist/commands/cache/clear.d.ts.map +1 -1
  69. package/dist/commands/cache/clear.js +17 -10
  70. package/dist/commands/cache/clear.js.map +1 -1
  71. package/dist/commands/claude/budget.d.ts +136 -24
  72. package/dist/commands/claude/budget.d.ts.map +1 -1
  73. package/dist/commands/claude/budget.js +152 -57
  74. package/dist/commands/claude/budget.js.map +1 -1
  75. package/dist/commands/claude/context.d.ts.map +1 -1
  76. package/dist/commands/claude/context.js +4 -3
  77. package/dist/commands/claude/context.js.map +1 -1
  78. package/dist/commands/claude/marketplace/changelog-utils.d.ts +1 -1
  79. package/dist/commands/claude/marketplace/changelog-utils.d.ts.map +1 -1
  80. package/dist/commands/claude/marketplace/changelog-utils.js +1 -2
  81. package/dist/commands/claude/marketplace/changelog-utils.js.map +1 -1
  82. package/dist/commands/claude/marketplace/git-publish.d.ts.map +1 -1
  83. package/dist/commands/claude/marketplace/git-publish.js +3 -2
  84. package/dist/commands/claude/marketplace/git-publish.js.map +1 -1
  85. package/dist/commands/claude/marketplace/license-utils.d.ts.map +1 -1
  86. package/dist/commands/claude/marketplace/license-utils.js +0 -1
  87. package/dist/commands/claude/marketplace/license-utils.js.map +1 -1
  88. package/dist/commands/claude/marketplace/publish-tree.d.ts.map +1 -1
  89. package/dist/commands/claude/marketplace/publish-tree.js +0 -6
  90. package/dist/commands/claude/marketplace/publish-tree.js.map +1 -1
  91. package/dist/commands/claude/marketplace/publish.d.ts.map +1 -1
  92. package/dist/commands/claude/marketplace/publish.js +2 -1
  93. package/dist/commands/claude/marketplace/publish.js.map +1 -1
  94. package/dist/commands/claude/marketplace/validate.d.ts +55 -22
  95. package/dist/commands/claude/marketplace/validate.d.ts.map +1 -1
  96. package/dist/commands/claude/marketplace/validate.js +392 -77
  97. package/dist/commands/claude/marketplace/validate.js.map +1 -1
  98. package/dist/commands/claude/org/api-keys.d.ts +0 -3
  99. package/dist/commands/claude/org/api-keys.d.ts.map +1 -1
  100. package/dist/commands/claude/org/api-keys.js +2 -1
  101. package/dist/commands/claude/org/api-keys.js.map +1 -1
  102. package/dist/commands/claude/org/helpers.d.ts +17 -22
  103. package/dist/commands/claude/org/helpers.d.ts.map +1 -1
  104. package/dist/commands/claude/org/helpers.js +6 -22
  105. package/dist/commands/claude/org/helpers.js.map +1 -1
  106. package/dist/commands/claude/org/invites.d.ts +0 -3
  107. package/dist/commands/claude/org/invites.d.ts.map +1 -1
  108. package/dist/commands/claude/org/invites.js +3 -2
  109. package/dist/commands/claude/org/invites.js.map +1 -1
  110. package/dist/commands/claude/org/skills.d.ts +103 -4
  111. package/dist/commands/claude/org/skills.d.ts.map +1 -1
  112. package/dist/commands/claude/org/skills.js +331 -123
  113. package/dist/commands/claude/org/skills.js.map +1 -1
  114. package/dist/commands/claude/org/users.d.ts +0 -3
  115. package/dist/commands/claude/org/users.d.ts.map +1 -1
  116. package/dist/commands/claude/org/users.js +3 -2
  117. package/dist/commands/claude/org/users.js.map +1 -1
  118. package/dist/commands/claude/org/workspaces.d.ts +0 -3
  119. package/dist/commands/claude/org/workspaces.d.ts.map +1 -1
  120. package/dist/commands/claude/org/workspaces.js +6 -5
  121. package/dist/commands/claude/org/workspaces.js.map +1 -1
  122. package/dist/commands/claude/plugin/build.d.ts +8 -0
  123. package/dist/commands/claude/plugin/build.d.ts.map +1 -1
  124. package/dist/commands/claude/plugin/build.js +28 -33
  125. package/dist/commands/claude/plugin/build.js.map +1 -1
  126. package/dist/commands/claude/plugin/helpers.d.ts.map +1 -1
  127. package/dist/commands/claude/plugin/helpers.js +0 -6
  128. package/dist/commands/claude/plugin/helpers.js.map +1 -1
  129. package/dist/commands/claude/plugin/install.d.ts +12 -0
  130. package/dist/commands/claude/plugin/install.d.ts.map +1 -1
  131. package/dist/commands/claude/plugin/install.js +71 -60
  132. package/dist/commands/claude/plugin/install.js.map +1 -1
  133. package/dist/commands/claude/plugin/list.d.ts.map +1 -1
  134. package/dist/commands/claude/plugin/list.js +3 -2
  135. package/dist/commands/claude/plugin/list.js.map +1 -1
  136. package/dist/commands/claude/plugin/plugin-changelog.d.ts.map +1 -1
  137. package/dist/commands/claude/plugin/plugin-changelog.js +0 -2
  138. package/dist/commands/claude/plugin/plugin-changelog.js.map +1 -1
  139. package/dist/commands/claude/plugin/plugin-files.d.ts.map +1 -1
  140. package/dist/commands/claude/plugin/plugin-files.js +0 -3
  141. package/dist/commands/claude/plugin/plugin-files.js.map +1 -1
  142. package/dist/commands/claude/plugin/plugin-validators.d.ts.map +1 -1
  143. package/dist/commands/claude/plugin/plugin-validators.js +3 -6
  144. package/dist/commands/claude/plugin/plugin-validators.js.map +1 -1
  145. package/dist/commands/claude/plugin/tree-copy.d.ts +73 -0
  146. package/dist/commands/claude/plugin/tree-copy.d.ts.map +1 -1
  147. package/dist/commands/claude/plugin/tree-copy.js +221 -15
  148. package/dist/commands/claude/plugin/tree-copy.js.map +1 -1
  149. package/dist/commands/claude/plugin/uninstall.d.ts.map +1 -1
  150. package/dist/commands/claude/plugin/uninstall.js +4 -4
  151. package/dist/commands/claude/plugin/uninstall.js.map +1 -1
  152. package/dist/commands/consistency-check.d.ts +9 -13
  153. package/dist/commands/consistency-check.d.ts.map +1 -1
  154. package/dist/commands/consistency-check.js +91 -81
  155. package/dist/commands/consistency-check.js.map +1 -1
  156. package/dist/commands/corpus/index.d.ts.map +1 -1
  157. package/dist/commands/corpus/index.js +3 -0
  158. package/dist/commands/corpus/index.js.map +1 -1
  159. package/dist/commands/corpus/report.d.ts +0 -1
  160. package/dist/commands/corpus/report.d.ts.map +1 -1
  161. package/dist/commands/corpus/report.js +1 -3
  162. package/dist/commands/corpus/report.js.map +1 -1
  163. package/dist/commands/corpus/runner.d.ts +58 -1
  164. package/dist/commands/corpus/runner.d.ts.map +1 -1
  165. package/dist/commands/corpus/runner.js +92 -21
  166. package/dist/commands/corpus/runner.js.map +1 -1
  167. package/dist/commands/corpus/scan.d.ts.map +1 -1
  168. package/dist/commands/corpus/scan.js +2 -4
  169. package/dist/commands/corpus/scan.js.map +1 -1
  170. package/dist/commands/corpus/seed.d.ts +4 -4
  171. package/dist/commands/corpus/seed.d.ts.map +1 -1
  172. package/dist/commands/corpus/seed.js +1 -3
  173. package/dist/commands/corpus/seed.js.map +1 -1
  174. package/dist/commands/doctor.d.ts +14 -0
  175. package/dist/commands/doctor.d.ts.map +1 -1
  176. package/dist/commands/doctor.js +147 -51
  177. package/dist/commands/doctor.js.map +1 -1
  178. package/dist/commands/inventory.d.ts.map +1 -1
  179. package/dist/commands/inventory.js +6 -7
  180. package/dist/commands/inventory.js.map +1 -1
  181. package/dist/commands/mcp/list-collections.d.ts.map +1 -1
  182. package/dist/commands/mcp/list-collections.js +2 -1
  183. package/dist/commands/mcp/list-collections.js.map +1 -1
  184. package/dist/commands/mcp/serve.d.ts.map +1 -1
  185. package/dist/commands/mcp/serve.js +3 -2
  186. package/dist/commands/mcp/serve.js.map +1 -1
  187. package/dist/commands/okf/index.d.ts +11 -0
  188. package/dist/commands/okf/index.d.ts.map +1 -0
  189. package/dist/commands/okf/index.js +114 -0
  190. package/dist/commands/okf/index.js.map +1 -0
  191. package/dist/commands/okf/validate.d.ts +191 -0
  192. package/dist/commands/okf/validate.d.ts.map +1 -0
  193. package/dist/commands/okf/validate.js +190 -0
  194. package/dist/commands/okf/validate.js.map +1 -0
  195. package/dist/commands/phase-utils.d.ts +30 -13
  196. package/dist/commands/phase-utils.d.ts.map +1 -1
  197. package/dist/commands/phase-utils.js +56 -19
  198. package/dist/commands/phase-utils.js.map +1 -1
  199. package/dist/commands/positional-args.d.ts.map +1 -1
  200. package/dist/commands/positional-args.js +2 -1
  201. package/dist/commands/positional-args.js.map +1 -1
  202. package/dist/commands/rag/clear-command.d.ts.map +1 -1
  203. package/dist/commands/rag/clear-command.js +2 -1
  204. package/dist/commands/rag/clear-command.js.map +1 -1
  205. package/dist/commands/rag/index-command.d.ts +27 -2
  206. package/dist/commands/rag/index-command.d.ts.map +1 -1
  207. package/dist/commands/rag/index-command.js +48 -8
  208. package/dist/commands/rag/index-command.js.map +1 -1
  209. package/dist/commands/rag/index.d.ts.map +1 -1
  210. package/dist/commands/rag/index.js +2 -0
  211. package/dist/commands/rag/index.js.map +1 -1
  212. package/dist/commands/rag/query-command.d.ts.map +1 -1
  213. package/dist/commands/rag/query-command.js +2 -1
  214. package/dist/commands/rag/query-command.js.map +1 -1
  215. package/dist/commands/rag/stats-command.d.ts.map +1 -1
  216. package/dist/commands/rag/stats-command.js +2 -1
  217. package/dist/commands/rag/stats-command.js.map +1 -1
  218. package/dist/commands/resources/check-progress.d.ts +3 -0
  219. package/dist/commands/resources/check-progress.d.ts.map +1 -1
  220. package/dist/commands/resources/check-progress.js +11 -2
  221. package/dist/commands/resources/check-progress.js.map +1 -1
  222. package/dist/commands/resources/check-supervisor.d.ts.map +1 -1
  223. package/dist/commands/resources/check-supervisor.js +14 -7
  224. package/dist/commands/resources/check-supervisor.js.map +1 -1
  225. package/dist/commands/resources/check.d.ts +168 -8
  226. package/dist/commands/resources/check.d.ts.map +1 -1
  227. package/dist/commands/resources/check.js +222 -118
  228. package/dist/commands/resources/check.js.map +1 -1
  229. package/dist/commands/resources/index.d.ts.map +1 -1
  230. package/dist/commands/resources/index.js +64 -29
  231. package/dist/commands/resources/index.js.map +1 -1
  232. package/dist/commands/resources/query.d.ts +18 -1
  233. package/dist/commands/resources/query.d.ts.map +1 -1
  234. package/dist/commands/resources/query.js +33 -12
  235. package/dist/commands/resources/query.js.map +1 -1
  236. package/dist/commands/resources/scan.d.ts.map +1 -1
  237. package/dist/commands/resources/scan.js +4 -8
  238. package/dist/commands/resources/scan.js.map +1 -1
  239. package/dist/commands/resources/validate.d.ts +30 -1
  240. package/dist/commands/resources/validate.d.ts.map +1 -1
  241. package/dist/commands/resources/validate.js +116 -14
  242. package/dist/commands/resources/validate.js.map +1 -1
  243. package/dist/commands/skill/review-checklist.d.ts +3 -2
  244. package/dist/commands/skill/review-checklist.d.ts.map +1 -1
  245. package/dist/commands/skill/review-checklist.js +4 -2
  246. package/dist/commands/skill/review-checklist.js.map +1 -1
  247. package/dist/commands/skill/review.d.ts +247 -0
  248. package/dist/commands/skill/review.d.ts.map +1 -1
  249. package/dist/commands/skill/review.js +98 -61
  250. package/dist/commands/skill/review.js.map +1 -1
  251. package/dist/commands/skill/test/configure.d.ts.map +1 -1
  252. package/dist/commands/skill/test/configure.js +0 -1
  253. package/dist/commands/skill/test/configure.js.map +1 -1
  254. package/dist/commands/skill/test/run.d.ts +12 -6
  255. package/dist/commands/skill/test/run.d.ts.map +1 -1
  256. package/dist/commands/skill/test/run.js +137 -67
  257. package/dist/commands/skill/test/run.js.map +1 -1
  258. package/dist/commands/skills/build.d.ts +69 -2
  259. package/dist/commands/skills/build.d.ts.map +1 -1
  260. package/dist/commands/skills/build.js +128 -42
  261. package/dist/commands/skills/build.js.map +1 -1
  262. package/dist/commands/skills/install.d.ts +2 -1
  263. package/dist/commands/skills/install.d.ts.map +1 -1
  264. package/dist/commands/skills/install.js +20 -30
  265. package/dist/commands/skills/install.js.map +1 -1
  266. package/dist/commands/skills/list.d.ts +2 -1
  267. package/dist/commands/skills/list.d.ts.map +1 -1
  268. package/dist/commands/skills/list.js +44 -26
  269. package/dist/commands/skills/list.js.map +1 -1
  270. package/dist/commands/skills/package.d.ts +1 -1
  271. package/dist/commands/skills/package.d.ts.map +1 -1
  272. package/dist/commands/skills/package.js +6 -8
  273. package/dist/commands/skills/package.js.map +1 -1
  274. package/dist/commands/skills/scope-guard.d.ts.map +1 -1
  275. package/dist/commands/skills/scope-guard.js +2 -4
  276. package/dist/commands/skills/scope-guard.js.map +1 -1
  277. package/dist/commands/skills/shared.d.ts.map +1 -1
  278. package/dist/commands/skills/shared.js +2 -4
  279. package/dist/commands/skills/shared.js.map +1 -1
  280. package/dist/commands/skills/skill-discovery.d.ts +40 -1
  281. package/dist/commands/skills/skill-discovery.d.ts.map +1 -1
  282. package/dist/commands/skills/skill-discovery.js +32 -4
  283. package/dist/commands/skills/skill-discovery.js.map +1 -1
  284. package/dist/commands/skills/source-resolvers.d.ts +15 -0
  285. package/dist/commands/skills/source-resolvers.d.ts.map +1 -1
  286. package/dist/commands/skills/source-resolvers.js +25 -4
  287. package/dist/commands/skills/source-resolvers.js.map +1 -1
  288. package/dist/commands/skills/validate-command.d.ts.map +1 -1
  289. package/dist/commands/skills/validate-command.js +11 -3
  290. package/dist/commands/skills/validate-command.js.map +1 -1
  291. package/dist/commands/skills/validate.d.ts +66 -7
  292. package/dist/commands/skills/validate.d.ts.map +1 -1
  293. package/dist/commands/skills/validate.js +133 -29
  294. package/dist/commands/skills/validate.js.map +1 -1
  295. package/dist/commands/validate.d.ts +1 -1
  296. package/dist/commands/validate.js +8 -6
  297. package/dist/commands/validate.js.map +1 -1
  298. package/dist/commands/verify.d.ts +102 -5
  299. package/dist/commands/verify.d.ts.map +1 -1
  300. package/dist/commands/verify.js +246 -69
  301. package/dist/commands/verify.js.map +1 -1
  302. package/dist/index.d.ts +0 -1
  303. package/dist/index.d.ts.map +1 -1
  304. package/dist/index.js +5 -1
  305. package/dist/index.js.map +1 -1
  306. package/dist/report-schemas.d.ts +45 -0
  307. package/dist/report-schemas.d.ts.map +1 -0
  308. package/dist/report-schemas.js +102 -0
  309. package/dist/report-schemas.js.map +1 -0
  310. package/dist/skill-resolution/packaging-config.d.ts +49 -5
  311. package/dist/skill-resolution/packaging-config.d.ts.map +1 -1
  312. package/dist/skill-resolution/packaging-config.js +59 -19
  313. package/dist/skill-resolution/packaging-config.js.map +1 -1
  314. package/dist/skill-resolution/resolve-skill-reference.d.ts.map +1 -1
  315. package/dist/skill-resolution/resolve-skill-reference.js +8 -6
  316. package/dist/skill-resolution/resolve-skill-reference.js.map +1 -1
  317. package/dist/utils/agent-discovery.d.ts.map +1 -1
  318. package/dist/utils/agent-discovery.js +22 -11
  319. package/dist/utils/agent-discovery.js.map +1 -1
  320. package/dist/utils/agent-runner.d.ts.map +1 -1
  321. package/dist/utils/agent-runner.js +0 -1
  322. package/dist/utils/agent-runner.js.map +1 -1
  323. package/dist/utils/command-error.d.ts +87 -55
  324. package/dist/utils/command-error.d.ts.map +1 -1
  325. package/dist/utils/command-error.js +113 -64
  326. package/dist/utils/command-error.js.map +1 -1
  327. package/dist/utils/config-loader.d.ts +2 -1
  328. package/dist/utils/config-loader.d.ts.map +1 -1
  329. package/dist/utils/config-loader.js +3 -6
  330. package/dist/utils/config-loader.js.map +1 -1
  331. package/dist/utils/context-budget-issues.d.ts +1 -1
  332. package/dist/utils/context-budget-issues.js +1 -1
  333. package/dist/utils/corpus-target.d.ts +5 -27
  334. package/dist/utils/corpus-target.d.ts.map +1 -1
  335. package/dist/utils/corpus-target.js +7 -33
  336. package/dist/utils/corpus-target.js.map +1 -1
  337. package/dist/utils/edge-lens-evaluation.d.ts +83 -0
  338. package/dist/utils/edge-lens-evaluation.d.ts.map +1 -0
  339. package/dist/utils/edge-lens-evaluation.js +110 -0
  340. package/dist/utils/edge-lens-evaluation.js.map +1 -0
  341. package/dist/utils/help-loader.d.ts.map +1 -1
  342. package/dist/utils/help-loader.js +0 -1
  343. package/dist/utils/help-loader.js.map +1 -1
  344. package/dist/utils/issue-rendering.d.ts +2 -2
  345. package/dist/utils/issue-rendering.js +2 -2
  346. package/dist/utils/optional-backend.d.ts +5 -4
  347. package/dist/utils/optional-backend.d.ts.map +1 -1
  348. package/dist/utils/optional-backend.js +7 -5
  349. package/dist/utils/optional-backend.js.map +1 -1
  350. package/dist/utils/output.d.ts +9 -0
  351. package/dist/utils/output.d.ts.map +1 -1
  352. package/dist/utils/output.js +16 -0
  353. package/dist/utils/output.js.map +1 -1
  354. package/dist/utils/package-json.d.ts +24 -0
  355. package/dist/utils/package-json.d.ts.map +1 -0
  356. package/dist/utils/package-json.js +49 -0
  357. package/dist/utils/package-json.js.map +1 -0
  358. package/dist/utils/project-root-policy.d.ts +20 -0
  359. package/dist/utils/project-root-policy.d.ts.map +1 -1
  360. package/dist/utils/project-root-policy.js +30 -0
  361. package/dist/utils/project-root-policy.js.map +1 -1
  362. package/dist/utils/projection-query.d.ts +41 -4
  363. package/dist/utils/projection-query.d.ts.map +1 -1
  364. package/dist/utils/projection-query.js +44 -10
  365. package/dist/utils/projection-query.js.map +1 -1
  366. package/dist/utils/projection-store.d.ts +41 -15
  367. package/dist/utils/projection-store.d.ts.map +1 -1
  368. package/dist/utils/projection-store.js +65 -18
  369. package/dist/utils/projection-store.js.map +1 -1
  370. package/dist/utils/repeatable-option.d.ts +37 -0
  371. package/dist/utils/repeatable-option.d.ts.map +1 -0
  372. package/dist/utils/repeatable-option.js +39 -0
  373. package/dist/utils/repeatable-option.js.map +1 -0
  374. package/dist/utils/resource-loader.d.ts +4 -3
  375. package/dist/utils/resource-loader.d.ts.map +1 -1
  376. package/dist/utils/resource-loader.js +63 -32
  377. package/dist/utils/resource-loader.js.map +1 -1
  378. package/dist/utils/root-argv.d.ts +53 -0
  379. package/dist/utils/root-argv.d.ts.map +1 -0
  380. package/dist/utils/root-argv.js +215 -0
  381. package/dist/utils/root-argv.js.map +1 -0
  382. package/dist/utils/run-integrity.d.ts +95 -0
  383. package/dist/utils/run-integrity.d.ts.map +1 -0
  384. package/dist/utils/run-integrity.js +102 -0
  385. package/dist/utils/run-integrity.js.map +1 -0
  386. package/dist/utils/skill-packaging-config.d.ts +27 -0
  387. package/dist/utils/skill-packaging-config.d.ts.map +1 -1
  388. package/dist/utils/skill-packaging-config.js +30 -0
  389. package/dist/utils/skill-packaging-config.js.map +1 -1
  390. package/dist/utils/sqlite-experimental-warning.d.ts +69 -0
  391. package/dist/utils/sqlite-experimental-warning.d.ts.map +1 -0
  392. package/dist/utils/sqlite-experimental-warning.js +144 -0
  393. package/dist/utils/sqlite-experimental-warning.js.map +1 -0
  394. package/dist/utils/url-redact.d.ts.map +1 -1
  395. package/dist/utils/url-redact.js +4 -6
  396. package/dist/utils/url-redact.js.map +1 -1
  397. package/dist/utils/user-context-scanner.d.ts +8 -0
  398. package/dist/utils/user-context-scanner.d.ts.map +1 -1
  399. package/dist/utils/user-context-scanner.js +4 -2
  400. package/dist/utils/user-context-scanner.js.map +1 -1
  401. package/dist/utils/validate-help-files.d.ts.map +1 -1
  402. package/dist/utils/validate-help-files.js +12 -6
  403. package/dist/utils/validate-help-files.js.map +1 -1
  404. package/dist/utils/vat-bin-path.d.ts.map +1 -1
  405. package/dist/utils/vat-bin-path.js +0 -1
  406. package/dist/utils/vat-bin-path.js.map +1 -1
  407. package/dist/version.d.ts.map +1 -1
  408. package/dist/version.js +1 -3
  409. package/dist/version.js.map +1 -1
  410. package/docs/audit.md +141 -37
  411. package/docs/doctor.md +7 -6
  412. package/docs/index.md +19 -11
  413. package/docs/rag.md +3 -2
  414. package/docs/resources.md +5 -2
  415. package/docs/skill-test.md +10 -9
  416. package/docs/skills.md +53 -69
  417. package/package.json +24 -29
  418. package/schemas/ard-emit.json +149 -0
  419. package/schemas/claude-budget.json +197 -0
  420. package/schemas/okf-validate.json +170 -0
  421. package/schemas/resources-check.json +186 -0
  422. package/schemas/skill-review.json +230 -0
@@ -5,22 +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 { crawlAndResolveRegistry, detectDeclaredButMissing, detectMarketplacePluginSourceMissing, resetPackagingRegistryCache, detectPresentButUndeclared, detectReferenceTargetMissing, detectResourceFormat, enumerateSurfaces, materializeIssue, 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';
9
9
  import { analyzeCompatibility, checkSettingsCompatibility, detectSkillClaudePluginNameMismatch, crawlSkillLinkRegistry, extractClaudeMarketplaceInventory, extractClaudePluginInventory, getClaudeUserPaths, readEffectiveSettings, validatePlugin, } from '@vibe-agent-toolkit/claude-marketplace';
10
10
  import { detectFormat } from '@vibe-agent-toolkit/discovery';
11
- import { calculateValidationStatus, countBySeverity, } from '@vibe-agent-toolkit/schema';
12
- import { findProjectRoot, isAbsolutePath, isFilesystemAccessError, issueLocation, 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';
13
15
  import { gitFindRoot, isGitUrl, parseGitUrl, } from '@vibe-agent-toolkit/utils/git';
14
16
  import { Command } from 'commander';
15
- import picomatch from 'picomatch';
16
17
  import { resetSkillDiscoveryCache, resolveProjectDeclaredEvalSuites, resolveSkillPackagingConfig, stripValidationAllowForDisplay, } from '../skill-resolution/packaging-config.js';
17
- import { handleCommandError } from '../utils/command-error.js';
18
+ import { handleCommandError, handleExpectedFailure } from '../utils/command-error.js';
18
19
  import { ConfigLoadError, loadConfig, resetLoadedConfigCache, } from '../utils/config-loader.js';
19
- import { countCollapsedFindings, formatCollapsedFindingsHint, formatIssueLines, formatSeverityBreakdown, issuesToRenderAtVerbosity, } from '../utils/issue-rendering.js';
20
+ import { countCollapsedFindings, formatCollapsedFindingsHint, formatIssueLines, formatSeverityBreakdown, issuesToRenderAtVerbosity, sumSeverityCounts, } from '../utils/issue-rendering.js';
20
21
  import { resolveIssueSeverity } from '../utils/issue-severity.js';
21
22
  import { createLogger } from '../utils/logger.js';
22
23
  import { writeYamlOutput } from '../utils/output.js';
23
24
  import { relativizePathEntries } from '../utils/relativize-paths.js';
25
+ import { nothingCheckedFinding } from '../utils/run-integrity.js';
24
26
  import { mergeSkillPackagingConfig } from '../utils/skill-packaging-config.js';
25
27
  import { renderSkillQualityFooter } from '../utils/skill-quality-footer.js';
26
28
  import { computeConfigVerdicts } from '../utils/verdict-helpers.js';
@@ -28,13 +30,13 @@ import { crawlOwnsSubtree, distributedTreeFindings, getOrCreateGitTracker, gitTr
28
30
  import { withClonedRepo } from './audit/git-url-clone.js';
29
31
  import { buildHierarchicalOutput } from './audit/hierarchical-output.js';
30
32
  import { renderProvenanceHeader, rewritePathsInResults, } from './audit/provenance.js';
33
+ import { enumerateAuditPopulation, resolveProjectExcludes, VAT_CONFIG_FILENAME, } from './audit/scan-population.js';
31
34
  import { createAuditSettingsCommand } from './audit-settings.js';
32
- import { discoverSkillsFromConfig } from './skills/skill-discovery.js';
35
+ import { discoverSkillsFromConfig, SKILLS_INCLUDE_REMEDY, } from './skills/skill-discovery.js';
33
36
  /** Resource type constant for agent skills, avoiding duplicate string literals. */
34
37
  const RESOURCE_TYPE_AGENT_SKILL = 'agent-skill';
35
38
  /** Resource type constant for Claude plugins, avoiding duplicate string literals. */
36
39
  const RESOURCE_TYPE_CLAUDE_PLUGIN = 'claude-plugin';
37
- const VAT_CONFIG_FILENAME = 'vibe-agent-toolkit.config.yaml';
38
40
  /**
39
41
  * Build config-aware context for a single VAT project at `scanRoot`.
40
42
  *
@@ -51,9 +53,8 @@ const VAT_CONFIG_FILENAME = 'vibe-agent-toolkit.config.yaml';
51
53
  *
52
54
  * Returns `null` if `scanRoot` has no config or the config has no skills.
53
55
  */
54
- async function buildVATProjectContext(scanRoot, logger) {
56
+ async function buildVATProjectContext(scanRoot, logger, locationRoot) {
55
57
  const configPath = safePath.join(scanRoot, VAT_CONFIG_FILENAME);
56
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- scanRoot is a controlled parameter
57
58
  if (!fs.existsSync(configPath)) {
58
59
  return null;
59
60
  }
@@ -69,7 +70,7 @@ async function buildVATProjectContext(scanRoot, logger) {
69
70
  return null;
70
71
  const skillConfigs = new Map();
71
72
  try {
72
- const discovered = await discoverSkillsFromConfig(config.skills, scanRoot);
73
+ const discovered = await discoverSkillsFromConfig(config.skills, scanRoot, degradingDiscovery(scanRoot, logger, locationRoot));
73
74
  const { defaults, config: perSkillConfig } = config.skills;
74
75
  for (const skill of discovered) {
75
76
  const absPath = safePath.resolve(skill.sourcePath);
@@ -79,7 +80,16 @@ async function buildVATProjectContext(scanRoot, logger) {
79
80
  logger.debug(`Config-aware audit: found ${discovered.length} skill(s) in ${scanRoot}`);
80
81
  }
81
82
  catch (error) {
82
- 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);
83
93
  }
84
94
  if (skillConfigs.size === 0) {
85
95
  return null;
@@ -207,22 +217,38 @@ function packagingResultToValidationResult(skillPath, result, configTargets, loc
207
217
  * no governing config OR when the one found could not be loaded.
208
218
  */
209
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;
210
226
  let fullConfig;
211
227
  try {
212
- fullConfig = await resolveSkillPackagingConfig(skillPath);
228
+ fullConfig = await resolveSkillPackagingConfig(skillPath, degradingDiscovery(configRoot, logger, locationRoot));
213
229
  }
214
230
  catch (err) {
215
- if (!(err instanceof ConfigLoadError))
216
- throw err;
217
- recordUnloadableConfig(err, logger, locationRoot);
218
- return 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;
219
244
  }
220
245
  return fullConfig === null ? null : stripValidationAllowForDisplay(fullConfig);
221
246
  }
222
247
  /**
223
- * Configs this run has already reported as unloadable, keyed by the config's own
224
- * project root, each holding the synthetic result that carries its finding into
225
- * the report.
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.
226
252
  *
227
253
  * 🚨 MODULE-scoped, and cleared by {@link resetAuditCaches}, exactly like
228
254
  * `config-loader.ts`'s `warnedConfigPaths`. It used to be a `Set` threaded as a
@@ -235,15 +261,45 @@ async function resolveGoverningConfig(skillPath, logger, locationRoot) {
235
261
  * while the lane that actually repeated was never exercised. A run-scoped ledger
236
262
  * cannot be re-created per call by mistake; a parameter can.
237
263
  *
238
- * ⚠️ Keyed on `ConfigLoadError.projectRoot`, NOT on `err.message`. The old key
239
- * was the message with a comment claiming it "names the offending config path" —
240
- * true only for a Zod failure, which is what the fixture happened to be. A YAML
241
- * SYNTAX error is thrown out of `yaml.parse` and wrapped as `Failed to load
242
- * config: ${message}`, and `YAMLParseError.message` carries no filename at all,
243
- * so two nested projects with the same copy-pasted broken config collapsed to ONE
244
- * warning naming NEITHER.
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.
245
272
  */
246
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
+ }
247
303
  /**
248
304
  * Warn about — and file a finding for — a governing config this run could not
249
305
  * load, once per config however many skills it governs.
@@ -253,10 +309,10 @@ const unloadableConfigResults = new Map();
253
309
  * @param locationRoot - The run's anchor base for the emitted location
254
310
  */
255
311
  function recordUnloadableConfig(err, logger, locationRoot) {
256
- if (unloadableConfigResults.has(err.projectRoot))
257
- return;
258
312
  const configPath = safePath.join(err.projectRoot, VAT_CONFIG_FILENAME);
259
- unloadableConfigResults.set(err.projectRoot, unreadablePathResult(configPath, err, locationRoot));
313
+ if (unloadableConfigResults.has(configPath))
314
+ return;
315
+ unloadableConfigResults.set(configPath, unreadablePathResult(configPath, err, locationRoot));
260
316
  // The filesystem answer changes the NOUN, because the two conditions need
261
317
  // different actions from the operator: `chmod`/ownership on one side, an edit
262
318
  // to the file on the other. Collapsing them into one sentence was the whole
@@ -265,6 +321,102 @@ function recordUnloadableConfig(err, logger, locationRoot) {
265
321
  logger.warn(`Ignoring ${why} config at ${configPath}; skills it governs are validated`
266
322
  + ` config-free: ${err.message}`);
267
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
+ }
268
420
  /**
269
421
  * The synthetic results for every config this run degraded past, so the report
270
422
  * carries what the stderr warning says.
@@ -275,6 +427,23 @@ function recordUnloadableConfig(err, logger, locationRoot) {
275
427
  function unloadableConfigFindings() {
276
428
  return [...unloadableConfigResults.values()];
277
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
+ }
278
447
  async function validateSingleSkill(skillPath, options, logger, locationRoot, crawlTree, isVATGenerated) {
279
448
  // Config-aware validation: walk UP to the skill's nearest-ancestor
280
449
  // vibe-agent-toolkit.config.yaml and apply the skill's packaging block. A
@@ -295,10 +464,18 @@ async function validateSingleSkill(skillPath, options, logger, locationRoot, cra
295
464
  // The project's declared eval suites, memoized per config root, so this lane
296
465
  // predicts the same bundle the packager produces: no skill's report counts a
297
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);
298
471
  const sharedCtx = {
299
- registry: await crawlAndResolveRegistry(projectRoot),
472
+ registry: await crawlAndResolveRegistry(projectRoot, { unreadable }),
473
+ unreadable,
300
474
  locationRoot,
301
- 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(),
302
479
  };
303
480
  if (gitTracker !== null) {
304
481
  sharedCtx.gitTracker = gitTracker;
@@ -308,8 +485,8 @@ async function validateSingleSkill(skillPath, options, logger, locationRoot, cra
308
485
  await appendDistributedTreeFindings(configAware, skillPath, locationRoot, crawlTree);
309
486
  return configAware;
310
487
  }
311
- // Fallback: basic validation
312
- const validateOptions = { skillPath, locationRoot };
488
+ // Fallback: basic validation. Severity is applied by `applySeverityFilter`.
489
+ const validateOptions = { skillPath, locationRoot, validation: {} };
313
490
  if (options.warnUnreferencedFiles) {
314
491
  validateOptions.checkUnreferencedFiles = true;
315
492
  }
@@ -355,6 +532,14 @@ function collect(value, previous) {
355
532
  /**
356
533
  * Create audit command
357
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.
358
543
  */
359
544
  export function createAuditCommand() {
360
545
  const audit = new Command('audit');
@@ -421,11 +606,20 @@ Description:
421
606
  done
422
607
  (See packages/cli/docs/audit.md for details.)
423
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
+
424
617
  Validation Behavior:
425
- Advisory only: audit surfaces all validation issues for inspection.
618
+ Audit surfaces all validation issues for inspection.
426
619
  Unlike 'vat skills validate', audit:
427
620
  - NEVER applies validation.allow (allowed codes are always shown)
428
- - 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
429
623
  - ALWAYS exits 0 for validation results (never gates on errors)
430
624
 
431
625
  For gated validation (CI/CD), use: vat skills validate
@@ -445,11 +639,16 @@ Validation Checks:
445
639
 
446
640
  Warnings (should fix):
447
641
  - Skill exceeds recommended length (>5000 lines)
448
- - Compat smells — requires browser auth, local shell, or external CLI
449
- (COMPAT_REQUIRES_BROWSER_AUTH, COMPAT_REQUIRES_LOCAL_SHELL,
450
- 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)
451
644
  - Unreferenced files detected (with --warn-unreferenced-files)
452
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
+
453
652
  Gitignore-Aware Scanning:
454
653
  When scanning inside a git repository, paths matched by .gitignore are
455
654
  skipped by default. This excludes build artifacts (dist/), dependencies
@@ -459,8 +658,9 @@ Gitignore-Aware Scanning:
459
658
  Use --include-artifacts to scan gitignored paths (e.g., to audit a
460
659
  bundled marketplace plugin in dist/).
461
660
 
462
- User-supplied --exclude patterns are always applied on top.
463
- 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.
464
664
 
465
665
  Config-Aware Validation:
466
666
  The governing vibe-agent-toolkit.config.yaml is found by walking UP from
@@ -470,14 +670,29 @@ Config-Aware Validation:
470
670
  which prevents false warnings for links the build pipeline resolves.
471
671
 
472
672
  Config-aware mode never applies validation.allow — audit always shows
473
- all issues. validation.severity IS applied: a code set to 'ignore' is
474
- hidden. skills.defaults.validation.severity applies project-wide (skills,
475
- plugins and marketplaces alike); skills.config.<name>.validation.severity
476
- 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
477
680
 
478
681
  Exit Codes:
479
- 0 - Always (even when validation errors are surfaced)
480
- 2 - System error (config invalid, directory not found)
682
+ 0 - The audit completed with nothing at error severity (warnings and
683
+ informational findings are in the report, not the exit code).
684
+ 1 - The audit completed and reports 'status: error': at least one
685
+ error-severity finding, or zero files audited. A path inside the tree
686
+ the scan could not read, or a governing config that cannot be loaded
687
+ or whose skills.include reaches an unreadable directory, is
688
+ SCAN_PATH_UNREADABLE (warning): the run is degraded, not failed —
689
+ readable siblings are still validated, the config's skills
690
+ config-free, and the refused path is not counted in filesScanned (so
691
+ a root with nothing readable is a zero-files refusal).
692
+ 2 - The audit could not run at all: the path does not exist or is a file
693
+ no audit lane recognises, --user with no Claude config dir, a git URL
694
+ that would not clone, an unknown flag, an internal failure. There is
695
+ no report to read.
481
696
 
482
697
  Examples:
483
698
  $ vat audit ./plugins/ # Audit recursively (default)
@@ -531,11 +746,8 @@ async function auditUserDirectories(recursive, options, startTime, logger) {
531
746
  // --user scans three sibling directories; they share ONE enclosing Claude
532
747
  // config dir, so that is the run's single stated root.
533
748
  const scanRoot = safePath.resolve(claudeDir);
534
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- safe: path constructed from os.homedir()
535
749
  const pluginsDirExists = fs.existsSync(pluginsDir);
536
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- safe: path constructed from os.homedir()
537
750
  const skillsDirExists = fs.existsSync(skillsDir);
538
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- safe: path constructed from os.homedir()
539
751
  const marketplacesDirExists = fs.existsSync(marketplacesDir);
540
752
  if (!pluginsDirExists && !skillsDirExists && !marketplacesDirExists) {
541
753
  logger.error(`No user-level Claude directories found:`);
@@ -543,9 +755,9 @@ async function auditUserDirectories(recursive, options, startTime, logger) {
543
755
  logger.error(` Skills: ${skillsDir}`);
544
756
  logger.error(` Marketplaces: ${marketplacesDir}`);
545
757
  logger.error('Claude plugins/skills/marketplaces have not been installed yet.');
546
- process.exit(2);
758
+ process.exit(ExitCode.ERROR);
547
759
  }
548
- const results = [];
760
+ const scanned = [];
549
761
  // The roots that exist, minus any one another already covers — see
550
762
  // {@link userScanTargets}. `marketplaces/` lives inside `plugins/`.
551
763
  const present = [
@@ -555,12 +767,12 @@ async function auditUserDirectories(recursive, options, startTime, logger) {
555
767
  ];
556
768
  for (const target of userScanTargets(present, recursive)) {
557
769
  logger.debug(`Auditing user-level resources at: ${target}`);
558
- results.push(...await getValidationResults(target, recursive, options, logger, scanRoot));
770
+ scanned.push(...await getValidationResults(target, recursive, options, logger, scanRoot));
559
771
  }
560
772
  // The `--user` report is assembled here rather than by `buildAuditReport`, so
561
773
  // it needs its own append or the config findings would reach stderr and not the
562
774
  // document — see the same call in `buildAuditReport`.
563
- results.push(...unloadableConfigFindings());
775
+ const results = withDegradedConfigFindings(scanned);
564
776
  // Run compatibility analysis if --compat flag is set
565
777
  const compatMap = options.compat
566
778
  ? await runCompatAnalysis(results, logger, scanRoot) // --settings not supported in --user mode
@@ -573,7 +785,7 @@ async function auditUserDirectories(recursive, options, startTime, logger) {
573
785
  if (verbose) {
574
786
  renderVerboseEvidence(results, scanRoot, logger);
575
787
  }
576
- logHierarchicalSummary(results, logger);
788
+ logHierarchicalSummary(results, summary.issues, logger);
577
789
  }
578
790
  /** Skill resource types that can have per-skill validation config. */
579
791
  const SKILL_RESULT_TYPES = new Set([RESOURCE_TYPE_AGENT_SKILL, 'vat-agent']);
@@ -597,7 +809,6 @@ const SKILL_RESULT_TYPES = new Set([RESOURCE_TYPE_AGENT_SKILL, 'vat-agent']);
597
809
  */
598
810
  export function deriveScanRoot(targetPath) {
599
811
  const resolved = safePath.resolve(targetPath);
600
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved is the operator's own audit target
601
812
  const isDirectory = fs.existsSync(resolved) && fs.statSync(resolved).isDirectory();
602
813
  return isDirectory ? resolved : safePath.resolve(resolved, '..');
603
814
  }
@@ -645,7 +856,7 @@ function buildFilteredResult(result, filteredIssues) {
645
856
  /**
646
857
  * Apply severity resolution to validation results.
647
858
  *
648
- * Audit is advisory only: it applies `validation.severity` to decide what to
859
+ * Audit shows everything: it applies `validation.severity` to decide what to
649
860
  * show and at what severity, but deliberately ignores `validation.allow`.
650
861
  *
651
862
  * **Both directions, via the one shared resolver.** This used to compute the
@@ -657,13 +868,21 @@ function buildFilteredResult(result, filteredIssues) {
657
868
  * same config promoted correctly. Two lanes, one config key, opposite answers.
658
869
  *
659
870
  * Status and counts are re-derived from the resolved severities by
660
- * {@link buildFilteredResult}, so a promotion moves the reported status. It does
661
- * NOT move this command's exit code — `vat audit` is advisory and its exit-code
662
- * behaviour is a separate, pre-existing question tracked in its own issue. That
663
- * asymmetry with `vat verify` (whose exit code IS severity-derived) is
664
- * deliberate here rather than overlooked.
665
- *
666
- * 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.
667
886
  * - `skills.defaults.validation.severity` — the PROJECT-WIDE map, applied to
668
887
  * every result this run produced, including `claude-plugin` and `marketplace`
669
888
  * ones. It used to be applied only to skill-typed results, which left the
@@ -698,11 +917,13 @@ function buildFilteredResult(result, filteredIssues) {
698
917
  * @returns New results array with ignored codes removed from issues
699
918
  */
700
919
  function applySeverityFilter(results, config) {
701
- if (config?.skills === undefined) {
702
- return results;
703
- }
704
- const skillsConfig = config.skills;
705
- 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 };
706
927
  // `.filter` after `.map`, not `.map` alone: a synthetic unreadable-path result
707
928
  // exists ONLY to carry its finding, so once the adopter has set
708
929
  // `severity.SCAN_PATH_UNREADABLE: ignore` the result has nothing left to say.
@@ -714,9 +935,9 @@ function applySeverityFilter(results, config) {
714
935
  const skillName = SKILL_RESULT_TYPES.has(result.type) ? result.metadata?.name : undefined;
715
936
  const perSkillSeverity = skillName === undefined
716
937
  ? {}
717
- : (skillsConfig.config?.[skillName]?.validation?.severity ?? {});
718
- // Merge: per-skill overrides default
719
- 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 };
720
941
  if (Object.keys(effectiveSeverity).length === 0) {
721
942
  return result;
722
943
  }
@@ -779,15 +1000,18 @@ export async function buildAuditReport(scanPath, options, startTime, logger) {
779
1000
  const recursive = options.recursive !== false;
780
1001
  logger.debug(`Auditing resources at: ${scanPath}`);
781
1002
  const scanRoot = deriveScanRoot(scanPath);
782
- // Appended, not merged into a neighbour: a config the scan could not load has
783
- // no host result to hang off, and attributing it to a skill would name a file
784
- // that was read fine. Without this the ONLY record was a stderr warning, so a
785
- // run that downgraded every skill under that config to the weaker config-free
786
- // validator exited 0 with a report that said nothing about it.
787
- const rawResults = [
788
- ...await getValidationResults(scanPath, recursive, options, logger, scanRoot),
789
- ...unloadableConfigFindings(),
790
- ];
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);
791
1015
  // Load config for severity filtering (audit ignores allow; only severity matters).
792
1016
  //
793
1017
  // Guarded, and the ordering is why: the scan above has ALREADY run and collected
@@ -814,13 +1038,9 @@ export async function buildAuditReport(scanPath, options, startTime, logger) {
814
1038
  logger.warn(`Config could not be read; severity overrides are not applied: ${String(err)}`);
815
1039
  }
816
1040
  // Apply severity filtering: hide codes whose effective severity is 'ignore'.
817
- // Allow is deliberately NOT applied — audit is advisory only.
1041
+ // Allow is deliberately NOT applied — audit shows every finding.
818
1042
  const results = applySeverityFilter(rawResults, config);
819
1043
  const effectiveSettings = await resolveEffectiveSettings(options, scanPath, logger);
820
- // Build config-aware context scoped to the single derived config root —
821
- // used by compat analysis to resolve the plugin-level `targets` union.
822
- // VAT's design: one config per project, no composition.
823
- const vatContextForCompat = await buildVATProjectContext(deriveConfigRoot(scanPath), logger);
824
1044
  // Run compatibility analysis if --compat flag is set
825
1045
  const compatMap = options.compat
826
1046
  ? await runCompatAnalysis(results, logger, scanRoot, effectiveSettings, vatContextForCompat)
@@ -881,7 +1101,6 @@ export async function auditCommand(targetPath, options) {
881
1101
  // cloned https://github.com/<that>.git instead, reaching out to the
882
1102
  // network with a name derived from the caller's own directory layout.
883
1103
  // Shorthand is a fallback for arguments that name nothing on disk.
884
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- targetPath is the user's own audit target; existence is the question being asked
885
1104
  if (targetPath !== undefined && !fsExistsSync(targetPath) && isGitUrl(targetPath)) {
886
1105
  await runUrlAudit(targetPath, options);
887
1106
  return;
@@ -893,6 +1112,12 @@ export async function auditCommand(targetPath, options) {
893
1112
  return;
894
1113
  }
895
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
+ }
896
1121
  await runAuditAtPath(scanPath, options);
897
1122
  }
898
1123
  catch (error) {
@@ -902,6 +1127,41 @@ export async function auditCommand(targetPath, options) {
902
1127
  handleCommandError(error, logger, startTime, 'Audit');
903
1128
  }
904
1129
  }
1130
+ /**
1131
+ * Why the ROOT argument cannot be audited at all, or `undefined` when the scan
1132
+ * may start.
1133
+ *
1134
+ * The exit-code contract every verb shares: a path that does not exist, or a
1135
+ * file no audit lane recognises, is the INVOCATION's mistake — `ExitCode.ERROR`
1136
+ * (2) before any scan, the same ending `vat resources validate` and `vat skill
1137
+ * review` give the same argument. Until this check the audit ran anyway and
1138
+ * published `UNKNOWN_FORMAT` as a finding at exit 1 with `filesScanned: 1`: a
1139
+ * typo in a CI step read as "the tree failed its gate", and the denominator
1140
+ * counted a file that was never there.
1141
+ *
1142
+ * ONLY the root, and only these two conditions. A root the OS refuses to stat
1143
+ * is not answered here — the scan degrades it to `SCAN_PATH_UNREADABLE`, which
1144
+ * is not counted as a scanned file, so a tree with nothing readable ends on the
1145
+ * zero-files refusal (exit 1) like any other tree that yielded nothing. A
1146
+ * sub-path inside a readable tree is never the invocation's fault and stays a
1147
+ * finding. Pinned by the `audit → error` rows of `exit-codes.system.test.ts`.
1148
+ */
1149
+ async function unusableRootReason(scanPath) {
1150
+ let isDirectory;
1151
+ try {
1152
+ isDirectory = fs.lstatSync(scanPath).isDirectory();
1153
+ }
1154
+ catch (error) {
1155
+ if (isPathAbsentError(error))
1156
+ return `Path does not exist: ${scanPath}`;
1157
+ return undefined;
1158
+ }
1159
+ if (isDirectory || detectFormat(scanPath) === RESOURCE_TYPE_AGENT_SKILL)
1160
+ return undefined;
1161
+ if ((await detectResourceFormat(scanPath)).type !== 'unknown')
1162
+ return undefined;
1163
+ return `Path is not a resource this command can audit (a SKILL.md, a plugin or marketplace directory, or a Claude registry file): ${scanPath}`;
1164
+ }
905
1165
  /**
906
1166
  * Dispatch a single surface to the matching validator. Keeps the multi-surface
907
1167
  * branch of {@link getValidationResults} focused so cognitive complexity stays
@@ -1004,22 +1264,50 @@ async function appendPluginInventoryToSurfaceResults(surfaceResults, scanPath, l
1004
1264
  }
1005
1265
  /**
1006
1266
  * Surface `inventory.parseErrors[]` as `PLUGIN_INVALID_JSON` findings on
1007
- * `result`, skipping the plugin.json manifest entry (which is already covered
1008
- * by `validatePlugin`). Only hooks/hooks.json and .mcp.json parse errors reach
1009
- * this helper; they are appended with error severity.
1267
+ * `result`, skipping the entries another lane already files under their own
1268
+ * code:
1269
+ *
1270
+ * - the plugin.json manifest entry, covered by `validatePlugin`;
1271
+ * - every `SKILL.md` entry. The skill extractor records a SKILL.md it could
1272
+ * not open, could not parse, or could not link-walk on the plugin's
1273
+ * `parseErrors[]` — and each of those is the SKILL's finding, filed on the
1274
+ * skill by its own validation (`SCAN_PATH_UNREADABLE`,
1275
+ * `SKILL_MISSING_FRONTMATTER`) in both the plugin-target lane
1276
+ * (`validatePluginSkillsViaInventory`, over the same inventory) and the
1277
+ * directory lane (the walk reaches every SKILL.md beneath the plugin).
1278
+ * Re-filing it here was a second copy under a code that names a file it is
1279
+ * not (plugin.json) and a format it is not (JSON), at error severity beside
1280
+ * the skill lane's warning, and carrying the build host's absolute path.
1281
+ *
1282
+ * What remains — hooks/hooks.json, .mcp.json, a directory the crawl could not
1283
+ * list — is appended with error severity when the SUBJECT is defective, and
1284
+ * as `SCAN_PATH_UNREADABLE` (warning) when the row is marked `unreadable`
1285
+ * (the OS refused the path); the message is spelled scan-root-relative
1286
+ * wherever it names the file.
1010
1287
  */
1011
1288
  function appendInventoryParseErrors(result, inv, locationRoot) {
1012
1289
  const pluginJsonSuffix = safePath.join('.claude-plugin', 'plugin.json');
1290
+ // One refused path, one refusal: the distributed-tree detector crawls the
1291
+ // same directories and files the ones it cannot list on this same result.
1292
+ const refusalsFiled = new Set(result.issues.filter((issue) => issue.code === 'SCAN_PATH_UNREADABLE').map((issue) => issue.location));
1013
1293
  const parseIssues = [];
1014
1294
  for (const err of inv.parseErrors) {
1015
- if (err.path.endsWith(pluginJsonSuffix))
1295
+ if (err.path.endsWith(pluginJsonSuffix) || basename(err.path) === 'SKILL.md')
1016
1296
  continue;
1017
- parseIssues.push({
1018
- severity: 'error',
1019
- code: 'PLUGIN_INVALID_JSON',
1020
- message: err.message,
1021
- location: issueLocation(err.path, locationRoot),
1022
- });
1297
+ const message = withRelativePath(err.message, err.path, locationRoot);
1298
+ const location = issueLocation(err.path, locationRoot);
1299
+ if (err.unreadable !== true) {
1300
+ parseIssues.push({ severity: 'error', code: 'PLUGIN_INVALID_JSON', message, location });
1301
+ continue;
1302
+ }
1303
+ // A path the OS refused was not examined; that is the run degrading, not
1304
+ // a defect of the plugin, and it is filed at the severity every other lane
1305
+ // gives a refusal. Under `PLUGIN_INVALID_JSON` (error) an `EACCES` on one
1306
+ // skill directory failed the whole audit over a mode bit.
1307
+ if (refusalsFiled.has(location))
1308
+ continue;
1309
+ refusalsFiled.add(location);
1310
+ parseIssues.push(materializeIssue('SCAN_PATH_UNREADABLE', { location, detail: `${location}: ${message}` }));
1023
1311
  }
1024
1312
  // Status was hand-set to 'error' here while `issueCounts` and `summary` kept
1025
1313
  // describing the pre-append list. One appender derives all three.
@@ -1043,7 +1331,8 @@ function appendInventoryParseErrors(result, inv, locationRoot) {
1043
1331
  * way into a validator unprotected: a `SKILL.md` named directly on the command
1044
1332
  * line, a plugin directory, a marketplace, each surface of a multi-surface tree.
1045
1333
  * All of them still ended the whole run with `status: error`, exit 2 and zero
1046
- * findings — the exact symptom of issue #180, in the lanes nobody had checked.
1334
+ * findings — the exact symptom of the unreadable-directory defect, in the lanes
1335
+ * nobody had checked.
1047
1336
  *
1048
1337
  * Guarding the seven dispatch sites individually would have been the same mistake
1049
1338
  * a seventh time. This function is what every one of them is reached through, so
@@ -1058,7 +1347,7 @@ function appendInventoryParseErrors(result, inv, locationRoot) {
1058
1347
  */
1059
1348
  export async function getValidationResults(scanPath, recursive, options, logger, locationRoot) {
1060
1349
  try {
1061
- return await validateAuditSubject(scanPath, recursive, options, logger, locationRoot);
1350
+ return dedupeUnreadablePathResults(await validateAuditSubject(scanPath, recursive, options, logger, locationRoot));
1062
1351
  }
1063
1352
  catch (error) {
1064
1353
  // Only the filesystem refusing the subject degrades; a defect in a validator
@@ -1069,6 +1358,83 @@ export async function getValidationResults(scanPath, recursive, options, logger,
1069
1358
  return [unreadablePathResult(scanPath, error, locationRoot)];
1070
1359
  }
1071
1360
  }
1361
+ /**
1362
+ * One refused path, one `SCAN_PATH_UNREADABLE` — however many lanes met it.
1363
+ *
1364
+ * Two lanes can reach the same unreadable directory in one run: the directory
1365
+ * walk (`scanDirectory`, which files the synthetic {@link unreadablePathResult})
1366
+ * and the distributed-tree detector, which crawls a skill's own directory at any
1367
+ * depth and reports what it could not list as an issue ON the skill's result.
1368
+ * Their populations overlap, but neither contains the other: the walk skips
1369
+ * what `--exclude`, the project's `resources.exclude` and gitignore skip, and
1370
+ * does not descend at all under `--no-recursive` or when the SKILL.md is named
1371
+ * directly — the detector enters all of those. So "the walk already reports
1372
+ * everything under the scan root" is false in both directions, and a rule that
1373
+ * silenced one lane by geography would drop the refusal in exactly the cases
1374
+ * where it is the only report. The rule is instead keyed on the FACT: the
1375
+ * code and the location, which both lanes derive from the same directory and
1376
+ * the same anchor root.
1377
+ *
1378
+ * The hosted issue wins and the synthetic result is dropped, not the reverse:
1379
+ * the detector's copy says which SKILL's crawl was cut short, which the
1380
+ * synthetic result cannot, and it is the only copy the direct-target lane ever
1381
+ * produces — so keeping it makes the two lanes' reports the same shape. A
1382
+ * synthetic result that carried nothing but the duplicate is removed whole; one
1383
+ * that somehow carried more keeps its other issues (it cannot today, but the
1384
+ * filter says what it means rather than assuming).
1385
+ *
1386
+ * A THIRD lane files synthetic results too: skill discovery under a governing
1387
+ * config, which refuses a directory its `skills.include` cannot list and is
1388
+ * recorded through {@link recordRefusedDiscovery}. In the directory lane the
1389
+ * walk has already filed its own synthetic result on that same directory, so
1390
+ * two synthetic results can carry one fact. Among synthetics the FIRST wins and
1391
+ * later copies of the same location are dropped — the walk's copy carries the
1392
+ * raw errno, the discovery's copy the crawl's remedy sentence, and the stderr
1393
+ * warning carries the latter either way.
1394
+ */
1395
+ function dedupeUnreadablePathResults(results) {
1396
+ const reported = new Set();
1397
+ for (const result of results) {
1398
+ if (result.type === 'unknown')
1399
+ continue;
1400
+ for (const issue of result.issues) {
1401
+ if (issue.code === 'SCAN_PATH_UNREADABLE')
1402
+ reported.add(issue.location ?? '');
1403
+ }
1404
+ }
1405
+ return results.flatMap((result) => {
1406
+ if (result.type !== 'unknown')
1407
+ return [result];
1408
+ const kept = withoutHostedRefusals(result, reported);
1409
+ for (const survivor of kept) {
1410
+ for (const issue of survivor.issues) {
1411
+ if (issue.code === 'SCAN_PATH_UNREADABLE')
1412
+ reported.add(issue.location ?? '');
1413
+ }
1414
+ }
1415
+ return kept;
1416
+ });
1417
+ }
1418
+ /**
1419
+ * A synthetic result minus the refusals some hosted result already carries —
1420
+ * as an empty array when nothing is left, so {@link dedupeUnreadablePathResults}
1421
+ * can `flatMap` it away.
1422
+ */
1423
+ function withoutHostedRefusals(result, hosted) {
1424
+ const issues = result.issues.filter((issue) => !(issue.code === 'SCAN_PATH_UNREADABLE' && hosted.has(issue.location ?? '')));
1425
+ if (issues.length === 0)
1426
+ return [];
1427
+ if (issues.length === result.issues.length)
1428
+ return [result];
1429
+ const issueCounts = countBySeverity(issues);
1430
+ return [{
1431
+ ...result,
1432
+ status: calculateValidationStatus(issues),
1433
+ summary: formatCountsSummary(issueCounts),
1434
+ issues,
1435
+ issueCounts,
1436
+ }];
1437
+ }
1072
1438
  async function validateAuditSubject(scanPath, recursive, options, logger, locationRoot) {
1073
1439
  const format = detectFormat(scanPath);
1074
1440
  // Special handling for direct SKILL.md file
@@ -1133,26 +1499,45 @@ async function validateAuditSubject(scanPath, recursive, options, logger, locati
1133
1499
  return [result];
1134
1500
  }
1135
1501
  // If unknown format, check if it's a directory we can scan
1136
- const fsp = await import('node:fs/promises');
1137
- try {
1138
- const stat = await fsp.stat(scanPath);
1139
- if (stat.isDirectory()) {
1140
- logger.debug('Scanning directory for resources');
1141
- // The project's `resources.exclude` is applied by the scan context,
1142
- // on the project-root basis its globs were written against — NOT
1143
- // merged into `options.exclude`, whose patterns are relative to the
1144
- // directory the operator named. See resolveProjectExcludes.
1145
- return scanDirectory(scanPath, recursive, options, logger, await resolveScanContext(scanPath, locationRoot, logger));
1146
- }
1147
- }
1148
- catch {
1149
- // Path doesn't exist or not accessible, let validate() handle it
1502
+ if (await isScannableDirectory(scanPath)) {
1503
+ logger.debug('Scanning directory for resources');
1504
+ // The project's `resources.exclude` is applied by the scan context,
1505
+ // on the project-root basis its globs were written against — NOT
1506
+ // merged into `options.exclude`, whose patterns are relative to the
1507
+ // directory the operator named. See resolveProjectExcludes.
1508
+ return scanDirectory(scanPath, recursive, options, logger, await resolveScanContext(scanPath, locationRoot, logger));
1150
1509
  }
1151
1510
  // Unknown resource type - use unified validator which will return appropriate error
1152
1511
  logger.debug(`Unknown resource type at: ${scanPath}`);
1153
1512
  const result = await validate(scanPath, { locationRoot });
1154
1513
  return [result];
1155
1514
  }
1515
+ /**
1516
+ * Whether `scanPath` is a directory the walk can enter.
1517
+ *
1518
+ * `false` for a file and for NOTHING — `validate()` then names the path as
1519
+ * unknown or absent, which is the answer the operator can act on. A refusal on
1520
+ * the stat is neither: it propagates to `getValidationResults`, whose own catch
1521
+ * files it as `SCAN_PATH_UNREADABLE`, so the audit still degrades (the run
1522
+ * completes, the refusal a finding) rather than refusing the run.
1523
+ *
1524
+ * 🪤 The stat used to share ONE `try` with `scanDirectory` itself, and the
1525
+ * catch was bare. Every throw from the scan — a crawl refusal, a `TypeError`
1526
+ * two frames down — was absorbed into "let validate() handle it", and
1527
+ * `validate()` then reported the directory as an unknown resource format. A
1528
+ * scan that examined nothing was reported as a directory that was nothing.
1529
+ */
1530
+ async function isScannableDirectory(scanPath) {
1531
+ const fsp = await import('node:fs/promises');
1532
+ try {
1533
+ return (await fsp.stat(scanPath)).isDirectory();
1534
+ }
1535
+ catch (error) {
1536
+ if (isPathAbsentError(error))
1537
+ return false;
1538
+ throw error;
1539
+ }
1540
+ }
1156
1541
  /**
1157
1542
  * Inventory-driven plugin skill walker. Builds the plugin inventory via
1158
1543
  * `extractClaudePluginInventory` and dispatches `validateSingleSkill` for each
@@ -1218,9 +1603,9 @@ function resolveConfigTargetsForPlugin(pluginDir, vatContext) {
1218
1603
  const union = new Set();
1219
1604
  for (const [skillPathAbs, packagingConfig] of vatContext.skillConfigs) {
1220
1605
  const rel = safePath.relative(pluginDirAbs, skillPathAbs);
1221
- // Skill lives inside the plugin directory iff relative path does not
1222
- // start with '..' and is not absolute.
1223
- if (rel === '' || rel.startsWith('..') || isAbsolutePath(rel))
1606
+ // Skill lives inside the plugin directory iff the relative path neither
1607
+ // climbs out nor (on Windows, cross-drive) comes back absolute.
1608
+ if (rel === '' || relativeEscapesRoot(rel))
1224
1609
  continue;
1225
1610
  const skillTargets = packagingConfig.targets;
1226
1611
  if (skillTargets === undefined)
@@ -1232,7 +1617,15 @@ function resolveConfigTargetsForPlugin(pluginDir, vatContext) {
1232
1617
  return union.size === 0 ? undefined : [...union];
1233
1618
  }
1234
1619
  /**
1235
- * Run compatibility analysis on plugin results and return a map of path -> CompatibilityResult.
1620
+ * `message`, with `absolutePath` spelled root-relative wherever it appears.
1621
+ * An OS refusal names the path it refused, absolutely; every other path in
1622
+ * the document is relative to `root`, and this one leaks the build host.
1623
+ */
1624
+ function withRelativePath(message, absolutePath, locationRoot) {
1625
+ return message.replaceAll(absolutePath, issueLocation(absolutePath, locationRoot) || '.');
1626
+ }
1627
+ /**
1628
+ * Run compatibility analysis on plugin results and return a map of path -> PluginCompatEntry.
1236
1629
  * Non-plugin results are skipped silently.
1237
1630
  * When effectiveSettings is provided, also runs settings conflict detection.
1238
1631
  *
@@ -1253,67 +1646,99 @@ export async function runCompatAnalysis(results, logger, locationRoot, effective
1253
1646
  for (const result of results) {
1254
1647
  if (result.type !== RESOURCE_TYPE_CLAUDE_PLUGIN)
1255
1648
  continue;
1256
- try {
1257
- logger.debug(`Running compatibility analysis for: ${result.path}`);
1258
- const configTargets = resolveConfigTargetsForPlugin(result.path, vatContext);
1259
- const analyzeOptions = configTargets === undefined
1260
- ? undefined
1261
- : { configTargets };
1262
- const compat = await analyzeCompatibility(result.path, locationRoot, analyzeOptions);
1263
- // Settings conflict detection (when --settings flag is used)
1264
- if (effectiveSettings === undefined) {
1265
- compatMap.set(result.path, compat);
1266
- }
1267
- else {
1268
- try {
1269
- const settingsConflicts = await checkSettingsCompatibility(result.path, effectiveSettings);
1270
- compatMap.set(result.path, { ...compat, settingsConflicts });
1271
- }
1272
- catch (settingsErr) {
1273
- logger.debug(`Settings compat check skipped for ${result.path}: ${String(settingsErr)}`);
1274
- compatMap.set(result.path, compat);
1275
- }
1276
- }
1277
- }
1278
- catch (err) {
1279
- // Log but do not fail the audit — compat analysis is best-effort
1280
- logger.debug(`Compatibility analysis skipped for ${result.path}: ${String(err)}`);
1649
+ const entry = {
1650
+ compat: await analyzeOrExplain(result.path, locationRoot, vatContext, logger),
1651
+ };
1652
+ if (effectiveSettings !== undefined) {
1653
+ entry.settings = await settingsVerdict(result.path, effectiveSettings, locationRoot, logger);
1281
1654
  }
1655
+ compatMap.set(result.path, entry);
1282
1656
  }
1283
1657
  return compatMap;
1284
1658
  }
1659
+ /**
1660
+ * The analyzer's result for one plugin, or — when it threw — the reason, said
1661
+ * on stderr and carried into the document. Compat analysis is best-effort:
1662
+ * it never fails the audit, but a plugin it could not analyze is reported as
1663
+ * exactly that, never as a plugin with no `compatibility:` block.
1664
+ */
1665
+ async function analyzeOrExplain(pluginDir, locationRoot, vatContext, logger) {
1666
+ try {
1667
+ logger.debug(`Running compatibility analysis for: ${pluginDir}`);
1668
+ const configTargets = resolveConfigTargetsForPlugin(pluginDir, vatContext);
1669
+ const analyzeOptions = configTargets === undefined ? undefined : { configTargets };
1670
+ return await analyzeCompatibility(pluginDir, locationRoot, analyzeOptions);
1671
+ }
1672
+ catch (err) {
1673
+ const refused = typeof err.path === 'string' ? err.path : undefined;
1674
+ const raw = err instanceof Error ? err.message : String(err);
1675
+ const reason = refused === undefined ? raw : withRelativePath(raw, refused, locationRoot);
1676
+ logger.warn(`Compatibility analysis could not run for ${issueLocation(pluginDir, locationRoot) || '.'}: ${reason}`);
1677
+ return { analyzed: false, reason };
1678
+ }
1679
+ }
1680
+ /**
1681
+ * One plugin's settings verdict: the checker's conflicts and the paths it
1682
+ * never saw — and, when the checker itself fails, the plugin as the one thing
1683
+ * unchecked, rather than no `settings:` block at all.
1684
+ *
1685
+ * The checker failing used to be `logger.debug` and a result with no settings
1686
+ * block, so a plugin the check could not run on was indistinguishable from one
1687
+ * the operator never asked about. A run that asked for `--settings` and got no
1688
+ * answer for a plugin is told so on stderr and in the document.
1689
+ */
1690
+ async function settingsVerdict(pluginDir, effectiveSettings, locationRoot, logger) {
1691
+ try {
1692
+ return await checkSettingsCompatibility(pluginDir, effectiveSettings);
1693
+ }
1694
+ catch (settingsErr) {
1695
+ const reason = settingsErr instanceof Error ? settingsErr.message : String(settingsErr);
1696
+ logger.warn(`Settings compatibility could not be checked for ${issueLocation(pluginDir, locationRoot) || '.'}: ${reason}`);
1697
+ return { conflicts: [], unchecked: [{ path: pluginDir, reason }] };
1698
+ }
1699
+ }
1700
+ /**
1701
+ * The `settings:` block for one plugin, every path anchored at `locationRoot`.
1702
+ * `compatible` is derived from BOTH halves: zero conflicts over a skill the
1703
+ * check never read is not compatibility, it is silence.
1704
+ */
1705
+ function renderSettingsBlock(check, locationRoot) {
1706
+ const settings = {
1707
+ compatible: check.conflicts.length === 0 && check.unchecked.length === 0,
1708
+ conflicts: check.conflicts,
1709
+ };
1710
+ if (check.unchecked.length > 0) {
1711
+ settings.unchecked = check.unchecked.map(({ path, reason }) => ({
1712
+ path: issueLocation(path, locationRoot) || '.',
1713
+ reason: withRelativePath(reason, path, locationRoot),
1714
+ }));
1715
+ }
1716
+ return settings;
1717
+ }
1285
1718
  /**
1286
1719
  * Merge compatibility analysis results into validation result output objects.
1287
1720
  * Returns an array of plain objects ready for YAML serialization.
1288
- * When settingsConflicts are present, adds a `settings:` block for cleaner output.
1721
+ * A plugin the run asked `--settings` about gets a `settings:` block, whatever
1722
+ * the analyzer did.
1289
1723
  */
1290
- function mergeCompatIntoResults(results, compatMap) {
1724
+ function mergeCompatIntoResults(results, compatMap, locationRoot) {
1291
1725
  return results.map(r => {
1292
- const compat = compatMap.get(r.path);
1293
- if (compat === undefined)
1726
+ const entry = compatMap.get(r.path);
1727
+ if (entry === undefined)
1294
1728
  return r;
1295
- // Extract settingsConflicts from compat to render as a separate `settings:` block
1296
- const { settingsConflicts, ...compatWithoutSettings } = compat;
1297
- if (settingsConflicts !== undefined) {
1298
- return {
1299
- ...r,
1300
- compatibility: compatWithoutSettings,
1301
- settings: {
1302
- compatible: settingsConflicts.length === 0,
1303
- conflicts: settingsConflicts,
1304
- },
1305
- };
1306
- }
1307
- return { ...r, compatibility: compat };
1729
+ const merged = { ...r, compatibility: entry.compat };
1730
+ if (entry.settings !== undefined)
1731
+ merged.settings = renderSettingsBlock(entry.settings, locationRoot);
1732
+ return merged;
1308
1733
  });
1309
1734
  }
1310
1735
  /**
1311
1736
  * Apply compatibility data to results if a compatMap is provided and non-empty.
1312
1737
  * Returns the original results array when no compat data is available.
1313
1738
  */
1314
- function applyCompatMap(results, compatMap) {
1739
+ function applyCompatMap(results, compatMap, locationRoot) {
1315
1740
  if (compatMap !== undefined && compatMap.size > 0) {
1316
- return mergeCompatIntoResults(results, compatMap);
1741
+ return mergeCompatIntoResults(results, compatMap, locationRoot);
1317
1742
  }
1318
1743
  return results;
1319
1744
  }
@@ -1350,7 +1775,7 @@ function applyVerboseFilter(results, verbose) {
1350
1775
  const NESTED_PATH_CARRIERS = ['linkedFiles'];
1351
1776
  function calculateSummary(results, startTime, compatMap, verbose, root) {
1352
1777
  const { entries, ...base } = buildBaseSummary(results, startTime);
1353
- const withCompat = applyCompatMap(entries, compatMap);
1778
+ const withCompat = applyCompatMap(entries, compatMap, root);
1354
1779
  return {
1355
1780
  // Stated once, first, and the only absolute path in the document.
1356
1781
  root,
@@ -1430,16 +1855,6 @@ function findingSubject(result, root) {
1430
1855
  const directoryName = basename(result.path);
1431
1856
  return directoryName === '' ? '.' : directoryName;
1432
1857
  }
1433
- /**
1434
- * The clause every "audit found errors" line ends with.
1435
- *
1436
- * One string because the two summary lanes — per-file and per-skill — both make
1437
- * the same claim and used to word it differently, which is how one of them ended
1438
- * up saying "Audit failed" over an exit code of 0. Naming the command that DOES
1439
- * gate is the actionable half: an adopter reading "advisory" still has to be
1440
- * told what to reach for instead.
1441
- */
1442
- const ADVISORY_EXIT_NOTE = '— advisory, exit 0; use `vat validate` to gate on this';
1443
1858
  /**
1444
1859
  * Render supporting evidence beneath each CAPABILITY_* issue when the
1445
1860
  * audit was invoked with --verbose. Evidence comes from the validation
@@ -1463,24 +1878,75 @@ function renderVerboseEvidence(results, root, logger) {
1463
1878
  }
1464
1879
  }
1465
1880
  }
1881
+ /**
1882
+ * The stderr half of a run-level refusal, so the human channel says what the
1883
+ * document says. The document is derived in `buildBaseSummary`; this only
1884
+ * RENDERS it. Both stderr lanes call it first, because "Audit successful: 0
1885
+ * file(s) passed" over `status: error` is the exact disagreement the refusal
1886
+ * exists to end (invariant 6 of `run-integrity.ts`). The run ends on
1887
+ * `FINDINGS`, like every other `status: error` this command publishes.
1888
+ *
1889
+ * @returns Whether a refusal was rendered, so the caller skips its verdict line
1890
+ */
1891
+ function logRunIntegrity(runIssues, logger) {
1892
+ if (runIssues === undefined || runIssues.length === 0)
1893
+ return false;
1894
+ logger.error('Audit is not a verdict: it audited 0 files');
1895
+ for (const issue of runIssues) {
1896
+ for (const line of formatIssueLines(issue, ' '))
1897
+ logger.error(line);
1898
+ }
1899
+ return true;
1900
+ }
1901
+ /**
1902
+ * The stderr line for the settings lane: how many conflicts it found and how
1903
+ * many paths it never compared, across every plugin in the document.
1904
+ *
1905
+ * 🚩 This read `f.compatibility?.settingsConflicts` — a field the renderer had
1906
+ * already moved out into the `settings:` block — so the count was 0 on every
1907
+ * run and the line never printed. It reads the rendered block now, the same
1908
+ * one the YAML carries, and it counts the unchecked half too: a run that
1909
+ * skipped a skill says so without `--debug`.
1910
+ */
1911
+ function logSettingsTotals(files, logger) {
1912
+ let conflicts = 0;
1913
+ let unchecked = 0;
1914
+ // The compat lane's own unchecked paths — a plugin the analyzer could only
1915
+ // partly read is in the document under `compatibility.unchecked`, and this is
1916
+ // the one line that keeps it from being silent on the human channel while the
1917
+ // settings lane says its half. `analyzed: false` has no list: it is the
1918
+ // plugin-wide failure, and the `reason` line already says so.
1919
+ let compatUnchecked = 0;
1920
+ for (const { compatibility, settings } of files) {
1921
+ conflicts += settings?.conflicts.length ?? 0;
1922
+ unchecked += settings?.unchecked?.length ?? 0;
1923
+ if (compatibility !== undefined && 'unchecked' in compatibility)
1924
+ compatUnchecked += compatibility.unchecked.length;
1925
+ }
1926
+ if (conflicts > 0) {
1927
+ logger.error(`⚠ ${conflicts} settings conflict(s) found — see 'settings' section in YAML output`);
1928
+ }
1929
+ if (unchecked > 0) {
1930
+ logger.error(`⚠ ${unchecked} path(s) the settings check could not compare — see 'settings.unchecked' in YAML output`);
1931
+ }
1932
+ if (compatUnchecked > 0) {
1933
+ logger.error(`⚠ ${compatUnchecked} path(s) the compatibility analysis could not read — see 'compatibility.unchecked' in YAML output`);
1934
+ }
1935
+ }
1466
1936
  function handleAuditResults(results, summary, logger, verbose) {
1467
1937
  const { filesWithErrors: errorCount, filesWithWarnings: warningCount, filesPassed: successCount, } = summary.summary;
1468
- // Report settings conflicts (advisory, non-blocking)
1469
- const totalSettingsConflicts = (summary.files ?? []).reduce((sum, f) => {
1470
- return sum + (f.compatibility?.settingsConflicts?.length ?? 0);
1471
- }, 0);
1472
- if (totalSettingsConflicts > 0) {
1473
- logger.error(`\u26a0 ${totalSettingsConflicts} settings conflict(s) found — see 'settings' section in YAML output`);
1474
- }
1475
- // 🔑 Audit is advisory only — always exit 0 for validation results, and the
1476
- // WORDING has to agree with that or the three signals contradict each other.
1477
- // This line used to read "Audit failed", beside `status: error` in the
1478
- // document, on exit 0: an adopter wiring `vat audit` into CI read a failure,
1479
- // saw a failure status, and got a green step. "Found" is what actually
1480
- // happened; the gate is `vat validate`, which is named here rather than
1481
- // implied.
1482
- if (errorCount > 0) {
1483
- logger.error(`Audit found ${errorCount} file(s) with errors ${ADVISORY_EXIT_NOTE}`);
1938
+ logSettingsTotals(summary.files ?? [], logger);
1939
+ // The exit code follows the document's `status`, as it does in every other
1940
+ // command of this CLI: `status: error` — an error-severity finding, or a run
1941
+ // that audited zero files — ends on `FINDINGS`. This used to be the one
1942
+ // command that exited 0 over `status: error` ("advisory"), and an adopter
1943
+ // wiring it into CI read "Audit failed" on stderr beside a green step.
1944
+ const refused = logRunIntegrity(summary.issues, logger);
1945
+ if (refused) {
1946
+ // Rendered above; there is no file verdict to print over zero files.
1947
+ }
1948
+ else if (errorCount > 0) {
1949
+ logger.error(`Audit found ${errorCount} file(s) with errors`);
1484
1950
  logFindingsForStatus(results, 'error', summary.root, logger.error.bind(logger), verbose);
1485
1951
  }
1486
1952
  else if (warningCount > 0) {
@@ -1496,7 +1962,7 @@ function handleAuditResults(results, summary, logger, verbose) {
1496
1962
  : `Audit successful: ${successCount} file(s) passed`);
1497
1963
  }
1498
1964
  renderAuditFooter(results, logger);
1499
- process.exit(0);
1965
+ process.exit(refused || errorCount > 0 ? ExitCode.FINDINGS : ExitCode.OK);
1500
1966
  }
1501
1967
  /**
1502
1968
  * Render the skill-quality checklist footer when audit results contain
@@ -1591,17 +2057,6 @@ function logFindingsForStatus(results, status, root, logFn, verbose) {
1591
2057
  logFn(line);
1592
2058
  }
1593
2059
  }
1594
- /**
1595
- * Check whether a path should be excluded during directory scanning.
1596
- * For directories, checks both bare path and path with trailing slash
1597
- * so patterns like "dist/**" prune the directory itself.
1598
- */
1599
- function isExcludedPath(isMatch, relativePath, isDirectory) {
1600
- if (isDirectory) {
1601
- return isMatch(relativePath) || isMatch(relativePath + '/');
1602
- }
1603
- return isMatch(relativePath);
1604
- }
1605
2060
  /**
1606
2061
  * Does the SKILL.md at `skillMdPath` own its own presence-side crawl, or has an
1607
2062
  * ancestor crawl already covered it?
@@ -1615,39 +2070,27 @@ function ownsOwnCrawl(scanCtx, skillMdPath) {
1615
2070
  return scanCtx.crawledRoot === null || !isWithin(scanCtx.crawledRoot, safePath.resolve(skillMdPath));
1616
2071
  }
1617
2072
  /**
1618
- * Hand the descending context an owner for everything below `owner`, unless one
1619
- * is already claimed. First owner wins, and `null` claims nothing.
1620
- */
1621
- function claimSubtree(scanCtx, owner) {
1622
- if (owner === null || scanCtx.crawledRoot !== null)
1623
- return scanCtx;
1624
- return { ...scanCtx, crawledRoot: safePath.resolve(owner) };
1625
- }
1626
- /**
1627
- * The context this directory's SUBDIRECTORIES are walked with.
1628
- *
1629
- * A `SKILL.md` here means this directory's own crawl reaches every descendant at
1630
- * any depth, so it — not each nested skill in turn — owns the subtree below.
2073
+ * The directories whose presence-side crawl already owns everything beneath
2074
+ * them — see {@link ScanContext.crawledRoot} for why there are two owner kinds
2075
+ * and why the first owner wins.
1631
2076
  *
1632
- * Resolved before the entry loop rather than inside it: `SKILL.md` and the
1633
- * subdirectories it governs are siblings in one `readdir`, and their order is
1634
- * the filesystem's to choose, so deciding this mid-loop would make the verdict
1635
- * depend on it.
1636
- *
1637
- * Conditional on the crawl actually RUNNING ({@link crawlOwnsSubtree}), not on
1638
- * the SKILL.md merely existing: a repo-source skill's crawl is skipped
1639
- * entirely, so claiming its subtree would trade a double count for a missed
1640
- * finding — and provenance is not monotone down a tree.
2077
+ * Kept as a list of roots rather than threaded down a recursion: the
2078
+ * population arrives flat and top-down (an ancestor's subjects before a
2079
+ * descendant's), so "who owns this path" is the first claimed root that
2080
+ * contains it, and a claim is only ever made where no ancestor holds one.
1641
2081
  */
1642
- async function contextForDescendants(dirPath, entries, scanCtx) {
1643
- if (!entries.some((entry) => entry.isFile() && entry.name === 'SKILL.md'))
1644
- return scanCtx;
1645
- const skillMd = safePath.join(dirPath, 'SKILL.md');
1646
- if (!ownsOwnCrawl(scanCtx, skillMd))
1647
- return scanCtx;
1648
- if (!await crawlOwnsSubtree(skillMd))
1649
- return scanCtx;
1650
- return claimSubtree(scanCtx, dirPath);
2082
+ class SubtreeClaims {
2083
+ roots = [];
2084
+ /** The claimed root containing `absolutePath`, or `null` while nothing does. */
2085
+ owner(absolutePath) {
2086
+ const resolved = safePath.resolve(absolutePath);
2087
+ return this.roots.find((root) => isWithin(root, resolved)) ?? null;
2088
+ }
2089
+ /** Claim everything below `dir`, unless an ancestor already did. First owner wins. */
2090
+ claim(dir) {
2091
+ if (this.owner(dir) === null)
2092
+ this.roots.push(safePath.resolve(dir));
2093
+ }
1651
2094
  }
1652
2095
  /**
1653
2096
  * Handle file entry during directory scan
@@ -1678,17 +2121,28 @@ async function handleFileEntry(entry, fullPath, options, logger, scanCtx) {
1678
2121
  // checks in the link-graph walk stay O(1).
1679
2122
  // `projectSkills`: same project-wide test-input rule as the lane above,
1680
2123
  // resolved through the same per-config-root memo.
1681
- const projectSkills = await resolveProjectDeclaredEvalSuites(fullPath);
2124
+ const projectSkills = await resolveProjectDeclaredEvalSuites(fullPath, degradingDiscovery(findProjectRoot(safePath.resolve(fullPath, '..')) ?? locationRoot, logger, locationRoot));
2125
+ // `suiteProbe`: the RUN's, not this entry's — the directory walk reaches
2126
+ // this function once per SKILL.md it finds. See {@link runSuiteProbe}.
2127
+ const suiteProbe = suiteProbeForRun();
2128
+ // No registry is supplied on this lane, so `validateSkillForPackaging`
2129
+ // crawls one per project root through `crawlAndResolveRegistry` — under
2130
+ // audit's ruling, which is the ONLY reason one `chmod 000` sibling no
2131
+ // longer exits this whole run at 2 with zero findings. The root is the
2132
+ // one the validator itself derives (`findProjectRoot(dirname) ?? dirname`),
2133
+ // so the refuse sentence and the finding name the same crawl.
2134
+ const skillDir = safePath.resolve(fullPath, '..');
2135
+ const unreadable = degradingRegistry(findProjectRoot(skillDir) ?? skillDir, logger, locationRoot);
1682
2136
  const sharedCtx = scanCtx.gitTracker === null
1683
- ? { locationRoot, projectSkills }
1684
- : { gitTracker: scanCtx.gitTracker, locationRoot, projectSkills };
2137
+ ? { locationRoot, projectSkills, suiteProbe, unreadable }
2138
+ : { gitTracker: scanCtx.gitTracker, locationRoot, projectSkills, suiteProbe, unreadable };
1685
2139
  const packagingResult = await validateSkillForPackaging(fullPath, skillConfig, 'source', sharedCtx);
1686
2140
  const configAware = packagingResultToValidationResult(fullPath, packagingResult, skillConfig.targets, locationRoot);
1687
2141
  await appendDistributedTreeFindings(configAware, fullPath, locationRoot, ownsOwnCrawl(scanCtx, fullPath));
1688
2142
  return configAware;
1689
2143
  }
1690
- // Wild mode: basic validation
1691
- const validateOptions = { skillPath: fullPath, locationRoot };
2144
+ // Wild mode: basic validation. Severity is applied by `applySeverityFilter`.
2145
+ const validateOptions = { skillPath: fullPath, locationRoot, validation: {} };
1692
2146
  if (options.warnUnreferencedFiles) {
1693
2147
  validateOptions.checkUnreferencedFiles = true;
1694
2148
  }
@@ -1702,89 +2156,15 @@ async function handleFileEntry(entry, fullPath, options, logger, scanCtx) {
1702
2156
  return null;
1703
2157
  }
1704
2158
  /**
1705
- * Handle directory entry during directory scan
2159
+ * Validate a directory the population found holding `.claude-plugin/` — a
2160
+ * plugin or a marketplace, whichever the unified validator decides.
1706
2161
  */
1707
- async function handleDirectoryEntry(fullPath, recursive, options, logger, baseDir, scanCtx, nestedConfigLog) {
1708
- const fs = await import('node:fs/promises');
1709
- const { locationRoot } = scanCtx;
1710
- const results = [];
1711
- // Check if directory contains a plugin or marketplace
1712
- const claudePluginDir = safePath.join(fullPath, '.claude-plugin');
1713
- const hasClaudePlugin = await fs.access(claudePluginDir).then(() => true).catch(() => false);
1714
- if (hasClaudePlugin) {
1715
- logger.debug(`Validating resource directory: ${fullPath}`);
1716
- const result = await validate(fullPath, { validatePlugin, locationRoot });
1717
- const inv = await pluginInventoryAt(fullPath);
1718
- appendInventoryParseErrors(result, inv, locationRoot);
1719
- results.push(result);
1720
- }
1721
- // Recurse into subdirectories (both plugin/marketplace dirs and regular dirs).
1722
- // Below a plugin, `validatePlugin` has already crawled the whole subtree for
1723
- // agent-instruction files, so every crawl below must stand down or one file
1724
- // is reported twice. See {@link ScanContext.crawledRoot} — first owner wins,
1725
- // and it never clears on the way down.
1726
- const childCtx = claimSubtree(scanCtx, hasClaudePlugin ? fullPath : null);
1727
- if (recursive) {
1728
- const subResults = await scanDirectory(fullPath, recursive, options, logger, childCtx, baseDir, nestedConfigLog);
1729
- results.push(...subResults);
1730
- }
1731
- return results;
1732
- }
1733
- /**
1734
- * Compile the governing project's `resources.exclude` for a scan.
1735
- *
1736
- * The config is found by walking UP from the scan directory ({@link findProjectRoot}),
1737
- * not by looking in it. Looking only in the scan directory is how a path argument
1738
- * silently voided every exclude the project had declared: `vat audit
1739
- * packages/x/resources/skills/` found no config there, so a package that excludes
1740
- * its deliberately-broken eval fixtures had them audited as production skills the
1741
- * moment anyone named a subdirectory. Commit 8a466b0c fixed the same defect for
1742
- * `vat resources validate|scan` and `vat rag index`; this is the lane it missed.
1743
- * The rule is the same one that commit established: a path argument says WHICH
1744
- * tree to audit, and `exclude` applies either way.
1745
- *
1746
- * With one deliberate exception, mirroring the gitignore rule in
1747
- * {@link resolveScanContext}: when the operator points the scan AT an excluded
1748
- * tree, their explicit intent wins and the excludes are dropped for that run.
1749
- * Otherwise naming an excluded directory would report `filesScanned: 0,
1750
- * status: success` — a green run that scanned nothing.
1751
- */
1752
- function resolveProjectExcludes(scanDir, logger) {
1753
- const projectRoot = findProjectRoot(scanDir);
1754
- if (projectRoot === null)
1755
- return null;
1756
- let patterns;
1757
- try {
1758
- patterns = loadConfig(projectRoot)?.resources?.exclude ?? [];
1759
- }
1760
- catch (err) {
1761
- // Audit is a bulk linter over trees it does not own; a broken governing
1762
- // config must not abort the scan. Say so rather than dropping it silently.
1763
- //
1764
- // 🚨 `warn`, not `debug`. This comment already said "say so" while logging at
1765
- // a level nobody sees without `--debug`, so what actually happened was the
1766
- // silent drop it forbids — and the consequence is the one this function's own
1767
- // docstring spells out: EVERY `resources.exclude` the project declared is
1768
- // void, so a package that excludes its deliberately-broken eval fixtures has
1769
- // them audited as production skills. That is findings APPEARING, not findings
1770
- // disappearing, which is exactly the direction an operator will read as VAT
1771
- // being wrong rather than as their config being broken.
1772
- logger.warn(`Config at ${projectRoot} could not be read; every resources.exclude it declares is`
1773
- + ` dropped for this run, so excluded trees are audited as ordinary source: ${String(err)}`);
1774
- return null;
1775
- }
1776
- if (patterns.length === 0)
1777
- return null;
1778
- // dot:true so excludes like `**/.cache/*` match through dotfile dirs;
1779
- // without it the exclude silently never fires.
1780
- const isMatch = picomatch([...patterns], { dot: true });
1781
- const scanRelToProject = safePath.relative(projectRoot, safePath.resolve(scanDir)).replaceAll('\\', '/');
1782
- if (scanRelToProject !== '' && isExcludedPath(isMatch, scanRelToProject, true)) {
1783
- logger.debug(`Scan root ${scanRelToProject} is excluded by the config at ${projectRoot}; ` +
1784
- 'auditing it anyway because it was named explicitly');
1785
- return null;
1786
- }
1787
- return { isMatch, base: projectRoot, label: 'excluded by config' };
2162
+ async function validatePluginDirectory(dir, logger, locationRoot) {
2163
+ logger.debug(`Validating resource directory: ${dir}`);
2164
+ const result = await validate(dir, { validatePlugin, locationRoot });
2165
+ const inv = await pluginInventoryAt(dir);
2166
+ appendInventoryParseErrors(result, inv, locationRoot);
2167
+ return result;
1788
2168
  }
1789
2169
  /**
1790
2170
  * Cache of (projectRoot → registry for the *inventory* link walk).
@@ -1855,6 +2235,7 @@ export function resetAuditCaches() {
1855
2235
  resetPackagingRegistryCache();
1856
2236
  inventoryRegistryCache.clear();
1857
2237
  unloadableConfigResults.clear();
2238
+ runSuiteProbe = undefined;
1858
2239
  resetProjectRootCaches();
1859
2240
  resetLoadedConfigCache();
1860
2241
  resetSkillDiscoveryCache();
@@ -1883,43 +2264,6 @@ async function resolveScanContext(dirPath, locationRoot, logger) {
1883
2264
  }
1884
2265
  return { gitRoot: detectedRoot, gitTracker: tracker, locationRoot, projectExcludes, crawledRoot: null };
1885
2266
  }
1886
- /**
1887
- * Build the gitignore exclusion map for a set of entry paths.
1888
- * Returns null when gitignore filtering is not applicable (no git root,
1889
- * --include-artifacts, or outside a git repo).
1890
- */
1891
- function buildGitIgnoreMap(entryPaths, gitTracker, includeArtifacts) {
1892
- if (gitTracker === null || includeArtifacts) {
1893
- return null;
1894
- }
1895
- const map = new Map();
1896
- for (const entryPath of entryPaths) {
1897
- map.set(entryPath, gitTracker.isIgnoredByActiveSet(entryPath));
1898
- }
1899
- return map;
1900
- }
1901
- /**
1902
- * Check whether a directory entry should be skipped during scanning.
1903
- * Returns a reason string for debug logging, or null if the entry should be kept.
1904
- */
1905
- function getSkipReason(entry, fullPath, gitIgnoredMap, excludeMatchers) {
1906
- // Always skip .git directory (not reported by git check-ignore)
1907
- if (entry.isDirectory() && entry.name === '.git') {
1908
- return '.git';
1909
- }
1910
- // Skip gitignored paths
1911
- if (gitIgnoredMap !== null && gitIgnoredMap.get(fullPath) === true) {
1912
- return `gitignored: ${entry.name}`;
1913
- }
1914
- // Each matcher answers against ITS OWN base — see {@link ExcludeMatcher}.
1915
- for (const matcher of excludeMatchers) {
1916
- const relativePath = safePath.relative(matcher.base, fullPath).replaceAll('\\', '/');
1917
- if (isExcludedPath(matcher.isMatch, relativePath, entry.isDirectory())) {
1918
- return `${matcher.label}: ${relativePath}`;
1919
- }
1920
- }
1921
- return null;
1922
- }
1923
2267
  /**
1924
2268
  * The one finding a path the walk could not read produces — a directory it could
1925
2269
  * not enter, or a file it could not open.
@@ -1936,14 +2280,11 @@ function getSkipReason(entry, fullPath, gitIgnoredMap, excludeMatchers) {
1936
2280
  * is no longer the ONLY thing the run says — the code, the location and the fix
1937
2281
  * come from the registry.
1938
2282
  *
1939
- * KNOWN, and deliberately not fixed here: this result counts toward
1940
- * `summary.filesScanned`, so a path the scan could not read is counted among the
1941
- * files it scanned. Dropping it from that denominator alone would be worse —
1942
- * `filesWithWarnings` counts the same results, so the summary would report more
1943
- * files with warnings than files scanned. Saying it honestly needs a field of its
1944
- * own, which is a change to the report's shape and belongs with issue #177
1945
- * ("report counts don't describe the artifact") rather than smuggled in here. The
1946
- * finding itself names the path, so nothing is hidden in the meantime.
2283
+ * This result is a `files[]` row but NOT a scanned file: `countFilesByStatus`
2284
+ * recognises it ({@link isUnreadablePathResult}) and reports it under
2285
+ * `summary.pathsUnreadable`, outside `filesScanned` and the status counts. A
2286
+ * root the OS refused therefore audits zero files and ends on the zero-files
2287
+ * refusal, instead of `filesScanned: 1` over a path nothing read.
1947
2288
  */
1948
2289
  function unreadablePathResult(dirPath, error, locationRoot) {
1949
2290
  // `issueLocation` is `path.relative`, so it answers `''` when the subject IS the
@@ -1954,10 +2295,14 @@ function unreadablePathResult(dirPath, error, locationRoot) {
1954
2295
  // identical reason, already exists on the distributed-tree finding — see
1955
2296
  // `anchoredTreeLocation` in ./audit/distributed-tree.ts.
1956
2297
  const location = issueLocation(dirPath, locationRoot) || '.';
2298
+ // The OS names the path it refused absolutely; the report speaks scan-root
2299
+ // coordinates everywhere else, and an absolute path here is the build host's
2300
+ // `$HOME` in every CI log.
2301
+ const reason = withRelativePath(error instanceof Error ? error.message : String(error), dirPath, locationRoot);
1957
2302
  const issues = [
1958
2303
  materializeIssue('SCAN_PATH_UNREADABLE', {
1959
2304
  location,
1960
- detail: `${location}: ${error instanceof Error ? error.message : String(error)}`,
2305
+ detail: `${location}: ${reason}`,
1961
2306
  }),
1962
2307
  ];
1963
2308
  const issueCounts = countBySeverity(issues);
@@ -1971,30 +2316,36 @@ function unreadablePathResult(dirPath, error, locationRoot) {
1971
2316
  };
1972
2317
  }
1973
2318
  /**
1974
- * Scan ONE directory entry, degrading to a finding if the filesystem refuses it.
2319
+ * Validate ONE subject the population found, degrading to a finding if the
2320
+ * filesystem refuses it.
1975
2321
  *
1976
- * Guarded per ENTRY, not just per directory: wrapping only the parent `readdir`
1977
- * left the other half of the same defect live — an unreadable FILE (a root-owned
1978
- * `SKILL.md`, a quarantined bundle) threw out of `validateSkill` and still
1979
- * aborted the whole run with `status: error` and zero findings. Under
2322
+ * Guarded per SUBJECT, not just per listing: an unreadable FILE (a root-owned
2323
+ * `SKILL.md`, a quarantined bundle) used to throw out of `validateSkill` and
2324
+ * abort the whole run with `status: error` and zero findings. Under
1980
2325
  * `~/.claude/plugins` — the flagship `vat audit --user` target, populated by sudo
1981
2326
  * installs and macOS quarantine — a root-owned FILE is at least as likely as a
1982
- * root-owned directory (issue #180).
2327
+ * root-owned directory.
1983
2328
  *
1984
- * Extracted from `scanDirectory` rather than inlined because the added branch put
1985
- * that function over the cognitive-complexity ceiling; the walk keeps the
1986
- * bookkeeping, this keeps the per-entry decision.
2329
+ * Ownership is decided here and only here, in population order: a plugin
2330
+ * directory claims its subtree unconditionally; a skill claims its own only
2331
+ * when nothing above it already has AND its crawl actually runs
2332
+ * ({@link crawlOwnsSubtree}) — a repo-source skill's crawl is skipped, so
2333
+ * claiming its subtree would trade a double count for a missed finding.
1987
2334
  */
1988
- async function scanEntry(entry, fullPath, ctx) {
2335
+ async function validateScanSubject(subject, options, logger, scanCtx, claims) {
2336
+ const { locationRoot } = scanCtx;
1989
2337
  try {
1990
- if (entry.isFile()) {
1991
- const result = await handleFileEntry(entry, fullPath, ctx.options, ctx.logger, ctx.scanCtx);
1992
- return result === null ? [] : [result];
2338
+ if (subject.kind === 'plugin') {
2339
+ const result = await validatePluginDirectory(subject.dir, logger, locationRoot);
2340
+ claims.claim(subject.dir);
2341
+ return [result];
1993
2342
  }
1994
- if (entry.isDirectory()) {
1995
- return await handleDirectoryEntry(fullPath, ctx.recursive, ctx.options, ctx.logger, ctx.baseDir, ctx.descendCtx, ctx.nestedConfigLog);
2343
+ const subjectCtx = { ...scanCtx, crawledRoot: claims.owner(subject.path) };
2344
+ const result = await handleFileEntry({ name: basename(subject.path) }, subject.path, options, logger, subjectCtx);
2345
+ if (subject.kind === 'skill' && ownsOwnCrawl(subjectCtx, subject.path) && await crawlOwnsSubtree(subject.path)) {
2346
+ claims.claim(subject.dir);
1996
2347
  }
1997
- return [];
2348
+ return result === null ? [] : [result];
1998
2349
  }
1999
2350
  catch (error) {
2000
2351
  // ONLY filesystem-access errors degrade. Anything else is rethrown, because
@@ -2003,87 +2354,61 @@ async function scanEntry(entry, fullPath, ctx) {
2003
2354
  // prevent — it would make the tool quietest exactly when it is most wrong.
2004
2355
  if (!isFilesystemAccessError(error))
2005
2356
  throw error;
2006
- ctx.logger.debug(`Unreadable entry: ${fullPath}`);
2007
- return [unreadablePathResult(fullPath, error, ctx.scanCtx.locationRoot)];
2357
+ const path = subject.kind === 'plugin' ? subject.dir : subject.path;
2358
+ logger.debug(`Unreadable entry: ${path}`);
2359
+ return [unreadablePathResult(path, error, locationRoot)];
2008
2360
  }
2009
2361
  }
2010
- async function scanDirectory(dirPath, recursive, options, logger, scanCtx, baseDir, nestedConfigLog) {
2011
- const fs = await import('node:fs/promises');
2012
- const results = [];
2013
- const userExcludes = options.exclude ?? [];
2014
- const resolvedBaseDir = baseDir ?? dirPath;
2015
- // The git context (root + tracker) and the run's anchor base are built once by
2016
- // the caller; every recursion reuses them unchanged.
2017
- const resolvedScanCtx = scanCtx;
2018
- const resolvedNestedLog = nestedConfigLog ?? new Set();
2019
- // Top-down pre-warm of findProjectRoot's walk-up cache (spec §8). The first
2020
- // call here drives a single ancestor walk; every subsequent recursion is a
2021
- // Layer-1 hit chain. By the time we reach leaf SKILL.md files,
2022
- // resolveSkillPackagingConfig's per-skill walk-up is all cache hits.
2362
+ /**
2363
+ * The remedy a refused listing names: the operator's own knob for dropping a
2364
+ * directory from the audit on purpose.
2365
+ */
2366
+ function scanRefusalRemedy() {
2367
+ return 'To leave it out deliberately, exclude it with --exclude <pattern>.';
2368
+ }
2369
+ /**
2370
+ * Audit a directory: every plugin, skill and registry file the `crawl` lane
2371
+ * finds beneath it, plus one finding per directory the lane could not list.
2372
+ *
2373
+ * The enumeration is `enumerateAuditPopulation`'s — this function owns no
2374
+ * walk of its own. What it decides is what the audit MAKES of the population:
2375
+ * validation per subject, subtree ownership across subjects (see
2376
+ * {@link validateScanSubject}), the nested-config breadcrumb, and the refusal
2377
+ * findings. A refused directory is a finding, not a thrown error: `vat audit`
2378
+ * is a bulk linter over trees it does not own, and one root-owned directory
2379
+ * under `~/.claude/plugins` used to abort the flagship `vat audit --user` run
2380
+ * outright, losing every finding already collected. The scope of the loss is
2381
+ * the subtree that refused; every readable sibling is still validated.
2382
+ */
2383
+ async function scanDirectory(dirPath, recursive, options, logger, scanCtx) {
2384
+ // Warm findProjectRoot's walk-up cache from the scan root: every per-skill
2385
+ // walk-up below lands on a cached ancestor instead of re-walking to `/`.
2023
2386
  findProjectRoot(dirPath);
2024
- // Emit a one-time info breadcrumb when we encounter a nested
2025
- // vibe-agent-toolkit.config.yaml that is NOT at the scan root. Configs do
2026
- // not compose across VAT projects; the message reminds operators that
2027
- // only per-skill packaging rules from this config still apply.
2028
- const nestedConfigPath = safePath.join(dirPath, VAT_CONFIG_FILENAME);
2029
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- dirPath is a controlled scan path
2030
- if (dirPath !== resolvedBaseDir && fsExistsSync(nestedConfigPath) && !resolvedNestedLog.has(nestedConfigPath)) {
2031
- resolvedNestedLog.add(nestedConfigPath);
2032
- const rel = safePath.relative(resolvedBaseDir, nestedConfigPath).replaceAll('\\', '/');
2033
- 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.`);
2034
- }
2035
- // Compile picomatch for user-supplied --exclude patterns.
2036
- // dot:true so excludes like `**/private/*` match through dotfile dirs
2037
- // (`.claude/.../private/x`); without it the exclude silently never fires.
2038
- // These are relative to the scan base; the config's excludes carry their own
2039
- // (project-root) base on the matcher — see {@link ExcludeMatcher}.
2040
- const excludeMatchers = [];
2041
- if (userExcludes.length > 0) {
2042
- excludeMatchers.push({
2043
- isMatch: picomatch(userExcludes, { dot: true }),
2044
- base: resolvedBaseDir,
2045
- label: 'excluded',
2046
- });
2047
- }
2048
- if (resolvedScanCtx.projectExcludes !== null) {
2049
- excludeMatchers.push(resolvedScanCtx.projectExcludes);
2050
- }
2051
- // Guarded because `vat audit` is a bulk linter over trees it does not own, and
2052
- // an unreadable entry is an ordinary condition there — one root-owned directory
2053
- // under `~/.claude/plugins` used to abort the flagship `vat audit --user` run
2054
- // outright, losing every finding already collected (issue #180). The failure is
2055
- // scoped to the subtree that caused it: this returns instead of throwing, so the
2056
- // CALLER's loop keeps its readable siblings and the walk continues.
2057
- let entries;
2058
- try {
2059
- entries = await fs.readdir(dirPath, { withFileTypes: true });
2060
- }
2061
- catch (error) {
2062
- // Same predicate as the per-entry guard below — see it for why a non-IO
2063
- // throw must not be degraded into a finding.
2064
- if (!isFilesystemAccessError(error))
2065
- throw error;
2066
- logger.debug(`Unreadable directory: ${dirPath}`);
2067
- return [unreadablePathResult(dirPath, error, resolvedScanCtx.locationRoot)];
2068
- }
2069
- const descendCtx = await contextForDescendants(dirPath, entries, resolvedScanCtx);
2070
- // Build full paths and batch-check gitignore status
2071
- const entryPaths = entries.map(e => safePath.join(dirPath, e.name));
2072
- const gitIgnoredMap = buildGitIgnoreMap(entryPaths, resolvedScanCtx.gitTracker, options.includeArtifacts ?? false);
2073
- for (const entry of entries) {
2074
- const fullPath = safePath.join(dirPath, entry.name);
2075
- const skipReason = getSkipReason(entry, fullPath, gitIgnoredMap, excludeMatchers);
2076
- if (skipReason !== null) {
2077
- logger.debug(`Excluding path: ${skipReason}`);
2078
- continue;
2079
- }
2080
- results.push(...await scanEntry(entry, fullPath, {
2081
- recursive, options, logger,
2082
- scanCtx: resolvedScanCtx,
2083
- descendCtx,
2084
- baseDir: resolvedBaseDir,
2085
- nestedConfigLog: resolvedNestedLog,
2086
- }));
2387
+ const population = await enumerateAuditPopulation({
2388
+ scanDir: dirPath,
2389
+ recursive,
2390
+ // No tracker means no git root, or a scan root that is itself gitignored
2391
+ // (the operator's explicit intent wins); either way ignored files are in.
2392
+ respectGitignore: scanCtx.gitTracker !== null && !(options.includeArtifacts ?? false),
2393
+ userExcludes: options.exclude ?? [],
2394
+ projectExcludes: scanCtx.projectExcludes,
2395
+ });
2396
+ // A one-time breadcrumb per nested `vibe-agent-toolkit.config.yaml` that is
2397
+ // NOT at the scan root. Configs do not compose across VAT projects; the
2398
+ // message reminds operators that only per-skill packaging rules from this
2399
+ // config still apply.
2400
+ for (const nestedConfigPath of population.nestedConfigs) {
2401
+ const rel = toForwardSlash(safePath.relative(dirPath, nestedConfigPath));
2402
+ logger.info(`Nested ${VAT_CONFIG_FILENAME} detected at ${rel} — configs do not compose across VAT projects. Per-skill packaging rules from this config still apply to skills declared in it.`);
2403
+ }
2404
+ const results = population.refusals.map((refusal) => {
2405
+ logger.debug(`Unreadable directory: ${refusal.directory}`);
2406
+ const err = new DirectoryListingRefusedError(refusal, { root: dirPath, remedy: scanRefusalRemedy() });
2407
+ return unreadablePathResult(refusal.directory, err, scanCtx.locationRoot);
2408
+ });
2409
+ const claims = new SubtreeClaims();
2410
+ for (const subject of population.subjects) {
2411
+ results.push(...await validateScanSubject(subject, options, logger, scanCtx, claims));
2087
2412
  }
2088
2413
  return results;
2089
2414
  }
@@ -2112,13 +2437,34 @@ function calculateOverallStatus(results) {
2112
2437
  function countResultsWithFindings(results) {
2113
2438
  return results.filter((r) => r.issues.length > 0).length;
2114
2439
  }
2115
- /** Count files by their own status. */
2440
+ /**
2441
+ * Whether a `files[]` row is a refused path rather than a scanned file: the
2442
+ * synthetic result {@link unreadablePathResult} builds, carrying nothing but
2443
+ * `SCAN_PATH_UNREADABLE`. Keyed on the code, not on `type: 'unknown'` alone —
2444
+ * an unrecognised file is also `unknown`, and it WAS examined.
2445
+ */
2446
+ function isUnreadablePathResult(result) {
2447
+ return result.type === 'unknown'
2448
+ && result.issues.length > 0
2449
+ && result.issues.every((issue) => issue.code === 'SCAN_PATH_UNREADABLE');
2450
+ }
2451
+ /**
2452
+ * Count files by their own status.
2453
+ *
2454
+ * A refused path is NOT a scanned file. It used to be counted as one, which
2455
+ * made the zero-files refusal unreachable for exactly the tree it exists for:
2456
+ * `chmod 000` on the whole root gave `filesScanned: 1, filesPassed: 0`,
2457
+ * `status: warning`, exit 0 — nothing read, gate green. Kept out of every
2458
+ * status count too, or `filesWithWarnings` would exceed `filesScanned`.
2459
+ */
2116
2460
  function countFilesByStatus(results) {
2461
+ const scanned = results.filter((r) => !isUnreadablePathResult(r));
2117
2462
  return {
2118
- filesScanned: results.length,
2119
- filesPassed: results.filter((r) => r.status === 'success').length,
2120
- filesWithWarnings: results.filter((r) => r.status === 'warning').length,
2121
- filesWithErrors: results.filter((r) => r.status === 'error').length,
2463
+ filesScanned: scanned.length,
2464
+ filesPassed: scanned.filter((r) => r.status === 'success').length,
2465
+ filesWithWarnings: scanned.filter((r) => r.status === 'warning').length,
2466
+ filesWithErrors: scanned.filter((r) => r.status === 'error').length,
2467
+ pathsUnreadable: results.length - scanned.length,
2122
2468
  };
2123
2469
  }
2124
2470
  /**
@@ -2146,6 +2492,24 @@ function countFilesByStatus(results) {
2146
2492
  * also present in its owner's `issues`, so the nested list is a VIEW of records
2147
2493
  * already counted, not extra records. `audit-report-coherence.integration.test.ts`
2148
2494
  * pins that subset relation, because counting once is only correct while it holds.
2495
+ *
2496
+ * 🚨 **Zero files audited is an ERROR, not "an empty tree audits cleanly".**
2497
+ * `calculateOverallStatus([])` is `success`, so an existing directory holding
2498
+ * nothing auditable — moved plugins, a wrong subdirectory, an excluded tree, a
2499
+ * tree of files no lane recognises — published `status: success` beside
2500
+ * `filesScanned: 0`, and this command's own documentation tells CI to gate on
2501
+ * that `status`. Derived HERE, in the one builder both the directory lane
2502
+ * (`calculateSummary`) and the `--user` lane (`calculateHierarchicalSummary`)
2503
+ * pass through, so neither can publish a clean status over a zero denominator.
2504
+ * Through the shared mechanism in `run-integrity.ts`: one non-overridable
2505
+ * `RESOURCE_CHECK_BROKEN` at `error`, published under a top-level `issues` —
2506
+ * the claim is about the RUN, so it is not a `files[]` row and does not count
2507
+ * toward `filesScanned` — and counted in the header `issueCounts`, so the
2508
+ * identity becomes `issueCounts === Σ files[].issueCounts + Σ issues`.
2509
+ *
2510
+ * The exit code is NOT touched by this: `status` describes the findings and the
2511
+ * exit code describes whether the run completed, and this run completed. See
2512
+ * {@link createAuditCommand}.
2149
2513
  */
2150
2514
  function buildBaseSummary(results, startTime) {
2151
2515
  const issueCounts = { errors: 0, warnings: 0, info: 0 };
@@ -2156,11 +2520,24 @@ function buildBaseSummary(results, startTime) {
2156
2520
  issueCounts.info += counts.info;
2157
2521
  return { ...result, issueCounts: counts };
2158
2522
  });
2523
+ const summary = countFilesByStatus(entries);
2524
+ // The denominator is files READ: a tree whose every path was refused audited
2525
+ // nothing, and says so through the same refusal as an empty tree.
2526
+ const runIssues = nothingCheckedFinding(summary.filesScanned, entries.flatMap((entry) => entry.issues), () => 'The audit ran over 0 files, so this report is not a verdict: a tree with nothing'
2527
+ + ' to audit produces the same counts as a clean one. The path resolved to a tree'
2528
+ + ' holding no auditable file — usually a wrong subdirectory, plugins or skills that'
2529
+ + ' have moved, an excluded tree, files no audit lane recognises, or `--no-recursive`'
2530
+ + ' over a tree whose resources sit deeper. Point the command at the plugin,'
2531
+ + ' marketplace, registry or SKILL.md tree it should read.');
2159
2532
  return {
2160
2533
  entries,
2161
- status: calculateOverallStatus(entries),
2162
- summary: countFilesByStatus(entries),
2163
- issueCounts,
2534
+ // Every run-integrity finding is `error` by construction (invariant 2 of
2535
+ // `run-integrity.ts`), so a non-empty refusal IS the status; otherwise the
2536
+ // files decide it, as before.
2537
+ status: runIssues.length > 0 ? 'error' : calculateOverallStatus(entries),
2538
+ summary,
2539
+ issueCounts: sumSeverityCounts([issueCounts, countBySeverity(runIssues)]),
2540
+ ...(runIssues.length === 0 ? {} : { issues: [...runIssues] }),
2164
2541
  duration: `${Date.now() - startTime}ms`,
2165
2542
  };
2166
2543
  }
@@ -2169,7 +2546,7 @@ function buildBaseSummary(results, startTime) {
2169
2546
  */
2170
2547
  function calculateHierarchicalSummary(results, hierarchical, startTime, compatMap, verbose, root) {
2171
2548
  const { entries, ...base } = buildBaseSummary(results, startTime);
2172
- const withCompat = applyCompatMap(entries, compatMap);
2549
+ const withCompat = applyCompatMap(entries, compatMap, root);
2173
2550
  return {
2174
2551
  root,
2175
2552
  ...base,
@@ -2187,15 +2564,18 @@ function calculateHierarchicalSummary(results, hierarchical, startTime, compatMa
2187
2564
  /**
2188
2565
  * Log hierarchical summary to stderr
2189
2566
  */
2190
- function logHierarchicalSummary(results, logger) {
2567
+ function logHierarchicalSummary(results, runIssues, logger) {
2191
2568
  const status = calculateOverallStatus(results);
2192
2569
  const skillsWithIssues = countResultsWithFindings(results);
2193
2570
  const totalSkills = results.length;
2194
- // Audit is advisory only — always exit 0 for validation results.
2195
- // Use vat skills validate for gated validation (exit 1 on errors).
2196
- if (status === 'error') {
2571
+ // Same rule as the per-file lane: the exit code follows `status`.
2572
+ const refused = logRunIntegrity(runIssues, logger);
2573
+ if (refused) {
2574
+ // Rendered above; there is no skill verdict to print over zero skills.
2575
+ }
2576
+ else if (status === 'error') {
2197
2577
  const errorCount = results.filter((r) => r.status === 'error').length;
2198
- logger.error(`Audit found ${errorCount} skill(s) with errors (${totalSkills} scanned, ${skillsWithIssues} with issues) ${ADVISORY_EXIT_NOTE}`);
2578
+ logger.error(`Audit found ${errorCount} skill(s) with errors (${totalSkills} scanned, ${skillsWithIssues} with issues)`);
2199
2579
  }
2200
2580
  else if (status === 'warning') {
2201
2581
  const warningCount = results.filter((r) => r.status === 'warning').length;
@@ -2210,6 +2590,6 @@ function logHierarchicalSummary(results, logger) {
2210
2590
  logger.info(`Audit successful: ${totalSkills} skill(s) passed`);
2211
2591
  }
2212
2592
  renderAuditFooter(results, logger);
2213
- process.exit(0);
2593
+ process.exit(refused || status === 'error' ? ExitCode.FINDINGS : ExitCode.OK);
2214
2594
  }
2215
2595
  //# sourceMappingURL=audit.js.map