loki-mode 6.0.0 → 6.2.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 +20 -0
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/bmad-adapter.py +776 -0
- package/autonomy/loki +393 -0
- package/autonomy/prd-analyzer.py +26 -4
- package/autonomy/run.sh +149 -4
- package/autonomy/sandbox.sh +181 -1
- package/dashboard/__init__.py +1 -1
- package/docs/INSTALLATION.md +1 -1
- package/docs/architecture/bmad-integration-epic.md +271 -0
- package/docs/architecture/bmad-integration-review.md +86 -0
- package/docs/architecture/bmad-integration-validation.md +249 -0
- package/docs/architecture/bmad-loki-voice-agent-council-analysis.md +61 -0
- package/mcp/__init__.py +1 -1
- package/mcp/requirements.txt +1 -0
- package/mcp/server.py +152 -0
- package/package.json +1 -1
- package/templates/clusters/README.md +21 -0
- package/templates/clusters/code-review.json +36 -0
- package/templates/clusters/performance-audit.json +29 -0
- package/templates/clusters/refactoring.json +29 -0
- package/templates/clusters/security-review.json +36 -0
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""BMAD Artifact Adapter for Loki Mode
|
|
3
|
+
|
|
4
|
+
Discovers, parses, and normalizes BMAD methodology artifacts into
|
|
5
|
+
Loki Mode's native format. Bridges BMAD workflow output into the
|
|
6
|
+
prd-analyzer.py and .loki/queue/ pipeline.
|
|
7
|
+
|
|
8
|
+
Stdlib only - no pip dependencies required. Python 3.9+.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
python3 bmad-adapter.py <project-path> [options]
|
|
12
|
+
--output-dir DIR Where to write output files (default: .loki/)
|
|
13
|
+
--json Output metadata as JSON to stdout
|
|
14
|
+
--validate Run artifact chain validation
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import sys
|
|
22
|
+
import tempfile
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
25
|
+
|
|
26
|
+
# Maximum artifact file size (10 MB)
|
|
27
|
+
MAX_ARTIFACT_SIZE = 10 * 1024 * 1024
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _safe_read(path: Path) -> str:
|
|
31
|
+
"""Read a file with size limit and encoding safety."""
|
|
32
|
+
size = path.stat().st_size
|
|
33
|
+
if size > MAX_ARTIFACT_SIZE:
|
|
34
|
+
raise ValueError(f"Artifact too large ({size} bytes, max {MAX_ARTIFACT_SIZE}): {path.name}")
|
|
35
|
+
return path.read_text(encoding="utf-8", errors="replace")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _write_atomic(path: Path, content: str) -> None:
|
|
39
|
+
"""Write content to file atomically using temp file + rename."""
|
|
40
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
fd, tmp_path = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
|
|
42
|
+
try:
|
|
43
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
44
|
+
f.write(content)
|
|
45
|
+
os.replace(tmp_path, str(path))
|
|
46
|
+
except Exception:
|
|
47
|
+
try:
|
|
48
|
+
os.unlink(tmp_path)
|
|
49
|
+
except OSError:
|
|
50
|
+
pass
|
|
51
|
+
raise
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# -- BMAD Workflow Definitions ------------------------------------------------
|
|
55
|
+
|
|
56
|
+
# Expected steps for each BMAD workflow type
|
|
57
|
+
WORKFLOW_STEPS = {
|
|
58
|
+
"prd": [
|
|
59
|
+
"init", "discovery", "vision", "executive-summary", "success",
|
|
60
|
+
"journeys", "functional", "nonfunctional", "polish", "complete",
|
|
61
|
+
],
|
|
62
|
+
"architecture": [
|
|
63
|
+
"init", "context", "decisions", "patterns", "structure",
|
|
64
|
+
"validation", "complete",
|
|
65
|
+
],
|
|
66
|
+
"epics": [
|
|
67
|
+
"validate-prerequisites", "design-epics", "create-stories",
|
|
68
|
+
"final-validation",
|
|
69
|
+
],
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# Standard BMAD output directory structure
|
|
73
|
+
BMAD_OUTPUT_DIR = "_bmad-output/planning-artifacts"
|
|
74
|
+
BMAD_CONFIG_DIR = "_bmad"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# -- YAML Frontmatter Parsing ------------------------------------------------
|
|
78
|
+
|
|
79
|
+
def parse_frontmatter(text: str) -> Tuple[Dict[str, Any], str]:
|
|
80
|
+
"""Extract YAML frontmatter from a markdown document.
|
|
81
|
+
|
|
82
|
+
Returns (metadata_dict, body_without_frontmatter).
|
|
83
|
+
Handles simple YAML: scalars, lists (flow and block), quoted strings.
|
|
84
|
+
Does NOT require PyYAML -- uses regex-based extraction.
|
|
85
|
+
"""
|
|
86
|
+
stripped = text.lstrip()
|
|
87
|
+
if not stripped.startswith("---"):
|
|
88
|
+
return {}, text
|
|
89
|
+
|
|
90
|
+
# Find closing ---
|
|
91
|
+
lines = stripped.split("\n")
|
|
92
|
+
end_idx = None
|
|
93
|
+
for i, line in enumerate(lines[1:], start=1):
|
|
94
|
+
if line.strip() == "---":
|
|
95
|
+
end_idx = i
|
|
96
|
+
break
|
|
97
|
+
|
|
98
|
+
if end_idx is None:
|
|
99
|
+
return {}, text
|
|
100
|
+
|
|
101
|
+
frontmatter_lines = lines[1:end_idx]
|
|
102
|
+
body = "\n".join(lines[end_idx + 1:]).lstrip("\n")
|
|
103
|
+
metadata: Dict[str, Any] = {}
|
|
104
|
+
|
|
105
|
+
for line in frontmatter_lines:
|
|
106
|
+
line = line.strip()
|
|
107
|
+
if not line or line.startswith("#"):
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
match = re.match(r"^(\w[\w-]*):\s*(.*)", line)
|
|
111
|
+
if not match:
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
key = match.group(1)
|
|
115
|
+
value = match.group(2).strip()
|
|
116
|
+
|
|
117
|
+
# Flow-style list: [item1, item2, item3]
|
|
118
|
+
if value.startswith("[") and value.endswith("]"):
|
|
119
|
+
items = value[1:-1].split(",")
|
|
120
|
+
metadata[key] = [_unquote(item.strip()) for item in items if item.strip()]
|
|
121
|
+
# Quoted string
|
|
122
|
+
elif (value.startswith("'") and value.endswith("'")) or \
|
|
123
|
+
(value.startswith('"') and value.endswith('"')):
|
|
124
|
+
metadata[key] = value[1:-1]
|
|
125
|
+
# Plain scalar
|
|
126
|
+
else:
|
|
127
|
+
metadata[key] = value
|
|
128
|
+
|
|
129
|
+
return metadata, body
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _unquote(s: str) -> str:
|
|
133
|
+
"""Remove surrounding quotes from a string."""
|
|
134
|
+
if len(s) >= 2:
|
|
135
|
+
if (s[0] == "'" and s[-1] == "'") or (s[0] == '"' and s[-1] == '"'):
|
|
136
|
+
return s[1:-1]
|
|
137
|
+
return s
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# -- Artifact Discovery -------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
class BmadArtifacts:
|
|
143
|
+
"""Container for discovered BMAD artifacts in a project directory."""
|
|
144
|
+
|
|
145
|
+
def __init__(self, project_path: str):
|
|
146
|
+
self.project_path = Path(project_path).resolve()
|
|
147
|
+
self.prd_path: Optional[Path] = None
|
|
148
|
+
self.architecture_path: Optional[Path] = None
|
|
149
|
+
self.epics_path: Optional[Path] = None
|
|
150
|
+
self.output_dir: Optional[Path] = None
|
|
151
|
+
self.errors: List[str] = []
|
|
152
|
+
self._discover()
|
|
153
|
+
|
|
154
|
+
def _discover(self) -> None:
|
|
155
|
+
"""Find BMAD artifacts in the project directory."""
|
|
156
|
+
# Check for custom output folder via _bmad/ config
|
|
157
|
+
config_dir = self.project_path / BMAD_CONFIG_DIR
|
|
158
|
+
if config_dir.is_dir():
|
|
159
|
+
config_file = config_dir / "config.json"
|
|
160
|
+
if config_file.exists():
|
|
161
|
+
try:
|
|
162
|
+
with open(config_file, "r", encoding="utf-8") as f:
|
|
163
|
+
config = json.load(f)
|
|
164
|
+
custom_output = config.get("outputDir", "")
|
|
165
|
+
if custom_output:
|
|
166
|
+
candidate = (self.project_path / custom_output / "planning-artifacts").resolve()
|
|
167
|
+
# Ensure resolved path stays within the project root
|
|
168
|
+
if candidate.is_dir() and str(candidate).startswith(str(self.project_path) + os.sep):
|
|
169
|
+
self.output_dir = candidate
|
|
170
|
+
except (json.JSONDecodeError, OSError):
|
|
171
|
+
pass
|
|
172
|
+
|
|
173
|
+
# Default output directory
|
|
174
|
+
if self.output_dir is None:
|
|
175
|
+
default_dir = self.project_path / BMAD_OUTPUT_DIR
|
|
176
|
+
if default_dir.is_dir():
|
|
177
|
+
self.output_dir = default_dir
|
|
178
|
+
else:
|
|
179
|
+
self.errors.append(
|
|
180
|
+
f"BMAD output directory not found: {BMAD_OUTPUT_DIR}"
|
|
181
|
+
)
|
|
182
|
+
return
|
|
183
|
+
|
|
184
|
+
# Find PRD: prd-*.md or prd.md
|
|
185
|
+
prd_candidates = sorted(self.output_dir.glob("prd-*.md"))
|
|
186
|
+
if prd_candidates:
|
|
187
|
+
self.prd_path = prd_candidates[0]
|
|
188
|
+
else:
|
|
189
|
+
prd_fallback = self.output_dir / "prd.md"
|
|
190
|
+
if prd_fallback.exists():
|
|
191
|
+
self.prd_path = prd_fallback
|
|
192
|
+
else:
|
|
193
|
+
self.errors.append("No PRD file found (expected prd-*.md or prd.md)")
|
|
194
|
+
|
|
195
|
+
# Find architecture.md (optional)
|
|
196
|
+
arch_path = self.output_dir / "architecture.md"
|
|
197
|
+
if arch_path.exists():
|
|
198
|
+
self.architecture_path = arch_path
|
|
199
|
+
|
|
200
|
+
# Find epics.md (optional)
|
|
201
|
+
epics_path = self.output_dir / "epics.md"
|
|
202
|
+
if epics_path.exists():
|
|
203
|
+
self.epics_path = epics_path
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def is_valid(self) -> bool:
|
|
207
|
+
"""True if at least a PRD was found."""
|
|
208
|
+
return self.prd_path is not None
|
|
209
|
+
|
|
210
|
+
def inventory(self) -> Dict[str, Optional[str]]:
|
|
211
|
+
"""Return artifact paths as strings (or None if missing)."""
|
|
212
|
+
return {
|
|
213
|
+
"prd": str(self.prd_path) if self.prd_path else None,
|
|
214
|
+
"architecture": str(self.architecture_path) if self.architecture_path else None,
|
|
215
|
+
"epics": str(self.epics_path) if self.epics_path else None,
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# -- Workflow Completeness ----------------------------------------------------
|
|
220
|
+
|
|
221
|
+
def assess_workflow(metadata: Dict[str, Any]) -> Dict[str, Any]:
|
|
222
|
+
"""Assess workflow completeness from frontmatter metadata.
|
|
223
|
+
|
|
224
|
+
Returns dict with:
|
|
225
|
+
- workflow_type: str
|
|
226
|
+
- steps_completed: list
|
|
227
|
+
- steps_expected: list
|
|
228
|
+
- completion_pct: float (0-100)
|
|
229
|
+
- is_complete: bool
|
|
230
|
+
"""
|
|
231
|
+
workflow_type = metadata.get("workflowType", "unknown")
|
|
232
|
+
steps_completed = metadata.get("stepsCompleted", [])
|
|
233
|
+
if isinstance(steps_completed, str):
|
|
234
|
+
steps_completed = [steps_completed]
|
|
235
|
+
|
|
236
|
+
steps_expected = WORKFLOW_STEPS.get(workflow_type, [])
|
|
237
|
+
if steps_expected:
|
|
238
|
+
pct = round(len(steps_completed) / len(steps_expected) * 100, 1)
|
|
239
|
+
else:
|
|
240
|
+
pct = 0.0 if not steps_completed else 100.0
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
"workflow_type": workflow_type,
|
|
244
|
+
"steps_completed": steps_completed,
|
|
245
|
+
"steps_expected": steps_expected,
|
|
246
|
+
"completion_pct": pct,
|
|
247
|
+
"is_complete": "complete" in steps_completed or "final-validation" in steps_completed,
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# -- Project Classification Extraction ----------------------------------------
|
|
252
|
+
|
|
253
|
+
def extract_classification(body: str) -> Dict[str, str]:
|
|
254
|
+
"""Extract Project Classification metadata from the PRD body.
|
|
255
|
+
|
|
256
|
+
Looks for a '## Project Classification' section with bullet items like:
|
|
257
|
+
- **Project Type:** web_app
|
|
258
|
+
"""
|
|
259
|
+
classification: Dict[str, str] = {}
|
|
260
|
+
|
|
261
|
+
# Find the classification section
|
|
262
|
+
match = re.search(
|
|
263
|
+
r"##\s+Project Classification\s*\n(.*?)(?=\n##\s|\Z)",
|
|
264
|
+
body,
|
|
265
|
+
re.DOTALL,
|
|
266
|
+
)
|
|
267
|
+
if not match:
|
|
268
|
+
return classification
|
|
269
|
+
|
|
270
|
+
section = match.group(1)
|
|
271
|
+
# Extract key-value pairs from bold-label bullets
|
|
272
|
+
# Handles both **Key:** value (colon inside bold) and **Key**: value (colon outside)
|
|
273
|
+
for m in re.finditer(r"\*\*(.+?):\*\*\s*(.+)", section):
|
|
274
|
+
key = m.group(1).strip().lower().replace(" ", "_")
|
|
275
|
+
value = m.group(2).strip()
|
|
276
|
+
classification[key] = value
|
|
277
|
+
if not classification:
|
|
278
|
+
# Fallback: colon outside bold markers
|
|
279
|
+
for m in re.finditer(r"\*\*(.+?)\*\*:\s*(.+)", section):
|
|
280
|
+
key = m.group(1).strip().lower().replace(" ", "_")
|
|
281
|
+
value = m.group(2).strip()
|
|
282
|
+
classification[key] = value
|
|
283
|
+
|
|
284
|
+
return classification
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# -- PRD Normalization --------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
def normalize_prd(prd_path: Path) -> Tuple[Dict[str, Any], str]:
|
|
290
|
+
"""Read a BMAD PRD, strip frontmatter, return (metadata, clean_body).
|
|
291
|
+
|
|
292
|
+
The clean body preserves all section headings as-is with no
|
|
293
|
+
destructive remapping. Suitable for feeding into prd-analyzer.py.
|
|
294
|
+
"""
|
|
295
|
+
text = _safe_read(prd_path)
|
|
296
|
+
metadata, body = parse_frontmatter(text)
|
|
297
|
+
return metadata, body
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# -- Epic/Story Extraction ----------------------------------------------------
|
|
301
|
+
|
|
302
|
+
def parse_epics(epics_path: Path) -> List[Dict[str, Any]]:
|
|
303
|
+
"""Parse epics.md into structured JSON.
|
|
304
|
+
|
|
305
|
+
Returns:
|
|
306
|
+
[
|
|
307
|
+
{
|
|
308
|
+
"epic": "Epic 1: Core Task Board",
|
|
309
|
+
"description": "...",
|
|
310
|
+
"stories": [
|
|
311
|
+
{
|
|
312
|
+
"id": "1.1",
|
|
313
|
+
"title": "Task CRUD",
|
|
314
|
+
"as_a": "team member",
|
|
315
|
+
"i_want": "create, edit, and delete tasks",
|
|
316
|
+
"so_that": "I can track my work items.",
|
|
317
|
+
"acceptance_criteria": ["Given...When...Then..."]
|
|
318
|
+
}
|
|
319
|
+
]
|
|
320
|
+
}
|
|
321
|
+
]
|
|
322
|
+
"""
|
|
323
|
+
text = _safe_read(epics_path)
|
|
324
|
+
_, body = parse_frontmatter(text)
|
|
325
|
+
|
|
326
|
+
epics: List[Dict[str, Any]] = []
|
|
327
|
+
current_epic: Optional[Dict[str, Any]] = None
|
|
328
|
+
current_story: Optional[Dict[str, Any]] = None
|
|
329
|
+
in_acceptance = False
|
|
330
|
+
acceptance_lines: List[str] = []
|
|
331
|
+
|
|
332
|
+
def _flush_acceptance() -> None:
|
|
333
|
+
"""Flush accumulated acceptance criteria lines into current story."""
|
|
334
|
+
nonlocal in_acceptance, acceptance_lines
|
|
335
|
+
if current_story is not None and acceptance_lines:
|
|
336
|
+
criteria_text = " ".join(acceptance_lines).strip()
|
|
337
|
+
if criteria_text:
|
|
338
|
+
current_story["acceptance_criteria"].append(criteria_text)
|
|
339
|
+
acceptance_lines = []
|
|
340
|
+
|
|
341
|
+
for line in body.split("\n"):
|
|
342
|
+
stripped = line.strip()
|
|
343
|
+
|
|
344
|
+
# Epic heading: ## Epic N: Title
|
|
345
|
+
epic_match = re.match(r"^##\s+(Epic\s+\d+.*)", stripped)
|
|
346
|
+
if epic_match:
|
|
347
|
+
# Flush any pending acceptance criteria
|
|
348
|
+
_flush_acceptance()
|
|
349
|
+
in_acceptance = False
|
|
350
|
+
current_story = None
|
|
351
|
+
|
|
352
|
+
epic_title = epic_match.group(1).strip()
|
|
353
|
+
current_epic = {
|
|
354
|
+
"epic": epic_title,
|
|
355
|
+
"description": "",
|
|
356
|
+
"stories": [],
|
|
357
|
+
}
|
|
358
|
+
epics.append(current_epic)
|
|
359
|
+
continue
|
|
360
|
+
|
|
361
|
+
# Story heading: ### Story N.M: Title
|
|
362
|
+
story_match = re.match(r"^###\s+Story\s+(\d+\.\d+):\s*(.*)", stripped)
|
|
363
|
+
if story_match and current_epic is not None:
|
|
364
|
+
_flush_acceptance()
|
|
365
|
+
in_acceptance = False
|
|
366
|
+
|
|
367
|
+
story_id = story_match.group(1)
|
|
368
|
+
story_title = story_match.group(2).strip()
|
|
369
|
+
current_story = {
|
|
370
|
+
"id": story_id,
|
|
371
|
+
"title": story_title,
|
|
372
|
+
"as_a": "",
|
|
373
|
+
"i_want": "",
|
|
374
|
+
"so_that": "",
|
|
375
|
+
"acceptance_criteria": [],
|
|
376
|
+
}
|
|
377
|
+
current_epic["stories"].append(current_story)
|
|
378
|
+
continue
|
|
379
|
+
|
|
380
|
+
# Inside a story: parse user story format
|
|
381
|
+
if current_story is not None:
|
|
382
|
+
# "As a ..."
|
|
383
|
+
as_match = re.match(r"^As an?\s+(.+),?\s*$", stripped)
|
|
384
|
+
if as_match:
|
|
385
|
+
_flush_acceptance()
|
|
386
|
+
in_acceptance = False
|
|
387
|
+
current_story["as_a"] = as_match.group(1).rstrip(",")
|
|
388
|
+
continue
|
|
389
|
+
|
|
390
|
+
# "I want ..."
|
|
391
|
+
want_match = re.match(r"^I want(?:\s+to)?\s+(.+),?\s*$", stripped)
|
|
392
|
+
if want_match:
|
|
393
|
+
current_story["i_want"] = want_match.group(1).rstrip(",")
|
|
394
|
+
continue
|
|
395
|
+
|
|
396
|
+
# "So that ..."
|
|
397
|
+
so_match = re.match(r"^So that\s+(.+)", stripped)
|
|
398
|
+
if so_match:
|
|
399
|
+
current_story["so_that"] = so_match.group(1).rstrip(".")
|
|
400
|
+
continue
|
|
401
|
+
|
|
402
|
+
# Acceptance Criteria header
|
|
403
|
+
if stripped.startswith("**Acceptance Criteria:**"):
|
|
404
|
+
_flush_acceptance()
|
|
405
|
+
in_acceptance = True
|
|
406
|
+
continue
|
|
407
|
+
|
|
408
|
+
# Acceptance criteria lines (Given/When/Then/And)
|
|
409
|
+
if in_acceptance:
|
|
410
|
+
ac_match = re.match(r"^\*\*(\w+)\*\*\s+(.*)", stripped)
|
|
411
|
+
if ac_match:
|
|
412
|
+
keyword = ac_match.group(1)
|
|
413
|
+
text_part = ac_match.group(2)
|
|
414
|
+
if keyword in ("Given", "When"):
|
|
415
|
+
# Flush previous criterion on a new Given/When
|
|
416
|
+
if keyword == "Given":
|
|
417
|
+
_flush_acceptance()
|
|
418
|
+
acceptance_lines.append(f"{keyword} {text_part}")
|
|
419
|
+
elif keyword in ("Then", "And"):
|
|
420
|
+
acceptance_lines.append(f"{keyword} {text_part}")
|
|
421
|
+
continue
|
|
422
|
+
# Non-AC line while in acceptance -> end
|
|
423
|
+
if stripped and not stripped.startswith("**"):
|
|
424
|
+
_flush_acceptance()
|
|
425
|
+
in_acceptance = False
|
|
426
|
+
|
|
427
|
+
# Epic description (text right after epic heading, before first story)
|
|
428
|
+
if current_epic is not None and current_story is None and stripped:
|
|
429
|
+
if not stripped.startswith("#") and not stripped.startswith("- "):
|
|
430
|
+
if current_epic["description"]:
|
|
431
|
+
current_epic["description"] += " " + stripped
|
|
432
|
+
else:
|
|
433
|
+
current_epic["description"] = stripped
|
|
434
|
+
|
|
435
|
+
# Flush any remaining acceptance criteria
|
|
436
|
+
_flush_acceptance()
|
|
437
|
+
|
|
438
|
+
return epics
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
# -- Architecture Summary -----------------------------------------------------
|
|
442
|
+
|
|
443
|
+
def summarize_architecture(arch_path: Path) -> str:
|
|
444
|
+
"""Produce a condensed architecture summary for prompt injection.
|
|
445
|
+
|
|
446
|
+
Extracts key decision sections and the project structure block.
|
|
447
|
+
"""
|
|
448
|
+
text = _safe_read(arch_path)
|
|
449
|
+
_, body = parse_frontmatter(text)
|
|
450
|
+
|
|
451
|
+
sections_to_keep = [
|
|
452
|
+
"Core Architectural Decisions",
|
|
453
|
+
"Implementation Patterns",
|
|
454
|
+
"Project Structure",
|
|
455
|
+
]
|
|
456
|
+
|
|
457
|
+
lines = body.split("\n")
|
|
458
|
+
output_lines: List[str] = []
|
|
459
|
+
capturing = False
|
|
460
|
+
current_level = 0
|
|
461
|
+
|
|
462
|
+
for line in lines:
|
|
463
|
+
heading_match = re.match(r"^(#{1,3})\s+(.*)", line)
|
|
464
|
+
if heading_match:
|
|
465
|
+
level = len(heading_match.group(1))
|
|
466
|
+
title = heading_match.group(2).strip()
|
|
467
|
+
|
|
468
|
+
# Check if this is a section we want
|
|
469
|
+
if any(s.lower() in title.lower() for s in sections_to_keep):
|
|
470
|
+
capturing = True
|
|
471
|
+
current_level = level
|
|
472
|
+
output_lines.append(line)
|
|
473
|
+
continue
|
|
474
|
+
# If same or higher level heading, stop capturing
|
|
475
|
+
if capturing and level <= current_level:
|
|
476
|
+
capturing = False
|
|
477
|
+
|
|
478
|
+
if capturing:
|
|
479
|
+
output_lines.append(line)
|
|
480
|
+
|
|
481
|
+
summary = "\n".join(output_lines).strip()
|
|
482
|
+
if not summary:
|
|
483
|
+
# Fallback: return full body (minus frontmatter)
|
|
484
|
+
summary = body.strip()
|
|
485
|
+
|
|
486
|
+
return summary
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
# -- Artifact Chain Validation ------------------------------------------------
|
|
490
|
+
|
|
491
|
+
def validate_chain(
|
|
492
|
+
artifacts: BmadArtifacts,
|
|
493
|
+
prd_body: str,
|
|
494
|
+
epics_data: Optional[List[Dict[str, Any]]],
|
|
495
|
+
prd_metadata: Dict[str, Any],
|
|
496
|
+
) -> List[Dict[str, str]]:
|
|
497
|
+
"""Validate the BMAD artifact chain for completeness and consistency.
|
|
498
|
+
|
|
499
|
+
Checks:
|
|
500
|
+
1. PRD references product-brief themes (inputDocuments)
|
|
501
|
+
2. FR coverage in epics (which FRs have stories)
|
|
502
|
+
3. Missing artifacts warnings
|
|
503
|
+
4. Uncovered FRs warnings
|
|
504
|
+
|
|
505
|
+
Returns a list of {level: "warning"|"info"|"error", message: str}.
|
|
506
|
+
"""
|
|
507
|
+
findings: List[Dict[str, str]] = []
|
|
508
|
+
|
|
509
|
+
# 1. Check if PRD references input documents
|
|
510
|
+
input_docs = prd_metadata.get("inputDocuments", [])
|
|
511
|
+
if not input_docs:
|
|
512
|
+
findings.append({
|
|
513
|
+
"level": "warning",
|
|
514
|
+
"message": "PRD frontmatter has no inputDocuments -- cannot verify product-brief linkage.",
|
|
515
|
+
})
|
|
516
|
+
else:
|
|
517
|
+
findings.append({
|
|
518
|
+
"level": "info",
|
|
519
|
+
"message": f"PRD references input documents: {', '.join(input_docs)}",
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
# 2. Missing artifacts
|
|
523
|
+
if artifacts.architecture_path is None:
|
|
524
|
+
findings.append({
|
|
525
|
+
"level": "warning",
|
|
526
|
+
"message": "Architecture document not found. Technical decisions are not documented.",
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
if artifacts.epics_path is None:
|
|
530
|
+
findings.append({
|
|
531
|
+
"level": "warning",
|
|
532
|
+
"message": "Epics document not found. No story breakdown available.",
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
# 3. Extract FRs from PRD body
|
|
536
|
+
fr_pattern = re.compile(r"\b(FR\d+)\b")
|
|
537
|
+
prd_frs = set(fr_pattern.findall(prd_body))
|
|
538
|
+
|
|
539
|
+
if not prd_frs:
|
|
540
|
+
findings.append({
|
|
541
|
+
"level": "warning",
|
|
542
|
+
"message": "No functional requirements (FRnn) found in PRD body.",
|
|
543
|
+
})
|
|
544
|
+
else:
|
|
545
|
+
findings.append({
|
|
546
|
+
"level": "info",
|
|
547
|
+
"message": f"PRD defines {len(prd_frs)} functional requirements: {', '.join(sorted(prd_frs))}",
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
# 4. Check FR coverage in epics
|
|
551
|
+
if epics_data is not None and prd_frs:
|
|
552
|
+
# Parse the epics.md body directly for FR references
|
|
553
|
+
epics_text = ""
|
|
554
|
+
if artifacts.epics_path:
|
|
555
|
+
epics_text = _safe_read(artifacts.epics_path)
|
|
556
|
+
|
|
557
|
+
covered_frs = set(fr_pattern.findall(epics_text))
|
|
558
|
+
uncovered = prd_frs - covered_frs
|
|
559
|
+
if uncovered:
|
|
560
|
+
findings.append({
|
|
561
|
+
"level": "warning",
|
|
562
|
+
"message": f"Uncovered functional requirements (no epic/story): {', '.join(sorted(uncovered))}",
|
|
563
|
+
})
|
|
564
|
+
else:
|
|
565
|
+
findings.append({
|
|
566
|
+
"level": "info",
|
|
567
|
+
"message": "All functional requirements are covered by epics.",
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
# 5. Workflow completeness checks
|
|
571
|
+
if artifacts.prd_path:
|
|
572
|
+
workflow = assess_workflow(prd_metadata)
|
|
573
|
+
if not workflow["is_complete"]:
|
|
574
|
+
findings.append({
|
|
575
|
+
"level": "warning",
|
|
576
|
+
"message": (
|
|
577
|
+
f"PRD workflow is incomplete ({workflow['completion_pct']}%). "
|
|
578
|
+
f"Completed: {', '.join(workflow['steps_completed'])}. "
|
|
579
|
+
f"Expected: {', '.join(workflow['steps_expected'])}."
|
|
580
|
+
),
|
|
581
|
+
})
|
|
582
|
+
|
|
583
|
+
return findings
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
# -- Output File Generation ---------------------------------------------------
|
|
587
|
+
|
|
588
|
+
def write_outputs(
|
|
589
|
+
output_dir: Path,
|
|
590
|
+
metadata: Dict[str, Any],
|
|
591
|
+
normalized_prd: str,
|
|
592
|
+
arch_summary: Optional[str],
|
|
593
|
+
tasks_json: Optional[List[Dict[str, Any]]],
|
|
594
|
+
validation_report: Optional[List[Dict[str, str]]],
|
|
595
|
+
) -> List[str]:
|
|
596
|
+
"""Write all output files to the specified directory.
|
|
597
|
+
|
|
598
|
+
Returns list of written file paths.
|
|
599
|
+
"""
|
|
600
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
601
|
+
written: List[str] = []
|
|
602
|
+
|
|
603
|
+
# bmad-metadata.json
|
|
604
|
+
meta_path = output_dir / "bmad-metadata.json"
|
|
605
|
+
_write_atomic(meta_path, json.dumps(metadata, indent=2))
|
|
606
|
+
written.append(str(meta_path))
|
|
607
|
+
|
|
608
|
+
# bmad-prd-normalized.md
|
|
609
|
+
prd_path = output_dir / "bmad-prd-normalized.md"
|
|
610
|
+
_write_atomic(prd_path, normalized_prd)
|
|
611
|
+
written.append(str(prd_path))
|
|
612
|
+
|
|
613
|
+
# bmad-architecture-summary.md
|
|
614
|
+
if arch_summary is not None:
|
|
615
|
+
arch_path = output_dir / "bmad-architecture-summary.md"
|
|
616
|
+
_write_atomic(arch_path, arch_summary)
|
|
617
|
+
written.append(str(arch_path))
|
|
618
|
+
|
|
619
|
+
# bmad-tasks.json
|
|
620
|
+
if tasks_json is not None:
|
|
621
|
+
tasks_path = output_dir / "bmad-tasks.json"
|
|
622
|
+
_write_atomic(tasks_path, json.dumps(tasks_json, indent=2))
|
|
623
|
+
written.append(str(tasks_path))
|
|
624
|
+
|
|
625
|
+
# bmad-validation.md
|
|
626
|
+
if validation_report is not None:
|
|
627
|
+
val_path = output_dir / "bmad-validation.md"
|
|
628
|
+
val_lines = ["# BMAD Artifact Chain Validation Report\n"]
|
|
629
|
+
for item in validation_report:
|
|
630
|
+
level = item["level"].upper()
|
|
631
|
+
val_lines.append(f"- [{level}] {item['message']}")
|
|
632
|
+
_write_atomic(val_path, "\n".join(val_lines) + "\n")
|
|
633
|
+
written.append(str(val_path))
|
|
634
|
+
|
|
635
|
+
return written
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
# -- Main Orchestration -------------------------------------------------------
|
|
639
|
+
|
|
640
|
+
def run(
|
|
641
|
+
project_path: str,
|
|
642
|
+
output_dir: str = ".loki",
|
|
643
|
+
as_json: bool = False,
|
|
644
|
+
validate: bool = False,
|
|
645
|
+
) -> int:
|
|
646
|
+
"""Main entry point. Returns exit code (0 = success, 1 = errors)."""
|
|
647
|
+
|
|
648
|
+
# 1. Discover artifacts
|
|
649
|
+
artifacts = BmadArtifacts(project_path)
|
|
650
|
+
|
|
651
|
+
if not artifacts.is_valid:
|
|
652
|
+
for err in artifacts.errors:
|
|
653
|
+
print(f"ERROR: {err}", file=sys.stderr)
|
|
654
|
+
print(
|
|
655
|
+
"This does not appear to be a BMAD project. "
|
|
656
|
+
f"Expected {BMAD_OUTPUT_DIR}/ with a prd-*.md or prd.md file.",
|
|
657
|
+
file=sys.stderr,
|
|
658
|
+
)
|
|
659
|
+
return 1
|
|
660
|
+
|
|
661
|
+
# 2. Parse PRD
|
|
662
|
+
prd_metadata, prd_body = normalize_prd(artifacts.prd_path) # type: ignore[arg-type]
|
|
663
|
+
classification = extract_classification(prd_body)
|
|
664
|
+
workflow = assess_workflow(prd_metadata)
|
|
665
|
+
|
|
666
|
+
# 3. Parse architecture (optional)
|
|
667
|
+
arch_summary: Optional[str] = None
|
|
668
|
+
if artifacts.architecture_path:
|
|
669
|
+
arch_summary = summarize_architecture(artifacts.architecture_path)
|
|
670
|
+
|
|
671
|
+
# 4. Parse epics (optional)
|
|
672
|
+
epics_data: Optional[List[Dict[str, Any]]] = None
|
|
673
|
+
if artifacts.epics_path:
|
|
674
|
+
epics_data = parse_epics(artifacts.epics_path)
|
|
675
|
+
|
|
676
|
+
# 5. Build combined metadata
|
|
677
|
+
combined_metadata: Dict[str, Any] = {
|
|
678
|
+
"project_classification": classification,
|
|
679
|
+
"workflow": workflow,
|
|
680
|
+
"artifacts": artifacts.inventory(),
|
|
681
|
+
"frontmatter": prd_metadata,
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
# 6. Validation (optional)
|
|
685
|
+
validation_report: Optional[List[Dict[str, str]]] = None
|
|
686
|
+
if validate:
|
|
687
|
+
validation_report = validate_chain(
|
|
688
|
+
artifacts, prd_body, epics_data, prd_metadata,
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
# 7. JSON output to stdout
|
|
692
|
+
if as_json:
|
|
693
|
+
output = {
|
|
694
|
+
"metadata": combined_metadata,
|
|
695
|
+
}
|
|
696
|
+
if validation_report is not None:
|
|
697
|
+
output["validation"] = validation_report
|
|
698
|
+
if epics_data is not None:
|
|
699
|
+
output["epics"] = epics_data
|
|
700
|
+
print(json.dumps(output, indent=2))
|
|
701
|
+
return 0
|
|
702
|
+
|
|
703
|
+
# 8. Write output files
|
|
704
|
+
output_path = Path(output_dir)
|
|
705
|
+
if output_path.is_absolute():
|
|
706
|
+
abs_output_dir = output_path
|
|
707
|
+
else:
|
|
708
|
+
abs_output_dir = (Path(project_path).resolve() / output_dir).resolve()
|
|
709
|
+
written = write_outputs(
|
|
710
|
+
output_dir=abs_output_dir,
|
|
711
|
+
metadata=combined_metadata,
|
|
712
|
+
normalized_prd=prd_body,
|
|
713
|
+
arch_summary=arch_summary,
|
|
714
|
+
tasks_json=epics_data,
|
|
715
|
+
validation_report=validation_report,
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
print(f"BMAD adapter: processed {artifacts.prd_path}")
|
|
719
|
+
print(f" Workflow: {workflow['workflow_type']} ({workflow['completion_pct']}% complete)")
|
|
720
|
+
if classification:
|
|
721
|
+
print(f" Classification: {classification.get('project_type', 'unknown')} / {classification.get('complexity', 'unknown')}")
|
|
722
|
+
print(f" Artifacts: PRD={'found' if artifacts.prd_path else 'MISSING'}, "
|
|
723
|
+
f"Architecture={'found' if artifacts.architecture_path else 'missing'}, "
|
|
724
|
+
f"Epics={'found' if artifacts.epics_path else 'missing'}")
|
|
725
|
+
print(f" Output files written to {abs_output_dir}/:")
|
|
726
|
+
for path in written:
|
|
727
|
+
print(f" - {Path(path).name}")
|
|
728
|
+
|
|
729
|
+
return 0
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def main() -> None:
|
|
733
|
+
parser = argparse.ArgumentParser(
|
|
734
|
+
description="BMAD Artifact Adapter for Loki Mode",
|
|
735
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
736
|
+
epilog=(
|
|
737
|
+
"Examples:\n"
|
|
738
|
+
" python3 bmad-adapter.py ./my-project\n"
|
|
739
|
+
" python3 bmad-adapter.py ./my-project --json\n"
|
|
740
|
+
" python3 bmad-adapter.py ./my-project --validate\n"
|
|
741
|
+
" python3 bmad-adapter.py ./my-project --output-dir .loki/ --validate\n"
|
|
742
|
+
),
|
|
743
|
+
)
|
|
744
|
+
parser.add_argument(
|
|
745
|
+
"project_path",
|
|
746
|
+
help="Path to the project directory containing BMAD artifacts",
|
|
747
|
+
)
|
|
748
|
+
parser.add_argument(
|
|
749
|
+
"--output-dir",
|
|
750
|
+
default=".loki",
|
|
751
|
+
help="Where to write output files (default: .loki/)",
|
|
752
|
+
)
|
|
753
|
+
parser.add_argument(
|
|
754
|
+
"--json",
|
|
755
|
+
action="store_true",
|
|
756
|
+
dest="as_json",
|
|
757
|
+
help="Output metadata as JSON to stdout (no files written)",
|
|
758
|
+
)
|
|
759
|
+
parser.add_argument(
|
|
760
|
+
"--validate",
|
|
761
|
+
action="store_true",
|
|
762
|
+
help="Run artifact chain validation",
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
args = parser.parse_args()
|
|
766
|
+
exit_code = run(
|
|
767
|
+
project_path=args.project_path,
|
|
768
|
+
output_dir=args.output_dir,
|
|
769
|
+
as_json=args.as_json,
|
|
770
|
+
validate=args.validate,
|
|
771
|
+
)
|
|
772
|
+
sys.exit(exit_code)
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
if __name__ == "__main__":
|
|
776
|
+
main()
|