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/core/git.py CHANGED
@@ -6,24 +6,23 @@ from pathlib import Path
6
6
 
7
7
  logger = logging.getLogger("monoco.core.git")
8
8
 
9
+
9
10
  def _run_git(args: List[str], cwd: Path) -> Tuple[int, str, str]:
10
11
  """Run a raw git command."""
11
12
  try:
12
13
  result = subprocess.run(
13
- ["git"] + args,
14
- cwd=cwd,
15
- capture_output=True,
16
- text=True,
17
- check=False
14
+ ["git"] + args, cwd=cwd, capture_output=True, text=True, check=False
18
15
  )
19
16
  return result.returncode, result.stdout, result.stderr
20
17
  except FileNotFoundError:
21
18
  return 1, "", "Git executable not found"
22
19
 
20
+
23
21
  def is_git_repo(path: Path) -> bool:
24
22
  code, _, _ = _run_git(["rev-parse", "--is-inside-work-tree"], path)
25
23
  return code == 0
26
24
 
25
+
27
26
  def get_git_status(path: Path, subpath: Optional[str] = None) -> List[str]:
28
27
  """
29
28
  Get list of modified files.
@@ -32,11 +31,11 @@ def get_git_status(path: Path, subpath: Optional[str] = None) -> List[str]:
32
31
  cmd = ["status", "--porcelain"]
33
32
  if subpath:
34
33
  cmd.append(subpath)
35
-
34
+
36
35
  code, stdout, _ = _run_git(cmd, path)
37
36
  if code != 0:
38
37
  raise RuntimeError("Failed to check git status")
39
-
38
+
40
39
  lines = []
41
40
  for line in stdout.splitlines():
42
41
  line = line.strip()
@@ -50,6 +49,7 @@ def get_git_status(path: Path, subpath: Optional[str] = None) -> List[str]:
50
49
  lines.append(path_str)
51
50
  return lines
52
51
 
52
+
53
53
  def git_add(path: Path, files: List[str]) -> None:
54
54
  if not files:
55
55
  return
@@ -57,43 +57,46 @@ def git_add(path: Path, files: List[str]) -> None:
57
57
  if code != 0:
58
58
  raise RuntimeError(f"Git add failed: {stderr}")
59
59
 
60
+
60
61
  def git_commit(path: Path, message: str) -> str:
61
62
  code, stdout, stderr = _run_git(["commit", "-m", message], path)
62
63
  if code != 0:
63
64
  raise RuntimeError(f"Git commit failed: {stderr}")
64
-
65
+
65
66
  code, hash_out, _ = _run_git(["rev-parse", "HEAD"], path)
66
67
  return hash_out.strip()
67
68
 
69
+
68
70
  def search_commits_by_message(path: Path, grep_pattern: str) -> List[Dict[str, str]]:
69
71
  cmd = ["log", f"--grep={grep_pattern}", "--name-only", "--format=COMMIT:%H|%s"]
70
72
  code, stdout, stderr = _run_git(cmd, path)
71
73
  if code != 0:
72
74
  raise RuntimeError(f"Git log failed: {stderr}")
73
-
75
+
74
76
  commits = []
75
77
  current_commit = None
76
-
78
+
77
79
  for line in stdout.splitlines():
78
80
  if line.startswith("COMMIT:"):
79
81
  if current_commit:
80
82
  commits.append(current_commit)
81
-
83
+
82
84
  parts = line[7:].split("|", 1)
83
85
  current_commit = {
84
86
  "hash": parts[0],
85
87
  "subject": parts[1] if len(parts) > 1 else "",
86
- "files": []
88
+ "files": [],
87
89
  }
88
90
  elif line.strip():
89
91
  if current_commit:
90
92
  current_commit["files"].append(line.strip())
91
-
93
+
92
94
  if current_commit:
93
95
  commits.append(current_commit)
94
-
96
+
95
97
  return commits
96
98
 
99
+
97
100
  def get_commit_stats(path: Path, commit_hash: str) -> Dict[str, int]:
98
101
  cmd = ["show", "--shortstat", "--format=", commit_hash]
99
102
  code, stdout, _ = _run_git(cmd, path)
@@ -110,87 +113,111 @@ def get_commit_stats(path: Path, commit_hash: str) -> Dict[str, int]:
110
113
  stats["deletions"] = int(p.split()[0])
111
114
  return stats
112
115
 
116
+
113
117
  # --- Branch & Worktree Extensions ---
114
118
 
119
+
115
120
  def get_current_branch(path: Path) -> str:
116
121
  code, stdout, _ = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], path)
117
122
  if code != 0:
118
123
  return ""
119
124
  return stdout.strip()
120
125
 
126
+
121
127
  def branch_exists(path: Path, branch_name: str) -> bool:
122
128
  code, _, _ = _run_git(["rev-parse", "--verify", branch_name], path)
123
129
  return code == 0
124
130
 
131
+
125
132
  def create_branch(path: Path, branch_name: str, checkout: bool = False):
126
133
  cmd = ["checkout", "-b", branch_name] if checkout else ["branch", branch_name]
127
134
  code, _, stderr = _run_git(cmd, path)
128
135
  if code != 0:
129
136
  raise RuntimeError(f"Failed to create branch {branch_name}: {stderr}")
130
137
 
138
+
131
139
  def checkout_branch(path: Path, branch_name: str):
132
140
  code, _, stderr = _run_git(["checkout", branch_name], path)
133
141
  if code != 0:
134
142
  raise RuntimeError(f"Failed to checkout {branch_name}: {stderr}")
135
143
 
144
+
136
145
  def delete_branch(path: Path, branch_name: str, force: bool = False):
137
146
  flag = "-D" if force else "-d"
138
147
  code, _, stderr = _run_git(["branch", flag, branch_name], path)
139
148
  if code != 0:
140
149
  raise RuntimeError(f"Failed to delete branch {branch_name}: {stderr}")
141
150
 
151
+
142
152
  def get_worktrees(path: Path) -> List[Tuple[str, str, str]]:
143
153
  """Returns list of (path, head, branch)"""
144
154
  code, stdout, stderr = _run_git(["worktree", "list", "--porcelain"], path)
145
155
  if code != 0:
146
156
  raise RuntimeError(f"Failed to list worktrees: {stderr}")
147
-
157
+
148
158
  trees = []
149
159
  current = {}
150
160
  for line in stdout.splitlines():
151
161
  if line.startswith("worktree "):
152
162
  if current:
153
- trees.append((current.get("worktree"), current.get("HEAD"), current.get("branch")))
163
+ trees.append(
164
+ (
165
+ current.get("worktree"),
166
+ current.get("HEAD"),
167
+ current.get("branch"),
168
+ )
169
+ )
154
170
  current = {"worktree": line[9:].strip()}
155
171
  elif line.startswith("HEAD "):
156
172
  current["HEAD"] = line[5:].strip()
157
173
  elif line.startswith("branch "):
158
174
  current["branch"] = line[7:].strip()
159
-
175
+
160
176
  if current:
161
- trees.append((current.get("worktree"), current.get("HEAD"), current.get("branch")))
177
+ trees.append(
178
+ (current.get("worktree"), current.get("HEAD"), current.get("branch"))
179
+ )
162
180
  return trees
163
181
 
182
+
164
183
  def worktree_add(path: Path, branch_name: str, worktree_path: Path):
165
- # If branch doesn't exist, -b will create it.
184
+ # If branch doesn't exist, -b will create it.
166
185
  # Logic: git worktree add [-b <new_branch>] <path> <commit-ish>
167
-
186
+
168
187
  # We assume if branch_exists, use it. If not, create it.
169
188
  cmd = ["worktree", "add"]
170
189
  if not branch_exists(path, branch_name):
171
190
  cmd.extend(["-b", branch_name])
172
-
191
+
173
192
  cmd.extend([str(worktree_path), branch_name])
174
-
193
+
175
194
  code, _, stderr = _run_git(cmd, path)
176
195
  if code != 0:
177
196
  raise RuntimeError(f"Failed to create worktree: {stderr}")
178
197
 
198
+
179
199
  def worktree_remove(path: Path, worktree_path: Path, force: bool = False):
180
200
  cmd = ["worktree", "remove"]
181
201
  if force:
182
202
  cmd.append("--force")
183
203
  cmd.append(str(worktree_path))
184
-
204
+
185
205
  code, _, stderr = _run_git(cmd, path)
186
206
  if code != 0:
187
207
  raise RuntimeError(f"Failed to remove worktree: {stderr}")
188
208
 
209
+
189
210
  class GitMonitor:
190
211
  """
191
212
  Polls the Git repository for HEAD changes and triggers updates.
192
213
  """
193
- def __init__(self, path: Path, on_head_change: Callable[[str], Awaitable[None]], poll_interval: float = 2.0):
214
+
215
+ def __init__(
216
+ self,
217
+ path: Path,
218
+ on_head_change: Callable[[str], Awaitable[None]],
219
+ poll_interval: float = 2.0,
220
+ ):
194
221
  self.path = path
195
222
  self.on_head_change = on_head_change
196
223
  self.poll_interval = poll_interval
@@ -200,10 +227,12 @@ class GitMonitor:
200
227
  async def get_head_hash(self) -> Optional[str]:
201
228
  try:
202
229
  process = await asyncio.create_subprocess_exec(
203
- "git", "rev-parse", "HEAD",
230
+ "git",
231
+ "rev-parse",
232
+ "HEAD",
204
233
  cwd=self.path,
205
234
  stdout=asyncio.subprocess.PIPE,
206
- stderr=asyncio.subprocess.PIPE
235
+ stderr=asyncio.subprocess.PIPE,
207
236
  )
208
237
  stdout, _ = await process.communicate()
209
238
  if process.returncode == 0:
@@ -216,15 +245,17 @@ class GitMonitor:
216
245
  async def start(self):
217
246
  self.is_running = True
218
247
  logger.info(f"Git Monitor started for {self.path}.")
219
-
248
+
220
249
  self.last_head_hash = await self.get_head_hash()
221
-
250
+
222
251
  while self.is_running:
223
252
  await asyncio.sleep(self.poll_interval)
224
253
  current_hash = await self.get_head_hash()
225
-
254
+
226
255
  if current_hash and current_hash != self.last_head_hash:
227
- logger.info(f"Git HEAD changed: {self.last_head_hash} -> {current_hash}")
256
+ logger.info(
257
+ f"Git HEAD changed: {self.last_head_hash} -> {current_hash}"
258
+ )
228
259
  self.last_head_hash = current_hash
229
260
  await self.on_head_change(current_hash)
230
261
 
monoco/core/hooks.py ADDED
@@ -0,0 +1,57 @@
1
+ import os
2
+ from pathlib import Path
3
+ from typing import Dict
4
+ from rich.console import Console
5
+
6
+ console = Console()
7
+
8
+
9
+ def install_hooks(project_root: Path, hooks: Dict[str, str]):
10
+ """
11
+ Install git hooks based on configuration.
12
+ """
13
+ git_dir = project_root / ".git"
14
+ if not git_dir.exists():
15
+ console.print("[dim]Skipping hooks installation: Not a git repository.[/dim]")
16
+ return
17
+
18
+ hooks_dir = git_dir / "hooks"
19
+ hooks_dir.mkdir(exist_ok=True)
20
+
21
+ for hook_name, command in hooks.items():
22
+ hook_path = hooks_dir / hook_name
23
+
24
+ # Check if exists
25
+ if hook_path.exists():
26
+ # Check if it was generated by us
27
+ try:
28
+ with open(hook_path, "r") as f:
29
+ first_line = f.readline()
30
+ if "Monoco Hook" not in first_line:
31
+ console.print(
32
+ f"[yellow]Warning: Hook '{hook_name}' already exists and is not managed by Monoco. Skipping.[/yellow]"
33
+ )
34
+ continue
35
+ # If it IS managed by us, we overwrite it to update the command
36
+ except Exception:
37
+ console.print(
38
+ f"[yellow]Warning: Hook '{hook_name}' exists and cannot be read. Skipping.[/yellow]"
39
+ )
40
+ continue
41
+
42
+ content = f"""#!/bin/sh
43
+ # Monoco Hook: {hook_name}
44
+ # Auto-generated by Monoco Toolkit. Do not edit manually.
45
+
46
+ {command}
47
+ exit $?
48
+ """
49
+ try:
50
+ with open(hook_path, "w") as f:
51
+ f.write(content)
52
+
53
+ # Make executable
54
+ os.chmod(hook_path, 0o755)
55
+ console.print(f"[green]✓ Installed hook '{hook_name}'[/green]")
56
+ except Exception as e:
57
+ console.print(f"[red]Failed to install hook '{hook_name}': {e}[/red]")
monoco/core/injection.py CHANGED
@@ -1,6 +1,7 @@
1
1
  import re
2
2
  from pathlib import Path
3
- from typing import Dict, List, Optional
3
+ from typing import Dict
4
+
4
5
 
5
6
  class PromptInjector:
6
7
  """
@@ -9,17 +10,17 @@ class PromptInjector:
9
10
  """
10
11
 
11
12
  MANAGED_HEADER = "## Monoco Toolkit"
12
-
13
+
13
14
  def __init__(self, target_file: Path):
14
15
  self.target_file = target_file
15
16
 
16
17
  def inject(self, prompts: Dict[str, str]) -> bool:
17
18
  """
18
19
  Injects the provided prompts into the target file.
19
-
20
+
20
21
  Args:
21
22
  prompts: A dictionary where key is the section title and value is the content.
22
-
23
+
23
24
  Returns:
24
25
  True if changes were written, False otherwise.
25
26
  """
@@ -28,7 +29,7 @@ class PromptInjector:
28
29
  current_content = self.target_file.read_text(encoding="utf-8")
29
30
 
30
31
  new_content = self._merge_content(current_content, prompts)
31
-
32
+
32
33
  if new_content != current_content:
33
34
  self.target_file.write_text(new_content, encoding="utf-8")
34
35
  return True
@@ -40,24 +41,26 @@ class PromptInjector:
40
41
  """
41
42
  # 1. Generate the new managed block content
42
43
  managed_block = [self.MANAGED_HEADER, ""]
43
- managed_block.append("> **Auto-Generated**: This section is managed by Monoco. Do not edit manually.\n")
44
-
44
+ managed_block.append(
45
+ "> **Auto-Generated**: This section is managed by Monoco. Do not edit manually.\n"
46
+ )
47
+
45
48
  for title, content in prompts.items():
46
49
  managed_block.append(f"### {title}")
47
- managed_block.append("") # Blank line after header
48
-
50
+ managed_block.append("") # Blank line after header
51
+
49
52
  # Sanitize content: remove leading header if it matches the title
50
53
  clean_content = content.strip()
51
54
  # Regex to match optional leading hash header matching the title (case insensitive)
52
55
  # e.g. "### Issue Management" or "# Issue Management"
53
56
  pattern = r"^(#+\s*)" + re.escape(title) + r"\s*\n"
54
57
  match = re.match(pattern, clean_content, re.IGNORECASE)
55
-
58
+
56
59
  if match:
57
- clean_content = clean_content[match.end():].strip()
58
-
60
+ clean_content = clean_content[match.end() :].strip()
61
+
59
62
  managed_block.append(clean_content)
60
- managed_block.append("") # Blank line after section
63
+ managed_block.append("") # Blank line after section
61
64
 
62
65
  managed_block_str = "\n".join(managed_block).strip() + "\n"
63
66
 
@@ -65,13 +68,13 @@ class PromptInjector:
65
68
  lines = original.splitlines()
66
69
  start_idx = -1
67
70
  end_idx = -1
68
-
71
+
69
72
  # Find start
70
73
  for i, line in enumerate(lines):
71
74
  if line.strip() == self.MANAGED_HEADER:
72
75
  start_idx = i
73
76
  break
74
-
77
+
75
78
  if start_idx == -1:
76
79
  # Block not found, append to end
77
80
  if original and not original.endswith("\n"):
@@ -85,44 +88,44 @@ class PromptInjector:
85
88
  # Or EOF
86
89
  # Note: If MANAGED_HEADER is "# ...", we look for next "# ..."
87
90
  # But allow "## ..." as children.
88
-
91
+
89
92
  header_level_match = re.match(r"^(#+)\s", self.MANAGED_HEADER)
90
93
  header_level_prefix = header_level_match.group(1) if header_level_match else "#"
91
-
94
+
92
95
  for i in range(start_idx + 1, len(lines)):
93
96
  line = lines[i]
94
97
  # Check if this line is a header of the same level or higher (fewer #s)
95
98
  # e.g. if Managed is "###", then "#" and "##" are higher/parents, "###" is sibling.
96
99
  # We treat siblings as end of block too.
97
100
  if line.startswith("#"):
98
- # Match regex to get level
99
- match = re.match(r"^(#+)\s", line)
100
- if match:
101
- level = match.group(1)
102
- if len(level) <= len(header_level_prefix):
103
- end_idx = i
104
- break
105
-
101
+ # Match regex to get level
102
+ match = re.match(r"^(#+)\s", line)
103
+ if match:
104
+ level = match.group(1)
105
+ if len(level) <= len(header_level_prefix):
106
+ end_idx = i
107
+ break
108
+
106
109
  if end_idx == -1:
107
110
  end_idx = len(lines)
108
111
 
109
112
  # 3. Construct result
110
113
  pre_block = "\n".join(lines[:start_idx])
111
114
  post_block = "\n".join(lines[end_idx:])
112
-
115
+
113
116
  result = pre_block
114
117
  if result:
115
- result += "\n\n"
116
-
118
+ result += "\n\n"
119
+
117
120
  result += managed_block_str
118
-
121
+
119
122
  if post_block:
120
123
  # Ensure separation if post block exists and isn't just empty lines
121
124
  if post_block.strip():
122
125
  result += "\n" + post_block
123
126
  else:
124
- result += post_block # Keep trailing newlines if any, or normalize?
125
-
127
+ result += post_block # Keep trailing newlines if any, or normalize?
128
+
126
129
  return result.strip() + "\n"
127
130
 
128
131
  def remove(self) -> bool:
@@ -157,23 +160,23 @@ class PromptInjector:
157
160
  for i in range(start_idx + 1, len(lines)):
158
161
  line = lines[i]
159
162
  if line.startswith("#"):
160
- match = re.match(r"^(#+)\s", line)
161
- if match:
162
- level = match.group(1)
163
- if len(level) <= len(header_level_prefix):
164
- end_idx = i
165
- break
166
-
163
+ match = re.match(r"^(#+)\s", line)
164
+ if match:
165
+ level = match.group(1)
166
+ if len(level) <= len(header_level_prefix):
167
+ end_idx = i
168
+ break
169
+
167
170
  if end_idx == -1:
168
171
  end_idx = len(lines)
169
172
 
170
173
  # Reconstruct content without the block
171
174
  # We also need to be careful about surrounding newlines to avoid leaving gaps
172
-
175
+
173
176
  # Check lines before start_idx
174
- while start_idx > 0 and not lines[start_idx-1].strip():
177
+ while start_idx > 0 and not lines[start_idx - 1].strip():
175
178
  start_idx -= 1
176
-
179
+
177
180
  # Check lines after end_idx (optional, but good for cleanup)
178
181
  # Usually end_idx points to the next header or EOF.
179
182
  # If it points to next header, we keep it.
@@ -182,12 +185,12 @@ class PromptInjector:
182
185
  post_block = lines[end_idx:]
183
186
 
184
187
  # If we removed everything, the file might become empty or just newlines
185
-
188
+
186
189
  new_lines = pre_block + post_block
187
190
  if not new_lines:
188
- new_content = ""
191
+ new_content = ""
189
192
  else:
190
- new_content = "\n".join(new_lines).strip() + "\n"
193
+ new_content = "\n".join(new_lines).strip() + "\n"
191
194
 
192
195
  if new_content != current_content:
193
196
  self.target_file.write_text(new_content, encoding="utf-8")