@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.
- package/README.md +1 -1
- package/package.json +2 -1
- package/packages/core/dist/cli/commands/init.d.ts.map +1 -1
- package/packages/core/dist/cli/commands/init.js +7 -6
- package/packages/core/dist/cli/commands/init.js.map +1 -1
- package/packages/core/dist/cli/utils/symlinks.d.ts +7 -0
- package/packages/core/dist/cli/utils/symlinks.d.ts.map +1 -1
- package/packages/core/dist/cli/utils/symlinks.js +25 -0
- package/packages/core/dist/cli/utils/symlinks.js.map +1 -1
- package/pennyfarthing-dist/scripts/core/phase-check-start.sh +1 -1
- package/pennyfarthing-dist/scripts/core/prime.sh +23 -0
- package/pennyfarthing-dist/scripts/core/run.sh +5 -5
- package/pennyfarthing-dist/scripts/git/install-git-hooks.sh +2 -2
- package/pennyfarthing-dist/scripts/git/release.sh +2 -2
- package/pennyfarthing-dist/scripts/health/drift-detection.sh +1 -1
- package/pennyfarthing-dist/scripts/hooks/post-merge.sh +2 -2
- package/pennyfarthing-dist/scripts/hooks/pre-push.sh +2 -2
- package/pennyfarthing-dist/scripts/hooks/session-stop.sh +1 -1
- package/pennyfarthing-dist/scripts/jira/create-jira-epic.sh +1 -1
- package/pennyfarthing-dist/scripts/jira/create-jira-story.sh +1 -1
- package/pennyfarthing-dist/scripts/jira/jira-reconcile.sh +1 -1
- package/pennyfarthing-dist/scripts/lib/common.sh +1 -1
- package/pennyfarthing-dist/scripts/lib/find-root.sh +4 -4
- package/pennyfarthing-dist/scripts/maintenance/sidecar-health.sh +1 -1
- package/pennyfarthing-dist/scripts/misc/add_short_names.py +2 -2
- package/pennyfarthing-dist/scripts/misc/backlog.sh +2 -2
- package/pennyfarthing-dist/scripts/misc/deploy.sh +2 -2
- package/pennyfarthing-dist/scripts/misc/generate-skill-docs.sh +4 -4
- package/pennyfarthing-dist/scripts/misc/log-skill-usage.sh +2 -2
- package/pennyfarthing-dist/scripts/misc/run-ci.sh +1 -1
- package/pennyfarthing-dist/scripts/misc/skill-usage-report.sh +2 -2
- package/pennyfarthing-dist/scripts/sprint/archive-story.sh +1 -1
- package/pennyfarthing-dist/scripts/sprint/available-stories.sh +1 -1
- package/pennyfarthing-dist/scripts/sprint/check-story.sh +1 -1
- package/pennyfarthing-dist/scripts/sprint/get-epic-field.sh +1 -1
- package/pennyfarthing-dist/scripts/sprint/get-story-field.sh +1 -1
- package/pennyfarthing-dist/scripts/sprint/import_epic_to_future.py +2 -2
- package/pennyfarthing-dist/scripts/sprint/list-future.sh +1 -1
- package/pennyfarthing-dist/scripts/sprint/new-sprint.sh +1 -1
- package/pennyfarthing-dist/scripts/sprint/promote-epic.sh +1 -1
- package/pennyfarthing-dist/scripts/sprint/sprint-common.sh +3 -3
- package/pennyfarthing-dist/scripts/sprint/sprint-info.sh +1 -1
- package/pennyfarthing-dist/scripts/sprint/sprint-metrics.sh +2 -2
- package/pennyfarthing-dist/scripts/theme/compute_theme_tiers.py +2 -2
- package/pennyfarthing-dist/scripts/workflow/check.py +2 -2
- package/pennyfarthing-dist/scripts/workflow/finish-story.sh +1 -1
- package/pennyfarthing-dist/scripts/workflow/fix-session-phase.sh +1 -1
- package/pennyfarthing-dist/scripts/workflow/get-workflow-type.py +2 -2
- package/pennyfarthing-dist/scripts/workflow/list-workflows.sh +1 -1
- package/pennyfarthing-dist/scripts/workflow/phase-owner.sh +1 -1
- package/pennyfarthing-dist/scripts/workflow/resume-workflow.sh +1 -1
- package/pennyfarthing-dist/scripts/workflow/show-workflow.sh +1 -1
- package/pennyfarthing-dist/scripts/workflow/start-workflow.sh +1 -1
- package/pennyfarthing-dist/scripts/workflow/workflow-status.sh +1 -1
- package/pennyfarthing_scripts/__init__.py +17 -0
- package/pennyfarthing_scripts/__pycache__/__init__.cpython-311.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/config.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/jira.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/jira_epic_creation.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/jira_sync.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/jira_sync_story.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/sprint.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/workflow.cpython-311.pyc +0 -0
- package/pennyfarthing_scripts/__pycache__/workflow.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/bellmode_hook.py +154 -0
- package/pennyfarthing_scripts/brownfield/__init__.py +35 -0
- package/pennyfarthing_scripts/brownfield/__main__.py +7 -0
- package/pennyfarthing_scripts/brownfield/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/brownfield/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/brownfield/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/brownfield/__pycache__/discover.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/brownfield/cli.py +131 -0
- package/pennyfarthing_scripts/brownfield/discover.py +753 -0
- package/pennyfarthing_scripts/common/__init__.py +49 -0
- package/pennyfarthing_scripts/common/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/common/__pycache__/config.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/common/__pycache__/output.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/common/config.py +65 -0
- package/pennyfarthing_scripts/common/output.py +180 -0
- package/pennyfarthing_scripts/config.py +21 -0
- package/pennyfarthing_scripts/git/__init__.py +29 -0
- package/pennyfarthing_scripts/git/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git/__pycache__/create_branches.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git/__pycache__/status_all.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/git/create_branches.py +439 -0
- package/pennyfarthing_scripts/git/status_all.py +310 -0
- package/pennyfarthing_scripts/hooks.py +455 -0
- package/pennyfarthing_scripts/jira/__init__.py +93 -0
- package/pennyfarthing_scripts/jira/__main__.py +10 -0
- package/pennyfarthing_scripts/jira/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/bidirectional.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/claim.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/client.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/compat.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/epic.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/mappings.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/models.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/story.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/__pycache__/sync.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/jira/bidirectional.py +561 -0
- package/pennyfarthing_scripts/jira/claim.py +211 -0
- package/pennyfarthing_scripts/jira/cli.py +150 -0
- package/pennyfarthing_scripts/jira/client.py +613 -0
- package/pennyfarthing_scripts/jira/epic.py +176 -0
- package/pennyfarthing_scripts/jira/story.py +219 -0
- package/pennyfarthing_scripts/jira/sync.py +350 -0
- package/pennyfarthing_scripts/jira_bidirectional_sync.py +37 -0
- package/pennyfarthing_scripts/jira_epic_creation.py +30 -0
- package/pennyfarthing_scripts/jira_sync.py +36 -0
- package/pennyfarthing_scripts/jira_sync_story.py +30 -0
- package/pennyfarthing_scripts/output.py +37 -0
- package/pennyfarthing_scripts/preflight/__init__.py +17 -0
- package/pennyfarthing_scripts/preflight/__main__.py +10 -0
- package/pennyfarthing_scripts/preflight/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/preflight/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/preflight/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/preflight/__pycache__/finish.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/preflight/cli.py +141 -0
- package/pennyfarthing_scripts/preflight/finish.py +382 -0
- package/pennyfarthing_scripts/pretooluse_hook.py +142 -0
- package/pennyfarthing_scripts/prime/__init__.py +38 -0
- package/pennyfarthing_scripts/prime/__main__.py +8 -0
- package/pennyfarthing_scripts/prime/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/cli.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/loader.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/models.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/persona.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/session.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/__pycache__/workflow.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/prime/cli.py +220 -0
- package/pennyfarthing_scripts/prime/loader.py +239 -0
- package/pennyfarthing_scripts/sprint/__init__.py +66 -0
- package/pennyfarthing_scripts/sprint/__main__.py +10 -0
- package/pennyfarthing_scripts/sprint/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/__main__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/archive.cpython-314.pyc +0 -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__/status.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/validator.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/__pycache__/work.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/sprint/archive.py +108 -0
- package/pennyfarthing_scripts/sprint/cli.py +124 -0
- package/pennyfarthing_scripts/sprint/loader.py +193 -0
- package/pennyfarthing_scripts/sprint/status.py +122 -0
- package/pennyfarthing_scripts/sprint/validator.py +405 -0
- package/pennyfarthing_scripts/sprint/work.py +192 -0
- package/pennyfarthing_scripts/story/__init__.py +67 -0
- package/pennyfarthing_scripts/story/__main__.py +10 -0
- package/pennyfarthing_scripts/story/cli.py +105 -0
- package/pennyfarthing_scripts/story/create.py +167 -0
- package/pennyfarthing_scripts/story/size.py +113 -0
- package/pennyfarthing_scripts/story/template.py +151 -0
- package/pennyfarthing_scripts/swebench.py +216 -0
- package/pennyfarthing_scripts/tests/__init__.py +1 -0
- package/pennyfarthing_scripts/tests/__pycache__/__init__.cpython-314.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_brownfield.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_git_utils.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_prime.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/__pycache__/test_sprint_validator.cpython-314-pytest-9.0.2.pyc +0 -0
- package/pennyfarthing_scripts/tests/conftest.py +106 -0
- package/pennyfarthing_scripts/tests/test_brownfield.py +842 -0
- package/pennyfarthing_scripts/tests/test_cli_modules.py +245 -0
- package/pennyfarthing_scripts/tests/test_common.py +180 -0
- package/pennyfarthing_scripts/tests/test_git_utils.py +866 -0
- package/pennyfarthing_scripts/tests/test_jira_package.py +334 -0
- package/pennyfarthing_scripts/tests/test_package_structure.py +372 -0
- package/pennyfarthing_scripts/tests/test_prime.py +397 -0
- package/pennyfarthing_scripts/tests/test_sprint_package.py +236 -0
- package/pennyfarthing_scripts/tests/test_sprint_validator.py +675 -0
- package/pennyfarthing_scripts/tests/test_story_package.py +156 -0
- package/pennyfarthing_scripts/welcome_hook.py +157 -0
- 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()
|