@maledorak/lore-mcp 1.0.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 +7 -0
- package/bin/lore-mcp.js +836 -0
- package/package.json +45 -0
package/README.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# @maledorak/lore-mcp
|
|
2
|
+
|
|
3
|
+
MCP server for **lore framework** - AI-readable project memory with task/ADR/wiki management.
|
|
4
|
+
|
|
5
|
+
## Documentation
|
|
6
|
+
|
|
7
|
+
See full documentation and setup instructions: [maledorak-private-marketplace](https://github.com/maledorak/maledorak-private-marketplace)
|
package/bin/lore-mcp.js
ADDED
|
@@ -0,0 +1,836 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* MCP Server for Lore Framework
|
|
4
|
+
*
|
|
5
|
+
* Provides tools for managing lore/ directory:
|
|
6
|
+
* - lore-set-user: Set current user from team.yaml
|
|
7
|
+
* - lore-set-task: Set current task symlink
|
|
8
|
+
* - lore-show-session: Show current session state
|
|
9
|
+
* - lore-list-users: List available users from team.yaml
|
|
10
|
+
* - lore-clear-task: Clear current task symlink
|
|
11
|
+
* - lore-generate-index: Regenerate lore/README.md and next-tasks.md
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
15
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync, unlinkSync, symlinkSync, readlinkSync } from 'fs';
|
|
18
|
+
import { join, dirname, relative } from 'path';
|
|
19
|
+
import { fileURLToPath } from 'url';
|
|
20
|
+
import { parse as parseYaml } from 'yaml';
|
|
21
|
+
import matter from 'gray-matter';
|
|
22
|
+
import { globSync } from 'glob';
|
|
23
|
+
|
|
24
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
|
|
26
|
+
// Get project directory from current working directory
|
|
27
|
+
// MCP servers run with cwd set to the project directory
|
|
28
|
+
function getProjectDir() {
|
|
29
|
+
return process.cwd();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getLoreDir() {
|
|
33
|
+
return join(getProjectDir(), 'lore');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getSessionDir() {
|
|
37
|
+
return join(getLoreDir(), '0-session');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ============================================================================
|
|
41
|
+
// Session Management (from set-session.js)
|
|
42
|
+
// ============================================================================
|
|
43
|
+
|
|
44
|
+
function loadTeam(sessionDir) {
|
|
45
|
+
const teamFile = join(sessionDir, 'team.yaml');
|
|
46
|
+
if (!existsSync(teamFile)) {
|
|
47
|
+
throw new Error(`team.yaml not found at ${teamFile}`);
|
|
48
|
+
}
|
|
49
|
+
const content = readFileSync(teamFile, 'utf8');
|
|
50
|
+
return parseYaml(content);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function generateCurrentUserMd(userId, userData, team) {
|
|
54
|
+
const lines = [];
|
|
55
|
+
|
|
56
|
+
lines.push('---');
|
|
57
|
+
lines.push(`name: ${userId}`);
|
|
58
|
+
if (userData.github) lines.push(`github: ${userData.github}`);
|
|
59
|
+
if (userData.role) lines.push(`role: ${userData.role}`);
|
|
60
|
+
lines.push('---');
|
|
61
|
+
lines.push('');
|
|
62
|
+
|
|
63
|
+
const name = userData.name || userId;
|
|
64
|
+
lines.push(`# Current User: ${name}`);
|
|
65
|
+
lines.push('');
|
|
66
|
+
|
|
67
|
+
if (userData.focus) {
|
|
68
|
+
lines.push(`**Focus:** ${userData.focus.trim()}`);
|
|
69
|
+
lines.push('');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (userData.prompting) {
|
|
73
|
+
lines.push('## Communication Preferences');
|
|
74
|
+
lines.push('');
|
|
75
|
+
lines.push(userData.prompting.trim());
|
|
76
|
+
lines.push('');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (userData.note) {
|
|
80
|
+
lines.push(`> ${userData.note}`);
|
|
81
|
+
lines.push('');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const otherMembers = Object.entries(team).filter(([k]) => k !== userId);
|
|
85
|
+
if (otherMembers.length > 0) {
|
|
86
|
+
lines.push('---');
|
|
87
|
+
lines.push('');
|
|
88
|
+
lines.push('## Rest of Team');
|
|
89
|
+
lines.push('');
|
|
90
|
+
lines.push('| Name | Role |');
|
|
91
|
+
lines.push('|------|------|');
|
|
92
|
+
for (const [memberId, memberData] of otherMembers) {
|
|
93
|
+
const memberName = memberData.name || memberId;
|
|
94
|
+
const role = memberData.role || '—';
|
|
95
|
+
lines.push(`| ${memberName} | ${role} |`);
|
|
96
|
+
}
|
|
97
|
+
lines.push('');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return lines.join('\n');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function setUser(sessionDir, userId) {
|
|
104
|
+
const team = loadTeam(sessionDir);
|
|
105
|
+
|
|
106
|
+
if (!team[userId]) {
|
|
107
|
+
const available = Object.keys(team).join(', ');
|
|
108
|
+
throw new Error(`User '${userId}' not found. Available: ${available}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const userData = team[userId];
|
|
112
|
+
const content = generateCurrentUserMd(userId, userData, team);
|
|
113
|
+
const currentUserMd = join(sessionDir, 'current-user.md');
|
|
114
|
+
writeFileSync(currentUserMd, content);
|
|
115
|
+
|
|
116
|
+
return { userId, name: userData.name || userId };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function findTask(loreDir, taskId) {
|
|
120
|
+
const tasksDir = join(loreDir, '1-tasks');
|
|
121
|
+
const taskNum = taskId.replace(/^0+/, '') || '0';
|
|
122
|
+
|
|
123
|
+
for (const statusDir of ['active', 'blocked', 'archive']) {
|
|
124
|
+
const statusPath = join(tasksDir, statusDir);
|
|
125
|
+
if (!existsSync(statusPath)) continue;
|
|
126
|
+
|
|
127
|
+
const items = readdirSync(statusPath);
|
|
128
|
+
for (const item of items) {
|
|
129
|
+
if (item.startsWith('_')) continue;
|
|
130
|
+
|
|
131
|
+
const itemPath = join(statusPath, item);
|
|
132
|
+
const itemId = item.split('_')[0].replace(/^0+/, '') || '0';
|
|
133
|
+
|
|
134
|
+
if (itemId === taskNum) {
|
|
135
|
+
const stat = statSync(itemPath);
|
|
136
|
+
if (stat.isDirectory()) {
|
|
137
|
+
const readme = join(itemPath, 'README.md');
|
|
138
|
+
if (existsSync(readme)) return readme;
|
|
139
|
+
} else if (item.endsWith('.md')) {
|
|
140
|
+
return itemPath;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function setTask(loreDir, sessionDir, taskId) {
|
|
150
|
+
const taskPath = findTask(loreDir, taskId);
|
|
151
|
+
|
|
152
|
+
if (!taskPath) {
|
|
153
|
+
throw new Error(`Task ${taskId} not found in 1-tasks/{active,blocked,archive}/`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const currentTaskMd = join(sessionDir, 'current-task.md');
|
|
157
|
+
const currentTaskJson = join(sessionDir, 'current-task.json');
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
if (existsSync(currentTaskMd)) unlinkSync(currentTaskMd);
|
|
161
|
+
} catch (e) { /* ignore */ }
|
|
162
|
+
|
|
163
|
+
const relativePath = join('..', relative(loreDir, taskPath));
|
|
164
|
+
symlinkSync(relativePath, currentTaskMd);
|
|
165
|
+
|
|
166
|
+
// Get task directory (parent of the task file for directory-based tasks, or dirname for file-based)
|
|
167
|
+
const taskDir = relative(loreDir, dirname(taskPath));
|
|
168
|
+
|
|
169
|
+
// Write task metadata for easy access by agents/scripts
|
|
170
|
+
const taskMeta = {
|
|
171
|
+
id: taskId,
|
|
172
|
+
path: taskDir
|
|
173
|
+
};
|
|
174
|
+
writeFileSync(currentTaskJson, JSON.stringify(taskMeta, null, 2));
|
|
175
|
+
|
|
176
|
+
return { taskId, path: relativePath };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function clearTask(sessionDir) {
|
|
180
|
+
const currentTaskMd = join(sessionDir, 'current-task.md');
|
|
181
|
+
const currentTaskJson = join(sessionDir, 'current-task.json');
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
let cleared = false;
|
|
185
|
+
if (existsSync(currentTaskMd)) {
|
|
186
|
+
unlinkSync(currentTaskMd);
|
|
187
|
+
cleared = true;
|
|
188
|
+
}
|
|
189
|
+
if (existsSync(currentTaskJson)) {
|
|
190
|
+
unlinkSync(currentTaskJson);
|
|
191
|
+
cleared = true;
|
|
192
|
+
}
|
|
193
|
+
return cleared ? { cleared: true } : { cleared: false, message: 'No task was set' };
|
|
194
|
+
} catch (e) {
|
|
195
|
+
return { cleared: false, error: e.message };
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function showSession(sessionDir) {
|
|
200
|
+
const result = { user: null, task: null };
|
|
201
|
+
|
|
202
|
+
const currentUserMd = join(sessionDir, 'current-user.md');
|
|
203
|
+
if (existsSync(currentUserMd)) {
|
|
204
|
+
const content = readFileSync(currentUserMd, 'utf8');
|
|
205
|
+
for (const line of content.split('\n')) {
|
|
206
|
+
if (line.startsWith('name:')) {
|
|
207
|
+
result.user = line.split(':')[1].trim();
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const currentTaskMd = join(sessionDir, 'current-task.md');
|
|
214
|
+
try {
|
|
215
|
+
const target = readlinkSync(currentTaskMd);
|
|
216
|
+
const parts = target.split('/');
|
|
217
|
+
for (const part of parts) {
|
|
218
|
+
if (part && /^\d/.test(part) && part.includes('_')) {
|
|
219
|
+
result.task = { id: part.split('_')[0], path: target };
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} catch (e) {
|
|
224
|
+
result.task = null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return result;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function listUsers(sessionDir) {
|
|
231
|
+
const team = loadTeam(sessionDir);
|
|
232
|
+
const users = [];
|
|
233
|
+
|
|
234
|
+
for (const [userId, userData] of Object.entries(team)) {
|
|
235
|
+
users.push({
|
|
236
|
+
id: userId,
|
|
237
|
+
name: userData.name || userId,
|
|
238
|
+
role: userData.role || null,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return users;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ============================================================================
|
|
246
|
+
// Index Generation (from lore-generate-index.js)
|
|
247
|
+
// ============================================================================
|
|
248
|
+
|
|
249
|
+
function extractTitleFromContent(content) {
|
|
250
|
+
for (const line of content.split('\n')) {
|
|
251
|
+
if (line.startsWith('# ')) return line.slice(2).trim();
|
|
252
|
+
}
|
|
253
|
+
return 'Untitled';
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function extractBlockedByFromHistory(history) {
|
|
257
|
+
if (!history || !Array.isArray(history) || history.length === 0) return [];
|
|
258
|
+
|
|
259
|
+
const latest = history[history.length - 1];
|
|
260
|
+
if (latest.status === 'blocked') {
|
|
261
|
+
const by = latest.by || [];
|
|
262
|
+
return Array.isArray(by) ? by.map(String) : by ? [String(by)] : [];
|
|
263
|
+
}
|
|
264
|
+
return [];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function parseTasks(loreDir) {
|
|
268
|
+
const tasks = new Map();
|
|
269
|
+
const tasksBase = join(loreDir, '1-tasks');
|
|
270
|
+
|
|
271
|
+
for (const subdir of ['active', 'blocked', 'archive']) {
|
|
272
|
+
const subdirPath = join(tasksBase, subdir);
|
|
273
|
+
if (!existsSync(subdirPath)) continue;
|
|
274
|
+
|
|
275
|
+
const items = readdirSync(subdirPath);
|
|
276
|
+
for (const item of items) {
|
|
277
|
+
if (item.startsWith('_')) continue;
|
|
278
|
+
|
|
279
|
+
const itemPath = join(subdirPath, item);
|
|
280
|
+
let taskPath = null;
|
|
281
|
+
|
|
282
|
+
const stat = statSync(itemPath);
|
|
283
|
+
if (stat.isFile() && item.endsWith('.md')) {
|
|
284
|
+
taskPath = itemPath;
|
|
285
|
+
} else if (stat.isDirectory()) {
|
|
286
|
+
const readme = join(itemPath, 'README.md');
|
|
287
|
+
if (existsSync(readme)) taskPath = readme;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (!taskPath) continue;
|
|
291
|
+
|
|
292
|
+
let taskId;
|
|
293
|
+
if (taskPath.endsWith('README.md')) {
|
|
294
|
+
taskId = dirname(taskPath).split('/').pop().split('_')[0];
|
|
295
|
+
} else {
|
|
296
|
+
taskId = item.replace('.md', '').split('_')[0];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (!/^\d+$/.test(taskId)) continue;
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
const content = readFileSync(taskPath, 'utf8');
|
|
303
|
+
const { data: meta, content: body } = matter(content);
|
|
304
|
+
|
|
305
|
+
if (!meta || Object.keys(meta).length === 0) continue;
|
|
306
|
+
|
|
307
|
+
let status = meta.status || 'active';
|
|
308
|
+
if (subdir === 'archive') status = 'completed';
|
|
309
|
+
else if (subdir === 'blocked') status = 'blocked';
|
|
310
|
+
|
|
311
|
+
tasks.set(String(meta.id || taskId), {
|
|
312
|
+
id: String(meta.id || taskId),
|
|
313
|
+
title: meta.title || extractTitleFromContent(body),
|
|
314
|
+
type: meta.type || 'FEATURE',
|
|
315
|
+
status,
|
|
316
|
+
path: relative(dirname(loreDir), taskPath),
|
|
317
|
+
blockedBy: extractBlockedByFromHistory(meta.history || []),
|
|
318
|
+
relatedTasks: meta.related_tasks || [],
|
|
319
|
+
relatedAdr: meta.related_adr || [],
|
|
320
|
+
tags: meta.tags || [],
|
|
321
|
+
});
|
|
322
|
+
} catch (e) { /* skip invalid */ }
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return tasks;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function parseAdrs(loreDir) {
|
|
330
|
+
const adrs = new Map();
|
|
331
|
+
const adrDir = join(loreDir, '2-adrs');
|
|
332
|
+
|
|
333
|
+
if (!existsSync(adrDir)) return adrs;
|
|
334
|
+
|
|
335
|
+
const files = globSync('*.md', { cwd: adrDir });
|
|
336
|
+
for (const file of files) {
|
|
337
|
+
if (file.startsWith('_')) continue;
|
|
338
|
+
|
|
339
|
+
const adrPath = join(adrDir, file);
|
|
340
|
+
const adrId = file.replace('.md', '').split('_')[0];
|
|
341
|
+
|
|
342
|
+
try {
|
|
343
|
+
const content = readFileSync(adrPath, 'utf8');
|
|
344
|
+
const { data: meta, content: body } = matter(content);
|
|
345
|
+
|
|
346
|
+
if (!meta || Object.keys(meta).length === 0) continue;
|
|
347
|
+
|
|
348
|
+
adrs.set(String(meta.id || adrId), {
|
|
349
|
+
id: String(meta.id || adrId),
|
|
350
|
+
title: meta.title || extractTitleFromContent(body),
|
|
351
|
+
status: meta.status || 'proposed',
|
|
352
|
+
path: relative(dirname(loreDir), adrPath),
|
|
353
|
+
relatedTasks: meta.related_tasks || [],
|
|
354
|
+
tags: meta.tags || [],
|
|
355
|
+
});
|
|
356
|
+
} catch (e) { /* skip invalid */ }
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return adrs;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function computeBlocks(tasks) {
|
|
363
|
+
const blocks = new Map();
|
|
364
|
+
for (const [tid] of tasks) blocks.set(tid, []);
|
|
365
|
+
|
|
366
|
+
for (const [, task] of tasks) {
|
|
367
|
+
for (const blockerId of task.blockedBy) {
|
|
368
|
+
if (blocks.has(blockerId)) blocks.get(blockerId).push(task.id);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return blocks;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function generateMermaid(tasks, adrs) {
|
|
376
|
+
const lines = ['```mermaid', 'flowchart LR'];
|
|
377
|
+
|
|
378
|
+
const completed = [...tasks.values()].filter(t => t.status === 'completed');
|
|
379
|
+
const active = [...tasks.values()].filter(t => t.status === 'active');
|
|
380
|
+
const blocked = [...tasks.values()].filter(t => t.status === 'blocked');
|
|
381
|
+
|
|
382
|
+
if (completed.length > 0) {
|
|
383
|
+
lines.push(' subgraph Completed');
|
|
384
|
+
for (const t of completed.sort((a, b) => a.id.localeCompare(b.id))) {
|
|
385
|
+
const shortTitle = t.title.length > 25 ? t.title.slice(0, 25) + '...' : t.title;
|
|
386
|
+
lines.push(` T${t.id}["${t.id}: ${shortTitle}"]`);
|
|
387
|
+
}
|
|
388
|
+
lines.push(' end');
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (active.length > 0) {
|
|
392
|
+
lines.push(' subgraph Active');
|
|
393
|
+
for (const t of active.sort((a, b) => a.id.localeCompare(b.id))) {
|
|
394
|
+
const shortTitle = t.title.length > 25 ? t.title.slice(0, 25) + '...' : t.title;
|
|
395
|
+
lines.push(` T${t.id}["${t.id}: ${shortTitle}"]`);
|
|
396
|
+
}
|
|
397
|
+
lines.push(' end');
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (blocked.length > 0) {
|
|
401
|
+
lines.push(' subgraph Blocked');
|
|
402
|
+
for (const t of blocked.sort((a, b) => a.id.localeCompare(b.id))) {
|
|
403
|
+
const shortTitle = t.title.length > 25 ? t.title.slice(0, 25) + '...' : t.title;
|
|
404
|
+
lines.push(` T${t.id}["${t.id}: ${shortTitle}"]`);
|
|
405
|
+
}
|
|
406
|
+
lines.push(' end');
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (adrs.size > 0) {
|
|
410
|
+
lines.push(' subgraph ADRs');
|
|
411
|
+
for (const a of [...adrs.values()].sort((a, b) => a.id.localeCompare(b.id))) {
|
|
412
|
+
const shortTitle = a.title.length > 20 ? a.title.slice(0, 20) + '...' : a.title;
|
|
413
|
+
lines.push(` ADR${a.id}[/"ADR ${a.id}: ${shortTitle}"/]`);
|
|
414
|
+
}
|
|
415
|
+
lines.push(' end');
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
lines.push('');
|
|
419
|
+
|
|
420
|
+
for (const [, task] of tasks) {
|
|
421
|
+
for (const blockerId of task.blockedBy) {
|
|
422
|
+
if (tasks.has(blockerId)) lines.push(` T${blockerId} --> T${task.id}`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
lines.push('');
|
|
427
|
+
|
|
428
|
+
for (const [, task] of tasks) {
|
|
429
|
+
for (const adrId of task.relatedAdr) {
|
|
430
|
+
if (adrs.has(adrId)) lines.push(` ADR${adrId} -.-> T${task.id}`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
lines.push('```');
|
|
435
|
+
return lines.join('\n');
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function generateStatusTable(tasks, blocks) {
|
|
439
|
+
const lines = [
|
|
440
|
+
'## Task Status',
|
|
441
|
+
'',
|
|
442
|
+
'| ID | Title | Type | Status | Blocked By | Blocks | ADRs |',
|
|
443
|
+
'|:---|:------|:-----|:-------|:-----------|:-------|:-----|',
|
|
444
|
+
];
|
|
445
|
+
|
|
446
|
+
const statusOrder = { active: 0, blocked: 1, completed: 2 };
|
|
447
|
+
const sortedTasks = [...tasks.values()].sort((a, b) => {
|
|
448
|
+
const orderDiff = (statusOrder[a.status] ?? 4) - (statusOrder[b.status] ?? 4);
|
|
449
|
+
return orderDiff !== 0 ? orderDiff : a.id.localeCompare(b.id);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
for (const task of sortedTasks) {
|
|
453
|
+
const blockedBy = task.blockedBy.length > 0 ? task.blockedBy.join(', ') : '—';
|
|
454
|
+
const taskBlocks = blocks.get(task.id)?.length > 0 ? blocks.get(task.id).sort().join(', ') : '—';
|
|
455
|
+
const relatedAdr = task.relatedAdr.length > 0 ? task.relatedAdr.join(', ') : '—';
|
|
456
|
+
const statusDisplay = task.status === 'active' ? `**${task.status}**` : task.status;
|
|
457
|
+
const title = task.title.length > 35 ? task.title.slice(0, 35) + '...' : task.title;
|
|
458
|
+
|
|
459
|
+
lines.push(`| ${task.id} | [${title}](${task.path}) | ${task.type} | ${statusDisplay} | ${blockedBy} | ${taskBlocks} | ${relatedAdr} |`);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return lines.join('\n');
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function generateAdrTable(adrs) {
|
|
466
|
+
const lines = [
|
|
467
|
+
'## Architecture Decision Records',
|
|
468
|
+
'',
|
|
469
|
+
'| ID | Title | Status | Related Tasks |',
|
|
470
|
+
'|:---|:------|:-------|:--------------|',
|
|
471
|
+
];
|
|
472
|
+
|
|
473
|
+
for (const adr of [...adrs.values()].sort((a, b) => a.id.localeCompare(b.id))) {
|
|
474
|
+
const related = adr.relatedTasks.length > 0 ? adr.relatedTasks.join(', ') : '—';
|
|
475
|
+
lines.push(`| ${adr.id} | [${adr.title}](${adr.path}) | ${adr.status} | ${related} |`);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
return lines.join('\n');
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function findReadyTasks(tasks) {
|
|
482
|
+
const ready = [];
|
|
483
|
+
const completedIds = new Set([...tasks.values()].filter(t => t.status === 'completed').map(t => t.id));
|
|
484
|
+
|
|
485
|
+
for (const [, task] of tasks) {
|
|
486
|
+
if (!['active', 'blocked'].includes(task.status)) continue;
|
|
487
|
+
|
|
488
|
+
if (task.blockedBy.length === 0 || task.blockedBy.every(b => completedIds.has(b))) {
|
|
489
|
+
ready.push(task);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return ready;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function findCriticalBlockers(tasks, blocks) {
|
|
497
|
+
const activeTasks = new Set([...tasks.values()].filter(t => ['active', 'blocked'].includes(t.status)).map(t => t.id));
|
|
498
|
+
|
|
499
|
+
const blockers = [];
|
|
500
|
+
for (const [taskId, blockedTasks] of blocks) {
|
|
501
|
+
const activeBlocked = blockedTasks.filter(t => activeTasks.has(t));
|
|
502
|
+
const task = tasks.get(taskId);
|
|
503
|
+
if (activeBlocked.length > 0 && task && ['active', 'blocked'].includes(task.status)) {
|
|
504
|
+
blockers.push([taskId, activeBlocked.length]);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return blockers.sort((a, b) => b[1] - a[1]);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function generateNext(tasks, blocks, ready) {
|
|
512
|
+
const lines = [
|
|
513
|
+
'# Next Tasks',
|
|
514
|
+
'',
|
|
515
|
+
'> Auto-generated. Use `lore-generate-index` tool to regenerate.',
|
|
516
|
+
'> Full index: [README.md](../README.md)',
|
|
517
|
+
'',
|
|
518
|
+
];
|
|
519
|
+
|
|
520
|
+
const activeCount = [...tasks.values()].filter(t => t.status === 'active').length;
|
|
521
|
+
const blockedCount = [...tasks.values()].filter(t => t.status === 'blocked').length;
|
|
522
|
+
const completedCount = [...tasks.values()].filter(t => t.status === 'completed').length;
|
|
523
|
+
|
|
524
|
+
lines.push(`**Active:** ${activeCount} | **Blocked:** ${blockedCount} | **Completed:** ${completedCount}`);
|
|
525
|
+
lines.push('');
|
|
526
|
+
|
|
527
|
+
if (ready.length > 0) {
|
|
528
|
+
lines.push('## Ready to Start');
|
|
529
|
+
lines.push('');
|
|
530
|
+
|
|
531
|
+
const sortedReady = ready.sort((a, b) => (blocks.get(b.id)?.length || 0) - (blocks.get(a.id)?.length || 0));
|
|
532
|
+
|
|
533
|
+
for (const task of sortedReady.slice(0, 10)) {
|
|
534
|
+
const blockCount = blocks.get(task.id)?.length || 0;
|
|
535
|
+
const priority = blockCount >= 3 ? ' [HIGH]' : '';
|
|
536
|
+
const unblocks = blockCount > 0 ? `unblocks ${blockCount}` : 'no blockers';
|
|
537
|
+
lines.push(`- **${task.id}** [${task.title}](${task.path}) — ${unblocks}${priority}`);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
lines.push('');
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const blockedTasks = [...tasks.values()].filter(t => t.status === 'blocked');
|
|
544
|
+
if (blockedTasks.length > 0) {
|
|
545
|
+
const blockedIds = blockedTasks.map(t => t.id).sort().join(', ');
|
|
546
|
+
lines.push(`## Blocked (${blockedTasks.length})`);
|
|
547
|
+
lines.push('');
|
|
548
|
+
lines.push(blockedIds);
|
|
549
|
+
lines.push('');
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
lines.push('---');
|
|
553
|
+
lines.push('');
|
|
554
|
+
lines.push('Set current task: use `lore-set-task` tool with task ID');
|
|
555
|
+
lines.push('');
|
|
556
|
+
|
|
557
|
+
return lines.join('\n');
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function generateIndex(loreDir) {
|
|
561
|
+
const tasks = parseTasks(loreDir);
|
|
562
|
+
const adrs = parseAdrs(loreDir);
|
|
563
|
+
const blocks = computeBlocks(tasks);
|
|
564
|
+
|
|
565
|
+
const ready = findReadyTasks(tasks);
|
|
566
|
+
const critical = findCriticalBlockers(tasks, blocks);
|
|
567
|
+
|
|
568
|
+
const nextContent = generateNext(tasks, blocks, ready);
|
|
569
|
+
|
|
570
|
+
const sections = [];
|
|
571
|
+
|
|
572
|
+
const now = new Date();
|
|
573
|
+
const dateStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
|
574
|
+
|
|
575
|
+
sections.push(`# Lore Index
|
|
576
|
+
|
|
577
|
+
> Auto-generated on ${dateStr}. Do not edit manually.
|
|
578
|
+
> Use \`lore-generate-index\` tool to regenerate.
|
|
579
|
+
|
|
580
|
+
Quick reference for task dependencies, status, and ADR relationships.`);
|
|
581
|
+
|
|
582
|
+
const activeCount = [...tasks.values()].filter(t => t.status === 'active').length;
|
|
583
|
+
const blockedCount = [...tasks.values()].filter(t => t.status === 'blocked').length;
|
|
584
|
+
const completedCount = [...tasks.values()].filter(t => t.status === 'completed').length;
|
|
585
|
+
const adrCount = adrs.size;
|
|
586
|
+
|
|
587
|
+
sections.push(`
|
|
588
|
+
## Quick Stats
|
|
589
|
+
|
|
590
|
+
| Active | Blocked | Completed | ADRs |
|
|
591
|
+
|:------:|:-------:|:---------:|:----:|
|
|
592
|
+
| ${activeCount} | ${blockedCount} | ${completedCount} | ${adrCount} |`);
|
|
593
|
+
|
|
594
|
+
if (ready.length > 0) {
|
|
595
|
+
sections.push('\n## Ready to Start\n\nThese tasks have no blockers (or all blockers completed):\n');
|
|
596
|
+
for (const task of ready.sort((a, b) => a.id.localeCompare(b.id))) {
|
|
597
|
+
const blockCount = blocks.get(task.id)?.length || 0;
|
|
598
|
+
const priority = blockCount >= 3 ? '**HIGH**' : blockCount >= 1 ? 'medium' : 'low';
|
|
599
|
+
sections.push(`- **Task ${task.id}**: [${task.title}](${task.path}) — blocks ${blockCount} tasks (${priority})`);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (critical.length > 0) {
|
|
604
|
+
sections.push('\n## Critical Blockers\n\nThese tasks block the most other work:\n');
|
|
605
|
+
for (const [taskId, count] of critical.slice(0, 5)) {
|
|
606
|
+
const task = tasks.get(taskId);
|
|
607
|
+
sections.push(`- **Task ${taskId}**: [${task.title}](${task.path}) — blocks ${count} tasks`);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
sections.push('\n## Dependency Graph\n');
|
|
612
|
+
sections.push(generateMermaid(tasks, adrs));
|
|
613
|
+
sections.push('\n' + generateStatusTable(tasks, blocks));
|
|
614
|
+
sections.push('\n' + generateAdrTable(adrs));
|
|
615
|
+
|
|
616
|
+
sections.push(`
|
|
617
|
+
## Legend
|
|
618
|
+
|
|
619
|
+
**Task Status:**
|
|
620
|
+
- \`active\` — Work can proceed
|
|
621
|
+
- \`blocked\` — Waiting on dependencies
|
|
622
|
+
- \`completed\` — Done, in archive
|
|
623
|
+
|
|
624
|
+
**Graph Arrows:**
|
|
625
|
+
- \`A --> B\` — A blocks B (B depends on A)
|
|
626
|
+
- \`ADR -.-> Task\` — ADR informs Task
|
|
627
|
+
`);
|
|
628
|
+
|
|
629
|
+
return { readme: sections.join('\n'), next: nextContent, stats: { activeCount, blockedCount, completedCount, adrCount } };
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function runGenerateIndex(loreDir) {
|
|
633
|
+
const { readme, next, stats } = generateIndex(loreDir);
|
|
634
|
+
|
|
635
|
+
const indexPath = join(loreDir, 'README.md');
|
|
636
|
+
writeFileSync(indexPath, readme);
|
|
637
|
+
|
|
638
|
+
const nextPath = join(loreDir, '0-session', 'next-tasks.md');
|
|
639
|
+
if (existsSync(dirname(nextPath))) {
|
|
640
|
+
writeFileSync(nextPath, next);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
return {
|
|
644
|
+
generated: [indexPath, nextPath],
|
|
645
|
+
stats,
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// ============================================================================
|
|
650
|
+
// MCP Server Setup
|
|
651
|
+
// ============================================================================
|
|
652
|
+
|
|
653
|
+
const server = new McpServer({
|
|
654
|
+
name: 'lore',
|
|
655
|
+
version: '1.0.0',
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
// Tool: lore-set-user
|
|
659
|
+
server.registerTool(
|
|
660
|
+
'lore-set-user',
|
|
661
|
+
{
|
|
662
|
+
title: 'Set User',
|
|
663
|
+
description: 'Set current user from team.yaml',
|
|
664
|
+
inputSchema: { user_id: z.string().describe('User ID from team.yaml (e.g., "mariusz")') },
|
|
665
|
+
},
|
|
666
|
+
async ({ user_id }) => {
|
|
667
|
+
try {
|
|
668
|
+
const sessionDir = getSessionDir();
|
|
669
|
+
if (!existsSync(sessionDir)) {
|
|
670
|
+
return { content: [{ type: 'text', text: `Error: 0-session/ directory not found. Run lore framework bootstrap first.` }] };
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const result = setUser(sessionDir, user_id);
|
|
674
|
+
return { content: [{ type: 'text', text: `User set: ${result.userId} (${result.name})` }] };
|
|
675
|
+
} catch (e) {
|
|
676
|
+
return { content: [{ type: 'text', text: `Error: ${e.message}` }] };
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
);
|
|
680
|
+
|
|
681
|
+
// Tool: lore-set-task
|
|
682
|
+
server.registerTool(
|
|
683
|
+
'lore-set-task',
|
|
684
|
+
{
|
|
685
|
+
title: 'Set Task',
|
|
686
|
+
description: 'Set current task by ID (creates symlink to task file)',
|
|
687
|
+
inputSchema: { task_id: z.string().describe('Task ID (e.g., "0042" or "18")') },
|
|
688
|
+
},
|
|
689
|
+
async ({ task_id }) => {
|
|
690
|
+
try {
|
|
691
|
+
const loreDir = getLoreDir();
|
|
692
|
+
const sessionDir = getSessionDir();
|
|
693
|
+
|
|
694
|
+
if (!existsSync(sessionDir)) {
|
|
695
|
+
return { content: [{ type: 'text', text: `Error: 0-session/ directory not found. Run lore framework bootstrap first.` }] };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const result = setTask(loreDir, sessionDir, task_id);
|
|
699
|
+
return { content: [{ type: 'text', text: `Task set: ${result.taskId} -> ${result.path}` }] };
|
|
700
|
+
} catch (e) {
|
|
701
|
+
return { content: [{ type: 'text', text: `Error: ${e.message}` }] };
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
);
|
|
705
|
+
|
|
706
|
+
// Tool: lore-show-session
|
|
707
|
+
server.registerTool(
|
|
708
|
+
'lore-show-session',
|
|
709
|
+
{
|
|
710
|
+
title: 'Show Session',
|
|
711
|
+
description: 'Show current session state (user and task)',
|
|
712
|
+
inputSchema: {},
|
|
713
|
+
},
|
|
714
|
+
async () => {
|
|
715
|
+
try {
|
|
716
|
+
const sessionDir = getSessionDir();
|
|
717
|
+
|
|
718
|
+
if (!existsSync(sessionDir)) {
|
|
719
|
+
return { content: [{ type: 'text', text: `Error: 0-session/ directory not found. Run lore framework bootstrap first.` }] };
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
const result = showSession(sessionDir);
|
|
723
|
+
const lines = [];
|
|
724
|
+
|
|
725
|
+
if (result.user) {
|
|
726
|
+
lines.push(`User: ${result.user}`);
|
|
727
|
+
} else {
|
|
728
|
+
const envUser = process.env.LORE_SESSION_CURRENT_USER;
|
|
729
|
+
lines.push(envUser ? `User: not set (LORE_SESSION_CURRENT_USER=${envUser} available)` : 'User: not set');
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
if (result.task) {
|
|
733
|
+
lines.push(`Task: ${result.task.id} -> ${result.task.path}`);
|
|
734
|
+
} else {
|
|
735
|
+
lines.push('Task: not set');
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
739
|
+
} catch (e) {
|
|
740
|
+
return { content: [{ type: 'text', text: `Error: ${e.message}` }] };
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
);
|
|
744
|
+
|
|
745
|
+
// Tool: lore-list-users
|
|
746
|
+
server.registerTool(
|
|
747
|
+
'lore-list-users',
|
|
748
|
+
{
|
|
749
|
+
title: 'List Users',
|
|
750
|
+
description: 'List available users from team.yaml',
|
|
751
|
+
inputSchema: {},
|
|
752
|
+
},
|
|
753
|
+
async () => {
|
|
754
|
+
try {
|
|
755
|
+
const sessionDir = getSessionDir();
|
|
756
|
+
|
|
757
|
+
if (!existsSync(sessionDir)) {
|
|
758
|
+
return { content: [{ type: 'text', text: `Error: 0-session/ directory not found. Run lore framework bootstrap first.` }] };
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
const users = listUsers(sessionDir);
|
|
762
|
+
const lines = ['Available users:', ''];
|
|
763
|
+
for (const user of users) {
|
|
764
|
+
const role = user.role ? ` (${user.role})` : '';
|
|
765
|
+
lines.push(`- ${user.id}: ${user.name}${role}`);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
769
|
+
} catch (e) {
|
|
770
|
+
return { content: [{ type: 'text', text: `Error: ${e.message}` }] };
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
);
|
|
774
|
+
|
|
775
|
+
// Tool: lore-clear-task
|
|
776
|
+
server.registerTool(
|
|
777
|
+
'lore-clear-task',
|
|
778
|
+
{
|
|
779
|
+
title: 'Clear Task',
|
|
780
|
+
description: 'Clear current task symlink',
|
|
781
|
+
inputSchema: {},
|
|
782
|
+
},
|
|
783
|
+
async () => {
|
|
784
|
+
try {
|
|
785
|
+
const sessionDir = getSessionDir();
|
|
786
|
+
|
|
787
|
+
if (!existsSync(sessionDir)) {
|
|
788
|
+
return { content: [{ type: 'text', text: `Error: 0-session/ directory not found. Run lore framework bootstrap first.` }] };
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const result = clearTask(sessionDir);
|
|
792
|
+
if (result.cleared) {
|
|
793
|
+
return { content: [{ type: 'text', text: 'Task cleared' }] };
|
|
794
|
+
} else {
|
|
795
|
+
return { content: [{ type: 'text', text: result.message || result.error || 'No task was set' }] };
|
|
796
|
+
}
|
|
797
|
+
} catch (e) {
|
|
798
|
+
return { content: [{ type: 'text', text: `Error: ${e.message}` }] };
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
);
|
|
802
|
+
|
|
803
|
+
// Tool: lore-generate-index
|
|
804
|
+
server.registerTool(
|
|
805
|
+
'lore-generate-index',
|
|
806
|
+
{
|
|
807
|
+
title: 'Generate Index',
|
|
808
|
+
description: 'Regenerate lore/README.md and 0-session/next-tasks.md from task and ADR frontmatter',
|
|
809
|
+
inputSchema: {},
|
|
810
|
+
},
|
|
811
|
+
async () => {
|
|
812
|
+
try {
|
|
813
|
+
const loreDir = getLoreDir();
|
|
814
|
+
|
|
815
|
+
if (!existsSync(loreDir)) {
|
|
816
|
+
return { content: [{ type: 'text', text: `Error: lore/ directory not found at ${loreDir}` }] };
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const result = runGenerateIndex(loreDir);
|
|
820
|
+
const lines = [
|
|
821
|
+
'Generated:',
|
|
822
|
+
...result.generated.map(p => `- ${p}`),
|
|
823
|
+
'',
|
|
824
|
+
`Stats: ${result.stats.activeCount} active, ${result.stats.blockedCount} blocked, ${result.stats.completedCount} completed, ${result.stats.adrCount} ADRs`,
|
|
825
|
+
];
|
|
826
|
+
|
|
827
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] };
|
|
828
|
+
} catch (e) {
|
|
829
|
+
return { content: [{ type: 'text', text: `Error: ${e.message}` }] };
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
);
|
|
833
|
+
|
|
834
|
+
// Start server
|
|
835
|
+
const transport = new StdioServerTransport();
|
|
836
|
+
await server.connect(transport);
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@maledorak/lore-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server for lore framework - AI-readable project memory with task/ADR/wiki management",
|
|
5
|
+
"bin": {
|
|
6
|
+
"lore-mcp": "./bin/lore-mcp.js"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin/"
|
|
14
|
+
],
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@modelcontextprotocol/sdk": "^1.25.0",
|
|
17
|
+
"glob": "^10.0.0",
|
|
18
|
+
"gray-matter": "^4.0.0",
|
|
19
|
+
"yaml": "^2.0.0",
|
|
20
|
+
"zod": "^3.0.0"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"mcp",
|
|
24
|
+
"claude",
|
|
25
|
+
"claude-code",
|
|
26
|
+
"lore",
|
|
27
|
+
"ai-memory",
|
|
28
|
+
"project-management",
|
|
29
|
+
"task-tracking",
|
|
30
|
+
"adr"
|
|
31
|
+
],
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "https://github.com/maledorak/maledorak-private-marketplace"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/maledorak/maledorak-private-marketplace/tree/main/packages/lore-mcp",
|
|
37
|
+
"author": {
|
|
38
|
+
"name": "Mariusz (Maledorak) Korzekwa",
|
|
39
|
+
"email": "mariusz@korzekwa.dev"
|
|
40
|
+
},
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
}
|
|
45
|
+
}
|