lore-framework-mcp 1.2.2__py3-none-any.whl

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.
@@ -0,0 +1,22 @@
1
+ """
2
+ Lore Framework MCP Server
3
+
4
+ MCP server and CLI for managing lore/ directory structure.
5
+ Provides tools for session management, task tracking, and index generation.
6
+ """
7
+
8
+ import sys
9
+ from .server import mcp, run_server
10
+ from .cli import run_cli
11
+
12
+ __version__ = "1.2.2"
13
+
14
+
15
+ def main():
16
+ """Entry point - detect CLI mode or start MCP server."""
17
+ if len(sys.argv) > 1:
18
+ # CLI mode
19
+ sys.exit(run_cli(sys.argv))
20
+ else:
21
+ # MCP server mode
22
+ run_server()
@@ -0,0 +1,303 @@
1
+ """
2
+ Lore Framework CLI
3
+
4
+ CLI interface for lore-framework-mcp.
5
+
6
+ Usage:
7
+ lore-framework-mcp set-user <user_id>
8
+ lore-framework-mcp set-user --env
9
+ lore-framework-mcp set-task <task_id>
10
+ lore-framework-mcp show-session
11
+ lore-framework-mcp list-users
12
+ lore-framework-mcp clear-task
13
+ lore-framework-mcp generate-index [--next-only] [--quiet]
14
+ """
15
+
16
+ import os
17
+ import sys
18
+ import json
19
+ from pathlib import Path
20
+
21
+ import yaml
22
+ import frontmatter
23
+
24
+ from .server import (
25
+ get_project_dir,
26
+ get_lore_dir,
27
+ get_session_dir,
28
+ load_team,
29
+ generate_current_user_md,
30
+ find_task,
31
+ parse_tasks,
32
+ parse_adrs,
33
+ compute_blocks,
34
+ generate_readme,
35
+ generate_next,
36
+ )
37
+
38
+
39
+ def parse_args(argv: list[str]) -> tuple[str, list[str], dict]:
40
+ """Parse command line arguments."""
41
+ args = argv[1:] # Remove script name
42
+ command = args[0] if args else "help"
43
+ positional = []
44
+ flags = {"env": False, "next_only": False, "quiet": False}
45
+
46
+ for i, arg in enumerate(args[1:], 1):
47
+ if arg == "--env":
48
+ flags["env"] = True
49
+ elif arg == "--next-only":
50
+ flags["next_only"] = True
51
+ elif arg in ("--quiet", "-q"):
52
+ flags["quiet"] = True
53
+ elif not arg.startswith("-"):
54
+ positional.append(arg)
55
+
56
+ return command, positional, flags
57
+
58
+
59
+ def cmd_set_user(args: list[str], flags: dict) -> int:
60
+ """Set current user from team.yaml."""
61
+ session_dir = get_session_dir()
62
+
63
+ if not session_dir.exists():
64
+ print("Error: 0-session/ directory not found", file=sys.stderr)
65
+ return 1
66
+
67
+ if flags["env"]:
68
+ user_id = os.environ.get("LORE_SESSION_CURRENT_USER")
69
+ if not user_id:
70
+ print("Error: LORE_SESSION_CURRENT_USER not set", file=sys.stderr)
71
+ return 1
72
+ else:
73
+ if not args:
74
+ print("Usage: lore-framework-mcp set-user <user_id> | --env", file=sys.stderr)
75
+ return 1
76
+ user_id = args[0]
77
+
78
+ try:
79
+ team = load_team(session_dir)
80
+ except FileNotFoundError as e:
81
+ print(f"Error: {e}", file=sys.stderr)
82
+ return 1
83
+
84
+ if user_id not in team:
85
+ print(f"Error: User '{user_id}' not found", file=sys.stderr)
86
+ print(f"Available: {', '.join(team.keys())}", file=sys.stderr)
87
+ return 1
88
+
89
+ user_data = team[user_id]
90
+ content = generate_current_user_md(user_id, user_data, team)
91
+ (session_dir / "current-user.md").write_text(content)
92
+
93
+ if not flags["quiet"]:
94
+ print(f"User: {user_id}")
95
+ return 0
96
+
97
+
98
+ def cmd_set_task(args: list[str], flags: dict) -> int:
99
+ """Set current task by ID."""
100
+ lore_dir = get_lore_dir()
101
+ session_dir = get_session_dir()
102
+
103
+ if not session_dir.exists():
104
+ print("Error: 0-session/ directory not found", file=sys.stderr)
105
+ return 1
106
+
107
+ if not args:
108
+ print("Usage: lore-framework-mcp set-task <task_id>", file=sys.stderr)
109
+ return 1
110
+
111
+ task_id = args[0]
112
+ task_path = find_task(lore_dir, task_id)
113
+
114
+ if not task_path:
115
+ print(f"Error: Task {task_id} not found", file=sys.stderr)
116
+ return 1
117
+
118
+ current_task_md = session_dir / "current-task.md"
119
+ current_task_json = session_dir / "current-task.json"
120
+
121
+ # Remove existing symlink
122
+ if current_task_md.exists() or current_task_md.is_symlink():
123
+ current_task_md.unlink()
124
+
125
+ # Create relative symlink
126
+ relative_path = os.path.relpath(task_path, session_dir)
127
+ current_task_md.symlink_to(relative_path)
128
+
129
+ # Write task metadata
130
+ task_dir = os.path.relpath(task_path.parent, lore_dir)
131
+ current_task_json.write_text(json.dumps({"id": task_id, "path": task_dir}, indent=2))
132
+
133
+ if not flags["quiet"]:
134
+ print(f"Task: {task_id} -> {relative_path}")
135
+ return 0
136
+
137
+
138
+ def cmd_show_session() -> int:
139
+ """Show current session state."""
140
+ session_dir = get_session_dir()
141
+
142
+ if not session_dir.exists():
143
+ print("Error: 0-session/ directory not found", file=sys.stderr)
144
+ return 1
145
+
146
+ # User
147
+ current_user_md = session_dir / "current-user.md"
148
+ if current_user_md.exists():
149
+ content = current_user_md.read_text()
150
+ for line in content.split("\n"):
151
+ if line.startswith("name:"):
152
+ print(f"User: {line.split(':', 1)[1].strip()}")
153
+ break
154
+ else:
155
+ env_user = os.environ.get("LORE_SESSION_CURRENT_USER")
156
+ if env_user:
157
+ print(f"User: not set (LORE_SESSION_CURRENT_USER={env_user} available)")
158
+ else:
159
+ print("User: not set")
160
+
161
+ # Task
162
+ current_task_md = session_dir / "current-task.md"
163
+ if current_task_md.is_symlink():
164
+ target = os.readlink(current_task_md)
165
+ parts = target.split("/")
166
+ for part in parts:
167
+ if part and part[0].isdigit() and "_" in part:
168
+ print(f"Task: {part.split('_')[0]} -> {target}")
169
+ break
170
+ else:
171
+ print("Task: not set")
172
+
173
+ return 0
174
+
175
+
176
+ def cmd_list_users() -> int:
177
+ """List available users from team.yaml."""
178
+ session_dir = get_session_dir()
179
+
180
+ if not session_dir.exists():
181
+ print("Error: 0-session/ directory not found", file=sys.stderr)
182
+ return 1
183
+
184
+ try:
185
+ team = load_team(session_dir)
186
+ except FileNotFoundError as e:
187
+ print(f"Error: {e}", file=sys.stderr)
188
+ return 1
189
+
190
+ print("Available users:")
191
+ for user_id, user_data in team.items():
192
+ name = user_data.get("name", user_id)
193
+ role = f" ({user_data['role']})" if user_data.get("role") else ""
194
+ print(f" {user_id}: {name}{role}")
195
+
196
+ return 0
197
+
198
+
199
+ def cmd_clear_task(flags: dict) -> int:
200
+ """Clear current task symlink."""
201
+ session_dir = get_session_dir()
202
+
203
+ if not session_dir.exists():
204
+ print("Error: 0-session/ directory not found", file=sys.stderr)
205
+ return 1
206
+
207
+ current_task_md = session_dir / "current-task.md"
208
+ current_task_json = session_dir / "current-task.json"
209
+
210
+ cleared = False
211
+ if current_task_md.exists() or current_task_md.is_symlink():
212
+ current_task_md.unlink()
213
+ cleared = True
214
+ if current_task_json.exists():
215
+ current_task_json.unlink()
216
+ cleared = True
217
+
218
+ if not flags["quiet"]:
219
+ print("Task: cleared" if cleared else "Task: none set")
220
+ return 0
221
+
222
+
223
+ def cmd_generate_index(flags: dict) -> int:
224
+ """Regenerate lore index files."""
225
+ lore_dir = get_lore_dir()
226
+
227
+ if not lore_dir.exists():
228
+ print(f"Error: lore/ directory not found at {lore_dir}", file=sys.stderr)
229
+ return 1
230
+
231
+ tasks = parse_tasks(lore_dir)
232
+ adrs = parse_adrs(lore_dir)
233
+ blocks = compute_blocks(tasks)
234
+
235
+ # Generate next-tasks.md
236
+ next_content = generate_next(tasks, blocks)
237
+ next_path = lore_dir / "0-session" / "next-tasks.md"
238
+ if next_path.parent.exists():
239
+ next_path.write_text(next_content)
240
+ if not flags["quiet"]:
241
+ print(f"Generated {next_path}")
242
+
243
+ # Generate README.md (unless --next-only)
244
+ if not flags["next_only"]:
245
+ readme_content = generate_readme(tasks, adrs, blocks)
246
+ readme_path = lore_dir / "README.md"
247
+ readme_path.write_text(readme_content)
248
+ if not flags["quiet"]:
249
+ print(f"Generated {readme_path}")
250
+
251
+ return 0
252
+
253
+
254
+ def cmd_help() -> int:
255
+ """Show help message."""
256
+ print("""lore-framework-mcp - CLI and MCP server for Lore Framework
257
+
258
+ Usage:
259
+ lore-framework-mcp [command] [options]
260
+
261
+ Commands:
262
+ set-user <id> Set current user from team.yaml
263
+ set-user --env Set user from LORE_SESSION_CURRENT_USER env var
264
+ set-task <id> Set current task by ID
265
+ show-session Show current session state
266
+ list-users List available users from team.yaml
267
+ clear-task Clear current task
268
+ generate-index Regenerate lore/README.md and next-tasks.md
269
+ help Show this help message
270
+
271
+ Options:
272
+ --env Use LORE_SESSION_CURRENT_USER for set-user
273
+ --next-only Only generate next-tasks.md (skip README.md)
274
+ --quiet, -q Suppress output
275
+
276
+ MCP Server:
277
+ Run without arguments to start the MCP server (stdio transport).
278
+ """)
279
+ return 0
280
+
281
+
282
+ def run_cli(argv: list[str]) -> int:
283
+ """Run CLI command."""
284
+ command, args, flags = parse_args(argv)
285
+
286
+ commands = {
287
+ "set-user": lambda: cmd_set_user(args, flags),
288
+ "set-task": lambda: cmd_set_task(args, flags),
289
+ "show-session": cmd_show_session,
290
+ "list-users": cmd_list_users,
291
+ "clear-task": lambda: cmd_clear_task(flags),
292
+ "generate-index": lambda: cmd_generate_index(flags),
293
+ "help": cmd_help,
294
+ "--help": cmd_help,
295
+ "-h": cmd_help,
296
+ }
297
+
298
+ if command in commands:
299
+ return commands[command]()
300
+ else:
301
+ print(f"Unknown command: {command}", file=sys.stderr)
302
+ print('Run with "help" for usage information.', file=sys.stderr)
303
+ return 1
@@ -0,0 +1,572 @@
1
+ """
2
+ Lore Framework MCP Server
3
+
4
+ Provides MCP tools for session and index management.
5
+ """
6
+
7
+ import os
8
+ import json
9
+ from pathlib import Path
10
+ from datetime import datetime
11
+
12
+ import yaml
13
+ import frontmatter
14
+ from mcp.server.fastmcp import FastMCP
15
+
16
+ # Create MCP server
17
+ mcp = FastMCP("lore-framework")
18
+
19
+
20
+ def get_project_dir() -> Path:
21
+ """Get project directory from env or cwd."""
22
+ return Path(os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd()))
23
+
24
+
25
+ def get_lore_dir() -> Path:
26
+ """Get lore directory path."""
27
+ return get_project_dir() / "lore"
28
+
29
+
30
+ def get_session_dir() -> Path:
31
+ """Get session directory path."""
32
+ return get_lore_dir() / "0-session"
33
+
34
+
35
+ def load_team(session_dir: Path) -> dict:
36
+ """Load team.yaml file."""
37
+ team_file = session_dir / "team.yaml"
38
+ if not team_file.exists():
39
+ raise FileNotFoundError(f"team.yaml not found at {team_file}")
40
+ with open(team_file, "r") as f:
41
+ return yaml.safe_load(f) or {}
42
+
43
+
44
+ def generate_current_user_md(user_id: str, user_data: dict, team: dict) -> str:
45
+ """Generate current-user.md content."""
46
+ lines = []
47
+
48
+ # Frontmatter
49
+ lines.append("---")
50
+ lines.append(f"name: {user_id}")
51
+ if user_data.get("github"):
52
+ lines.append(f"github: {user_data['github']}")
53
+ if user_data.get("role"):
54
+ lines.append(f"role: {user_data['role']}")
55
+ lines.append("---")
56
+ lines.append("")
57
+
58
+ # Content
59
+ name = user_data.get("name", user_id)
60
+ lines.append(f"# Current User: {name}")
61
+ lines.append("")
62
+
63
+ if user_data.get("focus"):
64
+ lines.append(f"**Focus:** {user_data['focus'].strip()}")
65
+ lines.append("")
66
+
67
+ if user_data.get("prompting"):
68
+ lines.append("## Communication Preferences")
69
+ lines.append("")
70
+ lines.append(user_data["prompting"].strip())
71
+ lines.append("")
72
+
73
+ if user_data.get("note"):
74
+ lines.append(f"> {user_data['note']}")
75
+ lines.append("")
76
+
77
+ # Other team members
78
+ other_members = [(k, v) for k, v in team.items() if k != user_id]
79
+ if other_members:
80
+ lines.append("---")
81
+ lines.append("")
82
+ lines.append("## Rest of Team")
83
+ lines.append("")
84
+ lines.append("| Name | Role |")
85
+ lines.append("|------|------|")
86
+ for member_id, member_data in other_members:
87
+ member_name = member_data.get("name", member_id)
88
+ role = member_data.get("role", "—")
89
+ lines.append(f"| {member_name} | {role} |")
90
+ lines.append("")
91
+
92
+ return "\n".join(lines)
93
+
94
+
95
+ def find_task(lore_dir: Path, task_id: str) -> Path | None:
96
+ """Find task file by ID."""
97
+ tasks_dir = lore_dir / "1-tasks"
98
+ task_num = task_id.lstrip("0") or "0"
99
+
100
+ for status_dir in ["active", "blocked", "archive", "backlog"]:
101
+ status_path = tasks_dir / status_dir
102
+ if not status_path.exists():
103
+ continue
104
+
105
+ for item in status_path.iterdir():
106
+ if item.name.startswith("_"):
107
+ continue
108
+
109
+ item_id = item.name.split("_")[0].lstrip("0") or "0"
110
+ if item_id == task_num:
111
+ if item.is_dir():
112
+ readme = item / "README.md"
113
+ if readme.exists():
114
+ return readme
115
+ elif item.suffix == ".md":
116
+ return item
117
+
118
+ return None
119
+
120
+
121
+ # ============================================================================
122
+ # MCP Tools
123
+ # ============================================================================
124
+
125
+ @mcp.tool()
126
+ def lore_framework_set_user(user_id: str) -> str:
127
+ """Set current user from team.yaml.
128
+
129
+ Args:
130
+ user_id: The user ID from team.yaml
131
+ """
132
+ session_dir = get_session_dir()
133
+
134
+ if not session_dir.exists():
135
+ return "Error: 0-session/ directory not found. Run lore framework bootstrap first."
136
+
137
+ try:
138
+ team = load_team(session_dir)
139
+ except FileNotFoundError as e:
140
+ return f"Error: {e}"
141
+
142
+ if user_id not in team:
143
+ available = ", ".join(team.keys())
144
+ return f"Error: User '{user_id}' not found. Available: {available}"
145
+
146
+ user_data = team[user_id]
147
+ content = generate_current_user_md(user_id, user_data, team)
148
+
149
+ current_user_md = session_dir / "current-user.md"
150
+ current_user_md.write_text(content)
151
+
152
+ name = user_data.get("name", user_id)
153
+ return f"User set: {user_id} ({name})"
154
+
155
+
156
+ @mcp.tool()
157
+ def lore_framework_set_task(task_id: str) -> str:
158
+ """Set current task by ID (creates symlink to task file).
159
+
160
+ Args:
161
+ task_id: The task ID (e.g., "1", "01", "123")
162
+ """
163
+ lore_dir = get_lore_dir()
164
+ session_dir = get_session_dir()
165
+
166
+ if not session_dir.exists():
167
+ return "Error: 0-session/ directory not found. Run lore framework bootstrap first."
168
+
169
+ task_path = find_task(lore_dir, task_id)
170
+ if not task_path:
171
+ return f"Error: Task {task_id} not found. Check 1-tasks/{{active,blocked,archive,backlog}}/"
172
+
173
+ current_task_md = session_dir / "current-task.md"
174
+ current_task_json = session_dir / "current-task.json"
175
+
176
+ # Remove existing symlink
177
+ if current_task_md.exists() or current_task_md.is_symlink():
178
+ current_task_md.unlink()
179
+
180
+ # Create relative symlink
181
+ relative_path = os.path.relpath(task_path, session_dir)
182
+ current_task_md.symlink_to(relative_path)
183
+
184
+ # Write task metadata
185
+ task_dir = os.path.relpath(task_path.parent, lore_dir)
186
+ current_task_json.write_text(json.dumps({"id": task_id, "path": task_dir}, indent=2))
187
+
188
+ return f"Task set: {task_id} -> {relative_path}"
189
+
190
+
191
+ @mcp.tool()
192
+ def lore_framework_show_session() -> str:
193
+ """Show current session state (user and task)."""
194
+ session_dir = get_session_dir()
195
+
196
+ if not session_dir.exists():
197
+ return "Error: 0-session/ directory not found. Run lore framework bootstrap first."
198
+
199
+ lines = []
200
+
201
+ # User
202
+ current_user_md = session_dir / "current-user.md"
203
+ if current_user_md.exists():
204
+ content = current_user_md.read_text()
205
+ for line in content.split("\n"):
206
+ if line.startswith("name:"):
207
+ lines.append(f"User: {line.split(':', 1)[1].strip()}")
208
+ break
209
+ else:
210
+ env_user = os.environ.get("LORE_SESSION_CURRENT_USER")
211
+ if env_user:
212
+ lines.append(f"User: not set (LORE_SESSION_CURRENT_USER={env_user} available)")
213
+ else:
214
+ lines.append("User: not set")
215
+
216
+ # Task
217
+ current_task_md = session_dir / "current-task.md"
218
+ if current_task_md.is_symlink():
219
+ target = os.readlink(current_task_md)
220
+ parts = target.split("/")
221
+ for part in parts:
222
+ if part and part[0].isdigit() and "_" in part:
223
+ lines.append(f"Task: {part.split('_')[0]} -> {target}")
224
+ break
225
+ else:
226
+ lines.append("Task: not set")
227
+
228
+ return "\n".join(lines)
229
+
230
+
231
+ @mcp.tool()
232
+ def lore_framework_list_users() -> str:
233
+ """List available users from team.yaml."""
234
+ session_dir = get_session_dir()
235
+
236
+ if not session_dir.exists():
237
+ return "Error: 0-session/ directory not found. Run lore framework bootstrap first."
238
+
239
+ try:
240
+ team = load_team(session_dir)
241
+ except FileNotFoundError as e:
242
+ return f"Error: {e}"
243
+
244
+ lines = ["Available users:", ""]
245
+ for user_id, user_data in team.items():
246
+ name = user_data.get("name", user_id)
247
+ role = f" ({user_data['role']})" if user_data.get("role") else ""
248
+ lines.append(f"- {user_id}: {name}{role}")
249
+
250
+ return "\n".join(lines)
251
+
252
+
253
+ @mcp.tool()
254
+ def lore_framework_clear_task() -> str:
255
+ """Clear current task symlink."""
256
+ session_dir = get_session_dir()
257
+
258
+ if not session_dir.exists():
259
+ return "Error: 0-session/ directory not found. Run lore framework bootstrap first."
260
+
261
+ current_task_md = session_dir / "current-task.md"
262
+ current_task_json = session_dir / "current-task.json"
263
+
264
+ cleared = False
265
+ if current_task_md.exists() or current_task_md.is_symlink():
266
+ current_task_md.unlink()
267
+ cleared = True
268
+ if current_task_json.exists():
269
+ current_task_json.unlink()
270
+ cleared = True
271
+
272
+ return "Task cleared" if cleared else "No task was set"
273
+
274
+
275
+ @mcp.tool()
276
+ def lore_framework_generate_index() -> str:
277
+ """Regenerate lore/README.md and 0-session/next-tasks.md from task and ADR frontmatter."""
278
+ lore_dir = get_lore_dir()
279
+
280
+ if not lore_dir.exists():
281
+ return f"Error: lore/ directory not found at {lore_dir}"
282
+
283
+ tasks = parse_tasks(lore_dir)
284
+ adrs = parse_adrs(lore_dir)
285
+ blocks = compute_blocks(tasks)
286
+
287
+ # Generate README.md
288
+ readme_content = generate_readme(tasks, adrs, blocks)
289
+ readme_path = lore_dir / "README.md"
290
+ readme_path.write_text(readme_content)
291
+
292
+ # Generate next-tasks.md
293
+ next_content = generate_next(tasks, blocks)
294
+ next_path = lore_dir / "0-session" / "next-tasks.md"
295
+ if next_path.parent.exists():
296
+ next_path.write_text(next_content)
297
+
298
+ stats = {
299
+ "active": len([t for t in tasks.values() if t["status"] == "active"]),
300
+ "blocked": len([t for t in tasks.values() if t["status"] == "blocked"]),
301
+ "backlog": len([t for t in tasks.values() if t["status"] == "backlog"]),
302
+ "completed": len([t for t in tasks.values() if t["status"] == "completed"]),
303
+ "adrs": len(adrs),
304
+ }
305
+
306
+ return f"""Generated:
307
+ - {readme_path}
308
+ - {next_path}
309
+
310
+ Stats: {stats['active']} active, {stats['blocked']} blocked, {stats['backlog']} backlog, {stats['completed']} completed, {stats['adrs']} ADRs"""
311
+
312
+
313
+ # ============================================================================
314
+ # Index Generation Helpers
315
+ # ============================================================================
316
+
317
+ def parse_tasks(lore_dir: Path) -> dict:
318
+ """Parse all tasks from 1-tasks/."""
319
+ tasks = {}
320
+ tasks_base = lore_dir / "1-tasks"
321
+
322
+ for subdir in ["active", "blocked", "archive", "backlog"]:
323
+ subdir_path = tasks_base / subdir
324
+ if not subdir_path.exists():
325
+ continue
326
+
327
+ for item in subdir_path.iterdir():
328
+ if item.name.startswith("_"):
329
+ continue
330
+
331
+ task_path = None
332
+ if item.is_file() and item.suffix == ".md":
333
+ task_path = item
334
+ elif item.is_dir():
335
+ readme = item / "README.md"
336
+ if readme.exists():
337
+ task_path = readme
338
+
339
+ if not task_path:
340
+ continue
341
+
342
+ # Extract task ID
343
+ if task_path.name == "README.md":
344
+ task_id = task_path.parent.name.split("_")[0]
345
+ else:
346
+ task_id = item.stem.split("_")[0]
347
+
348
+ if not task_id.isdigit():
349
+ continue
350
+
351
+ try:
352
+ post = frontmatter.load(task_path)
353
+ meta = post.metadata
354
+
355
+ if not meta:
356
+ continue
357
+
358
+ status = meta.get("status", "active")
359
+ if subdir == "archive":
360
+ status = "completed"
361
+ elif subdir == "blocked":
362
+ status = "blocked"
363
+ elif subdir == "backlog":
364
+ status = "backlog"
365
+
366
+ # Extract blocked_by from history
367
+ blocked_by = []
368
+ history = meta.get("history", [])
369
+ if history and isinstance(history, list) and len(history) > 0:
370
+ latest = history[-1]
371
+ if latest.get("status") == "blocked":
372
+ by = latest.get("by", [])
373
+ if isinstance(by, list):
374
+ blocked_by = [str(b) for b in by]
375
+ elif by:
376
+ blocked_by = [str(by)]
377
+
378
+ tasks[str(meta.get("id", task_id))] = {
379
+ "id": str(meta.get("id", task_id)),
380
+ "title": meta.get("title") or extract_title(post.content),
381
+ "type": meta.get("type", "FEATURE"),
382
+ "status": status,
383
+ "path": str(task_path.relative_to(lore_dir.parent)),
384
+ "blocked_by": blocked_by,
385
+ "related_adr": meta.get("related_adr", []) or [],
386
+ }
387
+ except Exception:
388
+ pass
389
+
390
+ return tasks
391
+
392
+
393
+ def parse_adrs(lore_dir: Path) -> dict:
394
+ """Parse all ADRs from 2-adrs/."""
395
+ adrs = {}
396
+ adr_dir = lore_dir / "2-adrs"
397
+
398
+ if not adr_dir.exists():
399
+ return adrs
400
+
401
+ for item in adr_dir.glob("*.md"):
402
+ if item.name.startswith("_"):
403
+ continue
404
+
405
+ adr_id = item.stem.split("_")[0]
406
+
407
+ try:
408
+ post = frontmatter.load(item)
409
+ meta = post.metadata
410
+
411
+ if not meta:
412
+ continue
413
+
414
+ adrs[str(meta.get("id", adr_id))] = {
415
+ "id": str(meta.get("id", adr_id)),
416
+ "title": meta.get("title") or extract_title(post.content),
417
+ "status": meta.get("status", "proposed"),
418
+ "path": str(item.relative_to(lore_dir.parent)),
419
+ "related_tasks": meta.get("related_tasks", []) or [],
420
+ }
421
+ except Exception:
422
+ pass
423
+
424
+ return adrs
425
+
426
+
427
+ def extract_title(content: str) -> str:
428
+ """Extract title from markdown content."""
429
+ for line in content.split("\n"):
430
+ if line.startswith("# "):
431
+ return line[2:].strip()
432
+ return "Untitled"
433
+
434
+
435
+ def compute_blocks(tasks: dict) -> dict:
436
+ """Compute which tasks block other tasks."""
437
+ blocks = {tid: [] for tid in tasks}
438
+
439
+ for task in tasks.values():
440
+ for blocker_id in task.get("blocked_by", []):
441
+ if blocker_id in blocks:
442
+ blocks[blocker_id].append(task["id"])
443
+
444
+ return blocks
445
+
446
+
447
+ def generate_readme(tasks: dict, adrs: dict, blocks: dict) -> str:
448
+ """Generate lore/README.md content."""
449
+ now = datetime.now()
450
+ date_str = now.strftime("%Y-%m-%d %H:%M")
451
+
452
+ active_count = len([t for t in tasks.values() if t["status"] == "active"])
453
+ blocked_count = len([t for t in tasks.values() if t["status"] == "blocked"])
454
+ backlog_count = len([t for t in tasks.values() if t["status"] == "backlog"])
455
+ completed_count = len([t for t in tasks.values() if t["status"] == "completed"])
456
+ adr_count = len(adrs)
457
+
458
+ sections = [f"""# Lore Index
459
+
460
+ > Auto-generated on {date_str}. Do not edit manually.
461
+ > Use `lore-framework_generate-index` tool to regenerate.
462
+
463
+ Quick reference for task dependencies, status, and ADR relationships.
464
+
465
+ ## Quick Stats
466
+
467
+ | Active | Blocked | Backlog | Completed | ADRs |
468
+ |:------:|:-------:|:-------:|:---------:|:----:|
469
+ | {active_count} | {blocked_count} | {backlog_count} | {completed_count} | {adr_count} |"""]
470
+
471
+ # Ready to start
472
+ completed_ids = {t["id"] for t in tasks.values() if t["status"] == "completed"}
473
+ ready = []
474
+ for task in tasks.values():
475
+ if task["status"] not in ["active", "blocked"]:
476
+ continue
477
+ blocked_by = task.get("blocked_by", [])
478
+ if not blocked_by or all(b in completed_ids for b in blocked_by):
479
+ ready.append(task)
480
+
481
+ if ready:
482
+ sections.append("\n## Ready to Start\n\nThese tasks have no blockers (or all blockers completed):\n")
483
+ for task in sorted(ready, key=lambda t: t["id"]):
484
+ block_count = len(blocks.get(task["id"], []))
485
+ priority = "**HIGH**" if block_count >= 3 else "medium" if block_count >= 1 else "low"
486
+ sections.append(f"- **Task {task['id']}**: [{task['title']}]({task['path']}) — blocks {block_count} tasks ({priority})")
487
+
488
+ # Task status table
489
+ sections.append("\n## Task Status\n")
490
+ sections.append("| ID | Title | Type | Status | Blocked By | Blocks | ADRs |")
491
+ sections.append("|:---|:------|:-----|:-------|:-----------|:-------|:-----|")
492
+
493
+ status_order = {"active": 0, "blocked": 1, "backlog": 2, "completed": 3}
494
+ sorted_tasks = sorted(tasks.values(), key=lambda t: (status_order.get(t["status"], 4), t["id"]))
495
+
496
+ for task in sorted_tasks:
497
+ blocked_by = ", ".join(task.get("blocked_by", [])) or "—"
498
+ task_blocks = ", ".join(sorted(blocks.get(task["id"], []))) or "—"
499
+ related_adr = ", ".join(str(a) for a in task.get("related_adr", [])) or "—"
500
+ status_display = f"**{task['status']}**" if task["status"] == "active" else task["status"]
501
+ title = task["title"][:35] + "..." if len(task["title"]) > 35 else task["title"]
502
+ sections.append(f"| {task['id']} | [{title}]({task['path']}) | {task['type']} | {status_display} | {blocked_by} | {task_blocks} | {related_adr} |")
503
+
504
+ # ADR table
505
+ if adrs:
506
+ sections.append("\n## Architecture Decision Records\n")
507
+ sections.append("| ID | Title | Status | Related Tasks |")
508
+ sections.append("|:---|:------|:-------|:--------------|")
509
+ for adr in sorted(adrs.values(), key=lambda a: a["id"]):
510
+ related = ", ".join(str(t) for t in adr.get("related_tasks", [])) or "—"
511
+ sections.append(f"| {adr['id']} | [{adr['title']}]({adr['path']}) | {adr['status']} | {related} |")
512
+
513
+ return "\n".join(sections)
514
+
515
+
516
+ def generate_next(tasks: dict, blocks: dict) -> str:
517
+ """Generate 0-session/next-tasks.md content."""
518
+ completed_ids = {t["id"] for t in tasks.values() if t["status"] == "completed"}
519
+ ready = []
520
+ for task in tasks.values():
521
+ if task["status"] not in ["active", "blocked"]:
522
+ continue
523
+ blocked_by = task.get("blocked_by", [])
524
+ if not blocked_by or all(b in completed_ids for b in blocked_by):
525
+ ready.append(task)
526
+
527
+ active_count = len([t for t in tasks.values() if t["status"] == "active"])
528
+ blocked_count = len([t for t in tasks.values() if t["status"] == "blocked"])
529
+ backlog_count = len([t for t in tasks.values() if t["status"] == "backlog"])
530
+ completed_count = len([t for t in tasks.values() if t["status"] == "completed"])
531
+
532
+ lines = [
533
+ "# Next Tasks",
534
+ "",
535
+ "> Auto-generated. Use `lore-framework_generate-index` tool to regenerate.",
536
+ "> Full index: [README.md](../README.md)",
537
+ "",
538
+ f"**Active:** {active_count} | **Blocked:** {blocked_count} | **Backlog:** {backlog_count} | **Completed:** {completed_count}",
539
+ "",
540
+ ]
541
+
542
+ if ready:
543
+ lines.append("## Ready to Start")
544
+ lines.append("")
545
+
546
+ sorted_ready = sorted(ready, key=lambda t: -len(blocks.get(t["id"], [])))
547
+ for task in sorted_ready[:10]:
548
+ block_count = len(blocks.get(task["id"], []))
549
+ priority = " [HIGH]" if block_count >= 3 else ""
550
+ unblocks = f"unblocks {block_count}" if block_count > 0 else "no blockers"
551
+ lines.append(f"- **{task['id']}** [{task['title']}]({task['path']}) — {unblocks}{priority}")
552
+ lines.append("")
553
+
554
+ blocked_tasks = [t for t in tasks.values() if t["status"] == "blocked"]
555
+ if blocked_tasks:
556
+ blocked_ids = ", ".join(sorted(t["id"] for t in blocked_tasks))
557
+ lines.append(f"## Blocked ({len(blocked_tasks)})")
558
+ lines.append("")
559
+ lines.append(blocked_ids)
560
+ lines.append("")
561
+
562
+ lines.append("---")
563
+ lines.append("")
564
+ lines.append("Set current task: use `lore-framework_set-task` tool with task ID")
565
+ lines.append("")
566
+
567
+ return "\n".join(lines)
568
+
569
+
570
+ def run_server():
571
+ """Run the MCP server."""
572
+ mcp.run()
@@ -0,0 +1,115 @@
1
+ Metadata-Version: 2.4
2
+ Name: lore-framework-mcp
3
+ Version: 1.2.2
4
+ Summary: MCP server for Lore Framework - AI-readable project memory with task/ADR/wiki management
5
+ Project-URL: Homepage, https://github.com/maledorak/maledorak-marketplace/tree/main/packages/lore-framework-mcp-py
6
+ Project-URL: Repository, https://github.com/maledorak/maledorak-marketplace
7
+ Author-email: "Mariusz (Maledorak) Korzekwa" <mariusz@korzekwa.dev>
8
+ License-Expression: MIT
9
+ Keywords: adr,ai-memory,claude,claude-code,lore,mcp,project-management,task-tracking
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: mcp>=1.0.0
20
+ Requires-Dist: python-frontmatter>=1.0.0
21
+ Requires-Dist: pyyaml>=6.0
22
+ Description-Content-Type: text/markdown
23
+
24
+ # Lore Framework MCP (Python)
25
+
26
+ MCP server for Lore Framework - AI-readable project memory with task/ADR/wiki management.
27
+
28
+ ## Installation
29
+
30
+ ```bash
31
+ # Using uvx (recommended)
32
+ uvx lore-framework-mcp
33
+
34
+ # Using pip
35
+ pip install lore-framework-mcp
36
+ ```
37
+
38
+ ## Usage
39
+
40
+ ### As MCP Server
41
+
42
+ Add to your `.mcp.json`:
43
+
44
+ ```json
45
+ {
46
+ "mcpServers": {
47
+ "lore-framework": {
48
+ "command": "uvx",
49
+ "args": ["lore-framework-mcp"]
50
+ }
51
+ }
52
+ }
53
+ ```
54
+
55
+ ### As CLI
56
+
57
+ ```bash
58
+ # Set current user
59
+ lore-framework-mcp set-user <user_id>
60
+ lore-framework-mcp set-user --env # from LORE_SESSION_CURRENT_USER
61
+
62
+ # Set current task
63
+ lore-framework-mcp set-task <task_id>
64
+
65
+ # Show session state
66
+ lore-framework-mcp show-session
67
+
68
+ # List available users
69
+ lore-framework-mcp list-users
70
+
71
+ # Clear current task
72
+ lore-framework-mcp clear-task
73
+
74
+ # Regenerate indexes
75
+ lore-framework-mcp generate-index
76
+ lore-framework-mcp generate-index --next-only --quiet
77
+ ```
78
+
79
+ ## MCP Tools
80
+
81
+ | Tool | Description |
82
+ |------|-------------|
83
+ | `lore_framework_set_user` | Set current user from team.yaml |
84
+ | `lore_framework_set_task` | Set current task by ID (creates symlink) |
85
+ | `lore_framework_show_session` | Show current session state (user and task) |
86
+ | `lore_framework_list_users` | List available users from team.yaml |
87
+ | `lore_framework_clear_task` | Clear current task symlink |
88
+ | `lore_framework_generate_index` | Regenerate lore/README.md and next-tasks.md |
89
+
90
+ ## Lore Directory Structure
91
+
92
+ ```
93
+ lore/
94
+ ├── 0-session/ # Session state (gitignored)
95
+ │ ├── team.yaml # Team members definition
96
+ │ ├── current-user.md # Active user (generated)
97
+ │ ├── current-task.md # Symlink to active task
98
+ │ └── next-tasks.md # Auto-generated task queue
99
+ ├── 1-tasks/ # Task management
100
+ │ ├── active/ # In-progress tasks
101
+ │ ├── blocked/ # Blocked tasks
102
+ │ ├── backlog/ # Planned tasks
103
+ │ └── archive/ # Completed tasks
104
+ ├── 2-adrs/ # Architecture Decision Records
105
+ ├── 3-wiki/ # Project documentation
106
+ └── README.md # Auto-generated index
107
+ ```
108
+
109
+ ## Documentation
110
+
111
+ See full documentation: [Lore Framework Plugin](https://github.com/maledorak/maledorak-marketplace/tree/main/plugins/lore-framework)
112
+
113
+ ## License
114
+
115
+ MIT
@@ -0,0 +1,7 @@
1
+ lore_framework_mcp/__init__.py,sha256=VUDi7g79QYVWyGIy1cr-qNPI5VJA3N4Qi2FU1WlOgIo,479
2
+ lore_framework_mcp/cli.py,sha256=UPAS0HgP9aIYfvZo7CuQpPhfK6egfpHbwpXMLAJbPRw,8840
3
+ lore_framework_mcp/server.py,sha256=XRCruIfGIyly12P_jQs7h6viXRTZ3V19ZU7NfBrosf4,19313
4
+ lore_framework_mcp-1.2.2.dist-info/METADATA,sha256=Ui_UEp27QVGsvjDUniMIhjmdqn3fax6i-bGUzF7W3Cg,3407
5
+ lore_framework_mcp-1.2.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
6
+ lore_framework_mcp-1.2.2.dist-info/entry_points.txt,sha256=N60Mad4vqpVo9_rZa9fW1H9BVGIWzZu-P3EHi1a8rzI,63
7
+ lore_framework_mcp-1.2.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ lore-framework-mcp = lore_framework_mcp:main