@kage-core/kage-graph-mcp 1.1.26 → 1.1.28

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/README.md CHANGED
@@ -37,7 +37,8 @@ Restart your agent once after setup so MCP tools reload.
37
37
  - repo-local memory for decisions, runbooks, bug fixes, gotchas, conventions,
38
38
  and code explanations
39
39
  - a code graph for files, symbols, imports, calls, routes, tests, and packages,
40
- including generic call/test signals for non-TypeScript repos
40
+ including generic call/test signals and Python framework routes for
41
+ non-TypeScript repos
41
42
  - memory-code links so project knowledge points at the code it affects
42
43
  - decision intelligence for why-memory coverage, stale/weak packets, and
43
44
  important files that still lack linked repo knowledge
@@ -72,6 +73,7 @@ kage reviewers --project . --changed-files src/auth.ts,src/session.ts --json
72
73
  kage risk --project . --targets src/auth.ts --json
73
74
  kage learn --project . --learning "Use npm test after parser changes."
74
75
  kage refresh --project .
76
+ kage hook install --project .
75
77
  kage pr check --project .
76
78
  kage metrics --project . --json
77
79
  kage audit --project . --json
@@ -133,6 +135,7 @@ Kage is optimized so repeat work scales with changed files, not the whole repo:
133
135
  - read-only recall reuses fresh graph artifacts
134
136
  - unchanged structural file facts are reused
135
137
  - generated graphs are compact and avoid duplicated structural JSON
138
+ - optional git `post-commit` hooks keep repo memory and branch summaries current
136
139
  - generated/vendor/cache paths are ignored
137
140
  - huge files are represented safely instead of deeply expanded
138
141
  - recall builds lookup maps once per query instead of repeatedly scanning graph
package/dist/cli.js CHANGED
@@ -24,6 +24,9 @@ Usage:
24
24
  kage daemon status --project <dir> [--json]
25
25
  kage daemon doctor --project <dir> [--json]
26
26
  kage viewer --project <dir> [--port 3113]
27
+ kage hook install --project <dir> [--json]
28
+ kage hook status --project <dir> [--json]
29
+ kage hook uninstall --project <dir> [--json]
27
30
  kage refresh --project <dir> [--full] [--json]
28
31
  kage gc --project <dir> [--dry-run] [--force] [--json]
29
32
  kage pr summarize --project <dir> [--json]
@@ -319,6 +322,37 @@ async function main() {
319
322
  await (0, daemon_js_1.startViewer)(projectArg(args), { port: numberArg(args, "--port", 3113) });
320
323
  return;
321
324
  }
325
+ if (command === "hook") {
326
+ const action = args[1];
327
+ const projectDir = projectArg(args);
328
+ const result = action === "install"
329
+ ? (0, kernel_js_1.kageHookInstall)(projectDir)
330
+ : action === "status"
331
+ ? (0, kernel_js_1.kageHookStatus)(projectDir)
332
+ : action === "uninstall"
333
+ ? (0, kernel_js_1.kageHookUninstall)(projectDir)
334
+ : null;
335
+ if (!result)
336
+ usage();
337
+ if (args.includes("--json")) {
338
+ console.log(JSON.stringify(result, null, 2));
339
+ if (!result.ok)
340
+ process.exit(2);
341
+ return;
342
+ }
343
+ console.log(result.message);
344
+ if (result.hook_path)
345
+ console.log(`Hook: ${result.hook_path}`);
346
+ console.log(`Installed: ${result.installed ? "yes" : "no"}`);
347
+ console.log(`Changed: ${result.changed ? "yes" : "no"}`);
348
+ if (result.warnings.length)
349
+ console.log(`Warnings:\n${result.warnings.map((warning) => ` - ${warning}`).join("\n")}`);
350
+ if (result.errors.length)
351
+ console.log(`Errors:\n${result.errors.map((error) => ` - ${error}`).join("\n")}`);
352
+ if (!result.ok)
353
+ process.exit(2);
354
+ return;
355
+ }
322
356
  if (command === "gc") {
323
357
  const project = projectArg(args);
324
358
  const dryRun = args.includes("--dry-run");
package/dist/kernel.js CHANGED
@@ -109,6 +109,9 @@ exports.buildBranchOverlay = buildBranchOverlay;
109
109
  exports.createReviewArtifact = createReviewArtifact;
110
110
  exports.prSummarize = prSummarize;
111
111
  exports.prCheck = prCheck;
112
+ exports.kageHookStatus = kageHookStatus;
113
+ exports.kageHookInstall = kageHookInstall;
114
+ exports.kageHookUninstall = kageHookUninstall;
112
115
  exports.exportPublicBundle = exportPublicBundle;
113
116
  exports.orgStatus = orgStatus;
114
117
  exports.orgUploadPacket = orgUploadPacket;
@@ -2827,11 +2830,43 @@ function extractGenericCalls(path, text, symbols, symbolByName) {
2827
2830
  }
2828
2831
  return calls.sort((a, b) => a.line - b.line || a.to_symbol.localeCompare(b.to_symbol));
2829
2832
  }
2833
+ function offsetForLine(text, oneBasedLine) {
2834
+ if (oneBasedLine <= 1)
2835
+ return 0;
2836
+ const lines = text.split(/\r?\n/).slice(0, oneBasedLine - 1);
2837
+ return lines.join("\n").length + (lines.length ? 1 : 0);
2838
+ }
2839
+ function normalizeWebRoutePath(routePath) {
2840
+ let cleaned = routePath
2841
+ .trim()
2842
+ .replace(/^r(["'`])|(["'`])$/g, "")
2843
+ .replace(/^['"`]|['"`]$/g, "")
2844
+ .replace(/\\/g, "")
2845
+ .replace(/^\^/, "")
2846
+ .replace(/\$$/, "")
2847
+ .replace(/\{([A-Za-z_][\w]*)\}/g, ":$1")
2848
+ .replace(/<(?:(?:int|str|slug|uuid|path):)?([A-Za-z_][\w]*)>/g, ":$1")
2849
+ .replace(/\/+/g, "/");
2850
+ if (!cleaned.startsWith("/"))
2851
+ cleaned = `/${cleaned}`;
2852
+ if (cleaned.length > 1)
2853
+ cleaned = cleaned.replace(/\/$/, "");
2854
+ return cleaned || "/";
2855
+ }
2856
+ function pythonRouteFramework(text) {
2857
+ return /\bfrom\s+flask\s+import\b|\bimport\s+flask\b|\bFlask\s*\(/.test(text) ? "flask" : "fastapi";
2858
+ }
2859
+ function parsePythonMethodList(value) {
2860
+ if (!value)
2861
+ return ["GET"];
2862
+ const methods = [...value.matchAll(/["']([A-Za-z]+)["']/g)].map((match) => match[1].toUpperCase());
2863
+ return methods.length ? unique(methods) : ["GET"];
2864
+ }
2830
2865
  function extractRoutes(path, text, symbols) {
2831
2866
  const routes = [];
2832
2867
  const addRoute = (method, routePath, offset, framework, handler = null) => {
2833
2868
  const line = lineForOffset(text, offset);
2834
- const cleanRoutePath = routePath.replace(/\\/g, "");
2869
+ const cleanRoutePath = normalizeWebRoutePath(routePath);
2835
2870
  const containing = handler ? symbols.find((symbol) => symbol.path === path && symbol.name === handler) : symbolAtLine(symbols, path, line);
2836
2871
  routes.push({
2837
2872
  id: routeId(path, method, cleanRoutePath, line),
@@ -2853,6 +2888,34 @@ function extractRoutes(path, text, symbols) {
2853
2888
  const routeMatch = text.match(new RegExp(`const\\s+${match[2]}Match\\s*=\\s*url\\.pathname\\.match\\(\\s*/\\^\\\\/([^/]+)[^/]*`));
2854
2889
  addRoute(match[1], routeMatch ? `/${routeMatch[1]}/:id` : "/:dynamic", match.index ?? 0, "node-http");
2855
2890
  }
2891
+ if (extensionOf(path) === ".py") {
2892
+ const lines = text.split(/\r?\n/);
2893
+ const framework = pythonRouteFramework(text);
2894
+ for (let index = 0; index < lines.length; index += 1) {
2895
+ const line = lines[index];
2896
+ const decorator = line.match(/^\s*@(?:\w+\.)?(get|post|put|patch|delete|options|head)\s*\(\s*["']([^"']+)["']/i);
2897
+ if (decorator) {
2898
+ const handlerLine = lines.slice(index + 1, Math.min(lines.length, index + 7)).find((candidate) => /^\s*(?:async\s+)?def\s+[A-Za-z_][\w]*\s*\(/.test(candidate));
2899
+ const handler = handlerLine?.match(/^\s*(?:async\s+)?def\s+([A-Za-z_][\w]*)\s*\(/)?.[1] ?? null;
2900
+ addRoute(decorator[1].toUpperCase(), decorator[2], offsetForLine(text, index + 1), framework, handler);
2901
+ continue;
2902
+ }
2903
+ const flaskRoute = line.match(/^\s*@(?:\w+\.)?route\s*\(\s*["']([^"']+)["']/i);
2904
+ if (flaskRoute) {
2905
+ const handlerLine = lines.slice(index + 1, Math.min(lines.length, index + 7)).find((candidate) => /^\s*(?:async\s+)?def\s+[A-Za-z_][\w]*\s*\(/.test(candidate));
2906
+ const handler = handlerLine?.match(/^\s*(?:async\s+)?def\s+([A-Za-z_][\w]*)\s*\(/)?.[1] ?? null;
2907
+ const methods = line.match(/methods\s*=\s*\[([^\]]+)\]/i)?.[1];
2908
+ for (const method of parsePythonMethodList(methods))
2909
+ addRoute(method, flaskRoute[1], offsetForLine(text, index + 1), "flask", handler);
2910
+ continue;
2911
+ }
2912
+ const djangoPath = line.match(/\b(?:path|re_path)\s*\(\s*r?["']([^"']+)["']\s*,\s*([A-Za-z_][\w.]+)/);
2913
+ if (djangoPath) {
2914
+ const handler = djangoPath[2].split(".").pop() ?? null;
2915
+ addRoute("ANY", djangoPath[1], offsetForLine(text, index + 1), "django", handler);
2916
+ }
2917
+ }
2918
+ }
2856
2919
  if (/app\/api\//.test(path)) {
2857
2920
  for (const symbol of symbols.filter((symbol) => symbol.path === path && symbol.export && ["GET", "POST", "PUT", "PATCH", "DELETE"].includes(symbol.name))) {
2858
2921
  const apiPath = `/${path.replace(/^.*app\/api\//, "").replace(/\/route\.[cm]?[jt]sx?$/, "").replace(/\[([^\]]+)\]/g, ":$1")}`;
@@ -8217,6 +8280,156 @@ function prCheck(projectDir) {
8217
8280
  required_actions: requiredActions,
8218
8281
  };
8219
8282
  }
8283
+ const KAGE_POST_COMMIT_HOOK_START = "# >>> KAGE_POST_COMMIT_HOOK_V1";
8284
+ const KAGE_POST_COMMIT_HOOK_END = "# <<< KAGE_POST_COMMIT_HOOK_V1";
8285
+ function regexpEscape(value) {
8286
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
8287
+ }
8288
+ function shellQuote(value) {
8289
+ return `'${value.replace(/'/g, "'\"'\"'")}'`;
8290
+ }
8291
+ function gitHookPath(projectDir) {
8292
+ const raw = readGit(projectDir, ["rev-parse", "--git-path", "hooks/post-commit"]);
8293
+ if (!raw)
8294
+ return null;
8295
+ return (0, node_path_1.resolve)(projectDir, raw);
8296
+ }
8297
+ function hasKageHookBlock(content) {
8298
+ return content.includes(KAGE_POST_COMMIT_HOOK_START) && content.includes(KAGE_POST_COMMIT_HOOK_END);
8299
+ }
8300
+ function stripKageHookBlock(content) {
8301
+ const pattern = new RegExp(`\\n?${regexpEscape(KAGE_POST_COMMIT_HOOK_START)}[\\s\\S]*?${regexpEscape(KAGE_POST_COMMIT_HOOK_END)}\\n?`, "g");
8302
+ const stripped = content.replace(pattern, "\n").replace(/\n{3,}/g, "\n\n").trimEnd();
8303
+ return stripped ? `${stripped}\n` : "";
8304
+ }
8305
+ function kagePostCommitHookBlock(projectDir) {
8306
+ const project = shellQuote((0, node_path_1.resolve)(projectDir));
8307
+ return [
8308
+ KAGE_POST_COMMIT_HOOK_START,
8309
+ "# Kage post-commit hook: keep repo memory and review summary current.",
8310
+ "# Set KAGE_SKIP_HOOK=1 to bypass, or KAGE_BIN=/path/to/kage to override.",
8311
+ "if [ \"${KAGE_SKIP_HOOK:-0}\" != \"1\" ]; then",
8312
+ " KAGE_BIN=\"${KAGE_BIN:-kage}\"",
8313
+ " if command -v \"$KAGE_BIN\" >/dev/null 2>&1; then",
8314
+ " (",
8315
+ ` "$KAGE_BIN" refresh --project ${project} --json >/dev/null 2>&1 || true`,
8316
+ ` "$KAGE_BIN" pr summarize --project ${project} --json >/dev/null 2>&1 || true`,
8317
+ " ) &",
8318
+ " fi",
8319
+ "fi",
8320
+ KAGE_POST_COMMIT_HOOK_END,
8321
+ ].join("\n");
8322
+ }
8323
+ function kageHookStatus(projectDir) {
8324
+ const hookPath = gitHookPath(projectDir);
8325
+ if (!hookPath) {
8326
+ return {
8327
+ ok: false,
8328
+ action: "status",
8329
+ project_dir: projectDir,
8330
+ hook_path: null,
8331
+ installed: false,
8332
+ changed: false,
8333
+ message: "Not a git repository or git is unavailable.",
8334
+ errors: ["Not a git repository or git is unavailable."],
8335
+ warnings: [],
8336
+ };
8337
+ }
8338
+ const content = safeReadText(hookPath) ?? "";
8339
+ const installed = hasKageHookBlock(content);
8340
+ return {
8341
+ ok: true,
8342
+ action: "status",
8343
+ project_dir: projectDir,
8344
+ hook_path: hookPath,
8345
+ installed,
8346
+ changed: false,
8347
+ message: installed ? "Kage post-commit hook is installed." : "Kage post-commit hook is not installed.",
8348
+ errors: [],
8349
+ warnings: (0, node_fs_1.existsSync)(hookPath) ? [] : ["No post-commit hook file exists yet."],
8350
+ };
8351
+ }
8352
+ function kageHookInstall(projectDir) {
8353
+ const hookPath = gitHookPath(projectDir);
8354
+ if (!hookPath) {
8355
+ return {
8356
+ ok: false,
8357
+ action: "install",
8358
+ project_dir: projectDir,
8359
+ hook_path: null,
8360
+ installed: false,
8361
+ changed: false,
8362
+ message: "Not a git repository or git is unavailable.",
8363
+ errors: ["Not a git repository or git is unavailable."],
8364
+ warnings: [],
8365
+ };
8366
+ }
8367
+ ensureDir((0, node_path_1.dirname)(hookPath));
8368
+ const existing = safeReadText(hookPath) ?? "";
8369
+ const base = stripKageHookBlock(existing);
8370
+ const prefix = base.trim() ? base.trimEnd() : "#!/bin/sh";
8371
+ const next = `${prefix}\n\n${kagePostCommitHookBlock(projectDir)}\n`;
8372
+ const changed = existing !== next;
8373
+ if (changed)
8374
+ (0, node_fs_1.writeFileSync)(hookPath, next, "utf8");
8375
+ (0, node_fs_1.chmodSync)(hookPath, 0o755);
8376
+ return {
8377
+ ok: true,
8378
+ action: "install",
8379
+ project_dir: projectDir,
8380
+ hook_path: hookPath,
8381
+ installed: true,
8382
+ changed,
8383
+ message: changed ? "Installed Kage post-commit hook." : "Kage post-commit hook is already current.",
8384
+ errors: [],
8385
+ warnings: [],
8386
+ };
8387
+ }
8388
+ function kageHookUninstall(projectDir) {
8389
+ const hookPath = gitHookPath(projectDir);
8390
+ if (!hookPath) {
8391
+ return {
8392
+ ok: false,
8393
+ action: "uninstall",
8394
+ project_dir: projectDir,
8395
+ hook_path: null,
8396
+ installed: false,
8397
+ changed: false,
8398
+ message: "Not a git repository or git is unavailable.",
8399
+ errors: ["Not a git repository or git is unavailable."],
8400
+ warnings: [],
8401
+ };
8402
+ }
8403
+ const existing = safeReadText(hookPath) ?? "";
8404
+ const installed = hasKageHookBlock(existing);
8405
+ if (!installed) {
8406
+ return {
8407
+ ok: true,
8408
+ action: "uninstall",
8409
+ project_dir: projectDir,
8410
+ hook_path: hookPath,
8411
+ installed: false,
8412
+ changed: false,
8413
+ message: "Kage post-commit hook was not installed.",
8414
+ errors: [],
8415
+ warnings: (0, node_fs_1.existsSync)(hookPath) ? [] : ["No post-commit hook file exists."],
8416
+ };
8417
+ }
8418
+ const next = stripKageHookBlock(existing);
8419
+ (0, node_fs_1.writeFileSync)(hookPath, next, "utf8");
8420
+ (0, node_fs_1.chmodSync)(hookPath, 0o755);
8421
+ return {
8422
+ ok: true,
8423
+ action: "uninstall",
8424
+ project_dir: projectDir,
8425
+ hook_path: hookPath,
8426
+ installed: false,
8427
+ changed: true,
8428
+ message: "Removed Kage post-commit hook.",
8429
+ errors: [],
8430
+ warnings: [],
8431
+ };
8432
+ }
8220
8433
  function exportPublicBundle(projectDir) {
8221
8434
  ensureMemoryDirs(projectDir);
8222
8435
  const candidates = loadPacketsFromDir(publicCandidatesDir(projectDir));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kage-core/kage-graph-mcp",
3
- "version": "1.1.26",
3
+ "version": "1.1.28",
4
4
  "description": "Local-first repo memory, code graph, and recall MCP server for coding agents",
5
5
  "main": "dist/index.js",
6
6
  "files": [
package/viewer/app.js CHANGED
@@ -2252,6 +2252,257 @@
2252
2252
  });
2253
2253
  els.intelligenceList.appendChild(item);
2254
2254
  });
2255
+ var sections = buildIntelligenceSections(reports);
2256
+ if (sections.length) {
2257
+ var grid = document.createElement("div");
2258
+ grid.className = "intel-deep-grid";
2259
+ sections.forEach(function (section) {
2260
+ grid.appendChild(renderIntelligenceSection(section));
2261
+ });
2262
+ els.intelligenceList.appendChild(grid);
2263
+ }
2264
+ }
2265
+
2266
+ function renderIntelligenceSection(section) {
2267
+ var panel = document.createElement("article");
2268
+ panel.className = "intel-section";
2269
+ var header = document.createElement("div");
2270
+ header.className = "intel-section-header";
2271
+ header.innerHTML = "<div><h3></h3><span></span></div><strong></strong>";
2272
+ header.querySelector("h3").textContent = section.title;
2273
+ header.querySelector("span").textContent = section.kicker || "";
2274
+ header.querySelector("strong").textContent = section.stat || "";
2275
+ panel.appendChild(header);
2276
+ if (section.summary) {
2277
+ var summary = document.createElement("p");
2278
+ summary.className = "intel-section-summary";
2279
+ summary.textContent = section.summary;
2280
+ panel.appendChild(summary);
2281
+ }
2282
+ var list = document.createElement("div");
2283
+ list.className = "intel-section-list";
2284
+ section.rows.slice(0, section.limit || 8).forEach(function (row) {
2285
+ var button = document.createElement("button");
2286
+ button.type = "button";
2287
+ button.className = classNames("intel-row", row.status && "intel-row-" + safeCssName(row.status), row.path && "clickable");
2288
+ button.innerHTML = [
2289
+ "<span class=\"intel-row-main\"><strong></strong><em></em></span>",
2290
+ "<span class=\"intel-row-meta\"></span>",
2291
+ "<span class=\"intel-row-bar\"><i></i></span>"
2292
+ ].join("");
2293
+ button.querySelector("strong").textContent = row.label || "";
2294
+ button.querySelector("em").textContent = row.value || "";
2295
+ button.querySelector(".intel-row-meta").textContent = row.meta || "";
2296
+ button.querySelector(".intel-row-bar i").style.width = clamp(Number(row.score || 0), 4, 100) + "%";
2297
+ if (row.path) {
2298
+ button.title = "Focus " + row.path + " in the graph";
2299
+ button.addEventListener("click", function () {
2300
+ focusGraphPath(row.path);
2301
+ });
2302
+ } else {
2303
+ button.disabled = true;
2304
+ }
2305
+ list.appendChild(button);
2306
+ });
2307
+ panel.appendChild(list);
2308
+ return panel;
2309
+ }
2310
+
2311
+ function buildIntelligenceSections(reports) {
2312
+ var sections = [];
2313
+ var contributors = reports.contributors;
2314
+ var risk = reports.risk;
2315
+ var decisions = reports.decisions;
2316
+ var health = reports.moduleHealth;
2317
+ var insights = reports.graphInsights;
2318
+
2319
+ if (contributors || risk) {
2320
+ var profiles = contributors && Array.isArray(contributors.contributors) ? contributors.contributors : [];
2321
+ var silos = risk && Array.isArray(risk.ownership_silos) ? risk.ownership_silos : [];
2322
+ var maxOwned = Math.max(1, profiles.reduce(function (max, profile) {
2323
+ return Math.max(max, Number(profile.primary_owned_files || 0));
2324
+ }, 0));
2325
+ var rows = profiles.slice(0, 6).map(function (profile) {
2326
+ var owned = Number(profile.primary_owned_files || 0);
2327
+ return {
2328
+ label: shortContributor(profile.contributor),
2329
+ value: owned + " owned",
2330
+ meta: profile.commits_90d + " commits in 90d, " + (profile.silo_files ? profile.silo_files.length : 0) + " silo files",
2331
+ score: owned / maxOwned * 100,
2332
+ status: owned > 0 && profile.silo_files && profile.silo_files.length ? "warn" : "ok",
2333
+ };
2334
+ }).concat(silos.slice(0, 4).map(function (silo) {
2335
+ return {
2336
+ label: silo.file_path,
2337
+ value: Math.round(Number(silo.primary_owner_pct || 0) * 100) + "% owner",
2338
+ meta: shortContributor(silo.primary_owner || "unknown") + ", " + (silo.commit_count_total || 0) + " commits",
2339
+ score: Number(silo.primary_owner_pct || 0) * 100,
2340
+ status: "warn",
2341
+ path: silo.file_path,
2342
+ };
2343
+ }));
2344
+ if (rows.length) {
2345
+ sections.push({
2346
+ title: "Ownership Map",
2347
+ kicker: "who owns what",
2348
+ stat: silos.length + " silos",
2349
+ summary: "Repowise has a dedicated ownership page. Kage now surfaces the same reviewer-critical signal inside the memory viewer, tied back to selectable files.",
2350
+ rows: rows,
2351
+ limit: 10,
2352
+ });
2353
+ }
2354
+ }
2355
+
2356
+ if (health && Array.isArray(health.modules)) {
2357
+ var modules = health.modules.slice().sort(function (a, b) {
2358
+ return Number(a.score || 0) - Number(b.score || 0) || String(a.module).localeCompare(String(b.module));
2359
+ });
2360
+ sections.push({
2361
+ title: "Module Health Map",
2362
+ kicker: "churn / tests / ownership",
2363
+ stat: modules.length + " modules",
2364
+ summary: "Lowest-scoring modules are shown first so the viewer points people toward the riskiest areas instead of only drawing nodes.",
2365
+ rows: modules.slice(0, 8).map(function (item) {
2366
+ return {
2367
+ label: item.module,
2368
+ value: item.grade + " / " + item.score,
2369
+ meta: Array.isArray(item.reasons) ? item.reasons.slice(0, 2).join("; ") : "",
2370
+ score: Number(item.score || 0),
2371
+ status: Number(item.score || 0) < 60 ? "danger" : Number(item.score || 0) < 75 ? "warn" : "ok",
2372
+ };
2373
+ }),
2374
+ });
2375
+ }
2376
+
2377
+ if (decisions && Array.isArray(decisions.coverage_gaps)) {
2378
+ var gaps = decisions.coverage_gaps;
2379
+ sections.push({
2380
+ title: "Onboarding Targets",
2381
+ kicker: "missing repo lore",
2382
+ stat: (decisions.coverage_percent != null ? decisions.coverage_percent + "%" : "n/a"),
2383
+ summary: "Files with centrality, churn, test gaps, or ownership but no linked why-memory. These are the places a future agent is most likely to rediscover context.",
2384
+ rows: gaps.slice(0, 8).map(function (gap) {
2385
+ var score = Math.min(100, Number(gap.dependents || 0) * 18 + Number(gap.churn_90d || 0) * 6 + 12);
2386
+ return {
2387
+ label: gap.path,
2388
+ value: "needs memory",
2389
+ meta: gap.reason || "",
2390
+ score: score,
2391
+ status: "warn",
2392
+ path: gap.path,
2393
+ };
2394
+ }),
2395
+ });
2396
+ }
2397
+
2398
+ if (insights && Array.isArray(insights.communities)) {
2399
+ var communities = insights.communities.slice().sort(function (a, b) {
2400
+ return (b.files ? b.files.length : 0) - (a.files ? a.files.length : 0);
2401
+ });
2402
+ var maxFiles = Math.max(1, communities.reduce(function (max, community) {
2403
+ return Math.max(max, community.files ? community.files.length : 0);
2404
+ }, 0));
2405
+ sections.push({
2406
+ title: "Architecture Communities",
2407
+ kicker: "module clusters",
2408
+ stat: communities.length + " clusters",
2409
+ summary: "Repowise exposes architecture/community views. Kage can show the same high-level clusters next to the memory-code graph so users know what a dense graph means.",
2410
+ rows: communities.slice(0, 8).map(function (community) {
2411
+ var files = community.files || [];
2412
+ var entrypoints = community.entrypoints || [];
2413
+ var routes = community.routes || [];
2414
+ return {
2415
+ label: community.label || ("community " + community.id),
2416
+ value: files.length + " files",
2417
+ meta: entrypoints.length + " entrypoints, " + routes.length + " routes",
2418
+ score: files.length / maxFiles * 100,
2419
+ status: routes.length || entrypoints.length ? "ok" : "",
2420
+ path: files[0],
2421
+ };
2422
+ }),
2423
+ });
2424
+ }
2425
+
2426
+ if (insights && Array.isArray(insights.entry_flows) && insights.entry_flows.length) {
2427
+ sections.push({
2428
+ title: "Execution Flows",
2429
+ kicker: "entrypoint traces",
2430
+ stat: insights.entry_flows.length + " flows",
2431
+ summary: "Short traces make the code graph explainable: where execution starts, what it crosses, and which file to inspect first.",
2432
+ rows: insights.entry_flows.slice(0, 8).map(function (flow) {
2433
+ var path = flow.path || [];
2434
+ return {
2435
+ label: flow.entry,
2436
+ value: Math.max(0, path.length - 1) + " hops",
2437
+ meta: path.slice(0, 5).join(" -> "),
2438
+ score: Math.min(100, Math.max(12, path.length * 18)),
2439
+ status: "ok",
2440
+ path: flow.entry,
2441
+ };
2442
+ }),
2443
+ });
2444
+ }
2445
+
2446
+ if (risk) {
2447
+ var targets = Array.isArray(risk.targets) ? risk.targets : Object.keys(risk.targets || {}).map(function (key) { return risk.targets[key]; });
2448
+ var hotspots = Array.isArray(risk.global_hotspots) ? risk.global_hotspots : [];
2449
+ var riskRows = targets.slice(0, 6).map(function (item) {
2450
+ return {
2451
+ label: item.target,
2452
+ value: item.risk_type || "risk",
2453
+ meta: item.risk_summary || "",
2454
+ score: Math.round(Number(item.hotspot_score || 0) * 100) || Math.min(100, Number(item.dependents_count || 0) * 12 + (item.test_gap ? 24 : 0)),
2455
+ status: item.test_gap || item.risk_type === "single-owner" ? "warn" : "",
2456
+ path: item.target,
2457
+ };
2458
+ }).concat(hotspots.slice(0, 4).map(function (hotspot) {
2459
+ return {
2460
+ label: hotspot.file_path,
2461
+ value: Math.round(Number(hotspot.hotspot_score || 0) * 100) + "% hot",
2462
+ meta: (hotspot.commit_count_90d || 0) + " commits in 90d, owner " + shortContributor(hotspot.primary_owner || "unknown"),
2463
+ score: Math.round(Number(hotspot.hotspot_score || 0) * 100),
2464
+ status: "danger",
2465
+ path: hotspot.file_path,
2466
+ };
2467
+ }));
2468
+ if (riskRows.length) {
2469
+ sections.push({
2470
+ title: "Blast Radius",
2471
+ kicker: "change impact",
2472
+ stat: riskRows.length + " signals",
2473
+ summary: "Change risk is useful only if it is browsable. Rows focus the graph on affected files when the graph node exists.",
2474
+ rows: riskRows,
2475
+ limit: 10,
2476
+ });
2477
+ }
2478
+ }
2479
+
2480
+ return sections.filter(function (section) { return section.rows && section.rows.length; });
2481
+ }
2482
+
2483
+ function focusGraphPath(path) {
2484
+ if (!path) return;
2485
+ var normalized = String(path).replace(/\\/g, "/").replace(/^\.\//, "");
2486
+ var found = state.entities.find(function (entity) {
2487
+ var entityPath = String(entity.path || "").replace(/\\/g, "/").replace(/^\.\//, "");
2488
+ return entityPath === normalized || entity.id === normalized || entity.id === "code:file:" + normalized;
2489
+ }) || state.entities.find(function (entity) {
2490
+ var text = [entity.id, entity.path, entity.name].filter(Boolean).join(" ");
2491
+ return text.indexOf(normalized) !== -1;
2492
+ });
2493
+ if (!found) {
2494
+ els.searchInput.value = normalized;
2495
+ scheduleRender();
2496
+ return;
2497
+ }
2498
+ state.selected = { kind: "entity", id: found.id };
2499
+ els.searchInput.value = normalized;
2500
+ scheduleRender();
2501
+ }
2502
+
2503
+ function shortContributor(value) {
2504
+ var text = String(value || "unknown");
2505
+ return text.replace(/\s*<[^>]+>\s*$/, "");
2255
2506
  }
2256
2507
 
2257
2508
  function buildIntelligenceCards(reports) {
package/viewer/index.html CHANGED
@@ -4,7 +4,7 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Kage Memory Terminal</title>
7
- <link rel="stylesheet" href="./styles.css?v=20">
7
+ <link rel="stylesheet" href="./styles.css?v=21">
8
8
  </head>
9
9
  <body>
10
10
  <header class="app-header">
@@ -171,6 +171,6 @@
171
171
  </section>
172
172
  </main>
173
173
 
174
- <script src="./app.js?v=20"></script>
174
+ <script src="./app.js?v=21"></script>
175
175
  </body>
176
176
  </html>
package/viewer/styles.css CHANGED
@@ -608,6 +608,12 @@ input:focus, select:focus, button:focus, .file-picker:focus-within {
608
608
  gap: 8px;
609
609
  align-content: start;
610
610
  }
611
+ .intel-deep-grid {
612
+ grid-column: 1 / -1;
613
+ display: grid;
614
+ grid-template-columns: repeat(3, minmax(0, 1fr));
615
+ gap: 8px;
616
+ }
611
617
  .intel-card {
612
618
  min-height: 160px;
613
619
  padding: 11px;
@@ -648,6 +654,114 @@ input:focus, select:focus, button:focus, .file-picker:focus-within {
648
654
  overflow-wrap: anywhere;
649
655
  }
650
656
  .intel-card strong { color: var(--terminal-strong); }
657
+ .intel-section {
658
+ min-height: 230px;
659
+ padding: 11px;
660
+ border: 1px solid rgba(65, 255, 143, 0.24);
661
+ border-radius: 4px;
662
+ background: linear-gradient(180deg, rgba(13, 25, 19, 0.96), rgba(5, 11, 8, 0.96));
663
+ overflow: hidden;
664
+ }
665
+ .intel-section-header {
666
+ display: flex;
667
+ align-items: flex-start;
668
+ justify-content: space-between;
669
+ gap: 10px;
670
+ }
671
+ .intel-section-header h3 {
672
+ margin: 0;
673
+ color: var(--terminal);
674
+ font-size: 12px;
675
+ letter-spacing: 0;
676
+ }
677
+ .intel-section-header span {
678
+ display: block;
679
+ margin-top: 3px;
680
+ color: var(--terminal-dim);
681
+ font-size: 10px;
682
+ font-weight: 760;
683
+ text-transform: uppercase;
684
+ }
685
+ .intel-section-header strong {
686
+ flex: 0 0 auto;
687
+ color: var(--terminal-strong);
688
+ font-size: 11px;
689
+ }
690
+ .intel-section-summary {
691
+ margin: 8px 0 10px;
692
+ color: var(--muted);
693
+ font-size: 11px;
694
+ overflow-wrap: anywhere;
695
+ }
696
+ .intel-section-list {
697
+ display: grid;
698
+ gap: 7px;
699
+ }
700
+ .intel-row {
701
+ width: 100%;
702
+ display: grid;
703
+ grid-template-columns: minmax(0, 1fr);
704
+ gap: 5px;
705
+ min-height: 0;
706
+ padding: 8px;
707
+ border-color: var(--line);
708
+ background: rgba(3, 6, 4, 0.55);
709
+ color: var(--text);
710
+ text-align: left;
711
+ white-space: normal;
712
+ box-shadow: none;
713
+ cursor: default;
714
+ }
715
+ .intel-row.clickable { cursor: pointer; }
716
+ .intel-row.clickable:hover {
717
+ border-color: var(--terminal-strong);
718
+ background: rgba(65, 255, 143, 0.075);
719
+ }
720
+ .intel-row-main {
721
+ display: flex;
722
+ align-items: baseline;
723
+ justify-content: space-between;
724
+ gap: 8px;
725
+ min-width: 0;
726
+ }
727
+ .intel-row-main strong {
728
+ min-width: 0;
729
+ color: var(--text);
730
+ font-size: 11px;
731
+ overflow: hidden;
732
+ text-overflow: ellipsis;
733
+ white-space: nowrap;
734
+ }
735
+ .intel-row-main em {
736
+ flex: 0 0 auto;
737
+ color: var(--terminal-strong);
738
+ font-size: 10px;
739
+ font-style: normal;
740
+ }
741
+ .intel-row-meta {
742
+ color: var(--terminal-dim);
743
+ font-size: 10px;
744
+ overflow: hidden;
745
+ text-overflow: ellipsis;
746
+ white-space: nowrap;
747
+ }
748
+ .intel-row-bar {
749
+ display: block;
750
+ height: 4px;
751
+ overflow: hidden;
752
+ border-radius: 999px;
753
+ background: rgba(65, 255, 143, 0.10);
754
+ }
755
+ .intel-row-bar i {
756
+ display: block;
757
+ height: 100%;
758
+ border-radius: inherit;
759
+ background: var(--terminal-strong);
760
+ }
761
+ .intel-row-warn .intel-row-main em,
762
+ .intel-row-warn .intel-row-bar i { color: var(--warn); background: var(--warn); }
763
+ .intel-row-danger .intel-row-main em,
764
+ .intel-row-danger .intel-row-bar i { color: var(--danger); background: var(--danger); }
651
765
  .review-item {
652
766
  padding: 10px 0;
653
767
  border-top: 1px solid var(--line);
@@ -821,6 +935,8 @@ input:focus, select:focus, button:focus, .file-picker:focus-within {
821
935
  .control-panel label, .control-panel button { margin-top: 0; }
822
936
  .graph-panel { min-height: 620px; }
823
937
  #graphCanvas, .three-graph, .graph-canvas-wrap, #graphSvg { min-height: 560px; }
938
+ .intelligence-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
939
+ .intel-deep-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
824
940
  .details-panel {
825
941
  height: auto;
826
942
  max-height: 70vh;
@@ -835,6 +951,7 @@ input:focus, select:focus, button:focus, .file-picker:focus-within {
835
951
  .metrics-grid { grid-template-columns: 1fr 1fr; }
836
952
  .proof-list { grid-template-columns: 1fr 1fr; }
837
953
  .intelligence-list { grid-template-columns: 1fr; }
954
+ .intel-deep-grid { grid-template-columns: 1fr; }
838
955
  .graph-toolbar { align-items: flex-start; flex-direction: column; }
839
956
  .graph-actions { width: 100%; }
840
957
  .interaction-hint { flex: 1; white-space: normal; }