@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,253 @@
1
+ """Tests for BMAD markdown parser."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+ import pytest
9
+
10
+ from pennyfarthing_scripts.bmad.parser import (
11
+ BMAD_TO_PF_STATUS,
12
+ PF_TO_BMAD_STATUS,
13
+ discover_bmad_stories,
14
+ map_bmad_to_pf,
15
+ map_pf_to_bmad,
16
+ parse_bmad_epic,
17
+ parse_bmad_story,
18
+ )
19
+
20
+ # =============================================================================
21
+ # Sample BMAD content
22
+ # =============================================================================
23
+
24
+ SAMPLE_STORY = """\
25
+ # Story 2.1: OCSF Event Schema
26
+
27
+ Status: ready-for-dev
28
+ Story-Key: 2-1-ocsf-event-schema
29
+ Jira: DPGD-24 / DPGD-35
30
+ Epic: 2 - Core Event Ingestion & Storage
31
+ Date: 2026-02-17
32
+
33
+ ## Story
34
+
35
+ As a **security engineer**,
36
+ I want **a standardized event schema based on OCSF**,
37
+ So that **all ingested events have consistent structure**.
38
+
39
+ ## Acceptance Criteria
40
+
41
+ **AC-1: Schema Validation**
42
+ **Given** an incoming event
43
+ **When** it is ingested
44
+ **Then** it conforms to the OCSF schema
45
+
46
+ ## Tasks / Subtasks
47
+
48
+ - [ ] Task 1: Define schema (AC: #1)
49
+ """
50
+
51
+ SAMPLE_STORY_COMPLETED = """\
52
+ # Story 1.1: Initialize Cargo Workspace
53
+
54
+ Status: completed
55
+ Story-Key: 1-1-initialize-cargo-workspace
56
+ Jira: DPGD-10 / DPGD-15
57
+ Epic: 1 - Project Foundation & Developer Experience
58
+ Date: 2026-02-17
59
+
60
+ ## Story
61
+
62
+ As a **developer**,
63
+ I want **a properly initialized Cargo workspace**,
64
+ So that **I can start developing**.
65
+
66
+ ## Acceptance Criteria
67
+
68
+ **AC-1: Workspace Initialized**
69
+ **Given** a fresh clone
70
+ **When** I run cargo build
71
+ **Then** it succeeds
72
+ """
73
+
74
+ SAMPLE_EPIC = """\
75
+ ---
76
+ epicNumber: 2
77
+ title: "Core Event Ingestion & Storage"
78
+ phase: "MVP"
79
+ status: "draft"
80
+ storyCount: 15
81
+ frsAddressed: ["FR25-FR40"]
82
+ nfrsAddressed: ["NFR10-NFR15"]
83
+ ---
84
+
85
+ # Epic 2: Core Event Ingestion & Storage
86
+
87
+ ## Epic Goal
88
+
89
+ Enable high-throughput event ingestion with columnar storage.
90
+ """
91
+
92
+
93
+ # =============================================================================
94
+ # Status Mapping
95
+ # =============================================================================
96
+
97
+
98
+ class TestStatusMapping:
99
+ def test_bmad_to_pf_all_values(self):
100
+ assert map_bmad_to_pf("draft") == "planning"
101
+ assert map_bmad_to_pf("ready-for-dev") == "ready"
102
+ assert map_bmad_to_pf("in-progress") == "in_progress"
103
+ assert map_bmad_to_pf("in-review") == "in_progress"
104
+ assert map_bmad_to_pf("completed") == "done"
105
+ assert map_bmad_to_pf("blocked") == "backlog"
106
+
107
+ def test_bmad_to_pf_unknown_defaults_to_planning(self):
108
+ assert map_bmad_to_pf("unknown-status") == "planning"
109
+
110
+ def test_bmad_to_pf_case_insensitive(self):
111
+ assert map_bmad_to_pf("Ready-For-Dev") == "ready"
112
+ assert map_bmad_to_pf("COMPLETED") == "done"
113
+
114
+ def test_pf_to_bmad_all_values(self):
115
+ assert map_pf_to_bmad("planning") == "draft"
116
+ assert map_pf_to_bmad("ready") == "ready-for-dev"
117
+ assert map_pf_to_bmad("in_progress") == "in-progress"
118
+ assert map_pf_to_bmad("done") == "completed"
119
+ assert map_pf_to_bmad("backlog") == "blocked"
120
+ assert map_pf_to_bmad("canceled") == "completed"
121
+
122
+ def test_pf_to_bmad_unknown_defaults_to_draft(self):
123
+ assert map_pf_to_bmad("unknown") == "draft"
124
+
125
+ def test_roundtrip_bmad_through_pf(self):
126
+ """BMAD → PF → BMAD should preserve status (except in-review → in-progress → in-progress)."""
127
+ for bmad_status, pf_status in BMAD_TO_PF_STATUS.items():
128
+ roundtrip = map_pf_to_bmad(pf_status)
129
+ # in-review maps to in_progress which maps back to in-progress (not in-review)
130
+ if bmad_status == "in-review":
131
+ assert roundtrip == "in-progress"
132
+ else:
133
+ assert roundtrip == bmad_status, f"Roundtrip failed for {bmad_status}"
134
+
135
+
136
+ # =============================================================================
137
+ # Story Parsing
138
+ # =============================================================================
139
+
140
+
141
+ class TestParseStory:
142
+ def test_parse_basic_story(self, tmp_path: Path):
143
+ story_file = tmp_path / "2-1-ocsf-event-schema.md"
144
+ story_file.write_text(SAMPLE_STORY)
145
+
146
+ result = parse_bmad_story(story_file)
147
+
148
+ assert result["id"] == "2-1"
149
+ assert result["title"] == "OCSF Event Schema"
150
+ assert result["status"] == "ready"
151
+ assert result["bmad_key"] == "2-1-ocsf-event-schema"
152
+ assert result["bmad_status"] == "ready-for-dev"
153
+ assert result["jira"] == "DPGD-24 / DPGD-35"
154
+ assert result["epic_num"] == "2"
155
+ assert result["points"] == 3
156
+ assert "AC-1" in result["acceptance_criteria"]
157
+
158
+ def test_parse_completed_story(self, tmp_path: Path):
159
+ story_file = tmp_path / "1-1-initialize-cargo-workspace.md"
160
+ story_file.write_text(SAMPLE_STORY_COMPLETED)
161
+
162
+ result = parse_bmad_story(story_file)
163
+
164
+ assert result["id"] == "1-1"
165
+ assert result["status"] == "done"
166
+ assert result["bmad_status"] == "completed"
167
+ assert result["bmad_key"] == "1-1-initialize-cargo-workspace"
168
+
169
+ def test_parse_story_with_missing_fields(self, tmp_path: Path):
170
+ minimal = "# Story 5.3: Retry Logic\n\nSome content\n"
171
+ story_file = tmp_path / "5-3-retry-logic.md"
172
+ story_file.write_text(minimal)
173
+
174
+ result = parse_bmad_story(story_file)
175
+
176
+ # Should still parse with defaults
177
+ assert result["status"] == "planning"
178
+ assert result["bmad_status"] == "draft"
179
+ assert result["points"] == 3
180
+
181
+
182
+ # =============================================================================
183
+ # Epic Parsing
184
+ # =============================================================================
185
+
186
+
187
+ class TestParseEpic:
188
+ def test_parse_epic_with_frontmatter(self, tmp_path: Path):
189
+ epic_file = tmp_path / "epic-02-event-ingestion.md"
190
+ epic_file.write_text(SAMPLE_EPIC)
191
+
192
+ result = parse_bmad_epic(epic_file)
193
+
194
+ assert result["epicNumber"] == 2
195
+ assert result["title"] == "Core Event Ingestion & Storage"
196
+ assert result["phase"] == "MVP"
197
+ assert result["status"] == "draft"
198
+ assert result["storyCount"] == 15
199
+
200
+ def test_parse_epic_without_frontmatter(self, tmp_path: Path):
201
+ epic_file = tmp_path / "epic-99-something.md"
202
+ epic_file.write_text("# Epic 99: Something\n\nNo frontmatter here.\n")
203
+
204
+ result = parse_bmad_epic(epic_file)
205
+
206
+ assert result["epicNumber"] == 0
207
+ assert result["status"] == "draft"
208
+
209
+
210
+ # =============================================================================
211
+ # Discovery
212
+ # =============================================================================
213
+
214
+
215
+ class TestDiscovery:
216
+ def test_discover_stories(self, tmp_path: Path):
217
+ impl = tmp_path / "implementation-artifacts"
218
+ impl.mkdir()
219
+
220
+ (impl / "1-1-workspace.md").write_text(SAMPLE_STORY_COMPLETED)
221
+ (impl / "2-1-schema.md").write_text(SAMPLE_STORY)
222
+ # Meta files should be skipped
223
+ (impl / "0-1-bmad-lifecycle.md").write_text("# Meta file\n")
224
+ # Non-matching files should be skipped
225
+ (impl / "readme.md").write_text("# Readme\n")
226
+
227
+ stories = discover_bmad_stories(tmp_path)
228
+
229
+ assert len(stories) == 2
230
+ assert stories[0]["id"] == "1-1"
231
+ assert stories[1]["id"] == "2-1"
232
+
233
+ def test_discover_stories_empty_dir(self, tmp_path: Path):
234
+ stories = discover_bmad_stories(tmp_path)
235
+ assert stories == []
236
+
237
+ def test_discover_epics(self, tmp_path: Path):
238
+ epics_dir = tmp_path / "planning-artifacts" / "epics"
239
+ epics_dir.mkdir(parents=True)
240
+
241
+ (epics_dir / "epic-01-foundation.md").write_text(
242
+ "---\nepicNumber: 1\ntitle: Foundation\nphase: MVP\nstatus: draft\nstoryCount: 5\n---\n"
243
+ )
244
+ (epics_dir / "epic-02-ingestion.md").write_text(SAMPLE_EPIC)
245
+ (epics_dir / "index.md").write_text("# Index\n")
246
+
247
+ from pennyfarthing_scripts.bmad.parser import discover_bmad_epics
248
+
249
+ epics = discover_bmad_epics(tmp_path)
250
+
251
+ assert len(epics) == 2
252
+ assert epics[0]["epicNumber"] == 1
253
+ assert epics[1]["epicNumber"] == 2
@@ -0,0 +1,223 @@
1
+ """Tests for BMAD sync engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from pennyfarthing_scripts.bmad.sync import (
10
+ BmadSyncChange,
11
+ BmadSyncPlan,
12
+ _update_bmad_file_status,
13
+ format_sync_plan,
14
+ generate_sync_plan,
15
+ )
16
+
17
+
18
+ # =============================================================================
19
+ # Fixtures
20
+ # =============================================================================
21
+
22
+
23
+ def _pf_story(story_id: str, bmad_key: str, status: str = "ready") -> dict:
24
+ return {
25
+ "id": story_id,
26
+ "title": f"Story {story_id}",
27
+ "status": status,
28
+ "bmad_key": bmad_key,
29
+ "points": 3,
30
+ }
31
+
32
+
33
+ def _bmad_story(bmad_key: str, bmad_status: str = "ready-for-dev") -> dict:
34
+ from pennyfarthing_scripts.bmad.parser import map_bmad_to_pf
35
+
36
+ parts = bmad_key.split("-", 2)
37
+ return {
38
+ "id": f"{parts[0]}-{parts[1]}",
39
+ "title": f"Story {bmad_key}",
40
+ "status": map_bmad_to_pf(bmad_status),
41
+ "bmad_key": bmad_key,
42
+ "bmad_status": bmad_status,
43
+ "bmad_path": f"/fake/{bmad_key}.md",
44
+ "epic_num": parts[0],
45
+ "points": 3,
46
+ }
47
+
48
+
49
+ # =============================================================================
50
+ # Sync Plan Generation
51
+ # =============================================================================
52
+
53
+
54
+ class TestGenerateSyncPlan:
55
+ def test_all_in_sync(self):
56
+ pf = [_pf_story("1-1", "1-1-workspace", "ready")]
57
+ bmad = [_bmad_story("1-1-workspace", "ready-for-dev")]
58
+
59
+ plan = generate_sync_plan(pf, bmad, direction="both")
60
+
61
+ assert plan.both == ["1-1-workspace"]
62
+ assert plan.changes == []
63
+ assert plan.pf_only == []
64
+ assert plan.bmad_only == []
65
+
66
+ def test_pull_detects_bmad_change(self):
67
+ pf = [_pf_story("1-1", "1-1-workspace", "ready")]
68
+ bmad = [_bmad_story("1-1-workspace", "completed")]
69
+
70
+ plan = generate_sync_plan(pf, bmad, direction="pull")
71
+
72
+ assert len(plan.changes) == 1
73
+ change = plan.changes[0]
74
+ assert change.action == "update-pf"
75
+ assert change.pf_value == "ready"
76
+ assert change.bmad_value == "completed"
77
+ assert change.target_value == "done"
78
+
79
+ def test_push_detects_pf_change(self):
80
+ pf = [_pf_story("2-1", "2-1-schema", "done")]
81
+ bmad = [_bmad_story("2-1-schema", "ready-for-dev")]
82
+
83
+ plan = generate_sync_plan(pf, bmad, direction="push")
84
+
85
+ assert len(plan.changes) == 1
86
+ change = plan.changes[0]
87
+ assert change.action == "update-bmad"
88
+ assert change.target_value == "completed"
89
+
90
+ def test_both_pf_wins(self):
91
+ pf = [_pf_story("1-1", "1-1-workspace", "in_progress")]
92
+ bmad = [_bmad_story("1-1-workspace", "ready-for-dev")]
93
+
94
+ plan = generate_sync_plan(pf, bmad, direction="both", pf_wins=True)
95
+
96
+ assert len(plan.changes) == 1
97
+ assert plan.changes[0].action == "update-bmad"
98
+ assert plan.changes[0].target_value == "in-progress"
99
+
100
+ def test_both_bmad_wins(self):
101
+ pf = [_pf_story("1-1", "1-1-workspace", "in_progress")]
102
+ bmad = [_bmad_story("1-1-workspace", "completed")]
103
+
104
+ plan = generate_sync_plan(pf, bmad, direction="both", pf_wins=False)
105
+
106
+ assert len(plan.changes) == 1
107
+ assert plan.changes[0].action == "update-pf"
108
+ assert plan.changes[0].target_value == "done"
109
+
110
+ def test_pf_only_stories(self):
111
+ pf = [_pf_story("1-1", "1-1-workspace"), _pf_story("9-9", "9-9-custom")]
112
+ bmad = [_bmad_story("1-1-workspace")]
113
+
114
+ plan = generate_sync_plan(pf, bmad, direction="both")
115
+
116
+ assert plan.pf_only == ["9-9-custom"]
117
+ assert plan.both == ["1-1-workspace"]
118
+
119
+ def test_bmad_only_stories(self):
120
+ pf = [_pf_story("1-1", "1-1-workspace")]
121
+ bmad = [_bmad_story("1-1-workspace"), _bmad_story("3-2-rule-parser")]
122
+
123
+ plan = generate_sync_plan(pf, bmad, direction="both")
124
+
125
+ assert plan.bmad_only == ["3-2-rule-parser"]
126
+
127
+ def test_multiple_changes(self):
128
+ pf = [
129
+ _pf_story("1-1", "1-1-workspace", "done"),
130
+ _pf_story("2-1", "2-1-schema", "in_progress"),
131
+ ]
132
+ bmad = [
133
+ _bmad_story("1-1-workspace", "ready-for-dev"),
134
+ _bmad_story("2-1-schema", "ready-for-dev"),
135
+ ]
136
+
137
+ plan = generate_sync_plan(pf, bmad, direction="push")
138
+
139
+ assert len(plan.changes) == 2
140
+
141
+ def test_empty_inputs(self):
142
+ plan = generate_sync_plan([], [], direction="both")
143
+
144
+ assert plan.changes == []
145
+ assert plan.both == []
146
+ assert plan.pf_only == []
147
+ assert plan.bmad_only == []
148
+
149
+
150
+ # =============================================================================
151
+ # BMAD File Update
152
+ # =============================================================================
153
+
154
+
155
+ class TestUpdateBmadFile:
156
+ def test_update_status_line(self, tmp_path: Path):
157
+ md_file = tmp_path / "1-1-workspace.md"
158
+ md_file.write_text(
159
+ "# Story 1.1: Workspace\n\n"
160
+ "Status: ready-for-dev\n"
161
+ "Story-Key: 1-1-workspace\n"
162
+ "Jira: DPGD-10 / DPGD-15\n\n"
163
+ "## Story\n\nContent here.\n"
164
+ )
165
+
166
+ result = _update_bmad_file_status(str(md_file), "completed")
167
+
168
+ assert result is True
169
+ content = md_file.read_text()
170
+ assert "Status: completed" in content
171
+ assert "ready-for-dev" not in content
172
+ # Other lines preserved
173
+ assert "Story-Key: 1-1-workspace" in content
174
+ assert "Jira: DPGD-10 / DPGD-15" in content
175
+
176
+ def test_update_nonexistent_file(self, tmp_path: Path):
177
+ result = _update_bmad_file_status(str(tmp_path / "missing.md"), "completed")
178
+ assert result is False
179
+
180
+ def test_update_file_without_status_line(self, tmp_path: Path):
181
+ md_file = tmp_path / "no-status.md"
182
+ md_file.write_text("# No status\n\nJust content.\n")
183
+
184
+ result = _update_bmad_file_status(str(md_file), "completed")
185
+ assert result is False
186
+
187
+
188
+ # =============================================================================
189
+ # Formatting
190
+ # =============================================================================
191
+
192
+
193
+ class TestFormatSyncPlan:
194
+ def test_format_empty_plan(self):
195
+ plan = BmadSyncPlan()
196
+ output = format_sync_plan(plan)
197
+ assert "Everything is in sync" in output
198
+
199
+ def test_format_with_changes(self):
200
+ plan = BmadSyncPlan(
201
+ changes=[
202
+ BmadSyncChange(
203
+ bmad_key="1-1-workspace",
204
+ pf_id="1-1",
205
+ field="status",
206
+ action="update-pf",
207
+ pf_value="ready",
208
+ bmad_value="completed",
209
+ target_value="done",
210
+ )
211
+ ],
212
+ both=["1-1-workspace"],
213
+ )
214
+ output = format_sync_plan(plan)
215
+ assert "Changes (1)" in output
216
+ assert "BMAD→PF" in output
217
+ assert "1-1-workspace" in output
218
+
219
+ def test_format_with_new_bmad(self):
220
+ plan = BmadSyncPlan(bmad_only=["3-2-rule-parser", "3-3-rule-engine"])
221
+ output = format_sync_plan(plan)
222
+ assert "New in BMAD (2)" in output
223
+ assert "3-2-rule-parser" in output
@@ -90,6 +90,11 @@ from pennyfarthing_scripts.jira.cli import jira # noqa: E402
90
90
 
91
91
  cli.add_command(jira)
92
92
 
93
+ # Import and register bmad group
94
+ from pennyfarthing_scripts.bmad.cli import bmad # noqa: E402
95
+
96
+ cli.add_command(bmad)
97
+
93
98
  # Import and register theme group
94
99
  from pennyfarthing_scripts.theme.cli import theme # noqa: E402
95
100
 
@@ -147,6 +152,11 @@ from pennyfarthing_scripts.hooks.cli import hooks # noqa: E402
147
152
 
148
153
  cli.add_command(hooks)
149
154
 
155
+ # Import and register settings group
156
+ from pennyfarthing_scripts.settings.cli import settings # noqa: E402
157
+
158
+ cli.add_command(settings)
159
+
150
160
 
151
161
  @cli.group()
152
162
  def agent():
File without changes
@@ -0,0 +1,55 @@
1
+ """
2
+ Settings CLI group — view and manage .pennyfarthing/config.local.yaml.
3
+
4
+ Usage:
5
+ pf settings show # Pretty-print interesting settings
6
+ pf settings get <key> # Get value by dot-path
7
+ pf settings set <key> <value> # Set value by dot-path
8
+ """
9
+
10
+ import click
11
+
12
+
13
+ @click.group()
14
+ def settings():
15
+ """View and manage .pennyfarthing/config.local.yaml settings."""
16
+ pass
17
+
18
+
19
+ @settings.command()
20
+ def show():
21
+ """Pretty-print all interesting settings."""
22
+ from pennyfarthing_scripts.settings.settings import show_settings
23
+
24
+ click.echo(show_settings())
25
+
26
+
27
+ @settings.command()
28
+ @click.argument("key")
29
+ def get(key: str):
30
+ """Get a setting value by dot-path (e.g. workflow.relay_mode)."""
31
+ from pennyfarthing_scripts.settings.settings import get_setting
32
+
33
+ try:
34
+ value = get_setting(key)
35
+ except KeyError:
36
+ click.echo(f"Key not found: {key}", err=True)
37
+ raise SystemExit(1) from None
38
+
39
+ if isinstance(value, dict):
40
+ import yaml
41
+
42
+ click.echo(yaml.dump(value, default_flow_style=False, sort_keys=False).rstrip())
43
+ else:
44
+ click.echo(value)
45
+
46
+
47
+ @settings.command()
48
+ @click.argument("key")
49
+ @click.argument("value")
50
+ def set(key: str, value: str):
51
+ """Set a setting value by dot-path (e.g. workflow.bell_mode true)."""
52
+ from pennyfarthing_scripts.settings.settings import set_setting
53
+
54
+ set_setting(key, value)
55
+ click.echo(f"{key} = {value}")