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.
Files changed (63) hide show
  1. devflow/__init__.py +0 -0
  2. devflow/adapters/__init__.py +47 -0
  3. devflow/adapters/agents.py +49 -0
  4. devflow/adapters/base.py +81 -0
  5. devflow/adapters/claude_code.py +120 -0
  6. devflow/adapters/codex.py +111 -0
  7. devflow/adapters/detector.py +118 -0
  8. devflow/adapters/instructions.py +263 -0
  9. devflow/adapters/kiro.py +211 -0
  10. devflow/adapters/opencode.py +237 -0
  11. devflow/adapters/registry.py +41 -0
  12. devflow/cli.py +39 -0
  13. devflow/commands/__init__.py +6 -0
  14. devflow/commands/approve.py +98 -0
  15. devflow/commands/harness.py +98 -0
  16. devflow/commands/init.py +544 -0
  17. devflow/commands/spec.py +514 -0
  18. devflow/commands/status.py +156 -0
  19. devflow/core/__init__.py +15 -0
  20. devflow/core/architecture.py +469 -0
  21. devflow/core/artifacts/__init__.py +29 -0
  22. devflow/core/artifacts/errors.py +19 -0
  23. devflow/core/artifacts/models.py +82 -0
  24. devflow/core/artifacts/store.py +130 -0
  25. devflow/core/clock.py +67 -0
  26. devflow/core/config.py +706 -0
  27. devflow/core/constitution.py +166 -0
  28. devflow/core/discovery.py +1304 -0
  29. devflow/core/ports.py +159 -0
  30. devflow/core/profile_manager.py +311 -0
  31. devflow/core/project.py +533 -0
  32. devflow/core/spec_workflow.py +932 -0
  33. devflow/core/specification.py +2443 -0
  34. devflow/core/state/__init__.py +75 -0
  35. devflow/core/state/errors.py +39 -0
  36. devflow/core/state/events.py +725 -0
  37. devflow/core/state/locator.py +116 -0
  38. devflow/core/state/lock.py +69 -0
  39. devflow/core/state/manager.py +960 -0
  40. devflow/core/state/models.py +164 -0
  41. devflow/core/state/transitions.py +114 -0
  42. devflow/models/__init__.py +6 -0
  43. devflow/models/profile.py +63 -0
  44. devflow/models/project.py +47 -0
  45. devflow/profiles/ai-rag/constitution/ai-rag-standards.md +49 -0
  46. devflow/profiles/ai-rag/profile.yaml +28 -0
  47. devflow/profiles/base/constitution/architecture.md +58 -0
  48. devflow/profiles/base/constitution/code-standards.md +85 -0
  49. devflow/profiles/base/constitution/constitution.md +85 -0
  50. devflow/profiles/base/profile.yaml +15 -0
  51. devflow/profiles/django-backend/constitution/django-standards.md +64 -0
  52. devflow/profiles/django-backend/profile.yaml +24 -0
  53. devflow/profiles/fastapi-backend/constitution/fastapi-standards.md +59 -0
  54. devflow/profiles/fastapi-backend/profile.yaml +23 -0
  55. devflow/prompts/__init__.py +200 -0
  56. devflow/prompts/discovery.md +119 -0
  57. devflow/prompts/spec.greenfield.md +149 -0
  58. devflow/prompts/spec.md +116 -0
  59. devflow_toolkit-0.3.0.dist-info/METADATA +628 -0
  60. devflow_toolkit-0.3.0.dist-info/RECORD +63 -0
  61. devflow_toolkit-0.3.0.dist-info/WHEEL +4 -0
  62. devflow_toolkit-0.3.0.dist-info/entry_points.txt +2 -0
  63. 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
+ }