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.
- package/README.md +190 -6
- package/package.json +9 -2
- package/pyproject.toml +34 -2
- package/src/devcouncil/app/config.py +167 -5
- package/src/devcouncil/artifacts/graph.py +23 -3
- package/src/devcouncil/assets/__init__.py +1 -0
- package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
- package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
- package/src/devcouncil/cli/commands/agents.py +292 -0
- package/src/devcouncil/cli/commands/artifacts.py +6 -3
- package/src/devcouncil/cli/commands/check.py +209 -0
- package/src/devcouncil/cli/commands/config.py +43 -4
- package/src/devcouncil/cli/commands/cost.py +57 -0
- package/src/devcouncil/cli/commands/dashboard.py +6 -1
- package/src/devcouncil/cli/commands/doctor.py +221 -21
- package/src/devcouncil/cli/commands/evidence.py +48 -0
- package/src/devcouncil/cli/commands/go.py +452 -33
- package/src/devcouncil/cli/commands/handoff.py +69 -0
- package/src/devcouncil/cli/commands/hook.py +124 -15
- package/src/devcouncil/cli/commands/init.py +154 -18
- package/src/devcouncil/cli/commands/integrate.py +894 -105
- package/src/devcouncil/cli/commands/map.py +80 -10
- package/src/devcouncil/cli/commands/plan.py +212 -51
- package/src/devcouncil/cli/commands/prompt.py +18 -7
- package/src/devcouncil/cli/commands/repair.py +40 -23
- package/src/devcouncil/cli/commands/report.py +8 -0
- package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
- package/src/devcouncil/cli/commands/rollback.py +27 -28
- package/src/devcouncil/cli/commands/run.py +69 -49
- package/src/devcouncil/cli/commands/runs.py +223 -0
- package/src/devcouncil/cli/commands/scaffold.py +32 -0
- package/src/devcouncil/cli/commands/semantic.py +47 -0
- package/src/devcouncil/cli/commands/setup.py +145 -6
- package/src/devcouncil/cli/commands/shell.py +73 -0
- package/src/devcouncil/cli/commands/skills.py +88 -0
- package/src/devcouncil/cli/commands/status.py +25 -1
- package/src/devcouncil/cli/commands/trace.py +47 -3
- package/src/devcouncil/cli/commands/verify.py +138 -3
- package/src/devcouncil/cli/commands/watch.py +9 -9
- package/src/devcouncil/cli/commands/watch_fs.py +40 -0
- package/src/devcouncil/cli/main.py +56 -7
- package/src/devcouncil/domain/evidence.py +22 -2
- package/src/devcouncil/domain/gap.py +27 -1
- package/src/devcouncil/domain/task.py +31 -2
- package/src/devcouncil/execution/checkpoints.py +246 -0
- package/src/devcouncil/execution/context_builder.py +1 -1
- package/src/devcouncil/execution/fs_watcher.py +180 -0
- package/src/devcouncil/execution/handoff.py +102 -0
- package/src/devcouncil/execution/hook_policy.py +162 -74
- package/src/devcouncil/execution/patch.py +59 -10
- package/src/devcouncil/execution/permissions.py +17 -24
- package/src/devcouncil/execution/policy_engine.py +343 -0
- package/src/devcouncil/execution/prompt_builder.py +633 -21
- package/src/devcouncil/execution/shell_session.py +225 -0
- package/src/devcouncil/execution/task_runner.py +6 -2
- package/src/devcouncil/executors/agent_registry.py +575 -0
- package/src/devcouncil/executors/coding_cli.py +663 -39
- package/src/devcouncil/executors/native/agent.py +121 -20
- package/src/devcouncil/gating/checks/clean_git.py +3 -1
- package/src/devcouncil/gating/checks/secret_scan_check.py +40 -21
- package/src/devcouncil/gating/policy.py +158 -10
- package/src/devcouncil/hardware.py +184 -0
- package/src/devcouncil/indexing/ast_matcher.py +1 -1
- package/src/devcouncil/indexing/lsp.py +45 -4
- package/src/devcouncil/indexing/repo_mapper.py +1256 -9
- package/src/devcouncil/indexing/semantic_index.py +205 -0
- package/src/devcouncil/integrations/actions.py +146 -0
- package/src/devcouncil/integrations/check.py +423 -0
- package/src/devcouncil/integrations/github_intent.py +142 -0
- package/src/devcouncil/integrations/gitnexus.py +35 -0
- package/src/devcouncil/integrations/mcp/server.py +1552 -29
- package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
- package/src/devcouncil/live/cards.py +161 -19
- package/src/devcouncil/live/signals.py +2 -2
- package/src/devcouncil/live/transcripts.py +9 -6
- package/src/devcouncil/llm/cache.py +10 -6
- package/src/devcouncil/llm/model_defaults.yaml +44 -0
- package/src/devcouncil/llm/provider.py +515 -34
- package/src/devcouncil/llm/router.py +231 -46
- package/src/devcouncil/optimization/__init__.py +1 -0
- package/src/devcouncil/optimization/gepa_agent.py +318 -0
- package/src/devcouncil/planning/correction_manifest.py +303 -0
- package/src/devcouncil/planning/critique_service.py +7 -2
- package/src/devcouncil/planning/plan_service.py +17 -3
- package/src/devcouncil/planning/prompt_enhancer_service.py +82 -1
- package/src/devcouncil/planning/spec_service.py +27 -1
- package/src/devcouncil/repo/ci_scaffold.py +157 -0
- package/src/devcouncil/repo/gitignore.py +123 -0
- package/src/devcouncil/repo/sca.py +374 -0
- package/src/devcouncil/reporting/json_report.py +11 -1
- package/src/devcouncil/reporting/markdown_report.py +15 -0
- package/src/devcouncil/skills/__init__.py +19 -0
- package/src/devcouncil/skills/library/README.md +46 -0
- package/src/devcouncil/skills/library/ai-training.md +50 -0
- package/src/devcouncil/skills/library/android.md +50 -0
- package/src/devcouncil/skills/library/backend.md +52 -0
- package/src/devcouncil/skills/library/core-engineering.md +95 -0
- package/src/devcouncil/skills/library/data-engineering.md +47 -0
- package/src/devcouncil/skills/library/desktop.md +46 -0
- package/src/devcouncil/skills/library/devops.md +48 -0
- package/src/devcouncil/skills/library/game-dev.md +46 -0
- package/src/devcouncil/skills/library/ios.md +48 -0
- package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
- package/src/devcouncil/skills/library/security.md +48 -0
- package/src/devcouncil/skills/library/systems.md +48 -0
- package/src/devcouncil/skills/library/web.md +47 -0
- package/src/devcouncil/skills/library/windows.md +47 -0
- package/src/devcouncil/skills/registry.py +330 -0
- package/src/devcouncil/storage/db.py +83 -2
- package/src/devcouncil/storage/models.py +121 -0
- package/src/devcouncil/storage/native.py +557 -0
- package/src/devcouncil/storage/repositories.py +137 -75
- package/src/devcouncil/telemetry/cost.py +123 -17
- package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
- package/src/devcouncil/telemetry/pricing.py +28 -0
- package/src/devcouncil/telemetry/traces.py +62 -7
- package/src/devcouncil/telemetry/tracker.py +12 -9
- package/src/devcouncil/ui/dashboard.py +324 -23
- package/src/devcouncil/utils/redaction.py +9 -3
- package/src/devcouncil/utils/subprocess_env.py +69 -0
- package/src/devcouncil/verification/acceptance_compiler.py +125 -0
- package/src/devcouncil/verification/ad_hoc_check.py +129 -0
- package/src/devcouncil/verification/diff_coverage.py +353 -0
- package/src/devcouncil/verification/next_actions.py +189 -0
- package/src/devcouncil/verification/sandbox.py +178 -0
- package/src/devcouncil/verification/test_resolver.py +91 -0
- package/src/devcouncil/verification/verifier.py +1065 -47
- package/uv.lock +205 -64
- 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
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
if
|
|
37
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
91
|
-
|
|
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.
|
|
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
|