@vibe-agent-toolkit/cli 0.2.0-rc.7 → 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 (353) hide show
  1. package/README.md +0 -1
  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 +5 -2
  6. package/dist/bin.js.map +1 -1
  7. package/dist/commands/agent/build.d.ts.map +1 -1
  8. package/dist/commands/agent/build.js +2 -1
  9. package/dist/commands/agent/build.js.map +1 -1
  10. package/dist/commands/agent/import.d.ts.map +1 -1
  11. package/dist/commands/agent/import.js +3 -2
  12. package/dist/commands/agent/import.js.map +1 -1
  13. package/dist/commands/agent/install-path.d.ts +36 -0
  14. package/dist/commands/agent/install-path.d.ts.map +1 -0
  15. package/dist/commands/agent/install-path.js +44 -0
  16. package/dist/commands/agent/install-path.js.map +1 -0
  17. package/dist/commands/agent/install.d.ts.map +1 -1
  18. package/dist/commands/agent/install.js +22 -10
  19. package/dist/commands/agent/install.js.map +1 -1
  20. package/dist/commands/agent/installed.d.ts.map +1 -1
  21. package/dist/commands/agent/installed.js +22 -12
  22. package/dist/commands/agent/installed.js.map +1 -1
  23. package/dist/commands/agent/list.d.ts.map +1 -1
  24. package/dist/commands/agent/list.js +2 -1
  25. package/dist/commands/agent/list.js.map +1 -1
  26. package/dist/commands/agent/run.d.ts.map +1 -1
  27. package/dist/commands/agent/run.js +2 -1
  28. package/dist/commands/agent/run.js.map +1 -1
  29. package/dist/commands/agent/uninstall.d.ts.map +1 -1
  30. package/dist/commands/agent/uninstall.js +13 -6
  31. package/dist/commands/agent/uninstall.js.map +1 -1
  32. package/dist/commands/agent/validate.d.ts.map +1 -1
  33. package/dist/commands/agent/validate.js +3 -2
  34. package/dist/commands/agent/validate.js.map +1 -1
  35. package/dist/commands/ard/emit.d.ts +72 -23
  36. package/dist/commands/ard/emit.d.ts.map +1 -1
  37. package/dist/commands/ard/emit.js +109 -56
  38. package/dist/commands/ard/emit.js.map +1 -1
  39. package/dist/commands/ard/index.d.ts +1 -1
  40. package/dist/commands/ard/index.d.ts.map +1 -1
  41. package/dist/commands/ard/index.js +4 -3
  42. package/dist/commands/ard/index.js.map +1 -1
  43. package/dist/commands/audit/distributed-tree.d.ts.map +1 -1
  44. package/dist/commands/audit/distributed-tree.js +9 -6
  45. package/dist/commands/audit/distributed-tree.js.map +1 -1
  46. package/dist/commands/audit/scan-population.d.ts +158 -0
  47. package/dist/commands/audit/scan-population.d.ts.map +1 -0
  48. package/dist/commands/audit/scan-population.js +292 -0
  49. package/dist/commands/audit/scan-population.js.map +1 -0
  50. package/dist/commands/audit-settings.d.ts.map +1 -1
  51. package/dist/commands/audit-settings.js +6 -5
  52. package/dist/commands/audit-settings.js.map +1 -1
  53. package/dist/commands/audit.d.ts +16 -14
  54. package/dist/commands/audit.d.ts.map +1 -1
  55. package/dist/commands/audit.js +304 -389
  56. package/dist/commands/audit.js.map +1 -1
  57. package/dist/commands/build.d.ts.map +1 -1
  58. package/dist/commands/build.js +24 -24
  59. package/dist/commands/build.js.map +1 -1
  60. package/dist/commands/cache/clear.d.ts.map +1 -1
  61. package/dist/commands/cache/clear.js +17 -10
  62. package/dist/commands/cache/clear.js.map +1 -1
  63. package/dist/commands/claude/budget.d.ts +127 -28
  64. package/dist/commands/claude/budget.d.ts.map +1 -1
  65. package/dist/commands/claude/budget.js +76 -33
  66. package/dist/commands/claude/budget.js.map +1 -1
  67. package/dist/commands/claude/context.d.ts.map +1 -1
  68. package/dist/commands/claude/context.js +3 -2
  69. package/dist/commands/claude/context.js.map +1 -1
  70. package/dist/commands/claude/marketplace/changelog-utils.d.ts +1 -1
  71. package/dist/commands/claude/marketplace/changelog-utils.d.ts.map +1 -1
  72. package/dist/commands/claude/marketplace/changelog-utils.js +1 -2
  73. package/dist/commands/claude/marketplace/changelog-utils.js.map +1 -1
  74. package/dist/commands/claude/marketplace/git-publish.d.ts.map +1 -1
  75. package/dist/commands/claude/marketplace/git-publish.js +3 -2
  76. package/dist/commands/claude/marketplace/git-publish.js.map +1 -1
  77. package/dist/commands/claude/marketplace/license-utils.d.ts.map +1 -1
  78. package/dist/commands/claude/marketplace/license-utils.js +0 -1
  79. package/dist/commands/claude/marketplace/license-utils.js.map +1 -1
  80. package/dist/commands/claude/marketplace/publish-tree.d.ts.map +1 -1
  81. package/dist/commands/claude/marketplace/publish-tree.js +0 -6
  82. package/dist/commands/claude/marketplace/publish-tree.js.map +1 -1
  83. package/dist/commands/claude/marketplace/publish.d.ts.map +1 -1
  84. package/dist/commands/claude/marketplace/publish.js +2 -1
  85. package/dist/commands/claude/marketplace/publish.js.map +1 -1
  86. package/dist/commands/claude/marketplace/validate.d.ts.map +1 -1
  87. package/dist/commands/claude/marketplace/validate.js +25 -15
  88. package/dist/commands/claude/marketplace/validate.js.map +1 -1
  89. package/dist/commands/claude/org/api-keys.d.ts +0 -3
  90. package/dist/commands/claude/org/api-keys.d.ts.map +1 -1
  91. package/dist/commands/claude/org/api-keys.js +2 -1
  92. package/dist/commands/claude/org/api-keys.js.map +1 -1
  93. package/dist/commands/claude/org/helpers.d.ts +17 -22
  94. package/dist/commands/claude/org/helpers.d.ts.map +1 -1
  95. package/dist/commands/claude/org/helpers.js +6 -22
  96. package/dist/commands/claude/org/helpers.js.map +1 -1
  97. package/dist/commands/claude/org/invites.d.ts +0 -3
  98. package/dist/commands/claude/org/invites.d.ts.map +1 -1
  99. package/dist/commands/claude/org/invites.js +3 -2
  100. package/dist/commands/claude/org/invites.js.map +1 -1
  101. package/dist/commands/claude/org/skills.d.ts +19 -1
  102. package/dist/commands/claude/org/skills.d.ts.map +1 -1
  103. package/dist/commands/claude/org/skills.js +141 -105
  104. package/dist/commands/claude/org/skills.js.map +1 -1
  105. package/dist/commands/claude/org/users.d.ts +0 -3
  106. package/dist/commands/claude/org/users.d.ts.map +1 -1
  107. package/dist/commands/claude/org/users.js +3 -2
  108. package/dist/commands/claude/org/users.js.map +1 -1
  109. package/dist/commands/claude/org/workspaces.d.ts +0 -3
  110. package/dist/commands/claude/org/workspaces.d.ts.map +1 -1
  111. package/dist/commands/claude/org/workspaces.js +6 -5
  112. package/dist/commands/claude/org/workspaces.js.map +1 -1
  113. package/dist/commands/claude/plugin/build.d.ts.map +1 -1
  114. package/dist/commands/claude/plugin/build.js +12 -31
  115. package/dist/commands/claude/plugin/build.js.map +1 -1
  116. package/dist/commands/claude/plugin/helpers.d.ts.map +1 -1
  117. package/dist/commands/claude/plugin/helpers.js +0 -6
  118. package/dist/commands/claude/plugin/helpers.js.map +1 -1
  119. package/dist/commands/claude/plugin/install.d.ts +12 -0
  120. package/dist/commands/claude/plugin/install.d.ts.map +1 -1
  121. package/dist/commands/claude/plugin/install.js +71 -60
  122. package/dist/commands/claude/plugin/install.js.map +1 -1
  123. package/dist/commands/claude/plugin/list.d.ts.map +1 -1
  124. package/dist/commands/claude/plugin/list.js +3 -2
  125. package/dist/commands/claude/plugin/list.js.map +1 -1
  126. package/dist/commands/claude/plugin/plugin-changelog.d.ts.map +1 -1
  127. package/dist/commands/claude/plugin/plugin-changelog.js +0 -2
  128. package/dist/commands/claude/plugin/plugin-changelog.js.map +1 -1
  129. package/dist/commands/claude/plugin/plugin-files.d.ts.map +1 -1
  130. package/dist/commands/claude/plugin/plugin-files.js +0 -3
  131. package/dist/commands/claude/plugin/plugin-files.js.map +1 -1
  132. package/dist/commands/claude/plugin/plugin-validators.d.ts.map +1 -1
  133. package/dist/commands/claude/plugin/plugin-validators.js +3 -6
  134. package/dist/commands/claude/plugin/plugin-validators.js.map +1 -1
  135. package/dist/commands/claude/plugin/tree-copy.d.ts +2 -1
  136. package/dist/commands/claude/plugin/tree-copy.d.ts.map +1 -1
  137. package/dist/commands/claude/plugin/tree-copy.js +15 -14
  138. package/dist/commands/claude/plugin/tree-copy.js.map +1 -1
  139. package/dist/commands/claude/plugin/uninstall.d.ts.map +1 -1
  140. package/dist/commands/claude/plugin/uninstall.js +4 -4
  141. package/dist/commands/claude/plugin/uninstall.js.map +1 -1
  142. package/dist/commands/consistency-check.d.ts +9 -13
  143. package/dist/commands/consistency-check.d.ts.map +1 -1
  144. package/dist/commands/consistency-check.js +91 -81
  145. package/dist/commands/consistency-check.js.map +1 -1
  146. package/dist/commands/corpus/report.d.ts.map +1 -1
  147. package/dist/commands/corpus/report.js +1 -2
  148. package/dist/commands/corpus/report.js.map +1 -1
  149. package/dist/commands/corpus/runner.d.ts.map +1 -1
  150. package/dist/commands/corpus/runner.js +2 -6
  151. package/dist/commands/corpus/runner.js.map +1 -1
  152. package/dist/commands/corpus/scan.d.ts.map +1 -1
  153. package/dist/commands/corpus/scan.js +2 -3
  154. package/dist/commands/corpus/scan.js.map +1 -1
  155. package/dist/commands/corpus/seed.d.ts +4 -4
  156. package/dist/commands/corpus/seed.d.ts.map +1 -1
  157. package/dist/commands/corpus/seed.js +1 -3
  158. package/dist/commands/corpus/seed.js.map +1 -1
  159. package/dist/commands/doctor.d.ts.map +1 -1
  160. package/dist/commands/doctor.js +55 -37
  161. package/dist/commands/doctor.js.map +1 -1
  162. package/dist/commands/inventory.d.ts.map +1 -1
  163. package/dist/commands/inventory.js +6 -7
  164. package/dist/commands/inventory.js.map +1 -1
  165. package/dist/commands/mcp/list-collections.d.ts.map +1 -1
  166. package/dist/commands/mcp/list-collections.js +2 -1
  167. package/dist/commands/mcp/list-collections.js.map +1 -1
  168. package/dist/commands/mcp/serve.d.ts.map +1 -1
  169. package/dist/commands/mcp/serve.js +3 -2
  170. package/dist/commands/mcp/serve.js.map +1 -1
  171. package/dist/commands/okf/index.d.ts.map +1 -1
  172. package/dist/commands/okf/index.js +14 -2
  173. package/dist/commands/okf/index.js.map +1 -1
  174. package/dist/commands/okf/validate.d.ts +157 -48
  175. package/dist/commands/okf/validate.d.ts.map +1 -1
  176. package/dist/commands/okf/validate.js +109 -76
  177. package/dist/commands/okf/validate.js.map +1 -1
  178. package/dist/commands/phase-utils.d.ts +11 -10
  179. package/dist/commands/phase-utils.d.ts.map +1 -1
  180. package/dist/commands/phase-utils.js +30 -14
  181. package/dist/commands/phase-utils.js.map +1 -1
  182. package/dist/commands/positional-args.d.ts.map +1 -1
  183. package/dist/commands/positional-args.js +2 -1
  184. package/dist/commands/positional-args.js.map +1 -1
  185. package/dist/commands/rag/clear-command.d.ts.map +1 -1
  186. package/dist/commands/rag/clear-command.js +2 -1
  187. package/dist/commands/rag/clear-command.js.map +1 -1
  188. package/dist/commands/rag/index-command.d.ts.map +1 -1
  189. package/dist/commands/rag/index-command.js +4 -1
  190. package/dist/commands/rag/index-command.js.map +1 -1
  191. package/dist/commands/rag/query-command.d.ts.map +1 -1
  192. package/dist/commands/rag/query-command.js +2 -1
  193. package/dist/commands/rag/query-command.js.map +1 -1
  194. package/dist/commands/rag/stats-command.d.ts.map +1 -1
  195. package/dist/commands/rag/stats-command.js +2 -1
  196. package/dist/commands/rag/stats-command.js.map +1 -1
  197. package/dist/commands/resources/check-progress.d.ts.map +1 -1
  198. package/dist/commands/resources/check-progress.js +5 -2
  199. package/dist/commands/resources/check-progress.js.map +1 -1
  200. package/dist/commands/resources/check-supervisor.d.ts.map +1 -1
  201. package/dist/commands/resources/check-supervisor.js +14 -7
  202. package/dist/commands/resources/check-supervisor.js.map +1 -1
  203. package/dist/commands/resources/check.d.ts +135 -4
  204. package/dist/commands/resources/check.d.ts.map +1 -1
  205. package/dist/commands/resources/check.js +97 -80
  206. package/dist/commands/resources/check.js.map +1 -1
  207. package/dist/commands/resources/index.d.ts.map +1 -1
  208. package/dist/commands/resources/index.js +24 -21
  209. package/dist/commands/resources/index.js.map +1 -1
  210. package/dist/commands/resources/query.d.ts.map +1 -1
  211. package/dist/commands/resources/query.js +4 -8
  212. package/dist/commands/resources/query.js.map +1 -1
  213. package/dist/commands/resources/scan.d.ts.map +1 -1
  214. package/dist/commands/resources/scan.js +4 -8
  215. package/dist/commands/resources/scan.js.map +1 -1
  216. package/dist/commands/resources/validate.d.ts +1 -1
  217. package/dist/commands/resources/validate.d.ts.map +1 -1
  218. package/dist/commands/resources/validate.js +0 -1
  219. package/dist/commands/resources/validate.js.map +1 -1
  220. package/dist/commands/skill/review-checklist.d.ts +3 -2
  221. package/dist/commands/skill/review-checklist.d.ts.map +1 -1
  222. package/dist/commands/skill/review-checklist.js +4 -2
  223. package/dist/commands/skill/review-checklist.js.map +1 -1
  224. package/dist/commands/skill/review.d.ts +247 -0
  225. package/dist/commands/skill/review.d.ts.map +1 -1
  226. package/dist/commands/skill/review.js +90 -60
  227. package/dist/commands/skill/review.js.map +1 -1
  228. package/dist/commands/skill/test/configure.d.ts.map +1 -1
  229. package/dist/commands/skill/test/configure.js +0 -1
  230. package/dist/commands/skill/test/configure.js.map +1 -1
  231. package/dist/commands/skill/test/run.d.ts +12 -6
  232. package/dist/commands/skill/test/run.d.ts.map +1 -1
  233. package/dist/commands/skill/test/run.js +118 -63
  234. package/dist/commands/skill/test/run.js.map +1 -1
  235. package/dist/commands/skills/build.d.ts +69 -2
  236. package/dist/commands/skills/build.d.ts.map +1 -1
  237. package/dist/commands/skills/build.js +112 -39
  238. package/dist/commands/skills/build.js.map +1 -1
  239. package/dist/commands/skills/install.d.ts +2 -1
  240. package/dist/commands/skills/install.d.ts.map +1 -1
  241. package/dist/commands/skills/install.js +20 -30
  242. package/dist/commands/skills/install.js.map +1 -1
  243. package/dist/commands/skills/list.d.ts.map +1 -1
  244. package/dist/commands/skills/list.js +14 -18
  245. package/dist/commands/skills/list.js.map +1 -1
  246. package/dist/commands/skills/package.d.ts +1 -1
  247. package/dist/commands/skills/package.d.ts.map +1 -1
  248. package/dist/commands/skills/package.js +6 -8
  249. package/dist/commands/skills/package.js.map +1 -1
  250. package/dist/commands/skills/scope-guard.d.ts.map +1 -1
  251. package/dist/commands/skills/scope-guard.js +2 -4
  252. package/dist/commands/skills/scope-guard.js.map +1 -1
  253. package/dist/commands/skills/shared.d.ts.map +1 -1
  254. package/dist/commands/skills/shared.js +2 -4
  255. package/dist/commands/skills/shared.js.map +1 -1
  256. package/dist/commands/skills/skill-discovery.d.ts.map +1 -1
  257. package/dist/commands/skills/skill-discovery.js +0 -1
  258. package/dist/commands/skills/skill-discovery.js.map +1 -1
  259. package/dist/commands/skills/source-resolvers.d.ts +15 -0
  260. package/dist/commands/skills/source-resolvers.d.ts.map +1 -1
  261. package/dist/commands/skills/source-resolvers.js +25 -4
  262. package/dist/commands/skills/source-resolvers.js.map +1 -1
  263. package/dist/commands/validate.d.ts +1 -1
  264. package/dist/commands/validate.js +8 -6
  265. package/dist/commands/validate.js.map +1 -1
  266. package/dist/commands/verify.d.ts +8 -2
  267. package/dist/commands/verify.d.ts.map +1 -1
  268. package/dist/commands/verify.js +86 -56
  269. package/dist/commands/verify.js.map +1 -1
  270. package/dist/index.d.ts +0 -1
  271. package/dist/index.d.ts.map +1 -1
  272. package/dist/index.js +5 -1
  273. package/dist/index.js.map +1 -1
  274. package/dist/report-schemas.d.ts +45 -0
  275. package/dist/report-schemas.d.ts.map +1 -0
  276. package/dist/report-schemas.js +102 -0
  277. package/dist/report-schemas.js.map +1 -0
  278. package/dist/skill-resolution/resolve-skill-reference.d.ts.map +1 -1
  279. package/dist/skill-resolution/resolve-skill-reference.js +2 -3
  280. package/dist/skill-resolution/resolve-skill-reference.js.map +1 -1
  281. package/dist/utils/agent-discovery.d.ts.map +1 -1
  282. package/dist/utils/agent-discovery.js +22 -11
  283. package/dist/utils/agent-discovery.js.map +1 -1
  284. package/dist/utils/agent-runner.d.ts.map +1 -1
  285. package/dist/utils/agent-runner.js +0 -1
  286. package/dist/utils/agent-runner.js.map +1 -1
  287. package/dist/utils/command-error.d.ts +63 -62
  288. package/dist/utils/command-error.d.ts.map +1 -1
  289. package/dist/utils/command-error.js +80 -76
  290. package/dist/utils/command-error.js.map +1 -1
  291. package/dist/utils/config-loader.d.ts +2 -1
  292. package/dist/utils/config-loader.d.ts.map +1 -1
  293. package/dist/utils/config-loader.js +3 -6
  294. package/dist/utils/config-loader.js.map +1 -1
  295. package/dist/utils/context-budget-issues.d.ts +1 -1
  296. package/dist/utils/context-budget-issues.js +1 -1
  297. package/dist/utils/corpus-target.d.ts +5 -27
  298. package/dist/utils/corpus-target.d.ts.map +1 -1
  299. package/dist/utils/corpus-target.js +7 -33
  300. package/dist/utils/corpus-target.js.map +1 -1
  301. package/dist/utils/help-loader.d.ts.map +1 -1
  302. package/dist/utils/help-loader.js +0 -1
  303. package/dist/utils/help-loader.js.map +1 -1
  304. package/dist/utils/issue-rendering.d.ts +2 -2
  305. package/dist/utils/issue-rendering.js +2 -2
  306. package/dist/utils/optional-backend.d.ts.map +1 -1
  307. package/dist/utils/optional-backend.js +2 -1
  308. package/dist/utils/optional-backend.js.map +1 -1
  309. package/dist/utils/output.d.ts +9 -0
  310. package/dist/utils/output.d.ts.map +1 -1
  311. package/dist/utils/output.js +16 -0
  312. package/dist/utils/output.js.map +1 -1
  313. package/dist/utils/package-json.d.ts +24 -0
  314. package/dist/utils/package-json.d.ts.map +1 -0
  315. package/dist/utils/package-json.js +49 -0
  316. package/dist/utils/package-json.js.map +1 -0
  317. package/dist/utils/project-root-policy.d.ts.map +1 -1
  318. package/dist/utils/project-root-policy.js +0 -2
  319. package/dist/utils/project-root-policy.js.map +1 -1
  320. package/dist/utils/resource-loader.js +2 -2
  321. package/dist/utils/resource-loader.js.map +1 -1
  322. package/dist/utils/skill-packaging-config.d.ts +27 -0
  323. package/dist/utils/skill-packaging-config.d.ts.map +1 -1
  324. package/dist/utils/skill-packaging-config.js +30 -0
  325. package/dist/utils/skill-packaging-config.js.map +1 -1
  326. package/dist/utils/url-redact.d.ts.map +1 -1
  327. package/dist/utils/url-redact.js +4 -6
  328. package/dist/utils/url-redact.js.map +1 -1
  329. package/dist/utils/user-context-scanner.d.ts.map +1 -1
  330. package/dist/utils/user-context-scanner.js +0 -2
  331. package/dist/utils/user-context-scanner.js.map +1 -1
  332. package/dist/utils/validate-help-files.d.ts.map +1 -1
  333. package/dist/utils/validate-help-files.js +3 -4
  334. package/dist/utils/validate-help-files.js.map +1 -1
  335. package/dist/utils/vat-bin-path.d.ts.map +1 -1
  336. package/dist/utils/vat-bin-path.js +0 -1
  337. package/dist/utils/vat-bin-path.js.map +1 -1
  338. package/dist/version.d.ts.map +1 -1
  339. package/dist/version.js +1 -3
  340. package/dist/version.js.map +1 -1
  341. package/docs/audit.md +44 -53
  342. package/docs/doctor.md +1 -0
  343. package/docs/index.md +18 -10
  344. package/docs/rag.md +0 -1
  345. package/docs/resources.md +0 -1
  346. package/docs/skill-test.md +10 -9
  347. package/docs/skills.md +6 -7
  348. package/package.json +22 -27
  349. package/schemas/ard-emit.json +149 -0
  350. package/schemas/claude-budget.json +197 -0
  351. package/schemas/okf-validate.json +170 -0
  352. package/schemas/resources-check.json +186 -0
  353. package/schemas/skill-review.json +230 -0
@@ -9,14 +9,13 @@ import { conventionalSuiteProbe, crawlAndResolveRegistry, detectDeclaredButMissi
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
11
  import { listingRefusalRemedy } from '@vibe-agent-toolkit/resources';
12
- import { calculateValidationStatus, countBySeverity, } from '@vibe-agent-toolkit/schema';
13
- import { findProjectRoot, isAbsolutePath, isFilesystemAccessError, issueLocation, resetProjectRootCaches, safePath, } from '@vibe-agent-toolkit/utils';
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
14
  import { DirectoryListingRefusedError } from '@vibe-agent-toolkit/utils/crawl';
15
15
  import { gitFindRoot, isGitUrl, parseGitUrl, } from '@vibe-agent-toolkit/utils/git';
16
16
  import { Command } from 'commander';
17
- import picomatch from 'picomatch';
18
17
  import { resetSkillDiscoveryCache, resolveProjectDeclaredEvalSuites, resolveSkillPackagingConfig, stripValidationAllowForDisplay, } from '../skill-resolution/packaging-config.js';
19
- import { handleCommandError } from '../utils/command-error.js';
18
+ import { handleCommandError, handleExpectedFailure } from '../utils/command-error.js';
20
19
  import { ConfigLoadError, loadConfig, resetLoadedConfigCache, } from '../utils/config-loader.js';
21
20
  import { countCollapsedFindings, formatCollapsedFindingsHint, formatIssueLines, formatSeverityBreakdown, issuesToRenderAtVerbosity, sumSeverityCounts, } from '../utils/issue-rendering.js';
22
21
  import { resolveIssueSeverity } from '../utils/issue-severity.js';
@@ -31,13 +30,13 @@ import { crawlOwnsSubtree, distributedTreeFindings, getOrCreateGitTracker, gitTr
31
30
  import { withClonedRepo } from './audit/git-url-clone.js';
32
31
  import { buildHierarchicalOutput } from './audit/hierarchical-output.js';
33
32
  import { renderProvenanceHeader, rewritePathsInResults, } from './audit/provenance.js';
33
+ import { enumerateAuditPopulation, resolveProjectExcludes, VAT_CONFIG_FILENAME, } from './audit/scan-population.js';
34
34
  import { createAuditSettingsCommand } from './audit-settings.js';
35
35
  import { discoverSkillsFromConfig, SKILLS_INCLUDE_REMEDY, } from './skills/skill-discovery.js';
36
36
  /** Resource type constant for agent skills, avoiding duplicate string literals. */
37
37
  const RESOURCE_TYPE_AGENT_SKILL = 'agent-skill';
38
38
  /** Resource type constant for Claude plugins, avoiding duplicate string literals. */
39
39
  const RESOURCE_TYPE_CLAUDE_PLUGIN = 'claude-plugin';
40
- const VAT_CONFIG_FILENAME = 'vibe-agent-toolkit.config.yaml';
41
40
  /**
42
41
  * Build config-aware context for a single VAT project at `scanRoot`.
43
42
  *
@@ -56,7 +55,6 @@ const VAT_CONFIG_FILENAME = 'vibe-agent-toolkit.config.yaml';
56
55
  */
57
56
  async function buildVATProjectContext(scanRoot, logger, locationRoot) {
58
57
  const configPath = safePath.join(scanRoot, VAT_CONFIG_FILENAME);
59
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- scanRoot is a controlled parameter
60
58
  if (!fs.existsSync(configPath)) {
61
59
  return null;
62
60
  }
@@ -355,8 +353,8 @@ function degradingDiscovery(configRoot, logger, locationRoot) {
355
353
  * config-aware lane makes. Every other verb hands this crawl `'refuse'` (a build
356
354
  * must not ship a shorter bundle), and while the registry carried that ruling
357
355
  * INSIDE itself `vat audit` on a tree with one `chmod 000` sibling exited 2
358
- * with `status: error` and ZERO findings — issue #180's exact shape, from the
359
- * crawl the fix for #180 never looked at. Audit's contract is the opposite:
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:
360
358
  * status describes what was found, exit describes whether the run completed.
361
359
  *
362
360
  * Recorded through the same per-directory ledger as a refused discovery, so a
@@ -487,8 +485,8 @@ async function validateSingleSkill(skillPath, options, logger, locationRoot, cra
487
485
  await appendDistributedTreeFindings(configAware, skillPath, locationRoot, crawlTree);
488
486
  return configAware;
489
487
  }
490
- // Fallback: basic validation
491
- const validateOptions = { skillPath, locationRoot };
488
+ // Fallback: basic validation. Severity is applied by `applySeverityFilter`.
489
+ const validateOptions = { skillPath, locationRoot, validation: {} };
492
490
  if (options.warnUnreferencedFiles) {
493
491
  validateOptions.checkUnreferencedFiles = true;
494
492
  }
@@ -535,19 +533,13 @@ function collect(value, previous) {
535
533
  * Create audit command
536
534
  * Top-level command: vat audit [path]
537
535
  *
538
- * 🔑 **Two verdicts, deliberately.** The YAML `status` describes the FINDINGS;
539
- * the exit code describes whether the RUN completed. `status: error` beside exit
540
- * `0` is the correct pair for a tree with errors, and both facts are worth
541
- * publishing — but `status` moves with the exit code in every other command of
542
- * this CLI, so this one is the exception and the help text says so out loud (see
543
- * the `Output` and `Exit Codes` sections below, and `docs/audit.md`).
544
- *
545
- * ⚠️ Do NOT "reconcile" the two by making one follow the other. Making the exit
546
- * code follow `status` turns an advisory report into a gate — the thing
547
- * `vat validate` already is, and the thing this command's published contract
548
- * promises it is not. Making `status` follow the exit code deletes the only
549
- * machine-readable verdict a CI consumer has, and a stamped `success` over a
550
- * report full of errors is a stronger falsehood than the mismatch ever was.
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.
551
543
  */
552
544
  export function createAuditCommand() {
553
545
  const audit = new Command('audit');
@@ -614,22 +606,16 @@ Description:
614
606
  done
615
607
  (See packages/cli/docs/audit.md for details.)
616
608
 
617
- Output — two verdicts, and they answer different questions:
618
- The YAML 'status' describes the FINDINGS. The exit code describes whether the
619
- RUN completed. On a tree with errors that is 'status: error' beside exit 0,
620
- and both are correct.
621
-
622
- Note that 'status' means something narrower here than elsewhere in this CLI,
623
- where it moves with the exit code. Gate CI on the report — 'status' and
624
- 'issueCounts' — never on this command's exit code.
609
+ Output:
610
+ The YAML 'status' describes the findings, and the exit code follows it, as
611
+ in every other command of this CLI.
625
612
 
626
613
  A run that audited ZERO files is refused, not passed: 'status: error' with one
627
614
  non-overridable RESOURCE_CHECK_BROKEN under a top-level 'issues:' (the claim is
628
- about the run, so it is not a files[] row), still beside exit 0 — the run
629
- completed; it just is not a verdict.
615
+ about the run, so it is not a files[] row), and exit 1.
630
616
 
631
617
  Validation Behavior:
632
- Advisory only: audit surfaces all validation issues for inspection.
618
+ Audit surfaces all validation issues for inspection.
633
619
  Unlike 'vat skills validate', audit:
634
620
  - NEVER applies validation.allow (allowed codes are always shown)
635
621
  - Respects validation.severity: a code set to 'ignore' is hidden; warnings
@@ -653,11 +639,16 @@ Validation Checks:
653
639
 
654
640
  Warnings (should fix):
655
641
  - Skill exceeds recommended length (>5000 lines)
656
- - Compat smells — requires browser auth, local shell, or external CLI
657
- (COMPAT_REQUIRES_BROWSER_AUTH, COMPAT_REQUIRES_LOCAL_SHELL,
658
- 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)
659
644
  - Unreferenced files detected (with --warn-unreferenced-files)
660
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
+
661
652
  Gitignore-Aware Scanning:
662
653
  When scanning inside a git repository, paths matched by .gitignore are
663
654
  skipped by default. This excludes build artifacts (dist/), dependencies
@@ -667,8 +658,9 @@ Gitignore-Aware Scanning:
667
658
  Use --include-artifacts to scan gitignored paths (e.g., to audit a
668
659
  bundled marketplace plugin in dist/).
669
660
 
670
- User-supplied --exclude patterns are always applied on top.
671
- 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.
672
664
 
673
665
  Config-Aware Validation:
674
666
  The governing vibe-agent-toolkit.config.yaml is found by walking UP from
@@ -687,14 +679,19 @@ Config-Aware Validation:
687
679
  skills.config.<name>.validation... that one skill
688
680
 
689
681
  Exit Codes:
690
- 0 - Always, when the audit completes — including when it reports
691
- 'status: error'. The findings are in the report; see Output above.
692
- A missing or unrecognised path is UNKNOWN_FORMAT; an unreadable one,
693
- or a governing config that cannot be loaded or whose skills.include
694
- reaches an unreadable directory, is SCAN_PATH_UNREADABLE — readable
695
- siblings are still validated, and the config's skills config-free.
696
- 2 - The audit could not run at all (--user with no Claude config dir,
697
- a git URL that would not clone, an internal failure), so there is
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
698
695
  no report to read.
699
696
 
700
697
  Examples:
@@ -749,11 +746,8 @@ async function auditUserDirectories(recursive, options, startTime, logger) {
749
746
  // --user scans three sibling directories; they share ONE enclosing Claude
750
747
  // config dir, so that is the run's single stated root.
751
748
  const scanRoot = safePath.resolve(claudeDir);
752
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- safe: path constructed from os.homedir()
753
749
  const pluginsDirExists = fs.existsSync(pluginsDir);
754
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- safe: path constructed from os.homedir()
755
750
  const skillsDirExists = fs.existsSync(skillsDir);
756
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- safe: path constructed from os.homedir()
757
751
  const marketplacesDirExists = fs.existsSync(marketplacesDir);
758
752
  if (!pluginsDirExists && !skillsDirExists && !marketplacesDirExists) {
759
753
  logger.error(`No user-level Claude directories found:`);
@@ -761,7 +755,7 @@ async function auditUserDirectories(recursive, options, startTime, logger) {
761
755
  logger.error(` Skills: ${skillsDir}`);
762
756
  logger.error(` Marketplaces: ${marketplacesDir}`);
763
757
  logger.error('Claude plugins/skills/marketplaces have not been installed yet.');
764
- process.exit(2);
758
+ process.exit(ExitCode.ERROR);
765
759
  }
766
760
  const scanned = [];
767
761
  // The roots that exist, minus any one another already covers — see
@@ -815,7 +809,6 @@ const SKILL_RESULT_TYPES = new Set([RESOURCE_TYPE_AGENT_SKILL, 'vat-agent']);
815
809
  */
816
810
  export function deriveScanRoot(targetPath) {
817
811
  const resolved = safePath.resolve(targetPath);
818
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- resolved is the operator's own audit target
819
812
  const isDirectory = fs.existsSync(resolved) && fs.statSync(resolved).isDirectory();
820
813
  return isDirectory ? resolved : safePath.resolve(resolved, '..');
821
814
  }
@@ -863,7 +856,7 @@ function buildFilteredResult(result, filteredIssues) {
863
856
  /**
864
857
  * Apply severity resolution to validation results.
865
858
  *
866
- * Audit is advisory only: it applies `validation.severity` to decide what to
859
+ * Audit shows everything: it applies `validation.severity` to decide what to
867
860
  * show and at what severity, but deliberately ignores `validation.allow`.
868
861
  *
869
862
  * **Both directions, via the one shared resolver.** This used to compute the
@@ -1045,7 +1038,7 @@ export async function buildAuditReport(scanPath, options, startTime, logger) {
1045
1038
  logger.warn(`Config could not be read; severity overrides are not applied: ${String(err)}`);
1046
1039
  }
1047
1040
  // Apply severity filtering: hide codes whose effective severity is 'ignore'.
1048
- // Allow is deliberately NOT applied — audit is advisory only.
1041
+ // Allow is deliberately NOT applied — audit shows every finding.
1049
1042
  const results = applySeverityFilter(rawResults, config);
1050
1043
  const effectiveSettings = await resolveEffectiveSettings(options, scanPath, logger);
1051
1044
  // Run compatibility analysis if --compat flag is set
@@ -1108,7 +1101,6 @@ export async function auditCommand(targetPath, options) {
1108
1101
  // cloned https://github.com/<that>.git instead, reaching out to the
1109
1102
  // network with a name derived from the caller's own directory layout.
1110
1103
  // Shorthand is a fallback for arguments that name nothing on disk.
1111
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- targetPath is the user's own audit target; existence is the question being asked
1112
1104
  if (targetPath !== undefined && !fsExistsSync(targetPath) && isGitUrl(targetPath)) {
1113
1105
  await runUrlAudit(targetPath, options);
1114
1106
  return;
@@ -1120,6 +1112,12 @@ export async function auditCommand(targetPath, options) {
1120
1112
  return;
1121
1113
  }
1122
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
+ }
1123
1121
  await runAuditAtPath(scanPath, options);
1124
1122
  }
1125
1123
  catch (error) {
@@ -1129,6 +1127,41 @@ export async function auditCommand(targetPath, options) {
1129
1127
  handleCommandError(error, logger, startTime, 'Audit');
1130
1128
  }
1131
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
+ }
1132
1165
  /**
1133
1166
  * Dispatch a single surface to the matching validator. Keeps the multi-surface
1134
1167
  * branch of {@link getValidationResults} focused so cognitive complexity stays
@@ -1246,21 +1279,35 @@ async function appendPluginInventoryToSurfaceResults(surfaceResults, scanPath, l
1246
1279
  * not (plugin.json) and a format it is not (JSON), at error severity beside
1247
1280
  * the skill lane's warning, and carrying the build host's absolute path.
1248
1281
  *
1249
- * What remains — hooks/hooks.json, .mcp.json — is appended with error
1250
- * severity, the message spelled scan-root-relative wherever it names the file.
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.
1251
1287
  */
1252
1288
  function appendInventoryParseErrors(result, inv, locationRoot) {
1253
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));
1254
1293
  const parseIssues = [];
1255
1294
  for (const err of inv.parseErrors) {
1256
1295
  if (err.path.endsWith(pluginJsonSuffix) || basename(err.path) === 'SKILL.md')
1257
1296
  continue;
1258
- parseIssues.push({
1259
- severity: 'error',
1260
- code: 'PLUGIN_INVALID_JSON',
1261
- message: withRelativePath(err.message, err.path, locationRoot),
1262
- location: issueLocation(err.path, locationRoot),
1263
- });
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}` }));
1264
1311
  }
1265
1312
  // Status was hand-set to 'error' here while `issueCounts` and `summary` kept
1266
1313
  // describing the pre-append list. One appender derives all three.
@@ -1284,7 +1331,8 @@ function appendInventoryParseErrors(result, inv, locationRoot) {
1284
1331
  * way into a validator unprotected: a `SKILL.md` named directly on the command
1285
1332
  * line, a plugin directory, a marketplace, each surface of a multi-surface tree.
1286
1333
  * All of them still ended the whole run with `status: error`, exit 2 and zero
1287
- * 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.
1288
1336
  *
1289
1337
  * Guarding the seven dispatch sites individually would have been the same mistake
1290
1338
  * a seventh time. This function is what every one of them is reached through, so
@@ -1451,26 +1499,45 @@ async function validateAuditSubject(scanPath, recursive, options, logger, locati
1451
1499
  return [result];
1452
1500
  }
1453
1501
  // If unknown format, check if it's a directory we can scan
1454
- const fsp = await import('node:fs/promises');
1455
- try {
1456
- const stat = await fsp.stat(scanPath);
1457
- if (stat.isDirectory()) {
1458
- logger.debug('Scanning directory for resources');
1459
- // The project's `resources.exclude` is applied by the scan context,
1460
- // on the project-root basis its globs were written against — NOT
1461
- // merged into `options.exclude`, whose patterns are relative to the
1462
- // directory the operator named. See resolveProjectExcludes.
1463
- return scanDirectory(scanPath, recursive, options, logger, await resolveScanContext(scanPath, locationRoot, logger));
1464
- }
1465
- }
1466
- catch {
1467
- // 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));
1468
1509
  }
1469
1510
  // Unknown resource type - use unified validator which will return appropriate error
1470
1511
  logger.debug(`Unknown resource type at: ${scanPath}`);
1471
1512
  const result = await validate(scanPath, { locationRoot });
1472
1513
  return [result];
1473
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
+ }
1474
1541
  /**
1475
1542
  * Inventory-driven plugin skill walker. Builds the plugin inventory via
1476
1543
  * `extractClaudePluginInventory` and dispatches `validateSingleSkill` for each
@@ -1536,9 +1603,9 @@ function resolveConfigTargetsForPlugin(pluginDir, vatContext) {
1536
1603
  const union = new Set();
1537
1604
  for (const [skillPathAbs, packagingConfig] of vatContext.skillConfigs) {
1538
1605
  const rel = safePath.relative(pluginDirAbs, skillPathAbs);
1539
- // Skill lives inside the plugin directory iff relative path does not
1540
- // start with '..' and is not absolute.
1541
- 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))
1542
1609
  continue;
1543
1610
  const skillTargets = packagingConfig.targets;
1544
1611
  if (skillTargets === undefined)
@@ -1788,16 +1855,6 @@ function findingSubject(result, root) {
1788
1855
  const directoryName = basename(result.path);
1789
1856
  return directoryName === '' ? '.' : directoryName;
1790
1857
  }
1791
- /**
1792
- * The clause every "audit found errors" line ends with.
1793
- *
1794
- * One string because the two summary lanes — per-file and per-skill — both make
1795
- * the same claim and used to word it differently, which is how one of them ended
1796
- * up saying "Audit failed" over an exit code of 0. Naming the command that DOES
1797
- * gate is the actionable half: an adopter reading "advisory" still has to be
1798
- * told what to reach for instead.
1799
- */
1800
- const ADVISORY_EXIT_NOTE = '— advisory, exit 0; use `vat validate` to gate on this';
1801
1858
  /**
1802
1859
  * Render supporting evidence beneath each CAPABILITY_* issue when the
1803
1860
  * audit was invoked with --verbose. Evidence comes from the validation
@@ -1826,15 +1883,15 @@ function renderVerboseEvidence(results, root, logger) {
1826
1883
  * document says. The document is derived in `buildBaseSummary`; this only
1827
1884
  * RENDERS it. Both stderr lanes call it first, because "Audit successful: 0
1828
1885
  * file(s) passed" over `status: error` is the exact disagreement the refusal
1829
- * exists to end (invariant 6 of `run-integrity.ts`). Exit stays 0 — the run
1830
- * completed — and the note says which command gates.
1886
+ * exists to end (invariant 6 of `run-integrity.ts`). The run ends on
1887
+ * `FINDINGS`, like every other `status: error` this command publishes.
1831
1888
  *
1832
1889
  * @returns Whether a refusal was rendered, so the caller skips its verdict line
1833
1890
  */
1834
1891
  function logRunIntegrity(runIssues, logger) {
1835
1892
  if (runIssues === undefined || runIssues.length === 0)
1836
1893
  return false;
1837
- logger.error(`Audit is not a verdict: it audited 0 files ${ADVISORY_EXIT_NOTE}`);
1894
+ logger.error('Audit is not a verdict: it audited 0 files');
1838
1895
  for (const issue of runIssues) {
1839
1896
  for (const line of formatIssueLines(issue, ' '))
1840
1897
  logger.error(line);
@@ -1879,26 +1936,17 @@ function logSettingsTotals(files, logger) {
1879
1936
  function handleAuditResults(results, summary, logger, verbose) {
1880
1937
  const { filesWithErrors: errorCount, filesWithWarnings: warningCount, filesPassed: successCount, } = summary.summary;
1881
1938
  logSettingsTotals(summary.files ?? [], logger);
1882
- // 🔑 Audit is advisory only — always exit 0 for validation results, and the
1883
- // WORDING has to say so. This line used to read "Audit failed" on exit 0: an
1884
- // adopter wiring `vat audit` into CI read a failure and got a green step.
1885
- // "Found" is what actually happened, and the gate is `vat validate`, named
1886
- // here rather than implied.
1887
- //
1888
- // ⚠️ That fixed the WORDING and nothing else. The document still says
1889
- // `status: error` over exit 0 — measured on
1890
- // `packages/agent-skills/test/fixtures/skill-files` — and that is the
1891
- // published contract, not a leftover: `status` describes the FINDINGS, the
1892
- // exit code describes whether the RUN completed. An earlier version of this
1893
- // comment claimed all three signals had been reconciled; two of the three
1894
- // had, and the third is not a signal to reconcile. Where a reader is told
1895
- // which is which is {@link createAuditCommand}'s help — see its `Output`
1896
- // section, and `docs/audit.md`.
1897
- if (logRunIntegrity(summary.issues, 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) {
1898
1946
  // Rendered above; there is no file verdict to print over zero files.
1899
1947
  }
1900
1948
  else if (errorCount > 0) {
1901
- logger.error(`Audit found ${errorCount} file(s) with errors ${ADVISORY_EXIT_NOTE}`);
1949
+ logger.error(`Audit found ${errorCount} file(s) with errors`);
1902
1950
  logFindingsForStatus(results, 'error', summary.root, logger.error.bind(logger), verbose);
1903
1951
  }
1904
1952
  else if (warningCount > 0) {
@@ -1914,7 +1962,7 @@ function handleAuditResults(results, summary, logger, verbose) {
1914
1962
  : `Audit successful: ${successCount} file(s) passed`);
1915
1963
  }
1916
1964
  renderAuditFooter(results, logger);
1917
- process.exit(0);
1965
+ process.exit(refused || errorCount > 0 ? ExitCode.FINDINGS : ExitCode.OK);
1918
1966
  }
1919
1967
  /**
1920
1968
  * Render the skill-quality checklist footer when audit results contain
@@ -2009,17 +2057,6 @@ function logFindingsForStatus(results, status, root, logFn, verbose) {
2009
2057
  logFn(line);
2010
2058
  }
2011
2059
  }
2012
- /**
2013
- * Check whether a path should be excluded during directory scanning.
2014
- * For directories, checks both bare path and path with trailing slash
2015
- * so patterns like "dist/**" prune the directory itself.
2016
- */
2017
- function isExcludedPath(isMatch, relativePath, isDirectory) {
2018
- if (isDirectory) {
2019
- return isMatch(relativePath) || isMatch(relativePath + '/');
2020
- }
2021
- return isMatch(relativePath);
2022
- }
2023
2060
  /**
2024
2061
  * Does the SKILL.md at `skillMdPath` own its own presence-side crawl, or has an
2025
2062
  * ancestor crawl already covered it?
@@ -2033,39 +2070,27 @@ function ownsOwnCrawl(scanCtx, skillMdPath) {
2033
2070
  return scanCtx.crawledRoot === null || !isWithin(scanCtx.crawledRoot, safePath.resolve(skillMdPath));
2034
2071
  }
2035
2072
  /**
2036
- * Hand the descending context an owner for everything below `owner`, unless one
2037
- * is already claimed. First owner wins, and `null` claims nothing.
2038
- */
2039
- function claimSubtree(scanCtx, owner) {
2040
- if (owner === null || scanCtx.crawledRoot !== null)
2041
- return scanCtx;
2042
- return { ...scanCtx, crawledRoot: safePath.resolve(owner) };
2043
- }
2044
- /**
2045
- * The context this directory's SUBDIRECTORIES are walked with.
2046
- *
2047
- * A `SKILL.md` here means this directory's own crawl reaches every descendant at
2048
- * 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.
2049
2076
  *
2050
- * Resolved before the entry loop rather than inside it: `SKILL.md` and the
2051
- * subdirectories it governs are siblings in one `readdir`, and their order is
2052
- * the filesystem's to choose, so deciding this mid-loop would make the verdict
2053
- * depend on it.
2054
- *
2055
- * Conditional on the crawl actually RUNNING ({@link crawlOwnsSubtree}), not on
2056
- * the SKILL.md merely existing: a repo-source skill's crawl is skipped
2057
- * entirely, so claiming its subtree would trade a double count for a missed
2058
- * 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.
2059
2081
  */
2060
- async function contextForDescendants(dirPath, entries, scanCtx) {
2061
- if (!entries.some((entry) => entry.isFile() && entry.name === 'SKILL.md'))
2062
- return scanCtx;
2063
- const skillMd = safePath.join(dirPath, 'SKILL.md');
2064
- if (!ownsOwnCrawl(scanCtx, skillMd))
2065
- return scanCtx;
2066
- if (!await crawlOwnsSubtree(skillMd))
2067
- return scanCtx;
2068
- 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
+ }
2069
2094
  }
2070
2095
  /**
2071
2096
  * Handle file entry during directory scan
@@ -2116,8 +2141,8 @@ async function handleFileEntry(entry, fullPath, options, logger, scanCtx) {
2116
2141
  await appendDistributedTreeFindings(configAware, fullPath, locationRoot, ownsOwnCrawl(scanCtx, fullPath));
2117
2142
  return configAware;
2118
2143
  }
2119
- // Wild mode: basic validation
2120
- const validateOptions = { skillPath: fullPath, locationRoot };
2144
+ // Wild mode: basic validation. Severity is applied by `applySeverityFilter`.
2145
+ const validateOptions = { skillPath: fullPath, locationRoot, validation: {} };
2121
2146
  if (options.warnUnreferencedFiles) {
2122
2147
  validateOptions.checkUnreferencedFiles = true;
2123
2148
  }
@@ -2131,89 +2156,15 @@ async function handleFileEntry(entry, fullPath, options, logger, scanCtx) {
2131
2156
  return null;
2132
2157
  }
2133
2158
  /**
2134
- * Handle directory entry during directory scan
2135
- */
2136
- async function handleDirectoryEntry(fullPath, recursive, options, logger, baseDir, scanCtx, nestedConfigLog) {
2137
- const fs = await import('node:fs/promises');
2138
- const { locationRoot } = scanCtx;
2139
- const results = [];
2140
- // Check if directory contains a plugin or marketplace
2141
- const claudePluginDir = safePath.join(fullPath, '.claude-plugin');
2142
- const hasClaudePlugin = await fs.access(claudePluginDir).then(() => true).catch(() => false);
2143
- if (hasClaudePlugin) {
2144
- logger.debug(`Validating resource directory: ${fullPath}`);
2145
- const result = await validate(fullPath, { validatePlugin, locationRoot });
2146
- const inv = await pluginInventoryAt(fullPath);
2147
- appendInventoryParseErrors(result, inv, locationRoot);
2148
- results.push(result);
2149
- }
2150
- // Recurse into subdirectories (both plugin/marketplace dirs and regular dirs).
2151
- // Below a plugin, `validatePlugin` has already crawled the whole subtree for
2152
- // agent-instruction files, so every crawl below must stand down or one file
2153
- // is reported twice. See {@link ScanContext.crawledRoot} — first owner wins,
2154
- // and it never clears on the way down.
2155
- const childCtx = claimSubtree(scanCtx, hasClaudePlugin ? fullPath : null);
2156
- if (recursive) {
2157
- const subResults = await scanDirectory(fullPath, recursive, options, logger, childCtx, baseDir, nestedConfigLog);
2158
- results.push(...subResults);
2159
- }
2160
- return results;
2161
- }
2162
- /**
2163
- * Compile the governing project's `resources.exclude` for a scan.
2164
- *
2165
- * The config is found by walking UP from the scan directory ({@link findProjectRoot}),
2166
- * not by looking in it. Looking only in the scan directory is how a path argument
2167
- * silently voided every exclude the project had declared: `vat audit
2168
- * packages/x/resources/skills/` found no config there, so a package that excludes
2169
- * its deliberately-broken eval fixtures had them audited as production skills the
2170
- * moment anyone named a subdirectory. Commit 8a466b0c fixed the same defect for
2171
- * `vat resources validate|scan` and `vat rag index`; this is the lane it missed.
2172
- * The rule is the same one that commit established: a path argument says WHICH
2173
- * tree to audit, and `exclude` applies either way.
2174
- *
2175
- * With one deliberate exception, mirroring the gitignore rule in
2176
- * {@link resolveScanContext}: when the operator points the scan AT an excluded
2177
- * tree, their explicit intent wins and the excludes are dropped for that run.
2178
- * Otherwise naming an excluded directory would report `filesScanned: 0,
2179
- * status: success` — a green run that scanned nothing.
2159
+ * Validate a directory the population found holding `.claude-plugin/` — a
2160
+ * plugin or a marketplace, whichever the unified validator decides.
2180
2161
  */
2181
- function resolveProjectExcludes(scanDir, logger) {
2182
- const projectRoot = findProjectRoot(scanDir);
2183
- if (projectRoot === null)
2184
- return null;
2185
- let patterns;
2186
- try {
2187
- patterns = loadConfig(projectRoot)?.resources?.exclude ?? [];
2188
- }
2189
- catch (err) {
2190
- // Audit is a bulk linter over trees it does not own; a broken governing
2191
- // config must not abort the scan. Say so rather than dropping it silently.
2192
- //
2193
- // 🚨 `warn`, not `debug`. This comment already said "say so" while logging at
2194
- // a level nobody sees without `--debug`, so what actually happened was the
2195
- // silent drop it forbids — and the consequence is the one this function's own
2196
- // docstring spells out: EVERY `resources.exclude` the project declared is
2197
- // void, so a package that excludes its deliberately-broken eval fixtures has
2198
- // them audited as production skills. That is findings APPEARING, not findings
2199
- // disappearing, which is exactly the direction an operator will read as VAT
2200
- // being wrong rather than as their config being broken.
2201
- logger.warn(`Config at ${projectRoot} could not be read; every resources.exclude it declares is`
2202
- + ` dropped for this run, so excluded trees are audited as ordinary source: ${String(err)}`);
2203
- return null;
2204
- }
2205
- if (patterns.length === 0)
2206
- return null;
2207
- // dot:true so excludes like `**/.cache/*` match through dotfile dirs;
2208
- // without it the exclude silently never fires.
2209
- const isMatch = picomatch([...patterns], { dot: true });
2210
- const scanRelToProject = safePath.relative(projectRoot, safePath.resolve(scanDir)).replaceAll('\\', '/');
2211
- if (scanRelToProject !== '' && isExcludedPath(isMatch, scanRelToProject, true)) {
2212
- logger.debug(`Scan root ${scanRelToProject} is excluded by the config at ${projectRoot}; ` +
2213
- 'auditing it anyway because it was named explicitly');
2214
- return null;
2215
- }
2216
- 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;
2217
2168
  }
2218
2169
  /**
2219
2170
  * Cache of (projectRoot → registry for the *inventory* link walk).
@@ -2313,43 +2264,6 @@ async function resolveScanContext(dirPath, locationRoot, logger) {
2313
2264
  }
2314
2265
  return { gitRoot: detectedRoot, gitTracker: tracker, locationRoot, projectExcludes, crawledRoot: null };
2315
2266
  }
2316
- /**
2317
- * Build the gitignore exclusion map for a set of entry paths.
2318
- * Returns null when gitignore filtering is not applicable (no git root,
2319
- * --include-artifacts, or outside a git repo).
2320
- */
2321
- function buildGitIgnoreMap(entryPaths, gitTracker, includeArtifacts) {
2322
- if (gitTracker === null || includeArtifacts) {
2323
- return null;
2324
- }
2325
- const map = new Map();
2326
- for (const entryPath of entryPaths) {
2327
- map.set(entryPath, gitTracker.isIgnoredByActiveSet(entryPath));
2328
- }
2329
- return map;
2330
- }
2331
- /**
2332
- * Check whether a directory entry should be skipped during scanning.
2333
- * Returns a reason string for debug logging, or null if the entry should be kept.
2334
- */
2335
- function getSkipReason(entry, fullPath, gitIgnoredMap, excludeMatchers) {
2336
- // Always skip .git directory (not reported by git check-ignore)
2337
- if (entry.isDirectory() && entry.name === '.git') {
2338
- return '.git';
2339
- }
2340
- // Skip gitignored paths
2341
- if (gitIgnoredMap !== null && gitIgnoredMap.get(fullPath) === true) {
2342
- return `gitignored: ${entry.name}`;
2343
- }
2344
- // Each matcher answers against ITS OWN base — see {@link ExcludeMatcher}.
2345
- for (const matcher of excludeMatchers) {
2346
- const relativePath = safePath.relative(matcher.base, fullPath).replaceAll('\\', '/');
2347
- if (isExcludedPath(matcher.isMatch, relativePath, entry.isDirectory())) {
2348
- return `${matcher.label}: ${relativePath}`;
2349
- }
2350
- }
2351
- return null;
2352
- }
2353
2267
  /**
2354
2268
  * The one finding a path the walk could not read produces — a directory it could
2355
2269
  * not enter, or a file it could not open.
@@ -2366,14 +2280,11 @@ function getSkipReason(entry, fullPath, gitIgnoredMap, excludeMatchers) {
2366
2280
  * is no longer the ONLY thing the run says — the code, the location and the fix
2367
2281
  * come from the registry.
2368
2282
  *
2369
- * KNOWN, and deliberately not fixed here: this result counts toward
2370
- * `summary.filesScanned`, so a path the scan could not read is counted among the
2371
- * files it scanned. Dropping it from that denominator alone would be worse —
2372
- * `filesWithWarnings` counts the same results, so the summary would report more
2373
- * files with warnings than files scanned. Saying it honestly needs a field of its
2374
- * own, which is a change to the report's shape and belongs with issue #177
2375
- * ("report counts don't describe the artifact") rather than smuggled in here. The
2376
- * 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.
2377
2288
  */
2378
2289
  function unreadablePathResult(dirPath, error, locationRoot) {
2379
2290
  // `issueLocation` is `path.relative`, so it answers `''` when the subject IS the
@@ -2405,30 +2316,36 @@ function unreadablePathResult(dirPath, error, locationRoot) {
2405
2316
  };
2406
2317
  }
2407
2318
  /**
2408
- * 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.
2409
2321
  *
2410
- * Guarded per ENTRY, not just per directory: wrapping only the parent `readdir`
2411
- * left the other half of the same defect live — an unreadable FILE (a root-owned
2412
- * `SKILL.md`, a quarantined bundle) threw out of `validateSkill` and still
2413
- * 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
2414
2325
  * `~/.claude/plugins` — the flagship `vat audit --user` target, populated by sudo
2415
2326
  * installs and macOS quarantine — a root-owned FILE is at least as likely as a
2416
- * root-owned directory (issue #180).
2327
+ * root-owned directory.
2417
2328
  *
2418
- * Extracted from `scanDirectory` rather than inlined because the added branch put
2419
- * that function over the cognitive-complexity ceiling; the walk keeps the
2420
- * 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.
2421
2334
  */
2422
- async function scanEntry(entry, fullPath, ctx) {
2335
+ async function validateScanSubject(subject, options, logger, scanCtx, claims) {
2336
+ const { locationRoot } = scanCtx;
2423
2337
  try {
2424
- if (entry.isFile()) {
2425
- const result = await handleFileEntry(entry, fullPath, ctx.options, ctx.logger, ctx.scanCtx);
2426
- 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];
2427
2342
  }
2428
- if (entry.isDirectory()) {
2429
- 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);
2430
2347
  }
2431
- return [];
2348
+ return result === null ? [] : [result];
2432
2349
  }
2433
2350
  catch (error) {
2434
2351
  // ONLY filesystem-access errors degrade. Anything else is rethrown, because
@@ -2437,87 +2354,61 @@ async function scanEntry(entry, fullPath, ctx) {
2437
2354
  // prevent — it would make the tool quietest exactly when it is most wrong.
2438
2355
  if (!isFilesystemAccessError(error))
2439
2356
  throw error;
2440
- ctx.logger.debug(`Unreadable entry: ${fullPath}`);
2441
- 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)];
2442
2360
  }
2443
2361
  }
2444
- async function scanDirectory(dirPath, recursive, options, logger, scanCtx, baseDir, nestedConfigLog) {
2445
- const fs = await import('node:fs/promises');
2446
- const results = [];
2447
- const userExcludes = options.exclude ?? [];
2448
- const resolvedBaseDir = baseDir ?? dirPath;
2449
- // The git context (root + tracker) and the run's anchor base are built once by
2450
- // the caller; every recursion reuses them unchanged.
2451
- const resolvedScanCtx = scanCtx;
2452
- const resolvedNestedLog = nestedConfigLog ?? new Set();
2453
- // Top-down pre-warm of findProjectRoot's walk-up cache (spec §8). The first
2454
- // call here drives a single ancestor walk; every subsequent recursion is a
2455
- // Layer-1 hit chain. By the time we reach leaf SKILL.md files,
2456
- // 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 `/`.
2457
2386
  findProjectRoot(dirPath);
2458
- // Emit a one-time info breadcrumb when we encounter a nested
2459
- // vibe-agent-toolkit.config.yaml that is NOT at the scan root. Configs do
2460
- // not compose across VAT projects; the message reminds operators that
2461
- // only per-skill packaging rules from this config still apply.
2462
- const nestedConfigPath = safePath.join(dirPath, VAT_CONFIG_FILENAME);
2463
- // eslint-disable-next-line security/detect-non-literal-fs-filename -- dirPath is a controlled scan path
2464
- if (dirPath !== resolvedBaseDir && fsExistsSync(nestedConfigPath) && !resolvedNestedLog.has(nestedConfigPath)) {
2465
- resolvedNestedLog.add(nestedConfigPath);
2466
- const rel = safePath.relative(resolvedBaseDir, nestedConfigPath).replaceAll('\\', '/');
2467
- 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.`);
2468
- }
2469
- // Compile picomatch for user-supplied --exclude patterns.
2470
- // dot:true so excludes like `**/private/*` match through dotfile dirs
2471
- // (`.claude/.../private/x`); without it the exclude silently never fires.
2472
- // These are relative to the scan base; the config's excludes carry their own
2473
- // (project-root) base on the matcher — see {@link ExcludeMatcher}.
2474
- const excludeMatchers = [];
2475
- if (userExcludes.length > 0) {
2476
- excludeMatchers.push({
2477
- isMatch: picomatch(userExcludes, { dot: true }),
2478
- base: resolvedBaseDir,
2479
- label: 'excluded',
2480
- });
2481
- }
2482
- if (resolvedScanCtx.projectExcludes !== null) {
2483
- excludeMatchers.push(resolvedScanCtx.projectExcludes);
2484
- }
2485
- // Guarded because `vat audit` is a bulk linter over trees it does not own, and
2486
- // an unreadable entry is an ordinary condition there — one root-owned directory
2487
- // under `~/.claude/plugins` used to abort the flagship `vat audit --user` run
2488
- // outright, losing every finding already collected (issue #180). The failure is
2489
- // scoped to the subtree that caused it: this returns instead of throwing, so the
2490
- // CALLER's loop keeps its readable siblings and the walk continues.
2491
- let entries;
2492
- try {
2493
- entries = await fs.readdir(dirPath, { withFileTypes: true });
2494
- }
2495
- catch (error) {
2496
- // Same predicate as the per-entry guard below — see it for why a non-IO
2497
- // throw must not be degraded into a finding.
2498
- if (!isFilesystemAccessError(error))
2499
- throw error;
2500
- logger.debug(`Unreadable directory: ${dirPath}`);
2501
- return [unreadablePathResult(dirPath, error, resolvedScanCtx.locationRoot)];
2502
- }
2503
- const descendCtx = await contextForDescendants(dirPath, entries, resolvedScanCtx);
2504
- // Build full paths and batch-check gitignore status
2505
- const entryPaths = entries.map(e => safePath.join(dirPath, e.name));
2506
- const gitIgnoredMap = buildGitIgnoreMap(entryPaths, resolvedScanCtx.gitTracker, options.includeArtifacts ?? false);
2507
- for (const entry of entries) {
2508
- const fullPath = safePath.join(dirPath, entry.name);
2509
- const skipReason = getSkipReason(entry, fullPath, gitIgnoredMap, excludeMatchers);
2510
- if (skipReason !== null) {
2511
- logger.debug(`Excluding path: ${skipReason}`);
2512
- continue;
2513
- }
2514
- results.push(...await scanEntry(entry, fullPath, {
2515
- recursive, options, logger,
2516
- scanCtx: resolvedScanCtx,
2517
- descendCtx,
2518
- baseDir: resolvedBaseDir,
2519
- nestedConfigLog: resolvedNestedLog,
2520
- }));
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));
2521
2412
  }
2522
2413
  return results;
2523
2414
  }
@@ -2546,13 +2437,34 @@ function calculateOverallStatus(results) {
2546
2437
  function countResultsWithFindings(results) {
2547
2438
  return results.filter((r) => r.issues.length > 0).length;
2548
2439
  }
2549
- /** 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
+ */
2550
2460
  function countFilesByStatus(results) {
2461
+ const scanned = results.filter((r) => !isUnreadablePathResult(r));
2551
2462
  return {
2552
- filesScanned: results.length,
2553
- filesPassed: results.filter((r) => r.status === 'success').length,
2554
- filesWithWarnings: results.filter((r) => r.status === 'warning').length,
2555
- 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,
2556
2468
  };
2557
2469
  }
2558
2470
  /**
@@ -2608,7 +2520,10 @@ function buildBaseSummary(results, startTime) {
2608
2520
  issueCounts.info += counts.info;
2609
2521
  return { ...result, issueCounts: counts };
2610
2522
  });
2611
- const runIssues = nothingCheckedFinding(entries.length, entries.flatMap((entry) => entry.issues), () => 'The audit ran over 0 files, so this report is not a verdict: a tree with nothing'
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'
2612
2527
  + ' to audit produces the same counts as a clean one. The path resolved to a tree'
2613
2528
  + ' holding no auditable file — usually a wrong subdirectory, plugins or skills that'
2614
2529
  + ' have moved, an excluded tree, files no audit lane recognises, or `--no-recursive`'
@@ -2620,7 +2535,7 @@ function buildBaseSummary(results, startTime) {
2620
2535
  // `run-integrity.ts`), so a non-empty refusal IS the status; otherwise the
2621
2536
  // files decide it, as before.
2622
2537
  status: runIssues.length > 0 ? 'error' : calculateOverallStatus(entries),
2623
- summary: countFilesByStatus(entries),
2538
+ summary,
2624
2539
  issueCounts: sumSeverityCounts([issueCounts, countBySeverity(runIssues)]),
2625
2540
  ...(runIssues.length === 0 ? {} : { issues: [...runIssues] }),
2626
2541
  duration: `${Date.now() - startTime}ms`,
@@ -2653,14 +2568,14 @@ function logHierarchicalSummary(results, runIssues, logger) {
2653
2568
  const status = calculateOverallStatus(results);
2654
2569
  const skillsWithIssues = countResultsWithFindings(results);
2655
2570
  const totalSkills = results.length;
2656
- // Audit is advisory only — always exit 0 for validation results.
2657
- // Use vat skills validate for gated validation (exit 1 on errors).
2658
- if (logRunIntegrity(runIssues, logger)) {
2571
+ // Same rule as the per-file lane: the exit code follows `status`.
2572
+ const refused = logRunIntegrity(runIssues, logger);
2573
+ if (refused) {
2659
2574
  // Rendered above; there is no skill verdict to print over zero skills.
2660
2575
  }
2661
2576
  else if (status === 'error') {
2662
2577
  const errorCount = results.filter((r) => r.status === 'error').length;
2663
- 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)`);
2664
2579
  }
2665
2580
  else if (status === 'warning') {
2666
2581
  const warningCount = results.filter((r) => r.status === 'warning').length;
@@ -2675,6 +2590,6 @@ function logHierarchicalSummary(results, runIssues, logger) {
2675
2590
  logger.info(`Audit successful: ${totalSkills} skill(s) passed`);
2676
2591
  }
2677
2592
  renderAuditFooter(results, logger);
2678
- process.exit(0);
2593
+ process.exit(refused || status === 'error' ? ExitCode.FINDINGS : ExitCode.OK);
2679
2594
  }
2680
2595
  //# sourceMappingURL=audit.js.map