moai-adk 0.8.1__py3-none-any.whl → 0.8.2__py3-none-any.whl
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.
Potentially problematic release.
This version of moai-adk might be problematic. Click here for more details.
- moai_adk/cli/commands/update.py +15 -4
- moai_adk/core/tags/__init__.py +87 -0
- moai_adk/core/tags/ci_validator.py +435 -0
- moai_adk/core/tags/cli.py +283 -0
- moai_adk/core/tags/generator.py +109 -0
- moai_adk/core/tags/inserter.py +99 -0
- moai_adk/core/tags/mapper.py +126 -0
- moai_adk/core/tags/parser.py +76 -0
- moai_adk/core/tags/pre_commit_validator.py +355 -0
- moai_adk/core/tags/reporter.py +959 -0
- moai_adk/core/tags/tags.py +149 -0
- moai_adk/core/tags/validator.py +897 -0
- moai_adk/templates/.claude/agents/alfred/cc-manager.md +25 -2
- moai_adk/templates/.claude/agents/alfred/debug-helper.md +24 -12
- moai_adk/templates/.claude/agents/alfred/doc-syncer.md +19 -12
- moai_adk/templates/.claude/agents/alfred/git-manager.md +20 -12
- moai_adk/templates/.claude/agents/alfred/implementation-planner.md +19 -12
- moai_adk/templates/.claude/agents/alfred/project-manager.md +29 -2
- moai_adk/templates/.claude/agents/alfred/quality-gate.md +25 -2
- moai_adk/templates/.claude/agents/alfred/skill-factory.md +30 -2
- moai_adk/templates/.claude/agents/alfred/spec-builder.md +26 -11
- moai_adk/templates/.claude/agents/alfred/tag-agent.md +30 -8
- moai_adk/templates/.claude/agents/alfred/tdd-implementer.md +27 -12
- moai_adk/templates/.claude/agents/alfred/trust-checker.md +25 -2
- moai_adk/templates/.claude/commands/alfred/0-project.md +5 -0
- moai_adk/templates/.claude/commands/alfred/1-plan.md +17 -4
- moai_adk/templates/.claude/commands/alfred/2-run.md +7 -0
- moai_adk/templates/.claude/commands/alfred/3-sync.md +6 -0
- moai_adk/templates/.claude/hooks/alfred/.moai/cache/version-check.json +9 -0
- moai_adk/templates/.claude/hooks/alfred/README.md +258 -145
- moai_adk/templates/.claude/hooks/alfred/TROUBLESHOOTING.md +471 -0
- moai_adk/templates/.claude/hooks/alfred/alfred_hooks.py +92 -57
- moai_adk/templates/.claude/hooks/alfred/core/version_cache.py +198 -0
- moai_adk/templates/.claude/hooks/alfred/notification__handle_events.py +102 -0
- moai_adk/templates/.claude/hooks/alfred/post_tool__log_changes.py +102 -0
- moai_adk/templates/.claude/hooks/alfred/pre_tool__auto_checkpoint.py +108 -0
- moai_adk/templates/.claude/hooks/alfred/session_end__cleanup.py +102 -0
- moai_adk/templates/.claude/hooks/alfred/session_start__show_project_info.py +102 -0
- moai_adk/templates/.claude/hooks/alfred/{core → shared/core}/project.py +269 -13
- moai_adk/templates/.claude/hooks/alfred/shared/core/version_cache.py +198 -0
- moai_adk/templates/.claude/hooks/alfred/{handlers → shared/handlers}/session.py +21 -7
- moai_adk/templates/.claude/hooks/alfred/stop__handle_interrupt.py +102 -0
- moai_adk/templates/.claude/hooks/alfred/subagent_stop__handle_subagent_end.py +102 -0
- moai_adk/templates/.claude/hooks/alfred/user_prompt__jit_load_docs.py +120 -0
- moai_adk/templates/.claude/settings.json +5 -5
- moai_adk/templates/.claude/skills/moai-foundation-ears/SKILL.md +9 -6
- moai_adk/templates/.claude/skills/moai-spec-authoring/README.md +56 -56
- moai_adk/templates/.claude/skills/moai-spec-authoring/SKILL.md +101 -100
- moai_adk/templates/.claude/skills/moai-spec-authoring/examples/validate-spec.sh +3 -3
- moai_adk/templates/.claude/skills/moai-spec-authoring/examples.md +219 -219
- moai_adk/templates/.claude/skills/moai-spec-authoring/reference.md +287 -287
- moai_adk/templates/.github/ISSUE_TEMPLATE/spec.yml +9 -11
- moai_adk/templates/.github/PULL_REQUEST_TEMPLATE.md +9 -21
- moai_adk/templates/.github/workflows/moai-release-create.yml +100 -0
- moai_adk/templates/.github/workflows/moai-release-pipeline.yml +182 -0
- moai_adk/templates/.github/workflows/release.yml +49 -0
- moai_adk/templates/.github/workflows/tag-report.yml +261 -0
- moai_adk/templates/.github/workflows/tag-validation.yml +176 -0
- moai_adk/templates/.moai/config.json +6 -1
- moai_adk/templates/.moai/hooks/install.sh +79 -0
- moai_adk/templates/.moai/hooks/pre-commit.sh +66 -0
- moai_adk/templates/CLAUDE.md +39 -40
- moai_adk/templates/src/moai_adk/core/__init__.py +5 -0
- moai_adk/templates/src/moai_adk/core/tags/__init__.py +87 -0
- moai_adk/templates/src/moai_adk/core/tags/ci_validator.py +435 -0
- moai_adk/templates/src/moai_adk/core/tags/cli.py +283 -0
- moai_adk/templates/src/moai_adk/core/tags/pre_commit_validator.py +355 -0
- moai_adk/templates/src/moai_adk/core/tags/reporter.py +959 -0
- moai_adk/templates/src/moai_adk/core/tags/validator.py +897 -0
- {moai_adk-0.8.1.dist-info → moai_adk-0.8.2.dist-info}/METADATA +226 -1
- {moai_adk-0.8.1.dist-info → moai_adk-0.8.2.dist-info}/RECORD +83 -50
- moai_adk/templates/.claude/hooks/alfred/HOOK_SCHEMA_VALIDATION.md +0 -313
- moai_adk/templates/.moai/memory/config-schema.md +0 -444
- moai_adk/templates/.moai/memory/gitflow-protection-policy.md +0 -220
- moai_adk/templates/.moai/memory/spec-metadata.md +0 -356
- /moai_adk/templates/.claude/hooks/alfred/{core → shared/core}/__init__.py +0 -0
- /moai_adk/templates/.claude/hooks/alfred/{core → shared/core}/checkpoint.py +0 -0
- /moai_adk/templates/.claude/hooks/alfred/{core → shared/core}/context.py +0 -0
- /moai_adk/templates/.claude/hooks/alfred/{core → shared/core}/tags.py +0 -0
- /moai_adk/templates/.claude/hooks/alfred/{handlers → shared/handlers}/__init__.py +0 -0
- /moai_adk/templates/.claude/hooks/alfred/{handlers → shared/handlers}/notification.py +0 -0
- /moai_adk/templates/.claude/hooks/alfred/{handlers → shared/handlers}/tool.py +0 -0
- /moai_adk/templates/.claude/hooks/alfred/{handlers → shared/handlers}/user.py +0 -0
- /moai_adk/templates/.moai/memory/{issue-label-mapping.md → ISSUE-LABEL-MAPPING.md} +0 -0
- {moai_adk-0.8.1.dist-info → moai_adk-0.8.2.dist-info}/WHEEL +0 -0
- {moai_adk-0.8.1.dist-info → moai_adk-0.8.2.dist-info}/entry_points.txt +0 -0
- {moai_adk-0.8.1.dist-info → moai_adk-0.8.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# @CODE:HOOKS-CLARITY-001 | SPEC: Individual hook files for better UX
|
|
3
|
+
"""SessionStart Hook: Show Project Information
|
|
4
|
+
|
|
5
|
+
Claude Code Event: SessionStart
|
|
6
|
+
Purpose: Display project status, language, Git info, and SPEC progress when session starts
|
|
7
|
+
Execution: Triggered automatically when Claude Code session begins
|
|
8
|
+
|
|
9
|
+
Output: System message with formatted project summary
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import signal
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
# Setup import path for shared modules
|
|
19
|
+
HOOKS_DIR = Path(__file__).parent
|
|
20
|
+
SHARED_DIR = HOOKS_DIR / "shared"
|
|
21
|
+
if str(SHARED_DIR) not in sys.path:
|
|
22
|
+
sys.path.insert(0, str(SHARED_DIR))
|
|
23
|
+
|
|
24
|
+
from handlers import handle_session_start
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class HookTimeoutError(Exception):
|
|
28
|
+
"""Hook execution timeout exception"""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _timeout_handler(signum, frame):
|
|
33
|
+
"""Signal handler for 5-second timeout"""
|
|
34
|
+
raise HookTimeoutError("Hook execution exceeded 5-second timeout")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def main() -> None:
|
|
38
|
+
"""Main entry point for SessionStart hook
|
|
39
|
+
|
|
40
|
+
Displays project information including:
|
|
41
|
+
- Programming language
|
|
42
|
+
- Git branch and status
|
|
43
|
+
- SPEC progress (completed/total)
|
|
44
|
+
- Recent checkpoints
|
|
45
|
+
|
|
46
|
+
Exit Codes:
|
|
47
|
+
0: Success
|
|
48
|
+
1: Error (timeout, JSON parse failure, handler exception)
|
|
49
|
+
"""
|
|
50
|
+
# Set 5-second timeout
|
|
51
|
+
signal.signal(signal.SIGALRM, _timeout_handler)
|
|
52
|
+
signal.alarm(5)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
# Read JSON payload from stdin
|
|
56
|
+
input_data = sys.stdin.read()
|
|
57
|
+
data = json.loads(input_data) if input_data.strip() else {}
|
|
58
|
+
|
|
59
|
+
# Call handler
|
|
60
|
+
result = handle_session_start(data)
|
|
61
|
+
|
|
62
|
+
# Output result as JSON
|
|
63
|
+
print(json.dumps(result.to_dict()))
|
|
64
|
+
sys.exit(0)
|
|
65
|
+
|
|
66
|
+
except HookTimeoutError:
|
|
67
|
+
# Timeout - return minimal valid response
|
|
68
|
+
timeout_response: dict[str, Any] = {
|
|
69
|
+
"continue": True,
|
|
70
|
+
"systemMessage": "⚠️ Session start timeout - continuing without project info"
|
|
71
|
+
}
|
|
72
|
+
print(json.dumps(timeout_response))
|
|
73
|
+
print("SessionStart hook timeout after 5 seconds", file=sys.stderr)
|
|
74
|
+
sys.exit(1)
|
|
75
|
+
|
|
76
|
+
except json.JSONDecodeError as e:
|
|
77
|
+
# JSON parse error
|
|
78
|
+
error_response: dict[str, Any] = {
|
|
79
|
+
"continue": True,
|
|
80
|
+
"hookSpecificOutput": {"error": f"JSON parse error: {e}"}
|
|
81
|
+
}
|
|
82
|
+
print(json.dumps(error_response))
|
|
83
|
+
print(f"SessionStart JSON parse error: {e}", file=sys.stderr)
|
|
84
|
+
sys.exit(1)
|
|
85
|
+
|
|
86
|
+
except Exception as e:
|
|
87
|
+
# Unexpected error
|
|
88
|
+
error_response: dict[str, Any] = {
|
|
89
|
+
"continue": True,
|
|
90
|
+
"hookSpecificOutput": {"error": f"SessionStart error: {e}"}
|
|
91
|
+
}
|
|
92
|
+
print(json.dumps(error_response))
|
|
93
|
+
print(f"SessionStart unexpected error: {e}", file=sys.stderr)
|
|
94
|
+
sys.exit(1)
|
|
95
|
+
|
|
96
|
+
finally:
|
|
97
|
+
# Always cancel alarm
|
|
98
|
+
signal.alarm(0)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if __name__ == "__main__":
|
|
102
|
+
main()
|
|
@@ -6,11 +6,15 @@ Project information inquiry (language, Git, SPEC progress, etc.)
|
|
|
6
6
|
|
|
7
7
|
import json
|
|
8
8
|
import signal
|
|
9
|
+
import socket
|
|
9
10
|
import subprocess
|
|
10
11
|
from contextlib import contextmanager
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
from typing import Any
|
|
13
14
|
|
|
15
|
+
# Cache directory for version check results
|
|
16
|
+
CACHE_DIR_NAME = ".moai/cache"
|
|
17
|
+
|
|
14
18
|
|
|
15
19
|
class TimeoutError(Exception):
|
|
16
20
|
"""Signal-based timeout exception"""
|
|
@@ -336,11 +340,184 @@ def get_project_language(cwd: str) -> str:
|
|
|
336
340
|
return detect_language(cwd)
|
|
337
341
|
|
|
338
342
|
|
|
339
|
-
|
|
340
|
-
|
|
343
|
+
# @CODE:CONFIG-INTEGRATION-001
|
|
344
|
+
def get_version_check_config(cwd: str) -> dict[str, Any]:
|
|
345
|
+
"""Read version check configuration from .moai/config.json
|
|
346
|
+
|
|
347
|
+
Returns version check settings with sensible defaults.
|
|
348
|
+
Supports frequency-based cache TTL configuration.
|
|
349
|
+
|
|
350
|
+
Args:
|
|
351
|
+
cwd: Project root directory path
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
dict with keys:
|
|
355
|
+
- "enabled": Boolean (default: True)
|
|
356
|
+
- "frequency": "always" | "daily" | "weekly" | "never" (default: "daily")
|
|
357
|
+
- "cache_ttl_hours": TTL in hours based on frequency
|
|
358
|
+
|
|
359
|
+
Frequency to TTL mapping:
|
|
360
|
+
- "always": 0 hours (no caching)
|
|
361
|
+
- "daily": 24 hours
|
|
362
|
+
- "weekly": 168 hours (7 days)
|
|
363
|
+
- "never": infinity (never check)
|
|
364
|
+
|
|
365
|
+
TDD History:
|
|
366
|
+
- RED: 8 test scenarios (defaults, custom, disabled, TTL, etc.)
|
|
367
|
+
- GREEN: Minimal config reading with defaults
|
|
368
|
+
- REFACTOR: Add validation and error handling
|
|
369
|
+
"""
|
|
370
|
+
# TTL mapping by frequency
|
|
371
|
+
TTL_BY_FREQUENCY = {
|
|
372
|
+
"always": 0,
|
|
373
|
+
"daily": 24,
|
|
374
|
+
"weekly": 168,
|
|
375
|
+
"never": float('inf')
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
# Default configuration
|
|
379
|
+
defaults = {
|
|
380
|
+
"enabled": True,
|
|
381
|
+
"frequency": "daily",
|
|
382
|
+
"cache_ttl_hours": 24
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
config_path = Path(cwd) / ".moai" / "config.json"
|
|
386
|
+
if not config_path.exists():
|
|
387
|
+
return defaults
|
|
388
|
+
|
|
389
|
+
try:
|
|
390
|
+
config = json.loads(config_path.read_text())
|
|
391
|
+
|
|
392
|
+
# Extract moai.version_check section
|
|
393
|
+
moai_config = config.get("moai", {})
|
|
394
|
+
version_check_config = moai_config.get("version_check", {})
|
|
395
|
+
|
|
396
|
+
# Read enabled flag (default: True)
|
|
397
|
+
enabled = version_check_config.get("enabled", defaults["enabled"])
|
|
398
|
+
|
|
399
|
+
# Read frequency (default: "daily")
|
|
400
|
+
frequency = moai_config.get("update_check_frequency", defaults["frequency"])
|
|
401
|
+
|
|
402
|
+
# Validate frequency
|
|
403
|
+
if frequency not in TTL_BY_FREQUENCY:
|
|
404
|
+
frequency = defaults["frequency"]
|
|
405
|
+
|
|
406
|
+
# Calculate TTL from frequency
|
|
407
|
+
cache_ttl_hours = TTL_BY_FREQUENCY[frequency]
|
|
408
|
+
|
|
409
|
+
# Allow explicit cache_ttl_hours override
|
|
410
|
+
if "cache_ttl_hours" in version_check_config:
|
|
411
|
+
cache_ttl_hours = version_check_config["cache_ttl_hours"]
|
|
412
|
+
|
|
413
|
+
return {
|
|
414
|
+
"enabled": enabled,
|
|
415
|
+
"frequency": frequency,
|
|
416
|
+
"cache_ttl_hours": cache_ttl_hours
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
except (OSError, json.JSONDecodeError, KeyError):
|
|
420
|
+
# Config read or parse error - return defaults
|
|
421
|
+
return defaults
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# @CODE:NETWORK-DETECT-001
|
|
425
|
+
def is_network_available(timeout_seconds: float = 0.1) -> bool:
|
|
426
|
+
"""Quick network availability check using socket.
|
|
427
|
+
|
|
428
|
+
Does NOT check PyPI specifically, just basic connectivity.
|
|
429
|
+
Returns immediately on success (< 50ms typically).
|
|
430
|
+
Returns False on any error without raising exceptions.
|
|
431
|
+
|
|
432
|
+
Args:
|
|
433
|
+
timeout_seconds: Socket timeout in seconds (default 0.1s)
|
|
434
|
+
|
|
435
|
+
Returns:
|
|
436
|
+
True if network appears available, False otherwise
|
|
437
|
+
|
|
438
|
+
Examples:
|
|
439
|
+
>>> is_network_available()
|
|
440
|
+
True # Network is available
|
|
441
|
+
>>> is_network_available(timeout_seconds=0.001)
|
|
442
|
+
False # Timeout too short, returns False
|
|
341
443
|
|
|
342
|
-
|
|
343
|
-
|
|
444
|
+
TDD History:
|
|
445
|
+
- RED: 3 test scenarios (success, failure, timeout)
|
|
446
|
+
- GREEN: Minimal socket.create_connection implementation
|
|
447
|
+
- REFACTOR: Add error handling for all exception types
|
|
448
|
+
"""
|
|
449
|
+
try:
|
|
450
|
+
# Try connecting to Google's public DNS server (8.8.8.8:53)
|
|
451
|
+
# This is a reliable host that's typically reachable
|
|
452
|
+
connection = socket.create_connection(("8.8.8.8", 53), timeout=timeout_seconds)
|
|
453
|
+
connection.close()
|
|
454
|
+
return True
|
|
455
|
+
except (socket.timeout, OSError, Exception):
|
|
456
|
+
# Any connection error means network is unavailable
|
|
457
|
+
# This includes: timeout, connection refused, network unreachable, etc.
|
|
458
|
+
return False
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
# @CODE:MAJOR-UPDATE-WARN-001
|
|
462
|
+
def is_major_version_change(current: str, latest: str) -> bool:
|
|
463
|
+
"""Detect if version change is a major version bump.
|
|
464
|
+
|
|
465
|
+
A major version change is when the first (major) component increases:
|
|
466
|
+
- 0.8.1 → 1.0.0: True (0 → 1)
|
|
467
|
+
- 1.2.3 → 2.0.0: True (1 → 2)
|
|
468
|
+
- 0.8.1 → 0.9.0: False (0 → 0, minor changed)
|
|
469
|
+
- 1.2.3 → 1.3.0: False (1 → 1)
|
|
470
|
+
|
|
471
|
+
Args:
|
|
472
|
+
current: Current version string (e.g., "0.8.1")
|
|
473
|
+
latest: Latest version string (e.g., "1.0.0")
|
|
474
|
+
|
|
475
|
+
Returns:
|
|
476
|
+
True if major version increased, False otherwise
|
|
477
|
+
|
|
478
|
+
Examples:
|
|
479
|
+
>>> is_major_version_change("0.8.1", "1.0.0")
|
|
480
|
+
True
|
|
481
|
+
>>> is_major_version_change("0.8.1", "0.9.0")
|
|
482
|
+
False
|
|
483
|
+
>>> is_major_version_change("dev", "1.0.0")
|
|
484
|
+
False # Invalid versions return False
|
|
485
|
+
|
|
486
|
+
TDD History:
|
|
487
|
+
- RED: 4 test scenarios (0→1, 1→2, minor, invalid)
|
|
488
|
+
- GREEN: Minimal version parsing and comparison
|
|
489
|
+
- REFACTOR: Improve error handling for invalid versions
|
|
490
|
+
"""
|
|
491
|
+
try:
|
|
492
|
+
# Parse version strings into integer components
|
|
493
|
+
current_parts = [int(x) for x in current.split(".")]
|
|
494
|
+
latest_parts = [int(x) for x in latest.split(".")]
|
|
495
|
+
|
|
496
|
+
# Compare major version (first component)
|
|
497
|
+
if len(current_parts) >= 1 and len(latest_parts) >= 1:
|
|
498
|
+
return latest_parts[0] > current_parts[0]
|
|
499
|
+
|
|
500
|
+
# If parsing succeeds but empty, no major change
|
|
501
|
+
return False
|
|
502
|
+
|
|
503
|
+
except (ValueError, AttributeError, IndexError):
|
|
504
|
+
# Invalid version format - return False (no exception)
|
|
505
|
+
return False
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# @CODE:VERSION-CACHE-INTEGRATION-001
|
|
509
|
+
def get_package_version_info(cwd: str = ".") -> dict[str, Any]:
|
|
510
|
+
"""Check MoAI-ADK current and latest version with caching and offline support.
|
|
511
|
+
|
|
512
|
+
Execution flow:
|
|
513
|
+
1. Try to load from cache (< 50ms)
|
|
514
|
+
2. If cache invalid, check network
|
|
515
|
+
3. If network available, query PyPI
|
|
516
|
+
4. If network unavailable, return current version only
|
|
517
|
+
5. Save result to cache for next time
|
|
518
|
+
|
|
519
|
+
Args:
|
|
520
|
+
cwd: Project root directory (for cache location)
|
|
344
521
|
|
|
345
522
|
Returns:
|
|
346
523
|
dict with keys:
|
|
@@ -348,16 +525,57 @@ def get_package_version_info() -> dict[str, Any]:
|
|
|
348
525
|
- "latest": Latest version available on PyPI
|
|
349
526
|
- "update_available": Boolean indicating if update is available
|
|
350
527
|
- "upgrade_command": Recommended upgrade command (if update available)
|
|
528
|
+
- "release_notes_url": URL to release notes (Phase 3)
|
|
529
|
+
- "is_major_update": Boolean indicating major version change (Phase 3)
|
|
351
530
|
|
|
352
531
|
Note:
|
|
353
|
-
-
|
|
354
|
-
-
|
|
355
|
-
-
|
|
532
|
+
- Cache hit (< 24 hours): Returns in ~20ms, no network access
|
|
533
|
+
- Cache miss + online: Query PyPI (1s timeout), cache result
|
|
534
|
+
- Cache miss + offline: Return current version only (~100ms)
|
|
535
|
+
- Offline + cached: Return from cache in ~20ms
|
|
536
|
+
|
|
537
|
+
TDD History:
|
|
538
|
+
- RED: 5 test scenarios (network detection, cache integration, offline mode)
|
|
539
|
+
- GREEN: Integrate VersionCache with network detection
|
|
540
|
+
- REFACTOR: Extract cache directory constant, improve error handling
|
|
541
|
+
- Phase 3: Add release_notes_url and is_major_update fields (@CODE:MAJOR-UPDATE-WARN-001)
|
|
356
542
|
"""
|
|
543
|
+
import importlib.util
|
|
357
544
|
import urllib.error
|
|
358
545
|
import urllib.request
|
|
359
546
|
from importlib.metadata import PackageNotFoundError, version
|
|
360
547
|
|
|
548
|
+
# Import VersionCache from the same directory (using dynamic import for testing compatibility)
|
|
549
|
+
try:
|
|
550
|
+
version_cache_path = Path(__file__).parent / "version_cache.py"
|
|
551
|
+
spec = importlib.util.spec_from_file_location("version_cache", version_cache_path)
|
|
552
|
+
if spec and spec.loader:
|
|
553
|
+
version_cache_module = importlib.util.module_from_spec(spec)
|
|
554
|
+
spec.loader.exec_module(version_cache_module)
|
|
555
|
+
VersionCache = version_cache_module.VersionCache
|
|
556
|
+
else:
|
|
557
|
+
# Skip caching if module can't be loaded
|
|
558
|
+
VersionCache = None
|
|
559
|
+
except (ImportError, OSError) as e:
|
|
560
|
+
# Graceful degradation: skip caching on import errors
|
|
561
|
+
VersionCache = None
|
|
562
|
+
|
|
563
|
+
# 1. Initialize cache (skip if VersionCache couldn't be imported)
|
|
564
|
+
cache_dir = Path(cwd) / CACHE_DIR_NAME
|
|
565
|
+
version_cache = VersionCache(cache_dir) if VersionCache else None
|
|
566
|
+
|
|
567
|
+
# 2. Try to load from cache (fast path)
|
|
568
|
+
if version_cache and version_cache.is_valid():
|
|
569
|
+
cached_info = version_cache.load()
|
|
570
|
+
if cached_info:
|
|
571
|
+
# Ensure new fields exist for backward compatibility
|
|
572
|
+
if "release_notes_url" not in cached_info:
|
|
573
|
+
# Add missing fields to old cached data
|
|
574
|
+
cached_info.setdefault("release_notes_url", None)
|
|
575
|
+
cached_info.setdefault("is_major_update", False)
|
|
576
|
+
return cached_info
|
|
577
|
+
|
|
578
|
+
# 3. Cache miss - need to query PyPI
|
|
361
579
|
result = {
|
|
362
580
|
"current": "unknown",
|
|
363
581
|
"latest": "unknown",
|
|
@@ -372,20 +590,45 @@ def get_package_version_info() -> dict[str, Any]:
|
|
|
372
590
|
result["current"] = "dev"
|
|
373
591
|
return result
|
|
374
592
|
|
|
375
|
-
#
|
|
593
|
+
# 4. Check if version check is enabled in config (Phase 4)
|
|
594
|
+
config = get_version_check_config(cwd)
|
|
595
|
+
if not config["enabled"]:
|
|
596
|
+
# Version check disabled - return only current version
|
|
597
|
+
return result
|
|
598
|
+
|
|
599
|
+
# 5. Check network before PyPI query
|
|
600
|
+
if not is_network_available():
|
|
601
|
+
# Offline mode - return current version only
|
|
602
|
+
return result
|
|
603
|
+
|
|
604
|
+
# 6. Network available - query PyPI
|
|
605
|
+
pypi_data = None
|
|
376
606
|
try:
|
|
377
607
|
with timeout_handler(1):
|
|
378
608
|
url = "https://pypi.org/pypi/moai-adk/json"
|
|
379
609
|
headers = {"Accept": "application/json"}
|
|
380
610
|
req = urllib.request.Request(url, headers=headers)
|
|
381
611
|
with urllib.request.urlopen(req, timeout=0.8) as response:
|
|
382
|
-
|
|
383
|
-
result["latest"] =
|
|
612
|
+
pypi_data = json.load(response)
|
|
613
|
+
result["latest"] = pypi_data.get("info", {}).get("version", "unknown")
|
|
614
|
+
|
|
615
|
+
# Extract release notes URL from project_urls
|
|
616
|
+
try:
|
|
617
|
+
project_urls = pypi_data.get("info", {}).get("project_urls", {})
|
|
618
|
+
release_url = project_urls.get("Changelog", "")
|
|
619
|
+
if not release_url:
|
|
620
|
+
# Fallback to GitHub releases URL pattern
|
|
621
|
+
release_url = f"https://github.com/modu-ai/moai-adk/releases/tag/v{result['latest']}"
|
|
622
|
+
result["release_notes_url"] = release_url
|
|
623
|
+
except (KeyError, AttributeError, TypeError):
|
|
624
|
+
result["release_notes_url"] = None
|
|
625
|
+
|
|
384
626
|
except (urllib.error.URLError, TimeoutError, Exception):
|
|
385
|
-
#
|
|
386
|
-
|
|
627
|
+
# PyPI query failed - return current version
|
|
628
|
+
result["release_notes_url"] = None
|
|
629
|
+
pass
|
|
387
630
|
|
|
388
|
-
# Compare versions (simple comparison)
|
|
631
|
+
# 7. Compare versions (simple comparison)
|
|
389
632
|
if result["current"] != "unknown" and result["latest"] != "unknown":
|
|
390
633
|
try:
|
|
391
634
|
# Parse versions for comparison
|
|
@@ -400,10 +643,20 @@ def get_package_version_info() -> dict[str, Any]:
|
|
|
400
643
|
if latest_parts > current_parts:
|
|
401
644
|
result["update_available"] = True
|
|
402
645
|
result["upgrade_command"] = f"uv pip install --upgrade moai-adk>={result['latest']}"
|
|
646
|
+
|
|
647
|
+
# Detect major version change
|
|
648
|
+
result["is_major_update"] = is_major_version_change(result["current"], result["latest"])
|
|
649
|
+
else:
|
|
650
|
+
result["is_major_update"] = False
|
|
403
651
|
except (ValueError, AttributeError):
|
|
404
652
|
# Version parsing failed - skip comparison
|
|
653
|
+
result["is_major_update"] = False
|
|
405
654
|
pass
|
|
406
655
|
|
|
656
|
+
# 8. Save result to cache (if caching is available)
|
|
657
|
+
if version_cache:
|
|
658
|
+
version_cache.save(result)
|
|
659
|
+
|
|
407
660
|
return result
|
|
408
661
|
|
|
409
662
|
|
|
@@ -412,5 +665,8 @@ __all__ = [
|
|
|
412
665
|
"get_git_info",
|
|
413
666
|
"count_specs",
|
|
414
667
|
"get_project_language",
|
|
668
|
+
"get_version_check_config",
|
|
669
|
+
"is_network_available",
|
|
670
|
+
"is_major_version_change",
|
|
415
671
|
"get_package_version_info",
|
|
416
672
|
]
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# @CODE:VERSION-CACHE-001
|
|
3
|
+
"""Version information cache with TTL support
|
|
4
|
+
|
|
5
|
+
TTL-based caching system for version check results to minimize network calls
|
|
6
|
+
during SessionStart hook execution.
|
|
7
|
+
|
|
8
|
+
SPEC: SPEC-UPDATE-ENHANCE-001 - SessionStart 버전 체크 시스템 강화
|
|
9
|
+
Phase 1: Cache System Implementation
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class VersionCache:
|
|
19
|
+
"""TTL-based version information cache
|
|
20
|
+
|
|
21
|
+
Caches version check results with configurable Time-To-Live (TTL)
|
|
22
|
+
to avoid excessive network calls to PyPI during SessionStart events.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
cache_dir: Directory to store cache file
|
|
26
|
+
ttl_hours: Time-to-live in hours (default 24)
|
|
27
|
+
cache_file: Path to the cache JSON file
|
|
28
|
+
|
|
29
|
+
Examples:
|
|
30
|
+
>>> cache = VersionCache(Path(".moai/cache"), ttl_hours=24)
|
|
31
|
+
>>> cache.save({"current_version": "0.8.1", "latest_version": "0.9.0"})
|
|
32
|
+
True
|
|
33
|
+
>>> cache.is_valid()
|
|
34
|
+
True
|
|
35
|
+
>>> data = cache.load()
|
|
36
|
+
>>> data["current_version"]
|
|
37
|
+
'0.8.1'
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, cache_dir: Path, ttl_hours: int = 24):
|
|
41
|
+
"""Initialize cache with TTL in hours
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
cache_dir: Directory where cache file will be stored
|
|
45
|
+
ttl_hours: Time-to-live in hours (default 24)
|
|
46
|
+
"""
|
|
47
|
+
self.cache_dir = Path(cache_dir)
|
|
48
|
+
self.ttl_hours = ttl_hours
|
|
49
|
+
self.cache_file = self.cache_dir / "version-check.json"
|
|
50
|
+
|
|
51
|
+
def _calculate_age_hours(self, last_check_iso: str) -> float:
|
|
52
|
+
"""Calculate age in hours from ISO timestamp (internal helper)
|
|
53
|
+
|
|
54
|
+
Normalizes timezone-aware and naive datetimes for consistent comparison.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
last_check_iso: ISO format timestamp string
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
Age in hours
|
|
61
|
+
|
|
62
|
+
Raises:
|
|
63
|
+
ValueError: If timestamp parsing fails
|
|
64
|
+
"""
|
|
65
|
+
last_check = datetime.fromisoformat(last_check_iso)
|
|
66
|
+
|
|
67
|
+
# Normalize to naive datetime (remove timezone for comparison)
|
|
68
|
+
if last_check.tzinfo is not None:
|
|
69
|
+
last_check = last_check.replace(tzinfo=None)
|
|
70
|
+
|
|
71
|
+
now = datetime.now()
|
|
72
|
+
return (now - last_check).total_seconds() / 3600
|
|
73
|
+
|
|
74
|
+
def is_valid(self) -> bool:
|
|
75
|
+
"""Check if cache exists and is not expired
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
True if cache file exists and is within TTL, False otherwise
|
|
79
|
+
|
|
80
|
+
Examples:
|
|
81
|
+
>>> cache = VersionCache(Path(".moai/cache"))
|
|
82
|
+
>>> cache.is_valid()
|
|
83
|
+
False # No cache file exists yet
|
|
84
|
+
"""
|
|
85
|
+
if not self.cache_file.exists():
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
with open(self.cache_file, 'r') as f:
|
|
90
|
+
data = json.load(f)
|
|
91
|
+
|
|
92
|
+
age_hours = self._calculate_age_hours(data["last_check"])
|
|
93
|
+
return age_hours < self.ttl_hours
|
|
94
|
+
|
|
95
|
+
except (json.JSONDecodeError, KeyError, ValueError, OSError):
|
|
96
|
+
# Corrupted or invalid cache file
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
def load(self) -> dict[str, Any] | None:
|
|
100
|
+
"""Load cached version info if valid
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Cached version info dictionary if valid, None otherwise
|
|
104
|
+
|
|
105
|
+
Examples:
|
|
106
|
+
>>> cache = VersionCache(Path(".moai/cache"))
|
|
107
|
+
>>> data = cache.load()
|
|
108
|
+
>>> data is None
|
|
109
|
+
True # No valid cache exists
|
|
110
|
+
"""
|
|
111
|
+
if not self.is_valid():
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
with open(self.cache_file, 'r') as f:
|
|
116
|
+
return json.load(f)
|
|
117
|
+
except (json.JSONDecodeError, OSError):
|
|
118
|
+
# Graceful degradation on read errors
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
def save(self, version_info: dict[str, Any]) -> bool:
|
|
122
|
+
"""Save version info to cache file
|
|
123
|
+
|
|
124
|
+
Creates cache directory if it doesn't exist.
|
|
125
|
+
Updates last_check timestamp to current time if not provided.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
version_info: Version information dictionary to cache
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
True on successful save, False on error
|
|
132
|
+
|
|
133
|
+
Examples:
|
|
134
|
+
>>> cache = VersionCache(Path(".moai/cache"))
|
|
135
|
+
>>> cache.save({"current_version": "0.8.1"})
|
|
136
|
+
True
|
|
137
|
+
"""
|
|
138
|
+
try:
|
|
139
|
+
# Create cache directory if it doesn't exist
|
|
140
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
|
|
142
|
+
# Update last_check timestamp only if not provided (for testing)
|
|
143
|
+
if "last_check" not in version_info:
|
|
144
|
+
version_info["last_check"] = datetime.now(timezone.utc).isoformat()
|
|
145
|
+
|
|
146
|
+
# Write to cache file
|
|
147
|
+
with open(self.cache_file, 'w') as f:
|
|
148
|
+
json.dump(version_info, f, indent=2)
|
|
149
|
+
|
|
150
|
+
return True
|
|
151
|
+
|
|
152
|
+
except (OSError, TypeError) as e:
|
|
153
|
+
# Graceful degradation on write errors
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
def clear(self) -> bool:
|
|
157
|
+
"""Clear/remove cache file
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
True if cache file was removed or didn't exist, False on error
|
|
161
|
+
|
|
162
|
+
Examples:
|
|
163
|
+
>>> cache = VersionCache(Path(".moai/cache"))
|
|
164
|
+
>>> cache.clear()
|
|
165
|
+
True
|
|
166
|
+
"""
|
|
167
|
+
try:
|
|
168
|
+
if self.cache_file.exists():
|
|
169
|
+
self.cache_file.unlink()
|
|
170
|
+
return True
|
|
171
|
+
except OSError:
|
|
172
|
+
return False
|
|
173
|
+
|
|
174
|
+
def get_age_hours(self) -> float:
|
|
175
|
+
"""Get age of cache in hours
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
Age in hours, or 0.0 if cache doesn't exist or is invalid
|
|
179
|
+
|
|
180
|
+
Examples:
|
|
181
|
+
>>> cache = VersionCache(Path(".moai/cache"))
|
|
182
|
+
>>> cache.get_age_hours()
|
|
183
|
+
0.0 # No cache exists
|
|
184
|
+
"""
|
|
185
|
+
if not self.cache_file.exists():
|
|
186
|
+
return 0.0
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
with open(self.cache_file, 'r') as f:
|
|
190
|
+
data = json.load(f)
|
|
191
|
+
|
|
192
|
+
return self._calculate_age_hours(data["last_check"])
|
|
193
|
+
|
|
194
|
+
except (json.JSONDecodeError, KeyError, ValueError, OSError):
|
|
195
|
+
return 0.0
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
__all__ = ["VersionCache"]
|
|
@@ -51,9 +51,11 @@ def handle_session_start(payload: HookPayload) -> HookResult:
|
|
|
51
51
|
- FIX: Prevent duplicate output of clear step (only compact step is displayed)
|
|
52
52
|
- UPDATE: Migrated to Claude Code standard Hook schema
|
|
53
53
|
- HOTFIX: Add graceful degradation for timeout scenarios (Issue #66)
|
|
54
|
+
- Phase 3: Add major version warning and release notes display (@TEST:MAJOR-UPDATE-001-07/08)
|
|
54
55
|
|
|
55
56
|
@TAG:CHECKPOINT-EVENT-001
|
|
56
57
|
@TAG:HOOKS-TIMEOUT-001
|
|
58
|
+
@CODE:MAJOR-UPDATE-WARN-001
|
|
57
59
|
"""
|
|
58
60
|
# Claude Code SessionStart runs in several stages (clear, compact, etc.)
|
|
59
61
|
# Ignore the "clear" stage and output messages only at the "compact" stage
|
|
@@ -113,14 +115,26 @@ def handle_session_start(payload: HookPayload) -> HookResult:
|
|
|
113
115
|
|
|
114
116
|
# Add version info first (at the top, right after title)
|
|
115
117
|
if version_info and version_info.get("current") != "unknown":
|
|
116
|
-
version_line = f" 🗿 MoAI-ADK Ver: {version_info['current']}"
|
|
117
118
|
if version_info.get("update_available"):
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
119
|
+
# Check if this is a major version update
|
|
120
|
+
if version_info.get("is_major_update"):
|
|
121
|
+
# Major version warning
|
|
122
|
+
lines.append(f" ⚠️ Major version update available: {version_info['current']} → {version_info['latest']}")
|
|
123
|
+
lines.append(" Breaking changes detected. Review release notes:")
|
|
124
|
+
if version_info.get("release_notes_url"):
|
|
125
|
+
lines.append(f" 📝 {version_info['release_notes_url']}")
|
|
126
|
+
else:
|
|
127
|
+
# Regular update
|
|
128
|
+
lines.append(f" 🗿 MoAI-ADK Ver: {version_info['current']} → {version_info['latest']} available ✨")
|
|
129
|
+
if version_info.get("release_notes_url"):
|
|
130
|
+
lines.append(f" 📝 Release Notes: {version_info['release_notes_url']}")
|
|
131
|
+
|
|
132
|
+
# Add upgrade recommendation
|
|
133
|
+
if version_info.get("upgrade_command"):
|
|
134
|
+
lines.append(f" ⬆️ Upgrade: {version_info['upgrade_command']}")
|
|
135
|
+
else:
|
|
136
|
+
# No update available - show current version only
|
|
137
|
+
lines.append(f" 🗿 MoAI-ADK Ver: {version_info['current']}")
|
|
124
138
|
|
|
125
139
|
# Add language info
|
|
126
140
|
lines.append(f" 🐍 Language: {language}")
|