@kage-core/kage-graph-mcp 1.4.0 → 2.0.0

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