@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 +18 -0
- package/bin/cairn-cli.js +62 -17
- package/index.js +90 -1
- package/package.json +1 -1
- package/src/graph/cwd.js +21 -1
- package/src/graph/db.js +12 -0
- package/src/indexer/todoScanner.js +41 -0
- package/src/tools/employMemory.js +135 -0
- package/src/tools/maintain.js +4 -0
- package/src/tools/memo.js +168 -0
- package/src/tools/outline.js +10 -5
- package/src/tools/relocate.js +113 -0
- package/src/tools/resume.js +67 -22
- package/src/tools/switch.js +93 -0
- package/src/tools/todos.js +68 -0
- package/test/cairn-cli.test.js +8 -4
- package/test/memo.test.js +304 -0
- package/test/relocate.test.js +166 -0
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 === '
|
|
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' →
|
|
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
|
-
//
|
|
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
|
|
136
|
-
//
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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(
|
|
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
|
|
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.
|
|
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(
|
|
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
|
+
}
|
package/src/tools/maintain.js
CHANGED
|
@@ -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
|