devcouncil 0.1.1 → 0.2.0

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 (129) hide show
  1. package/README.md +190 -6
  2. package/package.json +9 -2
  3. package/pyproject.toml +34 -2
  4. package/src/devcouncil/app/config.py +167 -5
  5. package/src/devcouncil/artifacts/graph.py +23 -3
  6. package/src/devcouncil/assets/__init__.py +1 -0
  7. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  8. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  9. package/src/devcouncil/cli/commands/agents.py +292 -0
  10. package/src/devcouncil/cli/commands/artifacts.py +6 -3
  11. package/src/devcouncil/cli/commands/check.py +209 -0
  12. package/src/devcouncil/cli/commands/config.py +43 -4
  13. package/src/devcouncil/cli/commands/cost.py +57 -0
  14. package/src/devcouncil/cli/commands/dashboard.py +6 -1
  15. package/src/devcouncil/cli/commands/doctor.py +221 -21
  16. package/src/devcouncil/cli/commands/evidence.py +48 -0
  17. package/src/devcouncil/cli/commands/go.py +452 -33
  18. package/src/devcouncil/cli/commands/handoff.py +69 -0
  19. package/src/devcouncil/cli/commands/hook.py +124 -15
  20. package/src/devcouncil/cli/commands/init.py +154 -18
  21. package/src/devcouncil/cli/commands/integrate.py +894 -105
  22. package/src/devcouncil/cli/commands/map.py +80 -10
  23. package/src/devcouncil/cli/commands/plan.py +212 -51
  24. package/src/devcouncil/cli/commands/prompt.py +18 -7
  25. package/src/devcouncil/cli/commands/repair.py +40 -23
  26. package/src/devcouncil/cli/commands/report.py +8 -0
  27. package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
  28. package/src/devcouncil/cli/commands/rollback.py +27 -28
  29. package/src/devcouncil/cli/commands/run.py +69 -49
  30. package/src/devcouncil/cli/commands/runs.py +223 -0
  31. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  32. package/src/devcouncil/cli/commands/semantic.py +47 -0
  33. package/src/devcouncil/cli/commands/setup.py +145 -6
  34. package/src/devcouncil/cli/commands/shell.py +73 -0
  35. package/src/devcouncil/cli/commands/skills.py +88 -0
  36. package/src/devcouncil/cli/commands/status.py +25 -1
  37. package/src/devcouncil/cli/commands/trace.py +47 -3
  38. package/src/devcouncil/cli/commands/verify.py +138 -3
  39. package/src/devcouncil/cli/commands/watch.py +9 -9
  40. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  41. package/src/devcouncil/cli/main.py +56 -7
  42. package/src/devcouncil/domain/evidence.py +22 -2
  43. package/src/devcouncil/domain/gap.py +27 -1
  44. package/src/devcouncil/domain/task.py +31 -2
  45. package/src/devcouncil/execution/checkpoints.py +246 -0
  46. package/src/devcouncil/execution/context_builder.py +1 -1
  47. package/src/devcouncil/execution/fs_watcher.py +180 -0
  48. package/src/devcouncil/execution/handoff.py +102 -0
  49. package/src/devcouncil/execution/hook_policy.py +162 -74
  50. package/src/devcouncil/execution/patch.py +59 -10
  51. package/src/devcouncil/execution/permissions.py +17 -24
  52. package/src/devcouncil/execution/policy_engine.py +343 -0
  53. package/src/devcouncil/execution/prompt_builder.py +633 -21
  54. package/src/devcouncil/execution/shell_session.py +225 -0
  55. package/src/devcouncil/execution/task_runner.py +6 -2
  56. package/src/devcouncil/executors/agent_registry.py +575 -0
  57. package/src/devcouncil/executors/coding_cli.py +663 -39
  58. package/src/devcouncil/executors/native/agent.py +121 -20
  59. package/src/devcouncil/gating/checks/clean_git.py +3 -1
  60. package/src/devcouncil/gating/checks/secret_scan_check.py +40 -21
  61. package/src/devcouncil/gating/policy.py +158 -10
  62. package/src/devcouncil/hardware.py +184 -0
  63. package/src/devcouncil/indexing/ast_matcher.py +1 -1
  64. package/src/devcouncil/indexing/lsp.py +45 -4
  65. package/src/devcouncil/indexing/repo_mapper.py +1256 -9
  66. package/src/devcouncil/indexing/semantic_index.py +205 -0
  67. package/src/devcouncil/integrations/actions.py +146 -0
  68. package/src/devcouncil/integrations/check.py +423 -0
  69. package/src/devcouncil/integrations/github_intent.py +142 -0
  70. package/src/devcouncil/integrations/gitnexus.py +35 -0
  71. package/src/devcouncil/integrations/mcp/server.py +1552 -29
  72. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  73. package/src/devcouncil/live/cards.py +161 -19
  74. package/src/devcouncil/live/signals.py +2 -2
  75. package/src/devcouncil/live/transcripts.py +9 -6
  76. package/src/devcouncil/llm/cache.py +10 -6
  77. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  78. package/src/devcouncil/llm/provider.py +515 -34
  79. package/src/devcouncil/llm/router.py +231 -46
  80. package/src/devcouncil/optimization/__init__.py +1 -0
  81. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  82. package/src/devcouncil/planning/correction_manifest.py +303 -0
  83. package/src/devcouncil/planning/critique_service.py +7 -2
  84. package/src/devcouncil/planning/plan_service.py +17 -3
  85. package/src/devcouncil/planning/prompt_enhancer_service.py +82 -1
  86. package/src/devcouncil/planning/spec_service.py +27 -1
  87. package/src/devcouncil/repo/ci_scaffold.py +157 -0
  88. package/src/devcouncil/repo/gitignore.py +123 -0
  89. package/src/devcouncil/repo/sca.py +374 -0
  90. package/src/devcouncil/reporting/json_report.py +11 -1
  91. package/src/devcouncil/reporting/markdown_report.py +15 -0
  92. package/src/devcouncil/skills/__init__.py +19 -0
  93. package/src/devcouncil/skills/library/README.md +46 -0
  94. package/src/devcouncil/skills/library/ai-training.md +50 -0
  95. package/src/devcouncil/skills/library/android.md +50 -0
  96. package/src/devcouncil/skills/library/backend.md +52 -0
  97. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  98. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  99. package/src/devcouncil/skills/library/desktop.md +46 -0
  100. package/src/devcouncil/skills/library/devops.md +48 -0
  101. package/src/devcouncil/skills/library/game-dev.md +46 -0
  102. package/src/devcouncil/skills/library/ios.md +48 -0
  103. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  104. package/src/devcouncil/skills/library/security.md +48 -0
  105. package/src/devcouncil/skills/library/systems.md +48 -0
  106. package/src/devcouncil/skills/library/web.md +47 -0
  107. package/src/devcouncil/skills/library/windows.md +47 -0
  108. package/src/devcouncil/skills/registry.py +330 -0
  109. package/src/devcouncil/storage/db.py +83 -2
  110. package/src/devcouncil/storage/models.py +121 -0
  111. package/src/devcouncil/storage/native.py +557 -0
  112. package/src/devcouncil/storage/repositories.py +137 -75
  113. package/src/devcouncil/telemetry/cost.py +123 -17
  114. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  115. package/src/devcouncil/telemetry/pricing.py +28 -0
  116. package/src/devcouncil/telemetry/traces.py +62 -7
  117. package/src/devcouncil/telemetry/tracker.py +12 -9
  118. package/src/devcouncil/ui/dashboard.py +324 -23
  119. package/src/devcouncil/utils/redaction.py +9 -3
  120. package/src/devcouncil/utils/subprocess_env.py +69 -0
  121. package/src/devcouncil/verification/acceptance_compiler.py +125 -0
  122. package/src/devcouncil/verification/ad_hoc_check.py +129 -0
  123. package/src/devcouncil/verification/diff_coverage.py +353 -0
  124. package/src/devcouncil/verification/next_actions.py +189 -0
  125. package/src/devcouncil/verification/sandbox.py +178 -0
  126. package/src/devcouncil/verification/test_resolver.py +91 -0
  127. package/src/devcouncil/verification/verifier.py +1065 -47
  128. package/uv.lock +205 -64
  129. package/src/devcouncil/indexing/symbol_index.py +0 -0
@@ -1,14 +1,33 @@
1
+ import json
2
+ import os
3
+ import queue
1
4
  import shutil
2
5
  import subprocess
3
- import os
6
+ import sys
7
+ import threading
8
+ import time
9
+ import uuid
10
+ from datetime import datetime, timezone
4
11
  from pathlib import Path
5
12
 
6
13
  from rich.console import Console
7
14
 
8
15
  from devcouncil.domain.requirement import Requirement
9
16
  from devcouncil.domain.task import Task
17
+ from devcouncil.app.config import load_config
10
18
  from devcouncil.execution.executor import Executor, ExecutionResult
11
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
12
31
 
13
32
  console = Console()
14
33
 
@@ -16,34 +35,199 @@ console = Console()
16
35
  class CodingCliExecutor(Executor):
17
36
  """Execute a DevCouncil task by handing it off to an external coding CLI."""
18
37
 
19
- _ALIASES = {
20
- "codex-cli": "codex",
21
- "gemini-cli": "gemini",
22
- "claude-cli": "claude",
23
- "claude-code": "claude",
24
- }
25
-
26
- def __init__(self, project_root: Path, client: str, timeout_seconds: int = 1800):
27
- self.client = self._normalize_client(client)
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
+ ):
28
46
  self.project_root = project_root
47
+ self.client = self._normalize_client(client)
29
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)
30
55
 
31
56
  def _normalize_client(self, client: str) -> str:
32
- normalized = (client or "").strip().lower().replace("_", "-")
33
- return self._ALIASES.get(normalized, normalized)
34
-
35
- def _command(self) -> list[str]:
36
- if self.client == "codex":
37
- return ["codex", "exec", "-"]
38
- if self.client == "gemini":
39
- return ["gemini"]
40
- if self.client == "claude":
41
- return ["claude", "-p"]
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
42
63
  raise ValueError(f"Unsupported coding CLI client: {self.client}")
43
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
+
44
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
+
45
229
  try:
46
- command = self._command()
230
+ command = self._command(task.id)
47
231
  except ValueError as exc:
48
232
  return ExecutionResult(success=False, message=str(exc))
49
233
 
@@ -55,47 +239,463 @@ class CodingCliExecutor(Executor):
55
239
  )
56
240
 
57
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)
58
252
  instruction_file = self.project_root / ".devcouncil" / f"{task.id}-{self.client}-task.md"
59
253
  instruction_file.parent.mkdir(parents=True, exist_ok=True)
60
254
  instruction_file.write_text(prompt, encoding="utf-8")
61
255
 
62
- env = {**dict(os.environ), "DEVCOUNCIL_PROJECT_ROOT": str(self.project_root)}
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
63
259
  log_prefix = f"{task.id}-{self.client}"
260
+ run_id = str(uuid.uuid4())
261
+ self.last_run_id = run_id
64
262
 
65
263
  console.print(f"Starting [bold]{self.client.upper()}[/bold] for task [bold]{task.id}[/bold]...")
66
264
  console.print(f"Task prompt: [dim]{instruction_file}[/dim]")
67
- console.print(f"Command: [dim]{' '.join(command)}[/dim]")
68
265
 
266
+ started = time.monotonic()
69
267
  try:
70
- result = subprocess.run(
71
- command,
72
- input=prompt,
73
- capture_output=True,
74
- text=True,
75
- encoding="utf-8",
76
- errors="replace",
77
- cwd=self.project_root,
78
- env=env,
79
- timeout=self.timeout_seconds,
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,
80
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()
81
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]")
82
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
+ )
83
317
  stderr_preview = (result.stderr or result.stdout or "").strip().splitlines()[:5]
84
- detail = stderr_preview[0] if stderr_preview else "No diagnostics were produced."
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
+ )
85
326
  return ExecutionResult(
86
327
  success=False,
87
328
  message=f"{self.client} exited with code {result.returncode}: {detail}",
88
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
+ )
89
346
  return ExecutionResult(success=True, message=f"{self.client} execution finished.")
90
- except subprocess.TimeoutExpired as exc:
91
- _ = exc
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
+ )
92
361
  return ExecutionResult(
93
362
  success=False,
94
- message=f"{self.client} execution timed out after {self.timeout_seconds}s.",
363
+ message=f"{self.client} execution timed out after {self._effective_timeout()}s.",
95
364
  )
96
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
+ )
97
374
  return ExecutionResult(success=False, message=str(exc))
98
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
+
99
699
  def _write_log(self, task_client: str, result: subprocess.CompletedProcess[str]) -> None:
100
700
  log_dir = self.project_root / ".devcouncil" / "logs"
101
701
  log_dir.mkdir(parents=True, exist_ok=True)
@@ -104,9 +704,33 @@ class CodingCliExecutor(Executor):
104
704
  "\n".join([
105
705
  f"command_returncode={result.returncode}",
106
706
  "=== stdout ===",
107
- result.stdout or "",
707
+ redact_text(result.stdout or ""),
108
708
  "=== stderr ===",
109
- result.stderr or "",
709
+ redact_text(result.stderr or ""),
110
710
  ]),
111
711
  encoding="utf-8",
112
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