monoco-toolkit 0.3.5__py3-none-any.whl → 0.3.9__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.
- monoco/cli/workspace.py +1 -1
- monoco/core/config.py +51 -0
- monoco/core/hooks/__init__.py +19 -0
- monoco/core/hooks/base.py +104 -0
- monoco/core/hooks/builtin/__init__.py +11 -0
- monoco/core/hooks/builtin/git_cleanup.py +266 -0
- monoco/core/hooks/builtin/logging_hook.py +78 -0
- monoco/core/hooks/context.py +131 -0
- monoco/core/hooks/registry.py +222 -0
- monoco/core/integrations.py +6 -0
- monoco/core/registry.py +2 -0
- monoco/core/setup.py +1 -1
- monoco/core/skills.py +226 -42
- monoco/features/{scheduler → agent}/__init__.py +4 -2
- monoco/features/{scheduler → agent}/cli.py +134 -80
- monoco/features/{scheduler → agent}/config.py +17 -3
- monoco/features/agent/defaults.py +55 -0
- monoco/features/agent/flow_skills.py +281 -0
- monoco/features/{scheduler → agent}/manager.py +39 -2
- monoco/features/{scheduler → agent}/models.py +6 -3
- monoco/features/{scheduler → agent}/reliability.py +1 -1
- monoco/features/agent/resources/skills/flow_engineer/SKILL.md +94 -0
- monoco/features/agent/resources/skills/flow_manager/SKILL.md +88 -0
- monoco/features/agent/resources/skills/flow_reviewer/SKILL.md +114 -0
- monoco/features/{scheduler → agent}/session.py +39 -5
- monoco/features/{scheduler → agent}/worker.py +2 -2
- monoco/features/i18n/resources/skills/i18n_scan_workflow/SKILL.md +105 -0
- monoco/features/issue/commands.py +427 -21
- monoco/features/issue/core.py +104 -0
- monoco/features/issue/criticality.py +553 -0
- monoco/features/issue/domain/models.py +28 -2
- monoco/features/issue/engine/machine.py +65 -37
- monoco/features/issue/git_service.py +185 -0
- monoco/features/issue/linter.py +291 -62
- monoco/features/issue/models.py +91 -14
- monoco/features/issue/resources/en/SKILL.md +48 -0
- monoco/features/issue/resources/skills/issue_lifecycle_workflow/SKILL.md +159 -0
- monoco/features/issue/resources/zh/SKILL.md +50 -0
- monoco/features/issue/test_priority_integration.py +1 -0
- monoco/features/issue/validator.py +185 -65
- monoco/features/memo/__init__.py +4 -0
- monoco/features/memo/adapter.py +32 -0
- monoco/features/memo/cli.py +112 -0
- monoco/features/memo/core.py +146 -0
- monoco/features/memo/resources/skills/note_processing_workflow/SKILL.md +140 -0
- monoco/features/memo/resources/zh/AGENTS.md +8 -0
- monoco/features/memo/resources/zh/SKILL.md +75 -0
- monoco/features/spike/resources/skills/research_workflow/SKILL.md +121 -0
- monoco/main.py +6 -3
- {monoco_toolkit-0.3.5.dist-info → monoco_toolkit-0.3.9.dist-info}/METADATA +1 -1
- {monoco_toolkit-0.3.5.dist-info → monoco_toolkit-0.3.9.dist-info}/RECORD +56 -35
- monoco/features/scheduler/defaults.py +0 -54
- monoco/features/skills/__init__.py +0 -0
- monoco/features/skills/core.py +0 -102
- /monoco/core/{hooks.py → githooks.py} +0 -0
- /monoco/features/{scheduler → agent}/engines.py +0 -0
- {monoco_toolkit-0.3.5.dist-info → monoco_toolkit-0.3.9.dist-info}/WHEEL +0 -0
- {monoco_toolkit-0.3.5.dist-info → monoco_toolkit-0.3.9.dist-info}/entry_points.txt +0 -0
- {monoco_toolkit-0.3.5.dist-info → monoco_toolkit-0.3.9.dist-info}/licenses/LICENSE +0 -0
monoco/cli/workspace.py
CHANGED
|
@@ -4,7 +4,7 @@ from rich.console import Console
|
|
|
4
4
|
import yaml
|
|
5
5
|
|
|
6
6
|
from monoco.core.output import AgentOutput, OutputManager
|
|
7
|
-
from monoco.core.
|
|
7
|
+
from monoco.core.githooks import install_hooks
|
|
8
8
|
|
|
9
9
|
app = typer.Typer(help="Manage Monoco Workspace")
|
|
10
10
|
console = Console()
|
monoco/core/config.py
CHANGED
|
@@ -93,12 +93,55 @@ class TransitionConfig(BaseModel):
|
|
|
93
93
|
command_template: Optional[str] = None
|
|
94
94
|
|
|
95
95
|
|
|
96
|
+
class CriticalityRuleConfig(BaseModel):
|
|
97
|
+
"""Configuration for auto-escalation rules."""
|
|
98
|
+
|
|
99
|
+
name: str
|
|
100
|
+
description: str = ""
|
|
101
|
+
path_patterns: List[str] = Field(default_factory=list)
|
|
102
|
+
tag_patterns: List[str] = Field(default_factory=list)
|
|
103
|
+
target_level: str = "medium" # low, medium, high, critical
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class CriticalityConfig(BaseModel):
|
|
107
|
+
"""Configuration for issue criticality system."""
|
|
108
|
+
|
|
109
|
+
enabled: bool = Field(default=True)
|
|
110
|
+
# Type to criticality default mapping
|
|
111
|
+
type_defaults: Dict[str, str] = Field(
|
|
112
|
+
default_factory=lambda: {
|
|
113
|
+
"epic": "high",
|
|
114
|
+
"feature": "medium",
|
|
115
|
+
"chore": "low",
|
|
116
|
+
"fix": "high",
|
|
117
|
+
}
|
|
118
|
+
)
|
|
119
|
+
# Auto-escalation rules
|
|
120
|
+
auto_rules: List[CriticalityRuleConfig] = Field(default_factory=list)
|
|
121
|
+
|
|
122
|
+
def merge(self, other: "CriticalityConfig") -> "CriticalityConfig":
|
|
123
|
+
if not other:
|
|
124
|
+
return self
|
|
125
|
+
if other.enabled is not None:
|
|
126
|
+
self.enabled = other.enabled
|
|
127
|
+
if other.type_defaults:
|
|
128
|
+
self.type_defaults.update(other.type_defaults)
|
|
129
|
+
if other.auto_rules:
|
|
130
|
+
# Merge by name
|
|
131
|
+
existing = {r.name: r for r in self.auto_rules}
|
|
132
|
+
for rule in other.auto_rules:
|
|
133
|
+
existing[rule.name] = rule
|
|
134
|
+
self.auto_rules = list(existing.values())
|
|
135
|
+
return self
|
|
136
|
+
|
|
137
|
+
|
|
96
138
|
class IssueSchemaConfig(BaseModel):
|
|
97
139
|
types: List[IssueTypeConfig] = Field(default_factory=list)
|
|
98
140
|
statuses: List[str] = Field(default_factory=list)
|
|
99
141
|
stages: List[str] = Field(default_factory=list)
|
|
100
142
|
solutions: List[str] = Field(default_factory=list)
|
|
101
143
|
workflows: List[TransitionConfig] = Field(default_factory=list)
|
|
144
|
+
criticality: CriticalityConfig = Field(default_factory=CriticalityConfig)
|
|
102
145
|
|
|
103
146
|
def merge(self, other: "IssueSchemaConfig") -> "IssueSchemaConfig":
|
|
104
147
|
if not other:
|
|
@@ -130,6 +173,10 @@ class IssueSchemaConfig(BaseModel):
|
|
|
130
173
|
wf_map[ow.name] = ow
|
|
131
174
|
self.workflows = list(wf_map.values())
|
|
132
175
|
|
|
176
|
+
# Criticality config
|
|
177
|
+
if other.criticality:
|
|
178
|
+
self.criticality = self.criticality.merge(other.criticality)
|
|
179
|
+
|
|
133
180
|
return self
|
|
134
181
|
|
|
135
182
|
|
|
@@ -187,6 +234,10 @@ class MonocoConfig(BaseModel):
|
|
|
187
234
|
default_factory=dict,
|
|
188
235
|
description="Git hooks configuration (hook_name -> command)",
|
|
189
236
|
)
|
|
237
|
+
session_hooks: Dict[str, Any] = Field(
|
|
238
|
+
default_factory=dict,
|
|
239
|
+
description="Session lifecycle hooks configuration (hook_name -> config)",
|
|
240
|
+
)
|
|
190
241
|
|
|
191
242
|
issue: IssueSchemaConfig = Field(default_factory=IssueSchemaConfig)
|
|
192
243
|
domains: DomainConfig = Field(default_factory=DomainConfig)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Monoco Native Hook System
|
|
3
|
+
|
|
4
|
+
Provides lifecycle hooks for Agent Sessions, independent of specific CLI tools.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .base import SessionLifecycleHook, HookResult, HookStatus
|
|
8
|
+
from .context import HookContext
|
|
9
|
+
from .registry import HookRegistry, get_registry, reset_registry
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"SessionLifecycleHook",
|
|
13
|
+
"HookResult",
|
|
14
|
+
"HookStatus",
|
|
15
|
+
"HookContext",
|
|
16
|
+
"HookRegistry",
|
|
17
|
+
"get_registry",
|
|
18
|
+
"reset_registry",
|
|
19
|
+
]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base classes for Session Lifecycle Hooks.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Optional
|
|
8
|
+
from enum import Enum
|
|
9
|
+
|
|
10
|
+
from .context import HookContext
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HookStatus(str, Enum):
|
|
14
|
+
"""Status of hook execution."""
|
|
15
|
+
SUCCESS = "success"
|
|
16
|
+
FAILURE = "failure"
|
|
17
|
+
SKIPPED = "skipped"
|
|
18
|
+
WARNING = "warning"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class HookResult:
|
|
23
|
+
"""Result of a hook execution."""
|
|
24
|
+
status: HookStatus
|
|
25
|
+
message: str = ""
|
|
26
|
+
details: Optional[dict] = None
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def success(cls, message: str = "", details: Optional[dict] = None) -> "HookResult":
|
|
30
|
+
return cls(status=HookStatus.SUCCESS, message=message, details=details)
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def failure(cls, message: str = "", details: Optional[dict] = None) -> "HookResult":
|
|
34
|
+
return cls(status=HookStatus.FAILURE, message=message, details=details)
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def skipped(cls, message: str = "", details: Optional[dict] = None) -> "HookResult":
|
|
38
|
+
return cls(status=HookStatus.SKIPPED, message=message, details=details)
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def warning(cls, message: str = "", details: Optional[dict] = None) -> "HookResult":
|
|
42
|
+
return cls(status=HookStatus.WARNING, message=message, details=details)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SessionLifecycleHook(ABC):
|
|
46
|
+
"""
|
|
47
|
+
Abstract base class for session lifecycle hooks.
|
|
48
|
+
|
|
49
|
+
Hooks can be registered to execute at specific points in a session's lifecycle:
|
|
50
|
+
- on_session_start: Called when a session starts
|
|
51
|
+
- on_session_end: Called when a session ends (terminate)
|
|
52
|
+
|
|
53
|
+
Example:
|
|
54
|
+
class MyHook(SessionLifecycleHook):
|
|
55
|
+
def on_session_start(self, context: HookContext) -> HookResult:
|
|
56
|
+
print(f"Session {context.session_id} started")
|
|
57
|
+
return HookResult.success()
|
|
58
|
+
|
|
59
|
+
def on_session_end(self, context: HookContext) -> HookResult:
|
|
60
|
+
print(f"Session {context.session_id} ended")
|
|
61
|
+
return HookResult.success()
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, name: Optional[str] = None, config: Optional[dict] = None):
|
|
65
|
+
"""
|
|
66
|
+
Initialize the hook.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
name: Optional name for the hook. If not provided, uses class name.
|
|
70
|
+
config: Optional configuration dictionary for the hook.
|
|
71
|
+
"""
|
|
72
|
+
self.name = name or self.__class__.__name__
|
|
73
|
+
self.config = config or {}
|
|
74
|
+
self.enabled = self.config.get("enabled", True)
|
|
75
|
+
|
|
76
|
+
@abstractmethod
|
|
77
|
+
def on_session_start(self, context: HookContext) -> HookResult:
|
|
78
|
+
"""
|
|
79
|
+
Called when a session starts.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
context: The hook context containing session information.
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
HookResult indicating the outcome of the hook execution.
|
|
86
|
+
"""
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
@abstractmethod
|
|
90
|
+
def on_session_end(self, context: HookContext) -> HookResult:
|
|
91
|
+
"""
|
|
92
|
+
Called when a session ends.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
context: The hook context containing session information.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
HookResult indicating the outcome of the hook execution.
|
|
99
|
+
"""
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
def is_enabled(self) -> bool:
|
|
103
|
+
"""Check if this hook is enabled."""
|
|
104
|
+
return self.enabled
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GitCleanupHook - Performs git cleanup operations when a session ends.
|
|
3
|
+
|
|
4
|
+
This hook ensures that:
|
|
5
|
+
1. Current branch is switched back to main (if safe)
|
|
6
|
+
2. Feature branches are deleted if the associated issue is completed/merged
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from ..base import SessionLifecycleHook, HookResult, HookStatus
|
|
13
|
+
from ..context import HookContext
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("monoco.core.hooks.git_cleanup")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GitCleanupHook(SessionLifecycleHook):
|
|
19
|
+
"""
|
|
20
|
+
Hook for cleaning up git state when a session ends.
|
|
21
|
+
|
|
22
|
+
Configuration options:
|
|
23
|
+
- auto_switch_to_main: Whether to automatically switch to main branch (default: True)
|
|
24
|
+
- auto_delete_merged_branches: Whether to delete merged feature branches (default: True)
|
|
25
|
+
- main_branch: Name of the main branch (default: "main", fallback: "master")
|
|
26
|
+
- require_clean_worktree: Whether to require clean worktree before operations (default: True)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, name: Optional[str] = None, config: Optional[dict] = None):
|
|
30
|
+
super().__init__(name=name or "git_cleanup", config=config)
|
|
31
|
+
|
|
32
|
+
# Configuration with defaults
|
|
33
|
+
self.auto_switch_to_main = self.config.get("auto_switch_to_main", True)
|
|
34
|
+
self.auto_delete_merged_branches = self.config.get("auto_delete_merged_branches", True)
|
|
35
|
+
self.main_branch = self.config.get("main_branch", "main")
|
|
36
|
+
self.require_clean_worktree = self.config.get("require_clean_worktree", True)
|
|
37
|
+
|
|
38
|
+
def on_session_start(self, context: HookContext) -> HookResult:
|
|
39
|
+
"""
|
|
40
|
+
Called when session starts.
|
|
41
|
+
|
|
42
|
+
No action needed at session start for git cleanup.
|
|
43
|
+
"""
|
|
44
|
+
return HookResult.success("Git cleanup hook initialized")
|
|
45
|
+
|
|
46
|
+
def on_session_end(self, context: HookContext) -> HookResult:
|
|
47
|
+
"""
|
|
48
|
+
Called when session ends.
|
|
49
|
+
|
|
50
|
+
Performs cleanup operations:
|
|
51
|
+
1. Check current git state
|
|
52
|
+
2. Switch to main branch if needed and safe
|
|
53
|
+
3. Delete feature branch if issue is completed
|
|
54
|
+
"""
|
|
55
|
+
if not context.git:
|
|
56
|
+
return HookResult.skipped("No git context available")
|
|
57
|
+
|
|
58
|
+
project_root = context.git.project_root
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
from monoco.core import git
|
|
62
|
+
|
|
63
|
+
# Check if we're in a git repo
|
|
64
|
+
if not git.is_git_repo(project_root):
|
|
65
|
+
return HookResult.skipped("Not a git repository")
|
|
66
|
+
|
|
67
|
+
# Get current state
|
|
68
|
+
current_branch = git.get_current_branch(project_root)
|
|
69
|
+
default_branch = self._get_default_branch(project_root)
|
|
70
|
+
|
|
71
|
+
# Check for uncommitted changes
|
|
72
|
+
has_changes = len(git.get_git_status(project_root)) > 0
|
|
73
|
+
|
|
74
|
+
results = []
|
|
75
|
+
|
|
76
|
+
# Step 1: Switch to main branch if needed
|
|
77
|
+
if self.auto_switch_to_main and current_branch != default_branch:
|
|
78
|
+
switch_result = self._switch_to_main(
|
|
79
|
+
project_root, current_branch, default_branch, has_changes
|
|
80
|
+
)
|
|
81
|
+
results.append(switch_result)
|
|
82
|
+
|
|
83
|
+
if switch_result.status == HookStatus.SUCCESS:
|
|
84
|
+
current_branch = default_branch
|
|
85
|
+
|
|
86
|
+
# Step 2: Delete feature branch if issue is completed
|
|
87
|
+
if self.auto_delete_merged_branches and context.issue:
|
|
88
|
+
delete_result = self._cleanup_feature_branch(
|
|
89
|
+
project_root, context.issue, default_branch, current_branch
|
|
90
|
+
)
|
|
91
|
+
if delete_result:
|
|
92
|
+
results.append(delete_result)
|
|
93
|
+
|
|
94
|
+
# Combine results
|
|
95
|
+
failures = [r for r in results if r.status == HookStatus.FAILURE]
|
|
96
|
+
warnings = [r for r in results if r.status == HookStatus.WARNING]
|
|
97
|
+
|
|
98
|
+
if failures:
|
|
99
|
+
return HookResult.failure(
|
|
100
|
+
f"Git cleanup completed with {len(failures)} failures",
|
|
101
|
+
{"results": [r.message for r in results]}
|
|
102
|
+
)
|
|
103
|
+
elif warnings:
|
|
104
|
+
return HookResult.warning(
|
|
105
|
+
f"Git cleanup completed with {len(warnings)} warnings",
|
|
106
|
+
{"results": [r.message for r in results]}
|
|
107
|
+
)
|
|
108
|
+
else:
|
|
109
|
+
return HookResult.success(
|
|
110
|
+
"Git cleanup completed successfully",
|
|
111
|
+
{"results": [r.message for r in results]}
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
except Exception as e:
|
|
115
|
+
logger.error(f"Git cleanup failed: {e}")
|
|
116
|
+
return HookResult.failure(f"Git cleanup failed: {e}")
|
|
117
|
+
|
|
118
|
+
def _get_default_branch(self, project_root) -> str:
|
|
119
|
+
"""Determine the default branch (main or master)."""
|
|
120
|
+
from monoco.core import git
|
|
121
|
+
|
|
122
|
+
if git.branch_exists(project_root, self.main_branch):
|
|
123
|
+
return self.main_branch
|
|
124
|
+
elif git.branch_exists(project_root, "master"):
|
|
125
|
+
return "master"
|
|
126
|
+
return self.main_branch
|
|
127
|
+
|
|
128
|
+
def _switch_to_main(
|
|
129
|
+
self,
|
|
130
|
+
project_root,
|
|
131
|
+
current_branch: str,
|
|
132
|
+
default_branch: str,
|
|
133
|
+
has_changes: bool
|
|
134
|
+
) -> HookResult:
|
|
135
|
+
"""
|
|
136
|
+
Switch to the main branch if safe to do so.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
project_root: The project root path
|
|
140
|
+
current_branch: Current branch name
|
|
141
|
+
default_branch: Target branch name (main/master)
|
|
142
|
+
has_changes: Whether there are uncommitted changes
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
HookResult indicating success/failure
|
|
146
|
+
"""
|
|
147
|
+
from monoco.core import git
|
|
148
|
+
|
|
149
|
+
# Safety check: uncommitted changes
|
|
150
|
+
if has_changes and self.require_clean_worktree:
|
|
151
|
+
return HookResult.warning(
|
|
152
|
+
f"Cannot switch from '{current_branch}' to '{default_branch}': "
|
|
153
|
+
f"uncommitted changes exist. Please commit or stash changes."
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# Check if default branch exists
|
|
157
|
+
if not git.branch_exists(project_root, default_branch):
|
|
158
|
+
return HookResult.warning(
|
|
159
|
+
f"Cannot switch to '{default_branch}': branch does not exist"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
git.checkout_branch(project_root, default_branch)
|
|
164
|
+
return HookResult.success(
|
|
165
|
+
f"Switched from '{current_branch}' to '{default_branch}'"
|
|
166
|
+
)
|
|
167
|
+
except Exception as e:
|
|
168
|
+
return HookResult.failure(
|
|
169
|
+
f"Failed to switch to '{default_branch}': {e}"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def _cleanup_feature_branch(
|
|
173
|
+
self,
|
|
174
|
+
project_root,
|
|
175
|
+
issue,
|
|
176
|
+
default_branch: str,
|
|
177
|
+
current_branch: str
|
|
178
|
+
) -> Optional[HookResult]:
|
|
179
|
+
"""
|
|
180
|
+
Clean up the feature branch associated with an issue.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
project_root: The project root path
|
|
184
|
+
issue: The IssueInfo object
|
|
185
|
+
default_branch: The default branch name
|
|
186
|
+
current_branch: Current branch name
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
HookResult or None if no action needed
|
|
190
|
+
"""
|
|
191
|
+
from monoco.core import git
|
|
192
|
+
|
|
193
|
+
# Get the branch name from issue
|
|
194
|
+
branch_name = issue.branch_name
|
|
195
|
+
if not branch_name:
|
|
196
|
+
# Try to infer from convention: feat/<issue_id>-*
|
|
197
|
+
# This is a fallback if isolation metadata is not set
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
# Check if branch exists
|
|
201
|
+
if not git.branch_exists(project_root, branch_name):
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
# Safety: don't delete the branch we're currently on
|
|
205
|
+
if current_branch == branch_name:
|
|
206
|
+
return HookResult.warning(
|
|
207
|
+
f"Cannot delete branch '{branch_name}': currently checked out"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# Check if issue is completed/closed
|
|
211
|
+
is_completed = issue.status in ("closed", "done", "merged")
|
|
212
|
+
|
|
213
|
+
if not is_completed:
|
|
214
|
+
return HookResult.skipped(
|
|
215
|
+
f"Branch '{branch_name}' not deleted: issue status is '{issue.status}'"
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# Check if branch is merged into default branch
|
|
219
|
+
try:
|
|
220
|
+
is_merged = self._is_branch_merged(project_root, branch_name, default_branch)
|
|
221
|
+
except Exception:
|
|
222
|
+
is_merged = False
|
|
223
|
+
|
|
224
|
+
if not is_merged:
|
|
225
|
+
return HookResult.warning(
|
|
226
|
+
f"Branch '{branch_name}' not deleted: not merged into '{default_branch}'"
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
# Safe to delete
|
|
230
|
+
try:
|
|
231
|
+
git.delete_branch(project_root, branch_name, force=False)
|
|
232
|
+
return HookResult.success(
|
|
233
|
+
f"Deleted merged branch '{branch_name}'"
|
|
234
|
+
)
|
|
235
|
+
except Exception as e:
|
|
236
|
+
return HookResult.failure(
|
|
237
|
+
f"Failed to delete branch '{branch_name}': {e}"
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
def _is_branch_merged(
|
|
241
|
+
self,
|
|
242
|
+
project_root,
|
|
243
|
+
branch: str,
|
|
244
|
+
target: str
|
|
245
|
+
) -> bool:
|
|
246
|
+
"""
|
|
247
|
+
Check if a branch is merged into the target branch.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
project_root: The project root path
|
|
251
|
+
branch: The branch to check
|
|
252
|
+
target: The target branch to check against
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
True if branch is merged into target
|
|
256
|
+
"""
|
|
257
|
+
from monoco.core import git
|
|
258
|
+
|
|
259
|
+
# Use git merge-base to check if branch is ancestor of target
|
|
260
|
+
code, stdout, _ = git._run_git(
|
|
261
|
+
["merge-base", "--is-ancestor", branch, target],
|
|
262
|
+
project_root
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
# Exit code 0 means branch is ancestor of target (merged)
|
|
266
|
+
return code == 0
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LoggingHook - Logs session lifecycle events.
|
|
3
|
+
|
|
4
|
+
Simple hook that logs when sessions start and end for auditing/debugging.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from ..base import SessionLifecycleHook, HookResult
|
|
12
|
+
from ..context import HookContext
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("monoco.core.hooks.logging")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LoggingHook(SessionLifecycleHook):
|
|
18
|
+
"""
|
|
19
|
+
Hook for logging session lifecycle events.
|
|
20
|
+
|
|
21
|
+
Configuration options:
|
|
22
|
+
- log_level: The logging level to use (default: INFO)
|
|
23
|
+
- log_start: Whether to log session start (default: True)
|
|
24
|
+
- log_end: Whether to log session end (default: True)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, name: Optional[str] = None, config: Optional[dict] = None):
|
|
28
|
+
super().__init__(name=name or "logging", config=config)
|
|
29
|
+
|
|
30
|
+
self.log_level = self._parse_log_level(self.config.get("log_level", "INFO"))
|
|
31
|
+
self.log_start = self.config.get("log_start", True)
|
|
32
|
+
self.log_end = self.config.get("log_end", True)
|
|
33
|
+
|
|
34
|
+
def _parse_log_level(self, level: str) -> int:
|
|
35
|
+
"""Parse log level string to logging constant."""
|
|
36
|
+
levels = {
|
|
37
|
+
"DEBUG": logging.DEBUG,
|
|
38
|
+
"INFO": logging.INFO,
|
|
39
|
+
"WARNING": logging.WARNING,
|
|
40
|
+
"ERROR": logging.ERROR,
|
|
41
|
+
"CRITICAL": logging.CRITICAL,
|
|
42
|
+
}
|
|
43
|
+
return levels.get(level.upper(), logging.INFO)
|
|
44
|
+
|
|
45
|
+
def on_session_start(self, context: HookContext) -> HookResult:
|
|
46
|
+
"""Log session start."""
|
|
47
|
+
if not self.log_start:
|
|
48
|
+
return HookResult.skipped("Session start logging disabled")
|
|
49
|
+
|
|
50
|
+
issue_info = f" for issue {context.issue.id}" if context.issue else ""
|
|
51
|
+
log_message = (
|
|
52
|
+
f"Session {context.session_id} started{issue_info} "
|
|
53
|
+
f"(role: {context.role_name})"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
logger.log(self.log_level, log_message)
|
|
57
|
+
|
|
58
|
+
return HookResult.success(f"Logged session start: {context.session_id}")
|
|
59
|
+
|
|
60
|
+
def on_session_end(self, context: HookContext) -> HookResult:
|
|
61
|
+
"""Log session end with duration."""
|
|
62
|
+
if not self.log_end:
|
|
63
|
+
return HookResult.skipped("Session end logging disabled")
|
|
64
|
+
|
|
65
|
+
duration = datetime.now() - context.created_at
|
|
66
|
+
duration_seconds = duration.total_seconds()
|
|
67
|
+
|
|
68
|
+
issue_info = f" for issue {context.issue.id}" if context.issue else ""
|
|
69
|
+
log_message = (
|
|
70
|
+
f"Session {context.session_id} ended{issue_info} "
|
|
71
|
+
f"(duration: {duration_seconds:.2f}s, status: {context.session_status})"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
logger.log(self.log_level, log_message)
|
|
75
|
+
|
|
76
|
+
return HookResult.success(
|
|
77
|
+
f"Logged session end: {context.session_id} (duration: {duration_seconds:.2f}s)"
|
|
78
|
+
)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Hook Context - Data passed to hooks during session lifecycle events.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional, Dict, Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class IssueInfo:
|
|
13
|
+
"""Information about the issue associated with a session."""
|
|
14
|
+
id: str
|
|
15
|
+
status: Optional[str] = None
|
|
16
|
+
stage: Optional[str] = None
|
|
17
|
+
title: Optional[str] = None
|
|
18
|
+
branch_name: Optional[str] = None
|
|
19
|
+
is_merged: bool = False
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def from_metadata(cls, metadata: Any) -> "IssueInfo":
|
|
23
|
+
"""Create IssueInfo from IssueMetadata."""
|
|
24
|
+
return cls(
|
|
25
|
+
id=getattr(metadata, "id", ""),
|
|
26
|
+
status=getattr(metadata, "status", None),
|
|
27
|
+
stage=getattr(metadata, "stage", None),
|
|
28
|
+
title=getattr(metadata, "title", None),
|
|
29
|
+
branch_name=getattr(metadata, "isolation", {}).get("ref") if hasattr(metadata, "isolation") and metadata.isolation else None,
|
|
30
|
+
is_merged=False, # Will be determined by GitCleanupHook
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class GitInfo:
|
|
36
|
+
"""Git repository information."""
|
|
37
|
+
project_root: Path
|
|
38
|
+
current_branch: Optional[str] = None
|
|
39
|
+
has_uncommitted_changes: bool = False
|
|
40
|
+
default_branch: str = "main"
|
|
41
|
+
|
|
42
|
+
def __post_init__(self):
|
|
43
|
+
if self.current_branch is None:
|
|
44
|
+
# Lazy load current branch
|
|
45
|
+
try:
|
|
46
|
+
from monoco.core import git
|
|
47
|
+
self.current_branch = git.get_current_branch(self.project_root)
|
|
48
|
+
except Exception:
|
|
49
|
+
self.current_branch = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class HookContext:
|
|
54
|
+
"""
|
|
55
|
+
Context object passed to lifecycle hooks.
|
|
56
|
+
|
|
57
|
+
Contains all relevant information about the session, issue, and environment
|
|
58
|
+
that hooks might need to perform their operations.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
# Session Information
|
|
62
|
+
session_id: str
|
|
63
|
+
role_name: str
|
|
64
|
+
session_status: str
|
|
65
|
+
created_at: datetime
|
|
66
|
+
|
|
67
|
+
# Issue Information
|
|
68
|
+
issue: Optional[IssueInfo] = None
|
|
69
|
+
|
|
70
|
+
# Git Information
|
|
71
|
+
git: Optional[GitInfo] = None
|
|
72
|
+
|
|
73
|
+
# Additional Context
|
|
74
|
+
extra: Dict[str, Any] = field(default_factory=dict)
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def from_runtime_session(
|
|
78
|
+
cls,
|
|
79
|
+
runtime_session: Any,
|
|
80
|
+
project_root: Optional[Path] = None,
|
|
81
|
+
) -> "HookContext":
|
|
82
|
+
"""
|
|
83
|
+
Create a HookContext from a RuntimeSession.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
runtime_session: The RuntimeSession object
|
|
87
|
+
project_root: Optional project root path
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
A populated HookContext
|
|
91
|
+
"""
|
|
92
|
+
model = runtime_session.model
|
|
93
|
+
|
|
94
|
+
# Build IssueInfo if we have an issue_id
|
|
95
|
+
issue_info = None
|
|
96
|
+
if model.issue_id:
|
|
97
|
+
issue_info = IssueInfo(
|
|
98
|
+
id=model.issue_id,
|
|
99
|
+
branch_name=model.branch_name,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
# Try to load full issue metadata
|
|
103
|
+
try:
|
|
104
|
+
from monoco.features.issue.core import find_issue_path, parse_issue
|
|
105
|
+
from monoco.core.config import find_monoco_root
|
|
106
|
+
|
|
107
|
+
if project_root is None:
|
|
108
|
+
project_root = find_monoco_root()
|
|
109
|
+
|
|
110
|
+
issues_root = project_root / "Issues"
|
|
111
|
+
issue_path = find_issue_path(issues_root, model.issue_id)
|
|
112
|
+
if issue_path:
|
|
113
|
+
metadata = parse_issue(issue_path)
|
|
114
|
+
if metadata:
|
|
115
|
+
issue_info = IssueInfo.from_metadata(metadata)
|
|
116
|
+
except Exception:
|
|
117
|
+
pass # Use basic issue info
|
|
118
|
+
|
|
119
|
+
# Build GitInfo
|
|
120
|
+
git_info = None
|
|
121
|
+
if project_root:
|
|
122
|
+
git_info = GitInfo(project_root=project_root)
|
|
123
|
+
|
|
124
|
+
return cls(
|
|
125
|
+
session_id=model.id,
|
|
126
|
+
role_name=model.role_name,
|
|
127
|
+
session_status=model.status,
|
|
128
|
+
created_at=model.created_at,
|
|
129
|
+
issue=issue_info,
|
|
130
|
+
git=git_info,
|
|
131
|
+
)
|