claude-code-memory-explorer 0.11.0 → 0.11.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/public/app.js CHANGED
@@ -4,6 +4,7 @@ let projectData = null;
4
4
  let stackData = [];
5
5
  let summaryData = null;
6
6
  let selectedFileId = null;
7
+ let healthViewOpen = true; // health dashboard is the default view; selecting a file exits it
7
8
 
8
9
  // #endregion STATE
9
10
 
@@ -304,10 +305,11 @@ function toggleHelpModal() {
304
305
  document.getElementById('helpModal').classList.toggle('open');
305
306
  }
306
307
 
307
- // biome-ignore lint/correctness/noUnusedVariables: called from topbar markup
308
- function openAnalysisModal() {
309
- document.getElementById('analysisModal').classList.add('open');
310
- refreshAnalysis();
308
+ function openHealthView() {
309
+ healthViewOpen = !healthViewOpen;
310
+ document.getElementById('analyzeBtn')?.classList.toggle('active', healthViewOpen);
311
+ renderPreview();
312
+ if (healthViewOpen) refreshAnalysis();
311
313
  }
312
314
 
313
315
  function bindModalKeys(inputId, modalId, submitFn) {
@@ -342,6 +344,7 @@ const LOAD_ICONS = {
342
344
  startup: '\u25D2',
343
345
  conditional: '\u25CB',
344
346
  ondemand: '\u25CC',
347
+ tree: '\u25CE',
345
348
  import: '@',
346
349
  link: '\u2197',
347
350
  };
@@ -350,6 +353,7 @@ const LOAD_TITLES = {
350
353
  startup: 'Loaded at startup (partial)',
351
354
  conditional: 'Conditional (path-scoped)',
352
355
  ondemand: 'On-demand',
356
+ tree: 'Loaded progressively (when Claude works in its directory)',
353
357
  import: 'Imported by parent file',
354
358
  link: 'Referenced via markdown link (not auto-loaded)',
355
359
  };
@@ -397,7 +401,8 @@ function renderTree() {
397
401
  const loadIcon = LOAD_ICONS[item.load] || '';
398
402
  const loadTitle = LOAD_TITLES[item.load] || item.load;
399
403
  const meta = `${item.lines}L`;
400
- const isConditional = item.load === 'conditional' || item.load === 'ondemand' || item.load === 'link';
404
+ const isConditional =
405
+ item.load === 'conditional' || item.load === 'ondemand' || item.load === 'link' || item.load === 'tree';
401
406
  const pad = indent ? ` style="padding-left:${12 + indent * 16}px"` : '';
402
407
  const muted = item.scope === 'agent-memory' ? ' tree-agent-item' : '';
403
408
  const children = childrenOf[item.id];
@@ -405,10 +410,19 @@ function renderTree() {
405
410
  const chevron = children
406
411
  ? `<span class="tree-chevron" onclick="_toggleItem(event,'${escAttrJs(item.id)}')" title="${children.length} referenced">${expanded ? '▾' : '▸'}</span>`
407
412
  : '<span class="tree-chevron-spacer"></span>';
408
- let h = `<div class="tree-item${sel}${indent ? ' tree-child' : ''}${isConditional ? ' tree-conditional' : ''}${muted}" data-id="${esc(item.id)}" title="${esc(item.path)}" onclick="selectFile('${escAttrJs(item.id)}')"${pad}>`;
413
+ const fp = footprintHighlight && footprintHit(item) ? ' fp-hit' : '';
414
+ let h = `<div class="tree-item${sel}${indent ? ' tree-child' : ''}${isConditional ? ' tree-conditional' : ''}${muted}${fp}" data-id="${esc(item.id)}" title="${esc(item.path)}" onclick="selectFile('${escAttrJs(item.id)}')"${pad}>`;
409
415
  h += chevron;
410
416
  h += `<span class="load-icon" title="${loadTitle}" style="color:var(--scope-${item.scope})">${loadIcon}</span>`;
411
417
  h += `<span class="file-name">${esc(item.name)}</span>`;
418
+ const hs = treeHealth.get(item.id);
419
+ if (hs) {
420
+ const sev = hs.high ? 'high' : hs.med ? 'med' : 'low';
421
+ const n = hs.high || hs.med || hs.low;
422
+ h += `<span class="tree-health th-${esc(sev)}" title="${esc(hs.high)} high · ${esc(hs.med)} med · ${esc(hs.low)} low — open Health for details">${n}</span>`;
423
+ } else if (auditedIds.has(item.id)) {
424
+ h += '<span class="tree-health th-clean" title="Audited — no findings"></span>';
425
+ }
412
426
  h += `<span class="file-meta">${meta}</span>`;
413
427
  h += '</div>';
414
428
  if (expanded) {
@@ -509,6 +523,10 @@ function pushFileState(id) {
509
523
  }
510
524
 
511
525
  function selectFile(id, pushState = true) {
526
+ if (healthViewOpen) {
527
+ healthViewOpen = false;
528
+ document.getElementById('analyzeBtn')?.classList.remove('active');
529
+ }
512
530
  selectedFileId = selectedFileId === id ? null : id;
513
531
  if (selectedFileId) {
514
532
  const item = stackData.find((s) => s.id === selectedFileId);
@@ -644,6 +662,12 @@ function renderMemoryIndexTable(entries) {
644
662
 
645
663
  async function renderPreview() {
646
664
  const panel = document.getElementById('previewPanel');
665
+ if (healthViewOpen) {
666
+ document.getElementById('analyzeBtn')?.classList.add('active');
667
+ panel.innerHTML = '<div class="health-view"><div id="analysisSection"></div></div>';
668
+ if (lastAnalysisSt) rerenderAnalysis();
669
+ return;
670
+ }
647
671
  const source = stackData.find((s) => s.id === selectedFileId);
648
672
  if (!source) {
649
673
  panel.innerHTML =
@@ -723,6 +747,8 @@ async function renderPreview() {
723
747
  html += `<div class="preview-filepath" title="${esc(source.path)}">${esc(source.path)}</div>`;
724
748
  html += '</div>';
725
749
 
750
+ html += renderFileFindings(source);
751
+
726
752
  const hl = (text) => {
727
753
  const { text: processed, placeholders } = linkifyImports(text, source.id);
728
754
  // Skill names aren't filenames (e.g. "aspire") — derive the language from the real file
@@ -878,19 +904,78 @@ let analysisPollTimer = null;
878
904
  let lastAnalysisSt = null;
879
905
  let analysisFindings = []; // findings of the rendered result, addressed by index from handlers
880
906
  let analysisFilter = 'all';
881
- let analysisDismissed = new Set(); // session-only; keys are `${kind}|${title}`
882
- let analysisTs = null; // reset dismissals + filter when a new result lands
883
- let analysisRunIdx = 0; // which run from the per-project history is shown (0 = latest)
884
-
885
- // Session-only identity of a finding, used by dismiss/copy/render alike
907
+ // Server persists dismissals per scope: project findings (cross included) in the
908
+ // project entry, user-scope findings in the shared user entry (visible from every
909
+ // project). Keys are `${kind}|${title}`.
910
+ const dismissedByScope = { project: new Set(), user: new Set() };
911
+ function dismissScope(f) {
912
+ return f.scope === 'user' ? 'user' : 'project';
913
+ }
914
+ function isDismissed(f) {
915
+ return dismissedByScope[dismissScope(f)].has(findingKey(f));
916
+ }
917
+ function dismissedCount() {
918
+ return dismissedByScope.project.size + dismissedByScope.user.size;
919
+ }
920
+ let dismissWrites = 0; // dismiss POSTs in flight — polls must not clobber the local set meanwhile
921
+ let lastPollKey = null; // fingerprint of the last rendered analysis state
922
+ let analysisTs = null; // reset filters when a new result lands
923
+ let analysisRunIdx = -1; // shown run: -1 = merged current state (default), 0+ = single run
924
+ let fixSelected = new Set(); // finding keys queued for the copyable fix plan
925
+ let analysisFileFilter = null; // {id, name} from a memory-map cell click; narrows the findings list
926
+ let treeHealth = new Map(); // source id -> {high, med, low} from the latest run
927
+ let auditedIds = new Set(); // ids covered by the latest run (clean files get a hollow dot)
928
+
929
+ // Identity of a finding, used by dismiss/copy/render alike
886
930
  function findingKey(f) {
887
931
  return `${f.kind}|${f.title}`;
888
932
  }
889
933
 
890
- function resetAnalysisView(runIdx = 0) {
934
+ function resetAnalysisView(runIdx = -1) {
891
935
  analysisRunIdx = runIdx;
892
936
  analysisFilter = 'all';
893
- analysisDismissed = new Set();
937
+ fixSelected = new Set();
938
+ analysisFileFilter = null;
939
+ }
940
+
941
+ // Findings name files by bare name while map cells carry display names (e.g.
942
+ // skills), so matching goes through ids — the same resolution as findingFileIds.
943
+ function _setFileFilter(id, name) {
944
+ analysisFileFilter = analysisFileFilter?.id === id ? null : { id, name };
945
+ rerenderAnalysis();
946
+ }
947
+
948
+ function findingMatchesFileFilter(f) {
949
+ if (!analysisFileFilter) return true;
950
+ return (f.files || []).some(
951
+ (n) => n === analysisFileFilter.name || findingFileIds(n, f.scope).includes(analysisFileFilter.id),
952
+ );
953
+ }
954
+
955
+ // Health annotations on the tree always reflect the merged current state,
956
+ // regardless of which historical run the health view is showing.
957
+ function computeTreeHealth(st) {
958
+ treeHealth = new Map();
959
+ auditedIds = new Set();
960
+ const run = mergedRunView(st);
961
+ if (!run) return;
962
+ for (const s of runAuditedSources(run)) auditedIds.add(s.id);
963
+ for (const f of run.result?.findings || []) {
964
+ if (isDismissed(f)) continue;
965
+ for (const name of f.files || []) {
966
+ for (const id of findingFileIds(name, f.scope)) {
967
+ const cur = treeHealth.get(id) || { high: 0, med: 0, low: 0 };
968
+ cur[f.severity] = (cur[f.severity] || 0) + 1;
969
+ treeHealth.set(id, cur);
970
+ }
971
+ }
972
+ }
973
+ }
974
+
975
+ // Dismissals change what the tree badges should show — recompute and repaint.
976
+ function syncTreeHealth() {
977
+ computeTreeHealth(lastAnalysisSt);
978
+ renderTree();
894
979
  }
895
980
  let scopePanelOpen = false;
896
981
  let scopeChecked = null; // Set of source ids; seeded with the default scope on first open
@@ -918,7 +1003,7 @@ async function _runAnalysis(force, ids) {
918
1003
  headers: { 'Content-Type': 'application/json' },
919
1004
  body: JSON.stringify(body),
920
1005
  });
921
- if (!res.ok && res.status !== 409) {
1006
+ if (!res.ok) {
922
1007
  const msg = await res
923
1008
  .json()
924
1009
  .then((e) => e.error)
@@ -936,23 +1021,53 @@ async function _runAnalysis(force, ids) {
936
1021
  }
937
1022
 
938
1023
  async function refreshAnalysis() {
939
- const el = document.getElementById('analysisSection');
940
- if (!el) return;
941
1024
  let st;
942
1025
  try {
943
1026
  st = await fetchJSON('/api/memory/analysis');
944
1027
  } catch {
945
1028
  return;
946
1029
  }
947
- if (st.ts !== analysisTs) {
948
- analysisTs = st.ts;
1030
+ const tsKey = `${st.ts}|${st.user?.ts || ''}`;
1031
+ const isNew = tsKey !== analysisTs;
1032
+ if (isNew) {
1033
+ analysisTs = tsKey;
949
1034
  resetAnalysisView();
950
1035
  }
1036
+ // A dismiss POST in flight owns the set — adopting the server's (older) copy here
1037
+ // would briefly resurrect the finding the user just dismissed.
1038
+ if (!dismissWrites) {
1039
+ dismissedByScope.project = new Set(st.dismissed || []);
1040
+ dismissedByScope.user = new Set(st.user?.dismissed || []);
1041
+ }
951
1042
  lastAnalysisSt = st;
952
- el.innerHTML = renderAnalysis(st);
953
- applyScopeIndeterminate(el);
1043
+ // Polls where only the banner clock changed must not rebuild the review DOM —
1044
+ // that would tear down the cards mid-interaction every 2.5s while a run is in flight.
1045
+ const pollKey = JSON.stringify([
1046
+ tsKey,
1047
+ st.error,
1048
+ [...dismissedByScope.project, ...dismissedByScope.user],
1049
+ (st.pending || []).map((p) => `${p.id}${p.stalled ? ':stalled' : ''}`),
1050
+ ]);
1051
+ const banner = document.getElementById('runningBanner');
1052
+ if (pollKey === lastPollKey && banner) {
1053
+ banner.innerHTML = renderRunningBanner(st);
1054
+ } else {
1055
+ computeTreeHealth(st);
1056
+ renderTree();
1057
+ if (isNew && !healthViewOpen && selectedFileId) renderPreview();
1058
+ const el = document.getElementById('analysisSection');
1059
+ if (el) {
1060
+ el.innerHTML = renderAnalysis(st);
1061
+ applyScopeIndeterminate(el);
1062
+ }
1063
+ }
1064
+ lastPollKey = pollKey;
954
1065
  clearTimeout(analysisPollTimer);
955
- if (st.running) analysisPollTimer = setTimeout(refreshAnalysis, 2500);
1066
+ // Stalled-only pendings keep a slow poll alive — the banner promises a late
1067
+ // completion will surface, so it must actually be able to.
1068
+ const pend = st.pending || [];
1069
+ if (pend.some((p) => !p.stalled)) analysisPollTimer = setTimeout(refreshAnalysis, 2500);
1070
+ else if (pend.length) analysisPollTimer = setTimeout(refreshAnalysis, 30000);
956
1071
  }
957
1072
 
958
1073
  // Re-render from the cached state — used by scope/filter/dismiss handlers so a
@@ -1003,6 +1118,13 @@ function analysisScopeGroups() {
1003
1118
  tag: 'agent',
1004
1119
  items: stackData.filter((s) => s.scope === 'agent-memory'),
1005
1120
  },
1121
+ {
1122
+ key: 'user',
1123
+ label: 'User CLAUDE.md',
1124
+ cls: 't-user',
1125
+ tag: 'user',
1126
+ items: stackData.filter((s) => s.scope === 'user' && !s.parentId),
1127
+ },
1006
1128
  ].filter((g) => g.items.length);
1007
1129
  }
1008
1130
 
@@ -1095,22 +1217,33 @@ function memoryDirPath() {
1095
1217
  }
1096
1218
 
1097
1219
  function findingPrompt(f) {
1098
- const dir = memoryDirPath();
1099
- const lines = [
1100
- dir
1220
+ let header;
1221
+ if (f.scope === 'user') {
1222
+ const path = stackData.find((s) => s.id === 'user-claude-md')?.path || '~/.claude/CLAUDE.md';
1223
+ header = `In the user-level Claude Code instructions at ${path}, apply this fix:`;
1224
+ } else {
1225
+ const dir = memoryDirPath();
1226
+ header = dir
1101
1227
  ? `In the Claude Code auto-memory at ${dir}, apply this fix:`
1102
- : "In this project's Claude Code auto-memory, apply this fix:",
1103
- '',
1104
- `Finding (${f.kind}, ${f.severity}): ${f.title}`,
1105
- f.detail,
1106
- '',
1107
- `Action: ${f.suggestion}`,
1108
- ];
1228
+ : "In this project's Claude Code auto-memory, apply this fix:";
1229
+ }
1230
+ const lines = [header, '', `Finding (${f.kind}, ${f.severity}): ${f.title}`, f.detail, '', `Action: ${f.suggestion}`];
1109
1231
  if ((f.files || []).length) lines.push(`Files: ${f.files.join(', ')}`);
1110
- lines.push('', 'Update MEMORY.md index lines if files are added, renamed, or removed.');
1232
+ if (f.scope !== 'user') lines.push('', 'Update MEMORY.md index lines if files are added, renamed, or removed.');
1111
1233
  return lines.join('\n');
1112
1234
  }
1113
1235
 
1236
+ // Badge shown next to the kind for non-project findings; project scope is the
1237
+ // default and carries no badge. Reuses the scope-tag palette from the picker.
1238
+ function scopeBadge(f) {
1239
+ if (!f.scope || f.scope === 'project') return '';
1240
+ const title =
1241
+ f.scope === 'user'
1242
+ ? 'User-scope finding — shared across projects'
1243
+ : 'Cross-scope finding — cites both user and project instructions';
1244
+ return `<span class="scope-tag t-${esc(f.scope)}" title="${esc(title)}">${esc(f.scope)}</span>`;
1245
+ }
1246
+
1114
1247
  function copyText(text, okMsg) {
1115
1248
  navigator.clipboard
1116
1249
  .writeText(text)
@@ -1124,25 +1257,55 @@ function _copyFindingPrompt(idx) {
1124
1257
  }
1125
1258
 
1126
1259
  function _copyAllFindings() {
1127
- const live = analysisFindings.filter((f) => !analysisDismissed.has(findingKey(f)));
1260
+ const live = analysisFindings.filter((f) => !isDismissed(f));
1128
1261
  if (!live.length) return;
1129
1262
  copyText(live.map(findingPrompt).join('\n\n---\n\n'), `${live.length} prompts copied`);
1130
1263
  }
1131
1264
 
1132
- function _dismissFinding(btn, idx) {
1133
- const f = analysisFindings[idx];
1134
- if (!f) return;
1265
+ // The local set already changed, so the UI never waits; the response's authoritative
1266
+ // list is adopted once no other write is racing it.
1267
+ function persistDismissed(scope, body) {
1268
+ dismissWrites++;
1269
+ fetch('/api/memory/analysis/dismiss', {
1270
+ method: 'POST',
1271
+ headers: { 'Content-Type': 'application/json' },
1272
+ body: JSON.stringify({ ...body, scope }),
1273
+ })
1274
+ .then((r) => r.json())
1275
+ .then((d) => {
1276
+ if (dismissWrites === 1 && Array.isArray(d.dismissed)) dismissedByScope[scope] = new Set(d.dismissed);
1277
+ })
1278
+ .catch(() => {})
1279
+ .finally(() => dismissWrites--);
1280
+ }
1281
+
1282
+ // Shared by the health view and the file-preview cards — collapse the card,
1283
+ // persist the dismissal, then repaint whichever surface hosted it.
1284
+ function dismissFinding(btn, f, repaint) {
1135
1285
  const card = btn.closest('.analysis-card');
1136
1286
  card.style.maxHeight = `${card.offsetHeight}px`;
1137
1287
  requestAnimationFrame(() => card.classList.add('leaving'));
1138
1288
  setTimeout(() => {
1139
- analysisDismissed.add(findingKey(f));
1140
- rerenderAnalysis();
1289
+ // User-scope dismissals go to the shared user entry so they hold across projects.
1290
+ dismissedByScope[dismissScope(f)].add(findingKey(f));
1291
+ persistDismissed(dismissScope(f), { key: findingKey(f) });
1292
+ fixSelected.delete(findingKey(f)); // a dismissed finding can't stay queued in the fix plan
1293
+ syncTreeHealth();
1294
+ repaint();
1141
1295
  }, 280);
1142
1296
  }
1143
1297
 
1298
+ function _dismissFinding(btn, idx) {
1299
+ const f = analysisFindings[idx];
1300
+ if (f) dismissFinding(btn, f, rerenderAnalysis);
1301
+ }
1302
+
1144
1303
  function _restoreDismissed() {
1145
- analysisDismissed = new Set();
1304
+ for (const scope of Object.keys(dismissedByScope)) {
1305
+ if (dismissedByScope[scope].size) persistDismissed(scope, { all: true });
1306
+ dismissedByScope[scope] = new Set();
1307
+ }
1308
+ syncTreeHealth();
1146
1309
  rerenderAnalysis();
1147
1310
  }
1148
1311
 
@@ -1173,6 +1336,266 @@ function fileChipScope(source) {
1173
1336
  return source.scope === 'agent-memory' ? 'agent' : source.scope;
1174
1337
  }
1175
1338
 
1339
+ // Mirrors the server's analyzableSources: rules stay reference-only context for
1340
+ // the analyzer; the user CLAUDE.md is auditable (project-blind reviewer, results
1341
+ // shared across projects via the user-scope entry).
1342
+ function analyzableSource(s) {
1343
+ return (
1344
+ s.scope === 'memory' ||
1345
+ s.scope === 'agent-memory' ||
1346
+ ((s.scope === 'skill' || s.scope === 'user' || s.scope === 'project' || s.scope === 'local') && !s.parentId)
1347
+ );
1348
+ }
1349
+
1350
+ // Files covered by a run. Runs carry an `audited` snapshot taken at analysis
1351
+ // time — the authoritative list even after the stack changes. Older runs
1352
+ // predate the snapshot and fall back to re-deriving from the live stack.
1353
+ function runAuditedSources(run) {
1354
+ if (!run) return [];
1355
+ if (run.audited) return run.audited;
1356
+ // Soft-linked (@import) children never go into the analysis, so keep them out
1357
+ // of every health annotation even when their parent's id is in the run scope.
1358
+ const own = stackData.filter((s) => !s.parentId && analyzableSource(s));
1359
+ return run.ids ? own.filter((s) => run.ids.includes(s.id)) : own.filter((s) => s.scope === 'memory');
1360
+ }
1361
+
1362
+ // Findings name files by bare name, which collides across scopes (user vs
1363
+ // project CLAUDE.md) — the finding's `scope` field breaks the tie: user-scope
1364
+ // findings map only to user sources, project-scope ones exclude them ("cross"
1365
+ // keeps both sides). Attribute to audit-eligible sources first; fall back to
1366
+ // any source with that name only when none are eligible — the agent may report
1367
+ // out-of-scope files it read recursively, and those still deserve a badge.
1368
+ function findingFileIds(name, scope) {
1369
+ // The agent sometimes reports a relative path instead of the display name
1370
+ // (e.g. "dev-tools/bonya/.claude/skills/bonya/SKILL.md" for skill
1371
+ // "dev-tools/bonya:bonya") — match those by path suffix. Only slash-bearing
1372
+ // names qualify: a bare "SKILL.md" would suffix-match every skill.
1373
+ // The agent sometimes copies the prompt's scope marker into the name
1374
+ // ("CLAUDE.md (user)") — strip it, the scope field already carries it.
1375
+ const n = name.replace(/\s*\((user|project|local)\)$/, '').replace(/\\/g, '/');
1376
+ const byPath = (s) => {
1377
+ if (!n.includes('/')) return false;
1378
+ const p = (s.path || '').replace(/\\/g, '/');
1379
+ return p === n || p.endsWith(`/${n}`);
1380
+ };
1381
+ let matches = stackData.filter((s) => s.name === n || byPath(s));
1382
+ if (scope === 'user') matches = matches.filter((s) => s.scope === 'user');
1383
+ else if (scope === 'project') matches = matches.filter((s) => s.scope !== 'user');
1384
+ const eligible = matches.filter((s) => !s.parentId && analyzableSource(s));
1385
+ return (eligible.length ? eligible : matches).map((s) => s.id);
1386
+ }
1387
+
1388
+ // Project runs and shared user-scope runs interleave into one timeline —
1389
+ // run chips, the trend, and the merged view all address runs through this.
1390
+ function allAnalysisRuns(st) {
1391
+ const proj = (st?.runs || []).map((run) => ({ run, scope: 'project' }));
1392
+ const user = (st?.user?.runs || []).map((run) => ({ run, scope: 'user' }));
1393
+ return [...proj, ...user].sort((a, b) => b.run.ts - a.run.ts);
1394
+ }
1395
+
1396
+ // The default health view merges runs: each file carries the verdict of the
1397
+ // newest run that audited it, so a narrow re-run (say, one skill) overlays the
1398
+ // wider picture instead of replacing it. Run chips still show single runs.
1399
+ function mergedRunView(st) {
1400
+ const runs = allAnalysisRuns(st).map((e) => e.run);
1401
+ if (runs.length <= 1) return runs[0] || null;
1402
+ const owner = new Map(); // file id -> newest run that audited it
1403
+ const audited = [];
1404
+ for (const run of runs) {
1405
+ for (const s of runAuditedSources(run)) {
1406
+ if (owner.has(s.id)) continue;
1407
+ owner.set(s.id, run);
1408
+ audited.push(s);
1409
+ }
1410
+ }
1411
+ const findings = [];
1412
+ const seen = new Set();
1413
+ for (const run of runs) {
1414
+ for (const f of run.result?.findings || []) {
1415
+ if (seen.has(findingKey(f))) continue;
1416
+ const ids = (f.files || []).flatMap((n) => findingFileIds(n, f.scope));
1417
+ // A finding survives while its run still owns a file it touches — a newer
1418
+ // run that re-audited the file supersedes the old verdict. File-less
1419
+ // findings (suggested new memories) belong to the latest run only.
1420
+ const owns = ids.length ? ids.some((id) => owner.get(id) === run) : run === runs[0];
1421
+ if (!owns) continue;
1422
+ seen.add(findingKey(f));
1423
+ findings.push(f);
1424
+ }
1425
+ }
1426
+ const latest = runs[0];
1427
+ return {
1428
+ ...latest,
1429
+ audited,
1430
+ // The latest run's summary describes only its own scope — misleading when
1431
+ // merged, so it is dropped here.
1432
+ result: { ...latest.result, findings, summary: null, scopeDesc: `${runs.length} runs merged` },
1433
+ };
1434
+ }
1435
+
1436
+ const SEV_WEIGHT = { high: 10, med: 3, low: 1 };
1437
+
1438
+ // Start at 100, subtract per finding by severity. Blunt but explainable.
1439
+ function healthScore(findings) {
1440
+ const penalty = findings.reduce((s, f) => s + (SEV_WEIGHT[f.severity] || 1), 0);
1441
+ return Math.max(2, 100 - penalty);
1442
+ }
1443
+
1444
+ function sevCounts(findings) {
1445
+ const c = { high: 0, med: 0, low: 0 };
1446
+ for (const f of findings) c[f.severity] = (c[f.severity] || 0) + 1;
1447
+ return c;
1448
+ }
1449
+
1450
+ function renderHealthDash(st, shown) {
1451
+ // Score, tiles, and clean count track the live (non-dismissed) list they sit above.
1452
+ const findings = (shown.result?.findings || []).filter((f) => !isDismissed(f));
1453
+ const c = sevCounts(findings);
1454
+ const audited = runAuditedSources(shown);
1455
+ const dirty = new Set(findings.flatMap((f) => (f.files || []).flatMap((n) => findingFileIds(n, f.scope))));
1456
+ const clean = audited.filter((s) => !dirty.has(s.id)).length;
1457
+ const score = healthScore(findings);
1458
+ const scoreColor = score >= 80 ? 'var(--success)' : score >= 50 ? 'var(--warning)' : 'var(--error)';
1459
+ const CIRC = 163.4; // 2πr for r=26
1460
+ let html = '<div class="hv-tiles">';
1461
+ html += '<div class="hv-tile hv-ring-tile">';
1462
+ html += `<div class="hv-ring"><svg width="64" height="64" viewBox="0 0 64 64"><circle cx="32" cy="32" r="26" fill="none" stroke="var(--bg-hover)" stroke-width="6"/><circle cx="32" cy="32" r="26" fill="none" stroke="${esc(scoreColor)}" stroke-width="6" stroke-linecap="round" transform="rotate(-90 32 32)" stroke-dasharray="${CIRC}" stroke-dashoffset="${esc((CIRC * (100 - score)) / 100)}"/></svg><span class="hv-ring-val" style="color:${scoreColor}">${score}</span></div>`;
1463
+ html += `<div class="hv-tile-txt" title="Score = 100 − 10/high − 3/med − 1/low"><span class="hv-lbl">Health</span><span class="hv-sub">of 100</span></div></div>`;
1464
+ for (const [sev, label] of [
1465
+ ['high', 'High'],
1466
+ ['med', 'Medium'],
1467
+ ['low', 'Low'],
1468
+ ]) {
1469
+ html += `<div class="hv-tile"><span class="hv-lbl"><span class="dot sev-dot-${esc(sev)}"></span>${label}</span><span class="hv-num hv-num-${esc(sev)}">${c[sev]}</span></div>`;
1470
+ }
1471
+ html += `<div class="hv-tile"><span class="hv-lbl">Clean</span><span class="hv-num hv-num-clean">${clean}<i>/${audited.length}</i></span><span class="hv-sub">audited files</span></div>`;
1472
+ html += renderTrend(st);
1473
+ html += '</div>';
1474
+ return html;
1475
+ }
1476
+
1477
+ // Findings per run as stacked mini-columns, oldest to newest; click switches the shown run.
1478
+ function renderTrend(st) {
1479
+ const runs = allAnalysisRuns(st);
1480
+ if (runs.length < 2) return '';
1481
+ let html = '<div class="hv-tile hv-trend-tile"><span class="hv-lbl">Trend</span><div class="hv-trend">';
1482
+ for (let i = runs.length - 1; i >= 0; i--) {
1483
+ const c = sevCounts(runs[i].run.result?.findings || []);
1484
+ const px = (n) => (n ? Math.max(3, n * 4) : 0);
1485
+ html += `<div class="hv-trun${i === analysisRunIdx ? ' cur' : ''}" onclick="_setAnalysisRun(${i})" title="${esc(analysisTimeAgo(runs[i].run.ts))}${runs[i].scope === 'user' ? ' · user' : ''} — ${c.high} high · ${c.med} med · ${c.low} low">`;
1486
+ if (c.low) html += `<i class="tl" style="height:${px(c.low)}px"></i>`;
1487
+ if (c.med) html += `<i class="tm" style="height:${px(c.med)}px"></i>`;
1488
+ if (c.high) html += `<i class="th" style="height:${px(c.high)}px"></i>`;
1489
+ if (!c.high && !c.med && !c.low) html += '<i class="tz"></i>';
1490
+ html += '</div>';
1491
+ }
1492
+ html += '</div></div>';
1493
+ return html;
1494
+ }
1495
+
1496
+ // Every audited file sized by line count, colored by its worst finding.
1497
+ function renderMemoryMap(shown) {
1498
+ const audited = runAuditedSources(shown);
1499
+ if (!audited.length) return '';
1500
+ const findings = (shown.result?.findings || []).filter((f) => !isDismissed(f));
1501
+ const perFile = new Map();
1502
+ for (const f of findings) {
1503
+ for (const name of f.files || []) {
1504
+ for (const id of findingFileIds(name, f.scope)) {
1505
+ const cur = perFile.get(id) || { high: 0, med: 0, low: 0 };
1506
+ cur[f.severity]++;
1507
+ perFile.set(id, cur);
1508
+ }
1509
+ }
1510
+ }
1511
+ const sevRank = { high: 0, med: 1, low: 2, clean: 3 };
1512
+ const cellSev = (s) => {
1513
+ const c = perFile.get(s.id);
1514
+ return c ? (c.high ? 'high' : c.med ? 'med' : 'low') : 'clean';
1515
+ };
1516
+ let html = '<div class="hv-map-label">Memory map — colored by worst finding</div><div class="hv-map">';
1517
+ for (const s of [...audited].sort(
1518
+ (a, b) => sevRank[cellSev(a)] - sevRank[cellSev(b)] || a.name.localeCompare(b.name),
1519
+ )) {
1520
+ const c = perFile.get(s.id);
1521
+ const sev = cellSev(s);
1522
+ const n = c ? c.high + c.med + c.low : 0;
1523
+ const title = `${s.name} — ${s.lines}L${n ? ` · ${n} finding${n > 1 ? 's' : ''} (worst: ${sev})` : ' · clean'}`;
1524
+ html += `<div class="hv-cell hv-${esc(sev)}${analysisFileFilter?.id === s.id ? ' active' : ''}" onclick="_setFileFilter('${escAttrJs(s.id)}','${escAttrJs(s.name)}')" title="${esc(title)} — click to filter findings">`;
1525
+ if (n) html += `<span class="hv-cell-n hv-cn-${esc(sev)}">${n}</span>`;
1526
+ html += `<span class="hv-cell-name">${esc(s.name)}</span><span class="hv-cell-meta">${s.lines}L</span></div>`;
1527
+ }
1528
+ html += '</div>';
1529
+ return html;
1530
+ }
1531
+
1532
+ // Findings for one file from the merged current state, shown inline in the file preview.
1533
+ function renderFileFindings(source) {
1534
+ const run = mergedRunView(lastAnalysisSt);
1535
+ if (!run) return '';
1536
+ const hits = (run.result?.findings || [])
1537
+ .map((f, i) => [f, i])
1538
+ .filter(([f]) => (f.files || []).some((n) => findingFileIds(n, f.scope).includes(source.id)) && !isDismissed(f));
1539
+ if (!hits.length) return '';
1540
+ let html = `<div class="preview-findings"><div class="pf-label">Claude analysis — ${hits.length} finding${hits.length > 1 ? 's' : ''} for this file</div>`;
1541
+ for (const [f, i] of hits) {
1542
+ html += `<div class="analysis-card pf-card ${esc(`sev-${f.severity}`)}">`;
1543
+ html += `<div class="analysis-card-head" onclick="this.parentElement.classList.toggle('expanded')">`;
1544
+ html += `<span class="kind-badge ${esc(`kind-${f.kind}`)}">${esc(f.kind)}</span>`;
1545
+ html += scopeBadge(f);
1546
+ html += `<span class="analysis-card-title">${esc(f.title)}</span>`;
1547
+ html += '<span class="card-actions">';
1548
+ html += `<button class="card-btn" onclick="event.stopPropagation();_copyLatestFindingPrompt(${i})" title="Copy an agent-ready instruction for this finding">${COPY_ICON}Copy prompt</button>`;
1549
+ html += `<button class="card-btn" onclick="event.stopPropagation();_dismissLatestFinding(this, ${i})" title="Drop this finding from the list">${DISMISS_ICON}Dismiss</button>`;
1550
+ html += '</span></div>';
1551
+ html += `<div class="analysis-card-detail">${esc(f.detail)}</div>`;
1552
+ html += `<div class="analysis-card-suggestion">${esc(f.suggestion)}</div>`;
1553
+ html += '</div>';
1554
+ }
1555
+ html += '</div>';
1556
+ return html;
1557
+ }
1558
+
1559
+ // Indexes into the merged current state — the same list renderFileFindings
1560
+ // draws from, unlike the health view, which can show a historical run.
1561
+ function _copyLatestFindingPrompt(i) {
1562
+ const f = mergedRunView(lastAnalysisSt)?.result?.findings?.[i];
1563
+ if (f) copyText(findingPrompt(f), 'Prompt copied — paste into any Claude Code session');
1564
+ }
1565
+
1566
+ // Dismissals share the health view's set, so a finding dropped here
1567
+ // disappears there too (and vice versa).
1568
+ function _dismissLatestFinding(btn, i) {
1569
+ const f = mergedRunView(lastAnalysisSt)?.result?.findings?.[i];
1570
+ if (f) dismissFinding(btn, f, renderPreview);
1571
+ }
1572
+
1573
+ function _toggleFixSelect(idx) {
1574
+ const f = analysisFindings[idx];
1575
+ if (!f) return;
1576
+ const key = findingKey(f);
1577
+ if (fixSelected.has(key)) fixSelected.delete(key);
1578
+ else fixSelected.add(key);
1579
+ rerenderAnalysis();
1580
+ }
1581
+
1582
+ function _copyFixPlan() {
1583
+ const order = { high: 0, med: 1, low: 2 };
1584
+ const picked = analysisFindings
1585
+ .filter((f) => fixSelected.has(findingKey(f)))
1586
+ .sort((a, b) => order[a.severity] - order[b.severity]);
1587
+ if (!picked.length) return;
1588
+ const plan = [
1589
+ `Fix plan — ${picked.length} finding${picked.length > 1 ? 's' : ''} from a Claude Code memory audit, ordered by severity. Apply each in order.`,
1590
+ '',
1591
+ ...picked.map((f, i) => `## ${i + 1}. ${findingPrompt(f)}`),
1592
+ ].join('\n\n');
1593
+ copyText(
1594
+ plan,
1595
+ `Fix plan copied — ${picked.length} finding${picked.length > 1 ? 's' : ''}, paste into any Claude Code session`,
1596
+ );
1597
+ }
1598
+
1176
1599
  function renderAnalysisHead(st, r, ts) {
1177
1600
  const meta = [];
1178
1601
  if (ts) meta.push(analysisTimeAgo(ts));
@@ -1180,7 +1603,8 @@ function renderAnalysisHead(st, r, ts) {
1180
1603
  if (r?.durationMs != null) meta.push(`${Math.round(r.durationMs / 1000)}s`);
1181
1604
  if (r?.model) meta.push(r.model);
1182
1605
  if (r?.scopeDesc) meta.push(`scope: ${r.scopeDesc}`);
1183
- if (st.stale && analysisRunIdx === 0) meta.push('memory changed since');
1606
+ if (st.stale && analysisRunIdx <= 0) meta.push('memory changed since');
1607
+ if (st.user?.stale && analysisRunIdx < 0) meta.push('user CLAUDE.md changed since');
1184
1608
  let html = '<div class="analysis-head">';
1185
1609
  html += '<span class="analysis-title">Claude analysis</span>';
1186
1610
  html += `<span class="analysis-meta">${esc(meta.join(' · '))}</span>`;
@@ -1194,49 +1618,75 @@ function renderAnalysisHead(st, r, ts) {
1194
1618
  }
1195
1619
 
1196
1620
  function _setAnalysisRun(i) {
1197
- resetAnalysisView(i);
1621
+ resetAnalysisView(i); // clears the file filter, so the tree badges must be restored too
1622
+ syncTreeHealth();
1198
1623
  rerenderAnalysis();
1199
1624
  }
1200
1625
 
1201
- async function _deleteAnalysisRun(ev, ts) {
1626
+ async function _deleteAnalysisRun(ev, ts, scope) {
1202
1627
  ev.stopPropagation();
1203
1628
  await fetch('/api/memory/analysis/delete-run', {
1204
1629
  method: 'POST',
1205
1630
  headers: { 'Content-Type': 'application/json' },
1206
- body: JSON.stringify({ ts }),
1631
+ body: JSON.stringify({ ts, scope }),
1207
1632
  });
1208
1633
  resetAnalysisView();
1209
1634
  analysisTs = null; // force refreshAnalysis to re-adopt the new latest run
1210
1635
  refreshAnalysis();
1211
1636
  }
1212
1637
 
1213
- // Past runs for this project (newest first).
1638
+ // Past runs, newest first — project runs plus the shared user-scope runs.
1214
1639
  function renderRunsStrip(st) {
1215
- const runs = st.runs || [];
1640
+ const runs = allAnalysisRuns(st);
1216
1641
  if (!runs.length) return '';
1217
1642
  let html = '<div class="runs-strip"><span class="runs-label">runs</span>';
1218
- runs.forEach((run, i) => {
1643
+ if (runs.length > 1) {
1644
+ html += `<span class="run-chip${analysisRunIdx < 0 ? ' active' : ''}" onclick="_setAnalysisRun(-1)" title="Each file shows the newest run that audited it">current · ${runs.length} runs merged</span>`;
1645
+ }
1646
+ runs.forEach(({ run, scope }, i) => {
1219
1647
  const r = run.result || {};
1220
1648
  const bits = [analysisTimeAgo(run.ts)];
1649
+ if (scope === 'user') bits.push('user');
1221
1650
  if (r.model) bits.push(r.model);
1222
1651
  if (r.costUsd != null) bits.push(`$${r.costUsd.toFixed(2)}`);
1223
- html += `<span class="run-chip${i === analysisRunIdx ? ' active' : ''}" onclick="_setAnalysisRun(${i})" title="${esc(r.scopeDesc || '')}">${esc(bits.join(' · '))}<button class="run-chip-x" onclick="_deleteAnalysisRun(event, ${escAttrJs(run.ts)})" title="Delete this run">×</button></span>`;
1652
+ html += `<span class="run-chip${scope === 'user' ? ' run-chip-user' : ''}${i === analysisRunIdx ? ' active' : ''}" onclick="_setAnalysisRun(${i})" title="${esc(r.scopeDesc || '')}">${esc(bits.join(' · '))}<button class="run-chip-x" onclick="_deleteAnalysisRun(event, ${escAttrJs(run.ts)}, '${escAttrJs(scope)}')" title="Delete this run">×</button></span>`;
1224
1653
  });
1225
1654
  html += '</div>';
1226
1655
  return html;
1227
1656
  }
1228
1657
 
1229
- function renderAnalysis(st) {
1230
- if (st.running) {
1231
- return '<div class="analysis-box"><div class="analysis-status"><span class="analysis-spinner"></span>Analyzing memory with Claude Code — usually 30–90s…</div></div>';
1658
+ // The previous review stays on screen while runs are in flight — running state is a
1659
+ // banner above it, not a replacement view.
1660
+ function renderRunningBanner(st) {
1661
+ const pending = st.pending || [];
1662
+ if (!pending.length) return '';
1663
+ let html = '';
1664
+ for (const p of pending) {
1665
+ const bits = [];
1666
+ if (p.model) bits.push(p.model);
1667
+ if (p.scopeDesc) bits.push(p.scopeDesc);
1668
+ if (p.startedAt) bits.push(`started ${analysisTimeAgo(p.startedAt)}`);
1669
+ const meta = bits.length ? ` <span class="analysis-meta">${esc(bits.join(' · '))}</span>` : '';
1670
+ if (p.stalled) {
1671
+ html += `<div class="analysis-status analysis-stalled">Run appears stalled (server restarted mid-run?) — its result will surface if it ever completes.${meta}</div>`;
1672
+ } else {
1673
+ html += `<div class="analysis-status"><span class="analysis-spinner"></span>Analyzing memory with Claude Code — usually 30–90s…${meta}</div>`;
1674
+ }
1232
1675
  }
1233
- const runs = st.runs || [];
1234
- const shown = runs[analysisRunIdx];
1676
+ return html;
1677
+ }
1678
+
1679
+ function renderAnalysis(st) {
1680
+ const shown = analysisRunIdx < 0 ? mergedRunView(st) : allAnalysisRuns(st)[analysisRunIdx]?.run;
1681
+ // The wrapper stays in the DOM even when empty so quiet polls can update just
1682
+ // the banner in place instead of rebuilding the whole review (see refreshAnalysis).
1235
1683
  let html = '<div class="analysis-box">';
1684
+ html += `<div id="runningBanner">${renderRunningBanner(st)}</div>`;
1236
1685
  html += renderAnalysisHead(st, shown?.result, shown?.ts);
1686
+ // A failed run no longer wipes history — show the error above the surviving review.
1237
1687
  if (st.error) {
1238
- html += `<div class="analysis-status analysis-status-error">${esc(st.error)} <button class="action-btn small" onclick="_runAnalysis(true)">Retry</button></div></div>`;
1239
- return html;
1688
+ html += `<div class="analysis-status analysis-status-error">${esc(st.error)} <button class="action-btn small" onclick="_runAnalysis(true)">Retry</button></div>`;
1689
+ if (!shown) return `${html}</div>`;
1240
1690
  }
1241
1691
  if (!shown) {
1242
1692
  html += '<div class="analysis-empty">No analysis yet — pick a scope and run one.</div></div>';
@@ -1245,9 +1695,11 @@ function renderAnalysis(st) {
1245
1695
  const r = shown.result;
1246
1696
  analysisFindings = r.findings || [];
1247
1697
  const byName = Object.fromEntries(stackData.map((s) => [s.name, s]));
1698
+ html += renderHealthDash(st, shown);
1248
1699
  if (r.summary) html += `<div class="analysis-summary">${esc(r.summary)}</div>`;
1700
+ html += renderMemoryMap(shown);
1249
1701
 
1250
- const live = analysisFindings.filter((f) => !analysisDismissed.has(findingKey(f)));
1702
+ const live = analysisFindings.filter((f) => !isDismissed(f));
1251
1703
  const count = (sev) => live.filter((f) => f.severity === sev).length;
1252
1704
  if (analysisFindings.length) {
1253
1705
  html += '<div class="sev-strip">';
@@ -1257,20 +1709,31 @@ function renderAnalysis(st) {
1257
1709
  if (sev !== 'all') html += '<span class="dot"></span>';
1258
1710
  html += `${sev} ${n}</button>`;
1259
1711
  }
1260
- if (analysisDismissed.size) {
1261
- html += `<span class="dismissed-note">${analysisDismissed.size} dismissed · <a onclick="_restoreDismissed()">restore</a></span>`;
1712
+ if (analysisFileFilter) {
1713
+ html += `<button class="sev-pill active" onclick="_setFileFilter('${escAttrJs(analysisFileFilter.id)}','${escAttrJs(analysisFileFilter.name)}')" title="Clear the file filter">${esc(analysisFileFilter.name)} ×</button>`;
1714
+ }
1715
+ const nDismissed = dismissedCount();
1716
+ if (nDismissed) {
1717
+ html += `<span class="dismissed-note">${nDismissed} dismissed · <a onclick="_restoreDismissed()">restore</a></span>`;
1718
+ }
1719
+ if (fixSelected.size) {
1720
+ html += `<button class="card-btn fix-plan-btn" onclick="_copyFixPlan()" title="Copy the selected findings as one ordered fix prompt">${COPY_ICON}Copy fix plan (${fixSelected.size})</button>`;
1262
1721
  }
1263
1722
  html += '</div>';
1264
1723
  }
1265
1724
 
1266
- const visible = live.filter((f) => analysisFilter === 'all' || f.severity === analysisFilter);
1725
+ const visible = live.filter(
1726
+ (f) => (analysisFilter === 'all' || f.severity === analysisFilter) && findingMatchesFileFilter(f),
1727
+ );
1267
1728
  if (!analysisFindings.length) html += '<div class="analysis-empty">No issues found.</div>';
1268
1729
  else if (!visible.length) html += '<div class="analysis-empty">Nothing here — filtered out or dismissed.</div>';
1269
1730
  for (const f of visible) {
1270
1731
  const idx = analysisFindings.indexOf(f);
1271
1732
  html += `<div class="analysis-card ${esc(`sev-${f.severity}`)}">`;
1272
1733
  html += '<div class="analysis-card-head">';
1734
+ html += `<input type="checkbox" class="fix-check" ${fixSelected.has(findingKey(f)) ? 'checked ' : ''}onclick="_toggleFixSelect(${idx})" title="Queue for the fix plan">`;
1273
1735
  html += `<span class="kind-badge ${esc(`kind-${f.kind}`)}">${esc(f.kind)}</span>`;
1736
+ html += scopeBadge(f);
1274
1737
  html += `<span class="analysis-card-title">${esc(f.title)}</span>`;
1275
1738
  html += '<span class="card-actions">';
1276
1739
  html += `<button class="card-btn" onclick="_copyFindingPrompt(${idx})" title="Copy an agent-ready instruction for this finding">${COPY_ICON}Copy prompt</button>`;
@@ -1280,10 +1743,13 @@ function renderAnalysis(st) {
1280
1743
  html += '<div class="analysis-card-files">';
1281
1744
  if (!files.length) html += '<span class="no-files">no file — new memory suggested</span>';
1282
1745
  for (const fn of files) {
1283
- const child = byName[fn];
1746
+ // Same name-or-path resolution as the map and tree badges — a chip must
1747
+ // stay clickable when the agent reported a path instead of the name.
1748
+ const ids = findingFileIds(fn, f.scope);
1749
+ const child = stackData.find((s) => ids.includes(s.id)) || byName[fn];
1284
1750
  const scopeTag = fileChipScope(child);
1285
1751
  if (child) {
1286
- html += `<a class="file-chip" href="#" onclick="closeModal('analysisModal');selectFile('${escAttrJs(child.id)}');return false" title="${esc(child.path)}">${FILE_ICON}${esc(fn)}<span class="fscope fscope-${esc(scopeTag)}">${esc(scopeTag)}</span></a>`;
1752
+ html += `<a class="file-chip" href="#" onclick="selectFile('${escAttrJs(child.id)}');return false" title="${esc(child.path)}">${FILE_ICON}${esc(fn)}<span class="fscope fscope-${esc(scopeTag)}">${esc(scopeTag)}</span></a>`;
1287
1753
  } else {
1288
1754
  html += `<span class="file-chip">${FILE_ICON}${esc(fn)}</span>`;
1289
1755
  }
@@ -1301,8 +1767,42 @@ function renderAnalysis(st) {
1301
1767
 
1302
1768
  // #region RENDER_BUDGET
1303
1769
 
1770
+ let footprintHighlight = false;
1771
+ let footprintScope = null; // null = whole footprint; a scope name = just that segment's files
1772
+ let footprintIds = new Set(); // source ids the server counted into the footprint
1773
+
1774
+ // Clicking the top bar highlights all footprint files; clicking a segment narrows
1775
+ // the highlight to that segment's scope (the skill segment = skill descriptions).
1776
+ function _toggleFootprint(scope = null, ev) {
1777
+ if (ev) ev.stopPropagation();
1778
+ if (footprintHighlight && footprintScope === scope) {
1779
+ footprintHighlight = false;
1780
+ footprintScope = null;
1781
+ } else {
1782
+ footprintHighlight = true;
1783
+ footprintScope = scope;
1784
+ }
1785
+ document.body.classList.toggle('fp-mode', footprintHighlight);
1786
+ applySegmentState();
1787
+ renderTree();
1788
+ }
1789
+
1790
+ function footprintHit(item) {
1791
+ if (footprintScope === 'skill') return item.scope === 'skill' && !item.parentId;
1792
+ if (footprintScope) return footprintIds.has(item.id) && item.scope === footprintScope;
1793
+ return footprintIds.has(item.id);
1794
+ }
1795
+
1796
+ function applySegmentState() {
1797
+ const scoped = footprintHighlight && footprintScope !== null;
1798
+ for (const el of document.querySelectorAll('.budget-segment')) {
1799
+ el.classList.toggle('seg-dim', scoped && el.dataset.scope !== footprintScope);
1800
+ }
1801
+ }
1802
+
1304
1803
  function renderBudget() {
1305
1804
  if (!summaryData) return;
1805
+ footprintIds = new Set(summaryData.ids || []);
1306
1806
 
1307
1807
  // Summary stat cards
1308
1808
  document.getElementById('statFiles').textContent = summaryData.totalFiles;
@@ -1325,13 +1825,14 @@ function renderBudget() {
1325
1825
  const chars = scopeTotals[scope];
1326
1826
  if (!chars) continue;
1327
1827
  const pct = (chars / totalChars) * 100;
1328
- html += `<div class="budget-segment" style="width:${pct}%;background:var(--scope-${scope})" title="${SCOPE_LABELS[scope] || scope}: ${chars.toLocaleString()} chars (${pct.toFixed(1)}%)"></div>`;
1828
+ html += `<div class="budget-segment" data-scope="${esc(scope)}" onclick="_toggleFootprint('${escAttrJs(scope)}', event)" style="width:${pct}%;background:var(--scope-${scope})" title="${SCOPE_LABELS[scope] || scope}: ${chars.toLocaleString()} chars (${pct.toFixed(1)}%) — click to highlight"></div>`;
1329
1829
  }
1330
1830
  if (skillDescChars) {
1331
1831
  const pct = (skillDescChars / totalChars) * 100;
1332
- html += `<div class="budget-segment" style="width:${pct}%;background:var(--scope-skill)" title="Skill descriptions (${esc(summaryData.skillDesc.count)} enabled): ${skillDescChars.toLocaleString()} chars (${pct.toFixed(1)}%)"></div>`;
1832
+ html += `<div class="budget-segment" data-scope="skill" onclick="_toggleFootprint('skill', event)" style="width:${pct}%;background:var(--scope-skill)" title="Skill descriptions (${esc(summaryData.skillDesc.count)} enabled): ${skillDescChars.toLocaleString()} chars (${pct.toFixed(1)}%) — click to highlight"></div>`;
1333
1833
  }
1334
1834
  segContainer.innerHTML = html;
1835
+ applySegmentState();
1335
1836
  }
1336
1837
 
1337
1838
  // #endregion RENDER_BUDGET
@@ -1342,18 +1843,17 @@ async function loadData() {
1342
1843
  try {
1343
1844
  [stackData, summaryData] = await Promise.all([fetchJSON('/api/stack'), fetchJSON('/api/summary')]);
1344
1845
  invalidateTreeIndex();
1846
+ renderBudget(); // refreshes footprintIds, which renderTree's highlight reads
1345
1847
  renderTree();
1346
- renderBudget();
1347
- if (!selectedFileId) {
1848
+ refreshAnalysis(); // health dots on the tree come from the latest run
1849
+ if (!selectedFileId && !healthViewOpen) {
1348
1850
  const proj = stackData.find((s) => s.scope === 'project' && s.name === 'CLAUDE.md');
1349
1851
  const user = stackData.find((s) => s.scope === 'user' && s.name === 'CLAUDE.md');
1350
1852
  const auto = proj || user;
1351
1853
  if (auto) selectedFileId = auto.id;
1352
1854
  }
1353
- if (selectedFileId) {
1354
- renderTree();
1355
- renderPreview();
1356
- }
1855
+ if (selectedFileId) renderTree();
1856
+ renderPreview();
1357
1857
  } catch (err) {
1358
1858
  showToast(`Failed to load: ${err.message}`, 'error');
1359
1859
  }
@@ -1396,6 +1896,11 @@ document.addEventListener('keydown', (e) => {
1396
1896
  }
1397
1897
  return;
1398
1898
  }
1899
+ if (e.key === 'Escape' && healthViewOpen) {
1900
+ e.preventDefault();
1901
+ openHealthView();
1902
+ return;
1903
+ }
1399
1904
  if (e.key === 't') toggleTheme();
1400
1905
  if (e.key === 'r') refreshData();
1401
1906
  if (e.key === '?') {