engrm 0.4.0 → 0.4.3
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 +49 -5
- package/dist/cli.js +288 -28
- package/dist/hooks/codex-stop.js +62 -0
- package/dist/hooks/elicitation-result.js +1690 -1637
- package/dist/hooks/post-tool-use.js +326 -231
- package/dist/hooks/pre-compact.js +410 -78
- package/dist/hooks/sentinel.js +150 -103
- package/dist/hooks/session-start.js +2311 -1983
- package/dist/hooks/stop.js +302 -147
- package/dist/server.js +634 -118
- package/package.json +6 -5
- package/bin/build.mjs +0 -97
- package/bin/engrm.mjs +0 -13
package/dist/hooks/sentinel.js
CHANGED
|
@@ -2,6 +2,82 @@
|
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
4
4
|
|
|
5
|
+
// src/sentinel/audit.ts
|
|
6
|
+
async function auditCodeChange(config, _db, toolName, filePath, content) {
|
|
7
|
+
if (shouldSkip(filePath, config.sentinel.skip_patterns)) {
|
|
8
|
+
return { verdict: "PASS", reason: "File matches skip pattern" };
|
|
9
|
+
}
|
|
10
|
+
if (!config.candengo_url || !config.candengo_api_key) {
|
|
11
|
+
return { verdict: "PASS", reason: "Server not configured" };
|
|
12
|
+
}
|
|
13
|
+
const url = `${config.candengo_url.replace(/\/$/, "")}/v1/mem/check`;
|
|
14
|
+
try {
|
|
15
|
+
const response = await fetch(url, {
|
|
16
|
+
method: "POST",
|
|
17
|
+
headers: {
|
|
18
|
+
"Content-Type": "application/json",
|
|
19
|
+
Authorization: `Bearer ${config.candengo_api_key}`
|
|
20
|
+
},
|
|
21
|
+
body: JSON.stringify({
|
|
22
|
+
tool_name: toolName,
|
|
23
|
+
file_path: filePath,
|
|
24
|
+
content: content.slice(0, 8000)
|
|
25
|
+
}),
|
|
26
|
+
signal: AbortSignal.timeout(15000)
|
|
27
|
+
});
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
return { verdict: "PASS", reason: "Review service unavailable" };
|
|
30
|
+
}
|
|
31
|
+
const data = await response.json();
|
|
32
|
+
return parseServerResponse(data);
|
|
33
|
+
} catch {
|
|
34
|
+
return { verdict: "PASS", reason: "Review service unreachable" };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function parseServerResponse(data) {
|
|
38
|
+
const verdict = data.verdict;
|
|
39
|
+
if (verdict !== "PASS" && verdict !== "WARN" && verdict !== "BLOCK" && verdict !== "DRIFT") {
|
|
40
|
+
return { verdict: "PASS", reason: "Invalid verdict from server" };
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
verdict,
|
|
44
|
+
reason: data.reason ?? "No reason given",
|
|
45
|
+
rule: data.rule ?? undefined,
|
|
46
|
+
severity: parseSeverity(data.severity)
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function parseSeverity(s) {
|
|
50
|
+
if (s === "critical" || s === "high" || s === "medium" || s === "low")
|
|
51
|
+
return s;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
function shouldSkip(filePath, patterns) {
|
|
55
|
+
for (const pattern of patterns) {
|
|
56
|
+
if (filePath.includes(pattern))
|
|
57
|
+
return true;
|
|
58
|
+
try {
|
|
59
|
+
if (new RegExp(pattern).test(filePath))
|
|
60
|
+
return true;
|
|
61
|
+
} catch {}
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
function checkDailyLimit(db, limit) {
|
|
66
|
+
const today = new Date().toISOString().split("T")[0];
|
|
67
|
+
const key = `sentinel_audit_count_${today}`;
|
|
68
|
+
try {
|
|
69
|
+
const current = db.db.query("SELECT value FROM sync_state WHERE key = ?").get(key);
|
|
70
|
+
const count = current ? parseInt(current.value, 10) : 0;
|
|
71
|
+
if (count >= limit)
|
|
72
|
+
return false;
|
|
73
|
+
db.db.query(`INSERT INTO sync_state (key, value) VALUES (?, ?)
|
|
74
|
+
ON CONFLICT(key) DO UPDATE SET value = ?`).run(key, String(count + 1), String(count + 1));
|
|
75
|
+
return true;
|
|
76
|
+
} catch {
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
5
81
|
// src/config.ts
|
|
6
82
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
83
|
import { homedir, hostname, networkInterfaces } from "node:os";
|
|
@@ -699,8 +775,8 @@ class MemDatabase {
|
|
|
699
775
|
return this.db.query("SELECT * FROM projects WHERE id = ?").get(id) ?? null;
|
|
700
776
|
}
|
|
701
777
|
insertObservation(obs) {
|
|
702
|
-
const now = Math.floor(Date.now() / 1000);
|
|
703
|
-
const createdAt = new Date().toISOString();
|
|
778
|
+
const now = obs.created_at_epoch ?? Math.floor(Date.now() / 1000);
|
|
779
|
+
const createdAt = obs.created_at ?? new Date(now * 1000).toISOString();
|
|
704
780
|
const result = this.db.query(`INSERT INTO observations (
|
|
705
781
|
session_id, project_id, type, title, narrative, facts, concepts,
|
|
706
782
|
files_read, files_modified, quality, lifecycle, sensitivity,
|
|
@@ -717,11 +793,14 @@ class MemDatabase {
|
|
|
717
793
|
getObservationById(id) {
|
|
718
794
|
return this.db.query("SELECT * FROM observations WHERE id = ?").get(id) ?? null;
|
|
719
795
|
}
|
|
720
|
-
getObservationsByIds(ids) {
|
|
796
|
+
getObservationsByIds(ids, userId) {
|
|
721
797
|
if (ids.length === 0)
|
|
722
798
|
return [];
|
|
723
799
|
const placeholders = ids.map(() => "?").join(",");
|
|
724
|
-
|
|
800
|
+
const visibilityClause = userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
|
|
801
|
+
return this.db.query(`SELECT * FROM observations
|
|
802
|
+
WHERE id IN (${placeholders})${visibilityClause}
|
|
803
|
+
ORDER BY created_at_epoch DESC`).all(...ids, ...userId ? [userId] : []);
|
|
725
804
|
}
|
|
726
805
|
getRecentObservations(projectId, sincEpoch, limit = 50) {
|
|
727
806
|
return this.db.query(`SELECT * FROM observations
|
|
@@ -729,8 +808,9 @@ class MemDatabase {
|
|
|
729
808
|
ORDER BY created_at_epoch DESC
|
|
730
809
|
LIMIT ?`).all(projectId, sincEpoch, limit);
|
|
731
810
|
}
|
|
732
|
-
searchFts(query, projectId, lifecycles = ["active", "aging", "pinned"], limit = 20) {
|
|
811
|
+
searchFts(query, projectId, lifecycles = ["active", "aging", "pinned"], limit = 20, userId) {
|
|
733
812
|
const lifecyclePlaceholders = lifecycles.map(() => "?").join(",");
|
|
813
|
+
const visibilityClause = userId ? " AND (o.sensitivity != 'personal' OR o.user_id = ?)" : "";
|
|
734
814
|
if (projectId !== null) {
|
|
735
815
|
return this.db.query(`SELECT o.id, observations_fts.rank
|
|
736
816
|
FROM observations_fts
|
|
@@ -738,33 +818,39 @@ class MemDatabase {
|
|
|
738
818
|
WHERE observations_fts MATCH ?
|
|
739
819
|
AND o.project_id = ?
|
|
740
820
|
AND o.lifecycle IN (${lifecyclePlaceholders})
|
|
821
|
+
${visibilityClause}
|
|
741
822
|
ORDER BY observations_fts.rank
|
|
742
|
-
LIMIT ?`).all(query, projectId, ...lifecycles, limit);
|
|
823
|
+
LIMIT ?`).all(query, projectId, ...lifecycles, ...userId ? [userId] : [], limit);
|
|
743
824
|
}
|
|
744
825
|
return this.db.query(`SELECT o.id, observations_fts.rank
|
|
745
826
|
FROM observations_fts
|
|
746
827
|
JOIN observations o ON o.id = observations_fts.rowid
|
|
747
828
|
WHERE observations_fts MATCH ?
|
|
748
829
|
AND o.lifecycle IN (${lifecyclePlaceholders})
|
|
830
|
+
${visibilityClause}
|
|
749
831
|
ORDER BY observations_fts.rank
|
|
750
|
-
LIMIT ?`).all(query, ...lifecycles, limit);
|
|
832
|
+
LIMIT ?`).all(query, ...lifecycles, ...userId ? [userId] : [], limit);
|
|
751
833
|
}
|
|
752
|
-
getTimeline(anchorId, projectId, depthBefore = 3, depthAfter = 3) {
|
|
753
|
-
const
|
|
834
|
+
getTimeline(anchorId, projectId, depthBefore = 3, depthAfter = 3, userId) {
|
|
835
|
+
const visibilityClause = userId ? " AND (sensitivity != 'personal' OR user_id = ?)" : "";
|
|
836
|
+
const anchor = this.db.query(`SELECT * FROM observations WHERE id = ?${visibilityClause}`).get(anchorId, ...userId ? [userId] : []) ?? null;
|
|
754
837
|
if (!anchor)
|
|
755
838
|
return [];
|
|
756
839
|
const projectFilter = projectId !== null ? "AND project_id = ?" : "";
|
|
757
840
|
const projectParams = projectId !== null ? [projectId] : [];
|
|
841
|
+
const visibilityParams = userId ? [userId] : [];
|
|
758
842
|
const before = this.db.query(`SELECT * FROM observations
|
|
759
843
|
WHERE created_at_epoch < ? ${projectFilter}
|
|
760
844
|
AND lifecycle IN ('active', 'aging', 'pinned')
|
|
845
|
+
${visibilityClause}
|
|
761
846
|
ORDER BY created_at_epoch DESC
|
|
762
|
-
LIMIT ?`).all(anchor.created_at_epoch, ...projectParams, depthBefore);
|
|
847
|
+
LIMIT ?`).all(anchor.created_at_epoch, ...projectParams, ...visibilityParams, depthBefore);
|
|
763
848
|
const after = this.db.query(`SELECT * FROM observations
|
|
764
849
|
WHERE created_at_epoch > ? ${projectFilter}
|
|
765
850
|
AND lifecycle IN ('active', 'aging', 'pinned')
|
|
851
|
+
${visibilityClause}
|
|
766
852
|
ORDER BY created_at_epoch ASC
|
|
767
|
-
LIMIT ?`).all(anchor.created_at_epoch, ...projectParams, depthAfter);
|
|
853
|
+
LIMIT ?`).all(anchor.created_at_epoch, ...projectParams, ...visibilityParams, depthAfter);
|
|
768
854
|
return [...before.reverse(), anchor, ...after];
|
|
769
855
|
}
|
|
770
856
|
pinObservation(id, pinned) {
|
|
@@ -878,11 +964,12 @@ class MemDatabase {
|
|
|
878
964
|
return;
|
|
879
965
|
this.db.query("DELETE FROM vec_observations WHERE observation_id = ?").run(observationId);
|
|
880
966
|
}
|
|
881
|
-
searchVec(queryEmbedding, projectId, lifecycles = ["active", "aging", "pinned"], limit = 20) {
|
|
967
|
+
searchVec(queryEmbedding, projectId, lifecycles = ["active", "aging", "pinned"], limit = 20, userId) {
|
|
882
968
|
if (!this.vecAvailable)
|
|
883
969
|
return [];
|
|
884
970
|
const lifecyclePlaceholders = lifecycles.map(() => "?").join(",");
|
|
885
971
|
const embeddingBlob = new Uint8Array(queryEmbedding.buffer);
|
|
972
|
+
const visibilityClause = userId ? " AND (o.sensitivity != 'personal' OR o.user_id = ?)" : "";
|
|
886
973
|
if (projectId !== null) {
|
|
887
974
|
return this.db.query(`SELECT v.observation_id, v.distance
|
|
888
975
|
FROM vec_observations v
|
|
@@ -891,7 +978,7 @@ class MemDatabase {
|
|
|
891
978
|
AND k = ?
|
|
892
979
|
AND o.project_id = ?
|
|
893
980
|
AND o.lifecycle IN (${lifecyclePlaceholders})
|
|
894
|
-
AND o.superseded_by IS NULL`).all(embeddingBlob, limit, projectId, ...lifecycles);
|
|
981
|
+
AND o.superseded_by IS NULL` + visibilityClause).all(embeddingBlob, limit, projectId, ...lifecycles, ...userId ? [userId] : []);
|
|
895
982
|
}
|
|
896
983
|
return this.db.query(`SELECT v.observation_id, v.distance
|
|
897
984
|
FROM vec_observations v
|
|
@@ -899,7 +986,7 @@ class MemDatabase {
|
|
|
899
986
|
WHERE v.embedding MATCH ?
|
|
900
987
|
AND k = ?
|
|
901
988
|
AND o.lifecycle IN (${lifecyclePlaceholders})
|
|
902
|
-
AND o.superseded_by IS NULL`).all(embeddingBlob, limit, ...lifecycles);
|
|
989
|
+
AND o.superseded_by IS NULL` + visibilityClause).all(embeddingBlob, limit, ...lifecycles, ...userId ? [userId] : []);
|
|
903
990
|
}
|
|
904
991
|
getUnembeddedCount() {
|
|
905
992
|
if (!this.vecAvailable)
|
|
@@ -1018,80 +1105,58 @@ class MemDatabase {
|
|
|
1018
1105
|
}
|
|
1019
1106
|
}
|
|
1020
1107
|
|
|
1021
|
-
// src/
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1108
|
+
// src/hooks/common.ts
|
|
1109
|
+
var c = {
|
|
1110
|
+
dim: "\x1B[2m",
|
|
1111
|
+
yellow: "\x1B[33m",
|
|
1112
|
+
reset: "\x1B[0m"
|
|
1113
|
+
};
|
|
1114
|
+
async function readStdin() {
|
|
1115
|
+
const chunks = [];
|
|
1116
|
+
for await (const chunk of process.stdin) {
|
|
1117
|
+
chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString());
|
|
1028
1118
|
}
|
|
1029
|
-
|
|
1119
|
+
return chunks.join("");
|
|
1120
|
+
}
|
|
1121
|
+
async function parseStdinJson() {
|
|
1122
|
+
const raw = await readStdin();
|
|
1123
|
+
if (!raw.trim())
|
|
1124
|
+
return null;
|
|
1030
1125
|
try {
|
|
1031
|
-
|
|
1032
|
-
method: "POST",
|
|
1033
|
-
headers: {
|
|
1034
|
-
"Content-Type": "application/json",
|
|
1035
|
-
Authorization: `Bearer ${config.candengo_api_key}`
|
|
1036
|
-
},
|
|
1037
|
-
body: JSON.stringify({
|
|
1038
|
-
tool_name: toolName,
|
|
1039
|
-
file_path: filePath,
|
|
1040
|
-
content: content.slice(0, 8000)
|
|
1041
|
-
}),
|
|
1042
|
-
signal: AbortSignal.timeout(15000)
|
|
1043
|
-
});
|
|
1044
|
-
if (!response.ok) {
|
|
1045
|
-
return { verdict: "PASS", reason: "Review service unavailable" };
|
|
1046
|
-
}
|
|
1047
|
-
const data = await response.json();
|
|
1048
|
-
return parseServerResponse(data);
|
|
1126
|
+
return JSON.parse(raw);
|
|
1049
1127
|
} catch {
|
|
1050
|
-
return
|
|
1128
|
+
return null;
|
|
1051
1129
|
}
|
|
1052
1130
|
}
|
|
1053
|
-
function
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
return
|
|
1131
|
+
function bootstrapHook(hookName) {
|
|
1132
|
+
if (!configExists()) {
|
|
1133
|
+
warnUser(hookName, "Engrm not configured. Run: npx engrm init");
|
|
1134
|
+
return null;
|
|
1057
1135
|
}
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
}
|
|
1065
|
-
function parseSeverity(s) {
|
|
1066
|
-
if (s === "critical" || s === "high" || s === "medium" || s === "low")
|
|
1067
|
-
return s;
|
|
1068
|
-
return;
|
|
1069
|
-
}
|
|
1070
|
-
function shouldSkip(filePath, patterns) {
|
|
1071
|
-
for (const pattern of patterns) {
|
|
1072
|
-
if (filePath.includes(pattern))
|
|
1073
|
-
return true;
|
|
1074
|
-
try {
|
|
1075
|
-
if (new RegExp(pattern).test(filePath))
|
|
1076
|
-
return true;
|
|
1077
|
-
} catch {}
|
|
1136
|
+
let config;
|
|
1137
|
+
try {
|
|
1138
|
+
config = loadConfig();
|
|
1139
|
+
} catch (err) {
|
|
1140
|
+
warnUser(hookName, `Config error: ${err instanceof Error ? err.message : String(err)}`);
|
|
1141
|
+
return null;
|
|
1078
1142
|
}
|
|
1079
|
-
|
|
1080
|
-
}
|
|
1081
|
-
function checkDailyLimit(db, limit) {
|
|
1082
|
-
const today = new Date().toISOString().split("T")[0];
|
|
1083
|
-
const key = `sentinel_audit_count_${today}`;
|
|
1143
|
+
let db;
|
|
1084
1144
|
try {
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
db.db.query(`INSERT INTO sync_state (key, value) VALUES (?, ?)
|
|
1090
|
-
ON CONFLICT(key) DO UPDATE SET value = ?`).run(key, String(count + 1), String(count + 1));
|
|
1091
|
-
return true;
|
|
1092
|
-
} catch {
|
|
1093
|
-
return true;
|
|
1145
|
+
db = new MemDatabase(getDbPath());
|
|
1146
|
+
} catch (err) {
|
|
1147
|
+
warnUser(hookName, `Database error: ${err instanceof Error ? err.message : String(err)}`);
|
|
1148
|
+
return null;
|
|
1094
1149
|
}
|
|
1150
|
+
return { config, db };
|
|
1151
|
+
}
|
|
1152
|
+
function warnUser(hookName, message) {
|
|
1153
|
+
console.error(`${c.yellow}engrm ${hookName}:${c.reset} ${c.dim}${message}${c.reset}`);
|
|
1154
|
+
}
|
|
1155
|
+
function runHook(hookName, fn) {
|
|
1156
|
+
fn().catch((err) => {
|
|
1157
|
+
warnUser(hookName, `Unexpected error: ${err instanceof Error ? err.message : String(err)}`);
|
|
1158
|
+
process.exit(0);
|
|
1159
|
+
});
|
|
1095
1160
|
}
|
|
1096
1161
|
|
|
1097
1162
|
// src/storage/projects.ts
|
|
@@ -1200,32 +1265,16 @@ function detectProject(directory) {
|
|
|
1200
1265
|
|
|
1201
1266
|
// hooks/sentinel.ts
|
|
1202
1267
|
async function main() {
|
|
1203
|
-
const
|
|
1204
|
-
|
|
1205
|
-
chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString());
|
|
1206
|
-
}
|
|
1207
|
-
const raw = chunks.join("");
|
|
1208
|
-
if (!raw.trim())
|
|
1209
|
-
process.exit(0);
|
|
1210
|
-
let event;
|
|
1211
|
-
try {
|
|
1212
|
-
event = JSON.parse(raw);
|
|
1213
|
-
} catch {
|
|
1268
|
+
const event = await parseStdinJson();
|
|
1269
|
+
if (!event)
|
|
1214
1270
|
process.exit(0);
|
|
1215
|
-
}
|
|
1216
1271
|
if (event.tool_name !== "Write" && event.tool_name !== "Edit") {
|
|
1217
1272
|
process.exit(0);
|
|
1218
1273
|
}
|
|
1219
|
-
|
|
1274
|
+
const boot = bootstrapHook("sentinel");
|
|
1275
|
+
if (!boot)
|
|
1220
1276
|
process.exit(0);
|
|
1221
|
-
|
|
1222
|
-
let db;
|
|
1223
|
-
try {
|
|
1224
|
-
config = loadConfig();
|
|
1225
|
-
db = new MemDatabase(getDbPath());
|
|
1226
|
-
} catch {
|
|
1227
|
-
process.exit(0);
|
|
1228
|
-
}
|
|
1277
|
+
const { config, db } = boot;
|
|
1229
1278
|
if (!config.sentinel.enabled) {
|
|
1230
1279
|
db.close();
|
|
1231
1280
|
process.exit(0);
|
|
@@ -1306,6 +1355,4 @@ async function main() {
|
|
|
1306
1355
|
}
|
|
1307
1356
|
process.exit(0);
|
|
1308
1357
|
}
|
|
1309
|
-
main
|
|
1310
|
-
process.exit(0);
|
|
1311
|
-
});
|
|
1358
|
+
runHook("sentinel", main);
|