monoco-toolkit 0.3.11__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.
- monoco/core/automation/__init__.py +51 -0
- monoco/core/automation/config.py +338 -0
- monoco/core/automation/field_watcher.py +296 -0
- monoco/core/automation/handlers.py +723 -0
- monoco/core/config.py +1 -1
- monoco/core/executor/__init__.py +38 -0
- monoco/core/executor/agent_action.py +254 -0
- monoco/core/executor/git_action.py +303 -0
- monoco/core/executor/im_action.py +309 -0
- monoco/core/executor/pytest_action.py +218 -0
- monoco/core/git.py +15 -0
- monoco/core/hooks/context.py +74 -13
- monoco/core/router/__init__.py +55 -0
- monoco/core/router/action.py +341 -0
- monoco/core/router/router.py +392 -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 +171 -0
- monoco/core/scheduler/local.py +377 -0
- monoco/core/watcher/__init__.py +57 -0
- monoco/core/watcher/base.py +365 -0
- monoco/core/watcher/dropzone.py +152 -0
- monoco/core/watcher/issue.py +303 -0
- monoco/core/watcher/memo.py +200 -0
- monoco/core/watcher/task.py +238 -0
- monoco/daemon/events.py +34 -0
- monoco/daemon/scheduler.py +172 -201
- monoco/daemon/services.py +27 -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/worker.py +1 -1
- monoco/features/issue/commands.py +90 -32
- monoco/features/issue/core.py +249 -4
- monoco/features/spike/commands.py +5 -3
- {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.3.12.dist-info}/METADATA +1 -1
- {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.3.12.dist-info}/RECORD +41 -20
- monoco/features/agent/apoptosis.py +0 -44
- monoco/features/agent/manager.py +0 -127
- monoco/features/agent/session.py +0 -169
- {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.3.12.dist-info}/WHEEL +0 -0
- {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.3.12.dist-info}/entry_points.txt +0 -0
- {monoco_toolkit-0.3.11.dist-info → monoco_toolkit-0.3.12.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,200 @@
|
|
|
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.
|
|
72
|
+
|
|
73
|
+
Monitors the Memos/inbox.md file for:
|
|
74
|
+
- New memo entries
|
|
75
|
+
- Pending memo count changes
|
|
76
|
+
- Threshold crossing events
|
|
77
|
+
|
|
78
|
+
Example:
|
|
79
|
+
>>> config = WatchConfig(
|
|
80
|
+
... path=Path("./Memos/inbox.md"),
|
|
81
|
+
... patterns=["*.md"],
|
|
82
|
+
... )
|
|
83
|
+
>>> watcher = MemoWatcher(config, threshold=5)
|
|
84
|
+
>>> await watcher.start()
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
# Regex to match memo entries (lines starting with "- " that aren't checkboxes)
|
|
88
|
+
MEMO_PATTERN = re.compile(r"^-\s+(?!\[)[^\n]*$", re.MULTILINE)
|
|
89
|
+
# Regex to match checkbox items
|
|
90
|
+
CHECKBOX_PATTERN = re.compile(r"^-\s*\[([ xX-])\]", re.MULTILINE)
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
config: WatchConfig,
|
|
95
|
+
event_bus: Optional[EventBus] = None,
|
|
96
|
+
name: str = "MemoWatcher",
|
|
97
|
+
threshold: int = 5,
|
|
98
|
+
):
|
|
99
|
+
super().__init__(config, event_bus, name)
|
|
100
|
+
self.threshold = threshold
|
|
101
|
+
self._last_pending_count = 0
|
|
102
|
+
self._threshold_crossed = False
|
|
103
|
+
|
|
104
|
+
async def _check_changes(self) -> None:
|
|
105
|
+
"""Check for memo changes."""
|
|
106
|
+
if not self.config.path.exists():
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
content = self._read_file_content(self.config.path) or ""
|
|
111
|
+
pending_count = self._count_pending_memos(content)
|
|
112
|
+
|
|
113
|
+
# Check if count changed
|
|
114
|
+
if pending_count != self._last_pending_count:
|
|
115
|
+
await self._handle_count_change(pending_count)
|
|
116
|
+
self._last_pending_count = pending_count
|
|
117
|
+
|
|
118
|
+
except Exception as e:
|
|
119
|
+
logger.error(f"Error checking memo file: {e}")
|
|
120
|
+
|
|
121
|
+
async def _handle_count_change(self, pending_count: int) -> None:
|
|
122
|
+
"""Handle memo count change."""
|
|
123
|
+
# Check for threshold crossing
|
|
124
|
+
threshold_crossed = pending_count >= self.threshold
|
|
125
|
+
|
|
126
|
+
if threshold_crossed and not self._threshold_crossed:
|
|
127
|
+
# Threshold just crossed
|
|
128
|
+
event = MemoFileEvent(
|
|
129
|
+
path=self.config.path,
|
|
130
|
+
change_type=ChangeType.MODIFIED,
|
|
131
|
+
pending_count=pending_count,
|
|
132
|
+
threshold=self.threshold,
|
|
133
|
+
metadata={
|
|
134
|
+
"previous_count": self._last_pending_count,
|
|
135
|
+
"event_type": "threshold_crossed",
|
|
136
|
+
},
|
|
137
|
+
)
|
|
138
|
+
await self.emit(event)
|
|
139
|
+
logger.info(f"Memo threshold crossed: {pending_count} >= {self.threshold}")
|
|
140
|
+
|
|
141
|
+
elif pending_count > self._last_pending_count:
|
|
142
|
+
# New memos added
|
|
143
|
+
event = MemoFileEvent(
|
|
144
|
+
path=self.config.path,
|
|
145
|
+
change_type=ChangeType.MODIFIED,
|
|
146
|
+
pending_count=pending_count,
|
|
147
|
+
threshold=self.threshold,
|
|
148
|
+
metadata={
|
|
149
|
+
"previous_count": self._last_pending_count,
|
|
150
|
+
"event_type": "memos_added",
|
|
151
|
+
},
|
|
152
|
+
)
|
|
153
|
+
await self.emit(event)
|
|
154
|
+
logger.debug(f"New memos added: {pending_count} total")
|
|
155
|
+
|
|
156
|
+
self._threshold_crossed = threshold_crossed
|
|
157
|
+
|
|
158
|
+
def _count_pending_memos(self, content: str) -> int:
|
|
159
|
+
"""
|
|
160
|
+
Count pending (unchecked) memos in content.
|
|
161
|
+
|
|
162
|
+
Counts:
|
|
163
|
+
- Lines starting with "- " that are not checkboxes
|
|
164
|
+
- Checkbox items with empty or "-" state
|
|
165
|
+
"""
|
|
166
|
+
count = 0
|
|
167
|
+
lines = content.split("\n")
|
|
168
|
+
|
|
169
|
+
for line in lines:
|
|
170
|
+
line = line.strip()
|
|
171
|
+
if not line.startswith("-"):
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
# Check if it's a checkbox
|
|
175
|
+
checkbox_match = self.CHECKBOX_PATTERN.match(line)
|
|
176
|
+
if checkbox_match:
|
|
177
|
+
state = checkbox_match.group(1).lower()
|
|
178
|
+
# Count if not checked (x or X)
|
|
179
|
+
if state not in ("x", "X"):
|
|
180
|
+
count += 1
|
|
181
|
+
else:
|
|
182
|
+
# Regular memo entry
|
|
183
|
+
count += 1
|
|
184
|
+
|
|
185
|
+
return count
|
|
186
|
+
|
|
187
|
+
def set_threshold(self, threshold: int) -> None:
|
|
188
|
+
"""Update the threshold value."""
|
|
189
|
+
self.threshold = threshold
|
|
190
|
+
self._threshold_crossed = self._last_pending_count >= threshold
|
|
191
|
+
|
|
192
|
+
def get_stats(self) -> Dict[str, Any]:
|
|
193
|
+
"""Get watcher statistics."""
|
|
194
|
+
stats = super().get_stats()
|
|
195
|
+
stats.update({
|
|
196
|
+
"pending_count": self._last_pending_count,
|
|
197
|
+
"threshold": self.threshold,
|
|
198
|
+
"threshold_crossed": self._threshold_crossed,
|
|
199
|
+
})
|
|
200
|
+
return stats
|