@misterhuydo/cairn-mcp 1.4.2 → 1.6.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/README.md CHANGED
@@ -73,11 +73,29 @@ No manual steps. The index lives in `.cairn/index.db` inside your project — li
73
73
  | `cairn_outline` | Project-wide structural outline + heuristic issue detection (god classes, lifecycle gaps, naming inconsistencies, missing tests) |
74
74
  | `cairn_code_graph` | Dependency health — instability, cycles, load-bearing modules |
75
75
  | `cairn_security` | Scan for XSS, SQLi, hardcoded secrets, weak crypto, and more |
76
+ | `cairn_todos` | Scan codebase for TODO/FIXME/HACK comments, add manual items, resolve and list them |
76
77
  | `cairn_bundle` | Minified source snapshot (auto-handled by hooks) |
77
78
  | `cairn_checkpoint` | Save session state (auto-handled by hooks) |
78
79
 
79
80
  ---
80
81
 
82
+ ## TODO tracking
83
+
84
+ `cairn_todos` scans every file Cairn indexes for `TODO`, `FIXME`, `HACK`, `XXX`, and `NOTE` comments and stores them in the project database. Manual items you add yourself are never overwritten by a scan.
85
+
86
+ ```
87
+ cairn_todos { action: "list" } → all todos (open + done)
88
+ cairn_todos { action: "list", status: "open" } → open only
89
+ cairn_todos { action: "list", source: "manual" } → items you added yourself
90
+ cairn_todos { action: "add", text: "...", kind: "FIXME" } → add a manual item
91
+ cairn_todos { action: "resolve", id: 42 } → mark done
92
+ cairn_todos { action: "scan" } → re-scan without full maintain
93
+ ```
94
+
95
+ Stats are included automatically in `cairn_maintain` (`todos_found`) and `cairn_resume` (`open_todos`), so you always know the backlog at a glance.
96
+
97
+ ---
98
+
81
99
  ## Supported languages
82
100
 
83
101
  | Language | What Cairn extracts |
package/bin/cairn-cli.js CHANGED
@@ -18,11 +18,16 @@ process.on('uncaughtException', (e) => {
18
18
 
19
19
  const subcommand = process.argv[2];
20
20
 
21
- if (subcommand === 'minify') {
21
+ if (subcommand === '--version' || subcommand === '-v') {
22
+ const pkg = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
23
+ console.log(pkg.version);
24
+ process.exit(0);
25
+
26
+ } else if (subcommand === 'minify') {
22
27
  // PreToolUse[Read] hook.
23
28
  // Per-file state machine stored in .cairn/minify-map.json:
24
29
  // no entry → minify, write to .cairn/views/, block Read (exit 2) with inline content or outline
25
- // 'compressed' → re-read after edit-guard blocked; advance to 'edit-ready', pass through (exit 0)
30
+ // 'compressed' → edit-guard blocked Edit + provided content; if re-read happens, advance to 'edit-ready'
26
31
  // 'edit-ready' → Edit is imminent; pass through without touching state (exit 0)
27
32
  let stdinData = '';
28
33
  process.stdin.setEncoding('utf8');
@@ -64,7 +69,8 @@ if (subcommand === 'minify') {
64
69
  }
65
70
 
66
71
  if (entry?.state === 'compressed') {
67
- // Re-read requested by edit-guard: advance to edit-ready so Edit can proceed
72
+ // edit-guard already advanced to 'edit-ready' and showed content inline.
73
+ // If Claude re-reads the original anyway, advance to edit-ready and pass through.
68
74
  entry.state = 'edit-ready';
69
75
  saveMap(cairnDir, map);
70
76
  process.exit(0);
@@ -129,19 +135,22 @@ if (subcommand === 'minify') {
129
135
  });
130
136
 
131
137
  } else if (subcommand === 'edit-guard') {
132
- // PreToolUse[Edit] hook.
138
+ // PreToolUse[Edit|Write] hook.
133
139
  // Per-file state:
134
- // no entry → file was never minified (full content always shown), allow Edit
135
- // 'compressed' → block Edit, instruct Claude to re-read original first
136
- // 'edit-ready' → re-read was done with full content, allow Edit and clear entry
140
+ // no entry → file was never minified (full content always shown), allow Edit/Write
141
+ // 'compressed' → block Edit; advance to 'edit-ready' and provide full content inline
142
+ // Write always allowed (it supplies its own content); clears entry
143
+ // 'edit-ready' → content was provided/re-read, allow Edit and clear entry
137
144
  let stdinData = '';
138
145
  process.stdin.setEncoding('utf8');
139
146
  process.stdin.on('data', chunk => { stdinData += chunk; });
140
147
  process.stdin.on('end', () => {
141
148
  let filePath = '(unknown)';
149
+ let toolName = 'Edit';
142
150
  try {
143
151
  const input = JSON.parse(stdinData);
144
152
  filePath = input?.tool_input?.file_path || '(unknown)';
153
+ toolName = input?.tool_name || 'Edit';
145
154
  } catch { }
146
155
 
147
156
  const resolvedPath = path.resolve(filePath);
@@ -159,18 +168,44 @@ if (subcommand === 'minify') {
159
168
  process.exit(0);
160
169
  }
161
170
 
171
+ // Write replaces the entire file — no need to show old content. Clear and allow.
172
+ if (toolName === 'Write') {
173
+ delete map[resolvedPath];
174
+ saveMap(cairnDir, map);
175
+ process.exit(0);
176
+ }
177
+
162
178
  if (entry.state === 'edit-ready') {
163
179
  delete map[resolvedPath];
164
180
  saveMap(cairnDir, map);
165
181
  process.exit(0);
166
182
  }
167
183
 
168
- // state='compressed': Claude hasn't re-read with full content yet
169
- saveMap(cairnDir, map); // save any cleanup from validateMap
170
- process.stderr.write(
171
- `[cairn] Re-read the file before editing: Read("${filePath}")\n` +
172
- `The file content was compressed by cairn. Re-read it now to get full content, then retry the Edit.\n`
173
- );
184
+ // state='compressed': Claude hasn't re-read with full content yet.
185
+ // Advance to 'edit-ready' now so the next Edit attempt is allowed regardless
186
+ // of whether Claude re-reads the original or the cached view.
187
+ entry.state = 'edit-ready';
188
+ saveMap(cairnDir, map);
189
+
190
+ // For small-to-medium files, provide full content inline so Claude can retry
191
+ // without a separate Read round-trip, and so there's no cached-view confusion.
192
+ let fullContent = null;
193
+ try {
194
+ const raw = fs.readFileSync(resolvedPath, 'utf8');
195
+ if (raw.split('\n').length <= 300) fullContent = raw;
196
+ } catch { }
197
+
198
+ if (fullContent !== null) {
199
+ process.stdout.write(
200
+ `[cairn] File was compressed in context. Full source content:\n\n${fullContent}\n\n` +
201
+ `Re-apply your Edit using the exact content above (state is now edit-ready).\n`
202
+ );
203
+ } else {
204
+ process.stdout.write(
205
+ `[cairn] Re-read the file before editing: Read("${filePath}")\n` +
206
+ `The file content was compressed by cairn. Re-read it now to get full content, then retry the Edit.\n`
207
+ );
208
+ }
174
209
  process.exit(2);
175
210
  });
176
211
 
@@ -274,7 +309,12 @@ if (subcommand === 'minify') {
274
309
  }
275
310
  const session = JSON.parse(fs.readFileSync(sessionPath, 'utf8'));
276
311
  const when = new Date(session.checkpoint_at).toLocaleString();
277
- process.stdout.write(`[cairn] Prior session: "${session.message}" (${when}). Call cairn_resume to restore prior context.\n`);
312
+ process.stdout.write([
313
+ `[cairn] Prior session: "${session.message}" (${when}).`,
314
+ `[cairn] SESSION: call cairn_resume to restore context (or cairn_maintain for a fresh index). At end of session call cairn_checkpoint with message + active_files + notes.`,
315
+ `[cairn] TOOLS: prefer cairn_search / cairn_bundle / cairn_describe over manual file browsing. Use cairn_outline for structural overview, cairn_todos for backlog, cairn_security before PRs.`,
316
+ `[cairn] MEMORY: IMPORTANT — call cairn_memo IMMEDIATELY and SILENTLY (do NOT ask the user, do NOT list what you are about to save, do NOT confirm afterward) when you observe — preference: user corrects/confirms your behaviour; experience: non-trivial problem solved; decision: architectural choice made; knowledge: non-obvious codebase fact discovered. cairn_employ_memory is explicit only — never call it unless asked.`,
317
+ ].join('\n') + '\n');
278
318
  fs.writeFileSync(lockPath, new Date().toISOString(), 'utf8');
279
319
  process.exit(0);
280
320
 
@@ -306,7 +346,8 @@ if (subcommand === 'minify') {
306
346
  console.log('');
307
347
  console.log('Active hooks:');
308
348
  console.log(' PreToolUse[Read] -> cairn minify (compress source files for reading)');
309
- console.log(' PreToolUse[Edit] -> cairn edit-guard (block Edit until re-read with full content)');
349
+ console.log(' PreToolUse[Edit] -> cairn edit-guard (block Edit until re-read with full content)')
350
+ console.log(' PreToolUse[Write] -> cairn edit-guard (clear minify state before full file overwrite)');
310
351
  console.log(' Stop -> cairn checkpoint --auto (auto-save session)');
311
352
  console.log(' UserPromptSubmit -> cairn resume-hint (remind Claude of prior session)');
312
353
  console.log('');
@@ -325,13 +366,14 @@ if (subcommand === 'minify') {
325
366
  console.log('');
326
367
  console.log('Active hooks:');
327
368
  console.log(' PreToolUse[Read] -> cairn minify (compress source files for reading)');
328
- console.log(' PreToolUse[Edit] -> cairn edit-guard (block Edit until re-read with full content)');
369
+ console.log(' PreToolUse[Edit] -> cairn edit-guard (block Edit until re-read with full content)')
370
+ console.log(' PreToolUse[Write] -> cairn edit-guard (clear minify state before full file overwrite)');
329
371
  console.log(' Stop -> cairn checkpoint --auto (auto-save session)');
330
372
  console.log(' UserPromptSubmit -> cairn resume-hint (remind Claude of prior session)');
331
373
  process.exit(0);
332
374
 
333
375
  } else {
334
- console.error('Usage: cairn <install | install-hooks [--global] | minify | edit-guard | validate-map | checkpoint --auto | resume-hint>');
376
+ console.error('Usage: cairn <--version | install | install-hooks [--global] | minify | edit-guard | validate-map | checkpoint --auto | resume-hint>');
335
377
  process.exit(1);
336
378
  }
337
379
 
@@ -355,6 +397,9 @@ function applyHooks(settingsDir, isGlobal) {
355
397
  if (!cleanedPreHooks.some(h => h.matcher === 'Edit' && h.hooks?.some(hh => hh.command === 'cairn edit-guard'))) {
356
398
  cleanedPreHooks.push({ matcher: 'Edit', hooks: [{ type: 'command', command: 'cairn edit-guard' }] });
357
399
  }
400
+ if (!cleanedPreHooks.some(h => h.matcher === 'Write' && h.hooks?.some(hh => hh.command === 'cairn edit-guard'))) {
401
+ cleanedPreHooks.push({ matcher: 'Write', hooks: [{ type: 'command', command: 'cairn edit-guard' }] });
402
+ }
358
403
  settings.hooks.PreToolUse = cleanedPreHooks;
359
404
 
360
405
  // Stop: cairn checkpoint --auto
package/index.js CHANGED
@@ -13,6 +13,10 @@ import { checkpoint } from './src/tools/checkpoint.js';
13
13
  import { resume } from './src/tools/resume.js';
14
14
  import { minify } from './src/tools/minify.js';
15
15
  import { outlineProject } from './src/tools/outline.js';
16
+ import { todos } from './src/tools/todos.js';
17
+ import { memo } from './src/tools/memo.js';
18
+ import { switchProject } from './src/tools/switch.js';
19
+ import { employMemory } from './src/tools/employMemory.js';
16
20
 
17
21
  const db = openDB();
18
22
 
@@ -139,7 +143,7 @@ Typical workflow: cairn_search → find files → cairn_bundle those paths → C
139
143
  },
140
144
  {
141
145
  name: 'cairn_resume',
142
- description: 'Restore the last saved session state. Detects which files changed since the checkpoint and incrementally re-indexes only those files. Call at the start of a session instead of cairn_maintain when resuming work.',
146
+ description: 'Restore the last saved session state. Detects which files changed since the checkpoint and incrementally re-indexes only those files. Also loads preference memories and surfaces the memory index. Call at the start of a session instead of cairn_maintain when resuming work. If the project was moved to a different drive or directory, paths are repaired automatically.',
143
147
  inputSchema: {
144
148
  type: 'object',
145
149
  properties: {},
@@ -187,6 +191,87 @@ Use to: get a project-wide bird's-eye view, triage code quality, find where to l
187
191
  required: ['file_path'],
188
192
  },
189
193
  },
194
+ {
195
+ name: 'cairn_memo',
196
+ description: 'Persist and retrieve typed memories in .cairn/memory/. Use this — not the auto-memory system — for all project-level memories. Triggered by: "save cairn memory", "cairn remember", "build cairn memories", "save experience to cairn". Four types: preference (user working style — always loaded on resume), experience (transferable lessons learned — migrated between projects), decision (project-specific architectural choices), knowledge (project-specific codebase facts). Memories survive context loss and accumulate across sessions.',
197
+ inputSchema: {
198
+ type: 'object',
199
+ properties: {
200
+ action: {
201
+ type: 'string',
202
+ enum: ['write', 'read', 'list', 'delete'],
203
+ description: 'write: save/update a memory | read: load a memory by name | list: show all memories with index | delete: remove a memory',
204
+ },
205
+ name: { type: 'string', description: 'Memory identifier (required for write/read/delete)' },
206
+ type: {
207
+ type: 'string',
208
+ enum: ['preference', 'experience', 'decision', 'knowledge'],
209
+ description: 'Memory type (required for write)',
210
+ },
211
+ content: { type: 'string', description: 'Memory body — what should be remembered (required for write)' },
212
+ description: { type: 'string', description: 'One-line summary shown in the memory index' },
213
+ origin: { type: 'string', description: 'Optional: source project path, for traceability of migrated memories' },
214
+ },
215
+ required: ['action'],
216
+ },
217
+ },
218
+ {
219
+ name: 'cairn_switch',
220
+ description: 'Switch the active Cairn project context to a different directory. Detects the project root automatically from any path within the project (via .git, package.json, .cairn, etc.). Call cairn_resume or cairn_maintain after switching. Call with no arguments to return to the original session\'s project.',
221
+ inputSchema: {
222
+ type: 'object',
223
+ properties: {
224
+ project_path: {
225
+ type: 'string',
226
+ description: 'Any path inside the target project (file or directory). Omit to restore the original session root.',
227
+ },
228
+ },
229
+ },
230
+ },
231
+ {
232
+ name: 'cairn_employ_memory',
233
+ description: 'Migrate memories from another project\'s .cairn/memory/ into the current project. By default copies only transferable types (preference + experience) — skips project-specific decision and knowledge. Imported memories are tagged with their origin project path for traceability.',
234
+ inputSchema: {
235
+ type: 'object',
236
+ properties: {
237
+ source_path: {
238
+ type: 'string',
239
+ description: 'Path to the source project (any file/directory inside it, or its .cairn directory)',
240
+ },
241
+ types: {
242
+ type: 'array',
243
+ items: { type: 'string', enum: ['preference', 'experience', 'decision', 'knowledge'] },
244
+ description: 'Optional: memory types to migrate (default: ["preference", "experience"])',
245
+ },
246
+ overwrite: {
247
+ type: 'boolean',
248
+ default: false,
249
+ description: 'Overwrite existing memories with the same filename (default: false)',
250
+ },
251
+ },
252
+ required: ['source_path'],
253
+ },
254
+ },
255
+ {
256
+ name: 'cairn_todos',
257
+ description: 'Manage TODO/FIXME/HACK/XXX items. Scans the codebase for TODO-style comments and lets you add your own. Stats are included in cairn_maintain and cairn_resume reports.',
258
+ inputSchema: {
259
+ type: 'object',
260
+ properties: {
261
+ action: {
262
+ type: 'string',
263
+ enum: ['list', 'add', 'resolve', 'scan'],
264
+ description: 'list: show todos | add: create a manual todo | resolve: mark done by id | scan: re-scan codebase',
265
+ },
266
+ text: { type: 'string', description: 'Text for the new todo (add action)' },
267
+ kind: { type: 'string', enum: ['TODO', 'FIXME', 'HACK', 'XXX', 'NOTE'], description: 'Kind for new todo (default: TODO)' },
268
+ id: { type: 'number', description: 'Todo ID to resolve (resolve action)' },
269
+ status: { type: 'string', enum: ['open', 'done'], description: 'Filter by status (list action)' },
270
+ source: { type: 'string', enum: ['scan', 'manual'], description: 'Filter by source (list action)' },
271
+ },
272
+ required: ['action'],
273
+ },
274
+ },
190
275
  ],
191
276
  }));
192
277
 
@@ -203,6 +288,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
203
288
  case 'cairn_resume': return await resume(db);
204
289
  case 'cairn_outline': return outlineProject(db, args);
205
290
  case 'cairn_minify': return minify(db, args);
291
+ case 'cairn_todos': return await todos(db, args);
292
+ case 'cairn_memo': return memo(db, args);
293
+ case 'cairn_switch': return switchProject(db, args);
294
+ case 'cairn_employ_memory': return employMemory(db, args);
206
295
  default: throw new Error(`Unknown tool: ${name}`);
207
296
  }
208
297
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/cairn-mcp",
3
- "version": "1.4.2",
3
+ "version": "1.6.2",
4
4
  "description": "MCP server that gives Claude Code persistent memory across sessions. Index your codebase once, search symbols, bundle source, scan for vulnerabilities, and checkpoint/resume work — across Java, TypeScript, Vue, Python, SQL and more.",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/src/graph/cwd.js CHANGED
@@ -1,11 +1,31 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
 
4
+ let _activeProjectRoot = null;
5
+
6
+ export function getProjectRoot() {
7
+ return _activeProjectRoot || process.cwd();
8
+ }
9
+
10
+ export function setActiveProjectRoot(rootPath) {
11
+ const previous = _activeProjectRoot;
12
+ _activeProjectRoot = rootPath;
13
+ return previous;
14
+ }
15
+
4
16
  export function getCairnDir() {
5
- const cairnDir = path.join(process.cwd(), '.cairn');
17
+ const cairnDir = path.join(getProjectRoot(), '.cairn');
6
18
  fs.mkdirSync(path.join(cairnDir, 'bundles'), { recursive: true });
7
19
  // Write sentinel so findCairnDir() recognises this as a cairn project directory
8
20
  const sentinel = path.join(cairnDir, '.cairn-project');
9
21
  if (!fs.existsSync(sentinel)) fs.writeFileSync(sentinel, '', 'utf8');
10
22
  return cairnDir;
11
23
  }
24
+
25
+ export function getMemoryDir() {
26
+ const memoryDir = path.join(getCairnDir(), 'memory');
27
+ fs.mkdirSync(memoryDir, { recursive: true });
28
+ const indexPath = path.join(memoryDir, 'MEMORY.md');
29
+ if (!fs.existsSync(indexPath)) fs.writeFileSync(indexPath, '# Memory Index\n', 'utf8');
30
+ return memoryDir;
31
+ }
package/src/graph/db.js CHANGED
@@ -57,6 +57,18 @@ const SCHEMA = `
57
57
  CREATE TABLE IF NOT EXISTS sub_indexes (
58
58
  path TEXT PRIMARY KEY
59
59
  );
60
+
61
+ CREATE TABLE IF NOT EXISTS todos (
62
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
63
+ source TEXT NOT NULL DEFAULT 'manual',
64
+ file TEXT,
65
+ line INTEGER,
66
+ kind TEXT NOT NULL DEFAULT 'TODO',
67
+ text TEXT NOT NULL,
68
+ status TEXT NOT NULL DEFAULT 'open',
69
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
70
+ resolved_at TEXT
71
+ );
60
72
  `;
61
73
 
62
74
  // Each sub-DB gets its IDs offset by this multiplier to prevent collisions
@@ -0,0 +1,41 @@
1
+ import fs from 'fs/promises';
2
+
3
+ // Matches TODO/FIXME/HACK/XXX/NOTE in single-line comments (//, #),
4
+ // block comment lines (* or /*), and HTML comments (<!--).
5
+ const TODO_RE = /(?:\/\/|#|\/\*|\*|<!--)\s*(TODO|FIXME|HACK|XXX|NOTE)[:\s]+(.*)/i;
6
+
7
+ /**
8
+ * Delete all scan-sourced todos, then re-scan filePaths for TODO-style comments.
9
+ * Manual todos are always preserved.
10
+ * Returns the number of todos found.
11
+ */
12
+ export async function scanTodos(db, filePaths) {
13
+ db.prepare('DELETE FROM main.todos WHERE source = ?').run('scan');
14
+
15
+ const insert = db.prepare(
16
+ 'INSERT INTO main.todos (source, file, line, kind, text) VALUES (?, ?, ?, ?, ?)'
17
+ );
18
+
19
+ let count = 0;
20
+ for (const filePath of filePaths) {
21
+ try {
22
+ const content = await fs.readFile(filePath, 'utf-8');
23
+ const lines = content.split('\n');
24
+ for (let i = 0; i < lines.length; i++) {
25
+ const match = TODO_RE.exec(lines[i]);
26
+ if (match) {
27
+ const kind = match[1].toUpperCase();
28
+ const text = match[2].trim();
29
+ if (text) {
30
+ insert.run('scan', filePath, i + 1, kind, text);
31
+ count++;
32
+ }
33
+ }
34
+ }
35
+ } catch {
36
+ // skip unreadable files
37
+ }
38
+ }
39
+
40
+ return count;
41
+ }
@@ -0,0 +1,135 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { getMemoryDir } from '../graph/cwd.js';
4
+ import { detectProjectRoot } from './switch.js';
5
+ import { loadIndexEntries, TRANSFERABLE_TYPES } from './memo.js';
6
+
7
+ export function employMemory(_db, { source_path, types, overwrite = false }) {
8
+ if (!source_path) throw new Error('source_path is required');
9
+
10
+ // Resolve source .cairn/memory/ directory
11
+ let sourceMemoryDir;
12
+ const normalized = source_path.replace(/\\/g, '/');
13
+
14
+ if (normalized.endsWith('/.cairn/memory') || normalized.endsWith('\\.cairn\\memory')) {
15
+ sourceMemoryDir = source_path;
16
+ } else if (normalized.endsWith('/.cairn') || normalized.endsWith('\\.cairn')) {
17
+ sourceMemoryDir = path.join(source_path, 'memory');
18
+ } else {
19
+ const projectRoot = detectProjectRoot(source_path);
20
+ if (!projectRoot) {
21
+ return {
22
+ content: [{
23
+ type: 'text',
24
+ text: JSON.stringify({
25
+ migrated: false,
26
+ reason: 'Could not detect a project root from source_path.',
27
+ source_path,
28
+ }, null, 2),
29
+ }],
30
+ };
31
+ }
32
+ sourceMemoryDir = path.join(projectRoot, '.cairn', 'memory');
33
+ }
34
+
35
+ if (!fs.existsSync(sourceMemoryDir)) {
36
+ return {
37
+ content: [{
38
+ type: 'text',
39
+ text: JSON.stringify({
40
+ migrated: false,
41
+ reason: 'Source project has no .cairn/memory directory. Run cairn_maintain or cairn_resume there first.',
42
+ source_memory_dir: sourceMemoryDir,
43
+ }, null, 2),
44
+ }],
45
+ };
46
+ }
47
+
48
+ const allowedTypes = types && types.length > 0 ? types : TRANSFERABLE_TYPES;
49
+ const sourceEntries = loadIndexEntries(sourceMemoryDir).filter(e => allowedTypes.includes(e.type));
50
+
51
+ if (sourceEntries.length === 0) {
52
+ return {
53
+ content: [{
54
+ type: 'text',
55
+ text: JSON.stringify({
56
+ migrated: false,
57
+ reason: `No memories of types [${allowedTypes.join(', ')}] found in source project.`,
58
+ source_memory_dir: sourceMemoryDir,
59
+ }, null, 2),
60
+ }],
61
+ };
62
+ }
63
+
64
+ const targetMemoryDir = getMemoryDir();
65
+
66
+ // Determine origin label from source path
67
+ const originLabel = path.dirname(path.dirname(sourceMemoryDir)); // project root
68
+
69
+ // Load target index
70
+ const targetEntries = loadIndexEntries(targetMemoryDir);
71
+ const targetFileSet = new Set(targetEntries.map(e => e.file));
72
+
73
+ const results = { copied: [], skipped: [], errors: [] };
74
+
75
+ for (const entry of sourceEntries) {
76
+ const srcFile = path.join(sourceMemoryDir, entry.file);
77
+ if (!fs.existsSync(srcFile)) {
78
+ results.errors.push({ name: entry.name, reason: 'source file missing' });
79
+ continue;
80
+ }
81
+
82
+ const destFile = path.join(targetMemoryDir, entry.file);
83
+ const alreadyExists = targetFileSet.has(entry.file);
84
+
85
+ if (alreadyExists && !overwrite) {
86
+ results.skipped.push({ name: entry.name, reason: 'already exists (use overwrite: true to replace)' });
87
+ continue;
88
+ }
89
+
90
+ // Read source, inject origin if not already set
91
+ let raw = fs.readFileSync(srcFile, 'utf8');
92
+ if (!raw.includes('\norigin:')) {
93
+ raw = raw.replace('---\n\n', `---\norigin: ${originLabel}\n\n`);
94
+ // fallback if pattern doesn't match
95
+ if (!raw.includes(`origin: ${originLabel}`)) {
96
+ raw = raw.replace(/^---\n/, `---\norigin: ${originLabel}\n`);
97
+ }
98
+ }
99
+
100
+ try {
101
+ fs.writeFileSync(destFile, raw, 'utf8');
102
+ if (!alreadyExists) {
103
+ targetEntries.push({ ...entry });
104
+ targetFileSet.add(entry.file);
105
+ }
106
+ results.copied.push({ name: entry.name, type: entry.type, origin: originLabel });
107
+ } catch (e) {
108
+ results.errors.push({ name: entry.name, reason: e.message });
109
+ }
110
+ }
111
+
112
+ // Rewrite target MEMORY.md index
113
+ if (results.copied.length > 0) {
114
+ const lines = ['# Memory Index', ''];
115
+ for (const e of targetEntries) {
116
+ lines.push(`- [${e.name}](${e.file}) — ${e.description} \`[${e.type}]\``);
117
+ }
118
+ fs.writeFileSync(path.join(targetMemoryDir, 'MEMORY.md'), lines.join('\n') + '\n', 'utf8');
119
+ }
120
+
121
+ return {
122
+ content: [{
123
+ type: 'text',
124
+ text: JSON.stringify({
125
+ migrated: results.copied.length > 0,
126
+ source: originLabel,
127
+ types_migrated: allowedTypes,
128
+ copied: results.copied.length,
129
+ skipped: results.skipped.length,
130
+ errors: results.errors.length,
131
+ details: results,
132
+ }, null, 2),
133
+ }],
134
+ };
135
+ }
@@ -9,6 +9,7 @@ import { parseGradle } from '../indexer/buildParsers/gradleParser.js';
9
9
  import { upsertFile, upsertSymbol, clearFileData } from '../graph/nodes.js';
10
10
  import { insertDependency, insertBuildDep, insertSecurityFinding } from '../graph/edges.js';
11
11
  import { registerSubIndexes, refreshFederatedViews } from '../graph/db.js';
12
+ import { scanTodos } from '../indexer/todoScanner.js';
12
13
 
13
14
  function inferFileType(filePath) {
14
15
  const fp = filePath.toLowerCase();
@@ -85,6 +86,7 @@ export async function maintain(db, { languages } = {}) {
85
86
  symbols_total: 0,
86
87
  dependencies_mapped: 0,
87
88
  security_findings: 0,
89
+ todos_found: 0,
88
90
  };
89
91
 
90
92
  // Walk only files NOT already covered by a sub-project index
@@ -108,6 +110,8 @@ export async function maintain(db, { languages } = {}) {
108
110
  }
109
111
  }
110
112
 
113
+ stats.todos_found = await scanTodos(db, files);
114
+
111
115
  rebuildFts(db);
112
116
 
113
117
  // Register sub-indexes and attach them so queries are federated immediately