@kage-core/kage-graph-mcp 2.2.7 → 2.3.1

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
@@ -67,6 +67,8 @@ exports.valueSummary = valueSummary;
67
67
  exports.formatTokenCount = formatTokenCount;
68
68
  exports.kageFileContext = kageFileContext;
69
69
  exports.kageActivity = kageActivity;
70
+ exports.detectContradictions = detectContradictions;
71
+ exports.kageConflicts = kageConflicts;
70
72
  exports.kageMemoryReconciliation = kageMemoryReconciliation;
71
73
  exports.evaluateMemoryAdmission = evaluateMemoryAdmission;
72
74
  exports.validatePacket = validatePacket;
@@ -87,6 +89,9 @@ exports.writeLspSymbolIndex = writeLspSymbolIndex;
87
89
  exports.writeCodeIndex = writeCodeIndex;
88
90
  exports.buildCodeGraph = buildCodeGraph;
89
91
  exports.buildKnowledgeGraph = buildKnowledgeGraph;
92
+ exports.buildDocsIndex = buildDocsIndex;
93
+ exports.searchDocs = searchDocs;
94
+ exports.docsRecallSection = docsRecallSection;
90
95
  exports.buildIndexes = buildIndexes;
91
96
  exports.indexProject = indexProject;
92
97
  exports.refreshProject = refreshProject;
@@ -105,6 +110,8 @@ exports.kageRisk = kageRisk;
105
110
  exports.kageDependencyPath = kageDependencyPath;
106
111
  exports.kageCleanupCandidates = kageCleanupCandidates;
107
112
  exports.truthReport = truthReport;
113
+ exports.truthScorecardSvg = truthScorecardSvg;
114
+ exports.truthScorecardMarkdown = truthScorecardMarkdown;
108
115
  exports.defaultClaudeMemStorePath = defaultClaudeMemStorePath;
109
116
  exports.claudeMemProjectKey = claudeMemProjectKey;
110
117
  exports.parseClaudeMemFileList = parseClaudeMemFileList;
@@ -175,6 +182,7 @@ exports.remediationFor = remediationFor;
175
182
  exports.approvePending = approvePending;
176
183
  exports.rejectPending = rejectPending;
177
184
  exports.changelog = changelog;
185
+ exports.generateSkills = generateSkills;
178
186
  exports.reverifyMemory = reverifyMemory;
179
187
  exports.supersedeMemory = supersedeMemory;
180
188
  exports.kageMemoryLineage = kageMemoryLineage;
@@ -189,6 +197,7 @@ exports.capturePersonal = capturePersonal;
189
197
  exports.syncSetup = syncSetup;
190
198
  exports.syncStatus = syncStatus;
191
199
  exports.syncPersonal = syncPersonal;
200
+ exports.kageLayers = kageLayers;
192
201
  const node_crypto_1 = require("node:crypto");
193
202
  const node_child_process_1 = require("node:child_process");
194
203
  const node_fs_1 = require("node:fs");
@@ -249,6 +258,9 @@ exports.SETUP_AGENTS = [
249
258
  "kilo-code",
250
259
  "claude-desktop",
251
260
  "aider",
261
+ "openclaw",
262
+ "copilot",
263
+ "hermes",
252
264
  "generic-mcp",
253
265
  ];
254
266
  const DEFAULT_CONFIDENCE = 0.7;
@@ -275,6 +287,13 @@ If Kage appears installed but no Kage tools are available, report that the activ
275
287
  agent session has not loaded the MCP server and ask the user to restart the
276
288
  agent. After restart, call \`kage_verify_agent\` to prove the harness is live.
277
289
 
290
+ ## Show the Value
291
+
292
+ \`kage_context\` and \`kage_recall\` return a one-line gains receipt (tokens/$ saved
293
+ this session, stale memories withheld). When it is non-trivial, relay it to the
294
+ user in your own words — Kage's value is otherwise invisible, and a user who never
295
+ sees it churns. Repeat only what the tool actually reported; never fabricate numbers.
296
+
278
297
  ## Automatic Capture
279
298
 
280
299
  When you learn something reusable, create repo-local memory with \`kage_learn\`.
@@ -1345,6 +1364,199 @@ function duplicateCandidatesWithContext(packet, context, threshold = 0.58) {
1345
1364
  status: entry.packet.status,
1346
1365
  }));
1347
1366
  }
1367
+ // Negation cues: a body that flips a claim. "do not use", "is not", "never",
1368
+ // "should not", "no longer", "stop using", "avoid", "instead of".
1369
+ const CONTRADICTION_NEGATION_CUES = [
1370
+ "do not",
1371
+ "don't",
1372
+ "does not",
1373
+ "doesn't",
1374
+ "is not",
1375
+ "isn't",
1376
+ "are not",
1377
+ "aren't",
1378
+ "was not",
1379
+ "should not",
1380
+ "shouldn't",
1381
+ "must not",
1382
+ "cannot",
1383
+ "can't",
1384
+ "never",
1385
+ "no longer",
1386
+ "not idempotent",
1387
+ "not safe",
1388
+ "not thread-safe",
1389
+ "not supported",
1390
+ "not required",
1391
+ "not needed",
1392
+ "stop using",
1393
+ "avoid",
1394
+ "deprecated",
1395
+ "removed",
1396
+ "disable",
1397
+ "disabled",
1398
+ ];
1399
+ // Replacement cues: a body that points the claim at a different answer.
1400
+ // "use Y instead", "replaced by", "switched to", "now use", "migrate to".
1401
+ const CONTRADICTION_REPLACEMENT_CUES = [
1402
+ "instead",
1403
+ "instead of",
1404
+ "replaced by",
1405
+ "replaced with",
1406
+ "replace with",
1407
+ "switched to",
1408
+ "switch to",
1409
+ "migrated to",
1410
+ "migrate to",
1411
+ "now use",
1412
+ "now uses",
1413
+ "moved to",
1414
+ "superseded by",
1415
+ "in favor of",
1416
+ "rather than",
1417
+ ];
1418
+ function normalizedClaimText(packet) {
1419
+ return `${packet.title}\n${packet.summary}\n${packet.body}`.toLowerCase();
1420
+ }
1421
+ function negationCueCount(text) {
1422
+ let count = 0;
1423
+ for (const cue of CONTRADICTION_NEGATION_CUES)
1424
+ if (text.includes(cue))
1425
+ count += 1;
1426
+ return count;
1427
+ }
1428
+ function replacementCueCount(text) {
1429
+ let count = 0;
1430
+ for (const cue of CONTRADICTION_REPLACEMENT_CUES)
1431
+ if (text.includes(cue))
1432
+ count += 1;
1433
+ return count;
1434
+ }
1435
+ // Tokens too generic to mean "these two packets are about the same subject".
1436
+ // In a memory repo about an agent-memory tool, words like "kage", "memory",
1437
+ // "agent", "code", "now", "use" appear in nearly every decision packet — so two
1438
+ // unrelated decisions that merely both touch mcp/index.ts would share several of
1439
+ // them and (under the old subjectOverlap<2 bypass) be flagged as contradictions.
1440
+ // The SUBJECT must be the DISTINCTIVE vocabulary, not the house style.
1441
+ const CONTRADICTION_GENERIC_SUBJECT = new Set([
1442
+ ...STOPWORDS,
1443
+ "use", "uses", "used", "using", "via", "per", "now", "new", "when", "then",
1444
+ "this", "that", "these", "those", "not", "but", "also", "into", "from", "its",
1445
+ "kage", "mcp", "memory", "memories", "agent", "agents", "packet", "packets",
1446
+ "code", "graph", "repo", "repository", "file", "files", "path", "paths",
1447
+ "recall", "capture", "captured", "decision", "decisions", "must", "should",
1448
+ "report", "reports", "result", "results", "support", "supports", "supported",
1449
+ ]);
1450
+ // Distinctive subject vocabulary of a packet: title+summary tokens with generic
1451
+ // / house-style words removed. Two packets share a subject only when their
1452
+ // DISTINCTIVE tokens overlap, not when they both say "kage memory decision".
1453
+ function distinctiveSubjectTokens(packet) {
1454
+ return new Set([...tokenSet(`${packet.title}\n${packet.summary}`)].filter((token) => !CONTRADICTION_GENERIC_SUBJECT.has(token)));
1455
+ }
1456
+ // Opposing-polarity signal between two same-subject bodies. Conservative: a
1457
+ // contradiction needs one side to assert a claim and the other to negate or
1458
+ // replace it — NOT both sides carrying identical framing (that's a duplicate).
1459
+ function opposingPolarity(aText, bText) {
1460
+ const aNeg = negationCueCount(aText);
1461
+ const bNeg = negationCueCount(bText);
1462
+ const aRepl = replacementCueCount(aText);
1463
+ const bRepl = replacementCueCount(bText);
1464
+ // (1) Negation asymmetry: one side negates the claim, the other does not.
1465
+ // Two equally-hedged notes (both negate, or neither negates) are not flagged.
1466
+ if (aNeg === 0 && bNeg >= 1)
1467
+ return "one memory negates a claim the other asserts";
1468
+ if (bNeg === 0 && aNeg >= 1)
1469
+ return "one memory negates a claim the other asserts";
1470
+ // (2) Replacement asymmetry: one side says "use Y instead", the other still
1471
+ // asserts the original (no replacement framing). Same-subject + redirect.
1472
+ if (aRepl === 0 && bRepl >= 1)
1473
+ return "one memory replaces the approach the other still recommends";
1474
+ if (bRepl === 0 && aRepl >= 1)
1475
+ return "one memory replaces the approach the other still recommends";
1476
+ return null;
1477
+ }
1478
+ // Detect existing approved packets that contradict `candidate`. Pure: takes the
1479
+ // candidate and (optionally) the existing pool, performs no writes. Returns the
1480
+ // conflicting approved packets with the shared paths and a human reason.
1481
+ function detectContradictions(projectDir, candidate, opts = {}) {
1482
+ // Precision over recall: two packets sharing a path is NOT enough — they must
1483
+ // be about the same distinctive subject. 0.5 (was 0.34) plus the generic-token
1484
+ // filter is what collapses the false-positive storm (786 pairs -> ~real).
1485
+ const subjectThreshold = opts.subjectThreshold ?? 0.5;
1486
+ const candidatePaths = new Set(candidate.paths.filter((path) => meaningfulMemoryPath(path) && !shouldSkipRepoMemoryPath(path)));
1487
+ if (!candidatePaths.size)
1488
+ return [];
1489
+ const existing = (opts.existing ?? loadApprovedPackets(projectDir)).filter((packet) => packet.id !== candidate.id && packet.status === "approved");
1490
+ const candidateText = normalizedClaimText(candidate);
1491
+ const candidateSubject = distinctiveSubjectTokens(candidate);
1492
+ const findings = [];
1493
+ for (const packet of existing) {
1494
+ // (a) shared cited path — the structural anchor that makes a contradiction
1495
+ // about the SAME thing rather than two unrelated facts.
1496
+ const sharedPaths = packet.paths.filter((path) => candidatePaths.has(path));
1497
+ if (!sharedPaths.length)
1498
+ continue;
1499
+ // (b) same DISTINCTIVE subject. A genuine contradiction is the SAME claim
1500
+ // negated ("use X" vs "do not use X"), so the two packets must be near
1501
+ // paraphrases of each other's subject — measured over distinctive tokens
1502
+ // (jaccard). Two decisions that merely touch adjacent code in the same
1503
+ // file share a path and a few tokens but are NOT paraphrases, so they no
1504
+ // longer qualify. No token-count bypass: that was the false-positive door.
1505
+ const packetSubject = distinctiveSubjectTokens(packet);
1506
+ const subjectSimilarity = jaccard(candidateSubject, packetSubject);
1507
+ if (subjectSimilarity < subjectThreshold)
1508
+ continue;
1509
+ // (c) opposing polarity — the claim itself is flipped/redirected.
1510
+ const reason = opposingPolarity(candidateText, normalizedClaimText(packet));
1511
+ if (!reason)
1512
+ continue;
1513
+ findings.push({
1514
+ packet_id: packet.id,
1515
+ title: packet.title,
1516
+ shared_paths: sharedPaths,
1517
+ reason: `${reason} (shared path: ${sharedPaths.join(", ")})`,
1518
+ });
1519
+ }
1520
+ return findings
1521
+ .sort((a, b) => a.title.localeCompare(b.title))
1522
+ .slice(0, 5);
1523
+ }
1524
+ // Repo-wide pairwise contradiction scan across approved packets. Reuses
1525
+ // detectContradictions per packet against the already-loaded pool so the same
1526
+ // conservative heuristic governs both write-time surfacing and the audit view.
1527
+ function kageConflicts(projectDir) {
1528
+ ensureMemoryDirs(projectDir);
1529
+ const approved = loadApprovedPackets(projectDir);
1530
+ const byId = new Map(approved.map((packet) => [packet.id, packet]));
1531
+ const pairs = [];
1532
+ const seen = new Set();
1533
+ for (const packet of approved) {
1534
+ const findings = detectContradictions(projectDir, packet, { existing: approved });
1535
+ for (const finding of findings) {
1536
+ const ordered = [packet.id, finding.packet_id].sort();
1537
+ const key = ordered.join("\0");
1538
+ if (seen.has(key))
1539
+ continue;
1540
+ seen.add(key);
1541
+ const firstPacket = byId.get(ordered[0]);
1542
+ const secondPacket = byId.get(ordered[1]);
1543
+ pairs.push({
1544
+ a: { id: firstPacket.id, title: firstPacket.title },
1545
+ b: { id: secondPacket.id, title: secondPacket.title },
1546
+ shared_paths: finding.shared_paths,
1547
+ reason: finding.reason,
1548
+ });
1549
+ }
1550
+ }
1551
+ pairs.sort((x, y) => x.a.title.localeCompare(y.a.title) || x.b.title.localeCompare(y.b.title));
1552
+ return {
1553
+ ok: true,
1554
+ project_dir: projectDir,
1555
+ generated_at: nowIso(),
1556
+ count: pairs.length,
1557
+ pairs,
1558
+ };
1559
+ }
1348
1560
  function packetFeedbackScore(packet) {
1349
1561
  const quality = (packet.quality ?? {});
1350
1562
  return Number(quality.votes_up ?? 0) * 2 - Number(quality.votes_down ?? 0) * 3 - Number(quality.reports_stale ?? 0) * 4;
@@ -1419,15 +1631,144 @@ function memoryPathFingerprint(projectDir, path, cache) {
1419
1631
  return null;
1420
1632
  }
1421
1633
  }
1422
- function memoryPathFingerprints(projectDir, paths) {
1634
+ // Symbol-anchoring is only attempted where extractSymbols (the TS/JS parser) is
1635
+ // reliable. Other languages fall back to whole-file fingerprints — no granularity
1636
+ // benefit yet, but no regression either.
1637
+ const ANCHOR_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
1638
+ function pathSupportsSymbolAnchors(path) {
1639
+ return ANCHOR_EXTENSIONS.has((0, node_path_1.extname)(path).toLowerCase());
1640
+ }
1641
+ // Whole identifiers mentioned in a packet's text — the names of the symbols the
1642
+ // memory is talking about. camelCase / snake_case kept intact (not sub-tokenized)
1643
+ // so "detectContradictions" matches the symbol, not "detect" + "contradictions".
1644
+ function identifierTokens(text) {
1645
+ const out = new Set();
1646
+ for (const match of text.matchAll(/[A-Za-z_$][A-Za-z0-9_$]*/g))
1647
+ out.add(match[0].toLowerCase());
1648
+ return out;
1649
+ }
1650
+ // current-file symbol span hashes, keyed by `${nameLower}\0${kind}` -> [sha256...].
1651
+ // Cached by mtime+size: extraction only runs when a file actually changed.
1652
+ const anchorSymbolCache = new Map();
1653
+ function symbolSpanHashesFromText(path, text) {
1654
+ const byKey = new Map();
1655
+ let symbols;
1656
+ try {
1657
+ symbols = extractSymbols(path, text);
1658
+ }
1659
+ catch {
1660
+ return byKey;
1661
+ }
1662
+ const lines = text.split(/\r?\n/);
1663
+ for (const symbol of symbols) {
1664
+ if (symbol.name.length < 4)
1665
+ continue;
1666
+ if (symbol.end_line == null || symbol.line < 1 || symbol.end_line < symbol.line)
1667
+ continue;
1668
+ const span = lines.slice(symbol.line - 1, symbol.end_line).join("\n");
1669
+ if (!span.trim())
1670
+ continue;
1671
+ const key = `${symbol.name.toLowerCase()}\0${symbol.kind}`;
1672
+ const list = byKey.get(key) ?? [];
1673
+ list.push(sha256Hex(Buffer.from(span, "utf8")));
1674
+ byKey.set(key, list);
1675
+ }
1676
+ return byKey;
1677
+ }
1678
+ function fileSymbolSpanHashes(projectDir, path) {
1679
+ const normalized = path.replace(/\\/g, "/").replace(/^\/+/, "");
1680
+ if (!pathSupportsSymbolAnchors(normalized))
1681
+ return null;
1682
+ const absolutePath = (0, node_path_1.join)(projectDir, normalized);
1683
+ let stats;
1684
+ try {
1685
+ stats = (0, node_fs_1.statSync)(absolutePath);
1686
+ if (!stats.isFile())
1687
+ return null;
1688
+ }
1689
+ catch {
1690
+ return null;
1691
+ }
1692
+ const cacheKey = `${projectDir}\0${normalized}`;
1693
+ const warm = anchorSymbolCache.get(cacheKey);
1694
+ if (warm && warm.mtimeMs === stats.mtimeMs && warm.size === stats.size)
1695
+ return warm.byKey;
1696
+ let text;
1697
+ try {
1698
+ text = (0, node_fs_1.readFileSync)(absolutePath, "utf8");
1699
+ }
1700
+ catch {
1701
+ return null;
1702
+ }
1703
+ const byKey = symbolSpanHashesFromText(normalized, text);
1704
+ anchorSymbolCache.set(cacheKey, { mtimeMs: stats.mtimeMs, size: stats.size, byKey });
1705
+ return byKey;
1706
+ }
1707
+ // Compute fingerprints for a packet's cited paths. When anchorText (the packet's
1708
+ // title+summary+body) is supplied, anchor each TS/JS file to the symbols the
1709
+ // memory actually names, so unrelated edits in the same file do not mark it stale.
1710
+ function memoryPathFingerprints(projectDir, paths, anchorText) {
1711
+ const idents = anchorText ? identifierTokens(anchorText) : null;
1423
1712
  const fingerprints = [];
1424
1713
  for (const path of unique(paths).filter(fingerprintableMemoryPath)) {
1425
1714
  const fingerprint = memoryPathFingerprint(projectDir, path);
1426
- if (fingerprint)
1427
- fingerprints.push(fingerprint);
1715
+ if (!fingerprint)
1716
+ continue;
1717
+ if (idents && pathSupportsSymbolAnchors(path)) {
1718
+ const byKey = fileSymbolSpanHashes(projectDir, path);
1719
+ if (byKey) {
1720
+ // extractSymbols recurses into bodies, so a generic local like `match` can
1721
+ // appear many times. Only anchor names that resolve to EXACTLY ONE symbol
1722
+ // span in the file — an unambiguous handle on the thing the memory means.
1723
+ const spanCountByName = new Map();
1724
+ for (const [key, hashes] of byKey) {
1725
+ const name = key.slice(0, key.indexOf("\0"));
1726
+ spanCountByName.set(name, (spanCountByName.get(name) ?? 0) + hashes.length);
1727
+ }
1728
+ const symbols = [];
1729
+ for (const [key, hashes] of byKey) {
1730
+ const [name, kind] = key.split("\0");
1731
+ if (!idents.has(name) || spanCountByName.get(name) !== 1)
1732
+ continue;
1733
+ symbols.push({ name, kind, sha256: hashes[0] });
1734
+ }
1735
+ if (symbols.length) {
1736
+ fingerprints.push({ ...fingerprint, symbols: symbols.slice(0, 32) });
1737
+ continue;
1738
+ }
1739
+ }
1740
+ }
1741
+ fingerprints.push(fingerprint);
1428
1742
  }
1429
1743
  return fingerprints;
1430
1744
  }
1745
+ // Has the content a memory depends on changed since capture? Whole-file identical
1746
+ // is always "no". When the file differs but the memory is anchored to specific
1747
+ // symbols, it is "changed" only if one of those symbols was edited or removed —
1748
+ // edits elsewhere in the same file do not invalidate it. Unanchored fingerprints
1749
+ // (non-TS files, prose memories) keep the whole-file policy.
1750
+ function fingerprintPathContentChanged(projectDir, stored, cache) {
1751
+ const current = memoryPathFingerprint(projectDir, stored.path, cache);
1752
+ if (current === null)
1753
+ return false; // missing path is handled by the dedicated check
1754
+ if (current.sha256 === stored.sha256)
1755
+ return false; // whole file byte-identical
1756
+ if (stored.symbols && stored.symbols.length) {
1757
+ const byKey = fileSymbolSpanHashes(projectDir, stored.path);
1758
+ if (byKey) {
1759
+ for (const symbol of stored.symbols) {
1760
+ const currentHashes = byKey.get(`${symbol.name.toLowerCase()}\0${symbol.kind}`);
1761
+ if (!currentHashes || !currentHashes.length)
1762
+ return true; // anchored symbol gone
1763
+ if (!currentHashes.includes(symbol.sha256))
1764
+ return true; // anchored symbol edited
1765
+ }
1766
+ return false; // every anchored symbol is byte-identical; the rest of the file is irrelevant
1767
+ }
1768
+ // Parser unavailable for this file now — fall through to the whole-file signal.
1769
+ }
1770
+ return true;
1771
+ }
1431
1772
  function packetStoredPathFingerprints(packet) {
1432
1773
  const raw = (packet.freshness ?? {}).path_fingerprints;
1433
1774
  if (!Array.isArray(raw))
@@ -1441,9 +1782,31 @@ function packetStoredPathFingerprints(packet) {
1441
1782
  const size = Number(record.size ?? 0);
1442
1783
  if (!path || !sha256 || !Number.isFinite(size) || !fingerprintableMemoryPath(path))
1443
1784
  return [];
1444
- return [{ path, sha256, size }];
1785
+ const symbols = Array.isArray(record.symbols)
1786
+ ? record.symbols.flatMap((entry) => {
1787
+ if (!entry || typeof entry !== "object")
1788
+ return [];
1789
+ const sym = entry;
1790
+ const name = typeof sym.name === "string" ? sym.name : "";
1791
+ const kind = typeof sym.kind === "string" ? sym.kind : "";
1792
+ const symSha = typeof sym.sha256 === "string" ? sym.sha256 : "";
1793
+ if (!name || !symSha)
1794
+ return [];
1795
+ return [{ name, kind, sha256: symSha }];
1796
+ })
1797
+ : undefined;
1798
+ return [{ path, sha256, size, ...(symbols && symbols.length ? { symbols } : {}) }];
1445
1799
  });
1446
1800
  }
1801
+ // Append-only "ledger" docs (CHANGELOG, HISTORY) churn on nearly every commit.
1802
+ // Their content hash changes constantly without invalidating a memory's claim, so
1803
+ // they are excluded from content-change (soft-stale) detection — you can still
1804
+ // cite one, but appending an entry must not mark the memory stale. Deletion still
1805
+ // counts as staleness; this only suppresses the noisy "linked path changed" reason.
1806
+ const APPEND_ONLY_LEDGER_RE = /(^|\/)(change[-_ ]?log|history)(\.[a-z0-9]+)?$/i;
1807
+ function isAppendOnlyLedgerPath(path) {
1808
+ return APPEND_ONLY_LEDGER_RE.test(normalizeRelPath(path));
1809
+ }
1447
1810
  function staleMemoryReasons(projectDir, packet, fingerprintCache) {
1448
1811
  const reasons = [];
1449
1812
  const quality = (packet.quality ?? {});
@@ -1473,11 +1836,9 @@ function staleMemoryReasons(projectDir, packet, fingerprintCache) {
1473
1836
  const storedFingerprints = packetStoredPathFingerprints(packet);
1474
1837
  const changedPaths = storedFingerprints
1475
1838
  .filter((fingerprint) => !isGroundingIgnored(projectDir, fingerprint.path))
1839
+ .filter((fingerprint) => !isAppendOnlyLedgerPath(fingerprint.path))
1476
1840
  .filter((fingerprint) => (0, node_fs_1.existsSync)((0, node_path_1.join)(projectDir, fingerprint.path)))
1477
- .filter((fingerprint) => {
1478
- const current = memoryPathFingerprint(projectDir, fingerprint.path, fingerprintCache);
1479
- return current !== null && current.sha256 !== fingerprint.sha256;
1480
- })
1841
+ .filter((fingerprint) => fingerprintPathContentChanged(projectDir, fingerprint, fingerprintCache))
1481
1842
  .map((fingerprint) => fingerprint.path);
1482
1843
  if (changedPaths.length) {
1483
1844
  reasons.push(`linked path changed since memory was verified: ${changedPaths.slice(0, 4).join(", ")}`);
@@ -1531,6 +1892,22 @@ function recallHardStaleReason(projectDir, packet, cache) {
1531
1892
  }
1532
1893
  return null;
1533
1894
  }
1895
+ // Strict recall gate: hard-stale (recallHardStaleReason) PLUS soft-stale — a cited
1896
+ // file whose content changed under the memory, or partially-deleted citations.
1897
+ // Used to keep content-changed memory out of recall, skills, and the suppressed
1898
+ // report, so the agent never acts on a claim the code has moved past. NOT used by
1899
+ // compaction: a merely-changed packet needs reverify, not auto-deprecation.
1900
+ function recallStaleReason(projectDir, packet, cache) {
1901
+ const hard = recallHardStaleReason(projectDir, packet, cache);
1902
+ if (hard)
1903
+ return hard;
1904
+ // Strict recall (task #39): also withhold memory whose cited file CONTENT changed
1905
+ // (the fingerprint moved under it). Partial-missing citations stay served-but-
1906
+ // flagged — only the content-drift case is suppressed here.
1907
+ const changed = staleMemoryReasons(projectDir, packet, cache)
1908
+ .find((reason) => reason.startsWith("linked path changed since memory was verified"));
1909
+ return changed ?? null;
1910
+ }
1534
1911
  function changedPathsFromStaleReasons(reasons) {
1535
1912
  return unique(reasons.flatMap((reason) => {
1536
1913
  const match = reason.match(/^linked path changed since memory was verified: (.+)$/);
@@ -5984,6 +6361,213 @@ function hydrateKnowledgeGraphArtifact(projectDir, artifact) {
5984
6361
  edges: readJson(edgesPath),
5985
6362
  };
5986
6363
  }
6364
+ // --- Docs search index ---------------------------------------------------
6365
+ // Searchable index over the REPO's OWN committed documentation (project README,
6366
+ // docs/**, *.md, and common doc dirs — plus any framework/API docs that are
6367
+ // checked into this repo). It indexes nothing from the internet; only files
6368
+ // that exist on disk in this project. Recall can therefore answer from docs,
6369
+ // not just learned memory packets and code.
6370
+ const DOCS_INDEX_SCHEMA_VERSION = 1;
6371
+ // Heading-anchored chunks are capped so a single long section can't dominate the
6372
+ // BM25 length normalization or bloat the artifact.
6373
+ const DOCS_CHUNK_MAX_CHARS = 1600;
6374
+ // Directory names that commonly hold prose documentation, beyond the root README
6375
+ // and a top-level docs/ dir. These are matched as path segments.
6376
+ const DOCS_DIR_NAMES = new Set(["docs", "doc", "documentation", "guides", "guide", "wiki", "manual", "handbook"]);
6377
+ const DOCS_EXTENSIONS = new Set([".md", ".mdx", ".markdown", ".rst", ".txt"]);
6378
+ function isDocFile(relativePath) {
6379
+ if (shouldSkipCodePath(relativePath))
6380
+ return false;
6381
+ if (isNoisePath(relativePath))
6382
+ return false;
6383
+ const segments = relativePath.split("/");
6384
+ const name = segments[segments.length - 1];
6385
+ if (!DOCS_EXTENSIONS.has(extensionOf(name)))
6386
+ return false;
6387
+ // A markdown/rst/txt file qualifies if it is the root README, sits under a
6388
+ // recognised doc directory, or is any committed *.md anywhere in the tree.
6389
+ if (/^readme\b/i.test(name))
6390
+ return true;
6391
+ if (segments.slice(0, -1).some((part) => DOCS_DIR_NAMES.has(part.toLowerCase())))
6392
+ return true;
6393
+ const extension = extensionOf(name);
6394
+ return extension === ".md" || extension === ".mdx" || extension === ".markdown";
6395
+ }
6396
+ function discoverDocFiles(projectDir) {
6397
+ const all = walkFiles(projectDir, (absolute) => {
6398
+ const relativePath = (0, node_path_1.relative)(projectDir, absolute).replace(/\\/g, "/");
6399
+ return isDocFile(relativePath);
6400
+ });
6401
+ return all.map((absolute) => (0, node_path_1.relative)(projectDir, absolute).replace(/\\/g, "/")).sort();
6402
+ }
6403
+ function headingAnchor(heading) {
6404
+ return heading
6405
+ .toLowerCase()
6406
+ .replace(/[^\w\s-]/g, "")
6407
+ .trim()
6408
+ .replace(/\s+/g, "-");
6409
+ }
6410
+ // Split a doc into heading-anchored chunks. Each chunk carries the heading path
6411
+ // (e.g. "Setup > Install") plus the body beneath it, capped at DOCS_CHUNK_MAX_CHARS.
6412
+ function chunkDoc(docPath, text) {
6413
+ const lines = text.split(/\r?\n/);
6414
+ const chunks = [];
6415
+ const headingStack = [];
6416
+ let bodyLines = [];
6417
+ let chunkStartLine = 1;
6418
+ let inFence = false;
6419
+ const headingPath = () => headingStack.map((entry) => entry.title).join(" > ");
6420
+ const flush = (startLine) => {
6421
+ const body = bodyLines.join("\n").trim();
6422
+ bodyLines = [];
6423
+ if (!body)
6424
+ return;
6425
+ const heading = headingPath() || (0, node_path_1.basename)(docPath);
6426
+ for (let offset = 0; offset < body.length; offset += DOCS_CHUNK_MAX_CHARS) {
6427
+ const slice = body.slice(offset, offset + DOCS_CHUNK_MAX_CHARS);
6428
+ chunks.push({
6429
+ doc_path: docPath,
6430
+ heading,
6431
+ anchor: headingAnchor(headingStack[headingStack.length - 1]?.title ?? heading),
6432
+ text: slice,
6433
+ line: startLine,
6434
+ });
6435
+ }
6436
+ };
6437
+ lines.forEach((line, index) => {
6438
+ const fenceMatch = line.match(/^\s*(?:```|~~~)/);
6439
+ if (fenceMatch)
6440
+ inFence = !inFence;
6441
+ const headingMatch = inFence ? null : line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
6442
+ if (headingMatch) {
6443
+ flush(chunkStartLine);
6444
+ const level = headingMatch[1].length;
6445
+ const title = headingMatch[2].trim();
6446
+ while (headingStack.length && headingStack[headingStack.length - 1].level >= level)
6447
+ headingStack.pop();
6448
+ headingStack.push({ level, title });
6449
+ chunkStartLine = index + 1;
6450
+ return;
6451
+ }
6452
+ bodyLines.push(line);
6453
+ });
6454
+ flush(chunkStartLine);
6455
+ return chunks;
6456
+ }
6457
+ function buildDocsIndex(projectDir) {
6458
+ const docFiles = discoverDocFiles(projectDir);
6459
+ const chunks = [];
6460
+ for (const docPath of docFiles) {
6461
+ const text = safeReadText((0, node_path_1.join)(projectDir, docPath));
6462
+ if (!text)
6463
+ continue;
6464
+ chunks.push(...chunkDoc(docPath, text));
6465
+ }
6466
+ const artifact = {
6467
+ schema_version: DOCS_INDEX_SCHEMA_VERSION,
6468
+ generated_at: nowIso(),
6469
+ source: "repo-docs",
6470
+ doc_count: docFiles.length,
6471
+ chunk_count: chunks.length,
6472
+ chunks,
6473
+ };
6474
+ writeJson((0, node_path_1.join)(indexesDir(projectDir), "docs-index.json"), artifact);
6475
+ return artifact;
6476
+ }
6477
+ function readDocsIndex(projectDir) {
6478
+ const path = (0, node_path_1.join)(indexesDir(projectDir), "docs-index.json");
6479
+ if (!(0, node_fs_1.existsSync)(path))
6480
+ return null;
6481
+ try {
6482
+ const artifact = readJson(path);
6483
+ if (!Array.isArray(artifact?.chunks))
6484
+ return null;
6485
+ return artifact;
6486
+ }
6487
+ catch {
6488
+ return null;
6489
+ }
6490
+ }
6491
+ // BM25 over doc chunks, reusing the same lexical scorer (tokenize + stemming +
6492
+ // IDF) as packet recall. Heading text is weighted above body, mirroring how the
6493
+ // packet scorer weights titles.
6494
+ function scoreDocsBm25(queryTerms, chunks) {
6495
+ const terms = expandQueryTerms(queryTerms);
6496
+ const result = new Map();
6497
+ if (!terms.length || !chunks.length)
6498
+ return result;
6499
+ const HEADING_WEIGHT = 3;
6500
+ const documents = chunks.map((chunk) => {
6501
+ const termFrequency = new Map();
6502
+ let length = 0;
6503
+ const add = (textValue, weight) => {
6504
+ for (const token of tokenize(textValue)) {
6505
+ termFrequency.set(token, (termFrequency.get(token) ?? 0) + weight);
6506
+ length += weight;
6507
+ }
6508
+ };
6509
+ add(chunk.heading, HEADING_WEIGHT);
6510
+ add(chunk.text, 1);
6511
+ return { termFrequency, length: Math.max(1, length) };
6512
+ });
6513
+ const averageLength = documents.reduce((sum, document) => sum + document.length, 0) / documents.length || 1;
6514
+ const documentFrequency = new Map();
6515
+ for (const term of terms) {
6516
+ documentFrequency.set(term, documents.filter((document) => document.termFrequency.has(term)).length);
6517
+ }
6518
+ documents.forEach((document, index) => {
6519
+ let score = 0;
6520
+ for (const term of terms) {
6521
+ const termFrequency = document.termFrequency.get(term) ?? 0;
6522
+ if (termFrequency <= 0)
6523
+ continue;
6524
+ const df = documentFrequency.get(term) ?? 0;
6525
+ const idf = Math.log(1 + (documents.length - df + 0.5) / (df + 0.5));
6526
+ const denominator = termFrequency + BM25_K1 * (1 - BM25_B + BM25_B * (document.length / averageLength));
6527
+ score += idf * ((termFrequency * (BM25_K1 + 1)) / denominator);
6528
+ }
6529
+ if (score > 0)
6530
+ result.set(index, Number(score.toFixed(2)));
6531
+ });
6532
+ return result;
6533
+ }
6534
+ function docsSnippet(text) {
6535
+ const collapsed = text.replace(/\s+/g, " ").trim();
6536
+ return collapsed.length > 200 ? `${collapsed.slice(0, 197)}...` : collapsed;
6537
+ }
6538
+ function searchDocs(projectDir, query, limit = 5) {
6539
+ // Use the persisted index when present; rebuild on the fly otherwise so the
6540
+ // search works even before the first refresh.
6541
+ const artifact = readDocsIndex(projectDir) ?? buildDocsIndex(projectDir);
6542
+ const chunks = artifact.chunks;
6543
+ const scores = scoreDocsBm25(tokenize(query), chunks);
6544
+ return [...scores.entries()]
6545
+ .sort((a, b) => b[1] - a[1] || chunks[a[0]].doc_path.localeCompare(chunks[b[0]].doc_path) || chunks[a[0]].line - chunks[b[0]].line)
6546
+ .slice(0, Math.max(0, limit))
6547
+ .map(([index, score]) => {
6548
+ const chunk = chunks[index];
6549
+ return {
6550
+ doc_path: chunk.doc_path,
6551
+ heading: chunk.heading,
6552
+ line: chunk.line,
6553
+ snippet: docsSnippet(chunk.text),
6554
+ score,
6555
+ };
6556
+ });
6557
+ }
6558
+ // Renders a "Docs" section appended to recall output. Honest framing: these are
6559
+ // the repo's own committed docs, not the internet.
6560
+ function docsRecallSection(projectDir, query, limit = 3) {
6561
+ const hits = searchDocs(projectDir, query, limit);
6562
+ if (!hits.length)
6563
+ return null;
6564
+ const lines = ["Docs (from this repo's own committed documentation):"];
6565
+ for (const hit of hits) {
6566
+ lines.push(`- ${hit.doc_path}:${hit.line} — ${hit.heading}`);
6567
+ lines.push(` ${hit.snippet}`);
6568
+ }
6569
+ return lines.join("\n");
6570
+ }
5987
6571
  function buildPacketIndexes(projectDir) {
5988
6572
  ensureMemoryDirs(projectDir);
5989
6573
  const packets = loadPacketsFromDir(packetsDir(projectDir)).sort((a, b) => a.id.localeCompare(b.id));
@@ -6029,6 +6613,10 @@ function buildPacketIndexes(projectDir) {
6029
6613
  writeJson(written[2], byTag);
6030
6614
  writeJson(written[3], byType);
6031
6615
  written.push(writeSparseVectorIndex(projectDir, packets));
6616
+ // Docs search index over the repo's own committed documentation. Built here so
6617
+ // it stays current through both indexProject and refreshProject.
6618
+ buildDocsIndex(projectDir);
6619
+ written.push((0, node_path_1.join)(indexesDir(projectDir), "docs-index.json"));
6032
6620
  return written;
6033
6621
  }
6034
6622
  function readCurrentCodeGraph(projectDir, expectedInputHash) {
@@ -6130,6 +6718,7 @@ function currentOrBuildGraphs(projectDir) {
6130
6718
  (0, node_path_1.join)(indexesDir(projectDir), "by-tag.json"),
6131
6719
  (0, node_path_1.join)(indexesDir(projectDir), "by-type.json"),
6132
6720
  (0, node_path_1.join)(indexesDir(projectDir), "vector-local.json"),
6721
+ (0, node_path_1.join)(indexesDir(projectDir), "docs-index.json"),
6133
6722
  (0, node_path_1.join)(indexesDir(projectDir), "structural.json"),
6134
6723
  (0, node_path_1.join)(indexesDir(projectDir), "graph.json"),
6135
6724
  (0, node_path_1.join)(indexesDir(projectDir), "code-graph.json"),
@@ -6422,7 +7011,7 @@ function kageSuppressedMemory(projectDir) {
6422
7011
  const cache = new Map();
6423
7012
  const items = loadApprovedPackets(projectDir)
6424
7013
  .map((packet) => {
6425
- const reason = recallHardStaleReason(projectDir, packet, cache);
7014
+ const reason = recallStaleReason(projectDir, packet, cache);
6426
7015
  return reason ? { id: packet.id, title: packet.title, type: packet.type, reason, paths: packet.paths } : null;
6427
7016
  })
6428
7017
  .filter((entry) => entry !== null);
@@ -6497,7 +7086,7 @@ function compactProject(projectDir, options = {}) {
6497
7086
  paths: keptPaths,
6498
7087
  freshness: {
6499
7088
  ...(packet.freshness ?? {}),
6500
- path_fingerprints: memoryPathFingerprints(projectDir, keptPaths),
7089
+ path_fingerprints: memoryPathFingerprints(projectDir, keptPaths, `${packet.title}\n${packet.summary}\n${packet.body}`),
6501
7090
  last_verified_at: nowIso(),
6502
7091
  },
6503
7092
  updated_at: nowIso(),
@@ -7312,7 +7901,7 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
7312
7901
  const approvedPackets = includeStale
7313
7902
  ? allApprovedPackets
7314
7903
  : allApprovedPackets.filter((packet) => {
7315
- const reason = recallHardStaleReason(projectDir, packet, staleFingerprintCache);
7904
+ const reason = recallStaleReason(projectDir, packet, staleFingerprintCache);
7316
7905
  if (reason) {
7317
7906
  suppressed.push({ id: packet.id, title: packet.title, reason });
7318
7907
  return false;
@@ -7404,13 +7993,20 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
7404
7993
  ? [`## Structural Blast Radius (${structuralHops}-hop)`, ...blastRadius.map((path, index) => `${index + 1}. ${path}`), ""]
7405
7994
  : []),
7406
7995
  scored.length ? "## Relevant Memory" : "No relevant repo memory found.",
7407
- ...scored.flatMap((entry, index) => [
7408
- "",
7409
- `${index + 1}. [${entry.packet.type} | ${entry.packet.scope} | confidence ${entry.packet.confidence.toFixed(2)}] ${entry.packet.title}`,
7410
- ` Summary: ${entry.packet.summary}`,
7411
- ` Why matched: ${entry.why_matched.join(", ") || "text relevance"}`,
7412
- ` Source: ${sourceLabel(entry.packet)}`,
7413
- ]),
7996
+ ...scored.flatMap((entry, index) => {
7997
+ const contradicts = (entry.packet.quality ?? {}).contradicts;
7998
+ const contested = Array.isArray(contradicts) && contradicts.length > 0;
7999
+ return [
8000
+ "",
8001
+ `${index + 1}. [${entry.packet.type} | ${entry.packet.scope} | confidence ${entry.packet.confidence.toFixed(2)}] ${entry.packet.title}`,
8002
+ ` Summary: ${entry.packet.summary}`,
8003
+ ` Why matched: ${entry.why_matched.join(", ") || "text relevance"}`,
8004
+ ` Source: ${sourceLabel(entry.packet)}`,
8005
+ ...(contested
8006
+ ? [` ⚠ Contested: this memory contradicts ${contradicts.length} other packet(s) (${contradicts.join(", ")}). Resolve with kage conflicts / kage supersede before relying on it.`]
8007
+ : []),
8008
+ ];
8009
+ }),
7414
8010
  "",
7415
8011
  pendingScored.length ? "## Working Memory (Pending Review)" : "",
7416
8012
  ...pendingScored.flatMap((entry, index) => [
@@ -7424,7 +8020,12 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
7424
8020
  graphContext.edges.length ? "## Related Graph Facts" : "",
7425
8021
  ...graphContext.edges.slice(0, 5).map((edge, index) => `${index + 1}. ${edge.fact} (evidence: ${edge.evidence.join(", ")})`),
7426
8022
  ...(suppressed.length
7427
- ? ["", `_${suppressed.length} stale memory packet(s) excluded from recall. Run kage verify for details._`]
8023
+ ? [
8024
+ "",
8025
+ "## Withheld (stale — not served)",
8026
+ `_${suppressed.length} memory packet(s) excluded from recall because the cited code moved under them. The claim may still hold — reverify to restore, or supersede if it changed. Do not act on these as-is._`,
8027
+ ...suppressed.slice(0, 5).map((s) => `- ${s.title} — ${s.reason} (kage reverify --packet ${s.id})`),
8028
+ ]
7428
8029
  : []),
7429
8030
  ...(personalEntries.length
7430
8031
  ? [
@@ -8530,7 +9131,7 @@ function kageCleanupCandidates(projectDir) {
8530
9131
  summary: `${candidates.length} conservative cleanup candidate(s), ${skippedEntryPoints.length} entrypoint-like source file(s) skipped, ${skippedRuntimeReferences.length} runtime reference(s) skipped.`,
8531
9132
  };
8532
9133
  }
8533
- const TRUTH_REPORT_MAX_FINDINGS = 12;
9134
+ const TRUTH_REPORT_MAX_FINDINGS = 16;
8534
9135
  const TRUTH_REPORT_AI_ERA_DAYS = 120;
8535
9136
  // Symbol names too generic to mean "two teams built the same thing".
8536
9137
  const TRUTH_COMMON_SYMBOL_NAMES = new Set([
@@ -8814,6 +9415,106 @@ function truthReport(projectDir) {
8814
9415
  }
8815
9416
  }
8816
9417
  voidFindings.sort((a, b) => b.surprise - a.surprise || a.title.localeCompare(b.title));
9418
+ // 1e. Untested hot paths: central, churned source files no test exercises.
9419
+ // Build the set of source paths some test covers — directly (covers_path /
9420
+ // covers_symbol) or indirectly (a test file imports it). When the repo has no
9421
+ // tests at all, "untested" is the baseline, not a finding, so we skip it.
9422
+ const hasTests = graph.tests.length > 0 || graph.files.some((file) => file.kind === "test");
9423
+ const testedPaths = new Set();
9424
+ if (hasTests) {
9425
+ const symbolPathById = new Map(graph.symbols.map((symbol) => [symbol.id, symbol.path]));
9426
+ const symbolPathsByName = new Map();
9427
+ for (const symbol of graph.symbols) {
9428
+ const list = symbolPathsByName.get(symbol.name) ?? [];
9429
+ list.push(symbol.path);
9430
+ symbolPathsByName.set(symbol.name, list);
9431
+ }
9432
+ for (const test of graph.tests) {
9433
+ if (test.covers_path)
9434
+ testedPaths.add(test.covers_path.replace(/\\/g, "/").replace(/^\/+/, ""));
9435
+ if (test.covers_symbol) {
9436
+ const byId = symbolPathById.get(test.covers_symbol);
9437
+ if (byId)
9438
+ testedPaths.add(byId);
9439
+ for (const path of symbolPathsByName.get(test.covers_symbol) ?? [])
9440
+ testedPaths.add(path);
9441
+ }
9442
+ }
9443
+ for (const edge of graph.imports) {
9444
+ if (edge.to_path && fileByPath.get(edge.from_path)?.kind === "test")
9445
+ testedPaths.add(edge.to_path);
9446
+ }
9447
+ }
9448
+ const untestedFindings = [];
9449
+ if (hasTests) {
9450
+ for (const file of sourceFiles) {
9451
+ if (isEntrypointLike(file.path))
9452
+ continue;
9453
+ const fileCentrality = centrality.get(file.path) ?? 0;
9454
+ const commits = fileCommits.get(file.path) ?? 0;
9455
+ if (fileCentrality < 5)
9456
+ continue;
9457
+ if (hasGit && commits < 2)
9458
+ continue;
9459
+ if (testedPaths.has(file.path))
9460
+ continue;
9461
+ untestedFindings.push({
9462
+ kind: "untested_hot",
9463
+ title: `${file.path} — untested hot path`,
9464
+ detail: `${fileCentrality} other file(s)/call(s) depend on it${commits ? ` and it has changed ${commits} time(s)` : ""}, yet no test imports or directly targets it. Coverage gaps on a hub file like this are where regressions hide.`,
9465
+ evidence: [`${file.path}:1 centrality ${fileCentrality}, tests directly covering it: 0`],
9466
+ surprise: Math.min(100, 30 + Math.min(45, fileCentrality * 3) + (hasGit ? Math.min(15, commits) : 0)),
9467
+ });
9468
+ }
9469
+ }
9470
+ untestedFindings.sort((a, b) => b.surprise - a.surprise || a.title.localeCompare(b.title));
9471
+ // 1f. Complexity hotspots: very large source files many things depend on —
9472
+ // where knowledge concentrates and onboarding stalls.
9473
+ const complexityFindings = [];
9474
+ for (const file of sourceFiles) {
9475
+ const fileCentrality = centrality.get(file.path) ?? 0;
9476
+ if (file.line_count < 400)
9477
+ continue;
9478
+ if (fileCentrality < 3 && file.line_count < 800)
9479
+ continue;
9480
+ complexityFindings.push({
9481
+ kind: "complexity_hotspot",
9482
+ title: `${file.path} — ${file.line_count} lines, ${fileCentrality} dependent(s)`,
9483
+ detail: `A ${file.line_count}-line file that ${fileCentrality} other file(s) depend on. The biggest, most-connected files are exactly where undocumented knowledge piles up.`,
9484
+ evidence: [`${file.path}:1 ${file.line_count} lines, centrality ${fileCentrality}`],
9485
+ surprise: Math.min(100, 25 + Math.min(40, Math.round(file.line_count / 40)) + Math.min(25, fileCentrality * 2)),
9486
+ });
9487
+ }
9488
+ complexityFindings.sort((a, b) => b.surprise - a.surprise || a.title.localeCompare(b.title));
9489
+ // 1g. Known debt: TODO/FIXME/HACK/deprecation markers left in code. Each is a
9490
+ // decision deferred and undocumented. Scan the most-connected files first and
9491
+ // bound the walk so a huge repo stays fast.
9492
+ const debtFindings = [];
9493
+ const DEBT_RE = /(?:^|[^A-Za-z0-9_])(TODO|FIXME|HACK|XXX|@deprecated|@todo)(?:[^A-Za-z0-9_]|$)/gi;
9494
+ const debtScanTargets = [...sourceFiles]
9495
+ .sort((a, b) => (centrality.get(b.path) ?? 0) - (centrality.get(a.path) ?? 0))
9496
+ .slice(0, 400);
9497
+ for (const file of debtScanTargets) {
9498
+ const text = truthFileText(file.path);
9499
+ if (!text)
9500
+ continue;
9501
+ const matches = text.match(DEBT_RE);
9502
+ const count = matches ? matches.length : 0;
9503
+ if (count < 1)
9504
+ continue;
9505
+ const fileCentrality = centrality.get(file.path) ?? 0;
9506
+ // A lone marker in a leaf file is noise; require either repetition or reach.
9507
+ if (count < 2 && fileCentrality < 2)
9508
+ continue;
9509
+ debtFindings.push({
9510
+ kind: "debt_marker",
9511
+ title: `${file.path} — ${count} unresolved debt marker${count === 1 ? "" : "s"}`,
9512
+ detail: `TODO/FIXME/HACK/deprecation note(s) left in code${fileCentrality ? `, in a file ${fileCentrality} other(s) depend on` : ""}. Each is a decision deferred and undocumented.`,
9513
+ evidence: [`${file.path}:1 ${count} marker(s), centrality ${fileCentrality}`],
9514
+ surprise: Math.min(100, 20 + Math.min(35, count * 6) + Math.min(20, fileCentrality * 2)),
9515
+ });
9516
+ }
9517
+ debtFindings.sort((a, b) => b.surprise - a.surprise || a.title.localeCompare(b.title));
8817
9518
  // 2. Doc-truth: checkable claims in README/docs vs reality.
8818
9519
  const docLieFindings = [];
8819
9520
  if (docLines.length) {
@@ -8900,19 +9601,29 @@ function truthReport(projectDir) {
8900
9601
  docLieFindings.sort((a, b) => b.surprise - a.surprise || a.title.localeCompare(b.title));
8901
9602
  // Cap per category so one noisy category cannot drown the report, then rank globally.
8902
9603
  const findings = [
9604
+ ...voidFindings.slice(0, 4),
9605
+ ...untestedFindings.slice(0, 4),
9606
+ ...complexityFindings.slice(0, 4),
9607
+ ...debtFindings.slice(0, 4),
9608
+ ...busFindings.slice(0, 4),
8903
9609
  ...duplicateFindings.slice(0, 4),
8904
9610
  ...ghostFindings.slice(0, 4),
8905
- ...busFindings.slice(0, 4),
8906
- ...voidFindings.slice(0, 4),
8907
9611
  ...docLieFindings.slice(0, 4),
8908
9612
  ].sort((a, b) => b.surprise - a.surprise || a.title.localeCompare(b.title)).slice(0, TRUTH_REPORT_MAX_FINDINGS);
8909
- const headlineParts = [
8910
- `${duplicateFindings.length} duplicate cluster${duplicateFindings.length === 1 ? "" : "s"}`,
8911
- `${ghostFindings.length} ghost export${ghostFindings.length === 1 ? "" : "s"}`,
8912
- `${busFindings.length} bus-factor-1 hot file${busFindings.length === 1 ? "" : "s"}`,
8913
- `${voidFindings.length} knowledge void${voidFindings.length === 1 ? "" : "s"}`,
8914
- ...(docLines.length ? [`${docLieFindings.length} doc lie${docLieFindings.length === 1 ? "" : "s"}`] : []),
9613
+ // Lead the headline with what we actually found; categories that came back
9614
+ // clean are reported separately (see CLI "Clean:" line) so zeros never read
9615
+ // as "scan found nothing".
9616
+ const headlineCandidates = [
9617
+ [voidFindings.length, `${voidFindings.length} knowledge void${voidFindings.length === 1 ? "" : "s"}`],
9618
+ [untestedFindings.length, `${untestedFindings.length} untested hot path${untestedFindings.length === 1 ? "" : "s"}`],
9619
+ [complexityFindings.length, `${complexityFindings.length} complexity hotspot${complexityFindings.length === 1 ? "" : "s"}`],
9620
+ [debtFindings.length, `${debtFindings.length} debt marker file${debtFindings.length === 1 ? "" : "s"}`],
9621
+ [busFindings.length, `${busFindings.length} bus-factor-1 hot file${busFindings.length === 1 ? "" : "s"}`],
9622
+ [duplicateFindings.length, `${duplicateFindings.length} duplicate cluster${duplicateFindings.length === 1 ? "" : "s"}`],
9623
+ [ghostFindings.length, `${ghostFindings.length} ghost export${ghostFindings.length === 1 ? "" : "s"}`],
9624
+ ...(docLines.length ? [[docLieFindings.length, `${docLieFindings.length} doc lie${docLieFindings.length === 1 ? "" : "s"}`]] : []),
8915
9625
  ];
9626
+ const headlineParts = headlineCandidates.filter(([count]) => count > 0).map(([, label]) => label);
8916
9627
  return {
8917
9628
  schema_version: 1,
8918
9629
  project_dir: projectDir,
@@ -8924,6 +9635,9 @@ function truthReport(projectDir) {
8924
9635
  ghost_exports: ghostFindings.length,
8925
9636
  bus_factor_files: busFindings.length,
8926
9637
  knowledge_voids: voidFindings.length,
9638
+ untested_hot_paths: untestedFindings.length,
9639
+ complexity_hotspots: complexityFindings.length,
9640
+ debt_markers: debtFindings.length,
8927
9641
  doc_lies: docLieFindings.length,
8928
9642
  docs_scanned: docFiles.length,
8929
9643
  },
@@ -8937,6 +9651,118 @@ function truthReport(projectDir) {
8937
9651
  ],
8938
9652
  };
8939
9653
  }
9654
+ // ─────────────────────────────────────────────────────────────────────────────
9655
+ // Shareable Truth Report scorecard. `kage scan --scorecard` turns a TruthReport
9656
+ // into a screenshot-able SVG (and a Markdown variant) so the 60-second scan
9657
+ // becomes something people post — a repo scorecard, not just terminal output.
9658
+ // This is the top-of-funnel artifact: a stranger runs it on any repo, gets a
9659
+ // shareable card, and the memory loop is what they install afterwards.
9660
+ // ─────────────────────────────────────────────────────────────────────────────
9661
+ const SCORECARD_CARDS = [
9662
+ { key: "knowledge_voids", label: "KNOWLEDGE VOIDS" },
9663
+ { key: "untested_hot_paths", label: "UNTESTED HOT PATHS" },
9664
+ { key: "complexity_hotspots", label: "COMPLEXITY HOTSPOTS" },
9665
+ { key: "debt_markers", label: "KNOWN DEBT" },
9666
+ { key: "bus_factor_files", label: "BUS-FACTOR-1 FILES" },
9667
+ { key: "duplicate_clusters", label: "DUPLICATE IMPLS" },
9668
+ { key: "ghost_exports", label: "GHOST EXPORTS" },
9669
+ { key: "doc_lies", label: "DOC LIES" },
9670
+ ];
9671
+ function scorecardRepoName(projectDir) {
9672
+ const parts = projectDir.split(/[\\/]+/).filter(Boolean);
9673
+ return parts.length ? parts[parts.length - 1] : projectDir;
9674
+ }
9675
+ function svgEscape(value) {
9676
+ return value
9677
+ .replace(/&/g, "&amp;")
9678
+ .replace(/</g, "&lt;")
9679
+ .replace(/>/g, "&gt;")
9680
+ .replace(/"/g, "&quot;");
9681
+ }
9682
+ // 0 findings is good (green); a few is worth a look (amber); a pile is red.
9683
+ function scorecardColor(count) {
9684
+ if (count === 0)
9685
+ return "#1a7f37";
9686
+ if (count < 5)
9687
+ return "#bc4c00";
9688
+ return "#cf222e";
9689
+ }
9690
+ function truthScorecardSvg(report) {
9691
+ const repo = scorecardRepoName(report.project_dir);
9692
+ const totalFindings = SCORECARD_CARDS.reduce((sum, card) => sum + (report.totals[card.key] || 0), 0);
9693
+ const width = 820;
9694
+ const pad = 24;
9695
+ const gap = 16;
9696
+ const cols = 4;
9697
+ const cardW = Math.round((width - pad * 2 - gap * (cols - 1)) / cols);
9698
+ const cardH = 96;
9699
+ const gridTop = 104;
9700
+ const rows = Math.ceil(SCORECARD_CARDS.length / cols);
9701
+ const footerTop = gridTop + rows * cardH + (rows - 1) * gap + 20;
9702
+ const height = footerTop + 56;
9703
+ const subtitle = `${repo} · ${report.totals.files_scanned} files · ${report.totals.symbols_scanned} symbols scanned`;
9704
+ const headline = report.headline
9705
+ ? report.headline.length > 92
9706
+ ? `${report.headline.slice(0, 89)}…`
9707
+ : report.headline
9708
+ : totalFindings === 0
9709
+ ? "No surprising findings — this repo's knowledge is well distributed."
9710
+ : `${totalFindings} knowledge risk${totalFindings === 1 ? "" : "s"} surfaced`;
9711
+ const cards = SCORECARD_CARDS.map((card, i) => {
9712
+ const count = report.totals[card.key] || 0;
9713
+ const x = pad + (i % cols) * (cardW + gap);
9714
+ const y = gridTop + Math.floor(i / cols) * (cardH + gap);
9715
+ const cx = x + cardW / 2;
9716
+ return [
9717
+ ` <g>`,
9718
+ ` <rect x="${x}" y="${y}" width="${cardW}" height="${cardH}" rx="12" fill="#ffffff" stroke="#d0d7de" stroke-width="1"/>`,
9719
+ ` <text x="${cx}" y="${y + 50}" text-anchor="middle" font-size="34" font-weight="700" fill="${scorecardColor(count)}">${count}</text>`,
9720
+ ` <text x="${cx}" y="${y + 76}" text-anchor="middle" font-size="10.5" font-weight="600" letter-spacing="0.8" fill="#57606a">${svgEscape(card.label)}</text>`,
9721
+ ` </g>`,
9722
+ ].join("\n");
9723
+ }).join("\n");
9724
+ return [
9725
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif">`,
9726
+ ` <rect x="0" y="0" width="${width}" height="${height}" rx="16" fill="#0d1117"/>`,
9727
+ ` <text x="${pad}" y="46" font-size="22" font-weight="700" fill="#41ff8f">Kage Truth Report</text>`,
9728
+ ` <text x="${pad}" y="72" font-size="13" fill="#8b949e">${svgEscape(subtitle)}</text>`,
9729
+ ` <text x="${pad}" y="${gridTop - 14}" font-size="13" font-weight="600" fill="#c9d1d9">${svgEscape(headline)}</text>`,
9730
+ cards,
9731
+ ` <text x="${pad}" y="${footerTop + 22}" font-size="12.5" fill="#8b949e">Run it on your repo:</text>`,
9732
+ ` <text x="${pad + 130}" y="${footerTop + 22}" font-size="12.5" font-weight="600" fill="#41ff8f">npx -y @kage-core/kage-graph-mcp install</text>`,
9733
+ ` <text x="${width - pad}" y="${footerTop + 22}" text-anchor="end" font-size="12.5" fill="#57606a">kage-core.com</text>`,
9734
+ `</svg>`,
9735
+ ].join("\n");
9736
+ }
9737
+ function truthScorecardMarkdown(report) {
9738
+ const repo = scorecardRepoName(report.project_dir);
9739
+ const totalFindings = SCORECARD_CARDS.reduce((sum, card) => sum + (report.totals[card.key] || 0), 0);
9740
+ const rows = SCORECARD_CARDS.map((card) => {
9741
+ const count = report.totals[card.key] || 0;
9742
+ const mark = count === 0 ? "✅" : count < 5 ? "⚠️" : "🔴";
9743
+ const label = card.label.toLowerCase().replace(/\b\w/g, (c) => c.toUpperCase()).replace(/\bImpls\b/, "Implementations");
9744
+ return `| ${label} | ${count} ${mark} |`;
9745
+ }).join("\n");
9746
+ const headline = report.headline || (totalFindings === 0
9747
+ ? "No surprising findings — this repo's knowledge is well distributed."
9748
+ : `${totalFindings} knowledge risk${totalFindings === 1 ? "" : "s"} surfaced`);
9749
+ return [
9750
+ `## Kage Truth Report — ${repo}`,
9751
+ ``,
9752
+ `Scanned ${report.totals.files_scanned} files, ${report.totals.symbols_scanned} symbols.`,
9753
+ ``,
9754
+ `> ${headline}`,
9755
+ ``,
9756
+ `| Signal | Count |`,
9757
+ `| --- | --- |`,
9758
+ rows,
9759
+ ``,
9760
+ `Each signal is a place an agent loses time re-learning what your team already knows — [what these mean](https://github.com/kage-core/Kage/blob/master/docs/scorecard-metrics.md).`,
9761
+ ``,
9762
+ `Run it on your repo: \`npx -y @kage-core/kage-graph-mcp install\` · [kage-core.com](https://kage-core.com)`,
9763
+ ``,
9764
+ ].join("\n");
9765
+ }
8940
9766
  function defaultClaudeMemStorePath() {
8941
9767
  const dataDir = process.env.CLAUDE_MEM_DATA_DIR || (0, node_path_1.join)((0, node_os_1.homedir)(), ".claude-mem");
8942
9768
  return (0, node_path_1.join)(dataDir, "claude-mem.db");
@@ -12521,6 +13347,7 @@ function learn(input) {
12521
13347
  strictCitations: input.strictCitations,
12522
13348
  graphNodes: input.graphNodes,
12523
13349
  pendingReview: input.pendingReview,
13350
+ strictContradictions: input.strictContradictions,
12524
13351
  discoveryTokens: input.discoveryTokens,
12525
13352
  });
12526
13353
  }
@@ -12602,7 +13429,7 @@ function capture(input) {
12602
13429
  freshness: {
12603
13430
  ttl_days: 365,
12604
13431
  last_verified_at: createdAt,
12605
- path_fingerprints: memoryPathFingerprints(input.projectDir, groundedPaths),
13432
+ path_fingerprints: memoryPathFingerprints(input.projectDir, groundedPaths, `${input.title}\n${input.summary ?? ""}\n${input.body}`),
12606
13433
  path_fingerprint_policy: "source_hash_staleness",
12607
13434
  verification: "repo_local_agent_capture",
12608
13435
  },
@@ -12630,9 +13457,26 @@ function capture(input) {
12630
13457
  const validation = validatePacket(packet);
12631
13458
  if (!validation.ok)
12632
13459
  return { ok: false, errors: validation.errors, warnings };
13460
+ // Memory-vs-memory contradiction detection: surface (and optionally refuse)
13461
+ // a capture that directly opposes an existing approved packet on a shared
13462
+ // path, instead of silently storing two conflicting facts.
13463
+ const contradictions = detectContradictions(input.projectDir, packet);
13464
+ if (contradictions.length && input.strictContradictions) {
13465
+ return {
13466
+ ok: false,
13467
+ errors: [
13468
+ `Contradiction blocked: this memory contradicts ${contradictions.length} existing approved ` +
13469
+ `packet(s): ${contradictions.map((c) => `${c.packet_id} (${c.title})`).join("; ")}. ` +
13470
+ `Resolve with kage supersede --packet <old> --replacement <new>, or drop --strict-contradictions to keep both.`,
13471
+ ],
13472
+ warnings,
13473
+ contradictions,
13474
+ };
13475
+ }
12633
13476
  packet.quality = {
12634
13477
  ...packet.quality,
12635
13478
  ...evaluateMemoryQuality(input.projectDir, packet),
13479
+ ...(contradictions.length ? { contradicts: contradictions.map((c) => c.packet_id) } : {}),
12636
13480
  };
12637
13481
  const path = writePacket(input.projectDir, packet, input.pendingReview ? "pending" : "packets");
12638
13482
  recordMemoryAudit(input.projectDir, "capture", [packet], {
@@ -12641,7 +13485,7 @@ function capture(input) {
12641
13485
  path: (0, node_path_1.relative)(input.projectDir, path),
12642
13486
  source_kind: packet.source_refs[0]?.kind ?? "explicit_capture",
12643
13487
  });
12644
- return { ok: true, packet, path, errors: [], warnings };
13488
+ return { ok: true, packet, path, errors: [], warnings, ...(contradictions.length ? { contradictions } : {}) };
12645
13489
  }
12646
13490
  function createPublicCandidate(projectDir, id) {
12647
13491
  ensureMemoryDirs(projectDir);
@@ -12884,6 +13728,8 @@ PAYLOAD="$(cat || true)"
12884
13728
  CWD="$(printf "%s" "$PAYLOAD" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('cwd',''))" 2>/dev/null || echo "")"
12885
13729
 
12886
13730
  [[ -d "$CWD/.agent_memory" ]] || exit 0
13731
+ # Resolve a repo-local install too, so hooks work without a global kage on PATH.
13732
+ export PATH="$CWD/node_modules/.bin:$PATH"
12887
13733
  command -v kage >/dev/null 2>&1 || exit 0
12888
13734
 
12889
13735
  if git -C "$CWD" status --porcelain -uall >/dev/null 2>&1 && [[ -n "$(git -C "$CWD" status --porcelain -uall)" ]]; then
@@ -12940,6 +13786,8 @@ print(d.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or "")
12940
13786
  ' 2>/dev/null || echo "")"
12941
13787
 
12942
13788
  [[ -d "$CWD/.agent_memory" ]] || exit 0
13789
+ # Resolve a repo-local install too, so hooks work without a global kage on PATH.
13790
+ export PATH="$CWD/node_modules/.bin:$PATH"
12943
13791
  command -v kage >/dev/null 2>&1 || exit 0
12944
13792
 
12945
13793
  EVENT="$(PAYLOAD="$PAYLOAD" python3 -c 'import json, os
@@ -13066,6 +13914,8 @@ print(d.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or "")
13066
13914
  ' 2>/dev/null || echo "")"
13067
13915
 
13068
13916
  [[ -d "$CWD/.agent_memory" ]] || exit 0
13917
+ # Resolve a repo-local install too, so hooks work without a global kage on PATH.
13918
+ export PATH="$CWD/node_modules/.bin:$PATH"
13069
13919
  command -v kage >/dev/null 2>&1 || exit 0
13070
13920
 
13071
13921
  FILE_PATH="$(PAYLOAD="$PAYLOAD" python3 -c 'import json, os
@@ -13115,6 +13965,15 @@ fi
13115
13965
 
13116
13966
  exit 0
13117
13967
  `;
13968
+ // PreToolUse(Edit/Write) — the enforcement counterpart to the Read hook. Before
13969
+ // the agent MODIFIES a file, inject the verified memory about it (and surface what
13970
+ // Kage is withholding as stale), so "recall before you edit" is not optional. CLI
13971
+ // only (kage file-context), so it works even when the MCP server is not loaded.
13972
+ const editContextHookScript = readContextHookScript
13973
+ .replace("PreToolUse(Read) hook — injects verified file-linked memory right before the agent reads a file.", "PreToolUse(Edit/Write) hook — injects verified file-linked memory right before the agent edits a file, so recall precedes every change.")
13974
+ .replace("Never blocks the Read.", "Never blocks the edit.")
13975
+ .replace('STATE_DIR="/tmp/kage-read-context"', 'STATE_DIR="/tmp/kage-edit-context"')
13976
+ .replace("never block the Read.", "never block the edit.");
13118
13977
  const settingsPath = (0, node_path_1.join)(home, ".claude", "settings.json");
13119
13978
  const hookEntry = {
13120
13979
  hooks: {
@@ -13124,6 +13983,9 @@ exit 0
13124
13983
  { matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 5 }] },
13125
13984
  // Verified memory at the moment of relevance: short timeout, never blocks the Read.
13126
13985
  { matcher: "Read", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/kage-read-context.sh", timeout: 6 }] },
13986
+ // Enforcement: recall before an edit. Injects verified memory + withheld-stale
13987
+ // for the file the agent is about to change. Never blocks the edit.
13988
+ { matcher: "Edit|Write|MultiEdit", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/kage-edit-context.sh", timeout: 6 }] },
13127
13989
  ],
13128
13990
  PostToolUse: [{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 5 }] }],
13129
13991
  PostToolUseFailure: [{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 5 }] }],
@@ -13136,7 +13998,7 @@ exit 0
13136
13998
  setSnippet(path, JSON.stringify({ mcpServers: { kage: server } }, null, 2), [
13137
13999
  "Add the MCP server to ~/.claude.json, then restart Claude Code.",
13138
14000
  "alwaysLoad: true makes Kage tools immediately visible without requiring ToolSearch.",
13139
- `Also create ${hookDir}/session-start.sh, observe.sh, kage-read-context.sh, and stop.sh with the hook scripts and add SessionStart/UserPromptSubmit/PreToolUse/PostToolUse/PostToolUseFailure/PreCompact/Stop/SessionEnd hooks to ~/.claude/settings.json.`,
14001
+ `Also create ${hookDir}/session-start.sh, observe.sh, kage-read-context.sh, kage-edit-context.sh, and stop.sh with the hook scripts and add SessionStart/UserPromptSubmit/PreToolUse/PostToolUse/PostToolUseFailure/PreCompact/Stop/SessionEnd hooks to ~/.claude/settings.json.`,
13140
14002
  "Run `kage init --project <repo>` inside each repo to install the ambient memory policy.",
13141
14003
  ], true);
13142
14004
  if (options.write) {
@@ -13146,6 +14008,7 @@ exit 0
13146
14008
  (0, node_fs_1.writeFileSync)((0, node_path_1.join)(hookDir, "session-start.sh"), hookScript, { mode: 0o755 });
13147
14009
  (0, node_fs_1.writeFileSync)((0, node_path_1.join)(hookDir, "observe.sh"), observeHookScript, { mode: 0o755 });
13148
14010
  (0, node_fs_1.writeFileSync)((0, node_path_1.join)(hookDir, "kage-read-context.sh"), readContextHookScript, { mode: 0o755 });
14011
+ (0, node_fs_1.writeFileSync)((0, node_path_1.join)(hookDir, "kage-edit-context.sh"), editContextHookScript, { mode: 0o755 });
13149
14012
  (0, node_fs_1.writeFileSync)((0, node_path_1.join)(hookDir, "stop.sh"), stopHookScript, { mode: 0o755 });
13150
14013
  upsertJsonSettings(settingsPath, hookEntry);
13151
14014
  result.wrote = true;
@@ -13175,6 +14038,9 @@ exit 0
13175
14038
  "roo-code": (0, node_path_1.join)(home, ".roo", "mcp_settings.json"),
13176
14039
  "kilo-code": (0, node_path_1.join)(home, ".kilo", "mcp_settings.json"),
13177
14040
  "claude-desktop": (0, node_path_1.join)(home, ".config", "claude", "claude_desktop_config.json"),
14041
+ openclaw: (0, node_path_1.join)(home, ".openclaw", "mcp.json"),
14042
+ copilot: (0, node_path_1.join)(home, ".config", "github-copilot", "mcp.json"),
14043
+ hermes: (0, node_path_1.join)(home, ".hermes", "mcp.json"),
13178
14044
  "generic-mcp": "",
13179
14045
  };
13180
14046
  setSnippet(paths[agent] || null, universal, [`Merge this MCP stdio config into ${agent}'s MCP settings.`, "Restart the agent after updating config."]);
@@ -14632,7 +15498,9 @@ function staleCatch(projectDir, changedFiles) {
14632
15498
  if (current === null) {
14633
15499
  invalidated.push({ packet_id: packet.id, packet_title: packet.title, cited_path: stored.path, reason: "cited file was deleted" });
14634
15500
  }
14635
- else if (current.sha256 !== stored.sha256) {
15501
+ else if (fingerprintPathContentChanged(projectDir, stored, fpCache)) {
15502
+ // Anchored memories are only invalidated when the symbols they cite change,
15503
+ // not when something unrelated in the same file moves.
14636
15504
  invalidated.push({ packet_id: packet.id, packet_title: packet.title, cited_path: stored.path, reason: "content changed since this memory was verified" });
14637
15505
  }
14638
15506
  }
@@ -14705,6 +15573,12 @@ function prCheck(projectDir) {
14705
15573
  if (reconciliation.unresolved_count > 0) {
14706
15574
  warnings.push(`${reconciliation.unresolved_count} memory reconciliation item(s) may need update after recent code changes (review on handoff; not blocking).`);
14707
15575
  }
15576
+ // Surface unresolved memory-vs-memory contradictions (not a hard fail —
15577
+ // resolving them is an agent/human decision via supersede or keep-both).
15578
+ const conflicts = kageConflicts(projectDir);
15579
+ if (conflicts.count > 0) {
15580
+ warnings.push(`${conflicts.count} memory contradiction pair(s) unresolved — run kage conflicts, then kage supersede the wrong one (not blocking).`);
15581
+ }
14708
15582
  if (!codeGraphCurrent || !memoryGraphCurrent) {
14709
15583
  errors.push("Generated graph artifacts are missing or not current for this working tree content.");
14710
15584
  requiredActions.push("Run kage refresh --project <dir> before merge.");
@@ -15733,6 +16607,141 @@ function packetSupersessionReason(packet) {
15733
16607
  const evidence = edge ? packetEdgeValue(edge, "evidence") : "";
15734
16608
  return evidence || "This memory was superseded by newer repo knowledge.";
15735
16609
  }
16610
+ // The whole value of generated skills is that the team shares them via git. If
16611
+ // the output dir is git-ignored, they silently won't be — so we surface it.
16612
+ function pathIsGitIgnored(projectDir, relPath) {
16613
+ try {
16614
+ (0, node_child_process_1.execFileSync)("git", ["-C", projectDir, "check-ignore", "-q", relPath], { stdio: "ignore" });
16615
+ return true; // exit 0 = path is ignored
16616
+ }
16617
+ catch {
16618
+ return false; // non-zero = not ignored (or not a git repo)
16619
+ }
16620
+ }
16621
+ // A SKILL.md is a reusable *procedure*. Runbooks and workflows are procedures by
16622
+ // nature; rationale-only decisions and per-diff change-memory are not skills.
16623
+ const SKILL_ELIGIBLE_TYPES = ["runbook", "workflow"];
16624
+ // Auto-distilled junk sometimes lands a raw hook/tool payload as a "runbook".
16625
+ // Those must never become skills the agent loads as instructions.
16626
+ const SKILL_PAYLOAD_MARKERS = [
16627
+ "task-notification", "tool-use-id", "toolu_", "hookspecificoutput",
16628
+ "nooutputexpected", "isimage", "stop_hook_active", '"interrupted"', "interrupted:",
16629
+ ];
16630
+ function looksLikePayloadMemory(packet) {
16631
+ const haystack = `${packet.title} ${packet.summary} ${packet.body}`.toLowerCase();
16632
+ return SKILL_PAYLOAD_MARKERS.some((marker) => haystack.includes(marker));
16633
+ }
16634
+ function packetSkillBody(packet) {
16635
+ const ctx = packet.context ?? {};
16636
+ const lines = [`# ${packet.title}`, ""];
16637
+ if (packet.summary)
16638
+ lines.push(packet.summary, "");
16639
+ if (ctx.fact && ctx.fact !== packet.summary)
16640
+ lines.push(ctx.fact, "");
16641
+ if (ctx.why)
16642
+ lines.push(`**Why it matters:** ${ctx.why}`, "");
16643
+ if (ctx.trigger)
16644
+ lines.push(`**Use this when:** ${ctx.trigger}`, "");
16645
+ if (ctx.action)
16646
+ lines.push("## What to do", "", ctx.action, "");
16647
+ else if (packet.body && packet.body !== packet.summary)
16648
+ lines.push("## Detail", "", packet.body.trim(), "");
16649
+ if (ctx.verification)
16650
+ lines.push("## Verify", "", ctx.verification, "");
16651
+ if (ctx.risk_if_forgotten)
16652
+ lines.push(`**If you skip this:** ${ctx.risk_if_forgotten}`, "");
16653
+ const citedPaths = (packet.paths ?? []).filter((p) => meaningfulMemoryPath(p));
16654
+ if (citedPaths.length) {
16655
+ lines.push("## Grounded in", "");
16656
+ for (const p of citedPaths.slice(0, 12))
16657
+ lines.push(`- \`${p}\``);
16658
+ lines.push("");
16659
+ }
16660
+ lines.push("---", `_Generated by \`kage skills\` from verified repo memory (packet \`${packet.id}\`). Checked against the code it cites; regenerate with \`kage skills\`._`);
16661
+ return lines.join("\n");
16662
+ }
16663
+ function skillDescription(packet) {
16664
+ const ctx = packet.context ?? {};
16665
+ const base = (packet.summary || ctx.fact || packet.title).replace(/\s+/g, " ").trim();
16666
+ const when = ctx.trigger ? ` Use when: ${ctx.trigger.replace(/\s+/g, " ").trim()}` : "";
16667
+ const full = `${base}${when}`;
16668
+ return full.length > 480 ? `${full.slice(0, 477)}...` : full;
16669
+ }
16670
+ // A packet earns a skill only if it is a genuine, self-contained procedure:
16671
+ // right type, not a per-diff change-memory packet, not a leaked payload, and
16672
+ // carrying an actual action or a substantive body.
16673
+ function isSkillWorthy(packet) {
16674
+ if (!SKILL_ELIGIBLE_TYPES.includes(packet.type))
16675
+ return false;
16676
+ if (isGeneratedChangeMemory(packet))
16677
+ return false;
16678
+ if (looksLikePayloadMemory(packet))
16679
+ return false;
16680
+ const ctx = packet.context ?? {};
16681
+ const hasProcedure = Boolean(ctx.action) || (packet.body ?? "").trim().length >= 120;
16682
+ return hasProcedure;
16683
+ }
16684
+ function generateSkills(projectDir, options = {}) {
16685
+ ensureMemoryDirs(projectDir);
16686
+ const dryRun = options.dryRun === true;
16687
+ const relDir = options.dir ?? (0, node_path_1.join)(".claude", "skills");
16688
+ const result = {
16689
+ ok: true,
16690
+ project_dir: projectDir,
16691
+ dir: relDir,
16692
+ dry_run: dryRun,
16693
+ generated: [],
16694
+ skipped: [],
16695
+ total_eligible: 0,
16696
+ git_ignored: pathIsGitIgnored(projectDir, relDir),
16697
+ errors: [],
16698
+ };
16699
+ const cache = new Map();
16700
+ const candidates = loadApprovedPackets(projectDir)
16701
+ .filter((packet) => SKILL_ELIGIBLE_TYPES.includes(packet.type))
16702
+ .sort((a, b) => a.title.localeCompare(b.title));
16703
+ const usedSlugs = new Set();
16704
+ for (const packet of candidates) {
16705
+ if (!isSkillWorthy(packet)) {
16706
+ result.skipped.push({ title: packet.title, reason: "no actionable procedure (rationale-only)" });
16707
+ continue;
16708
+ }
16709
+ const staleReason = recallStaleReason(projectDir, packet, cache);
16710
+ if (staleReason) {
16711
+ result.skipped.push({ title: packet.title, reason: `not grounded: ${staleReason}` });
16712
+ continue;
16713
+ }
16714
+ result.total_eligible += 1;
16715
+ let slug = slugify(packet.title).slice(0, 60).replace(/-+$/, "") || `skill-${result.total_eligible}`;
16716
+ while (usedSlugs.has(slug))
16717
+ slug = `${slug}-2`;
16718
+ usedSlugs.add(slug);
16719
+ const skillDir = (0, node_path_1.join)(projectDir, relDir, slug);
16720
+ const skillPath = (0, node_path_1.join)(skillDir, "SKILL.md");
16721
+ const content = [
16722
+ "---",
16723
+ `name: ${slug}`,
16724
+ `description: ${skillDescription(packet).replace(/\n/g, " ")}`,
16725
+ "---",
16726
+ "",
16727
+ packetSkillBody(packet),
16728
+ "",
16729
+ ].join("\n");
16730
+ if (!dryRun) {
16731
+ try {
16732
+ (0, node_fs_1.mkdirSync)(skillDir, { recursive: true });
16733
+ (0, node_fs_1.writeFileSync)(skillPath, content, "utf8");
16734
+ }
16735
+ catch (error) {
16736
+ result.errors.push(`failed to write ${slug}: ${error.message}`);
16737
+ continue;
16738
+ }
16739
+ }
16740
+ result.generated.push({ slug, title: packet.title, path: (0, node_path_1.join)(relDir, slug, "SKILL.md"), packet_id: packet.id, type: packet.type });
16741
+ }
16742
+ result.ok = result.errors.length === 0;
16743
+ return result;
16744
+ }
15736
16745
  // Re-verify a still-true packet in place: re-check cited paths, refresh
15737
16746
  // fingerprints and last_verified_at, and clear stale flags. The alternative to
15738
16747
  // supersede churn when code changed but the memory's claim did not. Refuses
@@ -15770,7 +16779,7 @@ function reverifyMemory(projectDir, packetId) {
15770
16779
  const presentPaths = citedPaths.filter((path) => !result.missing_paths.includes(path));
15771
16780
  const now = nowIso();
15772
16781
  const freshness = { ...(packet.freshness ?? {}) };
15773
- freshness.path_fingerprints = memoryPathFingerprints(projectDir, presentPaths);
16782
+ freshness.path_fingerprints = memoryPathFingerprints(projectDir, presentPaths, `${packet.title}\n${packet.summary}\n${packet.body}`);
15774
16783
  freshness.last_verified_at = now;
15775
16784
  const { stale: _stale, stale_reasons: _staleReasons, suggested_action: _suggestedAction, ...nextQuality } = quality;
15776
16785
  writeJson(entry.path, {
@@ -15840,8 +16849,35 @@ function supersedeMemory(projectDir, oldPacketId, replacementPacketId, reason =
15840
16849
  upsertPacketEdge(oldPacket, "superseded_by", replacementPacket.id, trimmedReason, at);
15841
16850
  replacementPacket.updated_at = at;
15842
16851
  upsertPacketEdge(replacementPacket, "supersedes", oldPacket.id, trimmedReason, at);
16852
+ // Superseding resolves any recorded contradiction involving the retired
16853
+ // packet: drop the old id from every other packet's quality.contradicts, and
16854
+ // clear the old packet's own contradicts list (it is no longer live memory).
16855
+ const clearContradiction = (packet) => {
16856
+ const quality = (packet.quality ?? {});
16857
+ const existing = Array.isArray(quality.contradicts) ? quality.contradicts : [];
16858
+ if (!existing.length)
16859
+ return false;
16860
+ const next = existing.filter((id) => id !== oldPacket.id && id !== replacementPacket.id);
16861
+ if (next.length === existing.length)
16862
+ return false;
16863
+ if (next.length)
16864
+ quality.contradicts = next;
16865
+ else
16866
+ delete quality.contradicts;
16867
+ packet.quality = quality;
16868
+ packet.updated_at = at;
16869
+ return true;
16870
+ };
16871
+ clearContradiction(oldPacket);
16872
+ clearContradiction(replacementPacket);
15843
16873
  writeJson(oldEntry.path, oldPacket);
15844
16874
  writeJson(replacementEntry.path, replacementPacket);
16875
+ for (const entry of entries) {
16876
+ if (entry.packet.id === oldPacket.id || entry.packet.id === replacementPacket.id)
16877
+ continue;
16878
+ if (clearContradiction(entry.packet))
16879
+ writeJson(entry.path, entry.packet);
16880
+ }
15845
16881
  recordMemoryAudit(projectDir, "supersede", [oldPacket, replacementPacket], {
15846
16882
  old_packet_id: oldPacket.id,
15847
16883
  replacement_packet_id: replacementPacket.id,
@@ -16155,7 +17191,7 @@ function personalRecallEntries(projectDir, terms, limit = 3) {
16155
17191
  if (!packets.length)
16156
17192
  return [];
16157
17193
  const cache = new Map();
16158
- const eligible = packets.filter((packet) => recallHardStaleReason(projectDir, packet, cache) === null);
17194
+ const eligible = packets.filter((packet) => recallStaleReason(projectDir, packet, cache) === null);
16159
17195
  if (!eligible.length)
16160
17196
  return [];
16161
17197
  const scores = scorePacketsBm25(terms, eligible);
@@ -16507,3 +17543,36 @@ function syncPersonal() {
16507
17543
  result.ok = true;
16508
17544
  return result;
16509
17545
  }
17546
+ // Kage's memory is hierarchical by construction:
17547
+ // L0 raw — session observations captured by hooks (unreviewed signal)
17548
+ // L1 reviewed — verified memory packets (the trusted store)
17549
+ // L2 synthesis — generated overviews: repo maps, project profile, branch/change summaries
17550
+ // This surfaces that existing structure rather than inventing a field.
17551
+ function kageLayers(projectDir) {
17552
+ const L2_TYPES = new Set(["repo_map"]);
17553
+ const isSynthesis = (p) => {
17554
+ const q = (p.quality ?? {});
17555
+ return L2_TYPES.has(p.type) || q.candidate_kind === "change_memory";
17556
+ };
17557
+ const entries = (0, node_fs_1.existsSync)(packetsDir(projectDir)) ? loadPacketEntriesFromDir(packetsDir(projectDir)) : [];
17558
+ const l2 = entries.filter((e) => isSynthesis(e.packet));
17559
+ const l1 = entries.filter((e) => !isSynthesis(e.packet));
17560
+ let observationCount = 0;
17561
+ const obsDir = observationsDir(projectDir);
17562
+ if ((0, node_fs_1.existsSync)(obsDir)) {
17563
+ try {
17564
+ observationCount = (0, node_fs_1.readdirSync)(obsDir).filter((f) => f.endsWith(".jsonl") || f.endsWith(".json")).length;
17565
+ }
17566
+ catch {
17567
+ observationCount = 0;
17568
+ }
17569
+ }
17570
+ return {
17571
+ project_dir: projectDir,
17572
+ layers: [
17573
+ { layer: "L0", label: "Raw observations", description: "Session signal captured by hooks; unreviewed, feeds auto-distill.", count: observationCount, examples: [] },
17574
+ { layer: "L1", label: "Reviewed memory", description: "Verified packets — cited, fingerprinted, the trusted recall store.", count: l1.length, examples: l1.slice(0, 3).map((e) => e.packet.title) },
17575
+ { layer: "L2", label: "Synthesis", description: "Generated overviews: repo maps and branch/change summaries.", count: l2.length, examples: l2.slice(0, 3).map((e) => e.packet.title) },
17576
+ ],
17577
+ };
17578
+ }