@kage-core/kage-graph-mcp 2.3.0 → 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
@@ -110,6 +110,8 @@ exports.kageRisk = kageRisk;
110
110
  exports.kageDependencyPath = kageDependencyPath;
111
111
  exports.kageCleanupCandidates = kageCleanupCandidates;
112
112
  exports.truthReport = truthReport;
113
+ exports.truthScorecardSvg = truthScorecardSvg;
114
+ exports.truthScorecardMarkdown = truthScorecardMarkdown;
113
115
  exports.defaultClaudeMemStorePath = defaultClaudeMemStorePath;
114
116
  exports.claudeMemProjectKey = claudeMemProjectKey;
115
117
  exports.parseClaudeMemFileList = parseClaudeMemFileList;
@@ -180,6 +182,7 @@ exports.remediationFor = remediationFor;
180
182
  exports.approvePending = approvePending;
181
183
  exports.rejectPending = rejectPending;
182
184
  exports.changelog = changelog;
185
+ exports.generateSkills = generateSkills;
183
186
  exports.reverifyMemory = reverifyMemory;
184
187
  exports.supersedeMemory = supersedeMemory;
185
188
  exports.kageMemoryLineage = kageMemoryLineage;
@@ -284,6 +287,13 @@ If Kage appears installed but no Kage tools are available, report that the activ
284
287
  agent session has not loaded the MCP server and ask the user to restart the
285
288
  agent. After restart, call \`kage_verify_agent\` to prove the harness is live.
286
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
+
287
297
  ## Automatic Capture
288
298
 
289
299
  When you learn something reusable, create repo-local memory with \`kage_learn\`.
@@ -1422,17 +1432,26 @@ function replacementCueCount(text) {
1422
1432
  count += 1;
1423
1433
  return count;
1424
1434
  }
1425
- // The subject is the shared vocabulary that BOTH packets are talking about. We
1426
- // require the two packets to agree on a subject (token overlap in title+summary)
1427
- // but DISAGREE on polarity around it.
1428
- function sharedSubjectTokens(a, b) {
1429
- const aSubject = tokenSet(`${a.title}\n${a.summary}`);
1430
- const bSubject = tokenSet(`${b.title}\n${b.summary}`);
1431
- const shared = new Set();
1432
- for (const token of aSubject)
1433
- if (bSubject.has(token))
1434
- shared.add(token);
1435
- return shared;
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)));
1436
1455
  }
1437
1456
  // Opposing-polarity signal between two same-subject bodies. Conservative: a
1438
1457
  // contradiction needs one side to assert a claim and the other to negate or
@@ -1460,13 +1479,16 @@ function opposingPolarity(aText, bText) {
1460
1479
  // candidate and (optionally) the existing pool, performs no writes. Returns the
1461
1480
  // conflicting approved packets with the shared paths and a human reason.
1462
1481
  function detectContradictions(projectDir, candidate, opts = {}) {
1463
- const subjectThreshold = opts.subjectThreshold ?? 0.34;
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;
1464
1486
  const candidatePaths = new Set(candidate.paths.filter((path) => meaningfulMemoryPath(path) && !shouldSkipRepoMemoryPath(path)));
1465
1487
  if (!candidatePaths.size)
1466
1488
  return [];
1467
1489
  const existing = (opts.existing ?? loadApprovedPackets(projectDir)).filter((packet) => packet.id !== candidate.id && packet.status === "approved");
1468
1490
  const candidateText = normalizedClaimText(candidate);
1469
- const candidateSubject = tokenSet(`${candidate.title}\n${candidate.summary}`);
1491
+ const candidateSubject = distinctiveSubjectTokens(candidate);
1470
1492
  const findings = [];
1471
1493
  for (const packet of existing) {
1472
1494
  // (a) shared cited path — the structural anchor that makes a contradiction
@@ -1474,12 +1496,15 @@ function detectContradictions(projectDir, candidate, opts = {}) {
1474
1496
  const sharedPaths = packet.paths.filter((path) => candidatePaths.has(path));
1475
1497
  if (!sharedPaths.length)
1476
1498
  continue;
1477
- // (b) same subject high title/summary similarity. Reuses the duplicate
1478
- // similarity machinery (jaccard over token sets) so detection rides on
1479
- // the same proven scorer rather than a new bespoke metric.
1480
- const subjectSimilarity = jaccard(candidateSubject, tokenSet(`${packet.title}\n${packet.summary}`));
1481
- const subjectOverlap = sharedSubjectTokens(candidate, packet).size;
1482
- if (subjectSimilarity < subjectThreshold && subjectOverlap < 2)
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)
1483
1508
  continue;
1484
1509
  // (c) opposing polarity — the claim itself is flipped/redirected.
1485
1510
  const reason = opposingPolarity(candidateText, normalizedClaimText(packet));
@@ -1606,15 +1631,144 @@ function memoryPathFingerprint(projectDir, path, cache) {
1606
1631
  return null;
1607
1632
  }
1608
1633
  }
1609
- 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;
1610
1712
  const fingerprints = [];
1611
1713
  for (const path of unique(paths).filter(fingerprintableMemoryPath)) {
1612
1714
  const fingerprint = memoryPathFingerprint(projectDir, path);
1613
- if (fingerprint)
1614
- 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);
1615
1742
  }
1616
1743
  return fingerprints;
1617
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
+ }
1618
1772
  function packetStoredPathFingerprints(packet) {
1619
1773
  const raw = (packet.freshness ?? {}).path_fingerprints;
1620
1774
  if (!Array.isArray(raw))
@@ -1628,9 +1782,31 @@ function packetStoredPathFingerprints(packet) {
1628
1782
  const size = Number(record.size ?? 0);
1629
1783
  if (!path || !sha256 || !Number.isFinite(size) || !fingerprintableMemoryPath(path))
1630
1784
  return [];
1631
- 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 } : {}) }];
1632
1799
  });
1633
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
+ }
1634
1810
  function staleMemoryReasons(projectDir, packet, fingerprintCache) {
1635
1811
  const reasons = [];
1636
1812
  const quality = (packet.quality ?? {});
@@ -1660,11 +1836,9 @@ function staleMemoryReasons(projectDir, packet, fingerprintCache) {
1660
1836
  const storedFingerprints = packetStoredPathFingerprints(packet);
1661
1837
  const changedPaths = storedFingerprints
1662
1838
  .filter((fingerprint) => !isGroundingIgnored(projectDir, fingerprint.path))
1839
+ .filter((fingerprint) => !isAppendOnlyLedgerPath(fingerprint.path))
1663
1840
  .filter((fingerprint) => (0, node_fs_1.existsSync)((0, node_path_1.join)(projectDir, fingerprint.path)))
1664
- .filter((fingerprint) => {
1665
- const current = memoryPathFingerprint(projectDir, fingerprint.path, fingerprintCache);
1666
- return current !== null && current.sha256 !== fingerprint.sha256;
1667
- })
1841
+ .filter((fingerprint) => fingerprintPathContentChanged(projectDir, fingerprint, fingerprintCache))
1668
1842
  .map((fingerprint) => fingerprint.path);
1669
1843
  if (changedPaths.length) {
1670
1844
  reasons.push(`linked path changed since memory was verified: ${changedPaths.slice(0, 4).join(", ")}`);
@@ -1718,6 +1892,22 @@ function recallHardStaleReason(projectDir, packet, cache) {
1718
1892
  }
1719
1893
  return null;
1720
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
+ }
1721
1911
  function changedPathsFromStaleReasons(reasons) {
1722
1912
  return unique(reasons.flatMap((reason) => {
1723
1913
  const match = reason.match(/^linked path changed since memory was verified: (.+)$/);
@@ -6821,7 +7011,7 @@ function kageSuppressedMemory(projectDir) {
6821
7011
  const cache = new Map();
6822
7012
  const items = loadApprovedPackets(projectDir)
6823
7013
  .map((packet) => {
6824
- const reason = recallHardStaleReason(projectDir, packet, cache);
7014
+ const reason = recallStaleReason(projectDir, packet, cache);
6825
7015
  return reason ? { id: packet.id, title: packet.title, type: packet.type, reason, paths: packet.paths } : null;
6826
7016
  })
6827
7017
  .filter((entry) => entry !== null);
@@ -6896,7 +7086,7 @@ function compactProject(projectDir, options = {}) {
6896
7086
  paths: keptPaths,
6897
7087
  freshness: {
6898
7088
  ...(packet.freshness ?? {}),
6899
- path_fingerprints: memoryPathFingerprints(projectDir, keptPaths),
7089
+ path_fingerprints: memoryPathFingerprints(projectDir, keptPaths, `${packet.title}\n${packet.summary}\n${packet.body}`),
6900
7090
  last_verified_at: nowIso(),
6901
7091
  },
6902
7092
  updated_at: nowIso(),
@@ -7711,7 +7901,7 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
7711
7901
  const approvedPackets = includeStale
7712
7902
  ? allApprovedPackets
7713
7903
  : allApprovedPackets.filter((packet) => {
7714
- const reason = recallHardStaleReason(projectDir, packet, staleFingerprintCache);
7904
+ const reason = recallStaleReason(projectDir, packet, staleFingerprintCache);
7715
7905
  if (reason) {
7716
7906
  suppressed.push({ id: packet.id, title: packet.title, reason });
7717
7907
  return false;
@@ -7830,7 +8020,12 @@ function recallWithVectorScores(projectDir, query, limit = 5, explain = false, i
7830
8020
  graphContext.edges.length ? "## Related Graph Facts" : "",
7831
8021
  ...graphContext.edges.slice(0, 5).map((edge, index) => `${index + 1}. ${edge.fact} (evidence: ${edge.evidence.join(", ")})`),
7832
8022
  ...(suppressed.length
7833
- ? ["", `_${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
+ ]
7834
8029
  : []),
7835
8030
  ...(personalEntries.length
7836
8031
  ? [
@@ -8936,7 +9131,7 @@ function kageCleanupCandidates(projectDir) {
8936
9131
  summary: `${candidates.length} conservative cleanup candidate(s), ${skippedEntryPoints.length} entrypoint-like source file(s) skipped, ${skippedRuntimeReferences.length} runtime reference(s) skipped.`,
8937
9132
  };
8938
9133
  }
8939
- const TRUTH_REPORT_MAX_FINDINGS = 12;
9134
+ const TRUTH_REPORT_MAX_FINDINGS = 16;
8940
9135
  const TRUTH_REPORT_AI_ERA_DAYS = 120;
8941
9136
  // Symbol names too generic to mean "two teams built the same thing".
8942
9137
  const TRUTH_COMMON_SYMBOL_NAMES = new Set([
@@ -9220,6 +9415,106 @@ function truthReport(projectDir) {
9220
9415
  }
9221
9416
  }
9222
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));
9223
9518
  // 2. Doc-truth: checkable claims in README/docs vs reality.
9224
9519
  const docLieFindings = [];
9225
9520
  if (docLines.length) {
@@ -9306,19 +9601,29 @@ function truthReport(projectDir) {
9306
9601
  docLieFindings.sort((a, b) => b.surprise - a.surprise || a.title.localeCompare(b.title));
9307
9602
  // Cap per category so one noisy category cannot drown the report, then rank globally.
9308
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),
9309
9609
  ...duplicateFindings.slice(0, 4),
9310
9610
  ...ghostFindings.slice(0, 4),
9311
- ...busFindings.slice(0, 4),
9312
- ...voidFindings.slice(0, 4),
9313
9611
  ...docLieFindings.slice(0, 4),
9314
9612
  ].sort((a, b) => b.surprise - a.surprise || a.title.localeCompare(b.title)).slice(0, TRUTH_REPORT_MAX_FINDINGS);
9315
- const headlineParts = [
9316
- `${duplicateFindings.length} duplicate cluster${duplicateFindings.length === 1 ? "" : "s"}`,
9317
- `${ghostFindings.length} ghost export${ghostFindings.length === 1 ? "" : "s"}`,
9318
- `${busFindings.length} bus-factor-1 hot file${busFindings.length === 1 ? "" : "s"}`,
9319
- `${voidFindings.length} knowledge void${voidFindings.length === 1 ? "" : "s"}`,
9320
- ...(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"}`]] : []),
9321
9625
  ];
9626
+ const headlineParts = headlineCandidates.filter(([count]) => count > 0).map(([, label]) => label);
9322
9627
  return {
9323
9628
  schema_version: 1,
9324
9629
  project_dir: projectDir,
@@ -9330,6 +9635,9 @@ function truthReport(projectDir) {
9330
9635
  ghost_exports: ghostFindings.length,
9331
9636
  bus_factor_files: busFindings.length,
9332
9637
  knowledge_voids: voidFindings.length,
9638
+ untested_hot_paths: untestedFindings.length,
9639
+ complexity_hotspots: complexityFindings.length,
9640
+ debt_markers: debtFindings.length,
9333
9641
  doc_lies: docLieFindings.length,
9334
9642
  docs_scanned: docFiles.length,
9335
9643
  },
@@ -9343,6 +9651,118 @@ function truthReport(projectDir) {
9343
9651
  ],
9344
9652
  };
9345
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
+ }
9346
9766
  function defaultClaudeMemStorePath() {
9347
9767
  const dataDir = process.env.CLAUDE_MEM_DATA_DIR || (0, node_path_1.join)((0, node_os_1.homedir)(), ".claude-mem");
9348
9768
  return (0, node_path_1.join)(dataDir, "claude-mem.db");
@@ -13009,7 +13429,7 @@ function capture(input) {
13009
13429
  freshness: {
13010
13430
  ttl_days: 365,
13011
13431
  last_verified_at: createdAt,
13012
- path_fingerprints: memoryPathFingerprints(input.projectDir, groundedPaths),
13432
+ path_fingerprints: memoryPathFingerprints(input.projectDir, groundedPaths, `${input.title}\n${input.summary ?? ""}\n${input.body}`),
13013
13433
  path_fingerprint_policy: "source_hash_staleness",
13014
13434
  verification: "repo_local_agent_capture",
13015
13435
  },
@@ -13308,6 +13728,8 @@ PAYLOAD="$(cat || true)"
13308
13728
  CWD="$(printf "%s" "$PAYLOAD" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('cwd',''))" 2>/dev/null || echo "")"
13309
13729
 
13310
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"
13311
13733
  command -v kage >/dev/null 2>&1 || exit 0
13312
13734
 
13313
13735
  if git -C "$CWD" status --porcelain -uall >/dev/null 2>&1 && [[ -n "$(git -C "$CWD" status --porcelain -uall)" ]]; then
@@ -13364,6 +13786,8 @@ print(d.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or "")
13364
13786
  ' 2>/dev/null || echo "")"
13365
13787
 
13366
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"
13367
13791
  command -v kage >/dev/null 2>&1 || exit 0
13368
13792
 
13369
13793
  EVENT="$(PAYLOAD="$PAYLOAD" python3 -c 'import json, os
@@ -13490,6 +13914,8 @@ print(d.get("cwd") or os.environ.get("CLAUDE_PROJECT_DIR") or "")
13490
13914
  ' 2>/dev/null || echo "")"
13491
13915
 
13492
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"
13493
13919
  command -v kage >/dev/null 2>&1 || exit 0
13494
13920
 
13495
13921
  FILE_PATH="$(PAYLOAD="$PAYLOAD" python3 -c 'import json, os
@@ -13539,6 +13965,15 @@ fi
13539
13965
 
13540
13966
  exit 0
13541
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.");
13542
13977
  const settingsPath = (0, node_path_1.join)(home, ".claude", "settings.json");
13543
13978
  const hookEntry = {
13544
13979
  hooks: {
@@ -13548,6 +13983,9 @@ exit 0
13548
13983
  { matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 5 }] },
13549
13984
  // Verified memory at the moment of relevance: short timeout, never blocks the Read.
13550
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 }] },
13551
13989
  ],
13552
13990
  PostToolUse: [{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 5 }] }],
13553
13991
  PostToolUseFailure: [{ matcher: "", hooks: [{ type: "command", command: "bash ~/.claude/kage/hooks/observe.sh", timeout: 5 }] }],
@@ -13560,7 +13998,7 @@ exit 0
13560
13998
  setSnippet(path, JSON.stringify({ mcpServers: { kage: server } }, null, 2), [
13561
13999
  "Add the MCP server to ~/.claude.json, then restart Claude Code.",
13562
14000
  "alwaysLoad: true makes Kage tools immediately visible without requiring ToolSearch.",
13563
- `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.`,
13564
14002
  "Run `kage init --project <repo>` inside each repo to install the ambient memory policy.",
13565
14003
  ], true);
13566
14004
  if (options.write) {
@@ -13570,6 +14008,7 @@ exit 0
13570
14008
  (0, node_fs_1.writeFileSync)((0, node_path_1.join)(hookDir, "session-start.sh"), hookScript, { mode: 0o755 });
13571
14009
  (0, node_fs_1.writeFileSync)((0, node_path_1.join)(hookDir, "observe.sh"), observeHookScript, { mode: 0o755 });
13572
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 });
13573
14012
  (0, node_fs_1.writeFileSync)((0, node_path_1.join)(hookDir, "stop.sh"), stopHookScript, { mode: 0o755 });
13574
14013
  upsertJsonSettings(settingsPath, hookEntry);
13575
14014
  result.wrote = true;
@@ -15059,7 +15498,9 @@ function staleCatch(projectDir, changedFiles) {
15059
15498
  if (current === null) {
15060
15499
  invalidated.push({ packet_id: packet.id, packet_title: packet.title, cited_path: stored.path, reason: "cited file was deleted" });
15061
15500
  }
15062
- 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.
15063
15504
  invalidated.push({ packet_id: packet.id, packet_title: packet.title, cited_path: stored.path, reason: "content changed since this memory was verified" });
15064
15505
  }
15065
15506
  }
@@ -16166,6 +16607,141 @@ function packetSupersessionReason(packet) {
16166
16607
  const evidence = edge ? packetEdgeValue(edge, "evidence") : "";
16167
16608
  return evidence || "This memory was superseded by newer repo knowledge.";
16168
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
+ }
16169
16745
  // Re-verify a still-true packet in place: re-check cited paths, refresh
16170
16746
  // fingerprints and last_verified_at, and clear stale flags. The alternative to
16171
16747
  // supersede churn when code changed but the memory's claim did not. Refuses
@@ -16203,7 +16779,7 @@ function reverifyMemory(projectDir, packetId) {
16203
16779
  const presentPaths = citedPaths.filter((path) => !result.missing_paths.includes(path));
16204
16780
  const now = nowIso();
16205
16781
  const freshness = { ...(packet.freshness ?? {}) };
16206
- freshness.path_fingerprints = memoryPathFingerprints(projectDir, presentPaths);
16782
+ freshness.path_fingerprints = memoryPathFingerprints(projectDir, presentPaths, `${packet.title}\n${packet.summary}\n${packet.body}`);
16207
16783
  freshness.last_verified_at = now;
16208
16784
  const { stale: _stale, stale_reasons: _staleReasons, suggested_action: _suggestedAction, ...nextQuality } = quality;
16209
16785
  writeJson(entry.path, {
@@ -16615,7 +17191,7 @@ function personalRecallEntries(projectDir, terms, limit = 3) {
16615
17191
  if (!packets.length)
16616
17192
  return [];
16617
17193
  const cache = new Map();
16618
- const eligible = packets.filter((packet) => recallHardStaleReason(projectDir, packet, cache) === null);
17194
+ const eligible = packets.filter((packet) => recallStaleReason(projectDir, packet, cache) === null);
16619
17195
  if (!eligible.length)
16620
17196
  return [];
16621
17197
  const scores = scorePacketsBm25(terms, eligible);