@vibe-agent-toolkit/cli 0.1.41-rc.8 → 0.1.41

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 (76) hide show
  1. package/dist/bin.js +11 -1
  2. package/dist/bin.js.map +1 -1
  3. package/dist/commands/audit/distributed-tree.d.ts +172 -0
  4. package/dist/commands/audit/distributed-tree.d.ts.map +1 -0
  5. package/dist/commands/audit/distributed-tree.js +319 -0
  6. package/dist/commands/audit/distributed-tree.js.map +1 -0
  7. package/dist/commands/audit.d.ts +35 -0
  8. package/dist/commands/audit.d.ts.map +1 -1
  9. package/dist/commands/audit.js +312 -93
  10. package/dist/commands/audit.js.map +1 -1
  11. package/dist/commands/build.d.ts.map +1 -1
  12. package/dist/commands/build.js +16 -4
  13. package/dist/commands/build.js.map +1 -1
  14. package/dist/commands/claude/marketplace/git-publish.d.ts.map +1 -1
  15. package/dist/commands/claude/marketplace/git-publish.js +21 -3
  16. package/dist/commands/claude/marketplace/git-publish.js.map +1 -1
  17. package/dist/commands/claude/marketplace/validate.d.ts +31 -3
  18. package/dist/commands/claude/marketplace/validate.d.ts.map +1 -1
  19. package/dist/commands/claude/marketplace/validate.js +105 -12
  20. package/dist/commands/claude/marketplace/validate.js.map +1 -1
  21. package/dist/commands/claude/plugin/build.d.ts +9 -2
  22. package/dist/commands/claude/plugin/build.d.ts.map +1 -1
  23. package/dist/commands/claude/plugin/build.js +90 -8
  24. package/dist/commands/claude/plugin/build.js.map +1 -1
  25. package/dist/commands/claude/plugin/plugin-files.d.ts.map +1 -1
  26. package/dist/commands/claude/plugin/plugin-files.js +6 -2
  27. package/dist/commands/claude/plugin/plugin-files.js.map +1 -1
  28. package/dist/commands/claude/plugin/tree-copy.d.ts +40 -0
  29. package/dist/commands/claude/plugin/tree-copy.d.ts.map +1 -1
  30. package/dist/commands/claude/plugin/tree-copy.js +100 -4
  31. package/dist/commands/claude/plugin/tree-copy.js.map +1 -1
  32. package/dist/commands/doctor.js +1 -1
  33. package/dist/commands/doctor.js.map +1 -1
  34. package/dist/commands/positional-args.d.ts +36 -0
  35. package/dist/commands/positional-args.d.ts.map +1 -0
  36. package/dist/commands/positional-args.js +52 -0
  37. package/dist/commands/positional-args.js.map +1 -0
  38. package/dist/commands/skill/review-checklist.d.ts.map +1 -1
  39. package/dist/commands/skill/review-checklist.js +5 -0
  40. package/dist/commands/skill/review-checklist.js.map +1 -1
  41. package/dist/commands/skills/build.d.ts +258 -9
  42. package/dist/commands/skills/build.d.ts.map +1 -1
  43. package/dist/commands/skills/build.js +698 -111
  44. package/dist/commands/skills/build.js.map +1 -1
  45. package/dist/commands/skills/scope-guard.d.ts +64 -0
  46. package/dist/commands/skills/scope-guard.d.ts.map +1 -0
  47. package/dist/commands/skills/scope-guard.js +86 -0
  48. package/dist/commands/skills/scope-guard.js.map +1 -0
  49. package/dist/commands/skills/validate-command.d.ts.map +1 -1
  50. package/dist/commands/skills/validate-command.js +19 -1
  51. package/dist/commands/skills/validate-command.js.map +1 -1
  52. package/dist/commands/skills/validate.d.ts.map +1 -1
  53. package/dist/commands/skills/validate.js +37 -7
  54. package/dist/commands/skills/validate.js.map +1 -1
  55. package/dist/commands/validate.d.ts.map +1 -1
  56. package/dist/commands/validate.js +18 -4
  57. package/dist/commands/validate.js.map +1 -1
  58. package/dist/commands/verify.d.ts +77 -7
  59. package/dist/commands/verify.d.ts.map +1 -1
  60. package/dist/commands/verify.js +254 -57
  61. package/dist/commands/verify.js.map +1 -1
  62. package/dist/utils/issue-rendering.d.ts +42 -0
  63. package/dist/utils/issue-rendering.d.ts.map +1 -1
  64. package/dist/utils/issue-rendering.js +53 -0
  65. package/dist/utils/issue-rendering.js.map +1 -1
  66. package/dist/utils/issue-severity.d.ts +51 -0
  67. package/dist/utils/issue-severity.d.ts.map +1 -0
  68. package/dist/utils/issue-severity.js +63 -0
  69. package/dist/utils/issue-severity.js.map +1 -0
  70. package/dist/version.d.ts +22 -2
  71. package/dist/version.d.ts.map +1 -1
  72. package/dist/version.js +31 -2
  73. package/dist/version.js.map +1 -1
  74. package/docs/index.md +12 -0
  75. package/docs/skills.md +16 -2
  76. package/package.json +12 -12
@@ -4,23 +4,26 @@
4
4
  */
5
5
  import * as fs from 'node:fs';
6
6
  import { existsSync as fsExistsSync } from 'node:fs';
7
+ import { basename } from 'node:path';
7
8
  import { calculateValidationStatus, countBySeverity, } from '@vibe-agent-toolkit/agent-schema';
8
9
  import { crawlAndResolveRegistry, detectDeclaredButMissing, detectMarketplacePluginSourceMissing, resetPackagingRegistryCache, detectPresentButUndeclared, detectReferenceTargetMissing, detectResourceFormat, enumerateSurfaces, validate, validateMarketplace, validateSkill, validateSkillForPackaging, } from '@vibe-agent-toolkit/agent-skills';
9
10
  import { analyzeCompatibility, checkSettingsCompatibility, detectSkillClaudePluginNameMismatch, crawlSkillLinkRegistry, extractClaudeMarketplaceInventory, extractClaudePluginInventory, getClaudeUserPaths, readEffectiveSettings, validatePlugin, } from '@vibe-agent-toolkit/claude-marketplace';
10
11
  import { detectFormat } from '@vibe-agent-toolkit/discovery';
11
- import { findProjectRoot, gitFindRoot, GitTracker, isAbsolutePath, isGitUrl, issueLocation, parseGitUrl, resetProjectRootCaches, safePath, } from '@vibe-agent-toolkit/utils';
12
+ import { findProjectRoot, gitFindRoot, isAbsolutePath, isGitUrl, issueLocation, parseGitUrl, resetProjectRootCaches, safePath, } from '@vibe-agent-toolkit/utils';
12
13
  import { Command } from 'commander';
13
14
  import picomatch from 'picomatch';
14
15
  import { resetSkillDiscoveryCache, resolveProjectDeclaredEvalSuites, resolveSkillPackagingConfig, stripValidationAllowForDisplay, } from '../skill-resolution/packaging-config.js';
15
16
  import { handleCommandError } from '../utils/command-error.js';
16
17
  import { ConfigLoadError, loadConfig, resetLoadedConfigCache, } from '../utils/config-loader.js';
17
- import { formatIssueLines, formatSeverityBreakdown, issuesToRenderAtVerbosity, } from '../utils/issue-rendering.js';
18
+ import { countCollapsedFindings, formatCollapsedFindingsHint, formatIssueLines, formatSeverityBreakdown, issuesToRenderAtVerbosity, } from '../utils/issue-rendering.js';
19
+ import { resolveIssueSeverity } from '../utils/issue-severity.js';
18
20
  import { createLogger } from '../utils/logger.js';
19
21
  import { writeYamlOutput } from '../utils/output.js';
20
22
  import { relativizePathEntries } from '../utils/relativize-paths.js';
21
23
  import { mergeSkillPackagingConfig } from '../utils/skill-packaging-config.js';
22
24
  import { renderSkillQualityFooter } from '../utils/skill-quality-footer.js';
23
25
  import { computeConfigVerdicts } from '../utils/verdict-helpers.js';
26
+ import { crawlOwnsSubtree, distributedTreeFindings, getOrCreateGitTracker, isWithin, resetGitTrackerCache, } from './audit/distributed-tree.js';
24
27
  import { withClonedRepo } from './audit/git-url-clone.js';
25
28
  import { buildHierarchicalOutput } from './audit/hierarchical-output.js';
26
29
  import { renderProvenanceHeader, rewritePathsInResults, } from './audit/provenance.js';
@@ -150,8 +153,15 @@ function packagingResultToValidationResult(skillPath, result, configTargets, loc
150
153
  /**
151
154
  * Validate a single SKILL.md file, using config-aware validation if available.
152
155
  * Used for direct SKILL.md and vat-agent paths passed to `vat audit`.
156
+ *
157
+ * @param crawlTree Whether this call owns the presence-side crawl of the skill's
158
+ * directory (see {@link appendDistributedTreeFindings} below). REQUIRED
159
+ * and never defaulted, because the wrong answer is silent in both directions: a
160
+ * skill nested inside a plugin is already crawled by `validatePlugin` at any
161
+ * depth, so crawling again reports one file twice, while omitting it for a
162
+ * standalone bundle is the blindness this parameter exists to end.
153
163
  */
154
- async function validateSingleSkill(skillPath, options, logger, locationRoot, isVATGenerated) {
164
+ async function validateSingleSkill(skillPath, options, logger, locationRoot, crawlTree, isVATGenerated) {
155
165
  // Try config-aware validation: walk UP to the skill's nearest-ancestor
156
166
  // vibe-agent-toolkit.config.yaml and apply the skill's packaging block. A
157
167
  // broken governing config is tolerated here (audit is a bulk linter): log it
@@ -187,7 +197,9 @@ async function validateSingleSkill(skillPath, options, logger, locationRoot, isV
187
197
  sharedCtx.gitTracker = gitTracker;
188
198
  }
189
199
  const packagingResult = await validateSkillForPackaging(skillPath, skillConfig, 'source', sharedCtx);
190
- return packagingResultToValidationResult(skillPath, packagingResult, skillConfig.targets, locationRoot);
200
+ const configAware = packagingResultToValidationResult(skillPath, packagingResult, skillConfig.targets, locationRoot);
201
+ await appendDistributedTreeFindings(configAware, skillPath, locationRoot, crawlTree);
202
+ return configAware;
191
203
  }
192
204
  // Fallback: basic validation
193
205
  const validateOptions = { skillPath, locationRoot };
@@ -197,7 +209,35 @@ async function validateSingleSkill(skillPath, options, logger, locationRoot, isV
197
209
  if (isVATGenerated === true) {
198
210
  validateOptions.isVATGenerated = true;
199
211
  }
200
- return validateSkill(validateOptions);
212
+ const result = await validateSkill(validateOptions);
213
+ await appendDistributedTreeFindings(result, skillPath, locationRoot, crawlTree);
214
+ return result;
215
+ }
216
+ /**
217
+ * Crawl a DISTRIBUTED skill's own directory for agent-instruction files and
218
+ * append what it finds. Mutates `result`, like every other additive detector
219
+ * pass in this command.
220
+ *
221
+ * Which trees count as distributed — and why the config's declaration is NOT the
222
+ * discriminator any more — lives with the classifier in
223
+ * `./audit/distributed-tree.ts` (`classifyScannedSkillTree`). In short: a repository that never adopted
224
+ * VAT declares nothing, and an adopting repo's `include` globs never enumerate
225
+ * its drafts and fixtures, so "not declared" swept up two populations of
226
+ * ordinary repo source.
227
+ *
228
+ * Called from BOTH lanes — config-aware and wild — deliberately. It used to be
229
+ * reachable only from the wild one, which left the abandoned discriminator alive
230
+ * at the caller in its purest suppressing form: adding a `vibe-agent-toolkit.config.yaml`
231
+ * to a dotfiles repo removed findings from skills installed under `~/.claude`,
232
+ * because a config-aware match returned before the classifier was ever asked.
233
+ * Whether the project also declares a skill says nothing about where that skill
234
+ * lives, so it must not decide whether the question gets asked.
235
+ *
236
+ * @param crawlTree `false` when a plugin lane already crawled this subtree — see
237
+ * {@link validateSingleSkill}.
238
+ */
239
+ async function appendDistributedTreeFindings(result, skillPath, locationRoot, crawlTree) {
240
+ appendIssues(result, await distributedTreeFindings(skillPath, locationRoot, crawlTree));
201
241
  }
202
242
  /**
203
243
  * Collect repeated option values into an array (used for --exclude)
@@ -218,7 +258,12 @@ export function createAuditCommand() {
218
258
  .option('--exclude <glob>', 'Exclude paths matching glob pattern (repeatable)', collect, [])
219
259
  .option('--include-artifacts', 'Include gitignored paths (build artifacts, dependencies) that are excluded by default')
220
260
  .option('--user', 'Audit user-level Claude resources (default: $CLAUDE_CONFIG_DIR or ~/.claude — scans plugins/, skills/, marketplaces/)')
221
- .option('--verbose', 'Show all scanned resources, including those without issues')
261
+ // `-v` short form: `vat audit -v` used to print the version (the root
262
+ // `-v, --version` shadowed every subcommand), and after that alias was
263
+ // removed it exited 1 as an unknown option — while five sibling verbs
264
+ // accepted it. Advertising only the long form is what made the CHANGELOG's
265
+ // "`-v` means `--verbose` on every verb" claim untrue.
266
+ .option('-v, --verbose', 'Show all scanned resources, including those without issues')
222
267
  .option('--warn-unreferenced-files', 'Warn about files not referenced in skill markdown')
223
268
  .option('--compat', 'Run compatibility analysis for each plugin (shows claude-code, claude-cowork, claude-chat support)')
224
269
  .option('--settings [file]', 'Check plugins against Claude settings (auto-discover or specify file; requires --compat)')
@@ -311,13 +356,17 @@ Gitignore-Aware Scanning:
311
356
  Outside a git repository, no automatic exclusions apply.
312
357
 
313
358
  Config-Aware Validation:
314
- When a vibe-agent-toolkit.config.yaml is found at the git root, audit
315
- uses the project's build settings (linkFollowDepth, files,
316
- excludeReferencesFromBundle) to validate skills. This prevents false
317
- warnings for links that the build pipeline resolves.
359
+ The governing vibe-agent-toolkit.config.yaml is found by walking UP from
360
+ the audited path, so a built bundle deep under dist/ is still governed by
361
+ its project's config. Audit uses that project's build settings
362
+ (linkFollowDepth, files, excludeReferencesFromBundle) to validate skills,
363
+ which prevents false warnings for links the build pipeline resolves.
318
364
 
319
365
  Config-aware mode never applies validation.allow — audit always shows
320
- all issues. validation.severity is respected for display grouping.
366
+ all issues. validation.severity IS applied: a code set to 'ignore' is
367
+ hidden. skills.defaults.validation.severity applies project-wide (skills,
368
+ plugins and marketplaces alike); skills.config.<name>.validation.severity
369
+ layers on top for that skill.
321
370
 
322
371
  Exit Codes:
323
372
  0 - Always (even when validation errors are surfaced)
@@ -335,6 +384,37 @@ Examples:
335
384
  `);
336
385
  return audit;
337
386
  }
387
+ /**
388
+ * Which of the Claude install roots a `--user` run actually walks.
389
+ *
390
+ * The three roots read as siblings and are NOT: `marketplacesDir` is
391
+ * `<claudeDir>/plugins/marketplaces`, i.e. INSIDE `pluginsDir`. A recursive walk
392
+ * of `plugins/` already reaches every installed marketplace, so scanning
393
+ * `marketplaces/` afterwards audited each marketplace-installed plugin and skill
394
+ * a SECOND time, in a second result, under the identical `path:`. On one real
395
+ * machine that turned 7 distinct agent-instruction files into 12 reported rows —
396
+ * and it inflates every finding class, not one code, because the whole subject
397
+ * is re-audited rather than one detector re-firing.
398
+ *
399
+ * The containment test is `isWithin`, the same one the provenance classifier
400
+ * uses, so "inside" means the same thing on both sides and on Windows.
401
+ *
402
+ * Gated on `recursive`, and that is not defensive dressing: under
403
+ * `--no-recursive` a walk of `plugins/` stops at its top level and never reaches
404
+ * `marketplaces/`, so dropping the nested root there would replace a double
405
+ * count with no coverage at all.
406
+ *
407
+ * Pure, and exported for that reason: the shape of the defect is "the list of
408
+ * roots was wrong", which is assertable as a list.
409
+ *
410
+ * @internal Exported for unit testing only — not part of the public CLI API.
411
+ */
412
+ export function userScanTargets(candidates, recursive) {
413
+ const resolved = candidates.map((dir) => safePath.resolve(dir));
414
+ if (!recursive)
415
+ return resolved;
416
+ return resolved.filter((dir) => !resolved.some((other) => other !== dir && isWithin(other, dir)));
417
+ }
338
418
  /**
339
419
  * Handle --user audit: scans ~/.claude/plugins, ~/.claude/skills, ~/.claude/marketplaces
340
420
  * and outputs hierarchical YAML. Calls process.exit() when done.
@@ -359,17 +439,16 @@ async function auditUserDirectories(recursive, options, startTime, logger) {
359
439
  process.exit(2);
360
440
  }
361
441
  const results = [];
362
- if (pluginsDirExists) {
363
- logger.debug(`Auditing user-level plugins at: ${pluginsDir}`);
364
- results.push(...await getValidationResults(pluginsDir, recursive, options, logger, scanRoot));
365
- }
366
- if (skillsDirExists) {
367
- logger.debug(`Auditing user-level skills at: ${skillsDir}`);
368
- results.push(...await getValidationResults(skillsDir, recursive, options, logger, scanRoot));
369
- }
370
- if (marketplacesDirExists) {
371
- logger.debug(`Auditing user-level marketplaces at: ${marketplacesDir}`);
372
- results.push(...await getValidationResults(marketplacesDir, recursive, options, logger, scanRoot));
442
+ // The roots that exist, minus any one another already covers — see
443
+ // {@link userScanTargets}. `marketplaces/` lives inside `plugins/`.
444
+ const present = [
445
+ ...(pluginsDirExists ? [pluginsDir] : []),
446
+ ...(skillsDirExists ? [skillsDir] : []),
447
+ ...(marketplacesDirExists ? [marketplacesDir] : []),
448
+ ];
449
+ for (const target of userScanTargets(present, recursive)) {
450
+ logger.debug(`Auditing user-level resources at: ${target}`);
451
+ results.push(...await getValidationResults(target, recursive, options, logger, scanRoot));
373
452
  }
374
453
  // Run compatibility analysis if --compat flag is set
375
454
  const compatMap = options.compat
@@ -385,22 +464,6 @@ async function auditUserDirectories(recursive, options, startTime, logger) {
385
464
  }
386
465
  logHierarchicalSummary(results, logger);
387
466
  }
388
- /**
389
- * Apply severity filtering to validation results.
390
- *
391
- * Audit is advisory only: it applies `validation.severity` to decide what to
392
- * show, but deliberately ignores `validation.allow`. Codes configured as
393
- * `severity: 'ignore'` are stripped from the result issues before rendering.
394
- *
395
- * The severity config is per-skill, keyed by skill name in
396
- * `config.skills.config[skillName].validation.severity`.
397
- * Defaults config (`config.skills.defaults.validation.severity`) is also
398
- * checked as a fallback.
399
- *
400
- * @param results - Raw validation results from skill/plugin validators
401
- * @param config - Parsed VATConfig (may be undefined if no config file)
402
- * @returns New results array with ignored codes removed from issues
403
- */
404
467
  /** Skill resource types that can have per-skill validation config. */
405
468
  const SKILL_RESULT_TYPES = new Set([RESOURCE_TYPE_AGENT_SKILL, 'vat-agent']);
406
469
  /**
@@ -430,17 +493,27 @@ export function deriveScanRoot(targetPath) {
430
493
  /**
431
494
  * Derive the project root directory for config loading.
432
495
  *
433
- * For a direct SKILL.md path, the project root is two levels up (skill dir → skills/ → project/).
434
- * For a directory or undefined path, use the directory itself (or cwd).
496
+ * Walks UP from the audit target through {@link findProjectRoot} — the same
497
+ * discovery ladder (`vibe-agent-toolkit.config.yaml`, then `.git/`) that
498
+ * `resolveSkillPackagingConfig` uses per skill, so the two cannot disagree about
499
+ * which project governs a path.
500
+ *
501
+ * It used to guess by SHAPE instead: a directory argument resolved to ITSELF and
502
+ * a `SKILL.md` argument to exactly two levels up. Neither reaches a project root
503
+ * from a built bundle — `vat audit dist/skills/demo` looked for the config inside
504
+ * the bundle, found none, and `applySeverityFilter` early-returned. The effect was
505
+ * that `severity.PACKAGED_AGENT_INSTRUCTION_FILE: ignore` — the opt-out this very
506
+ * command's `fix` text tells the reader to write — did nothing whenever audit was
507
+ * pointed at the bundle rather than at the project. Measured: `warnings: 1` with
508
+ * the override and without it, from the same tree, while `vat audit .` on that
509
+ * same project honoured it.
510
+ *
511
+ * `null` (no config and no git anywhere above) falls back to the target itself,
512
+ * which is what the shape-based version returned for every path.
435
513
  */
436
514
  function deriveConfigRoot(targetPath) {
437
- if (targetPath === undefined) {
438
- return process.cwd();
439
- }
440
- if (targetPath.endsWith('SKILL.md')) {
441
- return safePath.resolve(safePath.join(targetPath, '..', '..'));
442
- }
443
- return safePath.resolve(targetPath);
515
+ const start = targetPath === undefined ? process.cwd() : deriveScanRoot(targetPath);
516
+ return findProjectRoot(start) ?? start;
444
517
  }
445
518
  /**
446
519
  * Build the recalculated status and summary after issues have been filtered.
@@ -459,16 +532,55 @@ function buildFilteredResult(result, filteredIssues) {
459
532
  };
460
533
  }
461
534
  /**
462
- * Apply severity filtering to validation results.
535
+ * Apply severity resolution to validation results.
463
536
  *
464
537
  * Audit is advisory only: it applies `validation.severity` to decide what to
465
- * show, but deliberately ignores `validation.allow`. Codes configured as
466
- * `severity: 'ignore'` are stripped from the result issues before rendering.
538
+ * show and at what severity, but deliberately ignores `validation.allow`.
467
539
  *
468
- * The severity config is per-skill, keyed by skill name in
469
- * `config.skills.config[skillName].validation.severity`.
470
- * Defaults config (`config.skills.defaults.validation.severity`) is also
471
- * checked as a fallback.
540
+ * **Both directions, via the one shared resolver.** This used to compute the
541
+ * merged override map correctly and then consult it for a single value —
542
+ * `issues.filter(i => effective[i.code] !== 'ignore')` — so `ignore` worked
543
+ * while `error`, `warning` and `info` fell through untouched. Measured end to
544
+ * end on a real adopter tree: `severity: error` at BOTH scopes left
545
+ * `severity: warning`, `errors: 0`, `status: warning`, while `vat verify` on the
546
+ * same config promoted correctly. Two lanes, one config key, opposite answers.
547
+ *
548
+ * Status and counts are re-derived from the resolved severities by
549
+ * {@link buildFilteredResult}, so a promotion moves the reported status. It does
550
+ * NOT move this command's exit code — `vat audit` is advisory and its exit-code
551
+ * behaviour is a separate, pre-existing question tracked in its own issue. That
552
+ * asymmetry with `vat verify` (whose exit code IS severity-derived) is
553
+ * deliberate here rather than overlooked.
554
+ *
555
+ * Two scopes, both live:
556
+ * - `skills.defaults.validation.severity` — the PROJECT-WIDE map, applied to
557
+ * every result this run produced, including `claude-plugin` and `marketplace`
558
+ * ones. It used to be applied only to skill-typed results, which left the
559
+ * opt-out inert for the population that motivated it: the false positive
560
+ * `PACKAGED_AGENT_INSTRUCTION_FILE` was kept at `warning` for — an official
561
+ * plugin's intentional scaffold template — is a finding on a PLUGIN tree, and
562
+ * no per-skill key can name it. Measured before the fix: a plugin result kept
563
+ * its warning with `severity: ignore` set at `skills.defaults`.
564
+ * - `skills.config.<name>.validation.severity` — layered on top, and only for a
565
+ * result that names a skill, since that key is keyed by skill name.
566
+ *
567
+ * KNOWN, DELIBERATELY NOT FIXED — per-skill scope cannot reach a finding nested
568
+ * inside a plugin tree, even when that finding IS attributable to a skill.
569
+ * `vat audit <plugin dir>` reports two findings: the plugin root's `CLAUDE.md`
570
+ * (attributable to no skill, which is the case the project-wide scope above was
571
+ * added for) and an `AGENTS.md` nested inside one of the plugin's OWN skills,
572
+ * which is attributable. Measured: neither moves under
573
+ * `skills.config.<that skill>` nor under `skills.config.<the plugin's name>`;
574
+ * only the project-wide `skills.defaults` moves them. The mechanism is the line
575
+ * below — a `claude-plugin` result is not in `SKILL_RESULT_TYPES`, so `skillName`
576
+ * is `undefined` and the per-skill map is never consulted, no matter which name
577
+ * the adopter writes.
578
+ *
579
+ * The consequence worth stating plainly: an adopter with ONE intentional
580
+ * agent-instruction file in ONE plugin has to silence the code project-wide in
581
+ * this lane, losing the detector everywhere else. The marketplace lane has the
582
+ * same project-wide-only limitation for a different reason, recorded there: both
583
+ * marketplace config schemas are `.strict()` with no `validation` key at all.
472
584
  *
473
585
  * @param results - Raw validation results from skill/plugin validators
474
586
  * @param config - Parsed VATConfig (may be undefined if no config file)
@@ -481,10 +593,7 @@ function applySeverityFilter(results, config) {
481
593
  const skillsConfig = config.skills;
482
594
  const defaultSeverity = skillsConfig.defaults?.validation?.severity ?? {};
483
595
  return results.map(result => {
484
- if (!SKILL_RESULT_TYPES.has(result.type)) {
485
- return result;
486
- }
487
- const skillName = result.metadata?.name;
596
+ const skillName = SKILL_RESULT_TYPES.has(result.type) ? result.metadata?.name : undefined;
488
597
  const perSkillSeverity = skillName === undefined
489
598
  ? {}
490
599
  : (skillsConfig.config?.[skillName]?.validation?.severity ?? {});
@@ -493,11 +602,18 @@ function applySeverityFilter(results, config) {
493
602
  if (Object.keys(effectiveSeverity).length === 0) {
494
603
  return result;
495
604
  }
496
- const filteredIssues = result.issues.filter(issue => effectiveSeverity[issue.code] !== 'ignore');
497
- if (filteredIssues.length === result.issues.length) {
605
+ const resolvedIssues = resolveIssueSeverity(result.issues, { severity: effectiveSeverity });
606
+ // Short-circuit only when NOTHING moved. Comparing lengths alone was the old
607
+ // test, and it is blind to the promote direction by construction: a resolved
608
+ // `error` keeps the array exactly as long as it was, so an early return on
609
+ // equal lengths discarded every re-severitied issue and reported the raw
610
+ // ones. The severities have to be compared too.
611
+ const unchanged = resolvedIssues.length === result.issues.length &&
612
+ resolvedIssues.every((issue, index) => issue.severity === result.issues[index]?.severity);
613
+ if (unchanged) {
498
614
  return result;
499
615
  }
500
- return buildFilteredResult(result, filteredIssues);
616
+ return buildFilteredResult(result, resolvedIssues);
501
617
  });
502
618
  }
503
619
  /**
@@ -627,7 +743,11 @@ export async function auditCommand(targetPath, options) {
627
743
  */
628
744
  async function validateSurface(surface, options, logger, locationRoot) {
629
745
  if (surface.type === RESOURCE_TYPE_AGENT_SKILL) {
630
- return validateSingleSkill(surface.path, options, logger, locationRoot);
746
+ // No crawl: this arm only runs for a MULTI-surface root, which in practice is
747
+ // skill-claude-plugin (SKILL.md beside .claude-plugin/plugin.json). The plugin
748
+ // surface below crawls that same directory at any depth, so crawling here
749
+ // would report every agent-instruction file in it a second time.
750
+ return validateSingleSkill(surface.path, options, logger, locationRoot, false);
631
751
  }
632
752
  if (surface.type === RESOURCE_TYPE_CLAUDE_PLUGIN) {
633
753
  return validatePlugin(surface.path, { locationRoot });
@@ -754,13 +874,13 @@ export async function getValidationResults(scanPath, recursive, options, logger,
754
874
  // Special handling for direct SKILL.md file
755
875
  if (format === RESOURCE_TYPE_AGENT_SKILL) {
756
876
  logger.debug('Detected single Agent Skill');
757
- return [await validateSingleSkill(scanPath, options, logger, locationRoot)];
877
+ return [await validateSingleSkill(scanPath, options, logger, locationRoot, true)];
758
878
  }
759
879
  // Special handling for VAT agent: validate its SKILL.md
760
880
  if (format === 'vat-agent') {
761
881
  const skillPath = safePath.join(scanPath, 'SKILL.md');
762
882
  logger.debug('Detected VAT agent, validating SKILL.md');
763
- return [await validateSingleSkill(skillPath, options, logger, locationRoot, true)];
883
+ return [await validateSingleSkill(skillPath, options, logger, locationRoot, true, true)];
764
884
  }
765
885
  // Enumerate all manifest surfaces at the directory root. If multiple are
766
886
  // present (e.g., skill-claude-plugin: SKILL.md + .claude-plugin/plugin.json),
@@ -852,7 +972,9 @@ async function validatePluginSkillsViaInventory(scanPath, options, logger, locat
852
972
  continue;
853
973
  }
854
974
  logger.debug(` Validating plugin-bundled skill (inventory): ${skill.files.skillMd}`);
855
- results.push(await validateSingleSkill(skill.files.skillMd, options, logger, locationRoot));
975
+ // No crawl: `validatePlugin` already scanned the whole plugin tree, nested
976
+ // skill directories included, so this lane would duplicate its findings.
977
+ results.push(await validateSingleSkill(skill.files.skillMd, options, logger, locationRoot, false));
856
978
  }
857
979
  return results;
858
980
  }
@@ -1060,6 +1182,37 @@ function renderIssueEvidence(issue, evidenceSources, logger) {
1060
1182
  logger.info(formatEvidenceLine(ev));
1061
1183
  }
1062
1184
  }
1185
+ /**
1186
+ * The name a findings block is headed with — never the empty string.
1187
+ *
1188
+ * `issueLocation` answers "where is this, relative to the scan root", and for a
1189
+ * result that IS the scan root (auditing a plugin directory produces exactly
1190
+ * that) the honest relative path is `''`. Rendered straight into a heading, that
1191
+ * produced ` — 2 warnings:` — the operator was told a nameless something had two
1192
+ * warnings and then, because warnings collapse at default verbosity, shown
1193
+ * neither. Fall back in decreasing order of usefulness to the reader:
1194
+ *
1195
+ * 1. the relative path, whenever there is one (the common case, unchanged);
1196
+ * 2. the resource's own name from metadata already on the result — for a plugin
1197
+ * or skill this is the most recognisable subject there is;
1198
+ * 3. the last path segment, i.e. the scanned directory's own name;
1199
+ * 4. `'.'`, the minimum honest way to say "the root you pointed me at".
1200
+ *
1201
+ * Every step reads data already in hand — no new I/O, and no re-deriving a path
1202
+ * the caller already resolved.
1203
+ */
1204
+ function findingSubject(result, root) {
1205
+ const location = issueLocation(result.path, root);
1206
+ if (location !== '')
1207
+ return location;
1208
+ const name = result.metadata?.name;
1209
+ if (name !== undefined && name !== '')
1210
+ return name;
1211
+ // `basename` is empty for a filesystem root ('/' or 'C:\'), which is the only
1212
+ // case left that '.' has to cover.
1213
+ const directoryName = basename(result.path);
1214
+ return directoryName === '' ? '.' : directoryName;
1215
+ }
1063
1216
  /**
1064
1217
  * Render supporting evidence beneath each CAPABILITY_* issue when the
1065
1218
  * audit was invoked with --verbose. Evidence comes from the validation
@@ -1077,7 +1230,7 @@ function renderVerboseEvidence(results, root, logger) {
1077
1230
  ];
1078
1231
  if (evidenceSources.length === 0)
1079
1232
  continue;
1080
- logger.info(`\n${issueLocation(result.path, root)} — supporting evidence:`);
1233
+ logger.info(`\n${findingSubject(result, root)} — supporting evidence:`);
1081
1234
  for (const issue of capabilityIssues) {
1082
1235
  renderIssueEvidence(issue, evidenceSources, logger);
1083
1236
  }
@@ -1154,6 +1307,15 @@ function renderAuditFooter(results, logger) {
1154
1307
  * the failure direction `issue-rendering.ts` exists to prevent. The trailing
1155
1308
  * line names the collapsed total and both ways to see it, for the same reason.
1156
1309
  *
1310
+ * That trailing line points at THIS audit's YAML report, deliberately — do not
1311
+ * broaden it back into a claim about "the YAML report" in general. It is only
1312
+ * true of the verbs that emit full `issues:` arrays, and which verbs those are
1313
+ * has changed: `vat skills build` now publishes them too, so the sentence is
1314
+ * shared rather than re-spelled (see `formatCollapsedFindingsHint`, which takes
1315
+ * the report name for exactly this reason). Any verb that publishes counts alone
1316
+ * must still not use it — there, it would send a CI consumer to a document that
1317
+ * cannot answer the question.
1318
+ *
1157
1319
  * Pure: takes results, returns lines. It reads `issues` and never rewrites it,
1158
1320
  * because `runAuditAtPath` builds the YAML document from the very same array.
1159
1321
  *
@@ -1166,16 +1328,23 @@ export function formatAuditFindingsLines(results, root, verbose) {
1166
1328
  const rendered = issuesToRenderAtVerbosity(result.issues, verbose);
1167
1329
  // What --verbose WOULD show, minus what this verbosity shows. Derived from
1168
1330
  // the policy rather than re-stating it, so the count cannot contradict it.
1169
- collapsed += issuesToRenderAtVerbosity(result.issues, true).length - rendered.length;
1170
- lines.push(`\n${issueLocation(result.path, root)} — ${formatSeverityBreakdown(countBySeverity(result.issues))}:`);
1331
+ collapsed += countCollapsedFindings(result.issues, verbose);
1332
+ // The heading is unconditional and counts the WHOLE set — for a
1333
+ // warning-only file at default verbosity it IS the report. The trailing
1334
+ // colon is what varies: it introduces the blocks below, so a heading with
1335
+ // nothing beneath it does not print one. Same rule, same reason, as
1336
+ // `formatPostBuildIssueReport` in `skills/build.ts` — a colon promising a
1337
+ // list the current verbosity will not print is a promise the renderer
1338
+ // already knows it will break.
1339
+ const heading = `\n${findingSubject(result, root)} — ${formatSeverityBreakdown(countBySeverity(result.issues))}`;
1340
+ lines.push(rendered.length === 0 ? heading : `${heading}:`);
1171
1341
  for (const issue of rendered) {
1172
1342
  lines.push(...formatIssueLines(issue, ' '));
1173
1343
  }
1174
1344
  }
1175
- if (collapsed > 0) {
1176
- lines.push(`\n${collapsed} warning/info finding(s) not shown — re-run with --verbose, ` +
1177
- 'or read the YAML report on stdout, which always lists every finding.');
1178
- }
1345
+ const hint = formatCollapsedFindingsHint(collapsed, 'audit');
1346
+ if (hint !== undefined)
1347
+ lines.push(hint);
1179
1348
  return lines;
1180
1349
  }
1181
1350
  /**
@@ -1201,6 +1370,53 @@ function isExcludedPath(isMatch, relativePath, isDirectory) {
1201
1370
  }
1202
1371
  return isMatch(relativePath);
1203
1372
  }
1373
+ /**
1374
+ * Does the SKILL.md at `skillMdPath` own its own presence-side crawl, or has an
1375
+ * ancestor crawl already covered it?
1376
+ *
1377
+ * The single place the walk answers that question, for both owner kinds — see
1378
+ * {@link ScanContext.crawledRoot}. `isWithin` rather than a bare
1379
+ * `crawledRoot === null`: the claim is a containment claim and it is spelled
1380
+ * once, in the module that already got the separator right.
1381
+ */
1382
+ function ownsOwnCrawl(scanCtx, skillMdPath) {
1383
+ return scanCtx.crawledRoot === null || !isWithin(scanCtx.crawledRoot, safePath.resolve(skillMdPath));
1384
+ }
1385
+ /**
1386
+ * Hand the descending context an owner for everything below `owner`, unless one
1387
+ * is already claimed. First owner wins, and `null` claims nothing.
1388
+ */
1389
+ function claimSubtree(scanCtx, owner) {
1390
+ if (owner === null || scanCtx.crawledRoot !== null)
1391
+ return scanCtx;
1392
+ return { ...scanCtx, crawledRoot: safePath.resolve(owner) };
1393
+ }
1394
+ /**
1395
+ * The context this directory's SUBDIRECTORIES are walked with.
1396
+ *
1397
+ * A `SKILL.md` here means this directory's own crawl reaches every descendant at
1398
+ * any depth, so it — not each nested skill in turn — owns the subtree below.
1399
+ *
1400
+ * Resolved before the entry loop rather than inside it: `SKILL.md` and the
1401
+ * subdirectories it governs are siblings in one `readdir`, and their order is
1402
+ * the filesystem's to choose, so deciding this mid-loop would make the verdict
1403
+ * depend on it.
1404
+ *
1405
+ * Conditional on the crawl actually RUNNING ({@link crawlOwnsSubtree}), not on
1406
+ * the SKILL.md merely existing: a repo-source skill's crawl is skipped
1407
+ * entirely, so claiming its subtree would trade a double count for a missed
1408
+ * finding — and provenance is not monotone down a tree.
1409
+ */
1410
+ async function contextForDescendants(dirPath, entries, scanCtx) {
1411
+ if (!entries.some((entry) => entry.isFile() && entry.name === 'SKILL.md'))
1412
+ return scanCtx;
1413
+ const skillMd = safePath.join(dirPath, 'SKILL.md');
1414
+ if (!ownsOwnCrawl(scanCtx, skillMd))
1415
+ return scanCtx;
1416
+ if (!await crawlOwnsSubtree(skillMd))
1417
+ return scanCtx;
1418
+ return claimSubtree(scanCtx, dirPath);
1419
+ }
1204
1420
  /**
1205
1421
  * Handle file entry during directory scan
1206
1422
  */
@@ -1230,14 +1446,21 @@ async function handleFileEntry(entry, fullPath, options, logger, scanCtx) {
1230
1446
  ? { locationRoot, projectSkills }
1231
1447
  : { gitTracker: scanCtx.gitTracker, locationRoot, projectSkills };
1232
1448
  const packagingResult = await validateSkillForPackaging(fullPath, skillConfig, 'source', sharedCtx);
1233
- return packagingResultToValidationResult(fullPath, packagingResult, skillConfig.targets, locationRoot);
1449
+ const configAware = packagingResultToValidationResult(fullPath, packagingResult, skillConfig.targets, locationRoot);
1450
+ await appendDistributedTreeFindings(configAware, fullPath, locationRoot, ownsOwnCrawl(scanCtx, fullPath));
1451
+ return configAware;
1234
1452
  }
1235
1453
  // Wild mode: basic validation
1236
1454
  const validateOptions = { skillPath: fullPath, locationRoot };
1237
1455
  if (options.warnUnreferencedFiles) {
1238
1456
  validateOptions.checkUnreferencedFiles = true;
1239
1457
  }
1240
- return validateSkill(validateOptions);
1458
+ const result = await validateSkill(validateOptions);
1459
+ // Wild mode is where the distributed-tree question is even ASKED; the
1460
+ // classifier answers it. Suppressed under a plugin ancestor, which already
1461
+ // crawled this whole subtree at any depth.
1462
+ await appendDistributedTreeFindings(result, fullPath, locationRoot, ownsOwnCrawl(scanCtx, fullPath));
1463
+ return result;
1241
1464
  }
1242
1465
  return null;
1243
1466
  }
@@ -1258,9 +1481,14 @@ async function handleDirectoryEntry(fullPath, recursive, options, logger, baseDi
1258
1481
  appendInventoryParseErrors(result, inv, locationRoot);
1259
1482
  results.push(result);
1260
1483
  }
1261
- // Recurse into subdirectories (both plugin/marketplace dirs and regular dirs)
1484
+ // Recurse into subdirectories (both plugin/marketplace dirs and regular dirs).
1485
+ // Below a plugin, `validatePlugin` has already crawled the whole subtree for
1486
+ // agent-instruction files, so every crawl below must stand down or one file
1487
+ // is reported twice. See {@link ScanContext.crawledRoot} — first owner wins,
1488
+ // and it never clears on the way down.
1489
+ const childCtx = claimSubtree(scanCtx, hasClaudePlugin ? fullPath : null);
1262
1490
  if (recursive) {
1263
- const subResults = await scanDirectory(fullPath, recursive, options, logger, scanCtx, baseDir, nestedConfigLog);
1491
+ const subResults = await scanDirectory(fullPath, recursive, options, logger, childCtx, baseDir, nestedConfigLog);
1264
1492
  results.push(...subResults);
1265
1493
  }
1266
1494
  return results;
@@ -1311,8 +1539,6 @@ function resolveProjectExcludes(scanDir, logger) {
1311
1539
  }
1312
1540
  return { isMatch, base: projectRoot, label: 'excluded by config' };
1313
1541
  }
1314
- /** Cache of (gitRoot → initialized GitTracker) to avoid re-spawning ls-files. */
1315
- const gitTrackerCache = new Map();
1316
1542
  /**
1317
1543
  * Cache of (projectRoot → registry for the *inventory* link walk).
1318
1544
  *
@@ -1367,23 +1593,13 @@ async function pluginInventoryAt(dir) {
1367
1593
  * governing configs, or skill-discovery maps from a prior run.
1368
1594
  */
1369
1595
  export function resetAuditCaches() {
1370
- gitTrackerCache.clear();
1596
+ resetGitTrackerCache();
1371
1597
  resetPackagingRegistryCache();
1372
1598
  inventoryRegistryCache.clear();
1373
1599
  resetProjectRootCaches();
1374
1600
  resetLoadedConfigCache();
1375
1601
  resetSkillDiscoveryCache();
1376
1602
  }
1377
- async function getOrCreateGitTracker(gitRoot) {
1378
- const cached = gitTrackerCache.get(gitRoot);
1379
- if (cached !== undefined) {
1380
- return cached;
1381
- }
1382
- const tracker = new GitTracker(gitRoot);
1383
- await tracker.initialize();
1384
- gitTrackerCache.set(gitRoot, tracker);
1385
- return tracker;
1386
- }
1387
1603
  /**
1388
1604
  * Resolve the effective git root and build a per-repo {@link GitTracker} for a
1389
1605
  * scan. On the first call for a scan (no tracker yet), detects the git root
@@ -1398,15 +1614,15 @@ async function resolveScanContext(dirPath, locationRoot, logger) {
1398
1614
  const projectExcludes = resolveProjectExcludes(dirPath, logger);
1399
1615
  const detectedRoot = gitFindRoot(dirPath);
1400
1616
  if (detectedRoot === null) {
1401
- return { gitRoot: null, gitTracker: null, locationRoot, projectExcludes };
1617
+ return { gitRoot: null, gitTracker: null, locationRoot, projectExcludes, crawledRoot: null };
1402
1618
  }
1403
1619
  const tracker = await getOrCreateGitTracker(detectedRoot);
1404
1620
  // If the scan root itself is gitignored (e.g. user targeted `dist/`), disable
1405
1621
  // gitignore filtering so the user's explicit intent wins.
1406
1622
  if (tracker.isIgnoredByActiveSet(dirPath)) {
1407
- return { gitRoot: null, gitTracker: null, locationRoot, projectExcludes };
1623
+ return { gitRoot: null, gitTracker: null, locationRoot, projectExcludes, crawledRoot: null };
1408
1624
  }
1409
- return { gitRoot: detectedRoot, gitTracker: tracker, locationRoot, projectExcludes };
1625
+ return { gitRoot: detectedRoot, gitTracker: tracker, locationRoot, projectExcludes, crawledRoot: null };
1410
1626
  }
1411
1627
  /**
1412
1628
  * Build the gitignore exclusion map for a set of entry paths.
@@ -1487,6 +1703,7 @@ async function scanDirectory(dirPath, recursive, options, logger, scanCtx, baseD
1487
1703
  excludeMatchers.push(resolvedScanCtx.projectExcludes);
1488
1704
  }
1489
1705
  const entries = await fs.readdir(dirPath, { withFileTypes: true });
1706
+ const descendCtx = await contextForDescendants(dirPath, entries, resolvedScanCtx);
1490
1707
  // Build full paths and batch-check gitignore status
1491
1708
  const entryPaths = entries.map(e => safePath.join(dirPath, e.name));
1492
1709
  const gitIgnoredMap = buildGitIgnoreMap(entryPaths, resolvedScanCtx.gitTracker, options.includeArtifacts ?? false);
@@ -1504,7 +1721,9 @@ async function scanDirectory(dirPath, recursive, options, logger, scanCtx, baseD
1504
1721
  }
1505
1722
  }
1506
1723
  else if (entry.isDirectory()) {
1507
- const dirResults = await handleDirectoryEntry(fullPath, recursive, options, logger, resolvedBaseDir, resolvedScanCtx, resolvedNestedLog);
1724
+ // `descendCtx`, not `resolvedScanCtx`: this level's own skill (if any)
1725
+ // owns everything below, including this subdirectory.
1726
+ const dirResults = await handleDirectoryEntry(fullPath, recursive, options, logger, resolvedBaseDir, descendCtx, resolvedNestedLog);
1508
1727
  results.push(...dirResults);
1509
1728
  }
1510
1729
  }