a2c-cli 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- a2c_cli/__init__.py +1 -0
- a2c_cli/commands/__init__.py +1 -0
- a2c_cli/commands/apply.py +111 -0
- a2c_cli/commands/doctor.py +23 -0
- a2c_cli/commands/list_cmd.py +26 -0
- a2c_cli/commands/plan.py +322 -0
- a2c_cli/commands/show.py +98 -0
- a2c_cli/commands/validate.py +22 -0
- a2c_cli/context.py +56 -0
- a2c_cli/exit_codes.py +10 -0
- a2c_cli/main.py +329 -0
- a2c_cli/output.py +367 -0
- a2c_cli-0.2.1.dist-info/METADATA +43 -0
- a2c_cli-0.2.1.dist-info/RECORD +17 -0
- a2c_cli-0.2.1.dist-info/WHEEL +5 -0
- a2c_cli-0.2.1.dist-info/entry_points.txt +2 -0
- a2c_cli-0.2.1.dist-info/top_level.txt +1 -0
a2c_cli/main.py
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""CLI entrypoint and command groups."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from a2c_cli.commands.apply import (
|
|
11
|
+
apply_epic_draft_cmd,
|
|
12
|
+
apply_task_intake_draft_cmd,
|
|
13
|
+
apply_tasks_from_epic_cmd,
|
|
14
|
+
)
|
|
15
|
+
from a2c_cli.commands.doctor import doctor_cmd
|
|
16
|
+
from a2c_cli.commands.list_cmd import list_epics, list_sprints, list_tasks
|
|
17
|
+
from a2c_cli.commands.plan import create_epic_cmd, create_task_cmd, decompose_epic_cmd
|
|
18
|
+
from a2c_cli.commands.show import (
|
|
19
|
+
show_draft_epic,
|
|
20
|
+
show_draft_task,
|
|
21
|
+
show_draft_task_intake,
|
|
22
|
+
show_epic,
|
|
23
|
+
show_sprint,
|
|
24
|
+
show_task,
|
|
25
|
+
)
|
|
26
|
+
from a2c_cli.commands.validate import validate_cmd
|
|
27
|
+
from a2c_core import __version__
|
|
28
|
+
|
|
29
|
+
app = typer.Typer(
|
|
30
|
+
name="a2c",
|
|
31
|
+
help="Architecture-to-Commit (A2C) — inspect, validate, and plan repositories",
|
|
32
|
+
no_args_is_help=True,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
list_app = typer.Typer(help="List planning artifacts")
|
|
36
|
+
show_app = typer.Typer(help="Show one planning artifact")
|
|
37
|
+
plan_app = typer.Typer(help="AI-assisted planning proposals (review-first)")
|
|
38
|
+
apply_app = typer.Typer(help="Apply validated proposals into planning/")
|
|
39
|
+
|
|
40
|
+
PathOption = Annotated[
|
|
41
|
+
Path | None,
|
|
42
|
+
typer.Option("--path", "-C", help="Repository path (default: current directory)"),
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@app.callback()
|
|
47
|
+
def main() -> None:
|
|
48
|
+
"""A2C command-line interface."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.command("version")
|
|
52
|
+
def version_cmd() -> None:
|
|
53
|
+
"""Print the a2c-core product version."""
|
|
54
|
+
typer.echo(f"a2c-core {__version__}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.command("validate")
|
|
58
|
+
def validate(path: PathOption = None) -> None:
|
|
59
|
+
"""Validate config and planning artifacts."""
|
|
60
|
+
validate_cmd(path)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@list_app.command("epics")
|
|
64
|
+
def list_epics_cmd(path: PathOption = None) -> None:
|
|
65
|
+
list_epics(path)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@list_app.command("tasks")
|
|
69
|
+
def list_tasks_cmd(path: PathOption = None) -> None:
|
|
70
|
+
list_tasks(path)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@list_app.command("sprints")
|
|
74
|
+
def list_sprints_cmd(path: PathOption = None) -> None:
|
|
75
|
+
list_sprints(path)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
app.add_typer(list_app, name="list")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@show_app.command("epic")
|
|
82
|
+
def show_epic_cmd(
|
|
83
|
+
artifact_id: Annotated[str, typer.Argument(help="Epic id")],
|
|
84
|
+
path: PathOption = None,
|
|
85
|
+
) -> None:
|
|
86
|
+
show_epic(artifact_id, path)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@show_app.command("task")
|
|
90
|
+
def show_task_cmd(
|
|
91
|
+
artifact_id: Annotated[str, typer.Argument(help="Task id")],
|
|
92
|
+
path: PathOption = None,
|
|
93
|
+
) -> None:
|
|
94
|
+
show_task(artifact_id, path)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@show_app.command("sprint")
|
|
98
|
+
def show_sprint_cmd(
|
|
99
|
+
artifact_id: Annotated[str, typer.Argument(help="Sprint id")],
|
|
100
|
+
path: PathOption = None,
|
|
101
|
+
) -> None:
|
|
102
|
+
show_sprint(artifact_id, path)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@show_app.command("draft-epic")
|
|
106
|
+
def show_draft_epic_cmd(
|
|
107
|
+
epic_id: Annotated[str, typer.Argument(help="Proposed epic id for the cached draft")],
|
|
108
|
+
path: PathOption = None,
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Show one proposed epic draft (not applied)."""
|
|
111
|
+
show_draft_epic(epic_id, path)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@show_app.command("draft-task-intake")
|
|
115
|
+
def show_draft_task_intake_cmd(
|
|
116
|
+
draft_key: Annotated[str, typer.Argument(help="Draft key from plan create-task")],
|
|
117
|
+
path: PathOption = None,
|
|
118
|
+
) -> None:
|
|
119
|
+
"""Show one proposed single-task intake draft (not applied)."""
|
|
120
|
+
show_draft_task_intake(draft_key, path)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@show_app.command("draft-task")
|
|
124
|
+
def show_draft_task_cmd(
|
|
125
|
+
epic_id: Annotated[str, typer.Argument(help="Epic id for the cached proposal")],
|
|
126
|
+
draft_index: Annotated[int, typer.Argument(help="1-based draft index")],
|
|
127
|
+
path: PathOption = None,
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Show one proposed task draft (not yet applied)."""
|
|
130
|
+
show_draft_task(epic_id, draft_index, path)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
app.add_typer(show_app, name="show")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@plan_app.command("create-epic")
|
|
137
|
+
def plan_create_epic_cmd(
|
|
138
|
+
path: PathOption = None,
|
|
139
|
+
from_text: Annotated[
|
|
140
|
+
str | None,
|
|
141
|
+
typer.Option("--from-text", help="Short feature brief as inline text"),
|
|
142
|
+
] = None,
|
|
143
|
+
from_file: Annotated[
|
|
144
|
+
Path | None,
|
|
145
|
+
typer.Option("--from-file", help="Path to a Markdown or text brief"),
|
|
146
|
+
] = None,
|
|
147
|
+
use_stdin: Annotated[
|
|
148
|
+
bool,
|
|
149
|
+
typer.Option("--stdin", help="Read brief from standard input"),
|
|
150
|
+
] = False,
|
|
151
|
+
provider: Annotated[
|
|
152
|
+
str | None,
|
|
153
|
+
typer.Option("--provider", help="AI provider (mock, openai, local, ollama)"),
|
|
154
|
+
] = None,
|
|
155
|
+
fixture: Annotated[
|
|
156
|
+
Path | None,
|
|
157
|
+
typer.Option("--fixture", help="JSON fixture for mock provider"),
|
|
158
|
+
] = None,
|
|
159
|
+
model: Annotated[
|
|
160
|
+
str | None,
|
|
161
|
+
typer.Option("--model", help="Override decomposition.model from config"),
|
|
162
|
+
] = None,
|
|
163
|
+
endpoint: Annotated[
|
|
164
|
+
str | None,
|
|
165
|
+
typer.Option("--endpoint", help="Override decomposition.endpoint from config"),
|
|
166
|
+
] = None,
|
|
167
|
+
) -> None:
|
|
168
|
+
"""Propose an epic draft from a brief without writing planning files."""
|
|
169
|
+
create_epic_cmd(
|
|
170
|
+
path,
|
|
171
|
+
from_text=from_text,
|
|
172
|
+
from_file=from_file,
|
|
173
|
+
use_stdin=use_stdin,
|
|
174
|
+
provider_name=provider,
|
|
175
|
+
fixture_path=fixture,
|
|
176
|
+
model=model,
|
|
177
|
+
endpoint=endpoint,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@plan_app.command("create-task")
|
|
182
|
+
def plan_create_task_cmd(
|
|
183
|
+
path: PathOption = None,
|
|
184
|
+
from_text: Annotated[
|
|
185
|
+
str | None,
|
|
186
|
+
typer.Option("--from-text", help="Short description or bug report text"),
|
|
187
|
+
] = None,
|
|
188
|
+
from_file: Annotated[
|
|
189
|
+
Path | None,
|
|
190
|
+
typer.Option("--from-file", help="Path to a text or Markdown intake file"),
|
|
191
|
+
] = None,
|
|
192
|
+
from_task_json: Annotated[
|
|
193
|
+
Path | None,
|
|
194
|
+
typer.Option(
|
|
195
|
+
"--from-task-json",
|
|
196
|
+
help="Path to a single decomposition-style task JSON object (no LLM)",
|
|
197
|
+
),
|
|
198
|
+
] = None,
|
|
199
|
+
use_stdin: Annotated[
|
|
200
|
+
bool,
|
|
201
|
+
typer.Option("--stdin", help="Read intake text from standard input"),
|
|
202
|
+
] = False,
|
|
203
|
+
task_type: Annotated[
|
|
204
|
+
str | None,
|
|
205
|
+
typer.Option("--type", help="Task template: feature or bug"),
|
|
206
|
+
] = None,
|
|
207
|
+
use_bug: Annotated[
|
|
208
|
+
bool,
|
|
209
|
+
typer.Option("--bug", help="Use bug intake template (same as --type bug)"),
|
|
210
|
+
] = False,
|
|
211
|
+
epic: Annotated[
|
|
212
|
+
str | None,
|
|
213
|
+
typer.Option("--epic", help="Optional epic id to link the task to"),
|
|
214
|
+
] = None,
|
|
215
|
+
provider: Annotated[
|
|
216
|
+
str | None,
|
|
217
|
+
typer.Option("--provider", help="AI provider (mock, openai, local, ollama)"),
|
|
218
|
+
] = None,
|
|
219
|
+
fixture: Annotated[
|
|
220
|
+
Path | None,
|
|
221
|
+
typer.Option("--fixture", help="JSON fixture for mock provider"),
|
|
222
|
+
] = None,
|
|
223
|
+
model: Annotated[
|
|
224
|
+
str | None,
|
|
225
|
+
typer.Option("--model", help="Override decomposition.model from config"),
|
|
226
|
+
] = None,
|
|
227
|
+
endpoint: Annotated[
|
|
228
|
+
str | None,
|
|
229
|
+
typer.Option("--endpoint", help="Override decomposition.endpoint from config"),
|
|
230
|
+
] = None,
|
|
231
|
+
) -> None:
|
|
232
|
+
"""Propose a single task draft from raw input without writing planning files."""
|
|
233
|
+
create_task_cmd(
|
|
234
|
+
path,
|
|
235
|
+
from_text=from_text,
|
|
236
|
+
from_file=from_file,
|
|
237
|
+
from_task_json=from_task_json,
|
|
238
|
+
use_stdin=use_stdin,
|
|
239
|
+
task_type=task_type,
|
|
240
|
+
use_bug=use_bug,
|
|
241
|
+
epic_id=epic,
|
|
242
|
+
provider_name=provider,
|
|
243
|
+
fixture_path=fixture,
|
|
244
|
+
model=model,
|
|
245
|
+
endpoint=endpoint,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
@plan_app.command("decompose-epic")
|
|
250
|
+
def plan_decompose_epic_cmd(
|
|
251
|
+
epic_id: Annotated[str, typer.Argument(help="Epic id to decompose")],
|
|
252
|
+
path: PathOption = None,
|
|
253
|
+
provider: Annotated[
|
|
254
|
+
str | None,
|
|
255
|
+
typer.Option("--provider", help="AI provider (mock, openai, local, ollama)"),
|
|
256
|
+
] = None,
|
|
257
|
+
fixture: Annotated[
|
|
258
|
+
Path | None,
|
|
259
|
+
typer.Option("--fixture", help="JSON fixture for mock provider"),
|
|
260
|
+
] = None,
|
|
261
|
+
model: Annotated[
|
|
262
|
+
str | None,
|
|
263
|
+
typer.Option("--model", help="Override decomposition.model from config"),
|
|
264
|
+
] = None,
|
|
265
|
+
endpoint: Annotated[
|
|
266
|
+
str | None,
|
|
267
|
+
typer.Option("--endpoint", help="Override decomposition.endpoint from config"),
|
|
268
|
+
] = None,
|
|
269
|
+
) -> None:
|
|
270
|
+
"""Propose task drafts for an epic without writing planning files."""
|
|
271
|
+
decompose_epic_cmd(
|
|
272
|
+
epic_id,
|
|
273
|
+
path,
|
|
274
|
+
provider_name=provider,
|
|
275
|
+
fixture_path=fixture,
|
|
276
|
+
model=model,
|
|
277
|
+
endpoint=endpoint,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
app.add_typer(plan_app, name="plan")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@apply_app.command("draft-epic")
|
|
285
|
+
def apply_draft_epic_command(
|
|
286
|
+
epic_id: Annotated[str, typer.Argument(help="Proposed epic id with a cached draft")],
|
|
287
|
+
path: PathOption = None,
|
|
288
|
+
force: Annotated[
|
|
289
|
+
bool,
|
|
290
|
+
typer.Option("--force", help="Overwrite an existing epic file with the same id"),
|
|
291
|
+
] = False,
|
|
292
|
+
) -> None:
|
|
293
|
+
"""Apply a cached epic draft into planning/epics/."""
|
|
294
|
+
apply_epic_draft_cmd(epic_id, path, force=force)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@apply_app.command("draft-task")
|
|
298
|
+
def apply_draft_task_command(
|
|
299
|
+
draft_key: Annotated[str, typer.Argument(help="Draft key from plan create-task")],
|
|
300
|
+
path: PathOption = None,
|
|
301
|
+
force: Annotated[
|
|
302
|
+
bool,
|
|
303
|
+
typer.Option("--force", help="Overwrite an existing task file with the same id"),
|
|
304
|
+
] = False,
|
|
305
|
+
) -> None:
|
|
306
|
+
"""Apply a cached single-task intake draft into planning/tasks/."""
|
|
307
|
+
apply_task_intake_draft_cmd(draft_key, path, force=force)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
@apply_app.command("tasks-from-epic")
|
|
311
|
+
def apply_tasks_from_epic_command(
|
|
312
|
+
epic_id: Annotated[str, typer.Argument(help="Epic id with a cached proposal")],
|
|
313
|
+
path: PathOption = None,
|
|
314
|
+
force: Annotated[
|
|
315
|
+
bool,
|
|
316
|
+
typer.Option("--force", help="Overwrite existing task files with the same id"),
|
|
317
|
+
] = False,
|
|
318
|
+
) -> None:
|
|
319
|
+
"""Apply cached task drafts for an epic into planning/tasks/."""
|
|
320
|
+
apply_tasks_from_epic_cmd(epic_id, path, force=force)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
app.add_typer(apply_app, name="apply")
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@app.command("doctor")
|
|
327
|
+
def doctor(path: PathOption = None) -> None:
|
|
328
|
+
"""Check repository structure and readiness."""
|
|
329
|
+
doctor_cmd(path)
|
a2c_cli/output.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
"""Human-readable CLI output formatters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from a2c_core.errors import ValidationIssue
|
|
10
|
+
from a2c_core.schemas.artifacts import Epic, Sprint, Task
|
|
11
|
+
from a2c_core.schemas.workflows_io import (
|
|
12
|
+
EpicDecompositionProposal,
|
|
13
|
+
EpicDraftProposal,
|
|
14
|
+
TaskDraftItem,
|
|
15
|
+
TaskIntakeDraftProposal,
|
|
16
|
+
)
|
|
17
|
+
from a2c_core.services.doctor import DoctorReport
|
|
18
|
+
from a2c_core.services.inspection import InspectionResult
|
|
19
|
+
|
|
20
|
+
_BODY_SECTION_RE = re.compile(r"^##\s+(.+)$", re.MULTILINE)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def format_validation_report(result: InspectionResult) -> None:
|
|
24
|
+
"""Print validate command summary."""
|
|
25
|
+
if result.root is None:
|
|
26
|
+
typer.echo("A2C validation: FAILED")
|
|
27
|
+
typer.echo("error: not inside an A2C repository", err=True)
|
|
28
|
+
if result.errors:
|
|
29
|
+
_echo_issue_block("Errors", result.errors, err=True)
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
status = "PASSED" if result.ok else "FAILED"
|
|
33
|
+
typer.echo(f"A2C validation: {status}")
|
|
34
|
+
if result.root is not None:
|
|
35
|
+
typer.echo(f"Repository: {result.root}")
|
|
36
|
+
|
|
37
|
+
typer.echo("")
|
|
38
|
+
typer.echo("Planning artifacts:")
|
|
39
|
+
for summary in result.summaries:
|
|
40
|
+
invalid = _invalid_count(result.errors, summary.kind)
|
|
41
|
+
valid = max(summary.parsed - invalid, 0)
|
|
42
|
+
found = summary.parsed + summary.loader_errors
|
|
43
|
+
typer.echo(
|
|
44
|
+
f" {summary.kind + 's':7} found {found:2d} "
|
|
45
|
+
f"valid {valid:2d} invalid {invalid + summary.loader_errors:2d}"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
if result.warnings:
|
|
49
|
+
typer.echo("")
|
|
50
|
+
_echo_issue_block("Warnings", result.warnings)
|
|
51
|
+
|
|
52
|
+
if result.errors:
|
|
53
|
+
typer.echo("")
|
|
54
|
+
_echo_issue_block("Errors", result.errors, err=True)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _invalid_count(errors: list[ValidationIssue], kind: str) -> int:
|
|
58
|
+
segment = f"planning/{kind}s/"
|
|
59
|
+
paths = {
|
|
60
|
+
issue.path for issue in errors if issue.path and segment in issue.path.replace("\\", "/")
|
|
61
|
+
}
|
|
62
|
+
return len(paths)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _echo_issue_block(title: str, issues: list[ValidationIssue], *, err: bool = False) -> None:
|
|
66
|
+
typer.echo(f"{title} ({len(issues)}):", err=err)
|
|
67
|
+
for issue in issues:
|
|
68
|
+
path = issue.path or "-"
|
|
69
|
+
typer.echo(f" {path}: [{issue.code}] {issue.message}", err=err)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def format_epic_list(epics: list[Epic]) -> None:
|
|
73
|
+
if not epics:
|
|
74
|
+
typer.echo("No epics found.")
|
|
75
|
+
return
|
|
76
|
+
typer.echo(f"{'ID':<24} {'STATUS':<12} TITLE")
|
|
77
|
+
for epic in sorted(epics, key=lambda item: item.id):
|
|
78
|
+
typer.echo(f"{epic.id:<24} {epic.status:<12} {epic.title}")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def format_task_list(tasks: list[Task]) -> None:
|
|
82
|
+
if not tasks:
|
|
83
|
+
typer.echo("No tasks found.")
|
|
84
|
+
return
|
|
85
|
+
typer.echo(f"{'ID':<24} {'STATUS':<12} {'EPIC':<18} TITLE")
|
|
86
|
+
for task in sorted(tasks, key=lambda item: item.id):
|
|
87
|
+
epic = task.epic_id or "-"
|
|
88
|
+
typer.echo(f"{task.id:<24} {task.status:<12} {epic:<18} {task.title}")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def format_sprint_list(sprints: list[Sprint]) -> None:
|
|
92
|
+
if not sprints:
|
|
93
|
+
typer.echo("No sprints found.")
|
|
94
|
+
return
|
|
95
|
+
typer.echo(f"{'ID':<24} {'STATUS':<12} TITLE")
|
|
96
|
+
for sprint in sorted(sprints, key=lambda item: item.id):
|
|
97
|
+
typer.echo(f"{sprint.id:<24} {sprint.status:<12} {sprint.title}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def format_epic_show(epic: Epic) -> None:
|
|
101
|
+
_format_artifact_show(
|
|
102
|
+
kind="Epic",
|
|
103
|
+
artifact_id=epic.id,
|
|
104
|
+
title=epic.title,
|
|
105
|
+
status=epic.status,
|
|
106
|
+
fields=[
|
|
107
|
+
("Summary", epic.summary),
|
|
108
|
+
("Tasks", ", ".join(epic.task_ids) if epic.task_ids else None),
|
|
109
|
+
],
|
|
110
|
+
body=epic.body,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def format_task_show(task: Task) -> None:
|
|
115
|
+
_format_artifact_show(
|
|
116
|
+
kind="Task",
|
|
117
|
+
artifact_id=task.id,
|
|
118
|
+
title=task.title,
|
|
119
|
+
status=task.status,
|
|
120
|
+
fields=[
|
|
121
|
+
("Epic", task.epic_id),
|
|
122
|
+
],
|
|
123
|
+
body=task.body,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def format_sprint_show(sprint: Sprint) -> None:
|
|
128
|
+
_format_artifact_show(
|
|
129
|
+
kind="Sprint",
|
|
130
|
+
artifact_id=sprint.id,
|
|
131
|
+
title=sprint.title,
|
|
132
|
+
status=sprint.status,
|
|
133
|
+
fields=[
|
|
134
|
+
("Tasks", ", ".join(sprint.task_ids) if sprint.task_ids else None),
|
|
135
|
+
],
|
|
136
|
+
body=sprint.body,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _format_artifact_show(
|
|
141
|
+
*,
|
|
142
|
+
kind: str,
|
|
143
|
+
artifact_id: str,
|
|
144
|
+
title: str,
|
|
145
|
+
status: str,
|
|
146
|
+
fields: list[tuple[str, str | None]],
|
|
147
|
+
body: str,
|
|
148
|
+
) -> None:
|
|
149
|
+
typer.echo(f"{kind}: {artifact_id}")
|
|
150
|
+
typer.echo(f"Title: {title}")
|
|
151
|
+
typer.echo(f"Status: {status}")
|
|
152
|
+
for label, value in fields:
|
|
153
|
+
if value:
|
|
154
|
+
typer.echo(f"{label + ':':<8} {value}")
|
|
155
|
+
|
|
156
|
+
sections = _extract_body_sections(body)
|
|
157
|
+
if sections:
|
|
158
|
+
typer.echo("")
|
|
159
|
+
for heading, content in sections:
|
|
160
|
+
typer.echo(f"## {heading}")
|
|
161
|
+
typer.echo(content.strip())
|
|
162
|
+
typer.echo("")
|
|
163
|
+
elif body.strip():
|
|
164
|
+
typer.echo("")
|
|
165
|
+
typer.echo(body.strip())
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _extract_body_sections(body: str) -> list[tuple[str, str]]:
|
|
169
|
+
matches = list(_BODY_SECTION_RE.finditer(body))
|
|
170
|
+
if not matches:
|
|
171
|
+
return []
|
|
172
|
+
sections: list[tuple[str, str]] = []
|
|
173
|
+
for index, match in enumerate(matches):
|
|
174
|
+
heading = match.group(1).strip()
|
|
175
|
+
start = match.end()
|
|
176
|
+
end = matches[index + 1].start() if index + 1 < len(matches) else len(body)
|
|
177
|
+
content = body[start:end].strip()
|
|
178
|
+
if content:
|
|
179
|
+
sections.append((heading, content))
|
|
180
|
+
return sections
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def format_doctor_report(report: DoctorReport) -> None:
|
|
184
|
+
if report.root is None:
|
|
185
|
+
typer.echo("A2C doctor: not inside an A2C repository")
|
|
186
|
+
for check in report.checks:
|
|
187
|
+
typer.echo(f" [FAIL] {check.name}: {check.message}")
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
status = "healthy" if report.ok else "issues found"
|
|
191
|
+
typer.echo(f"A2C doctor: {status}")
|
|
192
|
+
typer.echo(f"Repository: {report.root}")
|
|
193
|
+
typer.echo("")
|
|
194
|
+
for check in report.checks:
|
|
195
|
+
label = "ok" if check.ok else "FAIL"
|
|
196
|
+
typer.echo(f" [{label:4}] {check.name}: {check.message}")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def format_decomposition_summary(proposal: EpicDecompositionProposal) -> None:
|
|
200
|
+
"""Print a review summary for proposed task drafts."""
|
|
201
|
+
typer.echo(f"A2C plan: proposed tasks for epic {proposal.epic_id} (drafts — not applied)")
|
|
202
|
+
typer.echo("")
|
|
203
|
+
typer.echo("Summary:")
|
|
204
|
+
typer.echo(proposal.summary.strip())
|
|
205
|
+
typer.echo("")
|
|
206
|
+
typer.echo(f"Proposed tasks ({len(proposal.tasks)}):")
|
|
207
|
+
if not proposal.tasks:
|
|
208
|
+
typer.echo(" (none)")
|
|
209
|
+
return
|
|
210
|
+
typer.echo(f" {'#':<4} {'ID':<24} {'STATUS':<12} TITLE")
|
|
211
|
+
for index, draft in enumerate(proposal.tasks, start=1):
|
|
212
|
+
typer.echo(f" {index:<4} {draft.id:<24} {draft.status:<12} {draft.title}")
|
|
213
|
+
summary_line = _first_summary_line(draft.body)
|
|
214
|
+
if summary_line:
|
|
215
|
+
typer.echo(f" {summary_line}")
|
|
216
|
+
if draft.rationale:
|
|
217
|
+
typer.echo(f" rationale: {draft.rationale}")
|
|
218
|
+
typer.echo("")
|
|
219
|
+
typer.echo("Next: review drafts, then run apply tasks-from-epic to write files.")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def format_draft_task_show(draft: TaskDraftItem, *, index: int, epic_id: str) -> None:
|
|
223
|
+
"""Print one proposed task draft in detail."""
|
|
224
|
+
typer.echo(f"Draft task #{index} for epic {epic_id} (not applied)")
|
|
225
|
+
typer.echo(f"ID: {draft.id}")
|
|
226
|
+
typer.echo(f"Title: {draft.title}")
|
|
227
|
+
typer.echo(f"Status: {draft.status}")
|
|
228
|
+
typer.echo(f"Epic: {draft.epic_id}")
|
|
229
|
+
if draft.rationale:
|
|
230
|
+
typer.echo(f"Why: {draft.rationale}")
|
|
231
|
+
|
|
232
|
+
sections = _extract_body_sections(draft.body)
|
|
233
|
+
if sections:
|
|
234
|
+
typer.echo("")
|
|
235
|
+
for heading, content in sections:
|
|
236
|
+
typer.echo(f"## {heading}")
|
|
237
|
+
typer.echo(content.strip())
|
|
238
|
+
typer.echo("")
|
|
239
|
+
elif draft.body.strip():
|
|
240
|
+
typer.echo("")
|
|
241
|
+
typer.echo(draft.body.strip())
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def format_apply_epic_summary(epic_id: str) -> None:
|
|
245
|
+
typer.echo(f"Created epic {epic_id} in planning/epics/{epic_id}.md")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def format_create_epic_summary(proposal: EpicDraftProposal) -> None:
|
|
249
|
+
"""Print a review summary for a proposed epic draft."""
|
|
250
|
+
epic = proposal.epic
|
|
251
|
+
typer.echo(f"A2C plan: proposed epic {epic.id} (draft — not applied)")
|
|
252
|
+
typer.echo("")
|
|
253
|
+
typer.echo(f"ID: {epic.id}")
|
|
254
|
+
typer.echo(f"Title: {epic.title}")
|
|
255
|
+
typer.echo(f"Status: {epic.status}")
|
|
256
|
+
if epic.summary:
|
|
257
|
+
typer.echo(f"Summary: {epic.summary}")
|
|
258
|
+
|
|
259
|
+
sections = _extract_body_sections(epic.body)
|
|
260
|
+
if sections:
|
|
261
|
+
typer.echo("")
|
|
262
|
+
typer.echo("Sections:")
|
|
263
|
+
for heading, content in sections:
|
|
264
|
+
preview = _first_content_line(content)
|
|
265
|
+
if preview:
|
|
266
|
+
typer.echo(f" ## {heading}: {preview}")
|
|
267
|
+
else:
|
|
268
|
+
typer.echo(f" ## {heading}")
|
|
269
|
+
|
|
270
|
+
typer.echo("")
|
|
271
|
+
typer.echo("Next: review the draft, then run apply draft-epic to write planning/epics/.")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def format_create_task_summary(proposal: TaskIntakeDraftProposal) -> None:
|
|
275
|
+
"""Print a review summary for a proposed task intake draft."""
|
|
276
|
+
typer.echo(f"A2C plan: proposed task draft {proposal.draft_key} (not applied)")
|
|
277
|
+
typer.echo("")
|
|
278
|
+
typer.echo(f"Draft key: {proposal.draft_key}")
|
|
279
|
+
typer.echo("Task ID: (allocated at apply)")
|
|
280
|
+
typer.echo(f"Title: {proposal.title}")
|
|
281
|
+
typer.echo(f"Type: {proposal.task_kind}")
|
|
282
|
+
typer.echo(f"Status: {proposal.status}")
|
|
283
|
+
if proposal.epic_id:
|
|
284
|
+
typer.echo(f"Epic: {proposal.epic_id}")
|
|
285
|
+
|
|
286
|
+
sections = _extract_body_sections(proposal.body)
|
|
287
|
+
if sections:
|
|
288
|
+
typer.echo("")
|
|
289
|
+
typer.echo("Sections:")
|
|
290
|
+
for heading, content in sections:
|
|
291
|
+
preview = _first_content_line(content)
|
|
292
|
+
if preview:
|
|
293
|
+
typer.echo(f" ## {heading}: {preview}")
|
|
294
|
+
else:
|
|
295
|
+
typer.echo(f" ## {heading}")
|
|
296
|
+
|
|
297
|
+
typer.echo("")
|
|
298
|
+
typer.echo("Next: review the draft, then run apply draft-task to write planning/tasks/.")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def format_draft_task_intake_show(proposal: TaskIntakeDraftProposal) -> None:
|
|
302
|
+
"""Print one proposed task intake draft in detail."""
|
|
303
|
+
typer.echo(f"Draft task {proposal.draft_key} (not applied)")
|
|
304
|
+
typer.echo("Task ID: (allocated at apply)")
|
|
305
|
+
typer.echo(f"Title: {proposal.title}")
|
|
306
|
+
typer.echo(f"Type: {proposal.task_kind}")
|
|
307
|
+
typer.echo(f"Status: {proposal.status}")
|
|
308
|
+
if proposal.epic_id:
|
|
309
|
+
typer.echo(f"Epic: {proposal.epic_id}")
|
|
310
|
+
|
|
311
|
+
sections = _extract_body_sections(proposal.body)
|
|
312
|
+
if sections:
|
|
313
|
+
typer.echo("")
|
|
314
|
+
for heading, content in sections:
|
|
315
|
+
typer.echo(f"## {heading}")
|
|
316
|
+
typer.echo(content.strip())
|
|
317
|
+
typer.echo("")
|
|
318
|
+
elif proposal.body.strip():
|
|
319
|
+
typer.echo("")
|
|
320
|
+
typer.echo(proposal.body.strip())
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def format_apply_task_intake_summary(task_id: str, *, title: str, epic_id: str | None) -> None:
|
|
324
|
+
typer.echo(f"Created task {task_id} in planning/tasks/{task_id}.md")
|
|
325
|
+
typer.echo(f"Title: {title}")
|
|
326
|
+
if epic_id:
|
|
327
|
+
typer.echo(f"Epic: {epic_id}")
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def format_draft_epic_show(proposal: EpicDraftProposal) -> None:
|
|
331
|
+
"""Print one proposed epic draft in detail."""
|
|
332
|
+
epic = proposal.epic
|
|
333
|
+
typer.echo(f"Draft epic {epic.id} (not applied)")
|
|
334
|
+
typer.echo(f"Title: {epic.title}")
|
|
335
|
+
typer.echo(f"Status: {epic.status}")
|
|
336
|
+
if epic.summary:
|
|
337
|
+
typer.echo(f"Summary: {epic.summary}")
|
|
338
|
+
|
|
339
|
+
sections = _extract_body_sections(epic.body)
|
|
340
|
+
if sections:
|
|
341
|
+
typer.echo("")
|
|
342
|
+
for heading, content in sections:
|
|
343
|
+
typer.echo(f"## {heading}")
|
|
344
|
+
typer.echo(content.strip())
|
|
345
|
+
typer.echo("")
|
|
346
|
+
elif epic.body.strip():
|
|
347
|
+
typer.echo("")
|
|
348
|
+
typer.echo(epic.body.strip())
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def format_apply_summary(epic_id: str, created_ids: list[str]) -> None:
|
|
352
|
+
joined = ", ".join(created_ids)
|
|
353
|
+
typer.echo(f"Created {len(created_ids)} new tasks for {epic_id}: {joined}")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _first_content_line(content: str) -> str | None:
|
|
357
|
+
line = content.strip().splitlines()[0] if content.strip() else ""
|
|
358
|
+
return line or None
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _first_summary_line(body: str) -> str | None:
|
|
362
|
+
sections = _extract_body_sections(body)
|
|
363
|
+
for heading, content in sections:
|
|
364
|
+
if heading.lower() == "summary":
|
|
365
|
+
first_line = content.strip().splitlines()[0] if content.strip() else ""
|
|
366
|
+
return first_line or None
|
|
367
|
+
return None
|