@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.
Files changed (126) hide show
  1. package/package.json +2 -1
  2. package/pennyfarthing-dist/scripts/core/prime.sh +8 -0
  3. package/pennyfarthing_scripts/__init__.py +17 -0
  4. package/pennyfarthing_scripts/__pycache__/__init__.cpython-311.pyc +0 -0
  5. package/pennyfarthing_scripts/__pycache__/__init__.cpython-314.pyc +0 -0
  6. package/pennyfarthing_scripts/__pycache__/config.cpython-314.pyc +0 -0
  7. package/pennyfarthing_scripts/__pycache__/jira.cpython-314.pyc +0 -0
  8. package/pennyfarthing_scripts/__pycache__/jira_epic_creation.cpython-314.pyc +0 -0
  9. package/pennyfarthing_scripts/__pycache__/jira_sync.cpython-314.pyc +0 -0
  10. package/pennyfarthing_scripts/__pycache__/jira_sync_story.cpython-314.pyc +0 -0
  11. package/pennyfarthing_scripts/__pycache__/sprint.cpython-314.pyc +0 -0
  12. package/pennyfarthing_scripts/__pycache__/workflow.cpython-311.pyc +0 -0
  13. package/pennyfarthing_scripts/__pycache__/workflow.cpython-314.pyc +0 -0
  14. package/pennyfarthing_scripts/bellmode_hook.py +154 -0
  15. package/pennyfarthing_scripts/brownfield/__init__.py +35 -0
  16. package/pennyfarthing_scripts/brownfield/__main__.py +7 -0
  17. package/pennyfarthing_scripts/brownfield/__pycache__/__init__.cpython-314.pyc +0 -0
  18. package/pennyfarthing_scripts/brownfield/__pycache__/__main__.cpython-314.pyc +0 -0
  19. package/pennyfarthing_scripts/brownfield/__pycache__/cli.cpython-314.pyc +0 -0
  20. package/pennyfarthing_scripts/brownfield/__pycache__/discover.cpython-314.pyc +0 -0
  21. package/pennyfarthing_scripts/brownfield/cli.py +131 -0
  22. package/pennyfarthing_scripts/brownfield/discover.py +753 -0
  23. package/pennyfarthing_scripts/common/__init__.py +49 -0
  24. package/pennyfarthing_scripts/common/__pycache__/__init__.cpython-314.pyc +0 -0
  25. package/pennyfarthing_scripts/common/__pycache__/config.cpython-314.pyc +0 -0
  26. package/pennyfarthing_scripts/common/__pycache__/output.cpython-314.pyc +0 -0
  27. package/pennyfarthing_scripts/common/config.py +65 -0
  28. package/pennyfarthing_scripts/common/output.py +180 -0
  29. package/pennyfarthing_scripts/config.py +21 -0
  30. package/pennyfarthing_scripts/git/__init__.py +29 -0
  31. package/pennyfarthing_scripts/git/__pycache__/__init__.cpython-314.pyc +0 -0
  32. package/pennyfarthing_scripts/git/__pycache__/create_branches.cpython-314.pyc +0 -0
  33. package/pennyfarthing_scripts/git/__pycache__/status_all.cpython-314.pyc +0 -0
  34. package/pennyfarthing_scripts/git/create_branches.py +439 -0
  35. package/pennyfarthing_scripts/git/status_all.py +310 -0
  36. package/pennyfarthing_scripts/hooks.py +455 -0
  37. package/pennyfarthing_scripts/jira/__init__.py +93 -0
  38. package/pennyfarthing_scripts/jira/__main__.py +10 -0
  39. package/pennyfarthing_scripts/jira/__pycache__/__init__.cpython-314.pyc +0 -0
  40. package/pennyfarthing_scripts/jira/__pycache__/__main__.cpython-314.pyc +0 -0
  41. package/pennyfarthing_scripts/jira/__pycache__/bidirectional.cpython-314.pyc +0 -0
  42. package/pennyfarthing_scripts/jira/__pycache__/claim.cpython-314.pyc +0 -0
  43. package/pennyfarthing_scripts/jira/__pycache__/cli.cpython-314.pyc +0 -0
  44. package/pennyfarthing_scripts/jira/__pycache__/client.cpython-314.pyc +0 -0
  45. package/pennyfarthing_scripts/jira/__pycache__/compat.cpython-314.pyc +0 -0
  46. package/pennyfarthing_scripts/jira/__pycache__/epic.cpython-314.pyc +0 -0
  47. package/pennyfarthing_scripts/jira/__pycache__/mappings.cpython-314.pyc +0 -0
  48. package/pennyfarthing_scripts/jira/__pycache__/models.cpython-314.pyc +0 -0
  49. package/pennyfarthing_scripts/jira/__pycache__/story.cpython-314.pyc +0 -0
  50. package/pennyfarthing_scripts/jira/__pycache__/sync.cpython-314.pyc +0 -0
  51. package/pennyfarthing_scripts/jira/bidirectional.py +561 -0
  52. package/pennyfarthing_scripts/jira/claim.py +211 -0
  53. package/pennyfarthing_scripts/jira/cli.py +150 -0
  54. package/pennyfarthing_scripts/jira/client.py +613 -0
  55. package/pennyfarthing_scripts/jira/epic.py +176 -0
  56. package/pennyfarthing_scripts/jira/story.py +219 -0
  57. package/pennyfarthing_scripts/jira/sync.py +350 -0
  58. package/pennyfarthing_scripts/jira_bidirectional_sync.py +37 -0
  59. package/pennyfarthing_scripts/jira_epic_creation.py +30 -0
  60. package/pennyfarthing_scripts/jira_sync.py +36 -0
  61. package/pennyfarthing_scripts/jira_sync_story.py +30 -0
  62. package/pennyfarthing_scripts/output.py +37 -0
  63. package/pennyfarthing_scripts/preflight/__init__.py +17 -0
  64. package/pennyfarthing_scripts/preflight/__main__.py +10 -0
  65. package/pennyfarthing_scripts/preflight/__pycache__/__init__.cpython-314.pyc +0 -0
  66. package/pennyfarthing_scripts/preflight/__pycache__/__main__.cpython-314.pyc +0 -0
  67. package/pennyfarthing_scripts/preflight/__pycache__/cli.cpython-314.pyc +0 -0
  68. package/pennyfarthing_scripts/preflight/__pycache__/finish.cpython-314.pyc +0 -0
  69. package/pennyfarthing_scripts/preflight/cli.py +141 -0
  70. package/pennyfarthing_scripts/preflight/finish.py +382 -0
  71. package/pennyfarthing_scripts/pretooluse_hook.py +142 -0
  72. package/pennyfarthing_scripts/prime/__init__.py +38 -0
  73. package/pennyfarthing_scripts/prime/__main__.py +8 -0
  74. package/pennyfarthing_scripts/prime/__pycache__/__init__.cpython-314.pyc +0 -0
  75. package/pennyfarthing_scripts/prime/__pycache__/__main__.cpython-314.pyc +0 -0
  76. package/pennyfarthing_scripts/prime/__pycache__/cli.cpython-314.pyc +0 -0
  77. package/pennyfarthing_scripts/prime/__pycache__/loader.cpython-314.pyc +0 -0
  78. package/pennyfarthing_scripts/prime/__pycache__/models.cpython-314.pyc +0 -0
  79. package/pennyfarthing_scripts/prime/__pycache__/persona.cpython-314.pyc +0 -0
  80. package/pennyfarthing_scripts/prime/__pycache__/session.cpython-314.pyc +0 -0
  81. package/pennyfarthing_scripts/prime/__pycache__/workflow.cpython-314.pyc +0 -0
  82. package/pennyfarthing_scripts/prime/cli.py +220 -0
  83. package/pennyfarthing_scripts/prime/loader.py +239 -0
  84. package/pennyfarthing_scripts/sprint/__init__.py +66 -0
  85. package/pennyfarthing_scripts/sprint/__main__.py +10 -0
  86. package/pennyfarthing_scripts/sprint/__pycache__/__init__.cpython-314.pyc +0 -0
  87. package/pennyfarthing_scripts/sprint/__pycache__/__main__.cpython-314.pyc +0 -0
  88. package/pennyfarthing_scripts/sprint/__pycache__/archive.cpython-314.pyc +0 -0
  89. package/pennyfarthing_scripts/sprint/__pycache__/cli.cpython-314.pyc +0 -0
  90. package/pennyfarthing_scripts/sprint/__pycache__/loader.cpython-314.pyc +0 -0
  91. package/pennyfarthing_scripts/sprint/__pycache__/status.cpython-314.pyc +0 -0
  92. package/pennyfarthing_scripts/sprint/__pycache__/validator.cpython-314.pyc +0 -0
  93. package/pennyfarthing_scripts/sprint/__pycache__/work.cpython-314.pyc +0 -0
  94. package/pennyfarthing_scripts/sprint/archive.py +108 -0
  95. package/pennyfarthing_scripts/sprint/cli.py +124 -0
  96. package/pennyfarthing_scripts/sprint/loader.py +193 -0
  97. package/pennyfarthing_scripts/sprint/status.py +122 -0
  98. package/pennyfarthing_scripts/sprint/validator.py +405 -0
  99. package/pennyfarthing_scripts/sprint/work.py +192 -0
  100. package/pennyfarthing_scripts/story/__init__.py +67 -0
  101. package/pennyfarthing_scripts/story/__main__.py +10 -0
  102. package/pennyfarthing_scripts/story/cli.py +105 -0
  103. package/pennyfarthing_scripts/story/create.py +167 -0
  104. package/pennyfarthing_scripts/story/size.py +113 -0
  105. package/pennyfarthing_scripts/story/template.py +151 -0
  106. package/pennyfarthing_scripts/swebench.py +216 -0
  107. package/pennyfarthing_scripts/tests/__init__.py +1 -0
  108. package/pennyfarthing_scripts/tests/__pycache__/__init__.cpython-314.pyc +0 -0
  109. package/pennyfarthing_scripts/tests/__pycache__/conftest.cpython-314-pytest-9.0.2.pyc +0 -0
  110. package/pennyfarthing_scripts/tests/__pycache__/test_brownfield.cpython-314-pytest-9.0.2.pyc +0 -0
  111. package/pennyfarthing_scripts/tests/__pycache__/test_git_utils.cpython-314-pytest-9.0.2.pyc +0 -0
  112. package/pennyfarthing_scripts/tests/__pycache__/test_prime.cpython-314-pytest-9.0.2.pyc +0 -0
  113. package/pennyfarthing_scripts/tests/__pycache__/test_sprint_validator.cpython-314-pytest-9.0.2.pyc +0 -0
  114. package/pennyfarthing_scripts/tests/conftest.py +106 -0
  115. package/pennyfarthing_scripts/tests/test_brownfield.py +842 -0
  116. package/pennyfarthing_scripts/tests/test_cli_modules.py +245 -0
  117. package/pennyfarthing_scripts/tests/test_common.py +180 -0
  118. package/pennyfarthing_scripts/tests/test_git_utils.py +866 -0
  119. package/pennyfarthing_scripts/tests/test_jira_package.py +334 -0
  120. package/pennyfarthing_scripts/tests/test_package_structure.py +372 -0
  121. package/pennyfarthing_scripts/tests/test_prime.py +397 -0
  122. package/pennyfarthing_scripts/tests/test_sprint_package.py +236 -0
  123. package/pennyfarthing_scripts/tests/test_sprint_validator.py +675 -0
  124. package/pennyfarthing_scripts/tests/test_story_package.py +156 -0
  125. package/pennyfarthing_scripts/welcome_hook.py +157 -0
  126. 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
+ ]
@@ -0,0 +1,10 @@
1
+ """
2
+ Entry point for python -m pennyfarthing_scripts.jira
3
+ """
4
+
5
+ import sys
6
+
7
+ from pennyfarthing_scripts.jira.cli import cli
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(cli())