@kage-core/kage-graph-mcp 1.3.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/kernel.js CHANGED
@@ -62,6 +62,10 @@ 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;
68
+ exports.kageActivity = kageActivity;
65
69
  exports.kageMemoryReconciliation = kageMemoryReconciliation;
66
70
  exports.evaluateMemoryAdmission = evaluateMemoryAdmission;
67
71
  exports.validatePacket = validatePacket;
@@ -75,6 +79,8 @@ exports.kageMemoryAudit = kageMemoryAudit;
75
79
  exports.kageMemoryHandoff = kageMemoryHandoff;
76
80
  exports.buildStructuralFileForWorker = buildStructuralFileForWorker;
77
81
  exports.buildStructuralIndex = buildStructuralIndex;
82
+ exports.treeSitterLanguagesForPaths = treeSitterLanguagesForPaths;
83
+ exports.ensureTreeSitterLanguages = ensureTreeSitterLanguages;
78
84
  exports.writeLspSymbolIndex = writeLspSymbolIndex;
79
85
  exports.writeCodeIndex = writeCodeIndex;
80
86
  exports.buildCodeGraph = buildCodeGraph;
@@ -96,6 +102,7 @@ exports.kageTeammateBrief = kageTeammateBrief;
96
102
  exports.kageRisk = kageRisk;
97
103
  exports.kageDependencyPath = kageDependencyPath;
98
104
  exports.kageCleanupCandidates = kageCleanupCandidates;
105
+ exports.truthReport = truthReport;
99
106
  exports.kageReviewerSuggestions = kageReviewerSuggestions;
100
107
  exports.kageContributors = kageContributors;
101
108
  exports.kageContextSlots = kageContextSlots;
@@ -137,19 +144,13 @@ exports.proposeFromDiff = proposeFromDiff;
137
144
  exports.buildBranchOverlay = buildBranchOverlay;
138
145
  exports.createReviewArtifact = createReviewArtifact;
139
146
  exports.prSummarize = prSummarize;
147
+ exports.staleCatch = staleCatch;
148
+ exports.formatStaleCatch = formatStaleCatch;
140
149
  exports.prCheck = prCheck;
141
150
  exports.kageHookStatus = kageHookStatus;
142
151
  exports.kageHookInstall = kageHookInstall;
143
152
  exports.kageHookUninstall = kageHookUninstall;
144
153
  exports.exportPublicBundle = exportPublicBundle;
145
- exports.orgStatus = orgStatus;
146
- exports.orgUploadPacket = orgUploadPacket;
147
- exports.orgReviewPacket = orgReviewPacket;
148
- exports.orgRecall = orgRecall;
149
- exports.layeredRecall = layeredRecall;
150
- exports.exportOrgRegistry = exportOrgRegistry;
151
- exports.buildMarketplace = buildMarketplace;
152
- exports.buildGlobalCdnBundle = buildGlobalCdnBundle;
153
154
  exports.recordFeedback = recordFeedback;
154
155
  exports.validateProject = validateProject;
155
156
  exports.initProject = initProject;
@@ -873,6 +874,8 @@ function kageMemoryLifecycle(projectDir) {
873
874
  const item = {
874
875
  packet_id: packet.id,
875
876
  title: packet.title,
877
+ summary: packet.summary ?? "",
878
+ body: packet.body ?? "",
876
879
  type: packet.type,
877
880
  status: packet.status,
878
881
  health: action.health,
@@ -957,6 +960,217 @@ function recordRecallAccess(projectDir, results) {
957
960
  // Recall should never fail because local access telemetry could not be updated.
958
961
  }
959
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
+ }
1126
+ const AUDIT_ACTIVITY_KIND = {
1127
+ capture: "capture", approve: "capture", supersede: "supersede", deprecate: "deprecate",
1128
+ update: "update", promote: "promote", feedback: "feedback",
1129
+ };
1130
+ function kageActivity(projectDir, options = {}) {
1131
+ const limit = options.limit ?? 80;
1132
+ const events = [];
1133
+ let recalls = 0;
1134
+ readMemoryAccessEntries(projectDir).forEach((entry) => {
1135
+ (entry.recent ?? []).forEach((r) => {
1136
+ if (!r || !r.at)
1137
+ return;
1138
+ recalls += 1;
1139
+ events.push({ at: r.at, kind: "recall", title: entry.title, detail: `recalled · rank ${r.rank}` });
1140
+ });
1141
+ });
1142
+ let captures = 0;
1143
+ for (const audit of loadMemoryAuditEntries(projectDir)) {
1144
+ const kind = AUDIT_ACTIVITY_KIND[audit.operation] ?? "other";
1145
+ if (kind === "capture")
1146
+ captures += 1;
1147
+ const extra = audit.packet_titles.length > 1 ? ` (+${audit.packet_titles.length - 1} more)` : "";
1148
+ events.push({ at: audit.timestamp, kind, title: (audit.packet_titles[0] ?? audit.operation) + extra, detail: audit.operation, actor: audit.actor });
1149
+ }
1150
+ events.sort((a, b) => (Date.parse(b.at) || 0) - (Date.parse(a.at) || 0));
1151
+ const dayMap = new Map();
1152
+ events.forEach((e) => { if (e.kind === "recall") {
1153
+ const d = e.at.slice(0, 10);
1154
+ dayMap.set(d, (dayMap.get(d) ?? 0) + 1);
1155
+ } });
1156
+ // Zero-fill the last 14 calendar days so the chart reads as a timeline, not a lone bar.
1157
+ const daily = [];
1158
+ for (let i = 13; i >= 0; i -= 1) {
1159
+ const day = new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
1160
+ daily.push({ day, recalls: dayMap.get(day) ?? 0 });
1161
+ }
1162
+ const cutoff7 = Date.now() - 7 * 24 * 60 * 60 * 1000;
1163
+ const recalls7d = events.filter((e) => e.kind === "recall" && (Date.parse(e.at) || 0) >= cutoff7).length;
1164
+ return {
1165
+ schema_version: 1,
1166
+ project_dir: projectDir,
1167
+ generated_at: nowIso(),
1168
+ window_days: ACCESS_WINDOW_DAYS,
1169
+ totals: { events: events.length, recalls, captures, recalls_7d: recalls7d },
1170
+ daily,
1171
+ events: events.slice(0, limit),
1172
+ };
1173
+ }
960
1174
  function isGeneratedChangeMemory(packet) {
961
1175
  return packet.type === "workflow"
962
1176
  && packet.tags.includes("change-memory")
@@ -1053,6 +1267,10 @@ function fingerprintableMemoryPath(path) {
1053
1267
  const normalized = path.replace(/\\/g, "/").replace(/^\/+/, "");
1054
1268
  return meaningfulMemoryPath(normalized) && !normalized.startsWith(".agent_memory/");
1055
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();
1056
1274
  function memoryPathFingerprint(projectDir, path, cache) {
1057
1275
  const normalized = path.replace(/\\/g, "/").replace(/^\/+/, "");
1058
1276
  if (!fingerprintableMemoryPath(normalized))
@@ -1067,11 +1285,17 @@ function memoryPathFingerprint(projectDir, path, cache) {
1067
1285
  cache?.set(cacheKey, null);
1068
1286
  return null;
1069
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
+ }
1070
1293
  const fingerprint = {
1071
1294
  path: normalized,
1072
1295
  sha256: sha256Hex((0, node_fs_1.readFileSync)(absolutePath)),
1073
1296
  size: stats.size,
1074
1297
  };
1298
+ fingerprintProcessCache.set(cacheKey, { mtimeMs: stats.mtimeMs, size: stats.size, fingerprint });
1075
1299
  cache?.set(cacheKey, fingerprint);
1076
1300
  return fingerprint;
1077
1301
  }
@@ -1122,7 +1346,7 @@ function staleMemoryReasons(projectDir, packet, fingerprintCache) {
1122
1346
  if (ageDays > ttlDays)
1123
1347
  reasons.push(`freshness ttl expired (${Math.floor(ageDays)}d old, ttl ${ttlDays}d)`);
1124
1348
  }
1125
- const paths = packet.paths.filter(meaningfulMemoryPath);
1349
+ const paths = packet.paths.filter((path) => meaningfulMemoryPath(path) && !isGroundingIgnored(projectDir, path));
1126
1350
  const missingPaths = paths.filter((path) => !(0, node_fs_1.existsSync)((0, node_path_1.join)(projectDir, path)));
1127
1351
  if (paths.length > 0 && missingPaths.length === paths.length) {
1128
1352
  reasons.push(`all referenced paths are missing: ${missingPaths.slice(0, 4).join(", ")}`);
@@ -1133,6 +1357,7 @@ function staleMemoryReasons(projectDir, packet, fingerprintCache) {
1133
1357
  if (freshness.path_fingerprint_policy === "source_hash_staleness") {
1134
1358
  const storedFingerprints = packetStoredPathFingerprints(packet);
1135
1359
  const changedPaths = storedFingerprints
1360
+ .filter((fingerprint) => !isGroundingIgnored(projectDir, fingerprint.path))
1136
1361
  .filter((fingerprint) => (0, node_fs_1.existsSync)((0, node_path_1.join)(projectDir, fingerprint.path)))
1137
1362
  .filter((fingerprint) => {
1138
1363
  const current = memoryPathFingerprint(projectDir, fingerprint.path, fingerprintCache);
@@ -2341,7 +2566,7 @@ function pathExistsInRepo(projectDir, packetPath) {
2341
2566
  }
2342
2567
  function packetGroundingWarnings(projectDir, packet, source) {
2343
2568
  const warnings = [];
2344
- const meaningfulPaths = packet.paths.filter((path) => path && path !== "root" && !shouldSkipRepoMemoryPath(path));
2569
+ const meaningfulPaths = packet.paths.filter((path) => path && path !== "root" && !shouldSkipRepoMemoryPath(path) && !isGroundingIgnored(projectDir, path));
2345
2570
  const missingPaths = meaningfulPaths.filter((path) => !pathExistsInRepo(projectDir, path));
2346
2571
  if (meaningfulPaths.length && missingPaths.length === meaningfulPaths.length) {
2347
2572
  warnings.push(`${source}: none of the referenced paths exist in this repo: ${missingPaths.join(", ")}`);
@@ -2455,6 +2680,10 @@ function shouldSkipCodePath(relativePath) {
2455
2680
  .some((part) => [
2456
2681
  ".git",
2457
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",
2458
2687
  "node_modules",
2459
2688
  "vendor",
2460
2689
  ".venv",
@@ -2738,7 +2967,9 @@ function structuralFileCacheDir(projectDir) {
2738
2967
  function structuralPackedFileCachePath(projectDir) {
2739
2968
  return (0, node_path_1.join)(structuralIndexDir(projectDir), "file-cache.json");
2740
2969
  }
2741
- const STRUCTURAL_EXTRACTOR_VERSION = 2;
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
2742
2973
  function structuralFileCachePath(projectDir, rel, hash) {
2743
2974
  return (0, node_path_1.join)(structuralFileCacheDir(projectDir), `v${STRUCTURAL_EXTRACTOR_VERSION}-${slugify(rel)}-${hash}.json`);
2744
2975
  }
@@ -2805,6 +3036,54 @@ function readKageIgnore(projectDir) {
2805
3036
  .map((line) => line.trim())
2806
3037
  .filter((line) => line.length > 0 && !line.startsWith("#"));
2807
3038
  }
3039
+ // A repo can declare non-knowledge paths (e.g. a presentation/visualization layer)
3040
+ // in .kageignore. Those paths must not count as memory grounding: memory should never
3041
+ // be anchored to, or marked stale by, files the repo says are not knowledge-bearing.
3042
+ function normalizeRelPath(path) {
3043
+ return String(path).replace(/\\/g, "/").replace(/^\/+/, "");
3044
+ }
3045
+ function isGroundingIgnored(projectDir, path) {
3046
+ const patterns = readKageIgnore(projectDir);
3047
+ if (!patterns.length)
3048
+ return false;
3049
+ return isKageIgnored(normalizeRelPath(path), patterns);
3050
+ }
3051
+ // Strip .kageignore'd paths from a packet's grounding (paths, source refs, and
3052
+ // path fingerprints). Returns a new packet if anything changed, else null.
3053
+ function prunePacketGroundingPaths(packet, patterns) {
3054
+ if (!patterns.length)
3055
+ return null;
3056
+ const ignored = (p) => typeof p === "string" && isKageIgnored(normalizeRelPath(p), patterns);
3057
+ let changed = false;
3058
+ const paths = packet.paths.filter((p) => (ignored(p) ? ((changed = true), false) : true));
3059
+ const sourceRefs = packet.source_refs.map((ref) => {
3060
+ const next = { ...ref };
3061
+ if (ignored(next.path)) {
3062
+ delete next.path;
3063
+ changed = true;
3064
+ }
3065
+ if (Array.isArray(next.changed_files)) {
3066
+ const kept = next.changed_files.filter((f) => !ignored(f));
3067
+ if (kept.length !== next.changed_files.length) {
3068
+ next.changed_files = kept;
3069
+ changed = true;
3070
+ }
3071
+ }
3072
+ return next;
3073
+ });
3074
+ const freshness = { ...(packet.freshness ?? {}) };
3075
+ if (Array.isArray(freshness.path_fingerprints)) {
3076
+ const fps = freshness.path_fingerprints;
3077
+ const kept = fps.filter((f) => !ignored(f?.path));
3078
+ if (kept.length !== fps.length) {
3079
+ freshness.path_fingerprints = kept;
3080
+ changed = true;
3081
+ }
3082
+ }
3083
+ if (!changed)
3084
+ return null;
3085
+ return { ...packet, paths, source_refs: sourceRefs, freshness };
3086
+ }
2808
3087
  function wildcardPattern(pattern) {
2809
3088
  const escaped = pattern
2810
3089
  .replace(/[.+^${}()|[\]\\]/g, "\\$&")
@@ -3103,6 +3382,16 @@ function writeStructuralFileCachePack(projectDir, results) {
3103
3382
  packedStructuralCache.delete((0, node_path_1.resolve)(projectDir));
3104
3383
  (0, node_fs_1.rmSync)(structuralFileCacheDir(projectDir), { recursive: true, force: true });
3105
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
+ }
3106
3395
  function buildStructuralFile(projectDir, absolutePath, knownFiles, prior) {
3107
3396
  const rel = (0, node_path_1.relative)(projectDir, absolutePath).replace(/\\/g, "/");
3108
3397
  const stats = (0, node_fs_1.statSync)(absolutePath);
@@ -3110,11 +3399,11 @@ function buildStructuralFile(projectDir, absolutePath, knownFiles, prior) {
3110
3399
  const canReuseHash = priorEntry && priorEntry.size_bytes === stats.size && Math.round(priorEntry.mtime_ms) === Math.round(stats.mtimeMs);
3111
3400
  let buffer = canReuseHash ? null : (0, node_fs_1.readFileSync)(absolutePath);
3112
3401
  let hash = canReuseHash ? priorEntry.hash : sha256Hex(buffer ?? "");
3113
- let cached = readCachedStructuralFile(projectDir, rel, hash);
3402
+ let cached = usableStructuralCache(rel, readCachedStructuralFile(projectDir, rel, hash));
3114
3403
  if (!cached && !buffer) {
3115
3404
  buffer = (0, node_fs_1.readFileSync)(absolutePath);
3116
3405
  hash = sha256Hex(buffer);
3117
- cached = readCachedStructuralFile(projectDir, rel, hash);
3406
+ cached = usableStructuralCache(rel, readCachedStructuralFile(projectDir, rel, hash));
3118
3407
  }
3119
3408
  const entry = {
3120
3409
  path: rel,
@@ -3136,7 +3425,7 @@ function buildStructuralFile(projectDir, absolutePath, knownFiles, prior) {
3136
3425
  rawImports.push(...extractImports(projectDir, rel, content, knownFiles));
3137
3426
  }
3138
3427
  else if (CODE_EXTENSIONS.has(extensionOf(rel))) {
3139
- rawSymbols.push(...extractGenericSymbols(rel, content));
3428
+ rawSymbols.push(...(extractTreeSitterSymbols(rel, content) ?? extractGenericSymbols(rel, content)));
3140
3429
  rawImports.push(...extractGenericImports(projectDir, rel, content, knownFiles));
3141
3430
  }
3142
3431
  }
@@ -3558,6 +3847,20 @@ function extractSymbols(path, text) {
3558
3847
  addSymbol(declaration.name.text, kind, declaration, exported, node.getText(sourceFile).split(/\r?\n/)[0]);
3559
3848
  }
3560
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
+ }
3561
3864
  else if (codeFileKind(path) === "test" && ts.isCallExpression(node)) {
3562
3865
  const callee = propertyOrIdentifierName(node.expression);
3563
3866
  const first = stringLiteralValue(node.arguments[0]);
@@ -3569,6 +3872,255 @@ function extractSymbols(path, text) {
3569
3872
  visit(sourceFile);
3570
3873
  return symbols.sort((a, b) => a.line - b.line || a.name.localeCompare(b.name));
3571
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
+ }
3572
4124
  function extractGenericSymbols(path, text) {
3573
4125
  const symbols = [];
3574
4126
  const language = codeLanguage(path);
@@ -3808,7 +4360,7 @@ function normalizeCallConfidence(value, fallback) {
3808
4360
  return Number(Math.max(0, Math.min(1, numeric)).toFixed(2));
3809
4361
  }
3810
4362
  function normalizeCallResolution(value, fallback) {
3811
- 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;
3812
4364
  }
3813
4365
  function normalizeCallEdge(call, fallback) {
3814
4366
  if (!isRecord(call) || typeof call.to_symbol !== "string")
@@ -3822,7 +4374,34 @@ function normalizeCallEdge(call, fallback) {
3822
4374
  resolution: normalizeCallResolution(call.resolution, fallback.resolution),
3823
4375
  };
3824
4376
  }
3825
- function extractCalls(path, text, symbols, symbolByName) {
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) {
3826
4405
  const sourceFile = sourceFileFor(path, text);
3827
4406
  const calls = [];
3828
4407
  const visit = (node) => {
@@ -3844,7 +4423,7 @@ function extractCalls(path, text, symbols, symbolByName) {
3844
4423
  }
3845
4424
  const line = lineForNode(sourceFile, node);
3846
4425
  const caller = symbolAtLine(symbols, path, line);
3847
- for (const target of targets.slice(0, 3)) {
4426
+ for (const { target, confidence } of resolveCallTargets(name, path, targets, context, { local: 0.9, imported: 0.85, sameDir: 0.6, nameOnly: 0.35 })) {
3848
4427
  if (calls.length >= MAX_CODE_GRAPH_CALLS_PER_FILE)
3849
4428
  break;
3850
4429
  if (target.path === path && target.line === line)
@@ -3854,7 +4433,7 @@ function extractCalls(path, text, symbols, symbolByName) {
3854
4433
  to_symbol: target.id,
3855
4434
  path,
3856
4435
  line,
3857
- confidence: target.path === path ? 0.9 : 0.75,
4436
+ confidence,
3858
4437
  resolution: "typescript_ast_name",
3859
4438
  });
3860
4439
  }
@@ -3877,7 +4456,7 @@ const GENERIC_CALL_STOP_WORDS = new Set([
3877
4456
  "switch",
3878
4457
  "while",
3879
4458
  ]);
3880
- function extractGenericCalls(path, text, symbols, symbolByName) {
4459
+ function extractGenericCalls(path, text, symbols, symbolByName, context = EMPTY_CALL_RESOLUTION) {
3881
4460
  const calls = [];
3882
4461
  const lines = text.split(/\r?\n/);
3883
4462
  for (let index = 0; index < lines.length && calls.length < MAX_CODE_GRAPH_CALLS_PER_FILE; index += 1) {
@@ -3895,7 +4474,7 @@ function extractGenericCalls(path, text, symbols, symbolByName) {
3895
4474
  if (!targets?.length)
3896
4475
  continue;
3897
4476
  const caller = symbolAtLine(symbols, path, line);
3898
- for (const target of targets.slice(0, 3)) {
4477
+ for (const { target, confidence } of resolveCallTargets(name, path, targets, context, { local: 0.7, imported: 0.65, sameDir: 0.5, nameOnly: 0.3 })) {
3899
4478
  if (calls.length >= MAX_CODE_GRAPH_CALLS_PER_FILE)
3900
4479
  break;
3901
4480
  calls.push({
@@ -3903,7 +4482,7 @@ function extractGenericCalls(path, text, symbols, symbolByName) {
3903
4482
  to_symbol: target.id,
3904
4483
  path,
3905
4484
  line,
3906
- confidence: target.path === path ? 0.7 : 0.55,
4485
+ confidence,
3907
4486
  resolution: "generic_static_name",
3908
4487
  });
3909
4488
  }
@@ -4139,8 +4718,16 @@ function fileInputEntries(projectDir, paths, kind) {
4139
4718
  sha256: sha256Hex((0, node_fs_1.readFileSync)(path)),
4140
4719
  }));
4141
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
+ }
4142
4728
  function codeGraphInputHash(projectDir, absoluteFiles = listCodeFiles(projectDir)) {
4143
4729
  return graphInputHash([
4730
+ codeGraphBuilderVersionEntry(),
4144
4731
  ...fileInputEntries(projectDir, absoluteFiles, "code_file"),
4145
4732
  ...fileInputEntries(projectDir, externalIndexFiles(projectDir).map((index) => index.path), "external_code_index"),
4146
4733
  ]);
@@ -4150,6 +4737,7 @@ function codeGraphInputHashFromStructural(projectDir, structural) {
4150
4737
  }
4151
4738
  function codeGraphInputHashFromStructuralFingerprint(projectDir, fingerprint) {
4152
4739
  return graphInputHash([
4740
+ codeGraphBuilderVersionEntry(),
4153
4741
  { kind: "code_graph_input", path: ".agent_memory/structural/fingerprint", sha256: fingerprint },
4154
4742
  ...fileInputEntries(projectDir, externalIndexFiles(projectDir).map((index) => index.path), "external_code_index"),
4155
4743
  ]);
@@ -4175,6 +4763,7 @@ function currentCodeGraphInputHash(projectDir) {
4175
4763
  }
4176
4764
  function codeGraphStructuralFingerprint(projectDir, structural) {
4177
4765
  const entries = [
4766
+ `builder:${CODE_GRAPH_BUILDER_VERSION}`,
4178
4767
  `structural:${structural.manifest.fingerprint}`,
4179
4768
  ...externalIndexFiles(projectDir)
4180
4769
  .map((index) => index.path)
@@ -4630,6 +5219,11 @@ function buildCodeGraph(projectDir, options = {}) {
4630
5219
  writeCodeIndexManifest(projectDir, codeIndexManifestFromStructural(projectDir, structural, fingerprint, structural.manifest.cache));
4631
5220
  const externalFacts = loadExternalCodeFacts(projectDir);
4632
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
+ }
4633
5227
  const addSymbol = (symbol) => {
4634
5228
  if (!fileByPath.has(symbol.path))
4635
5229
  return;
@@ -4670,9 +5264,21 @@ function buildCodeGraph(projectDir, options = {}) {
4670
5264
  break;
4671
5265
  const fileSymbols = symbols.filter((symbol) => symbol.path === rel);
4672
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
+ };
4673
5279
  const fileCalls = TS_AST_EXTENSIONS.has(extensionOf(rel))
4674
- ? extractCalls(rel, content, fileSymbols, symbolByName)
4675
- : 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);
4676
5282
  calls.push(...fileCalls.slice(0, Math.max(0, MAX_CODE_GRAPH_CALLS - calls.length)));
4677
5283
  routes.push(...extractRoutes(rel, content, fileSymbols));
4678
5284
  tests.push(...extractTests(rel, content, fileSymbols, fileImports));
@@ -5396,7 +6002,10 @@ function buildIndexes(projectDir) {
5396
6002
  }
5397
6003
  function indexProjectDetailed(projectDir, options = {}) {
5398
6004
  ensureMemoryDirs(projectDir);
5399
- const policy = installAgentPolicy(projectDir);
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");
5400
6009
  const migrated = migrateLegacyMarkdown(projectDir);
5401
6010
  const overview = createRepoOverviewPacket(projectDir);
5402
6011
  if (overview)
@@ -5412,7 +6021,7 @@ function indexProjectDetailed(projectDir, options = {}) {
5412
6021
  packets: loadPacketsFromDir(packetsDir(projectDir)).length,
5413
6022
  migrated,
5414
6023
  indexes: indexes.map((path) => (0, node_path_1.relative)(projectDir, path)),
5415
- policyPath: (0, node_path_1.relative)(projectDir, policy.path),
6024
+ policyPath: (0, node_fs_1.existsSync)(existingPolicyPath) ? (0, node_path_1.relative)(projectDir, existingPolicyPath) : undefined,
5416
6025
  },
5417
6026
  codeGraph: built?.codeGraph,
5418
6027
  knowledgeGraph: built?.knowledgeGraph,
@@ -5447,13 +6056,18 @@ function refreshPacketStaleness(projectDir) {
5447
6056
  const findings = [];
5448
6057
  let updated = 0;
5449
6058
  const fingerprintCache = new Map();
6059
+ const ignorePatterns = readKageIgnore(projectDir);
5450
6060
  for (const entry of loadPacketEntriesFromDir(packetsDir(projectDir))) {
5451
- const reasons = staleMemoryReasons(projectDir, entry.packet, fingerprintCache);
5452
- const oldQuality = (entry.packet.quality ?? {});
5453
- const oldFreshness = (entry.packet.freshness ?? {});
6061
+ // Drop any .kageignore'd grounding (presentation layers etc.) from the stored packet
6062
+ // so memory is never anchored to non-knowledge files.
6063
+ const pruned = prunePacketGroundingPaths(entry.packet, ignorePatterns);
6064
+ const packet = pruned ?? entry.packet;
6065
+ const reasons = staleMemoryReasons(projectDir, packet, fingerprintCache);
6066
+ const oldQuality = (packet.quality ?? {});
6067
+ const oldFreshness = (packet.freshness ?? {});
5454
6068
  let nextQuality;
5455
6069
  if (reasons.length) {
5456
- const finding = staleFinding(entry.packet, reasons);
6070
+ const finding = staleFinding(packet, reasons);
5457
6071
  findings.push(finding);
5458
6072
  nextQuality = {
5459
6073
  ...oldQuality,
@@ -5467,11 +6081,12 @@ function refreshPacketStaleness(projectDir) {
5467
6081
  nextQuality = rest;
5468
6082
  }
5469
6083
  const nextFreshness = oldFreshness;
5470
- const changed = JSON.stringify(oldQuality) !== JSON.stringify(nextQuality)
6084
+ const changed = pruned !== null
6085
+ || JSON.stringify(oldQuality) !== JSON.stringify(nextQuality)
5471
6086
  || JSON.stringify(oldFreshness) !== JSON.stringify(nextFreshness);
5472
6087
  if (changed) {
5473
6088
  writeJson(entry.path, {
5474
- ...entry.packet,
6089
+ ...packet,
5475
6090
  freshness: nextFreshness,
5476
6091
  quality: nextQuality,
5477
6092
  updated_at: nowIso(),
@@ -6622,8 +7237,17 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
6622
7237
  }))
6623
7238
  : undefined,
6624
7239
  };
6625
- if (inputs.trackAccess !== false)
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) {
6626
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
+ }
6627
7251
  return result;
6628
7252
  }
6629
7253
  function recall(projectDir, query, limit = 5, explain = false, inputs = {}) {
@@ -6676,20 +7300,35 @@ function boostTermScore(boost, term) {
6676
7300
  function queryCodeGraph(projectDir, query, limit = 10, graph) {
6677
7301
  graph = graph ?? readCurrentCodeGraph(projectDir) ?? buildCodeGraph(projectDir);
6678
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
+ };
6679
7318
  const files = graph.files
6680
- .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) }))
6681
7320
  .filter((entry) => entry.score > 0)
6682
7321
  .sort((a, b) => b.score - a.score || a.file.path.localeCompare(b.file.path))
6683
7322
  .slice(0, limit)
6684
7323
  .map((entry) => entry.file);
6685
7324
  const symbols = graph.symbols
6686
- .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) }))
6687
7326
  .filter((entry) => entry.score > 0)
6688
7327
  .sort((a, b) => b.score - a.score || a.symbol.path.localeCompare(b.symbol.path) || a.symbol.line - b.symbol.line)
6689
7328
  .slice(0, limit)
6690
7329
  .map((entry) => entry.symbol);
6691
7330
  const routes = graph.routes
6692
- .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) }))
6693
7332
  .filter((entry) => entry.score > 0)
6694
7333
  .sort((a, b) => b.score - a.score || a.route.path.localeCompare(b.route.path))
6695
7334
  .slice(0, limit)
@@ -6716,9 +7355,57 @@ function queryCodeGraph(projectDir, query, limit = 10, graph) {
6716
7355
  .slice(0, limit);
6717
7356
  const symbolIds = new Set(symbols.map((symbol) => symbol.id));
6718
7357
  const symbolNameById = new Map(graph.symbols.map((symbol) => [symbol.id, `${symbol.name} (${symbol.path}:${symbol.line})`]));
6719
- const calls = graph.calls
6720
- .filter((call) => symbolIds.has(call.to_symbol) || Boolean(call.from_symbol && symbolIds.has(call.from_symbol)))
6721
- .slice(0, limit);
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" });
6722
7409
  const structuralIndex = readCurrentStructuralIndex(projectDir);
6723
7410
  const graphPaths = new Set(graph.files.map((file) => file.path));
6724
7411
  const graphSymbolIds = new Set(graph.symbols.map((symbol) => symbol.id));
@@ -6737,7 +7424,7 @@ function queryCodeGraph(projectDir, query, limit = 10, graph) {
6737
7424
  ? structuralIndex.symbols
6738
7425
  .map((symbol) => ({
6739
7426
  symbol,
6740
- 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),
6741
7428
  }))
6742
7429
  .filter((entry) => entry.score > 0 && !graphSymbolIds.has(entry.symbol.id))
6743
7430
  .sort((a, b) => b.score - a.score || a.symbol.path.localeCompare(b.symbol.path) || a.symbol.line - b.symbol.line)
@@ -6764,25 +7451,39 @@ function queryCodeGraph(projectDir, query, limit = 10, graph) {
6764
7451
  "",
6765
7452
  `Query: ${query}`,
6766
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
+ : []),
6767
7462
  files.length || symbols.length || routes.length || tests.length ? "## Code Facts" : "No related source-derived code facts found.",
6768
- ...routes.map((route, index) => `${index + 1}. [route] ${route.method} ${route.path} in ${route.file_path}:${route.line}`),
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.
6769
7466
  ...symbols.map((symbol, index) => `${index + 1}. [symbol] ${symbol.kind} ${symbol.name} in ${symbol.path}:${symbol.line} (${symbol.language}, ${symbol.parser})`),
6770
- ...tests.map((test, index) => `${index + 1}. [test] ${test.title} in ${test.test_path}:${test.line}${test.covers_symbol ? ` covers ${test.covers_symbol}` : ""}`),
6771
- ...files.slice(0, 5).map((file, index) => `${index + 1}. [file] ${file.path} (${file.kind}, ${file.language}, ${file.parser})`),
6772
- structuralFiles.length || structuralSymbols.length || structuralEdges.length ? "" : "",
6773
- structuralFiles.length || structuralSymbols.length || structuralEdges.length ? "## Structural Index" : "",
6774
- ...structuralSymbols.map((symbol, index) => `${index + 1}. [structural symbol] ${symbol.kind} ${symbol.name} in ${symbol.path}:${symbol.line} (${symbol.language}, ${symbol.parser})`),
6775
- ...structuralFiles.slice(0, 5).map((file, index) => `${index + 1}. [structural file] ${file.path} (${file.kind}, ${file.language}, ${file.extraction})`),
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})`),
6776
7474
  ...structuralEdges
6777
7475
  .filter((edge) => edge.relation === "imports")
6778
- .slice(0, 5)
7476
+ .slice(0, symbols.length >= 3 ? 2 : 5)
6779
7477
  .map((edge, index) => `${index + 1}. [structural import] ${edge.source_file}${edge.source_location ? `:${edge.source_location.replace(/^L/, "")}` : ""} -> ${edge.target} (${edge.confidence})`),
6780
7478
  imports.length ? "" : "",
6781
7479
  imports.length ? "## Imports" : "",
6782
- ...imports.map(({ item }, index) => `${index + 1}. ${item.from_path}:${item.line} ${item.kind} ${item.specifier}${item.to_path ? ` -> ${item.to_path}` : ""}`),
6783
- calls.length ? "" : "",
6784
- calls.length ? "## Calls" : "",
6785
- ...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)})`),
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)})`)),
6786
7487
  ];
6787
7488
  return {
6788
7489
  query,
@@ -7597,6 +8298,377 @@ function kageCleanupCandidates(projectDir) {
7597
8298
  summary: `${candidates.length} conservative cleanup candidate(s), ${skippedEntryPoints.length} entrypoint-like source file(s) skipped, ${skippedRuntimeReferences.length} runtime reference(s) skipped.`,
7598
8299
  };
7599
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
+ }
7600
8672
  function kageReviewerSuggestions(projectDir, targets = [], changedFiles = []) {
7601
8673
  const graph = readCurrentCodeGraph(projectDir) ?? buildCodeGraph(projectDir);
7602
8674
  const graphPaths = new Set(graph.files.map((file) => file.path));
@@ -10897,8 +11969,11 @@ function capture(input) {
10897
11969
  };
10898
11970
  }
10899
11971
  const warnings = [];
10900
- const meaningfulPaths = (input.paths ?? [])
10901
- .filter((path) => path && meaningfulMemoryPath(path) && !shouldSkipRepoMemoryPath(path));
11972
+ // .kageignore'd paths (e.g. a presentation/visualization layer) are not knowledge-bearing,
11973
+ // so they never become grounding for a packet — dropped before validation and storage.
11974
+ const groundedPaths = (input.paths ?? []).filter((path) => path && !isGroundingIgnored(input.projectDir, path));
11975
+ const meaningfulPaths = groundedPaths
11976
+ .filter((path) => meaningfulMemoryPath(path) && !shouldSkipRepoMemoryPath(path));
10902
11977
  const missingPaths = meaningfulPaths.filter((path) => !pathExistsInRepo(input.projectDir, path));
10903
11978
  // Citation validation. Strict mode (agent-facing record_memory tools / CLI) rejects a
10904
11979
  // write whose every cited path is missing — the PRD's "reject if citations don't exist".
@@ -10937,7 +12012,7 @@ function capture(input) {
10937
12012
  status: "approved",
10938
12013
  confidence: DEFAULT_CONFIDENCE,
10939
12014
  tags: input.tags ?? [],
10940
- paths: input.paths ?? [],
12015
+ paths: groundedPaths,
10941
12016
  stack: input.stack ?? [],
10942
12017
  source_refs: [
10943
12018
  {
@@ -10949,7 +12024,7 @@ function capture(input) {
10949
12024
  freshness: {
10950
12025
  ttl_days: 365,
10951
12026
  last_verified_at: createdAt,
10952
- path_fingerprints: memoryPathFingerprints(input.projectDir, input.paths ?? []),
12027
+ path_fingerprints: memoryPathFingerprints(input.projectDir, groundedPaths),
10953
12028
  path_fingerprint_policy: "source_hash_staleness",
10954
12029
  verification: "repo_local_agent_capture",
10955
12030
  },
@@ -11123,8 +12198,18 @@ function setupAgent(agent, projectDir, options = {}) {
11123
12198
  if (!exports.SETUP_AGENTS.includes(agent))
11124
12199
  throw new Error(`Unsupported agent: ${agent}`);
11125
12200
  const serverPath = options.serverPath ?? (0, node_path_1.join)(__dirname, "index.js");
11126
- const serverCommand = "node";
11127
- const serverArgs = [serverPath];
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
+ }
11128
12213
  const home = options.homeDir ?? process.env.HOME ?? "~";
11129
12214
  const universal = JSON.stringify({ mcpServers: { kage: { command: serverCommand, args: serverArgs } } }, null, 2);
11130
12215
  const result = {
@@ -11147,7 +12232,7 @@ function setupAgent(agent, projectDir, options = {}) {
11147
12232
  };
11148
12233
  if (agent === "codex") {
11149
12234
  const path = (0, node_path_1.join)(home, ".codex", "config.toml");
11150
- const config = `[mcp_servers.kage]\ncommand = "node"\nargs = ["${serverPath}"]\n`;
12235
+ const config = `[mcp_servers.kage]\ncommand = "${serverCommand}"\nargs = [${serverArgs.map((arg) => JSON.stringify(arg)).join(", ")}]\n`;
11151
12236
  setSnippet(path, config, ["Add this block to ~/.codex/config.toml, then restart Codex.", "Run `kage init --project <repo>` inside each repo."], true);
11152
12237
  if (options.write) {
11153
12238
  ensureDir((0, node_path_1.dirname)(path));
@@ -12501,6 +13586,71 @@ function prSummarize(projectDir) {
12501
13586
  warnings,
12502
13587
  };
12503
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
+ }
12504
13654
  function prCheck(projectDir) {
12505
13655
  ensureMemoryDirs(projectDir);
12506
13656
  const overlay = buildBranchOverlay(projectDir);
@@ -12509,11 +13659,14 @@ function prCheck(projectDir) {
12509
13659
  const tree = gitTree(projectDir);
12510
13660
  const codeInputHash = currentCodeGraphInputHash(projectDir);
12511
13661
  const memoryInputHash = knowledgeGraphInputHash(projectDir, codeInputHash);
12512
- const stalePackets = loadPacketsFromDir(packetsDir(projectDir))
13662
+ const fpCache = new Map();
13663
+ const staleEntries = loadPacketsFromDir(packetsDir(projectDir))
12513
13664
  .filter((packet) => packet.status === "approved" || packet.status === "pending")
12514
- .map((packet) => ({ packet, reasons: staleMemoryReasons(projectDir, packet) }))
12515
- .filter((entry) => entry.reasons.length)
12516
- .map((entry) => staleFinding(entry.packet, entry.reasons));
13665
+ .map((packet) => ({ packet, reasons: staleMemoryReasons(projectDir, packet, fpCache), hard: recallHardStaleReason(projectDir, packet, fpCache) !== null }))
13666
+ .filter((entry) => entry.reasons.length);
13667
+ const stalePackets = staleEntries.map((entry) => staleFinding(entry.packet, entry.reasons));
13668
+ const hardStaleCount = staleEntries.filter((entry) => entry.hard).length;
13669
+ const softStaleCount = staleEntries.length - hardStaleCount;
12517
13670
  const memoryPacketChanges = unique(rawStatus
12518
13671
  .split(/\r?\n/)
12519
13672
  .map(parsePorcelainPath)
@@ -12526,13 +13679,18 @@ function prCheck(projectDir) {
12526
13679
  const errors = [...validation.errors];
12527
13680
  const warnings = [...validation.warnings];
12528
13681
  const requiredActions = [];
12529
- if (stalePackets.length) {
12530
- errors.push(`${stalePackets.length} stale memory packet(s) require update, verification, or supersession.`);
12531
- requiredActions.push("Run kage refresh, then update or supersede stale packets.");
13682
+ // Block only on hard-stale memory (cited files deleted, ttl expired, reported
13683
+ // stale). Soft-stale ("linked code changed since capture") is normal during
13684
+ // active development surface it as a warning, don't fail the gate.
13685
+ if (hardStaleCount) {
13686
+ errors.push(`${hardStaleCount} memory packet(s) are hard-stale (deleted citations, expired ttl, or reported) and must be updated or superseded.`);
13687
+ requiredActions.push("Run kage compact (or kage gc), then update or supersede the affected packets.");
13688
+ }
13689
+ if (softStaleCount) {
13690
+ warnings.push(`${softStaleCount} memory packet(s) reference code that changed since capture — review with kage verify (not blocking).`);
12532
13691
  }
12533
13692
  if (reconciliation.unresolved_count > 0) {
12534
- errors.push(`${reconciliation.unresolved_count} memory reconciliation item(s) require agent update or supersession.`);
12535
- requiredActions.push(...reconciliation.items.slice(0, 5).map((item) => item.next_action));
13693
+ warnings.push(`${reconciliation.unresolved_count} memory reconciliation item(s) may need update after recent code changes (review on handoff; not blocking).`);
12536
13694
  }
12537
13695
  if (!codeGraphCurrent || !memoryGraphCurrent) {
12538
13696
  errors.push("Generated graph artifacts are missing or not current for this working tree content.");
@@ -12540,8 +13698,7 @@ function prCheck(projectDir) {
12540
13698
  }
12541
13699
  const distillableSessions = sessions.sessions.filter((session) => session.durable_observations > 0);
12542
13700
  if (distillableSessions.length) {
12543
- errors.push(`${distillableSessions.length} distillable session learning${distillableSessions.length === 1 ? "" : "s"} require review before merge.`);
12544
- requiredActions.push(...distillableSessions.slice(0, 5).map((session) => session.next_action));
13701
+ warnings.push(`${distillableSessions.length} distillable session learning${distillableSessions.length === 1 ? "" : "s"} pending review (run kage distill; not blocking).`);
12545
13702
  }
12546
13703
  if (!memoryPacketChanges.length && overlay.changed_files.some((path) => !path.startsWith(".agent_memory/"))) {
12547
13704
  warnings.push("No repo memory packet changed for this branch. If durable knowledge was learned, run kage propose --from-diff or kage learn.");
@@ -12860,150 +14017,6 @@ function recallFromPackets(query, packets, limit, label) {
12860
14017
  results: scored,
12861
14018
  };
12862
14019
  }
12863
- function orgStatus(projectDir, org) {
12864
- ensureDir(orgInboxDir(projectDir, org));
12865
- ensureDir(orgPacketsDir(projectDir, org));
12866
- ensureDir(orgRejectedDir(projectDir, org));
12867
- return {
12868
- org: slugify(org),
12869
- path: orgRootDir(projectDir, org),
12870
- inbox: loadOrgInboxPackets(projectDir, org).length,
12871
- approved: loadOrgApprovedPackets(projectDir, org).length,
12872
- rejected: loadPacketsFromDir(orgRejectedDir(projectDir, org)).length,
12873
- audit_events: orgAuditCount(projectDir, org),
12874
- 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,
12875
- };
12876
- }
12877
- function orgUploadPacket(projectDir, org, id) {
12878
- ensureMemoryDirs(projectDir);
12879
- ensureDir(orgInboxDir(projectDir, org));
12880
- const source = loadApprovedPackets(projectDir).find((packet) => packet.id === id);
12881
- if (!source)
12882
- return { ok: false, errors: [`Approved packet not found: ${id}`] };
12883
- if (["blocked", "confidential"].includes(source.sensitivity)) {
12884
- return { ok: false, errors: [`Packet sensitivity cannot be uploaded to org memory: ${source.sensitivity}`] };
12885
- }
12886
- const findings = scanSensitiveText(`${source.title}\n${source.summary}\n${source.body}\n${source.paths.join("\n")}`);
12887
- if (findings.length)
12888
- return { ok: false, errors: [`Sensitive content blocked: ${unique(findings).join(", ")}`] };
12889
- const createdAt = nowIso();
12890
- const packet = {
12891
- ...source,
12892
- id: `org:${slugify(org)}:${(0, node_crypto_1.createHash)("sha256").update(source.id).digest("hex").slice(0, 16)}:${slugify(source.title)}`,
12893
- scope: "org",
12894
- visibility: "org",
12895
- sensitivity: source.sensitivity === "public" ? "public" : "internal",
12896
- status: "pending",
12897
- tags: unique([...source.tags, "org-candidate"]).sort(),
12898
- source_refs: [
12899
- ...source.source_refs,
12900
- {
12901
- kind: "org_upload_candidate",
12902
- source_packet_id: source.id,
12903
- repo_key: repoKey(projectDir),
12904
- },
12905
- ],
12906
- quality: {
12907
- ...source.quality,
12908
- org_review_required: true,
12909
- source_packet_id: source.id,
12910
- },
12911
- created_at: createdAt,
12912
- updated_at: createdAt,
12913
- };
12914
- const validation = validatePacket(packet, "org candidate");
12915
- if (!validation.ok)
12916
- return { ok: false, errors: validation.errors };
12917
- const path = (0, node_path_1.join)(orgInboxDir(projectDir, org), packetFileName(packet));
12918
- writeJson(path, packet);
12919
- appendOrgAudit(projectDir, org, { action: "upload_candidate", packet_id: packet.id, source_packet_id: source.id });
12920
- return { ok: true, packet, path, errors: [] };
12921
- }
12922
- function orgReviewPacket(projectDir, org, id, action) {
12923
- ensureDir(orgInboxDir(projectDir, org));
12924
- ensureDir(orgPacketsDir(projectDir, org));
12925
- ensureDir(orgRejectedDir(projectDir, org));
12926
- const sourcePath = walkFiles(orgInboxDir(projectDir, org), (path) => path.endsWith(".json"))
12927
- .find((path) => readJson(path).id === id);
12928
- if (!sourcePath)
12929
- return { ok: false, errors: [`Org inbox packet not found: ${id}`] };
12930
- const packet = readJson(sourcePath);
12931
- packet.status = action === "approve" ? "approved" : "deprecated";
12932
- packet.updated_at = nowIso();
12933
- packet.quality = {
12934
- ...packet.quality,
12935
- org_reviewed_at: packet.updated_at,
12936
- org_review_action: action,
12937
- };
12938
- const targetDir = action === "approve" ? orgPacketsDir(projectDir, org) : orgRejectedDir(projectDir, org);
12939
- const targetPath = (0, node_path_1.join)(targetDir, packetFileName(packet));
12940
- writeJson(targetPath, packet);
12941
- (0, node_fs_1.renameSync)(sourcePath, `${sourcePath}.reviewed`);
12942
- appendOrgAudit(projectDir, org, { action: `review_${action}`, packet_id: packet.id });
12943
- exportOrgRegistry(projectDir, org);
12944
- return { ok: true, path: targetPath, errors: [] };
12945
- }
12946
- function orgRecall(projectDir, org, query, limit = 5) {
12947
- return recallFromPackets(query, loadOrgApprovedPackets(projectDir, org), limit, `Org:${slugify(org)}`);
12948
- }
12949
- function layeredRecall(projectDir, query, options = {}) {
12950
- const limit = options.limit ?? 5;
12951
- const repo = recall(projectDir, query, limit, true);
12952
- const org = options.org ? orgRecall(projectDir, options.org, query, limit) : undefined;
12953
- const global = options.includeGlobal ? recallFromPackets(query, loadPacketsFromDir(publicCandidatesDir(projectDir)), limit, "Global") : undefined;
12954
- const blocks = [
12955
- "# Kage Layered Recall",
12956
- "",
12957
- "Priority: branch > repo local > org > global",
12958
- "",
12959
- repo.context_block,
12960
- org ? `\n---\n\n${org.context_block}` : "",
12961
- global ? `\n---\n\n${global.context_block}` : "",
12962
- ].filter(Boolean);
12963
- return {
12964
- query,
12965
- priority_order: ["branch", "repo", ...(org ? ["org"] : []), ...(global ? ["global"] : [])],
12966
- context_block: blocks.join("\n"),
12967
- repo,
12968
- ...(org ? { org } : {}),
12969
- ...(global ? { global } : {}),
12970
- };
12971
- }
12972
- function exportOrgRegistry(projectDir, org) {
12973
- const packets = loadOrgApprovedPackets(projectDir, org);
12974
- const payload = {
12975
- schema_version: 1,
12976
- org: slugify(org),
12977
- repo_key: repoKey(projectDir),
12978
- generated_at: nowIso(),
12979
- metrics: {
12980
- packets: packets.length,
12981
- by_type: countBy(packets, (packet) => packet.type),
12982
- by_repo_path: countBy(packets.flatMap((packet) => packet.paths), (path) => path),
12983
- },
12984
- packets: packets.map((packet) => ({
12985
- id: packet.id,
12986
- title: packet.title,
12987
- summary: packet.summary,
12988
- type: packet.type,
12989
- tags: packet.tags,
12990
- paths: packet.paths,
12991
- source_refs: packet.source_refs,
12992
- updated_at: packet.updated_at,
12993
- content_sha256: (0, node_crypto_1.createHash)("sha256").update(canonicalPacketText(packet)).digest("hex"),
12994
- })),
12995
- };
12996
- const manifest = (0, index_js_1.createSignedManifest)({
12997
- kind: "org_registry",
12998
- name: `${slugify(org)} org memory`,
12999
- version: nowIso().slice(0, 10),
13000
- keyId: `${slugify(org)}-local`,
13001
- payload,
13002
- });
13003
- writeJson((0, node_path_1.join)(orgRootDir(projectDir, org), "registry.json"), manifest);
13004
- appendOrgAudit(projectDir, org, { action: "export_registry", packets: packets.length });
13005
- return orgStatus(projectDir, org);
13006
- }
13007
14020
  function canonicalPacketText(packet) {
13008
14021
  return JSON.stringify({
13009
14022
  title: packet.title,
@@ -13014,86 +14027,6 @@ function canonicalPacketText(packet) {
13014
14027
  paths: packet.paths,
13015
14028
  });
13016
14029
  }
13017
- function buildMarketplace(projectDir) {
13018
- ensureMemoryDirs(projectDir);
13019
- const packs = registryRecommendations(projectDir).map((item) => ({
13020
- ...item,
13021
- source: "repo_metadata",
13022
- }));
13023
- const manifest = {
13024
- schema_version: 1,
13025
- project_dir: projectDir,
13026
- generated_at: nowIso(),
13027
- packs,
13028
- install_policy: "explicit_human_approval_required",
13029
- };
13030
- const path = (0, node_path_1.join)(marketplaceDir(projectDir), "manifest.json");
13031
- writeJson(path, manifest);
13032
- const planLines = [
13033
- "# Kage Marketplace Install Plan",
13034
- "",
13035
- "Kage never installs marketplace assets automatically. Review each pack, then install it with your agent's normal trusted setup flow.",
13036
- "",
13037
- ...packs.flatMap((pack) => [
13038
- `## ${pack.title}`,
13039
- "",
13040
- `- ID: \`${pack.id}\``,
13041
- `- Kind: \`${pack.kind}\``,
13042
- `- Trust: \`${pack.trust}\``,
13043
- `- Install policy: \`${pack.install}\``,
13044
- `- Matched: ${pack.matched.join(", ") || "(repo metadata)"}`,
13045
- "",
13046
- pack.summary,
13047
- "",
13048
- ]),
13049
- ];
13050
- (0, node_fs_1.writeFileSync)((0, node_path_1.join)(marketplaceDir(projectDir), "install-plan.md"), `${planLines.join("\n").trim()}\n`, "utf8");
13051
- return { ok: true, path, packs, errors: [] };
13052
- }
13053
- function buildGlobalCdnBundle(projectDir, org = "local") {
13054
- ensureMemoryDirs(projectDir);
13055
- const publicBundle = exportPublicBundle(projectDir);
13056
- if (!publicBundle.ok) {
13057
- return { ok: false, root: globalCdnDir(projectDir), packet_count: 0, marketplace_packs: 0, errors: publicBundle.errors };
13058
- }
13059
- const marketplace = buildMarketplace(projectDir);
13060
- const publicManifest = readJson(publicBundle.path);
13061
- const registryManifest = (0, index_js_1.generateOrgRegistryManifest)({
13062
- org: slugify(org),
13063
- version: nowIso().slice(0, 10),
13064
- keyId: `${slugify(org)}-global-local`,
13065
- bundles: [publicManifest],
13066
- });
13067
- const root = globalCdnDir(projectDir);
13068
- const digest = registryManifest.signature.payload_sha256.slice(0, 16);
13069
- const manifestPath = (0, node_path_1.join)(root, `registry.${digest}.json`);
13070
- const aliasPath = (0, node_path_1.join)(root, "latest.json");
13071
- writeJson(manifestPath, registryManifest);
13072
- writeJson((0, node_path_1.join)(root, "registry.json"), registryManifest);
13073
- writeJson((0, node_path_1.join)(root, "revocations.json"), {
13074
- schema_version: 1,
13075
- generated_at: nowIso(),
13076
- revoked: [],
13077
- });
13078
- writeJson(aliasPath, {
13079
- schema_version: 1,
13080
- registry: (0, node_path_1.relative)(root, manifestPath),
13081
- marketplace: (0, node_path_1.relative)(root, marketplace.path),
13082
- payload_sha256: registryManifest.signature.payload_sha256,
13083
- generated_at: registryManifest.generated_at,
13084
- rollback_ready: true,
13085
- });
13086
- return {
13087
- ok: true,
13088
- root,
13089
- manifest_path: manifestPath,
13090
- alias_path: aliasPath,
13091
- marketplace_path: marketplace.path,
13092
- packet_count: registryManifest.payload.metrics.entry_count,
13093
- marketplace_packs: marketplace.packs.length,
13094
- errors: [],
13095
- };
13096
- }
13097
14030
  function recordFeedback(projectDir, id, feedback) {
13098
14031
  ensureMemoryDirs(projectDir);
13099
14032
  if (!["helpful", "wrong", "stale"].includes(feedback)) {
@@ -13244,13 +14177,19 @@ function installClaudeSettings(projectDir) {
13244
14177
  settings.allowedTools = merged;
13245
14178
  writeJson(settingsPath, settings);
13246
14179
  }
13247
- function initProject(projectDir) {
13248
- installAgentPolicy(projectDir);
13249
- installClaudeSettings(projectDir);
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
+ }
13250
14189
  const index = indexProject(projectDir, { graphs: false });
13251
14190
  const validation = validateProject(projectDir);
13252
14191
  const sampleRecall = recallFromPackets("how do I run tests", loadApprovedPackets(projectDir), 5, "Repo Memory");
13253
- return { index, validation, sampleRecall };
14192
+ return { index, validation, sampleRecall, policyInstalled };
13254
14193
  }
13255
14194
  function doctorProject(projectDir) {
13256
14195
  ensureMemoryDirs(projectDir);