@vibe-agent-toolkit/cli 0.2.0-rc.5 → 0.2.0-rc.7

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 (220) hide show
  1. package/README.md +4 -4
  2. package/dist/bin.js +41 -158
  3. package/dist/bin.js.map +1 -1
  4. package/dist/command-loaders.d.ts +2 -2
  5. package/dist/command-loaders.d.ts.map +1 -1
  6. package/dist/command-loaders.js +4 -2
  7. package/dist/command-loaders.js.map +1 -1
  8. package/dist/commands/ard/emit.d.ts +104 -0
  9. package/dist/commands/ard/emit.d.ts.map +1 -0
  10. package/dist/commands/ard/emit.js +223 -0
  11. package/dist/commands/ard/emit.js.map +1 -0
  12. package/dist/commands/ard/index.d.ts +12 -0
  13. package/dist/commands/ard/index.d.ts.map +1 -0
  14. package/dist/commands/ard/index.js +79 -0
  15. package/dist/commands/ard/index.js.map +1 -0
  16. package/dist/commands/ard/surfaces.d.ts +56 -0
  17. package/dist/commands/ard/surfaces.d.ts.map +1 -0
  18. package/dist/commands/ard/surfaces.js +150 -0
  19. package/dist/commands/ard/surfaces.js.map +1 -0
  20. package/dist/commands/audit.d.ts +75 -8
  21. package/dist/commands/audit.d.ts.map +1 -1
  22. package/dist/commands/audit.js +726 -127
  23. package/dist/commands/audit.js.map +1 -1
  24. package/dist/commands/claude/budget.d.ts +13 -0
  25. package/dist/commands/claude/budget.d.ts.map +1 -1
  26. package/dist/commands/claude/budget.js +80 -28
  27. package/dist/commands/claude/budget.js.map +1 -1
  28. package/dist/commands/claude/claude-config.d.ts.map +1 -1
  29. package/dist/commands/claude/claude-config.js +6 -0
  30. package/dist/commands/claude/claude-config.js.map +1 -1
  31. package/dist/commands/claude/context.js +1 -1
  32. package/dist/commands/claude/context.js.map +1 -1
  33. package/dist/commands/claude/marketplace/validate.d.ts +55 -22
  34. package/dist/commands/claude/marketplace/validate.d.ts.map +1 -1
  35. package/dist/commands/claude/marketplace/validate.js +380 -75
  36. package/dist/commands/claude/marketplace/validate.js.map +1 -1
  37. package/dist/commands/claude/org/helpers.d.ts +59 -1
  38. package/dist/commands/claude/org/helpers.d.ts.map +1 -1
  39. package/dist/commands/claude/org/helpers.js +58 -7
  40. package/dist/commands/claude/org/helpers.js.map +1 -1
  41. package/dist/commands/claude/org/index.d.ts.map +1 -1
  42. package/dist/commands/claude/org/index.js +24 -16
  43. package/dist/commands/claude/org/index.js.map +1 -1
  44. package/dist/commands/claude/org/skills.d.ts +509 -0
  45. package/dist/commands/claude/org/skills.d.ts.map +1 -1
  46. package/dist/commands/claude/org/skills.js +1364 -94
  47. package/dist/commands/claude/org/skills.js.map +1 -1
  48. package/dist/commands/claude/org/users.d.ts.map +1 -1
  49. package/dist/commands/claude/org/users.js +3 -1
  50. package/dist/commands/claude/org/users.js.map +1 -1
  51. package/dist/commands/claude/org/workspaces.d.ts.map +1 -1
  52. package/dist/commands/claude/org/workspaces.js +5 -2
  53. package/dist/commands/claude/org/workspaces.js.map +1 -1
  54. package/dist/commands/claude/plugin/build.d.ts +8 -0
  55. package/dist/commands/claude/plugin/build.d.ts.map +1 -1
  56. package/dist/commands/claude/plugin/build.js +16 -2
  57. package/dist/commands/claude/plugin/build.js.map +1 -1
  58. package/dist/commands/claude/plugin/tree-copy.d.ts +72 -0
  59. package/dist/commands/claude/plugin/tree-copy.d.ts.map +1 -1
  60. package/dist/commands/claude/plugin/tree-copy.js +215 -10
  61. package/dist/commands/claude/plugin/tree-copy.js.map +1 -1
  62. package/dist/commands/corpus/index.d.ts.map +1 -1
  63. package/dist/commands/corpus/index.js +3 -0
  64. package/dist/commands/corpus/index.js.map +1 -1
  65. package/dist/commands/corpus/report.d.ts +0 -1
  66. package/dist/commands/corpus/report.d.ts.map +1 -1
  67. package/dist/commands/corpus/report.js +0 -1
  68. package/dist/commands/corpus/report.js.map +1 -1
  69. package/dist/commands/corpus/runner.d.ts +58 -1
  70. package/dist/commands/corpus/runner.d.ts.map +1 -1
  71. package/dist/commands/corpus/runner.js +90 -15
  72. package/dist/commands/corpus/runner.js.map +1 -1
  73. package/dist/commands/corpus/scan.d.ts.map +1 -1
  74. package/dist/commands/corpus/scan.js +0 -1
  75. package/dist/commands/corpus/scan.js.map +1 -1
  76. package/dist/commands/doctor.d.ts +14 -0
  77. package/dist/commands/doctor.d.ts.map +1 -1
  78. package/dist/commands/doctor.js +96 -18
  79. package/dist/commands/doctor.js.map +1 -1
  80. package/dist/commands/okf/index.d.ts +11 -0
  81. package/dist/commands/okf/index.d.ts.map +1 -0
  82. package/dist/commands/okf/index.js +102 -0
  83. package/dist/commands/okf/index.js.map +1 -0
  84. package/dist/commands/okf/validate.d.ts +82 -0
  85. package/dist/commands/okf/validate.d.ts.map +1 -0
  86. package/dist/commands/okf/validate.js +157 -0
  87. package/dist/commands/okf/validate.js.map +1 -0
  88. package/dist/commands/phase-utils.d.ts +19 -3
  89. package/dist/commands/phase-utils.d.ts.map +1 -1
  90. package/dist/commands/phase-utils.js +26 -5
  91. package/dist/commands/phase-utils.js.map +1 -1
  92. package/dist/commands/rag/index-command.d.ts +27 -2
  93. package/dist/commands/rag/index-command.d.ts.map +1 -1
  94. package/dist/commands/rag/index-command.js +44 -7
  95. package/dist/commands/rag/index-command.js.map +1 -1
  96. package/dist/commands/rag/index.d.ts.map +1 -1
  97. package/dist/commands/rag/index.js +2 -0
  98. package/dist/commands/rag/index.js.map +1 -1
  99. package/dist/commands/resources/check-progress.d.ts +3 -0
  100. package/dist/commands/resources/check-progress.d.ts.map +1 -1
  101. package/dist/commands/resources/check-progress.js +6 -0
  102. package/dist/commands/resources/check-progress.js.map +1 -1
  103. package/dist/commands/resources/check.d.ts +33 -4
  104. package/dist/commands/resources/check.d.ts.map +1 -1
  105. package/dist/commands/resources/check.js +131 -44
  106. package/dist/commands/resources/check.js.map +1 -1
  107. package/dist/commands/resources/index.d.ts.map +1 -1
  108. package/dist/commands/resources/index.js +41 -9
  109. package/dist/commands/resources/index.js.map +1 -1
  110. package/dist/commands/resources/query.d.ts +18 -1
  111. package/dist/commands/resources/query.d.ts.map +1 -1
  112. package/dist/commands/resources/query.js +29 -4
  113. package/dist/commands/resources/query.js.map +1 -1
  114. package/dist/commands/resources/validate.d.ts +29 -0
  115. package/dist/commands/resources/validate.d.ts.map +1 -1
  116. package/dist/commands/resources/validate.js +116 -13
  117. package/dist/commands/resources/validate.js.map +1 -1
  118. package/dist/commands/skill/review-checklist.d.ts.map +1 -1
  119. package/dist/commands/skill/review-checklist.js +35 -0
  120. package/dist/commands/skill/review-checklist.js.map +1 -1
  121. package/dist/commands/skill/review.d.ts.map +1 -1
  122. package/dist/commands/skill/review.js +9 -2
  123. package/dist/commands/skill/review.js.map +1 -1
  124. package/dist/commands/skill/test/configure.d.ts +37 -0
  125. package/dist/commands/skill/test/configure.d.ts.map +1 -1
  126. package/dist/commands/skill/test/configure.js +57 -12
  127. package/dist/commands/skill/test/configure.js.map +1 -1
  128. package/dist/commands/skill/test/run.d.ts.map +1 -1
  129. package/dist/commands/skill/test/run.js +21 -6
  130. package/dist/commands/skill/test/run.js.map +1 -1
  131. package/dist/commands/skills/build.d.ts.map +1 -1
  132. package/dist/commands/skills/build.js +16 -3
  133. package/dist/commands/skills/build.js.map +1 -1
  134. package/dist/commands/skills/list.d.ts +2 -1
  135. package/dist/commands/skills/list.d.ts.map +1 -1
  136. package/dist/commands/skills/list.js +31 -9
  137. package/dist/commands/skills/list.js.map +1 -1
  138. package/dist/commands/skills/skill-discovery.d.ts +40 -1
  139. package/dist/commands/skills/skill-discovery.d.ts.map +1 -1
  140. package/dist/commands/skills/skill-discovery.js +32 -3
  141. package/dist/commands/skills/skill-discovery.js.map +1 -1
  142. package/dist/commands/skills/validate-command.d.ts.map +1 -1
  143. package/dist/commands/skills/validate-command.js +11 -3
  144. package/dist/commands/skills/validate-command.js.map +1 -1
  145. package/dist/commands/skills/validate.d.ts +66 -7
  146. package/dist/commands/skills/validate.d.ts.map +1 -1
  147. package/dist/commands/skills/validate.js +133 -29
  148. package/dist/commands/skills/validate.js.map +1 -1
  149. package/dist/commands/verify.d.ts +94 -3
  150. package/dist/commands/verify.d.ts.map +1 -1
  151. package/dist/commands/verify.js +192 -45
  152. package/dist/commands/verify.js.map +1 -1
  153. package/dist/skill-resolution/packaging-config.d.ts +49 -5
  154. package/dist/skill-resolution/packaging-config.d.ts.map +1 -1
  155. package/dist/skill-resolution/packaging-config.js +59 -19
  156. package/dist/skill-resolution/packaging-config.js.map +1 -1
  157. package/dist/skill-resolution/resolve-skill-reference.d.ts.map +1 -1
  158. package/dist/skill-resolution/resolve-skill-reference.js +6 -3
  159. package/dist/skill-resolution/resolve-skill-reference.js.map +1 -1
  160. package/dist/utils/command-error.d.ts +31 -0
  161. package/dist/utils/command-error.d.ts.map +1 -1
  162. package/dist/utils/command-error.js +45 -0
  163. package/dist/utils/command-error.js.map +1 -1
  164. package/dist/utils/config-loader.d.ts.map +1 -1
  165. package/dist/utils/config-loader.js +22 -6
  166. package/dist/utils/config-loader.js.map +1 -1
  167. package/dist/utils/edge-lens-evaluation.d.ts +83 -0
  168. package/dist/utils/edge-lens-evaluation.d.ts.map +1 -0
  169. package/dist/utils/edge-lens-evaluation.js +110 -0
  170. package/dist/utils/edge-lens-evaluation.js.map +1 -0
  171. package/dist/utils/optional-backend.d.ts +5 -4
  172. package/dist/utils/optional-backend.d.ts.map +1 -1
  173. package/dist/utils/optional-backend.js +5 -4
  174. package/dist/utils/optional-backend.js.map +1 -1
  175. package/dist/utils/project-root-policy.d.ts +20 -0
  176. package/dist/utils/project-root-policy.d.ts.map +1 -1
  177. package/dist/utils/project-root-policy.js +32 -0
  178. package/dist/utils/project-root-policy.js.map +1 -1
  179. package/dist/utils/projection-query.d.ts +41 -4
  180. package/dist/utils/projection-query.d.ts.map +1 -1
  181. package/dist/utils/projection-query.js +44 -10
  182. package/dist/utils/projection-query.js.map +1 -1
  183. package/dist/utils/projection-store.d.ts +41 -15
  184. package/dist/utils/projection-store.d.ts.map +1 -1
  185. package/dist/utils/projection-store.js +65 -18
  186. package/dist/utils/projection-store.js.map +1 -1
  187. package/dist/utils/repeatable-option.d.ts +37 -0
  188. package/dist/utils/repeatable-option.d.ts.map +1 -0
  189. package/dist/utils/repeatable-option.js +39 -0
  190. package/dist/utils/repeatable-option.js.map +1 -0
  191. package/dist/utils/resource-loader.d.ts +4 -3
  192. package/dist/utils/resource-loader.d.ts.map +1 -1
  193. package/dist/utils/resource-loader.js +61 -30
  194. package/dist/utils/resource-loader.js.map +1 -1
  195. package/dist/utils/root-argv.d.ts +53 -0
  196. package/dist/utils/root-argv.d.ts.map +1 -0
  197. package/dist/utils/root-argv.js +215 -0
  198. package/dist/utils/root-argv.js.map +1 -0
  199. package/dist/utils/run-integrity.d.ts +95 -0
  200. package/dist/utils/run-integrity.d.ts.map +1 -0
  201. package/dist/utils/run-integrity.js +102 -0
  202. package/dist/utils/run-integrity.js.map +1 -0
  203. package/dist/utils/sqlite-experimental-warning.d.ts +69 -0
  204. package/dist/utils/sqlite-experimental-warning.d.ts.map +1 -0
  205. package/dist/utils/sqlite-experimental-warning.js +144 -0
  206. package/dist/utils/sqlite-experimental-warning.js.map +1 -0
  207. package/dist/utils/user-context-scanner.d.ts +8 -0
  208. package/dist/utils/user-context-scanner.d.ts.map +1 -1
  209. package/dist/utils/user-context-scanner.js +4 -0
  210. package/dist/utils/user-context-scanner.js.map +1 -1
  211. package/dist/utils/validate-help-files.d.ts.map +1 -1
  212. package/dist/utils/validate-help-files.js +9 -2
  213. package/dist/utils/validate-help-files.js.map +1 -1
  214. package/docs/audit.md +120 -7
  215. package/docs/doctor.md +6 -6
  216. package/docs/index.md +1 -1
  217. package/docs/rag.md +3 -1
  218. package/docs/resources.md +5 -1
  219. package/docs/skills.md +47 -62
  220. package/package.json +18 -18
@@ -5,22 +5,25 @@
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 { listingRefusalRemedy } from '@vibe-agent-toolkit/resources';
11
12
  import { calculateValidationStatus, countBySeverity, } from '@vibe-agent-toolkit/schema';
12
13
  import { findProjectRoot, isAbsolutePath, isFilesystemAccessError, issueLocation, resetProjectRootCaches, safePath, } 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
17
  import picomatch from 'picomatch';
16
18
  import { resetSkillDiscoveryCache, resolveProjectDeclaredEvalSuites, resolveSkillPackagingConfig, stripValidationAllowForDisplay, } from '../skill-resolution/packaging-config.js';
17
19
  import { handleCommandError } from '../utils/command-error.js';
18
20
  import { ConfigLoadError, loadConfig, resetLoadedConfigCache, } from '../utils/config-loader.js';
19
- import { countCollapsedFindings, formatCollapsedFindingsHint, formatIssueLines, formatSeverityBreakdown, issuesToRenderAtVerbosity, } from '../utils/issue-rendering.js';
21
+ import { countCollapsedFindings, formatCollapsedFindingsHint, formatIssueLines, formatSeverityBreakdown, issuesToRenderAtVerbosity, sumSeverityCounts, } from '../utils/issue-rendering.js';
20
22
  import { resolveIssueSeverity } from '../utils/issue-severity.js';
21
23
  import { createLogger } from '../utils/logger.js';
22
24
  import { writeYamlOutput } from '../utils/output.js';
23
25
  import { relativizePathEntries } from '../utils/relativize-paths.js';
26
+ import { nothingCheckedFinding } from '../utils/run-integrity.js';
24
27
  import { mergeSkillPackagingConfig } from '../utils/skill-packaging-config.js';
25
28
  import { renderSkillQualityFooter } from '../utils/skill-quality-footer.js';
26
29
  import { computeConfigVerdicts } from '../utils/verdict-helpers.js';
@@ -29,7 +32,7 @@ import { withClonedRepo } from './audit/git-url-clone.js';
29
32
  import { buildHierarchicalOutput } from './audit/hierarchical-output.js';
30
33
  import { renderProvenanceHeader, rewritePathsInResults, } from './audit/provenance.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. */
@@ -51,7 +54,7 @@ const VAT_CONFIG_FILENAME = 'vibe-agent-toolkit.config.yaml';
51
54
  *
52
55
  * Returns `null` if `scanRoot` has no config or the config has no skills.
53
56
  */
54
- async function buildVATProjectContext(scanRoot, logger) {
57
+ async function buildVATProjectContext(scanRoot, logger, locationRoot) {
55
58
  const configPath = safePath.join(scanRoot, VAT_CONFIG_FILENAME);
56
59
  // eslint-disable-next-line security/detect-non-literal-fs-filename -- scanRoot is a controlled parameter
57
60
  if (!fs.existsSync(configPath)) {
@@ -69,7 +72,7 @@ async function buildVATProjectContext(scanRoot, logger) {
69
72
  return null;
70
73
  const skillConfigs = new Map();
71
74
  try {
72
- const discovered = await discoverSkillsFromConfig(config.skills, scanRoot);
75
+ const discovered = await discoverSkillsFromConfig(config.skills, scanRoot, degradingDiscovery(scanRoot, logger, locationRoot));
73
76
  const { defaults, config: perSkillConfig } = config.skills;
74
77
  for (const skill of discovered) {
75
78
  const absPath = safePath.resolve(skill.sourcePath);
@@ -79,7 +82,16 @@ async function buildVATProjectContext(scanRoot, logger) {
79
82
  logger.debug(`Config-aware audit: found ${discovered.length} skill(s) in ${scanRoot}`);
80
83
  }
81
84
  catch (error) {
82
- logger.debug(`Config-aware audit: failed to discover skills in ${scanRoot}: ${String(error)}`);
85
+ // Discovery was handed the degrade policy above, so a refused listing never
86
+ // reaches here; one that does (a caller that switched it to `'refuse'`) is
87
+ // still degraded past through the same ledger rather than swallowed. It used
88
+ // to be `logger.debug` for ANY error, which is how a `chmod 000` directory
89
+ // under `skills.include` downgraded every skill to config-free validation with
90
+ // the directory named nowhere an operator without `--debug` could see it. A
91
+ // defect in discovery is not environmental and still fails loudly.
92
+ if (!(error instanceof DirectoryListingRefusedError))
93
+ throw error;
94
+ recordRefusedDiscovery(error, scanRoot, logger, locationRoot);
83
95
  }
84
96
  if (skillConfigs.size === 0) {
85
97
  return null;
@@ -162,22 +174,287 @@ function packagingResultToValidationResult(skillPath, result, configTargets, loc
162
174
  * depth, so crawling again reports one file twice, while omitting it for a
163
175
  * standalone bundle is the blindness this parameter exists to end.
164
176
  */
165
- async function validateSingleSkill(skillPath, options, logger, locationRoot, crawlTree, isVATGenerated) {
166
- // Try config-aware validation: walk UP to the skill's nearest-ancestor
167
- // vibe-agent-toolkit.config.yaml and apply the skill's packaging block. A
168
- // broken governing config is tolerated here (audit is a bulk linter): log it
169
- // and fall back to config-free validation rather than aborting the scan.
177
+ /**
178
+ * Resolve a skill's nearest-ancestor `vibe-agent-toolkit.config.yaml`, treating
179
+ * one that cannot be LOADED as absent rather than as fatal.
180
+ *
181
+ * 🔑 Both audit lanes call this, and that is the point. They used to carry
182
+ * separate copies of the fork, and the copies disagreed: the single-target lane
183
+ * caught `ConfigLoadError` and fell back, the directory-scan lane let it throw.
184
+ * One unrecognized key in one nested config therefore aborted a whole-tree scan
185
+ * with exit 2 and zero skills audited, while auditing a single SKILL.md under
186
+ * that same config exited 0 — a verdict that depended on whether the argument
187
+ * was a file or its parent directory. Keeping the tolerance in ONE function is
188
+ * what stops the two answers drifting apart again.
189
+ *
190
+ * Degrading beats destroying, and silence is not the alternative — the policy
191
+ * `SCAN_PATH_UNREADABLE` states in its own registry docstring. So this warns
192
+ * rather than logging at debug, AND files a finding on the config path, because
193
+ * the warning goes to stderr while the report goes to stdout: a run that
194
+ * downgraded N skills to the much weaker config-free `validateSkill` and exited 0
195
+ * said nothing about it in the machine-readable artifact anyone actually diffs.
196
+ * That is the "detector silently disables itself" shape this code cites and was
197
+ * still doing.
198
+ *
199
+ * ⚠️ The filesystem question IS asked, and the answer changes what the operator
200
+ * is told. `config-loader.ts` preserves `cause` specifically so callers can ask
201
+ * `isFilesystemAccessError` and "decide whether to degrade or abort"; catching
202
+ * `ConfigLoadError` unconditionally never asked at all, so a `chmod 000` config
203
+ * and a typo produced the same sentence — and a permissions problem is not a
204
+ * broken config.
205
+ *
206
+ * 🔑 Asking it and still DEGRADING is deliberate, and was measured. Re-throwing
207
+ * so the per-entry guard anchors the finding is the obvious alternative and is
208
+ * worse: on a fixture with one unreadable config and one perfectly readable
209
+ * skill, the re-throw produced `filesScanned: 1, filesPassed: 0` with the finding
210
+ * anchored on `skills/demo/SKILL.md` and the skill never validated at all. On a
211
+ * real `--user` run that is hundreds of readable skills losing every check
212
+ * because one config is `chmod 000` — the exact "destroying" this lane exists to
213
+ * prevent, and a violation of the neighbouring promise that "a skill beside the
214
+ * unreadable path is validated exactly as if it were not there". The finding is
215
+ * filed on the CONFIG, which is the path that was actually refused, and the skill
216
+ * goes on to config-free validation.
217
+ *
218
+ * @returns the skill's display-safe packaging config, or `null` when there is
219
+ * no governing config OR when the one found could not be loaded.
220
+ */
221
+ async function resolveGoverningConfig(skillPath, logger, locationRoot) {
222
+ // The walk-up the resolver is about to do (cached), so the degrade handler can
223
+ // name the config whose include reached the directory. `null` means no config
224
+ // governs this skill; the resolver answers the same and nothing can be refused.
225
+ const configRoot = findProjectRoot(safePath.resolve(skillPath, '..'));
226
+ if (configRoot === null)
227
+ return null;
170
228
  let fullConfig;
171
229
  try {
172
- fullConfig = await resolveSkillPackagingConfig(skillPath);
230
+ fullConfig = await resolveSkillPackagingConfig(skillPath, degradingDiscovery(configRoot, logger, locationRoot));
173
231
  }
174
232
  catch (err) {
175
- if (!(err instanceof ConfigLoadError))
176
- throw err;
177
- logger.debug(` Ignoring broken governing config for ${skillPath}: ${err.message}`);
178
- fullConfig = null;
233
+ if (err instanceof ConfigLoadError) {
234
+ recordUnloadableConfig(err, logger, locationRoot);
235
+ return null;
236
+ }
237
+ // Unreachable while the resolver is handed `degradingDiscovery` above — the
238
+ // crawl then enumerates around a refused directory instead of throwing — but
239
+ // kept as the fallback for the default policy: same two channels, the skill
240
+ // goes on config-free rather than the run aborting.
241
+ if (err instanceof DirectoryListingRefusedError) {
242
+ recordRefusedDiscovery(err, configRoot, logger, locationRoot);
243
+ return null;
244
+ }
245
+ throw err;
179
246
  }
180
- const skillConfig = fullConfig === null ? null : stripValidationAllowForDisplay(fullConfig);
247
+ return fullConfig === null ? null : stripValidationAllowForDisplay(fullConfig);
248
+ }
249
+ /**
250
+ * Configs this run has already degraded past — unloadable, or loaded but with a
251
+ * `skills.include` the crawl could not fully expand — keyed by the PATH that was
252
+ * refused (the config file, or the directory the crawl could not list), each
253
+ * holding the synthetic result that carries its finding into the report.
254
+ *
255
+ * 🚨 MODULE-scoped, and cleared by {@link resetAuditCaches}, exactly like
256
+ * `config-loader.ts`'s `warnedConfigPaths`. It used to be a `Set` threaded as a
257
+ * parameter, and the threading had a hole: `handleFileEntry` passed the scan's
258
+ * real log, but `validateSingleSkill` constructed a fresh `new Set()` on EVERY
259
+ * call — and `validatePluginSkillsViaInventory` calls it in a loop over every
260
+ * skill in a plugin. A 40-skill plugin under one unloadable config printed 40
261
+ * identical warnings. The integration fixture was a bare directory, which routes
262
+ * through `handleFileEntry`, so the "warns once, not once per skill" test passed
263
+ * while the lane that actually repeated was never exercised. A run-scoped ledger
264
+ * cannot be re-created per call by mistake; a parameter can.
265
+ *
266
+ * ⚠️ Keyed on the refused PATH (derived from `ConfigLoadError.projectRoot` or
267
+ * `DirectoryListingRefusedError.refusal.directory`), NOT on `err.message`. The
268
+ * old key was the message with a comment claiming it "names the offending config
269
+ * path" — true only for a Zod failure, which is what the fixture happened to be.
270
+ * A YAML SYNTAX error is thrown out of `yaml.parse` and wrapped as `Failed to
271
+ * load config: ${message}`, and `YAMLParseError.message` carries no filename at
272
+ * all, so two nested projects with the same copy-pasted broken config collapsed
273
+ * to ONE warning naming NEITHER.
274
+ */
275
+ const unloadableConfigResults = new Map();
276
+ /**
277
+ * The RUN's conventional-suite probe — the memo behind "does
278
+ * `<skill-root>/evals/evals.json` exist?".
279
+ *
280
+ * Resolving one skill's test input asks that question of the SUBJECT and of
281
+ * every entry in `projectSkills`, so a probe minted per skill costs S questions
282
+ * per skill and S² per run over the same S paths. Measured with the lab on a
283
+ * 103-skill adopter: 10,815 probes over 103 distinct paths, half of the
284
+ * command's entire filesystem traffic.
285
+ *
286
+ * 🚨 MODULE-scoped and cleared by {@link resetAuditCaches}, for the same reason
287
+ * `unloadableConfigResults` above is: this command has TWO lanes that build a
288
+ * shared context — {@link validateSingleSkill} and {@link handleFileEntry} — and
289
+ * both build it PER SKILL, inside their own loops. A probe threaded as a
290
+ * parameter would be re-minted by whichever lane forgot to thread it, silently,
291
+ * and that is exactly how this stayed quadratic: the field existed and no caller
292
+ * set it.
293
+ *
294
+ * ⚠️ Its lifetime is the RUN, never longer. The answer is a filesystem snapshot,
295
+ * and a memo outliving the invocation would keep answering for a tree that has
296
+ * since changed — in the CLI entrypoint AND in an in-process test sharing a
297
+ * vitest worker.
298
+ */
299
+ let runSuiteProbe;
300
+ /** The run's probe, minted on first use and dropped by {@link resetAuditCaches}. */
301
+ function suiteProbeForRun() {
302
+ runSuiteProbe ??= conventionalSuiteProbe();
303
+ return runSuiteProbe;
304
+ }
305
+ /**
306
+ * Warn about — and file a finding for — a governing config this run could not
307
+ * load, once per config however many skills it governs.
308
+ *
309
+ * @param err - The load failure, carrying the project root that owns the config
310
+ * @param logger - Warnings go to stderr; the finding goes to the report
311
+ * @param locationRoot - The run's anchor base for the emitted location
312
+ */
313
+ function recordUnloadableConfig(err, logger, locationRoot) {
314
+ const configPath = safePath.join(err.projectRoot, VAT_CONFIG_FILENAME);
315
+ if (unloadableConfigResults.has(configPath))
316
+ return;
317
+ unloadableConfigResults.set(configPath, unreadablePathResult(configPath, err, locationRoot));
318
+ // The filesystem answer changes the NOUN, because the two conditions need
319
+ // different actions from the operator: `chmod`/ownership on one side, an edit
320
+ // to the file on the other. Collapsing them into one sentence was the whole
321
+ // cost of never asking.
322
+ const why = isFilesystemAccessError(err) ? 'unreadable' : 'unloadable';
323
+ logger.warn(`Ignoring ${why} config at ${configPath}; skills it governs are validated`
324
+ + ` config-free: ${err.message}`);
325
+ }
326
+ /**
327
+ * Audit's discovery policy: enumerate AROUND a directory the crawl cannot list,
328
+ * report the gap, and keep every skill the crawl could see under its config.
329
+ *
330
+ * 🔑 This is what keeps ONE `chmod 000` sibling from costing a readable,
331
+ * declared skill every config-aware check. Discovery's default is to refuse —
332
+ * right for a build that must not ship a shorter bundle — but audit already
333
+ * validates "a skill beside the unreadable path exactly as if it were not
334
+ * there", and that promise is only kept if discovery degrades the same way the
335
+ * walk does. The refusal is recorded through the same ledger as an unloadable
336
+ * config, so it is warned about once and filed once however many skills ask.
337
+ *
338
+ * @param configRoot - The project root whose `skills.include` is being expanded
339
+ * @param logger - Warnings go to stderr; the finding goes to the report
340
+ * @param locationRoot - The run's anchor base for the emitted location
341
+ */
342
+ function degradingDiscovery(configRoot, logger, locationRoot) {
343
+ return {
344
+ degrade: (refusal) => {
345
+ recordRefusedDiscovery(new DirectoryListingRefusedError(refusal, { root: configRoot, remedy: SKILLS_INCLUDE_REMEDY }), configRoot, logger, locationRoot);
346
+ },
347
+ };
348
+ }
349
+ /**
350
+ * Audit's ruling for the registry crawl — the link graph `crawlAndResolveRegistry`
351
+ * builds over the whole project root for the config-aware lane: enumerate
352
+ * AROUND a directory it cannot list, file the gap, keep every readable document.
353
+ *
354
+ * The other half of {@link degradingDiscovery}, on the other crawl the
355
+ * config-aware lane makes. Every other verb hands this crawl `'refuse'` (a build
356
+ * must not ship a shorter bundle), and while the registry carried that ruling
357
+ * 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:
360
+ * status describes what was found, exit describes whether the run completed.
361
+ *
362
+ * Recorded through the same per-directory ledger as a refused discovery, so a
363
+ * directory both crawls meet is warned about once and filed once; a directory
364
+ * only THIS crawl meets (the link graph covers the whole root, discovery only
365
+ * `skills.include`'s bases) gets its own sentence, naming the crawl that
366
+ * skipped it. The finding carries the projection's remedy — the same sentence
367
+ * the refuse ruling throws — because `--exclude` governs audit's own walk, not
368
+ * this crawl, and an ignore rule is the one knob that does.
369
+ *
370
+ * @param projectRoot - The root the link graph is crawled from
371
+ * @param logger - Warnings go to stderr; the finding goes to the report
372
+ * @param locationRoot - The run's anchor base for the emitted location
373
+ */
374
+ function degradingRegistry(projectRoot, logger, locationRoot) {
375
+ return {
376
+ degrade: (refusal) => {
377
+ const err = new DirectoryListingRefusedError(refusal, { root: projectRoot, remedy: listingRefusalRemedy(projectRoot) });
378
+ recordRefusedListing(err, logger, locationRoot, () => `The link graph under ${projectRoot} skipped an unreadable directory; every document beneath it`
379
+ + ` is missing from this audit's link and reference checks: ${err.message}`);
380
+ },
381
+ };
382
+ }
383
+ /**
384
+ * Warn about — and file a finding for — a directory that skill discovery under
385
+ * `configRoot` could not list, once per directory however many skills ask.
386
+ *
387
+ * The finding is anchored on the DIRECTORY, not the config: the config loaded
388
+ * fine, and the directory is the path the filesystem refused. In the directory
389
+ * lane the walk's own per-entry guard files the same code on the same location;
390
+ * {@link dedupeUnreadablePathResults} keeps one. The warning carries the crawl's
391
+ * own sentence, which already names the directory against the project root and
392
+ * the `skills.include` remedy.
393
+ *
394
+ * @param err - The refusal, naming the directory the crawl could not list
395
+ * @param configRoot - The project root whose `skills.include` reached it
396
+ * @param logger - Warnings go to stderr; the finding goes to the report
397
+ * @param locationRoot - The run's anchor base for the emitted location
398
+ */
399
+ function recordRefusedDiscovery(err, configRoot, logger, locationRoot) {
400
+ const configPath = safePath.join(configRoot, VAT_CONFIG_FILENAME);
401
+ recordRefusedListing(err, logger, locationRoot, () => `Skill discovery under ${configPath} skipped an unreadable directory; any skill beneath it`
402
+ + ` is missing from this audit: ${err.message}`);
403
+ }
404
+ /**
405
+ * The one ledger write for a refused listing, whichever crawl met it: file
406
+ * `SCAN_PATH_UNREADABLE` on the directory and warn once on stderr. Keyed on the
407
+ * refused directory, so the second crawl to meet it — discovery and the link
408
+ * graph both walk `skills/locked` in the directory lane — says nothing more.
409
+ *
410
+ * @param err - The refusal, naming the directory the crawl could not list
411
+ * @param logger - Warnings go to stderr; the finding goes to the report
412
+ * @param locationRoot - The run's anchor base for the emitted location
413
+ * @param warning - The stderr sentence, built only when this is the first record
414
+ */
415
+ function recordRefusedListing(err, logger, locationRoot, warning) {
416
+ const directory = err.refusal.directory;
417
+ if (unloadableConfigResults.has(directory))
418
+ return;
419
+ unloadableConfigResults.set(directory, unreadablePathResult(directory, err, locationRoot));
420
+ logger.warn(warning());
421
+ }
422
+ /**
423
+ * The synthetic results for every config this run degraded past, so the report
424
+ * carries what the stderr warning says.
425
+ *
426
+ * Read, never drained: `resetAuditCaches()` owns the lifetime, and a drain would
427
+ * make the answer depend on which aggregation site ran first.
428
+ */
429
+ function unloadableConfigFindings() {
430
+ return [...unloadableConfigResults.values()];
431
+ }
432
+ /**
433
+ * A run's results plus the findings for every config it degraded past, with each
434
+ * refused path reported once.
435
+ *
436
+ * Appended, not merged into a neighbour: a config the scan could not load, or a
437
+ * directory its discovery could not list, has no host result to hang off, and
438
+ * attributing it to a skill would name a file that was read fine. Without the
439
+ * append the ONLY record was a stderr warning, so a run that downgraded every
440
+ * skill under that config to the weaker config-free validator exited 0 with a
441
+ * report that said nothing about it. Deduped AFTER the append, because the
442
+ * directory lane's walk files its own `SCAN_PATH_UNREADABLE` on the very
443
+ * directory a refused discovery names — one fact, one finding, whichever lane
444
+ * met it first.
445
+ */
446
+ function withDegradedConfigFindings(results) {
447
+ return dedupeUnreadablePathResults([...results, ...unloadableConfigFindings()]);
448
+ }
449
+ async function validateSingleSkill(skillPath, options, logger, locationRoot, crawlTree, isVATGenerated) {
450
+ // Config-aware validation: walk UP to the skill's nearest-ancestor
451
+ // vibe-agent-toolkit.config.yaml and apply the skill's packaging block. A
452
+ // config that cannot be loaded is treated as absent — see
453
+ // {@link resolveGoverningConfig}, which both audit lanes share so they cannot
454
+ // disagree about that again. The warn-once ledger is run-scoped module state,
455
+ // NOT a set minted here — a fresh `new Set()` on this line is what made a
456
+ // 40-skill plugin print 40 identical warnings.
457
+ const skillConfig = await resolveGoverningConfig(skillPath, logger, locationRoot);
181
458
  if (skillConfig !== null) {
182
459
  logger.debug(` Using config-aware validation for: ${skillPath}`);
183
460
  const { gitTracker } = await resolveScanContext(safePath.resolve(skillPath), locationRoot, logger);
@@ -189,10 +466,18 @@ async function validateSingleSkill(skillPath, options, logger, locationRoot, cra
189
466
  // The project's declared eval suites, memoized per config root, so this lane
190
467
  // predicts the same bundle the packager produces: no skill's report counts a
191
468
  // SIBLING skill's eval suite as an ordinary bundled file.
469
+ // Audit's ruling for the link-graph crawl — see {@link degradingRegistry}.
470
+ // Stated on the context too, for the fallback crawl `validateSkillForPackaging`
471
+ // makes when this registry does not cover the skill: one ruling, both crawls.
472
+ const unreadable = degradingRegistry(projectRoot, logger, locationRoot);
192
473
  const sharedCtx = {
193
- registry: await crawlAndResolveRegistry(projectRoot),
474
+ registry: await crawlAndResolveRegistry(projectRoot, { unreadable }),
475
+ unreadable,
194
476
  locationRoot,
195
- projectSkills: await resolveProjectDeclaredEvalSuites(skillPath),
477
+ projectSkills: await resolveProjectDeclaredEvalSuites(skillPath, degradingDiscovery(projectRoot, logger, locationRoot)),
478
+ // The RUN's probe, not this call's — see {@link runSuiteProbe}. This lane
479
+ // is called in a loop over every skill in a plugin.
480
+ suiteProbe: suiteProbeForRun(),
196
481
  };
197
482
  if (gitTracker !== null) {
198
483
  sharedCtx.gitTracker = gitTracker;
@@ -249,6 +534,20 @@ function collect(value, previous) {
249
534
  /**
250
535
  * Create audit command
251
536
  * Top-level command: vat audit [path]
537
+ *
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.
252
551
  */
253
552
  export function createAuditCommand() {
254
553
  const audit = new Command('audit');
@@ -315,11 +614,26 @@ Description:
315
614
  done
316
615
  (See packages/cli/docs/audit.md for details.)
317
616
 
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.
625
+
626
+ A run that audited ZERO files is refused, not passed: 'status: error' with one
627
+ 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.
630
+
318
631
  Validation Behavior:
319
632
  Advisory only: audit surfaces all validation issues for inspection.
320
633
  Unlike 'vat skills validate', audit:
321
634
  - NEVER applies validation.allow (allowed codes are always shown)
322
- - Respects validation.severity: codes set to 'ignore' are hidden
635
+ - Respects validation.severity: a code set to 'ignore' is hidden; warnings
636
+ and errors are both reported, each at its configured severity
323
637
  - ALWAYS exits 0 for validation results (never gates on errors)
324
638
 
325
639
  For gated validation (CI/CD), use: vat skills validate
@@ -364,14 +678,24 @@ Config-Aware Validation:
364
678
  which prevents false warnings for links the build pipeline resolves.
365
679
 
366
680
  Config-aware mode never applies validation.allow — audit always shows
367
- all issues. validation.severity IS applied: a code set to 'ignore' is
368
- hidden. skills.defaults.validation.severity applies project-wide (skills,
369
- plugins and marketplaces alike); skills.config.<name>.validation.severity
370
- layers on top for that skill.
681
+ all issues. validation.severity IS applied, from three scopes, least
682
+ specific first:
683
+ resources.validation.severity project-wide (the same dial
684
+ 'vat resources validate' reads)
685
+ skills.defaults.validation.severity project-wide (skills, plugins and
686
+ marketplaces alike)
687
+ skills.config.<name>.validation... that one skill
371
688
 
372
689
  Exit Codes:
373
- 0 - Always (even when validation errors are surfaced)
374
- 2 - System error (config invalid, directory not found)
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
698
+ no report to read.
375
699
 
376
700
  Examples:
377
701
  $ vat audit ./plugins/ # Audit recursively (default)
@@ -439,7 +763,7 @@ async function auditUserDirectories(recursive, options, startTime, logger) {
439
763
  logger.error('Claude plugins/skills/marketplaces have not been installed yet.');
440
764
  process.exit(2);
441
765
  }
442
- const results = [];
766
+ const scanned = [];
443
767
  // The roots that exist, minus any one another already covers — see
444
768
  // {@link userScanTargets}. `marketplaces/` lives inside `plugins/`.
445
769
  const present = [
@@ -449,8 +773,12 @@ async function auditUserDirectories(recursive, options, startTime, logger) {
449
773
  ];
450
774
  for (const target of userScanTargets(present, recursive)) {
451
775
  logger.debug(`Auditing user-level resources at: ${target}`);
452
- results.push(...await getValidationResults(target, recursive, options, logger, scanRoot));
776
+ scanned.push(...await getValidationResults(target, recursive, options, logger, scanRoot));
453
777
  }
778
+ // The `--user` report is assembled here rather than by `buildAuditReport`, so
779
+ // it needs its own append or the config findings would reach stderr and not the
780
+ // document — see the same call in `buildAuditReport`.
781
+ const results = withDegradedConfigFindings(scanned);
454
782
  // Run compatibility analysis if --compat flag is set
455
783
  const compatMap = options.compat
456
784
  ? await runCompatAnalysis(results, logger, scanRoot) // --settings not supported in --user mode
@@ -463,7 +791,7 @@ async function auditUserDirectories(recursive, options, startTime, logger) {
463
791
  if (verbose) {
464
792
  renderVerboseEvidence(results, scanRoot, logger);
465
793
  }
466
- logHierarchicalSummary(results, logger);
794
+ logHierarchicalSummary(results, summary.issues, logger);
467
795
  }
468
796
  /** Skill resource types that can have per-skill validation config. */
469
797
  const SKILL_RESULT_TYPES = new Set([RESOURCE_TYPE_AGENT_SKILL, 'vat-agent']);
@@ -547,13 +875,21 @@ function buildFilteredResult(result, filteredIssues) {
547
875
  * same config promoted correctly. Two lanes, one config key, opposite answers.
548
876
  *
549
877
  * Status and counts are re-derived from the resolved severities by
550
- * {@link buildFilteredResult}, so a promotion moves the reported status. It does
551
- * NOT move this command's exit code — `vat audit` is advisory and its exit-code
552
- * behaviour is a separate, pre-existing question tracked in its own issue. That
553
- * asymmetry with `vat verify` (whose exit code IS severity-derived) is
554
- * deliberate here rather than overlooked.
555
- *
556
- * Two scopes, both live:
878
+ * {@link buildFilteredResult}, so a promotion moves the reported STATUS — which
879
+ * describes the findings — and never the exit code, which describes whether the
880
+ * run completed. The two are different questions and audit publishes both; see
881
+ * {@link handleAuditResults} and the `Output` section of this command's help.
882
+ * `vat verify` derives its exit code from severity because it is a gate; this
883
+ * command is a report.
884
+ *
885
+ * THREE scopes, least specific first:
886
+ * - `resources.validation.severity` — the PROJECT-WIDE map `vat resources
887
+ * validate` already honours. Audit read only the two `skills.*` scopes below,
888
+ * so one dial in one config file produced opposite answers from the two
889
+ * commands: measured on a two-code fixture, `LINK_MISSING_TARGET: ignore` and
890
+ * `LINK_TO_NAVIGATION_FILE: error` written here moved neither finding in
891
+ * audit. It is applied FIRST — a `skills` scope naming the same code is the
892
+ * more specific statement and wins.
557
893
  * - `skills.defaults.validation.severity` — the PROJECT-WIDE map, applied to
558
894
  * every result this run produced, including `claude-plugin` and `marketplace`
559
895
  * ones. It used to be applied only to skill-typed results, which left the
@@ -588,11 +924,13 @@ function buildFilteredResult(result, filteredIssues) {
588
924
  * @returns New results array with ignored codes removed from issues
589
925
  */
590
926
  function applySeverityFilter(results, config) {
591
- if (config?.skills === undefined) {
592
- return results;
593
- }
594
- const skillsConfig = config.skills;
595
- const defaultSeverity = skillsConfig.defaults?.validation?.severity ?? {};
927
+ const skillsConfig = config?.skills;
928
+ // NOT gated on `config.skills` existing. It was, and that made the resources
929
+ // dial below inert for exactly the projects that have only one — a repo of
930
+ // plugins declares no skills at all.
931
+ const resourceSeverity = config?.resources?.validation?.severity ?? {};
932
+ const defaultSeverity = skillsConfig?.defaults?.validation?.severity ?? {};
933
+ const projectSeverity = { ...resourceSeverity, ...defaultSeverity };
596
934
  // `.filter` after `.map`, not `.map` alone: a synthetic unreadable-path result
597
935
  // exists ONLY to carry its finding, so once the adopter has set
598
936
  // `severity.SCAN_PATH_UNREADABLE: ignore` the result has nothing left to say.
@@ -604,9 +942,9 @@ function applySeverityFilter(results, config) {
604
942
  const skillName = SKILL_RESULT_TYPES.has(result.type) ? result.metadata?.name : undefined;
605
943
  const perSkillSeverity = skillName === undefined
606
944
  ? {}
607
- : (skillsConfig.config?.[skillName]?.validation?.severity ?? {});
608
- // Merge: per-skill overrides default
609
- const effectiveSeverity = { ...defaultSeverity, ...perSkillSeverity };
945
+ : (skillsConfig?.config?.[skillName]?.validation?.severity ?? {});
946
+ // Merge, least specific first: resources → skills.defaults → skills.config.<name>
947
+ const effectiveSeverity = { ...projectSeverity, ...perSkillSeverity };
610
948
  if (Object.keys(effectiveSeverity).length === 0) {
611
949
  return result;
612
950
  }
@@ -669,7 +1007,18 @@ export async function buildAuditReport(scanPath, options, startTime, logger) {
669
1007
  const recursive = options.recursive !== false;
670
1008
  logger.debug(`Auditing resources at: ${scanPath}`);
671
1009
  const scanRoot = deriveScanRoot(scanPath);
672
- const rawResults = await getValidationResults(scanPath, recursive, options, logger, scanRoot);
1010
+ const scanned = await getValidationResults(scanPath, recursive, options, logger, scanRoot);
1011
+ // Build config-aware context scoped to the single derived config root —
1012
+ // used by compat analysis to resolve the plugin-level `targets` union.
1013
+ // VAT's design: one config per project, no composition.
1014
+ //
1015
+ // BEFORE the report is assembled, not after: this is the lane that expands
1016
+ // `skills.include` even when the scan reached no SKILL.md under the config,
1017
+ // and a refusal it meets is recorded in the same ledger the append below
1018
+ // reads. Built afterwards, that finding reached stderr and not the document.
1019
+ const vatContextForCompat = await buildVATProjectContext(deriveConfigRoot(scanPath), logger, scanRoot);
1020
+ // See {@link withDegradedConfigFindings} for why these are appended, not merged.
1021
+ const rawResults = withDegradedConfigFindings(scanned);
673
1022
  // Load config for severity filtering (audit ignores allow; only severity matters).
674
1023
  //
675
1024
  // Guarded, and the ordering is why: the scan above has ALREADY run and collected
@@ -699,10 +1048,6 @@ export async function buildAuditReport(scanPath, options, startTime, logger) {
699
1048
  // Allow is deliberately NOT applied — audit is advisory only.
700
1049
  const results = applySeverityFilter(rawResults, config);
701
1050
  const effectiveSettings = await resolveEffectiveSettings(options, scanPath, logger);
702
- // Build config-aware context scoped to the single derived config root —
703
- // used by compat analysis to resolve the plugin-level `targets` union.
704
- // VAT's design: one config per project, no composition.
705
- const vatContextForCompat = await buildVATProjectContext(deriveConfigRoot(scanPath), logger);
706
1051
  // Run compatibility analysis if --compat flag is set
707
1052
  const compatMap = options.compat
708
1053
  ? await runCompatAnalysis(results, logger, scanRoot, effectiveSettings, vatContextForCompat)
@@ -886,20 +1231,34 @@ async function appendPluginInventoryToSurfaceResults(surfaceResults, scanPath, l
886
1231
  }
887
1232
  /**
888
1233
  * Surface `inventory.parseErrors[]` as `PLUGIN_INVALID_JSON` findings on
889
- * `result`, skipping the plugin.json manifest entry (which is already covered
890
- * by `validatePlugin`). Only hooks/hooks.json and .mcp.json parse errors reach
891
- * this helper; they are appended with error severity.
1234
+ * `result`, skipping the entries another lane already files under their own
1235
+ * code:
1236
+ *
1237
+ * - the plugin.json manifest entry, covered by `validatePlugin`;
1238
+ * - every `SKILL.md` entry. The skill extractor records a SKILL.md it could
1239
+ * not open, could not parse, or could not link-walk on the plugin's
1240
+ * `parseErrors[]` — and each of those is the SKILL's finding, filed on the
1241
+ * skill by its own validation (`SCAN_PATH_UNREADABLE`,
1242
+ * `SKILL_MISSING_FRONTMATTER`) in both the plugin-target lane
1243
+ * (`validatePluginSkillsViaInventory`, over the same inventory) and the
1244
+ * directory lane (the walk reaches every SKILL.md beneath the plugin).
1245
+ * Re-filing it here was a second copy under a code that names a file it is
1246
+ * not (plugin.json) and a format it is not (JSON), at error severity beside
1247
+ * the skill lane's warning, and carrying the build host's absolute path.
1248
+ *
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.
892
1251
  */
893
1252
  function appendInventoryParseErrors(result, inv, locationRoot) {
894
1253
  const pluginJsonSuffix = safePath.join('.claude-plugin', 'plugin.json');
895
1254
  const parseIssues = [];
896
1255
  for (const err of inv.parseErrors) {
897
- if (err.path.endsWith(pluginJsonSuffix))
1256
+ if (err.path.endsWith(pluginJsonSuffix) || basename(err.path) === 'SKILL.md')
898
1257
  continue;
899
1258
  parseIssues.push({
900
1259
  severity: 'error',
901
1260
  code: 'PLUGIN_INVALID_JSON',
902
- message: err.message,
1261
+ message: withRelativePath(err.message, err.path, locationRoot),
903
1262
  location: issueLocation(err.path, locationRoot),
904
1263
  });
905
1264
  }
@@ -940,7 +1299,7 @@ function appendInventoryParseErrors(result, inv, locationRoot) {
940
1299
  */
941
1300
  export async function getValidationResults(scanPath, recursive, options, logger, locationRoot) {
942
1301
  try {
943
- return await validateAuditSubject(scanPath, recursive, options, logger, locationRoot);
1302
+ return dedupeUnreadablePathResults(await validateAuditSubject(scanPath, recursive, options, logger, locationRoot));
944
1303
  }
945
1304
  catch (error) {
946
1305
  // Only the filesystem refusing the subject degrades; a defect in a validator
@@ -951,6 +1310,83 @@ export async function getValidationResults(scanPath, recursive, options, logger,
951
1310
  return [unreadablePathResult(scanPath, error, locationRoot)];
952
1311
  }
953
1312
  }
1313
+ /**
1314
+ * One refused path, one `SCAN_PATH_UNREADABLE` — however many lanes met it.
1315
+ *
1316
+ * Two lanes can reach the same unreadable directory in one run: the directory
1317
+ * walk (`scanDirectory`, which files the synthetic {@link unreadablePathResult})
1318
+ * and the distributed-tree detector, which crawls a skill's own directory at any
1319
+ * depth and reports what it could not list as an issue ON the skill's result.
1320
+ * Their populations overlap, but neither contains the other: the walk skips
1321
+ * what `--exclude`, the project's `resources.exclude` and gitignore skip, and
1322
+ * does not descend at all under `--no-recursive` or when the SKILL.md is named
1323
+ * directly — the detector enters all of those. So "the walk already reports
1324
+ * everything under the scan root" is false in both directions, and a rule that
1325
+ * silenced one lane by geography would drop the refusal in exactly the cases
1326
+ * where it is the only report. The rule is instead keyed on the FACT: the
1327
+ * code and the location, which both lanes derive from the same directory and
1328
+ * the same anchor root.
1329
+ *
1330
+ * The hosted issue wins and the synthetic result is dropped, not the reverse:
1331
+ * the detector's copy says which SKILL's crawl was cut short, which the
1332
+ * synthetic result cannot, and it is the only copy the direct-target lane ever
1333
+ * produces — so keeping it makes the two lanes' reports the same shape. A
1334
+ * synthetic result that carried nothing but the duplicate is removed whole; one
1335
+ * that somehow carried more keeps its other issues (it cannot today, but the
1336
+ * filter says what it means rather than assuming).
1337
+ *
1338
+ * A THIRD lane files synthetic results too: skill discovery under a governing
1339
+ * config, which refuses a directory its `skills.include` cannot list and is
1340
+ * recorded through {@link recordRefusedDiscovery}. In the directory lane the
1341
+ * walk has already filed its own synthetic result on that same directory, so
1342
+ * two synthetic results can carry one fact. Among synthetics the FIRST wins and
1343
+ * later copies of the same location are dropped — the walk's copy carries the
1344
+ * raw errno, the discovery's copy the crawl's remedy sentence, and the stderr
1345
+ * warning carries the latter either way.
1346
+ */
1347
+ function dedupeUnreadablePathResults(results) {
1348
+ const reported = new Set();
1349
+ for (const result of results) {
1350
+ if (result.type === 'unknown')
1351
+ continue;
1352
+ for (const issue of result.issues) {
1353
+ if (issue.code === 'SCAN_PATH_UNREADABLE')
1354
+ reported.add(issue.location ?? '');
1355
+ }
1356
+ }
1357
+ return results.flatMap((result) => {
1358
+ if (result.type !== 'unknown')
1359
+ return [result];
1360
+ const kept = withoutHostedRefusals(result, reported);
1361
+ for (const survivor of kept) {
1362
+ for (const issue of survivor.issues) {
1363
+ if (issue.code === 'SCAN_PATH_UNREADABLE')
1364
+ reported.add(issue.location ?? '');
1365
+ }
1366
+ }
1367
+ return kept;
1368
+ });
1369
+ }
1370
+ /**
1371
+ * A synthetic result minus the refusals some hosted result already carries —
1372
+ * as an empty array when nothing is left, so {@link dedupeUnreadablePathResults}
1373
+ * can `flatMap` it away.
1374
+ */
1375
+ function withoutHostedRefusals(result, hosted) {
1376
+ const issues = result.issues.filter((issue) => !(issue.code === 'SCAN_PATH_UNREADABLE' && hosted.has(issue.location ?? '')));
1377
+ if (issues.length === 0)
1378
+ return [];
1379
+ if (issues.length === result.issues.length)
1380
+ return [result];
1381
+ const issueCounts = countBySeverity(issues);
1382
+ return [{
1383
+ ...result,
1384
+ status: calculateValidationStatus(issues),
1385
+ summary: formatCountsSummary(issueCounts),
1386
+ issues,
1387
+ issueCounts,
1388
+ }];
1389
+ }
954
1390
  async function validateAuditSubject(scanPath, recursive, options, logger, locationRoot) {
955
1391
  const format = detectFormat(scanPath);
956
1392
  // Special handling for direct SKILL.md file
@@ -1114,7 +1550,15 @@ function resolveConfigTargetsForPlugin(pluginDir, vatContext) {
1114
1550
  return union.size === 0 ? undefined : [...union];
1115
1551
  }
1116
1552
  /**
1117
- * Run compatibility analysis on plugin results and return a map of path -> CompatibilityResult.
1553
+ * `message`, with `absolutePath` spelled root-relative wherever it appears.
1554
+ * An OS refusal names the path it refused, absolutely; every other path in
1555
+ * the document is relative to `root`, and this one leaks the build host.
1556
+ */
1557
+ function withRelativePath(message, absolutePath, locationRoot) {
1558
+ return message.replaceAll(absolutePath, issueLocation(absolutePath, locationRoot) || '.');
1559
+ }
1560
+ /**
1561
+ * Run compatibility analysis on plugin results and return a map of path -> PluginCompatEntry.
1118
1562
  * Non-plugin results are skipped silently.
1119
1563
  * When effectiveSettings is provided, also runs settings conflict detection.
1120
1564
  *
@@ -1135,67 +1579,99 @@ export async function runCompatAnalysis(results, logger, locationRoot, effective
1135
1579
  for (const result of results) {
1136
1580
  if (result.type !== RESOURCE_TYPE_CLAUDE_PLUGIN)
1137
1581
  continue;
1138
- try {
1139
- logger.debug(`Running compatibility analysis for: ${result.path}`);
1140
- const configTargets = resolveConfigTargetsForPlugin(result.path, vatContext);
1141
- const analyzeOptions = configTargets === undefined
1142
- ? undefined
1143
- : { configTargets };
1144
- const compat = await analyzeCompatibility(result.path, locationRoot, analyzeOptions);
1145
- // Settings conflict detection (when --settings flag is used)
1146
- if (effectiveSettings === undefined) {
1147
- compatMap.set(result.path, compat);
1148
- }
1149
- else {
1150
- try {
1151
- const settingsConflicts = await checkSettingsCompatibility(result.path, effectiveSettings);
1152
- compatMap.set(result.path, { ...compat, settingsConflicts });
1153
- }
1154
- catch (settingsErr) {
1155
- logger.debug(`Settings compat check skipped for ${result.path}: ${String(settingsErr)}`);
1156
- compatMap.set(result.path, compat);
1157
- }
1158
- }
1159
- }
1160
- catch (err) {
1161
- // Log but do not fail the audit — compat analysis is best-effort
1162
- logger.debug(`Compatibility analysis skipped for ${result.path}: ${String(err)}`);
1582
+ const entry = {
1583
+ compat: await analyzeOrExplain(result.path, locationRoot, vatContext, logger),
1584
+ };
1585
+ if (effectiveSettings !== undefined) {
1586
+ entry.settings = await settingsVerdict(result.path, effectiveSettings, locationRoot, logger);
1163
1587
  }
1588
+ compatMap.set(result.path, entry);
1164
1589
  }
1165
1590
  return compatMap;
1166
1591
  }
1592
+ /**
1593
+ * The analyzer's result for one plugin, or — when it threw — the reason, said
1594
+ * on stderr and carried into the document. Compat analysis is best-effort:
1595
+ * it never fails the audit, but a plugin it could not analyze is reported as
1596
+ * exactly that, never as a plugin with no `compatibility:` block.
1597
+ */
1598
+ async function analyzeOrExplain(pluginDir, locationRoot, vatContext, logger) {
1599
+ try {
1600
+ logger.debug(`Running compatibility analysis for: ${pluginDir}`);
1601
+ const configTargets = resolveConfigTargetsForPlugin(pluginDir, vatContext);
1602
+ const analyzeOptions = configTargets === undefined ? undefined : { configTargets };
1603
+ return await analyzeCompatibility(pluginDir, locationRoot, analyzeOptions);
1604
+ }
1605
+ catch (err) {
1606
+ const refused = typeof err.path === 'string' ? err.path : undefined;
1607
+ const raw = err instanceof Error ? err.message : String(err);
1608
+ const reason = refused === undefined ? raw : withRelativePath(raw, refused, locationRoot);
1609
+ logger.warn(`Compatibility analysis could not run for ${issueLocation(pluginDir, locationRoot) || '.'}: ${reason}`);
1610
+ return { analyzed: false, reason };
1611
+ }
1612
+ }
1613
+ /**
1614
+ * One plugin's settings verdict: the checker's conflicts and the paths it
1615
+ * never saw — and, when the checker itself fails, the plugin as the one thing
1616
+ * unchecked, rather than no `settings:` block at all.
1617
+ *
1618
+ * The checker failing used to be `logger.debug` and a result with no settings
1619
+ * block, so a plugin the check could not run on was indistinguishable from one
1620
+ * the operator never asked about. A run that asked for `--settings` and got no
1621
+ * answer for a plugin is told so on stderr and in the document.
1622
+ */
1623
+ async function settingsVerdict(pluginDir, effectiveSettings, locationRoot, logger) {
1624
+ try {
1625
+ return await checkSettingsCompatibility(pluginDir, effectiveSettings);
1626
+ }
1627
+ catch (settingsErr) {
1628
+ const reason = settingsErr instanceof Error ? settingsErr.message : String(settingsErr);
1629
+ logger.warn(`Settings compatibility could not be checked for ${issueLocation(pluginDir, locationRoot) || '.'}: ${reason}`);
1630
+ return { conflicts: [], unchecked: [{ path: pluginDir, reason }] };
1631
+ }
1632
+ }
1633
+ /**
1634
+ * The `settings:` block for one plugin, every path anchored at `locationRoot`.
1635
+ * `compatible` is derived from BOTH halves: zero conflicts over a skill the
1636
+ * check never read is not compatibility, it is silence.
1637
+ */
1638
+ function renderSettingsBlock(check, locationRoot) {
1639
+ const settings = {
1640
+ compatible: check.conflicts.length === 0 && check.unchecked.length === 0,
1641
+ conflicts: check.conflicts,
1642
+ };
1643
+ if (check.unchecked.length > 0) {
1644
+ settings.unchecked = check.unchecked.map(({ path, reason }) => ({
1645
+ path: issueLocation(path, locationRoot) || '.',
1646
+ reason: withRelativePath(reason, path, locationRoot),
1647
+ }));
1648
+ }
1649
+ return settings;
1650
+ }
1167
1651
  /**
1168
1652
  * Merge compatibility analysis results into validation result output objects.
1169
1653
  * Returns an array of plain objects ready for YAML serialization.
1170
- * When settingsConflicts are present, adds a `settings:` block for cleaner output.
1654
+ * A plugin the run asked `--settings` about gets a `settings:` block, whatever
1655
+ * the analyzer did.
1171
1656
  */
1172
- function mergeCompatIntoResults(results, compatMap) {
1657
+ function mergeCompatIntoResults(results, compatMap, locationRoot) {
1173
1658
  return results.map(r => {
1174
- const compat = compatMap.get(r.path);
1175
- if (compat === undefined)
1659
+ const entry = compatMap.get(r.path);
1660
+ if (entry === undefined)
1176
1661
  return r;
1177
- // Extract settingsConflicts from compat to render as a separate `settings:` block
1178
- const { settingsConflicts, ...compatWithoutSettings } = compat;
1179
- if (settingsConflicts !== undefined) {
1180
- return {
1181
- ...r,
1182
- compatibility: compatWithoutSettings,
1183
- settings: {
1184
- compatible: settingsConflicts.length === 0,
1185
- conflicts: settingsConflicts,
1186
- },
1187
- };
1188
- }
1189
- return { ...r, compatibility: compat };
1662
+ const merged = { ...r, compatibility: entry.compat };
1663
+ if (entry.settings !== undefined)
1664
+ merged.settings = renderSettingsBlock(entry.settings, locationRoot);
1665
+ return merged;
1190
1666
  });
1191
1667
  }
1192
1668
  /**
1193
1669
  * Apply compatibility data to results if a compatMap is provided and non-empty.
1194
1670
  * Returns the original results array when no compat data is available.
1195
1671
  */
1196
- function applyCompatMap(results, compatMap) {
1672
+ function applyCompatMap(results, compatMap, locationRoot) {
1197
1673
  if (compatMap !== undefined && compatMap.size > 0) {
1198
- return mergeCompatIntoResults(results, compatMap);
1674
+ return mergeCompatIntoResults(results, compatMap, locationRoot);
1199
1675
  }
1200
1676
  return results;
1201
1677
  }
@@ -1232,7 +1708,7 @@ function applyVerboseFilter(results, verbose) {
1232
1708
  const NESTED_PATH_CARRIERS = ['linkedFiles'];
1233
1709
  function calculateSummary(results, startTime, compatMap, verbose, root) {
1234
1710
  const { entries, ...base } = buildBaseSummary(results, startTime);
1235
- const withCompat = applyCompatMap(entries, compatMap);
1711
+ const withCompat = applyCompatMap(entries, compatMap, root);
1236
1712
  return {
1237
1713
  // Stated once, first, and the only absolute path in the document.
1238
1714
  root,
@@ -1345,23 +1821,83 @@ function renderVerboseEvidence(results, root, logger) {
1345
1821
  }
1346
1822
  }
1347
1823
  }
1824
+ /**
1825
+ * The stderr half of a run-level refusal, so the human channel says what the
1826
+ * document says. The document is derived in `buildBaseSummary`; this only
1827
+ * RENDERS it. Both stderr lanes call it first, because "Audit successful: 0
1828
+ * 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.
1831
+ *
1832
+ * @returns Whether a refusal was rendered, so the caller skips its verdict line
1833
+ */
1834
+ function logRunIntegrity(runIssues, logger) {
1835
+ if (runIssues === undefined || runIssues.length === 0)
1836
+ return false;
1837
+ logger.error(`Audit is not a verdict: it audited 0 files ${ADVISORY_EXIT_NOTE}`);
1838
+ for (const issue of runIssues) {
1839
+ for (const line of formatIssueLines(issue, ' '))
1840
+ logger.error(line);
1841
+ }
1842
+ return true;
1843
+ }
1844
+ /**
1845
+ * The stderr line for the settings lane: how many conflicts it found and how
1846
+ * many paths it never compared, across every plugin in the document.
1847
+ *
1848
+ * 🚩 This read `f.compatibility?.settingsConflicts` — a field the renderer had
1849
+ * already moved out into the `settings:` block — so the count was 0 on every
1850
+ * run and the line never printed. It reads the rendered block now, the same
1851
+ * one the YAML carries, and it counts the unchecked half too: a run that
1852
+ * skipped a skill says so without `--debug`.
1853
+ */
1854
+ function logSettingsTotals(files, logger) {
1855
+ let conflicts = 0;
1856
+ let unchecked = 0;
1857
+ // The compat lane's own unchecked paths — a plugin the analyzer could only
1858
+ // partly read is in the document under `compatibility.unchecked`, and this is
1859
+ // the one line that keeps it from being silent on the human channel while the
1860
+ // settings lane says its half. `analyzed: false` has no list: it is the
1861
+ // plugin-wide failure, and the `reason` line already says so.
1862
+ let compatUnchecked = 0;
1863
+ for (const { compatibility, settings } of files) {
1864
+ conflicts += settings?.conflicts.length ?? 0;
1865
+ unchecked += settings?.unchecked?.length ?? 0;
1866
+ if (compatibility !== undefined && 'unchecked' in compatibility)
1867
+ compatUnchecked += compatibility.unchecked.length;
1868
+ }
1869
+ if (conflicts > 0) {
1870
+ logger.error(`⚠ ${conflicts} settings conflict(s) found — see 'settings' section in YAML output`);
1871
+ }
1872
+ if (unchecked > 0) {
1873
+ logger.error(`⚠ ${unchecked} path(s) the settings check could not compare — see 'settings.unchecked' in YAML output`);
1874
+ }
1875
+ if (compatUnchecked > 0) {
1876
+ logger.error(`⚠ ${compatUnchecked} path(s) the compatibility analysis could not read — see 'compatibility.unchecked' in YAML output`);
1877
+ }
1878
+ }
1348
1879
  function handleAuditResults(results, summary, logger, verbose) {
1349
1880
  const { filesWithErrors: errorCount, filesWithWarnings: warningCount, filesPassed: successCount, } = summary.summary;
1350
- // Report settings conflicts (advisory, non-blocking)
1351
- const totalSettingsConflicts = (summary.files ?? []).reduce((sum, f) => {
1352
- return sum + (f.compatibility?.settingsConflicts?.length ?? 0);
1353
- }, 0);
1354
- if (totalSettingsConflicts > 0) {
1355
- logger.error(`\u26a0 ${totalSettingsConflicts} settings conflict(s) found — see 'settings' section in YAML output`);
1356
- }
1881
+ logSettingsTotals(summary.files ?? [], logger);
1357
1882
  // 🔑 Audit is advisory only — always exit 0 for validation results, and the
1358
- // WORDING has to agree with that or the three signals contradict each other.
1359
- // This line used to read "Audit failed", beside `status: error` in the
1360
- // document, on exit 0: an adopter wiring `vat audit` into CI read a failure,
1361
- // saw a failure status, and got a green step. "Found" is what actually
1362
- // happened; the gate is `vat validate`, which is named here rather than
1363
- // implied.
1364
- if (errorCount > 0) {
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)) {
1898
+ // Rendered above; there is no file verdict to print over zero files.
1899
+ }
1900
+ else if (errorCount > 0) {
1365
1901
  logger.error(`Audit found ${errorCount} file(s) with errors ${ADVISORY_EXIT_NOTE}`);
1366
1902
  logFindingsForStatus(results, 'error', summary.root, logger.error.bind(logger), verbose);
1367
1903
  }
@@ -1547,18 +2083,34 @@ async function handleFileEntry(entry, fullPath, options, logger, scanCtx) {
1547
2083
  // Config-aware: walk UP to the skill's nearest-ancestor config and apply
1548
2084
  // ONLY that skill's declared packaging rules. Configs do not compose
1549
2085
  // across VAT projects — audit does not merge rules across sibling configs.
1550
- const fullConfig = await resolveSkillPackagingConfig(fullPath);
1551
- const skillConfig = fullConfig === null ? null : stripValidationAllowForDisplay(fullConfig);
2086
+ // Shared with the single-target lane, so one unloadable nested config
2087
+ // cannot abort this whole scan while that lane shrugs it off. The warn-once
2088
+ // ledger lives in `unloadableConfigResults` (module scope, cleared by
2089
+ // `resetAuditCaches`) rather than being threaded here, so BOTH lanes dedupe
2090
+ // against the same state — this one used to, the other minted a fresh set
2091
+ // per skill.
2092
+ const skillConfig = await resolveGoverningConfig(fullPath, logger, locationRoot);
1552
2093
  if (skillConfig !== null) {
1553
2094
  logger.debug(` Using config-aware validation for: ${fullPath}`);
1554
2095
  // Thread the per-scan tracker into packaging validation so gitignore
1555
2096
  // checks in the link-graph walk stay O(1).
1556
2097
  // `projectSkills`: same project-wide test-input rule as the lane above,
1557
2098
  // resolved through the same per-config-root memo.
1558
- const projectSkills = await resolveProjectDeclaredEvalSuites(fullPath);
2099
+ const projectSkills = await resolveProjectDeclaredEvalSuites(fullPath, degradingDiscovery(findProjectRoot(safePath.resolve(fullPath, '..')) ?? locationRoot, logger, locationRoot));
2100
+ // `suiteProbe`: the RUN's, not this entry's — the directory walk reaches
2101
+ // this function once per SKILL.md it finds. See {@link runSuiteProbe}.
2102
+ const suiteProbe = suiteProbeForRun();
2103
+ // No registry is supplied on this lane, so `validateSkillForPackaging`
2104
+ // crawls one per project root through `crawlAndResolveRegistry` — under
2105
+ // audit's ruling, which is the ONLY reason one `chmod 000` sibling no
2106
+ // longer exits this whole run at 2 with zero findings. The root is the
2107
+ // one the validator itself derives (`findProjectRoot(dirname) ?? dirname`),
2108
+ // so the refuse sentence and the finding name the same crawl.
2109
+ const skillDir = safePath.resolve(fullPath, '..');
2110
+ const unreadable = degradingRegistry(findProjectRoot(skillDir) ?? skillDir, logger, locationRoot);
1559
2111
  const sharedCtx = scanCtx.gitTracker === null
1560
- ? { locationRoot, projectSkills }
1561
- : { gitTracker: scanCtx.gitTracker, locationRoot, projectSkills };
2112
+ ? { locationRoot, projectSkills, suiteProbe, unreadable }
2113
+ : { gitTracker: scanCtx.gitTracker, locationRoot, projectSkills, suiteProbe, unreadable };
1562
2114
  const packagingResult = await validateSkillForPackaging(fullPath, skillConfig, 'source', sharedCtx);
1563
2115
  const configAware = packagingResultToValidationResult(fullPath, packagingResult, skillConfig.targets, locationRoot);
1564
2116
  await appendDistributedTreeFindings(configAware, fullPath, locationRoot, ownsOwnCrawl(scanCtx, fullPath));
@@ -1637,7 +2189,17 @@ function resolveProjectExcludes(scanDir, logger) {
1637
2189
  catch (err) {
1638
2190
  // Audit is a bulk linter over trees it does not own; a broken governing
1639
2191
  // config must not abort the scan. Say so rather than dropping it silently.
1640
- logger.debug(`Ignoring unreadable config at ${projectRoot}: ${String(err)}`);
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)}`);
1641
2203
  return null;
1642
2204
  }
1643
2205
  if (patterns.length === 0)
@@ -1721,6 +2283,8 @@ export function resetAuditCaches() {
1721
2283
  resetGitTrackerCache();
1722
2284
  resetPackagingRegistryCache();
1723
2285
  inventoryRegistryCache.clear();
2286
+ unloadableConfigResults.clear();
2287
+ runSuiteProbe = undefined;
1724
2288
  resetProjectRootCaches();
1725
2289
  resetLoadedConfigCache();
1726
2290
  resetSkillDiscoveryCache();
@@ -1820,10 +2384,14 @@ function unreadablePathResult(dirPath, error, locationRoot) {
1820
2384
  // identical reason, already exists on the distributed-tree finding — see
1821
2385
  // `anchoredTreeLocation` in ./audit/distributed-tree.ts.
1822
2386
  const location = issueLocation(dirPath, locationRoot) || '.';
2387
+ // The OS names the path it refused absolutely; the report speaks scan-root
2388
+ // coordinates everywhere else, and an absolute path here is the build host's
2389
+ // `$HOME` in every CI log.
2390
+ const reason = withRelativePath(error instanceof Error ? error.message : String(error), dirPath, locationRoot);
1823
2391
  const issues = [
1824
2392
  materializeIssue('SCAN_PATH_UNREADABLE', {
1825
2393
  location,
1826
- detail: `${location}: ${error instanceof Error ? error.message : String(error)}`,
2394
+ detail: `${location}: ${reason}`,
1827
2395
  }),
1828
2396
  ];
1829
2397
  const issueCounts = countBySeverity(issues);
@@ -2012,6 +2580,24 @@ function countFilesByStatus(results) {
2012
2580
  * also present in its owner's `issues`, so the nested list is a VIEW of records
2013
2581
  * already counted, not extra records. `audit-report-coherence.integration.test.ts`
2014
2582
  * pins that subset relation, because counting once is only correct while it holds.
2583
+ *
2584
+ * 🚨 **Zero files audited is an ERROR, not "an empty tree audits cleanly".**
2585
+ * `calculateOverallStatus([])` is `success`, so an existing directory holding
2586
+ * nothing auditable — moved plugins, a wrong subdirectory, an excluded tree, a
2587
+ * tree of files no lane recognises — published `status: success` beside
2588
+ * `filesScanned: 0`, and this command's own documentation tells CI to gate on
2589
+ * that `status`. Derived HERE, in the one builder both the directory lane
2590
+ * (`calculateSummary`) and the `--user` lane (`calculateHierarchicalSummary`)
2591
+ * pass through, so neither can publish a clean status over a zero denominator.
2592
+ * Through the shared mechanism in `run-integrity.ts`: one non-overridable
2593
+ * `RESOURCE_CHECK_BROKEN` at `error`, published under a top-level `issues` —
2594
+ * the claim is about the RUN, so it is not a `files[]` row and does not count
2595
+ * toward `filesScanned` — and counted in the header `issueCounts`, so the
2596
+ * identity becomes `issueCounts === Σ files[].issueCounts + Σ issues`.
2597
+ *
2598
+ * The exit code is NOT touched by this: `status` describes the findings and the
2599
+ * exit code describes whether the run completed, and this run completed. See
2600
+ * {@link createAuditCommand}.
2015
2601
  */
2016
2602
  function buildBaseSummary(results, startTime) {
2017
2603
  const issueCounts = { errors: 0, warnings: 0, info: 0 };
@@ -2022,11 +2608,21 @@ function buildBaseSummary(results, startTime) {
2022
2608
  issueCounts.info += counts.info;
2023
2609
  return { ...result, issueCounts: counts };
2024
2610
  });
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'
2612
+ + ' to audit produces the same counts as a clean one. The path resolved to a tree'
2613
+ + ' holding no auditable file — usually a wrong subdirectory, plugins or skills that'
2614
+ + ' have moved, an excluded tree, files no audit lane recognises, or `--no-recursive`'
2615
+ + ' over a tree whose resources sit deeper. Point the command at the plugin,'
2616
+ + ' marketplace, registry or SKILL.md tree it should read.');
2025
2617
  return {
2026
2618
  entries,
2027
- status: calculateOverallStatus(entries),
2619
+ // Every run-integrity finding is `error` by construction (invariant 2 of
2620
+ // `run-integrity.ts`), so a non-empty refusal IS the status; otherwise the
2621
+ // files decide it, as before.
2622
+ status: runIssues.length > 0 ? 'error' : calculateOverallStatus(entries),
2028
2623
  summary: countFilesByStatus(entries),
2029
- issueCounts,
2624
+ issueCounts: sumSeverityCounts([issueCounts, countBySeverity(runIssues)]),
2625
+ ...(runIssues.length === 0 ? {} : { issues: [...runIssues] }),
2030
2626
  duration: `${Date.now() - startTime}ms`,
2031
2627
  };
2032
2628
  }
@@ -2035,7 +2631,7 @@ function buildBaseSummary(results, startTime) {
2035
2631
  */
2036
2632
  function calculateHierarchicalSummary(results, hierarchical, startTime, compatMap, verbose, root) {
2037
2633
  const { entries, ...base } = buildBaseSummary(results, startTime);
2038
- const withCompat = applyCompatMap(entries, compatMap);
2634
+ const withCompat = applyCompatMap(entries, compatMap, root);
2039
2635
  return {
2040
2636
  root,
2041
2637
  ...base,
@@ -2053,13 +2649,16 @@ function calculateHierarchicalSummary(results, hierarchical, startTime, compatMa
2053
2649
  /**
2054
2650
  * Log hierarchical summary to stderr
2055
2651
  */
2056
- function logHierarchicalSummary(results, logger) {
2652
+ function logHierarchicalSummary(results, runIssues, logger) {
2057
2653
  const status = calculateOverallStatus(results);
2058
2654
  const skillsWithIssues = countResultsWithFindings(results);
2059
2655
  const totalSkills = results.length;
2060
2656
  // Audit is advisory only — always exit 0 for validation results.
2061
2657
  // Use vat skills validate for gated validation (exit 1 on errors).
2062
- if (status === 'error') {
2658
+ if (logRunIntegrity(runIssues, logger)) {
2659
+ // Rendered above; there is no skill verdict to print over zero skills.
2660
+ }
2661
+ else if (status === 'error') {
2063
2662
  const errorCount = results.filter((r) => r.status === 'error').length;
2064
2663
  logger.error(`Audit found ${errorCount} skill(s) with errors (${totalSkills} scanned, ${skillsWithIssues} with issues) ${ADVISORY_EXIT_NOTE}`);
2065
2664
  }