@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 +69 -3
- package/bin/cairn-cli.js +128 -11
- package/index.js +4 -4
- package/package.json +1 -1
- package/src/tools/memo.js +8 -22
- package/src/tools/resume.js +30 -38
- package/src/tools/roadmap.js +64 -13
- package/src/tools/roadmapCapture.js +341 -0
- package/src/tools/roadmapFile.js +178 -0
- package/test/resume-roadmap.test.js +95 -0
- package/test/roadmap-capture.test.js +262 -0
- package/test/roadmapFile.test.js +246 -0
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
|
-
|
|
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
|
-
| `
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
|
|
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
|
|
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('', '──
|
|
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
|
|
782
|
-
//
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
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
|
|
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 +
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
//
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
};
|
package/src/tools/resume.js
CHANGED
|
@@ -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
|
|
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
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
//
|
|
157
|
+
// roadmap_phases table missing on very old indexes; ignore
|
|
169
158
|
}
|
|
170
159
|
|
|
171
|
-
// Build
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
openTodos > 0 ?
|
|
182
|
-
|
|
183
|
-
|
|
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 {
|