@kage-core/kage-graph-mcp 1.4.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +157 -137
- package/dist/daemon.js +78 -57
- package/dist/index.js +24 -182
- package/dist/kernel.js +1097 -274
- package/dist/structural-worker.js +17 -13
- package/package.json +4 -2
- package/viewer/console.js +164 -19
- package/viewer/index.html +133 -78
package/dist/kernel.js
CHANGED
|
@@ -62,6 +62,9 @@ exports.makePacketId = makePacketId;
|
|
|
62
62
|
exports.parseFrontmatter = parseFrontmatter;
|
|
63
63
|
exports.kageMemoryAccess = kageMemoryAccess;
|
|
64
64
|
exports.kageMemoryLifecycle = kageMemoryLifecycle;
|
|
65
|
+
exports.recordValueEvent = recordValueEvent;
|
|
66
|
+
exports.valueSummary = valueSummary;
|
|
67
|
+
exports.formatTokenCount = formatTokenCount;
|
|
65
68
|
exports.kageActivity = kageActivity;
|
|
66
69
|
exports.kageMemoryReconciliation = kageMemoryReconciliation;
|
|
67
70
|
exports.evaluateMemoryAdmission = evaluateMemoryAdmission;
|
|
@@ -76,6 +79,8 @@ exports.kageMemoryAudit = kageMemoryAudit;
|
|
|
76
79
|
exports.kageMemoryHandoff = kageMemoryHandoff;
|
|
77
80
|
exports.buildStructuralFileForWorker = buildStructuralFileForWorker;
|
|
78
81
|
exports.buildStructuralIndex = buildStructuralIndex;
|
|
82
|
+
exports.treeSitterLanguagesForPaths = treeSitterLanguagesForPaths;
|
|
83
|
+
exports.ensureTreeSitterLanguages = ensureTreeSitterLanguages;
|
|
79
84
|
exports.writeLspSymbolIndex = writeLspSymbolIndex;
|
|
80
85
|
exports.writeCodeIndex = writeCodeIndex;
|
|
81
86
|
exports.buildCodeGraph = buildCodeGraph;
|
|
@@ -97,6 +102,7 @@ exports.kageTeammateBrief = kageTeammateBrief;
|
|
|
97
102
|
exports.kageRisk = kageRisk;
|
|
98
103
|
exports.kageDependencyPath = kageDependencyPath;
|
|
99
104
|
exports.kageCleanupCandidates = kageCleanupCandidates;
|
|
105
|
+
exports.truthReport = truthReport;
|
|
100
106
|
exports.kageReviewerSuggestions = kageReviewerSuggestions;
|
|
101
107
|
exports.kageContributors = kageContributors;
|
|
102
108
|
exports.kageContextSlots = kageContextSlots;
|
|
@@ -138,19 +144,13 @@ exports.proposeFromDiff = proposeFromDiff;
|
|
|
138
144
|
exports.buildBranchOverlay = buildBranchOverlay;
|
|
139
145
|
exports.createReviewArtifact = createReviewArtifact;
|
|
140
146
|
exports.prSummarize = prSummarize;
|
|
147
|
+
exports.staleCatch = staleCatch;
|
|
148
|
+
exports.formatStaleCatch = formatStaleCatch;
|
|
141
149
|
exports.prCheck = prCheck;
|
|
142
150
|
exports.kageHookStatus = kageHookStatus;
|
|
143
151
|
exports.kageHookInstall = kageHookInstall;
|
|
144
152
|
exports.kageHookUninstall = kageHookUninstall;
|
|
145
153
|
exports.exportPublicBundle = exportPublicBundle;
|
|
146
|
-
exports.orgStatus = orgStatus;
|
|
147
|
-
exports.orgUploadPacket = orgUploadPacket;
|
|
148
|
-
exports.orgReviewPacket = orgReviewPacket;
|
|
149
|
-
exports.orgRecall = orgRecall;
|
|
150
|
-
exports.layeredRecall = layeredRecall;
|
|
151
|
-
exports.exportOrgRegistry = exportOrgRegistry;
|
|
152
|
-
exports.buildMarketplace = buildMarketplace;
|
|
153
|
-
exports.buildGlobalCdnBundle = buildGlobalCdnBundle;
|
|
154
154
|
exports.recordFeedback = recordFeedback;
|
|
155
155
|
exports.validateProject = validateProject;
|
|
156
156
|
exports.initProject = initProject;
|
|
@@ -960,6 +960,169 @@ function recordRecallAccess(projectDir, results) {
|
|
|
960
960
|
// Recall should never fail because local access telemetry could not be updated.
|
|
961
961
|
}
|
|
962
962
|
}
|
|
963
|
+
// ---------------------------------------------------------------------------
|
|
964
|
+
// Value ledger: persistent per-repo receipts of what the harness actually saved
|
|
965
|
+
// or blocked — tokens not spent re-reading cited source, hard-stale memories
|
|
966
|
+
// withheld from recall, caller-intent questions answered from the call graph.
|
|
967
|
+
// Read by `kage gains` and the receipt lines appended to recall/context output.
|
|
968
|
+
// ---------------------------------------------------------------------------
|
|
969
|
+
const VALUE_LEDGER_SCHEMA_VERSION = 1;
|
|
970
|
+
const VALUE_LEDGER_EVENT_CAP = 5000;
|
|
971
|
+
// Rough Sonnet-class input price used for the dollar estimate: $15 per 1M tokens.
|
|
972
|
+
const VALUE_DOLLARS_PER_MILLION_TOKENS = 15;
|
|
973
|
+
function valueLedgerPath(projectDir) {
|
|
974
|
+
return (0, node_path_1.join)(reportsDir(projectDir), "value.json");
|
|
975
|
+
}
|
|
976
|
+
function emptyValueLedger() {
|
|
977
|
+
return {
|
|
978
|
+
schema_version: VALUE_LEDGER_SCHEMA_VERSION,
|
|
979
|
+
totals: { tokens_saved: 0, stale_withheld: 0, stale_caught: 0, recalls: 0, caller_answers: 0 },
|
|
980
|
+
events: [],
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
function nonNegativeCount(value) {
|
|
984
|
+
const parsed = Number(value);
|
|
985
|
+
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 0;
|
|
986
|
+
}
|
|
987
|
+
function readValueLedger(projectDir) {
|
|
988
|
+
const path = valueLedgerPath(projectDir);
|
|
989
|
+
if (!(0, node_fs_1.existsSync)(path))
|
|
990
|
+
return emptyValueLedger();
|
|
991
|
+
try {
|
|
992
|
+
const raw = readJson(path);
|
|
993
|
+
const totals = (raw.totals ?? {});
|
|
994
|
+
const events = (Array.isArray(raw.events) ? raw.events : [])
|
|
995
|
+
.filter((event) => {
|
|
996
|
+
const candidate = event;
|
|
997
|
+
return Boolean(candidate)
|
|
998
|
+
&& typeof candidate?.at === "string"
|
|
999
|
+
&& Number.isFinite(Date.parse(candidate.at))
|
|
1000
|
+
&& (candidate.kind === "recall_served" || candidate.kind === "stale_withheld" || candidate.kind === "stale_caught" || candidate.kind === "caller_answered");
|
|
1001
|
+
})
|
|
1002
|
+
.slice(-VALUE_LEDGER_EVENT_CAP);
|
|
1003
|
+
return {
|
|
1004
|
+
schema_version: VALUE_LEDGER_SCHEMA_VERSION,
|
|
1005
|
+
totals: {
|
|
1006
|
+
tokens_saved: nonNegativeCount(totals.tokens_saved),
|
|
1007
|
+
stale_withheld: nonNegativeCount(totals.stale_withheld),
|
|
1008
|
+
stale_caught: nonNegativeCount(totals.stale_caught),
|
|
1009
|
+
recalls: nonNegativeCount(totals.recalls),
|
|
1010
|
+
caller_answers: nonNegativeCount(totals.caller_answers),
|
|
1011
|
+
},
|
|
1012
|
+
events,
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
catch {
|
|
1016
|
+
return emptyValueLedger();
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
function recordValueEvents(projectDir, events) {
|
|
1020
|
+
if (!events.length)
|
|
1021
|
+
return;
|
|
1022
|
+
try {
|
|
1023
|
+
const ledger = readValueLedger(projectDir);
|
|
1024
|
+
const at = nowIso();
|
|
1025
|
+
for (const event of events) {
|
|
1026
|
+
const record = { at, kind: event.kind };
|
|
1027
|
+
if (event.kind === "recall_served") {
|
|
1028
|
+
record.tokens_saved = nonNegativeCount(event.tokens_saved);
|
|
1029
|
+
ledger.totals.tokens_saved += record.tokens_saved;
|
|
1030
|
+
ledger.totals.recalls += 1;
|
|
1031
|
+
}
|
|
1032
|
+
else if (event.kind === "stale_withheld") {
|
|
1033
|
+
record.packet_title = event.packet_title;
|
|
1034
|
+
ledger.totals.stale_withheld += 1;
|
|
1035
|
+
}
|
|
1036
|
+
else if (event.kind === "stale_caught") {
|
|
1037
|
+
record.packet_title = event.packet_title;
|
|
1038
|
+
ledger.totals.stale_caught += 1;
|
|
1039
|
+
}
|
|
1040
|
+
else {
|
|
1041
|
+
ledger.totals.caller_answers += 1;
|
|
1042
|
+
}
|
|
1043
|
+
ledger.events.push(record);
|
|
1044
|
+
}
|
|
1045
|
+
if (ledger.events.length > VALUE_LEDGER_EVENT_CAP)
|
|
1046
|
+
ledger.events = ledger.events.slice(-VALUE_LEDGER_EVENT_CAP);
|
|
1047
|
+
// Atomic read-modify-write: write to a temp file then rename so a crashed or
|
|
1048
|
+
// concurrent writer can never leave a torn value.json behind.
|
|
1049
|
+
const path = valueLedgerPath(projectDir);
|
|
1050
|
+
ensureDir((0, node_path_1.dirname)(path));
|
|
1051
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
1052
|
+
(0, node_fs_1.writeFileSync)(tmp, `${JSON.stringify(ledger, null, 2)}\n`, "utf8");
|
|
1053
|
+
(0, node_fs_1.renameSync)(tmp, path);
|
|
1054
|
+
}
|
|
1055
|
+
catch {
|
|
1056
|
+
// Value telemetry must never break recall or code-graph queries.
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
function recordValueEvent(projectDir, event) {
|
|
1060
|
+
recordValueEvents(projectDir, [event]);
|
|
1061
|
+
}
|
|
1062
|
+
function estimatedTokenDollars(tokensSaved) {
|
|
1063
|
+
return Number(((tokensSaved / 1_000_000) * VALUE_DOLLARS_PER_MILLION_TOKENS).toFixed(2));
|
|
1064
|
+
}
|
|
1065
|
+
function summarizeValueWindow(events, cutoff) {
|
|
1066
|
+
const window = { tokens_saved: 0, stale_withheld: 0, stale_caught: 0, recalls: 0, caller_answers: 0 };
|
|
1067
|
+
for (const event of events) {
|
|
1068
|
+
const at = Date.parse(event.at);
|
|
1069
|
+
if (!Number.isFinite(at) || at < cutoff)
|
|
1070
|
+
continue;
|
|
1071
|
+
if (event.kind === "recall_served") {
|
|
1072
|
+
window.recalls += 1;
|
|
1073
|
+
window.tokens_saved += nonNegativeCount(event.tokens_saved);
|
|
1074
|
+
}
|
|
1075
|
+
else if (event.kind === "stale_withheld") {
|
|
1076
|
+
window.stale_withheld += 1;
|
|
1077
|
+
}
|
|
1078
|
+
else if (event.kind === "stale_caught") {
|
|
1079
|
+
window.stale_caught += 1;
|
|
1080
|
+
}
|
|
1081
|
+
else {
|
|
1082
|
+
window.caller_answers += 1;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
return { ...window, estimated_dollars: estimatedTokenDollars(window.tokens_saved) };
|
|
1086
|
+
}
|
|
1087
|
+
function valueSummary(projectDir) {
|
|
1088
|
+
const ledger = readValueLedger(projectDir);
|
|
1089
|
+
const todayStart = new Date();
|
|
1090
|
+
todayStart.setHours(0, 0, 0, 0);
|
|
1091
|
+
return {
|
|
1092
|
+
schema_version: VALUE_LEDGER_SCHEMA_VERSION,
|
|
1093
|
+
project_dir: (0, node_path_1.resolve)(projectDir),
|
|
1094
|
+
today: summarizeValueWindow(ledger.events, todayStart.getTime()),
|
|
1095
|
+
last_7d: summarizeValueWindow(ledger.events, Date.now() - 7 * 86_400_000),
|
|
1096
|
+
all_time: { ...ledger.totals, estimated_dollars: estimatedTokenDollars(ledger.totals.tokens_saved) },
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
// Human display for ledger token counts: 412 -> "412", 412_345 -> "412K", 4_120_000 -> "4.1M".
|
|
1100
|
+
function formatTokenCount(tokens) {
|
|
1101
|
+
const count = Math.max(0, Math.round(tokens));
|
|
1102
|
+
if (count >= 1_000_000)
|
|
1103
|
+
return `${(count / 1_000_000).toFixed(1)}M`;
|
|
1104
|
+
if (count >= 1_000)
|
|
1105
|
+
return `${Math.round(count / 1_000)}K`;
|
|
1106
|
+
return String(count);
|
|
1107
|
+
}
|
|
1108
|
+
// Receipt math: tokens an agent would have spent reading the cited source files
|
|
1109
|
+
// of the served packets (bytes / 4) minus the tokens the recall context block
|
|
1110
|
+
// itself costs (length / 4). Floored at zero — a recall never "costs" savings.
|
|
1111
|
+
function recallTokensSaved(projectDir, results, contextBlock) {
|
|
1112
|
+
const paths = unique(results.flatMap((entry) => entry.packet.paths).filter((path) => meaningfulMemoryPath(path)));
|
|
1113
|
+
let sourceBytes = 0;
|
|
1114
|
+
for (const path of paths) {
|
|
1115
|
+
try {
|
|
1116
|
+
const stats = (0, node_fs_1.statSync)((0, node_path_1.join)(projectDir, path));
|
|
1117
|
+
if (stats.isFile())
|
|
1118
|
+
sourceBytes += stats.size;
|
|
1119
|
+
}
|
|
1120
|
+
catch {
|
|
1121
|
+
// Missing cited files save nothing.
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
return Math.max(0, Math.floor(sourceBytes / 4) - Math.floor(contextBlock.length / 4));
|
|
1125
|
+
}
|
|
963
1126
|
const AUDIT_ACTIVITY_KIND = {
|
|
964
1127
|
capture: "capture", approve: "capture", supersede: "supersede", deprecate: "deprecate",
|
|
965
1128
|
update: "update", promote: "promote", feedback: "feedback",
|
|
@@ -1104,6 +1267,10 @@ function fingerprintableMemoryPath(path) {
|
|
|
1104
1267
|
const normalized = path.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
1105
1268
|
return meaningfulMemoryPath(normalized) && !normalized.startsWith(".agent_memory/");
|
|
1106
1269
|
}
|
|
1270
|
+
// Process-level fingerprint cache validated by mtime+size: staleness checks run on
|
|
1271
|
+
// every recall, and re-hashing every grounded file dominated recall latency on
|
|
1272
|
+
// repos with many packets. Content changes always re-hash (mtime/size moves).
|
|
1273
|
+
const fingerprintProcessCache = new Map();
|
|
1107
1274
|
function memoryPathFingerprint(projectDir, path, cache) {
|
|
1108
1275
|
const normalized = path.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
1109
1276
|
if (!fingerprintableMemoryPath(normalized))
|
|
@@ -1118,11 +1285,17 @@ function memoryPathFingerprint(projectDir, path, cache) {
|
|
|
1118
1285
|
cache?.set(cacheKey, null);
|
|
1119
1286
|
return null;
|
|
1120
1287
|
}
|
|
1288
|
+
const warm = fingerprintProcessCache.get(cacheKey);
|
|
1289
|
+
if (warm && warm.mtimeMs === stats.mtimeMs && warm.size === stats.size) {
|
|
1290
|
+
cache?.set(cacheKey, warm.fingerprint);
|
|
1291
|
+
return warm.fingerprint;
|
|
1292
|
+
}
|
|
1121
1293
|
const fingerprint = {
|
|
1122
1294
|
path: normalized,
|
|
1123
1295
|
sha256: sha256Hex((0, node_fs_1.readFileSync)(absolutePath)),
|
|
1124
1296
|
size: stats.size,
|
|
1125
1297
|
};
|
|
1298
|
+
fingerprintProcessCache.set(cacheKey, { mtimeMs: stats.mtimeMs, size: stats.size, fingerprint });
|
|
1126
1299
|
cache?.set(cacheKey, fingerprint);
|
|
1127
1300
|
return fingerprint;
|
|
1128
1301
|
}
|
|
@@ -2507,6 +2680,10 @@ function shouldSkipCodePath(relativePath) {
|
|
|
2507
2680
|
.some((part) => [
|
|
2508
2681
|
".git",
|
|
2509
2682
|
".agent_memory",
|
|
2683
|
+
// Agent working dirs: .claude holds settings AND worktrees/ (parallel agent
|
|
2684
|
+
// checkouts) — indexing them pollutes the code graph with phantom duplicates.
|
|
2685
|
+
".claude",
|
|
2686
|
+
".codex",
|
|
2510
2687
|
"node_modules",
|
|
2511
2688
|
"vendor",
|
|
2512
2689
|
".venv",
|
|
@@ -2790,7 +2967,9 @@ function structuralFileCacheDir(projectDir) {
|
|
|
2790
2967
|
function structuralPackedFileCachePath(projectDir) {
|
|
2791
2968
|
return (0, node_path_1.join)(structuralIndexDir(projectDir), "file-cache.json");
|
|
2792
2969
|
}
|
|
2793
|
-
|
|
2970
|
+
// Bump whenever symbol/call extraction changes, or cached per-file results
|
|
2971
|
+
// keep serving pre-change output and upgrades silently never land.
|
|
2972
|
+
const STRUCTURAL_EXTRACTOR_VERSION = 4; // v4: tree-sitter symbols for python/go/rust/java/ruby
|
|
2794
2973
|
function structuralFileCachePath(projectDir, rel, hash) {
|
|
2795
2974
|
return (0, node_path_1.join)(structuralFileCacheDir(projectDir), `v${STRUCTURAL_EXTRACTOR_VERSION}-${slugify(rel)}-${hash}.json`);
|
|
2796
2975
|
}
|
|
@@ -3203,6 +3382,16 @@ function writeStructuralFileCachePack(projectDir, results) {
|
|
|
3203
3382
|
packedStructuralCache.delete((0, node_path_1.resolve)(projectDir));
|
|
3204
3383
|
(0, node_fs_1.rmSync)(structuralFileCacheDir(projectDir), { recursive: true, force: true });
|
|
3205
3384
|
}
|
|
3385
|
+
// A regex-tier cache entry for a language whose tree-sitter grammar is now
|
|
3386
|
+
// loaded would pin weak symbols past the upgrade; treat it as a miss so the
|
|
3387
|
+
// file re-extracts at the stronger tier.
|
|
3388
|
+
function usableStructuralCache(rel, cached) {
|
|
3389
|
+
if (!cached)
|
|
3390
|
+
return null;
|
|
3391
|
+
if (treeSitterParserFor(rel) && cached.symbols.some((symbol) => symbol.parser === "generic-static"))
|
|
3392
|
+
return null;
|
|
3393
|
+
return cached;
|
|
3394
|
+
}
|
|
3206
3395
|
function buildStructuralFile(projectDir, absolutePath, knownFiles, prior) {
|
|
3207
3396
|
const rel = (0, node_path_1.relative)(projectDir, absolutePath).replace(/\\/g, "/");
|
|
3208
3397
|
const stats = (0, node_fs_1.statSync)(absolutePath);
|
|
@@ -3210,11 +3399,11 @@ function buildStructuralFile(projectDir, absolutePath, knownFiles, prior) {
|
|
|
3210
3399
|
const canReuseHash = priorEntry && priorEntry.size_bytes === stats.size && Math.round(priorEntry.mtime_ms) === Math.round(stats.mtimeMs);
|
|
3211
3400
|
let buffer = canReuseHash ? null : (0, node_fs_1.readFileSync)(absolutePath);
|
|
3212
3401
|
let hash = canReuseHash ? priorEntry.hash : sha256Hex(buffer ?? "");
|
|
3213
|
-
let cached = readCachedStructuralFile(projectDir, rel, hash);
|
|
3402
|
+
let cached = usableStructuralCache(rel, readCachedStructuralFile(projectDir, rel, hash));
|
|
3214
3403
|
if (!cached && !buffer) {
|
|
3215
3404
|
buffer = (0, node_fs_1.readFileSync)(absolutePath);
|
|
3216
3405
|
hash = sha256Hex(buffer);
|
|
3217
|
-
cached = readCachedStructuralFile(projectDir, rel, hash);
|
|
3406
|
+
cached = usableStructuralCache(rel, readCachedStructuralFile(projectDir, rel, hash));
|
|
3218
3407
|
}
|
|
3219
3408
|
const entry = {
|
|
3220
3409
|
path: rel,
|
|
@@ -3236,7 +3425,7 @@ function buildStructuralFile(projectDir, absolutePath, knownFiles, prior) {
|
|
|
3236
3425
|
rawImports.push(...extractImports(projectDir, rel, content, knownFiles));
|
|
3237
3426
|
}
|
|
3238
3427
|
else if (CODE_EXTENSIONS.has(extensionOf(rel))) {
|
|
3239
|
-
rawSymbols.push(...extractGenericSymbols(rel, content));
|
|
3428
|
+
rawSymbols.push(...(extractTreeSitterSymbols(rel, content) ?? extractGenericSymbols(rel, content)));
|
|
3240
3429
|
rawImports.push(...extractGenericImports(projectDir, rel, content, knownFiles));
|
|
3241
3430
|
}
|
|
3242
3431
|
}
|
|
@@ -3658,6 +3847,20 @@ function extractSymbols(path, text) {
|
|
|
3658
3847
|
addSymbol(declaration.name.text, kind, declaration, exported, node.getText(sourceFile).split(/\r?\n/)[0]);
|
|
3659
3848
|
}
|
|
3660
3849
|
}
|
|
3850
|
+
else if (ts.isExpressionStatement(node) &&
|
|
3851
|
+
ts.isBinaryExpression(node.expression) &&
|
|
3852
|
+
node.expression.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
|
|
3853
|
+
ts.isPropertyAccessExpression(node.expression.left) &&
|
|
3854
|
+
(ts.isFunctionExpression(node.expression.right) || ts.isArrowFunction(node.expression.right))) {
|
|
3855
|
+
// Method-assignment pattern: `app.use = function use(fn) {…}`, `proto.handle = (req) => {…}`.
|
|
3856
|
+
// Express/Koa-style APIs define most of their public surface this way; without this
|
|
3857
|
+
// branch those symbols are invisible to the code graph.
|
|
3858
|
+
const left = node.expression.left;
|
|
3859
|
+
const receiver = left.expression.getText(sourceFile);
|
|
3860
|
+
const exported = receiver === "exports" || receiver === "module.exports" || receiver.endsWith(".prototype");
|
|
3861
|
+
const firstLine = `${left.getText(sourceFile)} = ${node.expression.right.getText(sourceFile).split(/\r?\n/)[0] ?? ""}`;
|
|
3862
|
+
addSymbol(left.name.text, "method", node, exported, firstLine.trim().slice(0, 180));
|
|
3863
|
+
}
|
|
3661
3864
|
else if (codeFileKind(path) === "test" && ts.isCallExpression(node)) {
|
|
3662
3865
|
const callee = propertyOrIdentifierName(node.expression);
|
|
3663
3866
|
const first = stringLiteralValue(node.arguments[0]);
|
|
@@ -3669,6 +3872,255 @@ function extractSymbols(path, text) {
|
|
|
3669
3872
|
visit(sourceFile);
|
|
3670
3873
|
return symbols.sort((a, b) => a.line - b.line || a.name.localeCompare(b.name));
|
|
3671
3874
|
}
|
|
3875
|
+
const TREE_SITTER_LANGUAGES = {
|
|
3876
|
+
python: "python",
|
|
3877
|
+
go: "go",
|
|
3878
|
+
rust: "rust",
|
|
3879
|
+
java: "java",
|
|
3880
|
+
ruby: "ruby",
|
|
3881
|
+
};
|
|
3882
|
+
const treeSitterParsers = new Map();
|
|
3883
|
+
const treeSitterFailedLanguages = new Set();
|
|
3884
|
+
// web-tree-sitter mutates its CommonJS export during init, so the pre-init
|
|
3885
|
+
// Parser class is cached once instead of re-required per language.
|
|
3886
|
+
let treeSitterRuntime = null;
|
|
3887
|
+
function treeSitterLanguagesForPaths(paths) {
|
|
3888
|
+
return [...new Set(paths.map((path) => codeLanguage(path)).filter((language) => language in TREE_SITTER_LANGUAGES))];
|
|
3889
|
+
}
|
|
3890
|
+
// web-tree-sitter only initializes asynchronously, so grammars are loaded here —
|
|
3891
|
+
// once per process, before any file loop — and per-file extraction stays
|
|
3892
|
+
// synchronous. A language whose grammar fails to load falls back to the regex
|
|
3893
|
+
// extractor instead of crashing or blocking.
|
|
3894
|
+
async function ensureTreeSitterLanguages(languages = Object.keys(TREE_SITTER_LANGUAGES)) {
|
|
3895
|
+
const wanted = languages.filter((language) => language in TREE_SITTER_LANGUAGES && !treeSitterParsers.has(language) && !treeSitterFailedLanguages.has(language));
|
|
3896
|
+
if (!wanted.length)
|
|
3897
|
+
return;
|
|
3898
|
+
if (!treeSitterRuntime) {
|
|
3899
|
+
try {
|
|
3900
|
+
const Parser = require("web-tree-sitter");
|
|
3901
|
+
await Parser.init();
|
|
3902
|
+
treeSitterRuntime = Parser;
|
|
3903
|
+
}
|
|
3904
|
+
catch {
|
|
3905
|
+
for (const language of wanted)
|
|
3906
|
+
treeSitterFailedLanguages.add(language);
|
|
3907
|
+
return;
|
|
3908
|
+
}
|
|
3909
|
+
}
|
|
3910
|
+
const runtime = treeSitterRuntime;
|
|
3911
|
+
if (!runtime)
|
|
3912
|
+
return;
|
|
3913
|
+
for (const language of wanted) {
|
|
3914
|
+
try {
|
|
3915
|
+
const grammar = await runtime.Language.load(require.resolve(`tree-sitter-wasms/out/tree-sitter-${TREE_SITTER_LANGUAGES[language]}.wasm`));
|
|
3916
|
+
const parser = new runtime();
|
|
3917
|
+
parser.setLanguage(grammar);
|
|
3918
|
+
treeSitterParsers.set(language, parser);
|
|
3919
|
+
}
|
|
3920
|
+
catch {
|
|
3921
|
+
treeSitterFailedLanguages.add(language);
|
|
3922
|
+
}
|
|
3923
|
+
}
|
|
3924
|
+
}
|
|
3925
|
+
function treeSitterParserFor(path) {
|
|
3926
|
+
if (TS_AST_EXTENSIONS.has(extensionOf(path)))
|
|
3927
|
+
return null;
|
|
3928
|
+
return treeSitterParsers.get(codeLanguage(path)) ?? null;
|
|
3929
|
+
}
|
|
3930
|
+
const TREE_SITTER_SYMBOL_NODE_TYPES = {
|
|
3931
|
+
python: ["function_definition", "class_definition", "assignment"],
|
|
3932
|
+
go: ["function_declaration", "method_declaration", "type_spec"],
|
|
3933
|
+
rust: ["function_item", "function_signature_item", "struct_item", "enum_item", "trait_item"],
|
|
3934
|
+
java: ["class_declaration", "interface_declaration", "enum_declaration", "method_declaration", "constructor_declaration"],
|
|
3935
|
+
ruby: ["method", "singleton_method", "class", "module"],
|
|
3936
|
+
};
|
|
3937
|
+
const TREE_SITTER_CALL_NODE_TYPES = {
|
|
3938
|
+
python: ["call"],
|
|
3939
|
+
go: ["call_expression"],
|
|
3940
|
+
rust: ["call_expression"],
|
|
3941
|
+
java: ["method_invocation", "object_creation_expression"],
|
|
3942
|
+
ruby: ["call"],
|
|
3943
|
+
};
|
|
3944
|
+
const TREE_SITTER_CLASS_ANCESTORS = {
|
|
3945
|
+
python: new Set(["class_definition"]),
|
|
3946
|
+
rust: new Set(["impl_item", "trait_item"]),
|
|
3947
|
+
ruby: new Set(["class", "module"]),
|
|
3948
|
+
};
|
|
3949
|
+
function treeSitterHasClassAncestor(language, node) {
|
|
3950
|
+
const types = TREE_SITTER_CLASS_ANCESTORS[language];
|
|
3951
|
+
if (!types)
|
|
3952
|
+
return false;
|
|
3953
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
3954
|
+
if (types.has(current.type))
|
|
3955
|
+
return true;
|
|
3956
|
+
}
|
|
3957
|
+
return false;
|
|
3958
|
+
}
|
|
3959
|
+
function treeSitterSymbolFromNode(language, node) {
|
|
3960
|
+
if (language === "python") {
|
|
3961
|
+
if (node.type === "assignment") {
|
|
3962
|
+
const left = node.childForFieldName("left");
|
|
3963
|
+
if (left?.type !== "identifier" || node.childForFieldName("right")?.type !== "lambda")
|
|
3964
|
+
return null;
|
|
3965
|
+
return { name: left.text, kind: "function", exported: !left.text.startsWith("_") };
|
|
3966
|
+
}
|
|
3967
|
+
const name = node.childForFieldName("name")?.text;
|
|
3968
|
+
if (!name)
|
|
3969
|
+
return null;
|
|
3970
|
+
if (node.type === "class_definition")
|
|
3971
|
+
return { name, kind: "class", exported: !name.startsWith("_") };
|
|
3972
|
+
return { name, kind: treeSitterHasClassAncestor(language, node) ? "method" : "function", exported: !name.startsWith("_") };
|
|
3973
|
+
}
|
|
3974
|
+
if (language === "go") {
|
|
3975
|
+
const name = node.childForFieldName("name")?.text;
|
|
3976
|
+
if (!name)
|
|
3977
|
+
return null;
|
|
3978
|
+
const exported = /^[A-Z]/.test(name);
|
|
3979
|
+
if (node.type === "method_declaration")
|
|
3980
|
+
return { name, kind: "method", exported };
|
|
3981
|
+
if (node.type === "type_spec") {
|
|
3982
|
+
const typeNode = node.childForFieldName("type");
|
|
3983
|
+
if (typeNode?.type !== "struct_type" && typeNode?.type !== "interface_type")
|
|
3984
|
+
return null;
|
|
3985
|
+
return { name, kind: "class", exported };
|
|
3986
|
+
}
|
|
3987
|
+
return { name, kind: "function", exported };
|
|
3988
|
+
}
|
|
3989
|
+
if (language === "rust") {
|
|
3990
|
+
const name = node.childForFieldName("name")?.text;
|
|
3991
|
+
if (!name)
|
|
3992
|
+
return null;
|
|
3993
|
+
const exported = node.namedChildren.some((child) => child.type === "visibility_modifier");
|
|
3994
|
+
if (node.type === "struct_item" || node.type === "enum_item" || node.type === "trait_item")
|
|
3995
|
+
return { name, kind: "class", exported };
|
|
3996
|
+
return { name, kind: treeSitterHasClassAncestor(language, node) ? "method" : "function", exported };
|
|
3997
|
+
}
|
|
3998
|
+
if (language === "java") {
|
|
3999
|
+
const name = node.childForFieldName("name")?.text;
|
|
4000
|
+
if (!name)
|
|
4001
|
+
return null;
|
|
4002
|
+
const exported = node.namedChildren.some((child) => child.type === "modifiers" && /\bpublic\b/.test(child.text));
|
|
4003
|
+
if (node.type === "method_declaration" || node.type === "constructor_declaration")
|
|
4004
|
+
return { name, kind: "method", exported };
|
|
4005
|
+
return { name, kind: "class", exported };
|
|
4006
|
+
}
|
|
4007
|
+
if (language === "ruby") {
|
|
4008
|
+
const name = node.childForFieldName("name")?.text;
|
|
4009
|
+
if (!name)
|
|
4010
|
+
return null;
|
|
4011
|
+
if (node.type === "class" || node.type === "module")
|
|
4012
|
+
return { name, kind: "class", exported: true };
|
|
4013
|
+
return { name, kind: treeSitterHasClassAncestor(language, node) ? "method" : "function", exported: !name.startsWith("_") };
|
|
4014
|
+
}
|
|
4015
|
+
return null;
|
|
4016
|
+
}
|
|
4017
|
+
function extractTreeSitterSymbols(path, text) {
|
|
4018
|
+
const parser = treeSitterParserFor(path);
|
|
4019
|
+
if (!parser)
|
|
4020
|
+
return null;
|
|
4021
|
+
const language = codeLanguage(path);
|
|
4022
|
+
const fileKind = codeFileKind(path);
|
|
4023
|
+
let tree;
|
|
4024
|
+
try {
|
|
4025
|
+
tree = parser.parse(text);
|
|
4026
|
+
}
|
|
4027
|
+
catch {
|
|
4028
|
+
return null;
|
|
4029
|
+
}
|
|
4030
|
+
const symbols = [];
|
|
4031
|
+
try {
|
|
4032
|
+
for (const node of tree.rootNode.descendantsOfType(TREE_SITTER_SYMBOL_NODE_TYPES[language] ?? [])) {
|
|
4033
|
+
const fact = treeSitterSymbolFromNode(language, node);
|
|
4034
|
+
if (!fact)
|
|
4035
|
+
continue;
|
|
4036
|
+
const kind = fact.kind !== "class" && fileKind === "test" && /^(test_|Test|it_|should_)/.test(fact.name) ? "test" : fact.kind;
|
|
4037
|
+
const line = node.startPosition.row + 1;
|
|
4038
|
+
symbols.push({
|
|
4039
|
+
id: symbolId(path, fact.name, kind, line),
|
|
4040
|
+
name: fact.name,
|
|
4041
|
+
kind,
|
|
4042
|
+
path,
|
|
4043
|
+
language,
|
|
4044
|
+
parser: "tree-sitter",
|
|
4045
|
+
export: fact.exported,
|
|
4046
|
+
line,
|
|
4047
|
+
end_line: node.endPosition.row + 1,
|
|
4048
|
+
signature: node.text.split("\n", 1)[0].trim().slice(0, 180),
|
|
4049
|
+
});
|
|
4050
|
+
}
|
|
4051
|
+
}
|
|
4052
|
+
finally {
|
|
4053
|
+
tree.delete();
|
|
4054
|
+
}
|
|
4055
|
+
return symbols.sort((a, b) => a.line - b.line || a.name.localeCompare(b.name));
|
|
4056
|
+
}
|
|
4057
|
+
function treeSitterCalleeName(language, node) {
|
|
4058
|
+
if (language === "java") {
|
|
4059
|
+
if (node.type === "object_creation_expression")
|
|
4060
|
+
return node.childForFieldName("type")?.text.split("<")[0].split(".").pop() ?? null;
|
|
4061
|
+
return node.childForFieldName("name")?.text ?? null;
|
|
4062
|
+
}
|
|
4063
|
+
if (language === "ruby") {
|
|
4064
|
+
const method = node.childForFieldName("method");
|
|
4065
|
+
return method?.type === "identifier" ? method.text : null;
|
|
4066
|
+
}
|
|
4067
|
+
const callee = node.childForFieldName("function");
|
|
4068
|
+
if (!callee)
|
|
4069
|
+
return null;
|
|
4070
|
+
if (callee.type === "identifier")
|
|
4071
|
+
return callee.text;
|
|
4072
|
+
if (callee.type === "attribute")
|
|
4073
|
+
return callee.childForFieldName("attribute")?.text ?? null;
|
|
4074
|
+
if (callee.type === "selector_expression" || callee.type === "field_expression")
|
|
4075
|
+
return callee.childForFieldName("field")?.text ?? null;
|
|
4076
|
+
if (callee.type === "scoped_identifier")
|
|
4077
|
+
return callee.childForFieldName("name")?.text ?? null;
|
|
4078
|
+
return null;
|
|
4079
|
+
}
|
|
4080
|
+
function extractTreeSitterCalls(path, text, symbols, symbolByName, context = EMPTY_CALL_RESOLUTION) {
|
|
4081
|
+
const parser = treeSitterParserFor(path);
|
|
4082
|
+
if (!parser)
|
|
4083
|
+
return null;
|
|
4084
|
+
const language = codeLanguage(path);
|
|
4085
|
+
let tree;
|
|
4086
|
+
try {
|
|
4087
|
+
tree = parser.parse(text);
|
|
4088
|
+
}
|
|
4089
|
+
catch {
|
|
4090
|
+
return null;
|
|
4091
|
+
}
|
|
4092
|
+
const calls = [];
|
|
4093
|
+
try {
|
|
4094
|
+
for (const node of tree.rootNode.descendantsOfType(TREE_SITTER_CALL_NODE_TYPES[language] ?? [])) {
|
|
4095
|
+
if (calls.length >= MAX_CODE_GRAPH_CALLS_PER_FILE)
|
|
4096
|
+
break;
|
|
4097
|
+
const name = treeSitterCalleeName(language, node);
|
|
4098
|
+
if (!name || !/^[A-Za-z_]\w*$/.test(name))
|
|
4099
|
+
continue;
|
|
4100
|
+
const line = node.startPosition.row + 1;
|
|
4101
|
+
const targets = symbolByName.get(name)?.filter((target) => target.path !== path || target.line !== line);
|
|
4102
|
+
if (!targets?.length)
|
|
4103
|
+
continue;
|
|
4104
|
+
const caller = symbolAtLine(symbols, path, line);
|
|
4105
|
+
for (const { target, confidence } of resolveCallTargets(name, path, targets, context, { local: 0.8, imported: 0.75, sameDir: 0.55, nameOnly: 0.32 })) {
|
|
4106
|
+
if (calls.length >= MAX_CODE_GRAPH_CALLS_PER_FILE)
|
|
4107
|
+
break;
|
|
4108
|
+
calls.push({
|
|
4109
|
+
from_symbol: caller?.id ?? null,
|
|
4110
|
+
to_symbol: target.id,
|
|
4111
|
+
path,
|
|
4112
|
+
line,
|
|
4113
|
+
confidence,
|
|
4114
|
+
resolution: "tree_sitter_name",
|
|
4115
|
+
});
|
|
4116
|
+
}
|
|
4117
|
+
}
|
|
4118
|
+
}
|
|
4119
|
+
finally {
|
|
4120
|
+
tree.delete();
|
|
4121
|
+
}
|
|
4122
|
+
return calls.sort((a, b) => a.line - b.line || a.to_symbol.localeCompare(b.to_symbol));
|
|
4123
|
+
}
|
|
3672
4124
|
function extractGenericSymbols(path, text) {
|
|
3673
4125
|
const symbols = [];
|
|
3674
4126
|
const language = codeLanguage(path);
|
|
@@ -3908,7 +4360,7 @@ function normalizeCallConfidence(value, fallback) {
|
|
|
3908
4360
|
return Number(Math.max(0, Math.min(1, numeric)).toFixed(2));
|
|
3909
4361
|
}
|
|
3910
4362
|
function normalizeCallResolution(value, fallback) {
|
|
3911
|
-
return value === "typescript_ast_name" || value === "generic_static_name" || value === "external_index" ? value : fallback;
|
|
4363
|
+
return value === "typescript_ast_name" || value === "tree_sitter_name" || value === "generic_static_name" || value === "external_index" ? value : fallback;
|
|
3912
4364
|
}
|
|
3913
4365
|
function normalizeCallEdge(call, fallback) {
|
|
3914
4366
|
if (!isRecord(call) || typeof call.to_symbol !== "string")
|
|
@@ -3922,7 +4374,34 @@ function normalizeCallEdge(call, fallback) {
|
|
|
3922
4374
|
resolution: normalizeCallResolution(call.resolution, fallback.resolution),
|
|
3923
4375
|
};
|
|
3924
4376
|
}
|
|
3925
|
-
|
|
4377
|
+
const EMPTY_CALL_RESOLUTION = { importedPaths: new Set(), importedNames: new Map(), dir: "" };
|
|
4378
|
+
// Resolve a callee name to symbol targets through scope, not just name matching:
|
|
4379
|
+
// local file → explicit import binding → imported module → same directory →
|
|
4380
|
+
// (last resort) one name-only match at low confidence. A name imported from an
|
|
4381
|
+
// external package resolves to NO repo symbol — same-name repo symbols are not
|
|
4382
|
+
// the callee, and emitting them is how bogus cross-package edges were born.
|
|
4383
|
+
function resolveCallTargets(name, path, targets, context, confidences) {
|
|
4384
|
+
const local = targets.filter((target) => target.path === path);
|
|
4385
|
+
if (local.length)
|
|
4386
|
+
return local.slice(0, 2).map((target) => ({ target, confidence: confidences.local }));
|
|
4387
|
+
if (context.importedNames.has(name)) {
|
|
4388
|
+
const toPath = context.importedNames.get(name) ?? null;
|
|
4389
|
+
if (toPath === null)
|
|
4390
|
+
return []; // imported from an external package
|
|
4391
|
+
return targets
|
|
4392
|
+
.filter((target) => target.path === toPath)
|
|
4393
|
+
.slice(0, 2)
|
|
4394
|
+
.map((target) => ({ target, confidence: confidences.imported }));
|
|
4395
|
+
}
|
|
4396
|
+
const viaImports = targets.filter((target) => context.importedPaths.has(target.path));
|
|
4397
|
+
if (viaImports.length)
|
|
4398
|
+
return viaImports.slice(0, 2).map((target) => ({ target, confidence: confidences.imported }));
|
|
4399
|
+
const sameDir = targets.filter((target) => context.dir ? target.path.startsWith(`${context.dir}/`) : !target.path.includes("/"));
|
|
4400
|
+
if (sameDir.length)
|
|
4401
|
+
return sameDir.slice(0, 1).map((target) => ({ target, confidence: confidences.sameDir }));
|
|
4402
|
+
return targets.slice(0, 1).map((target) => ({ target, confidence: confidences.nameOnly }));
|
|
4403
|
+
}
|
|
4404
|
+
function extractCalls(path, text, symbols, symbolByName, context = EMPTY_CALL_RESOLUTION) {
|
|
3926
4405
|
const sourceFile = sourceFileFor(path, text);
|
|
3927
4406
|
const calls = [];
|
|
3928
4407
|
const visit = (node) => {
|
|
@@ -3944,7 +4423,7 @@ function extractCalls(path, text, symbols, symbolByName) {
|
|
|
3944
4423
|
}
|
|
3945
4424
|
const line = lineForNode(sourceFile, node);
|
|
3946
4425
|
const caller = symbolAtLine(symbols, path, line);
|
|
3947
|
-
for (const target of targets.
|
|
4426
|
+
for (const { target, confidence } of resolveCallTargets(name, path, targets, context, { local: 0.9, imported: 0.85, sameDir: 0.6, nameOnly: 0.35 })) {
|
|
3948
4427
|
if (calls.length >= MAX_CODE_GRAPH_CALLS_PER_FILE)
|
|
3949
4428
|
break;
|
|
3950
4429
|
if (target.path === path && target.line === line)
|
|
@@ -3954,7 +4433,7 @@ function extractCalls(path, text, symbols, symbolByName) {
|
|
|
3954
4433
|
to_symbol: target.id,
|
|
3955
4434
|
path,
|
|
3956
4435
|
line,
|
|
3957
|
-
confidence
|
|
4436
|
+
confidence,
|
|
3958
4437
|
resolution: "typescript_ast_name",
|
|
3959
4438
|
});
|
|
3960
4439
|
}
|
|
@@ -3977,7 +4456,7 @@ const GENERIC_CALL_STOP_WORDS = new Set([
|
|
|
3977
4456
|
"switch",
|
|
3978
4457
|
"while",
|
|
3979
4458
|
]);
|
|
3980
|
-
function extractGenericCalls(path, text, symbols, symbolByName) {
|
|
4459
|
+
function extractGenericCalls(path, text, symbols, symbolByName, context = EMPTY_CALL_RESOLUTION) {
|
|
3981
4460
|
const calls = [];
|
|
3982
4461
|
const lines = text.split(/\r?\n/);
|
|
3983
4462
|
for (let index = 0; index < lines.length && calls.length < MAX_CODE_GRAPH_CALLS_PER_FILE; index += 1) {
|
|
@@ -3995,7 +4474,7 @@ function extractGenericCalls(path, text, symbols, symbolByName) {
|
|
|
3995
4474
|
if (!targets?.length)
|
|
3996
4475
|
continue;
|
|
3997
4476
|
const caller = symbolAtLine(symbols, path, line);
|
|
3998
|
-
for (const target of targets.
|
|
4477
|
+
for (const { target, confidence } of resolveCallTargets(name, path, targets, context, { local: 0.7, imported: 0.65, sameDir: 0.5, nameOnly: 0.3 })) {
|
|
3999
4478
|
if (calls.length >= MAX_CODE_GRAPH_CALLS_PER_FILE)
|
|
4000
4479
|
break;
|
|
4001
4480
|
calls.push({
|
|
@@ -4003,7 +4482,7 @@ function extractGenericCalls(path, text, symbols, symbolByName) {
|
|
|
4003
4482
|
to_symbol: target.id,
|
|
4004
4483
|
path,
|
|
4005
4484
|
line,
|
|
4006
|
-
confidence
|
|
4485
|
+
confidence,
|
|
4007
4486
|
resolution: "generic_static_name",
|
|
4008
4487
|
});
|
|
4009
4488
|
}
|
|
@@ -4239,8 +4718,16 @@ function fileInputEntries(projectDir, paths, kind) {
|
|
|
4239
4718
|
sha256: sha256Hex((0, node_fs_1.readFileSync)(path)),
|
|
4240
4719
|
}));
|
|
4241
4720
|
}
|
|
4721
|
+
// Bump when call/route/test derivation logic changes so existing repos rebuild
|
|
4722
|
+
// their code graph on next index — otherwise builder fixes never reach users
|
|
4723
|
+
// whose files haven't changed.
|
|
4724
|
+
const CODE_GRAPH_BUILDER_VERSION = 3; // v3: tree-sitter calls for python/go/rust/java/ruby
|
|
4725
|
+
function codeGraphBuilderVersionEntry() {
|
|
4726
|
+
return { kind: "code_graph_builder", path: "kage", sha256: String(CODE_GRAPH_BUILDER_VERSION) };
|
|
4727
|
+
}
|
|
4242
4728
|
function codeGraphInputHash(projectDir, absoluteFiles = listCodeFiles(projectDir)) {
|
|
4243
4729
|
return graphInputHash([
|
|
4730
|
+
codeGraphBuilderVersionEntry(),
|
|
4244
4731
|
...fileInputEntries(projectDir, absoluteFiles, "code_file"),
|
|
4245
4732
|
...fileInputEntries(projectDir, externalIndexFiles(projectDir).map((index) => index.path), "external_code_index"),
|
|
4246
4733
|
]);
|
|
@@ -4250,6 +4737,7 @@ function codeGraphInputHashFromStructural(projectDir, structural) {
|
|
|
4250
4737
|
}
|
|
4251
4738
|
function codeGraphInputHashFromStructuralFingerprint(projectDir, fingerprint) {
|
|
4252
4739
|
return graphInputHash([
|
|
4740
|
+
codeGraphBuilderVersionEntry(),
|
|
4253
4741
|
{ kind: "code_graph_input", path: ".agent_memory/structural/fingerprint", sha256: fingerprint },
|
|
4254
4742
|
...fileInputEntries(projectDir, externalIndexFiles(projectDir).map((index) => index.path), "external_code_index"),
|
|
4255
4743
|
]);
|
|
@@ -4275,6 +4763,7 @@ function currentCodeGraphInputHash(projectDir) {
|
|
|
4275
4763
|
}
|
|
4276
4764
|
function codeGraphStructuralFingerprint(projectDir, structural) {
|
|
4277
4765
|
const entries = [
|
|
4766
|
+
`builder:${CODE_GRAPH_BUILDER_VERSION}`,
|
|
4278
4767
|
`structural:${structural.manifest.fingerprint}`,
|
|
4279
4768
|
...externalIndexFiles(projectDir)
|
|
4280
4769
|
.map((index) => index.path)
|
|
@@ -4730,6 +5219,11 @@ function buildCodeGraph(projectDir, options = {}) {
|
|
|
4730
5219
|
writeCodeIndexManifest(projectDir, codeIndexManifestFromStructural(projectDir, structural, fingerprint, structural.manifest.cache));
|
|
4731
5220
|
const externalFacts = loadExternalCodeFacts(projectDir);
|
|
4732
5221
|
const fileByPath = new Map(files.map((file) => [file.path, file]));
|
|
5222
|
+
for (const symbol of symbols) {
|
|
5223
|
+
const file = fileByPath.get(symbol.path);
|
|
5224
|
+
if (file)
|
|
5225
|
+
file.parser = strongerParser(file.parser, symbol.parser);
|
|
5226
|
+
}
|
|
4733
5227
|
const addSymbol = (symbol) => {
|
|
4734
5228
|
if (!fileByPath.has(symbol.path))
|
|
4735
5229
|
return;
|
|
@@ -4770,9 +5264,21 @@ function buildCodeGraph(projectDir, options = {}) {
|
|
|
4770
5264
|
break;
|
|
4771
5265
|
const fileSymbols = symbols.filter((symbol) => symbol.path === rel);
|
|
4772
5266
|
const fileImports = imports.filter((item) => item.from_path === rel);
|
|
5267
|
+
const importedNames = new Map();
|
|
5268
|
+
for (const item of fileImports) {
|
|
5269
|
+
for (const importedName of item.imported) {
|
|
5270
|
+
if (!importedNames.has(importedName))
|
|
5271
|
+
importedNames.set(importedName, item.to_path);
|
|
5272
|
+
}
|
|
5273
|
+
}
|
|
5274
|
+
const resolution = {
|
|
5275
|
+
importedPaths: new Set(fileImports.map((item) => item.to_path).filter((value) => Boolean(value))),
|
|
5276
|
+
importedNames,
|
|
5277
|
+
dir: rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "",
|
|
5278
|
+
};
|
|
4773
5279
|
const fileCalls = TS_AST_EXTENSIONS.has(extensionOf(rel))
|
|
4774
|
-
? extractCalls(rel, content, fileSymbols, symbolByName)
|
|
4775
|
-
: extractGenericCalls(rel, content, fileSymbols, symbolByName);
|
|
5280
|
+
? extractCalls(rel, content, fileSymbols, symbolByName, resolution)
|
|
5281
|
+
: extractTreeSitterCalls(rel, content, fileSymbols, symbolByName, resolution) ?? extractGenericCalls(rel, content, fileSymbols, symbolByName, resolution);
|
|
4776
5282
|
calls.push(...fileCalls.slice(0, Math.max(0, MAX_CODE_GRAPH_CALLS - calls.length)));
|
|
4777
5283
|
routes.push(...extractRoutes(rel, content, fileSymbols));
|
|
4778
5284
|
tests.push(...extractTests(rel, content, fileSymbols, fileImports));
|
|
@@ -5496,7 +6002,10 @@ function buildIndexes(projectDir) {
|
|
|
5496
6002
|
}
|
|
5497
6003
|
function indexProjectDetailed(projectDir, options = {}) {
|
|
5498
6004
|
ensureMemoryDirs(projectDir);
|
|
5499
|
-
|
|
6005
|
+
// Indexing must never write agent-policy files into the user's repo.
|
|
6006
|
+
// Policy installation is explicit: `kage policy`, `kage init --with-policy`,
|
|
6007
|
+
// or the kage_install_policy MCP tool.
|
|
6008
|
+
const existingPolicyPath = (0, node_path_1.join)(projectDir, "AGENTS.md");
|
|
5500
6009
|
const migrated = migrateLegacyMarkdown(projectDir);
|
|
5501
6010
|
const overview = createRepoOverviewPacket(projectDir);
|
|
5502
6011
|
if (overview)
|
|
@@ -5512,7 +6021,7 @@ function indexProjectDetailed(projectDir, options = {}) {
|
|
|
5512
6021
|
packets: loadPacketsFromDir(packetsDir(projectDir)).length,
|
|
5513
6022
|
migrated,
|
|
5514
6023
|
indexes: indexes.map((path) => (0, node_path_1.relative)(projectDir, path)),
|
|
5515
|
-
policyPath: (0, node_path_1.relative)(projectDir,
|
|
6024
|
+
policyPath: (0, node_fs_1.existsSync)(existingPolicyPath) ? (0, node_path_1.relative)(projectDir, existingPolicyPath) : undefined,
|
|
5516
6025
|
},
|
|
5517
6026
|
codeGraph: built?.codeGraph,
|
|
5518
6027
|
knowledgeGraph: built?.knowledgeGraph,
|
|
@@ -6728,8 +7237,17 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
|
|
|
6728
7237
|
}))
|
|
6729
7238
|
: undefined,
|
|
6730
7239
|
};
|
|
6731
|
-
|
|
7240
|
+
result.value_receipt = {
|
|
7241
|
+
tokens_saved: scored.length ? recallTokensSaved(projectDir, result.results, result.context_block) : 0,
|
|
7242
|
+
stale_withheld: suppressed.length,
|
|
7243
|
+
};
|
|
7244
|
+
if (inputs.trackAccess !== false) {
|
|
6732
7245
|
recordRecallAccess(projectDir, result.results);
|
|
7246
|
+
recordValueEvents(projectDir, [
|
|
7247
|
+
...suppressed.map((entry) => ({ kind: "stale_withheld", packet_title: entry.title })),
|
|
7248
|
+
...(scored.length ? [{ kind: "recall_served", tokens_saved: result.value_receipt.tokens_saved }] : []),
|
|
7249
|
+
]);
|
|
7250
|
+
}
|
|
6733
7251
|
return result;
|
|
6734
7252
|
}
|
|
6735
7253
|
function recall(projectDir, query, limit = 5, explain = false, inputs = {}) {
|
|
@@ -6782,20 +7300,35 @@ function boostTermScore(boost, term) {
|
|
|
6782
7300
|
function queryCodeGraph(projectDir, query, limit = 10, graph) {
|
|
6783
7301
|
graph = graph ?? readCurrentCodeGraph(projectDir) ?? buildCodeGraph(projectDir);
|
|
6784
7302
|
const terms = tokenize(query);
|
|
7303
|
+
// Implementation queries must rank core source above tests/examples/benchmarks.
|
|
7304
|
+
// Without this, path-term matches let `test/` and `examples/` swamp `lib/` —
|
|
7305
|
+
// the exact inversion that made code-graph answers lose to grep.
|
|
7306
|
+
const testIntent = terms.some((term) => ["test", "tests", "spec", "coverage", "fixture"].includes(term));
|
|
7307
|
+
const exampleIntent = terms.some((term) => ["example", "examples", "sample", "demo"].includes(term));
|
|
7308
|
+
const pathKindWeight = (path) => {
|
|
7309
|
+
const kind = codeFileKind(path);
|
|
7310
|
+
if (kind === "test")
|
|
7311
|
+
return testIntent ? 1.15 : 0.45;
|
|
7312
|
+
if (/(^|\/)(examples?|samples?|demos?|fixtures?|benchmarks?)\//.test(path))
|
|
7313
|
+
return exampleIntent ? 1.1 : 0.5;
|
|
7314
|
+
if (kind === "source")
|
|
7315
|
+
return 1.0;
|
|
7316
|
+
return 0.85; // config/manifest/doc: useful, but below implementation
|
|
7317
|
+
};
|
|
6785
7318
|
const files = graph.files
|
|
6786
|
-
.map((file) => ({ file, score: scoreText(terms, `${file.path} ${file.kind} ${file.language} ${file.parser}`, [file.path, file.language]) }))
|
|
7319
|
+
.map((file) => ({ file, score: scoreText(terms, `${file.path} ${file.kind} ${file.language} ${file.parser}`, [file.path, file.language]) * pathKindWeight(file.path) }))
|
|
6787
7320
|
.filter((entry) => entry.score > 0)
|
|
6788
7321
|
.sort((a, b) => b.score - a.score || a.file.path.localeCompare(b.file.path))
|
|
6789
7322
|
.slice(0, limit)
|
|
6790
7323
|
.map((entry) => entry.file);
|
|
6791
7324
|
const symbols = graph.symbols
|
|
6792
|
-
.map((symbol) => ({ symbol, score: scoreText(terms, `${symbol.name} ${symbol.kind} ${symbol.path} ${symbol.language} ${symbol.signature}`, [symbol.name, symbol.path]) }))
|
|
7325
|
+
.map((symbol) => ({ symbol, score: scoreText(terms, `${symbol.name} ${symbol.kind} ${symbol.path} ${symbol.language} ${symbol.signature}`, [symbol.name, symbol.path]) * pathKindWeight(symbol.path) }))
|
|
6793
7326
|
.filter((entry) => entry.score > 0)
|
|
6794
7327
|
.sort((a, b) => b.score - a.score || a.symbol.path.localeCompare(b.symbol.path) || a.symbol.line - b.symbol.line)
|
|
6795
7328
|
.slice(0, limit)
|
|
6796
7329
|
.map((entry) => entry.symbol);
|
|
6797
7330
|
const routes = graph.routes
|
|
6798
|
-
.map((route) => ({ route, score: scoreText(terms, `route routes endpoint api handler ${route.method} ${route.path} ${route.file_path} ${route.framework}`, [route.path, route.file_path]) }))
|
|
7331
|
+
.map((route) => ({ route, score: scoreText(terms, `route routes endpoint api handler ${route.method} ${route.path} ${route.file_path} ${route.framework}`, [route.path, route.file_path]) * pathKindWeight(route.file_path) }))
|
|
6799
7332
|
.filter((entry) => entry.score > 0)
|
|
6800
7333
|
.sort((a, b) => b.score - a.score || a.route.path.localeCompare(b.route.path))
|
|
6801
7334
|
.slice(0, limit)
|
|
@@ -6822,9 +7355,57 @@ function queryCodeGraph(projectDir, query, limit = 10, graph) {
|
|
|
6822
7355
|
.slice(0, limit);
|
|
6823
7356
|
const symbolIds = new Set(symbols.map((symbol) => symbol.id));
|
|
6824
7357
|
const symbolNameById = new Map(graph.symbols.map((symbol) => [symbol.id, `${symbol.name} (${symbol.path}:${symbol.line})`]));
|
|
6825
|
-
|
|
6826
|
-
|
|
6827
|
-
|
|
7358
|
+
// Caller-intent queries ("who calls X", "which functions call X", "usages of X")
|
|
7359
|
+
// must be answered from the call-edge index — the one question a call graph is
|
|
7360
|
+
// uniquely qualified to answer. Keyword scoring alone returns definitions instead.
|
|
7361
|
+
const callerIntent = /\b(?:who|what|which)\b[^?]*\bcalls?\b|\bcallers?\s+(?:of|for)\b|\bcall\s*sites?\b|\busages?\s+of\b|\bwhere\s+is\b.+\b(?:called|invoked|used)\b/i.test(query);
|
|
7362
|
+
const intentStopwords = new Set(["call", "calls", "called", "caller", "callers", "calling", "invoked", "invoke", "usage", "usages", "site", "sites", "who", "what", "which", "where", "function", "functions", "method", "methods", "file", "files", "of", "for", "is", "the", "in", "are", "do", "does"]);
|
|
7363
|
+
let callerTargets = [];
|
|
7364
|
+
if (callerIntent) {
|
|
7365
|
+
const byName = new Map();
|
|
7366
|
+
for (const symbol of graph.symbols) {
|
|
7367
|
+
const key = symbol.name.toLowerCase();
|
|
7368
|
+
const bucket = byName.get(key);
|
|
7369
|
+
if (bucket)
|
|
7370
|
+
bucket.push(symbol);
|
|
7371
|
+
else
|
|
7372
|
+
byName.set(key, [symbol]);
|
|
7373
|
+
}
|
|
7374
|
+
const rawWords = query.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? [];
|
|
7375
|
+
const seen = new Set();
|
|
7376
|
+
for (const word of rawWords) {
|
|
7377
|
+
const key = word.toLowerCase();
|
|
7378
|
+
if (seen.has(key) || intentStopwords.has(key))
|
|
7379
|
+
continue;
|
|
7380
|
+
const matches = byName.get(key);
|
|
7381
|
+
if (!matches)
|
|
7382
|
+
continue;
|
|
7383
|
+
seen.add(key);
|
|
7384
|
+
callerTargets.push(...matches.slice(0, 4));
|
|
7385
|
+
}
|
|
7386
|
+
callerTargets = callerTargets.slice(0, 8);
|
|
7387
|
+
}
|
|
7388
|
+
const callerTargetIds = new Set(callerTargets.map((symbol) => symbol.id));
|
|
7389
|
+
const callerTargetNames = new Set(callerTargets.map((symbol) => symbol.name.toLowerCase()));
|
|
7390
|
+
// Edges below 0.5 are name-only guesses; presenting them as callers destroys
|
|
7391
|
+
// trust in the one answer a call graph exists to give.
|
|
7392
|
+
const callerEdges = callerTargetIds.size
|
|
7393
|
+
? graph.calls
|
|
7394
|
+
.filter((call) => call.confidence >= 0.5)
|
|
7395
|
+
.filter((call) => callerTargetIds.has(call.to_symbol) || callerTargetNames.has((symbolNameById.get(call.to_symbol) ?? call.to_symbol).split(" ")[0]?.toLowerCase() ?? ""))
|
|
7396
|
+
.slice(0, 20)
|
|
7397
|
+
: [];
|
|
7398
|
+
const calls = callerEdges.length
|
|
7399
|
+
? callerEdges
|
|
7400
|
+
: graph.calls
|
|
7401
|
+
.filter((call) => call.confidence >= 0.5)
|
|
7402
|
+
.filter((call) => symbolIds.has(call.to_symbol) || Boolean(call.from_symbol && symbolIds.has(call.from_symbol)))
|
|
7403
|
+
.slice(0, limit);
|
|
7404
|
+
// Value ledger: a caller-intent query answered from the call-edge index is a
|
|
7405
|
+
// grep/agent round-trip the user did not pay for. Only repos that already opted
|
|
7406
|
+
// into Kage memory get the write — a bare code-graph query stays read-only.
|
|
7407
|
+
if (callerEdges.length && (0, node_fs_1.existsSync)(memoryRoot(projectDir)))
|
|
7408
|
+
recordValueEvent(projectDir, { kind: "caller_answered" });
|
|
6828
7409
|
const structuralIndex = readCurrentStructuralIndex(projectDir);
|
|
6829
7410
|
const graphPaths = new Set(graph.files.map((file) => file.path));
|
|
6830
7411
|
const graphSymbolIds = new Set(graph.symbols.map((symbol) => symbol.id));
|
|
@@ -6843,7 +7424,7 @@ function queryCodeGraph(projectDir, query, limit = 10, graph) {
|
|
|
6843
7424
|
? structuralIndex.symbols
|
|
6844
7425
|
.map((symbol) => ({
|
|
6845
7426
|
symbol,
|
|
6846
|
-
score: scoreText(terms, `${symbol.name} ${symbol.kind} ${symbol.path} ${symbol.language} ${symbol.parser}`, [symbol.name, symbol.path]),
|
|
7427
|
+
score: scoreText(terms, `${symbol.name} ${symbol.kind} ${symbol.path} ${symbol.language} ${symbol.parser}`, [symbol.name, symbol.path]) * pathKindWeight(symbol.path),
|
|
6847
7428
|
}))
|
|
6848
7429
|
.filter((entry) => entry.score > 0 && !graphSymbolIds.has(entry.symbol.id))
|
|
6849
7430
|
.sort((a, b) => b.score - a.score || a.symbol.path.localeCompare(b.symbol.path) || a.symbol.line - b.symbol.line)
|
|
@@ -6870,25 +7451,39 @@ function queryCodeGraph(projectDir, query, limit = 10, graph) {
|
|
|
6870
7451
|
"",
|
|
6871
7452
|
`Query: ${query}`,
|
|
6872
7453
|
"",
|
|
7454
|
+
...(callerEdges.length
|
|
7455
|
+
? [
|
|
7456
|
+
"## Callers (from the call-edge index)",
|
|
7457
|
+
...callerTargets.map((symbol) => `target: ${symbol.kind} ${symbol.name} defined in ${symbol.path}:${symbol.line}`),
|
|
7458
|
+
...callerEdges.map((call, index) => `${index + 1}. ${call.from_symbol ? symbolNameById.get(call.from_symbol) ?? call.from_symbol : call.path} calls ${symbolNameById.get(call.to_symbol) ?? call.to_symbol} at ${call.path}:${call.line}`),
|
|
7459
|
+
"",
|
|
7460
|
+
]
|
|
7461
|
+
: []),
|
|
6873
7462
|
files.length || symbols.length || routes.length || tests.length ? "## Code Facts" : "No related source-derived code facts found.",
|
|
6874
|
-
|
|
7463
|
+
// Compaction: when symbol hits are strong, supporting facts (routes/tests/files/
|
|
7464
|
+
// imports) shrink to a few lines each. The block is agent fuel — every line that
|
|
7465
|
+
// doesn't help locate the answer is tokens the agent pays for nothing.
|
|
6875
7466
|
...symbols.map((symbol, index) => `${index + 1}. [symbol] ${symbol.kind} ${symbol.name} in ${symbol.path}:${symbol.line} (${symbol.language}, ${symbol.parser})`),
|
|
6876
|
-
...
|
|
6877
|
-
...
|
|
6878
|
-
|
|
6879
|
-
structuralFiles.length || structuralSymbols.length
|
|
6880
|
-
|
|
6881
|
-
...
|
|
7467
|
+
...routes.slice(0, symbols.length >= 3 ? 3 : limit).map((route, index) => `${index + 1}. [route] ${route.method} ${route.path} in ${route.file_path}:${route.line}`),
|
|
7468
|
+
...tests.slice(0, symbols.length >= 3 ? 2 : limit).map((test, index) => `${index + 1}. [test] ${test.title} in ${test.test_path}:${test.line}${test.covers_symbol ? ` covers ${test.covers_symbol}` : ""}`),
|
|
7469
|
+
...files.slice(0, 3).map((file, index) => `${index + 1}. [file] ${file.path} (${file.kind}, ${file.language}, ${file.parser})`),
|
|
7470
|
+
structuralFiles.length || structuralSymbols.length ? "" : "",
|
|
7471
|
+
structuralFiles.length || structuralSymbols.length ? "## Structural Index" : "",
|
|
7472
|
+
...structuralSymbols.slice(0, symbols.length >= 3 ? 3 : limit).map((symbol, index) => `${index + 1}. [structural symbol] ${symbol.kind} ${symbol.name} in ${symbol.path}:${symbol.line} (${symbol.language}, ${symbol.parser})`),
|
|
7473
|
+
...structuralFiles.slice(0, 3).map((file, index) => `${index + 1}. [structural file] ${file.path} (${file.kind}, ${file.language}, ${file.extraction})`),
|
|
6882
7474
|
...structuralEdges
|
|
6883
7475
|
.filter((edge) => edge.relation === "imports")
|
|
6884
|
-
.slice(0, 5)
|
|
7476
|
+
.slice(0, symbols.length >= 3 ? 2 : 5)
|
|
6885
7477
|
.map((edge, index) => `${index + 1}. [structural import] ${edge.source_file}${edge.source_location ? `:${edge.source_location.replace(/^L/, "")}` : ""} -> ${edge.target} (${edge.confidence})`),
|
|
6886
7478
|
imports.length ? "" : "",
|
|
6887
7479
|
imports.length ? "## Imports" : "",
|
|
6888
|
-
...imports.map(({ item }, index) => `${index + 1}. ${item.from_path}:${item.line} ${item.kind} ${item.specifier}${item.to_path ? ` -> ${item.to_path}` : ""}`),
|
|
6889
|
-
|
|
6890
|
-
calls.length ? "
|
|
6891
|
-
|
|
7480
|
+
...imports.slice(0, symbols.length >= 3 ? 3 : limit).map(({ item }, index) => `${index + 1}. ${item.from_path}:${item.line} ${item.kind} ${item.specifier}${item.to_path ? ` -> ${item.to_path}` : ""}`),
|
|
7481
|
+
// When caller intent was answered above, don't repeat the same edges here.
|
|
7482
|
+
calls.length && !callerEdges.length ? "" : "",
|
|
7483
|
+
calls.length && !callerEdges.length ? "## Calls" : "",
|
|
7484
|
+
...(callerEdges.length
|
|
7485
|
+
? []
|
|
7486
|
+
: calls.map((call, index) => `${index + 1}. ${call.from_symbol ? symbolNameById.get(call.from_symbol) ?? call.from_symbol : call.path} calls ${symbolNameById.get(call.to_symbol) ?? call.to_symbol} at ${call.path}:${call.line} (${call.resolution}, confidence ${call.confidence.toFixed(2)})`)),
|
|
6892
7487
|
];
|
|
6893
7488
|
return {
|
|
6894
7489
|
query,
|
|
@@ -7703,6 +8298,377 @@ function kageCleanupCandidates(projectDir) {
|
|
|
7703
8298
|
summary: `${candidates.length} conservative cleanup candidate(s), ${skippedEntryPoints.length} entrypoint-like source file(s) skipped, ${skippedRuntimeReferences.length} runtime reference(s) skipped.`,
|
|
7704
8299
|
};
|
|
7705
8300
|
}
|
|
8301
|
+
const TRUTH_REPORT_MAX_FINDINGS = 12;
|
|
8302
|
+
const TRUTH_REPORT_AI_ERA_DAYS = 120;
|
|
8303
|
+
// Symbol names too generic to mean "two teams built the same thing".
|
|
8304
|
+
const TRUTH_COMMON_SYMBOL_NAMES = new Set([
|
|
8305
|
+
"main", "init", "run", "setup", "start", "stop", "open", "close", "create", "destroy",
|
|
8306
|
+
"constructor", "tostring", "render", "handler", "handle", "execute", "default", "index",
|
|
8307
|
+
"build", "parse", "load", "save", "update", "delete", "remove", "test", "validate",
|
|
8308
|
+
"format", "process", "next", "send", "write", "read", "config", "helper", "util",
|
|
8309
|
+
]);
|
|
8310
|
+
function truthExcludedPath(path) {
|
|
8311
|
+
return /(^|\/)(tests?|__tests__|specs?|examples?|fixtures?|benchmarks?|mocks?|__mocks__|vendor|node_modules|dist|build)\//i.test(path)
|
|
8312
|
+
|| /\.(test|spec)\.[^.]+$/i.test(path)
|
|
8313
|
+
|| /(^|\/)test[^/]*\.[^.]+$/i.test(path);
|
|
8314
|
+
}
|
|
8315
|
+
const TRUTH_DOC_PATH_EXTENSIONS = "ts|tsx|js|jsx|mjs|cjs|json|md|yml|yaml|toml|py|rb|go|rs|java|kt|sh|bash|css|scss|html|sql|proto|graphql|c|h|cpp|hpp|cs|txt";
|
|
8316
|
+
function truthDocPathCandidates(line) {
|
|
8317
|
+
const candidates = new Set();
|
|
8318
|
+
for (const match of line.matchAll(/`([^`\n]+)`/g)) {
|
|
8319
|
+
const token = match[1].trim();
|
|
8320
|
+
if (new RegExp(`^(?:\\./)?[\\w.-]+(?:/[\\w.-]+)+\\.(?:${TRUTH_DOC_PATH_EXTENSIONS})$`).test(token))
|
|
8321
|
+
candidates.add(token.replace(/^\.\//, ""));
|
|
8322
|
+
}
|
|
8323
|
+
for (const match of line.matchAll(new RegExp(`(?:^|[\\s("'\\[])((?:\\./)?[\\w.-]+(?:/[\\w.-]+)+\\.(?:${TRUTH_DOC_PATH_EXTENSIONS}))(?=$|[\\s)"'\\],.:;])`, "g"))) {
|
|
8324
|
+
candidates.add(match[1].replace(/^\.\//, ""));
|
|
8325
|
+
}
|
|
8326
|
+
return [...candidates].filter((candidate) => !/[*<>{}$\\]/.test(candidate)
|
|
8327
|
+
&& !candidate.startsWith("http")
|
|
8328
|
+
&& !candidate.includes("node_modules")
|
|
8329
|
+
&& !candidate.startsWith(".agent_memory"));
|
|
8330
|
+
}
|
|
8331
|
+
function truthReport(projectDir) {
|
|
8332
|
+
const graph = readCurrentCodeGraph(projectDir) ?? buildCodeGraph(projectDir);
|
|
8333
|
+
const warnings = [];
|
|
8334
|
+
const fileByPath = new Map(graph.files.map((file) => [file.path, file]));
|
|
8335
|
+
const sourceFiles = graph.files.filter((file) => file.kind === "source" && !truthExcludedPath(file.path));
|
|
8336
|
+
// Single git pass: per-file distinct authors, total commit count, newest commit epoch.
|
|
8337
|
+
const hasGit = Boolean(gitHead(projectDir));
|
|
8338
|
+
if (!hasGit)
|
|
8339
|
+
warnings.push("Git history is unavailable; bus-factor, churn, and recency signals are skipped.");
|
|
8340
|
+
const shallowGit = hasGit && readGit(projectDir, ["rev-parse", "--is-shallow-repository"]) === "true";
|
|
8341
|
+
if (shallowGit)
|
|
8342
|
+
warnings.push("Git history is shallow (partial clone); churn, bus-factor, and recency findings undercount reality.");
|
|
8343
|
+
const fileAuthors = new Map();
|
|
8344
|
+
const fileCommits = new Map();
|
|
8345
|
+
const fileNewestEpoch = new Map();
|
|
8346
|
+
if (hasGit) {
|
|
8347
|
+
const raw = readGit(projectDir, ["log", "--no-renames", "--format=__KAGE_SCAN__%x1f%ae%x1f%ct", "--name-only"]) ?? "";
|
|
8348
|
+
// Resolve the repo->project prefix once; gitPathToProjectRelative spawns git per call,
|
|
8349
|
+
// which is far too slow for a full-history name-only walk.
|
|
8350
|
+
const projectPrefix = readGit(projectDir, ["rev-parse", "--show-prefix"])?.replace(/\\/g, "/").replace(/\/+$/, "") ?? "";
|
|
8351
|
+
let author = "";
|
|
8352
|
+
let epoch = 0;
|
|
8353
|
+
for (const rawLine of raw.split(/\r?\n/)) {
|
|
8354
|
+
const line = rawLine.trim();
|
|
8355
|
+
if (!line)
|
|
8356
|
+
continue;
|
|
8357
|
+
if (line.startsWith("__KAGE_SCAN__")) {
|
|
8358
|
+
const parts = line.split("\x1f");
|
|
8359
|
+
author = (parts[1] ?? "").toLowerCase();
|
|
8360
|
+
epoch = Number(parts[2] ?? 0) || 0;
|
|
8361
|
+
continue;
|
|
8362
|
+
}
|
|
8363
|
+
if (!author)
|
|
8364
|
+
continue;
|
|
8365
|
+
const normalized = line.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
8366
|
+
const path = projectPrefix && normalized.startsWith(`${projectPrefix}/`) ? normalized.slice(projectPrefix.length + 1) : normalized;
|
|
8367
|
+
if (!fileByPath.has(path))
|
|
8368
|
+
continue;
|
|
8369
|
+
const authors = fileAuthors.get(path) ?? new Set();
|
|
8370
|
+
authors.add(author);
|
|
8371
|
+
fileAuthors.set(path, authors);
|
|
8372
|
+
fileCommits.set(path, (fileCommits.get(path) ?? 0) + 1);
|
|
8373
|
+
// git log is newest-first, so the first sighting is the newest commit touching the file.
|
|
8374
|
+
if (!fileNewestEpoch.has(path))
|
|
8375
|
+
fileNewestEpoch.set(path, epoch);
|
|
8376
|
+
}
|
|
8377
|
+
}
|
|
8378
|
+
// Centrality: import + call edges touching the file.
|
|
8379
|
+
const centrality = new Map();
|
|
8380
|
+
const bumpCentrality = (path) => {
|
|
8381
|
+
if (!path || !fileByPath.has(path))
|
|
8382
|
+
return;
|
|
8383
|
+
centrality.set(path, (centrality.get(path) ?? 0) + 1);
|
|
8384
|
+
};
|
|
8385
|
+
for (const edge of graph.imports) {
|
|
8386
|
+
bumpCentrality(edge.from_path);
|
|
8387
|
+
bumpCentrality(edge.to_path);
|
|
8388
|
+
}
|
|
8389
|
+
for (const call of graph.calls)
|
|
8390
|
+
bumpCentrality(call.path);
|
|
8391
|
+
const nowEpoch = Math.floor(Date.now() / 1000);
|
|
8392
|
+
const aiEraCutoff = nowEpoch - TRUTH_REPORT_AI_ERA_DAYS * 24 * 3600;
|
|
8393
|
+
// 1a. Duplicate implementations: same-name symbols spread across directories.
|
|
8394
|
+
const symbolsByName = new Map();
|
|
8395
|
+
for (const symbol of graph.symbols) {
|
|
8396
|
+
if (!["function", "class", "method"].includes(symbol.kind))
|
|
8397
|
+
continue;
|
|
8398
|
+
if (symbol.name.length < 4 || TRUTH_COMMON_SYMBOL_NAMES.has(symbol.name.toLowerCase()))
|
|
8399
|
+
continue;
|
|
8400
|
+
if (truthExcludedPath(symbol.path) || fileByPath.get(symbol.path)?.kind !== "source")
|
|
8401
|
+
continue;
|
|
8402
|
+
const list = symbolsByName.get(symbol.name.toLowerCase()) ?? [];
|
|
8403
|
+
list.push(symbol);
|
|
8404
|
+
symbolsByName.set(symbol.name.toLowerCase(), list);
|
|
8405
|
+
}
|
|
8406
|
+
const duplicateFindings = [];
|
|
8407
|
+
for (const [, members] of symbolsByName) {
|
|
8408
|
+
const dirs = new Set(members.map((member) => (0, node_path_1.dirname)(member.path)));
|
|
8409
|
+
const paths = new Set(members.map((member) => member.path));
|
|
8410
|
+
if (dirs.size < 2 || paths.size < 2)
|
|
8411
|
+
continue;
|
|
8412
|
+
const signatureCounts = new Map();
|
|
8413
|
+
for (const member of members) {
|
|
8414
|
+
const normalized = member.signature.replace(/\s+/g, "");
|
|
8415
|
+
signatureCounts.set(normalized, (signatureCounts.get(normalized) ?? 0) + 1);
|
|
8416
|
+
}
|
|
8417
|
+
const signatureMatch = [...signatureCounts.values()].some((count) => count >= 2);
|
|
8418
|
+
const newestEpoch = Math.max(0, ...members.map((member) => fileNewestEpoch.get(member.path) ?? 0));
|
|
8419
|
+
const recent = hasGit && newestEpoch > aiEraCutoff;
|
|
8420
|
+
duplicateFindings.push({
|
|
8421
|
+
kind: "duplicate_cluster",
|
|
8422
|
+
title: `${members[0].name} — ${paths.size} implementations across ${dirs.size} directories${recent ? " [recent, likely AI-era]" : ""}`,
|
|
8423
|
+
detail: signatureMatch
|
|
8424
|
+
? "Same name AND near-identical signature: almost certainly parallel implementations of the same idea."
|
|
8425
|
+
: "Same name in unrelated directories: agents and humans may be solving the same problem twice.",
|
|
8426
|
+
evidence: members.slice(0, 5).map((member) => `${member.path}:${member.line} ${member.kind} ${member.signature.slice(0, 80)}`),
|
|
8427
|
+
surprise: Math.min(100, 45 + paths.size * 8 + (signatureMatch ? 15 : 0) + (recent ? 20 : 0)),
|
|
8428
|
+
});
|
|
8429
|
+
}
|
|
8430
|
+
duplicateFindings.sort((a, b) => b.surprise - a.surprise || a.title.localeCompare(b.title));
|
|
8431
|
+
// 1b. Ghost knowledge: exported symbols nothing in this repo calls or imports by name.
|
|
8432
|
+
const referenced = new Set();
|
|
8433
|
+
for (const call of graph.calls)
|
|
8434
|
+
referenced.add(call.to_symbol);
|
|
8435
|
+
for (const edge of graph.imports)
|
|
8436
|
+
for (const name of edge.imported)
|
|
8437
|
+
referenced.add(name);
|
|
8438
|
+
for (const route of graph.routes)
|
|
8439
|
+
if (route.handler_symbol)
|
|
8440
|
+
referenced.add(route.handler_symbol);
|
|
8441
|
+
for (const test of graph.tests)
|
|
8442
|
+
if (test.covers_symbol)
|
|
8443
|
+
referenced.add(test.covers_symbol);
|
|
8444
|
+
const ghostCandidates = [];
|
|
8445
|
+
for (const symbol of graph.symbols) {
|
|
8446
|
+
if (!symbol.export || !["function", "class", "method"].includes(symbol.kind))
|
|
8447
|
+
continue;
|
|
8448
|
+
if (truthExcludedPath(symbol.path) || isEntrypointLike(symbol.path))
|
|
8449
|
+
continue;
|
|
8450
|
+
if (fileByPath.get(symbol.path)?.kind !== "source")
|
|
8451
|
+
continue;
|
|
8452
|
+
if (referenced.has(symbol.id) || referenced.has(symbol.name))
|
|
8453
|
+
continue;
|
|
8454
|
+
ghostCandidates.push(symbol);
|
|
8455
|
+
}
|
|
8456
|
+
// Graph edges miss dynamic/property references, so a ghost claim must survive a raw-text
|
|
8457
|
+
// check: the name may appear nowhere in the repo outside its own file.
|
|
8458
|
+
const truthTextCache = new Map();
|
|
8459
|
+
const truthFileText = (path) => {
|
|
8460
|
+
const cached = truthTextCache.get(path);
|
|
8461
|
+
if (cached !== undefined)
|
|
8462
|
+
return cached;
|
|
8463
|
+
const file = fileByPath.get(path);
|
|
8464
|
+
const text = file && file.size_bytes <= 512 * 1024 ? safeReadText((0, node_path_1.join)(projectDir, path)) ?? "" : "";
|
|
8465
|
+
truthTextCache.set(path, text);
|
|
8466
|
+
return text;
|
|
8467
|
+
};
|
|
8468
|
+
const ghostFindings = ghostCandidates.slice(0, 60).flatMap((symbol) => {
|
|
8469
|
+
const namePattern = new RegExp(`(^|[^A-Za-z0-9_$])${escapeRegex(symbol.name)}([^A-Za-z0-9_$]|$)`);
|
|
8470
|
+
const mentionedElsewhere = graph.files.some((file) => file.path !== symbol.path
|
|
8471
|
+
&& (file.kind === "source" || file.kind === "test")
|
|
8472
|
+
&& namePattern.test(truthFileText(file.path)));
|
|
8473
|
+
if (mentionedElsewhere)
|
|
8474
|
+
return [];
|
|
8475
|
+
const fileCentrality = centrality.get(symbol.path) ?? 0;
|
|
8476
|
+
return [{
|
|
8477
|
+
kind: "ghost_export",
|
|
8478
|
+
title: `${symbol.name} — exported, never called`,
|
|
8479
|
+
detail: "No call edge, no import, and the name appears in no other file. Dead code, or knowledge nobody wired in.",
|
|
8480
|
+
evidence: [`${symbol.path}:${symbol.line} ${symbol.kind} ${symbol.signature.slice(0, 80)}`],
|
|
8481
|
+
surprise: Math.min(100, 35 + Math.min(30, fileCentrality)),
|
|
8482
|
+
}];
|
|
8483
|
+
});
|
|
8484
|
+
ghostFindings.sort((a, b) => b.surprise - a.surprise || a.title.localeCompare(b.title));
|
|
8485
|
+
// 1c. Knowledge concentration: single-author files ranked by graph centrality.
|
|
8486
|
+
const busFindings = [];
|
|
8487
|
+
let singleAuthorCount = 0;
|
|
8488
|
+
if (hasGit) {
|
|
8489
|
+
for (const file of sourceFiles) {
|
|
8490
|
+
const authors = fileAuthors.get(file.path);
|
|
8491
|
+
if (!authors || authors.size !== 1)
|
|
8492
|
+
continue;
|
|
8493
|
+
singleAuthorCount += 1;
|
|
8494
|
+
const commits = fileCommits.get(file.path) ?? 0;
|
|
8495
|
+
const fileCentrality = centrality.get(file.path) ?? 0;
|
|
8496
|
+
if (commits < 2 || fileCentrality < 1)
|
|
8497
|
+
continue;
|
|
8498
|
+
busFindings.push({
|
|
8499
|
+
kind: "bus_factor",
|
|
8500
|
+
title: `${file.path} — bus factor 1`,
|
|
8501
|
+
detail: `Every one of ${commits} commit(s) came from ${[...authors][0]}. ${fileCentrality} graph edge(s) depend on a file only one person has ever touched.`,
|
|
8502
|
+
evidence: [`${file.path}:1 sole author ${[...authors][0]}, ${commits} commit(s), centrality ${fileCentrality}`],
|
|
8503
|
+
surprise: Math.min(100, 30 + Math.min(40, fileCentrality) + Math.min(20, commits)),
|
|
8504
|
+
});
|
|
8505
|
+
}
|
|
8506
|
+
if (!shallowGit && sourceFiles.length >= 5 && singleAuthorCount >= sourceFiles.length * 0.9) {
|
|
8507
|
+
warnings.push("Nearly every file has a single author; this looks like a solo-maintained repo, so bus-factor-1 is the baseline, not the exception.");
|
|
8508
|
+
}
|
|
8509
|
+
}
|
|
8510
|
+
busFindings.sort((a, b) => b.surprise - a.surprise || a.title.localeCompare(b.title));
|
|
8511
|
+
// 1d. Knowledge void: high churn x high centrality, zero memory packets, zero doc mentions.
|
|
8512
|
+
const packets = loadApprovedPackets(projectDir);
|
|
8513
|
+
const packetCovers = (path) => packets.some((packet) => packet.paths.some((cited) => {
|
|
8514
|
+
const normalized = cited.replace(/\/+$/, "");
|
|
8515
|
+
return normalized === path || path.startsWith(`${normalized}/`) || normalized.startsWith(`${path}/`);
|
|
8516
|
+
}));
|
|
8517
|
+
const docFiles = [];
|
|
8518
|
+
for (const candidate of ["README.md", "readme.md", "Readme.md"]) {
|
|
8519
|
+
if ((0, node_fs_1.existsSync)((0, node_path_1.join)(projectDir, candidate))) {
|
|
8520
|
+
docFiles.push(candidate);
|
|
8521
|
+
break;
|
|
8522
|
+
}
|
|
8523
|
+
}
|
|
8524
|
+
const docsDir = (0, node_path_1.join)(projectDir, "docs");
|
|
8525
|
+
if (safeStat(docsDir)?.isDirectory()) {
|
|
8526
|
+
for (const name of (0, node_fs_1.readdirSync)(docsDir).filter((entry) => entry.endsWith(".md")).sort().slice(0, 20)) {
|
|
8527
|
+
docFiles.push((0, node_path_1.join)("docs", name));
|
|
8528
|
+
}
|
|
8529
|
+
}
|
|
8530
|
+
const docLines = [];
|
|
8531
|
+
for (const doc of docFiles) {
|
|
8532
|
+
const text = safeReadText((0, node_path_1.join)(projectDir, doc));
|
|
8533
|
+
if (!text)
|
|
8534
|
+
continue;
|
|
8535
|
+
text.split(/\r?\n/).forEach((line, index) => docLines.push({ doc, line: index + 1, text: line }));
|
|
8536
|
+
}
|
|
8537
|
+
const docsText = docLines.map((entry) => entry.text).join("\n");
|
|
8538
|
+
const voidFindings = [];
|
|
8539
|
+
if (hasGit) {
|
|
8540
|
+
for (const file of sourceFiles) {
|
|
8541
|
+
const commits = fileCommits.get(file.path) ?? 0;
|
|
8542
|
+
const fileCentrality = centrality.get(file.path) ?? 0;
|
|
8543
|
+
if (commits < 5 || fileCentrality < 3)
|
|
8544
|
+
continue;
|
|
8545
|
+
if (packetCovers(file.path) || docsText.includes(file.path))
|
|
8546
|
+
continue;
|
|
8547
|
+
voidFindings.push({
|
|
8548
|
+
kind: "knowledge_void",
|
|
8549
|
+
title: `${file.path} — knowledge void`,
|
|
8550
|
+
detail: `${commits} commits of accumulated decisions, ${fileCentrality} graph edge(s) depending on it — and zero memory packets or doc mentions. Agents and new hires fly blind here.`,
|
|
8551
|
+
evidence: [`${file.path}:1 churn ${commits} x centrality ${fileCentrality}, memory packets citing it: 0`],
|
|
8552
|
+
surprise: Math.min(100, 25 + Math.min(45, Math.round(Math.sqrt(commits * fileCentrality))) + Math.min(20, fileCentrality)),
|
|
8553
|
+
});
|
|
8554
|
+
}
|
|
8555
|
+
}
|
|
8556
|
+
voidFindings.sort((a, b) => b.surprise - a.surprise || a.title.localeCompare(b.title));
|
|
8557
|
+
// 2. Doc-truth: checkable claims in README/docs vs reality.
|
|
8558
|
+
const docLieFindings = [];
|
|
8559
|
+
if (docLines.length) {
|
|
8560
|
+
const seenLies = new Set();
|
|
8561
|
+
const packageJsonText = safeReadText((0, node_path_1.join)(projectDir, "package.json"));
|
|
8562
|
+
let scripts = {};
|
|
8563
|
+
let binNames = [];
|
|
8564
|
+
if (packageJsonText) {
|
|
8565
|
+
try {
|
|
8566
|
+
const parsed = JSON.parse(packageJsonText);
|
|
8567
|
+
scripts = parsed.scripts ?? {};
|
|
8568
|
+
binNames = typeof parsed.bin === "string" ? [parsed.name ?? ""].filter(Boolean) : Object.keys(parsed.bin ?? {});
|
|
8569
|
+
}
|
|
8570
|
+
catch {
|
|
8571
|
+
warnings.push("package.json could not be parsed; npm-script doc checks skipped.");
|
|
8572
|
+
}
|
|
8573
|
+
}
|
|
8574
|
+
// CLI claims are only checkable when there is obvious CLI source to check them against.
|
|
8575
|
+
const cliSourceText = binNames.length
|
|
8576
|
+
? graph.files
|
|
8577
|
+
.filter((file) => file.kind === "source" && (/(^|\/)(cli|bin|commands?)[^/]*\.[^.]+$/i.test(file.path) || /(^|\/)(cli|bin|commands)\//i.test(file.path)))
|
|
8578
|
+
.slice(0, 30)
|
|
8579
|
+
.map((file) => safeReadText((0, node_path_1.join)(projectDir, file.path)) ?? "")
|
|
8580
|
+
.join("\n")
|
|
8581
|
+
: "";
|
|
8582
|
+
for (const entry of docLines) {
|
|
8583
|
+
for (const candidate of truthDocPathCandidates(entry.text)) {
|
|
8584
|
+
const key = `path:${candidate}`;
|
|
8585
|
+
if (seenLies.has(key) || (0, node_fs_1.existsSync)((0, node_path_1.join)(projectDir, candidate)))
|
|
8586
|
+
continue;
|
|
8587
|
+
seenLies.add(key);
|
|
8588
|
+
docLieFindings.push({
|
|
8589
|
+
kind: "doc_lie",
|
|
8590
|
+
title: `${entry.doc}:${entry.line} cites ${candidate} — reality: file does not exist`,
|
|
8591
|
+
detail: `"${entry.text.trim().slice(0, 100)}"`,
|
|
8592
|
+
evidence: [`${entry.doc}:${entry.line}`],
|
|
8593
|
+
surprise: 70,
|
|
8594
|
+
});
|
|
8595
|
+
}
|
|
8596
|
+
if (packageJsonText && Object.keys(scripts).length) {
|
|
8597
|
+
for (const match of entry.text.matchAll(/\bnpm run ([A-Za-z0-9:_-]+)/g)) {
|
|
8598
|
+
const script = match[1];
|
|
8599
|
+
const key = `script:${script}`;
|
|
8600
|
+
if (seenLies.has(key) || scripts[script])
|
|
8601
|
+
continue;
|
|
8602
|
+
seenLies.add(key);
|
|
8603
|
+
docLieFindings.push({
|
|
8604
|
+
kind: "doc_lie",
|
|
8605
|
+
title: `${entry.doc}:${entry.line} says \`npm run ${script}\` — reality: no "${script}" script in package.json`,
|
|
8606
|
+
detail: `"${entry.text.trim().slice(0, 100)}"`,
|
|
8607
|
+
evidence: [`${entry.doc}:${entry.line}`],
|
|
8608
|
+
surprise: 75,
|
|
8609
|
+
});
|
|
8610
|
+
}
|
|
8611
|
+
}
|
|
8612
|
+
if (cliSourceText) {
|
|
8613
|
+
for (const bin of binNames) {
|
|
8614
|
+
for (const match of entry.text.matchAll(new RegExp(`\`${bin} ([a-z][a-z0-9-]+)`, "g"))) {
|
|
8615
|
+
const subcommand = match[1];
|
|
8616
|
+
const key = `cli:${subcommand}`;
|
|
8617
|
+
if (seenLies.has(key) || cliSourceText.includes(`"${subcommand}"`) || cliSourceText.includes(`'${subcommand}'`))
|
|
8618
|
+
continue;
|
|
8619
|
+
seenLies.add(key);
|
|
8620
|
+
docLieFindings.push({
|
|
8621
|
+
kind: "doc_lie",
|
|
8622
|
+
title: `${entry.doc}:${entry.line} documents \`${bin} ${subcommand}\` — reality: no such command in CLI source`,
|
|
8623
|
+
detail: `"${entry.text.trim().slice(0, 100)}"`,
|
|
8624
|
+
evidence: [`${entry.doc}:${entry.line}`],
|
|
8625
|
+
surprise: 80,
|
|
8626
|
+
});
|
|
8627
|
+
}
|
|
8628
|
+
}
|
|
8629
|
+
}
|
|
8630
|
+
}
|
|
8631
|
+
}
|
|
8632
|
+
docLieFindings.sort((a, b) => b.surprise - a.surprise || a.title.localeCompare(b.title));
|
|
8633
|
+
// Cap per category so one noisy category cannot drown the report, then rank globally.
|
|
8634
|
+
const findings = [
|
|
8635
|
+
...duplicateFindings.slice(0, 4),
|
|
8636
|
+
...ghostFindings.slice(0, 4),
|
|
8637
|
+
...busFindings.slice(0, 4),
|
|
8638
|
+
...voidFindings.slice(0, 4),
|
|
8639
|
+
...docLieFindings.slice(0, 4),
|
|
8640
|
+
].sort((a, b) => b.surprise - a.surprise || a.title.localeCompare(b.title)).slice(0, TRUTH_REPORT_MAX_FINDINGS);
|
|
8641
|
+
const headlineParts = [
|
|
8642
|
+
`${duplicateFindings.length} duplicate cluster${duplicateFindings.length === 1 ? "" : "s"}`,
|
|
8643
|
+
`${ghostFindings.length} ghost export${ghostFindings.length === 1 ? "" : "s"}`,
|
|
8644
|
+
`${busFindings.length} bus-factor-1 hot file${busFindings.length === 1 ? "" : "s"}`,
|
|
8645
|
+
`${voidFindings.length} knowledge void${voidFindings.length === 1 ? "" : "s"}`,
|
|
8646
|
+
...(docLines.length ? [`${docLieFindings.length} doc lie${docLieFindings.length === 1 ? "" : "s"}`] : []),
|
|
8647
|
+
];
|
|
8648
|
+
return {
|
|
8649
|
+
schema_version: 1,
|
|
8650
|
+
project_dir: projectDir,
|
|
8651
|
+
generated_at: nowIso(),
|
|
8652
|
+
totals: {
|
|
8653
|
+
files_scanned: graph.files.length,
|
|
8654
|
+
symbols_scanned: graph.symbols.length,
|
|
8655
|
+
duplicate_clusters: duplicateFindings.length,
|
|
8656
|
+
ghost_exports: ghostFindings.length,
|
|
8657
|
+
bus_factor_files: busFindings.length,
|
|
8658
|
+
knowledge_voids: voidFindings.length,
|
|
8659
|
+
doc_lies: docLieFindings.length,
|
|
8660
|
+
docs_scanned: docFiles.length,
|
|
8661
|
+
},
|
|
8662
|
+
headline: headlineParts.join(" · "),
|
|
8663
|
+
findings,
|
|
8664
|
+
warnings,
|
|
8665
|
+
next_actions: [
|
|
8666
|
+
"kage init --project <dir> create repo memory so this knowledge stops living in one head",
|
|
8667
|
+
"kage learn --project <dir> --learning \"...\" --paths <file> capture what only your team knows",
|
|
8668
|
+
"kage viewer --project <dir> watch the void close",
|
|
8669
|
+
],
|
|
8670
|
+
};
|
|
8671
|
+
}
|
|
7706
8672
|
function kageReviewerSuggestions(projectDir, targets = [], changedFiles = []) {
|
|
7707
8673
|
const graph = readCurrentCodeGraph(projectDir) ?? buildCodeGraph(projectDir);
|
|
7708
8674
|
const graphPaths = new Set(graph.files.map((file) => file.path));
|
|
@@ -11232,8 +12198,18 @@ function setupAgent(agent, projectDir, options = {}) {
|
|
|
11232
12198
|
if (!exports.SETUP_AGENTS.includes(agent))
|
|
11233
12199
|
throw new Error(`Unsupported agent: ${agent}`);
|
|
11234
12200
|
const serverPath = options.serverPath ?? (0, node_path_1.join)(__dirname, "index.js");
|
|
11235
|
-
|
|
11236
|
-
|
|
12201
|
+
// An npx cache path (~/.npm/_npx/<hash>/...) is ephemeral — npx prunes it and
|
|
12202
|
+
// the configured MCP server silently dies. Point such configs at the package
|
|
12203
|
+
// runner instead so they survive cache eviction.
|
|
12204
|
+
const ephemeralNpxPath = serverPath.includes(`${node_path_1.sep}_npx${node_path_1.sep}`);
|
|
12205
|
+
const serverCommand = ephemeralNpxPath ? "npx" : "node";
|
|
12206
|
+
const serverArgs = ephemeralNpxPath ? ["-y", "@kage-core/kage-graph-mcp"] : [serverPath];
|
|
12207
|
+
if (options.write) {
|
|
12208
|
+
// `setup <agent> --write` is an explicit wiring action: the harness policy
|
|
12209
|
+
// (AGENTS.md / CLAUDE.md) is part of that wiring, unlike plain init/index
|
|
12210
|
+
// which must not touch repo-visible files.
|
|
12211
|
+
installAgentPolicy(projectDir);
|
|
12212
|
+
}
|
|
11237
12213
|
const home = options.homeDir ?? process.env.HOME ?? "~";
|
|
11238
12214
|
const universal = JSON.stringify({ mcpServers: { kage: { command: serverCommand, args: serverArgs } } }, null, 2);
|
|
11239
12215
|
const result = {
|
|
@@ -11256,7 +12232,7 @@ function setupAgent(agent, projectDir, options = {}) {
|
|
|
11256
12232
|
};
|
|
11257
12233
|
if (agent === "codex") {
|
|
11258
12234
|
const path = (0, node_path_1.join)(home, ".codex", "config.toml");
|
|
11259
|
-
const config = `[mcp_servers.kage]\ncommand = "
|
|
12235
|
+
const config = `[mcp_servers.kage]\ncommand = "${serverCommand}"\nargs = [${serverArgs.map((arg) => JSON.stringify(arg)).join(", ")}]\n`;
|
|
11260
12236
|
setSnippet(path, config, ["Add this block to ~/.codex/config.toml, then restart Codex.", "Run `kage init --project <repo>` inside each repo."], true);
|
|
11261
12237
|
if (options.write) {
|
|
11262
12238
|
ensureDir((0, node_path_1.dirname)(path));
|
|
@@ -12610,6 +13586,71 @@ function prSummarize(projectDir) {
|
|
|
12610
13586
|
warnings,
|
|
12611
13587
|
};
|
|
12612
13588
|
}
|
|
13589
|
+
// Working-tree changes vs HEAD: unstaged + staged + untracked. Outside a git
|
|
13590
|
+
// repo (or before the first commit) this degrades to "nothing changed".
|
|
13591
|
+
function workingTreeChangedFiles(projectDir) {
|
|
13592
|
+
const sections = [
|
|
13593
|
+
readGit(projectDir, ["diff", "--name-only", "HEAD"]),
|
|
13594
|
+
readGit(projectDir, ["diff", "--name-only", "--cached"]),
|
|
13595
|
+
readGit(projectDir, ["ls-files", "--others", "--exclude-standard"]),
|
|
13596
|
+
];
|
|
13597
|
+
return unique(sections
|
|
13598
|
+
.filter((section) => Boolean(section))
|
|
13599
|
+
.flatMap((section) => section.split(/\r?\n/))
|
|
13600
|
+
.map((path) => path.trim())
|
|
13601
|
+
.filter(Boolean)
|
|
13602
|
+
.map((path) => gitPathToProjectRelative(projectDir, path))
|
|
13603
|
+
.filter((path) => Boolean(path)));
|
|
13604
|
+
}
|
|
13605
|
+
function staleCatch(projectDir, changedFiles) {
|
|
13606
|
+
const changed = new Set((changedFiles?.length ? changedFiles : workingTreeChangedFiles(projectDir))
|
|
13607
|
+
.map((path) => path.replace(/\\/g, "/").replace(/^\/+/, ""))
|
|
13608
|
+
.filter(Boolean));
|
|
13609
|
+
const invalidated = [];
|
|
13610
|
+
if (changed.size) {
|
|
13611
|
+
const fpCache = new Map();
|
|
13612
|
+
for (const packet of loadPacketsFromDir(packetsDir(projectDir))) {
|
|
13613
|
+
if (packet.status !== "approved" && packet.status !== "pending")
|
|
13614
|
+
continue;
|
|
13615
|
+
for (const stored of packetStoredPathFingerprints(packet)) {
|
|
13616
|
+
if (!changed.has(stored.path) || isGroundingIgnored(projectDir, stored.path))
|
|
13617
|
+
continue;
|
|
13618
|
+
const current = memoryPathFingerprint(projectDir, stored.path, fpCache);
|
|
13619
|
+
if (current === null) {
|
|
13620
|
+
invalidated.push({ packet_id: packet.id, packet_title: packet.title, cited_path: stored.path, reason: "cited file was deleted" });
|
|
13621
|
+
}
|
|
13622
|
+
else if (current.sha256 !== stored.sha256) {
|
|
13623
|
+
invalidated.push({ packet_id: packet.id, packet_title: packet.title, cited_path: stored.path, reason: "content changed since this memory was verified" });
|
|
13624
|
+
}
|
|
13625
|
+
}
|
|
13626
|
+
}
|
|
13627
|
+
}
|
|
13628
|
+
if (invalidated.length) {
|
|
13629
|
+
recordValueEvents(projectDir, invalidated.map((item) => ({ kind: "stale_caught", packet_title: item.packet_title })));
|
|
13630
|
+
}
|
|
13631
|
+
const summary = invalidated.length
|
|
13632
|
+
? `Your changes invalidated ${invalidated.length} team ${invalidated.length === 1 ? "memory" : "memories"}.`
|
|
13633
|
+
: "No team memory invalidated by this change.";
|
|
13634
|
+
return {
|
|
13635
|
+
ok: true,
|
|
13636
|
+
project_dir: (0, node_path_1.resolve)(projectDir),
|
|
13637
|
+
changed_files: [...changed].sort(),
|
|
13638
|
+
invalidated,
|
|
13639
|
+
summary,
|
|
13640
|
+
};
|
|
13641
|
+
}
|
|
13642
|
+
// Shared human rendering for the stale-catch moment (CLI pr check, kage
|
|
13643
|
+
// staleguard, and the kage_pr_check MCP tool all print the exact same lines).
|
|
13644
|
+
function formatStaleCatch(result) {
|
|
13645
|
+
if (!result.invalidated.length)
|
|
13646
|
+
return ["✓ No team memory invalidated by this change"];
|
|
13647
|
+
const count = result.invalidated.length;
|
|
13648
|
+
return [
|
|
13649
|
+
`⚠ Your changes invalidated ${count} team ${count === 1 ? "memory" : "memories"}:`,
|
|
13650
|
+
...result.invalidated.map((item) => ` • ${item.packet_title} — cites ${item.cited_path} (${item.reason})`),
|
|
13651
|
+
" fix: kage learn (update) | kage supersede --packet <id>",
|
|
13652
|
+
];
|
|
13653
|
+
}
|
|
12613
13654
|
function prCheck(projectDir) {
|
|
12614
13655
|
ensureMemoryDirs(projectDir);
|
|
12615
13656
|
const overlay = buildBranchOverlay(projectDir);
|
|
@@ -12976,150 +14017,6 @@ function recallFromPackets(query, packets, limit, label) {
|
|
|
12976
14017
|
results: scored,
|
|
12977
14018
|
};
|
|
12978
14019
|
}
|
|
12979
|
-
function orgStatus(projectDir, org) {
|
|
12980
|
-
ensureDir(orgInboxDir(projectDir, org));
|
|
12981
|
-
ensureDir(orgPacketsDir(projectDir, org));
|
|
12982
|
-
ensureDir(orgRejectedDir(projectDir, org));
|
|
12983
|
-
return {
|
|
12984
|
-
org: slugify(org),
|
|
12985
|
-
path: orgRootDir(projectDir, org),
|
|
12986
|
-
inbox: loadOrgInboxPackets(projectDir, org).length,
|
|
12987
|
-
approved: loadOrgApprovedPackets(projectDir, org).length,
|
|
12988
|
-
rejected: loadPacketsFromDir(orgRejectedDir(projectDir, org)).length,
|
|
12989
|
-
audit_events: orgAuditCount(projectDir, org),
|
|
12990
|
-
registry_path: (0, node_fs_1.existsSync)((0, node_path_1.join)(orgRootDir(projectDir, org), "registry.json")) ? (0, node_path_1.join)(orgRootDir(projectDir, org), "registry.json") : undefined,
|
|
12991
|
-
};
|
|
12992
|
-
}
|
|
12993
|
-
function orgUploadPacket(projectDir, org, id) {
|
|
12994
|
-
ensureMemoryDirs(projectDir);
|
|
12995
|
-
ensureDir(orgInboxDir(projectDir, org));
|
|
12996
|
-
const source = loadApprovedPackets(projectDir).find((packet) => packet.id === id);
|
|
12997
|
-
if (!source)
|
|
12998
|
-
return { ok: false, errors: [`Approved packet not found: ${id}`] };
|
|
12999
|
-
if (["blocked", "confidential"].includes(source.sensitivity)) {
|
|
13000
|
-
return { ok: false, errors: [`Packet sensitivity cannot be uploaded to org memory: ${source.sensitivity}`] };
|
|
13001
|
-
}
|
|
13002
|
-
const findings = scanSensitiveText(`${source.title}\n${source.summary}\n${source.body}\n${source.paths.join("\n")}`);
|
|
13003
|
-
if (findings.length)
|
|
13004
|
-
return { ok: false, errors: [`Sensitive content blocked: ${unique(findings).join(", ")}`] };
|
|
13005
|
-
const createdAt = nowIso();
|
|
13006
|
-
const packet = {
|
|
13007
|
-
...source,
|
|
13008
|
-
id: `org:${slugify(org)}:${(0, node_crypto_1.createHash)("sha256").update(source.id).digest("hex").slice(0, 16)}:${slugify(source.title)}`,
|
|
13009
|
-
scope: "org",
|
|
13010
|
-
visibility: "org",
|
|
13011
|
-
sensitivity: source.sensitivity === "public" ? "public" : "internal",
|
|
13012
|
-
status: "pending",
|
|
13013
|
-
tags: unique([...source.tags, "org-candidate"]).sort(),
|
|
13014
|
-
source_refs: [
|
|
13015
|
-
...source.source_refs,
|
|
13016
|
-
{
|
|
13017
|
-
kind: "org_upload_candidate",
|
|
13018
|
-
source_packet_id: source.id,
|
|
13019
|
-
repo_key: repoKey(projectDir),
|
|
13020
|
-
},
|
|
13021
|
-
],
|
|
13022
|
-
quality: {
|
|
13023
|
-
...source.quality,
|
|
13024
|
-
org_review_required: true,
|
|
13025
|
-
source_packet_id: source.id,
|
|
13026
|
-
},
|
|
13027
|
-
created_at: createdAt,
|
|
13028
|
-
updated_at: createdAt,
|
|
13029
|
-
};
|
|
13030
|
-
const validation = validatePacket(packet, "org candidate");
|
|
13031
|
-
if (!validation.ok)
|
|
13032
|
-
return { ok: false, errors: validation.errors };
|
|
13033
|
-
const path = (0, node_path_1.join)(orgInboxDir(projectDir, org), packetFileName(packet));
|
|
13034
|
-
writeJson(path, packet);
|
|
13035
|
-
appendOrgAudit(projectDir, org, { action: "upload_candidate", packet_id: packet.id, source_packet_id: source.id });
|
|
13036
|
-
return { ok: true, packet, path, errors: [] };
|
|
13037
|
-
}
|
|
13038
|
-
function orgReviewPacket(projectDir, org, id, action) {
|
|
13039
|
-
ensureDir(orgInboxDir(projectDir, org));
|
|
13040
|
-
ensureDir(orgPacketsDir(projectDir, org));
|
|
13041
|
-
ensureDir(orgRejectedDir(projectDir, org));
|
|
13042
|
-
const sourcePath = walkFiles(orgInboxDir(projectDir, org), (path) => path.endsWith(".json"))
|
|
13043
|
-
.find((path) => readJson(path).id === id);
|
|
13044
|
-
if (!sourcePath)
|
|
13045
|
-
return { ok: false, errors: [`Org inbox packet not found: ${id}`] };
|
|
13046
|
-
const packet = readJson(sourcePath);
|
|
13047
|
-
packet.status = action === "approve" ? "approved" : "deprecated";
|
|
13048
|
-
packet.updated_at = nowIso();
|
|
13049
|
-
packet.quality = {
|
|
13050
|
-
...packet.quality,
|
|
13051
|
-
org_reviewed_at: packet.updated_at,
|
|
13052
|
-
org_review_action: action,
|
|
13053
|
-
};
|
|
13054
|
-
const targetDir = action === "approve" ? orgPacketsDir(projectDir, org) : orgRejectedDir(projectDir, org);
|
|
13055
|
-
const targetPath = (0, node_path_1.join)(targetDir, packetFileName(packet));
|
|
13056
|
-
writeJson(targetPath, packet);
|
|
13057
|
-
(0, node_fs_1.renameSync)(sourcePath, `${sourcePath}.reviewed`);
|
|
13058
|
-
appendOrgAudit(projectDir, org, { action: `review_${action}`, packet_id: packet.id });
|
|
13059
|
-
exportOrgRegistry(projectDir, org);
|
|
13060
|
-
return { ok: true, path: targetPath, errors: [] };
|
|
13061
|
-
}
|
|
13062
|
-
function orgRecall(projectDir, org, query, limit = 5) {
|
|
13063
|
-
return recallFromPackets(query, loadOrgApprovedPackets(projectDir, org), limit, `Org:${slugify(org)}`);
|
|
13064
|
-
}
|
|
13065
|
-
function layeredRecall(projectDir, query, options = {}) {
|
|
13066
|
-
const limit = options.limit ?? 5;
|
|
13067
|
-
const repo = recall(projectDir, query, limit, true);
|
|
13068
|
-
const org = options.org ? orgRecall(projectDir, options.org, query, limit) : undefined;
|
|
13069
|
-
const global = options.includeGlobal ? recallFromPackets(query, loadPacketsFromDir(publicCandidatesDir(projectDir)), limit, "Global") : undefined;
|
|
13070
|
-
const blocks = [
|
|
13071
|
-
"# Kage Layered Recall",
|
|
13072
|
-
"",
|
|
13073
|
-
"Priority: branch > repo local > org > global",
|
|
13074
|
-
"",
|
|
13075
|
-
repo.context_block,
|
|
13076
|
-
org ? `\n---\n\n${org.context_block}` : "",
|
|
13077
|
-
global ? `\n---\n\n${global.context_block}` : "",
|
|
13078
|
-
].filter(Boolean);
|
|
13079
|
-
return {
|
|
13080
|
-
query,
|
|
13081
|
-
priority_order: ["branch", "repo", ...(org ? ["org"] : []), ...(global ? ["global"] : [])],
|
|
13082
|
-
context_block: blocks.join("\n"),
|
|
13083
|
-
repo,
|
|
13084
|
-
...(org ? { org } : {}),
|
|
13085
|
-
...(global ? { global } : {}),
|
|
13086
|
-
};
|
|
13087
|
-
}
|
|
13088
|
-
function exportOrgRegistry(projectDir, org) {
|
|
13089
|
-
const packets = loadOrgApprovedPackets(projectDir, org);
|
|
13090
|
-
const payload = {
|
|
13091
|
-
schema_version: 1,
|
|
13092
|
-
org: slugify(org),
|
|
13093
|
-
repo_key: repoKey(projectDir),
|
|
13094
|
-
generated_at: nowIso(),
|
|
13095
|
-
metrics: {
|
|
13096
|
-
packets: packets.length,
|
|
13097
|
-
by_type: countBy(packets, (packet) => packet.type),
|
|
13098
|
-
by_repo_path: countBy(packets.flatMap((packet) => packet.paths), (path) => path),
|
|
13099
|
-
},
|
|
13100
|
-
packets: packets.map((packet) => ({
|
|
13101
|
-
id: packet.id,
|
|
13102
|
-
title: packet.title,
|
|
13103
|
-
summary: packet.summary,
|
|
13104
|
-
type: packet.type,
|
|
13105
|
-
tags: packet.tags,
|
|
13106
|
-
paths: packet.paths,
|
|
13107
|
-
source_refs: packet.source_refs,
|
|
13108
|
-
updated_at: packet.updated_at,
|
|
13109
|
-
content_sha256: (0, node_crypto_1.createHash)("sha256").update(canonicalPacketText(packet)).digest("hex"),
|
|
13110
|
-
})),
|
|
13111
|
-
};
|
|
13112
|
-
const manifest = (0, index_js_1.createSignedManifest)({
|
|
13113
|
-
kind: "org_registry",
|
|
13114
|
-
name: `${slugify(org)} org memory`,
|
|
13115
|
-
version: nowIso().slice(0, 10),
|
|
13116
|
-
keyId: `${slugify(org)}-local`,
|
|
13117
|
-
payload,
|
|
13118
|
-
});
|
|
13119
|
-
writeJson((0, node_path_1.join)(orgRootDir(projectDir, org), "registry.json"), manifest);
|
|
13120
|
-
appendOrgAudit(projectDir, org, { action: "export_registry", packets: packets.length });
|
|
13121
|
-
return orgStatus(projectDir, org);
|
|
13122
|
-
}
|
|
13123
14020
|
function canonicalPacketText(packet) {
|
|
13124
14021
|
return JSON.stringify({
|
|
13125
14022
|
title: packet.title,
|
|
@@ -13130,86 +14027,6 @@ function canonicalPacketText(packet) {
|
|
|
13130
14027
|
paths: packet.paths,
|
|
13131
14028
|
});
|
|
13132
14029
|
}
|
|
13133
|
-
function buildMarketplace(projectDir) {
|
|
13134
|
-
ensureMemoryDirs(projectDir);
|
|
13135
|
-
const packs = registryRecommendations(projectDir).map((item) => ({
|
|
13136
|
-
...item,
|
|
13137
|
-
source: "repo_metadata",
|
|
13138
|
-
}));
|
|
13139
|
-
const manifest = {
|
|
13140
|
-
schema_version: 1,
|
|
13141
|
-
project_dir: projectDir,
|
|
13142
|
-
generated_at: nowIso(),
|
|
13143
|
-
packs,
|
|
13144
|
-
install_policy: "explicit_human_approval_required",
|
|
13145
|
-
};
|
|
13146
|
-
const path = (0, node_path_1.join)(marketplaceDir(projectDir), "manifest.json");
|
|
13147
|
-
writeJson(path, manifest);
|
|
13148
|
-
const planLines = [
|
|
13149
|
-
"# Kage Marketplace Install Plan",
|
|
13150
|
-
"",
|
|
13151
|
-
"Kage never installs marketplace assets automatically. Review each pack, then install it with your agent's normal trusted setup flow.",
|
|
13152
|
-
"",
|
|
13153
|
-
...packs.flatMap((pack) => [
|
|
13154
|
-
`## ${pack.title}`,
|
|
13155
|
-
"",
|
|
13156
|
-
`- ID: \`${pack.id}\``,
|
|
13157
|
-
`- Kind: \`${pack.kind}\``,
|
|
13158
|
-
`- Trust: \`${pack.trust}\``,
|
|
13159
|
-
`- Install policy: \`${pack.install}\``,
|
|
13160
|
-
`- Matched: ${pack.matched.join(", ") || "(repo metadata)"}`,
|
|
13161
|
-
"",
|
|
13162
|
-
pack.summary,
|
|
13163
|
-
"",
|
|
13164
|
-
]),
|
|
13165
|
-
];
|
|
13166
|
-
(0, node_fs_1.writeFileSync)((0, node_path_1.join)(marketplaceDir(projectDir), "install-plan.md"), `${planLines.join("\n").trim()}\n`, "utf8");
|
|
13167
|
-
return { ok: true, path, packs, errors: [] };
|
|
13168
|
-
}
|
|
13169
|
-
function buildGlobalCdnBundle(projectDir, org = "local") {
|
|
13170
|
-
ensureMemoryDirs(projectDir);
|
|
13171
|
-
const publicBundle = exportPublicBundle(projectDir);
|
|
13172
|
-
if (!publicBundle.ok) {
|
|
13173
|
-
return { ok: false, root: globalCdnDir(projectDir), packet_count: 0, marketplace_packs: 0, errors: publicBundle.errors };
|
|
13174
|
-
}
|
|
13175
|
-
const marketplace = buildMarketplace(projectDir);
|
|
13176
|
-
const publicManifest = readJson(publicBundle.path);
|
|
13177
|
-
const registryManifest = (0, index_js_1.generateOrgRegistryManifest)({
|
|
13178
|
-
org: slugify(org),
|
|
13179
|
-
version: nowIso().slice(0, 10),
|
|
13180
|
-
keyId: `${slugify(org)}-global-local`,
|
|
13181
|
-
bundles: [publicManifest],
|
|
13182
|
-
});
|
|
13183
|
-
const root = globalCdnDir(projectDir);
|
|
13184
|
-
const digest = registryManifest.signature.payload_sha256.slice(0, 16);
|
|
13185
|
-
const manifestPath = (0, node_path_1.join)(root, `registry.${digest}.json`);
|
|
13186
|
-
const aliasPath = (0, node_path_1.join)(root, "latest.json");
|
|
13187
|
-
writeJson(manifestPath, registryManifest);
|
|
13188
|
-
writeJson((0, node_path_1.join)(root, "registry.json"), registryManifest);
|
|
13189
|
-
writeJson((0, node_path_1.join)(root, "revocations.json"), {
|
|
13190
|
-
schema_version: 1,
|
|
13191
|
-
generated_at: nowIso(),
|
|
13192
|
-
revoked: [],
|
|
13193
|
-
});
|
|
13194
|
-
writeJson(aliasPath, {
|
|
13195
|
-
schema_version: 1,
|
|
13196
|
-
registry: (0, node_path_1.relative)(root, manifestPath),
|
|
13197
|
-
marketplace: (0, node_path_1.relative)(root, marketplace.path),
|
|
13198
|
-
payload_sha256: registryManifest.signature.payload_sha256,
|
|
13199
|
-
generated_at: registryManifest.generated_at,
|
|
13200
|
-
rollback_ready: true,
|
|
13201
|
-
});
|
|
13202
|
-
return {
|
|
13203
|
-
ok: true,
|
|
13204
|
-
root,
|
|
13205
|
-
manifest_path: manifestPath,
|
|
13206
|
-
alias_path: aliasPath,
|
|
13207
|
-
marketplace_path: marketplace.path,
|
|
13208
|
-
packet_count: registryManifest.payload.metrics.entry_count,
|
|
13209
|
-
marketplace_packs: marketplace.packs.length,
|
|
13210
|
-
errors: [],
|
|
13211
|
-
};
|
|
13212
|
-
}
|
|
13213
14030
|
function recordFeedback(projectDir, id, feedback) {
|
|
13214
14031
|
ensureMemoryDirs(projectDir);
|
|
13215
14032
|
if (!["helpful", "wrong", "stale"].includes(feedback)) {
|
|
@@ -13360,13 +14177,19 @@ function installClaudeSettings(projectDir) {
|
|
|
13360
14177
|
settings.allowedTools = merged;
|
|
13361
14178
|
writeJson(settingsPath, settings);
|
|
13362
14179
|
}
|
|
13363
|
-
function initProject(projectDir) {
|
|
13364
|
-
|
|
13365
|
-
|
|
14180
|
+
function initProject(projectDir, options = {}) {
|
|
14181
|
+
// Default init touches ONLY .agent_memory/. Agent-policy files (AGENTS.md,
|
|
14182
|
+
// CLAUDE.md) and .claude/settings.json are repo-visible and reviewable, so
|
|
14183
|
+
// writing them requires explicit opt-in (`kage init --with-policy` or `kage policy`).
|
|
14184
|
+
const policyInstalled = options.policy === true;
|
|
14185
|
+
if (policyInstalled) {
|
|
14186
|
+
installAgentPolicy(projectDir);
|
|
14187
|
+
installClaudeSettings(projectDir);
|
|
14188
|
+
}
|
|
13366
14189
|
const index = indexProject(projectDir, { graphs: false });
|
|
13367
14190
|
const validation = validateProject(projectDir);
|
|
13368
14191
|
const sampleRecall = recallFromPackets("how do I run tests", loadApprovedPackets(projectDir), 5, "Repo Memory");
|
|
13369
|
-
return { index, validation, sampleRecall };
|
|
14192
|
+
return { index, validation, sampleRecall, policyInstalled };
|
|
13370
14193
|
}
|
|
13371
14194
|
function doctorProject(projectDir) {
|
|
13372
14195
|
ensureMemoryDirs(projectDir);
|