@kage-core/kage-graph-mcp 1.1.37 → 1.2.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
@@ -83,12 +83,15 @@ exports.buildIndexes = buildIndexes;
83
83
  exports.indexProject = indexProject;
84
84
  exports.refreshProject = refreshProject;
85
85
  exports.gcProject = gcProject;
86
+ exports.verifyCitations = verifyCitations;
87
+ exports.compactProject = compactProject;
86
88
  exports.installAgentPolicy = installAgentPolicy;
87
89
  exports.createDenseEmbeddingProvider = createDenseEmbeddingProvider;
88
90
  exports.buildEmbeddingIndex = buildEmbeddingIndex;
89
91
  exports.recall = recall;
90
92
  exports.recallWithEmbeddings = recallWithEmbeddings;
91
93
  exports.queryCodeGraph = queryCodeGraph;
94
+ exports.kageTeammateBrief = kageTeammateBrief;
92
95
  exports.kageRisk = kageRisk;
93
96
  exports.kageDependencyPath = kageDependencyPath;
94
97
  exports.kageCleanupCandidates = kageCleanupCandidates;
@@ -102,6 +105,7 @@ exports.kageCapabilityAudit = kageCapabilityAudit;
102
105
  exports.kageDecisionIntelligence = kageDecisionIntelligence;
103
106
  exports.kageModuleHealth = kageModuleHealth;
104
107
  exports.kageGraphInsights = kageGraphInsights;
108
+ exports.kageRepoXray = kageRepoXray;
105
109
  exports.kageWorkspace = kageWorkspace;
106
110
  exports.kageWorkspaceRecall = kageWorkspaceRecall;
107
111
  exports.queryGraph = queryGraph;
@@ -124,6 +128,7 @@ exports.verifyAgentActivation = verifyAgentActivation;
124
128
  exports.observe = observe;
125
129
  exports.kageSessionCaptureReport = kageSessionCaptureReport;
126
130
  exports.kageSessionReplay = kageSessionReplay;
131
+ exports.kageSessionLearningLedger = kageSessionLearningLedger;
127
132
  exports.distillSession = distillSession;
128
133
  exports.proposeFromDiff = proposeFromDiff;
129
134
  exports.buildBranchOverlay = buildBranchOverlay;
@@ -177,6 +182,27 @@ exports.MEMORY_TYPES = [
177
182
  "negative_result",
178
183
  "constraint",
179
184
  ];
185
+ // Bounded context assembly (PRD Feature 2: "inject only the relevant rule + structural
186
+ // map, dropping the rest"). Opt-in: when a token budget is set, keep the highest-priority
187
+ // sections (preamble + code graph + memory come first in the block) and drop trailing
188
+ // lower-priority sections until the estimate fits. Default (no budget) is unchanged.
189
+ function boundContextBlock(block, budget) {
190
+ if (!Number.isFinite(budget) || budget <= 0 || estimateTokens(block) <= budget)
191
+ return block;
192
+ const parts = block.split(/\n(?=## )/);
193
+ const kept = [parts[0]];
194
+ let dropped = 0;
195
+ for (const section of parts.slice(1)) {
196
+ if (estimateTokens([...kept, section].join("\n")) <= budget)
197
+ kept.push(section);
198
+ else
199
+ dropped += 1;
200
+ }
201
+ const result = kept.join("\n");
202
+ return dropped
203
+ ? `${result}\n\n_Context trimmed to ~${budget} tokens; ${dropped} lower-priority section(s) dropped._`
204
+ : result;
205
+ }
180
206
  const graphMemoryCache = new Map();
181
207
  exports.SETUP_AGENTS = [
182
208
  "codex",
@@ -1116,6 +1142,52 @@ function staleMemoryReasons(projectDir, packet, fingerprintCache) {
1116
1142
  }
1117
1143
  return unique(reasons);
1118
1144
  }
1145
+ // Classifies stale reasons into severity. "hard" reasons (deprecated status, user
1146
+ // reported stale, ttl expired, all citations deleted) mean the memory should be
1147
+ // excluded from recall; "soft" reasons (some citations missing, a linked file
1148
+ // changed) mean keep-but-flag — the memory may just need review, not suppression.
1149
+ function staleSeverity(reasons) {
1150
+ if (!reasons.length)
1151
+ return "none";
1152
+ const hard = reasons.some((reason) => reason.startsWith("packet status is") ||
1153
+ reason.startsWith("user or agent reported") ||
1154
+ reason.startsWith("freshness ttl expired") ||
1155
+ reason.startsWith("all referenced paths are missing"));
1156
+ return hard ? "hard" : "soft";
1157
+ }
1158
+ // Decides whether a packet should be excluded from the recall payload (PRD Feature 3:
1159
+ // "deleted or heavily refactored since the timestamp"). Distinct from staleMemoryReasons:
1160
+ // a citation that NEVER existed (no stored fingerprint) is an ungrounded write — guarded
1161
+ // at capture time — not recall-time staleness, so it does NOT trigger exclusion here.
1162
+ // Returns a reason string when the memory is hard-stale, otherwise null.
1163
+ function recallHardStaleReason(projectDir, packet, cache) {
1164
+ if (packet.status === "deprecated" || packet.status === "superseded")
1165
+ return `packet status is ${packet.status}`;
1166
+ const quality = (packet.quality ?? {});
1167
+ if (Number(quality.reports_stale ?? 0) > 0)
1168
+ return "user or agent reported this memory stale";
1169
+ const freshness = (packet.freshness ?? {});
1170
+ const ttlDays = Number(freshness.ttl_days ?? freshness.ttlDays ?? 0);
1171
+ const verifiedAt = Date.parse(String(freshness.last_verified_at ?? packet.updated_at ?? packet.created_at));
1172
+ if (Number.isFinite(ttlDays) && ttlDays > 0 && Number.isFinite(verifiedAt)) {
1173
+ const ageDays = (Date.now() - verifiedAt) / (1000 * 60 * 60 * 24);
1174
+ if (ageDays > ttlDays)
1175
+ return `freshness ttl expired (${Math.floor(ageDays)}d old, ttl ${ttlDays}d)`;
1176
+ }
1177
+ // Only paths that existed at capture get a stored fingerprint; if every one of them is
1178
+ // now gone, the memory's evidence was deleted out from under it.
1179
+ const stored = packetStoredPathFingerprints(packet);
1180
+ if (stored.length) {
1181
+ const deleted = stored.filter((fingerprint) => {
1182
+ const current = memoryPathFingerprint(projectDir, fingerprint.path, cache);
1183
+ return current === null;
1184
+ });
1185
+ if (deleted.length === stored.length) {
1186
+ return `all cited files deleted since capture: ${deleted.slice(0, 4).map((fingerprint) => fingerprint.path).join(", ")}`;
1187
+ }
1188
+ }
1189
+ return null;
1190
+ }
1119
1191
  function changedPathsFromStaleReasons(reasons) {
1120
1192
  return unique(reasons.flatMap((reason) => {
1121
1193
  const match = reason.match(/^linked path changed since memory was verified: (.+)$/);
@@ -1520,13 +1592,28 @@ function walkFiles(root, predicate) {
1520
1592
  }
1521
1593
  return out.sort();
1522
1594
  }
1595
+ // Tolerant packet read: a single corrupt or merge-conflicted packet (e.g. an
1596
+ // unresolved `<<<<<<<` from a teammate's git merge) must not take down all of
1597
+ // recall/verify/compact. Skip the bad file with a warning and keep going.
1598
+ function tryReadPacket(path) {
1599
+ try {
1600
+ return readJson(path);
1601
+ }
1602
+ catch (error) {
1603
+ process.stderr.write(`kage: skipping unreadable memory packet ${path}: ${error.message}\n`);
1604
+ return null;
1605
+ }
1606
+ }
1523
1607
  function loadPacketsFromDir(dir) {
1524
1608
  if (!(0, node_fs_1.existsSync)(dir))
1525
1609
  return [];
1526
1610
  return (0, node_fs_1.readdirSync)(dir)
1527
1611
  .filter((name) => name.endsWith(".json"))
1528
1612
  .sort()
1529
- .map((name) => readJson((0, node_path_1.join)(dir, name)));
1613
+ .flatMap((name) => {
1614
+ const packet = tryReadPacket((0, node_path_1.join)(dir, name));
1615
+ return packet ? [packet] : [];
1616
+ });
1530
1617
  }
1531
1618
  function loadPacketEntriesFromDir(dir) {
1532
1619
  if (!(0, node_fs_1.existsSync)(dir))
@@ -1534,9 +1621,10 @@ function loadPacketEntriesFromDir(dir) {
1534
1621
  return (0, node_fs_1.readdirSync)(dir)
1535
1622
  .filter((name) => name.endsWith(".json"))
1536
1623
  .sort()
1537
- .map((name) => {
1624
+ .flatMap((name) => {
1538
1625
  const path = (0, node_path_1.join)(dir, name);
1539
- return { path, packet: readJson(path) };
1626
+ const packet = tryReadPacket(path);
1627
+ return packet ? [{ path, packet }] : [];
1540
1628
  });
1541
1629
  }
1542
1630
  function loadApprovedPackets(projectDir) {
@@ -5507,6 +5595,115 @@ function gcProject(projectDir, options = {}) {
5507
5595
  total_scanned: packetEntries.length,
5508
5596
  };
5509
5597
  }
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.
5600
+ function verifyCitations(projectDir, options = {}) {
5601
+ ensureMemoryDirs(projectDir);
5602
+ const approved = loadApprovedPackets(projectDir);
5603
+ const targets = options.id ? approved.filter((packet) => packet.id === options.id) : approved;
5604
+ if (options.id && !targets.length) {
5605
+ return { ok: false, project_dir: projectDir, checked: 0, valid: 0, stale: 0, ungrounded: 0, packets: [], errors: [`Approved packet not found: ${options.id}`] };
5606
+ }
5607
+ const cache = new Map();
5608
+ const packets = targets.map((packet) => {
5609
+ const meaningful = packet.paths.filter((path) => meaningfulMemoryPath(path) && !shouldSkipRepoMemoryPath(path));
5610
+ const missing = meaningful.filter((path) => !pathExistsInRepo(projectDir, path));
5611
+ const reasons = staleMemoryReasons(projectDir, packet, cache);
5612
+ const severity = staleSeverity(reasons);
5613
+ const grounded = packetGroundingWarnings(projectDir, packet, "packet").length === 0;
5614
+ return {
5615
+ id: packet.id,
5616
+ title: packet.title,
5617
+ status: packet.status,
5618
+ paths: packet.paths,
5619
+ missing_paths: missing,
5620
+ grounded,
5621
+ stale: severity !== "none",
5622
+ stale_severity: severity,
5623
+ stale_reasons: reasons,
5624
+ };
5625
+ });
5626
+ return {
5627
+ ok: true,
5628
+ project_dir: projectDir,
5629
+ checked: packets.length,
5630
+ valid: packets.filter((entry) => !entry.stale && entry.grounded).length,
5631
+ stale: packets.filter((entry) => entry.stale).length,
5632
+ ungrounded: packets.filter((entry) => !entry.grounded).length,
5633
+ packets,
5634
+ errors: [],
5635
+ };
5636
+ }
5637
+ // Deterministic memory consolidation (no hosted LLM — preserves the no-API-key promise):
5638
+ // 1. prune dead citations from packets and refresh their path fingerprints,
5639
+ // 2. deprecate hard-stale packets (delegating to the same severity rules as recall/gc),
5640
+ // 3. surface near-duplicate clusters for an agent to merge via kage_supersede.
5641
+ function compactProject(projectDir, options = {}) {
5642
+ ensureMemoryDirs(projectDir);
5643
+ const dryRun = options.dryRun === true;
5644
+ const entries = loadPacketEntriesFromDir(packetsDir(projectDir));
5645
+ const prunedCitations = [];
5646
+ const deprecated = [];
5647
+ const cache = new Map();
5648
+ for (const { path, packet } of entries) {
5649
+ if (packet.status === "deprecated" || packet.status === "superseded")
5650
+ continue;
5651
+ const hardReason = recallHardStaleReason(projectDir, packet, cache);
5652
+ if (hardReason) {
5653
+ deprecated.push({ id: packet.id, title: packet.title, reason: hardReason });
5654
+ if (!dryRun)
5655
+ writeJson(path, { ...packet, status: "deprecated", updated_at: nowIso() });
5656
+ continue;
5657
+ }
5658
+ const meaningful = packet.paths.filter((p) => meaningfulMemoryPath(p) && !shouldSkipRepoMemoryPath(p));
5659
+ const missing = meaningful.filter((p) => !pathExistsInRepo(projectDir, p));
5660
+ if (missing.length) {
5661
+ const keptPaths = packet.paths.filter((p) => !missing.includes(p));
5662
+ prunedCitations.push({ id: packet.id, title: packet.title, removed_paths: missing });
5663
+ if (!dryRun) {
5664
+ writeJson(path, {
5665
+ ...packet,
5666
+ paths: keptPaths,
5667
+ freshness: {
5668
+ ...(packet.freshness ?? {}),
5669
+ path_fingerprints: memoryPathFingerprints(projectDir, keptPaths),
5670
+ last_verified_at: nowIso(),
5671
+ },
5672
+ updated_at: nowIso(),
5673
+ });
5674
+ }
5675
+ }
5676
+ }
5677
+ // Cluster near-duplicate approved packets (report only — merging is an agent decision).
5678
+ const context = memoryQualityContext(projectDir);
5679
+ const approved = context.packets.filter((packet) => packet.status === "approved");
5680
+ const seen = new Set();
5681
+ const clusters = [];
5682
+ for (const packet of approved) {
5683
+ if (seen.has(packet.id))
5684
+ continue;
5685
+ const dupes = duplicateCandidatesWithContext(packet, context, 0.6).filter((dupe) => dupe.status === "approved");
5686
+ if (!dupes.length)
5687
+ continue;
5688
+ const members = [{ id: packet.id, title: packet.title }, ...dupes.map((dupe) => ({ id: dupe.id, title: dupe.title }))];
5689
+ members.forEach((member) => seen.add(member.id));
5690
+ clusters.push({ score: Math.max(...dupes.map((dupe) => dupe.score)), packets: members });
5691
+ }
5692
+ if (!dryRun && (prunedCitations.length || deprecated.length)) {
5693
+ const rebuilt = buildGraphIndexes(projectDir);
5694
+ writeJson((0, node_path_1.join)(memoryRoot(projectDir), "metrics.json"), kageMetricsShallow(projectDir, rebuilt));
5695
+ }
5696
+ return {
5697
+ ok: true,
5698
+ project_dir: projectDir,
5699
+ dry_run: dryRun,
5700
+ pruned_citations: prunedCitations,
5701
+ deprecated,
5702
+ duplicate_clusters: clusters,
5703
+ total_scanned: entries.length,
5704
+ errors: [],
5705
+ };
5706
+ }
5510
5707
  function installAgentPolicy(projectDir) {
5511
5708
  const agentsPath = (0, node_path_1.join)(projectDir, "AGENTS.md");
5512
5709
  const claudePath = (0, node_path_1.join)(projectDir, "CLAUDE.md");
@@ -6274,7 +6471,23 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
6274
6471
  })()
6275
6472
  : recallQueryExpansion(query);
6276
6473
  const terms = expansion.terms;
6277
- const approvedPackets = loadApprovedPackets(projectDir);
6474
+ const allApprovedPackets = loadApprovedPackets(projectDir);
6475
+ const includeStale = inputs.includeStale === true;
6476
+ const staleFingerprintCache = new Map();
6477
+ const suppressed = [];
6478
+ // Just-in-time staleness gate: hard-stale memory (deleted citations, expired ttl,
6479
+ // reported stale) is excluded from the recall payload so the agent never sees it
6480
+ // as valid. Suppression is recorded (not silent) so `kage verify` can explain it.
6481
+ const approvedPackets = includeStale
6482
+ ? allApprovedPackets
6483
+ : allApprovedPackets.filter((packet) => {
6484
+ const reason = recallHardStaleReason(projectDir, packet, staleFingerprintCache);
6485
+ if (reason) {
6486
+ suppressed.push({ id: packet.id, title: packet.title, reason });
6487
+ return false;
6488
+ }
6489
+ return true;
6490
+ });
6278
6491
  const baseScores = scorePacketsBm25(expansion.baseTerms, approvedPackets);
6279
6492
  const temporalScores = scorePacketsBm25(expansion.temporalTerms, approvedPackets);
6280
6493
  const semanticScores = scorePacketsBm25(expansion.semanticTerms, approvedPackets);
@@ -6332,6 +6545,13 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
6332
6545
  const graphContext = queryGraph(projectDir, query, 5, knowledgeGraph);
6333
6546
  const codeContext = queryCodeGraph(projectDir, query, 5, codeGraph);
6334
6547
  const pinnedContext = renderPinnedRepoContext(readContextSlots(projectDir));
6548
+ // PRD Feature 2: traverse the code graph outward from the recalled memory's files
6549
+ // (the semantic entry point) to assemble a bounded structural blast radius. Opt-in
6550
+ // via inputs.structuralHops so default recall output is unchanged.
6551
+ const structuralHops = inputs.structuralHops ?? 0;
6552
+ const blastRadius = structuralHops > 0
6553
+ ? structuralBlastRadius(codeGraph, unique(scored.flatMap((entry) => entry.packet.paths).filter((path) => meaningfulMemoryPath(path))), structuralHops)
6554
+ : [];
6335
6555
  const lines = [
6336
6556
  `# Kage Context`,
6337
6557
  "",
@@ -6344,6 +6564,9 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
6344
6564
  ...codeContext.tests.slice(0, 3).map((test, index) => `${index + 1}. [test] ${test.title} in ${test.test_path}:${test.line}${test.covers_symbol ? ` covers ${test.covers_symbol}` : ""}`),
6345
6565
  ...(!codeContext.symbols.length && !codeContext.routes.length && !codeContext.tests.length ? codeContext.files.slice(0, 3).map((file, index) => `${index + 1}. [file] ${file.path} (${file.kind})`) : []),
6346
6566
  "",
6567
+ ...(blastRadius.length
6568
+ ? [`## Structural Blast Radius (${structuralHops}-hop)`, ...blastRadius.map((path, index) => `${index + 1}. ${path}`), ""]
6569
+ : []),
6347
6570
  scored.length ? "## Relevant Memory" : "No relevant repo memory found.",
6348
6571
  ...scored.flatMap((entry, index) => [
6349
6572
  "",
@@ -6364,11 +6587,16 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
6364
6587
  "",
6365
6588
  graphContext.edges.length ? "## Related Graph Facts" : "",
6366
6589
  ...graphContext.edges.slice(0, 5).map((edge, index) => `${index + 1}. ${edge.fact} (evidence: ${edge.evidence.join(", ")})`),
6590
+ ...(suppressed.length
6591
+ ? ["", `_${suppressed.length} stale memory packet(s) excluded from recall. Run kage verify for details._`]
6592
+ : []),
6367
6593
  ];
6594
+ const assembledBlock = lines.join("\n");
6368
6595
  const result = {
6369
6596
  query,
6370
- context_block: lines.join("\n"),
6597
+ context_block: inputs.maxContextTokens ? boundContextBlock(assembledBlock, inputs.maxContextTokens) : assembledBlock,
6371
6598
  results: scored,
6599
+ suppressed: suppressed.length ? suppressed : undefined,
6372
6600
  explanations: explain
6373
6601
  ? scored.map((entry) => ({
6374
6602
  packet_id: entry.packet.id,
@@ -6555,6 +6783,112 @@ function queryCodeGraph(projectDir, query, limit = 10, graph) {
6555
6783
  structural_edges: structuralEdges,
6556
6784
  };
6557
6785
  }
6786
+ function fileHintsFromText(text) {
6787
+ const matches = text.match(/[A-Za-z0-9_./@-]+\.(?:ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|kts|rb|php|cs|c|h|cc|cpp|hpp|swift|json|md)\b/g) ?? [];
6788
+ return [...new Set(matches.map((match) => match.replace(/^\.\//, "")).filter((match) => !/^https?:\/\//.test(match)))];
6789
+ }
6790
+ function dedupeStrings(values) {
6791
+ return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
6792
+ }
6793
+ function teammateBriefLines(brief) {
6794
+ const verification = brief.verification_contract;
6795
+ const lines = [
6796
+ "\n## Teammate Brief",
6797
+ "Purpose: reduce verification debt and context loss for this task.",
6798
+ "",
6799
+ "### Verification Contract",
6800
+ ];
6801
+ if (verification.focus_files.length) {
6802
+ lines.push(`Focus files: ${verification.focus_files.join(", ")}`);
6803
+ }
6804
+ if (verification.related_tests.length) {
6805
+ lines.push("Related tests:");
6806
+ for (const test of verification.related_tests.slice(0, 5)) {
6807
+ lines.push(`- ${test.test_path}${test.title ? ` - ${test.title}` : ""}${test.covers ? ` (covers ${test.covers})` : ""}`);
6808
+ }
6809
+ }
6810
+ else if (verification.focus_files.length) {
6811
+ lines.push("Related tests: none found in the current code graph.");
6812
+ }
6813
+ if (verification.test_gap_files.length) {
6814
+ lines.push(`Test gaps: ${verification.test_gap_files.join(", ")}`);
6815
+ }
6816
+ if (brief.memory_warnings.length) {
6817
+ lines.push("", "### Memory Warnings", ...brief.memory_warnings.slice(0, 5).map((warning) => `- ${warning}`));
6818
+ }
6819
+ lines.push("", "### Next Actions");
6820
+ for (const action of brief.next_actions.slice(0, 6)) {
6821
+ lines.push(`- ${action}`);
6822
+ }
6823
+ return lines;
6824
+ }
6825
+ function kageTeammateBrief(projectDir, options) {
6826
+ const query = options.query;
6827
+ const focusFiles = dedupeStrings([
6828
+ ...(options.targets ?? []),
6829
+ ...(options.changedFiles ?? []),
6830
+ ...fileHintsFromText(query),
6831
+ ]);
6832
+ const codeQuery = dedupeStrings([query, ...focusFiles]).join(" ");
6833
+ const code = queryCodeGraph(projectDir, codeQuery || query, 12);
6834
+ const relatedTests = code.tests
6835
+ .map((test) => ({
6836
+ test_path: test.test_path,
6837
+ title: test.title,
6838
+ covers: test.covers_path ?? test.covers_symbol ?? null,
6839
+ }))
6840
+ .filter((test, index, all) => all.findIndex((item) => item.test_path === test.test_path && item.title === test.title) === index);
6841
+ const riskTargets = options.riskResult ? Object.values(options.riskResult.targets) : [];
6842
+ const testGapFiles = dedupeStrings([
6843
+ ...riskTargets.filter((target) => target.test_gap).map((target) => target.target),
6844
+ ...(focusFiles.length && !relatedTests.length ? focusFiles : []),
6845
+ ]);
6846
+ const memoryWarnings = [
6847
+ ...((options.recallResult?.results ?? [])
6848
+ .filter((entry) => Boolean((entry.packet.quality ?? {}).stale))
6849
+ .map((entry) => `Recalled memory may be stale: ${entry.packet.title}.`)),
6850
+ ...(options.reconciliation?.unresolved_count
6851
+ ? [`${options.reconciliation.unresolved_count} linked memory item(s) need update, supersede, or stale marking before handoff.`]
6852
+ : []),
6853
+ ];
6854
+ const requiredActions = [
6855
+ ...(relatedTests.length
6856
+ ? [`Run or account for related test coverage: ${relatedTests.slice(0, 3).map((test) => test.test_path).join(", ")}.`]
6857
+ : focusFiles.length
6858
+ ? ["No related tests were found; identify the correct verification before claiming completion."]
6859
+ : ["Identify task-specific verification before claiming completion."]),
6860
+ ...testGapFiles.map((file) => `Resolve test-gap risk for ${file} or explain why existing verification is sufficient.`),
6861
+ ...(memoryWarnings.length ? ["Resolve memory warnings before final handoff."] : []),
6862
+ ];
6863
+ const nextActions = dedupeStrings([
6864
+ ...requiredActions,
6865
+ ...(riskTargets.length
6866
+ ? riskTargets
6867
+ .filter((target) => target.co_change_warnings.length)
6868
+ .slice(0, 2)
6869
+ .map((target) => `Review co-change partners for ${target.target}: ${target.co_change_warnings.slice(0, 3).map((item) => item.file_path).join(", ")}.`)
6870
+ : []),
6871
+ "Keep any durable lesson evidence-backed; future agents should inherit only verified repo knowledge.",
6872
+ ]);
6873
+ const briefWithoutBlock = {
6874
+ schema_version: 1,
6875
+ project_dir: projectDir,
6876
+ generated_at: nowIso(),
6877
+ query,
6878
+ verification_contract: {
6879
+ focus_files: focusFiles,
6880
+ related_tests: relatedTests,
6881
+ test_gap_files: testGapFiles,
6882
+ required_actions: requiredActions,
6883
+ },
6884
+ memory_warnings: memoryWarnings,
6885
+ next_actions: nextActions,
6886
+ };
6887
+ return {
6888
+ ...briefWithoutBlock,
6889
+ context_block: teammateBriefLines(briefWithoutBlock).join("\n"),
6890
+ };
6891
+ }
6558
6892
  function gitLines(projectDir, args) {
6559
6893
  return (readGit(projectDir, args) ?? "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
6560
6894
  }
@@ -6662,7 +6996,7 @@ function gitFileSignal(projectDir, path, graphPaths) {
6662
6996
  }
6663
6997
  function gitChangedFiles(projectDir) {
6664
6998
  return gitLines(projectDir, ["status", "--porcelain", "-uall"])
6665
- .map((line) => line.slice(3).trim().split(" -> ").at(-1) ?? "")
6999
+ .map((line) => parsePorcelainPath(line).split(" -> ").at(-1) ?? "")
6666
7000
  .filter(Boolean)
6667
7001
  .map((path) => gitPathToProjectRelative(projectDir, path) ?? path)
6668
7002
  .filter((path) => !isNoisePath(path));
@@ -6721,6 +7055,45 @@ function codeDependents(graph) {
6721
7055
  }
6722
7056
  return dependents;
6723
7057
  }
7058
+ // Bounded N-hop structural traversal from a set of seed files (the files the recalled
7059
+ // memory is about) — the PRD's "structural blast radius". Walks both import directions
7060
+ // (who-depends-on and what-it-depends-on), excludes the seeds, and ranks by how many
7061
+ // files depend on each node. Pure graph traversal; no full-repo scan.
7062
+ function structuralBlastRadius(graph, seedPaths, hops, limit = 8) {
7063
+ const seeds = seedPaths.filter(Boolean);
7064
+ if (!seeds.length || hops <= 0)
7065
+ return [];
7066
+ const dependents = codeDependents(graph);
7067
+ const dependencies = new Map();
7068
+ for (const edge of graph.imports) {
7069
+ if (!edge.from_path || !edge.to_path)
7070
+ continue;
7071
+ const list = dependencies.get(edge.from_path) ?? new Set();
7072
+ list.add(edge.to_path);
7073
+ dependencies.set(edge.from_path, list);
7074
+ }
7075
+ const seedSet = new Set(seeds);
7076
+ const visited = new Set();
7077
+ let frontier = new Set(seeds);
7078
+ for (let depth = 0; depth < hops; depth += 1) {
7079
+ const next = new Set();
7080
+ for (const node of frontier) {
7081
+ for (const neighbor of [...(dependents.get(node) ?? []), ...(dependencies.get(node) ?? [])]) {
7082
+ if (seedSet.has(neighbor) || visited.has(neighbor))
7083
+ continue;
7084
+ visited.add(neighbor);
7085
+ next.add(neighbor);
7086
+ }
7087
+ }
7088
+ frontier = next;
7089
+ }
7090
+ const score = new Map();
7091
+ for (const [path, incoming] of dependents.entries())
7092
+ score.set(path, incoming.size);
7093
+ return [...visited]
7094
+ .sort((a, b) => (score.get(b) ?? 0) - (score.get(a) ?? 0) || a.localeCompare(b))
7095
+ .slice(0, limit);
7096
+ }
6724
7097
  function impactSurface(target, dependents, graph) {
6725
7098
  const visited = new Set();
6726
7099
  let frontier = new Set([target]);
@@ -7562,7 +7935,7 @@ function kageProjectProfile(projectDir) {
7562
7935
  }
7563
7936
  const memoryConceptCounts = new Map();
7564
7937
  for (const packet of approved) {
7565
- for (const tag of packet.tags.filter((item) => item && !["session-learning", "agentmemory-comparison"].includes(item))) {
7938
+ for (const tag of packet.tags.filter((item) => item && !["session-learning", "external-comparison"].includes(item))) {
7566
7939
  memoryConceptCounts.set(tag, (memoryConceptCounts.get(tag) ?? 0) + 1);
7567
7940
  }
7568
7941
  }
@@ -8307,13 +8680,261 @@ function kageGraphInsights(projectDir) {
8307
8680
  summary: `${centralFiles.length} central file(s), ${cycles.length} dependency cycle(s), ${communities.length} communit${communities.length === 1 ? "y" : "ies"}, ${flows.length} entry flow(s).`,
8308
8681
  };
8309
8682
  }
8683
+ function xrayItem(input) {
8684
+ return {
8685
+ ...input,
8686
+ strength: Math.max(1, Math.min(100, Math.round(input.strength ?? 50))),
8687
+ status: input.status ?? "ok",
8688
+ };
8689
+ }
8690
+ function uniqueXrayItems(items) {
8691
+ const byPath = new Map();
8692
+ for (const item of items) {
8693
+ const existing = byPath.get(item.path);
8694
+ if (!existing || item.strength > existing.strength || (item.status === "risk" && existing.status !== "risk")) {
8695
+ byPath.set(item.path, item);
8696
+ }
8697
+ }
8698
+ return [...byPath.values()].sort((a, b) => b.strength - a.strength || a.path.localeCompare(b.path));
8699
+ }
8700
+ function isXrayCodePath(path, graphPaths) {
8701
+ const normalized = path.replace(/\\/g, "/").replace(/^\/+/, "");
8702
+ return graphPaths.has(normalized) && !normalized.startsWith(".agent_memory/") && !normalized.startsWith("agent_memory/");
8703
+ }
8704
+ function kageRepoXray(projectDir) {
8705
+ const graph = readCurrentCodeGraph(projectDir) ?? buildCodeGraph(projectDir);
8706
+ const profile = kageProjectProfile(projectDir);
8707
+ const risk = kageRisk(projectDir);
8708
+ const health = kageModuleHealth(projectDir);
8709
+ const insights = kageGraphInsights(projectDir);
8710
+ const decisions = kageDecisionIntelligence(projectDir);
8711
+ const approved = loadApprovedPackets(projectDir);
8712
+ const graphPaths = new Set(graph.files.map((file) => file.path));
8713
+ const routeCounts = countBy(graph.routes, (route) => route.file_path);
8714
+ const testsBySource = new Map();
8715
+ for (const test of graph.tests) {
8716
+ const key = test.covers_path ?? test.covers_symbol ?? "";
8717
+ if (!key)
8718
+ continue;
8719
+ const list = testsBySource.get(key) ?? [];
8720
+ list.push(test);
8721
+ testsBySource.set(key, list);
8722
+ }
8723
+ const entryItems = uniqueXrayItems([
8724
+ ...graph.routes.map((route) => xrayItem({
8725
+ label: `${route.method} ${route.path}`,
8726
+ path: route.file_path,
8727
+ kind: "route",
8728
+ strength: 90,
8729
+ status: "ok",
8730
+ evidence: [`Route handler in ${route.file_path}`, `${route.method} ${route.path}`],
8731
+ action: "Start here to understand request flow before changing runtime behavior.",
8732
+ })),
8733
+ ...insights.entry_flows.map((flow) => xrayItem({
8734
+ label: flow.entry,
8735
+ path: flow.entry,
8736
+ kind: "entry_flow",
8737
+ strength: Math.min(96, 64 + flow.path.length * 6),
8738
+ status: "ok",
8739
+ evidence: [`Entry flow: ${flow.path.slice(0, 5).join(" -> ")}`],
8740
+ action: "Trace this entry flow before editing shared dependencies.",
8741
+ })),
8742
+ ...profile.run_commands.slice(0, 4).map((command) => xrayItem({
8743
+ label: command.name,
8744
+ path: "package.json",
8745
+ kind: "script",
8746
+ strength: 58,
8747
+ status: "ok",
8748
+ evidence: [`package script: ${command.name} = ${command.command}`],
8749
+ action: "Use this command evidence when verifying changes.",
8750
+ })),
8751
+ ]).slice(0, 8);
8752
+ const centralByPath = new Map(insights.central_files.map((file) => [file.path, file]));
8753
+ const coreItems = uniqueXrayItems([
8754
+ ...profile.key_files.map((file) => {
8755
+ const central = centralByPath.get(file.path);
8756
+ return xrayItem({
8757
+ label: file.path,
8758
+ path: file.path,
8759
+ kind: file.kind,
8760
+ strength: Math.min(100, file.score + (central ? central.dependents * 6 : 0)),
8761
+ status: "ok",
8762
+ evidence: unique([
8763
+ ...file.why,
8764
+ ...(central ? [`centrality ${central.pagerank}, ${central.dependents} dependent(s)`] : []),
8765
+ ]).slice(0, 4),
8766
+ action: "Inspect this file early; Kage sees it as a central part of the repo.",
8767
+ });
8768
+ }),
8769
+ ...insights.central_files.slice(0, 8).map((file) => xrayItem({
8770
+ label: file.path,
8771
+ path: file.path,
8772
+ kind: file.kind,
8773
+ strength: Math.min(100, 45 + file.dependents * 8 + file.imports * 3),
8774
+ status: "ok",
8775
+ evidence: [`${file.dependents} dependent(s)`, `${file.imports} outgoing import(s)`],
8776
+ action: "Use this as a structural orientation point before following dependencies.",
8777
+ })),
8778
+ ]).slice(0, 10);
8779
+ const riskTargets = Object.values(risk.targets).filter((target) => isXrayCodePath(target.target, graphPaths));
8780
+ const riskHotspots = risk.global_hotspots.filter((hotspot) => isXrayCodePath(hotspot.file_path, graphPaths));
8781
+ const riskItems = uniqueXrayItems([
8782
+ ...riskTargets.map((target) => xrayItem({
8783
+ label: target.target,
8784
+ path: target.target,
8785
+ kind: target.risk_type,
8786
+ strength: Math.max(30, Math.round(target.hotspot_score * 100), target.dependents_count * 12, target.test_gap ? 70 : 0),
8787
+ status: target.test_gap || target.risk_type === "single-owner" || target.risk_type === "churn-heavy" ? "risk" : "watch",
8788
+ evidence: [
8789
+ `${target.dependents_count} direct dependent(s)`,
8790
+ `${target.git.commit_count_90d} commit(s) in 90d`,
8791
+ target.test_gap ? "test gap" : "test signal found",
8792
+ ],
8793
+ action: "Review dependents, tests, and owners before editing this path.",
8794
+ })),
8795
+ ...riskHotspots.slice(0, 8).map((hotspot) => xrayItem({
8796
+ label: hotspot.file_path,
8797
+ path: hotspot.file_path,
8798
+ kind: "hotspot",
8799
+ strength: Math.round(hotspot.hotspot_score * 100),
8800
+ status: "risk",
8801
+ evidence: [`${hotspot.commit_count_90d} commit(s) in 90d`, `primary owner ${hotspot.primary_owner ?? "unknown"}`],
8802
+ action: "Treat this as a change hotspot; ask Kage for risk before editing.",
8803
+ })),
8804
+ ...health.modules.filter((module) => module.grade === "C" || module.grade === "D").slice(0, 5).map((module) => xrayItem({
8805
+ label: module.module,
8806
+ path: module.module === "(root)" ? "." : module.module,
8807
+ kind: "module",
8808
+ strength: 100 - module.score,
8809
+ status: module.grade === "D" ? "risk" : "watch",
8810
+ evidence: module.reasons.slice(0, 3),
8811
+ action: "Use module health reasons to decide tests and review scope.",
8812
+ })),
8813
+ ]).slice(0, 10);
8814
+ const testItems = uniqueXrayItems([
8815
+ ...graph.tests.map((test) => xrayItem({
8816
+ label: test.title || test.test_path,
8817
+ path: test.test_path,
8818
+ kind: "test",
8819
+ strength: 78,
8820
+ status: "ok",
8821
+ evidence: [`covers ${test.covers_path ?? test.covers_symbol ?? "repo behavior"}`],
8822
+ action: "Run or account for this test when changing the covered code.",
8823
+ })),
8824
+ ...graph.files
8825
+ .filter((file) => file.kind === "source" && !hasTestCoverage(file.path, graph))
8826
+ .slice(0, 8)
8827
+ .map((file) => xrayItem({
8828
+ label: file.path,
8829
+ path: file.path,
8830
+ kind: "test_gap",
8831
+ strength: routeCounts[file.path] ? 82 : 58,
8832
+ status: "watch",
8833
+ evidence: routeCounts[file.path] ? [`${routeCounts[file.path]} route(s), no direct test signal`] : ["no direct test signal"],
8834
+ action: "Identify the right verification path before claiming a change here is safe.",
8835
+ })),
8836
+ ]).slice(0, 12);
8837
+ const memoryByPath = new Map();
8838
+ for (const packet of approved) {
8839
+ for (const path of packet.paths.filter((item) => graphPaths.has(item))) {
8840
+ const list = memoryByPath.get(path) ?? [];
8841
+ list.push(packet);
8842
+ memoryByPath.set(path, list);
8843
+ }
8844
+ }
8845
+ const memoryItems = uniqueXrayItems([...memoryByPath.entries()].map(([path, packets]) => xrayItem({
8846
+ label: path,
8847
+ path,
8848
+ kind: "memory_overlay",
8849
+ strength: Math.min(100, packets.length * 22 + (testsBySource.get(path)?.length ?? 0) * 8 + (routeCounts[path] ?? 0) * 8),
8850
+ status: "ok",
8851
+ evidence: packets.slice(0, 3).map((packet) => `${packet.type}: ${packet.title}`),
8852
+ action: "Read linked memory before editing; this is repo lore attached to code.",
8853
+ }))).slice(0, 10);
8854
+ const gapItems = uniqueXrayItems(decisions.coverage_gaps.slice(0, 10).map((gap) => xrayItem({
8855
+ label: gap.path,
8856
+ path: gap.path,
8857
+ kind: "knowledge_gap",
8858
+ strength: Math.min(100, gap.dependents * 16 + gap.churn_90d * 8 + 24),
8859
+ status: "watch",
8860
+ evidence: [gap.reason, `${gap.dependents} dependent(s)`, `${gap.churn_90d} commit(s) in 90d`],
8861
+ action: "Capture why-memory here when the next session learns reusable context.",
8862
+ })));
8863
+ const layers = [
8864
+ {
8865
+ id: "entry_points",
8866
+ title: "Entry Points",
8867
+ summary: entryItems.length ? "Where runtime behavior appears to start." : "No route, script, or entry-flow signals found yet.",
8868
+ items: entryItems,
8869
+ },
8870
+ {
8871
+ id: "core_modules",
8872
+ title: "Core Modules",
8873
+ summary: coreItems.length ? "Files Kage would inspect first to understand this repo." : "No central code files found yet.",
8874
+ items: coreItems,
8875
+ },
8876
+ {
8877
+ id: "change_risk",
8878
+ title: "Change Risk",
8879
+ summary: riskItems.length ? "Hotspots, low-health modules, and risky change targets." : "No local risk signals found yet.",
8880
+ items: riskItems,
8881
+ },
8882
+ {
8883
+ id: "test_map",
8884
+ title: "Test Map",
8885
+ summary: testItems.length ? "Verification paths and code with missing direct test signals." : "No tests found in the code graph.",
8886
+ items: testItems,
8887
+ },
8888
+ {
8889
+ id: "memory_overlay",
8890
+ title: "Memory Overlay",
8891
+ summary: memoryItems.length ? "Repo knowledge already attached to code." : "No code-linked memory yet.",
8892
+ items: memoryItems,
8893
+ },
8894
+ {
8895
+ id: "knowledge_gaps",
8896
+ title: "Knowledge Gaps",
8897
+ summary: gapItems.length ? "High-signal code paths that need why-memory." : "No decision-memory coverage gaps detected.",
8898
+ items: gapItems,
8899
+ },
8900
+ ];
8901
+ const script = [
8902
+ "I mapped your repo.",
8903
+ `I found ${entryItems.length} entry point(s), ${coreItems.length} core code signal(s), ${riskItems.length} risk signal(s), and ${testItems.length} verification signal(s).`,
8904
+ memoryItems.length
8905
+ ? `${memoryItems.length} code area(s) already have attached repo memory.`
8906
+ : "I do not see much code-linked repo memory yet, so I will learn carefully during the session.",
8907
+ "Click any X-Ray item to focus the graph and see the evidence.",
8908
+ ];
8909
+ const nextActions = [
8910
+ ...(entryItems.length ? [`Start orientation from ${entryItems[0].path}.`] : ["Run kage refresh so entry points can be indexed."]),
8911
+ ...(riskItems.length ? [`Review highest-risk area ${riskItems[0].path} before making edits.`] : []),
8912
+ ...(testItems.some((item) => item.kind === "test_gap") ? ["Resolve test-map gaps by identifying task-specific verification before handoff."] : []),
8913
+ ...(gapItems.length ? ["Capture why-memory for knowledge gaps when the session uncovers durable context."] : []),
8914
+ ];
8915
+ const warnings = unique([
8916
+ ...profile.warnings,
8917
+ ...risk.warnings,
8918
+ ...health.warnings,
8919
+ ...insights.warnings,
8920
+ ...decisions.warnings,
8921
+ ]);
8922
+ return {
8923
+ schema_version: 1,
8924
+ project_dir: projectDir,
8925
+ generated_at: nowIso(),
8926
+ summary: `Repo X-Ray mapped ${graph.files.length} file(s), ${graph.symbols.length} symbol(s), ${graph.routes.length} route(s), ${graph.tests.length} test signal(s), and ${approved.length} memory packet(s).`,
8927
+ first_use_script: script,
8928
+ layers,
8929
+ next_actions: unique(nextActions),
8930
+ warnings,
8931
+ };
8932
+ }
8310
8933
  const WORKSPACE_SKIP_DIRS = new Set([
8311
8934
  ".agent_memory",
8312
8935
  ".git",
8313
8936
  ".hg",
8314
8937
  ".next",
8315
- ".repowise",
8316
- ".repowise-workspace",
8317
8938
  "coverage",
8318
8939
  "dist",
8319
8940
  "node_modules",
@@ -10096,6 +10717,9 @@ function learn(input) {
10096
10717
  paths: input.paths,
10097
10718
  stack: input.stack,
10098
10719
  context: input.context,
10720
+ allowMissingPaths: input.allowMissingPaths,
10721
+ strictCitations: input.strictCitations,
10722
+ graphNodes: input.graphNodes,
10099
10723
  });
10100
10724
  }
10101
10725
  function capture(input) {
@@ -10111,7 +10735,34 @@ function capture(input) {
10111
10735
  errors: [`Sensitive content blocked: ${unique(scanFindings).join(", ")}`],
10112
10736
  };
10113
10737
  }
10738
+ const warnings = [];
10739
+ const meaningfulPaths = (input.paths ?? [])
10740
+ .filter((path) => path && meaningfulMemoryPath(path) && !shouldSkipRepoMemoryPath(path));
10741
+ const missingPaths = meaningfulPaths.filter((path) => !pathExistsInRepo(input.projectDir, path));
10742
+ // Citation validation. Strict mode (agent-facing record_memory tools / CLI) rejects a
10743
+ // write whose every cited path is missing — the PRD's "reject if citations don't exist".
10744
+ // The core library stays permissive (warn-only) for programmatic callers and migrations.
10745
+ if (input.strictCitations && meaningfulPaths.length && missingPaths.length === meaningfulPaths.length && !input.allowMissingPaths) {
10746
+ return {
10747
+ ok: false,
10748
+ errors: [
10749
+ `Citation validation failed: none of the referenced paths exist in this repo: ${missingPaths.join(", ")}. ` +
10750
+ `Fix the paths, or pass allow_missing_paths to record anyway (e.g. for a file you are about to create).`,
10751
+ ],
10752
+ warnings: [],
10753
+ };
10754
+ }
10755
+ if (missingPaths.length) {
10756
+ warnings.push(`Some referenced paths do not exist in this repo: ${missingPaths.join(", ")}`);
10757
+ }
10114
10758
  const createdAt = nowIso();
10759
+ // Agent-asserted links to code-graph nodes (PRD `graph_nodes`): the agent recording the
10760
+ // memory knows which symbol/route/file the rule is about, so let it declare them instead
10761
+ // of relying solely on background derivation.
10762
+ const graphEdges = unique(input.graphNodes ?? [])
10763
+ .map((node) => node.trim())
10764
+ .filter(Boolean)
10765
+ .map((node) => ({ relation: "references_code", to: node, evidence: "agent_capture", created_at: createdAt }));
10115
10766
  const packet = {
10116
10767
  schema_version: exports.PACKET_SCHEMA_VERSION,
10117
10768
  id: makePacketId(input.projectDir, type, input.title, String(Date.now())),
@@ -10153,10 +10804,12 @@ function capture(input) {
10153
10804
  },
10154
10805
  created_at: createdAt,
10155
10806
  updated_at: createdAt,
10807
+ author_branch: gitBranch(input.projectDir),
10156
10808
  };
10809
+ packet.edges = graphEdges;
10157
10810
  const validation = validatePacket(packet);
10158
10811
  if (!validation.ok)
10159
- return { ok: false, errors: validation.errors };
10812
+ return { ok: false, errors: validation.errors, warnings };
10160
10813
  packet.quality = {
10161
10814
  ...packet.quality,
10162
10815
  ...evaluateMemoryQuality(input.projectDir, packet),
@@ -10168,7 +10821,7 @@ function capture(input) {
10168
10821
  path: (0, node_path_1.relative)(input.projectDir, path),
10169
10822
  source_kind: packet.source_refs[0]?.kind ?? "explicit_capture",
10170
10823
  });
10171
- return { ok: true, packet, path, errors: [] };
10824
+ return { ok: true, packet, path, errors: [], warnings };
10172
10825
  }
10173
10826
  function createPublicCandidate(projectDir, id) {
10174
10827
  ensureMemoryDirs(projectDir);
@@ -11156,6 +11809,171 @@ function kageSessionReplay(projectDir, options = {}) {
11156
11809
  : "No durable candidates in this digest yet; keep observing or capture reusable learnings with kage learn.",
11157
11810
  };
11158
11811
  }
11812
+ function distilledObservationSessions(projectDir) {
11813
+ const ids = new Set();
11814
+ for (const packet of [...loadApprovedPackets(projectDir), ...loadPendingPackets(projectDir)]) {
11815
+ for (const ref of packet.source_refs) {
11816
+ if (ref.kind === "observation_session" && typeof ref.session_id === "string" && ref.session_id.trim()) {
11817
+ ids.add(ref.session_id.trim());
11818
+ }
11819
+ }
11820
+ }
11821
+ return ids;
11822
+ }
11823
+ function eventLearningCandidate(event, knownCommands) {
11824
+ if (event.type === "command_result") {
11825
+ if (typeof event.exit_code === "number" && event.exit_code !== 0 && !`${event.summary ?? ""}\n${event.text ?? ""}`.trim()) {
11826
+ return null;
11827
+ }
11828
+ const reusable = reusableCommandObservation(event, knownCommands);
11829
+ if (reusable)
11830
+ return { memory_type: "runbook", reason: reusable.learning };
11831
+ }
11832
+ if (event.type === "file_change") {
11833
+ const learning = reusableFileObservation(event);
11834
+ if (learning)
11835
+ return { memory_type: "workflow", reason: learning };
11836
+ }
11837
+ if (event.type === "user_prompt") {
11838
+ const learning = reusablePromptObservation(event);
11839
+ if (learning)
11840
+ return { memory_type: "decision", reason: learning };
11841
+ }
11842
+ return null;
11843
+ }
11844
+ function ignoredObservationReason(event) {
11845
+ if (event.type === "tool_use" || event.type === "tool_result")
11846
+ return "Tool telemetry helps replay the session but is not durable repo knowledge by itself.";
11847
+ if (event.type === "command_result" || event.type === "test_result")
11848
+ return "Verification evidence is useful for this session but needs a reusable cause, fix, or runbook before saving.";
11849
+ if (event.type === "file_change")
11850
+ return "The file touch is generic; save only if it explains a convention, workflow, bug, or invariant.";
11851
+ if (event.type === "user_prompt")
11852
+ return "The prompt is episodic; save only decisions, policies, gotchas, or reusable context.";
11853
+ return "Session bookkeeping is not durable repo memory.";
11854
+ }
11855
+ function learningLedgerContextBlock(report) {
11856
+ const lines = ["\n## Session Learning Ledger"];
11857
+ if (!report.sessions.length) {
11858
+ lines.push("No observed session events found.");
11859
+ return lines.join("\n");
11860
+ }
11861
+ lines.push(`Save candidates: ${report.totals.save_candidates}`);
11862
+ lines.push(`Needs evidence: ${report.totals.needs_evidence}`);
11863
+ if (report.totals.already_distilled)
11864
+ lines.push(`Already distilled: ${report.totals.already_distilled}`);
11865
+ lines.push("");
11866
+ lines.push("### Memory Decisions");
11867
+ for (const session of report.sessions.slice(0, 3)) {
11868
+ lines.push(`Session ${session.session_id}: ${session.save_candidates} save, ${session.needs_evidence} needs evidence, ${session.ignore_items} ignore.`);
11869
+ for (const decision of session.decisions.filter((item) => item.disposition !== "ignore").slice(0, 4)) {
11870
+ lines.push(`- ${decision.disposition}: ${decision.memory_type ?? decision.event_type} - ${decision.evidence}`);
11871
+ }
11872
+ }
11873
+ lines.push("", "### Next Actions");
11874
+ for (const action of unique(report.sessions.map((session) => session.next_action)).slice(0, 4)) {
11875
+ lines.push(`- ${action}`);
11876
+ }
11877
+ return lines.join("\n");
11878
+ }
11879
+ function kageSessionLearningLedger(projectDir, options = {}) {
11880
+ ensureMemoryDirs(projectDir);
11881
+ const limit = Math.max(1, Math.min(200, Math.floor(options.limit ?? 50)));
11882
+ const observations = loadObservations(projectDir, options.sessionId);
11883
+ const knownCommands = knownRepoCommands(projectDir);
11884
+ const distilledSessions = distilledObservationSessions(projectDir);
11885
+ const bySession = new Map();
11886
+ for (const observation of observations) {
11887
+ const rows = bySession.get(observation.session_id) ?? [];
11888
+ rows.push(observation);
11889
+ bySession.set(observation.session_id, rows);
11890
+ }
11891
+ const sessions = Array.from(bySession.entries()).map(([sessionId, rows]) => {
11892
+ const sorted = rows.slice().sort((a, b) => a.timestamp.localeCompare(b.timestamp));
11893
+ const alreadyDistilled = distilledSessions.has(sessionId);
11894
+ const distillCommand = `kage distill --project . --session ${sessionId}`;
11895
+ const decisions = sorted.map((event) => {
11896
+ const candidate = eventLearningCandidate(event, knownCommands);
11897
+ const failingEvidence = (event.type === "command_result" || event.type === "test_result") && typeof event.exit_code === "number" && event.exit_code !== 0;
11898
+ const evidence = summarize(observationDigestSummary(event)).slice(0, 220);
11899
+ if (candidate) {
11900
+ return {
11901
+ observation_id: event.id,
11902
+ timestamp: event.timestamp,
11903
+ session_id: event.session_id,
11904
+ event_type: event.type,
11905
+ disposition: alreadyDistilled ? "already_distilled" : "save",
11906
+ memory_type: candidate.memory_type,
11907
+ reason: alreadyDistilled ? "A memory packet already references this observed session." : candidate.reason,
11908
+ evidence,
11909
+ ...(event.path ? { path: event.path } : {}),
11910
+ ...(event.command ? { command: normalizeCommandText(event.command) } : {}),
11911
+ ...(typeof event.exit_code === "number" ? { exit_code: event.exit_code } : {}),
11912
+ distill_command: distillCommand,
11913
+ };
11914
+ }
11915
+ return {
11916
+ observation_id: event.id,
11917
+ timestamp: event.timestamp,
11918
+ session_id: event.session_id,
11919
+ event_type: event.type,
11920
+ disposition: failingEvidence ? "needs_evidence" : "ignore",
11921
+ reason: failingEvidence ? "A failure happened, but the observation does not yet explain a reusable cause, fix, workaround, or runbook." : ignoredObservationReason(event),
11922
+ evidence,
11923
+ ...(event.path ? { path: event.path } : {}),
11924
+ ...(event.command ? { command: normalizeCommandText(event.command) } : {}),
11925
+ ...(typeof event.exit_code === "number" ? { exit_code: event.exit_code } : {}),
11926
+ ...(failingEvidence ? { distill_command: distillCommand } : {}),
11927
+ };
11928
+ });
11929
+ const saveCandidates = decisions.filter((decision) => decision.disposition === "save").length;
11930
+ const needsEvidence = decisions.filter((decision) => decision.disposition === "needs_evidence").length;
11931
+ const ignoreItems = decisions.filter((decision) => decision.disposition === "ignore").length;
11932
+ const alreadyDistilledCount = decisions.filter((decision) => decision.disposition === "already_distilled").length;
11933
+ const nextAction = saveCandidates > 0
11934
+ ? `${distillCommand} and review save candidates before handoff.`
11935
+ : needsEvidence > 0
11936
+ ? "Add a concise cause/fix summary for failing observations before deciding whether to save them."
11937
+ : alreadyDistilledCount > 0
11938
+ ? "Session learning already has memory packets; update or supersede them only if the facts changed."
11939
+ : "No save-worthy session fact yet; keep observing without creating memory noise.";
11940
+ return {
11941
+ session_id: sessionId,
11942
+ first_at: sorted[0]?.timestamp ?? "",
11943
+ last_at: sorted.at(-1)?.timestamp ?? "",
11944
+ observations: sorted.length,
11945
+ save_candidates: saveCandidates,
11946
+ ignore_items: ignoreItems,
11947
+ needs_evidence: needsEvidence,
11948
+ already_distilled: alreadyDistilledCount,
11949
+ commands: unique(sorted.map((event) => event.command).filter(Boolean)).slice(0, 8),
11950
+ paths: unique(sorted.map((event) => event.path).filter(Boolean)).slice(0, 12),
11951
+ decisions: decisions.slice(0, limit),
11952
+ next_action: nextAction,
11953
+ };
11954
+ }).sort((a, b) => b.last_at.localeCompare(a.last_at));
11955
+ const totals = {
11956
+ sessions: sessions.length,
11957
+ observations: observations.length,
11958
+ save_candidates: sessions.reduce((sum, session) => sum + session.save_candidates, 0),
11959
+ ignore_items: sessions.reduce((sum, session) => sum + session.ignore_items, 0),
11960
+ needs_evidence: sessions.reduce((sum, session) => sum + session.needs_evidence, 0),
11961
+ already_distilled: sessions.reduce((sum, session) => sum + session.already_distilled, 0),
11962
+ };
11963
+ const reportWithoutBlock = {
11964
+ schema_version: 1,
11965
+ project_dir: projectDir,
11966
+ generated_at: nowIso(),
11967
+ ...(options.sessionId ? { selected_session_id: options.sessionId } : {}),
11968
+ totals,
11969
+ sessions,
11970
+ privacy_model: "The ledger classifies privacy-scanned observation metadata into save, ignore, needs-evidence, and already-distilled decisions; raw transcript text is not the product surface.",
11971
+ };
11972
+ return {
11973
+ ...reportWithoutBlock,
11974
+ context_block: learningLedgerContextBlock(reportWithoutBlock),
11975
+ };
11976
+ }
11159
11977
  function distillSession(projectDir, sessionId) {
11160
11978
  const observations = loadObservations(projectDir, sessionId);
11161
11979
  const candidates = [];
@@ -11525,6 +12343,7 @@ function prCheck(projectDir) {
11525
12343
  const codeInputHash = currentCodeGraphInputHash(projectDir);
11526
12344
  const memoryInputHash = knowledgeGraphInputHash(projectDir, codeInputHash);
11527
12345
  const stalePackets = loadPacketsFromDir(packetsDir(projectDir))
12346
+ .filter((packet) => packet.status === "approved" || packet.status === "pending")
11528
12347
  .map((packet) => ({ packet, reasons: staleMemoryReasons(projectDir, packet) }))
11529
12348
  .filter((entry) => entry.reasons.length)
11530
12349
  .map((entry) => staleFinding(entry.packet, entry.reasons));
@@ -11587,12 +12406,65 @@ function regexpEscape(value) {
11587
12406
  function shellQuote(value) {
11588
12407
  return `'${value.replace(/'/g, "'\"'\"'")}'`;
11589
12408
  }
11590
- function gitHookPath(projectDir) {
11591
- const raw = readGit(projectDir, ["rev-parse", "--git-path", "hooks/post-commit"]);
12409
+ function gitHookPath(projectDir, hookName = "post-commit") {
12410
+ const raw = readGit(projectDir, ["rev-parse", "--git-path", `hooks/${hookName}`]);
11592
12411
  if (!raw)
11593
12412
  return null;
11594
12413
  return (0, node_path_1.resolve)(projectDir, raw);
11595
12414
  }
12415
+ // Hooks that fire after history changes underfoot (git pull / merge / checkout):
12416
+ // re-index repo memory so newly pulled teammate packets are immediately recallable.
12417
+ const KAGE_SYNC_HOOKS = ["post-merge", "post-checkout"];
12418
+ function kageSyncHookBlock(projectDir) {
12419
+ const project = shellQuote((0, node_path_1.resolve)(projectDir));
12420
+ return [
12421
+ KAGE_POST_COMMIT_HOOK_START,
12422
+ "# Kage sync hook: re-index repo memory after pull/merge/checkout.",
12423
+ "# Set KAGE_SKIP_HOOK=1 to bypass, or KAGE_BIN=/path/to/kage to override.",
12424
+ "if [ \"${KAGE_SKIP_HOOK:-0}\" != \"1\" ]; then",
12425
+ " KAGE_BIN=\"${KAGE_BIN:-kage}\"",
12426
+ " if command -v \"$KAGE_BIN\" >/dev/null 2>&1; then",
12427
+ " (",
12428
+ ` "$KAGE_BIN" index --project ${project} --json >/dev/null 2>&1 || true`,
12429
+ " ) &",
12430
+ " fi",
12431
+ "fi",
12432
+ KAGE_POST_COMMIT_HOOK_END,
12433
+ ].join("\n");
12434
+ }
12435
+ function installSyncHooks(projectDir) {
12436
+ const installed = [];
12437
+ for (const hookName of KAGE_SYNC_HOOKS) {
12438
+ const hookPath = gitHookPath(projectDir, hookName);
12439
+ if (!hookPath)
12440
+ continue;
12441
+ ensureDir((0, node_path_1.dirname)(hookPath));
12442
+ const existing = safeReadText(hookPath) ?? "";
12443
+ const base = stripKageHookBlock(existing);
12444
+ const prefix = base.trim() ? base.trimEnd() : "#!/bin/sh";
12445
+ const next = `${prefix}\n\n${kageSyncHookBlock(projectDir)}\n`;
12446
+ if (existing !== next)
12447
+ (0, node_fs_1.writeFileSync)(hookPath, next, "utf8");
12448
+ (0, node_fs_1.chmodSync)(hookPath, 0o755);
12449
+ installed.push(hookPath);
12450
+ }
12451
+ return installed;
12452
+ }
12453
+ function uninstallSyncHooks(projectDir) {
12454
+ const removed = [];
12455
+ for (const hookName of KAGE_SYNC_HOOKS) {
12456
+ const hookPath = gitHookPath(projectDir, hookName);
12457
+ if (!hookPath)
12458
+ continue;
12459
+ const existing = safeReadText(hookPath) ?? "";
12460
+ if (!hasKageHookBlock(existing))
12461
+ continue;
12462
+ (0, node_fs_1.writeFileSync)(hookPath, stripKageHookBlock(existing), "utf8");
12463
+ (0, node_fs_1.chmodSync)(hookPath, 0o755);
12464
+ removed.push(hookPath);
12465
+ }
12466
+ return removed;
12467
+ }
11596
12468
  function hasKageHookBlock(content) {
11597
12469
  return content.includes(KAGE_POST_COMMIT_HOOK_START) && content.includes(KAGE_POST_COMMIT_HOOK_END);
11598
12470
  }
@@ -11668,20 +12540,24 @@ function kageHookInstall(projectDir) {
11668
12540
  const base = stripKageHookBlock(existing);
11669
12541
  const prefix = base.trim() ? base.trimEnd() : "#!/bin/sh";
11670
12542
  const next = `${prefix}\n\n${kagePostCommitHookBlock(projectDir)}\n`;
11671
- const changed = existing !== next;
11672
- if (changed)
12543
+ const commitChanged = existing !== next;
12544
+ if (commitChanged)
11673
12545
  (0, node_fs_1.writeFileSync)(hookPath, next, "utf8");
11674
12546
  (0, node_fs_1.chmodSync)(hookPath, 0o755);
12547
+ const syncHooks = installSyncHooks(projectDir);
11675
12548
  return {
11676
12549
  ok: true,
11677
12550
  action: "install",
11678
12551
  project_dir: projectDir,
11679
12552
  hook_path: hookPath,
11680
12553
  installed: true,
11681
- changed,
11682
- message: changed ? "Installed Kage post-commit hook." : "Kage post-commit hook is already current.",
12554
+ changed: commitChanged,
12555
+ message: commitChanged
12556
+ ? "Installed Kage post-commit hook and pull/merge sync hooks."
12557
+ : "Kage post-commit and sync hooks are already current.",
11683
12558
  errors: [],
11684
12559
  warnings: [],
12560
+ additional_hooks: syncHooks,
11685
12561
  };
11686
12562
  }
11687
12563
  function kageHookUninstall(projectDir) {
@@ -11717,6 +12593,7 @@ function kageHookUninstall(projectDir) {
11717
12593
  const next = stripKageHookBlock(existing);
11718
12594
  (0, node_fs_1.writeFileSync)(hookPath, next, "utf8");
11719
12595
  (0, node_fs_1.chmodSync)(hookPath, 0o755);
12596
+ const removedSyncHooks = uninstallSyncHooks(projectDir);
11720
12597
  return {
11721
12598
  ok: true,
11722
12599
  action: "uninstall",
@@ -11724,9 +12601,10 @@ function kageHookUninstall(projectDir) {
11724
12601
  hook_path: hookPath,
11725
12602
  installed: false,
11726
12603
  changed: true,
11727
- message: "Removed Kage post-commit hook.",
12604
+ message: "Removed Kage post-commit and sync hooks.",
11728
12605
  errors: [],
11729
12606
  warnings: [],
12607
+ additional_hooks: removedSyncHooks,
11730
12608
  };
11731
12609
  }
11732
12610
  function exportPublicBundle(projectDir) {