vigiles 5.0.1 → 5.2.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 (72) hide show
  1. package/README.md +15 -9
  2. package/dist/adapters/claude-code/adapter.js +1 -0
  3. package/dist/adapters/claude-code/agent-runtime.d.ts +30 -6
  4. package/dist/adapters/claude-code/agent-runtime.js +66 -37
  5. package/dist/adapters/claude-code/dialect.js +37 -2
  6. package/dist/adapters/claude-code/effect-region.d.ts +9 -0
  7. package/dist/adapters/claude-code/effect-region.js +45 -0
  8. package/dist/adapters/claude-code/layout.js +3 -0
  9. package/dist/adapters/claude-code/skill-runtime.d.ts +25 -0
  10. package/dist/adapters/claude-code/skill-runtime.js +48 -0
  11. package/dist/adapters/codex/adapter.js +3 -0
  12. package/dist/adapters/codex/eval.d.ts +94 -0
  13. package/dist/adapters/codex/eval.js +227 -0
  14. package/dist/adapters/codex/layout.js +3 -0
  15. package/dist/adapters/opencode/adapter.js +1 -0
  16. package/dist/adapters/opencode/layout.js +3 -0
  17. package/dist/check.d.ts +8 -0
  18. package/dist/check.js +27 -3
  19. package/dist/cli.js +712 -21
  20. package/dist/codex.d.ts +1 -0
  21. package/dist/codex.js +3 -0
  22. package/dist/core/adapter.d.ts +10 -0
  23. package/dist/core/bash-effects.d.ts +41 -0
  24. package/dist/core/bash-effects.js +405 -0
  25. package/dist/core/compile.d.ts +3 -1
  26. package/dist/core/compile.js +169 -74
  27. package/dist/core/description-overlap.d.ts +27 -0
  28. package/dist/core/description-overlap.js +53 -0
  29. package/dist/core/dialect.d.ts +18 -0
  30. package/dist/core/effects.d.ts +172 -0
  31. package/dist/core/effects.js +245 -0
  32. package/dist/core/frontmatter-read.d.ts +25 -0
  33. package/dist/core/frontmatter-read.js +138 -0
  34. package/dist/core/hook-events.d.ts +34 -0
  35. package/dist/core/hook-events.js +48 -0
  36. package/dist/core/layout.d.ts +6 -0
  37. package/dist/core/mcp-config.d.ts +20 -0
  38. package/dist/core/mcp-config.js +40 -0
  39. package/dist/core/mcp-hook.d.ts +35 -0
  40. package/dist/core/mcp-hook.js +70 -0
  41. package/dist/core/mcp-tool.d.ts +50 -0
  42. package/dist/core/mcp-tool.js +61 -0
  43. package/dist/core/orphans.js +21 -0
  44. package/dist/core/spec.d.ts +142 -3
  45. package/dist/core/spec.js +48 -0
  46. package/dist/core/tool-contract.d.ts +68 -0
  47. package/dist/core/tool-contract.js +113 -0
  48. package/dist/core/types.d.ts +91 -2
  49. package/dist/core/validate.js +23 -1
  50. package/dist/eval.d.ts +69 -13
  51. package/dist/eval.js +106 -51
  52. package/dist/harness-test.d.ts +7 -0
  53. package/dist/harness-test.js +19 -7
  54. package/dist/leaderboard.d.ts +2 -0
  55. package/dist/leaderboard.js +63 -3
  56. package/dist/optimize.d.ts +74 -0
  57. package/dist/optimize.js +94 -0
  58. package/dist/plugin-loader.d.ts +1 -0
  59. package/dist/plugin-loader.js +71 -18
  60. package/dist/scaffold-test.d.ts +30 -0
  61. package/dist/scaffold-test.js +158 -0
  62. package/dist/scan-behavioral.d.ts +73 -0
  63. package/dist/scan-behavioral.js +150 -0
  64. package/dist/scan.d.ts +166 -1
  65. package/dist/scan.js +622 -55
  66. package/dist/score-explainer.d.ts +69 -0
  67. package/dist/score-explainer.js +169 -0
  68. package/dist/test-coverage.d.ts +7 -0
  69. package/dist/test-coverage.js +39 -24
  70. package/package.json +2 -1
  71. package/skills/{migrate-to-spec → adopt-spec}/SKILL.md +4 -6
  72. package/skills/edit-spec/SKILL.md +1 -1
@@ -0,0 +1,245 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.classifyToolEffect = classifyToolEffect;
4
+ exports.effectSurface = effectSurface;
5
+ exports.purityViolations = purityViolations;
6
+ exports.pureContractViolations = pureContractViolations;
7
+ exports.decidePurityGate = decidePurityGate;
8
+ const hash_js_1 = require("./hash.js");
9
+ const bash_effects_js_1 = require("./bash-effects.js");
10
+ // ---------------------------------------------------------------------------
11
+ // Internal helpers
12
+ // ---------------------------------------------------------------------------
13
+ /** Strips a `Tool(restriction)` suffix and returns the base tool name. */
14
+ function baseTool(raw) {
15
+ return raw.split("(")[0].trim();
16
+ }
17
+ /** Returns true for the wildcard sentinels that mean "inherits-all". */
18
+ function isWildcard(tool) {
19
+ return tool === "" || tool === "*";
20
+ }
21
+ // ---------------------------------------------------------------------------
22
+ // Public API
23
+ // ---------------------------------------------------------------------------
24
+ /**
25
+ * Classify the effect of ONE tool name against a dialect's known catalogs.
26
+ *
27
+ * A `Tool(restriction)` suffix (e.g. `Bash(git:*)`) is stripped first — the
28
+ * restriction narrows what the tool can DO but doesn't change its effect class
29
+ * (Bash with any restriction is still conservatively side-effecting).
30
+ *
31
+ * Classification rules (in priority order):
32
+ * 1. In `dialect.sideEffectingTools` → `"side-effecting"`
33
+ * 2. In `dialect.builtinAgentTools` (and NOT side-effecting) → `"read-only"`
34
+ * 3. Matches `dialect.mcpToolPattern` → `"unknown"` (MCP tools are not
35
+ * classifiable from the name alone — treated as unknown-effect)
36
+ * 4. Otherwise → `"unknown"` (unrecognized tool; may be a plugin tool or a typo)
37
+ */
38
+ function classifyToolEffect(tool, dialect) {
39
+ const base = baseTool(tool);
40
+ const sideEffecting = dialect.sideEffectingTools ?? [];
41
+ if (sideEffecting.includes(base))
42
+ return "side-effecting";
43
+ if (dialect.builtinAgentTools.includes(base))
44
+ return "read-only";
45
+ if (dialect.mcpToolPattern.test(base))
46
+ return "unknown";
47
+ return "unknown";
48
+ }
49
+ /**
50
+ * Compute the static effect surface of a declared `tools:` contract.
51
+ *
52
+ * `"*"` / `""` (inherits-all) entries make purity `"unrestricted"` because the
53
+ * contract grants access to all tools including every side-effecting one — the
54
+ * full surface is unknowable statically. They are NOT listed in any bucket
55
+ * (they represent a wildcard, not a named tool).
56
+ *
57
+ * De-duplication: a tool name that appears more than once in `tools` is counted
58
+ * once in its bucket (base tool after restriction stripping).
59
+ */
60
+ function effectSurface(tools, dialect) {
61
+ const readOnly = new Set();
62
+ const sideEffecting = new Set();
63
+ const unknown = new Set();
64
+ let hasWildcard = false;
65
+ let hasBash = false;
66
+ for (const raw of tools) {
67
+ const base = baseTool(raw);
68
+ if (isWildcard(base)) {
69
+ hasWildcard = true;
70
+ continue; // wildcards don't go into any named bucket
71
+ }
72
+ const effect = classifyToolEffect(raw, dialect);
73
+ switch (effect) {
74
+ case "read-only":
75
+ readOnly.add(base);
76
+ break;
77
+ case "side-effecting":
78
+ sideEffecting.add(base);
79
+ if (base === "Bash")
80
+ hasBash = true;
81
+ break;
82
+ case "unknown":
83
+ unknown.add(base);
84
+ break;
85
+ default:
86
+ (0, hash_js_1.assertNever)(effect);
87
+ }
88
+ }
89
+ const purity = hasWildcard || hasBash || unknown.size > 0
90
+ ? "unrestricted"
91
+ : sideEffecting.size > 0
92
+ ? "bounded"
93
+ : "pure";
94
+ return {
95
+ readOnly: [...readOnly],
96
+ sideEffecting: [...sideEffecting],
97
+ unknown: [...unknown],
98
+ purity,
99
+ };
100
+ }
101
+ /**
102
+ * Returns the violations of a DECLARED purity floor — the tools that make the
103
+ * actual effect surface LOOSER than the declared level. Empty ⇒ the contract
104
+ * honours the declared level. The `message` on each is actionable (names the
105
+ * tool, the effect class, and what to do).
106
+ *
107
+ * What counts as a violation depends on `declared`:
108
+ * - `"pure"`: every side-effecting tool (incl. `Bash`), every
109
+ * unknown-effect tool, and any wildcard (a pure unit may
110
+ * only observe — no `Bash`, no effects, fully static).
111
+ * - `"bounded"`: only the truly UNBOUNDED tools — unknown-effect (MCP /
112
+ * unrecognized) and wildcards. Every decidable side-effecting
113
+ * tool is ALLOWED: Write/Edit confine to the boundary, and
114
+ * `Bash` is admitted because the RUNTIME gate
115
+ * (`decidePurityGate`) refines it by command (read-only Bash
116
+ * is an observation; a mutating command is denied).
117
+ * - `"unrestricted"`: never a violation (the rung carries no constraint).
118
+ *
119
+ * A wildcard (`"*"` / `""`) contract is a violation at every constrained level:
120
+ * "inherits-all" grants every tool, so neither `pure` nor `bounded` can hold.
121
+ */
122
+ function purityViolations(tools, dialect, declared) {
123
+ if (declared === "unrestricted")
124
+ return []; // no constraint to violate
125
+ const violations = [];
126
+ const seen = new Set();
127
+ for (const raw of tools) {
128
+ const base = baseTool(raw);
129
+ if (isWildcard(base)) {
130
+ const key = base === "" ? '""' : '"*"';
131
+ if (!seen.has(key)) {
132
+ seen.add(key);
133
+ violations.push({
134
+ tool: base,
135
+ effect: "side-effecting",
136
+ message: `${key} (inherits-all) is not allowed in a ${declared} contract — it grants access to every tool, including side-effecting ones. Declare explicit tools instead.`,
137
+ });
138
+ }
139
+ continue;
140
+ }
141
+ if (seen.has(base))
142
+ continue;
143
+ const effect = classifyToolEffect(raw, dialect);
144
+ switch (effect) {
145
+ case "read-only":
146
+ break; // allowed at every level
147
+ case "unknown":
148
+ seen.add(base);
149
+ violations.push({
150
+ tool: base,
151
+ effect,
152
+ message: `"${base}" has unknown effect class (MCP or unrecognized tool); a ${declared} contract cannot declare it — its effects are unbounded from static analysis. Remove it or declare the unit dangerously-unrestricted.`,
153
+ });
154
+ break;
155
+ case "side-effecting":
156
+ // In a BOUNDED unit every decidable side-effecting tool is allowed:
157
+ // Write/Edit confine to the boundary, and `Bash` is admitted because the
158
+ // RUNTIME gate (`decidePurityGate`) refines it by command — a read-only
159
+ // Bash is an observation, a mutating command is denied. Only `pure` bars
160
+ // them (a pure unit may only observe; no `Bash`, no effects).
161
+ if (declared === "bounded")
162
+ break;
163
+ seen.add(base);
164
+ violations.push({
165
+ tool: base,
166
+ effect,
167
+ message: base === "Bash"
168
+ ? `"Bash" is undecidable at the tool-name level; a pure unit cannot declare it. A read-only Bash belongs in a bounded unit (the runtime gate confines it by command) — declare the unit bounded or dangerously-unrestricted.`
169
+ : `"${base}" is side-effecting; a pure unit cannot declare it. Remove it or declare the unit bounded.`,
170
+ });
171
+ break;
172
+ default:
173
+ (0, hash_js_1.assertNever)(effect);
174
+ }
175
+ }
176
+ return violations;
177
+ }
178
+ /**
179
+ * The violations of a `purity: "pure"` contract — every side-effecting,
180
+ * unknown-effect, or wildcard tool. A thin alias for `purityViolations(…,
181
+ * "pure")` kept for the common pure case.
182
+ */
183
+ function pureContractViolations(tools, dialect) {
184
+ return purityViolations(tools, dialect, "pure");
185
+ }
186
+ /**
187
+ * The RUNTIME half of the purity contract: decide whether a single LIVE tool
188
+ * call is allowed under the active unit's declared purity floor.
189
+ *
190
+ * Unlike `purityViolations` (which checks the DECLARED tools contract
191
+ * statically), this sees the ACTUAL call — including the `Bash` command string —
192
+ * so it refines `Bash` by effect via `isReadOnlyBash`. That command is the whole
193
+ * reason the gate's home is the runtime hook: only here is the concrete command
194
+ * visible (the static surface sees a tool name + a `Bash(git:*)` pattern, never
195
+ * the command).
196
+ *
197
+ * Rules (the ladder, command-refined):
198
+ * - `unrestricted` → always allow (no constraint).
199
+ * - read-only tool → allow at every level.
200
+ * - `Bash` → allow iff the command is provably read-only (an observation);
201
+ * otherwise deny — a mutating/undecidable command's effect must move to a
202
+ * marked boundary. Same at `pure` and `bounded`.
203
+ * - other side-effecting tool (Write, Edit, …) → allow under `bounded`
204
+ * (a decidable, boundary-confined effect), deny under `pure` (observe-only).
205
+ * - unknown-effect (MCP / unrecognized) → deny under `pure`/`bounded`
206
+ * (unbounded from static analysis).
207
+ *
208
+ * Dialect injected (core ⊄ adapter). Reuses `classifyToolEffect` +
209
+ * `isReadOnlyBash` — one-detector-no-drift with compile + scan.
210
+ */
211
+ function decidePurityGate(declared, tool, command, dialect) {
212
+ if (declared === "unrestricted")
213
+ return { allow: true, message: "" };
214
+ const base = baseTool(tool);
215
+ const effect = classifyToolEffect(tool, dialect);
216
+ if (effect === "read-only")
217
+ return { allow: true, message: "" };
218
+ if (base === "Bash") {
219
+ if (command !== undefined && (0, bash_effects_js_1.isReadOnlyBash)(command)) {
220
+ return { allow: true, message: "" };
221
+ }
222
+ const shown = command ? `"${command}" ` : "";
223
+ return {
224
+ allow: false,
225
+ message: `Bash command ${shown}is not provably read-only; a ${declared} unit may run only read-only Bash ` +
226
+ `(observation). Move the side effect into a marked boundary, or declare the unit dangerously-unrestricted.`,
227
+ };
228
+ }
229
+ if (effect === "side-effecting") {
230
+ if (declared === "bounded")
231
+ return { allow: true, message: "" };
232
+ return {
233
+ allow: false,
234
+ message: `"${base}" is side-effecting; a pure unit may only observe. ` +
235
+ `Declare the unit bounded (or dangerously-unrestricted) to use it.`,
236
+ };
237
+ }
238
+ // unknown — MCP / unrecognized tool
239
+ return {
240
+ allow: false,
241
+ message: `"${base}" has unknown effect class; a ${declared} unit cannot use it — its effects are unbounded. ` +
242
+ `Declare the unit dangerously-unrestricted to allow it.`,
243
+ };
244
+ }
245
+ //# sourceMappingURL=effects.js.map
@@ -0,0 +1,25 @@
1
+ export interface FrontmatterRead {
2
+ /** Parsed mapping when the block is valid YAML, else null (malformed or scalar). */
3
+ readonly data: Record<string, unknown> | null;
4
+ /** Raw text inside the leading `---` fences, or null when there's no block. */
5
+ readonly block: string | null;
6
+ /** True when a leading `---` block EXISTS but is NOT valid YAML. */
7
+ readonly malformed: boolean;
8
+ }
9
+ /** Extract + parse the leading frontmatter block, never throwing. */
10
+ export declare function readFrontmatter(markdown: string): FrontmatterRead;
11
+ /**
12
+ * A top-level scalar field — from parsed YAML when valid, else a regex salvage
13
+ * from the raw block (handling a block scalar `>`/`|` and a quoted value that
14
+ * starts on the next indented line).
15
+ */
16
+ export declare function frontmatterScalar(fm: FrontmatterRead, key: string): string | undefined;
17
+ /**
18
+ * A tool-list field (`tools:` / `disallowedTools:`) — an array or a comma list,
19
+ * normalized to string[]. Returns `null` when the key is ABSENT (the "no
20
+ * contract / inherits all" signal the rail honors) and `[]` when the key is
21
+ * PRESENT but empty ("no tools"). Salvages from the raw block when YAML is
22
+ * malformed, so the rail still reads the contract.
23
+ */
24
+ export declare function frontmatterList(fm: FrontmatterRead, key: string): string[] | null;
25
+ //# sourceMappingURL=frontmatter-read.d.ts.map
@@ -0,0 +1,138 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.readFrontmatter = readFrontmatter;
4
+ exports.frontmatterScalar = frontmatterScalar;
5
+ exports.frontmatterList = frontmatterList;
6
+ /**
7
+ * Lenient frontmatter reader — ONE reader for the SKILL.md / subagent `---` block,
8
+ * shared by `scan` and the PreToolUse rail (`agent-runtime`). It is deliberately
9
+ * fault-tolerant: it audits arbitrary third-party files, so it must never throw
10
+ * and must salvage the few scalar/list fields it needs even from a block that
11
+ * isn't valid YAML.
12
+ *
13
+ * The strategy is "real parser, with a safety net": try `js-yaml` (so block
14
+ * scalars, quoted/multi-line values, and flow arrays parse correctly for free);
15
+ * if the block isn't valid YAML, fall back to a regex salvage of the requested
16
+ * field and record `malformed: true` (the signal the `frontmatter-valid` rule
17
+ * reports). A single bad line therefore never blanks out a whole file's metadata.
18
+ *
19
+ * This replaces three divergent hand-parsers (the old `readField` in scan.ts and
20
+ * the regex parse in agent-runtime.ts); `core/frontmatter.ts` is a DIFFERENT
21
+ * concern (the Level-1 `vigiles:` rule block) and is untouched.
22
+ */
23
+ const js_yaml_1 = require("js-yaml");
24
+ // Frontmatter is the very first thing in the file. Anchoring at the start — not
25
+ // `(?:^|\n)` — means a `---` horizontal rule in the BODY is never mistaken for
26
+ // frontmatter (which matters for the malformed-YAML verdict). A leading BOM is
27
+ // stripped first; an optional leading HTML comment is allowed too — vigiles
28
+ // stamps a compiled file with `<!-- vigiles:sha256:… -->` before the `---`.
29
+ const BLOCK_RE = /^\uFEFF?(?:<!--[\s\S]*?-->\s*)?---\r?\n([\s\S]*?)\r?\n---/;
30
+ /** A YAML block-scalar indicator: `>`/`|` with optional chomp (`+`/`-`) + indent digit. */
31
+ const BLOCK_SCALAR_RE = /^[|>][+-]?\d*$/;
32
+ /** Extract + parse the leading frontmatter block, never throwing. */
33
+ function readFrontmatter(markdown) {
34
+ const m = BLOCK_RE.exec(markdown);
35
+ if (!m)
36
+ return { data: null, block: null, malformed: false };
37
+ const block = m[1];
38
+ try {
39
+ const parsed = (0, js_yaml_1.load)(block);
40
+ if (parsed !== null &&
41
+ typeof parsed === "object" &&
42
+ !Array.isArray(parsed)) {
43
+ return {
44
+ data: parsed,
45
+ block,
46
+ malformed: false,
47
+ };
48
+ }
49
+ // Valid YAML but not a mapping (e.g. a bare scalar) — usable as no data, but
50
+ // not "malformed": it parsed fine. Salvage will read fields from the block.
51
+ return { data: null, block, malformed: false };
52
+ }
53
+ catch (e) {
54
+ if (e instanceof js_yaml_1.YAMLException)
55
+ return { data: null, block, malformed: true };
56
+ throw e;
57
+ }
58
+ }
59
+ /**
60
+ * A top-level scalar field — from parsed YAML when valid, else a regex salvage
61
+ * from the raw block (handling a block scalar `>`/`|` and a quoted value that
62
+ * starts on the next indented line).
63
+ */
64
+ function frontmatterScalar(fm, key) {
65
+ if (fm.data && Object.prototype.hasOwnProperty.call(fm.data, key)) {
66
+ const v = fm.data[key];
67
+ if (typeof v === "string")
68
+ return v.trim() || undefined;
69
+ if (typeof v === "number" || typeof v === "boolean")
70
+ return String(v);
71
+ return undefined; // an array/object/null isn't a scalar field
72
+ }
73
+ return fm.block === null ? undefined : salvageField(fm.block, key);
74
+ }
75
+ /**
76
+ * A tool-list field (`tools:` / `disallowedTools:`) — an array or a comma list,
77
+ * normalized to string[]. Returns `null` when the key is ABSENT (the "no
78
+ * contract / inherits all" signal the rail honors) and `[]` when the key is
79
+ * PRESENT but empty ("no tools"). Salvages from the raw block when YAML is
80
+ * malformed, so the rail still reads the contract.
81
+ */
82
+ function frontmatterList(fm, key) {
83
+ if (fm.data && Object.prototype.hasOwnProperty.call(fm.data, key)) {
84
+ const v = fm.data[key];
85
+ if (v === null)
86
+ return []; // `key:` with nothing after it → empty contract
87
+ if (Array.isArray(v))
88
+ return v.map((x) => String(x).trim()).filter((s) => s.length > 0);
89
+ if (typeof v === "string")
90
+ return splitList(v);
91
+ return [];
92
+ }
93
+ return fm.block === null ? null : salvageList(fm.block, key);
94
+ }
95
+ // --- salvage (the malformed-YAML / no-data fallback) ------------------------
96
+ /** Old `readField`: gather a possibly multi-line scalar value from the raw block. */
97
+ function salvageField(block, key) {
98
+ const lines = block.split(/\r?\n/);
99
+ const idx = lines.findIndex((l) => new RegExp(`^${key}:`).test(l));
100
+ if (idx === -1)
101
+ return undefined;
102
+ const keyIndent = /^(\s*)/.exec(lines[idx])?.[1].length ?? 0;
103
+ const inline = (new RegExp(`^${key}:[ \\t]*(.*)$`).exec(lines[idx])?.[1] ?? "").trim();
104
+ if (inline && !BLOCK_SCALAR_RE.test(inline)) {
105
+ return inline.replace(/^["']|["']$/g, "").trim() || undefined;
106
+ }
107
+ const collected = [];
108
+ for (let i = idx + 1; i < lines.length; i++) {
109
+ if (lines[i].trim() === "")
110
+ continue;
111
+ const indent = /^(\s*)/.exec(lines[i])?.[1].length ?? 0;
112
+ if (indent <= keyIndent)
113
+ break;
114
+ collected.push(lines[i].trim());
115
+ }
116
+ return (collected
117
+ .join(" ")
118
+ .trim()
119
+ .replace(/^["']/, "")
120
+ .replace(/["']$/, "")
121
+ .trim() || undefined);
122
+ }
123
+ /** Salvage a list field from the raw block (single-line key only). */
124
+ function salvageList(block, key) {
125
+ const match = new RegExp(`^${key}:[ \\t]*(.*)$`, "m").exec(block);
126
+ if (!match)
127
+ return null;
128
+ return splitList(match[1]);
129
+ }
130
+ /** Split a comma list or inline-array string into trimmed, de-quoted tokens. */
131
+ function splitList(raw) {
132
+ return raw
133
+ .replace(/^\[|\]$/g, "")
134
+ .split(",")
135
+ .map((t) => t.trim().replace(/^["']|["']$/g, ""))
136
+ .filter((t) => t.length > 0);
137
+ }
138
+ //# sourceMappingURL=frontmatter-read.js.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Hook-event verification — the cross-referencing moat applied to the EVENT a
3
+ * hook registers under. A `hooks` block keys each entry by event name
4
+ * (`PreToolUse`, `SessionStart`, …); a TYPO (`PreToolUSe`) means the hook
5
+ * silently never fires — a dead registration no generic JSON linter catches.
6
+ *
7
+ * Like the tool catalog, the event set is NOT closed in practice: frameworks
8
+ * extend it (TheBushidoCollective/han ships a custom runtime with `TeammateIdle`,
9
+ * `WorktreeRemove`, … in its own `hooks.json`). So the audit path (scan/lint) is
10
+ * HIGH-PRECISION — `confidentHookEventIssues` keeps only a close typo
11
+ * (a did-you-mean within edit distance 2), never a bare unrecognized event that
12
+ * may be a custom/future one. ONE detector (one-detector-no-drift): scan + the
13
+ * `hook-events` lint rule call the same code. Dialect injected (core ⊄ adapter).
14
+ */
15
+ import type { HarnessDialect } from "./dialect.js";
16
+ export interface HookEventIssue {
17
+ readonly event: string;
18
+ /** Closest known event (did-you-mean), or null. */
19
+ readonly suggestion: string | null;
20
+ readonly message: string;
21
+ }
22
+ /**
23
+ * The HIGH-CONFIDENCE subset (what scan / lint act on): only an unrecognized
24
+ * event that's a close typo of a real one. A bare unknown (no near match) is
25
+ * likely a framework/custom event, not a defect — never flagged when auditing.
26
+ */
27
+ export declare function confidentHookEventIssues(issues: readonly HookEventIssue[]): HookEventIssue[];
28
+ /**
29
+ * Verify hook-event names against the dialect catalog. Returns one issue per
30
+ * unrecognized event. Like the tool-contract check, a suggestion (edit distance
31
+ * ≤ 2) is the confidence signal that an unknown is really a typo of a real event.
32
+ */
33
+ export declare function verifyHookEvents(events: readonly string[], dialect: HarnessDialect): HookEventIssue[];
34
+ //# sourceMappingURL=hook-events.d.ts.map
@@ -0,0 +1,48 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.confidentHookEventIssues = confidentHookEventIssues;
4
+ exports.verifyHookEvents = verifyHookEvents;
5
+ const linters_js_1 = require("./linters.js");
6
+ /** Closest known hook event by edit distance (≤ 2) — a confidence signal. */
7
+ function closestEvent(event, dialect) {
8
+ let best = null;
9
+ let bestDistance = Infinity;
10
+ for (const known of dialect.hookEvents) {
11
+ const d = (0, linters_js_1.editDistance)(event.toLowerCase(), known.toLowerCase());
12
+ if (d < bestDistance) {
13
+ bestDistance = d;
14
+ best = known;
15
+ }
16
+ }
17
+ return bestDistance <= 2 ? best : null;
18
+ }
19
+ /**
20
+ * The HIGH-CONFIDENCE subset (what scan / lint act on): only an unrecognized
21
+ * event that's a close typo of a real one. A bare unknown (no near match) is
22
+ * likely a framework/custom event, not a defect — never flagged when auditing.
23
+ */
24
+ function confidentHookEventIssues(issues) {
25
+ return issues.filter((i) => i.suggestion !== null);
26
+ }
27
+ /**
28
+ * Verify hook-event names against the dialect catalog. Returns one issue per
29
+ * unrecognized event. Like the tool-contract check, a suggestion (edit distance
30
+ * ≤ 2) is the confidence signal that an unknown is really a typo of a real event.
31
+ */
32
+ function verifyHookEvents(events, dialect) {
33
+ const known = new Set(dialect.hookEvents);
34
+ const issues = [];
35
+ for (const event of events) {
36
+ if (known.has(event))
37
+ continue;
38
+ const near = closestEvent(event, dialect);
39
+ const hint = near ? ` Did you mean "${near}"?` : "";
40
+ issues.push({
41
+ event,
42
+ suggestion: near,
43
+ message: `Unknown hook event "${event}" — a hook here never fires. Valid events: ${dialect.hookEvents.join(", ")}.${hint}`,
44
+ });
45
+ }
46
+ return issues;
47
+ }
48
+ //# sourceMappingURL=hook-events.js.map
@@ -29,6 +29,12 @@ export interface PluginLayout {
29
29
  readonly instructionFile: string;
30
30
  /** Surface dirs materialized into the sandbox, e.g. skills/agents/commands. */
31
31
  readonly surfaceDirs: readonly string[];
32
+ /** Skills dir, holding the nested `<dir>/<name>/SKILL.md`, e.g. `skills`. */
33
+ readonly skillDir: string;
34
+ /** Subagents dir, holding flat `<dir>/<name>.md`, e.g. `agents` (`""` = none). */
35
+ readonly agentDir: string;
36
+ /** Slash-commands dir, holding flat `<dir>/<name>.md`, e.g. `commands`. */
37
+ readonly commandDir: string;
32
38
  /** Dir the surfaces are materialized under, e.g. `.claude`. */
33
39
  readonly materializeRoot: string;
34
40
  /** Env token expanded to the plugin's absolute root in hook commands. */
@@ -0,0 +1,20 @@
1
+ /**
2
+ * MCP-config verification — a declared MCP server that can't start. Each server
3
+ * entry must say HOW to reach it: a `command` (stdio server) OR a `url` (http/sse
4
+ * server). An entry with neither is malformed — the server silently never comes
5
+ * up, and the tools it was meant to provide are missing. A JSON linter sees valid
6
+ * JSON; only an MCP-shape-aware check knows the entry is unreachable.
7
+ *
8
+ * Pure + FP-safe: the command-or-url requirement is unambiguous (not a catalog we
9
+ * might have wrong). ONE detector reused by scan + the `mcp-config` lint rule.
10
+ */
11
+ export interface McpIssue {
12
+ readonly server: string;
13
+ readonly message: string;
14
+ }
15
+ /**
16
+ * Validate a `mcpServers` map. Returns one issue per entry that declares neither
17
+ * a `command` (stdio) nor a `url` (http/sse) — the two ways to reach a server.
18
+ */
19
+ export declare function verifyMcpServers(servers: Record<string, unknown>): McpIssue[];
20
+ //# sourceMappingURL=mcp-config.d.ts.map
@@ -0,0 +1,40 @@
1
+ "use strict";
2
+ /**
3
+ * MCP-config verification — a declared MCP server that can't start. Each server
4
+ * entry must say HOW to reach it: a `command` (stdio server) OR a `url` (http/sse
5
+ * server). An entry with neither is malformed — the server silently never comes
6
+ * up, and the tools it was meant to provide are missing. A JSON linter sees valid
7
+ * JSON; only an MCP-shape-aware check knows the entry is unreachable.
8
+ *
9
+ * Pure + FP-safe: the command-or-url requirement is unambiguous (not a catalog we
10
+ * might have wrong). ONE detector reused by scan + the `mcp-config` lint rule.
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.verifyMcpServers = verifyMcpServers;
14
+ /**
15
+ * Validate a `mcpServers` map. Returns one issue per entry that declares neither
16
+ * a `command` (stdio) nor a `url` (http/sse) — the two ways to reach a server.
17
+ */
18
+ function verifyMcpServers(servers) {
19
+ const issues = [];
20
+ for (const [server, raw] of Object.entries(servers)) {
21
+ if (typeof raw !== "object" || raw === null) {
22
+ issues.push({
23
+ server,
24
+ message: `MCP server "${server}" is not a config object.`,
25
+ });
26
+ continue;
27
+ }
28
+ const cfg = raw;
29
+ const hasCommand = typeof cfg.command === "string" && cfg.command.length > 0;
30
+ const hasUrl = typeof cfg.url === "string" && cfg.url.length > 0;
31
+ if (!hasCommand && !hasUrl) {
32
+ issues.push({
33
+ server,
34
+ message: `MCP server "${server}" declares neither a "command" (stdio) nor a "url" (http/sse) — it can't start.`,
35
+ });
36
+ }
37
+ }
38
+ return issues;
39
+ }
40
+ //# sourceMappingURL=mcp-config.js.map
@@ -0,0 +1,35 @@
1
+ /**
2
+ * MCP-hook target verification — the cross-referencing moat applied to a
3
+ * `type: "mcp_tool"` HOOK action. Claude Code hooks support five action types
4
+ * (command / http / mcp_tool / prompt / agent); an `mcp_tool` action calls a tool
5
+ * on an already-connected MCP server and REQUIRES a `server` + `tool` field. Two
6
+ * ways it silently never fires:
7
+ *
8
+ * 1. it omits `server` or `tool` — malformed, can't dispatch (unambiguous, like
9
+ * mcp-config; always flagged);
10
+ * 2. its `server` isn't one the plugin declares — can't resolve (gated on the
11
+ * plugin shipping a declared `mcpServers` set, exactly like mcp-tool-resolves;
12
+ * built-in servers such as `ide` are allowlisted).
13
+ *
14
+ * Pure + high-precision. ONE detector reused by scan + the
15
+ * `mcp-hook-target-resolves` lint rule. The dialect is injected (core ⊄ adapter).
16
+ *
17
+ * Scope note: the matcher surface (a `mcp__server__.*` matcher naming an
18
+ * undeclared server) is a DIFFERENT, regex-shaped check left to a future
19
+ * `hook-matcher` rule — this one is the literal `mcp_tool` action target only.
20
+ */
21
+ import type { HarnessDialect } from "./dialect.js";
22
+ export type McpHookIssueKind = "incomplete" | "undeclared-server";
23
+ export interface McpHookIssue {
24
+ readonly server: string | null;
25
+ readonly kind: McpHookIssueKind;
26
+ readonly message: string;
27
+ }
28
+ /**
29
+ * Verify every `type: "mcp_tool"` hook action against the plugin's declared MCP
30
+ * servers. Returns an {@link McpHookIssue} for each incomplete action (no
31
+ * `server`/`tool`) and — when the plugin declares a server set — each action
32
+ * whose `server` is neither declared nor a known built-in.
33
+ */
34
+ export declare function verifyMcpHookTargets(hooks: unknown, declaredServers: readonly string[], dialect: HarnessDialect): McpHookIssue[];
35
+ //# sourceMappingURL=mcp-hook.d.ts.map
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.verifyMcpHookTargets = verifyMcpHookTargets;
4
+ /**
5
+ * Collect the action objects from a hooks config. The canonical Claude Code shape
6
+ * is `{ <event>: [ { matcher?, hooks: [ <action>, … ] }, … ] }`; we also tolerate
7
+ * an entry that IS an action (a `type` field directly). Non-object input → none.
8
+ */
9
+ function collectHookActions(hooks) {
10
+ const actions = [];
11
+ if (hooks === null || typeof hooks !== "object")
12
+ return actions;
13
+ for (const groups of Object.values(hooks)) {
14
+ if (!Array.isArray(groups))
15
+ continue;
16
+ for (const group of groups) {
17
+ if (group === null || typeof group !== "object")
18
+ continue;
19
+ const g = group;
20
+ if (Array.isArray(g.hooks)) {
21
+ for (const a of g.hooks)
22
+ if (a !== null && typeof a === "object")
23
+ actions.push(a);
24
+ }
25
+ else if (typeof g.type === "string") {
26
+ actions.push(g);
27
+ }
28
+ }
29
+ }
30
+ return actions;
31
+ }
32
+ /**
33
+ * Verify every `type: "mcp_tool"` hook action against the plugin's declared MCP
34
+ * servers. Returns an {@link McpHookIssue} for each incomplete action (no
35
+ * `server`/`tool`) and — when the plugin declares a server set — each action
36
+ * whose `server` is neither declared nor a known built-in.
37
+ */
38
+ function verifyMcpHookTargets(hooks, declaredServers, dialect) {
39
+ const known = new Set([
40
+ ...declaredServers,
41
+ ...(dialect.knownMcpServers ?? []),
42
+ ]);
43
+ const issues = [];
44
+ for (const action of collectHookActions(hooks)) {
45
+ if (action.type !== "mcp_tool")
46
+ continue;
47
+ const server = typeof action.server === "string" ? action.server : "";
48
+ const tool = typeof action.tool === "string" ? action.tool : "";
49
+ if (server === "" || tool === "") {
50
+ issues.push({
51
+ server: server || null,
52
+ kind: "incomplete",
53
+ message: `mcp_tool hook is missing a ${server === "" ? "server" : "tool"} field — it can't dispatch.`,
54
+ });
55
+ continue;
56
+ }
57
+ // Gate: with no declared set the server may be user-global/project (unknowable).
58
+ if (declaredServers.length === 0)
59
+ continue;
60
+ if (known.has(server))
61
+ continue;
62
+ issues.push({
63
+ server,
64
+ kind: "undeclared-server",
65
+ message: `mcp_tool hook targets server "${server}", which the plugin doesn't declare (declared: ${declaredServers.join(", ")}) — the hook can't resolve.`,
66
+ });
67
+ }
68
+ return issues;
69
+ }
70
+ //# sourceMappingURL=mcp-hook.js.map