monoco-toolkit 0.3.11__py3-none-any.whl → 0.4.0__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/core/automation/__init__.py +40 -0
- monoco/core/automation/field_watcher.py +296 -0
- monoco/core/automation/handlers.py +805 -0
- monoco/core/config.py +29 -11
- monoco/core/daemon/__init__.py +5 -0
- monoco/core/daemon/pid.py +290 -0
- monoco/core/git.py +15 -0
- monoco/core/hooks/context.py +74 -13
- monoco/core/injection.py +86 -8
- monoco/core/integrations.py +0 -24
- monoco/core/router/__init__.py +17 -0
- monoco/core/router/action.py +202 -0
- monoco/core/scheduler/__init__.py +63 -0
- monoco/core/scheduler/base.py +152 -0
- monoco/core/scheduler/engines.py +175 -0
- monoco/core/scheduler/events.py +197 -0
- monoco/core/scheduler/local.py +377 -0
- monoco/core/setup.py +9 -0
- monoco/core/sync.py +199 -4
- monoco/core/watcher/__init__.py +63 -0
- monoco/core/watcher/base.py +382 -0
- monoco/core/watcher/dropzone.py +152 -0
- monoco/core/watcher/im.py +460 -0
- monoco/core/watcher/issue.py +303 -0
- monoco/core/watcher/memo.py +192 -0
- monoco/core/watcher/task.py +238 -0
- monoco/daemon/app.py +3 -60
- monoco/daemon/commands.py +459 -25
- monoco/daemon/events.py +34 -0
- monoco/daemon/scheduler.py +157 -201
- monoco/daemon/services.py +42 -243
- monoco/features/agent/__init__.py +25 -7
- monoco/features/agent/cli.py +91 -57
- monoco/features/agent/engines.py +31 -170
- monoco/features/agent/resources/en/AGENTS.md +14 -14
- monoco/features/agent/resources/en/skills/monoco_role_engineer/SKILL.md +101 -0
- monoco/features/agent/resources/en/skills/monoco_role_manager/SKILL.md +95 -0
- monoco/features/agent/resources/en/skills/monoco_role_planner/SKILL.md +177 -0
- monoco/features/agent/resources/en/skills/monoco_role_reviewer/SKILL.md +139 -0
- monoco/features/agent/resources/zh/skills/monoco_role_engineer/SKILL.md +101 -0
- monoco/features/agent/resources/zh/skills/monoco_role_manager/SKILL.md +95 -0
- monoco/features/agent/resources/zh/skills/monoco_role_planner/SKILL.md +177 -0
- monoco/features/agent/resources/zh/skills/monoco_role_reviewer/SKILL.md +139 -0
- monoco/features/agent/worker.py +1 -1
- monoco/features/hooks/__init__.py +61 -6
- monoco/features/hooks/commands.py +281 -271
- monoco/features/hooks/dispatchers/__init__.py +23 -0
- monoco/features/hooks/dispatchers/agent_dispatcher.py +486 -0
- monoco/features/hooks/dispatchers/git_dispatcher.py +478 -0
- monoco/features/hooks/manager.py +357 -0
- monoco/features/hooks/models.py +262 -0
- monoco/features/hooks/parser.py +322 -0
- monoco/features/hooks/universal_interceptor.py +503 -0
- monoco/features/im/__init__.py +67 -0
- monoco/features/im/core.py +782 -0
- monoco/features/im/models.py +311 -0
- monoco/features/issue/commands.py +133 -60
- monoco/features/issue/core.py +385 -40
- monoco/features/issue/domain_commands.py +0 -19
- monoco/features/issue/resources/en/AGENTS.md +17 -122
- monoco/features/issue/resources/hooks/agent/before-tool.sh +102 -0
- monoco/features/issue/resources/hooks/agent/session-start.sh +88 -0
- monoco/features/issue/resources/hooks/{post-checkout.sh → git/git-post-checkout.sh} +10 -9
- monoco/features/issue/resources/hooks/git/git-pre-commit.sh +31 -0
- monoco/features/issue/resources/hooks/{pre-push.sh → git/git-pre-push.sh} +7 -13
- monoco/features/issue/resources/zh/AGENTS.md +18 -123
- monoco/features/memo/cli.py +15 -64
- monoco/features/memo/core.py +6 -34
- monoco/features/memo/models.py +24 -15
- monoco/features/memo/resources/en/AGENTS.md +31 -0
- monoco/features/memo/resources/zh/AGENTS.md +28 -5
- monoco/features/spike/commands.py +5 -3
- monoco/main.py +5 -3
- {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.4.0.dist-info}/METADATA +1 -1
- monoco_toolkit-0.4.0.dist-info/RECORD +170 -0
- monoco/core/execution.py +0 -67
- monoco/features/agent/apoptosis.py +0 -44
- monoco/features/agent/manager.py +0 -127
- monoco/features/agent/resources/atoms/atom-code-dev.yaml +0 -61
- monoco/features/agent/resources/atoms/atom-issue-lifecycle.yaml +0 -73
- monoco/features/agent/resources/atoms/atom-knowledge.yaml +0 -55
- monoco/features/agent/resources/atoms/atom-review.yaml +0 -60
- monoco/features/agent/resources/en/skills/monoco_atom_core/SKILL.md +0 -99
- monoco/features/agent/resources/en/skills/monoco_workflow_agent_engineer/SKILL.md +0 -94
- monoco/features/agent/resources/en/skills/monoco_workflow_agent_manager/SKILL.md +0 -93
- monoco/features/agent/resources/en/skills/monoco_workflow_agent_planner/SKILL.md +0 -85
- monoco/features/agent/resources/en/skills/monoco_workflow_agent_reviewer/SKILL.md +0 -114
- monoco/features/agent/resources/workflows/workflow-dev.yaml +0 -83
- monoco/features/agent/resources/workflows/workflow-issue-create.yaml +0 -72
- monoco/features/agent/resources/workflows/workflow-review.yaml +0 -94
- monoco/features/agent/resources/zh/roles/monoco_role_engineer.yaml +0 -49
- monoco/features/agent/resources/zh/roles/monoco_role_manager.yaml +0 -46
- monoco/features/agent/resources/zh/roles/monoco_role_planner.yaml +0 -46
- monoco/features/agent/resources/zh/roles/monoco_role_reviewer.yaml +0 -47
- monoco/features/agent/resources/zh/skills/monoco_atom_core/SKILL.md +0 -99
- monoco/features/agent/resources/zh/skills/monoco_workflow_agent_engineer/SKILL.md +0 -94
- monoco/features/agent/resources/zh/skills/monoco_workflow_agent_manager/SKILL.md +0 -88
- monoco/features/agent/resources/zh/skills/monoco_workflow_agent_planner/SKILL.md +0 -259
- monoco/features/agent/resources/zh/skills/monoco_workflow_agent_reviewer/SKILL.md +0 -137
- monoco/features/agent/session.py +0 -169
- monoco/features/artifact/resources/zh/skills/monoco_atom_artifact/SKILL.md +0 -278
- monoco/features/glossary/resources/en/skills/monoco_atom_glossary/SKILL.md +0 -35
- monoco/features/glossary/resources/zh/skills/monoco_atom_glossary/SKILL.md +0 -35
- monoco/features/hooks/adapter.py +0 -67
- monoco/features/hooks/core.py +0 -441
- monoco/features/i18n/resources/en/skills/monoco_atom_i18n/SKILL.md +0 -96
- monoco/features/i18n/resources/en/skills/monoco_workflow_i18n_scan/SKILL.md +0 -105
- monoco/features/i18n/resources/zh/skills/monoco_atom_i18n/SKILL.md +0 -96
- monoco/features/i18n/resources/zh/skills/monoco_workflow_i18n_scan/SKILL.md +0 -105
- monoco/features/issue/resources/en/skills/monoco_atom_issue/SKILL.md +0 -165
- monoco/features/issue/resources/en/skills/monoco_workflow_issue_creation/SKILL.md +0 -167
- monoco/features/issue/resources/en/skills/monoco_workflow_issue_development/SKILL.md +0 -224
- monoco/features/issue/resources/en/skills/monoco_workflow_issue_management/SKILL.md +0 -159
- monoco/features/issue/resources/en/skills/monoco_workflow_issue_refinement/SKILL.md +0 -203
- monoco/features/issue/resources/hooks/pre-commit.sh +0 -41
- monoco/features/issue/resources/zh/skills/monoco_atom_issue_lifecycle/SKILL.md +0 -190
- monoco/features/issue/resources/zh/skills/monoco_workflow_issue_creation/SKILL.md +0 -167
- monoco/features/issue/resources/zh/skills/monoco_workflow_issue_development/SKILL.md +0 -224
- monoco/features/issue/resources/zh/skills/monoco_workflow_issue_management/SKILL.md +0 -159
- monoco/features/issue/resources/zh/skills/monoco_workflow_issue_refinement/SKILL.md +0 -203
- monoco/features/memo/resources/en/skills/monoco_atom_memo/SKILL.md +0 -77
- monoco/features/memo/resources/en/skills/monoco_workflow_note_processing/SKILL.md +0 -140
- monoco/features/memo/resources/zh/skills/monoco_atom_memo/SKILL.md +0 -77
- monoco/features/memo/resources/zh/skills/monoco_workflow_note_processing/SKILL.md +0 -140
- monoco/features/spike/resources/en/skills/monoco_atom_spike/SKILL.md +0 -76
- monoco/features/spike/resources/en/skills/monoco_workflow_research/SKILL.md +0 -121
- monoco/features/spike/resources/zh/skills/monoco_atom_spike/SKILL.md +0 -76
- monoco/features/spike/resources/zh/skills/monoco_workflow_research/SKILL.md +0 -121
- monoco_toolkit-0.3.11.dist-info/RECORD +0 -181
- {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.4.0.dist-info}/WHEEL +0 -0
- {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.4.0.dist-info}/entry_points.txt +0 -0
- {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.4.0.dist-info}/licenses/LICENSE +0 -0
monoco/core/config.py
CHANGED
|
@@ -160,7 +160,7 @@ class AgentConcurrencyConfig(BaseModel):
|
|
|
160
160
|
engineer: int = Field(default=1, description="Maximum concurrent Engineer agents")
|
|
161
161
|
architect: int = Field(default=1, description="Maximum concurrent Architect agents")
|
|
162
162
|
reviewer: int = Field(default=1, description="Maximum concurrent Reviewer agents")
|
|
163
|
-
|
|
163
|
+
# Note: Planner role removed in FEAT-0155 (was never used)
|
|
164
164
|
# Cool-down configuration
|
|
165
165
|
failure_cooldown_seconds: int = Field(default=60, description="Cooldown period after a failure before retrying")
|
|
166
166
|
|
|
@@ -462,8 +462,13 @@ class ConfigMonitor:
|
|
|
462
462
|
self.config_path = config_path
|
|
463
463
|
self.on_change = on_change
|
|
464
464
|
self.observer = Observer()
|
|
465
|
+
self._started = False
|
|
465
466
|
|
|
466
467
|
async def start(self):
|
|
468
|
+
if self._started:
|
|
469
|
+
logger.warning(f"Config Monitor already started for {self.config_path}")
|
|
470
|
+
return
|
|
471
|
+
|
|
467
472
|
loop = asyncio.get_running_loop()
|
|
468
473
|
event_handler = ConfigEventHandler(loop, self.on_change, self.config_path)
|
|
469
474
|
|
|
@@ -471,15 +476,28 @@ class ConfigMonitor:
|
|
|
471
476
|
# Ensure parent exists at least
|
|
472
477
|
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
473
478
|
|
|
474
|
-
#
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
479
|
+
# Watch the specific file, not the parent directory
|
|
480
|
+
# This avoids "already scheduled" errors when multiple files are in the same directory
|
|
481
|
+
try:
|
|
482
|
+
self.observer.schedule(
|
|
483
|
+
event_handler, str(self.config_path), recursive=False
|
|
484
|
+
)
|
|
485
|
+
self.observer.start()
|
|
486
|
+
self._started = True
|
|
487
|
+
logger.info(f"Config Monitor started for {self.config_path}")
|
|
488
|
+
except RuntimeError as e:
|
|
489
|
+
logger.error(f"Failed to start Config Monitor for {self.config_path}: {e}")
|
|
490
|
+
raise
|
|
480
491
|
|
|
481
492
|
def stop(self):
|
|
482
|
-
if self.
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
493
|
+
if not self._started:
|
|
494
|
+
return
|
|
495
|
+
try:
|
|
496
|
+
if self.observer.is_alive():
|
|
497
|
+
self.observer.stop()
|
|
498
|
+
self.observer.join()
|
|
499
|
+
logger.info(f"Config Monitor stopped for {self.config_path}")
|
|
500
|
+
except Exception as e:
|
|
501
|
+
logger.warning(f"Error stopping Config Monitor: {e}")
|
|
502
|
+
finally:
|
|
503
|
+
self._started = False
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""PID file management and port utilities for Monoco Daemon."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import signal
|
|
6
|
+
import socket
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PIDFileError(Exception):
|
|
13
|
+
"""Exception raised for PID file related errors."""
|
|
14
|
+
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PIDManager:
|
|
19
|
+
"""Manages PID file for workspace-scoped daemon process.
|
|
20
|
+
|
|
21
|
+
PID file format (JSON):
|
|
22
|
+
{
|
|
23
|
+
"pid": 12345,
|
|
24
|
+
"host": "127.0.0.1",
|
|
25
|
+
"port": 8642,
|
|
26
|
+
"started_at": "2026-02-03T12:00:00",
|
|
27
|
+
"version": "0.3.12"
|
|
28
|
+
}
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
PID_FILENAME = "monoco.pid"
|
|
32
|
+
PID_DIR = "run"
|
|
33
|
+
|
|
34
|
+
def __init__(self, workspace_root: Path):
|
|
35
|
+
self.workspace_root = Path(workspace_root)
|
|
36
|
+
self.pid_file = self._get_pid_file_path()
|
|
37
|
+
|
|
38
|
+
def _get_pid_file_path(self) -> Path:
|
|
39
|
+
"""Get the PID file path for the workspace."""
|
|
40
|
+
pid_dir = self.workspace_root / ".monoco" / self.PID_DIR
|
|
41
|
+
pid_dir.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
return pid_dir / self.PID_FILENAME
|
|
43
|
+
|
|
44
|
+
def create_pid_file(
|
|
45
|
+
self, host: str, port: int, version: str = "0.3.12"
|
|
46
|
+
) -> Path:
|
|
47
|
+
"""Create a PID file with process metadata.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
host: The host address the daemon is listening on
|
|
51
|
+
port: The port the daemon is listening on
|
|
52
|
+
version: Monoco toolkit version
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Path to the created PID file
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
PIDFileError: If PID file already exists and process is still alive
|
|
59
|
+
"""
|
|
60
|
+
# Check for existing PID file
|
|
61
|
+
existing = self.read_pid_file()
|
|
62
|
+
if existing and self.is_process_alive(existing["pid"]):
|
|
63
|
+
raise PIDFileError(
|
|
64
|
+
f"Daemon already running (PID: {existing['pid']}, "
|
|
65
|
+
f"port: {existing['port']})"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Clean up stale PID file if exists
|
|
69
|
+
if self.pid_file.exists():
|
|
70
|
+
self.remove_pid_file()
|
|
71
|
+
|
|
72
|
+
pid_data = {
|
|
73
|
+
"pid": os.getpid(),
|
|
74
|
+
"host": host,
|
|
75
|
+
"port": port,
|
|
76
|
+
"started_at": datetime.now().isoformat(),
|
|
77
|
+
"version": version,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
# Write atomically using temp file
|
|
81
|
+
temp_file = self.pid_file.with_suffix(".tmp")
|
|
82
|
+
try:
|
|
83
|
+
with open(temp_file, "w", encoding="utf-8") as f:
|
|
84
|
+
json.dump(pid_data, f, indent=2)
|
|
85
|
+
temp_file.rename(self.pid_file)
|
|
86
|
+
except Exception as e:
|
|
87
|
+
if temp_file.exists():
|
|
88
|
+
temp_file.unlink()
|
|
89
|
+
raise PIDFileError(f"Failed to create PID file: {e}") from e
|
|
90
|
+
|
|
91
|
+
return self.pid_file
|
|
92
|
+
|
|
93
|
+
def read_pid_file(self) -> Optional[dict]:
|
|
94
|
+
"""Read and parse the PID file.
|
|
95
|
+
|
|
96
|
+
Returns:
|
|
97
|
+
Dict with pid, host, port, started_at, version or None if not exists
|
|
98
|
+
"""
|
|
99
|
+
if not self.pid_file.exists():
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
with open(self.pid_file, "r", encoding="utf-8") as f:
|
|
104
|
+
return json.load(f)
|
|
105
|
+
except (json.JSONDecodeError, IOError):
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
def remove_pid_file(self) -> bool:
|
|
109
|
+
"""Remove the PID file.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
True if file was removed, False if it didn't exist
|
|
113
|
+
"""
|
|
114
|
+
try:
|
|
115
|
+
self.pid_file.unlink()
|
|
116
|
+
return True
|
|
117
|
+
except FileNotFoundError:
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def is_process_alive(pid: int) -> bool:
|
|
122
|
+
"""Check if a process with given PID is still running.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
pid: Process ID to check
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
True if process exists and is running
|
|
129
|
+
"""
|
|
130
|
+
try:
|
|
131
|
+
os.kill(pid, 0)
|
|
132
|
+
return True
|
|
133
|
+
except (OSError, ProcessLookupError):
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
def get_daemon_info(self) -> Optional[dict]:
|
|
137
|
+
"""Get daemon info if it's running.
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Daemon info dict if running, None otherwise
|
|
141
|
+
"""
|
|
142
|
+
pid_data = self.read_pid_file()
|
|
143
|
+
if not pid_data:
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
if not self.is_process_alive(pid_data["pid"]):
|
|
147
|
+
# Stale PID file, clean it up
|
|
148
|
+
self.remove_pid_file()
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
return pid_data
|
|
152
|
+
|
|
153
|
+
def send_signal(self, sig: int) -> bool:
|
|
154
|
+
"""Send a signal to the daemon process.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
sig: Signal to send (e.g., signal.SIGTERM)
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
True if signal was sent successfully
|
|
161
|
+
"""
|
|
162
|
+
pid_data = self.read_pid_file()
|
|
163
|
+
if not pid_data:
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
pid = pid_data["pid"]
|
|
167
|
+
try:
|
|
168
|
+
os.kill(pid, sig)
|
|
169
|
+
return True
|
|
170
|
+
except (OSError, ProcessLookupError):
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
def terminate(self, timeout: int = 5) -> bool:
|
|
174
|
+
"""Gracefully terminate the daemon process.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
timeout: Seconds to wait for graceful shutdown
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
True if process was terminated
|
|
181
|
+
"""
|
|
182
|
+
pid_data = self.read_pid_file()
|
|
183
|
+
if not pid_data:
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
pid = pid_data["pid"]
|
|
187
|
+
|
|
188
|
+
# Try graceful shutdown first
|
|
189
|
+
try:
|
|
190
|
+
os.kill(pid, signal.SIGTERM)
|
|
191
|
+
except (OSError, ProcessLookupError):
|
|
192
|
+
# Process already gone
|
|
193
|
+
self.remove_pid_file()
|
|
194
|
+
return True
|
|
195
|
+
|
|
196
|
+
# Wait for process to terminate
|
|
197
|
+
import time
|
|
198
|
+
|
|
199
|
+
for _ in range(timeout * 10):
|
|
200
|
+
if not self.is_process_alive(pid):
|
|
201
|
+
self.remove_pid_file()
|
|
202
|
+
return True
|
|
203
|
+
time.sleep(0.1)
|
|
204
|
+
|
|
205
|
+
# Force kill if still running
|
|
206
|
+
try:
|
|
207
|
+
os.kill(pid, signal.SIGKILL)
|
|
208
|
+
except (OSError, ProcessLookupError):
|
|
209
|
+
pass
|
|
210
|
+
|
|
211
|
+
self.remove_pid_file()
|
|
212
|
+
return True
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class PortManager:
|
|
216
|
+
"""Port management utilities for daemon."""
|
|
217
|
+
|
|
218
|
+
DEFAULT_PORT = 8642
|
|
219
|
+
MAX_PORT_RETRY = 100
|
|
220
|
+
|
|
221
|
+
@staticmethod
|
|
222
|
+
def is_port_in_use(port: int, host: str = "127.0.0.1") -> bool:
|
|
223
|
+
"""Check if a port is already in use.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
port: Port number to check
|
|
227
|
+
host: Host address to check
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
True if port is in use
|
|
231
|
+
"""
|
|
232
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
233
|
+
try:
|
|
234
|
+
s.bind((host, port))
|
|
235
|
+
return False
|
|
236
|
+
except OSError:
|
|
237
|
+
return True
|
|
238
|
+
|
|
239
|
+
@classmethod
|
|
240
|
+
def find_available_port(
|
|
241
|
+
cls, start_port: int = DEFAULT_PORT, host: str = "127.0.0.1", max_retry: int = MAX_PORT_RETRY
|
|
242
|
+
) -> int:
|
|
243
|
+
"""Find an available port starting from start_port.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
start_port: Starting port number
|
|
247
|
+
host: Host address to bind
|
|
248
|
+
max_retry: Maximum number of ports to try
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
Available port number
|
|
252
|
+
|
|
253
|
+
Raises:
|
|
254
|
+
PIDFileError: If no available port found within range
|
|
255
|
+
"""
|
|
256
|
+
for port in range(start_port, start_port + max_retry):
|
|
257
|
+
if not cls.is_port_in_use(port, host):
|
|
258
|
+
return port
|
|
259
|
+
|
|
260
|
+
raise PIDFileError(
|
|
261
|
+
f"No available port found in range {start_port}-{start_port + max_retry - 1}"
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
@classmethod
|
|
265
|
+
def get_port_with_fallback(
|
|
266
|
+
cls, preferred_port: int = DEFAULT_PORT, host: str = "127.0.0.1", auto_increment: bool = True
|
|
267
|
+
) -> int:
|
|
268
|
+
"""Get a port, either the preferred one or an available fallback.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
preferred_port: Preferred port to use
|
|
272
|
+
host: Host address
|
|
273
|
+
auto_increment: If True, find next available port; if False, raise error
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
Port number to use
|
|
277
|
+
|
|
278
|
+
Raises:
|
|
279
|
+
PIDFileError: If preferred port is in use and auto_increment is False
|
|
280
|
+
"""
|
|
281
|
+
if not cls.is_port_in_use(preferred_port, host):
|
|
282
|
+
return preferred_port
|
|
283
|
+
|
|
284
|
+
if not auto_increment:
|
|
285
|
+
raise PIDFileError(
|
|
286
|
+
f"Port {preferred_port} is already in use. "
|
|
287
|
+
f"Use --port to specify a different port."
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
return cls.find_available_port(preferred_port + 1, host)
|
monoco/core/git.py
CHANGED
|
@@ -230,6 +230,21 @@ def worktree_remove(path: Path, worktree_path: Path, force: bool = False):
|
|
|
230
230
|
raise RuntimeError(f"Failed to remove worktree: {stderr}")
|
|
231
231
|
|
|
232
232
|
|
|
233
|
+
def get_current_head(path: Path) -> str:
|
|
234
|
+
"""Get the current HEAD commit hash."""
|
|
235
|
+
code, stdout, stderr = _run_git(["rev-parse", "HEAD"], path)
|
|
236
|
+
if code != 0:
|
|
237
|
+
raise RuntimeError(f"Failed to get current HEAD: {stderr}")
|
|
238
|
+
return stdout.strip()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def git_reset_hard(path: Path, ref: str):
|
|
242
|
+
"""Reset the repository to a specific ref using --hard."""
|
|
243
|
+
code, _, stderr = _run_git(["reset", "--hard", ref], path)
|
|
244
|
+
if code != 0:
|
|
245
|
+
raise RuntimeError(f"Git reset --hard to {ref} failed: {stderr}")
|
|
246
|
+
|
|
247
|
+
|
|
233
248
|
class GitMonitor:
|
|
234
249
|
"""
|
|
235
250
|
Polls the Git repository for HEAD changes and triggers updates.
|
monoco/core/hooks/context.py
CHANGED
|
@@ -74,29 +74,90 @@ class HookContext:
|
|
|
74
74
|
extra: Dict[str, Any] = field(default_factory=dict)
|
|
75
75
|
|
|
76
76
|
@classmethod
|
|
77
|
-
def
|
|
77
|
+
def from_agent_task(
|
|
78
78
|
cls,
|
|
79
|
-
|
|
79
|
+
task: Any,
|
|
80
80
|
project_root: Optional[Path] = None,
|
|
81
81
|
) -> "HookContext":
|
|
82
82
|
"""
|
|
83
|
-
Create a HookContext from
|
|
83
|
+
Create a HookContext from an AgentTask.
|
|
84
84
|
|
|
85
85
|
Args:
|
|
86
|
-
|
|
86
|
+
task: The AgentTask object
|
|
87
87
|
project_root: Optional project root path
|
|
88
88
|
|
|
89
89
|
Returns:
|
|
90
90
|
A populated HookContext
|
|
91
91
|
"""
|
|
92
|
-
|
|
92
|
+
# Build IssueInfo if we have an issue_id
|
|
93
|
+
issue_info = None
|
|
94
|
+
issue_id = getattr(task, "issue_id", None)
|
|
95
|
+
if issue_id:
|
|
96
|
+
issue_info = IssueInfo(
|
|
97
|
+
id=issue_id,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
# Try to load full issue metadata
|
|
101
|
+
try:
|
|
102
|
+
from monoco.features.issue.core import find_issue_path, parse_issue
|
|
103
|
+
from monoco.core.config import find_monoco_root
|
|
104
|
+
|
|
105
|
+
if project_root is None:
|
|
106
|
+
project_root = find_monoco_root()
|
|
107
|
+
|
|
108
|
+
issues_root = project_root / "Issues"
|
|
109
|
+
issue_path = find_issue_path(issues_root, issue_id)
|
|
110
|
+
if issue_path:
|
|
111
|
+
metadata = parse_issue(issue_path)
|
|
112
|
+
if metadata:
|
|
113
|
+
issue_info = IssueInfo.from_metadata(metadata)
|
|
114
|
+
except Exception:
|
|
115
|
+
pass # Use basic issue info
|
|
93
116
|
|
|
117
|
+
# Build GitInfo
|
|
118
|
+
git_info = None
|
|
119
|
+
if project_root:
|
|
120
|
+
git_info = GitInfo(project_root=project_root)
|
|
121
|
+
|
|
122
|
+
return cls(
|
|
123
|
+
session_id=getattr(task, "task_id", "unknown"),
|
|
124
|
+
role_name=getattr(task, "role_name", "unknown"),
|
|
125
|
+
session_status="pending",
|
|
126
|
+
created_at=getattr(task, "created_at", datetime.now()),
|
|
127
|
+
issue=issue_info,
|
|
128
|
+
git=git_info,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
@classmethod
|
|
132
|
+
def from_session_state(
|
|
133
|
+
cls,
|
|
134
|
+
session_id: str,
|
|
135
|
+
role_name: str,
|
|
136
|
+
issue_id: Optional[str],
|
|
137
|
+
status: str,
|
|
138
|
+
project_root: Optional[Path] = None,
|
|
139
|
+
) -> "HookContext":
|
|
140
|
+
"""
|
|
141
|
+
Create a HookContext from session state parameters.
|
|
142
|
+
|
|
143
|
+
This is a more flexible factory method that doesn't depend on
|
|
144
|
+
specific session implementations.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
session_id: The session/task ID
|
|
148
|
+
role_name: The role name
|
|
149
|
+
issue_id: Optional issue ID
|
|
150
|
+
status: Session status
|
|
151
|
+
project_root: Optional project root path
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
A populated HookContext
|
|
155
|
+
"""
|
|
94
156
|
# Build IssueInfo if we have an issue_id
|
|
95
157
|
issue_info = None
|
|
96
|
-
if
|
|
158
|
+
if issue_id:
|
|
97
159
|
issue_info = IssueInfo(
|
|
98
|
-
id=
|
|
99
|
-
branch_name=model.branch_name,
|
|
160
|
+
id=issue_id,
|
|
100
161
|
)
|
|
101
162
|
|
|
102
163
|
# Try to load full issue metadata
|
|
@@ -108,7 +169,7 @@ class HookContext:
|
|
|
108
169
|
project_root = find_monoco_root()
|
|
109
170
|
|
|
110
171
|
issues_root = project_root / "Issues"
|
|
111
|
-
issue_path = find_issue_path(issues_root,
|
|
172
|
+
issue_path = find_issue_path(issues_root, issue_id)
|
|
112
173
|
if issue_path:
|
|
113
174
|
metadata = parse_issue(issue_path)
|
|
114
175
|
if metadata:
|
|
@@ -122,10 +183,10 @@ class HookContext:
|
|
|
122
183
|
git_info = GitInfo(project_root=project_root)
|
|
123
184
|
|
|
124
185
|
return cls(
|
|
125
|
-
session_id=
|
|
126
|
-
role_name=
|
|
127
|
-
session_status=
|
|
128
|
-
created_at=
|
|
186
|
+
session_id=session_id,
|
|
187
|
+
role_name=role_name,
|
|
188
|
+
session_status=status,
|
|
189
|
+
created_at=datetime.now(),
|
|
129
190
|
issue=issue_info,
|
|
130
191
|
git=git_info,
|
|
131
192
|
)
|
monoco/core/injection.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import re
|
|
2
2
|
from pathlib import Path
|
|
3
|
-
from typing import Dict
|
|
3
|
+
from typing import Dict, Optional
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
class PromptInjector:
|
|
@@ -13,8 +13,52 @@ class PromptInjector:
|
|
|
13
13
|
MANAGED_START = "<!-- MONOCO_GENERATED_START -->"
|
|
14
14
|
MANAGED_END = "<!-- MONOCO_GENERATED_END -->"
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
FILE_HEADER_COMMENT = """<!--
|
|
17
|
+
⚠️ IMPORTANT: This file is partially managed by Monoco.
|
|
18
|
+
- Content between MONOCO_GENERATED_START and MONOCO_GENERATED_END is auto-generated.
|
|
19
|
+
- Do NOT manually edit the managed block.
|
|
20
|
+
- Do NOT add content after MONOCO_GENERATED_END (use separate files instead).
|
|
21
|
+
-->
|
|
22
|
+
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, target_file: Path, verbose: bool = True):
|
|
17
26
|
self.target_file = target_file
|
|
27
|
+
self.verbose = verbose
|
|
28
|
+
|
|
29
|
+
def _detect_external_content(self, content: str) -> Optional[str]:
|
|
30
|
+
"""
|
|
31
|
+
Detects content outside the managed block.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
The external content string if found, None otherwise.
|
|
35
|
+
"""
|
|
36
|
+
if not content or self.MANAGED_END not in content:
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
# Split by MANAGED_END and check if there's non-empty content after
|
|
40
|
+
parts = content.split(self.MANAGED_END)
|
|
41
|
+
if len(parts) < 2:
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
post_content = parts[-1].strip()
|
|
45
|
+
# Check if there's meaningful content (not just whitespace or newlines)
|
|
46
|
+
if post_content and len(post_content) > 10: # Threshold to avoid false positives
|
|
47
|
+
return post_content
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
def _warn_external_content(self, external_content: str):
|
|
51
|
+
"""Outputs warning about external content."""
|
|
52
|
+
if not self.verbose:
|
|
53
|
+
return
|
|
54
|
+
|
|
55
|
+
# Truncate long content for warning message
|
|
56
|
+
preview = external_content[:200].replace("\n", " ")
|
|
57
|
+
if len(external_content) > 200:
|
|
58
|
+
preview += "..."
|
|
59
|
+
|
|
60
|
+
print(f"⚠️ Warning: Manual content detected after Managed Block in {self.target_file}")
|
|
61
|
+
print(f" Consider moving to a separate file. Found content starting with: {preview}")
|
|
18
62
|
|
|
19
63
|
def inject(self, prompts: Dict[str, str]) -> bool:
|
|
20
64
|
"""
|
|
@@ -30,6 +74,11 @@ class PromptInjector:
|
|
|
30
74
|
if self.target_file.exists():
|
|
31
75
|
current_content = self.target_file.read_text(encoding="utf-8")
|
|
32
76
|
|
|
77
|
+
# Check for external content and warn
|
|
78
|
+
external_content = self._detect_external_content(current_content)
|
|
79
|
+
if external_content:
|
|
80
|
+
self._warn_external_content(external_content)
|
|
81
|
+
|
|
33
82
|
new_content = self._merge_content(current_content, prompts)
|
|
34
83
|
|
|
35
84
|
if new_content != current_content:
|
|
@@ -76,12 +125,18 @@ class PromptInjector:
|
|
|
76
125
|
managed_block_str = "\n".join(managed_block).strip() + "\n"
|
|
77
126
|
managed_block_str = f"{self.MANAGED_START}\n{managed_block_str}\n{self.MANAGED_END}\n"
|
|
78
127
|
|
|
128
|
+
# 2. Add file header comment if not present
|
|
129
|
+
has_header = original.strip().startswith("<!--") and "IMPORTANT: This file is partially managed by Monoco" in original
|
|
130
|
+
|
|
79
131
|
# 2. Find and replace/append in the original content
|
|
80
132
|
# Check for delimiters first
|
|
81
133
|
if self.MANAGED_START in original and self.MANAGED_END in original:
|
|
82
134
|
try:
|
|
83
135
|
pre = original.split(self.MANAGED_START)[0]
|
|
84
136
|
post = original.split(self.MANAGED_END)[1]
|
|
137
|
+
# Add header comment if not present
|
|
138
|
+
if not has_header and not pre.strip().startswith("<!--"):
|
|
139
|
+
pre = self.FILE_HEADER_COMMENT + pre
|
|
85
140
|
# Reconstruct
|
|
86
141
|
return pre + managed_block_str.strip() + post
|
|
87
142
|
except IndexError:
|
|
@@ -105,12 +160,16 @@ class PromptInjector:
|
|
|
105
160
|
|
|
106
161
|
if start_idx == -1:
|
|
107
162
|
# Block not found, append to end
|
|
163
|
+
result = ""
|
|
164
|
+
if not has_header:
|
|
165
|
+
result = self.FILE_HEADER_COMMENT
|
|
108
166
|
if original and not original.endswith("\n"):
|
|
109
|
-
|
|
167
|
+
result += original + "\n\n" + managed_block_str.strip()
|
|
110
168
|
elif original:
|
|
111
|
-
|
|
169
|
+
result += original + "\n" + managed_block_str.strip()
|
|
112
170
|
else:
|
|
113
|
-
|
|
171
|
+
result += managed_block_str.strip() + "\n"
|
|
172
|
+
return result
|
|
114
173
|
|
|
115
174
|
# Find end: Look for next header of level 1 or 2 (siblings or parents)
|
|
116
175
|
header_level_match = re.match(r"^(#+)\s", self.MANAGED_HEADER)
|
|
@@ -134,9 +193,13 @@ class PromptInjector:
|
|
|
134
193
|
pre_block = "\n".join(lines[:start_idx])
|
|
135
194
|
post_block = "\n".join(lines[end_idx:])
|
|
136
195
|
|
|
137
|
-
result =
|
|
138
|
-
if
|
|
139
|
-
|
|
196
|
+
result = ""
|
|
197
|
+
# Add header comment if not present
|
|
198
|
+
if not has_header and not pre_block.strip().startswith("<!--"):
|
|
199
|
+
result = self.FILE_HEADER_COMMENT
|
|
200
|
+
|
|
201
|
+
if pre_block:
|
|
202
|
+
result += pre_block + "\n\n"
|
|
140
203
|
|
|
141
204
|
result += managed_block_str
|
|
142
205
|
|
|
@@ -218,6 +281,21 @@ class PromptInjector:
|
|
|
218
281
|
pre_block = lines[:start_idx]
|
|
219
282
|
post_block = lines[end_idx:]
|
|
220
283
|
|
|
284
|
+
# Check if pre_block contains only the file header comment
|
|
285
|
+
pre_text = "\n".join(pre_block)
|
|
286
|
+
if pre_text.strip() and "This file is partially managed by Monoco" in pre_text:
|
|
287
|
+
# Check if pre_block is just the header comment
|
|
288
|
+
is_only_header = all(
|
|
289
|
+
line.strip().startswith("<!--") or
|
|
290
|
+
line.strip().startswith("⚠️ IMPORTANT") or
|
|
291
|
+
line.strip().startswith("-") or
|
|
292
|
+
line.strip().startswith("-->") or
|
|
293
|
+
not line.strip()
|
|
294
|
+
for line in pre_block
|
|
295
|
+
)
|
|
296
|
+
if is_only_header and not post_block:
|
|
297
|
+
pre_block = []
|
|
298
|
+
|
|
221
299
|
# If we removed everything, the file might become empty or just newlines
|
|
222
300
|
|
|
223
301
|
new_lines = pre_block + post_block
|
monoco/core/integrations.py
CHANGED
|
@@ -94,14 +94,6 @@ class AgentProviderHealth(BaseModel):
|
|
|
94
94
|
|
|
95
95
|
# Default Integration Registry
|
|
96
96
|
DEFAULT_INTEGRATIONS: Dict[str, AgentIntegration] = {
|
|
97
|
-
"cursor": AgentIntegration(
|
|
98
|
-
key="cursor",
|
|
99
|
-
name="Cursor",
|
|
100
|
-
system_prompt_file=".cursorrules",
|
|
101
|
-
skill_root_dir=".cursor/skills/",
|
|
102
|
-
bin_name="cursor",
|
|
103
|
-
version_cmd="--version",
|
|
104
|
-
),
|
|
105
97
|
"claude": AgentIntegration(
|
|
106
98
|
key="claude",
|
|
107
99
|
name="Claude Code",
|
|
@@ -118,22 +110,6 @@ DEFAULT_INTEGRATIONS: Dict[str, AgentIntegration] = {
|
|
|
118
110
|
bin_name="gemini",
|
|
119
111
|
version_cmd="--version",
|
|
120
112
|
),
|
|
121
|
-
"qwen": AgentIntegration(
|
|
122
|
-
key="qwen",
|
|
123
|
-
name="Qwen Code",
|
|
124
|
-
system_prompt_file="QWEN.md",
|
|
125
|
-
skill_root_dir=".qwen/skills/",
|
|
126
|
-
bin_name="qwen",
|
|
127
|
-
version_cmd="--version",
|
|
128
|
-
),
|
|
129
|
-
"kimi": AgentIntegration(
|
|
130
|
-
key="kimi",
|
|
131
|
-
name="Kimi CLI",
|
|
132
|
-
system_prompt_file="AGENTS.md",
|
|
133
|
-
skill_root_dir=".agent/skills/",
|
|
134
|
-
bin_name="kimi",
|
|
135
|
-
version_cmd="--version",
|
|
136
|
-
),
|
|
137
113
|
"agent": AgentIntegration(
|
|
138
114
|
key="agent",
|
|
139
115
|
name="Generic Agent",
|