@pennyfarthing/core 11.2.2 → 11.3.2

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.
Files changed (168) hide show
  1. package/README.md +3 -3
  2. package/package.json +1 -1
  3. package/packages/core/dist/cli/commands/doctor-legacy.test.js +2 -2
  4. package/packages/core/dist/cli/commands/doctor-legacy.test.js.map +1 -1
  5. package/packages/core/dist/cli/commands/doctor.d.ts +63 -0
  6. package/packages/core/dist/cli/commands/doctor.d.ts.map +1 -1
  7. package/packages/core/dist/cli/commands/doctor.js +280 -43
  8. package/packages/core/dist/cli/commands/doctor.js.map +1 -1
  9. package/packages/core/dist/cli/commands/init.d.ts +12 -0
  10. package/packages/core/dist/cli/commands/init.d.ts.map +1 -1
  11. package/packages/core/dist/cli/commands/init.js +45 -0
  12. package/packages/core/dist/cli/commands/init.js.map +1 -1
  13. package/packages/core/dist/cli/commands/pyproject-install.test.d.ts +19 -0
  14. package/packages/core/dist/cli/commands/pyproject-install.test.d.ts.map +1 -0
  15. package/packages/core/dist/cli/commands/pyproject-install.test.js +261 -0
  16. package/packages/core/dist/cli/commands/pyproject-install.test.js.map +1 -0
  17. package/packages/core/dist/cli/commands/update-consolidation.test.js +14 -6
  18. package/packages/core/dist/cli/commands/update-consolidation.test.js.map +1 -1
  19. package/packages/core/dist/cli/commands/update.d.ts.map +1 -1
  20. package/packages/core/dist/cli/commands/update.js +5 -1
  21. package/packages/core/dist/cli/commands/update.js.map +1 -1
  22. package/packages/core/dist/cli/index.js +2 -0
  23. package/packages/core/dist/cli/index.js.map +1 -1
  24. package/packages/core/dist/cli/utils/python.d.ts +1 -0
  25. package/packages/core/dist/cli/utils/python.d.ts.map +1 -1
  26. package/packages/core/dist/cli/utils/python.js +22 -1
  27. package/packages/core/dist/cli/utils/python.js.map +1 -1
  28. package/packages/core/dist/cli/utils/settings-hook-migration.test.d.ts +17 -0
  29. package/packages/core/dist/cli/utils/settings-hook-migration.test.d.ts.map +1 -0
  30. package/packages/core/dist/cli/utils/settings-hook-migration.test.js +382 -0
  31. package/packages/core/dist/cli/utils/settings-hook-migration.test.js.map +1 -0
  32. package/packages/core/dist/cli/utils/settings-pf-wrapper.test.d.ts +16 -0
  33. package/packages/core/dist/cli/utils/settings-pf-wrapper.test.d.ts.map +1 -0
  34. package/packages/core/dist/cli/utils/settings-pf-wrapper.test.js +377 -0
  35. package/packages/core/dist/cli/utils/settings-pf-wrapper.test.js.map +1 -0
  36. package/packages/core/dist/cli/utils/settings.d.ts.map +1 -1
  37. package/packages/core/dist/cli/utils/settings.js +15 -2
  38. package/packages/core/dist/cli/utils/settings.js.map +1 -1
  39. package/packages/core/dist/server/paths.d.ts.map +1 -1
  40. package/packages/core/dist/server/paths.js +6 -0
  41. package/packages/core/dist/server/paths.js.map +1 -1
  42. package/packages/core/dist/server/settings.d.ts.map +1 -1
  43. package/packages/core/dist/server/settings.js +5 -0
  44. package/packages/core/dist/server/settings.js.map +1 -1
  45. package/packages/core/dist/workflow/tandem-workflow-templates.test.js +7 -5
  46. package/packages/core/dist/workflow/tandem-workflow-templates.test.js.map +1 -1
  47. package/packages/core/dist/workflow/workflow-graph-validation.d.ts +65 -0
  48. package/packages/core/dist/workflow/workflow-graph-validation.d.ts.map +1 -0
  49. package/packages/core/dist/workflow/workflow-graph-validation.js +190 -0
  50. package/packages/core/dist/workflow/workflow-graph-validation.js.map +1 -0
  51. package/packages/core/dist/workflow/workflow-graph-validation.test.d.ts +18 -0
  52. package/packages/core/dist/workflow/workflow-graph-validation.test.d.ts.map +1 -0
  53. package/packages/core/dist/workflow/workflow-graph-validation.test.js +706 -0
  54. package/packages/core/dist/workflow/workflow-graph-validation.test.js.map +1 -0
  55. package/packages/core/dist/workflow/workflow-migration.test.js +6 -5
  56. package/packages/core/dist/workflow/workflow-migration.test.js.map +1 -1
  57. package/pennyfarthing-dist/agents/dev.md +4 -2
  58. package/pennyfarthing-dist/agents/devops.md +2 -10
  59. package/pennyfarthing-dist/agents/reviewer-preflight.md +4 -5
  60. package/pennyfarthing-dist/agents/sm.md +4 -17
  61. package/pennyfarthing-dist/commands/pf-health-check.md +30 -11
  62. package/pennyfarthing-dist/gates/{confidence-sm.md → confidence.md} +16 -17
  63. package/pennyfarthing-dist/gates/dev-exit.md +75 -0
  64. package/pennyfarthing-dist/gates/merge-ready.md +49 -0
  65. package/pennyfarthing-dist/gates/release-ready.md +95 -0
  66. package/pennyfarthing-dist/gates/reviewer-preflight-check.md +90 -0
  67. package/pennyfarthing-dist/gates/sm-setup-exit.md +82 -0
  68. package/pennyfarthing-dist/guides/agent-behavior.md +88 -30
  69. package/pennyfarthing-dist/guides/gates.md +7 -2
  70. package/pennyfarthing-dist/scripts/lib/find-root.sh +5 -0
  71. package/pennyfarthing-dist/scripts/lib/run-pf.sh +7 -0
  72. package/pennyfarthing-dist/skills/pf-settings/skill.md +42 -0
  73. package/pennyfarthing-dist/skills/skill-registry.yaml +15 -0
  74. package/pennyfarthing-dist/templates/pyproject.toml +27 -0
  75. package/pennyfarthing-dist/workflows/bdd-tandem.yaml +7 -3
  76. package/pennyfarthing-dist/workflows/bdd.yaml +7 -3
  77. package/pennyfarthing-dist/workflows/installation-check/steps/step-01-foundation.md +77 -0
  78. package/pennyfarthing-dist/workflows/installation-check/steps/step-02-commands.md +82 -0
  79. package/pennyfarthing-dist/workflows/installation-check/steps/step-03-hooks.md +121 -0
  80. package/pennyfarthing-dist/workflows/installation-check/steps/step-04-scripts.md +83 -0
  81. package/pennyfarthing-dist/workflows/installation-check/steps/step-05-layout.md +81 -0
  82. package/pennyfarthing-dist/workflows/installation-check/steps/step-06-legacy.md +94 -0
  83. package/pennyfarthing-dist/workflows/installation-check/steps/step-07-tools.md +80 -0
  84. package/pennyfarthing-dist/workflows/installation-check/steps/step-08-summary.md +99 -0
  85. package/pennyfarthing-dist/workflows/installation-check/workflow.yaml +47 -0
  86. package/pennyfarthing-dist/workflows/tdd-tandem.yaml +7 -3
  87. package/pennyfarthing-dist/workflows/tdd.yaml +7 -3
  88. package/pennyfarthing-dist/workflows/trivial.yaml +7 -3
  89. package/pennyfarthing_scripts/__pycache__/__init__.cpython-314.pyc +0 -0
  90. package/pennyfarthing_scripts/__pycache__/cli.cpython-314.pyc +0 -0
  91. package/pennyfarthing_scripts/__pycache__/context.cpython-314.pyc +0 -0
  92. package/pennyfarthing_scripts/bc/__pycache__/cli.cpython-314.pyc +0 -0
  93. package/pennyfarthing_scripts/bc/__pycache__/focus.cpython-314.pyc +0 -0
  94. package/pennyfarthing_scripts/bc/__pycache__/split.cpython-314.pyc +0 -0
  95. package/pennyfarthing_scripts/bc/cli.py +21 -0
  96. package/pennyfarthing_scripts/bc/focus.py +1 -0
  97. package/pennyfarthing_scripts/bc/split.py +52 -0
  98. package/pennyfarthing_scripts/bikerack/__pycache__/audit_log_panel.cpython-314.pyc +0 -0
  99. package/pennyfarthing_scripts/bikerack/__pycache__/base_panel.cpython-314.pyc +0 -0
  100. package/pennyfarthing_scripts/bikerack/__pycache__/changed_panel.cpython-314.pyc +0 -0
  101. package/pennyfarthing_scripts/bikerack/__pycache__/context_meter_footer.cpython-314.pyc +0 -0
  102. package/pennyfarthing_scripts/bikerack/__pycache__/debug_panel.cpython-314.pyc +0 -0
  103. package/pennyfarthing_scripts/bikerack/__pycache__/diffs_panel.cpython-314.pyc +0 -0
  104. package/pennyfarthing_scripts/bikerack/__pycache__/events.cpython-314.pyc +0 -0
  105. package/pennyfarthing_scripts/bikerack/__pycache__/launcher.cpython-314.pyc +0 -0
  106. package/pennyfarthing_scripts/bikerack/__pycache__/portrait_resolver.cpython-314.pyc +0 -0
  107. package/pennyfarthing_scripts/bikerack/__pycache__/progress_panel.cpython-314.pyc +0 -0
  108. package/pennyfarthing_scripts/bikerack/__pycache__/sprint_panel.cpython-314.pyc +0 -0
  109. package/pennyfarthing_scripts/bikerack/__pycache__/tui.cpython-314.pyc +0 -0
  110. package/pennyfarthing_scripts/bikerack/__pycache__/ws_client.cpython-314.pyc +0 -0
  111. package/pennyfarthing_scripts/bikerack/context_meter_footer.py +53 -3
  112. package/pennyfarthing_scripts/bikerack/tui.py +202 -8
  113. package/pennyfarthing_scripts/bmad/__init__.py +1 -0
  114. package/pennyfarthing_scripts/bmad/__pycache__/__init__.cpython-314.pyc +0 -0
  115. package/pennyfarthing_scripts/bmad/__pycache__/cli.cpython-314.pyc +0 -0
  116. package/pennyfarthing_scripts/bmad/__pycache__/parser.cpython-314.pyc +0 -0
  117. package/pennyfarthing_scripts/bmad/__pycache__/sync.cpython-314.pyc +0 -0
  118. package/pennyfarthing_scripts/bmad/__pycache__/test_parser.cpython-314-pytest-9.0.2.pyc +0 -0
  119. package/pennyfarthing_scripts/bmad/__pycache__/test_sync.cpython-314-pytest-9.0.2.pyc +0 -0
  120. package/pennyfarthing_scripts/bmad/cli.py +197 -0
  121. package/pennyfarthing_scripts/bmad/importer.py +200 -0
  122. package/pennyfarthing_scripts/bmad/parser.py +233 -0
  123. package/pennyfarthing_scripts/bmad/sync.py +464 -0
  124. package/pennyfarthing_scripts/bmad/test_parser.py +253 -0
  125. package/pennyfarthing_scripts/bmad/test_sync.py +223 -0
  126. package/pennyfarthing_scripts/cli.py +10 -0
  127. package/pennyfarthing_scripts/common/__pycache__/config.cpython-314.pyc +0 -0
  128. package/pennyfarthing_scripts/consultation/__pycache__/cli.cpython-314.pyc +0 -0
  129. package/pennyfarthing_scripts/handoff/__pycache__/cli.cpython-314.pyc +0 -0
  130. package/pennyfarthing_scripts/handoff/__pycache__/complete_phase.cpython-314.pyc +0 -0
  131. package/pennyfarthing_scripts/handoff/__pycache__/gate_file.cpython-314.pyc +0 -0
  132. package/pennyfarthing_scripts/handoff/__pycache__/gate_runner.cpython-314.pyc +0 -0
  133. package/pennyfarthing_scripts/handoff/__pycache__/marker.cpython-314.pyc +0 -0
  134. package/pennyfarthing_scripts/handoff/__pycache__/resolve_gate.cpython-314.pyc +0 -0
  135. package/pennyfarthing_scripts/hooks/__pycache__/__init__.cpython-314.pyc +0 -0
  136. package/pennyfarthing_scripts/hooks/__pycache__/bell_mode.cpython-314.pyc +0 -0
  137. package/pennyfarthing_scripts/hooks/__pycache__/cli.cpython-314.pyc +0 -0
  138. package/pennyfarthing_scripts/hooks/__pycache__/context_breaker.cpython-314.pyc +0 -0
  139. package/pennyfarthing_scripts/hooks/__pycache__/context_warning.cpython-314.pyc +0 -0
  140. package/pennyfarthing_scripts/hooks/__pycache__/cyclist_pretooluse.cpython-314.pyc +0 -0
  141. package/pennyfarthing_scripts/hooks/__pycache__/pre_edit_check.cpython-314.pyc +0 -0
  142. package/pennyfarthing_scripts/hooks/__pycache__/reflector_check.cpython-314.pyc +0 -0
  143. package/pennyfarthing_scripts/hooks/__pycache__/schema_validation.cpython-314.pyc +0 -0
  144. package/pennyfarthing_scripts/hooks/__pycache__/session_start.cpython-314.pyc +0 -0
  145. package/pennyfarthing_scripts/hooks/__pycache__/session_stop.cpython-314.pyc +0 -0
  146. package/pennyfarthing_scripts/hooks/__pycache__/sprint_yaml_validation.cpython-314.pyc +0 -0
  147. package/pennyfarthing_scripts/hooks/__pycache__/statusline.cpython-314.pyc +0 -0
  148. package/pennyfarthing_scripts/prime/__pycache__/workflow.cpython-314.pyc +0 -0
  149. package/pennyfarthing_scripts/settings/__init__.py +0 -0
  150. package/pennyfarthing_scripts/settings/__pycache__/__init__.cpython-314.pyc +0 -0
  151. package/pennyfarthing_scripts/settings/__pycache__/cli.cpython-314.pyc +0 -0
  152. package/pennyfarthing_scripts/settings/__pycache__/settings.cpython-314.pyc +0 -0
  153. package/pennyfarthing_scripts/settings/cli.py +55 -0
  154. package/pennyfarthing_scripts/settings/settings.py +98 -0
  155. package/pennyfarthing_scripts/sprint/__pycache__/cli.cpython-314.pyc +0 -0
  156. package/pennyfarthing_scripts/sprint/__pycache__/loader.cpython-314.pyc +0 -0
  157. package/pennyfarthing_scripts/sprint/__pycache__/story_update.cpython-314.pyc +0 -0
  158. package/pennyfarthing_scripts/tests/__pycache__/test_confidence_sm_evaluation.cpython-314-pytest-9.0.2.pyc +0 -0
  159. package/pennyfarthing_scripts/tests/__pycache__/test_confidence_sm_gate.cpython-314-pytest-9.0.2.pyc +0 -0
  160. package/pennyfarthing_scripts/tests/__pycache__/test_gate_file_resolution.cpython-314-pytest-9.0.2.pyc +0 -0
  161. package/pennyfarthing_scripts/tests/__pycache__/test_gate_runner.cpython-314-pytest-9.0.2.pyc +0 -0
  162. package/pennyfarthing_scripts/tests/__pycache__/test_resolve_gate_file_field.cpython-314-pytest-9.0.2.pyc +0 -0
  163. package/pennyfarthing_scripts/tests/__pycache__/test_workflow_list_team.cpython-314.pyc +0 -0
  164. package/pennyfarthing_scripts/tests/test_confidence_sm_gate.py +17 -16
  165. package/pennyfarthing_scripts/tests/test_resolve_gate_file_field.py +45 -47
  166. package/pennyfarthing_scripts/tests/test_workflow_list_team.py +0 -4
  167. package/pennyfarthing_scripts/workflow/__pycache__/cli.cpython-314.pyc +0 -0
  168. package/pennyfarthing_scripts/workflow/__pycache__/state.cpython-314.pyc +0 -0
@@ -0,0 +1,197 @@
1
+ """
2
+ CLI commands for BMAD adapter.
3
+
4
+ Provides:
5
+ pf bmad import <path> — Initial import from BMAD project
6
+ pf bmad sync --pull [--dry-run] — Pull BMAD changes into PF YAML
7
+ pf bmad sync --push [--dry-run] — Push PF changes to BMAD markdown
8
+ pf bmad sync --both [--dry-run] — Bidirectional sync
9
+ pf bmad status — Drift report
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ import click
18
+
19
+
20
+ @click.group()
21
+ def bmad():
22
+ """BMAD adapter — bidirectional sprint sync."""
23
+ pass
24
+
25
+
26
+ # =============================================================================
27
+ # pf bmad import
28
+ # =============================================================================
29
+
30
+
31
+ @bmad.command("import")
32
+ @click.argument("bmad_path", type=click.Path(exists=True))
33
+ @click.option("--repos", default="axiathon", help="Default repos value for stories")
34
+ @click.option("--dry-run", is_flag=True, help="Preview without writing")
35
+ def bmad_import(bmad_path: str, repos: str, dry_run: bool):
36
+ """Import a BMAD project into PF sprint YAML.
37
+
38
+ BMAD_PATH is the path to the _bmad-output/ directory.
39
+ """
40
+ from pennyfarthing_scripts.bmad.importer import import_bmad_project
41
+ from pennyfarthing_scripts.common.config import get_project_root
42
+
43
+ root = get_project_root()
44
+ sprint_dir = root / "sprint"
45
+ source = Path(bmad_path).resolve()
46
+
47
+ result = import_bmad_project(
48
+ bmad_root=source,
49
+ sprint_dir=sprint_dir,
50
+ repos=repos,
51
+ dry_run=dry_run,
52
+ project_root=root,
53
+ )
54
+
55
+ if result.get("success"):
56
+ prefix = "[DRY-RUN] " if result.get("dry_run") else ""
57
+ click.echo(f"{prefix}{result['message']}")
58
+ click.echo(f" Epics: {result['epics_count']}")
59
+ click.echo(f" Stories: {result['stories_count']}")
60
+ click.echo(f" Points: {result['total_points']}")
61
+ if result.get("epic_ids"):
62
+ click.echo(f" Epic IDs: {', '.join(result['epic_ids'])}")
63
+ else:
64
+ click.echo(f"Failed: {result.get('error')}", err=True)
65
+ raise SystemExit(1)
66
+
67
+
68
+ # =============================================================================
69
+ # pf bmad sync
70
+ # =============================================================================
71
+
72
+
73
+ @bmad.command()
74
+ @click.option("--pull", "direction", flag_value="pull", help="Pull BMAD changes into PF")
75
+ @click.option("--push", "direction", flag_value="push", help="Push PF changes to BMAD")
76
+ @click.option("--both", "direction", flag_value="both", help="Bidirectional sync")
77
+ @click.option("--dry-run", is_flag=True, help="Preview without applying")
78
+ @click.option("--pf-wins", is_flag=True, default=True, help="PF status wins on conflict (default)")
79
+ @click.option("--bmad-wins", is_flag=True, help="BMAD status wins on conflict")
80
+ @click.option("--import-new", is_flag=True, help="Import new BMAD stories not in PF")
81
+ def sync(
82
+ direction: str | None,
83
+ dry_run: bool,
84
+ pf_wins: bool,
85
+ bmad_wins: bool,
86
+ import_new: bool,
87
+ ):
88
+ """Sync status between PF YAML and BMAD markdown."""
89
+ if not direction:
90
+ click.echo("Specify --pull, --push, or --both", err=True)
91
+ raise SystemExit(1)
92
+
93
+ from pennyfarthing_scripts.bmad.parser import discover_bmad_stories
94
+ from pennyfarthing_scripts.bmad.sync import (
95
+ _collect_pf_stories,
96
+ execute_sync_plan,
97
+ format_sync_plan,
98
+ generate_sync_plan,
99
+ )
100
+ from pennyfarthing_scripts.common.config import get_project_root, load_pennyfarthing_config
101
+
102
+ root = get_project_root()
103
+ config = load_pennyfarthing_config(root)
104
+ bmad_config = config.get("bmad", {})
105
+
106
+ source_root_str = bmad_config.get("source_root")
107
+ if not source_root_str:
108
+ click.echo(
109
+ "No bmad.source_root configured in .pennyfarthing/config.local.yaml",
110
+ err=True,
111
+ )
112
+ raise SystemExit(1)
113
+
114
+ bmad_root = (root / source_root_str).resolve()
115
+ if not bmad_root.is_dir():
116
+ click.echo(f"BMAD source root not found: {bmad_root}", err=True)
117
+ raise SystemExit(1)
118
+
119
+ sprint_path = root / "sprint" / "current-sprint.yaml"
120
+ if not sprint_path.exists():
121
+ click.echo(f"Sprint file not found: {sprint_path}", err=True)
122
+ click.echo("Run 'pf bmad import' first.", err=True)
123
+ raise SystemExit(1)
124
+
125
+ story_subdir = bmad_config.get("story_dir", "implementation-artifacts")
126
+ wins_pf = not bmad_wins
127
+
128
+ pf_stories = _collect_pf_stories(sprint_path)
129
+ bmad_stories = discover_bmad_stories(bmad_root, story_dir=story_subdir)
130
+
131
+ plan = generate_sync_plan(
132
+ pf_stories,
133
+ bmad_stories,
134
+ direction=direction,
135
+ pf_wins=wins_pf,
136
+ )
137
+
138
+ click.echo(format_sync_plan(plan))
139
+
140
+ if not plan.changes and not (import_new and plan.bmad_only):
141
+ return
142
+
143
+ result = execute_sync_plan(
144
+ plan,
145
+ dry_run=dry_run,
146
+ sprint_path=sprint_path,
147
+ bmad_root=bmad_root,
148
+ import_new=import_new,
149
+ )
150
+
151
+ if dry_run:
152
+ click.echo(f"\n[DRY-RUN] {result.changes_planned} changes would be applied")
153
+ if import_new and plan.bmad_only:
154
+ click.echo(f"[DRY-RUN] {len(plan.bmad_only)} new stories would be imported")
155
+ return
156
+
157
+ click.echo(f"\nApplied {result.changes_applied}/{result.changes_planned} changes")
158
+ if result.new_stories_imported:
159
+ click.echo(f"Imported {result.new_stories_imported} new stories")
160
+ if result.errors:
161
+ for err in result.errors:
162
+ click.echo(f" Error: {err}", err=True)
163
+ raise SystemExit(1)
164
+
165
+
166
+ # =============================================================================
167
+ # pf bmad status
168
+ # =============================================================================
169
+
170
+
171
+ @bmad.command()
172
+ def status():
173
+ """Show drift report — what's out of sync between PF and BMAD."""
174
+ from pennyfarthing_scripts.bmad.sync import drift_report
175
+ from pennyfarthing_scripts.common.config import get_project_root, load_pennyfarthing_config
176
+
177
+ root = get_project_root()
178
+ config = load_pennyfarthing_config(root)
179
+ bmad_config = config.get("bmad", {})
180
+
181
+ source_root_str = bmad_config.get("source_root")
182
+ if not source_root_str:
183
+ click.echo(
184
+ "No bmad.source_root configured in .pennyfarthing/config.local.yaml",
185
+ err=True,
186
+ )
187
+ raise SystemExit(1)
188
+
189
+ bmad_root = (root / source_root_str).resolve()
190
+ sprint_path = root / "sprint" / "current-sprint.yaml"
191
+
192
+ if not sprint_path.exists():
193
+ click.echo("No sprint file found. Run 'pf bmad import' first.", err=True)
194
+ raise SystemExit(1)
195
+
196
+ report = drift_report(sprint_path, bmad_root)
197
+ click.echo(report)
@@ -0,0 +1,200 @@
1
+ """
2
+ BMAD project importer — initial import from BMAD markdown to PF sprint YAML.
3
+
4
+ Creates current-sprint.yaml + epic shard files from BMAD's
5
+ implementation-artifacts/ and planning-artifacts/epics/ directories.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import date
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from pennyfarthing_scripts.bmad.parser import (
15
+ discover_bmad_epics,
16
+ discover_bmad_stories,
17
+ map_bmad_to_pf,
18
+ )
19
+ from pennyfarthing_scripts.common.config import get_project_root, load_pennyfarthing_config
20
+
21
+
22
+ def _get_bmad_config(project_root: Path) -> dict[str, Any]:
23
+ """Read bmad section from .pennyfarthing/config.local.yaml."""
24
+ config = load_pennyfarthing_config(project_root)
25
+ return config.get("bmad", {})
26
+
27
+
28
+ def _group_stories_by_epic(stories: list[dict]) -> dict[int, list[dict]]:
29
+ """Group story dicts by their epic number."""
30
+ groups: dict[int, list[dict]] = {}
31
+ for story in stories:
32
+ epic_num = int(story["epic_num"])
33
+ groups.setdefault(epic_num, []).append(story)
34
+ return groups
35
+
36
+
37
+ def _build_epic_shard(
38
+ epic_num: int,
39
+ epic_meta: dict[str, Any] | None,
40
+ stories: list[dict],
41
+ repos: str,
42
+ ) -> dict[str, Any]:
43
+ """Build a PF epic shard dict from BMAD data.
44
+
45
+ Args:
46
+ epic_num: Epic number
47
+ epic_meta: Parsed BMAD epic metadata (or None if no epic file)
48
+ stories: List of parsed BMAD story dicts for this epic
49
+ repos: Default repos value
50
+
51
+ Returns:
52
+ PF epic shard dict ready for validation and writing.
53
+ """
54
+ title = epic_meta["title"] if epic_meta else f"Epic {epic_num}"
55
+ phase = epic_meta.get("phase", "MVP") if epic_meta else "MVP"
56
+
57
+ pf_stories: list[dict[str, Any]] = []
58
+ for story in stories:
59
+ pf_story: dict[str, Any] = {
60
+ "id": story["id"],
61
+ "title": story["title"],
62
+ "points": story.get("points", 3),
63
+ "priority": story.get("priority", "P1"),
64
+ "status": story["status"],
65
+ "repos": repos,
66
+ "workflow": story.get("workflow", "tdd"),
67
+ "bmad_key": story["bmad_key"],
68
+ }
69
+ if story.get("jira"):
70
+ pf_story["jira"] = story["jira"]
71
+ pf_stories.append(pf_story)
72
+
73
+ total_points = sum(s.get("points", 3) for s in pf_stories)
74
+
75
+ return {
76
+ "id": str(epic_num),
77
+ "title": title,
78
+ "status": "planning",
79
+ "description": f"Phase: {phase}",
80
+ "priority": "P1",
81
+ "points": total_points,
82
+ "marker": "bmad",
83
+ "repos": repos,
84
+ "stories": pf_stories,
85
+ }
86
+
87
+
88
+ def import_bmad_project(
89
+ bmad_root: Path,
90
+ sprint_dir: Path,
91
+ *,
92
+ repos: str = "axiathon",
93
+ dry_run: bool = False,
94
+ project_root: Path | None = None,
95
+ ) -> dict[str, Any]:
96
+ """Import a BMAD project into PF sprint YAML.
97
+
98
+ Args:
99
+ bmad_root: Path to BMAD _bmad-output/ directory
100
+ sprint_dir: Path to PF sprint/ directory
101
+ repos: Default repos value for stories
102
+ dry_run: If True, preview without writing
103
+ project_root: Project root (auto-detect if None)
104
+
105
+ Returns:
106
+ Result dict with success, counts, and details.
107
+ """
108
+ from pennyfarthing_scripts.sprint.validator import validate_epic_shard
109
+ from pennyfarthing_scripts.sprint.yaml_io import write_sprint
110
+
111
+ root = project_root or get_project_root()
112
+ bmad_config = _get_bmad_config(root)
113
+
114
+ # Resolve paths from config if available
115
+ story_subdir = bmad_config.get("story_dir", "implementation-artifacts")
116
+ epic_subdir = bmad_config.get("epic_dir", "planning-artifacts/epics")
117
+
118
+ # Discover BMAD content
119
+ stories = discover_bmad_stories(bmad_root, story_dir=story_subdir)
120
+ epics_meta = discover_bmad_epics(bmad_root, epic_dir=epic_subdir)
121
+
122
+ if not stories:
123
+ return {"success": False, "error": f"No stories found in {bmad_root / story_subdir}"}
124
+
125
+ # Build epic metadata lookup
126
+ epic_lookup: dict[int, dict] = {e["epicNumber"]: e for e in epics_meta}
127
+
128
+ # Group stories by epic
129
+ grouped = _group_stories_by_epic(stories)
130
+
131
+ # Build epic shards
132
+ epic_shards: list[dict[str, Any]] = []
133
+ validation_errors: list[str] = []
134
+
135
+ for epic_num in sorted(grouped.keys()):
136
+ epic_stories = grouped[epic_num]
137
+ epic_meta = epic_lookup.get(epic_num)
138
+ shard = _build_epic_shard(epic_num, epic_meta, epic_stories, repos)
139
+
140
+ # Validate
141
+ result = validate_epic_shard(shard)
142
+ if not result.valid:
143
+ msgs = "; ".join(e.message for e in result.errors)
144
+ validation_errors.append(f"Epic {epic_num}: {msgs}")
145
+ else:
146
+ epic_shards.append(shard)
147
+
148
+ if validation_errors:
149
+ return {
150
+ "success": False,
151
+ "error": "Validation failed:\n " + "\n ".join(validation_errors),
152
+ }
153
+
154
+ total_stories = sum(len(e["stories"]) for e in epic_shards)
155
+ total_points = sum(e.get("points", 0) for e in epic_shards)
156
+
157
+ if dry_run:
158
+ return {
159
+ "success": True,
160
+ "dry_run": True,
161
+ "epics_count": len(epic_shards),
162
+ "stories_count": total_stories,
163
+ "total_points": total_points,
164
+ "epic_ids": [e["id"] for e in epic_shards],
165
+ "message": (
166
+ f"Would import {len(epic_shards)} epics, "
167
+ f"{total_stories} stories ({total_points} points)"
168
+ ),
169
+ }
170
+
171
+ # Build sprint data structure
172
+ today = date.today().isoformat()
173
+ sprint_data: dict[str, Any] = {
174
+ "sprint": {
175
+ "name": "BMAD Import",
176
+ "goal": f"Imported from BMAD on {today}",
177
+ "start_date": today,
178
+ "end_date": today,
179
+ "status": "active",
180
+ },
181
+ "epics": epic_shards,
182
+ }
183
+
184
+ # Write using shard-aware writer
185
+ sprint_path = sprint_dir / "current-sprint.yaml"
186
+ sprint_dir.mkdir(parents=True, exist_ok=True)
187
+ write_sprint(sprint_path, sprint_data)
188
+
189
+ return {
190
+ "success": True,
191
+ "epics_count": len(epic_shards),
192
+ "stories_count": total_stories,
193
+ "total_points": total_points,
194
+ "sprint_path": str(sprint_path),
195
+ "message": (
196
+ f"Imported {len(epic_shards)} epics, "
197
+ f"{total_stories} stories ({total_points} points) "
198
+ f"to {sprint_path}"
199
+ ),
200
+ }
@@ -0,0 +1,233 @@
1
+ """
2
+ BMAD markdown parser for Pennyfarthing sprint adapter.
3
+
4
+ Reads BMAD story and epic markdown files and returns PF-compatible dicts.
5
+ Story files: implementation-artifacts/{epic}-{story}-{slug}.md
6
+ Epic files: planning-artifacts/epics/epic-{nn}-{slug}.md
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import yaml
16
+
17
+
18
+ # =============================================================================
19
+ # Status Mapping
20
+ # =============================================================================
21
+
22
+ BMAD_TO_PF_STATUS: dict[str, str] = {
23
+ "draft": "planning",
24
+ "ready-for-dev": "ready",
25
+ "in-progress": "in_progress",
26
+ "in-review": "in_progress",
27
+ "completed": "done",
28
+ "blocked": "backlog",
29
+ }
30
+
31
+ PF_TO_BMAD_STATUS: dict[str, str] = {
32
+ "planning": "draft",
33
+ "ready": "ready-for-dev",
34
+ "in_progress": "in-progress",
35
+ "done": "completed",
36
+ "backlog": "blocked",
37
+ "canceled": "completed",
38
+ }
39
+
40
+
41
+ def map_bmad_to_pf(bmad_status: str) -> str:
42
+ """Map a BMAD status string to a PF status."""
43
+ return BMAD_TO_PF_STATUS.get(bmad_status.strip().lower(), "planning")
44
+
45
+
46
+ def map_pf_to_bmad(pf_status: str) -> str:
47
+ """Map a PF status string to a BMAD status."""
48
+ return PF_TO_BMAD_STATUS.get(pf_status.strip().lower(), "draft")
49
+
50
+
51
+ # =============================================================================
52
+ # Story Parsing
53
+ # =============================================================================
54
+
55
+ # Header patterns: flat Key: value lines after the # title
56
+ _HEADER_PATTERNS: dict[str, re.Pattern[str]] = {
57
+ "status": re.compile(r"^Status:\s*(.+)$", re.MULTILINE),
58
+ "story_key": re.compile(r"^Story-Key:\s*(.+)$", re.MULTILINE),
59
+ "jira": re.compile(r"^Jira:\s*(.+)$", re.MULTILINE),
60
+ "epic_line": re.compile(r"^Epic:\s*(.+)$", re.MULTILINE),
61
+ "date": re.compile(r"^Date:\s*(.+)$", re.MULTILINE),
62
+ }
63
+
64
+ _TITLE_RE = re.compile(r"^#\s+Story\s+\d+\.\d+:\s*(.+)$", re.MULTILINE)
65
+
66
+ # AC block: everything between ## Acceptance Criteria and the next ## heading
67
+ _AC_RE = re.compile(
68
+ r"## Acceptance Criteria\s*\n(.*?)(?=\n## |\Z)", re.DOTALL
69
+ )
70
+
71
+
72
+ def parse_bmad_story(path: Path) -> dict[str, Any]:
73
+ """Parse a single BMAD story markdown file.
74
+
75
+ Args:
76
+ path: Path to the .md file in implementation-artifacts/
77
+
78
+ Returns:
79
+ PF-compatible story dict with extra bmad_key and bmad_path fields.
80
+ """
81
+ content = path.read_text()
82
+
83
+ # Extract header fields
84
+ fields: dict[str, str] = {}
85
+ for name, pattern in _HEADER_PATTERNS.items():
86
+ match = pattern.search(content)
87
+ if match:
88
+ fields[name] = match.group(1).strip()
89
+
90
+ story_key = fields.get("story_key", "")
91
+ parts = story_key.split("-", 2) # e.g. "1-5-testing-framework" → ["1","5","testing-framework"]
92
+ epic_num = parts[0] if len(parts) >= 2 else "0"
93
+ story_num = parts[1] if len(parts) >= 2 else "0"
94
+ pf_id = f"{epic_num}-{story_num}"
95
+
96
+ # Title from # heading
97
+ title_match = _TITLE_RE.search(content)
98
+ title = title_match.group(1).strip() if title_match else path.stem
99
+
100
+ # BMAD status → PF status
101
+ bmad_status = fields.get("status", "draft")
102
+ pf_status = map_bmad_to_pf(bmad_status)
103
+
104
+ # Jira references (format: "DPGD-14 / DPGD-21")
105
+ jira_raw = fields.get("jira", "")
106
+
107
+ # Acceptance criteria summary
108
+ ac_match = _AC_RE.search(content)
109
+ ac_text = ac_match.group(1).strip() if ac_match else ""
110
+
111
+ return {
112
+ "id": pf_id,
113
+ "title": title,
114
+ "status": pf_status,
115
+ "points": 3, # Default; BMAD stories don't carry points in impl artifacts
116
+ "priority": "P1",
117
+ "workflow": "tdd",
118
+ "bmad_key": story_key,
119
+ "bmad_status": bmad_status,
120
+ "bmad_path": str(path),
121
+ "jira": jira_raw,
122
+ "epic_num": epic_num,
123
+ "acceptance_criteria": ac_text,
124
+ }
125
+
126
+
127
+ # =============================================================================
128
+ # Epic Parsing
129
+ # =============================================================================
130
+
131
+
132
+ def parse_bmad_epic(path: Path) -> dict[str, Any]:
133
+ """Parse a BMAD epic markdown file with YAML frontmatter.
134
+
135
+ Args:
136
+ path: Path to epic-{nn}-{slug}.md in planning-artifacts/epics/
137
+
138
+ Returns:
139
+ Dict with epicNumber, title, phase, status, storyCount.
140
+ """
141
+ content = path.read_text()
142
+
143
+ # Extract YAML frontmatter between --- markers
144
+ fm_match = re.match(r"^---\s*\n(.*?)\n---", content, re.DOTALL)
145
+ if not fm_match:
146
+ return {
147
+ "epicNumber": 0,
148
+ "title": path.stem,
149
+ "phase": "MVP",
150
+ "status": "draft",
151
+ "storyCount": 0,
152
+ }
153
+
154
+ fm = yaml.safe_load(fm_match.group(1)) or {}
155
+ return {
156
+ "epicNumber": fm.get("epicNumber", 0),
157
+ "title": fm.get("title", path.stem),
158
+ "phase": fm.get("phase", "MVP"),
159
+ "status": fm.get("status", "draft"),
160
+ "storyCount": fm.get("storyCount", 0),
161
+ }
162
+
163
+
164
+ # =============================================================================
165
+ # Discovery
166
+ # =============================================================================
167
+
168
+
169
+ def discover_bmad_stories(
170
+ source_root: Path,
171
+ story_dir: str = "implementation-artifacts",
172
+ ) -> list[dict[str, Any]]:
173
+ """Scan BMAD implementation-artifacts/ and return parsed stories.
174
+
175
+ Args:
176
+ source_root: Path to _bmad-output/ (or equivalent)
177
+ story_dir: Subdirectory name for story files
178
+
179
+ Returns:
180
+ List of parsed story dicts, sorted by (epic_num, story_num).
181
+ """
182
+ artifacts_dir = source_root / story_dir
183
+ if not artifacts_dir.is_dir():
184
+ return []
185
+
186
+ stories: list[dict[str, Any]] = []
187
+ for md_file in sorted(artifacts_dir.glob("*.md")):
188
+ # Skip non-story files (e.g. 0-1-bmad-method-lifecycle.md is meta)
189
+ if md_file.name.startswith("0-"):
190
+ continue
191
+ # Must match {digit}-{digit}-*.md pattern
192
+ if not re.match(r"^\d+-\d+-", md_file.name):
193
+ continue
194
+ story = parse_bmad_story(md_file)
195
+ stories.append(story)
196
+
197
+ # Sort by epic number, then story number
198
+ def sort_key(s: dict) -> tuple[int, int]:
199
+ parts = s["id"].split("-")
200
+ return (int(parts[0]), int(parts[1]))
201
+
202
+ stories.sort(key=sort_key)
203
+ return stories
204
+
205
+
206
+ def discover_bmad_epics(
207
+ source_root: Path,
208
+ epic_dir: str = "planning-artifacts/epics",
209
+ ) -> list[dict[str, Any]]:
210
+ """Scan BMAD planning-artifacts/epics/ and return parsed epics.
211
+
212
+ Args:
213
+ source_root: Path to _bmad-output/ (or equivalent)
214
+ epic_dir: Subdirectory path for epic files
215
+
216
+ Returns:
217
+ List of parsed epic dicts, sorted by epicNumber.
218
+ """
219
+ epics_dir = source_root / epic_dir
220
+ if not epics_dir.is_dir():
221
+ return []
222
+
223
+ epics: list[dict[str, Any]] = []
224
+ for md_file in sorted(epics_dir.glob("epic-*.md")):
225
+ # Skip index.md or non-epic files
226
+ if md_file.name == "index.md":
227
+ continue
228
+ epic = parse_bmad_epic(md_file)
229
+ if epic["epicNumber"] > 0:
230
+ epics.append(epic)
231
+
232
+ epics.sort(key=lambda e: e["epicNumber"])
233
+ return epics