claude-code-memory-explorer 0.11.1 → 0.13.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/package.json +1 -1
- package/public/app.js +199 -53
- package/public/index.html +16 -33
- package/public/style.css +184 -36
- package/server.js +57 -10
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -221,11 +221,10 @@ async function putProject(dirPath) {
|
|
|
221
221
|
}
|
|
222
222
|
|
|
223
223
|
function changeProject() {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
renderRecentProjects();
|
|
224
|
+
document.getElementById('projectPickerInput').value = '';
|
|
225
|
+
renderProjectPicker();
|
|
227
226
|
document.getElementById('projectPickerModal').classList.add('open');
|
|
228
|
-
|
|
227
|
+
setPickerMode(false);
|
|
229
228
|
}
|
|
230
229
|
|
|
231
230
|
async function submitProjectPicker() {
|
|
@@ -248,6 +247,8 @@ async function submitProjectPicker() {
|
|
|
248
247
|
}
|
|
249
248
|
}
|
|
250
249
|
|
|
250
|
+
const RECENT_PROJECTS_MAX = 20;
|
|
251
|
+
|
|
251
252
|
function getRecentProjects() {
|
|
252
253
|
try {
|
|
253
254
|
return JSON.parse(localStorage.getItem('recentProjects') || '[]');
|
|
@@ -259,38 +260,102 @@ function getRecentProjects() {
|
|
|
259
260
|
function addRecentProject(p) {
|
|
260
261
|
const recent = getRecentProjects().filter((r) => r !== p);
|
|
261
262
|
recent.unshift(p);
|
|
262
|
-
localStorage.setItem('recentProjects', JSON.stringify(recent.slice(0,
|
|
263
|
+
localStorage.setItem('recentProjects', JSON.stringify(recent.slice(0, RECENT_PROJECTS_MAX)));
|
|
263
264
|
}
|
|
264
265
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
266
|
+
// The recents list is the switcher; typing a path is the secondary act behind "+ Add path".
|
|
267
|
+
let pickerRows = [];
|
|
268
|
+
let pickerIdx = -1;
|
|
269
|
+
|
|
270
|
+
function setPickerMode(add) {
|
|
271
|
+
document.getElementById('projectPickerFilter').hidden = add;
|
|
272
|
+
document.getElementById('projectPickerAddForm').hidden = !add;
|
|
273
|
+
const input = document.getElementById(add ? 'projectPathInput' : 'projectPickerInput');
|
|
274
|
+
if (add) input.value = document.getElementById('projectBtn').title;
|
|
275
|
+
// The overlay fades in, so a focus() on a field that is still hidden is dropped.
|
|
276
|
+
setTimeout(() => input.focus(), 100);
|
|
270
277
|
}
|
|
271
278
|
|
|
272
|
-
function
|
|
273
|
-
|
|
279
|
+
function splitProjectPath(p) {
|
|
280
|
+
const trimmed = p.replace(/[/\\]+$/, '');
|
|
281
|
+
const cut = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
|
|
282
|
+
return { name: trimmed.slice(cut + 1), parent: trimmed.slice(0, Math.max(cut, 0)) };
|
|
274
283
|
}
|
|
275
284
|
|
|
276
|
-
function
|
|
277
|
-
const
|
|
285
|
+
function renderProjectPicker() {
|
|
286
|
+
const list = document.getElementById('projectPickerList');
|
|
287
|
+
const q = document.getElementById('projectPickerInput').value.trim().toLowerCase();
|
|
288
|
+
const current = document.getElementById('projectBtn').title;
|
|
278
289
|
const recent = getRecentProjects();
|
|
279
|
-
|
|
280
|
-
|
|
290
|
+
pickerRows = q ? recent.filter((p) => p.toLowerCase().includes(q)) : recent;
|
|
291
|
+
if (!pickerRows.length) {
|
|
292
|
+
pickerIdx = -1;
|
|
293
|
+
const msg = recent.length ? 'No projects match' : 'No recent projects — add one below';
|
|
294
|
+
list.innerHTML = `<div class="picker-empty">${msg}</div>`;
|
|
281
295
|
return;
|
|
282
296
|
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
297
|
+
list.innerHTML = pickerRows
|
|
298
|
+
.map((p, i) => {
|
|
299
|
+
const { name, parent } = splitProjectPath(p);
|
|
300
|
+
const cur = p === current;
|
|
301
|
+
return `<div class="picker-row${cur ? ' current' : ''}" onclick="_pickProject(${i})" title="${esc(p)}">
|
|
302
|
+
<span class="picker-name">${esc(name)}</span>
|
|
303
|
+
<span class="picker-parent">${esc(parent)}</span>
|
|
304
|
+
${cur ? '<span class="picker-tag">current</span>' : ''}
|
|
305
|
+
<button class="picker-remove" onclick="_removeRecentProject(${i}, event)" title="Remove">✕</button>
|
|
306
|
+
</div>`;
|
|
307
|
+
})
|
|
308
|
+
.join('');
|
|
309
|
+
// Opens on the first row that would actually change something.
|
|
310
|
+
setPickerCursor(pickerRows.findIndex((p) => p !== current));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function setPickerCursor(idx) {
|
|
314
|
+
const list = document.getElementById('projectPickerList');
|
|
315
|
+
list.children[pickerIdx]?.classList.remove('selected');
|
|
316
|
+
pickerIdx = Math.min(Math.max(idx, 0), pickerRows.length - 1);
|
|
317
|
+
const row = list.children[pickerIdx];
|
|
318
|
+
row?.classList.add('selected');
|
|
319
|
+
row?.scrollIntoView({ block: 'nearest' });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function _pickProject(idx) {
|
|
323
|
+
const p = pickerRows[idx];
|
|
324
|
+
if (!p) return;
|
|
325
|
+
document.getElementById('projectPathInput').value = p;
|
|
326
|
+
submitProjectPicker();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function _removeRecentProject(idx, e) {
|
|
330
|
+
e.stopPropagation();
|
|
331
|
+
const target = pickerRows[idx];
|
|
332
|
+
if (!target) return;
|
|
333
|
+
localStorage.setItem('recentProjects', JSON.stringify(getRecentProjects().filter((p) => p !== target)));
|
|
334
|
+
renderProjectPicker();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function initProjectPicker() {
|
|
338
|
+
document.getElementById('projectPickerAdd').addEventListener('click', () => setPickerMode(true));
|
|
339
|
+
document.getElementById('projectPickerBack').addEventListener('click', () => setPickerMode(false));
|
|
340
|
+
const filter = document.getElementById('projectPickerInput');
|
|
341
|
+
filter.addEventListener('input', renderProjectPicker);
|
|
342
|
+
filter.addEventListener('keydown', (e) => {
|
|
343
|
+
if (e.key === 'Escape') closeModal('projectPickerModal');
|
|
344
|
+
else if (e.key === 'ArrowDown' || (e.key === 'n' && e.ctrlKey)) setPickerCursor(pickerIdx + 1);
|
|
345
|
+
else if (e.key === 'ArrowUp' || (e.key === 'p' && e.ctrlKey)) setPickerCursor(pickerIdx - 1);
|
|
346
|
+
else if (e.key === 'Enter') _pickProject(pickerIdx);
|
|
347
|
+
else return;
|
|
348
|
+
e.preventDefault();
|
|
349
|
+
e.stopPropagation();
|
|
350
|
+
});
|
|
351
|
+
document.getElementById('projectPathInput').addEventListener('keydown', (e) => {
|
|
352
|
+
if (e.key === 'Enter') submitProjectPicker();
|
|
353
|
+
// Escape steps back to the list rather than closing — the list is where you came from.
|
|
354
|
+
else if (e.key === 'Escape') setPickerMode(false);
|
|
355
|
+
else return;
|
|
356
|
+
e.preventDefault();
|
|
357
|
+
e.stopPropagation();
|
|
358
|
+
});
|
|
294
359
|
}
|
|
295
360
|
|
|
296
361
|
// #endregion PROJECT
|
|
@@ -301,7 +366,80 @@ function closeModal(id) {
|
|
|
301
366
|
document.getElementById(id).classList.remove('open');
|
|
302
367
|
}
|
|
303
368
|
|
|
369
|
+
// Each entry pairs a left and a right group onto the same grid rows, so their
|
|
370
|
+
// headings sit level and the shorter group just leaves empty rows.
|
|
371
|
+
// `combo` joins the keys with a plus (a chord) instead of listing them as
|
|
372
|
+
// alternatives; `hub` marks rows that only work inside Claude Code Hub.
|
|
373
|
+
const SHORTCUT_PAIRS = [
|
|
374
|
+
[
|
|
375
|
+
{
|
|
376
|
+
title: 'Navigate',
|
|
377
|
+
rows: [
|
|
378
|
+
{ keys: ['j', '↓'], label: 'Next item' },
|
|
379
|
+
{ keys: ['k', '↑'], label: 'Previous item' },
|
|
380
|
+
{ keys: ['l', '→'], label: 'Expand / enter child' },
|
|
381
|
+
{ keys: ['h', '←'], label: 'Collapse / parent' },
|
|
382
|
+
{ keys: ['Enter'], label: 'Select item' },
|
|
383
|
+
{ keys: ['Esc'], label: 'Close modal' },
|
|
384
|
+
],
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
title: 'Actions',
|
|
388
|
+
rows: [
|
|
389
|
+
{ keys: ['e'], label: 'Open in editor' },
|
|
390
|
+
{ keys: ['r'], label: 'Refresh data' },
|
|
391
|
+
{ keys: ['t'], label: 'Toggle theme' },
|
|
392
|
+
{ keys: ['Shift', 'P'], combo: true, label: 'Switch project' },
|
|
393
|
+
{ keys: ['?'], label: 'Show this help' },
|
|
394
|
+
],
|
|
395
|
+
},
|
|
396
|
+
],
|
|
397
|
+
[
|
|
398
|
+
{
|
|
399
|
+
title: 'Hub',
|
|
400
|
+
hub: true,
|
|
401
|
+
rows: [
|
|
402
|
+
{ keys: ['Ctrl', 'Alt', '←/→'], combo: true, label: 'Previous / next hub app' },
|
|
403
|
+
{ keys: ['Alt', '1…9'], combo: true, label: 'Jump to hub app by number' },
|
|
404
|
+
{ keys: ['Ctrl', 'Alt', 'P'], combo: true, label: 'Project picker' },
|
|
405
|
+
],
|
|
406
|
+
},
|
|
407
|
+
],
|
|
408
|
+
];
|
|
409
|
+
|
|
410
|
+
const EMPTY_GROUP = { title: '', rows: [] };
|
|
411
|
+
|
|
412
|
+
// Interleaves each pair's rows left-then-right so CSS grid auto-placement lands
|
|
413
|
+
// them on shared row tracks (see .shortcuts in style.css).
|
|
414
|
+
function buildHelpShortcuts() {
|
|
415
|
+
const cells = [];
|
|
416
|
+
SHORTCUT_PAIRS.forEach(([left, right = EMPTY_GROUP], pair) => {
|
|
417
|
+
const first = pair === 0 ? ' sc-first' : '';
|
|
418
|
+
const sides = [
|
|
419
|
+
[left, 'sc-l'],
|
|
420
|
+
[right, 'sc-r'],
|
|
421
|
+
];
|
|
422
|
+
const head = (g, side) =>
|
|
423
|
+
g.title ? `<div class="${esc(`sc-group ${side}${first}${g.hub ? ' sc-hub' : ''}`)}">${g.title}</div>` : '';
|
|
424
|
+
cells.push(sides.map(([g, side]) => head(g, side)).join(''));
|
|
425
|
+
for (let i = 0; i < Math.max(left.rows.length, right.rows.length); i++) {
|
|
426
|
+
for (const [group, side] of sides) {
|
|
427
|
+
const row = group.rows[i];
|
|
428
|
+
if (!row) continue;
|
|
429
|
+
const sep = row.combo ? '<span class="sc-plus">+</span>' : '<span class="sc-or">/</span>';
|
|
430
|
+
const keys = row.keys.map((k) => `<kbd>${esc(k)}</kbd>`).join(sep);
|
|
431
|
+
const cls = side + (group.hub ? ' sc-hub' : '');
|
|
432
|
+
cells.push(`<dt class="${esc(cls)}">${keys}</dt><dd class="${esc(cls)}">${esc(row.label)}</dd>`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
return cells.join('');
|
|
437
|
+
}
|
|
438
|
+
|
|
304
439
|
function toggleHelpModal() {
|
|
440
|
+
const list = document.getElementById('helpShortcuts');
|
|
441
|
+
if (!list.childElementCount) list.innerHTML = buildHelpShortcuts();
|
|
442
|
+
list.classList.toggle('sc-standalone', !window.__HUB__?.enabled);
|
|
305
443
|
document.getElementById('helpModal').classList.toggle('open');
|
|
306
444
|
}
|
|
307
445
|
|
|
@@ -312,19 +450,6 @@ function openHealthView() {
|
|
|
312
450
|
if (healthViewOpen) refreshAnalysis();
|
|
313
451
|
}
|
|
314
452
|
|
|
315
|
-
function bindModalKeys(inputId, modalId, submitFn) {
|
|
316
|
-
document.getElementById(inputId).addEventListener('keydown', (e) => {
|
|
317
|
-
if (e.key === 'Enter') {
|
|
318
|
-
e.preventDefault();
|
|
319
|
-
submitFn();
|
|
320
|
-
}
|
|
321
|
-
if (e.key === 'Escape') {
|
|
322
|
-
e.preventDefault();
|
|
323
|
-
closeModal(modalId);
|
|
324
|
-
}
|
|
325
|
-
});
|
|
326
|
-
}
|
|
327
|
-
|
|
328
453
|
// #endregion MODAL
|
|
329
454
|
|
|
330
455
|
// #region RENDER_TREE
|
|
@@ -1055,11 +1180,7 @@ async function refreshAnalysis() {
|
|
|
1055
1180
|
computeTreeHealth(st);
|
|
1056
1181
|
renderTree();
|
|
1057
1182
|
if (isNew && !healthViewOpen && selectedFileId) renderPreview();
|
|
1058
|
-
|
|
1059
|
-
if (el) {
|
|
1060
|
-
el.innerHTML = renderAnalysis(st);
|
|
1061
|
-
applyScopeIndeterminate(el);
|
|
1062
|
-
}
|
|
1183
|
+
rerenderAnalysis(st);
|
|
1063
1184
|
}
|
|
1064
1185
|
lastPollKey = pollKey;
|
|
1065
1186
|
clearTimeout(analysisPollTimer);
|
|
@@ -1072,11 +1193,16 @@ async function refreshAnalysis() {
|
|
|
1072
1193
|
|
|
1073
1194
|
// Re-render from the cached state — used by scope/filter/dismiss handlers so a
|
|
1074
1195
|
// checkbox click never triggers a server round-trip.
|
|
1075
|
-
function rerenderAnalysis() {
|
|
1196
|
+
function rerenderAnalysis(st = lastAnalysisSt) {
|
|
1076
1197
|
const el = document.getElementById('analysisSection');
|
|
1077
|
-
if (!el || !
|
|
1078
|
-
|
|
1198
|
+
if (!el || !st) return;
|
|
1199
|
+
// The scope tree is the scroll container; innerHTML replaces it, which would
|
|
1200
|
+
// snap a group toggle deep in the list back to the top of the panel.
|
|
1201
|
+
const scroll = el.querySelector('.scope-tree')?.scrollTop || 0;
|
|
1202
|
+
el.innerHTML = renderAnalysis(st);
|
|
1079
1203
|
applyScopeIndeterminate(el);
|
|
1204
|
+
const tree = el.querySelector('.scope-tree');
|
|
1205
|
+
if (tree) tree.scrollTop = scroll;
|
|
1080
1206
|
}
|
|
1081
1207
|
|
|
1082
1208
|
// `indeterminate` is a DOM property, not an attribute — set it after every innerHTML render.
|
|
@@ -1216,9 +1342,25 @@ function memoryDirPath() {
|
|
|
1216
1342
|
return idx ? idx.path.replace(/[\\/][^\\/]+$/, '') : null;
|
|
1217
1343
|
}
|
|
1218
1344
|
|
|
1345
|
+
// Resolve a finding's bare file names to the live stack sources they name, so a
|
|
1346
|
+
// prompt can cite real paths instead of guessing a location from `scope`.
|
|
1347
|
+
function findingSources(f) {
|
|
1348
|
+
const ids = new Set();
|
|
1349
|
+
for (const name of f.files || []) {
|
|
1350
|
+
for (const id of findingFileIds(name, f.scope)) ids.add(id);
|
|
1351
|
+
}
|
|
1352
|
+
return [...ids].map((id) => stackData.find((s) => s.id === id)).filter((s) => s?.path);
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1219
1355
|
function findingPrompt(f) {
|
|
1356
|
+
const sources = findingSources(f);
|
|
1357
|
+
const paths = sources.map((s) => s.path);
|
|
1220
1358
|
let header;
|
|
1221
|
-
if (
|
|
1359
|
+
if (paths.length === 1) {
|
|
1360
|
+
header = `In the Claude Code instruction file at ${paths[0]}, apply this fix:`;
|
|
1361
|
+
} else if (paths.length > 1) {
|
|
1362
|
+
header = 'In the Claude Code instruction files listed below, apply this fix:';
|
|
1363
|
+
} else if (f.scope === 'user') {
|
|
1222
1364
|
const path = stackData.find((s) => s.id === 'user-claude-md')?.path || '~/.claude/CLAUDE.md';
|
|
1223
1365
|
header = `In the user-level Claude Code instructions at ${path}, apply this fix:`;
|
|
1224
1366
|
} else {
|
|
@@ -1228,8 +1370,12 @@ function findingPrompt(f) {
|
|
|
1228
1370
|
: "In this project's Claude Code auto-memory, apply this fix:";
|
|
1229
1371
|
}
|
|
1230
1372
|
const lines = [header, '', `Finding (${f.kind}, ${f.severity}): ${f.title}`, f.detail, '', `Action: ${f.suggestion}`];
|
|
1231
|
-
|
|
1232
|
-
if (
|
|
1373
|
+
const cited = paths.length ? paths : f.files || [];
|
|
1374
|
+
if (cited.length) lines.push(`Files: ${cited.join(', ')}`);
|
|
1375
|
+
// The index note only applies to auto-memory files; a CLAUDE.md or SKILL.md
|
|
1376
|
+
// fix has no MEMORY.md to keep in sync.
|
|
1377
|
+
const touchesMemory = paths.length ? sources.some((s) => s.scope === 'memory') : f.scope !== 'user';
|
|
1378
|
+
if (touchesMemory) lines.push('', 'Update MEMORY.md index lines if files are added, renamed, or removed.');
|
|
1233
1379
|
return lines.join('\n');
|
|
1234
1380
|
}
|
|
1235
1381
|
|
|
@@ -1890,7 +2036,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
1890
2036
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
|
1891
2037
|
const modal = document.querySelector('.modal-overlay.open');
|
|
1892
2038
|
if (modal) {
|
|
1893
|
-
if (e.key === 'Escape') {
|
|
2039
|
+
if (e.key === 'Escape' || (e.key === '?' && modal.id === 'helpModal')) {
|
|
1894
2040
|
modal.classList.remove('open');
|
|
1895
2041
|
e.preventDefault();
|
|
1896
2042
|
}
|
|
@@ -2095,7 +2241,7 @@ window.addEventListener('popstate', (e) => {
|
|
|
2095
2241
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
2096
2242
|
loadTheme();
|
|
2097
2243
|
initResize();
|
|
2098
|
-
|
|
2244
|
+
initProjectPicker();
|
|
2099
2245
|
// Handle ?project= query param, else restore last-used project from localStorage
|
|
2100
2246
|
const params = new URLSearchParams(location.search);
|
|
2101
2247
|
let desiredProject = params.get('project');
|
package/public/index.html
CHANGED
|
@@ -100,15 +100,21 @@
|
|
|
100
100
|
<button class="modal-close" onclick="closeModal('projectPickerModal')">✕</button>
|
|
101
101
|
</div>
|
|
102
102
|
<div class="modal-body">
|
|
103
|
-
<div class="
|
|
104
|
-
<
|
|
105
|
-
<
|
|
106
|
-
<
|
|
103
|
+
<div class="picker-filter" id="projectPickerFilter">
|
|
104
|
+
<input type="text" id="projectPickerInput" class="picker-input" placeholder="Filter projects..." autocomplete="off">
|
|
105
|
+
<div class="picker-list" id="projectPickerList"></div>
|
|
106
|
+
<button class="picker-add" id="projectPickerAdd">+ Add path...</button>
|
|
107
107
|
</div>
|
|
108
|
-
<div id="
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
108
|
+
<div class="picker-add-form" id="projectPickerAddForm" hidden>
|
|
109
|
+
<div class="modal-field">
|
|
110
|
+
<label>Project directory</label>
|
|
111
|
+
<input type="text" id="projectPathInput" placeholder="/path/to/project" autocomplete="off">
|
|
112
|
+
<span class="modal-hint">Full path to the project directory</span>
|
|
113
|
+
</div>
|
|
114
|
+
<div class="modal-actions">
|
|
115
|
+
<button class="action-btn" id="projectPickerBack">Back</button>
|
|
116
|
+
<button class="action-btn primary" id="projectPickerSubmit" onclick="submitProjectPicker()">Switch</button>
|
|
117
|
+
</div>
|
|
112
118
|
</div>
|
|
113
119
|
</div>
|
|
114
120
|
</div>
|
|
@@ -152,7 +158,7 @@
|
|
|
152
158
|
|
|
153
159
|
<!-- Help modal -->
|
|
154
160
|
<div class="modal-overlay" id="helpModal" onclick="if(event.target===this)toggleHelpModal()">
|
|
155
|
-
<div class="modal">
|
|
161
|
+
<div class="modal modal-help">
|
|
156
162
|
<div class="modal-header">
|
|
157
163
|
<h3>Memory Diagnoser</h3>
|
|
158
164
|
<button class="modal-close" onclick="toggleHelpModal()">✕</button>
|
|
@@ -161,31 +167,8 @@
|
|
|
161
167
|
<p class="modal-desc">Browse and understand all memory sources that influence Claude Code behavior for your project.</p>
|
|
162
168
|
<hr class="modal-divider">
|
|
163
169
|
<h4 class="shortcut-section">Keyboard Shortcuts</h4>
|
|
164
|
-
<
|
|
165
|
-
<div>
|
|
166
|
-
<h4 class="shortcut-section">Navigation</h4>
|
|
167
|
-
<table class="shortcut-table">
|
|
168
|
-
<tr><td><kbd>j</kbd> / <kbd>↓</kbd></td><td>Next item</td></tr>
|
|
169
|
-
<tr><td><kbd>k</kbd> / <kbd>↑</kbd></td><td>Previous item</td></tr>
|
|
170
|
-
<tr><td><kbd>l</kbd> / <kbd>→</kbd></td><td>Expand / enter child</td></tr>
|
|
171
|
-
<tr><td><kbd>h</kbd> / <kbd>←</kbd></td><td>Collapse / parent</td></tr>
|
|
172
|
-
<tr><td><kbd>Enter</kbd></td><td>Select item</td></tr>
|
|
173
|
-
<tr><td><kbd>Esc</kbd></td><td>Close modal</td></tr>
|
|
174
|
-
</table>
|
|
175
|
-
</div>
|
|
176
|
-
<div>
|
|
177
|
-
<h4 class="shortcut-section">Actions</h4>
|
|
178
|
-
<table class="shortcut-table">
|
|
179
|
-
<tr><td><kbd>r</kbd></td><td>Refresh data</td></tr>
|
|
180
|
-
<tr><td><kbd>t</kbd></td><td>Toggle theme</td></tr>
|
|
181
|
-
<tr><td><kbd>e</kbd></td><td>Open in editor</td></tr>
|
|
182
|
-
<tr><td><kbd>Shift+P</kbd></td><td>Switch project</td></tr>
|
|
183
|
-
<tr><td><kbd>?</kbd></td><td>Show this help</td></tr>
|
|
184
|
-
</table>
|
|
185
|
-
</div>
|
|
186
|
-
</div>
|
|
170
|
+
<dl class="shortcuts" id="helpShortcuts"></dl>
|
|
187
171
|
</div>
|
|
188
|
-
<button class="modal-close-btn" onclick="toggleHelpModal()">Close</button>
|
|
189
172
|
</div>
|
|
190
173
|
</div>
|
|
191
174
|
|
package/public/style.css
CHANGED
|
@@ -805,6 +805,24 @@ body.light .tree-conditional .file-name {
|
|
|
805
805
|
max-height: 80vh;
|
|
806
806
|
overflow-y: auto;
|
|
807
807
|
}
|
|
808
|
+
/* The paired shortcut columns need room the 440px default cannot give. */
|
|
809
|
+
.modal.modal-help {
|
|
810
|
+
width: 960px;
|
|
811
|
+
}
|
|
812
|
+
/* Matches the roomier help modals in the other hub apps: larger title, 24px gutters. */
|
|
813
|
+
.modal-help .modal-header {
|
|
814
|
+
padding: 20px 24px;
|
|
815
|
+
}
|
|
816
|
+
.modal-help .modal-header h3 {
|
|
817
|
+
font-family: var(--font-mono);
|
|
818
|
+
font-size: 18px;
|
|
819
|
+
}
|
|
820
|
+
.modal-help .modal-body {
|
|
821
|
+
padding: 24px;
|
|
822
|
+
}
|
|
823
|
+
.modal-help .modal-desc {
|
|
824
|
+
font-size: 13px;
|
|
825
|
+
}
|
|
808
826
|
.modal.modal-wide {
|
|
809
827
|
width: 1140px;
|
|
810
828
|
}
|
|
@@ -878,21 +896,6 @@ body.light .tree-conditional .file-name {
|
|
|
878
896
|
border-top: 1px solid var(--border);
|
|
879
897
|
margin: 12px 0;
|
|
880
898
|
}
|
|
881
|
-
.modal-close-btn {
|
|
882
|
-
display: block;
|
|
883
|
-
width: calc(100% - 32px);
|
|
884
|
-
margin: 0 16px 16px;
|
|
885
|
-
padding: 8px;
|
|
886
|
-
background: var(--bg-elevated);
|
|
887
|
-
border: 1px solid var(--border);
|
|
888
|
-
border-radius: 4px;
|
|
889
|
-
color: var(--text-secondary);
|
|
890
|
-
cursor: pointer;
|
|
891
|
-
font-size: 12px;
|
|
892
|
-
}
|
|
893
|
-
.modal-close-btn:hover {
|
|
894
|
-
background: var(--bg-hover);
|
|
895
|
-
}
|
|
896
899
|
/* matches the marketplace app's action-btn look */
|
|
897
900
|
.action-btn {
|
|
898
901
|
padding: 4px 10px;
|
|
@@ -961,19 +964,90 @@ body.light .tree-conditional .file-name {
|
|
|
961
964
|
margin-bottom: 6px;
|
|
962
965
|
text-transform: uppercase;
|
|
963
966
|
}
|
|
964
|
-
|
|
967
|
+
/* One shared grid for both halves (key | label | spacer | key | label): each key
|
|
968
|
+
gutter is sized by its column's widest combo, and left/right rows share row
|
|
969
|
+
tracks, so paired groups line up — a shorter group just leaves empty rows.
|
|
970
|
+
Cells opt into a half via sc-l / sc-r; the markup is generated from
|
|
971
|
+
SHORTCUT_PAIRS in app.js. */
|
|
972
|
+
.shortcuts {
|
|
973
|
+
margin: 0;
|
|
965
974
|
display: grid;
|
|
966
|
-
grid-template-columns: 1fr 1fr;
|
|
967
|
-
gap:
|
|
975
|
+
grid-template-columns: max-content 1fr 28px max-content 1fr;
|
|
976
|
+
column-gap: 14px;
|
|
968
977
|
}
|
|
969
|
-
.
|
|
970
|
-
|
|
978
|
+
.shortcuts dt.sc-l {
|
|
979
|
+
grid-column: 1;
|
|
980
|
+
}
|
|
981
|
+
.shortcuts dd.sc-l {
|
|
982
|
+
grid-column: 2;
|
|
983
|
+
}
|
|
984
|
+
.shortcuts dt.sc-r {
|
|
985
|
+
grid-column: 4;
|
|
986
|
+
}
|
|
987
|
+
.shortcuts dd.sc-r {
|
|
988
|
+
grid-column: 5;
|
|
989
|
+
}
|
|
990
|
+
.shortcuts .sc-group.sc-l {
|
|
991
|
+
grid-column: 1 / 3;
|
|
992
|
+
}
|
|
993
|
+
.shortcuts .sc-group.sc-r {
|
|
994
|
+
grid-column: 4 / 6;
|
|
995
|
+
}
|
|
996
|
+
.shortcuts dt {
|
|
997
|
+
margin: 0;
|
|
998
|
+
display: flex;
|
|
999
|
+
align-items: center;
|
|
1000
|
+
align-self: center;
|
|
1001
|
+
gap: 4px;
|
|
1002
|
+
padding: 3px 0;
|
|
1003
|
+
}
|
|
1004
|
+
.shortcuts dd {
|
|
1005
|
+
margin: 0;
|
|
1006
|
+
/* The other hub apps set a mono body font; memory is sans, so the help rows
|
|
1007
|
+
opt into mono to keep the shortcut tables reading the same across apps. */
|
|
1008
|
+
font-family: var(--font-mono);
|
|
1009
|
+
font-size: 13px;
|
|
1010
|
+
color: var(--text-secondary);
|
|
1011
|
+
align-self: center;
|
|
1012
|
+
padding: 3px 0;
|
|
1013
|
+
}
|
|
1014
|
+
/* Section headings carry the column: full-width rule + primary ink, so the eye
|
|
1015
|
+
finds the group before it reads any single row. */
|
|
1016
|
+
.shortcuts .sc-group {
|
|
1017
|
+
padding: 18px 0 6px;
|
|
1018
|
+
margin-bottom: 6px;
|
|
1019
|
+
font-size: 11px;
|
|
1020
|
+
font-weight: 700;
|
|
1021
|
+
letter-spacing: 0.16em;
|
|
1022
|
+
text-transform: uppercase;
|
|
1023
|
+
color: var(--text-primary);
|
|
1024
|
+
border-bottom: 1px solid var(--border);
|
|
971
1025
|
}
|
|
972
|
-
.
|
|
973
|
-
padding: 2px
|
|
1026
|
+
.shortcuts .sc-group.sc-first {
|
|
1027
|
+
padding-top: 2px;
|
|
974
1028
|
}
|
|
975
|
-
|
|
976
|
-
|
|
1029
|
+
/* keycap chips: thicker bottom border reads as a key */
|
|
1030
|
+
.shortcuts kbd {
|
|
1031
|
+
display: inline-flex;
|
|
1032
|
+
align-items: center;
|
|
1033
|
+
justify-content: center;
|
|
1034
|
+
min-width: 22px;
|
|
1035
|
+
height: 22px;
|
|
1036
|
+
padding: 0 6px;
|
|
1037
|
+
box-sizing: border-box;
|
|
1038
|
+
color: var(--text-primary);
|
|
1039
|
+
border-bottom-width: 2px;
|
|
1040
|
+
border-radius: 5px;
|
|
1041
|
+
}
|
|
1042
|
+
.shortcuts .sc-plus,
|
|
1043
|
+
.shortcuts .sc-or {
|
|
1044
|
+
color: var(--text-muted);
|
|
1045
|
+
font-size: 11px;
|
|
1046
|
+
}
|
|
1047
|
+
/* Hub keys do nothing outside Claude Code Hub, so the standalone app hides that
|
|
1048
|
+
whole column rather than listing keys that no-op. */
|
|
1049
|
+
.shortcuts.sc-standalone .sc-hub {
|
|
1050
|
+
display: none;
|
|
977
1051
|
}
|
|
978
1052
|
kbd {
|
|
979
1053
|
background: var(--bg-hover);
|
|
@@ -989,31 +1063,77 @@ kbd {
|
|
|
989
1063
|
font-size: 10px;
|
|
990
1064
|
line-height: 1;
|
|
991
1065
|
}
|
|
992
|
-
.
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
1066
|
+
.picker-filter,
|
|
1067
|
+
.picker-add-form {
|
|
1068
|
+
display: flex;
|
|
1069
|
+
flex-direction: column;
|
|
1070
|
+
gap: 10px;
|
|
1071
|
+
}
|
|
1072
|
+
.picker-filter[hidden],
|
|
1073
|
+
.picker-add-form[hidden] {
|
|
1074
|
+
display: none;
|
|
1075
|
+
}
|
|
1076
|
+
.picker-input {
|
|
1077
|
+
width: 100%;
|
|
1078
|
+
padding: 8px 10px;
|
|
1079
|
+
background: var(--bg-deep);
|
|
1080
|
+
border: 1px solid var(--border);
|
|
1081
|
+
border-radius: 4px;
|
|
1082
|
+
color: var(--text-primary);
|
|
1083
|
+
font-family: var(--font-mono);
|
|
1084
|
+
font-size: 13px;
|
|
1085
|
+
}
|
|
1086
|
+
.picker-input:focus {
|
|
1087
|
+
outline: none;
|
|
1088
|
+
border-color: var(--accent);
|
|
996
1089
|
}
|
|
997
|
-
.
|
|
1090
|
+
.picker-list {
|
|
1091
|
+
max-height: 50vh;
|
|
1092
|
+
overflow-y: auto;
|
|
998
1093
|
display: flex;
|
|
999
|
-
|
|
1094
|
+
flex-direction: column;
|
|
1095
|
+
gap: 2px;
|
|
1096
|
+
}
|
|
1097
|
+
.picker-row {
|
|
1098
|
+
display: flex;
|
|
1099
|
+
align-items: baseline;
|
|
1100
|
+
gap: 8px;
|
|
1000
1101
|
padding: 5px 8px;
|
|
1102
|
+
border: 1px solid transparent;
|
|
1001
1103
|
border-radius: 4px;
|
|
1002
1104
|
cursor: pointer;
|
|
1003
1105
|
font-size: 12px;
|
|
1004
1106
|
font-family: var(--font-mono);
|
|
1005
1107
|
color: var(--text-secondary);
|
|
1006
1108
|
}
|
|
1007
|
-
.
|
|
1109
|
+
.picker-row:hover {
|
|
1008
1110
|
background: var(--bg-hover);
|
|
1111
|
+
color: var(--text-primary);
|
|
1112
|
+
}
|
|
1113
|
+
.picker-row.selected {
|
|
1114
|
+
background: var(--bg-hover);
|
|
1115
|
+
border-color: var(--accent);
|
|
1116
|
+
color: var(--text-primary);
|
|
1009
1117
|
}
|
|
1010
|
-
.
|
|
1118
|
+
.picker-name {
|
|
1119
|
+
white-space: nowrap;
|
|
1120
|
+
}
|
|
1121
|
+
.picker-parent {
|
|
1011
1122
|
flex: 1;
|
|
1123
|
+
min-width: 0;
|
|
1012
1124
|
overflow: hidden;
|
|
1013
1125
|
text-overflow: ellipsis;
|
|
1014
1126
|
white-space: nowrap;
|
|
1127
|
+
font-size: 11px;
|
|
1128
|
+
color: var(--text-muted);
|
|
1129
|
+
}
|
|
1130
|
+
.picker-tag {
|
|
1131
|
+
font-size: 10px;
|
|
1132
|
+
text-transform: uppercase;
|
|
1133
|
+
letter-spacing: 0.5px;
|
|
1134
|
+
color: var(--accent-text);
|
|
1015
1135
|
}
|
|
1016
|
-
.
|
|
1136
|
+
.picker-remove {
|
|
1017
1137
|
background: none;
|
|
1018
1138
|
border: none;
|
|
1019
1139
|
color: var(--text-muted);
|
|
@@ -1022,9 +1142,33 @@ kbd {
|
|
|
1022
1142
|
padding: 0 4px;
|
|
1023
1143
|
opacity: 0;
|
|
1024
1144
|
}
|
|
1025
|
-
.
|
|
1145
|
+
.picker-row:hover .picker-remove,
|
|
1146
|
+
.picker-row.selected .picker-remove {
|
|
1026
1147
|
opacity: 1;
|
|
1027
1148
|
}
|
|
1149
|
+
.picker-remove:hover {
|
|
1150
|
+
color: var(--error);
|
|
1151
|
+
}
|
|
1152
|
+
.picker-empty {
|
|
1153
|
+
padding: 12px 10px;
|
|
1154
|
+
color: var(--text-muted);
|
|
1155
|
+
font-size: 12px;
|
|
1156
|
+
}
|
|
1157
|
+
.picker-add {
|
|
1158
|
+
width: 100%;
|
|
1159
|
+
text-align: left;
|
|
1160
|
+
background: none;
|
|
1161
|
+
border: none;
|
|
1162
|
+
border-top: 1px solid var(--border);
|
|
1163
|
+
padding: 8px 2px 2px;
|
|
1164
|
+
color: var(--text-muted);
|
|
1165
|
+
cursor: pointer;
|
|
1166
|
+
font-family: inherit;
|
|
1167
|
+
font-size: 12px;
|
|
1168
|
+
}
|
|
1169
|
+
.picker-add:hover {
|
|
1170
|
+
color: var(--accent-text);
|
|
1171
|
+
}
|
|
1028
1172
|
/* #endregion MODAL */
|
|
1029
1173
|
|
|
1030
1174
|
/* #region LOADING */
|
|
@@ -1131,8 +1275,12 @@ body.light {
|
|
|
1131
1275
|
.summary-bar {
|
|
1132
1276
|
flex-wrap: wrap;
|
|
1133
1277
|
}
|
|
1134
|
-
|
|
1135
|
-
|
|
1278
|
+
/* Rows are interleaved left-then-right, so a one-column stack would read out
|
|
1279
|
+
of order. Keep both halves and just tighten the gutters. */
|
|
1280
|
+
.shortcuts {
|
|
1281
|
+
grid-template-columns: max-content 1fr 12px max-content 1fr;
|
|
1282
|
+
column-gap: 8px;
|
|
1283
|
+
font-size: 11px;
|
|
1136
1284
|
}
|
|
1137
1285
|
}
|
|
1138
1286
|
/* #endregion RESPONSIVE */
|
package/server.js
CHANGED
|
@@ -850,6 +850,25 @@ const RUBRIC_CLAUDE_MD_USER = [
|
|
|
850
850
|
'- Preferences should carry their why: a bare prohibition gets overridden when inconvenient; a one-clause reason makes it generalize.',
|
|
851
851
|
].join('\n');
|
|
852
852
|
|
|
853
|
+
// The server already knows every `@import` a file makes: parseImports() carries the
|
|
854
|
+
// grammar (npm scopes excluded, extension required) and resolveImport() the resolution
|
|
855
|
+
// (~ = home, else relative to the importing file). Handing the reviewer that answer as
|
|
856
|
+
// fact beats shipping it a prose reimplementation it would run differently each time.
|
|
857
|
+
// Hard `@` imports only — markdown links are references, not inlined content.
|
|
858
|
+
function importLines(src) {
|
|
859
|
+
const lines = [`PATH: ${src.path}`];
|
|
860
|
+
const resolved = [];
|
|
861
|
+
const missing = [];
|
|
862
|
+
for (const imp of parseImports(src.content).imports) {
|
|
863
|
+
const abs = resolveImport(imp, src.path);
|
|
864
|
+
if (fs.existsSync(abs)) resolved.push(abs);
|
|
865
|
+
else missing.push(`${imp} (tried ${abs})`);
|
|
866
|
+
}
|
|
867
|
+
if (resolved.length) lines.push(`IMPORTS: ${resolved.join(', ')}`);
|
|
868
|
+
if (missing.length) lines.push(`MISSING IMPORTS: ${missing.join(', ')}`);
|
|
869
|
+
return lines;
|
|
870
|
+
}
|
|
871
|
+
|
|
853
872
|
function buildAnalyzePrompt(memSources, skillSources, agentSources, claudeMdSources, userSrc, userAudited) {
|
|
854
873
|
const fullSkills = skillSources || [];
|
|
855
874
|
const fullAgent = agentSources || [];
|
|
@@ -885,8 +904,9 @@ function buildAnalyzePrompt(memSources, skillSources, agentSources, claudeMdSour
|
|
|
885
904
|
parts.push(
|
|
886
905
|
'',
|
|
887
906
|
'Rules:',
|
|
888
|
-
`- You are the orchestrator; do not review files yourself. Your working directory is the project root. For every audited file, launch the type-matched reviewer subagent via your Agent tool — "memory-reviewer" for memory and agent-memory files, "skill-reviewer" for skills, "claude-md-reviewer" for project CLAUDE.md files${userAudited ? ', "claude-md-user-reviewer" for the user-level CLAUDE.md' : ''} — all in a single parallel batch of blocking calls (never background/async agents — you must hold your final report until every reviewer has returned). Pass each one only the file name and its
|
|
907
|
+
`- You are the orchestrator; do not review files yourself. Your working directory is the project root. For every audited file, launch the type-matched reviewer subagent via your Agent tool — "memory-reviewer" for memory and agent-memory files, "skill-reviewer" for skills, "claude-md-reviewer" for project CLAUDE.md files${userAudited ? ', "claude-md-user-reviewer" for the user-level CLAUDE.md' : ''} — all in a single parallel batch of blocking calls (never background/async agents — you must hold your final report until every reviewer has returned). Pass each one only the file name, its full content, and — for skills and CLAUDE.md files — the PATH:, IMPORTS:, and MISSING IMPORTS: lines from its block below; the reviewers carry their own criteria and verify claims against the repo${userAudited ? ' (the user-level reviewer verifies against the machine, never the repo)' : ''}.`,
|
|
889
908
|
`- Your own job: merge the reviewers' per-file findings and add the cross-file kinds (duplicate, contradiction, merge, promote${crossScope ? ', override, shadow' : ''}) by comparing the files side by side.`,
|
|
909
|
+
'- @imports are inlined content: the files on a block\'s IMPORTS: line load as part of that file. Reviewers read them, so treat what they report from an import as belonging to the importing file when you compare files side by side — a rule imported by one file and written out in another is still a duplicate. Imported files are never audited on their own; attribute every finding to the importer.',
|
|
890
910
|
'- Verification is a filter: drop any finding a reviewer\'s claim checks disproved, and never report "likely outdated" for a claim a reviewer confirmed still HOLDS. Record what was checked in the finding\'s "evidence" field (one sentence, internal bookkeeping — it is not shown to the user). Omit "evidence" for judgment-only findings.',
|
|
891
911
|
'- files: the exact file names involved as listed below (e.g. feedback_foo.md, MEMORY.md, or a skill name like my-skill). Bare names only — never append scope markers like "(user)"; scope is carried by the scope field.',
|
|
892
912
|
'- scope: "user" if the finding only cites the user-level CLAUDE.md (demote findings are always "user"); "cross" if it cites both the user-level file and any project-side file (override, shadow, and promote findings are "cross"); "project" for everything else.',
|
|
@@ -900,9 +920,12 @@ function buildAnalyzePrompt(memSources, skillSources, agentSources, claudeMdSour
|
|
|
900
920
|
'',
|
|
901
921
|
);
|
|
902
922
|
if (userAudited) {
|
|
903
|
-
parts.push('USER CLAUDE.MD (audit this with claude-md-user-reviewer):', `=== FILE (user): ${userSrc.name} ===`, userSrc.content, '');
|
|
923
|
+
parts.push('USER CLAUDE.MD (audit this with claude-md-user-reviewer):', `=== FILE (user): ${userSrc.name} ===`, ...importLines(userSrc), userSrc.content, '');
|
|
904
924
|
} else {
|
|
905
|
-
parts.push(
|
|
925
|
+
parts.push(
|
|
926
|
+
'GLOBAL CLAUDE.md (reference only, do not audit it):',
|
|
927
|
+
...(userSrc ? importLines(userSrc).slice(1).map((l) => `${l} — inlined into the global file; read these before judging a rule missing from it`) : []),
|
|
928
|
+
'<<<', userSrc?.content || '(none)', '>>>', '');
|
|
906
929
|
}
|
|
907
930
|
if (memSources.length) {
|
|
908
931
|
parts.push('MEMORY FILES:');
|
|
@@ -913,7 +936,7 @@ function buildAnalyzePrompt(memSources, skillSources, agentSources, claudeMdSour
|
|
|
913
936
|
if (fullSkills.length) {
|
|
914
937
|
parts.push('PROJECT SKILLS (audit these):');
|
|
915
938
|
for (const s of fullSkills) {
|
|
916
|
-
parts.push(`=== SKILL: ${s.name} ===`, s.content, '');
|
|
939
|
+
parts.push(`=== SKILL: ${s.name} ===`, ...importLines(s), s.content, '');
|
|
917
940
|
}
|
|
918
941
|
}
|
|
919
942
|
if (fullAgent.length) {
|
|
@@ -925,7 +948,7 @@ function buildAnalyzePrompt(memSources, skillSources, agentSources, claudeMdSour
|
|
|
925
948
|
if (fullClaudeMd.length) {
|
|
926
949
|
parts.push('CLAUDE.MD FILES (audit these):');
|
|
927
950
|
for (const s of fullClaudeMd) {
|
|
928
|
-
parts.push(`=== FILE (${s.scope}): ${s.name} ===`, s.content, '');
|
|
951
|
+
parts.push(`=== FILE (${s.scope}): ${s.name} ===`, ...importLines(s), s.content, '');
|
|
929
952
|
}
|
|
930
953
|
}
|
|
931
954
|
return parts.join('\n');
|
|
@@ -940,6 +963,7 @@ const REVIEWER_COMMON = [
|
|
|
940
963
|
'You review exactly one file that shapes Claude Code behavior; the parent passes its name and full content.',
|
|
941
964
|
'Verify before judging: for every claim that references the repository in your working directory — a file, directory, command, script, flag, or path — check whether it still holds with your read-only tools. For tools the file expects on the machine, check existence with `command -v` / `which` / `where` (the only Bash commands you may run). Never call something "likely stale" when you can check it.',
|
|
942
965
|
'Also judge whether the content is still needed at all: if the thing it guards against is gone (the tool was removed, the convention is enforced elsewhere, the referenced file no longer exists), say so explicitly.',
|
|
966
|
+
'A check you could not run is not evidence of absence. If a probe is denied by permissions or errors out, report that claim as UNCHECKED and say why — never turn a failed check into a stale or invalidate finding.',
|
|
943
967
|
];
|
|
944
968
|
|
|
945
969
|
// The user reviewer swaps repo verification for machine verification — the working
|
|
@@ -948,6 +972,7 @@ const REVIEWER_COMMON_USER = [
|
|
|
948
972
|
'You review the user-level (global) CLAUDE.md that loads in every session of every project; the parent passes its name and full content.',
|
|
949
973
|
'Verify before judging, against the machine only: check tools the file names with `command -v` / `which` / `where` (the only Bash commands you may run), and check files, skills, or agents it references under ~/.claude with your read tools. NEVER read or judge against the repository in your working directory — it is one arbitrary project and proves nothing about a global rule.',
|
|
950
974
|
'Also judge whether the content is still needed at all: if the tool it references is gone from the machine or the file it points to no longer exists, say so explicitly.',
|
|
975
|
+
'A check you could not run is not evidence of absence. If a probe is denied by permissions or errors out, report that claim as UNCHECKED and say why — never turn a failed check into a stale or invalidate finding.',
|
|
951
976
|
];
|
|
952
977
|
|
|
953
978
|
const REVIEWER_REPORT = [
|
|
@@ -956,10 +981,24 @@ const REVIEWER_REPORT = [
|
|
|
956
981
|
'2. FINDINGS: candidate findings for this file only (kind: stale, invalidate, or quality; severity high/med/low; title; detail; one-sentence fix). An empty list is a valid answer — do not pad.',
|
|
957
982
|
];
|
|
958
983
|
|
|
984
|
+
// `@path/to/file.md` in a CLAUDE.md or a skill is an import: Claude Code inlines the
|
|
985
|
+
// target's content where the token sits. A reviewer that judges only the text in front
|
|
986
|
+
// of it reads an import as a dangling reference and never sees the rules the file
|
|
987
|
+
// actually loads. `importLines()` resolves the graph server-side and states it as fact,
|
|
988
|
+
// so the rubric only has to say what an import means. Memory files are excluded: the
|
|
989
|
+
// imports they make are audited as sources in their own right.
|
|
990
|
+
const RUBRIC_IMPORTS = [
|
|
991
|
+
'Import notation (@path):',
|
|
992
|
+
'- The IMPORTS: line of your file block lists absolute paths Claude Code inlines into this file at load time. Read each one and judge its content against every criterion above as part of this file — including imports of imports. Duplication between the file and its own import, contradictions across its imports, and bloat pulled in through an import all count against the importing file.',
|
|
993
|
+
'- An @path token is an import, never a broken link or an unexplained reference: do not flag the pointer itself as vague or undefined. Anything on the MISSING IMPORTS: line resolves to nothing, and is the only missing-import finding to report (kind: stale).',
|
|
994
|
+
].join('\n');
|
|
995
|
+
|
|
996
|
+
// `rubric` takes one rubric constant or a list of them, so a reviewer can stack the
|
|
997
|
+
// shared import rubric on top of its per-type one without a second parameter.
|
|
959
998
|
function reviewerAgent(description, rubric, common = REVIEWER_COMMON) {
|
|
960
999
|
return {
|
|
961
1000
|
description,
|
|
962
|
-
prompt: [common.join('\n'), RUBRIC_CORE, rubric, REVIEWER_REPORT.join('\n')].join('\n\n'),
|
|
1001
|
+
prompt: [common.join('\n'), RUBRIC_CORE, ...[].concat(rubric), REVIEWER_REPORT.join('\n')].join('\n\n'),
|
|
963
1002
|
// Bash is permission-scoped to command -v / which / where by the spawn's
|
|
964
1003
|
// --allowedTools — anything else the reviewer tries is auto-denied in -p mode.
|
|
965
1004
|
tools: ['Read', 'Grep', 'Glob', 'Bash'],
|
|
@@ -973,15 +1012,15 @@ const ANALYSIS_AGENTS = {
|
|
|
973
1012
|
),
|
|
974
1013
|
'skill-reviewer': reviewerAgent(
|
|
975
1014
|
'Reviews and verifies one SKILL.md file against the skill rubric and the repository. One per skill, in parallel.',
|
|
976
|
-
RUBRIC_SKILL,
|
|
1015
|
+
[RUBRIC_SKILL, RUBRIC_IMPORTS],
|
|
977
1016
|
),
|
|
978
1017
|
'claude-md-reviewer': reviewerAgent(
|
|
979
1018
|
'Reviews and verifies one CLAUDE.md instruction file against the CLAUDE.md rubric and the repository. One per file, in parallel.',
|
|
980
|
-
RUBRIC_CLAUDE_MD,
|
|
1019
|
+
[RUBRIC_CLAUDE_MD, RUBRIC_IMPORTS],
|
|
981
1020
|
),
|
|
982
1021
|
'claude-md-user-reviewer': reviewerAgent(
|
|
983
1022
|
'Reviews and verifies the user-level (global) CLAUDE.md against the user rubric, PATH, and ~/.claude — never against the working repository.',
|
|
984
|
-
RUBRIC_CLAUDE_MD_USER,
|
|
1023
|
+
[RUBRIC_CLAUDE_MD_USER, RUBRIC_IMPORTS],
|
|
985
1024
|
REVIEWER_COMMON_USER,
|
|
986
1025
|
),
|
|
987
1026
|
};
|
|
@@ -1017,7 +1056,15 @@ function runClaudeAnalysis(prompt, model, cwd) {
|
|
|
1017
1056
|
try {
|
|
1018
1057
|
const envelope = JSON.parse(out);
|
|
1019
1058
|
if (envelope.is_error) return finish({ ok: false, error: String(envelope.result || 'Claude Code reported an error') });
|
|
1020
|
-
|
|
1059
|
+
// A run can exit clean without structured_output (schema retries exhausted,
|
|
1060
|
+
// truncated stdout). Spreading that absence downstream yields an empty
|
|
1061
|
+
// findings list, which the UI cannot tell apart from a genuine all-clear —
|
|
1062
|
+
// so treat a missing payload as a failure rather than as "no problems".
|
|
1063
|
+
const data = envelope.structured_output;
|
|
1064
|
+
if (!data || !Array.isArray(data.findings)) {
|
|
1065
|
+
return finish({ ok: false, error: 'Claude Code returned no structured output — the audit did not complete, so no result is being shown.' });
|
|
1066
|
+
}
|
|
1067
|
+
finish({ ok: true, data, costUsd: envelope.total_cost_usd, durationMs: envelope.duration_ms });
|
|
1021
1068
|
} catch {
|
|
1022
1069
|
finish({ ok: false, error: `Claude Code returned an invalid JSON envelope${err ? `: ${err.slice(0, 300)}` : ''}` });
|
|
1023
1070
|
}
|