@misterhuydo/cairn-mcp 1.9.0 → 1.12.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 CHANGED
@@ -46,8 +46,8 @@ No need to re-run `cairn install` — hooks and MCP config carry over between ve
46
46
  |---|---|---|
47
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
- | `Stop` | End of every response | Session auto-saved to `.cairn/session.json` |
50
- | `UserPromptSubmit` | First message of a new session | Fresh project: Claude prompted to run `cairn_maintain`. Returning session: Claude prompted to run `cairn_resume` |
49
+ | `Stop` | End of every response | Session auto-saved to `.cairn/session.json`, Claude auto-memory backed up to `.cairn/memory/`, and the roadmap re-surfaced — all confirmed in one line |
50
+ | `UserPromptSubmit` | First message of a new session | Fresh project: Claude prompted to run `cairn_maintain`. Returning session: Claude prompted to run `cairn_resume`. Memory restored from `.cairn/memory/` if the Claude store is empty (new machine / fresh clone) |
51
51
 
52
52
  ---
53
53
 
package/bin/cairn-cli.js CHANGED
@@ -9,154 +9,193 @@ import { minifyVue } from '../src/bundler/vueMinifier.js';
9
9
  import { checkpoint } from '../src/tools/checkpoint.js';
10
10
  import { findCairnDir, getTempPath, loadMap, saveMap, validateMap, OUTLINE_THRESHOLD } from '../src/tools/minifyMap.js';
11
11
  import { generateOutline } from '../src/bundler/outliner.js';
12
+ import { claudeSlug } from '../src/graph/cwd.js';
12
13
 
13
14
  const MINIFY_LANGS = new Set(['java', 'typescript', 'javascript', 'vue', 'python', 'sql']);
14
15
 
15
- // Memory sync scripts deployed to ~/.claude/scripts/ during global install.
16
- // Using forward slashes in hook commands — bash on Windows handles them fine.
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
16
 
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; }
17
+ // ── Memory sync (Claude auto-memory is canonical; .cairn/memory is the backup) ──
18
+ // Resolve the Claude Code auto-memory dir for a project cwd, or null when this
19
+ // isn't a Claude Code project (no projects/<slug> dir) — callers then no-op.
20
+ function claudeMemoryDirFor(cwd) {
21
+ const projDir = path.join(os.homedir(), '.claude', 'projects', claudeSlug(cwd));
22
+ if (!fs.existsSync(projDir)) return null;
23
+ return path.join(projDir, 'memory');
24
+ }
25
+ function listMd(dir) {
26
+ try { return fs.readdirSync(dir).filter(f => f.endsWith('.md')); } catch { return []; }
27
+ }
28
+ // Backup: Claude store -> .cairn/memory (flat, add/update only, mtime-preserving).
29
+ // Never deletes from the backup — a backup is a superset, so an intentional
30
+ // Claude-side deletion doesn't erase history. Returns count of files written.
31
+ function backupMemory(cwd) {
32
+ const claudeDir = claudeMemoryDirFor(cwd);
33
+ if (!claudeDir || !fs.existsSync(claudeDir)) return 0;
34
+ const backupDir = path.join(cwd, '.cairn', 'memory');
35
+ fs.mkdirSync(backupDir, { recursive: true });
36
+ let n = 0;
37
+ for (const f of listMd(claudeDir)) {
38
+ const src = path.join(claudeDir, f);
39
+ let st; try { st = fs.statSync(src); } catch { continue; }
40
+ if (!st.isFile()) continue;
41
+ const dst = path.join(backupDir, f);
42
+ let need = !fs.existsSync(dst);
43
+ if (!need) { try { need = st.mtimeMs > fs.statSync(dst).mtimeMs; } catch { need = true; } }
44
+ if (need) {
45
+ fs.copyFileSync(src, dst);
46
+ try { fs.utimesSync(dst, st.atime, st.mtime); } catch {}
47
+ n++;
48
+ }
49
+ }
50
+ return n;
51
+ }
52
+ // Restore: .cairn/memory -> Claude store, ONLY when the Claude store is empty
53
+ // (new machine / fresh clone / user wiped ~/.claude/projects). Returns count.
54
+ function restoreMemoryIfEmpty(cwd) {
55
+ const claudeDir = claudeMemoryDirFor(cwd);
56
+ if (!claudeDir) return 0;
57
+ fs.mkdirSync(claudeDir, { recursive: true });
58
+ if (listMd(claudeDir).some(f => f !== 'MEMORY.md')) return 0; // already populated
59
+ const backupDir = path.join(cwd, '.cairn', 'memory');
60
+ if (!fs.existsSync(backupDir)) return 0;
61
+ let n = 0;
62
+ for (const f of fs.readdirSync(backupDir)) {
63
+ const src = path.join(backupDir, f);
64
+ let st; try { st = fs.statSync(src); } catch { continue; }
53
65
  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 {}
66
+ fs.copyFileSync(src, path.join(claudeDir, f));
67
+ try { fs.utimesSync(path.join(claudeDir, f), st.atime, st.mtime); } catch {}
68
+ n++;
57
69
  }
58
- });
59
- `;
60
-
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 {}
70
+ return n;
90
71
  }
91
72
 
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++;
73
+ // ── Roadmap view (shared by `session-tick` and `roadmap-hint`) ──
74
+ // Returns { active, currentLabel, treeLines, advanced, memoLines } reading the
75
+ // roadmap_phases tree + cursor. active=false when no DB / no cursor.
76
+ async function roadmapView(cairnDir) {
77
+ const inactive = { active: false };
78
+ const dbPath = path.join(cairnDir, 'index.db');
79
+ if (!fs.existsSync(dbPath)) return inactive;
80
+ let DatabaseSync;
81
+ try { ({ DatabaseSync } = await import('node:sqlite')); } catch { return inactive; }
82
+ let db;
83
+ try { db = new DatabaseSync(dbPath, { readonly: true }); } catch { return inactive; }
84
+
85
+ const slugify = (n) => n.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
86
+ function extractPhaseSection(memoPath, phaseNumber) {
87
+ if (!fs.existsSync(memoPath)) return null;
88
+ const raw = fs.readFileSync(memoPath, 'utf8');
89
+ let inPhasesSection = false, collecting = false;
90
+ const acc = [];
91
+ for (const line of raw.split(/\r?\n/)) {
92
+ if (/^##\s+Phases\s*$/i.test(line)) { inPhasesSection = true; continue; }
93
+ if (inPhasesSection && /^##\s+/.test(line) && !/^###/.test(line)) break;
94
+ if (!inPhasesSection) continue;
95
+ const m = /^###\s+Phase\s+(\d+)\s*:\s*(.+?)\s*$/.exec(line);
96
+ if (m) {
97
+ if (collecting) break;
98
+ if (parseInt(m[1], 10) === phaseNumber) { acc.push(`### Phase ${m[1]}: ${m[2]}`); collecting = true; }
99
+ continue;
100
+ }
101
+ if (collecting) acc.push(line);
113
102
  }
103
+ return acc.join('\n').trim() || null;
114
104
  }
115
- }
116
- if (bootstrapped > 0) {
117
- process.stdout.write(\`[cairn] Bootstrapped \${bootstrapped} auto-memory file(s) into .cairn/memory/auto-memory/\\n\`);
118
- }
119
105
 
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++;
106
+ try {
107
+ const cursorRow = db.prepare("SELECT phase_id FROM roadmap_cursor WHERE singleton = 1").get();
108
+ const cursorId = cursorRow?.phase_id ?? null;
109
+ if (cursorId == null) { db.close(); return inactive; }
110
+ const all = db.prepare(
111
+ 'SELECT id, decision_name, phase_number, parent_phase_id, text, status, position FROM roadmap_phases ORDER BY decision_name, phase_number, position, id'
112
+ ).all();
113
+
114
+ const STATUS_BOX = { open: '[ ]', in_progress: '[~]', done: '[x]', blocked: '[!]', stale: '[s]' };
115
+ const byParent = new Map();
116
+ for (const p of all) {
117
+ const k = p.parent_phase_id ?? 'root';
118
+ if (!byParent.has(k)) byParent.set(k, []);
119
+ byParent.get(k).push(p);
132
120
  }
133
- if (restored > 0) {
134
- process.stdout.write(\`[cairn] Restored \${restored} memory files from .cairn/memory/auto-memory/ (new project location detected)\\n\`);
121
+ for (const arr of byParent.values()) {
122
+ arr.sort((a, b) => {
123
+ if (a.decision_name && b.decision_name && a.decision_name !== b.decision_name) return a.decision_name.localeCompare(b.decision_name);
124
+ if (a.phase_number != null && b.phase_number != null) return a.phase_number - b.phase_number;
125
+ return a.position - b.position || a.id - b.id;
126
+ });
127
+ }
128
+ const roots = byParent.get('root') || [];
129
+ const decisionRoots = new Map();
130
+ for (const r of roots) {
131
+ if (r.decision_name == null) continue;
132
+ if (!decisionRoots.has(r.decision_name)) decisionRoots.set(r.decision_name, []);
133
+ decisionRoots.get(r.decision_name).push(r);
134
+ }
135
+ const lines = [];
136
+ const visitSubtree = (parentId, depth) => {
137
+ for (const p of byParent.get(parentId) || []) {
138
+ if (p.status === 'stale') continue;
139
+ const arrow = cursorId === p.id ? ' →' : '';
140
+ const label = p.phase_number != null ? `Phase ${p.phase_number}: ${p.text}` : p.text;
141
+ lines.push(`${' '.repeat(depth)}${STATUS_BOX[p.status] || '[?]'}${arrow} ${label}`);
142
+ visitSubtree(p.id, depth + 1);
143
+ }
144
+ };
145
+ for (const [decisionName, phases] of decisionRoots) {
146
+ const live = phases.filter(p => p.status !== 'stale');
147
+ if (live.length === 0) continue;
148
+ const allDone = live.every(p => p.status === 'done');
149
+ const anyInProg = live.some(p => p.status === 'in_progress');
150
+ const rootStatus = allDone ? 'done' : (anyInProg ? 'in_progress' : 'open');
151
+ const doneCount = live.filter(p => p.status === 'done').length;
152
+ lines.push(`${STATUS_BOX[rootStatus]} Decision: ${decisionName} (${doneCount}/${live.length} done)`);
153
+ for (const p of phases) {
154
+ if (p.status === 'stale') continue;
155
+ const arrow = cursorId === p.id ? ' →' : '';
156
+ lines.push(` ${STATUS_BOX[p.status] || '[?]'}${arrow} Phase ${p.phase_number}: ${p.text}`);
157
+ visitSubtree(p.id, 2);
158
+ }
135
159
  }
136
- }
137
- }
138
160
 
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++;
161
+ const findById = (id) => all.find(p => p.id === id);
162
+ const cursorNode = findById(cursorId);
163
+ let rootDecision = cursorNode?.decision_name ?? null;
164
+ let rootPhaseNum = cursorNode?.phase_number ?? null;
165
+ let walker = cursorNode;
166
+ while (walker && walker.parent_phase_id != null) {
167
+ walker = findById(walker.parent_phase_id);
168
+ if (!walker) break;
169
+ rootDecision = walker.decision_name ?? rootDecision;
170
+ rootPhaseNum = walker.phase_number ?? rootPhaseNum;
171
+ }
172
+ db.close();
173
+
174
+ const currentLabel = cursorNode
175
+ ? (cursorNode.phase_number != null
176
+ ? `${cursorNode.decision_name} / Phase ${cursorNode.phase_number}: ${cursorNode.text}`
177
+ : `${rootDecision} / Phase ${rootPhaseNum} → ${cursorNode.text}`)
178
+ : '(unknown)';
179
+
180
+ // Advance detection via state file
181
+ const stateFile = path.join(cairnDir, '.last-roadmap-cursor');
182
+ let lastCursor = null;
183
+ try { lastCursor = parseInt(fs.readFileSync(stateFile, 'utf8').trim(), 10); } catch {}
184
+ const advanced = lastCursor !== cursorId;
185
+ try { fs.writeFileSync(stateFile, String(cursorId), 'utf8'); } catch {}
186
+
187
+ let memoLines = null;
188
+ if (advanced && rootDecision != null && rootPhaseNum != null) {
189
+ const memoPath = path.join(cairnDir, 'memory', `decision_${slugify(rootDecision)}.md`);
190
+ const section = extractPhaseSection(memoPath, rootPhaseNum);
191
+ if (section) memoLines = section.split('\n');
192
+ }
193
+ return { active: true, currentLabel, treeLines: lines, advanced, memoLines };
194
+ } catch (e) {
195
+ try { db?.close(); } catch {}
196
+ return inactive;
154
197
  }
155
198
  }
156
- if (synced > 0) {
157
- process.stdout.write(\`[cairn] Synced \${synced} memory file(s) from .cairn/memory/ to Claude memory dir\\n\`);
158
- }
159
- `;
160
199
 
161
200
  process.on('uncaughtException', (e) => {
162
201
  process.stderr.write(`cairn: ${e.message}\n`);
@@ -405,6 +444,72 @@ if (subcommand === '--version' || subcommand === '-v') {
405
444
  checkpoint(null, { message, active_files: activeFiles, notes: existingNotes });
406
445
  process.exit(0);
407
446
 
447
+ } else if (subcommand === 'session-tick') {
448
+ // Stop hook: end-of-turn heartbeat. Saves a checkpoint, backs up the Claude
449
+ // auto-memory store -> .cairn/memory, and re-surfaces the roadmap. Stop-hook
450
+ // stdout is invisible to the user, so everything is surfaced via JSON:
451
+ // systemMessage -> one-line confirmation (checkpoint · memory · roadmap)
452
+ // additionalContext -> full roadmap tree (+ current-phase memo on advance)
453
+ const cwd = process.cwd();
454
+ const cairnDir = path.join(cwd, '.cairn');
455
+ if (!fs.existsSync(cairnDir)) process.exit(0);
456
+
457
+ const seg = [];
458
+
459
+ // 1. checkpoint (silent)
460
+ try {
461
+ const message = `Auto-checkpoint at ${new Date().toISOString()}`;
462
+ let existingNotes = [];
463
+ const sessionPath = path.join(cairnDir, 'session.json');
464
+ if (fs.existsSync(sessionPath)) {
465
+ try { existingNotes = JSON.parse(fs.readFileSync(sessionPath, 'utf8')).notes || []; } catch {}
466
+ }
467
+ const map = loadMap(cairnDir);
468
+ const activeFiles = Object.entries(map).filter(([, e]) => (e.readCount ?? 0) > 0).map(([p]) => p);
469
+ checkpoint(null, { message, active_files: activeFiles, notes: existingNotes });
470
+ seg.push('✓ checkpoint saved');
471
+ } catch {}
472
+
473
+ // 2. memory backup: Claude store -> .cairn/memory
474
+ try {
475
+ const n = backupMemory(cwd);
476
+ if (n > 0) seg.push(`memory backed up (${n})`);
477
+ } catch {}
478
+
479
+ // 3. roadmap re-surface (read-only; content changes happen via cairn_roadmap)
480
+ let additionalContext = '';
481
+ try {
482
+ const rv = await roadmapView(cairnDir);
483
+ if (rv.active) {
484
+ seg.push(`roadmap ${rv.advanced ? 'updated' : 'on track'}: ${rv.currentLabel}`);
485
+ const ctx = ['[cairn] Production roadmap (→ = cursor / current phase):', ...rv.treeLines.map(l => ' ' + l)];
486
+ if (rv.memoLines) {
487
+ ctx.push('', '── Decision memo (current phase) ──', ...rv.memoLines,
488
+ '── (re-read after each phase to confirm direction) ──');
489
+ }
490
+ ctx.push('', 'Keep this current via cairn_roadmap on MATERIAL change (phase done / blocked / new) — not every turn.');
491
+ additionalContext = ctx.join('\n');
492
+ }
493
+ } catch {}
494
+
495
+ if (seg.length === 0) process.exit(0);
496
+ const out = { systemMessage: `[cairn] ${seg.join(' · ')}` };
497
+ if (additionalContext) out.additionalContext = additionalContext;
498
+ process.stdout.write(JSON.stringify(out));
499
+ process.exit(0);
500
+
501
+ } else if (subcommand === 'memory-restore') {
502
+ // UserPromptSubmit hook: restore .cairn/memory -> Claude store ONLY when the
503
+ // Claude store is empty (new machine / fresh clone / wiped ~/.claude/projects).
504
+ // One-way, non-destructive. UserPromptSubmit stdout IS visible to the user.
505
+ const cwd = process.cwd();
506
+ if (!fs.existsSync(path.join(cwd, '.cairn'))) process.exit(0);
507
+ try {
508
+ const n = restoreMemoryIfEmpty(cwd);
509
+ if (n > 0) process.stdout.write(`[cairn] Restored ${n} memory file(s) from .cairn/memory backup (Claude store was empty)\n`);
510
+ } catch {}
511
+ process.exit(0);
512
+
408
513
  } else if (subcommand === 'resume-hint') {
409
514
  const cairnDir = path.join(process.cwd(), '.cairn');
410
515
  const sessionPath = path.join(cairnDir, 'session.json');
@@ -537,79 +642,24 @@ if (subcommand === '--version' || subcommand === '-v') {
537
642
  process.exit(0);
538
643
 
539
644
  } 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.
645
+ // Manual/legacy: print the roadmap tree to stdout when a cursor is active.
646
+ // The end-of-turn Stop hook now uses `session-tick`, which surfaces the same
647
+ // view via JSON (Stop-hook stdout is invisible to the user). Shares roadmapView.
543
648
  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 {}
649
+ const rv = await roadmapView(cairnDir);
650
+ if (!rv.active) process.exit(0);
651
+ const out = [
652
+ '[cairn] ROADMAP — cursor is active.',
653
+ ...rv.treeLines.map(l => '[cairn] ' + l),
654
+ `[cairn] Current: ${rv.currentLabel}`,
655
+ ];
656
+ if (rv.memoLines) {
657
+ out.push('[cairn] ───── Decision memo (current phase) ─────');
658
+ for (const ln of rv.memoLines) out.push('[cairn] ' + ln);
659
+ out.push('[cairn] ───── (re-read after each phase to confirm direction) ─────');
612
660
  }
661
+ out.push('[cairn] Use cairn_roadmap set_status <id> done (auto-advances), focus <id>, or next to move cursor.');
662
+ process.stdout.write(out.join('\n') + '\n');
613
663
  process.exit(0);
614
664
 
615
665
  } else if (subcommand === 'install') {
@@ -642,11 +692,9 @@ if (subcommand === '--version' || subcommand === '-v') {
642
692
  console.log(' PreToolUse[Read] -> cairn minify (compress source files for reading)');
643
693
  console.log(' PreToolUse[Edit] -> cairn edit-guard (block Edit until re-read with full content)')
644
694
  console.log(' PreToolUse[Write] -> cairn edit-guard (clear minify state before full file overwrite)');
645
- console.log(' PostToolUse[Write] -> sync-memory-forward.mjs (sync ~/.claude/memory -> .cairn/memory/auto-memory/)');
646
- console.log(' Stop -> cairn checkpoint --auto (auto-save session)');
647
- console.log(' Stop -> cairn roadmap-hint (re-print roadmap when cursor is active)');
695
+ console.log(' Stop -> cairn session-tick (checkpoint + memory backup + roadmap, surfaced)');
648
696
  console.log(' UserPromptSubmit -> cairn resume-hint (remind Claude of prior session)');
649
- console.log(' UserPromptSubmit -> sync-memory-restore.mjs (sync .cairn/memory/ -> ~/.claude/memory, mtime-based)');
697
+ console.log(' UserPromptSubmit -> cairn memory-restore (restore .cairn/memory -> Claude store if empty)');
650
698
  console.log('');
651
699
  console.log('Restart Claude Code to activate the MCP server, then open any project and start working.');
652
700
  console.log('Upgrade any time with: npm install -g @misterhuydo/cairn-mcp');
@@ -665,19 +713,13 @@ if (subcommand === '--version' || subcommand === '-v') {
665
713
  console.log(' PreToolUse[Read] -> cairn minify (compress source files for reading)');
666
714
  console.log(' PreToolUse[Edit] -> cairn edit-guard (block Edit until re-read with full content)')
667
715
  console.log(' PreToolUse[Write] -> cairn edit-guard (clear minify state before full file overwrite)');
668
- if (isGlobal) {
669
- console.log(' PostToolUse[Write] -> sync-memory-forward.mjs (sync ~/.claude/memory -> .cairn/memory/auto-memory/)');
670
- }
671
- console.log(' Stop -> cairn checkpoint --auto (auto-save session)');
672
- console.log(' Stop -> cairn roadmap-hint (re-print roadmap when cursor is active)');
716
+ console.log(' Stop -> cairn session-tick (checkpoint + memory backup + roadmap, surfaced)');
673
717
  console.log(' UserPromptSubmit -> cairn resume-hint (remind Claude of prior session)');
674
- if (isGlobal) {
675
- console.log(' UserPromptSubmit -> sync-memory-restore.mjs (sync .cairn/memory/ -> ~/.claude/memory, mtime-based)');
676
- }
718
+ console.log(' UserPromptSubmit -> cairn memory-restore (restore .cairn/memory -> Claude store if empty)');
677
719
  process.exit(0);
678
720
 
679
721
  } else {
680
- console.error('Usage: cairn <--version | install | install-hooks [--global] | minify | edit-guard | validate-map | checkpoint --auto | resume-hint | roadmap-hint>');
722
+ console.error('Usage: cairn <--version | install | install-hooks [--global] | minify | edit-guard | validate-map | checkpoint --auto | session-tick | resume-hint | roadmap-hint | memory-restore>');
681
723
  process.exit(1);
682
724
  }
683
725
 
@@ -706,69 +748,52 @@ function applyHooks(settingsDir, isGlobal) {
706
748
  }
707
749
  settings.hooks.PreToolUse = cleanedPreHooks;
708
750
 
709
- // Stop: cairn checkpoint --auto
710
- const stopHooks = settings.hooks.Stop || [];
711
- if (!stopHooks.some(h => h.hooks?.some(hh => hh.command === 'cairn checkpoint --auto'))) {
712
- stopHooks.push({ hooks: [{ type: 'command', command: 'cairn checkpoint --auto' }] });
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' }] });
751
+ // Helper: drop hooks whose command matches a predicate, then prune empty entries.
752
+ const stripHooks = (entries, pred) => (entries || [])
753
+ .map(entry => ({ ...entry, hooks: (entry.hooks || []).filter(h => !pred(h.command)) }))
754
+ .filter(entry => (entry.hooks || []).length > 0);
755
+ // Legacy deployed memory-sync scripts (forward/restore, .mjs or .py) from prior installs.
756
+ const isLegacyMemoryScript = (cmd) => typeof cmd === 'string' &&
757
+ /sync-memory-(forward|restore)\.(mjs|py)/.test(cmd);
758
+
759
+ // Stop: single end-of-turn heartbeat. Migrate away from the old split hooks
760
+ // (checkpoint --auto + roadmap-hint) — both wrote to Stop-hook stdout, which
761
+ // Claude Code never surfaces. `session-tick` checkpoints, backs up memory, and
762
+ // re-surfaces the roadmap via JSON systemMessage/additionalContext.
763
+ let stopHooks = stripHooks(settings.hooks.Stop, (cmd) =>
764
+ cmd === 'cairn checkpoint --auto' || cmd === 'cairn roadmap-hint');
765
+ if (!stopHooks.some(h => h.hooks?.some(hh => hh.command === 'cairn session-tick'))) {
766
+ stopHooks.push({ hooks: [{ type: 'command', command: 'cairn session-tick' }] });
717
767
  }
718
768
  settings.hooks.Stop = stopHooks;
719
769
 
720
- // UserPromptSubmit: cairn resume-hint
721
- let submitHooks = settings.hooks.UserPromptSubmit || [];
770
+ // UserPromptSubmit: resume-hint + memory-restore (restore .cairn/memory -> Claude
771
+ // store only when the Claude store is empty). Strip the old deployed restore script.
772
+ let submitHooks = stripHooks(settings.hooks.UserPromptSubmit, isLegacyMemoryScript);
722
773
  if (!submitHooks.some(h => h.hooks?.some(hh => hh.command === 'cairn resume-hint'))) {
723
774
  submitHooks.push({ hooks: [{ type: 'command', command: 'cairn resume-hint' }] });
724
775
  }
776
+ if (!submitHooks.some(h => h.hooks?.some(hh => hh.command === 'cairn memory-restore'))) {
777
+ submitHooks.push({ hooks: [{ type: 'command', command: 'cairn memory-restore' }] });
778
+ }
779
+ settings.hooks.UserPromptSubmit = submitHooks;
780
+
781
+ // PostToolUse: drop the legacy forward-sync hook entirely. The Stop-hook backup
782
+ // (session-tick) now captures Claude auto-memory writes into .cairn/memory.
783
+ const cleanedPost = stripHooks(settings.hooks.PostToolUse, isLegacyMemoryScript);
784
+ if (cleanedPost.length > 0) settings.hooks.PostToolUse = cleanedPost;
785
+ else delete settings.hooks.PostToolUse;
725
786
 
726
787
  if (isGlobal) {
727
- // Deploy memory sync scripts to ~/.claude/scripts/ (Node — cross-platform, no python3 required)
788
+ // Remove obsolete deployed sync scripts from prior installs.
728
789
  const scriptsDir = path.join(settingsDir, 'scripts');
729
- fs.mkdirSync(scriptsDir, { recursive: true });
730
- const forwardScript = path.join(scriptsDir, 'sync-memory-forward.mjs');
731
- const restoreScript = path.join(scriptsDir, 'sync-memory-restore.mjs');
732
- fs.writeFileSync(forwardScript, SYNC_FORWARD_SCRIPT, 'utf8');
733
- fs.writeFileSync(restoreScript, SYNC_RESTORE_SCRIPT, 'utf8');
734
-
735
- // Clean up legacy Python scripts from prior installs
736
- for (const legacy of ['sync-memory-forward.py', 'sync-memory-restore.py']) {
790
+ for (const legacy of ['sync-memory-forward.mjs', 'sync-memory-restore.mjs',
791
+ 'sync-memory-forward.py', 'sync-memory-restore.py']) {
737
792
  const p = path.join(scriptsDir, legacy);
738
793
  if (fs.existsSync(p)) { try { fs.unlinkSync(p); } catch {} }
739
794
  }
740
-
741
- // Use forward slashes in hook commands — works across bash on Windows and Unix
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);
755
-
756
- // PostToolUse[Write]: forward sync Claude memory -> .cairn/memory/auto-memory/
757
- let postHooks = stripLegacy(settings.hooks.PostToolUse || []);
758
- if (!postHooks.some(h => h.matcher === 'Write' && h.hooks?.some(hh => hh.command === fwdCmd))) {
759
- postHooks.push({ matcher: 'Write', hooks: [{ type: 'command', command: fwdCmd }] });
760
- }
761
- settings.hooks.PostToolUse = postHooks;
762
-
763
- // UserPromptSubmit: sync .cairn/memory/ -> Claude memory dir (always, mtime-based)
764
- submitHooks = stripLegacy(submitHooks);
765
- if (!submitHooks.some(h => h.hooks?.some(hh => hh.command === rstCmd))) {
766
- submitHooks.push({ hooks: [{ type: 'command', command: rstCmd }] });
767
- }
768
795
  }
769
796
 
770
- settings.hooks.UserPromptSubmit = submitHooks;
771
-
772
797
  // Write .cairn-project sentinel for project-scoped installs
773
798
  if (!isGlobal) {
774
799
  const cairnDir = path.join(process.cwd(), '.cairn');