@kage-core/kage-graph-mcp 1.2.0 → 1.4.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,7 @@ exports.makePacketId = makePacketId;
62
62
  exports.parseFrontmatter = parseFrontmatter;
63
63
  exports.kageMemoryAccess = kageMemoryAccess;
64
64
  exports.kageMemoryLifecycle = kageMemoryLifecycle;
65
+ exports.kageActivity = kageActivity;
65
66
  exports.kageMemoryReconciliation = kageMemoryReconciliation;
66
67
  exports.evaluateMemoryAdmission = evaluateMemoryAdmission;
67
68
  exports.validatePacket = validatePacket;
@@ -83,6 +84,7 @@ exports.buildIndexes = buildIndexes;
83
84
  exports.indexProject = indexProject;
84
85
  exports.refreshProject = refreshProject;
85
86
  exports.gcProject = gcProject;
87
+ exports.kageSuppressedMemory = kageSuppressedMemory;
86
88
  exports.verifyCitations = verifyCitations;
87
89
  exports.compactProject = compactProject;
88
90
  exports.installAgentPolicy = installAgentPolicy;
@@ -114,6 +116,8 @@ exports.kageMetrics = kageMetrics;
114
116
  exports.auditProject = auditProject;
115
117
  exports.memoryInbox = memoryInbox;
116
118
  exports.qualityReport = qualityReport;
119
+ exports.benchmarkTrust = benchmarkTrust;
120
+ exports.runDemo = runDemo;
117
121
  exports.benchmarkProject = benchmarkProject;
118
122
  exports.benchmarkCodingMemoryQuality = benchmarkCodingMemoryQuality;
119
123
  exports.benchmarkMemoryScale = benchmarkMemoryScale;
@@ -870,6 +874,8 @@ function kageMemoryLifecycle(projectDir) {
870
874
  const item = {
871
875
  packet_id: packet.id,
872
876
  title: packet.title,
877
+ summary: packet.summary ?? "",
878
+ body: packet.body ?? "",
873
879
  type: packet.type,
874
880
  status: packet.status,
875
881
  health: action.health,
@@ -954,6 +960,54 @@ function recordRecallAccess(projectDir, results) {
954
960
  // Recall should never fail because local access telemetry could not be updated.
955
961
  }
956
962
  }
963
+ const AUDIT_ACTIVITY_KIND = {
964
+ capture: "capture", approve: "capture", supersede: "supersede", deprecate: "deprecate",
965
+ update: "update", promote: "promote", feedback: "feedback",
966
+ };
967
+ function kageActivity(projectDir, options = {}) {
968
+ const limit = options.limit ?? 80;
969
+ const events = [];
970
+ let recalls = 0;
971
+ readMemoryAccessEntries(projectDir).forEach((entry) => {
972
+ (entry.recent ?? []).forEach((r) => {
973
+ if (!r || !r.at)
974
+ return;
975
+ recalls += 1;
976
+ events.push({ at: r.at, kind: "recall", title: entry.title, detail: `recalled · rank ${r.rank}` });
977
+ });
978
+ });
979
+ let captures = 0;
980
+ for (const audit of loadMemoryAuditEntries(projectDir)) {
981
+ const kind = AUDIT_ACTIVITY_KIND[audit.operation] ?? "other";
982
+ if (kind === "capture")
983
+ captures += 1;
984
+ const extra = audit.packet_titles.length > 1 ? ` (+${audit.packet_titles.length - 1} more)` : "";
985
+ events.push({ at: audit.timestamp, kind, title: (audit.packet_titles[0] ?? audit.operation) + extra, detail: audit.operation, actor: audit.actor });
986
+ }
987
+ events.sort((a, b) => (Date.parse(b.at) || 0) - (Date.parse(a.at) || 0));
988
+ const dayMap = new Map();
989
+ events.forEach((e) => { if (e.kind === "recall") {
990
+ const d = e.at.slice(0, 10);
991
+ dayMap.set(d, (dayMap.get(d) ?? 0) + 1);
992
+ } });
993
+ // Zero-fill the last 14 calendar days so the chart reads as a timeline, not a lone bar.
994
+ const daily = [];
995
+ for (let i = 13; i >= 0; i -= 1) {
996
+ const day = new Date(Date.now() - i * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
997
+ daily.push({ day, recalls: dayMap.get(day) ?? 0 });
998
+ }
999
+ const cutoff7 = Date.now() - 7 * 24 * 60 * 60 * 1000;
1000
+ const recalls7d = events.filter((e) => e.kind === "recall" && (Date.parse(e.at) || 0) >= cutoff7).length;
1001
+ return {
1002
+ schema_version: 1,
1003
+ project_dir: projectDir,
1004
+ generated_at: nowIso(),
1005
+ window_days: ACCESS_WINDOW_DAYS,
1006
+ totals: { events: events.length, recalls, captures, recalls_7d: recalls7d },
1007
+ daily,
1008
+ events: events.slice(0, limit),
1009
+ };
1010
+ }
957
1011
  function isGeneratedChangeMemory(packet) {
958
1012
  return packet.type === "workflow"
959
1013
  && packet.tags.includes("change-memory")
@@ -1119,7 +1173,7 @@ function staleMemoryReasons(projectDir, packet, fingerprintCache) {
1119
1173
  if (ageDays > ttlDays)
1120
1174
  reasons.push(`freshness ttl expired (${Math.floor(ageDays)}d old, ttl ${ttlDays}d)`);
1121
1175
  }
1122
- const paths = packet.paths.filter(meaningfulMemoryPath);
1176
+ const paths = packet.paths.filter((path) => meaningfulMemoryPath(path) && !isGroundingIgnored(projectDir, path));
1123
1177
  const missingPaths = paths.filter((path) => !(0, node_fs_1.existsSync)((0, node_path_1.join)(projectDir, path)));
1124
1178
  if (paths.length > 0 && missingPaths.length === paths.length) {
1125
1179
  reasons.push(`all referenced paths are missing: ${missingPaths.slice(0, 4).join(", ")}`);
@@ -1130,6 +1184,7 @@ function staleMemoryReasons(projectDir, packet, fingerprintCache) {
1130
1184
  if (freshness.path_fingerprint_policy === "source_hash_staleness") {
1131
1185
  const storedFingerprints = packetStoredPathFingerprints(packet);
1132
1186
  const changedPaths = storedFingerprints
1187
+ .filter((fingerprint) => !isGroundingIgnored(projectDir, fingerprint.path))
1133
1188
  .filter((fingerprint) => (0, node_fs_1.existsSync)((0, node_path_1.join)(projectDir, fingerprint.path)))
1134
1189
  .filter((fingerprint) => {
1135
1190
  const current = memoryPathFingerprint(projectDir, fingerprint.path, fingerprintCache);
@@ -2338,7 +2393,7 @@ function pathExistsInRepo(projectDir, packetPath) {
2338
2393
  }
2339
2394
  function packetGroundingWarnings(projectDir, packet, source) {
2340
2395
  const warnings = [];
2341
- const meaningfulPaths = packet.paths.filter((path) => path && path !== "root" && !shouldSkipRepoMemoryPath(path));
2396
+ const meaningfulPaths = packet.paths.filter((path) => path && path !== "root" && !shouldSkipRepoMemoryPath(path) && !isGroundingIgnored(projectDir, path));
2342
2397
  const missingPaths = meaningfulPaths.filter((path) => !pathExistsInRepo(projectDir, path));
2343
2398
  if (meaningfulPaths.length && missingPaths.length === meaningfulPaths.length) {
2344
2399
  warnings.push(`${source}: none of the referenced paths exist in this repo: ${missingPaths.join(", ")}`);
@@ -2802,6 +2857,54 @@ function readKageIgnore(projectDir) {
2802
2857
  .map((line) => line.trim())
2803
2858
  .filter((line) => line.length > 0 && !line.startsWith("#"));
2804
2859
  }
2860
+ // A repo can declare non-knowledge paths (e.g. a presentation/visualization layer)
2861
+ // in .kageignore. Those paths must not count as memory grounding: memory should never
2862
+ // be anchored to, or marked stale by, files the repo says are not knowledge-bearing.
2863
+ function normalizeRelPath(path) {
2864
+ return String(path).replace(/\\/g, "/").replace(/^\/+/, "");
2865
+ }
2866
+ function isGroundingIgnored(projectDir, path) {
2867
+ const patterns = readKageIgnore(projectDir);
2868
+ if (!patterns.length)
2869
+ return false;
2870
+ return isKageIgnored(normalizeRelPath(path), patterns);
2871
+ }
2872
+ // Strip .kageignore'd paths from a packet's grounding (paths, source refs, and
2873
+ // path fingerprints). Returns a new packet if anything changed, else null.
2874
+ function prunePacketGroundingPaths(packet, patterns) {
2875
+ if (!patterns.length)
2876
+ return null;
2877
+ const ignored = (p) => typeof p === "string" && isKageIgnored(normalizeRelPath(p), patterns);
2878
+ let changed = false;
2879
+ const paths = packet.paths.filter((p) => (ignored(p) ? ((changed = true), false) : true));
2880
+ const sourceRefs = packet.source_refs.map((ref) => {
2881
+ const next = { ...ref };
2882
+ if (ignored(next.path)) {
2883
+ delete next.path;
2884
+ changed = true;
2885
+ }
2886
+ if (Array.isArray(next.changed_files)) {
2887
+ const kept = next.changed_files.filter((f) => !ignored(f));
2888
+ if (kept.length !== next.changed_files.length) {
2889
+ next.changed_files = kept;
2890
+ changed = true;
2891
+ }
2892
+ }
2893
+ return next;
2894
+ });
2895
+ const freshness = { ...(packet.freshness ?? {}) };
2896
+ if (Array.isArray(freshness.path_fingerprints)) {
2897
+ const fps = freshness.path_fingerprints;
2898
+ const kept = fps.filter((f) => !ignored(f?.path));
2899
+ if (kept.length !== fps.length) {
2900
+ freshness.path_fingerprints = kept;
2901
+ changed = true;
2902
+ }
2903
+ }
2904
+ if (!changed)
2905
+ return null;
2906
+ return { ...packet, paths, source_refs: sourceRefs, freshness };
2907
+ }
2805
2908
  function wildcardPattern(pattern) {
2806
2909
  const escaped = pattern
2807
2910
  .replace(/[.+^${}()|[\]\\]/g, "\\$&")
@@ -5444,13 +5547,18 @@ function refreshPacketStaleness(projectDir) {
5444
5547
  const findings = [];
5445
5548
  let updated = 0;
5446
5549
  const fingerprintCache = new Map();
5550
+ const ignorePatterns = readKageIgnore(projectDir);
5447
5551
  for (const entry of loadPacketEntriesFromDir(packetsDir(projectDir))) {
5448
- const reasons = staleMemoryReasons(projectDir, entry.packet, fingerprintCache);
5449
- const oldQuality = (entry.packet.quality ?? {});
5450
- const oldFreshness = (entry.packet.freshness ?? {});
5552
+ // Drop any .kageignore'd grounding (presentation layers etc.) from the stored packet
5553
+ // so memory is never anchored to non-knowledge files.
5554
+ const pruned = prunePacketGroundingPaths(entry.packet, ignorePatterns);
5555
+ const packet = pruned ?? entry.packet;
5556
+ const reasons = staleMemoryReasons(projectDir, packet, fingerprintCache);
5557
+ const oldQuality = (packet.quality ?? {});
5558
+ const oldFreshness = (packet.freshness ?? {});
5451
5559
  let nextQuality;
5452
5560
  if (reasons.length) {
5453
- const finding = staleFinding(entry.packet, reasons);
5561
+ const finding = staleFinding(packet, reasons);
5454
5562
  findings.push(finding);
5455
5563
  nextQuality = {
5456
5564
  ...oldQuality,
@@ -5464,11 +5572,12 @@ function refreshPacketStaleness(projectDir) {
5464
5572
  nextQuality = rest;
5465
5573
  }
5466
5574
  const nextFreshness = oldFreshness;
5467
- const changed = JSON.stringify(oldQuality) !== JSON.stringify(nextQuality)
5575
+ const changed = pruned !== null
5576
+ || JSON.stringify(oldQuality) !== JSON.stringify(nextQuality)
5468
5577
  || JSON.stringify(oldFreshness) !== JSON.stringify(nextFreshness);
5469
5578
  if (changed) {
5470
5579
  writeJson(entry.path, {
5471
- ...entry.packet,
5580
+ ...packet,
5472
5581
  freshness: nextFreshness,
5473
5582
  quality: nextQuality,
5474
5583
  updated_at: nowIso(),
@@ -5595,8 +5704,20 @@ function gcProject(projectDir, options = {}) {
5595
5704
  total_scanned: packetEntries.length,
5596
5705
  };
5597
5706
  }
5598
- // On-demand citation/freshness check the agent can call before trusting a memory.
5599
- // Pass an id to verify one packet, or omit to audit all approved memory.
5707
+ // The memory recall is actively WITHHOLDING from agents right now (hard-stale:
5708
+ // cited files deleted, ttl expired, or reported stale). This is the human-facing
5709
+ // counterpart to the silent recall-time exclusion — surfaced, never hidden.
5710
+ function kageSuppressedMemory(projectDir) {
5711
+ ensureMemoryDirs(projectDir);
5712
+ const cache = new Map();
5713
+ const items = loadApprovedPackets(projectDir)
5714
+ .map((packet) => {
5715
+ const reason = recallHardStaleReason(projectDir, packet, cache);
5716
+ return reason ? { id: packet.id, title: packet.title, type: packet.type, reason, paths: packet.paths } : null;
5717
+ })
5718
+ .filter((entry) => entry !== null);
5719
+ return { schema_version: 1, generated_at: nowIso(), count: items.length, items };
5720
+ }
5600
5721
  function verifyCitations(projectDir, options = {}) {
5601
5722
  ensureMemoryDirs(projectDir);
5602
5723
  const approved = loadApprovedPackets(projectDir);
@@ -9838,6 +9959,152 @@ function qualityReport(projectDir) {
9838
9959
  packets: rows,
9839
9960
  };
9840
9961
  }
9962
+ // The Trust Benchmark measures what retrieval benchmarks cannot: whether the memory
9963
+ // system can be TRUSTED — does it refuse to store hallucinated citations, does it
9964
+ // withhold memory whose evidence was deleted, and is live repo memory actually grounded.
9965
+ // Controlled gates run in an isolated sandbox; the grounding gate runs on the real repo.
9966
+ function benchmarkTrust(projectDir) {
9967
+ const runDir = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "kage-trust-"));
9968
+ const sandbox = (0, node_path_1.join)(runDir, "project");
9969
+ try {
9970
+ ensureMemoryDirs(sandbox);
9971
+ (0, node_fs_1.mkdirSync)((0, node_path_1.join)(sandbox, "src"), { recursive: true });
9972
+ // Gate 1 — Hallucinated-citation rejection: a strict capture whose every cited path
9973
+ // is missing must be rejected. (No competitor validates citations at write time.)
9974
+ const hallucinationAttempts = 8;
9975
+ let rejected = 0;
9976
+ for (let i = 0; i < hallucinationAttempts; i += 1) {
9977
+ const result = capture({
9978
+ projectDir: sandbox,
9979
+ title: `Hallucinated rule ${i}`,
9980
+ body: `Use the helper in src/ghost-${i}.ts for retry handling.`,
9981
+ type: "decision",
9982
+ paths: [`src/ghost-${i}.ts`],
9983
+ strictCitations: true,
9984
+ });
9985
+ if (!result.ok)
9986
+ rejected += 1;
9987
+ }
9988
+ // Gate 2 — Stale-memory exclusion: memory grounded in real files at capture time
9989
+ // must be withheld from recall once those files are deleted (the "deleted since
9990
+ // capture" signal). We only count memories that were recallable BEFORE deletion.
9991
+ const staleAttempts = 8;
9992
+ const recallableBefore = [];
9993
+ for (let i = 0; i < staleAttempts; i += 1) {
9994
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(sandbox, "src", `widget-${i}.ts`), `export const widget${i} = ${i};\n`, "utf8");
9995
+ capture({
9996
+ projectDir: sandbox,
9997
+ title: `Widget ${i} retry invariant`,
9998
+ body: `Widget ${i} retries use idempotency token zeta${i} in src/widget-${i}.ts to avoid duplicate charges.`,
9999
+ type: "decision",
10000
+ paths: [`src/widget-${i}.ts`],
10001
+ });
10002
+ }
10003
+ for (let i = 0; i < staleAttempts; i += 1) {
10004
+ const before = recall(sandbox, `widget ${i} retry idempotency token zeta${i}`, 5, false, { trackAccess: false });
10005
+ recallableBefore[i] = before.results.some((entry) => entry.packet.title === `Widget ${i} retry invariant`);
10006
+ }
10007
+ for (let i = 0; i < staleAttempts; i += 1) {
10008
+ (0, node_fs_1.rmSync)((0, node_path_1.join)(sandbox, "src", `widget-${i}.ts`), { force: true });
10009
+ }
10010
+ let recallableCount = 0;
10011
+ let excludedAfter = 0;
10012
+ for (let i = 0; i < staleAttempts; i += 1) {
10013
+ if (!recallableBefore[i])
10014
+ continue;
10015
+ recallableCount += 1;
10016
+ const after = recall(sandbox, `widget ${i} retry idempotency token zeta${i}`, 5, false, { trackAccess: false });
10017
+ const surfaced = after.results.some((entry) => entry.packet.title === `Widget ${i} retry invariant`);
10018
+ if (!surfaced)
10019
+ excludedAfter += 1;
10020
+ }
10021
+ // Gate 3 — Live grounding: how much of the real repo's approved memory is grounded
10022
+ // (cited files exist) and not stale.
10023
+ const verify = verifyCitations(projectDir);
10024
+ const liveChecked = verify.checked;
10025
+ const grounded = verify.packets.filter((entry) => entry.grounded && !entry.stale).length;
10026
+ const hallucinationRate = percent(rejected, hallucinationAttempts);
10027
+ const staleRate = percent(excludedAfter, recallableCount || staleAttempts);
10028
+ const liveGroundingRate = liveChecked > 0 ? percent(grounded, liveChecked) : 100;
10029
+ const wrongAdvicePrevented = percent(rejected + excludedAfter, hallucinationAttempts + (recallableCount || staleAttempts));
10030
+ const gates = [
10031
+ { name: "hallucinated_citation_rejection", target: 100, actual: hallucinationRate, unit: "percent", pass: hallucinationRate >= 100 },
10032
+ { name: "stale_memory_exclusion", target: 100, actual: staleRate, unit: "percent", pass: staleRate >= 100 },
10033
+ { name: "live_grounding_rate", target: 80, actual: liveGroundingRate, unit: "percent", pass: liveGroundingRate >= 80 },
10034
+ ];
10035
+ const trustScore = Math.round((hallucinationRate + staleRate + liveGroundingRate) / 3);
10036
+ return {
10037
+ schema_version: 1,
10038
+ generated_at: nowIso(),
10039
+ ok: gates.every((gate) => gate.pass),
10040
+ trust_score: trustScore,
10041
+ gates,
10042
+ metrics: {
10043
+ hallucinated_citation_rejection_rate: hallucinationRate,
10044
+ stale_memory_exclusion_rate: staleRate,
10045
+ live_grounding_rate: liveGroundingRate,
10046
+ wrong_advice_prevented_rate: wrongAdvicePrevented,
10047
+ },
10048
+ detail: {
10049
+ hallucination: { attempted: hallucinationAttempts, rejected },
10050
+ staleness: { recallable_before: recallableCount, excluded_after: excludedAfter },
10051
+ live_memory: { checked: liveChecked, grounded, stale: verify.stale },
10052
+ },
10053
+ };
10054
+ }
10055
+ finally {
10056
+ (0, node_fs_1.rmSync)(runDir, { recursive: true, force: true });
10057
+ }
10058
+ }
10059
+ // `kage demo`: a self-contained 60-second proof of the trust wedge. Seeds a tiny
10060
+ // repo with grounded memory, then shows Kage (1) reject a hallucinated citation,
10061
+ // (2) withhold a memory whose cited file was deleted, and (3) recall only grounded
10062
+ // memory — the three things that make agent memory trustworthy.
10063
+ function runDemo(demoDir) {
10064
+ (0, node_fs_1.rmSync)(demoDir, { recursive: true, force: true });
10065
+ (0, node_fs_1.mkdirSync)((0, node_path_1.join)(demoDir, "src"), { recursive: true });
10066
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(demoDir, "src", "auth.ts"), "export function validateToken() { return true; }\n", "utf8");
10067
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(demoDir, "src", "payments.ts"), "export function charge() { return 'ok'; }\n", "utf8");
10068
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(demoDir, "src", "legacy-retry.ts"), "export function retry() { return 1; }\n", "utf8");
10069
+ ensureMemoryDirs(demoDir);
10070
+ const captured = [];
10071
+ for (const m of [
10072
+ { title: "Auth uses jose, not jsonwebtoken", body: "Validate tokens with jose in src/auth.ts; jsonwebtoken was removed.", paths: ["src/auth.ts"] },
10073
+ { title: "Payments must be idempotent", body: "charge() in src/payments.ts must be idempotent to avoid double charges.", paths: ["src/payments.ts"] },
10074
+ { title: "Legacy retry helper is the fallback", body: "Old retry logic lives in src/legacy-retry.ts and is used as a fallback.", paths: ["src/legacy-retry.ts"] },
10075
+ ]) {
10076
+ const r = capture({ projectDir: demoDir, title: m.title, body: m.body, type: "decision", paths: m.paths });
10077
+ if (r.ok && r.packet)
10078
+ captured.push(r.packet.title);
10079
+ }
10080
+ // (2) delete a cited file → that memory becomes stale and is withheld from recall.
10081
+ (0, node_fs_1.unlinkSync)((0, node_path_1.join)(demoDir, "src", "legacy-retry.ts"));
10082
+ // (1) a hallucinated citation is rejected at write time.
10083
+ const hallucinated = capture({
10084
+ projectDir: demoDir,
10085
+ title: "Use the helper in src/ghost.ts",
10086
+ body: "Retry handling lives in src/ghost.ts.",
10087
+ type: "decision",
10088
+ paths: ["src/ghost.ts"],
10089
+ strictCitations: true,
10090
+ });
10091
+ const rejected = hallucinated.ok ? null : { title: "Use the helper in src/ghost.ts", error: hallucinated.errors[0] ?? "rejected" };
10092
+ // (3) recall surfaces grounded memory; the stale one is withheld.
10093
+ const recall = recallWithVectorScores(demoDir, "auth token payments retry idempotency", 5, false, { trackAccess: false });
10094
+ const recalled = recall.results.map((entry) => entry.packet.title);
10095
+ const withheld = kageSuppressedMemory(demoDir).items.map((item) => ({ title: item.title, reason: item.reason }));
10096
+ const trust = benchmarkTrust(demoDir);
10097
+ return {
10098
+ ok: true,
10099
+ project_dir: demoDir,
10100
+ captured,
10101
+ rejected_hallucination: rejected,
10102
+ recalled,
10103
+ withheld,
10104
+ trust_score: trust.trust_score,
10105
+ viewer_command: `kage viewer --project ${demoDir}`,
10106
+ };
10107
+ }
9841
10108
  function benchmarkProject(projectDir, inputs = {}) {
9842
10109
  ensureMemoryDirs(projectDir);
9843
10110
  const built = inputs.codeGraph && inputs.knowledgeGraph ? null : currentOrBuildGraphs(projectDir);
@@ -10736,8 +11003,11 @@ function capture(input) {
10736
11003
  };
10737
11004
  }
10738
11005
  const warnings = [];
10739
- const meaningfulPaths = (input.paths ?? [])
10740
- .filter((path) => path && meaningfulMemoryPath(path) && !shouldSkipRepoMemoryPath(path));
11006
+ // .kageignore'd paths (e.g. a presentation/visualization layer) are not knowledge-bearing,
11007
+ // so they never become grounding for a packet — dropped before validation and storage.
11008
+ const groundedPaths = (input.paths ?? []).filter((path) => path && !isGroundingIgnored(input.projectDir, path));
11009
+ const meaningfulPaths = groundedPaths
11010
+ .filter((path) => meaningfulMemoryPath(path) && !shouldSkipRepoMemoryPath(path));
10741
11011
  const missingPaths = meaningfulPaths.filter((path) => !pathExistsInRepo(input.projectDir, path));
10742
11012
  // Citation validation. Strict mode (agent-facing record_memory tools / CLI) rejects a
10743
11013
  // write whose every cited path is missing — the PRD's "reject if citations don't exist".
@@ -10776,7 +11046,7 @@ function capture(input) {
10776
11046
  status: "approved",
10777
11047
  confidence: DEFAULT_CONFIDENCE,
10778
11048
  tags: input.tags ?? [],
10779
- paths: input.paths ?? [],
11049
+ paths: groundedPaths,
10780
11050
  stack: input.stack ?? [],
10781
11051
  source_refs: [
10782
11052
  {
@@ -10788,7 +11058,7 @@ function capture(input) {
10788
11058
  freshness: {
10789
11059
  ttl_days: 365,
10790
11060
  last_verified_at: createdAt,
10791
- path_fingerprints: memoryPathFingerprints(input.projectDir, input.paths ?? []),
11061
+ path_fingerprints: memoryPathFingerprints(input.projectDir, groundedPaths),
10792
11062
  path_fingerprint_policy: "source_hash_staleness",
10793
11063
  verification: "repo_local_agent_capture",
10794
11064
  },
@@ -11150,6 +11420,10 @@ elif event_name == "PreCompact":
11150
11420
  payload = {"type": "session_end", "summary": "Claude Code is compacting context; distill durable observations before compaction."}
11151
11421
  elif event_name == "SessionEnd":
11152
11422
  payload = {"type": "session_end", "summary": "Claude Code session ended; distill durable observations for teammate handoff."}
11423
+ elif event_name == "SubagentStop":
11424
+ payload = {"type": "session_end", "summary": "Subagent finished; distill durable observations from the subagent run."}
11425
+ elif event_name == "PreToolUse":
11426
+ payload = {"type": "tool_use", "tool": tool, "path": path, "command": command, "summary": "About to run: " + compact(command or tool or d, 200)}
11153
11427
  else:
11154
11428
  payload = {"type": "tool_use", "tool": tool, "path": path, "command": command, "summary": compact(d, 320), "text": compact(d)}
11155
11429
 
@@ -11161,7 +11435,7 @@ if [[ -n "$OBSERVATION" ]]; then
11161
11435
  kage observe --project "$CWD" --event "$OBSERVATION" --json >/dev/null 2>&1 || true
11162
11436
  fi
11163
11437
 
11164
- if [[ "$EVENT" == "PreCompact" || "$EVENT" == "SessionEnd" ]]; then
11438
+ if [[ "$EVENT" == "PreCompact" || "$EVENT" == "SessionEnd" || "$EVENT" == "SubagentStop" ]]; then
11165
11439
  kage distill --project "$CWD" --session "$SESSION" --json >/dev/null 2>&1 || true
11166
11440
  fi
11167
11441
 
@@ -11197,11 +11471,13 @@ exit 0
11197
11471
  hooks: {
11198
11472
  SessionStart: [{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/session-start.sh", timeout: 5 }] }],
11199
11473
  UserPromptSubmit: [{ hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 12 }] }],
11474
+ PreToolUse: [{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 5 }] }],
11200
11475
  PostToolUse: [{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 5 }] }],
11201
11476
  PostToolUseFailure: [{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 5 }] }],
11202
11477
  PreCompact: [{ hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 20 }] }],
11203
11478
  Stop: [{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/stop.sh", timeout: 20 }] }],
11204
11479
  SessionEnd: [{ hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 20 }] }],
11480
+ SubagentStop: [{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 20 }] }],
11205
11481
  },
11206
11482
  };
11207
11483
  setSnippet(path, JSON.stringify({ mcpServers: { kage: server } }, null, 2), [
@@ -12342,11 +12618,14 @@ function prCheck(projectDir) {
12342
12618
  const tree = gitTree(projectDir);
12343
12619
  const codeInputHash = currentCodeGraphInputHash(projectDir);
12344
12620
  const memoryInputHash = knowledgeGraphInputHash(projectDir, codeInputHash);
12345
- const stalePackets = loadPacketsFromDir(packetsDir(projectDir))
12621
+ const fpCache = new Map();
12622
+ const staleEntries = loadPacketsFromDir(packetsDir(projectDir))
12346
12623
  .filter((packet) => packet.status === "approved" || packet.status === "pending")
12347
- .map((packet) => ({ packet, reasons: staleMemoryReasons(projectDir, packet) }))
12348
- .filter((entry) => entry.reasons.length)
12349
- .map((entry) => staleFinding(entry.packet, entry.reasons));
12624
+ .map((packet) => ({ packet, reasons: staleMemoryReasons(projectDir, packet, fpCache), hard: recallHardStaleReason(projectDir, packet, fpCache) !== null }))
12625
+ .filter((entry) => entry.reasons.length);
12626
+ const stalePackets = staleEntries.map((entry) => staleFinding(entry.packet, entry.reasons));
12627
+ const hardStaleCount = staleEntries.filter((entry) => entry.hard).length;
12628
+ const softStaleCount = staleEntries.length - hardStaleCount;
12350
12629
  const memoryPacketChanges = unique(rawStatus
12351
12630
  .split(/\r?\n/)
12352
12631
  .map(parsePorcelainPath)
@@ -12359,13 +12638,18 @@ function prCheck(projectDir) {
12359
12638
  const errors = [...validation.errors];
12360
12639
  const warnings = [...validation.warnings];
12361
12640
  const requiredActions = [];
12362
- if (stalePackets.length) {
12363
- errors.push(`${stalePackets.length} stale memory packet(s) require update, verification, or supersession.`);
12364
- requiredActions.push("Run kage refresh, then update or supersede stale packets.");
12641
+ // Block only on hard-stale memory (cited files deleted, ttl expired, reported
12642
+ // stale). Soft-stale ("linked code changed since capture") is normal during
12643
+ // active development surface it as a warning, don't fail the gate.
12644
+ if (hardStaleCount) {
12645
+ errors.push(`${hardStaleCount} memory packet(s) are hard-stale (deleted citations, expired ttl, or reported) and must be updated or superseded.`);
12646
+ requiredActions.push("Run kage compact (or kage gc), then update or supersede the affected packets.");
12647
+ }
12648
+ if (softStaleCount) {
12649
+ warnings.push(`${softStaleCount} memory packet(s) reference code that changed since capture — review with kage verify (not blocking).`);
12365
12650
  }
12366
12651
  if (reconciliation.unresolved_count > 0) {
12367
- errors.push(`${reconciliation.unresolved_count} memory reconciliation item(s) require agent update or supersession.`);
12368
- requiredActions.push(...reconciliation.items.slice(0, 5).map((item) => item.next_action));
12652
+ warnings.push(`${reconciliation.unresolved_count} memory reconciliation item(s) may need update after recent code changes (review on handoff; not blocking).`);
12369
12653
  }
12370
12654
  if (!codeGraphCurrent || !memoryGraphCurrent) {
12371
12655
  errors.push("Generated graph artifacts are missing or not current for this working tree content.");
@@ -12373,8 +12657,7 @@ function prCheck(projectDir) {
12373
12657
  }
12374
12658
  const distillableSessions = sessions.sessions.filter((session) => session.durable_observations > 0);
12375
12659
  if (distillableSessions.length) {
12376
- errors.push(`${distillableSessions.length} distillable session learning${distillableSessions.length === 1 ? "" : "s"} require review before merge.`);
12377
- requiredActions.push(...distillableSessions.slice(0, 5).map((session) => session.next_action));
12660
+ warnings.push(`${distillableSessions.length} distillable session learning${distillableSessions.length === 1 ? "" : "s"} pending review (run kage distill; not blocking).`);
12378
12661
  }
12379
12662
  if (!memoryPacketChanges.length && overlay.changed_files.some((path) => !path.startsWith(".agent_memory/"))) {
12380
12663
  warnings.push("No repo memory packet changed for this branch. If durable knowledge was learned, run kage propose --from-diff or kage learn.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kage-core/kage-graph-mcp",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Local-first repo memory, code graph, and recall MCP server for coding agents",
5
5
  "main": "dist/index.js",
6
6
  "files": [