monoco-toolkit 0.1.7__py3-none-any.whl → 0.2.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.
- monoco/core/agent/__init__.py +5 -0
- monoco/core/agent/action.py +144 -0
- monoco/core/agent/adapters.py +106 -0
- monoco/core/agent/protocol.py +31 -0
- monoco/core/agent/state.py +117 -0
- monoco/core/config.py +42 -1
- monoco/core/execution.py +62 -0
- monoco/core/git.py +51 -2
- monoco/core/output.py +13 -2
- monoco/core/state.py +53 -0
- monoco/core/workspace.py +75 -12
- monoco/daemon/app.py +120 -57
- monoco/daemon/models.py +4 -0
- monoco/daemon/services.py +56 -155
- monoco/features/agent/commands.py +160 -0
- monoco/features/agent/doctor.py +30 -0
- monoco/features/issue/core.py +80 -47
- monoco/features/issue/executions/refine.md +26 -0
- monoco/features/issue/models.py +18 -6
- monoco/features/issue/monitor.py +94 -0
- monoco/main.py +13 -0
- {monoco_toolkit-0.1.7.dist-info → monoco_toolkit-0.2.0.dist-info}/METADATA +1 -1
- {monoco_toolkit-0.1.7.dist-info → monoco_toolkit-0.2.0.dist-info}/RECORD +26 -15
- {monoco_toolkit-0.1.7.dist-info → monoco_toolkit-0.2.0.dist-info}/WHEEL +0 -0
- {monoco_toolkit-0.1.7.dist-info → monoco_toolkit-0.2.0.dist-info}/entry_points.txt +0 -0
- {monoco_toolkit-0.1.7.dist-info → monoco_toolkit-0.2.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
|
|
2
|
+
import re
|
|
3
|
+
import yaml
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Dict, List, Optional, Any
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
class ActionContext(BaseModel):
|
|
9
|
+
"""Context information for matching actions."""
|
|
10
|
+
id: Optional[str] = None
|
|
11
|
+
type: Optional[str] = None
|
|
12
|
+
stage: Optional[str] = None
|
|
13
|
+
status: Optional[str] = None
|
|
14
|
+
file_path: Optional[str] = None
|
|
15
|
+
project_id: Optional[str] = None
|
|
16
|
+
|
|
17
|
+
class ActionWhen(BaseModel):
|
|
18
|
+
"""Conditions under which an action should be displayed/active."""
|
|
19
|
+
idMatch: Optional[str] = None
|
|
20
|
+
typeMatch: Optional[str] = None
|
|
21
|
+
stageMatch: Optional[str] = None
|
|
22
|
+
statusMatch: Optional[str] = None
|
|
23
|
+
fileMatch: Optional[str] = None
|
|
24
|
+
|
|
25
|
+
def matches(self, context: ActionContext) -> bool:
|
|
26
|
+
"""Evaluate if the context matches these criteria."""
|
|
27
|
+
if self.idMatch and context.id and not re.match(self.idMatch, context.id):
|
|
28
|
+
return False
|
|
29
|
+
if self.typeMatch and context.type and not re.match(self.typeMatch, context.type):
|
|
30
|
+
return False
|
|
31
|
+
if self.stageMatch and context.stage and not re.match(self.stageMatch, context.stage):
|
|
32
|
+
return False
|
|
33
|
+
if self.statusMatch and context.status and not re.match(self.statusMatch, context.status):
|
|
34
|
+
return False
|
|
35
|
+
if self.fileMatch and context.file_path and not re.match(self.fileMatch, context.file_path):
|
|
36
|
+
return False
|
|
37
|
+
return True
|
|
38
|
+
|
|
39
|
+
class PromptyAction(BaseModel):
|
|
40
|
+
name: str
|
|
41
|
+
description: str
|
|
42
|
+
version: Optional[str] = "1.0.0"
|
|
43
|
+
authors: List[str] = []
|
|
44
|
+
model: Dict[str, Any] = {}
|
|
45
|
+
inputs: Dict[str, Any] = {}
|
|
46
|
+
outputs: Dict[str, Any] = {}
|
|
47
|
+
template: str
|
|
48
|
+
when: Optional[ActionWhen] = None
|
|
49
|
+
|
|
50
|
+
# Monoco specific metadata
|
|
51
|
+
path: Optional[str] = None
|
|
52
|
+
provider: Optional[str] = None # Derived from model.api or explicitly set
|
|
53
|
+
|
|
54
|
+
class ActionRegistry:
|
|
55
|
+
def __init__(self, project_root: Optional[Path] = None):
|
|
56
|
+
self.project_root = project_root
|
|
57
|
+
self._actions: List[PromptyAction] = []
|
|
58
|
+
|
|
59
|
+
def scan(self) -> List[PromptyAction]:
|
|
60
|
+
"""Scan user global and project local directories for .prompty files."""
|
|
61
|
+
self._actions = []
|
|
62
|
+
|
|
63
|
+
# 1. User Global: ~/.monoco/actions/
|
|
64
|
+
user_dir = Path.home() / ".monoco" / "actions"
|
|
65
|
+
self._scan_dir(user_dir)
|
|
66
|
+
|
|
67
|
+
# 2. Project Local: {project_root}/.monoco/actions/
|
|
68
|
+
if self.project_root:
|
|
69
|
+
project_dir = self.project_root / ".monoco" / "actions"
|
|
70
|
+
self._scan_dir(project_dir)
|
|
71
|
+
|
|
72
|
+
return self._actions
|
|
73
|
+
|
|
74
|
+
def _scan_dir(self, directory: Path):
|
|
75
|
+
if not directory.exists():
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
for prompty_file in directory.glob("*.prompty"):
|
|
79
|
+
try:
|
|
80
|
+
action = self._load_action(prompty_file)
|
|
81
|
+
if action:
|
|
82
|
+
self._actions.append(action)
|
|
83
|
+
except Exception as e:
|
|
84
|
+
print(f"Failed to load action {prompty_file}: {e}")
|
|
85
|
+
|
|
86
|
+
def _load_action(self, file_path: Path) -> Optional[PromptyAction]:
|
|
87
|
+
content = file_path.read_text(encoding="utf-8")
|
|
88
|
+
|
|
89
|
+
# Prompty Parser (Standard YAML Frontmatter + Body)
|
|
90
|
+
# We look for the first --- and the second ---
|
|
91
|
+
parts = re.split(r"^---\s*$", content, maxsplit=2, flags=re.MULTILINE)
|
|
92
|
+
|
|
93
|
+
if len(parts) < 3:
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
frontmatter_raw = parts[1]
|
|
97
|
+
body = parts[2].strip()
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
meta = yaml.safe_load(frontmatter_raw)
|
|
101
|
+
if not meta or "name" not in meta:
|
|
102
|
+
# Use filename as fallback name if missing? Prompty usually requires name.
|
|
103
|
+
if not meta: meta = {}
|
|
104
|
+
meta["name"] = meta.get("name", file_path.stem)
|
|
105
|
+
|
|
106
|
+
# Map Prompty 'when' if present
|
|
107
|
+
when_data = meta.get("when")
|
|
108
|
+
when = ActionWhen(**when_data) if when_data else None
|
|
109
|
+
|
|
110
|
+
action = PromptyAction(
|
|
111
|
+
name=meta["name"],
|
|
112
|
+
description=meta.get("description", ""),
|
|
113
|
+
version=meta.get("version"),
|
|
114
|
+
authors=meta.get("authors", []),
|
|
115
|
+
model=meta.get("model", {}),
|
|
116
|
+
inputs=meta.get("inputs", {}),
|
|
117
|
+
outputs=meta.get("outputs", {}),
|
|
118
|
+
template=body,
|
|
119
|
+
when=when,
|
|
120
|
+
path=str(file_path.absolute()),
|
|
121
|
+
provider=meta.get("provider") or meta.get("model", {}).get("api")
|
|
122
|
+
)
|
|
123
|
+
return action
|
|
124
|
+
|
|
125
|
+
except Exception as e:
|
|
126
|
+
print(f"Invalid Prompty in {file_path}: {e}")
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
def list_available(self, context: Optional[ActionContext] = None) -> List[PromptyAction]:
|
|
130
|
+
if not self._actions:
|
|
131
|
+
self.scan()
|
|
132
|
+
|
|
133
|
+
if not context:
|
|
134
|
+
return self._actions
|
|
135
|
+
|
|
136
|
+
return [a for a in self._actions if not a.when or a.when.matches(context)]
|
|
137
|
+
|
|
138
|
+
def get(self, name: str) -> Optional[PromptyAction]:
|
|
139
|
+
if not self._actions:
|
|
140
|
+
self.scan()
|
|
141
|
+
for a in self._actions:
|
|
142
|
+
if a.name == name:
|
|
143
|
+
return a
|
|
144
|
+
return None
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI Adapters for Agent Frameworks.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from typing import List
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from .protocol import AgentClient
|
|
10
|
+
|
|
11
|
+
class BaseCLIClient:
|
|
12
|
+
def __init__(self, executable: str):
|
|
13
|
+
self._executable = executable
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def name(self) -> str:
|
|
17
|
+
return self._executable
|
|
18
|
+
|
|
19
|
+
async def available(self) -> bool:
|
|
20
|
+
return shutil.which(self._executable) is not None
|
|
21
|
+
|
|
22
|
+
def _build_prompt(self, prompt: str, context_files: List[Path]) -> str:
|
|
23
|
+
"""Concatenate prompt and context files."""
|
|
24
|
+
full_prompt = [prompt]
|
|
25
|
+
if context_files:
|
|
26
|
+
full_prompt.append("\n\n--- CONTEXT FILES ---")
|
|
27
|
+
for file_path in context_files:
|
|
28
|
+
try:
|
|
29
|
+
full_prompt.append(f"\nFile: {file_path}")
|
|
30
|
+
full_prompt.append("```")
|
|
31
|
+
# Read file content safely
|
|
32
|
+
full_prompt.append(file_path.read_text(encoding="utf-8", errors="replace"))
|
|
33
|
+
full_prompt.append("```")
|
|
34
|
+
except Exception as e:
|
|
35
|
+
full_prompt.append(f"Error reading {file_path}: {e}")
|
|
36
|
+
full_prompt.append("--- END CONTEXT ---\n")
|
|
37
|
+
return "\n".join(full_prompt)
|
|
38
|
+
|
|
39
|
+
async def _run_command(self, args: List[str]) -> str:
|
|
40
|
+
"""Run the CLI command and return stdout."""
|
|
41
|
+
# Using synchronous subprocess in async function for now
|
|
42
|
+
# Ideally this should use asyncio.create_subprocess_exec
|
|
43
|
+
import asyncio
|
|
44
|
+
|
|
45
|
+
proc = await asyncio.create_subprocess_exec(
|
|
46
|
+
*args,
|
|
47
|
+
stdout=asyncio.subprocess.PIPE,
|
|
48
|
+
stderr=asyncio.subprocess.PIPE
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
stdout, stderr = await proc.communicate()
|
|
52
|
+
|
|
53
|
+
if proc.returncode != 0:
|
|
54
|
+
error_msg = stderr.decode().strip()
|
|
55
|
+
raise RuntimeError(f"Agent CLI failed (code {proc.returncode}): {error_msg}")
|
|
56
|
+
|
|
57
|
+
return stdout.decode().strip()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class GeminiClient(BaseCLIClient, AgentClient):
|
|
61
|
+
"""Adapter for Google Gemini CLI."""
|
|
62
|
+
|
|
63
|
+
def __init__(self):
|
|
64
|
+
super().__init__("gemini")
|
|
65
|
+
|
|
66
|
+
async def execute(self, prompt: str, context_files: List[Path] = []) -> str:
|
|
67
|
+
full_prompt = self._build_prompt(prompt, context_files)
|
|
68
|
+
# Usage: gemini "prompt"
|
|
69
|
+
return await self._run_command([self._executable, full_prompt])
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ClaudeClient(BaseCLIClient, AgentClient):
|
|
73
|
+
"""Adapter for Anthropic Claude CLI."""
|
|
74
|
+
|
|
75
|
+
def __init__(self):
|
|
76
|
+
super().__init__("claude")
|
|
77
|
+
|
|
78
|
+
async def execute(self, prompt: str, context_files: List[Path] = []) -> str:
|
|
79
|
+
full_prompt = self._build_prompt(prompt, context_files)
|
|
80
|
+
# Usage: claude -p "prompt"
|
|
81
|
+
return await self._run_command([self._executable, "-p", full_prompt])
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class QwenClient(BaseCLIClient, AgentClient):
|
|
85
|
+
"""Adapter for Alibaba Qwen CLI."""
|
|
86
|
+
|
|
87
|
+
def __init__(self):
|
|
88
|
+
super().__init__("qwen")
|
|
89
|
+
|
|
90
|
+
async def execute(self, prompt: str, context_files: List[Path] = []) -> str:
|
|
91
|
+
full_prompt = self._build_prompt(prompt, context_files)
|
|
92
|
+
# Usage: qwen "prompt"
|
|
93
|
+
return await self._run_command([self._executable, full_prompt])
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
_ADAPTERS = {
|
|
97
|
+
"gemini": GeminiClient,
|
|
98
|
+
"claude": ClaudeClient,
|
|
99
|
+
"qwen": QwenClient
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
def get_agent_client(name: str) -> AgentClient:
|
|
103
|
+
"""Factory to get agent client by name."""
|
|
104
|
+
if name not in _ADAPTERS:
|
|
105
|
+
raise ValueError(f"Unknown agent provider: {name}")
|
|
106
|
+
return _ADAPTERS[name]()
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Protocol definition for Agent Clients.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Protocol, List, Optional
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
class AgentClient(Protocol):
|
|
9
|
+
"""Protocol for interacting with CLI-based agents."""
|
|
10
|
+
|
|
11
|
+
@property
|
|
12
|
+
def name(self) -> str:
|
|
13
|
+
"""Name of the agent provider (e.g. 'gemini', 'claude')."""
|
|
14
|
+
...
|
|
15
|
+
|
|
16
|
+
async def available(self) -> bool:
|
|
17
|
+
"""Check if the agent is available in the current environment."""
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
async def execute(self, prompt: str, context_files: List[Path] = []) -> str:
|
|
21
|
+
"""
|
|
22
|
+
Execute a prompt against the agent.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
prompt: The main instructions.
|
|
26
|
+
context_files: List of files to provide as context.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
The raw string response from the agent.
|
|
30
|
+
"""
|
|
31
|
+
...
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Agent State Management.
|
|
3
|
+
|
|
4
|
+
Handles persistence and retrieval of agent availability state.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import logging
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from typing import Dict, Optional
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("monoco.core.agent.state")
|
|
17
|
+
|
|
18
|
+
class AgentProviderState(BaseModel):
|
|
19
|
+
available: bool
|
|
20
|
+
path: Optional[str] = None
|
|
21
|
+
error: Optional[str] = None
|
|
22
|
+
latency_ms: Optional[int] = None
|
|
23
|
+
|
|
24
|
+
class AgentState(BaseModel):
|
|
25
|
+
last_checked: datetime
|
|
26
|
+
providers: Dict[str, AgentProviderState]
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def is_stale(self) -> bool:
|
|
30
|
+
"""Check if the state is older than 7 days."""
|
|
31
|
+
delta = datetime.now(timezone.utc) - self.last_checked
|
|
32
|
+
return delta.days > 7
|
|
33
|
+
|
|
34
|
+
class AgentStateManager:
|
|
35
|
+
def __init__(self, state_path: Path = Path.home() / ".monoco" / "agent_state.yaml"):
|
|
36
|
+
self.state_path = state_path
|
|
37
|
+
self._state: Optional[AgentState] = None
|
|
38
|
+
|
|
39
|
+
def load(self) -> Optional[AgentState]:
|
|
40
|
+
"""Load state from file, returning None if missing or invalid."""
|
|
41
|
+
if not self.state_path.exists():
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
with open(self.state_path, "r") as f:
|
|
46
|
+
data = yaml.safe_load(f)
|
|
47
|
+
if not data:
|
|
48
|
+
return None
|
|
49
|
+
# Handle ISO string to datetime conversion if needed provided by Pydantic mostly
|
|
50
|
+
return AgentState(**data)
|
|
51
|
+
except Exception as e:
|
|
52
|
+
logger.warning(f"Failed to load agent state: {e}")
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
def get_or_refresh(self, force: bool = False) -> AgentState:
|
|
56
|
+
"""Get current state, refreshing if missing, stale, or forced."""
|
|
57
|
+
if not force:
|
|
58
|
+
self._state = self.load()
|
|
59
|
+
if self._state and not self._state.is_stale:
|
|
60
|
+
return self._state
|
|
61
|
+
|
|
62
|
+
return self.refresh()
|
|
63
|
+
|
|
64
|
+
def refresh(self) -> AgentState:
|
|
65
|
+
"""Run the diagnostic script and update state file."""
|
|
66
|
+
logger.info("Refreshing agent state...")
|
|
67
|
+
|
|
68
|
+
# Locate the shell script
|
|
69
|
+
# Assuming monoco is installed as a package, we need to find where the script lives.
|
|
70
|
+
# For dev environment: Toolkit/scripts/check_agents.sh
|
|
71
|
+
# For production: It might need to be packaged or generated.
|
|
72
|
+
|
|
73
|
+
# Current strategy: Look in known relative locations
|
|
74
|
+
script_path = self._find_script()
|
|
75
|
+
if not script_path:
|
|
76
|
+
raise FileNotFoundError("Could not find check_agents.sh script")
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
# Ensure the directory exists
|
|
80
|
+
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
|
|
82
|
+
subprocess.run(
|
|
83
|
+
[str(script_path), str(self.state_path)],
|
|
84
|
+
check=True,
|
|
85
|
+
capture_output=True,
|
|
86
|
+
text=True
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Reload to get the object
|
|
90
|
+
state = self.load()
|
|
91
|
+
if not state:
|
|
92
|
+
raise ValueError("Script ran but state file is invalid or empty")
|
|
93
|
+
|
|
94
|
+
self._state = state
|
|
95
|
+
return state
|
|
96
|
+
|
|
97
|
+
except subprocess.CalledProcessError as e:
|
|
98
|
+
logger.error(f"Agent check script failed: {e.stderr}")
|
|
99
|
+
raise RuntimeError(f"Agent check failed: {e.stderr}") from e
|
|
100
|
+
|
|
101
|
+
def _find_script(self) -> Optional[Path]:
|
|
102
|
+
"""Find the check_agents.sh script."""
|
|
103
|
+
# Check dev path relative to this file
|
|
104
|
+
# this file: monoco/core/agent/state.py
|
|
105
|
+
# root: monoco/../../
|
|
106
|
+
|
|
107
|
+
current_file = Path(__file__).resolve()
|
|
108
|
+
|
|
109
|
+
# Strategy 1: Development logic (Toolkit/scripts/check_agents.sh)
|
|
110
|
+
dev_path = current_file.parents[3] / "scripts" / "check_agents.sh"
|
|
111
|
+
if dev_path.exists():
|
|
112
|
+
return dev_path
|
|
113
|
+
|
|
114
|
+
# Strategy 2: If installed in site-packages, maybe we package scripts nearby?
|
|
115
|
+
# TODO: Define packaging strategy for scripts
|
|
116
|
+
|
|
117
|
+
return None
|
monoco/core/config.py
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import os
|
|
2
2
|
import yaml
|
|
3
3
|
from pathlib import Path
|
|
4
|
-
from typing import Optional, Dict, Any
|
|
4
|
+
from typing import Optional, Dict, Any, Callable, Awaitable
|
|
5
5
|
from enum import Enum
|
|
6
6
|
from pydantic import BaseModel, Field
|
|
7
7
|
|
|
8
8
|
import logging
|
|
9
|
+
import asyncio
|
|
10
|
+
from watchdog.observers import Observer
|
|
11
|
+
from watchdog.events import FileSystemEventHandler
|
|
9
12
|
|
|
10
13
|
# Import AgentIntegration for type hints
|
|
11
14
|
from typing import TYPE_CHECKING
|
|
@@ -163,3 +166,41 @@ def save_raw_config(scope: ConfigScope, data: Dict[str, Any], project_root: Opti
|
|
|
163
166
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
164
167
|
with open(path, "w") as f:
|
|
165
168
|
yaml.dump(data, f, default_flow_style=False)
|
|
169
|
+
|
|
170
|
+
class ConfigEventHandler(FileSystemEventHandler):
|
|
171
|
+
def __init__(self, loop, on_change: Callable[[], Awaitable[None]], config_path: Path):
|
|
172
|
+
self.loop = loop
|
|
173
|
+
self.on_change = on_change
|
|
174
|
+
self.config_path = config_path
|
|
175
|
+
|
|
176
|
+
def on_modified(self, event):
|
|
177
|
+
if event.src_path == str(self.config_path):
|
|
178
|
+
asyncio.run_coroutine_threadsafe(self.on_change(), self.loop)
|
|
179
|
+
|
|
180
|
+
class ConfigMonitor:
|
|
181
|
+
"""
|
|
182
|
+
Monitors a configuration file for changes.
|
|
183
|
+
"""
|
|
184
|
+
def __init__(self, config_path: Path, on_change: Callable[[], Awaitable[None]]):
|
|
185
|
+
self.config_path = config_path
|
|
186
|
+
self.on_change = on_change
|
|
187
|
+
self.observer = Observer()
|
|
188
|
+
|
|
189
|
+
async def start(self):
|
|
190
|
+
loop = asyncio.get_running_loop()
|
|
191
|
+
event_handler = ConfigEventHandler(loop, self.on_change, self.config_path)
|
|
192
|
+
|
|
193
|
+
if not self.config_path.exists():
|
|
194
|
+
# Ensure parent exists at least
|
|
195
|
+
self.config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
196
|
+
|
|
197
|
+
# We watch the parent directory for the specific file
|
|
198
|
+
self.observer.schedule(event_handler, str(self.config_path.parent), recursive=False)
|
|
199
|
+
self.observer.start()
|
|
200
|
+
logger.info(f"Config Monitor started for {self.config_path}")
|
|
201
|
+
|
|
202
|
+
def stop(self):
|
|
203
|
+
if self.observer.is_alive():
|
|
204
|
+
self.observer.stop()
|
|
205
|
+
self.observer.join()
|
|
206
|
+
logger.info(f"Config Monitor stopped for {self.config_path}")
|
monoco/core/execution.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import List, Dict, Optional
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
class ExecutionProfile(BaseModel):
|
|
7
|
+
name: str
|
|
8
|
+
source: str # "Global" or "Project"
|
|
9
|
+
path: str
|
|
10
|
+
content: Optional[str] = None
|
|
11
|
+
|
|
12
|
+
def scan_execution_profiles(project_root: Optional[Path] = None) -> List[ExecutionProfile]:
|
|
13
|
+
"""
|
|
14
|
+
Scan for execution profiles (SOPs) in global and project scopes.
|
|
15
|
+
"""
|
|
16
|
+
profiles = []
|
|
17
|
+
|
|
18
|
+
# 1. Global Scope
|
|
19
|
+
global_path = Path.home() / ".monoco" / "execution"
|
|
20
|
+
if global_path.exists():
|
|
21
|
+
profiles.extend(_scan_dir(global_path, "Global"))
|
|
22
|
+
|
|
23
|
+
# 2. Project Scope
|
|
24
|
+
if project_root:
|
|
25
|
+
project_path = project_root / ".monoco" / "execution"
|
|
26
|
+
if project_path.exists():
|
|
27
|
+
profiles.extend(_scan_dir(project_path, "Project"))
|
|
28
|
+
|
|
29
|
+
return profiles
|
|
30
|
+
|
|
31
|
+
def _scan_dir(base_path: Path, source: str) -> List[ExecutionProfile]:
|
|
32
|
+
profiles = []
|
|
33
|
+
if not base_path.is_dir():
|
|
34
|
+
return profiles
|
|
35
|
+
|
|
36
|
+
for item in base_path.iterdir():
|
|
37
|
+
if item.is_dir():
|
|
38
|
+
sop_path = item / "SOP.md"
|
|
39
|
+
if sop_path.exists():
|
|
40
|
+
profiles.append(ExecutionProfile(
|
|
41
|
+
name=item.name,
|
|
42
|
+
source=source,
|
|
43
|
+
path=str(sop_path.absolute())
|
|
44
|
+
))
|
|
45
|
+
return profiles
|
|
46
|
+
|
|
47
|
+
def get_profile_detail(profile_path: str) -> Optional[ExecutionProfile]:
|
|
48
|
+
path = Path(profile_path)
|
|
49
|
+
if not path.exists():
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
# Determine source (rough heuristic)
|
|
53
|
+
source = "Project"
|
|
54
|
+
if str(path).startswith(str(Path.home() / ".monoco")):
|
|
55
|
+
source = "Global"
|
|
56
|
+
|
|
57
|
+
return ExecutionProfile(
|
|
58
|
+
name=path.parent.name,
|
|
59
|
+
source=source,
|
|
60
|
+
path=str(path.absolute()),
|
|
61
|
+
content=path.read_text(encoding='utf-8')
|
|
62
|
+
)
|
monoco/core/git.py
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
+
from typing import List, Tuple, Optional, Dict, Callable, Awaitable
|
|
2
|
+
import asyncio
|
|
3
|
+
import logging
|
|
1
4
|
import subprocess
|
|
2
|
-
import shutil
|
|
3
5
|
from pathlib import Path
|
|
4
|
-
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger("monoco.core.git")
|
|
5
8
|
|
|
6
9
|
def _run_git(args: List[str], cwd: Path) -> Tuple[int, str, str]:
|
|
7
10
|
"""Run a raw git command."""
|
|
@@ -182,3 +185,49 @@ def worktree_remove(path: Path, worktree_path: Path, force: bool = False):
|
|
|
182
185
|
code, _, stderr = _run_git(cmd, path)
|
|
183
186
|
if code != 0:
|
|
184
187
|
raise RuntimeError(f"Failed to remove worktree: {stderr}")
|
|
188
|
+
|
|
189
|
+
class GitMonitor:
|
|
190
|
+
"""
|
|
191
|
+
Polls the Git repository for HEAD changes and triggers updates.
|
|
192
|
+
"""
|
|
193
|
+
def __init__(self, path: Path, on_head_change: Callable[[str], Awaitable[None]], poll_interval: float = 2.0):
|
|
194
|
+
self.path = path
|
|
195
|
+
self.on_head_change = on_head_change
|
|
196
|
+
self.poll_interval = poll_interval
|
|
197
|
+
self.last_head_hash: Optional[str] = None
|
|
198
|
+
self.is_running = False
|
|
199
|
+
|
|
200
|
+
async def get_head_hash(self) -> Optional[str]:
|
|
201
|
+
try:
|
|
202
|
+
process = await asyncio.create_subprocess_exec(
|
|
203
|
+
"git", "rev-parse", "HEAD",
|
|
204
|
+
cwd=self.path,
|
|
205
|
+
stdout=asyncio.subprocess.PIPE,
|
|
206
|
+
stderr=asyncio.subprocess.PIPE
|
|
207
|
+
)
|
|
208
|
+
stdout, _ = await process.communicate()
|
|
209
|
+
if process.returncode == 0:
|
|
210
|
+
return stdout.decode().strip()
|
|
211
|
+
return None
|
|
212
|
+
except Exception as e:
|
|
213
|
+
logger.error(f"Git polling error: {e}")
|
|
214
|
+
return None
|
|
215
|
+
|
|
216
|
+
async def start(self):
|
|
217
|
+
self.is_running = True
|
|
218
|
+
logger.info(f"Git Monitor started for {self.path}.")
|
|
219
|
+
|
|
220
|
+
self.last_head_hash = await self.get_head_hash()
|
|
221
|
+
|
|
222
|
+
while self.is_running:
|
|
223
|
+
await asyncio.sleep(self.poll_interval)
|
|
224
|
+
current_hash = await self.get_head_hash()
|
|
225
|
+
|
|
226
|
+
if current_hash and current_hash != self.last_head_hash:
|
|
227
|
+
logger.info(f"Git HEAD changed: {self.last_head_hash} -> {current_hash}")
|
|
228
|
+
self.last_head_hash = current_hash
|
|
229
|
+
await self.on_head_change(current_hash)
|
|
230
|
+
|
|
231
|
+
def stop(self):
|
|
232
|
+
self.is_running = False
|
|
233
|
+
logger.info(f"Git Monitor stopping for {self.path}...")
|
monoco/core/output.py
CHANGED
|
@@ -24,8 +24,8 @@ class OutputManager:
|
|
|
24
24
|
"""
|
|
25
25
|
Check if running in Agent Mode.
|
|
26
26
|
Triggers:
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
1. Environment variable AGENT_FLAG=true (or 1)
|
|
28
|
+
2. Environment variable MONOCO_AGENT=true (or 1)
|
|
29
29
|
"""
|
|
30
30
|
return os.getenv("AGENT_FLAG", "").lower() in ("true", "1") or \
|
|
31
31
|
os.getenv("MONOCO_AGENT", "").lower() in ("true", "1")
|
|
@@ -40,6 +40,16 @@ class OutputManager:
|
|
|
40
40
|
else:
|
|
41
41
|
OutputManager._render_human(data, title)
|
|
42
42
|
|
|
43
|
+
@staticmethod
|
|
44
|
+
def error(message: str):
|
|
45
|
+
"""
|
|
46
|
+
Print error message.
|
|
47
|
+
"""
|
|
48
|
+
if OutputManager.is_agent_mode():
|
|
49
|
+
print(json.dumps({"error": message}))
|
|
50
|
+
else:
|
|
51
|
+
rprint(f"[bold red]Error:[/bold red] {message}")
|
|
52
|
+
|
|
43
53
|
@staticmethod
|
|
44
54
|
def _render_agent(data: Any):
|
|
45
55
|
"""
|
|
@@ -95,3 +105,4 @@ class OutputManager:
|
|
|
95
105
|
|
|
96
106
|
# Global helper
|
|
97
107
|
print_output = OutputManager.print
|
|
108
|
+
print_error = OutputManager.error
|
monoco/core/state.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import Optional, Any, Dict
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
logger = logging.getLogger("monoco.core.state")
|
|
8
|
+
|
|
9
|
+
class WorkspaceState(BaseModel):
|
|
10
|
+
"""
|
|
11
|
+
Persisted state for a Monoco workspace (collection of projects).
|
|
12
|
+
Stored in <workspace_root>/.monoco/state.json
|
|
13
|
+
"""
|
|
14
|
+
last_active_project_id: Optional[str] = None
|
|
15
|
+
|
|
16
|
+
@classmethod
|
|
17
|
+
def load(cls, workspace_root: Path) -> "WorkspaceState":
|
|
18
|
+
state_file = workspace_root / ".monoco" / "state.json"
|
|
19
|
+
if not state_file.exists():
|
|
20
|
+
return cls()
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
content = state_file.read_text(encoding='utf-8')
|
|
24
|
+
if not content.strip():
|
|
25
|
+
return cls()
|
|
26
|
+
data = json.loads(content)
|
|
27
|
+
return cls(**data)
|
|
28
|
+
except Exception as e:
|
|
29
|
+
logger.error(f"Failed to load workspace state from {state_file}: {e}")
|
|
30
|
+
return cls()
|
|
31
|
+
|
|
32
|
+
def save(self, workspace_root: Path):
|
|
33
|
+
state_file = workspace_root / ".monoco" / "state.json"
|
|
34
|
+
state_file.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
# We merge with existing on disk if possible to preserve unknown keys
|
|
38
|
+
current_data = {}
|
|
39
|
+
if state_file.exists():
|
|
40
|
+
try:
|
|
41
|
+
content = state_file.read_text(encoding='utf-8')
|
|
42
|
+
if content.strip():
|
|
43
|
+
current_data = json.loads(content)
|
|
44
|
+
except:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
new_data = self.model_dump(exclude_unset=True)
|
|
48
|
+
current_data.update(new_data)
|
|
49
|
+
|
|
50
|
+
state_file.write_text(json.dumps(current_data, indent=2), encoding='utf-8')
|
|
51
|
+
except Exception as e:
|
|
52
|
+
logger.error(f"Failed to save workspace state to {state_file}: {e}")
|
|
53
|
+
raise
|