monoco-toolkit 0.3.3__py3-none-any.whl → 0.3.5__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.
@@ -8,6 +8,7 @@ from monoco.features.i18n.core import detect_language
8
8
  from .models import IssueMetadata
9
9
  from .domain.parser import MarkdownParser
10
10
  from .domain.models import ContentBlock
11
+ from .resolver import ReferenceResolver, ResolutionContext
11
12
 
12
13
 
13
14
  class IssueValidator:
@@ -20,9 +21,19 @@ class IssueValidator:
20
21
  self.issue_root = issue_root
21
22
 
22
23
  def validate(
23
- self, meta: IssueMetadata, content: str, all_issue_ids: Set[str] = set()
24
+ self,
25
+ meta: IssueMetadata,
26
+ content: str,
27
+ all_issue_ids: Set[str] = set(),
28
+ current_project: Optional[str] = None,
29
+ workspace_root: Optional[str] = None,
24
30
  ) -> List[Diagnostic]:
31
+ """
32
+ Validate an issue and return diagnostics.
33
+ """
25
34
  diagnostics = []
35
+ self._current_project = current_project
36
+ self._workspace_root = workspace_root
26
37
 
27
38
  # Parse Content into Blocks (Domain Layer)
28
39
  # Handle case where content might be just body (from update_issue) or full file
@@ -311,7 +322,11 @@ class IssueValidator:
311
322
  if stripped == expected_header:
312
323
  header_found = True
313
324
 
314
- if stripped == "## Review Comments":
325
+ # Flexible matching for Review Comments header
326
+ if any(
327
+ kw in stripped
328
+ for kw in ["Review Comments", "评审备注", "评审记录", "Review"]
329
+ ):
315
330
  review_header_found = True
316
331
  review_header_index = i
317
332
 
@@ -406,6 +421,16 @@ class IssueValidator:
406
421
  ) -> List[Diagnostic]:
407
422
  diagnostics = []
408
423
 
424
+ # Initialize Resolver
425
+ resolver = None
426
+ if all_ids:
427
+ context = ResolutionContext(
428
+ current_project=self._current_project or "local",
429
+ workspace_root=self._workspace_root,
430
+ available_ids=all_ids,
431
+ )
432
+ resolver = ReferenceResolver(context)
433
+
409
434
  # Malformed ID Check
410
435
  if meta.parent and meta.parent.startswith("#"):
411
436
  line = self._get_field_line(content, "parent")
@@ -441,7 +466,7 @@ class IssueValidator:
441
466
  )
442
467
  )
443
468
 
444
- if not all_ids:
469
+ if not all_ids or not resolver:
445
470
  return diagnostics
446
471
 
447
472
  # Logic: Epics must have a parent (unless it is the Sink Root EPIC-0000)
@@ -457,8 +482,9 @@ class IssueValidator:
457
482
 
458
483
  if (
459
484
  meta.parent
460
- and meta.parent not in all_ids
485
+ and meta.parent != "EPIC-0000"
461
486
  and not meta.parent.startswith("#")
487
+ and not resolver.is_valid_reference(meta.parent)
462
488
  ):
463
489
  line = self._get_field_line(content, "parent")
464
490
  diagnostics.append(
@@ -470,7 +496,7 @@ class IssueValidator:
470
496
  )
471
497
 
472
498
  for dep in meta.dependencies:
473
- if dep not in all_ids:
499
+ if not resolver.is_valid_reference(dep):
474
500
  line = self._get_field_line(content, "dependencies")
475
501
  diagnostics.append(
476
502
  self._create_diagnostic(
@@ -503,24 +529,27 @@ class IssueValidator:
503
529
  matches = re.finditer(r"\b((?:EPIC|FEAT|CHORE|FIX)-\d{4})\b", line)
504
530
  for match in matches:
505
531
  ref_id = match.group(1)
506
- if ref_id != meta.id and ref_id not in all_ids:
507
- # Check if it's a namespaced ID? The regex only catches local IDs.
508
- # If users use MON::FEAT-0001, the regex might catch FEAT-0001.
509
- # But all_ids contains full IDs (potentially namespaced).
510
- # Simple logic: if ref_id isn't in all_ids, check if any id ENDS with ref_id
511
-
512
- found_namespaced = any(
513
- known.endswith(f"::{ref_id}") for known in all_ids
514
- )
532
+ # Check for namespaced ID before this match?
533
+ # The regex above only catches the ID part.
534
+ # Let's adjust regex to optionally catch namespace::
535
+ full_match = re.search(
536
+ r"\b(?:([a-z0-9_-]+)::)?(" + re.escape(ref_id) + r")\b",
537
+ line[max(0, match.start() - 50) : match.end()],
538
+ )
515
539
 
516
- if not found_namespaced:
517
- diagnostics.append(
518
- self._create_diagnostic(
519
- f"Broken Reference: Issue '{ref_id}' not found.",
520
- DiagnosticSeverity.Warning,
521
- line=i,
522
- )
540
+ check_id = ref_id
541
+ if full_match and full_match.group(1):
542
+ check_id = f"{full_match.group(1)}::{ref_id}"
543
+
544
+ if ref_id != meta.id and not resolver.is_valid_reference(check_id):
545
+ diagnostics.append(
546
+ self._create_diagnostic(
547
+ f"Broken Reference: Issue '{check_id}' not found.",
548
+ DiagnosticSeverity.Warning,
549
+ line=i,
523
550
  )
551
+ )
552
+ return diagnostics
524
553
  return diagnostics
525
554
 
526
555
  def _validate_time_consistency(
@@ -7,6 +7,90 @@ from monoco.core.config import get_config
7
7
  from monoco.features.scheduler import SessionManager, load_scheduler_config
8
8
 
9
9
  app = typer.Typer(name="agent", help="Manage agent sessions")
10
+ role_app = typer.Typer(name="role", help="Manage agent roles")
11
+
12
+
13
+ @role_app.command(name="list")
14
+ def list_roles():
15
+ """
16
+ List available agent roles and their sources.
17
+ """
18
+ from monoco.features.scheduler.config import RoleLoader
19
+
20
+ settings = get_config()
21
+ project_root = Path(settings.paths.root).resolve()
22
+
23
+ loader = RoleLoader(project_root)
24
+ roles = loader.load_all()
25
+
26
+ output = []
27
+ for name, role in roles.items():
28
+ output.append(
29
+ {
30
+ "role": name,
31
+ "engine": role.engine,
32
+ "source": loader.sources.get(name, "unknown"),
33
+ "description": role.description,
34
+ }
35
+ )
36
+
37
+ print_output(output, title="Agent Roles")
38
+
39
+
40
+ @app.command()
41
+ def draft(
42
+ desc: str = typer.Option(..., "--desc", "-d", help="Description of the task"),
43
+ type: str = typer.Option(
44
+ "feature", "--type", "-t", help="Issue type (feature/chore/fix)"
45
+ ),
46
+ ):
47
+ """
48
+ Draft a new issue based on a natural language description.
49
+ This creates a temporary 'drafter' agent session.
50
+ """
51
+ from monoco.core.output import print_error
52
+
53
+ settings = get_config()
54
+ project_root = Path(settings.paths.root).resolve()
55
+
56
+ # Load Roles
57
+ roles = load_scheduler_config(project_root)
58
+ # Use 'crafter' as the role for drafting (it handles new tasks)
59
+ role_name = "crafter"
60
+ selected_role = roles.get(role_name)
61
+
62
+ if not selected_role:
63
+ print_error(f"Role '{role_name}' not found.")
64
+ raise typer.Exit(code=1)
65
+
66
+ print_output(
67
+ f"Drafting {type} from description: '{desc}'",
68
+ title="Agent Drafter",
69
+ )
70
+
71
+ manager = SessionManager()
72
+ # We use a placeholder ID as we don't know the ID yet.
73
+ # The agent is expected to create the file, so the ID will be generated then.
74
+ session = manager.create_session("NEW_TASK", selected_role)
75
+
76
+ context = {"description": desc, "type": type}
77
+
78
+ try:
79
+ session.start(context=context)
80
+
81
+ # Monitoring Loop
82
+ while session.refresh_status() == "running":
83
+ time.sleep(1)
84
+
85
+ if session.model.status == "failed":
86
+ print_error("Drafting failed.")
87
+ else:
88
+ print_output("Drafting completed.", title="Agent Drafter")
89
+
90
+ except KeyboardInterrupt:
91
+ print("\nStopping...")
92
+ session.terminate()
93
+ print_output("Drafting cancelled.")
10
94
 
11
95
 
12
96
  @app.command()
@@ -43,6 +127,7 @@ def run(
43
127
  role_name = role or "builder"
44
128
  description = None
45
129
  else:
130
+ # Implicit Draft Mode via run command
46
131
  issue_id = "NEW_TASK"
47
132
  role_name = role or "crafter"
48
133
  description = target
@@ -84,7 +169,7 @@ def run(
84
169
  session.start(context=context)
85
170
 
86
171
  # Monitoring Loop
87
- while session.model.status == "running":
172
+ while session.refresh_status() == "running":
88
173
  time.sleep(1)
89
174
 
90
175
  if session.model.status == "failed":
@@ -141,9 +226,15 @@ def autopsy(
141
226
  if re.match(r"^[a-zA-Z]+-\d+$", target):
142
227
  print_output(f"Session not in memory. Analyzing Issue {target} directly.")
143
228
  # We create a transient session just to trigger the coroner
144
- from .defaults import DEFAULT_ROLES
229
+ settings = get_config()
230
+ project_root = Path(settings.paths.root).resolve()
231
+ roles = load_scheduler_config(project_root)
232
+ builder_role = roles.get("builder")
233
+
234
+ if not builder_role:
235
+ print_output("Builder role not found.", style="red")
236
+ raise typer.Exit(code=1)
145
237
 
146
- builder_role = next(r for r in DEFAULT_ROLES if r.name == "builder")
147
238
  session = manager.create_session(target.upper(), builder_role)
148
239
  session.model.status = "failed"
149
240
  else:
@@ -192,13 +283,3 @@ def logs(session_id: str):
192
283
  # Placeholder
193
284
  print("[12:00:00] Session started")
194
285
  print("[12:00:01] Worker initialized")
195
-
196
-
197
- @app.command()
198
- def kill(session_id: str):
199
- """
200
- Terminate a session.
201
- """
202
- print_output(f"Killing session {session_id}...", title="Kill Session")
203
- # Placeholder
204
- print("Signal sent.")
@@ -1,32 +1,68 @@
1
+ from typing import Dict, Optional
1
2
  import yaml
2
3
  from pathlib import Path
3
- from typing import Dict
4
4
  from .models import RoleTemplate, SchedulerConfig
5
5
  from .defaults import DEFAULT_ROLES
6
6
 
7
7
 
8
- def load_scheduler_config(project_root: Path) -> Dict[str, RoleTemplate]:
8
+ class RoleLoader:
9
9
  """
10
- Load scheduler configuration from .monoco/scheduler.yaml
11
- Merges with default roles.
10
+ Tiered configuration loader for Agent Roles.
11
+ Level 1: Builtin Fallback
12
+ Level 2: Global (~/.monoco/roles.yaml)
13
+ Level 3: Project (./.monoco/roles.yaml)
12
14
  """
13
- roles = {role.name: role for role in DEFAULT_ROLES}
14
15
 
15
- config_path = project_root / ".monoco" / "scheduler.yaml"
16
- if config_path.exists():
16
+ def __init__(self, project_root: Optional[Path] = None):
17
+ self.project_root = project_root
18
+ self.user_home = Path.home()
19
+ self.roles: Dict[str, RoleTemplate] = {}
20
+ self.sources: Dict[str, str] = {} # role_name -> source description
21
+
22
+ def load_all(self) -> Dict[str, RoleTemplate]:
23
+ # Level 1: Defaults
24
+ for role in DEFAULT_ROLES:
25
+ self.roles[role.name] = role
26
+ self.sources[role.name] = "builtin"
27
+
28
+ # Level 2: Global
29
+ global_path = self.user_home / ".monoco" / "roles.yaml"
30
+ self._load_from_path(global_path, "global")
31
+
32
+ # Level 3: Project
33
+ if self.project_root:
34
+ project_path = self.project_root / ".monoco" / "roles.yaml"
35
+ self._load_from_path(project_path, "project")
36
+
37
+ return self.roles
38
+
39
+ def _load_from_path(self, path: Path, source_label: str):
40
+ if not path.exists():
41
+ return
42
+
17
43
  try:
18
- with open(config_path, "r") as f:
44
+ with open(path, "r") as f:
19
45
  data = yaml.safe_load(f) or {}
20
46
 
21
- # Use Pydantic to validate the whole config if possible, or just the roles list
22
- # Depending on file structure. Assuming the file has a 'roles' key.
23
47
  if "roles" in data:
24
- # We can validate using SchedulerConfig
48
+ # Validate using SchedulerConfig
25
49
  config = SchedulerConfig(roles=data["roles"])
26
50
  for role in config.roles:
27
- roles[role.name] = role
51
+ # Level 3 > Level 2 > Level 1 (名字相同的 Role 进行覆盖/Merge)
52
+ # Currently we do total replacement for same-named roles
53
+ self.roles[role.name] = role
54
+ self.sources[role.name] = str(path)
28
55
  except Exception as e:
29
- # For now, just log or print. Ideally use a logger.
30
- print(f"Warning: Failed to load scheduler config: {e}")
56
+ # We don't want to crash the whole tool if a config is malformed,
57
+ # but we should probably warn.
58
+ import sys
59
+
60
+ print(f"Warning: Failed to load roles from {path}: {e}", file=sys.stderr)
61
+
31
62
 
32
- return roles
63
+ def load_scheduler_config(project_root: Path) -> Dict[str, RoleTemplate]:
64
+ """
65
+ Legacy compatibility wrapper for functional access.
66
+ """
67
+ loader = RoleLoader(project_root)
68
+ return loader.load_all()
@@ -0,0 +1,149 @@
1
+ """
2
+ Agent Engine Adapters for Monoco Scheduler.
3
+
4
+ This module provides a unified interface for different AI agent execution engines,
5
+ allowing the Worker to seamlessly switch between Gemini, Claude, and future engines.
6
+ """
7
+
8
+ from abc import ABC, abstractmethod
9
+ from typing import List
10
+
11
+
12
+ class EngineAdapter(ABC):
13
+ """
14
+ Abstract base class for agent engine adapters.
15
+
16
+ Each adapter is responsible for:
17
+ 1. Constructing the correct CLI command for its engine
18
+ 2. Handling engine-specific error scenarios
19
+ 3. Providing metadata about the engine's capabilities
20
+ """
21
+
22
+ @abstractmethod
23
+ def build_command(self, prompt: str) -> List[str]:
24
+ """
25
+ Build the CLI command to execute the agent with the given prompt.
26
+
27
+ Args:
28
+ prompt: The instruction/context to send to the agent
29
+
30
+ Returns:
31
+ List of command arguments (e.g., ["gemini", "-y", "prompt text"])
32
+ """
33
+ pass
34
+
35
+ @property
36
+ @abstractmethod
37
+ def name(self) -> str:
38
+ """Return the canonical name of this engine."""
39
+ pass
40
+
41
+ @property
42
+ def supports_yolo_mode(self) -> bool:
43
+ """Whether this engine supports auto-approval mode."""
44
+ return False
45
+
46
+
47
+ class GeminiAdapter(EngineAdapter):
48
+ """
49
+ Adapter for Google Gemini CLI.
50
+
51
+ Command format: gemini -y <prompt>
52
+ The -y flag enables "YOLO mode" (auto-approval of actions).
53
+ """
54
+
55
+ def build_command(self, prompt: str) -> List[str]:
56
+ return ["gemini", "-y", prompt]
57
+
58
+ @property
59
+ def name(self) -> str:
60
+ return "gemini"
61
+
62
+ @property
63
+ def supports_yolo_mode(self) -> bool:
64
+ return True
65
+
66
+
67
+ class ClaudeAdapter(EngineAdapter):
68
+ """
69
+ Adapter for Anthropic Claude CLI.
70
+
71
+ Command format: claude -p <prompt>
72
+ The -p/--print flag enables non-interactive mode (print response and exit).
73
+ """
74
+
75
+ def build_command(self, prompt: str) -> List[str]:
76
+ return ["claude", "-p", prompt]
77
+
78
+ @property
79
+ def name(self) -> str:
80
+ return "claude"
81
+
82
+ @property
83
+ def supports_yolo_mode(self) -> bool:
84
+ # Claude uses -p for non-interactive mode, similar concept
85
+ return True
86
+
87
+
88
+ class QwenAdapter(EngineAdapter):
89
+ """
90
+ Adapter for Qwen Code CLI.
91
+
92
+ Command format: qwen -y <prompt>
93
+ The -y flag enables "YOLO mode" (auto-approval of actions).
94
+ """
95
+
96
+ def build_command(self, prompt: str) -> List[str]:
97
+ return ["qwen", "-y", prompt]
98
+
99
+ @property
100
+ def name(self) -> str:
101
+ return "qwen"
102
+
103
+ @property
104
+ def supports_yolo_mode(self) -> bool:
105
+ return True
106
+
107
+
108
+ class EngineFactory:
109
+ """
110
+ Factory for creating engine adapter instances.
111
+
112
+ Usage:
113
+ adapter = EngineFactory.create("gemini")
114
+ command = adapter.build_command("Write a test")
115
+ """
116
+
117
+ _adapters = {
118
+ "gemini": GeminiAdapter,
119
+ "claude": ClaudeAdapter,
120
+ "qwen": QwenAdapter,
121
+ }
122
+
123
+ @classmethod
124
+ def create(cls, engine_name: str) -> EngineAdapter:
125
+ """
126
+ Create an adapter instance for the specified engine.
127
+
128
+ Args:
129
+ engine_name: Name of the engine (e.g., "gemini", "claude")
130
+
131
+ Returns:
132
+ An instance of the appropriate EngineAdapter
133
+
134
+ Raises:
135
+ ValueError: If the engine is not supported
136
+ """
137
+ adapter_class = cls._adapters.get(engine_name.lower())
138
+ if not adapter_class:
139
+ supported = ", ".join(cls._adapters.keys())
140
+ raise ValueError(
141
+ f"Unsupported engine: '{engine_name}'. "
142
+ f"Supported engines: {supported}"
143
+ )
144
+ return adapter_class()
145
+
146
+ @classmethod
147
+ def supported_engines(cls) -> List[str]:
148
+ """Return a list of all supported engine names."""
149
+ return list(cls._adapters.keys())
@@ -1,6 +1,8 @@
1
+ from pathlib import Path
2
+ from monoco.core.config import get_config
1
3
  from .manager import SessionManager
2
4
  from .session import RuntimeSession
3
- from .defaults import DEFAULT_ROLES
5
+ from .config import load_scheduler_config
4
6
 
5
7
 
6
8
  class ApoptosisManager:
@@ -11,10 +13,15 @@ class ApoptosisManager:
11
13
 
12
14
  def __init__(self, session_manager: SessionManager):
13
15
  self.session_manager = session_manager
16
+
17
+ # Load roles dynamically based on current project context
18
+ settings = get_config()
19
+ project_root = Path(settings.paths.root).resolve()
20
+ roles = load_scheduler_config(project_root)
21
+
14
22
  # Find coroner role
15
- self.coroner_role = next(
16
- (r for r in DEFAULT_ROLES if r.name == "coroner"), None
17
- )
23
+ self.coroner_role = roles.get("coroner")
24
+
18
25
  if not self.coroner_role:
19
26
  raise ValueError("Coroner role not defined!")
20
27
 
@@ -1,5 +1,6 @@
1
1
  from typing import Optional
2
2
  from .models import RoleTemplate
3
+ from .engines import EngineFactory
3
4
 
4
5
 
5
6
  class Worker:
@@ -37,7 +38,8 @@ class Worker:
37
38
  import sys
38
39
 
39
40
  # Prepare the prompt
40
- if self.role.name == "drafter" and context:
41
+ # We treat 'crafter' as a drafter when context is provided (Draft Mode)
42
+ if (self.role.name == "drafter" or self.role.name == "crafter") and context:
41
43
  issue_type = context.get("type", "feature")
42
44
  description = context.get("description", "No description")
43
45
  prompt = (
@@ -64,10 +66,9 @@ class Worker:
64
66
  print(f"[{self.role.name}] Goal: {self.role.goal}")
65
67
 
66
68
  try:
67
- # Execute CLI agent with YOLO mode
68
- engine_args = (
69
- [engine, "-y", prompt] if engine == "gemini" else [engine, prompt]
70
- )
69
+ # Use factory to get the appropriate engine adapter
70
+ adapter = EngineFactory.create(engine)
71
+ engine_args = adapter.build_command(prompt)
71
72
 
72
73
  self._process = subprocess.Popen(
73
74
  engine_args, stdout=sys.stdout, stderr=sys.stderr, text=True
@@ -77,6 +78,9 @@ class Worker:
77
78
  # DO NOT WAIT HERE.
78
79
  # The scheduler/monitoring loop is responsible for checking status.
79
80
 
81
+ except ValueError as e:
82
+ # Engine not supported by factory
83
+ raise RuntimeError(f"Unsupported engine '{engine}'. {str(e)}")
80
84
  except FileNotFoundError:
81
85
  raise RuntimeError(
82
86
  f"Agent engine '{engine}' not found. Please ensure it is installed and in PATH."
monoco/main.py CHANGED
@@ -169,6 +169,7 @@ app.add_typer(workspace_cmd.app, name="workspace", help="Manage workspace")
169
169
  from monoco.features.scheduler import cli as scheduler_cmd
170
170
 
171
171
  app.add_typer(scheduler_cmd.app, name="agent", help="Manage agent sessions")
172
+ app.add_typer(scheduler_cmd.role_app, name="role", help="Manage agent roles")
172
173
 
173
174
 
174
175
  from monoco.daemon.commands import serve