@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.
- package/README.md +3 -3
- package/package.json +1 -1
- package/packages/core/dist/cli/commands/doctor-legacy.test.js +2 -2
- package/packages/core/dist/cli/commands/doctor-legacy.test.js.map +1 -1
- package/packages/core/dist/cli/commands/doctor.d.ts +63 -0
- package/packages/core/dist/cli/commands/doctor.d.ts.map +1 -1
- package/packages/core/dist/cli/commands/doctor.js +280 -43
- package/packages/core/dist/cli/commands/doctor.js.map +1 -1
- package/packages/core/dist/cli/commands/init.d.ts +12 -0
- package/packages/core/dist/cli/commands/init.d.ts.map +1 -1
- package/packages/core/dist/cli/commands/init.js +45 -0
- package/packages/core/dist/cli/commands/init.js.map +1 -1
- package/packages/core/dist/cli/commands/pyproject-install.test.d.ts +19 -0
- package/packages/core/dist/cli/commands/pyproject-install.test.d.ts.map +1 -0
- package/packages/core/dist/cli/commands/pyproject-install.test.js +261 -0
- package/packages/core/dist/cli/commands/pyproject-install.test.js.map +1 -0
- package/packages/core/dist/cli/commands/update-consolidation.test.js +14 -6
- package/packages/core/dist/cli/commands/update-consolidation.test.js.map +1 -1
- package/packages/core/dist/cli/commands/update.d.ts.map +1 -1
- package/packages/core/dist/cli/commands/update.js +5 -1
- package/packages/core/dist/cli/commands/update.js.map +1 -1
- package/packages/core/dist/cli/index.js +2 -0
- package/packages/core/dist/cli/index.js.map +1 -1
- package/packages/core/dist/cli/utils/python.d.ts +1 -0
- package/packages/core/dist/cli/utils/python.d.ts.map +1 -1
- package/packages/core/dist/cli/utils/python.js +22 -1
- package/packages/core/dist/cli/utils/python.js.map +1 -1
- package/packages/core/dist/cli/utils/settings-hook-migration.test.d.ts +17 -0
- package/packages/core/dist/cli/utils/settings-hook-migration.test.d.ts.map +1 -0
- package/packages/core/dist/cli/utils/settings-hook-migration.test.js +382 -0
- package/packages/core/dist/cli/utils/settings-hook-migration.test.js.map +1 -0
- package/packages/core/dist/cli/utils/settings-pf-wrapper.test.d.ts +16 -0
- package/packages/core/dist/cli/utils/settings-pf-wrapper.test.d.ts.map +1 -0
- package/packages/core/dist/cli/utils/settings-pf-wrapper.test.js +377 -0
- package/packages/core/dist/cli/utils/settings-pf-wrapper.test.js.map +1 -0
- package/packages/core/dist/cli/utils/settings.d.ts.map +1 -1
- package/packages/core/dist/cli/utils/settings.js +15 -2
- package/packages/core/dist/cli/utils/settings.js.map +1 -1
- package/packages/core/dist/server/paths.d.ts.map +1 -1
- package/packages/core/dist/server/paths.js +6 -0
- package/packages/core/dist/server/paths.js.map +1 -1
- package/packages/core/dist/server/settings.d.ts.map +1 -1
- package/packages/core/dist/server/settings.js +5 -0
- package/packages/core/dist/server/settings.js.map +1 -1
- package/packages/core/dist/workflow/tandem-workflow-templates.test.js +7 -5
- package/packages/core/dist/workflow/tandem-workflow-templates.test.js.map +1 -1
- package/packages/core/dist/workflow/workflow-graph-validation.d.ts +65 -0
- package/packages/core/dist/workflow/workflow-graph-validation.d.ts.map +1 -0
- package/packages/core/dist/workflow/workflow-graph-validation.js +190 -0
- package/packages/core/dist/workflow/workflow-graph-validation.js.map +1 -0
- package/packages/core/dist/workflow/workflow-graph-validation.test.d.ts +18 -0
- package/packages/core/dist/workflow/workflow-graph-validation.test.d.ts.map +1 -0
- package/packages/core/dist/workflow/workflow-graph-validation.test.js +706 -0
- package/packages/core/dist/workflow/workflow-graph-validation.test.js.map +1 -0
- package/packages/core/dist/workflow/workflow-migration.test.js +6 -5
- package/packages/core/dist/workflow/workflow-migration.test.js.map +1 -1
- package/pennyfarthing-dist/agents/dev.md +4 -2
- package/pennyfarthing-dist/agents/devops.md +2 -10
- package/pennyfarthing-dist/agents/reviewer-preflight.md +4 -5
- package/pennyfarthing-dist/agents/sm.md +4 -17
- package/pennyfarthing-dist/commands/pf-health-check.md +30 -11
- package/pennyfarthing-dist/gates/{confidence-sm.md → confidence.md} +16 -17
- package/pennyfarthing-dist/gates/dev-exit.md +75 -0
- package/pennyfarthing-dist/gates/merge-ready.md +49 -0
- package/pennyfarthing-dist/gates/release-ready.md +95 -0
- package/pennyfarthing-dist/gates/reviewer-preflight-check.md +90 -0
- package/pennyfarthing-dist/gates/sm-setup-exit.md +82 -0
- package/pennyfarthing-dist/guides/agent-behavior.md +88 -30
- package/pennyfarthing-dist/guides/gates.md +7 -2
- package/pennyfarthing-dist/scripts/lib/find-root.sh +5 -0
- package/pennyfarthing-dist/scripts/lib/run-pf.sh +7 -0
- package/pennyfarthing-dist/skills/pf-settings/skill.md +42 -0
- package/pennyfarthing-dist/skills/skill-registry.yaml +15 -0
- package/pennyfarthing-dist/templates/pyproject.toml +27 -0
- package/pennyfarthing-dist/workflows/bdd-tandem.yaml +7 -3
- package/pennyfarthing-dist/workflows/bdd.yaml +7 -3
- package/pennyfarthing-dist/workflows/installation-check/steps/step-01-foundation.md +77 -0
- package/pennyfarthing-dist/workflows/installation-check/steps/step-02-commands.md +82 -0
- package/pennyfarthing-dist/workflows/installation-check/steps/step-03-hooks.md +121 -0
- package/pennyfarthing-dist/workflows/installation-check/steps/step-04-scripts.md +83 -0
- package/pennyfarthing-dist/workflows/installation-check/steps/step-05-layout.md +81 -0
- package/pennyfarthing-dist/workflows/installation-check/steps/step-06-legacy.md +94 -0
- package/pennyfarthing-dist/workflows/installation-check/steps/step-07-tools.md +80 -0
- package/pennyfarthing-dist/workflows/installation-check/steps/step-08-summary.md +99 -0
- package/pennyfarthing-dist/workflows/installation-check/workflow.yaml +47 -0
- package/pennyfarthing-dist/workflows/tdd-tandem.yaml +7 -3
- package/pennyfarthing-dist/workflows/tdd.yaml +7 -3
- package/pennyfarthing-dist/workflows/trivial.yaml +7 -3
- package/pennyfarthing_scripts/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/context.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bc/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bc/__pycache__/focus.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bc/__pycache__/split.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bc/cli.py +21 -0
- package/pennyfarthing_scripts/bc/focus.py +1 -0
- package/pennyfarthing_scripts/bc/split.py +52 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/audit_log_panel.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/base_panel.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/changed_panel.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/context_meter_footer.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/debug_panel.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/diffs_panel.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/events.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/launcher.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/portrait_resolver.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/progress_panel.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/sprint_panel.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/tui.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/__pycache__/ws_client.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bikerack/context_meter_footer.py +53 -3
- package/pennyfarthing_scripts/bikerack/tui.py +202 -8
- package/pennyfarthing_scripts/bmad/__init__.py +1 -0
- package/pennyfarthing_scripts/bmad/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bmad/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bmad/__pycache__/parser.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bmad/__pycache__/sync.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bmad/__pycache__/test_parser.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/bmad/__pycache__/test_sync.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/bmad/cli.py +197 -0
- package/pennyfarthing_scripts/bmad/importer.py +200 -0
- package/pennyfarthing_scripts/bmad/parser.py +233 -0
- package/pennyfarthing_scripts/bmad/sync.py +464 -0
- package/pennyfarthing_scripts/bmad/test_parser.py +253 -0
- package/pennyfarthing_scripts/bmad/test_sync.py +223 -0
- package/pennyfarthing_scripts/cli.py +10 -0
- package/pennyfarthing_scripts/common/__pycache__/config.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/consultation/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/handoff/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/handoff/__pycache__/complete_phase.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/handoff/__pycache__/gate_file.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/handoff/__pycache__/gate_runner.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/handoff/__pycache__/marker.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/handoff/__pycache__/resolve_gate.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hooks/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hooks/__pycache__/bell_mode.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hooks/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hooks/__pycache__/context_breaker.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hooks/__pycache__/context_warning.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hooks/__pycache__/cyclist_pretooluse.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hooks/__pycache__/pre_edit_check.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hooks/__pycache__/reflector_check.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hooks/__pycache__/schema_validation.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hooks/__pycache__/session_start.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hooks/__pycache__/session_stop.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hooks/__pycache__/sprint_yaml_validation.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/hooks/__pycache__/statusline.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/workflow.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/settings/__init__.py +0 -0
- package/pennyfarthing_scripts/settings/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/settings/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/settings/__pycache__/settings.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/settings/cli.py +55 -0
- package/pennyfarthing_scripts/settings/settings.py +98 -0
- package/pennyfarthing_scripts/sprint/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/loader.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/story_update.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_confidence_sm_evaluation.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_confidence_sm_gate.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_gate_file_resolution.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_gate_runner.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_resolve_gate_file_field.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_workflow_list_team.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/tests/test_confidence_sm_gate.py +17 -16
- package/pennyfarthing_scripts/tests/test_resolve_gate_file_field.py +45 -47
- package/pennyfarthing_scripts/tests/test_workflow_list_team.py +0 -4
- package/pennyfarthing_scripts/workflow/__pycache__/cli.cpython-314.pyc +0 -0
- 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
|