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