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
|
@@ -0,0 +1,960 @@
|
|
|
1
|
+
"""Workflow state, approvals and append-only event projection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from devflow.core.artifacts.errors import ArtifactError
|
|
13
|
+
from devflow.core.artifacts.models import (
|
|
14
|
+
ARTIFACT_SOURCES,
|
|
15
|
+
ArtifactRecord,
|
|
16
|
+
ArtifactStatus,
|
|
17
|
+
)
|
|
18
|
+
from devflow.core.artifacts.store import ArtifactStore
|
|
19
|
+
from devflow.core.architecture import ARCHITECTURE_RELATIVE_PATH
|
|
20
|
+
from devflow.core.clock import (
|
|
21
|
+
Clock,
|
|
22
|
+
IdGenerator,
|
|
23
|
+
SystemClock,
|
|
24
|
+
UuidIdGenerator,
|
|
25
|
+
format_timestamp,
|
|
26
|
+
)
|
|
27
|
+
from devflow.core.discovery import (
|
|
28
|
+
DISCOVERY_FILENAME,
|
|
29
|
+
DiscoveryIncompleteError,
|
|
30
|
+
DiscoveryManager,
|
|
31
|
+
)
|
|
32
|
+
from devflow.core.state.errors import (
|
|
33
|
+
ArtifactNotValidatedError,
|
|
34
|
+
ChangeNotFoundError,
|
|
35
|
+
InvalidStateError,
|
|
36
|
+
StaleArtifactError,
|
|
37
|
+
StateError,
|
|
38
|
+
UnsupportedStageError,
|
|
39
|
+
)
|
|
40
|
+
from devflow.core.state.events import (
|
|
41
|
+
WORKFLOW_INITIALIZED,
|
|
42
|
+
WORKFLOW_TRANSITION,
|
|
43
|
+
WorkflowEvent,
|
|
44
|
+
append_event,
|
|
45
|
+
apply_event,
|
|
46
|
+
read_events,
|
|
47
|
+
replay_events,
|
|
48
|
+
)
|
|
49
|
+
from devflow.core.state.lock import ChangeLock
|
|
50
|
+
from devflow.core.state.locator import (
|
|
51
|
+
ChangeLocation,
|
|
52
|
+
WorkflowLocator,
|
|
53
|
+
)
|
|
54
|
+
from devflow.core.state.models import (
|
|
55
|
+
STAGE_ARTIFACTS,
|
|
56
|
+
STAGE_TARGETS,
|
|
57
|
+
WORKFLOW_STATUSES,
|
|
58
|
+
ApprovalRecord,
|
|
59
|
+
WorkflowState,
|
|
60
|
+
WorkflowStatus,
|
|
61
|
+
)
|
|
62
|
+
from devflow.core.state.transitions import (
|
|
63
|
+
artifact_blockers,
|
|
64
|
+
ensure_transition,
|
|
65
|
+
required_artifacts,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
_PROJECTION_HEADER = (
|
|
69
|
+
"# DevFlow workflow state.\n"
|
|
70
|
+
"# This file is a projection of events.jsonl and may be rebuilt.\n"
|
|
71
|
+
)
|
|
72
|
+
_ORPHAN_CANDIDATES = (
|
|
73
|
+
"discovery.yaml",
|
|
74
|
+
"spec.md",
|
|
75
|
+
"plan.md",
|
|
76
|
+
"tasks.yaml",
|
|
77
|
+
"review.md",
|
|
78
|
+
)
|
|
79
|
+
_TIMESTAMP_FIELDS = frozenset(
|
|
80
|
+
{
|
|
81
|
+
"created_at",
|
|
82
|
+
"updated_at",
|
|
83
|
+
"validated_at",
|
|
84
|
+
"approved_at",
|
|
85
|
+
"stale_at",
|
|
86
|
+
"at",
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _canonical_state(state: WorkflowState) -> Any:
|
|
92
|
+
"""Return a representation-independent form of persisted state."""
|
|
93
|
+
return _canonical_value(state.to_dict())
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _canonical_value(value: Any, *, timestamp: bool = False) -> Any:
|
|
97
|
+
if isinstance(value, dict):
|
|
98
|
+
return {
|
|
99
|
+
str(key): _canonical_value(
|
|
100
|
+
value[key],
|
|
101
|
+
timestamp=str(key) in _TIMESTAMP_FIELDS,
|
|
102
|
+
)
|
|
103
|
+
for key in sorted(value, key=lambda item: str(item))
|
|
104
|
+
}
|
|
105
|
+
if isinstance(value, (list, tuple)):
|
|
106
|
+
return [_canonical_value(item) for item in value]
|
|
107
|
+
if timestamp and isinstance(value, str):
|
|
108
|
+
return _canonical_timestamp(value)
|
|
109
|
+
return value
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _canonical_timestamp(value: str) -> str:
|
|
113
|
+
if not value:
|
|
114
|
+
return value
|
|
115
|
+
try:
|
|
116
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
117
|
+
except ValueError:
|
|
118
|
+
return value
|
|
119
|
+
if parsed.tzinfo is None:
|
|
120
|
+
parsed = parsed.replace(tzinfo=timezone.utc)
|
|
121
|
+
else:
|
|
122
|
+
parsed = parsed.astimezone(timezone.utc)
|
|
123
|
+
return parsed.isoformat(timespec="microseconds").replace("+00:00", "Z")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass(frozen=True)
|
|
127
|
+
class ChangeStatus:
|
|
128
|
+
"""Current state and diagnostics for one change."""
|
|
129
|
+
|
|
130
|
+
location: ChangeLocation
|
|
131
|
+
state: WorkflowState
|
|
132
|
+
warnings: tuple[str, ...]
|
|
133
|
+
orphans: tuple[str, ...]
|
|
134
|
+
last_event: WorkflowEvent | None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True)
|
|
138
|
+
class ApprovalResult:
|
|
139
|
+
"""Outcome of an approval operation."""
|
|
140
|
+
|
|
141
|
+
location: ChangeLocation
|
|
142
|
+
state: WorkflowState
|
|
143
|
+
stage: str
|
|
144
|
+
approval: ApprovalRecord
|
|
145
|
+
changed: bool
|
|
146
|
+
warnings: tuple[str, ...] = ()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class WorkflowStateManager:
|
|
150
|
+
"""Own workflow state, artifact registration and human approvals."""
|
|
151
|
+
|
|
152
|
+
def __init__(
|
|
153
|
+
self,
|
|
154
|
+
specs_path: Path,
|
|
155
|
+
*,
|
|
156
|
+
project_root: Path | None = None,
|
|
157
|
+
clock: Clock | None = None,
|
|
158
|
+
ids: IdGenerator | None = None,
|
|
159
|
+
) -> None:
|
|
160
|
+
self.specs_path = specs_path.resolve()
|
|
161
|
+
self.root = (
|
|
162
|
+
project_root.resolve()
|
|
163
|
+
if project_root is not None
|
|
164
|
+
else self.specs_path.parent.parent
|
|
165
|
+
)
|
|
166
|
+
self.store = ArtifactStore(self.root)
|
|
167
|
+
self.locator = WorkflowLocator(self.specs_path)
|
|
168
|
+
self.clock = clock or SystemClock()
|
|
169
|
+
self.ids = ids or UuidIdGenerator()
|
|
170
|
+
|
|
171
|
+
def initialize_change(
|
|
172
|
+
self,
|
|
173
|
+
directory: Path,
|
|
174
|
+
*,
|
|
175
|
+
spec_id: str | None = None,
|
|
176
|
+
change: str | None = None,
|
|
177
|
+
) -> WorkflowState:
|
|
178
|
+
"""Create workflow metadata for a change if it does not exist."""
|
|
179
|
+
location = self.locator.from_directory(directory)
|
|
180
|
+
with self._lock(location):
|
|
181
|
+
if location.workflow_path.is_file() or location.events_path.is_file():
|
|
182
|
+
return self._load_state(location, persist=True)
|
|
183
|
+
return self._initialize_locked(
|
|
184
|
+
location,
|
|
185
|
+
spec_id=spec_id,
|
|
186
|
+
change=change,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
def _initialize_locked(
|
|
190
|
+
self,
|
|
191
|
+
location: ChangeLocation,
|
|
192
|
+
*,
|
|
193
|
+
spec_id: str | None = None,
|
|
194
|
+
change: str | None = None,
|
|
195
|
+
) -> WorkflowState:
|
|
196
|
+
location.directory.mkdir(parents=True, exist_ok=True)
|
|
197
|
+
location.runs_path.mkdir(parents=True, exist_ok=True)
|
|
198
|
+
state = WorkflowState(
|
|
199
|
+
spec_id=spec_id or location.spec_id,
|
|
200
|
+
change=change or location.change,
|
|
201
|
+
)
|
|
202
|
+
return self._append_and_save(
|
|
203
|
+
location,
|
|
204
|
+
state,
|
|
205
|
+
WORKFLOW_INITIALIZED,
|
|
206
|
+
{
|
|
207
|
+
"version": 1,
|
|
208
|
+
"spec_id": state.spec_id,
|
|
209
|
+
"change": state.change,
|
|
210
|
+
"status": WorkflowStatus.DRAFT.value,
|
|
211
|
+
"created_at": format_timestamp(self.clock.now()),
|
|
212
|
+
},
|
|
213
|
+
actor="devflow",
|
|
214
|
+
run_id=None,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
def record_artifact(
|
|
218
|
+
self,
|
|
219
|
+
location: ChangeLocation,
|
|
220
|
+
*,
|
|
221
|
+
stage: str,
|
|
222
|
+
path: Path,
|
|
223
|
+
validated: bool,
|
|
224
|
+
source: str,
|
|
225
|
+
kind: str | None = None,
|
|
226
|
+
metadata: dict[str, Any] | None = None,
|
|
227
|
+
actor: str = "devflow",
|
|
228
|
+
run_id: str | None = None,
|
|
229
|
+
) -> WorkflowState:
|
|
230
|
+
"""Register a written artifact and optionally mark it validated."""
|
|
231
|
+
if stage not in STAGE_ARTIFACTS:
|
|
232
|
+
msg = (
|
|
233
|
+
f"Unknown workflow stage: {stage}. "
|
|
234
|
+
f"Supported values: {', '.join(STAGE_ARTIFACTS)}."
|
|
235
|
+
)
|
|
236
|
+
raise UnsupportedStageError(msg)
|
|
237
|
+
if source not in ARTIFACT_SOURCES:
|
|
238
|
+
source = "system"
|
|
239
|
+
artifact_kind = kind or stage
|
|
240
|
+
merged_metadata = {"stage": stage, **(metadata or {})}
|
|
241
|
+
|
|
242
|
+
with self._lock(location):
|
|
243
|
+
if (
|
|
244
|
+
not location.workflow_path.is_file()
|
|
245
|
+
and not location.events_path.is_file()
|
|
246
|
+
):
|
|
247
|
+
self._initialize_locked(location)
|
|
248
|
+
state = self._load_state(location, persist=True)
|
|
249
|
+
relative = self._relative_artifact_path(path)
|
|
250
|
+
digest = self.store.digest_file(path)
|
|
251
|
+
existing = state.artifacts.get(artifact_kind)
|
|
252
|
+
|
|
253
|
+
if (
|
|
254
|
+
existing is not None
|
|
255
|
+
and existing.digest == digest
|
|
256
|
+
and all(
|
|
257
|
+
existing.metadata.get(key) == value
|
|
258
|
+
for key, value in merged_metadata.items()
|
|
259
|
+
)
|
|
260
|
+
and (
|
|
261
|
+
existing.status == ArtifactStatus.APPROVED.value
|
|
262
|
+
or (validated and existing.status == ArtifactStatus.VALIDATED.value)
|
|
263
|
+
)
|
|
264
|
+
):
|
|
265
|
+
return state
|
|
266
|
+
|
|
267
|
+
if (
|
|
268
|
+
existing is not None
|
|
269
|
+
and existing.status == ArtifactStatus.APPROVED.value
|
|
270
|
+
and existing.digest != digest
|
|
271
|
+
):
|
|
272
|
+
state = self._mark_stale(
|
|
273
|
+
location,
|
|
274
|
+
state,
|
|
275
|
+
stage=stage,
|
|
276
|
+
kind=artifact_kind,
|
|
277
|
+
observed_digest=digest,
|
|
278
|
+
actor=actor,
|
|
279
|
+
run_id=run_id,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
state = self._append_and_save(
|
|
283
|
+
location,
|
|
284
|
+
state,
|
|
285
|
+
f"{stage}_generated",
|
|
286
|
+
{
|
|
287
|
+
"stage": stage,
|
|
288
|
+
"kind": artifact_kind,
|
|
289
|
+
"path": relative,
|
|
290
|
+
"digest": digest,
|
|
291
|
+
"source": source,
|
|
292
|
+
"metadata": merged_metadata,
|
|
293
|
+
},
|
|
294
|
+
actor=actor,
|
|
295
|
+
run_id=run_id,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
if validated:
|
|
299
|
+
state = self._append_and_save(
|
|
300
|
+
location,
|
|
301
|
+
state,
|
|
302
|
+
f"{stage}_validated",
|
|
303
|
+
{
|
|
304
|
+
"stage": stage,
|
|
305
|
+
"kind": artifact_kind,
|
|
306
|
+
"path": relative,
|
|
307
|
+
"digest": digest,
|
|
308
|
+
"source": source,
|
|
309
|
+
"metadata": merged_metadata,
|
|
310
|
+
},
|
|
311
|
+
actor=actor,
|
|
312
|
+
run_id=run_id,
|
|
313
|
+
)
|
|
314
|
+
return state
|
|
315
|
+
|
|
316
|
+
def load(self, location: ChangeLocation) -> WorkflowState:
|
|
317
|
+
"""Load, replay and reconcile the current state."""
|
|
318
|
+
return self.reconcile(location)
|
|
319
|
+
|
|
320
|
+
def reconcile(self, location: ChangeLocation) -> WorkflowState:
|
|
321
|
+
"""Refresh artifact digests and mark changed artifacts as stale."""
|
|
322
|
+
with self._lock(location):
|
|
323
|
+
state = self._load_state(location, persist=True)
|
|
324
|
+
return self._reconcile_locked(location, state)
|
|
325
|
+
|
|
326
|
+
def status(self, location: ChangeLocation) -> ChangeStatus:
|
|
327
|
+
"""Return the reconciled state and its diagnostics."""
|
|
328
|
+
state = self.reconcile(location)
|
|
329
|
+
events = read_events(location.events_path)
|
|
330
|
+
warnings, orphans = self._diagnostics(location, state)
|
|
331
|
+
return ChangeStatus(
|
|
332
|
+
location=location,
|
|
333
|
+
state=state,
|
|
334
|
+
warnings=warnings,
|
|
335
|
+
orphans=orphans,
|
|
336
|
+
last_event=events[-1] if events else None,
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
def transition(
|
|
340
|
+
self,
|
|
341
|
+
location: ChangeLocation,
|
|
342
|
+
target: str,
|
|
343
|
+
*,
|
|
344
|
+
actor: str = "devflow",
|
|
345
|
+
run_id: str | None = None,
|
|
346
|
+
) -> WorkflowState:
|
|
347
|
+
"""Move a workflow to a new state when the transition is allowed."""
|
|
348
|
+
with self._lock(location):
|
|
349
|
+
state = self._load_state(location, persist=True)
|
|
350
|
+
ensure_transition(state.status, target)
|
|
351
|
+
if state.status == target:
|
|
352
|
+
return state
|
|
353
|
+
return self._append_and_save(
|
|
354
|
+
location,
|
|
355
|
+
state,
|
|
356
|
+
WORKFLOW_TRANSITION,
|
|
357
|
+
{"to": target},
|
|
358
|
+
actor=actor,
|
|
359
|
+
run_id=run_id,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
def invalidate_artifact(
|
|
363
|
+
self,
|
|
364
|
+
location: ChangeLocation,
|
|
365
|
+
*,
|
|
366
|
+
stage: str,
|
|
367
|
+
kind: str,
|
|
368
|
+
actor: str = "devflow",
|
|
369
|
+
run_id: str | None = None,
|
|
370
|
+
) -> WorkflowState:
|
|
371
|
+
"""Mark a registered artifact stale without changing its content."""
|
|
372
|
+
if stage not in STAGE_ARTIFACTS:
|
|
373
|
+
msg = (
|
|
374
|
+
f"Unknown workflow stage: {stage}. "
|
|
375
|
+
f"Supported values: {', '.join(STAGE_ARTIFACTS)}."
|
|
376
|
+
)
|
|
377
|
+
raise UnsupportedStageError(msg)
|
|
378
|
+
with self._lock(location):
|
|
379
|
+
state = self._load_state(location, persist=True)
|
|
380
|
+
state = self._reconcile_locked(location, state)
|
|
381
|
+
record = state.artifacts.get(kind)
|
|
382
|
+
if record is None or record.status == ArtifactStatus.STALE.value:
|
|
383
|
+
return state
|
|
384
|
+
try:
|
|
385
|
+
observed = self.store.digest_file(
|
|
386
|
+
self._artifact_path(record.path)
|
|
387
|
+
)
|
|
388
|
+
except ArtifactError:
|
|
389
|
+
observed = ""
|
|
390
|
+
return self._mark_stale(
|
|
391
|
+
location,
|
|
392
|
+
state,
|
|
393
|
+
stage=stage,
|
|
394
|
+
kind=kind,
|
|
395
|
+
observed_digest=observed,
|
|
396
|
+
actor=actor,
|
|
397
|
+
run_id=run_id,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
def approve(
|
|
401
|
+
self,
|
|
402
|
+
location: ChangeLocation,
|
|
403
|
+
stage: str,
|
|
404
|
+
*,
|
|
405
|
+
by: str = "user",
|
|
406
|
+
actor: str | None = None,
|
|
407
|
+
run_id: str | None = None,
|
|
408
|
+
) -> ApprovalResult:
|
|
409
|
+
"""Approve the artifacts of a stage, bound to their digests."""
|
|
410
|
+
normalized = stage.strip().lower()
|
|
411
|
+
if normalized not in STAGE_ARTIFACTS:
|
|
412
|
+
msg = (
|
|
413
|
+
f"Unknown workflow stage: {stage}. "
|
|
414
|
+
f"Supported values: {', '.join(STAGE_ARTIFACTS)}."
|
|
415
|
+
)
|
|
416
|
+
raise UnsupportedStageError(msg)
|
|
417
|
+
if normalized not in STAGE_TARGETS:
|
|
418
|
+
msg = f"Stage '{stage}' cannot be approved."
|
|
419
|
+
raise UnsupportedStageError(msg)
|
|
420
|
+
|
|
421
|
+
warnings: list[str] = []
|
|
422
|
+
|
|
423
|
+
with self._lock(location):
|
|
424
|
+
state = self._load_state(location, persist=True)
|
|
425
|
+
state = self._reconcile_locked(location, state)
|
|
426
|
+
kinds = STAGE_ARTIFACTS[normalized]
|
|
427
|
+
digests: dict[str, str] = {}
|
|
428
|
+
discovery_digest = ""
|
|
429
|
+
|
|
430
|
+
if normalized == "spec":
|
|
431
|
+
discovery_path = location.directory / DISCOVERY_FILENAME
|
|
432
|
+
if discovery_path.exists() or discovery_path.is_symlink():
|
|
433
|
+
discovery = DiscoveryManager(self.root).load(
|
|
434
|
+
location.directory,
|
|
435
|
+
required=True,
|
|
436
|
+
)
|
|
437
|
+
if discovery is None or not discovery.is_complete:
|
|
438
|
+
blockers = (
|
|
439
|
+
discovery.blockers()
|
|
440
|
+
if discovery is not None
|
|
441
|
+
else ("Discovery is missing.",)
|
|
442
|
+
)
|
|
443
|
+
msg = (
|
|
444
|
+
"Cannot approve stage 'spec': discovery is not complete. "
|
|
445
|
+
"Resolve: "
|
|
446
|
+
+ "; ".join(blockers).rstrip(".")
|
|
447
|
+
+ ". Then run 'devflow spec --continue "
|
|
448
|
+
+ location.spec_id
|
|
449
|
+
+ "'."
|
|
450
|
+
)
|
|
451
|
+
raise DiscoveryIncompleteError(msg)
|
|
452
|
+
try:
|
|
453
|
+
discovery_digest = self.store.digest_file(discovery_path)
|
|
454
|
+
except ArtifactError as error:
|
|
455
|
+
msg = (
|
|
456
|
+
"Cannot approve stage 'spec': discovery.yaml cannot be "
|
|
457
|
+
"read. Regenerate discovery first."
|
|
458
|
+
)
|
|
459
|
+
raise StaleArtifactError(msg) from error
|
|
460
|
+
|
|
461
|
+
discovery_record = state.artifacts.get("discovery")
|
|
462
|
+
if discovery_record is None:
|
|
463
|
+
msg = (
|
|
464
|
+
"Cannot approve stage 'spec': discovery is not registered "
|
|
465
|
+
"in the workflow. Run 'devflow spec --continue "
|
|
466
|
+
f"{location.spec_id}' to register it."
|
|
467
|
+
)
|
|
468
|
+
raise ArtifactNotValidatedError(msg)
|
|
469
|
+
self._ensure_artifact_current(
|
|
470
|
+
location,
|
|
471
|
+
state,
|
|
472
|
+
normalized,
|
|
473
|
+
"discovery",
|
|
474
|
+
discovery_record,
|
|
475
|
+
actor or by,
|
|
476
|
+
run_id,
|
|
477
|
+
)
|
|
478
|
+
if discovery_record.status not in {
|
|
479
|
+
ArtifactStatus.VALIDATED.value,
|
|
480
|
+
ArtifactStatus.APPROVED.value,
|
|
481
|
+
}:
|
|
482
|
+
msg = (
|
|
483
|
+
"Cannot approve stage 'spec': discovery is "
|
|
484
|
+
f"{discovery_record.status}, not validated."
|
|
485
|
+
)
|
|
486
|
+
raise ArtifactNotValidatedError(msg)
|
|
487
|
+
digests["discovery"] = discovery_digest
|
|
488
|
+
else:
|
|
489
|
+
warnings.append(
|
|
490
|
+
"This change has no discovery.yaml. It is approved as a "
|
|
491
|
+
"legacy 0.2.0 change."
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
for kind in kinds:
|
|
495
|
+
record = state.artifacts.get(kind)
|
|
496
|
+
if record is None:
|
|
497
|
+
msg = (
|
|
498
|
+
f"Cannot approve stage '{normalized}': artifact "
|
|
499
|
+
f"'{kind}' is not registered for {location.change}."
|
|
500
|
+
)
|
|
501
|
+
raise ArtifactNotValidatedError(msg)
|
|
502
|
+
self._ensure_artifact_current(
|
|
503
|
+
location,
|
|
504
|
+
state,
|
|
505
|
+
normalized,
|
|
506
|
+
kind,
|
|
507
|
+
record,
|
|
508
|
+
actor or by,
|
|
509
|
+
run_id,
|
|
510
|
+
)
|
|
511
|
+
if record.status not in {
|
|
512
|
+
ArtifactStatus.VALIDATED.value,
|
|
513
|
+
ArtifactStatus.APPROVED.value,
|
|
514
|
+
}:
|
|
515
|
+
msg = (
|
|
516
|
+
f"Cannot approve stage '{normalized}': artifact "
|
|
517
|
+
f"'{kind}' is {record.status}, not validated."
|
|
518
|
+
)
|
|
519
|
+
raise ArtifactNotValidatedError(msg)
|
|
520
|
+
digests[kind] = record.digest
|
|
521
|
+
|
|
522
|
+
spec_record = state.artifacts.get("spec")
|
|
523
|
+
architecture_digest = ""
|
|
524
|
+
if spec_record is not None:
|
|
525
|
+
architecture_digest = str(
|
|
526
|
+
spec_record.metadata.get("architecture_digest", "")
|
|
527
|
+
)
|
|
528
|
+
if architecture_digest:
|
|
529
|
+
manifest_path = self.root / ARCHITECTURE_RELATIVE_PATH
|
|
530
|
+
try:
|
|
531
|
+
current_manifest = self.store.digest_file(manifest_path)
|
|
532
|
+
except ArtifactError as error:
|
|
533
|
+
msg = (
|
|
534
|
+
"Cannot approve stage 'spec': the declared architecture "
|
|
535
|
+
"manifest is missing. Restore it or regenerate the "
|
|
536
|
+
"specification."
|
|
537
|
+
)
|
|
538
|
+
raise StaleArtifactError(msg) from error
|
|
539
|
+
if current_manifest != architecture_digest:
|
|
540
|
+
msg = (
|
|
541
|
+
"Cannot approve stage 'spec': the declared architecture "
|
|
542
|
+
"manifest changed after the specification was generated. "
|
|
543
|
+
f"Run 'devflow spec --continue {location.spec_id}' to "
|
|
544
|
+
"regenerate it."
|
|
545
|
+
)
|
|
546
|
+
raise StaleArtifactError(msg)
|
|
547
|
+
if discovery_digest:
|
|
548
|
+
recorded_discovery = str(
|
|
549
|
+
spec_record.metadata.get("discovery_digest", "")
|
|
550
|
+
)
|
|
551
|
+
if recorded_discovery != discovery_digest:
|
|
552
|
+
msg = (
|
|
553
|
+
"Cannot approve stage 'spec': discovery changed after the "
|
|
554
|
+
"specification was generated. Run 'devflow spec --continue "
|
|
555
|
+
f"{location.spec_id}' to regenerate it."
|
|
556
|
+
)
|
|
557
|
+
raise ArtifactNotValidatedError(msg)
|
|
558
|
+
|
|
559
|
+
existing = state.approvals.get(normalized)
|
|
560
|
+
if existing is not None:
|
|
561
|
+
if (
|
|
562
|
+
existing.artifacts == digests
|
|
563
|
+
and existing.architecture_digest == architecture_digest
|
|
564
|
+
and all(
|
|
565
|
+
state.artifacts[kind].status
|
|
566
|
+
== ArtifactStatus.APPROVED.value
|
|
567
|
+
for kind in digests
|
|
568
|
+
)
|
|
569
|
+
):
|
|
570
|
+
return ApprovalResult(
|
|
571
|
+
location=location,
|
|
572
|
+
state=state,
|
|
573
|
+
stage=normalized,
|
|
574
|
+
approval=existing,
|
|
575
|
+
changed=False,
|
|
576
|
+
warnings=tuple(warnings),
|
|
577
|
+
)
|
|
578
|
+
msg = (
|
|
579
|
+
f"Cannot approve stage '{normalized}': it is already approved "
|
|
580
|
+
"with different artifacts. Start a new change or request an "
|
|
581
|
+
"explicit revision."
|
|
582
|
+
)
|
|
583
|
+
raise StateError(msg)
|
|
584
|
+
|
|
585
|
+
if state.status == WorkflowStatus.AWAITING_CLARIFICATION.value:
|
|
586
|
+
state = self._append_and_save(
|
|
587
|
+
location,
|
|
588
|
+
state,
|
|
589
|
+
WORKFLOW_TRANSITION,
|
|
590
|
+
{"to": WorkflowStatus.DRAFT.value},
|
|
591
|
+
actor=actor or by,
|
|
592
|
+
run_id=run_id,
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
target = STAGE_TARGETS[normalized]
|
|
596
|
+
if state.status != target:
|
|
597
|
+
ensure_transition(state.status, target)
|
|
598
|
+
blockers = self._approval_blockers(
|
|
599
|
+
state,
|
|
600
|
+
target,
|
|
601
|
+
tuple(digests),
|
|
602
|
+
)
|
|
603
|
+
if blockers:
|
|
604
|
+
msg = (
|
|
605
|
+
f"Cannot approve stage '{normalized}': required artifacts "
|
|
606
|
+
"are not approved: " + ", ".join(blockers) + "."
|
|
607
|
+
)
|
|
608
|
+
raise ArtifactNotValidatedError(msg)
|
|
609
|
+
|
|
610
|
+
payload: dict[str, Any] = {
|
|
611
|
+
"stage": normalized,
|
|
612
|
+
"by": by,
|
|
613
|
+
"artifacts": digests,
|
|
614
|
+
"workflow_status": target,
|
|
615
|
+
}
|
|
616
|
+
if architecture_digest:
|
|
617
|
+
payload["architecture_digest"] = architecture_digest
|
|
618
|
+
state = self._append_and_save(
|
|
619
|
+
location,
|
|
620
|
+
state,
|
|
621
|
+
f"{normalized}_approved",
|
|
622
|
+
payload,
|
|
623
|
+
actor=actor or by,
|
|
624
|
+
run_id=run_id,
|
|
625
|
+
)
|
|
626
|
+
return ApprovalResult(
|
|
627
|
+
location=location,
|
|
628
|
+
state=state,
|
|
629
|
+
stage=normalized,
|
|
630
|
+
approval=state.approvals[normalized],
|
|
631
|
+
changed=True,
|
|
632
|
+
warnings=tuple(warnings),
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
def _ensure_artifact_current(
|
|
636
|
+
self,
|
|
637
|
+
location: ChangeLocation,
|
|
638
|
+
state: WorkflowState,
|
|
639
|
+
stage: str,
|
|
640
|
+
kind: str,
|
|
641
|
+
record: ArtifactRecord,
|
|
642
|
+
actor: str,
|
|
643
|
+
run_id: str | None,
|
|
644
|
+
) -> None:
|
|
645
|
+
"""Raise when a registered artifact no longer matches its digest."""
|
|
646
|
+
if record.status == ArtifactStatus.STALE.value:
|
|
647
|
+
msg = (
|
|
648
|
+
f"Cannot approve stage '{stage}': artifact '{kind}' is stale. "
|
|
649
|
+
"Regenerate and validate it first."
|
|
650
|
+
)
|
|
651
|
+
raise StaleArtifactError(msg)
|
|
652
|
+
try:
|
|
653
|
+
current_digest = self.store.digest_file(
|
|
654
|
+
self._artifact_path(record.path)
|
|
655
|
+
)
|
|
656
|
+
except ArtifactError as error:
|
|
657
|
+
msg = (
|
|
658
|
+
f"Cannot approve stage '{stage}': artifact '{kind}' cannot be "
|
|
659
|
+
"read. Regenerate it first."
|
|
660
|
+
)
|
|
661
|
+
raise StaleArtifactError(msg) from error
|
|
662
|
+
if current_digest == record.digest:
|
|
663
|
+
return
|
|
664
|
+
self._mark_stale(
|
|
665
|
+
location,
|
|
666
|
+
state,
|
|
667
|
+
stage=stage,
|
|
668
|
+
kind=kind,
|
|
669
|
+
observed_digest=current_digest,
|
|
670
|
+
actor=actor,
|
|
671
|
+
run_id=run_id,
|
|
672
|
+
)
|
|
673
|
+
msg = (
|
|
674
|
+
f"Cannot approve stage '{stage}': artifact '{kind}' changed after "
|
|
675
|
+
"validation. Regenerate and validate it before approving."
|
|
676
|
+
)
|
|
677
|
+
raise StaleArtifactError(msg)
|
|
678
|
+
|
|
679
|
+
def _reconcile_locked(
|
|
680
|
+
self,
|
|
681
|
+
location: ChangeLocation,
|
|
682
|
+
state: WorkflowState,
|
|
683
|
+
) -> WorkflowState:
|
|
684
|
+
for kind, record in sorted(state.artifacts.items()):
|
|
685
|
+
if record.status == ArtifactStatus.STALE.value:
|
|
686
|
+
continue
|
|
687
|
+
try:
|
|
688
|
+
current_digest = self.store.digest_file(
|
|
689
|
+
self._artifact_path(record.path)
|
|
690
|
+
)
|
|
691
|
+
except ArtifactError:
|
|
692
|
+
current_digest = ""
|
|
693
|
+
if current_digest == record.digest:
|
|
694
|
+
continue
|
|
695
|
+
state = self._mark_stale(
|
|
696
|
+
location,
|
|
697
|
+
state,
|
|
698
|
+
stage=record.metadata.get("stage", kind),
|
|
699
|
+
kind=kind,
|
|
700
|
+
observed_digest=current_digest,
|
|
701
|
+
actor="devflow",
|
|
702
|
+
run_id=None,
|
|
703
|
+
)
|
|
704
|
+
return state
|
|
705
|
+
|
|
706
|
+
def _mark_stale(
|
|
707
|
+
self,
|
|
708
|
+
location: ChangeLocation,
|
|
709
|
+
state: WorkflowState,
|
|
710
|
+
*,
|
|
711
|
+
stage: str,
|
|
712
|
+
kind: str,
|
|
713
|
+
observed_digest: str,
|
|
714
|
+
actor: str,
|
|
715
|
+
run_id: str | None,
|
|
716
|
+
) -> WorkflowState:
|
|
717
|
+
record = state.artifacts.get(kind)
|
|
718
|
+
if record is None or record.status == ArtifactStatus.STALE.value:
|
|
719
|
+
return state
|
|
720
|
+
return self._append_and_save(
|
|
721
|
+
location,
|
|
722
|
+
state,
|
|
723
|
+
f"{stage}_stale",
|
|
724
|
+
{
|
|
725
|
+
"stage": stage,
|
|
726
|
+
"kind": kind,
|
|
727
|
+
"path": record.path,
|
|
728
|
+
"digest": observed_digest,
|
|
729
|
+
"previous_digest": record.digest,
|
|
730
|
+
},
|
|
731
|
+
actor=actor,
|
|
732
|
+
run_id=run_id,
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
def _approval_blockers(
|
|
736
|
+
self,
|
|
737
|
+
state: WorkflowState,
|
|
738
|
+
target: str,
|
|
739
|
+
approving: tuple[str, ...],
|
|
740
|
+
) -> tuple[str, ...]:
|
|
741
|
+
blockers: list[str] = []
|
|
742
|
+
for kind in required_artifacts(target):
|
|
743
|
+
record = state.artifacts.get(kind)
|
|
744
|
+
if record is None:
|
|
745
|
+
blockers.append(f"{kind} (missing)")
|
|
746
|
+
elif kind in approving:
|
|
747
|
+
if record.status not in {
|
|
748
|
+
ArtifactStatus.VALIDATED.value,
|
|
749
|
+
ArtifactStatus.APPROVED.value,
|
|
750
|
+
}:
|
|
751
|
+
blockers.append(f"{kind} ({record.status})")
|
|
752
|
+
elif record.status != ArtifactStatus.APPROVED.value:
|
|
753
|
+
blockers.append(f"{kind} ({record.status})")
|
|
754
|
+
return tuple(blockers)
|
|
755
|
+
|
|
756
|
+
def _diagnostics(
|
|
757
|
+
self,
|
|
758
|
+
location: ChangeLocation,
|
|
759
|
+
state: WorkflowState,
|
|
760
|
+
) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
|
761
|
+
warnings: list[str] = []
|
|
762
|
+
for kind, record in sorted(state.artifacts.items()):
|
|
763
|
+
try:
|
|
764
|
+
current_digest = self.store.digest_file(
|
|
765
|
+
self._artifact_path(record.path)
|
|
766
|
+
)
|
|
767
|
+
except ArtifactError:
|
|
768
|
+
warnings.append(
|
|
769
|
+
f"Artifact '{kind}' is registered but missing from disk."
|
|
770
|
+
)
|
|
771
|
+
continue
|
|
772
|
+
if record.status == ArtifactStatus.STALE.value:
|
|
773
|
+
warnings.append(
|
|
774
|
+
f"Artifact '{kind}' changed after validation or approval."
|
|
775
|
+
)
|
|
776
|
+
elif current_digest != record.digest:
|
|
777
|
+
warnings.append(
|
|
778
|
+
f"Artifact '{kind}' no longer matches its recorded digest."
|
|
779
|
+
)
|
|
780
|
+
|
|
781
|
+
orphans: list[str] = []
|
|
782
|
+
for name in _ORPHAN_CANDIDATES:
|
|
783
|
+
candidate = location.directory / name
|
|
784
|
+
key = _KIND_BY_FILENAME.get(name, name)
|
|
785
|
+
if candidate.is_file() and key not in state.artifacts:
|
|
786
|
+
orphans.append(name)
|
|
787
|
+
warnings.append(
|
|
788
|
+
f"Artifact '{name}' exists but is not registered in "
|
|
789
|
+
"events.jsonl."
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
discovery_record = state.artifacts.get("discovery")
|
|
793
|
+
if state.status == WorkflowStatus.AWAITING_CLARIFICATION.value:
|
|
794
|
+
if discovery_record is None:
|
|
795
|
+
warnings.append(
|
|
796
|
+
"Workflow is awaiting clarification but discovery is not "
|
|
797
|
+
"registered."
|
|
798
|
+
)
|
|
799
|
+
elif discovery_record.status != ArtifactStatus.VALIDATED.value:
|
|
800
|
+
warnings.append(
|
|
801
|
+
"Workflow is awaiting clarification until discovery is "
|
|
802
|
+
"complete."
|
|
803
|
+
)
|
|
804
|
+
elif (
|
|
805
|
+
discovery_record is not None
|
|
806
|
+
and discovery_record.status == ArtifactStatus.GENERATED.value
|
|
807
|
+
):
|
|
808
|
+
warnings.append(
|
|
809
|
+
"Discovery is registered but not complete; the specification "
|
|
810
|
+
"must not be approved."
|
|
811
|
+
)
|
|
812
|
+
|
|
813
|
+
if state.status != WorkflowStatus.DRAFT.value:
|
|
814
|
+
blockers = artifact_blockers(state, state.status)
|
|
815
|
+
if blockers:
|
|
816
|
+
warnings.append(
|
|
817
|
+
f"Workflow is blocked in '{state.status}': "
|
|
818
|
+
+ ", ".join(blockers)
|
|
819
|
+
+ "."
|
|
820
|
+
)
|
|
821
|
+
return tuple(warnings), tuple(orphans)
|
|
822
|
+
|
|
823
|
+
def _load_state(
|
|
824
|
+
self,
|
|
825
|
+
location: ChangeLocation,
|
|
826
|
+
*,
|
|
827
|
+
persist: bool,
|
|
828
|
+
) -> WorkflowState:
|
|
829
|
+
if not location.directory.is_dir():
|
|
830
|
+
msg = f"Change directory does not exist: {location.directory}"
|
|
831
|
+
raise ChangeNotFoundError(msg)
|
|
832
|
+
|
|
833
|
+
events = read_events(location.events_path)
|
|
834
|
+
state = replay_events(events)
|
|
835
|
+
|
|
836
|
+
if not events:
|
|
837
|
+
if location.workflow_path.is_file():
|
|
838
|
+
msg = (
|
|
839
|
+
"State projection exists without an event log: "
|
|
840
|
+
f"{location.workflow_path}"
|
|
841
|
+
)
|
|
842
|
+
raise InvalidStateError(msg)
|
|
843
|
+
msg = (
|
|
844
|
+
f"No workflow state found for {location.change}. "
|
|
845
|
+
"Run 'devflow spec' first."
|
|
846
|
+
)
|
|
847
|
+
raise ChangeNotFoundError(msg)
|
|
848
|
+
|
|
849
|
+
projection = (
|
|
850
|
+
self._read_projection(location)
|
|
851
|
+
if location.workflow_path.is_file()
|
|
852
|
+
else None
|
|
853
|
+
)
|
|
854
|
+
if (
|
|
855
|
+
projection is not None
|
|
856
|
+
and projection.last_event_seq > state.last_event_seq
|
|
857
|
+
):
|
|
858
|
+
msg = (
|
|
859
|
+
"State projection references events that are missing from "
|
|
860
|
+
f"the event log: {location.workflow_path}"
|
|
861
|
+
)
|
|
862
|
+
raise InvalidStateError(msg)
|
|
863
|
+
|
|
864
|
+
if persist and (
|
|
865
|
+
projection is None
|
|
866
|
+
or _canonical_state(projection) != _canonical_state(state)
|
|
867
|
+
):
|
|
868
|
+
self._write_projection(location, state)
|
|
869
|
+
return state
|
|
870
|
+
|
|
871
|
+
def _append_and_save(
|
|
872
|
+
self,
|
|
873
|
+
location: ChangeLocation,
|
|
874
|
+
state: WorkflowState,
|
|
875
|
+
event_type: str,
|
|
876
|
+
payload: dict[str, Any],
|
|
877
|
+
*,
|
|
878
|
+
actor: str,
|
|
879
|
+
run_id: str | None,
|
|
880
|
+
) -> WorkflowState:
|
|
881
|
+
event = WorkflowEvent(
|
|
882
|
+
seq=state.last_event_seq + 1,
|
|
883
|
+
event_id=self.ids.new_id("evt"),
|
|
884
|
+
type=event_type,
|
|
885
|
+
timestamp=format_timestamp(self.clock.now()),
|
|
886
|
+
actor=actor,
|
|
887
|
+
run_id=run_id,
|
|
888
|
+
payload=payload,
|
|
889
|
+
)
|
|
890
|
+
append_event(location.events_path, event)
|
|
891
|
+
apply_event(state, event)
|
|
892
|
+
self._write_projection(location, state)
|
|
893
|
+
return state
|
|
894
|
+
|
|
895
|
+
def _read_projection(self, location: ChangeLocation) -> WorkflowState:
|
|
896
|
+
try:
|
|
897
|
+
with location.workflow_path.open(encoding="utf-8") as handle:
|
|
898
|
+
data = yaml.safe_load(handle)
|
|
899
|
+
except OSError as error:
|
|
900
|
+
msg = f"Could not read workflow state: {location.workflow_path}"
|
|
901
|
+
raise InvalidStateError(msg) from error
|
|
902
|
+
except yaml.YAMLError as error:
|
|
903
|
+
msg = f"Invalid YAML in workflow state: {location.workflow_path}"
|
|
904
|
+
raise InvalidStateError(msg) from error
|
|
905
|
+
if not isinstance(data, dict):
|
|
906
|
+
msg = "workflow.yaml must contain a mapping."
|
|
907
|
+
raise InvalidStateError(msg)
|
|
908
|
+
try:
|
|
909
|
+
state = WorkflowState.from_dict(data)
|
|
910
|
+
except (KeyError, TypeError, ValueError) as error:
|
|
911
|
+
msg = f"Invalid workflow state: {location.workflow_path}"
|
|
912
|
+
raise InvalidStateError(msg) from error
|
|
913
|
+
if state.status not in WORKFLOW_STATUSES:
|
|
914
|
+
msg = (
|
|
915
|
+
f"Unknown workflow status '{state.status}' in "
|
|
916
|
+
f"{location.workflow_path}"
|
|
917
|
+
)
|
|
918
|
+
raise InvalidStateError(msg)
|
|
919
|
+
return state
|
|
920
|
+
|
|
921
|
+
def _write_projection(
|
|
922
|
+
self,
|
|
923
|
+
location: ChangeLocation,
|
|
924
|
+
state: WorkflowState,
|
|
925
|
+
) -> None:
|
|
926
|
+
content = _PROJECTION_HEADER + yaml.safe_dump(
|
|
927
|
+
state.to_dict(),
|
|
928
|
+
sort_keys=False,
|
|
929
|
+
allow_unicode=True,
|
|
930
|
+
)
|
|
931
|
+
self.store.write(location.workflow_path, content)
|
|
932
|
+
|
|
933
|
+
def _lock(self, location: ChangeLocation) -> ChangeLock:
|
|
934
|
+
return ChangeLock(location.lock_path, change=location.change)
|
|
935
|
+
|
|
936
|
+
def _artifact_path(self, relative: str) -> Path:
|
|
937
|
+
path = (self.root / relative).resolve()
|
|
938
|
+
try:
|
|
939
|
+
path.relative_to(self.root)
|
|
940
|
+
except ValueError as error:
|
|
941
|
+
msg = f"Artifact path escapes the project root: {relative}"
|
|
942
|
+
raise StateError(msg) from error
|
|
943
|
+
return path
|
|
944
|
+
|
|
945
|
+
def _relative_artifact_path(self, path: Path) -> str:
|
|
946
|
+
resolved = path.resolve()
|
|
947
|
+
try:
|
|
948
|
+
return resolved.relative_to(self.root).as_posix()
|
|
949
|
+
except ValueError as error:
|
|
950
|
+
msg = f"Artifact path escapes the project root: {path}"
|
|
951
|
+
raise StateError(msg) from error
|
|
952
|
+
|
|
953
|
+
|
|
954
|
+
_KIND_BY_FILENAME = {
|
|
955
|
+
"discovery.yaml": "discovery",
|
|
956
|
+
"spec.md": "spec",
|
|
957
|
+
"plan.md": "plan",
|
|
958
|
+
"tasks.yaml": "tasks",
|
|
959
|
+
"review.md": "review",
|
|
960
|
+
}
|