@misterhuydo/cairn-mcp 1.12.0 → 1.14.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
@@ -33,10 +33,15 @@ Restart Claude Code once after running this — required for the MCP server to l
33
33
  ## Upgrade
34
34
 
35
35
  ```bash
36
- npm install -g @misterhuydo/cairn-mcp
36
+ npm install -g @misterhuydo/cairn-mcp@latest
37
+ cairn install
37
38
  ```
38
39
 
39
- No need to re-run `cairn install` hooks and MCP config carry over between versions.
40
+ Updating the package replaces only the binary. Re-run `cairn install` to re-sync the
41
+ hook set — a release that adds new hooks (e.g. the roadmap-capture hooks) won't wire
42
+ them otherwise. It's idempotent and additive, so your MCP config and any existing
43
+ hooks are preserved. Restart Claude Code once afterward so the new hooks and MCP
44
+ server load.
40
45
 
41
46
  ---
42
47
 
@@ -46,7 +51,9 @@ No need to re-run `cairn install` — hooks and MCP config carry over between ve
46
51
  |---|---|---|
47
52
  | `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
53
  | `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`, Claude auto-memory backed up to `.cairn/memory/`, and the roadmap re-surfaced — all confirmed in one line |
54
+ | `PostToolUse[ExitPlanMode]` | A plan is approved | The plan is parsed into `### Phase N` entries in `.cairn/roadmap.md`, synced, and the cursor activated |
55
+ | `PostToolUse[TodoWrite]` | The todo list changes | Todos mirror onto the roadmap as sub-tasks under the current phase (or seed root phases if none exist) |
56
+ | `Stop` | End of every response | Session auto-saved to `.cairn/session.json`, Claude auto-memory backed up to `.cairn/memory/`, the roadmap re-surfaced, and any git-detected completed phases surfaced for you to confirm — all in one line |
50
57
  | `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
58
 
52
59
  ---
@@ -74,6 +81,7 @@ No manual steps. The index lives in `.cairn/index.db` inside your project — li
74
81
  | `cairn_code_graph` | Dependency health — instability, cycles, load-bearing modules |
75
82
  | `cairn_security` | Scan for XSS, SQLi, hardcoded secrets, weak crypto, and more |
76
83
  | `cairn_todos` | Scan codebase for TODO/FIXME/HACK comments, add manual items, resolve and list them |
84
+ | `cairn_roadmap` | The active project plan: phases authored in `.cairn/roadmap.md`, with a cursor, dependencies, and auto-pruning of shipped phases into `.cairn/roadmap_completed.md` |
77
85
  | `cairn_bundle` | Minified source snapshot (auto-handled by hooks) |
78
86
  | `cairn_checkpoint` | Save session state (auto-handled by hooks) |
79
87
  | `cairn_minify` | Minify a single file on demand (fallback when hooks are not installed) |
@@ -133,6 +141,64 @@ Stats are included automatically in `cairn_maintain` (`todos_found`) and `cairn_
133
141
 
134
142
  ---
135
143
 
144
+ ## Roadmap
145
+
146
+ TODOs are a long-tail backlog; the **roadmap** is the active plan. It's a first-class
147
+ document at `.cairn/roadmap.md` (not a memory) — a `## Phases` section of
148
+ `### Phase N: title` blocks, plus whatever overview prose you like:
149
+
150
+ ```markdown
151
+ ## Phases
152
+
153
+ ### Phase 1: Decouple roadmap from memory
154
+ Move phases out of decision memos into this file.
155
+
156
+ ### Phase 2: Completed log
157
+ Prune shipped phases, append them to roadmap_completed.md.
158
+ ```
159
+
160
+ `cairn_roadmap` reads that file and tracks it:
161
+
162
+ ```
163
+ cairn_roadmap { action: "tree" } → render the plan (auto-syncs roadmap.md)
164
+ cairn_roadmap { action: "next" } → advance the cursor to the next actionable phase
165
+ cairn_roadmap { action: "focus", id: 3 } → make phase 3 the current focus
166
+ cairn_roadmap { action: "set_status", id: 3, status: "done" } → ship it
167
+ cairn_roadmap { action: "add", parent: 3, text: "..." } → ad-hoc sub-task under a phase
168
+ cairn_roadmap { action: "deps_add", from: 2, to: 4 } → phase 2 blocks phase 4
169
+ ```
170
+
171
+ Editing `roadmap.md` *is* editing the plan — read actions re-seed from it, and the
172
+ end-of-turn hook folds in any hand-edits. Marking a phase **done** prunes it from
173
+ `roadmap.md` and appends it to the append-only `.cairn/roadmap_completed.md`, so the
174
+ file always shows only what's next. The shipped phase stays in the database (counted
175
+ in the `M/N done` rollup, recoverable) but is hidden from the tree.
176
+
177
+ If a project still has its roadmap in the old `decision_production_roadmap.md` memo,
178
+ the first roadmap read migrates it automatically: the content moves to
179
+ `.cairn/roadmap.md` and the memo (plus its index entry) is removed from the memory
180
+ store. Content is preserved verbatim; the migration is one-time and idempotent.
181
+
182
+ ### Automatic capture
183
+
184
+ You don't have to register phases by hand. Two `PostToolUse` hooks fold work into
185
+ the roadmap mechanically:
186
+
187
+ - **Approve a plan** (Claude's plan mode → `ExitPlanMode`) and its steps are parsed
188
+ into `### Phase N` entries in `roadmap.md`. Headings become phases; failing that, a
189
+ top-level numbered list; failing that, the whole plan becomes one phase. Duplicate
190
+ titles are skipped, and the cursor activates so the roadmap surfaces immediately.
191
+ - **Write a todo list** (`TodoWrite`) and the items mirror onto the roadmap — as
192
+ ad-hoc sub-tasks under the current phase, or as seed root phases when no roadmap
193
+ exists yet. A sub-task that's already `done` is never downgraded.
194
+
195
+ Completion is **proposed, never auto-applied**. The end-of-turn hook inspects new git
196
+ commits; when a commit references a phase (explicit `phase N`, or a strong overlap
197
+ with the phase title) the candidate is surfaced for you to confirm — Claude asks
198
+ before calling `set_status … done`, so a stray keyword can't silently prune your plan.
199
+
200
+ ---
201
+
136
202
  ## Supported languages
137
203
 
138
204
  | Language | What Cairn extracts |
package/bin/cairn-cli.js CHANGED
@@ -10,6 +10,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
12
  import { claudeSlug } from '../src/graph/cwd.js';
13
+ import { syncRoadmapFromFile, ROADMAP_GROUP } from '../src/tools/roadmapFile.js';
14
+ import { mergePlanPhases, syncTodosToRoadmap, detectDonePhases } from '../src/tools/roadmapCapture.js';
15
+ import { findNextActionable, activatePhase } from '../src/tools/roadmap.js';
13
16
 
14
17
  const MINIFY_LANGS = new Set(['java', 'typescript', 'javascript', 'vue', 'python', 'sql']);
15
18
 
@@ -149,9 +152,12 @@ async function roadmapView(cairnDir) {
149
152
  const anyInProg = live.some(p => p.status === 'in_progress');
150
153
  const rootStatus = allDone ? 'done' : (anyInProg ? 'in_progress' : 'open');
151
154
  const doneCount = live.filter(p => p.status === 'done').length;
152
- lines.push(`${STATUS_BOX[rootStatus]} Decision: ${decisionName} (${doneCount}/${live.length} done)`);
155
+ const header = decisionName === ROADMAP_GROUP ? 'Roadmap' : `Decision: ${decisionName}`;
156
+ lines.push(`${STATUS_BOX[rootStatus]} ${header} (${doneCount}/${live.length} done)`);
153
157
  for (const p of phases) {
154
158
  if (p.status === 'stale') continue;
159
+ // Shipped roadmap phases are kept (counted above) but hidden — show only what's next.
160
+ if (decisionName === ROADMAP_GROUP && p.status === 'done') continue;
155
161
  const arrow = cursorId === p.id ? ' →' : '';
156
162
  lines.push(` ${STATUS_BOX[p.status] || '[?]'}${arrow} Phase ${p.phase_number}: ${p.text}`);
157
163
  visitSubtree(p.id, 2);
@@ -186,8 +192,12 @@ async function roadmapView(cairnDir) {
186
192
 
187
193
  let memoLines = null;
188
194
  if (advanced && rootDecision != null && rootPhaseNum != null) {
189
- const memoPath = path.join(cairnDir, 'memory', `decision_${slugify(rootDecision)}.md`);
190
- const section = extractPhaseSection(memoPath, rootPhaseNum);
195
+ // The phase body lives in roadmap.md for the roadmap group; legacy decision-grouped
196
+ // phases still read from their decision memo.
197
+ const sourcePath = rootDecision === ROADMAP_GROUP
198
+ ? path.join(cairnDir, 'roadmap.md')
199
+ : path.join(cairnDir, 'memory', `decision_${slugify(rootDecision)}.md`);
200
+ const section = extractPhaseSection(sourcePath, rootPhaseNum);
191
201
  if (section) memoLines = section.split('\n');
192
202
  }
193
203
  return { active: true, currentLabel, treeLines: lines, advanced, memoLines };
@@ -407,6 +417,74 @@ if (subcommand === '--version' || subcommand === '-v') {
407
417
  process.exit(2);
408
418
  });
409
419
 
420
+ } else if (subcommand === 'roadmap-capture') {
421
+ // PostToolUse[ExitPlanMode|TodoWrite] hook. Mechanically folds approved plans
422
+ // and the live todo list into the roadmap so it stays current without Claude
423
+ // having to remember to call cairn_roadmap.
424
+ // ExitPlanMode → parse plan markdown into `### Phase N` entries in roadmap.md,
425
+ // sync to DB, and set the cursor if none is active.
426
+ // TodoWrite → mirror todos as sub-tasks under the cursor phase (or seed
427
+ // root phases when no roadmap exists yet).
428
+ let stdinData = '';
429
+ process.stdin.setEncoding('utf8');
430
+ process.stdin.on('data', chunk => { stdinData += chunk; });
431
+ process.stdin.on('end', async () => {
432
+ let input;
433
+ try { input = JSON.parse(stdinData); } catch { process.exit(0); }
434
+ const toolName = input?.tool_name || '';
435
+ const toolInput = input?.tool_input || {};
436
+
437
+ const cwd = process.cwd();
438
+ const cairnDir = path.join(cwd, '.cairn');
439
+ if (!fs.existsSync(cairnDir)) process.exit(0);
440
+ const dbPath = path.join(cairnDir, 'index.db');
441
+ if (!fs.existsSync(dbPath)) process.exit(0);
442
+
443
+ let DatabaseSync;
444
+ try { ({ DatabaseSync } = await import('node:sqlite')); } catch { process.exit(0); }
445
+
446
+ const isPlan = /exit.?plan.?mode/i.test(toolName);
447
+ const isTodo = /todo.?write/i.test(toolName);
448
+ if (!isPlan && !isTodo) process.exit(0);
449
+
450
+ let message = null;
451
+ let db;
452
+ try {
453
+ if (isPlan) {
454
+ const plan = toolInput.plan;
455
+ if (typeof plan !== 'string' || !plan.trim()) process.exit(0);
456
+ const res = mergePlanPhases(plan);
457
+ db = new DatabaseSync(dbPath);
458
+ syncRoadmapFromFile(db);
459
+ // Activate the roadmap if nothing is focused yet, so the Stop hook surfaces it.
460
+ const cursor = db.prepare('SELECT phase_id FROM main.roadmap_cursor WHERE singleton = 1').get()?.phase_id ?? null;
461
+ if (cursor == null) {
462
+ const next = findNextActionable(db, null);
463
+ if (next.id != null) activatePhase(db, next.id);
464
+ }
465
+ if (res.added.length > 0) {
466
+ message = `[cairn] roadmap: captured ${res.added.length} phase(s) from plan${res.skipped.length ? ` (${res.skipped.length} already present)` : ''}.`;
467
+ }
468
+ } else if (isTodo) {
469
+ db = new DatabaseSync(dbPath);
470
+ const res = syncTodosToRoadmap(db, toolInput.todos);
471
+ if (res.seeded) {
472
+ message = `[cairn] roadmap: seeded ${res.seeded} phase(s) from todos.`;
473
+ } else if (res.inserted > 0) {
474
+ message = `[cairn] roadmap: linked ${res.inserted} new sub-task(s) to the current phase.`;
475
+ }
476
+ }
477
+ } catch (e) {
478
+ process.stderr.write(`[cairn] roadmap-capture skipped: ${e.message}\n`);
479
+ try { db?.close(); } catch {}
480
+ process.exit(0);
481
+ }
482
+ try { db?.close(); } catch {}
483
+
484
+ if (message) process.stdout.write(JSON.stringify({ systemMessage: message }));
485
+ process.exit(0);
486
+ });
487
+
410
488
  } else if (subcommand === 'validate-map') {
411
489
  // Clean up stale entries and reset per-session readCounts.
412
490
  // Called by cairn_resume at session start.
@@ -476,7 +554,23 @@ if (subcommand === '--version' || subcommand === '-v') {
476
554
  if (n > 0) seg.push(`memory backed up (${n})`);
477
555
  } catch {}
478
556
 
479
- // 3. roadmap re-surface (read-only; content changes happen via cairn_roadmap)
557
+ // 3. roadmap re-surface. First fold any hand-edits to .cairn/roadmap.md into the DB
558
+ // so the heartbeat reflects the file even when no cairn_roadmap call was made.
559
+ // Then detect phases that look shipped from new commits — proposed, NOT applied:
560
+ // surfaced below so Claude confirms with the user before marking done.
561
+ let doneCandidates = [];
562
+ try {
563
+ const dbPath = path.join(cairnDir, 'index.db');
564
+ if (fs.existsSync(dbPath)) {
565
+ const { DatabaseSync } = await import('node:sqlite');
566
+ const wdb = new DatabaseSync(dbPath);
567
+ try {
568
+ syncRoadmapFromFile(wdb);
569
+ try { doneCandidates = detectDonePhases(wdb, cwd).candidates; } catch {}
570
+ } finally { wdb.close(); }
571
+ }
572
+ } catch {}
573
+
480
574
  let additionalContext = '';
481
575
  try {
482
576
  const rv = await roadmapView(cairnDir);
@@ -484,7 +578,7 @@ if (subcommand === '--version' || subcommand === '-v') {
484
578
  seg.push(`roadmap ${rv.advanced ? 'updated' : 'on track'}: ${rv.currentLabel}`);
485
579
  const ctx = ['[cairn] Production roadmap (→ = cursor / current phase):', ...rv.treeLines.map(l => ' ' + l)];
486
580
  if (rv.memoLines) {
487
- ctx.push('', '── Decision memo (current phase) ──', ...rv.memoLines,
581
+ ctx.push('', '── Current phase detail (from roadmap.md) ──', ...rv.memoLines,
488
582
  '── (re-read after each phase to confirm direction) ──');
489
583
  }
490
584
  ctx.push('', 'Keep this current via cairn_roadmap on MATERIAL change (phase done / blocked / new) — not every turn.');
@@ -492,6 +586,18 @@ if (subcommand === '--version' || subcommand === '-v') {
492
586
  }
493
587
  } catch {}
494
588
 
589
+ // Surface git-detected completion candidates for user confirmation (never auto-applied).
590
+ if (doneCandidates.length > 0) {
591
+ seg.push(`${doneCandidates.length} phase(s) may be done — confirm with user`);
592
+ const lines = ['', '[cairn] Possible COMPLETED phase(s) detected from recent commits —',
593
+ 'CONFIRM WITH THE USER before marking done; do NOT mark done unilaterally:'];
594
+ for (const c of doneCandidates) {
595
+ lines.push(` • Phase ${c.phase_number}: ${c.text} (commit ${c.commit}, ${c.confidence})` +
596
+ ` → if confirmed: cairn_roadmap set_status ${c.phase_id} done`);
597
+ }
598
+ additionalContext = additionalContext ? additionalContext + '\n' + lines.join('\n') : lines.join('\n').trimStart();
599
+ }
600
+
495
601
  if (seg.length === 0) process.exit(0);
496
602
  const out = { systemMessage: `[cairn] ${seg.join(' · ')}` };
497
603
  if (additionalContext) out.additionalContext = additionalContext;
@@ -692,6 +798,8 @@ if (subcommand === '--version' || subcommand === '-v') {
692
798
  console.log(' PreToolUse[Read] -> cairn minify (compress source files for reading)');
693
799
  console.log(' PreToolUse[Edit] -> cairn edit-guard (block Edit until re-read with full content)')
694
800
  console.log(' PreToolUse[Write] -> cairn edit-guard (clear minify state before full file overwrite)');
801
+ console.log(' PostToolUse[ExitPlanMode] -> cairn roadmap-capture (approved plan -> roadmap phases)');
802
+ console.log(' PostToolUse[TodoWrite] -> cairn roadmap-capture (todos -> roadmap sub-tasks)');
695
803
  console.log(' Stop -> cairn session-tick (checkpoint + memory backup + roadmap, surfaced)');
696
804
  console.log(' UserPromptSubmit -> cairn resume-hint (remind Claude of prior session)');
697
805
  console.log(' UserPromptSubmit -> cairn memory-restore (restore .cairn/memory -> Claude store if empty)');
@@ -713,13 +821,15 @@ if (subcommand === '--version' || subcommand === '-v') {
713
821
  console.log(' PreToolUse[Read] -> cairn minify (compress source files for reading)');
714
822
  console.log(' PreToolUse[Edit] -> cairn edit-guard (block Edit until re-read with full content)')
715
823
  console.log(' PreToolUse[Write] -> cairn edit-guard (clear minify state before full file overwrite)');
824
+ console.log(' PostToolUse[ExitPlanMode] -> cairn roadmap-capture (approved plan -> roadmap phases)');
825
+ console.log(' PostToolUse[TodoWrite] -> cairn roadmap-capture (todos -> roadmap sub-tasks)');
716
826
  console.log(' Stop -> cairn session-tick (checkpoint + memory backup + roadmap, surfaced)');
717
827
  console.log(' UserPromptSubmit -> cairn resume-hint (remind Claude of prior session)');
718
828
  console.log(' UserPromptSubmit -> cairn memory-restore (restore .cairn/memory -> Claude store if empty)');
719
829
  process.exit(0);
720
830
 
721
831
  } else {
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>');
832
+ console.error('Usage: cairn <--version | install | install-hooks [--global] | minify | edit-guard | validate-map | checkpoint --auto | session-tick | resume-hint | roadmap-hint | roadmap-capture | memory-restore>');
723
833
  process.exit(1);
724
834
  }
725
835
 
@@ -778,11 +888,18 @@ function applyHooks(settingsDir, isGlobal) {
778
888
  }
779
889
  settings.hooks.UserPromptSubmit = submitHooks;
780
890
 
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;
891
+ // PostToolUse: drop the legacy forward-sync hook (the session-tick backup now
892
+ // captures Claude auto-memory writes), then ensure roadmap-capture fires on
893
+ // ExitPlanMode (plan → phases) and TodoWrite (todos → sub-tasks).
894
+ let postHooks = stripHooks(settings.hooks.PostToolUse, isLegacyMemoryScript);
895
+ const ensurePost = (matcher) => {
896
+ if (!postHooks.some(h => h.matcher === matcher && h.hooks?.some(hh => hh.command === 'cairn roadmap-capture'))) {
897
+ postHooks.push({ matcher, hooks: [{ type: 'command', command: 'cairn roadmap-capture' }] });
898
+ }
899
+ };
900
+ ensurePost('ExitPlanMode');
901
+ ensurePost('TodoWrite');
902
+ settings.hooks.PostToolUse = postHooks;
786
903
 
787
904
  if (isGlobal) {
788
905
  // Remove obsolete deployed sync scripts from prior installs.
package/index.js CHANGED
@@ -263,7 +263,7 @@ Use to: get a project-wide bird's-eye view, triage code quality, find where to l
263
263
  },
264
264
  {
265
265
  name: 'cairn_todos',
266
- description: 'Long-tail backlog: scanned TODO/FIXME/HACK/XXX comments + manual notes. Stats are included in cairn_maintain and cairn_resume reports. For active multi-phase plans (with cursor + decision-memo references) use cairn_roadmap instead.',
266
+ description: 'Long-tail backlog: scanned TODO/FIXME/HACK/XXX comments + manual notes. Stats are included in cairn_maintain and cairn_resume reports. For the active multi-phase plan (authored in .cairn/roadmap.md, with a cursor + deps) use cairn_roadmap instead.',
267
267
  inputSchema: {
268
268
  type: 'object',
269
269
  properties: {
@@ -283,14 +283,14 @@ Use to: get a project-wide bird's-eye view, triage code quality, find where to l
283
283
  },
284
284
  {
285
285
  name: 'cairn_roadmap',
286
- description: 'Active project plan: phases with status + decision-memo backing + cursor + dep graph. Phases are AUTHORED in decision memos via a `## Phases` section (### Phase 1: title, ### Phase 2: ...) saving a decision memo via cairn_memo auto-seeds them here. Edit the memo to revise the plan; phases re-sync (existing preserved, removed marked stale, added appended; the response includes inserted_phases / updated_phases / staled_phases arrays so the diff is visible). Sub-tasks under a phase are ad-hoc via add --parent. Inter-phase deps via deps_add — `next` will skip dep-blocked phases and explain in the reason. The Stop hook re-prints this tree after every assistant turn while a cursor is set, and injects the current phase memo section on cursor advance.',
286
+ description: 'Active project plan: phases with status + cursor + dep graph. Phases are AUTHORED in the first-class document .cairn/roadmap.md (NOT in memory) via a `## Phases` section (### Phase 1: title, ### Phase 2: ...). Read actions (tree/status/next/sync) re-seed from that file, so editing roadmap.md IS editing the plan (existing phases preserved, removed marked stale, added appended). Marking a phase done prunes it from roadmap.md and appends it to .cairn/roadmap_completed.md (append-only log); the done row stays in the DB but is hidden from the tree, so the plan always shows what is next. Sub-tasks under a phase are ad-hoc via add --parent. Inter-phase deps via deps_add — `next` will skip dep-blocked phases and explain in the reason. The Stop hook re-prints this tree after every assistant turn while a cursor is set.',
287
287
  inputSchema: {
288
288
  type: 'object',
289
289
  properties: {
290
290
  action: {
291
291
  type: 'string',
292
- enum: ['tree', 'status', 'focus', 'next', 'set_status', 'add', 'deps_add', 'deps_remove', 'deps_list'],
293
- description: 'tree: render roadmap grouped by decision (M/N done rollup; stale hidden by default — pass show_stale: true to view; pass decision: <name> to filter) | status: one-line summary | focus <id>: move cursor, mark in_progress, propagate up (warns if dep-blocked but does not refuse) | next: DFS-advance cursor to next actionable phase (skips stale + dep-blocked, returns reason) | set_status <id> <status>: open|in_progress|done|blocked|stale (done auto-advances cursor) | add <parent> <text>: ad-hoc sub-task under an existing phase | deps_add <from> <to>: declare phase <from> blocks phase <to> (cycle-checked) | deps_remove <from> <to> | deps_list [<id>]: list edges (or just edges touching <id>)',
292
+ enum: ['tree', 'status', 'sync', 'focus', 'next', 'set_status', 'add', 'deps_add', 'deps_remove', 'deps_list'],
293
+ description: 'tree: render the roadmap (M/N done rollup; stale + shipped-done hidden by default — pass show_stale: true for stale) | status: one-line summary | sync: re-seed from .cairn/roadmap.md (also runs automatically on tree/status/next) | focus <id>: move cursor, mark in_progress, propagate up (warns if dep-blocked but does not refuse) | next: DFS-advance cursor to next actionable phase (skips stale + dep-blocked, returns reason) | set_status <id> <status>: open|in_progress|done|blocked|stale (done auto-advances cursor; a done roadmap phase is pruned from roadmap.md + logged to roadmap_completed.md) | add <parent> <text>: ad-hoc sub-task under an existing phase | deps_add <from> <to>: declare phase <from> blocks phase <to> (cycle-checked) | deps_remove <from> <to> | deps_list [<id>]: list edges (or just edges touching <id>)',
294
294
  },
295
295
  id: { type: 'number', description: 'Phase ID (focus, set_status, deps_list)' },
296
296
  status: { type: 'string', enum: ['open', 'in_progress', 'done', 'blocked', 'stale'], description: 'New status (set_status)' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/cairn-mcp",
3
- "version": "1.12.0",
3
+ "version": "1.14.0",
4
4
  "description": "MCP server that gives Claude Code persistent memory across sessions. Index your codebase once, search symbols, bundle source, scan for vulnerabilities, and checkpoint/resume work — across Java, TypeScript, Vue, Python, SQL and more.",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/src/tools/memo.js CHANGED
@@ -106,7 +106,10 @@ export function syncPhasesToRoadmap(db, decisionName, phases) {
106
106
  }
107
107
  }
108
108
  for (const [num, row] of existingByNum) {
109
- if (!seenNums.has(num) && row.status !== 'stale') {
109
+ // A phase dropped from the source is staled — UNLESS it already shipped (done).
110
+ // Shipped phases are pruned from roadmap.md on completion, so their absence is
111
+ // expected; they stay 'done' (archived, counted in rollups), never re-staled.
112
+ if (!seenNums.has(num) && row.status !== 'stale' && row.status !== 'done') {
110
113
  markStale.run(row.id);
111
114
  staledPhases.push({ id: row.id, phase_number: num });
112
115
  }
@@ -229,14 +232,6 @@ export function memo(db, { action, name, type, content, description = '', origin
229
232
  const filePath = path.join(memoryDir, removed.file);
230
233
  if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
231
234
  writeIndex(memoryDir, entries);
232
- // If this was a decision memo, mark all its roadmap phases as stale (preserve history).
233
- if (removed.type === 'decision' && db) {
234
- try {
235
- db.prepare(
236
- "UPDATE main.roadmap_phases SET status = 'stale' WHERE decision_name = ? AND status != 'stale'"
237
- ).run(removed.name);
238
- } catch {}
239
- }
240
235
  return { content: [{ type: 'text', text: JSON.stringify({ deleted: true, name }, null, 2) }] };
241
236
  }
242
237
 
@@ -277,18 +272,10 @@ export function memo(db, { action, name, type, content, description = '', origin
277
272
  else entries.push(entry);
278
273
  writeIndex(memoryDir, entries);
279
274
 
280
- // Decision memos with a `## Phases` section seed/update the project roadmap.
281
- let phaseSync = null;
282
- if (type === 'decision' && db) {
283
- try {
284
- const phases = parsePhases(content);
285
- if (phases.length > 0) {
286
- phaseSync = syncPhasesToRoadmap(db, name, phases);
287
- }
288
- } catch (e) {
289
- process.stderr.write(`[cairn] phase sync failed for ${name}: ${e.message}\n`);
290
- }
291
- }
275
+ // NOTE: roadmap phases are NOT seeded from memos. The production roadmap is a
276
+ // first-class document at .cairn/roadmap.md (see roadmapFile.js / cairn_roadmap);
277
+ // decision memos are pure semantic memory. parsePhases/syncPhasesToRoadmap are
278
+ // still exported here because roadmapFile.js reuses them to seed from that file.
292
279
 
293
280
  return {
294
281
  content: [{
@@ -299,7 +286,6 @@ export function memo(db, { action, name, type, content, description = '', origin
299
286
  type,
300
287
  file: filePath,
301
288
  action: isUpdate ? 'updated' : 'created',
302
- ...(phaseSync ? { phase_sync: phaseSync } : {}),
303
289
  }, null, 2),
304
290
  }],
305
291
  };
@@ -10,6 +10,8 @@ import { incrementalMaintain } from './maintain.js';
10
10
  import { loadMap, saveMap, validateMap } from './minifyMap.js';
11
11
  import { loadIndexEntries, loadPreferenceMemories } from './memo.js';
12
12
  import { detectRelocation, repairRelocation } from './relocate.js';
13
+ import { roadmap as roadmapDispatch } from './roadmap.js';
14
+ import { roadmapPath } from './roadmapFile.js';
13
15
 
14
16
  export async function resume(db) {
15
17
  const cairnDir = getCairnDir();
@@ -132,56 +134,46 @@ export async function resume(db) {
132
134
  // todos table not yet present in older indexes; ignore
133
135
  }
134
136
 
135
- // Roadmap surfacing: render compact tree if a cursor is set or any todo is nested.
137
+ // Roadmap surfacing seed from .cairn/roadmap.md and report the live plan so the
138
+ // agent always knows a roadmap exists, where it lives, and what the current phase is.
139
+ // Pre-v1.13 this read the legacy todos / roadmap_cursor.todo_id model and silently
140
+ // surfaced nothing once phases moved to the roadmap_phases table — agents went
141
+ // hunting for a roadmap that was right there. We now reuse the canonical roadmap
142
+ // dispatcher (which seeds from the file) so there is zero rendering drift.
136
143
  let roadmap = null;
137
144
  try {
138
- const cursorRow = db.prepare('SELECT todo_id FROM main.roadmap_cursor WHERE singleton = 1').get();
139
- const cursorId = cursorRow?.todo_id ?? null;
140
- const hasNested = db.prepare('SELECT 1 FROM main.todos WHERE parent_id IS NOT NULL LIMIT 1').get();
141
- if (cursorId != null || hasNested) {
142
- const all = db.prepare(
143
- 'SELECT id, parent_id, position, status, text FROM main.todos ORDER BY IFNULL(parent_id, -1), position, id'
144
- ).all();
145
- const STATUS_BOX = { open: '[ ]', in_progress: '[~]', done: '[x]', blocked: '[!]' };
146
- const childrenByParent = new Map();
147
- for (const t of all) {
148
- const key = t.parent_id ?? null;
149
- if (!childrenByParent.has(key)) childrenByParent.set(key, []);
150
- childrenByParent.get(key).push(t);
151
- }
152
- const lines = [];
153
- const visit = (parentId, depth) => {
154
- for (const t of childrenByParent.get(parentId) || []) {
155
- const arrow = cursorId === t.id ? ' →' : '';
156
- lines.push(`${' '.repeat(depth)}${STATUS_BOX[t.status] || '[?]'}${arrow} ${t.text} (id:${t.id})`);
157
- visit(t.id, depth + 1);
158
- }
159
- };
160
- visit(null, 0);
161
- const cursorFull = cursorId ? db.prepare('SELECT id, text FROM main.todos WHERE id = ?').get(cursorId) : null;
145
+ const hasRoadmapFile = fs.existsSync(roadmapPath());
146
+ const status = JSON.parse(roadmapDispatch(db, { action: 'status' }).content[0].text);
147
+ if (hasRoadmapFile || status.decisions_total > 0) {
148
+ const tree = roadmapDispatch(db, { action: 'tree', compact: true }).content[0].text;
162
149
  roadmap = {
163
- tree: lines.join('\n'),
164
- cursor: cursorFull ? { id: cursorFull.id, text: cursorFull.text } : null,
150
+ file: path.relative(getProjectRoot(), roadmapPath()).replace(/\\/g, '/'),
151
+ current: status.current, // "<decision> / Phase N (text)" or null
152
+ decisions_in_flight: status.decisions_in_flight,
153
+ tree,
165
154
  };
166
155
  }
167
156
  } catch {
168
- // roadmap_cursor table missing on very old indexes; ignore
157
+ // roadmap_phases table missing on very old indexes; ignore
169
158
  }
170
159
 
171
- // Build human-readable resume summary
172
- const changedSummary = changed.length > 0
173
- ? changed.slice(0, 10).map(c => `${c.change}: ${path.relative(getProjectRoot(), c.file).replace(/\\/g, '/')}`).join(', ')
174
- : 'none';
160
+ // Build a tight one-line headline. Detailed data (changed files, notes, memory
161
+ // index) ships as structured fields below — the summary points at them, it does
162
+ // not re-serialize them, so the resume payload stays small.
163
+ const roadmapSummary = roadmap
164
+ ? (roadmap.current
165
+ ? `Roadmap (${roadmap.file}) — current: ${roadmap.current}`
166
+ : `Roadmap (${roadmap.file}) — no cursor set (call cairn_roadmap next)`)
167
+ : '';
175
168
 
176
169
  const resumeSummary = [
177
170
  `Last session: ${session.message}`,
178
171
  relocation ? `Project relocated from ${relocation.oldRoot} → repaired automatically.` : '',
179
- changed.length > 0 ? `Files changed since checkpoint: ${changedSummary}` : 'No files changed since checkpoint.',
180
- subSchemas.length > 0 ? `Federated sub-indexes: ${subSchemas.length}` : '',
181
- openTodos > 0 ? `Open todos: ${openTodos}` : '',
182
- roadmap?.cursor ? `Roadmap cursor → (id:${roadmap.cursor.id}) ${roadmap.cursor.text}` : '',
183
- session.notes?.length > 0 ? `Notes: ${session.notes.map(n => (n && typeof n === 'object' && n.ref) ? `${n.description} (see: ${n.ref})` : String(n)).join(' | ')}` : '',
184
- ].filter(Boolean).join(' ');
172
+ roadmapSummary,
173
+ changed.length > 0 ? `${changed.length} file(s) changed since checkpoint` : 'No files changed since checkpoint',
174
+ openTodos > 0 ? `${openTodos} open todo(s)` : '',
175
+ subSchemas.length > 0 ? `${subSchemas.length} sub-index(es)` : '',
176
+ ].filter(Boolean).join(' · ');
185
177
 
186
178
  let gitignoreHint = null;
187
179
  try {