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.
- package/README.md +201 -6
- package/package.json +9 -2
- package/pyproject.toml +34 -2
- package/src/devcouncil/app/config.py +348 -12
- package/src/devcouncil/app/orchestrator.py +10 -6
- package/src/devcouncil/app/state_machine.py +4 -0
- package/src/devcouncil/artifacts/graph.py +32 -5
- 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 +220 -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/design.py +186 -0
- package/src/devcouncil/cli/commands/doctor.py +379 -22
- package/src/devcouncil/cli/commands/evidence.py +48 -0
- package/src/devcouncil/cli/commands/go.py +532 -33
- package/src/devcouncil/cli/commands/handoff.py +69 -0
- package/src/devcouncil/cli/commands/hook.py +296 -15
- package/src/devcouncil/cli/commands/init.py +161 -20
- package/src/devcouncil/cli/commands/integrate.py +1371 -124
- package/src/devcouncil/cli/commands/logs.py +106 -0
- package/src/devcouncil/cli/commands/map.py +80 -10
- package/src/devcouncil/cli/commands/okf.py +245 -0
- package/src/devcouncil/cli/commands/plan.py +256 -55
- package/src/devcouncil/cli/commands/prompt.py +18 -7
- package/src/devcouncil/cli/commands/repair.py +50 -24
- 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 +195 -54
- 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 +267 -0
- package/src/devcouncil/cli/commands/status.py +30 -15
- package/src/devcouncil/cli/commands/trace.py +47 -3
- package/src/devcouncil/cli/commands/verify.py +144 -3
- package/src/devcouncil/cli/commands/watch.py +32 -12
- package/src/devcouncil/cli/commands/watch_fs.py +40 -0
- package/src/devcouncil/cli/main.py +91 -7
- package/src/devcouncil/domain/evidence.py +29 -2
- package/src/devcouncil/domain/gap.py +27 -1
- package/src/devcouncil/domain/task.py +31 -2
- package/src/devcouncil/execution/checkpoints.py +256 -0
- package/src/devcouncil/execution/context_builder.py +1 -1
- package/src/devcouncil/execution/fs_watcher.py +205 -0
- package/src/devcouncil/execution/handoff.py +102 -0
- package/src/devcouncil/execution/hook_policy.py +162 -74
- package/src/devcouncil/execution/patch.py +65 -10
- package/src/devcouncil/execution/permissions.py +24 -24
- package/src/devcouncil/execution/policy_engine.py +350 -0
- package/src/devcouncil/execution/prompt_builder.py +751 -23
- package/src/devcouncil/execution/shell_session.py +231 -0
- package/src/devcouncil/execution/task_runner.py +24 -9
- package/src/devcouncil/executors/agent_registry.py +596 -0
- package/src/devcouncil/executors/coding_cli.py +791 -39
- package/src/devcouncil/executors/mini_swe.py +6 -0
- package/src/devcouncil/executors/native/agent.py +135 -19
- package/src/devcouncil/executors/openhands.py +6 -0
- package/src/devcouncil/gating/checks/clean_git.py +3 -1
- package/src/devcouncil/gating/checks/secret_scan_check.py +47 -21
- package/src/devcouncil/gating/policy.py +190 -11
- package/src/devcouncil/hardware.py +184 -0
- package/src/devcouncil/indexing/ast_matcher.py +17 -7
- package/src/devcouncil/indexing/lsp.py +45 -4
- package/src/devcouncil/indexing/repo_mapper.py +1284 -15
- package/src/devcouncil/indexing/semantic_index.py +221 -0
- package/src/devcouncil/integrations/actions.py +166 -0
- package/src/devcouncil/integrations/check.py +426 -0
- package/src/devcouncil/integrations/claude_assets.py +444 -0
- package/src/devcouncil/integrations/code_review_graph.py +13 -2
- package/src/devcouncil/integrations/github_intent.py +149 -0
- package/src/devcouncil/integrations/gitnexus.py +45 -2
- package/src/devcouncil/integrations/mcp/server.py +1944 -32
- package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
- package/src/devcouncil/integrations/pr_comments.py +9 -0
- package/src/devcouncil/knowledge/__init__.py +23 -0
- package/src/devcouncil/knowledge/design.py +374 -0
- package/src/devcouncil/knowledge/design_conformance.py +317 -0
- package/src/devcouncil/knowledge/fetch.py +223 -0
- package/src/devcouncil/knowledge/frontmatter.py +51 -0
- package/src/devcouncil/knowledge/okf.py +202 -0
- package/src/devcouncil/knowledge/skill_bridge.py +96 -0
- package/src/devcouncil/knowledge/sources.py +239 -0
- package/src/devcouncil/live/cards.py +181 -25
- package/src/devcouncil/live/repair_prompt.py +29 -6
- package/src/devcouncil/live/reviewer.py +72 -13
- package/src/devcouncil/live/signals.py +2 -2
- package/src/devcouncil/live/summary.py +18 -8
- package/src/devcouncil/live/transcripts.py +47 -11
- package/src/devcouncil/llm/cache.py +20 -8
- package/src/devcouncil/llm/model_defaults.yaml +44 -0
- package/src/devcouncil/llm/provider.py +617 -49
- package/src/devcouncil/llm/router.py +337 -53
- package/src/devcouncil/optimization/__init__.py +1 -0
- package/src/devcouncil/optimization/gepa_agent.py +318 -0
- package/src/devcouncil/optimization/skillopt.py +673 -0
- package/src/devcouncil/planning/arbiter_service.py +10 -2
- package/src/devcouncil/planning/correction_manifest.py +346 -0
- package/src/devcouncil/planning/critique_service.py +16 -4
- package/src/devcouncil/planning/plan_service.py +86 -6
- package/src/devcouncil/planning/prompt_enhancer_service.py +206 -1
- package/src/devcouncil/planning/repair_service.py +8 -2
- package/src/devcouncil/planning/spec_service.py +37 -3
- package/src/devcouncil/repo/ci_scaffold.py +165 -0
- package/src/devcouncil/repo/gitignore.py +123 -0
- package/src/devcouncil/repo/sca.py +384 -0
- package/src/devcouncil/reporting/json_report.py +22 -1
- package/src/devcouncil/reporting/markdown_report.py +29 -1
- package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
- package/src/devcouncil/reporting/okf_html.py +323 -0
- package/src/devcouncil/reporting/report_builder.py +18 -1
- 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 +408 -0
- package/src/devcouncil/storage/db.py +140 -3
- package/src/devcouncil/storage/models.py +125 -0
- package/src/devcouncil/storage/native.py +559 -0
- package/src/devcouncil/storage/repositories.py +157 -78
- package/src/devcouncil/telemetry/cost.py +123 -17
- package/src/devcouncil/telemetry/logging_setup.py +244 -0
- package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
- package/src/devcouncil/telemetry/pricing.py +28 -0
- package/src/devcouncil/telemetry/stages.py +141 -0
- package/src/devcouncil/telemetry/traces.py +62 -7
- package/src/devcouncil/telemetry/tracker.py +24 -10
- package/src/devcouncil/ui/dashboard.py +393 -28
- package/src/devcouncil/utils/redaction.py +9 -3
- package/src/devcouncil/utils/subprocess_env.py +69 -0
- package/src/devcouncil/verification/acceptance_compiler.py +253 -0
- package/src/devcouncil/verification/ad_hoc_check.py +135 -0
- package/src/devcouncil/verification/diff_coverage.py +353 -0
- package/src/devcouncil/verification/implementation_reviewer.py +11 -2
- package/src/devcouncil/verification/next_actions.py +189 -0
- package/src/devcouncil/verification/sandbox.py +181 -0
- package/src/devcouncil/verification/test_resolver.py +91 -0
- package/src/devcouncil/verification/verifier.py +1549 -143
- package/uv.lock +205 -64
- 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
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
if
|
|
37
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
91
|
-
|
|
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.
|
|
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
|