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/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