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.
Files changed (132) hide show
  1. monoco/core/automation/__init__.py +40 -0
  2. monoco/core/automation/field_watcher.py +296 -0
  3. monoco/core/automation/handlers.py +805 -0
  4. monoco/core/config.py +29 -11
  5. monoco/core/daemon/__init__.py +5 -0
  6. monoco/core/daemon/pid.py +290 -0
  7. monoco/core/git.py +15 -0
  8. monoco/core/hooks/context.py +74 -13
  9. monoco/core/injection.py +86 -8
  10. monoco/core/integrations.py +0 -24
  11. monoco/core/router/__init__.py +17 -0
  12. monoco/core/router/action.py +202 -0
  13. monoco/core/scheduler/__init__.py +63 -0
  14. monoco/core/scheduler/base.py +152 -0
  15. monoco/core/scheduler/engines.py +175 -0
  16. monoco/core/scheduler/events.py +197 -0
  17. monoco/core/scheduler/local.py +377 -0
  18. monoco/core/setup.py +9 -0
  19. monoco/core/sync.py +199 -4
  20. monoco/core/watcher/__init__.py +63 -0
  21. monoco/core/watcher/base.py +382 -0
  22. monoco/core/watcher/dropzone.py +152 -0
  23. monoco/core/watcher/im.py +460 -0
  24. monoco/core/watcher/issue.py +303 -0
  25. monoco/core/watcher/memo.py +192 -0
  26. monoco/core/watcher/task.py +238 -0
  27. monoco/daemon/app.py +3 -60
  28. monoco/daemon/commands.py +459 -25
  29. monoco/daemon/events.py +34 -0
  30. monoco/daemon/scheduler.py +157 -201
  31. monoco/daemon/services.py +42 -243
  32. monoco/features/agent/__init__.py +25 -7
  33. monoco/features/agent/cli.py +91 -57
  34. monoco/features/agent/engines.py +31 -170
  35. monoco/features/agent/resources/en/AGENTS.md +14 -14
  36. monoco/features/agent/resources/en/skills/monoco_role_engineer/SKILL.md +101 -0
  37. monoco/features/agent/resources/en/skills/monoco_role_manager/SKILL.md +95 -0
  38. monoco/features/agent/resources/en/skills/monoco_role_planner/SKILL.md +177 -0
  39. monoco/features/agent/resources/en/skills/monoco_role_reviewer/SKILL.md +139 -0
  40. monoco/features/agent/resources/zh/skills/monoco_role_engineer/SKILL.md +101 -0
  41. monoco/features/agent/resources/zh/skills/monoco_role_manager/SKILL.md +95 -0
  42. monoco/features/agent/resources/zh/skills/monoco_role_planner/SKILL.md +177 -0
  43. monoco/features/agent/resources/zh/skills/monoco_role_reviewer/SKILL.md +139 -0
  44. monoco/features/agent/worker.py +1 -1
  45. monoco/features/hooks/__init__.py +61 -6
  46. monoco/features/hooks/commands.py +281 -271
  47. monoco/features/hooks/dispatchers/__init__.py +23 -0
  48. monoco/features/hooks/dispatchers/agent_dispatcher.py +486 -0
  49. monoco/features/hooks/dispatchers/git_dispatcher.py +478 -0
  50. monoco/features/hooks/manager.py +357 -0
  51. monoco/features/hooks/models.py +262 -0
  52. monoco/features/hooks/parser.py +322 -0
  53. monoco/features/hooks/universal_interceptor.py +503 -0
  54. monoco/features/im/__init__.py +67 -0
  55. monoco/features/im/core.py +782 -0
  56. monoco/features/im/models.py +311 -0
  57. monoco/features/issue/commands.py +133 -60
  58. monoco/features/issue/core.py +385 -40
  59. monoco/features/issue/domain_commands.py +0 -19
  60. monoco/features/issue/resources/en/AGENTS.md +17 -122
  61. monoco/features/issue/resources/hooks/agent/before-tool.sh +102 -0
  62. monoco/features/issue/resources/hooks/agent/session-start.sh +88 -0
  63. monoco/features/issue/resources/hooks/{post-checkout.sh → git/git-post-checkout.sh} +10 -9
  64. monoco/features/issue/resources/hooks/git/git-pre-commit.sh +31 -0
  65. monoco/features/issue/resources/hooks/{pre-push.sh → git/git-pre-push.sh} +7 -13
  66. monoco/features/issue/resources/zh/AGENTS.md +18 -123
  67. monoco/features/memo/cli.py +15 -64
  68. monoco/features/memo/core.py +6 -34
  69. monoco/features/memo/models.py +24 -15
  70. monoco/features/memo/resources/en/AGENTS.md +31 -0
  71. monoco/features/memo/resources/zh/AGENTS.md +28 -5
  72. monoco/features/spike/commands.py +5 -3
  73. monoco/main.py +5 -3
  74. {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.4.0.dist-info}/METADATA +1 -1
  75. monoco_toolkit-0.4.0.dist-info/RECORD +170 -0
  76. monoco/core/execution.py +0 -67
  77. monoco/features/agent/apoptosis.py +0 -44
  78. monoco/features/agent/manager.py +0 -127
  79. monoco/features/agent/resources/atoms/atom-code-dev.yaml +0 -61
  80. monoco/features/agent/resources/atoms/atom-issue-lifecycle.yaml +0 -73
  81. monoco/features/agent/resources/atoms/atom-knowledge.yaml +0 -55
  82. monoco/features/agent/resources/atoms/atom-review.yaml +0 -60
  83. monoco/features/agent/resources/en/skills/monoco_atom_core/SKILL.md +0 -99
  84. monoco/features/agent/resources/en/skills/monoco_workflow_agent_engineer/SKILL.md +0 -94
  85. monoco/features/agent/resources/en/skills/monoco_workflow_agent_manager/SKILL.md +0 -93
  86. monoco/features/agent/resources/en/skills/monoco_workflow_agent_planner/SKILL.md +0 -85
  87. monoco/features/agent/resources/en/skills/monoco_workflow_agent_reviewer/SKILL.md +0 -114
  88. monoco/features/agent/resources/workflows/workflow-dev.yaml +0 -83
  89. monoco/features/agent/resources/workflows/workflow-issue-create.yaml +0 -72
  90. monoco/features/agent/resources/workflows/workflow-review.yaml +0 -94
  91. monoco/features/agent/resources/zh/roles/monoco_role_engineer.yaml +0 -49
  92. monoco/features/agent/resources/zh/roles/monoco_role_manager.yaml +0 -46
  93. monoco/features/agent/resources/zh/roles/monoco_role_planner.yaml +0 -46
  94. monoco/features/agent/resources/zh/roles/monoco_role_reviewer.yaml +0 -47
  95. monoco/features/agent/resources/zh/skills/monoco_atom_core/SKILL.md +0 -99
  96. monoco/features/agent/resources/zh/skills/monoco_workflow_agent_engineer/SKILL.md +0 -94
  97. monoco/features/agent/resources/zh/skills/monoco_workflow_agent_manager/SKILL.md +0 -88
  98. monoco/features/agent/resources/zh/skills/monoco_workflow_agent_planner/SKILL.md +0 -259
  99. monoco/features/agent/resources/zh/skills/monoco_workflow_agent_reviewer/SKILL.md +0 -137
  100. monoco/features/agent/session.py +0 -169
  101. monoco/features/artifact/resources/zh/skills/monoco_atom_artifact/SKILL.md +0 -278
  102. monoco/features/glossary/resources/en/skills/monoco_atom_glossary/SKILL.md +0 -35
  103. monoco/features/glossary/resources/zh/skills/monoco_atom_glossary/SKILL.md +0 -35
  104. monoco/features/hooks/adapter.py +0 -67
  105. monoco/features/hooks/core.py +0 -441
  106. monoco/features/i18n/resources/en/skills/monoco_atom_i18n/SKILL.md +0 -96
  107. monoco/features/i18n/resources/en/skills/monoco_workflow_i18n_scan/SKILL.md +0 -105
  108. monoco/features/i18n/resources/zh/skills/monoco_atom_i18n/SKILL.md +0 -96
  109. monoco/features/i18n/resources/zh/skills/monoco_workflow_i18n_scan/SKILL.md +0 -105
  110. monoco/features/issue/resources/en/skills/monoco_atom_issue/SKILL.md +0 -165
  111. monoco/features/issue/resources/en/skills/monoco_workflow_issue_creation/SKILL.md +0 -167
  112. monoco/features/issue/resources/en/skills/monoco_workflow_issue_development/SKILL.md +0 -224
  113. monoco/features/issue/resources/en/skills/monoco_workflow_issue_management/SKILL.md +0 -159
  114. monoco/features/issue/resources/en/skills/monoco_workflow_issue_refinement/SKILL.md +0 -203
  115. monoco/features/issue/resources/hooks/pre-commit.sh +0 -41
  116. monoco/features/issue/resources/zh/skills/monoco_atom_issue_lifecycle/SKILL.md +0 -190
  117. monoco/features/issue/resources/zh/skills/monoco_workflow_issue_creation/SKILL.md +0 -167
  118. monoco/features/issue/resources/zh/skills/monoco_workflow_issue_development/SKILL.md +0 -224
  119. monoco/features/issue/resources/zh/skills/monoco_workflow_issue_management/SKILL.md +0 -159
  120. monoco/features/issue/resources/zh/skills/monoco_workflow_issue_refinement/SKILL.md +0 -203
  121. monoco/features/memo/resources/en/skills/monoco_atom_memo/SKILL.md +0 -77
  122. monoco/features/memo/resources/en/skills/monoco_workflow_note_processing/SKILL.md +0 -140
  123. monoco/features/memo/resources/zh/skills/monoco_atom_memo/SKILL.md +0 -77
  124. monoco/features/memo/resources/zh/skills/monoco_workflow_note_processing/SKILL.md +0 -140
  125. monoco/features/spike/resources/en/skills/monoco_atom_spike/SKILL.md +0 -76
  126. monoco/features/spike/resources/en/skills/monoco_workflow_research/SKILL.md +0 -121
  127. monoco/features/spike/resources/zh/skills/monoco_atom_spike/SKILL.md +0 -76
  128. monoco/features/spike/resources/zh/skills/monoco_workflow_research/SKILL.md +0 -121
  129. monoco_toolkit-0.3.11.dist-info/RECORD +0 -181
  130. {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.4.0.dist-info}/WHEEL +0 -0
  131. {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.4.0.dist-info}/entry_points.txt +0 -0
  132. {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
- planner: int = Field(default=1, description="Maximum concurrent Planner agents")
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
- # We watch the parent directory for the specific file
475
- self.observer.schedule(
476
- event_handler, str(self.config_path.parent), recursive=False
477
- )
478
- self.observer.start()
479
- logger.info(f"Config Monitor started for {self.config_path}")
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.observer.is_alive():
483
- self.observer.stop()
484
- self.observer.join()
485
- logger.info(f"Config Monitor stopped for {self.config_path}")
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,5 @@
1
+ """Monoco Daemon core components."""
2
+
3
+ from monoco.core.daemon.pid import PIDManager, PIDFileError, PortManager
4
+
5
+ __all__ = ["PIDManager", "PIDFileError", "PortManager"]
@@ -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.
@@ -74,29 +74,90 @@ class HookContext:
74
74
  extra: Dict[str, Any] = field(default_factory=dict)
75
75
 
76
76
  @classmethod
77
- def from_runtime_session(
77
+ def from_agent_task(
78
78
  cls,
79
- runtime_session: Any,
79
+ task: Any,
80
80
  project_root: Optional[Path] = None,
81
81
  ) -> "HookContext":
82
82
  """
83
- Create a HookContext from a RuntimeSession.
83
+ Create a HookContext from an AgentTask.
84
84
 
85
85
  Args:
86
- runtime_session: The RuntimeSession object
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
- model = runtime_session.model
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 model.issue_id:
158
+ if issue_id:
97
159
  issue_info = IssueInfo(
98
- id=model.issue_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, model.issue_id)
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=model.id,
126
- role_name=model.role_name,
127
- session_status=model.status,
128
- created_at=model.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
- def __init__(self, target_file: Path):
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
- return original + "\n\n" + managed_block_str.strip()
167
+ result += original + "\n\n" + managed_block_str.strip()
110
168
  elif original:
111
- return original + "\n" + managed_block_str.strip()
169
+ result += original + "\n" + managed_block_str.strip()
112
170
  else:
113
- return managed_block_str.strip() + "\n"
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 = pre_block
138
- if result:
139
- result += "\n\n"
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
@@ -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",