devflow-toolkit 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- devflow/__init__.py +0 -0
- devflow/adapters/__init__.py +47 -0
- devflow/adapters/agents.py +49 -0
- devflow/adapters/base.py +81 -0
- devflow/adapters/claude_code.py +120 -0
- devflow/adapters/codex.py +111 -0
- devflow/adapters/detector.py +118 -0
- devflow/adapters/instructions.py +263 -0
- devflow/adapters/kiro.py +211 -0
- devflow/adapters/opencode.py +237 -0
- devflow/adapters/registry.py +41 -0
- devflow/cli.py +39 -0
- devflow/commands/__init__.py +6 -0
- devflow/commands/approve.py +98 -0
- devflow/commands/harness.py +98 -0
- devflow/commands/init.py +544 -0
- devflow/commands/spec.py +514 -0
- devflow/commands/status.py +156 -0
- devflow/core/__init__.py +15 -0
- devflow/core/architecture.py +469 -0
- devflow/core/artifacts/__init__.py +29 -0
- devflow/core/artifacts/errors.py +19 -0
- devflow/core/artifacts/models.py +82 -0
- devflow/core/artifacts/store.py +130 -0
- devflow/core/clock.py +67 -0
- devflow/core/config.py +706 -0
- devflow/core/constitution.py +166 -0
- devflow/core/discovery.py +1304 -0
- devflow/core/ports.py +159 -0
- devflow/core/profile_manager.py +311 -0
- devflow/core/project.py +533 -0
- devflow/core/spec_workflow.py +932 -0
- devflow/core/specification.py +2443 -0
- devflow/core/state/__init__.py +75 -0
- devflow/core/state/errors.py +39 -0
- devflow/core/state/events.py +725 -0
- devflow/core/state/locator.py +116 -0
- devflow/core/state/lock.py +69 -0
- devflow/core/state/manager.py +960 -0
- devflow/core/state/models.py +164 -0
- devflow/core/state/transitions.py +114 -0
- devflow/models/__init__.py +6 -0
- devflow/models/profile.py +63 -0
- devflow/models/project.py +47 -0
- devflow/profiles/ai-rag/constitution/ai-rag-standards.md +49 -0
- devflow/profiles/ai-rag/profile.yaml +28 -0
- devflow/profiles/base/constitution/architecture.md +58 -0
- devflow/profiles/base/constitution/code-standards.md +85 -0
- devflow/profiles/base/constitution/constitution.md +85 -0
- devflow/profiles/base/profile.yaml +15 -0
- devflow/profiles/django-backend/constitution/django-standards.md +64 -0
- devflow/profiles/django-backend/profile.yaml +24 -0
- devflow/profiles/fastapi-backend/constitution/fastapi-standards.md +59 -0
- devflow/profiles/fastapi-backend/profile.yaml +23 -0
- devflow/prompts/__init__.py +200 -0
- devflow/prompts/discovery.md +119 -0
- devflow/prompts/spec.greenfield.md +149 -0
- devflow/prompts/spec.md +116 -0
- devflow_toolkit-0.3.0.dist-info/METADATA +628 -0
- devflow_toolkit-0.3.0.dist-info/RECORD +63 -0
- devflow_toolkit-0.3.0.dist-info/WHEEL +4 -0
- devflow_toolkit-0.3.0.dist-info/entry_points.txt +2 -0
- devflow_toolkit-0.3.0.dist-info/licenses/LICENSE +21 -0
devflow/core/config.py
ADDED
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
"""Reading and writing .devflow/config.yaml."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
import tempfile
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
from devflow.models.project import (
|
|
15
|
+
PROJECT_MODES,
|
|
16
|
+
Project,
|
|
17
|
+
canonical_project_mode,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
CONFIG_RELATIVE_PATH = Path(".devflow") / "config.yaml"
|
|
21
|
+
DEFAULT_CONSTITUTION_PATH = Path(".devflow") / "constitution"
|
|
22
|
+
DEFAULT_SPECIFICATIONS_PATH = Path(".devflow") / "specs"
|
|
23
|
+
DEFAULT_PLANS_PATH = Path(".devflow") / "plans"
|
|
24
|
+
DEFAULT_TEMPLATES_PATH = Path(".devflow") / "templates"
|
|
25
|
+
|
|
26
|
+
SUPPORTED_HARNESSES = (
|
|
27
|
+
"auto",
|
|
28
|
+
"codex",
|
|
29
|
+
"opencode",
|
|
30
|
+
"claude-code",
|
|
31
|
+
"kiro",
|
|
32
|
+
"none",
|
|
33
|
+
)
|
|
34
|
+
EXECUTION_MODES = ("interactive", "non-interactive")
|
|
35
|
+
|
|
36
|
+
_HARNESS_ALIASES = {
|
|
37
|
+
"claude": "claude-code",
|
|
38
|
+
"claude_code": "claude-code",
|
|
39
|
+
"claude-code": "claude-code",
|
|
40
|
+
"codex": "codex",
|
|
41
|
+
"kiro": "kiro",
|
|
42
|
+
"kiro-cli": "kiro",
|
|
43
|
+
"kiro_cli": "kiro",
|
|
44
|
+
"none": "none",
|
|
45
|
+
"off": "none",
|
|
46
|
+
"disabled": "none",
|
|
47
|
+
"auto": "auto",
|
|
48
|
+
"opencode": "opencode",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
_CONFIG_HEADER = (
|
|
52
|
+
"# DevFlow project configuration.\n"
|
|
53
|
+
"# Generated by 'devflow init'. Review it before committing.\n"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ConfigError(Exception):
|
|
58
|
+
"""Raised when DevFlow configuration is missing or invalid."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _preserve_mode(target: Path, temporary: Path) -> None:
|
|
62
|
+
"""Keep an existing file mode, or use a conventional file mode."""
|
|
63
|
+
mode = 0o644
|
|
64
|
+
try:
|
|
65
|
+
mode = stat.S_IMODE(target.stat().st_mode)
|
|
66
|
+
except OSError:
|
|
67
|
+
pass
|
|
68
|
+
os.chmod(temporary, mode)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def canonical_harness_name(value: str) -> str:
|
|
72
|
+
"""Normalize a harness name or one of its accepted aliases."""
|
|
73
|
+
normalized = value.strip().lower()
|
|
74
|
+
return _HARNESS_ALIASES.get(normalized, normalized)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def canonical_execution_mode(value: str) -> str:
|
|
78
|
+
"""Normalize an execution mode name."""
|
|
79
|
+
normalized = value.strip().lower().replace("_", "-")
|
|
80
|
+
if normalized in {"noninteractive", "noninteractive-mode"}:
|
|
81
|
+
return "non-interactive"
|
|
82
|
+
return normalized
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def validate_document_name(value: str, field: str) -> str:
|
|
86
|
+
"""Validate a constitution document name as a plain file name."""
|
|
87
|
+
if not isinstance(value, str) or not value.strip():
|
|
88
|
+
msg = f"{field} entries must be non-empty strings."
|
|
89
|
+
raise ConfigError(msg)
|
|
90
|
+
|
|
91
|
+
name = value.strip()
|
|
92
|
+
if (
|
|
93
|
+
name in {".", ".."}
|
|
94
|
+
or "/" in name
|
|
95
|
+
or "\\" in name
|
|
96
|
+
or "\x00" in name
|
|
97
|
+
):
|
|
98
|
+
msg = (
|
|
99
|
+
f"{field} entries must be plain file names inside the constitution "
|
|
100
|
+
f"directory: {value!r}."
|
|
101
|
+
)
|
|
102
|
+
raise ConfigError(msg)
|
|
103
|
+
return name
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class _ConfigDumper(yaml.SafeDumper):
|
|
107
|
+
"""Preserve indented YAML lists in generated configuration."""
|
|
108
|
+
|
|
109
|
+
def increase_indent(
|
|
110
|
+
self,
|
|
111
|
+
flow: bool = False,
|
|
112
|
+
indentless: bool = False,
|
|
113
|
+
) -> None:
|
|
114
|
+
return super().increase_indent(flow, False)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True)
|
|
118
|
+
class ConstitutionSettings:
|
|
119
|
+
"""Configuration for constitution files."""
|
|
120
|
+
|
|
121
|
+
path: Path
|
|
122
|
+
files: tuple[str, ...]
|
|
123
|
+
base: bool
|
|
124
|
+
profile_specific: bool
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True, init=False)
|
|
128
|
+
class AgentPermissions:
|
|
129
|
+
"""Capabilities granted to the configured agent harness.
|
|
130
|
+
|
|
131
|
+
``allow_workflow_write`` is the canonical permission for DevFlow
|
|
132
|
+
artifacts. ``allow_spec_write`` is a read/write compatibility alias
|
|
133
|
+
for adapters and configuration written before workflow stages existed.
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
allow_project_read: bool = True
|
|
137
|
+
allow_workflow_write: bool = True
|
|
138
|
+
allow_source_code_changes: bool = False
|
|
139
|
+
|
|
140
|
+
def __init__(
|
|
141
|
+
self,
|
|
142
|
+
allow_project_read: bool = True,
|
|
143
|
+
allow_workflow_write: bool = True,
|
|
144
|
+
allow_source_code_changes: bool = False,
|
|
145
|
+
*,
|
|
146
|
+
allow_spec_write: bool | None = None,
|
|
147
|
+
) -> None:
|
|
148
|
+
if allow_spec_write is not None:
|
|
149
|
+
allow_workflow_write = allow_spec_write
|
|
150
|
+
object.__setattr__(
|
|
151
|
+
self,
|
|
152
|
+
"allow_project_read",
|
|
153
|
+
allow_project_read,
|
|
154
|
+
)
|
|
155
|
+
object.__setattr__(
|
|
156
|
+
self,
|
|
157
|
+
"allow_workflow_write",
|
|
158
|
+
allow_workflow_write,
|
|
159
|
+
)
|
|
160
|
+
object.__setattr__(
|
|
161
|
+
self,
|
|
162
|
+
"allow_source_code_changes",
|
|
163
|
+
allow_source_code_changes,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def allow_spec_write(self) -> bool:
|
|
168
|
+
"""Compatibility alias for ``allow_workflow_write``."""
|
|
169
|
+
return self.allow_workflow_write
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass(frozen=True)
|
|
173
|
+
class AgentSettings:
|
|
174
|
+
"""Agent harness and execution preferences."""
|
|
175
|
+
|
|
176
|
+
harness: str = "auto"
|
|
177
|
+
execution_mode: str = "interactive"
|
|
178
|
+
permissions: AgentPermissions = AgentPermissions()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@dataclass(frozen=True)
|
|
182
|
+
class DevFlowConfig:
|
|
183
|
+
"""Validated DevFlow configuration for a project."""
|
|
184
|
+
|
|
185
|
+
root: Path
|
|
186
|
+
path: Path
|
|
187
|
+
project_name: str
|
|
188
|
+
project_type: str
|
|
189
|
+
project_mode: str
|
|
190
|
+
profiles: tuple[str, ...]
|
|
191
|
+
technologies: dict[str, list[str]]
|
|
192
|
+
constitution: ConstitutionSettings
|
|
193
|
+
specifications_path: Path
|
|
194
|
+
plans_path: Path
|
|
195
|
+
templates_path: Path
|
|
196
|
+
agent: AgentSettings
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def agent_harness(self) -> str:
|
|
200
|
+
"""Return the configured harness name."""
|
|
201
|
+
return self.agent.harness
|
|
202
|
+
|
|
203
|
+
@property
|
|
204
|
+
def execution_mode(self) -> str:
|
|
205
|
+
"""Return the configured execution mode."""
|
|
206
|
+
return self.agent.execution_mode
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def permissions(self) -> AgentPermissions:
|
|
210
|
+
"""Return the configured agent permissions."""
|
|
211
|
+
return self.agent.permissions
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
class ConfigManager:
|
|
215
|
+
"""Manage the persistent configuration of a DevFlow project."""
|
|
216
|
+
|
|
217
|
+
def __init__(self, root: Path) -> None:
|
|
218
|
+
self.root = root.resolve()
|
|
219
|
+
self.path = self.root / CONFIG_RELATIVE_PATH
|
|
220
|
+
|
|
221
|
+
def exists(self) -> bool:
|
|
222
|
+
"""Return whether the project has a DevFlow configuration."""
|
|
223
|
+
return self._target_path().is_file()
|
|
224
|
+
|
|
225
|
+
def _target_path(self) -> Path:
|
|
226
|
+
"""Resolve the configuration path without allowing symlink escapes."""
|
|
227
|
+
if self.path.is_symlink():
|
|
228
|
+
msg = (
|
|
229
|
+
"Configuration path must not be a symbolic link: "
|
|
230
|
+
f"{self.path}"
|
|
231
|
+
)
|
|
232
|
+
raise ConfigError(msg)
|
|
233
|
+
|
|
234
|
+
target = self.path.resolve()
|
|
235
|
+
try:
|
|
236
|
+
target.relative_to(self.root)
|
|
237
|
+
except ValueError as error:
|
|
238
|
+
msg = (
|
|
239
|
+
"Configuration path must stay inside the project root: "
|
|
240
|
+
f"{self.path}"
|
|
241
|
+
)
|
|
242
|
+
raise ConfigError(msg) from error
|
|
243
|
+
return target
|
|
244
|
+
|
|
245
|
+
def load(self) -> DevFlowConfig:
|
|
246
|
+
"""Load and validate the project configuration."""
|
|
247
|
+
data = self._read_document()
|
|
248
|
+
|
|
249
|
+
project = self._mapping(data.get("project"), "project")
|
|
250
|
+
constitution = self._mapping(
|
|
251
|
+
data.get("constitution"),
|
|
252
|
+
"constitution",
|
|
253
|
+
)
|
|
254
|
+
specifications = self._mapping(
|
|
255
|
+
data.get("specifications"),
|
|
256
|
+
"specifications",
|
|
257
|
+
)
|
|
258
|
+
plans = self._mapping(data.get("plans"), "plans")
|
|
259
|
+
templates = self._mapping(
|
|
260
|
+
data.get("templates", {}),
|
|
261
|
+
"templates",
|
|
262
|
+
)
|
|
263
|
+
agent = self._mapping(data.get("agent", {}), "agent")
|
|
264
|
+
agent_settings = self._agent_settings(agent)
|
|
265
|
+
|
|
266
|
+
profiles = self._string_list(
|
|
267
|
+
data.get("profiles", []),
|
|
268
|
+
"profiles",
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
constitution_path = self._relative_path(
|
|
272
|
+
constitution.get("path", DEFAULT_CONSTITUTION_PATH.as_posix()),
|
|
273
|
+
"constitution.path",
|
|
274
|
+
)
|
|
275
|
+
specifications_path = self._relative_path(
|
|
276
|
+
specifications.get(
|
|
277
|
+
"path",
|
|
278
|
+
DEFAULT_SPECIFICATIONS_PATH.as_posix(),
|
|
279
|
+
),
|
|
280
|
+
"specifications.path",
|
|
281
|
+
)
|
|
282
|
+
plans_path = self._relative_path(
|
|
283
|
+
plans.get("path", DEFAULT_PLANS_PATH.as_posix()),
|
|
284
|
+
"plans.path",
|
|
285
|
+
)
|
|
286
|
+
templates_path = self._relative_path(
|
|
287
|
+
templates.get("path", DEFAULT_TEMPLATES_PATH.as_posix()),
|
|
288
|
+
"templates.path",
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
technologies = self._technologies(data.get("technologies", {}))
|
|
292
|
+
|
|
293
|
+
name = project.get("name", self.root.name)
|
|
294
|
+
project_type = project.get("type", "unknown")
|
|
295
|
+
project_mode = canonical_project_mode(
|
|
296
|
+
str(project.get("mode", "existing")),
|
|
297
|
+
)
|
|
298
|
+
if not isinstance(name, str) or not name.strip():
|
|
299
|
+
msg = "project.name must be a non-empty string."
|
|
300
|
+
raise ConfigError(msg)
|
|
301
|
+
if not isinstance(project_type, str) or not project_type.strip():
|
|
302
|
+
msg = "project.type must be a non-empty string."
|
|
303
|
+
raise ConfigError(msg)
|
|
304
|
+
if project_mode not in PROJECT_MODES:
|
|
305
|
+
msg = (
|
|
306
|
+
f"project.mode must be one of: {', '.join(PROJECT_MODES)}."
|
|
307
|
+
)
|
|
308
|
+
raise ConfigError(msg)
|
|
309
|
+
|
|
310
|
+
return DevFlowConfig(
|
|
311
|
+
root=self.root,
|
|
312
|
+
path=self.path,
|
|
313
|
+
project_name=name.strip(),
|
|
314
|
+
project_type=project_type.strip(),
|
|
315
|
+
project_mode=project_mode,
|
|
316
|
+
profiles=tuple(profiles),
|
|
317
|
+
technologies=technologies,
|
|
318
|
+
constitution=ConstitutionSettings(
|
|
319
|
+
path=constitution_path,
|
|
320
|
+
files=tuple(
|
|
321
|
+
validate_document_name(item, "constitution.files")
|
|
322
|
+
for item in self._string_list(
|
|
323
|
+
constitution.get("files", []),
|
|
324
|
+
"constitution.files",
|
|
325
|
+
)
|
|
326
|
+
),
|
|
327
|
+
base=self._boolean(
|
|
328
|
+
constitution.get("base", True),
|
|
329
|
+
"constitution.base",
|
|
330
|
+
),
|
|
331
|
+
profile_specific=self._boolean(
|
|
332
|
+
constitution.get("profile_specific", True),
|
|
333
|
+
"constitution.profile_specific",
|
|
334
|
+
),
|
|
335
|
+
),
|
|
336
|
+
specifications_path=specifications_path,
|
|
337
|
+
plans_path=plans_path,
|
|
338
|
+
templates_path=templates_path,
|
|
339
|
+
agent=agent_settings,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
def create(
|
|
343
|
+
self,
|
|
344
|
+
project: Project,
|
|
345
|
+
profiles: list[str],
|
|
346
|
+
constitution_files: list[str],
|
|
347
|
+
project_type: str | None = None,
|
|
348
|
+
project_mode: str = "existing",
|
|
349
|
+
agent_harness: str = "auto",
|
|
350
|
+
execution_mode: str = "interactive",
|
|
351
|
+
permissions: AgentPermissions | None = None,
|
|
352
|
+
) -> DevFlowConfig:
|
|
353
|
+
"""Create and persist a configuration for a project."""
|
|
354
|
+
normalized_project_mode = canonical_project_mode(project_mode)
|
|
355
|
+
if normalized_project_mode not in PROJECT_MODES:
|
|
356
|
+
msg = (
|
|
357
|
+
f"Unknown project mode: {project_mode}. "
|
|
358
|
+
f"Supported values: {', '.join(PROJECT_MODES)}."
|
|
359
|
+
)
|
|
360
|
+
raise ConfigError(msg)
|
|
361
|
+
harness = canonical_harness_name(agent_harness)
|
|
362
|
+
if harness not in SUPPORTED_HARNESSES:
|
|
363
|
+
msg = (
|
|
364
|
+
f"Unknown agent harness: {agent_harness}. "
|
|
365
|
+
f"Supported values: {', '.join(SUPPORTED_HARNESSES)}."
|
|
366
|
+
)
|
|
367
|
+
raise ConfigError(msg)
|
|
368
|
+
mode = canonical_execution_mode(execution_mode)
|
|
369
|
+
if mode not in EXECUTION_MODES:
|
|
370
|
+
msg = (
|
|
371
|
+
f"Unknown execution mode: {execution_mode}. "
|
|
372
|
+
f"Supported values: {', '.join(EXECUTION_MODES)}."
|
|
373
|
+
)
|
|
374
|
+
raise ConfigError(msg)
|
|
375
|
+
agent_permissions = permissions or AgentPermissions()
|
|
376
|
+
validated_files = [
|
|
377
|
+
validate_document_name(filename, "constitution_files")
|
|
378
|
+
for filename in constitution_files
|
|
379
|
+
]
|
|
380
|
+
|
|
381
|
+
data = {
|
|
382
|
+
"project": {
|
|
383
|
+
"name": project.name,
|
|
384
|
+
"type": project_type or project.project_type,
|
|
385
|
+
"mode": normalized_project_mode,
|
|
386
|
+
},
|
|
387
|
+
"profiles": profiles,
|
|
388
|
+
"technologies": {
|
|
389
|
+
"languages": project.languages,
|
|
390
|
+
"frameworks": project.frameworks,
|
|
391
|
+
"databases": project.databases,
|
|
392
|
+
},
|
|
393
|
+
"constitution": {
|
|
394
|
+
"base": True,
|
|
395
|
+
"profile_specific": True,
|
|
396
|
+
"path": DEFAULT_CONSTITUTION_PATH.as_posix(),
|
|
397
|
+
"files": validated_files,
|
|
398
|
+
},
|
|
399
|
+
"specifications": {
|
|
400
|
+
"path": DEFAULT_SPECIFICATIONS_PATH.as_posix(),
|
|
401
|
+
},
|
|
402
|
+
"plans": {
|
|
403
|
+
"path": DEFAULT_PLANS_PATH.as_posix(),
|
|
404
|
+
},
|
|
405
|
+
"templates": {
|
|
406
|
+
"path": DEFAULT_TEMPLATES_PATH.as_posix(),
|
|
407
|
+
},
|
|
408
|
+
"agent": {
|
|
409
|
+
"harness": harness,
|
|
410
|
+
"execution": {
|
|
411
|
+
"mode": mode,
|
|
412
|
+
},
|
|
413
|
+
"permissions": {
|
|
414
|
+
"allow_project_read": (
|
|
415
|
+
agent_permissions.allow_project_read
|
|
416
|
+
),
|
|
417
|
+
"allow_workflow_write": (
|
|
418
|
+
agent_permissions.allow_workflow_write
|
|
419
|
+
),
|
|
420
|
+
"allow_spec_write": (
|
|
421
|
+
agent_permissions.allow_workflow_write
|
|
422
|
+
),
|
|
423
|
+
"allow_source_code_changes": (
|
|
424
|
+
agent_permissions.allow_source_code_changes
|
|
425
|
+
),
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
content = _CONFIG_HEADER + yaml.dump(
|
|
431
|
+
data,
|
|
432
|
+
Dumper=_ConfigDumper,
|
|
433
|
+
sort_keys=False,
|
|
434
|
+
allow_unicode=True,
|
|
435
|
+
)
|
|
436
|
+
self._write_atomic(content)
|
|
437
|
+
return self.load()
|
|
438
|
+
|
|
439
|
+
def set_agent_harness(
|
|
440
|
+
self,
|
|
441
|
+
harness: str,
|
|
442
|
+
execution_mode: str | None = None,
|
|
443
|
+
) -> DevFlowConfig:
|
|
444
|
+
"""Persist the selected harness and optional execution mode."""
|
|
445
|
+
canonical = canonical_harness_name(harness)
|
|
446
|
+
if canonical not in SUPPORTED_HARNESSES:
|
|
447
|
+
msg = (
|
|
448
|
+
f"Unknown agent harness: {harness}. "
|
|
449
|
+
f"Supported values: {', '.join(SUPPORTED_HARNESSES)}."
|
|
450
|
+
)
|
|
451
|
+
raise ConfigError(msg)
|
|
452
|
+
|
|
453
|
+
data = self._read_document()
|
|
454
|
+
agent = data.get("agent")
|
|
455
|
+
if not isinstance(agent, dict):
|
|
456
|
+
agent = {}
|
|
457
|
+
data["agent"] = agent
|
|
458
|
+
agent["harness"] = canonical
|
|
459
|
+
|
|
460
|
+
if execution_mode is not None:
|
|
461
|
+
mode = canonical_execution_mode(execution_mode)
|
|
462
|
+
if mode not in EXECUTION_MODES:
|
|
463
|
+
msg = (
|
|
464
|
+
f"Unknown execution mode: {execution_mode}. "
|
|
465
|
+
f"Supported values: {', '.join(EXECUTION_MODES)}."
|
|
466
|
+
)
|
|
467
|
+
raise ConfigError(msg)
|
|
468
|
+
execution = agent.get("execution")
|
|
469
|
+
if not isinstance(execution, dict):
|
|
470
|
+
execution = {}
|
|
471
|
+
agent["execution"] = execution
|
|
472
|
+
execution["mode"] = mode
|
|
473
|
+
|
|
474
|
+
self._write_document(data)
|
|
475
|
+
return self.load()
|
|
476
|
+
|
|
477
|
+
def set_execution_mode(self, execution_mode: str) -> DevFlowConfig:
|
|
478
|
+
"""Persist the selected execution mode."""
|
|
479
|
+
config = self.load()
|
|
480
|
+
return self.set_agent_harness(
|
|
481
|
+
config.agent_harness,
|
|
482
|
+
execution_mode=execution_mode,
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
def set_project_mode(self, project_mode: str) -> DevFlowConfig:
|
|
486
|
+
"""Persist the default project mode used by the next spec runs."""
|
|
487
|
+
normalized = canonical_project_mode(project_mode)
|
|
488
|
+
if normalized not in PROJECT_MODES:
|
|
489
|
+
msg = (
|
|
490
|
+
f"Unknown project mode: {project_mode}. "
|
|
491
|
+
f"Supported values: {', '.join(PROJECT_MODES)}."
|
|
492
|
+
)
|
|
493
|
+
raise ConfigError(msg)
|
|
494
|
+
|
|
495
|
+
data = self._read_document()
|
|
496
|
+
project = data.get("project")
|
|
497
|
+
if not isinstance(project, dict):
|
|
498
|
+
project = {}
|
|
499
|
+
data["project"] = project
|
|
500
|
+
project["mode"] = normalized
|
|
501
|
+
self._write_document(data)
|
|
502
|
+
return self.load()
|
|
503
|
+
|
|
504
|
+
def ensure_directories(self, config: DevFlowConfig) -> None:
|
|
505
|
+
"""Create the directories referenced by the configuration."""
|
|
506
|
+
for path in (
|
|
507
|
+
config.constitution.path,
|
|
508
|
+
config.specifications_path,
|
|
509
|
+
config.plans_path,
|
|
510
|
+
config.templates_path,
|
|
511
|
+
):
|
|
512
|
+
resolved = path.resolve()
|
|
513
|
+
try:
|
|
514
|
+
resolved.relative_to(self.root)
|
|
515
|
+
except ValueError as error:
|
|
516
|
+
msg = (
|
|
517
|
+
"Configured directory must stay inside the project root: "
|
|
518
|
+
f"{path}"
|
|
519
|
+
)
|
|
520
|
+
raise ConfigError(msg) from error
|
|
521
|
+
resolved.mkdir(parents=True, exist_ok=True)
|
|
522
|
+
|
|
523
|
+
def _read_document(self) -> dict[str, Any]:
|
|
524
|
+
target = self._target_path()
|
|
525
|
+
if not target.is_file():
|
|
526
|
+
msg = (
|
|
527
|
+
"DevFlow is not initialized in this project. "
|
|
528
|
+
"Run 'devflow init' first."
|
|
529
|
+
)
|
|
530
|
+
raise ConfigError(msg)
|
|
531
|
+
|
|
532
|
+
try:
|
|
533
|
+
with target.open(encoding="utf-8") as handle:
|
|
534
|
+
data = yaml.safe_load(handle)
|
|
535
|
+
except OSError as error:
|
|
536
|
+
msg = f"Could not read configuration: {self.path}"
|
|
537
|
+
raise ConfigError(msg) from error
|
|
538
|
+
except yaml.YAMLError as error:
|
|
539
|
+
msg = f"Invalid YAML in configuration: {self.path}"
|
|
540
|
+
raise ConfigError(msg) from error
|
|
541
|
+
|
|
542
|
+
if not isinstance(data, dict):
|
|
543
|
+
msg = "Configuration must contain a mapping."
|
|
544
|
+
raise ConfigError(msg)
|
|
545
|
+
return data
|
|
546
|
+
|
|
547
|
+
def _write_document(self, data: dict[str, Any]) -> None:
|
|
548
|
+
content = _CONFIG_HEADER + yaml.dump(
|
|
549
|
+
data,
|
|
550
|
+
Dumper=_ConfigDumper,
|
|
551
|
+
sort_keys=False,
|
|
552
|
+
allow_unicode=True,
|
|
553
|
+
)
|
|
554
|
+
self._write_atomic(content)
|
|
555
|
+
|
|
556
|
+
def _agent_settings(self, agent: dict[str, Any]) -> AgentSettings:
|
|
557
|
+
harness = canonical_harness_name(
|
|
558
|
+
str(agent.get("harness", "auto")),
|
|
559
|
+
)
|
|
560
|
+
if harness not in SUPPORTED_HARNESSES:
|
|
561
|
+
msg = (
|
|
562
|
+
f"agent.harness must be one of: "
|
|
563
|
+
f"{', '.join(SUPPORTED_HARNESSES)}."
|
|
564
|
+
)
|
|
565
|
+
raise ConfigError(msg)
|
|
566
|
+
|
|
567
|
+
execution = self._mapping(
|
|
568
|
+
agent.get("execution", {}),
|
|
569
|
+
"agent.execution",
|
|
570
|
+
)
|
|
571
|
+
mode = canonical_execution_mode(
|
|
572
|
+
str(execution.get("mode", "interactive")),
|
|
573
|
+
)
|
|
574
|
+
if mode not in EXECUTION_MODES:
|
|
575
|
+
msg = (
|
|
576
|
+
f"agent.execution.mode must be one of: "
|
|
577
|
+
f"{', '.join(EXECUTION_MODES)}."
|
|
578
|
+
)
|
|
579
|
+
raise ConfigError(msg)
|
|
580
|
+
|
|
581
|
+
permissions = self._mapping(
|
|
582
|
+
agent.get("permissions", {}),
|
|
583
|
+
"agent.permissions",
|
|
584
|
+
)
|
|
585
|
+
workflow_write = permissions.get("allow_workflow_write")
|
|
586
|
+
spec_write = permissions.get("allow_spec_write")
|
|
587
|
+
if workflow_write is None:
|
|
588
|
+
allow_workflow_write = (
|
|
589
|
+
True
|
|
590
|
+
if spec_write is None
|
|
591
|
+
else self._boolean(
|
|
592
|
+
spec_write,
|
|
593
|
+
"agent.permissions.allow_spec_write",
|
|
594
|
+
)
|
|
595
|
+
)
|
|
596
|
+
else:
|
|
597
|
+
allow_workflow_write = self._boolean(
|
|
598
|
+
workflow_write,
|
|
599
|
+
"agent.permissions.allow_workflow_write",
|
|
600
|
+
)
|
|
601
|
+
return AgentSettings(
|
|
602
|
+
harness=harness,
|
|
603
|
+
execution_mode=mode,
|
|
604
|
+
permissions=AgentPermissions(
|
|
605
|
+
allow_project_read=self._boolean(
|
|
606
|
+
permissions.get("allow_project_read", True),
|
|
607
|
+
"agent.permissions.allow_project_read",
|
|
608
|
+
),
|
|
609
|
+
allow_workflow_write=allow_workflow_write,
|
|
610
|
+
allow_source_code_changes=self._boolean(
|
|
611
|
+
permissions.get("allow_source_code_changes", False),
|
|
612
|
+
"agent.permissions.allow_source_code_changes",
|
|
613
|
+
),
|
|
614
|
+
),
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
def _write_atomic(self, content: str) -> None:
|
|
618
|
+
target = self._target_path()
|
|
619
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
620
|
+
resolved_parent = target.parent.resolve()
|
|
621
|
+
try:
|
|
622
|
+
resolved_parent.relative_to(self.root)
|
|
623
|
+
except ValueError as error:
|
|
624
|
+
msg = (
|
|
625
|
+
"Configuration directory must stay inside the project root: "
|
|
626
|
+
f"{target.parent}"
|
|
627
|
+
)
|
|
628
|
+
raise ConfigError(msg) from error
|
|
629
|
+
target = resolved_parent / target.name
|
|
630
|
+
|
|
631
|
+
descriptor, temporary_name = tempfile.mkstemp(
|
|
632
|
+
prefix=".config-",
|
|
633
|
+
suffix=".yaml",
|
|
634
|
+
dir=target.parent,
|
|
635
|
+
)
|
|
636
|
+
temporary = Path(temporary_name)
|
|
637
|
+
try:
|
|
638
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
639
|
+
handle.write(content)
|
|
640
|
+
handle.flush()
|
|
641
|
+
os.fsync(handle.fileno())
|
|
642
|
+
_preserve_mode(target, temporary)
|
|
643
|
+
temporary.replace(target)
|
|
644
|
+
finally:
|
|
645
|
+
temporary.unlink(missing_ok=True)
|
|
646
|
+
|
|
647
|
+
def _mapping(self, value: Any, field: str) -> dict[str, Any]:
|
|
648
|
+
if value is None:
|
|
649
|
+
return {}
|
|
650
|
+
if not isinstance(value, dict):
|
|
651
|
+
msg = f"{field} must be a mapping."
|
|
652
|
+
raise ConfigError(msg)
|
|
653
|
+
return value
|
|
654
|
+
|
|
655
|
+
def _string_list(self, value: Any, field: str) -> list[str]:
|
|
656
|
+
if value is None:
|
|
657
|
+
return []
|
|
658
|
+
if not isinstance(value, list) or not all(
|
|
659
|
+
isinstance(item, str)
|
|
660
|
+
for item in value
|
|
661
|
+
):
|
|
662
|
+
msg = f"{field} must be a list of strings."
|
|
663
|
+
raise ConfigError(msg)
|
|
664
|
+
return list(value)
|
|
665
|
+
|
|
666
|
+
def _boolean(self, value: Any, field: str) -> bool:
|
|
667
|
+
if not isinstance(value, bool):
|
|
668
|
+
msg = f"{field} must be a boolean."
|
|
669
|
+
raise ConfigError(msg)
|
|
670
|
+
return value
|
|
671
|
+
|
|
672
|
+
def _technologies(self, value: Any) -> dict[str, list[str]]:
|
|
673
|
+
if value is None:
|
|
674
|
+
return {}
|
|
675
|
+
if not isinstance(value, dict):
|
|
676
|
+
msg = "technologies must be a mapping."
|
|
677
|
+
raise ConfigError(msg)
|
|
678
|
+
|
|
679
|
+
technologies: dict[str, list[str]] = {}
|
|
680
|
+
for key, items in value.items():
|
|
681
|
+
if not isinstance(key, str):
|
|
682
|
+
msg = "technology keys must be strings."
|
|
683
|
+
raise ConfigError(msg)
|
|
684
|
+
technologies[key] = self._string_list(
|
|
685
|
+
items,
|
|
686
|
+
f"technologies.{key}",
|
|
687
|
+
)
|
|
688
|
+
return technologies
|
|
689
|
+
|
|
690
|
+
def _relative_path(self, value: Any, field: str) -> Path:
|
|
691
|
+
if not isinstance(value, str) or not value.strip():
|
|
692
|
+
msg = f"{field} must be a non-empty path string."
|
|
693
|
+
raise ConfigError(msg)
|
|
694
|
+
|
|
695
|
+
path = Path(value)
|
|
696
|
+
if path.is_absolute():
|
|
697
|
+
msg = f"{field} must be relative to the project root."
|
|
698
|
+
raise ConfigError(msg)
|
|
699
|
+
|
|
700
|
+
resolved = (self.root / path).resolve()
|
|
701
|
+
try:
|
|
702
|
+
resolved.relative_to(self.root)
|
|
703
|
+
except ValueError as error:
|
|
704
|
+
msg = f"{field} must stay inside the project root."
|
|
705
|
+
raise ConfigError(msg) from error
|
|
706
|
+
return resolved
|