@misterhuydo/cairn-mcp 1.7.5 → 1.9.0
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 +4 -4
- package/bin/cairn-cli.js +253 -96
- package/index.js +11 -8
- package/package.json +1 -1
- package/src/graph/db.js +25 -0
- package/src/tools/checkpoint.js +11 -6
- package/src/tools/resume.js +58 -20
- package/src/tools/todos.js +189 -8
package/README.md
CHANGED
|
@@ -44,7 +44,7 @@ No need to re-run `cairn install` — hooks and MCP config carry over between ve
|
|
|
44
44
|
|
|
45
45
|
| Hook | Trigger | Effect |
|
|
46
46
|
|---|---|---|
|
|
47
|
-
| `PreToolUse[Read]` | Every file read | Source files compressed ~68% before Claude sees them; large files show structural outline to save tokens |
|
|
47
|
+
| `PreToolUse[Read]` | Every file read | Source files compressed ~68% before Claude sees them; large files show structural outline with **line numbers** to save tokens |
|
|
48
48
|
| `PreToolUse[Edit]` | Every file edit | Blocks Edit if Claude only saw compressed content — requires a full re-read first |
|
|
49
49
|
| `Stop` | End of every response | Session auto-saved to `.cairn/session.json` |
|
|
50
50
|
| `UserPromptSubmit` | First message of a new session | Fresh project: Claude prompted to run `cairn_maintain`. Returning session: Claude prompted to run `cairn_resume` |
|
|
@@ -66,11 +66,11 @@ No manual steps. The index lives in `.cairn/index.db` inside your project — li
|
|
|
66
66
|
|
|
67
67
|
| Tool | What it does |
|
|
68
68
|
|---|---|
|
|
69
|
-
| `cairn_maintain` | Full index of the current project |
|
|
69
|
+
| `cairn_maintain` | Full index of the current project. Suggests adding `.cairn/` to `.gitignore` if not already present |
|
|
70
70
|
| `cairn_resume` | Restore last session + re-index only changed files |
|
|
71
|
-
| `cairn_search` | Find classes, functions, components by name or concept |
|
|
71
|
+
| `cairn_search` | Find classes, functions, components by name or concept — results include file path and **line number** |
|
|
72
72
|
| `cairn_describe` | Summarize what a folder or module does |
|
|
73
|
-
| `cairn_outline` | Structural outline + heuristic issue detection. On large federated projects returns a per-service summary; use `repo` param to drill into one service |
|
|
73
|
+
| `cairn_outline` | Structural outline with **line numbers** per symbol + heuristic issue detection. On large federated projects returns a per-service summary; use `repo` param to drill into one service |
|
|
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
76
|
| `cairn_todos` | Scan codebase for TODO/FIXME/HACK comments, add manual items, resolve and list them |
|
package/bin/cairn-cli.js
CHANGED
|
@@ -14,91 +14,148 @@ const MINIFY_LANGS = new Set(['java', 'typescript', 'javascript', 'vue', 'python
|
|
|
14
14
|
|
|
15
15
|
// Memory sync scripts deployed to ~/.claude/scripts/ during global install.
|
|
16
16
|
// Using forward slashes in hook commands — bash on Windows handles them fine.
|
|
17
|
-
const SYNC_FORWARD_SCRIPT =
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
import
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
17
|
+
const SYNC_FORWARD_SCRIPT = `#!/usr/bin/env node
|
|
18
|
+
// Forward sync: Claude Code auto-memory -> .cairn/memory/auto-memory/
|
|
19
|
+
// Triggered as a PostToolUse hook on Write.
|
|
20
|
+
// Only fires when the written file is inside Claude's projects memory dir
|
|
21
|
+
// and the current project has a .cairn/ folder.
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import os from 'node:os';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
|
|
26
|
+
let stdinData = '';
|
|
27
|
+
process.stdin.setEncoding('utf8');
|
|
28
|
+
process.stdin.on('data', c => { stdinData += c; });
|
|
29
|
+
process.stdin.on('end', () => {
|
|
30
|
+
let fp;
|
|
31
|
+
try { fp = JSON.parse(stdinData)?.tool_input?.file_path || ''; } catch { process.exit(0); }
|
|
32
|
+
if (!fp) process.exit(0);
|
|
33
|
+
|
|
34
|
+
const claudeProjects = path.normalize(path.join(os.homedir(), '.claude', 'projects'));
|
|
35
|
+
const fpNorm = path.normalize(fp);
|
|
36
|
+
if (!fpNorm.startsWith(claudeProjects) || !fpNorm.includes('memory')) process.exit(0);
|
|
37
|
+
|
|
38
|
+
const cwd = process.cwd();
|
|
39
|
+
if (!fs.existsSync(path.join(cwd, '.cairn'))) process.exit(0);
|
|
40
|
+
|
|
41
|
+
const memoryDir = path.dirname(fpNorm);
|
|
42
|
+
const cairnMemory = path.join(cwd, '.cairn', 'memory');
|
|
43
|
+
const dest = path.join(cairnMemory, 'auto-memory');
|
|
44
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
45
|
+
|
|
46
|
+
for (const f of fs.readdirSync(memoryDir)) {
|
|
47
|
+
// Skip files that exist in canonical top-level — those are mirrored from cairn_memo,
|
|
48
|
+
// not out-of-band auto-memory writes, so backing them up would duplicate canonical content.
|
|
49
|
+
if (fs.existsSync(path.join(cairnMemory, f))) continue;
|
|
50
|
+
const src = path.join(memoryDir, f);
|
|
51
|
+
let st;
|
|
52
|
+
try { st = fs.statSync(src); } catch { continue; }
|
|
53
|
+
if (!st.isFile()) continue;
|
|
54
|
+
const dst = path.join(dest, f);
|
|
55
|
+
fs.copyFileSync(src, dst);
|
|
56
|
+
try { fs.utimesSync(dst, st.atime, st.mtime); } catch {}
|
|
57
|
+
}
|
|
58
|
+
});
|
|
48
59
|
`;
|
|
49
60
|
|
|
50
|
-
const SYNC_RESTORE_SCRIPT =
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
import
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
encoded = cwd.replace(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
61
|
+
const SYNC_RESTORE_SCRIPT = `#!/usr/bin/env node
|
|
62
|
+
// Restore sync: .cairn/memory/ -> Claude Code memory dir for current location.
|
|
63
|
+
// Triggered as a UserPromptSubmit hook on each new session prompt.
|
|
64
|
+
// Three passes:
|
|
65
|
+
// 0. Bootstrap: capture any auto-memory files newer/missing in .cairn/memory/auto-memory/
|
|
66
|
+
// (catches files pre-dating the forward-sync hook install).
|
|
67
|
+
// 1. New-machine restore: copy .cairn/memory/auto-memory/ -> Claude memory dir
|
|
68
|
+
// (only when target is empty, preserves Claude-native memory on new drive)
|
|
69
|
+
// 2. Always: mtime-mirror .cairn/memory/*.md -> Claude memory dir
|
|
70
|
+
// (so cairn_memo writes are reflected)
|
|
71
|
+
// Path encoding: H:\\Projects\\Cairn -> H--Projects-Cairn
|
|
72
|
+
// (':', '\\', '/' all map to '-')
|
|
73
|
+
import fs from 'node:fs';
|
|
74
|
+
import os from 'node:os';
|
|
75
|
+
import path from 'node:path';
|
|
76
|
+
|
|
77
|
+
const cwd = process.cwd();
|
|
78
|
+
const cairnMemory = path.join(cwd, '.cairn', 'memory');
|
|
79
|
+
if (!fs.existsSync(cairnMemory)) process.exit(0);
|
|
80
|
+
|
|
81
|
+
const encoded = cwd.replace(/[:/\\\\]/g, '-').replace(/^-+/, '');
|
|
82
|
+
const memoryDir = path.join(os.homedir(), '.claude', 'projects', encoded, 'memory');
|
|
83
|
+
fs.mkdirSync(memoryDir, { recursive: true });
|
|
84
|
+
|
|
85
|
+
const cairnAutoMemory = path.join(cairnMemory, 'auto-memory');
|
|
86
|
+
|
|
87
|
+
function copyWithMtime(src, dst, srcStat) {
|
|
88
|
+
fs.copyFileSync(src, dst);
|
|
89
|
+
try { fs.utimesSync(dst, srcStat.atime, srcStat.mtime); } catch {}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Pass 0: bootstrap — capture auto-memory files missing/newer in .cairn/memory/auto-memory/
|
|
93
|
+
// (catches files that pre-date the forward-sync hook install)
|
|
94
|
+
// Skip files present in canonical top-level: they are mirrored from cairn_memo, not auto-memory.
|
|
95
|
+
const claudeFiles = fs.readdirSync(memoryDir);
|
|
96
|
+
let bootstrapped = 0;
|
|
97
|
+
if (claudeFiles.length > 0) {
|
|
98
|
+
fs.mkdirSync(cairnAutoMemory, { recursive: true });
|
|
99
|
+
for (const f of claudeFiles) {
|
|
100
|
+
if (fs.existsSync(path.join(cairnMemory, f))) continue;
|
|
101
|
+
const src = path.join(memoryDir, f);
|
|
102
|
+
let srcStat;
|
|
103
|
+
try { srcStat = fs.statSync(src); } catch { continue; }
|
|
104
|
+
if (!srcStat.isFile()) continue;
|
|
105
|
+
const dst = path.join(cairnAutoMemory, f);
|
|
106
|
+
let needCopy = !fs.existsSync(dst);
|
|
107
|
+
if (!needCopy) {
|
|
108
|
+
try { needCopy = srcStat.mtimeMs > fs.statSync(dst).mtimeMs; } catch { needCopy = true; }
|
|
109
|
+
}
|
|
110
|
+
if (needCopy) {
|
|
111
|
+
copyWithMtime(src, dst, srcStat);
|
|
112
|
+
bootstrapped++;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (bootstrapped > 0) {
|
|
117
|
+
process.stdout.write(\`[cairn] Bootstrapped \${bootstrapped} auto-memory file(s) into .cairn/memory/auto-memory/\\n\`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Pass 1: new-machine restore from auto-memory backup (only when target empty)
|
|
121
|
+
if (fs.existsSync(cairnAutoMemory)) {
|
|
122
|
+
const targetEmpty = !fs.readdirSync(memoryDir).some(f => f.endsWith('.md'));
|
|
123
|
+
if (targetEmpty) {
|
|
124
|
+
let restored = 0;
|
|
125
|
+
for (const f of fs.readdirSync(cairnAutoMemory)) {
|
|
126
|
+
const src = path.join(cairnAutoMemory, f);
|
|
127
|
+
let srcStat;
|
|
128
|
+
try { srcStat = fs.statSync(src); } catch { continue; }
|
|
129
|
+
if (!srcStat.isFile()) continue;
|
|
130
|
+
copyWithMtime(src, path.join(memoryDir, f), srcStat);
|
|
131
|
+
restored++;
|
|
132
|
+
}
|
|
133
|
+
if (restored > 0) {
|
|
134
|
+
process.stdout.write(\`[cairn] Restored \${restored} memory files from .cairn/memory/auto-memory/ (new project location detected)\\n\`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Pass 2: mtime-mirror top-level .cairn/memory/*.md -> Claude memory dir
|
|
140
|
+
let synced = 0;
|
|
141
|
+
for (const f of fs.readdirSync(cairnMemory)) {
|
|
142
|
+
const src = path.join(cairnMemory, f);
|
|
143
|
+
let srcStat;
|
|
144
|
+
try { srcStat = fs.statSync(src); } catch { continue; }
|
|
145
|
+
if (!srcStat.isFile()) continue; // skip subdirs like auto-memory/
|
|
146
|
+
const dst = path.join(memoryDir, f);
|
|
147
|
+
let needCopy = !fs.existsSync(dst);
|
|
148
|
+
if (!needCopy) {
|
|
149
|
+
try { needCopy = srcStat.mtimeMs > fs.statSync(dst).mtimeMs; } catch { needCopy = true; }
|
|
150
|
+
}
|
|
151
|
+
if (needCopy) {
|
|
152
|
+
copyWithMtime(src, dst, srcStat);
|
|
153
|
+
synced++;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (synced > 0) {
|
|
157
|
+
process.stdout.write(\`[cairn] Synced \${synced} memory file(s) from .cairn/memory/ to Claude memory dir\\n\`);
|
|
158
|
+
}
|
|
102
159
|
`;
|
|
103
160
|
|
|
104
161
|
process.on('uncaughtException', (e) => {
|
|
@@ -479,6 +536,82 @@ if (subcommand === '--version' || subcommand === '-v') {
|
|
|
479
536
|
fs.writeFileSync(lockPath, new Date().toISOString(), 'utf8');
|
|
480
537
|
process.exit(0);
|
|
481
538
|
|
|
539
|
+
} else if (subcommand === 'roadmap-hint') {
|
|
540
|
+
// Stop hook: print the roadmap tree + cursor only when a cursor is set.
|
|
541
|
+
// Silent (exit 0) for projects with no .cairn, no DB, or no cursor — so
|
|
542
|
+
// normal chat is unaffected. Activates the moment cairn_todos focus is called.
|
|
543
|
+
const cairnDir = path.join(process.cwd(), '.cairn');
|
|
544
|
+
const dbPath = path.join(cairnDir, 'index.db');
|
|
545
|
+
if (!fs.existsSync(dbPath)) process.exit(0);
|
|
546
|
+
|
|
547
|
+
let DatabaseSync;
|
|
548
|
+
try { ({ DatabaseSync } = await import('node:sqlite')); } catch { process.exit(0); }
|
|
549
|
+
|
|
550
|
+
let db;
|
|
551
|
+
try { db = new DatabaseSync(dbPath, { readonly: true }); } catch { process.exit(0); }
|
|
552
|
+
|
|
553
|
+
try {
|
|
554
|
+
const cursorRow = db.prepare(
|
|
555
|
+
"SELECT todo_id FROM roadmap_cursor WHERE singleton = 1"
|
|
556
|
+
).get();
|
|
557
|
+
const cursorId = cursorRow?.todo_id ?? null;
|
|
558
|
+
if (cursorId == null) { db.close(); process.exit(0); }
|
|
559
|
+
|
|
560
|
+
const all = db.prepare(
|
|
561
|
+
'SELECT id, parent_id, position, status, text FROM todos ORDER BY IFNULL(parent_id, -1), position, id'
|
|
562
|
+
).all();
|
|
563
|
+
db.close();
|
|
564
|
+
|
|
565
|
+
const STATUS_BOX = { open: '[ ]', in_progress: '[~]', done: '[x]', blocked: '[!]' };
|
|
566
|
+
const childrenByParent = new Map();
|
|
567
|
+
for (const t of all) {
|
|
568
|
+
const key = t.parent_id ?? null;
|
|
569
|
+
if (!childrenByParent.has(key)) childrenByParent.set(key, []);
|
|
570
|
+
childrenByParent.get(key).push(t);
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const lines = [];
|
|
574
|
+
const visit = (parentId, depth) => {
|
|
575
|
+
for (const t of childrenByParent.get(parentId) || []) {
|
|
576
|
+
const arrow = cursorId === t.id ? ' →' : '';
|
|
577
|
+
lines.push(`${' '.repeat(depth)}${STATUS_BOX[t.status] || '[?]'}${arrow} ${t.text} (id:${t.id})`);
|
|
578
|
+
visit(t.id, depth + 1);
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
visit(null, 0);
|
|
582
|
+
|
|
583
|
+
// Compute "Next" via DFS: first open child of cursor → next open sibling/uncle.
|
|
584
|
+
const isOpenish = (s) => s === 'open' || s === 'in_progress';
|
|
585
|
+
const findNext = (fromId) => {
|
|
586
|
+
const kids = (childrenByParent.get(fromId) || []).filter(t => isOpenish(t.status));
|
|
587
|
+
if (kids.length > 0) return kids[0];
|
|
588
|
+
let curId = fromId;
|
|
589
|
+
while (curId != null) {
|
|
590
|
+
const cur = all.find(t => t.id === curId);
|
|
591
|
+
if (!cur) return null;
|
|
592
|
+
const siblings = (childrenByParent.get(cur.parent_id ?? null) || [])
|
|
593
|
+
.filter(t => isOpenish(t.status) && (t.position > cur.position || (t.position === cur.position && t.id > cur.id)));
|
|
594
|
+
if (siblings.length > 0) return siblings[0];
|
|
595
|
+
curId = cur.parent_id;
|
|
596
|
+
}
|
|
597
|
+
return null;
|
|
598
|
+
};
|
|
599
|
+
const cursorNode = all.find(t => t.id === cursorId);
|
|
600
|
+
const next = findNext(cursorId);
|
|
601
|
+
|
|
602
|
+
const out = [
|
|
603
|
+
'[cairn] ROADMAP — cursor is active. Tree:',
|
|
604
|
+
...lines.map(l => '[cairn] ' + l),
|
|
605
|
+
cursorNode ? `[cairn] Current: (id:${cursorNode.id}) ${cursorNode.text}` : '',
|
|
606
|
+
next ? `[cairn] Next: (id:${next.id}) ${next.text}` : '[cairn] Next: (no open todos remaining)',
|
|
607
|
+
'[cairn] Use cairn_todos resolve <id> to mark done (auto-advances), focus <id> to move cursor, or next to advance manually.',
|
|
608
|
+
].filter(Boolean);
|
|
609
|
+
process.stdout.write(out.join('\n') + '\n');
|
|
610
|
+
} catch {
|
|
611
|
+
try { db?.close(); } catch {}
|
|
612
|
+
}
|
|
613
|
+
process.exit(0);
|
|
614
|
+
|
|
482
615
|
} else if (subcommand === 'install') {
|
|
483
616
|
// One-shot setup: register MCP server in ~/.claude.json + install global hooks.
|
|
484
617
|
const platform = process.platform;
|
|
@@ -509,10 +642,11 @@ if (subcommand === '--version' || subcommand === '-v') {
|
|
|
509
642
|
console.log(' PreToolUse[Read] -> cairn minify (compress source files for reading)');
|
|
510
643
|
console.log(' PreToolUse[Edit] -> cairn edit-guard (block Edit until re-read with full content)')
|
|
511
644
|
console.log(' PreToolUse[Write] -> cairn edit-guard (clear minify state before full file overwrite)');
|
|
512
|
-
console.log(' PostToolUse[Write] -> sync-memory-forward.
|
|
645
|
+
console.log(' PostToolUse[Write] -> sync-memory-forward.mjs (sync ~/.claude/memory -> .cairn/memory/auto-memory/)');
|
|
513
646
|
console.log(' Stop -> cairn checkpoint --auto (auto-save session)');
|
|
647
|
+
console.log(' Stop -> cairn roadmap-hint (re-print roadmap when cursor is active)');
|
|
514
648
|
console.log(' UserPromptSubmit -> cairn resume-hint (remind Claude of prior session)');
|
|
515
|
-
console.log(' UserPromptSubmit -> sync-memory-restore.
|
|
649
|
+
console.log(' UserPromptSubmit -> sync-memory-restore.mjs (sync .cairn/memory/ -> ~/.claude/memory, mtime-based)');
|
|
516
650
|
console.log('');
|
|
517
651
|
console.log('Restart Claude Code to activate the MCP server, then open any project and start working.');
|
|
518
652
|
console.log('Upgrade any time with: npm install -g @misterhuydo/cairn-mcp');
|
|
@@ -532,17 +666,18 @@ if (subcommand === '--version' || subcommand === '-v') {
|
|
|
532
666
|
console.log(' PreToolUse[Edit] -> cairn edit-guard (block Edit until re-read with full content)')
|
|
533
667
|
console.log(' PreToolUse[Write] -> cairn edit-guard (clear minify state before full file overwrite)');
|
|
534
668
|
if (isGlobal) {
|
|
535
|
-
console.log(' PostToolUse[Write] -> sync-memory-forward.
|
|
669
|
+
console.log(' PostToolUse[Write] -> sync-memory-forward.mjs (sync ~/.claude/memory -> .cairn/memory/auto-memory/)');
|
|
536
670
|
}
|
|
537
671
|
console.log(' Stop -> cairn checkpoint --auto (auto-save session)');
|
|
672
|
+
console.log(' Stop -> cairn roadmap-hint (re-print roadmap when cursor is active)');
|
|
538
673
|
console.log(' UserPromptSubmit -> cairn resume-hint (remind Claude of prior session)');
|
|
539
674
|
if (isGlobal) {
|
|
540
|
-
console.log(' UserPromptSubmit -> sync-memory-restore.
|
|
675
|
+
console.log(' UserPromptSubmit -> sync-memory-restore.mjs (sync .cairn/memory/ -> ~/.claude/memory, mtime-based)');
|
|
541
676
|
}
|
|
542
677
|
process.exit(0);
|
|
543
678
|
|
|
544
679
|
} else {
|
|
545
|
-
console.error('Usage: cairn <--version | install | install-hooks [--global] | minify | edit-guard | validate-map | checkpoint --auto | resume-hint>');
|
|
680
|
+
console.error('Usage: cairn <--version | install | install-hooks [--global] | minify | edit-guard | validate-map | checkpoint --auto | resume-hint | roadmap-hint>');
|
|
546
681
|
process.exit(1);
|
|
547
682
|
}
|
|
548
683
|
|
|
@@ -575,36 +710,58 @@ function applyHooks(settingsDir, isGlobal) {
|
|
|
575
710
|
const stopHooks = settings.hooks.Stop || [];
|
|
576
711
|
if (!stopHooks.some(h => h.hooks?.some(hh => hh.command === 'cairn checkpoint --auto'))) {
|
|
577
712
|
stopHooks.push({ hooks: [{ type: 'command', command: 'cairn checkpoint --auto' }] });
|
|
578
|
-
settings.hooks.Stop = stopHooks;
|
|
579
713
|
}
|
|
714
|
+
// Stop: cairn roadmap-hint (silent unless a roadmap_cursor is set)
|
|
715
|
+
if (!stopHooks.some(h => h.hooks?.some(hh => hh.command === 'cairn roadmap-hint'))) {
|
|
716
|
+
stopHooks.push({ hooks: [{ type: 'command', command: 'cairn roadmap-hint' }] });
|
|
717
|
+
}
|
|
718
|
+
settings.hooks.Stop = stopHooks;
|
|
580
719
|
|
|
581
720
|
// UserPromptSubmit: cairn resume-hint
|
|
582
|
-
|
|
721
|
+
let submitHooks = settings.hooks.UserPromptSubmit || [];
|
|
583
722
|
if (!submitHooks.some(h => h.hooks?.some(hh => hh.command === 'cairn resume-hint'))) {
|
|
584
723
|
submitHooks.push({ hooks: [{ type: 'command', command: 'cairn resume-hint' }] });
|
|
585
724
|
}
|
|
586
725
|
|
|
587
726
|
if (isGlobal) {
|
|
588
|
-
// Deploy memory sync scripts to ~/.claude/scripts/
|
|
727
|
+
// Deploy memory sync scripts to ~/.claude/scripts/ (Node — cross-platform, no python3 required)
|
|
589
728
|
const scriptsDir = path.join(settingsDir, 'scripts');
|
|
590
729
|
fs.mkdirSync(scriptsDir, { recursive: true });
|
|
591
|
-
const forwardScript = path.join(scriptsDir, 'sync-memory-forward.
|
|
592
|
-
const restoreScript = path.join(scriptsDir, 'sync-memory-restore.
|
|
730
|
+
const forwardScript = path.join(scriptsDir, 'sync-memory-forward.mjs');
|
|
731
|
+
const restoreScript = path.join(scriptsDir, 'sync-memory-restore.mjs');
|
|
593
732
|
fs.writeFileSync(forwardScript, SYNC_FORWARD_SCRIPT, 'utf8');
|
|
594
733
|
fs.writeFileSync(restoreScript, SYNC_RESTORE_SCRIPT, 'utf8');
|
|
595
734
|
|
|
735
|
+
// Clean up legacy Python scripts from prior installs
|
|
736
|
+
for (const legacy of ['sync-memory-forward.py', 'sync-memory-restore.py']) {
|
|
737
|
+
const p = path.join(scriptsDir, legacy);
|
|
738
|
+
if (fs.existsSync(p)) { try { fs.unlinkSync(p); } catch {} }
|
|
739
|
+
}
|
|
740
|
+
|
|
596
741
|
// Use forward slashes in hook commands — works across bash on Windows and Unix
|
|
597
|
-
const
|
|
598
|
-
const
|
|
742
|
+
const scriptsDirFwd = scriptsDir.replace(/\\/g, '/');
|
|
743
|
+
const fwdCmd = `node "${scriptsDirFwd}/sync-memory-forward.mjs"`;
|
|
744
|
+
const rstCmd = `node "${scriptsDirFwd}/sync-memory-restore.mjs"`;
|
|
745
|
+
|
|
746
|
+
// Strip stale python3 .py references from prior installs (avoids duplicate hooks firing)
|
|
747
|
+
const isLegacyPyCmd = (cmd) => typeof cmd === 'string' &&
|
|
748
|
+
/python3?\s+.*sync-memory-(forward|restore)\.py/.test(cmd);
|
|
749
|
+
const stripLegacy = (entries) => entries
|
|
750
|
+
.map(entry => ({
|
|
751
|
+
...entry,
|
|
752
|
+
hooks: (entry.hooks || []).filter(h => !isLegacyPyCmd(h.command)),
|
|
753
|
+
}))
|
|
754
|
+
.filter(entry => entry.hooks.length > 0);
|
|
599
755
|
|
|
600
756
|
// PostToolUse[Write]: forward sync Claude memory -> .cairn/memory/auto-memory/
|
|
601
|
-
|
|
757
|
+
let postHooks = stripLegacy(settings.hooks.PostToolUse || []);
|
|
602
758
|
if (!postHooks.some(h => h.matcher === 'Write' && h.hooks?.some(hh => hh.command === fwdCmd))) {
|
|
603
759
|
postHooks.push({ matcher: 'Write', hooks: [{ type: 'command', command: fwdCmd }] });
|
|
604
760
|
}
|
|
605
761
|
settings.hooks.PostToolUse = postHooks;
|
|
606
762
|
|
|
607
763
|
// UserPromptSubmit: sync .cairn/memory/ -> Claude memory dir (always, mtime-based)
|
|
764
|
+
submitHooks = stripLegacy(submitHooks);
|
|
608
765
|
if (!submitHooks.some(h => h.hooks?.some(hh => hh.command === rstCmd))) {
|
|
609
766
|
submitHooks.push({ hooks: [{ type: 'command', command: rstCmd }] });
|
|
610
767
|
}
|
package/index.js
CHANGED
|
@@ -262,20 +262,23 @@ Use to: get a project-wide bird's-eye view, triage code quality, find where to l
|
|
|
262
262
|
},
|
|
263
263
|
{
|
|
264
264
|
name: 'cairn_todos',
|
|
265
|
-
description: 'Manage TODO/FIXME/HACK/XXX items.
|
|
265
|
+
description: 'Manage TODO/FIXME/HACK/XXX items and multi-phase roadmaps. Supports a tree of nested phases with a "current cursor" pointer so multi-phase plans survive context drift. Use action=tree to render the roadmap, focus to set the cursor, next to advance. Stats are included in cairn_maintain and cairn_resume reports.',
|
|
266
266
|
inputSchema: {
|
|
267
267
|
type: 'object',
|
|
268
268
|
properties: {
|
|
269
269
|
action: {
|
|
270
270
|
type: 'string',
|
|
271
|
-
enum: ['list', 'add', 'resolve', 'scan'],
|
|
272
|
-
description: 'list:
|
|
271
|
+
enum: ['list', 'tree', 'add', 'resolve', 'focus', 'next', 'set_status', 'scan'],
|
|
272
|
+
description: 'list: flat list | tree: hierarchical roadmap with cursor | add: create manual todo (use parent + position for nesting) | resolve: mark done | focus: move cursor to id, mark in_progress, propagate up | next: DFS-advance cursor to next open node | set_status: set status to open|in_progress|done|blocked | scan: re-scan codebase',
|
|
273
273
|
},
|
|
274
|
-
text:
|
|
275
|
-
kind:
|
|
276
|
-
id:
|
|
277
|
-
status:
|
|
278
|
-
source:
|
|
274
|
+
text: { type: 'string', description: 'Text for the new todo (add action)' },
|
|
275
|
+
kind: { type: 'string', enum: ['TODO', 'FIXME', 'HACK', 'XXX', 'NOTE'], description: 'Kind for new todo (default: TODO)' },
|
|
276
|
+
id: { type: 'number', description: 'Todo ID (resolve, focus, set_status)' },
|
|
277
|
+
status: { type: 'string', enum: ['open', 'in_progress', 'done', 'blocked'], description: 'Filter (list) or new value (set_status)' },
|
|
278
|
+
source: { type: 'string', enum: ['scan', 'manual'], description: 'Filter by source (list action)' },
|
|
279
|
+
parent: { type: 'number', description: 'Parent todo ID for nesting (add action). Omit for root/phase.' },
|
|
280
|
+
position: { type: 'number', description: 'Sibling order within parent (add action). Auto-appends if omitted.' },
|
|
281
|
+
compact: { type: 'boolean', description: 'Compact tree render — strips id/kind tags (tree action)' },
|
|
279
282
|
},
|
|
280
283
|
required: ['action'],
|
|
281
284
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@misterhuydo/cairn-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
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/db.js
CHANGED
|
@@ -67,9 +67,16 @@ const SCHEMA = `
|
|
|
67
67
|
kind TEXT NOT NULL DEFAULT 'TODO',
|
|
68
68
|
text TEXT NOT NULL,
|
|
69
69
|
status TEXT NOT NULL DEFAULT 'open',
|
|
70
|
+
parent_id INTEGER REFERENCES todos(id) ON DELETE SET NULL,
|
|
71
|
+
position INTEGER NOT NULL DEFAULT 0,
|
|
70
72
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
71
73
|
resolved_at TEXT
|
|
72
74
|
);
|
|
75
|
+
|
|
76
|
+
CREATE TABLE IF NOT EXISTS roadmap_cursor (
|
|
77
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
78
|
+
todo_id INTEGER REFERENCES todos(id) ON DELETE SET NULL
|
|
79
|
+
);
|
|
73
80
|
`;
|
|
74
81
|
|
|
75
82
|
// Each sub-DB gets its IDs offset by this multiplier to prevent collisions
|
|
@@ -190,6 +197,24 @@ export function openDB() {
|
|
|
190
197
|
db.exec(SCHEMA);
|
|
191
198
|
// Migration: add line column to existing DBs that predate v1.7.0
|
|
192
199
|
try { db.exec('ALTER TABLE main.symbols ADD COLUMN line INTEGER'); } catch {}
|
|
200
|
+
// Migration: roadmap tree columns on todos (idempotent — fails silently if present)
|
|
201
|
+
let addedRoadmapCols = false;
|
|
202
|
+
try { db.exec('ALTER TABLE main.todos ADD COLUMN parent_id INTEGER REFERENCES todos(id) ON DELETE SET NULL'); addedRoadmapCols = true; } catch {}
|
|
203
|
+
try { db.exec('ALTER TABLE main.todos ADD COLUMN position INTEGER NOT NULL DEFAULT 0'); addedRoadmapCols = true; } catch {}
|
|
204
|
+
if (addedRoadmapCols) {
|
|
205
|
+
// Backfill position by id order within each parent group (null parent = root).
|
|
206
|
+
// ROW_NUMBER() requires SQLite 3.25+, which node:sqlite ships.
|
|
207
|
+
db.exec(`
|
|
208
|
+
UPDATE main.todos AS t
|
|
209
|
+
SET position = (
|
|
210
|
+
SELECT rn - 1 FROM (
|
|
211
|
+
SELECT id, ROW_NUMBER() OVER (
|
|
212
|
+
PARTITION BY IFNULL(parent_id, -1) ORDER BY id
|
|
213
|
+
) AS rn FROM main.todos
|
|
214
|
+
) ord WHERE ord.id = t.id
|
|
215
|
+
)
|
|
216
|
+
`);
|
|
217
|
+
}
|
|
193
218
|
mountParentSubIndexes(db);
|
|
194
219
|
refreshFederatedViews(db);
|
|
195
220
|
return db;
|
package/src/tools/checkpoint.js
CHANGED
|
@@ -47,14 +47,19 @@ export function checkpoint(_db, { message, active_files = [], notes = [] }) {
|
|
|
47
47
|
const sessionPath = path.join(cairnDir, 'session.json');
|
|
48
48
|
const dateStr = new Date().toISOString().slice(0, 10);
|
|
49
49
|
|
|
50
|
-
// Snapshot mtimes of
|
|
50
|
+
// Snapshot mtimes of all indexed files so resume can detect changes accurately.
|
|
51
|
+
// Without a full snapshot, resume labels every non-active file as "new" and
|
|
52
|
+
// triggers a full reindex on every call.
|
|
51
53
|
const mtimeSnapshot = {};
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
// File may not exist yet
|
|
54
|
+
try {
|
|
55
|
+
const rows = _db.prepare('SELECT path FROM main.files').all();
|
|
56
|
+
for (const { path: filePath } of rows) {
|
|
57
|
+
try { mtimeSnapshot[filePath] = fs.statSync(filePath).mtimeMs; } catch { }
|
|
57
58
|
}
|
|
59
|
+
} catch { /* DB may not exist on first run */ }
|
|
60
|
+
// Also include active_files in case they are not yet indexed
|
|
61
|
+
for (const filePath of active_files) {
|
|
62
|
+
try { mtimeSnapshot[filePath] = fs.statSync(filePath).mtimeMs; } catch { }
|
|
58
63
|
}
|
|
59
64
|
|
|
60
65
|
// Process all notes: long strings get filed, short ones stay inline, refs pass through
|
package/src/tools/resume.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
|
-
import {
|
|
3
|
+
import { exec } from 'child_process';
|
|
4
|
+
import { promisify } from 'util';
|
|
5
|
+
const execAsync = promisify(exec);
|
|
4
6
|
import { getCairnDir, getProjectRoot, getMemoryDir } from '../graph/cwd.js';
|
|
5
7
|
import { walkRepo } from '../indexer/fileWalker.js';
|
|
6
8
|
import { getAttachedSchemas } from '../graph/db.js';
|
|
@@ -125,11 +127,47 @@ export async function resume(db) {
|
|
|
125
127
|
// Count open todos for status report
|
|
126
128
|
let openTodos = 0;
|
|
127
129
|
try {
|
|
128
|
-
openTodos = db.prepare(
|
|
130
|
+
openTodos = db.prepare("SELECT COUNT(*) AS n FROM main.todos WHERE status IN ('open', 'in_progress')").get().n;
|
|
129
131
|
} catch {
|
|
130
132
|
// todos table not yet present in older indexes; ignore
|
|
131
133
|
}
|
|
132
134
|
|
|
135
|
+
// Roadmap surfacing: render compact tree if a cursor is set or any todo is nested.
|
|
136
|
+
let roadmap = null;
|
|
137
|
+
try {
|
|
138
|
+
const cursorRow = db.prepare('SELECT todo_id FROM main.roadmap_cursor WHERE singleton = 1').get();
|
|
139
|
+
const cursorId = cursorRow?.todo_id ?? null;
|
|
140
|
+
const hasNested = db.prepare('SELECT 1 FROM main.todos WHERE parent_id IS NOT NULL LIMIT 1').get();
|
|
141
|
+
if (cursorId != null || hasNested) {
|
|
142
|
+
const all = db.prepare(
|
|
143
|
+
'SELECT id, parent_id, position, status, text FROM main.todos ORDER BY IFNULL(parent_id, -1), position, id'
|
|
144
|
+
).all();
|
|
145
|
+
const STATUS_BOX = { open: '[ ]', in_progress: '[~]', done: '[x]', blocked: '[!]' };
|
|
146
|
+
const childrenByParent = new Map();
|
|
147
|
+
for (const t of all) {
|
|
148
|
+
const key = t.parent_id ?? null;
|
|
149
|
+
if (!childrenByParent.has(key)) childrenByParent.set(key, []);
|
|
150
|
+
childrenByParent.get(key).push(t);
|
|
151
|
+
}
|
|
152
|
+
const lines = [];
|
|
153
|
+
const visit = (parentId, depth) => {
|
|
154
|
+
for (const t of childrenByParent.get(parentId) || []) {
|
|
155
|
+
const arrow = cursorId === t.id ? ' →' : '';
|
|
156
|
+
lines.push(`${' '.repeat(depth)}${STATUS_BOX[t.status] || '[?]'}${arrow} ${t.text} (id:${t.id})`);
|
|
157
|
+
visit(t.id, depth + 1);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
visit(null, 0);
|
|
161
|
+
const cursorFull = cursorId ? db.prepare('SELECT id, text FROM main.todos WHERE id = ?').get(cursorId) : null;
|
|
162
|
+
roadmap = {
|
|
163
|
+
tree: lines.join('\n'),
|
|
164
|
+
cursor: cursorFull ? { id: cursorFull.id, text: cursorFull.text } : null,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
} catch {
|
|
168
|
+
// roadmap_cursor table missing on very old indexes; ignore
|
|
169
|
+
}
|
|
170
|
+
|
|
133
171
|
// Build human-readable resume summary
|
|
134
172
|
const changedSummary = changed.length > 0
|
|
135
173
|
? changed.slice(0, 10).map(c => `${c.change}: ${path.relative(getProjectRoot(), c.file).replace(/\\/g, '/')}`).join(', ')
|
|
@@ -141,6 +179,7 @@ export async function resume(db) {
|
|
|
141
179
|
changed.length > 0 ? `Files changed since checkpoint: ${changedSummary}` : 'No files changed since checkpoint.',
|
|
142
180
|
subSchemas.length > 0 ? `Federated sub-indexes: ${subSchemas.length}` : '',
|
|
143
181
|
openTodos > 0 ? `Open todos: ${openTodos}` : '',
|
|
182
|
+
roadmap?.cursor ? `Roadmap cursor → (id:${roadmap.cursor.id}) ${roadmap.cursor.text}` : '',
|
|
144
183
|
session.notes?.length > 0 ? `Notes: ${session.notes.map(n => (n && typeof n === 'object' && n.ref) ? `${n.description} (see: ${n.ref})` : String(n)).join(' | ')}` : '',
|
|
145
184
|
].filter(Boolean).join(' ');
|
|
146
185
|
|
|
@@ -168,32 +207,31 @@ export async function resume(db) {
|
|
|
168
207
|
resume_summary: resumeSummary,
|
|
169
208
|
};
|
|
170
209
|
if (gitignoreHint) result.gitignore_hint = gitignoreHint;
|
|
210
|
+
if (roadmap) result.roadmap = roadmap;
|
|
171
211
|
if (gitSnapshotNotes.length > 0) {
|
|
172
212
|
result.migrated = `Removed ${gitSnapshotNotes.length} legacy git-snapshot note(s). Original session backed up to session.bak.json.`;
|
|
173
213
|
}
|
|
174
214
|
|
|
175
|
-
// Git state —
|
|
176
|
-
// regardless of checkpoint staleness.
|
|
215
|
+
// Git state — run all three queries in parallel to avoid sequential git startup overhead.
|
|
177
216
|
const gitRoot = getProjectRoot();
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
})
|
|
217
|
+
const since = new Date(session.checkpoint_at).toISOString();
|
|
218
|
+
const [logResult, diffResult, statusResult] = await Promise.allSettled([
|
|
219
|
+
execAsync(`git log --oneline --after="${since}"`, { cwd: gitRoot, timeout: 5000 }),
|
|
220
|
+
execAsync('git diff --name-only HEAD', { cwd: gitRoot, timeout: 5000 }),
|
|
221
|
+
execAsync('git status --short', { cwd: gitRoot, timeout: 5000 }),
|
|
222
|
+
]);
|
|
223
|
+
if (logResult.status === 'fulfilled') {
|
|
224
|
+
const raw = logResult.value.stdout.trim();
|
|
183
225
|
if (raw) result.commits_since_checkpoint = raw.split('\n');
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const raw =
|
|
187
|
-
cwd: gitRoot, encoding: 'utf8', timeout: 5000,
|
|
188
|
-
}).trim();
|
|
226
|
+
}
|
|
227
|
+
if (diffResult.status === 'fulfilled') {
|
|
228
|
+
const raw = diffResult.value.stdout.trim();
|
|
189
229
|
if (raw) result.uncommitted_changes = raw.split('\n');
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
const raw =
|
|
193
|
-
cwd: gitRoot, encoding: 'utf8', timeout: 5000,
|
|
194
|
-
}).trim();
|
|
230
|
+
}
|
|
231
|
+
if (statusResult.status === 'fulfilled') {
|
|
232
|
+
const raw = statusResult.value.stdout.trim();
|
|
195
233
|
if (raw) result.git_status = raw;
|
|
196
|
-
}
|
|
234
|
+
}
|
|
197
235
|
|
|
198
236
|
if (relocation) result.relocated = relocation;
|
|
199
237
|
if (preferences.length > 0) {
|
package/src/tools/todos.js
CHANGED
|
@@ -1,8 +1,113 @@
|
|
|
1
1
|
import { walkRepo } from '../indexer/fileWalker.js';
|
|
2
2
|
import { scanTodos } from '../indexer/todoScanner.js';
|
|
3
3
|
|
|
4
|
+
const STATUS_BOX = {
|
|
5
|
+
open: '[ ]',
|
|
6
|
+
in_progress: '[~]',
|
|
7
|
+
done: '[x]',
|
|
8
|
+
blocked: '[!]',
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const VALID_STATUS = new Set(['open', 'in_progress', 'done', 'blocked']);
|
|
12
|
+
|
|
13
|
+
function getCursor(db) {
|
|
14
|
+
const row = db.prepare('SELECT todo_id FROM main.roadmap_cursor WHERE singleton = 1').get();
|
|
15
|
+
return row?.todo_id ?? null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function setCursor(db, todoId) {
|
|
19
|
+
db.prepare('INSERT OR REPLACE INTO main.roadmap_cursor (singleton, todo_id) VALUES (1, ?)').run(todoId);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function clearCursor(db) {
|
|
23
|
+
db.prepare('DELETE FROM main.roadmap_cursor WHERE singleton = 1').run();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Mark ancestors as in_progress (only if currently 'open' — don't downgrade done/blocked).
|
|
27
|
+
function propagateInProgressUp(db, todoId) {
|
|
28
|
+
const stmt = db.prepare('SELECT parent_id, status FROM main.todos WHERE id = ?');
|
|
29
|
+
const update = db.prepare("UPDATE main.todos SET status = 'in_progress' WHERE id = ? AND status = 'open'");
|
|
30
|
+
let cur = stmt.get(todoId);
|
|
31
|
+
while (cur && cur.parent_id != null) {
|
|
32
|
+
update.run(cur.parent_id);
|
|
33
|
+
cur = stmt.get(cur.parent_id);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// DFS-find next actionable node starting at/after `fromId`.
|
|
38
|
+
// Order: first open child → next open sibling → next open uncle (walk up).
|
|
39
|
+
function findNextOpen(db, fromId) {
|
|
40
|
+
const childOf = (pid) => db.prepare(
|
|
41
|
+
`SELECT id FROM main.todos
|
|
42
|
+
WHERE ${pid == null ? 'parent_id IS NULL' : 'parent_id = ?'}
|
|
43
|
+
AND status IN ('open', 'in_progress')
|
|
44
|
+
ORDER BY position, id`
|
|
45
|
+
).all(...(pid == null ? [] : [pid]));
|
|
46
|
+
|
|
47
|
+
if (fromId == null) {
|
|
48
|
+
const roots = childOf(null);
|
|
49
|
+
return roots[0]?.id ?? null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const children = childOf(fromId);
|
|
53
|
+
if (children.length > 0) return children[0].id;
|
|
54
|
+
|
|
55
|
+
let curId = fromId;
|
|
56
|
+
while (curId != null) {
|
|
57
|
+
const cur = db.prepare('SELECT parent_id, position FROM main.todos WHERE id = ?').get(curId);
|
|
58
|
+
if (!cur) return null;
|
|
59
|
+
const sibling = db.prepare(
|
|
60
|
+
`SELECT id FROM main.todos
|
|
61
|
+
WHERE ${cur.parent_id == null ? 'parent_id IS NULL' : 'parent_id = ?'}
|
|
62
|
+
AND (position > ? OR (position = ? AND id > ?))
|
|
63
|
+
AND status IN ('open', 'in_progress')
|
|
64
|
+
ORDER BY position, id LIMIT 1`
|
|
65
|
+
).get(...(cur.parent_id == null ? [cur.position, cur.position, curId] : [cur.parent_id, cur.position, cur.position, curId]));
|
|
66
|
+
if (sibling) return sibling.id;
|
|
67
|
+
curId = cur.parent_id;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function renderTree(db, { compact = false } = {}) {
|
|
73
|
+
const all = db.prepare(
|
|
74
|
+
'SELECT id, parent_id, position, status, kind, text FROM main.todos ORDER BY IFNULL(parent_id, -1), position, id'
|
|
75
|
+
).all();
|
|
76
|
+
if (all.length === 0) return '(no todos)';
|
|
77
|
+
|
|
78
|
+
const cursor = getCursor(db);
|
|
79
|
+
const childrenByParent = new Map();
|
|
80
|
+
for (const t of all) {
|
|
81
|
+
const key = t.parent_id ?? null;
|
|
82
|
+
if (!childrenByParent.has(key)) childrenByParent.set(key, []);
|
|
83
|
+
childrenByParent.get(key).push(t);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const lines = [];
|
|
87
|
+
const visit = (parentId, depth) => {
|
|
88
|
+
const kids = childrenByParent.get(parentId) || [];
|
|
89
|
+
for (const t of kids) {
|
|
90
|
+
const box = STATUS_BOX[t.status] || '[?]';
|
|
91
|
+
const arrow = cursor === t.id ? ' →' : '';
|
|
92
|
+
const tag = compact ? '' : ` (id:${t.id}${t.kind !== 'TODO' ? `, ${t.kind}` : ''})`;
|
|
93
|
+
lines.push(`${' '.repeat(depth)}${box}${arrow} ${t.text}${tag}`);
|
|
94
|
+
visit(t.id, depth + 1);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
visit(null, 0);
|
|
98
|
+
return lines.join('\n');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function nextPosition(db, parentId) {
|
|
102
|
+
const row = db.prepare(
|
|
103
|
+
`SELECT COALESCE(MAX(position), -1) + 1 AS next FROM main.todos
|
|
104
|
+
WHERE ${parentId == null ? 'parent_id IS NULL' : 'parent_id = ?'}`
|
|
105
|
+
).get(...(parentId == null ? [] : [parentId]));
|
|
106
|
+
return row.next;
|
|
107
|
+
}
|
|
108
|
+
|
|
4
109
|
export async function todos(db, args = {}) {
|
|
5
|
-
const { text, id, status, source } = args;
|
|
110
|
+
const { text, id, status, source, parent, position } = args;
|
|
6
111
|
const action = args.action ?? 'list';
|
|
7
112
|
const kind = args.kind;
|
|
8
113
|
|
|
@@ -13,9 +118,9 @@ export async function todos(db, args = {}) {
|
|
|
13
118
|
if (status) { query += ' AND status = ?'; params.push(status); }
|
|
14
119
|
if (source) { query += ' AND source = ?'; params.push(source); }
|
|
15
120
|
if (kind) { query += ' AND kind = ?'; params.push(kind.toUpperCase()); }
|
|
16
|
-
query += ' ORDER BY CASE status WHEN \'open\' THEN 0 ELSE 1 END, source, file, line';
|
|
121
|
+
query += ' ORDER BY CASE status WHEN \'open\' THEN 0 WHEN \'in_progress\' THEN 0 ELSE 1 END, source, file, line';
|
|
17
122
|
const rows = db.prepare(query).all(...params);
|
|
18
|
-
const open = rows.filter(r => r.status === 'open').length;
|
|
123
|
+
const open = rows.filter(r => r.status === 'open' || r.status === 'in_progress').length;
|
|
19
124
|
const done = rows.filter(r => r.status === 'done').length;
|
|
20
125
|
return {
|
|
21
126
|
content: [{
|
|
@@ -25,12 +130,34 @@ export async function todos(db, args = {}) {
|
|
|
25
130
|
};
|
|
26
131
|
}
|
|
27
132
|
|
|
133
|
+
case 'tree': {
|
|
134
|
+
const compact = args.compact === true;
|
|
135
|
+
const tree = renderTree(db, { compact });
|
|
136
|
+
const cursor = getCursor(db);
|
|
137
|
+
const cursorRow = cursor ? db.prepare('SELECT id, text FROM main.todos WHERE id = ?').get(cursor) : null;
|
|
138
|
+
const next = findNextOpen(db, cursor);
|
|
139
|
+
const nextRow = next ? db.prepare('SELECT id, text FROM main.todos WHERE id = ?').get(next) : null;
|
|
140
|
+
const summary = [
|
|
141
|
+
cursorRow ? `Current: (id:${cursorRow.id}) ${cursorRow.text}` : 'Current: (no cursor set)',
|
|
142
|
+
nextRow && (!cursorRow || nextRow.id !== cursorRow.id) ? `Next: (id:${nextRow.id}) ${nextRow.text}` : '',
|
|
143
|
+
].filter(Boolean).join('\n');
|
|
144
|
+
return {
|
|
145
|
+
content: [{ type: 'text', text: `${tree}\n\n${summary}` }],
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
28
149
|
case 'add': {
|
|
29
150
|
if (!text) throw new Error('text is required for add action');
|
|
30
151
|
const todoKind = (kind || 'TODO').toUpperCase();
|
|
152
|
+
const parentId = parent ?? null;
|
|
153
|
+
if (parentId != null) {
|
|
154
|
+
const exists = db.prepare('SELECT id FROM main.todos WHERE id = ?').get(parentId);
|
|
155
|
+
if (!exists) throw new Error(`Parent todo id ${parentId} does not exist`);
|
|
156
|
+
}
|
|
157
|
+
const pos = position ?? nextPosition(db, parentId);
|
|
31
158
|
const result = db.prepare(
|
|
32
|
-
'INSERT INTO main.todos (source, kind, text) VALUES (?, ?, ?)'
|
|
33
|
-
).run('manual', todoKind, text);
|
|
159
|
+
'INSERT INTO main.todos (source, kind, text, parent_id, position) VALUES (?, ?, ?, ?, ?)'
|
|
160
|
+
).run('manual', todoKind, text, parentId, pos);
|
|
34
161
|
const todo = db.prepare('SELECT * FROM main.todos WHERE id = ?').get(result.lastInsertRowid);
|
|
35
162
|
return {
|
|
36
163
|
content: [{ type: 'text', text: JSON.stringify({ added: todo }, null, 2) }],
|
|
@@ -43,9 +170,63 @@ export async function todos(db, args = {}) {
|
|
|
43
170
|
'UPDATE main.todos SET status = ?, resolved_at = datetime(\'now\') WHERE id = ?'
|
|
44
171
|
).run('done', id);
|
|
45
172
|
if (updated.changes === 0) throw new Error(`No todo found with id ${id}`);
|
|
173
|
+
const cursor = getCursor(db);
|
|
174
|
+
let advanced = null;
|
|
175
|
+
if (cursor === id) {
|
|
176
|
+
const next = findNextOpen(db, id);
|
|
177
|
+
if (next) { setCursor(db, next); advanced = next; }
|
|
178
|
+
else clearCursor(db);
|
|
179
|
+
}
|
|
180
|
+
const todo = db.prepare('SELECT * FROM main.todos WHERE id = ?').get(id);
|
|
181
|
+
return {
|
|
182
|
+
content: [{ type: 'text', text: JSON.stringify({ resolved: todo, cursor_advanced_to: advanced }, null, 2) }],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
case 'focus': {
|
|
187
|
+
if (!id) throw new Error('id is required for focus action');
|
|
188
|
+
const todo = db.prepare('SELECT * FROM main.todos WHERE id = ?').get(id);
|
|
189
|
+
if (!todo) throw new Error(`No todo found with id ${id}`);
|
|
190
|
+
setCursor(db, id);
|
|
191
|
+
db.prepare("UPDATE main.todos SET status = 'in_progress' WHERE id = ? AND status = 'open'").run(id);
|
|
192
|
+
propagateInProgressUp(db, id);
|
|
193
|
+
const refreshed = db.prepare('SELECT * FROM main.todos WHERE id = ?').get(id);
|
|
194
|
+
return {
|
|
195
|
+
content: [{ type: 'text', text: JSON.stringify({ focused: refreshed }, null, 2) }],
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
case 'next': {
|
|
200
|
+
const cursor = getCursor(db);
|
|
201
|
+
const next = findNextOpen(db, cursor);
|
|
202
|
+
if (next == null) {
|
|
203
|
+
clearCursor(db);
|
|
204
|
+
return {
|
|
205
|
+
content: [{ type: 'text', text: JSON.stringify({ cursor: null, message: 'No open todos remaining.' }, null, 2) }],
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
setCursor(db, next);
|
|
209
|
+
propagateInProgressUp(db, next);
|
|
210
|
+
const todo = db.prepare('SELECT * FROM main.todos WHERE id = ?').get(next);
|
|
211
|
+
return {
|
|
212
|
+
content: [{ type: 'text', text: JSON.stringify({ cursor: next, todo }, null, 2) }],
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
case 'set_status': {
|
|
217
|
+
if (!id) throw new Error('id is required for set_status action');
|
|
218
|
+
if (!status) throw new Error('status is required for set_status action');
|
|
219
|
+
if (!VALID_STATUS.has(status)) {
|
|
220
|
+
throw new Error(`Invalid status "${status}". Use: open, in_progress, done, blocked`);
|
|
221
|
+
}
|
|
222
|
+
const resolvedAt = status === 'done' ? "datetime('now')" : 'NULL';
|
|
223
|
+
const updated = db.prepare(
|
|
224
|
+
`UPDATE main.todos SET status = ?, resolved_at = ${resolvedAt} WHERE id = ?`
|
|
225
|
+
).run(status, id);
|
|
226
|
+
if (updated.changes === 0) throw new Error(`No todo found with id ${id}`);
|
|
46
227
|
const todo = db.prepare('SELECT * FROM main.todos WHERE id = ?').get(id);
|
|
47
228
|
return {
|
|
48
|
-
content: [{ type: 'text', text: JSON.stringify({
|
|
229
|
+
content: [{ type: 'text', text: JSON.stringify({ updated: todo }, null, 2) }],
|
|
49
230
|
};
|
|
50
231
|
}
|
|
51
232
|
|
|
@@ -53,7 +234,7 @@ export async function todos(db, args = {}) {
|
|
|
53
234
|
const files = await walkRepo(process.cwd());
|
|
54
235
|
const found = await scanTodos(db, files);
|
|
55
236
|
const open = db.prepare(
|
|
56
|
-
'SELECT COUNT(*) AS n FROM main.todos WHERE status
|
|
237
|
+
'SELECT COUNT(*) AS n FROM main.todos WHERE status IN (\'open\', \'in_progress\')'
|
|
57
238
|
).get().n;
|
|
58
239
|
return {
|
|
59
240
|
content: [{
|
|
@@ -64,6 +245,6 @@ export async function todos(db, args = {}) {
|
|
|
64
245
|
}
|
|
65
246
|
|
|
66
247
|
default:
|
|
67
|
-
throw new Error(`Unknown action: "${action}". Use list, add, resolve, or scan.`);
|
|
248
|
+
throw new Error(`Unknown action: "${action}". Use list, tree, add, resolve, focus, next, set_status, or scan.`);
|
|
68
249
|
}
|
|
69
250
|
}
|