@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 +2 -2
- package/bin/cairn-cli.js +290 -265
- package/index.js +35 -11
- package/package.json +1 -1
- package/src/graph/cwd.js +81 -31
- package/src/graph/db.js +70 -3
- package/src/tools/memo.js +139 -1
- package/src/tools/roadmap.js +455 -0
- package/src/tools/todos.js +12 -189
- package/test/cairn-cli.test.js +43 -0
- package/test/cwd.test.js +36 -0
- package/test/roadmap.test.js +390 -0
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
fs.
|
|
56
|
-
|
|
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
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
fs.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
134
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
//
|
|
541
|
-
//
|
|
542
|
-
//
|
|
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
|
|
545
|
-
if (!
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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('
|
|
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 ->
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
710
|
-
const
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
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:
|
|
721
|
-
|
|
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
|
-
//
|
|
788
|
+
// Remove obsolete deployed sync scripts from prior installs.
|
|
728
789
|
const scriptsDir = path.join(settingsDir, 'scripts');
|
|
729
|
-
|
|
730
|
-
|
|
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');
|