devcouncil 0.2.0 → 0.2.1

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 (205) hide show
  1. package/README.md +191 -253
  2. package/bin/devcouncil.js +130 -32
  3. package/package.json +6 -17
  4. package/pyproject.toml +0 -66
  5. package/src/devcouncil/__init__.py +0 -0
  6. package/src/devcouncil/__main__.py +0 -4
  7. package/src/devcouncil/app/__init__.py +0 -28
  8. package/src/devcouncil/app/config.py +0 -320
  9. package/src/devcouncil/app/errors.py +0 -23
  10. package/src/devcouncil/app/events.py +0 -44
  11. package/src/devcouncil/app/orchestrator.py +0 -92
  12. package/src/devcouncil/app/project_status.py +0 -29
  13. package/src/devcouncil/app/run_context.py +0 -39
  14. package/src/devcouncil/app/state_machine.py +0 -108
  15. package/src/devcouncil/artifacts/__init__.py +0 -1
  16. package/src/devcouncil/artifacts/coverage.py +0 -96
  17. package/src/devcouncil/artifacts/graph.py +0 -163
  18. package/src/devcouncil/artifacts/migrations.py +0 -20
  19. package/src/devcouncil/artifacts/schemas.py +0 -23
  20. package/src/devcouncil/artifacts/serializer.py +0 -21
  21. package/src/devcouncil/artifacts/validators.py +0 -27
  22. package/src/devcouncil/assets/__init__.py +0 -1
  23. package/src/devcouncil/assets/devcouncil-logo.svg +0 -60
  24. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  25. package/src/devcouncil/cli/__init__.py +0 -0
  26. package/src/devcouncil/cli/commands/__init__.py +0 -0
  27. package/src/devcouncil/cli/commands/agents.py +0 -292
  28. package/src/devcouncil/cli/commands/artifacts.py +0 -54
  29. package/src/devcouncil/cli/commands/ast.py +0 -22
  30. package/src/devcouncil/cli/commands/baseline.py +0 -35
  31. package/src/devcouncil/cli/commands/check.py +0 -209
  32. package/src/devcouncil/cli/commands/config.py +0 -115
  33. package/src/devcouncil/cli/commands/cost.py +0 -57
  34. package/src/devcouncil/cli/commands/dashboard.py +0 -31
  35. package/src/devcouncil/cli/commands/doctor.py +0 -340
  36. package/src/devcouncil/cli/commands/evidence.py +0 -48
  37. package/src/devcouncil/cli/commands/go.py +0 -656
  38. package/src/devcouncil/cli/commands/handoff.py +0 -69
  39. package/src/devcouncil/cli/commands/hook.py +0 -237
  40. package/src/devcouncil/cli/commands/init.py +0 -289
  41. package/src/devcouncil/cli/commands/integrate.py +0 -1515
  42. package/src/devcouncil/cli/commands/lsp.py +0 -20
  43. package/src/devcouncil/cli/commands/map.py +0 -112
  44. package/src/devcouncil/cli/commands/mcp_server.py +0 -18
  45. package/src/devcouncil/cli/commands/plan.py +0 -488
  46. package/src/devcouncil/cli/commands/prompt.py +0 -61
  47. package/src/devcouncil/cli/commands/repair.py +0 -89
  48. package/src/devcouncil/cli/commands/report.py +0 -137
  49. package/src/devcouncil/cli/commands/reset_demo_state.py +0 -33
  50. package/src/devcouncil/cli/commands/rollback.py +0 -59
  51. package/src/devcouncil/cli/commands/run.py +0 -289
  52. package/src/devcouncil/cli/commands/runs.py +0 -223
  53. package/src/devcouncil/cli/commands/scaffold.py +0 -32
  54. package/src/devcouncil/cli/commands/semantic.py +0 -47
  55. package/src/devcouncil/cli/commands/setup.py +0 -362
  56. package/src/devcouncil/cli/commands/shell.py +0 -73
  57. package/src/devcouncil/cli/commands/show.py +0 -76
  58. package/src/devcouncil/cli/commands/skills.py +0 -88
  59. package/src/devcouncil/cli/commands/status.py +0 -141
  60. package/src/devcouncil/cli/commands/tasks.py +0 -55
  61. package/src/devcouncil/cli/commands/trace.py +0 -88
  62. package/src/devcouncil/cli/commands/verify.py +0 -328
  63. package/src/devcouncil/cli/commands/version.py +0 -20
  64. package/src/devcouncil/cli/commands/watch.py +0 -574
  65. package/src/devcouncil/cli/commands/watch_fs.py +0 -40
  66. package/src/devcouncil/cli/main.py +0 -137
  67. package/src/devcouncil/council/__init__.py +0 -0
  68. package/src/devcouncil/council/prompts/__init__.py +0 -0
  69. package/src/devcouncil/council/prompts/arbiter.md +0 -19
  70. package/src/devcouncil/council/prompts/critic_a.md +0 -10
  71. package/src/devcouncil/council/prompts/critic_b.md +0 -10
  72. package/src/devcouncil/council/prompts/implementation_reviewer.md +0 -16
  73. package/src/devcouncil/council/prompts/planner_a.md +0 -16
  74. package/src/devcouncil/council/prompts/planner_b.md +0 -16
  75. package/src/devcouncil/council/prompts/rebuttal.md +0 -10
  76. package/src/devcouncil/council/prompts/spec_writer.md +0 -12
  77. package/src/devcouncil/domain/__init__.py +0 -0
  78. package/src/devcouncil/domain/assumption.py +0 -17
  79. package/src/devcouncil/domain/critique.py +0 -32
  80. package/src/devcouncil/domain/evidence.py +0 -47
  81. package/src/devcouncil/domain/gap.py +0 -52
  82. package/src/devcouncil/domain/requirement.py +0 -22
  83. package/src/devcouncil/domain/task.py +0 -55
  84. package/src/devcouncil/execution/__init__.py +0 -1
  85. package/src/devcouncil/execution/checkpoints.py +0 -246
  86. package/src/devcouncil/execution/context_builder.py +0 -60
  87. package/src/devcouncil/execution/executor.py +0 -15
  88. package/src/devcouncil/execution/fs_watcher.py +0 -180
  89. package/src/devcouncil/execution/handoff.py +0 -102
  90. package/src/devcouncil/execution/hook_policy.py +0 -253
  91. package/src/devcouncil/execution/patch.py +0 -77
  92. package/src/devcouncil/execution/paths.py +0 -14
  93. package/src/devcouncil/execution/permissions.py +0 -85
  94. package/src/devcouncil/execution/policy_engine.py +0 -343
  95. package/src/devcouncil/execution/prompt_builder.py +0 -671
  96. package/src/devcouncil/execution/shell_session.py +0 -225
  97. package/src/devcouncil/execution/task_runner.py +0 -170
  98. package/src/devcouncil/executors/__init__.py +0 -1
  99. package/src/devcouncil/executors/agent_registry.py +0 -575
  100. package/src/devcouncil/executors/coding_cli.py +0 -736
  101. package/src/devcouncil/executors/mini_swe.py +0 -73
  102. package/src/devcouncil/executors/native/__init__.py +0 -0
  103. package/src/devcouncil/executors/native/agent.py +0 -208
  104. package/src/devcouncil/executors/openhands.py +0 -71
  105. package/src/devcouncil/gating/__init__.py +0 -1
  106. package/src/devcouncil/gating/checks/__init__.py +0 -0
  107. package/src/devcouncil/gating/checks/clean_git.py +0 -52
  108. package/src/devcouncil/gating/checks/planned_files_check.py +0 -32
  109. package/src/devcouncil/gating/checks/requirement_coverage.py +0 -26
  110. package/src/devcouncil/gating/checks/secret_scan_check.py +0 -53
  111. package/src/devcouncil/gating/policy.py +0 -338
  112. package/src/devcouncil/hardware.py +0 -184
  113. package/src/devcouncil/indexing/__init__.py +0 -1
  114. package/src/devcouncil/indexing/ast_matcher.py +0 -168
  115. package/src/devcouncil/indexing/graph_index.py +0 -48
  116. package/src/devcouncil/indexing/lsp.py +0 -161
  117. package/src/devcouncil/indexing/repo_mapper.py +0 -1455
  118. package/src/devcouncil/indexing/semantic_index.py +0 -205
  119. package/src/devcouncil/integrations/actions.py +0 -146
  120. package/src/devcouncil/integrations/check.py +0 -423
  121. package/src/devcouncil/integrations/code_review_graph.py +0 -163
  122. package/src/devcouncil/integrations/github.py +0 -39
  123. package/src/devcouncil/integrations/github_intent.py +0 -142
  124. package/src/devcouncil/integrations/gitnexus.py +0 -62
  125. package/src/devcouncil/integrations/graphify.py +0 -34
  126. package/src/devcouncil/integrations/mcp/__init__.py +0 -0
  127. package/src/devcouncil/integrations/mcp/server.py +0 -2122
  128. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +0 -24
  129. package/src/devcouncil/integrations/pr_comments.py +0 -62
  130. package/src/devcouncil/live/__init__.py +0 -2
  131. package/src/devcouncil/live/cards.py +0 -349
  132. package/src/devcouncil/live/models.py +0 -63
  133. package/src/devcouncil/live/repair_prompt.py +0 -83
  134. package/src/devcouncil/live/reviewer.py +0 -70
  135. package/src/devcouncil/live/signals.py +0 -135
  136. package/src/devcouncil/live/summary.py +0 -34
  137. package/src/devcouncil/live/tasks.py +0 -18
  138. package/src/devcouncil/live/transcripts.py +0 -141
  139. package/src/devcouncil/llm/__init__.py +0 -1
  140. package/src/devcouncil/llm/cache.py +0 -42
  141. package/src/devcouncil/llm/model_defaults.yaml +0 -44
  142. package/src/devcouncil/llm/provider.py +0 -627
  143. package/src/devcouncil/llm/router.py +0 -310
  144. package/src/devcouncil/optimization/__init__.py +0 -1
  145. package/src/devcouncil/optimization/gepa_agent.py +0 -318
  146. package/src/devcouncil/planning/__init__.py +0 -1
  147. package/src/devcouncil/planning/arbiter_service.py +0 -57
  148. package/src/devcouncil/planning/correction_manifest.py +0 -303
  149. package/src/devcouncil/planning/critique_service.py +0 -71
  150. package/src/devcouncil/planning/plan_service.py +0 -60
  151. package/src/devcouncil/planning/prompt_enhancer_service.py +0 -167
  152. package/src/devcouncil/planning/repair_service.py +0 -39
  153. package/src/devcouncil/planning/spec_service.py +0 -70
  154. package/src/devcouncil/repo/__init__.py +0 -0
  155. package/src/devcouncil/repo/ci_scaffold.py +0 -157
  156. package/src/devcouncil/repo/gitignore.py +0 -123
  157. package/src/devcouncil/repo/sca.py +0 -374
  158. package/src/devcouncil/reporting/__init__.py +0 -0
  159. package/src/devcouncil/reporting/github_check.py +0 -32
  160. package/src/devcouncil/reporting/json_report.py +0 -30
  161. package/src/devcouncil/reporting/markdown_report.py +0 -83
  162. package/src/devcouncil/reporting/report_builder.py +0 -14
  163. package/src/devcouncil/skills/__init__.py +0 -19
  164. package/src/devcouncil/skills/library/README.md +0 -46
  165. package/src/devcouncil/skills/library/ai-training.md +0 -50
  166. package/src/devcouncil/skills/library/android.md +0 -50
  167. package/src/devcouncil/skills/library/backend.md +0 -52
  168. package/src/devcouncil/skills/library/core-engineering.md +0 -95
  169. package/src/devcouncil/skills/library/data-engineering.md +0 -47
  170. package/src/devcouncil/skills/library/desktop.md +0 -46
  171. package/src/devcouncil/skills/library/devops.md +0 -48
  172. package/src/devcouncil/skills/library/game-dev.md +0 -46
  173. package/src/devcouncil/skills/library/ios.md +0 -48
  174. package/src/devcouncil/skills/library/mobile-cross-platform.md +0 -46
  175. package/src/devcouncil/skills/library/security.md +0 -48
  176. package/src/devcouncil/skills/library/systems.md +0 -48
  177. package/src/devcouncil/skills/library/web.md +0 -47
  178. package/src/devcouncil/skills/library/windows.md +0 -47
  179. package/src/devcouncil/skills/registry.py +0 -330
  180. package/src/devcouncil/storage/__init__.py +0 -0
  181. package/src/devcouncil/storage/db.py +0 -147
  182. package/src/devcouncil/storage/models.py +0 -204
  183. package/src/devcouncil/storage/native.py +0 -557
  184. package/src/devcouncil/storage/repositories.py +0 -485
  185. package/src/devcouncil/telemetry/__init__.py +0 -0
  186. package/src/devcouncil/telemetry/cost.py +0 -140
  187. package/src/devcouncil/telemetry/model_pricing.yaml +0 -48
  188. package/src/devcouncil/telemetry/pricing.py +0 -28
  189. package/src/devcouncil/telemetry/traces.py +0 -146
  190. package/src/devcouncil/telemetry/tracker.py +0 -52
  191. package/src/devcouncil/ui/__init__.py +0 -1
  192. package/src/devcouncil/ui/dashboard.py +0 -423
  193. package/src/devcouncil/utils/__init__.py +0 -1
  194. package/src/devcouncil/utils/redaction.py +0 -147
  195. package/src/devcouncil/utils/subprocess_env.py +0 -69
  196. package/src/devcouncil/verification/__init__.py +0 -1
  197. package/src/devcouncil/verification/acceptance_compiler.py +0 -125
  198. package/src/devcouncil/verification/ad_hoc_check.py +0 -129
  199. package/src/devcouncil/verification/diff_coverage.py +0 -353
  200. package/src/devcouncil/verification/implementation_reviewer.py +0 -55
  201. package/src/devcouncil/verification/next_actions.py +0 -189
  202. package/src/devcouncil/verification/sandbox.py +0 -178
  203. package/src/devcouncil/verification/test_resolver.py +0 -91
  204. package/src/devcouncil/verification/verifier.py +0 -1548
  205. package/uv.lock +0 -1226
@@ -1,736 +0,0 @@
1
- import json
2
- import os
3
- import queue
4
- import shutil
5
- import subprocess
6
- import sys
7
- import threading
8
- import time
9
- import uuid
10
- from datetime import datetime, timezone
11
- from pathlib import Path
12
-
13
- from rich.console import Console
14
-
15
- from devcouncil.domain.requirement import Requirement
16
- from devcouncil.domain.task import Task
17
- from devcouncil.app.config import load_config
18
- from devcouncil.execution.executor import Executor, ExecutionResult
19
- from devcouncil.execution.prompt_builder import PromptBuilder
20
- from devcouncil.executors.agent_registry import (
21
- VALID_INPUT_MODES,
22
- CliAgentSpec,
23
- get_cli_agent_spec,
24
- load_agent_profiles,
25
- normalize_agent_name,
26
- resolve_cursor_agent_executable,
27
- )
28
- from devcouncil.repo.gitignore import ensure_gitignore
29
- from devcouncil.telemetry.traces import TraceLogger
30
- from devcouncil.utils.redaction import redact_text
31
-
32
- console = Console()
33
-
34
-
35
- class CodingCliExecutor(Executor):
36
- """Execute a DevCouncil task by handing it off to an external coding CLI."""
37
-
38
- def __init__(
39
- self,
40
- project_root: Path,
41
- client: str,
42
- timeout_seconds: int = 1800,
43
- profile: str | None = None,
44
- stream_output: bool | None = None,
45
- ):
46
- self.project_root = project_root
47
- self.client = self._normalize_client(client)
48
- self.timeout_seconds = timeout_seconds
49
- self.spec = self._resolve_spec()
50
- self.profile_name = profile or self.spec.default_profile or "default"
51
- self.profile = load_agent_profiles(project_root).get(self.profile_name)
52
- self.last_run_id: str | None = None
53
- self.last_transcript_path: Path | None = None
54
- self.stream_output = self._resolve_stream_output(stream_output)
55
-
56
- def _normalize_client(self, client: str) -> str:
57
- return normalize_agent_name(client)
58
-
59
- def _resolve_spec(self) -> CliAgentSpec:
60
- spec = get_cli_agent_spec(self.project_root, self.client)
61
- if spec:
62
- return spec
63
- raise ValueError(f"Unsupported coding CLI client: {self.client}")
64
-
65
- def _resolve_stream_output(self, stream_output: bool | None) -> bool:
66
- if stream_output is not None:
67
- return stream_output
68
- try:
69
- return bool(load_config(self.project_root).execution.stream_cli_output)
70
- except Exception:
71
- return False
72
-
73
- def _command(self, task_id: str | None = None) -> list[str]:
74
- if self.client == "warp":
75
- base = self._warp_command()
76
- elif self.client == "cursor":
77
- base = self._cursor_command(task_id)
78
- else:
79
- base = self.spec.base_command()
80
- return self._apply_profile_args(base)
81
-
82
- # Per-CLI flag used to override the model, when the CLI accepts one. Clients
83
- # absent from this map simply ignore a profile ``model`` override.
84
- _MODEL_FLAGS: dict[str, str] = {
85
- "claude": "--model",
86
- "codex": "--model",
87
- "gemini": "--model",
88
- "cursor": "--model",
89
- "qwen": "--model",
90
- "opencode": "--model",
91
- "aider": "--model",
92
- }
93
-
94
- def _apply_profile_args(self, command: list[str]) -> list[str]:
95
- """Apply per-profile CLI overrides to the resolved command.
96
-
97
- Empty/None overrides reproduce today's invocation exactly (no regression):
98
- ``model`` rewrites/adds the model flag for CLIs that accept one,
99
- ``permission_mode`` is translated into the right per-CLI flag (and an
100
- overly-permissive baked-in flag is replaced for stricter modes), and
101
- ``extra_args`` are appended verbatim. Surfaced in the run manifest so
102
- ``dev runs show`` reveals exactly how the CLI was invoked."""
103
- if not self.profile:
104
- return command
105
- result = list(command)
106
- result = self._apply_permission_mode(result)
107
- result = self._apply_model_override(result)
108
- # NOTE: extra_args are NOT appended here. For argument/prompt-file CLIs the prompt
109
- # (and sometimes its flag, e.g. warp --prompt / aider --message) is appended last
110
- # by _invocation; appending extra_args at the tail here would slot them between the
111
- # prompt flag and its value. _invocation places them correctly instead.
112
- return result
113
-
114
- def _apply_model_override(self, command: list[str]) -> list[str]:
115
- model = (self.profile.model or "").strip() if self.profile else ""
116
- if not model:
117
- return command
118
- flag = self._MODEL_FLAGS.get(self.client)
119
- if not flag:
120
- return command
121
- result = list(command)
122
- for index, part in enumerate(result):
123
- if part == flag and index + 1 < len(result):
124
- result[index + 1] = model
125
- return result
126
- return [*result, flag, model]
127
-
128
- def _apply_permission_mode(self, command: list[str]) -> list[str]:
129
- mode = (self.profile.permission_mode or "").strip() if self.profile else ""
130
- if not mode:
131
- return command
132
- if self.client == "claude":
133
- return self._apply_claude_permission_mode(command, mode)
134
- return command
135
-
136
- @staticmethod
137
- def _apply_claude_permission_mode(command: list[str], mode: str) -> list[str]:
138
- """Translate an abstract permission mode into Claude Code's
139
- ``--permission-mode`` value. ``auto`` keeps blanket auto-apply
140
- (``acceptEdits``); ``gated``/``ask`` drop blanket auto-apply so edits are
141
- gated (``default``); ``plan`` is read-only planning. An explicit native
142
- value (e.g. ``acceptEdits``, ``bypassPermissions``) is passed through."""
143
- translation = {
144
- "auto": "acceptEdits",
145
- "gated": "default",
146
- "ask": "default",
147
- "plan": "plan",
148
- }
149
- value = translation.get(mode.lower(), mode)
150
- result = list(command)
151
- for index, part in enumerate(result):
152
- if part == "--permission-mode" and index + 1 < len(result):
153
- result[index + 1] = value
154
- return result
155
- return [*result, "--permission-mode", value]
156
-
157
- def _cursor_command(self, task_id: str | None = None) -> list[str]:
158
- executable = resolve_cursor_agent_executable()
159
- if not executable:
160
- raise ValueError("cursor-agent (or agent) is not installed or not on PATH.")
161
- command = [
162
- executable,
163
- "--print",
164
- "--trust",
165
- "--workspace",
166
- str(self.project_root),
167
- ]
168
- chat_id = self._cursor_resume_chat_id(task_id)
169
- if chat_id:
170
- command.extend(["--resume", chat_id])
171
- command.append("Read and execute the DevCouncil task prompt at {prompt_file}.")
172
- return command
173
-
174
- def _warp_command(self) -> list[str]:
175
- config = self._load_warp_config()
176
- command = config.get("command", "oz")
177
- mode = config.get("run_mode", "local")
178
- subcommand = "run-cloud" if mode == "cloud" else "run"
179
- mcp_path = self._ensure_warp_mcp_config(config)
180
- args = [command, "agent", subcommand, "--name", "devcouncil-task", "--mcp", str(mcp_path)]
181
- if subcommand == "run":
182
- args.extend(["--cwd", str(self.project_root)])
183
- if profile := config.get("profile"):
184
- args.extend(["--profile", str(profile)])
185
- if model := config.get("model"):
186
- args.extend(["--model", str(model)])
187
- if environment := config.get("environment"):
188
- args.extend(["--environment", str(environment)])
189
- for share in config.get("share", []):
190
- args.extend(["--share", str(share)])
191
- args.append("--prompt")
192
- return args
193
-
194
- def _load_warp_config(self) -> dict:
195
- try:
196
- warp = load_config(self.project_root).integrations.warp
197
- data = warp.model_dump()
198
- except Exception:
199
- data = {}
200
- if command := os.environ.get("DEVCOUNCIL_WARP_COMMAND"):
201
- data["command"] = command
202
- if mode := os.environ.get("DEVCOUNCIL_WARP_RUN_MODE"):
203
- data["run_mode"] = mode
204
- if profile := os.environ.get("DEVCOUNCIL_WARP_PROFILE"):
205
- data["profile"] = profile
206
- if model := os.environ.get("DEVCOUNCIL_WARP_MODEL"):
207
- data["model"] = model
208
- if environment := os.environ.get("DEVCOUNCIL_WARP_ENVIRONMENT"):
209
- data["environment"] = environment
210
- return data
211
-
212
- def run_task(self, task: Task, requirements: list[Requirement]) -> ExecutionResult:
213
- if self.profile is None:
214
- return ExecutionResult(
215
- success=False,
216
- message=f"Unknown agent profile '{self.profile_name}' for {self.client}.",
217
- )
218
- if self.spec.input_mode not in VALID_INPUT_MODES:
219
- return ExecutionResult(
220
- success=False,
221
- message=(
222
- f"Invalid input_mode '{self.spec.input_mode}' for {self.client}. "
223
- "Use one of: argument, prompt-file, stdin."
224
- ),
225
- )
226
-
227
- ensure_gitignore(self.project_root)
228
-
229
- try:
230
- command = self._command(task.id)
231
- except ValueError as exc:
232
- return ExecutionResult(success=False, message=str(exc))
233
-
234
- executable = command[0]
235
- if not shutil.which(executable):
236
- return ExecutionResult(
237
- success=False,
238
- message=f"{self.client} CLI is not installed or not on PATH.",
239
- )
240
-
241
- prompt = PromptBuilder(self.project_root).build_task_prompt(task, requirements)
242
- from devcouncil.planning.correction_manifest import load_latest_correction_manifest
243
-
244
- correction = load_latest_correction_manifest(self.project_root, task.id)
245
- if correction is not None:
246
- prompt = (
247
- f"# DevCouncil Correction Manifest\n\n"
248
- f"{correction.model_dump_json(indent=2)}\n\n"
249
- f"{prompt}"
250
- )
251
- prompt = self._apply_profile_prompt(prompt)
252
- instruction_file = self.project_root / ".devcouncil" / f"{task.id}-{self.client}-task.md"
253
- instruction_file.parent.mkdir(parents=True, exist_ok=True)
254
- instruction_file.write_text(prompt, encoding="utf-8")
255
-
256
- custom_env = self.spec.env
257
- env = {**dict(os.environ), **custom_env, "DEVCOUNCIL_PROJECT_ROOT": str(self.project_root)}
258
- env["DEVCOUNCIL_AGENT_PROFILE"] = self.profile_name
259
- log_prefix = f"{task.id}-{self.client}"
260
- run_id = str(uuid.uuid4())
261
- self.last_run_id = run_id
262
-
263
- console.print(f"Starting [bold]{self.client.upper()}[/bold] for task [bold]{task.id}[/bold]...")
264
- console.print(f"Task prompt: [dim]{instruction_file}[/dim]")
265
-
266
- started = time.monotonic()
267
- try:
268
- invocation, input_text = self._invocation(command, prompt, instruction_file)
269
- display_invocation = self._display_invocation(invocation, prompt)
270
- # Print the resolved command (placeholders like {prompt_file} already
271
- # substituted, prompt redacted) rather than the raw template.
272
- console.print(f"Command: [dim]{' '.join(display_invocation)}[/dim]")
273
- manifest_path = self._write_run_manifest(
274
- run_id,
275
- task,
276
- display_invocation,
277
- instruction_file,
278
- stream=self.stream_output,
279
- )
280
- TraceLogger(self.project_root).log_event(
281
- "agent_run_started",
282
- {
283
- "agent": self.client,
284
- "profile": self.profile_name,
285
- "command": display_invocation,
286
- "prompt_file": str(instruction_file),
287
- "manifest": str(manifest_path),
288
- },
289
- run_id=run_id,
290
- task_id=task.id,
291
- summary=f"Started {self.client} for {task.id}",
292
- )
293
- transcript_path = (
294
- self.project_root / ".devcouncil" / "runs" / run_id / "transcript.txt"
295
- if self.stream_output
296
- else None
297
- )
298
- started = time.monotonic()
299
- result = self._run_subprocess(invocation, input_text, env, transcript_path=transcript_path)
300
- duration = round(time.monotonic() - started, 3)
301
- finished_at = datetime.now(timezone.utc).isoformat()
302
- self._write_log(log_prefix, result)
303
- if transcript_path and transcript_path.exists():
304
- self._append_manifest_transcript(run_id, transcript_path)
305
- self.last_transcript_path = transcript_path
306
- console.print(f"Stream transcript: [dim]{transcript_path}[/dim]")
307
- if result.returncode != 0:
308
- self._update_run_manifest(
309
- run_id,
310
- status="failed",
311
- returncode=result.returncode,
312
- stdout_preview=self._preview_lines(result.stdout),
313
- stderr_preview=self._preview_lines(result.stderr),
314
- finished_at=finished_at,
315
- duration_seconds=duration,
316
- )
317
- stderr_preview = (result.stderr or result.stdout or "").strip().splitlines()[:5]
318
- detail = redact_text(stderr_preview[0]) if stderr_preview else "No diagnostics were produced."
319
- TraceLogger(self.project_root).log_event(
320
- "agent_run_failed",
321
- {"agent": self.client, "profile": self.profile_name, "returncode": result.returncode, "detail": detail},
322
- run_id=run_id,
323
- task_id=task.id,
324
- summary=f"{self.client} exited with code {result.returncode}",
325
- )
326
- return ExecutionResult(
327
- success=False,
328
- message=f"{self.client} exited with code {result.returncode}: {detail}",
329
- )
330
- self._update_run_manifest(
331
- run_id,
332
- status="finished",
333
- returncode=result.returncode,
334
- stdout_preview=self._preview_lines(result.stdout),
335
- stderr_preview=self._preview_lines(result.stderr),
336
- finished_at=finished_at,
337
- duration_seconds=duration,
338
- )
339
- TraceLogger(self.project_root).log_event(
340
- "agent_run_finished",
341
- {"agent": self.client, "profile": self.profile_name, "returncode": result.returncode},
342
- run_id=run_id,
343
- task_id=task.id,
344
- summary=f"{self.client} finished for {task.id}",
345
- )
346
- return ExecutionResult(success=True, message=f"{self.client} execution finished.")
347
- except subprocess.TimeoutExpired:
348
- self._update_run_manifest(
349
- run_id,
350
- status="timeout",
351
- finished_at=datetime.now(timezone.utc).isoformat(),
352
- duration_seconds=round(time.monotonic() - started, 3),
353
- )
354
- TraceLogger(self.project_root).log_event(
355
- "agent_run_failed",
356
- {"agent": self.client, "profile": self.profile_name, "timeout_seconds": self._effective_timeout()},
357
- run_id=run_id,
358
- task_id=task.id,
359
- summary=f"{self.client} timed out for {task.id}",
360
- )
361
- return ExecutionResult(
362
- success=False,
363
- message=f"{self.client} execution timed out after {self._effective_timeout()}s.",
364
- )
365
- except Exception as exc:
366
- self._update_run_manifest(
367
- run_id,
368
- status="failed",
369
- returncode=None,
370
- stderr_preview=self._preview_lines(str(exc)),
371
- finished_at=datetime.now(timezone.utc).isoformat(),
372
- duration_seconds=round(time.monotonic() - started, 3),
373
- )
374
- return ExecutionResult(success=False, message=str(exc))
375
-
376
- def _resolve_invocation(self, invocation: list[str], env: dict[str, str]) -> list[str]:
377
- """Route Windows batch shims through the command interpreter.
378
-
379
- Coding CLIs installed via npm are exposed on Windows as ``.cmd``/``.bat``
380
- shims (e.g. ``codex.CMD``). ``CreateProcess`` (shell=False) cannot execute
381
- a batch file directly nor apply PATHEXT to a bare ``codex``, so the run
382
- fails with ``WinError 2``/``193``. When the program resolves to such a
383
- shim, invoke it via ``cmd /c <shim>``; ``.exe`` programs and non-Windows
384
- platforms are left untouched so the invocation passed to the agent is
385
- otherwise verbatim.
386
- """
387
- if not invocation or os.name != "nt":
388
- return invocation
389
- # Resolve against the PATH the child will actually run with (which includes any
390
- # per-agent env overrides), not the parent process PATH — otherwise shim
391
- # detection and execution can disagree on which executable runs.
392
- resolved = shutil.which(invocation[0], path=env.get("PATH"))
393
- if resolved and resolved.lower().endswith((".cmd", ".bat")):
394
- comspec = os.environ.get("COMSPEC", "cmd.exe")
395
- return [comspec, "/c", resolved, *invocation[1:]]
396
- return invocation
397
-
398
- @staticmethod
399
- def _emit_stream_line(line: str) -> None:
400
- """Print a streamed agent line without letting a non-encodable character
401
- crash the run. Coding agents emit Unicode (e.g. ``✓``) that the
402
- Windows console / a redirected cp1252 stdout cannot encode; an unguarded
403
- ``console.print`` would raise UnicodeEncodeError and be misreported as the
404
- agent failing to start, even though it ran (and may have applied edits).
405
- """
406
- try:
407
- console.print(line, end="")
408
- except UnicodeEncodeError:
409
- encoding = getattr(sys.stdout, "encoding", None) or "utf-8"
410
- safe = line.encode(encoding, errors="replace").decode(encoding, errors="replace")
411
- console.print(safe, end="")
412
-
413
- def _run_subprocess(
414
- self,
415
- invocation: list[str],
416
- input_text: str | None,
417
- env: dict[str, str],
418
- transcript_path: Path | None = None,
419
- ) -> subprocess.CompletedProcess[str]:
420
- timeout = self._effective_timeout()
421
- invocation = self._resolve_invocation(invocation, env)
422
- if not self.stream_output:
423
- return subprocess.run(
424
- invocation,
425
- input=input_text,
426
- capture_output=True,
427
- text=True,
428
- encoding="utf-8",
429
- errors="replace",
430
- cwd=self.project_root,
431
- env=env,
432
- timeout=timeout,
433
- )
434
-
435
- process = subprocess.Popen(
436
- invocation,
437
- stdin=subprocess.PIPE if input_text is not None else None,
438
- stdout=subprocess.PIPE,
439
- stderr=subprocess.STDOUT,
440
- text=True,
441
- encoding="utf-8",
442
- errors="replace",
443
- cwd=self.project_root,
444
- env=env,
445
- )
446
- stdin = process.stdin
447
- if input_text is not None and stdin is not None:
448
- # Feed stdin from a thread so a child that fills its stdout pipe
449
- # before consuming stdin cannot deadlock against us.
450
- def _feed_stdin() -> None:
451
- try:
452
- stdin.write(input_text)
453
- stdin.close()
454
- except (BrokenPipeError, OSError):
455
- pass
456
-
457
- threading.Thread(target=_feed_stdin, daemon=True).start()
458
-
459
- stdout = process.stdout
460
- assert stdout is not None
461
- lines: queue.Queue[str | None] = queue.Queue()
462
-
463
- def _drain_stdout() -> None:
464
- try:
465
- for raw_line in iter(stdout.readline, ""):
466
- lines.put(raw_line)
467
- finally:
468
- lines.put(None)
469
-
470
- reader = threading.Thread(target=_drain_stdout, daemon=True)
471
- reader.start()
472
-
473
- captured: list[str] = []
474
- transcript_handle = None
475
- if transcript_path is not None:
476
- transcript_path.parent.mkdir(parents=True, exist_ok=True)
477
- transcript_handle = transcript_path.open("w", encoding="utf-8")
478
- deadline = time.monotonic() + timeout
479
- try:
480
- while True:
481
- remaining = deadline - time.monotonic()
482
- if remaining <= 0:
483
- process.kill()
484
- # Reap the killed child so it doesn't linger as a zombie, and close
485
- # stdin so the feeder thread unblocks. Bounded wait — kill() already
486
- # signalled it.
487
- try:
488
- if process.stdin is not None:
489
- process.stdin.close()
490
- except OSError:
491
- pass
492
- try:
493
- process.wait(timeout=5)
494
- except subprocess.TimeoutExpired:
495
- pass
496
- raise subprocess.TimeoutExpired(invocation, timeout)
497
- try:
498
- line = lines.get(timeout=min(remaining, 1.0))
499
- except queue.Empty:
500
- continue
501
- if line is None:
502
- break
503
- self._emit_stream_line(line)
504
- captured.append(line)
505
- if transcript_handle is not None:
506
- transcript_handle.write(redact_text(line))
507
- transcript_handle.flush()
508
- process.wait()
509
- finally:
510
- if transcript_handle is not None:
511
- transcript_handle.close()
512
- reader.join(timeout=5)
513
-
514
- return subprocess.CompletedProcess(
515
- invocation,
516
- process.returncode if process.returncode is not None else 0,
517
- stdout="".join(captured),
518
- stderr="",
519
- )
520
-
521
- def _cursor_resume_mode(self) -> str:
522
- try:
523
- mode = (load_config(self.project_root).execution.cursor_resume_mode or "off").strip().lower()
524
- except Exception:
525
- mode = "off"
526
- if mode not in {"off", "project", "task"}:
527
- return "off"
528
- return mode
529
-
530
- def _cursor_session_path(self, task_id: str | None = None) -> Path:
531
- if self._cursor_resume_mode() == "task" and task_id:
532
- return self.project_root / ".devcouncil" / "sessions" / f"{task_id}-cursor.json"
533
- return self.project_root / ".devcouncil" / "integrations" / "cursor-session.json"
534
-
535
- def _cursor_resume_chat_id(self, task_id: str | None) -> str | None:
536
- mode = self._cursor_resume_mode()
537
- if mode == "off":
538
- return None
539
- path = self._cursor_session_path(task_id if mode == "task" else None)
540
- if path.exists():
541
- try:
542
- data = json.loads(path.read_text(encoding="utf-8")) or {}
543
- except json.JSONDecodeError:
544
- data = {}
545
- existing_chat_id = str(data.get("chat_id") or "").strip()
546
- if existing_chat_id:
547
- return existing_chat_id
548
- ensured_chat_id = self._ensure_cursor_chat_id()
549
- if not ensured_chat_id:
550
- return None
551
- path.parent.mkdir(parents=True, exist_ok=True)
552
- path.write_text(json.dumps({"chat_id": ensured_chat_id}, indent=2) + "\n", encoding="utf-8")
553
- return ensured_chat_id
554
-
555
- def _ensure_cursor_chat_id(self) -> str | None:
556
- executable = resolve_cursor_agent_executable()
557
- if not executable:
558
- return None
559
- try:
560
- result = subprocess.run(
561
- [executable, "create-chat"],
562
- capture_output=True,
563
- text=True,
564
- encoding="utf-8",
565
- errors="replace",
566
- cwd=self.project_root,
567
- timeout=60,
568
- )
569
- except subprocess.TimeoutExpired:
570
- return None
571
- if result.returncode != 0:
572
- return None
573
- chat_id = (result.stdout or result.stderr or "").strip().splitlines()[-1].strip()
574
- return chat_id or None
575
-
576
- def _effective_timeout(self) -> int:
577
- if self.profile and self.profile.timeout_seconds:
578
- return int(self.profile.timeout_seconds)
579
- return int(self.spec.timeout_seconds or self.timeout_seconds)
580
-
581
- def _invocation(self, command: list[str], prompt: str, instruction_file: Path) -> tuple[list[str], str | None]:
582
- mode = self.spec.input_mode
583
- resolved = [
584
- part.replace("{prompt_file}", str(instruction_file)).replace("{project_root}", str(self.project_root))
585
- for part in command
586
- ]
587
- extra = list(self.profile.extra_args) if (self.profile and self.profile.extra_args) else []
588
-
589
- def _place(base: list[str]) -> list[str]:
590
- """Insert profile extra_args after the base flags but before a trailing prompt
591
- flag (the last token of a baked-in prompt-flag CLI like warp ``--prompt`` /
592
- aider ``--message``), so that flag still binds to the prompt appended after it."""
593
- if extra and base and base[-1].startswith("-"):
594
- return [*base[:-1], *extra, base[-1]]
595
- return [*base, *extra]
596
-
597
- if mode == "stdin":
598
- return _place(resolved), prompt
599
- if mode == "argument":
600
- if any("{prompt}" in part for part in resolved):
601
- return [part.replace("{prompt}", prompt) for part in resolved] + extra, None
602
- prompt_arg = self.spec.prompt_arg
603
- if prompt_arg:
604
- return [*resolved, *extra, prompt_arg, prompt], None
605
- return [*_place(resolved), prompt], None
606
- if mode == "prompt-file":
607
- if "{prompt_file}" in " ".join(command):
608
- return resolved + extra, None
609
- prompt_arg = self.spec.prompt_arg
610
- if prompt_arg:
611
- return [*resolved, *extra, prompt_arg, str(instruction_file)], None
612
- return [*_place(resolved), str(instruction_file)], None
613
- return _place(resolved), prompt
614
-
615
- def _display_invocation(self, invocation: list[str], prompt: str) -> list[str]:
616
- return [part.replace(prompt, "<task prompt>") for part in invocation]
617
-
618
- def _apply_profile_prompt(self, prompt: str) -> str:
619
- if not self.profile:
620
- return prompt
621
- additions = []
622
- if self.profile.prompt_preamble:
623
- additions.append(self.profile.prompt_preamble)
624
- if self.profile.require_explicit_confirmation:
625
- additions.append("Ask for confirmation before any high-risk, out-of-scope, or destructive action.")
626
- if not additions:
627
- return prompt
628
- return "\n\n".join(["# DevCouncil Agent Profile", *additions, prompt])
629
-
630
- def _profile_override_summary(self) -> dict[str, object]:
631
- """Resolved per-profile CLI overrides recorded in the manifest so a
632
- supervisor can see exactly how the profile constrained the invocation."""
633
- if not self.profile:
634
- return {"extra_args": [], "permission_mode": None, "model": None}
635
- return {
636
- "extra_args": list(self.profile.extra_args or []),
637
- "permission_mode": self.profile.permission_mode,
638
- "model": self.profile.model,
639
- }
640
-
641
- def _update_run_manifest(self, run_id: str, **updates: object) -> None:
642
- manifest_path = self.project_root / ".devcouncil" / "runs" / run_id / "agent-run.json"
643
- if not manifest_path.exists():
644
- return
645
- try:
646
- manifest = json.loads(manifest_path.read_text(encoding="utf-8")) or {}
647
- except json.JSONDecodeError:
648
- return
649
- manifest.update(updates)
650
- manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
651
-
652
- def _preview_lines(self, value: str | None, *, limit: int = 20) -> list[str]:
653
- lines = redact_text(value or "").splitlines()
654
- return lines[:limit]
655
-
656
- def _append_manifest_transcript(self, run_id: str, transcript_path: Path) -> None:
657
- self._update_run_manifest(run_id, transcript=str(transcript_path))
658
-
659
- def _write_run_manifest(
660
- self,
661
- run_id: str,
662
- task: Task,
663
- invocation: list[str],
664
- instruction_file: Path,
665
- *,
666
- stream: bool = False,
667
- ) -> Path:
668
- run_dir = self.project_root / ".devcouncil" / "runs" / run_id
669
- run_dir.mkdir(parents=True, exist_ok=True)
670
- manifest_path = run_dir / "agent-run.json"
671
- manifest = {
672
- "run_id": run_id,
673
- "task_id": task.id,
674
- "agent": self.client,
675
- "display_name": self.spec.label,
676
- "profile": self.profile_name,
677
- "profile_overrides": self._profile_override_summary(),
678
- "kind": self.spec.kind,
679
- "command": invocation,
680
- "prompt_file": str(instruction_file),
681
- "planned_files": [planned.model_dump() for planned in task.planned_files],
682
- "allowed_commands": task.allowed_commands,
683
- "expected_tests": task.expected_tests,
684
- "timestamp": datetime.now(timezone.utc).isoformat(),
685
- "stream": stream,
686
- "artifact_version": 1,
687
- "started_at": datetime.now(timezone.utc).isoformat(),
688
- "status": "running",
689
- "transcript": None,
690
- "returncode": None,
691
- "stdout_preview": [],
692
- "stderr_preview": [],
693
- "finished_at": None,
694
- "duration_seconds": None,
695
- }
696
- manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
697
- return manifest_path
698
-
699
- def _write_log(self, task_client: str, result: subprocess.CompletedProcess[str]) -> None:
700
- log_dir = self.project_root / ".devcouncil" / "logs"
701
- log_dir.mkdir(parents=True, exist_ok=True)
702
- log_path = log_dir / f"{task_client}.log"
703
- log_path.write_text(
704
- "\n".join([
705
- f"command_returncode={result.returncode}",
706
- "=== stdout ===",
707
- redact_text(result.stdout or ""),
708
- "=== stderr ===",
709
- redact_text(result.stderr or ""),
710
- ]),
711
- encoding="utf-8",
712
- )
713
-
714
- def _ensure_warp_mcp_config(self, config: dict | None = None) -> Path:
715
- configured_path = (config or {}).get("mcp_config_path") or ".devcouncil/integrations/warp-mcp.json"
716
- path = Path(configured_path).expanduser()
717
- if not path.is_absolute():
718
- path = self.project_root / path
719
- path.parent.mkdir(parents=True, exist_ok=True)
720
- desired = {
721
- "devcouncil": {
722
- "command": "devcouncil",
723
- "args": ["mcp-server"],
724
- "env": {"DEVCOUNCIL_PROJECT_ROOT": str(self.project_root)},
725
- }
726
- }
727
- should_write = not path.exists()
728
- if path.exists():
729
- try:
730
- existing = json.loads(path.read_text(encoding="utf-8")) or {}
731
- except json.JSONDecodeError:
732
- existing = {}
733
- should_write = "mcpServers" in existing and "devcouncil" not in existing
734
- if should_write:
735
- path.write_text(json.dumps(desired, indent=2) + "\n", encoding="utf-8")
736
- return path