monoco-toolkit 0.3.10__py3-none-any.whl → 0.3.12__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 (130) hide show
  1. monoco/__main__.py +8 -0
  2. monoco/core/artifacts/__init__.py +16 -0
  3. monoco/core/artifacts/manager.py +575 -0
  4. monoco/core/artifacts/models.py +161 -0
  5. monoco/core/automation/__init__.py +51 -0
  6. monoco/core/automation/config.py +338 -0
  7. monoco/core/automation/field_watcher.py +296 -0
  8. monoco/core/automation/handlers.py +723 -0
  9. monoco/core/config.py +31 -4
  10. monoco/core/executor/__init__.py +38 -0
  11. monoco/core/executor/agent_action.py +254 -0
  12. monoco/core/executor/git_action.py +303 -0
  13. monoco/core/executor/im_action.py +309 -0
  14. monoco/core/executor/pytest_action.py +218 -0
  15. monoco/core/git.py +38 -0
  16. monoco/core/hooks/context.py +74 -13
  17. monoco/core/ingestion/__init__.py +20 -0
  18. monoco/core/ingestion/discovery.py +248 -0
  19. monoco/core/ingestion/watcher.py +343 -0
  20. monoco/core/ingestion/worker.py +436 -0
  21. monoco/core/loader.py +633 -0
  22. monoco/core/registry.py +34 -25
  23. monoco/core/router/__init__.py +55 -0
  24. monoco/core/router/action.py +341 -0
  25. monoco/core/router/router.py +392 -0
  26. monoco/core/scheduler/__init__.py +63 -0
  27. monoco/core/scheduler/base.py +152 -0
  28. monoco/core/scheduler/engines.py +175 -0
  29. monoco/core/scheduler/events.py +171 -0
  30. monoco/core/scheduler/local.py +377 -0
  31. monoco/core/skills.py +119 -80
  32. monoco/core/watcher/__init__.py +57 -0
  33. monoco/core/watcher/base.py +365 -0
  34. monoco/core/watcher/dropzone.py +152 -0
  35. monoco/core/watcher/issue.py +303 -0
  36. monoco/core/watcher/memo.py +200 -0
  37. monoco/core/watcher/task.py +238 -0
  38. monoco/daemon/app.py +77 -1
  39. monoco/daemon/commands.py +10 -0
  40. monoco/daemon/events.py +34 -0
  41. monoco/daemon/mailroom_service.py +196 -0
  42. monoco/daemon/models.py +1 -0
  43. monoco/daemon/scheduler.py +207 -0
  44. monoco/daemon/services.py +27 -58
  45. monoco/daemon/triggers.py +55 -0
  46. monoco/features/agent/__init__.py +25 -7
  47. monoco/features/agent/adapter.py +17 -7
  48. monoco/features/agent/cli.py +91 -57
  49. monoco/features/agent/engines.py +31 -170
  50. monoco/{core/resources/en/skills/monoco_core → features/agent/resources/en/skills/monoco_atom_core}/SKILL.md +2 -2
  51. monoco/features/agent/resources/en/skills/{flow_engineer → monoco_workflow_agent_engineer}/SKILL.md +2 -2
  52. monoco/features/agent/resources/en/skills/{flow_manager → monoco_workflow_agent_manager}/SKILL.md +2 -2
  53. monoco/features/agent/resources/en/skills/{flow_planner → monoco_workflow_agent_planner}/SKILL.md +2 -2
  54. monoco/features/agent/resources/en/skills/{flow_reviewer → monoco_workflow_agent_reviewer}/SKILL.md +2 -2
  55. monoco/features/agent/resources/{roles/role-engineer.yaml → zh/roles/monoco_role_engineer.yaml} +3 -3
  56. monoco/features/agent/resources/{roles/role-manager.yaml → zh/roles/monoco_role_manager.yaml} +8 -8
  57. monoco/features/agent/resources/{roles/role-planner.yaml → zh/roles/monoco_role_planner.yaml} +8 -8
  58. monoco/features/agent/resources/{roles/role-reviewer.yaml → zh/roles/monoco_role_reviewer.yaml} +8 -8
  59. monoco/{core/resources/zh/skills/monoco_core → features/agent/resources/zh/skills/monoco_atom_core}/SKILL.md +2 -2
  60. monoco/features/agent/resources/zh/skills/{flow_engineer → monoco_workflow_agent_engineer}/SKILL.md +2 -2
  61. monoco/features/agent/resources/zh/skills/{flow_manager → monoco_workflow_agent_manager}/SKILL.md +2 -2
  62. monoco/features/agent/resources/zh/skills/{flow_planner → monoco_workflow_agent_planner}/SKILL.md +2 -2
  63. monoco/features/agent/resources/zh/skills/{flow_reviewer → monoco_workflow_agent_reviewer}/SKILL.md +2 -2
  64. monoco/features/agent/worker.py +1 -1
  65. monoco/features/artifact/__init__.py +0 -0
  66. monoco/features/artifact/adapter.py +33 -0
  67. monoco/features/artifact/resources/zh/AGENTS.md +14 -0
  68. monoco/features/artifact/resources/zh/skills/monoco_atom_artifact/SKILL.md +278 -0
  69. monoco/features/glossary/adapter.py +18 -7
  70. monoco/features/glossary/resources/en/skills/{monoco_glossary → monoco_atom_glossary}/SKILL.md +2 -2
  71. monoco/features/glossary/resources/zh/skills/{monoco_glossary → monoco_atom_glossary}/SKILL.md +2 -2
  72. monoco/features/hooks/__init__.py +11 -0
  73. monoco/features/hooks/adapter.py +67 -0
  74. monoco/features/hooks/commands.py +309 -0
  75. monoco/features/hooks/core.py +441 -0
  76. monoco/features/hooks/resources/ADDING_HOOKS.md +234 -0
  77. monoco/features/i18n/adapter.py +18 -5
  78. monoco/features/i18n/core.py +482 -17
  79. monoco/features/i18n/resources/en/skills/{monoco_i18n → monoco_atom_i18n}/SKILL.md +2 -2
  80. monoco/features/i18n/resources/en/skills/{i18n_scan_workflow → monoco_workflow_i18n_scan}/SKILL.md +2 -2
  81. monoco/features/i18n/resources/zh/skills/{monoco_i18n → monoco_atom_i18n}/SKILL.md +2 -2
  82. monoco/features/i18n/resources/zh/skills/{i18n_scan_workflow → monoco_workflow_i18n_scan}/SKILL.md +2 -2
  83. monoco/features/issue/adapter.py +19 -6
  84. monoco/features/issue/commands.py +352 -20
  85. monoco/features/issue/core.py +475 -16
  86. monoco/features/issue/engine/machine.py +114 -4
  87. monoco/features/issue/linter.py +60 -5
  88. monoco/features/issue/models.py +2 -2
  89. monoco/features/issue/resources/en/AGENTS.md +109 -0
  90. monoco/features/issue/resources/en/skills/{monoco_issue → monoco_atom_issue}/SKILL.md +2 -2
  91. monoco/features/issue/resources/en/skills/{issue_create_workflow → monoco_workflow_issue_creation}/SKILL.md +2 -2
  92. monoco/features/issue/resources/en/skills/{issue_develop_workflow → monoco_workflow_issue_development}/SKILL.md +2 -2
  93. monoco/features/issue/resources/en/skills/{issue_lifecycle_workflow → monoco_workflow_issue_management}/SKILL.md +2 -2
  94. monoco/features/issue/resources/en/skills/{issue_refine_workflow → monoco_workflow_issue_refinement}/SKILL.md +2 -2
  95. monoco/features/issue/resources/hooks/post-checkout.sh +39 -0
  96. monoco/features/issue/resources/hooks/pre-commit.sh +41 -0
  97. monoco/features/issue/resources/hooks/pre-push.sh +35 -0
  98. monoco/features/issue/resources/zh/AGENTS.md +109 -0
  99. monoco/features/issue/resources/zh/skills/{monoco_issue → monoco_atom_issue_lifecycle}/SKILL.md +2 -2
  100. monoco/features/issue/resources/zh/skills/{issue_create_workflow → monoco_workflow_issue_creation}/SKILL.md +2 -2
  101. monoco/features/issue/resources/zh/skills/{issue_develop_workflow → monoco_workflow_issue_development}/SKILL.md +2 -2
  102. monoco/features/issue/resources/zh/skills/{issue_lifecycle_workflow → monoco_workflow_issue_management}/SKILL.md +2 -2
  103. monoco/features/issue/resources/zh/skills/{issue_refine_workflow → monoco_workflow_issue_refinement}/SKILL.md +2 -2
  104. monoco/features/issue/validator.py +101 -1
  105. monoco/features/memo/adapter.py +21 -8
  106. monoco/features/memo/cli.py +103 -10
  107. monoco/features/memo/core.py +178 -92
  108. monoco/features/memo/models.py +53 -0
  109. monoco/features/memo/resources/en/skills/{monoco_memo → monoco_atom_memo}/SKILL.md +2 -2
  110. monoco/features/memo/resources/en/skills/{note_processing_workflow → monoco_workflow_note_processing}/SKILL.md +2 -2
  111. monoco/features/memo/resources/zh/skills/{monoco_memo → monoco_atom_memo}/SKILL.md +2 -2
  112. monoco/features/memo/resources/zh/skills/{note_processing_workflow → monoco_workflow_note_processing}/SKILL.md +2 -2
  113. monoco/features/spike/adapter.py +18 -5
  114. monoco/features/spike/commands.py +5 -3
  115. monoco/features/spike/resources/en/skills/{monoco_spike → monoco_atom_spike}/SKILL.md +2 -2
  116. monoco/features/spike/resources/en/skills/{research_workflow → monoco_workflow_research}/SKILL.md +2 -2
  117. monoco/features/spike/resources/zh/skills/{monoco_spike → monoco_atom_spike}/SKILL.md +2 -2
  118. monoco/features/spike/resources/zh/skills/{research_workflow → monoco_workflow_research}/SKILL.md +2 -2
  119. monoco/main.py +38 -1
  120. {monoco_toolkit-0.3.10.dist-info → monoco_toolkit-0.3.12.dist-info}/METADATA +7 -1
  121. monoco_toolkit-0.3.12.dist-info/RECORD +202 -0
  122. monoco/features/agent/apoptosis.py +0 -44
  123. monoco/features/agent/manager.py +0 -91
  124. monoco/features/agent/session.py +0 -121
  125. monoco_toolkit-0.3.10.dist-info/RECORD +0 -156
  126. /monoco/{core → features/agent}/resources/en/AGENTS.md +0 -0
  127. /monoco/{core → features/agent}/resources/zh/AGENTS.md +0 -0
  128. {monoco_toolkit-0.3.10.dist-info → monoco_toolkit-0.3.12.dist-info}/WHEEL +0 -0
  129. {monoco_toolkit-0.3.10.dist-info → monoco_toolkit-0.3.12.dist-info}/entry_points.txt +0 -0
  130. {monoco_toolkit-0.3.10.dist-info → monoco_toolkit-0.3.12.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,309 @@
1
+ """
2
+ SendIMAction - Action for sending notifications.
3
+
4
+ Part of Layer 3 (Action Executor) in the event automation framework.
5
+ Provides action for sending IM/webhook notifications.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ import logging
13
+ from typing import Any, Dict, List, Optional
14
+
15
+ from monoco.core.scheduler import AgentEvent
16
+ from monoco.core.router import Action, ActionResult
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class NotificationResult:
22
+ """Result of a notification send."""
23
+
24
+ def __init__(
25
+ self,
26
+ success: bool,
27
+ message: str,
28
+ response: Optional[Any] = None,
29
+ ):
30
+ self.success = success
31
+ self.message = message
32
+ self.response = response
33
+
34
+
35
+ class SendIMAction(Action):
36
+ """
37
+ Action that sends notifications via IM or webhook.
38
+
39
+ This action sends notifications to various channels:
40
+ - Webhook (HTTP POST)
41
+ - Console (stdout)
42
+ - File (append to log file)
43
+
44
+ Future: Slack, Discord, Email, etc.
45
+
46
+ Example:
47
+ >>> action = SendIMAction(
48
+ ... channel="webhook",
49
+ ... webhook_url="https://hooks.example.com/notify",
50
+ ... message_template="Issue {issue_id} updated to {new_stage}",
51
+ ... )
52
+ >>> result = await action(event)
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ channel: str = "console",
58
+ message_template: str = "{event_type}: {payload}",
59
+ webhook_url: Optional[str] = None,
60
+ webhook_headers: Optional[Dict[str, str]] = None,
61
+ log_file: Optional[str] = None,
62
+ timeout: int = 30,
63
+ config: Optional[Dict[str, Any]] = None,
64
+ ):
65
+ super().__init__(config)
66
+ self.channel = channel
67
+ self.message_template = message_template
68
+ self.webhook_url = webhook_url
69
+ self.webhook_headers = webhook_headers or {}
70
+ self.log_file = log_file
71
+ self.timeout = timeout
72
+ self._last_result: Optional[NotificationResult] = None
73
+
74
+ @property
75
+ def name(self) -> str:
76
+ return f"SendIMAction({self.channel})"
77
+
78
+ async def can_execute(self, event: AgentEvent) -> bool:
79
+ """Check if the channel is available."""
80
+ if self.channel == "webhook":
81
+ return self.webhook_url is not None
82
+ elif self.channel == "file":
83
+ return self.log_file is not None
84
+ elif self.channel == "console":
85
+ return True
86
+ return False
87
+
88
+ async def execute(self, event: AgentEvent) -> ActionResult:
89
+ """Send notification."""
90
+ # Format message
91
+ message = self._format_message(event)
92
+
93
+ logger.debug(f"Sending {self.channel} notification: {message[:100]}...")
94
+
95
+ try:
96
+ if self.channel == "webhook":
97
+ result = await self._send_webhook(message, event)
98
+ elif self.channel == "file":
99
+ result = await self._write_to_file(message)
100
+ else: # console
101
+ result = await self._send_console(message)
102
+
103
+ self._last_result = result
104
+
105
+ if result.success:
106
+ return ActionResult.success_result(
107
+ output={
108
+ "channel": self.channel,
109
+ "message_sent": True,
110
+ },
111
+ metadata={
112
+ "message_preview": message[:200],
113
+ },
114
+ )
115
+ else:
116
+ return ActionResult.failure_result(
117
+ error=result.message,
118
+ metadata={
119
+ "channel": self.channel,
120
+ },
121
+ )
122
+
123
+ except Exception as e:
124
+ logger.error(f"Failed to send notification: {e}")
125
+ return ActionResult.failure_result(error=str(e))
126
+
127
+ def _format_message(self, event: AgentEvent) -> str:
128
+ """Format notification message with event data."""
129
+ try:
130
+ return self.message_template.format(
131
+ event_type=event.type.value,
132
+ timestamp=event.timestamp.isoformat(),
133
+ source=event.source or "unknown",
134
+ **event.payload,
135
+ )
136
+ except (KeyError, ValueError) as e:
137
+ # If formatting fails, return a simple message
138
+ return f"Event: {event.type.value} at {event.timestamp.isoformat()}"
139
+
140
+ async def _send_webhook(
141
+ self,
142
+ message: str,
143
+ event: AgentEvent,
144
+ ) -> NotificationResult:
145
+ """Send notification via webhook."""
146
+ try:
147
+ import aiohttp
148
+ except ImportError:
149
+ # Fallback to sync requests
150
+ return await self._send_webhook_sync(message, event)
151
+
152
+ payload = {
153
+ "message": message,
154
+ "event_type": event.type.value,
155
+ "timestamp": event.timestamp.isoformat(),
156
+ "source": event.source,
157
+ "payload": event.payload,
158
+ }
159
+
160
+ headers = {
161
+ "Content-Type": "application/json",
162
+ **self.webhook_headers,
163
+ }
164
+
165
+ try:
166
+ async with aiohttp.ClientSession() as session:
167
+ async with session.post(
168
+ self.webhook_url,
169
+ json=payload,
170
+ headers=headers,
171
+ timeout=aiohttp.ClientTimeout(total=self.timeout),
172
+ ) as response:
173
+ if response.status < 400:
174
+ return NotificationResult(
175
+ success=True,
176
+ message=f"Webhook sent: HTTP {response.status}",
177
+ response={
178
+ "status": response.status,
179
+ "body": await response.text(),
180
+ },
181
+ )
182
+ else:
183
+ return NotificationResult(
184
+ success=False,
185
+ message=f"Webhook failed: HTTP {response.status}",
186
+ response={"status": response.status},
187
+ )
188
+ except Exception as e:
189
+ return NotificationResult(
190
+ success=False,
191
+ message=f"Webhook error: {str(e)}",
192
+ )
193
+
194
+ async def _send_webhook_sync(
195
+ self,
196
+ message: str,
197
+ event: AgentEvent,
198
+ ) -> NotificationResult:
199
+ """Send webhook using sync requests (fallback)."""
200
+ try:
201
+ import requests
202
+ except ImportError:
203
+ return NotificationResult(
204
+ success=False,
205
+ message="Neither aiohttp nor requests available for webhook",
206
+ )
207
+
208
+ payload = {
209
+ "message": message,
210
+ "event_type": event.type.value,
211
+ "timestamp": event.timestamp.isoformat(),
212
+ "source": event.source,
213
+ "payload": event.payload,
214
+ }
215
+
216
+ headers = {
217
+ "Content-Type": "application/json",
218
+ **self.webhook_headers,
219
+ }
220
+
221
+ try:
222
+ response = requests.post(
223
+ self.webhook_url,
224
+ json=payload,
225
+ headers=headers,
226
+ timeout=self.timeout,
227
+ )
228
+
229
+ if response.status_code < 400:
230
+ return NotificationResult(
231
+ success=True,
232
+ message=f"Webhook sent: HTTP {response.status_code}",
233
+ response={
234
+ "status": response.status_code,
235
+ "body": response.text,
236
+ },
237
+ )
238
+ else:
239
+ return NotificationResult(
240
+ success=False,
241
+ message=f"Webhook failed: HTTP {response.status_code}",
242
+ )
243
+ except Exception as e:
244
+ return NotificationResult(
245
+ success=False,
246
+ message=f"Webhook error: {str(e)}",
247
+ )
248
+
249
+ async def _write_to_file(self, message: str) -> NotificationResult:
250
+ """Write notification to log file."""
251
+ try:
252
+ import aiofiles
253
+ except ImportError:
254
+ # Fallback to sync file write
255
+ return await self._write_to_file_sync(message)
256
+
257
+ try:
258
+ timestamp = asyncio.get_event_loop().time()
259
+ log_line = f"[{timestamp}] {message}\n"
260
+
261
+ async with aiofiles.open(self.log_file, "a") as f:
262
+ await f.write(log_line)
263
+
264
+ return NotificationResult(
265
+ success=True,
266
+ message=f"Written to {self.log_file}",
267
+ )
268
+ except Exception as e:
269
+ return NotificationResult(
270
+ success=False,
271
+ message=f"File write error: {str(e)}",
272
+ )
273
+
274
+ async def _write_to_file_sync(self, message: str) -> NotificationResult:
275
+ """Write to file synchronously (fallback)."""
276
+ try:
277
+ import time
278
+ log_line = f"[{time.time()}] {message}\n"
279
+
280
+ with open(self.log_file, "a") as f:
281
+ f.write(log_line)
282
+
283
+ return NotificationResult(
284
+ success=True,
285
+ message=f"Written to {self.log_file}",
286
+ )
287
+ except Exception as e:
288
+ return NotificationResult(
289
+ success=False,
290
+ message=f"File write error: {str(e)}",
291
+ )
292
+
293
+ async def _send_console(self, message: str) -> NotificationResult:
294
+ """Print notification to console."""
295
+ print(f"[NOTIFICATION] {message}")
296
+ return NotificationResult(
297
+ success=True,
298
+ message="Printed to console",
299
+ )
300
+
301
+ def get_stats(self) -> Dict[str, Any]:
302
+ """Get action statistics."""
303
+ stats = super().get_stats()
304
+ stats.update({
305
+ "channel": self.channel,
306
+ "webhook_configured": self.webhook_url is not None,
307
+ "log_file": self.log_file,
308
+ })
309
+ return stats
@@ -0,0 +1,218 @@
1
+ """
2
+ RunPytestAction - Action for running pytest tests.
3
+
4
+ Part of Layer 3 (Action Executor) in the event automation framework.
5
+ Executes pytest and parses results.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ import logging
13
+ import subprocess
14
+ from pathlib import Path
15
+ from typing import Any, Dict, List, Optional
16
+
17
+ from monoco.core.scheduler import AgentEvent
18
+ from monoco.core.router import Action, ActionResult
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class PytestResult:
24
+ """Result of a pytest execution."""
25
+
26
+ def __init__(
27
+ self,
28
+ returncode: int,
29
+ stdout: str,
30
+ stderr: str,
31
+ summary: Optional[Dict[str, Any]] = None,
32
+ ):
33
+ self.returncode = returncode
34
+ self.stdout = stdout
35
+ self.stderr = stderr
36
+ self.summary = summary or {}
37
+
38
+ @property
39
+ def passed(self) -> bool:
40
+ return self.returncode == 0
41
+
42
+ @property
43
+ def failed_count(self) -> int:
44
+ return self.summary.get("failed", 0)
45
+
46
+ @property
47
+ def passed_count(self) -> int:
48
+ return self.summary.get("passed", 0)
49
+
50
+ @property
51
+ def total_count(self) -> int:
52
+ return self.summary.get("total", 0)
53
+
54
+
55
+ class RunPytestAction(Action):
56
+ """
57
+ Action that runs pytest tests.
58
+
59
+ This action executes pytest with configurable options and
60
+ parses the results for downstream processing.
61
+
62
+ Example:
63
+ >>> action = RunPytestAction(
64
+ ... test_path="tests/",
65
+ ... markers=["unit"],
66
+ ... cov=True,
67
+ ... )
68
+ >>> result = await action(event)
69
+ """
70
+
71
+ def __init__(
72
+ self,
73
+ test_path: Optional[str] = None,
74
+ markers: Optional[List[str]] = None,
75
+ cov: bool = False,
76
+ cov_report: Optional[str] = None,
77
+ verbose: bool = True,
78
+ timeout: int = 300,
79
+ config: Optional[Dict[str, Any]] = None,
80
+ ):
81
+ super().__init__(config)
82
+ self.test_path = test_path or "."
83
+ self.markers = markers or []
84
+ self.cov = cov
85
+ self.cov_report = cov_report
86
+ self.verbose = verbose
87
+ self.timeout = timeout
88
+ self._last_result: Optional[PytestResult] = None
89
+
90
+ @property
91
+ def name(self) -> str:
92
+ return "RunPytestAction"
93
+
94
+ async def can_execute(self, event: AgentEvent) -> bool:
95
+ """Always can execute (no preconditions)."""
96
+ return True
97
+
98
+ async def execute(self, event: AgentEvent) -> ActionResult:
99
+ """Run pytest tests."""
100
+ logger.info(f"Running pytest for {self.test_path}")
101
+
102
+ try:
103
+ result = await self._run_pytest()
104
+ self._last_result = result
105
+
106
+ if result.passed:
107
+ return ActionResult.success_result(
108
+ output={
109
+ "passed": result.passed_count,
110
+ "failed": result.failed_count,
111
+ "total": result.total_count,
112
+ },
113
+ metadata={
114
+ "stdout_preview": result.stdout[:500] if result.stdout else None,
115
+ },
116
+ )
117
+ else:
118
+ return ActionResult.failure_result(
119
+ error=f"Tests failed: {result.failed_count} failures",
120
+ metadata={
121
+ "passed": result.passed_count,
122
+ "failed": result.failed_count,
123
+ "total": result.total_count,
124
+ "stderr_preview": result.stderr[:500] if result.stderr else None,
125
+ },
126
+ )
127
+
128
+ except Exception as e:
129
+ logger.error(f"Pytest execution failed: {e}")
130
+ return ActionResult.failure_result(error=str(e))
131
+
132
+ async def _run_pytest(self) -> PytestResult:
133
+ """Execute pytest subprocess."""
134
+ cmd = ["python", "-m", "pytest"]
135
+
136
+ # Add test path
137
+ cmd.append(self.test_path)
138
+
139
+ # Add markers
140
+ if self.markers:
141
+ marker_expr = " and ".join(self.markers)
142
+ cmd.extend(["-m", marker_expr])
143
+
144
+ # Add coverage
145
+ if self.cov:
146
+ cmd.append("--cov")
147
+ if self.cov_report:
148
+ cmd.extend(["--cov-report", self.cov_report])
149
+
150
+ # Add verbosity
151
+ if self.verbose:
152
+ cmd.append("-v")
153
+
154
+ # Add JSON report for parsing
155
+ cmd.extend(["--tb=short", "-q"])
156
+
157
+ logger.debug(f"Running command: {' '.join(cmd)}")
158
+
159
+ # Run subprocess
160
+ process = await asyncio.create_subprocess_exec(
161
+ *cmd,
162
+ stdout=asyncio.subprocess.PIPE,
163
+ stderr=asyncio.subprocess.PIPE,
164
+ )
165
+
166
+ try:
167
+ stdout, stderr = await asyncio.wait_for(
168
+ process.communicate(),
169
+ timeout=self.timeout,
170
+ )
171
+ except asyncio.TimeoutError:
172
+ process.kill()
173
+ raise RuntimeError(f"Pytest timed out after {self.timeout}s")
174
+
175
+ stdout_str = stdout.decode("utf-8", errors="replace")
176
+ stderr_str = stderr.decode("utf-8", errors="replace")
177
+
178
+ # Parse summary
179
+ summary = self._parse_summary(stdout_str)
180
+
181
+ return PytestResult(
182
+ returncode=process.returncode,
183
+ stdout=stdout_str,
184
+ stderr=stderr_str,
185
+ summary=summary,
186
+ )
187
+
188
+ def _parse_summary(self, output: str) -> Dict[str, int]:
189
+ """Parse pytest summary from output."""
190
+ summary = {"passed": 0, "failed": 0, "error": 0, "skipped": 0, "total": 0}
191
+
192
+ # Look for summary line like "5 passed, 2 failed in 0.5s"
193
+ import re
194
+ pattern = r"(\d+)\s+(passed|failed|error|skipped)"
195
+ matches = re.findall(pattern, output)
196
+
197
+ for count, status in matches:
198
+ summary[status] = int(count)
199
+ summary["total"] += int(count)
200
+
201
+ return summary
202
+
203
+ def get_last_result(self) -> Optional[PytestResult]:
204
+ """Get the last pytest result."""
205
+ return self._last_result
206
+
207
+ def get_stats(self) -> Dict[str, Any]:
208
+ """Get action statistics."""
209
+ stats = super().get_stats()
210
+ stats.update({
211
+ "test_path": self.test_path,
212
+ "markers": self.markers,
213
+ "last_result": {
214
+ "passed": self._last_result.passed if self._last_result else None,
215
+ "failed": self._last_result.failed_count if self._last_result else None,
216
+ } if self._last_result else None,
217
+ })
218
+ return stats
monoco/core/git.py CHANGED
@@ -149,6 +149,29 @@ def delete_branch(path: Path, branch_name: str, force: bool = False):
149
149
  raise RuntimeError(f"Failed to delete branch {branch_name}: {stderr}")
150
150
 
151
151
 
152
+ def get_merge_base(path: Path, ref1: str, ref2: str) -> str:
153
+ code, stdout, stderr = _run_git(["merge-base", ref1, ref2], path)
154
+ if code != 0:
155
+ raise RuntimeError(f"Failed to find merge base: {stderr}")
156
+ return stdout.strip()
157
+
158
+
159
+ def git_checkout_files(path: Path, ref: str, files: List[str]):
160
+ if not files:
161
+ return
162
+ code, _, stderr = _run_git(["checkout", ref, "--"] + files, path)
163
+ if code != 0:
164
+ raise RuntimeError(f"Failed to checkout files from {ref}: {stderr}")
165
+
166
+
167
+ def has_diff(path: Path, ref1: str, ref2: str, files: List[str]) -> bool:
168
+ """Check if there are differences between two refs for specific files."""
169
+ if not files:
170
+ return False
171
+ code, stdout, _ = _run_git(["diff", "--name-only", ref1, ref2, "--"] + files, path)
172
+ return code == 0 and bool(stdout.strip())
173
+
174
+
152
175
  def get_worktrees(path: Path) -> List[Tuple[str, str, str]]:
153
176
  """Returns list of (path, head, branch)"""
154
177
  code, stdout, stderr = _run_git(["worktree", "list", "--porcelain"], path)
@@ -207,6 +230,21 @@ def worktree_remove(path: Path, worktree_path: Path, force: bool = False):
207
230
  raise RuntimeError(f"Failed to remove worktree: {stderr}")
208
231
 
209
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
+
210
248
  class GitMonitor:
211
249
  """
212
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
  )