@pennyfarthing/core 7.8.0 → 7.8.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 (132) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -1
  3. package/packages/core/dist/cli/commands/doctor.d.ts +3 -0
  4. package/packages/core/dist/cli/commands/doctor.d.ts.map +1 -1
  5. package/packages/core/dist/cli/commands/doctor.js +20 -9
  6. package/packages/core/dist/cli/commands/doctor.js.map +1 -1
  7. package/pennyfarthing-dist/scripts/core/agent-session.sh +2 -2
  8. package/pennyfarthing-dist/scripts/core/prime.sh +8 -0
  9. package/pennyfarthing_scripts/__init__.py +17 -0
  10. package/pennyfarthing_scripts/__pycache__/__init__.cpython-311.pyc +0 -0
  11. package/pennyfarthing_scripts/__pycache__/__init__.cpython-314.pyc +0 -0
  12. package/pennyfarthing_scripts/__pycache__/config.cpython-314.pyc +0 -0
  13. package/pennyfarthing_scripts/__pycache__/jira.cpython-314.pyc +0 -0
  14. package/pennyfarthing_scripts/__pycache__/jira_epic_creation.cpython-314.pyc +0 -0
  15. package/pennyfarthing_scripts/__pycache__/jira_sync.cpython-314.pyc +0 -0
  16. package/pennyfarthing_scripts/__pycache__/jira_sync_story.cpython-314.pyc +0 -0
  17. package/pennyfarthing_scripts/__pycache__/sprint.cpython-314.pyc +0 -0
  18. package/pennyfarthing_scripts/__pycache__/workflow.cpython-311.pyc +0 -0
  19. package/pennyfarthing_scripts/__pycache__/workflow.cpython-314.pyc +0 -0
  20. package/pennyfarthing_scripts/bellmode_hook.py +154 -0
  21. package/pennyfarthing_scripts/brownfield/__init__.py +35 -0
  22. package/pennyfarthing_scripts/brownfield/__main__.py +7 -0
  23. package/pennyfarthing_scripts/brownfield/__pycache__/__init__.cpython-314.pyc +0 -0
  24. package/pennyfarthing_scripts/brownfield/__pycache__/__main__.cpython-314.pyc +0 -0
  25. package/pennyfarthing_scripts/brownfield/__pycache__/cli.cpython-314.pyc +0 -0
  26. package/pennyfarthing_scripts/brownfield/__pycache__/discover.cpython-314.pyc +0 -0
  27. package/pennyfarthing_scripts/brownfield/cli.py +131 -0
  28. package/pennyfarthing_scripts/brownfield/discover.py +753 -0
  29. package/pennyfarthing_scripts/common/__init__.py +49 -0
  30. package/pennyfarthing_scripts/common/__pycache__/__init__.cpython-314.pyc +0 -0
  31. package/pennyfarthing_scripts/common/__pycache__/config.cpython-314.pyc +0 -0
  32. package/pennyfarthing_scripts/common/__pycache__/output.cpython-314.pyc +0 -0
  33. package/pennyfarthing_scripts/common/config.py +65 -0
  34. package/pennyfarthing_scripts/common/output.py +180 -0
  35. package/pennyfarthing_scripts/config.py +21 -0
  36. package/pennyfarthing_scripts/git/__init__.py +29 -0
  37. package/pennyfarthing_scripts/git/__pycache__/__init__.cpython-314.pyc +0 -0
  38. package/pennyfarthing_scripts/git/__pycache__/create_branches.cpython-314.pyc +0 -0
  39. package/pennyfarthing_scripts/git/__pycache__/status_all.cpython-314.pyc +0 -0
  40. package/pennyfarthing_scripts/git/create_branches.py +439 -0
  41. package/pennyfarthing_scripts/git/status_all.py +310 -0
  42. package/pennyfarthing_scripts/hooks.py +455 -0
  43. package/pennyfarthing_scripts/jira/__init__.py +93 -0
  44. package/pennyfarthing_scripts/jira/__main__.py +10 -0
  45. package/pennyfarthing_scripts/jira/__pycache__/__init__.cpython-314.pyc +0 -0
  46. package/pennyfarthing_scripts/jira/__pycache__/__main__.cpython-314.pyc +0 -0
  47. package/pennyfarthing_scripts/jira/__pycache__/bidirectional.cpython-314.pyc +0 -0
  48. package/pennyfarthing_scripts/jira/__pycache__/claim.cpython-314.pyc +0 -0
  49. package/pennyfarthing_scripts/jira/__pycache__/cli.cpython-314.pyc +0 -0
  50. package/pennyfarthing_scripts/jira/__pycache__/client.cpython-314.pyc +0 -0
  51. package/pennyfarthing_scripts/jira/__pycache__/compat.cpython-314.pyc +0 -0
  52. package/pennyfarthing_scripts/jira/__pycache__/epic.cpython-314.pyc +0 -0
  53. package/pennyfarthing_scripts/jira/__pycache__/mappings.cpython-314.pyc +0 -0
  54. package/pennyfarthing_scripts/jira/__pycache__/models.cpython-314.pyc +0 -0
  55. package/pennyfarthing_scripts/jira/__pycache__/story.cpython-314.pyc +0 -0
  56. package/pennyfarthing_scripts/jira/__pycache__/sync.cpython-314.pyc +0 -0
  57. package/pennyfarthing_scripts/jira/bidirectional.py +561 -0
  58. package/pennyfarthing_scripts/jira/claim.py +211 -0
  59. package/pennyfarthing_scripts/jira/cli.py +150 -0
  60. package/pennyfarthing_scripts/jira/client.py +613 -0
  61. package/pennyfarthing_scripts/jira/epic.py +176 -0
  62. package/pennyfarthing_scripts/jira/story.py +219 -0
  63. package/pennyfarthing_scripts/jira/sync.py +350 -0
  64. package/pennyfarthing_scripts/jira_bidirectional_sync.py +37 -0
  65. package/pennyfarthing_scripts/jira_epic_creation.py +30 -0
  66. package/pennyfarthing_scripts/jira_sync.py +36 -0
  67. package/pennyfarthing_scripts/jira_sync_story.py +30 -0
  68. package/pennyfarthing_scripts/output.py +37 -0
  69. package/pennyfarthing_scripts/preflight/__init__.py +17 -0
  70. package/pennyfarthing_scripts/preflight/__main__.py +10 -0
  71. package/pennyfarthing_scripts/preflight/__pycache__/__init__.cpython-314.pyc +0 -0
  72. package/pennyfarthing_scripts/preflight/__pycache__/__main__.cpython-314.pyc +0 -0
  73. package/pennyfarthing_scripts/preflight/__pycache__/cli.cpython-314.pyc +0 -0
  74. package/pennyfarthing_scripts/preflight/__pycache__/finish.cpython-314.pyc +0 -0
  75. package/pennyfarthing_scripts/preflight/cli.py +141 -0
  76. package/pennyfarthing_scripts/preflight/finish.py +382 -0
  77. package/pennyfarthing_scripts/pretooluse_hook.py +142 -0
  78. package/pennyfarthing_scripts/prime/__init__.py +38 -0
  79. package/pennyfarthing_scripts/prime/__main__.py +8 -0
  80. package/pennyfarthing_scripts/prime/__pycache__/__init__.cpython-314.pyc +0 -0
  81. package/pennyfarthing_scripts/prime/__pycache__/__main__.cpython-314.pyc +0 -0
  82. package/pennyfarthing_scripts/prime/__pycache__/cli.cpython-314.pyc +0 -0
  83. package/pennyfarthing_scripts/prime/__pycache__/loader.cpython-314.pyc +0 -0
  84. package/pennyfarthing_scripts/prime/__pycache__/models.cpython-314.pyc +0 -0
  85. package/pennyfarthing_scripts/prime/__pycache__/persona.cpython-314.pyc +0 -0
  86. package/pennyfarthing_scripts/prime/__pycache__/session.cpython-314.pyc +0 -0
  87. package/pennyfarthing_scripts/prime/__pycache__/workflow.cpython-314.pyc +0 -0
  88. package/pennyfarthing_scripts/prime/cli.py +220 -0
  89. package/pennyfarthing_scripts/prime/loader.py +239 -0
  90. package/pennyfarthing_scripts/sprint/__init__.py +66 -0
  91. package/pennyfarthing_scripts/sprint/__main__.py +10 -0
  92. package/pennyfarthing_scripts/sprint/__pycache__/__init__.cpython-314.pyc +0 -0
  93. package/pennyfarthing_scripts/sprint/__pycache__/__main__.cpython-314.pyc +0 -0
  94. package/pennyfarthing_scripts/sprint/__pycache__/archive.cpython-314.pyc +0 -0
  95. package/pennyfarthing_scripts/sprint/__pycache__/cli.cpython-314.pyc +0 -0
  96. package/pennyfarthing_scripts/sprint/__pycache__/loader.cpython-314.pyc +0 -0
  97. package/pennyfarthing_scripts/sprint/__pycache__/status.cpython-314.pyc +0 -0
  98. package/pennyfarthing_scripts/sprint/__pycache__/validator.cpython-314.pyc +0 -0
  99. package/pennyfarthing_scripts/sprint/__pycache__/work.cpython-314.pyc +0 -0
  100. package/pennyfarthing_scripts/sprint/archive.py +108 -0
  101. package/pennyfarthing_scripts/sprint/cli.py +124 -0
  102. package/pennyfarthing_scripts/sprint/loader.py +193 -0
  103. package/pennyfarthing_scripts/sprint/status.py +122 -0
  104. package/pennyfarthing_scripts/sprint/validator.py +405 -0
  105. package/pennyfarthing_scripts/sprint/work.py +192 -0
  106. package/pennyfarthing_scripts/story/__init__.py +67 -0
  107. package/pennyfarthing_scripts/story/__main__.py +10 -0
  108. package/pennyfarthing_scripts/story/cli.py +105 -0
  109. package/pennyfarthing_scripts/story/create.py +167 -0
  110. package/pennyfarthing_scripts/story/size.py +113 -0
  111. package/pennyfarthing_scripts/story/template.py +151 -0
  112. package/pennyfarthing_scripts/swebench.py +216 -0
  113. package/pennyfarthing_scripts/tests/__init__.py +1 -0
  114. package/pennyfarthing_scripts/tests/__pycache__/__init__.cpython-314.pyc +0 -0
  115. package/pennyfarthing_scripts/tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc +0 -0
  116. package/pennyfarthing_scripts/tests/__pycache__/test_brownfield.cpython-314-pytest-9.0.2.pyc +0 -0
  117. package/pennyfarthing_scripts/tests/__pycache__/test_git_utils.cpython-314-pytest-9.0.2.pyc +0 -0
  118. package/pennyfarthing_scripts/tests/__pycache__/test_prime.cpython-314-pytest-9.0.2.pyc +0 -0
  119. package/pennyfarthing_scripts/tests/__pycache__/test_sprint_validator.cpython-314-pytest-9.0.2.pyc +0 -0
  120. package/pennyfarthing_scripts/tests/conftest.py +106 -0
  121. package/pennyfarthing_scripts/tests/test_brownfield.py +842 -0
  122. package/pennyfarthing_scripts/tests/test_cli_modules.py +245 -0
  123. package/pennyfarthing_scripts/tests/test_common.py +180 -0
  124. package/pennyfarthing_scripts/tests/test_git_utils.py +866 -0
  125. package/pennyfarthing_scripts/tests/test_jira_package.py +334 -0
  126. package/pennyfarthing_scripts/tests/test_package_structure.py +372 -0
  127. package/pennyfarthing_scripts/tests/test_prime.py +397 -0
  128. package/pennyfarthing_scripts/tests/test_sprint_package.py +236 -0
  129. package/pennyfarthing_scripts/tests/test_sprint_validator.py +675 -0
  130. package/pennyfarthing_scripts/tests/test_story_package.py +156 -0
  131. package/pennyfarthing_scripts/welcome_hook.py +157 -0
  132. package/pennyfarthing_scripts/workflow.py +183 -0
@@ -0,0 +1,397 @@
1
+ """Tests for pennyfarthing_scripts.prime module.
2
+
3
+ Tests context loading for prime command.
4
+ """
5
+
6
+ import pytest
7
+ from pathlib import Path
8
+ from unittest.mock import patch, MagicMock
9
+
10
+ from pennyfarthing_scripts.prime.loader import (
11
+ load_agent_definition,
12
+ load_behavior_guide,
13
+ load_sprint_context,
14
+ load_session_context,
15
+ load_sidecars,
16
+ load_domain_docs,
17
+ _extract_session_parts,
18
+ _find_session_file,
19
+ )
20
+ from pennyfarthing_scripts.prime.cli import prime, main
21
+
22
+
23
+ class TestLoadAgentDefinition:
24
+ """Tests for load_agent_definition function."""
25
+
26
+ def test_load_existing_agent(self, tmp_path: Path) -> None:
27
+ """Test loading an existing agent definition."""
28
+ # Setup
29
+ agents_dir = tmp_path / ".pennyfarthing" / "agents"
30
+ agents_dir.mkdir(parents=True)
31
+ agent_file = agents_dir / "dev.md"
32
+ agent_file.write_text("# Developer Agent\n\nTest content")
33
+
34
+ # Test
35
+ result = load_agent_definition("dev", tmp_path)
36
+
37
+ # Verify
38
+ assert result is not None
39
+ assert "# Developer Agent" in result
40
+ assert "Test content" in result
41
+
42
+ def test_load_nonexistent_agent(self, tmp_path: Path) -> None:
43
+ """Test loading a non-existent agent returns None."""
44
+ # Setup
45
+ agents_dir = tmp_path / ".pennyfarthing" / "agents"
46
+ agents_dir.mkdir(parents=True)
47
+
48
+ # Test
49
+ result = load_agent_definition("nonexistent", tmp_path)
50
+
51
+ # Verify
52
+ assert result is None
53
+
54
+
55
+ class TestLoadBehaviorGuide:
56
+ """Tests for load_behavior_guide function."""
57
+
58
+ def test_load_existing_guide(self, tmp_path: Path) -> None:
59
+ """Test loading an existing behavior guide."""
60
+ # Setup
61
+ guides_dir = tmp_path / ".pennyfarthing" / "guides"
62
+ guides_dir.mkdir(parents=True)
63
+ guide_file = guides_dir / "agent-behavior.md"
64
+ guide_file.write_text("# Agent Behavior Guide\n\nShared protocols")
65
+
66
+ # Test
67
+ result = load_behavior_guide(tmp_path)
68
+
69
+ # Verify
70
+ assert result is not None
71
+ assert "# Agent Behavior Guide" in result
72
+
73
+ def test_load_nonexistent_guide(self, tmp_path: Path) -> None:
74
+ """Test loading a non-existent guide returns None."""
75
+ # Setup
76
+ guides_dir = tmp_path / ".pennyfarthing" / "guides"
77
+ guides_dir.mkdir(parents=True)
78
+
79
+ # Test
80
+ result = load_behavior_guide(tmp_path)
81
+
82
+ # Verify
83
+ assert result is None
84
+
85
+
86
+ class TestLoadSprintContext:
87
+ """Tests for load_sprint_context function."""
88
+
89
+ def test_load_sprint_context(self, tmp_path: Path, sample_sprint_data: dict) -> None:
90
+ """Test loading sprint context."""
91
+ import yaml
92
+
93
+ # Setup
94
+ sprint_dir = tmp_path / "sprint"
95
+ sprint_dir.mkdir()
96
+ sprint_file = sprint_dir / "current-sprint.yaml"
97
+ # Add sprint number for the test
98
+ sample_sprint_data["sprint"]["number"] = 2604
99
+ sample_sprint_data["sprint"]["goal"] = "Test Goal"
100
+ sprint_file.write_text(yaml.dump(sample_sprint_data))
101
+
102
+ # Also need .pennyfarthing dir for project root detection
103
+ pf_dir = tmp_path / ".pennyfarthing"
104
+ pf_dir.mkdir()
105
+
106
+ # Test
107
+ with patch("pennyfarthing_scripts.prime.loader.get_project_root", return_value=tmp_path):
108
+ with patch("pennyfarthing_scripts.sprint.loader.get_project_root", return_value=tmp_path):
109
+ result = load_sprint_context(tmp_path)
110
+
111
+ # Verify
112
+ assert result is not None
113
+ assert "Sprint 2604" in result
114
+
115
+ def test_no_sprint_file(self, tmp_path: Path) -> None:
116
+ """Test when no sprint file exists."""
117
+ # Setup - no sprint file
118
+ pf_dir = tmp_path / ".pennyfarthing"
119
+ pf_dir.mkdir()
120
+
121
+ # Test
122
+ result = load_sprint_context(tmp_path)
123
+
124
+ # Verify
125
+ assert result is None
126
+
127
+
128
+ class TestExtractSessionParts:
129
+ """Tests for _extract_session_parts function."""
130
+
131
+ def test_extract_header_and_assessment(self) -> None:
132
+ """Test extracting header and assessment from session file."""
133
+ content = """# Session: Test Story
134
+
135
+ Story: 63-1
136
+ Status: in_progress
137
+ Agent: dev
138
+
139
+ ## TEA Assessment
140
+
141
+ Tests written successfully.
142
+ Coverage: 85%
143
+
144
+ ## Dev Assessment
145
+
146
+ Implementation complete.
147
+ All tests passing.
148
+ """
149
+ header, assessment = _extract_session_parts(content)
150
+
151
+ # Header should be everything before first ##
152
+ assert "# Session: Test Story" in header
153
+ assert "Story: 63-1" in header
154
+ assert "Agent: dev" in header
155
+
156
+ # Assessment should be the LAST assessment section
157
+ assert "## Dev Assessment" in assessment
158
+ assert "Implementation complete" in assessment
159
+ # Should NOT include TEA assessment
160
+ assert "TEA Assessment" not in assessment
161
+
162
+ def test_extract_no_assessment(self) -> None:
163
+ """Test when no assessment section exists."""
164
+ content = """# Session: Test Story
165
+
166
+ Story: 63-1
167
+ Status: in_progress
168
+ """
169
+ header, assessment = _extract_session_parts(content)
170
+
171
+ assert "# Session: Test Story" in header
172
+ assert assessment == ""
173
+
174
+ def test_extract_header_only(self) -> None:
175
+ """Test file with only header content."""
176
+ content = "# Session Header\n\nSome metadata"
177
+
178
+ header, assessment = _extract_session_parts(content)
179
+
180
+ assert "# Session Header" in header
181
+ assert assessment == ""
182
+
183
+
184
+ class TestFindSessionFile:
185
+ """Tests for _find_session_file function."""
186
+
187
+ def test_find_session_file(self, tmp_path: Path) -> None:
188
+ """Test finding session file."""
189
+ # Setup
190
+ session_dir = tmp_path / ".session"
191
+ session_dir.mkdir()
192
+ session_file = session_dir / "63-1-session.md"
193
+ session_file.write_text("# Session")
194
+
195
+ # Test
196
+ result = _find_session_file(tmp_path)
197
+
198
+ # Verify
199
+ assert result is not None
200
+ assert result.name == "63-1-session.md"
201
+
202
+ def test_no_session_directory(self, tmp_path: Path) -> None:
203
+ """Test when no session directory exists."""
204
+ result = _find_session_file(tmp_path)
205
+ assert result is None
206
+
207
+ def test_empty_session_directory(self, tmp_path: Path) -> None:
208
+ """Test when session directory is empty."""
209
+ session_dir = tmp_path / ".session"
210
+ session_dir.mkdir()
211
+
212
+ result = _find_session_file(tmp_path)
213
+ assert result is None
214
+
215
+
216
+ class TestLoadSessionContext:
217
+ """Tests for load_session_context function."""
218
+
219
+ def test_load_session_context(self, tmp_path: Path) -> None:
220
+ """Test loading full session context."""
221
+ # Setup
222
+ pf_dir = tmp_path / ".pennyfarthing"
223
+ pf_dir.mkdir()
224
+ session_dir = tmp_path / ".session"
225
+ session_dir.mkdir()
226
+ session_file = session_dir / "63-1-session.md"
227
+ session_file.write_text("""# Session: Story 63-1
228
+
229
+ Story: 63-1
230
+ Status: in_progress
231
+
232
+ ## Dev Assessment
233
+
234
+ Work complete.
235
+ """)
236
+
237
+ # Test
238
+ with patch("pennyfarthing_scripts.prime.loader.get_project_root", return_value=tmp_path):
239
+ result = load_session_context(tmp_path)
240
+
241
+ # Verify
242
+ assert result is not None
243
+ filename, header, assessment = result
244
+ assert filename == "63-1-session.md"
245
+ assert "# Session: Story 63-1" in header
246
+ assert "## Dev Assessment" in assessment
247
+
248
+
249
+ class TestLoadSidecars:
250
+ """Tests for load_sidecars function."""
251
+
252
+ def test_load_all_sidecars(self, tmp_path: Path) -> None:
253
+ """Test loading all sidecar files."""
254
+ # Setup
255
+ sidecar_dir = tmp_path / ".pennyfarthing" / "sidecars" / "dev"
256
+ sidecar_dir.mkdir(parents=True)
257
+ (sidecar_dir / "patterns.md").write_text("# Patterns")
258
+ (sidecar_dir / "gotchas.md").write_text("# Gotchas")
259
+ (sidecar_dir / "decisions.md").write_text("# Decisions")
260
+
261
+ # Test
262
+ result = load_sidecars("dev", tmp_path)
263
+
264
+ # Verify
265
+ assert len(result) == 3
266
+ assert "# Patterns" in result["patterns.md"]
267
+ assert "# Gotchas" in result["gotchas.md"]
268
+ assert "# Decisions" in result["decisions.md"]
269
+
270
+ def test_load_partial_sidecars(self, tmp_path: Path) -> None:
271
+ """Test loading when only some sidecars exist."""
272
+ # Setup
273
+ sidecar_dir = tmp_path / ".pennyfarthing" / "sidecars" / "dev"
274
+ sidecar_dir.mkdir(parents=True)
275
+ (sidecar_dir / "patterns.md").write_text("# Patterns only")
276
+
277
+ # Test
278
+ result = load_sidecars("dev", tmp_path)
279
+
280
+ # Verify
281
+ assert len(result) == 1
282
+ assert "patterns.md" in result
283
+
284
+ def test_no_sidecar_directory(self, tmp_path: Path) -> None:
285
+ """Test when no sidecar directory exists."""
286
+ result = load_sidecars("dev", tmp_path)
287
+ assert result == {}
288
+
289
+
290
+ class TestLoadDomainDocs:
291
+ """Tests for load_domain_docs function."""
292
+
293
+ def test_load_domain_docs(self, tmp_path: Path) -> None:
294
+ """Test loading domain documentation."""
295
+ # Setup
296
+ project_dir = tmp_path / ".claude" / "project"
297
+ project_dir.mkdir(parents=True)
298
+ (project_dir / "CLAUDE-api.md").write_text("# API Docs")
299
+ (project_dir / "CLAUDE-testing.md").write_text("# Testing Docs")
300
+ (project_dir / "other-file.md").write_text("# Not included")
301
+
302
+ # Test
303
+ result = load_domain_docs(tmp_path)
304
+
305
+ # Verify
306
+ assert len(result) == 2
307
+ filenames = [f for f, _ in result]
308
+ assert "CLAUDE-api.md" in filenames
309
+ assert "CLAUDE-testing.md" in filenames
310
+ assert "other-file.md" not in filenames
311
+
312
+ def test_no_domain_docs_directory(self, tmp_path: Path) -> None:
313
+ """Test when no .claude/project directory exists."""
314
+ result = load_domain_docs(tmp_path)
315
+ assert result == []
316
+
317
+
318
+ class TestPrimeFunction:
319
+ """Tests for the prime() function."""
320
+
321
+ def test_minimal_mode(self, tmp_path: Path, capsys) -> None:
322
+ """Test minimal mode returns immediately."""
323
+ result = prime(minimal=True, project_root=tmp_path)
324
+
325
+ assert result == 0
326
+ captured = capsys.readouterr()
327
+ assert captured.out == ""
328
+
329
+ def test_quiet_suppresses_headers(self, tmp_path: Path, capsys) -> None:
330
+ """Test quiet mode suppresses headers but shows content."""
331
+ # Setup
332
+ pf_dir = tmp_path / ".pennyfarthing"
333
+ pf_dir.mkdir()
334
+ agents_dir = pf_dir / "agents"
335
+ agents_dir.mkdir()
336
+ (agents_dir / "dev.md").write_text("Agent content here")
337
+
338
+ # Test
339
+ with patch("pennyfarthing_scripts.prime.cli.get_project_root", return_value=tmp_path):
340
+ result = prime(agent_name="dev", quiet=True, project_root=tmp_path)
341
+
342
+ # Verify
343
+ assert result == 0
344
+ captured = capsys.readouterr()
345
+ assert "Agent content here" in captured.out
346
+ assert "# Agent Definition" not in captured.out
347
+
348
+
349
+ class TestMainCLI:
350
+ """Tests for CLI main() function."""
351
+
352
+ def test_help_flag(self) -> None:
353
+ """Test --help flag."""
354
+ with pytest.raises(SystemExit) as exc_info:
355
+ main(["--help"])
356
+ assert exc_info.value.code == 0
357
+
358
+ def test_minimal_flag(self, capsys) -> None:
359
+ """Test --minimal flag."""
360
+ # With minimal, should just return 0 without any output
361
+ with patch("pennyfarthing_scripts.prime.cli.get_project_root") as mock_root:
362
+ mock_root.return_value = Path("/tmp/test")
363
+ result = main(["--minimal"])
364
+
365
+ assert result == 0
366
+ captured = capsys.readouterr()
367
+ assert captured.out == ""
368
+
369
+ def test_agent_flag(self, tmp_path: Path, capsys) -> None:
370
+ """Test --agent flag."""
371
+ # Setup
372
+ pf_dir = tmp_path / ".pennyfarthing"
373
+ pf_dir.mkdir()
374
+ agents_dir = pf_dir / "agents"
375
+ agents_dir.mkdir()
376
+ (agents_dir / "tea.md").write_text("# TEA Agent\nTest content")
377
+
378
+ # Test
379
+ with patch("pennyfarthing_scripts.prime.cli.get_project_root", return_value=tmp_path):
380
+ result = main(["--agent", "tea"])
381
+
382
+ # Verify
383
+ assert result == 0
384
+ captured = capsys.readouterr()
385
+ assert "# TEA Agent" in captured.out
386
+
387
+ def test_project_not_found_error(self, capsys) -> None:
388
+ """Test error handling when project root not found."""
389
+ with patch(
390
+ "pennyfarthing_scripts.prime.cli.get_project_root",
391
+ side_effect=FileNotFoundError("No project found"),
392
+ ):
393
+ result = main([])
394
+
395
+ assert result == 1
396
+ captured = capsys.readouterr()
397
+ assert "Error" in captured.err
@@ -0,0 +1,236 @@
1
+ """Tests for sprint/ library package.
2
+
3
+ Story 63-9: Reorganize pennyfarthing_scripts into fan-out CLI pattern.
4
+
5
+ These tests verify the sprint/ package modules work correctly
6
+ after reorganization from flat modules.
7
+ """
8
+
9
+ from pathlib import Path
10
+ from typing import Any
11
+ from unittest.mock import MagicMock, patch
12
+
13
+ import pytest
14
+
15
+
16
+ class TestSprintLoader:
17
+ """Tests for sprint/loader.py module."""
18
+
19
+ def test_load_sprint_returns_dict(self) -> None:
20
+ """load_sprint should return sprint data as dict."""
21
+ from pennyfarthing_scripts.sprint.loader import load_sprint
22
+
23
+ result = load_sprint()
24
+
25
+ # In a project with sprint data, should return dict
26
+ # May return None if no sprint file exists
27
+ assert result is None or isinstance(result, dict)
28
+
29
+ def test_load_sprint_with_custom_root(self) -> None:
30
+ """load_sprint should accept custom project root."""
31
+ from pennyfarthing_scripts.sprint.loader import load_sprint
32
+ from pennyfarthing_scripts.common.config import get_project_root
33
+
34
+ root = get_project_root()
35
+ result = load_sprint(project_root=root)
36
+
37
+ assert result is None or isinstance(result, dict)
38
+
39
+ def test_find_epic_by_number(self) -> None:
40
+ """find_epic should find epic by number."""
41
+ from pennyfarthing_scripts.sprint.loader import find_epic
42
+
43
+ sprint_data = {
44
+ "epics": [
45
+ {"id": "epic-63", "title": "Test Epic"},
46
+ {"id": "epic-64", "title": "Another Epic"},
47
+ ]
48
+ }
49
+
50
+ # Find by full ID
51
+ epic = find_epic(sprint_data, "epic-63")
52
+ assert epic is not None
53
+ assert epic["title"] == "Test Epic"
54
+
55
+ # Find by number only
56
+ epic = find_epic(sprint_data, "63")
57
+ assert epic is not None
58
+ assert epic["title"] == "Test Epic"
59
+
60
+ def test_find_epic_returns_none_if_not_found(self) -> None:
61
+ """find_epic should return None if epic not found."""
62
+ from pennyfarthing_scripts.sprint.loader import find_epic
63
+
64
+ sprint_data = {"epics": [{"id": "epic-63", "title": "Test Epic"}]}
65
+
66
+ result = find_epic(sprint_data, "epic-99")
67
+ assert result is None
68
+
69
+ def test_find_epic_handles_empty_data(self) -> None:
70
+ """find_epic should handle empty or None data."""
71
+ from pennyfarthing_scripts.sprint.loader import find_epic
72
+
73
+ assert find_epic(None, "63") is None
74
+ assert find_epic({}, "63") is None
75
+ assert find_epic({"epics": []}, "63") is None
76
+
77
+ def test_find_story_in_epic(self) -> None:
78
+ """find_story should find story within an epic."""
79
+ from pennyfarthing_scripts.sprint.loader import find_story
80
+
81
+ epic = {
82
+ "id": "epic-63",
83
+ "stories": [
84
+ {"id": "63-1", "title": "First Story"},
85
+ {"id": "63-2", "title": "Second Story"},
86
+ ],
87
+ }
88
+
89
+ story = find_story(epic, "63-1")
90
+ assert story is not None
91
+ assert story["title"] == "First Story"
92
+
93
+ def test_find_story_returns_none_if_not_found(self) -> None:
94
+ """find_story should return None if story not found."""
95
+ from pennyfarthing_scripts.sprint.loader import find_story
96
+
97
+ epic = {"id": "epic-63", "stories": [{"id": "63-1", "title": "Story"}]}
98
+
99
+ result = find_story(epic, "63-99")
100
+ assert result is None
101
+
102
+ def test_get_all_stories_returns_flat_list(self) -> None:
103
+ """get_all_stories should return flat list from all epics."""
104
+ from pennyfarthing_scripts.sprint.loader import get_all_stories
105
+
106
+ # With actual sprint data loaded
107
+ stories = get_all_stories()
108
+
109
+ # Should be a list (may be empty if no sprint data)
110
+ assert isinstance(stories, list)
111
+
112
+ def test_get_story_by_id(self) -> None:
113
+ """get_story_by_id should find story across all epics."""
114
+ from pennyfarthing_scripts.sprint.loader import get_story_by_id
115
+
116
+ # This relies on actual sprint data
117
+ result = get_story_by_id("nonexistent-99")
118
+ assert result is None # Should not find nonexistent story
119
+
120
+ def test_get_stories_by_status(self) -> None:
121
+ """get_stories_by_status should filter by status."""
122
+ from pennyfarthing_scripts.sprint.loader import get_stories_by_status
123
+
124
+ result = get_stories_by_status("backlog")
125
+ assert isinstance(result, list)
126
+
127
+ def test_get_story_field(self) -> None:
128
+ """get_story_field should extract field from story."""
129
+ from pennyfarthing_scripts.sprint.loader import get_story_field
130
+
131
+ sprint_data = {
132
+ "epics": [
133
+ {
134
+ "id": "epic-63",
135
+ "stories": [
136
+ {"id": "63-1", "status": "in_progress", "points": 3}
137
+ ],
138
+ }
139
+ ]
140
+ }
141
+
142
+ assert get_story_field(sprint_data, "63-1", "status") == "in_progress"
143
+ assert get_story_field(sprint_data, "63-1", "points") == 3
144
+ assert get_story_field(sprint_data, "63-1", "nonexistent") is None
145
+
146
+ def test_load_current_sprint_alias(self) -> None:
147
+ """load_current_sprint should be alias for load_sprint."""
148
+ from pennyfarthing_scripts.sprint.loader import (
149
+ load_current_sprint,
150
+ load_sprint,
151
+ )
152
+
153
+ # Should be the same function
154
+ assert load_current_sprint == load_sprint
155
+
156
+ def test_get_sprint_info(self) -> None:
157
+ """get_sprint_info should return sprint metadata."""
158
+ from pennyfarthing_scripts.sprint.loader import get_sprint_info
159
+
160
+ result = get_sprint_info()
161
+ assert isinstance(result, dict)
162
+
163
+ def test_get_epic_by_id(self) -> None:
164
+ """get_epic_by_id should find epic by ID."""
165
+ from pennyfarthing_scripts.sprint.loader import get_epic_by_id
166
+
167
+ # Test with nonexistent ID
168
+ result = get_epic_by_id("nonexistent-epic")
169
+ assert result is None
170
+
171
+
172
+ class TestSprintStatus:
173
+ """Tests for sprint/status.py module."""
174
+
175
+ def test_get_sprint_status_returns_dict(self) -> None:
176
+ """get_sprint_status should return status information."""
177
+ from pennyfarthing_scripts.sprint.status import get_sprint_status
178
+
179
+ result = get_sprint_status()
180
+
181
+ assert isinstance(result, dict)
182
+ # Should have expected keys
183
+ assert "total_stories" in result or result == {}
184
+ assert "completed" in result or result == {}
185
+
186
+ def test_format_status(self) -> None:
187
+ """format_status should return formatted string."""
188
+ from pennyfarthing_scripts.sprint.status import format_status
189
+
190
+ status = {
191
+ "total_stories": 10,
192
+ "completed": 3,
193
+ "in_progress": 2,
194
+ "backlog": 5,
195
+ }
196
+ result = format_status(status)
197
+
198
+ assert isinstance(result, str)
199
+ # Should contain numbers
200
+ assert "10" in result or "3" in result
201
+
202
+
203
+ class TestSprintWork:
204
+ """Tests for sprint/work.py module."""
205
+
206
+ def test_check_story_returns_availability(self) -> None:
207
+ """check_story should return story availability info."""
208
+ from pennyfarthing_scripts.sprint.work import check_story
209
+
210
+ result = check_story("nonexistent-99")
211
+
212
+ assert isinstance(result, dict)
213
+ assert "available" in result or "error" in result
214
+
215
+ def test_start_work_validates_story(self) -> None:
216
+ """start_work should validate story exists."""
217
+ from pennyfarthing_scripts.sprint.work import start_work
218
+
219
+ result = start_work("nonexistent-99", dry_run=True)
220
+
221
+ assert isinstance(result, dict)
222
+ assert result.get("success") is False or "error" in result
223
+
224
+
225
+ class TestSprintArchive:
226
+ """Tests for sprint/archive.py module."""
227
+
228
+ def test_archive_story_validates_story(self) -> None:
229
+ """archive_story should validate story exists."""
230
+ from pennyfarthing_scripts.sprint.archive import archive_story
231
+
232
+ result = archive_story("nonexistent-99", dry_run=True)
233
+
234
+ assert isinstance(result, dict)
235
+ # Should fail for nonexistent story
236
+ assert result.get("success") is False or "error" in result