monoco-toolkit 0.2.5__py3-none-any.whl → 0.2.8__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 (42) hide show
  1. monoco/core/agent/adapters.py +24 -1
  2. monoco/core/config.py +77 -17
  3. monoco/core/integrations.py +8 -0
  4. monoco/core/lsp.py +7 -0
  5. monoco/core/output.py +8 -1
  6. monoco/core/resources/zh/SKILL.md +6 -7
  7. monoco/core/setup.py +8 -0
  8. monoco/features/i18n/resources/zh/SKILL.md +5 -5
  9. monoco/features/issue/commands.py +179 -55
  10. monoco/features/issue/core.py +263 -124
  11. monoco/features/issue/domain/__init__.py +0 -0
  12. monoco/features/issue/domain/lifecycle.py +126 -0
  13. monoco/features/issue/domain/models.py +170 -0
  14. monoco/features/issue/domain/parser.py +223 -0
  15. monoco/features/issue/domain/workspace.py +104 -0
  16. monoco/features/issue/engine/__init__.py +22 -0
  17. monoco/features/issue/engine/config.py +172 -0
  18. monoco/features/issue/engine/machine.py +185 -0
  19. monoco/features/issue/engine/models.py +18 -0
  20. monoco/features/issue/linter.py +118 -12
  21. monoco/features/issue/lsp/__init__.py +3 -0
  22. monoco/features/issue/lsp/definition.py +72 -0
  23. monoco/features/issue/models.py +27 -9
  24. monoco/features/issue/resources/en/AGENTS.md +5 -0
  25. monoco/features/issue/resources/en/SKILL.md +26 -2
  26. monoco/features/issue/resources/zh/AGENTS.md +5 -0
  27. monoco/features/issue/resources/zh/SKILL.md +34 -10
  28. monoco/features/issue/validator.py +252 -66
  29. monoco/features/spike/core.py +5 -22
  30. monoco/features/spike/resources/zh/SKILL.md +2 -2
  31. monoco/main.py +2 -26
  32. monoco_toolkit-0.2.8.dist-info/METADATA +136 -0
  33. {monoco_toolkit-0.2.5.dist-info → monoco_toolkit-0.2.8.dist-info}/RECORD +36 -30
  34. monoco/features/agent/commands.py +0 -166
  35. monoco/features/agent/doctor.py +0 -30
  36. monoco/features/pty/core.py +0 -185
  37. monoco/features/pty/router.py +0 -138
  38. monoco/features/pty/server.py +0 -56
  39. monoco_toolkit-0.2.5.dist-info/METADATA +0 -93
  40. {monoco_toolkit-0.2.5.dist-info → monoco_toolkit-0.2.8.dist-info}/WHEEL +0 -0
  41. {monoco_toolkit-0.2.5.dist-info → monoco_toolkit-0.2.8.dist-info}/entry_points.txt +0 -0
  42. {monoco_toolkit-0.2.5.dist-info → monoco_toolkit-0.2.8.dist-info}/licenses/LICENSE +0 -0
@@ -14,16 +14,18 @@ from . import core
14
14
 
15
15
  app = typer.Typer(help="Agent-Native Issue Management.")
16
16
  backlog_app = typer.Typer(help="Manage backlog operations.")
17
+ lsp_app = typer.Typer(help="LSP Server commands.")
17
18
  app.add_typer(backlog_app, name="backlog")
19
+ app.add_typer(lsp_app, name="lsp")
18
20
  console = Console()
19
21
 
20
22
  @app.command("create")
21
23
  def create(
22
- type: IssueType = typer.Argument(..., help="Issue type (epic, feature, chore, fix)"),
24
+ type: str = typer.Argument(..., help="Issue type (epic, feature, chore, fix, etc.)"),
23
25
  title: str = typer.Option(..., "--title", "-t", help="Issue title"),
24
26
  parent: Optional[str] = typer.Option(None, "--parent", "-p", help="Parent Issue ID"),
25
27
  is_backlog: bool = typer.Option(False, "--backlog", help="Create as backlog item"),
26
- stage: Optional[IssueStage] = typer.Option(None, "--stage", help="Issue stage (draft, doing, review)"),
28
+ stage: Optional[str] = typer.Option(None, "--stage", help="Issue stage"),
27
29
  dependencies: List[str] = typer.Option([], "--dependency", "-d", help="Issue dependency ID(s)"),
28
30
  related: List[str] = typer.Option([], "--related", "-r", help="Related Issue ID(s)"),
29
31
  subdir: Optional[str] = typer.Option(None, "--subdir", "-s", help="Subdirectory for organization (e.g. 'Backend/Auth')"),
@@ -32,11 +34,19 @@ def create(
32
34
  root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
33
35
  json: AgentOutput = False,
34
36
  ):
37
+ """Create a new issue."""
35
38
  """Create a new issue."""
36
39
  config = get_config()
37
40
  issues_root = _resolve_issues_root(config, root)
38
- status = IssueStatus.BACKLOG if is_backlog else IssueStatus.OPEN
41
+ status = "backlog" if is_backlog else "open"
42
+
43
+ # Sanitize inputs (strip #)
44
+ if parent and parent.startswith("#"):
45
+ parent = parent[1:]
39
46
 
47
+ dependencies = [d[1:] if d.startswith("#") else d for d in dependencies]
48
+ related = [r[1:] if r.startswith("#") else r for r in related]
49
+
40
50
  if parent:
41
51
  parent_path = core.find_issue_path(issues_root, parent)
42
52
  if not parent_path:
@@ -63,11 +73,15 @@ def create(
63
73
  except ValueError:
64
74
  rel_path = path
65
75
 
66
- OutputManager.print({
67
- "issue": issue,
68
- "path": str(rel_path),
69
- "status": "created"
70
- })
76
+ if OutputManager.is_agent_mode():
77
+ OutputManager.print({
78
+ "issue": issue,
79
+ "path": str(rel_path),
80
+ "status": "created"
81
+ })
82
+ else:
83
+ console.print(f"[green]✔ Created {issue.id} in status {issue.status}.[/green]")
84
+ console.print(f"Path: {rel_path}")
71
85
 
72
86
  except ValueError as e:
73
87
  OutputManager.error(str(e))
@@ -77,8 +91,8 @@ def create(
77
91
  def update(
78
92
  issue_id: str = typer.Argument(..., help="Issue ID to update"),
79
93
  title: Optional[str] = typer.Option(None, "--title", "-t", help="New title"),
80
- status: Optional[IssueStatus] = typer.Option(None, "--status", help="New status"),
81
- stage: Optional[IssueStage] = typer.Option(None, "--stage", help="New stage"),
94
+ status: Optional[str] = typer.Option(None, "--status", help="New status"),
95
+ stage: Optional[str] = typer.Option(None, "--stage", help="New stage"),
82
96
  parent: Optional[str] = typer.Option(None, "--parent", "-p", help="Parent Issue ID"),
83
97
  sprint: Optional[str] = typer.Option(None, "--sprint", help="Sprint ID"),
84
98
  dependencies: Optional[List[str]] = typer.Option(None, "--dependency", "-d", help="Issue dependency ID(s)"),
@@ -125,7 +139,7 @@ def move_open(
125
139
  issues_root = _resolve_issues_root(config, root)
126
140
  try:
127
141
  # Pull operation: Force stage to TODO
128
- issue = core.update_issue(issues_root, issue_id, status=IssueStatus.OPEN, stage=IssueStage.DRAFT)
142
+ issue = core.update_issue(issues_root, issue_id, status="open", stage="draft")
129
143
  OutputManager.print({
130
144
  "issue": issue,
131
145
  "status": "opened"
@@ -153,13 +167,13 @@ def start(
153
167
 
154
168
  try:
155
169
  # Implicitly ensure status is Open
156
- issue = core.update_issue(issues_root, issue_id, status=IssueStatus.OPEN, stage=IssueStage.DOING)
170
+ issue = core.update_issue(issues_root, issue_id, status="open", stage="doing")
157
171
 
158
172
  isolation_info = None
159
173
 
160
174
  if branch:
161
175
  try:
162
- issue = core.start_issue_isolation(issues_root, issue_id, IsolationType.BRANCH, project_root)
176
+ issue = core.start_issue_isolation(issues_root, issue_id, "branch", project_root)
163
177
  isolation_info = {"type": "branch", "ref": issue.isolation.ref}
164
178
  except Exception as e:
165
179
  OutputManager.error(f"Failed to create branch: {e}")
@@ -167,7 +181,7 @@ def start(
167
181
 
168
182
  if worktree:
169
183
  try:
170
- issue = core.start_issue_isolation(issues_root, issue_id, IsolationType.WORKTREE, project_root)
184
+ issue = core.start_issue_isolation(issues_root, issue_id, "worktree", project_root)
171
185
  isolation_info = {"type": "worktree", "path": issue.isolation.path, "ref": issue.isolation.ref}
172
186
  except Exception as e:
173
187
  OutputManager.error(f"Failed to create worktree: {e}")
@@ -196,7 +210,7 @@ def submit(
196
210
  project_root = _resolve_project_root(config)
197
211
  try:
198
212
  # Implicitly ensure status is Open
199
- issue = core.update_issue(issues_root, issue_id, status=IssueStatus.OPEN, stage=IssueStage.REVIEW)
213
+ issue = core.update_issue(issues_root, issue_id, status="open", stage="review")
200
214
 
201
215
  # Delivery Report Generation
202
216
  report_status = "skipped"
@@ -228,7 +242,7 @@ def submit(
228
242
  @app.command("close")
229
243
  def move_close(
230
244
  issue_id: str = typer.Argument(..., help="Issue ID to close"),
231
- solution: Optional[IssueSolution] = typer.Option(None, "--solution", "-s", help="Solution type"),
245
+ solution: Optional[str] = typer.Option(None, "--solution", "-s", help="Solution type"),
232
246
  prune: bool = typer.Option(False, "--prune", help="Delete branch/worktree after close"),
233
247
  force: bool = typer.Option(False, "--force", help="Force delete branch/worktree"),
234
248
  root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
@@ -241,12 +255,15 @@ def move_close(
241
255
 
242
256
  # Pre-flight check for interactive guidance (Requirement FEAT-0082 #6)
243
257
  if solution is None:
244
- valid_solutions = [e.value for e in IssueSolution]
258
+ # Resolve options from engine
259
+ from .engine import get_engine
260
+ engine = get_engine(str(issues_root.parent))
261
+ valid_solutions = engine.issue_config.solutions or []
245
262
  OutputManager.error(f"Closing an issue requires a solution. Options: {', '.join(valid_solutions)}")
246
263
  raise typer.Exit(code=1)
247
264
 
248
265
  try:
249
- issue = core.update_issue(issues_root, issue_id, status=IssueStatus.CLOSED, solution=solution)
266
+ issue = core.update_issue(issues_root, issue_id, status="closed", solution=solution)
250
267
 
251
268
  pruned_resources = []
252
269
  if prune:
@@ -276,7 +293,7 @@ def push(
276
293
  config = get_config()
277
294
  issues_root = _resolve_issues_root(config, root)
278
295
  try:
279
- issue = core.update_issue(issues_root, issue_id, status=IssueStatus.BACKLOG)
296
+ issue = core.update_issue(issues_root, issue_id, status="backlog")
280
297
  OutputManager.print({
281
298
  "issue": issue,
282
299
  "status": "pushed_to_backlog"
@@ -295,7 +312,7 @@ def pull(
295
312
  config = get_config()
296
313
  issues_root = _resolve_issues_root(config, root)
297
314
  try:
298
- issue = core.update_issue(issues_root, issue_id, status=IssueStatus.OPEN, stage=IssueStage.DRAFT)
315
+ issue = core.update_issue(issues_root, issue_id, status="open", stage="draft")
299
316
  OutputManager.print({
300
317
  "issue": issue,
301
318
  "status": "pulled_from_backlog"
@@ -314,7 +331,7 @@ def cancel(
314
331
  config = get_config()
315
332
  issues_root = _resolve_issues_root(config, root)
316
333
  try:
317
- issue = core.update_issue(issues_root, issue_id, status=IssueStatus.CLOSED, solution=IssueSolution.CANCELLED)
334
+ issue = core.update_issue(issues_root, issue_id, status="closed", solution="cancelled")
318
335
  OutputManager.print({
319
336
  "issue": issue,
320
337
  "status": "cancelled"
@@ -427,10 +444,10 @@ def board(
427
444
  issue_list = []
428
445
  for issue in sorted(issues, key=lambda x: x.updated_at, reverse=True):
429
446
  type_color = {
430
- IssueType.FEATURE: "green",
431
- IssueType.CHORE: "blue",
432
- IssueType.FIX: "red",
433
- IssueType.EPIC: "magenta"
447
+ "feature": "green",
448
+ "chore": "blue",
449
+ "fix": "red",
450
+ "epic": "magenta"
434
451
  }.get(issue.type, "white")
435
452
 
436
453
  issue_list.append(
@@ -458,8 +475,8 @@ def board(
458
475
  @app.command("list")
459
476
  def list_cmd(
460
477
  status: Optional[str] = typer.Option(None, "--status", "-s", help="Filter by status (open, closed, backlog, all)"),
461
- type: Optional[IssueType] = typer.Option(None, "--type", "-t", help="Filter by type"),
462
- stage: Optional[IssueStage] = typer.Option(None, "--stage", help="Filter by stage"),
478
+ type: Optional[str] = typer.Option(None, "--type", "-t", help="Filter by type"),
479
+ stage: Optional[str] = typer.Option(None, "--stage", help="Filter by stage"),
463
480
  root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
464
481
  workspace: bool = typer.Option(False, "--workspace", "-w", help="Include issues from workspace members"),
465
482
  json: AgentOutput = False,
@@ -481,7 +498,7 @@ def list_cmd(
481
498
  for i in issues:
482
499
  # Status Filter
483
500
  if target_status != "all":
484
- if i.status.value != target_status:
501
+ if i.status != target_status:
485
502
  continue
486
503
 
487
504
  # Type Filter
@@ -530,13 +547,13 @@ def _render_issues_table(issues: List[IssueMetadata], title: str = "Issues"):
530
547
  t_color = type_colors.get(i.type, "white")
531
548
  s_color = status_colors.get(i.status, "white")
532
549
 
533
- stage_str = i.stage.value if i.stage else "-"
550
+ stage_str = i.stage if i.stage else "-"
534
551
  updated_str = i.updated_at.strftime("%Y-%m-%d %H:%M")
535
552
 
536
553
  table.add_row(
537
554
  i.id,
538
- f"[{t_color}]{i.type.value}[/{t_color}]",
539
- f"[{s_color}]{i.status.value}[/{s_color}]",
555
+ f"[{t_color}]{i.type}[/{t_color}]",
556
+ f"[{s_color}]{i.status}[/{s_color}]",
540
557
  stage_str,
541
558
  i.title,
542
559
  updated_str
@@ -605,11 +622,11 @@ def scope(
605
622
  return
606
623
 
607
624
  tree = Tree(f"[bold blue]Monoco Issue Scope[/bold blue]")
608
- epics = sorted([i for i in issues if i.type == IssueType.EPIC], key=lambda x: x.id)
609
- stories = [i for i in issues if i.type == IssueType.FEATURE]
610
- tasks = [i for i in issues if i.type in [IssueType.CHORE, IssueType.FIX]]
625
+ epics = sorted([i for i in issues if i.type == "epic"], key=lambda x: x.id)
626
+ stories = [i for i in issues if i.type == "feature"]
627
+ tasks = [i for i in issues if i.type in ["chore", "fix"]]
611
628
 
612
- status_map = {IssueStatus.OPEN: "[blue]●[/blue]", IssueStatus.CLOSED: "[green]✔[/green]", IssueStatus.BACKLOG: "[dim]💤[/dim]"}
629
+ status_map = {"open": "[blue]●[/blue]", "closed": "[green]✔[/green]", "backlog": "[dim]💤[/dim]"}
613
630
 
614
631
  for epic in epics:
615
632
  epic_node = tree.add(f"{status_map[epic.status]} [bold]{epic.id}[/bold]: {epic.title}")
@@ -622,6 +639,94 @@ def scope(
622
639
 
623
640
  console.print(Panel(tree, expand=False))
624
641
 
642
+ @app.command("sync-files")
643
+ def sync_files(
644
+ issue_id: Optional[str] = typer.Argument(None, help="Issue ID to sync (default: current context)"),
645
+ root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
646
+ json: AgentOutput = False,
647
+ ):
648
+ """
649
+ Sync issue 'files' field with git changed files.
650
+ """
651
+ config = get_config()
652
+ issues_root = _resolve_issues_root(config, root)
653
+ project_root = _resolve_project_root(config)
654
+
655
+ if not issue_id:
656
+ # Infer from branch
657
+ from monoco.core import git
658
+ current = git.get_current_branch(project_root)
659
+ # Try to parse ID from branch "feat/issue-123-slug"
660
+ import re
661
+ match = re.search(r"(?:feat|fix|chore|epic)/([a-zA-Z]+-\d+)", current)
662
+ if match:
663
+ issue_id = match.group(1).upper()
664
+ else:
665
+ OutputManager.error("Cannot infer Issue ID from current branch. Please specify Issue ID.")
666
+ raise typer.Exit(code=1)
667
+
668
+ try:
669
+ changed = core.sync_issue_files(issues_root, issue_id, project_root)
670
+ OutputManager.print({
671
+ "id": issue_id,
672
+ "status": "synced",
673
+ "files": changed
674
+ })
675
+ except Exception as e:
676
+ OutputManager.error(str(e))
677
+ raise typer.Exit(code=1)
678
+
679
+ @app.command("inspect")
680
+ def inspect(
681
+ target: str = typer.Argument(..., help="Issue ID or File Path"),
682
+ root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
683
+ ast: bool = typer.Option(False, "--ast", help="Output JSON AST structure for debugging"),
684
+ json: AgentOutput = False,
685
+ ):
686
+ """
687
+ Inspect a specific issue and return its metadata (including actions).
688
+ """
689
+ config = get_config()
690
+ issues_root = _resolve_issues_root(config, root)
691
+
692
+ # Try as Path
693
+ target_path = Path(target)
694
+ if target_path.exists() and target_path.is_file():
695
+ path = target_path
696
+ else:
697
+ # Try as ID
698
+ # Search path logic is needed? Or core.find_issue_path
699
+ path = core.find_issue_path(issues_root, target)
700
+ if not path:
701
+ OutputManager.error(f"Issue or file {target} not found.")
702
+ raise typer.Exit(code=1)
703
+
704
+ # AST Debug Mode
705
+ if ast:
706
+ from .domain.parser import MarkdownParser
707
+ content = path.read_text()
708
+ try:
709
+ domain_issue = MarkdownParser.parse(content, path=str(path))
710
+ print(domain_issue.model_dump_json(indent=2))
711
+ except Exception as e:
712
+ OutputManager.error(f"Failed to parse AST: {e}")
713
+ raise typer.Exit(code=1)
714
+ return
715
+
716
+ # Normal Mode
717
+ meta = core.parse_issue(path)
718
+
719
+ if not meta:
720
+ OutputManager.error(f"Could not parse issue {target}.")
721
+ raise typer.Exit(code=1)
722
+
723
+ # In JSON mode (AgentOutput), we might want to return rich data
724
+ if OutputManager.is_agent_mode():
725
+ OutputManager.print(meta)
726
+ else:
727
+ # For human, print yaml-like or table
728
+ console.print(meta)
729
+
625
730
  @app.command("lint")
626
731
  def lint(
627
732
  recursive: bool = typer.Option(False, "--recursive", "-r", help="Recursively scan subdirectories"),
@@ -662,27 +767,9 @@ def _resolve_issues_root(config, cli_root: Optional[str]) -> Path:
662
767
  return path
663
768
 
664
769
  # 2. Handle Default / Contextual Execution (No --root)
665
- # We need to detect if we are in a Workspace Root with multiple projects
770
+ # Strict Workspace Check: If not in a project root, we rely on the config root.
771
+ # (The global app callback already enforces presence of .monoco for most commands)
666
772
  cwd = Path.cwd()
667
-
668
- # If CWD is NOT a project root (no .monoco/), scan for subprojects
669
- if not is_project_root(cwd):
670
- subprojects = find_projects(cwd)
671
- if len(subprojects) > 1:
672
- console.print(f"[yellow]Workspace detected with {len(subprojects)} projects:[/yellow]")
673
- for p in subprojects:
674
- console.print(f" - [bold]{p.name}[/bold]")
675
- console.print("\n[yellow]Please specify a project using --root <PATH>.[/yellow]")
676
- # We don't exit here strictly, but usually this means we can't find 'Issues' in CWD anyway
677
- # so the config fallbacks below will likely fail or point to non-existent CWD/Issues.
678
- # But let's fail fast to be helpful.
679
- raise typer.Exit(code=1)
680
- elif len(subprojects) == 1:
681
- # Auto-select the only child project?
682
- # It's safer to require explicit intent, but let's try to be helpful if it's obvious.
683
- # However, standard behavior is usually "operate on current dir".
684
- # Let's stick to standard config resolution, but maybe warn.
685
- pass
686
773
 
687
774
  # 3. Config Fallback
688
775
  config_issues_path = Path(config.paths.issues)
@@ -826,3 +913,40 @@ def commit(
826
913
  except Exception as e:
827
914
  console.print(f"[red]Git Error:[/red] {e}")
828
915
  raise typer.Exit(code=1)
916
+
917
+ @lsp_app.command("definition")
918
+ def lsp_definition(
919
+ file: str = typer.Option(..., "--file", "-f", help="Abs path to file"),
920
+ line: int = typer.Option(..., "--line", "-l", help="0-indexed line number"),
921
+ character: int = typer.Option(..., "--char", "-c", help="0-indexed character number"),
922
+ ):
923
+ """
924
+ Handle textDocument/definition request.
925
+ Output: JSON Location | null
926
+ """
927
+ import json
928
+ from monoco.core.lsp import Position
929
+ from monoco.features.issue.lsp import DefinitionProvider
930
+
931
+ config = get_config()
932
+ # Workspace Root resolution is key here.
933
+ # If we are in a workspace, we want the workspace root, not just issue root.
934
+ # _resolve_project_root returns the closest project root or monoco root.
935
+ workspace_root = _resolve_project_root(config)
936
+ # Search for topmost workspace root to enable cross-project navigation
937
+ current_best = workspace_root
938
+ for parent in [workspace_root] + list(workspace_root.parents):
939
+ if (parent / ".monoco" / "workspace.yaml").exists() or (parent / ".monoco" / "project.yaml").exists():
940
+ current_best = parent
941
+ workspace_root = current_best
942
+
943
+ provider = DefinitionProvider(workspace_root)
944
+ file_path = Path(file)
945
+
946
+ locations = provider.provide_definition(
947
+ file_path,
948
+ Position(line=line, character=character)
949
+ )
950
+
951
+ # helper to serialize
952
+ print(json.dumps([l.model_dump(mode='json') for l in locations]))