monoco-toolkit 0.2.7__py3-none-any.whl → 0.3.0__py3-none-any.whl

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