monoco-toolkit 0.3.2__py3-none-any.whl → 0.3.3__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.
@@ -444,6 +444,17 @@ class IssueValidator:
444
444
  if not all_ids:
445
445
  return diagnostics
446
446
 
447
+ # Logic: Epics must have a parent (unless it is the Sink Root EPIC-0000)
448
+ if meta.type == "epic" and meta.id != "EPIC-0000" and not meta.parent:
449
+ line = self._get_field_line(content, "parent")
450
+ diagnostics.append(
451
+ self._create_diagnostic(
452
+ "Hierarchy Violation: Epics must have a parent (e.g., 'EPIC-0000').",
453
+ DiagnosticSeverity.Error,
454
+ line=line,
455
+ )
456
+ )
457
+
447
458
  if (
448
459
  meta.parent
449
460
  and meta.parent not in all_ids
@@ -575,6 +586,7 @@ class IssueValidator:
575
586
  has_domains_field = False
576
587
  lines = content.splitlines()
577
588
  in_fm = False
589
+ field_line = 0
578
590
  for i, line_content in enumerate(lines):
579
591
  stripped = line_content.strip()
580
592
  if stripped == "---":
@@ -585,6 +597,7 @@ class IssueValidator:
585
597
  elif in_fm:
586
598
  if stripped.startswith("domains:"):
587
599
  has_domains_field = True
600
+ field_line = i
588
601
  break
589
602
 
590
603
  # Governance Maturity Check
@@ -607,6 +620,40 @@ class IssueValidator:
607
620
  )
608
621
  )
609
622
 
623
+ # Domain Content Validation
624
+ from .domain_service import DomainService
625
+
626
+ service = DomainService()
627
+
628
+ if hasattr(meta, "domains") and meta.domains:
629
+ for domain in meta.domains:
630
+ if service.is_alias(domain):
631
+ canonical = service.get_canonical(domain)
632
+ diagnostics.append(
633
+ self._create_diagnostic(
634
+ f"Domain Alias: '{domain}' is an alias for '{canonical}'. Preference: Canonical.",
635
+ DiagnosticSeverity.Warning,
636
+ line=field_line,
637
+ )
638
+ )
639
+ elif not service.is_defined(domain):
640
+ if service.config.strict:
641
+ diagnostics.append(
642
+ self._create_diagnostic(
643
+ f"Unknown Domain: '{domain}' is not defined in domain ontology.",
644
+ DiagnosticSeverity.Error,
645
+ line=field_line,
646
+ )
647
+ )
648
+ else:
649
+ diagnostics.append(
650
+ self._create_diagnostic(
651
+ f"Unknown Domain: '{domain}' is not defined in domain ontology.",
652
+ DiagnosticSeverity.Warning,
653
+ line=field_line,
654
+ )
655
+ )
656
+
610
657
  return diagnostics
611
658
 
612
659
  def _validate_checkbox_logic_blocks(
@@ -0,0 +1,19 @@
1
+ from .models import RoleTemplate, SchedulerConfig
2
+ from .worker import Worker
3
+ from .config import load_scheduler_config
4
+ from .defaults import DEFAULT_ROLES
5
+ from .session import Session, RuntimeSession
6
+ from .manager import SessionManager
7
+ from .reliability import ApoptosisManager
8
+
9
+ __all__ = [
10
+ "RoleTemplate",
11
+ "SchedulerConfig",
12
+ "Worker",
13
+ "load_scheduler_config",
14
+ "DEFAULT_ROLES",
15
+ "Session",
16
+ "RuntimeSession",
17
+ "SessionManager",
18
+ "ApoptosisManager",
19
+ ]
@@ -0,0 +1,204 @@
1
+ import typer
2
+ import time
3
+ from pathlib import Path
4
+ from typing import Optional
5
+ from monoco.core.output import print_output
6
+ from monoco.core.config import get_config
7
+ from monoco.features.scheduler import SessionManager, load_scheduler_config
8
+
9
+ app = typer.Typer(name="agent", help="Manage agent sessions")
10
+
11
+
12
+ @app.command()
13
+ def run(
14
+ target: str = typer.Argument(
15
+ ..., help="Issue ID (e.g. FEAT-101) or a Task Description in quotes."
16
+ ),
17
+ role: Optional[str] = typer.Option(
18
+ None,
19
+ help="Specific role to use (crafter/builder/auditor). Default: intelligent selection.",
20
+ ),
21
+ detach: bool = typer.Option(
22
+ False, "--detach", "-d", help="Run in background (Daemon)"
23
+ ),
24
+ fail: bool = typer.Option(
25
+ False, "--fail", help="Simulate a crash for testing Apoptosis."
26
+ ),
27
+ ):
28
+ """
29
+ Start an agent session.
30
+ - If TARGET is an Issue ID: Work on that issue.
31
+ - If TARGET is a text description: Create a new issue (Crafter).
32
+ """
33
+ settings = get_config()
34
+ project_root = Path(settings.paths.root).resolve()
35
+
36
+ # 1. Smart Intent Recognition
37
+ import re
38
+
39
+ is_id = re.match(r"^[a-zA-Z]+-\d+$", target)
40
+
41
+ if is_id:
42
+ issue_id = target.upper()
43
+ role_name = role or "builder"
44
+ description = None
45
+ else:
46
+ issue_id = "NEW_TASK"
47
+ role_name = role or "crafter"
48
+ description = target
49
+
50
+ # 2. Load Roles
51
+ roles = load_scheduler_config(project_root)
52
+ selected_role = roles.get(role_name)
53
+
54
+ if not selected_role:
55
+ from monoco.core.output import print_error
56
+
57
+ print_error(f"Role '{role_name}' not found. Available: {list(roles.keys())}")
58
+ raise typer.Exit(code=1)
59
+
60
+ print_output(
61
+ f"Starting Agent Session for '{target}' as {role_name}...",
62
+ title="Agent Scheduler",
63
+ )
64
+
65
+ # 3. Initialize Session
66
+ manager = SessionManager()
67
+ session = manager.create_session(issue_id, selected_role)
68
+
69
+ if detach:
70
+ print_output(
71
+ "Background mode not fully implemented yet. Running in foreground."
72
+ )
73
+
74
+ try:
75
+ # Pass description if it's a new task
76
+ context = {"description": description} if description else None
77
+
78
+ if fail:
79
+ from monoco.core.output import rprint
80
+
81
+ rprint("[bold yellow]DEBUG: Simulating immediate crash...[/bold yellow]")
82
+ session.model.status = "failed"
83
+ else:
84
+ session.start(context=context)
85
+
86
+ # Monitoring Loop
87
+ while session.model.status == "running":
88
+ time.sleep(1)
89
+
90
+ if session.model.status == "failed":
91
+ from monoco.core.output import print_error
92
+
93
+ print_error(
94
+ f"Session {session.model.id} FAILED. Use 'monoco agent autopsy {session.model.id}' for analysis."
95
+ )
96
+ else:
97
+ print_output(
98
+ f"Session finished with status: {session.model.status}",
99
+ title="Agent Scheduler",
100
+ )
101
+
102
+ except KeyboardInterrupt:
103
+ print("\nStopping...")
104
+ session.terminate()
105
+ print_output("Session terminated.")
106
+
107
+
108
+ @app.command()
109
+ def kill(session_id: str):
110
+ """
111
+ Terminate a session.
112
+ """
113
+ manager = SessionManager()
114
+ session = manager.get_session(session_id)
115
+ if session:
116
+ session.terminate()
117
+ print_output(f"Session {session_id} terminated.")
118
+ else:
119
+ print_output(f"Session {session_id} not found.", style="red")
120
+
121
+
122
+ @app.command()
123
+ def autopsy(
124
+ target: str = typer.Argument(..., help="Session ID or Issue ID to analyze."),
125
+ ):
126
+ """
127
+ Execute Post-Mortem analysis on a failed session or target Issue.
128
+ """
129
+ from .reliability import ApoptosisManager
130
+
131
+ manager = SessionManager()
132
+
133
+ print_output(f"Initiating Autopsy for '{target}'...", title="Coroner")
134
+
135
+ # Try to find session
136
+ session = manager.get_session(target)
137
+ if not session:
138
+ # Fallback: Treat target as Issue ID and create a dummy failed session context
139
+ import re
140
+
141
+ if re.match(r"^[a-zA-Z]+-\d+$", target):
142
+ print_output(f"Session not in memory. Analyzing Issue {target} directly.")
143
+ # We create a transient session just to trigger the coroner
144
+ from .defaults import DEFAULT_ROLES
145
+
146
+ builder_role = next(r for r in DEFAULT_ROLES if r.name == "builder")
147
+ session = manager.create_session(target.upper(), builder_role)
148
+ session.model.status = "failed"
149
+ else:
150
+ print_output(
151
+ f"Could not find session or valid Issue ID for '{target}'", style="red"
152
+ )
153
+ raise typer.Exit(code=1)
154
+
155
+ apoptosis = ApoptosisManager(manager)
156
+ apoptosis.trigger_apoptosis(session.model.id)
157
+
158
+
159
+ @app.command(name="list")
160
+ def list_sessions():
161
+ """
162
+ List active agent sessions.
163
+ """
164
+ manager = SessionManager()
165
+ sessions = manager.list_sessions()
166
+
167
+ output = []
168
+ for s in sessions:
169
+ output.append(
170
+ {
171
+ "id": s.model.id,
172
+ "issue": s.model.issue_id,
173
+ "role": s.model.role_name,
174
+ "status": s.model.status,
175
+ "branch": s.model.branch_name,
176
+ }
177
+ )
178
+
179
+ print_output(
180
+ output
181
+ or "No active sessions found (Note: Persistence not implemented in CLI list yet).",
182
+ title="Active Sessions",
183
+ )
184
+
185
+
186
+ @app.command()
187
+ def logs(session_id: str):
188
+ """
189
+ Stream logs for a session.
190
+ """
191
+ print_output(f"Streaming logs for {session_id}...", title="Session Logs")
192
+ # Placeholder
193
+ print("[12:00:00] Session started")
194
+ 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.")
@@ -0,0 +1,32 @@
1
+ import yaml
2
+ from pathlib import Path
3
+ from typing import Dict
4
+ from .models import RoleTemplate, SchedulerConfig
5
+ from .defaults import DEFAULT_ROLES
6
+
7
+
8
+ def load_scheduler_config(project_root: Path) -> Dict[str, RoleTemplate]:
9
+ """
10
+ Load scheduler configuration from .monoco/scheduler.yaml
11
+ Merges with default roles.
12
+ """
13
+ roles = {role.name: role for role in DEFAULT_ROLES}
14
+
15
+ config_path = project_root / ".monoco" / "scheduler.yaml"
16
+ if config_path.exists():
17
+ try:
18
+ with open(config_path, "r") as f:
19
+ data = yaml.safe_load(f) or {}
20
+
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
+ if "roles" in data:
24
+ # We can validate using SchedulerConfig
25
+ config = SchedulerConfig(roles=data["roles"])
26
+ for role in config.roles:
27
+ roles[role.name] = role
28
+ 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}")
31
+
32
+ return roles
@@ -0,0 +1,54 @@
1
+ from .models import RoleTemplate
2
+
3
+ DEFAULT_ROLES = [
4
+ RoleTemplate(
5
+ name="crafter",
6
+ description="Responsible for initial design, research, and drafting issues from descriptions.",
7
+ trigger="task.received",
8
+ goal="Produce a structured Issue file and/or detailed design document.",
9
+ tools=[
10
+ "create_issue_file",
11
+ "read_file",
12
+ "search_web",
13
+ "view_file_outline",
14
+ "write_to_file",
15
+ ],
16
+ system_prompt=(
17
+ "You are a Crafter agent. Your goal is to turn vague ideas into structured engineering plans.\n"
18
+ "If the user provides a description, use 'monoco issue create' and 'monoco issue update' to build the task.\n"
19
+ "If the user provides an existing Issue, analyze the context and provide a detailed design or implementation plan."
20
+ ),
21
+ engine="gemini",
22
+ ),
23
+ RoleTemplate(
24
+ name="builder",
25
+ description="Responsible for implementation.",
26
+ trigger="design.approved",
27
+ goal="Implement code and tests",
28
+ tools=["read_file", "write_to_file", "run_command", "git"],
29
+ system_prompt="You are a Builder agent. Your job is to implement the code based on the design.",
30
+ engine="gemini",
31
+ ),
32
+ RoleTemplate(
33
+ name="auditor",
34
+ description="Responsible for code review.",
35
+ trigger="implementation.submitted",
36
+ goal="Review code and provide feedback",
37
+ tools=[
38
+ "read_file",
39
+ "read_terminal",
40
+ "run_command",
41
+ ], # Assumed read_diff and lint are via run_command
42
+ system_prompt="You are an Auditor agent. Your job is to review the code for quality and correctness.",
43
+ engine="gemini",
44
+ ),
45
+ RoleTemplate(
46
+ name="coroner",
47
+ description="Responsible for analyzing failure root causes (Autopsy).",
48
+ trigger="session.crashed",
49
+ goal="Produce a post-mortem report",
50
+ tools=["read_file", "read_terminal", "git_log"],
51
+ system_prompt="You are a Coroner agent. Your job is to analyze why the previous session failed and write a post-mortem report.",
52
+ engine="gemini",
53
+ ),
54
+ ]
@@ -0,0 +1,49 @@
1
+ from typing import Dict, List, Optional
2
+ import uuid
3
+ from .models import RoleTemplate
4
+ from .worker import Worker
5
+ from .session import Session, RuntimeSession
6
+
7
+
8
+ class SessionManager:
9
+ """
10
+ Manages the lifecycle of sessions.
11
+ Responsible for creating, tracking, and retrieving sessions.
12
+ """
13
+
14
+ def __init__(self):
15
+ # In-memory storage for now. In prod, this might be a DB or file-backed.
16
+ self._sessions: Dict[str, RuntimeSession] = {}
17
+
18
+ def create_session(self, issue_id: str, role: RoleTemplate) -> RuntimeSession:
19
+ session_id = str(uuid.uuid4())
20
+ branch_name = (
21
+ f"agent/{issue_id}/{session_id[:8]}" # Simple branch naming strategy
22
+ )
23
+
24
+ session_model = Session(
25
+ id=session_id,
26
+ issue_id=issue_id,
27
+ role_name=role.name,
28
+ branch_name=branch_name,
29
+ )
30
+
31
+ worker = Worker(role, issue_id)
32
+ runtime = RuntimeSession(session_model, worker)
33
+ self._sessions[session_id] = runtime
34
+ return runtime
35
+
36
+ def get_session(self, session_id: str) -> Optional[RuntimeSession]:
37
+ return self._sessions.get(session_id)
38
+
39
+ def list_sessions(self, issue_id: Optional[str] = None) -> List[RuntimeSession]:
40
+ if issue_id:
41
+ return [s for s in self._sessions.values() if s.model.issue_id == issue_id]
42
+ return list(self._sessions.values())
43
+
44
+ def terminate_session(self, session_id: str):
45
+ session = self.get_session(session_id)
46
+ if session:
47
+ session.terminate()
48
+ # We might want to keep the record for a while, so don't delete immediately
49
+ # del self._sessions[session_id]
@@ -0,0 +1,24 @@
1
+ from typing import List
2
+ from pydantic import BaseModel, Field
3
+
4
+
5
+ class RoleTemplate(BaseModel):
6
+ name: str = Field(
7
+ ..., description="Unique identifier for the role (e.g., 'crafter')"
8
+ )
9
+ description: str = Field(..., description="Human-readable description of the role")
10
+ trigger: str = Field(
11
+ ..., description="Event that triggers this agent (e.g., 'issue.created')"
12
+ )
13
+ goal: str = Field(..., description="The primary goal/output of this agent")
14
+ tools: List[str] = Field(default_factory=list, description="List of allowed tools")
15
+ system_prompt: str = Field(
16
+ ..., description="The system prompt template for this agent"
17
+ )
18
+ engine: str = Field(
19
+ default="gemini", description="CLI agent engine (gemini/claude)"
20
+ )
21
+
22
+
23
+ class SchedulerConfig(BaseModel):
24
+ roles: List[RoleTemplate] = Field(default_factory=list)
@@ -0,0 +1,99 @@
1
+ from .manager import SessionManager
2
+ from .session import RuntimeSession
3
+ from .defaults import DEFAULT_ROLES
4
+
5
+
6
+ class ApoptosisManager:
7
+ """
8
+ Handles the 'Apoptosis' (Programmed Cell Death) lifecycle for agents.
9
+ Ensures that failing agents are killed, analyzed, and the environment is reset.
10
+ """
11
+
12
+ def __init__(self, session_manager: SessionManager):
13
+ self.session_manager = session_manager
14
+ # Find coroner role
15
+ self.coroner_role = next(
16
+ (r for r in DEFAULT_ROLES if r.name == "coroner"), None
17
+ )
18
+ if not self.coroner_role:
19
+ raise ValueError("Coroner role not defined!")
20
+
21
+ def check_health(self, session: RuntimeSession) -> bool:
22
+ """
23
+ Check if a session is healthy.
24
+ In a real implementation, this would check heartbeat, CPU usage, or token limits.
25
+ """
26
+ # Placeholder logic: Random failure or external flag?
27
+ # For now, always healthy unless explicitly marked 'crashed' (which we can simulate)
28
+ if hasattr(session, "simulate_crash") and session.simulate_crash:
29
+ return False
30
+ return True
31
+
32
+ def trigger_apoptosis(self, session_id: str):
33
+ """
34
+ Execute the full death and rebirth cycle.
35
+ """
36
+ session = self.session_manager.get_session(session_id)
37
+ if not session:
38
+ print(f"Session {session_id} not found for apoptosis.")
39
+ return
40
+
41
+ print(f"💀 [Apoptosis] Starting lifecycle for Session {session_id}")
42
+
43
+ # 1. Kill
44
+ self._kill(session)
45
+
46
+ # 2. Autopsy
47
+ try:
48
+ self._perform_autopsy(session)
49
+ except Exception as e:
50
+ print(f"⚠️ Autopsy failed: {e}")
51
+
52
+ # 3. Reset
53
+ self._reset_environment(session)
54
+
55
+ print(
56
+ f"✅ [Apoptosis] Task {session.model.issue_id} has been reset and analyzed."
57
+ )
58
+
59
+ def _kill(self, session: RuntimeSession):
60
+ print(f"🔪 Killing worker process for {session.model.id}...")
61
+ session.terminate()
62
+ session.model.status = "crashed"
63
+
64
+ def _perform_autopsy(self, victim_session: RuntimeSession):
65
+ print(
66
+ f"🔍 Performing autopsy on {victim_session.model.id} via Coroner agent..."
67
+ )
68
+
69
+ # Start a Coroner session
70
+ coroner_session = self.session_manager.create_session(
71
+ victim_session.model.issue_id, self.coroner_role
72
+ )
73
+
74
+ # Context for the coroner
75
+ context = {
76
+ "description": f"The previous agent session ({victim_session.model.id}) for role '{victim_session.model.role_name}' crashed. Please analyze the environment and the Issue {victim_session.model.issue_id}, then write a ## Post-mortem section in the issue file."
77
+ }
78
+
79
+ coroner_session.start(context=context)
80
+ print("📄 Coroner agent finished analysis.")
81
+
82
+ def _reset_environment(self, session: RuntimeSession):
83
+ print("🧹 Resetting environment (simulated git reset --hard)...")
84
+ # In real impl:
85
+ # import subprocess
86
+ # subprocess.run(["git", "reset", "--hard"], check=True)
87
+ pass
88
+
89
+ def _retry(self, session: RuntimeSession):
90
+ print("🔄 Reincarnating session...")
91
+ # Create a new session with the same role and issue
92
+ new_session = self.session_manager.create_session(
93
+ session.model.issue_id,
94
+ # We need to find the original role object.
95
+ # Simplified: assuming we can find it by name or pass it.
96
+ # For now, just placeholder.
97
+ session.worker.role,
98
+ )
99
+ new_session.start()
@@ -0,0 +1,87 @@
1
+ from datetime import datetime
2
+ from typing import Optional
3
+ from pydantic import BaseModel, Field
4
+ from .worker import Worker
5
+
6
+
7
+ class Session(BaseModel):
8
+ """
9
+ Represents a runtime session of a worker.
10
+ Persisted state of the session.
11
+ """
12
+
13
+ id: str = Field(..., description="Unique session ID (likely UUID)")
14
+ issue_id: str = Field(..., description="The Issue ID this session is working on")
15
+ role_name: str = Field(..., description="Name of the role employed")
16
+ status: str = Field(
17
+ default="pending", description="pending, running, suspended, terminated"
18
+ )
19
+ branch_name: str = Field(
20
+ ..., description="Git branch name associated with this session"
21
+ )
22
+ created_at: datetime = Field(default_factory=datetime.now)
23
+ updated_at: datetime = Field(default_factory=datetime.now)
24
+ # History could be a list of logs or pointers to git commits
25
+ # For now, let's keep it simple. The git log IS the history.
26
+
27
+ class Config:
28
+ arbitrary_types_allowed = True
29
+
30
+
31
+ class RuntimeSession:
32
+ """
33
+ The in-memory wrapper around the Session model and the active Worker.
34
+ """
35
+
36
+ def __init__(self, session_model: Session, worker: Worker):
37
+ self.model = session_model
38
+ self.worker = worker
39
+
40
+ def start(self, context: Optional[dict] = None):
41
+ print(
42
+ f"Session {self.model.id}: Starting worker on branch {self.model.branch_name}"
43
+ )
44
+ # In real impl, checking out branch happening here
45
+ self.model.status = "running"
46
+ self.model.updated_at = datetime.now()
47
+
48
+ try:
49
+ self.worker.start(context)
50
+ # Async mode: we assume it started running.
51
+ # Use poll or refresh_status to check later.
52
+ self.model.status = "running"
53
+ except Exception:
54
+ self.model.status = "failed"
55
+ raise
56
+ finally:
57
+ self.model.updated_at = datetime.now()
58
+
59
+ def refresh_status(self) -> str:
60
+ """
61
+ Polls the worker and updates the session model status.
62
+ """
63
+ worker_status = self.worker.poll()
64
+ self.model.status = worker_status
65
+ self.model.updated_at = datetime.now()
66
+ return worker_status
67
+
68
+ def suspend(self):
69
+ print(f"Session {self.model.id}: Suspending worker")
70
+ self.worker.stop()
71
+ self.model.status = "suspended"
72
+ self.model.updated_at = datetime.now()
73
+ # In real impl, ensure git commit of current state?
74
+
75
+ def resume(self):
76
+ print(f"Session {self.model.id}: Resuming worker")
77
+ self.worker.start() # In real impl, might need to re-init process
78
+
79
+ # Async mode: assume running
80
+ self.model.status = "running"
81
+ self.model.updated_at = datetime.now()
82
+
83
+ def terminate(self):
84
+ print(f"Session {self.model.id}: Terminating")
85
+ self.worker.stop()
86
+ self.model.status = "terminated"
87
+ self.model.updated_at = datetime.now()