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,3 +1,4 @@
|
|
|
1
|
+
import logging
|
|
1
2
|
import subprocess
|
|
2
3
|
import sys
|
|
3
4
|
from pathlib import Path
|
|
@@ -8,6 +9,7 @@ from devcouncil.execution.executor import Executor, ExecutionResult
|
|
|
8
9
|
from devcouncil.execution.prompt_builder import PromptBuilder
|
|
9
10
|
|
|
10
11
|
console = Console()
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
11
13
|
|
|
12
14
|
class MiniSWEExecutor(Executor):
|
|
13
15
|
def __init__(self, project_root: Path):
|
|
@@ -22,6 +24,7 @@ class MiniSWEExecutor(Executor):
|
|
|
22
24
|
instruction_file.parent.mkdir(parents=True, exist_ok=True)
|
|
23
25
|
instruction_file.write_text(task_prompt, encoding="utf-8")
|
|
24
26
|
|
|
27
|
+
logger.info("mini-SWE-agent starting for %s", task.id)
|
|
25
28
|
console.print(f"Starting [bold]mini-SWE-agent[/bold] for task {task.id}...")
|
|
26
29
|
|
|
27
30
|
# In a real implementation, we'd invoke the agent CLI
|
|
@@ -50,10 +53,13 @@ class MiniSWEExecutor(Executor):
|
|
|
50
53
|
)
|
|
51
54
|
self._write_log(task.id, result)
|
|
52
55
|
if result.returncode != 0:
|
|
56
|
+
logger.error("mini-SWE-agent exited %s for %s", result.returncode, task.id)
|
|
53
57
|
console.print(f"[red]mini-SWE-agent exited with {result.returncode}.[/red]")
|
|
54
58
|
return ExecutionResult(success=False, message='Execution failed')
|
|
59
|
+
logger.info("mini-SWE-agent finished for %s", task.id)
|
|
55
60
|
return ExecutionResult(success=True, message='Execution successful')
|
|
56
61
|
except Exception as e:
|
|
62
|
+
logger.exception("mini-SWE-agent error for %s: %s", task.id, e)
|
|
57
63
|
console.print(f"[red]Error running mini-SWE-agent: {e}[/red]")
|
|
58
64
|
return ExecutionResult(success=False, message='Execution failed')
|
|
59
65
|
|
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
from typing import List, Dict, Any
|
|
2
|
+
import asyncio
|
|
3
|
+
import logging
|
|
2
4
|
from rich.console import Console
|
|
3
5
|
from pydantic import BaseModel
|
|
4
6
|
from devcouncil.domain.task import Task
|
|
5
7
|
from devcouncil.domain.requirement import Requirement
|
|
6
8
|
from devcouncil.execution.executor import Executor, ExecutionResult
|
|
7
|
-
from devcouncil.llm.router import ModelRouter
|
|
9
|
+
from devcouncil.llm.router import ModelRouter, StructuredOutputError
|
|
8
10
|
from devcouncil.execution.task_runner import TaskRunner
|
|
9
11
|
from devcouncil.execution.context_builder import ContextBuilder
|
|
12
|
+
from devcouncil.execution.prompt_builder import PromptBuilder
|
|
10
13
|
from devcouncil.execution.paths import resolve_project_path
|
|
14
|
+
from devcouncil.app.errors import ExecutionError
|
|
11
15
|
|
|
12
16
|
console = Console()
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
# Resilience bounds for the preview native loop.
|
|
20
|
+
MAX_AGENT_STEPS = 10
|
|
21
|
+
MAX_STRUCTURED_FAILURES = 2 # model can't produce a valid action -> give up cleanly
|
|
22
|
+
MAX_CONSECUTIVE_PATCH_FAILURES = 3 # stop spinning on a patch the model can't fix
|
|
13
23
|
|
|
14
24
|
class ToolCall(BaseModel):
|
|
15
25
|
tool: str
|
|
@@ -24,23 +34,41 @@ class NativeAgent(Executor):
|
|
|
24
34
|
def __init__(self, router: ModelRouter, task_runner: TaskRunner):
|
|
25
35
|
self.router = router
|
|
26
36
|
self.task_runner = task_runner
|
|
37
|
+
# ContextBuilder is retained only for the cheap list_files file listing; the
|
|
38
|
+
# implementation context itself uses the budgeted PromptBuilder so the native
|
|
39
|
+
# executor gets the same repo-map orientation, symbol outlines, dependents and
|
|
40
|
+
# context-window budgeting as the CLI executors (rather than a flat JSON dump).
|
|
27
41
|
self.context_builder = ContextBuilder(task_runner.project_root)
|
|
42
|
+
self.prompt_builder = PromptBuilder(task_runner.project_root)
|
|
43
|
+
|
|
44
|
+
def run_task(self, task: Task, requirements: List[Requirement]) -> ExecutionResult:
|
|
45
|
+
"""Run the preview native executor behind the normal synchronous executor contract."""
|
|
46
|
+
return asyncio.run(self._run_task_async(task, requirements))
|
|
28
47
|
|
|
29
|
-
async def
|
|
48
|
+
async def _run_task_async(self, task: Task, requirements: List[Requirement]) -> ExecutionResult:
|
|
49
|
+
logger.info("Native agent starting for %s (max_steps=%d)", task.id, MAX_AGENT_STEPS)
|
|
30
50
|
console.print(f"Starting [bold]Native Executor[/bold] for task {task.id}...")
|
|
51
|
+
console.print("[yellow]Native executor is preview quality; DevCouncil verification remains the completion gate.[/yellow]")
|
|
31
52
|
|
|
32
|
-
# 1. Gather rich context
|
|
33
|
-
|
|
34
|
-
|
|
53
|
+
# 1. Gather rich context (budgeted; includes repo-map orientation + symbol outlines)
|
|
54
|
+
context_block = self.prompt_builder.build_task_prompt(task, requirements)
|
|
55
|
+
from devcouncil.planning.correction_manifest import load_latest_correction_manifest
|
|
56
|
+
|
|
57
|
+
correction = load_latest_correction_manifest(self.task_runner.project_root, task.id)
|
|
58
|
+
correction_block = ""
|
|
59
|
+
if correction is not None:
|
|
60
|
+
correction_block = f"\nCorrection Manifest:\n{correction.model_dump_json(indent=2)}\n"
|
|
61
|
+
|
|
35
62
|
system_prompt = f"""
|
|
36
63
|
You are the DevCouncil Native Agent. Your goal is to implement the provided task.
|
|
37
64
|
Current Project Context:
|
|
38
|
-
{
|
|
65
|
+
{context_block}
|
|
66
|
+
{correction_block}
|
|
39
67
|
|
|
40
68
|
You have access to the following tools:
|
|
41
69
|
- read_file(path: str)
|
|
42
70
|
- list_files()
|
|
43
|
-
- apply_patch(patch: str)
|
|
71
|
+
- apply_patch(patch: str) OR apply_patch(path: str, content: str) as a fallback when a valid unified diff cannot be produced
|
|
44
72
|
- run_command(command: str)
|
|
45
73
|
|
|
46
74
|
Rules:
|
|
@@ -54,22 +82,71 @@ Rules:
|
|
|
54
82
|
# Initial task prompt
|
|
55
83
|
messages.append({"role": "user", "content": f"Begin implementing task {task.id} based on the context provided."})
|
|
56
84
|
|
|
57
|
-
#
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
85
|
+
# Bounded tool loop. Counters let us fail a single task cleanly instead of
|
|
86
|
+
# crashing the whole run (structured-output faults) or spinning on an
|
|
87
|
+
# unfixable patch.
|
|
88
|
+
structured_failures = 0
|
|
89
|
+
consecutive_patch_failures = 0
|
|
90
|
+
for step in range(MAX_AGENT_STEPS):
|
|
91
|
+
try:
|
|
92
|
+
action = await self.router.complete_structured(
|
|
93
|
+
role="native_agent",
|
|
94
|
+
messages=messages,
|
|
95
|
+
schema=AgentAction,
|
|
96
|
+
)
|
|
97
|
+
except StructuredOutputError as exc:
|
|
98
|
+
# The model could not produce a valid action even after healing/retry.
|
|
99
|
+
# native_agent has no fallback by design, so handle it here rather than
|
|
100
|
+
# letting it propagate and abort the entire `dev go` run.
|
|
101
|
+
structured_failures += 1
|
|
102
|
+
logger.warning("Native agent step %d: unparseable action (%d/%d): %s", step + 1, structured_failures, MAX_STRUCTURED_FAILURES, exc)
|
|
103
|
+
console.print(f"[red]Native agent could not parse a valid action: {exc}[/red]")
|
|
104
|
+
if structured_failures >= MAX_STRUCTURED_FAILURES:
|
|
105
|
+
logger.error("Native agent giving up on %s after %d unparseable responses", task.id, structured_failures)
|
|
106
|
+
return ExecutionResult(
|
|
107
|
+
success=False,
|
|
108
|
+
message=f"Native agent gave up after {structured_failures} unparseable responses.",
|
|
109
|
+
)
|
|
110
|
+
messages.append({
|
|
111
|
+
"role": "user",
|
|
112
|
+
"content": (
|
|
113
|
+
"[System] Your previous response was not valid JSON for the "
|
|
114
|
+
"AgentAction schema. Reply with a single valid JSON object only "
|
|
115
|
+
"(fields: thought, tool_calls, finish) — no prose, no fences."
|
|
116
|
+
),
|
|
117
|
+
})
|
|
118
|
+
continue
|
|
119
|
+
structured_failures = 0
|
|
120
|
+
|
|
121
|
+
logger.info(
|
|
122
|
+
"Native agent %s step %d/%d: %d tool call(s)%s",
|
|
123
|
+
task.id, step + 1, MAX_AGENT_STEPS, len(action.tool_calls),
|
|
124
|
+
" finish=True" if action.finish else "",
|
|
63
125
|
)
|
|
64
|
-
|
|
65
126
|
console.print(f"\n[bold]Step {step+1}:[/bold] {action.thought}")
|
|
66
|
-
|
|
127
|
+
|
|
128
|
+
# Record the agent's own turn so subsequent steps see what it already did.
|
|
129
|
+
# Without this the model only sees tool RESULTS, not its prior actions, and
|
|
130
|
+
# tends to repeat itself and never converge within the step budget.
|
|
131
|
+
messages.append({"role": "assistant", "content": action.model_dump_json()})
|
|
132
|
+
|
|
67
133
|
if action.finish:
|
|
134
|
+
logger.info("Native agent signaled completion for %s at step %d", task.id, step + 1)
|
|
68
135
|
console.print("[green]Native agent signaled completion.[/green]")
|
|
69
|
-
return ExecutionResult(success=True, message="Agent signaled completion")
|
|
136
|
+
return ExecutionResult(success=True, message="Agent signaled completion; pending DevCouncil verification")
|
|
137
|
+
|
|
138
|
+
if not action.tool_calls:
|
|
139
|
+
# No action and not finished — nudge instead of silently burning a step.
|
|
140
|
+
messages.append({"role": "user", "content": (
|
|
141
|
+
"[System] You produced no tool_calls and did not finish. Call a tool "
|
|
142
|
+
"(read_file/list_files/apply_patch/run_command) to make progress, or set "
|
|
143
|
+
"finish=true if the task is complete."
|
|
144
|
+
)})
|
|
145
|
+
continue
|
|
70
146
|
|
|
71
147
|
for tool_call in action.tool_calls:
|
|
72
148
|
result_summary = ""
|
|
149
|
+
logger.debug("Native agent tool call: %s args=%s", tool_call.tool, list(tool_call.args))
|
|
73
150
|
try:
|
|
74
151
|
if tool_call.tool == "read_file":
|
|
75
152
|
path = tool_call.args["path"]
|
|
@@ -90,8 +167,26 @@ Rules:
|
|
|
90
167
|
elif tool_call.tool == "write_file":
|
|
91
168
|
raise PermissionError("write_file is disabled for the native executor; use apply_patch.")
|
|
92
169
|
elif tool_call.tool == "apply_patch":
|
|
93
|
-
|
|
94
|
-
|
|
170
|
+
if "path" in tool_call.args and "content" in tool_call.args:
|
|
171
|
+
# Fallback for when the model can't produce a valid unified
|
|
172
|
+
# diff. Routes through write_file, which enforces the same
|
|
173
|
+
# planned-files permission check — no widening of scope.
|
|
174
|
+
self.task_runner.write_file(
|
|
175
|
+
tool_call.args["path"], tool_call.args["content"], task
|
|
176
|
+
)
|
|
177
|
+
consecutive_patch_failures = 0
|
|
178
|
+
result_summary = f"Wrote {tool_call.args['path']} via path+content fallback."
|
|
179
|
+
else:
|
|
180
|
+
patch = tool_call.args.get("patch", "")
|
|
181
|
+
if not patch or not patch.strip():
|
|
182
|
+
raise ExecutionError(
|
|
183
|
+
"Empty patch. Provide a unified git diff beginning with "
|
|
184
|
+
"'diff --git a/<path> b/<path>', then '--- a/<path>' (or "
|
|
185
|
+
"'--- /dev/null' for a new file), '+++ b/<path>', and '@@' hunks."
|
|
186
|
+
)
|
|
187
|
+
self.task_runner.apply_patch(patch, task)
|
|
188
|
+
consecutive_patch_failures = 0
|
|
189
|
+
result_summary = "Successfully applied patch."
|
|
95
190
|
elif tool_call.tool == "run_command":
|
|
96
191
|
cmd_result = self.task_runner.run_command(tool_call.args["command"], task)
|
|
97
192
|
result_summary = f"Command finished with exit code {cmd_result.exit_code}."
|
|
@@ -100,8 +195,29 @@ Rules:
|
|
|
100
195
|
|
|
101
196
|
messages.append({"role": "user", "content": f"[Tool Result] '{tool_call.tool}': {result_summary}"})
|
|
102
197
|
except Exception as e:
|
|
198
|
+
logger.warning("Native agent tool %s failed for %s: %s", tool_call.tool, task.id, e)
|
|
103
199
|
console.print(f"[red]Error executing tool {tool_call.tool}: {e}[/red]")
|
|
104
|
-
|
|
200
|
+
if tool_call.tool == "apply_patch":
|
|
201
|
+
consecutive_patch_failures += 1
|
|
202
|
+
if consecutive_patch_failures >= MAX_CONSECUTIVE_PATCH_FAILURES:
|
|
203
|
+
logger.error("Native agent giving up on %s after %d consecutive patch failures", task.id, consecutive_patch_failures)
|
|
204
|
+
return ExecutionResult(
|
|
205
|
+
success=False,
|
|
206
|
+
message=(
|
|
207
|
+
f"Native agent failed to apply a patch "
|
|
208
|
+
f"{consecutive_patch_failures} times in a row."
|
|
209
|
+
),
|
|
210
|
+
)
|
|
211
|
+
messages.append({"role": "user", "content": (
|
|
212
|
+
f"[Tool Error] 'apply_patch' failed: {e}\n"
|
|
213
|
+
"Re-read the target file, match the existing context lines EXACTLY, "
|
|
214
|
+
"and do NOT resubmit the same patch. If you cannot produce a valid "
|
|
215
|
+
"unified diff, call apply_patch with 'path' and 'content' instead to "
|
|
216
|
+
"write the whole file."
|
|
217
|
+
)})
|
|
218
|
+
else:
|
|
219
|
+
messages.append({"role": "user", "content": f"[Tool Error] '{tool_call.tool}' failed: {e}"})
|
|
105
220
|
|
|
221
|
+
logger.warning("Native agent reached max step limit (%d) for %s", MAX_AGENT_STEPS, task.id)
|
|
106
222
|
console.print("[red]Native agent reached maximum step limit.[/red]")
|
|
107
223
|
return ExecutionResult(success=False, message="Reached maximum step limit")
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import logging
|
|
1
2
|
import subprocess
|
|
2
3
|
from pathlib import Path
|
|
3
4
|
from rich.console import Console
|
|
@@ -7,6 +8,7 @@ from devcouncil.execution.executor import Executor, ExecutionResult
|
|
|
7
8
|
from devcouncil.execution.prompt_builder import PromptBuilder
|
|
8
9
|
|
|
9
10
|
console = Console()
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
10
12
|
|
|
11
13
|
class OpenHandsExecutor(Executor):
|
|
12
14
|
def __init__(self, project_root: Path):
|
|
@@ -16,6 +18,7 @@ class OpenHandsExecutor(Executor):
|
|
|
16
18
|
builder = PromptBuilder(self.project_root)
|
|
17
19
|
task_prompt = builder.build_task_prompt(task, requirements)
|
|
18
20
|
|
|
21
|
+
logger.info("OpenHands starting for %s", task.id)
|
|
19
22
|
console.print(f"Starting [bold]OpenHands[/bold] for task {task.id}...")
|
|
20
23
|
|
|
21
24
|
# OpenHands often expects a workspace mount and an instruction.
|
|
@@ -48,10 +51,13 @@ class OpenHandsExecutor(Executor):
|
|
|
48
51
|
)
|
|
49
52
|
self._write_log(task.id, result)
|
|
50
53
|
if result.returncode != 0:
|
|
54
|
+
logger.error("OpenHands exited %s for %s", result.returncode, task.id)
|
|
51
55
|
console.print(f"[red]OpenHands exited with {result.returncode}.[/red]")
|
|
52
56
|
return ExecutionResult(success=False, message=f"Exited with code {result.returncode}")
|
|
57
|
+
logger.info("OpenHands finished for %s", task.id)
|
|
53
58
|
return ExecutionResult(success=True, message="Completed successfully")
|
|
54
59
|
except Exception as e:
|
|
60
|
+
logger.exception("OpenHands error for %s: %s", task.id, e)
|
|
55
61
|
console.print(f"[red]Error running OpenHands: {e}[/red]")
|
|
56
62
|
return ExecutionResult(success=False, message=str(e))
|
|
57
63
|
|
|
@@ -9,7 +9,9 @@ class CleanGitCheck:
|
|
|
9
9
|
|
|
10
10
|
def _is_runtime_state(self, line: str) -> bool:
|
|
11
11
|
path = line[3:].strip().replace("\\", "/")
|
|
12
|
-
|
|
12
|
+
# DevCouncil manages the root .gitignore itself (ensure_gitignore runs on
|
|
13
|
+
# init and before every task), so it must not block execution.
|
|
14
|
+
return path.startswith(".devcouncil/") or path == ".gitignore"
|
|
13
15
|
|
|
14
16
|
def check(self, project_root, task_id: str) -> list[Gap]:
|
|
15
17
|
try:
|
|
@@ -1,34 +1,60 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import re
|
|
1
3
|
from typing import List
|
|
2
4
|
from devcouncil.domain.gap import Gap
|
|
3
5
|
from devcouncil.utils.redaction import SECRET_PATTERNS, redact_string
|
|
4
6
|
|
|
7
|
+
logger = logging.getLogger(__name__)
|
|
8
|
+
|
|
9
|
+
# Captures the new-file starting line from a unified-diff hunk header (@@ -a,b +c,d @@).
|
|
10
|
+
_HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")
|
|
11
|
+
|
|
12
|
+
|
|
5
13
|
class SecretScanner:
|
|
6
14
|
"""Scans code diffs for potential secrets (API keys, tokens, etc.)."""
|
|
7
|
-
|
|
15
|
+
|
|
8
16
|
def scan_diff(self, diff_content: str, task_id: str) -> List[Gap]:
|
|
9
|
-
gaps = []
|
|
10
|
-
lines = diff_content.splitlines()
|
|
17
|
+
gaps: List[Gap] = []
|
|
11
18
|
current_file = "unknown_file"
|
|
12
|
-
|
|
13
|
-
|
|
19
|
+
new_line_no = 0 # line number in the new file, tracked across hunks
|
|
20
|
+
counter = 0 # ensures unique gap ids
|
|
21
|
+
|
|
22
|
+
for line in diff_content.splitlines():
|
|
14
23
|
if line.startswith("+++ b/"):
|
|
15
24
|
current_file = line[6:]
|
|
16
25
|
continue
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
26
|
+
if line.startswith("+++") or line.startswith("---") or line.startswith("diff "):
|
|
27
|
+
continue
|
|
28
|
+
hunk = _HUNK_RE.match(line)
|
|
29
|
+
if hunk:
|
|
30
|
+
new_line_no = int(hunk.group(1))
|
|
31
|
+
continue
|
|
32
|
+
if line.startswith("-"):
|
|
33
|
+
continue # removed line — does not advance the new-file counter
|
|
34
|
+
if line.startswith("+"):
|
|
35
|
+
for key_type, pattern in SECRET_PATTERNS.items():
|
|
36
|
+
if pattern.search(line):
|
|
37
|
+
counter += 1
|
|
38
|
+
logger.warning(
|
|
39
|
+
"Potential %s secret detected in %s:%d (task %s)",
|
|
40
|
+
key_type, current_file, new_line_no, task_id,
|
|
41
|
+
)
|
|
42
|
+
gaps.append(Gap(
|
|
43
|
+
id=f"GAP-{task_id}-SECRET-{key_type.upper()}-{new_line_no}-{counter}",
|
|
44
|
+
severity="critical",
|
|
45
|
+
gap_type="security_risk",
|
|
46
|
+
task_id=task_id,
|
|
47
|
+
description=f"Potential {key_type} found in {current_file}:{new_line_no}.",
|
|
48
|
+
evidence=[redact_string(line.strip())],
|
|
49
|
+
recommended_fix="Remove the secret and use environment variables or a secret manager.",
|
|
50
|
+
blocking=True,
|
|
51
|
+
# Populate the routing fields so the security NextAction points
|
|
52
|
+
# the agent straight at the file:line instead of forcing a re-grep.
|
|
53
|
+
file=current_file,
|
|
54
|
+
line=new_line_no,
|
|
55
|
+
))
|
|
56
|
+
new_line_no += 1
|
|
20
57
|
continue
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if pattern.search(line):
|
|
24
|
-
gaps.append(Gap(
|
|
25
|
-
id=f"GAP-{task_id}-SECRET-{key_type.upper()}-{i}",
|
|
26
|
-
severity="critical",
|
|
27
|
-
gap_type="security_risk",
|
|
28
|
-
task_id=task_id,
|
|
29
|
-
description=f"Potential {key_type} found in {current_file} (diff line {i+1}).",
|
|
30
|
-
evidence=[redact_string(line.strip())],
|
|
31
|
-
recommended_fix="Remove the secret and use environment variables or a secret manager.",
|
|
32
|
-
blocking=True
|
|
33
|
-
))
|
|
58
|
+
# Context or blank line — advances the new-file counter.
|
|
59
|
+
new_line_no += 1
|
|
34
60
|
return gaps
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from collections import deque
|
|
1
3
|
from pydantic import BaseModel
|
|
2
4
|
from typing import Any, List, Optional
|
|
3
5
|
from pathlib import Path
|
|
@@ -11,10 +13,94 @@ from devcouncil.gating.checks.requirement_coverage import RequirementCoverageChe
|
|
|
11
13
|
from devcouncil.gating.checks.planned_files_check import PlannedFilesCheck
|
|
12
14
|
from devcouncil.gating.checks.clean_git import CleanGitCheck
|
|
13
15
|
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _log_gate(name: str, gaps: List[Gap], *, routine: bool = False, **context: Any) -> bool:
|
|
20
|
+
"""Log a gate decision and return whether it passed (no blocking gaps).
|
|
21
|
+
|
|
22
|
+
A passing gate is logged at INFO for once-per-plan checks (a real milestone) but at
|
|
23
|
+
DEBUG for ``routine`` per-task checks (e.g. task_ready, which fires for every task and
|
|
24
|
+
every repair attempt) so the ``-v`` stream stays milestone-level. A FAILED gate is
|
|
25
|
+
always WARNING — that's the signal you actually chase.
|
|
26
|
+
"""
|
|
27
|
+
blocking = [g for g in gaps if g.blocking]
|
|
28
|
+
passed = not blocking
|
|
29
|
+
suffix = "".join(f" {k}={v}" for k, v in context.items())
|
|
30
|
+
if passed:
|
|
31
|
+
log = logger.debug if routine else logger.info
|
|
32
|
+
log("Gate %s PASSED (%d advisory gap(s))%s", name, len(gaps), suffix)
|
|
33
|
+
else:
|
|
34
|
+
logger.warning(
|
|
35
|
+
"Gate %s FAILED%s: %s",
|
|
36
|
+
name, suffix,
|
|
37
|
+
"; ".join(f"{g.gap_type}: {g.description}" for g in blocking),
|
|
38
|
+
)
|
|
39
|
+
return passed
|
|
40
|
+
|
|
41
|
+
|
|
14
42
|
class GateResult(BaseModel):
|
|
15
43
|
passed: bool
|
|
16
44
|
gaps: List[Gap]
|
|
17
45
|
|
|
46
|
+
|
|
47
|
+
def _find_dependency_cycle(tasks: List[Task]) -> Optional[List[str]]:
|
|
48
|
+
"""Return one dependency cycle as an id path (e.g. [A, B, A]), or None. Only edges
|
|
49
|
+
to known task ids are followed; unknown deps are reported separately."""
|
|
50
|
+
ids = {t.id for t in tasks}
|
|
51
|
+
graph = {t.id: [d for d in t.depends_on if d in ids] for t in tasks}
|
|
52
|
+
WHITE, GREY, BLACK = 0, 1, 2
|
|
53
|
+
color = {tid: WHITE for tid in graph}
|
|
54
|
+
stack: List[str] = []
|
|
55
|
+
|
|
56
|
+
def visit(node: str) -> Optional[List[str]]:
|
|
57
|
+
color[node] = GREY
|
|
58
|
+
stack.append(node)
|
|
59
|
+
for nxt in graph.get(node, []):
|
|
60
|
+
if color[nxt] == GREY:
|
|
61
|
+
return stack[stack.index(nxt):] + [nxt]
|
|
62
|
+
if color[nxt] == WHITE:
|
|
63
|
+
found = visit(nxt)
|
|
64
|
+
if found:
|
|
65
|
+
return found
|
|
66
|
+
stack.pop()
|
|
67
|
+
color[node] = BLACK
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
for tid in graph:
|
|
71
|
+
if color[tid] == WHITE:
|
|
72
|
+
found = visit(tid)
|
|
73
|
+
if found:
|
|
74
|
+
return found
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def topological_order(tasks: List[Task]) -> List[Task]:
|
|
79
|
+
"""Order tasks so every task follows the ones it depends on. Stable: preserves the
|
|
80
|
+
given order among independent tasks. Falls back to the original order if a cycle
|
|
81
|
+
makes a full ordering impossible (the plan gate blocks cycles separately)."""
|
|
82
|
+
by_id = {t.id: t for t in tasks}
|
|
83
|
+
indegree = {t.id: 0 for t in tasks}
|
|
84
|
+
dependents: dict[str, List[str]] = {t.id: [] for t in tasks}
|
|
85
|
+
for task in tasks:
|
|
86
|
+
for dep in task.depends_on:
|
|
87
|
+
if dep in by_id:
|
|
88
|
+
indegree[task.id] += 1
|
|
89
|
+
dependents[dep].append(task.id)
|
|
90
|
+
# Kahn's algorithm, seeded in original order for stability.
|
|
91
|
+
ready = deque(t.id for t in tasks if indegree[t.id] == 0)
|
|
92
|
+
ordered: List[str] = []
|
|
93
|
+
while ready:
|
|
94
|
+
current = ready.popleft()
|
|
95
|
+
ordered.append(current)
|
|
96
|
+
for child in dependents[current]:
|
|
97
|
+
indegree[child] -= 1
|
|
98
|
+
if indegree[child] == 0:
|
|
99
|
+
ready.append(child)
|
|
100
|
+
if len(ordered) != len(tasks): # cycle — fall back to original order
|
|
101
|
+
return list(tasks)
|
|
102
|
+
return [by_id[tid] for tid in ordered]
|
|
103
|
+
|
|
18
104
|
class GatePolicy:
|
|
19
105
|
"""Central engine for executing project and task level quality gates."""
|
|
20
106
|
|
|
@@ -104,6 +190,68 @@ class GatePolicy:
|
|
|
104
190
|
blocking=True,
|
|
105
191
|
))
|
|
106
192
|
|
|
193
|
+
# Surface read-only-only tasks at PLANNING time (advisory): a task that declares
|
|
194
|
+
# planned files but none writable can implement nothing, and was previously only
|
|
195
|
+
# caught at execution by the task-readiness gate.
|
|
196
|
+
for task in tasks:
|
|
197
|
+
if task.planned_files and not any(
|
|
198
|
+
pf.allowed_change in ("create", "modify", "delete") for pf in task.planned_files
|
|
199
|
+
):
|
|
200
|
+
gaps.append(Gap(
|
|
201
|
+
id=f"GAP-PLAN-{task.id}-READ-ONLY",
|
|
202
|
+
severity="medium",
|
|
203
|
+
gap_type="task_not_implemented",
|
|
204
|
+
task_id=task.id,
|
|
205
|
+
description=(
|
|
206
|
+
f"Task {task.id} declares planned files but none are writable "
|
|
207
|
+
"(all read_only); it cannot implement any change. Expected only if "
|
|
208
|
+
"this is an analysis-only task."
|
|
209
|
+
),
|
|
210
|
+
recommended_fix=(
|
|
211
|
+
"Grant 'create', 'modify', or 'delete' to at least one planned file, "
|
|
212
|
+
"or confirm the task is intentionally analysis-only."
|
|
213
|
+
),
|
|
214
|
+
blocking=False,
|
|
215
|
+
))
|
|
216
|
+
|
|
217
|
+
# Surface overlapping ownership (advisory): when 2+ tasks each declare a
|
|
218
|
+
# writable (create/modify/delete) change to the SAME file, the plan is
|
|
219
|
+
# over-decomposed — the later task tends to duplicate or conflict with the
|
|
220
|
+
# earlier one (e.g. both add the same function), which then fails per-task
|
|
221
|
+
# verification. Consolidating a file's work into one task avoids this.
|
|
222
|
+
writers_by_file: dict[str, list[str]] = {}
|
|
223
|
+
writer_sets: dict[str, set[str]] = {}
|
|
224
|
+
for task in tasks:
|
|
225
|
+
for pf in task.planned_files:
|
|
226
|
+
if pf.allowed_change in ("create", "modify", "delete"):
|
|
227
|
+
path = pf.path.replace("\\", "/")
|
|
228
|
+
owners = writers_by_file.setdefault(path, [])
|
|
229
|
+
seen = writer_sets.setdefault(path, set())
|
|
230
|
+
if task.id not in seen:
|
|
231
|
+
seen.add(task.id)
|
|
232
|
+
owners.append(task.id)
|
|
233
|
+
for path, owners in writers_by_file.items():
|
|
234
|
+
if len(owners) > 1:
|
|
235
|
+
gaps.append(Gap(
|
|
236
|
+
id=f"GAP-PLAN-OVERLAP-{owners[0]}-{path.replace('/', '_')}",
|
|
237
|
+
severity="medium",
|
|
238
|
+
gap_type="task_not_implemented",
|
|
239
|
+
description=(
|
|
240
|
+
f"{len(owners)} tasks ({', '.join(owners)}) each declare writable "
|
|
241
|
+
f"changes to {path}; overlapping ownership over-decomposes the plan "
|
|
242
|
+
"and tends to cause duplicate/conflicting edits at execution."
|
|
243
|
+
),
|
|
244
|
+
recommended_fix=(
|
|
245
|
+
f"Consolidate the work on {path} into a single task, or scope the "
|
|
246
|
+
"others to read_only."
|
|
247
|
+
),
|
|
248
|
+
blocking=False,
|
|
249
|
+
))
|
|
250
|
+
|
|
251
|
+
# Validate the task dependency DAG: unknown depends_on ids and cycles would make
|
|
252
|
+
# execution ordering impossible / stall the run, so block the plan on them.
|
|
253
|
+
gaps.extend(self._validate_task_dependencies(tasks))
|
|
254
|
+
|
|
107
255
|
for assumption in assumptions or []:
|
|
108
256
|
if (
|
|
109
257
|
assumption.impact == "high"
|
|
@@ -145,7 +293,7 @@ class GatePolicy:
|
|
|
145
293
|
))
|
|
146
294
|
|
|
147
295
|
return GateResult(
|
|
148
|
-
passed=
|
|
296
|
+
passed=_log_gate("plan_approval", gaps),
|
|
149
297
|
gaps=gaps
|
|
150
298
|
)
|
|
151
299
|
|
|
@@ -159,32 +307,63 @@ class GatePolicy:
|
|
|
159
307
|
# 2. Check planned files
|
|
160
308
|
gaps.extend(self.planned_files.check(task))
|
|
161
309
|
|
|
162
|
-
# 3.
|
|
163
|
-
|
|
310
|
+
# 3. Surface a missing execution/verification contract — but do NOT block
|
|
311
|
+
# execution on it. The executor still needs to run to implement the code,
|
|
312
|
+
# and the evidence requirement is genuinely enforced at verify time
|
|
313
|
+
# (acceptance_criteria_unproven / NOAC gaps). Blocking here only prevents
|
|
314
|
+
# implementation and stalls multi-task plans when the planner under-specs a
|
|
315
|
+
# task; these stay advisory so the work can proceed and be judged on output.
|
|
316
|
+
if not task.allowed_commands and not task.expected_tests:
|
|
164
317
|
gaps.append(Gap(
|
|
165
318
|
id=f"GAP-{task.id}-NO-COMMANDS",
|
|
166
|
-
severity="
|
|
319
|
+
severity="medium",
|
|
167
320
|
gap_type="missing_test",
|
|
168
321
|
task_id=task.id,
|
|
169
322
|
description=f"Task {task.id} has no allowed commands for execution or verification.",
|
|
170
|
-
recommended_fix="Add explicit allowed_commands
|
|
171
|
-
blocking=
|
|
323
|
+
recommended_fix="Add explicit allowed_commands or expected_tests so verification can prove the acceptance criteria.",
|
|
324
|
+
blocking=False,
|
|
172
325
|
))
|
|
173
326
|
|
|
174
327
|
if not task.expected_tests:
|
|
175
328
|
gaps.append(Gap(
|
|
176
329
|
id=f"GAP-{task.id}-NO-EXPECTED-EVIDENCE",
|
|
177
|
-
severity="
|
|
330
|
+
severity="medium",
|
|
178
331
|
gap_type="missing_test",
|
|
179
332
|
task_id=task.id,
|
|
180
333
|
description=f"Task {task.id} has no expected verification evidence.",
|
|
181
334
|
recommended_fix="Add expected_tests or targeted static/manual review commands that prove the acceptance criteria.",
|
|
182
|
-
blocking=
|
|
335
|
+
blocking=False,
|
|
183
336
|
))
|
|
184
337
|
|
|
185
|
-
# 4. Check for task dependencies (if implemented)
|
|
186
|
-
|
|
187
338
|
return GateResult(
|
|
188
|
-
passed=
|
|
339
|
+
passed=_log_gate("task_ready", gaps, routine=True, task_id=task.id),
|
|
189
340
|
gaps=gaps
|
|
190
341
|
)
|
|
342
|
+
|
|
343
|
+
def _validate_task_dependencies(self, tasks: List[Task]) -> List[Gap]:
|
|
344
|
+
"""Block on a malformed dependency DAG: unknown depends_on ids and cycles."""
|
|
345
|
+
gaps: List[Gap] = []
|
|
346
|
+
ids = {t.id for t in tasks}
|
|
347
|
+
for task in tasks:
|
|
348
|
+
unknown = [dep for dep in task.depends_on if dep not in ids]
|
|
349
|
+
if unknown:
|
|
350
|
+
gaps.append(Gap(
|
|
351
|
+
id=f"GAP-PLAN-{task.id}-UNKNOWN-DEP",
|
|
352
|
+
severity="high",
|
|
353
|
+
gap_type="task_not_implemented",
|
|
354
|
+
task_id=task.id,
|
|
355
|
+
description=f"Task {task.id} depends on unknown task(s): {', '.join(unknown)}.",
|
|
356
|
+
recommended_fix="Reference only task IDs that exist in this plan, or remove the dependency.",
|
|
357
|
+
blocking=True,
|
|
358
|
+
))
|
|
359
|
+
cycle = _find_dependency_cycle(tasks)
|
|
360
|
+
if cycle:
|
|
361
|
+
gaps.append(Gap(
|
|
362
|
+
id="GAP-PLAN-DEP-CYCLE",
|
|
363
|
+
severity="high",
|
|
364
|
+
gap_type="task_not_implemented",
|
|
365
|
+
description=f"Task dependency cycle detected: {' -> '.join(cycle)}.",
|
|
366
|
+
recommended_fix="Break the cycle so the tasks can be ordered and executed.",
|
|
367
|
+
blocking=True,
|
|
368
|
+
))
|
|
369
|
+
return gaps
|