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,130 @@
|
|
|
1
|
+
"""Atomic artifact storage with normalized SHA-256 digests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from devflow.core.artifacts.errors import (
|
|
11
|
+
ArtifactError,
|
|
12
|
+
ArtifactNotFoundError,
|
|
13
|
+
ArtifactOutsideProjectError,
|
|
14
|
+
ArtifactWriteError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ArtifactStore:
|
|
19
|
+
"""Read, write and verify project artifacts.
|
|
20
|
+
|
|
21
|
+
Text is normalized before it is written and before its digest is
|
|
22
|
+
computed. The digest therefore identifies the stored artifact, not a
|
|
23
|
+
temporary prompt or a raw agent response.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, root: Path) -> None:
|
|
27
|
+
self.root = root.resolve()
|
|
28
|
+
|
|
29
|
+
def normalize(self, content: str) -> str:
|
|
30
|
+
"""Return the canonical stored form of a text artifact."""
|
|
31
|
+
text = content.lstrip("\ufeff")
|
|
32
|
+
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
33
|
+
return text.rstrip() + "\n"
|
|
34
|
+
|
|
35
|
+
def digest_text(self, content: str) -> str:
|
|
36
|
+
"""Return the SHA-256 digest of normalized text."""
|
|
37
|
+
normalized = self.normalize(content)
|
|
38
|
+
digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
|
39
|
+
return f"sha256:{digest}"
|
|
40
|
+
|
|
41
|
+
def digest_file(self, path: Path) -> str:
|
|
42
|
+
"""Return the digest of a stored artifact."""
|
|
43
|
+
return self.digest_text(self.read(path))
|
|
44
|
+
|
|
45
|
+
def read(self, path: Path) -> str:
|
|
46
|
+
"""Read an artifact as normalized text."""
|
|
47
|
+
target = self._inside_project(path)
|
|
48
|
+
if not target.is_file():
|
|
49
|
+
raise ArtifactNotFoundError(
|
|
50
|
+
f"Artifact does not exist: {self.display_path(target)}"
|
|
51
|
+
)
|
|
52
|
+
try:
|
|
53
|
+
content = target.read_text(encoding="utf-8")
|
|
54
|
+
except UnicodeDecodeError as error:
|
|
55
|
+
msg = f"Artifact is not valid UTF-8: {self.display_path(target)}"
|
|
56
|
+
raise ArtifactError(msg) from error
|
|
57
|
+
except OSError as error:
|
|
58
|
+
msg = f"Could not read artifact: {self.display_path(target)}"
|
|
59
|
+
raise ArtifactError(msg) from error
|
|
60
|
+
return self.normalize(content)
|
|
61
|
+
|
|
62
|
+
def write(
|
|
63
|
+
self,
|
|
64
|
+
path: Path,
|
|
65
|
+
content: str,
|
|
66
|
+
*,
|
|
67
|
+
archive: bool = False,
|
|
68
|
+
) -> str:
|
|
69
|
+
"""Write normalized text atomically and return its digest."""
|
|
70
|
+
target = self._inside_project(path)
|
|
71
|
+
normalized = self.normalize(content)
|
|
72
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
if archive and target.is_file():
|
|
74
|
+
self.archive(target)
|
|
75
|
+
|
|
76
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
77
|
+
prefix=f".{target.name}-",
|
|
78
|
+
suffix=".tmp",
|
|
79
|
+
dir=target.parent,
|
|
80
|
+
)
|
|
81
|
+
temporary = Path(temporary_name)
|
|
82
|
+
try:
|
|
83
|
+
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle:
|
|
84
|
+
handle.write(normalized)
|
|
85
|
+
handle.flush()
|
|
86
|
+
os.fsync(handle.fileno())
|
|
87
|
+
temporary.replace(target)
|
|
88
|
+
except OSError as error:
|
|
89
|
+
msg = f"Could not write artifact: {self.display_path(target)}"
|
|
90
|
+
raise ArtifactWriteError(msg) from error
|
|
91
|
+
finally:
|
|
92
|
+
temporary.unlink(missing_ok=True)
|
|
93
|
+
return self.digest_text(normalized)
|
|
94
|
+
|
|
95
|
+
def verify(self, path: Path, expected_digest: str) -> bool:
|
|
96
|
+
"""Return whether the stored artifact still matches a digest."""
|
|
97
|
+
try:
|
|
98
|
+
return self.digest_file(path) == expected_digest
|
|
99
|
+
except ArtifactError:
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
def archive(self, path: Path) -> Path:
|
|
103
|
+
"""Copy the current artifact into its history directory."""
|
|
104
|
+
target = self._inside_project(path)
|
|
105
|
+
content = self.read(target)
|
|
106
|
+
digest = self.digest_text(content)
|
|
107
|
+
short = digest.removeprefix("sha256:")[:12]
|
|
108
|
+
history = target.parent / "history"
|
|
109
|
+
history.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
archived = history / f"{short}-{target.name}"
|
|
111
|
+
if not archived.is_file():
|
|
112
|
+
self.write(archived, content)
|
|
113
|
+
return archived
|
|
114
|
+
|
|
115
|
+
def display_path(self, path: Path) -> str:
|
|
116
|
+
"""Return a project-relative path for messages."""
|
|
117
|
+
target = path.resolve()
|
|
118
|
+
try:
|
|
119
|
+
return target.relative_to(self.root).as_posix()
|
|
120
|
+
except ValueError:
|
|
121
|
+
return target.as_posix()
|
|
122
|
+
|
|
123
|
+
def _inside_project(self, path: Path) -> Path:
|
|
124
|
+
target = path.resolve()
|
|
125
|
+
try:
|
|
126
|
+
target.relative_to(self.root)
|
|
127
|
+
except ValueError as error:
|
|
128
|
+
msg = f"Artifact path escapes the project root: {target}"
|
|
129
|
+
raise ArtifactOutsideProjectError(msg) from error
|
|
130
|
+
return target
|
devflow/core/clock.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Small runtime contracts used by state and artifact components."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Clock(Protocol):
|
|
11
|
+
"""Provides the current time."""
|
|
12
|
+
|
|
13
|
+
def now(self) -> datetime:
|
|
14
|
+
"""Return the current UTC datetime."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SystemClock:
|
|
18
|
+
"""Clock backed by the system clock."""
|
|
19
|
+
|
|
20
|
+
def now(self) -> datetime:
|
|
21
|
+
return datetime.now(timezone.utc)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class FixedClock:
|
|
25
|
+
"""Deterministic clock for tests."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, value: datetime) -> None:
|
|
28
|
+
self.value = value
|
|
29
|
+
|
|
30
|
+
def now(self) -> datetime:
|
|
31
|
+
return self.value
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class IdGenerator(Protocol):
|
|
35
|
+
"""Generates stable identifiers for events and runs."""
|
|
36
|
+
|
|
37
|
+
def new_id(self, prefix: str) -> str:
|
|
38
|
+
"""Return a new identifier with the given prefix."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class UuidIdGenerator:
|
|
42
|
+
"""Identifier generator backed by UUID4."""
|
|
43
|
+
|
|
44
|
+
def new_id(self, prefix: str) -> str:
|
|
45
|
+
return f"{prefix}-{uuid.uuid4().hex[:12]}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SequentialIdGenerator:
|
|
49
|
+
"""Deterministic identifier generator for tests."""
|
|
50
|
+
|
|
51
|
+
def __init__(self) -> None:
|
|
52
|
+
self._counters: dict[str, int] = {}
|
|
53
|
+
|
|
54
|
+
def new_id(self, prefix: str) -> str:
|
|
55
|
+
current = self._counters.get(prefix, 0) + 1
|
|
56
|
+
self._counters[prefix] = current
|
|
57
|
+
return f"{prefix}-{current:03d}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def format_timestamp(value: datetime) -> str:
|
|
61
|
+
"""Format a datetime as a stable UTC ISO-8601 timestamp."""
|
|
62
|
+
normalized = value
|
|
63
|
+
if normalized.tzinfo is None:
|
|
64
|
+
normalized = normalized.replace(tzinfo=timezone.utc)
|
|
65
|
+
else:
|
|
66
|
+
normalized = normalized.astimezone(timezone.utc)
|
|
67
|
+
return normalized.isoformat(timespec="seconds").replace("+00:00", "Z")
|