monoco-toolkit 0.2.8__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.
- monoco/cli/project.py +35 -31
- monoco/cli/workspace.py +26 -16
- monoco/core/agent/__init__.py +0 -2
- monoco/core/agent/action.py +44 -20
- monoco/core/agent/adapters.py +20 -16
- monoco/core/agent/protocol.py +5 -4
- monoco/core/agent/state.py +21 -21
- monoco/core/config.py +90 -33
- monoco/core/execution.py +21 -16
- monoco/core/feature.py +8 -5
- monoco/core/git.py +61 -30
- monoco/core/hooks.py +57 -0
- monoco/core/injection.py +47 -44
- monoco/core/integrations.py +50 -35
- monoco/core/lsp.py +12 -1
- monoco/core/output.py +35 -16
- monoco/core/registry.py +3 -2
- monoco/core/setup.py +190 -124
- monoco/core/skills.py +121 -107
- monoco/core/state.py +12 -10
- monoco/core/sync.py +85 -56
- monoco/core/telemetry.py +10 -6
- monoco/core/workspace.py +26 -19
- monoco/daemon/app.py +123 -79
- monoco/daemon/commands.py +14 -13
- monoco/daemon/models.py +11 -3
- monoco/daemon/reproduce_stats.py +8 -8
- monoco/daemon/services.py +32 -33
- monoco/daemon/stats.py +59 -40
- monoco/features/config/commands.py +38 -25
- monoco/features/i18n/adapter.py +4 -5
- monoco/features/i18n/commands.py +83 -49
- monoco/features/i18n/core.py +94 -54
- monoco/features/issue/adapter.py +6 -7
- monoco/features/issue/commands.py +468 -272
- monoco/features/issue/core.py +419 -312
- monoco/features/issue/domain/lifecycle.py +33 -23
- monoco/features/issue/domain/models.py +71 -38
- monoco/features/issue/domain/parser.py +92 -69
- monoco/features/issue/domain/workspace.py +19 -16
- monoco/features/issue/engine/__init__.py +3 -3
- monoco/features/issue/engine/config.py +18 -25
- monoco/features/issue/engine/machine.py +72 -39
- monoco/features/issue/engine/models.py +4 -2
- monoco/features/issue/linter.py +287 -157
- monoco/features/issue/lsp/definition.py +26 -19
- monoco/features/issue/migration.py +45 -34
- monoco/features/issue/models.py +29 -13
- monoco/features/issue/monitor.py +24 -8
- monoco/features/issue/resources/en/SKILL.md +6 -2
- monoco/features/issue/validator.py +383 -208
- monoco/features/skills/__init__.py +0 -1
- monoco/features/skills/core.py +24 -18
- monoco/features/spike/adapter.py +4 -5
- monoco/features/spike/commands.py +51 -38
- monoco/features/spike/core.py +24 -16
- monoco/main.py +34 -21
- {monoco_toolkit-0.2.8.dist-info → monoco_toolkit-0.3.0.dist-info}/METADATA +1 -1
- monoco_toolkit-0.3.0.dist-info/RECORD +84 -0
- monoco_toolkit-0.2.8.dist-info/RECORD +0 -83
- {monoco_toolkit-0.2.8.dist-info → monoco_toolkit-0.3.0.dist-info}/WHEEL +0 -0
- {monoco_toolkit-0.2.8.dist-info → monoco_toolkit-0.3.0.dist-info}/entry_points.txt +0 -0
- {monoco_toolkit-0.2.8.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
|
|
12
|
-
from .models import IssueType, IssueStatus,
|
|
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(
|
|
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(
|
|
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(
|
|
30
|
-
|
|
31
|
-
|
|
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(
|
|
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
|
-
|
|
80
|
-
"status": "created"
|
|
81
|
-
})
|
|
93
|
+
OutputManager.print(
|
|
94
|
+
{"issue": issue, "path": str(rel_path), "status": "created"}
|
|
95
|
+
)
|
|
82
96
|
else:
|
|
83
|
-
console.print(
|
|
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(
|
|
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(
|
|
99
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
166
|
-
|
|
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(
|
|
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(
|
|
185
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
219
|
-
|
|
267
|
+
core.generate_delivery_report(issues_root, issue_id, project_root)
|
|
268
|
+
report_status = "generated"
|
|
220
269
|
except Exception as e:
|
|
221
|
-
|
|
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(
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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(
|
|
246
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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(
|
|
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(
|
|
366
|
-
|
|
367
|
-
|
|
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(
|
|
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
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
481
|
-
|
|
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
|
-
|
|
491
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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(
|
|
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 = {
|
|
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(
|
|
633
|
-
|
|
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(
|
|
636
|
-
|
|
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(
|
|
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(
|
|
645
|
-
|
|
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
|
-
|
|
787
|
+
issue_id = match.group(1).upper()
|
|
664
788
|
else:
|
|
665
|
-
|
|
666
|
-
|
|
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(
|
|
683
|
-
|
|
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
|
-
|
|
702
|
-
|
|
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
|
-
|
|
721
|
-
|
|
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
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
830
|
-
|
|
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
|
-
|
|
834
|
-
|
|
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(
|
|
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(
|
|
852
|
-
|
|
1033
|
+
console.print("[bold yellow]Detached Commit Mode[/bold yellow]")
|
|
1034
|
+
|
|
853
1035
|
if not staged_files:
|
|
854
|
-
|
|
855
|
-
|
|
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"
|
|
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(
|
|
880
|
-
|
|
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
|
|
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
|
-
|
|
904
|
-
|
|
1093
|
+
action = "update"
|
|
1094
|
+
message = f"docs(issues): {action} {match.id} {match.title}"
|
|
905
1095
|
else:
|
|
906
|
-
|
|
1096
|
+
message = f"docs(issues): update {staged_files[0]}"
|
|
907
1097
|
else:
|
|
908
|
-
|
|
1098
|
+
message = f"docs(issues): batch update {cnt} files"
|
|
909
1099
|
|
|
910
1100
|
commit_hash = git.git_commit(project_root, message)
|
|
911
|
-
console.print(
|
|
912
|
-
|
|
1101
|
+
console.print(
|
|
1102
|
+
f"[green]✔ Committed (DB):[/green] {commit_hash[:7]} - {message}"
|
|
1103
|
+
)
|
|
1104
|
+
|
|
913
1105
|
except Exception as e:
|
|
914
|
-
|
|
915
|
-
|
|
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(
|
|
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 (
|
|
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=
|
|
1148
|
+
print(json.dumps([l.model_dump(mode="json") for l in locations]))
|