monoco-toolkit 0.1.1__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 (76) hide show
  1. monoco/cli/__init__.py +0 -0
  2. monoco/cli/project.py +87 -0
  3. monoco/cli/workspace.py +46 -0
  4. monoco/core/agent/__init__.py +5 -0
  5. monoco/core/agent/action.py +144 -0
  6. monoco/core/agent/adapters.py +129 -0
  7. monoco/core/agent/protocol.py +31 -0
  8. monoco/core/agent/state.py +106 -0
  9. monoco/core/config.py +212 -17
  10. monoco/core/execution.py +62 -0
  11. monoco/core/feature.py +58 -0
  12. monoco/core/git.py +51 -2
  13. monoco/core/injection.py +196 -0
  14. monoco/core/integrations.py +242 -0
  15. monoco/core/lsp.py +68 -0
  16. monoco/core/output.py +21 -3
  17. monoco/core/registry.py +36 -0
  18. monoco/core/resources/en/AGENTS.md +8 -0
  19. monoco/core/resources/en/SKILL.md +66 -0
  20. monoco/core/resources/zh/AGENTS.md +8 -0
  21. monoco/core/resources/zh/SKILL.md +65 -0
  22. monoco/core/setup.py +96 -110
  23. monoco/core/skills.py +444 -0
  24. monoco/core/state.py +53 -0
  25. monoco/core/sync.py +224 -0
  26. monoco/core/telemetry.py +4 -1
  27. monoco/core/workspace.py +85 -20
  28. monoco/daemon/app.py +127 -58
  29. monoco/daemon/models.py +4 -0
  30. monoco/daemon/services.py +56 -155
  31. monoco/features/config/commands.py +125 -44
  32. monoco/features/i18n/adapter.py +29 -0
  33. monoco/features/i18n/commands.py +89 -10
  34. monoco/features/i18n/core.py +113 -27
  35. monoco/features/i18n/resources/en/AGENTS.md +8 -0
  36. monoco/features/i18n/resources/en/SKILL.md +94 -0
  37. monoco/features/i18n/resources/zh/AGENTS.md +8 -0
  38. monoco/features/i18n/resources/zh/SKILL.md +94 -0
  39. monoco/features/issue/adapter.py +34 -0
  40. monoco/features/issue/commands.py +343 -101
  41. monoco/features/issue/core.py +384 -150
  42. monoco/features/issue/domain/__init__.py +0 -0
  43. monoco/features/issue/domain/lifecycle.py +126 -0
  44. monoco/features/issue/domain/models.py +170 -0
  45. monoco/features/issue/domain/parser.py +223 -0
  46. monoco/features/issue/domain/workspace.py +104 -0
  47. monoco/features/issue/engine/__init__.py +22 -0
  48. monoco/features/issue/engine/config.py +172 -0
  49. monoco/features/issue/engine/machine.py +185 -0
  50. monoco/features/issue/engine/models.py +18 -0
  51. monoco/features/issue/linter.py +325 -120
  52. monoco/features/issue/lsp/__init__.py +3 -0
  53. monoco/features/issue/lsp/definition.py +72 -0
  54. monoco/features/issue/migration.py +134 -0
  55. monoco/features/issue/models.py +46 -24
  56. monoco/features/issue/monitor.py +94 -0
  57. monoco/features/issue/resources/en/AGENTS.md +20 -0
  58. monoco/features/issue/resources/en/SKILL.md +111 -0
  59. monoco/features/issue/resources/zh/AGENTS.md +20 -0
  60. monoco/features/issue/resources/zh/SKILL.md +138 -0
  61. monoco/features/issue/validator.py +455 -0
  62. monoco/features/spike/adapter.py +30 -0
  63. monoco/features/spike/commands.py +45 -24
  64. monoco/features/spike/core.py +6 -40
  65. monoco/features/spike/resources/en/AGENTS.md +7 -0
  66. monoco/features/spike/resources/en/SKILL.md +74 -0
  67. monoco/features/spike/resources/zh/AGENTS.md +7 -0
  68. monoco/features/spike/resources/zh/SKILL.md +74 -0
  69. monoco/main.py +91 -2
  70. monoco_toolkit-0.2.8.dist-info/METADATA +136 -0
  71. monoco_toolkit-0.2.8.dist-info/RECORD +83 -0
  72. monoco_toolkit-0.1.1.dist-info/METADATA +0 -93
  73. monoco_toolkit-0.1.1.dist-info/RECORD +0 -33
  74. {monoco_toolkit-0.1.1.dist-info → monoco_toolkit-0.2.8.dist-info}/WHEEL +0 -0
  75. {monoco_toolkit-0.1.1.dist-info → monoco_toolkit-0.2.8.dist-info}/entry_points.txt +0 -0
  76. {monoco_toolkit-0.1.1.dist-info → monoco_toolkit-0.2.8.dist-info}/licenses/LICENSE +0 -0
@@ -8,38 +8,49 @@ from rich.table import Table
8
8
  import typer
9
9
 
10
10
  from monoco.core.config import get_config
11
- from monoco.core.output import print_output
11
+ from monoco.core.output import print_output, OutputManager, AgentOutput
12
12
  from .models import IssueType, IssueStatus, IssueSolution, IssueStage, IsolationType, IssueMetadata
13
13
  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 (todo, 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')"),
30
32
  sprint: Optional[str] = typer.Option(None, "--sprint", help="Sprint ID"),
31
33
  tags: List[str] = typer.Option([], "--tag", help="Tags"),
32
34
  root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
35
+ json: AgentOutput = False,
33
36
  ):
37
+ """Create a new issue."""
34
38
  """Create a new issue."""
35
39
  config = get_config()
36
40
  issues_root = _resolve_issues_root(config, root)
37
- 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:]
38
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
+
39
50
  if parent:
40
51
  parent_path = core.find_issue_path(issues_root, parent)
41
52
  if not parent_path:
42
- console.print(f"[red]✘ Error:[/red] Parent issue {parent} not found.")
53
+ OutputManager.error(f"Parent issue {parent} not found.")
43
54
  raise typer.Exit(code=1)
44
55
 
45
56
  try:
@@ -62,26 +73,79 @@ def create(
62
73
  except ValueError:
63
74
  rel_path = path
64
75
 
65
- console.print(f"[green]✔[/green] Created [bold]{issue.id}[/bold] in status [cyan]{issue.status.value}[/cyan].")
66
- console.print(f"[dim]Path: {rel_path}[/dim]")
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}")
85
+
67
86
  except ValueError as e:
68
- console.print(f"[red]✘ Error:[/red] {str(e)}")
87
+ OutputManager.error(str(e))
69
88
  raise typer.Exit(code=1)
70
89
 
90
+ @app.command("update")
91
+ def update(
92
+ issue_id: str = typer.Argument(..., help="Issue ID to update"),
93
+ title: Optional[str] = typer.Option(None, "--title", "-t", help="New title"),
94
+ status: Optional[str] = typer.Option(None, "--status", help="New status"),
95
+ stage: Optional[str] = typer.Option(None, "--stage", help="New stage"),
96
+ parent: Optional[str] = typer.Option(None, "--parent", "-p", help="Parent Issue ID"),
97
+ sprint: Optional[str] = typer.Option(None, "--sprint", help="Sprint ID"),
98
+ dependencies: Optional[List[str]] = typer.Option(None, "--dependency", "-d", help="Issue dependency ID(s)"),
99
+ related: Optional[List[str]] = typer.Option(None, "--related", "-r", help="Related Issue ID(s)"),
100
+ tags: Optional[List[str]] = typer.Option(None, "--tag", help="Tags"),
101
+ root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
102
+ json: AgentOutput = False,
103
+ ):
104
+ """Update an existing issue."""
105
+ config = get_config()
106
+ issues_root = _resolve_issues_root(config, root)
107
+
108
+ try:
109
+ issue = core.update_issue(
110
+ issues_root,
111
+ issue_id,
112
+ status=status,
113
+ stage=stage,
114
+ title=title,
115
+ parent=parent,
116
+ sprint=sprint,
117
+ dependencies=dependencies,
118
+ related=related,
119
+ tags=tags
120
+ )
121
+
122
+ OutputManager.print({
123
+ "issue": issue,
124
+ "status": "updated"
125
+ })
126
+ except Exception as e:
127
+ OutputManager.error(str(e))
128
+ raise typer.Exit(code=1)
129
+
130
+
71
131
  @app.command("open")
72
132
  def move_open(
73
133
  issue_id: str = typer.Argument(..., help="Issue ID to open"),
74
134
  root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
135
+ json: AgentOutput = False,
75
136
  ):
76
- """Move issue to open status and set stage to Todo."""
137
+ """Move issue to open status and set stage to Draft."""
77
138
  config = get_config()
78
139
  issues_root = _resolve_issues_root(config, root)
79
140
  try:
80
141
  # Pull operation: Force stage to TODO
81
- core.update_issue(issues_root, issue_id, status=IssueStatus.OPEN, stage=IssueStage.TODO)
82
- console.print(f"[green]▶[/green] Issue [bold]{issue_id}[/bold] moved to open/todo.")
142
+ issue = core.update_issue(issues_root, issue_id, status="open", stage="draft")
143
+ OutputManager.print({
144
+ "issue": issue,
145
+ "status": "opened"
146
+ })
83
147
  except Exception as e:
84
- console.print(f"[red]✘ Error:[/red] {str(e)}")
148
+ OutputManager.error(str(e))
85
149
  raise typer.Exit(code=1)
86
150
 
87
151
  @app.command("start")
@@ -90,6 +154,7 @@ def start(
90
154
  branch: bool = typer.Option(False, "--branch", "-b", help="Start in a new git branch"),
91
155
  worktree: bool = typer.Option(False, "--worktree", "-w", help="Start in a new git worktree"),
92
156
  root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
157
+ json: AgentOutput = False,
93
158
  ):
94
159
  """Start working on an issue (Stage -> Doing)."""
95
160
  config = get_config()
@@ -97,32 +162,38 @@ def start(
97
162
  project_root = _resolve_project_root(config)
98
163
 
99
164
  if branch and worktree:
100
- console.print("[red]Error:[/red] Cannot specify both --branch and --worktree.")
165
+ OutputManager.error("Cannot specify both --branch and --worktree.")
101
166
  raise typer.Exit(code=1)
102
167
 
103
168
  try:
104
169
  # Implicitly ensure status is Open
105
- 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")
106
171
 
172
+ isolation_info = None
173
+
107
174
  if branch:
108
175
  try:
109
- meta = core.start_issue_isolation(issues_root, issue_id, IsolationType.BRANCH, project_root)
110
- console.print(f"[green]✔[/green] Switched to branch [bold]{meta.isolation.ref}[/bold]")
176
+ issue = core.start_issue_isolation(issues_root, issue_id, "branch", project_root)
177
+ isolation_info = {"type": "branch", "ref": issue.isolation.ref}
111
178
  except Exception as e:
112
- console.print(f"[red]Error:[/red] Failed to create branch: {e}")
179
+ OutputManager.error(f"Failed to create branch: {e}")
113
180
  raise typer.Exit(code=1)
114
181
 
115
182
  if worktree:
116
183
  try:
117
- meta = core.start_issue_isolation(issues_root, issue_id, IsolationType.WORKTREE, project_root)
118
- console.print(f"[green]✔[/green] Created worktree at [bold]{meta.isolation.path}[/bold]")
184
+ issue = core.start_issue_isolation(issues_root, issue_id, "worktree", project_root)
185
+ isolation_info = {"type": "worktree", "path": issue.isolation.path, "ref": issue.isolation.ref}
119
186
  except Exception as e:
120
- console.print(f"[red]Error:[/red] Failed to create worktree: {e}")
187
+ OutputManager.error(f"Failed to create worktree: {e}")
121
188
  raise typer.Exit(code=1)
122
189
 
123
- console.print(f"[green]🚀[/green] Issue [bold]{issue_id}[/bold] started.")
190
+ OutputManager.print({
191
+ "issue": issue,
192
+ "status": "started",
193
+ "isolation": isolation_info
194
+ })
124
195
  except Exception as e:
125
- console.print(f"[red]✘ Error:[/red] {str(e)}")
196
+ OutputManager.error(str(e))
126
197
  raise typer.Exit(code=1)
127
198
 
128
199
  @app.command("submit")
@@ -131,6 +202,7 @@ def submit(
131
202
  prune: bool = typer.Option(False, "--prune", help="Delete branch/worktree after submit"),
132
203
  force: bool = typer.Option(False, "--force", help="Force delete branch/worktree"),
133
204
  root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
205
+ json: AgentOutput = False,
134
206
  ):
135
207
  """Submit issue for review (Stage -> Review) and generate delivery report."""
136
208
  config = get_config()
@@ -138,116 +210,153 @@ def submit(
138
210
  project_root = _resolve_project_root(config)
139
211
  try:
140
212
  # Implicitly ensure status is Open
141
- core.update_issue(issues_root, issue_id, status=IssueStatus.OPEN, stage=IssueStage.REVIEW)
142
- console.print(f"[green]🚀[/green] Issue [bold]{issue_id}[/bold] submitted for review.")
213
+ issue = core.update_issue(issues_root, issue_id, status="open", stage="review")
143
214
 
144
215
  # Delivery Report Generation
216
+ report_status = "skipped"
145
217
  try:
146
218
  core.generate_delivery_report(issues_root, issue_id, project_root)
147
- console.print(f"[dim]✔ Delivery report appended to issue file.[/dim]")
219
+ report_status = "generated"
148
220
  except Exception as e:
149
- console.print(f"[yellow]⚠ Failed to generate delivery report: {e}[/yellow]")
221
+ report_status = f"failed: {e}"
150
222
 
223
+ pruned_resources = []
151
224
  if prune:
152
225
  try:
153
- deleted = core.prune_issue_resources(issues_root, issue_id, force, project_root)
154
- if deleted:
155
- console.print(f"[dim]✔ Pruned resources: {', '.join(deleted)}[/dim]")
226
+ pruned_resources = core.prune_issue_resources(issues_root, issue_id, force, project_root)
156
227
  except Exception as e:
157
- console.print(f"[red]Prune Error:[/red] {e}")
228
+ OutputManager.error(f"Prune Error: {e}")
158
229
  raise typer.Exit(code=1)
159
230
 
231
+ OutputManager.print({
232
+ "issue": issue,
233
+ "status": "submitted",
234
+ "report": report_status,
235
+ "pruned": pruned_resources
236
+ })
237
+
160
238
  except Exception as e:
161
- console.print(f"[red]✘ Error:[/red] {str(e)}")
239
+ OutputManager.error(str(e))
162
240
  raise typer.Exit(code=1)
163
241
 
164
242
  @app.command("close")
165
243
  def move_close(
166
244
  issue_id: str = typer.Argument(..., help="Issue ID to close"),
167
- solution: Optional[IssueSolution] = typer.Option(None, "--solution", "-s", help="Solution type"),
245
+ solution: Optional[str] = typer.Option(None, "--solution", "-s", help="Solution type"),
168
246
  prune: bool = typer.Option(False, "--prune", help="Delete branch/worktree after close"),
169
247
  force: bool = typer.Option(False, "--force", help="Force delete branch/worktree"),
170
248
  root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
249
+ json: AgentOutput = False,
171
250
  ):
172
251
  """Close issue."""
173
252
  config = get_config()
174
253
  issues_root = _resolve_issues_root(config, root)
175
254
  project_root = _resolve_project_root(config)
255
+
256
+ # Pre-flight check for interactive guidance (Requirement FEAT-0082 #6)
257
+ if solution is None:
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 []
262
+ OutputManager.error(f"Closing an issue requires a solution. Options: {', '.join(valid_solutions)}")
263
+ raise typer.Exit(code=1)
264
+
176
265
  try:
177
- core.update_issue(issues_root, issue_id, status=IssueStatus.CLOSED, solution=solution)
178
- console.print(f"[dim]✔[/dim] Issue [bold]{issue_id}[/bold] closed.")
266
+ issue = core.update_issue(issues_root, issue_id, status="closed", solution=solution)
179
267
 
268
+ pruned_resources = []
180
269
  if prune:
181
270
  try:
182
- deleted = core.prune_issue_resources(issues_root, issue_id, force, project_root)
183
- if deleted:
184
- console.print(f"[dim]✔ Pruned resources: {', '.join(deleted)}[/dim]")
271
+ pruned_resources = core.prune_issue_resources(issues_root, issue_id, force, project_root)
185
272
  except Exception as e:
186
- console.print(f"[red]Prune Error:[/red] {e}")
273
+ OutputManager.error(f"Prune Error: {e}")
187
274
  raise typer.Exit(code=1)
188
275
 
276
+ OutputManager.print({
277
+ "issue": issue,
278
+ "status": "closed",
279
+ "pruned": pruned_resources
280
+ })
281
+
189
282
  except Exception as e:
190
- console.print(f"[red]✘ Error:[/red] {str(e)}")
283
+ OutputManager.error(str(e))
191
284
  raise typer.Exit(code=1)
192
285
 
193
286
  @backlog_app.command("push")
194
287
  def push(
195
288
  issue_id: str = typer.Argument(..., help="Issue ID to push to backlog"),
196
289
  root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
290
+ json: AgentOutput = False,
197
291
  ):
198
292
  """Push issue to backlog."""
199
293
  config = get_config()
200
294
  issues_root = _resolve_issues_root(config, root)
201
295
  try:
202
- core.update_issue(issues_root, issue_id, status=IssueStatus.BACKLOG)
203
- console.print(f"[blue]💤[/blue] Issue [bold]{issue_id}[/bold] pushed to backlog.")
296
+ issue = core.update_issue(issues_root, issue_id, status="backlog")
297
+ OutputManager.print({
298
+ "issue": issue,
299
+ "status": "pushed_to_backlog"
300
+ })
204
301
  except Exception as e:
205
- console.print(f"[red]✘ Error:[/red] {str(e)}")
302
+ OutputManager.error(str(e))
206
303
  raise typer.Exit(code=1)
207
304
 
208
305
  @backlog_app.command("pull")
209
306
  def pull(
210
307
  issue_id: str = typer.Argument(..., help="Issue ID to pull from backlog"),
211
308
  root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
309
+ json: AgentOutput = False,
212
310
  ):
213
- """Pull issue from backlog (Open & Todo)."""
311
+ """Pull issue from backlog (Open & Draft)."""
214
312
  config = get_config()
215
313
  issues_root = _resolve_issues_root(config, root)
216
314
  try:
217
- core.update_issue(issues_root, issue_id, status=IssueStatus.OPEN, stage=IssueStage.TODO)
218
- console.print(f"[green]🔥[/green] Issue [bold]{issue_id}[/bold] pulled from backlog.")
315
+ issue = core.update_issue(issues_root, issue_id, status="open", stage="draft")
316
+ OutputManager.print({
317
+ "issue": issue,
318
+ "status": "pulled_from_backlog"
319
+ })
219
320
  except Exception as e:
220
- console.print(f"[red]✘ Error:[/red] {str(e)}")
321
+ OutputManager.error(str(e))
221
322
  raise typer.Exit(code=1)
222
323
 
223
324
  @app.command("cancel")
224
325
  def cancel(
225
326
  issue_id: str = typer.Argument(..., help="Issue ID to cancel"),
226
327
  root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
328
+ json: AgentOutput = False,
227
329
  ):
228
330
  """Cancel issue."""
229
331
  config = get_config()
230
332
  issues_root = _resolve_issues_root(config, root)
231
333
  try:
232
- core.update_issue(issues_root, issue_id, status=IssueStatus.CLOSED, solution=IssueSolution.CANCELLED)
233
- console.print(f"[red]✘[/red] Issue [bold]{issue_id}[/bold] cancelled.")
334
+ issue = core.update_issue(issues_root, issue_id, status="closed", solution="cancelled")
335
+ OutputManager.print({
336
+ "issue": issue,
337
+ "status": "cancelled"
338
+ })
234
339
  except Exception as e:
235
- console.print(f"[red]✘ Error:[/red] {str(e)}")
340
+ OutputManager.error(str(e))
236
341
  raise typer.Exit(code=1)
237
342
 
238
343
  @app.command("delete")
239
344
  def delete(
240
345
  issue_id: str = typer.Argument(..., help="Issue ID to delete"),
241
346
  root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
347
+ json: AgentOutput = False,
242
348
  ):
243
349
  """Physically remove an issue file."""
244
350
  config = get_config()
245
351
  issues_root = _resolve_issues_root(config, root)
246
352
  try:
247
353
  core.delete_issue_file(issues_root, issue_id)
248
- console.print(f"[red]✔[/red] Issue [bold]{issue_id}[/bold] physically deleted.")
354
+ OutputManager.print({
355
+ "id": issue_id,
356
+ "status": "deleted"
357
+ })
249
358
  except Exception as e:
250
- console.print(f"[red]✘ Error:[/red] {str(e)}")
359
+ OutputManager.error(str(e))
251
360
  raise typer.Exit(code=1)
252
361
 
253
362
  @app.command("move")
@@ -256,6 +365,7 @@ def move(
256
365
  target: str = typer.Option(..., "--to", help="Target project directory (e.g., ../OtherProject)"),
257
366
  renumber: bool = typer.Option(False, "--renumber", help="Automatically renumber on ID conflict"),
258
367
  root: Optional[str] = typer.Option(None, "--root", help="Override source issues root directory"),
368
+ json: AgentOutput = False,
259
369
  ):
260
370
  """Move an issue to another project."""
261
371
  config = get_config()
@@ -270,7 +380,7 @@ def move(
270
380
  elif target_path.name == "Issues" and target_path.exists():
271
381
  target_issues_root = target_path
272
382
  else:
273
- console.print(f"[red]✘ Error:[/red] Target path must be a project root with 'Issues' directory or an 'Issues' directory itself.")
383
+ OutputManager.error("Target path must be a project root with 'Issues' directory or an 'Issues' directory itself.")
274
384
  raise typer.Exit(code=1)
275
385
 
276
386
  try:
@@ -286,32 +396,37 @@ def move(
286
396
  except ValueError:
287
397
  rel_path = new_path
288
398
 
289
- if updated_meta.id != issue_id:
290
- console.print(f"[green]✔[/green] Moved and renumbered: [bold]{issue_id}[/bold] → [bold]{updated_meta.id}[/bold]")
291
- else:
292
- console.print(f"[green]✔[/green] Moved [bold]{issue_id}[/bold] to target project.")
293
-
294
- console.print(f"[dim]New path: {rel_path}[/dim]")
399
+ OutputManager.print({
400
+ "issue": updated_meta,
401
+ "new_path": str(rel_path),
402
+ "status": "moved",
403
+ "renumbered": updated_meta.id != issue_id
404
+ })
295
405
 
296
406
  except FileNotFoundError as e:
297
- console.print(f"[red]✘ Error:[/red] {str(e)}")
407
+ OutputManager.error(str(e))
298
408
  raise typer.Exit(code=1)
299
409
  except ValueError as e:
300
- console.print(f"[red]✘ Conflict:[/red] {str(e)}")
410
+ OutputManager.error(str(e))
301
411
  raise typer.Exit(code=1)
302
412
  except Exception as e:
303
- console.print(f"[red]✘ Error:[/red] {str(e)}")
413
+ OutputManager.error(str(e))
304
414
  raise typer.Exit(code=1)
305
415
 
306
416
  @app.command("board")
307
417
  def board(
308
418
  root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
419
+ json: AgentOutput = False,
309
420
  ):
310
421
  """Visualize issues in a Kanban board."""
311
422
  config = get_config()
312
423
  issues_root = _resolve_issues_root(config, root)
313
424
 
314
425
  board_data = core.get_board_data(issues_root)
426
+
427
+ if OutputManager.is_agent_mode():
428
+ OutputManager.print(board_data)
429
+ return
315
430
 
316
431
  from rich.columns import Columns
317
432
  from rich.console import RenderableType
@@ -319,7 +434,7 @@ def board(
319
434
  columns: List[RenderableType] = []
320
435
 
321
436
  stage_titles = {
322
- "todo": "[bold white]TODO[/bold white]",
437
+ "draft": "[bold white]DRAFT[/bold white]",
323
438
  "doing": "[bold yellow]DOING[/bold yellow]",
324
439
  "review": "[bold cyan]REVIEW[/bold cyan]",
325
440
  "done": "[bold green]DONE[/bold green]"
@@ -329,10 +444,10 @@ def board(
329
444
  issue_list = []
330
445
  for issue in sorted(issues, key=lambda x: x.updated_at, reverse=True):
331
446
  type_color = {
332
- IssueType.FEATURE: "green",
333
- IssueType.CHORE: "blue",
334
- IssueType.FIX: "red",
335
- IssueType.EPIC: "magenta"
447
+ "feature": "green",
448
+ "chore": "blue",
449
+ "fix": "red",
450
+ "epic": "magenta"
336
451
  }.get(issue.type, "white")
337
452
 
338
453
  issue_list.append(
@@ -360,10 +475,11 @@ def board(
360
475
  @app.command("list")
361
476
  def list_cmd(
362
477
  status: Optional[str] = typer.Option(None, "--status", "-s", help="Filter by status (open, closed, backlog, all)"),
363
- type: Optional[IssueType] = typer.Option(None, "--type", "-t", help="Filter by type"),
364
- 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"),
365
480
  root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
366
481
  workspace: bool = typer.Option(False, "--workspace", "-w", help="Include issues from workspace members"),
482
+ json: AgentOutput = False,
367
483
  ):
368
484
  """List issues in a table format with filtering."""
369
485
  config = get_config()
@@ -371,7 +487,7 @@ def list_cmd(
371
487
 
372
488
  # Validation
373
489
  if status and status.lower() not in ["open", "closed", "backlog", "all"]:
374
- console.print(f"[red]Invalid status:[/red] {status}. Use open, closed, backlog or all.")
490
+ OutputManager.error(f"Invalid status: {status}. Use open, closed, backlog or all.")
375
491
  raise typer.Exit(code=1)
376
492
 
377
493
  target_status = status.lower() if status else "open"
@@ -382,7 +498,7 @@ def list_cmd(
382
498
  for i in issues:
383
499
  # Status Filter
384
500
  if target_status != "all":
385
- if i.status.value != target_status:
501
+ if i.status != target_status:
386
502
  continue
387
503
 
388
504
  # Type Filter
@@ -395,12 +511,13 @@ def list_cmd(
395
511
 
396
512
  filtered.append(i)
397
513
 
398
- # Sort: Updated Descending
399
- filtered.append(i)
400
-
401
514
  # Sort: Updated Descending
402
515
  filtered.sort(key=lambda x: x.updated_at, reverse=True)
403
516
 
517
+ if OutputManager.is_agent_mode():
518
+ OutputManager.print(filtered)
519
+ return
520
+
404
521
  # Render
405
522
  _render_issues_table(filtered, title=f"Issues ({len(filtered)})")
406
523
 
@@ -430,13 +547,13 @@ def _render_issues_table(issues: List[IssueMetadata], title: str = "Issues"):
430
547
  t_color = type_colors.get(i.type, "white")
431
548
  s_color = status_colors.get(i.status, "white")
432
549
 
433
- stage_str = i.stage.value if i.stage else "-"
550
+ stage_str = i.stage if i.stage else "-"
434
551
  updated_str = i.updated_at.strftime("%Y-%m-%d %H:%M")
435
552
 
436
553
  table.add_row(
437
554
  i.id,
438
- f"[{t_color}]{i.type.value}[/{t_color}]",
439
- f"[{s_color}]{i.status.value}[/{s_color}]",
555
+ f"[{t_color}]{i.type}[/{t_color}]",
556
+ f"[{s_color}]{i.status}[/{s_color}]",
440
557
  stage_str,
441
558
  i.title,
442
559
  updated_str
@@ -448,6 +565,7 @@ def _render_issues_table(issues: List[IssueMetadata], title: str = "Issues"):
448
565
  def query_cmd(
449
566
  query: str = typer.Argument(..., help="Search query (e.g. '+bug -ui' or 'login')"),
450
567
  root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
568
+ json: AgentOutput = False,
451
569
  ):
452
570
  """
453
571
  Search issues using advanced syntax.
@@ -468,6 +586,10 @@ def query_cmd(
468
586
  # For now, updated at descending is useful.
469
587
  results.sort(key=lambda x: x.updated_at, reverse=True)
470
588
 
589
+ if OutputManager.is_agent_mode():
590
+ OutputManager.print(results)
591
+ return
592
+
471
593
  _render_issues_table(results, title=f"Search Results for '{query}' ({len(results)})")
472
594
 
473
595
  @app.command("scope")
@@ -477,6 +599,7 @@ def scope(
477
599
  recursive: bool = typer.Option(False, "--recursive", "-r", help="Recursively scan subdirectories"),
478
600
  root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
479
601
  workspace: bool = typer.Option(False, "--workspace", "-w", help="Include issues from workspace members"),
602
+ json: AgentOutput = False,
480
603
  ):
481
604
  """Show progress tree."""
482
605
  config = get_config()
@@ -494,12 +617,16 @@ def scope(
494
617
 
495
618
  issues = filtered_issues
496
619
 
620
+ if OutputManager.is_agent_mode():
621
+ OutputManager.print(issues)
622
+ return
623
+
497
624
  tree = Tree(f"[bold blue]Monoco Issue Scope[/bold blue]")
498
- epics = sorted([i for i in issues if i.type == IssueType.EPIC], key=lambda x: x.id)
499
- stories = [i for i in issues if i.type == IssueType.FEATURE]
500
- 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"]]
501
628
 
502
- 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]"}
503
630
 
504
631
  for epic in epics:
505
632
  epic_node = tree.add(f"{status_map[epic.status]} [bold]{epic.id}[/bold]: {epic.title}")
@@ -512,16 +639,112 @@ def scope(
512
639
 
513
640
  console.print(Panel(tree, expand=False))
514
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
+
515
730
  @app.command("lint")
516
731
  def lint(
517
732
  recursive: bool = typer.Option(False, "--recursive", "-r", help="Recursively scan subdirectories"),
733
+ fix: bool = typer.Option(False, "--fix", help="Attempt to automatically fix issues (e.g. missing headings)"),
734
+ format: str = typer.Option("table", "--format", "-f", help="Output format (table, json)"),
735
+ file: Optional[str] = typer.Option(None, "--file", help="Validate a single file instead of scanning the entire workspace"),
518
736
  root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
737
+ json: AgentOutput = False,
519
738
  ):
520
739
  """Verify the integrity of the Issues directory (declarative check)."""
521
740
  from . import linter
522
741
  config = get_config()
523
742
  issues_root = _resolve_issues_root(config, root)
524
- linter.run_lint(issues_root, recursive=recursive)
743
+
744
+ if OutputManager.is_agent_mode():
745
+ format = "json"
746
+
747
+ linter.run_lint(issues_root, recursive=recursive, fix=fix, format=format, file_path=file)
525
748
 
526
749
  def _resolve_issues_root(config, cli_root: Optional[str]) -> Path:
527
750
  """
@@ -544,27 +767,9 @@ def _resolve_issues_root(config, cli_root: Optional[str]) -> Path:
544
767
  return path
545
768
 
546
769
  # 2. Handle Default / Contextual Execution (No --root)
547
- # 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)
548
772
  cwd = Path.cwd()
549
-
550
- # If CWD is NOT a project root (no monoco.yaml/Issues), scan for subprojects
551
- if not is_project_root(cwd):
552
- subprojects = find_projects(cwd)
553
- if len(subprojects) > 1:
554
- console.print(f"[yellow]Workspace detected with {len(subprojects)} projects:[/yellow]")
555
- for p in subprojects:
556
- console.print(f" - [bold]{p.name}[/bold]")
557
- console.print("\n[yellow]Please specify a project using --root <PATH>.[/yellow]")
558
- # We don't exit here strictly, but usually this means we can't find 'Issues' in CWD anyway
559
- # so the config fallbacks below will likely fail or point to non-existent CWD/Issues.
560
- # But let's fail fast to be helpful.
561
- raise typer.Exit(code=1)
562
- elif len(subprojects) == 1:
563
- # Auto-select the only child project?
564
- # It's safer to require explicit intent, but let's try to be helpful if it's obvious.
565
- # However, standard behavior is usually "operate on current dir".
566
- # Let's stick to standard config resolution, but maybe warn.
567
- pass
568
773
 
569
774
  # 3. Config Fallback
570
775
  config_issues_path = Path(config.paths.issues)
@@ -708,3 +913,40 @@ def commit(
708
913
  except Exception as e:
709
914
  console.print(f"[red]Git Error:[/red] {e}")
710
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]))