@pennyfarthing/core 7.8.1 → 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.
- package/package.json +2 -1
- package/pennyfarthing-dist/scripts/core/prime.sh +8 -0
- 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,455 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared utilities for Pennyfarthing Claude Code hooks.
|
|
3
|
+
|
|
4
|
+
Provides common functionality for all hooks:
|
|
5
|
+
- Project root detection
|
|
6
|
+
- Port file discovery
|
|
7
|
+
- Settings loading (relay_mode, permission_mode)
|
|
8
|
+
- Context state checking
|
|
9
|
+
- HTTP communication with Cyclist
|
|
10
|
+
|
|
11
|
+
All hooks should import from this module for consistency.
|
|
12
|
+
|
|
13
|
+
Story: MSSCI-12409 - Hook consistency and relay mode compatibility
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
import urllib.request
|
|
20
|
+
import urllib.error
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import yaml
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# =============================================================================
|
|
29
|
+
# Port File Constants
|
|
30
|
+
# =============================================================================
|
|
31
|
+
|
|
32
|
+
# WheelHub port file - central coordination server for all communication
|
|
33
|
+
# Per ADR-0004: "the hub where all communication converges"
|
|
34
|
+
CYCLIST_PORT_FILE = ".cyclist-port"
|
|
35
|
+
|
|
36
|
+
# Legacy approval port file (deprecated, for backwards compatibility during migration)
|
|
37
|
+
CYCLIST_APPROVAL_PORT_FILE_LEGACY = ".cyclist-approval-port"
|
|
38
|
+
|
|
39
|
+
# Default port if file not found
|
|
40
|
+
DEFAULT_CYCLIST_PORT = 7431
|
|
41
|
+
|
|
42
|
+
# HTTP timeout for Cyclist communication
|
|
43
|
+
HTTP_TIMEOUT_SECONDS = 120
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# =============================================================================
|
|
47
|
+
# Project Root Detection
|
|
48
|
+
# =============================================================================
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def find_project_root(start_dir: Path | None = None) -> Path | None:
|
|
52
|
+
"""Find the project root by looking for marker files.
|
|
53
|
+
|
|
54
|
+
Searches for (in order):
|
|
55
|
+
1. .cyclist-port or .cyclist-approval-port (Cyclist is running)
|
|
56
|
+
2. .pennyfarthing directory
|
|
57
|
+
3. .claude directory
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
start_dir: Directory to start search from (defaults to cwd)
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Path to project root, or None if not found
|
|
64
|
+
"""
|
|
65
|
+
current = Path(start_dir) if start_dir else Path.cwd()
|
|
66
|
+
current = current.resolve()
|
|
67
|
+
|
|
68
|
+
while current != current.parent:
|
|
69
|
+
# Check for Cyclist port files first (indicates Cyclist is running)
|
|
70
|
+
if (current / CYCLIST_PORT_FILE).exists():
|
|
71
|
+
return current
|
|
72
|
+
if (current / CYCLIST_APPROVAL_PORT_FILE).exists():
|
|
73
|
+
return current
|
|
74
|
+
# Fall back to directory markers
|
|
75
|
+
if (current / ".pennyfarthing").is_dir():
|
|
76
|
+
return current
|
|
77
|
+
if (current / ".claude").is_dir():
|
|
78
|
+
return current
|
|
79
|
+
current = current.parent
|
|
80
|
+
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# =============================================================================
|
|
85
|
+
# Port File Reading
|
|
86
|
+
# =============================================================================
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def read_port_file(file_name: str, project_root: Path | None = None) -> int | None:
|
|
90
|
+
"""Read a port number from a Cyclist port file.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
file_name: Name of the port file (.cyclist-port or .cyclist-approval-port)
|
|
94
|
+
project_root: Project root directory (auto-detected if not provided)
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Port number, or None if file not found or invalid
|
|
98
|
+
"""
|
|
99
|
+
root = project_root or find_project_root()
|
|
100
|
+
if not root:
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
port_file = root / file_name
|
|
104
|
+
if not port_file.exists():
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
try:
|
|
108
|
+
content = port_file.read_text().strip()
|
|
109
|
+
port = int(content)
|
|
110
|
+
if 0 < port < 65536:
|
|
111
|
+
return port
|
|
112
|
+
except (ValueError, OSError):
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def get_cyclist_port(project_root: Path | None = None) -> int:
|
|
119
|
+
"""Get the WheelHub server port.
|
|
120
|
+
|
|
121
|
+
WheelHub is the central coordination server for all Cyclist communication,
|
|
122
|
+
including hook requests, OTEL, REST APIs, and WebSocket.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
project_root: Project root directory (auto-detected if not provided)
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Port number (default if file not found)
|
|
129
|
+
"""
|
|
130
|
+
port = read_port_file(CYCLIST_PORT_FILE, project_root)
|
|
131
|
+
if port:
|
|
132
|
+
return port
|
|
133
|
+
|
|
134
|
+
# Fallback to legacy approval port file during migration
|
|
135
|
+
legacy_port = read_port_file(CYCLIST_APPROVAL_PORT_FILE_LEGACY, project_root)
|
|
136
|
+
if legacy_port:
|
|
137
|
+
return legacy_port
|
|
138
|
+
|
|
139
|
+
return DEFAULT_CYCLIST_PORT
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# Alias for backwards compatibility
|
|
143
|
+
get_approval_port = get_cyclist_port
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# =============================================================================
|
|
147
|
+
# Settings Loading
|
|
148
|
+
# =============================================================================
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass
|
|
152
|
+
class CyclistSettings:
|
|
153
|
+
"""Cyclist workflow settings from config.local.yaml."""
|
|
154
|
+
|
|
155
|
+
permission_mode: str = "manual" # plan, manual, accept
|
|
156
|
+
relay_mode: bool = False
|
|
157
|
+
bell_mode: bool = False
|
|
158
|
+
theme: str | None = None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def load_settings(project_root: Path | None = None) -> CyclistSettings:
|
|
162
|
+
"""Load Cyclist settings from .pennyfarthing/config.local.yaml.
|
|
163
|
+
|
|
164
|
+
Handles legacy setting migrations:
|
|
165
|
+
- permission_mode: 'turbo' -> 'accept' + relay_mode: True
|
|
166
|
+
- handoff_mode: 'auto' -> relay_mode: True
|
|
167
|
+
- auto_handoff: True -> relay_mode: True
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
project_root: Project root directory (auto-detected if not provided)
|
|
171
|
+
|
|
172
|
+
Returns:
|
|
173
|
+
CyclistSettings with current configuration
|
|
174
|
+
"""
|
|
175
|
+
settings = CyclistSettings()
|
|
176
|
+
|
|
177
|
+
root = project_root or find_project_root()
|
|
178
|
+
if not root:
|
|
179
|
+
return settings
|
|
180
|
+
|
|
181
|
+
config_path = root / ".pennyfarthing" / "config.local.yaml"
|
|
182
|
+
if not config_path.exists():
|
|
183
|
+
return settings
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
with open(config_path) as f:
|
|
187
|
+
config = yaml.safe_load(f) or {}
|
|
188
|
+
except (OSError, yaml.YAMLError):
|
|
189
|
+
return settings
|
|
190
|
+
|
|
191
|
+
# Extract theme
|
|
192
|
+
settings.theme = config.get("theme")
|
|
193
|
+
|
|
194
|
+
# Extract workflow settings
|
|
195
|
+
workflow = config.get("workflow", {})
|
|
196
|
+
if not isinstance(workflow, dict):
|
|
197
|
+
return settings
|
|
198
|
+
|
|
199
|
+
# Handle permission_mode
|
|
200
|
+
mode = workflow.get("permission_mode", "manual")
|
|
201
|
+
if mode == "turbo":
|
|
202
|
+
# Migrate turbo -> accept + relay_mode
|
|
203
|
+
settings.permission_mode = "accept"
|
|
204
|
+
settings.relay_mode = True
|
|
205
|
+
elif mode in ("plan", "manual", "accept"):
|
|
206
|
+
settings.permission_mode = mode
|
|
207
|
+
else:
|
|
208
|
+
settings.permission_mode = "manual"
|
|
209
|
+
|
|
210
|
+
# Handle explicit relay_mode (overrides migration)
|
|
211
|
+
if "relay_mode" in workflow and isinstance(workflow["relay_mode"], bool):
|
|
212
|
+
settings.relay_mode = workflow["relay_mode"]
|
|
213
|
+
elif not settings.relay_mode:
|
|
214
|
+
# Check legacy settings
|
|
215
|
+
if workflow.get("handoff_mode") == "auto":
|
|
216
|
+
settings.relay_mode = True
|
|
217
|
+
elif workflow.get("auto_handoff") is True:
|
|
218
|
+
settings.relay_mode = True
|
|
219
|
+
|
|
220
|
+
# Handle bell_mode
|
|
221
|
+
if "bell_mode" in workflow and isinstance(workflow["bell_mode"], bool):
|
|
222
|
+
settings.bell_mode = workflow["bell_mode"]
|
|
223
|
+
|
|
224
|
+
return settings
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def is_relay_mode_enabled(project_root: Path | None = None) -> bool:
|
|
228
|
+
"""Check if relay mode (auto-handoff) is enabled.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
project_root: Project root directory (auto-detected if not provided)
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
True if relay mode is enabled
|
|
235
|
+
"""
|
|
236
|
+
return load_settings(project_root).relay_mode
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def is_bell_mode_enabled(project_root: Path | None = None) -> bool:
|
|
240
|
+
"""Check if bell mode is enabled.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
project_root: Project root directory (auto-detected if not provided)
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
True if bell mode is enabled
|
|
247
|
+
"""
|
|
248
|
+
return load_settings(project_root).bell_mode
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
# =============================================================================
|
|
252
|
+
# Context State
|
|
253
|
+
# =============================================================================
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@dataclass
|
|
257
|
+
class ContextState:
|
|
258
|
+
"""Current context usage state."""
|
|
259
|
+
|
|
260
|
+
used_tokens: int = 0
|
|
261
|
+
max_tokens: int = 200000
|
|
262
|
+
percentage: float = 0.0
|
|
263
|
+
is_high: bool = False # > 60%
|
|
264
|
+
is_critical: bool = False # > 80%
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def get_context_state(project_root: Path | None = None) -> ContextState:
|
|
268
|
+
"""Get current context usage from Cyclist API.
|
|
269
|
+
|
|
270
|
+
Calls Cyclist's /api/context endpoint which runs check-context.sh.
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
project_root: Project root directory (auto-detected if not provided)
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
ContextState with current usage (defaults if Cyclist not running)
|
|
277
|
+
"""
|
|
278
|
+
state = ContextState()
|
|
279
|
+
|
|
280
|
+
port = get_cyclist_port(project_root)
|
|
281
|
+
url = f"http://127.0.0.1:{port}/api/context"
|
|
282
|
+
|
|
283
|
+
try:
|
|
284
|
+
with urllib.request.urlopen(url, timeout=5) as response:
|
|
285
|
+
data = json.loads(response.read().decode())
|
|
286
|
+
state.used_tokens = data.get("used_tokens", 0)
|
|
287
|
+
state.max_tokens = data.get("max_tokens", 200000)
|
|
288
|
+
if state.max_tokens > 0:
|
|
289
|
+
state.percentage = (state.used_tokens / state.max_tokens) * 100
|
|
290
|
+
state.is_high = state.percentage > 60
|
|
291
|
+
state.is_critical = state.percentage > 80
|
|
292
|
+
except (urllib.error.URLError, json.JSONDecodeError, OSError):
|
|
293
|
+
# Cyclist not running or error - return defaults
|
|
294
|
+
pass
|
|
295
|
+
|
|
296
|
+
return state
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# =============================================================================
|
|
300
|
+
# Cyclist HTTP Communication
|
|
301
|
+
# =============================================================================
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def send_to_cyclist(
|
|
305
|
+
endpoint: str,
|
|
306
|
+
data: dict[str, Any],
|
|
307
|
+
port: int | None = None,
|
|
308
|
+
project_root: Path | None = None,
|
|
309
|
+
timeout: int = HTTP_TIMEOUT_SECONDS,
|
|
310
|
+
) -> dict[str, Any] | None:
|
|
311
|
+
"""Send a POST request to WheelHub (Cyclist's central coordination server).
|
|
312
|
+
|
|
313
|
+
All endpoints go through WheelHub per ADR-0004.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
endpoint: API endpoint path (e.g., "/api/hook-request")
|
|
317
|
+
data: JSON data to send
|
|
318
|
+
port: Port to use (auto-detected if not provided)
|
|
319
|
+
project_root: Project root for port discovery
|
|
320
|
+
timeout: Request timeout in seconds
|
|
321
|
+
|
|
322
|
+
Returns:
|
|
323
|
+
Response JSON as dict, or None on error
|
|
324
|
+
"""
|
|
325
|
+
if port is None:
|
|
326
|
+
port = get_cyclist_port(project_root)
|
|
327
|
+
|
|
328
|
+
url = f"http://127.0.0.1:{port}{endpoint}"
|
|
329
|
+
json_data = json.dumps(data).encode("utf-8")
|
|
330
|
+
|
|
331
|
+
request = urllib.request.Request(
|
|
332
|
+
url,
|
|
333
|
+
data=json_data,
|
|
334
|
+
headers={"Content-Type": "application/json"},
|
|
335
|
+
method="POST",
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
try:
|
|
339
|
+
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
340
|
+
return json.loads(response.read().decode())
|
|
341
|
+
except urllib.error.URLError as e:
|
|
342
|
+
# Connection refused means Cyclist isn't running
|
|
343
|
+
if "Connection refused" in str(e):
|
|
344
|
+
return None
|
|
345
|
+
raise
|
|
346
|
+
except (json.JSONDecodeError, OSError):
|
|
347
|
+
return None
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
# =============================================================================
|
|
351
|
+
# Hook Response Formatting
|
|
352
|
+
# =============================================================================
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
@dataclass
|
|
356
|
+
class HookResponse:
|
|
357
|
+
"""Standard hook response for Claude Code."""
|
|
358
|
+
|
|
359
|
+
event_name: str
|
|
360
|
+
decision: str | None = None # allow, deny, ask (for PreToolUse)
|
|
361
|
+
reason: str | None = None
|
|
362
|
+
updated_input: dict[str, Any] | None = None
|
|
363
|
+
additional_context: str | None = None # For PostToolUse context injection
|
|
364
|
+
|
|
365
|
+
def to_json(self) -> str:
|
|
366
|
+
"""Format as Claude Code hook JSON output."""
|
|
367
|
+
output: dict[str, Any] = {
|
|
368
|
+
"hookSpecificOutput": {
|
|
369
|
+
"hookEventName": self.event_name,
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
hook_output = output["hookSpecificOutput"]
|
|
374
|
+
|
|
375
|
+
if self.decision:
|
|
376
|
+
hook_output["permissionDecision"] = self.decision
|
|
377
|
+
if self.reason:
|
|
378
|
+
hook_output["permissionDecisionReason"] = self.reason
|
|
379
|
+
if self.updated_input:
|
|
380
|
+
hook_output["updatedInput"] = self.updated_input
|
|
381
|
+
if self.additional_context:
|
|
382
|
+
hook_output["additionalContext"] = self.additional_context
|
|
383
|
+
|
|
384
|
+
return json.dumps(output)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def output_hook_response(response: HookResponse) -> None:
|
|
388
|
+
"""Output hook response to stdout for Claude Code."""
|
|
389
|
+
print(response.to_json())
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def read_stdin_json() -> dict[str, Any]:
|
|
393
|
+
"""Read JSON from stdin (hook input from Claude Code).
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
Parsed JSON as dict
|
|
397
|
+
|
|
398
|
+
Raises:
|
|
399
|
+
ValueError: If input is not valid JSON
|
|
400
|
+
"""
|
|
401
|
+
data = sys.stdin.read()
|
|
402
|
+
try:
|
|
403
|
+
return json.loads(data)
|
|
404
|
+
except json.JSONDecodeError as e:
|
|
405
|
+
raise ValueError(f"Invalid JSON input: {e}") from e
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
# =============================================================================
|
|
409
|
+
# Hook Execution Utilities
|
|
410
|
+
# =============================================================================
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def is_cyclist_running(project_root: Path | None = None) -> bool:
|
|
414
|
+
"""Check if Cyclist server is running.
|
|
415
|
+
|
|
416
|
+
Args:
|
|
417
|
+
project_root: Project root directory (auto-detected if not provided)
|
|
418
|
+
|
|
419
|
+
Returns:
|
|
420
|
+
True if Cyclist is responding to health checks
|
|
421
|
+
"""
|
|
422
|
+
port = get_cyclist_port(project_root)
|
|
423
|
+
url = f"http://127.0.0.1:{port}/health"
|
|
424
|
+
|
|
425
|
+
try:
|
|
426
|
+
with urllib.request.urlopen(url, timeout=2) as response:
|
|
427
|
+
return response.status == 200
|
|
428
|
+
except (urllib.error.URLError, OSError):
|
|
429
|
+
return False
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def should_auto_approve(settings: CyclistSettings) -> bool:
|
|
433
|
+
"""Check if requests should be auto-approved based on settings.
|
|
434
|
+
|
|
435
|
+
Auto-approve when permission_mode is 'accept' (formerly turbo).
|
|
436
|
+
|
|
437
|
+
Args:
|
|
438
|
+
settings: Current Cyclist settings
|
|
439
|
+
|
|
440
|
+
Returns:
|
|
441
|
+
True if auto-approval is enabled
|
|
442
|
+
"""
|
|
443
|
+
return settings.permission_mode == "accept"
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def should_auto_handoff(settings: CyclistSettings) -> bool:
|
|
447
|
+
"""Check if handoffs should be automatic based on settings.
|
|
448
|
+
|
|
449
|
+
Args:
|
|
450
|
+
settings: Current Cyclist settings
|
|
451
|
+
|
|
452
|
+
Returns:
|
|
453
|
+
True if relay_mode is enabled
|
|
454
|
+
"""
|
|
455
|
+
return settings.relay_mode
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Jira integration package for Pennyfarthing scripts.
|
|
3
|
+
|
|
4
|
+
This package provides:
|
|
5
|
+
- client: JiraClient REST API wrapper and helper functions
|
|
6
|
+
- sync: Epic sync to Jira
|
|
7
|
+
- bidirectional: Bidirectional sync between YAML and Jira
|
|
8
|
+
- epic: Epic creation
|
|
9
|
+
- story: Single story sync
|
|
10
|
+
- claim: Story claiming
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
# Use the client module
|
|
14
|
+
from pennyfarthing_scripts.jira import JiraClient
|
|
15
|
+
client = JiraClient()
|
|
16
|
+
issue = client.get_issue_sync("MSSCI-12345")
|
|
17
|
+
|
|
18
|
+
# Use CLI
|
|
19
|
+
python -m pennyfarthing_scripts.jira <subcommand> [args]
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
# Re-export from client for backwards compatibility
|
|
23
|
+
from pennyfarthing_scripts.jira.client import (
|
|
24
|
+
# Constants
|
|
25
|
+
GITHUB_TO_JIRA_MAP,
|
|
26
|
+
JIRA_PROJECT,
|
|
27
|
+
JIRA_TO_STATUS,
|
|
28
|
+
JIRA_URL,
|
|
29
|
+
STATUS_TO_JIRA,
|
|
30
|
+
# Classes
|
|
31
|
+
JiraClient,
|
|
32
|
+
# Functions
|
|
33
|
+
add_comment,
|
|
34
|
+
check_dependencies,
|
|
35
|
+
extract_jira_key,
|
|
36
|
+
get_client,
|
|
37
|
+
get_issue,
|
|
38
|
+
get_jira_field,
|
|
39
|
+
get_story_points,
|
|
40
|
+
is_jira_cli_available,
|
|
41
|
+
map_github_to_jira,
|
|
42
|
+
map_jira_to_status,
|
|
43
|
+
map_status_to_jira,
|
|
44
|
+
update_issue_status,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Import submodules to make them accessible
|
|
48
|
+
from pennyfarthing_scripts.jira import (
|
|
49
|
+
bidirectional,
|
|
50
|
+
claim,
|
|
51
|
+
client,
|
|
52
|
+
epic,
|
|
53
|
+
story,
|
|
54
|
+
sync,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# CLI entry point - import module, not function, so "from jira import cli" gets the module
|
|
58
|
+
from pennyfarthing_scripts.jira import cli
|
|
59
|
+
from pennyfarthing_scripts.jira.cli import main
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
# Constants
|
|
63
|
+
"GITHUB_TO_JIRA_MAP",
|
|
64
|
+
"JIRA_PROJECT",
|
|
65
|
+
"JIRA_TO_STATUS",
|
|
66
|
+
"JIRA_URL",
|
|
67
|
+
"STATUS_TO_JIRA",
|
|
68
|
+
# Classes
|
|
69
|
+
"JiraClient",
|
|
70
|
+
# Functions
|
|
71
|
+
"add_comment",
|
|
72
|
+
"check_dependencies",
|
|
73
|
+
"extract_jira_key",
|
|
74
|
+
"get_client",
|
|
75
|
+
"get_issue",
|
|
76
|
+
"get_jira_field",
|
|
77
|
+
"get_story_points",
|
|
78
|
+
"is_jira_cli_available",
|
|
79
|
+
"map_github_to_jira",
|
|
80
|
+
"map_jira_to_status",
|
|
81
|
+
"map_status_to_jira",
|
|
82
|
+
"update_issue_status",
|
|
83
|
+
# Submodules
|
|
84
|
+
"bidirectional",
|
|
85
|
+
"claim",
|
|
86
|
+
"client",
|
|
87
|
+
"epic",
|
|
88
|
+
"story",
|
|
89
|
+
"sync",
|
|
90
|
+
# CLI
|
|
91
|
+
"cli",
|
|
92
|
+
"main",
|
|
93
|
+
]
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|