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/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 { listProjects, projectsRoot } from './src/projects.mjs';
12
- import { buildProject } from './src/model.mjs';
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
- /** A slug is only ever accepted if it names a directory we actually discovered. */
110
- function requireSlug(slug) {
111
- const known = listProjects(ROOT).some((p) => p.slug === slug);
112
- if (!known) throw new Error(`Unknown project: ${slug}`);
113
- return slug;
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] === 'projects' && req.method === 'GET') {
120
- return sendJson(res, 200, { root: ROOT, projects: listProjects(ROOT) });
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] !== 'projects' || segments.length < 3) {
142
+ if (segments[1] !== 'stores' || segments.length < 3) {
128
143
  return sendJson(res, 404, { error: 'Unknown endpoint' });
129
144
  }
130
145
 
131
- const slug = requireSlug(decodeURIComponent(segments[2]));
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, buildProject(ROOT, slug));
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(ROOT, slug, body.file));
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(ROOT, slug, body.file, body.alsoDelete || []));
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(ROOT, slug, body.files || [], { label: body.label || null }));
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(ROOT, slug));
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(ROOT, slug));
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(ROOT, slug, body.file, body.target));
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(ROOT, slug, body.id));
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(ROOT, slug, body.lineIndex, body.expectedText));
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
+ }