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
devflow/core/ports.py ADDED
@@ -0,0 +1,159 @@
1
+ """Ports and data contracts for harness-independent workflows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Protocol
9
+
10
+ from devflow.core.config import AgentPermissions, DevFlowConfig
11
+ from devflow.core.constitution import Constitution
12
+ from devflow.models.profile import Profile
13
+ from devflow.models.project import Project
14
+
15
+
16
+ class AdapterError(Exception):
17
+ """Raised when a harness adapter cannot prepare or run an agent."""
18
+
19
+
20
+ class HarnessExecutionError(AdapterError):
21
+ """Raised when the harness executable cannot be started."""
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class SetupContext:
26
+ """Project information needed to generate harness integration files."""
27
+
28
+ root: Path
29
+ project: Project
30
+ config: DevFlowConfig
31
+ profiles: tuple[Profile, ...]
32
+ constitution: Constitution
33
+ permissions: AgentPermissions
34
+
35
+ @property
36
+ def constitution_files(self) -> tuple[str, ...]:
37
+ """Return the constitution filenames in profile order."""
38
+ return tuple(
39
+ document.name for document in self.constitution.documents
40
+ )
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class RunContext:
45
+ """Information needed to invoke a harness for one specification."""
46
+
47
+ root: Path
48
+ prompt: str
49
+ spec_directory: Path
50
+ spec_path: Path
51
+ mode: str
52
+ permissions: AgentPermissions
53
+ output_path: Path | None = None
54
+
55
+
56
+ @dataclass(frozen=True)
57
+ class FileChange:
58
+ """A file created, updated or skipped during harness setup."""
59
+
60
+ path: Path
61
+ status: str
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class SetupResult:
66
+ """Outcome of generating the integration files for one harness."""
67
+
68
+ changes: tuple[FileChange, ...] = field(default_factory=tuple)
69
+ warnings: tuple[str, ...] = field(default_factory=tuple)
70
+
71
+ def add(self, change: FileChange) -> SetupResult:
72
+ """Return a new result with an additional file change."""
73
+ return SetupResult(
74
+ changes=(*self.changes, change),
75
+ warnings=self.warnings,
76
+ )
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class Invocation:
81
+ """A fully prepared harness process invocation."""
82
+
83
+ argv: tuple[str, ...]
84
+ cwd: Path
85
+ interactive: bool
86
+ stdin_text: str | None = None
87
+ output_path: Path | None = None
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class InvocationResult:
92
+ """Captured result of a harness invocation."""
93
+
94
+ returncode: int
95
+ stdout: str
96
+ stderr: str
97
+ output_path: Path | None = None
98
+
99
+ @property
100
+ def file_output(self) -> str:
101
+ """Return the adapter-provided output file contents, if any."""
102
+ if self.output_path is None or not self.output_path.is_file():
103
+ return ""
104
+ try:
105
+ return self.output_path.read_text(encoding="utf-8")
106
+ except OSError:
107
+ return ""
108
+
109
+
110
+ class HarnessPort(ABC):
111
+ """Provider-independent interface implemented by each harness."""
112
+
113
+ name: str
114
+ display_name: str
115
+
116
+ @abstractmethod
117
+ def is_available(self) -> bool:
118
+ """Return whether the harness executable is available."""
119
+
120
+ @abstractmethod
121
+ def setup(self, context: SetupContext) -> SetupResult:
122
+ """Generate the integration files for this harness."""
123
+
124
+ @abstractmethod
125
+ def build_run(self, context: RunContext) -> Invocation:
126
+ """Build the process invocation for a specification request."""
127
+
128
+ def permission_warnings(
129
+ self,
130
+ context: RunContext,
131
+ ) -> tuple[str, ...]:
132
+ """Return limitations of the technical permission enforcement."""
133
+ return ()
134
+
135
+
136
+ class InvocationExecutor(Protocol):
137
+ """Executes a prepared harness invocation."""
138
+
139
+ def __call__(self, invocation: Invocation) -> InvocationResult:
140
+ """Run the invocation and return its captured result."""
141
+
142
+
143
+ def build_setup_context(
144
+ *,
145
+ config: DevFlowConfig,
146
+ project: Project,
147
+ profiles: list[Profile] | tuple[Profile, ...],
148
+ constitution: Constitution,
149
+ permissions: AgentPermissions | None = None,
150
+ ) -> SetupContext:
151
+ """Build the shared setup context for a harness adapter."""
152
+ return SetupContext(
153
+ root=config.root,
154
+ project=project,
155
+ config=config,
156
+ profiles=tuple(profiles),
157
+ constitution=constitution,
158
+ permissions=permissions or config.permissions,
159
+ )
@@ -0,0 +1,311 @@
1
+ """Discovery, loading and installation of project profiles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import tempfile
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ import yaml
12
+
13
+ from devflow.core.project import canonicalize_dependency
14
+ from devflow.models.profile import Profile
15
+ from devflow.models.project import Project
16
+
17
+ _GENERIC_DETECTION_FILES = {
18
+ "pyproject.toml",
19
+ "package.json",
20
+ "requirements.txt",
21
+ }
22
+
23
+ _BASE_PROFILE = "base"
24
+
25
+
26
+ class ProfileError(Exception):
27
+ """Raised when a profile cannot be loaded or installed."""
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class ProfileMatch:
32
+ """A profile detected from project signals."""
33
+
34
+ profile: Profile
35
+ reasons: tuple[str, ...]
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class ConstitutionInstall:
40
+ """Result of installing constitution files into a project."""
41
+
42
+ copied: tuple[str, ...]
43
+ skipped: tuple[str, ...]
44
+
45
+
46
+ class ProfileManager:
47
+ """Load bundled profiles and detect the ones applicable to a project."""
48
+
49
+ def __init__(self, profiles_root: Path | None = None) -> None:
50
+ self.profiles_root = (
51
+ profiles_root
52
+ or Path(__file__).resolve().parent.parent / "profiles"
53
+ )
54
+
55
+ def available(self) -> dict[str, Profile]:
56
+ """Return all bundled profiles indexed by name."""
57
+ if not self.profiles_root.is_dir():
58
+ msg = f"Profiles directory not found: {self.profiles_root}"
59
+ raise ProfileError(msg)
60
+
61
+ profiles: dict[str, Profile] = {}
62
+ for manifest_path in sorted(
63
+ self.profiles_root.glob("*/profile.yaml")
64
+ ):
65
+ profile = self._load_manifest(manifest_path)
66
+ profiles[profile.name] = profile
67
+
68
+ if _BASE_PROFILE not in profiles:
69
+ msg = f"Base profile not found in {self.profiles_root}"
70
+ raise ProfileError(msg)
71
+ return profiles
72
+
73
+ def names(self) -> list[str]:
74
+ """Return the names of all available profiles."""
75
+ return sorted(self.available())
76
+
77
+ def resolve(self, names: list[str]) -> list[Profile]:
78
+ """Resolve profile names, always including the base profile."""
79
+ available = self.available()
80
+ selected: list[str] = []
81
+ for name in names:
82
+ if name and name != _BASE_PROFILE and name not in selected:
83
+ selected.append(name)
84
+
85
+ unknown = sorted(set(selected) - set(available))
86
+ if unknown:
87
+ known = ", ".join(sorted(available))
88
+ msg = (
89
+ f"Unknown profile(s): {', '.join(unknown)}. "
90
+ f"Available profiles: {known}"
91
+ )
92
+ raise ProfileError(msg)
93
+
94
+ ordered: list[Profile] = [available[_BASE_PROFILE]]
95
+ ordered.extend(available[name] for name in selected)
96
+ return ordered
97
+
98
+ def detect(self, project: Project) -> list[ProfileMatch]:
99
+ """Detect profiles using dependency and file indicators."""
100
+ matches: list[ProfileMatch] = []
101
+ for profile in self.available().values():
102
+ if profile.name == _BASE_PROFILE:
103
+ continue
104
+
105
+ reasons: list[str] = []
106
+ dependencies = [
107
+ dependency
108
+ for dependency in profile.detection_dependencies
109
+ if self._dependency_present(
110
+ project.dependencies,
111
+ dependency,
112
+ )
113
+ ]
114
+ if dependencies:
115
+ reasons.extend(
116
+ f"dependency: {dependency}"
117
+ for dependency in dependencies
118
+ )
119
+
120
+ files = [
121
+ filename
122
+ for filename in profile.detection_files
123
+ if (project.root / filename).exists()
124
+ ]
125
+ strong_files = [
126
+ filename
127
+ for filename in files
128
+ if filename not in _GENERIC_DETECTION_FILES
129
+ ]
130
+
131
+ if not reasons and strong_files:
132
+ reasons.extend(f"file: {filename}" for filename in strong_files)
133
+
134
+ if reasons:
135
+ matches.append(
136
+ ProfileMatch(profile=profile, reasons=tuple(reasons))
137
+ )
138
+
139
+ return matches
140
+
141
+ def install_constitution(
142
+ self,
143
+ root: Path,
144
+ profiles: list[Profile],
145
+ destination: Path | None = None,
146
+ ) -> ConstitutionInstall:
147
+ """Copy profile constitution files without overwriting project files."""
148
+ resolved_root = root.resolve()
149
+ target_directory = (
150
+ destination or resolved_root / ".devflow" / "constitution"
151
+ ).resolve()
152
+ try:
153
+ target_directory.relative_to(resolved_root)
154
+ except ValueError as error:
155
+ msg = (
156
+ "Constitution destination must stay inside the project root: "
157
+ f"{target_directory}"
158
+ )
159
+ raise ProfileError(msg) from error
160
+
161
+ target_directory.mkdir(parents=True, exist_ok=True)
162
+ resolved_target = target_directory.resolve()
163
+ if resolved_target != target_directory:
164
+ msg = (
165
+ "Constitution destination changed while it was being created: "
166
+ f"{target_directory}"
167
+ )
168
+ raise ProfileError(msg)
169
+ target_directory = resolved_target
170
+
171
+ copied: list[str] = []
172
+ skipped: list[str] = []
173
+ seen: set[str] = set()
174
+
175
+ for profile in profiles:
176
+ for raw_filename in profile.constitution:
177
+ filename = self._constitution_name(raw_filename, profile)
178
+ if filename in seen:
179
+ msg = (
180
+ f"Duplicate constitution file '{filename}' in profile "
181
+ f"'{profile.name}'."
182
+ )
183
+ raise ProfileError(msg)
184
+ seen.add(filename)
185
+
186
+ source = (
187
+ profile.path.resolve() / "constitution" / filename
188
+ ).resolve()
189
+ try:
190
+ source.relative_to(profile.path.resolve())
191
+ except ValueError as error:
192
+ msg = (
193
+ f"Constitution file '{filename}' escapes profile "
194
+ f"'{profile.name}'."
195
+ )
196
+ raise ProfileError(msg) from error
197
+ if not source.is_file():
198
+ msg = (
199
+ f"Constitution file '{filename}' is missing from profile "
200
+ f"'{profile.name}'."
201
+ )
202
+ raise ProfileError(msg)
203
+
204
+ target = target_directory / filename
205
+ if target.exists() or target.is_symlink():
206
+ skipped.append(filename)
207
+ continue
208
+
209
+ if not self._copy_exclusive(source, target):
210
+ skipped.append(filename)
211
+ continue
212
+ copied.append(filename)
213
+
214
+ return ConstitutionInstall(
215
+ copied=tuple(copied),
216
+ skipped=tuple(skipped),
217
+ )
218
+
219
+ def constitution_documents(
220
+ self,
221
+ profiles: list[Profile],
222
+ ) -> list[str]:
223
+ """Return constitution filenames in profile order."""
224
+ return [
225
+ self._constitution_name(filename, profile)
226
+ for profile in profiles
227
+ for filename in profile.constitution
228
+ ]
229
+
230
+ def _constitution_name(self, filename: str, profile: Profile) -> str:
231
+ """Validate a profile constitution entry as a plain file name."""
232
+ if not isinstance(filename, str) or not filename.strip():
233
+ msg = (
234
+ f"Profile '{profile.name}' declares an empty constitution "
235
+ "file name."
236
+ )
237
+ raise ProfileError(msg)
238
+
239
+ name = filename.strip()
240
+ if (
241
+ name in {".", ".."}
242
+ or "/" in name
243
+ or "\\" in name
244
+ or "\x00" in name
245
+ ):
246
+ msg = (
247
+ f"Profile '{profile.name}' declares an invalid constitution "
248
+ f"file name: {filename!r}."
249
+ )
250
+ raise ProfileError(msg)
251
+ return name
252
+
253
+ def _copy_exclusive(self, source: Path, target: Path) -> bool:
254
+ """Copy a file atomically without replacing an existing target."""
255
+ descriptor, temporary_name = tempfile.mkstemp(
256
+ prefix=f".{target.name}-",
257
+ suffix=".tmp",
258
+ dir=target.parent,
259
+ )
260
+ temporary = Path(temporary_name)
261
+ try:
262
+ with os.fdopen(descriptor, "wb") as output:
263
+ with source.open("rb") as input_handle:
264
+ shutil.copyfileobj(input_handle, output)
265
+ output.flush()
266
+ os.fsync(output.fileno())
267
+ try:
268
+ mode = source.stat().st_mode & 0o777
269
+ os.chmod(temporary, mode or 0o644)
270
+ except OSError:
271
+ pass
272
+ try:
273
+ os.link(temporary, target)
274
+ except FileExistsError:
275
+ return False
276
+ return True
277
+ finally:
278
+ temporary.unlink(missing_ok=True)
279
+
280
+ def _load_manifest(self, path: Path) -> Profile:
281
+ try:
282
+ with path.open(encoding="utf-8") as handle:
283
+ data = yaml.safe_load(handle)
284
+ except OSError as error:
285
+ msg = f"Could not read profile manifest: {path}"
286
+ raise ProfileError(msg) from error
287
+ except yaml.YAMLError as error:
288
+ msg = f"Invalid YAML in profile manifest: {path}"
289
+ raise ProfileError(msg) from error
290
+
291
+ if not isinstance(data, dict):
292
+ msg = f"Profile manifest must contain a mapping: {path}"
293
+ raise ProfileError(msg)
294
+
295
+ try:
296
+ return Profile.from_manifest(path.parent, data)
297
+ except ValueError as error:
298
+ msg = f"Invalid profile manifest {path}: {error}"
299
+ raise ProfileError(msg) from error
300
+
301
+ def _dependency_present(
302
+ self,
303
+ project_dependencies: set[str],
304
+ profile_dependency: str,
305
+ ) -> bool:
306
+ canonical = canonicalize_dependency(profile_dependency)
307
+ return any(
308
+ dependency == canonical
309
+ or dependency.startswith(f"{canonical}-")
310
+ for dependency in project_dependencies
311
+ )