@vibe-agent-toolkit/cli 0.2.0-rc.6 → 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 (194) 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 +605 -140
  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/context.js +1 -1
  29. package/dist/commands/claude/context.js.map +1 -1
  30. package/dist/commands/claude/marketplace/validate.d.ts +55 -22
  31. package/dist/commands/claude/marketplace/validate.d.ts.map +1 -1
  32. package/dist/commands/claude/marketplace/validate.js +380 -75
  33. package/dist/commands/claude/marketplace/validate.js.map +1 -1
  34. package/dist/commands/claude/org/skills.d.ts +84 -3
  35. package/dist/commands/claude/org/skills.d.ts.map +1 -1
  36. package/dist/commands/claude/org/skills.js +218 -46
  37. package/dist/commands/claude/org/skills.js.map +1 -1
  38. package/dist/commands/claude/plugin/build.d.ts +8 -0
  39. package/dist/commands/claude/plugin/build.d.ts.map +1 -1
  40. package/dist/commands/claude/plugin/build.js +16 -2
  41. package/dist/commands/claude/plugin/build.js.map +1 -1
  42. package/dist/commands/claude/plugin/tree-copy.d.ts +72 -0
  43. package/dist/commands/claude/plugin/tree-copy.d.ts.map +1 -1
  44. package/dist/commands/claude/plugin/tree-copy.js +215 -10
  45. package/dist/commands/claude/plugin/tree-copy.js.map +1 -1
  46. package/dist/commands/corpus/index.d.ts.map +1 -1
  47. package/dist/commands/corpus/index.js +3 -0
  48. package/dist/commands/corpus/index.js.map +1 -1
  49. package/dist/commands/corpus/report.d.ts +0 -1
  50. package/dist/commands/corpus/report.d.ts.map +1 -1
  51. package/dist/commands/corpus/report.js +0 -1
  52. package/dist/commands/corpus/report.js.map +1 -1
  53. package/dist/commands/corpus/runner.d.ts +58 -1
  54. package/dist/commands/corpus/runner.d.ts.map +1 -1
  55. package/dist/commands/corpus/runner.js +90 -15
  56. package/dist/commands/corpus/runner.js.map +1 -1
  57. package/dist/commands/corpus/scan.d.ts.map +1 -1
  58. package/dist/commands/corpus/scan.js +0 -1
  59. package/dist/commands/corpus/scan.js.map +1 -1
  60. package/dist/commands/doctor.d.ts +14 -0
  61. package/dist/commands/doctor.d.ts.map +1 -1
  62. package/dist/commands/doctor.js +96 -18
  63. package/dist/commands/doctor.js.map +1 -1
  64. package/dist/commands/okf/index.d.ts +11 -0
  65. package/dist/commands/okf/index.d.ts.map +1 -0
  66. package/dist/commands/okf/index.js +102 -0
  67. package/dist/commands/okf/index.js.map +1 -0
  68. package/dist/commands/okf/validate.d.ts +82 -0
  69. package/dist/commands/okf/validate.d.ts.map +1 -0
  70. package/dist/commands/okf/validate.js +157 -0
  71. package/dist/commands/okf/validate.js.map +1 -0
  72. package/dist/commands/phase-utils.d.ts +19 -3
  73. package/dist/commands/phase-utils.d.ts.map +1 -1
  74. package/dist/commands/phase-utils.js +26 -5
  75. package/dist/commands/phase-utils.js.map +1 -1
  76. package/dist/commands/rag/index-command.d.ts +27 -2
  77. package/dist/commands/rag/index-command.d.ts.map +1 -1
  78. package/dist/commands/rag/index-command.js +44 -7
  79. package/dist/commands/rag/index-command.js.map +1 -1
  80. package/dist/commands/rag/index.d.ts.map +1 -1
  81. package/dist/commands/rag/index.js +2 -0
  82. package/dist/commands/rag/index.js.map +1 -1
  83. package/dist/commands/resources/check-progress.d.ts +3 -0
  84. package/dist/commands/resources/check-progress.d.ts.map +1 -1
  85. package/dist/commands/resources/check-progress.js +6 -0
  86. package/dist/commands/resources/check-progress.js.map +1 -1
  87. package/dist/commands/resources/check.d.ts +33 -4
  88. package/dist/commands/resources/check.d.ts.map +1 -1
  89. package/dist/commands/resources/check.js +131 -44
  90. package/dist/commands/resources/check.js.map +1 -1
  91. package/dist/commands/resources/index.d.ts.map +1 -1
  92. package/dist/commands/resources/index.js +41 -9
  93. package/dist/commands/resources/index.js.map +1 -1
  94. package/dist/commands/resources/query.d.ts +18 -1
  95. package/dist/commands/resources/query.d.ts.map +1 -1
  96. package/dist/commands/resources/query.js +29 -4
  97. package/dist/commands/resources/query.js.map +1 -1
  98. package/dist/commands/resources/validate.d.ts +29 -0
  99. package/dist/commands/resources/validate.d.ts.map +1 -1
  100. package/dist/commands/resources/validate.js +116 -13
  101. package/dist/commands/resources/validate.js.map +1 -1
  102. package/dist/commands/skill/review.d.ts.map +1 -1
  103. package/dist/commands/skill/review.js +9 -2
  104. package/dist/commands/skill/review.js.map +1 -1
  105. package/dist/commands/skill/test/run.d.ts.map +1 -1
  106. package/dist/commands/skill/test/run.js +21 -6
  107. package/dist/commands/skill/test/run.js.map +1 -1
  108. package/dist/commands/skills/build.d.ts.map +1 -1
  109. package/dist/commands/skills/build.js +16 -3
  110. package/dist/commands/skills/build.js.map +1 -1
  111. package/dist/commands/skills/list.d.ts +2 -1
  112. package/dist/commands/skills/list.d.ts.map +1 -1
  113. package/dist/commands/skills/list.js +31 -9
  114. package/dist/commands/skills/list.js.map +1 -1
  115. package/dist/commands/skills/skill-discovery.d.ts +40 -1
  116. package/dist/commands/skills/skill-discovery.d.ts.map +1 -1
  117. package/dist/commands/skills/skill-discovery.js +32 -3
  118. package/dist/commands/skills/skill-discovery.js.map +1 -1
  119. package/dist/commands/skills/validate-command.d.ts.map +1 -1
  120. package/dist/commands/skills/validate-command.js +11 -3
  121. package/dist/commands/skills/validate-command.js.map +1 -1
  122. package/dist/commands/skills/validate.d.ts +66 -7
  123. package/dist/commands/skills/validate.d.ts.map +1 -1
  124. package/dist/commands/skills/validate.js +133 -29
  125. package/dist/commands/skills/validate.js.map +1 -1
  126. package/dist/commands/verify.d.ts +94 -3
  127. package/dist/commands/verify.d.ts.map +1 -1
  128. package/dist/commands/verify.js +192 -45
  129. package/dist/commands/verify.js.map +1 -1
  130. package/dist/skill-resolution/packaging-config.d.ts +49 -5
  131. package/dist/skill-resolution/packaging-config.d.ts.map +1 -1
  132. package/dist/skill-resolution/packaging-config.js +59 -19
  133. package/dist/skill-resolution/packaging-config.js.map +1 -1
  134. package/dist/skill-resolution/resolve-skill-reference.d.ts.map +1 -1
  135. package/dist/skill-resolution/resolve-skill-reference.js +6 -3
  136. package/dist/skill-resolution/resolve-skill-reference.js.map +1 -1
  137. package/dist/utils/command-error.d.ts +31 -0
  138. package/dist/utils/command-error.d.ts.map +1 -1
  139. package/dist/utils/command-error.js +45 -0
  140. package/dist/utils/command-error.js.map +1 -1
  141. package/dist/utils/edge-lens-evaluation.d.ts +83 -0
  142. package/dist/utils/edge-lens-evaluation.d.ts.map +1 -0
  143. package/dist/utils/edge-lens-evaluation.js +110 -0
  144. package/dist/utils/edge-lens-evaluation.js.map +1 -0
  145. package/dist/utils/optional-backend.d.ts +5 -4
  146. package/dist/utils/optional-backend.d.ts.map +1 -1
  147. package/dist/utils/optional-backend.js +5 -4
  148. package/dist/utils/optional-backend.js.map +1 -1
  149. package/dist/utils/project-root-policy.d.ts +20 -0
  150. package/dist/utils/project-root-policy.d.ts.map +1 -1
  151. package/dist/utils/project-root-policy.js +32 -0
  152. package/dist/utils/project-root-policy.js.map +1 -1
  153. package/dist/utils/projection-query.d.ts +41 -4
  154. package/dist/utils/projection-query.d.ts.map +1 -1
  155. package/dist/utils/projection-query.js +44 -10
  156. package/dist/utils/projection-query.js.map +1 -1
  157. package/dist/utils/projection-store.d.ts +41 -15
  158. package/dist/utils/projection-store.d.ts.map +1 -1
  159. package/dist/utils/projection-store.js +65 -18
  160. package/dist/utils/projection-store.js.map +1 -1
  161. package/dist/utils/repeatable-option.d.ts +37 -0
  162. package/dist/utils/repeatable-option.d.ts.map +1 -0
  163. package/dist/utils/repeatable-option.js +39 -0
  164. package/dist/utils/repeatable-option.js.map +1 -0
  165. package/dist/utils/resource-loader.d.ts +4 -3
  166. package/dist/utils/resource-loader.d.ts.map +1 -1
  167. package/dist/utils/resource-loader.js +61 -30
  168. package/dist/utils/resource-loader.js.map +1 -1
  169. package/dist/utils/root-argv.d.ts +53 -0
  170. package/dist/utils/root-argv.d.ts.map +1 -0
  171. package/dist/utils/root-argv.js +215 -0
  172. package/dist/utils/root-argv.js.map +1 -0
  173. package/dist/utils/run-integrity.d.ts +95 -0
  174. package/dist/utils/run-integrity.d.ts.map +1 -0
  175. package/dist/utils/run-integrity.js +102 -0
  176. package/dist/utils/run-integrity.js.map +1 -0
  177. package/dist/utils/sqlite-experimental-warning.d.ts +69 -0
  178. package/dist/utils/sqlite-experimental-warning.d.ts.map +1 -0
  179. package/dist/utils/sqlite-experimental-warning.js +144 -0
  180. package/dist/utils/sqlite-experimental-warning.js.map +1 -0
  181. package/dist/utils/user-context-scanner.d.ts +8 -0
  182. package/dist/utils/user-context-scanner.d.ts.map +1 -1
  183. package/dist/utils/user-context-scanner.js +4 -0
  184. package/dist/utils/user-context-scanner.js.map +1 -1
  185. package/dist/utils/validate-help-files.d.ts.map +1 -1
  186. package/dist/utils/validate-help-files.js +9 -2
  187. package/dist/utils/validate-help-files.js.map +1 -1
  188. package/docs/audit.md +120 -7
  189. package/docs/doctor.md +6 -6
  190. package/docs/index.md +1 -1
  191. package/docs/rag.md +3 -1
  192. package/docs/resources.md +5 -1
  193. package/docs/skills.md +47 -62
  194. 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;
@@ -207,22 +219,38 @@ function packagingResultToValidationResult(skillPath, result, configTargets, loc
207
219
  * no governing config OR when the one found could not be loaded.
208
220
  */
209
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;
210
228
  let fullConfig;
211
229
  try {
212
- fullConfig = await resolveSkillPackagingConfig(skillPath);
230
+ fullConfig = await resolveSkillPackagingConfig(skillPath, degradingDiscovery(configRoot, logger, locationRoot));
213
231
  }
214
232
  catch (err) {
215
- if (!(err instanceof ConfigLoadError))
216
- throw err;
217
- recordUnloadableConfig(err, logger, locationRoot);
218
- return 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;
219
246
  }
220
247
  return fullConfig === null ? null : stripValidationAllowForDisplay(fullConfig);
221
248
  }
222
249
  /**
223
- * Configs this run has already reported as unloadable, keyed by the config's own
224
- * project root, each holding the synthetic result that carries its finding into
225
- * the report.
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.
226
254
  *
227
255
  * 🚨 MODULE-scoped, and cleared by {@link resetAuditCaches}, exactly like
228
256
  * `config-loader.ts`'s `warnedConfigPaths`. It used to be a `Set` threaded as a
@@ -235,15 +263,45 @@ async function resolveGoverningConfig(skillPath, logger, locationRoot) {
235
263
  * while the lane that actually repeated was never exercised. A run-scoped ledger
236
264
  * cannot be re-created per call by mistake; a parameter can.
237
265
  *
238
- * ⚠️ Keyed on `ConfigLoadError.projectRoot`, NOT on `err.message`. The old key
239
- * was the message with a comment claiming it "names the offending config path" —
240
- * true only for a Zod failure, which is what the fixture happened to be. A YAML
241
- * SYNTAX error is thrown out of `yaml.parse` and wrapped as `Failed to load
242
- * config: ${message}`, and `YAMLParseError.message` carries no filename at all,
243
- * so two nested projects with the same copy-pasted broken config collapsed to ONE
244
- * warning naming NEITHER.
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.
245
274
  */
246
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
+ }
247
305
  /**
248
306
  * Warn about — and file a finding for — a governing config this run could not
249
307
  * load, once per config however many skills it governs.
@@ -253,10 +311,10 @@ const unloadableConfigResults = new Map();
253
311
  * @param locationRoot - The run's anchor base for the emitted location
254
312
  */
255
313
  function recordUnloadableConfig(err, logger, locationRoot) {
256
- if (unloadableConfigResults.has(err.projectRoot))
257
- return;
258
314
  const configPath = safePath.join(err.projectRoot, VAT_CONFIG_FILENAME);
259
- unloadableConfigResults.set(err.projectRoot, unreadablePathResult(configPath, err, locationRoot));
315
+ if (unloadableConfigResults.has(configPath))
316
+ return;
317
+ unloadableConfigResults.set(configPath, unreadablePathResult(configPath, err, locationRoot));
260
318
  // The filesystem answer changes the NOUN, because the two conditions need
261
319
  // different actions from the operator: `chmod`/ownership on one side, an edit
262
320
  // to the file on the other. Collapsing them into one sentence was the whole
@@ -265,6 +323,102 @@ function recordUnloadableConfig(err, logger, locationRoot) {
265
323
  logger.warn(`Ignoring ${why} config at ${configPath}; skills it governs are validated`
266
324
  + ` config-free: ${err.message}`);
267
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
+ }
268
422
  /**
269
423
  * The synthetic results for every config this run degraded past, so the report
270
424
  * carries what the stderr warning says.
@@ -275,6 +429,23 @@ function recordUnloadableConfig(err, logger, locationRoot) {
275
429
  function unloadableConfigFindings() {
276
430
  return [...unloadableConfigResults.values()];
277
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
+ }
278
449
  async function validateSingleSkill(skillPath, options, logger, locationRoot, crawlTree, isVATGenerated) {
279
450
  // Config-aware validation: walk UP to the skill's nearest-ancestor
280
451
  // vibe-agent-toolkit.config.yaml and apply the skill's packaging block. A
@@ -295,10 +466,18 @@ async function validateSingleSkill(skillPath, options, logger, locationRoot, cra
295
466
  // The project's declared eval suites, memoized per config root, so this lane
296
467
  // predicts the same bundle the packager produces: no skill's report counts a
297
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);
298
473
  const sharedCtx = {
299
- registry: await crawlAndResolveRegistry(projectRoot),
474
+ registry: await crawlAndResolveRegistry(projectRoot, { unreadable }),
475
+ unreadable,
300
476
  locationRoot,
301
- 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(),
302
481
  };
303
482
  if (gitTracker !== null) {
304
483
  sharedCtx.gitTracker = gitTracker;
@@ -355,6 +534,20 @@ function collect(value, previous) {
355
534
  /**
356
535
  * Create audit command
357
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.
358
551
  */
359
552
  export function createAuditCommand() {
360
553
  const audit = new Command('audit');
@@ -421,11 +614,26 @@ Description:
421
614
  done
422
615
  (See packages/cli/docs/audit.md for details.)
423
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
+
424
631
  Validation Behavior:
425
632
  Advisory only: audit surfaces all validation issues for inspection.
426
633
  Unlike 'vat skills validate', audit:
427
634
  - NEVER applies validation.allow (allowed codes are always shown)
428
- - 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
429
637
  - ALWAYS exits 0 for validation results (never gates on errors)
430
638
 
431
639
  For gated validation (CI/CD), use: vat skills validate
@@ -470,14 +678,24 @@ Config-Aware Validation:
470
678
  which prevents false warnings for links the build pipeline resolves.
471
679
 
472
680
  Config-aware mode never applies validation.allow — audit always shows
473
- all issues. validation.severity IS applied: a code set to 'ignore' is
474
- hidden. skills.defaults.validation.severity applies project-wide (skills,
475
- plugins and marketplaces alike); skills.config.<name>.validation.severity
476
- layers on top for that skill.
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
477
688
 
478
689
  Exit Codes:
479
- 0 - Always (even when validation errors are surfaced)
480
- 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.
481
699
 
482
700
  Examples:
483
701
  $ vat audit ./plugins/ # Audit recursively (default)
@@ -545,7 +763,7 @@ async function auditUserDirectories(recursive, options, startTime, logger) {
545
763
  logger.error('Claude plugins/skills/marketplaces have not been installed yet.');
546
764
  process.exit(2);
547
765
  }
548
- const results = [];
766
+ const scanned = [];
549
767
  // The roots that exist, minus any one another already covers — see
550
768
  // {@link userScanTargets}. `marketplaces/` lives inside `plugins/`.
551
769
  const present = [
@@ -555,12 +773,12 @@ async function auditUserDirectories(recursive, options, startTime, logger) {
555
773
  ];
556
774
  for (const target of userScanTargets(present, recursive)) {
557
775
  logger.debug(`Auditing user-level resources at: ${target}`);
558
- results.push(...await getValidationResults(target, recursive, options, logger, scanRoot));
776
+ scanned.push(...await getValidationResults(target, recursive, options, logger, scanRoot));
559
777
  }
560
778
  // The `--user` report is assembled here rather than by `buildAuditReport`, so
561
779
  // it needs its own append or the config findings would reach stderr and not the
562
780
  // document — see the same call in `buildAuditReport`.
563
- results.push(...unloadableConfigFindings());
781
+ const results = withDegradedConfigFindings(scanned);
564
782
  // Run compatibility analysis if --compat flag is set
565
783
  const compatMap = options.compat
566
784
  ? await runCompatAnalysis(results, logger, scanRoot) // --settings not supported in --user mode
@@ -573,7 +791,7 @@ async function auditUserDirectories(recursive, options, startTime, logger) {
573
791
  if (verbose) {
574
792
  renderVerboseEvidence(results, scanRoot, logger);
575
793
  }
576
- logHierarchicalSummary(results, logger);
794
+ logHierarchicalSummary(results, summary.issues, logger);
577
795
  }
578
796
  /** Skill resource types that can have per-skill validation config. */
579
797
  const SKILL_RESULT_TYPES = new Set([RESOURCE_TYPE_AGENT_SKILL, 'vat-agent']);
@@ -657,13 +875,21 @@ function buildFilteredResult(result, filteredIssues) {
657
875
  * same config promoted correctly. Two lanes, one config key, opposite answers.
658
876
  *
659
877
  * Status and counts are re-derived from the resolved severities by
660
- * {@link buildFilteredResult}, so a promotion moves the reported status. It does
661
- * NOT move this command's exit code — `vat audit` is advisory and its exit-code
662
- * behaviour is a separate, pre-existing question tracked in its own issue. That
663
- * asymmetry with `vat verify` (whose exit code IS severity-derived) is
664
- * deliberate here rather than overlooked.
665
- *
666
- * Two scopes, both live:
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.
667
893
  * - `skills.defaults.validation.severity` — the PROJECT-WIDE map, applied to
668
894
  * every result this run produced, including `claude-plugin` and `marketplace`
669
895
  * ones. It used to be applied only to skill-typed results, which left the
@@ -698,11 +924,13 @@ function buildFilteredResult(result, filteredIssues) {
698
924
  * @returns New results array with ignored codes removed from issues
699
925
  */
700
926
  function applySeverityFilter(results, config) {
701
- if (config?.skills === undefined) {
702
- return results;
703
- }
704
- const skillsConfig = config.skills;
705
- const defaultSeverity = skillsConfig.defaults?.validation?.severity ?? {};
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 };
706
934
  // `.filter` after `.map`, not `.map` alone: a synthetic unreadable-path result
707
935
  // exists ONLY to carry its finding, so once the adopter has set
708
936
  // `severity.SCAN_PATH_UNREADABLE: ignore` the result has nothing left to say.
@@ -714,9 +942,9 @@ function applySeverityFilter(results, config) {
714
942
  const skillName = SKILL_RESULT_TYPES.has(result.type) ? result.metadata?.name : undefined;
715
943
  const perSkillSeverity = skillName === undefined
716
944
  ? {}
717
- : (skillsConfig.config?.[skillName]?.validation?.severity ?? {});
718
- // Merge: per-skill overrides default
719
- 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 };
720
948
  if (Object.keys(effectiveSeverity).length === 0) {
721
949
  return result;
722
950
  }
@@ -779,15 +1007,18 @@ export async function buildAuditReport(scanPath, options, startTime, logger) {
779
1007
  const recursive = options.recursive !== false;
780
1008
  logger.debug(`Auditing resources at: ${scanPath}`);
781
1009
  const scanRoot = deriveScanRoot(scanPath);
782
- // Appended, not merged into a neighbour: a config the scan could not load has
783
- // no host result to hang off, and attributing it to a skill would name a file
784
- // that was read fine. Without this the ONLY record was a stderr warning, so a
785
- // run that downgraded every skill under that config to the weaker config-free
786
- // validator exited 0 with a report that said nothing about it.
787
- const rawResults = [
788
- ...await getValidationResults(scanPath, recursive, options, logger, scanRoot),
789
- ...unloadableConfigFindings(),
790
- ];
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);
791
1022
  // Load config for severity filtering (audit ignores allow; only severity matters).
792
1023
  //
793
1024
  // Guarded, and the ordering is why: the scan above has ALREADY run and collected
@@ -817,10 +1048,6 @@ export async function buildAuditReport(scanPath, options, startTime, logger) {
817
1048
  // Allow is deliberately NOT applied — audit is advisory only.
818
1049
  const results = applySeverityFilter(rawResults, config);
819
1050
  const effectiveSettings = await resolveEffectiveSettings(options, scanPath, logger);
820
- // Build config-aware context scoped to the single derived config root —
821
- // used by compat analysis to resolve the plugin-level `targets` union.
822
- // VAT's design: one config per project, no composition.
823
- const vatContextForCompat = await buildVATProjectContext(deriveConfigRoot(scanPath), logger);
824
1051
  // Run compatibility analysis if --compat flag is set
825
1052
  const compatMap = options.compat
826
1053
  ? await runCompatAnalysis(results, logger, scanRoot, effectiveSettings, vatContextForCompat)
@@ -1004,20 +1231,34 @@ async function appendPluginInventoryToSurfaceResults(surfaceResults, scanPath, l
1004
1231
  }
1005
1232
  /**
1006
1233
  * Surface `inventory.parseErrors[]` as `PLUGIN_INVALID_JSON` findings on
1007
- * `result`, skipping the plugin.json manifest entry (which is already covered
1008
- * by `validatePlugin`). Only hooks/hooks.json and .mcp.json parse errors reach
1009
- * this helper; they are appended with error severity.
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.
1010
1251
  */
1011
1252
  function appendInventoryParseErrors(result, inv, locationRoot) {
1012
1253
  const pluginJsonSuffix = safePath.join('.claude-plugin', 'plugin.json');
1013
1254
  const parseIssues = [];
1014
1255
  for (const err of inv.parseErrors) {
1015
- if (err.path.endsWith(pluginJsonSuffix))
1256
+ if (err.path.endsWith(pluginJsonSuffix) || basename(err.path) === 'SKILL.md')
1016
1257
  continue;
1017
1258
  parseIssues.push({
1018
1259
  severity: 'error',
1019
1260
  code: 'PLUGIN_INVALID_JSON',
1020
- message: err.message,
1261
+ message: withRelativePath(err.message, err.path, locationRoot),
1021
1262
  location: issueLocation(err.path, locationRoot),
1022
1263
  });
1023
1264
  }
@@ -1058,7 +1299,7 @@ function appendInventoryParseErrors(result, inv, locationRoot) {
1058
1299
  */
1059
1300
  export async function getValidationResults(scanPath, recursive, options, logger, locationRoot) {
1060
1301
  try {
1061
- return await validateAuditSubject(scanPath, recursive, options, logger, locationRoot);
1302
+ return dedupeUnreadablePathResults(await validateAuditSubject(scanPath, recursive, options, logger, locationRoot));
1062
1303
  }
1063
1304
  catch (error) {
1064
1305
  // Only the filesystem refusing the subject degrades; a defect in a validator
@@ -1069,6 +1310,83 @@ export async function getValidationResults(scanPath, recursive, options, logger,
1069
1310
  return [unreadablePathResult(scanPath, error, locationRoot)];
1070
1311
  }
1071
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
+ }
1072
1390
  async function validateAuditSubject(scanPath, recursive, options, logger, locationRoot) {
1073
1391
  const format = detectFormat(scanPath);
1074
1392
  // Special handling for direct SKILL.md file
@@ -1232,7 +1550,15 @@ function resolveConfigTargetsForPlugin(pluginDir, vatContext) {
1232
1550
  return union.size === 0 ? undefined : [...union];
1233
1551
  }
1234
1552
  /**
1235
- * 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.
1236
1562
  * Non-plugin results are skipped silently.
1237
1563
  * When effectiveSettings is provided, also runs settings conflict detection.
1238
1564
  *
@@ -1253,67 +1579,99 @@ export async function runCompatAnalysis(results, logger, locationRoot, effective
1253
1579
  for (const result of results) {
1254
1580
  if (result.type !== RESOURCE_TYPE_CLAUDE_PLUGIN)
1255
1581
  continue;
1256
- try {
1257
- logger.debug(`Running compatibility analysis for: ${result.path}`);
1258
- const configTargets = resolveConfigTargetsForPlugin(result.path, vatContext);
1259
- const analyzeOptions = configTargets === undefined
1260
- ? undefined
1261
- : { configTargets };
1262
- const compat = await analyzeCompatibility(result.path, locationRoot, analyzeOptions);
1263
- // Settings conflict detection (when --settings flag is used)
1264
- if (effectiveSettings === undefined) {
1265
- compatMap.set(result.path, compat);
1266
- }
1267
- else {
1268
- try {
1269
- const settingsConflicts = await checkSettingsCompatibility(result.path, effectiveSettings);
1270
- compatMap.set(result.path, { ...compat, settingsConflicts });
1271
- }
1272
- catch (settingsErr) {
1273
- logger.debug(`Settings compat check skipped for ${result.path}: ${String(settingsErr)}`);
1274
- compatMap.set(result.path, compat);
1275
- }
1276
- }
1277
- }
1278
- catch (err) {
1279
- // Log but do not fail the audit — compat analysis is best-effort
1280
- logger.debug(`Compatibility analysis skipped for ${result.path}: ${String(err)}`);
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);
1281
1587
  }
1588
+ compatMap.set(result.path, entry);
1282
1589
  }
1283
1590
  return compatMap;
1284
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
+ }
1285
1651
  /**
1286
1652
  * Merge compatibility analysis results into validation result output objects.
1287
1653
  * Returns an array of plain objects ready for YAML serialization.
1288
- * 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.
1289
1656
  */
1290
- function mergeCompatIntoResults(results, compatMap) {
1657
+ function mergeCompatIntoResults(results, compatMap, locationRoot) {
1291
1658
  return results.map(r => {
1292
- const compat = compatMap.get(r.path);
1293
- if (compat === undefined)
1659
+ const entry = compatMap.get(r.path);
1660
+ if (entry === undefined)
1294
1661
  return r;
1295
- // Extract settingsConflicts from compat to render as a separate `settings:` block
1296
- const { settingsConflicts, ...compatWithoutSettings } = compat;
1297
- if (settingsConflicts !== undefined) {
1298
- return {
1299
- ...r,
1300
- compatibility: compatWithoutSettings,
1301
- settings: {
1302
- compatible: settingsConflicts.length === 0,
1303
- conflicts: settingsConflicts,
1304
- },
1305
- };
1306
- }
1307
- return { ...r, compatibility: compat };
1662
+ const merged = { ...r, compatibility: entry.compat };
1663
+ if (entry.settings !== undefined)
1664
+ merged.settings = renderSettingsBlock(entry.settings, locationRoot);
1665
+ return merged;
1308
1666
  });
1309
1667
  }
1310
1668
  /**
1311
1669
  * Apply compatibility data to results if a compatMap is provided and non-empty.
1312
1670
  * Returns the original results array when no compat data is available.
1313
1671
  */
1314
- function applyCompatMap(results, compatMap) {
1672
+ function applyCompatMap(results, compatMap, locationRoot) {
1315
1673
  if (compatMap !== undefined && compatMap.size > 0) {
1316
- return mergeCompatIntoResults(results, compatMap);
1674
+ return mergeCompatIntoResults(results, compatMap, locationRoot);
1317
1675
  }
1318
1676
  return results;
1319
1677
  }
@@ -1350,7 +1708,7 @@ function applyVerboseFilter(results, verbose) {
1350
1708
  const NESTED_PATH_CARRIERS = ['linkedFiles'];
1351
1709
  function calculateSummary(results, startTime, compatMap, verbose, root) {
1352
1710
  const { entries, ...base } = buildBaseSummary(results, startTime);
1353
- const withCompat = applyCompatMap(entries, compatMap);
1711
+ const withCompat = applyCompatMap(entries, compatMap, root);
1354
1712
  return {
1355
1713
  // Stated once, first, and the only absolute path in the document.
1356
1714
  root,
@@ -1463,23 +1821,83 @@ function renderVerboseEvidence(results, root, logger) {
1463
1821
  }
1464
1822
  }
1465
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
+ }
1466
1879
  function handleAuditResults(results, summary, logger, verbose) {
1467
1880
  const { filesWithErrors: errorCount, filesWithWarnings: warningCount, filesPassed: successCount, } = summary.summary;
1468
- // Report settings conflicts (advisory, non-blocking)
1469
- const totalSettingsConflicts = (summary.files ?? []).reduce((sum, f) => {
1470
- return sum + (f.compatibility?.settingsConflicts?.length ?? 0);
1471
- }, 0);
1472
- if (totalSettingsConflicts > 0) {
1473
- logger.error(`\u26a0 ${totalSettingsConflicts} settings conflict(s) found — see 'settings' section in YAML output`);
1474
- }
1881
+ logSettingsTotals(summary.files ?? [], logger);
1475
1882
  // 🔑 Audit is advisory only — always exit 0 for validation results, and the
1476
- // WORDING has to agree with that or the three signals contradict each other.
1477
- // This line used to read "Audit failed", beside `status: error` in the
1478
- // document, on exit 0: an adopter wiring `vat audit` into CI read a failure,
1479
- // saw a failure status, and got a green step. "Found" is what actually
1480
- // happened; the gate is `vat validate`, which is named here rather than
1481
- // implied.
1482
- if (errorCount > 0) {
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) {
1483
1901
  logger.error(`Audit found ${errorCount} file(s) with errors ${ADVISORY_EXIT_NOTE}`);
1484
1902
  logFindingsForStatus(results, 'error', summary.root, logger.error.bind(logger), verbose);
1485
1903
  }
@@ -1678,10 +2096,21 @@ async function handleFileEntry(entry, fullPath, options, logger, scanCtx) {
1678
2096
  // checks in the link-graph walk stay O(1).
1679
2097
  // `projectSkills`: same project-wide test-input rule as the lane above,
1680
2098
  // resolved through the same per-config-root memo.
1681
- 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);
1682
2111
  const sharedCtx = scanCtx.gitTracker === null
1683
- ? { locationRoot, projectSkills }
1684
- : { gitTracker: scanCtx.gitTracker, locationRoot, projectSkills };
2112
+ ? { locationRoot, projectSkills, suiteProbe, unreadable }
2113
+ : { gitTracker: scanCtx.gitTracker, locationRoot, projectSkills, suiteProbe, unreadable };
1685
2114
  const packagingResult = await validateSkillForPackaging(fullPath, skillConfig, 'source', sharedCtx);
1686
2115
  const configAware = packagingResultToValidationResult(fullPath, packagingResult, skillConfig.targets, locationRoot);
1687
2116
  await appendDistributedTreeFindings(configAware, fullPath, locationRoot, ownsOwnCrawl(scanCtx, fullPath));
@@ -1855,6 +2284,7 @@ export function resetAuditCaches() {
1855
2284
  resetPackagingRegistryCache();
1856
2285
  inventoryRegistryCache.clear();
1857
2286
  unloadableConfigResults.clear();
2287
+ runSuiteProbe = undefined;
1858
2288
  resetProjectRootCaches();
1859
2289
  resetLoadedConfigCache();
1860
2290
  resetSkillDiscoveryCache();
@@ -1954,10 +2384,14 @@ function unreadablePathResult(dirPath, error, locationRoot) {
1954
2384
  // identical reason, already exists on the distributed-tree finding — see
1955
2385
  // `anchoredTreeLocation` in ./audit/distributed-tree.ts.
1956
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);
1957
2391
  const issues = [
1958
2392
  materializeIssue('SCAN_PATH_UNREADABLE', {
1959
2393
  location,
1960
- detail: `${location}: ${error instanceof Error ? error.message : String(error)}`,
2394
+ detail: `${location}: ${reason}`,
1961
2395
  }),
1962
2396
  ];
1963
2397
  const issueCounts = countBySeverity(issues);
@@ -2146,6 +2580,24 @@ function countFilesByStatus(results) {
2146
2580
  * also present in its owner's `issues`, so the nested list is a VIEW of records
2147
2581
  * already counted, not extra records. `audit-report-coherence.integration.test.ts`
2148
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}.
2149
2601
  */
2150
2602
  function buildBaseSummary(results, startTime) {
2151
2603
  const issueCounts = { errors: 0, warnings: 0, info: 0 };
@@ -2156,11 +2608,21 @@ function buildBaseSummary(results, startTime) {
2156
2608
  issueCounts.info += counts.info;
2157
2609
  return { ...result, issueCounts: counts };
2158
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.');
2159
2617
  return {
2160
2618
  entries,
2161
- 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),
2162
2623
  summary: countFilesByStatus(entries),
2163
- issueCounts,
2624
+ issueCounts: sumSeverityCounts([issueCounts, countBySeverity(runIssues)]),
2625
+ ...(runIssues.length === 0 ? {} : { issues: [...runIssues] }),
2164
2626
  duration: `${Date.now() - startTime}ms`,
2165
2627
  };
2166
2628
  }
@@ -2169,7 +2631,7 @@ function buildBaseSummary(results, startTime) {
2169
2631
  */
2170
2632
  function calculateHierarchicalSummary(results, hierarchical, startTime, compatMap, verbose, root) {
2171
2633
  const { entries, ...base } = buildBaseSummary(results, startTime);
2172
- const withCompat = applyCompatMap(entries, compatMap);
2634
+ const withCompat = applyCompatMap(entries, compatMap, root);
2173
2635
  return {
2174
2636
  root,
2175
2637
  ...base,
@@ -2187,13 +2649,16 @@ function calculateHierarchicalSummary(results, hierarchical, startTime, compatMa
2187
2649
  /**
2188
2650
  * Log hierarchical summary to stderr
2189
2651
  */
2190
- function logHierarchicalSummary(results, logger) {
2652
+ function logHierarchicalSummary(results, runIssues, logger) {
2191
2653
  const status = calculateOverallStatus(results);
2192
2654
  const skillsWithIssues = countResultsWithFindings(results);
2193
2655
  const totalSkills = results.length;
2194
2656
  // Audit is advisory only — always exit 0 for validation results.
2195
2657
  // Use vat skills validate for gated validation (exit 1 on errors).
2196
- 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') {
2197
2662
  const errorCount = results.filter((r) => r.status === 'error').length;
2198
2663
  logger.error(`Audit found ${errorCount} skill(s) with errors (${totalSkills} scanned, ${skillsWithIssues} with issues) ${ADVISORY_EXIT_NOTE}`);
2199
2664
  }