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.

Files changed (87) hide show
  1. moai_adk/cli/commands/update.py +15 -4
  2. moai_adk/core/tags/__init__.py +87 -0
  3. moai_adk/core/tags/ci_validator.py +435 -0
  4. moai_adk/core/tags/cli.py +283 -0
  5. moai_adk/core/tags/generator.py +109 -0
  6. moai_adk/core/tags/inserter.py +99 -0
  7. moai_adk/core/tags/mapper.py +126 -0
  8. moai_adk/core/tags/parser.py +76 -0
  9. moai_adk/core/tags/pre_commit_validator.py +355 -0
  10. moai_adk/core/tags/reporter.py +959 -0
  11. moai_adk/core/tags/tags.py +149 -0
  12. moai_adk/core/tags/validator.py +897 -0
  13. moai_adk/templates/.claude/agents/alfred/cc-manager.md +25 -2
  14. moai_adk/templates/.claude/agents/alfred/debug-helper.md +24 -12
  15. moai_adk/templates/.claude/agents/alfred/doc-syncer.md +19 -12
  16. moai_adk/templates/.claude/agents/alfred/git-manager.md +20 -12
  17. moai_adk/templates/.claude/agents/alfred/implementation-planner.md +19 -12
  18. moai_adk/templates/.claude/agents/alfred/project-manager.md +29 -2
  19. moai_adk/templates/.claude/agents/alfred/quality-gate.md +25 -2
  20. moai_adk/templates/.claude/agents/alfred/skill-factory.md +30 -2
  21. moai_adk/templates/.claude/agents/alfred/spec-builder.md +26 -11
  22. moai_adk/templates/.claude/agents/alfred/tag-agent.md +30 -8
  23. moai_adk/templates/.claude/agents/alfred/tdd-implementer.md +27 -12
  24. moai_adk/templates/.claude/agents/alfred/trust-checker.md +25 -2
  25. moai_adk/templates/.claude/commands/alfred/0-project.md +5 -0
  26. moai_adk/templates/.claude/commands/alfred/1-plan.md +17 -4
  27. moai_adk/templates/.claude/commands/alfred/2-run.md +7 -0
  28. moai_adk/templates/.claude/commands/alfred/3-sync.md +6 -0
  29. moai_adk/templates/.claude/hooks/alfred/.moai/cache/version-check.json +9 -0
  30. moai_adk/templates/.claude/hooks/alfred/README.md +258 -145
  31. moai_adk/templates/.claude/hooks/alfred/TROUBLESHOOTING.md +471 -0
  32. moai_adk/templates/.claude/hooks/alfred/alfred_hooks.py +92 -57
  33. moai_adk/templates/.claude/hooks/alfred/core/version_cache.py +198 -0
  34. moai_adk/templates/.claude/hooks/alfred/notification__handle_events.py +102 -0
  35. moai_adk/templates/.claude/hooks/alfred/post_tool__log_changes.py +102 -0
  36. moai_adk/templates/.claude/hooks/alfred/pre_tool__auto_checkpoint.py +108 -0
  37. moai_adk/templates/.claude/hooks/alfred/session_end__cleanup.py +102 -0
  38. moai_adk/templates/.claude/hooks/alfred/session_start__show_project_info.py +102 -0
  39. moai_adk/templates/.claude/hooks/alfred/{core → shared/core}/project.py +269 -13
  40. moai_adk/templates/.claude/hooks/alfred/shared/core/version_cache.py +198 -0
  41. moai_adk/templates/.claude/hooks/alfred/{handlers → shared/handlers}/session.py +21 -7
  42. moai_adk/templates/.claude/hooks/alfred/stop__handle_interrupt.py +102 -0
  43. moai_adk/templates/.claude/hooks/alfred/subagent_stop__handle_subagent_end.py +102 -0
  44. moai_adk/templates/.claude/hooks/alfred/user_prompt__jit_load_docs.py +120 -0
  45. moai_adk/templates/.claude/settings.json +5 -5
  46. moai_adk/templates/.claude/skills/moai-foundation-ears/SKILL.md +9 -6
  47. moai_adk/templates/.claude/skills/moai-spec-authoring/README.md +56 -56
  48. moai_adk/templates/.claude/skills/moai-spec-authoring/SKILL.md +101 -100
  49. moai_adk/templates/.claude/skills/moai-spec-authoring/examples/validate-spec.sh +3 -3
  50. moai_adk/templates/.claude/skills/moai-spec-authoring/examples.md +219 -219
  51. moai_adk/templates/.claude/skills/moai-spec-authoring/reference.md +287 -287
  52. moai_adk/templates/.github/ISSUE_TEMPLATE/spec.yml +9 -11
  53. moai_adk/templates/.github/PULL_REQUEST_TEMPLATE.md +9 -21
  54. moai_adk/templates/.github/workflows/moai-release-create.yml +100 -0
  55. moai_adk/templates/.github/workflows/moai-release-pipeline.yml +182 -0
  56. moai_adk/templates/.github/workflows/release.yml +49 -0
  57. moai_adk/templates/.github/workflows/tag-report.yml +261 -0
  58. moai_adk/templates/.github/workflows/tag-validation.yml +176 -0
  59. moai_adk/templates/.moai/config.json +6 -1
  60. moai_adk/templates/.moai/hooks/install.sh +79 -0
  61. moai_adk/templates/.moai/hooks/pre-commit.sh +66 -0
  62. moai_adk/templates/CLAUDE.md +39 -40
  63. moai_adk/templates/src/moai_adk/core/__init__.py +5 -0
  64. moai_adk/templates/src/moai_adk/core/tags/__init__.py +87 -0
  65. moai_adk/templates/src/moai_adk/core/tags/ci_validator.py +435 -0
  66. moai_adk/templates/src/moai_adk/core/tags/cli.py +283 -0
  67. moai_adk/templates/src/moai_adk/core/tags/pre_commit_validator.py +355 -0
  68. moai_adk/templates/src/moai_adk/core/tags/reporter.py +959 -0
  69. moai_adk/templates/src/moai_adk/core/tags/validator.py +897 -0
  70. {moai_adk-0.8.1.dist-info → moai_adk-0.8.2.dist-info}/METADATA +226 -1
  71. {moai_adk-0.8.1.dist-info → moai_adk-0.8.2.dist-info}/RECORD +83 -50
  72. moai_adk/templates/.claude/hooks/alfred/HOOK_SCHEMA_VALIDATION.md +0 -313
  73. moai_adk/templates/.moai/memory/config-schema.md +0 -444
  74. moai_adk/templates/.moai/memory/gitflow-protection-policy.md +0 -220
  75. moai_adk/templates/.moai/memory/spec-metadata.md +0 -356
  76. /moai_adk/templates/.claude/hooks/alfred/{core → shared/core}/__init__.py +0 -0
  77. /moai_adk/templates/.claude/hooks/alfred/{core → shared/core}/checkpoint.py +0 -0
  78. /moai_adk/templates/.claude/hooks/alfred/{core → shared/core}/context.py +0 -0
  79. /moai_adk/templates/.claude/hooks/alfred/{core → shared/core}/tags.py +0 -0
  80. /moai_adk/templates/.claude/hooks/alfred/{handlers → shared/handlers}/__init__.py +0 -0
  81. /moai_adk/templates/.claude/hooks/alfred/{handlers → shared/handlers}/notification.py +0 -0
  82. /moai_adk/templates/.claude/hooks/alfred/{handlers → shared/handlers}/tool.py +0 -0
  83. /moai_adk/templates/.claude/hooks/alfred/{handlers → shared/handlers}/user.py +0 -0
  84. /moai_adk/templates/.moai/memory/{issue-label-mapping.md → ISSUE-LABEL-MAPPING.md} +0 -0
  85. {moai_adk-0.8.1.dist-info → moai_adk-0.8.2.dist-info}/WHEEL +0 -0
  86. {moai_adk-0.8.1.dist-info → moai_adk-0.8.2.dist-info}/entry_points.txt +0 -0
  87. {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
- def get_package_version_info() -> dict[str, Any]:
340
- """Check MoAI-ADK current and latest version from PyPI
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
- Compares the installed version with the latest version available on PyPI.
343
- Returns version information for SessionStart hook to display update recommendations.
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
- - Has 1-second timeout to avoid blocking SessionStart
354
- - Returns graceful fallback if PyPI check fails
355
- - Handles version parsing gracefully
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
- # Get latest version from PyPI (with 1-second timeout)
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
- data = json.load(response)
383
- result["latest"] = data.get("info", {}).get("version", "unknown")
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
- # Network error or timeout - return with unknown latest version
386
- return result
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
- version_line += f" {version_info['latest']} available ✨"
119
- lines.append(version_line)
120
-
121
- # Add upgrade recommendation if update is available
122
- if version_info.get("update_available") and version_info.get("upgrade_command"):
123
- lines.append(f" ⬆️ Upgrade: {version_info['upgrade_command']}")
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}")