@pennyfarthing/core 7.8.1 → 7.8.4

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 (178) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -1
  3. package/packages/core/dist/cli/commands/init.d.ts.map +1 -1
  4. package/packages/core/dist/cli/commands/init.js +7 -6
  5. package/packages/core/dist/cli/commands/init.js.map +1 -1
  6. package/packages/core/dist/cli/utils/symlinks.d.ts +7 -0
  7. package/packages/core/dist/cli/utils/symlinks.d.ts.map +1 -1
  8. package/packages/core/dist/cli/utils/symlinks.js +25 -0
  9. package/packages/core/dist/cli/utils/symlinks.js.map +1 -1
  10. package/pennyfarthing-dist/scripts/core/phase-check-start.sh +1 -1
  11. package/pennyfarthing-dist/scripts/core/prime.sh +23 -0
  12. package/pennyfarthing-dist/scripts/core/run.sh +5 -5
  13. package/pennyfarthing-dist/scripts/git/install-git-hooks.sh +2 -2
  14. package/pennyfarthing-dist/scripts/git/release.sh +2 -2
  15. package/pennyfarthing-dist/scripts/health/drift-detection.sh +1 -1
  16. package/pennyfarthing-dist/scripts/hooks/post-merge.sh +2 -2
  17. package/pennyfarthing-dist/scripts/hooks/pre-push.sh +2 -2
  18. package/pennyfarthing-dist/scripts/hooks/session-stop.sh +1 -1
  19. package/pennyfarthing-dist/scripts/jira/create-jira-epic.sh +1 -1
  20. package/pennyfarthing-dist/scripts/jira/create-jira-story.sh +1 -1
  21. package/pennyfarthing-dist/scripts/jira/jira-reconcile.sh +1 -1
  22. package/pennyfarthing-dist/scripts/lib/common.sh +1 -1
  23. package/pennyfarthing-dist/scripts/lib/find-root.sh +4 -4
  24. package/pennyfarthing-dist/scripts/maintenance/sidecar-health.sh +1 -1
  25. package/pennyfarthing-dist/scripts/misc/add_short_names.py +2 -2
  26. package/pennyfarthing-dist/scripts/misc/backlog.sh +2 -2
  27. package/pennyfarthing-dist/scripts/misc/deploy.sh +2 -2
  28. package/pennyfarthing-dist/scripts/misc/generate-skill-docs.sh +4 -4
  29. package/pennyfarthing-dist/scripts/misc/log-skill-usage.sh +2 -2
  30. package/pennyfarthing-dist/scripts/misc/run-ci.sh +1 -1
  31. package/pennyfarthing-dist/scripts/misc/skill-usage-report.sh +2 -2
  32. package/pennyfarthing-dist/scripts/sprint/archive-story.sh +1 -1
  33. package/pennyfarthing-dist/scripts/sprint/available-stories.sh +1 -1
  34. package/pennyfarthing-dist/scripts/sprint/check-story.sh +1 -1
  35. package/pennyfarthing-dist/scripts/sprint/get-epic-field.sh +1 -1
  36. package/pennyfarthing-dist/scripts/sprint/get-story-field.sh +1 -1
  37. package/pennyfarthing-dist/scripts/sprint/import_epic_to_future.py +2 -2
  38. package/pennyfarthing-dist/scripts/sprint/list-future.sh +1 -1
  39. package/pennyfarthing-dist/scripts/sprint/new-sprint.sh +1 -1
  40. package/pennyfarthing-dist/scripts/sprint/promote-epic.sh +1 -1
  41. package/pennyfarthing-dist/scripts/sprint/sprint-common.sh +3 -3
  42. package/pennyfarthing-dist/scripts/sprint/sprint-info.sh +1 -1
  43. package/pennyfarthing-dist/scripts/sprint/sprint-metrics.sh +2 -2
  44. package/pennyfarthing-dist/scripts/theme/compute_theme_tiers.py +2 -2
  45. package/pennyfarthing-dist/scripts/workflow/check.py +2 -2
  46. package/pennyfarthing-dist/scripts/workflow/finish-story.sh +1 -1
  47. package/pennyfarthing-dist/scripts/workflow/fix-session-phase.sh +1 -1
  48. package/pennyfarthing-dist/scripts/workflow/get-workflow-type.py +2 -2
  49. package/pennyfarthing-dist/scripts/workflow/list-workflows.sh +1 -1
  50. package/pennyfarthing-dist/scripts/workflow/phase-owner.sh +1 -1
  51. package/pennyfarthing-dist/scripts/workflow/resume-workflow.sh +1 -1
  52. package/pennyfarthing-dist/scripts/workflow/show-workflow.sh +1 -1
  53. package/pennyfarthing-dist/scripts/workflow/start-workflow.sh +1 -1
  54. package/pennyfarthing-dist/scripts/workflow/workflow-status.sh +1 -1
  55. package/pennyfarthing_scripts/__init__.py +17 -0
  56. package/pennyfarthing_scripts/__pycache__/__init__.cpython-311.pyc +0 -0
  57. package/pennyfarthing_scripts/__pycache__/__init__.cpython-314.pyc +0 -0
  58. package/pennyfarthing_scripts/__pycache__/config.cpython-314.pyc +0 -0
  59. package/pennyfarthing_scripts/__pycache__/jira.cpython-314.pyc +0 -0
  60. package/pennyfarthing_scripts/__pycache__/jira_epic_creation.cpython-314.pyc +0 -0
  61. package/pennyfarthing_scripts/__pycache__/jira_sync.cpython-314.pyc +0 -0
  62. package/pennyfarthing_scripts/__pycache__/jira_sync_story.cpython-314.pyc +0 -0
  63. package/pennyfarthing_scripts/__pycache__/sprint.cpython-314.pyc +0 -0
  64. package/pennyfarthing_scripts/__pycache__/workflow.cpython-311.pyc +0 -0
  65. package/pennyfarthing_scripts/__pycache__/workflow.cpython-314.pyc +0 -0
  66. package/pennyfarthing_scripts/bellmode_hook.py +154 -0
  67. package/pennyfarthing_scripts/brownfield/__init__.py +35 -0
  68. package/pennyfarthing_scripts/brownfield/__main__.py +7 -0
  69. package/pennyfarthing_scripts/brownfield/__pycache__/__init__.cpython-314.pyc +0 -0
  70. package/pennyfarthing_scripts/brownfield/__pycache__/__main__.cpython-314.pyc +0 -0
  71. package/pennyfarthing_scripts/brownfield/__pycache__/cli.cpython-314.pyc +0 -0
  72. package/pennyfarthing_scripts/brownfield/__pycache__/discover.cpython-314.pyc +0 -0
  73. package/pennyfarthing_scripts/brownfield/cli.py +131 -0
  74. package/pennyfarthing_scripts/brownfield/discover.py +753 -0
  75. package/pennyfarthing_scripts/common/__init__.py +49 -0
  76. package/pennyfarthing_scripts/common/__pycache__/__init__.cpython-314.pyc +0 -0
  77. package/pennyfarthing_scripts/common/__pycache__/config.cpython-314.pyc +0 -0
  78. package/pennyfarthing_scripts/common/__pycache__/output.cpython-314.pyc +0 -0
  79. package/pennyfarthing_scripts/common/config.py +65 -0
  80. package/pennyfarthing_scripts/common/output.py +180 -0
  81. package/pennyfarthing_scripts/config.py +21 -0
  82. package/pennyfarthing_scripts/git/__init__.py +29 -0
  83. package/pennyfarthing_scripts/git/__pycache__/__init__.cpython-314.pyc +0 -0
  84. package/pennyfarthing_scripts/git/__pycache__/create_branches.cpython-314.pyc +0 -0
  85. package/pennyfarthing_scripts/git/__pycache__/status_all.cpython-314.pyc +0 -0
  86. package/pennyfarthing_scripts/git/create_branches.py +439 -0
  87. package/pennyfarthing_scripts/git/status_all.py +310 -0
  88. package/pennyfarthing_scripts/hooks.py +455 -0
  89. package/pennyfarthing_scripts/jira/__init__.py +93 -0
  90. package/pennyfarthing_scripts/jira/__main__.py +10 -0
  91. package/pennyfarthing_scripts/jira/__pycache__/__init__.cpython-314.pyc +0 -0
  92. package/pennyfarthing_scripts/jira/__pycache__/__main__.cpython-314.pyc +0 -0
  93. package/pennyfarthing_scripts/jira/__pycache__/bidirectional.cpython-314.pyc +0 -0
  94. package/pennyfarthing_scripts/jira/__pycache__/claim.cpython-314.pyc +0 -0
  95. package/pennyfarthing_scripts/jira/__pycache__/cli.cpython-314.pyc +0 -0
  96. package/pennyfarthing_scripts/jira/__pycache__/client.cpython-314.pyc +0 -0
  97. package/pennyfarthing_scripts/jira/__pycache__/compat.cpython-314.pyc +0 -0
  98. package/pennyfarthing_scripts/jira/__pycache__/epic.cpython-314.pyc +0 -0
  99. package/pennyfarthing_scripts/jira/__pycache__/mappings.cpython-314.pyc +0 -0
  100. package/pennyfarthing_scripts/jira/__pycache__/models.cpython-314.pyc +0 -0
  101. package/pennyfarthing_scripts/jira/__pycache__/story.cpython-314.pyc +0 -0
  102. package/pennyfarthing_scripts/jira/__pycache__/sync.cpython-314.pyc +0 -0
  103. package/pennyfarthing_scripts/jira/bidirectional.py +561 -0
  104. package/pennyfarthing_scripts/jira/claim.py +211 -0
  105. package/pennyfarthing_scripts/jira/cli.py +150 -0
  106. package/pennyfarthing_scripts/jira/client.py +613 -0
  107. package/pennyfarthing_scripts/jira/epic.py +176 -0
  108. package/pennyfarthing_scripts/jira/story.py +219 -0
  109. package/pennyfarthing_scripts/jira/sync.py +350 -0
  110. package/pennyfarthing_scripts/jira_bidirectional_sync.py +37 -0
  111. package/pennyfarthing_scripts/jira_epic_creation.py +30 -0
  112. package/pennyfarthing_scripts/jira_sync.py +36 -0
  113. package/pennyfarthing_scripts/jira_sync_story.py +30 -0
  114. package/pennyfarthing_scripts/output.py +37 -0
  115. package/pennyfarthing_scripts/preflight/__init__.py +17 -0
  116. package/pennyfarthing_scripts/preflight/__main__.py +10 -0
  117. package/pennyfarthing_scripts/preflight/__pycache__/__init__.cpython-314.pyc +0 -0
  118. package/pennyfarthing_scripts/preflight/__pycache__/__main__.cpython-314.pyc +0 -0
  119. package/pennyfarthing_scripts/preflight/__pycache__/cli.cpython-314.pyc +0 -0
  120. package/pennyfarthing_scripts/preflight/__pycache__/finish.cpython-314.pyc +0 -0
  121. package/pennyfarthing_scripts/preflight/cli.py +141 -0
  122. package/pennyfarthing_scripts/preflight/finish.py +382 -0
  123. package/pennyfarthing_scripts/pretooluse_hook.py +142 -0
  124. package/pennyfarthing_scripts/prime/__init__.py +38 -0
  125. package/pennyfarthing_scripts/prime/__main__.py +8 -0
  126. package/pennyfarthing_scripts/prime/__pycache__/__init__.cpython-314.pyc +0 -0
  127. package/pennyfarthing_scripts/prime/__pycache__/__main__.cpython-314.pyc +0 -0
  128. package/pennyfarthing_scripts/prime/__pycache__/cli.cpython-314.pyc +0 -0
  129. package/pennyfarthing_scripts/prime/__pycache__/loader.cpython-314.pyc +0 -0
  130. package/pennyfarthing_scripts/prime/__pycache__/models.cpython-314.pyc +0 -0
  131. package/pennyfarthing_scripts/prime/__pycache__/persona.cpython-314.pyc +0 -0
  132. package/pennyfarthing_scripts/prime/__pycache__/session.cpython-314.pyc +0 -0
  133. package/pennyfarthing_scripts/prime/__pycache__/workflow.cpython-314.pyc +0 -0
  134. package/pennyfarthing_scripts/prime/cli.py +220 -0
  135. package/pennyfarthing_scripts/prime/loader.py +239 -0
  136. package/pennyfarthing_scripts/sprint/__init__.py +66 -0
  137. package/pennyfarthing_scripts/sprint/__main__.py +10 -0
  138. package/pennyfarthing_scripts/sprint/__pycache__/__init__.cpython-314.pyc +0 -0
  139. package/pennyfarthing_scripts/sprint/__pycache__/__main__.cpython-314.pyc +0 -0
  140. package/pennyfarthing_scripts/sprint/__pycache__/archive.cpython-314.pyc +0 -0
  141. package/pennyfarthing_scripts/sprint/__pycache__/cli.cpython-314.pyc +0 -0
  142. package/pennyfarthing_scripts/sprint/__pycache__/loader.cpython-314.pyc +0 -0
  143. package/pennyfarthing_scripts/sprint/__pycache__/status.cpython-314.pyc +0 -0
  144. package/pennyfarthing_scripts/sprint/__pycache__/validator.cpython-314.pyc +0 -0
  145. package/pennyfarthing_scripts/sprint/__pycache__/work.cpython-314.pyc +0 -0
  146. package/pennyfarthing_scripts/sprint/archive.py +108 -0
  147. package/pennyfarthing_scripts/sprint/cli.py +124 -0
  148. package/pennyfarthing_scripts/sprint/loader.py +193 -0
  149. package/pennyfarthing_scripts/sprint/status.py +122 -0
  150. package/pennyfarthing_scripts/sprint/validator.py +405 -0
  151. package/pennyfarthing_scripts/sprint/work.py +192 -0
  152. package/pennyfarthing_scripts/story/__init__.py +67 -0
  153. package/pennyfarthing_scripts/story/__main__.py +10 -0
  154. package/pennyfarthing_scripts/story/cli.py +105 -0
  155. package/pennyfarthing_scripts/story/create.py +167 -0
  156. package/pennyfarthing_scripts/story/size.py +113 -0
  157. package/pennyfarthing_scripts/story/template.py +151 -0
  158. package/pennyfarthing_scripts/swebench.py +216 -0
  159. package/pennyfarthing_scripts/tests/__init__.py +1 -0
  160. package/pennyfarthing_scripts/tests/__pycache__/__init__.cpython-314.pyc +0 -0
  161. package/pennyfarthing_scripts/tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc +0 -0
  162. package/pennyfarthing_scripts/tests/__pycache__/test_brownfield.cpython-314-pytest-9.0.2.pyc +0 -0
  163. package/pennyfarthing_scripts/tests/__pycache__/test_git_utils.cpython-314-pytest-9.0.2.pyc +0 -0
  164. package/pennyfarthing_scripts/tests/__pycache__/test_prime.cpython-314-pytest-9.0.2.pyc +0 -0
  165. package/pennyfarthing_scripts/tests/__pycache__/test_sprint_validator.cpython-314-pytest-9.0.2.pyc +0 -0
  166. package/pennyfarthing_scripts/tests/conftest.py +106 -0
  167. package/pennyfarthing_scripts/tests/test_brownfield.py +842 -0
  168. package/pennyfarthing_scripts/tests/test_cli_modules.py +245 -0
  169. package/pennyfarthing_scripts/tests/test_common.py +180 -0
  170. package/pennyfarthing_scripts/tests/test_git_utils.py +866 -0
  171. package/pennyfarthing_scripts/tests/test_jira_package.py +334 -0
  172. package/pennyfarthing_scripts/tests/test_package_structure.py +372 -0
  173. package/pennyfarthing_scripts/tests/test_prime.py +397 -0
  174. package/pennyfarthing_scripts/tests/test_sprint_package.py +236 -0
  175. package/pennyfarthing_scripts/tests/test_sprint_validator.py +675 -0
  176. package/pennyfarthing_scripts/tests/test_story_package.py +156 -0
  177. package/pennyfarthing_scripts/welcome_hook.py +157 -0
  178. package/pennyfarthing_scripts/workflow.py +183 -0
@@ -0,0 +1,156 @@
1
+ """Tests for story/ library package.
2
+
3
+ Story 63-9: Reorganize pennyfarthing_scripts into fan-out CLI pattern.
4
+
5
+ These tests verify the story/ package modules work correctly.
6
+ """
7
+
8
+ from typing import Any
9
+ from unittest.mock import patch
10
+
11
+ import pytest
12
+
13
+
14
+ class TestStorySizeModule:
15
+ """Tests for story/size.py module."""
16
+
17
+ def test_get_sizing_guidelines_returns_dict(self) -> None:
18
+ """get_sizing_guidelines should return sizing info."""
19
+ from pennyfarthing_scripts.story.size import get_sizing_guidelines
20
+
21
+ result = get_sizing_guidelines()
22
+
23
+ assert isinstance(result, dict)
24
+ # Should have point values as keys
25
+ assert 1 in result or "1" in result or len(result) > 0
26
+
27
+ def test_get_sizing_guidelines_for_specific_points(self) -> None:
28
+ """get_sizing_guidelines should filter by points."""
29
+ from pennyfarthing_scripts.story.size import get_sizing_guidelines
30
+
31
+ result = get_sizing_guidelines(points=3)
32
+
33
+ assert isinstance(result, dict)
34
+
35
+ def test_format_size_info(self) -> None:
36
+ """format_size_info should return formatted string."""
37
+ from pennyfarthing_scripts.story.size import format_size_info
38
+
39
+ size_info = {
40
+ 3: {
41
+ "scale": "Small",
42
+ "complexity": "Few files, some testing",
43
+ "examples": ["Validation", "single component"],
44
+ }
45
+ }
46
+ result = format_size_info(size_info)
47
+
48
+ assert isinstance(result, str)
49
+ assert "Small" in result or "3" in result
50
+
51
+
52
+ class TestStoryTemplateModule:
53
+ """Tests for story/template.py module."""
54
+
55
+ def test_get_template_feature(self) -> None:
56
+ """get_template should return feature template."""
57
+ from pennyfarthing_scripts.story.template import get_template
58
+
59
+ result = get_template("feature")
60
+
61
+ assert isinstance(result, dict) or isinstance(result, str)
62
+ # Should have template content
63
+
64
+ def test_get_template_bug(self) -> None:
65
+ """get_template should return bug template."""
66
+ from pennyfarthing_scripts.story.template import get_template
67
+
68
+ result = get_template("bug")
69
+
70
+ assert isinstance(result, dict) or isinstance(result, str)
71
+
72
+ def test_get_template_unknown_returns_default(self) -> None:
73
+ """get_template should return default for unknown type."""
74
+ from pennyfarthing_scripts.story.template import get_template
75
+
76
+ result = get_template("unknown_type")
77
+
78
+ # Should return None or default template
79
+ assert result is None or isinstance(result, (dict, str))
80
+
81
+ def test_get_all_templates(self) -> None:
82
+ """get_all_templates should return all available templates."""
83
+ from pennyfarthing_scripts.story.template import get_all_templates
84
+
85
+ result = get_all_templates()
86
+
87
+ assert isinstance(result, dict)
88
+ # Should have standard types
89
+ assert "feature" in result or len(result) > 0
90
+
91
+
92
+ class TestStoryCreateModule:
93
+ """Tests for story/create.py module."""
94
+
95
+ def test_generate_story_yaml(self) -> None:
96
+ """generate_story_yaml should create valid YAML block."""
97
+ from pennyfarthing_scripts.story.create import generate_story_yaml
98
+
99
+ result = generate_story_yaml(
100
+ epic_id="MSSCI-11952",
101
+ title="Add error handling",
102
+ points=3,
103
+ story_type="feature",
104
+ )
105
+
106
+ assert isinstance(result, str)
107
+ # Should contain key fields
108
+ assert "title" in result or "Add error handling" in result
109
+ assert "points" in result or "3" in result
110
+
111
+ def test_generate_story_yaml_with_options(self) -> None:
112
+ """generate_story_yaml should support optional parameters."""
113
+ from pennyfarthing_scripts.story.create import generate_story_yaml
114
+
115
+ result = generate_story_yaml(
116
+ epic_id="MSSCI-11952",
117
+ title="Bug fix",
118
+ points=2,
119
+ story_type="bug",
120
+ priority="P1",
121
+ workflow="tdd",
122
+ )
123
+
124
+ assert isinstance(result, str)
125
+ # Should contain type and priority
126
+ assert "bug" in result.lower() or "P1" in result
127
+
128
+ def test_create_story_validates_points(self) -> None:
129
+ """create_story should validate point values."""
130
+ from pennyfarthing_scripts.story.create import create_story
131
+
132
+ # Invalid points should fail or warn
133
+ result = create_story(
134
+ epic_id="MSSCI-11952",
135
+ title="Test",
136
+ points=100, # Invalid - too high
137
+ dry_run=True,
138
+ )
139
+
140
+ assert isinstance(result, dict)
141
+ # May succeed with warning or fail
142
+ assert "success" in result or "error" in result or "warning" in result
143
+
144
+ def test_create_story_dry_run(self) -> None:
145
+ """create_story with dry_run should not modify files."""
146
+ from pennyfarthing_scripts.story.create import create_story
147
+
148
+ result = create_story(
149
+ epic_id="MSSCI-11952",
150
+ title="Test Story",
151
+ points=3,
152
+ dry_run=True,
153
+ )
154
+
155
+ assert isinstance(result, dict)
156
+ assert result.get("dry_run") is True
@@ -0,0 +1,157 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Welcome Hook (Python)
4
+
5
+ Display a friendly welcome message on session start.
6
+
7
+ For CLI: Displays ASCII art of a penny-farthing bicycle
8
+ For Cyclist: Sends WebSocket message to display logo and welcome
9
+
10
+ Called by Claude Code SessionStart hook.
11
+
12
+ Story: MSSCI-12409 - Hook consistency and WheelHub consolidation
13
+ """
14
+
15
+ import json
16
+ import os
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ # Add parent directory to path for imports
21
+ sys.path.insert(0, str(Path(__file__).parent))
22
+
23
+ from hooks import (
24
+ find_project_root,
25
+ send_to_cyclist,
26
+ load_settings,
27
+ is_cyclist_running,
28
+ )
29
+
30
+
31
+ # Once-per-session guard
32
+ _welcome_shown_file: Path | None = None
33
+
34
+
35
+ def get_welcome_lock_path(project_root: Path) -> Path:
36
+ """Get path to welcome shown lock file."""
37
+ session_id = os.environ.get("CLAUDE_SESSION_ID", str(os.getpid()))
38
+ session_dir = project_root / ".session"
39
+ session_dir.mkdir(parents=True, exist_ok=True)
40
+ return session_dir / f".welcome-shown-{session_id}"
41
+
42
+
43
+ def was_welcome_shown(project_root: Path) -> bool:
44
+ """Check if welcome was already shown for this session."""
45
+ lock_path = get_welcome_lock_path(project_root)
46
+ return lock_path.exists()
47
+
48
+
49
+ def mark_welcome_shown(project_root: Path) -> None:
50
+ """Mark welcome as shown for this session."""
51
+ lock_path = get_welcome_lock_path(project_root)
52
+ lock_path.touch()
53
+
54
+
55
+ def get_project_name(project_root: Path) -> str:
56
+ """Get project name from package.json or directory name."""
57
+ package_json = project_root / "package.json"
58
+ if package_json.exists():
59
+ try:
60
+ with open(package_json) as f:
61
+ data = json.load(f)
62
+ name = data.get("name")
63
+ if name:
64
+ return name
65
+ except (json.JSONDecodeError, OSError):
66
+ pass
67
+
68
+ return project_root.name
69
+
70
+
71
+ def get_theme(project_root: Path) -> str | None:
72
+ """Get current theme from settings."""
73
+ settings = load_settings(project_root)
74
+ return settings.theme
75
+
76
+
77
+ def display_cli_welcome(project_name: str, theme: str | None) -> None:
78
+ """Display ASCII art welcome for CLI mode."""
79
+ print("""
80
+ ___
81
+ / \\
82
+ | | Welcome to
83
+ | | ╔═══════════════════════════════════╗
84
+ \\___/ ║ ╔═╗╔═╗╔╗╔╔╗╔╦═╗╔═╗╔═╗╔═╗╦╔═╗ ║
85
+ ║ ║ ╠═╝║╣ ║║║║║║ ╠╣ ╠═╣╠╦╝ ║ ╠═╣ ║
86
+ ║ ║ ╩ ╚═╝╝╚╝╝╚╝╩ ╩ ╩╩╚═ ╩ ╩ ╩ ║
87
+ ╔═╩═╗ ╚═══════════════════════════════════╝
88
+ / \\
89
+ │ O │ Agent-powered development with style
90
+ \\ /
91
+ ╚═══╝
92
+ """)
93
+
94
+ if project_name:
95
+ print(f" Project: {project_name}")
96
+ if theme:
97
+ print(f" Theme: {theme}")
98
+ print()
99
+
100
+
101
+ def send_cyclist_welcome(project_root: Path, project_name: str, theme: str | None) -> None:
102
+ """Send welcome message to Cyclist via WebSocket API."""
103
+ try:
104
+ send_to_cyclist(
105
+ endpoint="/api/welcome",
106
+ data={
107
+ "project": project_name or "",
108
+ "theme": theme or "",
109
+ },
110
+ project_root=project_root,
111
+ timeout=5,
112
+ )
113
+ except Exception:
114
+ # Ignore errors - don't block hook
115
+ pass
116
+
117
+
118
+ def main() -> None:
119
+ """Main entry point for SessionStart welcome hook."""
120
+ try:
121
+ # Read and discard stdin (required by hook protocol)
122
+ sys.stdin.read()
123
+
124
+ # Find project root
125
+ project_root = find_project_root()
126
+ if not project_root:
127
+ sys.exit(0)
128
+
129
+ # Check if welcome was already shown for this session
130
+ if was_welcome_shown(project_root):
131
+ sys.exit(0)
132
+
133
+ # Mark welcome as shown
134
+ mark_welcome_shown(project_root)
135
+
136
+ # Get project info
137
+ project_name = get_project_name(project_root)
138
+ theme = get_theme(project_root)
139
+
140
+ # Check if running in Cyclist
141
+ if is_cyclist_running(project_root):
142
+ # Send welcome via WebSocket API
143
+ send_cyclist_welcome(project_root, project_name, theme)
144
+ else:
145
+ # CLI mode - display ASCII art
146
+ display_cli_welcome(project_name, theme)
147
+
148
+ sys.exit(0)
149
+
150
+ except Exception as e:
151
+ # On error, exit silently
152
+ print(f"[welcome-hook] Error: {e}", file=sys.stderr)
153
+ sys.exit(0)
154
+
155
+
156
+ if __name__ == "__main__":
157
+ main()
@@ -0,0 +1,183 @@
1
+ """
2
+ Scale level detection and workflow routing for Pennyfarthing.
3
+
4
+ BMAD Scale Levels:
5
+ - Level 0: fix, bug, typo, small change, patch (1 story, no artifacts)
6
+ - Level 1: simple, basic, small feature, add (1-10 stories, tech-spec)
7
+ - Level 2: dashboard, several features, admin panel (5-15 stories, PRD optional arch)
8
+ - Level 3: platform, integration, complex, system (12-40 stories, PRD + architecture)
9
+ - Level 4: enterprise, multi-tenant, multiple products (40+ stories, full BMAD)
10
+
11
+ Story: MSSCI-12416 - Define Scale Levels
12
+ Epic: MSSCI-12415 - Scale Adaptation and Brownfield Support
13
+ """
14
+
15
+ from typing import Any
16
+ import re
17
+
18
+ # Scale level definitions with keywords, thresholds, and metadata
19
+ SCALE_LEVELS: dict[int, dict[str, Any]] = {
20
+ 0: {
21
+ "level": 0,
22
+ "scope": "fix, bug, typo, small change, patch",
23
+ "keywords": ["fix", "bug", "typo", "patch", "hotfix", "small change"],
24
+ "stories_min": 1,
25
+ "stories_max": 1,
26
+ "workflow": "trivial",
27
+ "artifacts": [],
28
+ },
29
+ 1: {
30
+ "level": 1,
31
+ "scope": "simple, basic, small feature, add",
32
+ "keywords": ["simple", "basic", "small", "add", "minor"],
33
+ "stories_min": 1,
34
+ "stories_max": 10,
35
+ "workflow": "quick-spec",
36
+ "artifacts": ["tech-spec"],
37
+ },
38
+ 2: {
39
+ "level": 2,
40
+ "scope": "dashboard, several features, admin panel",
41
+ "keywords": ["dashboard", "admin panel", "several", "multiple features"],
42
+ "stories_min": 5,
43
+ "stories_max": 15,
44
+ "workflow": "prd",
45
+ "artifacts": ["prd"],
46
+ },
47
+ 3: {
48
+ "level": 3,
49
+ "scope": "platform, integration, complex, system",
50
+ "keywords": ["platform", "integration", "complex", "system"],
51
+ "stories_min": 12,
52
+ "stories_max": 40,
53
+ "workflow": "prd",
54
+ "artifacts": ["prd", "architecture"],
55
+ },
56
+ 4: {
57
+ "level": 4,
58
+ "scope": "enterprise, multi-tenant, multiple products",
59
+ "keywords": ["enterprise", "multi-tenant", "multiple products"],
60
+ "stories_min": 40,
61
+ "stories_max": None, # No upper limit
62
+ "workflow": "prd",
63
+ "artifacts": ["prd", "architecture", "epics-and-stories"],
64
+ },
65
+ }
66
+
67
+
68
+ def detect_scale_level(description: str) -> int:
69
+ """Detect scale level from description keywords.
70
+
71
+ Keywords are matched case-insensitively. When multiple levels match,
72
+ the highest level wins (higher specificity).
73
+
74
+ Args:
75
+ description: Text describing the work to be done
76
+
77
+ Returns:
78
+ Scale level 0-4, defaults to 1 if no keywords match
79
+ """
80
+ description_lower = description.lower()
81
+ matched_level = 1 # Default
82
+
83
+ # Check from highest to lowest level (highest specificity wins)
84
+ for level in [4, 3, 2, 1, 0]:
85
+ keywords = SCALE_LEVELS[level]["keywords"]
86
+ for keyword in keywords:
87
+ # Use word boundary matching for better accuracy
88
+ pattern = r"\b" + re.escape(keyword.lower()) + r"\b"
89
+ if re.search(pattern, description_lower):
90
+ # Higher levels have priority, so return immediately for L4-L2
91
+ if level >= 2:
92
+ return level
93
+ # For L0-L1, track the match but continue checking for higher levels
94
+ matched_level = level
95
+
96
+ return matched_level
97
+
98
+
99
+ def scale_level_from_story_count(count: int) -> int:
100
+ """Infer scale level from estimated story count.
101
+
102
+ Args:
103
+ count: Estimated number of stories
104
+
105
+ Returns:
106
+ Scale level 0-4 based on story count thresholds
107
+ """
108
+ if count <= 1:
109
+ return 1 # Could be 0 or 1, default to 1
110
+ elif count <= 10:
111
+ return 1
112
+ elif count <= 15:
113
+ return 2
114
+ elif count <= 40:
115
+ return 3
116
+ else:
117
+ return 4
118
+
119
+
120
+ def get_workflow_for_scale_level(level: int) -> str:
121
+ """Get the recommended workflow for a scale level.
122
+
123
+ Args:
124
+ level: Scale level 0-4
125
+
126
+ Returns:
127
+ Workflow name (trivial, quick-spec, or prd)
128
+ """
129
+ if level not in SCALE_LEVELS:
130
+ return "prd" # Safe default for unknown levels
131
+ return SCALE_LEVELS[level]["workflow"]
132
+
133
+
134
+ def get_required_artifacts(level: int) -> list[str]:
135
+ """Get required planning artifacts for a scale level.
136
+
137
+ Args:
138
+ level: Scale level 0-4
139
+
140
+ Returns:
141
+ List of required artifact names
142
+ """
143
+ if level not in SCALE_LEVELS:
144
+ return []
145
+ return SCALE_LEVELS[level]["artifacts"]
146
+
147
+
148
+ def determine_scale_level(
149
+ description: str, explicit_level: int | None = None
150
+ ) -> int:
151
+ """Determine scale level with optional user override.
152
+
153
+ Args:
154
+ description: Text describing the work to be done
155
+ explicit_level: User-specified level override (0-4)
156
+
157
+ Returns:
158
+ Scale level 0-4
159
+
160
+ Raises:
161
+ ValueError: If explicit_level is not 0-4
162
+ """
163
+ if explicit_level is not None:
164
+ if explicit_level < 0 or explicit_level > 4:
165
+ raise ValueError(f"Scale level must be 0-4, got {explicit_level}")
166
+ return explicit_level
167
+
168
+ return detect_scale_level(description)
169
+
170
+
171
+ def get_scale_level_info(level: int) -> dict[str, Any]:
172
+ """Get complete metadata for a scale level.
173
+
174
+ Args:
175
+ level: Scale level 0-4
176
+
177
+ Returns:
178
+ Dict with level, scope, stories_min, stories_max, workflow, artifacts
179
+ """
180
+ if level not in SCALE_LEVELS:
181
+ return {"level": level, "scope": "unknown", "stories_min": 0,
182
+ "stories_max": 0, "workflow": "prd", "artifacts": []}
183
+ return SCALE_LEVELS[level].copy()