claude-memory-admin 1.10.0 → 1.10.2

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/src/agents.mjs CHANGED
@@ -1,9 +1,12 @@
1
- // User-scope subagent definitions: ~/.claude/agents/*.md.
1
+ // Subagent definitions: agents/*.md, in the user scope and in each repository.
2
2
  //
3
- // Not to be confused with ~/.claude/agent-memory (src/stores.mjs), which is what
4
- // those agents *remember*. This module is about what they *are*: a markdown file
5
- // whose frontmatter names the agent and, optionally, pins the model and effort
6
- // it runs at. Pinning a summariser to Haiku while a reviewer stays on Opus is the
3
+ // Not to be confused with agent-memory (src/stores.mjs), which is what those
4
+ // agents *remember*. This module is about what they *are*: a markdown file whose
5
+ // frontmatter names the agent and, optionally, pins the model and effort it runs
6
+ // at, and declares with `memory:` whether it keeps a memory directory at all.
7
+ // That last field is the link between the two: a store exists because some file
8
+ // here asked for it, and a store no file asks for any more is a store nothing
9
+ // loads. Pinning a summariser to Haiku while a reviewer stays on Opus is the
7
10
  // finer-grained version of the CLAUDE_CODE_SUBAGENT_MODEL switch in src/cost.mjs,
8
11
  // which - worth remembering when both are set - outranks everything here.
9
12
  //
@@ -12,13 +15,21 @@
12
15
  // somebody wrote on purpose, and this tool has no view on it.
13
16
 
14
17
  import fs from 'node:fs';
15
- import os from 'node:os';
16
18
  import path from 'node:path';
17
19
 
20
+ import { canonicalPath, configPath } from './config.mjs';
18
21
  import { parseFrontmatter } from './parse.mjs';
19
22
  import { writeFileAtomic } from './mutate.mjs';
20
23
 
21
- export const AGENTS_DIR = path.join(os.homedir(), '.claude', 'agents');
24
+ export const AGENTS_DIR = configPath('agents');
25
+ export const PROJECT_AGENTS_DIR = path.join('.claude', 'agents');
26
+
27
+ /** The scopes `memory:` accepts, and the store kind each one produces. */
28
+ export const MEMORY_SCOPES = {
29
+ user: 'agent-user',
30
+ project: 'agent-project',
31
+ local: 'agent-local',
32
+ };
22
33
 
23
34
  const MODEL_ID = /^claude-[A-Za-z0-9._[\]-]+$/;
24
35
 
@@ -55,6 +66,7 @@ export const AGENT_PROBLEM_SEVERITY = {
55
66
  'name-mismatch': 'warn',
56
67
  'unknown-model': 'warn',
57
68
  'unknown-effort': 'warn',
69
+ 'unknown-memory-scope': 'warn',
58
70
  };
59
71
 
60
72
  function known(field, value) {
@@ -92,9 +104,9 @@ export function safeAgentPath(dir, file) {
92
104
  return full;
93
105
  }
94
106
 
95
- function describeAgent(dir, file) {
107
+ function describeAgent(dir, file, { scope = 'user', writable = true, projectPath = null } = {}) {
96
108
  const full = path.join(dir, file);
97
- const stem = file.replace(/\.md$/, '');
109
+ const stem = path.basename(file).replace(/\.md$/, '');
98
110
 
99
111
  let text;
100
112
  try {
@@ -102,11 +114,15 @@ function describeAgent(dir, file) {
102
114
  } catch (err) {
103
115
  return {
104
116
  file,
117
+ scope,
118
+ writable: false,
119
+ projectPath,
105
120
  name: stem,
106
121
  description: '',
107
122
  tools: '',
108
123
  model: null,
109
124
  effort: null,
125
+ memory: null,
110
126
  bytes: 0,
111
127
  problems: [{ kind: 'no-frontmatter', severity: 'bad', detail: err.message }],
112
128
  };
@@ -117,6 +133,7 @@ function describeAgent(dir, file) {
117
133
  const name = scalar('name') || stem;
118
134
  const model = scalar('model') || null;
119
135
  const effort = scalar('effort') || null;
136
+ const memory = scalar('memory') || null;
120
137
 
121
138
  const problems = [];
122
139
  const flag = (kind, detail) => problems.push({ kind, severity: AGENT_PROBLEM_SEVERITY[kind], detail });
@@ -129,26 +146,39 @@ function describeAgent(dir, file) {
129
146
  if (scalar('name') && scalar('name') !== stem) flag('name-mismatch', `Named "${scalar('name')}" in a file called "${file}".`);
130
147
  if (model && !known('model', model)) flag('unknown-model', `model: ${model} is neither an alias nor a claude- model name.`);
131
148
  if (effort && !known('effort', effort)) flag('unknown-effort', `effort: ${effort} is not one of low, medium, high, xhigh or max.`);
149
+ if (memory && !Object.prototype.hasOwnProperty.call(MEMORY_SCOPES, memory)) {
150
+ flag('unknown-memory-scope', `memory: ${memory} is not one of user, project or local, so this agent keeps no memory directory.`);
151
+ }
132
152
  }
133
153
 
134
154
  return {
135
155
  file,
156
+ scope,
157
+ // Only a plain .md at the top of the user directory is rewritable: writes go
158
+ // through safeAgentPath, which takes a bare basename, and nothing here has
159
+ // any business editing a file inside somebody's repository.
160
+ writable: writable && file === path.basename(file),
161
+ projectPath,
136
162
  name,
137
163
  description: scalar('description'),
138
164
  tools: scalar('tools'),
139
165
  model,
140
166
  effort,
167
+ memory: memory && Object.prototype.hasOwnProperty.call(MEMORY_SCOPES, memory) ? memory : null,
168
+ memoryRaw: memory,
141
169
  bytes: Buffer.byteLength(text, 'utf8'),
142
170
  problems,
143
171
  };
144
172
  }
145
173
 
146
174
  /**
147
- * Every user-scope agent definition. A directory that is not there is the
148
- * ordinary state of a machine whose owner has never written one, not a failure,
149
- * so it answers with an empty list.
175
+ * Every .md under an agents directory, including the subfolders people use to
176
+ * group them. Claude Code scans both agent roots recursively and identifies an
177
+ * agent by its `name` rather than by where the file sits, so a scan that stopped
178
+ * at the top level would miss agents that are running.
150
179
  */
151
- export function listAgents({ dir = AGENTS_DIR } = {}) {
180
+ function agentFiles(dir, prefix = '', depth = 0) {
181
+ if (depth > 4) return [];
152
182
  let entries;
153
183
  try {
154
184
  entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -156,12 +186,54 @@ export function listAgents({ dir = AGENTS_DIR } = {}) {
156
186
  return [];
157
187
  }
158
188
 
159
- return entries
160
- .filter((entry) => entry.isFile() && entry.name.endsWith('.md') && !entry.name.startsWith('.'))
161
- .map((entry) => describeAgent(dir, entry.name))
189
+ const files = [];
190
+ for (const entry of entries) {
191
+ if (entry.name.startsWith('.')) continue;
192
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
193
+ if (entry.isDirectory()) files.push(...agentFiles(path.join(dir, entry.name), rel, depth + 1));
194
+ else if (entry.isFile() && entry.name.endsWith('.md')) files.push(rel);
195
+ }
196
+ return files;
197
+ }
198
+
199
+ /**
200
+ * Every user-scope agent definition. A directory that is not there is the
201
+ * ordinary state of a machine whose owner has never written one, not a failure,
202
+ * so it answers with an empty list.
203
+ */
204
+ export function listAgents({ dir = AGENTS_DIR } = {}) {
205
+ return agentFiles(dir)
206
+ .map((file) => describeAgent(dir, file, { scope: 'user', writable: true }))
207
+ .sort((a, b) => a.name.localeCompare(b.name));
208
+ }
209
+
210
+ /**
211
+ * Project-scope definitions, from <repo>/.claude/agents. These are read and
212
+ * never written: they belong to a repository somebody else may share, and an
213
+ * agent with `memory: project` is normally defined here rather than in the user
214
+ * scope, so leaving them out would orphan the very stores this exists to explain.
215
+ */
216
+ export function listProjectAgents(projectPath, { relative = PROJECT_AGENTS_DIR } = {}) {
217
+ if (!projectPath || !path.isAbsolute(projectPath)) return [];
218
+ const dir = path.join(projectPath, relative);
219
+ return agentFiles(dir)
220
+ .map((file) => describeAgent(dir, file, { scope: 'project', writable: false, projectPath }))
162
221
  .sort((a, b) => a.name.localeCompare(b.name));
163
222
  }
164
223
 
224
+ /** Every definition on the machine this tool can see: user scope plus each repository. */
225
+ export function listAllAgents({ dir = AGENTS_DIR, projectPaths = [] } = {}) {
226
+ const agents = listAgents({ dir });
227
+ const seen = new Set();
228
+ for (const projectPath of projectPaths) {
229
+ const key = canonicalPath(projectPath || '');
230
+ if (!key || seen.has(key)) continue;
231
+ seen.add(key);
232
+ agents.push(...listProjectAgents(projectPath));
233
+ }
234
+ return agents;
235
+ }
236
+
165
237
  /** Whether the directory itself exists, which is what tells an empty list from a missing one. */
166
238
  export function agentsDirExists({ dir = AGENTS_DIR } = {}) {
167
239
  try {
@@ -205,7 +277,11 @@ function insertionIndex(block) {
205
277
  * that changed, never re-emit the document from a parse of it.
206
278
  */
207
279
  export function rewriteAgentField(text, field, value) {
208
- const lines = text.split('\n');
280
+ // Rejoin with whatever the file already used. A checkout on Windows is CRLF
281
+ // throughout, and splitting on \n then rejoining with it would leave every
282
+ // line this function did not touch ending \r\n and the one it did ending \n.
283
+ const eol = /\r\n/.test(text) ? '\r\n' : '\n';
284
+ const lines = text.split(/\r?\n/);
209
285
  if (lines[0]?.trim() !== '---') {
210
286
  throw new Error('This file has no frontmatter block, so there is nothing to set.');
211
287
  }
@@ -230,7 +306,7 @@ export function rewriteAgentField(text, field, value) {
230
306
  block[at] = `${field}: ${value}`;
231
307
  }
232
308
 
233
- return [lines[0], ...block, ...lines.slice(end)].join('\n');
309
+ return [lines[0], ...block, ...lines.slice(end)].join(eol);
234
310
  }
235
311
 
236
312
  function normaliseAgentValue(field, value) {
package/src/checks.mjs CHANGED
@@ -2,6 +2,13 @@ import path from 'node:path';
2
2
  import { parseFrontmatter } from './parse.mjs';
3
3
 
4
4
  export const VALID_TYPES = ['user', 'feedback', 'project', 'reference'];
5
+
6
+ /** The scope each subagent store kind stands for, for the messages below. */
7
+ const STORE_SCOPES = {
8
+ 'agent-user': 'user',
9
+ 'agent-project': 'project',
10
+ 'agent-local': 'local',
11
+ };
5
12
  export const EMPTY_BODY_CHARS = 40;
6
13
  export const EMPTY_INSTRUCTION_CHARS = 10;
7
14
  export const HOOK_ECHO_OVERLAP = 0.9;
@@ -218,6 +225,68 @@ export function checkNoMemoryDespiteSessions(store, memories, retention) {
218
225
  }];
219
226
  }
220
227
 
228
+ /**
229
+ * A subagent store nothing declares any more.
230
+ *
231
+ * The directory only exists because some agent file once carried a `memory:`
232
+ * field naming this scope. Rename the agent, move it to another scope or drop
233
+ * the field and the directory stays exactly where it is, still holding whatever
234
+ * it learned, and no session will ever read it again. That is invisible on disk,
235
+ * which is the whole reason to say it here.
236
+ */
237
+ export function checkAgentStoreOrphan(store) {
238
+ if (!store || !store.linkage || store.linked) return [];
239
+ if (store.declaredScope) return [];
240
+
241
+ return [{
242
+ kind: 'agent-store-orphan',
243
+ severity: 'warn',
244
+ agentName: store.agentName,
245
+ scope: STORE_SCOPES[store.kind] || null,
246
+ defined: Boolean(store.defined),
247
+ }];
248
+ }
249
+
250
+ /** The agent still exists, but its `memory:` names a different scope than this store. */
251
+ export function checkAgentStoreScopeMismatch(store) {
252
+ if (!store || !store.linkage || store.linked) return [];
253
+ if (!store.declaredScope) return [];
254
+
255
+ return [{
256
+ kind: 'agent-store-scope-mismatch',
257
+ severity: 'warn',
258
+ agentName: store.agentName,
259
+ scope: STORE_SCOPES[store.kind] || null,
260
+ declaredScope: store.declaredScope,
261
+ declaredBy: store.declaredBy,
262
+ }];
263
+ }
264
+
265
+ /**
266
+ * Subagent memory is part of auto memory, so turning auto memory off turns this
267
+ * store off with it: the `memory:` field stops having any effect, the agent
268
+ * launches with no memory instructions and no file tools, and the directory can
269
+ * neither be read nor grow. Nothing about the files themselves shows that.
270
+ */
271
+ export function checkAgentMemoryInert(store) {
272
+ if (!store || !store.linkage || !store.inert) return [];
273
+
274
+ return [{
275
+ kind: 'agent-memory-inert',
276
+ severity: 'warn',
277
+ agentName: store.agentName,
278
+ setBy: store.inertBy || null,
279
+ }];
280
+ }
281
+
282
+ export function agentStoreChecks(store) {
283
+ return [
284
+ ...checkAgentMemoryInert(store),
285
+ ...checkAgentStoreOrphan(store),
286
+ ...checkAgentStoreScopeMismatch(store),
287
+ ];
288
+ }
289
+
221
290
  export function sessionChecks(store, memories, retention, { remembered = false, resolveOrigin = null } = {}) {
222
291
  if (!retention) return [];
223
292
  return [
package/src/config.mjs ADDED
@@ -0,0 +1,130 @@
1
+ // Where Claude Code keeps its files, and the path primitives that answer it the
2
+ // same way on all three platforms.
3
+ //
4
+ // Everything under ~/.claude - the projects root, the agent definitions, the
5
+ // agent memory directories, the user settings file, the output styles - moves as
6
+ // one when CLAUDE_CONFIG_DIR is set. Hardcoding the home-relative path in each
7
+ // module meant a machine with a custom config dir opened this tool to an empty
8
+ // list rather than to its memory, so all of them resolve through configDir()
9
+ // instead.
10
+ //
11
+ // The platform is a parameter on every function here rather than a read of
12
+ // process.platform inside it. Only one of the three platforms is ever available
13
+ // to develop on, and behaviour that cannot be exercised from a test is behaviour
14
+ // nobody checks: the defaults keep the call sites unchanged and the parameter is
15
+ // what lets test/config.test.mjs assert the Windows rules from anywhere.
16
+
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+
20
+ /** The separator rules of the named platform, rather than of the host. */
21
+ const pathFor = (platform) => (platform === 'win32' ? path.win32 : path.posix);
22
+
23
+ /**
24
+ * Expand a leading `~/`. Claude Code documents that form for autoMemoryDirectory
25
+ * and it is what people type on every platform, Windows included, so it is
26
+ * handled before any separator rule is applied.
27
+ *
28
+ * The join goes through pathFor rather than through path directly. A caller that
29
+ * names a platform is naming its separator too, and the native join would answer
30
+ * from the host instead: a POSIX home joined under win32 comes back rooted at no
31
+ * drive, which isAbsolutePath then rejects for a reason the caller never asked
32
+ * for.
33
+ */
34
+ export function expandHome(value, { home = os.homedir(), platform = process.platform } = {}) {
35
+ if (typeof value !== 'string') return value;
36
+ if (value === '~') return home;
37
+ if (value.startsWith('~/') || value.startsWith('~\\')) return pathFor(platform).join(home, value.slice(2));
38
+ return value;
39
+ }
40
+
41
+ /**
42
+ * Whether a path is absolute in the sense Claude Code requires: rooted, and on
43
+ * Windows rooted at a drive or a UNC share.
44
+ *
45
+ * path.isAbsolute is not enough on its own. Under win32 it answers true for
46
+ * "/foo", which has no drive and so names nothing that can be opened. Accepting
47
+ * it would turn a typo into a silent read of the wrong place, which is the one
48
+ * failure every caller here exists to prevent.
49
+ */
50
+ export function isAbsolutePath(value, platform = process.platform) {
51
+ if (typeof value !== 'string' || !value) return false;
52
+ if (platform === 'win32') {
53
+ return /^[A-Za-z]:[\\/]/.test(value) || /^\\\\[^\\]/.test(value) || /^\/\/[^/]/.test(value);
54
+ }
55
+ return value.startsWith('/');
56
+ }
57
+
58
+ /** A path with forward slashes, which is the only separator glob syntax knows. */
59
+ export function toPosix(value) {
60
+ return typeof value === 'string' ? value.replace(/\\/g, '/') : value;
61
+ }
62
+
63
+ /**
64
+ * A path in the form to compare two paths by, never the form to display or store.
65
+ *
66
+ * Windows and a default macOS volume are both case-insensitive, so two spellings
67
+ * of one directory are one directory, and a dedup set keyed on the raw string
68
+ * misses the duplicate. Lowercasing is only sound for the comparison: the store
69
+ * ids in src/stores.mjs are derived from the path as found on disk and have to
70
+ * stay that way, or every id on the machine changes.
71
+ */
72
+ export function canonicalPath(value, platform = process.platform) {
73
+ if (typeof value !== 'string' || !value) return value;
74
+ const resolved = toPosix(path.resolve(value));
75
+ return platform === 'win32' || platform === 'darwin' ? resolved.toLowerCase() : resolved;
76
+ }
77
+
78
+ export const DEFAULT_CONFIG_DIR_NAME = '.claude';
79
+
80
+ /**
81
+ * The config directory and what decided it, in the shape src/projects.mjs
82
+ * already uses for the memory root: a tool that silently reads somewhere other
83
+ * than the default is worse than one that reads nothing, so the source travels
84
+ * with the path.
85
+ *
86
+ * A CLAUDE_CONFIG_DIR that is not absolute is reported and not used. Claude Code
87
+ * would not accept it either, so falling back to the default is the honest
88
+ * answer - but silently is not, hence `invalid`.
89
+ */
90
+ export function configSource({ env = process.env, home = os.homedir(), platform = process.platform } = {}) {
91
+ const fallback = pathFor(platform).join(home, DEFAULT_CONFIG_DIR_NAME);
92
+ const raw = typeof env.CLAUDE_CONFIG_DIR === 'string' ? env.CLAUDE_CONFIG_DIR.trim() : '';
93
+ if (!raw) return { path: fallback, source: 'default', raw: null, invalid: null };
94
+
95
+ const expanded = expandHome(raw, { home, platform });
96
+ if (!isAbsolutePath(expanded, platform)) {
97
+ return {
98
+ path: fallback,
99
+ source: 'default',
100
+ raw,
101
+ invalid: `CLAUDE_CONFIG_DIR "${raw}" is not an absolute or ~/ path`,
102
+ };
103
+ }
104
+ return { path: expanded, source: 'env', raw, invalid: null };
105
+ }
106
+
107
+ export function configDir(options = {}) {
108
+ return configSource(options).path;
109
+ }
110
+
111
+ /** A path inside the config directory, such as configPath('agents'). */
112
+ export function configPath(...segments) {
113
+ return path.join(configDir(), ...segments);
114
+ }
115
+
116
+ /**
117
+ * CLAUDE_CODE_PROJECT_DIR_NAME, which fixes the <project> directory name so that
118
+ * every repository launched with this config dir shares one store instead of
119
+ * getting a slug of its own.
120
+ *
121
+ * Claude Code documents it as being set beside CLAUDE_CONFIG_DIR, and it is only
122
+ * honoured here on that condition: on its own it would silently redirect a
123
+ * default installation to a directory Claude Code is not using.
124
+ */
125
+ export function fixedProjectDirName({ env = process.env } = {}) {
126
+ if (typeof env.CLAUDE_CONFIG_DIR !== 'string' || !env.CLAUDE_CONFIG_DIR.trim()) return null;
127
+ const name = typeof env.CLAUDE_CODE_PROJECT_DIR_NAME === 'string' ? env.CLAUDE_CODE_PROJECT_DIR_NAME.trim() : '';
128
+ if (!name || name !== path.basename(name) || name.startsWith('.')) return null;
129
+ return name;
130
+ }
package/src/cost.mjs CHANGED
@@ -18,9 +18,9 @@
18
18
  // prompt and are untouched by it.
19
19
 
20
20
  import fs from 'node:fs';
21
- import os from 'node:os';
22
21
  import path from 'node:path';
23
22
 
23
+ import { configPath } from './config.mjs';
24
24
  import { parseFrontmatter } from './parse.mjs';
25
25
  import { writeFileAtomic } from './mutate.mjs';
26
26
  import {
@@ -31,7 +31,7 @@ import {
31
31
  settingsCandidates,
32
32
  } from './settings.mjs';
33
33
 
34
- export const OUTPUT_STYLES_DIR = path.join(os.homedir(), '.claude', 'output-styles');
34
+ export const OUTPUT_STYLES_DIR = configPath('output-styles');
35
35
 
36
36
  // A full model name is documented as acceptable wherever an alias is, and this
37
37
  // tool has no business deciding which ones exist. The shape is checked so a
@@ -13,8 +13,8 @@
13
13
  // would hide exactly the problem this is meant to surface.
14
14
 
15
15
  import fs from 'node:fs';
16
- import os from 'node:os';
17
16
  import path from 'node:path';
17
+ import { canonicalPath, configDir, DEFAULT_CONFIG_DIR_NAME, expandHome, toPosix } from './config.mjs';
18
18
  import { parseFrontmatter } from './parse.mjs';
19
19
  import { estimateTokens } from './stats.mjs';
20
20
  import { lookup, settingsLayers } from './settings.mjs';
@@ -133,7 +133,7 @@ export function findImports(text) {
133
133
  }
134
134
 
135
135
  function resolveImport(spec, fromFile) {
136
- if (spec.startsWith('~/')) return path.join(os.homedir(), spec.slice(2));
136
+ if (spec.startsWith('~/') || spec.startsWith('~\\')) return expandHome(spec);
137
137
  if (path.isAbsolute(spec)) return spec;
138
138
  // Relative to the file holding the import, not to the working directory.
139
139
  return path.resolve(path.dirname(fromFile), spec);
@@ -149,7 +149,10 @@ function resolveImport(spec, fromFile) {
149
149
  export function expandImports(rootFile, rootText, { projectDir = null } = {}) {
150
150
  const files = [];
151
151
  const problems = [];
152
- const seen = new Set([rootFile]);
152
+ // Canonical keys: on a case-insensitive filesystem two spellings of one file
153
+ // are one file, and a cycle spelled differently at each hop would otherwise
154
+ // recurse until it hit the depth limit instead of being named as a cycle.
155
+ const seen = new Set([canonicalPath(rootFile)]);
153
156
  let frontier = [{ file: rootFile, text: rootText, depth: 0 }];
154
157
 
155
158
  while (frontier.length) {
@@ -162,7 +165,7 @@ export function expandImports(rootFile, rootText, { projectDir = null } = {}) {
162
165
  problems.push({ kind: 'too-deep', spec, from: current.file, file: resolved });
163
166
  continue;
164
167
  }
165
- if (seen.has(resolved)) {
168
+ if (seen.has(canonicalPath(resolved))) {
166
169
  problems.push({ kind: 'cycle', spec, from: current.file, file: resolved });
167
170
  continue;
168
171
  }
@@ -173,7 +176,7 @@ export function expandImports(rootFile, rootText, { projectDir = null } = {}) {
173
176
  continue;
174
177
  }
175
178
 
176
- seen.add(resolved);
179
+ seen.add(canonicalPath(resolved));
177
180
  // An import resolving outside the project is the kind Claude Code asks
178
181
  // you to approve once; a declined one then stays silently disabled.
179
182
  const external = projectDir ? path.relative(projectDir, resolved).startsWith('..') : false;
@@ -342,17 +345,34 @@ function entry(file, scope, kind, { conditional = false } = {}) {
342
345
  }
343
346
 
344
347
  /** Directories from the filesystem root down to `dir`, in load order. */
348
+ /**
349
+ * Every directory from the filesystem root down to `dir`, root last.
350
+ *
351
+ * Walked with dirname rather than by splitting on the separator, because the
352
+ * separator alone does not describe a root: a Windows path starts at `C:\` and
353
+ * a UNC path at `\\server\share`, and splitting either of those produces
354
+ * candidates that name nothing.
355
+ */
345
356
  function ancestors(dir) {
346
- const parts = path.resolve(dir).split(path.sep);
347
357
  const out = [];
348
- for (let i = 1; i < parts.length; i++) {
349
- out.push(parts.slice(0, i + 1).join(path.sep) || path.sep);
358
+ let current = path.resolve(dir);
359
+ while (true) {
360
+ out.push(current);
361
+ const parent = path.dirname(current);
362
+ if (parent === current) break;
363
+ current = parent;
350
364
  }
351
- return out;
365
+ return out.reverse();
352
366
  }
353
367
 
354
- function globToRegExp(pattern) {
355
- // Only used for claudeMdExcludes, which matches absolute paths.
368
+ export function globToRegExp(pattern, platform = process.platform) {
369
+ // Only used for claudeMdExcludes, which matches absolute paths. Glob syntax
370
+ // knows one separator, so a Windows path has to be compared in its forward
371
+ // slash form or every pattern silently matches nothing - and on the two
372
+ // case-insensitive platforms the comparison has to ignore case for the same
373
+ // reason a path does.
374
+ const flags = platform === 'win32' || platform === 'darwin' ? 'i' : '';
375
+ pattern = toPosix(pattern);
356
376
  let out = '';
357
377
  for (let i = 0; i < pattern.length; i++) {
358
378
  const char = pattern[i];
@@ -362,16 +382,26 @@ function globToRegExp(pattern) {
362
382
  } else if (char === '?') out += '[^/]';
363
383
  else out += char.replace(/[.+^${}()|[\]\\]/g, '\\$&');
364
384
  }
365
- return new RegExp(`^${out}$`);
385
+ return new RegExp(`^${out}$`, flags);
366
386
  }
367
387
 
368
388
  /**
369
389
  * Managed policy and user scope: what loads whichever project a session starts in.
370
390
  *
371
- * `home` is a parameter rather than a call to os.homedir() so this is a total
391
+ * `home` is a parameter rather than a read of the config directory so this is a total
372
392
  * function of where it is told to look, which is what makes it testable without a
373
393
  * real home directory.
374
394
  */
395
+ /**
396
+ * The user-scope directory: normally the config directory, which
397
+ * CLAUDE_CONFIG_DIR may have moved off the home directory entirely. A `home`
398
+ * passed in names its own, which is what lets the tests point the user scope at
399
+ * a fixture.
400
+ */
401
+ function userDir(home) {
402
+ return home ? path.join(home, DEFAULT_CONFIG_DIR_NAME) : configDir();
403
+ }
404
+
375
405
  function userCandidates(home, layers) {
376
406
  const candidates = [entry(managedClaudeMd(), 'managed', 'claude-md')];
377
407
 
@@ -382,8 +412,9 @@ function userCandidates(home, layers) {
382
412
  : null;
383
413
  if (inline) candidates.push(inline);
384
414
 
385
- candidates.push(entry(path.join(home, '.claude', 'CLAUDE.md'), 'user', 'claude-md'));
386
- for (const file of listRuleFiles(path.join(home, '.claude', 'rules'))) {
415
+ const dir = userDir(home);
416
+ candidates.push(entry(path.join(dir, 'CLAUDE.md'), 'user', 'claude-md'));
417
+ for (const file of listRuleFiles(path.join(dir, 'rules'))) {
387
418
  candidates.push(ruleEntry(file, 'user'));
388
419
  }
389
420
  return candidates;
@@ -416,14 +447,14 @@ function readExcludes(layers) {
416
447
  * one cannot drift apart in what they count or what they consider broken.
417
448
  */
418
449
  function finalize(candidates, { projectDir, excludes }) {
419
- const matchers = excludes.map(globToRegExp);
450
+ const matchers = excludes.map((pattern) => globToRegExp(pattern));
420
451
  const excluded = [];
421
452
  const loaded = [];
422
453
  const problems = [];
423
454
 
424
455
  for (const candidate of candidates.filter(Boolean)) {
425
456
  // Managed policy cannot be excluded; that is the point of managed policy.
426
- if (candidate.scope !== 'managed' && matchers.some((re) => re.test(candidate.file))) {
457
+ if (candidate.scope !== 'managed' && matchers.some((re) => re.test(toPosix(candidate.file)))) {
427
458
  excluded.push(candidate.file);
428
459
  continue;
429
460
  }
@@ -498,7 +529,7 @@ function finalize(candidates, { projectDir, excludes }) {
498
529
  * and walking into them would bury the one finding that matters.
499
530
  */
500
531
  export function unreferencedUserFiles(home, loadedFiles) {
501
- const dir = path.join(home, '.claude');
532
+ const dir = userDir(home);
502
533
  // Imports arrive already absolutised by resolveImport while candidates are
503
534
  // joined from `home`, so both sides are resolved before they are compared.
504
535
  const loaded = new Set(loadedFiles.map((item) => path.resolve(item.file)));
@@ -526,7 +557,7 @@ export function unreferencedUserFiles(home, loadedFiles) {
526
557
  export function resolveInstructions(projectDir) {
527
558
  const layers = settingsLayers({ projectDir });
528
559
  const candidates = [
529
- ...userCandidates(os.homedir(), layers),
560
+ ...userCandidates(null, layers),
530
561
  ...projectCandidates(projectDir),
531
562
  ];
532
563
  const resolved = finalize(candidates, { projectDir, excludes: readExcludes(layers) });
@@ -552,7 +583,7 @@ export function resolveInstructions(projectDir) {
552
583
  * With no project there is no boundary for an import to resolve outside of, so
553
584
  * nothing is marked external here - that check belongs to the whole-session view.
554
585
  */
555
- export function resolveGlobalInstructions({ home = os.homedir(), settingsFile = null } = {}) {
586
+ export function resolveGlobalInstructions({ home = null, settingsFile = null } = {}) {
556
587
  const layers = settingsLayers(settingsFile ? { settingsFile } : {});
557
588
  const resolved = finalize(userCandidates(home, layers), { projectDir: null, excludes: readExcludes(layers) });
558
589
  resolved.problems.push(...unreferencedUserFiles(home, resolved.files));
@@ -4,9 +4,10 @@
4
4
  // happening right now.
5
5
 
6
6
  import fs from 'node:fs';
7
- import os from 'node:os';
8
7
  import path from 'node:path';
9
8
 
9
+ import { configPath } from './config.mjs';
10
+
10
11
  function isAlive(pid) {
11
12
  try {
12
13
  process.kill(pid, 0);
@@ -21,7 +22,7 @@ function isAlive(pid) {
21
22
  * left behind by a process that has since exited is silently skipped rather
22
23
  * than reported as stale - the registry itself is not this app's to clean up.
23
24
  */
24
- export function readLiveSessions({ dir = path.join(os.homedir(), '.claude', 'sessions') } = {}) {
25
+ export function readLiveSessions({ dir = configPath('sessions') } = {}) {
25
26
  let entries;
26
27
  try {
27
28
  entries = fs.readdirSync(dir, { withFileTypes: true });
package/src/model.mjs CHANGED
@@ -15,7 +15,7 @@ import { parseIndex, parseFrontmatter, extractWikilinks } from './parse.mjs';
15
15
  import { listMemoryFiles, memoryDir, resolveProjectPath, shortLabel } from './projects.mjs';
16
16
  import { autoMemoryState } from './settings.mjs';
17
17
  import { ageInDays, estimateTokens, findDuplicates, indexStats } from './stats.mjs';
18
- import { memoryChecks, sessionChecks } from './checks.mjs';
18
+ import { agentStoreChecks, memoryChecks, sessionChecks } from './checks.mjs';
19
19
  import { listSessions, originSession, retention, transcriptDir } from './sessions.mjs';
20
20
  import { rememberedPath } from './pathcache.mjs';
21
21
 
@@ -198,6 +198,7 @@ export function buildStore(store) {
198
198
  ...(health.longHooks.length
199
199
  ? [{ kind: 'long-hooks', severity: 'warn', count: health.longHooks.length, longest: health.longHooks[0] }]
200
200
  : []),
201
+ ...agentStoreChecks(store),
201
202
  ...memoryChecks(memories, index),
202
203
  ...sessionChecks(store, memories, sessions.retention, {
203
204
  remembered: sessions.remembered,