@kage-core/kage-graph-mcp 3.2.0 → 3.3.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
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.PACKET_MERGE_DRIVER_CONFIG = exports.PACKET_MERGE_ATTRIBUTE_LINE = exports.AUTO_DISTILL_SIGNAL_THRESHOLD = exports.AUTO_DISTILL_TAG = exports.VALUE_DOLLARS_PER_MILLION_TOKENS = exports.SETUP_AGENTS = exports.MEMORY_TYPES = exports.PACKET_SCHEMA_VERSION = void 0;
36
+ exports.PACKET_MERGE_DRIVER_CONFIG = exports.PACKET_MERGE_ATTRIBUTE_LINE = exports.AUTO_DISTILL_SIGNAL_THRESHOLD = exports.KAGE_HOOKS_VERSION = exports.AUTO_DISTILL_TAG = exports.VALUE_DOLLARS_PER_MILLION_TOKENS = exports.SETUP_AGENTS = exports.MEMORY_TYPES = exports.PACKET_SCHEMA_VERSION = void 0;
37
37
  exports.memoryRoot = memoryRoot;
38
38
  exports.packetsDir = packetsDir;
39
39
  exports.pendingDir = pendingDir;
@@ -97,11 +97,13 @@ exports.indexProject = indexProject;
97
97
  exports.refreshProject = refreshProject;
98
98
  exports.gcProject = gcProject;
99
99
  exports.kageSuppressedMemory = kageSuppressedMemory;
100
+ exports.packetVerificationLabel = packetVerificationLabel;
100
101
  exports.verifyCitations = verifyCitations;
101
102
  exports.compactProject = compactProject;
102
103
  exports.installAgentPolicy = installAgentPolicy;
103
104
  exports.createDenseEmbeddingProvider = createDenseEmbeddingProvider;
104
105
  exports.buildEmbeddingIndex = buildEmbeddingIndex;
106
+ exports.recallRecencyScore = recallRecencyScore;
105
107
  exports.isUngroundedConversationalCapture = isUngroundedConversationalCapture;
106
108
  exports.recall = recall;
107
109
  exports.recallWithEmbeddings = recallWithEmbeddings;
@@ -295,13 +297,10 @@ Do this without waiting for the user to ask. Kage should feel like ambient repo
295
297
  If Kage appears installed but no Kage tools are available, report that the active
296
298
  agent session has not loaded the MCP server and ask the user to restart the
297
299
  agent. After restart, call \`kage_verify_agent\` to prove the harness is live.
298
-
299
- ## Show the Value
300
-
301
- \`kage_context\` and \`kage_recall\` return a one-line gains receipt (tokens/$ saved
302
- this session, stale memories withheld). When it is non-trivial, relay it to the
303
- user in your own words — Kage's value is otherwise invisible, and a user who never
304
- sees it churns. Repeat only what the tool actually reported; never fabricate numbers.
300
+ Until then, fall back to the memory directly: read the packet files under
301
+ \`.agent_memory/packets/\` each is a self-describing OKF markdown document
302
+ (verification status in \`x-kage-*\` frontmatter; treat anything not marked
303
+ verified as unconfirmed). No tools are required to read them.
305
304
 
306
305
  ## Automatic Capture
307
306
 
@@ -1281,7 +1280,7 @@ function kageFileContext(projectDir, filePath) {
1281
1280
  const lines = [
1282
1281
  `# Kage File Context: ${rel}`,
1283
1282
  ...verified.flatMap((packet, index) => [
1284
- `${index + 1}. [${packet.type} | confidence ${packet.confidence.toFixed(2)}] ${packet.title}`,
1283
+ `${index + 1}. [${packet.type} | ${packetVerificationLabel(packet)}] ${packet.title}`,
1285
1284
  ` ${packet.summary}`,
1286
1285
  ]),
1287
1286
  `_${verified.length} verified memor${verified.length === 1 ? "y" : "ies"} citing this file (citations checked, not stale)._`,
@@ -1296,6 +1295,9 @@ function kageFileContext(projectDir, filePath) {
1296
1295
  }));
1297
1296
  const replay = replayTokensSaved(verified, result.context_block);
1298
1297
  recordValueEvent(projectDir, { kind: "recall_served", tokens_saved: replay, replay_tokens: replay });
1298
+ // File-context serves are real uses — the uses_30d counter read 0 forever
1299
+ // because only recall() counted.
1300
+ recordRecallAccess(projectDir, verified.map((packet) => ({ packet })));
1299
1301
  return result;
1300
1302
  }
1301
1303
  const AUDIT_ACTIVITY_KIND = {
@@ -1688,6 +1690,32 @@ function identifierTokens(text) {
1688
1690
  out.add(match[0].toLowerCase());
1689
1691
  return out;
1690
1692
  }
1693
+ // Anchor candidates must be written AS CODE in the memory text — camelCase,
1694
+ // snake_case, `backticked`, or called() — never plain prose words. Treating
1695
+ // every word as a candidate anchored 90% of packets to tokens like "verified"
1696
+ // and "when" that happened to collide with incidental symbols in the file.
1697
+ function codeAnchorTokens(text) {
1698
+ const out = new Set();
1699
+ const add = (token) => {
1700
+ if (token.length >= 6)
1701
+ out.add(token.toLowerCase());
1702
+ };
1703
+ for (const match of text.matchAll(/`([^`\n]+)`/g)) {
1704
+ for (const token of match[1].matchAll(/[A-Za-z_$][A-Za-z0-9_$]*/g))
1705
+ add(token[0]);
1706
+ }
1707
+ for (const match of text.matchAll(/\b[a-z][a-z0-9]*[A-Z][A-Za-z0-9]*\b/g))
1708
+ add(match[0]);
1709
+ for (const match of text.matchAll(/\b[A-Za-z0-9]+_[A-Za-z0-9_]+\b/g))
1710
+ add(match[0]);
1711
+ for (const match of text.matchAll(/\b([A-Za-z_$][A-Za-z0-9_$]{2,})\(\)/g))
1712
+ add(match[1]);
1713
+ return out;
1714
+ }
1715
+ // Symbol kinds that make meaningful anchors. Constants are allowed only when
1716
+ // the name itself is code-shaped (contains an underscore after lowercasing) —
1717
+ // a constant literally named "verified" is a prose-word collision, not a handle.
1718
+ const ANCHOR_SYMBOL_KINDS = new Set(["function", "class", "method", "interface", "type", "enum"]);
1691
1719
  // current-file symbol span hashes, keyed by `${nameLower}\0${kind}` -> [sha256...].
1692
1720
  // Cached by mtime+size: extraction only runs when a file actually changed.
1693
1721
  const anchorSymbolCache = new Map();
@@ -1749,7 +1777,7 @@ function fileSymbolSpanHashes(projectDir, path) {
1749
1777
  // title+summary+body) is supplied, anchor each TS/JS file to the symbols the
1750
1778
  // memory actually names, so unrelated edits in the same file do not mark it stale.
1751
1779
  function memoryPathFingerprints(projectDir, paths, anchorText) {
1752
- const idents = anchorText ? identifierTokens(anchorText) : null;
1780
+ const idents = anchorText ? codeAnchorTokens(anchorText) : null;
1753
1781
  const fingerprints = [];
1754
1782
  for (const path of unique(paths).filter(fingerprintableMemoryPath)) {
1755
1783
  const fingerprint = memoryPathFingerprint(projectDir, path);
@@ -1771,6 +1799,8 @@ function memoryPathFingerprints(projectDir, paths, anchorText) {
1771
1799
  const [name, kind] = key.split("\0");
1772
1800
  if (!idents.has(name) || spanCountByName.get(name) !== 1)
1773
1801
  continue;
1802
+ if (!ANCHOR_SYMBOL_KINDS.has(kind) && !name.includes("_"))
1803
+ continue;
1774
1804
  symbols.push({ name, kind, sha256: hashes[0] });
1775
1805
  }
1776
1806
  if (symbols.length) {
@@ -6958,6 +6988,10 @@ function refreshPacketStaleness(projectDir, options = {}) {
6958
6988
  let updated = 0;
6959
6989
  const fingerprintCache = new Map();
6960
6990
  const ignorePatterns = readKageIgnore(projectDir);
6991
+ // Usage telemetry reconciliation: the live counters accumulate in the
6992
+ // machine-local memory-access report; refresh copies them onto the packet so
6993
+ // the committed store carries real usage instead of a hardcoded zero.
6994
+ const accessEntries = readMemoryAccessEntries(projectDir);
6961
6995
  for (const entry of loadPacketEntriesFromDir(packetsDir(projectDir))) {
6962
6996
  // Drop any .kageignore'd grounding (presentation layers etc.) from the stored packet
6963
6997
  // so memory is never anchored to non-knowledge files.
@@ -6981,6 +7015,15 @@ function refreshPacketStaleness(projectDir, options = {}) {
6981
7015
  const { stale: _stale, stale_reasons: _staleReasons, suggested_action: _suggestedAction, ...rest } = oldQuality;
6982
7016
  nextQuality = rest;
6983
7017
  }
7018
+ const access = accessEntries.get(packet.id);
7019
+ if (access && (access.uses_30d !== nextQuality.uses_30d || access.total_uses !== nextQuality.total_uses)) {
7020
+ nextQuality = {
7021
+ ...nextQuality,
7022
+ uses_30d: access.uses_30d,
7023
+ total_uses: access.total_uses,
7024
+ ...(access.last_accessed_at ? { last_accessed_at: access.last_accessed_at } : {}),
7025
+ };
7026
+ }
6984
7027
  const nextFreshness = oldFreshness;
6985
7028
  const contentChanged = pruned !== null;
6986
7029
  const changed = contentChanged
@@ -6991,7 +7034,10 @@ function refreshPacketStaleness(projectDir, options = {}) {
6991
7034
  ...packet,
6992
7035
  freshness: nextFreshness,
6993
7036
  quality: nextQuality,
6994
- updated_at: nowIso(),
7037
+ // updated_at is a CONTENT timestamp. Metadata rewrites (stale flags,
7038
+ // usage counters) bumping it made dead packets look fresh forever:
7039
+ // recency scoring lied and gc retention could never age them out.
7040
+ updated_at: contentChanged ? nowIso() : packet.updated_at,
6995
7041
  });
6996
7042
  updated += 1;
6997
7043
  }
@@ -7062,6 +7108,8 @@ function refreshProject(projectDir, options = {}) {
7062
7108
  next_actions: nextActions,
7063
7109
  };
7064
7110
  }
7111
+ // How long deprecated/superseded packets stay on disk before gc deletes them.
7112
+ const GC_DEAD_PACKET_RETENTION_DAYS = positiveIntEnv("KAGE_GC_RETENTION_DAYS", 30);
7065
7113
  function gcProject(projectDir, options = {}) {
7066
7114
  ensureMemoryDirs(projectDir);
7067
7115
  const packetEntries = loadPacketEntriesFromDir(packetsDir(projectDir));
@@ -7069,8 +7117,20 @@ function gcProject(projectDir, options = {}) {
7069
7117
  const deleted = [];
7070
7118
  const skipped = [];
7071
7119
  for (const { path, packet } of packetEntries) {
7072
- if (packet.status === "deprecated") {
7073
- skipped.push({ id: packet.id, title: packet.title, reason: "already deprecated" });
7120
+ if (packet.status === "deprecated" || packet.status === "superseded") {
7121
+ // Dead packets used to be immortal — 32% of the store was deprecated
7122
+ // weight every teammate cloned forever. Retain briefly for undo, then
7123
+ // delete; the audit trail keeps the tombstone.
7124
+ const stamp = Date.parse(packet.updated_at || packet.created_at || "");
7125
+ const expired = Number.isFinite(stamp) && Date.now() - stamp > GC_DEAD_PACKET_RETENTION_DAYS * 86_400_000;
7126
+ if (expired) {
7127
+ if (!options.dryRun)
7128
+ (0, node_fs_1.unlinkSync)(path);
7129
+ deleted.push({ id: packet.id, title: packet.title });
7130
+ }
7131
+ else {
7132
+ skipped.push({ id: packet.id, title: packet.title, reason: `${packet.status} — retained ${GC_DEAD_PACKET_RETENTION_DAYS}d before deletion` });
7133
+ }
7074
7134
  continue;
7075
7135
  }
7076
7136
  // Serialized transcript / tool-output / file-content dumps and ungrounded conversational
@@ -7150,6 +7210,18 @@ function kageSuppressedMemory(projectDir) {
7150
7210
  .filter((entry) => entry !== null);
7151
7211
  return { schema_version: 1, generated_at: nowIso(), count: items.length, items };
7152
7212
  }
7213
+ // A packet is "verified" only when something actually checked the claim: an
7214
+ // evidence-backed reverification. Capture at birth is provenance, not
7215
+ // verification — packets are born unverified and must earn the label.
7216
+ function packetVerificationLabel(packet) {
7217
+ const quality = (packet.quality ?? {});
7218
+ if (quality.stale === true)
7219
+ return "stale";
7220
+ const freshness = (packet.freshness ?? {});
7221
+ const verification = freshness.verification;
7222
+ const checked = typeof verification === "string" && verification.length > 0 && verification !== "repo_local_agent_capture";
7223
+ return checked && freshness.last_verified_at ? "verified" : "unverified";
7224
+ }
7153
7225
  function verifyCitations(projectDir, options = {}) {
7154
7226
  ensureMemoryDirs(projectDir);
7155
7227
  const approved = loadApprovedPackets(projectDir);
@@ -7176,13 +7248,17 @@ function verifyCitations(projectDir, options = {}) {
7176
7248
  stale_reasons: reasons,
7177
7249
  };
7178
7250
  });
7251
+ const hardStale = packets.filter((entry) => entry.stale_severity === "hard").length;
7252
+ const ungrounded = packets.filter((entry) => !entry.grounded).length;
7179
7253
  return {
7180
- ok: true,
7254
+ // ok used to be hardcoded true, which made `kage verify` a check that
7255
+ // cannot fail. It fails now: hard-stale or ungrounded memory is a defect.
7256
+ ok: hardStale === 0 && ungrounded === 0,
7181
7257
  project_dir: projectDir,
7182
7258
  checked: packets.length,
7183
7259
  valid: packets.filter((entry) => !entry.stale && entry.grounded).length,
7184
7260
  stale: packets.filter((entry) => entry.stale).length,
7185
- ungrounded: packets.filter((entry) => !entry.grounded).length,
7261
+ ungrounded,
7186
7262
  packets,
7187
7263
  errors: [],
7188
7264
  };
@@ -7935,16 +8011,19 @@ function recallGraphLookup(graph) {
7935
8011
  }
7936
8012
  return { packetEntityByPacketId, edgesByEntityId };
7937
8013
  }
7938
- function recallBreakdown(projectDir, terms, packet, textScore, temporalScore = 0, semanticScore = 0, vectorScore = 0, usageScore = 0, graph = buildKnowledgeGraph(projectDir), lookup = recallGraphLookup(graph)) {
8014
+ function recallBreakdown(projectDir, terms, packet, textScore, temporalScore = 0, semanticScore = 0, vectorScore = 0, usageScore = 0, recencyScore = 0, identifierScore = 0, graph = buildKnowledgeGraph(projectDir), lookup = recallGraphLookup(graph)) {
7939
8015
  const packetEntityId = lookup.packetEntityByPacketId.get(packet.id);
7940
8016
  const rawGraphScore = packetEntityId
7941
8017
  ? (lookup.edgesByEntityId.get(packetEntityId) ?? []).reduce((sum, edge) => sum + scoreText(terms, edge.fact), 0)
7942
8018
  : 0;
8019
+ // Graph prior at parity with lexical evidence, log1p-damped: raw edge sums
8020
+ // grow with graph density, not with relevance — an old release note with 40
8021
+ // edges must not outscore the packet whose title matches the query.
7943
8022
  const graphCap = packet.type === "reference"
7944
8023
  ? 0
7945
- : (textScore > 0 ? textScore * 1.5 + 12 : 8);
8024
+ : (textScore > 0 ? Math.min(textScore, 8) : 4);
7946
8025
  const graphWeight = packet.type === "reference" ? 0 : 0.45;
7947
- const graphScore = Math.min(rawGraphScore * graphWeight, graphCap);
8026
+ const graphScore = Math.min(Math.log1p(rawGraphScore * graphWeight) * 3, graphCap);
7948
8027
  const pathTypeTag = scoreText(terms, `${packet.type} ${packet.tags.join(" ")} ${packet.paths.join(" ")}`, [packet.type, ...packet.tags, ...packet.paths]);
7949
8028
  const intent = recallIntentBoost(terms, packet);
7950
8029
  const freshness = packet.status === "approved" ? 2 : packet.status === "pending" ? 0 : -5;
@@ -7958,12 +8037,15 @@ function recallBreakdown(projectDir, terms, packet, textScore, temporalScore = 0
7958
8037
  // produced confident off-domain junk: a hot, high-quality packet ranked above the one
7959
8038
  // packet whose title literally contained the queried term, because its unconditional
7960
8039
  // quality+freshness (~12 pts) beat a weak-but-real lexical match.
7961
- const coreRelevance = textScore + graphScore + intent + vector;
8040
+ const coreRelevance = textScore + graphScore + intent + vector + identifierScore;
7962
8041
  const matchSignal = coreRelevance + pathTypeTag;
7963
8042
  const effectiveUsage = coreRelevance > 0 ? usage : 0;
7964
8043
  const effectiveQuality = matchSignal > 0 ? quality : 0;
7965
8044
  const effectiveFreshness = matchSignal > 0 ? freshness : Math.min(freshness, 0);
7966
- const final = Number((textScore + graphScore + pathTypeTag * pathTypeTagWeight + intent + vector + effectiveUsage + effectiveFreshness + effectiveQuality + feedback).toFixed(2));
8045
+ // Recency amplifies matches and sinks aged changelog-shaped memory; like
8046
+ // freshness, the positive side never floats a non-match.
8047
+ const effectiveRecency = matchSignal > 0 ? recencyScore : Math.min(recencyScore, 0);
8048
+ const final = Number((textScore + graphScore + pathTypeTag * pathTypeTagWeight + intent + vector + identifierScore + effectiveUsage + effectiveFreshness + effectiveRecency + effectiveQuality + feedback).toFixed(2));
7967
8049
  return {
7968
8050
  bm25: textScore,
7969
8051
  text: textScore,
@@ -7975,11 +8057,31 @@ function recallBreakdown(projectDir, terms, packet, textScore, temporalScore = 0
7975
8057
  vector,
7976
8058
  usage,
7977
8059
  freshness,
8060
+ recency: Number(effectiveRecency.toFixed(2)),
8061
+ identifier: Number(identifierScore.toFixed(2)),
7978
8062
  quality: Number(quality.toFixed(2)),
7979
8063
  feedback,
7980
8064
  final,
7981
8065
  };
7982
8066
  }
8067
+ // Changelog-shaped memory (decisions, fixes, change summaries) ages fast — a
8068
+ // four-month-old release note outranking the current runbook was the headline
8069
+ // ranking bug. Evergreen types (runbooks, conventions, gotchas) keep the boost
8070
+ // window but never take the penalty.
8071
+ const RECENCY_FAST_TYPES = new Set(["decision", "bug_fix", "workflow", "reference", "issue_context"]);
8072
+ function recallRecencyScore(packet) {
8073
+ const stamp = Date.parse(packet.updated_at || packet.created_at || "");
8074
+ if (!Number.isFinite(stamp))
8075
+ return 0;
8076
+ const days = (Date.now() - stamp) / 86_400_000;
8077
+ if (days <= 14)
8078
+ return 3;
8079
+ if (days <= 60)
8080
+ return 1;
8081
+ if (days <= 120)
8082
+ return 0;
8083
+ return RECENCY_FAST_TYPES.has(packet.type) ? -3 : 0;
8084
+ }
7983
8085
  function recallDiversitySource(packet) {
7984
8086
  for (const ref of packet.source_refs) {
7985
8087
  if (ref.kind === "observation_session" && typeof ref.session_id === "string" && ref.session_id.trim()) {
@@ -8021,6 +8123,11 @@ function diversifyRecallEntries(entries, limit, maxPerSource = 3) {
8021
8123
  function isSerializedDumpTitle(title) {
8022
8124
  const t = (title ?? "").trimStart();
8023
8125
  return /^(workflow|runbook)\s*:?\s*[{[]/i.test(t)
8126
+ // "Runbook: Tool failed: {...}" evaded the brace check above because prose
8127
+ // sits between the label and the payload; braces early in a title are a
8128
+ // dump signature regardless of what precedes them.
8129
+ || /^(workflow|runbook)\s*:.{0,40}[{[]/i.test(t)
8130
+ || /^(workflow|runbook)\s*:\s*tool failed/i.test(t)
8024
8131
  || t.startsWith('{"')
8025
8132
  || /^<(task-notification|div|svg|html)\b/i.test(t)
8026
8133
  || /\btool_use_id\b|toolu_[A-Za-z0-9]{10}/.test(title)
@@ -8175,6 +8282,22 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
8175
8282
  const referenceBodyScores = scoreReferenceBodyBm25(terms, approvedPackets);
8176
8283
  const accessEntries = readMemoryAccessEntries(projectDir, approvedPackets);
8177
8284
  const graphLookup = recallGraphLookup(knowledgeGraph);
8285
+ // Terse identifier queries ("recallBreakdown") often share no prose with the
8286
+ // packet that documents them; ground them through the code graph instead — a
8287
+ // packet citing the file that defines the queried identifier is evidence.
8288
+ const identifierTerms = unique((query.match(/[A-Za-z_][A-Za-z0-9_]{5,}/g) ?? []).filter((token) => /[a-z][A-Z]|_/.test(token)));
8289
+ let identifierFiles = null;
8290
+ if (identifierTerms.length) {
8291
+ try {
8292
+ const wanted = new Set(identifierTerms.map((token) => token.toLowerCase()));
8293
+ identifierFiles = new Set(buildCodeGraph(projectDir).symbols
8294
+ .filter((symbol) => wanted.has(symbol.name.toLowerCase()))
8295
+ .map((symbol) => symbol.path));
8296
+ }
8297
+ catch {
8298
+ identifierFiles = null;
8299
+ }
8300
+ }
8178
8301
  const rankedScored = approvedPackets
8179
8302
  .map((packet) => {
8180
8303
  const base = baseScores.get(packet.id) ?? { score: 0, why: [] };
@@ -8185,8 +8308,10 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
8185
8308
  const lexicalScore = base.score + temporal.score + semantic.score;
8186
8309
  const textScore = packet.type === "reference" ? Math.max(lexicalScore, referenceBodyScore) : lexicalScore;
8187
8310
  const usageScore = memoryAccessScore(accessEntries.get(packet.id));
8188
- const score_breakdown = recallBreakdown(projectDir, terms, packet, textScore, temporal.score, semantic.score, vector.score, usageScore, knowledgeGraph, graphLookup);
8189
- const relevance = textScore + score_breakdown.graph + score_breakdown.path_type_tag + score_breakdown.intent + score_breakdown.vector;
8311
+ const identifierScore = identifierFiles && identifierFiles.size && packet.paths.some((path) => identifierFiles.has(path)) ? 6 : 0;
8312
+ const recencyScore = recallRecencyScore(packet);
8313
+ const score_breakdown = recallBreakdown(projectDir, terms, packet, textScore, temporal.score, semantic.score, vector.score, usageScore, recencyScore, identifierScore, knowledgeGraph, graphLookup);
8314
+ const relevance = textScore + score_breakdown.graph + score_breakdown.path_type_tag + score_breakdown.intent + score_breakdown.vector + score_breakdown.identifier;
8190
8315
  const why = [
8191
8316
  ...base.why,
8192
8317
  ...temporal.why.map((item) => `temporal:${item}`),
@@ -8194,6 +8319,7 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
8194
8319
  ...(semantic.score > 0 ? expansion.semanticLabels.map((label) => `semantic-concept:${label}`) : []),
8195
8320
  ...vector.why,
8196
8321
  ...(usageScore > 0 ? [`usage:${accessEntries.get(packet.id)?.uses_30d ?? 0} recalls in 30d`] : []),
8322
+ ...(identifierScore > 0 ? ["identifier: query names a symbol defined in a cited file"] : []),
8197
8323
  ];
8198
8324
  return { packet, score: score_breakdown.final, relevance, why_matched: unique(why).slice(0, 12), score_breakdown };
8199
8325
  })
@@ -8277,7 +8403,7 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
8277
8403
  pendingScored.length ? "## Working Memory (Pending Review)" : "",
8278
8404
  ...pendingScored.flatMap((entry, index) => [
8279
8405
  "",
8280
- `${index + 1}. [${entry.packet.type} | pending | confidence ${entry.packet.confidence.toFixed(2)}] ${entry.packet.title}`,
8406
+ `${index + 1}. [${entry.packet.type} | pending | unreviewed draft] ${entry.packet.title}`,
8281
8407
  ` Summary: ${entry.packet.summary}`,
8282
8408
  ` Why matched: ${entry.why_matched.join(", ") || "text relevance"}`,
8283
8409
  ` Source: pending packet; unapproved local/session memory`,
@@ -8300,7 +8426,7 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
8300
8426
  "_Cross-machine personal store (~/.kage/memory). Lower trust than repo memory: not repo-reviewed — verify before relying on it. Repo memory above takes precedence on conflict._",
8301
8427
  ...personalEntries.flatMap((entry, index) => [
8302
8428
  "",
8303
- `${index + 1}. [personal] [${entry.packet.type} | confidence ${entry.packet.confidence.toFixed(2)}] ${entry.packet.title}`,
8429
+ `${index + 1}. [personal] [${entry.packet.type} | ${packetVerificationLabel(entry.packet)}] ${entry.packet.title}`,
8304
8430
  ` [personal] Summary: ${entry.packet.summary}`,
8305
8431
  ` [personal] Why matched: ${entry.why_matched.join(", ") || "text relevance"}`,
8306
8432
  ` [personal] Verification: ${entry.unverifiable ? "unverifiable (citation-free personal note)" : "citations re-verified against this checkout"}`,
@@ -10087,9 +10213,9 @@ function truthReport(projectDir) {
10087
10213
  findings,
10088
10214
  warnings,
10089
10215
  next_actions: [
10090
- "npx -y @kage-core/kage-graph-mcp install one command: creates repo memory + wires your agents (Claude Code, Codex, Cursor, ...)",
10091
- "then just work agents capture learnings and recall them, verified against this code",
10092
- "kage gains --project . the receipt: what the memory loop saved you this week",
10216
+ "kage check --project . verify CLAUDE.md/AGENTS.md/docs claims against this code counted, not estimated",
10217
+ "kage check --init-ci gate every PR: fail only when a diff breaks a documented claim",
10218
+ "npx -y @kage-core/kage-graph-mcp install wire repo memory + agents (Claude Code, Codex, Cursor, ...)",
10093
10219
  ],
10094
10220
  };
10095
10221
  }
@@ -14100,6 +14226,24 @@ function setupAgent(agent, projectDir, options = {}) {
14100
14226
  const path = (0, node_path_1.join)(home, ".claude.json");
14101
14227
  const server = { type: "stdio", command: serverCommand, args: serverArgs, alwaysLoad: true };
14102
14228
  const hookDir = (0, node_path_1.join)(home, ".claude", "kage", "hooks");
14229
+ // The hooks used to die on `command -v kage || exit 0`: an npx install puts
14230
+ // nothing on PATH, so every ambient hook silently no-oped for new users.
14231
+ // Resolve the CLI the same way the MCP server config does — PATH first
14232
+ // (fast), then the install-time cli.js (guarded by -f so npx cache pruning
14233
+ // degrades gracefully), then the package runner. The loop never silently dies.
14234
+ // portableHooks (plugin generation): the scripts are committed and shared,
14235
+ // so no machine-specific path may be baked in — PATH then package runner.
14236
+ const hookCliPath = options.portableHooks ? "" : (0, node_path_1.join)((0, node_path_1.dirname)(serverPath), "cli.js");
14237
+ const hookKageResolve = `# kage-hooks-v${exports.KAGE_HOOKS_VERSION}
14238
+ # Resolve the kage CLI: repo-local, PATH${hookCliPath ? ", baked install path" : ""}, then the package runner.
14239
+ export PATH="$CWD/node_modules/.bin:$PATH"
14240
+ if command -v kage >/dev/null 2>&1; then
14241
+ :
14242
+ ${hookCliPath ? `elif [[ -f "${hookCliPath}" ]] && command -v node >/dev/null 2>&1; then
14243
+ kage() { node "${hookCliPath}" "$@"; }
14244
+ ` : ""}else
14245
+ kage() { npx -y --package=@kage-core/kage-graph-mcp kage "$@"; }
14246
+ fi`;
14103
14247
  const hookScript = `#!/usr/bin/env bash
14104
14248
  # Kage SessionStart hook — injects full memory policy as a system message.
14105
14249
  # Silent if Kage is not initialized in the current project.
@@ -14132,6 +14276,7 @@ Before finishing a task that changed files: kage_pr_summarize or kage_propose_fr
14132
14276
  If recalled memory helped: kage_feedback helpful. If wrong or stale: kage_feedback wrong or stale."
14133
14277
  fi
14134
14278
 
14279
+ ${hookKageResolve}
14135
14280
  # Session continuity: append a compact "previously…" digest when prior session data exists.
14136
14281
  if command -v kage >/dev/null 2>&1; then
14137
14282
  PREVIOUSLY="$(kage resume --project "$CWD" 2>/dev/null || true)"
@@ -14153,8 +14298,7 @@ PAYLOAD="$(cat || true)"
14153
14298
  CWD="$(printf "%s" "$PAYLOAD" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('cwd',''))" 2>/dev/null || echo "")"
14154
14299
 
14155
14300
  [[ -d "$CWD/.agent_memory" ]] || exit 0
14156
- # Resolve a repo-local install too, so hooks work without a global kage on PATH.
14157
- export PATH="$CWD/node_modules/.bin:$PATH"
14301
+ ${hookKageResolve}
14158
14302
  command -v kage >/dev/null 2>&1 || exit 0
14159
14303
 
14160
14304
  if git -C "$CWD" status --porcelain -uall >/dev/null 2>&1 && [[ -n "$(git -C "$CWD" status --porcelain -uall)" ]]; then
@@ -14211,8 +14355,7 @@ print(d.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or "")
14211
14355
  ' 2>/dev/null || echo "")"
14212
14356
 
14213
14357
  [[ -d "$CWD/.agent_memory" ]] || exit 0
14214
- # Resolve a repo-local install too, so hooks work without a global kage on PATH.
14215
- export PATH="$CWD/node_modules/.bin:$PATH"
14358
+ ${hookKageResolve}
14216
14359
  command -v kage >/dev/null 2>&1 || exit 0
14217
14360
 
14218
14361
  EVENT="$(PAYLOAD="$PAYLOAD" python3 -c 'import json, os
@@ -14262,17 +14405,53 @@ tool_response = d.get("tool_response") or d.get("toolResponse") or d.get("result
14262
14405
  prompt = first(d.get("prompt"), d.get("user_prompt"), d.get("message"))
14263
14406
  path = ""
14264
14407
  command = ""
14408
+ new_text = ""
14409
+ old_text = ""
14265
14410
  if isinstance(tool_input, dict):
14266
14411
  path = first(tool_input.get("file_path"), tool_input.get("path"), tool_input.get("notebook_path"))
14267
14412
  command = first(tool_input.get("command"))
14413
+ new_text = first(tool_input.get("new_string"), tool_input.get("content"), tool_input.get("new_source"))
14414
+ old_text = first(tool_input.get("old_string"))
14415
+ if not new_text and isinstance(tool_input.get("edits"), list):
14416
+ new_text = " ".join(e.get("new_string") or "" for e in tool_input["edits"] if isinstance(e, dict))[:1200]
14417
+
14418
+ def prose(value, limit=1200, tail=False):
14419
+ # Plain-text extraction. Serialized dicts read as noise to the signal
14420
+ # scorer (jsonNoiseText), so pull the human-readable field instead of
14421
+ # json.dumps-ing the payload — otherwise every tool observation scores 0.
14422
+ if isinstance(value, dict):
14423
+ for key in ("stdout", "stderr", "output", "error", "message", "content", "text"):
14424
+ candidate = value.get(key)
14425
+ if isinstance(candidate, str) and candidate.strip():
14426
+ flat = " ".join(candidate.split())
14427
+ return flat[-limit:] if tail else flat[:limit]
14428
+ flat = " ".join(str(v) for v in value.values() if isinstance(v, str) and v.strip())
14429
+ flat = " ".join(flat.split())
14430
+ return flat[:limit]
14431
+ if value is None:
14432
+ return ""
14433
+ flat = " ".join(str(value).split())
14434
+ return flat[-limit:] if tail else flat[:limit]
14268
14435
 
14269
14436
  if event_name == "UserPromptSubmit":
14270
14437
  payload = {"type": "user_prompt", "text": prompt, "summary": compact(prompt, 240)}
14271
14438
  elif event_name == "PostToolUseFailure":
14272
- payload = {"type": "command_result" if command else "tool_result", "tool": tool, "path": path, "command": command, "summary": "Tool failed: " + compact(tool_response or d, 320), "text": compact(tool_response or d)}
14439
+ err = prose(tool_response or d, 900, tail=True)
14440
+ line = (command or tool or "tool") + " failed: " + err
14441
+ payload = {"type": "command_result" if command else "tool_result", "tool": tool, "path": path, "command": command, "summary": line[:320], "text": line}
14273
14442
  elif event_name == "PostToolUse":
14274
- obs_type = "file_change" if path else ("command_result" if command else "tool_use")
14275
- payload = {"type": obs_type, "tool": tool, "path": path, "command": command, "summary": compact(tool_response or tool_input, 320), "text": compact(tool_response or tool_input)}
14443
+ if path and (new_text or old_text):
14444
+ # The edit content is where fixes and conventions live; tool_response
14445
+ # only says "success" and must never displace it.
14446
+ change = ("changed " + path + ": " + old_text[:160] + " -> " + new_text[:480]) if old_text else ("wrote " + path + ": " + new_text[:600])
14447
+ payload = {"type": "file_change", "tool": tool, "path": path, "summary": change[:320], "text": change}
14448
+ elif command:
14449
+ out = prose(tool_response, 900, tail=True)
14450
+ line = command + (": " + out if out else " completed")
14451
+ payload = {"type": "command_result", "tool": tool, "path": path, "command": command, "summary": line[:320], "text": line}
14452
+ else:
14453
+ body = prose(tool_response) or prose(tool_input)
14454
+ payload = {"type": "file_change" if path else "tool_use", "tool": tool, "path": path, "command": command, "summary": ((tool + ": ") if tool else "") + body[:300], "text": body}
14276
14455
  elif event_name == "PreCompact":
14277
14456
  payload = {"type": "session_end", "summary": "Claude Code is compacting context; distill durable observations before compaction."}
14278
14457
  elif event_name == "SessionEnd":
@@ -14293,7 +14472,9 @@ if [[ -n "$OBSERVATION" ]]; then
14293
14472
  fi
14294
14473
 
14295
14474
  if [[ "$EVENT" == "PreCompact" || "$EVENT" == "SessionEnd" || "$EVENT" == "SubagentStop" ]]; then
14296
- kage distill --project "$CWD" --session "$SESSION" --json >/dev/null 2>&1 || true
14475
+ # --auto is load-bearing: it is the gated path (signal filter, dedupe, pending
14476
+ # review). Without it, distill writes unfiltered packets stamped approved.
14477
+ kage distill --auto --project "$CWD" --session "$SESSION" --json >/dev/null 2>&1 || true
14297
14478
  fi
14298
14479
 
14299
14480
  if [[ "$EVENT" == "UserPromptSubmit" ]]; then
@@ -14332,8 +14513,7 @@ print(d.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or "")
14332
14513
  ' 2>/dev/null || echo "")"
14333
14514
 
14334
14515
  [[ -d "$CWD/.agent_memory" ]] || exit 0
14335
- # Resolve a repo-local install too, so hooks work without a global kage on PATH.
14336
- export PATH="$CWD/node_modules/.bin:$PATH"
14516
+ ${hookKageResolve}
14337
14517
  command -v kage >/dev/null 2>&1 || exit 0
14338
14518
 
14339
14519
  FILE_PATH="$(PAYLOAD="$PAYLOAD" python3 -c 'import json, os
@@ -14474,7 +14654,7 @@ function generatePluginHooks(pluginDir) {
14474
14654
  const tmpHome = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "kage-plugin-home-"));
14475
14655
  const tmpProject = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "kage-plugin-proj-"));
14476
14656
  try {
14477
- setupAgent("claude-code", tmpProject, { write: true, homeDir: tmpHome });
14657
+ setupAgent("claude-code", tmpProject, { write: true, homeDir: tmpHome, portableHooks: true });
14478
14658
  const srcHookDir = (0, node_path_1.join)(tmpHome, ".claude", "kage", "hooks");
14479
14659
  const settings = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(tmpHome, ".claude", "settings.json"), "utf8"));
14480
14660
  const hooksOut = (0, node_path_1.join)(pluginDir, "hooks");
@@ -14605,6 +14785,10 @@ function configMentionsKage(path) {
14605
14785
  return /\bkage\b/.test(text) && /(mcp|mcpServers|mcp_servers)/i.test(text);
14606
14786
  }
14607
14787
  const CLAUDE_AMBIENT_HOOK_EVENTS = ["SessionStart", "UserPromptSubmit", "PostToolUse", "PostToolUseFailure", "PreCompact", "Stop", "SessionEnd"];
14788
+ // Bump whenever a hook template changes behavior. Installed scripts carry the
14789
+ // stamp; doctor/verify report a mismatch so fixes actually reach existing
14790
+ // installs instead of only new setups.
14791
+ exports.KAGE_HOOKS_VERSION = 2;
14608
14792
  function claudeHookEventConfigured(settings, event) {
14609
14793
  const hooks = settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks)
14610
14794
  ? settings.hooks
@@ -14631,16 +14815,27 @@ function claudeAmbientHookSummary(homeDir) {
14631
14815
  }
14632
14816
  const installed = CLAUDE_AMBIENT_HOOK_EVENTS.filter((event) => claudeHookEventConfigured(settings, event));
14633
14817
  const missing = CLAUDE_AMBIENT_HOOK_EVENTS.filter((event) => !installed.includes(event));
14818
+ const outdated = [];
14634
14819
  for (const scriptPath of scriptPaths) {
14635
- if (!(0, node_fs_1.existsSync)(scriptPath))
14820
+ if (!(0, node_fs_1.existsSync)(scriptPath)) {
14636
14821
  missing.push((0, node_path_1.basename)(scriptPath));
14822
+ continue;
14823
+ }
14824
+ // A present-but-stale script is worse than a missing one: it runs old
14825
+ // behavior silently. Unstamped scripts predate versioning (v1).
14826
+ const text = safeReadText(scriptPath) ?? "";
14827
+ const stamp = text.match(/^# kage-hooks-v(\d+)$/m);
14828
+ const version = stamp ? Number(stamp[1]) : 1;
14829
+ if (version < exports.KAGE_HOOKS_VERSION)
14830
+ outdated.push((0, node_path_1.basename)(scriptPath));
14637
14831
  }
14638
14832
  return {
14639
14833
  required: [...CLAUDE_AMBIENT_HOOK_EVENTS],
14640
14834
  installed,
14641
14835
  missing: unique(missing),
14836
+ outdated,
14642
14837
  script_paths: scriptPaths,
14643
- ready: missing.length === 0,
14838
+ ready: missing.length === 0 && outdated.length === 0,
14644
14839
  };
14645
14840
  }
14646
14841
  function verifyAgentActivation(agent, projectDir, options = {}) {
@@ -14662,7 +14857,7 @@ function verifyAgentActivation(agent, projectDir, options = {}) {
14662
14857
  const mcpToolReachable = Boolean(options.mcpToolReachable);
14663
14858
  const hookSummary = agent === "claude-code"
14664
14859
  ? claudeAmbientHookSummary(options.homeDir ?? process.env.HOME ?? "~")
14665
- : { required: [], installed: [], missing: [], script_paths: [], ready: true };
14860
+ : { required: [], installed: [], missing: [], outdated: [], script_paths: [], ready: true };
14666
14861
  const ambientHooksPresent = hookSummary.ready;
14667
14862
  const warnings = [];
14668
14863
  const nextSteps = [];
@@ -15097,6 +15292,15 @@ function reusablePromptObservation(event) {
15097
15292
  "always",
15098
15293
  "never",
15099
15294
  "prefer",
15295
+ // Debugging intent is the highest-signal prompt there is: the session that
15296
+ // follows usually contains the root cause and the fix.
15297
+ "fail",
15298
+ "fix",
15299
+ "error",
15300
+ "broken",
15301
+ "regression",
15302
+ "doesn't work",
15303
+ "not working",
15100
15304
  "avoid",
15101
15305
  ];
15102
15306
  if (!durableSignals.some((signal) => lower.includes(signal)))
@@ -15461,10 +15665,13 @@ function distillSession(projectDir, sessionId, options = {}) {
15461
15665
  const auto = Boolean(options.auto);
15462
15666
  const mode = auto ? "auto" : "manual";
15463
15667
  const observations = loadObservations(projectDir, sessionId);
15464
- if (auto && observations.length === 0) {
15668
+ if (observations.length === 0) {
15465
15669
  return { ok: true, session_id: sessionId, observations: 0, candidates: [], errors: [], mode, skipped_reason: "no_observations", skipped_low_signal: 0 };
15466
15670
  }
15467
- if (auto && sessionAlreadyCaptured(projectDir, sessionId, observations)) {
15671
+ // Dedupe guards both modes: SessionEnd + PreCompact + SubagentStop can all
15672
+ // fire for one session, and re-distilling the same material wrote duplicate
15673
+ // packets for years.
15674
+ if (sessionAlreadyCaptured(projectDir, sessionId, observations)) {
15468
15675
  return { ok: true, session_id: sessionId, observations: observations.length, candidates: [], errors: [], mode, skipped_reason: "session_already_captured", skipped_low_signal: 0 };
15469
15676
  }
15470
15677
  const candidates = [];
@@ -15481,7 +15688,9 @@ function distillSession(projectDir, sessionId, options = {}) {
15481
15688
  {
15482
15689
  kind: "observation_session",
15483
15690
  session_id: sessionId,
15484
- observation_ids: observationIds,
15691
+ // A sample is enough provenance; full arrays made single packets 112KB
15692
+ // and every teammate clones them.
15693
+ observation_ids: observationIds.slice(0, 20),
15485
15694
  observation_count: observations.length,
15486
15695
  },
15487
15696
  ];
@@ -15526,13 +15735,12 @@ function distillSession(projectDir, sessionId, options = {}) {
15526
15735
  return result;
15527
15736
  };
15528
15737
  const autoTags = auto ? [exports.AUTO_DISTILL_TAG] : [];
15529
- // Auto-distill quality gate: drafts may only be seeded by observations scoring at
15738
+ // Distill quality gate: drafts may only be seeded by observations scoring at
15530
15739
  // least AUTO_DISTILL_SIGNAL_THRESHOLD. Events tagged low_signal at ingestion skip
15531
- // cheaply; untagged (older) records are scored here. Manual distill is not gated.
15740
+ // cheaply; untagged (older) records are scored here. Both modes are gated
15741
+ // ungated manual distill is how 116KB dumps got stamped approved+verified.
15532
15742
  let skippedLowSignal = 0;
15533
15743
  const signalGate = (events) => {
15534
- if (!auto)
15535
- return events;
15536
15744
  return events.filter((event) => {
15537
15745
  const lowSignal = event.low_signal === true
15538
15746
  || (event.low_signal === undefined && observationSignalScore(event) < exports.AUTO_DISTILL_SIGNAL_THRESHOLD);
@@ -15544,6 +15752,31 @@ function distillSession(projectDir, sessionId, options = {}) {
15544
15752
  const commandEvents = signalGate(observations.filter((event) => event.type === "command_result" && event.command));
15545
15753
  const fileEvents = signalGate(observations.filter((event) => event.type === "file_change" && event.path));
15546
15754
  const promptEvents = signalGate(observations.filter((event) => event.type === "user_prompt" && (event.text || event.summary)));
15755
+ // A fail→pass pair on the same command is the strongest evidence a session
15756
+ // produced a real fix. Stamp it on the drafts: it is true, it is checkable
15757
+ // from the observations, and it lets grounded fixes cross the promote bar.
15758
+ // Scanned pre-gate: the failing run often carries error text the gate keeps,
15759
+ // but the passing run can be terse.
15760
+ const failThenPassed = (() => {
15761
+ const failedAt = new Map();
15762
+ const proven = [];
15763
+ observations.forEach((event, index) => {
15764
+ if (event.type !== "command_result" || !event.command)
15765
+ return;
15766
+ const cmd = normalizeCommandText(event.command);
15767
+ const failed = typeof event.exit_code === "number"
15768
+ ? event.exit_code !== 0
15769
+ : /\bfail(ed|ure|ing)?\b|\berror\b/i.test(`${event.summary ?? ""} ${event.text ?? ""}`);
15770
+ if (failed)
15771
+ failedAt.set(cmd, index);
15772
+ else if (failedAt.has(cmd) && failedAt.get(cmd) < index && !proven.includes(cmd))
15773
+ proven.push(cmd);
15774
+ });
15775
+ return proven;
15776
+ })();
15777
+ const verificationLine = failThenPassed.length
15778
+ ? `\n\nVerified: \`${failThenPassed[0]}\` failed then passed after the change — reproduced in session ${sessionId}.`
15779
+ : "";
15547
15780
  const meaningfulCommandEvents = commandEvents
15548
15781
  .map((event) => ({ event, reusable: reusableCommandObservation(event, knownRepoCommands(projectDir)) }))
15549
15782
  .filter((item) => Boolean(item.reusable));
@@ -15554,11 +15787,13 @@ function distillSession(projectDir, sessionId, options = {}) {
15554
15787
  projectDir,
15555
15788
  title: `Runbook: ${lead}`,
15556
15789
  summary: `Observed commands: ${commands.slice(0, 3).join(", ")}`,
15557
- body: `Reusable command observation distilled from session ${sessionId}:\n\n${meaningfulCommandEvents.map((item) => `- ${item.reusable.command}: ${item.reusable.learning}`).join("\n")}\n\nReview before approving as a durable runbook.`,
15790
+ body: `Reusable command observation distilled from session ${sessionId}:\n\n${meaningfulCommandEvents.map((item) => `- ${item.reusable.command}: ${item.reusable.learning}`).join("\n")}${verificationLine}\n\nReview before approving as a durable runbook.`,
15558
15791
  type: "runbook",
15559
15792
  tags: ["observed-session", "commands", "runbook", ...autoTags],
15560
15793
  paths: unique(meaningfulCommandEvents.map((item) => item.event.path).filter(Boolean)),
15561
- pendingReview: auto,
15794
+ // Distilled drafts are born pending in every mode; only the grounded
15795
+ // high-signal auto-promotion path may lift them to approved.
15796
+ pendingReview: true,
15562
15797
  })));
15563
15798
  }
15564
15799
  const meaningfulFileEvents = fileEvents
@@ -15571,23 +15806,25 @@ function distillSession(projectDir, sessionId, options = {}) {
15571
15806
  projectDir,
15572
15807
  title: `Workflow: ${lead}`,
15573
15808
  summary: lead,
15574
- body: `Reusable file observation distilled from session ${sessionId}:\n\n${meaningfulFileEvents.map((item) => `- ${item.event.path}: ${item.learning}`).join("\n")}\n\nReview before approving as durable repo memory.`,
15809
+ body: `Reusable file observation distilled from session ${sessionId}:\n\n${meaningfulFileEvents.map((item) => `- ${item.event.path}: ${item.learning}`).join("\n")}${verificationLine}\n\nReview before approving as durable repo memory.`,
15575
15810
  type: "workflow",
15576
15811
  tags: ["observed-session", "workflow", ...autoTags],
15577
15812
  paths,
15578
- pendingReview: auto,
15813
+ pendingReview: true,
15579
15814
  })));
15580
15815
  }
15581
15816
  if (promptEvents.length) {
15582
- const text = promptEvents.map(reusablePromptObservation).filter(Boolean).join("\n").trim();
15817
+ // Prompt-derived text is the least grounded input: clamp per prompt and
15818
+ // joined, and always land it in the pending inbox.
15819
+ const text = promptEvents.map((event) => clampInline(reusablePromptObservation(event), 500)).filter(Boolean).join("\n").trim().slice(0, 4000);
15583
15820
  if (text)
15584
15821
  candidates.push(annotate(learn({
15585
15822
  projectDir,
15586
15823
  title: titleFromLearning(text),
15587
- learning: text,
15824
+ learning: `${text}${verificationLine}`,
15588
15825
  evidence: `Observation session: ${sessionId}`,
15589
15826
  tags: ["observed-session", "intent", ...autoTags],
15590
- pendingReview: auto,
15827
+ pendingReview: true,
15591
15828
  })));
15592
15829
  }
15593
15830
  for (const result of candidates)
@@ -15743,7 +15980,17 @@ function createDiffChangeMemory(projectDir, summary) {
15743
15980
  const verifyCommands = npmScriptCommands(projectDir)
15744
15981
  .filter((command) => /(test|check|lint|build|type|verify)/i.test(command))
15745
15982
  .slice(0, 8);
15746
- const changedList = summary.changed_files.slice(0, 40).map((file) => `- ${file}`).join("\n");
15983
+ // Change-memory carries the substance of a change, not Kage's own
15984
+ // bookkeeping: memory files, git plumbing, and Kage-written policy files are
15985
+ // excluded — git already stores those diffs, and lists of packet filenames
15986
+ // were the whole body of the worst change-memory packets.
15987
+ const kagePolicyFiles = new Set(["CLAUDE.md", "AGENTS.md"].filter((name) => (safeReadText((0, node_path_1.join)(projectDir, name)) ?? "").includes("KAGE_MEMORY_POLICY")));
15988
+ const meaningfulChanged = summary.changed_files.filter((file) => !file.startsWith(".agent_memory/")
15989
+ && file !== ".gitattributes"
15990
+ && !kagePolicyFiles.has(file));
15991
+ const listedChanged = meaningfulChanged.length ? meaningfulChanged : summary.changed_files;
15992
+ const changedList = listedChanged.slice(0, 25).map((file) => `- ${file}`).join("\n")
15993
+ + (listedChanged.length > 25 ? `\n- … ${listedChanged.length - 25} more` : "");
15747
15994
  const verifyList = verifyCommands.length
15748
15995
  ? verifyCommands.map((command) => `- ${command}`).join("\n")
15749
15996
  : "- Add the exact test, build, or manual verification command when you refine this memory.";
@@ -15759,7 +16006,7 @@ function createDiffChangeMemory(projectDir, summary) {
15759
16006
  "```text",
15760
16007
  // Clamp the diff stat: a huge diff would otherwise produce a dump-sized change-memory
15761
16008
  // body. This path builds the packet directly (not via capture()), so bound it here.
15762
- clampBlock(summary.diff_stat, 4000),
16009
+ clampBlock(summary.diff_stat, 1500),
15763
16010
  "```",
15764
16011
  "",
15765
16012
  "How to verify:",
@@ -15778,7 +16025,7 @@ function createDiffChangeMemory(projectDir, summary) {
15778
16025
  schema_version: exports.PACKET_SCHEMA_VERSION,
15779
16026
  id: stableId,
15780
16027
  title,
15781
- summary: `Repo-local context for ${summary.changed_files.length} changed repo path${summary.changed_files.length === 1 ? "" : "s"} on ${branch}.`,
16028
+ summary: `Repo-local context for ${listedChanged.length} changed repo path${listedChanged.length === 1 ? "" : "s"} on ${branch}.`,
15782
16029
  body,
15783
16030
  type: "workflow",
15784
16031
  scope: "repo",
@@ -15787,7 +16034,7 @@ function createDiffChangeMemory(projectDir, summary) {
15787
16034
  status: "approved",
15788
16035
  confidence: 0.62,
15789
16036
  tags: unique(["change-memory", "diff-proposal", "repo-local", branch ? `branch:${slugify(branch)}` : "branch:detached"]),
15790
- paths: summary.changed_files.slice(0, 40),
16037
+ paths: listedChanged.slice(0, 40),
15791
16038
  stack: inferStack(projectDir),
15792
16039
  source_refs: [
15793
16040
  {
@@ -15795,12 +16042,12 @@ function createDiffChangeMemory(projectDir, summary) {
15795
16042
  branch,
15796
16043
  head,
15797
16044
  merge_base: summary.merge_base,
15798
- changed_files: summary.changed_files,
16045
+ changed_files: summary.changed_files.slice(0, 100),
15799
16046
  summary_path: (0, node_path_1.join)(reviewDir(projectDir), `branch-summary-${slugify(branch)}.json`),
15800
16047
  },
15801
16048
  ],
15802
16049
  context: {
15803
- fact: `Current branch ${branch} changes ${summary.changed_files.length} repo path${summary.changed_files.length === 1 ? "" : "s"}.`,
16050
+ fact: `Current branch ${branch} changes ${listedChanged.length} repo path${listedChanged.length === 1 ? "" : "s"}.`,
15804
16051
  why: "Branch change memory gives future agents durable context from the git diff when they continue, review, or verify this work.",
15805
16052
  trigger: "Recall when asking what changed on this branch, preparing a PR review, or resuming this work.",
15806
16053
  action: "Use the changed file list and diff summary as orientation, then inspect the actual diff and source files before making further edits.",
@@ -16732,24 +16979,30 @@ function mergePacketFiles(oursPath, basePath, theirsPath) {
16732
16979
  const raw = safeReadText(path);
16733
16980
  if (raw === null)
16734
16981
  return null;
16735
- if (path.endsWith(".md")) {
16736
- const packet = (0, okf_js_1.okfConceptToPacket)(raw);
16737
- return packet ? { raw, packet } : null;
16738
- }
16739
- try {
16740
- const parsed = JSON.parse(raw);
16741
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
16742
- return { raw, packet: parsed };
16982
+ // Sniff content, never the extension: git merge temp files may keep or
16983
+ // drop the original extension depending on the flow, and .md packet files
16984
+ // have held both raw JSON and OKF frontmatter. Routing raw-JSON .md files
16985
+ // to the OKF parser made every sync-bot race a manual conflict.
16986
+ if (raw.trimStart().startsWith("{")) {
16987
+ try {
16988
+ const parsed = JSON.parse(raw);
16989
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
16990
+ return { raw, packet: parsed };
16991
+ }
16992
+ }
16993
+ catch {
16994
+ // fall through to the other parsers
16743
16995
  }
16744
16996
  }
16745
- catch {
16746
- // A side that carries committed conflict markers (the exact failure mode
16747
- // this driver exists to end) can often be recovered with repair's
16748
- // conflict-splitting logic before giving up on it.
16749
- const recovered = resolveConflictedPacket(raw);
16750
- if (recovered)
16751
- return { raw: `${JSON.stringify(recovered, null, 2)}\n`, packet: recovered };
16752
- }
16997
+ const okf = (0, okf_js_1.okfConceptToPacket)(raw);
16998
+ if (okf)
16999
+ return { raw, packet: okf };
17000
+ // A side that carries committed conflict markers (the exact failure mode
17001
+ // this driver exists to end) can often be recovered with repair's
17002
+ // conflict-splitting logic before giving up on it.
17003
+ const recovered = resolveConflictedPacket(raw);
17004
+ if (recovered)
17005
+ return { raw: `${JSON.stringify(recovered, null, 2)}\n`, packet: recovered };
16753
17006
  return null;
16754
17007
  };
16755
17008
  const ours = readSide(oursPath);
@@ -17296,7 +17549,7 @@ function generateSkills(projectDir, options = {}) {
17296
17549
  // supersede churn when code changed but the memory's claim did not. Refuses
17297
17550
  // when ALL cited evidence is gone — that memory needs supersede or stale, not
17298
17551
  // a rubber stamp.
17299
- function reverifyMemory(projectDir, packetId) {
17552
+ function reverifyMemory(projectDir, packetId, options = {}) {
17300
17553
  ensureMemoryDirs(projectDir);
17301
17554
  const result = {
17302
17555
  ok: false,
@@ -17304,6 +17557,7 @@ function reverifyMemory(projectDir, packetId) {
17304
17557
  packet_id: packetId,
17305
17558
  refreshed_paths: [],
17306
17559
  missing_paths: [],
17560
+ changed_paths: [],
17307
17561
  was_stale: false,
17308
17562
  errors: [],
17309
17563
  };
@@ -17328,13 +17582,52 @@ function reverifyMemory(projectDir, packetId) {
17328
17582
  const presentPaths = citedPaths.filter((path) => !result.missing_paths.includes(path));
17329
17583
  const now = nowIso();
17330
17584
  const freshness = { ...(packet.freshness ?? {}) };
17331
- freshness.path_fingerprints = memoryPathFingerprints(projectDir, presentPaths, `${packet.title}\n${packet.summary}\n${packet.body}`);
17585
+ const nextPrints = memoryPathFingerprints(projectDir, presentPaths, `${packet.title}\n${packet.summary}\n${packet.body}`);
17586
+ // Evidence gate: when cited code changed since the stored fingerprints, a
17587
+ // bare re-stamp would launder a possibly-false claim back to "verified".
17588
+ // Byte-identical files may refresh freely; changed files demand evidence.
17589
+ const storedShas = new Map(packetStoredPathFingerprints(packet).map((print) => [print.path, print.sha256]));
17590
+ const changedPaths = nextPrints
17591
+ .filter((print) => storedShas.has(print.path) && storedShas.get(print.path) !== print.sha256)
17592
+ .map((print) => print.path);
17593
+ result.changed_paths = changedPaths;
17594
+ const evidence = (options.evidence ?? "").trim();
17595
+ const verifiedBy = (options.verifiedBy ?? "").trim();
17596
+ if (changedPaths.length && !evidence && !verifiedBy) {
17597
+ result.errors.push(`Cited code changed since the last verification (${changedPaths.join(", ")}). `
17598
+ + "Re-stamping without evidence would mark an unchecked claim verified: rerun with "
17599
+ + "--evidence \"<what you checked>\" or --verified-by \"<command/test that proved it>\", "
17600
+ + "or supersede the packet if the claim no longer holds.");
17601
+ return result;
17602
+ }
17603
+ freshness.path_fingerprints = nextPrints;
17332
17604
  freshness.last_verified_at = now;
17605
+ // Only an evidence-backed recheck upgrades verification; a clean fingerprint
17606
+ // refresh keeps whatever verification the packet already had.
17607
+ if (evidence || verifiedBy)
17608
+ freshness.verification = "evidence_reverification";
17333
17609
  const { stale: _stale, stale_reasons: _staleReasons, suggested_action: _suggestedAction, ...nextQuality } = quality;
17610
+ const sourceRefs = changedPaths.length
17611
+ ? [
17612
+ ...(packet.source_refs ?? []),
17613
+ {
17614
+ kind: "reverification",
17615
+ at: now,
17616
+ ...(verifiedBy ? { verified_by: verifiedBy } : {}),
17617
+ ...(evidence ? { evidence } : {}),
17618
+ changed_paths: changedPaths.map((path) => ({
17619
+ path,
17620
+ prior_sha256: storedShas.get(path),
17621
+ sha256: nextPrints.find((print) => print.path === path)?.sha256,
17622
+ })),
17623
+ },
17624
+ ]
17625
+ : packet.source_refs;
17334
17626
  writeJson(entry.path, {
17335
17627
  ...packet,
17336
17628
  paths: presentPaths.length ? presentPaths : packet.paths,
17337
17629
  freshness,
17630
+ source_refs: sourceRefs,
17338
17631
  quality: { ...nextQuality, reverified_at: now },
17339
17632
  updated_at: now,
17340
17633
  });