loki-mode 6.27.2 → 6.29.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 CHANGED
@@ -10,7 +10,7 @@
10
10
  [![Autonomi](https://img.shields.io/badge/Autonomi-autonomi.dev-5B4EEA)](https://www.autonomi.dev/)
11
11
  [![Docker Pulls](https://img.shields.io/docker/pulls/asklokesh/loki-mode)](https://hub.docker.com/r/asklokesh/loki-mode)
12
12
 
13
- **Current Version: v6.27.0**
13
+ **Current Version: v6.28.0**
14
14
 
15
15
  ### Traction
16
16
 
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Multi-agent autonomous startup system. Triggers on "Loki Mode". Takes PRD to deployed product with minimal human intervention. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v6.27.0
6
+ # Loki Mode v6.29.0
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -267,4 +267,4 @@ The following features are documented in skill modules but not yet fully automat
267
267
  | Quality gates 3-reviewer system | Implemented (v5.35.0) | 5 specialist reviewers in `skills/quality-gates.md`; execution in run.sh |
268
268
  | Benchmarks (HumanEval, SWE-bench) | Infrastructure only | Runner scripts and datasets exist in `benchmarks/`; no published results |
269
269
 
270
- **v6.27.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
270
+ **v6.29.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 6.27.2
1
+ 6.29.0
@@ -306,6 +306,42 @@ def normalize_prd(prd_path: Path) -> Tuple[Dict[str, Any], str]:
306
306
 
307
307
  # -- Epic/Story Extraction ----------------------------------------------------
308
308
 
309
+ def _extract_phase_label(text: str) -> Tuple[str, int]:
310
+ """Extract MVP/phase label from an epic title or list entry.
311
+
312
+ Returns (priority_label, sort_weight):
313
+ - "(MVP)" -> ("mvp", 1)
314
+ - "(Phase 2)" or "post-MVP" -> ("phase2", 2)
315
+ - "(Phase 3)" or "deferred" -> ("phase3", 3)
316
+ - No label -> ("medium", 2)
317
+ """
318
+ lower = text.lower()
319
+ if "(mvp)" in lower or "mvp" in lower.split():
320
+ return "mvp", 1
321
+ if re.search(r"\(phase\s*2\)", lower) or "post-mvp" in lower:
322
+ return "phase2", 2
323
+ if re.search(r"\(phase\s*3\)", lower) or "deferred" in lower:
324
+ return "phase3", 3
325
+ return "medium", 2
326
+
327
+
328
+ def _build_epic_phase_map(body: str) -> Dict[int, Tuple[str, int]]:
329
+ """Scan the Epic List section for phase labels by epic number.
330
+
331
+ Looks for lines like: "- Epic 1: Core Task Board (MVP)"
332
+ Returns {epic_number: (priority_label, sort_weight)}.
333
+ """
334
+ phase_map: Dict[int, Tuple[str, int]] = {}
335
+ for m in re.finditer(r"Epic\s+(\d+)\s*:\s*(.+)", body):
336
+ epic_num = int(m.group(1))
337
+ full_text = m.group(2).strip()
338
+ label, weight = _extract_phase_label(full_text)
339
+ # Only store if we actually found a label (not default)
340
+ if label != "medium" or epic_num not in phase_map:
341
+ phase_map[epic_num] = (label, weight)
342
+ return phase_map
343
+
344
+
309
345
  def parse_epics(epics_path: Path) -> List[Dict[str, Any]]:
310
346
  """Parse epics.md into structured JSON.
311
347
 
@@ -313,11 +349,15 @@ def parse_epics(epics_path: Path) -> List[Dict[str, Any]]:
313
349
  [
314
350
  {
315
351
  "epic": "Epic 1: Core Task Board",
352
+ "priority": "mvp",
353
+ "priority_weight": 1,
316
354
  "description": "...",
317
355
  "stories": [
318
356
  {
319
357
  "id": "1.1",
320
358
  "title": "Task CRUD",
359
+ "priority": "mvp",
360
+ "priority_weight": 1,
321
361
  "as_a": "team member",
322
362
  "i_want": "create, edit, and delete tasks",
323
363
  "so_that": "I can track my work items.",
@@ -330,6 +370,9 @@ def parse_epics(epics_path: Path) -> List[Dict[str, Any]]:
330
370
  text = _safe_read(epics_path)
331
371
  _, body = parse_frontmatter(text)
332
372
 
373
+ # Pre-scan for phase labels in Epic List section and headings
374
+ epic_phase_map = _build_epic_phase_map(body)
375
+
333
376
  epics: List[Dict[str, Any]] = []
334
377
  current_epic: Optional[Dict[str, Any]] = None
335
378
  current_story: Optional[Dict[str, Any]] = None
@@ -349,7 +392,7 @@ def parse_epics(epics_path: Path) -> List[Dict[str, Any]]:
349
392
  stripped = line.strip()
350
393
 
351
394
  # Epic heading: ## Epic N: Title
352
- epic_match = re.match(r"^##\s+(Epic\s+\d+.*)", stripped)
395
+ epic_match = re.match(r"^##\s+(Epic\s+(\d+).*)", stripped)
353
396
  if epic_match:
354
397
  # Flush any pending acceptance criteria
355
398
  _flush_acceptance()
@@ -357,8 +400,18 @@ def parse_epics(epics_path: Path) -> List[Dict[str, Any]]:
357
400
  current_story = None
358
401
 
359
402
  epic_title = epic_match.group(1).strip()
403
+ epic_num = int(epic_match.group(2))
404
+
405
+ # Get phase from pre-scanned map, fallback to heading text
406
+ if epic_num in epic_phase_map:
407
+ priority, weight = epic_phase_map[epic_num]
408
+ else:
409
+ priority, weight = _extract_phase_label(epic_title)
410
+
360
411
  current_epic = {
361
412
  "epic": epic_title,
413
+ "priority": priority,
414
+ "priority_weight": weight,
362
415
  "description": "",
363
416
  "stories": [],
364
417
  }
@@ -373,9 +426,14 @@ def parse_epics(epics_path: Path) -> List[Dict[str, Any]]:
373
426
 
374
427
  story_id = story_match.group(1)
375
428
  story_title = story_match.group(2).strip()
429
+ # Inherit priority from parent epic
430
+ epic_priority = current_epic.get("priority", "medium")
431
+ epic_weight = current_epic.get("priority_weight", 2)
376
432
  current_story = {
377
433
  "id": story_id,
378
434
  "title": story_title,
435
+ "priority": epic_priority,
436
+ "priority_weight": epic_weight,
379
437
  "as_a": "",
380
438
  "i_want": "",
381
439
  "so_that": "",
@@ -512,6 +570,153 @@ def parse_sprint_status(path: Path) -> set:
512
570
  return completed
513
571
 
514
572
 
573
+ # -- Sprint Status Write-Back -------------------------------------------------
574
+
575
+ def write_sprint_status(path: Path, completed_stories: set) -> bool:
576
+ """Update sprint-status.yml to mark completed stories.
577
+
578
+ Reads the existing file, finds story entries by name, and updates
579
+ their status to 'completed'. Writes back atomically.
580
+
581
+ Returns True if any updates were made.
582
+ """
583
+ if not path.exists():
584
+ return False
585
+
586
+ text = _safe_read(path)
587
+ lines = text.split("\n")
588
+ updated = False
589
+ current_name: Optional[str] = None
590
+
591
+ # Normalize completed story names for matching
592
+ completed_lower = {s.lower() for s in completed_stories}
593
+
594
+ new_lines: List[str] = []
595
+ i = 0
596
+ while i < len(lines):
597
+ line = lines[i]
598
+ stripped = line.strip()
599
+
600
+ # Track story name
601
+ name_match = re.match(r'^(\s*)-?\s*name:\s*["\']?(.*?)["\']?\s*$', stripped)
602
+ if name_match:
603
+ current_name = name_match.group(2).strip()
604
+ new_lines.append(line)
605
+ i += 1
606
+ continue
607
+
608
+ # Update status line for matching stories
609
+ status_match = re.match(r'^(\s*)status:\s*["\']?(.*?)["\']?\s*$', stripped)
610
+ if status_match and current_name and current_name.lower() in completed_lower:
611
+ indent = status_match.group(1)
612
+ old_status = status_match.group(2).strip().lower()
613
+ if old_status not in ("completed", "done"):
614
+ # Preserve indentation, update status
615
+ new_lines.append(f"{indent}status: completed")
616
+ updated = True
617
+ current_name = None
618
+ i += 1
619
+ continue
620
+
621
+ new_lines.append(line)
622
+ i += 1
623
+
624
+ if updated:
625
+ _write_atomic(path, "\n".join(new_lines))
626
+
627
+ return updated
628
+
629
+
630
+ def update_epics_checkboxes(epics_path: Path, completed_stories: set) -> bool:
631
+ """Update epics.md to check off completed stories.
632
+
633
+ For each completed story, adds or updates a checkbox marker
634
+ under the story heading: `- [x] Completed` or `- [ ] Pending`.
635
+
636
+ Returns True if any updates were made.
637
+ """
638
+ if not epics_path.exists():
639
+ return False
640
+
641
+ text = _safe_read(epics_path)
642
+ lines = text.split("\n")
643
+
644
+ # Normalize completed story IDs and titles for matching
645
+ completed_ids = set()
646
+ completed_titles = set()
647
+ for s in completed_stories:
648
+ completed_ids.add(s.lower())
649
+ completed_titles.add(s.lower())
650
+
651
+ new_lines: List[str] = []
652
+ updated = False
653
+ i = 0
654
+ while i < len(lines):
655
+ line = lines[i]
656
+ stripped = line.strip()
657
+
658
+ # Story heading: ### Story N.M: Title
659
+ story_match = re.match(r"^###\s+Story\s+(\d+\.\d+):\s*(.*)", stripped)
660
+ if story_match:
661
+ story_id = story_match.group(1)
662
+ story_title = story_match.group(2).strip()
663
+ is_completed = (
664
+ story_id in completed_ids
665
+ or story_title.lower() in completed_titles
666
+ or f"Story {story_id}: {story_title}".lower() in completed_titles
667
+ )
668
+
669
+ new_lines.append(line)
670
+ i += 1
671
+
672
+ # Check if next non-empty line is already a checkbox
673
+ # Skip blank lines between heading and checkbox
674
+ blank_count = 0
675
+ while i < len(lines) and not lines[i].strip():
676
+ new_lines.append(lines[i])
677
+ blank_count += 1
678
+ i += 1
679
+
680
+ if i < len(lines):
681
+ next_stripped = lines[i].strip()
682
+ checkbox_match = re.match(r"^-\s+\[([ xX])\]\s+(Completed|Pending)", next_stripped)
683
+ if checkbox_match:
684
+ # Update existing checkbox
685
+ if is_completed and checkbox_match.group(1) == " ":
686
+ new_lines.append("- [x] Completed")
687
+ updated = True
688
+ i += 1
689
+ continue
690
+ elif not is_completed and checkbox_match.group(1) in ("x", "X"):
691
+ new_lines.append("- [ ] Pending")
692
+ updated = True
693
+ i += 1
694
+ continue
695
+ else:
696
+ # Already correct state
697
+ new_lines.append(lines[i])
698
+ i += 1
699
+ continue
700
+ else:
701
+ # No existing checkbox -- insert one if story is completed
702
+ if is_completed:
703
+ new_lines.append("")
704
+ new_lines.append("- [x] Completed")
705
+ new_lines.append("")
706
+ updated = True
707
+ # Continue processing current line (don't skip it)
708
+ continue
709
+ continue
710
+
711
+ new_lines.append(line)
712
+ i += 1
713
+
714
+ if updated:
715
+ _write_atomic(epics_path, "\n".join(new_lines))
716
+
717
+ return updated
718
+
719
+
515
720
  # -- Architecture Summary -----------------------------------------------------
516
721
 
517
722
  def summarize_architecture(arch_path: Path) -> str:
@@ -820,6 +1025,75 @@ def run(
820
1025
  return 0
821
1026
 
822
1027
 
1028
+ def write_back(
1029
+ project_path: str,
1030
+ completed_story: Optional[str] = None,
1031
+ completed_stories_file: Optional[str] = None,
1032
+ ) -> int:
1033
+ """Write-back mode: update sprint-status.yml and epics.md checkboxes.
1034
+
1035
+ Reads completed stories from either --completed-story arg or
1036
+ .loki/bmad-completed-stories.json, then updates the BMAD source files.
1037
+
1038
+ Returns exit code (0 = success, 1 = errors).
1039
+ """
1040
+ artifacts = BmadArtifacts(project_path)
1041
+ if not artifacts.is_valid:
1042
+ print("ERROR: Not a valid BMAD project", file=sys.stderr)
1043
+ return 1
1044
+
1045
+ # Collect completed stories
1046
+ completed: set = set()
1047
+
1048
+ if completed_story:
1049
+ completed.add(completed_story)
1050
+
1051
+ if completed_stories_file:
1052
+ cpath = Path(completed_stories_file)
1053
+ if cpath.exists():
1054
+ try:
1055
+ data = json.loads(cpath.read_text(encoding="utf-8"))
1056
+ if isinstance(data, list):
1057
+ completed.update(s for s in data if isinstance(s, str))
1058
+ except (json.JSONDecodeError, OSError) as e:
1059
+ print(f"Warning: Could not read completed stories file: {e}", file=sys.stderr)
1060
+
1061
+ if not completed:
1062
+ print("No completed stories to write back", file=sys.stderr)
1063
+ return 0
1064
+
1065
+ updates = 0
1066
+
1067
+ # Update sprint-status.yml
1068
+ if artifacts.sprint_status_path:
1069
+ try:
1070
+ if write_sprint_status(artifacts.sprint_status_path, completed):
1071
+ print(f"Updated sprint-status.yml with {len(completed)} completed stories")
1072
+ updates += 1
1073
+ else:
1074
+ print("sprint-status.yml: no changes needed")
1075
+ except Exception as e:
1076
+ print(f"Warning: Failed to update sprint-status.yml: {e}", file=sys.stderr)
1077
+
1078
+ # Update epics.md checkboxes
1079
+ if artifacts.epics_path:
1080
+ try:
1081
+ if update_epics_checkboxes(artifacts.epics_path, completed):
1082
+ print(f"Updated epics.md checkboxes for {len(completed)} completed stories")
1083
+ updates += 1
1084
+ else:
1085
+ print("epics.md: no changes needed")
1086
+ except Exception as e:
1087
+ print(f"Warning: Failed to update epics.md: {e}", file=sys.stderr)
1088
+
1089
+ if updates > 0:
1090
+ print(f"Write-back complete: {updates} file(s) updated")
1091
+ else:
1092
+ print("Write-back complete: no files updated")
1093
+
1094
+ return 0
1095
+
1096
+
823
1097
  def main() -> None:
824
1098
  parser = argparse.ArgumentParser(
825
1099
  description="BMAD Artifact Adapter for Loki Mode",
@@ -830,6 +1104,7 @@ def main() -> None:
830
1104
  " python3 bmad-adapter.py ./my-project --json\n"
831
1105
  " python3 bmad-adapter.py ./my-project --validate\n"
832
1106
  " python3 bmad-adapter.py ./my-project --output-dir .loki/ --validate\n"
1107
+ " python3 bmad-adapter.py ./my-project --write-back --completed-story 'Task CRUD'\n"
833
1108
  ),
834
1109
  )
835
1110
  parser.add_argument(
@@ -852,14 +1127,38 @@ def main() -> None:
852
1127
  action="store_true",
853
1128
  help="Run artifact chain validation",
854
1129
  )
1130
+ parser.add_argument(
1131
+ "--write-back",
1132
+ action="store_true",
1133
+ dest="write_back_mode",
1134
+ help="Update sprint-status.yml and epics.md with completed stories",
1135
+ )
1136
+ parser.add_argument(
1137
+ "--completed-story",
1138
+ default=None,
1139
+ help="Name/title of a single completed story (for --write-back)",
1140
+ )
1141
+ parser.add_argument(
1142
+ "--completed-stories-file",
1143
+ default=None,
1144
+ help="Path to JSON file with list of completed story names (for --write-back)",
1145
+ )
855
1146
 
856
1147
  args = parser.parse_args()
857
- exit_code = run(
858
- project_path=args.project_path,
859
- output_dir=args.output_dir,
860
- as_json=args.as_json,
861
- validate=args.validate,
862
- )
1148
+
1149
+ if args.write_back_mode:
1150
+ exit_code = write_back(
1151
+ project_path=args.project_path,
1152
+ completed_story=args.completed_story,
1153
+ completed_stories_file=args.completed_stories_file,
1154
+ )
1155
+ else:
1156
+ exit_code = run(
1157
+ project_path=args.project_path,
1158
+ output_dir=args.output_dir,
1159
+ as_json=args.as_json,
1160
+ validate=args.validate,
1161
+ )
863
1162
  sys.exit(exit_code)
864
1163
 
865
1164