vigiles 10.0.0 → 11.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 (38) hide show
  1. package/README.md +113 -83
  2. package/dist/adapters/claude-code/dialect.js +15 -0
  3. package/dist/audit-report.d.ts +1 -1
  4. package/dist/audit-report.template.html +1 -1
  5. package/dist/audit-score.d.ts +19 -12
  6. package/dist/audit-score.js +65 -11
  7. package/dist/cli.js +249 -0
  8. package/dist/core/CLAUDE.md.spec.d.ts +3 -0
  9. package/dist/core/CLAUDE.md.spec.js +26 -0
  10. package/dist/core/delegation-trifecta.d.ts +64 -0
  11. package/dist/core/delegation-trifecta.js +124 -0
  12. package/dist/core/dialect.d.ts +18 -0
  13. package/dist/core/hook-block-ineffective.d.ts +62 -0
  14. package/dist/core/hook-block-ineffective.js +153 -0
  15. package/dist/core/hook-matcher.d.ts +66 -0
  16. package/dist/core/hook-matcher.js +182 -0
  17. package/dist/core/hook-normalize.d.ts +43 -0
  18. package/dist/core/hook-normalize.js +78 -0
  19. package/dist/core/lethal-trifecta.d.ts +100 -0
  20. package/dist/core/lethal-trifecta.js +197 -0
  21. package/dist/core/plugin-dir-layout.d.ts +30 -0
  22. package/dist/core/plugin-dir-layout.js +73 -0
  23. package/dist/core/rule-meta.d.ts +82 -0
  24. package/dist/core/rule-meta.js +266 -0
  25. package/dist/core/skill-missing-fence.d.ts +47 -0
  26. package/dist/core/skill-missing-fence.js +119 -0
  27. package/dist/core/skill-resources.d.ts +27 -0
  28. package/dist/core/skill-resources.js +167 -0
  29. package/dist/core/types.d.ts +71 -0
  30. package/dist/core/validate.d.ts +1 -0
  31. package/dist/core/validate.js +26 -4
  32. package/dist/leaderboard.d.ts +1 -0
  33. package/dist/leaderboard.js +42 -4
  34. package/dist/scan.d.ts +106 -0
  35. package/dist/scan.js +251 -45
  36. package/dist/setup-plan.d.ts +6 -3
  37. package/dist/setup-plan.js +12 -2
  38. package/package.json +1 -1
@@ -42,6 +42,24 @@ export interface HarnessDialect {
42
42
  readonly knownMcpServers?: readonly string[];
43
43
  /** Hook event names the harness fires. */
44
44
  readonly hookEvents: readonly string[];
45
+ /**
46
+ * The subset of `hookEvents` where a block decision (`exit 2` / a deny field)
47
+ * is SILENTLY IGNORED ENTIRELY — no veto AND no model feedback (Claude Code's
48
+ * SessionStart / SessionEnd / Notification / PreCompact: exit 2 there writes
49
+ * stderr only to the user). The basis for the `hook-block-ineffective`
50
+ * "wrong-event" check, which fires ONLY on these (so it stays FP-safe and never
51
+ * cries wolf on a PostToolUse feedback/nudge hook). Optional (additive,
52
+ * non-breaking) — absent ⇒ the harness's block semantics are undeclared and the
53
+ * check does not run for it.
54
+ */
55
+ readonly noEffectHookEvents?: readonly string[];
56
+ /**
57
+ * The subset of blocking events whose deny REQUIRES the structured
58
+ * `permissionDecision` field (e.g. Claude Code's `PreToolUse`), where the
59
+ * legacy top-level `decision` field is silently ignored. The basis for the
60
+ * `hook-block-ineffective` "wrong-field" check. Optional (additive).
61
+ */
62
+ readonly permissionDecisionHookEvents?: readonly string[];
45
63
  /** Instruction-file targets the harness reads (also the h1 heading). */
46
64
  readonly instructionTargets: readonly string[];
47
65
  /** The env token expanded to the plugin root in hook commands. */
@@ -0,0 +1,62 @@
1
+ /** The two "looks like it blocks but doesn't" failure shapes. */
2
+ export type HookBlockKind = "wrong-event" | "wrong-field";
3
+ /** One false-confidence finding: a hook that appears to block but silently won't. */
4
+ export interface HookBlockFinding {
5
+ readonly event: string;
6
+ readonly kind: HookBlockKind;
7
+ /** The hook script that was inspected (path if resolved from a script file, else null = inline command). */
8
+ readonly scriptPath: string | null;
9
+ readonly message: string;
10
+ }
11
+ /** A single hook registration to inspect. */
12
+ export interface HookScriptEntry {
13
+ readonly event: string;
14
+ readonly matcher?: string;
15
+ /** The hook command line as registered. */
16
+ readonly command: string;
17
+ /** Resolved path to the script file the command runs, if known (else null → inspect `command`). */
18
+ readonly scriptPath?: string | null;
19
+ }
20
+ /** Options for {@link hookBlockIssues}. All fields are injectable for testability. */
21
+ export interface HookBlockOptions {
22
+ /**
23
+ * Events on this harness where a block decision (`exit 2` / a deny field) is
24
+ * SILENTLY IGNORED ENTIRELY — no veto AND no feedback to the model (Claude
25
+ * Code's `SessionStart`/`SessionEnd`/`Notification`/`PreCompact`: exit 2 there
26
+ * only writes stderr to the user). These are the ONLY events the "wrong-event"
27
+ * check fires on, so it stays FP-safe.
28
+ *
29
+ * Deliberately EXCLUDES `PostToolUse`: a `PostToolUse` exit 2 feeds stderr back
30
+ * to the model — a legitimate FEEDBACK channel, NOT a failed block — so flagging
31
+ * it would cry wolf on every nudge/lint hook (the dogfood lesson: vigiles's own
32
+ * `refs-nudge.sh` is exactly that shape). A hook that exits 2 on `PostToolUse`
33
+ * intending to BLOCK is misguided, but that intent is not deterministically
34
+ * distinguishable from feedback, so we don't flag it.
35
+ */
36
+ readonly noEffectEvents: ReadonlySet<string>;
37
+ /**
38
+ * Events that require the structured `permissionDecision` field for a deny
39
+ * (e.g. `PreToolUse`). On these events the legacy top-level `"decision":"block"`
40
+ * field is ignored; only `hookSpecificOutput.permissionDecision:"deny"` works.
41
+ */
42
+ readonly permissionDecisionEvents: ReadonlySet<string>;
43
+ /**
44
+ * Injectable file read (default: node:fs `readFileSync(p, "utf8")`).
45
+ * Returns `""` on any error so a missing / unreadable script doesn't crash
46
+ * the detector — it simply produces no findings for that entry.
47
+ */
48
+ readonly readFileSync?: (p: string) => string;
49
+ }
50
+ /**
51
+ * Detect false-confidence "blocks" across a set of hook entries.
52
+ *
53
+ * Returns one {@link HookBlockFinding} per entry (at most one per entry —
54
+ * `wrong-event` takes precedence over `wrong-field`). Identical
55
+ * (event, kind, scriptPath) pairs are de-duped.
56
+ *
57
+ * @param entries - The hook registrations to inspect (event + command/script).
58
+ * @param opts - Injected sets of blocking/permission events, and an optional
59
+ * `readFileSync` (default: node:fs).
60
+ */
61
+ export declare function hookBlockIssues(entries: readonly HookScriptEntry[], opts: HookBlockOptions): HookBlockFinding[];
62
+ //# sourceMappingURL=hook-block-ineffective.d.ts.map
@@ -0,0 +1,153 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.hookBlockIssues = hookBlockIssues;
4
+ /**
5
+ * Hook-block-ineffective detector — the #1 verified hook pain ("false confidence").
6
+ *
7
+ * A safety hook that LOOKS like it blocks but SILENTLY DOESN'T. Two shapes:
8
+ *
9
+ * 1. **wrong-event** — the script tries to block (contains `exit 2`, a legacy
10
+ * `"decision":"block"` JSON, or a `"permissionDecision":"deny"`) but is
11
+ * registered on an event where a block is SILENTLY IGNORED ENTIRELY —
12
+ * SessionStart / SessionEnd / Notification / PreCompact, where exit 2 writes
13
+ * stderr only to the user (no veto, no model feedback). The author believes a
14
+ * gate is in place; nothing happens. (#19009 names the class.) NB `PostToolUse`
15
+ * is deliberately NOT flagged: there exit 2 feeds stderr back to the model — a
16
+ * legitimate FEEDBACK channel (a nudge/lint hook), not a failed block, and the
17
+ * block-vs-feedback intent isn't deterministically separable.
18
+ *
19
+ * 2. **wrong-field** — the hook IS on a permission-gated event (e.g. PreToolUse)
20
+ * but emits the LEGACY top-level `"decision":"block"` field instead of the
21
+ * required `hookSpecificOutput.permissionDecision:"deny"`. A copied template
22
+ * (PostToolUse style → PreToolUse registration) is the usual cause; the deny
23
+ * is silently discarded, nothing is blocked.
24
+ *
25
+ * Both shapes cause identical user-visible behaviour: the hook "works" (exits,
26
+ * no crash) but never actually stops anything. The only signal is "why did this
27
+ * run anyway?" after an incident.
28
+ *
29
+ * FP-SAFETY — conservative literal patterns only, `warn` severity by default:
30
+ * - `exit 2` is matched by a shell-context regex that requires surrounding
31
+ * whitespace/control chars to avoid false-positives on `exit 200` or a
32
+ * `git status --exit-code 2` argument.
33
+ * - JSON decision patterns are matched literally (no partial JSON walking).
34
+ * - Nothing is flagged on an unknown event (we only know which events CAN
35
+ * block because the caller injected that set).
36
+ *
37
+ * HARNESS-NEUTRAL — the sets of blocking events and permission-decision events
38
+ * are INJECTED from the dialect (never hard-coded here). The caller supplies the
39
+ * Claude Code sets; a Codex adapter supplies its own. This is the same
40
+ * dependency-injection pattern used by `verifyHookEvents` and `verifyToolContract`
41
+ * (core ⊄ adapter — one-detector-no-drift).
42
+ *
43
+ * ONE detector reused by scan + the `hook-block-ineffective` lint rule (two
44
+ * callers, no drift). See `research/hook-pain-points.md` for the verified corpus
45
+ * and `docs/compiled-hooks.md` for the authoritative fix (compiled hooks make
46
+ * this whole class unrepresentable).
47
+ */
48
+ const node_fs_1 = require("node:fs");
49
+ // ---------------------------------------------------------------------------
50
+ // Block-mechanism patterns (conservative / FP-safe)
51
+ // ---------------------------------------------------------------------------
52
+ /**
53
+ * An `exit 2` statement.
54
+ *
55
+ * Requires a shell-context separator before `exit` (start-of-line, whitespace,
56
+ * `;`, `&`, `|`) and word-boundary / separator after `2` — so `exit 200` and
57
+ * `--exit-code 2` are NOT matched.
58
+ */
59
+ const EXIT_2 = /(^|[\s;&|])exit\s+2(\s|;|$|['")])/m;
60
+ /**
61
+ * A status-2 exit in a NON-shell hook script (a hook file may be `.js`/`.mjs`/
62
+ * `.py`/`.rb`): `process.exit(2)` (Node), `sys.exit(2)` / `exit(2)` (Python),
63
+ * `Process.exit(2)` / `exit(2)` (Ruby), `os._exit(2)`. So a guard written in
64
+ * Node/Python on a no-effect event isn't shown clean.
65
+ */
66
+ const EXIT_2_CODE = /\b(?:process\.exit|sys\.exit|os\._exit|Process\.exit|exit)\s*\(\s*2\s*\)/;
67
+ /**
68
+ * A legacy top-level `"decision":"block"` or `"decision":"deny"` JSON field.
69
+ * This is the OLD Claude Code hook output format. On permission-gated events
70
+ * (PreToolUse) it is ignored; on non-blocking events it never had any effect.
71
+ */
72
+ const DECISION_BLOCK = /"decision"\s*:\s*"(block|deny)"/;
73
+ /**
74
+ * The CORRECT structured deny for permission-gated events:
75
+ * `"permissionDecision":"deny"` or `"permissionDecision":"ask"`.
76
+ * (Both require a structured response, as opposed to the legacy field.)
77
+ */
78
+ const PERMISSION_DENY = /"permissionDecision"\s*:\s*"(deny|ask)"/;
79
+ // ---------------------------------------------------------------------------
80
+ // Detector
81
+ // ---------------------------------------------------------------------------
82
+ function defaultReadFile(p) {
83
+ try {
84
+ return (0, node_fs_1.readFileSync)(p, "utf8");
85
+ }
86
+ catch {
87
+ return "";
88
+ }
89
+ }
90
+ /**
91
+ * Detect false-confidence "blocks" across a set of hook entries.
92
+ *
93
+ * Returns one {@link HookBlockFinding} per entry (at most one per entry —
94
+ * `wrong-event` takes precedence over `wrong-field`). Identical
95
+ * (event, kind, scriptPath) pairs are de-duped.
96
+ *
97
+ * @param entries - The hook registrations to inspect (event + command/script).
98
+ * @param opts - Injected sets of blocking/permission events, and an optional
99
+ * `readFileSync` (default: node:fs).
100
+ */
101
+ function hookBlockIssues(entries, opts) {
102
+ const { noEffectEvents, permissionDecisionEvents } = opts;
103
+ const readFile = opts.readFileSync ?? defaultReadFile;
104
+ const findings = [];
105
+ const seen = new Set();
106
+ for (const entry of entries) {
107
+ // Determine the script text and the canonical "path" label for findings.
108
+ const scriptPath = entry.scriptPath ?? null;
109
+ const text = scriptPath !== null ? readFile(scriptPath) : (entry.command ?? "");
110
+ // Detect block mechanisms.
111
+ const hasExit2 = EXIT_2.test(text) || EXIT_2_CODE.test(text);
112
+ const hasDecisionBlock = DECISION_BLOCK.test(text);
113
+ const hasPermissionDeny = PERMISSION_DENY.test(text);
114
+ const triesBlock = hasExit2 || hasDecisionBlock || hasPermissionDeny;
115
+ if (!triesBlock)
116
+ continue;
117
+ let kind = null;
118
+ let message = "";
119
+ if (noEffectEvents.has(entry.event)) {
120
+ // wrong-event: a block on an event where it's silently ignored entirely
121
+ // (no veto AND no feedback — stderr goes to the user, not the model).
122
+ kind = "wrong-event";
123
+ message =
124
+ `This hook tries to block (exit 2 / "decision" / "permissionDecision") ` +
125
+ `but on "${entry.event}" a block decision is silently ignored — it can ` +
126
+ `neither veto nor feed the model back (stderr goes only to the user). ` +
127
+ `Nothing is prevented. Move the gate to a blocking event (e.g. PreToolUse) ` +
128
+ `so the deny fires BEFORE the action.`;
129
+ }
130
+ else if (permissionDecisionEvents.has(entry.event) &&
131
+ hasDecisionBlock &&
132
+ !hasPermissionDeny) {
133
+ // wrong-field: on a permission-gated event, uses the legacy field.
134
+ kind = "wrong-field";
135
+ message =
136
+ `On "${entry.event}" a deny must use ` +
137
+ `\`hookSpecificOutput.permissionDecision:"deny"\`; this script uses the ` +
138
+ `legacy top-level \`"decision"\` field, which is ignored on this event, ` +
139
+ `so nothing is blocked. Update the JSON output to the structured form: ` +
140
+ `\`{"hookSpecificOutput":{"permissionDecision":"deny"}}\`.`;
141
+ }
142
+ if (kind === null)
143
+ continue;
144
+ // De-dupe identical (event, kind, scriptPath) triples.
145
+ const dedupeKey = `${entry.event}:${kind}:${scriptPath ?? ""}`;
146
+ if (seen.has(dedupeKey))
147
+ continue;
148
+ seen.add(dedupeKey);
149
+ findings.push({ event: entry.event, kind, scriptPath, message });
150
+ }
151
+ return findings;
152
+ }
153
+ //# sourceMappingURL=hook-block-ineffective.js.map
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Hook-matcher verification — the cross-referencing moat applied to the MATCHER
3
+ * string inside a hook registration. A PreToolUse hook fires only when its
4
+ * `matcher` equals the tool name the harness emits (or matches via glob/regex); a
5
+ * typo or wrong form silently prevents the hook from ever running — exactly the
6
+ * FALSE CONFIDENCE failure the compiled-hooks design exists to eliminate
7
+ * (research/hook-pain-points.md).
8
+ *
9
+ * THREE kinds of bad matcher, each verified here (one-detector-no-drift):
10
+ *
11
+ * 1. **tool-typo** — a bare token that is a CLOSE TYPO (edit distance ≤ 2) of a
12
+ * real built-in tool name but not an exact match (`bash` → `Bash`, `read` →
13
+ * `Read`). Suggests the correct casing. Reuses `closestTool` from
14
+ * `tool-contract.ts` — same edit-distance logic, same ≤ 2 confidence bound.
15
+ *
16
+ * 2. **mcp-form** — a token that looks MCP-ish (starts with `mcp`, case-
17
+ * insensitive) but is NOT the required `mcp__<server>__<tool>` double-underscore
18
+ * shape (single underscores, a hyphen, a trailing `*`…). Suggests the corrected
19
+ * form when the server/tool segments can be recovered.
20
+ *
21
+ * 3. **mcp-undeclared** — a correctly-formed `mcp__<server>__…` token whose server
22
+ * is NOT in the plugin's declared MCP servers. Gated EXACTLY like
23
+ * `mcp-tool-resolves`: (a) no declared set → skip (reaches global/project
24
+ * servers); (b) built-ins allowlisted via `dialect.knownMcpServers`; (c) the
25
+ * plugin-namespaced `mcp__plugin_…__…` form is skipped. Reuses `mcpToolServer`
26
+ * from `mcp-tool.ts` for the extraction — one parser, no drift.
27
+ *
28
+ * FP-SAFE: only a SINGLE bare token is inspected. A matcher that is empty, a pure
29
+ * wildcard (`*` / `.*`), or contains alternation (`|`) or other regex meta-
30
+ * characters is skipped — it is a pattern/glob with legitimate broad matching, not
31
+ * a tool name. Same don't-cry-wolf discipline as every other vigiles detector.
32
+ *
33
+ * Pure + ONE detector reused by `scan` + the `hook-matcher` lint rule
34
+ * (one-detector-no-drift). The dialect is injected (core ⊄ adapter).
35
+ */
36
+ import type { HarnessDialect } from "./dialect.js";
37
+ /** Which matching failure was detected in the hook matcher string. */
38
+ export type HookMatcherKind = "tool-typo" | "mcp-form" | "mcp-undeclared";
39
+ /** One finding for a hook matcher that will silently never fire. */
40
+ export interface HookMatcherFinding {
41
+ /** The matcher string exactly as written. */
42
+ readonly matcher: string;
43
+ /** Which class of error was detected. */
44
+ readonly kind: HookMatcherKind;
45
+ /**
46
+ * The corrected matcher when the intent is recoverable (e.g. `Bash` for
47
+ * `bash`, `mcp__memory__.*` for `mcp_memory_*`). Absent when the server
48
+ * segment can't be recovered from a malformed MCP form.
49
+ */
50
+ readonly suggestion?: string;
51
+ /** A ready-to-show, actionable message. */
52
+ readonly message: string;
53
+ }
54
+ /** A single hook registration entry — its event and matcher string. */
55
+ export interface HookMatcherEntry {
56
+ readonly event: string;
57
+ readonly matcher: string;
58
+ }
59
+ /**
60
+ * Verify hook-matcher strings for the three forms that silently never fire.
61
+ * Returns one {@link HookMatcherFinding} per offending entry. De-duplicates
62
+ * repeated matchers. Returns `[]` when all matchers are FP-safe to skip or
63
+ * are correct.
64
+ */
65
+ export declare function hookMatcherIssues(entries: readonly HookMatcherEntry[], declaredServers: readonly string[], dialect: HarnessDialect): HookMatcherFinding[];
66
+ //# sourceMappingURL=hook-matcher.d.ts.map
@@ -0,0 +1,182 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.hookMatcherIssues = hookMatcherIssues;
4
+ const tool_contract_js_1 = require("./tool-contract.js");
5
+ const mcp_tool_js_1 = require("./mcp-tool.js");
6
+ // ---------------------------------------------------------------------------
7
+ // Internal helpers
8
+ // ---------------------------------------------------------------------------
9
+ /**
10
+ * Whether a matcher token should be skipped for FP-safety. We ONLY inspect
11
+ * a SINGLE bare token that could plausibly be a literal tool name or MCP
12
+ * reference. Anything with regex / glob meta-characters, alternation, a
13
+ * trailing glob wildcard alone, or an empty string is a pattern — skip it.
14
+ *
15
+ * Conservative by design: an unrecognized form → skip, never flag.
16
+ */
17
+ function isInspectableToken(token) {
18
+ if (token.length === 0)
19
+ return false;
20
+ // Pure wildcard forms used as "match-all" matchers.
21
+ if (token === "*" || token === ".*" || token === "**")
22
+ return false;
23
+ // Contains regex alternation — a combined matcher, not a single tool name.
24
+ if (token.includes("|"))
25
+ return false;
26
+ // Contains a parenthesised group `(…)` — regex, not a tool name.
27
+ if (token.includes("(") || token.includes(")"))
28
+ return false;
29
+ // Contains a `[` — character class; skip.
30
+ if (token.includes("["))
31
+ return false;
32
+ // A leading `^` or trailing `$` — anchored regex.
33
+ if (token.startsWith("^") || token.endsWith("$"))
34
+ return false;
35
+ // Leading `.*` — regex prefix; always a pattern.
36
+ if (token.startsWith(".*"))
37
+ return false;
38
+ // A trailing `.*`/`*` is a glob/regex suffix on a plain TOOL matcher (`Bash.*`,
39
+ // `Read*`) → skip. But for an MCP-ish token the trailing wildcard is EXACTLY
40
+ // what we must inspect: `mcp__server__.*` is the legitimate match-all-tools
41
+ // form, and `mcp_memory_*` is the classic single-underscore typo we want to
42
+ // catch — so do NOT skip a wildcard suffix on an `mcp`-ish token.
43
+ if (!looksMcpIsh(token) && (token.endsWith(".*") || token.endsWith("*")))
44
+ return false;
45
+ return true;
46
+ }
47
+ /**
48
+ * A token starts with `mcp` (case-insensitive) and contains at least one
49
+ * `_` (making it look like an MCP tool reference, not a harness built-in).
50
+ */
51
+ function looksMcpIsh(token) {
52
+ return /^mcp[_-]/i.test(token);
53
+ }
54
+ /**
55
+ * Whether `token` matches the canonical `mcp__<server>__<rest>` double-
56
+ * underscore shape (the valid MCP matcher form). We use the dialect's own
57
+ * `mcpToolPattern` extended to allow trailing `.*` for wildcard matchers,
58
+ * since a hook `matcher` may be `mcp__server__.*` (match-all-tools-on-server).
59
+ */
60
+ function isValidMcpForm(token, dialect) {
61
+ // The canonical pattern from the dialect: `mcp__server__tool`.
62
+ if (dialect.mcpToolPattern.test(token))
63
+ return true;
64
+ // Also allow the wildcard suffix form `mcp__server__.*`.
65
+ if (/^mcp__[a-z0-9_-]+__\.\*$/i.test(token))
66
+ return true;
67
+ return false;
68
+ }
69
+ /**
70
+ * Attempt to recover the server segment from a malformed MCP token so we can
71
+ * suggest the corrected `mcp__<server>__.*` form. Returns null when no
72
+ * segment can be confidently recovered.
73
+ *
74
+ * Handles:
75
+ * - Single-underscore: `mcp_memory_search` → server=`memory`, tool=`search`
76
+ * - Hyphenated: `mcp-memory-search` → server=`memory`, tool=`search`
77
+ * - Glob suffix: `mcp_memory_*` → server=`memory`
78
+ * - Mixed: `mcp__memory_*` → only one `__` segment found
79
+ */
80
+ function recoverMcpServer(token) {
81
+ // Strip a leading `mcp` and then a separator (`__`, `_`, `-`).
82
+ const rest = token.replace(/^mcp(?:__|_|-)/i, "");
83
+ if (!rest || rest === token)
84
+ return null;
85
+ // Split on single underscores or hyphens (not `__`) to get the next segment.
86
+ // We want the first non-empty segment after the `mcp` prefix separator.
87
+ const segments = rest.split(/(?<!_)_(?!_)|(?<!-)(?:-(?!-))/);
88
+ const server = segments[0];
89
+ if (!server || server.length === 0)
90
+ return null;
91
+ // Reject segments that are clearly numeric-only or single chars (too ambiguous).
92
+ if (/^\d+$/.test(server))
93
+ return null;
94
+ return server;
95
+ }
96
+ // ---------------------------------------------------------------------------
97
+ // Public detector
98
+ // ---------------------------------------------------------------------------
99
+ /**
100
+ * Verify hook-matcher strings for the three forms that silently never fire.
101
+ * Returns one {@link HookMatcherFinding} per offending entry. De-duplicates
102
+ * repeated matchers. Returns `[]` when all matchers are FP-safe to skip or
103
+ * are correct.
104
+ */
105
+ function hookMatcherIssues(entries, declaredServers, dialect) {
106
+ const findings = [];
107
+ const seen = new Set();
108
+ for (const { matcher } of entries) {
109
+ // De-dupe repeated matchers across entries.
110
+ if (seen.has(matcher))
111
+ continue;
112
+ seen.add(matcher);
113
+ // Skip wildcards, alternation, regex patterns — FP-safety.
114
+ if (!isInspectableToken(matcher))
115
+ continue;
116
+ // ── kind: mcp-form ──────────────────────────────────────────────────────
117
+ // The token looks MCP-ish but is NOT the valid double-underscore form.
118
+ if (looksMcpIsh(matcher)) {
119
+ if (!isValidMcpForm(matcher, dialect)) {
120
+ const server = recoverMcpServer(matcher);
121
+ const suggestion = server ? `mcp__${server}__.*` : undefined;
122
+ const hintPart = suggestion !== undefined
123
+ ? ` Did you mean "${suggestion}"?`
124
+ : " Use the form `mcp__<server>__<tool>` (double underscores).";
125
+ findings.push({
126
+ matcher,
127
+ kind: "mcp-form",
128
+ ...(suggestion !== undefined ? { suggestion } : {}),
129
+ message: `Hook matcher "${matcher}" is not a valid MCP tool reference (requires double underscores: \`mcp__server__tool\`).${hintPart}`,
130
+ });
131
+ continue;
132
+ }
133
+ // ── kind: mcp-undeclared ──────────────────────────────────────────────
134
+ // A correctly-formed MCP token whose server isn't in the declared set.
135
+ // Guard 1: no declared set → skip (reaches global/project servers).
136
+ if (declaredServers.length === 0)
137
+ continue;
138
+ // `mcpToolServer` reads the `mcp__server__tool` form; a server-wide WILDCARD
139
+ // matcher (`mcp__server__.*`) isn't a concrete tool, so fall back to the
140
+ // wildcard server segment so an undeclared server is still caught.
141
+ const server = (0, mcp_tool_js_1.mcpToolServer)(matcher, dialect) ??
142
+ /^mcp__([a-z0-9_-]+)__\.\*$/i.exec(matcher)?.[1] ??
143
+ null;
144
+ if (server === null)
145
+ continue; // plugin-namespaced form → guard 3, skip
146
+ // The plugin-namespaced `mcp__plugin_<plugin>_<server>__` form is the
147
+ // plugin's OWN server — never an undeclared reference (mirrors mcpToolServer).
148
+ if (/^plugin_/i.test(server))
149
+ continue;
150
+ const known = new Set([
151
+ ...declaredServers,
152
+ ...(dialect.knownMcpServers ?? []),
153
+ ]);
154
+ // Guard 2: built-in server → skip.
155
+ if (known.has(server))
156
+ continue;
157
+ findings.push({
158
+ matcher,
159
+ kind: "mcp-undeclared",
160
+ message: `Hook matcher "${matcher}" references MCP server "${server}", which the plugin doesn't declare (declared: ${declaredServers.join(", ")}) — the hook can't fire.`,
161
+ });
162
+ continue;
163
+ }
164
+ // ── kind: tool-typo ─────────────────────────────────────────────────────
165
+ // A bare token that is NOT an exact built-in tool but IS a close typo of one.
166
+ const knownTools = new Set(dialect.builtinAgentTools);
167
+ if (knownTools.has(matcher))
168
+ continue; // exact match → no issue
169
+ // Reuse the same ≤ 2 edit-distance helper from tool-contract.ts.
170
+ const near = (0, tool_contract_js_1.closestTool)(matcher, dialect);
171
+ if (near === null)
172
+ continue; // far/unknown → likely a plugin tool, not a typo
173
+ findings.push({
174
+ matcher,
175
+ kind: "tool-typo",
176
+ suggestion: near,
177
+ message: `Hook matcher "${matcher}" doesn't match any built-in tool — the hook silently never fires. Did you mean "${near}"?`,
178
+ });
179
+ }
180
+ return findings;
181
+ }
182
+ //# sourceMappingURL=hook-matcher.js.map
@@ -0,0 +1,43 @@
1
+ /**
2
+ * vigiles — hook settings normalization (the typed boundary for shipped hooks).
3
+ *
4
+ * A repo's hooks ship as raw, untrusted, parsed JSON/TOML — `unknown` at the
5
+ * loader edge (`LoadedPlugin.settings.hooks`). Rather than have every detector
6
+ * re-walk that `unknown` with inline casts (parse-don't-validate violated N
7
+ * times), this parses it ONCE at the boundary into a typed, flattened
8
+ * `HookRegistration[]` that the walkers consume.
9
+ *
10
+ * HARNESS-AGNOSTIC BY TOLERANCE, NOT BY A PORT. Two shipping shapes exist:
11
+ * - Claude Code (JSON): `{ Event: [{ matcher, hooks: [{ command }] }] }`
12
+ * - Codex (TOML): `{ Event: [{ command }] }` (`[[hooks.Event]] command=…`)
13
+ * A single tolerant reader absorbs both — a missing `hooks` array means the
14
+ * entry itself is the lone command holder. The difference is small enough that
15
+ * one parser covers it, so we deliberately DON'T add a per-harness port for it
16
+ * (rule-of-three / YAGNI: design the neutral shape first, defer the abstraction
17
+ * until a harness needs a genuinely divergent shape). If one ever does, this is
18
+ * the single seam to lift behind the layout/dialect.
19
+ *
20
+ * Pure + fully testable (no IO); the script resolution that turns a `command`
21
+ * into an on-disk path stays in the caller (it needs the plugin root + fs).
22
+ */
23
+ /**
24
+ * One flattened hook registration: a single command bound to an event, with its
25
+ * optional matcher. The neutral form every hook detector reads — CC-nested and
26
+ * Codex-flat both collapse to this.
27
+ */
28
+ export interface HookRegistration {
29
+ /** The event the hook registers under, e.g. `"PreToolUse"`. */
30
+ readonly event: string;
31
+ /** The tool/path matcher, or `null` when the entry declares none. */
32
+ readonly matcher: string | null;
33
+ /** The shell command the hook runs (non-empty). */
34
+ readonly command: string;
35
+ }
36
+ /**
37
+ * Parse the raw `settings.hooks` value into typed registrations. Returns `[]`
38
+ * for any non-object / malformed input — never throws.
39
+ */
40
+ export declare function normalizeHooks(raw: unknown): HookRegistration[];
41
+ /** Distinct event names present in the raw hooks object (object-keyed shape). */
42
+ export declare function hookEventNames(raw: unknown): string[];
43
+ //# sourceMappingURL=hook-normalize.d.ts.map
@@ -0,0 +1,78 @@
1
+ "use strict";
2
+ /**
3
+ * vigiles — hook settings normalization (the typed boundary for shipped hooks).
4
+ *
5
+ * A repo's hooks ship as raw, untrusted, parsed JSON/TOML — `unknown` at the
6
+ * loader edge (`LoadedPlugin.settings.hooks`). Rather than have every detector
7
+ * re-walk that `unknown` with inline casts (parse-don't-validate violated N
8
+ * times), this parses it ONCE at the boundary into a typed, flattened
9
+ * `HookRegistration[]` that the walkers consume.
10
+ *
11
+ * HARNESS-AGNOSTIC BY TOLERANCE, NOT BY A PORT. Two shipping shapes exist:
12
+ * - Claude Code (JSON): `{ Event: [{ matcher, hooks: [{ command }] }] }`
13
+ * - Codex (TOML): `{ Event: [{ command }] }` (`[[hooks.Event]] command=…`)
14
+ * A single tolerant reader absorbs both — a missing `hooks` array means the
15
+ * entry itself is the lone command holder. The difference is small enough that
16
+ * one parser covers it, so we deliberately DON'T add a per-harness port for it
17
+ * (rule-of-three / YAGNI: design the neutral shape first, defer the abstraction
18
+ * until a harness needs a genuinely divergent shape). If one ever does, this is
19
+ * the single seam to lift behind the layout/dialect.
20
+ *
21
+ * Pure + fully testable (no IO); the script resolution that turns a `command`
22
+ * into an on-disk path stays in the caller (it needs the plugin root + fs).
23
+ */
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.normalizeHooks = normalizeHooks;
26
+ exports.hookEventNames = hookEventNames;
27
+ /** True for a non-null, non-array object. */
28
+ function isRecord(v) {
29
+ return v !== null && typeof v === "object" && !Array.isArray(v);
30
+ }
31
+ /** The string `matcher` of an entry, or `null` when absent/empty. */
32
+ function entryMatcher(entry) {
33
+ const m = entry.matcher;
34
+ return typeof m === "string" && m.length > 0 ? m : null;
35
+ }
36
+ /**
37
+ * Flatten ONE `{ event: [...] }` group's entry into registrations. Tolerant of
38
+ * both shapes: a Claude Code entry nests `hooks: [{command}]`; a Codex flat
39
+ * entry IS the command holder (no `hooks` array), so the entry stands in for it.
40
+ */
41
+ function flattenEntry(event, entry) {
42
+ if (!isRecord(entry))
43
+ return [];
44
+ const matcher = entryMatcher(entry);
45
+ const nested = entry.hooks;
46
+ const holders = Array.isArray(nested) ? nested : [entry];
47
+ const out = [];
48
+ for (const h of holders) {
49
+ if (!isRecord(h))
50
+ continue;
51
+ const command = h.command;
52
+ if (typeof command !== "string" || command.length === 0)
53
+ continue;
54
+ out.push({ event, matcher, command });
55
+ }
56
+ return out;
57
+ }
58
+ /**
59
+ * Parse the raw `settings.hooks` value into typed registrations. Returns `[]`
60
+ * for any non-object / malformed input — never throws.
61
+ */
62
+ function normalizeHooks(raw) {
63
+ if (!isRecord(raw))
64
+ return [];
65
+ const out = [];
66
+ for (const [event, arr] of Object.entries(raw)) {
67
+ if (!Array.isArray(arr))
68
+ continue;
69
+ for (const entry of arr)
70
+ out.push(...flattenEntry(event, entry));
71
+ }
72
+ return out;
73
+ }
74
+ /** Distinct event names present in the raw hooks object (object-keyed shape). */
75
+ function hookEventNames(raw) {
76
+ return isRecord(raw) ? Object.keys(raw) : [];
77
+ }
78
+ //# sourceMappingURL=hook-normalize.js.map