claude-code-memory-explorer 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -18
- package/package.json +1 -1
- package/public/app.js +598 -72
- package/public/index.html +5 -18
- package/public/style.css +429 -24
- package/server.js +291 -113
package/server.js
CHANGED
|
@@ -598,7 +598,7 @@ function stripContent(source) {
|
|
|
598
598
|
const app = express();
|
|
599
599
|
|
|
600
600
|
// Mounted before express.json() so a rejected request never buffers a body.
|
|
601
|
-
const net = createNetGuard({ appName: '
|
|
601
|
+
const net = createNetGuard({ appName: 'Memory Diagnoser' });
|
|
602
602
|
app.use(net.hostGuard);
|
|
603
603
|
app.use(net.frameGuard);
|
|
604
604
|
app.use(net.originGuard);
|
|
@@ -608,7 +608,7 @@ app.use(express.static(path.join(__dirname, 'public')));
|
|
|
608
608
|
|
|
609
609
|
app.get('/hub-config', (_req, res) => {
|
|
610
610
|
res.json({
|
|
611
|
-
name: '
|
|
611
|
+
name: 'Memory Diagnoser',
|
|
612
612
|
icon: 'brain',
|
|
613
613
|
description: 'Explore Claude Code memory sources',
|
|
614
614
|
enabled: !!process.env.CLAUDE_HUB,
|
|
@@ -711,78 +711,81 @@ const ANALYSIS_SCHEMA = {
|
|
|
711
711
|
items: {
|
|
712
712
|
type: 'object',
|
|
713
713
|
properties: {
|
|
714
|
-
kind: { type: 'string', enum: ['duplicate', 'contradiction', 'promote', 'merge', 'stale', 'invalidate', 'quality'] },
|
|
714
|
+
kind: { type: 'string', enum: ['duplicate', 'contradiction', 'promote', 'merge', 'stale', 'invalidate', 'quality', 'override', 'shadow', 'demote'] },
|
|
715
715
|
severity: { type: 'string', enum: ['high', 'med', 'low'] },
|
|
716
|
+
scope: { type: 'string', enum: ['user', 'project', 'cross'] },
|
|
716
717
|
title: { type: 'string' },
|
|
717
718
|
detail: { type: 'string' },
|
|
718
719
|
files: { type: 'array', items: { type: 'string' } },
|
|
719
720
|
suggestion: { type: 'string' },
|
|
720
721
|
evidence: { type: 'string' },
|
|
721
722
|
},
|
|
722
|
-
required: ['kind', 'severity', 'title', 'detail', 'files', 'suggestion'],
|
|
723
|
+
required: ['kind', 'severity', 'scope', 'title', 'detail', 'files', 'suggestion'],
|
|
723
724
|
},
|
|
724
725
|
},
|
|
725
726
|
},
|
|
726
727
|
required: ['summary', 'findings'],
|
|
727
728
|
};
|
|
728
729
|
|
|
729
|
-
//
|
|
730
|
-
|
|
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');
|
|
731
735
|
|
|
732
|
-
|
|
733
|
-
// last result. Completed runs are persisted to disk so they survive server restarts.
|
|
734
|
-
const ANALYSIS_STORE = path.join(CLAUDE_DIR, 'claude-code-memory-analysis.json');
|
|
735
|
-
const analysisStates = new Map();
|
|
736
|
-
|
|
737
|
-
const EMPTY_ANALYSIS = { running: false, result: null, error: null, hash: null, ts: null, ids: null, runs: [] };
|
|
736
|
+
const EMPTY_ANALYSIS = { pending: [], result: null, error: null, hash: null, ts: null, ids: null, runs: [], dismissed: [] };
|
|
738
737
|
const MAX_ANALYSIS_RUNS = 10;
|
|
739
738
|
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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`);
|
|
746
748
|
}
|
|
747
749
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
return {}; // no store yet, or unreadable
|
|
758
|
-
}
|
|
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
759
|
}
|
|
760
760
|
|
|
761
|
-
// The store file is shared between server instances (hub + standalone), so disk is
|
|
762
|
-
// the source of truth whenever this instance has no run in flight for the project.
|
|
763
761
|
function getAnalysisState(project) {
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
return EMPTY_ANALYSIS;
|
|
762
|
+
let st = null;
|
|
763
|
+
try {
|
|
764
|
+
st = JSON.parse(fs.readFileSync(analysisFile(project), 'utf-8'));
|
|
765
|
+
} catch {
|
|
766
|
+
return { ...EMPTY_ANALYSIS };
|
|
770
767
|
}
|
|
771
|
-
const loaded =
|
|
772
|
-
|
|
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;
|
|
773
777
|
return loaded;
|
|
774
778
|
}
|
|
775
779
|
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
function saveAnalysisState(project) {
|
|
779
|
-
const disk = readAnalysisStore();
|
|
780
|
-
const st = analysisStates.get(project);
|
|
781
|
-
if (st && (st.result || st.error || (st.runs || []).length))
|
|
782
|
-
disk[project] = { result: st.result, error: st.error, hash: st.hash, ts: st.ts, ids: st.ids, runs: st.runs || [] };
|
|
783
|
-
else delete disk[project];
|
|
780
|
+
function saveAnalysisState(project, st) {
|
|
781
|
+
const { pending = [], error = null, runs = [], dismissed = [] } = st;
|
|
784
782
|
try {
|
|
785
|
-
|
|
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');
|
|
786
789
|
} catch { /* best-effort persistence */ }
|
|
787
790
|
}
|
|
788
791
|
|
|
@@ -834,7 +837,39 @@ const RUBRIC_CLAUDE_MD = [
|
|
|
834
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.',
|
|
835
838
|
].join('\n');
|
|
836
839
|
|
|
837
|
-
|
|
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
|
+
// The server already knows every `@import` a file makes: parseImports() carries the
|
|
854
|
+
// grammar (npm scopes excluded, extension required) and resolveImport() the resolution
|
|
855
|
+
// (~ = home, else relative to the importing file). Handing the reviewer that answer as
|
|
856
|
+
// fact beats shipping it a prose reimplementation it would run differently each time.
|
|
857
|
+
// Hard `@` imports only — markdown links are references, not inlined content.
|
|
858
|
+
function importLines(src) {
|
|
859
|
+
const lines = [`PATH: ${src.path}`];
|
|
860
|
+
const resolved = [];
|
|
861
|
+
const missing = [];
|
|
862
|
+
for (const imp of parseImports(src.content).imports) {
|
|
863
|
+
const abs = resolveImport(imp, src.path);
|
|
864
|
+
if (fs.existsSync(abs)) resolved.push(abs);
|
|
865
|
+
else missing.push(`${imp} (tried ${abs})`);
|
|
866
|
+
}
|
|
867
|
+
if (resolved.length) lines.push(`IMPORTS: ${resolved.join(', ')}`);
|
|
868
|
+
if (missing.length) lines.push(`MISSING IMPORTS: ${missing.join(', ')}`);
|
|
869
|
+
return lines;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function buildAnalyzePrompt(memSources, skillSources, agentSources, claudeMdSources, userSrc, userAudited) {
|
|
838
873
|
const fullSkills = skillSources || [];
|
|
839
874
|
const fullAgent = agentSources || [];
|
|
840
875
|
const fullClaudeMd = claudeMdSources || [];
|
|
@@ -843,6 +878,8 @@ function buildAnalyzePrompt(memSources, userClaudeMd, skillSources, agentSources
|
|
|
843
878
|
if (fullSkills.length) audited.push('project skills (SKILL.md files)');
|
|
844
879
|
if (fullAgent.length) audited.push('agent memory files');
|
|
845
880
|
if (fullClaudeMd.length) audited.push('CLAUDE.md instruction files');
|
|
881
|
+
if (userAudited) audited.push('the user-level (global) CLAUDE.md');
|
|
882
|
+
const crossScope = userAudited && fullClaudeMd.length;
|
|
846
883
|
const parts = [
|
|
847
884
|
`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.`,
|
|
848
885
|
'',
|
|
@@ -854,22 +891,42 @@ function buildAnalyzePrompt(memSources, userClaudeMd, skillSources, agentSources
|
|
|
854
891
|
'- stale: content that is outdated — verified against the repo when checkable, or judged from its own text otherwise.',
|
|
855
892
|
'- 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.',
|
|
856
893
|
'- quality: content that is vague, missing its why, or unlikely to change agent behavior — reported by the reviewers against their rubrics.',
|
|
894
|
+
];
|
|
895
|
+
if (crossScope) {
|
|
896
|
+
parts.push(
|
|
897
|
+
'- 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.',
|
|
898
|
+
'- shadow: a project rule that restates a user-level rule near-verbatim; the project copy is the deletion candidate.',
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
if (userAudited) {
|
|
902
|
+
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.');
|
|
903
|
+
}
|
|
904
|
+
parts.push(
|
|
857
905
|
'',
|
|
858
906
|
'Rules:',
|
|
859
|
-
|
|
860
|
-
|
|
907
|
+
`- You are the orchestrator; do not review files yourself. Your working directory is the project root. For every audited file, launch the type-matched reviewer subagent via your Agent tool — "memory-reviewer" for memory and agent-memory files, "skill-reviewer" for skills, "claude-md-reviewer" for project CLAUDE.md files${userAudited ? ', "claude-md-user-reviewer" for the user-level CLAUDE.md' : ''} — all in a single parallel batch of blocking calls (never background/async agents — you must hold your final report until every reviewer has returned). Pass each one only the file name, its full content, and — for skills and CLAUDE.md files — the PATH:, IMPORTS:, and MISSING IMPORTS: lines from its block below; the reviewers carry their own criteria and verify claims against the repo${userAudited ? ' (the user-level reviewer verifies against the machine, never the repo)' : ''}.`,
|
|
908
|
+
`- Your own job: merge the reviewers' per-file findings and add the cross-file kinds (duplicate, contradiction, merge, promote${crossScope ? ', override, shadow' : ''}) by comparing the files side by side.`,
|
|
909
|
+
'- @imports are inlined content: the files on a block\'s IMPORTS: line load as part of that file. Reviewers read them, so treat what they report from an import as belonging to the importing file when you compare files side by side — a rule imported by one file and written out in another is still a duplicate. Imported files are never audited on their own; attribute every finding to the importer.',
|
|
861
910
|
'- Verification is a filter: drop any finding a reviewer\'s claim checks disproved, and never report "likely outdated" for a claim a reviewer confirmed still HOLDS. Record what was checked in the finding\'s "evidence" field (one sentence, internal bookkeeping — it is not shown to the user). Omit "evidence" for judgment-only findings.',
|
|
862
|
-
'- files: the exact file names involved as listed below (e.g. feedback_foo.md, MEMORY.md, or a skill name like my-skill).',
|
|
911
|
+
'- files: the exact file names involved as listed below (e.g. feedback_foo.md, MEMORY.md, or a skill name like my-skill). Bare names only — never append scope markers like "(user)"; scope is carried by the scope field.',
|
|
912
|
+
'- scope: "user" if the finding only cites the user-level CLAUDE.md (demote findings are always "user"); "cross" if it cites both the user-level file and any project-side file (override, shadow, and promote findings are "cross"); "project" for everything else.',
|
|
863
913
|
'- suggestion: one actionable sentence (what to merge, delete, rewrite, or promote).',
|
|
864
914
|
'- severity: high = actively harmful (contradictions, wrong guidance), med = wasted context or confusion, low = polish.',
|
|
865
915
|
'- summary: 1-2 sentences on the overall health of this set.',
|
|
916
|
+
// The Sonnet orchestrator has been observed serializing findings as XML-ish text
|
|
917
|
+
// inside "summary" (failing schema validation), then "probing" with placeholder
|
|
918
|
+
// payloads until one passed — losing the real report. Spell the output contract out.
|
|
919
|
+
'- 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.',
|
|
866
920
|
'',
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
921
|
+
);
|
|
922
|
+
if (userAudited) {
|
|
923
|
+
parts.push('USER CLAUDE.MD (audit this with claude-md-user-reviewer):', `=== FILE (user): ${userSrc.name} ===`, ...importLines(userSrc), userSrc.content, '');
|
|
924
|
+
} else {
|
|
925
|
+
parts.push(
|
|
926
|
+
'GLOBAL CLAUDE.md (reference only, do not audit it):',
|
|
927
|
+
...(userSrc ? importLines(userSrc).slice(1).map((l) => `${l} — inlined into the global file; read these before judging a rule missing from it`) : []),
|
|
928
|
+
'<<<', userSrc?.content || '(none)', '>>>', '');
|
|
929
|
+
}
|
|
873
930
|
if (memSources.length) {
|
|
874
931
|
parts.push('MEMORY FILES:');
|
|
875
932
|
for (const s of memSources) {
|
|
@@ -879,7 +936,7 @@ function buildAnalyzePrompt(memSources, userClaudeMd, skillSources, agentSources
|
|
|
879
936
|
if (fullSkills.length) {
|
|
880
937
|
parts.push('PROJECT SKILLS (audit these):');
|
|
881
938
|
for (const s of fullSkills) {
|
|
882
|
-
parts.push(`=== SKILL: ${s.name} ===`, s.content, '');
|
|
939
|
+
parts.push(`=== SKILL: ${s.name} ===`, ...importLines(s), s.content, '');
|
|
883
940
|
}
|
|
884
941
|
}
|
|
885
942
|
if (fullAgent.length) {
|
|
@@ -891,7 +948,7 @@ function buildAnalyzePrompt(memSources, userClaudeMd, skillSources, agentSources
|
|
|
891
948
|
if (fullClaudeMd.length) {
|
|
892
949
|
parts.push('CLAUDE.MD FILES (audit these):');
|
|
893
950
|
for (const s of fullClaudeMd) {
|
|
894
|
-
parts.push(`=== FILE (${s.scope}): ${s.name} ===`, s.content, '');
|
|
951
|
+
parts.push(`=== FILE (${s.scope}): ${s.name} ===`, ...importLines(s), s.content, '');
|
|
895
952
|
}
|
|
896
953
|
}
|
|
897
954
|
return parts.join('\n');
|
|
@@ -904,8 +961,18 @@ const ANALYSIS_MODELS = new Set(['sonnet', 'opus', 'fable']);
|
|
|
904
961
|
// agent's system prompt — the parent orchestrator only passes file name + content.
|
|
905
962
|
const REVIEWER_COMMON = [
|
|
906
963
|
'You review exactly one file that shapes Claude Code behavior; the parent passes its name and full content.',
|
|
907
|
-
'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. Never call something "likely stale" when you can check it.',
|
|
964
|
+
'Verify before judging: for every claim that references the repository in your working directory — a file, directory, command, script, flag, or path — check whether it still holds with your read-only tools. For tools the file expects on the machine, check existence with `command -v` / `which` / `where` (the only Bash commands you may run). Never call something "likely stale" when you can check it.',
|
|
908
965
|
'Also judge whether the content is still needed at all: if the thing it guards against is gone (the tool was removed, the convention is enforced elsewhere, the referenced file no longer exists), say so explicitly.',
|
|
966
|
+
'A check you could not run is not evidence of absence. If a probe is denied by permissions or errors out, report that claim as UNCHECKED and say why — never turn a failed check into a stale or invalidate finding.',
|
|
967
|
+
];
|
|
968
|
+
|
|
969
|
+
// The user reviewer swaps repo verification for machine verification — the working
|
|
970
|
+
// directory is an arbitrary project and must not influence a global-file audit.
|
|
971
|
+
const REVIEWER_COMMON_USER = [
|
|
972
|
+
'You review the user-level (global) CLAUDE.md that loads in every session of every project; the parent passes its name and full content.',
|
|
973
|
+
'Verify before judging, against the machine only: check tools the file names with `command -v` / `which` / `where` (the only Bash commands you may run), and check files, skills, or agents it references under ~/.claude with your read tools. NEVER read or judge against the repository in your working directory — it is one arbitrary project and proves nothing about a global rule.',
|
|
974
|
+
'Also judge whether the content is still needed at all: if the tool it references is gone from the machine or the file it points to no longer exists, say so explicitly.',
|
|
975
|
+
'A check you could not run is not evidence of absence. If a probe is denied by permissions or errors out, report that claim as UNCHECKED and say why — never turn a failed check into a stale or invalidate finding.',
|
|
909
976
|
];
|
|
910
977
|
|
|
911
978
|
const REVIEWER_REPORT = [
|
|
@@ -914,11 +981,27 @@ const REVIEWER_REPORT = [
|
|
|
914
981
|
'2. FINDINGS: candidate findings for this file only (kind: stale, invalidate, or quality; severity high/med/low; title; detail; one-sentence fix). An empty list is a valid answer — do not pad.',
|
|
915
982
|
];
|
|
916
983
|
|
|
917
|
-
|
|
984
|
+
// `@path/to/file.md` in a CLAUDE.md or a skill is an import: Claude Code inlines the
|
|
985
|
+
// target's content where the token sits. A reviewer that judges only the text in front
|
|
986
|
+
// of it reads an import as a dangling reference and never sees the rules the file
|
|
987
|
+
// actually loads. `importLines()` resolves the graph server-side and states it as fact,
|
|
988
|
+
// so the rubric only has to say what an import means. Memory files are excluded: the
|
|
989
|
+
// imports they make are audited as sources in their own right.
|
|
990
|
+
const RUBRIC_IMPORTS = [
|
|
991
|
+
'Import notation (@path):',
|
|
992
|
+
'- The IMPORTS: line of your file block lists absolute paths Claude Code inlines into this file at load time. Read each one and judge its content against every criterion above as part of this file — including imports of imports. Duplication between the file and its own import, contradictions across its imports, and bloat pulled in through an import all count against the importing file.',
|
|
993
|
+
'- An @path token is an import, never a broken link or an unexplained reference: do not flag the pointer itself as vague or undefined. Anything on the MISSING IMPORTS: line resolves to nothing, and is the only missing-import finding to report (kind: stale).',
|
|
994
|
+
].join('\n');
|
|
995
|
+
|
|
996
|
+
// `rubric` takes one rubric constant or a list of them, so a reviewer can stack the
|
|
997
|
+
// shared import rubric on top of its per-type one without a second parameter.
|
|
998
|
+
function reviewerAgent(description, rubric, common = REVIEWER_COMMON) {
|
|
918
999
|
return {
|
|
919
1000
|
description,
|
|
920
|
-
prompt: [
|
|
921
|
-
|
|
1001
|
+
prompt: [common.join('\n'), RUBRIC_CORE, ...[].concat(rubric), REVIEWER_REPORT.join('\n')].join('\n\n'),
|
|
1002
|
+
// Bash is permission-scoped to command -v / which / where by the spawn's
|
|
1003
|
+
// --allowedTools — anything else the reviewer tries is auto-denied in -p mode.
|
|
1004
|
+
tools: ['Read', 'Grep', 'Glob', 'Bash'],
|
|
922
1005
|
};
|
|
923
1006
|
}
|
|
924
1007
|
|
|
@@ -929,18 +1012,28 @@ const ANALYSIS_AGENTS = {
|
|
|
929
1012
|
),
|
|
930
1013
|
'skill-reviewer': reviewerAgent(
|
|
931
1014
|
'Reviews and verifies one SKILL.md file against the skill rubric and the repository. One per skill, in parallel.',
|
|
932
|
-
RUBRIC_SKILL,
|
|
1015
|
+
[RUBRIC_SKILL, RUBRIC_IMPORTS],
|
|
933
1016
|
),
|
|
934
1017
|
'claude-md-reviewer': reviewerAgent(
|
|
935
1018
|
'Reviews and verifies one CLAUDE.md instruction file against the CLAUDE.md rubric and the repository. One per file, in parallel.',
|
|
936
|
-
RUBRIC_CLAUDE_MD,
|
|
1019
|
+
[RUBRIC_CLAUDE_MD, RUBRIC_IMPORTS],
|
|
1020
|
+
),
|
|
1021
|
+
'claude-md-user-reviewer': reviewerAgent(
|
|
1022
|
+
'Reviews and verifies the user-level (global) CLAUDE.md against the user rubric, PATH, and ~/.claude — never against the working repository.',
|
|
1023
|
+
[RUBRIC_CLAUDE_MD_USER, RUBRIC_IMPORTS],
|
|
1024
|
+
REVIEWER_COMMON_USER,
|
|
937
1025
|
),
|
|
938
1026
|
};
|
|
939
1027
|
|
|
1028
|
+
// Tool-existence checks are the only Bash the reviewers get; everything else
|
|
1029
|
+
// stays auto-denied by headless mode's default permissions.
|
|
1030
|
+
const ANALYSIS_ALLOWED_TOOLS = ['Bash(command -v:*)', 'Bash(which:*)', 'Bash(where:*)'];
|
|
1031
|
+
|
|
940
1032
|
function runClaudeAnalysis(prompt, model, cwd) {
|
|
941
1033
|
return new Promise((resolve) => {
|
|
942
1034
|
const args = ['-p', '--output-format', 'json', '--json-schema', JSON.stringify(ANALYSIS_SCHEMA)];
|
|
943
1035
|
args.push('--agents', JSON.stringify(ANALYSIS_AGENTS));
|
|
1036
|
+
args.push('--allowedTools', ...ANALYSIS_ALLOWED_TOOLS);
|
|
944
1037
|
if (model) args.push('--model', model);
|
|
945
1038
|
let child;
|
|
946
1039
|
try {
|
|
@@ -953,8 +1046,9 @@ function runClaudeAnalysis(prompt, model, cwd) {
|
|
|
953
1046
|
let out = '';
|
|
954
1047
|
let err = '';
|
|
955
1048
|
let done = false;
|
|
956
|
-
|
|
957
|
-
|
|
1049
|
+
// No timeout: a long run is never killed — it runs until claude exits on its
|
|
1050
|
+
// own. Past PENDING_STALL_MS the UI just shows it as stalled instead of spinning.
|
|
1051
|
+
const finish = (r) => { if (done) return; done = true; resolve(r); };
|
|
958
1052
|
child.stdout.on('data', (d) => { out += d; });
|
|
959
1053
|
child.stderr.on('data', (d) => { err += d; });
|
|
960
1054
|
child.on('error', (e) => finish({ ok: false, error: `claude not found — is Claude Code installed? (${e.message})` }));
|
|
@@ -962,7 +1056,15 @@ function runClaudeAnalysis(prompt, model, cwd) {
|
|
|
962
1056
|
try {
|
|
963
1057
|
const envelope = JSON.parse(out);
|
|
964
1058
|
if (envelope.is_error) return finish({ ok: false, error: String(envelope.result || 'Claude Code reported an error') });
|
|
965
|
-
|
|
1059
|
+
// A run can exit clean without structured_output (schema retries exhausted,
|
|
1060
|
+
// truncated stdout). Spreading that absence downstream yields an empty
|
|
1061
|
+
// findings list, which the UI cannot tell apart from a genuine all-clear —
|
|
1062
|
+
// so treat a missing payload as a failure rather than as "no problems".
|
|
1063
|
+
const data = envelope.structured_output;
|
|
1064
|
+
if (!data || !Array.isArray(data.findings)) {
|
|
1065
|
+
return finish({ ok: false, error: 'Claude Code returned no structured output — the audit did not complete, so no result is being shown.' });
|
|
1066
|
+
}
|
|
1067
|
+
finish({ ok: true, data, costUsd: envelope.total_cost_usd, durationMs: envelope.duration_ms });
|
|
966
1068
|
} catch {
|
|
967
1069
|
finish({ ok: false, error: `Claude Code returned an invalid JSON envelope${err ? `: ${err.slice(0, 300)}` : ''}` });
|
|
968
1070
|
}
|
|
@@ -979,7 +1081,7 @@ function analyzableSources(stack) {
|
|
|
979
1081
|
(s) =>
|
|
980
1082
|
s.scope === 'memory' ||
|
|
981
1083
|
s.scope === 'agent-memory' ||
|
|
982
|
-
((s.scope === 'skill' || CLAUDE_MD_SCOPES.has(s.scope)) && !s.parentId),
|
|
1084
|
+
((s.scope === 'skill' || s.scope === 'user' || CLAUDE_MD_SCOPES.has(s.scope)) && !s.parentId),
|
|
983
1085
|
);
|
|
984
1086
|
}
|
|
985
1087
|
|
|
@@ -1001,98 +1103,163 @@ function scopeDescription(selected) {
|
|
|
1001
1103
|
const skl = n((s) => s.scope === 'skill');
|
|
1002
1104
|
const agt = n((s) => s.scope === 'agent-memory');
|
|
1003
1105
|
const cmd = n((s) => CLAUDE_MD_SCOPES.has(s.scope));
|
|
1106
|
+
const usr = n((s) => s.scope === 'user');
|
|
1004
1107
|
if (mem) parts.push(`${mem} memory`);
|
|
1005
1108
|
if (skl) parts.push(`${skl} skill${skl === 1 ? '' : 's'}`);
|
|
1006
1109
|
if (agt) parts.push(`${agt} agent`);
|
|
1007
1110
|
if (cmd) parts.push(`${cmd} CLAUDE.md`);
|
|
1111
|
+
if (usr) parts.push('user CLAUDE.md');
|
|
1008
1112
|
return parts.join(' + ');
|
|
1009
1113
|
}
|
|
1010
1114
|
|
|
1011
1115
|
app.get('/api/memory/analysis', (_req, res) => {
|
|
1012
1116
|
const st = getAnalysisState(currentProjectPath);
|
|
1117
|
+
const running = st.pending.some((p) => !p.stalled);
|
|
1118
|
+
// The shared user-scope entry rides along on every project's payload; its
|
|
1119
|
+
// staleness tracks the user CLAUDE.md itself, independent of the project.
|
|
1120
|
+
const us = getAnalysisState(USER_SCOPE);
|
|
1013
1121
|
// Staleness needs a full stack scan + content hash — skip it while a run is in
|
|
1014
1122
|
// flight (the client polls every 2.5s and stale is meaningless mid-run anyway).
|
|
1015
1123
|
let stale = false;
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
const
|
|
1019
|
-
|
|
1124
|
+
let userStale = false;
|
|
1125
|
+
if (!running && (st.result || us.result)) {
|
|
1126
|
+
const stack = getStack();
|
|
1127
|
+
if (st.result) {
|
|
1128
|
+
const selected = selectAnalysisSources(stack, st.ids);
|
|
1129
|
+
const hash = selected.length ? memoryContentHash(selected) : null;
|
|
1130
|
+
stale = st.hash !== hash;
|
|
1131
|
+
}
|
|
1132
|
+
if (us.result) {
|
|
1133
|
+
const userSrc = stack.find((s) => s.id === 'user-claude-md');
|
|
1134
|
+
userStale = us.hash !== (userSrc ? memoryContentHash([userSrc]) : null);
|
|
1135
|
+
}
|
|
1020
1136
|
}
|
|
1021
1137
|
res.json({
|
|
1022
|
-
|
|
1138
|
+
pending: st.pending,
|
|
1023
1139
|
result: st.result,
|
|
1024
1140
|
error: st.error,
|
|
1025
1141
|
ts: st.ts,
|
|
1026
1142
|
stale,
|
|
1027
1143
|
project: currentProjectPath,
|
|
1028
1144
|
runs: st.runs || [],
|
|
1145
|
+
dismissed: st.dismissed || [],
|
|
1146
|
+
user: { result: us.result, ts: us.ts, stale: userStale, runs: us.runs || [], dismissed: us.dismissed || [] },
|
|
1029
1147
|
});
|
|
1030
1148
|
});
|
|
1031
1149
|
|
|
1150
|
+
// scope:'user' targets the shared user-scope entry, so the action holds across
|
|
1151
|
+
// projects; anything else targets the current project's entry.
|
|
1152
|
+
function analysisTarget(req) {
|
|
1153
|
+
return req.body?.scope === 'user' ? USER_SCOPE : currentProjectPath;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1032
1156
|
app.post('/api/memory/analysis/delete-run', (req, res) => {
|
|
1033
|
-
const
|
|
1034
|
-
|
|
1157
|
+
const target = analysisTarget(req);
|
|
1158
|
+
const st = getAnalysisState(target);
|
|
1035
1159
|
const ts = req.body?.ts;
|
|
1036
1160
|
const runs = (st.runs || []).filter((run) => run.ts !== ts);
|
|
1037
1161
|
if (runs.length === (st.runs || []).length) return res.status(404).json({ error: 'run not found' });
|
|
1038
|
-
|
|
1039
|
-
analysisStates.set(currentProjectPath, {
|
|
1040
|
-
...st,
|
|
1041
|
-
result: latest?.result || null,
|
|
1042
|
-
ts: latest?.ts || null,
|
|
1043
|
-
hash: latest?.hash || null,
|
|
1044
|
-
ids: latest ? latest.ids : null,
|
|
1045
|
-
error: null,
|
|
1046
|
-
runs,
|
|
1047
|
-
});
|
|
1048
|
-
saveAnalysisState(currentProjectPath);
|
|
1162
|
+
saveAnalysisState(target, { ...st, error: null, runs });
|
|
1049
1163
|
res.json({ ok: true, runs: runs.length });
|
|
1050
1164
|
});
|
|
1051
1165
|
|
|
1166
|
+
// Dismissals persist per project so they survive reloads and re-runs.
|
|
1167
|
+
// Body: {key} adds, {all:true} clears.
|
|
1168
|
+
app.post('/api/memory/analysis/dismiss', (req, res) => {
|
|
1169
|
+
const target = analysisTarget(req);
|
|
1170
|
+
const st = getAnalysisState(target);
|
|
1171
|
+
const set = new Set(st.dismissed || []);
|
|
1172
|
+
if (req.body?.all) set.clear();
|
|
1173
|
+
else {
|
|
1174
|
+
const key = req.body?.key;
|
|
1175
|
+
if (typeof key !== 'string' || !key) return res.status(400).json({ error: 'key required' });
|
|
1176
|
+
set.add(key);
|
|
1177
|
+
}
|
|
1178
|
+
saveAnalysisState(target, { ...st, dismissed: [...set] });
|
|
1179
|
+
res.json({ ok: true, dismissed: [...set] });
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1052
1182
|
app.post('/api/memory/analyze', (req, res) => {
|
|
1053
1183
|
const prev = getAnalysisState(currentProjectPath);
|
|
1054
|
-
if (prev.running) return res.status(409).json({ error: 'analysis already running' });
|
|
1055
1184
|
const stack = getStack();
|
|
1056
1185
|
const ids = req.body?.ids;
|
|
1057
1186
|
const selected = selectAnalysisSources(stack, ids);
|
|
1058
1187
|
if (!selected.length) return res.status(404).json({ error: 'nothing to analyze for this scope' });
|
|
1059
1188
|
const hash = memoryContentHash(selected);
|
|
1060
|
-
|
|
1061
|
-
|
|
1189
|
+
// The user CLAUDE.md is audited when picked; otherwise it stays reference
|
|
1190
|
+
// context for the 'promote' kind.
|
|
1191
|
+
const userSelected = selected.find((s) => s.scope === 'user');
|
|
1192
|
+
// A user-only run's result lives in the shared user-scope entry, so that is
|
|
1193
|
+
// where its cache hit must be checked.
|
|
1194
|
+
const userOnly = !!userSelected && selected.length === 1;
|
|
1195
|
+
const cacheEntry = userOnly ? getAnalysisState(USER_SCOPE) : prev;
|
|
1196
|
+
if (!req.body?.force && cacheEntry.result && cacheEntry.hash === hash) {
|
|
1197
|
+
return res.json({ cached: true });
|
|
1062
1198
|
}
|
|
1063
1199
|
const memSources = selected.filter((s) => s.scope === 'memory');
|
|
1064
1200
|
const skills = selected.filter((s) => s.scope === 'skill');
|
|
1065
1201
|
const agentSources = selected.filter((s) => s.scope === 'agent-memory');
|
|
1066
1202
|
const claudeMdSources = selected.filter((s) => CLAUDE_MD_SCOPES.has(s.scope));
|
|
1067
|
-
|
|
1068
|
-
const
|
|
1069
|
-
const prompt = buildAnalyzePrompt(memSources, userClaudeMd?.content, skills, agentSources, claudeMdSources);
|
|
1203
|
+
const userSrc = userSelected || stack.find((s) => s.id === 'user-claude-md');
|
|
1204
|
+
const prompt = buildAnalyzePrompt(memSources, skills, agentSources, claudeMdSources, userSrc, !!userSelected);
|
|
1070
1205
|
const stateIds = Array.isArray(ids) && ids.length ? ids : null;
|
|
1071
1206
|
const model = ANALYSIS_MODELS.has(req.body?.model) ? req.body.model : null;
|
|
1072
1207
|
// Capture the project now — the user may switch projects while the run is in flight,
|
|
1073
1208
|
// and the result must land under the project it was computed for.
|
|
1074
1209
|
const project = currentProjectPath;
|
|
1075
|
-
const
|
|
1076
|
-
|
|
1077
|
-
|
|
1210
|
+
const runId = crypto.randomUUID();
|
|
1211
|
+
const scopeDesc = scopeDescription(selected);
|
|
1212
|
+
saveAnalysisState(project, {
|
|
1213
|
+
...prev,
|
|
1214
|
+
// Starting a fresh run retires stalled leftovers — the user has moved on.
|
|
1215
|
+
pending: [...prev.pending.filter((p) => !p.stalled), { id: runId, startedAt: Date.now(), model, scopeDesc }],
|
|
1216
|
+
});
|
|
1217
|
+
const userHash = userSelected ? memoryContentHash([userSelected]) : null;
|
|
1218
|
+
// Snapshot the audited files into the run — the stack changes over time, so
|
|
1219
|
+
// historical runs must not re-derive their scope from the live stack. The user
|
|
1220
|
+
// file is owned by the user-scope entry's snapshot, not the project's.
|
|
1221
|
+
// Snapshotting up front also keeps `selected` (full file contents) out of the
|
|
1222
|
+
// completion closure, which can live for hours on a run that never returns.
|
|
1223
|
+
const audited = selected.filter((s) => s.scope !== 'user').map((s) => ({ id: s.id, name: s.name, scope: s.scope, lines: s.lines }));
|
|
1224
|
+
const userAudited = userSelected ? [{ id: userSelected.id, name: userSelected.name, scope: 'user', lines: userSelected.lines }] : null;
|
|
1225
|
+
const userId = userSelected?.id || null;
|
|
1226
|
+
// Prepend a run to an entry's history, newest first, capped.
|
|
1227
|
+
const withRun = (cur, run) => [run, ...(cur.runs || [])].slice(0, MAX_ANALYSIS_RUNS);
|
|
1228
|
+
runClaudeAnalysis(prompt, model, userOnly ? os.homedir() : project).then((r) => {
|
|
1078
1229
|
const ts = Date.now();
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1230
|
+
let result = r.ok ? { ...r.data, costUsd: r.costUsd, durationMs: r.durationMs, scopeDesc, model } : null;
|
|
1231
|
+
if (result) {
|
|
1232
|
+
// Reviewers sometimes copy the prompt's scope marker into file names
|
|
1233
|
+
// ("CLAUDE.md (user)") — canonicalize before persisting so no consumer sees it.
|
|
1234
|
+
const findings = (result.findings || []).map((f) => ({ ...f, files: (f.files || []).map((n) => n.replace(/\s*\((user|project|local)\)$/, '')) }));
|
|
1235
|
+
result = { ...result, findings };
|
|
1236
|
+
}
|
|
1237
|
+
// Findings about the user CLAUDE.md alone are shared across projects: split them
|
|
1238
|
+
// into the user-scope entry; project and cross findings stay with the project.
|
|
1239
|
+
if (result && userId) {
|
|
1240
|
+
const all = result.findings;
|
|
1241
|
+
const uCur = getAnalysisState(USER_SCOPE);
|
|
1242
|
+
const uRun = {
|
|
1243
|
+
result: { ...result, findings: all.filter((f) => f.scope === 'user'), scopeDesc: 'user CLAUDE.md' },
|
|
1244
|
+
ts,
|
|
1245
|
+
hash: userHash,
|
|
1246
|
+
ids: [userId],
|
|
1247
|
+
audited: userAudited,
|
|
1248
|
+
};
|
|
1249
|
+
saveAnalysisState(USER_SCOPE, { ...uCur, runs: withRun(uCur, uRun) });
|
|
1250
|
+
result = { ...result, findings: all.filter((f) => f.scope !== 'user') };
|
|
1251
|
+
}
|
|
1252
|
+
// Re-read from disk — parallel runs or another instance may have finished meanwhile.
|
|
1253
|
+
const cur = getAnalysisState(project);
|
|
1254
|
+
const runs = result && !userOnly ? withRun(cur, { result, ts, hash, ids: stateIds, audited }) : cur.runs || [];
|
|
1255
|
+
saveAnalysisState(project, {
|
|
1256
|
+
...cur,
|
|
1257
|
+
pending: cur.pending.filter((p) => p.id !== runId),
|
|
1087
1258
|
error: r.ok ? null : r.error,
|
|
1088
|
-
hash,
|
|
1089
|
-
ts,
|
|
1090
|
-
ids: stateIds,
|
|
1091
1259
|
runs,
|
|
1092
1260
|
});
|
|
1093
|
-
saveAnalysisState(project);
|
|
1094
1261
|
});
|
|
1095
|
-
res.status(202).json({
|
|
1262
|
+
res.status(202).json({ ok: true });
|
|
1096
1263
|
});
|
|
1097
1264
|
|
|
1098
1265
|
// #endregion ANALYZER
|
|
@@ -1121,7 +1288,17 @@ app.get('/api/summary', (_req, res) => {
|
|
|
1121
1288
|
const stack = getStack();
|
|
1122
1289
|
// Memory topic files load on demand like skill bodies — only the startup MEMORY.md
|
|
1123
1290
|
// index is standing cost, so ondemand memory-scope files stay out of the footprint.
|
|
1124
|
-
|
|
1291
|
+
// Nested CLAUDE.md files (load:'tree') load progressively when Claude works in their
|
|
1292
|
+
// directory, so they and their import chains are not standing cost either.
|
|
1293
|
+
const treeIds = new Set(stack.filter(s => s.load === 'tree').map(s => s.id));
|
|
1294
|
+
let grew = true;
|
|
1295
|
+
while (grew) {
|
|
1296
|
+
grew = false;
|
|
1297
|
+
for (const s of stack) {
|
|
1298
|
+
if (s.parentId && treeIds.has(s.parentId) && !treeIds.has(s.id)) { treeIds.add(s.id); grew = true; }
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
const sources = stack.filter(s => s.scope !== 'skill' && s.load !== 'link' && !treeIds.has(s.id)
|
|
1125
1302
|
&& !((s.scope === 'memory' || s.scope === 'agent-memory') && s.load === 'ondemand'));
|
|
1126
1303
|
const totalFiles = sources.length;
|
|
1127
1304
|
const totalLines = sources.reduce((s, f) => s + (f.lines || 0), 0);
|
|
@@ -1135,7 +1312,8 @@ app.get('/api/summary', (_req, res) => {
|
|
|
1135
1312
|
const scopeChars = {};
|
|
1136
1313
|
for (const f of sources) scopeChars[f.scope] = (scopeChars[f.scope] || 0) + (f.chars || 0);
|
|
1137
1314
|
const totalChars = sources.reduce((s, f) => s + (f.chars || 0), 0) + skillDesc.chars;
|
|
1138
|
-
|
|
1315
|
+
// ids: which sources make up the footprint, so the client can highlight them
|
|
1316
|
+
res.json({ totalFiles, totalLines, totalBytes, totalChars, scopeChars, skillDesc, alwaysLoaded, conditional, onDemand, ids: sources.map(s => s.id) });
|
|
1139
1317
|
});
|
|
1140
1318
|
|
|
1141
1319
|
app.get('/api/stack', (_req, res) => {
|
|
@@ -1207,7 +1385,7 @@ app.get('/api/imports', (req, res) => {
|
|
|
1207
1385
|
// #region STARTUP
|
|
1208
1386
|
|
|
1209
1387
|
const onReady = (port) => {
|
|
1210
|
-
console.log(`
|
|
1388
|
+
console.log(`Memory Diagnoser running at http://localhost:${port}`);
|
|
1211
1389
|
console.log(`Project: ${currentProjectPath}`);
|
|
1212
1390
|
const warning = net.exposureWarning();
|
|
1213
1391
|
if (warning) console.log(warning);
|