vigiles 7.0.0 → 9.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 (58) hide show
  1. package/README.md +207 -88
  2. package/dist/adoptability.d.ts +55 -0
  3. package/dist/adoptability.js +196 -0
  4. package/dist/audit-html.d.ts +20 -0
  5. package/dist/audit-html.js +61 -0
  6. package/dist/audit-prompts.d.ts +46 -0
  7. package/dist/audit-prompts.js +90 -0
  8. package/dist/audit-report.d.ts +70 -0
  9. package/dist/audit-report.js +51 -0
  10. package/dist/audit-report.template.html +110 -0
  11. package/dist/audit-score.d.ts +44 -0
  12. package/dist/audit-score.js +221 -0
  13. package/dist/cli-commands.d.ts +1 -1
  14. package/dist/cli-commands.js +3 -7
  15. package/dist/cli.d.ts +1 -1
  16. package/dist/cli.js +749 -180
  17. package/dist/core/adopt.d.ts +65 -0
  18. package/dist/core/adopt.js +199 -0
  19. package/dist/core/compose.d.ts +1 -1
  20. package/dist/core/compose.js +1 -1
  21. package/dist/core/evolve.d.ts +4 -0
  22. package/dist/core/evolve.js +4 -0
  23. package/dist/core/frontmatter.d.ts +8 -7
  24. package/dist/core/frontmatter.js +8 -7
  25. package/dist/core/generate-harness.d.ts +1 -1
  26. package/dist/core/generate-harness.js +3 -3
  27. package/dist/core/generate-schema.js +1 -1
  28. package/dist/core/inline.d.ts +6 -6
  29. package/dist/core/inline.js +17 -7
  30. package/dist/core/integrity.d.ts +31 -0
  31. package/dist/core/integrity.js +45 -0
  32. package/dist/core/orphans.js +1 -1
  33. package/dist/core/spec.d.ts +40 -2
  34. package/dist/core/spec.js +16 -1
  35. package/dist/core/types.d.ts +42 -6
  36. package/dist/core/validate.js +26 -26
  37. package/dist/dialect-drift.js +1 -1
  38. package/dist/eval.d.ts +1 -1
  39. package/dist/eval.js +1 -1
  40. package/dist/guardrail-check.d.ts +1 -1
  41. package/dist/guardrail-check.js +1 -1
  42. package/dist/optimize.d.ts +12 -5
  43. package/dist/optimize.js +27 -5
  44. package/dist/scan-behavioral.d.ts +8 -2
  45. package/dist/scan-behavioral.js +6 -4
  46. package/dist/scan-trigger-suggest.d.ts +91 -0
  47. package/dist/scan-trigger-suggest.js +103 -0
  48. package/dist/scan.d.ts +53 -12
  49. package/dist/scan.js +92 -16
  50. package/dist/score-explainer.d.ts +1 -1
  51. package/dist/setup-plan.d.ts +59 -1
  52. package/dist/setup-plan.js +103 -5
  53. package/hooks/post-edit.sh +1 -1
  54. package/package.json +4 -2
  55. package/skills/adopt-spec/SKILL.md +7 -7
  56. package/skills/linter-docs/eslint.md +1 -1
  57. package/skills/strengthen/SKILL.md +1 -1
  58. package/skills/test-harness/SKILL.md +1 -1
@@ -0,0 +1,91 @@
1
+ /**
2
+ * audit → the ONE read-vs-run decision. `audit` is a Lighthouse-style LOCAL
3
+ * report: a deterministic READ by default — safe + identical on every OS, nothing
4
+ * executes — NOT a CI step (CI uses `vigiles lint`, the deterministic gate). The
5
+ * executing checks (safety battery, live MCP resolution, skill-firing trigger-rate)
6
+ * run ONLY when there's a human to consent: at a TTY `audit` ASKS once (and
7
+ * remembers in `.vigilesrc.json` `audit.measure`); headless (an agent / `--json` /
8
+ * `--no-interactive` / a pipe) it stays a read + a one-line nudge — never hangs,
9
+ * never silently executes. There is deliberately NO execution flag: automation
10
+ * tests the harness through the `vigiles/testing` API + skills (the layered tiers),
11
+ * not through the report verb. The IO (prompt / run / remember) lives in the CLI;
12
+ * this is the pure decision + helpers.
13
+ */
14
+ /** Only the env vars that signal a reachable model (parse, don't validate). */
15
+ export interface ModelEnv {
16
+ readonly ANTHROPIC_API_KEY?: string;
17
+ readonly CLAUDECODE?: string;
18
+ readonly CLAUDE_CODE_ENTRYPOINT?: string;
19
+ }
20
+ /**
21
+ * Is a real model reachable for the trigger tier? Either a metered API key
22
+ * (`ANTHROPIC_API_KEY`), OR an authenticated Claude Code session (`CLAUDECODE=1`
23
+ * / `CLAUDE_CODE_ENTRYPOINT`, web/desktop/CLI) — the latter drives the `claude`
24
+ * CLI on the user's subscription, no key needed and $0 metered. A tiny env-only
25
+ * predicate (not a live probe), so it never spends a token just to decide.
26
+ */
27
+ export declare function hasModelAccess(env: ModelEnv): boolean;
28
+ /**
29
+ * Is the reachable model METERED (a paid API key) rather than a subscription?
30
+ * Only affects the consent DISCLOSURE wording (a metered key bills per token; a
31
+ * subscription is $0 metered) — the run/skip decision itself is consent-driven,
32
+ * not metered-driven.
33
+ */
34
+ export declare function isMeteredAccess(env: ModelEnv): boolean;
35
+ /** Why the executing checks were skipped (drives the "not run" nudge). */
36
+ export type ExecuteSkipReason = "nothing" | "headless" | "remembered-no";
37
+ /**
38
+ * What `audit` should do about the EXECUTING checks (battery + live MCP +
39
+ * trigger-rate), as ONE bundle:
40
+ * - `run` — run them now (a remembered yes).
41
+ * - `ask` — interactive human + something to run + no sticky choice: ask once,
42
+ * then remember.
43
+ * - `skip` — stay a deterministic read; the `reason` drives a one-line nudge.
44
+ */
45
+ export type ExecuteDecision = {
46
+ readonly kind: "run";
47
+ } | {
48
+ readonly kind: "ask";
49
+ } | {
50
+ readonly kind: "skip";
51
+ readonly reason: ExecuteSkipReason;
52
+ };
53
+ export interface ExecuteEnv {
54
+ /** Is there ANY executable surface — runnable hooks, an own-repo MCP server, or
55
+ * a model-invocable skill? Nothing to run → never ask, never nudge. */
56
+ readonly hasExecutable: boolean;
57
+ /** Both stdin AND stdout are a terminal (a human who can answer + wait). */
58
+ readonly isTTY: boolean;
59
+ /** `--json` — machine output; stays a read even at a TTY (never prompt). */
60
+ readonly json: boolean;
61
+ /** `--no-interactive` / `--yes` — explicit agent/CI mode (never prompt). */
62
+ readonly noInteractive: boolean;
63
+ /** Sticky remembered choice from `.vigilesrc.json` (`audit.measure`), or undefined. */
64
+ readonly remembered?: boolean;
65
+ }
66
+ /**
67
+ * Decide what `audit` does with the executing checks. Total + pure; the first
68
+ * matching rule wins. There is NO execution flag — `audit` is a local report, so
69
+ * the executing checks need a human to consent:
70
+ * 1. nothing executable → skip "nothing" (a clean read; no nudge)
71
+ * 2. headless (`--json` / `--no-interactive` / non-TTY — an agent, a pipe, CI) →
72
+ * skip "headless" (no one to ask; automation uses the `vigiles/testing` API)
73
+ * 3. sticky no → skip "remembered-no"
74
+ * 4. sticky yes → run
75
+ * 5. interactive human, no sticky choice → ask (then remember)
76
+ */
77
+ export declare function decideExecute(o: ExecuteEnv): ExecuteDecision;
78
+ /**
79
+ * The one-line "executing checks not run" nudge for a skipped read (the
80
+ * no-silent-skips corollary). Returns null for `nothing` (nothing to run — not a
81
+ * gap). There is no flag to point at — `audit` runs them only interactively, and
82
+ * automation uses the `vigiles/testing` API.
83
+ */
84
+ export declare function formatExecuteSkip(reason: ExecuteSkipReason): string | null;
85
+ /**
86
+ * A starter `--prompts` file (the real `TriggerPromptSet` shape: bare skill name
87
+ * → `{ prompts, irrelevant }`). One entry per triggerable skill, with TODO
88
+ * placeholders the user replaces with real requests.
89
+ */
90
+ export declare function scaffoldTriggerPrompts(skillNames: readonly string[]): string;
91
+ //# sourceMappingURL=scan-trigger-suggest.d.ts.map
@@ -0,0 +1,103 @@
1
+ "use strict";
2
+ /**
3
+ * audit → the ONE read-vs-run decision. `audit` is a Lighthouse-style LOCAL
4
+ * report: a deterministic READ by default — safe + identical on every OS, nothing
5
+ * executes — NOT a CI step (CI uses `vigiles lint`, the deterministic gate). The
6
+ * executing checks (safety battery, live MCP resolution, skill-firing trigger-rate)
7
+ * run ONLY when there's a human to consent: at a TTY `audit` ASKS once (and
8
+ * remembers in `.vigilesrc.json` `audit.measure`); headless (an agent / `--json` /
9
+ * `--no-interactive` / a pipe) it stays a read + a one-line nudge — never hangs,
10
+ * never silently executes. There is deliberately NO execution flag: automation
11
+ * tests the harness through the `vigiles/testing` API + skills (the layered tiers),
12
+ * not through the report verb. The IO (prompt / run / remember) lives in the CLI;
13
+ * this is the pure decision + helpers.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.hasModelAccess = hasModelAccess;
17
+ exports.isMeteredAccess = isMeteredAccess;
18
+ exports.decideExecute = decideExecute;
19
+ exports.formatExecuteSkip = formatExecuteSkip;
20
+ exports.scaffoldTriggerPrompts = scaffoldTriggerPrompts;
21
+ /**
22
+ * Is a real model reachable for the trigger tier? Either a metered API key
23
+ * (`ANTHROPIC_API_KEY`), OR an authenticated Claude Code session (`CLAUDECODE=1`
24
+ * / `CLAUDE_CODE_ENTRYPOINT`, web/desktop/CLI) — the latter drives the `claude`
25
+ * CLI on the user's subscription, no key needed and $0 metered. A tiny env-only
26
+ * predicate (not a live probe), so it never spends a token just to decide.
27
+ */
28
+ function hasModelAccess(env) {
29
+ return Boolean(env.ANTHROPIC_API_KEY ||
30
+ env.CLAUDECODE === "1" ||
31
+ env.CLAUDE_CODE_ENTRYPOINT);
32
+ }
33
+ /**
34
+ * Is the reachable model METERED (a paid API key) rather than a subscription?
35
+ * Only affects the consent DISCLOSURE wording (a metered key bills per token; a
36
+ * subscription is $0 metered) — the run/skip decision itself is consent-driven,
37
+ * not metered-driven.
38
+ */
39
+ function isMeteredAccess(env) {
40
+ return Boolean(env.ANTHROPIC_API_KEY);
41
+ }
42
+ /**
43
+ * Decide what `audit` does with the executing checks. Total + pure; the first
44
+ * matching rule wins. There is NO execution flag — `audit` is a local report, so
45
+ * the executing checks need a human to consent:
46
+ * 1. nothing executable → skip "nothing" (a clean read; no nudge)
47
+ * 2. headless (`--json` / `--no-interactive` / non-TTY — an agent, a pipe, CI) →
48
+ * skip "headless" (no one to ask; automation uses the `vigiles/testing` API)
49
+ * 3. sticky no → skip "remembered-no"
50
+ * 4. sticky yes → run
51
+ * 5. interactive human, no sticky choice → ask (then remember)
52
+ */
53
+ function decideExecute(o) {
54
+ if (!o.hasExecutable)
55
+ return { kind: "skip", reason: "nothing" };
56
+ if (o.json || o.noInteractive || !o.isTTY)
57
+ return { kind: "skip", reason: "headless" };
58
+ if (o.remembered === false)
59
+ return { kind: "skip", reason: "remembered-no" };
60
+ if (o.remembered === true)
61
+ return { kind: "run" };
62
+ return { kind: "ask" };
63
+ }
64
+ /**
65
+ * The one-line "executing checks not run" nudge for a skipped read (the
66
+ * no-silent-skips corollary). Returns null for `nothing` (nothing to run — not a
67
+ * gap). There is no flag to point at — `audit` runs them only interactively, and
68
+ * automation uses the `vigiles/testing` API.
69
+ */
70
+ function formatExecuteSkip(reason) {
71
+ switch (reason) {
72
+ case "nothing":
73
+ return null;
74
+ case "headless":
75
+ return ("\nℹ Executing checks (safety battery · live MCP · skill firing) skipped — " +
76
+ "`audit` runs them only interactively (a terminal). For automation, test the " +
77
+ "harness with the `vigiles/testing` API.");
78
+ case "remembered-no":
79
+ return ("\nℹ Executing checks not run (you disabled them — edit .vigilesrc.json " +
80
+ "`audit.measure` to re-enable).");
81
+ }
82
+ }
83
+ /**
84
+ * A starter `--prompts` file (the real `TriggerPromptSet` shape: bare skill name
85
+ * → `{ prompts, irrelevant }`). One entry per triggerable skill, with TODO
86
+ * placeholders the user replaces with real requests.
87
+ */
88
+ function scaffoldTriggerPrompts(skillNames) {
89
+ const obj = {};
90
+ for (const name of skillNames) {
91
+ obj[name] = {
92
+ prompts: [
93
+ `TODO: a request that SHOULD trigger "${name}"`,
94
+ `TODO: a differently-phrased request that should also trigger it`,
95
+ ],
96
+ irrelevant: [
97
+ `TODO: an unrelated request that should NOT trigger "${name}"`,
98
+ ],
99
+ };
100
+ }
101
+ return JSON.stringify(obj, null, 2) + "\n";
102
+ }
103
+ //# sourceMappingURL=scan-trigger-suggest.js.map
package/dist/scan.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * `vigiles scan <dir>` — point vigiles at any plugin/repo and see what it ships
2
+ * `vigiles audit <dir>` — point vigiles at any plugin/repo and see what it ships
3
3
  * and what's broken, with **no model and no API key**.
4
4
  *
5
5
  * This is the deterministic substrate under the plugin/skill leaderboard
@@ -27,14 +27,20 @@ export interface ScanSkill {
27
27
  readonly name: string;
28
28
  readonly path: string;
29
29
  readonly hasDescription: boolean;
30
+ /**
31
+ * The skill's effective description (frontmatter `description`, else the first
32
+ * body paragraph — the same text the selector keys on), trimmed; `undefined`
33
+ * when neither exists. Feeds the model trigger tier's auto-generated probes.
34
+ */
35
+ readonly description?: string;
30
36
  readonly userInvoked: boolean;
31
37
  /**
32
38
  * The description's dominant script when it DIFFERS from the expected one
33
39
  * (default `"Latin"`), else null. The model's skill-selection context is
34
40
  * English-centric, so a description in another script carries a cross-language
35
41
  * trigger risk — it may under-fire on English prompts. A RISK flag, not a
36
- * defect (a language-matched audience is fine); measure the real gap with
37
- * `scan --trigger`.
42
+ * defect (a language-matched audience is fine); measure the real gap with the
43
+ * `audit` trigger tier / `measureTriggerRate`.
38
44
  */
39
45
  readonly descriptionScript: Script | null;
40
46
  }
@@ -86,15 +92,32 @@ export interface FrontmatterValueIssue {
86
92
  /** ok = file present; missing = referenced but absent; unresolved = path still has an unexpanded var, can't check. */
87
93
  export type HookStatus = "ok" | "missing" | "unresolved";
88
94
  export interface ScanHook {
95
+ /**
96
+ * The full hook command as it would be run (plugin-root token expanded, shell
97
+ * quotes stripped). Present on script-based hooks; empty string on hooks whose
98
+ * command is entirely inline (no script file) — but inline hooks never appear
99
+ * in `hooks[]`, they are counted by `inlineHooks`, so in practice `command` is
100
+ * always non-empty when a `ScanHook` is in the list.
101
+ */
102
+ readonly command: string;
89
103
  readonly script: string;
90
104
  readonly status: HookStatus;
105
+ /**
106
+ * The hook EVENT this script is registered under (`PreToolUse`, `PostToolUse`,
107
+ * `SessionStart`, …), when it can be determined from the canonical
108
+ * object-keyed-by-event settings shape; `undefined` for a non-object/array
109
+ * config. The safety battery uses it to test only the blocking-capable
110
+ * `PreToolUse` guards — so a `SessionStart`/`PostToolUse` hook isn't unfairly
111
+ * scored against "does it block rm -rf".
112
+ */
113
+ readonly event?: string;
91
114
  }
92
115
  /**
93
116
  * The repo's top-level instruction file (`CLAUDE.md` / `AGENTS.md`), if present.
94
117
  * Every cc/codex repo has one even when it ships no plugin surface, so `scan`
95
118
  * reports it — otherwise a plain instruction-only repo looks empty. `hasSpec` is
96
119
  * the deterministic fact that a `<file>.spec.ts` sits beside it (spec-managed vs
97
- * hand-written); it is informational, NOT the `require-spec` gate (that's lint).
120
+ * hand-written); it is informational, NOT the `require-instructions-spec` gate (that's lint).
98
121
  */
99
122
  export interface ScanInstructions {
100
123
  readonly file: string;
@@ -109,6 +132,13 @@ export interface ScanReport {
109
132
  readonly hooks: readonly ScanHook[];
110
133
  /** Hook entries with no script file (inline shell one-liners) — can't be path-checked. */
111
134
  readonly inlineHooks: number;
135
+ /**
136
+ * Hand-written hook commands that are NOT compiled `vigiles/hook` artifacts (a
137
+ * compiled hook's command invokes `vigiles hook-runtime run-program`). The basis
138
+ * for the `prefer-compiled-hooks` recommendation — a single nudge regardless of
139
+ * count. Zero when there are no hooks or every hook is vigiles-managed.
140
+ */
141
+ readonly manualHookCount: number;
112
142
  readonly commands: number;
113
143
  readonly mcp: boolean;
114
144
  /**
@@ -175,17 +205,28 @@ export interface SurfaceClassifier {
175
205
  * no drift). The ≥20% guard avoids a near-empty string tripping on one letter.
176
206
  */
177
207
  export declare function unexpectedScript(text: string, expected?: Script): Script | null;
208
+ /**
209
+ * A compiled `vigiles/hook` artifact runs through the `hook-runtime run-program`
210
+ * runtime entrypoint; any other hook command is hand-written (a shell script or
211
+ * an inline one-liner) the author maintains directly. The basis for the
212
+ * `prefer-compiled-hooks` nudge.
213
+ */
214
+ export declare function isManagedHookCommand(command: string): boolean;
215
+ /** The `prefer-compiled-hooks` recommendation message (shared by `lint` + `scan`). */
216
+ export declare function preferCompiledHooksMessage(count: number): string;
178
217
  /** Scan a plugin/repo directory and report its surfaces + structural issues. */
179
218
  export declare function scanPlugin(dir: string, layout?: PluginLayout, dialect?: HarnessDialect): ScanReport;
180
219
  /**
181
- * LIVE MCP tool resolution for a scanned plugin — the opt-in (`scan --verify-mcp`)
182
- * dynamic check no static linter can do: it STARTS each declared MCP server and
183
- * checks every `mcp__server__tool` the plugin's agents reference actually exists on
184
- * it (catching rename/removal rot, e.g. `create_issue`→`issue_write`). Reuses the
220
+ * LIVE MCP tool resolution for a scanned plugin — the dynamic check no static
221
+ * linter can do: it STARTS each declared MCP server and checks every
222
+ * `mcp__server__tool` the plugin's agents reference actually exists on it
223
+ * (catching rename/removal rot, e.g. `create_issue`→`issue_write`). Reuses the
185
224
  * already-computed `report` (its agents' tool lists) + the declared server configs;
186
225
  * returns `[]` when the plugin declares no MCP servers (nothing to start). Async +
187
- * side-effecting (spawns servers) — which is exactly why it's opt-in, not a default
188
- * lint rule. See `verifyMcpContractTools` (core/mcp.ts).
226
+ * side-effecting (spawns servers) — so `audit` runs it by default only for the
227
+ * user's OWN repo (own-repo, like running your own tools); a FOREIGN plugin's
228
+ * servers are never spawned, and `--fast` opts out. See `verifyMcpContractTools`
229
+ * (core/mcp.ts).
189
230
  */
190
231
  export declare function verifyLiveMcpTools(report: ScanReport, layout: PluginLayout, dialect: HarnessDialect, timeoutMs?: number): Promise<McpContractToolError[]>;
191
232
  /** Render the live MCP tool-check result (human-readable). */
@@ -211,7 +252,7 @@ export interface MarketplaceInfo {
211
252
  * Read a `marketplace.json` beside the layout's plugin manifest and classify its
212
253
  * members into on-disk vs external. Returns `null` when `dir` is not a
213
254
  * marketplace. The source of truth behind {@link expandMarketplace} and the
214
- * curated-marketplace report in `vigiles scan`.
255
+ * curated-marketplace report in `vigiles audit`.
215
256
  */
216
257
  export declare function inspectMarketplace(dir: string, layout?: PluginLayout): MarketplaceInfo | null;
217
258
  /**
@@ -219,7 +260,7 @@ export declare function inspectMarketplace(dir: string, layout?: PluginLayout):
219
260
  * plugin manifest, e.g. `.claude-plugin/marketplace.json`), expand it into the
220
261
  * absolute dirs of its member plugins. Returns `null` when there's no
221
262
  * marketplace, `[]` when it's a marketplace whose members are all external (not
222
- * on disk). Used by `vigiles scan` to rank a whole marketplace — wshobson/agents
263
+ * on disk). Used by `vigiles audit` to rank a whole marketplace — wshobson/agents
223
264
  * alone ships 80+ plugins under one `marketplace.json`. See {@link inspectMarketplace}.
224
265
  */
225
266
  export declare function expandMarketplace(dir: string, layout?: PluginLayout): string[] | null;
package/dist/scan.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  /**
3
- * `vigiles scan <dir>` — point vigiles at any plugin/repo and see what it ships
3
+ * `vigiles audit <dir>` — point vigiles at any plugin/repo and see what it ships
4
4
  * and what's broken, with **no model and no API key**.
5
5
  *
6
6
  * This is the deterministic substrate under the plugin/skill leaderboard
@@ -14,6 +14,8 @@
14
14
  */
15
15
  Object.defineProperty(exports, "__esModule", { value: true });
16
16
  exports.unexpectedScript = unexpectedScript;
17
+ exports.isManagedHookCommand = isManagedHookCommand;
18
+ exports.preferCompiledHooksMessage = preferCompiledHooksMessage;
17
19
  exports.scanPlugin = scanPlugin;
18
20
  exports.verifyLiveMcpTools = verifyLiveMcpTools;
19
21
  exports.formatMcpContractReport = formatMcpContractReport;
@@ -166,6 +168,7 @@ function scanSkills(files, cls) {
166
168
  name: fm.name ?? skillName(path),
167
169
  path,
168
170
  hasDescription: Boolean(effectiveDesc && effectiveDesc.length >= 20),
171
+ description: effectiveDesc?.trim(),
169
172
  userInvoked: /^\s*disable-model-invocation:\s*true\s*$/m.test(md),
170
173
  descriptionScript: effectiveDesc ? unexpectedScript(effectiveDesc) : null,
171
174
  });
@@ -242,7 +245,7 @@ function scanAgents(files, dialect, declaredServers, cls) {
242
245
  * quotes. A token that still carries any `$VAR` after that is genuinely
243
246
  * uncheckable.
244
247
  */
245
- function resolveScript(token, root, pluginRootToken) {
248
+ function resolveScript(token, root, pluginRootToken, fullCommand) {
246
249
  // "${CLAUDE_PLUGIN_ROOT}" → unbraced "$CLAUDE_PLUGIN_ROOT".
247
250
  const unbraced = pluginRootToken.replace(/^\$\{(.+)\}$/, "$$$1");
248
251
  const cleaned = token
@@ -250,14 +253,23 @@ function resolveScript(token, root, pluginRootToken) {
250
253
  .replaceAll(pluginRootToken, root)
251
254
  .replaceAll(unbraced, root);
252
255
  if (cleaned.includes("$"))
253
- return { script: token, status: "unresolved" };
256
+ return { command: fullCommand, script: token, status: "unresolved" };
254
257
  // A relative hook path (`./hooks/x.sh`, `scripts/x.py`) is the plugin's own —
255
258
  // resolve it against the PLUGIN ROOT, not the scanner's cwd. Without this, a
256
259
  // plugin that references `./hooks/x.sh` (the file IS present) was reported
257
260
  // MISSING because existsSync() checked cwd-relative (a false positive caught on
258
261
  // ananddtyagi/cc-marketplace). The displayed `script` stays as the author wrote it.
259
262
  const abs = (0, node_path_1.isAbsolute)(cleaned) ? cleaned : (0, node_path_1.resolve)(root, cleaned);
260
- return { script: cleaned, status: (0, node_fs_1.existsSync)(abs) ? "ok" : "missing" };
263
+ // Resolve the full command the same way we resolve the script token (expand
264
+ // plugin-root, strip outer quotes) so the CLI can pass it to verifyGuardrail.
265
+ const resolvedCommand = fullCommand
266
+ .replaceAll(pluginRootToken, root)
267
+ .replaceAll(unbraced, root);
268
+ return {
269
+ command: resolvedCommand,
270
+ script: cleaned,
271
+ status: (0, node_fs_1.existsSync)(abs) ? "ok" : "missing",
272
+ };
261
273
  }
262
274
  // A shell existence guard around a command — `[ ! -f x ] || x`, `[ -f x ] && x`,
263
275
  // `test -f x && …`. Authors use it to make a hook OPTIONAL (run the script only
@@ -265,10 +277,65 @@ function resolveScript(token, root, pluginRootToken) {
265
277
  // target is INTENTIONAL, not a broken reference. Don't flag scripts in such a
266
278
  // command as MISSING (a false positive caught on gmickel/flow-next's ralph-guard).
267
279
  const EXISTENCE_GUARD = /(?:\[\[?\s*!?\s*-[efsx]\s)|(?:\btest\s+!?\s*-[efsx]\s)/;
280
+ /**
281
+ * A compiled `vigiles/hook` artifact runs through the `hook-runtime run-program`
282
+ * runtime entrypoint; any other hook command is hand-written (a shell script or
283
+ * an inline one-liner) the author maintains directly. The basis for the
284
+ * `prefer-compiled-hooks` nudge.
285
+ */
286
+ function isManagedHookCommand(command) {
287
+ return /\bhook-runtime\b/.test(command);
288
+ }
289
+ /** The `prefer-compiled-hooks` recommendation message (shared by `lint` + `scan`). */
290
+ function preferCompiledHooksMessage(count) {
291
+ return (`${String(count)} hand-written hook command(s) — if any gate the agent ` +
292
+ `(a block/deny decision), compiled hooks (\`vigiles/hook\`) make whole hook ` +
293
+ `bug classes unrepresentable at authoring time, and \`guardrail-check\` proves ` +
294
+ `an existing one blocks. See docs/compiled-hooks.md.`);
295
+ }
268
296
  /** Pull script-file hook commands out of the resolved settings; count inline ones. */
297
+ /**
298
+ * Best-effort map of each script token → the hook EVENT it's registered under,
299
+ * by walking the canonical object-keyed-by-event settings shape
300
+ * (`{ PreToolUse: [{ hooks: [{ command }] }], … }`). Lets the safety battery
301
+ * scope itself to `PreToolUse` (the only event that can block a tool call), so a
302
+ * `SessionStart`/`PostToolUse`/`Stop` hook isn't tested against the disaster
303
+ * catalog. Returns an empty map for a non-object/array config (event → unknown).
304
+ */
305
+ function eventsByScript(hooks) {
306
+ const map = new Map();
307
+ if (!hooks || typeof hooks !== "object" || Array.isArray(hooks))
308
+ return map;
309
+ for (const [event, arr] of Object.entries(hooks)) {
310
+ if (!Array.isArray(arr))
311
+ continue;
312
+ for (const entry of arr) {
313
+ const hookList = entry.hooks;
314
+ if (!Array.isArray(hookList))
315
+ continue;
316
+ for (const h of hookList) {
317
+ const cmd = h.command;
318
+ if (typeof cmd !== "string")
319
+ continue;
320
+ for (const tok of cmd.match(SCRIPT_RE) ?? []) {
321
+ if (!map.has(tok))
322
+ map.set(tok, event);
323
+ }
324
+ }
325
+ }
326
+ }
327
+ return map;
328
+ }
269
329
  function scanHooks(settings, root, pluginRootToken) {
270
330
  const text = JSON.stringify(settings.hooks ?? {});
271
331
  const commands = [...text.matchAll(/"command":\s*"((?:[^"\\]|\\.)*)"/g)].map((m) => m[1]);
332
+ const evMap = eventsByScript(settings.hooks);
333
+ // A hand-written hook is any non-empty command that isn't a vigiles-managed
334
+ // (compiled) hook-runtime invocation — the basis for the prefer-compiled-hooks nudge.
335
+ const manual = commands.filter((c) => {
336
+ const u = c.replace(/\\(.)/g, "$1").trim();
337
+ return u !== "" && !isManagedHookCommand(u);
338
+ }).length;
272
339
  const byScript = new Map();
273
340
  let inline = 0;
274
341
  for (const cmd of commands) {
@@ -285,12 +352,13 @@ function scanHooks(settings, root, pluginRootToken) {
285
352
  continue;
286
353
  }
287
354
  for (const tok of found) {
288
- const hook = resolveScript(tok, root, pluginRootToken);
289
- byScript.set(hook.script, hook);
355
+ const hook = resolveScript(tok, root, pluginRootToken, unescaped);
356
+ const event = evMap.get(tok);
357
+ byScript.set(hook.script, event ? { ...hook, event } : hook);
290
358
  }
291
359
  }
292
360
  const hooks = [...byScript.values()].sort((a, b) => a.script.localeCompare(b.script));
293
- return { hooks, inline };
361
+ return { hooks, inline, manual };
294
362
  }
295
363
  // ---------------------------------------------------------------------------
296
364
  // Public API
@@ -487,7 +555,7 @@ function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect) {
487
555
  const lay = layout ?? layout_js_1.claudeCodeLayout;
488
556
  const cls = makeClassifier(lay);
489
557
  const loaded = (0, plugin_loader_js_1.loadPlugin)(dir, lay);
490
- const { hooks, inline } = scanHooks(loaded.settings, (0, node_path_1.resolve)(dir), lay.pluginRootToken);
558
+ const { hooks, inline, manual } = scanHooks(loaded.settings, (0, node_path_1.resolve)(dir), lay.pluginRootToken);
491
559
  // Hook-event keys are a CLOSED platform set — an unrecognized one is a dead
492
560
  // registration (the hook never fires), so flag every unknown (not just typos).
493
561
  // ONLY for the canonical object-keyed-by-event shape: a plugin shipping a
@@ -521,6 +589,7 @@ function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect) {
521
589
  agents,
522
590
  hooks,
523
591
  inlineHooks: inline,
592
+ manualHookCount: manual,
524
593
  commands: Object.keys(loaded.files).filter(cls.isCommand).length,
525
594
  mcp: loaded.warnings.some((w) => w.includes("MCP server")),
526
595
  danglingRefs: (0, plugin_loader_js_2.danglingRefs)((0, node_path_1.resolve)(dir), lay),
@@ -539,14 +608,16 @@ function scanPlugin(dir, layout, dialect = dialect_js_1.claudeCodeDialect) {
539
608
  };
540
609
  }
541
610
  /**
542
- * LIVE MCP tool resolution for a scanned plugin — the opt-in (`scan --verify-mcp`)
543
- * dynamic check no static linter can do: it STARTS each declared MCP server and
544
- * checks every `mcp__server__tool` the plugin's agents reference actually exists on
545
- * it (catching rename/removal rot, e.g. `create_issue`→`issue_write`). Reuses the
611
+ * LIVE MCP tool resolution for a scanned plugin — the dynamic check no static
612
+ * linter can do: it STARTS each declared MCP server and checks every
613
+ * `mcp__server__tool` the plugin's agents reference actually exists on it
614
+ * (catching rename/removal rot, e.g. `create_issue`→`issue_write`). Reuses the
546
615
  * already-computed `report` (its agents' tool lists) + the declared server configs;
547
616
  * returns `[]` when the plugin declares no MCP servers (nothing to start). Async +
548
- * side-effecting (spawns servers) — which is exactly why it's opt-in, not a default
549
- * lint rule. See `verifyMcpContractTools` (core/mcp.ts).
617
+ * side-effecting (spawns servers) — so `audit` runs it by default only for the
618
+ * user's OWN repo (own-repo, like running your own tools); a FOREIGN plugin's
619
+ * servers are never spawned, and `--fast` opts out. See `verifyMcpContractTools`
620
+ * (core/mcp.ts).
550
621
  */
551
622
  async function verifyLiveMcpTools(report, layout, dialect, timeoutMs = 10000) {
552
623
  // collectMcpServers yields the raw JSON server entries; a malformed one (no
@@ -571,7 +642,7 @@ function formatMcpContractReport(errors) {
571
642
  * Read a `marketplace.json` beside the layout's plugin manifest and classify its
572
643
  * members into on-disk vs external. Returns `null` when `dir` is not a
573
644
  * marketplace. The source of truth behind {@link expandMarketplace} and the
574
- * curated-marketplace report in `vigiles scan`.
645
+ * curated-marketplace report in `vigiles audit`.
575
646
  */
576
647
  function inspectMarketplace(dir, layout = layout_js_1.claudeCodeLayout) {
577
648
  const mpPath = (0, node_path_1.join)(dir, (0, node_path_1.dirname)(layout.manifestPath), "marketplace.json");
@@ -623,7 +694,7 @@ function inspectMarketplace(dir, layout = layout_js_1.claudeCodeLayout) {
623
694
  * plugin manifest, e.g. `.claude-plugin/marketplace.json`), expand it into the
624
695
  * absolute dirs of its member plugins. Returns `null` when there's no
625
696
  * marketplace, `[]` when it's a marketplace whose members are all external (not
626
- * on disk). Used by `vigiles scan` to rank a whole marketplace — wshobson/agents
697
+ * on disk). Used by `vigiles audit` to rank a whole marketplace — wshobson/agents
627
698
  * alone ships 80+ plugins under one `marketplace.json`. See {@link inspectMarketplace}.
628
699
  */
629
700
  function expandMarketplace(dir, layout = layout_js_1.claudeCodeLayout) {
@@ -749,6 +820,11 @@ function formatScanReport(r) {
749
820
  if (r.skillMetaIssues.length > 0) {
750
821
  out.push(`ℹ ${String(r.skillMetaIssues.length)} skill(s) lack an explicit frontmatter name/description (recommended for a reliable trigger surface) — they still load via fallback`, "");
751
822
  }
823
+ // One discovery nudge toward compiled hooks (never per-hook); the hand-written
824
+ // shell lane stays first-class, so this is a recommendation, not a defect.
825
+ if (r.manualHookCount > 0) {
826
+ out.push(`ℹ ${preferCompiledHooksMessage(r.manualHookCount)}`, "");
827
+ }
752
828
  // Malformed-YAML frontmatter is INFORMATIONAL, not a structural defect: js-yaml
753
829
  // is stricter than some loaders (a colon/quote/<example> in a one-line
754
830
  // description trips it though the file may still load), and the other fields are
@@ -35,7 +35,7 @@ export type BehavioralSymptom = "wrong-skill-fires" | "skill-never-fires" | "age
35
35
  * never-available tool can't be called); the cause is near-certain.
36
36
  * - `"possible"` — a high-precision PROXY for a behavioral risk (a description
37
37
  * overlap / a foreign-script description); deterministic to detect, but whether
38
- * it actually moved behaviour is confirmed by `scan --trigger`.
38
+ * it actually moved behaviour is confirmed by the `audit` trigger tier.
39
39
  */
40
40
  export type ExplanationConfidence = "likely" | "possible";
41
41
  export interface ScoreExplanation {
@@ -27,6 +27,10 @@ export interface SetupPlan {
27
27
  export interface ParsedSetupArgs {
28
28
  target?: string;
29
29
  strict: boolean;
30
+ /** `--report-only` — write the gating rules at "warn" (nothing fails CI). The
31
+ * orthogonal severity dial; composes with `--strict` (which rules) by setting
32
+ * their severity. */
33
+ reportOnly: boolean;
30
34
  yes: boolean;
31
35
  /** `--force` — rewrite a stale CI workflow in place. */
32
36
  force: boolean;
@@ -51,9 +55,52 @@ export declare function defaultPlan(strict?: boolean): SetupPlan;
51
55
  * changed (so the IO layer skips the write). The IO (read/parse/write + the
52
56
  * malformed-file guard) stays in cli.ts.
53
57
  */
58
+ /**
59
+ * The structural rules `init` gates BY DEFAULT (severity `error`, so a broken
60
+ * surface fails `vigiles lint`). Every one is HIGH-PRECISION / FP-safe — it fires
61
+ * only on a genuine defect (a never-available/typo'd tool, a subagent missing
62
+ * `name`/`description`, a typo'd hook event, a dead hook script, a broken MCP
63
+ * ref, two skills that collide in the selector) — so a well-formed plugin stays
64
+ * green and catching real breakage out of the box never cries wolf.
65
+ *
66
+ * Deliberately EXCLUDES `require-instructions-spec` and the workflow-forcing rules:
67
+ * those make a CLEAN repo fail (you simply haven't written the spec/test yet), so
68
+ * they stay opt-in under `--strict` (progressive adoption — see
69
+ * `STRICT_EXTRA_RULES`).
70
+ *
71
+ * This is the **`structural`** rule group (see research/install-enforcement-dx.md).
72
+ */
73
+ export declare const STRUCTURAL_RULES: readonly ["subagent-tool-contract", "subagent-frontmatter", "hook-events", "hook-script-exists", "mcp-config", "mcp-tool-resolves", "mcp-hook-target-resolves", "disallowed-tools-contract", "description-overlap"];
74
+ /**
75
+ * The **`workflow`** group — the WORKFLOW-FORCING / opinionated tier `--strict`
76
+ * gates, which a clean repo can still fail because you haven't done the work yet:
77
+ * a spec per instruction file (`require-instructions-spec`), a test/eval per
78
+ * surface (`untested-*`). Opt-in by design (the smooth-adoption on-ramp). The
79
+ * Clippy-`pedantic` / TS-`strict` analog — ONE opinionated opt-in.
80
+ *
81
+ * NB `frontmatter-valid` / `skill-frontmatter` live in the `nudge` group, not
82
+ * here: they're acknowledged-noisy recommendations we never gate on (see
83
+ * research/install-enforcement-dx.md).
84
+ */
85
+ export declare const WORKFLOW_RULES: readonly ["require-instructions-spec", "untested-skill", "untested-subagent", "untested-hook"];
86
+ /**
87
+ * The **`nudge`** group — recommendations / acknowledged-noisy checks that NEVER
88
+ * gate (not even under `--strict`): `frontmatter-valid` (js-yaml is stricter than
89
+ * CC's loader), `skill-frontmatter` (skills load without it) and `unmarked-refs`
90
+ * (the undecidable-plaintext nudge) sit at `warn`; `prefer-compiled-hooks` defaults
91
+ * OFF (a recommendation that shouldn't fire unasked — the shell lane stays
92
+ * first-class). `init` does not write these — they keep their own default
93
+ * severities. Named for the group taxonomy (research/install-enforcement-dx.md).
94
+ */
95
+ export declare const NUDGE_RULES: readonly ["frontmatter-valid", "skill-frontmatter", "prefer-compiled-hooks", "unmarked-refs"];
54
96
  export declare function mergeProjectConfig(existing: Record<string, unknown>, opts: {
55
97
  harness: string | string[];
56
98
  strict: boolean;
99
+ reportOnly?: boolean;
100
+ /** Whether the LINT pillar is on (default true). The rule gate is a lint-layer
101
+ * concern, so a test-only setup (`init --test` / `--no-lint`) records the
102
+ * harness but writes NO lint rules. */
103
+ lint?: boolean;
57
104
  }): Record<string, unknown> | null;
58
105
  /**
59
106
  * Whether to drop into interactive prompts: a human at a TTY who passed neither
@@ -62,7 +109,18 @@ export declare function mergeProjectConfig(existing: Record<string, unknown>, op
62
109
  */
63
110
  export declare function shouldPrompt(parsed: ParsedSetupArgs, isTTY: boolean): boolean;
64
111
  /** Interactive answers (only the fields the prompts cover). */
65
- export type SetupAnswers = Partial<Pick<SetupPlan, "lint" | "test" | "gha" | "plugin">>;
112
+ export type SetupAnswers = Partial<Pick<SetupPlan, "lint" | "test" | "gha" | "plugin" | "strict">>;
113
+ /** Ask one question with a default — injected so the interactive Q&A is pure +
114
+ * unit-testable (a fake `ask` scripts answers; no TTY, no readline). */
115
+ export type AskFn = (question: string, def: string) => Promise<string>;
116
+ /**
117
+ * The interactive setup Q&A as PURE logic over an injected `ask` — the prompts,
118
+ * their defaults, and the answer→`SetupAnswers` mapping. The IO shell (readline)
119
+ * lives in `cli.ts`'s `promptSetup`, which just supplies a real `ask`. Keeping
120
+ * this here means the fragile interactive path is unit-tested deterministically
121
+ * (the questions can't silently break) without a terminal.
122
+ */
123
+ export declare function collectSetupAnswers(ask: AskFn): Promise<SetupAnswers>;
66
124
  /**
67
125
  * How to install vigiles's skills/hooks for ONE harness — the deterministic
68
126
  * decision behind the IO in cli.ts, so a CI test asserts WHICH commands an