vigiles 12.6.0 → 12.7.0

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.
@@ -9,6 +9,10 @@ exports.claudeCodeLayout = {
9
9
  settingsFormat: "json",
10
10
  instructionFile: "CLAUDE.md",
11
11
  surfaceDirs: ["skills", "agents", "commands"],
12
+ // A plain Claude Code USER keeps skills/agents/commands under `.claude/`, not at
13
+ // the repo root (that's the published-plugin shape). Read both so a normal repo
14
+ // isn't seen as an empty machine.
15
+ userSurfaceRoot: ".claude",
12
16
  skillDir: "skills",
13
17
  agentDir: "agents",
14
18
  commandDir: "commands",
package/dist/cli.js CHANGED
@@ -924,18 +924,49 @@ function verifyMarkdownModeRules(files, silent, config) {
924
924
  * --summary Print a single-line summary (for SessionStart hooks)
925
925
  * --json Print structured JSON report (for CI integration)
926
926
  */
927
+ /**
928
+ * The repo root that `sharedDirs` resolve against. The caller's cwd (where
929
+ * `.vigilesrc.json` lives) is used ONLY when the scan target is INSIDE it — e.g.
930
+ * `lint packages/foo` from the repo root, where the shared tree is an ancestor of
931
+ * the scoped subdir. When the target is NOT under cwd (`lint path/to/other-repo`),
932
+ * we resolve against the TARGET itself, so a foreign-repo lint never lets the
933
+ * caller's own files satisfy the target's bundled resources (scoped-lint integrity).
934
+ */
935
+ function sharedDirsRootFor(scanTarget) {
936
+ const cwd = process.cwd();
937
+ const target = (0, node_path_1.resolve)(scanTarget);
938
+ const rel = (0, node_path_1.relative)(cwd, target);
939
+ const underCwd = rel === "" || (!rel.startsWith("..") && !(0, node_path_1.isAbsolute)(rel));
940
+ return underCwd ? cwd : target;
941
+ }
927
942
  async function runLint(restArgs, flags, config) {
928
943
  const summary = flags.includes("--summary");
929
944
  const json = flags.includes("--json");
930
945
  const silent = summary || json;
931
946
  const files = findInstructionFiles(restArgs, config?.exclude);
947
+ // P0-2: scope the surface checks (subagent contracts, skill resources, MCP, …)
948
+ // to an explicit DIRECTORY target when one is given, instead of always scanning
949
+ // the whole working dir and reporting surfaces the user didn't point at. Only a
950
+ // SINGLE existing directory narrows; a file / several paths / none → cwd, so
951
+ // bare `vigiles lint` (the CI-common case) stays byte-identical. scanPlugin
952
+ // reads only under this root, so a surface outside it can never enter the report.
953
+ // Computed BEFORE the harness resolves so auto-detection keys on the TARGET, not
954
+ // cwd — `lint path/to/codex-repo` picks that repo's harness, not the caller's.
955
+ const positional = restArgs.filter((a) => !a.startsWith("--"));
956
+ const scanRoot = positional.length === 1 &&
957
+ (0, node_fs_1.existsSync)(positional[0]) &&
958
+ (0, node_fs_1.lstatSync)(positional[0]).isDirectory()
959
+ ? (0, node_path_1.resolve)(positional[0])
960
+ : process.cwd();
932
961
  // Resolve the active harness ONCE so the harness-specific checks below run
933
962
  // against the right adapter's dialect (tool/event catalogs) and surfaces —
934
963
  // not a hard-coded Claude Code default. A subagent-surface rule reports n/a
935
- // on a harness without subagents (Codex) rather than scanning nothing.
964
+ // on a harness without subagents (Codex) rather than scanning nothing. Detect
965
+ // against `scanRoot` (the target), so a scoped scan of another-harness repo
966
+ // uses that repo's layout/dialect.
936
967
  const harnessFlag = harnessFlagFrom(flags);
937
968
  const lintSelection = (0, adapter_registry_js_1.resolveHarnessSelection)({
938
- root: process.cwd(),
969
+ root: scanRoot,
939
970
  flag: harnessFlag,
940
971
  configHarness: (0, adapter_registry_js_1.normalizeHarnessList)(config?.harness),
941
972
  });
@@ -998,71 +1029,71 @@ async function runLint(restArgs, flags, config) {
998
1029
  // 7b. Untested-surface check — skills/agents/hooks shipping without a test or
999
1030
  // eval. Warning by default (a nudge, exit 0); set rules.untested-{skill,agent,
1000
1031
  // hook} to "error" to gate CI. See src/test-coverage.ts and docs/rules/.
1001
- const untested = checkUntestedSurfaces(config, silent, adapter);
1032
+ const untested = checkUntestedSurfaces(config, silent, adapter, scanRoot);
1002
1033
  // 7c. Subagent tool-contract check — cross-reference each subagent's `tools:`
1003
1034
  // rail against the harness catalog (the moat). n/a on a harness with no
1004
1035
  // subagents. Off by default unless a severity is configured; warning surfaces
1005
1036
  // a typo/never-available tool, error gates CI.
1006
- const toolContract = checkSubagentToolContracts(config, silent, adapter);
1037
+ const toolContract = checkSubagentToolContracts(config, silent, adapter, scanRoot);
1007
1038
  // 7d. Hook-event check — a hook registered under an event the harness doesn't
1008
1039
  // define never fires. High-precision (close typos only). Off unless configured.
1009
- const hookEvents = checkHookEvents(config, silent, adapter);
1040
+ const hookEvents = checkHookEvents(config, silent, adapter, scanRoot);
1010
1041
  // 7e. Subagent-frontmatter check — a subagent missing required frontmatter
1011
1042
  // (name + description) won't register. n/a on a harness with no subagents.
1012
- const frontmatter = checkFrontmatterSchema(config, silent, adapter);
1043
+ const frontmatter = checkFrontmatterSchema(config, silent, adapter, scanRoot);
1013
1044
  // 7f. MCP-config check — a declared MCP server with no command/url can't start.
1014
- const mcpConfig = checkMcpConfig(config, silent, adapter);
1045
+ const mcpConfig = checkMcpConfig(config, silent, adapter, scanRoot);
1015
1046
  // 7g. Skill-frontmatter — RECOMMEND explicit name/description on skills (a
1016
1047
  // reliable trigger surface). Best-practice nudge; skills load without it.
1017
- const skillFm = checkSkillFrontmatter(config, silent, adapter);
1048
+ const skillFm = checkSkillFrontmatter(config, silent, adapter, scanRoot);
1018
1049
  // 7h. MCP tool-resolution — an `mcp__server__tool` in a contract whose server
1019
1050
  // the plugin doesn't declare can't resolve (the MCP half of the tool moat).
1020
- const mcpToolResolves = checkMcpToolResolves(config, silent, adapter);
1051
+ const mcpToolResolves = checkMcpToolResolves(config, silent, adapter, scanRoot);
1021
1052
  // 7i. Hook-script existence — a hook command referencing a missing script file
1022
1053
  // never runs (matches Anthropic's own `claude plugin validate`).
1023
- const hookScripts = checkHookScriptExists(config, silent, adapter);
1054
+ const hookScripts = checkHookScriptExists(config, silent, adapter, scanRoot);
1024
1055
  // 7j. Disallowed-tools — a `disallowedTools:` block-list typo blocks nothing
1025
1056
  // (the deny-side mirror of subagent-tool-contract; close-typo only).
1026
- const disallowedTools = checkDisallowedTools(config, silent, adapter);
1057
+ const disallowedTools = checkDisallowedTools(config, silent, adapter, scanRoot);
1027
1058
  // 7k. Description-overlap — two model-invocable skills with near-identical
1028
1059
  // descriptions collide in the selector (deterministic NCD precision proxy).
1029
- const descriptionOverlap = checkDescriptionOverlap(config, silent, adapter);
1060
+ const descriptionOverlap = checkDescriptionOverlap(config, silent, adapter, scanRoot);
1030
1061
  // 7k². Skill-description-budget — a model-invocable skill whose description is
1031
1062
  // so long the trigger signal is buried (heuristic proxy; degrades recall +
1032
1063
  // precision). Generous 500-char budget; warn-tier, never gates.
1033
- const descriptionBudget = checkDescriptionBudget(config, silent, adapter);
1064
+ const descriptionBudget = checkDescriptionBudget(config, silent, adapter, scanRoot);
1034
1065
  // 7l. Frontmatter-valid — a `---` block that isn't valid YAML (warn; js-yaml is
1035
1066
  // stricter than some loaders, so verify before enforcing).
1036
- const frontmatterValid = checkFrontmatterValid(config, silent, adapter);
1067
+ const frontmatterValid = checkFrontmatterValid(config, silent, adapter, scanRoot);
1037
1068
  // 7m. MCP hook-target — a `type: mcp_tool` hook action that's incomplete or
1038
1069
  // targets an undeclared server (the moat applied to the hook surface).
1039
- const mcpHookTargets = checkMcpHookTargets(config, silent, adapter);
1070
+ const mcpHookTargets = checkMcpHookTargets(config, silent, adapter, scanRoot);
1040
1071
  // 7n. Prefer-compiled-hooks — ONE discovery nudge (not per-hook) toward
1041
1072
  // compiled `vigiles/hook` artifacts when hand-written hooks ship. Recommendation.
1042
- const preferCompiledHooks = checkPreferCompiledHooks(config, silent, adapter);
1073
+ const preferCompiledHooks = checkPreferCompiledHooks(config, silent, adapter, scanRoot);
1043
1074
  // 7o. Lethal-trifecta — a unit (subagent / model-invocable skill) whose tools
1044
1075
  // hold all three legs (read-private + ingest-untrusted + exfiltrate) is a
1045
1076
  // prompt-injection exfil path (Rule of Two). Capability SET-intersection.
1046
- const lethalTrifecta = checkLethalTrifecta(config, silent, adapter);
1077
+ const lethalTrifecta = checkLethalTrifecta(config, silent, adapter, scanRoot);
1047
1078
  // 7p. Skill-resource — a SKILL.md body referencing a bundled file that doesn't
1048
1079
  // exist on disk under the skill dir (the agent gets nothing). FP-safe.
1049
- const skillResources = checkSkillResourceResolves(config, silent, adapter);
1080
+ const skillResources = checkSkillResourceResolves(config, silent, adapter, scanRoot);
1050
1081
  // 7q. Skill-missing-fence — a SKILL.md opening with `name:`/`description:` but no
1051
1082
  // `---` fence loads as plain body (invisible — no name/description/trigger).
1052
- const skillFence = checkSkillMissingFence(config, silent, adapter);
1083
+ const skillFence = checkSkillMissingFence(config, silent, adapter, scanRoot);
1053
1084
  // 7r. Plugin-dir-layout — functional surface dirs (skills/agents/commands) nested
1054
1085
  // inside the `.claude-plugin/` manifest dir where the harness can't see them.
1055
- const pluginLayout = checkPluginDirLayout(config, silent, adapter);
1086
+ const pluginLayout = checkPluginDirLayout(config, silent, adapter, scanRoot);
1056
1087
  // 7s. Delegation-trifecta — a lethal trifecta that emerges across a delegation
1057
1088
  // edge (a subagent's own ∪ delegated-to capability) though no single unit trips it.
1058
- const delegationTrifecta = checkDelegationTrifecta(config, silent, adapter);
1089
+ const delegationTrifecta = checkDelegationTrifecta(config, silent, adapter, scanRoot);
1059
1090
  // 7t. Hook-block-ineffective — a hook that looks like it blocks but silently
1060
1091
  // doesn't (block decision on a non-blocking event, or the legacy `decision`
1061
1092
  // field on a permission-gated event). The #1 verified hook pain (#19009).
1062
- const hookBlock = checkHookBlockIneffective(config, silent, adapter);
1093
+ const hookBlock = checkHookBlockIneffective(config, silent, adapter, scanRoot);
1063
1094
  // 7u. Hook-matcher — a hook `matcher` that never fires (tool-name typo, or a
1064
1095
  // malformed/undeclared MCP form).
1065
- const hookMatcher = checkHookMatcher(config, silent, adapter);
1096
+ const hookMatcher = checkHookMatcher(config, silent, adapter, scanRoot);
1066
1097
  // 8. Validate vigiles builder calls inside markdown code blocks. Default
1067
1098
  // is to validate every ref; illustrative blocks opt out via
1068
1099
  // `<!-- vigiles:ignore -->` (single block) or
@@ -2456,7 +2487,7 @@ function checkIntegrityForFiles(files, severity, silent) {
2456
2487
  * "warn" prints but never fails CI; "error" fails (exit 2). Returns the raw
2457
2488
  * untested count plus the severity-gated error count.
2458
2489
  */
2459
- function checkUntestedSurfaces(config, silent, adapter) {
2490
+ function checkUntestedSurfaces(config, silent, adapter, scanRoot) {
2460
2491
  const rules = config?.rules;
2461
2492
  const skillSev = (0, types_js_1.ruleSeverity)(rules?.["untested-skill"]);
2462
2493
  const agentSev = (0, types_js_1.ruleSeverity)(rules?.["untested-subagent"]);
@@ -2472,7 +2503,7 @@ function checkUntestedSurfaces(config, silent, adapter) {
2472
2503
  ...(0, types_js_1.ruleOptions)(rules?.["untested-hook"]),
2473
2504
  };
2474
2505
  const report = (0, test_coverage_js_1.findUntestedSurfaces)({
2475
- basePath: process.cwd(),
2506
+ basePath: scanRoot,
2476
2507
  layout: adapter.layout,
2477
2508
  skills: skillSev !== false,
2478
2509
  agents: agentSev !== false,
@@ -2515,7 +2546,7 @@ function reportNotApplicable(check, surface, adapter, silent) {
2515
2546
  * (plugin/MCP-provided) is never a false alarm. Warning by default; set
2516
2547
  * `subagent-tool-contract: "error"` to gate CI. Returns the issue + error counts.
2517
2548
  */
2518
- function checkSubagentToolContracts(config, silent, adapter) {
2549
+ function checkSubagentToolContracts(config, silent, adapter, scanRoot) {
2519
2550
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["subagent-tool-contract"]);
2520
2551
  if (!sev)
2521
2552
  return { issues: 0, errors: 0 };
@@ -2528,7 +2559,7 @@ function checkSubagentToolContracts(config, silent, adapter) {
2528
2559
  // `agents/` path, so a harness with a different subagent dir Just Works.
2529
2560
  let agents;
2530
2561
  try {
2531
- agents = (0, scan_js_1.scanPlugin)(process.cwd(), adapter.layout, adapter.dialect).agents;
2562
+ agents = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).agents;
2532
2563
  }
2533
2564
  catch {
2534
2565
  return { issues: 0, errors: 0 };
@@ -2558,7 +2589,7 @@ function checkSubagentToolContracts(config, silent, adapter) {
2558
2589
  * `hookEventIssues` (the shared detector, high-precision: close typos only, never
2559
2590
  * a framework/custom event). Warning by default; "error" gates CI.
2560
2591
  */
2561
- function checkHookEvents(config, silent, adapter) {
2592
+ function checkHookEvents(config, silent, adapter, scanRoot) {
2562
2593
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["hook-events"]);
2563
2594
  if (!sev)
2564
2595
  return { issues: 0, errors: 0 };
@@ -2568,7 +2599,7 @@ function checkHookEvents(config, silent, adapter) {
2568
2599
  }
2569
2600
  let found;
2570
2601
  try {
2571
- found = (0, scan_js_1.scanPlugin)(process.cwd(), adapter.layout, adapter.dialect).hookEventIssues;
2602
+ found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).hookEventIssues;
2572
2603
  }
2573
2604
  catch {
2574
2605
  return { issues: 0, errors: 0 };
@@ -2589,7 +2620,7 @@ function checkHookEvents(config, silent, adapter) {
2589
2620
  * of a real one) — it silently falls back / is ignored. Reuses `scanPlugin`'s
2590
2621
  * `frontmatterIssues` + `frontmatterValueIssues`. Warning by default; "error" gates CI.
2591
2622
  */
2592
- function checkFrontmatterSchema(config, silent, adapter) {
2623
+ function checkFrontmatterSchema(config, silent, adapter, scanRoot) {
2593
2624
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["subagent-frontmatter"]);
2594
2625
  if (!sev)
2595
2626
  return { issues: 0, errors: 0 };
@@ -2599,7 +2630,7 @@ function checkFrontmatterSchema(config, silent, adapter) {
2599
2630
  }
2600
2631
  let found;
2601
2632
  try {
2602
- const r = (0, scan_js_1.scanPlugin)(process.cwd(), adapter.layout, adapter.dialect);
2633
+ const r = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect);
2603
2634
  found = [...r.frontmatterIssues, ...r.frontmatterValueIssues];
2604
2635
  }
2605
2636
  catch {
@@ -2622,13 +2653,13 @@ function checkFrontmatterSchema(config, silent, adapter) {
2622
2653
  * default; set "error" to enforce it on your own skills. Reuses `scanPlugin`'s
2623
2654
  * `skillMetaIssues`.
2624
2655
  */
2625
- function checkSkillFrontmatter(config, silent, adapter) {
2656
+ function checkSkillFrontmatter(config, silent, adapter, scanRoot) {
2626
2657
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["skill-frontmatter"]);
2627
2658
  if (!sev)
2628
2659
  return { issues: 0, errors: 0 };
2629
2660
  let found;
2630
2661
  try {
2631
- found = (0, scan_js_1.scanPlugin)(process.cwd(), adapter.layout, adapter.dialect).skillMetaIssues;
2662
+ found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).skillMetaIssues;
2632
2663
  }
2633
2664
  catch {
2634
2665
  return { issues: 0, errors: 0 };
@@ -2649,13 +2680,13 @@ function checkSkillFrontmatter(config, silent, adapter) {
2649
2680
  * lane stays first-class — so it fires once and the message links the guide.
2650
2681
  * Reuses `scanPlugin`'s `manualHookCount` (one-detector-no-drift).
2651
2682
  */
2652
- function checkPreferCompiledHooks(config, silent, adapter) {
2683
+ function checkPreferCompiledHooks(config, silent, adapter, scanRoot) {
2653
2684
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["prefer-compiled-hooks"]);
2654
2685
  if (!sev)
2655
2686
  return { issues: 0, errors: 0 };
2656
2687
  let count;
2657
2688
  try {
2658
- count = (0, scan_js_1.scanPlugin)(process.cwd(), adapter.layout, adapter.dialect).manualHookCount;
2689
+ count = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).manualHookCount;
2659
2690
  }
2660
2691
  catch {
2661
2692
  return { issues: 0, errors: 0 };
@@ -2675,13 +2706,13 @@ function checkPreferCompiledHooks(config, silent, adapter) {
2675
2706
  * (stdio) nor a `url` (http/sse) can't start. Reuses `scanPlugin`'s `mcpIssues`.
2676
2707
  * Warning by default; "error" gates CI.
2677
2708
  */
2678
- function checkMcpConfig(config, silent, adapter) {
2709
+ function checkMcpConfig(config, silent, adapter, scanRoot) {
2679
2710
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["mcp-config"]);
2680
2711
  if (!sev)
2681
2712
  return { issues: 0, errors: 0 };
2682
2713
  let found;
2683
2714
  try {
2684
- found = (0, scan_js_1.scanPlugin)(process.cwd(), adapter.layout, adapter.dialect).mcpIssues;
2715
+ found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).mcpIssues;
2685
2716
  }
2686
2717
  catch {
2687
2718
  return { issues: 0, errors: 0 };
@@ -2702,7 +2733,7 @@ function checkMcpConfig(config, silent, adapter) {
2702
2733
  * `disallowedToolIssues` (close-typo only — high-precision). Warning by default;
2703
2734
  * "error" gates CI.
2704
2735
  */
2705
- function checkDisallowedTools(config, silent, adapter) {
2736
+ function checkDisallowedTools(config, silent, adapter, scanRoot) {
2706
2737
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["disallowed-tools-contract"]);
2707
2738
  if (!sev)
2708
2739
  return { issues: 0, errors: 0 };
@@ -2712,7 +2743,7 @@ function checkDisallowedTools(config, silent, adapter) {
2712
2743
  }
2713
2744
  let found;
2714
2745
  try {
2715
- found = (0, scan_js_1.scanPlugin)(process.cwd(), adapter.layout, adapter.dialect).agents.flatMap((a) => a.disallowedToolIssues.map((i) => ({ message: i.message, path: a.path })));
2746
+ found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).agents.flatMap((a) => a.disallowedToolIssues.map((i) => ({ message: i.message, path: a.path })));
2716
2747
  }
2717
2748
  catch {
2718
2749
  return { issues: 0, errors: 0 };
@@ -2734,13 +2765,13 @@ function checkDisallowedTools(config, silent, adapter) {
2734
2765
  * colon / `<example>` is flagged though it may still load — hence WARN by default
2735
2766
  * (verify before setting "error").
2736
2767
  */
2737
- function checkFrontmatterValid(config, silent, adapter) {
2768
+ function checkFrontmatterValid(config, silent, adapter, scanRoot) {
2738
2769
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["frontmatter-valid"]);
2739
2770
  if (!sev)
2740
2771
  return { issues: 0, errors: 0 };
2741
2772
  let found;
2742
2773
  try {
2743
- found = (0, scan_js_1.scanPlugin)(process.cwd(), adapter.layout, adapter.dialect).malformedFrontmatter;
2774
+ found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).malformedFrontmatter;
2744
2775
  }
2745
2776
  catch {
2746
2777
  return { issues: 0, errors: 0 };
@@ -2761,13 +2792,13 @@ function checkFrontmatterValid(config, silent, adapter) {
2761
2792
  * `scanPlugin`'s `descriptionOverlaps` (calibrated FP-safe: only basically
2762
2793
  * identical text). Warning by default; "error" gates CI.
2763
2794
  */
2764
- function checkDescriptionOverlap(config, silent, adapter) {
2795
+ function checkDescriptionOverlap(config, silent, adapter, scanRoot) {
2765
2796
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["description-overlap"]);
2766
2797
  if (!sev)
2767
2798
  return { issues: 0, errors: 0 };
2768
2799
  let found;
2769
2800
  try {
2770
- found = (0, scan_js_1.scanPlugin)(process.cwd(), adapter.layout, adapter.dialect).descriptionOverlaps;
2801
+ found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).descriptionOverlaps;
2771
2802
  }
2772
2803
  catch {
2773
2804
  return { issues: 0, errors: 0 };
@@ -2788,13 +2819,13 @@ function checkDescriptionOverlap(config, silent, adapter) {
2788
2819
  * deterministic heuristic proxy (generous 500-char budget). Reuses `scanPlugin`'s
2789
2820
  * `descriptionBudgetIssues`. Warning by default; "error" gates CI.
2790
2821
  */
2791
- function checkDescriptionBudget(config, silent, adapter) {
2822
+ function checkDescriptionBudget(config, silent, adapter, scanRoot) {
2792
2823
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["skill-description-budget"]);
2793
2824
  if (!sev)
2794
2825
  return { issues: 0, errors: 0 };
2795
2826
  let found;
2796
2827
  try {
2797
- found = (0, scan_js_1.scanPlugin)(process.cwd(), adapter.layout, adapter.dialect).descriptionBudgetIssues;
2828
+ found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).descriptionBudgetIssues;
2798
2829
  }
2799
2830
  catch {
2800
2831
  return { issues: 0, errors: 0 };
@@ -2817,13 +2848,13 @@ function checkDescriptionBudget(config, silent, adapter) {
2817
2848
  * and skills, so it is NOT gated on the `subagents` capability — a skill-only
2818
2849
  * harness still has the surface.
2819
2850
  */
2820
- function checkLethalTrifecta(config, silent, adapter) {
2851
+ function checkLethalTrifecta(config, silent, adapter, scanRoot) {
2821
2852
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["lethal-trifecta"]);
2822
2853
  if (!sev)
2823
2854
  return { issues: 0, errors: 0 };
2824
2855
  let found;
2825
2856
  try {
2826
- found = (0, scan_js_1.scanPlugin)(process.cwd(), adapter.layout, adapter.dialect).trifectaFindings;
2857
+ found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).trifectaFindings;
2827
2858
  }
2828
2859
  catch {
2829
2860
  return { issues: 0, errors: 0 };
@@ -2845,13 +2876,18 @@ function checkLethalTrifecta(config, silent, adapter) {
2845
2876
  * nothing. Reuses `scanPlugin`'s `skillResourceIssues` (high-precision / FP-safe,
2846
2877
  * one detector, no drift). Warning by default; "error" gates CI.
2847
2878
  */
2848
- function checkSkillResourceResolves(config, silent, adapter) {
2879
+ function checkSkillResourceResolves(config, silent, adapter, scanRoot) {
2849
2880
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["skill-resource-resolves"]);
2850
2881
  if (!sev)
2851
2882
  return { issues: 0, errors: 0 };
2852
2883
  let found;
2853
2884
  try {
2854
- found = (0, scan_js_1.scanPlugin)(process.cwd(), adapter.layout, adapter.dialect).skillResourceIssues;
2885
+ found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect, {
2886
+ sharedDirs: config?.sharedDirs,
2887
+ // sharedDirs live at the repo root that OWNS the scan target — cwd for a
2888
+ // scoped subdir of this repo, the target itself for a foreign-repo lint.
2889
+ sharedDirsRoot: sharedDirsRootFor(scanRoot),
2890
+ }).skillResourceIssues;
2855
2891
  }
2856
2892
  catch {
2857
2893
  return { issues: 0, errors: 0 };
@@ -2873,13 +2909,13 @@ function checkSkillResourceResolves(config, silent, adapter) {
2873
2909
  * Reuses `scanPlugin`'s `skillFenceIssues` (one detector, no drift). Warning by
2874
2910
  * default; "error" gates CI.
2875
2911
  */
2876
- function checkSkillMissingFence(config, silent, adapter) {
2912
+ function checkSkillMissingFence(config, silent, adapter, scanRoot) {
2877
2913
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["skill-missing-fence"]);
2878
2914
  if (!sev)
2879
2915
  return { issues: 0, errors: 0 };
2880
2916
  let found;
2881
2917
  try {
2882
- found = (0, scan_js_1.scanPlugin)(process.cwd(), adapter.layout, adapter.dialect).skillFenceIssues;
2918
+ found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).skillFenceIssues;
2883
2919
  }
2884
2920
  catch {
2885
2921
  return { issues: 0, errors: 0 };
@@ -2901,13 +2937,13 @@ function checkSkillMissingFence(config, silent, adapter) {
2901
2937
  * `pluginLayoutIssues` (one detector, no drift). Warning by default; "error"
2902
2938
  * gates CI.
2903
2939
  */
2904
- function checkPluginDirLayout(config, silent, adapter) {
2940
+ function checkPluginDirLayout(config, silent, adapter, scanRoot) {
2905
2941
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["plugin-dir-layout"]);
2906
2942
  if (!sev)
2907
2943
  return { issues: 0, errors: 0 };
2908
2944
  let found;
2909
2945
  try {
2910
- found = (0, scan_js_1.scanPlugin)(process.cwd(), adapter.layout, adapter.dialect).pluginLayoutIssues;
2946
+ found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).pluginLayoutIssues;
2911
2947
  }
2912
2948
  catch {
2913
2949
  return { issues: 0, errors: 0 };
@@ -2929,13 +2965,13 @@ function checkPluginDirLayout(config, silent, adapter) {
2929
2965
  * gates CI. Surfaces across the subagent graph, so it is NOT gated on a
2930
2966
  * capability the way a surface-specific rule is.
2931
2967
  */
2932
- function checkDelegationTrifecta(config, silent, adapter) {
2968
+ function checkDelegationTrifecta(config, silent, adapter, scanRoot) {
2933
2969
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["delegation-trifecta"]);
2934
2970
  if (!sev)
2935
2971
  return { issues: 0, errors: 0 };
2936
2972
  let found;
2937
2973
  try {
2938
- found = (0, scan_js_1.scanPlugin)(process.cwd(), adapter.layout, adapter.dialect).delegationTrifecta;
2974
+ found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).delegationTrifecta;
2939
2975
  }
2940
2976
  catch {
2941
2977
  return { issues: 0, errors: 0 };
@@ -2957,7 +2993,7 @@ function checkDelegationTrifecta(config, silent, adapter) {
2957
2993
  * permission-gated event (#19009, the #1 verified hook pain). Reuses `scanPlugin`'s
2958
2994
  * `hookBlockFindings` (one detector, no drift). Warning by default; "error" gates CI.
2959
2995
  */
2960
- function checkHookBlockIneffective(config, silent, adapter) {
2996
+ function checkHookBlockIneffective(config, silent, adapter, scanRoot) {
2961
2997
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["hook-block-ineffective"]);
2962
2998
  if (!sev)
2963
2999
  return { issues: 0, errors: 0 };
@@ -2967,7 +3003,7 @@ function checkHookBlockIneffective(config, silent, adapter) {
2967
3003
  }
2968
3004
  let found;
2969
3005
  try {
2970
- found = (0, scan_js_1.scanPlugin)(process.cwd(), adapter.layout, adapter.dialect).hookBlockFindings;
3006
+ found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).hookBlockFindings;
2971
3007
  }
2972
3008
  catch {
2973
3009
  return { issues: 0, errors: 0 };
@@ -2989,7 +3025,7 @@ function checkHookBlockIneffective(config, silent, adapter) {
2989
3025
  * Reuses `scanPlugin`'s `hookMatcherFindings` (one detector, no drift). Warning
2990
3026
  * by default; "error" gates CI.
2991
3027
  */
2992
- function checkHookMatcher(config, silent, adapter) {
3028
+ function checkHookMatcher(config, silent, adapter, scanRoot) {
2993
3029
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["hook-matcher"]);
2994
3030
  if (!sev)
2995
3031
  return { issues: 0, errors: 0 };
@@ -2999,7 +3035,7 @@ function checkHookMatcher(config, silent, adapter) {
2999
3035
  }
3000
3036
  let found;
3001
3037
  try {
3002
- found = (0, scan_js_1.scanPlugin)(process.cwd(), adapter.layout, adapter.dialect).hookMatcherFindings;
3038
+ found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).hookMatcherFindings;
3003
3039
  }
3004
3040
  catch {
3005
3041
  return { issues: 0, errors: 0 };
@@ -3020,7 +3056,7 @@ function checkHookMatcher(config, silent, adapter) {
3020
3056
  * `mcpHookIssues` (high-precision: declared-set gated, built-ins allowlisted).
3021
3057
  * Warning by default; "error" gates CI.
3022
3058
  */
3023
- function checkMcpHookTargets(config, silent, adapter) {
3059
+ function checkMcpHookTargets(config, silent, adapter, scanRoot) {
3024
3060
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["mcp-hook-target-resolves"]);
3025
3061
  if (!sev)
3026
3062
  return { issues: 0, errors: 0 };
@@ -3030,7 +3066,7 @@ function checkMcpHookTargets(config, silent, adapter) {
3030
3066
  }
3031
3067
  let found;
3032
3068
  try {
3033
- found = (0, scan_js_1.scanPlugin)(process.cwd(), adapter.layout, adapter.dialect).mcpHookIssues;
3069
+ found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).mcpHookIssues;
3034
3070
  }
3035
3071
  catch {
3036
3072
  return { issues: 0, errors: 0 };
@@ -3052,7 +3088,7 @@ function checkMcpHookTargets(config, silent, adapter) {
3052
3088
  * existence-guarded one-liners, inline commands). Matches Anthropic's own
3053
3089
  * `claude plugin validate`. Warning by default; "error" gates CI.
3054
3090
  */
3055
- function checkHookScriptExists(config, silent, adapter) {
3091
+ function checkHookScriptExists(config, silent, adapter, scanRoot) {
3056
3092
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["hook-script-exists"]);
3057
3093
  if (!sev)
3058
3094
  return { issues: 0, errors: 0 };
@@ -3062,7 +3098,7 @@ function checkHookScriptExists(config, silent, adapter) {
3062
3098
  }
3063
3099
  let missing;
3064
3100
  try {
3065
- missing = (0, scan_js_1.scanPlugin)(process.cwd(), adapter.layout, adapter.dialect).hooks.filter((h) => h.status === "missing");
3101
+ missing = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).hooks.filter((h) => h.status === "missing");
3066
3102
  }
3067
3103
  catch {
3068
3104
  return { issues: 0, errors: 0 };
@@ -3087,7 +3123,7 @@ function checkHookScriptExists(config, silent, adapter) {
3087
3123
  * — high-precision (gated on a declared set, built-ins allowlisted, the
3088
3124
  * plugin-namespaced form skipped). Warning by default; "error" gates CI.
3089
3125
  */
3090
- function checkMcpToolResolves(config, silent, adapter) {
3126
+ function checkMcpToolResolves(config, silent, adapter, scanRoot) {
3091
3127
  const sev = (0, types_js_1.ruleSeverity)(config?.rules?.["mcp-tool-resolves"]);
3092
3128
  if (!sev)
3093
3129
  return { issues: 0, errors: 0 };
@@ -3097,7 +3133,7 @@ function checkMcpToolResolves(config, silent, adapter) {
3097
3133
  }
3098
3134
  let found;
3099
3135
  try {
3100
- found = (0, scan_js_1.scanPlugin)(process.cwd(), adapter.layout, adapter.dialect).agents.flatMap((a) => a.mcpToolIssues.map((i) => ({ message: i.message, path: a.path })));
3136
+ found = (0, scan_js_1.scanPlugin)(scanRoot, adapter.layout, adapter.dialect).agents.flatMap((a) => a.mcpToolIssues.map((i) => ({ message: i.message, path: a.path })));
3101
3137
  }
3102
3138
  catch {
3103
3139
  return { issues: 0, errors: 0 };
@@ -5038,7 +5074,10 @@ async function main() {
5038
5074
  const adapter = harnessFlag
5039
5075
  ? (0, adapter_registry_js_1.resolveAdapter)(root, harnessFlag)
5040
5076
  : det.adapter;
5041
- const report = (0, scan_js_1.scanPlugin)(targets[0], adapter.layout, adapter.dialect);
5077
+ const report = (0, scan_js_1.scanPlugin)(targets[0], adapter.layout, adapter.dialect, {
5078
+ sharedDirs: config.sharedDirs,
5079
+ sharedDirsRoot: sharedDirsRootFor(targets[0]),
5080
+ });
5042
5081
  if (!json) {
5043
5082
  console.log(`Detected harness: ${adapter.name}`);
5044
5083
  if (!harnessFlag && det.ambiguousWith.length > 0) {
@@ -29,6 +29,17 @@ export interface PluginLayout {
29
29
  readonly instructionFile: string;
30
30
  /** Surface dirs materialized into the sandbox, e.g. skills/agents/commands. */
31
31
  readonly surfaceDirs: readonly string[];
32
+ /**
33
+ * Project-level dir under which an END-USER (not a plugin author) keeps the
34
+ * same surfaces, e.g. `.claude` → `.claude/skills`, `.claude/agents`. When set,
35
+ * the loader reads each surface from BOTH `<root>/<surface>` (the plugin /
36
+ * skills-library shape) AND `<root>/<userSurfaceRoot>/<surface>` (the shape a
37
+ * plain Claude Code user has), normalizing to the same materialized key. Most
38
+ * Claude Code users are NOT publishing a plugin — their skills live here, so
39
+ * without this the loader would see an empty machine for a normal repo.
40
+ * Undefined ⇒ only the primary location is read (backwards-compatible).
41
+ */
42
+ readonly userSurfaceRoot?: string;
32
43
  /** Skills dir, holding the nested `<dir>/<name>/SKILL.md`, e.g. `skills`. */
33
44
  readonly skillDir: string;
34
45
  /** Subagents dir, holding flat `<dir>/<name>.md`, e.g. `agents` (`""` = none). */
@@ -14,6 +14,22 @@ export interface SkillResourceFinding {
14
14
  export interface SkillResourceOptions {
15
15
  /** Injectable existence check (default: node:fs existsSync). */
16
16
  readonly existsSync?: (p: string) => boolean;
17
+ /**
18
+ * Repo root, used only together with `sharedDirs` (below). Off by default.
19
+ */
20
+ readonly repoRoot?: string;
21
+ /**
22
+ * OPT-IN shared-resource dirs — top-level dir names a repo shares across skills
23
+ * (`.vigilesrc.json` `sharedDirs`, e.g. `["scripts", "references"]`). Many skill
24
+ * libraries keep ONE top-level `scripts/` tree instead of a copy beside every
25
+ * SKILL.md, so a ref like `scripts/promptfoo/x.py` lives at the repo root. When
26
+ * a ref's FIRST path segment is a declared shared dir, it may ALSO resolve
27
+ * against `repoRoot`. Scoped to declared dirs on PURPOSE: a repo that sets no
28
+ * `sharedDirs` is byte-identical to before (skill-dir-only), and even with it a
29
+ * ref OUTSIDE a shared dir still can't be masked by a same-named repo-root file.
30
+ * The controlled fix for feedback P1-4 (opt-in, never a default behavior change).
31
+ */
32
+ readonly sharedDirs?: readonly string[];
17
33
  }
18
34
  /**
19
35
  * The bundled-resource references in a SKILL.md body that don't resolve on disk
@@ -72,11 +72,28 @@ function localResourceTarget(rawTarget) {
72
72
  // and almost always a plugin-root or runtime path, not a bundled file.
73
73
  if (target.includes("$"))
74
74
  return null;
75
- // Drop a URL fragment / query suffix so `references/api.md#auth` resolves to
76
- // the file. (Only after the scheme check above, so we never mangle a URL.)
75
+ // SKIP: `~/`-rooted paths the user's home / machine-global config, referenced
76
+ // intentionally from OUTSIDE the repo (JIT routing like `Read ~/.claude/docs/x.md`).
77
+ // Not a repo-bundled resource; unverifiable from the repo and never a "broken ref".
78
+ if (target === "~" || target.startsWith("~/"))
79
+ return null;
80
+ // Drop a URL fragment / query suffix so `references/api.md#auth` AND
81
+ // `references/schema.json?raw=1` resolve to the file. Done BEFORE the glob skip
82
+ // below so a legitimate `?query` suffix on a real bundled ref isn't mistaken for
83
+ // a glob `?` and wrongly skipped — the file must still be checked. (Only after
84
+ // the scheme check above, so we never mangle a URL.)
77
85
  const path = target.replace(/[?#].*$/, "");
78
86
  if (path.length === 0)
79
87
  return null;
88
+ // SKIP: globs and template placeholders — a ref carrying a glob metacharacter
89
+ // (`*`) or a brace/angle-bracket placeholder (`{trivial,…}`, `<linter>`) is a
90
+ // directory CONVENTION or an example, not a concrete file (`references/*.md`,
91
+ // `references/linter-cards/{a,b}/<linter>.md`). Resolving it as a literal path and
92
+ // reporting "missing" is a false positive (feedback P1-3). `?` is intentionally
93
+ // NOT in this class — it's the query separator stripped above; a genuine `?`-glob
94
+ // truncates to an extensionless path and is dropped by HAS_EXT below anyway.
95
+ if (/[*{}<>]/.test(path))
96
+ return null;
80
97
  // SKIP: a `../` escape OUT of the skill dir — undecidable / not a bundled
81
98
  // resource (it points at a sibling skill or the repo). A leading `./` is fine.
82
99
  const normalized = path.replace(/^\.\//, "");
@@ -134,6 +151,18 @@ function candidatesInLine(line, lineNo) {
134
151
  */
135
152
  function skillResourceIssues(skillBody, skillDir, opts = {}) {
136
153
  const exists = opts.existsSync ?? node_fs_1.existsSync;
154
+ const sharedDirs = new Set(opts.sharedDirs ?? []);
155
+ // A ref resolves if it exists under the skill's own dir. If (and only if) its
156
+ // first segment is a DECLARED shared dir, it may also resolve against the repo
157
+ // root — the opt-in shared-tree case. No shared dirs → skill-dir-only (unchanged).
158
+ const resolvesAnywhere = (rel) => {
159
+ if (exists((0, node_path_1.resolve)(skillDir, rel)))
160
+ return true;
161
+ const firstSeg = rel.split("/")[0];
162
+ return (opts.repoRoot !== undefined &&
163
+ sharedDirs.has(firstSeg) &&
164
+ exists((0, node_path_1.resolve)(opts.repoRoot, rel)));
165
+ };
137
166
  const findings = [];
138
167
  const seen = new Set();
139
168
  const lines = skillBody.split("\n");
@@ -146,8 +175,7 @@ function skillResourceIssues(skillBody, skillDir, opts = {}) {
146
175
  if (inFence)
147
176
  continue;
148
177
  for (const c of candidatesInLine(lines[i], i + 1)) {
149
- const full = (0, node_path_1.resolve)(skillDir, c.resolved);
150
- if (exists(full))
178
+ if (resolvesAnywhere(c.resolved))
151
179
  continue;
152
180
  // De-dupe the same missing file referenced several times in the body.
153
181
  const key = `${c.kind}:${c.resolved}`;
@@ -333,6 +333,18 @@ export interface VigilesConfig {
333
333
  * excluded.
334
334
  */
335
335
  exclude?: readonly string[];
336
+ /**
337
+ * Top-level dir names shared across skills, e.g. `["scripts", "references"]`.
338
+ * OPT-IN: many skill libraries keep ONE top-level `scripts/`/`references/` tree
339
+ * rather than a copy beside every `SKILL.md`, so a bundled ref like
340
+ * `scripts/promptfoo/x.py` lives at the REPO ROOT. When a `SKILL.md` body ref's
341
+ * first path segment is a declared shared dir, `skill-resource-resolves` / audit
342
+ * ALSO resolves it against the repo root, not only the skill's own dir. Scoped
343
+ * to declared dirs on purpose: a repo that omits this key behaves exactly as
344
+ * before (skill-dir-only resolution), and a ref outside a shared dir is never
345
+ * masked by a same-named repo-root file. See feedback P1-4.
346
+ */
347
+ sharedDirs?: readonly string[];
336
348
  /**
337
349
  * The harness(es) this repo targets — selects the compile dialect / skill
338
350
  * frontmatter profile / instruction-file shape, instead of sniffing the cwd.
@@ -14,6 +14,15 @@ export interface LoadedPlugin {
14
14
  * read it in a test, or just to know what the deterministic run won't reach.
15
15
  */
16
16
  readonly warnings: readonly string[];
17
+ /**
18
+ * Map from a materialized `files` key to the ABSOLUTE on-disk path it was read
19
+ * from. A surface can be materialized under a canonical key (`.claude/skills/
20
+ * foo/SKILL.md`) while living on disk at a different root (the repo-root
21
+ * `skills/` OR the project-level `.claude/skills/`), so a consumer that needs
22
+ * the real dir (e.g. resolving a skill's bundled resources) must reverse-map
23
+ * through this instead of guessing from the key. Present for every surface file.
24
+ */
25
+ readonly sources: Record<string, string>;
17
26
  }
18
27
  /**
19
28
  * Load the real harness at `pluginPath`. Returns the resolved settings (hooks),
@@ -36,6 +36,7 @@ exports.resolveHarness = resolveHarness;
36
36
  const node_fs_1 = require("node:fs");
37
37
  const node_path_1 = require("node:path");
38
38
  const toml_1 = require("@iarna/toml");
39
+ const hash_js_1 = require("./core/hash.js");
39
40
  const MAX_SKILL_FILE_BYTES = 256 * 1024;
40
41
  /** Parse a JSON file, or null on any error (missing / malformed). */
41
42
  function safeReadJson(path) {
@@ -140,32 +141,108 @@ function loadPlugin(pluginPath, layout) {
140
141
  ? JSON.parse(JSON.stringify(hooks).replaceAll(layout.pluginRootToken, root))
141
142
  : undefined;
142
143
  const files = {};
144
+ const sources = {};
143
145
  const instructions = (0, node_path_1.join)(root, layout.instructionFile);
144
146
  if ((0, node_fs_1.existsSync)(instructions)) {
145
147
  files[layout.instructionFile] = (0, node_fs_1.readFileSync)(instructions, "utf-8");
148
+ sources[layout.instructionFile] = instructions;
146
149
  }
147
- // Materialize each project-level surface under the materialize root so the
148
- // assembled context is present in the sandbox (best-effort — headless
149
- // activation of plugin skills/subagents/commands is not guaranteed; the body
150
- // is present for the agent to read either way). Counting what we materialize
151
- // also lets us warn about surfaces the deterministic tier can't drive.
152
- const counts = {};
153
- for (const surface of layout.surfaceDirs) {
154
- const dir = (0, node_path_1.join)(root, surface);
155
- if (!(0, node_fs_1.existsSync)(dir) || !(0, node_fs_1.statSync)(dir).isDirectory())
156
- continue;
157
- const tree = readTree(dir, root);
158
- for (const [rel, content] of Object.entries(tree)) {
159
- files[(0, node_path_1.join)(layout.materializeRoot, rel)] = content;
160
- }
161
- counts[surface] = Object.keys(tree).length;
162
- }
150
+ const counts = materializeSurfaces(root, layout, files, sources);
163
151
  return {
164
152
  settings: resolvedHooks ? { hooks: resolvedHooks } : {},
165
153
  files,
154
+ sources,
166
155
  warnings: pluginWarnings(root, counts, resolvedHooks, files, layout),
167
156
  };
168
157
  }
158
+ /**
159
+ * A surface holds a LOADABLE file — a `<name>/SKILL.md` for skills, a `.md` for
160
+ * agents/commands. A stray non-surface file (`skills/README.md`, `.gitkeep`) does
161
+ * NOT count, else it would mark the root populated and shadow a plain user's real
162
+ * `.claude/skills`.
163
+ */
164
+ function surfaceHasLoadable(layout, surface, tree) {
165
+ const keys = Object.keys(tree);
166
+ return surface === layout.skillDir
167
+ ? keys.some((k) => (0, node_path_1.basename)(k) === "SKILL.md")
168
+ : keys.some((k) => k.endsWith(".md"));
169
+ }
170
+ /**
171
+ * Classify the repo shape from disk, with EXPLICIT precedence:
172
+ * 1. a `<root>/SKILL.md` → the target IS one skill dir (single-skill).
173
+ * 2. any root-surface with LOADABLE content, OR a plugin manifest / hooks
174
+ * convention → read the ROOT surfaces. A plugin ships from its manifest even
175
+ * with no root surface dirs, so its dev `.claude/…` is never a fallback.
176
+ * 3. else, if the layout declares a user-surface root → a plain user repo.
177
+ * 4. else → nothing loadable.
178
+ * Pure over the pre-read `rootTrees` + a few existence checks — one place to test.
179
+ */
180
+ function classifySurfaceSource(root, layout, rootTrees) {
181
+ if (layout.skillDir && (0, node_fs_1.existsSync)((0, node_path_1.join)(root, "SKILL.md"))) {
182
+ return { kind: "single-skill", skillName: (0, node_path_1.basename)(root) };
183
+ }
184
+ const rootHasLoadable = layout.surfaceDirs.some((s) => surfaceHasLoadable(layout, s, rootTrees.get(s) ?? {}));
185
+ const isPluginShaped = (0, node_fs_1.existsSync)((0, node_path_1.join)(root, layout.manifestPath)) ||
186
+ (0, node_fs_1.existsSync)((0, node_path_1.join)(root, layout.hooksConventionPath));
187
+ if (rootHasLoadable || isPluginShaped)
188
+ return { kind: "root" };
189
+ if (layout.userSurfaceRoot !== undefined) {
190
+ return { kind: "user", sub: layout.userSurfaceRoot };
191
+ }
192
+ return { kind: "none" };
193
+ }
194
+ function materializeSurfaces(root, layout, files, sources) {
195
+ const counts = {};
196
+ const isDir = (p) => (0, node_fs_1.existsSync)(p) && (0, node_fs_1.statSync)(p).isDirectory();
197
+ // Read each ROOT-level surface tree once (keys relative to the surface dir).
198
+ const rootTrees = new Map();
199
+ for (const surface of layout.surfaceDirs) {
200
+ const dir = (0, node_path_1.join)(root, surface);
201
+ if (isDir(dir))
202
+ rootTrees.set(surface, readTree(dir, dir));
203
+ }
204
+ const add = (key, content, onDisk) => {
205
+ files[key] = content;
206
+ sources[key] = onDisk;
207
+ };
208
+ const source = classifySurfaceSource(root, layout, rootTrees);
209
+ switch (source.kind) {
210
+ case "single-skill": {
211
+ // Materialize the WHOLE skill dir under the canonical skills key, so its
212
+ // bundled resources (scripts/references/assets) ship too, not just SKILL.md.
213
+ const tree = readTree(root, root);
214
+ for (const [rel, content] of Object.entries(tree)) {
215
+ add((0, node_path_1.join)(layout.materializeRoot, layout.skillDir, source.skillName, rel), content, (0, node_path_1.join)(root, rel));
216
+ }
217
+ counts[layout.skillDir] = Object.keys(tree).length;
218
+ break;
219
+ }
220
+ case "root":
221
+ case "user": {
222
+ const base = source.kind === "user" ? (0, node_path_1.join)(root, source.sub) : root;
223
+ for (const surface of layout.surfaceDirs) {
224
+ const dir = (0, node_path_1.join)(base, surface);
225
+ // Root surfaces were pre-read; user surfaces are read fresh here.
226
+ const tree = source.kind === "root"
227
+ ? (rootTrees.get(surface) ?? {})
228
+ : isDir(dir)
229
+ ? readTree(dir, dir)
230
+ : {};
231
+ for (const [rel, content] of Object.entries(tree)) {
232
+ add((0, node_path_1.join)(layout.materializeRoot, surface, rel), content, (0, node_path_1.join)(dir, rel));
233
+ }
234
+ counts[surface] = Object.keys(tree).length;
235
+ }
236
+ break;
237
+ }
238
+ case "none":
239
+ break;
240
+ /* v8 ignore next 2 -- exhaustiveness guard, unreachable given SurfaceSource */
241
+ default:
242
+ (0, hash_js_1.assertNever)(source);
243
+ }
244
+ return counts;
245
+ }
169
246
  /**
170
247
  * Flag surfaces present-but-not-deterministically-exercisable. Subagents
171
248
  * (`agents/`) and slash commands (`commands/`) are materialized into the sandbox
package/dist/scan.d.ts CHANGED
@@ -301,7 +301,10 @@ export declare function isManagedHookCommand(command: string): boolean;
301
301
  /** The `prefer-compiled-hooks` recommendation message (shared by `lint` + `scan`). */
302
302
  export declare function preferCompiledHooksMessage(count: number): string;
303
303
  /** Scan a plugin/repo directory and report its surfaces + structural issues. */
304
- export declare function scanPlugin(dir: string, layout?: PluginLayout, dialect?: HarnessDialect): ScanReport;
304
+ export declare function scanPlugin(dir: string, layout?: PluginLayout, dialect?: HarnessDialect, opts?: {
305
+ sharedDirs?: readonly string[];
306
+ sharedDirsRoot?: string;
307
+ }): ScanReport;
305
308
  /**
306
309
  * LIVE MCP tool resolution for a scanned plugin — the dynamic check no static
307
310
  * linter can do: it STARTS each declared MCP server and checks every
package/dist/scan.js CHANGED
@@ -151,11 +151,18 @@ function onDiskPath(materializedKey, materializeRoot) {
151
151
  : materializedKey;
152
152
  }
153
153
  function scanSkills(files, cls, ctx) {
154
- const { root, materializeRoot, dialect } = ctx;
154
+ const { root, materializeRoot, dialect, sharedDirs } = ctx;
155
+ // `sharedDirs` are declared relative to the REPO root (config location), which
156
+ // is `root` for a whole-repo scan but a PARENT when the scan is scoped to a
157
+ // subdir. Resolve them against that, not the scoped subdir.
158
+ const sharedDirsRoot = ctx.sharedDirsRoot ?? root;
155
159
  const out = [];
156
160
  for (const [path, md] of Object.entries(files)) {
157
161
  if (!cls.isSkill(path))
158
162
  continue;
163
+ // Prefer the real on-disk dir (a `.claude/skills/…` skill materializes under
164
+ // the same canonical key as a repo-root one, but lives elsewhere on disk).
165
+ const onDiskDir = ctx.sources?.[path];
159
166
  const fm = frontmatter(md);
160
167
  // A skill's trigger surface is its frontmatter `description` OR — when that's
161
168
  // absent — Claude Code's fallback to the first body paragraph. Only when
@@ -166,8 +173,18 @@ function scanSkills(files, cls, ctx) {
166
173
  // Bundled-resource refs resolve against the skill's OWN dir (resources ship
167
174
  // beside the SKILL.md), built from the plugin root + the file's ON-DISK dir
168
175
  // (the materialize-root prefix the loader added is stripped back off).
169
- const skillDir = (0, node_path_1.resolve)(root, (0, node_path_1.dirname)(onDiskPath(path, materializeRoot)));
170
- const resourceIssues = (0, skill_resources_js_1.skillResourceIssues)(skillBody(md), skillDir);
176
+ const skillDir = onDiskDir
177
+ ? (0, node_path_1.dirname)(onDiskDir)
178
+ : (0, node_path_1.resolve)(root, (0, node_path_1.dirname)(onDiskPath(path, materializeRoot)));
179
+ // Bundled refs resolve against the skill's OWN dir. A repo that shares a
180
+ // top-level tree across skills (`sharedDirs` in .vigilesrc.json) ALSO resolves
181
+ // a ref under one of those declared dirs against the repo root — OPT-IN, so a
182
+ // repo that doesn't set it is byte-identical to before (no masking of a real
183
+ // missing bundled resource). See feedback P1-4.
184
+ const resourceIssues = (0, skill_resources_js_1.skillResourceIssues)(skillBody(md), skillDir, {
185
+ repoRoot: sharedDirsRoot,
186
+ sharedDirs,
187
+ });
171
188
  // The lethal trifecta is a property of what a unit CAN do, which for a skill is
172
189
  // its declared `allowed-tools` (the CC skill tool contract). Only a model-
173
190
  // invocable skill can be hijacked by attacker content, so a user-invoked one is
@@ -702,7 +719,7 @@ function summarizePurity(agents) {
702
719
  }, { pure: 0, bounded: 0, unrestricted: 0 });
703
720
  }
704
721
  /** Scan a plugin/repo directory and report its surfaces + structural issues. */
705
- function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect) {
722
+ function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect, opts = {}) {
706
723
  const lay = layout ?? layout_js_1.claudeCodeLayout;
707
724
  const cls = makeClassifier(lay);
708
725
  const loaded = (0, plugin_loader_js_1.loadPlugin)(dir, lay);
@@ -734,6 +751,9 @@ function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect) {
734
751
  root: (0, node_path_1.resolve)(dir),
735
752
  materializeRoot: lay.materializeRoot,
736
753
  dialect,
754
+ sources: loaded.sources,
755
+ sharedDirs: opts.sharedDirs,
756
+ sharedDirsRoot: opts.sharedDirsRoot,
737
757
  });
738
758
  const puritySummary = summarizePurity(agents);
739
759
  const { trifectaFindings, skillResourceFindings, skillFenceFindings } = collectSurfaceFindings(agents, skills);
@@ -958,7 +978,18 @@ function formatScanReport(r) {
958
978
  out.push(...section("MCP hook targets", r.mcpHookIssues.map((i) => ` ✗ ${i.message}`)));
959
979
  out.push(...section("Description overlap (precision risk)", r.descriptionOverlaps.map((o) => ` ⚠ ${o.message}`)));
960
980
  out.push(...section("Description budget (trigger-signal risk)", r.descriptionBudgetIssues.map((o) => ` ⚠ ${o.message}`)));
961
- out.push(...section("Lethal trifecta (prompt-injection exfil risk)", r.trifectaFindings.map((t) => ` ${t.finding.severity === "hard" ? "✗" : "⚠"} ${t.kind} ${t.name} (${t.path}): ${t.finding.message}`)));
981
+ out.push(...section("Lethal trifecta (prompt-injection exfil risk)",
982
+ // The section header already carries the count. HARD findings name their
983
+ // specific legs (keep the message). ADVISORY (inherits-all) findings all
984
+ // carry the SAME boilerplate paragraph — at bulk that's a wall of identical
985
+ // text, so collapse each to a one-liner (feedback P2-6). Gate on "no NEW
986
+ // trifecta" with `vigiles lint` (the lethal-trifecta rule), not by eyeballing.
987
+ r.trifectaFindings.map((t) => {
988
+ const mark = t.finding.severity === "hard" ? "✗" : "⚠";
989
+ return t.finding.severity === "hard"
990
+ ? ` ${mark} ${t.kind} ${t.name} (${t.path}): ${t.finding.message}`
991
+ : ` ${mark} ${t.kind} ${t.name} (${t.path}) — inherits-all contract holds all three legs (declare a tools list dropping one)`;
992
+ })));
962
993
  out.push(...section("Skill bundled resources", r.skillResourceIssues.map((s) => ` ✗ ${s.name}: ${s.finding.ref} (line ${String(s.finding.line)}) — bundled resource not found`)));
963
994
  out.push(...section("Invisible skills (missing frontmatter fence)", r.skillFenceIssues.map((s) => ` ✗ ${s.name} (${s.path}): opens with \`${s.finding.key}:\` but no \`---\` fence — loads as body, never fires`)));
964
995
  out.push(...section("Misplaced plugin directories", r.pluginLayoutIssues.map((p) => ` ✗ ${p.message}`)));
@@ -86,6 +86,22 @@ function discoverSkills(basePath, ignore, layout) {
86
86
  ignored: content.includes(IGNORE_MARKER),
87
87
  });
88
88
  }
89
+ // Single-skill-directory target: a bare `SKILL.md` AT the base (the dir you
90
+ // pointed lint/audit at). The globs above only match `<skillDir>/*/SKILL.md`
91
+ // NESTED under the base, so without this the untested-skill check would silently
92
+ // vanish for exactly the single-skill target that scoping now supports.
93
+ const rootSkill = (0, node_path_1.join)(basePath, "SKILL.md");
94
+ if ((0, node_fs_1.existsSync)(rootSkill)) {
95
+ const name = (0, node_path_1.basename)(basePath);
96
+ const content = read(rootSkill);
97
+ out.push({
98
+ kind: "skill",
99
+ path: "SKILL.md",
100
+ name,
101
+ tokens: [`${layout.skillDir}/${name}`, `:${name}`],
102
+ ignored: content.includes(IGNORE_MARKER),
103
+ });
104
+ }
89
105
  return out;
90
106
  }
91
107
  function discoverAgents(basePath, ignore, layout) {
@@ -171,7 +187,13 @@ function discoverTests(basePath, globs, ignore) {
171
187
  /** Colocated: a test inside a skill dir, or a name-prefixed sibling of an agent/hook. */
172
188
  function isColocated(surface, testPath) {
173
189
  if (surface.kind === "skill") {
174
- return testPath.startsWith(`${(0, node_path_1.dirname)(surface.path)}/`);
190
+ const dir = (0, node_path_1.dirname)(surface.path);
191
+ // A root `SKILL.md` (single-skill-dir target) lives at ".", so any TOP-LEVEL
192
+ // test is colocated — globSync returns those without a "./" prefix, which a
193
+ // bare `startsWith("./")` would miss (false "untested").
194
+ return dir === "."
195
+ ? (0, node_path_1.dirname)(testPath) === "."
196
+ : testPath.startsWith(`${dir}/`);
175
197
  }
176
198
  return ((0, node_path_1.dirname)(testPath) === (0, node_path_1.dirname)(surface.path) &&
177
199
  (0, node_path_1.basename)(testPath).startsWith(`${surface.name}.`));
@@ -223,10 +245,14 @@ function findUntestedSurfaces(options = {}) {
223
245
  }
224
246
  /** Suggested colocated test path for an untested surface (shown in the warning). */
225
247
  function suggestedTestPath(surface) {
248
+ // A root skill lives at ".", so drop the "./" prefix — the suggested path then
249
+ // matches what globSync actually discovers at the top level.
250
+ const dir = (0, node_path_1.dirname)(surface.path);
251
+ const prefix = dir === "." ? "" : `${dir}/`;
226
252
  if (surface.kind === "skill") {
227
- return `${(0, node_path_1.dirname)(surface.path)}/${surface.name}.eval.mjs`;
253
+ return `${prefix}${surface.name}.eval.mjs`;
228
254
  }
229
- return `${(0, node_path_1.dirname)(surface.path)}/${surface.name}.harness.mjs`;
255
+ return `${prefix}${surface.name}.harness.mjs`;
230
256
  }
231
257
  /** Format an untested-surface report as human-readable text. */
232
258
  function formatUntestedReport(report) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vigiles",
3
- "version": "12.6.0",
3
+ "version": "12.7.0",
4
4
  "description": "Lint & test the harness your AI agent runs on — verify the references in your CLAUDE.md / AGENTS.md and test that your hooks and skills actually work.",
5
5
  "keywords": [
6
6
  "claude-code",