devflow-toolkit 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.
- devflow/__init__.py +0 -0
- devflow/adapters/__init__.py +47 -0
- devflow/adapters/agents.py +49 -0
- devflow/adapters/base.py +81 -0
- devflow/adapters/claude_code.py +120 -0
- devflow/adapters/codex.py +111 -0
- devflow/adapters/detector.py +118 -0
- devflow/adapters/instructions.py +263 -0
- devflow/adapters/kiro.py +211 -0
- devflow/adapters/opencode.py +237 -0
- devflow/adapters/registry.py +41 -0
- devflow/cli.py +39 -0
- devflow/commands/__init__.py +6 -0
- devflow/commands/approve.py +98 -0
- devflow/commands/harness.py +98 -0
- devflow/commands/init.py +544 -0
- devflow/commands/spec.py +514 -0
- devflow/commands/status.py +156 -0
- devflow/core/__init__.py +15 -0
- devflow/core/architecture.py +469 -0
- devflow/core/artifacts/__init__.py +29 -0
- devflow/core/artifacts/errors.py +19 -0
- devflow/core/artifacts/models.py +82 -0
- devflow/core/artifacts/store.py +130 -0
- devflow/core/clock.py +67 -0
- devflow/core/config.py +706 -0
- devflow/core/constitution.py +166 -0
- devflow/core/discovery.py +1304 -0
- devflow/core/ports.py +159 -0
- devflow/core/profile_manager.py +311 -0
- devflow/core/project.py +533 -0
- devflow/core/spec_workflow.py +932 -0
- devflow/core/specification.py +2443 -0
- devflow/core/state/__init__.py +75 -0
- devflow/core/state/errors.py +39 -0
- devflow/core/state/events.py +725 -0
- devflow/core/state/locator.py +116 -0
- devflow/core/state/lock.py +69 -0
- devflow/core/state/manager.py +960 -0
- devflow/core/state/models.py +164 -0
- devflow/core/state/transitions.py +114 -0
- devflow/models/__init__.py +6 -0
- devflow/models/profile.py +63 -0
- devflow/models/project.py +47 -0
- devflow/profiles/ai-rag/constitution/ai-rag-standards.md +49 -0
- devflow/profiles/ai-rag/profile.yaml +28 -0
- devflow/profiles/base/constitution/architecture.md +58 -0
- devflow/profiles/base/constitution/code-standards.md +85 -0
- devflow/profiles/base/constitution/constitution.md +85 -0
- devflow/profiles/base/profile.yaml +15 -0
- devflow/profiles/django-backend/constitution/django-standards.md +64 -0
- devflow/profiles/django-backend/profile.yaml +24 -0
- devflow/profiles/fastapi-backend/constitution/fastapi-standards.md +59 -0
- devflow/profiles/fastapi-backend/profile.yaml +23 -0
- devflow/prompts/__init__.py +200 -0
- devflow/prompts/discovery.md +119 -0
- devflow/prompts/spec.greenfield.md +149 -0
- devflow/prompts/spec.md +116 -0
- devflow_toolkit-0.3.0.dist-info/METADATA +628 -0
- devflow_toolkit-0.3.0.dist-info/RECORD +63 -0
- devflow_toolkit-0.3.0.dist-info/WHEEL +4 -0
- devflow_toolkit-0.3.0.dist-info/entry_points.txt +2 -0
- devflow_toolkit-0.3.0.dist-info/licenses/LICENSE +21 -0
devflow/commands/spec.py
ADDED
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
"""Implementation of the devflow spec command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
from devflow.adapters.base import execute_invocation
|
|
13
|
+
from devflow.commands.harness import (
|
|
14
|
+
adapter_for,
|
|
15
|
+
print_setup_result,
|
|
16
|
+
resolve_harness,
|
|
17
|
+
validate_harness_name,
|
|
18
|
+
)
|
|
19
|
+
from devflow.core.architecture import ArchitectureError
|
|
20
|
+
from devflow.core.artifacts.errors import ArtifactError
|
|
21
|
+
from devflow.core.config import (
|
|
22
|
+
EXECUTION_MODES,
|
|
23
|
+
ConfigError,
|
|
24
|
+
ConfigManager,
|
|
25
|
+
DevFlowConfig,
|
|
26
|
+
canonical_execution_mode,
|
|
27
|
+
)
|
|
28
|
+
from devflow.core.constitution import ConstitutionError
|
|
29
|
+
from devflow.core.discovery import DiscoveryError
|
|
30
|
+
from devflow.core.profile_manager import ProfileError
|
|
31
|
+
from devflow.core.project import ProjectDetector
|
|
32
|
+
from devflow.core.spec_workflow import (
|
|
33
|
+
SpecWorkflow,
|
|
34
|
+
SpecWorkflowRequest,
|
|
35
|
+
SpecWorkflowResult,
|
|
36
|
+
)
|
|
37
|
+
from devflow.core.state import (
|
|
38
|
+
StateError,
|
|
39
|
+
WorkflowLocator,
|
|
40
|
+
WorkflowStateManager,
|
|
41
|
+
WorkflowStatus,
|
|
42
|
+
)
|
|
43
|
+
from devflow.models.project import (
|
|
44
|
+
PROJECT_MODES,
|
|
45
|
+
canonical_project_mode,
|
|
46
|
+
)
|
|
47
|
+
from devflow.prompts import PromptError
|
|
48
|
+
|
|
49
|
+
console = Console()
|
|
50
|
+
error_console = Console(stderr=True)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def spec(
|
|
54
|
+
description: Optional[str] = typer.Argument(
|
|
55
|
+
None,
|
|
56
|
+
help=(
|
|
57
|
+
"Description of the new change. Omit it when using --continue."
|
|
58
|
+
),
|
|
59
|
+
),
|
|
60
|
+
path: Path = typer.Argument(
|
|
61
|
+
Path("."),
|
|
62
|
+
help="Project root or a path inside it.",
|
|
63
|
+
),
|
|
64
|
+
harness: Optional[str] = typer.Option(
|
|
65
|
+
None,
|
|
66
|
+
"--harness",
|
|
67
|
+
help=(
|
|
68
|
+
"Agent harness for this run: auto, codex, opencode, claude-code, "
|
|
69
|
+
"kiro or none."
|
|
70
|
+
),
|
|
71
|
+
),
|
|
72
|
+
mode: Optional[str] = typer.Option(
|
|
73
|
+
None,
|
|
74
|
+
"--mode",
|
|
75
|
+
help="Agent execution mode: interactive or non-interactive.",
|
|
76
|
+
),
|
|
77
|
+
no_agent: bool = typer.Option(
|
|
78
|
+
False,
|
|
79
|
+
"--no-agent",
|
|
80
|
+
help="Use deterministic static analysis without invoking an agent.",
|
|
81
|
+
),
|
|
82
|
+
project_mode: Optional[str] = typer.Option(
|
|
83
|
+
None,
|
|
84
|
+
"--project-mode",
|
|
85
|
+
help=(
|
|
86
|
+
"Specification context: existing (default) or greenfield."
|
|
87
|
+
),
|
|
88
|
+
),
|
|
89
|
+
continue_id: Optional[str] = typer.Option(
|
|
90
|
+
None,
|
|
91
|
+
"--continue",
|
|
92
|
+
help=(
|
|
93
|
+
"Resume an existing change by id (006) or directory name. "
|
|
94
|
+
"Does not create a new specification."
|
|
95
|
+
),
|
|
96
|
+
),
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Generate or resume a specification for a change request."""
|
|
99
|
+
try:
|
|
100
|
+
_create_specification(
|
|
101
|
+
description,
|
|
102
|
+
path,
|
|
103
|
+
harness,
|
|
104
|
+
mode,
|
|
105
|
+
no_agent,
|
|
106
|
+
project_mode,
|
|
107
|
+
continue_id,
|
|
108
|
+
)
|
|
109
|
+
except (
|
|
110
|
+
ArchitectureError,
|
|
111
|
+
ConfigError,
|
|
112
|
+
ConstitutionError,
|
|
113
|
+
DiscoveryError,
|
|
114
|
+
ProfileError,
|
|
115
|
+
PromptError,
|
|
116
|
+
StateError,
|
|
117
|
+
ArtifactError,
|
|
118
|
+
FileNotFoundError,
|
|
119
|
+
NotADirectoryError,
|
|
120
|
+
OSError,
|
|
121
|
+
) as error:
|
|
122
|
+
error_console.print(f"[red]Error:[/red] {error}")
|
|
123
|
+
raise typer.Exit(code=1) from error
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _create_specification(
|
|
127
|
+
description: Optional[str],
|
|
128
|
+
path: Path,
|
|
129
|
+
harness: Optional[str],
|
|
130
|
+
mode: Optional[str],
|
|
131
|
+
no_agent: bool,
|
|
132
|
+
project_mode: Optional[str],
|
|
133
|
+
continue_id: Optional[str],
|
|
134
|
+
) -> None:
|
|
135
|
+
request = (description or "").strip()
|
|
136
|
+
resume = (continue_id or "").strip() or None
|
|
137
|
+
if resume is not None and request:
|
|
138
|
+
# `devflow spec --continue 001 .` binds the path to the first
|
|
139
|
+
# positional argument. Treat an existing directory as the project
|
|
140
|
+
# path and keep --continue unambiguous.
|
|
141
|
+
candidate = Path(request).expanduser()
|
|
142
|
+
if path == Path(".") and candidate.is_dir():
|
|
143
|
+
path = candidate
|
|
144
|
+
request = ""
|
|
145
|
+
else:
|
|
146
|
+
msg = (
|
|
147
|
+
"Pass either a new change description or --continue, not both. "
|
|
148
|
+
"--continue reuses the request stored in discovery.yaml."
|
|
149
|
+
)
|
|
150
|
+
raise ConfigError(msg)
|
|
151
|
+
if resume is None and not request:
|
|
152
|
+
msg = (
|
|
153
|
+
"The change description cannot be empty. Use "
|
|
154
|
+
"'devflow spec \"...\"' or 'devflow spec --continue <id>'."
|
|
155
|
+
)
|
|
156
|
+
raise ConfigError(msg)
|
|
157
|
+
|
|
158
|
+
detector = ProjectDetector()
|
|
159
|
+
root = detector.find_root(path)
|
|
160
|
+
config = ConfigManager(root).load()
|
|
161
|
+
|
|
162
|
+
if resume is not None:
|
|
163
|
+
_ensure_continuable(config, resume)
|
|
164
|
+
|
|
165
|
+
requested_harness = validate_harness_name(
|
|
166
|
+
"none" if no_agent else (harness or config.agent_harness)
|
|
167
|
+
)
|
|
168
|
+
execution_mode, mode_warnings = _execution_mode(
|
|
169
|
+
mode or config.execution_mode,
|
|
170
|
+
)
|
|
171
|
+
effective_project_mode = _project_mode(
|
|
172
|
+
project_mode or config.project_mode,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
allow_prompt = sys.stdin.isatty()
|
|
176
|
+
selected_harness, selection_warnings = resolve_harness(
|
|
177
|
+
requested_harness,
|
|
178
|
+
allow_prompt=allow_prompt,
|
|
179
|
+
require_installed=True,
|
|
180
|
+
)
|
|
181
|
+
adapter = adapter_for(selected_harness) if selected_harness else None
|
|
182
|
+
|
|
183
|
+
workflow = SpecWorkflow(executor=execute_invocation)
|
|
184
|
+
result = workflow.run(
|
|
185
|
+
SpecWorkflowRequest(
|
|
186
|
+
description=request,
|
|
187
|
+
config=config,
|
|
188
|
+
execution_mode=execution_mode,
|
|
189
|
+
adapter=adapter,
|
|
190
|
+
warnings=tuple([*mode_warnings, *selection_warnings]),
|
|
191
|
+
project_mode=effective_project_mode,
|
|
192
|
+
continue_spec_id=resume,
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
console.print(
|
|
197
|
+
f"[bold]Project mode:[/bold] {result.project_mode}"
|
|
198
|
+
)
|
|
199
|
+
if result.architecture_digest is not None:
|
|
200
|
+
console.print(
|
|
201
|
+
f"[bold]Architecture digest:[/bold] "
|
|
202
|
+
f"{result.architecture_digest}",
|
|
203
|
+
soft_wrap=True,
|
|
204
|
+
)
|
|
205
|
+
if result.discovery is not None:
|
|
206
|
+
console.print(
|
|
207
|
+
f"[bold]Discovery:[/bold] {result.discovery.status} "
|
|
208
|
+
f"({len(result.discovery.pending_blocking)} blocking, "
|
|
209
|
+
f"{len(result.discovery.pending_assumptions)} assumptions)"
|
|
210
|
+
)
|
|
211
|
+
if result.discovery_digest:
|
|
212
|
+
console.print(
|
|
213
|
+
f"[bold]Discovery digest:[/bold] {result.discovery_digest}",
|
|
214
|
+
soft_wrap=True,
|
|
215
|
+
)
|
|
216
|
+
if result.harness_name is not None:
|
|
217
|
+
console.print(
|
|
218
|
+
f"[bold]Agent harness:[/bold] {result.harness_name} "
|
|
219
|
+
f"({result.execution_mode})"
|
|
220
|
+
)
|
|
221
|
+
_print_warnings(list(result.warnings))
|
|
222
|
+
if result.setup_result is not None:
|
|
223
|
+
print_setup_result(result.setup_result, config.root, console)
|
|
224
|
+
_print_warnings(list(result.run_warnings))
|
|
225
|
+
|
|
226
|
+
if result.blocked:
|
|
227
|
+
_print_blocked(result=result, config=config)
|
|
228
|
+
elif result.spec_reused and result.spec is not None:
|
|
229
|
+
console.print(
|
|
230
|
+
f"[bold green]Specification {result.spec.spec_id} is current."
|
|
231
|
+
"[/bold green] No changes were made."
|
|
232
|
+
)
|
|
233
|
+
console.print(
|
|
234
|
+
f"[bold]File:[/bold] "
|
|
235
|
+
f"{result.spec.path.relative_to(config.root)}",
|
|
236
|
+
soft_wrap=True,
|
|
237
|
+
)
|
|
238
|
+
else:
|
|
239
|
+
_print_result(result=result, config=config)
|
|
240
|
+
|
|
241
|
+
_record_workflow(
|
|
242
|
+
config=config,
|
|
243
|
+
result=result,
|
|
244
|
+
workflow=workflow,
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _ensure_continuable(config: DevFlowConfig, selector: str) -> None:
|
|
249
|
+
"""Reject continuing a change whose specification is already approved."""
|
|
250
|
+
manager = WorkflowStateManager(
|
|
251
|
+
config.specifications_path,
|
|
252
|
+
project_root=config.root,
|
|
253
|
+
)
|
|
254
|
+
location = WorkflowLocator(config.specifications_path).find(selector)
|
|
255
|
+
state = manager.load(location)
|
|
256
|
+
allowed = {
|
|
257
|
+
WorkflowStatus.DRAFT.value,
|
|
258
|
+
WorkflowStatus.AWAITING_CLARIFICATION.value,
|
|
259
|
+
}
|
|
260
|
+
if state.status not in allowed or "spec" in state.approvals:
|
|
261
|
+
msg = (
|
|
262
|
+
f"Change '{location.change}' is already approved or advanced "
|
|
263
|
+
f"({state.status}). --continue does not regenerate approved "
|
|
264
|
+
"artifacts. Start a new specification or request an explicit "
|
|
265
|
+
"revision."
|
|
266
|
+
)
|
|
267
|
+
raise StateError(msg)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _record_workflow(
|
|
271
|
+
*,
|
|
272
|
+
config: DevFlowConfig,
|
|
273
|
+
result: SpecWorkflowResult,
|
|
274
|
+
workflow: SpecWorkflow,
|
|
275
|
+
) -> None:
|
|
276
|
+
"""Register Discovery and the specification in the workflow state."""
|
|
277
|
+
directory = result.directory
|
|
278
|
+
if directory is None:
|
|
279
|
+
return
|
|
280
|
+
manager = WorkflowStateManager(
|
|
281
|
+
config.specifications_path,
|
|
282
|
+
project_root=config.root,
|
|
283
|
+
)
|
|
284
|
+
manager.initialize_change(directory)
|
|
285
|
+
location = WorkflowLocator(
|
|
286
|
+
config.specifications_path
|
|
287
|
+
).from_directory(directory)
|
|
288
|
+
|
|
289
|
+
if result.discovery_path is not None and result.discovery is not None:
|
|
290
|
+
manager.record_artifact(
|
|
291
|
+
location,
|
|
292
|
+
stage="discovery",
|
|
293
|
+
path=result.discovery_path,
|
|
294
|
+
validated=result.discovery.is_complete,
|
|
295
|
+
source=result.discovery_source,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
if result.spec is not None:
|
|
299
|
+
content = result.spec.path.read_text(encoding="utf-8")
|
|
300
|
+
valid, _ = workflow.manager.validate(
|
|
301
|
+
content,
|
|
302
|
+
project_mode=result.project_mode,
|
|
303
|
+
architecture=result.architecture,
|
|
304
|
+
discovery_digest=result.discovery_digest,
|
|
305
|
+
)
|
|
306
|
+
source = (
|
|
307
|
+
"deterministic"
|
|
308
|
+
if result.spec.source == "static"
|
|
309
|
+
else result.spec.source
|
|
310
|
+
)
|
|
311
|
+
if result.spec_written:
|
|
312
|
+
_invalidate_replaced_spec(
|
|
313
|
+
manager=manager,
|
|
314
|
+
location=location,
|
|
315
|
+
path=result.spec.path,
|
|
316
|
+
)
|
|
317
|
+
metadata: dict[str, str] = {}
|
|
318
|
+
if result.discovery_digest:
|
|
319
|
+
metadata["discovery_digest"] = result.discovery_digest
|
|
320
|
+
if result.architecture_digest:
|
|
321
|
+
metadata["architecture_digest"] = result.architecture_digest
|
|
322
|
+
manager.record_artifact(
|
|
323
|
+
location,
|
|
324
|
+
stage="spec",
|
|
325
|
+
path=result.spec.path,
|
|
326
|
+
validated=valid,
|
|
327
|
+
source=source,
|
|
328
|
+
metadata=metadata,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
state = manager.load(location)
|
|
332
|
+
if result.blocked:
|
|
333
|
+
_invalidate_outdated_spec(
|
|
334
|
+
manager=manager,
|
|
335
|
+
location=location,
|
|
336
|
+
result=result,
|
|
337
|
+
)
|
|
338
|
+
state = manager.transition(
|
|
339
|
+
location,
|
|
340
|
+
WorkflowStatus.AWAITING_CLARIFICATION.value,
|
|
341
|
+
)
|
|
342
|
+
elif state.status == WorkflowStatus.AWAITING_CLARIFICATION.value:
|
|
343
|
+
state = manager.transition(
|
|
344
|
+
location,
|
|
345
|
+
WorkflowStatus.DRAFT.value,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
console.print(
|
|
349
|
+
"[bold]Workflow:[/bold] "
|
|
350
|
+
f"{state.status} · metadata in "
|
|
351
|
+
f"{location.directory.relative_to(config.root).as_posix()}"
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
def _invalidate_replaced_spec(
|
|
356
|
+
*,
|
|
357
|
+
manager: WorkflowStateManager,
|
|
358
|
+
location,
|
|
359
|
+
path: Path,
|
|
360
|
+
) -> None:
|
|
361
|
+
"""Mark the previous specification stale before writing a new digest."""
|
|
362
|
+
state = manager.load(location)
|
|
363
|
+
record = state.artifacts.get("spec")
|
|
364
|
+
if record is None:
|
|
365
|
+
return
|
|
366
|
+
current_digest = manager.store.digest_file(path)
|
|
367
|
+
if record.digest == current_digest:
|
|
368
|
+
return
|
|
369
|
+
manager.invalidate_artifact(
|
|
370
|
+
location,
|
|
371
|
+
stage="spec",
|
|
372
|
+
kind="spec",
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _invalidate_outdated_spec(
|
|
377
|
+
*,
|
|
378
|
+
manager: WorkflowStateManager,
|
|
379
|
+
location,
|
|
380
|
+
result: SpecWorkflowResult,
|
|
381
|
+
) -> None:
|
|
382
|
+
"""Mark a specification stale when Discovery changed under it."""
|
|
383
|
+
if not result.discovery_digest:
|
|
384
|
+
return
|
|
385
|
+
state = manager.load(location)
|
|
386
|
+
record = state.artifacts.get("spec")
|
|
387
|
+
if record is None:
|
|
388
|
+
return
|
|
389
|
+
recorded = str(record.metadata.get("discovery_digest", ""))
|
|
390
|
+
if recorded and recorded != result.discovery_digest:
|
|
391
|
+
manager.invalidate_artifact(
|
|
392
|
+
location,
|
|
393
|
+
stage="spec",
|
|
394
|
+
kind="spec",
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _project_mode(project_mode: str) -> str:
|
|
399
|
+
normalized = canonical_project_mode(project_mode)
|
|
400
|
+
if normalized not in PROJECT_MODES:
|
|
401
|
+
msg = (
|
|
402
|
+
f"Unknown project mode: {project_mode}. "
|
|
403
|
+
f"Supported values: {', '.join(PROJECT_MODES)}."
|
|
404
|
+
)
|
|
405
|
+
raise ConfigError(msg)
|
|
406
|
+
return normalized
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _execution_mode(mode: str) -> tuple[str, list[str]]:
|
|
410
|
+
normalized = canonical_execution_mode(mode)
|
|
411
|
+
if normalized not in EXECUTION_MODES:
|
|
412
|
+
msg = (
|
|
413
|
+
f"Unknown execution mode: {mode}. "
|
|
414
|
+
f"Supported values: {', '.join(EXECUTION_MODES)}."
|
|
415
|
+
)
|
|
416
|
+
raise ConfigError(msg)
|
|
417
|
+
|
|
418
|
+
warnings: list[str] = []
|
|
419
|
+
if normalized == "interactive" and not sys.stdin.isatty():
|
|
420
|
+
warnings.append(
|
|
421
|
+
"Interactive mode requires a terminal. This run will use "
|
|
422
|
+
"non-interactive mode."
|
|
423
|
+
)
|
|
424
|
+
normalized = "non-interactive"
|
|
425
|
+
return normalized, warnings
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _print_warnings(warnings: list[str]) -> None:
|
|
429
|
+
for warning in warnings:
|
|
430
|
+
console.print(f"[yellow]Warning:[/yellow] {warning}")
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _print_blocked(
|
|
434
|
+
*,
|
|
435
|
+
result: SpecWorkflowResult,
|
|
436
|
+
config: DevFlowConfig,
|
|
437
|
+
) -> None:
|
|
438
|
+
discovery = result.discovery
|
|
439
|
+
console.print(
|
|
440
|
+
"[bold yellow]Discovery is awaiting clarification."
|
|
441
|
+
"[/bold yellow] No specification was generated."
|
|
442
|
+
)
|
|
443
|
+
if result.discovery_path is not None:
|
|
444
|
+
console.print(
|
|
445
|
+
f"[bold]Discovery file:[/bold] "
|
|
446
|
+
f"{result.discovery_path.relative_to(config.root)}",
|
|
447
|
+
soft_wrap=True,
|
|
448
|
+
)
|
|
449
|
+
if discovery is None:
|
|
450
|
+
return
|
|
451
|
+
blockers = discovery.blockers()
|
|
452
|
+
if blockers:
|
|
453
|
+
console.print("\n[bold]Pending decisions[/bold]")
|
|
454
|
+
for blocker in blockers:
|
|
455
|
+
console.print(f" - {blocker}", soft_wrap=True)
|
|
456
|
+
console.print(
|
|
457
|
+
"\nAnswer the pending questions and explicitly accept or reject "
|
|
458
|
+
"each proposed assumption. Edit discovery.yaml, or run an "
|
|
459
|
+
"interactive harness, then resume:"
|
|
460
|
+
)
|
|
461
|
+
console.print(
|
|
462
|
+
f" devflow spec --continue {discovery.spec_id}",
|
|
463
|
+
soft_wrap=True,
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _print_result(
|
|
468
|
+
*,
|
|
469
|
+
result: SpecWorkflowResult,
|
|
470
|
+
config: DevFlowConfig,
|
|
471
|
+
) -> None:
|
|
472
|
+
for warning in result.constitution_warnings:
|
|
473
|
+
console.print(f"[yellow]Warning:[/yellow] {warning}")
|
|
474
|
+
|
|
475
|
+
spec = result.spec
|
|
476
|
+
if spec is None:
|
|
477
|
+
return
|
|
478
|
+
console.print(
|
|
479
|
+
f"[bold green]Specification {spec.spec_id} created.[/bold green]"
|
|
480
|
+
)
|
|
481
|
+
console.print(
|
|
482
|
+
f"[bold]File:[/bold] {spec.path.relative_to(config.root)}",
|
|
483
|
+
soft_wrap=True,
|
|
484
|
+
)
|
|
485
|
+
if result.harness_name is not None and spec.source != "static":
|
|
486
|
+
console.print(
|
|
487
|
+
f"[bold]Generated by:[/bold] {spec.source}"
|
|
488
|
+
)
|
|
489
|
+
else:
|
|
490
|
+
console.print("[bold]Generated by:[/bold] deterministic analysis")
|
|
491
|
+
if result.harness_name is not None:
|
|
492
|
+
console.print(
|
|
493
|
+
"[dim]The harness was available but its output could not be "
|
|
494
|
+
"validated.[/dim]"
|
|
495
|
+
)
|
|
496
|
+
console.print(
|
|
497
|
+
"[bold]Profiles:[/bold] "
|
|
498
|
+
+ (", ".join(config.profiles) or "base only")
|
|
499
|
+
)
|
|
500
|
+
console.print(
|
|
501
|
+
"[bold]Constitution:[/bold] "
|
|
502
|
+
+ ", ".join(result.constitution_names)
|
|
503
|
+
)
|
|
504
|
+
console.print(
|
|
505
|
+
f"[bold]Open questions:[/bold] {len(spec.open_questions)}"
|
|
506
|
+
)
|
|
507
|
+
if result.discovery is not None:
|
|
508
|
+
console.print(
|
|
509
|
+
f"[bold]Discovery:[/bold] {result.discovery.status}"
|
|
510
|
+
)
|
|
511
|
+
console.print(
|
|
512
|
+
"\nNext: review the specification, resolve any remaining "
|
|
513
|
+
"non-blocking questions, and approve it before implementation."
|
|
514
|
+
)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Implementation of the devflow status command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from devflow.core.artifacts.errors import ArtifactError
|
|
11
|
+
from devflow.core.config import (
|
|
12
|
+
ConfigError,
|
|
13
|
+
ConfigManager,
|
|
14
|
+
DevFlowConfig,
|
|
15
|
+
)
|
|
16
|
+
from devflow.core.discovery import DiscoveryError, DiscoveryManager
|
|
17
|
+
from devflow.core.project import ProjectDetector
|
|
18
|
+
from devflow.core.state import (
|
|
19
|
+
ChangeLocation,
|
|
20
|
+
StateError,
|
|
21
|
+
WorkflowLocator,
|
|
22
|
+
WorkflowStateManager,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
console = Console()
|
|
26
|
+
error_console = Console(stderr=True)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def status(
|
|
30
|
+
spec_id: str = typer.Argument(
|
|
31
|
+
...,
|
|
32
|
+
help="Change id (001) or change directory (001-add-health).",
|
|
33
|
+
),
|
|
34
|
+
path: Path = typer.Argument(
|
|
35
|
+
Path("."),
|
|
36
|
+
help="Project root or a path inside it.",
|
|
37
|
+
),
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Show workflow state, artifact digests and approvals."""
|
|
40
|
+
try:
|
|
41
|
+
_status(spec_id, path)
|
|
42
|
+
except (
|
|
43
|
+
ConfigError,
|
|
44
|
+
StateError,
|
|
45
|
+
ArtifactError,
|
|
46
|
+
FileNotFoundError,
|
|
47
|
+
NotADirectoryError,
|
|
48
|
+
OSError,
|
|
49
|
+
) as error:
|
|
50
|
+
error_console.print(f"[red]Error:[/red] {error}")
|
|
51
|
+
raise typer.Exit(code=1) from error
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _status(spec_id: str, path: Path) -> None:
|
|
55
|
+
root = ProjectDetector().find_root(path)
|
|
56
|
+
config = ConfigManager(root).load()
|
|
57
|
+
manager = WorkflowStateManager(
|
|
58
|
+
config.specifications_path,
|
|
59
|
+
project_root=config.root,
|
|
60
|
+
)
|
|
61
|
+
location = WorkflowLocator(config.specifications_path).find(spec_id)
|
|
62
|
+
current = manager.status(location)
|
|
63
|
+
state = current.state
|
|
64
|
+
|
|
65
|
+
console.print(f"[bold]Change:[/bold] {location.change}")
|
|
66
|
+
console.print(f"[bold]Spec id:[/bold] {location.spec_id}")
|
|
67
|
+
console.print(f"[bold]Status:[/bold] {state.status}")
|
|
68
|
+
console.print(f"[bold]Updated:[/bold] {state.updated_at or 'unknown'}")
|
|
69
|
+
if current.last_event is not None:
|
|
70
|
+
console.print(
|
|
71
|
+
f"[bold]Last event:[/bold] #{current.last_event.seq} "
|
|
72
|
+
f"{current.last_event.type}"
|
|
73
|
+
)
|
|
74
|
+
else:
|
|
75
|
+
console.print("[bold]Last event:[/bold] none")
|
|
76
|
+
|
|
77
|
+
if state.artifacts:
|
|
78
|
+
console.print("\n[bold]Artifacts[/bold]")
|
|
79
|
+
for kind, record in sorted(state.artifacts.items()):
|
|
80
|
+
console.print(
|
|
81
|
+
f" {kind}: {record.status} · {record.digest} · "
|
|
82
|
+
f"{record.path}",
|
|
83
|
+
soft_wrap=True,
|
|
84
|
+
)
|
|
85
|
+
else:
|
|
86
|
+
console.print("\n[yellow]No artifacts are registered.[/yellow]")
|
|
87
|
+
|
|
88
|
+
_print_discovery(config=config, location=location)
|
|
89
|
+
|
|
90
|
+
if state.approvals:
|
|
91
|
+
console.print("\n[bold]Approvals[/bold]")
|
|
92
|
+
for stage, approval in sorted(state.approvals.items()):
|
|
93
|
+
bound = ", ".join(
|
|
94
|
+
f"{kind}={digest}"
|
|
95
|
+
for kind, digest in sorted(approval.artifacts.items())
|
|
96
|
+
)
|
|
97
|
+
console.print(
|
|
98
|
+
f" {stage}: {approval.by} at {approval.at} · {bound}",
|
|
99
|
+
soft_wrap=True,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if current.warnings:
|
|
103
|
+
console.print("\n[bold]Warnings[/bold]")
|
|
104
|
+
for warning in current.warnings:
|
|
105
|
+
console.print(f"[yellow]- {warning}[/yellow]")
|
|
106
|
+
if current.orphans:
|
|
107
|
+
console.print(
|
|
108
|
+
"\n[dim]Orphan artifacts were written without a workflow event. "
|
|
109
|
+
"They are not approved and do not advance the change.[/dim]"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _print_discovery(
|
|
114
|
+
*,
|
|
115
|
+
config: DevFlowConfig,
|
|
116
|
+
location: ChangeLocation,
|
|
117
|
+
) -> None:
|
|
118
|
+
manager = DiscoveryManager(config.root)
|
|
119
|
+
if not manager.exists(location.directory):
|
|
120
|
+
return
|
|
121
|
+
console.print("\n[bold]Discovery[/bold]")
|
|
122
|
+
try:
|
|
123
|
+
discovery = manager.load(location.directory, required=True)
|
|
124
|
+
except DiscoveryError as error:
|
|
125
|
+
console.print(f"[yellow]- invalid discovery: {error}[/yellow]")
|
|
126
|
+
return
|
|
127
|
+
if discovery is None:
|
|
128
|
+
return
|
|
129
|
+
console.print(
|
|
130
|
+
f" status: {discovery.status} · "
|
|
131
|
+
f"{len(discovery.pending_blocking)} blocking · "
|
|
132
|
+
f"{len(discovery.pending_assumptions)} assumptions"
|
|
133
|
+
)
|
|
134
|
+
for question in discovery.questions:
|
|
135
|
+
marker = "blocking" if question.blocking else "non-blocking"
|
|
136
|
+
answer = (
|
|
137
|
+
f" -> {question.answer}"
|
|
138
|
+
if question.answer
|
|
139
|
+
else " (pending)"
|
|
140
|
+
)
|
|
141
|
+
console.print(
|
|
142
|
+
f" {question.id} [{question.category}, {marker}] "
|
|
143
|
+
f"{question.status}{answer}",
|
|
144
|
+
soft_wrap=True,
|
|
145
|
+
)
|
|
146
|
+
for assumption in discovery.assumptions:
|
|
147
|
+
state = (
|
|
148
|
+
f"accepted by {assumption.accepted_by}"
|
|
149
|
+
if assumption.is_accepted
|
|
150
|
+
else "pending acceptance"
|
|
151
|
+
)
|
|
152
|
+
console.print(
|
|
153
|
+
f" {assumption.id} [{assumption.source}] {state}: "
|
|
154
|
+
f"{assumption.value}",
|
|
155
|
+
soft_wrap=True,
|
|
156
|
+
)
|
devflow/core/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Core services for DevFlow."""
|
|
2
|
+
|
|
3
|
+
from devflow.core.config import ConfigManager
|
|
4
|
+
from devflow.core.constitution import ConstitutionLoader
|
|
5
|
+
from devflow.core.profile_manager import ProfileManager
|
|
6
|
+
from devflow.core.project import ProjectDetector
|
|
7
|
+
from devflow.core.specification import SpecificationManager
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"ConfigManager",
|
|
11
|
+
"ConstitutionLoader",
|
|
12
|
+
"ProfileManager",
|
|
13
|
+
"ProjectDetector",
|
|
14
|
+
"SpecificationManager",
|
|
15
|
+
]
|