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
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""
|
|
2
|
+
IssueWatcher - Monitors Issue files for changes.
|
|
3
|
+
|
|
4
|
+
Part of Layer 1 (File Watcher) in the event automation framework.
|
|
5
|
+
Emits events for Issue creation, modification, and deletion,
|
|
6
|
+
as well as field-level changes in YAML Front Matter.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Dict, List, Optional, Set
|
|
15
|
+
|
|
16
|
+
from monoco.core.scheduler import AgentEventType, EventBus, event_bus
|
|
17
|
+
from monoco.features.issue.domain.parser import MarkdownParser
|
|
18
|
+
from monoco.features.issue.domain.models import Issue
|
|
19
|
+
|
|
20
|
+
from .base import (
|
|
21
|
+
ChangeType,
|
|
22
|
+
FieldChange,
|
|
23
|
+
FileEvent,
|
|
24
|
+
FilesystemWatcher,
|
|
25
|
+
WatchConfig,
|
|
26
|
+
PollingWatcher,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class IssueFileEvent(FileEvent):
|
|
33
|
+
"""FileEvent specific to Issue files."""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
path: Path,
|
|
38
|
+
change_type: ChangeType,
|
|
39
|
+
issue_id: Optional[str] = None,
|
|
40
|
+
field_changes: Optional[List[FieldChange]] = None,
|
|
41
|
+
**kwargs,
|
|
42
|
+
):
|
|
43
|
+
super().__init__(
|
|
44
|
+
path=path,
|
|
45
|
+
change_type=change_type,
|
|
46
|
+
watcher_name="IssueWatcher",
|
|
47
|
+
**kwargs,
|
|
48
|
+
)
|
|
49
|
+
self.issue_id = issue_id
|
|
50
|
+
self.field_changes = field_changes or []
|
|
51
|
+
|
|
52
|
+
def to_agent_event_type(self) -> Optional[AgentEventType]:
|
|
53
|
+
"""Convert to appropriate AgentEventType."""
|
|
54
|
+
if self.change_type == ChangeType.CREATED:
|
|
55
|
+
return AgentEventType.ISSUE_CREATED
|
|
56
|
+
elif self.change_type == ChangeType.MODIFIED:
|
|
57
|
+
# Check for specific field changes
|
|
58
|
+
for fc in self.field_changes:
|
|
59
|
+
if fc.field_name == "stage":
|
|
60
|
+
return AgentEventType.ISSUE_STAGE_CHANGED
|
|
61
|
+
elif fc.field_name == "status":
|
|
62
|
+
return AgentEventType.ISSUE_STATUS_CHANGED
|
|
63
|
+
return AgentEventType.ISSUE_UPDATED
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
def to_payload(self) -> Dict[str, Any]:
|
|
67
|
+
"""Convert to payload with Issue-specific fields."""
|
|
68
|
+
payload = super().to_payload()
|
|
69
|
+
payload["issue_id"] = self.issue_id
|
|
70
|
+
payload["field_changes"] = [
|
|
71
|
+
{
|
|
72
|
+
"field": fc.field_name,
|
|
73
|
+
"old_value": fc.old_value,
|
|
74
|
+
"new_value": fc.new_value,
|
|
75
|
+
}
|
|
76
|
+
for fc in self.field_changes
|
|
77
|
+
]
|
|
78
|
+
return payload
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class IssueWatcher(PollingWatcher):
|
|
82
|
+
"""
|
|
83
|
+
Watcher for Issue files.
|
|
84
|
+
|
|
85
|
+
Monitors the Issues/ directory for:
|
|
86
|
+
- New Issue file creation
|
|
87
|
+
- Issue file modifications
|
|
88
|
+
- Issue file deletion
|
|
89
|
+
- YAML Front Matter field changes (status, stage, assignee, etc.)
|
|
90
|
+
|
|
91
|
+
Example:
|
|
92
|
+
>>> config = WatchConfig(
|
|
93
|
+
... path=Path("./Issues"),
|
|
94
|
+
... patterns=["*.md"],
|
|
95
|
+
... recursive=True,
|
|
96
|
+
... )
|
|
97
|
+
>>> watcher = IssueWatcher(config)
|
|
98
|
+
>>> await watcher.start()
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
# Fields to track for changes
|
|
102
|
+
TRACKED_FIELDS = ["status", "stage", "assignee", "criticality", "title"]
|
|
103
|
+
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
config: WatchConfig,
|
|
107
|
+
event_bus: Optional[EventBus] = None,
|
|
108
|
+
name: str = "IssueWatcher",
|
|
109
|
+
tracked_fields: Optional[List[str]] = None,
|
|
110
|
+
):
|
|
111
|
+
# Ensure we watch for markdown files in Issues directory
|
|
112
|
+
if not config.patterns:
|
|
113
|
+
config.patterns = ["*.md"]
|
|
114
|
+
|
|
115
|
+
super().__init__(config, event_bus, name)
|
|
116
|
+
self.tracked_fields = tracked_fields or self.TRACKED_FIELDS
|
|
117
|
+
self._issue_cache: Dict[str, Dict[str, Any]] = {} # issue_id -> field values
|
|
118
|
+
|
|
119
|
+
async def _check_changes(self) -> None:
|
|
120
|
+
"""Check for Issue file changes."""
|
|
121
|
+
current_states = self._scan_files()
|
|
122
|
+
current_paths = set(current_states.keys())
|
|
123
|
+
cached_paths = set(self._file_states.keys())
|
|
124
|
+
|
|
125
|
+
# Detect new files
|
|
126
|
+
for path in current_paths - cached_paths:
|
|
127
|
+
await self._handle_new_file(path, current_states[path])
|
|
128
|
+
|
|
129
|
+
# Detect deleted files
|
|
130
|
+
for path in cached_paths - current_paths:
|
|
131
|
+
await self._handle_deleted_file(path)
|
|
132
|
+
|
|
133
|
+
# Detect modified files
|
|
134
|
+
for path in current_paths & cached_paths:
|
|
135
|
+
old_state = self._file_states[path]
|
|
136
|
+
new_state = current_states[path]
|
|
137
|
+
|
|
138
|
+
if old_state.get("hash") != new_state.get("hash"):
|
|
139
|
+
await self._handle_modified_file(path, old_state, new_state)
|
|
140
|
+
|
|
141
|
+
# Update cache
|
|
142
|
+
self._file_states = current_states
|
|
143
|
+
|
|
144
|
+
async def _handle_new_file(self, path: Path, state: Dict[str, Any]) -> None:
|
|
145
|
+
"""Handle new Issue file creation."""
|
|
146
|
+
content = state.get("content", "")
|
|
147
|
+
issue = self._parse_issue(content, path)
|
|
148
|
+
|
|
149
|
+
if issue:
|
|
150
|
+
# Cache the issue fields
|
|
151
|
+
self._issue_cache[issue.frontmatter.id] = self._extract_tracked_fields(issue)
|
|
152
|
+
|
|
153
|
+
event = IssueFileEvent(
|
|
154
|
+
path=path,
|
|
155
|
+
change_type=ChangeType.CREATED,
|
|
156
|
+
issue_id=issue.frontmatter.id,
|
|
157
|
+
new_content=content,
|
|
158
|
+
metadata={
|
|
159
|
+
"title": issue.frontmatter.title,
|
|
160
|
+
"status": issue.frontmatter.status,
|
|
161
|
+
"stage": issue.frontmatter.stage,
|
|
162
|
+
},
|
|
163
|
+
)
|
|
164
|
+
await self.emit(event)
|
|
165
|
+
logger.debug(f"Issue created: {issue.frontmatter.id}")
|
|
166
|
+
|
|
167
|
+
async def _handle_deleted_file(self, path: Path) -> None:
|
|
168
|
+
"""Handle Issue file deletion."""
|
|
169
|
+
# Find issue_id from path (would need to track this)
|
|
170
|
+
event = IssueFileEvent(
|
|
171
|
+
path=path,
|
|
172
|
+
change_type=ChangeType.DELETED,
|
|
173
|
+
metadata={"path": str(path)},
|
|
174
|
+
)
|
|
175
|
+
await self.emit(event)
|
|
176
|
+
logger.debug(f"Issue deleted: {path}")
|
|
177
|
+
|
|
178
|
+
async def _handle_modified_file(
|
|
179
|
+
self,
|
|
180
|
+
path: Path,
|
|
181
|
+
old_state: Dict[str, Any],
|
|
182
|
+
new_state: Dict[str, Any],
|
|
183
|
+
) -> None:
|
|
184
|
+
"""Handle Issue file modification."""
|
|
185
|
+
old_content = old_state.get("content", "")
|
|
186
|
+
new_content = new_state.get("content", "")
|
|
187
|
+
|
|
188
|
+
issue = self._parse_issue(new_content, path)
|
|
189
|
+
if not issue:
|
|
190
|
+
return
|
|
191
|
+
|
|
192
|
+
issue_id = issue.frontmatter.id
|
|
193
|
+
|
|
194
|
+
# Detect field changes
|
|
195
|
+
field_changes = self._detect_field_changes(issue_id, issue)
|
|
196
|
+
|
|
197
|
+
# Update cache
|
|
198
|
+
self._issue_cache[issue_id] = self._extract_tracked_fields(issue)
|
|
199
|
+
|
|
200
|
+
# Emit event with field changes
|
|
201
|
+
event = IssueFileEvent(
|
|
202
|
+
path=path,
|
|
203
|
+
change_type=ChangeType.MODIFIED,
|
|
204
|
+
issue_id=issue_id,
|
|
205
|
+
old_content=old_content,
|
|
206
|
+
new_content=new_content,
|
|
207
|
+
field_changes=field_changes,
|
|
208
|
+
metadata={
|
|
209
|
+
"title": issue.frontmatter.title,
|
|
210
|
+
"status": issue.frontmatter.status,
|
|
211
|
+
"stage": issue.frontmatter.stage,
|
|
212
|
+
},
|
|
213
|
+
)
|
|
214
|
+
await self.emit(event)
|
|
215
|
+
|
|
216
|
+
# Also emit individual field change events
|
|
217
|
+
for fc in field_changes:
|
|
218
|
+
await self._emit_field_change_event(issue, fc)
|
|
219
|
+
|
|
220
|
+
if field_changes:
|
|
221
|
+
logger.debug(f"Issue {issue_id} modified: {[fc.field_name for fc in field_changes]}")
|
|
222
|
+
|
|
223
|
+
async def _emit_field_change_event(
|
|
224
|
+
self,
|
|
225
|
+
issue: Issue,
|
|
226
|
+
field_change: FieldChange,
|
|
227
|
+
) -> None:
|
|
228
|
+
"""Emit a specific field change event."""
|
|
229
|
+
event_type = None
|
|
230
|
+
|
|
231
|
+
if field_change.field_name == "stage":
|
|
232
|
+
event_type = AgentEventType.ISSUE_STAGE_CHANGED
|
|
233
|
+
elif field_change.field_name == "status":
|
|
234
|
+
event_type = AgentEventType.ISSUE_STATUS_CHANGED
|
|
235
|
+
|
|
236
|
+
if event_type and self.event_bus:
|
|
237
|
+
await self.event_bus.publish(
|
|
238
|
+
event_type,
|
|
239
|
+
{
|
|
240
|
+
"issue_id": issue.frontmatter.id,
|
|
241
|
+
"issue_title": issue.frontmatter.title,
|
|
242
|
+
"old_value": field_change.old_value,
|
|
243
|
+
"new_value": field_change.new_value,
|
|
244
|
+
"field": field_change.field_name,
|
|
245
|
+
"path": str(issue.path) if issue.path else None,
|
|
246
|
+
},
|
|
247
|
+
source=f"watcher.{self.name}",
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
def _parse_issue(self, content: str, path: Path) -> Optional[Issue]:
|
|
251
|
+
"""Parse Issue from content."""
|
|
252
|
+
try:
|
|
253
|
+
return MarkdownParser.parse(content, str(path))
|
|
254
|
+
except Exception as e:
|
|
255
|
+
logger.debug(f"Could not parse issue from {path}: {e}")
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
def _extract_tracked_fields(self, issue: Issue) -> Dict[str, Any]:
|
|
259
|
+
"""Extract tracked fields from an Issue."""
|
|
260
|
+
fm = issue.frontmatter
|
|
261
|
+
fields = {}
|
|
262
|
+
|
|
263
|
+
for field_name in self.tracked_fields:
|
|
264
|
+
if hasattr(fm, field_name):
|
|
265
|
+
fields[field_name] = getattr(fm, field_name)
|
|
266
|
+
|
|
267
|
+
return fields
|
|
268
|
+
|
|
269
|
+
def _detect_field_changes(
|
|
270
|
+
self,
|
|
271
|
+
issue_id: str,
|
|
272
|
+
issue: Issue,
|
|
273
|
+
) -> List[FieldChange]:
|
|
274
|
+
"""Detect changes in tracked fields."""
|
|
275
|
+
changes = []
|
|
276
|
+
old_fields = self._issue_cache.get(issue_id, {})
|
|
277
|
+
new_fields = self._extract_tracked_fields(issue)
|
|
278
|
+
|
|
279
|
+
for field_name in self.tracked_fields:
|
|
280
|
+
old_value = old_fields.get(field_name)
|
|
281
|
+
new_value = new_fields.get(field_name)
|
|
282
|
+
|
|
283
|
+
if old_value != new_value and old_value is not None:
|
|
284
|
+
changes.append(FieldChange(
|
|
285
|
+
field_name=field_name,
|
|
286
|
+
old_value=old_value,
|
|
287
|
+
new_value=new_value,
|
|
288
|
+
))
|
|
289
|
+
|
|
290
|
+
return changes
|
|
291
|
+
|
|
292
|
+
def get_issue_state(self, issue_id: str) -> Optional[Dict[str, Any]]:
|
|
293
|
+
"""Get cached state for an issue."""
|
|
294
|
+
return self._issue_cache.get(issue_id)
|
|
295
|
+
|
|
296
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
297
|
+
"""Get watcher statistics."""
|
|
298
|
+
stats = super().get_stats()
|
|
299
|
+
stats.update({
|
|
300
|
+
"tracked_issues": len(self._issue_cache),
|
|
301
|
+
"tracked_fields": self.tracked_fields,
|
|
302
|
+
})
|
|
303
|
+
return stats
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MemoWatcher - Monitors Memo inbox for changes.
|
|
3
|
+
|
|
4
|
+
Part of Layer 1 (File Watcher) in the event automation framework.
|
|
5
|
+
Emits events when memo count crosses thresholds.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
from monoco.core.scheduler import AgentEventType, EventBus, event_bus
|
|
17
|
+
|
|
18
|
+
from .base import (
|
|
19
|
+
ChangeType,
|
|
20
|
+
FileEvent,
|
|
21
|
+
FilesystemWatcher,
|
|
22
|
+
WatchConfig,
|
|
23
|
+
PollingWatcher,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class MemoFileEvent(FileEvent):
|
|
30
|
+
"""FileEvent specific to Memo files."""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
path: Path,
|
|
35
|
+
change_type: ChangeType,
|
|
36
|
+
pending_count: int = 0,
|
|
37
|
+
threshold: int = 5,
|
|
38
|
+
**kwargs,
|
|
39
|
+
):
|
|
40
|
+
super().__init__(
|
|
41
|
+
path=path,
|
|
42
|
+
change_type=change_type,
|
|
43
|
+
watcher_name="MemoWatcher",
|
|
44
|
+
**kwargs,
|
|
45
|
+
)
|
|
46
|
+
self.pending_count = pending_count
|
|
47
|
+
self.threshold = threshold
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def threshold_crossed(self) -> bool:
|
|
51
|
+
"""Check if threshold has been crossed."""
|
|
52
|
+
return self.pending_count >= self.threshold
|
|
53
|
+
|
|
54
|
+
def to_agent_event_type(self) -> Optional[AgentEventType]:
|
|
55
|
+
"""Convert to appropriate AgentEventType."""
|
|
56
|
+
if self.pending_count >= self.threshold:
|
|
57
|
+
return AgentEventType.MEMO_THRESHOLD
|
|
58
|
+
return AgentEventType.MEMO_CREATED
|
|
59
|
+
|
|
60
|
+
def to_payload(self) -> Dict[str, Any]:
|
|
61
|
+
"""Convert to payload with Memo-specific fields."""
|
|
62
|
+
payload = super().to_payload()
|
|
63
|
+
payload["pending_count"] = self.pending_count
|
|
64
|
+
payload["threshold"] = self.threshold
|
|
65
|
+
payload["threshold_crossed"] = self.pending_count >= self.threshold
|
|
66
|
+
return payload
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class MemoWatcher(PollingWatcher):
|
|
70
|
+
"""
|
|
71
|
+
Watcher for Memo inbox file (Signal Queue Model).
|
|
72
|
+
|
|
73
|
+
Monitors the Memos/inbox.md file for:
|
|
74
|
+
- New memo signals (file non-empty)
|
|
75
|
+
- Threshold crossing events (memo count >= threshold)
|
|
76
|
+
|
|
77
|
+
Signal Queue Semantics (FEAT-0165):
|
|
78
|
+
- File existence = signal pending
|
|
79
|
+
- File empty = no signals
|
|
80
|
+
- Consumer clears file = signals consumed
|
|
81
|
+
|
|
82
|
+
Example:
|
|
83
|
+
>>> config = WatchConfig(
|
|
84
|
+
... path=Path("./Memos/inbox.md"),
|
|
85
|
+
... patterns=["*.md"],
|
|
86
|
+
... )
|
|
87
|
+
>>> watcher = MemoWatcher(config, threshold=5)
|
|
88
|
+
>>> await watcher.start()
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
# Regex to match memo headers (## [uid])
|
|
92
|
+
MEMO_HEADER_PATTERN = re.compile(r"^##\s*\[[a-f0-9]+\]", re.MULTILINE)
|
|
93
|
+
|
|
94
|
+
def __init__(
|
|
95
|
+
self,
|
|
96
|
+
config: WatchConfig,
|
|
97
|
+
event_bus: Optional[EventBus] = None,
|
|
98
|
+
name: str = "MemoWatcher",
|
|
99
|
+
threshold: int = 5,
|
|
100
|
+
):
|
|
101
|
+
super().__init__(config, event_bus, name)
|
|
102
|
+
self.threshold = threshold
|
|
103
|
+
self._last_memo_count = 0
|
|
104
|
+
self._threshold_crossed = False
|
|
105
|
+
|
|
106
|
+
async def _check_changes(self) -> None:
|
|
107
|
+
"""Check for memo changes."""
|
|
108
|
+
if not self.config.path.exists():
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
content = self._read_file_content(self.config.path) or ""
|
|
113
|
+
memo_count = self._count_memos(content)
|
|
114
|
+
|
|
115
|
+
# Check if count changed
|
|
116
|
+
if memo_count != self._last_memo_count:
|
|
117
|
+
await self._handle_count_change(memo_count)
|
|
118
|
+
self._last_memo_count = memo_count
|
|
119
|
+
|
|
120
|
+
except Exception as e:
|
|
121
|
+
logger.error(f"Error checking memo file: {e}")
|
|
122
|
+
|
|
123
|
+
async def _handle_count_change(self, memo_count: int) -> None:
|
|
124
|
+
"""Handle memo count change."""
|
|
125
|
+
# Check for threshold crossing
|
|
126
|
+
threshold_crossed = memo_count >= self.threshold
|
|
127
|
+
|
|
128
|
+
if threshold_crossed and not self._threshold_crossed:
|
|
129
|
+
# Threshold just crossed
|
|
130
|
+
event = MemoFileEvent(
|
|
131
|
+
path=self.config.path,
|
|
132
|
+
change_type=ChangeType.MODIFIED,
|
|
133
|
+
pending_count=memo_count,
|
|
134
|
+
threshold=self.threshold,
|
|
135
|
+
metadata={
|
|
136
|
+
"previous_count": self._last_memo_count,
|
|
137
|
+
"event_type": "threshold_crossed",
|
|
138
|
+
},
|
|
139
|
+
)
|
|
140
|
+
await self.emit(event)
|
|
141
|
+
logger.info(f"Memo threshold crossed: {memo_count} >= {self.threshold}")
|
|
142
|
+
|
|
143
|
+
elif memo_count > self._last_memo_count:
|
|
144
|
+
# New memos added
|
|
145
|
+
event = MemoFileEvent(
|
|
146
|
+
path=self.config.path,
|
|
147
|
+
change_type=ChangeType.MODIFIED,
|
|
148
|
+
pending_count=memo_count,
|
|
149
|
+
threshold=self.threshold,
|
|
150
|
+
metadata={
|
|
151
|
+
"previous_count": self._last_memo_count,
|
|
152
|
+
"event_type": "memos_added",
|
|
153
|
+
},
|
|
154
|
+
)
|
|
155
|
+
await self.emit(event)
|
|
156
|
+
logger.debug(f"New memos added: {memo_count} total")
|
|
157
|
+
|
|
158
|
+
elif memo_count == 0 and self._last_memo_count > 0:
|
|
159
|
+
# Inbox was cleared (consumed)
|
|
160
|
+
logger.info(f"Inbox cleared (consumed {self._last_memo_count} memos)")
|
|
161
|
+
|
|
162
|
+
self._threshold_crossed = threshold_crossed
|
|
163
|
+
|
|
164
|
+
def _count_memos(self, content: str) -> int:
|
|
165
|
+
"""
|
|
166
|
+
Count memos in content by matching memo headers.
|
|
167
|
+
|
|
168
|
+
In Signal Queue Model:
|
|
169
|
+
- Each memo has a header like: ## [uid] YYYY-MM-DD HH:MM:SS
|
|
170
|
+
- We count headers to determine number of pending signals
|
|
171
|
+
"""
|
|
172
|
+
if not content or not content.strip():
|
|
173
|
+
return 0
|
|
174
|
+
|
|
175
|
+
# Count memo headers
|
|
176
|
+
matches = self.MEMO_HEADER_PATTERN.findall(content)
|
|
177
|
+
return len(matches)
|
|
178
|
+
|
|
179
|
+
def set_threshold(self, threshold: int) -> None:
|
|
180
|
+
"""Update the threshold value."""
|
|
181
|
+
self.threshold = threshold
|
|
182
|
+
self._threshold_crossed = self._last_memo_count >= threshold
|
|
183
|
+
|
|
184
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
185
|
+
"""Get watcher statistics."""
|
|
186
|
+
stats = super().get_stats()
|
|
187
|
+
stats.update({
|
|
188
|
+
"memo_count": self._last_memo_count,
|
|
189
|
+
"threshold": self.threshold,
|
|
190
|
+
"threshold_crossed": self._threshold_crossed,
|
|
191
|
+
})
|
|
192
|
+
return stats
|