engramx 0.2.1 → 0.3.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.
- package/README.md +100 -31
- package/dist/{chunk-YQ3FPGPC.js → chunk-7KYR4SPZ.js} +1 -1
- package/dist/{chunk-22ADJYQ5.js → chunk-NYFDM4FR.js} +177 -2
- package/dist/cli.js +1376 -3
- package/dist/{core-HWOM7GSU.js → core-MPNNCPFW.js} +3 -1
- package/dist/index.js +2 -2
- package/dist/serve.js +1 -1
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -4,9 +4,10 @@ import {
|
|
|
4
4
|
install,
|
|
5
5
|
status,
|
|
6
6
|
uninstall
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-7KYR4SPZ.js";
|
|
8
8
|
import {
|
|
9
9
|
benchmark,
|
|
10
|
+
getFileContext,
|
|
10
11
|
godNodes,
|
|
11
12
|
init,
|
|
12
13
|
learn,
|
|
@@ -14,13 +15,1076 @@ import {
|
|
|
14
15
|
path,
|
|
15
16
|
query,
|
|
16
17
|
stats
|
|
17
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-NYFDM4FR.js";
|
|
18
19
|
|
|
19
20
|
// src/cli.ts
|
|
20
21
|
import { Command } from "commander";
|
|
21
22
|
import chalk from "chalk";
|
|
23
|
+
import {
|
|
24
|
+
existsSync as existsSync5,
|
|
25
|
+
readFileSync as readFileSync3,
|
|
26
|
+
writeFileSync,
|
|
27
|
+
mkdirSync,
|
|
28
|
+
unlinkSync,
|
|
29
|
+
copyFileSync,
|
|
30
|
+
renameSync as renameSync2
|
|
31
|
+
} from "fs";
|
|
32
|
+
import { dirname as dirname3, join as join5, resolve as pathResolve } from "path";
|
|
33
|
+
import { homedir } from "os";
|
|
34
|
+
|
|
35
|
+
// src/intercept/safety.ts
|
|
36
|
+
import { existsSync } from "fs";
|
|
37
|
+
import { join } from "path";
|
|
38
|
+
var PASSTHROUGH = null;
|
|
39
|
+
var DEFAULT_HANDLER_TIMEOUT_MS = 2e3;
|
|
40
|
+
async function withTimeout(promise, ms = DEFAULT_HANDLER_TIMEOUT_MS) {
|
|
41
|
+
let timer;
|
|
42
|
+
const timeout = new Promise((resolve3) => {
|
|
43
|
+
timer = setTimeout(() => resolve3(PASSTHROUGH), ms);
|
|
44
|
+
});
|
|
45
|
+
try {
|
|
46
|
+
return await Promise.race([promise, timeout]);
|
|
47
|
+
} finally {
|
|
48
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
async function wrapSafely(handler, onError) {
|
|
52
|
+
try {
|
|
53
|
+
return await handler();
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (onError) {
|
|
56
|
+
try {
|
|
57
|
+
onError(err);
|
|
58
|
+
} catch {
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return PASSTHROUGH;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async function runHandler(handler, opts = {}) {
|
|
65
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_HANDLER_TIMEOUT_MS;
|
|
66
|
+
return wrapSafely(() => withTimeout(handler(), timeoutMs), opts.onError);
|
|
67
|
+
}
|
|
68
|
+
function isHookDisabled(projectRoot) {
|
|
69
|
+
if (projectRoot === null) return true;
|
|
70
|
+
try {
|
|
71
|
+
return existsSync(join(projectRoot, ".engram", "hook-disabled"));
|
|
72
|
+
} catch {
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// src/intercept/context.ts
|
|
78
|
+
import { existsSync as existsSync2, realpathSync, statSync } from "fs";
|
|
79
|
+
import { dirname, isAbsolute, join as join2, resolve, sep } from "path";
|
|
80
|
+
var ENGRAM_DIR = ".engram";
|
|
81
|
+
var GRAPH_FILE = "graph.db";
|
|
82
|
+
var MAX_WALK_DEPTH = 40;
|
|
83
|
+
var projectRootCache = /* @__PURE__ */ new Map();
|
|
84
|
+
function normalizePath(filePath, cwd) {
|
|
85
|
+
if (!filePath) return "";
|
|
86
|
+
try {
|
|
87
|
+
const abs = isAbsolute(filePath) ? filePath : resolve(cwd, filePath);
|
|
88
|
+
return resolve(abs);
|
|
89
|
+
} catch {
|
|
90
|
+
return "";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function isExemptPath(absPath) {
|
|
94
|
+
if (!absPath) return true;
|
|
95
|
+
const p = absPath.replaceAll(sep, "/");
|
|
96
|
+
if (p.includes("/.engram/cache/")) return true;
|
|
97
|
+
if (p.includes("/node_modules/")) return true;
|
|
98
|
+
if (p.includes("/.git/")) return true;
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
function isHardSystemPath(absPath) {
|
|
102
|
+
if (!absPath) return true;
|
|
103
|
+
const p = absPath.replaceAll(sep, "/");
|
|
104
|
+
if (p === "/" || p.startsWith("/dev/") || p.startsWith("/proc/")) return true;
|
|
105
|
+
if (p.startsWith("/sys/")) return true;
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
109
|
+
// Images
|
|
110
|
+
".png",
|
|
111
|
+
".jpg",
|
|
112
|
+
".jpeg",
|
|
113
|
+
".gif",
|
|
114
|
+
".webp",
|
|
115
|
+
".ico",
|
|
116
|
+
".bmp",
|
|
117
|
+
".tiff",
|
|
118
|
+
".svg",
|
|
119
|
+
// Documents
|
|
120
|
+
".pdf",
|
|
121
|
+
".doc",
|
|
122
|
+
".docx",
|
|
123
|
+
".xls",
|
|
124
|
+
".xlsx",
|
|
125
|
+
".ppt",
|
|
126
|
+
".pptx",
|
|
127
|
+
// Archives
|
|
128
|
+
".zip",
|
|
129
|
+
".tar",
|
|
130
|
+
".gz",
|
|
131
|
+
".tgz",
|
|
132
|
+
".bz2",
|
|
133
|
+
".xz",
|
|
134
|
+
".7z",
|
|
135
|
+
".rar",
|
|
136
|
+
// Compiled / binary code
|
|
137
|
+
".exe",
|
|
138
|
+
".dll",
|
|
139
|
+
".so",
|
|
140
|
+
".dylib",
|
|
141
|
+
".o",
|
|
142
|
+
".a",
|
|
143
|
+
".class",
|
|
144
|
+
".wasm",
|
|
145
|
+
// Audio / video
|
|
146
|
+
".mp3",
|
|
147
|
+
".mp4",
|
|
148
|
+
".m4a",
|
|
149
|
+
".wav",
|
|
150
|
+
".flac",
|
|
151
|
+
".ogg",
|
|
152
|
+
".avi",
|
|
153
|
+
".mov",
|
|
154
|
+
".mkv",
|
|
155
|
+
".webm",
|
|
156
|
+
// Data blobs
|
|
157
|
+
".bin",
|
|
158
|
+
".dat",
|
|
159
|
+
".db",
|
|
160
|
+
".sqlite",
|
|
161
|
+
".parquet",
|
|
162
|
+
// Fonts
|
|
163
|
+
".ttf",
|
|
164
|
+
".otf",
|
|
165
|
+
".woff",
|
|
166
|
+
".woff2",
|
|
167
|
+
".eot"
|
|
168
|
+
]);
|
|
169
|
+
function isBinaryFile(absPath) {
|
|
170
|
+
if (!absPath) return false;
|
|
171
|
+
const dot = absPath.lastIndexOf(".");
|
|
172
|
+
if (dot === -1) return false;
|
|
173
|
+
const ext = absPath.slice(dot).toLowerCase();
|
|
174
|
+
return BINARY_EXTENSIONS.has(ext);
|
|
175
|
+
}
|
|
176
|
+
function isSecretFile(absPath) {
|
|
177
|
+
if (!absPath) return false;
|
|
178
|
+
const idx = Math.max(absPath.lastIndexOf("/"), absPath.lastIndexOf(sep));
|
|
179
|
+
const base = idx === -1 ? absPath : absPath.slice(idx + 1);
|
|
180
|
+
const lower = base.toLowerCase();
|
|
181
|
+
if (lower === ".env") return true;
|
|
182
|
+
if (lower.startsWith(".env.")) return true;
|
|
183
|
+
if (lower === "secrets.json" || lower === "secrets.yaml" || lower === "secrets.yml") return true;
|
|
184
|
+
if (lower === "credentials" || lower === "credentials.json") return true;
|
|
185
|
+
if (lower === "config.secret.json") return true;
|
|
186
|
+
if (lower.endsWith(".pem")) return true;
|
|
187
|
+
if (lower.endsWith(".key")) return true;
|
|
188
|
+
if (lower.endsWith(".p12")) return true;
|
|
189
|
+
if (lower.endsWith(".pfx")) return true;
|
|
190
|
+
if (lower.endsWith(".keystore")) return true;
|
|
191
|
+
if (lower === "id_rsa" || lower === "id_ed25519" || lower === "id_ecdsa" || lower === "id_dsa") {
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
function isContentUnsafeForIntercept(absPath) {
|
|
197
|
+
return isBinaryFile(absPath) || isSecretFile(absPath);
|
|
198
|
+
}
|
|
199
|
+
function findProjectRoot(filePath) {
|
|
200
|
+
if (!filePath) return null;
|
|
201
|
+
let startDir;
|
|
202
|
+
try {
|
|
203
|
+
const st = statSync(filePath);
|
|
204
|
+
startDir = st.isDirectory() ? filePath : dirname(filePath);
|
|
205
|
+
} catch {
|
|
206
|
+
startDir = dirname(filePath);
|
|
207
|
+
}
|
|
208
|
+
if (projectRootCache.has(startDir)) {
|
|
209
|
+
return projectRootCache.get(startDir) ?? null;
|
|
210
|
+
}
|
|
211
|
+
let current = startDir;
|
|
212
|
+
let depth = 0;
|
|
213
|
+
while (depth < MAX_WALK_DEPTH) {
|
|
214
|
+
const candidate = join2(current, ENGRAM_DIR, GRAPH_FILE);
|
|
215
|
+
try {
|
|
216
|
+
if (existsSync2(candidate)) {
|
|
217
|
+
projectRootCache.set(startDir, current);
|
|
218
|
+
return current;
|
|
219
|
+
}
|
|
220
|
+
} catch {
|
|
221
|
+
}
|
|
222
|
+
const parent = dirname(current);
|
|
223
|
+
if (parent === current) {
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
current = parent;
|
|
227
|
+
depth += 1;
|
|
228
|
+
}
|
|
229
|
+
projectRootCache.set(startDir, null);
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
function isInsideProject(filePath, projectRoot) {
|
|
233
|
+
if (!filePath || !projectRoot) return false;
|
|
234
|
+
try {
|
|
235
|
+
const realRoot = realpathSync(projectRoot);
|
|
236
|
+
let realFile;
|
|
237
|
+
try {
|
|
238
|
+
realFile = realpathSync(filePath);
|
|
239
|
+
} catch {
|
|
240
|
+
realFile = resolve(filePath);
|
|
241
|
+
}
|
|
242
|
+
const rootWithSep = realRoot.endsWith(sep) ? realRoot : realRoot + sep;
|
|
243
|
+
return realFile === realRoot || realFile.startsWith(rootWithSep);
|
|
244
|
+
} catch {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
function isValidCwd(cwd) {
|
|
249
|
+
if (!cwd || typeof cwd !== "string") return false;
|
|
250
|
+
if (!isAbsolute(cwd)) return false;
|
|
251
|
+
try {
|
|
252
|
+
return statSync(cwd).isDirectory();
|
|
253
|
+
} catch {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function resolveInterceptContext(filePath, cwd) {
|
|
258
|
+
if (!filePath) return { proceed: false, reason: "empty-path" };
|
|
259
|
+
const absPath = normalizePath(filePath, cwd);
|
|
260
|
+
if (!absPath) return { proceed: false, reason: "normalize-failed" };
|
|
261
|
+
if (isHardSystemPath(absPath)) {
|
|
262
|
+
return { proceed: false, reason: "system-path" };
|
|
263
|
+
}
|
|
264
|
+
const projectRoot = findProjectRoot(absPath);
|
|
265
|
+
if (projectRoot === null) {
|
|
266
|
+
return { proceed: false, reason: "no-project-root" };
|
|
267
|
+
}
|
|
268
|
+
if (!isInsideProject(absPath, projectRoot)) {
|
|
269
|
+
return { proceed: false, reason: "outside-project" };
|
|
270
|
+
}
|
|
271
|
+
if (isExemptPath(absPath)) {
|
|
272
|
+
return { proceed: false, reason: "exempt-path" };
|
|
273
|
+
}
|
|
274
|
+
return { proceed: true, absPath, projectRoot };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// src/intercept/formatter.ts
|
|
278
|
+
var MAX_RESPONSE_CHARS = 8e3;
|
|
279
|
+
var TRUNCATION_MARKER = "\n\n[... engram summary truncated to fit hook response limit ...]";
|
|
280
|
+
function truncateForHook(text) {
|
|
281
|
+
if (!text) return "";
|
|
282
|
+
if (text.length <= MAX_RESPONSE_CHARS) return text;
|
|
283
|
+
const budget = MAX_RESPONSE_CHARS - TRUNCATION_MARKER.length;
|
|
284
|
+
let cut = budget;
|
|
285
|
+
const code = text.charCodeAt(cut - 1);
|
|
286
|
+
if (code >= 55296 && code <= 56319) cut -= 1;
|
|
287
|
+
return text.slice(0, cut) + TRUNCATION_MARKER;
|
|
288
|
+
}
|
|
289
|
+
function buildDenyResponse(reason) {
|
|
290
|
+
return {
|
|
291
|
+
hookSpecificOutput: {
|
|
292
|
+
hookEventName: "PreToolUse",
|
|
293
|
+
permissionDecision: "deny",
|
|
294
|
+
permissionDecisionReason: truncateForHook(reason)
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
function buildAllowWithContextResponse(additionalContext) {
|
|
299
|
+
const trimmed = additionalContext?.trim() ?? "";
|
|
300
|
+
if (trimmed.length === 0) {
|
|
301
|
+
return {
|
|
302
|
+
hookSpecificOutput: {
|
|
303
|
+
hookEventName: "PreToolUse",
|
|
304
|
+
permissionDecision: "allow"
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
return {
|
|
309
|
+
hookSpecificOutput: {
|
|
310
|
+
hookEventName: "PreToolUse",
|
|
311
|
+
permissionDecision: "allow",
|
|
312
|
+
additionalContext: truncateForHook(trimmed)
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
function buildSessionContextResponse(eventName, additionalContext) {
|
|
317
|
+
const trimmed = additionalContext?.trim() ?? "";
|
|
318
|
+
if (trimmed.length === 0) return null;
|
|
319
|
+
return {
|
|
320
|
+
hookSpecificOutput: {
|
|
321
|
+
hookEventName: eventName,
|
|
322
|
+
additionalContext: truncateForHook(trimmed)
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// src/intercept/handlers/read.ts
|
|
328
|
+
var READ_CONFIDENCE_THRESHOLD = 0.7;
|
|
329
|
+
async function handleRead(payload) {
|
|
330
|
+
if (payload.tool_name !== "Read") return PASSTHROUGH;
|
|
331
|
+
const filePath = payload.tool_input?.file_path;
|
|
332
|
+
if (!filePath || typeof filePath !== "string") return PASSTHROUGH;
|
|
333
|
+
const offset = payload.tool_input.offset;
|
|
334
|
+
const limit = payload.tool_input.limit;
|
|
335
|
+
if (typeof offset === "number" && offset > 0 || typeof limit === "number" && limit > 0) {
|
|
336
|
+
return PASSTHROUGH;
|
|
337
|
+
}
|
|
338
|
+
if (isContentUnsafeForIntercept(filePath)) return PASSTHROUGH;
|
|
339
|
+
const ctx = resolveInterceptContext(filePath, payload.cwd);
|
|
340
|
+
if (!ctx.proceed) return PASSTHROUGH;
|
|
341
|
+
if (isContentUnsafeForIntercept(ctx.absPath)) return PASSTHROUGH;
|
|
342
|
+
if (isHookDisabled(ctx.projectRoot)) return PASSTHROUGH;
|
|
343
|
+
const fileCtx = await getFileContext(ctx.projectRoot, ctx.absPath);
|
|
344
|
+
if (!fileCtx.found || fileCtx.codeNodeCount === 0) return PASSTHROUGH;
|
|
345
|
+
if (fileCtx.isStale) return PASSTHROUGH;
|
|
346
|
+
if (fileCtx.confidence < READ_CONFIDENCE_THRESHOLD) return PASSTHROUGH;
|
|
347
|
+
return buildDenyResponse(fileCtx.summary);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// src/intercept/handlers/edit-write.ts
|
|
351
|
+
import { relative, resolve as resolvePath } from "path";
|
|
352
|
+
var MAX_LANDMINES_IN_WARNING = 5;
|
|
353
|
+
function formatLandmineWarning(projectRelativeFile, mistakeList) {
|
|
354
|
+
const header = `[engram landmines] ${mistakeList.length} past mistake${mistakeList.length === 1 ? "" : "s"} recorded for ${projectRelativeFile}:`;
|
|
355
|
+
const items = mistakeList.map((m) => {
|
|
356
|
+
const conf = m.confidence === "EXTRACTED" ? "" : ` [${m.confidence.toLowerCase()}]`;
|
|
357
|
+
return ` - ${m.label}${conf}`;
|
|
358
|
+
});
|
|
359
|
+
const footer = "Review these before editing to avoid re-introducing a known failure mode. engram recorded these from past session notes (bug:/fix: lines in your CLAUDE.md).";
|
|
360
|
+
return [header, ...items, "", footer].join("\n");
|
|
361
|
+
}
|
|
362
|
+
async function handleEditOrWrite(payload) {
|
|
363
|
+
if (payload.tool_name !== "Edit" && payload.tool_name !== "Write") {
|
|
364
|
+
return PASSTHROUGH;
|
|
365
|
+
}
|
|
366
|
+
const filePath = payload.tool_input?.file_path;
|
|
367
|
+
if (!filePath || typeof filePath !== "string") return PASSTHROUGH;
|
|
368
|
+
if (isContentUnsafeForIntercept(filePath)) return PASSTHROUGH;
|
|
369
|
+
const ctx = resolveInterceptContext(filePath, payload.cwd);
|
|
370
|
+
if (!ctx.proceed) return PASSTHROUGH;
|
|
371
|
+
if (isContentUnsafeForIntercept(ctx.absPath)) return PASSTHROUGH;
|
|
372
|
+
if (isHookDisabled(ctx.projectRoot)) return PASSTHROUGH;
|
|
373
|
+
const relPath = relative(resolvePath(ctx.projectRoot), ctx.absPath);
|
|
374
|
+
if (!relPath || relPath.startsWith("..")) return PASSTHROUGH;
|
|
375
|
+
let found;
|
|
376
|
+
try {
|
|
377
|
+
found = await mistakes(ctx.projectRoot, {
|
|
378
|
+
sourceFile: relPath,
|
|
379
|
+
limit: MAX_LANDMINES_IN_WARNING
|
|
380
|
+
});
|
|
381
|
+
} catch {
|
|
382
|
+
return PASSTHROUGH;
|
|
383
|
+
}
|
|
384
|
+
if (found.length === 0) return PASSTHROUGH;
|
|
385
|
+
const warning = formatLandmineWarning(relPath, found);
|
|
386
|
+
return buildAllowWithContextResponse(warning);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// src/intercept/handlers/bash.ts
|
|
390
|
+
var READ_LIKE_COMMANDS = /* @__PURE__ */ new Set([
|
|
391
|
+
"cat",
|
|
392
|
+
"head",
|
|
393
|
+
"tail",
|
|
394
|
+
"less",
|
|
395
|
+
"more"
|
|
396
|
+
]);
|
|
397
|
+
var UNSAFE_SHELL_CHARS = /[|&;<>()$`\\*?[\]{}"']/;
|
|
398
|
+
function parseReadLikeBashCommand(command) {
|
|
399
|
+
if (!command || typeof command !== "string") return null;
|
|
400
|
+
if (command.length > 200) return null;
|
|
401
|
+
const trimmed = command.trim();
|
|
402
|
+
if (trimmed !== command) {
|
|
403
|
+
return null;
|
|
404
|
+
}
|
|
405
|
+
if (UNSAFE_SHELL_CHARS.test(trimmed)) return null;
|
|
406
|
+
const tokens = trimmed.split(/\s+/);
|
|
407
|
+
if (tokens.length !== 2) return null;
|
|
408
|
+
const [cmd, path2] = tokens;
|
|
409
|
+
if (!READ_LIKE_COMMANDS.has(cmd)) return null;
|
|
410
|
+
if (path2.startsWith("-")) return null;
|
|
411
|
+
if (path2.length === 0) return null;
|
|
412
|
+
if (path2.includes("\0")) return null;
|
|
413
|
+
return path2;
|
|
414
|
+
}
|
|
415
|
+
async function handleBash(payload) {
|
|
416
|
+
if (payload.tool_name !== "Bash") return PASSTHROUGH;
|
|
417
|
+
const command = payload.tool_input?.command;
|
|
418
|
+
if (!command || typeof command !== "string") return PASSTHROUGH;
|
|
419
|
+
const filePath = parseReadLikeBashCommand(command);
|
|
420
|
+
if (filePath === null) return PASSTHROUGH;
|
|
421
|
+
return handleRead({
|
|
422
|
+
tool_name: "Read",
|
|
423
|
+
cwd: payload.cwd,
|
|
424
|
+
tool_input: {
|
|
425
|
+
file_path: filePath
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// src/intercept/handlers/session-start.ts
|
|
431
|
+
import { existsSync as existsSync3, readFileSync } from "fs";
|
|
432
|
+
import { basename, dirname as dirname2, join as join3, resolve as resolve2 } from "path";
|
|
433
|
+
var MAX_GOD_NODES = 10;
|
|
434
|
+
var MAX_LANDMINES_IN_BRIEF = 3;
|
|
435
|
+
function readGitBranch(projectRoot) {
|
|
436
|
+
try {
|
|
437
|
+
let current = resolve2(projectRoot);
|
|
438
|
+
for (let depth = 0; depth < 10; depth++) {
|
|
439
|
+
const headPath = join3(current, ".git", "HEAD");
|
|
440
|
+
if (existsSync3(headPath)) {
|
|
441
|
+
const content = readFileSync(headPath, "utf-8").trim();
|
|
442
|
+
const refMatch = content.match(/^ref:\s+refs\/heads\/(.+)$/);
|
|
443
|
+
if (refMatch) return refMatch[1];
|
|
444
|
+
if (/^[0-9a-f]{7,40}$/i.test(content)) return "detached";
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
const parent = dirname2(current);
|
|
448
|
+
if (parent === current) return null;
|
|
449
|
+
current = parent;
|
|
450
|
+
}
|
|
451
|
+
return null;
|
|
452
|
+
} catch {
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
function formatBrief(args) {
|
|
457
|
+
const lines = [];
|
|
458
|
+
const minedAgo = args.stats.lastMined > 0 ? describeAgo(Date.now() - args.stats.lastMined) : "unknown";
|
|
459
|
+
const branchStr = args.branch ? ` (branch: ${args.branch})` : "";
|
|
460
|
+
lines.push(`[engram] Project brief for ${args.projectName}${branchStr}`);
|
|
461
|
+
lines.push(
|
|
462
|
+
`Graph: ${args.stats.nodes} nodes, ${args.stats.edges} edges, ${args.stats.extractedPct}% extracted. Last mined: ${minedAgo}.`
|
|
463
|
+
);
|
|
464
|
+
lines.push("");
|
|
465
|
+
if (args.godNodes.length > 0) {
|
|
466
|
+
lines.push("Core entities (most connected):");
|
|
467
|
+
for (const g of args.godNodes) {
|
|
468
|
+
lines.push(
|
|
469
|
+
` - ${g.label} [${g.kind}] (${g.degree} conn) \u2014 ${g.sourceFile}`
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
lines.push("");
|
|
473
|
+
}
|
|
474
|
+
if (args.landmines.length > 0) {
|
|
475
|
+
lines.push("Known landmines (past issues to watch for):");
|
|
476
|
+
for (const m of args.landmines) {
|
|
477
|
+
lines.push(` - ${m.sourceFile}: ${m.label}`);
|
|
478
|
+
}
|
|
479
|
+
lines.push("");
|
|
480
|
+
}
|
|
481
|
+
lines.push(
|
|
482
|
+
"Tip: engram intercepts Read/Edit/Write/Bash tool calls. Code structure comes through the graph automatically; you don't need to explore files to understand layout. Use explicit offset/limit on Read if you need raw lines."
|
|
483
|
+
);
|
|
484
|
+
return lines.join("\n");
|
|
485
|
+
}
|
|
486
|
+
function describeAgo(ms) {
|
|
487
|
+
if (ms < 0) return "just now";
|
|
488
|
+
const s = Math.floor(ms / 1e3);
|
|
489
|
+
if (s < 60) return `${s}s ago`;
|
|
490
|
+
const m = Math.floor(s / 60);
|
|
491
|
+
if (m < 60) return `${m}m ago`;
|
|
492
|
+
const h = Math.floor(m / 60);
|
|
493
|
+
if (h < 24) return `${h}h ago`;
|
|
494
|
+
const d = Math.floor(h / 24);
|
|
495
|
+
return `${d}d ago`;
|
|
496
|
+
}
|
|
497
|
+
async function handleSessionStart(payload) {
|
|
498
|
+
if (payload.hook_event_name !== "SessionStart") return PASSTHROUGH;
|
|
499
|
+
const source = payload.source ?? "startup";
|
|
500
|
+
if (source === "resume") return PASSTHROUGH;
|
|
501
|
+
const cwd = payload.cwd;
|
|
502
|
+
if (!isValidCwd(cwd)) return PASSTHROUGH;
|
|
503
|
+
const projectRoot = findProjectRoot(cwd);
|
|
504
|
+
if (projectRoot === null) return PASSTHROUGH;
|
|
505
|
+
if (isHookDisabled(projectRoot)) return PASSTHROUGH;
|
|
506
|
+
try {
|
|
507
|
+
const [gods, mistakeList, graphStats] = await Promise.all([
|
|
508
|
+
godNodes(projectRoot, MAX_GOD_NODES).catch(() => []),
|
|
509
|
+
mistakes(projectRoot, { limit: MAX_LANDMINES_IN_BRIEF }).catch(
|
|
510
|
+
() => []
|
|
511
|
+
),
|
|
512
|
+
stats(projectRoot).catch(() => ({
|
|
513
|
+
nodes: 0,
|
|
514
|
+
edges: 0,
|
|
515
|
+
communities: 0,
|
|
516
|
+
extractedPct: 0,
|
|
517
|
+
inferredPct: 0,
|
|
518
|
+
ambiguousPct: 0,
|
|
519
|
+
lastMined: 0,
|
|
520
|
+
totalQueryTokensSaved: 0
|
|
521
|
+
}))
|
|
522
|
+
]);
|
|
523
|
+
if (graphStats.nodes === 0 && gods.length === 0) return PASSTHROUGH;
|
|
524
|
+
const branch = readGitBranch(projectRoot);
|
|
525
|
+
const projectName = basename(projectRoot);
|
|
526
|
+
const text = formatBrief({
|
|
527
|
+
projectName,
|
|
528
|
+
branch,
|
|
529
|
+
stats: {
|
|
530
|
+
nodes: graphStats.nodes,
|
|
531
|
+
edges: graphStats.edges,
|
|
532
|
+
extractedPct: graphStats.extractedPct,
|
|
533
|
+
lastMined: graphStats.lastMined
|
|
534
|
+
},
|
|
535
|
+
godNodes: gods,
|
|
536
|
+
landmines: mistakeList.map((m) => ({
|
|
537
|
+
label: m.label,
|
|
538
|
+
sourceFile: m.sourceFile
|
|
539
|
+
}))
|
|
540
|
+
});
|
|
541
|
+
return buildSessionContextResponse("SessionStart", text);
|
|
542
|
+
} catch {
|
|
543
|
+
return PASSTHROUGH;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// src/intercept/handlers/user-prompt.ts
|
|
548
|
+
var MIN_SIGNIFICANT_TERMS = 2;
|
|
549
|
+
var MIN_MATCHED_NODES = 3;
|
|
550
|
+
var PROMPT_INJECTION_TOKEN_BUDGET = 500;
|
|
551
|
+
var STOPWORDS = /* @__PURE__ */ new Set([
|
|
552
|
+
"a",
|
|
553
|
+
"an",
|
|
554
|
+
"the",
|
|
555
|
+
"is",
|
|
556
|
+
"are",
|
|
557
|
+
"was",
|
|
558
|
+
"were",
|
|
559
|
+
"be",
|
|
560
|
+
"been",
|
|
561
|
+
"being",
|
|
562
|
+
"have",
|
|
563
|
+
"has",
|
|
564
|
+
"had",
|
|
565
|
+
"do",
|
|
566
|
+
"does",
|
|
567
|
+
"did",
|
|
568
|
+
"will",
|
|
569
|
+
"would",
|
|
570
|
+
"could",
|
|
571
|
+
"should",
|
|
572
|
+
"may",
|
|
573
|
+
"might",
|
|
574
|
+
"must",
|
|
575
|
+
"can",
|
|
576
|
+
"shall",
|
|
577
|
+
"to",
|
|
578
|
+
"of",
|
|
579
|
+
"for",
|
|
580
|
+
"in",
|
|
581
|
+
"on",
|
|
582
|
+
"at",
|
|
583
|
+
"by",
|
|
584
|
+
"with",
|
|
585
|
+
"from",
|
|
586
|
+
"and",
|
|
587
|
+
"or",
|
|
588
|
+
"but",
|
|
589
|
+
"not",
|
|
590
|
+
"no",
|
|
591
|
+
"this",
|
|
592
|
+
"that",
|
|
593
|
+
"these",
|
|
594
|
+
"those",
|
|
595
|
+
"i",
|
|
596
|
+
"you",
|
|
597
|
+
"he",
|
|
598
|
+
"she",
|
|
599
|
+
"it",
|
|
600
|
+
"we",
|
|
601
|
+
"they",
|
|
602
|
+
"my",
|
|
603
|
+
"your",
|
|
604
|
+
"his",
|
|
605
|
+
"her",
|
|
606
|
+
"its",
|
|
607
|
+
"our",
|
|
608
|
+
"their",
|
|
609
|
+
"me",
|
|
610
|
+
"him",
|
|
611
|
+
"us",
|
|
612
|
+
"them",
|
|
613
|
+
"as",
|
|
614
|
+
"if",
|
|
615
|
+
"when",
|
|
616
|
+
"where",
|
|
617
|
+
"why",
|
|
618
|
+
"how",
|
|
619
|
+
"what",
|
|
620
|
+
"which",
|
|
621
|
+
"who",
|
|
622
|
+
"whom",
|
|
623
|
+
"so",
|
|
624
|
+
"than",
|
|
625
|
+
"then",
|
|
626
|
+
"just"
|
|
627
|
+
]);
|
|
628
|
+
function extractKeywords(prompt) {
|
|
629
|
+
if (!prompt || typeof prompt !== "string") return [];
|
|
630
|
+
const tokens = prompt.toLowerCase().split(/[^a-z0-9_]+/).filter((t) => t.length >= 3).filter((t) => !STOPWORDS.has(t));
|
|
631
|
+
const seen = /* @__PURE__ */ new Set();
|
|
632
|
+
const result = [];
|
|
633
|
+
for (const t of tokens) {
|
|
634
|
+
if (seen.has(t)) continue;
|
|
635
|
+
seen.add(t);
|
|
636
|
+
result.push(t);
|
|
637
|
+
}
|
|
638
|
+
return result;
|
|
639
|
+
}
|
|
640
|
+
async function handleUserPromptSubmit(payload) {
|
|
641
|
+
if (payload.hook_event_name !== "UserPromptSubmit") return PASSTHROUGH;
|
|
642
|
+
const prompt = payload.prompt;
|
|
643
|
+
if (!prompt || typeof prompt !== "string") return PASSTHROUGH;
|
|
644
|
+
if (prompt.length > 8e3) return PASSTHROUGH;
|
|
645
|
+
const keywords = extractKeywords(prompt);
|
|
646
|
+
if (keywords.length < MIN_SIGNIFICANT_TERMS) return PASSTHROUGH;
|
|
647
|
+
const cwd = payload.cwd;
|
|
648
|
+
if (!isValidCwd(cwd)) return PASSTHROUGH;
|
|
649
|
+
const projectRoot = findProjectRoot(cwd);
|
|
650
|
+
if (projectRoot === null) return PASSTHROUGH;
|
|
651
|
+
if (isHookDisabled(projectRoot)) return PASSTHROUGH;
|
|
652
|
+
let result;
|
|
653
|
+
try {
|
|
654
|
+
result = await query(projectRoot, keywords.join(" "), {
|
|
655
|
+
tokenBudget: PROMPT_INJECTION_TOKEN_BUDGET,
|
|
656
|
+
depth: 2
|
|
657
|
+
// Shallower than default (3) to keep injection focused.
|
|
658
|
+
});
|
|
659
|
+
} catch {
|
|
660
|
+
return PASSTHROUGH;
|
|
661
|
+
}
|
|
662
|
+
if (result.nodesFound < MIN_MATCHED_NODES) return PASSTHROUGH;
|
|
663
|
+
const header = `[engram] Pre-query context for this message (matched ${result.nodesFound} graph nodes):`;
|
|
664
|
+
const text = `${header}
|
|
665
|
+
|
|
666
|
+
${result.text}`;
|
|
667
|
+
return buildSessionContextResponse("UserPromptSubmit", text);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// src/intelligence/hook-log.ts
|
|
671
|
+
import {
|
|
672
|
+
appendFileSync,
|
|
673
|
+
existsSync as existsSync4,
|
|
674
|
+
renameSync,
|
|
675
|
+
statSync as statSync2,
|
|
676
|
+
readFileSync as readFileSync2
|
|
677
|
+
} from "fs";
|
|
678
|
+
import { join as join4 } from "path";
|
|
679
|
+
var HOOK_LOG_MAX_BYTES = 10 * 1024 * 1024;
|
|
680
|
+
var LOG_FILENAME = "hook-log.jsonl";
|
|
681
|
+
var LOG_ROTATED_FILENAME = "hook-log.jsonl.1";
|
|
682
|
+
function logHookEvent(projectRoot, entry) {
|
|
683
|
+
if (!projectRoot) return;
|
|
684
|
+
try {
|
|
685
|
+
const logPath = join4(projectRoot, ".engram", LOG_FILENAME);
|
|
686
|
+
rotateIfNeeded(projectRoot);
|
|
687
|
+
const line = JSON.stringify({
|
|
688
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
689
|
+
...entry
|
|
690
|
+
}) + "\n";
|
|
691
|
+
appendFileSync(logPath, line);
|
|
692
|
+
} catch {
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
function rotateIfNeeded(projectRoot) {
|
|
696
|
+
try {
|
|
697
|
+
const logPath = join4(projectRoot, ".engram", LOG_FILENAME);
|
|
698
|
+
if (!existsSync4(logPath)) return;
|
|
699
|
+
const size = statSync2(logPath).size;
|
|
700
|
+
if (size < HOOK_LOG_MAX_BYTES) return;
|
|
701
|
+
const rotatedPath = join4(projectRoot, ".engram", LOG_ROTATED_FILENAME);
|
|
702
|
+
renameSync(logPath, rotatedPath);
|
|
703
|
+
} catch {
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
function readHookLog(projectRoot) {
|
|
707
|
+
try {
|
|
708
|
+
const logPath = join4(projectRoot, ".engram", LOG_FILENAME);
|
|
709
|
+
if (!existsSync4(logPath)) return [];
|
|
710
|
+
const raw = readFileSync2(logPath, "utf-8");
|
|
711
|
+
const entries = [];
|
|
712
|
+
for (const line of raw.split("\n")) {
|
|
713
|
+
if (!line.trim()) continue;
|
|
714
|
+
try {
|
|
715
|
+
entries.push(JSON.parse(line));
|
|
716
|
+
} catch {
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return entries;
|
|
720
|
+
} catch {
|
|
721
|
+
return [];
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// src/intercept/handlers/post-tool.ts
|
|
726
|
+
function extractFilePath(toolName, toolInput) {
|
|
727
|
+
if (!toolInput) return void 0;
|
|
728
|
+
if (toolName === "Read" || toolName === "Edit" || toolName === "Write") {
|
|
729
|
+
const fp = toolInput.file_path;
|
|
730
|
+
return typeof fp === "string" ? fp : void 0;
|
|
731
|
+
}
|
|
732
|
+
return void 0;
|
|
733
|
+
}
|
|
734
|
+
function estimateOutputSize(toolResponse) {
|
|
735
|
+
if (toolResponse === null || toolResponse === void 0) return 0;
|
|
736
|
+
if (typeof toolResponse === "string") return toolResponse.length;
|
|
737
|
+
if (typeof toolResponse === "object") {
|
|
738
|
+
const resp = toolResponse;
|
|
739
|
+
if (typeof resp.output === "string") return resp.output.length;
|
|
740
|
+
try {
|
|
741
|
+
return JSON.stringify(toolResponse).length;
|
|
742
|
+
} catch {
|
|
743
|
+
return 0;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return 0;
|
|
747
|
+
}
|
|
748
|
+
function detectError(toolResponse) {
|
|
749
|
+
if (toolResponse === null || toolResponse === void 0) return false;
|
|
750
|
+
if (typeof toolResponse === "object") {
|
|
751
|
+
const resp = toolResponse;
|
|
752
|
+
if (resp.error !== void 0 && resp.error !== null) return true;
|
|
753
|
+
}
|
|
754
|
+
return false;
|
|
755
|
+
}
|
|
756
|
+
async function handlePostTool(payload) {
|
|
757
|
+
if (payload.hook_event_name !== "PostToolUse") return PASSTHROUGH;
|
|
758
|
+
try {
|
|
759
|
+
const cwd = payload.cwd;
|
|
760
|
+
if (!isValidCwd(cwd)) return PASSTHROUGH;
|
|
761
|
+
const projectRoot = findProjectRoot(cwd);
|
|
762
|
+
if (projectRoot === null) return PASSTHROUGH;
|
|
763
|
+
if (isHookDisabled(projectRoot)) return PASSTHROUGH;
|
|
764
|
+
const toolName = payload.tool_name;
|
|
765
|
+
const filePath = extractFilePath(toolName, payload.tool_input);
|
|
766
|
+
const outputSize = estimateOutputSize(payload.tool_response);
|
|
767
|
+
const hasError = detectError(payload.tool_response);
|
|
768
|
+
logHookEvent(projectRoot, {
|
|
769
|
+
event: "PostToolUse",
|
|
770
|
+
tool: typeof toolName === "string" ? toolName : "unknown",
|
|
771
|
+
path: filePath,
|
|
772
|
+
outputSize,
|
|
773
|
+
success: !hasError
|
|
774
|
+
});
|
|
775
|
+
} catch {
|
|
776
|
+
}
|
|
777
|
+
return PASSTHROUGH;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// src/intercept/dispatch.ts
|
|
781
|
+
function validatePayload(raw) {
|
|
782
|
+
if (raw === null || typeof raw !== "object") return null;
|
|
783
|
+
const p = raw;
|
|
784
|
+
if (typeof p.hook_event_name !== "string") return null;
|
|
785
|
+
if (typeof p.cwd !== "string") return null;
|
|
786
|
+
if (p.tool_input !== void 0 && (p.tool_input === null || typeof p.tool_input !== "object")) {
|
|
787
|
+
return null;
|
|
788
|
+
}
|
|
789
|
+
return p;
|
|
790
|
+
}
|
|
791
|
+
async function dispatchHook(rawPayload) {
|
|
792
|
+
const payload = validatePayload(rawPayload);
|
|
793
|
+
if (payload === null) return PASSTHROUGH;
|
|
794
|
+
const event = payload.hook_event_name;
|
|
795
|
+
switch (event) {
|
|
796
|
+
case "PreToolUse":
|
|
797
|
+
return dispatchPreToolUse(payload);
|
|
798
|
+
case "SessionStart":
|
|
799
|
+
return runHandler(
|
|
800
|
+
() => handleSessionStart(payload)
|
|
801
|
+
);
|
|
802
|
+
case "UserPromptSubmit":
|
|
803
|
+
return runHandler(
|
|
804
|
+
() => handleUserPromptSubmit(payload)
|
|
805
|
+
);
|
|
806
|
+
case "PostToolUse":
|
|
807
|
+
return runHandler(
|
|
808
|
+
() => handlePostTool(payload)
|
|
809
|
+
);
|
|
810
|
+
default:
|
|
811
|
+
return PASSTHROUGH;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
async function dispatchPreToolUse(payload) {
|
|
815
|
+
const tool = typeof payload.tool_name === "string" ? payload.tool_name : "";
|
|
816
|
+
const handlerPayload = payload;
|
|
817
|
+
let result;
|
|
818
|
+
switch (tool) {
|
|
819
|
+
case "Read":
|
|
820
|
+
result = await runHandler(
|
|
821
|
+
() => handleRead(handlerPayload)
|
|
822
|
+
);
|
|
823
|
+
break;
|
|
824
|
+
case "Edit":
|
|
825
|
+
case "Write":
|
|
826
|
+
result = await runHandler(
|
|
827
|
+
() => handleEditOrWrite(handlerPayload)
|
|
828
|
+
);
|
|
829
|
+
break;
|
|
830
|
+
case "Bash":
|
|
831
|
+
result = await runHandler(
|
|
832
|
+
() => handleBash(handlerPayload)
|
|
833
|
+
);
|
|
834
|
+
break;
|
|
835
|
+
default:
|
|
836
|
+
return PASSTHROUGH;
|
|
837
|
+
}
|
|
838
|
+
try {
|
|
839
|
+
const cwd = handlerPayload.cwd;
|
|
840
|
+
if (isValidCwd(cwd)) {
|
|
841
|
+
const projectRoot = findProjectRoot(cwd);
|
|
842
|
+
if (projectRoot) {
|
|
843
|
+
const decision = extractPreToolDecision(result);
|
|
844
|
+
const filePath = typeof handlerPayload.tool_input?.file_path === "string" ? handlerPayload.tool_input.file_path : void 0;
|
|
845
|
+
logHookEvent(projectRoot, {
|
|
846
|
+
event: "PreToolUse",
|
|
847
|
+
tool,
|
|
848
|
+
path: filePath,
|
|
849
|
+
decision
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
} catch {
|
|
854
|
+
}
|
|
855
|
+
return result;
|
|
856
|
+
}
|
|
857
|
+
function extractPreToolDecision(result) {
|
|
858
|
+
if (result === null || result === void 0) return "passthrough";
|
|
859
|
+
try {
|
|
860
|
+
const r = result;
|
|
861
|
+
const d = r.hookSpecificOutput?.permissionDecision;
|
|
862
|
+
if (d === "deny") return "deny";
|
|
863
|
+
if (d === "allow") return "allow";
|
|
864
|
+
} catch {
|
|
865
|
+
}
|
|
866
|
+
return "passthrough";
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
// src/intercept/installer.ts
|
|
870
|
+
var ENGRAM_HOOK_EVENTS = [
|
|
871
|
+
"PreToolUse",
|
|
872
|
+
"PostToolUse",
|
|
873
|
+
"SessionStart",
|
|
874
|
+
"UserPromptSubmit"
|
|
875
|
+
];
|
|
876
|
+
var ENGRAM_PRETOOL_MATCHER = "Read|Edit|Write|Bash";
|
|
877
|
+
var DEFAULT_ENGRAM_COMMAND = "engram intercept";
|
|
878
|
+
var DEFAULT_HOOK_TIMEOUT_SEC = 5;
|
|
879
|
+
function buildEngramHookEntries(command = DEFAULT_ENGRAM_COMMAND, timeout = DEFAULT_HOOK_TIMEOUT_SEC) {
|
|
880
|
+
const baseCmd = {
|
|
881
|
+
type: "command",
|
|
882
|
+
command,
|
|
883
|
+
timeout
|
|
884
|
+
};
|
|
885
|
+
return {
|
|
886
|
+
PreToolUse: {
|
|
887
|
+
matcher: ENGRAM_PRETOOL_MATCHER,
|
|
888
|
+
hooks: [baseCmd]
|
|
889
|
+
},
|
|
890
|
+
PostToolUse: {
|
|
891
|
+
// Match all tools — PostToolUse is an observer for any completion.
|
|
892
|
+
matcher: ".*",
|
|
893
|
+
hooks: [baseCmd]
|
|
894
|
+
},
|
|
895
|
+
SessionStart: {
|
|
896
|
+
// No matcher — SessionStart has no tool name.
|
|
897
|
+
hooks: [baseCmd]
|
|
898
|
+
},
|
|
899
|
+
UserPromptSubmit: {
|
|
900
|
+
// No matcher — UserPromptSubmit has no tool name.
|
|
901
|
+
hooks: [baseCmd]
|
|
902
|
+
}
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
function isEngramHookEntry(entry) {
|
|
906
|
+
if (entry === null || typeof entry !== "object") return false;
|
|
907
|
+
const e = entry;
|
|
908
|
+
if (!Array.isArray(e.hooks)) return false;
|
|
909
|
+
for (const h of e.hooks) {
|
|
910
|
+
if (h === null || typeof h !== "object") continue;
|
|
911
|
+
const cmd = h.command;
|
|
912
|
+
if (typeof cmd === "string" && cmd.includes("engram intercept")) {
|
|
913
|
+
return true;
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
return false;
|
|
917
|
+
}
|
|
918
|
+
function installEngramHooks(settings, command = DEFAULT_ENGRAM_COMMAND) {
|
|
919
|
+
const entries = buildEngramHookEntries(command);
|
|
920
|
+
const added = [];
|
|
921
|
+
const alreadyPresent = [];
|
|
922
|
+
const hooksClone = {};
|
|
923
|
+
const existingHooks = settings.hooks ?? {};
|
|
924
|
+
for (const [key, value] of Object.entries(existingHooks)) {
|
|
925
|
+
if (Array.isArray(value)) {
|
|
926
|
+
hooksClone[key] = value.map((entry) => ({ ...entry }));
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
for (const event of ENGRAM_HOOK_EVENTS) {
|
|
930
|
+
const eventArr = hooksClone[event] ?? [];
|
|
931
|
+
const hasEngram = eventArr.some((e) => isEngramHookEntry(e));
|
|
932
|
+
if (hasEngram) {
|
|
933
|
+
alreadyPresent.push(event);
|
|
934
|
+
hooksClone[event] = eventArr;
|
|
935
|
+
continue;
|
|
936
|
+
}
|
|
937
|
+
hooksClone[event] = [...eventArr, entries[event]];
|
|
938
|
+
added.push(event);
|
|
939
|
+
}
|
|
940
|
+
return {
|
|
941
|
+
updated: { ...settings, hooks: hooksClone },
|
|
942
|
+
added,
|
|
943
|
+
alreadyPresent
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
function uninstallEngramHooks(settings) {
|
|
947
|
+
const removed = [];
|
|
948
|
+
const existingHooks = settings.hooks ?? {};
|
|
949
|
+
const hooksClone = {};
|
|
950
|
+
for (const [event, arr] of Object.entries(existingHooks)) {
|
|
951
|
+
if (!Array.isArray(arr)) continue;
|
|
952
|
+
const filtered = arr.filter((entry) => !isEngramHookEntry(entry));
|
|
953
|
+
if (filtered.length !== arr.length && isKnownEngramEvent(event)) {
|
|
954
|
+
removed.push(event);
|
|
955
|
+
}
|
|
956
|
+
if (filtered.length > 0) {
|
|
957
|
+
hooksClone[event] = filtered;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
const updatedSettings = { ...settings };
|
|
961
|
+
if (Object.keys(hooksClone).length === 0) {
|
|
962
|
+
delete updatedSettings.hooks;
|
|
963
|
+
} else {
|
|
964
|
+
updatedSettings.hooks = hooksClone;
|
|
965
|
+
}
|
|
966
|
+
return { updated: updatedSettings, removed };
|
|
967
|
+
}
|
|
968
|
+
function isKnownEngramEvent(event) {
|
|
969
|
+
return ENGRAM_HOOK_EVENTS.includes(event);
|
|
970
|
+
}
|
|
971
|
+
function formatInstallDiff(before, after) {
|
|
972
|
+
const lines = [];
|
|
973
|
+
const beforeHooks = before.hooks ?? {};
|
|
974
|
+
const afterHooks = after.hooks ?? {};
|
|
975
|
+
for (const event of ENGRAM_HOOK_EVENTS) {
|
|
976
|
+
const beforeArr = beforeHooks[event] ?? [];
|
|
977
|
+
const afterArr = afterHooks[event] ?? [];
|
|
978
|
+
if (beforeArr.length === afterArr.length) continue;
|
|
979
|
+
lines.push(`+ ${event}: ${beforeArr.length} \u2192 ${afterArr.length} entries`);
|
|
980
|
+
const added = afterArr.filter((entry) => isEngramHookEntry(entry));
|
|
981
|
+
const beforeHasEngram = beforeArr.some((entry) => isEngramHookEntry(entry));
|
|
982
|
+
if (!beforeHasEngram && added.length > 0) {
|
|
983
|
+
for (const entry of added) {
|
|
984
|
+
const matcher = entry.matcher ? ` matcher=${JSON.stringify(entry.matcher)}` : "";
|
|
985
|
+
const cmds = entry.hooks.map((h) => h.command).join(", ");
|
|
986
|
+
lines.push(` + {${matcher} command="${cmds}"}`);
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
return lines.length > 0 ? lines.join("\n") : "(no changes)";
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// src/intercept/stats.ts
|
|
994
|
+
var ESTIMATED_TOKENS_PER_READ_DENY = 1200;
|
|
995
|
+
function summarizeHookLog(entries) {
|
|
996
|
+
const byEvent = {};
|
|
997
|
+
const byTool = {};
|
|
998
|
+
const byDecision = {};
|
|
999
|
+
let readDenyCount = 0;
|
|
1000
|
+
let firstEntryTs = null;
|
|
1001
|
+
let lastEntryTs = null;
|
|
1002
|
+
for (const entry of entries) {
|
|
1003
|
+
const event = entry.event ?? "unknown";
|
|
1004
|
+
byEvent[event] = (byEvent[event] ?? 0) + 1;
|
|
1005
|
+
const tool = entry.tool ?? "unknown";
|
|
1006
|
+
byTool[tool] = (byTool[tool] ?? 0) + 1;
|
|
1007
|
+
if (entry.decision) {
|
|
1008
|
+
byDecision[entry.decision] = (byDecision[entry.decision] ?? 0) + 1;
|
|
1009
|
+
}
|
|
1010
|
+
if (event === "PreToolUse" && tool === "Read" && entry.decision === "deny") {
|
|
1011
|
+
readDenyCount += 1;
|
|
1012
|
+
}
|
|
1013
|
+
const ts = entry.ts;
|
|
1014
|
+
if (typeof ts === "string") {
|
|
1015
|
+
if (firstEntryTs === null || ts < firstEntryTs) firstEntryTs = ts;
|
|
1016
|
+
if (lastEntryTs === null || ts > lastEntryTs) lastEntryTs = ts;
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
return {
|
|
1020
|
+
totalInvocations: entries.length,
|
|
1021
|
+
byEvent: Object.freeze(byEvent),
|
|
1022
|
+
byTool: Object.freeze(byTool),
|
|
1023
|
+
byDecision: Object.freeze(byDecision),
|
|
1024
|
+
readDenyCount,
|
|
1025
|
+
estimatedTokensSaved: readDenyCount * ESTIMATED_TOKENS_PER_READ_DENY,
|
|
1026
|
+
firstEntry: firstEntryTs,
|
|
1027
|
+
lastEntry: lastEntryTs
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
function formatStatsSummary(summary) {
|
|
1031
|
+
if (summary.totalInvocations === 0) {
|
|
1032
|
+
return "engram hook stats: no log entries yet.\n\nRun engram install-hook in a project, then use Claude Code to see interceptions.";
|
|
1033
|
+
}
|
|
1034
|
+
const lines = [];
|
|
1035
|
+
lines.push(`engram hook stats (${summary.totalInvocations} invocations)`);
|
|
1036
|
+
lines.push("\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
|
|
1037
|
+
if (summary.firstEntry && summary.lastEntry) {
|
|
1038
|
+
lines.push(`Time range: ${summary.firstEntry} \u2192 ${summary.lastEntry}`);
|
|
1039
|
+
lines.push("");
|
|
1040
|
+
}
|
|
1041
|
+
lines.push("By event:");
|
|
1042
|
+
const eventEntries = Object.entries(summary.byEvent).sort(
|
|
1043
|
+
(a, b) => b[1] - a[1]
|
|
1044
|
+
);
|
|
1045
|
+
for (const [event, count] of eventEntries) {
|
|
1046
|
+
const pct = (count / summary.totalInvocations * 100).toFixed(1);
|
|
1047
|
+
lines.push(` ${event.padEnd(18)} ${String(count).padStart(5)} (${pct}%)`);
|
|
1048
|
+
}
|
|
1049
|
+
lines.push("");
|
|
1050
|
+
lines.push("By tool:");
|
|
1051
|
+
const toolEntries = Object.entries(summary.byTool).filter(([k]) => k !== "unknown").sort((a, b) => b[1] - a[1]);
|
|
1052
|
+
for (const [tool, count] of toolEntries) {
|
|
1053
|
+
lines.push(` ${tool.padEnd(18)} ${String(count).padStart(5)}`);
|
|
1054
|
+
}
|
|
1055
|
+
if (toolEntries.length === 0) {
|
|
1056
|
+
lines.push(" (no tool-tagged entries)");
|
|
1057
|
+
}
|
|
1058
|
+
lines.push("");
|
|
1059
|
+
const decisionEntries = Object.entries(summary.byDecision);
|
|
1060
|
+
if (decisionEntries.length > 0) {
|
|
1061
|
+
lines.push("PreToolUse decisions:");
|
|
1062
|
+
for (const [decision, count] of decisionEntries.sort(
|
|
1063
|
+
(a, b) => b[1] - a[1]
|
|
1064
|
+
)) {
|
|
1065
|
+
lines.push(` ${decision.padEnd(18)} ${String(count).padStart(5)}`);
|
|
1066
|
+
}
|
|
1067
|
+
lines.push("");
|
|
1068
|
+
}
|
|
1069
|
+
if (summary.readDenyCount > 0) {
|
|
1070
|
+
lines.push(
|
|
1071
|
+
`Estimated tokens saved: ~${summary.estimatedTokensSaved.toLocaleString()}`
|
|
1072
|
+
);
|
|
1073
|
+
lines.push(
|
|
1074
|
+
` (${summary.readDenyCount} Read denies \xD7 ${ESTIMATED_TOKENS_PER_READ_DENY} tok/deny avg)`
|
|
1075
|
+
);
|
|
1076
|
+
} else {
|
|
1077
|
+
lines.push("Estimated tokens saved: 0");
|
|
1078
|
+
lines.push(" (no PreToolUse:Read denies recorded yet)");
|
|
1079
|
+
}
|
|
1080
|
+
return lines.join("\n");
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// src/cli.ts
|
|
22
1084
|
var program = new Command();
|
|
23
|
-
program.name("engram").description(
|
|
1085
|
+
program.name("engram").description(
|
|
1086
|
+
"Context as infra for AI coding tools \u2014 hook-based Read/Edit interception + structural graph summaries"
|
|
1087
|
+
).version("0.3.0");
|
|
24
1088
|
program.command("init").description("Scan codebase and build knowledge graph (zero LLM cost)").argument("[path]", "Project directory", ".").option(
|
|
25
1089
|
"--with-skills [dir]",
|
|
26
1090
|
"Also index Claude Code skills from ~/.claude/skills/ or a given path"
|
|
@@ -174,4 +1238,313 @@ program.command("gen").description("Generate CLAUDE.md / .cursorrules section fr
|
|
|
174
1238
|
);
|
|
175
1239
|
}
|
|
176
1240
|
);
|
|
1241
|
+
function resolveSettingsPath(scope, projectPath) {
|
|
1242
|
+
const absProject = pathResolve(projectPath);
|
|
1243
|
+
switch (scope) {
|
|
1244
|
+
case "local":
|
|
1245
|
+
return join5(absProject, ".claude", "settings.local.json");
|
|
1246
|
+
case "project":
|
|
1247
|
+
return join5(absProject, ".claude", "settings.json");
|
|
1248
|
+
case "user":
|
|
1249
|
+
return join5(homedir(), ".claude", "settings.json");
|
|
1250
|
+
default:
|
|
1251
|
+
return null;
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
program.command("intercept").description(
|
|
1255
|
+
"Hook entry point. Reads JSON from stdin, writes response JSON to stdout. Called by Claude Code."
|
|
1256
|
+
).action(async () => {
|
|
1257
|
+
const stdinTimeout = setTimeout(() => {
|
|
1258
|
+
process.exit(0);
|
|
1259
|
+
}, 3e3);
|
|
1260
|
+
let input = "";
|
|
1261
|
+
try {
|
|
1262
|
+
for await (const chunk of process.stdin) {
|
|
1263
|
+
input += chunk;
|
|
1264
|
+
if (input.length > 1e6) break;
|
|
1265
|
+
}
|
|
1266
|
+
} catch {
|
|
1267
|
+
clearTimeout(stdinTimeout);
|
|
1268
|
+
process.exit(0);
|
|
1269
|
+
}
|
|
1270
|
+
clearTimeout(stdinTimeout);
|
|
1271
|
+
if (!input.trim()) process.exit(0);
|
|
1272
|
+
let payload;
|
|
1273
|
+
try {
|
|
1274
|
+
payload = JSON.parse(input);
|
|
1275
|
+
} catch {
|
|
1276
|
+
process.exit(0);
|
|
1277
|
+
}
|
|
1278
|
+
try {
|
|
1279
|
+
const result = await dispatchHook(payload);
|
|
1280
|
+
if (result && typeof result === "object") {
|
|
1281
|
+
process.stdout.write(JSON.stringify(result));
|
|
1282
|
+
}
|
|
1283
|
+
} catch {
|
|
1284
|
+
}
|
|
1285
|
+
process.exit(0);
|
|
1286
|
+
});
|
|
1287
|
+
program.command("install-hook").description("Install engram hook entries into Claude Code settings").option("--scope <scope>", "local | project | user", "local").option("--dry-run", "Show diff without writing", false).option("-p, --project <path>", "Project directory", ".").action(
|
|
1288
|
+
async (opts) => {
|
|
1289
|
+
const settingsPath = resolveSettingsPath(opts.scope, opts.project);
|
|
1290
|
+
if (!settingsPath) {
|
|
1291
|
+
console.error(
|
|
1292
|
+
chalk.red(
|
|
1293
|
+
`Unknown scope: ${opts.scope} (expected: local | project | user)`
|
|
1294
|
+
)
|
|
1295
|
+
);
|
|
1296
|
+
process.exit(1);
|
|
1297
|
+
}
|
|
1298
|
+
let existing = {};
|
|
1299
|
+
if (existsSync5(settingsPath)) {
|
|
1300
|
+
try {
|
|
1301
|
+
const raw = readFileSync3(settingsPath, "utf-8");
|
|
1302
|
+
existing = raw.trim() ? JSON.parse(raw) : {};
|
|
1303
|
+
} catch (err) {
|
|
1304
|
+
console.error(
|
|
1305
|
+
chalk.red(
|
|
1306
|
+
`Failed to parse ${settingsPath}: ${err.message}`
|
|
1307
|
+
)
|
|
1308
|
+
);
|
|
1309
|
+
console.error(
|
|
1310
|
+
chalk.dim(
|
|
1311
|
+
"Fix the JSON syntax and re-run install-hook, or remove the file and start fresh."
|
|
1312
|
+
)
|
|
1313
|
+
);
|
|
1314
|
+
process.exit(1);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
const result = installEngramHooks(existing);
|
|
1318
|
+
console.log(
|
|
1319
|
+
chalk.bold(`
|
|
1320
|
+
\u{1F4CC} engram install-hook (scope: ${opts.scope})`)
|
|
1321
|
+
);
|
|
1322
|
+
console.log(chalk.dim(` Target: ${settingsPath}`));
|
|
1323
|
+
if (result.added.length === 0) {
|
|
1324
|
+
console.log(
|
|
1325
|
+
chalk.yellow(
|
|
1326
|
+
`
|
|
1327
|
+
All engram hooks already installed (${result.alreadyPresent.join(", ")}).`
|
|
1328
|
+
)
|
|
1329
|
+
);
|
|
1330
|
+
console.log(
|
|
1331
|
+
chalk.dim(
|
|
1332
|
+
" Run 'engram uninstall-hook' first if you want to reinstall."
|
|
1333
|
+
)
|
|
1334
|
+
);
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
console.log(chalk.cyan("\n Changes:"));
|
|
1338
|
+
console.log(
|
|
1339
|
+
formatInstallDiff(existing, result.updated).split("\n").map((l) => " " + l).join("\n")
|
|
1340
|
+
);
|
|
1341
|
+
if (opts.dryRun) {
|
|
1342
|
+
console.log(chalk.dim("\n (dry-run \u2014 no changes written)"));
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
try {
|
|
1346
|
+
mkdirSync(dirname3(settingsPath), { recursive: true });
|
|
1347
|
+
if (existsSync5(settingsPath)) {
|
|
1348
|
+
const backupPath = `${settingsPath}.engram-backup-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.bak`;
|
|
1349
|
+
copyFileSync(settingsPath, backupPath);
|
|
1350
|
+
console.log(chalk.dim(` Backup: ${backupPath}`));
|
|
1351
|
+
}
|
|
1352
|
+
const tmpPath = settingsPath + ".engram-tmp";
|
|
1353
|
+
writeFileSync(
|
|
1354
|
+
tmpPath,
|
|
1355
|
+
JSON.stringify(result.updated, null, 2) + "\n"
|
|
1356
|
+
);
|
|
1357
|
+
renameSync2(tmpPath, settingsPath);
|
|
1358
|
+
} catch (err) {
|
|
1359
|
+
console.error(
|
|
1360
|
+
chalk.red(`
|
|
1361
|
+
\u274C Write failed: ${err.message}`)
|
|
1362
|
+
);
|
|
1363
|
+
process.exit(1);
|
|
1364
|
+
}
|
|
1365
|
+
console.log(
|
|
1366
|
+
chalk.green(
|
|
1367
|
+
`
|
|
1368
|
+
\u2705 Installed ${result.added.length} hook event${result.added.length === 1 ? "" : "s"}: ${result.added.join(", ")}`
|
|
1369
|
+
)
|
|
1370
|
+
);
|
|
1371
|
+
if (result.alreadyPresent.length > 0) {
|
|
1372
|
+
console.log(
|
|
1373
|
+
chalk.dim(
|
|
1374
|
+
` Already present: ${result.alreadyPresent.join(", ")}`
|
|
1375
|
+
)
|
|
1376
|
+
);
|
|
1377
|
+
}
|
|
1378
|
+
console.log(
|
|
1379
|
+
chalk.dim(
|
|
1380
|
+
"\n Next: open a Claude Code session and engram will start intercepting tool calls."
|
|
1381
|
+
)
|
|
1382
|
+
);
|
|
1383
|
+
}
|
|
1384
|
+
);
|
|
1385
|
+
program.command("uninstall-hook").description("Remove engram hook entries from Claude Code settings").option("--scope <scope>", "local | project | user", "local").option("-p, --project <path>", "Project directory", ".").action(async (opts) => {
|
|
1386
|
+
const settingsPath = resolveSettingsPath(opts.scope, opts.project);
|
|
1387
|
+
if (!settingsPath) {
|
|
1388
|
+
console.error(chalk.red(`Unknown scope: ${opts.scope}`));
|
|
1389
|
+
process.exit(1);
|
|
1390
|
+
}
|
|
1391
|
+
if (!existsSync5(settingsPath)) {
|
|
1392
|
+
console.log(
|
|
1393
|
+
chalk.yellow(`No settings file at ${settingsPath} \u2014 nothing to remove.`)
|
|
1394
|
+
);
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
let existing;
|
|
1398
|
+
try {
|
|
1399
|
+
const raw = readFileSync3(settingsPath, "utf-8");
|
|
1400
|
+
existing = raw.trim() ? JSON.parse(raw) : {};
|
|
1401
|
+
} catch (err) {
|
|
1402
|
+
console.error(
|
|
1403
|
+
chalk.red(`Failed to parse ${settingsPath}: ${err.message}`)
|
|
1404
|
+
);
|
|
1405
|
+
process.exit(1);
|
|
1406
|
+
}
|
|
1407
|
+
const result = uninstallEngramHooks(existing);
|
|
1408
|
+
if (result.removed.length === 0) {
|
|
1409
|
+
console.log(
|
|
1410
|
+
chalk.yellow(`
|
|
1411
|
+
No engram hooks found in ${settingsPath}.`)
|
|
1412
|
+
);
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
try {
|
|
1416
|
+
const backupPath = `${settingsPath}.engram-backup-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.bak`;
|
|
1417
|
+
copyFileSync(settingsPath, backupPath);
|
|
1418
|
+
const tmpPath = settingsPath + ".engram-tmp";
|
|
1419
|
+
writeFileSync(tmpPath, JSON.stringify(result.updated, null, 2) + "\n");
|
|
1420
|
+
renameSync2(tmpPath, settingsPath);
|
|
1421
|
+
console.log(
|
|
1422
|
+
chalk.green(
|
|
1423
|
+
`
|
|
1424
|
+
\u2705 Removed engram hooks from ${result.removed.length} event${result.removed.length === 1 ? "" : "s"}: ${result.removed.join(", ")}`
|
|
1425
|
+
)
|
|
1426
|
+
);
|
|
1427
|
+
console.log(chalk.dim(` Backup: ${backupPath}`));
|
|
1428
|
+
} catch (err) {
|
|
1429
|
+
console.error(
|
|
1430
|
+
chalk.red(`
|
|
1431
|
+
\u274C Write failed: ${err.message}`)
|
|
1432
|
+
);
|
|
1433
|
+
process.exit(1);
|
|
1434
|
+
}
|
|
1435
|
+
});
|
|
1436
|
+
program.command("hook-stats").description("Summarize hook-log.jsonl for a project").option("-p, --project <path>", "Project directory", ".").option("--json", "Output as JSON", false).action(async (opts) => {
|
|
1437
|
+
const absProject = pathResolve(opts.project);
|
|
1438
|
+
const projectRoot = findProjectRoot(absProject) ?? absProject;
|
|
1439
|
+
const entries = readHookLog(projectRoot);
|
|
1440
|
+
const summary = summarizeHookLog(entries);
|
|
1441
|
+
if (opts.json) {
|
|
1442
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
1443
|
+
return;
|
|
1444
|
+
}
|
|
1445
|
+
console.log(formatStatsSummary(summary));
|
|
1446
|
+
});
|
|
1447
|
+
program.command("hook-preview").description("Show what the Read handler would do for a file (dry-run)").argument("<file>", "Target file path").option("-p, --project <path>", "Project directory", ".").action(async (file, opts) => {
|
|
1448
|
+
const absProject = pathResolve(opts.project);
|
|
1449
|
+
const absFile = pathResolve(absProject, file);
|
|
1450
|
+
const payload = {
|
|
1451
|
+
hook_event_name: "PreToolUse",
|
|
1452
|
+
tool_name: "Read",
|
|
1453
|
+
cwd: absProject,
|
|
1454
|
+
tool_input: { file_path: absFile }
|
|
1455
|
+
};
|
|
1456
|
+
const result = await dispatchHook(payload);
|
|
1457
|
+
console.log(chalk.bold(`
|
|
1458
|
+
\u{1F4CB} Hook preview: ${absFile}`));
|
|
1459
|
+
console.log(chalk.dim(` Project: ${absProject}`));
|
|
1460
|
+
console.log();
|
|
1461
|
+
if (result === null || result === void 0) {
|
|
1462
|
+
console.log(
|
|
1463
|
+
chalk.yellow(" Decision: PASSTHROUGH (Read would execute normally)")
|
|
1464
|
+
);
|
|
1465
|
+
console.log(
|
|
1466
|
+
chalk.dim(
|
|
1467
|
+
" Possible reasons: file not in graph, confidence below threshold, content unsafe, outside project, stale graph."
|
|
1468
|
+
)
|
|
1469
|
+
);
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
const wrapped = result;
|
|
1473
|
+
const decision = wrapped.hookSpecificOutput?.permissionDecision;
|
|
1474
|
+
if (decision === "deny") {
|
|
1475
|
+
console.log(chalk.green(" Decision: DENY (Read would be replaced)"));
|
|
1476
|
+
console.log(chalk.dim(" Summary (would be delivered to Claude):"));
|
|
1477
|
+
console.log();
|
|
1478
|
+
const reason = wrapped.hookSpecificOutput?.permissionDecisionReason ?? "";
|
|
1479
|
+
console.log(
|
|
1480
|
+
reason.split("\n").map((l) => " " + l).join("\n")
|
|
1481
|
+
);
|
|
1482
|
+
return;
|
|
1483
|
+
}
|
|
1484
|
+
if (decision === "allow") {
|
|
1485
|
+
console.log(chalk.cyan(" Decision: ALLOW (with additionalContext)"));
|
|
1486
|
+
const ctx = wrapped.hookSpecificOutput?.additionalContext ?? "";
|
|
1487
|
+
if (ctx) {
|
|
1488
|
+
console.log(chalk.dim(" Additional context that would be injected:"));
|
|
1489
|
+
console.log(
|
|
1490
|
+
ctx.split("\n").map((l) => " " + l).join("\n")
|
|
1491
|
+
);
|
|
1492
|
+
}
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
console.log(chalk.yellow(` Decision: ${decision ?? "unknown"}`));
|
|
1496
|
+
});
|
|
1497
|
+
program.command("hook-disable").description("Disable engram hooks via kill switch (does not uninstall)").option("-p, --project <path>", "Project directory", ".").action(async (opts) => {
|
|
1498
|
+
const absProject = pathResolve(opts.project);
|
|
1499
|
+
const projectRoot = findProjectRoot(absProject);
|
|
1500
|
+
if (!projectRoot) {
|
|
1501
|
+
console.error(
|
|
1502
|
+
chalk.red(`Not an engram project: ${absProject}`)
|
|
1503
|
+
);
|
|
1504
|
+
console.error(chalk.dim("Run 'engram init' first."));
|
|
1505
|
+
process.exit(1);
|
|
1506
|
+
}
|
|
1507
|
+
const flagPath = join5(projectRoot, ".engram", "hook-disabled");
|
|
1508
|
+
try {
|
|
1509
|
+
writeFileSync(flagPath, (/* @__PURE__ */ new Date()).toISOString());
|
|
1510
|
+
console.log(
|
|
1511
|
+
chalk.green(`\u2705 engram hooks disabled for ${projectRoot}`)
|
|
1512
|
+
);
|
|
1513
|
+
console.log(chalk.dim(` Flag: ${flagPath}`));
|
|
1514
|
+
console.log(
|
|
1515
|
+
chalk.dim(" Run 'engram hook-enable' to re-enable.")
|
|
1516
|
+
);
|
|
1517
|
+
} catch (err) {
|
|
1518
|
+
console.error(
|
|
1519
|
+
chalk.red(`Failed to create flag: ${err.message}`)
|
|
1520
|
+
);
|
|
1521
|
+
process.exit(1);
|
|
1522
|
+
}
|
|
1523
|
+
});
|
|
1524
|
+
program.command("hook-enable").description("Re-enable engram hooks (remove kill switch flag)").option("-p, --project <path>", "Project directory", ".").action(async (opts) => {
|
|
1525
|
+
const absProject = pathResolve(opts.project);
|
|
1526
|
+
const projectRoot = findProjectRoot(absProject);
|
|
1527
|
+
if (!projectRoot) {
|
|
1528
|
+
console.error(chalk.red(`Not an engram project: ${absProject}`));
|
|
1529
|
+
process.exit(1);
|
|
1530
|
+
}
|
|
1531
|
+
const flagPath = join5(projectRoot, ".engram", "hook-disabled");
|
|
1532
|
+
if (!existsSync5(flagPath)) {
|
|
1533
|
+
console.log(
|
|
1534
|
+
chalk.yellow(`engram hooks already enabled for ${projectRoot}`)
|
|
1535
|
+
);
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
try {
|
|
1539
|
+
unlinkSync(flagPath);
|
|
1540
|
+
console.log(
|
|
1541
|
+
chalk.green(`\u2705 engram hooks re-enabled for ${projectRoot}`)
|
|
1542
|
+
);
|
|
1543
|
+
} catch (err) {
|
|
1544
|
+
console.error(
|
|
1545
|
+
chalk.red(`Failed to remove flag: ${err.message}`)
|
|
1546
|
+
);
|
|
1547
|
+
process.exit(1);
|
|
1548
|
+
}
|
|
1549
|
+
});
|
|
177
1550
|
program.parse();
|