claude-code-memory-explorer 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -18
- package/package.json +1 -1
- package/public/app.js +598 -72
- package/public/index.html +5 -18
- package/public/style.css +429 -24
- package/server.js +291 -113
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
|
-
|
|
308
|
-
|
|
309
|
-
document.getElementById('
|
|
310
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
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 =
|
|
934
|
+
function resetAnalysisView(runIdx = -1) {
|
|
891
935
|
analysisRunIdx = runIdx;
|
|
892
936
|
analysisFilter = 'all';
|
|
893
|
-
|
|
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
|
|
1006
|
+
if (!res.ok) {
|
|
922
1007
|
const msg = await res
|
|
923
1008
|
.json()
|
|
924
1009
|
.then((e) => e.error)
|
|
@@ -936,32 +1021,63 @@ 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
|
-
|
|
948
|
-
|
|
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
|
-
|
|
953
|
-
|
|
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
|
+
rerenderAnalysis(st);
|
|
1059
|
+
}
|
|
1060
|
+
lastPollKey = pollKey;
|
|
954
1061
|
clearTimeout(analysisPollTimer);
|
|
955
|
-
|
|
1062
|
+
// Stalled-only pendings keep a slow poll alive — the banner promises a late
|
|
1063
|
+
// completion will surface, so it must actually be able to.
|
|
1064
|
+
const pend = st.pending || [];
|
|
1065
|
+
if (pend.some((p) => !p.stalled)) analysisPollTimer = setTimeout(refreshAnalysis, 2500);
|
|
1066
|
+
else if (pend.length) analysisPollTimer = setTimeout(refreshAnalysis, 30000);
|
|
956
1067
|
}
|
|
957
1068
|
|
|
958
1069
|
// Re-render from the cached state — used by scope/filter/dismiss handlers so a
|
|
959
1070
|
// checkbox click never triggers a server round-trip.
|
|
960
|
-
function rerenderAnalysis() {
|
|
1071
|
+
function rerenderAnalysis(st = lastAnalysisSt) {
|
|
961
1072
|
const el = document.getElementById('analysisSection');
|
|
962
|
-
if (!el || !
|
|
963
|
-
|
|
1073
|
+
if (!el || !st) return;
|
|
1074
|
+
// The scope tree is the scroll container; innerHTML replaces it, which would
|
|
1075
|
+
// snap a group toggle deep in the list back to the top of the panel.
|
|
1076
|
+
const scroll = el.querySelector('.scope-tree')?.scrollTop || 0;
|
|
1077
|
+
el.innerHTML = renderAnalysis(st);
|
|
964
1078
|
applyScopeIndeterminate(el);
|
|
1079
|
+
const tree = el.querySelector('.scope-tree');
|
|
1080
|
+
if (tree) tree.scrollTop = scroll;
|
|
965
1081
|
}
|
|
966
1082
|
|
|
967
1083
|
// `indeterminate` is a DOM property, not an attribute — set it after every innerHTML render.
|
|
@@ -1003,6 +1119,13 @@ function analysisScopeGroups() {
|
|
|
1003
1119
|
tag: 'agent',
|
|
1004
1120
|
items: stackData.filter((s) => s.scope === 'agent-memory'),
|
|
1005
1121
|
},
|
|
1122
|
+
{
|
|
1123
|
+
key: 'user',
|
|
1124
|
+
label: 'User CLAUDE.md',
|
|
1125
|
+
cls: 't-user',
|
|
1126
|
+
tag: 'user',
|
|
1127
|
+
items: stackData.filter((s) => s.scope === 'user' && !s.parentId),
|
|
1128
|
+
},
|
|
1006
1129
|
].filter((g) => g.items.length);
|
|
1007
1130
|
}
|
|
1008
1131
|
|
|
@@ -1094,23 +1217,54 @@ function memoryDirPath() {
|
|
|
1094
1217
|
return idx ? idx.path.replace(/[\\/][^\\/]+$/, '') : null;
|
|
1095
1218
|
}
|
|
1096
1219
|
|
|
1220
|
+
// Resolve a finding's bare file names to the live stack sources they name, so a
|
|
1221
|
+
// prompt can cite real paths instead of guessing a location from `scope`.
|
|
1222
|
+
function findingSources(f) {
|
|
1223
|
+
const ids = new Set();
|
|
1224
|
+
for (const name of f.files || []) {
|
|
1225
|
+
for (const id of findingFileIds(name, f.scope)) ids.add(id);
|
|
1226
|
+
}
|
|
1227
|
+
return [...ids].map((id) => stackData.find((s) => s.id === id)).filter((s) => s?.path);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1097
1230
|
function findingPrompt(f) {
|
|
1098
|
-
const
|
|
1099
|
-
const
|
|
1100
|
-
|
|
1231
|
+
const sources = findingSources(f);
|
|
1232
|
+
const paths = sources.map((s) => s.path);
|
|
1233
|
+
let header;
|
|
1234
|
+
if (paths.length === 1) {
|
|
1235
|
+
header = `In the Claude Code instruction file at ${paths[0]}, apply this fix:`;
|
|
1236
|
+
} else if (paths.length > 1) {
|
|
1237
|
+
header = 'In the Claude Code instruction files listed below, apply this fix:';
|
|
1238
|
+
} else if (f.scope === 'user') {
|
|
1239
|
+
const path = stackData.find((s) => s.id === 'user-claude-md')?.path || '~/.claude/CLAUDE.md';
|
|
1240
|
+
header = `In the user-level Claude Code instructions at ${path}, apply this fix:`;
|
|
1241
|
+
} else {
|
|
1242
|
+
const dir = memoryDirPath();
|
|
1243
|
+
header = dir
|
|
1101
1244
|
? `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
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
lines.push('', 'Update MEMORY.md index lines if files are added, renamed, or removed.');
|
|
1245
|
+
: "In this project's Claude Code auto-memory, apply this fix:";
|
|
1246
|
+
}
|
|
1247
|
+
const lines = [header, '', `Finding (${f.kind}, ${f.severity}): ${f.title}`, f.detail, '', `Action: ${f.suggestion}`];
|
|
1248
|
+
const cited = paths.length ? paths : f.files || [];
|
|
1249
|
+
if (cited.length) lines.push(`Files: ${cited.join(', ')}`);
|
|
1250
|
+
// The index note only applies to auto-memory files; a CLAUDE.md or SKILL.md
|
|
1251
|
+
// fix has no MEMORY.md to keep in sync.
|
|
1252
|
+
const touchesMemory = paths.length ? sources.some((s) => s.scope === 'memory') : f.scope !== 'user';
|
|
1253
|
+
if (touchesMemory) lines.push('', 'Update MEMORY.md index lines if files are added, renamed, or removed.');
|
|
1111
1254
|
return lines.join('\n');
|
|
1112
1255
|
}
|
|
1113
1256
|
|
|
1257
|
+
// Badge shown next to the kind for non-project findings; project scope is the
|
|
1258
|
+
// default and carries no badge. Reuses the scope-tag palette from the picker.
|
|
1259
|
+
function scopeBadge(f) {
|
|
1260
|
+
if (!f.scope || f.scope === 'project') return '';
|
|
1261
|
+
const title =
|
|
1262
|
+
f.scope === 'user'
|
|
1263
|
+
? 'User-scope finding — shared across projects'
|
|
1264
|
+
: 'Cross-scope finding — cites both user and project instructions';
|
|
1265
|
+
return `<span class="scope-tag t-${esc(f.scope)}" title="${esc(title)}">${esc(f.scope)}</span>`;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1114
1268
|
function copyText(text, okMsg) {
|
|
1115
1269
|
navigator.clipboard
|
|
1116
1270
|
.writeText(text)
|
|
@@ -1124,25 +1278,55 @@ function _copyFindingPrompt(idx) {
|
|
|
1124
1278
|
}
|
|
1125
1279
|
|
|
1126
1280
|
function _copyAllFindings() {
|
|
1127
|
-
const live = analysisFindings.filter((f) => !
|
|
1281
|
+
const live = analysisFindings.filter((f) => !isDismissed(f));
|
|
1128
1282
|
if (!live.length) return;
|
|
1129
1283
|
copyText(live.map(findingPrompt).join('\n\n---\n\n'), `${live.length} prompts copied`);
|
|
1130
1284
|
}
|
|
1131
1285
|
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1286
|
+
// The local set already changed, so the UI never waits; the response's authoritative
|
|
1287
|
+
// list is adopted once no other write is racing it.
|
|
1288
|
+
function persistDismissed(scope, body) {
|
|
1289
|
+
dismissWrites++;
|
|
1290
|
+
fetch('/api/memory/analysis/dismiss', {
|
|
1291
|
+
method: 'POST',
|
|
1292
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1293
|
+
body: JSON.stringify({ ...body, scope }),
|
|
1294
|
+
})
|
|
1295
|
+
.then((r) => r.json())
|
|
1296
|
+
.then((d) => {
|
|
1297
|
+
if (dismissWrites === 1 && Array.isArray(d.dismissed)) dismissedByScope[scope] = new Set(d.dismissed);
|
|
1298
|
+
})
|
|
1299
|
+
.catch(() => {})
|
|
1300
|
+
.finally(() => dismissWrites--);
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
// Shared by the health view and the file-preview cards — collapse the card,
|
|
1304
|
+
// persist the dismissal, then repaint whichever surface hosted it.
|
|
1305
|
+
function dismissFinding(btn, f, repaint) {
|
|
1135
1306
|
const card = btn.closest('.analysis-card');
|
|
1136
1307
|
card.style.maxHeight = `${card.offsetHeight}px`;
|
|
1137
1308
|
requestAnimationFrame(() => card.classList.add('leaving'));
|
|
1138
1309
|
setTimeout(() => {
|
|
1139
|
-
|
|
1140
|
-
|
|
1310
|
+
// User-scope dismissals go to the shared user entry so they hold across projects.
|
|
1311
|
+
dismissedByScope[dismissScope(f)].add(findingKey(f));
|
|
1312
|
+
persistDismissed(dismissScope(f), { key: findingKey(f) });
|
|
1313
|
+
fixSelected.delete(findingKey(f)); // a dismissed finding can't stay queued in the fix plan
|
|
1314
|
+
syncTreeHealth();
|
|
1315
|
+
repaint();
|
|
1141
1316
|
}, 280);
|
|
1142
1317
|
}
|
|
1143
1318
|
|
|
1319
|
+
function _dismissFinding(btn, idx) {
|
|
1320
|
+
const f = analysisFindings[idx];
|
|
1321
|
+
if (f) dismissFinding(btn, f, rerenderAnalysis);
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1144
1324
|
function _restoreDismissed() {
|
|
1145
|
-
|
|
1325
|
+
for (const scope of Object.keys(dismissedByScope)) {
|
|
1326
|
+
if (dismissedByScope[scope].size) persistDismissed(scope, { all: true });
|
|
1327
|
+
dismissedByScope[scope] = new Set();
|
|
1328
|
+
}
|
|
1329
|
+
syncTreeHealth();
|
|
1146
1330
|
rerenderAnalysis();
|
|
1147
1331
|
}
|
|
1148
1332
|
|
|
@@ -1173,6 +1357,266 @@ function fileChipScope(source) {
|
|
|
1173
1357
|
return source.scope === 'agent-memory' ? 'agent' : source.scope;
|
|
1174
1358
|
}
|
|
1175
1359
|
|
|
1360
|
+
// Mirrors the server's analyzableSources: rules stay reference-only context for
|
|
1361
|
+
// the analyzer; the user CLAUDE.md is auditable (project-blind reviewer, results
|
|
1362
|
+
// shared across projects via the user-scope entry).
|
|
1363
|
+
function analyzableSource(s) {
|
|
1364
|
+
return (
|
|
1365
|
+
s.scope === 'memory' ||
|
|
1366
|
+
s.scope === 'agent-memory' ||
|
|
1367
|
+
((s.scope === 'skill' || s.scope === 'user' || s.scope === 'project' || s.scope === 'local') && !s.parentId)
|
|
1368
|
+
);
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
// Files covered by a run. Runs carry an `audited` snapshot taken at analysis
|
|
1372
|
+
// time — the authoritative list even after the stack changes. Older runs
|
|
1373
|
+
// predate the snapshot and fall back to re-deriving from the live stack.
|
|
1374
|
+
function runAuditedSources(run) {
|
|
1375
|
+
if (!run) return [];
|
|
1376
|
+
if (run.audited) return run.audited;
|
|
1377
|
+
// Soft-linked (@import) children never go into the analysis, so keep them out
|
|
1378
|
+
// of every health annotation even when their parent's id is in the run scope.
|
|
1379
|
+
const own = stackData.filter((s) => !s.parentId && analyzableSource(s));
|
|
1380
|
+
return run.ids ? own.filter((s) => run.ids.includes(s.id)) : own.filter((s) => s.scope === 'memory');
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// Findings name files by bare name, which collides across scopes (user vs
|
|
1384
|
+
// project CLAUDE.md) — the finding's `scope` field breaks the tie: user-scope
|
|
1385
|
+
// findings map only to user sources, project-scope ones exclude them ("cross"
|
|
1386
|
+
// keeps both sides). Attribute to audit-eligible sources first; fall back to
|
|
1387
|
+
// any source with that name only when none are eligible — the agent may report
|
|
1388
|
+
// out-of-scope files it read recursively, and those still deserve a badge.
|
|
1389
|
+
function findingFileIds(name, scope) {
|
|
1390
|
+
// The agent sometimes reports a relative path instead of the display name
|
|
1391
|
+
// (e.g. "dev-tools/bonya/.claude/skills/bonya/SKILL.md" for skill
|
|
1392
|
+
// "dev-tools/bonya:bonya") — match those by path suffix. Only slash-bearing
|
|
1393
|
+
// names qualify: a bare "SKILL.md" would suffix-match every skill.
|
|
1394
|
+
// The agent sometimes copies the prompt's scope marker into the name
|
|
1395
|
+
// ("CLAUDE.md (user)") — strip it, the scope field already carries it.
|
|
1396
|
+
const n = name.replace(/\s*\((user|project|local)\)$/, '').replace(/\\/g, '/');
|
|
1397
|
+
const byPath = (s) => {
|
|
1398
|
+
if (!n.includes('/')) return false;
|
|
1399
|
+
const p = (s.path || '').replace(/\\/g, '/');
|
|
1400
|
+
return p === n || p.endsWith(`/${n}`);
|
|
1401
|
+
};
|
|
1402
|
+
let matches = stackData.filter((s) => s.name === n || byPath(s));
|
|
1403
|
+
if (scope === 'user') matches = matches.filter((s) => s.scope === 'user');
|
|
1404
|
+
else if (scope === 'project') matches = matches.filter((s) => s.scope !== 'user');
|
|
1405
|
+
const eligible = matches.filter((s) => !s.parentId && analyzableSource(s));
|
|
1406
|
+
return (eligible.length ? eligible : matches).map((s) => s.id);
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
// Project runs and shared user-scope runs interleave into one timeline —
|
|
1410
|
+
// run chips, the trend, and the merged view all address runs through this.
|
|
1411
|
+
function allAnalysisRuns(st) {
|
|
1412
|
+
const proj = (st?.runs || []).map((run) => ({ run, scope: 'project' }));
|
|
1413
|
+
const user = (st?.user?.runs || []).map((run) => ({ run, scope: 'user' }));
|
|
1414
|
+
return [...proj, ...user].sort((a, b) => b.run.ts - a.run.ts);
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
// The default health view merges runs: each file carries the verdict of the
|
|
1418
|
+
// newest run that audited it, so a narrow re-run (say, one skill) overlays the
|
|
1419
|
+
// wider picture instead of replacing it. Run chips still show single runs.
|
|
1420
|
+
function mergedRunView(st) {
|
|
1421
|
+
const runs = allAnalysisRuns(st).map((e) => e.run);
|
|
1422
|
+
if (runs.length <= 1) return runs[0] || null;
|
|
1423
|
+
const owner = new Map(); // file id -> newest run that audited it
|
|
1424
|
+
const audited = [];
|
|
1425
|
+
for (const run of runs) {
|
|
1426
|
+
for (const s of runAuditedSources(run)) {
|
|
1427
|
+
if (owner.has(s.id)) continue;
|
|
1428
|
+
owner.set(s.id, run);
|
|
1429
|
+
audited.push(s);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
const findings = [];
|
|
1433
|
+
const seen = new Set();
|
|
1434
|
+
for (const run of runs) {
|
|
1435
|
+
for (const f of run.result?.findings || []) {
|
|
1436
|
+
if (seen.has(findingKey(f))) continue;
|
|
1437
|
+
const ids = (f.files || []).flatMap((n) => findingFileIds(n, f.scope));
|
|
1438
|
+
// A finding survives while its run still owns a file it touches — a newer
|
|
1439
|
+
// run that re-audited the file supersedes the old verdict. File-less
|
|
1440
|
+
// findings (suggested new memories) belong to the latest run only.
|
|
1441
|
+
const owns = ids.length ? ids.some((id) => owner.get(id) === run) : run === runs[0];
|
|
1442
|
+
if (!owns) continue;
|
|
1443
|
+
seen.add(findingKey(f));
|
|
1444
|
+
findings.push(f);
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
const latest = runs[0];
|
|
1448
|
+
return {
|
|
1449
|
+
...latest,
|
|
1450
|
+
audited,
|
|
1451
|
+
// The latest run's summary describes only its own scope — misleading when
|
|
1452
|
+
// merged, so it is dropped here.
|
|
1453
|
+
result: { ...latest.result, findings, summary: null, scopeDesc: `${runs.length} runs merged` },
|
|
1454
|
+
};
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
const SEV_WEIGHT = { high: 10, med: 3, low: 1 };
|
|
1458
|
+
|
|
1459
|
+
// Start at 100, subtract per finding by severity. Blunt but explainable.
|
|
1460
|
+
function healthScore(findings) {
|
|
1461
|
+
const penalty = findings.reduce((s, f) => s + (SEV_WEIGHT[f.severity] || 1), 0);
|
|
1462
|
+
return Math.max(2, 100 - penalty);
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
function sevCounts(findings) {
|
|
1466
|
+
const c = { high: 0, med: 0, low: 0 };
|
|
1467
|
+
for (const f of findings) c[f.severity] = (c[f.severity] || 0) + 1;
|
|
1468
|
+
return c;
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
function renderHealthDash(st, shown) {
|
|
1472
|
+
// Score, tiles, and clean count track the live (non-dismissed) list they sit above.
|
|
1473
|
+
const findings = (shown.result?.findings || []).filter((f) => !isDismissed(f));
|
|
1474
|
+
const c = sevCounts(findings);
|
|
1475
|
+
const audited = runAuditedSources(shown);
|
|
1476
|
+
const dirty = new Set(findings.flatMap((f) => (f.files || []).flatMap((n) => findingFileIds(n, f.scope))));
|
|
1477
|
+
const clean = audited.filter((s) => !dirty.has(s.id)).length;
|
|
1478
|
+
const score = healthScore(findings);
|
|
1479
|
+
const scoreColor = score >= 80 ? 'var(--success)' : score >= 50 ? 'var(--warning)' : 'var(--error)';
|
|
1480
|
+
const CIRC = 163.4; // 2πr for r=26
|
|
1481
|
+
let html = '<div class="hv-tiles">';
|
|
1482
|
+
html += '<div class="hv-tile hv-ring-tile">';
|
|
1483
|
+
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>`;
|
|
1484
|
+
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>`;
|
|
1485
|
+
for (const [sev, label] of [
|
|
1486
|
+
['high', 'High'],
|
|
1487
|
+
['med', 'Medium'],
|
|
1488
|
+
['low', 'Low'],
|
|
1489
|
+
]) {
|
|
1490
|
+
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>`;
|
|
1491
|
+
}
|
|
1492
|
+
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>`;
|
|
1493
|
+
html += renderTrend(st);
|
|
1494
|
+
html += '</div>';
|
|
1495
|
+
return html;
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// Findings per run as stacked mini-columns, oldest to newest; click switches the shown run.
|
|
1499
|
+
function renderTrend(st) {
|
|
1500
|
+
const runs = allAnalysisRuns(st);
|
|
1501
|
+
if (runs.length < 2) return '';
|
|
1502
|
+
let html = '<div class="hv-tile hv-trend-tile"><span class="hv-lbl">Trend</span><div class="hv-trend">';
|
|
1503
|
+
for (let i = runs.length - 1; i >= 0; i--) {
|
|
1504
|
+
const c = sevCounts(runs[i].run.result?.findings || []);
|
|
1505
|
+
const px = (n) => (n ? Math.max(3, n * 4) : 0);
|
|
1506
|
+
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">`;
|
|
1507
|
+
if (c.low) html += `<i class="tl" style="height:${px(c.low)}px"></i>`;
|
|
1508
|
+
if (c.med) html += `<i class="tm" style="height:${px(c.med)}px"></i>`;
|
|
1509
|
+
if (c.high) html += `<i class="th" style="height:${px(c.high)}px"></i>`;
|
|
1510
|
+
if (!c.high && !c.med && !c.low) html += '<i class="tz"></i>';
|
|
1511
|
+
html += '</div>';
|
|
1512
|
+
}
|
|
1513
|
+
html += '</div></div>';
|
|
1514
|
+
return html;
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
// Every audited file sized by line count, colored by its worst finding.
|
|
1518
|
+
function renderMemoryMap(shown) {
|
|
1519
|
+
const audited = runAuditedSources(shown);
|
|
1520
|
+
if (!audited.length) return '';
|
|
1521
|
+
const findings = (shown.result?.findings || []).filter((f) => !isDismissed(f));
|
|
1522
|
+
const perFile = new Map();
|
|
1523
|
+
for (const f of findings) {
|
|
1524
|
+
for (const name of f.files || []) {
|
|
1525
|
+
for (const id of findingFileIds(name, f.scope)) {
|
|
1526
|
+
const cur = perFile.get(id) || { high: 0, med: 0, low: 0 };
|
|
1527
|
+
cur[f.severity]++;
|
|
1528
|
+
perFile.set(id, cur);
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
const sevRank = { high: 0, med: 1, low: 2, clean: 3 };
|
|
1533
|
+
const cellSev = (s) => {
|
|
1534
|
+
const c = perFile.get(s.id);
|
|
1535
|
+
return c ? (c.high ? 'high' : c.med ? 'med' : 'low') : 'clean';
|
|
1536
|
+
};
|
|
1537
|
+
let html = '<div class="hv-map-label">Memory map — colored by worst finding</div><div class="hv-map">';
|
|
1538
|
+
for (const s of [...audited].sort(
|
|
1539
|
+
(a, b) => sevRank[cellSev(a)] - sevRank[cellSev(b)] || a.name.localeCompare(b.name),
|
|
1540
|
+
)) {
|
|
1541
|
+
const c = perFile.get(s.id);
|
|
1542
|
+
const sev = cellSev(s);
|
|
1543
|
+
const n = c ? c.high + c.med + c.low : 0;
|
|
1544
|
+
const title = `${s.name} — ${s.lines}L${n ? ` · ${n} finding${n > 1 ? 's' : ''} (worst: ${sev})` : ' · clean'}`;
|
|
1545
|
+
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">`;
|
|
1546
|
+
if (n) html += `<span class="hv-cell-n hv-cn-${esc(sev)}">${n}</span>`;
|
|
1547
|
+
html += `<span class="hv-cell-name">${esc(s.name)}</span><span class="hv-cell-meta">${s.lines}L</span></div>`;
|
|
1548
|
+
}
|
|
1549
|
+
html += '</div>';
|
|
1550
|
+
return html;
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
// Findings for one file from the merged current state, shown inline in the file preview.
|
|
1554
|
+
function renderFileFindings(source) {
|
|
1555
|
+
const run = mergedRunView(lastAnalysisSt);
|
|
1556
|
+
if (!run) return '';
|
|
1557
|
+
const hits = (run.result?.findings || [])
|
|
1558
|
+
.map((f, i) => [f, i])
|
|
1559
|
+
.filter(([f]) => (f.files || []).some((n) => findingFileIds(n, f.scope).includes(source.id)) && !isDismissed(f));
|
|
1560
|
+
if (!hits.length) return '';
|
|
1561
|
+
let html = `<div class="preview-findings"><div class="pf-label">Claude analysis — ${hits.length} finding${hits.length > 1 ? 's' : ''} for this file</div>`;
|
|
1562
|
+
for (const [f, i] of hits) {
|
|
1563
|
+
html += `<div class="analysis-card pf-card ${esc(`sev-${f.severity}`)}">`;
|
|
1564
|
+
html += `<div class="analysis-card-head" onclick="this.parentElement.classList.toggle('expanded')">`;
|
|
1565
|
+
html += `<span class="kind-badge ${esc(`kind-${f.kind}`)}">${esc(f.kind)}</span>`;
|
|
1566
|
+
html += scopeBadge(f);
|
|
1567
|
+
html += `<span class="analysis-card-title">${esc(f.title)}</span>`;
|
|
1568
|
+
html += '<span class="card-actions">';
|
|
1569
|
+
html += `<button class="card-btn" onclick="event.stopPropagation();_copyLatestFindingPrompt(${i})" title="Copy an agent-ready instruction for this finding">${COPY_ICON}Copy prompt</button>`;
|
|
1570
|
+
html += `<button class="card-btn" onclick="event.stopPropagation();_dismissLatestFinding(this, ${i})" title="Drop this finding from the list">${DISMISS_ICON}Dismiss</button>`;
|
|
1571
|
+
html += '</span></div>';
|
|
1572
|
+
html += `<div class="analysis-card-detail">${esc(f.detail)}</div>`;
|
|
1573
|
+
html += `<div class="analysis-card-suggestion">${esc(f.suggestion)}</div>`;
|
|
1574
|
+
html += '</div>';
|
|
1575
|
+
}
|
|
1576
|
+
html += '</div>';
|
|
1577
|
+
return html;
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
// Indexes into the merged current state — the same list renderFileFindings
|
|
1581
|
+
// draws from, unlike the health view, which can show a historical run.
|
|
1582
|
+
function _copyLatestFindingPrompt(i) {
|
|
1583
|
+
const f = mergedRunView(lastAnalysisSt)?.result?.findings?.[i];
|
|
1584
|
+
if (f) copyText(findingPrompt(f), 'Prompt copied — paste into any Claude Code session');
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
// Dismissals share the health view's set, so a finding dropped here
|
|
1588
|
+
// disappears there too (and vice versa).
|
|
1589
|
+
function _dismissLatestFinding(btn, i) {
|
|
1590
|
+
const f = mergedRunView(lastAnalysisSt)?.result?.findings?.[i];
|
|
1591
|
+
if (f) dismissFinding(btn, f, renderPreview);
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
function _toggleFixSelect(idx) {
|
|
1595
|
+
const f = analysisFindings[idx];
|
|
1596
|
+
if (!f) return;
|
|
1597
|
+
const key = findingKey(f);
|
|
1598
|
+
if (fixSelected.has(key)) fixSelected.delete(key);
|
|
1599
|
+
else fixSelected.add(key);
|
|
1600
|
+
rerenderAnalysis();
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
function _copyFixPlan() {
|
|
1604
|
+
const order = { high: 0, med: 1, low: 2 };
|
|
1605
|
+
const picked = analysisFindings
|
|
1606
|
+
.filter((f) => fixSelected.has(findingKey(f)))
|
|
1607
|
+
.sort((a, b) => order[a.severity] - order[b.severity]);
|
|
1608
|
+
if (!picked.length) return;
|
|
1609
|
+
const plan = [
|
|
1610
|
+
`Fix plan — ${picked.length} finding${picked.length > 1 ? 's' : ''} from a Claude Code memory audit, ordered by severity. Apply each in order.`,
|
|
1611
|
+
'',
|
|
1612
|
+
...picked.map((f, i) => `## ${i + 1}. ${findingPrompt(f)}`),
|
|
1613
|
+
].join('\n\n');
|
|
1614
|
+
copyText(
|
|
1615
|
+
plan,
|
|
1616
|
+
`Fix plan copied — ${picked.length} finding${picked.length > 1 ? 's' : ''}, paste into any Claude Code session`,
|
|
1617
|
+
);
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1176
1620
|
function renderAnalysisHead(st, r, ts) {
|
|
1177
1621
|
const meta = [];
|
|
1178
1622
|
if (ts) meta.push(analysisTimeAgo(ts));
|
|
@@ -1180,7 +1624,8 @@ function renderAnalysisHead(st, r, ts) {
|
|
|
1180
1624
|
if (r?.durationMs != null) meta.push(`${Math.round(r.durationMs / 1000)}s`);
|
|
1181
1625
|
if (r?.model) meta.push(r.model);
|
|
1182
1626
|
if (r?.scopeDesc) meta.push(`scope: ${r.scopeDesc}`);
|
|
1183
|
-
if (st.stale && analysisRunIdx
|
|
1627
|
+
if (st.stale && analysisRunIdx <= 0) meta.push('memory changed since');
|
|
1628
|
+
if (st.user?.stale && analysisRunIdx < 0) meta.push('user CLAUDE.md changed since');
|
|
1184
1629
|
let html = '<div class="analysis-head">';
|
|
1185
1630
|
html += '<span class="analysis-title">Claude analysis</span>';
|
|
1186
1631
|
html += `<span class="analysis-meta">${esc(meta.join(' · '))}</span>`;
|
|
@@ -1194,49 +1639,75 @@ function renderAnalysisHead(st, r, ts) {
|
|
|
1194
1639
|
}
|
|
1195
1640
|
|
|
1196
1641
|
function _setAnalysisRun(i) {
|
|
1197
|
-
resetAnalysisView(i);
|
|
1642
|
+
resetAnalysisView(i); // clears the file filter, so the tree badges must be restored too
|
|
1643
|
+
syncTreeHealth();
|
|
1198
1644
|
rerenderAnalysis();
|
|
1199
1645
|
}
|
|
1200
1646
|
|
|
1201
|
-
async function _deleteAnalysisRun(ev, ts) {
|
|
1647
|
+
async function _deleteAnalysisRun(ev, ts, scope) {
|
|
1202
1648
|
ev.stopPropagation();
|
|
1203
1649
|
await fetch('/api/memory/analysis/delete-run', {
|
|
1204
1650
|
method: 'POST',
|
|
1205
1651
|
headers: { 'Content-Type': 'application/json' },
|
|
1206
|
-
body: JSON.stringify({ ts }),
|
|
1652
|
+
body: JSON.stringify({ ts, scope }),
|
|
1207
1653
|
});
|
|
1208
1654
|
resetAnalysisView();
|
|
1209
1655
|
analysisTs = null; // force refreshAnalysis to re-adopt the new latest run
|
|
1210
1656
|
refreshAnalysis();
|
|
1211
1657
|
}
|
|
1212
1658
|
|
|
1213
|
-
// Past runs
|
|
1659
|
+
// Past runs, newest first — project runs plus the shared user-scope runs.
|
|
1214
1660
|
function renderRunsStrip(st) {
|
|
1215
|
-
const runs = st
|
|
1661
|
+
const runs = allAnalysisRuns(st);
|
|
1216
1662
|
if (!runs.length) return '';
|
|
1217
1663
|
let html = '<div class="runs-strip"><span class="runs-label">runs</span>';
|
|
1218
|
-
runs.
|
|
1664
|
+
if (runs.length > 1) {
|
|
1665
|
+
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>`;
|
|
1666
|
+
}
|
|
1667
|
+
runs.forEach(({ run, scope }, i) => {
|
|
1219
1668
|
const r = run.result || {};
|
|
1220
1669
|
const bits = [analysisTimeAgo(run.ts)];
|
|
1670
|
+
if (scope === 'user') bits.push('user');
|
|
1221
1671
|
if (r.model) bits.push(r.model);
|
|
1222
1672
|
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>`;
|
|
1673
|
+
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
1674
|
});
|
|
1225
1675
|
html += '</div>';
|
|
1226
1676
|
return html;
|
|
1227
1677
|
}
|
|
1228
1678
|
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1679
|
+
// The previous review stays on screen while runs are in flight — running state is a
|
|
1680
|
+
// banner above it, not a replacement view.
|
|
1681
|
+
function renderRunningBanner(st) {
|
|
1682
|
+
const pending = st.pending || [];
|
|
1683
|
+
if (!pending.length) return '';
|
|
1684
|
+
let html = '';
|
|
1685
|
+
for (const p of pending) {
|
|
1686
|
+
const bits = [];
|
|
1687
|
+
if (p.model) bits.push(p.model);
|
|
1688
|
+
if (p.scopeDesc) bits.push(p.scopeDesc);
|
|
1689
|
+
if (p.startedAt) bits.push(`started ${analysisTimeAgo(p.startedAt)}`);
|
|
1690
|
+
const meta = bits.length ? ` <span class="analysis-meta">${esc(bits.join(' · '))}</span>` : '';
|
|
1691
|
+
if (p.stalled) {
|
|
1692
|
+
html += `<div class="analysis-status analysis-stalled">Run appears stalled (server restarted mid-run?) — its result will surface if it ever completes.${meta}</div>`;
|
|
1693
|
+
} else {
|
|
1694
|
+
html += `<div class="analysis-status"><span class="analysis-spinner"></span>Analyzing memory with Claude Code — usually 30–90s…${meta}</div>`;
|
|
1695
|
+
}
|
|
1232
1696
|
}
|
|
1233
|
-
|
|
1234
|
-
|
|
1697
|
+
return html;
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
function renderAnalysis(st) {
|
|
1701
|
+
const shown = analysisRunIdx < 0 ? mergedRunView(st) : allAnalysisRuns(st)[analysisRunIdx]?.run;
|
|
1702
|
+
// The wrapper stays in the DOM even when empty so quiet polls can update just
|
|
1703
|
+
// the banner in place instead of rebuilding the whole review (see refreshAnalysis).
|
|
1235
1704
|
let html = '<div class="analysis-box">';
|
|
1705
|
+
html += `<div id="runningBanner">${renderRunningBanner(st)}</div>`;
|
|
1236
1706
|
html += renderAnalysisHead(st, shown?.result, shown?.ts);
|
|
1707
|
+
// A failed run no longer wipes history — show the error above the surviving review.
|
|
1237
1708
|
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
|
|
1239
|
-
return html
|
|
1709
|
+
html += `<div class="analysis-status analysis-status-error">${esc(st.error)} <button class="action-btn small" onclick="_runAnalysis(true)">Retry</button></div>`;
|
|
1710
|
+
if (!shown) return `${html}</div>`;
|
|
1240
1711
|
}
|
|
1241
1712
|
if (!shown) {
|
|
1242
1713
|
html += '<div class="analysis-empty">No analysis yet — pick a scope and run one.</div></div>';
|
|
@@ -1245,9 +1716,11 @@ function renderAnalysis(st) {
|
|
|
1245
1716
|
const r = shown.result;
|
|
1246
1717
|
analysisFindings = r.findings || [];
|
|
1247
1718
|
const byName = Object.fromEntries(stackData.map((s) => [s.name, s]));
|
|
1719
|
+
html += renderHealthDash(st, shown);
|
|
1248
1720
|
if (r.summary) html += `<div class="analysis-summary">${esc(r.summary)}</div>`;
|
|
1721
|
+
html += renderMemoryMap(shown);
|
|
1249
1722
|
|
|
1250
|
-
const live = analysisFindings.filter((f) => !
|
|
1723
|
+
const live = analysisFindings.filter((f) => !isDismissed(f));
|
|
1251
1724
|
const count = (sev) => live.filter((f) => f.severity === sev).length;
|
|
1252
1725
|
if (analysisFindings.length) {
|
|
1253
1726
|
html += '<div class="sev-strip">';
|
|
@@ -1257,20 +1730,31 @@ function renderAnalysis(st) {
|
|
|
1257
1730
|
if (sev !== 'all') html += '<span class="dot"></span>';
|
|
1258
1731
|
html += `${sev} ${n}</button>`;
|
|
1259
1732
|
}
|
|
1260
|
-
if (
|
|
1261
|
-
html += `<
|
|
1733
|
+
if (analysisFileFilter) {
|
|
1734
|
+
html += `<button class="sev-pill active" onclick="_setFileFilter('${escAttrJs(analysisFileFilter.id)}','${escAttrJs(analysisFileFilter.name)}')" title="Clear the file filter">${esc(analysisFileFilter.name)} ×</button>`;
|
|
1735
|
+
}
|
|
1736
|
+
const nDismissed = dismissedCount();
|
|
1737
|
+
if (nDismissed) {
|
|
1738
|
+
html += `<span class="dismissed-note">${nDismissed} dismissed · <a onclick="_restoreDismissed()">restore</a></span>`;
|
|
1739
|
+
}
|
|
1740
|
+
if (fixSelected.size) {
|
|
1741
|
+
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
1742
|
}
|
|
1263
1743
|
html += '</div>';
|
|
1264
1744
|
}
|
|
1265
1745
|
|
|
1266
|
-
const visible = live.filter(
|
|
1746
|
+
const visible = live.filter(
|
|
1747
|
+
(f) => (analysisFilter === 'all' || f.severity === analysisFilter) && findingMatchesFileFilter(f),
|
|
1748
|
+
);
|
|
1267
1749
|
if (!analysisFindings.length) html += '<div class="analysis-empty">No issues found.</div>';
|
|
1268
1750
|
else if (!visible.length) html += '<div class="analysis-empty">Nothing here — filtered out or dismissed.</div>';
|
|
1269
1751
|
for (const f of visible) {
|
|
1270
1752
|
const idx = analysisFindings.indexOf(f);
|
|
1271
1753
|
html += `<div class="analysis-card ${esc(`sev-${f.severity}`)}">`;
|
|
1272
1754
|
html += '<div class="analysis-card-head">';
|
|
1755
|
+
html += `<input type="checkbox" class="fix-check" ${fixSelected.has(findingKey(f)) ? 'checked ' : ''}onclick="_toggleFixSelect(${idx})" title="Queue for the fix plan">`;
|
|
1273
1756
|
html += `<span class="kind-badge ${esc(`kind-${f.kind}`)}">${esc(f.kind)}</span>`;
|
|
1757
|
+
html += scopeBadge(f);
|
|
1274
1758
|
html += `<span class="analysis-card-title">${esc(f.title)}</span>`;
|
|
1275
1759
|
html += '<span class="card-actions">';
|
|
1276
1760
|
html += `<button class="card-btn" onclick="_copyFindingPrompt(${idx})" title="Copy an agent-ready instruction for this finding">${COPY_ICON}Copy prompt</button>`;
|
|
@@ -1280,10 +1764,13 @@ function renderAnalysis(st) {
|
|
|
1280
1764
|
html += '<div class="analysis-card-files">';
|
|
1281
1765
|
if (!files.length) html += '<span class="no-files">no file — new memory suggested</span>';
|
|
1282
1766
|
for (const fn of files) {
|
|
1283
|
-
|
|
1767
|
+
// Same name-or-path resolution as the map and tree badges — a chip must
|
|
1768
|
+
// stay clickable when the agent reported a path instead of the name.
|
|
1769
|
+
const ids = findingFileIds(fn, f.scope);
|
|
1770
|
+
const child = stackData.find((s) => ids.includes(s.id)) || byName[fn];
|
|
1284
1771
|
const scopeTag = fileChipScope(child);
|
|
1285
1772
|
if (child) {
|
|
1286
|
-
html += `<a class="file-chip" href="#" onclick="
|
|
1773
|
+
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
1774
|
} else {
|
|
1288
1775
|
html += `<span class="file-chip">${FILE_ICON}${esc(fn)}</span>`;
|
|
1289
1776
|
}
|
|
@@ -1301,8 +1788,42 @@ function renderAnalysis(st) {
|
|
|
1301
1788
|
|
|
1302
1789
|
// #region RENDER_BUDGET
|
|
1303
1790
|
|
|
1791
|
+
let footprintHighlight = false;
|
|
1792
|
+
let footprintScope = null; // null = whole footprint; a scope name = just that segment's files
|
|
1793
|
+
let footprintIds = new Set(); // source ids the server counted into the footprint
|
|
1794
|
+
|
|
1795
|
+
// Clicking the top bar highlights all footprint files; clicking a segment narrows
|
|
1796
|
+
// the highlight to that segment's scope (the skill segment = skill descriptions).
|
|
1797
|
+
function _toggleFootprint(scope = null, ev) {
|
|
1798
|
+
if (ev) ev.stopPropagation();
|
|
1799
|
+
if (footprintHighlight && footprintScope === scope) {
|
|
1800
|
+
footprintHighlight = false;
|
|
1801
|
+
footprintScope = null;
|
|
1802
|
+
} else {
|
|
1803
|
+
footprintHighlight = true;
|
|
1804
|
+
footprintScope = scope;
|
|
1805
|
+
}
|
|
1806
|
+
document.body.classList.toggle('fp-mode', footprintHighlight);
|
|
1807
|
+
applySegmentState();
|
|
1808
|
+
renderTree();
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
function footprintHit(item) {
|
|
1812
|
+
if (footprintScope === 'skill') return item.scope === 'skill' && !item.parentId;
|
|
1813
|
+
if (footprintScope) return footprintIds.has(item.id) && item.scope === footprintScope;
|
|
1814
|
+
return footprintIds.has(item.id);
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
function applySegmentState() {
|
|
1818
|
+
const scoped = footprintHighlight && footprintScope !== null;
|
|
1819
|
+
for (const el of document.querySelectorAll('.budget-segment')) {
|
|
1820
|
+
el.classList.toggle('seg-dim', scoped && el.dataset.scope !== footprintScope);
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1304
1824
|
function renderBudget() {
|
|
1305
1825
|
if (!summaryData) return;
|
|
1826
|
+
footprintIds = new Set(summaryData.ids || []);
|
|
1306
1827
|
|
|
1307
1828
|
// Summary stat cards
|
|
1308
1829
|
document.getElementById('statFiles').textContent = summaryData.totalFiles;
|
|
@@ -1325,13 +1846,14 @@ function renderBudget() {
|
|
|
1325
1846
|
const chars = scopeTotals[scope];
|
|
1326
1847
|
if (!chars) continue;
|
|
1327
1848
|
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>`;
|
|
1849
|
+
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
1850
|
}
|
|
1330
1851
|
if (skillDescChars) {
|
|
1331
1852
|
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>`;
|
|
1853
|
+
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
1854
|
}
|
|
1334
1855
|
segContainer.innerHTML = html;
|
|
1856
|
+
applySegmentState();
|
|
1335
1857
|
}
|
|
1336
1858
|
|
|
1337
1859
|
// #endregion RENDER_BUDGET
|
|
@@ -1342,18 +1864,17 @@ async function loadData() {
|
|
|
1342
1864
|
try {
|
|
1343
1865
|
[stackData, summaryData] = await Promise.all([fetchJSON('/api/stack'), fetchJSON('/api/summary')]);
|
|
1344
1866
|
invalidateTreeIndex();
|
|
1867
|
+
renderBudget(); // refreshes footprintIds, which renderTree's highlight reads
|
|
1345
1868
|
renderTree();
|
|
1346
|
-
|
|
1347
|
-
if (!selectedFileId) {
|
|
1869
|
+
refreshAnalysis(); // health dots on the tree come from the latest run
|
|
1870
|
+
if (!selectedFileId && !healthViewOpen) {
|
|
1348
1871
|
const proj = stackData.find((s) => s.scope === 'project' && s.name === 'CLAUDE.md');
|
|
1349
1872
|
const user = stackData.find((s) => s.scope === 'user' && s.name === 'CLAUDE.md');
|
|
1350
1873
|
const auto = proj || user;
|
|
1351
1874
|
if (auto) selectedFileId = auto.id;
|
|
1352
1875
|
}
|
|
1353
|
-
if (selectedFileId)
|
|
1354
|
-
|
|
1355
|
-
renderPreview();
|
|
1356
|
-
}
|
|
1876
|
+
if (selectedFileId) renderTree();
|
|
1877
|
+
renderPreview();
|
|
1357
1878
|
} catch (err) {
|
|
1358
1879
|
showToast(`Failed to load: ${err.message}`, 'error');
|
|
1359
1880
|
}
|
|
@@ -1396,6 +1917,11 @@ document.addEventListener('keydown', (e) => {
|
|
|
1396
1917
|
}
|
|
1397
1918
|
return;
|
|
1398
1919
|
}
|
|
1920
|
+
if (e.key === 'Escape' && healthViewOpen) {
|
|
1921
|
+
e.preventDefault();
|
|
1922
|
+
openHealthView();
|
|
1923
|
+
return;
|
|
1924
|
+
}
|
|
1399
1925
|
if (e.key === 't') toggleTheme();
|
|
1400
1926
|
if (e.key === 'r') refreshData();
|
|
1401
1927
|
if (e.key === '?') {
|