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,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