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.
@@ -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()