monoco-toolkit 0.1.5__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.
@@ -0,0 +1,710 @@
1
+ import typer
2
+ from pathlib import Path
3
+ from typing import Optional, List
4
+ from rich.console import Console
5
+ from rich.tree import Tree
6
+ from rich.panel import Panel
7
+ from rich.table import Table
8
+ import typer
9
+
10
+ from monoco.core.config import get_config
11
+ from monoco.core.output import print_output
12
+ from .models import IssueType, IssueStatus, IssueSolution, IssueStage, IsolationType, IssueMetadata
13
+ from . import core
14
+
15
+ app = typer.Typer(help="Agent-Native Issue Management.")
16
+ backlog_app = typer.Typer(help="Manage backlog operations.")
17
+ app.add_typer(backlog_app, name="backlog")
18
+ console = Console()
19
+
20
+ @app.command("create")
21
+ def create(
22
+ type: IssueType = typer.Argument(..., help="Issue type (epic, feature, chore, fix)"),
23
+ title: str = typer.Option(..., "--title", "-t", help="Issue title"),
24
+ parent: Optional[str] = typer.Option(None, "--parent", "-p", help="Parent Issue ID"),
25
+ 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)"),
27
+ dependencies: List[str] = typer.Option([], "--dependency", "-d", help="Issue dependency ID(s)"),
28
+ related: List[str] = typer.Option([], "--related", "-r", help="Related Issue ID(s)"),
29
+ subdir: Optional[str] = typer.Option(None, "--subdir", "-s", help="Subdirectory for organization (e.g. 'Backend/Auth')"),
30
+ sprint: Optional[str] = typer.Option(None, "--sprint", help="Sprint ID"),
31
+ tags: List[str] = typer.Option([], "--tag", help="Tags"),
32
+ root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
33
+ ):
34
+ """Create a new issue."""
35
+ config = get_config()
36
+ issues_root = _resolve_issues_root(config, root)
37
+ status = IssueStatus.BACKLOG if is_backlog else IssueStatus.OPEN
38
+
39
+ if parent:
40
+ parent_path = core.find_issue_path(issues_root, parent)
41
+ if not parent_path:
42
+ console.print(f"[red]✘ Error:[/red] Parent issue {parent} not found.")
43
+ raise typer.Exit(code=1)
44
+
45
+ try:
46
+ issue, path = core.create_issue_file(
47
+ issues_root,
48
+ type,
49
+ title,
50
+ parent,
51
+ status=status,
52
+ stage=stage,
53
+ dependencies=dependencies,
54
+ related=related,
55
+ subdir=subdir,
56
+ sprint=sprint,
57
+ tags=tags
58
+ )
59
+
60
+ try:
61
+ rel_path = path.relative_to(Path.cwd())
62
+ except ValueError:
63
+ rel_path = path
64
+
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]")
67
+ except ValueError as e:
68
+ console.print(f"[red]✘ Error:[/red] {str(e)}")
69
+ raise typer.Exit(code=1)
70
+
71
+ @app.command("open")
72
+ def move_open(
73
+ issue_id: str = typer.Argument(..., help="Issue ID to open"),
74
+ root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
75
+ ):
76
+ """Move issue to open status and set stage to Todo."""
77
+ config = get_config()
78
+ issues_root = _resolve_issues_root(config, root)
79
+ try:
80
+ # 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.")
83
+ except Exception as e:
84
+ console.print(f"[red]✘ Error:[/red] {str(e)}")
85
+ raise typer.Exit(code=1)
86
+
87
+ @app.command("start")
88
+ def start(
89
+ issue_id: str = typer.Argument(..., help="Issue ID to start"),
90
+ branch: bool = typer.Option(False, "--branch", "-b", help="Start in a new git branch"),
91
+ worktree: bool = typer.Option(False, "--worktree", "-w", help="Start in a new git worktree"),
92
+ root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
93
+ ):
94
+ """Start working on an issue (Stage -> Doing)."""
95
+ config = get_config()
96
+ issues_root = _resolve_issues_root(config, root)
97
+ project_root = _resolve_project_root(config)
98
+
99
+ if branch and worktree:
100
+ console.print("[red]Error:[/red] Cannot specify both --branch and --worktree.")
101
+ raise typer.Exit(code=1)
102
+
103
+ try:
104
+ # Implicitly ensure status is Open
105
+ core.update_issue(issues_root, issue_id, status=IssueStatus.OPEN, stage=IssueStage.DOING)
106
+
107
+ if branch:
108
+ 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]")
111
+ except Exception as e:
112
+ console.print(f"[red]Error:[/red] Failed to create branch: {e}")
113
+ raise typer.Exit(code=1)
114
+
115
+ if worktree:
116
+ 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]")
119
+ except Exception as e:
120
+ console.print(f"[red]Error:[/red] Failed to create worktree: {e}")
121
+ raise typer.Exit(code=1)
122
+
123
+ console.print(f"[green]🚀[/green] Issue [bold]{issue_id}[/bold] started.")
124
+ except Exception as e:
125
+ console.print(f"[red]✘ Error:[/red] {str(e)}")
126
+ raise typer.Exit(code=1)
127
+
128
+ @app.command("submit")
129
+ def submit(
130
+ issue_id: str = typer.Argument(..., help="Issue ID to submit"),
131
+ prune: bool = typer.Option(False, "--prune", help="Delete branch/worktree after submit"),
132
+ force: bool = typer.Option(False, "--force", help="Force delete branch/worktree"),
133
+ root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
134
+ ):
135
+ """Submit issue for review (Stage -> Review) and generate delivery report."""
136
+ config = get_config()
137
+ issues_root = _resolve_issues_root(config, root)
138
+ project_root = _resolve_project_root(config)
139
+ try:
140
+ # 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.")
143
+
144
+ # Delivery Report Generation
145
+ try:
146
+ core.generate_delivery_report(issues_root, issue_id, project_root)
147
+ console.print(f"[dim]✔ Delivery report appended to issue file.[/dim]")
148
+ except Exception as e:
149
+ console.print(f"[yellow]⚠ Failed to generate delivery report: {e}[/yellow]")
150
+
151
+ if prune:
152
+ 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]")
156
+ except Exception as e:
157
+ console.print(f"[red]Prune Error:[/red] {e}")
158
+ raise typer.Exit(code=1)
159
+
160
+ except Exception as e:
161
+ console.print(f"[red]✘ Error:[/red] {str(e)}")
162
+ raise typer.Exit(code=1)
163
+
164
+ @app.command("close")
165
+ def move_close(
166
+ issue_id: str = typer.Argument(..., help="Issue ID to close"),
167
+ solution: Optional[IssueSolution] = typer.Option(None, "--solution", "-s", help="Solution type"),
168
+ prune: bool = typer.Option(False, "--prune", help="Delete branch/worktree after close"),
169
+ force: bool = typer.Option(False, "--force", help="Force delete branch/worktree"),
170
+ root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
171
+ ):
172
+ """Close issue."""
173
+ config = get_config()
174
+ issues_root = _resolve_issues_root(config, root)
175
+ project_root = _resolve_project_root(config)
176
+ 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.")
179
+
180
+ if prune:
181
+ 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]")
185
+ except Exception as e:
186
+ console.print(f"[red]Prune Error:[/red] {e}")
187
+ raise typer.Exit(code=1)
188
+
189
+ except Exception as e:
190
+ console.print(f"[red]✘ Error:[/red] {str(e)}")
191
+ raise typer.Exit(code=1)
192
+
193
+ @backlog_app.command("push")
194
+ def push(
195
+ issue_id: str = typer.Argument(..., help="Issue ID to push to backlog"),
196
+ root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
197
+ ):
198
+ """Push issue to backlog."""
199
+ config = get_config()
200
+ issues_root = _resolve_issues_root(config, root)
201
+ 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.")
204
+ except Exception as e:
205
+ console.print(f"[red]✘ Error:[/red] {str(e)}")
206
+ raise typer.Exit(code=1)
207
+
208
+ @backlog_app.command("pull")
209
+ def pull(
210
+ issue_id: str = typer.Argument(..., help="Issue ID to pull from backlog"),
211
+ root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
212
+ ):
213
+ """Pull issue from backlog (Open & Todo)."""
214
+ config = get_config()
215
+ issues_root = _resolve_issues_root(config, root)
216
+ 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.")
219
+ except Exception as e:
220
+ console.print(f"[red]✘ Error:[/red] {str(e)}")
221
+ raise typer.Exit(code=1)
222
+
223
+ @app.command("cancel")
224
+ def cancel(
225
+ issue_id: str = typer.Argument(..., help="Issue ID to cancel"),
226
+ root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
227
+ ):
228
+ """Cancel issue."""
229
+ config = get_config()
230
+ issues_root = _resolve_issues_root(config, root)
231
+ 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.")
234
+ except Exception as e:
235
+ console.print(f"[red]✘ Error:[/red] {str(e)}")
236
+ raise typer.Exit(code=1)
237
+
238
+ @app.command("delete")
239
+ def delete(
240
+ issue_id: str = typer.Argument(..., help="Issue ID to delete"),
241
+ root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
242
+ ):
243
+ """Physically remove an issue file."""
244
+ config = get_config()
245
+ issues_root = _resolve_issues_root(config, root)
246
+ try:
247
+ core.delete_issue_file(issues_root, issue_id)
248
+ console.print(f"[red]✔[/red] Issue [bold]{issue_id}[/bold] physically deleted.")
249
+ except Exception as e:
250
+ console.print(f"[red]✘ Error:[/red] {str(e)}")
251
+ raise typer.Exit(code=1)
252
+
253
+ @app.command("move")
254
+ def move(
255
+ issue_id: str = typer.Argument(..., help="Issue ID to move"),
256
+ target: str = typer.Option(..., "--to", help="Target project directory (e.g., ../OtherProject)"),
257
+ renumber: bool = typer.Option(False, "--renumber", help="Automatically renumber on ID conflict"),
258
+ root: Optional[str] = typer.Option(None, "--root", help="Override source issues root directory"),
259
+ ):
260
+ """Move an issue to another project."""
261
+ config = get_config()
262
+ source_issues_root = _resolve_issues_root(config, root)
263
+
264
+ # Resolve target project
265
+ target_path = Path(target).resolve()
266
+
267
+ # Check if target is a project root or Issues directory
268
+ if (target_path / "Issues").exists():
269
+ target_issues_root = target_path / "Issues"
270
+ elif target_path.name == "Issues" and target_path.exists():
271
+ target_issues_root = target_path
272
+ else:
273
+ console.print(f"[red]✘ Error:[/red] Target path must be a project root with 'Issues' directory or an 'Issues' directory itself.")
274
+ raise typer.Exit(code=1)
275
+
276
+ try:
277
+ updated_meta, new_path = core.move_issue(
278
+ source_issues_root,
279
+ issue_id,
280
+ target_issues_root,
281
+ renumber=renumber
282
+ )
283
+
284
+ try:
285
+ rel_path = new_path.relative_to(Path.cwd())
286
+ except ValueError:
287
+ rel_path = new_path
288
+
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]")
295
+
296
+ except FileNotFoundError as e:
297
+ console.print(f"[red]✘ Error:[/red] {str(e)}")
298
+ raise typer.Exit(code=1)
299
+ except ValueError as e:
300
+ console.print(f"[red]✘ Conflict:[/red] {str(e)}")
301
+ raise typer.Exit(code=1)
302
+ except Exception as e:
303
+ console.print(f"[red]✘ Error:[/red] {str(e)}")
304
+ raise typer.Exit(code=1)
305
+
306
+ @app.command("board")
307
+ def board(
308
+ root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
309
+ ):
310
+ """Visualize issues in a Kanban board."""
311
+ config = get_config()
312
+ issues_root = _resolve_issues_root(config, root)
313
+
314
+ board_data = core.get_board_data(issues_root)
315
+
316
+ from rich.columns import Columns
317
+ from rich.console import RenderableType
318
+
319
+ columns: List[RenderableType] = []
320
+
321
+ stage_titles = {
322
+ "todo": "[bold white]TODO[/bold white]",
323
+ "doing": "[bold yellow]DOING[/bold yellow]",
324
+ "review": "[bold cyan]REVIEW[/bold cyan]",
325
+ "done": "[bold green]DONE[/bold green]"
326
+ }
327
+
328
+ for stage, issues in board_data.items():
329
+ issue_list = []
330
+ for issue in sorted(issues, key=lambda x: x.updated_at, reverse=True):
331
+ type_color = {
332
+ IssueType.FEATURE: "green",
333
+ IssueType.CHORE: "blue",
334
+ IssueType.FIX: "red",
335
+ IssueType.EPIC: "magenta"
336
+ }.get(issue.type, "white")
337
+
338
+ issue_list.append(
339
+ Panel(
340
+ f"[{type_color}]{issue.id}[/{type_color}]\n{issue.title}",
341
+ expand=True,
342
+ padding=(0, 1)
343
+ )
344
+ )
345
+
346
+ from rich.console import Group
347
+ content = Group(*issue_list) if issue_list else "[dim]Empty[/dim]"
348
+
349
+ columns.append(
350
+ Panel(
351
+ content,
352
+ title=stage_titles.get(stage, stage.upper()),
353
+ width=35,
354
+ padding=(1, 1)
355
+ )
356
+ )
357
+
358
+ console.print(Columns(columns, equal=True, expand=True))
359
+
360
+ @app.command("list")
361
+ def list_cmd(
362
+ 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"),
365
+ root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
366
+ workspace: bool = typer.Option(False, "--workspace", "-w", help="Include issues from workspace members"),
367
+ ):
368
+ """List issues in a table format with filtering."""
369
+ config = get_config()
370
+ issues_root = _resolve_issues_root(config, root)
371
+
372
+ # Validation
373
+ 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.")
375
+ raise typer.Exit(code=1)
376
+
377
+ target_status = status.lower() if status else "open"
378
+
379
+ issues = core.list_issues(issues_root, recursive_workspace=workspace)
380
+ filtered = []
381
+
382
+ for i in issues:
383
+ # Status Filter
384
+ if target_status != "all":
385
+ if i.status.value != target_status:
386
+ continue
387
+
388
+ # Type Filter
389
+ if type and i.type != type:
390
+ continue
391
+
392
+ # Stage Filter
393
+ if stage and i.stage != stage:
394
+ continue
395
+
396
+ filtered.append(i)
397
+
398
+ # Sort: Updated Descending
399
+ filtered.append(i)
400
+
401
+ # Sort: Updated Descending
402
+ filtered.sort(key=lambda x: x.updated_at, reverse=True)
403
+
404
+ # Render
405
+ _render_issues_table(filtered, title=f"Issues ({len(filtered)})")
406
+
407
+ def _render_issues_table(issues: List[IssueMetadata], title: str = "Issues"):
408
+ table = Table(title=title, show_header=True, header_style="bold magenta")
409
+ table.add_column("ID", style="cyan", width=12)
410
+ table.add_column("Type", width=10)
411
+ table.add_column("Status", width=10)
412
+ table.add_column("Stage", width=10)
413
+ table.add_column("Title", style="white")
414
+ table.add_column("Updated", style="dim", width=20)
415
+
416
+ type_colors = {
417
+ IssueType.EPIC: "magenta",
418
+ IssueType.FEATURE: "green",
419
+ IssueType.CHORE: "blue",
420
+ IssueType.FIX: "red"
421
+ }
422
+
423
+ status_colors = {
424
+ IssueStatus.OPEN: "green",
425
+ IssueStatus.BACKLOG: "blue",
426
+ IssueStatus.CLOSED: "dim"
427
+ }
428
+
429
+ for i in issues:
430
+ t_color = type_colors.get(i.type, "white")
431
+ s_color = status_colors.get(i.status, "white")
432
+
433
+ stage_str = i.stage.value if i.stage else "-"
434
+ updated_str = i.updated_at.strftime("%Y-%m-%d %H:%M")
435
+
436
+ table.add_row(
437
+ i.id,
438
+ f"[{t_color}]{i.type.value}[/{t_color}]",
439
+ f"[{s_color}]{i.status.value}[/{s_color}]",
440
+ stage_str,
441
+ i.title,
442
+ updated_str
443
+ )
444
+
445
+ console.print(table)
446
+
447
+ @app.command("query")
448
+ def query_cmd(
449
+ query: str = typer.Argument(..., help="Search query (e.g. '+bug -ui' or 'login')"),
450
+ root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
451
+ ):
452
+ """
453
+ Search issues using advanced syntax.
454
+
455
+ Syntax:
456
+ term : Must include 'term' (implicit AND)
457
+ +term : Must include 'term'
458
+ -term : Must NOT include 'term'
459
+
460
+ Scope: ID, Title, Body, Tags, Status, Stage, Dependencies, Related.
461
+ """
462
+ config = get_config()
463
+ issues_root = _resolve_issues_root(config, root)
464
+
465
+ results = core.search_issues(issues_root, query)
466
+
467
+ # Sort by relevance? Or just updated?
468
+ # For now, updated at descending is useful.
469
+ results.sort(key=lambda x: x.updated_at, reverse=True)
470
+
471
+ _render_issues_table(results, title=f"Search Results for '{query}' ({len(results)})")
472
+
473
+ @app.command("scope")
474
+ def scope(
475
+ sprint: Optional[str] = typer.Option(None, "--sprint", help="Filter by Sprint ID"),
476
+ all: bool = typer.Option(False, "--all", "-a", help="Show all, otherwise show only open items"),
477
+ recursive: bool = typer.Option(False, "--recursive", "-r", help="Recursively scan subdirectories"),
478
+ root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
479
+ workspace: bool = typer.Option(False, "--workspace", "-w", help="Include issues from workspace members"),
480
+ ):
481
+ """Show progress tree."""
482
+ config = get_config()
483
+ issues_root = _resolve_issues_root(config, root)
484
+
485
+ issues = core.list_issues(issues_root, recursive_workspace=workspace)
486
+ filtered_issues = []
487
+
488
+ for meta in issues:
489
+ if sprint and meta.sprint != sprint:
490
+ continue
491
+ if not all and meta.status != IssueStatus.OPEN:
492
+ continue
493
+ filtered_issues.append(meta)
494
+
495
+ issues = filtered_issues
496
+
497
+ 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]]
501
+
502
+ status_map = {IssueStatus.OPEN: "[blue]●[/blue]", IssueStatus.CLOSED: "[green]✔[/green]", IssueStatus.BACKLOG: "[dim]💤[/dim]"}
503
+
504
+ for epic in epics:
505
+ epic_node = tree.add(f"{status_map[epic.status]} [bold]{epic.id}[/bold]: {epic.title}")
506
+ child_stories = sorted([s for s in stories if s.parent == epic.id], key=lambda x: x.id)
507
+ for story in child_stories:
508
+ story_node = epic_node.add(f"{status_map[story.status]} [bold]{story.id}[/bold]: {story.title}")
509
+ child_tasks = sorted([t for t in tasks if t.parent == story.id], key=lambda x: x.id)
510
+ for task in child_tasks:
511
+ story_node.add(f"{status_map[task.status]} [bold]{task.id}[/bold]: {task.title}")
512
+
513
+ console.print(Panel(tree, expand=False))
514
+
515
+ @app.command("lint")
516
+ def lint(
517
+ recursive: bool = typer.Option(False, "--recursive", "-r", help="Recursively scan subdirectories"),
518
+ root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
519
+ ):
520
+ """Verify the integrity of the Issues directory (declarative check)."""
521
+ from . import linter
522
+ config = get_config()
523
+ issues_root = _resolve_issues_root(config, root)
524
+ linter.run_lint(issues_root, recursive=recursive)
525
+
526
+ def _resolve_issues_root(config, cli_root: Optional[str]) -> Path:
527
+ """
528
+ Resolve the absolute path to the issues directory.
529
+ Implements Smart Path Resolution & Workspace Awareness.
530
+ """
531
+ from monoco.core.workspace import is_project_root, find_projects
532
+
533
+ # 1. Handle Explicit CLI Root
534
+ if cli_root:
535
+ path = Path(cli_root).resolve()
536
+
537
+ # Scenario A: User pointed to a Project Root (e.g. ./Toolkit)
538
+ # We auto-resolve to ./Toolkit/Issues if it exists
539
+ if is_project_root(path) and (path / "Issues").exists():
540
+ return path / "Issues"
541
+
542
+ # Scenario B: User pointed to Issues dir directly (e.g. ./Toolkit/Issues)
543
+ # Or user pointed to a path that will be created
544
+ return path
545
+
546
+ # 2. Handle Default / Contextual Execution (No --root)
547
+ # We need to detect if we are in a Workspace Root with multiple projects
548
+ 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
+
569
+ # 3. Config Fallback
570
+ config_issues_path = Path(config.paths.issues)
571
+ if config_issues_path.is_absolute():
572
+ return config_issues_path
573
+ else:
574
+ return (Path(config.paths.root) / config_issues_path).resolve()
575
+
576
+ def _resolve_project_root(config) -> Path:
577
+ """Resolve project root from config or defaults."""
578
+ return Path(config.paths.root).resolve()
579
+
580
+ @app.command("commit")
581
+ def commit(
582
+ message: Optional[str] = typer.Option(None, "--message", "-m", help="Commit message"),
583
+ issue_id: Optional[str] = typer.Option(None, "--issue", "-i", help="Link commit to Issue ID"),
584
+ detached: bool = typer.Option(False, "--detached", help="Flag commit as intentionally detached (no issue link)"),
585
+ type: Optional[str] = typer.Option(None, "--type", "-t", help="Commit type (feat, fix, etc.)"),
586
+ scope: Optional[str] = typer.Option(None, "--scope", "-s", help="Commit scope"),
587
+ subject: Optional[str] = typer.Option(None, "--subject", help="Commit subject"),
588
+ root: Optional[str] = typer.Option(None, "--root", help="Override issues root directory"),
589
+ ):
590
+ """
591
+ Atomic Commit: Validate (Lint) and Commit.
592
+
593
+ Modes:
594
+ 1. Linked Commit (--issue): Commits staged changes with 'Ref: <ID>' footer.
595
+ 2. Detached Commit (--detached): Commits staged changes without link.
596
+ 3. Auto-Issue (No args): Only allowed if ONLY issue files are modified.
597
+ """
598
+ config = get_config()
599
+ issues_root = _resolve_issues_root(config, root)
600
+ project_root = _resolve_project_root(config)
601
+
602
+ # 1. Lint Check (Gatekeeper)
603
+ console.print("[dim]Running pre-commit lint check...[/dim]")
604
+ try:
605
+ from . import linter
606
+ linter.check_integrity(issues_root, recursive=True)
607
+ except Exception:
608
+ pass
609
+
610
+ # 2. Stage & Commit
611
+ from monoco.core import git
612
+
613
+ try:
614
+ # Check Staging Status
615
+ code, stdout, _ = git._run_git(["diff", "--cached", "--name-only"], project_root)
616
+ staged_files = [l for l in stdout.splitlines() if l.strip()]
617
+
618
+ # Determine Mode
619
+ if issue_id:
620
+ # MODE: Linked Commit
621
+ console.print(f"[bold cyan]Linked Commit Mode[/bold cyan] (Ref: {issue_id})")
622
+
623
+ if not core.find_issue_path(issues_root, issue_id):
624
+ console.print(f"[red]Error:[/red] Issue {issue_id} not found.")
625
+ raise typer.Exit(code=1)
626
+
627
+ if not staged_files:
628
+ console.print("[yellow]No staged files.[/yellow] Please `git add` files.")
629
+ raise typer.Exit(code=1)
630
+
631
+ if not message:
632
+ if not type or not subject:
633
+ console.print("[red]Error:[/red] Provide --message OR (--type and --subject).")
634
+ raise typer.Exit(code=1)
635
+ scope_part = f"({scope})" if scope else ""
636
+ message = f"{type}{scope_part}: {subject}"
637
+
638
+ if f"Ref: {issue_id}" not in message:
639
+ message += f"\n\nRef: {issue_id}"
640
+
641
+ commit_hash = git.git_commit(project_root, message)
642
+ console.print(f"[green]✔ Committed:[/green] {commit_hash[:7]}")
643
+
644
+ elif detached:
645
+ # MODE: Detached
646
+ console.print(f"[bold yellow]Detached Commit Mode[/bold yellow]")
647
+
648
+ if not staged_files:
649
+ console.print("[yellow]No staged files.[/yellow] Please `git add` files.")
650
+ raise typer.Exit(code=1)
651
+
652
+ if not message:
653
+ console.print("[red]Error:[/red] Detached commits require --message.")
654
+ raise typer.Exit(code=1)
655
+
656
+ commit_hash = git.git_commit(project_root, message)
657
+ console.print(f"[green]✔ Committed:[/green] {commit_hash[:7]}")
658
+
659
+ else:
660
+ # MODE: Implicit / Auto-DB
661
+ # Strict Policy: Only allow if changes are constrained to Issues/ directory
662
+
663
+ # Check if any non-issue files are staged
664
+ # (We assume issues dir is 'Issues/')
665
+ try:
666
+ rel_issues = issues_root.relative_to(project_root)
667
+ issues_prefix = str(rel_issues)
668
+ except ValueError:
669
+ issues_prefix = "Issues" # Fallback
670
+
671
+ non_issue_staged = [f for f in staged_files if not f.startswith(issues_prefix)]
672
+
673
+ if non_issue_staged:
674
+ console.print(f"[red]⛔ Strict Policy:[/red] Code changes detected in staging ({len(non_issue_staged)} files).")
675
+ console.print("You must specify [bold]--issue <ID>[/bold] or [bold]--detached[/bold].")
676
+ raise typer.Exit(code=1)
677
+
678
+ # If nothing staged, check unstaged Issue files (Legacy Auto-Add)
679
+ if not staged_files:
680
+ status_files = git.get_git_status(project_root, str(rel_issues))
681
+ if not status_files:
682
+ console.print("[yellow]Nothing to commit.[/yellow]")
683
+ return
684
+
685
+ # Auto-stage Issue files
686
+ git.git_add(project_root, status_files)
687
+ staged_files = status_files # Now they are staged
688
+ else:
689
+ pass
690
+
691
+ # Auto-generate message from Issue File
692
+ if not message:
693
+ cnt = len(staged_files)
694
+ if cnt == 1:
695
+ fpath = project_root / staged_files[0]
696
+ match = core.parse_issue(fpath)
697
+ if match:
698
+ action = "update"
699
+ message = f"docs(issues): {action} {match.id} {match.title}"
700
+ else:
701
+ message = f"docs(issues): update {staged_files[0]}"
702
+ else:
703
+ message = f"docs(issues): batch update {cnt} files"
704
+
705
+ commit_hash = git.git_commit(project_root, message)
706
+ console.print(f"[green]✔ Committed (DB):[/green] {commit_hash[:7]} - {message}")
707
+
708
+ except Exception as e:
709
+ console.print(f"[red]Git Error:[/red] {e}")
710
+ raise typer.Exit(code=1)