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,263 @@
1
+ """Shared rendering and safe writing of harness integration files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import stat
8
+ import tempfile
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from devflow.core.config import DevFlowConfig
14
+ from devflow.models.profile import Profile
15
+ from devflow.models.project import Project
16
+
17
+ MANAGED_MARKER = "<!-- devflow:managed -->"
18
+
19
+
20
+ class ManagedWriteError(OSError):
21
+ """Raised when a managed integration file would escape the project."""
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class ManagedWriteResult:
26
+ """Outcome of writing a managed integration file."""
27
+
28
+ path: Path
29
+ status: str
30
+
31
+
32
+ def _managed_path(root: Path, path: Path) -> Path:
33
+ """Resolve a managed file path, rejecting symlinks and escapes."""
34
+ if path.is_symlink():
35
+ msg = f"Managed file is a symbolic link: {path}"
36
+ raise ManagedWriteError(msg)
37
+
38
+ resolved_root = root.resolve()
39
+ target = path.resolve()
40
+ try:
41
+ target.relative_to(resolved_root)
42
+ except ValueError as error:
43
+ msg = (
44
+ "Managed file must stay inside the project root: "
45
+ f"{path}"
46
+ )
47
+ raise ManagedWriteError(msg) from error
48
+ return target
49
+
50
+
51
+ def _write_atomic(path: Path, content: str) -> None:
52
+ """Write text atomically inside an already validated directory."""
53
+ descriptor, temporary_name = tempfile.mkstemp(
54
+ prefix=f".{path.name}-",
55
+ suffix=".tmp",
56
+ dir=path.parent,
57
+ )
58
+ temporary = Path(temporary_name)
59
+ try:
60
+ with os.fdopen(
61
+ descriptor,
62
+ "w",
63
+ encoding="utf-8",
64
+ newline="\n",
65
+ ) as handle:
66
+ handle.write(content)
67
+ handle.flush()
68
+ os.fsync(handle.fileno())
69
+
70
+ mode = 0o644
71
+ try:
72
+ mode = stat.S_IMODE(path.stat().st_mode)
73
+ except OSError:
74
+ pass
75
+ os.chmod(temporary, mode)
76
+ temporary.replace(path)
77
+ finally:
78
+ temporary.unlink(missing_ok=True)
79
+
80
+
81
+ def render_agent_instructions(
82
+ project: Project,
83
+ profiles: list[Profile],
84
+ constitution_files: list[str],
85
+ config: DevFlowConfig,
86
+ *,
87
+ imports: bool = False,
88
+ ) -> str:
89
+ """Render the shared, harness-independent project instructions."""
90
+ detected_profiles = [
91
+ profile for profile in profiles if profile.name != "base"
92
+ ]
93
+ lines = [
94
+ MANAGED_MARKER,
95
+ "# DevFlow Agent Instructions",
96
+ "",
97
+ "You are working on an existing software project.",
98
+ "",
99
+ "Before analyzing, planning or implementing any change:",
100
+ "",
101
+ ]
102
+
103
+ if imports:
104
+ lines.extend(
105
+ [
106
+ "The following constitution documents are imported into this "
107
+ "context:",
108
+ "",
109
+ ]
110
+ )
111
+ for filename in constitution_files:
112
+ lines.append(f"@.devflow/constitution/{filename}")
113
+ else:
114
+ for index, filename in enumerate(constitution_files, start=1):
115
+ lines.append(
116
+ f"{index}. Read `.devflow/constitution/{filename}`."
117
+ )
118
+
119
+ lines.extend(
120
+ [
121
+ "",
122
+ "Only apply technology-specific guidelines when the "
123
+ "corresponding technology is present in the project.",
124
+ "",
125
+ "## Project Context",
126
+ "",
127
+ f"- Project: `{project.name}`",
128
+ f"- Type: `{project.project_type}`",
129
+ "- Profiles: "
130
+ + (
131
+ ", ".join(
132
+ f"`{profile.name}`" for profile in detected_profiles
133
+ )
134
+ or "base only"
135
+ ),
136
+ "- Languages: "
137
+ + (", ".join(project.languages) or "not detected"),
138
+ "- Frameworks: "
139
+ + (", ".join(project.frameworks) or "not detected"),
140
+ "",
141
+ "## Existing Codebase",
142
+ "",
143
+ "- Inspect the relevant existing code before making changes.",
144
+ "- Understand current implementations and dependencies.",
145
+ "- Reuse existing patterns and components when appropriate.",
146
+ "- Do not assume the project is a greenfield application.",
147
+ "",
148
+ "## Specification Workflow",
149
+ "",
150
+ "When creating a specification:",
151
+ "",
152
+ "- Analyze the relevant existing implementation.",
153
+ "- Identify affected components.",
154
+ "- Define expected behavior.",
155
+ "- Document functional requirements.",
156
+ "- Define verifiable acceptance criteria.",
157
+ "- Identify constraints and unresolved questions.",
158
+ "- Do not implement code during specification generation.",
159
+ "- Write specifications only under the configured specs "
160
+ "directory.",
161
+ "",
162
+ "## Configuration",
163
+ "",
164
+ f"- Configuration: `{config.path.relative_to(config.root)}`",
165
+ "- Specifications: "
166
+ f"`{config.specifications_path.relative_to(config.root)}`",
167
+ "- Plans: "
168
+ f"`{config.plans_path.relative_to(config.root)}`",
169
+ "",
170
+ "Always respect the project constitution.",
171
+ "",
172
+ ]
173
+ )
174
+ return "\n".join(lines)
175
+
176
+
177
+ def write_text_managed(
178
+ path: Path,
179
+ content: str,
180
+ *,
181
+ root: Path,
182
+ marker: str = MANAGED_MARKER,
183
+ ) -> ManagedWriteResult:
184
+ """Create or update a text file without overwriting unmanaged content."""
185
+ if path.is_symlink():
186
+ return ManagedWriteResult(path=path, status="skipped")
187
+
188
+ target = _managed_path(root, path)
189
+ target.parent.mkdir(parents=True, exist_ok=True)
190
+ target = _managed_path(root, target)
191
+
192
+ if target.exists():
193
+ try:
194
+ existing = target.read_text(encoding="utf-8")
195
+ except OSError:
196
+ return ManagedWriteResult(path=target, status="skipped")
197
+
198
+ if marker not in existing:
199
+ return ManagedWriteResult(path=target, status="skipped")
200
+ if existing == content:
201
+ return ManagedWriteResult(path=target, status="unchanged")
202
+
203
+ _write_atomic(target, content)
204
+ return ManagedWriteResult(path=target, status="updated")
205
+
206
+ _write_atomic(target, content)
207
+ return ManagedWriteResult(path=target, status="created")
208
+
209
+
210
+ def write_json_managed(
211
+ path: Path,
212
+ data: dict[str, Any],
213
+ *,
214
+ root: Path,
215
+ marker_key: str = "devflowManaged",
216
+ ) -> ManagedWriteResult:
217
+ """Create or update a JSON file marked as DevFlow-managed."""
218
+ payload = {marker_key: True, **data}
219
+ content = json.dumps(payload, indent=2) + "\n"
220
+
221
+ if path.is_symlink():
222
+ return ManagedWriteResult(path=path, status="skipped")
223
+
224
+ target = _managed_path(root, path)
225
+ target.parent.mkdir(parents=True, exist_ok=True)
226
+ target = _managed_path(root, target)
227
+
228
+ if target.exists():
229
+ try:
230
+ existing = json.loads(target.read_text(encoding="utf-8"))
231
+ except (OSError, json.JSONDecodeError):
232
+ return ManagedWriteResult(path=target, status="skipped")
233
+
234
+ if not isinstance(existing, dict) or not existing.get(marker_key):
235
+ return ManagedWriteResult(path=target, status="skipped")
236
+ if existing == payload:
237
+ return ManagedWriteResult(path=target, status="unchanged")
238
+
239
+ _write_atomic(target, content)
240
+ return ManagedWriteResult(path=target, status="updated")
241
+
242
+ _write_atomic(target, content)
243
+ return ManagedWriteResult(path=target, status="created")
244
+
245
+
246
+ def write_json_if_missing(
247
+ path: Path,
248
+ data: dict[str, Any],
249
+ *,
250
+ root: Path,
251
+ ) -> ManagedWriteResult:
252
+ """Create a JSON configuration file only when it does not exist."""
253
+ if path.is_symlink():
254
+ return ManagedWriteResult(path=path, status="skipped")
255
+
256
+ target = _managed_path(root, path)
257
+ if target.exists():
258
+ return ManagedWriteResult(path=target, status="unchanged")
259
+
260
+ target.parent.mkdir(parents=True, exist_ok=True)
261
+ target = _managed_path(root, target)
262
+ _write_atomic(target, json.dumps(data, indent=2) + "\n")
263
+ return ManagedWriteResult(path=target, status="created")
@@ -0,0 +1,211 @@
1
+ """Kiro CLI harness adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from devflow.adapters.base import (
6
+ FileChange,
7
+ HarnessAdapter,
8
+ Invocation,
9
+ RunContext,
10
+ SetupContext,
11
+ SetupResult,
12
+ )
13
+ from devflow.adapters.instructions import (
14
+ MANAGED_MARKER,
15
+ render_agent_instructions,
16
+ write_json_managed,
17
+ write_text_managed,
18
+ )
19
+
20
+ INTERACTIVE_AGENT = "devflow-spec"
21
+ READONLY_AGENT = "devflow-spec-readonly"
22
+
23
+
24
+ class KiroAdapter(HarnessAdapter):
25
+ """Generate native Kiro steering files and custom agents."""
26
+
27
+ name = "kiro"
28
+ display_name = "Kiro"
29
+ executable = "kiro-cli"
30
+
31
+ def setup(self, context: SetupContext) -> SetupResult:
32
+ changes: list[FileChange] = []
33
+ warnings: list[str] = []
34
+
35
+ steering = context.root / ".kiro" / "steering"
36
+ for document in context.constitution.documents:
37
+ content = self._render_steering(document.name, document.content)
38
+ result = write_text_managed(
39
+ steering / f"devflow-{document.name}",
40
+ content,
41
+ root=context.root,
42
+ )
43
+ changes.append(FileChange(path=result.path, status=result.status))
44
+ if result.status == "skipped":
45
+ warnings.append(
46
+ f"Kiro steering file {result.path.relative_to(context.root)} "
47
+ "contains unmanaged content and was left untouched."
48
+ )
49
+
50
+ workflow = render_agent_instructions(
51
+ project=context.project,
52
+ profiles=list(context.profiles),
53
+ constitution_files=list(context.constitution_files),
54
+ config=context.config,
55
+ )
56
+ workflow_result = write_text_managed(
57
+ steering / "devflow-spec-workflow.md",
58
+ workflow,
59
+ root=context.root,
60
+ )
61
+ changes.append(
62
+ FileChange(
63
+ path=workflow_result.path,
64
+ status=workflow_result.status,
65
+ )
66
+ )
67
+
68
+ agents = context.root / ".kiro" / "agents"
69
+ interactive = write_json_managed(
70
+ agents / f"{INTERACTIVE_AGENT}.json",
71
+ self._interactive_agent(context),
72
+ root=context.root,
73
+ )
74
+ changes.append(
75
+ FileChange(path=interactive.path, status=interactive.status)
76
+ )
77
+ if interactive.status == "skipped":
78
+ warnings.append(
79
+ "Existing .kiro/agents/devflow-spec.json is unmanaged and was "
80
+ "left untouched."
81
+ )
82
+
83
+ readonly = write_json_managed(
84
+ agents / f"{READONLY_AGENT}.json",
85
+ self._readonly_agent(),
86
+ root=context.root,
87
+ )
88
+ changes.append(
89
+ FileChange(path=readonly.path, status=readonly.status)
90
+ )
91
+ if readonly.status == "skipped":
92
+ warnings.append(
93
+ "Existing .kiro/agents/devflow-spec-readonly.json is unmanaged "
94
+ "and was left untouched."
95
+ )
96
+
97
+ return SetupResult(changes=tuple(changes), warnings=tuple(warnings))
98
+
99
+ def build_run(self, context: RunContext) -> Invocation:
100
+ root = context.root
101
+ if context.mode == "interactive":
102
+ writable = (
103
+ context.permissions.allow_spec_write
104
+ or context.permissions.allow_source_code_changes
105
+ )
106
+ agent = INTERACTIVE_AGENT if writable else READONLY_AGENT
107
+ trusted = "fs_read"
108
+ if context.permissions.allow_source_code_changes:
109
+ trusted = "fs_read,fs_write"
110
+ argv = (
111
+ self.executable,
112
+ "chat",
113
+ "--agent",
114
+ agent,
115
+ f"--trust-tools={trusted}",
116
+ context.prompt,
117
+ )
118
+ return Invocation(argv=argv, cwd=root, interactive=True)
119
+
120
+ argv = (
121
+ self.executable,
122
+ "chat",
123
+ "--no-interactive",
124
+ "--agent",
125
+ READONLY_AGENT,
126
+ "--trust-tools=fs_read",
127
+ "--output-format",
128
+ "text",
129
+ context.prompt,
130
+ )
131
+ return Invocation(argv=argv, cwd=root, interactive=False)
132
+
133
+ def permission_warnings(
134
+ self,
135
+ context: RunContext,
136
+ ) -> tuple[str, ...]:
137
+ warnings: list[str] = []
138
+ if context.mode == "interactive":
139
+ if context.permissions.allow_source_code_changes:
140
+ warnings.append(
141
+ "Kiro is trusted to write with fs_write because source code "
142
+ "changes are allowed. The specification prompt still forbids "
143
+ "implementing the change."
144
+ )
145
+ elif context.permissions.allow_spec_write:
146
+ warnings.append(
147
+ "Kiro custom agents can restrict tools but cannot scope "
148
+ "fs_write to .devflow/specs. Writes require explicit approval; "
149
+ "reject any write outside the specs directory."
150
+ )
151
+ return tuple(warnings)
152
+
153
+ def _render_steering(self, name: str, content: str) -> str:
154
+ lines = [
155
+ "---",
156
+ "inclusion: always",
157
+ "---",
158
+ MANAGED_MARKER,
159
+ f"# DevFlow Constitution: {name}",
160
+ "",
161
+ f"Source: `.devflow/constitution/{name}`",
162
+ "",
163
+ content.rstrip(),
164
+ "",
165
+ ]
166
+ return "\n".join(lines)
167
+
168
+ def _interactive_agent(
169
+ self,
170
+ context: SetupContext,
171
+ ) -> dict[str, object]:
172
+ tools = ["read", "grep", "glob"]
173
+ allowed = ["read", "grep", "glob"]
174
+ if context.permissions.allow_spec_write:
175
+ tools.append("write")
176
+ if context.permissions.allow_source_code_changes:
177
+ tools.append("shell")
178
+ allowed.append("write")
179
+ allowed.append("shell")
180
+
181
+ return {
182
+ "name": INTERACTIVE_AGENT,
183
+ "description": (
184
+ "DevFlow specification agent. Reads the project, asks "
185
+ "clarifying questions and writes only DevFlow specifications."
186
+ ),
187
+ "prompt": (
188
+ "Follow the DevFlow steering files. Generate the requested "
189
+ "specification under .devflow/specs and do not modify "
190
+ "application source code."
191
+ ),
192
+ "tools": tools,
193
+ "allowedTools": allowed,
194
+ "includeMcpJson": False,
195
+ }
196
+
197
+ def _readonly_agent(self) -> dict[str, object]:
198
+ return {
199
+ "name": READONLY_AGENT,
200
+ "description": (
201
+ "Read-only DevFlow specification agent. Returns a complete "
202
+ "specification without modifying the project."
203
+ ),
204
+ "prompt": (
205
+ "Follow the DevFlow steering files. Return a complete "
206
+ "specification as the final answer without writing any file."
207
+ ),
208
+ "tools": ["read", "grep", "glob"],
209
+ "allowedTools": ["read", "grep", "glob"],
210
+ "includeMcpJson": False,
211
+ }
@@ -0,0 +1,237 @@
1
+ """OpenCode harness adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import yaml
6
+
7
+ from devflow.adapters.base import (
8
+ FileChange,
9
+ HarnessAdapter,
10
+ Invocation,
11
+ RunContext,
12
+ SetupContext,
13
+ SetupResult,
14
+ )
15
+ from devflow.adapters.instructions import (
16
+ MANAGED_MARKER,
17
+ render_agent_instructions,
18
+ write_json_if_missing,
19
+ write_text_managed,
20
+ )
21
+
22
+ INTERACTIVE_AGENT = "devflow-spec"
23
+ READONLY_AGENT = "devflow-spec-readonly"
24
+
25
+
26
+ class OpenCodeAdapter(HarnessAdapter):
27
+ """Generate OpenCode agents and invoke mini or run mode."""
28
+
29
+ name = "opencode"
30
+ display_name = "OpenCode"
31
+ executable = "opencode"
32
+
33
+ def setup(self, context: SetupContext) -> SetupResult:
34
+ changes: list[FileChange] = []
35
+ warnings: list[str] = []
36
+
37
+ instructions = render_agent_instructions(
38
+ project=context.project,
39
+ profiles=list(context.profiles),
40
+ constitution_files=list(context.constitution_files),
41
+ config=context.config,
42
+ )
43
+ result = write_text_managed(
44
+ context.root / "AGENTS.md",
45
+ instructions,
46
+ root=context.root,
47
+ )
48
+ changes.append(FileChange(path=result.path, status=result.status))
49
+
50
+ config_result = write_json_if_missing(
51
+ context.root / "opencode.json",
52
+ {"$schema": "https://opencode.ai/config.json"},
53
+ root=context.root,
54
+ )
55
+ changes.append(
56
+ FileChange(path=config_result.path, status=config_result.status)
57
+ )
58
+
59
+ agents_directory = context.root / ".opencode" / "agents"
60
+ interactive = write_text_managed(
61
+ agents_directory / f"{INTERACTIVE_AGENT}.md",
62
+ self._render_agent(
63
+ name=INTERACTIVE_AGENT,
64
+ description=(
65
+ "DevFlow specification agent. Reads the project, asks "
66
+ "clarifying questions and writes only DevFlow specs."
67
+ ),
68
+ context=context,
69
+ interactive=True,
70
+ ),
71
+ root=context.root,
72
+ )
73
+ changes.append(
74
+ FileChange(path=interactive.path, status=interactive.status)
75
+ )
76
+
77
+ readonly = write_text_managed(
78
+ agents_directory / f"{READONLY_AGENT}.md",
79
+ self._render_agent(
80
+ name=READONLY_AGENT,
81
+ description=(
82
+ "Read-only DevFlow specification agent. Returns a complete "
83
+ "specification without modifying the project."
84
+ ),
85
+ context=context,
86
+ interactive=False,
87
+ ),
88
+ root=context.root,
89
+ )
90
+ changes.append(
91
+ FileChange(path=readonly.path, status=readonly.status)
92
+ )
93
+
94
+ if result.status == "skipped":
95
+ warnings.append(
96
+ "AGENTS.md contains unmanaged content and was left untouched."
97
+ )
98
+ return SetupResult(changes=tuple(changes), warnings=tuple(warnings))
99
+
100
+ def build_run(self, context: RunContext) -> Invocation:
101
+ root = context.root
102
+ if context.mode == "interactive":
103
+ argv = (
104
+ self.executable,
105
+ "mini",
106
+ "--agent",
107
+ INTERACTIVE_AGENT,
108
+ "--prompt",
109
+ context.prompt,
110
+ )
111
+ return Invocation(argv=argv, cwd=root, interactive=True)
112
+
113
+ agent = (
114
+ INTERACTIVE_AGENT
115
+ if context.permissions.allow_source_code_changes
116
+ else READONLY_AGENT
117
+ )
118
+ argv = (
119
+ self.executable,
120
+ "run",
121
+ "--agent",
122
+ agent,
123
+ context.prompt,
124
+ )
125
+ return Invocation(argv=argv, cwd=root, interactive=False)
126
+
127
+ def permission_warnings(
128
+ self,
129
+ context: RunContext,
130
+ ) -> tuple[str, ...]:
131
+ warnings: list[str] = []
132
+ if context.mode == "non-interactive" and (
133
+ context.permissions.allow_source_code_changes
134
+ ):
135
+ warnings.append(
136
+ "OpenCode runs with an agent that may edit source code because "
137
+ "source changes are allowed. The specification prompt still "
138
+ "forbids implementing the change."
139
+ )
140
+ if not context.permissions.allow_project_read:
141
+ warnings.append(
142
+ "Project reads are disabled; OpenCode cannot inspect the "
143
+ "codebase. DevFlow will use deterministic analysis."
144
+ )
145
+ return tuple(warnings)
146
+
147
+ def _render_agent(
148
+ self,
149
+ *,
150
+ name: str,
151
+ description: str,
152
+ context: SetupContext,
153
+ interactive: bool,
154
+ ) -> str:
155
+ permissions = self._permissions(context, interactive=interactive)
156
+ frontmatter = yaml.safe_dump(
157
+ {
158
+ "description": description,
159
+ "mode": "primary",
160
+ "permissions": permissions,
161
+ },
162
+ sort_keys=False,
163
+ allow_unicode=True,
164
+ ).strip()
165
+ body = [
166
+ MANAGED_MARKER,
167
+ f"# {name}",
168
+ "",
169
+ "You are the DevFlow specification agent for this project.",
170
+ "",
171
+ "Follow `AGENTS.md` and the project constitution. Analyze the "
172
+ "existing code before writing.",
173
+ "",
174
+ "Write only under `.devflow/specs/`. Never modify application "
175
+ "source code while generating a specification.",
176
+ "",
177
+ "The complete task, target path and output contract are provided "
178
+ "in the user prompt for each run.",
179
+ "",
180
+ ]
181
+ return f"---\n{frontmatter}\n---\n\n" + "\n".join(body)
182
+
183
+ def _permissions(
184
+ self,
185
+ context: SetupContext,
186
+ *,
187
+ interactive: bool,
188
+ ) -> list[dict[str, str]]:
189
+ permissions: list[dict[str, str]] = []
190
+ if context.permissions.allow_project_read:
191
+ for action in ("read", "glob", "grep"):
192
+ permissions.append(
193
+ {"action": action, "resource": "*", "effect": "allow"}
194
+ )
195
+
196
+ if context.permissions.allow_source_code_changes:
197
+ permissions.append(
198
+ {"action": "edit", "resource": "*", "effect": "allow"}
199
+ )
200
+ elif context.permissions.allow_spec_write:
201
+ permissions.append(
202
+ {"action": "edit", "resource": "*", "effect": "deny"}
203
+ )
204
+ permissions.append(
205
+ {
206
+ "action": "edit",
207
+ "resource": ".devflow/specs/**",
208
+ "effect": "allow",
209
+ }
210
+ )
211
+ else:
212
+ permissions.append(
213
+ {"action": "edit", "resource": "*", "effect": "deny"}
214
+ )
215
+
216
+ permissions.extend(
217
+ [
218
+ {
219
+ "action": "shell",
220
+ "resource": "*",
221
+ "effect": (
222
+ "allow"
223
+ if context.permissions.allow_source_code_changes
224
+ else "deny"
225
+ ),
226
+ },
227
+ {
228
+ "action": "question",
229
+ "resource": "*",
230
+ "effect": "allow" if interactive else "deny",
231
+ },
232
+ {"action": "webfetch", "resource": "*", "effect": "deny"},
233
+ {"action": "websearch", "resource": "*", "effect": "deny"},
234
+ {"action": "subagent", "resource": "*", "effect": "deny"},
235
+ ]
236
+ )
237
+ return permissions