monoco-toolkit 0.2.8__py3-none-any.whl → 0.3.1__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 (63) 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 +468 -272
  36. monoco/features/issue/core.py +419 -312
  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 +287 -157
  46. monoco/features/issue/lsp/definition.py +26 -19
  47. monoco/features/issue/migration.py +45 -34
  48. monoco/features/issue/models.py +29 -13
  49. monoco/features/issue/monitor.py +24 -8
  50. monoco/features/issue/resources/en/SKILL.md +6 -2
  51. monoco/features/issue/validator.py +395 -208
  52. monoco/features/skills/__init__.py +0 -1
  53. monoco/features/skills/core.py +24 -18
  54. monoco/features/spike/adapter.py +4 -5
  55. monoco/features/spike/commands.py +51 -38
  56. monoco/features/spike/core.py +24 -16
  57. monoco/main.py +34 -21
  58. {monoco_toolkit-0.2.8.dist-info → monoco_toolkit-0.3.1.dist-info}/METADATA +1 -1
  59. monoco_toolkit-0.3.1.dist-info/RECORD +84 -0
  60. monoco_toolkit-0.2.8.dist-info/RECORD +0 -83
  61. {monoco_toolkit-0.2.8.dist-info → monoco_toolkit-0.3.1.dist-info}/WHEEL +0 -0
  62. {monoco_toolkit-0.2.8.dist-info → monoco_toolkit-0.3.1.dist-info}/entry_points.txt +0 -0
  63. {monoco_toolkit-0.2.8.dist-info → monoco_toolkit-0.3.1.dist-info}/licenses/LICENSE +0 -0
@@ -8,8 +8,8 @@ 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, OutputManager, AgentOutput
12
- from .models import IssueType, IssueStatus, IssueSolution, IssueStage, IsolationType, IssueMetadata
11
+ from monoco.core.output import OutputManager, AgentOutput
12
+ from .models import IssueType, IssueStatus, IssueMetadata
13
13
  from . import core
14
14
 
15
15
  app = typer.Typer(help="Agent-Native Issue Management.")
@@ -19,19 +19,35 @@ app.add_typer(backlog_app, name="backlog")
19
19
  app.add_typer(lsp_app, name="lsp")
20
20
  console = Console()
21
21
 
22
+
22
23
  @app.command("create")
23
24
  def create(
24
- type: str = typer.Argument(..., help="Issue type (epic, feature, chore, fix, etc.)"),
25
+ type: str = typer.Argument(
26
+ ..., help="Issue type (epic, feature, chore, fix, etc.)"
27
+ ),
25
28
  title: str = typer.Option(..., "--title", "-t", help="Issue title"),
26
- parent: Optional[str] = typer.Option(None, "--parent", "-p", help="Parent Issue ID"),
29
+ parent: Optional[str] = typer.Option(
30
+ None, "--parent", "-p", help="Parent Issue ID"
31
+ ),
27
32
  is_backlog: bool = typer.Option(False, "--backlog", help="Create as backlog item"),
28
33
  stage: Optional[str] = typer.Option(None, "--stage", help="Issue stage"),
29
- dependencies: List[str] = typer.Option([], "--dependency", "-d", help="Issue dependency ID(s)"),
30
- related: List[str] = typer.Option([], "--related", "-r", help="Related Issue ID(s)"),
31
- subdir: Optional[str] = typer.Option(None, "--subdir", "-s", help="Subdirectory for organization (e.g. 'Backend/Auth')"),
34
+ dependencies: List[str] = typer.Option(
35
+ [], "--dependency", "-d", help="Issue dependency ID(s)"
36
+ ),
37
+ related: List[str] = typer.Option(
38
+ [], "--related", "-r", help="Related Issue ID(s)"
39
+ ),
40
+ subdir: Optional[str] = typer.Option(
41
+ None,
42
+ "--subdir",
43
+ "-s",
44
+ help="Subdirectory for organization (e.g. 'Backend/Auth')",
45
+ ),
32
46
  sprint: Optional[str] = typer.Option(None, "--sprint", help="Sprint ID"),
33
47
  tags: List[str] = typer.Option([], "--tag", help="Tags"),
34
- root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
48
+ root: Optional[str] = typer.Option(
49
+ None, "--root", help="Override issues root directory"
50
+ ),
35
51
  json: AgentOutput = False,
36
52
  ):
37
53
  """Create a new issue."""
@@ -39,11 +55,11 @@ def create(
39
55
  config = get_config()
40
56
  issues_root = _resolve_issues_root(config, root)
41
57
  status = "backlog" if is_backlog else "open"
42
-
58
+
43
59
  # Sanitize inputs (strip #)
44
60
  if parent and parent.startswith("#"):
45
61
  parent = parent[1:]
46
-
62
+
47
63
  dependencies = [d[1:] if d.startswith("#") else d for d in dependencies]
48
64
  related = [r[1:] if r.startswith("#") else r for r in related]
49
65
 
@@ -55,74 +71,80 @@ def create(
55
71
 
56
72
  try:
57
73
  issue, path = core.create_issue_file(
58
- issues_root,
59
- type,
60
- title,
61
- parent,
62
- status=status,
74
+ issues_root,
75
+ type,
76
+ title,
77
+ parent,
78
+ status=status,
63
79
  stage=stage,
64
- dependencies=dependencies,
65
- related=related,
80
+ dependencies=dependencies,
81
+ related=related,
66
82
  subdir=subdir,
67
83
  sprint=sprint,
68
- tags=tags
84
+ tags=tags,
69
85
  )
70
-
86
+
71
87
  try:
72
88
  rel_path = path.relative_to(Path.cwd())
73
89
  except ValueError:
74
90
  rel_path = path
75
91
 
76
92
  if OutputManager.is_agent_mode():
77
- OutputManager.print({
78
- "issue": issue,
79
- "path": str(rel_path),
80
- "status": "created"
81
- })
93
+ OutputManager.print(
94
+ {"issue": issue, "path": str(rel_path), "status": "created"}
95
+ )
82
96
  else:
83
- console.print(f"[green]✔ Created {issue.id} in status {issue.status}.[/green]")
97
+ console.print(
98
+ f"[green]✔ Created {issue.id} in status {issue.status}.[/green]"
99
+ )
84
100
  console.print(f"Path: {rel_path}")
85
101
 
86
102
  except ValueError as e:
87
103
  OutputManager.error(str(e))
88
104
  raise typer.Exit(code=1)
89
105
 
106
+
90
107
  @app.command("update")
91
108
  def update(
92
109
  issue_id: str = typer.Argument(..., help="Issue ID to update"),
93
110
  title: Optional[str] = typer.Option(None, "--title", "-t", help="New title"),
94
111
  status: Optional[str] = typer.Option(None, "--status", help="New status"),
95
112
  stage: Optional[str] = typer.Option(None, "--stage", help="New stage"),
96
- parent: Optional[str] = typer.Option(None, "--parent", "-p", help="Parent Issue ID"),
113
+ parent: Optional[str] = typer.Option(
114
+ None, "--parent", "-p", help="Parent Issue ID"
115
+ ),
97
116
  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)"),
117
+ dependencies: Optional[List[str]] = typer.Option(
118
+ None, "--dependency", "-d", help="Issue dependency ID(s)"
119
+ ),
120
+ related: Optional[List[str]] = typer.Option(
121
+ None, "--related", "-r", help="Related Issue ID(s)"
122
+ ),
100
123
  tags: Optional[List[str]] = typer.Option(None, "--tag", help="Tags"),
101
- root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
124
+ root: Optional[str] = typer.Option(
125
+ None, "--root", help="Override issues root directory"
126
+ ),
102
127
  json: AgentOutput = False,
103
128
  ):
104
129
  """Update an existing issue."""
105
130
  config = get_config()
106
131
  issues_root = _resolve_issues_root(config, root)
107
-
132
+
108
133
  try:
109
134
  issue = core.update_issue(
110
- issues_root,
111
- issue_id,
112
- status=status,
135
+ issues_root,
136
+ issue_id,
137
+ status=status,
113
138
  stage=stage,
114
139
  title=title,
115
140
  parent=parent,
116
141
  sprint=sprint,
117
- dependencies=dependencies,
118
- related=related,
119
- tags=tags
142
+ dependencies=dependencies,
143
+ related=related,
144
+ tags=tags,
120
145
  )
121
-
122
- OutputManager.print({
123
- "issue": issue,
124
- "status": "updated"
125
- })
146
+
147
+ OutputManager.print({"issue": issue, "status": "updated"})
126
148
  except Exception as e:
127
149
  OutputManager.error(str(e))
128
150
  raise typer.Exit(code=1)
@@ -131,7 +153,9 @@ def update(
131
153
  @app.command("open")
132
154
  def move_open(
133
155
  issue_id: str = typer.Argument(..., help="Issue ID to open"),
134
- root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
156
+ root: Optional[str] = typer.Option(
157
+ None, "--root", help="Override issues root directory"
158
+ ),
135
159
  json: AgentOutput = False,
136
160
  ):
137
161
  """Move issue to open status and set stage to Draft."""
@@ -140,68 +164,93 @@ def move_open(
140
164
  try:
141
165
  # Pull operation: Force stage to TODO
142
166
  issue = core.update_issue(issues_root, issue_id, status="open", stage="draft")
143
- OutputManager.print({
144
- "issue": issue,
145
- "status": "opened"
146
- })
167
+ OutputManager.print({"issue": issue, "status": "opened"})
147
168
  except Exception as e:
148
169
  OutputManager.error(str(e))
149
170
  raise typer.Exit(code=1)
150
171
 
172
+
151
173
  @app.command("start")
152
174
  def start(
153
175
  issue_id: str = typer.Argument(..., help="Issue ID to start"),
154
- branch: bool = typer.Option(False, "--branch", "-b", help="Start in a new git branch"),
155
- worktree: bool = typer.Option(False, "--worktree", "-w", help="Start in a new git worktree"),
156
- root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
176
+ branch: bool = typer.Option(
177
+ False,
178
+ "--branch",
179
+ "-b",
180
+ help="[Recommended] Start in a new git branch (feat/<id>-<slug>)",
181
+ ),
182
+ worktree: bool = typer.Option(
183
+ False,
184
+ "--worktree",
185
+ "-w",
186
+ help="Start in a new git worktree for parallel development",
187
+ ),
188
+ root: Optional[str] = typer.Option(
189
+ None, "--root", help="Override issues root directory"
190
+ ),
157
191
  json: AgentOutput = False,
158
192
  ):
159
- """Start working on an issue (Stage -> Doing)."""
193
+ """
194
+ Start working on an issue (Stage -> Doing).
195
+
196
+ AGENTS: You SHOULD almost always use --branch to isolate your changes.
197
+ """
160
198
  config = get_config()
161
199
  issues_root = _resolve_issues_root(config, root)
162
200
  project_root = _resolve_project_root(config)
163
201
 
164
202
  if branch and worktree:
165
- OutputManager.error("Cannot specify both --branch and --worktree.")
166
- raise typer.Exit(code=1)
203
+ OutputManager.error("Cannot specify both --branch and --worktree.")
204
+ raise typer.Exit(code=1)
167
205
 
168
206
  try:
169
207
  # Implicitly ensure status is Open
170
208
  issue = core.update_issue(issues_root, issue_id, status="open", stage="doing")
171
-
209
+
172
210
  isolation_info = None
173
211
 
174
212
  if branch:
175
213
  try:
176
- issue = core.start_issue_isolation(issues_root, issue_id, "branch", project_root)
214
+ issue = core.start_issue_isolation(
215
+ issues_root, issue_id, "branch", project_root
216
+ )
177
217
  isolation_info = {"type": "branch", "ref": issue.isolation.ref}
178
218
  except Exception as e:
179
219
  OutputManager.error(f"Failed to create branch: {e}")
180
220
  raise typer.Exit(code=1)
181
-
221
+
182
222
  if worktree:
183
223
  try:
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}
224
+ issue = core.start_issue_isolation(
225
+ issues_root, issue_id, "worktree", project_root
226
+ )
227
+ isolation_info = {
228
+ "type": "worktree",
229
+ "path": issue.isolation.path,
230
+ "ref": issue.isolation.ref,
231
+ }
186
232
  except Exception as e:
187
233
  OutputManager.error(f"Failed to create worktree: {e}")
188
234
  raise typer.Exit(code=1)
189
235
 
190
- OutputManager.print({
191
- "issue": issue,
192
- "status": "started",
193
- "isolation": isolation_info
194
- })
236
+ OutputManager.print(
237
+ {"issue": issue, "status": "started", "isolation": isolation_info}
238
+ )
195
239
  except Exception as e:
196
240
  OutputManager.error(str(e))
197
241
  raise typer.Exit(code=1)
198
242
 
243
+
199
244
  @app.command("submit")
200
245
  def submit(
201
246
  issue_id: str = typer.Argument(..., help="Issue ID to submit"),
202
- prune: bool = typer.Option(False, "--prune", help="Delete branch/worktree after submit"),
247
+ prune: bool = typer.Option(
248
+ False, "--prune", help="Delete branch/worktree after submit"
249
+ ),
203
250
  force: bool = typer.Option(False, "--force", help="Force delete branch/worktree"),
204
- root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
251
+ root: Optional[str] = typer.Option(
252
+ None, "--root", help="Override issues root directory"
253
+ ),
205
254
  json: AgentOutput = False,
206
255
  ):
207
256
  """Submit issue for review (Stage -> Review) and generate delivery report."""
@@ -211,82 +260,101 @@ def submit(
211
260
  try:
212
261
  # Implicitly ensure status is Open
213
262
  issue = core.update_issue(issues_root, issue_id, status="open", stage="review")
214
-
263
+
215
264
  # Delivery Report Generation
216
265
  report_status = "skipped"
217
266
  try:
218
- core.generate_delivery_report(issues_root, issue_id, project_root)
219
- report_status = "generated"
267
+ core.generate_delivery_report(issues_root, issue_id, project_root)
268
+ report_status = "generated"
220
269
  except Exception as e:
221
- report_status = f"failed: {e}"
222
-
270
+ report_status = f"failed: {e}"
271
+
223
272
  pruned_resources = []
224
273
  if prune:
225
274
  try:
226
- pruned_resources = core.prune_issue_resources(issues_root, issue_id, force, project_root)
275
+ pruned_resources = core.prune_issue_resources(
276
+ issues_root, issue_id, force, project_root
277
+ )
227
278
  except Exception as e:
228
279
  OutputManager.error(f"Prune Error: {e}")
229
280
  raise typer.Exit(code=1)
230
-
231
- OutputManager.print({
232
- "issue": issue,
233
- "status": "submitted",
234
- "report": report_status,
235
- "pruned": pruned_resources
236
- })
237
-
281
+
282
+ OutputManager.print(
283
+ {
284
+ "issue": issue,
285
+ "status": "submitted",
286
+ "report": report_status,
287
+ "pruned": pruned_resources,
288
+ }
289
+ )
290
+
238
291
  except Exception as e:
239
292
  OutputManager.error(str(e))
240
293
  raise typer.Exit(code=1)
241
294
 
295
+
242
296
  @app.command("close")
243
297
  def move_close(
244
298
  issue_id: str = typer.Argument(..., help="Issue ID to close"),
245
- solution: Optional[str] = typer.Option(None, "--solution", "-s", help="Solution type"),
246
- prune: bool = typer.Option(False, "--prune", help="Delete branch/worktree after close"),
299
+ solution: Optional[str] = typer.Option(
300
+ None, "--solution", "-s", help="Solution type"
301
+ ),
302
+ prune: bool = typer.Option(
303
+ False, "--prune", help="Delete branch/worktree after close"
304
+ ),
247
305
  force: bool = typer.Option(False, "--force", help="Force delete branch/worktree"),
248
- root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
306
+ root: Optional[str] = typer.Option(
307
+ None, "--root", help="Override issues root directory"
308
+ ),
249
309
  json: AgentOutput = False,
250
310
  ):
251
311
  """Close issue."""
252
312
  config = get_config()
253
313
  issues_root = _resolve_issues_root(config, root)
254
314
  project_root = _resolve_project_root(config)
255
-
315
+
256
316
  # Pre-flight check for interactive guidance (Requirement FEAT-0082 #6)
257
317
  if solution is None:
258
318
  # Resolve options from engine
259
319
  from .engine import get_engine
320
+
260
321
  engine = get_engine(str(issues_root.parent))
261
322
  valid_solutions = engine.issue_config.solutions or []
262
- OutputManager.error(f"Closing an issue requires a solution. Options: {', '.join(valid_solutions)}")
323
+ OutputManager.error(
324
+ f"Closing an issue requires a solution. Options: {', '.join(valid_solutions)}"
325
+ )
263
326
  raise typer.Exit(code=1)
264
327
 
265
328
  try:
266
- issue = core.update_issue(issues_root, issue_id, status="closed", solution=solution)
267
-
329
+ issue = core.update_issue(
330
+ issues_root, issue_id, status="closed", solution=solution
331
+ )
332
+
268
333
  pruned_resources = []
269
334
  if prune:
270
335
  try:
271
- pruned_resources = core.prune_issue_resources(issues_root, issue_id, force, project_root)
336
+ pruned_resources = core.prune_issue_resources(
337
+ issues_root, issue_id, force, project_root
338
+ )
272
339
  except Exception as e:
273
340
  OutputManager.error(f"Prune Error: {e}")
274
341
  raise typer.Exit(code=1)
275
342
 
276
- OutputManager.print({
277
- "issue": issue,
278
- "status": "closed",
279
- "pruned": pruned_resources
280
- })
343
+ OutputManager.print(
344
+ {"issue": issue, "status": "closed", "pruned": pruned_resources}
345
+ )
281
346
 
282
347
  except Exception as e:
283
348
  OutputManager.error(str(e))
284
349
  raise typer.Exit(code=1)
285
350
 
351
+
286
352
  @backlog_app.command("push")
287
353
  def push(
288
354
  issue_id: str = typer.Argument(..., help="Issue ID to push to backlog"),
289
- root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
355
+ root: Optional[str] = typer.Option(
356
+ None, "--root", help="Override issues root directory"
357
+ ),
290
358
  json: AgentOutput = False,
291
359
  ):
292
360
  """Push issue to backlog."""
@@ -294,18 +362,18 @@ def push(
294
362
  issues_root = _resolve_issues_root(config, root)
295
363
  try:
296
364
  issue = core.update_issue(issues_root, issue_id, status="backlog")
297
- OutputManager.print({
298
- "issue": issue,
299
- "status": "pushed_to_backlog"
300
- })
365
+ OutputManager.print({"issue": issue, "status": "pushed_to_backlog"})
301
366
  except Exception as e:
302
367
  OutputManager.error(str(e))
303
368
  raise typer.Exit(code=1)
304
369
 
370
+
305
371
  @backlog_app.command("pull")
306
372
  def pull(
307
373
  issue_id: str = typer.Argument(..., help="Issue ID to pull from backlog"),
308
- root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
374
+ root: Optional[str] = typer.Option(
375
+ None, "--root", help="Override issues root directory"
376
+ ),
309
377
  json: AgentOutput = False,
310
378
  ):
311
379
  """Pull issue from backlog (Open & Draft)."""
@@ -313,37 +381,39 @@ def pull(
313
381
  issues_root = _resolve_issues_root(config, root)
314
382
  try:
315
383
  issue = core.update_issue(issues_root, issue_id, status="open", stage="draft")
316
- OutputManager.print({
317
- "issue": issue,
318
- "status": "pulled_from_backlog"
319
- })
384
+ OutputManager.print({"issue": issue, "status": "pulled_from_backlog"})
320
385
  except Exception as e:
321
386
  OutputManager.error(str(e))
322
387
  raise typer.Exit(code=1)
323
388
 
389
+
324
390
  @app.command("cancel")
325
391
  def cancel(
326
392
  issue_id: str = typer.Argument(..., help="Issue ID to cancel"),
327
- root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
393
+ root: Optional[str] = typer.Option(
394
+ None, "--root", help="Override issues root directory"
395
+ ),
328
396
  json: AgentOutput = False,
329
397
  ):
330
398
  """Cancel issue."""
331
399
  config = get_config()
332
400
  issues_root = _resolve_issues_root(config, root)
333
401
  try:
334
- issue = core.update_issue(issues_root, issue_id, status="closed", solution="cancelled")
335
- OutputManager.print({
336
- "issue": issue,
337
- "status": "cancelled"
338
- })
402
+ issue = core.update_issue(
403
+ issues_root, issue_id, status="closed", solution="cancelled"
404
+ )
405
+ OutputManager.print({"issue": issue, "status": "cancelled"})
339
406
  except Exception as e:
340
407
  OutputManager.error(str(e))
341
408
  raise typer.Exit(code=1)
342
409
 
410
+
343
411
  @app.command("delete")
344
412
  def delete(
345
413
  issue_id: str = typer.Argument(..., help="Issue ID to delete"),
346
- root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
414
+ root: Optional[str] = typer.Option(
415
+ None, "--root", help="Override issues root directory"
416
+ ),
347
417
  json: AgentOutput = False,
348
418
  ):
349
419
  """Physically remove an issue file."""
@@ -351,58 +421,63 @@ def delete(
351
421
  issues_root = _resolve_issues_root(config, root)
352
422
  try:
353
423
  core.delete_issue_file(issues_root, issue_id)
354
- OutputManager.print({
355
- "id": issue_id,
356
- "status": "deleted"
357
- })
424
+ OutputManager.print({"id": issue_id, "status": "deleted"})
358
425
  except Exception as e:
359
426
  OutputManager.error(str(e))
360
427
  raise typer.Exit(code=1)
361
428
 
429
+
362
430
  @app.command("move")
363
431
  def move(
364
432
  issue_id: str = typer.Argument(..., help="Issue ID to move"),
365
- target: str = typer.Option(..., "--to", help="Target project directory (e.g., ../OtherProject)"),
366
- renumber: bool = typer.Option(False, "--renumber", help="Automatically renumber on ID conflict"),
367
- root: Optional[str] = typer.Option(None, "--root", help="Override source issues root directory"),
433
+ target: str = typer.Option(
434
+ ..., "--to", help="Target project directory (e.g., ../OtherProject)"
435
+ ),
436
+ renumber: bool = typer.Option(
437
+ False, "--renumber", help="Automatically renumber on ID conflict"
438
+ ),
439
+ root: Optional[str] = typer.Option(
440
+ None, "--root", help="Override source issues root directory"
441
+ ),
368
442
  json: AgentOutput = False,
369
443
  ):
370
444
  """Move an issue to another project."""
371
445
  config = get_config()
372
446
  source_issues_root = _resolve_issues_root(config, root)
373
-
447
+
374
448
  # Resolve target project
375
449
  target_path = Path(target).resolve()
376
-
450
+
377
451
  # Check if target is a project root or Issues directory
378
452
  if (target_path / "Issues").exists():
379
453
  target_issues_root = target_path / "Issues"
380
454
  elif target_path.name == "Issues" and target_path.exists():
381
455
  target_issues_root = target_path
382
456
  else:
383
- OutputManager.error("Target path must be a project root with 'Issues' directory or an 'Issues' directory itself.")
457
+ OutputManager.error(
458
+ "Target path must be a project root with 'Issues' directory or an 'Issues' directory itself."
459
+ )
384
460
  raise typer.Exit(code=1)
385
-
461
+
386
462
  try:
387
463
  updated_meta, new_path = core.move_issue(
388
- source_issues_root,
389
- issue_id,
390
- target_issues_root,
391
- renumber=renumber
464
+ source_issues_root, issue_id, target_issues_root, renumber=renumber
392
465
  )
393
-
466
+
394
467
  try:
395
468
  rel_path = new_path.relative_to(Path.cwd())
396
469
  except ValueError:
397
470
  rel_path = new_path
398
-
399
- OutputManager.print({
400
- "issue": updated_meta,
401
- "new_path": str(rel_path),
402
- "status": "moved",
403
- "renumbered": updated_meta.id != issue_id
404
- })
405
-
471
+
472
+ OutputManager.print(
473
+ {
474
+ "issue": updated_meta,
475
+ "new_path": str(rel_path),
476
+ "status": "moved",
477
+ "renumbered": updated_meta.id != issue_id,
478
+ }
479
+ )
480
+
406
481
  except FileNotFoundError as e:
407
482
  OutputManager.error(str(e))
408
483
  raise typer.Exit(code=1)
@@ -413,33 +488,36 @@ def move(
413
488
  OutputManager.error(str(e))
414
489
  raise typer.Exit(code=1)
415
490
 
491
+
416
492
  @app.command("board")
417
493
  def board(
418
- root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
494
+ root: Optional[str] = typer.Option(
495
+ None, "--root", help="Override issues root directory"
496
+ ),
419
497
  json: AgentOutput = False,
420
498
  ):
421
499
  """Visualize issues in a Kanban board."""
422
500
  config = get_config()
423
501
  issues_root = _resolve_issues_root(config, root)
424
-
502
+
425
503
  board_data = core.get_board_data(issues_root)
426
504
 
427
505
  if OutputManager.is_agent_mode():
428
506
  OutputManager.print(board_data)
429
507
  return
430
-
508
+
431
509
  from rich.columns import Columns
432
510
  from rich.console import RenderableType
433
-
511
+
434
512
  columns: List[RenderableType] = []
435
-
513
+
436
514
  stage_titles = {
437
515
  "draft": "[bold white]DRAFT[/bold white]",
438
516
  "doing": "[bold yellow]DOING[/bold yellow]",
439
517
  "review": "[bold cyan]REVIEW[/bold cyan]",
440
- "done": "[bold green]DONE[/bold green]"
518
+ "done": "[bold green]DONE[/bold green]",
441
519
  }
442
-
520
+
443
521
  for stage, issues in board_data.items():
444
522
  issue_list = []
445
523
  for issue in sorted(issues, key=lambda x: x.updated_at, reverse=True):
@@ -447,73 +525,83 @@ def board(
447
525
  "feature": "green",
448
526
  "chore": "blue",
449
527
  "fix": "red",
450
- "epic": "magenta"
528
+ "epic": "magenta",
451
529
  }.get(issue.type, "white")
452
-
530
+
453
531
  issue_list.append(
454
532
  Panel(
455
533
  f"[{type_color}]{issue.id}[/{type_color}]\n{issue.title}",
456
534
  expand=True,
457
- padding=(0, 1)
535
+ padding=(0, 1),
458
536
  )
459
537
  )
460
-
538
+
461
539
  from rich.console import Group
540
+
462
541
  content = Group(*issue_list) if issue_list else "[dim]Empty[/dim]"
463
-
542
+
464
543
  columns.append(
465
544
  Panel(
466
545
  content,
467
546
  title=stage_titles.get(stage, stage.upper()),
468
547
  width=35,
469
- padding=(1, 1)
548
+ padding=(1, 1),
470
549
  )
471
550
  )
472
551
 
473
552
  console.print(Columns(columns, equal=True, expand=True))
474
553
 
554
+
475
555
  @app.command("list")
476
556
  def list_cmd(
477
- status: Optional[str] = typer.Option(None, "--status", "-s", help="Filter by status (open, closed, backlog, all)"),
557
+ status: Optional[str] = typer.Option(
558
+ None, "--status", "-s", help="Filter by status (open, closed, backlog, all)"
559
+ ),
478
560
  type: Optional[str] = typer.Option(None, "--type", "-t", help="Filter by type"),
479
561
  stage: Optional[str] = typer.Option(None, "--stage", help="Filter by stage"),
480
- root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
481
- workspace: bool = typer.Option(False, "--workspace", "-w", help="Include issues from workspace members"),
562
+ root: Optional[str] = typer.Option(
563
+ None, "--root", help="Override issues root directory"
564
+ ),
565
+ workspace: bool = typer.Option(
566
+ False, "--workspace", "-w", help="Include issues from workspace members"
567
+ ),
482
568
  json: AgentOutput = False,
483
569
  ):
484
570
  """List issues in a table format with filtering."""
485
571
  config = get_config()
486
572
  issues_root = _resolve_issues_root(config, root)
487
-
573
+
488
574
  # Validation
489
575
  if status and status.lower() not in ["open", "closed", "backlog", "all"]:
490
- OutputManager.error(f"Invalid status: {status}. Use open, closed, backlog or all.")
491
- raise typer.Exit(code=1)
492
-
576
+ OutputManager.error(
577
+ f"Invalid status: {status}. Use open, closed, backlog or all."
578
+ )
579
+ raise typer.Exit(code=1)
580
+
493
581
  target_status = status.lower() if status else "open"
494
-
582
+
495
583
  issues = core.list_issues(issues_root, recursive_workspace=workspace)
496
584
  filtered = []
497
-
585
+
498
586
  for i in issues:
499
587
  # Status Filter
500
588
  if target_status != "all":
501
589
  if i.status != target_status:
502
590
  continue
503
-
591
+
504
592
  # Type Filter
505
593
  if type and i.type != type:
506
594
  continue
507
-
595
+
508
596
  # Stage Filter
509
597
  if stage and i.stage != stage:
510
598
  continue
511
-
599
+
512
600
  filtered.append(i)
513
-
601
+
514
602
  # Sort: Updated Descending
515
603
  filtered.sort(key=lambda x: x.updated_at, reverse=True)
516
-
604
+
517
605
  if OutputManager.is_agent_mode():
518
606
  OutputManager.print(filtered)
519
607
  return
@@ -521,6 +609,7 @@ def list_cmd(
521
609
  # Render
522
610
  _render_issues_table(filtered, title=f"Issues ({len(filtered)})")
523
611
 
612
+
524
613
  def _render_issues_table(issues: List[IssueMetadata], title: str = "Issues"):
525
614
  table = Table(title=title, show_header=True, header_style="bold magenta")
526
615
  table.add_column("ID", style="cyan", width=12)
@@ -529,120 +618,153 @@ def _render_issues_table(issues: List[IssueMetadata], title: str = "Issues"):
529
618
  table.add_column("Stage", width=10)
530
619
  table.add_column("Title", style="white")
531
620
  table.add_column("Updated", style="dim", width=20)
532
-
621
+
533
622
  type_colors = {
534
623
  IssueType.EPIC: "magenta",
535
624
  IssueType.FEATURE: "green",
536
625
  IssueType.CHORE: "blue",
537
- IssueType.FIX: "red"
626
+ IssueType.FIX: "red",
538
627
  }
539
-
628
+
540
629
  status_colors = {
541
630
  IssueStatus.OPEN: "green",
542
631
  IssueStatus.BACKLOG: "blue",
543
- IssueStatus.CLOSED: "dim"
632
+ IssueStatus.CLOSED: "dim",
544
633
  }
545
634
 
546
635
  for i in issues:
547
636
  t_color = type_colors.get(i.type, "white")
548
637
  s_color = status_colors.get(i.status, "white")
549
-
638
+
550
639
  stage_str = i.stage if i.stage else "-"
551
640
  updated_str = i.updated_at.strftime("%Y-%m-%d %H:%M")
552
-
641
+
553
642
  table.add_row(
554
643
  i.id,
555
644
  f"[{t_color}]{i.type}[/{t_color}]",
556
645
  f"[{s_color}]{i.status}[/{s_color}]",
557
646
  stage_str,
558
647
  i.title,
559
- updated_str
648
+ updated_str,
560
649
  )
561
-
650
+
562
651
  console.print(table)
563
652
 
653
+
564
654
  @app.command("query")
565
655
  def query_cmd(
566
656
  query: str = typer.Argument(..., help="Search query (e.g. '+bug -ui' or 'login')"),
567
- root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
657
+ root: Optional[str] = typer.Option(
658
+ None, "--root", help="Override issues root directory"
659
+ ),
568
660
  json: AgentOutput = False,
569
661
  ):
570
662
  """
571
663
  Search issues using advanced syntax.
572
-
664
+
573
665
  Syntax:
574
666
  term : Must include 'term' (implicit AND)
575
667
  +term : Must include 'term'
576
668
  -term : Must NOT include 'term'
577
-
669
+
578
670
  Scope: ID, Title, Body, Tags, Status, Stage, Dependencies, Related.
579
671
  """
580
672
  config = get_config()
581
673
  issues_root = _resolve_issues_root(config, root)
582
-
674
+
583
675
  results = core.search_issues(issues_root, query)
584
-
676
+
585
677
  # Sort by relevance? Or just updated?
586
678
  # For now, updated at descending is useful.
587
679
  results.sort(key=lambda x: x.updated_at, reverse=True)
588
-
680
+
589
681
  if OutputManager.is_agent_mode():
590
682
  OutputManager.print(results)
591
683
  return
592
684
 
593
- _render_issues_table(results, title=f"Search Results for '{query}' ({len(results)})")
685
+ _render_issues_table(
686
+ results, title=f"Search Results for '{query}' ({len(results)})"
687
+ )
688
+
594
689
 
595
690
  @app.command("scope")
596
691
  def scope(
597
692
  sprint: Optional[str] = typer.Option(None, "--sprint", help="Filter by Sprint ID"),
598
- all: bool = typer.Option(False, "--all", "-a", help="Show all, otherwise show only open items"),
599
- recursive: bool = typer.Option(False, "--recursive", "-r", help="Recursively scan subdirectories"),
600
- root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
601
- workspace: bool = typer.Option(False, "--workspace", "-w", help="Include issues from workspace members"),
693
+ all: bool = typer.Option(
694
+ False, "--all", "-a", help="Show all, otherwise show only open items"
695
+ ),
696
+ recursive: bool = typer.Option(
697
+ False, "--recursive", "-r", help="Recursively scan subdirectories"
698
+ ),
699
+ root: Optional[str] = typer.Option(
700
+ None, "--root", help="Override issues root directory"
701
+ ),
702
+ workspace: bool = typer.Option(
703
+ False, "--workspace", "-w", help="Include issues from workspace members"
704
+ ),
602
705
  json: AgentOutput = False,
603
706
  ):
604
707
  """Show progress tree."""
605
708
  config = get_config()
606
709
  issues_root = _resolve_issues_root(config, root)
607
-
710
+
608
711
  issues = core.list_issues(issues_root, recursive_workspace=workspace)
609
712
  filtered_issues = []
610
-
713
+
611
714
  for meta in issues:
612
715
  if sprint and meta.sprint != sprint:
613
716
  continue
614
717
  if not all and meta.status != IssueStatus.OPEN:
615
718
  continue
616
719
  filtered_issues.append(meta)
617
-
720
+
618
721
  issues = filtered_issues
619
722
 
620
723
  if OutputManager.is_agent_mode():
621
724
  OutputManager.print(issues)
622
725
  return
623
726
 
624
- tree = Tree(f"[bold blue]Monoco Issue Scope[/bold blue]")
727
+ tree = Tree("[bold blue]Monoco Issue Scope[/bold blue]")
625
728
  epics = sorted([i for i in issues if i.type == "epic"], key=lambda x: x.id)
626
729
  stories = [i for i in issues if i.type == "feature"]
627
730
  tasks = [i for i in issues if i.type in ["chore", "fix"]]
628
731
 
629
- status_map = {"open": "[blue]●[/blue]", "closed": "[green]✔[/green]", "backlog": "[dim]💤[/dim]"}
732
+ status_map = {
733
+ "open": "[blue]●[/blue]",
734
+ "closed": "[green]✔[/green]",
735
+ "backlog": "[dim]💤[/dim]",
736
+ }
630
737
 
631
738
  for epic in epics:
632
- epic_node = tree.add(f"{status_map[epic.status]} [bold]{epic.id}[/bold]: {epic.title}")
633
- child_stories = sorted([s for s in stories if s.parent == epic.id], key=lambda x: x.id)
739
+ epic_node = tree.add(
740
+ f"{status_map[epic.status]} [bold]{epic.id}[/bold]: {epic.title}"
741
+ )
742
+ child_stories = sorted(
743
+ [s for s in stories if s.parent == epic.id], key=lambda x: x.id
744
+ )
634
745
  for story in child_stories:
635
- story_node = epic_node.add(f"{status_map[story.status]} [bold]{story.id}[/bold]: {story.title}")
636
- child_tasks = sorted([t for t in tasks if t.parent == story.id], key=lambda x: x.id)
746
+ story_node = epic_node.add(
747
+ f"{status_map[story.status]} [bold]{story.id}[/bold]: {story.title}"
748
+ )
749
+ child_tasks = sorted(
750
+ [t for t in tasks if t.parent == story.id], key=lambda x: x.id
751
+ )
637
752
  for task in child_tasks:
638
- story_node.add(f"{status_map[task.status]} [bold]{task.id}[/bold]: {task.title}")
753
+ story_node.add(
754
+ f"{status_map[task.status]} [bold]{task.id}[/bold]: {task.title}"
755
+ )
639
756
 
640
757
  console.print(Panel(tree, expand=False))
641
758
 
759
+
642
760
  @app.command("sync-files")
643
761
  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"),
762
+ issue_id: Optional[str] = typer.Argument(
763
+ None, help="Issue ID to sync (default: current context)"
764
+ ),
765
+ root: Optional[str] = typer.Option(
766
+ None, "--root", help="Override issues root directory"
767
+ ),
646
768
  json: AgentOutput = False,
647
769
  ):
648
770
  """
@@ -651,36 +773,41 @@ def sync_files(
651
773
  config = get_config()
652
774
  issues_root = _resolve_issues_root(config, root)
653
775
  project_root = _resolve_project_root(config)
654
-
776
+
655
777
  if not issue_id:
656
778
  # Infer from branch
657
779
  from monoco.core import git
780
+
658
781
  current = git.get_current_branch(project_root)
659
782
  # Try to parse ID from branch "feat/issue-123-slug"
660
783
  import re
784
+
661
785
  match = re.search(r"(?:feat|fix|chore|epic)/([a-zA-Z]+-\d+)", current)
662
786
  if match:
663
- issue_id = match.group(1).upper()
787
+ issue_id = match.group(1).upper()
664
788
  else:
665
- OutputManager.error("Cannot infer Issue ID from current branch. Please specify Issue ID.")
666
- raise typer.Exit(code=1)
789
+ OutputManager.error(
790
+ "Cannot infer Issue ID from current branch. Please specify Issue ID."
791
+ )
792
+ raise typer.Exit(code=1)
667
793
 
668
794
  try:
669
795
  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
- })
796
+ OutputManager.print({"id": issue_id, "status": "synced", "files": changed})
675
797
  except Exception as e:
676
798
  OutputManager.error(str(e))
677
799
  raise typer.Exit(code=1)
678
800
 
801
+
679
802
  @app.command("inspect")
680
803
  def inspect(
681
804
  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"),
805
+ root: Optional[str] = typer.Option(
806
+ None, "--root", help="Override issues root directory"
807
+ ),
808
+ ast: bool = typer.Option(
809
+ False, "--ast", help="Output JSON AST structure for debugging"
810
+ ),
684
811
  json: AgentOutput = False,
685
812
  ):
686
813
  """
@@ -688,7 +815,7 @@ def inspect(
688
815
  """
689
816
  config = get_config()
690
817
  issues_root = _resolve_issues_root(config, root)
691
-
818
+
692
819
  # Try as Path
693
820
  target_path = Path(target)
694
821
  if target_path.exists() and target_path.is_file():
@@ -698,12 +825,13 @@ def inspect(
698
825
  # Search path logic is needed? Or core.find_issue_path
699
826
  path = core.find_issue_path(issues_root, target)
700
827
  if not path:
701
- OutputManager.error(f"Issue or file {target} not found.")
702
- raise typer.Exit(code=1)
703
-
828
+ OutputManager.error(f"Issue or file {target} not found.")
829
+ raise typer.Exit(code=1)
830
+
704
831
  # AST Debug Mode
705
832
  if ast:
706
833
  from .domain.parser import MarkdownParser
834
+
707
835
  content = path.read_text()
708
836
  try:
709
837
  domain_issue = MarkdownParser.parse(content, path=str(path))
@@ -715,10 +843,10 @@ def inspect(
715
843
 
716
844
  # Normal Mode
717
845
  meta = core.parse_issue(path)
718
-
846
+
719
847
  if not meta:
720
- OutputManager.error(f"Could not parse issue {target}.")
721
- raise typer.Exit(code=1)
848
+ OutputManager.error(f"Could not parse issue {target}.")
849
+ raise typer.Exit(code=1)
722
850
 
723
851
  # In JSON mode (AgentOutput), we might want to return rich data
724
852
  if OutputManager.is_agent_mode():
@@ -727,45 +855,76 @@ def inspect(
727
855
  # For human, print yaml-like or table
728
856
  console.print(meta)
729
857
 
858
+
730
859
  @app.command("lint")
731
860
  def lint(
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"),
736
- root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
861
+ files: Optional[List[str]] = typer.Argument(
862
+ None, help="List of specific files to validate"
863
+ ),
864
+ recursive: bool = typer.Option(
865
+ False, "--recursive", "-r", help="Recursively scan subdirectories"
866
+ ),
867
+ fix: bool = typer.Option(
868
+ False,
869
+ "--fix",
870
+ help="Attempt to automatically fix issues (e.g. missing headings)",
871
+ ),
872
+ format: str = typer.Option(
873
+ "table", "--format", "-f", help="Output format (table, json)"
874
+ ),
875
+ file: Optional[str] = typer.Option(
876
+ None,
877
+ "--file",
878
+ help="[Deprecated] Validate a single file. Use arguments instead.",
879
+ ),
880
+ root: Optional[str] = typer.Option(
881
+ None, "--root", help="Override issues root directory"
882
+ ),
737
883
  json: AgentOutput = False,
738
884
  ):
739
885
  """Verify the integrity of the Issues directory (declarative check)."""
740
886
  from . import linter
887
+
741
888
  config = get_config()
742
889
  issues_root = _resolve_issues_root(config, root)
743
-
890
+
744
891
  if OutputManager.is_agent_mode():
745
892
  format = "json"
746
893
 
747
- linter.run_lint(issues_root, recursive=recursive, fix=fix, format=format, file_path=file)
894
+ # Merge legacy --file option into files list
895
+ target_files = files if files else []
896
+ if file:
897
+ target_files.append(file)
898
+
899
+ linter.run_lint(
900
+ issues_root,
901
+ recursive=recursive,
902
+ fix=fix,
903
+ format=format,
904
+ file_paths=target_files if target_files else None,
905
+ )
906
+
748
907
 
749
908
  def _resolve_issues_root(config, cli_root: Optional[str]) -> Path:
750
909
  """
751
910
  Resolve the absolute path to the issues directory.
752
911
  Implements Smart Path Resolution & Workspace Awareness.
753
912
  """
754
- from monoco.core.workspace import is_project_root, find_projects
755
-
913
+ from monoco.core.workspace import is_project_root
914
+
756
915
  # 1. Handle Explicit CLI Root
757
916
  if cli_root:
758
917
  path = Path(cli_root).resolve()
759
-
918
+
760
919
  # Scenario A: User pointed to a Project Root (e.g. ./Toolkit)
761
920
  # We auto-resolve to ./Toolkit/Issues if it exists
762
921
  if is_project_root(path) and (path / "Issues").exists():
763
- return path / "Issues"
764
-
922
+ return path / "Issues"
923
+
765
924
  # Scenario B: User pointed to Issues dir directly (e.g. ./Toolkit/Issues)
766
925
  # Or user pointed to a path that will be created
767
926
  return path
768
-
927
+
769
928
  # 2. Handle Default / Contextual Execution (No --root)
770
929
  # Strict Workspace Check: If not in a project root, we rely on the config root.
771
930
  # (The global app callback already enforces presence of .monoco for most commands)
@@ -778,23 +937,37 @@ def _resolve_issues_root(config, cli_root: Optional[str]) -> Path:
778
937
  else:
779
938
  return (Path(config.paths.root) / config_issues_path).resolve()
780
939
 
940
+
781
941
  def _resolve_project_root(config) -> Path:
782
942
  """Resolve project root from config or defaults."""
783
943
  return Path(config.paths.root).resolve()
784
944
 
945
+
785
946
  @app.command("commit")
786
947
  def commit(
787
- message: Optional[str] = typer.Option(None, "--message", "-m", help="Commit message"),
788
- issue_id: Optional[str] = typer.Option(None, "--issue", "-i", help="Link commit to Issue ID"),
789
- detached: bool = typer.Option(False, "--detached", help="Flag commit as intentionally detached (no issue link)"),
790
- type: Optional[str] = typer.Option(None, "--type", "-t", help="Commit type (feat, fix, etc.)"),
948
+ message: Optional[str] = typer.Option(
949
+ None, "--message", "-m", help="Commit message"
950
+ ),
951
+ issue_id: Optional[str] = typer.Option(
952
+ None, "--issue", "-i", help="Link commit to Issue ID"
953
+ ),
954
+ detached: bool = typer.Option(
955
+ False,
956
+ "--detached",
957
+ help="Flag commit as intentionally detached (no issue link)",
958
+ ),
959
+ type: Optional[str] = typer.Option(
960
+ None, "--type", "-t", help="Commit type (feat, fix, etc.)"
961
+ ),
791
962
  scope: Optional[str] = typer.Option(None, "--scope", "-s", help="Commit scope"),
792
963
  subject: Optional[str] = typer.Option(None, "--subject", help="Commit subject"),
793
- root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
964
+ root: Optional[str] = typer.Option(
965
+ None, "--root", help="Override issues root directory"
966
+ ),
794
967
  ):
795
968
  """
796
969
  Atomic Commit: Validate (Lint) and Commit.
797
-
970
+
798
971
  Modes:
799
972
  1. Linked Commit (--issue): Commits staged changes with 'Ref: <ID>' footer.
800
973
  2. Detached Commit (--detached): Commits staged changes without link.
@@ -803,81 +976,98 @@ def commit(
803
976
  config = get_config()
804
977
  issues_root = _resolve_issues_root(config, root)
805
978
  project_root = _resolve_project_root(config)
806
-
979
+
807
980
  # 1. Lint Check (Gatekeeper)
808
981
  console.print("[dim]Running pre-commit lint check...[/dim]")
809
982
  try:
810
983
  from . import linter
984
+
811
985
  linter.check_integrity(issues_root, recursive=True)
812
986
  except Exception:
813
- pass
987
+ pass
814
988
 
815
989
  # 2. Stage & Commit
816
990
  from monoco.core import git
817
-
991
+
818
992
  try:
819
993
  # Check Staging Status
820
- code, stdout, _ = git._run_git(["diff", "--cached", "--name-only"], project_root)
994
+ code, stdout, _ = git._run_git(
995
+ ["diff", "--cached", "--name-only"], project_root
996
+ )
821
997
  staged_files = [l for l in stdout.splitlines() if l.strip()]
822
-
998
+
823
999
  # Determine Mode
824
1000
  if issue_id:
825
1001
  # MODE: Linked Commit
826
- console.print(f"[bold cyan]Linked Commit Mode[/bold cyan] (Ref: {issue_id})")
827
-
1002
+ console.print(
1003
+ f"[bold cyan]Linked Commit Mode[/bold cyan] (Ref: {issue_id})"
1004
+ )
1005
+
828
1006
  if not core.find_issue_path(issues_root, issue_id):
829
- console.print(f"[red]Error:[/red] Issue {issue_id} not found.")
830
- raise typer.Exit(code=1)
1007
+ console.print(f"[red]Error:[/red] Issue {issue_id} not found.")
1008
+ raise typer.Exit(code=1)
831
1009
 
832
1010
  if not staged_files:
833
- console.print("[yellow]No staged files.[/yellow] Please `git add` files.")
834
- raise typer.Exit(code=1)
1011
+ console.print(
1012
+ "[yellow]No staged files.[/yellow] Please `git add` files."
1013
+ )
1014
+ raise typer.Exit(code=1)
835
1015
 
836
1016
  if not message:
837
1017
  if not type or not subject:
838
- console.print("[red]Error:[/red] Provide --message OR (--type and --subject).")
1018
+ console.print(
1019
+ "[red]Error:[/red] Provide --message OR (--type and --subject)."
1020
+ )
839
1021
  raise typer.Exit(code=1)
840
1022
  scope_part = f"({scope})" if scope else ""
841
1023
  message = f"{type}{scope_part}: {subject}"
842
-
1024
+
843
1025
  if f"Ref: {issue_id}" not in message:
844
1026
  message += f"\n\nRef: {issue_id}"
845
-
1027
+
846
1028
  commit_hash = git.git_commit(project_root, message)
847
1029
  console.print(f"[green]✔ Committed:[/green] {commit_hash[:7]}")
848
1030
 
849
1031
  elif detached:
850
1032
  # MODE: Detached
851
- console.print(f"[bold yellow]Detached Commit Mode[/bold yellow]")
852
-
1033
+ console.print("[bold yellow]Detached Commit Mode[/bold yellow]")
1034
+
853
1035
  if not staged_files:
854
- console.print("[yellow]No staged files.[/yellow] Please `git add` files.")
855
- raise typer.Exit(code=1)
1036
+ console.print(
1037
+ "[yellow]No staged files.[/yellow] Please `git add` files."
1038
+ )
1039
+ raise typer.Exit(code=1)
856
1040
 
857
1041
  if not message:
858
1042
  console.print("[red]Error:[/red] Detached commits require --message.")
859
1043
  raise typer.Exit(code=1)
860
-
1044
+
861
1045
  commit_hash = git.git_commit(project_root, message)
862
1046
  console.print(f"[green]✔ Committed:[/green] {commit_hash[:7]}")
863
-
1047
+
864
1048
  else:
865
1049
  # MODE: Implicit / Auto-DB
866
1050
  # Strict Policy: Only allow if changes are constrained to Issues/ directory
867
-
1051
+
868
1052
  # Check if any non-issue files are staged
869
1053
  # (We assume issues dir is 'Issues/')
870
1054
  try:
871
1055
  rel_issues = issues_root.relative_to(project_root)
872
1056
  issues_prefix = str(rel_issues)
873
1057
  except ValueError:
874
- issues_prefix = "Issues" # Fallback
1058
+ issues_prefix = "Issues" # Fallback
1059
+
1060
+ non_issue_staged = [
1061
+ f for f in staged_files if not f.startswith(issues_prefix)
1062
+ ]
875
1063
 
876
- non_issue_staged = [f for f in staged_files if not f.startswith(issues_prefix)]
877
-
878
1064
  if non_issue_staged:
879
- console.print(f"[red]⛔ Strict Policy:[/red] Code changes detected in staging ({len(non_issue_staged)} files).")
880
- console.print("You must specify [bold]--issue <ID>[/bold] or [bold]--detached[/bold].")
1065
+ console.print(
1066
+ f"[red] Strict Policy:[/red] Code changes detected in staging ({len(non_issue_staged)} files)."
1067
+ )
1068
+ console.print(
1069
+ "You must specify [bold]--issue <ID>[/bold] or [bold]--detached[/bold]."
1070
+ )
881
1071
  raise typer.Exit(code=1)
882
1072
 
883
1073
  # If nothing staged, check unstaged Issue files (Legacy Auto-Add)
@@ -886,10 +1076,10 @@ def commit(
886
1076
  if not status_files:
887
1077
  console.print("[yellow]Nothing to commit.[/yellow]")
888
1078
  return
889
-
1079
+
890
1080
  # Auto-stage Issue files
891
1081
  git.git_add(project_root, status_files)
892
- staged_files = status_files # Now they are staged
1082
+ staged_files = status_files # Now they are staged
893
1083
  else:
894
1084
  pass
895
1085
 
@@ -900,25 +1090,30 @@ def commit(
900
1090
  fpath = project_root / staged_files[0]
901
1091
  match = core.parse_issue(fpath)
902
1092
  if match:
903
- action = "update"
904
- message = f"docs(issues): {action} {match.id} {match.title}"
1093
+ action = "update"
1094
+ message = f"docs(issues): {action} {match.id} {match.title}"
905
1095
  else:
906
- message = f"docs(issues): update {staged_files[0]}"
1096
+ message = f"docs(issues): update {staged_files[0]}"
907
1097
  else:
908
- message = f"docs(issues): batch update {cnt} files"
1098
+ message = f"docs(issues): batch update {cnt} files"
909
1099
 
910
1100
  commit_hash = git.git_commit(project_root, message)
911
- console.print(f"[green]✔ Committed (DB):[/green] {commit_hash[:7]} - {message}")
912
-
1101
+ console.print(
1102
+ f"[green]✔ Committed (DB):[/green] {commit_hash[:7]} - {message}"
1103
+ )
1104
+
913
1105
  except Exception as e:
914
- console.print(f"[red]Git Error:[/red] {e}")
915
- raise typer.Exit(code=1)
1106
+ console.print(f"[red]Git Error:[/red] {e}")
1107
+ raise typer.Exit(code=1)
1108
+
916
1109
 
917
1110
  @lsp_app.command("definition")
918
1111
  def lsp_definition(
919
1112
  file: str = typer.Option(..., "--file", "-f", help="Abs path to file"),
920
1113
  line: int = typer.Option(..., "--line", "-l", help="0-indexed line number"),
921
- character: int = typer.Option(..., "--char", "-c", help="0-indexed character number"),
1114
+ character: int = typer.Option(
1115
+ ..., "--char", "-c", help="0-indexed character number"
1116
+ ),
922
1117
  ):
923
1118
  """
924
1119
  Handle textDocument/definition request.
@@ -929,24 +1124,25 @@ def lsp_definition(
929
1124
  from monoco.features.issue.lsp import DefinitionProvider
930
1125
 
931
1126
  config = get_config()
932
- # Workspace Root resolution is key here.
1127
+ # Workspace Root resolution is key here.
933
1128
  # If we are in a workspace, we want the workspace root, not just issue root.
934
1129
  # _resolve_project_root returns the closest project root or monoco root.
935
1130
  workspace_root = _resolve_project_root(config)
936
1131
  # Search for topmost workspace root to enable cross-project navigation
937
1132
  current_best = workspace_root
938
1133
  for parent in [workspace_root] + list(workspace_root.parents):
939
- if (parent / ".monoco" / "workspace.yaml").exists() or (parent / ".monoco" / "project.yaml").exists():
1134
+ if (parent / ".monoco" / "workspace.yaml").exists() or (
1135
+ parent / ".monoco" / "project.yaml"
1136
+ ).exists():
940
1137
  current_best = parent
941
1138
  workspace_root = current_best
942
-
1139
+
943
1140
  provider = DefinitionProvider(workspace_root)
944
1141
  file_path = Path(file)
945
-
1142
+
946
1143
  locations = provider.provide_definition(
947
- file_path,
948
- Position(line=line, character=character)
1144
+ file_path, Position(line=line, character=character)
949
1145
  )
950
-
1146
+
951
1147
  # helper to serialize
952
- print(json.dumps([l.model_dump(mode='json') for l in locations]))
1148
+ print(json.dumps([l.model_dump(mode="json") for l in locations]))