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/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""A2C command-line interface — thin layer over a2c_core."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI command modules (Milestone 3+)."""
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Apply commands — write validated proposals into planning/."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from a2c_cli.context import echo_issues
|
|
10
|
+
from a2c_cli.exit_codes import EXIT_NO_PROPOSAL, EXIT_NOT_FOUND, EXIT_VALIDATION_FAILED
|
|
11
|
+
from a2c_cli.output import (
|
|
12
|
+
format_apply_epic_summary,
|
|
13
|
+
format_apply_summary,
|
|
14
|
+
format_apply_task_intake_summary,
|
|
15
|
+
)
|
|
16
|
+
from a2c_core.errors import A2C_WORKFLOW_001, A2C_WORKFLOW_003, A2C_WORKFLOW_004
|
|
17
|
+
from a2c_core.services import apply_epic_draft, apply_task_intake_draft, apply_tasks_from_epic
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def apply_epic_draft_cmd(
|
|
21
|
+
epic_id: str,
|
|
22
|
+
path: Path | None,
|
|
23
|
+
*,
|
|
24
|
+
force: bool,
|
|
25
|
+
) -> None:
|
|
26
|
+
"""Apply a cached epic draft into planning/epics/."""
|
|
27
|
+
result = apply_epic_draft(path, epic_id, force=force)
|
|
28
|
+
if not result.ok:
|
|
29
|
+
if any(issue.code == A2C_WORKFLOW_001 for issue in result.errors):
|
|
30
|
+
typer.echo("A2C apply: no valid epic draft", err=True)
|
|
31
|
+
echo_issues("Errors", result.errors, err=True)
|
|
32
|
+
raise typer.Exit(EXIT_NO_PROPOSAL)
|
|
33
|
+
if any(issue.code == A2C_WORKFLOW_003 for issue in result.errors):
|
|
34
|
+
typer.echo("A2C apply: epic id conflict", err=True)
|
|
35
|
+
echo_issues("Errors", result.errors, err=True)
|
|
36
|
+
raise typer.Exit(EXIT_VALIDATION_FAILED)
|
|
37
|
+
typer.echo("A2C apply: failed", err=True)
|
|
38
|
+
echo_issues("Errors", result.errors, err=True)
|
|
39
|
+
if result.warnings:
|
|
40
|
+
echo_issues("Warnings", result.warnings)
|
|
41
|
+
raise typer.Exit(EXIT_VALIDATION_FAILED)
|
|
42
|
+
|
|
43
|
+
assert result.value is not None
|
|
44
|
+
format_apply_epic_summary(result.value)
|
|
45
|
+
if result.warnings:
|
|
46
|
+
typer.echo("")
|
|
47
|
+
echo_issues("Warnings", result.warnings)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def apply_task_intake_draft_cmd(
|
|
51
|
+
draft_key: str,
|
|
52
|
+
path: Path | None,
|
|
53
|
+
*,
|
|
54
|
+
force: bool,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Apply a cached single-task intake draft into planning/tasks/."""
|
|
57
|
+
result = apply_task_intake_draft(path, draft_key, force=force)
|
|
58
|
+
if not result.ok:
|
|
59
|
+
if any(issue.code == A2C_WORKFLOW_001 for issue in result.errors):
|
|
60
|
+
typer.echo("A2C apply: no valid task draft", err=True)
|
|
61
|
+
echo_issues("Errors", result.errors, err=True)
|
|
62
|
+
raise typer.Exit(EXIT_NO_PROPOSAL)
|
|
63
|
+
if any(issue.code == A2C_WORKFLOW_003 for issue in result.errors):
|
|
64
|
+
typer.echo("A2C apply: task id conflict", err=True)
|
|
65
|
+
echo_issues("Errors", result.errors, err=True)
|
|
66
|
+
raise typer.Exit(EXIT_VALIDATION_FAILED)
|
|
67
|
+
typer.echo("A2C apply: failed", err=True)
|
|
68
|
+
echo_issues("Errors", result.errors, err=True)
|
|
69
|
+
if result.warnings:
|
|
70
|
+
echo_issues("Warnings", result.warnings)
|
|
71
|
+
raise typer.Exit(EXIT_VALIDATION_FAILED)
|
|
72
|
+
|
|
73
|
+
assert result.value is not None
|
|
74
|
+
task = result.value
|
|
75
|
+
format_apply_task_intake_summary(
|
|
76
|
+
task.id,
|
|
77
|
+
title=task.title,
|
|
78
|
+
epic_id=task.epic_id,
|
|
79
|
+
)
|
|
80
|
+
if result.warnings:
|
|
81
|
+
typer.echo("")
|
|
82
|
+
echo_issues("Warnings", result.warnings)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def apply_tasks_from_epic_cmd(
|
|
86
|
+
epic_id: str,
|
|
87
|
+
path: Path | None,
|
|
88
|
+
*,
|
|
89
|
+
force: bool,
|
|
90
|
+
) -> None:
|
|
91
|
+
"""Apply cached task drafts for an epic into planning/tasks/."""
|
|
92
|
+
result = apply_tasks_from_epic(path, epic_id, force=force)
|
|
93
|
+
if not result.ok:
|
|
94
|
+
if any(issue.code == A2C_WORKFLOW_004 for issue in result.errors):
|
|
95
|
+
typer.echo("A2C apply: epic not found", err=True)
|
|
96
|
+
raise typer.Exit(EXIT_NOT_FOUND)
|
|
97
|
+
if any(issue.code == A2C_WORKFLOW_001 for issue in result.errors):
|
|
98
|
+
typer.echo("A2C apply: no valid proposal", err=True)
|
|
99
|
+
echo_issues("Errors", result.errors, err=True)
|
|
100
|
+
raise typer.Exit(EXIT_NO_PROPOSAL)
|
|
101
|
+
typer.echo("A2C apply: failed", err=True)
|
|
102
|
+
echo_issues("Errors", result.errors, err=True)
|
|
103
|
+
if result.warnings:
|
|
104
|
+
echo_issues("Warnings", result.warnings)
|
|
105
|
+
raise typer.Exit(EXIT_VALIDATION_FAILED)
|
|
106
|
+
|
|
107
|
+
assert result.value is not None
|
|
108
|
+
format_apply_summary(epic_id, result.value)
|
|
109
|
+
if result.warnings:
|
|
110
|
+
typer.echo("")
|
|
111
|
+
echo_issues("Warnings", result.warnings)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Doctor command — repository health checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from a2c_cli.exit_codes import EXIT_NOT_A2C_REPO, EXIT_VALIDATION_FAILED
|
|
10
|
+
from a2c_cli.output import format_doctor_report
|
|
11
|
+
from a2c_core.services import check_repository_health
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def doctor_cmd(path: Path | None = None) -> None:
|
|
15
|
+
"""Check A2C repository structure and readiness."""
|
|
16
|
+
report = check_repository_health(path)
|
|
17
|
+
format_doctor_report(report)
|
|
18
|
+
|
|
19
|
+
if report.root is None:
|
|
20
|
+
raise typer.Exit(EXIT_NOT_A2C_REPO)
|
|
21
|
+
if not report.ok:
|
|
22
|
+
raise typer.Exit(EXIT_VALIDATION_FAILED)
|
|
23
|
+
raise typer.Exit(0)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""List planning artifacts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from a2c_cli.context import require_snapshot
|
|
8
|
+
from a2c_cli.output import format_epic_list, format_sprint_list, format_task_list
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def list_epics(path: Path | None = None) -> None:
|
|
12
|
+
"""List epics under planning/epics/."""
|
|
13
|
+
snapshot = require_snapshot(path)
|
|
14
|
+
format_epic_list(snapshot.epics)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def list_tasks(path: Path | None = None) -> None:
|
|
18
|
+
"""List tasks under planning/tasks/."""
|
|
19
|
+
snapshot = require_snapshot(path)
|
|
20
|
+
format_task_list(snapshot.tasks)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def list_sprints(path: Path | None = None) -> None:
|
|
24
|
+
"""List sprints under planning/sprints/."""
|
|
25
|
+
snapshot = require_snapshot(path)
|
|
26
|
+
format_sprint_list(snapshot.sprints)
|
a2c_cli/commands/plan.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Plan commands — AI-assisted proposal generation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from a2c_cli.context import echo_issues, require_inspection
|
|
11
|
+
from a2c_cli.exit_codes import EXIT_NOT_FOUND, EXIT_VALIDATION_FAILED
|
|
12
|
+
from a2c_cli.output import (
|
|
13
|
+
format_create_epic_summary,
|
|
14
|
+
format_create_task_summary,
|
|
15
|
+
format_decomposition_summary,
|
|
16
|
+
)
|
|
17
|
+
from a2c_core.errors import A2C_PROVIDER_004, A2C_WORKFLOW_004, ValidationIssue
|
|
18
|
+
from a2c_core.schemas.workflows_io import TaskIntakeKind
|
|
19
|
+
from a2c_core.services import (
|
|
20
|
+
create_epic_from_brief,
|
|
21
|
+
create_task_from_input,
|
|
22
|
+
create_task_from_task_json,
|
|
23
|
+
decompose_epic,
|
|
24
|
+
)
|
|
25
|
+
from a2c_core.workflows.provider_errors import ProviderError
|
|
26
|
+
from a2c_core.workflows.providers.resolver import (
|
|
27
|
+
merge_decomposition_settings,
|
|
28
|
+
resolve_create_epic_provider,
|
|
29
|
+
resolve_create_task_provider,
|
|
30
|
+
resolve_decomposition_provider,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _collect_brief(
|
|
35
|
+
*,
|
|
36
|
+
from_text: str | None,
|
|
37
|
+
from_file: Path | None,
|
|
38
|
+
use_stdin: bool,
|
|
39
|
+
) -> str:
|
|
40
|
+
modes = [from_text is not None, from_file is not None, use_stdin]
|
|
41
|
+
if sum(modes) != 1:
|
|
42
|
+
typer.echo(
|
|
43
|
+
"error: provide exactly one input: --from-text, --from-file, or --stdin",
|
|
44
|
+
err=True,
|
|
45
|
+
)
|
|
46
|
+
raise typer.Exit(EXIT_VALIDATION_FAILED)
|
|
47
|
+
|
|
48
|
+
if from_text is not None:
|
|
49
|
+
return from_text
|
|
50
|
+
if from_file is not None:
|
|
51
|
+
if not from_file.is_file():
|
|
52
|
+
typer.echo(f"error: brief file not found: {from_file}", err=True)
|
|
53
|
+
raise typer.Exit(EXIT_VALIDATION_FAILED)
|
|
54
|
+
return from_file.read_text(encoding="utf-8")
|
|
55
|
+
return sys.stdin.read()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _resolve_task_kind(*, task_type: str | None, use_bug: bool) -> TaskIntakeKind:
|
|
59
|
+
if use_bug or task_type == "bug":
|
|
60
|
+
return "bug"
|
|
61
|
+
if task_type is not None and task_type != "feature":
|
|
62
|
+
typer.echo(
|
|
63
|
+
f"error: unsupported task type {task_type!r}; use feature or bug",
|
|
64
|
+
err=True,
|
|
65
|
+
)
|
|
66
|
+
raise typer.Exit(EXIT_VALIDATION_FAILED)
|
|
67
|
+
return "feature"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _collect_task_input(
|
|
71
|
+
*,
|
|
72
|
+
from_text: str | None,
|
|
73
|
+
from_file: Path | None,
|
|
74
|
+
from_task_json: Path | None,
|
|
75
|
+
use_stdin: bool,
|
|
76
|
+
) -> str | None:
|
|
77
|
+
modes = [
|
|
78
|
+
from_text is not None,
|
|
79
|
+
from_file is not None,
|
|
80
|
+
from_task_json is not None,
|
|
81
|
+
use_stdin,
|
|
82
|
+
]
|
|
83
|
+
if sum(modes) != 1:
|
|
84
|
+
typer.echo(
|
|
85
|
+
"error: provide exactly one input: --from-text, --from-file, "
|
|
86
|
+
"--from-task-json, or --stdin",
|
|
87
|
+
err=True,
|
|
88
|
+
)
|
|
89
|
+
raise typer.Exit(EXIT_VALIDATION_FAILED)
|
|
90
|
+
|
|
91
|
+
if from_task_json is not None:
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
if from_text is not None:
|
|
95
|
+
return from_text
|
|
96
|
+
if from_file is not None:
|
|
97
|
+
if not from_file.is_file():
|
|
98
|
+
typer.echo(f"error: intake file not found: {from_file}", err=True)
|
|
99
|
+
raise typer.Exit(EXIT_VALIDATION_FAILED)
|
|
100
|
+
return from_file.read_text(encoding="utf-8")
|
|
101
|
+
return sys.stdin.read()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _reject_provider_flags_with_task_json(
|
|
105
|
+
*,
|
|
106
|
+
from_task_json: Path | None,
|
|
107
|
+
provider_name: str | None,
|
|
108
|
+
fixture_path: Path | None,
|
|
109
|
+
model: str | None,
|
|
110
|
+
endpoint: str | None,
|
|
111
|
+
) -> None:
|
|
112
|
+
if from_task_json is None:
|
|
113
|
+
return
|
|
114
|
+
blocked = [
|
|
115
|
+
name
|
|
116
|
+
for name, value in (
|
|
117
|
+
("--provider", provider_name),
|
|
118
|
+
("--fixture", fixture_path),
|
|
119
|
+
("--model", model),
|
|
120
|
+
("--endpoint", endpoint),
|
|
121
|
+
)
|
|
122
|
+
if value is not None
|
|
123
|
+
]
|
|
124
|
+
if blocked:
|
|
125
|
+
joined = ", ".join(blocked)
|
|
126
|
+
typer.echo(
|
|
127
|
+
f"error: {joined} cannot be used with --from-task-json",
|
|
128
|
+
err=True,
|
|
129
|
+
)
|
|
130
|
+
raise typer.Exit(EXIT_VALIDATION_FAILED)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def create_task_cmd(
|
|
134
|
+
path: Path | None,
|
|
135
|
+
*,
|
|
136
|
+
from_text: str | None,
|
|
137
|
+
from_file: Path | None,
|
|
138
|
+
from_task_json: Path | None,
|
|
139
|
+
use_stdin: bool,
|
|
140
|
+
task_type: str | None,
|
|
141
|
+
use_bug: bool,
|
|
142
|
+
epic_id: str | None,
|
|
143
|
+
provider_name: str | None,
|
|
144
|
+
fixture_path: Path | None,
|
|
145
|
+
model: str | None,
|
|
146
|
+
endpoint: str | None,
|
|
147
|
+
) -> None:
|
|
148
|
+
"""Generate and review a proposed single task draft from raw input."""
|
|
149
|
+
_reject_provider_flags_with_task_json(
|
|
150
|
+
from_task_json=from_task_json,
|
|
151
|
+
provider_name=provider_name,
|
|
152
|
+
fixture_path=fixture_path,
|
|
153
|
+
model=model,
|
|
154
|
+
endpoint=endpoint,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
if from_task_json is not None:
|
|
158
|
+
if not from_task_json.is_file():
|
|
159
|
+
typer.echo(f"error: task JSON file not found: {from_task_json}", err=True)
|
|
160
|
+
raise typer.Exit(EXIT_VALIDATION_FAILED)
|
|
161
|
+
result = create_task_from_task_json(path, from_task_json, epic_id=epic_id)
|
|
162
|
+
else:
|
|
163
|
+
input_text = _collect_task_input(
|
|
164
|
+
from_text=from_text,
|
|
165
|
+
from_file=from_file,
|
|
166
|
+
from_task_json=None,
|
|
167
|
+
use_stdin=use_stdin,
|
|
168
|
+
)
|
|
169
|
+
assert input_text is not None
|
|
170
|
+
task_kind = _resolve_task_kind(task_type=task_type, use_bug=use_bug)
|
|
171
|
+
inspection = require_inspection(path)
|
|
172
|
+
assert inspection.snapshot is not None
|
|
173
|
+
config = merge_decomposition_settings(
|
|
174
|
+
inspection.snapshot.config,
|
|
175
|
+
model=model,
|
|
176
|
+
endpoint=endpoint,
|
|
177
|
+
)
|
|
178
|
+
try:
|
|
179
|
+
provider = resolve_create_task_provider(
|
|
180
|
+
provider_name=provider_name,
|
|
181
|
+
config=config,
|
|
182
|
+
fixture_path=fixture_path,
|
|
183
|
+
repo_root=inspection.root,
|
|
184
|
+
)
|
|
185
|
+
except ProviderError as exc:
|
|
186
|
+
typer.echo("A2C plan: provider configuration failed", err=True)
|
|
187
|
+
echo_issues(
|
|
188
|
+
"Errors",
|
|
189
|
+
[ValidationIssue(code=exc.code, message=exc.message, path="provider")],
|
|
190
|
+
err=True,
|
|
191
|
+
)
|
|
192
|
+
raise typer.Exit(EXIT_VALIDATION_FAILED) from exc
|
|
193
|
+
|
|
194
|
+
result = create_task_from_input(
|
|
195
|
+
path,
|
|
196
|
+
input_text,
|
|
197
|
+
provider,
|
|
198
|
+
task_kind=task_kind,
|
|
199
|
+
epic_id=epic_id,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if not result.ok:
|
|
203
|
+
typer.echo("A2C plan: task authoring failed", err=True)
|
|
204
|
+
if any(issue.code == A2C_WORKFLOW_004 for issue in result.errors):
|
|
205
|
+
raise typer.Exit(EXIT_NOT_FOUND)
|
|
206
|
+
if any(issue.code == A2C_PROVIDER_004 for issue in result.errors):
|
|
207
|
+
raise typer.Exit(EXIT_VALIDATION_FAILED)
|
|
208
|
+
echo_issues("Errors", result.errors, err=True)
|
|
209
|
+
if result.warnings:
|
|
210
|
+
echo_issues("Warnings", result.warnings)
|
|
211
|
+
raise typer.Exit(EXIT_VALIDATION_FAILED)
|
|
212
|
+
|
|
213
|
+
assert result.value is not None
|
|
214
|
+
format_create_task_summary(result.value)
|
|
215
|
+
if result.warnings:
|
|
216
|
+
typer.echo("")
|
|
217
|
+
echo_issues("Warnings", result.warnings)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def create_epic_cmd(
|
|
221
|
+
path: Path | None,
|
|
222
|
+
*,
|
|
223
|
+
from_text: str | None,
|
|
224
|
+
from_file: Path | None,
|
|
225
|
+
use_stdin: bool,
|
|
226
|
+
provider_name: str | None,
|
|
227
|
+
fixture_path: Path | None,
|
|
228
|
+
model: str | None,
|
|
229
|
+
endpoint: str | None,
|
|
230
|
+
) -> None:
|
|
231
|
+
"""Generate and review a proposed epic draft from a short brief."""
|
|
232
|
+
brief = _collect_brief(from_text=from_text, from_file=from_file, use_stdin=use_stdin)
|
|
233
|
+
inspection = require_inspection(path)
|
|
234
|
+
assert inspection.snapshot is not None
|
|
235
|
+
config = merge_decomposition_settings(
|
|
236
|
+
inspection.snapshot.config,
|
|
237
|
+
model=model,
|
|
238
|
+
endpoint=endpoint,
|
|
239
|
+
)
|
|
240
|
+
try:
|
|
241
|
+
provider = resolve_create_epic_provider(
|
|
242
|
+
provider_name=provider_name,
|
|
243
|
+
config=config,
|
|
244
|
+
fixture_path=fixture_path,
|
|
245
|
+
repo_root=inspection.root,
|
|
246
|
+
)
|
|
247
|
+
except ProviderError as exc:
|
|
248
|
+
typer.echo("A2C plan: provider configuration failed", err=True)
|
|
249
|
+
echo_issues(
|
|
250
|
+
"Errors",
|
|
251
|
+
[ValidationIssue(code=exc.code, message=exc.message, path="provider")],
|
|
252
|
+
err=True,
|
|
253
|
+
)
|
|
254
|
+
raise typer.Exit(EXIT_VALIDATION_FAILED) from exc
|
|
255
|
+
|
|
256
|
+
result = create_epic_from_brief(path, brief, provider)
|
|
257
|
+
if not result.ok:
|
|
258
|
+
typer.echo("A2C plan: epic authoring failed", err=True)
|
|
259
|
+
if any(issue.code == A2C_PROVIDER_004 for issue in result.errors):
|
|
260
|
+
raise typer.Exit(EXIT_VALIDATION_FAILED)
|
|
261
|
+
echo_issues("Errors", result.errors, err=True)
|
|
262
|
+
if result.warnings:
|
|
263
|
+
echo_issues("Warnings", result.warnings)
|
|
264
|
+
raise typer.Exit(EXIT_VALIDATION_FAILED)
|
|
265
|
+
|
|
266
|
+
assert result.value is not None
|
|
267
|
+
format_create_epic_summary(result.value)
|
|
268
|
+
if result.warnings:
|
|
269
|
+
typer.echo("")
|
|
270
|
+
echo_issues("Warnings", result.warnings)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def decompose_epic_cmd(
|
|
274
|
+
epic_id: str,
|
|
275
|
+
path: Path | None,
|
|
276
|
+
*,
|
|
277
|
+
provider_name: str | None,
|
|
278
|
+
fixture_path: Path | None,
|
|
279
|
+
model: str | None,
|
|
280
|
+
endpoint: str | None,
|
|
281
|
+
) -> None:
|
|
282
|
+
"""Generate and review proposed task drafts for an epic."""
|
|
283
|
+
inspection = require_inspection(path)
|
|
284
|
+
assert inspection.snapshot is not None
|
|
285
|
+
config = merge_decomposition_settings(
|
|
286
|
+
inspection.snapshot.config,
|
|
287
|
+
model=model,
|
|
288
|
+
endpoint=endpoint,
|
|
289
|
+
)
|
|
290
|
+
try:
|
|
291
|
+
provider = resolve_decomposition_provider(
|
|
292
|
+
provider_name=provider_name,
|
|
293
|
+
config=config,
|
|
294
|
+
fixture_path=fixture_path,
|
|
295
|
+
repo_root=inspection.root,
|
|
296
|
+
)
|
|
297
|
+
except ProviderError as exc:
|
|
298
|
+
typer.echo("A2C plan: provider configuration failed", err=True)
|
|
299
|
+
echo_issues(
|
|
300
|
+
"Errors",
|
|
301
|
+
[ValidationIssue(code=exc.code, message=exc.message, path="provider")],
|
|
302
|
+
err=True,
|
|
303
|
+
)
|
|
304
|
+
raise typer.Exit(EXIT_VALIDATION_FAILED) from exc
|
|
305
|
+
|
|
306
|
+
result = decompose_epic(path, epic_id, provider)
|
|
307
|
+
if not result.ok:
|
|
308
|
+
typer.echo("A2C plan: decomposition failed", err=True)
|
|
309
|
+
if any(issue.code == A2C_WORKFLOW_004 for issue in result.errors):
|
|
310
|
+
raise typer.Exit(EXIT_NOT_FOUND)
|
|
311
|
+
if any(issue.code == A2C_PROVIDER_004 for issue in result.errors):
|
|
312
|
+
raise typer.Exit(EXIT_VALIDATION_FAILED)
|
|
313
|
+
echo_issues("Errors", result.errors, err=True)
|
|
314
|
+
if result.warnings:
|
|
315
|
+
echo_issues("Warnings", result.warnings)
|
|
316
|
+
raise typer.Exit(EXIT_VALIDATION_FAILED)
|
|
317
|
+
|
|
318
|
+
assert result.value is not None
|
|
319
|
+
format_decomposition_summary(result.value)
|
|
320
|
+
if result.warnings:
|
|
321
|
+
typer.echo("")
|
|
322
|
+
echo_issues("Warnings", result.warnings)
|
a2c_cli/commands/show.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Show a single planning artifact."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from a2c_cli.context import echo_issues, exit_not_found, require_snapshot
|
|
10
|
+
from a2c_cli.exit_codes import EXIT_NO_PROPOSAL, EXIT_NOT_FOUND
|
|
11
|
+
from a2c_cli.output import (
|
|
12
|
+
format_draft_epic_show,
|
|
13
|
+
format_draft_task_intake_show,
|
|
14
|
+
format_draft_task_show,
|
|
15
|
+
format_epic_show,
|
|
16
|
+
format_sprint_show,
|
|
17
|
+
format_task_show,
|
|
18
|
+
)
|
|
19
|
+
from a2c_core.services import (
|
|
20
|
+
get_cached_epic_draft,
|
|
21
|
+
get_cached_proposal,
|
|
22
|
+
get_cached_task_intake_draft,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def show_epic(artifact_id: str, path: Path | None = None) -> None:
|
|
27
|
+
"""Show one epic by id."""
|
|
28
|
+
snapshot = require_snapshot(path)
|
|
29
|
+
for epic in snapshot.epics:
|
|
30
|
+
if epic.id == artifact_id:
|
|
31
|
+
format_epic_show(epic)
|
|
32
|
+
return
|
|
33
|
+
exit_not_found("epic", artifact_id)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def show_task(artifact_id: str, path: Path | None = None) -> None:
|
|
37
|
+
"""Show one task by id."""
|
|
38
|
+
snapshot = require_snapshot(path)
|
|
39
|
+
for task in snapshot.tasks:
|
|
40
|
+
if task.id == artifact_id:
|
|
41
|
+
format_task_show(task)
|
|
42
|
+
return
|
|
43
|
+
exit_not_found("task", artifact_id)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def show_sprint(artifact_id: str, path: Path | None = None) -> None:
|
|
47
|
+
"""Show one sprint by id."""
|
|
48
|
+
snapshot = require_snapshot(path)
|
|
49
|
+
for sprint in snapshot.sprints:
|
|
50
|
+
if sprint.id == artifact_id:
|
|
51
|
+
format_sprint_show(sprint)
|
|
52
|
+
return
|
|
53
|
+
exit_not_found("sprint", artifact_id)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def show_draft_epic(epic_id: str, path: Path | None = None) -> None:
|
|
57
|
+
"""Show a proposed epic draft from cache."""
|
|
58
|
+
result = get_cached_epic_draft(path, epic_id)
|
|
59
|
+
if not result.ok:
|
|
60
|
+
typer.echo("A2C show: epic draft not available", err=True)
|
|
61
|
+
echo_issues("Errors", result.errors, err=True)
|
|
62
|
+
raise typer.Exit(EXIT_NO_PROPOSAL)
|
|
63
|
+
|
|
64
|
+
assert result.value is not None
|
|
65
|
+
format_draft_epic_show(result.value)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def show_draft_task_intake(draft_key: str, path: Path | None = None) -> None:
|
|
69
|
+
"""Show a proposed single-task intake draft from cache."""
|
|
70
|
+
result = get_cached_task_intake_draft(path, draft_key)
|
|
71
|
+
if not result.ok:
|
|
72
|
+
typer.echo("A2C show: task intake draft not available", err=True)
|
|
73
|
+
echo_issues("Errors", result.errors, err=True)
|
|
74
|
+
raise typer.Exit(EXIT_NO_PROPOSAL)
|
|
75
|
+
|
|
76
|
+
assert result.value is not None
|
|
77
|
+
format_draft_task_intake_show(result.value)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def show_draft_task(epic_id: str, draft_index: int, path: Path | None = None) -> None:
|
|
81
|
+
"""Show one proposed task draft from a cached decomposition."""
|
|
82
|
+
result = get_cached_proposal(path, epic_id)
|
|
83
|
+
if not result.ok:
|
|
84
|
+
typer.echo("A2C show: draft task not available", err=True)
|
|
85
|
+
echo_issues("Errors", result.errors, err=True)
|
|
86
|
+
raise typer.Exit(EXIT_NO_PROPOSAL)
|
|
87
|
+
|
|
88
|
+
assert result.value is not None
|
|
89
|
+
proposal = result.value
|
|
90
|
+
if draft_index < 1 or draft_index > len(proposal.tasks):
|
|
91
|
+
typer.echo(
|
|
92
|
+
f"error: draft index {draft_index} out of range (1..{len(proposal.tasks)})",
|
|
93
|
+
err=True,
|
|
94
|
+
)
|
|
95
|
+
raise typer.Exit(EXIT_NOT_FOUND)
|
|
96
|
+
|
|
97
|
+
draft = proposal.tasks[draft_index - 1]
|
|
98
|
+
format_draft_task_show(draft, index=draft_index, epic_id=epic_id)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Validate command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from a2c_cli.exit_codes import EXIT_NOT_A2C_REPO, EXIT_VALIDATION_FAILED
|
|
10
|
+
from a2c_cli.output import format_validation_report
|
|
11
|
+
from a2c_core.services import inspect_repository
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def validate_cmd(path: Path | None = None) -> None:
|
|
15
|
+
"""Validate A2C config and planning artifacts."""
|
|
16
|
+
result = inspect_repository(path)
|
|
17
|
+
format_validation_report(result)
|
|
18
|
+
|
|
19
|
+
if result.root is None:
|
|
20
|
+
raise typer.Exit(EXIT_NOT_A2C_REPO)
|
|
21
|
+
if result.errors:
|
|
22
|
+
raise typer.Exit(EXIT_VALIDATION_FAILED)
|
a2c_cli/context.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Shared CLI context — repository resolution and errors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from a2c_cli.exit_codes import EXIT_NOT_A2C_REPO, EXIT_NOT_FOUND
|
|
11
|
+
from a2c_core.errors import A2C_REPO_001
|
|
12
|
+
from a2c_core.services import inspect_repository
|
|
13
|
+
from a2c_core.services.inspection import InspectionResult
|
|
14
|
+
from a2c_core.services.repo import RepositorySnapshot
|
|
15
|
+
|
|
16
|
+
_NOT_A2C_MESSAGE = (
|
|
17
|
+
"Not inside an A2C repository. Expected a root marker such as "
|
|
18
|
+
"AGENTS.md, docs/workflow-manifest.yaml, or .a2c/config.yaml."
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def require_inspection(start: Path | None = None) -> InspectionResult:
|
|
23
|
+
"""Load repository inspection or exit when not in an A2C repo."""
|
|
24
|
+
result = inspect_repository(start)
|
|
25
|
+
if result.root is None:
|
|
26
|
+
issue = result.errors[0] if result.errors else None
|
|
27
|
+
if issue and issue.code == A2C_REPO_001:
|
|
28
|
+
typer.echo(f"error: {_NOT_A2C_MESSAGE}", err=True)
|
|
29
|
+
else:
|
|
30
|
+
typer.echo("error: unable to locate A2C repository root", err=True)
|
|
31
|
+
raise typer.Exit(EXIT_NOT_A2C_REPO)
|
|
32
|
+
return result
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def require_snapshot(start: Path | None = None) -> RepositorySnapshot:
|
|
36
|
+
"""Return a loaded snapshot for read-only list/show commands."""
|
|
37
|
+
result = require_inspection(start)
|
|
38
|
+
assert result.snapshot is not None
|
|
39
|
+
return result.snapshot
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def exit_not_found(kind: str, artifact_id: str) -> None:
|
|
43
|
+
"""Exit when a planning artifact id cannot be resolved."""
|
|
44
|
+
typer.echo(f"error: {kind} {artifact_id!r} not found", err=True)
|
|
45
|
+
raise typer.Exit(EXIT_NOT_FOUND)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def echo_issues(label: str, issues: list, *, err: bool = False) -> None:
|
|
49
|
+
"""Print structured validation issues."""
|
|
50
|
+
if not issues:
|
|
51
|
+
return
|
|
52
|
+
stream = sys.stderr if err else sys.stdout
|
|
53
|
+
typer.echo(f"{label} ({len(issues)}):", err=err)
|
|
54
|
+
for issue in issues:
|
|
55
|
+
path = issue.path or "-"
|
|
56
|
+
typer.echo(f" {path}: [{issue.code}] {issue.message}", err=err, file=stream)
|