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.
- 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 +500 -260
- monoco/features/issue/core.py +504 -293
- 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 +326 -111
- monoco/features/issue/lsp/definition.py +26 -19
- monoco/features/issue/migration.py +45 -34
- monoco/features/issue/models.py +30 -13
- monoco/features/issue/monitor.py +24 -8
- monoco/features/issue/resources/en/AGENTS.md +5 -0
- monoco/features/issue/resources/en/SKILL.md +30 -2
- monoco/features/issue/resources/zh/AGENTS.md +5 -0
- monoco/features/issue/resources/zh/SKILL.md +26 -1
- monoco/features/issue/validator.py +417 -172
- 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.7.dist-info → monoco_toolkit-0.3.0.dist-info}/METADATA +10 -3
- monoco_toolkit-0.3.0.dist-info/RECORD +84 -0
- monoco_toolkit-0.2.7.dist-info/RECORD +0 -83
- {monoco_toolkit-0.2.7.dist-info → monoco_toolkit-0.3.0.dist-info}/WHEEL +0 -0
- {monoco_toolkit-0.2.7.dist-info → monoco_toolkit-0.3.0.dist-info}/entry_points.txt +0 -0
- {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
|
|
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,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
|
-
|
|
73
|
-
"status": "created"
|
|
74
|
-
})
|
|
93
|
+
OutputManager.print(
|
|
94
|
+
{"issue": issue, "path": str(rel_path), "status": "created"}
|
|
95
|
+
)
|
|
75
96
|
else:
|
|
76
|
-
console.print(
|
|
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(
|
|
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(
|
|
92
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
159
|
-
|
|
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(
|
|
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(
|
|
178
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
212
|
-
|
|
267
|
+
core.generate_delivery_report(issues_root, issue_id, project_root)
|
|
268
|
+
report_status = "generated"
|
|
213
269
|
except Exception as e:
|
|
214
|
-
|
|
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(
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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(
|
|
239
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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(
|
|
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(
|
|
359
|
-
|
|
360
|
-
|
|
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(
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
474
|
-
|
|
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
|
-
|
|
484
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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(
|
|
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 = {
|
|
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(
|
|
626
|
-
|
|
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(
|
|
629
|
-
|
|
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(
|
|
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(
|
|
639
|
-
|
|
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
|
-
|
|
658
|
-
|
|
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
|
-
|
|
677
|
-
|
|
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
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
786
|
-
|
|
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
|
-
|
|
790
|
-
|
|
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(
|
|
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(
|
|
808
|
-
|
|
1033
|
+
console.print("[bold yellow]Detached Commit Mode[/bold yellow]")
|
|
1034
|
+
|
|
809
1035
|
if not staged_files:
|
|
810
|
-
|
|
811
|
-
|
|
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"
|
|
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(
|
|
836
|
-
|
|
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
|
|
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
|
-
|
|
860
|
-
|
|
1093
|
+
action = "update"
|
|
1094
|
+
message = f"docs(issues): {action} {match.id} {match.title}"
|
|
861
1095
|
else:
|
|
862
|
-
|
|
1096
|
+
message = f"docs(issues): update {staged_files[0]}"
|
|
863
1097
|
else:
|
|
864
|
-
|
|
1098
|
+
message = f"docs(issues): batch update {cnt} files"
|
|
865
1099
|
|
|
866
1100
|
commit_hash = git.git_commit(project_root, message)
|
|
867
|
-
console.print(
|
|
868
|
-
|
|
1101
|
+
console.print(
|
|
1102
|
+
f"[green]✔ Committed (DB):[/green] {commit_hash[:7]} - {message}"
|
|
1103
|
+
)
|
|
1104
|
+
|
|
869
1105
|
except Exception as e:
|
|
870
|
-
|
|
871
|
-
|
|
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(
|
|
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 (
|
|
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=
|
|
1148
|
+
print(json.dumps([l.model_dump(mode="json") for l in locations]))
|