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 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. An orphan can be
101
- given the `MEMORY.md` bullet it is missing without leaving the page.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-memory-admin",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Browse, audit and prune the auto memory Claude Code keeps under ~/.claude/projects",
5
5
  "keywords": [
6
6
  "claude",
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 ? 'ok' : store.memoryCount === 0 ? 'none' : 'ok';
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.kind === 'auto' && store.resolvedBy === 'unresolved'
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 === 'prune' && state.store.stats.index.overTarget) badge = '!';
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(tab.id === 'health' || tab.id === 'prune'), text: badge }) : null,
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
- container.append(node('p', { class: ui.note, text: 'Reading instruction files…' }));
402
- let data;
403
- try {
404
- data = await api(`/api/stores/${encodeURIComponent(state.storeId)}/instructions`);
405
- } catch (err) {
406
- container.textContent = '';
407
- return container.append(node('p', { class: ui.note, text: err.message }));
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
- const bad = problem.kind === 'missing' || problem.kind === 'invalid-glob' || problem.kind === 'glob-budget';
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
- return;
646
+ } else {
647
+ for (const item of health.issues) container.append(renderIssue(item, memories));
517
648
  }
518
649
 
519
- for (const item of health.issues) {
520
- container.append(renderIssue(item, memories));
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: async () => {
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: 'Open Prune',
634
- run: () => { state.tab = 'prune'; renderTabs(); renderTab(); },
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
- container.append(node('p', { class: ui.note, text: 'Reading settings files…' }));
666
- let data;
667
- try {
668
- data = await api(`/api/stores/${encodeURIComponent(state.storeId)}/settings`);
669
- } catch (err) {
670
- container.textContent = '';
671
- return container.append(node('p', { class: ui.note, text: err.message }));
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(true) }, [
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) listed.memoryCount = state.store.memories.length;
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 = (warn) => cx('ml-1.5', badge(warn ? 'warn' : 'neutral'));
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
+ }
@@ -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) => ({ kind: layer.status, scope: layer.scope, file: layer.file, detail: layer.error }));
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}`,