claude-memory-admin 1.0.0 → 1.0.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/README.md +97 -6
- package/package.json +1 -1
- package/public/app.mjs +387 -90
- package/public/styles.css +37 -0
- package/server.mjs +61 -20
- package/src/instructions.mjs +423 -0
- package/src/model.mjs +44 -11
- package/src/mutate.mjs +17 -23
- package/src/pathcache.mjs +93 -0
- package/src/projects.mjs +120 -34
- package/src/search.mjs +13 -11
- package/src/settings.mjs +151 -0
- package/src/stats.mjs +105 -12
- package/src/stores.mjs +146 -0
package/public/styles.css
CHANGED
|
@@ -207,6 +207,14 @@ code { font-family: var(--mono); font-size: .9em; }
|
|
|
207
207
|
.index-line.kind-index:hover .content { text-decoration: underline; }
|
|
208
208
|
.index-line.dangling { background: var(--danger-soft); }
|
|
209
209
|
|
|
210
|
+
/* Everything past the cutoff is still in the file but never reaches Claude, so
|
|
211
|
+
it is dimmed rather than hidden: it is real content that is not doing its job. */
|
|
212
|
+
.index-line.beyond-cut { opacity: .45; }
|
|
213
|
+
.index-line.beyond-cut .content { text-decoration-line: line-through; text-decoration-color: var(--border); }
|
|
214
|
+
.cut-line { display: flex; align-items: center; gap: 10px; margin: 10px 0; }
|
|
215
|
+
.cut-line::before, .cut-line::after { content: ""; flex: 1; border-top: 1px dashed var(--danger); }
|
|
216
|
+
.cut-label { font-family: var(--mono); font-size: 11px; color: var(--danger); white-space: nowrap; }
|
|
217
|
+
|
|
210
218
|
/* ---------- health + trash ---------- */
|
|
211
219
|
|
|
212
220
|
.issue { border: 1px solid var(--border); border-left: 3px solid var(--warn); border-radius: 8px; background: var(--panel); padding: 10px 14px; margin-bottom: 8px; display: flex; gap: 12px; align-items: flex-start; }
|
|
@@ -400,3 +408,32 @@ code { font-family: var(--mono); font-size: .9em; }
|
|
|
400
408
|
@media (prefers-color-scheme: dark) {
|
|
401
409
|
.sidebar-foot img { filter: invert(1); }
|
|
402
410
|
}
|
|
411
|
+
|
|
412
|
+
/* --- what stopped loading --- */
|
|
413
|
+
.cut-summary { border: 1px solid var(--danger); border-radius: 10px; background: var(--danger-soft); padding: 14px 16px; margin-bottom: 18px; }
|
|
414
|
+
.cut-summary-head { font-weight: 650; font-size: 14px; color: var(--danger); margin-bottom: 4px; }
|
|
415
|
+
.cut-summary .muted { margin: 0 0 10px; }
|
|
416
|
+
.cut-summary .issue { background: var(--panel); border-left-color: var(--danger); }
|
|
417
|
+
|
|
418
|
+
/* --- provenance markers --- */
|
|
419
|
+
.off-marker { font-family: var(--mono); font-size: 9.5px; text-transform: uppercase; letter-spacing: .04em; color: var(--warn); background: var(--warn-soft); border-radius: 3px; padding: 1px 4px; }
|
|
420
|
+
.root-source { font-family: var(--mono); font-size: 9.5px; text-transform: uppercase; letter-spacing: .04em; color: var(--accent); background: var(--accent-soft); border-radius: 3px; padding: 1px 4px; margin-left: 6px; }
|
|
421
|
+
#project-sub span { display: block; }
|
|
422
|
+
.sub-note { color: var(--muted); font-size: 11.5px; margin-top: 2px; }
|
|
423
|
+
.sub-warn { color: var(--warn); font-size: 11.5px; margin-top: 2px; }
|
|
424
|
+
.link-btn { padding: 1px 8px; font-size: 11.5px; vertical-align: baseline; }
|
|
425
|
+
.path-input { width: 100%; font-family: var(--mono); font-size: 12.5px; padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel-2); color: var(--text); margin: 4px 0 12px; }
|
|
426
|
+
.path-input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
|
|
427
|
+
.sidebar-group { font-size: 10px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); padding: 14px 14px 5px; }
|
|
428
|
+
|
|
429
|
+
/* --- context tab --- */
|
|
430
|
+
.ctx-row { display: flex; gap: 14px; align-items: baseline; justify-content: space-between; padding: 6px 4px; border-bottom: 1px solid var(--border); }
|
|
431
|
+
.ctx-row:last-child { border-bottom: none; }
|
|
432
|
+
.ctx-main { min-width: 0; }
|
|
433
|
+
.ctx-tags { display: flex; gap: 5px; flex-wrap: wrap; margin-bottom: 3px; }
|
|
434
|
+
.ctx-file { font-family: var(--mono); font-size: 11.5px; overflow-wrap: anywhere; }
|
|
435
|
+
.ctx-size { font-family: var(--mono); font-size: 11px; color: var(--muted); white-space: nowrap; }
|
|
436
|
+
.badge.scope-managed { background: var(--danger-soft); color: var(--danger); }
|
|
437
|
+
.badge.scope-user { background: var(--accent-soft); color: var(--accent); }
|
|
438
|
+
.badge.scope-project { background: var(--warn-soft); color: var(--warn); }
|
|
439
|
+
.badge.scope-local { background: var(--panel-2); color: var(--muted); }
|
package/server.mjs
CHANGED
|
@@ -8,8 +8,11 @@ import path from 'node:path';
|
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
import { spawn } from 'node:child_process';
|
|
10
10
|
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
11
|
+
import { projectsRoot, resolveRoot } from './src/projects.mjs';
|
|
12
|
+
import { buildStore } from './src/model.mjs';
|
|
13
|
+
import { resolveInstructions } from './src/instructions.mjs';
|
|
14
|
+
import { listStores } from './src/stores.mjs';
|
|
15
|
+
import { forgetPath, rememberPath } from './src/pathcache.mjs';
|
|
13
16
|
import { searchAll } from './src/search.mjs';
|
|
14
17
|
import {
|
|
15
18
|
deleteIndexLine,
|
|
@@ -106,71 +109,109 @@ function readBody(req) {
|
|
|
106
109
|
});
|
|
107
110
|
}
|
|
108
111
|
|
|
109
|
-
/**
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
112
|
+
/**
|
|
113
|
+
* An id is only ever accepted if it names a store we actually discovered, which
|
|
114
|
+
* is what keeps a request from addressing a directory the app never found.
|
|
115
|
+
*/
|
|
116
|
+
function requireStore(id) {
|
|
117
|
+
const store = listStores(ROOT).find((candidate) => candidate.id === id);
|
|
118
|
+
if (!store) throw new Error(`Unknown store: ${id}`);
|
|
119
|
+
return store;
|
|
114
120
|
}
|
|
115
121
|
|
|
116
122
|
async function handleApi(req, res, url) {
|
|
117
123
|
const segments = url.pathname.split('/').filter(Boolean); // ['api', 'projects', ...]
|
|
118
124
|
|
|
119
|
-
if (segments.length === 2 && segments[1] === '
|
|
120
|
-
|
|
125
|
+
if (segments.length === 2 && segments[1] === 'stores' && req.method === 'GET') {
|
|
126
|
+
const origin = resolveRoot();
|
|
127
|
+
return sendJson(res, 200, {
|
|
128
|
+
root: ROOT,
|
|
129
|
+
// Which settings layer chose this store, so a non-default one is visible
|
|
130
|
+
// rather than something the user has to infer from unfamiliar contents.
|
|
131
|
+
rootSource: origin.path === ROOT ? origin.source : 'flag',
|
|
132
|
+
rootFile: origin.path === ROOT ? origin.file : null,
|
|
133
|
+
rootWarning: origin.invalid,
|
|
134
|
+
stores: listStores(ROOT),
|
|
135
|
+
});
|
|
121
136
|
}
|
|
122
137
|
|
|
123
138
|
if (segments.length === 2 && segments[1] === 'search' && req.method === 'GET') {
|
|
124
139
|
return sendJson(res, 200, searchAll(ROOT, url.searchParams.get('q') || ''));
|
|
125
140
|
}
|
|
126
141
|
|
|
127
|
-
if (segments[1] !== '
|
|
142
|
+
if (segments[1] !== 'stores' || segments.length < 3) {
|
|
128
143
|
return sendJson(res, 404, { error: 'Unknown endpoint' });
|
|
129
144
|
}
|
|
130
145
|
|
|
131
|
-
const
|
|
146
|
+
const store = requireStore(decodeURIComponent(segments[2]));
|
|
147
|
+
const dir = store.dir;
|
|
132
148
|
const action = segments.slice(3).join('/');
|
|
133
149
|
|
|
134
150
|
if (!action && req.method === 'GET') {
|
|
135
|
-
return sendJson(res, 200,
|
|
151
|
+
return sendJson(res, 200, buildStore(store));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// What a session starting in this store's project would load as instructions.
|
|
155
|
+
// Read-only, and scoped to a directory the app already discovered rather than
|
|
156
|
+
// one named in the request.
|
|
157
|
+
if (action === 'instructions' && req.method === 'GET') {
|
|
158
|
+
const projectDir = store.kind === 'auto'
|
|
159
|
+
? (store.pathExists ? store.path : null)
|
|
160
|
+
: store.projectPath;
|
|
161
|
+
if (!projectDir) return sendJson(res, 200, { projectDir: null, files: [], problems: [], excluded: [], totals: null });
|
|
162
|
+
return sendJson(res, 200, resolveInstructions(projectDir));
|
|
136
163
|
}
|
|
137
164
|
|
|
138
165
|
if (action === 'delete-preview' && req.method === 'POST') {
|
|
139
166
|
const body = await readBody(req);
|
|
140
|
-
return sendJson(res, 200, deletePreview(
|
|
167
|
+
return sendJson(res, 200, deletePreview(dir, body.file));
|
|
141
168
|
}
|
|
142
169
|
|
|
143
170
|
if (action === 'delete' && req.method === 'POST') {
|
|
144
171
|
const body = await readBody(req);
|
|
145
|
-
return sendJson(res, 200, deleteMemory(
|
|
172
|
+
return sendJson(res, 200, deleteMemory(dir, body.file, body.alsoDelete || []));
|
|
146
173
|
}
|
|
147
174
|
|
|
148
175
|
if (action === 'delete-many' && req.method === 'POST') {
|
|
149
176
|
const body = await readBody(req);
|
|
150
|
-
return sendJson(res, 200, deleteMemories(
|
|
177
|
+
return sendJson(res, 200, deleteMemories(dir, body.files || [], { label: body.label || null }));
|
|
151
178
|
}
|
|
152
179
|
|
|
153
180
|
if (action === 'project/delete-preview' && req.method === 'POST') {
|
|
154
|
-
return sendJson(res, 200, projectDeletePreview(
|
|
181
|
+
return sendJson(res, 200, projectDeletePreview(dir));
|
|
155
182
|
}
|
|
156
183
|
|
|
157
184
|
if (action === 'project/delete' && req.method === 'POST') {
|
|
158
|
-
return sendJson(res, 200, deleteProject(
|
|
185
|
+
return sendJson(res, 200, deleteProject(dir));
|
|
159
186
|
}
|
|
160
187
|
|
|
161
188
|
if (action === 'wikilink/remove' && req.method === 'POST') {
|
|
162
189
|
const body = await readBody(req);
|
|
163
|
-
return sendJson(res, 200, removeWikilink(
|
|
190
|
+
return sendJson(res, 200, removeWikilink(dir, body.file, body.target));
|
|
164
191
|
}
|
|
165
192
|
|
|
166
193
|
if (action === 'restore' && req.method === 'POST') {
|
|
167
194
|
const body = await readBody(req);
|
|
168
|
-
return sendJson(res, 200, restoreMemory(
|
|
195
|
+
return sendJson(res, 200, restoreMemory(dir, body.id));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// The only write outside a memory directory, and only ever on request: it
|
|
199
|
+
// records a path the user confirmed so the store keeps its name after Claude
|
|
200
|
+
// Code sweeps the transcripts that proved it.
|
|
201
|
+
if (action === 'path/remember' && req.method === 'POST') {
|
|
202
|
+
if (store.kind !== 'auto') throw new Error('Only project stores have a path to remember');
|
|
203
|
+
const body = await readBody(req);
|
|
204
|
+
return sendJson(res, 200, rememberPath(store.slug, body.path));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (action === 'path/forget' && req.method === 'POST') {
|
|
208
|
+
if (store.kind !== 'auto') throw new Error('Only project stores have a path to remember');
|
|
209
|
+
return sendJson(res, 200, forgetPath(store.slug));
|
|
169
210
|
}
|
|
170
211
|
|
|
171
212
|
if (action === 'index-line/delete' && req.method === 'POST') {
|
|
172
213
|
const body = await readBody(req);
|
|
173
|
-
return sendJson(res, 200, deleteIndexLine(
|
|
214
|
+
return sendJson(res, 200, deleteIndexLine(dir, body.lineIndex, body.expectedText));
|
|
174
215
|
}
|
|
175
216
|
|
|
176
217
|
return sendJson(res, 404, { error: 'Unknown endpoint' });
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
// What Claude Code actually loads as instructions when a session starts in a
|
|
2
|
+
// directory: the CLAUDE.md chain, the files those import, and `.claude/rules/`.
|
|
3
|
+
//
|
|
4
|
+
// This is the other half of the startup context. MEMORY.md has a hard limit and
|
|
5
|
+
// this side does not, but the two are spent from the same budget, and the rules
|
|
6
|
+
// that decide which files load are fiddly enough that people are routinely
|
|
7
|
+
// surprised. Everything here is READ ONLY.
|
|
8
|
+
//
|
|
9
|
+
// The resolution is a re-derivation of documented behaviour, not a report from
|
|
10
|
+
// Claude Code itself. Where a rule is subtle - imports inside code spans do not
|
|
11
|
+
// count, a `paths:` glob with an unbalanced bracket matches nothing - the
|
|
12
|
+
// subtlety is implemented rather than smoothed over, because smoothing over it
|
|
13
|
+
// would hide exactly the problem this is meant to surface.
|
|
14
|
+
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { parseFrontmatter } from './parse.mjs';
|
|
19
|
+
import { estimateTokens } from './stats.mjs';
|
|
20
|
+
import { lookup, settingsLayers } from './settings.mjs';
|
|
21
|
+
|
|
22
|
+
/** Claude Code's own guidance for a CLAUDE.md. Guidance, not a cutoff. */
|
|
23
|
+
export const CLAUDE_MD_TARGET_LINES = 200;
|
|
24
|
+
|
|
25
|
+
/** An import chain deeper than this is not followed. */
|
|
26
|
+
export const MAX_IMPORT_DEPTH = 4;
|
|
27
|
+
|
|
28
|
+
/** A rule's whole `paths:` list shares this budget of expanded patterns. */
|
|
29
|
+
export const BRACE_PATTERN_BUDGET = 1000;
|
|
30
|
+
|
|
31
|
+
function managedClaudeMd() {
|
|
32
|
+
if (process.platform === 'darwin') return '/Library/Application Support/ClaudeCode/CLAUDE.md';
|
|
33
|
+
if (process.platform === 'win32') return 'C:\\Program Files\\ClaudeCode\\CLAUDE.md';
|
|
34
|
+
return '/etc/claude-code/CLAUDE.md';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readIfFile(file) {
|
|
38
|
+
try {
|
|
39
|
+
if (!fs.statSync(file).isFile()) return null;
|
|
40
|
+
return fs.readFileSync(file, 'utf8');
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/* ------------------------------------------------------------------ imports */
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Blank out fenced code blocks and code spans, keeping the byte length so every
|
|
50
|
+
* offset still lines up with the original.
|
|
51
|
+
*
|
|
52
|
+
* Import parsing skips both, which is the documented way to write `@README` in a
|
|
53
|
+
* CLAUDE.md without importing it. Scanning the raw text instead would report
|
|
54
|
+
* imports that never happen, and the file being wrong about its own examples is
|
|
55
|
+
* worse than not checking.
|
|
56
|
+
*/
|
|
57
|
+
export function maskCode(text) {
|
|
58
|
+
const blank = (match) => match.replace(/[^\n]/g, ' ');
|
|
59
|
+
return text
|
|
60
|
+
.replace(/^[ \t]*(```|~~~)[\s\S]*?^[ \t]*\1[ \t]*$/gm, blank)
|
|
61
|
+
.replace(/`+[^`\n]*`+/g, blank);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// A bare @ that is part of an email address or an npm scope is not an import,
|
|
65
|
+
// so the character before it has to be a boundary.
|
|
66
|
+
const IMPORT = /(^|[\s(<'"])@((?:~\/|\.{0,2}\/)?[A-Za-z0-9._~\-][A-Za-z0-9._~\-/]*)/g;
|
|
67
|
+
|
|
68
|
+
/** Every `@path` import in a file's text, with the offset it was found at. */
|
|
69
|
+
export function findImports(text) {
|
|
70
|
+
const masked = maskCode(text);
|
|
71
|
+
const found = [];
|
|
72
|
+
for (const match of masked.matchAll(IMPORT)) {
|
|
73
|
+
const end = match.index + match[0].length;
|
|
74
|
+
// "@team@example.com" opens with something that looks exactly like an
|
|
75
|
+
// import until the second @ arrives.
|
|
76
|
+
if (masked[end] === '@') continue;
|
|
77
|
+
found.push({ spec: match[2], index: match.index + match[1].length });
|
|
78
|
+
}
|
|
79
|
+
return found;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function resolveImport(spec, fromFile) {
|
|
83
|
+
if (spec.startsWith('~/')) return path.join(os.homedir(), spec.slice(2));
|
|
84
|
+
if (path.isAbsolute(spec)) return spec;
|
|
85
|
+
// Relative to the file holding the import, not to the working directory.
|
|
86
|
+
return path.resolve(path.dirname(fromFile), spec);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Follow a file's imports, breadth-first, to the documented maximum depth.
|
|
91
|
+
*
|
|
92
|
+
* Returns every file reached and every import that did not work out. A cycle is
|
|
93
|
+
* reported once and not followed, because a file importing itself back is a
|
|
94
|
+
* mistake worth naming rather than a loop worth running.
|
|
95
|
+
*/
|
|
96
|
+
export function expandImports(rootFile, rootText, { projectDir = null } = {}) {
|
|
97
|
+
const files = [];
|
|
98
|
+
const problems = [];
|
|
99
|
+
const seen = new Set([rootFile]);
|
|
100
|
+
let frontier = [{ file: rootFile, text: rootText, depth: 0 }];
|
|
101
|
+
|
|
102
|
+
while (frontier.length) {
|
|
103
|
+
const next = [];
|
|
104
|
+
for (const current of frontier) {
|
|
105
|
+
for (const { spec } of findImports(current.text)) {
|
|
106
|
+
const resolved = resolveImport(spec, current.file);
|
|
107
|
+
|
|
108
|
+
if (current.depth + 1 > MAX_IMPORT_DEPTH) {
|
|
109
|
+
problems.push({ kind: 'too-deep', spec, from: current.file, file: resolved });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (seen.has(resolved)) {
|
|
113
|
+
problems.push({ kind: 'cycle', spec, from: current.file, file: resolved });
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const text = readIfFile(resolved);
|
|
118
|
+
if (text === null) {
|
|
119
|
+
problems.push({ kind: 'missing', spec, from: current.file, file: resolved });
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
seen.add(resolved);
|
|
124
|
+
// An import resolving outside the project is the kind Claude Code asks
|
|
125
|
+
// you to approve once; a declined one then stays silently disabled.
|
|
126
|
+
const external = projectDir ? path.relative(projectDir, resolved).startsWith('..') : false;
|
|
127
|
+
const entry = { file: resolved, text, depth: current.depth + 1, importedBy: current.file, external };
|
|
128
|
+
files.push(entry);
|
|
129
|
+
if (external) problems.push({ kind: 'external', spec, from: current.file, file: resolved });
|
|
130
|
+
next.push(entry);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
frontier = next;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { files, problems };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/* -------------------------------------------------------------------- globs */
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Check a `paths:` glob the way it will actually behave.
|
|
143
|
+
*
|
|
144
|
+
* Two documented failure modes make a rule silently inert, and both look
|
|
145
|
+
* completely normal in the file:
|
|
146
|
+
* - a `[` that cannot be read as a bracket expression makes the pattern match
|
|
147
|
+
* nothing at all;
|
|
148
|
+
* - brace expansion past the budget makes the pattern get used unexpanded, so
|
|
149
|
+
* its literal braces match no file either.
|
|
150
|
+
*/
|
|
151
|
+
export function checkGlob(pattern) {
|
|
152
|
+
let expansions = 1;
|
|
153
|
+
for (const group of pattern.matchAll(/\{([^{}]*)\}/g)) {
|
|
154
|
+
expansions *= group[1].split(',').length;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Walk the pattern looking for a bracket expression that never closes.
|
|
158
|
+
let open = -1;
|
|
159
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
160
|
+
if (pattern[i] === '\\') { i += 1; continue; }
|
|
161
|
+
if (pattern[i] === '[' && open === -1) open = i;
|
|
162
|
+
else if (pattern[i] === ']' && open !== -1 && i > open + 1) open = -1;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (open !== -1) {
|
|
166
|
+
return { pattern, expansions, valid: false, reason: 'unclosed [ - glob syntax reads it as a bracket expression, so the pattern matches nothing' };
|
|
167
|
+
}
|
|
168
|
+
return { pattern, expansions, valid: true, reason: null };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Whether a rule's whole `paths:` list fits the shared expansion budget. */
|
|
172
|
+
export function checkGlobList(patterns) {
|
|
173
|
+
const checks = patterns.map(checkGlob);
|
|
174
|
+
// Patterns without braces do not count against the budget.
|
|
175
|
+
const total = checks.reduce((sum, check) => sum + (check.expansions > 1 ? check.expansions : 0), 0);
|
|
176
|
+
return {
|
|
177
|
+
checks,
|
|
178
|
+
expansions: total,
|
|
179
|
+
overBudget: total > BRACE_PATTERN_BUDGET,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* -------------------------------------------------------------------- rules */
|
|
184
|
+
|
|
185
|
+
function listRuleFiles(dir, { depth = 8, seenReal = new Set() } = {}) {
|
|
186
|
+
let real;
|
|
187
|
+
try {
|
|
188
|
+
real = fs.realpathSync(dir);
|
|
189
|
+
} catch {
|
|
190
|
+
return [];
|
|
191
|
+
}
|
|
192
|
+
// Symlinked rule directories are supported, and circular ones must not hang.
|
|
193
|
+
if (seenReal.has(real) || depth < 0) return [];
|
|
194
|
+
seenReal.add(real);
|
|
195
|
+
|
|
196
|
+
let entries;
|
|
197
|
+
try {
|
|
198
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
199
|
+
} catch {
|
|
200
|
+
return [];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const out = [];
|
|
204
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
205
|
+
const full = path.join(dir, entry.name);
|
|
206
|
+
let stat;
|
|
207
|
+
try {
|
|
208
|
+
stat = fs.statSync(full); // follows symlinks, which is the point
|
|
209
|
+
} catch {
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (stat.isDirectory()) out.push(...listRuleFiles(full, { depth: depth - 1, seenReal }));
|
|
213
|
+
else if (entry.name.endsWith('.md')) out.push(full);
|
|
214
|
+
}
|
|
215
|
+
return out;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function unquote(value) {
|
|
219
|
+
const trimmed = value.trim();
|
|
220
|
+
const first = trimmed[0];
|
|
221
|
+
const last = trimmed[trimmed.length - 1];
|
|
222
|
+
if (trimmed.length >= 2 && ((first === '"' && last === '"') || (first === "'" && last === "'"))) {
|
|
223
|
+
return trimmed.slice(1, -1);
|
|
224
|
+
}
|
|
225
|
+
return trimmed;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The `paths:` patterns of a rule, read straight from the frontmatter block.
|
|
230
|
+
*
|
|
231
|
+
* The frontmatter reader shared with memory files models scalars and one level
|
|
232
|
+
* of nesting, which is everything memory frontmatter uses and not a YAML list.
|
|
233
|
+
* Rather than widen a parser the whole app depends on, the one construct rules
|
|
234
|
+
* need is read here.
|
|
235
|
+
*/
|
|
236
|
+
export function readPathsList(frontmatterRaw) {
|
|
237
|
+
const patterns = [];
|
|
238
|
+
let inPaths = false;
|
|
239
|
+
|
|
240
|
+
for (const line of frontmatterRaw.split('\n')) {
|
|
241
|
+
const key = line.match(/^([A-Za-z0-9_.\-]+):[ \t]*(.*)$/);
|
|
242
|
+
if (key) {
|
|
243
|
+
inPaths = key[1] === 'paths';
|
|
244
|
+
if (!inPaths) continue;
|
|
245
|
+
const value = key[2].trim();
|
|
246
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
247
|
+
for (const part of value.slice(1, -1).split(',')) {
|
|
248
|
+
const pattern = unquote(part);
|
|
249
|
+
if (pattern) patterns.push(pattern);
|
|
250
|
+
}
|
|
251
|
+
inPaths = false;
|
|
252
|
+
} else if (value) {
|
|
253
|
+
patterns.push(unquote(value));
|
|
254
|
+
inPaths = false;
|
|
255
|
+
}
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (!inPaths) continue;
|
|
259
|
+
const item = line.match(/^[ \t]*-[ \t]+(.*)$/);
|
|
260
|
+
if (item) {
|
|
261
|
+
const pattern = unquote(item[1]);
|
|
262
|
+
if (pattern) patterns.push(pattern);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return patterns;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function ruleEntry(file, scope) {
|
|
269
|
+
const text = readIfFile(file);
|
|
270
|
+
if (text === null) return null;
|
|
271
|
+
const { raw } = parseFrontmatter(text);
|
|
272
|
+
const patterns = readPathsList(raw);
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
file,
|
|
276
|
+
scope,
|
|
277
|
+
kind: 'rule',
|
|
278
|
+
text,
|
|
279
|
+
conditional: patterns.length > 0,
|
|
280
|
+
globs: patterns.length ? checkGlobList(patterns) : null,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/* ------------------------------------------------------------------ loading */
|
|
285
|
+
|
|
286
|
+
function entry(file, scope, kind, { conditional = false } = {}) {
|
|
287
|
+
const text = readIfFile(file);
|
|
288
|
+
return text === null ? null : { file, scope, kind, text, conditional, globs: null };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Directories from the filesystem root down to `dir`, in load order. */
|
|
292
|
+
function ancestors(dir) {
|
|
293
|
+
const parts = path.resolve(dir).split(path.sep);
|
|
294
|
+
const out = [];
|
|
295
|
+
for (let i = 1; i < parts.length; i++) {
|
|
296
|
+
out.push(parts.slice(0, i + 1).join(path.sep) || path.sep);
|
|
297
|
+
}
|
|
298
|
+
return out;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function globToRegExp(pattern) {
|
|
302
|
+
// Only used for claudeMdExcludes, which matches absolute paths.
|
|
303
|
+
let out = '';
|
|
304
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
305
|
+
const char = pattern[i];
|
|
306
|
+
if (char === '*') {
|
|
307
|
+
if (pattern[i + 1] === '*') { out += '.*'; i += 1; if (pattern[i + 1] === '/') i += 1; }
|
|
308
|
+
else out += '[^/]*';
|
|
309
|
+
} else if (char === '?') out += '[^/]';
|
|
310
|
+
else out += char.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
311
|
+
}
|
|
312
|
+
return new RegExp(`^${out}$`);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Everything that would load for a session started in `projectDir`, in the order
|
|
317
|
+
* Claude Code loads it: broadest scope first, so the most specific instruction is
|
|
318
|
+
* the last thing read.
|
|
319
|
+
*/
|
|
320
|
+
export function resolveInstructions(projectDir) {
|
|
321
|
+
const home = os.homedir();
|
|
322
|
+
const layers = settingsLayers({ projectDir });
|
|
323
|
+
const excludeFound = lookup(layers, 'claudeMdExcludes');
|
|
324
|
+
const excludes = Array.isArray(excludeFound?.value) ? excludeFound.value.map(String) : [];
|
|
325
|
+
const excluded = [];
|
|
326
|
+
|
|
327
|
+
const candidates = [];
|
|
328
|
+
candidates.push(entry(managedClaudeMd(), 'managed', 'claude-md'));
|
|
329
|
+
|
|
330
|
+
const managedInline = lookup(layers, 'claudeMd');
|
|
331
|
+
const inline = typeof managedInline?.value === 'string' && managedInline.value.trim()
|
|
332
|
+
&& (managedInline.scope === 'managed')
|
|
333
|
+
? { file: managedInline.file, scope: 'managed', kind: 'managed-settings', text: managedInline.value, conditional: false, globs: null }
|
|
334
|
+
: null;
|
|
335
|
+
if (inline) candidates.push(inline);
|
|
336
|
+
|
|
337
|
+
candidates.push(entry(path.join(home, '.claude', 'CLAUDE.md'), 'user', 'claude-md'));
|
|
338
|
+
for (const file of listRuleFiles(path.join(home, '.claude', 'rules'))) {
|
|
339
|
+
candidates.push(ruleEntry(file, 'user'));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Root-to-cwd, CLAUDE.md then CLAUDE.local.md at each level.
|
|
343
|
+
for (const dir of ancestors(projectDir)) {
|
|
344
|
+
candidates.push(entry(path.join(dir, 'CLAUDE.md'), 'project', 'claude-md'));
|
|
345
|
+
candidates.push(entry(path.join(dir, 'CLAUDE.local.md'), 'local', 'claude-md'));
|
|
346
|
+
}
|
|
347
|
+
candidates.push(entry(path.join(projectDir, '.claude', 'CLAUDE.md'), 'project', 'claude-md'));
|
|
348
|
+
for (const file of listRuleFiles(path.join(projectDir, '.claude', 'rules'))) {
|
|
349
|
+
candidates.push(ruleEntry(file, 'project'));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const matchers = excludes.map(globToRegExp);
|
|
353
|
+
const loaded = [];
|
|
354
|
+
const problems = [];
|
|
355
|
+
|
|
356
|
+
for (const candidate of candidates.filter(Boolean)) {
|
|
357
|
+
// Managed policy cannot be excluded; that is the point of managed policy.
|
|
358
|
+
if (candidate.scope !== 'managed' && matchers.some((re) => re.test(candidate.file))) {
|
|
359
|
+
excluded.push(candidate.file);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
loaded.push(candidate);
|
|
363
|
+
|
|
364
|
+
if (candidate.kind === 'managed-settings') continue;
|
|
365
|
+
const expanded = expandImports(candidate.file, candidate.text, { projectDir });
|
|
366
|
+
problems.push(...expanded.problems.map((p) => ({ ...p, scope: candidate.scope })));
|
|
367
|
+
for (const imported of expanded.files) {
|
|
368
|
+
loaded.push({
|
|
369
|
+
file: imported.file,
|
|
370
|
+
scope: candidate.scope,
|
|
371
|
+
kind: 'import',
|
|
372
|
+
text: imported.text,
|
|
373
|
+
conditional: candidate.conditional,
|
|
374
|
+
globs: null,
|
|
375
|
+
importedBy: imported.importedBy,
|
|
376
|
+
depth: imported.depth,
|
|
377
|
+
external: imported.external,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// AGENTS.md is read by other tools, not by Claude Code, so one sitting next to
|
|
383
|
+
// an unrelated CLAUDE.md is a real and easy-to-miss divergence.
|
|
384
|
+
const agentsFile = path.join(projectDir, 'AGENTS.md');
|
|
385
|
+
if (fs.existsSync(agentsFile) && !loaded.some((item) => item.file === agentsFile)) {
|
|
386
|
+
problems.push({ kind: 'agents-md-not-imported', file: agentsFile, scope: 'project' });
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
for (const item of loaded) {
|
|
390
|
+
item.lines = item.text.split('\n').length;
|
|
391
|
+
item.bytes = Buffer.byteLength(item.text, 'utf8');
|
|
392
|
+
item.tokens = estimateTokens(item.text);
|
|
393
|
+
if (item.globs && !item.globs.checks.every((c) => c.valid)) {
|
|
394
|
+
for (const check of item.globs.checks.filter((c) => !c.valid)) {
|
|
395
|
+
problems.push({ kind: 'invalid-glob', file: item.file, scope: item.scope, pattern: check.pattern, reason: check.reason });
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (item.globs?.overBudget) {
|
|
399
|
+
problems.push({ kind: 'glob-budget', file: item.file, scope: item.scope, expansions: item.globs.expansions });
|
|
400
|
+
}
|
|
401
|
+
if (item.kind === 'claude-md' && item.lines > CLAUDE_MD_TARGET_LINES) {
|
|
402
|
+
problems.push({ kind: 'long-claude-md', file: item.file, scope: item.scope, lines: item.lines });
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const unconditional = loaded.filter((item) => !item.conditional);
|
|
407
|
+
return {
|
|
408
|
+
projectDir,
|
|
409
|
+
files: loaded,
|
|
410
|
+
excluded,
|
|
411
|
+
problems,
|
|
412
|
+
totals: {
|
|
413
|
+
files: loaded.length,
|
|
414
|
+
// Conditional rules only load when Claude reads a matching file, so they
|
|
415
|
+
// are counted apart from what every session pays for.
|
|
416
|
+
alwaysLines: unconditional.reduce((sum, item) => sum + item.lines, 0),
|
|
417
|
+
alwaysBytes: unconditional.reduce((sum, item) => sum + item.bytes, 0),
|
|
418
|
+
alwaysTokens: unconditional.reduce((sum, item) => sum + item.tokens, 0),
|
|
419
|
+
conditionalFiles: loaded.length - unconditional.length,
|
|
420
|
+
conditionalTokens: loaded.filter((i) => i.conditional).reduce((sum, item) => sum + item.tokens, 0),
|
|
421
|
+
},
|
|
422
|
+
};
|
|
423
|
+
}
|