vibe-splain 3.4.1 → 3.4.2

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.
@@ -0,0 +1,81 @@
1
+ // PreToolUse hook decision logic — the deterministic gate.
2
+ //
3
+ // Pure: given the agent's intended tool call and the scan artifact, decide
4
+ // whether to escalate and how. No stdin/stdout, no filesystem — the CLI command
5
+ // wraps this with I/O. This separation is what makes the gate testable against
6
+ // real scanned stores.
7
+ import { buildEscalationContext } from '@vibe-splain/brain/dist/network/escalation.js';
8
+ const DEFER = { action: 'defer' };
9
+ // Only file-editing tools can have a blast radius. Anything else defers.
10
+ const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit']);
11
+ export function decidePreToolUse(input, gateIndex, sessionState) {
12
+ if (!EDIT_TOOLS.has(input.tool_name ?? ''))
13
+ return DEFER;
14
+ if (!gateIndex) {
15
+ if (sessionState && !sessionState.warningShown) {
16
+ const warnMsg = "vibe-splain: .vibe-splainer/gate.json is missing. Please run 'vibe-splain scan' to generate the scan architecture and enable tool guarding.";
17
+ return {
18
+ action: 'emit',
19
+ output: {
20
+ hookSpecificOutput: {
21
+ hookEventName: 'PreToolUse',
22
+ permissionDecision: 'allow',
23
+ additionalContext: warnMsg,
24
+ systemMessage: warnMsg,
25
+ },
26
+ },
27
+ };
28
+ }
29
+ return DEFER;
30
+ }
31
+ const filePath = input.tool_input?.file_path;
32
+ if (!filePath)
33
+ return DEFER;
34
+ const ctx = buildEscalationContext(filePath, gateIndex);
35
+ if (!ctx || ctx.blastRadius === 'low')
36
+ return DEFER;
37
+ const block = formatEscalation(ctx);
38
+ // high → stop-and-reconsider (human gate). medium → non-blocking awareness:
39
+ // the agent sees the context on its next request without being interrupted.
40
+ if (ctx.blastRadius === 'high') {
41
+ return {
42
+ action: 'emit',
43
+ output: {
44
+ hookSpecificOutput: {
45
+ hookEventName: 'PreToolUse',
46
+ permissionDecision: 'ask',
47
+ permissionDecisionReason: block,
48
+ additionalContext: block,
49
+ },
50
+ },
51
+ };
52
+ }
53
+ return {
54
+ action: 'emit',
55
+ output: {
56
+ hookSpecificOutput: {
57
+ hookEventName: 'PreToolUse',
58
+ permissionDecision: 'allow',
59
+ additionalContext: block,
60
+ },
61
+ },
62
+ };
63
+ }
64
+ export function formatEscalation(ctx) {
65
+ const lines = [];
66
+ lines.push(`vibe-splain: ${ctx.blastRadius} blast radius — ${ctx.targetFile} (gravity ${ctx.gravity}).`);
67
+ if (ctx.dependentCount > 0) {
68
+ const shown = ctx.dependents.slice(0, 8);
69
+ const more = ctx.dependentCount - shown.length;
70
+ lines.push(`${ctx.dependentCount} file(s) depend on it:`);
71
+ for (const d of shown)
72
+ lines.push(` - ${d}`);
73
+ if (more > 0)
74
+ lines.push(` … (+${more} more)`);
75
+ }
76
+ for (const w of ctx.riskWarnings)
77
+ lines.push(`[${w.level}] ${w.message}`);
78
+ lines.push(ctx.smallestSafeChange.summary);
79
+ return lines.join('\n');
80
+ }
81
+ //# sourceMappingURL=preToolUse.js.map
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ // Lean entrypoint for the PreToolUse hook. Bundled separately from the main CLI
3
+ // so it pulls in ONLY the gate-index reader + decision logic — no Tree-Sitter
4
+ // WASM, no MCP SDK, no chokidar. Keeps per-edit startup near the bare-Node floor
5
+ // (~40–60ms) instead of loading the full scanner bundle (~260ms+).
6
+ import { hookPreToolUseCommand } from './commands/hook.js';
7
+ hookPreToolUseCommand();
8
+ //# sourceMappingURL=hook-entry.js.map
package/dist/hook.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function findProjectRoot(start: string | undefined): string | null;
package/dist/hook.js ADDED
@@ -0,0 +1,337 @@
1
+ #!/usr/bin/env node
2
+ // dist/hook.js
3
+ import { existsSync, readFileSync, writeFileSync } from "fs";
4
+ import { dirname, join } from "path";
5
+ import { tmpdir } from "os";
6
+
7
+ // ../brain/dist/network/gateIndex.js
8
+ function isDenoisedImporter(relPath) {
9
+ if (!relPath)
10
+ return false;
11
+ const norm = relPath.replace(/\\/g, "/");
12
+ const segs = norm.split("/");
13
+ const excludedDirs = /* @__PURE__ */ new Set([
14
+ "node_modules",
15
+ "vendor",
16
+ "vendored",
17
+ "site-packages",
18
+ "third_party",
19
+ "third-party",
20
+ ".yarn",
21
+ "bower_components",
22
+ "venv",
23
+ ".venv",
24
+ "env",
25
+ "virtualenv",
26
+ ".git",
27
+ ".vibe-splainer",
28
+ "dist",
29
+ "build",
30
+ "out",
31
+ "target",
32
+ ".next",
33
+ ".nuxt",
34
+ ".docusaurus",
35
+ "coverage",
36
+ ".nyc_output",
37
+ ".cache"
38
+ ]);
39
+ for (const s of segs) {
40
+ const sLower = s.toLowerCase();
41
+ if (excludedDirs.has(sLower)) {
42
+ return false;
43
+ }
44
+ if (sLower.endsWith(".venv")) {
45
+ return false;
46
+ }
47
+ }
48
+ const fileName = segs[segs.length - 1];
49
+ const fileNameLower = fileName.toLowerCase();
50
+ if (/\.min\.[a-z]+$/.test(fileNameLower) || fileNameLower.includes(".min.")) {
51
+ return false;
52
+ }
53
+ if (/\.generated\./.test(fileNameLower) || fileNameLower.includes("__generated__")) {
54
+ return false;
55
+ }
56
+ if (fileNameLower.endsWith(".lock")) {
57
+ return false;
58
+ }
59
+ if (norm.startsWith("virtual:") || norm.startsWith("__virtual:") || norm.startsWith("webpack:")) {
60
+ return false;
61
+ }
62
+ return true;
63
+ }
64
+
65
+ // ../brain/dist/network/escalation.js
66
+ var SECURITY_PATH = /\b(auth|credential|secret|token|webhook|payment|password|oauth|session)\b/i;
67
+ var SENSITIVE_EFFECTS = /* @__PURE__ */ new Set([
68
+ "database_write",
69
+ "server_action",
70
+ "trpc_mutation",
71
+ "email_send",
72
+ "external_api_call"
73
+ ]);
74
+ var NOTABLE_RISKS = /* @__PURE__ */ new Set([
75
+ "state_machine",
76
+ "mutation_orchestration",
77
+ "side_effect_coupling",
78
+ "async_race_risk",
79
+ "registry_bottleneck",
80
+ "error_swallowing"
81
+ ]);
82
+ function buildEscalationContext(targetFile, gateIndex) {
83
+ const file = lookupFile(targetFile, gateIndex);
84
+ if (!file)
85
+ return null;
86
+ const rel = file.relativePath;
87
+ const gravity = file.gravity;
88
+ const dependents = file.dependents;
89
+ const dependentsCount = dependents.length;
90
+ const sideEffects = file.sideEffects;
91
+ const riskTypes = file.riskTypes;
92
+ const severity = file.severity;
93
+ const gravity_tier = gravity > 70 ? "high" : gravity > 40 ? "medium" : "low";
94
+ const raw_substance_tier = file.hasBehavioralSubstance && dependentsCount >= 10 ? "high" : file.hasBehavioralSubstance && dependentsCount >= 4 ? "medium" : "low";
95
+ const tierOrder = { low: 1, medium: 2, high: 3 };
96
+ let blastRadius = tierOrder[gravity_tier] >= tierOrder[raw_substance_tier] ? gravity_tier : raw_substance_tier;
97
+ const isGeneratedOrVendored = !isDenoisedImporter(targetFile) || file.demoteReason !== null && file.demoteReason !== "no inbound references from application code";
98
+ if (isGeneratedOrVendored) {
99
+ blastRadius = "low";
100
+ }
101
+ const riskWarnings = buildRiskWarnings(rel, gravity, dependentsCount, severity, sideEffects, riskTypes);
102
+ return {
103
+ targetFile: rel,
104
+ gravity,
105
+ blastRadius,
106
+ dependents,
107
+ dependentCount: dependentsCount,
108
+ fanIn: file.fanIn,
109
+ fanOut: file.fanOut,
110
+ centrality: file.centrality,
111
+ severity,
112
+ sideEffects,
113
+ riskTypes,
114
+ riskWarnings,
115
+ smallestSafeChange: buildSafeChangePolicy(blastRadius)
116
+ };
117
+ }
118
+ function lookupFile(targetFile, gateIndex) {
119
+ const files = gateIndex?.files;
120
+ if (!files)
121
+ return null;
122
+ const norm = targetFile.replace(/\\/g, "/");
123
+ if (files[norm])
124
+ return files[norm];
125
+ const suffixHits = [];
126
+ for (const [key, rec] of Object.entries(files)) {
127
+ if (norm === key || norm.endsWith("/" + key) || key.endsWith("/" + norm)) {
128
+ suffixHits.push(rec);
129
+ }
130
+ }
131
+ return suffixHits.length === 1 ? suffixHits[0] : null;
132
+ }
133
+ function buildRiskWarnings(rel, gravity, dependentCount, severity, sideEffects, riskTypes) {
134
+ const warnings = [];
135
+ if (gravity > 70) {
136
+ warnings.push({
137
+ id: `rw_${rel}_blast_radius`,
138
+ level: "critical",
139
+ message: `Central file \u2014 editing it has a large blast radius. ${dependentCount} file(s) depend on it.`,
140
+ reason: `Gravity ${gravity}/100; fan-in ${dependentCount}.`
141
+ });
142
+ }
143
+ if (severity >= 4) {
144
+ warnings.push({
145
+ id: `rw_${rel}_severity`,
146
+ level: "warning",
147
+ message: "High-severity smells already present here. Avoid making them worse.",
148
+ reason: `Canonical severity ${severity}/5.`
149
+ });
150
+ }
151
+ if (SECURITY_PATH.test(rel)) {
152
+ warnings.push({
153
+ id: `rw_${rel}_security_path`,
154
+ level: "critical",
155
+ message: "Security-sensitive file (auth/credential/webhook/payment). Do not alter auth or credential handling unless that is the explicit task.",
156
+ reason: `Path matches security-sensitive pattern.`
157
+ });
158
+ }
159
+ const sensitive = sideEffects.filter((e) => SENSITIVE_EFFECTS.has(e));
160
+ if (sensitive.length > 0) {
161
+ warnings.push({
162
+ id: `rw_${rel}_side_effects`,
163
+ level: "warning",
164
+ message: `This file performs side effects (${sensitive.join(", ")}). Preserve existing behavior; do not drop or reorder them.`,
165
+ reason: `Side-effect profile: ${sensitive.join(", ")}.`
166
+ });
167
+ }
168
+ const notable = riskTypes.filter((r) => NOTABLE_RISKS.has(r));
169
+ if (notable.length > 0) {
170
+ warnings.push({
171
+ id: `rw_${rel}_risk_types`,
172
+ level: "info",
173
+ message: `Structural risk patterns detected (${notable.join(", ")}). Trace the affected paths before editing.`,
174
+ reason: `Risk types: ${notable.join(", ")}.`
175
+ });
176
+ }
177
+ return warnings;
178
+ }
179
+ function buildSafeChangePolicy(blastRadius) {
180
+ const rules = [
181
+ "Locate the exact failure site before editing.",
182
+ "Make the smallest localized change that addresses the task.",
183
+ "Do not perform general cleanup or refactoring.",
184
+ "Do not modify unrelated files, credentials, or auth configuration."
185
+ ];
186
+ if (blastRadius === "high") {
187
+ rules.push("This file is load-bearing: verify each dependent still type-checks against the changed surface.");
188
+ }
189
+ return {
190
+ summary: "Make the smallest localized change that addresses the requested task. Do not modify unrelated files, credentials, auth configuration, or neighboring modules unless the task explicitly requires it.",
191
+ rules,
192
+ forbiddenEditClasses: ["credentials", "auth_configuration", "global_refactoring"]
193
+ };
194
+ }
195
+
196
+ // dist/hook/preToolUse.js
197
+ var DEFER = { action: "defer" };
198
+ var EDIT_TOOLS = /* @__PURE__ */ new Set(["Edit", "Write", "MultiEdit"]);
199
+ function decidePreToolUse(input, gateIndex, sessionState) {
200
+ if (!EDIT_TOOLS.has(input.tool_name ?? ""))
201
+ return DEFER;
202
+ if (!gateIndex) {
203
+ if (sessionState && !sessionState.warningShown) {
204
+ const warnMsg = "vibe-splain: .vibe-splainer/gate.json is missing. Please run 'vibe-splain scan' to generate the scan architecture and enable tool guarding.";
205
+ return {
206
+ action: "emit",
207
+ output: {
208
+ hookSpecificOutput: {
209
+ hookEventName: "PreToolUse",
210
+ permissionDecision: "allow",
211
+ additionalContext: warnMsg,
212
+ systemMessage: warnMsg
213
+ }
214
+ }
215
+ };
216
+ }
217
+ return DEFER;
218
+ }
219
+ const filePath = input.tool_input?.file_path;
220
+ if (!filePath)
221
+ return DEFER;
222
+ const ctx = buildEscalationContext(filePath, gateIndex);
223
+ if (!ctx || ctx.blastRadius === "low")
224
+ return DEFER;
225
+ const block = formatEscalation(ctx);
226
+ if (ctx.blastRadius === "high") {
227
+ return {
228
+ action: "emit",
229
+ output: {
230
+ hookSpecificOutput: {
231
+ hookEventName: "PreToolUse",
232
+ permissionDecision: "ask",
233
+ permissionDecisionReason: block,
234
+ additionalContext: block
235
+ }
236
+ }
237
+ };
238
+ }
239
+ return {
240
+ action: "emit",
241
+ output: {
242
+ hookSpecificOutput: {
243
+ hookEventName: "PreToolUse",
244
+ permissionDecision: "allow",
245
+ additionalContext: block
246
+ }
247
+ }
248
+ };
249
+ }
250
+ function formatEscalation(ctx) {
251
+ const lines = [];
252
+ lines.push(`vibe-splain: ${ctx.blastRadius} blast radius \u2014 ${ctx.targetFile} (gravity ${ctx.gravity}).`);
253
+ if (ctx.dependentCount > 0) {
254
+ const shown = ctx.dependents.slice(0, 8);
255
+ const more = ctx.dependentCount - shown.length;
256
+ lines.push(`${ctx.dependentCount} file(s) depend on it:`);
257
+ for (const d of shown)
258
+ lines.push(` - ${d}`);
259
+ if (more > 0)
260
+ lines.push(` \u2026 (+${more} more)`);
261
+ }
262
+ for (const w of ctx.riskWarnings)
263
+ lines.push(`[${w.level}] ${w.message}`);
264
+ lines.push(ctx.smallestSafeChange.summary);
265
+ return lines.join("\n");
266
+ }
267
+
268
+ // dist/hook.js
269
+ function findProjectRoot(start) {
270
+ let dir = start || process.cwd();
271
+ for (let i = 0; i < 64; i++) {
272
+ if (existsSync(join(dir, ".vibe-splainer")))
273
+ return dir;
274
+ const parent = dirname(dir);
275
+ if (parent === dir)
276
+ break;
277
+ dir = parent;
278
+ }
279
+ return null;
280
+ }
281
+ function readStdin() {
282
+ return new Promise((resolve) => {
283
+ let data = "";
284
+ if (process.stdin.isTTY) {
285
+ resolve("");
286
+ return;
287
+ }
288
+ process.stdin.setEncoding("utf8");
289
+ process.stdin.on("data", (chunk) => {
290
+ data += chunk;
291
+ });
292
+ process.stdin.on("end", () => resolve(data));
293
+ process.stdin.on("error", () => resolve(data));
294
+ });
295
+ }
296
+ async function main() {
297
+ const rawStdin = await readStdin();
298
+ let input;
299
+ try {
300
+ input = JSON.parse(rawStdin);
301
+ } catch {
302
+ process.exit(0);
303
+ }
304
+ const root = findProjectRoot(input.cwd);
305
+ const gatePath = root ? join(root, ".vibe-splainer", "gate.json") : null;
306
+ const gateExists = gatePath ? existsSync(gatePath) : false;
307
+ let result;
308
+ if (!gateExists) {
309
+ const sessionId = input.session_id || "default";
310
+ const warnFile = join(tmpdir(), `vibe-splain-warn-${sessionId}`);
311
+ const warningShown = existsSync(warnFile);
312
+ result = decidePreToolUse(input, null, { warningShown });
313
+ if (result.action === "emit") {
314
+ try {
315
+ writeFileSync(warnFile, "1");
316
+ } catch {
317
+ }
318
+ }
319
+ } else {
320
+ let gateIndex = null;
321
+ try {
322
+ gateIndex = JSON.parse(readFileSync(gatePath, "utf8"));
323
+ } catch {
324
+ }
325
+ result = decidePreToolUse(input, gateIndex);
326
+ }
327
+ if (result.action === "emit") {
328
+ process.stdout.write(JSON.stringify(result.output));
329
+ }
330
+ process.exit(0);
331
+ }
332
+ main().catch(() => {
333
+ process.exit(0);
334
+ });
335
+ export {
336
+ findProjectRoot
337
+ };