monoco-toolkit 0.2.7__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. monoco/cli/project.py +35 -31
  2. monoco/cli/workspace.py +26 -16
  3. monoco/core/agent/__init__.py +0 -2
  4. monoco/core/agent/action.py +44 -20
  5. monoco/core/agent/adapters.py +20 -16
  6. monoco/core/agent/protocol.py +5 -4
  7. monoco/core/agent/state.py +21 -21
  8. monoco/core/config.py +90 -33
  9. monoco/core/execution.py +21 -16
  10. monoco/core/feature.py +8 -5
  11. monoco/core/git.py +61 -30
  12. monoco/core/hooks.py +57 -0
  13. monoco/core/injection.py +47 -44
  14. monoco/core/integrations.py +50 -35
  15. monoco/core/lsp.py +12 -1
  16. monoco/core/output.py +35 -16
  17. monoco/core/registry.py +3 -2
  18. monoco/core/setup.py +190 -124
  19. monoco/core/skills.py +121 -107
  20. monoco/core/state.py +12 -10
  21. monoco/core/sync.py +85 -56
  22. monoco/core/telemetry.py +10 -6
  23. monoco/core/workspace.py +26 -19
  24. monoco/daemon/app.py +123 -79
  25. monoco/daemon/commands.py +14 -13
  26. monoco/daemon/models.py +11 -3
  27. monoco/daemon/reproduce_stats.py +8 -8
  28. monoco/daemon/services.py +32 -33
  29. monoco/daemon/stats.py +59 -40
  30. monoco/features/config/commands.py +38 -25
  31. monoco/features/i18n/adapter.py +4 -5
  32. monoco/features/i18n/commands.py +83 -49
  33. monoco/features/i18n/core.py +94 -54
  34. monoco/features/issue/adapter.py +6 -7
  35. monoco/features/issue/commands.py +500 -260
  36. monoco/features/issue/core.py +504 -293
  37. monoco/features/issue/domain/lifecycle.py +33 -23
  38. monoco/features/issue/domain/models.py +71 -38
  39. monoco/features/issue/domain/parser.py +92 -69
  40. monoco/features/issue/domain/workspace.py +19 -16
  41. monoco/features/issue/engine/__init__.py +3 -3
  42. monoco/features/issue/engine/config.py +18 -25
  43. monoco/features/issue/engine/machine.py +72 -39
  44. monoco/features/issue/engine/models.py +4 -2
  45. monoco/features/issue/linter.py +326 -111
  46. monoco/features/issue/lsp/definition.py +26 -19
  47. monoco/features/issue/migration.py +45 -34
  48. monoco/features/issue/models.py +30 -13
  49. monoco/features/issue/monitor.py +24 -8
  50. monoco/features/issue/resources/en/AGENTS.md +5 -0
  51. monoco/features/issue/resources/en/SKILL.md +30 -2
  52. monoco/features/issue/resources/zh/AGENTS.md +5 -0
  53. monoco/features/issue/resources/zh/SKILL.md +26 -1
  54. monoco/features/issue/validator.py +417 -172
  55. monoco/features/skills/__init__.py +0 -1
  56. monoco/features/skills/core.py +24 -18
  57. monoco/features/spike/adapter.py +4 -5
  58. monoco/features/spike/commands.py +51 -38
  59. monoco/features/spike/core.py +24 -16
  60. monoco/main.py +34 -21
  61. {monoco_toolkit-0.2.7.dist-info → monoco_toolkit-0.3.0.dist-info}/METADATA +10 -3
  62. monoco_toolkit-0.3.0.dist-info/RECORD +84 -0
  63. monoco_toolkit-0.2.7.dist-info/RECORD +0 -83
  64. {monoco_toolkit-0.2.7.dist-info → monoco_toolkit-0.3.0.dist-info}/WHEEL +0 -0
  65. {monoco_toolkit-0.2.7.dist-info → monoco_toolkit-0.3.0.dist-info}/entry_points.txt +0 -0
  66. {monoco_toolkit-0.2.7.dist-info → monoco_toolkit-0.3.0.dist-info}/licenses/LICENSE +0 -0
monoco/daemon/services.py CHANGED
@@ -1,21 +1,18 @@
1
- import asyncio
2
1
  import logging
3
- import subprocess
4
- import os
5
- import re
6
2
  from typing import List, Optional, Dict, Any
7
3
  from asyncio import Queue
8
4
  from pathlib import Path
9
5
 
10
- from monoco.features.issue.core import parse_issue, IssueMetadata
11
6
  import json
12
7
 
13
8
  logger = logging.getLogger("monoco.daemon.services")
14
9
 
10
+
15
11
  class Broadcaster:
16
12
  """
17
13
  Manages SSE subscriptions and broadcasts events to all connected clients.
18
14
  """
15
+
19
16
  def __init__(self):
20
17
  self.subscribers: List[Queue] = []
21
18
 
@@ -33,32 +30,28 @@ class Broadcaster:
33
30
  async def broadcast(self, event_type: str, payload: dict):
34
31
  if not self.subscribers:
35
32
  return
36
-
37
- message = {
38
- "event": event_type,
39
- "data": json.dumps(payload)
40
- }
41
-
33
+
34
+ message = {"event": event_type, "data": json.dumps(payload)}
35
+
42
36
  # Dispatch to all queues
43
37
  for queue in self.subscribers:
44
38
  await queue.put(message)
45
-
39
+
46
40
  logger.debug(f"Broadcasted {event_type} to {len(self.subscribers)} clients.")
47
41
 
48
42
 
49
43
  # Monitors moved to monoco.core.git and monoco.features.issue.monitor
50
44
 
51
- from watchdog.observers import Observer
52
- from watchdog.events import FileSystemEventHandler
53
- from monoco.core.config import MonocoConfig, get_config
54
45
 
55
46
  from monoco.core.workspace import MonocoProject, Workspace
56
47
 
48
+
57
49
  class ProjectContext:
58
50
  """
59
51
  Holds the runtime state for a single project.
60
52
  Now wraps the core MonocoProject primitive.
61
53
  """
54
+
62
55
  def __init__(self, project: MonocoProject, broadcaster: Broadcaster):
63
56
  self.project = project
64
57
  self.id = project.id
@@ -73,11 +66,13 @@ class ProjectContext:
73
66
  def stop(self):
74
67
  self.monitor.stop()
75
68
 
69
+
76
70
  class ProjectManager:
77
71
  """
78
72
  Discovers and manages multiple Monoco projects within a workspace.
79
73
  Uses core Workspace primitive for discovery.
80
74
  """
75
+
81
76
  def __init__(self, workspace_root: Path, broadcaster: Broadcaster):
82
77
  self.workspace_root = workspace_root
83
78
  self.broadcaster = broadcaster
@@ -89,7 +84,7 @@ class ProjectManager:
89
84
  """
90
85
  logger.info(f"Scanning workspace: {self.workspace_root}")
91
86
  workspace = Workspace.discover(self.workspace_root)
92
-
87
+
93
88
  for project in workspace.projects:
94
89
  if project.id not in self.projects:
95
90
  ctx = ProjectContext(project, self.broadcaster)
@@ -114,50 +109,54 @@ class ProjectManager:
114
109
  "id": p.id,
115
110
  "name": p.name,
116
111
  "path": str(p.path),
117
- "issues_path": str(p.issues_root)
112
+ "issues_path": str(p.issues_root),
118
113
  }
119
114
  for p in self.projects.values()
120
115
  ]
121
116
 
117
+
122
118
  from monoco.features.issue.monitor import IssueMonitor
123
119
 
120
+
124
121
  class ProjectContext:
125
122
  """
126
123
  Holds the runtime state for a single project.
127
124
  Now wraps the core MonocoProject primitive.
128
125
  """
126
+
129
127
  def __init__(self, project: MonocoProject, broadcaster: Broadcaster):
130
128
  self.project = project
131
129
  self.id = project.id
132
130
  self.name = project.name
133
131
  self.path = project.path
134
132
  self.issues_root = project.issues_root
135
-
133
+
136
134
  async def on_upsert(issue_data: dict):
137
- await broadcaster.broadcast("issue_upserted", {
138
- "issue": issue_data,
139
- "project_id": self.id
140
- })
135
+ await broadcaster.broadcast(
136
+ "issue_upserted", {"issue": issue_data, "project_id": self.id}
137
+ )
141
138
 
142
139
  async def on_delete(issue_data: dict):
143
140
  # We skip broadcast here if it's part of a move?
144
- # Actually, standard upsert/delete is fine, but we need a specialized event for MOVE
141
+ # Actually, standard upsert/delete is fine, but we need a specialized event for MOVE
145
142
  # to help VS Code redirect without closing/reopening.
146
- await broadcaster.broadcast("issue_deleted", {
147
- "id": issue_data["id"],
148
- "project_id": self.id
149
- })
143
+ await broadcaster.broadcast(
144
+ "issue_deleted", {"id": issue_data["id"], "project_id": self.id}
145
+ )
150
146
 
151
147
  self.monitor = IssueMonitor(self.issues_root, on_upsert, on_delete)
152
148
 
153
149
  async def notify_move(self, old_path: str, new_path: str, issue_data: dict):
154
150
  """Explicitly notify frontend about a logical move (Physical path changed)."""
155
- await self.broadcaster.broadcast("issue_moved", {
156
- "old_path": old_path,
157
- "new_path": new_path,
158
- "issue": issue_data,
159
- "project_id": self.id
160
- })
151
+ await self.broadcaster.broadcast(
152
+ "issue_moved",
153
+ {
154
+ "old_path": old_path,
155
+ "new_path": new_path,
156
+ "issue": issue_data,
157
+ "project_id": self.id,
158
+ },
159
+ )
161
160
 
162
161
  async def start(self):
163
162
  await self.monitor.start()
monoco/daemon/stats.py CHANGED
@@ -1,4 +1,4 @@
1
- from typing import List, Dict, Set, Optional
1
+ from typing import List, Dict, Optional
2
2
  from datetime import datetime, timedelta
3
3
  from pathlib import Path
4
4
  from enum import Enum
@@ -6,11 +6,13 @@ from pydantic import BaseModel
6
6
  from monoco.features.issue.core import list_issues
7
7
  from monoco.features.issue.models import IssueStatus, IssueMetadata
8
8
 
9
+
9
10
  class ActivityType(str, Enum):
10
11
  CREATED = "created"
11
12
  UPDATED = "updated"
12
13
  CLOSED = "closed"
13
14
 
15
+
14
16
  class ActivityItem(BaseModel):
15
17
  id: str
16
18
  type: ActivityType
@@ -19,30 +21,32 @@ class ActivityItem(BaseModel):
19
21
  timestamp: datetime
20
22
  description: Optional[str] = None
21
23
 
24
+
22
25
  class DashboardStats(BaseModel):
23
26
  total_backlog: int
24
27
  completed_this_week: int
25
28
  blocked_issues_count: int
26
29
  velocity_trend: int # Delta compared to last week
27
30
  recent_activities: List[ActivityItem] = []
28
-
31
+
32
+
29
33
  def calculate_dashboard_stats(issues_root: Path) -> DashboardStats:
30
34
  raw_issues = list_issues(issues_root)
31
-
35
+
32
36
  # 1. Pre-process for fast lookup and deduplication
33
37
  issue_map: Dict[str, IssueMetadata] = {i.id: i for i in raw_issues}
34
38
  issues = list(issue_map.values())
35
-
39
+
36
40
  backlog_count = 0
37
41
  completed_this_week = 0
38
42
  completed_last_week = 0
39
43
  blocked_count = 0
40
-
44
+
41
45
  now = datetime.now()
42
46
  one_week_ago = now - timedelta(days=7)
43
47
  two_weeks_ago = now - timedelta(days=14)
44
- activity_window = now - timedelta(days=3) # Show activities from last 3 days
45
-
48
+ activity_window = now - timedelta(days=3) # Show activities from last 3 days
49
+
46
50
  activities: List[ActivityItem] = []
47
51
 
48
52
  for issue in issues:
@@ -50,7 +54,7 @@ def calculate_dashboard_stats(issues_root: Path) -> DashboardStats:
50
54
  # Total Backlog
51
55
  if issue.status == IssueStatus.BACKLOG:
52
56
  backlog_count += 1
53
-
57
+
54
58
  # Completed This Week & Last Week
55
59
  if issue.status == IssueStatus.CLOSED and issue.closed_at:
56
60
  closed_at = issue.closed_at
@@ -58,7 +62,7 @@ def calculate_dashboard_stats(issues_root: Path) -> DashboardStats:
58
62
  completed_this_week += 1
59
63
  elif closed_at >= two_weeks_ago and closed_at < one_week_ago:
60
64
  completed_last_week += 1
61
-
65
+
62
66
  # Blocked Issues
63
67
  if issue.status == IssueStatus.OPEN:
64
68
  is_blocked = False
@@ -73,52 +77,67 @@ def calculate_dashboard_stats(issues_root: Path) -> DashboardStats:
73
77
  # --- Activity Feed Generation ---
74
78
  # 1. Created Event
75
79
  if issue.created_at >= activity_window:
76
- activities.append(ActivityItem(
77
- id=f"act_create_{issue.id}",
78
- type=ActivityType.CREATED,
79
- issue_id=issue.id,
80
- issue_title=issue.title,
81
- timestamp=issue.created_at,
82
- description="Issue created"
83
- ))
80
+ activities.append(
81
+ ActivityItem(
82
+ id=f"act_create_{issue.id}",
83
+ type=ActivityType.CREATED,
84
+ issue_id=issue.id,
85
+ issue_title=issue.title,
86
+ timestamp=issue.created_at,
87
+ description="Issue created",
88
+ )
89
+ )
84
90
 
85
91
  # 2. Closed Event
86
- if issue.status == IssueStatus.CLOSED and issue.closed_at and issue.closed_at >= activity_window:
87
- activities.append(ActivityItem(
88
- id=f"act_close_{issue.id}",
89
- type=ActivityType.CLOSED,
90
- issue_id=issue.id,
91
- issue_title=issue.title,
92
- timestamp=issue.closed_at,
93
- description="Issue completed"
94
- ))
95
-
92
+ if (
93
+ issue.status == IssueStatus.CLOSED
94
+ and issue.closed_at
95
+ and issue.closed_at >= activity_window
96
+ ):
97
+ activities.append(
98
+ ActivityItem(
99
+ id=f"act_close_{issue.id}",
100
+ type=ActivityType.CLOSED,
101
+ issue_id=issue.id,
102
+ issue_title=issue.title,
103
+ timestamp=issue.closed_at,
104
+ description="Issue completed",
105
+ )
106
+ )
107
+
96
108
  # 3. Updated Event (Heuristic: updated recently and not just created/closed)
97
109
  # We skip 'updated' if it's too close to created_at or closed_at to avoid noise
98
110
  if issue.updated_at >= activity_window:
99
- is_creation = abs((issue.updated_at - issue.created_at).total_seconds()) < 60
100
- is_closing = issue.closed_at and abs((issue.updated_at - issue.closed_at).total_seconds()) < 60
101
-
111
+ is_creation = (
112
+ abs((issue.updated_at - issue.created_at).total_seconds()) < 60
113
+ )
114
+ is_closing = (
115
+ issue.closed_at
116
+ and abs((issue.updated_at - issue.closed_at).total_seconds()) < 60
117
+ )
118
+
102
119
  if not is_creation and not is_closing:
103
- activities.append(ActivityItem(
104
- id=f"act_update_{issue.id}_{issue.updated_at.timestamp()}",
105
- type=ActivityType.UPDATED,
106
- issue_id=issue.id,
107
- issue_title=issue.title,
108
- timestamp=issue.updated_at,
109
- description="Issue updated"
110
- ))
120
+ activities.append(
121
+ ActivityItem(
122
+ id=f"act_update_{issue.id}_{issue.updated_at.timestamp()}",
123
+ type=ActivityType.UPDATED,
124
+ issue_id=issue.id,
125
+ issue_title=issue.title,
126
+ timestamp=issue.updated_at,
127
+ description="Issue updated",
128
+ )
129
+ )
111
130
 
112
131
  # Sort activities by timestamp desc and take top 20
113
132
  activities.sort(key=lambda x: x.timestamp, reverse=True)
114
133
  recent_activities = activities[:20]
115
134
 
116
135
  velocity_trend = completed_this_week - completed_last_week
117
-
136
+
118
137
  return DashboardStats(
119
138
  total_backlog=backlog_count,
120
139
  completed_this_week=completed_this_week,
121
140
  blocked_issues_count=blocked_count,
122
141
  velocity_trend=velocity_trend,
123
- recent_activities=recent_activities
142
+ recent_activities=recent_activities,
124
143
  )
@@ -1,8 +1,7 @@
1
1
  import typer
2
2
  import yaml
3
3
  import json
4
- from pathlib import Path
5
- from typing import Optional, Any, Annotated
4
+ from typing import Any
6
5
  from rich.console import Console
7
6
  from rich.syntax import Syntax
8
7
  from pydantic import ValidationError
@@ -13,13 +12,13 @@ from monoco.core.config import (
13
12
  ConfigScope,
14
13
  load_raw_config,
15
14
  save_raw_config,
16
- get_config_path
17
15
  )
18
16
  from monoco.core.output import AgentOutput, OutputManager
19
17
 
20
18
  app = typer.Typer(help="Manage Monoco configuration")
21
19
  console = Console()
22
20
 
21
+
23
22
  def _parse_value(value: str) -> Any:
24
23
  """Parse string value into appropriate type (bool, int, float, str)."""
25
24
  if value.lower() in ("true", "yes", "on"):
@@ -36,16 +35,19 @@ def _parse_value(value: str) -> Any:
36
35
  except ValueError:
37
36
  return value
38
37
 
38
+
39
39
  @app.command()
40
40
  def show(
41
- output: str = typer.Option("yaml", "--output", "-o", help="Output format: yaml or json"),
41
+ output: str = typer.Option(
42
+ "yaml", "--output", "-o", help="Output format: yaml or json"
43
+ ),
42
44
  json_output: AgentOutput = False,
43
45
  ):
44
46
  """Show the currently active (merged) configuration."""
45
47
  config = get_config()
46
48
  # Pydantic v1/v2 compat: use dict() or model_dump()
47
49
  data = config.dict()
48
-
50
+
49
51
  if OutputManager.is_agent_mode():
50
52
  OutputManager.print(data)
51
53
  return
@@ -57,6 +59,7 @@ def show(
57
59
  syntax = Syntax(yaml_str, "yaml")
58
60
  console.print(syntax)
59
61
 
62
+
60
63
  @app.command()
61
64
  def get(
62
65
  key: str = typer.Argument(..., help="Configuration key (e.g. project.name)"),
@@ -65,17 +68,17 @@ def get(
65
68
  """Get a specific configuration value."""
66
69
  config = get_config()
67
70
  data = config.dict()
68
-
71
+
69
72
  parts = key.split(".")
70
73
  current = data
71
-
74
+
72
75
  for part in parts:
73
76
  if isinstance(current, dict) and part in current:
74
77
  current = current[part]
75
78
  else:
76
79
  OutputManager.error(f"Key '{key}' not found.")
77
80
  raise typer.Exit(code=1)
78
-
81
+
79
82
  if OutputManager.is_agent_mode():
80
83
  OutputManager.print({"key": key, "value": current})
81
84
  else:
@@ -87,36 +90,41 @@ def get(
87
90
  else:
88
91
  print(current)
89
92
 
93
+
90
94
  @app.command(name="set")
91
95
  def set_val(
92
96
  key: str = typer.Argument(..., help="Config key (e.g. telemetry.enabled)"),
93
97
  value: str = typer.Argument(..., help="Value to set"),
94
- global_scope: bool = typer.Option(False, "--global", "-g", help="Update global configuration"),
98
+ global_scope: bool = typer.Option(
99
+ False, "--global", "-g", help="Update global configuration"
100
+ ),
95
101
  json_output: AgentOutput = False,
96
102
  ):
97
103
  """Set a configuration value in specific scope (project by default)."""
98
104
  scope = ConfigScope.GLOBAL if global_scope else ConfigScope.PROJECT
99
-
105
+
100
106
  # 1. Load Raw Config for the target scope
101
107
  raw_data = load_raw_config(scope)
102
-
108
+
103
109
  # 2. Parse Key & Update Data
104
110
  parts = key.split(".")
105
111
  target = raw_data
106
-
112
+
107
113
  # Context management for nested updates
108
114
  for i, part in enumerate(parts[:-1]):
109
115
  if part not in target:
110
116
  target[part] = {}
111
117
  target = target[part]
112
118
  if not isinstance(target, dict):
113
- parent_key = ".".join(parts[:i+1])
114
- OutputManager.error(f"Cannot set '{key}': '{parent_key}' is not a dictionary ({type(target)}).")
119
+ parent_key = ".".join(parts[: i + 1])
120
+ OutputManager.error(
121
+ f"Cannot set '{key}': '{parent_key}' is not a dictionary ({type(target)})."
122
+ )
115
123
  raise typer.Exit(code=1)
116
-
124
+
117
125
  parsed_val = _parse_value(value)
118
126
  target[parts[-1]] = parsed_val
119
-
127
+
120
128
  # 3. Validate against Schema
121
129
  # We simulate a full load by creating a temporary MonocoConfig with these overrides.
122
130
  # Note: This validation is "active" - we want to ensure the resulting config WOULD be valid.
@@ -134,18 +142,23 @@ def set_val(
134
142
 
135
143
  # 4. Save
136
144
  save_raw_config(scope, raw_data)
137
-
145
+
138
146
  scope_display = "Global" if global_scope else "Project"
139
-
147
+
140
148
  if OutputManager.is_agent_mode():
141
- OutputManager.print({
142
- "status": "updated",
143
- "scope": scope_display.lower(),
144
- "key": key,
145
- "value": parsed_val
146
- })
149
+ OutputManager.print(
150
+ {
151
+ "status": "updated",
152
+ "scope": scope_display.lower(),
153
+ "key": key,
154
+ "value": parsed_val,
155
+ }
156
+ )
147
157
  else:
148
- console.print(f"[green]✓ Set {key} = {parsed_val} in {scope_display} config.[/green]")
158
+ console.print(
159
+ f"[green]✓ Set {key} = {parsed_val} in {scope_display} config.[/green]"
160
+ )
161
+
149
162
 
150
163
  if __name__ == "__main__":
151
164
  app()
@@ -3,6 +3,7 @@ from typing import Dict
3
3
  from monoco.core.feature import MonocoFeature, IntegrationData
4
4
  from monoco.features.i18n import core
5
5
 
6
+
6
7
  class I18nFeature(MonocoFeature):
7
8
  @property
8
9
  def name(self) -> str:
@@ -15,15 +16,13 @@ class I18nFeature(MonocoFeature):
15
16
  # Determine language from config, default to 'en'
16
17
  lang = config.get("i18n", {}).get("source_lang", "en")
17
18
  base_dir = Path(__file__).parent / "resources"
18
-
19
+
19
20
  prompt_file = base_dir / lang / "AGENTS.md"
20
21
  if not prompt_file.exists():
21
22
  prompt_file = base_dir / "en" / "AGENTS.md"
22
-
23
+
23
24
  content = ""
24
25
  if prompt_file.exists():
25
26
  content = prompt_file.read_text(encoding="utf-8").strip()
26
27
 
27
- return IntegrationData(
28
- system_prompts={"Documentation I18n": content}
29
- )
28
+ return IntegrationData(system_prompts={"Documentation I18n": content})