@misterhuydo/cairn-mcp 1.8.1 → 1.9.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
@@ -44,7 +44,7 @@ No need to re-run `cairn install` — hooks and MCP config carry over between ve
44
44
 
45
45
  | Hook | Trigger | Effect |
46
46
  |---|---|---|
47
- | `PreToolUse[Read]` | Every file read | Source files compressed ~68% before Claude sees them; large files show structural outline to save tokens |
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
49
  | `Stop` | End of every response | Session auto-saved to `.cairn/session.json` |
50
50
  | `UserPromptSubmit` | First message of a new session | Fresh project: Claude prompted to run `cairn_maintain`. Returning session: Claude prompted to run `cairn_resume` |
@@ -66,11 +66,11 @@ No manual steps. The index lives in `.cairn/index.db` inside your project — li
66
66
 
67
67
  | Tool | What it does |
68
68
  |---|---|
69
- | `cairn_maintain` | Full index of the current project |
69
+ | `cairn_maintain` | Full index of the current project. Suggests adding `.cairn/` to `.gitignore` if not already present |
70
70
  | `cairn_resume` | Restore last session + re-index only changed files |
71
- | `cairn_search` | Find classes, functions, components by name or concept |
71
+ | `cairn_search` | Find classes, functions, components by name or concept — results include file path and **line number** |
72
72
  | `cairn_describe` | Summarize what a folder or module does |
73
- | `cairn_outline` | Structural outline + heuristic issue detection. On large federated projects returns a per-service summary; use `repo` param to drill into one service |
73
+ | `cairn_outline` | Structural outline with **line numbers** per symbol + heuristic issue detection. On large federated projects returns a per-service summary; use `repo` param to drill into one service |
74
74
  | `cairn_code_graph` | Dependency health — instability, cycles, load-bearing modules |
75
75
  | `cairn_security` | Scan for XSS, SQLi, hardcoded secrets, weak crypto, and more |
76
76
  | `cairn_todos` | Scan codebase for TODO/FIXME/HACK comments, add manual items, resolve and list them |
package/bin/cairn-cli.js CHANGED
@@ -536,6 +536,82 @@ if (subcommand === '--version' || subcommand === '-v') {
536
536
  fs.writeFileSync(lockPath, new Date().toISOString(), 'utf8');
537
537
  process.exit(0);
538
538
 
539
+ } else if (subcommand === 'roadmap-hint') {
540
+ // Stop hook: print the roadmap tree + cursor only when a cursor is set.
541
+ // Silent (exit 0) for projects with no .cairn, no DB, or no cursor — so
542
+ // normal chat is unaffected. Activates the moment cairn_todos focus is called.
543
+ const cairnDir = path.join(process.cwd(), '.cairn');
544
+ const dbPath = path.join(cairnDir, 'index.db');
545
+ if (!fs.existsSync(dbPath)) process.exit(0);
546
+
547
+ let DatabaseSync;
548
+ try { ({ DatabaseSync } = await import('node:sqlite')); } catch { process.exit(0); }
549
+
550
+ let db;
551
+ try { db = new DatabaseSync(dbPath, { readonly: true }); } catch { process.exit(0); }
552
+
553
+ try {
554
+ const cursorRow = db.prepare(
555
+ "SELECT todo_id FROM roadmap_cursor WHERE singleton = 1"
556
+ ).get();
557
+ const cursorId = cursorRow?.todo_id ?? null;
558
+ if (cursorId == null) { db.close(); process.exit(0); }
559
+
560
+ const all = db.prepare(
561
+ 'SELECT id, parent_id, position, status, text FROM todos ORDER BY IFNULL(parent_id, -1), position, id'
562
+ ).all();
563
+ db.close();
564
+
565
+ const STATUS_BOX = { open: '[ ]', in_progress: '[~]', done: '[x]', blocked: '[!]' };
566
+ const childrenByParent = new Map();
567
+ for (const t of all) {
568
+ const key = t.parent_id ?? null;
569
+ if (!childrenByParent.has(key)) childrenByParent.set(key, []);
570
+ childrenByParent.get(key).push(t);
571
+ }
572
+
573
+ const lines = [];
574
+ const visit = (parentId, depth) => {
575
+ for (const t of childrenByParent.get(parentId) || []) {
576
+ const arrow = cursorId === t.id ? ' →' : '';
577
+ lines.push(`${' '.repeat(depth)}${STATUS_BOX[t.status] || '[?]'}${arrow} ${t.text} (id:${t.id})`);
578
+ visit(t.id, depth + 1);
579
+ }
580
+ };
581
+ visit(null, 0);
582
+
583
+ // Compute "Next" via DFS: first open child of cursor → next open sibling/uncle.
584
+ const isOpenish = (s) => s === 'open' || s === 'in_progress';
585
+ const findNext = (fromId) => {
586
+ const kids = (childrenByParent.get(fromId) || []).filter(t => isOpenish(t.status));
587
+ if (kids.length > 0) return kids[0];
588
+ let curId = fromId;
589
+ while (curId != null) {
590
+ const cur = all.find(t => t.id === curId);
591
+ if (!cur) return null;
592
+ const siblings = (childrenByParent.get(cur.parent_id ?? null) || [])
593
+ .filter(t => isOpenish(t.status) && (t.position > cur.position || (t.position === cur.position && t.id > cur.id)));
594
+ if (siblings.length > 0) return siblings[0];
595
+ curId = cur.parent_id;
596
+ }
597
+ return null;
598
+ };
599
+ const cursorNode = all.find(t => t.id === cursorId);
600
+ const next = findNext(cursorId);
601
+
602
+ const out = [
603
+ '[cairn] ROADMAP — cursor is active. Tree:',
604
+ ...lines.map(l => '[cairn] ' + l),
605
+ cursorNode ? `[cairn] Current: (id:${cursorNode.id}) ${cursorNode.text}` : '',
606
+ next ? `[cairn] Next: (id:${next.id}) ${next.text}` : '[cairn] Next: (no open todos remaining)',
607
+ '[cairn] Use cairn_todos resolve <id> to mark done (auto-advances), focus <id> to move cursor, or next to advance manually.',
608
+ ].filter(Boolean);
609
+ process.stdout.write(out.join('\n') + '\n');
610
+ } catch {
611
+ try { db?.close(); } catch {}
612
+ }
613
+ process.exit(0);
614
+
539
615
  } else if (subcommand === 'install') {
540
616
  // One-shot setup: register MCP server in ~/.claude.json + install global hooks.
541
617
  const platform = process.platform;
@@ -568,6 +644,7 @@ if (subcommand === '--version' || subcommand === '-v') {
568
644
  console.log(' PreToolUse[Write] -> cairn edit-guard (clear minify state before full file overwrite)');
569
645
  console.log(' PostToolUse[Write] -> sync-memory-forward.mjs (sync ~/.claude/memory -> .cairn/memory/auto-memory/)');
570
646
  console.log(' Stop -> cairn checkpoint --auto (auto-save session)');
647
+ console.log(' Stop -> cairn roadmap-hint (re-print roadmap when cursor is active)');
571
648
  console.log(' UserPromptSubmit -> cairn resume-hint (remind Claude of prior session)');
572
649
  console.log(' UserPromptSubmit -> sync-memory-restore.mjs (sync .cairn/memory/ -> ~/.claude/memory, mtime-based)');
573
650
  console.log('');
@@ -592,6 +669,7 @@ if (subcommand === '--version' || subcommand === '-v') {
592
669
  console.log(' PostToolUse[Write] -> sync-memory-forward.mjs (sync ~/.claude/memory -> .cairn/memory/auto-memory/)');
593
670
  }
594
671
  console.log(' Stop -> cairn checkpoint --auto (auto-save session)');
672
+ console.log(' Stop -> cairn roadmap-hint (re-print roadmap when cursor is active)');
595
673
  console.log(' UserPromptSubmit -> cairn resume-hint (remind Claude of prior session)');
596
674
  if (isGlobal) {
597
675
  console.log(' UserPromptSubmit -> sync-memory-restore.mjs (sync .cairn/memory/ -> ~/.claude/memory, mtime-based)');
@@ -599,7 +677,7 @@ if (subcommand === '--version' || subcommand === '-v') {
599
677
  process.exit(0);
600
678
 
601
679
  } else {
602
- console.error('Usage: cairn <--version | install | install-hooks [--global] | minify | edit-guard | validate-map | checkpoint --auto | resume-hint>');
680
+ console.error('Usage: cairn <--version | install | install-hooks [--global] | minify | edit-guard | validate-map | checkpoint --auto | resume-hint | roadmap-hint>');
603
681
  process.exit(1);
604
682
  }
605
683
 
@@ -632,8 +710,12 @@ function applyHooks(settingsDir, isGlobal) {
632
710
  const stopHooks = settings.hooks.Stop || [];
633
711
  if (!stopHooks.some(h => h.hooks?.some(hh => hh.command === 'cairn checkpoint --auto'))) {
634
712
  stopHooks.push({ hooks: [{ type: 'command', command: 'cairn checkpoint --auto' }] });
635
- settings.hooks.Stop = stopHooks;
636
713
  }
714
+ // Stop: cairn roadmap-hint (silent unless a roadmap_cursor is set)
715
+ if (!stopHooks.some(h => h.hooks?.some(hh => hh.command === 'cairn roadmap-hint'))) {
716
+ stopHooks.push({ hooks: [{ type: 'command', command: 'cairn roadmap-hint' }] });
717
+ }
718
+ settings.hooks.Stop = stopHooks;
637
719
 
638
720
  // UserPromptSubmit: cairn resume-hint
639
721
  let submitHooks = settings.hooks.UserPromptSubmit || [];
package/index.js CHANGED
@@ -262,20 +262,23 @@ Use to: get a project-wide bird's-eye view, triage code quality, find where to l
262
262
  },
263
263
  {
264
264
  name: 'cairn_todos',
265
- description: 'Manage TODO/FIXME/HACK/XXX items. Scans the codebase for TODO-style comments and lets you add your own. Stats are included in cairn_maintain and cairn_resume reports.',
265
+ description: 'Manage TODO/FIXME/HACK/XXX items and multi-phase roadmaps. Supports a tree of nested phases with a "current cursor" pointer so multi-phase plans survive context drift. Use action=tree to render the roadmap, focus to set the cursor, next to advance. Stats are included in cairn_maintain and cairn_resume reports.',
266
266
  inputSchema: {
267
267
  type: 'object',
268
268
  properties: {
269
269
  action: {
270
270
  type: 'string',
271
- enum: ['list', 'add', 'resolve', 'scan'],
272
- description: 'list: show todos | add: create a manual todo | resolve: mark done by id | scan: re-scan codebase',
271
+ enum: ['list', 'tree', 'add', 'resolve', 'focus', 'next', 'set_status', 'scan'],
272
+ description: 'list: flat list | tree: hierarchical roadmap with cursor | add: create manual todo (use parent + position for nesting) | resolve: mark done | focus: move cursor to id, mark in_progress, propagate up | next: DFS-advance cursor to next open node | set_status: set status to open|in_progress|done|blocked | scan: re-scan codebase',
273
273
  },
274
- text: { type: 'string', description: 'Text for the new todo (add action)' },
275
- kind: { type: 'string', enum: ['TODO', 'FIXME', 'HACK', 'XXX', 'NOTE'], description: 'Kind for new todo (default: TODO)' },
276
- id: { type: 'number', description: 'Todo ID to resolve (resolve action)' },
277
- status: { type: 'string', enum: ['open', 'done'], description: 'Filter by status (list action)' },
278
- source: { type: 'string', enum: ['scan', 'manual'], description: 'Filter by source (list action)' },
274
+ text: { type: 'string', description: 'Text for the new todo (add action)' },
275
+ kind: { type: 'string', enum: ['TODO', 'FIXME', 'HACK', 'XXX', 'NOTE'], description: 'Kind for new todo (default: TODO)' },
276
+ id: { type: 'number', description: 'Todo ID (resolve, focus, set_status)' },
277
+ status: { type: 'string', enum: ['open', 'in_progress', 'done', 'blocked'], description: 'Filter (list) or new value (set_status)' },
278
+ source: { type: 'string', enum: ['scan', 'manual'], description: 'Filter by source (list action)' },
279
+ parent: { type: 'number', description: 'Parent todo ID for nesting (add action). Omit for root/phase.' },
280
+ position: { type: 'number', description: 'Sibling order within parent (add action). Auto-appends if omitted.' },
281
+ compact: { type: 'boolean', description: 'Compact tree render — strips id/kind tags (tree action)' },
279
282
  },
280
283
  required: ['action'],
281
284
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/cairn-mcp",
3
- "version": "1.8.1",
3
+ "version": "1.9.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/graph/db.js CHANGED
@@ -67,9 +67,16 @@ const SCHEMA = `
67
67
  kind TEXT NOT NULL DEFAULT 'TODO',
68
68
  text TEXT NOT NULL,
69
69
  status TEXT NOT NULL DEFAULT 'open',
70
+ parent_id INTEGER REFERENCES todos(id) ON DELETE SET NULL,
71
+ position INTEGER NOT NULL DEFAULT 0,
70
72
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
71
73
  resolved_at TEXT
72
74
  );
75
+
76
+ CREATE TABLE IF NOT EXISTS roadmap_cursor (
77
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
78
+ todo_id INTEGER REFERENCES todos(id) ON DELETE SET NULL
79
+ );
73
80
  `;
74
81
 
75
82
  // Each sub-DB gets its IDs offset by this multiplier to prevent collisions
@@ -190,6 +197,24 @@ export function openDB() {
190
197
  db.exec(SCHEMA);
191
198
  // Migration: add line column to existing DBs that predate v1.7.0
192
199
  try { db.exec('ALTER TABLE main.symbols ADD COLUMN line INTEGER'); } catch {}
200
+ // Migration: roadmap tree columns on todos (idempotent — fails silently if present)
201
+ let addedRoadmapCols = false;
202
+ try { db.exec('ALTER TABLE main.todos ADD COLUMN parent_id INTEGER REFERENCES todos(id) ON DELETE SET NULL'); addedRoadmapCols = true; } catch {}
203
+ try { db.exec('ALTER TABLE main.todos ADD COLUMN position INTEGER NOT NULL DEFAULT 0'); addedRoadmapCols = true; } catch {}
204
+ if (addedRoadmapCols) {
205
+ // Backfill position by id order within each parent group (null parent = root).
206
+ // ROW_NUMBER() requires SQLite 3.25+, which node:sqlite ships.
207
+ db.exec(`
208
+ UPDATE main.todos AS t
209
+ SET position = (
210
+ SELECT rn - 1 FROM (
211
+ SELECT id, ROW_NUMBER() OVER (
212
+ PARTITION BY IFNULL(parent_id, -1) ORDER BY id
213
+ ) AS rn FROM main.todos
214
+ ) ord WHERE ord.id = t.id
215
+ )
216
+ `);
217
+ }
193
218
  mountParentSubIndexes(db);
194
219
  refreshFederatedViews(db);
195
220
  return db;
@@ -127,11 +127,47 @@ export async function resume(db) {
127
127
  // Count open todos for status report
128
128
  let openTodos = 0;
129
129
  try {
130
- openTodos = db.prepare('SELECT COUNT(*) AS n FROM main.todos WHERE status = \'open\'').get().n;
130
+ openTodos = db.prepare("SELECT COUNT(*) AS n FROM main.todos WHERE status IN ('open', 'in_progress')").get().n;
131
131
  } catch {
132
132
  // todos table not yet present in older indexes; ignore
133
133
  }
134
134
 
135
+ // Roadmap surfacing: render compact tree if a cursor is set or any todo is nested.
136
+ let roadmap = null;
137
+ 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;
162
+ roadmap = {
163
+ tree: lines.join('\n'),
164
+ cursor: cursorFull ? { id: cursorFull.id, text: cursorFull.text } : null,
165
+ };
166
+ }
167
+ } catch {
168
+ // roadmap_cursor table missing on very old indexes; ignore
169
+ }
170
+
135
171
  // Build human-readable resume summary
136
172
  const changedSummary = changed.length > 0
137
173
  ? changed.slice(0, 10).map(c => `${c.change}: ${path.relative(getProjectRoot(), c.file).replace(/\\/g, '/')}`).join(', ')
@@ -143,6 +179,7 @@ export async function resume(db) {
143
179
  changed.length > 0 ? `Files changed since checkpoint: ${changedSummary}` : 'No files changed since checkpoint.',
144
180
  subSchemas.length > 0 ? `Federated sub-indexes: ${subSchemas.length}` : '',
145
181
  openTodos > 0 ? `Open todos: ${openTodos}` : '',
182
+ roadmap?.cursor ? `Roadmap cursor → (id:${roadmap.cursor.id}) ${roadmap.cursor.text}` : '',
146
183
  session.notes?.length > 0 ? `Notes: ${session.notes.map(n => (n && typeof n === 'object' && n.ref) ? `${n.description} (see: ${n.ref})` : String(n)).join(' | ')}` : '',
147
184
  ].filter(Boolean).join(' ');
148
185
 
@@ -170,6 +207,7 @@ export async function resume(db) {
170
207
  resume_summary: resumeSummary,
171
208
  };
172
209
  if (gitignoreHint) result.gitignore_hint = gitignoreHint;
210
+ if (roadmap) result.roadmap = roadmap;
173
211
  if (gitSnapshotNotes.length > 0) {
174
212
  result.migrated = `Removed ${gitSnapshotNotes.length} legacy git-snapshot note(s). Original session backed up to session.bak.json.`;
175
213
  }
@@ -1,8 +1,113 @@
1
1
  import { walkRepo } from '../indexer/fileWalker.js';
2
2
  import { scanTodos } from '../indexer/todoScanner.js';
3
3
 
4
+ const STATUS_BOX = {
5
+ open: '[ ]',
6
+ in_progress: '[~]',
7
+ done: '[x]',
8
+ blocked: '[!]',
9
+ };
10
+
11
+ const VALID_STATUS = new Set(['open', 'in_progress', 'done', 'blocked']);
12
+
13
+ function getCursor(db) {
14
+ const row = db.prepare('SELECT todo_id FROM main.roadmap_cursor WHERE singleton = 1').get();
15
+ return row?.todo_id ?? null;
16
+ }
17
+
18
+ function setCursor(db, todoId) {
19
+ db.prepare('INSERT OR REPLACE INTO main.roadmap_cursor (singleton, todo_id) VALUES (1, ?)').run(todoId);
20
+ }
21
+
22
+ function clearCursor(db) {
23
+ db.prepare('DELETE FROM main.roadmap_cursor WHERE singleton = 1').run();
24
+ }
25
+
26
+ // Mark ancestors as in_progress (only if currently 'open' — don't downgrade done/blocked).
27
+ function propagateInProgressUp(db, todoId) {
28
+ const stmt = db.prepare('SELECT parent_id, status FROM main.todos WHERE id = ?');
29
+ const update = db.prepare("UPDATE main.todos SET status = 'in_progress' WHERE id = ? AND status = 'open'");
30
+ let cur = stmt.get(todoId);
31
+ while (cur && cur.parent_id != null) {
32
+ update.run(cur.parent_id);
33
+ cur = stmt.get(cur.parent_id);
34
+ }
35
+ }
36
+
37
+ // DFS-find next actionable node starting at/after `fromId`.
38
+ // Order: first open child → next open sibling → next open uncle (walk up).
39
+ function findNextOpen(db, fromId) {
40
+ const childOf = (pid) => db.prepare(
41
+ `SELECT id FROM main.todos
42
+ WHERE ${pid == null ? 'parent_id IS NULL' : 'parent_id = ?'}
43
+ AND status IN ('open', 'in_progress')
44
+ ORDER BY position, id`
45
+ ).all(...(pid == null ? [] : [pid]));
46
+
47
+ if (fromId == null) {
48
+ const roots = childOf(null);
49
+ return roots[0]?.id ?? null;
50
+ }
51
+
52
+ const children = childOf(fromId);
53
+ if (children.length > 0) return children[0].id;
54
+
55
+ let curId = fromId;
56
+ while (curId != null) {
57
+ const cur = db.prepare('SELECT parent_id, position FROM main.todos WHERE id = ?').get(curId);
58
+ if (!cur) return null;
59
+ const sibling = db.prepare(
60
+ `SELECT id FROM main.todos
61
+ WHERE ${cur.parent_id == null ? 'parent_id IS NULL' : 'parent_id = ?'}
62
+ AND (position > ? OR (position = ? AND id > ?))
63
+ AND status IN ('open', 'in_progress')
64
+ ORDER BY position, id LIMIT 1`
65
+ ).get(...(cur.parent_id == null ? [cur.position, cur.position, curId] : [cur.parent_id, cur.position, cur.position, curId]));
66
+ if (sibling) return sibling.id;
67
+ curId = cur.parent_id;
68
+ }
69
+ return null;
70
+ }
71
+
72
+ function renderTree(db, { compact = false } = {}) {
73
+ const all = db.prepare(
74
+ 'SELECT id, parent_id, position, status, kind, text FROM main.todos ORDER BY IFNULL(parent_id, -1), position, id'
75
+ ).all();
76
+ if (all.length === 0) return '(no todos)';
77
+
78
+ const cursor = getCursor(db);
79
+ const childrenByParent = new Map();
80
+ for (const t of all) {
81
+ const key = t.parent_id ?? null;
82
+ if (!childrenByParent.has(key)) childrenByParent.set(key, []);
83
+ childrenByParent.get(key).push(t);
84
+ }
85
+
86
+ const lines = [];
87
+ const visit = (parentId, depth) => {
88
+ const kids = childrenByParent.get(parentId) || [];
89
+ for (const t of kids) {
90
+ const box = STATUS_BOX[t.status] || '[?]';
91
+ const arrow = cursor === t.id ? ' →' : '';
92
+ const tag = compact ? '' : ` (id:${t.id}${t.kind !== 'TODO' ? `, ${t.kind}` : ''})`;
93
+ lines.push(`${' '.repeat(depth)}${box}${arrow} ${t.text}${tag}`);
94
+ visit(t.id, depth + 1);
95
+ }
96
+ };
97
+ visit(null, 0);
98
+ return lines.join('\n');
99
+ }
100
+
101
+ function nextPosition(db, parentId) {
102
+ const row = db.prepare(
103
+ `SELECT COALESCE(MAX(position), -1) + 1 AS next FROM main.todos
104
+ WHERE ${parentId == null ? 'parent_id IS NULL' : 'parent_id = ?'}`
105
+ ).get(...(parentId == null ? [] : [parentId]));
106
+ return row.next;
107
+ }
108
+
4
109
  export async function todos(db, args = {}) {
5
- const { text, id, status, source } = args;
110
+ const { text, id, status, source, parent, position } = args;
6
111
  const action = args.action ?? 'list';
7
112
  const kind = args.kind;
8
113
 
@@ -13,9 +118,9 @@ export async function todos(db, args = {}) {
13
118
  if (status) { query += ' AND status = ?'; params.push(status); }
14
119
  if (source) { query += ' AND source = ?'; params.push(source); }
15
120
  if (kind) { query += ' AND kind = ?'; params.push(kind.toUpperCase()); }
16
- query += ' ORDER BY CASE status WHEN \'open\' THEN 0 ELSE 1 END, source, file, line';
121
+ query += ' ORDER BY CASE status WHEN \'open\' THEN 0 WHEN \'in_progress\' THEN 0 ELSE 1 END, source, file, line';
17
122
  const rows = db.prepare(query).all(...params);
18
- const open = rows.filter(r => r.status === 'open').length;
123
+ const open = rows.filter(r => r.status === 'open' || r.status === 'in_progress').length;
19
124
  const done = rows.filter(r => r.status === 'done').length;
20
125
  return {
21
126
  content: [{
@@ -25,12 +130,34 @@ export async function todos(db, args = {}) {
25
130
  };
26
131
  }
27
132
 
133
+ case 'tree': {
134
+ const compact = args.compact === true;
135
+ const tree = renderTree(db, { compact });
136
+ const cursor = getCursor(db);
137
+ const cursorRow = cursor ? db.prepare('SELECT id, text FROM main.todos WHERE id = ?').get(cursor) : null;
138
+ const next = findNextOpen(db, cursor);
139
+ const nextRow = next ? db.prepare('SELECT id, text FROM main.todos WHERE id = ?').get(next) : null;
140
+ const summary = [
141
+ cursorRow ? `Current: (id:${cursorRow.id}) ${cursorRow.text}` : 'Current: (no cursor set)',
142
+ nextRow && (!cursorRow || nextRow.id !== cursorRow.id) ? `Next: (id:${nextRow.id}) ${nextRow.text}` : '',
143
+ ].filter(Boolean).join('\n');
144
+ return {
145
+ content: [{ type: 'text', text: `${tree}\n\n${summary}` }],
146
+ };
147
+ }
148
+
28
149
  case 'add': {
29
150
  if (!text) throw new Error('text is required for add action');
30
151
  const todoKind = (kind || 'TODO').toUpperCase();
152
+ const parentId = parent ?? null;
153
+ if (parentId != null) {
154
+ const exists = db.prepare('SELECT id FROM main.todos WHERE id = ?').get(parentId);
155
+ if (!exists) throw new Error(`Parent todo id ${parentId} does not exist`);
156
+ }
157
+ const pos = position ?? nextPosition(db, parentId);
31
158
  const result = db.prepare(
32
- 'INSERT INTO main.todos (source, kind, text) VALUES (?, ?, ?)'
33
- ).run('manual', todoKind, text);
159
+ 'INSERT INTO main.todos (source, kind, text, parent_id, position) VALUES (?, ?, ?, ?, ?)'
160
+ ).run('manual', todoKind, text, parentId, pos);
34
161
  const todo = db.prepare('SELECT * FROM main.todos WHERE id = ?').get(result.lastInsertRowid);
35
162
  return {
36
163
  content: [{ type: 'text', text: JSON.stringify({ added: todo }, null, 2) }],
@@ -43,9 +170,63 @@ export async function todos(db, args = {}) {
43
170
  'UPDATE main.todos SET status = ?, resolved_at = datetime(\'now\') WHERE id = ?'
44
171
  ).run('done', id);
45
172
  if (updated.changes === 0) throw new Error(`No todo found with id ${id}`);
173
+ const cursor = getCursor(db);
174
+ let advanced = null;
175
+ if (cursor === id) {
176
+ const next = findNextOpen(db, id);
177
+ if (next) { setCursor(db, next); advanced = next; }
178
+ else clearCursor(db);
179
+ }
180
+ const todo = db.prepare('SELECT * FROM main.todos WHERE id = ?').get(id);
181
+ return {
182
+ content: [{ type: 'text', text: JSON.stringify({ resolved: todo, cursor_advanced_to: advanced }, null, 2) }],
183
+ };
184
+ }
185
+
186
+ case 'focus': {
187
+ if (!id) throw new Error('id is required for focus action');
188
+ const todo = db.prepare('SELECT * FROM main.todos WHERE id = ?').get(id);
189
+ if (!todo) throw new Error(`No todo found with id ${id}`);
190
+ setCursor(db, id);
191
+ db.prepare("UPDATE main.todos SET status = 'in_progress' WHERE id = ? AND status = 'open'").run(id);
192
+ propagateInProgressUp(db, id);
193
+ const refreshed = db.prepare('SELECT * FROM main.todos WHERE id = ?').get(id);
194
+ return {
195
+ content: [{ type: 'text', text: JSON.stringify({ focused: refreshed }, null, 2) }],
196
+ };
197
+ }
198
+
199
+ case 'next': {
200
+ const cursor = getCursor(db);
201
+ const next = findNextOpen(db, cursor);
202
+ if (next == null) {
203
+ clearCursor(db);
204
+ return {
205
+ content: [{ type: 'text', text: JSON.stringify({ cursor: null, message: 'No open todos remaining.' }, null, 2) }],
206
+ };
207
+ }
208
+ setCursor(db, next);
209
+ propagateInProgressUp(db, next);
210
+ const todo = db.prepare('SELECT * FROM main.todos WHERE id = ?').get(next);
211
+ return {
212
+ content: [{ type: 'text', text: JSON.stringify({ cursor: next, todo }, null, 2) }],
213
+ };
214
+ }
215
+
216
+ case 'set_status': {
217
+ if (!id) throw new Error('id is required for set_status action');
218
+ if (!status) throw new Error('status is required for set_status action');
219
+ if (!VALID_STATUS.has(status)) {
220
+ throw new Error(`Invalid status "${status}". Use: open, in_progress, done, blocked`);
221
+ }
222
+ const resolvedAt = status === 'done' ? "datetime('now')" : 'NULL';
223
+ const updated = db.prepare(
224
+ `UPDATE main.todos SET status = ?, resolved_at = ${resolvedAt} WHERE id = ?`
225
+ ).run(status, id);
226
+ if (updated.changes === 0) throw new Error(`No todo found with id ${id}`);
46
227
  const todo = db.prepare('SELECT * FROM main.todos WHERE id = ?').get(id);
47
228
  return {
48
- content: [{ type: 'text', text: JSON.stringify({ resolved: todo }, null, 2) }],
229
+ content: [{ type: 'text', text: JSON.stringify({ updated: todo }, null, 2) }],
49
230
  };
50
231
  }
51
232
 
@@ -53,7 +234,7 @@ export async function todos(db, args = {}) {
53
234
  const files = await walkRepo(process.cwd());
54
235
  const found = await scanTodos(db, files);
55
236
  const open = db.prepare(
56
- 'SELECT COUNT(*) AS n FROM main.todos WHERE status = \'open\''
237
+ 'SELECT COUNT(*) AS n FROM main.todos WHERE status IN (\'open\', \'in_progress\')'
57
238
  ).get().n;
58
239
  return {
59
240
  content: [{
@@ -64,6 +245,6 @@ export async function todos(db, args = {}) {
64
245
  }
65
246
 
66
247
  default:
67
- throw new Error(`Unknown action: "${action}". Use list, add, resolve, or scan.`);
248
+ throw new Error(`Unknown action: "${action}". Use list, tree, add, resolve, focus, next, set_status, or scan.`);
68
249
  }
69
250
  }