claude-code-memory-explorer 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/server.js CHANGED
@@ -5,7 +5,8 @@ const express = require('express');
5
5
  const path = require('path');
6
6
  const fs = require('fs');
7
7
  const os = require('os');
8
- const { execFileSync } = require('child_process');
8
+ const { execFileSync, spawn } = require('child_process');
9
+ const crypto = require('crypto');
9
10
  const { assertOpenTarget, openInEditor } = require('./lib/open-editor');
10
11
  const { createNetGuard } = require('./lib/net-guard');
11
12
 
@@ -67,34 +68,78 @@ function fileInfo(filePath) {
67
68
  const content = fs.readFileSync(filePath, 'utf-8');
68
69
  const lines = content.split('\n').length;
69
70
  const bytes = Buffer.byteLength(content, 'utf-8');
71
+ const chars = content.length;
70
72
  const frontmatter = parseFrontmatter(content);
71
- return { path: filePath, content, lines, bytes, frontmatter };
73
+ return { path: filePath, content, lines, bytes, chars, frontmatter };
72
74
  } catch {
73
75
  return null;
74
76
  }
75
77
  }
76
78
 
79
+ function stripQuotes(val) {
80
+ if (val.length >= 2 && ((val[0] === '"' && val.endsWith('"')) || (val[0] === "'" && val.endsWith("'")))) {
81
+ return val.slice(1, -1);
82
+ }
83
+ return val;
84
+ }
85
+
77
86
  function parseFrontmatter(content) {
78
87
  const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
79
88
  if (!match) return null;
89
+ const lines = match[1].split(/\r?\n/);
80
90
  const fm = {};
81
- for (const line of match[1].split('\n')) {
82
- const kv = line.match(/^(\w+):\s*(.*)/);
83
- if (kv) {
84
- const val = kv[2].trim();
85
- if (!val) {
86
- fm[kv[1]] = [];
87
- } else if (val.startsWith('[') || val.startsWith('"')) {
88
- try { fm[kv[1]] = JSON.parse(val.replace(/'/g, '"')); } catch { fm[kv[1]] = val; }
89
- } else {
90
- fm[kv[1]] = val;
91
- }
91
+ let i = 0;
92
+ while (i < lines.length) {
93
+ const kv = lines[i].match(/^([\w-]+):\s*(.*)$/);
94
+ if (!kv) {
95
+ i++;
96
+ continue;
92
97
  }
93
- const arrItem = line.match(/^\s+-\s+"?(.+?)"?\s*$/);
94
- if (arrItem) {
95
- const lastKey = Object.keys(fm).pop();
96
- if (lastKey && !Array.isArray(fm[lastKey])) fm[lastKey] = [];
97
- if (lastKey) fm[lastKey].push(arrItem[1]);
98
+ const key = kv[1];
99
+ const val = kv[2].trim();
100
+ i++;
101
+ if (/^[>|][+-]?$/.test(val)) {
102
+ // Block scalar (>, >-, |, |-): consume the indented block that follows
103
+ const block = [];
104
+ while (i < lines.length && (/^\s/.test(lines[i]) || !lines[i].trim())) {
105
+ block.push(lines[i]);
106
+ i++;
107
+ }
108
+ while (block.length && !block[block.length - 1].trim()) block.pop();
109
+ const indents = block.filter((l) => l.trim()).map((l) => l.match(/^\s*/)[0].length);
110
+ const indent = indents.length ? Math.min(...indents) : 0;
111
+ const body = block.map((l) => l.slice(indent));
112
+ // Folded (>) joins lines with spaces; literal (|) keeps newlines
113
+ fm[key] = val[0] === '>' ? body.join(' ').replace(/\s+/g, ' ').trim() : body.join('\n');
114
+ } else if (!val) {
115
+ // Nested block: list items or a child map
116
+ const arr = [];
117
+ const map = {};
118
+ while (i < lines.length && (/^\s/.test(lines[i]) || !lines[i].trim())) {
119
+ const item = lines[i].match(/^\s+-\s+"?(.+?)"?\s*$/);
120
+ const child = lines[i].match(/^\s+([\w-]+):\s*(.*)$/);
121
+ if (item) arr.push(item[1]);
122
+ else if (child) map[child[1]] = stripQuotes(child[2].trim());
123
+ i++;
124
+ }
125
+ fm[key] = arr.length ? arr : Object.keys(map).length ? map : [];
126
+ } else if (val.startsWith('[')) {
127
+ try {
128
+ fm[key] = JSON.parse(val.replace(/'/g, '"'));
129
+ } catch {
130
+ fm[key] = val;
131
+ }
132
+ } else {
133
+ // Plain scalar; YAML folds indented continuation lines into the value
134
+ let scalar = val;
135
+ while (
136
+ i < lines.length && lines[i].trim() && /^\s/.test(lines[i]) &&
137
+ !/^\s+(?:[\w-]+:|-\s)/.test(lines[i])
138
+ ) {
139
+ scalar += ` ${lines[i].trim()}`;
140
+ i++;
141
+ }
142
+ fm[key] = stripQuotes(scalar);
98
143
  }
99
144
  }
100
145
  return Object.keys(fm).length ? fm : null;
@@ -245,6 +290,7 @@ function discoverMemorySources(projectPath) {
245
290
 
246
291
  // 4b. Scan subdirectories of projectPath for CLAUDE.md (tree-scoped)
247
292
  const SKIP_DIRS = new Set(['node_modules', '.git', '.hg', '.svn', 'dist', 'build', 'out', '.next', '.nuxt', 'vendor', '__pycache__', '.venv', 'venv']);
293
+ const nestedSkillDirs = [];
248
294
  function walkForClaudeMd(dir, depth) {
249
295
  if (depth > 5) return;
250
296
  let entries;
@@ -270,6 +316,10 @@ function discoverMemorySources(projectPath) {
270
316
  ...spreadImports(info.path, info.content),
271
317
  });
272
318
  }
319
+ const nestedSkills = path.join(subdir, '.claude', 'skills');
320
+ if (fs.existsSync(nestedSkills)) {
321
+ nestedSkillDirs.push({ dir: nestedSkills, rel: path.relative(projectPath, subdir).replace(/\\/g, '/') });
322
+ }
273
323
  walkForClaudeMd(subdir, depth + 1);
274
324
  }
275
325
  }
@@ -293,6 +343,30 @@ function discoverMemorySources(projectPath) {
293
343
  }
294
344
  }
295
345
 
346
+ // 5b. Project skills (.claude/skills, incl. nested subdirectory skill dirs)
347
+ function pushSkill(file, skillSource, extra = {}) {
348
+ const info = fileInfo(file);
349
+ if (!info) return;
350
+ const dirName = path.basename(path.dirname(file));
351
+ const skillName = (info.frontmatter && typeof info.frontmatter.name === 'string' && info.frontmatter.name) || dirName;
352
+ const display = extra.nestedRel ? `${extra.nestedRel}:${skillName}` : skillName;
353
+ sources.push({
354
+ id: `skill-${skillSource}-${slug(file)}`,
355
+ name: display,
356
+ scope: 'skill',
357
+ load: 'ondemand',
358
+ skillSource,
359
+ skillName,
360
+ ...extra,
361
+ ...info,
362
+ ...spreadImports(info.path, info.content),
363
+ });
364
+ }
365
+ for (const file of findSkillFiles(path.join(projectPath, '.claude', 'skills'))) pushSkill(file, 'project');
366
+ for (const { dir, rel } of nestedSkillDirs) {
367
+ for (const file of findSkillFiles(dir)) pushSkill(file, 'project', { nestedRel: rel });
368
+ }
369
+
296
370
  function pushMemoryDir(dir, scope, idPrefix, extraFields = {}) {
297
371
  const memoryMd = path.join(dir, 'MEMORY.md');
298
372
  const memInfo = fileInfo(memoryMd);
@@ -398,15 +472,20 @@ function discoverMemorySources(projectPath) {
398
472
  return sources;
399
473
  }
400
474
 
401
- function findMdFiles(dir) {
475
+ function findSkillFiles(root) {
476
+ return findMdFiles(root, { name: 'SKILL.md', maxDepth: 4 });
477
+ }
478
+
479
+ function findMdFiles(dir, opts = {}, depth = 0) {
480
+ const { name = null, maxDepth = Infinity } = opts;
402
481
  const results = [];
403
482
  try {
404
483
  const entries = fs.readdirSync(dir, { withFileTypes: true });
405
484
  for (const entry of entries) {
406
485
  const full = path.join(dir, entry.name);
407
486
  if (entry.isDirectory()) {
408
- results.push(...findMdFiles(full));
409
- } else if (entry.isFile() && entry.name.endsWith('.md')) {
487
+ if (depth < maxDepth) results.push(...findMdFiles(full, opts, depth + 1));
488
+ } else if (entry.isFile() && (name ? entry.name === name : entry.name.endsWith('.md'))) {
410
489
  results.push(full);
411
490
  }
412
491
  }
@@ -519,7 +598,7 @@ function stripContent(source) {
519
598
  const app = express();
520
599
 
521
600
  // Mounted before express.json() so a rejected request never buffers a body.
522
- const net = createNetGuard({ appName: 'Claude Code Memory' });
601
+ const net = createNetGuard({ appName: 'Memory Diagnoser' });
523
602
  app.use(net.hostGuard);
524
603
  app.use(net.frameGuard);
525
604
  app.use(net.originGuard);
@@ -529,7 +608,7 @@ app.use(express.static(path.join(__dirname, 'public')));
529
608
 
530
609
  app.get('/hub-config', (_req, res) => {
531
610
  res.json({
532
- name: 'Claude Code Memory',
611
+ name: 'Memory Diagnoser',
533
612
  icon: 'brain',
534
613
  description: 'Explore Claude Code memory sources',
535
614
  enabled: !!process.env.CLAUDE_HUB,
@@ -617,6 +696,527 @@ app.post('/api/memory/cleanup-orphans', (req, res) => {
617
696
  }
618
697
  });
619
698
 
699
+ // #region ANALYZER
700
+
701
+ // Auto-memory audit drafted by spawning Claude Code headlessly (`claude -p`), following
702
+ // the airun-coach-cockpit pattern: prompt via stdin (avoids the Windows arg-length cap),
703
+ // shape enforced by the CLI's native --json-schema flag, result read from the JSON
704
+ // envelope's structured_output field.
705
+ const ANALYSIS_SCHEMA = {
706
+ type: 'object',
707
+ properties: {
708
+ summary: { type: 'string' },
709
+ findings: {
710
+ type: 'array',
711
+ items: {
712
+ type: 'object',
713
+ properties: {
714
+ kind: { type: 'string', enum: ['duplicate', 'contradiction', 'promote', 'merge', 'stale', 'invalidate', 'quality', 'override', 'shadow', 'demote'] },
715
+ severity: { type: 'string', enum: ['high', 'med', 'low'] },
716
+ scope: { type: 'string', enum: ['user', 'project', 'cross'] },
717
+ title: { type: 'string' },
718
+ detail: { type: 'string' },
719
+ files: { type: 'array', items: { type: 'string' } },
720
+ suggestion: { type: 'string' },
721
+ evidence: { type: 'string' },
722
+ },
723
+ required: ['kind', 'severity', 'scope', 'title', 'detail', 'files', 'suggestion'],
724
+ },
725
+ },
726
+ },
727
+ required: ['summary', 'findings'],
728
+ };
729
+
730
+ // Analysis state lives in one file per project under ~/.claude/memory-analysis/,
731
+ // named by the same path encoding Claude Code uses for its projects dir. Disk is the
732
+ // single source of truth — in-flight runs are persisted as `pending` entries so they
733
+ // survive page reloads and are visible to other server instances (hub + standalone).
734
+ const ANALYSIS_DIR = path.join(CLAUDE_DIR, 'memory-analysis');
735
+
736
+ const EMPTY_ANALYSIS = { pending: [], result: null, error: null, hash: null, ts: null, ids: null, runs: [], dismissed: [] };
737
+ const MAX_ANALYSIS_RUNS = 10;
738
+
739
+ // Findings about the user-level CLAUDE.md are shared across projects: they live in
740
+ // their own entry so any project's view (and dismissals) sees the same audit.
741
+ // The sentinel contains ':' mid-string, which encodeProjectPath output never keeps,
742
+ // so it cannot collide with a real project file.
743
+ const USER_SCOPE = '::user::';
744
+
745
+ function analysisFile(project) {
746
+ if (project === USER_SCOPE) return path.join(ANALYSIS_DIR, 'user-scope.json');
747
+ return path.join(ANALYSIS_DIR, `${encodeProjectPath(project)}.json`);
748
+ }
749
+
750
+ // A pending entry whose server died mid-run would otherwise show as running forever.
751
+ // Instead of deleting it (losing the trace), mark it stalled: the UI stops spinning
752
+ // but the entry survives — if its run ever completes, the completion handler still
753
+ // removes it by id and the result surfaces. Runs are never killed, so stalled is a
754
+ // hint ("probably orphaned by a server restart"), not a verdict.
755
+ const PENDING_STALL_MS = 1_800_000;
756
+ function markStalePending(pending) {
757
+ const cutoff = Date.now() - PENDING_STALL_MS;
758
+ return (pending || []).map((p) => (p.startedAt > cutoff ? p : { ...p, stalled: true }));
759
+ }
760
+
761
+ function getAnalysisState(project) {
762
+ let st = null;
763
+ try {
764
+ st = JSON.parse(fs.readFileSync(analysisFile(project), 'utf-8'));
765
+ } catch {
766
+ return { ...EMPTY_ANALYSIS };
767
+ }
768
+ const loaded = { ...EMPTY_ANALYSIS, ...st };
769
+ loaded.pending = markStalePending(loaded.pending);
770
+ // result/ts/hash/ids are a projection of the newest run — derived here so no
771
+ // writer can persist them out of sync with the runs history.
772
+ const latest = (loaded.runs || [])[0];
773
+ loaded.result = latest?.result || null;
774
+ loaded.ts = latest?.ts || null;
775
+ loaded.hash = latest?.hash || null;
776
+ loaded.ids = latest ? latest.ids : null;
777
+ return loaded;
778
+ }
779
+
780
+ function saveAnalysisState(project, st) {
781
+ const { pending = [], error = null, runs = [], dismissed = [] } = st;
782
+ try {
783
+ if (!pending.length && !error && !runs.length && !dismissed.length) {
784
+ fs.rmSync(analysisFile(project), { force: true });
785
+ return;
786
+ }
787
+ fs.mkdirSync(ANALYSIS_DIR, { recursive: true });
788
+ fs.writeFileSync(analysisFile(project), JSON.stringify({ pending, error, runs, dismissed }), 'utf-8');
789
+ } catch { /* best-effort persistence */ }
790
+ }
791
+
792
+ function memoryContentHash(sources) {
793
+ const h = crypto.createHash('sha1');
794
+ for (const s of sources) h.update(s.path).update('\0').update(s.content).update('\0');
795
+ return h.digest('hex');
796
+ }
797
+
798
+ // Distilled from the "writing-for-agents" skill — inlined so the audit never depends on
799
+ // that skill being installed on the machine running the analysis. Split into a shared
800
+ // core plus per-type rubrics: a good memory file, a good skill, and a good CLAUDE.md
801
+ // have different failure modes, so each type only gets the criteria that apply to it.
802
+ const RUBRIC_CORE = [
803
+ 'Shared criteria (all audited files):',
804
+ '- Positive phrasing: state the target behavior, not a prohibition — negations make the banned behavior more salient.',
805
+ '- No caches of the environment: do not restate what package.json, --help, or the directory layout already says; it goes stale. Record the unwritten convention, the why, the gotcha.',
806
+ '- No no-ops: an instruction the model already does by default is dead weight.',
807
+ '- Single source of truth: one meaning lives in one place; duplication costs maintenance and inflates prominence.',
808
+ ].join('\n');
809
+
810
+ const RUBRIC_MEMORY = [
811
+ 'Memory-file criteria (auto memory and agent memory):',
812
+ '- One fact per file, with the why: a memory missing its rationale ("**Why:**") cannot be applied correctly later.',
813
+ '- Index earns its cost: MEMORY.md lines are always loaded — each line must be a sharp pointer (title + hook), never content.',
814
+ '- Durable, not episodic: record the lesson or convention, not the story of one session; convert relative dates to absolute.',
815
+ '- Still true: a memory naming a file, flag, or command that no longer matches its own description is stale.',
816
+ ].join('\n');
817
+
818
+ const RUBRIC_SKILL = [
819
+ 'Skill criteria (SKILL.md files):',
820
+ '- Pointer wording: the description must front-load its trigger words and list the distinct cases it handles; a weak description means the skill never fires.',
821
+ '- Progressive disclosure: inline what every use needs; push branch-specific reference into sibling files behind pointers. A bloated top file buries its steps.',
822
+ '- Completion criteria: steps should end on a checkable, demanding bound ("every X accounted for"), not a vague one ("understanding reached").',
823
+ ].join('\n');
824
+
825
+ const RUBRIC_CLAUDE_MD = [
826
+ 'CLAUDE.md criteria (always-loaded project instructions):',
827
+ '- Every word pays a per-turn cost, and non-universal content trains the model to ignore the whole file: flag task-specific or situational instructions that belong in a skill, slash command, rule file, or pointed-to doc.',
828
+ '- Flag vague or no-op rules: unbounded adjectives ("format code properly", "be careful") and instructions the model already follows by default ("write clean code") — require specific, checkable directives.',
829
+ '- Flag content the environment already answers cheaply (package.json scripts, --help output, what the code shows): it duplicates a source of truth and goes stale. Keep only unwritten conventions, gotchas, and the reasons behind choices.',
830
+ '- Flag stale or wrong facts: commands that no longer exist, paths that do not match the repo, references to removed tools — verify against the repo.',
831
+ '- Flag emphasis inflation: IMPORTANT/ALWAYS/NEVER/caps on routine guidance dilutes the few rules that genuinely need them.',
832
+ '- Flag wrong scope: personal preferences in a shared project file (belong in the global CLAUDE.md or CLAUDE.local.md), subdirectory-only rules in the root file, project specifics in the global file.',
833
+ '- Flag lint-shaped rules (formatting, import order, naming style) that a deterministic linter or hook should enforce instead of the model.',
834
+ '- Flag surprising or restrictive rules with no WHY: a bare prohibition gets overridden when inconvenient; a one-clause reason makes it generalize.',
835
+ '- Flag instructions Claude cannot act on: rules aimed at humans, tools or files it cannot access, conditions it cannot detect.',
836
+ '- Flag long inlined code snippets or command output where a file pointer would do, and token-wasteful formatting (decorative headers, filler prose, deep nesting for a few bullets).',
837
+ '- Missing high-value content is also a finding: the build/test/run commands, non-obvious architecture facts, and repo-specific gotchas are exactly what the file exists to carry.',
838
+ ].join('\n');
839
+
840
+ // The user-level CLAUDE.md loads in every session of every project, so its rubric is
841
+ // project-blind: rules must hold everywhere, and verification runs against the machine
842
+ // (PATH, ~/.claude), never against whichever repo happens to be the working directory.
843
+ const RUBRIC_CLAUDE_MD_USER = [
844
+ 'User-level CLAUDE.md criteria (global instructions, loaded in every session of every project):',
845
+ '- Universality: every rule must hold on any project. Flag rules referencing one specific repo, stack, tool chain, or project path — they belong in that project\'s CLAUDE.md (kind: demote).',
846
+ '- Machine verification only: check that tools the file names exist on PATH (command -v / which / where) and that files, skills, or agents it references exist under ~/.claude. NEVER verify against the working directory\'s repository — it is an arbitrary project and proves nothing about a global rule.',
847
+ '- Self-consistency: flag sections, tags, or mnemonics that contradict or duplicate each other within the file and its imports.',
848
+ '- Executability: flag standards name-dropped without concrete behaviors ("follow style guide X") and rules the model cannot act on — require the 2-3 specific behaviors the user actually wants.',
849
+ '- Flag emphasis inflation (all-caps, NEVER/ALWAYS without stakes) and vague or no-op rules the model cannot act on.',
850
+ '- Preferences should carry their why: a bare prohibition gets overridden when inconvenient; a one-clause reason makes it generalize.',
851
+ ].join('\n');
852
+
853
+ function buildAnalyzePrompt(memSources, skillSources, agentSources, claudeMdSources, userSrc, userAudited) {
854
+ const fullSkills = skillSources || [];
855
+ const fullAgent = agentSources || [];
856
+ const fullClaudeMd = claudeMdSources || [];
857
+ const audited = [];
858
+ if (memSources.length) audited.push('the persistent auto-memory files (an index MEMORY.md plus topic files)');
859
+ if (fullSkills.length) audited.push('project skills (SKILL.md files)');
860
+ if (fullAgent.length) audited.push('agent memory files');
861
+ if (fullClaudeMd.length) audited.push('CLAUDE.md instruction files');
862
+ if (userAudited) audited.push('the user-level (global) CLAUDE.md');
863
+ const crossScope = userAudited && fullClaudeMd.length;
864
+ const parts = [
865
+ `You are auditing what shapes Claude Code behavior in this project: ${audited.join(', ')}. Find real problems; an empty findings list is a valid answer. Do not pad.`,
866
+ '',
867
+ 'Finding kinds:',
868
+ '- duplicate: files that say the same thing in different words (memory vs memory, memory vs skill, skill vs skill).',
869
+ '- contradiction: files that give conflicting guidance.',
870
+ '- promote: a memory stating a general preference (not specific to this project) that belongs in the global CLAUDE.md. Skip it if the global CLAUDE.md already covers it.',
871
+ '- merge: several small files on one theme that would be clearer as one.',
872
+ '- stale: content that is outdated — verified against the repo when checkable, or judged from its own text otherwise.',
873
+ '- invalidate: a rule or memory that verification shows no longer applies at all (the thing it guards against is gone, the file/tool it references was removed, the convention is now enforced elsewhere) — suggest deleting it.',
874
+ '- quality: content that is vague, missing its why, or unlikely to change agent behavior — reported by the reviewers against their rubrics.',
875
+ ];
876
+ if (crossScope) {
877
+ parts.push(
878
+ '- override: a project rule that contradicts a user-level rule. This is often intentional (a project legitimately specializes global preferences) — suggest making the override explicit ("overrides global X because Y"), not deleting either side.',
879
+ '- shadow: a project rule that restates a user-level rule near-verbatim; the project copy is the deletion candidate.',
880
+ );
881
+ }
882
+ if (userAudited) {
883
+ parts.push('- demote: a user-level rule that is specific to one project or stack and belongs in that project\'s CLAUDE.md — the inverse of promote.');
884
+ }
885
+ parts.push(
886
+ '',
887
+ 'Rules:',
888
+ `- You are the orchestrator; do not review files yourself. Your working directory is the project root. For every audited file, launch the type-matched reviewer subagent via your Agent tool — "memory-reviewer" for memory and agent-memory files, "skill-reviewer" for skills, "claude-md-reviewer" for project CLAUDE.md files${userAudited ? ', "claude-md-user-reviewer" for the user-level CLAUDE.md' : ''} — all in a single parallel batch of blocking calls (never background/async agents — you must hold your final report until every reviewer has returned). Pass each one only the file name and its full content; the reviewers carry their own criteria and verify claims against the repo${userAudited ? ' (the user-level reviewer verifies against the machine, never the repo)' : ''}.`,
889
+ `- Your own job: merge the reviewers' per-file findings and add the cross-file kinds (duplicate, contradiction, merge, promote${crossScope ? ', override, shadow' : ''}) by comparing the files side by side.`,
890
+ '- Verification is a filter: drop any finding a reviewer\'s claim checks disproved, and never report "likely outdated" for a claim a reviewer confirmed still HOLDS. Record what was checked in the finding\'s "evidence" field (one sentence, internal bookkeeping — it is not shown to the user). Omit "evidence" for judgment-only findings.',
891
+ '- files: the exact file names involved as listed below (e.g. feedback_foo.md, MEMORY.md, or a skill name like my-skill). Bare names only — never append scope markers like "(user)"; scope is carried by the scope field.',
892
+ '- scope: "user" if the finding only cites the user-level CLAUDE.md (demote findings are always "user"); "cross" if it cites both the user-level file and any project-side file (override, shadow, and promote findings are "cross"); "project" for everything else.',
893
+ '- suggestion: one actionable sentence (what to merge, delete, rewrite, or promote).',
894
+ '- severity: high = actively harmful (contradictions, wrong guidance), med = wasted context or confusion, low = polish.',
895
+ '- summary: 1-2 sentences on the overall health of this set.',
896
+ // The Sonnet orchestrator has been observed serializing findings as XML-ish text
897
+ // inside "summary" (failing schema validation), then "probing" with placeholder
898
+ // payloads until one passed — losing the real report. Spell the output contract out.
899
+ '- Final structured output: every finding goes as an object in the top-level "findings" array; "summary" is a short plain string. Never embed findings inside the summary text, never use XML-style tags in any field, and never submit placeholder or probe output (e.g. "Test", or a truncated findings list) just to pass validation. If your output is rejected for a schema mismatch, resubmit the SAME complete set of findings as valid JSON matching the schema — do not shrink, drop, or simplify it.',
900
+ '',
901
+ );
902
+ if (userAudited) {
903
+ parts.push('USER CLAUDE.MD (audit this with claude-md-user-reviewer):', `=== FILE (user): ${userSrc.name} ===`, userSrc.content, '');
904
+ } else {
905
+ parts.push('GLOBAL CLAUDE.md (reference only, do not audit it):', '<<<', userSrc?.content || '(none)', '>>>', '');
906
+ }
907
+ if (memSources.length) {
908
+ parts.push('MEMORY FILES:');
909
+ for (const s of memSources) {
910
+ parts.push(`=== FILE: ${s.name} ===`, s.content, '');
911
+ }
912
+ }
913
+ if (fullSkills.length) {
914
+ parts.push('PROJECT SKILLS (audit these):');
915
+ for (const s of fullSkills) {
916
+ parts.push(`=== SKILL: ${s.name} ===`, s.content, '');
917
+ }
918
+ }
919
+ if (fullAgent.length) {
920
+ parts.push('AGENT MEMORY FILES (audit these):');
921
+ for (const s of fullAgent) {
922
+ parts.push(`=== FILE (agent ${s.agentName || '?'}): ${s.name} ===`, s.content, '');
923
+ }
924
+ }
925
+ if (fullClaudeMd.length) {
926
+ parts.push('CLAUDE.MD FILES (audit these):');
927
+ for (const s of fullClaudeMd) {
928
+ parts.push(`=== FILE (${s.scope}): ${s.name} ===`, s.content, '');
929
+ }
930
+ }
931
+ return parts.join('\n');
932
+ }
933
+
934
+ const ANALYSIS_MODELS = new Set(['sonnet', 'opus', 'fable']);
935
+
936
+ // Custom subagents for the per-file review fan-out (`claude --agents`). Each source
937
+ // type gets its own reviewer whose rubric and verification instructions live in the
938
+ // agent's system prompt — the parent orchestrator only passes file name + content.
939
+ const REVIEWER_COMMON = [
940
+ 'You review exactly one file that shapes Claude Code behavior; the parent passes its name and full content.',
941
+ 'Verify before judging: for every claim that references the repository in your working directory — a file, directory, command, script, flag, or path — check whether it still holds with your read-only tools. For tools the file expects on the machine, check existence with `command -v` / `which` / `where` (the only Bash commands you may run). Never call something "likely stale" when you can check it.',
942
+ 'Also judge whether the content is still needed at all: if the thing it guards against is gone (the tool was removed, the convention is enforced elsewhere, the referenced file no longer exists), say so explicitly.',
943
+ ];
944
+
945
+ // The user reviewer swaps repo verification for machine verification — the working
946
+ // directory is an arbitrary project and must not influence a global-file audit.
947
+ const REVIEWER_COMMON_USER = [
948
+ 'You review the user-level (global) CLAUDE.md that loads in every session of every project; the parent passes its name and full content.',
949
+ 'Verify before judging, against the machine only: check tools the file names with `command -v` / `which` / `where` (the only Bash commands you may run), and check files, skills, or agents it references under ~/.claude with your read tools. NEVER read or judge against the repository in your working directory — it is one arbitrary project and proves nothing about a global rule.',
950
+ 'Also judge whether the content is still needed at all: if the tool it references is gone from the machine or the file it points to no longer exists, say so explicitly.',
951
+ ];
952
+
953
+ const REVIEWER_REPORT = [
954
+ 'Report back compactly, nothing else:',
955
+ '1. CLAIMS: each checkable claim — HOLDS / FAILS / UNCHECKED, with one line of evidence (what you looked at).',
956
+ '2. FINDINGS: candidate findings for this file only (kind: stale, invalidate, or quality; severity high/med/low; title; detail; one-sentence fix). An empty list is a valid answer — do not pad.',
957
+ ];
958
+
959
+ function reviewerAgent(description, rubric, common = REVIEWER_COMMON) {
960
+ return {
961
+ description,
962
+ prompt: [common.join('\n'), RUBRIC_CORE, rubric, REVIEWER_REPORT.join('\n')].join('\n\n'),
963
+ // Bash is permission-scoped to command -v / which / where by the spawn's
964
+ // --allowedTools — anything else the reviewer tries is auto-denied in -p mode.
965
+ tools: ['Read', 'Grep', 'Glob', 'Bash'],
966
+ };
967
+ }
968
+
969
+ const ANALYSIS_AGENTS = {
970
+ 'memory-reviewer': reviewerAgent(
971
+ 'Reviews and verifies one auto-memory or agent-memory file against the memory rubric and the repository. One per file, in parallel.',
972
+ RUBRIC_MEMORY,
973
+ ),
974
+ 'skill-reviewer': reviewerAgent(
975
+ 'Reviews and verifies one SKILL.md file against the skill rubric and the repository. One per skill, in parallel.',
976
+ RUBRIC_SKILL,
977
+ ),
978
+ 'claude-md-reviewer': reviewerAgent(
979
+ 'Reviews and verifies one CLAUDE.md instruction file against the CLAUDE.md rubric and the repository. One per file, in parallel.',
980
+ RUBRIC_CLAUDE_MD,
981
+ ),
982
+ 'claude-md-user-reviewer': reviewerAgent(
983
+ 'Reviews and verifies the user-level (global) CLAUDE.md against the user rubric, PATH, and ~/.claude — never against the working repository.',
984
+ RUBRIC_CLAUDE_MD_USER,
985
+ REVIEWER_COMMON_USER,
986
+ ),
987
+ };
988
+
989
+ // Tool-existence checks are the only Bash the reviewers get; everything else
990
+ // stays auto-denied by headless mode's default permissions.
991
+ const ANALYSIS_ALLOWED_TOOLS = ['Bash(command -v:*)', 'Bash(which:*)', 'Bash(where:*)'];
992
+
993
+ function runClaudeAnalysis(prompt, model, cwd) {
994
+ return new Promise((resolve) => {
995
+ const args = ['-p', '--output-format', 'json', '--json-schema', JSON.stringify(ANALYSIS_SCHEMA)];
996
+ args.push('--agents', JSON.stringify(ANALYSIS_AGENTS));
997
+ args.push('--allowedTools', ...ANALYSIS_ALLOWED_TOOLS);
998
+ if (model) args.push('--model', model);
999
+ let child;
1000
+ try {
1001
+ // cwd = the project root so the agent can verify claims against the actual
1002
+ // files with its read-only tools (Read/Glob/Grep need no permission grants).
1003
+ child = spawn('claude', args, { cwd: cwd || os.tmpdir(), windowsHide: true });
1004
+ } catch (e) {
1005
+ return resolve({ ok: false, error: `Failed to spawn claude: ${e.message}` });
1006
+ }
1007
+ let out = '';
1008
+ let err = '';
1009
+ let done = false;
1010
+ // No timeout: a long run is never killed — it runs until claude exits on its
1011
+ // own. Past PENDING_STALL_MS the UI just shows it as stalled instead of spinning.
1012
+ const finish = (r) => { if (done) return; done = true; resolve(r); };
1013
+ child.stdout.on('data', (d) => { out += d; });
1014
+ child.stderr.on('data', (d) => { err += d; });
1015
+ child.on('error', (e) => finish({ ok: false, error: `claude not found — is Claude Code installed? (${e.message})` }));
1016
+ child.on('close', () => {
1017
+ try {
1018
+ const envelope = JSON.parse(out);
1019
+ if (envelope.is_error) return finish({ ok: false, error: String(envelope.result || 'Claude Code reported an error') });
1020
+ finish({ ok: true, data: envelope.structured_output, costUsd: envelope.total_cost_usd, durationMs: envelope.duration_ms });
1021
+ } catch {
1022
+ finish({ ok: false, error: `Claude Code returned an invalid JSON envelope${err ? `: ${err.slice(0, 300)}` : ''}` });
1023
+ }
1024
+ });
1025
+ child.stdin.end(prompt);
1026
+ });
1027
+ }
1028
+
1029
+ const CLAUDE_MD_SCOPES = new Set(['project', 'local']);
1030
+
1031
+ // Everything the analyzer can audit: auto memory, top-level skills, agent memory, CLAUDE.md files.
1032
+ function analyzableSources(stack) {
1033
+ return stack.filter(
1034
+ (s) =>
1035
+ s.scope === 'memory' ||
1036
+ s.scope === 'agent-memory' ||
1037
+ ((s.scope === 'skill' || s.scope === 'user' || CLAUDE_MD_SCOPES.has(s.scope)) && !s.parentId),
1038
+ );
1039
+ }
1040
+
1041
+ // Resolve the analysis scope: explicit ids from the picker, or the default set
1042
+ // (auto memory only — skills, agent memory, and CLAUDE.md files are opt-in).
1043
+ function selectAnalysisSources(stack, ids) {
1044
+ const all = analyzableSources(stack);
1045
+ if (Array.isArray(ids) && ids.length) {
1046
+ const wanted = new Set(ids);
1047
+ return all.filter((s) => wanted.has(s.id));
1048
+ }
1049
+ return all.filter((s) => s.scope === 'memory');
1050
+ }
1051
+
1052
+ function scopeDescription(selected) {
1053
+ const parts = [];
1054
+ const n = (pred) => selected.filter(pred).length;
1055
+ const mem = n((s) => s.scope === 'memory');
1056
+ const skl = n((s) => s.scope === 'skill');
1057
+ const agt = n((s) => s.scope === 'agent-memory');
1058
+ const cmd = n((s) => CLAUDE_MD_SCOPES.has(s.scope));
1059
+ const usr = n((s) => s.scope === 'user');
1060
+ if (mem) parts.push(`${mem} memory`);
1061
+ if (skl) parts.push(`${skl} skill${skl === 1 ? '' : 's'}`);
1062
+ if (agt) parts.push(`${agt} agent`);
1063
+ if (cmd) parts.push(`${cmd} CLAUDE.md`);
1064
+ if (usr) parts.push('user CLAUDE.md');
1065
+ return parts.join(' + ');
1066
+ }
1067
+
1068
+ app.get('/api/memory/analysis', (_req, res) => {
1069
+ const st = getAnalysisState(currentProjectPath);
1070
+ const running = st.pending.some((p) => !p.stalled);
1071
+ // The shared user-scope entry rides along on every project's payload; its
1072
+ // staleness tracks the user CLAUDE.md itself, independent of the project.
1073
+ const us = getAnalysisState(USER_SCOPE);
1074
+ // Staleness needs a full stack scan + content hash — skip it while a run is in
1075
+ // flight (the client polls every 2.5s and stale is meaningless mid-run anyway).
1076
+ let stale = false;
1077
+ let userStale = false;
1078
+ if (!running && (st.result || us.result)) {
1079
+ const stack = getStack();
1080
+ if (st.result) {
1081
+ const selected = selectAnalysisSources(stack, st.ids);
1082
+ const hash = selected.length ? memoryContentHash(selected) : null;
1083
+ stale = st.hash !== hash;
1084
+ }
1085
+ if (us.result) {
1086
+ const userSrc = stack.find((s) => s.id === 'user-claude-md');
1087
+ userStale = us.hash !== (userSrc ? memoryContentHash([userSrc]) : null);
1088
+ }
1089
+ }
1090
+ res.json({
1091
+ pending: st.pending,
1092
+ result: st.result,
1093
+ error: st.error,
1094
+ ts: st.ts,
1095
+ stale,
1096
+ project: currentProjectPath,
1097
+ runs: st.runs || [],
1098
+ dismissed: st.dismissed || [],
1099
+ user: { result: us.result, ts: us.ts, stale: userStale, runs: us.runs || [], dismissed: us.dismissed || [] },
1100
+ });
1101
+ });
1102
+
1103
+ // scope:'user' targets the shared user-scope entry, so the action holds across
1104
+ // projects; anything else targets the current project's entry.
1105
+ function analysisTarget(req) {
1106
+ return req.body?.scope === 'user' ? USER_SCOPE : currentProjectPath;
1107
+ }
1108
+
1109
+ app.post('/api/memory/analysis/delete-run', (req, res) => {
1110
+ const target = analysisTarget(req);
1111
+ const st = getAnalysisState(target);
1112
+ const ts = req.body?.ts;
1113
+ const runs = (st.runs || []).filter((run) => run.ts !== ts);
1114
+ if (runs.length === (st.runs || []).length) return res.status(404).json({ error: 'run not found' });
1115
+ saveAnalysisState(target, { ...st, error: null, runs });
1116
+ res.json({ ok: true, runs: runs.length });
1117
+ });
1118
+
1119
+ // Dismissals persist per project so they survive reloads and re-runs.
1120
+ // Body: {key} adds, {all:true} clears.
1121
+ app.post('/api/memory/analysis/dismiss', (req, res) => {
1122
+ const target = analysisTarget(req);
1123
+ const st = getAnalysisState(target);
1124
+ const set = new Set(st.dismissed || []);
1125
+ if (req.body?.all) set.clear();
1126
+ else {
1127
+ const key = req.body?.key;
1128
+ if (typeof key !== 'string' || !key) return res.status(400).json({ error: 'key required' });
1129
+ set.add(key);
1130
+ }
1131
+ saveAnalysisState(target, { ...st, dismissed: [...set] });
1132
+ res.json({ ok: true, dismissed: [...set] });
1133
+ });
1134
+
1135
+ app.post('/api/memory/analyze', (req, res) => {
1136
+ const prev = getAnalysisState(currentProjectPath);
1137
+ const stack = getStack();
1138
+ const ids = req.body?.ids;
1139
+ const selected = selectAnalysisSources(stack, ids);
1140
+ if (!selected.length) return res.status(404).json({ error: 'nothing to analyze for this scope' });
1141
+ const hash = memoryContentHash(selected);
1142
+ // The user CLAUDE.md is audited when picked; otherwise it stays reference
1143
+ // context for the 'promote' kind.
1144
+ const userSelected = selected.find((s) => s.scope === 'user');
1145
+ // A user-only run's result lives in the shared user-scope entry, so that is
1146
+ // where its cache hit must be checked.
1147
+ const userOnly = !!userSelected && selected.length === 1;
1148
+ const cacheEntry = userOnly ? getAnalysisState(USER_SCOPE) : prev;
1149
+ if (!req.body?.force && cacheEntry.result && cacheEntry.hash === hash) {
1150
+ return res.json({ cached: true });
1151
+ }
1152
+ const memSources = selected.filter((s) => s.scope === 'memory');
1153
+ const skills = selected.filter((s) => s.scope === 'skill');
1154
+ const agentSources = selected.filter((s) => s.scope === 'agent-memory');
1155
+ const claudeMdSources = selected.filter((s) => CLAUDE_MD_SCOPES.has(s.scope));
1156
+ const userSrc = userSelected || stack.find((s) => s.id === 'user-claude-md');
1157
+ const prompt = buildAnalyzePrompt(memSources, skills, agentSources, claudeMdSources, userSrc, !!userSelected);
1158
+ const stateIds = Array.isArray(ids) && ids.length ? ids : null;
1159
+ const model = ANALYSIS_MODELS.has(req.body?.model) ? req.body.model : null;
1160
+ // Capture the project now — the user may switch projects while the run is in flight,
1161
+ // and the result must land under the project it was computed for.
1162
+ const project = currentProjectPath;
1163
+ const runId = crypto.randomUUID();
1164
+ const scopeDesc = scopeDescription(selected);
1165
+ saveAnalysisState(project, {
1166
+ ...prev,
1167
+ // Starting a fresh run retires stalled leftovers — the user has moved on.
1168
+ pending: [...prev.pending.filter((p) => !p.stalled), { id: runId, startedAt: Date.now(), model, scopeDesc }],
1169
+ });
1170
+ const userHash = userSelected ? memoryContentHash([userSelected]) : null;
1171
+ // Snapshot the audited files into the run — the stack changes over time, so
1172
+ // historical runs must not re-derive their scope from the live stack. The user
1173
+ // file is owned by the user-scope entry's snapshot, not the project's.
1174
+ // Snapshotting up front also keeps `selected` (full file contents) out of the
1175
+ // completion closure, which can live for hours on a run that never returns.
1176
+ const audited = selected.filter((s) => s.scope !== 'user').map((s) => ({ id: s.id, name: s.name, scope: s.scope, lines: s.lines }));
1177
+ const userAudited = userSelected ? [{ id: userSelected.id, name: userSelected.name, scope: 'user', lines: userSelected.lines }] : null;
1178
+ const userId = userSelected?.id || null;
1179
+ // Prepend a run to an entry's history, newest first, capped.
1180
+ const withRun = (cur, run) => [run, ...(cur.runs || [])].slice(0, MAX_ANALYSIS_RUNS);
1181
+ runClaudeAnalysis(prompt, model, userOnly ? os.homedir() : project).then((r) => {
1182
+ const ts = Date.now();
1183
+ let result = r.ok ? { ...r.data, costUsd: r.costUsd, durationMs: r.durationMs, scopeDesc, model } : null;
1184
+ if (result) {
1185
+ // Reviewers sometimes copy the prompt's scope marker into file names
1186
+ // ("CLAUDE.md (user)") — canonicalize before persisting so no consumer sees it.
1187
+ const findings = (result.findings || []).map((f) => ({ ...f, files: (f.files || []).map((n) => n.replace(/\s*\((user|project|local)\)$/, '')) }));
1188
+ result = { ...result, findings };
1189
+ }
1190
+ // Findings about the user CLAUDE.md alone are shared across projects: split them
1191
+ // into the user-scope entry; project and cross findings stay with the project.
1192
+ if (result && userId) {
1193
+ const all = result.findings;
1194
+ const uCur = getAnalysisState(USER_SCOPE);
1195
+ const uRun = {
1196
+ result: { ...result, findings: all.filter((f) => f.scope === 'user'), scopeDesc: 'user CLAUDE.md' },
1197
+ ts,
1198
+ hash: userHash,
1199
+ ids: [userId],
1200
+ audited: userAudited,
1201
+ };
1202
+ saveAnalysisState(USER_SCOPE, { ...uCur, runs: withRun(uCur, uRun) });
1203
+ result = { ...result, findings: all.filter((f) => f.scope !== 'user') };
1204
+ }
1205
+ // Re-read from disk — parallel runs or another instance may have finished meanwhile.
1206
+ const cur = getAnalysisState(project);
1207
+ const runs = result && !userOnly ? withRun(cur, { result, ts, hash, ids: stateIds, audited }) : cur.runs || [];
1208
+ saveAnalysisState(project, {
1209
+ ...cur,
1210
+ pending: cur.pending.filter((p) => p.id !== runId),
1211
+ error: r.ok ? null : r.error,
1212
+ runs,
1213
+ });
1214
+ });
1215
+ res.status(202).json({ ok: true });
1216
+ });
1217
+
1218
+ // #endregion ANALYZER
1219
+
620
1220
  app.post('/api/open-in-editor', (req, res) => {
621
1221
  try {
622
1222
  openInEditor([assertOpenTarget(expandHome(req.body.path))]);
@@ -626,15 +1226,47 @@ app.post('/api/open-in-editor', (req, res) => {
626
1226
  }
627
1227
  });
628
1228
 
1229
+ // A skill's name + description ride the system prompt every session (unless model
1230
+ // invocation is disabled) — that is its standing cost, not the on-demand body.
1231
+ function skillDescMeta(source) {
1232
+ const fm = source.frontmatter || {};
1233
+ if (fm['disable-model-invocation'] === 'true') return null;
1234
+ const desc = typeof fm.description === 'string' ? fm.description : '';
1235
+ return { chars: (source.skillName || source.name || '').length + desc.trim().length };
1236
+ }
1237
+
629
1238
  app.get('/api/summary', (_req, res) => {
630
- const sources = getStack();
1239
+ // Skill bodies load on invocation and markdown-linked docs load only when Claude
1240
+ // follows the link — neither is part of the memory footprint, only hard @imports are.
1241
+ const stack = getStack();
1242
+ // Memory topic files load on demand like skill bodies — only the startup MEMORY.md
1243
+ // index is standing cost, so ondemand memory-scope files stay out of the footprint.
1244
+ // Nested CLAUDE.md files (load:'tree') load progressively when Claude works in their
1245
+ // directory, so they and their import chains are not standing cost either.
1246
+ const treeIds = new Set(stack.filter(s => s.load === 'tree').map(s => s.id));
1247
+ let grew = true;
1248
+ while (grew) {
1249
+ grew = false;
1250
+ for (const s of stack) {
1251
+ if (s.parentId && treeIds.has(s.parentId) && !treeIds.has(s.id)) { treeIds.add(s.id); grew = true; }
1252
+ }
1253
+ }
1254
+ const sources = stack.filter(s => s.scope !== 'skill' && s.load !== 'link' && !treeIds.has(s.id)
1255
+ && !((s.scope === 'memory' || s.scope === 'agent-memory') && s.load === 'ondemand'));
631
1256
  const totalFiles = sources.length;
632
1257
  const totalLines = sources.reduce((s, f) => s + (f.lines || 0), 0);
633
1258
  const totalBytes = sources.reduce((s, f) => s + (f.bytes || 0), 0);
634
1259
  const alwaysLoaded = sources.filter(s => s.load === 'always' || s.load === 'startup').length;
635
1260
  const conditional = sources.filter(s => s.load === 'conditional').length;
636
1261
  const onDemand = sources.filter(s => s.load === 'ondemand').length;
637
- res.json({ totalFiles, totalLines, totalBytes, alwaysLoaded, conditional, onDemand });
1262
+ const skillDescs = stack.filter(s => s.scope === 'skill').map(skillDescMeta).filter(Boolean);
1263
+ const skillDesc = { count: skillDescs.length, chars: skillDescs.reduce((s, d) => s + d.chars, 0) };
1264
+ // Per-scope char totals so the client budget bar shares this exact footprint filter
1265
+ const scopeChars = {};
1266
+ for (const f of sources) scopeChars[f.scope] = (scopeChars[f.scope] || 0) + (f.chars || 0);
1267
+ const totalChars = sources.reduce((s, f) => s + (f.chars || 0), 0) + skillDesc.chars;
1268
+ // ids: which sources make up the footprint, so the client can highlight them
1269
+ res.json({ totalFiles, totalLines, totalBytes, totalChars, scopeChars, skillDesc, alwaysLoaded, conditional, onDemand, ids: sources.map(s => s.id) });
638
1270
  });
639
1271
 
640
1272
  app.get('/api/stack', (_req, res) => {
@@ -706,7 +1338,7 @@ app.get('/api/imports', (req, res) => {
706
1338
  // #region STARTUP
707
1339
 
708
1340
  const onReady = (port) => {
709
- console.log(`Claude Code Memory running at http://localhost:${port}`);
1341
+ console.log(`Memory Diagnoser running at http://localhost:${port}`);
710
1342
  console.log(`Project: ${currentProjectPath}`);
711
1343
  const warning = net.exposureWarning();
712
1344
  if (warning) console.log(warning);