vigiles 2.5.0 → 3.0.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.
Files changed (209) hide show
  1. package/.claude-plugin/plugin.json +9 -0
  2. package/README.md +73 -434
  3. package/action.yml +143 -7
  4. package/dist/action-gate.d.ts +1 -1
  5. package/dist/action-gate.js +1 -1
  6. package/dist/adapter-conformance.d.ts +30 -0
  7. package/dist/adapter-conformance.js +153 -0
  8. package/dist/adapter-registry.d.ts +42 -0
  9. package/dist/adapter-registry.js +55 -0
  10. package/dist/adapter.d.ts +26 -0
  11. package/dist/adapter.js +16 -0
  12. package/dist/adapters/claude-code/adapter.d.ts +3 -0
  13. package/dist/adapters/claude-code/adapter.js +46 -0
  14. package/dist/{agent-result.d.ts → adapters/claude-code/agent-result.d.ts} +1 -1
  15. package/dist/adapters/claude-code/dialect.d.ts +13 -0
  16. package/dist/adapters/claude-code/dialect.js +51 -0
  17. package/dist/adapters/claude-code/egress-entry.d.ts +2 -0
  18. package/dist/adapters/claude-code/egress-entry.js +115 -0
  19. package/dist/adapters/claude-code/egress-proxy.d.ts +2 -0
  20. package/dist/adapters/claude-code/egress-proxy.js +60 -0
  21. package/dist/adapters/claude-code/egress.d.ts +114 -0
  22. package/dist/adapters/claude-code/egress.js +276 -0
  23. package/dist/adapters/claude-code/eval-baseline.d.ts +68 -0
  24. package/dist/adapters/claude-code/eval-baseline.js +173 -0
  25. package/dist/{eval-cache.d.ts → adapters/claude-code/eval-cache.d.ts} +1 -1
  26. package/dist/{eval-cache.js → adapters/claude-code/eval-cache.js} +1 -1
  27. package/dist/{eval.d.ts → adapters/claude-code/eval.d.ts} +28 -4
  28. package/dist/{eval.js → adapters/claude-code/eval.js} +61 -25
  29. package/dist/{harness-test.d.ts → adapters/claude-code/harness-test.d.ts} +33 -37
  30. package/dist/{harness-test.js → adapters/claude-code/harness-test.js} +124 -43
  31. package/dist/adapters/claude-code/hook-protocol.d.ts +10 -0
  32. package/dist/adapters/claude-code/hook-protocol.js +10 -0
  33. package/dist/adapters/claude-code/layout.d.ts +8 -0
  34. package/dist/adapters/claude-code/layout.js +18 -0
  35. package/dist/{mock-model.d.ts → adapters/claude-code/mock-model.d.ts} +2 -24
  36. package/dist/adapters/claude-code/model-mock.d.ts +11 -0
  37. package/dist/adapters/claude-code/model-mock.js +10 -0
  38. package/dist/adapters/claude-code/plugin-loader.d.ts +25 -0
  39. package/dist/adapters/claude-code/plugin-loader.js +19 -0
  40. package/dist/adapters/claude-code/run-hook.d.ts +204 -0
  41. package/dist/adapters/claude-code/run-hook.js +418 -0
  42. package/dist/adapters/claude-code/run-scripts.d.ts +52 -0
  43. package/dist/adapters/claude-code/run-scripts.js +150 -0
  44. package/dist/adapters/claude-code/runtime.d.ts +16 -0
  45. package/dist/adapters/claude-code/runtime.js +39 -0
  46. package/dist/{sandbox.d.ts → adapters/claude-code/sandbox.d.ts} +45 -4
  47. package/dist/{sandbox.js → adapters/claude-code/sandbox.js} +76 -8
  48. package/dist/{skill-driver.d.ts → adapters/claude-code/skill-driver.d.ts} +1 -1
  49. package/dist/adapters/codex/adapter.d.ts +3 -0
  50. package/dist/adapters/codex/adapter.js +49 -0
  51. package/dist/adapters/codex/dialect.d.ts +10 -0
  52. package/dist/adapters/codex/dialect.js +30 -0
  53. package/dist/adapters/codex/driver.d.ts +20 -0
  54. package/dist/adapters/codex/driver.js +89 -0
  55. package/dist/adapters/codex/hook-protocol.d.ts +10 -0
  56. package/dist/adapters/codex/hook-protocol.js +18 -0
  57. package/dist/adapters/codex/layout.d.ts +16 -0
  58. package/dist/adapters/codex/layout.js +18 -0
  59. package/dist/adapters/codex/mock-model.d.ts +52 -0
  60. package/dist/adapters/codex/mock-model.js +210 -0
  61. package/dist/adapters/codex/model-mock.d.ts +11 -0
  62. package/dist/adapters/codex/model-mock.js +10 -0
  63. package/dist/adapters/codex/runtime.d.ts +38 -0
  64. package/dist/adapters/codex/runtime.js +62 -0
  65. package/dist/adapters/opencode/adapter.d.ts +3 -0
  66. package/dist/adapters/opencode/adapter.js +48 -0
  67. package/dist/adapters/opencode/dialect.d.ts +10 -0
  68. package/dist/adapters/opencode/dialect.js +36 -0
  69. package/dist/adapters/opencode/layout.d.ts +9 -0
  70. package/dist/adapters/opencode/layout.js +25 -0
  71. package/dist/adapters/opencode/model-mock.d.ts +10 -0
  72. package/dist/adapters/opencode/model-mock.js +10 -0
  73. package/dist/adapters/opencode/runtime.d.ts +9 -0
  74. package/dist/adapters/opencode/runtime.js +21 -0
  75. package/dist/claude-code.d.ts +16 -0
  76. package/dist/claude-code.js +32 -0
  77. package/dist/cli-flags.d.ts +22 -0
  78. package/dist/cli-flags.js +38 -0
  79. package/dist/cli.js +406 -206
  80. package/dist/codex.d.ts +20 -0
  81. package/dist/codex.js +36 -0
  82. package/dist/community-skills.d.ts +1 -1
  83. package/dist/community-skills.js +2 -2
  84. package/dist/core/adapter.d.ts +88 -0
  85. package/dist/core/adapter.js +3 -0
  86. package/dist/{compile.d.ts → core/compile.d.ts} +11 -2
  87. package/dist/{compile.js → core/compile.js} +68 -53
  88. package/dist/core/compose.d.ts +79 -0
  89. package/dist/core/compose.js +145 -0
  90. package/dist/core/dialect.d.ts +51 -0
  91. package/dist/core/dialect.js +3 -0
  92. package/dist/core/harness-driver.d.ts +134 -0
  93. package/dist/core/harness-driver.js +3 -0
  94. package/dist/core/hook-protocol.d.ts +28 -0
  95. package/dist/core/hook-protocol.js +3 -0
  96. package/dist/core/layout.d.ts +43 -0
  97. package/dist/core/layout.js +3 -0
  98. package/dist/core/model-mock.d.ts +27 -0
  99. package/dist/core/model-mock.js +3 -0
  100. package/dist/core/refs.d.ts +62 -0
  101. package/dist/{refs.js → core/refs.js} +49 -23
  102. package/dist/core/runtime.d.ts +38 -0
  103. package/dist/core/runtime.js +3 -0
  104. package/dist/{types.d.ts → core/types.d.ts} +29 -0
  105. package/dist/{validate.d.ts → core/validate.d.ts} +1 -1
  106. package/dist/{validate.js → core/validate.js} +41 -4
  107. package/dist/e2e.d.ts +19 -0
  108. package/dist/e2e.js +39 -0
  109. package/dist/harness-assert.d.ts +60 -11
  110. package/dist/harness-assert.js +111 -9
  111. package/dist/integration.d.ts +16 -0
  112. package/dist/integration.js +32 -0
  113. package/dist/leaderboard.d.ts +33 -0
  114. package/dist/leaderboard.js +107 -0
  115. package/dist/linting.d.ts +9 -0
  116. package/dist/linting.js +25 -0
  117. package/dist/plugin-loader.d.ts +6 -3
  118. package/dist/plugin-loader.js +101 -49
  119. package/dist/scan.d.ts +49 -0
  120. package/dist/scan.js +177 -0
  121. package/dist/setup-plan.d.ts +51 -0
  122. package/dist/setup-plan.js +85 -0
  123. package/dist/skill-test.d.ts +1 -1
  124. package/dist/skill-test.js +1 -1
  125. package/dist/test-coverage.d.ts +71 -0
  126. package/dist/test-coverage.js +228 -0
  127. package/dist/testing.d.ts +12 -0
  128. package/dist/testing.js +28 -0
  129. package/dist/unit.d.ts +17 -0
  130. package/dist/unit.js +36 -0
  131. package/hooks/refs-nudge.sh +24 -0
  132. package/package.json +35 -16
  133. package/skills/audit-feedback-loop/SKILL.md +76 -0
  134. package/skills/edit-spec/SKILL.md +131 -0
  135. package/skills/enforce-rules-format/SKILL.md +71 -0
  136. package/skills/generate-logo/SKILL.md +103 -0
  137. package/skills/generate-rule/SKILL.md +64 -0
  138. package/skills/linter-docs/clippy.md +241 -0
  139. package/skills/linter-docs/eslint.md +384 -0
  140. package/skills/linter-docs/pylint.md +288 -0
  141. package/skills/linter-docs/rubocop.md +277 -0
  142. package/skills/linter-docs/ruff.md +187 -0
  143. package/skills/linter-docs/stylelint.md +247 -0
  144. package/skills/migrate-to-spec/SKILL.md +126 -0
  145. package/skills/pr-to-lint-rule/SKILL.md +97 -0
  146. package/skills/strengthen/SKILL.md +168 -0
  147. package/skills/test-harness/SKILL.md +157 -0
  148. package/dist/action.d.ts +0 -7
  149. package/dist/action.js +0 -180
  150. package/dist/refs.d.ts +0 -44
  151. package/dist/run-hook.d.ts +0 -77
  152. package/dist/run-hook.js +0 -80
  153. package/dist/run-scripts.d.ts +0 -20
  154. package/dist/run-scripts.js +0 -70
  155. /package/dist/{agent-result.js → adapters/claude-code/agent-result.js} +0 -0
  156. /package/dist/{agent-runtime.d.ts → adapters/claude-code/agent-runtime.d.ts} +0 -0
  157. /package/dist/{agent-runtime.js → adapters/claude-code/agent-runtime.js} +0 -0
  158. /package/dist/{judge.d.ts → adapters/claude-code/judge.d.ts} +0 -0
  159. /package/dist/{judge.js → adapters/claude-code/judge.js} +0 -0
  160. /package/dist/{mock-entry.d.ts → adapters/claude-code/mock-entry.d.ts} +0 -0
  161. /package/dist/{mock-entry.js → adapters/claude-code/mock-entry.js} +0 -0
  162. /package/dist/{mock-model.js → adapters/claude-code/mock-model.js} +0 -0
  163. /package/dist/{skill-driver.js → adapters/claude-code/skill-driver.js} +0 -0
  164. /package/dist/{skill-runtime.d.ts → adapters/claude-code/skill-runtime.d.ts} +0 -0
  165. /package/dist/{skill-runtime.js → adapters/claude-code/skill-runtime.js} +0 -0
  166. /package/dist/{stats.d.ts → adapters/claude-code/stats.d.ts} +0 -0
  167. /package/dist/{stats.js → adapters/claude-code/stats.js} +0 -0
  168. /package/dist/{compile-generator.d.ts → core/compile-generator.d.ts} +0 -0
  169. /package/dist/{compile-generator.js → core/compile-generator.js} +0 -0
  170. /package/dist/{coverage.d.ts → core/coverage.d.ts} +0 -0
  171. /package/dist/{coverage.js → core/coverage.js} +0 -0
  172. /package/dist/{doc-refs.d.ts → core/doc-refs.d.ts} +0 -0
  173. /package/dist/{doc-refs.js → core/doc-refs.js} +0 -0
  174. /package/dist/{evolve.d.ts → core/evolve.d.ts} +0 -0
  175. /package/dist/{evolve.js → core/evolve.js} +0 -0
  176. /package/dist/{frontmatter.d.ts → core/frontmatter.d.ts} +0 -0
  177. /package/dist/{frontmatter.js → core/frontmatter.js} +0 -0
  178. /package/dist/{generate-schema.d.ts → core/generate-schema.d.ts} +0 -0
  179. /package/dist/{generate-schema.js → core/generate-schema.js} +0 -0
  180. /package/dist/{generate-types.d.ts → core/generate-types.d.ts} +0 -0
  181. /package/dist/{generate-types.js → core/generate-types.js} +0 -0
  182. /package/dist/{hash.d.ts → core/hash.d.ts} +0 -0
  183. /package/dist/{hash.js → core/hash.js} +0 -0
  184. /package/dist/{inline.d.ts → core/inline.d.ts} +0 -0
  185. /package/dist/{inline.js → core/inline.js} +0 -0
  186. /package/dist/{integrity.d.ts → core/integrity.d.ts} +0 -0
  187. /package/dist/{integrity.js → core/integrity.js} +0 -0
  188. /package/dist/{linters.d.ts → core/linters.d.ts} +0 -0
  189. /package/dist/{linters.js → core/linters.js} +0 -0
  190. /package/dist/{mcp.d.ts → core/mcp.d.ts} +0 -0
  191. /package/dist/{mcp.js → core/mcp.js} +0 -0
  192. /package/dist/{orphans.d.ts → core/orphans.d.ts} +0 -0
  193. /package/dist/{orphans.js → core/orphans.js} +0 -0
  194. /package/dist/{proofs.d.ts → core/proofs.d.ts} +0 -0
  195. /package/dist/{proofs.js → core/proofs.js} +0 -0
  196. /package/dist/{session.d.ts → core/session.d.ts} +0 -0
  197. /package/dist/{session.js → core/session.js} +0 -0
  198. /package/dist/{sidecar.d.ts → core/sidecar.d.ts} +0 -0
  199. /package/dist/{sidecar.js → core/sidecar.js} +0 -0
  200. /package/dist/{spec.d.ts → core/spec.d.ts} +0 -0
  201. /package/dist/{spec.js → core/spec.js} +0 -0
  202. /package/dist/{symbols.d.ts → core/symbols.d.ts} +0 -0
  203. /package/dist/{symbols.js → core/symbols.js} +0 -0
  204. /package/dist/{test-utils.d.ts → core/test-utils.d.ts} +0 -0
  205. /package/dist/{test-utils.js → core/test-utils.js} +0 -0
  206. /package/dist/{types.js → core/types.js} +0 -0
  207. /package/{.claude-plugin/hooks → hooks}/post-edit.sh +0 -0
  208. /package/{.claude-plugin/hooks → hooks}/pre-edit.sh +0 -0
  209. /package/{.claude-plugin/hooks → hooks}/session-start.sh +0 -0
@@ -0,0 +1,107 @@
1
+ "use strict";
2
+ /**
3
+ * Plugin health leaderboard — rank many plugins by the deterministic structural
4
+ * signals `scan` already computes (research/divergent-bets.md #9, the engine).
5
+ *
6
+ * This is the no-model half: a defensible health score from concrete facts
7
+ * (missing hook scripts, skills that can't trigger, agents with no tool
8
+ * contract, untested surfaces). It deliberately does NOT score on the loader's
9
+ * free-text warnings — those include doc-mention false positives (see
10
+ * src/scan.ts), and a ranking that penalizes a prose mention would be unfair.
11
+ * The behavioural columns (real trigger-rate, observed egress, safety) need a
12
+ * model and stack on top later; this part runs anywhere in CI for free.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.scoreReport = scoreReport;
16
+ exports.rankPlugins = rankPlugins;
17
+ exports.formatLeaderboard = formatLeaderboard;
18
+ const node_path_1 = require("node:path");
19
+ const scan_js_1 = require("./scan.js");
20
+ // Penalty weights — broken-at-runtime costs most, footguns less, nudges least.
21
+ const W_MISSING_HOOK = 15; // a hook script that doesn't exist → never runs
22
+ const W_NO_DESCRIPTION = 10; // a skill with no usable description → can't trigger
23
+ const W_NO_CONTRACT = 5; // an agent with no `tools:` line → inherits everything
24
+ const W_UNTESTED = 3; // a surface with no test/eval → warning-tier
25
+ function gradeFor(score) {
26
+ if (score >= 90)
27
+ return "A";
28
+ if (score >= 80)
29
+ return "B";
30
+ if (score >= 70)
31
+ return "C";
32
+ if (score >= 60)
33
+ return "D";
34
+ return "F";
35
+ }
36
+ /** Deterministic structural-health score for one scanned plugin. */
37
+ function scoreReport(r) {
38
+ // An empty/unloadable machine isn't healthy — it's a non-plugin or a broken load.
39
+ if (r.skills.length + r.agents.length + r.hooks.length === 0) {
40
+ return { score: 0, issues: ["no loadable plugin surface"] };
41
+ }
42
+ const missingHooks = r.hooks.filter((h) => h.status === "missing").length;
43
+ const noDesc = r.skills.filter((s) => !s.hasDescription).length;
44
+ const noContract = r.agents.filter((a) => a.tools === null).length;
45
+ const deductions = [
46
+ {
47
+ n: missingHooks,
48
+ weight: W_MISSING_HOOK,
49
+ label: "hook script(s) MISSING",
50
+ },
51
+ {
52
+ n: noDesc,
53
+ weight: W_NO_DESCRIPTION,
54
+ label: "skill(s) with no usable description",
55
+ },
56
+ {
57
+ n: noContract,
58
+ weight: W_NO_CONTRACT,
59
+ label: "agent(s) inherit all tools (no contract)",
60
+ },
61
+ { n: r.untested, weight: W_UNTESTED, label: "untested surface(s)" },
62
+ ];
63
+ let penalty = 0;
64
+ const issues = [];
65
+ for (const d of deductions) {
66
+ if (d.n === 0)
67
+ continue;
68
+ penalty += d.n * d.weight;
69
+ issues.push(`${String(d.n)} ${d.label}`);
70
+ }
71
+ // Sort issues by cost (worst first) so the report leads with what matters.
72
+ issues.sort((a, b) => Number(b.split(" ")[0]) - Number(a.split(" ")[0]));
73
+ return { score: Math.max(0, 100 - penalty), issues };
74
+ }
75
+ /** Scan + score each directory, ranked best-first (ties broken by name). */
76
+ function rankPlugins(dirs) {
77
+ const scored = dirs.map((dir) => {
78
+ const report = (0, scan_js_1.scanPlugin)(dir);
79
+ const { score, issues } = scoreReport(report);
80
+ return {
81
+ dir,
82
+ name: (0, node_path_1.basename)(dir) || dir,
83
+ score,
84
+ grade: gradeFor(score),
85
+ issues,
86
+ report,
87
+ };
88
+ });
89
+ return scored.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
90
+ }
91
+ /** Format a ranked leaderboard as human-readable text. */
92
+ function formatLeaderboard(scores) {
93
+ const out = [
94
+ `Plugin health leaderboard (${String(scores.length)} scanned)`,
95
+ "",
96
+ " # score grade plugin",
97
+ ];
98
+ scores.forEach((s, i) => {
99
+ const rank = String(i + 1).padStart(2);
100
+ const score = String(s.score).padStart(3);
101
+ const issue = s.issues.length > 0 ? ` — ${s.issues.join("; ")}` : "";
102
+ out.push(` ${rank} ${score} ${s.grade} ${s.name}${issue}`);
103
+ });
104
+ out.push("", "Structural health only (no model). Weights: missing hook -15, no-description", "skill -10, agent-without-tool-contract -5, untested surface -3.");
105
+ return out.join("\n");
106
+ }
107
+ //# sourceMappingURL=leaderboard.js.map
@@ -0,0 +1,9 @@
1
+ /**
2
+ * `vigiles/linting` — Pillar 1 entry point: the **linting layer** for instruction
3
+ * files. Re-exports the spec builders/types and the compiler under one
4
+ * concern-named import. The granular paths (`vigiles/spec`, `vigiles/compile`)
5
+ * keep working; this just groups them so the import name matches the pillar.
6
+ */
7
+ export * from "./core/spec.js";
8
+ export * from "./core/compile.js";
9
+ //# sourceMappingURL=linting.d.ts.map
@@ -0,0 +1,25 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ Object.defineProperty(exports, "__esModule", { value: true });
17
+ /**
18
+ * `vigiles/linting` — Pillar 1 entry point: the **linting layer** for instruction
19
+ * files. Re-exports the spec builders/types and the compiler under one
20
+ * concern-named import. The granular paths (`vigiles/spec`, `vigiles/compile`)
21
+ * keep working; this just groups them so the import name matches the pillar.
22
+ */
23
+ __exportStar(require("./core/spec.js"), exports);
24
+ __exportStar(require("./core/compile.js"), exports);
25
+ //# sourceMappingURL=linting.js.map
@@ -1,3 +1,4 @@
1
+ import type { PluginLayout } from "./core/layout.js";
1
2
  export interface LoadedPlugin {
2
3
  /** A `.claude/settings.json`-shaped object with hooks resolved. */
3
4
  readonly settings: {
@@ -20,17 +21,19 @@ export interface LoadedPlugin {
20
21
  * sandbox, and `warnings` for surfaces the deterministic tier can't drive. Merge
21
22
  * `settings` with any inline settings and spread `files` into the fixture.
22
23
  */
23
- export declare function loadPlugin(pluginPath: string): LoadedPlugin;
24
+ export declare function loadPlugin(pluginPath: string, layout: PluginLayout): LoadedPlugin;
24
25
  /**
25
26
  * Resolve the effective harness for a test/eval (arm): load the plugin if given,
26
27
  * then layer inline settings + files on top. Shared by `runHarnessTest` and
27
- * `runEval` so both test the assembled machine the same way.
28
+ * `runEval` so both test the assembled machine the same way. `layout` is
29
+ * REQUIRED (no harness default at the composition root) — the Claude Code
30
+ * wrapper supplies `claudeCodeLayout` to preserve `resolveHarness(opts)`.
28
31
  */
29
32
  export declare function resolveHarness(opts: {
30
33
  plugin?: string;
31
34
  settings?: unknown;
32
35
  files?: Record<string, string>;
33
- }): {
36
+ }, layout: PluginLayout): {
34
37
  settings: unknown;
35
38
  files: Record<string, string>;
36
39
  };
@@ -3,28 +3,38 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.loadPlugin = loadPlugin;
4
4
  exports.resolveHarness = resolveHarness;
5
5
  /**
6
- * vigiles — load a real plugin/repo harness for testing.
6
+ * vigiles — harness-agnostic plugin/repo harness loader (composition root).
7
7
  *
8
- * The unit that matters is not a single hook but the *assembled machine*: the
9
- * hooks, settings, CLAUDE.md, skills, subagents, and commands a plugin/repo
10
- * actually ships, working together. `loadPlugin` reads that real harness so a
11
- * `runHarnessTest` / `runEval` runs against what ships — not a hand-retyped
12
- * subset that can drift. Hooks, CLAUDE.md and skills are exercisable at the
13
- * deterministic tier; subagents/commands/MCP are materialized but only run under
14
- * a real model, so `LoadedPlugin.warnings` flags them (no silent empty machine).
8
+ * Lives at the app/composition root, NOT in any harness adapter, so every
9
+ * adapter (Claude Code, Codex, OpenCode, …) loads its plugin through the SAME
10
+ * loader by injecting its own `PluginLayout` no adapter imports a sibling
11
+ * adapter. The unit that matters is not a single hook but the *assembled
12
+ * machine*: the hooks, settings, instruction file, skills, subagents, and
13
+ * commands a plugin/repo actually ships, working together. `loadPlugin` reads
14
+ * that real harness so a `runHarnessTest` / `runEval` runs against what ships —
15
+ * not a hand-retyped subset that can drift. Hooks, instruction file and skills
16
+ * are exercisable at the deterministic tier; subagents/commands/MCP are
17
+ * materialized but only run under a real model, so `LoadedPlugin.warnings` flags
18
+ * them (no silent empty machine).
15
19
  *
16
20
  * runHarnessTest({ plugin: "./", model: scriptModel([...]) });
17
21
  *
18
- * Resolution order for hooks: inline `hooks` in `.claude-plugin/plugin.json`, a
19
- * `hooks` string path in plugin.json, the `hooks/hooks.json` convention (e.g.
20
- * obra/superpowers), then a plain repo's `.claude/settings.json`. `${CLAUDE_
21
- * PLUGIN_ROOT}` in any hook command is expanded to the plugin's absolute path,
22
- * so the real hook scripts run from where they live (no copying needed). The
23
- * plugin's CLAUDE.md and skills/ are materialized into the sandbox so the
24
- * assembled context is present too.
22
+ * Resolution order for hooks: inline `hooks` in the layout's manifest, a `hooks`
23
+ * string path in the manifest, the layout's `hooks` convention path (e.g.
24
+ * obra/superpowers' `hooks/hooks.json`), then a plain repo's settings file.
25
+ * The layout's plugin-root token in any hook command is expanded to the plugin's
26
+ * absolute path, so the real hook scripts run from where they live (no copying
27
+ * needed). The plugin's instruction file and skills/ are materialized into the
28
+ * sandbox so the assembled context is present too.
29
+ *
30
+ * `layout` is REQUIRED here — there is no harness default at the composition
31
+ * root. Each adapter supplies its own (the Claude Code wrapper at
32
+ * `src/adapters/claude-code/plugin-loader.ts` defaults it to `claudeCodeLayout`
33
+ * to preserve `loadPlugin(dir)` ergonomics and the public `vigiles/*` exports).
25
34
  */
26
35
  const node_fs_1 = require("node:fs");
27
36
  const node_path_1 = require("node:path");
37
+ const toml_1 = require("@iarna/toml");
28
38
  const MAX_SKILL_FILE_BYTES = 256 * 1024;
29
39
  /** Parse a JSON file, or null on any error (missing / malformed). */
30
40
  function safeReadJson(path) {
@@ -39,6 +49,40 @@ function safeReadJson(path) {
39
49
  function readHooksFile(path) {
40
50
  return safeReadJson(path)?.hooks;
41
51
  }
52
+ /**
53
+ * Parse the layout's manifest in its declared `settingsFormat` — JSON (Claude
54
+ * Code's plugin.json) or TOML (Codex's `config.toml`). A TOML harness's manifest
55
+ * (hooks, `[mcp_servers]`) would otherwise read as empty through the JSON path.
56
+ * Behaviour-identical to `safeReadJson` when the format is JSON.
57
+ */
58
+ function safeReadManifest(root, layout) {
59
+ const path = (0, node_path_1.join)(root, layout.manifestPath);
60
+ if (layout.settingsFormat === "toml") {
61
+ try {
62
+ return (0, toml_1.parse)((0, node_fs_1.readFileSync)(path, "utf-8"));
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ return safeReadJson(path);
69
+ }
70
+ /**
71
+ * Read the `.hooks` field of a settings file in the layout's format — JSON
72
+ * (Claude Code's settings.json) or TOML (Codex's `config.toml` `[hooks]`). A
73
+ * TOML harness's hooks would otherwise be read as zero by the JSON path.
74
+ */
75
+ function readSettingsHooks(path, format) {
76
+ if (format === "json")
77
+ return readHooksFile(path);
78
+ try {
79
+ return (0, toml_1.parse)((0, node_fs_1.readFileSync)(path, "utf-8"))
80
+ .hooks;
81
+ }
82
+ catch {
83
+ return undefined;
84
+ }
85
+ }
42
86
  /**
43
87
  * Read the hooks block, handling the real-world plugin layouts:
44
88
  * 1. inline `hooks` object in .claude-plugin/plugin.json,
@@ -46,22 +90,23 @@ function readHooksFile(path) {
46
90
  * 3. the `hooks/hooks.json` convention (e.g. obra/superpowers) — auto-discovered,
47
91
  * 4. a plain repo's `.claude/settings.json`.
48
92
  */
49
- function readHooks(root) {
50
- // A malformed plugin.json must not crash the loader — fall through to the
51
- // other layouts (safeReadJson returns null on a parse error).
52
- const m = safeReadJson((0, node_path_1.join)(root, ".claude-plugin", "plugin.json"));
93
+ function readHooks(root, layout) {
94
+ // A malformed manifest must not crash the loader — fall through to the
95
+ // other layouts (safeReadManifest returns null on a parse error). Format-aware
96
+ // so a Codex `config.toml` manifest's `[hooks]` is read, not skipped.
97
+ const m = safeReadManifest(root, layout);
53
98
  if (m) {
54
99
  if (typeof m.hooks === "string")
55
100
  return readHooksFile((0, node_path_1.join)(root, m.hooks));
56
101
  if (m.hooks !== undefined)
57
102
  return m.hooks;
58
103
  }
59
- const conventionPath = (0, node_path_1.join)(root, "hooks", "hooks.json");
104
+ const conventionPath = (0, node_path_1.join)(root, layout.hooksConventionPath);
60
105
  if ((0, node_fs_1.existsSync)(conventionPath))
61
106
  return readHooksFile(conventionPath);
62
- const settingsPath = (0, node_path_1.join)(root, ".claude", "settings.json");
107
+ const settingsPath = (0, node_path_1.join)(root, layout.settingsPath);
63
108
  if ((0, node_fs_1.existsSync)(settingsPath))
64
- return readHooksFile(settingsPath);
109
+ return readSettingsHooks(settingsPath, layout.settingsFormat);
65
110
  return undefined;
66
111
  }
67
112
  /** Recursively collect text files under `dir` as `relativePath → contents`. */
@@ -85,39 +130,39 @@ function readTree(dir, base) {
85
130
  * sandbox, and `warnings` for surfaces the deterministic tier can't drive. Merge
86
131
  * `settings` with any inline settings and spread `files` into the fixture.
87
132
  */
88
- function loadPlugin(pluginPath) {
133
+ function loadPlugin(pluginPath, layout) {
89
134
  const root = (0, node_path_1.resolve)(pluginPath);
90
- const hooks = readHooks(root);
91
- // Expand ${CLAUDE_PLUGIN_ROOT} to the real absolute path so the actual hook
135
+ const hooks = readHooks(root, layout);
136
+ // Expand the plugin-root token to the real absolute path so the actual hook
92
137
  // scripts execute — we test the shipped wiring, not a reimplementation.
93
138
  const resolvedHooks = hooks
94
- ? JSON.parse(JSON.stringify(hooks).replaceAll("${CLAUDE_PLUGIN_ROOT}", root))
139
+ ? JSON.parse(JSON.stringify(hooks).replaceAll(layout.pluginRootToken, root))
95
140
  : undefined;
96
141
  const files = {};
97
- const claudeMd = (0, node_path_1.join)(root, "CLAUDE.md");
98
- if ((0, node_fs_1.existsSync)(claudeMd)) {
99
- files["CLAUDE.md"] = (0, node_fs_1.readFileSync)(claudeMd, "utf-8");
142
+ const instructions = (0, node_path_1.join)(root, layout.instructionFile);
143
+ if ((0, node_fs_1.existsSync)(instructions)) {
144
+ files[layout.instructionFile] = (0, node_fs_1.readFileSync)(instructions, "utf-8");
100
145
  }
101
- // Materialize each project-level surface under .claude/<surface>/ so the
146
+ // Materialize each project-level surface under the materialize root so the
102
147
  // assembled context is present in the sandbox (best-effort — headless
103
148
  // activation of plugin skills/subagents/commands is not guaranteed; the body
104
149
  // is present for the agent to read either way). Counting what we materialize
105
150
  // also lets us warn about surfaces the deterministic tier can't drive.
106
151
  const counts = {};
107
- for (const surface of ["skills", "agents", "commands"]) {
152
+ for (const surface of layout.surfaceDirs) {
108
153
  const dir = (0, node_path_1.join)(root, surface);
109
154
  if (!(0, node_fs_1.existsSync)(dir) || !(0, node_fs_1.statSync)(dir).isDirectory())
110
155
  continue;
111
156
  const tree = readTree(dir, root);
112
157
  for (const [rel, content] of Object.entries(tree)) {
113
- files[(0, node_path_1.join)(".claude", rel)] = content;
158
+ files[(0, node_path_1.join)(layout.materializeRoot, rel)] = content;
114
159
  }
115
160
  counts[surface] = Object.keys(tree).length;
116
161
  }
117
162
  return {
118
163
  settings: resolvedHooks ? { hooks: resolvedHooks } : {},
119
164
  files,
120
- warnings: pluginWarnings(root, counts, resolvedHooks, files),
165
+ warnings: pluginWarnings(root, counts, resolvedHooks, files, layout),
121
166
  };
122
167
  }
123
168
  /**
@@ -127,7 +172,7 @@ function loadPlugin(pluginPath) {
127
172
  * the eval tier. MCP servers aren't wired by the loader at all. And a plugin
128
173
  * that yields neither hooks nor files would otherwise be a silent empty machine.
129
174
  */
130
- function pluginWarnings(root, counts, hooks, files) {
175
+ function pluginWarnings(root, counts, hooks, files, layout) {
131
176
  const warnings = [];
132
177
  if (counts.agents) {
133
178
  warnings.push(`plugin defines ${String(counts.agents)} subagent file(s) under agents/ — these run only under a real model; test them at the eval tier (runEval), not the deterministic mock.`);
@@ -135,10 +180,10 @@ function pluginWarnings(root, counts, hooks, files) {
135
180
  if (counts.commands) {
136
181
  warnings.push(`plugin defines ${String(counts.commands)} slash-command file(s) under commands/ — slash-command invocation needs a real model; test at the eval tier.`);
137
182
  }
138
- if (hasMcp(root)) {
139
- warnings.push(`plugin declares MCP server(s) (mcpServers / .mcp.json) — the loader does not wire MCP; bring the server up yourself if your test needs it.`);
183
+ if (hasMcp(root, layout)) {
184
+ warnings.push(`plugin declares MCP server(s) (${layout.mcpManifestKey} / ${layout.mcpConfigFile}) — the loader does not wire MCP; bring the server up yourself if your test needs it.`);
140
185
  }
141
- const dangling = danglingRefs(root);
186
+ const dangling = danglingRefs(root, layout);
142
187
  if (dangling.length) {
143
188
  const shown = dangling.slice(0, 5).join(", ");
144
189
  const more = dangling.length > 5 ? `, … (+${String(dangling.length - 5)})` : "";
@@ -149,16 +194,20 @@ function pluginWarnings(root, counts, hooks, files) {
149
194
  }
150
195
  return warnings;
151
196
  }
152
- /** Whether the plugin declares any MCP servers (manifest field or .mcp.json). */
153
- function hasMcp(root) {
154
- if ((0, node_fs_1.existsSync)((0, node_path_1.join)(root, ".mcp.json")))
197
+ /** Whether the plugin declares any MCP servers (manifest key or standalone file).
198
+ * Format-aware: reads the layout's `mcpManifestKey` from a JSON OR TOML manifest,
199
+ * so Codex's `[mcp_servers]` TOML table is detected, not silently missed. */
200
+ function hasMcp(root, layout) {
201
+ if ((0, node_fs_1.existsSync)((0, node_path_1.join)(root, layout.mcpConfigFile)))
155
202
  return true;
156
- return (safeReadJson((0, node_path_1.join)(root, ".claude-plugin", "plugin.json"))?.mcpServers !==
157
- undefined);
203
+ return safeReadManifest(root, layout)?.[layout.mcpManifestKey] !== undefined;
158
204
  }
159
205
  // A plugin-relative path reference to a file under a standard surface dir, with a
160
206
  // known extension — e.g. a hook script that `cat`s `skills/using-superpowers/SKILL.md`.
161
- const INTRA_REF_RE = /(?:skills|hooks|commands|agents)\/[A-Za-z0-9._/-]+\.(?:md|sh|cmd|mjs|cjs|js|ts|py|rb|txt|json)/g;
207
+ const INTRA_REF_EXTS = "md|sh|cmd|mjs|cjs|js|ts|py|rb|txt|json";
208
+ function intraRefRe(layout) {
209
+ return new RegExp(`(?:${layout.intraRefDirs.join("|")})/[A-Za-z0-9._/-]+\\.(?:${INTRA_REF_EXTS})`, "g");
210
+ }
162
211
  /**
163
212
  * Intra-plugin file references that don't resolve — the partial-vendor / broken-
164
213
  * path class (e.g. obra/superpowers' `SessionStart` reads
@@ -168,15 +217,16 @@ const INTRA_REF_RE = /(?:skills|hooks|commands|agents)\/[A-Za-z0-9._/-]+\.(?:md|
168
217
  * and report the ones missing on disk. A static check that would have caught a
169
218
  * bug the dogfood hit twice. Best-effort: a warning, not an error.
170
219
  */
171
- function danglingRefs(root) {
220
+ function danglingRefs(root, layout) {
172
221
  const missing = new Set();
173
222
  const seen = new Set();
174
- for (const surface of ["hooks", "skills", "agents", "commands"]) {
223
+ const re = intraRefRe(layout);
224
+ for (const surface of layout.intraRefDirs) {
175
225
  const dir = (0, node_path_1.join)(root, surface);
176
226
  if (!(0, node_fs_1.existsSync)(dir) || !(0, node_fs_1.statSync)(dir).isDirectory())
177
227
  continue;
178
228
  for (const content of Object.values(readTree(dir, root))) {
179
- for (const m of content.matchAll(INTRA_REF_RE)) {
229
+ for (const m of content.matchAll(re)) {
180
230
  const ref = m[0];
181
231
  if (seen.has(ref))
182
232
  continue;
@@ -215,11 +265,13 @@ function mergeSettings(base, override) {
215
265
  /**
216
266
  * Resolve the effective harness for a test/eval (arm): load the plugin if given,
217
267
  * then layer inline settings + files on top. Shared by `runHarnessTest` and
218
- * `runEval` so both test the assembled machine the same way.
268
+ * `runEval` so both test the assembled machine the same way. `layout` is
269
+ * REQUIRED (no harness default at the composition root) — the Claude Code
270
+ * wrapper supplies `claudeCodeLayout` to preserve `resolveHarness(opts)`.
219
271
  */
220
- function resolveHarness(opts) {
272
+ function resolveHarness(opts, layout) {
221
273
  const loaded = opts.plugin
222
- ? loadPlugin(opts.plugin)
274
+ ? loadPlugin(opts.plugin, layout)
223
275
  : { settings: {}, files: {} };
224
276
  return {
225
277
  files: { ...loaded.files, ...opts.files },
package/dist/scan.d.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * `vigiles scan <dir>` — point vigiles at any plugin/repo and see what it ships
3
+ * and what's broken, with **no model and no API key**.
4
+ *
5
+ * This is the deterministic substrate under the plugin/skill leaderboard
6
+ * (research/divergent-bets.md #9) and the harness-aware audit
7
+ * (research/agent-supply-chain-security.md #1): it re-aims the machinery that
8
+ * already exists — `loadPlugin` (surfaces + dangling-ref/MCP/empty-machine
9
+ * warnings), `parseAgentTools` (the declared tool contract), and
10
+ * `findUntestedSurfaces` — into one read-only report. Behavioral checks that
11
+ * need to RUN the plugin (observed egress under the sandbox, real trigger-rate)
12
+ * stack on top later; this core stays pure so it runs anywhere in CI for free.
13
+ */
14
+ import type { PluginLayout } from "./core/layout.js";
15
+ export interface ScanSkill {
16
+ readonly name: string;
17
+ readonly path: string;
18
+ readonly hasDescription: boolean;
19
+ readonly userInvoked: boolean;
20
+ }
21
+ export interface ScanAgent {
22
+ readonly name: string;
23
+ readonly path: string;
24
+ /** Declared tool contract, or null when the agent ships no `tools:` (inherits all). */
25
+ readonly tools: readonly string[] | null;
26
+ }
27
+ /** ok = file present; missing = referenced but absent; unresolved = path still has an unexpanded var, can't check. */
28
+ export type HookStatus = "ok" | "missing" | "unresolved";
29
+ export interface ScanHook {
30
+ readonly script: string;
31
+ readonly status: HookStatus;
32
+ }
33
+ export interface ScanReport {
34
+ readonly dir: string;
35
+ readonly skills: readonly ScanSkill[];
36
+ readonly agents: readonly ScanAgent[];
37
+ readonly hooks: readonly ScanHook[];
38
+ /** Hook entries with no script file (inline shell one-liners) — can't be path-checked. */
39
+ readonly inlineHooks: number;
40
+ readonly commands: number;
41
+ readonly mcp: boolean;
42
+ readonly warnings: readonly string[];
43
+ readonly untested: number;
44
+ }
45
+ /** Scan a plugin/repo directory and report its surfaces + structural issues. */
46
+ export declare function scanPlugin(dir: string, layout?: PluginLayout): ScanReport;
47
+ /** Format a scan report as human-readable text. */
48
+ export declare function formatScanReport(r: ScanReport): string;
49
+ //# sourceMappingURL=scan.d.ts.map
package/dist/scan.js ADDED
@@ -0,0 +1,177 @@
1
+ "use strict";
2
+ /**
3
+ * `vigiles scan <dir>` — point vigiles at any plugin/repo and see what it ships
4
+ * and what's broken, with **no model and no API key**.
5
+ *
6
+ * This is the deterministic substrate under the plugin/skill leaderboard
7
+ * (research/divergent-bets.md #9) and the harness-aware audit
8
+ * (research/agent-supply-chain-security.md #1): it re-aims the machinery that
9
+ * already exists — `loadPlugin` (surfaces + dangling-ref/MCP/empty-machine
10
+ * warnings), `parseAgentTools` (the declared tool contract), and
11
+ * `findUntestedSurfaces` — into one read-only report. Behavioral checks that
12
+ * need to RUN the plugin (observed egress under the sandbox, real trigger-rate)
13
+ * stack on top later; this core stays pure so it runs anywhere in CI for free.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.scanPlugin = scanPlugin;
17
+ exports.formatScanReport = formatScanReport;
18
+ const node_fs_1 = require("node:fs");
19
+ const node_path_1 = require("node:path");
20
+ const plugin_loader_js_1 = require("./adapters/claude-code/plugin-loader.js");
21
+ const agent_runtime_js_1 = require("./adapters/claude-code/agent-runtime.js");
22
+ const test_coverage_js_1 = require("./test-coverage.js");
23
+ // ---------------------------------------------------------------------------
24
+ // Internals
25
+ // ---------------------------------------------------------------------------
26
+ const SCRIPT_RE = /\S+\.(?:sh|mjs|cjs|js|ts|py|rb)\b/g;
27
+ function frontmatter(md) {
28
+ const m = /(?:^|\n)---\r?\n([\s\S]*?)\r?\n---/.exec(md);
29
+ if (!m)
30
+ return {};
31
+ const name = /^name:\s*(.+)$/m.exec(m[1])?.[1]?.trim();
32
+ const description = /^description:\s*(.+)$/m.exec(m[1])?.[1]?.trim();
33
+ return { name, description };
34
+ }
35
+ const isSkill = (f) => /skills\/[^/]+\/SKILL\.md$/.test(f);
36
+ const isAgent = (f) => /agents\/[^/]+\.md$/.test(f) && !f.endsWith(".spec.ts");
37
+ const isCommand = (f) => /commands\/.+\.md$/.test(f);
38
+ function skillName(path) {
39
+ return (path
40
+ .replace(/\/SKILL\.md$/, "")
41
+ .split("/")
42
+ .pop() ?? path);
43
+ }
44
+ function scanSkills(files) {
45
+ const out = [];
46
+ for (const [path, md] of Object.entries(files)) {
47
+ if (!isSkill(path))
48
+ continue;
49
+ const fm = frontmatter(md);
50
+ out.push({
51
+ name: fm.name ?? skillName(path),
52
+ path,
53
+ hasDescription: Boolean(fm.description && fm.description.length >= 20),
54
+ userInvoked: /^\s*disable-model-invocation:\s*true\s*$/m.test(md),
55
+ });
56
+ }
57
+ return out.sort((a, b) => a.name.localeCompare(b.name));
58
+ }
59
+ function scanAgents(files) {
60
+ const out = [];
61
+ for (const [path, md] of Object.entries(files)) {
62
+ if (!isAgent(path))
63
+ continue;
64
+ out.push({ name: (0, node_path_1.basename)(path, ".md"), path, tools: (0, agent_runtime_js_1.parseAgentTools)(md) });
65
+ }
66
+ return out.sort((a, b) => a.name.localeCompare(b.name));
67
+ }
68
+ /**
69
+ * Resolve a hook script token to a checkable path. `loadPlugin` expands the
70
+ * braced `${CLAUDE_PLUGIN_ROOT}`; the unbraced shell form `$CLAUDE_PLUGIN_ROOT`
71
+ * survives, so resolve it against the plugin root here and strip shell quotes.
72
+ * A token that still carries any `$VAR` after that is genuinely uncheckable.
73
+ */
74
+ function resolveScript(token, root) {
75
+ const path = token
76
+ .replace(/["']/g, "")
77
+ .replaceAll("${CLAUDE_PLUGIN_ROOT}", root)
78
+ .replaceAll("$CLAUDE_PLUGIN_ROOT", root);
79
+ if (path.includes("$"))
80
+ return { script: token, status: "unresolved" };
81
+ return { script: path, status: (0, node_fs_1.existsSync)(path) ? "ok" : "missing" };
82
+ }
83
+ /** Pull script-file hook commands out of the resolved settings; count inline ones. */
84
+ function scanHooks(settings, root) {
85
+ const text = JSON.stringify(settings.hooks ?? {});
86
+ const commands = [...text.matchAll(/"command":\s*"((?:[^"\\]|\\.)*)"/g)].map((m) => m[1]);
87
+ const byScript = new Map();
88
+ let inline = 0;
89
+ for (const cmd of commands) {
90
+ const unescaped = cmd.replace(/\\(.)/g, "$1");
91
+ const found = unescaped.match(SCRIPT_RE);
92
+ if (!found || found.length === 0) {
93
+ inline++;
94
+ continue;
95
+ }
96
+ for (const tok of found) {
97
+ const hook = resolveScript(tok, root);
98
+ byScript.set(hook.script, hook);
99
+ }
100
+ }
101
+ const hooks = [...byScript.values()].sort((a, b) => a.script.localeCompare(b.script));
102
+ return { hooks, inline };
103
+ }
104
+ // ---------------------------------------------------------------------------
105
+ // Public API
106
+ // ---------------------------------------------------------------------------
107
+ /** Scan a plugin/repo directory and report its surfaces + structural issues. */
108
+ function scanPlugin(dir, layout) {
109
+ const loaded = (0, plugin_loader_js_1.loadPlugin)(dir, layout);
110
+ const { hooks, inline } = scanHooks(loaded.settings, (0, node_path_1.resolve)(dir));
111
+ return {
112
+ dir,
113
+ skills: scanSkills(loaded.files),
114
+ agents: scanAgents(loaded.files),
115
+ hooks,
116
+ inlineHooks: inline,
117
+ commands: Object.keys(loaded.files).filter(isCommand).length,
118
+ mcp: loaded.warnings.some((w) => w.includes("MCP server")),
119
+ warnings: loaded.warnings,
120
+ untested: (0, test_coverage_js_1.findUntestedSurfaces)({ basePath: dir }).untested.length,
121
+ };
122
+ }
123
+ function section(title, lines) {
124
+ if (lines.length === 0)
125
+ return [];
126
+ return [`${title} (${String(lines.length)}):`, ...lines, ""];
127
+ }
128
+ /** Format a scan report as human-readable text. */
129
+ function formatScanReport(r) {
130
+ const out = [`Scan: ${r.dir}`, ""];
131
+ out.push(...section("Skills", r.skills.map((s) => {
132
+ const mark = s.hasDescription ? "✓" : "⚠";
133
+ const note = s.hasDescription
134
+ ? s.userInvoked
135
+ ? "(user-invoked)"
136
+ : ""
137
+ : "(missing/short description — can't trigger)";
138
+ return ` ${mark} ${s.name} ${note}`.trimEnd();
139
+ })));
140
+ out.push(...section("Agents", r.agents.map((a) => {
141
+ const tools = a.tools === null
142
+ ? "tools: (inherits all — no contract)"
143
+ : `tools: ${a.tools.join(", ") || "(none)"}`;
144
+ return ` ${a.tools === null ? "⚠" : "✓"} ${a.name} — ${tools}`;
145
+ })));
146
+ const hookMark = {
147
+ ok: "✓",
148
+ missing: "✗",
149
+ unresolved: "?",
150
+ };
151
+ const hookNote = {
152
+ ok: "",
153
+ missing: " (referenced but MISSING)",
154
+ unresolved: " (unresolved var — can't check)",
155
+ };
156
+ const hookLines = r.hooks.map((h) => ` ${hookMark[h.status]} ${h.script}${hookNote[h.status]}`);
157
+ if (r.inlineHooks > 0) {
158
+ hookLines.push(` · ${String(r.inlineHooks)} inline hook(s) (no script file)`);
159
+ }
160
+ out.push(...section("Hooks", hookLines));
161
+ const facts = [];
162
+ if (r.commands > 0)
163
+ facts.push(`Commands: ${String(r.commands)}`);
164
+ facts.push(`MCP servers: ${r.mcp ? "yes" : "no"}`);
165
+ facts.push(`Untested surfaces: ${String(r.untested)}`);
166
+ out.push(...facts, "");
167
+ if (r.warnings.length > 0) {
168
+ out.push("Warnings:", ...r.warnings.map((w) => ` - ${w}`), "");
169
+ }
170
+ const broken = r.hooks.filter((h) => h.status === "missing").length +
171
+ r.skills.filter((s) => !s.hasDescription).length;
172
+ out.push(broken === 0
173
+ ? "✓ no structural issues found"
174
+ : `⚠ ${String(broken)} structural issue(s) — see ✗/⚠ above`);
175
+ return out.join("\n");
176
+ }
177
+ //# sourceMappingURL=scan.js.map