@misterhuydo/cairn-mcp 1.7.1 → 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cairn-cli.js +172 -109
- package/package.json +1 -1
- package/src/tools/checkpoint.js +101 -45
- package/src/tools/relocate.js +16 -7
- package/src/tools/resume.js +46 -20
- package/test/checkpoint.test.js +99 -0
- package/test/relocate.test.js +33 -0
- package/test/resume-migration.test.js +83 -0
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) => {
|
|
@@ -343,21 +400,9 @@ if (subcommand === '--version' || subcommand === '-v') {
|
|
|
343
400
|
.filter(([, e]) => (e.readCount ?? 0) > 0)
|
|
344
401
|
.map(([p]) => p);
|
|
345
402
|
} catch { }
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
|
|
349
|
-
try {
|
|
350
|
-
const stat = execSync('git diff --stat HEAD', {
|
|
351
|
-
cwd: process.cwd(), encoding: 'utf8', timeout: 5000,
|
|
352
|
-
}).trim();
|
|
353
|
-
const status = execSync('git status --short', {
|
|
354
|
-
cwd: process.cwd(), encoding: 'utf8', timeout: 5000,
|
|
355
|
-
}).trim();
|
|
356
|
-
if (stat || status) {
|
|
357
|
-
gitNotes.push(`git-snapshot: ${stat || '(clean)'} | status: ${status || '(clean)'}`);
|
|
358
|
-
}
|
|
359
|
-
} catch { }
|
|
360
|
-
checkpoint(null, { message, active_files: activeFiles, notes: gitNotes });
|
|
403
|
+
// git state is already returned live by cairn_resume (git_status + uncommitted_changes)
|
|
404
|
+
// so there is no value in baking a stale snapshot into the notes array.
|
|
405
|
+
checkpoint(null, { message, active_files: activeFiles, notes: existingNotes });
|
|
361
406
|
process.exit(0);
|
|
362
407
|
|
|
363
408
|
} else if (subcommand === 'resume-hint') {
|
|
@@ -521,10 +566,10 @@ if (subcommand === '--version' || subcommand === '-v') {
|
|
|
521
566
|
console.log(' PreToolUse[Read] -> cairn minify (compress source files for reading)');
|
|
522
567
|
console.log(' PreToolUse[Edit] -> cairn edit-guard (block Edit until re-read with full content)')
|
|
523
568
|
console.log(' PreToolUse[Write] -> cairn edit-guard (clear minify state before full file overwrite)');
|
|
524
|
-
console.log(' PostToolUse[Write] -> sync-memory-forward.
|
|
569
|
+
console.log(' PostToolUse[Write] -> sync-memory-forward.mjs (sync ~/.claude/memory -> .cairn/memory/auto-memory/)');
|
|
525
570
|
console.log(' Stop -> cairn checkpoint --auto (auto-save session)');
|
|
526
571
|
console.log(' UserPromptSubmit -> cairn resume-hint (remind Claude of prior session)');
|
|
527
|
-
console.log(' UserPromptSubmit -> sync-memory-restore.
|
|
572
|
+
console.log(' UserPromptSubmit -> sync-memory-restore.mjs (sync .cairn/memory/ -> ~/.claude/memory, mtime-based)');
|
|
528
573
|
console.log('');
|
|
529
574
|
console.log('Restart Claude Code to activate the MCP server, then open any project and start working.');
|
|
530
575
|
console.log('Upgrade any time with: npm install -g @misterhuydo/cairn-mcp');
|
|
@@ -544,12 +589,12 @@ if (subcommand === '--version' || subcommand === '-v') {
|
|
|
544
589
|
console.log(' PreToolUse[Edit] -> cairn edit-guard (block Edit until re-read with full content)')
|
|
545
590
|
console.log(' PreToolUse[Write] -> cairn edit-guard (clear minify state before full file overwrite)');
|
|
546
591
|
if (isGlobal) {
|
|
547
|
-
console.log(' PostToolUse[Write] -> sync-memory-forward.
|
|
592
|
+
console.log(' PostToolUse[Write] -> sync-memory-forward.mjs (sync ~/.claude/memory -> .cairn/memory/auto-memory/)');
|
|
548
593
|
}
|
|
549
594
|
console.log(' Stop -> cairn checkpoint --auto (auto-save session)');
|
|
550
595
|
console.log(' UserPromptSubmit -> cairn resume-hint (remind Claude of prior session)');
|
|
551
596
|
if (isGlobal) {
|
|
552
|
-
console.log(' UserPromptSubmit -> sync-memory-restore.
|
|
597
|
+
console.log(' UserPromptSubmit -> sync-memory-restore.mjs (sync .cairn/memory/ -> ~/.claude/memory, mtime-based)');
|
|
553
598
|
}
|
|
554
599
|
process.exit(0);
|
|
555
600
|
|
|
@@ -591,32 +636,50 @@ function applyHooks(settingsDir, isGlobal) {
|
|
|
591
636
|
}
|
|
592
637
|
|
|
593
638
|
// UserPromptSubmit: cairn resume-hint
|
|
594
|
-
|
|
639
|
+
let submitHooks = settings.hooks.UserPromptSubmit || [];
|
|
595
640
|
if (!submitHooks.some(h => h.hooks?.some(hh => hh.command === 'cairn resume-hint'))) {
|
|
596
641
|
submitHooks.push({ hooks: [{ type: 'command', command: 'cairn resume-hint' }] });
|
|
597
642
|
}
|
|
598
643
|
|
|
599
644
|
if (isGlobal) {
|
|
600
|
-
// Deploy memory sync scripts to ~/.claude/scripts/
|
|
645
|
+
// Deploy memory sync scripts to ~/.claude/scripts/ (Node — cross-platform, no python3 required)
|
|
601
646
|
const scriptsDir = path.join(settingsDir, 'scripts');
|
|
602
647
|
fs.mkdirSync(scriptsDir, { recursive: true });
|
|
603
|
-
const forwardScript = path.join(scriptsDir, 'sync-memory-forward.
|
|
604
|
-
const restoreScript = path.join(scriptsDir, 'sync-memory-restore.
|
|
648
|
+
const forwardScript = path.join(scriptsDir, 'sync-memory-forward.mjs');
|
|
649
|
+
const restoreScript = path.join(scriptsDir, 'sync-memory-restore.mjs');
|
|
605
650
|
fs.writeFileSync(forwardScript, SYNC_FORWARD_SCRIPT, 'utf8');
|
|
606
651
|
fs.writeFileSync(restoreScript, SYNC_RESTORE_SCRIPT, 'utf8');
|
|
607
652
|
|
|
653
|
+
// Clean up legacy Python scripts from prior installs
|
|
654
|
+
for (const legacy of ['sync-memory-forward.py', 'sync-memory-restore.py']) {
|
|
655
|
+
const p = path.join(scriptsDir, legacy);
|
|
656
|
+
if (fs.existsSync(p)) { try { fs.unlinkSync(p); } catch {} }
|
|
657
|
+
}
|
|
658
|
+
|
|
608
659
|
// Use forward slashes in hook commands — works across bash on Windows and Unix
|
|
609
|
-
const
|
|
610
|
-
const
|
|
660
|
+
const scriptsDirFwd = scriptsDir.replace(/\\/g, '/');
|
|
661
|
+
const fwdCmd = `node "${scriptsDirFwd}/sync-memory-forward.mjs"`;
|
|
662
|
+
const rstCmd = `node "${scriptsDirFwd}/sync-memory-restore.mjs"`;
|
|
663
|
+
|
|
664
|
+
// Strip stale python3 .py references from prior installs (avoids duplicate hooks firing)
|
|
665
|
+
const isLegacyPyCmd = (cmd) => typeof cmd === 'string' &&
|
|
666
|
+
/python3?\s+.*sync-memory-(forward|restore)\.py/.test(cmd);
|
|
667
|
+
const stripLegacy = (entries) => entries
|
|
668
|
+
.map(entry => ({
|
|
669
|
+
...entry,
|
|
670
|
+
hooks: (entry.hooks || []).filter(h => !isLegacyPyCmd(h.command)),
|
|
671
|
+
}))
|
|
672
|
+
.filter(entry => entry.hooks.length > 0);
|
|
611
673
|
|
|
612
674
|
// PostToolUse[Write]: forward sync Claude memory -> .cairn/memory/auto-memory/
|
|
613
|
-
|
|
675
|
+
let postHooks = stripLegacy(settings.hooks.PostToolUse || []);
|
|
614
676
|
if (!postHooks.some(h => h.matcher === 'Write' && h.hooks?.some(hh => hh.command === fwdCmd))) {
|
|
615
677
|
postHooks.push({ matcher: 'Write', hooks: [{ type: 'command', command: fwdCmd }] });
|
|
616
678
|
}
|
|
617
679
|
settings.hooks.PostToolUse = postHooks;
|
|
618
680
|
|
|
619
681
|
// UserPromptSubmit: sync .cairn/memory/ -> Claude memory dir (always, mtime-based)
|
|
682
|
+
submitHooks = stripLegacy(submitHooks);
|
|
620
683
|
if (!submitHooks.some(h => h.hooks?.some(hh => hh.command === rstCmd))) {
|
|
621
684
|
submitHooks.push({ hooks: [{ type: 'command', command: rstCmd }] });
|
|
622
685
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@misterhuydo/cairn-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.1",
|
|
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/tools/checkpoint.js
CHANGED
|
@@ -1,45 +1,101 @@
|
|
|
1
|
-
import fs from 'fs';
|
|
2
|
-
import path from 'path';
|
|
3
|
-
import { getCairnDir } from '../graph/cwd.js';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
fs.writeFileSync(
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { getCairnDir } from '../graph/cwd.js';
|
|
4
|
+
|
|
5
|
+
const NOTE_CHAR_LIMIT = 1000;
|
|
6
|
+
const SESSION_ROTATION_LIMIT = 100 * 1024; // 100KB
|
|
7
|
+
|
|
8
|
+
// Process a single note for storage:
|
|
9
|
+
// - ref objects pass through unchanged (already filed)
|
|
10
|
+
// - strings within limit get a date prefix and stay inline
|
|
11
|
+
// - strings over limit get filed to .cairn/notes/ and replaced with a ref object
|
|
12
|
+
function processNote(note, cairnDir, dateStr, index) {
|
|
13
|
+
// Already a ref object — pass through
|
|
14
|
+
if (note && typeof note === 'object' && note.ref) return note;
|
|
15
|
+
|
|
16
|
+
const text = typeof note === 'string' ? note : String(note);
|
|
17
|
+
|
|
18
|
+
// Add date prefix if missing
|
|
19
|
+
const dated = /^\[\d{4}-\d{2}-\d{2}\]/.test(text)
|
|
20
|
+
? text
|
|
21
|
+
: `[${dateStr}] ${text}`;
|
|
22
|
+
|
|
23
|
+
// Short enough to stay inline
|
|
24
|
+
if (dated.length <= NOTE_CHAR_LIMIT) return dated;
|
|
25
|
+
|
|
26
|
+
// Too long — save to .cairn/notes/ and return a ref
|
|
27
|
+
const notesDir = path.join(cairnDir, 'notes');
|
|
28
|
+
fs.mkdirSync(notesDir, { recursive: true });
|
|
29
|
+
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
30
|
+
const fileName = `note-${ts}-${index}.md`;
|
|
31
|
+
const filePath = path.join(notesDir, fileName);
|
|
32
|
+
fs.writeFileSync(filePath, dated, 'utf8');
|
|
33
|
+
|
|
34
|
+
// Description: first line of the note, capped at 120 chars
|
|
35
|
+
const description = dated.split('\n')[0].slice(0, 120);
|
|
36
|
+
return { description, ref: path.join('.cairn', 'notes', fileName) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function checkpoint(_db, { message, active_files = [], notes = [] }) {
|
|
40
|
+
// Coerce notes to array in case the caller passed a pre-serialized JSON string
|
|
41
|
+
if (typeof notes === 'string') {
|
|
42
|
+
try { notes = JSON.parse(notes); } catch { notes = notes ? [notes] : []; }
|
|
43
|
+
}
|
|
44
|
+
if (!Array.isArray(notes)) notes = [];
|
|
45
|
+
|
|
46
|
+
const cairnDir = getCairnDir();
|
|
47
|
+
const sessionPath = path.join(cairnDir, 'session.json');
|
|
48
|
+
const dateStr = new Date().toISOString().slice(0, 10);
|
|
49
|
+
|
|
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.
|
|
53
|
+
const mtimeSnapshot = {};
|
|
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 { }
|
|
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 { }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Process all notes: long strings get filed, short ones stay inline, refs pass through
|
|
66
|
+
const processedNotes = notes.map((n, i) => processNote(n, cairnDir, dateStr, i));
|
|
67
|
+
|
|
68
|
+
// Rotate session.json when it has grown beyond the size limit
|
|
69
|
+
if (fs.existsSync(sessionPath)) {
|
|
70
|
+
try {
|
|
71
|
+
const size = fs.statSync(sessionPath).size;
|
|
72
|
+
if (size > SESSION_ROTATION_LIMIT) {
|
|
73
|
+
let n = 1;
|
|
74
|
+
while (fs.existsSync(path.join(cairnDir, `session.${n}.json`))) n++;
|
|
75
|
+
fs.renameSync(sessionPath, path.join(cairnDir, `session.${n}.json`));
|
|
76
|
+
}
|
|
77
|
+
} catch { /* non-fatal */ }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const session = {
|
|
81
|
+
message,
|
|
82
|
+
checkpoint_at: new Date().toISOString(),
|
|
83
|
+
active_files,
|
|
84
|
+
notes: processedNotes,
|
|
85
|
+
mtime_snapshot: mtimeSnapshot,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
fs.writeFileSync(sessionPath, JSON.stringify(session, null, 2), 'utf8');
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
content: [{
|
|
92
|
+
type: 'text',
|
|
93
|
+
text: JSON.stringify({
|
|
94
|
+
saved: true,
|
|
95
|
+
checkpoint_at: session.checkpoint_at,
|
|
96
|
+
active_files_tracked: active_files.length,
|
|
97
|
+
notes_saved: processedNotes.length,
|
|
98
|
+
}, null, 2),
|
|
99
|
+
}],
|
|
100
|
+
};
|
|
101
|
+
}
|
package/src/tools/relocate.js
CHANGED
|
@@ -91,14 +91,23 @@ export function repairRelocation(db, cairnDir, session, map, oldRoot, newRoot) {
|
|
|
91
91
|
'utf8'
|
|
92
92
|
);
|
|
93
93
|
|
|
94
|
-
// 2. Repair index.db: rewrite absolute paths in
|
|
94
|
+
// 2. Repair index.db: rewrite absolute paths in all tables that store them
|
|
95
|
+
const oldBack = oldRoot.replace(/\//g, '\\');
|
|
96
|
+
function replaceInCol(table, col) {
|
|
97
|
+
try {
|
|
98
|
+
db.prepare(`UPDATE ${table} SET ${col} = replace(${col}, ?, ?)`).run(oldRoot, newRoot);
|
|
99
|
+
db.prepare(`UPDATE ${table} SET ${col} = replace(${col}, ?, ?)`).run(oldBack, newRoot);
|
|
100
|
+
} catch { /* column or table missing in older schema — safe to ignore */ }
|
|
101
|
+
}
|
|
102
|
+
replaceInCol('files', 'path');
|
|
103
|
+
replaceInCol('symbols', 'fqn');
|
|
104
|
+
replaceInCol('todos', 'file');
|
|
105
|
+
// FTS virtual table — delete stale content rows; the next maintain/reindex will repopulate
|
|
95
106
|
try {
|
|
96
|
-
db.prepare(
|
|
97
|
-
|
|
98
|
-
db.prepare(
|
|
99
|
-
|
|
100
|
-
);
|
|
101
|
-
} catch { /* older schema without files table — safe to ignore */ }
|
|
107
|
+
db.prepare(`DELETE FROM fts_index_content WHERE c0 LIKE ? OR c0 LIKE ? OR c3 LIKE ? OR c3 LIKE ?`)
|
|
108
|
+
.run(oldRoot + '%', oldBack + '%', oldRoot + '%', oldBack + '%');
|
|
109
|
+
db.prepare("INSERT INTO fts_index(fts_index) VALUES('rebuild')").run();
|
|
110
|
+
} catch { /* FTS not present in older schema */ }
|
|
102
111
|
|
|
103
112
|
// 3. Repair minify map
|
|
104
113
|
const newMap = {};
|
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';
|
|
@@ -38,6 +40,19 @@ export async function resume(db) {
|
|
|
38
40
|
}
|
|
39
41
|
if (!Array.isArray(session.notes)) session.notes = [];
|
|
40
42
|
|
|
43
|
+
// Migration: strip legacy git-snapshot notes (accumulated by old auto-checkpoint logic).
|
|
44
|
+
// These are pure noise — resume already returns live git_status + uncommitted_changes.
|
|
45
|
+
// Run once: back up the original to session.bak.json before modifying.
|
|
46
|
+
const gitSnapshotNotes = session.notes.filter(n => typeof n === 'string' && n.includes('git-snapshot:'));
|
|
47
|
+
if (gitSnapshotNotes.length > 0) {
|
|
48
|
+
const bakPath = path.join(cairnDir, 'session.bak.json');
|
|
49
|
+
if (!fs.existsSync(bakPath)) {
|
|
50
|
+
fs.copyFileSync(sessionPath, bakPath);
|
|
51
|
+
}
|
|
52
|
+
session.notes = session.notes.filter(n => !(typeof n === 'string' && n.includes('git-snapshot:')));
|
|
53
|
+
fs.writeFileSync(sessionPath, JSON.stringify(session, null, 2), 'utf8');
|
|
54
|
+
}
|
|
55
|
+
|
|
41
56
|
// Detect and auto-repair project relocation before any path comparisons
|
|
42
57
|
let relocation = null;
|
|
43
58
|
const snapshot = session.mtime_snapshot || {};
|
|
@@ -128,9 +143,18 @@ export async function resume(db) {
|
|
|
128
143
|
changed.length > 0 ? `Files changed since checkpoint: ${changedSummary}` : 'No files changed since checkpoint.',
|
|
129
144
|
subSchemas.length > 0 ? `Federated sub-indexes: ${subSchemas.length}` : '',
|
|
130
145
|
openTodos > 0 ? `Open todos: ${openTodos}` : '',
|
|
131
|
-
session.notes?.length > 0 ? `Notes: ${session.notes.join(' | ')}` : '',
|
|
146
|
+
session.notes?.length > 0 ? `Notes: ${session.notes.map(n => (n && typeof n === 'object' && n.ref) ? `${n.description} (see: ${n.ref})` : String(n)).join(' | ')}` : '',
|
|
132
147
|
].filter(Boolean).join(' ');
|
|
133
148
|
|
|
149
|
+
let gitignoreHint = null;
|
|
150
|
+
try {
|
|
151
|
+
const giContent = fs.readFileSync(path.join(getProjectRoot(), '.gitignore'), 'utf8');
|
|
152
|
+
const lines = giContent.split('\n').map(l => l.trim());
|
|
153
|
+
if (!lines.some(l => l === '.cairn' || l === '.cairn/' || l === '/.cairn' || l === '/.cairn/')) {
|
|
154
|
+
gitignoreHint = 'Add .cairn/ to your .gitignore to avoid committing the index and session cache.';
|
|
155
|
+
}
|
|
156
|
+
} catch { /* no .gitignore yet */ }
|
|
157
|
+
|
|
134
158
|
const result = {
|
|
135
159
|
last_checkpoint: session.checkpoint_at,
|
|
136
160
|
message: session.message,
|
|
@@ -145,29 +169,31 @@ export async function resume(db) {
|
|
|
145
169
|
notes: session.notes || [],
|
|
146
170
|
resume_summary: resumeSummary,
|
|
147
171
|
};
|
|
172
|
+
if (gitignoreHint) result.gitignore_hint = gitignoreHint;
|
|
173
|
+
if (gitSnapshotNotes.length > 0) {
|
|
174
|
+
result.migrated = `Removed ${gitSnapshotNotes.length} legacy git-snapshot note(s). Original session backed up to session.bak.json.`;
|
|
175
|
+
}
|
|
148
176
|
|
|
149
|
-
// Git state —
|
|
150
|
-
// regardless of checkpoint staleness.
|
|
177
|
+
// Git state — run all three queries in parallel to avoid sequential git startup overhead.
|
|
151
178
|
const gitRoot = getProjectRoot();
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
})
|
|
179
|
+
const since = new Date(session.checkpoint_at).toISOString();
|
|
180
|
+
const [logResult, diffResult, statusResult] = await Promise.allSettled([
|
|
181
|
+
execAsync(`git log --oneline --after="${since}"`, { cwd: gitRoot, timeout: 5000 }),
|
|
182
|
+
execAsync('git diff --name-only HEAD', { cwd: gitRoot, timeout: 5000 }),
|
|
183
|
+
execAsync('git status --short', { cwd: gitRoot, timeout: 5000 }),
|
|
184
|
+
]);
|
|
185
|
+
if (logResult.status === 'fulfilled') {
|
|
186
|
+
const raw = logResult.value.stdout.trim();
|
|
157
187
|
if (raw) result.commits_since_checkpoint = raw.split('\n');
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const raw =
|
|
161
|
-
cwd: gitRoot, encoding: 'utf8', timeout: 5000,
|
|
162
|
-
}).trim();
|
|
188
|
+
}
|
|
189
|
+
if (diffResult.status === 'fulfilled') {
|
|
190
|
+
const raw = diffResult.value.stdout.trim();
|
|
163
191
|
if (raw) result.uncommitted_changes = raw.split('\n');
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const raw =
|
|
167
|
-
cwd: gitRoot, encoding: 'utf8', timeout: 5000,
|
|
168
|
-
}).trim();
|
|
192
|
+
}
|
|
193
|
+
if (statusResult.status === 'fulfilled') {
|
|
194
|
+
const raw = statusResult.value.stdout.trim();
|
|
169
195
|
if (raw) result.git_status = raw;
|
|
170
|
-
}
|
|
196
|
+
}
|
|
171
197
|
|
|
172
198
|
if (relocation) result.relocated = relocation;
|
|
173
199
|
if (preferences.length > 0) {
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { strict as assert } from 'node:assert';
|
|
2
|
+
import { test, beforeEach, afterEach } from 'node:test';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import { setActiveProjectRoot } from '../src/graph/cwd.js';
|
|
7
|
+
|
|
8
|
+
let tmpDir;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cairn-checkpoint-'));
|
|
12
|
+
const cairnDir = path.join(tmpDir, '.cairn');
|
|
13
|
+
fs.mkdirSync(cairnDir, { recursive: true });
|
|
14
|
+
fs.writeFileSync(path.join(cairnDir, '.cairn-project'), '', 'utf8');
|
|
15
|
+
setActiveProjectRoot(tmpDir);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Dynamically import so setActiveProjectRoot takes effect before module runs
|
|
23
|
+
async function getCheckpoint() {
|
|
24
|
+
const { checkpoint } = await import(`../src/tools/checkpoint.js?t=${Date.now()}`);
|
|
25
|
+
return checkpoint;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
test('short notes stay inline with date prefix', async () => {
|
|
29
|
+
const checkpoint = await getCheckpoint();
|
|
30
|
+
checkpoint(null, { message: 'test', notes: ['short note'] });
|
|
31
|
+
|
|
32
|
+
const session = JSON.parse(fs.readFileSync(path.join(tmpDir, '.cairn', 'session.json'), 'utf8'));
|
|
33
|
+
assert.equal(session.notes.length, 1);
|
|
34
|
+
assert.ok(typeof session.notes[0] === 'string', 'short note should remain a string');
|
|
35
|
+
assert.match(session.notes[0], /^\[\d{4}-\d{2}-\d{2}\]/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('long notes are filed to .cairn/notes/ and replaced with a ref object', async () => {
|
|
39
|
+
const checkpoint = await getCheckpoint();
|
|
40
|
+
const longNote = 'x'.repeat(1001);
|
|
41
|
+
checkpoint(null, { message: 'test', notes: [longNote] });
|
|
42
|
+
|
|
43
|
+
const session = JSON.parse(fs.readFileSync(path.join(tmpDir, '.cairn', 'session.json'), 'utf8'));
|
|
44
|
+
assert.equal(session.notes.length, 1);
|
|
45
|
+
const note = session.notes[0];
|
|
46
|
+
assert.ok(note && typeof note === 'object', 'long note should be a ref object');
|
|
47
|
+
assert.ok(typeof note.ref === 'string', 'ref should be a string path');
|
|
48
|
+
assert.ok(typeof note.description === 'string', 'description should be present');
|
|
49
|
+
|
|
50
|
+
// Ref file should exist and contain the original content
|
|
51
|
+
const refPath = path.join(tmpDir, note.ref);
|
|
52
|
+
assert.ok(fs.existsSync(refPath), `ref file should exist at ${refPath}`);
|
|
53
|
+
const content = fs.readFileSync(refPath, 'utf8');
|
|
54
|
+
assert.ok(content.includes(longNote), 'ref file should contain the original note text');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('existing ref objects pass through unchanged', async () => {
|
|
58
|
+
const checkpoint = await getCheckpoint();
|
|
59
|
+
const ref = { description: '[2026-01-01] some decision', ref: '.cairn/notes/note-old.md' };
|
|
60
|
+
checkpoint(null, { message: 'test', notes: [ref] });
|
|
61
|
+
|
|
62
|
+
const session = JSON.parse(fs.readFileSync(path.join(tmpDir, '.cairn', 'session.json'), 'utf8'));
|
|
63
|
+
assert.deepEqual(session.notes[0], ref);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('session.json is rotated when it exceeds 100KB', async () => {
|
|
67
|
+
const checkpoint = await getCheckpoint();
|
|
68
|
+
const cairnDir = path.join(tmpDir, '.cairn');
|
|
69
|
+
const sessionPath = path.join(cairnDir, 'session.json');
|
|
70
|
+
|
|
71
|
+
// Write a fake oversized session.json (> 100KB)
|
|
72
|
+
fs.writeFileSync(sessionPath, JSON.stringify({ message: 'old', notes: [], active_files: [], mtime_snapshot: {}, checkpoint_at: '' }) + ' '.repeat(102 * 1024), 'utf8');
|
|
73
|
+
|
|
74
|
+
checkpoint(null, { message: 'new', notes: [] });
|
|
75
|
+
|
|
76
|
+
assert.ok(fs.existsSync(path.join(cairnDir, 'session.1.json')), 'old session should be rotated to session.1.json');
|
|
77
|
+
const current = JSON.parse(fs.readFileSync(sessionPath, 'utf8'));
|
|
78
|
+
assert.equal(current.message, 'new');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('old-format plain string notes longer than limit are migrated to refs on next checkpoint', async () => {
|
|
82
|
+
const checkpoint = await getCheckpoint();
|
|
83
|
+
const cairnDir = path.join(tmpDir, '.cairn');
|
|
84
|
+
|
|
85
|
+
// Simulate an old session.json with a long plain-string note (e.g. git-snapshot)
|
|
86
|
+
const oldNote = '[2026-04-11] git-snapshot: ' + 'M src/foo.js\n'.repeat(80);
|
|
87
|
+
fs.writeFileSync(
|
|
88
|
+
path.join(cairnDir, 'session.json'),
|
|
89
|
+
JSON.stringify({ message: 'old', notes: [oldNote], active_files: [], mtime_snapshot: {}, checkpoint_at: '' }),
|
|
90
|
+
'utf8'
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
// Pass the old notes through checkpoint (as would happen on next explicit checkpoint)
|
|
94
|
+
checkpoint(null, { message: 'migrated', notes: [oldNote] });
|
|
95
|
+
|
|
96
|
+
const session = JSON.parse(fs.readFileSync(path.join(cairnDir, 'session.json'), 'utf8'));
|
|
97
|
+
const note = session.notes[0];
|
|
98
|
+
assert.ok(note && typeof note === 'object' && note.ref, 'old long note should be migrated to a ref');
|
|
99
|
+
});
|
package/test/relocate.test.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { strict as assert } from 'node:assert';
|
|
2
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
3
|
import { test, beforeEach, afterEach } from 'node:test';
|
|
3
4
|
import fs from 'fs';
|
|
4
5
|
import path from 'path';
|
|
@@ -138,6 +139,38 @@ test('repairRelocation rewrites session.json paths', () => {
|
|
|
138
139
|
assert.ok(Object.keys(saved.mtime_snapshot).every(k => k.startsWith(newRoot)));
|
|
139
140
|
});
|
|
140
141
|
|
|
142
|
+
test('repairRelocation rewrites symbols.fqn and todos.file in DB', () => {
|
|
143
|
+
const cairnDir = path.join(dstDir, '.cairn');
|
|
144
|
+
fs.mkdirSync(cairnDir, { recursive: true });
|
|
145
|
+
|
|
146
|
+
const db = new DatabaseSync(':memory:');
|
|
147
|
+
db.prepare(`CREATE TABLE files (id INTEGER PRIMARY KEY, path TEXT UNIQUE)`).run();
|
|
148
|
+
db.prepare(`CREATE TABLE symbols (id INTEGER PRIMARY KEY, file_id INTEGER, fqn TEXT)`).run();
|
|
149
|
+
db.prepare(`CREATE TABLE todos (id INTEGER PRIMARY KEY, file TEXT)`).run();
|
|
150
|
+
|
|
151
|
+
const oldRoot = srcDir.replace(/\\/g, '/');
|
|
152
|
+
const newRoot = dstDir.replace(/\\/g, '/');
|
|
153
|
+
|
|
154
|
+
db.prepare('INSERT INTO files (path) VALUES (?)').run(`${oldRoot}/src/a.js`);
|
|
155
|
+
const fileId = db.prepare('SELECT id FROM files WHERE path = ?').get(`${oldRoot}/src/a.js`).id;
|
|
156
|
+
db.prepare('INSERT INTO symbols (file_id, fqn) VALUES (?, ?)').run(fileId, `${oldRoot}/src/a.js:MyClass`);
|
|
157
|
+
db.prepare('INSERT INTO todos (file) VALUES (?)').run(`${oldRoot}/README.md`);
|
|
158
|
+
|
|
159
|
+
const session = { message: 'x', checkpoint_at: '', active_files: [], mtime_snapshot: {} };
|
|
160
|
+
fs.writeFileSync(path.join(cairnDir, 'session.json'), JSON.stringify(session), 'utf8');
|
|
161
|
+
|
|
162
|
+
repairRelocation(db, cairnDir, session, {}, oldRoot, newRoot);
|
|
163
|
+
|
|
164
|
+
const file = db.prepare('SELECT path FROM files').get();
|
|
165
|
+
assert.ok(file.path.startsWith(newRoot), `files.path not updated: ${file.path}`);
|
|
166
|
+
|
|
167
|
+
const sym = db.prepare('SELECT fqn FROM symbols').get();
|
|
168
|
+
assert.ok(sym.fqn.startsWith(newRoot), `symbols.fqn not updated: ${sym.fqn}`);
|
|
169
|
+
|
|
170
|
+
const todo = db.prepare('SELECT file FROM todos').get();
|
|
171
|
+
assert.ok(todo.file.startsWith(newRoot), `todos.file not updated: ${todo.file}`);
|
|
172
|
+
});
|
|
173
|
+
|
|
141
174
|
test('repairRelocation rewrites minify map keys and tempPaths', () => {
|
|
142
175
|
const cairnDir = path.join(dstDir, '.cairn');
|
|
143
176
|
fs.mkdirSync(cairnDir, { recursive: true });
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { strict as assert } from 'node:assert';
|
|
2
|
+
import { test, beforeEach, afterEach } from 'node:test';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
|
|
7
|
+
// Test the git-snapshot migration logic in isolation by replicating the exact
|
|
8
|
+
// block from resume.js against a real session.json on disk.
|
|
9
|
+
function runMigration(cairnDir) {
|
|
10
|
+
const sessionPath = path.join(cairnDir, 'session.json');
|
|
11
|
+
const session = JSON.parse(fs.readFileSync(sessionPath, 'utf8'));
|
|
12
|
+
if (!Array.isArray(session.notes)) session.notes = [];
|
|
13
|
+
|
|
14
|
+
const gitSnapshotNotes = session.notes.filter(n => typeof n === 'string' && n.includes('git-snapshot:'));
|
|
15
|
+
if (gitSnapshotNotes.length > 0) {
|
|
16
|
+
const bakPath = path.join(cairnDir, 'session.bak.json');
|
|
17
|
+
if (!fs.existsSync(bakPath)) fs.copyFileSync(sessionPath, bakPath);
|
|
18
|
+
session.notes = session.notes.filter(n => !(typeof n === 'string' && n.includes('git-snapshot:')));
|
|
19
|
+
fs.writeFileSync(sessionPath, JSON.stringify(session, null, 2), 'utf8');
|
|
20
|
+
}
|
|
21
|
+
return gitSnapshotNotes.length;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let tmpDir;
|
|
25
|
+
beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cairn-resume-mig-')); });
|
|
26
|
+
afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); });
|
|
27
|
+
|
|
28
|
+
test('migration strips git-snapshot notes and backs up session.json', () => {
|
|
29
|
+
const cairnDir = path.join(tmpDir, '.cairn');
|
|
30
|
+
fs.mkdirSync(cairnDir, { recursive: true });
|
|
31
|
+
|
|
32
|
+
const realNote = '[2026-04-11] billing: 1000 credits = $1';
|
|
33
|
+
const gitNote = '[2026-04-11] git-snapshot: M src/foo.js | status: M src/foo.js';
|
|
34
|
+
fs.writeFileSync(path.join(cairnDir, 'session.json'), JSON.stringify({
|
|
35
|
+
message: 'old', checkpoint_at: '', active_files: [], mtime_snapshot: {},
|
|
36
|
+
notes: [realNote, gitNote, gitNote],
|
|
37
|
+
}), 'utf8');
|
|
38
|
+
|
|
39
|
+
const removed = runMigration(cairnDir);
|
|
40
|
+
|
|
41
|
+
assert.equal(removed, 2, 'should detect 2 git-snapshot notes');
|
|
42
|
+
|
|
43
|
+
const session = JSON.parse(fs.readFileSync(path.join(cairnDir, 'session.json'), 'utf8'));
|
|
44
|
+
assert.deepEqual(session.notes, [realNote], 'only the real note should remain');
|
|
45
|
+
|
|
46
|
+
assert.ok(fs.existsSync(path.join(cairnDir, 'session.bak.json')), 'session.bak.json should be created');
|
|
47
|
+
const bak = JSON.parse(fs.readFileSync(path.join(cairnDir, 'session.bak.json'), 'utf8'));
|
|
48
|
+
assert.equal(bak.notes.length, 3, 'backup should contain all original notes');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('migration does not overwrite an existing session.bak.json', () => {
|
|
52
|
+
const cairnDir = path.join(tmpDir, '.cairn');
|
|
53
|
+
fs.mkdirSync(cairnDir, { recursive: true });
|
|
54
|
+
|
|
55
|
+
const original = { message: 'original-backup', notes: [], active_files: [], mtime_snapshot: {}, checkpoint_at: '' };
|
|
56
|
+
fs.writeFileSync(path.join(cairnDir, 'session.bak.json'), JSON.stringify(original), 'utf8');
|
|
57
|
+
fs.writeFileSync(path.join(cairnDir, 'session.json'), JSON.stringify({
|
|
58
|
+
message: 'second-run', checkpoint_at: '', active_files: [], mtime_snapshot: {},
|
|
59
|
+
notes: ['[2026-04-12] git-snapshot: M x.js'],
|
|
60
|
+
}), 'utf8');
|
|
61
|
+
|
|
62
|
+
runMigration(cairnDir);
|
|
63
|
+
|
|
64
|
+
const bak = JSON.parse(fs.readFileSync(path.join(cairnDir, 'session.bak.json'), 'utf8'));
|
|
65
|
+
assert.equal(bak.message, 'original-backup', 'existing backup should not be overwritten');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('migration is a no-op when no git-snapshot notes are present', () => {
|
|
69
|
+
const cairnDir = path.join(tmpDir, '.cairn');
|
|
70
|
+
fs.mkdirSync(cairnDir, { recursive: true });
|
|
71
|
+
|
|
72
|
+
const notes = ['[2026-04-11] clean note'];
|
|
73
|
+
fs.writeFileSync(path.join(cairnDir, 'session.json'), JSON.stringify({
|
|
74
|
+
message: 'clean', checkpoint_at: '', active_files: [], mtime_snapshot: {}, notes,
|
|
75
|
+
}), 'utf8');
|
|
76
|
+
|
|
77
|
+
const removed = runMigration(cairnDir);
|
|
78
|
+
|
|
79
|
+
assert.equal(removed, 0);
|
|
80
|
+
assert.ok(!fs.existsSync(path.join(cairnDir, 'session.bak.json')), 'no backup should be created');
|
|
81
|
+
const session = JSON.parse(fs.readFileSync(path.join(cairnDir, 'session.json'), 'utf8'));
|
|
82
|
+
assert.deepEqual(session.notes, notes, 'notes should be unchanged');
|
|
83
|
+
});
|