@kage-core/kage-graph-mcp 3.1.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;
@@ -154,6 +156,7 @@ exports.setupAgent = setupAgent;
154
156
  exports.generatePluginHooks = generatePluginHooks;
155
157
  exports.setupDoctor = setupDoctor;
156
158
  exports.verifyAgentActivation = verifyAgentActivation;
159
+ exports.pruneObservations = pruneObservations;
157
160
  exports.observe = observe;
158
161
  exports.observationSignalScore = observationSignalScore;
159
162
  exports.kageSessionCaptureReport = kageSessionCaptureReport;
@@ -209,6 +212,7 @@ const node_worker_threads_1 = require("node:worker_threads");
209
212
  const ts = __importStar(require("typescript"));
210
213
  const index_js_1 = require("./registry/index.js");
211
214
  const okf_js_1 = require("./okf.js");
215
+ const metrics_math_js_1 = require("./metrics-math.js");
212
216
  exports.PACKET_SCHEMA_VERSION = 2;
213
217
  exports.MEMORY_TYPES = [
214
218
  "repo_map",
@@ -293,13 +297,10 @@ Do this without waiting for the user to ask. Kage should feel like ambient repo
293
297
  If Kage appears installed but no Kage tools are available, report that the active
294
298
  agent session has not loaded the MCP server and ask the user to restart the
295
299
  agent. After restart, call \`kage_verify_agent\` to prove the harness is live.
296
-
297
- ## Show the Value
298
-
299
- \`kage_context\` and \`kage_recall\` return a one-line gains receipt (tokens/$ saved
300
- this session, stale memories withheld). When it is non-trivial, relay it to the
301
- user in your own words — Kage's value is otherwise invisible, and a user who never
302
- 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.
303
304
 
304
305
  ## Automatic Capture
305
306
 
@@ -375,25 +376,32 @@ For normal coding tasks:
375
376
  For quick factual questions, \`kage_context\` alone is enough. For status or demo requests, call \`kage_metrics\`.
376
377
  ${AGENTS_POLICY_END}
377
378
  `;
379
+ // Hooks pass raw user prompts as recall queries ("what is X? can we replace it"),
380
+ // so interrogatives, pronouns, and auxiliaries must be stopwords too — otherwise
381
+ // filler words collect BM25/vector/graph credit and drown the query's rare,
382
+ // high-IDF terms (a packet literally titled with the queried term lost to
383
+ // packets matching only "what"/"can"/"we").
378
384
  const STOPWORDS = new Set([
379
- "a",
380
- "an",
381
- "and",
382
- "are",
383
- "do",
384
- "does",
385
- "for",
386
- "how",
387
- "i",
388
- "in",
389
- "is",
390
- "it",
391
- "of",
392
- "on",
393
- "or",
394
- "the",
395
- "to",
396
- "with",
385
+ "a", "about", "again", "also", "an", "and", "are", "as", "at",
386
+ "be", "been", "being", "but", "by",
387
+ "can", "could",
388
+ "did", "do", "does",
389
+ "else",
390
+ "for", "from",
391
+ "had", "has", "have", "having", "he", "her", "hers", "here", "him", "his", "how",
392
+ "i", "if", "in", "into", "is", "it", "its",
393
+ "just",
394
+ "let", "lets",
395
+ "may", "me", "might", "mine", "my",
396
+ "no", "not",
397
+ "of", "on", "once", "or", "our", "ours", "over",
398
+ "please",
399
+ "shall", "she", "should", "so",
400
+ "than", "that", "the", "their", "theirs", "them", "then", "there", "these", "they", "this", "those", "to", "too",
401
+ "under", "us",
402
+ "very",
403
+ "was", "we", "were", "what", "when", "where", "which", "who", "whom", "whose", "why", "will", "would", "with",
404
+ "you", "your", "yours",
397
405
  ]);
398
406
  function memoryRoot(projectDir) {
399
407
  return (0, node_path_1.join)(projectDir, ".agent_memory");
@@ -1272,7 +1280,7 @@ function kageFileContext(projectDir, filePath) {
1272
1280
  const lines = [
1273
1281
  `# Kage File Context: ${rel}`,
1274
1282
  ...verified.flatMap((packet, index) => [
1275
- `${index + 1}. [${packet.type} | confidence ${packet.confidence.toFixed(2)}] ${packet.title}`,
1283
+ `${index + 1}. [${packet.type} | ${packetVerificationLabel(packet)}] ${packet.title}`,
1276
1284
  ` ${packet.summary}`,
1277
1285
  ]),
1278
1286
  `_${verified.length} verified memor${verified.length === 1 ? "y" : "ies"} citing this file (citations checked, not stale)._`,
@@ -1287,6 +1295,9 @@ function kageFileContext(projectDir, filePath) {
1287
1295
  }));
1288
1296
  const replay = replayTokensSaved(verified, result.context_block);
1289
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 })));
1290
1301
  return result;
1291
1302
  }
1292
1303
  const AUDIT_ACTIVITY_KIND = {
@@ -1679,6 +1690,32 @@ function identifierTokens(text) {
1679
1690
  out.add(match[0].toLowerCase());
1680
1691
  return out;
1681
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"]);
1682
1719
  // current-file symbol span hashes, keyed by `${nameLower}\0${kind}` -> [sha256...].
1683
1720
  // Cached by mtime+size: extraction only runs when a file actually changed.
1684
1721
  const anchorSymbolCache = new Map();
@@ -1740,7 +1777,7 @@ function fileSymbolSpanHashes(projectDir, path) {
1740
1777
  // title+summary+body) is supplied, anchor each TS/JS file to the symbols the
1741
1778
  // memory actually names, so unrelated edits in the same file do not mark it stale.
1742
1779
  function memoryPathFingerprints(projectDir, paths, anchorText) {
1743
- const idents = anchorText ? identifierTokens(anchorText) : null;
1780
+ const idents = anchorText ? codeAnchorTokens(anchorText) : null;
1744
1781
  const fingerprints = [];
1745
1782
  for (const path of unique(paths).filter(fingerprintableMemoryPath)) {
1746
1783
  const fingerprint = memoryPathFingerprint(projectDir, path);
@@ -1762,6 +1799,8 @@ function memoryPathFingerprints(projectDir, paths, anchorText) {
1762
1799
  const [name, kind] = key.split("\0");
1763
1800
  if (!idents.has(name) || spanCountByName.get(name) !== 1)
1764
1801
  continue;
1802
+ if (!ANCHOR_SYMBOL_KINDS.has(kind) && !name.includes("_"))
1803
+ continue;
1765
1804
  symbols.push({ name, kind, sha256: hashes[0] });
1766
1805
  }
1767
1806
  if (symbols.length) {
@@ -6949,6 +6988,10 @@ function refreshPacketStaleness(projectDir, options = {}) {
6949
6988
  let updated = 0;
6950
6989
  const fingerprintCache = new Map();
6951
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);
6952
6995
  for (const entry of loadPacketEntriesFromDir(packetsDir(projectDir))) {
6953
6996
  // Drop any .kageignore'd grounding (presentation layers etc.) from the stored packet
6954
6997
  // so memory is never anchored to non-knowledge files.
@@ -6972,6 +7015,15 @@ function refreshPacketStaleness(projectDir, options = {}) {
6972
7015
  const { stale: _stale, stale_reasons: _staleReasons, suggested_action: _suggestedAction, ...rest } = oldQuality;
6973
7016
  nextQuality = rest;
6974
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
+ }
6975
7027
  const nextFreshness = oldFreshness;
6976
7028
  const contentChanged = pruned !== null;
6977
7029
  const changed = contentChanged
@@ -6982,7 +7034,10 @@ function refreshPacketStaleness(projectDir, options = {}) {
6982
7034
  ...packet,
6983
7035
  freshness: nextFreshness,
6984
7036
  quality: nextQuality,
6985
- 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,
6986
7041
  });
6987
7042
  updated += 1;
6988
7043
  }
@@ -7010,6 +7065,7 @@ function refreshProject(projectDir, options = {}) {
7010
7065
  }
7011
7066
  const validation = validateProject(projectDir);
7012
7067
  const metrics = kageMetricsShallow(projectDir, { codeGraph, knowledgeGraph, validation });
7068
+ pruneObservations(projectDir);
7013
7069
  ensureDir(reportsDir(projectDir));
7014
7070
  writeJson((0, node_path_1.join)(reportsDir(projectDir), "context-slots.json"), kageContextSlots(projectDir));
7015
7071
  writeJson((0, node_path_1.join)(reportsDir(projectDir), "handoff.json"), kageMemoryHandoff(projectDir));
@@ -7052,6 +7108,8 @@ function refreshProject(projectDir, options = {}) {
7052
7108
  next_actions: nextActions,
7053
7109
  };
7054
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);
7055
7113
  function gcProject(projectDir, options = {}) {
7056
7114
  ensureMemoryDirs(projectDir);
7057
7115
  const packetEntries = loadPacketEntriesFromDir(packetsDir(projectDir));
@@ -7059,8 +7117,20 @@ function gcProject(projectDir, options = {}) {
7059
7117
  const deleted = [];
7060
7118
  const skipped = [];
7061
7119
  for (const { path, packet } of packetEntries) {
7062
- if (packet.status === "deprecated") {
7063
- 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
+ }
7064
7134
  continue;
7065
7135
  }
7066
7136
  // Serialized transcript / tool-output / file-content dumps and ungrounded conversational
@@ -7140,6 +7210,18 @@ function kageSuppressedMemory(projectDir) {
7140
7210
  .filter((entry) => entry !== null);
7141
7211
  return { schema_version: 1, generated_at: nowIso(), count: items.length, items };
7142
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
+ }
7143
7225
  function verifyCitations(projectDir, options = {}) {
7144
7226
  ensureMemoryDirs(projectDir);
7145
7227
  const approved = loadApprovedPackets(projectDir);
@@ -7166,13 +7248,17 @@ function verifyCitations(projectDir, options = {}) {
7166
7248
  stale_reasons: reasons,
7167
7249
  };
7168
7250
  });
7251
+ const hardStale = packets.filter((entry) => entry.stale_severity === "hard").length;
7252
+ const ungrounded = packets.filter((entry) => !entry.grounded).length;
7169
7253
  return {
7170
- 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,
7171
7257
  project_dir: projectDir,
7172
7258
  checked: packets.length,
7173
7259
  valid: packets.filter((entry) => !entry.stale && entry.grounded).length,
7174
7260
  stale: packets.filter((entry) => entry.stale).length,
7175
- ungrounded: packets.filter((entry) => !entry.grounded).length,
7261
+ ungrounded,
7176
7262
  packets,
7177
7263
  errors: [],
7178
7264
  };
@@ -7925,16 +8011,19 @@ function recallGraphLookup(graph) {
7925
8011
  }
7926
8012
  return { packetEntityByPacketId, edgesByEntityId };
7927
8013
  }
7928
- 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)) {
7929
8015
  const packetEntityId = lookup.packetEntityByPacketId.get(packet.id);
7930
8016
  const rawGraphScore = packetEntityId
7931
8017
  ? (lookup.edgesByEntityId.get(packetEntityId) ?? []).reduce((sum, edge) => sum + scoreText(terms, edge.fact), 0)
7932
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.
7933
8022
  const graphCap = packet.type === "reference"
7934
8023
  ? 0
7935
- : (textScore > 0 ? textScore * 1.5 + 12 : 8);
8024
+ : (textScore > 0 ? Math.min(textScore, 8) : 4);
7936
8025
  const graphWeight = packet.type === "reference" ? 0 : 0.45;
7937
- const graphScore = Math.min(rawGraphScore * graphWeight, graphCap);
8026
+ const graphScore = Math.min(Math.log1p(rawGraphScore * graphWeight) * 3, graphCap);
7938
8027
  const pathTypeTag = scoreText(terms, `${packet.type} ${packet.tags.join(" ")} ${packet.paths.join(" ")}`, [packet.type, ...packet.tags, ...packet.paths]);
7939
8028
  const intent = recallIntentBoost(terms, packet);
7940
8029
  const freshness = packet.status === "approved" ? 2 : packet.status === "pending" ? 0 : -5;
@@ -7943,12 +8032,20 @@ function recallBreakdown(projectDir, terms, packet, textScore, temporalScore = 0
7943
8032
  const vector = Number(vectorScore.toFixed(2));
7944
8033
  const usage = Number(usageScore.toFixed(2));
7945
8034
  const pathTypeTagWeight = packet.type === "reference" ? 0.2 : 0.8;
7946
- // Popularity (usage) must only AMPLIFY genuine relevance, never float a packet that has no
7947
- // lexical/semantic/graph/intent match to the top — that is what produced confident
7948
- // off-domain junk (a hot packet ranked #1 for a query it shared no terms with).
7949
- const coreRelevance = textScore + graphScore + intent + vector;
8035
+ // Priors (usage, quality, freshness) must only AMPLIFY genuine relevance, never float a
8036
+ // packet that has no lexical/semantic/graph/tag/intent match to the top — that is what
8037
+ // produced confident off-domain junk: a hot, high-quality packet ranked above the one
8038
+ // packet whose title literally contained the queried term, because its unconditional
8039
+ // quality+freshness (~12 pts) beat a weak-but-real lexical match.
8040
+ const coreRelevance = textScore + graphScore + intent + vector + identifierScore;
8041
+ const matchSignal = coreRelevance + pathTypeTag;
7950
8042
  const effectiveUsage = coreRelevance > 0 ? usage : 0;
7951
- const final = Number((textScore + graphScore + pathTypeTag * pathTypeTagWeight + intent + vector + effectiveUsage + freshness + quality + feedback).toFixed(2));
8043
+ const effectiveQuality = matchSignal > 0 ? quality : 0;
8044
+ const effectiveFreshness = matchSignal > 0 ? freshness : Math.min(freshness, 0);
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));
7952
8049
  return {
7953
8050
  bm25: textScore,
7954
8051
  text: textScore,
@@ -7960,11 +8057,31 @@ function recallBreakdown(projectDir, terms, packet, textScore, temporalScore = 0
7960
8057
  vector,
7961
8058
  usage,
7962
8059
  freshness,
8060
+ recency: Number(effectiveRecency.toFixed(2)),
8061
+ identifier: Number(identifierScore.toFixed(2)),
7963
8062
  quality: Number(quality.toFixed(2)),
7964
8063
  feedback,
7965
8064
  final,
7966
8065
  };
7967
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
+ }
7968
8085
  function recallDiversitySource(packet) {
7969
8086
  for (const ref of packet.source_refs) {
7970
8087
  if (ref.kind === "observation_session" && typeof ref.session_id === "string" && ref.session_id.trim()) {
@@ -8006,6 +8123,11 @@ function diversifyRecallEntries(entries, limit, maxPerSource = 3) {
8006
8123
  function isSerializedDumpTitle(title) {
8007
8124
  const t = (title ?? "").trimStart();
8008
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)
8009
8131
  || t.startsWith('{"')
8010
8132
  || /^<(task-notification|div|svg|html)\b/i.test(t)
8011
8133
  || /\btool_use_id\b|toolu_[A-Za-z0-9]{10}/.test(title)
@@ -8160,6 +8282,22 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
8160
8282
  const referenceBodyScores = scoreReferenceBodyBm25(terms, approvedPackets);
8161
8283
  const accessEntries = readMemoryAccessEntries(projectDir, approvedPackets);
8162
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
+ }
8163
8301
  const rankedScored = approvedPackets
8164
8302
  .map((packet) => {
8165
8303
  const base = baseScores.get(packet.id) ?? { score: 0, why: [] };
@@ -8170,8 +8308,10 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
8170
8308
  const lexicalScore = base.score + temporal.score + semantic.score;
8171
8309
  const textScore = packet.type === "reference" ? Math.max(lexicalScore, referenceBodyScore) : lexicalScore;
8172
8310
  const usageScore = memoryAccessScore(accessEntries.get(packet.id));
8173
- const score_breakdown = recallBreakdown(projectDir, terms, packet, textScore, temporal.score, semantic.score, vector.score, usageScore, knowledgeGraph, graphLookup);
8174
- 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;
8175
8315
  const why = [
8176
8316
  ...base.why,
8177
8317
  ...temporal.why.map((item) => `temporal:${item}`),
@@ -8179,6 +8319,7 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
8179
8319
  ...(semantic.score > 0 ? expansion.semanticLabels.map((label) => `semantic-concept:${label}`) : []),
8180
8320
  ...vector.why,
8181
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"] : []),
8182
8323
  ];
8183
8324
  return { packet, score: score_breakdown.final, relevance, why_matched: unique(why).slice(0, 12), score_breakdown };
8184
8325
  })
@@ -8262,7 +8403,7 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
8262
8403
  pendingScored.length ? "## Working Memory (Pending Review)" : "",
8263
8404
  ...pendingScored.flatMap((entry, index) => [
8264
8405
  "",
8265
- `${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}`,
8266
8407
  ` Summary: ${entry.packet.summary}`,
8267
8408
  ` Why matched: ${entry.why_matched.join(", ") || "text relevance"}`,
8268
8409
  ` Source: pending packet; unapproved local/session memory`,
@@ -8285,7 +8426,7 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
8285
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._",
8286
8427
  ...personalEntries.flatMap((entry, index) => [
8287
8428
  "",
8288
- `${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}`,
8289
8430
  ` [personal] Summary: ${entry.packet.summary}`,
8290
8431
  ` [personal] Why matched: ${entry.why_matched.join(", ") || "text relevance"}`,
8291
8432
  ` [personal] Verification: ${entry.unverifiable ? "unverifiable (citation-free personal note)" : "citations re-verified against this checkout"}`,
@@ -10072,9 +10213,9 @@ function truthReport(projectDir) {
10072
10213
  findings,
10073
10214
  warnings,
10074
10215
  next_actions: [
10075
- "npx -y @kage-core/kage-graph-mcp install one command: creates repo memory + wires your agents (Claude Code, Codex, Cursor, ...)",
10076
- "then just work agents capture learnings and recall them, verified against this code",
10077
- "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, ...)",
10078
10219
  ],
10079
10220
  };
10080
10221
  }
@@ -12987,16 +13128,16 @@ function benchmarkCodingMemoryQuality(options = {}) {
12987
13128
  retrieved,
12988
13129
  latency_ms: latencyMs,
12989
13130
  context_tokens: estimateTokens(recalled.context_block),
12990
- recall: Object.fromEntries(metricsK.map((k) => [`at_${k}`, roundDecimal(codingRecallAt(retrieved, relevant, k) * 100, 2)])),
12991
- precision_at_5_percent: roundDecimal(codingPrecisionAt(retrieved, relevant, 5) * 100, 2),
12992
- ndcg_at_10: roundDecimal(codingNdcgAt(retrieved, relevant, 10), 4),
12993
- mrr: roundDecimal(codingMrr(retrieved, relevant), 4),
13131
+ recall: Object.fromEntries(metricsK.map((k) => [`at_${k}`, (0, metrics_math_js_1.roundDecimal)((0, metrics_math_js_1.codingRecallAt)(retrieved, relevant, k) * 100, 2)])),
13132
+ precision_at_5_percent: (0, metrics_math_js_1.roundDecimal)((0, metrics_math_js_1.codingPrecisionAt)(retrieved, relevant, 5) * 100, 2),
13133
+ ndcg_at_10: (0, metrics_math_js_1.roundDecimal)((0, metrics_math_js_1.codingNdcgAt)(retrieved, relevant, 10), 4),
13134
+ mrr: (0, metrics_math_js_1.roundDecimal)((0, metrics_math_js_1.codingMrr)(retrieved, relevant), 4),
12994
13135
  };
12995
13136
  });
12996
13137
  const sourceDiversity = codingMemorySourceDiversityProbe((0, node_path_1.join)(runDir, "source-diversity"));
12997
13138
  const allMemoryTokens = estimateTokens(loadApprovedPackets(projectDir).map(packetText).join("\n\n"));
12998
- const averageContextTokens = Math.round(averageNumber(perQuery.map((item) => item.context_tokens)));
12999
- const recallByK = Object.fromEntries(metricsK.map((k) => [`recall_at_${k}_percent`, roundDecimal(averageNumber(perQuery.map((item) => item.recall[`at_${k}`] ?? 0)), 2)]));
13139
+ const averageContextTokens = Math.round((0, metrics_math_js_1.averageNumber)(perQuery.map((item) => item.context_tokens)));
13140
+ const recallByK = Object.fromEntries(metricsK.map((k) => [`recall_at_${k}_percent`, (0, metrics_math_js_1.roundDecimal)((0, metrics_math_js_1.averageNumber)(perQuery.map((item) => item.recall[`at_${k}`] ?? 0)), 2)]));
13000
13141
  const summary = {
13001
13142
  benchmark: "Kage coding memory quality",
13002
13143
  retrieval_mode: "kage-recall-default",
@@ -13006,14 +13147,14 @@ function benchmarkCodingMemoryQuality(options = {}) {
13006
13147
  refresh_ms: refreshMs,
13007
13148
  ...recallByK,
13008
13149
  recall_at_k_percent: Number(recallByK[`recall_at_${topK}_percent`] ?? 0),
13009
- precision_at_5_percent: roundDecimal(averageNumber(perQuery.map((item) => item.precision_at_5_percent)), 2),
13010
- ndcg_at_10: roundDecimal(averageNumber(perQuery.map((item) => item.ndcg_at_10)), 4),
13011
- mrr: roundDecimal(averageNumber(perQuery.map((item) => item.mrr)), 4),
13012
- median_latency_ms: percentileNumber(perQuery.map((item) => item.latency_ms), 0.5),
13013
- p95_latency_ms: percentileNumber(perQuery.map((item) => item.latency_ms), 0.95),
13150
+ precision_at_5_percent: (0, metrics_math_js_1.roundDecimal)((0, metrics_math_js_1.averageNumber)(perQuery.map((item) => item.precision_at_5_percent)), 2),
13151
+ ndcg_at_10: (0, metrics_math_js_1.roundDecimal)((0, metrics_math_js_1.averageNumber)(perQuery.map((item) => item.ndcg_at_10)), 4),
13152
+ mrr: (0, metrics_math_js_1.roundDecimal)((0, metrics_math_js_1.averageNumber)(perQuery.map((item) => item.mrr)), 4),
13153
+ median_latency_ms: (0, metrics_math_js_1.percentileNumber)(perQuery.map((item) => item.latency_ms), 0.5),
13154
+ p95_latency_ms: (0, metrics_math_js_1.percentileNumber)(perQuery.map((item) => item.latency_ms), 0.95),
13014
13155
  all_memory_tokens: allMemoryTokens,
13015
13156
  average_context_tokens: averageContextTokens,
13016
- context_reduction_percent: roundDecimal(((allMemoryTokens - averageContextTokens) / Math.max(1, allMemoryTokens)) * 100, 2),
13157
+ context_reduction_percent: (0, metrics_math_js_1.roundDecimal)(((allMemoryTokens - averageContextTokens) / Math.max(1, allMemoryTokens)) * 100, 2),
13017
13158
  source_diversity_pass: sourceDiversity.pass,
13018
13159
  source_diversity_unique_sources: sourceDiversity.unique_sources,
13019
13160
  source_diversity_max_results_from_one_source: sourceDiversity.max_results_from_one_source,
@@ -13027,7 +13168,7 @@ function benchmarkCodingMemoryQuality(options = {}) {
13027
13168
  queries: queries.length,
13028
13169
  packets_per_topic: packetsPerTopic,
13029
13170
  distractors_per_topic: distractorsPerTopic,
13030
- categories: countByKey(queries, (item) => item.category),
13171
+ categories: (0, metrics_math_js_1.countByKey)(queries, (item) => item.category),
13031
13172
  },
13032
13173
  top_k: topK,
13033
13174
  metrics_k: metricsK,
@@ -13120,7 +13261,7 @@ function codingMemorySourceDiversityProbe(projectDir) {
13120
13261
  source,
13121
13262
  };
13122
13263
  });
13123
- const sourceCounts = countByKey(retrieved, (item) => item.source);
13264
+ const sourceCounts = (0, metrics_math_js_1.countByKey)(retrieved, (item) => item.source);
13124
13265
  const maxResultsFromOneSource = Math.max(0, ...Object.values(sourceCounts));
13125
13266
  const independentRank = retrieved.find((item) => item.source === "session:independent-session")?.rank ?? null;
13126
13267
  return {
@@ -13178,16 +13319,16 @@ function benchmarkMemoryScale(options = {}) {
13178
13319
  };
13179
13320
  });
13180
13321
  const allMemoryTokens = estimateTokens(loadApprovedPackets(projectDir).map(packetText).join("\n\n"));
13181
- const averageContextTokens = Math.round(averageNumber(queries.map((item) => item.context_tokens)));
13322
+ const averageContextTokens = Math.round((0, metrics_math_js_1.averageNumber)(queries.map((item) => item.context_tokens)));
13182
13323
  results.push({
13183
13324
  packets: size,
13184
13325
  refresh_ms: refreshMs,
13185
- recall_hit_rate_percent: roundDecimal((queries.filter((item) => item.hit).length / queries.length) * 100, 2),
13186
- median_recall_latency_ms: percentileNumber(queries.map((item) => item.latency_ms), 0.5),
13187
- p95_recall_latency_ms: percentileNumber(queries.map((item) => item.latency_ms), 0.95),
13326
+ recall_hit_rate_percent: (0, metrics_math_js_1.roundDecimal)((queries.filter((item) => item.hit).length / queries.length) * 100, 2),
13327
+ median_recall_latency_ms: (0, metrics_math_js_1.percentileNumber)(queries.map((item) => item.latency_ms), 0.5),
13328
+ p95_recall_latency_ms: (0, metrics_math_js_1.percentileNumber)(queries.map((item) => item.latency_ms), 0.95),
13188
13329
  all_memory_tokens: allMemoryTokens,
13189
13330
  average_context_tokens: averageContextTokens,
13190
- context_reduction_percent: roundDecimal(((allMemoryTokens - averageContextTokens) / Math.max(1, allMemoryTokens)) * 100, 2),
13331
+ context_reduction_percent: (0, metrics_math_js_1.roundDecimal)(((allMemoryTokens - averageContextTokens) / Math.max(1, allMemoryTokens)) * 100, 2),
13191
13332
  queries,
13192
13333
  });
13193
13334
  }
@@ -13319,7 +13460,7 @@ function codingObservation(index, item, target, variant) {
13319
13460
  topic: item.id,
13320
13461
  target,
13321
13462
  category: item.category,
13322
- title: `${titleCase(item.query)} repo memory ${variant + 1}`,
13463
+ title: `${(0, metrics_math_js_1.titleCase)(item.query)} repo memory ${variant + 1}`,
13323
13464
  summary: `Reusable learning about ${item.query}: ${item.lesson}`,
13324
13465
  body: [
13325
13466
  `During a real agent session, this durable repo learning was captured for ${item.query}.`,
@@ -13404,32 +13545,11 @@ function codingQualityByCategory(perQuery, metricsK) {
13404
13545
  return Array.from(groups.entries()).map(([category, rows]) => ({
13405
13546
  category,
13406
13547
  queries: rows.length,
13407
- ...Object.fromEntries(metricsK.map((k) => [`recall_at_${k}_percent`, roundDecimal(averageNumber(rows.map((item) => item.recall[`at_${k}`] ?? 0)), 2)])),
13408
- ndcg_at_10: roundDecimal(averageNumber(rows.map((item) => item.ndcg_at_10)), 4),
13409
- mrr: roundDecimal(averageNumber(rows.map((item) => item.mrr)), 4),
13548
+ ...Object.fromEntries(metricsK.map((k) => [`recall_at_${k}_percent`, (0, metrics_math_js_1.roundDecimal)((0, metrics_math_js_1.averageNumber)(rows.map((item) => item.recall[`at_${k}`] ?? 0)), 2)])),
13549
+ ndcg_at_10: (0, metrics_math_js_1.roundDecimal)((0, metrics_math_js_1.averageNumber)(rows.map((item) => item.ndcg_at_10)), 4),
13550
+ mrr: (0, metrics_math_js_1.roundDecimal)((0, metrics_math_js_1.averageNumber)(rows.map((item) => item.mrr)), 4),
13410
13551
  }));
13411
13552
  }
13412
- function codingRecallAt(retrieved, relevant, k) {
13413
- if (!relevant.size)
13414
- return 0;
13415
- return retrieved.slice(0, k).filter((item) => relevant.has(item.packet_id)).length / relevant.size;
13416
- }
13417
- function codingPrecisionAt(retrieved, relevant, k) {
13418
- const rows = retrieved.slice(0, k);
13419
- return rows.length ? rows.filter((item) => relevant.has(item.packet_id)).length / rows.length : 0;
13420
- }
13421
- function codingNdcgAt(retrieved, relevant, k) {
13422
- const dcg = retrieved.slice(0, k).reduce((sum, item, index) => sum + (relevant.has(item.packet_id) ? 1 / Math.log2(index + 2) : 0), 0);
13423
- const idealHits = Math.min(relevant.size, k);
13424
- let ideal = 0;
13425
- for (let index = 0; index < idealHits; index += 1)
13426
- ideal += 1 / Math.log2(index + 2);
13427
- return ideal ? dcg / ideal : 0;
13428
- }
13429
- function codingMrr(retrieved, relevant) {
13430
- const index = retrieved.findIndex((item) => relevant.has(item.packet_id));
13431
- return index >= 0 ? 1 / (index + 1) : 0;
13432
- }
13433
13553
  function codingTypeForCategory(category) {
13434
13554
  if (category === "runbook")
13435
13555
  return "runbook";
@@ -13442,31 +13562,6 @@ function codingTypeForCategory(category) {
13442
13562
  function codingFileForTopic(topic, variant) {
13443
13563
  return `src/${slugify(topic)}-${variant % 3}.ts`;
13444
13564
  }
13445
- function averageNumber(values) {
13446
- return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0;
13447
- }
13448
- function percentileNumber(values, p) {
13449
- if (!values.length)
13450
- return 0;
13451
- const sorted = values.slice().sort((a, b) => a - b);
13452
- const index = Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * p) - 1));
13453
- return sorted[index];
13454
- }
13455
- function roundDecimal(value, digits = 2) {
13456
- const factor = 10 ** digits;
13457
- return Math.round(value * factor) / factor;
13458
- }
13459
- function countByKey(rows, fn) {
13460
- const counts = {};
13461
- for (const row of rows) {
13462
- const key = fn(row);
13463
- counts[key] = (counts[key] ?? 0) + 1;
13464
- }
13465
- return counts;
13466
- }
13467
- function titleCase(value) {
13468
- return value.replace(/\b[a-z]/g, (match) => match.toUpperCase());
13469
- }
13470
13565
  function baselineDiscoveryFiles(projectDir, task) {
13471
13566
  const terms = tokenize(task);
13472
13567
  const graph = buildCodeGraph(projectDir);
@@ -14131,6 +14226,24 @@ function setupAgent(agent, projectDir, options = {}) {
14131
14226
  const path = (0, node_path_1.join)(home, ".claude.json");
14132
14227
  const server = { type: "stdio", command: serverCommand, args: serverArgs, alwaysLoad: true };
14133
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`;
14134
14247
  const hookScript = `#!/usr/bin/env bash
14135
14248
  # Kage SessionStart hook — injects full memory policy as a system message.
14136
14249
  # Silent if Kage is not initialized in the current project.
@@ -14163,6 +14276,7 @@ Before finishing a task that changed files: kage_pr_summarize or kage_propose_fr
14163
14276
  If recalled memory helped: kage_feedback helpful. If wrong or stale: kage_feedback wrong or stale."
14164
14277
  fi
14165
14278
 
14279
+ ${hookKageResolve}
14166
14280
  # Session continuity: append a compact "previously…" digest when prior session data exists.
14167
14281
  if command -v kage >/dev/null 2>&1; then
14168
14282
  PREVIOUSLY="$(kage resume --project "$CWD" 2>/dev/null || true)"
@@ -14184,8 +14298,7 @@ PAYLOAD="$(cat || true)"
14184
14298
  CWD="$(printf "%s" "$PAYLOAD" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('cwd',''))" 2>/dev/null || echo "")"
14185
14299
 
14186
14300
  [[ -d "$CWD/.agent_memory" ]] || exit 0
14187
- # Resolve a repo-local install too, so hooks work without a global kage on PATH.
14188
- export PATH="$CWD/node_modules/.bin:$PATH"
14301
+ ${hookKageResolve}
14189
14302
  command -v kage >/dev/null 2>&1 || exit 0
14190
14303
 
14191
14304
  if git -C "$CWD" status --porcelain -uall >/dev/null 2>&1 && [[ -n "$(git -C "$CWD" status --porcelain -uall)" ]]; then
@@ -14242,8 +14355,7 @@ print(d.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or "")
14242
14355
  ' 2>/dev/null || echo "")"
14243
14356
 
14244
14357
  [[ -d "$CWD/.agent_memory" ]] || exit 0
14245
- # Resolve a repo-local install too, so hooks work without a global kage on PATH.
14246
- export PATH="$CWD/node_modules/.bin:$PATH"
14358
+ ${hookKageResolve}
14247
14359
  command -v kage >/dev/null 2>&1 || exit 0
14248
14360
 
14249
14361
  EVENT="$(PAYLOAD="$PAYLOAD" python3 -c 'import json, os
@@ -14293,17 +14405,53 @@ tool_response = d.get("tool_response") or d.get("toolResponse") or d.get("result
14293
14405
  prompt = first(d.get("prompt"), d.get("user_prompt"), d.get("message"))
14294
14406
  path = ""
14295
14407
  command = ""
14408
+ new_text = ""
14409
+ old_text = ""
14296
14410
  if isinstance(tool_input, dict):
14297
14411
  path = first(tool_input.get("file_path"), tool_input.get("path"), tool_input.get("notebook_path"))
14298
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]
14299
14435
 
14300
14436
  if event_name == "UserPromptSubmit":
14301
14437
  payload = {"type": "user_prompt", "text": prompt, "summary": compact(prompt, 240)}
14302
14438
  elif event_name == "PostToolUseFailure":
14303
- 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}
14304
14442
  elif event_name == "PostToolUse":
14305
- obs_type = "file_change" if path else ("command_result" if command else "tool_use")
14306
- 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}
14307
14455
  elif event_name == "PreCompact":
14308
14456
  payload = {"type": "session_end", "summary": "Claude Code is compacting context; distill durable observations before compaction."}
14309
14457
  elif event_name == "SessionEnd":
@@ -14324,7 +14472,9 @@ if [[ -n "$OBSERVATION" ]]; then
14324
14472
  fi
14325
14473
 
14326
14474
  if [[ "$EVENT" == "PreCompact" || "$EVENT" == "SessionEnd" || "$EVENT" == "SubagentStop" ]]; then
14327
- 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
14328
14478
  fi
14329
14479
 
14330
14480
  if [[ "$EVENT" == "UserPromptSubmit" ]]; then
@@ -14363,8 +14513,7 @@ print(d.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or "")
14363
14513
  ' 2>/dev/null || echo "")"
14364
14514
 
14365
14515
  [[ -d "$CWD/.agent_memory" ]] || exit 0
14366
- # Resolve a repo-local install too, so hooks work without a global kage on PATH.
14367
- export PATH="$CWD/node_modules/.bin:$PATH"
14516
+ ${hookKageResolve}
14368
14517
  command -v kage >/dev/null 2>&1 || exit 0
14369
14518
 
14370
14519
  FILE_PATH="$(PAYLOAD="$PAYLOAD" python3 -c 'import json, os
@@ -14505,7 +14654,7 @@ function generatePluginHooks(pluginDir) {
14505
14654
  const tmpHome = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "kage-plugin-home-"));
14506
14655
  const tmpProject = (0, node_fs_1.mkdtempSync)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "kage-plugin-proj-"));
14507
14656
  try {
14508
- setupAgent("claude-code", tmpProject, { write: true, homeDir: tmpHome });
14657
+ setupAgent("claude-code", tmpProject, { write: true, homeDir: tmpHome, portableHooks: true });
14509
14658
  const srcHookDir = (0, node_path_1.join)(tmpHome, ".claude", "kage", "hooks");
14510
14659
  const settings = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(tmpHome, ".claude", "settings.json"), "utf8"));
14511
14660
  const hooksOut = (0, node_path_1.join)(pluginDir, "hooks");
@@ -14636,6 +14785,10 @@ function configMentionsKage(path) {
14636
14785
  return /\bkage\b/.test(text) && /(mcp|mcpServers|mcp_servers)/i.test(text);
14637
14786
  }
14638
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;
14639
14792
  function claudeHookEventConfigured(settings, event) {
14640
14793
  const hooks = settings.hooks && typeof settings.hooks === "object" && !Array.isArray(settings.hooks)
14641
14794
  ? settings.hooks
@@ -14662,16 +14815,27 @@ function claudeAmbientHookSummary(homeDir) {
14662
14815
  }
14663
14816
  const installed = CLAUDE_AMBIENT_HOOK_EVENTS.filter((event) => claudeHookEventConfigured(settings, event));
14664
14817
  const missing = CLAUDE_AMBIENT_HOOK_EVENTS.filter((event) => !installed.includes(event));
14818
+ const outdated = [];
14665
14819
  for (const scriptPath of scriptPaths) {
14666
- if (!(0, node_fs_1.existsSync)(scriptPath))
14820
+ if (!(0, node_fs_1.existsSync)(scriptPath)) {
14667
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));
14668
14831
  }
14669
14832
  return {
14670
14833
  required: [...CLAUDE_AMBIENT_HOOK_EVENTS],
14671
14834
  installed,
14672
14835
  missing: unique(missing),
14836
+ outdated,
14673
14837
  script_paths: scriptPaths,
14674
- ready: missing.length === 0,
14838
+ ready: missing.length === 0 && outdated.length === 0,
14675
14839
  };
14676
14840
  }
14677
14841
  function verifyAgentActivation(agent, projectDir, options = {}) {
@@ -14693,7 +14857,7 @@ function verifyAgentActivation(agent, projectDir, options = {}) {
14693
14857
  const mcpToolReachable = Boolean(options.mcpToolReachable);
14694
14858
  const hookSummary = agent === "claude-code"
14695
14859
  ? claudeAmbientHookSummary(options.homeDir ?? process.env.HOME ?? "~")
14696
- : { required: [], installed: [], missing: [], script_paths: [], ready: true };
14860
+ : { required: [], installed: [], missing: [], outdated: [], script_paths: [], ready: true };
14697
14861
  const ambientHooksPresent = hookSummary.ready;
14698
14862
  const warnings = [];
14699
14863
  const nextSteps = [];
@@ -14750,6 +14914,36 @@ function verifyAgentActivation(agent, projectDir, options = {}) {
14750
14914
  function observationPath(projectDir, id) {
14751
14915
  return (0, node_path_1.join)(observationsDir(projectDir), `${id}.json`);
14752
14916
  }
14917
+ // Observations are session-scoped raw signal: distill consumes them at session end and
14918
+ // the resume digest only reads the recent window. Without retention the directory grows
14919
+ // forever (measured: 12k files / 48MB in two months of dogfooding), so refresh prunes
14920
+ // records older than the retention window. 0 disables pruning.
14921
+ const OBSERVATION_RETENTION_DAYS = (() => {
14922
+ const raw = Number(process.env.KAGE_OBSERVATION_RETENTION_DAYS ?? "30");
14923
+ return Number.isFinite(raw) && raw >= 0 ? raw : 30;
14924
+ })();
14925
+ function pruneObservations(projectDir, maxAgeDays = OBSERVATION_RETENTION_DAYS) {
14926
+ if (maxAgeDays <= 0)
14927
+ return { pruned: 0 };
14928
+ const dir = observationsDir(projectDir);
14929
+ if (!(0, node_fs_1.existsSync)(dir))
14930
+ return { pruned: 0 };
14931
+ const cutoff = Date.now() - maxAgeDays * 86_400_000;
14932
+ let pruned = 0;
14933
+ for (const name of (0, node_fs_1.readdirSync)(dir)) {
14934
+ if (!name.endsWith(".json"))
14935
+ continue;
14936
+ const path = (0, node_path_1.join)(dir, name);
14937
+ try {
14938
+ if ((0, node_fs_1.statSync)(path).mtimeMs < cutoff) {
14939
+ (0, node_fs_1.unlinkSync)(path);
14940
+ pruned += 1;
14941
+ }
14942
+ }
14943
+ catch { /* concurrent removal — skip */ }
14944
+ }
14945
+ return { pruned };
14946
+ }
14753
14947
  function observationHash(projectDir, event) {
14754
14948
  const bucket = event.timestamp ? new Date(event.timestamp).toISOString().slice(0, 16) : nowIso().slice(0, 16);
14755
14949
  return (0, node_crypto_1.createHash)("sha256")
@@ -15098,6 +15292,15 @@ function reusablePromptObservation(event) {
15098
15292
  "always",
15099
15293
  "never",
15100
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",
15101
15304
  "avoid",
15102
15305
  ];
15103
15306
  if (!durableSignals.some((signal) => lower.includes(signal)))
@@ -15462,10 +15665,13 @@ function distillSession(projectDir, sessionId, options = {}) {
15462
15665
  const auto = Boolean(options.auto);
15463
15666
  const mode = auto ? "auto" : "manual";
15464
15667
  const observations = loadObservations(projectDir, sessionId);
15465
- if (auto && observations.length === 0) {
15668
+ if (observations.length === 0) {
15466
15669
  return { ok: true, session_id: sessionId, observations: 0, candidates: [], errors: [], mode, skipped_reason: "no_observations", skipped_low_signal: 0 };
15467
15670
  }
15468
- 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)) {
15469
15675
  return { ok: true, session_id: sessionId, observations: observations.length, candidates: [], errors: [], mode, skipped_reason: "session_already_captured", skipped_low_signal: 0 };
15470
15676
  }
15471
15677
  const candidates = [];
@@ -15482,7 +15688,9 @@ function distillSession(projectDir, sessionId, options = {}) {
15482
15688
  {
15483
15689
  kind: "observation_session",
15484
15690
  session_id: sessionId,
15485
- 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),
15486
15694
  observation_count: observations.length,
15487
15695
  },
15488
15696
  ];
@@ -15527,13 +15735,12 @@ function distillSession(projectDir, sessionId, options = {}) {
15527
15735
  return result;
15528
15736
  };
15529
15737
  const autoTags = auto ? [exports.AUTO_DISTILL_TAG] : [];
15530
- // 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
15531
15739
  // least AUTO_DISTILL_SIGNAL_THRESHOLD. Events tagged low_signal at ingestion skip
15532
- // 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.
15533
15742
  let skippedLowSignal = 0;
15534
15743
  const signalGate = (events) => {
15535
- if (!auto)
15536
- return events;
15537
15744
  return events.filter((event) => {
15538
15745
  const lowSignal = event.low_signal === true
15539
15746
  || (event.low_signal === undefined && observationSignalScore(event) < exports.AUTO_DISTILL_SIGNAL_THRESHOLD);
@@ -15545,6 +15752,31 @@ function distillSession(projectDir, sessionId, options = {}) {
15545
15752
  const commandEvents = signalGate(observations.filter((event) => event.type === "command_result" && event.command));
15546
15753
  const fileEvents = signalGate(observations.filter((event) => event.type === "file_change" && event.path));
15547
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
+ : "";
15548
15780
  const meaningfulCommandEvents = commandEvents
15549
15781
  .map((event) => ({ event, reusable: reusableCommandObservation(event, knownRepoCommands(projectDir)) }))
15550
15782
  .filter((item) => Boolean(item.reusable));
@@ -15555,11 +15787,13 @@ function distillSession(projectDir, sessionId, options = {}) {
15555
15787
  projectDir,
15556
15788
  title: `Runbook: ${lead}`,
15557
15789
  summary: `Observed commands: ${commands.slice(0, 3).join(", ")}`,
15558
- 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.`,
15559
15791
  type: "runbook",
15560
15792
  tags: ["observed-session", "commands", "runbook", ...autoTags],
15561
15793
  paths: unique(meaningfulCommandEvents.map((item) => item.event.path).filter(Boolean)),
15562
- 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,
15563
15797
  })));
15564
15798
  }
15565
15799
  const meaningfulFileEvents = fileEvents
@@ -15572,23 +15806,25 @@ function distillSession(projectDir, sessionId, options = {}) {
15572
15806
  projectDir,
15573
15807
  title: `Workflow: ${lead}`,
15574
15808
  summary: lead,
15575
- 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.`,
15576
15810
  type: "workflow",
15577
15811
  tags: ["observed-session", "workflow", ...autoTags],
15578
15812
  paths,
15579
- pendingReview: auto,
15813
+ pendingReview: true,
15580
15814
  })));
15581
15815
  }
15582
15816
  if (promptEvents.length) {
15583
- 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);
15584
15820
  if (text)
15585
15821
  candidates.push(annotate(learn({
15586
15822
  projectDir,
15587
15823
  title: titleFromLearning(text),
15588
- learning: text,
15824
+ learning: `${text}${verificationLine}`,
15589
15825
  evidence: `Observation session: ${sessionId}`,
15590
15826
  tags: ["observed-session", "intent", ...autoTags],
15591
- pendingReview: auto,
15827
+ pendingReview: true,
15592
15828
  })));
15593
15829
  }
15594
15830
  for (const result of candidates)
@@ -15744,7 +15980,17 @@ function createDiffChangeMemory(projectDir, summary) {
15744
15980
  const verifyCommands = npmScriptCommands(projectDir)
15745
15981
  .filter((command) => /(test|check|lint|build|type|verify)/i.test(command))
15746
15982
  .slice(0, 8);
15747
- 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` : "");
15748
15994
  const verifyList = verifyCommands.length
15749
15995
  ? verifyCommands.map((command) => `- ${command}`).join("\n")
15750
15996
  : "- Add the exact test, build, or manual verification command when you refine this memory.";
@@ -15760,7 +16006,7 @@ function createDiffChangeMemory(projectDir, summary) {
15760
16006
  "```text",
15761
16007
  // Clamp the diff stat: a huge diff would otherwise produce a dump-sized change-memory
15762
16008
  // body. This path builds the packet directly (not via capture()), so bound it here.
15763
- clampBlock(summary.diff_stat, 4000),
16009
+ clampBlock(summary.diff_stat, 1500),
15764
16010
  "```",
15765
16011
  "",
15766
16012
  "How to verify:",
@@ -15779,7 +16025,7 @@ function createDiffChangeMemory(projectDir, summary) {
15779
16025
  schema_version: exports.PACKET_SCHEMA_VERSION,
15780
16026
  id: stableId,
15781
16027
  title,
15782
- 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}.`,
15783
16029
  body,
15784
16030
  type: "workflow",
15785
16031
  scope: "repo",
@@ -15788,7 +16034,7 @@ function createDiffChangeMemory(projectDir, summary) {
15788
16034
  status: "approved",
15789
16035
  confidence: 0.62,
15790
16036
  tags: unique(["change-memory", "diff-proposal", "repo-local", branch ? `branch:${slugify(branch)}` : "branch:detached"]),
15791
- paths: summary.changed_files.slice(0, 40),
16037
+ paths: listedChanged.slice(0, 40),
15792
16038
  stack: inferStack(projectDir),
15793
16039
  source_refs: [
15794
16040
  {
@@ -15796,12 +16042,12 @@ function createDiffChangeMemory(projectDir, summary) {
15796
16042
  branch,
15797
16043
  head,
15798
16044
  merge_base: summary.merge_base,
15799
- changed_files: summary.changed_files,
16045
+ changed_files: summary.changed_files.slice(0, 100),
15800
16046
  summary_path: (0, node_path_1.join)(reviewDir(projectDir), `branch-summary-${slugify(branch)}.json`),
15801
16047
  },
15802
16048
  ],
15803
16049
  context: {
15804
- 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"}.`,
15805
16051
  why: "Branch change memory gives future agents durable context from the git diff when they continue, review, or verify this work.",
15806
16052
  trigger: "Recall when asking what changed on this branch, preparing a PR review, or resuming this work.",
15807
16053
  action: "Use the changed file list and diff summary as orientation, then inspect the actual diff and source files before making further edits.",
@@ -16733,24 +16979,30 @@ function mergePacketFiles(oursPath, basePath, theirsPath) {
16733
16979
  const raw = safeReadText(path);
16734
16980
  if (raw === null)
16735
16981
  return null;
16736
- if (path.endsWith(".md")) {
16737
- const packet = (0, okf_js_1.okfConceptToPacket)(raw);
16738
- return packet ? { raw, packet } : null;
16739
- }
16740
- try {
16741
- const parsed = JSON.parse(raw);
16742
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
16743
- 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
16744
16995
  }
16745
16996
  }
16746
- catch {
16747
- // A side that carries committed conflict markers (the exact failure mode
16748
- // this driver exists to end) can often be recovered with repair's
16749
- // conflict-splitting logic before giving up on it.
16750
- const recovered = resolveConflictedPacket(raw);
16751
- if (recovered)
16752
- return { raw: `${JSON.stringify(recovered, null, 2)}\n`, packet: recovered };
16753
- }
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 };
16754
17006
  return null;
16755
17007
  };
16756
17008
  const ours = readSide(oursPath);
@@ -17297,7 +17549,7 @@ function generateSkills(projectDir, options = {}) {
17297
17549
  // supersede churn when code changed but the memory's claim did not. Refuses
17298
17550
  // when ALL cited evidence is gone — that memory needs supersede or stale, not
17299
17551
  // a rubber stamp.
17300
- function reverifyMemory(projectDir, packetId) {
17552
+ function reverifyMemory(projectDir, packetId, options = {}) {
17301
17553
  ensureMemoryDirs(projectDir);
17302
17554
  const result = {
17303
17555
  ok: false,
@@ -17305,6 +17557,7 @@ function reverifyMemory(projectDir, packetId) {
17305
17557
  packet_id: packetId,
17306
17558
  refreshed_paths: [],
17307
17559
  missing_paths: [],
17560
+ changed_paths: [],
17308
17561
  was_stale: false,
17309
17562
  errors: [],
17310
17563
  };
@@ -17329,13 +17582,52 @@ function reverifyMemory(projectDir, packetId) {
17329
17582
  const presentPaths = citedPaths.filter((path) => !result.missing_paths.includes(path));
17330
17583
  const now = nowIso();
17331
17584
  const freshness = { ...(packet.freshness ?? {}) };
17332
- 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;
17333
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";
17334
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;
17335
17626
  writeJson(entry.path, {
17336
17627
  ...packet,
17337
17628
  paths: presentPaths.length ? presentPaths : packet.paths,
17338
17629
  freshness,
17630
+ source_refs: sourceRefs,
17339
17631
  quality: { ...nextQuality, reverified_at: now },
17340
17632
  updated_at: now,
17341
17633
  });