claude-memory-admin 1.3.0 → 1.4.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 +42 -2
- package/package.json +1 -1
- package/public/app.mjs +310 -45
- package/public/ui.mjs +3 -2
- package/server.mjs +65 -3
- package/src/checks.mjs +187 -0
- package/src/instructions.mjs +37 -2
- package/src/model.mjs +6 -0
- package/src/pathcheck.mjs +108 -0
- package/src/settings.mjs +23 -1
package/README.md
CHANGED
|
@@ -97,8 +97,17 @@ project is to the cliff, and makes it quick to get back under it.
|
|
|
97
97
|
- **Graph** the wikilinks between memories. Hovering dims everything that is not
|
|
98
98
|
a neighbour, which is the only practical way to read a dense cluster.
|
|
99
99
|
- **Health**: orphans, dangling pointers, broken wikilinks, files linked only
|
|
100
|
-
mid-sentence, `name` fields that disagree with the filename
|
|
101
|
-
|
|
100
|
+
mid-sentence, `name` fields that disagree with the filename, two files claiming
|
|
101
|
+
one `name`, a file bulleted twice, a blank `description`, a `type` outside the
|
|
102
|
+
four documented ones, a memory that is frontmatter and little else, a hook that
|
|
103
|
+
only restates the description it points at, a heading with nothing under it, and
|
|
104
|
+
entries that spill onto a second line. An orphan can be given the `MEMORY.md`
|
|
105
|
+
bullet it is missing without leaving the page.
|
|
106
|
+
- **Every count is visible before you click.** Each tab carries a badge, coloured
|
|
107
|
+
amber for something to tidy and red for something actively broken, and the
|
|
108
|
+
sidebar gives each store a dot of its worst severity - memory, instructions and
|
|
109
|
+
settings together. Which project is in trouble is the first thing the app tells
|
|
110
|
+
you, not something you find by opening eight tabs each.
|
|
102
111
|
- **Dates you can trust.** Claude Code stamps `modified` into frontmatter, but
|
|
103
112
|
only on files that already have some, and never adds frontmatter to a file
|
|
104
113
|
without it. Anything falling back to the file's mtime is labelled, because
|
|
@@ -178,6 +187,11 @@ It also finds the failures that leave a file silently doing nothing:
|
|
|
178
187
|
- A markdown file sitting in `~/.claude` that nothing in the chain reaches. It
|
|
179
188
|
looks load-bearing and loads nothing, which is what happens when the import
|
|
180
189
|
that pulled it in is deleted or its content is inlined.
|
|
190
|
+
- One file reached by two chains - your `~/.claude/CLAUDE.md` and the project's
|
|
191
|
+
own both importing it, say. Both copies load, and you pay for the content twice
|
|
192
|
+
every session.
|
|
193
|
+
- A `CLAUDE.md` or rule file that is empty apart from its frontmatter. It costs a
|
|
194
|
+
read and contributes no instruction.
|
|
181
195
|
|
|
182
196
|
Backticks are respected, so a `` `@README` `` in your prose is not reported as an
|
|
183
197
|
import, and neither is an email address.
|
|
@@ -200,6 +214,26 @@ This is re-derived from the documented resolution rules rather than reported by
|
|
|
200
214
|
Claude Code, and the tab says so. Run `/context` in a session for the ground
|
|
201
215
|
truth, or the `InstructionsLoaded` hook to log exactly what loaded and why.
|
|
202
216
|
|
|
217
|
+
## The one check that reads your code
|
|
218
|
+
|
|
219
|
+
Every check above reads `memory/` and the `CLAUDE.md` chain, and nothing else.
|
|
220
|
+
One more is available and **off by default**, because it reads the project itself:
|
|
221
|
+
it takes the paths a memory names in a code span and asks whether anything in the
|
|
222
|
+
repository still matches them. A memory whose `Foo.cs` was renamed two months ago
|
|
223
|
+
still reads as authoritative, and nothing else on this page can tell.
|
|
224
|
+
|
|
225
|
+
Turn it on from the Health tab, per store; the choice is remembered in the browser
|
|
226
|
+
and nothing is written to disk. It walks the project once, skipping `.git`,
|
|
227
|
+
`node_modules`, build output and the like, and matches by suffix - a memory that
|
|
228
|
+
says `Infrastructure/Reporting/Foo.cs` for a file that really lives under
|
|
229
|
+
`services/argus/src` is right, and resolving that against the repository root
|
|
230
|
+
alone reported seven real paths in ten as missing. Only a token with a real source
|
|
231
|
+
extension is treated as a claim about a file, because memories are also full of
|
|
232
|
+
HTTP routes and type names that no file was ever going to match.
|
|
233
|
+
|
|
234
|
+
It is a pointer, not a verdict, and the tab says so: a file that moved reads the
|
|
235
|
+
same as one the memory never got right.
|
|
236
|
+
|
|
203
237
|
## Which settings are actually in force
|
|
204
238
|
|
|
205
239
|
Five files can set the keys this tool cares about, and the one everybody edits,
|
|
@@ -274,6 +308,10 @@ claude-memory-admin --root /tmp/memory-snapshot
|
|
|
274
308
|
## Safety
|
|
275
309
|
|
|
276
310
|
- Binds `127.0.0.1`; no telemetry, no network calls.
|
|
311
|
+
- Reads only `~/.claude/projects`, the agent memory directories and the `CLAUDE.md`
|
|
312
|
+
chain, unless you switch on the path check above, which then also walks that one
|
|
313
|
+
project's directory. It only ever reads: no path a memory names is opened, only
|
|
314
|
+
looked up in an index built from the project itself.
|
|
277
315
|
- Every write target must resolve to a plain `.md` file inside that project's own
|
|
278
316
|
`memory/` directory. `..`, absolute paths and subdirectories are refused.
|
|
279
317
|
- One exception, and only when you ask for it: **Remember path** writes
|
|
@@ -320,6 +358,8 @@ ready to serve and never builds anything. After editing the source, run
|
|
|
320
358
|
| `src/stores.mjs` | Store discovery: the user scope, auto memory and the three agent scopes |
|
|
321
359
|
| `src/instructions.mjs` | CLAUDE.md chain, `@` imports and rules resolution |
|
|
322
360
|
| `src/parse.mjs` | `MEMORY.md` and frontmatter parsers, wikilinks |
|
|
361
|
+
| `src/checks.mjs` | The consistency checks, as pure functions over parsed data |
|
|
362
|
+
| `src/pathcheck.mjs` | The opt-in check of paths a memory names, against the repo |
|
|
323
363
|
| `src/model.mjs` | Joins index, files, graph and health into one model |
|
|
324
364
|
| `src/stats.mjs` | Load-limit accounting and overlap detection |
|
|
325
365
|
| `src/search.mjs` | Full-text search across every store |
|
package/package.json
CHANGED
package/public/app.mjs
CHANGED
|
@@ -17,8 +17,14 @@ const state = {
|
|
|
17
17
|
pruneSort: 'oldest',
|
|
18
18
|
pruneSelection: new Set(),
|
|
19
19
|
indexView: localStorage.getItem('memoryIndexView') === 'source' ? 'source' : 'rendered',
|
|
20
|
+
aux: { instructions: null, settings: null },
|
|
21
|
+
pathCheck: null,
|
|
20
22
|
};
|
|
21
23
|
|
|
24
|
+
const RANK = { ok: 0, warn: 1, bad: 2 };
|
|
25
|
+
const worst = (...severities) => severities.reduce((a, b) => (RANK[b] > RANK[a] ? b : a), 'ok');
|
|
26
|
+
const worstSeverity = (items) => (!items.length ? 'ok' : items.some((item) => item.severity === 'bad') ? 'bad' : 'warn');
|
|
27
|
+
|
|
22
28
|
const el = (id) => document.getElementById(id);
|
|
23
29
|
|
|
24
30
|
async function api(path, options) {
|
|
@@ -88,16 +94,25 @@ function storeSubtitle(store) {
|
|
|
88
94
|
return `${scope} · ${store.sublabel}`;
|
|
89
95
|
}
|
|
90
96
|
|
|
97
|
+
function issueTitle(store) {
|
|
98
|
+
const parts = [];
|
|
99
|
+
if (store.issueCount) parts.push(`${store.issueCount} in Health`);
|
|
100
|
+
if (store.context) parts.push(`${store.context} in Context`);
|
|
101
|
+
if (store.settings) parts.push(`${store.settings} in Settings`);
|
|
102
|
+
if (store.kind === 'auto' && store.resolvedBy === 'unresolved') {
|
|
103
|
+
parts.push('Real path could not be resolved - showing the raw folder name');
|
|
104
|
+
}
|
|
105
|
+
return parts.length ? `${store.dir}\n${parts.join(', ')}` : store.dir;
|
|
106
|
+
}
|
|
107
|
+
|
|
91
108
|
function storeButton(store) {
|
|
92
109
|
const global = store.kind === 'global';
|
|
93
|
-
const health = global
|
|
110
|
+
const health = !global && !store.hasMemoryDir ? 'none' : store.severity || 'ok';
|
|
94
111
|
const off = store.autoMemory && store.autoMemory.known && !store.autoMemory.enabled;
|
|
95
112
|
return node('button', {
|
|
96
113
|
class: ui.storeItem({ active: store.id === state.storeId, empty: !global && !store.hasMemoryDir }),
|
|
97
114
|
onclick: () => openStore(store.id),
|
|
98
|
-
title: store
|
|
99
|
-
? 'Real path could not be resolved - showing the raw folder name'
|
|
100
|
-
: store.dir,
|
|
115
|
+
title: issueTitle(store),
|
|
101
116
|
}, [
|
|
102
117
|
node('span', { class: ui.storeRow }, [
|
|
103
118
|
node('span', { class: ui.dot(health) }),
|
|
@@ -155,17 +170,32 @@ function renderTabs() {
|
|
|
155
170
|
if (global && tab.id !== 'context') continue;
|
|
156
171
|
if (!global && tab.id === 'context' && !hasProjectDir) continue;
|
|
157
172
|
let badge = null;
|
|
173
|
+
let tone = 'neutral';
|
|
158
174
|
if (tab.id === 'memories') badge = String(state.store.memories.length);
|
|
159
|
-
if (tab.id === 'health' && state.store.health.issueCount) badge = String(state.store.health.issueCount);
|
|
160
175
|
if (tab.id === 'trash' && state.store.trash.length) badge = String(state.store.trash.length);
|
|
161
|
-
if (tab.id === '
|
|
176
|
+
if (tab.id === 'health' && state.store.health.issueCount) {
|
|
177
|
+
badge = String(state.store.health.issueCount);
|
|
178
|
+
tone = state.store.health.severity;
|
|
179
|
+
}
|
|
180
|
+
if (tab.id === 'prune' && state.store.stats.index.level !== 'ok') {
|
|
181
|
+
badge = '!';
|
|
182
|
+
tone = state.store.stats.index.level === 'over' ? 'bad' : 'warn';
|
|
183
|
+
}
|
|
184
|
+
if (tab.id === 'context' && state.aux.instructions?.problems.length) {
|
|
185
|
+
badge = String(state.aux.instructions.problems.length);
|
|
186
|
+
tone = worstSeverity(state.aux.instructions.problems);
|
|
187
|
+
}
|
|
188
|
+
if (tab.id === 'settings' && state.aux.settings?.problems.length) {
|
|
189
|
+
badge = String(state.aux.settings.problems.length);
|
|
190
|
+
tone = worstSeverity(state.aux.settings.problems);
|
|
191
|
+
}
|
|
162
192
|
|
|
163
193
|
container.append(node('button', {
|
|
164
194
|
class: ui.tab(state.tab === tab.id),
|
|
165
195
|
onclick: () => { state.tab = tab.id; renderTabs(); renderTab(); },
|
|
166
196
|
}, [
|
|
167
197
|
document.createTextNode(tab.label),
|
|
168
|
-
badge ? node('span', { class: ui.tabBadge(
|
|
198
|
+
badge ? node('span', { class: ui.tabBadge(tone), text: badge }) : null,
|
|
169
199
|
]));
|
|
170
200
|
}
|
|
171
201
|
}
|
|
@@ -395,18 +425,22 @@ const CONTEXT_PROBLEMS = {
|
|
|
395
425
|
'long-claude-md': (p) => [`${p.lines} lines, over the 200-line guidance`, `${p.file} - long files cost context every session and reduce adherence`],
|
|
396
426
|
'agents-md-not-imported': (p) => ['AGENTS.md is not loaded', `${p.file} exists, but Claude Code reads CLAUDE.md. Import it with @AGENTS.md, or symlink it.`],
|
|
397
427
|
'unreferenced-user-file': (p) => ['Nothing in the chain reaches this file', `${p.file} sits next to CLAUDE.md and loads nothing. Import it with @${p.file.split('/').pop()}, or delete it.`],
|
|
428
|
+
'duplicate-load': (p) => [`Loaded ${p.count} times: ${p.file}`, `reached from ${[...new Set(p.via)].join(' and ')} - every copy is read into context, so you pay ~${p.wastedTokens.toLocaleString()} extra tokens a session`],
|
|
429
|
+
'empty-instruction-file': (p) => ['Loads, but says nothing', `${p.file} is empty apart from its frontmatter, so it costs a read and contributes no instruction`],
|
|
398
430
|
};
|
|
399
431
|
|
|
400
432
|
async function renderContext(container) {
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
433
|
+
let data = state.aux.instructions;
|
|
434
|
+
if (!data) {
|
|
435
|
+
container.append(node('p', { class: ui.note, text: 'Reading instruction files…' }));
|
|
436
|
+
try {
|
|
437
|
+
data = await api(`/api/stores/${encodeURIComponent(state.storeId)}/instructions`);
|
|
438
|
+
} catch (err) {
|
|
439
|
+
container.textContent = '';
|
|
440
|
+
return container.append(node('p', { class: ui.note, text: err.message }));
|
|
441
|
+
}
|
|
442
|
+
if (state.tab !== 'context') return;
|
|
408
443
|
}
|
|
409
|
-
if (state.tab !== 'context') return;
|
|
410
444
|
container.textContent = '';
|
|
411
445
|
|
|
412
446
|
const global = state.store.kind === 'global';
|
|
@@ -434,8 +468,7 @@ async function renderContext(container) {
|
|
|
434
468
|
for (const problem of data.problems) {
|
|
435
469
|
const describe = CONTEXT_PROBLEMS[problem.kind];
|
|
436
470
|
const [title, detail] = describe ? describe(problem) : [problem.kind, problem.file || ''];
|
|
437
|
-
|
|
438
|
-
container.append(node('div', { class: ui.issue(bad) }, [
|
|
471
|
+
container.append(node('div', { class: ui.issue(problem.severity === 'bad') }, [
|
|
439
472
|
node('div', { class: ui.issueBody }, [
|
|
440
473
|
node('div', { class: ui.issueTitle, text: title }),
|
|
441
474
|
node('div', { class: ui.issueDetail, text: detail }),
|
|
@@ -506,18 +539,135 @@ function issue(title, detail, { bad = false, action, secondary } = {}) {
|
|
|
506
539
|
]);
|
|
507
540
|
}
|
|
508
541
|
|
|
542
|
+
function storeHasProjectDir() {
|
|
543
|
+
return state.store.kind === 'auto'
|
|
544
|
+
? state.store.resolvedBy !== 'unresolved'
|
|
545
|
+
: Boolean(state.store.projectPath);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const pathCheckKey = () => `pathCheck:${state.storeId}`;
|
|
549
|
+
|
|
550
|
+
async function fetchPathCheck() {
|
|
551
|
+
const id = state.storeId;
|
|
552
|
+
let next;
|
|
553
|
+
try {
|
|
554
|
+
next = { running: false, data: await api(`/api/stores/${encodeURIComponent(id)}/path-check`) };
|
|
555
|
+
} catch (err) {
|
|
556
|
+
next = { running: false, error: err.message };
|
|
557
|
+
}
|
|
558
|
+
if (state.storeId !== id) return;
|
|
559
|
+
state.pathCheck = next;
|
|
560
|
+
if (state.tab === 'health') renderTab();
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function startPathCheck({ remember = true } = {}) {
|
|
564
|
+
if (remember) localStorage.setItem(pathCheckKey(), '1');
|
|
565
|
+
state.pathCheck = { running: true };
|
|
566
|
+
fetchPathCheck();
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function runPathCheck() {
|
|
570
|
+
startPathCheck();
|
|
571
|
+
renderTab();
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function stopPathCheck() {
|
|
575
|
+
localStorage.removeItem(pathCheckKey());
|
|
576
|
+
state.pathCheck = null;
|
|
577
|
+
renderTab();
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function renderPathCheck(container) {
|
|
581
|
+
if (!storeHasProjectDir()) return;
|
|
582
|
+
|
|
583
|
+
if (!state.pathCheck && localStorage.getItem(pathCheckKey()) === '1') {
|
|
584
|
+
startPathCheck({ remember: false });
|
|
585
|
+
}
|
|
586
|
+
const check = state.pathCheck;
|
|
587
|
+
|
|
588
|
+
const head = node('div', { class: ui.issue(false) });
|
|
589
|
+
const body = node('div', { class: ui.issueBody });
|
|
590
|
+
|
|
591
|
+
if (!check) {
|
|
592
|
+
body.append(
|
|
593
|
+
node('div', { class: ui.issueTitle, text: 'Paths named in memories are not being checked' }),
|
|
594
|
+
node('div', { class: ui.issueDetail, text: 'Every other check on this tab reads only memory/. This one reads the project itself, to find memories naming files that no longer exist. It stays off until you ask, and is remembered per store.' }),
|
|
595
|
+
);
|
|
596
|
+
head.append(body, node('button', { class: ui.buttonSmall, text: 'Check against the repo', onclick: () => runPathCheck() }));
|
|
597
|
+
return container.append(head);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (check.running) {
|
|
601
|
+
body.append(node('div', { class: ui.issueTitle, text: 'Checking paths against the repo…' }));
|
|
602
|
+
head.append(body);
|
|
603
|
+
return container.append(head);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (check.error) {
|
|
607
|
+
body.append(
|
|
608
|
+
node('div', { class: ui.issueTitle, text: 'Path check could not run' }),
|
|
609
|
+
node('div', { class: ui.issueDetail, text: check.error }),
|
|
610
|
+
);
|
|
611
|
+
head.append(body, node('button', { class: ui.buttonSmall, text: 'Turn off', onclick: () => stopPathCheck() }));
|
|
612
|
+
return container.append(head);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const { checked, capped, missing, indexed, truncated } = check.data;
|
|
616
|
+
const scanned = `${indexed.toLocaleString()} files and folders scanned${truncated ? ', stopped at the index limit' : ''}`;
|
|
617
|
+
body.append(
|
|
618
|
+
node('div', { class: ui.issueTitle, text: missing.length
|
|
619
|
+
? `${missing.length} of ${checked} paths named in memories match no file in the project`
|
|
620
|
+
: `All ${checked} paths named in memories still match a file` }),
|
|
621
|
+
node('div', { class: ui.issueDetail, text: capped
|
|
622
|
+
? `${scanned}; stopped after the first ${checked} paths, so there may be more`
|
|
623
|
+
: scanned }),
|
|
624
|
+
);
|
|
625
|
+
if (missing.length) {
|
|
626
|
+
body.append(node('div', { class: ui.issueDetail, text: 'A pointer rather than a verdict: a file that moved reads the same as one the memory never got right.' }));
|
|
627
|
+
}
|
|
628
|
+
head.append(
|
|
629
|
+
body,
|
|
630
|
+
node('button', { class: ui.buttonSmall, text: 'Turn off', onclick: () => stopPathCheck() }),
|
|
631
|
+
node('button', { class: ui.buttonSmall, text: 'Re-check', onclick: () => runPathCheck() }),
|
|
632
|
+
);
|
|
633
|
+
container.append(head);
|
|
634
|
+
|
|
635
|
+
for (const item of missing) container.append(renderIssue(item, state.store.memories));
|
|
636
|
+
}
|
|
637
|
+
|
|
509
638
|
function renderHealth(container) {
|
|
510
639
|
const { health, memories } = state.store;
|
|
640
|
+
|
|
511
641
|
if (!health.issues.length) {
|
|
512
642
|
container.append(node('div', { class: ui.card }, [
|
|
513
643
|
node('p', { class: ui.okLine, text: '\u2713 No consistency problems found.' }),
|
|
514
644
|
node('p', { class: ui.noteTight, text: 'Every memory file is referenced by MEMORY.md, every pointer resolves, and every [[wikilink]] finds its target.' }),
|
|
515
645
|
]));
|
|
516
|
-
|
|
646
|
+
} else {
|
|
647
|
+
for (const item of health.issues) container.append(renderIssue(item, memories));
|
|
517
648
|
}
|
|
518
649
|
|
|
519
|
-
|
|
520
|
-
|
|
650
|
+
container.append(node('div', { class: ui.sectionLabel, text: 'Beyond the memory directory' }));
|
|
651
|
+
renderPathCheck(container);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function goToTab(id) {
|
|
655
|
+
state.tab = id;
|
|
656
|
+
renderTabs();
|
|
657
|
+
renderTab();
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async function removeIndexLine(lineIndex, expectedText, done) {
|
|
661
|
+
if (!confirm(`Remove line ${lineIndex + 1} from MEMORY.md?\n\n${expectedText}`)) return;
|
|
662
|
+
try {
|
|
663
|
+
await api(`/api/stores/${encodeURIComponent(state.storeId)}/index-line/delete`, {
|
|
664
|
+
method: 'POST',
|
|
665
|
+
body: JSON.stringify({ lineIndex, expectedText }),
|
|
666
|
+
});
|
|
667
|
+
toast(done);
|
|
668
|
+
await openStore(state.storeId, { keepTab: true });
|
|
669
|
+
} catch (err) {
|
|
670
|
+
toast(err.message, { error: true });
|
|
521
671
|
}
|
|
522
672
|
}
|
|
523
673
|
|
|
@@ -533,19 +683,7 @@ function renderIssue(item, memories) {
|
|
|
533
683
|
bad,
|
|
534
684
|
action: {
|
|
535
685
|
label: 'Remove pointer',
|
|
536
|
-
run:
|
|
537
|
-
if (!confirm(`Remove line ${entry.index + 1} from MEMORY.md?\n\n${entry.text}`)) return;
|
|
538
|
-
try {
|
|
539
|
-
await api(`/api/stores/${encodeURIComponent(state.storeId)}/index-line/delete`, {
|
|
540
|
-
method: 'POST',
|
|
541
|
-
body: JSON.stringify({ lineIndex: entry.index, expectedText: entry.text }),
|
|
542
|
-
});
|
|
543
|
-
toast('Pointer removed');
|
|
544
|
-
await openStore(state.storeId, { keepTab: true });
|
|
545
|
-
} catch (err) {
|
|
546
|
-
toast(err.message, { error: true });
|
|
547
|
-
}
|
|
548
|
-
},
|
|
686
|
+
run: () => removeIndexLine(entry.index, entry.text, 'Pointer removed'),
|
|
549
687
|
},
|
|
550
688
|
},
|
|
551
689
|
);
|
|
@@ -627,16 +765,104 @@ function renderIssue(item, memories) {
|
|
|
627
765
|
return issue(
|
|
628
766
|
`${item.count} index hook${item.count === 1 ? '' : 's'} over 200 characters`,
|
|
629
767
|
`longest is ${item.longest.hookLength} chars on "${item.longest.title}" - hooks load every session, so these are the cheapest thing to trim.`,
|
|
768
|
+
{
|
|
769
|
+
bad,
|
|
770
|
+
action: { label: 'Open Prune', run: () => goToTab('prune') },
|
|
771
|
+
},
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
if (item.kind === 'duplicate-name') {
|
|
776
|
+
return issue(
|
|
777
|
+
`Two memories claim the name "${item.name}"`,
|
|
778
|
+
`${item.files.join(', ')} - every [[${item.name}]] resolves to ${item.reachable}, so the other one cannot be linked at all.`,
|
|
779
|
+
{
|
|
780
|
+
bad,
|
|
781
|
+
secondary: { label: 'Open first', run: () => selectMemory(item.files[0]) },
|
|
782
|
+
action: { label: 'Open second', run: () => selectMemory(item.files[1]) },
|
|
783
|
+
},
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
if (item.kind === 'duplicate-index-entry') {
|
|
788
|
+
return issue(
|
|
789
|
+
`Listed twice in MEMORY.md: ${item.file}`,
|
|
790
|
+
`lines ${item.lines.map((line) => line + 1).join(' and ')} - one memory, two index lines, both loaded every session.`,
|
|
630
791
|
{
|
|
631
792
|
bad,
|
|
632
793
|
action: {
|
|
633
|
-
label: '
|
|
634
|
-
run: () =>
|
|
794
|
+
label: 'Remove duplicate',
|
|
795
|
+
run: () => removeIndexLine(item.removable.index, item.removable.text, 'Duplicate removed'),
|
|
635
796
|
},
|
|
636
797
|
},
|
|
637
798
|
);
|
|
638
799
|
}
|
|
639
800
|
|
|
801
|
+
if (item.kind === 'missing-description') {
|
|
802
|
+
return issue(
|
|
803
|
+
`No description: ${item.file}`,
|
|
804
|
+
'The description is what a memory is recalled by, so this one is found on its name alone.',
|
|
805
|
+
{ bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
|
|
806
|
+
);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
if (item.kind === 'unknown-type') {
|
|
810
|
+
return issue(
|
|
811
|
+
`Unrecognised type: ${item.file}`,
|
|
812
|
+
`type: "${item.type}" - expected one of user, feedback, project, reference.`,
|
|
813
|
+
{ bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
if (item.kind === 'empty-body') {
|
|
818
|
+
const memory = memories.find((m) => m.file === item.file);
|
|
819
|
+
return issue(
|
|
820
|
+
`Almost nothing in it: ${item.file}`,
|
|
821
|
+
`"${memory?.name || item.file}" has ${item.chars} characters of body - it costs an index line every session and says nothing.`,
|
|
822
|
+
{
|
|
823
|
+
bad,
|
|
824
|
+
secondary: { label: 'Open', run: () => selectMemory(item.file) },
|
|
825
|
+
action: { label: 'Delete', run: () => openDeleteDialog(item.file) },
|
|
826
|
+
},
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if (item.kind === 'hook-repeats-description') {
|
|
831
|
+
return issue(
|
|
832
|
+
`Hook repeats the description: ${item.file}`,
|
|
833
|
+
`MEMORY.md line ${item.index + 1} - ${item.hookLength} characters restating what the file's own description already says. Only the hook loads every session.`,
|
|
834
|
+
{
|
|
835
|
+
bad,
|
|
836
|
+
secondary: { label: 'Open', run: () => selectMemory(item.file) },
|
|
837
|
+
action: { label: 'Open Prune', run: () => goToTab('prune') },
|
|
838
|
+
},
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if (item.kind === 'empty-section') {
|
|
843
|
+
return issue(
|
|
844
|
+
`Empty section in MEMORY.md: "${item.section}"`,
|
|
845
|
+
`line ${item.index + 1} - the heading has no entries under it, and headings count against the 200-line budget too.`,
|
|
846
|
+
{ bad, action: { label: 'Open MEMORY.md', run: () => goToTab('index') } },
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
if (item.kind === 'stale-path') {
|
|
851
|
+
return issue(
|
|
852
|
+
`No file matches ${item.token}`,
|
|
853
|
+
`named by "${item.name || item.file}"`,
|
|
854
|
+
{ bad, action: { label: 'Open', run: () => selectMemory(item.file) } },
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
if (item.kind === 'index-continuation') {
|
|
859
|
+
return issue(
|
|
860
|
+
`${item.count} index ${item.count === 1 ? 'entry spills' : 'entries spill'} onto extra lines`,
|
|
861
|
+
`${item.extraLines} extra line${item.extraLines === 1 ? '' : 's'} of the 200-line budget - the guidance is one line per entry, with the detail in the topic file.`,
|
|
862
|
+
{ bad, action: { label: 'Open MEMORY.md', run: () => goToTab('index') } },
|
|
863
|
+
);
|
|
864
|
+
}
|
|
865
|
+
|
|
640
866
|
return issue(item.kind, JSON.stringify(item));
|
|
641
867
|
}
|
|
642
868
|
|
|
@@ -662,15 +888,17 @@ const SETTINGS_PROBLEMS = {
|
|
|
662
888
|
const show = (value) => (value === undefined ? 'unset' : JSON.stringify(value));
|
|
663
889
|
|
|
664
890
|
async function renderSettings(container) {
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
891
|
+
let data = state.aux.settings;
|
|
892
|
+
if (!data) {
|
|
893
|
+
container.append(node('p', { class: ui.note, text: 'Reading settings files…' }));
|
|
894
|
+
try {
|
|
895
|
+
data = await api(`/api/stores/${encodeURIComponent(state.storeId)}/settings`);
|
|
896
|
+
} catch (err) {
|
|
897
|
+
container.textContent = '';
|
|
898
|
+
return container.append(node('p', { class: ui.note, text: err.message }));
|
|
899
|
+
}
|
|
900
|
+
if (state.tab !== 'settings') return;
|
|
672
901
|
}
|
|
673
|
-
if (state.tab !== 'settings') return;
|
|
674
902
|
container.textContent = '';
|
|
675
903
|
|
|
676
904
|
container.append(node('p', { class: ui.noteTight, text: 'What Claude Code would read for this store, in precedence order: managed policy first, then project and local settings, then your user file. This tab is read-only — it reports what is configured, it never changes it.' }));
|
|
@@ -684,7 +912,7 @@ async function renderSettings(container) {
|
|
|
684
912
|
for (const problem of data.problems) {
|
|
685
913
|
const describe = SETTINGS_PROBLEMS[problem.kind];
|
|
686
914
|
const [title, detail] = describe ? describe(problem) : [problem.kind, problem.file || ''];
|
|
687
|
-
container.append(node('div', { class: ui.issue(
|
|
915
|
+
container.append(node('div', { class: ui.issue(problem.severity === 'bad') }, [
|
|
688
916
|
node('div', { class: ui.issueBody }, [
|
|
689
917
|
node('div', { class: ui.issueTitle, text: title }),
|
|
690
918
|
node('div', { class: ui.issueDetail, text: detail }),
|
|
@@ -1743,9 +1971,22 @@ async function reloadStores() {
|
|
|
1743
1971
|
const data = await api('/api/stores');
|
|
1744
1972
|
state.stores = data.stores;
|
|
1745
1973
|
renderStores();
|
|
1974
|
+
sweepIssues();
|
|
1746
1975
|
return data;
|
|
1747
1976
|
}
|
|
1748
1977
|
|
|
1978
|
+
async function sweepIssues() {
|
|
1979
|
+
let data;
|
|
1980
|
+
try {
|
|
1981
|
+
data = await api('/api/stores/issues');
|
|
1982
|
+
} catch {
|
|
1983
|
+
return;
|
|
1984
|
+
}
|
|
1985
|
+
const bySummary = new Map(data.stores.map((summary) => [summary.id, summary]));
|
|
1986
|
+
for (const store of state.stores) Object.assign(store, bySummary.get(store.id) || {});
|
|
1987
|
+
renderStores();
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1749
1990
|
function renderStoreHeader() {
|
|
1750
1991
|
const store = state.store;
|
|
1751
1992
|
el('project-title').textContent = store.label;
|
|
@@ -1799,6 +2040,8 @@ function renderStoreHeader() {
|
|
|
1799
2040
|
async function openStore(id, { keepTab = false } = {}) {
|
|
1800
2041
|
if (id !== state.storeId) state.pruneSelection.clear();
|
|
1801
2042
|
state.storeId = id;
|
|
2043
|
+
state.aux = { instructions: null, settings: null };
|
|
2044
|
+
state.pathCheck = null;
|
|
1802
2045
|
if (!keepTab) {
|
|
1803
2046
|
const opening = state.stores.find((store) => store.id === id);
|
|
1804
2047
|
state.tab = opening && opening.kind === 'global' ? 'context' : 'memories';
|
|
@@ -1819,12 +2062,34 @@ async function openStore(id, { keepTab = false } = {}) {
|
|
|
1819
2062
|
renderStoreHeader();
|
|
1820
2063
|
|
|
1821
2064
|
const listed = state.stores.find((store) => store.id === id);
|
|
1822
|
-
if (listed)
|
|
2065
|
+
if (listed) {
|
|
2066
|
+
listed.memoryCount = state.store.memories.length;
|
|
2067
|
+
listed.issueCount = state.store.health.issueCount;
|
|
2068
|
+
listed.severity = worst(state.store.health.severity, listed.context ? 'warn' : 'ok', listed.settings ? 'warn' : 'ok');
|
|
2069
|
+
}
|
|
1823
2070
|
renderStores();
|
|
1824
2071
|
|
|
1825
2072
|
renderView();
|
|
1826
2073
|
renderTabs();
|
|
1827
2074
|
renderTab();
|
|
2075
|
+
|
|
2076
|
+
Promise.all([
|
|
2077
|
+
api(`/api/stores/${encodeURIComponent(id)}/instructions`).catch(() => null),
|
|
2078
|
+
api(`/api/stores/${encodeURIComponent(id)}/settings`).catch(() => null),
|
|
2079
|
+
]).then(([instructions, settings]) => {
|
|
2080
|
+
if (state.storeId !== id) return;
|
|
2081
|
+
state.aux = { instructions, settings };
|
|
2082
|
+
renderTabs();
|
|
2083
|
+
if (!listed) return;
|
|
2084
|
+
listed.context = instructions ? instructions.problems.length : 0;
|
|
2085
|
+
listed.settings = settings ? settings.problems.length : 0;
|
|
2086
|
+
listed.severity = worst(
|
|
2087
|
+
state.store.health.severity,
|
|
2088
|
+
instructions ? worstSeverity(instructions.problems) : 'ok',
|
|
2089
|
+
settings ? worstSeverity(settings.problems) : 'ok',
|
|
2090
|
+
);
|
|
2091
|
+
renderStores();
|
|
2092
|
+
});
|
|
1828
2093
|
}
|
|
1829
2094
|
|
|
1830
2095
|
function applyCollapsed() {
|
package/public/ui.mjs
CHANGED
|
@@ -44,7 +44,7 @@ export const storeCount = 'font-mono text-2xs text-fg-subtle';
|
|
|
44
44
|
export const storePath = 'block truncate font-mono text-2xs text-fg-subtle';
|
|
45
45
|
|
|
46
46
|
const dotBase = 'inline-block size-1.5 flex-none rounded-full';
|
|
47
|
-
const dotTone = { ok: 'bg-ok', warn: 'bg-warn', none: 'bg-line-strong' };
|
|
47
|
+
const dotTone = { ok: 'bg-ok', warn: 'bg-warn', bad: 'bg-danger', none: 'bg-line-strong' };
|
|
48
48
|
export const dot = (tone) => cx(dotBase, pick(dotTone, tone, 'none'));
|
|
49
49
|
|
|
50
50
|
export const offMarker = 'rounded-sm bg-warn-surface px-1 py-px font-mono text-2xs uppercase tracking-widest text-warn';
|
|
@@ -75,6 +75,7 @@ const badgeBase = 'flex-none rounded-sm px-1.5 py-px font-mono text-2xs lowercas
|
|
|
75
75
|
const badgeTone = {
|
|
76
76
|
neutral: 'bg-surface-sunken text-fg-muted',
|
|
77
77
|
warn: 'bg-warn-surface text-warn',
|
|
78
|
+
bad: 'bg-danger-surface text-danger',
|
|
78
79
|
ok: 'bg-ok-surface text-ok',
|
|
79
80
|
project: 'bg-type-project-surface text-type-project',
|
|
80
81
|
feedback: 'bg-type-feedback-surface text-type-feedback',
|
|
@@ -90,7 +91,7 @@ export const badge = (tone = 'neutral') => cx(badgeBase, pick(badgeTone, tone, '
|
|
|
90
91
|
const SCOPE_TONE = { managed: 'managed', user: 'scopeUser', project: 'scopeProject', local: 'scopeLocal' };
|
|
91
92
|
export const scopeBadge = (scope) => badge(SCOPE_TONE[scope] ?? 'neutral');
|
|
92
93
|
export const typeBadge = (type) => badge(badgeTone[type] ? type : 'neutral');
|
|
93
|
-
export const tabBadge = (
|
|
94
|
+
export const tabBadge = (tone = 'neutral') => cx('ml-1.5', badge(tone));
|
|
94
95
|
|
|
95
96
|
export const split = 'grid grid-cols-1 items-start gap-6 xl:grid-cols-[minmax(280px,380px)_1fr]';
|
|
96
97
|
export const detailPane = 'xl:sticky xl:top-4 xl:max-h-[calc(100vh-9rem)] xl:overflow-y-auto';
|
package/server.mjs
CHANGED
|
@@ -10,11 +10,12 @@ import { spawn } from 'node:child_process';
|
|
|
10
10
|
|
|
11
11
|
import { projectsRoot, resolveRoot } from './src/projects.mjs';
|
|
12
12
|
import { buildStore } from './src/model.mjs';
|
|
13
|
-
import { resolveGlobalInstructions, resolveInstructions } from './src/instructions.mjs';
|
|
14
|
-
import { settingsReport } from './src/settings.mjs';
|
|
13
|
+
import { resolveGlobalInstructions, resolveInstructions, summarise } from './src/instructions.mjs';
|
|
14
|
+
import { settingsReport, summariseSettings } from './src/settings.mjs';
|
|
15
15
|
import { listStores } from './src/stores.mjs';
|
|
16
16
|
import { forgetPath, rememberPath } from './src/pathcache.mjs';
|
|
17
17
|
import { searchAll } from './src/search.mjs';
|
|
18
|
+
import { verifyPaths } from './src/pathcheck.mjs';
|
|
18
19
|
import {
|
|
19
20
|
addIndexEntry,
|
|
20
21
|
addIndexEntryPreview,
|
|
@@ -153,13 +154,56 @@ function emptyModel(store) {
|
|
|
153
154
|
index: null,
|
|
154
155
|
memories: [],
|
|
155
156
|
graph: { nodes: [], edges: [], dangling: [] },
|
|
156
|
-
health: { orphans: [], referencedOnly: [], danglingIndex: [], danglingWikilinks: [], nameMismatches: [], missingFrontmatter: [], longHooks: [], issues: [], issueCount: 0 },
|
|
157
|
+
health: { orphans: [], referencedOnly: [], danglingIndex: [], danglingWikilinks: [], nameMismatches: [], missingFrontmatter: [], longHooks: [], issues: [], issueCount: 0, severity: 'ok' },
|
|
157
158
|
stats: null,
|
|
158
159
|
duplicates: [],
|
|
159
160
|
trash: [],
|
|
160
161
|
};
|
|
161
162
|
}
|
|
162
163
|
|
|
164
|
+
const WORST = { ok: 0, warn: 1, bad: 2 };
|
|
165
|
+
const worst = (...severities) => severities.reduce((a, b) => (WORST[b] > WORST[a] ? b : a), 'ok');
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Counts only, for every store at once, so the sidebar can say which project is
|
|
169
|
+
* in trouble before anything is opened. The full models are an order of
|
|
170
|
+
* magnitude larger and none of them is needed to draw a dot.
|
|
171
|
+
*/
|
|
172
|
+
function issueSweep() {
|
|
173
|
+
return listStores(ROOT).map((store) => {
|
|
174
|
+
const summary = { id: store.id, issueCount: 0, severity: 'ok', context: 0, settings: 0 };
|
|
175
|
+
|
|
176
|
+
if (store.kind !== 'global') {
|
|
177
|
+
try {
|
|
178
|
+
const { health } = buildStore(store);
|
|
179
|
+
summary.issueCount = health.issueCount;
|
|
180
|
+
summary.severity = health.severity;
|
|
181
|
+
} catch { /* a store that cannot be read has nothing to report */ }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const projectDir = storeProjectDir(store);
|
|
185
|
+
try {
|
|
186
|
+
const instructions = store.kind === 'global'
|
|
187
|
+
? summarise(resolveGlobalInstructions().problems)
|
|
188
|
+
: projectDir ? summarise(resolveInstructions(projectDir).problems) : null;
|
|
189
|
+
if (instructions) {
|
|
190
|
+
summary.context = instructions.count;
|
|
191
|
+
summary.severity = worst(summary.severity, instructions.severity);
|
|
192
|
+
}
|
|
193
|
+
} catch { /* unreadable instruction chain is reported by the tab itself */ }
|
|
194
|
+
|
|
195
|
+
if (projectDir) {
|
|
196
|
+
try {
|
|
197
|
+
const settings = summariseSettings(settingsReport({ projectDir }).problems);
|
|
198
|
+
summary.settings = settings.count;
|
|
199
|
+
summary.severity = worst(summary.severity, settings.severity);
|
|
200
|
+
} catch { /* likewise */ }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return summary;
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
163
207
|
async function handleApi(req, res, url) {
|
|
164
208
|
const segments = url.pathname.split('/').filter(Boolean); // ['api', 'projects', ...]
|
|
165
209
|
|
|
@@ -180,6 +224,12 @@ async function handleApi(req, res, url) {
|
|
|
180
224
|
return sendJson(res, 200, searchAll(ROOT, url.searchParams.get('q') || ''));
|
|
181
225
|
}
|
|
182
226
|
|
|
227
|
+
// Ahead of requireStore, which would otherwise read "issues" as a store id.
|
|
228
|
+
// Real ids always carry a "kind:" prefix, so the two can never collide.
|
|
229
|
+
if (segments.length === 3 && segments[1] === 'stores' && segments[2] === 'issues' && req.method === 'GET') {
|
|
230
|
+
return sendJson(res, 200, { stores: issueSweep() });
|
|
231
|
+
}
|
|
232
|
+
|
|
183
233
|
if (segments[1] !== 'stores' || segments.length < 3) {
|
|
184
234
|
return sendJson(res, 404, { error: 'Unknown endpoint' });
|
|
185
235
|
}
|
|
@@ -205,6 +255,18 @@ async function handleApi(req, res, url) {
|
|
|
205
255
|
return sendJson(res, 200, resolveInstructions(projectDir));
|
|
206
256
|
}
|
|
207
257
|
|
|
258
|
+
// Opt-in, and the only read this app makes into a project's own tree. Refused
|
|
259
|
+
// outright when nothing resolved a project directory, so a store can never be
|
|
260
|
+
// talked into stat-ing a path it has no business knowing about.
|
|
261
|
+
if (action === 'path-check' && req.method === 'GET') {
|
|
262
|
+
const projectDir = storeProjectDir(store);
|
|
263
|
+
if (!projectDir) {
|
|
264
|
+
return sendJson(res, 400, { error: 'This store is not tied to a project directory, so there is nothing to check paths against.' });
|
|
265
|
+
}
|
|
266
|
+
const { memories } = buildStore(store);
|
|
267
|
+
return sendJson(res, 200, verifyPaths(projectDir, memories));
|
|
268
|
+
}
|
|
269
|
+
|
|
208
270
|
if (action === 'settings' && req.method === 'GET') {
|
|
209
271
|
return sendJson(res, 200, settingsReport({ projectDir: storeProjectDir(store) }));
|
|
210
272
|
}
|
package/src/checks.mjs
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { parseFrontmatter } from './parse.mjs';
|
|
3
|
+
|
|
4
|
+
export const VALID_TYPES = ['user', 'feedback', 'project', 'reference'];
|
|
5
|
+
export const EMPTY_BODY_CHARS = 40;
|
|
6
|
+
export const EMPTY_INSTRUCTION_CHARS = 10;
|
|
7
|
+
export const HOOK_ECHO_OVERLAP = 0.9;
|
|
8
|
+
|
|
9
|
+
const VALID_TYPE_SET = new Set(VALID_TYPES);
|
|
10
|
+
|
|
11
|
+
const significantChars = (text) => String(text || '').replace(/\s+/g, '').length;
|
|
12
|
+
|
|
13
|
+
const wordSet = (text) => new Set(String(text || '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean));
|
|
14
|
+
|
|
15
|
+
function overlap(left, right) {
|
|
16
|
+
if (!left.size || !right.size) return 0;
|
|
17
|
+
let shared = 0;
|
|
18
|
+
for (const word of left) if (right.has(word)) shared += 1;
|
|
19
|
+
return shared / (left.size + right.size - shared);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function groupBy(items, key) {
|
|
23
|
+
const groups = new Map();
|
|
24
|
+
for (const item of items) {
|
|
25
|
+
const id = key(item);
|
|
26
|
+
if (!groups.has(id)) groups.set(id, []);
|
|
27
|
+
groups.get(id).push(item);
|
|
28
|
+
}
|
|
29
|
+
return groups;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function checkDuplicateNames(memories) {
|
|
33
|
+
return [...groupBy(memories, (memory) => memory.name)]
|
|
34
|
+
.filter(([, group]) => group.length > 1)
|
|
35
|
+
.map(([name, group]) => ({
|
|
36
|
+
kind: 'duplicate-name',
|
|
37
|
+
severity: 'bad',
|
|
38
|
+
name,
|
|
39
|
+
files: group.map((memory) => memory.file),
|
|
40
|
+
reachable: group[0].file,
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function checkMissingDescription(memories) {
|
|
45
|
+
return memories
|
|
46
|
+
.filter((memory) => memory.hasFrontmatter && !memory.description.trim())
|
|
47
|
+
.map((memory) => ({ kind: 'missing-description', severity: 'warn', file: memory.file, name: memory.name }));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function checkUnknownType(memories) {
|
|
51
|
+
return memories
|
|
52
|
+
.filter((memory) => memory.hasFrontmatter && !VALID_TYPE_SET.has(memory.type))
|
|
53
|
+
.map((memory) => ({ kind: 'unknown-type', severity: 'warn', file: memory.file, type: memory.type }));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function checkEmptyBody(memories) {
|
|
57
|
+
return memories
|
|
58
|
+
.filter((memory) => significantChars(memory.body) < EMPTY_BODY_CHARS)
|
|
59
|
+
.map((memory) => ({
|
|
60
|
+
kind: 'empty-body',
|
|
61
|
+
severity: 'warn',
|
|
62
|
+
file: memory.file,
|
|
63
|
+
name: memory.name,
|
|
64
|
+
chars: significantChars(memory.body),
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function checkHookRepeatsDescription(memories) {
|
|
69
|
+
return memories
|
|
70
|
+
.filter((memory) => memory.entry && memory.entry.hook && memory.description)
|
|
71
|
+
.filter((memory) => overlap(wordSet(memory.entry.hook), wordSet(memory.description)) >= HOOK_ECHO_OVERLAP)
|
|
72
|
+
.map((memory) => ({
|
|
73
|
+
kind: 'hook-repeats-description',
|
|
74
|
+
severity: 'warn',
|
|
75
|
+
file: memory.file,
|
|
76
|
+
index: memory.entry.index,
|
|
77
|
+
text: memory.entry.text,
|
|
78
|
+
hookLength: memory.entry.hook.length,
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function checkDuplicateIndexEntry(index) {
|
|
83
|
+
if (!index) return [];
|
|
84
|
+
return [...groupBy(index.entries, (entry) => entry.file)]
|
|
85
|
+
.filter(([, group]) => group.length > 1)
|
|
86
|
+
.map(([file, group]) => {
|
|
87
|
+
const last = group[group.length - 1];
|
|
88
|
+
return {
|
|
89
|
+
kind: 'duplicate-index-entry',
|
|
90
|
+
severity: 'bad',
|
|
91
|
+
file,
|
|
92
|
+
lines: group.map((entry) => entry.index),
|
|
93
|
+
removable: { index: last.index, text: last.text },
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function checkEmptySection(index) {
|
|
99
|
+
if (!index) return [];
|
|
100
|
+
const lines = index.parsedLines;
|
|
101
|
+
const empty = [];
|
|
102
|
+
|
|
103
|
+
for (let i = 0; i < lines.length; i++) {
|
|
104
|
+
const heading = lines[i];
|
|
105
|
+
if (heading.kind !== 'heading') continue;
|
|
106
|
+
|
|
107
|
+
let hasEntry = false;
|
|
108
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
109
|
+
const line = lines[j];
|
|
110
|
+
if (line.kind === 'heading' && line.level <= heading.level) break;
|
|
111
|
+
if (line.kind === 'index') { hasEntry = true; break; }
|
|
112
|
+
}
|
|
113
|
+
if (!hasEntry) empty.push({ kind: 'empty-section', severity: 'warn', section: heading.section, index: heading.index });
|
|
114
|
+
}
|
|
115
|
+
return empty;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function checkIndexContinuation(index) {
|
|
119
|
+
if (!index) return [];
|
|
120
|
+
|
|
121
|
+
const spilling = [];
|
|
122
|
+
for (const entry of index.entries) {
|
|
123
|
+
let extra = 0;
|
|
124
|
+
for (let i = entry.index + 1; i < index.lines.length; i++) {
|
|
125
|
+
const line = index.lines[i];
|
|
126
|
+
if (!line.trim() || !/^[ \t]/.test(line)) break;
|
|
127
|
+
extra += 1;
|
|
128
|
+
}
|
|
129
|
+
if (extra) spilling.push({ file: entry.file, title: entry.title, index: entry.index, extraLines: extra });
|
|
130
|
+
}
|
|
131
|
+
if (!spilling.length) return [];
|
|
132
|
+
|
|
133
|
+
return [{
|
|
134
|
+
kind: 'index-continuation',
|
|
135
|
+
severity: 'warn',
|
|
136
|
+
count: spilling.length,
|
|
137
|
+
extraLines: spilling.reduce((sum, entry) => sum + entry.extraLines, 0),
|
|
138
|
+
entries: spilling,
|
|
139
|
+
}];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function checkDuplicateLoad(files) {
|
|
143
|
+
const loadable = files.filter((item) => item.kind !== 'managed-settings');
|
|
144
|
+
return [...groupBy(loadable, (item) => path.resolve(item.file))]
|
|
145
|
+
.filter(([, group]) => group.length > 1)
|
|
146
|
+
.map(([, group]) => ({
|
|
147
|
+
kind: 'duplicate-load',
|
|
148
|
+
severity: 'warn',
|
|
149
|
+
file: group[0].file,
|
|
150
|
+
scope: group[0].scope,
|
|
151
|
+
count: group.length,
|
|
152
|
+
via: group.map((item) => item.importedBy || item.file),
|
|
153
|
+
wastedTokens: (group[0].tokens || 0) * (group.length - 1),
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function checkEmptyInstructionFile(files) {
|
|
158
|
+
const seen = new Set();
|
|
159
|
+
const empty = [];
|
|
160
|
+
|
|
161
|
+
for (const item of files) {
|
|
162
|
+
if (item.kind === 'managed-settings') continue;
|
|
163
|
+
const resolved = path.resolve(item.file);
|
|
164
|
+
if (seen.has(resolved)) continue;
|
|
165
|
+
seen.add(resolved);
|
|
166
|
+
if (significantChars(parseFrontmatter(item.text).body) >= EMPTY_INSTRUCTION_CHARS) continue;
|
|
167
|
+
empty.push({ kind: 'empty-instruction-file', severity: 'warn', file: item.file, scope: item.scope });
|
|
168
|
+
}
|
|
169
|
+
return empty;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function memoryChecks(memories, index) {
|
|
173
|
+
return [
|
|
174
|
+
...checkDuplicateNames(memories),
|
|
175
|
+
...checkDuplicateIndexEntry(index),
|
|
176
|
+
...checkMissingDescription(memories),
|
|
177
|
+
...checkUnknownType(memories),
|
|
178
|
+
...checkEmptyBody(memories),
|
|
179
|
+
...checkHookRepeatsDescription(memories),
|
|
180
|
+
...checkEmptySection(index),
|
|
181
|
+
...checkIndexContinuation(index),
|
|
182
|
+
];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function instructionChecks(files) {
|
|
186
|
+
return [...checkDuplicateLoad(files), ...checkEmptyInstructionFile(files)];
|
|
187
|
+
}
|
package/src/instructions.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import path from 'node:path';
|
|
|
18
18
|
import { parseFrontmatter } from './parse.mjs';
|
|
19
19
|
import { estimateTokens } from './stats.mjs';
|
|
20
20
|
import { lookup, settingsLayers } from './settings.mjs';
|
|
21
|
+
import { instructionChecks } from './checks.mjs';
|
|
21
22
|
|
|
22
23
|
/** Claude Code's own guidance for a CLAUDE.md. Guidance, not a cutoff. */
|
|
23
24
|
export const CLAUDE_MD_TARGET_LINES = 200;
|
|
@@ -28,6 +29,38 @@ export const MAX_IMPORT_DEPTH = 4;
|
|
|
28
29
|
/** A rule's whole `paths:` list shares this budget of expanded patterns. */
|
|
29
30
|
export const BRACE_PATTERN_BUDGET = 1000;
|
|
30
31
|
|
|
32
|
+
export const INSTRUCTION_SEVERITY = {
|
|
33
|
+
missing: 'bad',
|
|
34
|
+
'invalid-glob': 'bad',
|
|
35
|
+
'glob-budget': 'bad',
|
|
36
|
+
cycle: 'warn',
|
|
37
|
+
'too-deep': 'warn',
|
|
38
|
+
external: 'warn',
|
|
39
|
+
'long-claude-md': 'warn',
|
|
40
|
+
'agents-md-not-imported': 'warn',
|
|
41
|
+
'unreferenced-user-file': 'warn',
|
|
42
|
+
'duplicate-load': 'warn',
|
|
43
|
+
'empty-instruction-file': 'warn',
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function summarise(problems) {
|
|
47
|
+
const list = problems || [];
|
|
48
|
+
return {
|
|
49
|
+
count: list.length,
|
|
50
|
+
severity: list.some((problem) => INSTRUCTION_SEVERITY[problem.kind] === 'bad')
|
|
51
|
+
? 'bad'
|
|
52
|
+
: list.length ? 'warn' : 'ok',
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function stampSeverity(resolved) {
|
|
57
|
+
for (const problem of resolved.problems) {
|
|
58
|
+
problem.severity = INSTRUCTION_SEVERITY[problem.kind] || 'warn';
|
|
59
|
+
}
|
|
60
|
+
resolved.problems.sort((a, b) => (a.severity === 'bad' ? 0 : 1) - (b.severity === 'bad' ? 0 : 1));
|
|
61
|
+
return resolved;
|
|
62
|
+
}
|
|
63
|
+
|
|
31
64
|
function managedClaudeMd() {
|
|
32
65
|
if (process.platform === 'darwin') return '/Library/Application Support/ClaudeCode/CLAUDE.md';
|
|
33
66
|
if (process.platform === 'win32') return 'C:\\Program Files\\ClaudeCode\\CLAUDE.md';
|
|
@@ -431,6 +464,8 @@ function finalize(candidates, { projectDir, excludes }) {
|
|
|
431
464
|
}
|
|
432
465
|
}
|
|
433
466
|
|
|
467
|
+
problems.push(...instructionChecks(loaded));
|
|
468
|
+
|
|
434
469
|
const unconditional = loaded.filter((item) => !item.conditional);
|
|
435
470
|
return {
|
|
436
471
|
projectDir,
|
|
@@ -503,7 +538,7 @@ export function resolveInstructions(projectDir) {
|
|
|
503
538
|
resolved.problems.push({ kind: 'agents-md-not-imported', file: agentsFile, scope: 'project' });
|
|
504
539
|
}
|
|
505
540
|
|
|
506
|
-
return resolved;
|
|
541
|
+
return stampSeverity(resolved);
|
|
507
542
|
}
|
|
508
543
|
|
|
509
544
|
/**
|
|
@@ -521,5 +556,5 @@ export function resolveGlobalInstructions({ home = os.homedir(), settingsFile =
|
|
|
521
556
|
const layers = settingsLayers(settingsFile ? { settingsFile } : {});
|
|
522
557
|
const resolved = finalize(userCandidates(home, layers), { projectDir: null, excludes: readExcludes(layers) });
|
|
523
558
|
resolved.problems.push(...unreferencedUserFiles(home, resolved.files));
|
|
524
|
-
return resolved;
|
|
559
|
+
return stampSeverity(resolved);
|
|
525
560
|
}
|
package/src/model.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { parseIndex, parseFrontmatter, extractWikilinks } from './parse.mjs';
|
|
|
15
15
|
import { listMemoryFiles, memoryDir, resolveProjectPath, shortLabel } from './projects.mjs';
|
|
16
16
|
import { autoMemoryState } from './settings.mjs';
|
|
17
17
|
import { ageInDays, estimateTokens, findDuplicates, indexStats } from './stats.mjs';
|
|
18
|
+
import { memoryChecks } from './checks.mjs';
|
|
18
19
|
|
|
19
20
|
export const TRASH_DIR = '.trash';
|
|
20
21
|
|
|
@@ -173,8 +174,13 @@ export function buildStore(store) {
|
|
|
173
174
|
...(health.longHooks.length
|
|
174
175
|
? [{ kind: 'long-hooks', severity: 'warn', count: health.longHooks.length, longest: health.longHooks[0] }]
|
|
175
176
|
: []),
|
|
177
|
+
...memoryChecks(memories, index),
|
|
176
178
|
];
|
|
179
|
+
health.issues.sort((a, b) => (a.severity === 'bad' ? 0 : 1) - (b.severity === 'bad' ? 0 : 1));
|
|
177
180
|
health.issueCount = health.issues.length;
|
|
181
|
+
health.severity = health.issues.some((item) => item.severity === 'bad')
|
|
182
|
+
? 'bad'
|
|
183
|
+
: health.issues.length ? 'warn' : 'ok';
|
|
178
184
|
|
|
179
185
|
return {
|
|
180
186
|
...store,
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const MAX_PATH_CHECKS = 200;
|
|
5
|
+
export const MAX_INDEXED_FILES = 60000;
|
|
6
|
+
|
|
7
|
+
export const SKIP_DIRS = new Set([
|
|
8
|
+
'.git', 'node_modules', 'bin', 'obj', 'dist', 'build', 'out', 'target', 'vendor',
|
|
9
|
+
'.next', '.nuxt', '.svelte-kit', '.venv', 'venv', '__pycache__', '.gradle', '.idea',
|
|
10
|
+
'Pods', 'DerivedData', 'coverage', '.turbo', '.cache', '.angular', '.parcel-cache',
|
|
11
|
+
]);
|
|
12
|
+
|
|
13
|
+
const CODE_SPAN = /`([^`\n]+)`/g;
|
|
14
|
+
const SOURCE_EXT = /\.(mjs|cjs|jsx?|tsx?|json|jsonc|md|mdx|css|scss|less|html|vue|svelte|py|rb|go|rs|java|kt|swift|cs|fs|php|sh|zsh|ya?ml|toml|xml|sql|proto|lock|gradle|csproj|sln)$/i;
|
|
15
|
+
const BARE_EXTENSION = /^\.[A-Za-z0-9]+$/;
|
|
16
|
+
const LINE_SUFFIX = /:\d+(?:[-:]\d+)?$/;
|
|
17
|
+
const GLOB_OR_SHELL = /[*?{}[\]()<>|$=!"'\\]/;
|
|
18
|
+
|
|
19
|
+
function candidate(raw) {
|
|
20
|
+
let token = raw.trim();
|
|
21
|
+
if (!token || /\s/.test(token)) return null;
|
|
22
|
+
if (token.includes('://') || token.startsWith('@')) return null;
|
|
23
|
+
if (GLOB_OR_SHELL.test(token)) return null;
|
|
24
|
+
|
|
25
|
+
token = token.replace(LINE_SUFFIX, '').replace(/[.,;:]+$/, '').replace(/\/+$/, '');
|
|
26
|
+
if (token.startsWith('./')) token = token.slice(2);
|
|
27
|
+
if (!token || token.startsWith('/') || token.startsWith('~')) return null;
|
|
28
|
+
if (token.split('/').includes('..')) return null;
|
|
29
|
+
if (token.includes('...') || token.length > 200) return null;
|
|
30
|
+
if (BARE_EXTENSION.test(token)) return null;
|
|
31
|
+
if (!SOURCE_EXT.test(token)) return null;
|
|
32
|
+
if (token.split('/').some((segment) => SKIP_DIRS.has(segment))) return null;
|
|
33
|
+
|
|
34
|
+
return token;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function pathCandidates(body) {
|
|
38
|
+
const found = new Set();
|
|
39
|
+
for (const match of String(body || '').matchAll(CODE_SPAN)) {
|
|
40
|
+
const token = candidate(match[1]);
|
|
41
|
+
if (token) found.add(token);
|
|
42
|
+
}
|
|
43
|
+
return [...found];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function indexProject(root, { skipDirs = SKIP_DIRS, limit = MAX_INDEXED_FILES } = {}) {
|
|
47
|
+
const byLastSegment = new Map();
|
|
48
|
+
let count = 0;
|
|
49
|
+
let truncated = false;
|
|
50
|
+
|
|
51
|
+
const walk = (dir, prefix) => {
|
|
52
|
+
if (truncated) return;
|
|
53
|
+
let entries;
|
|
54
|
+
try {
|
|
55
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
56
|
+
} catch {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
if (count >= limit) {
|
|
61
|
+
truncated = true;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (skipDirs.has(entry.name)) continue;
|
|
65
|
+
|
|
66
|
+
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
67
|
+
count += 1;
|
|
68
|
+
if (!byLastSegment.has(entry.name)) byLastSegment.set(entry.name, []);
|
|
69
|
+
byLastSegment.get(entry.name).push(relative);
|
|
70
|
+
|
|
71
|
+
if (entry.isDirectory()) walk(path.join(dir, entry.name), relative);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
walk(root, '');
|
|
76
|
+
return { byLastSegment, count, truncated };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function resolvesInProject(index, token) {
|
|
80
|
+
const matches = index.byLastSegment.get(token.split('/').pop());
|
|
81
|
+
if (!matches) return false;
|
|
82
|
+
if (!token.includes('/')) return true;
|
|
83
|
+
return matches.some((relative) => relative === token || relative.endsWith(`/${token}`));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function verifyPaths(projectDir, memories) {
|
|
87
|
+
const root = path.resolve(projectDir);
|
|
88
|
+
const index = indexProject(root);
|
|
89
|
+
const missing = [];
|
|
90
|
+
let checked = 0;
|
|
91
|
+
let capped = false;
|
|
92
|
+
|
|
93
|
+
for (const memory of memories) {
|
|
94
|
+
for (const token of pathCandidates(memory.body)) {
|
|
95
|
+
if (checked >= MAX_PATH_CHECKS) {
|
|
96
|
+
capped = true;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
checked += 1;
|
|
100
|
+
if (!resolvesInProject(index, token)) {
|
|
101
|
+
missing.push({ kind: 'stale-path', severity: 'warn', file: memory.file, name: memory.name, token });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (capped) break;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { projectDir: root, checked, capped, indexed: index.count, truncated: index.truncated, missing };
|
|
108
|
+
}
|
package/src/settings.mjs
CHANGED
|
@@ -19,6 +19,21 @@ export const USER_SETTINGS = path.join(os.homedir(), '.claude', 'settings.json')
|
|
|
19
19
|
/** Default when nothing sets cleanupPeriodDays; transcripts older than this are swept. */
|
|
20
20
|
export const DEFAULT_CLEANUP_PERIOD_DAYS = 30;
|
|
21
21
|
|
|
22
|
+
export const SETTINGS_SEVERITY = {
|
|
23
|
+
unparseable: 'bad',
|
|
24
|
+
unreadable: 'bad',
|
|
25
|
+
'not-object': 'bad',
|
|
26
|
+
'invalid-auto-memory-directory': 'bad',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function summariseSettings(problems) {
|
|
30
|
+
const list = problems || [];
|
|
31
|
+
return {
|
|
32
|
+
count: list.length,
|
|
33
|
+
severity: list.some((problem) => problem.severity === 'bad') ? 'bad' : list.length ? 'warn' : 'ok',
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
22
37
|
function managedDir() {
|
|
23
38
|
if (process.platform === 'darwin') return '/Library/Application Support/ClaudeCode';
|
|
24
39
|
if (process.platform === 'win32') return 'C:\\Program Files\\ClaudeCode';
|
|
@@ -226,12 +241,19 @@ export function settingsReport(options = {}) {
|
|
|
226
241
|
|
|
227
242
|
const problems = layers
|
|
228
243
|
.filter((layer) => layer.status !== 'ok' && layer.status !== 'absent')
|
|
229
|
-
.map((layer) => ({
|
|
244
|
+
.map((layer) => ({
|
|
245
|
+
kind: layer.status,
|
|
246
|
+
severity: SETTINGS_SEVERITY[layer.status] || 'warn',
|
|
247
|
+
scope: layer.scope,
|
|
248
|
+
file: layer.file,
|
|
249
|
+
detail: layer.error,
|
|
250
|
+
}));
|
|
230
251
|
|
|
231
252
|
const directory = resolveMemoryDirectory(options);
|
|
232
253
|
if (directory && directory.invalid) {
|
|
233
254
|
problems.push({
|
|
234
255
|
kind: 'invalid-auto-memory-directory',
|
|
256
|
+
severity: SETTINGS_SEVERITY['invalid-auto-memory-directory'],
|
|
235
257
|
scope: directory.scope,
|
|
236
258
|
file: directory.file,
|
|
237
259
|
detail: `"${directory.raw}" is ${directory.invalid}`,
|