@misterhuydo/cairn-mcp 1.8.1 → 1.10.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 +4 -4
- package/bin/cairn-cli.js +160 -2
- package/index.js +24 -1
- package/package.json +1 -1
- package/src/graph/db.js +83 -0
- package/src/tools/memo.js +120 -1
- package/src/tools/resume.js +39 -1
- package/src/tools/roadmap.js +302 -0
- package/src/tools/todos.js +4 -0
- package/test/roadmap.test.js +196 -0
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,158 @@ 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 only when a cursor is set on the new
|
|
541
|
+
// roadmap_phases table (v1.10.0+). Silent (exit 0) when no .cairn, no DB,
|
|
542
|
+
// or no cursor. On *cursor advance* (cursor changed since last fire),
|
|
543
|
+
// also injects the decision memo's section for the new phase, so the
|
|
544
|
+
// architectural reasoning is in context exactly when needed.
|
|
545
|
+
const cairnDir = path.join(process.cwd(), '.cairn');
|
|
546
|
+
const dbPath = path.join(cairnDir, 'index.db');
|
|
547
|
+
if (!fs.existsSync(dbPath)) process.exit(0);
|
|
548
|
+
|
|
549
|
+
let DatabaseSync;
|
|
550
|
+
try { ({ DatabaseSync } = await import('node:sqlite')); } catch { process.exit(0); }
|
|
551
|
+
|
|
552
|
+
let db;
|
|
553
|
+
try { db = new DatabaseSync(dbPath, { readonly: true }); } catch { process.exit(0); }
|
|
554
|
+
|
|
555
|
+
const slugify = (n) => n.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
|
|
556
|
+
|
|
557
|
+
// Extract `### Phase N: ...` section body from a decision memo file.
|
|
558
|
+
function extractPhaseSection(memoPath, phaseNumber) {
|
|
559
|
+
if (!fs.existsSync(memoPath)) return null;
|
|
560
|
+
const raw = fs.readFileSync(memoPath, 'utf8');
|
|
561
|
+
const lines = raw.split(/\r?\n/);
|
|
562
|
+
let inPhasesSection = false;
|
|
563
|
+
let collecting = false;
|
|
564
|
+
const acc = [];
|
|
565
|
+
for (const line of lines) {
|
|
566
|
+
if (/^##\s+Phases\s*$/i.test(line)) { inPhasesSection = true; continue; }
|
|
567
|
+
if (inPhasesSection && /^##\s+/.test(line) && !/^###/.test(line)) break;
|
|
568
|
+
if (!inPhasesSection) continue;
|
|
569
|
+
const m = /^###\s+Phase\s+(\d+)\s*:\s*(.+?)\s*$/.exec(line);
|
|
570
|
+
if (m) {
|
|
571
|
+
if (collecting) break;
|
|
572
|
+
if (parseInt(m[1], 10) === phaseNumber) {
|
|
573
|
+
acc.push(`### Phase ${m[1]}: ${m[2]}`);
|
|
574
|
+
collecting = true;
|
|
575
|
+
}
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
if (collecting) acc.push(line);
|
|
579
|
+
}
|
|
580
|
+
return acc.join('\n').trim() || null;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
try {
|
|
584
|
+
const cursorRow = db.prepare("SELECT phase_id FROM roadmap_cursor WHERE singleton = 1").get();
|
|
585
|
+
const cursorId = cursorRow?.phase_id ?? null;
|
|
586
|
+
if (cursorId == null) { db.close(); process.exit(0); }
|
|
587
|
+
|
|
588
|
+
const all = db.prepare(
|
|
589
|
+
'SELECT id, decision_name, phase_number, parent_phase_id, text, status, position FROM roadmap_phases ORDER BY decision_name, phase_number, position, id'
|
|
590
|
+
).all();
|
|
591
|
+
|
|
592
|
+
// Build tree
|
|
593
|
+
const STATUS_BOX = { open: '[ ]', in_progress: '[~]', done: '[x]', blocked: '[!]', stale: '[s]' };
|
|
594
|
+
const byParent = new Map();
|
|
595
|
+
for (const p of all) {
|
|
596
|
+
const k = p.parent_phase_id ?? 'root';
|
|
597
|
+
if (!byParent.has(k)) byParent.set(k, []);
|
|
598
|
+
byParent.get(k).push(p);
|
|
599
|
+
}
|
|
600
|
+
for (const arr of byParent.values()) {
|
|
601
|
+
arr.sort((a, b) => {
|
|
602
|
+
if (a.decision_name && b.decision_name && a.decision_name !== b.decision_name) {
|
|
603
|
+
return a.decision_name.localeCompare(b.decision_name);
|
|
604
|
+
}
|
|
605
|
+
if (a.phase_number != null && b.phase_number != null) return a.phase_number - b.phase_number;
|
|
606
|
+
return a.position - b.position || a.id - b.id;
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
const roots = byParent.get('root') || [];
|
|
610
|
+
const decisionRoots = new Map();
|
|
611
|
+
for (const r of roots) {
|
|
612
|
+
if (r.decision_name == null) continue;
|
|
613
|
+
if (!decisionRoots.has(r.decision_name)) decisionRoots.set(r.decision_name, []);
|
|
614
|
+
decisionRoots.get(r.decision_name).push(r);
|
|
615
|
+
}
|
|
616
|
+
const lines = [];
|
|
617
|
+
const visitSubtree = (parentId, depth) => {
|
|
618
|
+
for (const p of byParent.get(parentId) || []) {
|
|
619
|
+
if (p.status === 'stale') continue;
|
|
620
|
+
const arrow = cursorId === p.id ? ' →' : '';
|
|
621
|
+
const label = p.phase_number != null ? `Phase ${p.phase_number}: ${p.text}` : p.text;
|
|
622
|
+
lines.push(`${' '.repeat(depth)}${STATUS_BOX[p.status] || '[?]'}${arrow} ${label}`);
|
|
623
|
+
visitSubtree(p.id, depth + 1);
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
for (const [decisionName, phases] of decisionRoots) {
|
|
627
|
+
const live = phases.filter(p => p.status !== 'stale');
|
|
628
|
+
if (live.length === 0) continue;
|
|
629
|
+
const allDone = live.every(p => p.status === 'done');
|
|
630
|
+
const anyInProg = live.some(p => p.status === 'in_progress');
|
|
631
|
+
const rootStatus = allDone ? 'done' : (anyInProg ? 'in_progress' : 'open');
|
|
632
|
+
lines.push(`${STATUS_BOX[rootStatus]} Decision: ${decisionName}`);
|
|
633
|
+
for (const p of phases) {
|
|
634
|
+
if (p.status === 'stale') continue;
|
|
635
|
+
const arrow = cursorId === p.id ? ' →' : '';
|
|
636
|
+
lines.push(` ${STATUS_BOX[p.status] || '[?]'}${arrow} Phase ${p.phase_number}: ${p.text}`);
|
|
637
|
+
visitSubtree(p.id, 2);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Find current node + walk up to its root phase to identify decision/phase
|
|
642
|
+
const findById = (id) => all.find(p => p.id === id);
|
|
643
|
+
let cursorNode = findById(cursorId);
|
|
644
|
+
let rootDecision = cursorNode?.decision_name ?? null;
|
|
645
|
+
let rootPhaseNum = cursorNode?.phase_number ?? null;
|
|
646
|
+
let walker = cursorNode;
|
|
647
|
+
while (walker && walker.parent_phase_id != null) {
|
|
648
|
+
walker = findById(walker.parent_phase_id);
|
|
649
|
+
if (!walker) break;
|
|
650
|
+
rootDecision = walker.decision_name ?? rootDecision;
|
|
651
|
+
rootPhaseNum = walker.phase_number ?? rootPhaseNum;
|
|
652
|
+
}
|
|
653
|
+
db.close();
|
|
654
|
+
|
|
655
|
+
// Detect advance via state file
|
|
656
|
+
const stateFile = path.join(cairnDir, '.last-roadmap-cursor');
|
|
657
|
+
let lastCursor = null;
|
|
658
|
+
try { lastCursor = parseInt(fs.readFileSync(stateFile, 'utf8').trim(), 10); } catch {}
|
|
659
|
+
const advanced = lastCursor !== cursorId;
|
|
660
|
+
try { fs.writeFileSync(stateFile, String(cursorId), 'utf8'); } catch {}
|
|
661
|
+
|
|
662
|
+
const out = [
|
|
663
|
+
'[cairn] ROADMAP — cursor is active.',
|
|
664
|
+
...lines.map(l => '[cairn] ' + l),
|
|
665
|
+
];
|
|
666
|
+
const currentLabel = cursorNode
|
|
667
|
+
? (cursorNode.phase_number != null
|
|
668
|
+
? `${cursorNode.decision_name} / Phase ${cursorNode.phase_number} (${cursorNode.text})`
|
|
669
|
+
: `${rootDecision} / Phase ${rootPhaseNum} → sub: ${cursorNode.text}`)
|
|
670
|
+
: '(unknown)';
|
|
671
|
+
out.push(`[cairn] Current: ${currentLabel}`);
|
|
672
|
+
|
|
673
|
+
if (advanced && rootDecision != null && rootPhaseNum != null) {
|
|
674
|
+
const memoPath = path.join(cairnDir, 'memory', `decision_${slugify(rootDecision)}.md`);
|
|
675
|
+
const section = extractPhaseSection(memoPath, rootPhaseNum);
|
|
676
|
+
if (section) {
|
|
677
|
+
out.push('[cairn] ───── Decision memo (current phase) ─────');
|
|
678
|
+
for (const ln of section.split('\n')) out.push('[cairn] ' + ln);
|
|
679
|
+
out.push('[cairn] ───── (re-read after each phase to confirm direction) ─────');
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
out.push('[cairn] Use cairn_roadmap set_status <id> done (auto-advances), focus <id>, or next to move cursor.');
|
|
684
|
+
process.stdout.write(out.join('\n') + '\n');
|
|
685
|
+
} catch (e) {
|
|
686
|
+
try { db?.close(); } catch {}
|
|
687
|
+
process.stderr.write(`[cairn] roadmap-hint error: ${e.message}\n`);
|
|
688
|
+
}
|
|
689
|
+
process.exit(0);
|
|
690
|
+
|
|
539
691
|
} else if (subcommand === 'install') {
|
|
540
692
|
// One-shot setup: register MCP server in ~/.claude.json + install global hooks.
|
|
541
693
|
const platform = process.platform;
|
|
@@ -568,6 +720,7 @@ if (subcommand === '--version' || subcommand === '-v') {
|
|
|
568
720
|
console.log(' PreToolUse[Write] -> cairn edit-guard (clear minify state before full file overwrite)');
|
|
569
721
|
console.log(' PostToolUse[Write] -> sync-memory-forward.mjs (sync ~/.claude/memory -> .cairn/memory/auto-memory/)');
|
|
570
722
|
console.log(' Stop -> cairn checkpoint --auto (auto-save session)');
|
|
723
|
+
console.log(' Stop -> cairn roadmap-hint (re-print roadmap when cursor is active)');
|
|
571
724
|
console.log(' UserPromptSubmit -> cairn resume-hint (remind Claude of prior session)');
|
|
572
725
|
console.log(' UserPromptSubmit -> sync-memory-restore.mjs (sync .cairn/memory/ -> ~/.claude/memory, mtime-based)');
|
|
573
726
|
console.log('');
|
|
@@ -592,6 +745,7 @@ if (subcommand === '--version' || subcommand === '-v') {
|
|
|
592
745
|
console.log(' PostToolUse[Write] -> sync-memory-forward.mjs (sync ~/.claude/memory -> .cairn/memory/auto-memory/)');
|
|
593
746
|
}
|
|
594
747
|
console.log(' Stop -> cairn checkpoint --auto (auto-save session)');
|
|
748
|
+
console.log(' Stop -> cairn roadmap-hint (re-print roadmap when cursor is active)');
|
|
595
749
|
console.log(' UserPromptSubmit -> cairn resume-hint (remind Claude of prior session)');
|
|
596
750
|
if (isGlobal) {
|
|
597
751
|
console.log(' UserPromptSubmit -> sync-memory-restore.mjs (sync .cairn/memory/ -> ~/.claude/memory, mtime-based)');
|
|
@@ -599,7 +753,7 @@ if (subcommand === '--version' || subcommand === '-v') {
|
|
|
599
753
|
process.exit(0);
|
|
600
754
|
|
|
601
755
|
} else {
|
|
602
|
-
console.error('Usage: cairn <--version | install | install-hooks [--global] | minify | edit-guard | validate-map | checkpoint --auto | resume-hint>');
|
|
756
|
+
console.error('Usage: cairn <--version | install | install-hooks [--global] | minify | edit-guard | validate-map | checkpoint --auto | resume-hint | roadmap-hint>');
|
|
603
757
|
process.exit(1);
|
|
604
758
|
}
|
|
605
759
|
|
|
@@ -632,8 +786,12 @@ function applyHooks(settingsDir, isGlobal) {
|
|
|
632
786
|
const stopHooks = settings.hooks.Stop || [];
|
|
633
787
|
if (!stopHooks.some(h => h.hooks?.some(hh => hh.command === 'cairn checkpoint --auto'))) {
|
|
634
788
|
stopHooks.push({ hooks: [{ type: 'command', command: 'cairn checkpoint --auto' }] });
|
|
635
|
-
settings.hooks.Stop = stopHooks;
|
|
636
789
|
}
|
|
790
|
+
// Stop: cairn roadmap-hint (silent unless a roadmap_cursor is set)
|
|
791
|
+
if (!stopHooks.some(h => h.hooks?.some(hh => hh.command === 'cairn roadmap-hint'))) {
|
|
792
|
+
stopHooks.push({ hooks: [{ type: 'command', command: 'cairn roadmap-hint' }] });
|
|
793
|
+
}
|
|
794
|
+
settings.hooks.Stop = stopHooks;
|
|
637
795
|
|
|
638
796
|
// UserPromptSubmit: cairn resume-hint
|
|
639
797
|
let submitHooks = settings.hooks.UserPromptSubmit || [];
|
package/index.js
CHANGED
|
@@ -14,6 +14,7 @@ import { resume } from './src/tools/resume.js';
|
|
|
14
14
|
import { minify } from './src/tools/minify.js';
|
|
15
15
|
import { outlineProject } from './src/tools/outline.js';
|
|
16
16
|
import { todos } from './src/tools/todos.js';
|
|
17
|
+
import { roadmap } from './src/tools/roadmap.js';
|
|
17
18
|
import { memo } from './src/tools/memo.js';
|
|
18
19
|
import { switchProject } from './src/tools/switch.js';
|
|
19
20
|
import { employMemory } from './src/tools/employMemory.js';
|
|
@@ -262,7 +263,7 @@ Use to: get a project-wide bird's-eye view, triage code quality, find where to l
|
|
|
262
263
|
},
|
|
263
264
|
{
|
|
264
265
|
name: 'cairn_todos',
|
|
265
|
-
description: '
|
|
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
267
|
inputSchema: {
|
|
267
268
|
type: 'object',
|
|
268
269
|
properties: {
|
|
@@ -280,6 +281,27 @@ Use to: get a project-wide bird's-eye view, triage code quality, find where to l
|
|
|
280
281
|
required: ['action'],
|
|
281
282
|
},
|
|
282
283
|
},
|
|
284
|
+
{
|
|
285
|
+
name: 'cairn_roadmap',
|
|
286
|
+
description: 'Active project plan: phases with status + decision-memo backing + cursor. 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). Sub-tasks under a phase are ad-hoc via add --parent. 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.',
|
|
287
|
+
inputSchema: {
|
|
288
|
+
type: 'object',
|
|
289
|
+
properties: {
|
|
290
|
+
action: {
|
|
291
|
+
type: 'string',
|
|
292
|
+
enum: ['tree', 'status', 'focus', 'next', 'set_status', 'add'],
|
|
293
|
+
description: 'tree: render roadmap grouped by decision | status: one-line summary (decisions in flight + current) | focus <id>: move cursor, mark in_progress, propagate up | next: DFS-advance cursor to next actionable phase (skips stale) | 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 (inherits decision_ref)',
|
|
294
|
+
},
|
|
295
|
+
id: { type: 'number', description: 'Phase ID (focus, set_status)' },
|
|
296
|
+
status: { type: 'string', enum: ['open', 'in_progress', 'done', 'blocked', 'stale'], description: 'New status (set_status)' },
|
|
297
|
+
parent: { type: 'number', description: 'Parent phase ID (add — required, ad-hoc sub-tasks must hang under an existing phase)' },
|
|
298
|
+
text: { type: 'string', description: 'Sub-task text (add action)' },
|
|
299
|
+
compact: { type: 'boolean', description: 'Compact tree render — strips id tags (tree action)' },
|
|
300
|
+
hide_stale: { type: 'boolean', description: 'Hide stale phases in tree render (tree action)' },
|
|
301
|
+
},
|
|
302
|
+
required: ['action'],
|
|
303
|
+
},
|
|
304
|
+
},
|
|
283
305
|
],
|
|
284
306
|
}));
|
|
285
307
|
|
|
@@ -300,6 +322,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
300
322
|
case 'cairn_outline': return outlineProject(db, args);
|
|
301
323
|
case 'cairn_minify': return minify(db, args);
|
|
302
324
|
case 'cairn_todos': return await todos(db, args);
|
|
325
|
+
case 'cairn_roadmap': return roadmap(db, args);
|
|
303
326
|
case 'cairn_memo': return memo(db, args);
|
|
304
327
|
case 'cairn_switch': return switchProject(db, args);
|
|
305
328
|
case 'cairn_employ_memory': return employMemory(db, args);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@misterhuydo/cairn-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.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,32 @@ 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
|
+
);
|
|
80
|
+
|
|
81
|
+
CREATE TABLE IF NOT EXISTS roadmap_phases (
|
|
82
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
83
|
+
decision_name TEXT,
|
|
84
|
+
phase_number INTEGER,
|
|
85
|
+
parent_phase_id INTEGER REFERENCES roadmap_phases(id) ON DELETE CASCADE,
|
|
86
|
+
text TEXT NOT NULL,
|
|
87
|
+
body TEXT,
|
|
88
|
+
status TEXT NOT NULL DEFAULT 'open',
|
|
89
|
+
position INTEGER NOT NULL DEFAULT 0,
|
|
90
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
91
|
+
resolved_at TEXT
|
|
92
|
+
);
|
|
93
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_roadmap_phases_decision_phase
|
|
94
|
+
ON roadmap_phases(decision_name, phase_number)
|
|
95
|
+
WHERE decision_name IS NOT NULL AND phase_number IS NOT NULL;
|
|
73
96
|
`;
|
|
74
97
|
|
|
75
98
|
// Each sub-DB gets its IDs offset by this multiplier to prevent collisions
|
|
@@ -190,6 +213,66 @@ export function openDB() {
|
|
|
190
213
|
db.exec(SCHEMA);
|
|
191
214
|
// Migration: add line column to existing DBs that predate v1.7.0
|
|
192
215
|
try { db.exec('ALTER TABLE main.symbols ADD COLUMN line INTEGER'); } catch {}
|
|
216
|
+
// Migration: roadmap tree columns on todos (v1.9.0 — kept dormant in v1.10+ for back-compat)
|
|
217
|
+
// Idempotent — fails silently if present. Columns are no longer read by tools as of v1.10.0,
|
|
218
|
+
// but kept to avoid destructive ALTER on user data.
|
|
219
|
+
let addedRoadmapCols = false;
|
|
220
|
+
try { db.exec('ALTER TABLE main.todos ADD COLUMN parent_id INTEGER REFERENCES todos(id) ON DELETE SET NULL'); addedRoadmapCols = true; } catch {}
|
|
221
|
+
try { db.exec('ALTER TABLE main.todos ADD COLUMN position INTEGER NOT NULL DEFAULT 0'); addedRoadmapCols = true; } catch {}
|
|
222
|
+
if (addedRoadmapCols) {
|
|
223
|
+
db.exec(`
|
|
224
|
+
UPDATE main.todos AS t
|
|
225
|
+
SET position = (
|
|
226
|
+
SELECT rn - 1 FROM (
|
|
227
|
+
SELECT id, ROW_NUMBER() OVER (
|
|
228
|
+
PARTITION BY IFNULL(parent_id, -1) ORDER BY id
|
|
229
|
+
) AS rn FROM main.todos
|
|
230
|
+
) ord WHERE ord.id = t.id
|
|
231
|
+
)
|
|
232
|
+
`);
|
|
233
|
+
}
|
|
234
|
+
// Migration: roadmap_cursor.phase_id (v1.10.0 — points at roadmap_phases.id)
|
|
235
|
+
try { db.exec('ALTER TABLE main.roadmap_cursor ADD COLUMN phase_id INTEGER REFERENCES roadmap_phases(id) ON DELETE SET NULL'); } catch {}
|
|
236
|
+
// Migration: copy any v1.9.0 in-flight roadmap (todos with parent_id) into roadmap_phases
|
|
237
|
+
// as a synthetic 'legacy_v1_9' decision. Runs once — guarded by checking if any phase exists
|
|
238
|
+
// under that decision_name.
|
|
239
|
+
try {
|
|
240
|
+
const legacyExists = db.prepare(
|
|
241
|
+
"SELECT 1 FROM main.roadmap_phases WHERE decision_name = 'legacy_v1_9' LIMIT 1"
|
|
242
|
+
).get();
|
|
243
|
+
if (!legacyExists) {
|
|
244
|
+
const v9Roots = db.prepare(
|
|
245
|
+
"SELECT id, text, status, position FROM main.todos WHERE parent_id IS NULL AND id IN (SELECT DISTINCT parent_id FROM main.todos WHERE parent_id IS NOT NULL) ORDER BY position, id"
|
|
246
|
+
).all();
|
|
247
|
+
if (v9Roots.length > 0) {
|
|
248
|
+
const insertPhase = db.prepare(
|
|
249
|
+
"INSERT INTO main.roadmap_phases (decision_name, phase_number, parent_phase_id, text, status, position) VALUES (?, ?, ?, ?, ?, ?)"
|
|
250
|
+
);
|
|
251
|
+
const oldToNew = new Map();
|
|
252
|
+
let phaseNum = 1;
|
|
253
|
+
for (const root of v9Roots) {
|
|
254
|
+
const r = insertPhase.run('legacy_v1_9', phaseNum++, null, root.text, root.status, root.position);
|
|
255
|
+
oldToNew.set(root.id, r.lastInsertRowid);
|
|
256
|
+
}
|
|
257
|
+
// Sub-tasks: keep one level (v1.9.0's nesting was usually one deep)
|
|
258
|
+
const v9Children = db.prepare(
|
|
259
|
+
"SELECT id, parent_id, text, status, position FROM main.todos WHERE parent_id IS NOT NULL ORDER BY position, id"
|
|
260
|
+
).all();
|
|
261
|
+
for (const c of v9Children) {
|
|
262
|
+
const newParent = oldToNew.get(c.parent_id);
|
|
263
|
+
if (newParent == null) continue;
|
|
264
|
+
insertPhase.run(null, null, newParent, c.text, c.status, c.position);
|
|
265
|
+
}
|
|
266
|
+
// If old cursor pointed at a migrated todo, repoint it to the new phase id.
|
|
267
|
+
const oldCursor = db.prepare('SELECT todo_id FROM main.roadmap_cursor WHERE singleton = 1').get();
|
|
268
|
+
if (oldCursor?.todo_id != null && oldToNew.has(oldCursor.todo_id)) {
|
|
269
|
+
db.prepare('UPDATE main.roadmap_cursor SET phase_id = ? WHERE singleton = 1').run(oldToNew.get(oldCursor.todo_id));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
} catch (e) {
|
|
274
|
+
process.stderr.write(`[cairn] v1.9.0→v1.10.0 roadmap migration skipped: ${e.message}\n`);
|
|
275
|
+
}
|
|
193
276
|
mountParentSubIndexes(db);
|
|
194
277
|
refreshFederatedViews(db);
|
|
195
278
|
return db;
|
package/src/tools/memo.js
CHANGED
|
@@ -5,6 +5,103 @@ import { getMemoryDir } from '../graph/cwd.js';
|
|
|
5
5
|
export const TRANSFERABLE_TYPES = ['preference', 'experience'];
|
|
6
6
|
export const MEMORY_TYPES = ['preference', 'experience', 'decision', 'knowledge'];
|
|
7
7
|
|
|
8
|
+
// Parse a decision memo's body for a `## Phases` section. Returns
|
|
9
|
+
// [{ phase_number, text, body }, ...]. Returns [] if no Phases section.
|
|
10
|
+
// Phase headers must look like: ### Phase N: title text
|
|
11
|
+
// Body is lines following the header until next ### Phase or ## heading.
|
|
12
|
+
// Duplicate phase_numbers: first wins, subsequent dropped (caller should warn).
|
|
13
|
+
export function parsePhases(content) {
|
|
14
|
+
if (!content || typeof content !== 'string') return [];
|
|
15
|
+
const lines = content.split(/\r?\n/);
|
|
16
|
+
let inPhasesSection = false;
|
|
17
|
+
let current = null;
|
|
18
|
+
const phases = [];
|
|
19
|
+
const seen = new Set();
|
|
20
|
+
|
|
21
|
+
for (const line of lines) {
|
|
22
|
+
// Section heading detection
|
|
23
|
+
if (/^##\s+Phases\s*$/i.test(line)) { inPhasesSection = true; continue; }
|
|
24
|
+
if (/^##\s+/.test(line) && !/^###/.test(line)) {
|
|
25
|
+
// Any other ## heading ends the Phases section
|
|
26
|
+
if (current) { phases.push(current); current = null; }
|
|
27
|
+
inPhasesSection = false;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (!inPhasesSection) continue;
|
|
31
|
+
|
|
32
|
+
// Phase header: ### Phase N: title
|
|
33
|
+
const phaseMatch = /^###\s+Phase\s+(\d+)\s*:\s*(.+?)\s*$/.exec(line);
|
|
34
|
+
if (phaseMatch) {
|
|
35
|
+
if (current) phases.push(current);
|
|
36
|
+
const num = parseInt(phaseMatch[1], 10);
|
|
37
|
+
const text = phaseMatch[2];
|
|
38
|
+
if (seen.has(num)) {
|
|
39
|
+
process.stderr.write(`[cairn] memo phase parser: duplicate Phase ${num} ignored\n`);
|
|
40
|
+
current = null;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
seen.add(num);
|
|
44
|
+
current = { phase_number: num, text, bodyLines: [] };
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
// Accumulate body
|
|
48
|
+
if (current) current.bodyLines.push(line);
|
|
49
|
+
}
|
|
50
|
+
if (current) phases.push(current);
|
|
51
|
+
|
|
52
|
+
return phases.map(p => ({
|
|
53
|
+
phase_number: p.phase_number,
|
|
54
|
+
text: p.text,
|
|
55
|
+
body: p.bodyLines.join('\n').trim() || null,
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Sync parsed phases to roadmap_phases for a given decision.
|
|
60
|
+
// Re-edit semantics:
|
|
61
|
+
// - Phase present in memo + DB: update text/body, preserve status/position
|
|
62
|
+
// - Phase added (in memo, not in DB): insert as 'open' with next position
|
|
63
|
+
// - Phase removed (in DB, not in memo): mark status='stale' (preserve work history)
|
|
64
|
+
// Sub-tasks (parent_phase_id NOT NULL, decision_name NULL) are ad-hoc and
|
|
65
|
+
// untouched by this sync — they live and die with the parent phase via FK cascade.
|
|
66
|
+
export function syncPhasesToRoadmap(db, decisionName, phases) {
|
|
67
|
+
const existing = db.prepare(
|
|
68
|
+
'SELECT id, phase_number, status FROM main.roadmap_phases WHERE decision_name = ? ORDER BY phase_number'
|
|
69
|
+
).all(decisionName);
|
|
70
|
+
const existingByNum = new Map(existing.map(p => [p.phase_number, p]));
|
|
71
|
+
const seenNums = new Set();
|
|
72
|
+
|
|
73
|
+
const update = db.prepare(
|
|
74
|
+
'UPDATE main.roadmap_phases SET text = ?, body = ?, status = CASE WHEN status = ? THEN ? ELSE status END WHERE id = ?'
|
|
75
|
+
);
|
|
76
|
+
const insert = db.prepare(
|
|
77
|
+
'INSERT INTO main.roadmap_phases (decision_name, phase_number, parent_phase_id, text, body, status, position) VALUES (?, ?, NULL, ?, ?, ?, ?)'
|
|
78
|
+
);
|
|
79
|
+
const markStale = db.prepare("UPDATE main.roadmap_phases SET status = 'stale' WHERE id = ?");
|
|
80
|
+
|
|
81
|
+
for (let i = 0; i < phases.length; i++) {
|
|
82
|
+
const p = phases[i];
|
|
83
|
+
seenNums.add(p.phase_number);
|
|
84
|
+
const existingRow = existingByNum.get(p.phase_number);
|
|
85
|
+
if (existingRow) {
|
|
86
|
+
// Re-activate stale phases on re-add (text matched by phase_number, so it's
|
|
87
|
+
// the "same" phase being restored).
|
|
88
|
+
update.run(p.text, p.body, 'stale', 'open', existingRow.id);
|
|
89
|
+
} else {
|
|
90
|
+
insert.run(decisionName, p.phase_number, p.text, p.body, 'open', i);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
for (const [num, row] of existingByNum) {
|
|
94
|
+
if (!seenNums.has(num) && row.status !== 'stale') {
|
|
95
|
+
markStale.run(row.id);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
updated: phases.filter(p => existingByNum.has(p.phase_number)).length,
|
|
100
|
+
inserted: phases.filter(p => !existingByNum.has(p.phase_number)).length,
|
|
101
|
+
staled: existing.filter(r => !seenNums.has(r.phase_number) && r.status !== 'stale').length,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
8
105
|
function slugify(name) {
|
|
9
106
|
return name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '');
|
|
10
107
|
}
|
|
@@ -60,7 +157,7 @@ export function loadPreferenceMemories(memoryDir) {
|
|
|
60
157
|
.filter(Boolean);
|
|
61
158
|
}
|
|
62
159
|
|
|
63
|
-
export function memo(
|
|
160
|
+
export function memo(db, { action, name, type, content, description = '', origin }) {
|
|
64
161
|
const memoryDir = getMemoryDir();
|
|
65
162
|
|
|
66
163
|
if (action === 'list') {
|
|
@@ -113,6 +210,14 @@ export function memo(_db, { action, name, type, content, description = '', origi
|
|
|
113
210
|
const filePath = path.join(memoryDir, removed.file);
|
|
114
211
|
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
115
212
|
writeIndex(memoryDir, entries);
|
|
213
|
+
// If this was a decision memo, mark all its roadmap phases as stale (preserve history).
|
|
214
|
+
if (removed.type === 'decision' && db) {
|
|
215
|
+
try {
|
|
216
|
+
db.prepare(
|
|
217
|
+
"UPDATE main.roadmap_phases SET status = 'stale' WHERE decision_name = ? AND status != 'stale'"
|
|
218
|
+
).run(removed.name);
|
|
219
|
+
} catch {}
|
|
220
|
+
}
|
|
116
221
|
return { content: [{ type: 'text', text: JSON.stringify({ deleted: true, name }, null, 2) }] };
|
|
117
222
|
}
|
|
118
223
|
|
|
@@ -153,6 +258,19 @@ export function memo(_db, { action, name, type, content, description = '', origi
|
|
|
153
258
|
else entries.push(entry);
|
|
154
259
|
writeIndex(memoryDir, entries);
|
|
155
260
|
|
|
261
|
+
// Decision memos with a `## Phases` section seed/update the project roadmap.
|
|
262
|
+
let phaseSync = null;
|
|
263
|
+
if (type === 'decision' && db) {
|
|
264
|
+
try {
|
|
265
|
+
const phases = parsePhases(content);
|
|
266
|
+
if (phases.length > 0) {
|
|
267
|
+
phaseSync = syncPhasesToRoadmap(db, name, phases);
|
|
268
|
+
}
|
|
269
|
+
} catch (e) {
|
|
270
|
+
process.stderr.write(`[cairn] phase sync failed for ${name}: ${e.message}\n`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
156
274
|
return {
|
|
157
275
|
content: [{
|
|
158
276
|
type: 'text',
|
|
@@ -162,6 +280,7 @@ export function memo(_db, { action, name, type, content, description = '', origi
|
|
|
162
280
|
type,
|
|
163
281
|
file: filePath,
|
|
164
282
|
action: isUpdate ? 'updated' : 'created',
|
|
283
|
+
...(phaseSync ? { phase_sync: phaseSync } : {}),
|
|
165
284
|
}, null, 2),
|
|
166
285
|
}],
|
|
167
286
|
};
|
package/src/tools/resume.js
CHANGED
|
@@ -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(
|
|
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
|
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// cairn_roadmap — active project plan with cursor + decision-memo backed phases.
|
|
2
|
+
// Phases enter the roadmap only via decision memos (see memo.js syncPhasesToRoadmap).
|
|
3
|
+
// Sub-tasks under a phase are ad-hoc, added via `add --parent <phase_id>`.
|
|
4
|
+
|
|
5
|
+
const STATUS_BOX = {
|
|
6
|
+
open: '[ ]',
|
|
7
|
+
in_progress: '[~]',
|
|
8
|
+
done: '[x]',
|
|
9
|
+
blocked: '[!]',
|
|
10
|
+
stale: '[s]',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const VALID_STATUS = new Set(['open', 'in_progress', 'done', 'blocked', 'stale']);
|
|
14
|
+
const ACTIONABLE = new Set(['open', 'in_progress']);
|
|
15
|
+
|
|
16
|
+
function getCursor(db) {
|
|
17
|
+
const row = db.prepare('SELECT phase_id FROM main.roadmap_cursor WHERE singleton = 1').get();
|
|
18
|
+
return row?.phase_id ?? null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function setCursor(db, phaseId) {
|
|
22
|
+
db.prepare(
|
|
23
|
+
'INSERT INTO main.roadmap_cursor (singleton, phase_id) VALUES (1, ?) ON CONFLICT(singleton) DO UPDATE SET phase_id = excluded.phase_id'
|
|
24
|
+
).run(phaseId);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function clearCursor(db) {
|
|
28
|
+
db.prepare('DELETE FROM main.roadmap_cursor WHERE singleton = 1').run();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Mark ancestor phases (parent chain) as in_progress, but only if currently 'open'.
|
|
32
|
+
function propagateInProgressUp(db, phaseId) {
|
|
33
|
+
const stmt = db.prepare('SELECT parent_phase_id, status FROM main.roadmap_phases WHERE id = ?');
|
|
34
|
+
const update = db.prepare("UPDATE main.roadmap_phases SET status = 'in_progress' WHERE id = ? AND status = 'open'");
|
|
35
|
+
let cur = stmt.get(phaseId);
|
|
36
|
+
while (cur && cur.parent_phase_id != null) {
|
|
37
|
+
update.run(cur.parent_phase_id);
|
|
38
|
+
cur = stmt.get(cur.parent_phase_id);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Move cursor to phaseId, promote it to in_progress (if open), propagate up.
|
|
43
|
+
function activatePhase(db, phaseId) {
|
|
44
|
+
setCursor(db, phaseId);
|
|
45
|
+
db.prepare("UPDATE main.roadmap_phases SET status = 'in_progress' WHERE id = ? AND status = 'open'").run(phaseId);
|
|
46
|
+
propagateInProgressUp(db, phaseId);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Compute a synthetic "decision-root" status from its phases.
|
|
50
|
+
// done if all non-stale phases done; in_progress if any in_progress; blocked if any blocked & no in_progress; stale if all stale; else open.
|
|
51
|
+
function decisionRootStatus(phases) {
|
|
52
|
+
const live = phases.filter(p => p.status !== 'stale');
|
|
53
|
+
if (live.length === 0) return 'stale';
|
|
54
|
+
if (live.every(p => p.status === 'done')) return 'done';
|
|
55
|
+
if (live.some(p => p.status === 'in_progress')) return 'in_progress';
|
|
56
|
+
if (live.some(p => p.status === 'blocked') && !live.some(p => p.status === 'in_progress')) return 'blocked';
|
|
57
|
+
return 'open';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// DFS-advance the cursor to the next actionable phase, skipping stale.
|
|
61
|
+
// Order within a decision: phase_number ascending; within a phase: sub-tasks first
|
|
62
|
+
// (parent_phase_id = phase_id, ordered by position), then next phase_number.
|
|
63
|
+
// Across decisions: by decision_name (stable alphabetical order — could be improved later).
|
|
64
|
+
function findNextActionable(db, fromPhaseId) {
|
|
65
|
+
const all = db.prepare(
|
|
66
|
+
'SELECT id, decision_name, phase_number, parent_phase_id, status, position FROM main.roadmap_phases ORDER BY decision_name, phase_number, position, id'
|
|
67
|
+
).all();
|
|
68
|
+
const isLive = (p) => ACTIONABLE.has(p.status);
|
|
69
|
+
|
|
70
|
+
// Build a flat DFS order: for each top-level phase (parent_phase_id null), visit its sub-tasks
|
|
71
|
+
// (parent_phase_id = phase.id) before the next top-level phase.
|
|
72
|
+
const childrenOf = new Map();
|
|
73
|
+
for (const p of all) {
|
|
74
|
+
const key = p.parent_phase_id ?? `root:${p.decision_name}`;
|
|
75
|
+
if (!childrenOf.has(key)) childrenOf.set(key, []);
|
|
76
|
+
childrenOf.get(key).push(p);
|
|
77
|
+
}
|
|
78
|
+
// Sort each child group
|
|
79
|
+
for (const arr of childrenOf.values()) {
|
|
80
|
+
arr.sort((a, b) => {
|
|
81
|
+
if (a.phase_number != null && b.phase_number != null) return a.phase_number - b.phase_number;
|
|
82
|
+
return a.position - b.position || a.id - b.id;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const order = [];
|
|
87
|
+
const decisionsSeen = new Set();
|
|
88
|
+
for (const p of all) {
|
|
89
|
+
if (p.parent_phase_id != null) continue;
|
|
90
|
+
if (decisionsSeen.has(p.decision_name)) continue;
|
|
91
|
+
decisionsSeen.add(p.decision_name);
|
|
92
|
+
const stack = [];
|
|
93
|
+
const phasesInDecision = childrenOf.get(`root:${p.decision_name}`) || [];
|
|
94
|
+
for (let i = phasesInDecision.length - 1; i >= 0; i--) stack.push(phasesInDecision[i]);
|
|
95
|
+
while (stack.length) {
|
|
96
|
+
const node = stack.pop();
|
|
97
|
+
order.push(node);
|
|
98
|
+
const kids = childrenOf.get(node.id) || [];
|
|
99
|
+
for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i]);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (fromPhaseId == null) {
|
|
104
|
+
return order.find(isLive)?.id ?? null;
|
|
105
|
+
}
|
|
106
|
+
const idx = order.findIndex(p => p.id === fromPhaseId);
|
|
107
|
+
if (idx === -1) return order.find(isLive)?.id ?? null;
|
|
108
|
+
for (let i = idx + 1; i < order.length; i++) {
|
|
109
|
+
if (isLive(order[i])) return order[i].id;
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function renderTree(db, { compact = false, hideStale = false } = {}) {
|
|
115
|
+
const all = db.prepare(
|
|
116
|
+
'SELECT id, decision_name, phase_number, parent_phase_id, text, status, position FROM main.roadmap_phases ORDER BY decision_name, phase_number, position, id'
|
|
117
|
+
).all();
|
|
118
|
+
if (all.length === 0) return '(no roadmap — write a decision memo with a `## Phases` section)';
|
|
119
|
+
|
|
120
|
+
const cursor = getCursor(db);
|
|
121
|
+
const byParent = new Map();
|
|
122
|
+
for (const p of all) {
|
|
123
|
+
const key = p.parent_phase_id ?? 'root';
|
|
124
|
+
if (!byParent.has(key)) byParent.set(key, []);
|
|
125
|
+
byParent.get(key).push(p);
|
|
126
|
+
}
|
|
127
|
+
for (const arr of byParent.values()) {
|
|
128
|
+
arr.sort((a, b) => {
|
|
129
|
+
if (a.decision_name && b.decision_name && a.decision_name !== b.decision_name) {
|
|
130
|
+
return a.decision_name.localeCompare(b.decision_name);
|
|
131
|
+
}
|
|
132
|
+
if (a.phase_number != null && b.phase_number != null) return a.phase_number - b.phase_number;
|
|
133
|
+
return a.position - b.position || a.id - b.id;
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Group root nodes by decision_name. Roots always have decision_name (they're memo phases).
|
|
138
|
+
const roots = byParent.get('root') || [];
|
|
139
|
+
const decisionRoots = new Map();
|
|
140
|
+
for (const r of roots) {
|
|
141
|
+
if (r.decision_name == null) continue; // safety: shouldn't happen
|
|
142
|
+
if (!decisionRoots.has(r.decision_name)) decisionRoots.set(r.decision_name, []);
|
|
143
|
+
decisionRoots.get(r.decision_name).push(r);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const lines = [];
|
|
147
|
+
const visitSubtree = (parentId, depth) => {
|
|
148
|
+
const kids = byParent.get(parentId) || [];
|
|
149
|
+
for (const p of kids) {
|
|
150
|
+
if (hideStale && p.status === 'stale') continue;
|
|
151
|
+
const box = STATUS_BOX[p.status] || '[?]';
|
|
152
|
+
const arrow = cursor === p.id ? ' →' : '';
|
|
153
|
+
const label = p.phase_number != null ? `Phase ${p.phase_number}: ${p.text}` : p.text;
|
|
154
|
+
const tag = compact ? '' : ` (id:${p.id})`;
|
|
155
|
+
lines.push(`${' '.repeat(depth)}${box}${arrow} ${label}${tag}`);
|
|
156
|
+
visitSubtree(p.id, depth + 1);
|
|
157
|
+
}
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
for (const [decisionName, phases] of decisionRoots) {
|
|
161
|
+
if (hideStale && phases.every(p => p.status === 'stale')) continue;
|
|
162
|
+
const rootStatus = decisionRootStatus(phases);
|
|
163
|
+
lines.push(`${STATUS_BOX[rootStatus]} Decision: ${decisionName}`);
|
|
164
|
+
for (const p of phases) {
|
|
165
|
+
if (hideStale && p.status === 'stale') continue;
|
|
166
|
+
const box = STATUS_BOX[p.status] || '[?]';
|
|
167
|
+
const arrow = cursor === p.id ? ' →' : '';
|
|
168
|
+
const label = `Phase ${p.phase_number}: ${p.text}`;
|
|
169
|
+
const tag = compact ? '' : ` (id:${p.id})`;
|
|
170
|
+
lines.push(` ${box}${arrow} ${label}${tag}`);
|
|
171
|
+
visitSubtree(p.id, 2);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return lines.join('\n');
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function statusSummary(db) {
|
|
178
|
+
const all = db.prepare(
|
|
179
|
+
"SELECT decision_name, status FROM main.roadmap_phases WHERE parent_phase_id IS NULL"
|
|
180
|
+
).all();
|
|
181
|
+
const byDecision = new Map();
|
|
182
|
+
for (const p of all) {
|
|
183
|
+
if (!byDecision.has(p.decision_name)) byDecision.set(p.decision_name, []);
|
|
184
|
+
byDecision.get(p.decision_name).push(p);
|
|
185
|
+
}
|
|
186
|
+
let inFlight = 0;
|
|
187
|
+
for (const phases of byDecision.values()) {
|
|
188
|
+
const root = decisionRootStatus(phases);
|
|
189
|
+
if (root === 'in_progress' || root === 'open') inFlight++;
|
|
190
|
+
}
|
|
191
|
+
const cursor = getCursor(db);
|
|
192
|
+
let currentLabel = null;
|
|
193
|
+
if (cursor != null) {
|
|
194
|
+
const cur = db.prepare('SELECT id, decision_name, phase_number, parent_phase_id, text FROM main.roadmap_phases WHERE id = ?').get(cursor);
|
|
195
|
+
if (cur) {
|
|
196
|
+
// Walk up to find the root phase if this is a sub-task
|
|
197
|
+
let rootDecision = cur.decision_name;
|
|
198
|
+
let rootPhaseNum = cur.phase_number;
|
|
199
|
+
let walker = cur;
|
|
200
|
+
while (walker.parent_phase_id != null) {
|
|
201
|
+
walker = db.prepare('SELECT id, decision_name, phase_number, parent_phase_id FROM main.roadmap_phases WHERE id = ?').get(walker.parent_phase_id);
|
|
202
|
+
if (!walker) break;
|
|
203
|
+
rootDecision = walker.decision_name ?? rootDecision;
|
|
204
|
+
rootPhaseNum = walker.phase_number ?? rootPhaseNum;
|
|
205
|
+
}
|
|
206
|
+
currentLabel = cur.phase_number != null
|
|
207
|
+
? `${cur.decision_name} / Phase ${cur.phase_number} (${cur.text})`
|
|
208
|
+
: `${rootDecision} / Phase ${rootPhaseNum} → sub: ${cur.text}`;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
decisions_total: byDecision.size,
|
|
213
|
+
decisions_in_flight: inFlight,
|
|
214
|
+
cursor_phase_id: cursor,
|
|
215
|
+
current: currentLabel,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function roadmap(db, args = {}) {
|
|
220
|
+
const action = args.action ?? 'tree';
|
|
221
|
+
const { id, status, parent, text } = args;
|
|
222
|
+
|
|
223
|
+
switch (action) {
|
|
224
|
+
case 'tree': {
|
|
225
|
+
const out = renderTree(db, { compact: args.compact === true, hideStale: args.hide_stale === true });
|
|
226
|
+
const summary = statusSummary(db);
|
|
227
|
+
const lines = [out, '', summary.current
|
|
228
|
+
? `Current: ${summary.current}`
|
|
229
|
+
: '(no cursor set — call cairn_roadmap focus <id>)'];
|
|
230
|
+
if (summary.decisions_in_flight > 1) {
|
|
231
|
+
lines.push(`In flight: ${summary.decisions_in_flight} decisions`);
|
|
232
|
+
}
|
|
233
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
case 'status': {
|
|
237
|
+
return { content: [{ type: 'text', text: JSON.stringify(statusSummary(db), null, 2) }] };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
case 'focus': {
|
|
241
|
+
if (!id) throw new Error('id is required for focus');
|
|
242
|
+
const phase = db.prepare('SELECT * FROM main.roadmap_phases WHERE id = ?').get(id);
|
|
243
|
+
if (!phase) throw new Error(`No phase with id ${id}`);
|
|
244
|
+
if (phase.status === 'stale') throw new Error(`Phase ${id} is stale — restore via decision memo edit`);
|
|
245
|
+
activatePhase(db, id);
|
|
246
|
+
const refreshed = db.prepare('SELECT * FROM main.roadmap_phases WHERE id = ?').get(id);
|
|
247
|
+
return { content: [{ type: 'text', text: JSON.stringify({ focused: refreshed }, null, 2) }] };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
case 'next': {
|
|
251
|
+
const cursor = getCursor(db);
|
|
252
|
+
const next = findNextActionable(db, cursor);
|
|
253
|
+
if (next == null) {
|
|
254
|
+
clearCursor(db);
|
|
255
|
+
return { content: [{ type: 'text', text: JSON.stringify({ cursor: null, message: 'No actionable phases remaining.' }, null, 2) }] };
|
|
256
|
+
}
|
|
257
|
+
activatePhase(db, next);
|
|
258
|
+
const phase = db.prepare('SELECT * FROM main.roadmap_phases WHERE id = ?').get(next);
|
|
259
|
+
return { content: [{ type: 'text', text: JSON.stringify({ cursor: next, phase }, null, 2) }] };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
case 'set_status': {
|
|
263
|
+
if (!id) throw new Error('id is required for set_status');
|
|
264
|
+
if (!status) throw new Error('status is required for set_status');
|
|
265
|
+
if (!VALID_STATUS.has(status)) {
|
|
266
|
+
throw new Error(`Invalid status "${status}". Use: ${[...VALID_STATUS].join(', ')}`);
|
|
267
|
+
}
|
|
268
|
+
const resolvedAt = status === 'done' ? "datetime('now')" : 'NULL';
|
|
269
|
+
const updated = db.prepare(
|
|
270
|
+
`UPDATE main.roadmap_phases SET status = ?, resolved_at = ${resolvedAt} WHERE id = ?`
|
|
271
|
+
).run(status, id);
|
|
272
|
+
if (updated.changes === 0) throw new Error(`No phase with id ${id}`);
|
|
273
|
+
// If we just marked the cursor done, auto-advance and activate the new node.
|
|
274
|
+
let advancedTo = null;
|
|
275
|
+
if (status === 'done' && getCursor(db) === id) {
|
|
276
|
+
const next = findNextActionable(db, id);
|
|
277
|
+
if (next != null) { activatePhase(db, next); advancedTo = next; }
|
|
278
|
+
else clearCursor(db);
|
|
279
|
+
}
|
|
280
|
+
const phase = db.prepare('SELECT * FROM main.roadmap_phases WHERE id = ?').get(id);
|
|
281
|
+
return { content: [{ type: 'text', text: JSON.stringify({ updated: phase, cursor_advanced_to: advancedTo }, null, 2) }] };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
case 'add': {
|
|
285
|
+
if (!text) throw new Error('text is required for add');
|
|
286
|
+
if (parent == null) throw new Error('parent (phase_id) is required — ad-hoc sub-tasks must hang under an existing phase. For freestanding work use cairn_todos add.');
|
|
287
|
+
const parentRow = db.prepare('SELECT id, decision_name FROM main.roadmap_phases WHERE id = ?').get(parent);
|
|
288
|
+
if (!parentRow) throw new Error(`Parent phase ${parent} not found`);
|
|
289
|
+
const nextPos = db.prepare(
|
|
290
|
+
'SELECT COALESCE(MAX(position), -1) + 1 AS n FROM main.roadmap_phases WHERE parent_phase_id = ?'
|
|
291
|
+
).get(parent).n;
|
|
292
|
+
const result = db.prepare(
|
|
293
|
+
'INSERT INTO main.roadmap_phases (decision_name, phase_number, parent_phase_id, text, status, position) VALUES (NULL, NULL, ?, ?, ?, ?)'
|
|
294
|
+
).run(parent, text, 'open', nextPos);
|
|
295
|
+
const inserted = db.prepare('SELECT * FROM main.roadmap_phases WHERE id = ?').get(result.lastInsertRowid);
|
|
296
|
+
return { content: [{ type: 'text', text: JSON.stringify({ added: inserted, inherits_decision_from: parentRow.decision_name }, null, 2) }] };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
default:
|
|
300
|
+
throw new Error(`Unknown action: "${action}". Use tree, status, focus, next, set_status, or add.`);
|
|
301
|
+
}
|
|
302
|
+
}
|
package/src/tools/todos.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { walkRepo } from '../indexer/fileWalker.js';
|
|
2
2
|
import { scanTodos } from '../indexer/todoScanner.js';
|
|
3
3
|
|
|
4
|
+
// cairn_todos is the long-tail backlog: scanned TODO/FIXME/HACK comments + manual notes.
|
|
5
|
+
// For active multi-phase plans (with status/cursor/decision-memo), use cairn_roadmap instead.
|
|
6
|
+
// The parent_id/position columns added in v1.9.0 are dormant — kept on the table for back-compat
|
|
7
|
+
// but no longer read by tools.
|
|
4
8
|
export async function todos(db, args = {}) {
|
|
5
9
|
const { text, id, status, source } = args;
|
|
6
10
|
const action = args.action ?? 'list';
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { strict as assert } from 'node:assert';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
4
|
+
import { parsePhases, syncPhasesToRoadmap } from '../src/tools/memo.js';
|
|
5
|
+
import { roadmap } from '../src/tools/roadmap.js';
|
|
6
|
+
|
|
7
|
+
// Build a fresh in-memory DB with the v1.10.0 schema (mirrors db.js SCHEMA, scoped).
|
|
8
|
+
function makeDB() {
|
|
9
|
+
const db = new DatabaseSync(':memory:');
|
|
10
|
+
db.exec(`
|
|
11
|
+
CREATE TABLE todos (
|
|
12
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, source TEXT, file TEXT, line INTEGER,
|
|
13
|
+
kind TEXT, text TEXT, status TEXT DEFAULT 'open', parent_id INTEGER, position INTEGER DEFAULT 0,
|
|
14
|
+
created_at TEXT DEFAULT (datetime('now')), resolved_at TEXT
|
|
15
|
+
);
|
|
16
|
+
CREATE TABLE roadmap_cursor (
|
|
17
|
+
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
18
|
+
todo_id INTEGER, phase_id INTEGER
|
|
19
|
+
);
|
|
20
|
+
CREATE TABLE roadmap_phases (
|
|
21
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
22
|
+
decision_name TEXT, phase_number INTEGER, parent_phase_id INTEGER REFERENCES roadmap_phases(id) ON DELETE CASCADE,
|
|
23
|
+
text TEXT NOT NULL, body TEXT, status TEXT DEFAULT 'open', position INTEGER DEFAULT 0,
|
|
24
|
+
created_at TEXT DEFAULT (datetime('now')), resolved_at TEXT
|
|
25
|
+
);
|
|
26
|
+
CREATE UNIQUE INDEX idx_roadmap_phases_decision_phase
|
|
27
|
+
ON roadmap_phases(decision_name, phase_number)
|
|
28
|
+
WHERE decision_name IS NOT NULL AND phase_number IS NOT NULL;
|
|
29
|
+
`);
|
|
30
|
+
return db;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test('parsePhases extracts ### Phase N: text + body until next ## heading', () => {
|
|
34
|
+
const memo = `Why explanation.
|
|
35
|
+
|
|
36
|
+
## Phases
|
|
37
|
+
|
|
38
|
+
### Phase 1: First
|
|
39
|
+
Body of first.
|
|
40
|
+
Multiple lines.
|
|
41
|
+
|
|
42
|
+
### Phase 2: Second
|
|
43
|
+
Just one line.
|
|
44
|
+
|
|
45
|
+
## Tradeoffs
|
|
46
|
+
Should not be parsed.
|
|
47
|
+
`;
|
|
48
|
+
const phases = parsePhases(memo);
|
|
49
|
+
assert.equal(phases.length, 2);
|
|
50
|
+
assert.equal(phases[0].phase_number, 1);
|
|
51
|
+
assert.equal(phases[0].text, 'First');
|
|
52
|
+
assert.equal(phases[0].body, 'Body of first.\nMultiple lines.');
|
|
53
|
+
assert.equal(phases[1].phase_number, 2);
|
|
54
|
+
assert.equal(phases[1].body, 'Just one line.');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('parsePhases returns [] when no ## Phases section exists', () => {
|
|
58
|
+
assert.deepEqual(parsePhases('No phases here.\n\n## Other heading\nstuff'), []);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('parsePhases ignores duplicate phase numbers (first wins)', () => {
|
|
62
|
+
const memo = `## Phases\n### Phase 1: A\n### Phase 1: B\n### Phase 2: C\n`;
|
|
63
|
+
const phases = parsePhases(memo);
|
|
64
|
+
assert.equal(phases.length, 2);
|
|
65
|
+
assert.equal(phases[0].text, 'A');
|
|
66
|
+
assert.equal(phases[1].phase_number, 2);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('syncPhasesToRoadmap: insert on first, preserve status on update, stale on remove', () => {
|
|
70
|
+
const db = makeDB();
|
|
71
|
+
const phases = [
|
|
72
|
+
{ phase_number: 1, text: 'First', body: 'b1' },
|
|
73
|
+
{ phase_number: 2, text: 'Second', body: 'b2' },
|
|
74
|
+
{ phase_number: 3, text: 'Third', body: 'b3' },
|
|
75
|
+
];
|
|
76
|
+
let r = syncPhasesToRoadmap(db, 'demo', phases);
|
|
77
|
+
assert.deepEqual(r, { updated: 0, inserted: 3, staled: 0 });
|
|
78
|
+
|
|
79
|
+
// Mark Phase 2 as in_progress, then re-sync without Phase 1
|
|
80
|
+
db.prepare("UPDATE roadmap_phases SET status='in_progress' WHERE phase_number=2 AND decision_name='demo'").run();
|
|
81
|
+
const phases2 = [
|
|
82
|
+
{ phase_number: 2, text: 'Second renamed', body: 'b2-new' },
|
|
83
|
+
{ phase_number: 3, text: 'Third', body: 'b3' },
|
|
84
|
+
{ phase_number: 4, text: 'Fourth', body: 'b4' },
|
|
85
|
+
];
|
|
86
|
+
r = syncPhasesToRoadmap(db, 'demo', phases2);
|
|
87
|
+
assert.deepEqual(r, { updated: 2, inserted: 1, staled: 1 });
|
|
88
|
+
|
|
89
|
+
const all = JSON.parse(JSON.stringify(
|
|
90
|
+
db.prepare("SELECT phase_number, status, text FROM roadmap_phases WHERE decision_name='demo' ORDER BY phase_number").all()
|
|
91
|
+
));
|
|
92
|
+
assert.deepEqual(all, [
|
|
93
|
+
{ phase_number: 1, status: 'stale', text: 'First' },
|
|
94
|
+
{ phase_number: 2, status: 'in_progress', text: 'Second renamed' },
|
|
95
|
+
{ phase_number: 3, status: 'open', text: 'Third' },
|
|
96
|
+
{ phase_number: 4, status: 'open', text: 'Fourth' },
|
|
97
|
+
]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('syncPhasesToRoadmap: re-add of stale phase reactivates it to open', () => {
|
|
101
|
+
const db = makeDB();
|
|
102
|
+
syncPhasesToRoadmap(db, 'demo', [{ phase_number: 1, text: 'A', body: null }]);
|
|
103
|
+
db.prepare("UPDATE roadmap_phases SET status='stale' WHERE phase_number=1").run();
|
|
104
|
+
syncPhasesToRoadmap(db, 'demo', [{ phase_number: 1, text: 'A back', body: null }]);
|
|
105
|
+
const row = db.prepare("SELECT status, text FROM roadmap_phases WHERE phase_number=1").get();
|
|
106
|
+
assert.equal(row.status, 'open');
|
|
107
|
+
assert.equal(row.text, 'A back');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('roadmap focus: marks phase in_progress + propagates up + sets cursor', () => {
|
|
111
|
+
const db = makeDB();
|
|
112
|
+
syncPhasesToRoadmap(db, 'demo', [
|
|
113
|
+
{ phase_number: 1, text: 'A', body: null },
|
|
114
|
+
{ phase_number: 2, text: 'B', body: null },
|
|
115
|
+
]);
|
|
116
|
+
const phase2 = db.prepare("SELECT id FROM roadmap_phases WHERE phase_number=2").get().id;
|
|
117
|
+
// Add a sub-task under phase 2
|
|
118
|
+
const sub = JSON.parse(roadmap(db, { action: 'add', parent: phase2, text: 'subtask' }).content[0].text).added.id;
|
|
119
|
+
roadmap(db, { action: 'focus', id: sub });
|
|
120
|
+
const subAfter = db.prepare("SELECT status FROM roadmap_phases WHERE id=?").get(sub);
|
|
121
|
+
const phase2After = db.prepare("SELECT status FROM roadmap_phases WHERE id=?").get(phase2);
|
|
122
|
+
const cursor = db.prepare('SELECT phase_id FROM roadmap_cursor WHERE singleton=1').get().phase_id;
|
|
123
|
+
assert.equal(subAfter.status, 'in_progress');
|
|
124
|
+
assert.equal(phase2After.status, 'in_progress');
|
|
125
|
+
assert.equal(cursor, sub);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('roadmap next: DFS-advances skipping stale, never landing on stale', () => {
|
|
129
|
+
const db = makeDB();
|
|
130
|
+
syncPhasesToRoadmap(db, 'demo', [
|
|
131
|
+
{ phase_number: 1, text: 'A', body: null },
|
|
132
|
+
{ phase_number: 2, text: 'B', body: null },
|
|
133
|
+
{ phase_number: 3, text: 'C', body: null },
|
|
134
|
+
]);
|
|
135
|
+
db.prepare("UPDATE roadmap_phases SET status='stale' WHERE phase_number=2").run();
|
|
136
|
+
// Cursor null -> next should land on Phase 1 (first actionable)
|
|
137
|
+
let next = JSON.parse(roadmap(db, { action: 'next' }).content[0].text);
|
|
138
|
+
assert.equal(next.phase.phase_number, 1);
|
|
139
|
+
// From Phase 1 -> next should skip stale Phase 2 and land on Phase 3
|
|
140
|
+
next = JSON.parse(roadmap(db, { action: 'next' }).content[0].text);
|
|
141
|
+
assert.equal(next.phase.phase_number, 3);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('roadmap set_status done auto-advances cursor to next actionable', () => {
|
|
145
|
+
const db = makeDB();
|
|
146
|
+
syncPhasesToRoadmap(db, 'demo', [
|
|
147
|
+
{ phase_number: 1, text: 'A', body: null },
|
|
148
|
+
{ phase_number: 2, text: 'B', body: null },
|
|
149
|
+
]);
|
|
150
|
+
const p1 = db.prepare("SELECT id FROM roadmap_phases WHERE phase_number=1").get().id;
|
|
151
|
+
roadmap(db, { action: 'focus', id: p1 });
|
|
152
|
+
const r = JSON.parse(roadmap(db, { action: 'set_status', id: p1, status: 'done' }).content[0].text);
|
|
153
|
+
const p2 = db.prepare("SELECT id FROM roadmap_phases WHERE phase_number=2").get().id;
|
|
154
|
+
assert.equal(r.cursor_advanced_to, p2);
|
|
155
|
+
// The new cursor should also be in_progress (activatePhase was called)
|
|
156
|
+
const p2Status = db.prepare("SELECT status FROM roadmap_phases WHERE id=?").get(p2).status;
|
|
157
|
+
assert.equal(p2Status, 'in_progress');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('roadmap add: requires parent phase, inherits decision_name=null on the row', () => {
|
|
161
|
+
const db = makeDB();
|
|
162
|
+
syncPhasesToRoadmap(db, 'demo', [{ phase_number: 1, text: 'A', body: null }]);
|
|
163
|
+
const p1 = db.prepare("SELECT id FROM roadmap_phases WHERE phase_number=1").get().id;
|
|
164
|
+
// No parent should fail
|
|
165
|
+
assert.throws(() => roadmap(db, { action: 'add', text: 'orphan' }), /parent.*required/i);
|
|
166
|
+
// With parent, it inserts with NULL decision_name (sub-task)
|
|
167
|
+
const r = JSON.parse(roadmap(db, { action: 'add', parent: p1, text: 'sub' }).content[0].text);
|
|
168
|
+
assert.equal(r.added.parent_phase_id, p1);
|
|
169
|
+
assert.equal(r.added.decision_name, null);
|
|
170
|
+
assert.equal(r.added.phase_number, null);
|
|
171
|
+
assert.equal(r.inherits_decision_from, 'demo');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('roadmap focus: refuses stale phase', () => {
|
|
175
|
+
const db = makeDB();
|
|
176
|
+
syncPhasesToRoadmap(db, 'demo', [{ phase_number: 1, text: 'A', body: null }]);
|
|
177
|
+
const p1 = db.prepare("SELECT id FROM roadmap_phases WHERE phase_number=1").get().id;
|
|
178
|
+
db.prepare("UPDATE roadmap_phases SET status='stale' WHERE id=?").run(p1);
|
|
179
|
+
assert.throws(() => roadmap(db, { action: 'focus', id: p1 }), /stale/i);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('roadmap status: counts decisions in flight + reports current cursor with sub-task root walk', () => {
|
|
183
|
+
const db = makeDB();
|
|
184
|
+
syncPhasesToRoadmap(db, 'demo_a', [
|
|
185
|
+
{ phase_number: 1, text: 'A1', body: null },
|
|
186
|
+
{ phase_number: 2, text: 'A2', body: null },
|
|
187
|
+
]);
|
|
188
|
+
syncPhasesToRoadmap(db, 'demo_b', [{ phase_number: 1, text: 'B1', body: null }]);
|
|
189
|
+
const a2 = db.prepare("SELECT id FROM roadmap_phases WHERE decision_name='demo_a' AND phase_number=2").get().id;
|
|
190
|
+
const sub = JSON.parse(roadmap(db, { action: 'add', parent: a2, text: 'subA2' }).content[0].text).added.id;
|
|
191
|
+
roadmap(db, { action: 'focus', id: sub });
|
|
192
|
+
const status = JSON.parse(roadmap(db, { action: 'status' }).content[0].text);
|
|
193
|
+
assert.equal(status.decisions_total, 2);
|
|
194
|
+
assert.equal(status.decisions_in_flight, 2);
|
|
195
|
+
assert.match(status.current, /demo_a \/ Phase 2 → sub: subA2/);
|
|
196
|
+
});
|