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,16 +1,24 @@
|
|
|
1
1
|
from typing import List, Dict, Any
|
|
2
|
+
import asyncio
|
|
2
3
|
from rich.console import Console
|
|
3
4
|
from pydantic import BaseModel
|
|
4
5
|
from devcouncil.domain.task import Task
|
|
5
6
|
from devcouncil.domain.requirement import Requirement
|
|
6
7
|
from devcouncil.execution.executor import Executor, ExecutionResult
|
|
7
|
-
from devcouncil.llm.router import ModelRouter
|
|
8
|
+
from devcouncil.llm.router import ModelRouter, StructuredOutputError
|
|
8
9
|
from devcouncil.execution.task_runner import TaskRunner
|
|
9
10
|
from devcouncil.execution.context_builder import ContextBuilder
|
|
11
|
+
from devcouncil.execution.prompt_builder import PromptBuilder
|
|
10
12
|
from devcouncil.execution.paths import resolve_project_path
|
|
13
|
+
from devcouncil.app.errors import ExecutionError
|
|
11
14
|
|
|
12
15
|
console = Console()
|
|
13
16
|
|
|
17
|
+
# Resilience bounds for the preview native loop.
|
|
18
|
+
MAX_AGENT_STEPS = 10
|
|
19
|
+
MAX_STRUCTURED_FAILURES = 2 # model can't produce a valid action -> give up cleanly
|
|
20
|
+
MAX_CONSECUTIVE_PATCH_FAILURES = 3 # stop spinning on a patch the model can't fix
|
|
21
|
+
|
|
14
22
|
class ToolCall(BaseModel):
|
|
15
23
|
tool: str
|
|
16
24
|
args: Dict[str, Any]
|
|
@@ -24,23 +32,40 @@ class NativeAgent(Executor):
|
|
|
24
32
|
def __init__(self, router: ModelRouter, task_runner: TaskRunner):
|
|
25
33
|
self.router = router
|
|
26
34
|
self.task_runner = task_runner
|
|
35
|
+
# ContextBuilder is retained only for the cheap list_files file listing; the
|
|
36
|
+
# implementation context itself uses the budgeted PromptBuilder so the native
|
|
37
|
+
# executor gets the same repo-map orientation, symbol outlines, dependents and
|
|
38
|
+
# context-window budgeting as the CLI executors (rather than a flat JSON dump).
|
|
27
39
|
self.context_builder = ContextBuilder(task_runner.project_root)
|
|
40
|
+
self.prompt_builder = PromptBuilder(task_runner.project_root)
|
|
41
|
+
|
|
42
|
+
def run_task(self, task: Task, requirements: List[Requirement]) -> ExecutionResult:
|
|
43
|
+
"""Run the preview native executor behind the normal synchronous executor contract."""
|
|
44
|
+
return asyncio.run(self._run_task_async(task, requirements))
|
|
28
45
|
|
|
29
|
-
async def
|
|
46
|
+
async def _run_task_async(self, task: Task, requirements: List[Requirement]) -> ExecutionResult:
|
|
30
47
|
console.print(f"Starting [bold]Native Executor[/bold] for task {task.id}...")
|
|
48
|
+
console.print("[yellow]Native executor is preview quality; DevCouncil verification remains the completion gate.[/yellow]")
|
|
31
49
|
|
|
32
|
-
# 1. Gather rich context
|
|
33
|
-
|
|
34
|
-
|
|
50
|
+
# 1. Gather rich context (budgeted; includes repo-map orientation + symbol outlines)
|
|
51
|
+
context_block = self.prompt_builder.build_task_prompt(task, requirements)
|
|
52
|
+
from devcouncil.planning.correction_manifest import load_latest_correction_manifest
|
|
53
|
+
|
|
54
|
+
correction = load_latest_correction_manifest(self.task_runner.project_root, task.id)
|
|
55
|
+
correction_block = ""
|
|
56
|
+
if correction is not None:
|
|
57
|
+
correction_block = f"\nCorrection Manifest:\n{correction.model_dump_json(indent=2)}\n"
|
|
58
|
+
|
|
35
59
|
system_prompt = f"""
|
|
36
60
|
You are the DevCouncil Native Agent. Your goal is to implement the provided task.
|
|
37
61
|
Current Project Context:
|
|
38
|
-
{
|
|
62
|
+
{context_block}
|
|
63
|
+
{correction_block}
|
|
39
64
|
|
|
40
65
|
You have access to the following tools:
|
|
41
66
|
- read_file(path: str)
|
|
42
67
|
- list_files()
|
|
43
|
-
- apply_patch(patch: str)
|
|
68
|
+
- apply_patch(patch: str) OR apply_patch(path: str, content: str) as a fallback when a valid unified diff cannot be produced
|
|
44
69
|
- run_command(command: str)
|
|
45
70
|
|
|
46
71
|
Rules:
|
|
@@ -54,19 +79,59 @@ Rules:
|
|
|
54
79
|
# Initial task prompt
|
|
55
80
|
messages.append({"role": "user", "content": f"Begin implementing task {task.id} based on the context provided."})
|
|
56
81
|
|
|
57
|
-
#
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
82
|
+
# Bounded tool loop. Counters let us fail a single task cleanly instead of
|
|
83
|
+
# crashing the whole run (structured-output faults) or spinning on an
|
|
84
|
+
# unfixable patch.
|
|
85
|
+
structured_failures = 0
|
|
86
|
+
consecutive_patch_failures = 0
|
|
87
|
+
for step in range(MAX_AGENT_STEPS):
|
|
88
|
+
try:
|
|
89
|
+
action = await self.router.complete_structured(
|
|
90
|
+
role="native_agent",
|
|
91
|
+
messages=messages,
|
|
92
|
+
schema=AgentAction,
|
|
93
|
+
)
|
|
94
|
+
except StructuredOutputError as exc:
|
|
95
|
+
# The model could not produce a valid action even after healing/retry.
|
|
96
|
+
# native_agent has no fallback by design, so handle it here rather than
|
|
97
|
+
# letting it propagate and abort the entire `dev go` run.
|
|
98
|
+
structured_failures += 1
|
|
99
|
+
console.print(f"[red]Native agent could not parse a valid action: {exc}[/red]")
|
|
100
|
+
if structured_failures >= MAX_STRUCTURED_FAILURES:
|
|
101
|
+
return ExecutionResult(
|
|
102
|
+
success=False,
|
|
103
|
+
message=f"Native agent gave up after {structured_failures} unparseable responses.",
|
|
104
|
+
)
|
|
105
|
+
messages.append({
|
|
106
|
+
"role": "user",
|
|
107
|
+
"content": (
|
|
108
|
+
"[System] Your previous response was not valid JSON for the "
|
|
109
|
+
"AgentAction schema. Reply with a single valid JSON object only "
|
|
110
|
+
"(fields: thought, tool_calls, finish) — no prose, no fences."
|
|
111
|
+
),
|
|
112
|
+
})
|
|
113
|
+
continue
|
|
114
|
+
structured_failures = 0
|
|
115
|
+
|
|
65
116
|
console.print(f"\n[bold]Step {step+1}:[/bold] {action.thought}")
|
|
66
|
-
|
|
117
|
+
|
|
118
|
+
# Record the agent's own turn so subsequent steps see what it already did.
|
|
119
|
+
# Without this the model only sees tool RESULTS, not its prior actions, and
|
|
120
|
+
# tends to repeat itself and never converge within the step budget.
|
|
121
|
+
messages.append({"role": "assistant", "content": action.model_dump_json()})
|
|
122
|
+
|
|
67
123
|
if action.finish:
|
|
68
124
|
console.print("[green]Native agent signaled completion.[/green]")
|
|
69
|
-
return ExecutionResult(success=True, message="Agent signaled completion")
|
|
125
|
+
return ExecutionResult(success=True, message="Agent signaled completion; pending DevCouncil verification")
|
|
126
|
+
|
|
127
|
+
if not action.tool_calls:
|
|
128
|
+
# No action and not finished — nudge instead of silently burning a step.
|
|
129
|
+
messages.append({"role": "user", "content": (
|
|
130
|
+
"[System] You produced no tool_calls and did not finish. Call a tool "
|
|
131
|
+
"(read_file/list_files/apply_patch/run_command) to make progress, or set "
|
|
132
|
+
"finish=true if the task is complete."
|
|
133
|
+
)})
|
|
134
|
+
continue
|
|
70
135
|
|
|
71
136
|
for tool_call in action.tool_calls:
|
|
72
137
|
result_summary = ""
|
|
@@ -90,8 +155,26 @@ Rules:
|
|
|
90
155
|
elif tool_call.tool == "write_file":
|
|
91
156
|
raise PermissionError("write_file is disabled for the native executor; use apply_patch.")
|
|
92
157
|
elif tool_call.tool == "apply_patch":
|
|
93
|
-
|
|
94
|
-
|
|
158
|
+
if "path" in tool_call.args and "content" in tool_call.args:
|
|
159
|
+
# Fallback for when the model can't produce a valid unified
|
|
160
|
+
# diff. Routes through write_file, which enforces the same
|
|
161
|
+
# planned-files permission check — no widening of scope.
|
|
162
|
+
self.task_runner.write_file(
|
|
163
|
+
tool_call.args["path"], tool_call.args["content"], task
|
|
164
|
+
)
|
|
165
|
+
consecutive_patch_failures = 0
|
|
166
|
+
result_summary = f"Wrote {tool_call.args['path']} via path+content fallback."
|
|
167
|
+
else:
|
|
168
|
+
patch = tool_call.args.get("patch", "")
|
|
169
|
+
if not patch or not patch.strip():
|
|
170
|
+
raise ExecutionError(
|
|
171
|
+
"Empty patch. Provide a unified git diff beginning with "
|
|
172
|
+
"'diff --git a/<path> b/<path>', then '--- a/<path>' (or "
|
|
173
|
+
"'--- /dev/null' for a new file), '+++ b/<path>', and '@@' hunks."
|
|
174
|
+
)
|
|
175
|
+
self.task_runner.apply_patch(patch, task)
|
|
176
|
+
consecutive_patch_failures = 0
|
|
177
|
+
result_summary = "Successfully applied patch."
|
|
95
178
|
elif tool_call.tool == "run_command":
|
|
96
179
|
cmd_result = self.task_runner.run_command(tool_call.args["command"], task)
|
|
97
180
|
result_summary = f"Command finished with exit code {cmd_result.exit_code}."
|
|
@@ -101,7 +184,25 @@ Rules:
|
|
|
101
184
|
messages.append({"role": "user", "content": f"[Tool Result] '{tool_call.tool}': {result_summary}"})
|
|
102
185
|
except Exception as e:
|
|
103
186
|
console.print(f"[red]Error executing tool {tool_call.tool}: {e}[/red]")
|
|
104
|
-
|
|
187
|
+
if tool_call.tool == "apply_patch":
|
|
188
|
+
consecutive_patch_failures += 1
|
|
189
|
+
if consecutive_patch_failures >= MAX_CONSECUTIVE_PATCH_FAILURES:
|
|
190
|
+
return ExecutionResult(
|
|
191
|
+
success=False,
|
|
192
|
+
message=(
|
|
193
|
+
f"Native agent failed to apply a patch "
|
|
194
|
+
f"{consecutive_patch_failures} times in a row."
|
|
195
|
+
),
|
|
196
|
+
)
|
|
197
|
+
messages.append({"role": "user", "content": (
|
|
198
|
+
f"[Tool Error] 'apply_patch' failed: {e}\n"
|
|
199
|
+
"Re-read the target file, match the existing context lines EXACTLY, "
|
|
200
|
+
"and do NOT resubmit the same patch. If you cannot produce a valid "
|
|
201
|
+
"unified diff, call apply_patch with 'path' and 'content' instead to "
|
|
202
|
+
"write the whole file."
|
|
203
|
+
)})
|
|
204
|
+
else:
|
|
205
|
+
messages.append({"role": "user", "content": f"[Tool Error] '{tool_call.tool}' failed: {e}"})
|
|
105
206
|
|
|
106
207
|
console.print("[red]Native agent reached maximum step limit.[/red]")
|
|
107
208
|
return ExecutionResult(success=False, message="Reached maximum step limit")
|
|
@@ -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,53 @@
|
|
|
1
|
+
import re
|
|
1
2
|
from typing import List
|
|
2
3
|
from devcouncil.domain.gap import Gap
|
|
3
4
|
from devcouncil.utils.redaction import SECRET_PATTERNS, redact_string
|
|
4
5
|
|
|
6
|
+
# Captures the new-file starting line from a unified-diff hunk header (@@ -a,b +c,d @@).
|
|
7
|
+
_HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")
|
|
8
|
+
|
|
9
|
+
|
|
5
10
|
class SecretScanner:
|
|
6
11
|
"""Scans code diffs for potential secrets (API keys, tokens, etc.)."""
|
|
7
|
-
|
|
12
|
+
|
|
8
13
|
def scan_diff(self, diff_content: str, task_id: str) -> List[Gap]:
|
|
9
|
-
gaps = []
|
|
10
|
-
lines = diff_content.splitlines()
|
|
14
|
+
gaps: List[Gap] = []
|
|
11
15
|
current_file = "unknown_file"
|
|
12
|
-
|
|
13
|
-
|
|
16
|
+
new_line_no = 0 # line number in the new file, tracked across hunks
|
|
17
|
+
counter = 0 # ensures unique gap ids
|
|
18
|
+
|
|
19
|
+
for line in diff_content.splitlines():
|
|
14
20
|
if line.startswith("+++ b/"):
|
|
15
21
|
current_file = line[6:]
|
|
16
22
|
continue
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
23
|
+
if line.startswith("+++") or line.startswith("---") or line.startswith("diff "):
|
|
24
|
+
continue
|
|
25
|
+
hunk = _HUNK_RE.match(line)
|
|
26
|
+
if hunk:
|
|
27
|
+
new_line_no = int(hunk.group(1))
|
|
28
|
+
continue
|
|
29
|
+
if line.startswith("-"):
|
|
30
|
+
continue # removed line — does not advance the new-file counter
|
|
31
|
+
if line.startswith("+"):
|
|
32
|
+
for key_type, pattern in SECRET_PATTERNS.items():
|
|
33
|
+
if pattern.search(line):
|
|
34
|
+
counter += 1
|
|
35
|
+
gaps.append(Gap(
|
|
36
|
+
id=f"GAP-{task_id}-SECRET-{key_type.upper()}-{new_line_no}-{counter}",
|
|
37
|
+
severity="critical",
|
|
38
|
+
gap_type="security_risk",
|
|
39
|
+
task_id=task_id,
|
|
40
|
+
description=f"Potential {key_type} found in {current_file}:{new_line_no}.",
|
|
41
|
+
evidence=[redact_string(line.strip())],
|
|
42
|
+
recommended_fix="Remove the secret and use environment variables or a secret manager.",
|
|
43
|
+
blocking=True,
|
|
44
|
+
# Populate the routing fields so the security NextAction points
|
|
45
|
+
# the agent straight at the file:line instead of forcing a re-grep.
|
|
46
|
+
file=current_file,
|
|
47
|
+
line=new_line_no,
|
|
48
|
+
))
|
|
49
|
+
new_line_no += 1
|
|
20
50
|
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
|
-
))
|
|
51
|
+
# Context or blank line — advances the new-file counter.
|
|
52
|
+
new_line_no += 1
|
|
34
53
|
return gaps
|
|
@@ -15,6 +15,64 @@ class GateResult(BaseModel):
|
|
|
15
15
|
passed: bool
|
|
16
16
|
gaps: List[Gap]
|
|
17
17
|
|
|
18
|
+
|
|
19
|
+
def _find_dependency_cycle(tasks: List[Task]) -> Optional[List[str]]:
|
|
20
|
+
"""Return one dependency cycle as an id path (e.g. [A, B, A]), or None. Only edges
|
|
21
|
+
to known task ids are followed; unknown deps are reported separately."""
|
|
22
|
+
ids = {t.id for t in tasks}
|
|
23
|
+
graph = {t.id: [d for d in t.depends_on if d in ids] for t in tasks}
|
|
24
|
+
WHITE, GREY, BLACK = 0, 1, 2
|
|
25
|
+
color = {tid: WHITE for tid in graph}
|
|
26
|
+
stack: List[str] = []
|
|
27
|
+
|
|
28
|
+
def visit(node: str) -> Optional[List[str]]:
|
|
29
|
+
color[node] = GREY
|
|
30
|
+
stack.append(node)
|
|
31
|
+
for nxt in graph.get(node, []):
|
|
32
|
+
if color[nxt] == GREY:
|
|
33
|
+
return stack[stack.index(nxt):] + [nxt]
|
|
34
|
+
if color[nxt] == WHITE:
|
|
35
|
+
found = visit(nxt)
|
|
36
|
+
if found:
|
|
37
|
+
return found
|
|
38
|
+
stack.pop()
|
|
39
|
+
color[node] = BLACK
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
for tid in graph:
|
|
43
|
+
if color[tid] == WHITE:
|
|
44
|
+
found = visit(tid)
|
|
45
|
+
if found:
|
|
46
|
+
return found
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def topological_order(tasks: List[Task]) -> List[Task]:
|
|
51
|
+
"""Order tasks so every task follows the ones it depends on. Stable: preserves the
|
|
52
|
+
given order among independent tasks. Falls back to the original order if a cycle
|
|
53
|
+
makes a full ordering impossible (the plan gate blocks cycles separately)."""
|
|
54
|
+
by_id = {t.id: t for t in tasks}
|
|
55
|
+
indegree = {t.id: 0 for t in tasks}
|
|
56
|
+
dependents: dict[str, List[str]] = {t.id: [] for t in tasks}
|
|
57
|
+
for task in tasks:
|
|
58
|
+
for dep in task.depends_on:
|
|
59
|
+
if dep in by_id:
|
|
60
|
+
indegree[task.id] += 1
|
|
61
|
+
dependents[dep].append(task.id)
|
|
62
|
+
# Kahn's algorithm, seeded in original order for stability.
|
|
63
|
+
ready = [t.id for t in tasks if indegree[t.id] == 0]
|
|
64
|
+
ordered: List[str] = []
|
|
65
|
+
while ready:
|
|
66
|
+
current = ready.pop(0)
|
|
67
|
+
ordered.append(current)
|
|
68
|
+
for child in dependents[current]:
|
|
69
|
+
indegree[child] -= 1
|
|
70
|
+
if indegree[child] == 0:
|
|
71
|
+
ready.append(child)
|
|
72
|
+
if len(ordered) != len(tasks): # cycle — fall back to original order
|
|
73
|
+
return list(tasks)
|
|
74
|
+
return [by_id[tid] for tid in ordered]
|
|
75
|
+
|
|
18
76
|
class GatePolicy:
|
|
19
77
|
"""Central engine for executing project and task level quality gates."""
|
|
20
78
|
|
|
@@ -104,6 +162,65 @@ class GatePolicy:
|
|
|
104
162
|
blocking=True,
|
|
105
163
|
))
|
|
106
164
|
|
|
165
|
+
# Surface read-only-only tasks at PLANNING time (advisory): a task that declares
|
|
166
|
+
# planned files but none writable can implement nothing, and was previously only
|
|
167
|
+
# caught at execution by the task-readiness gate.
|
|
168
|
+
for task in tasks:
|
|
169
|
+
if task.planned_files and not any(
|
|
170
|
+
pf.allowed_change in ("create", "modify", "delete") for pf in task.planned_files
|
|
171
|
+
):
|
|
172
|
+
gaps.append(Gap(
|
|
173
|
+
id=f"GAP-PLAN-{task.id}-READ-ONLY",
|
|
174
|
+
severity="medium",
|
|
175
|
+
gap_type="task_not_implemented",
|
|
176
|
+
task_id=task.id,
|
|
177
|
+
description=(
|
|
178
|
+
f"Task {task.id} declares planned files but none are writable "
|
|
179
|
+
"(all read_only); it cannot implement any change. Expected only if "
|
|
180
|
+
"this is an analysis-only task."
|
|
181
|
+
),
|
|
182
|
+
recommended_fix=(
|
|
183
|
+
"Grant 'create', 'modify', or 'delete' to at least one planned file, "
|
|
184
|
+
"or confirm the task is intentionally analysis-only."
|
|
185
|
+
),
|
|
186
|
+
blocking=False,
|
|
187
|
+
))
|
|
188
|
+
|
|
189
|
+
# Surface overlapping ownership (advisory): when 2+ tasks each declare a
|
|
190
|
+
# writable (create/modify/delete) change to the SAME file, the plan is
|
|
191
|
+
# over-decomposed — the later task tends to duplicate or conflict with the
|
|
192
|
+
# earlier one (e.g. both add the same function), which then fails per-task
|
|
193
|
+
# verification. Consolidating a file's work into one task avoids this.
|
|
194
|
+
writers_by_file: dict[str, list[str]] = {}
|
|
195
|
+
for task in tasks:
|
|
196
|
+
for pf in task.planned_files:
|
|
197
|
+
if pf.allowed_change in ("create", "modify", "delete"):
|
|
198
|
+
path = pf.path.replace("\\", "/")
|
|
199
|
+
writers_by_file.setdefault(path, [])
|
|
200
|
+
if task.id not in writers_by_file[path]:
|
|
201
|
+
writers_by_file[path].append(task.id)
|
|
202
|
+
for path, owners in writers_by_file.items():
|
|
203
|
+
if len(owners) > 1:
|
|
204
|
+
gaps.append(Gap(
|
|
205
|
+
id=f"GAP-PLAN-OVERLAP-{owners[0]}-{path.replace('/', '_')}",
|
|
206
|
+
severity="medium",
|
|
207
|
+
gap_type="task_not_implemented",
|
|
208
|
+
description=(
|
|
209
|
+
f"{len(owners)} tasks ({', '.join(owners)}) each declare writable "
|
|
210
|
+
f"changes to {path}; overlapping ownership over-decomposes the plan "
|
|
211
|
+
"and tends to cause duplicate/conflicting edits at execution."
|
|
212
|
+
),
|
|
213
|
+
recommended_fix=(
|
|
214
|
+
f"Consolidate the work on {path} into a single task, or scope the "
|
|
215
|
+
"others to read_only."
|
|
216
|
+
),
|
|
217
|
+
blocking=False,
|
|
218
|
+
))
|
|
219
|
+
|
|
220
|
+
# Validate the task dependency DAG: unknown depends_on ids and cycles would make
|
|
221
|
+
# execution ordering impossible / stall the run, so block the plan on them.
|
|
222
|
+
gaps.extend(self._validate_task_dependencies(tasks))
|
|
223
|
+
|
|
107
224
|
for assumption in assumptions or []:
|
|
108
225
|
if (
|
|
109
226
|
assumption.impact == "high"
|
|
@@ -159,32 +276,63 @@ class GatePolicy:
|
|
|
159
276
|
# 2. Check planned files
|
|
160
277
|
gaps.extend(self.planned_files.check(task))
|
|
161
278
|
|
|
162
|
-
# 3.
|
|
163
|
-
|
|
279
|
+
# 3. Surface a missing execution/verification contract — but do NOT block
|
|
280
|
+
# execution on it. The executor still needs to run to implement the code,
|
|
281
|
+
# and the evidence requirement is genuinely enforced at verify time
|
|
282
|
+
# (acceptance_criteria_unproven / NOAC gaps). Blocking here only prevents
|
|
283
|
+
# implementation and stalls multi-task plans when the planner under-specs a
|
|
284
|
+
# task; these stay advisory so the work can proceed and be judged on output.
|
|
285
|
+
if not task.allowed_commands and not task.expected_tests:
|
|
164
286
|
gaps.append(Gap(
|
|
165
287
|
id=f"GAP-{task.id}-NO-COMMANDS",
|
|
166
|
-
severity="
|
|
288
|
+
severity="medium",
|
|
167
289
|
gap_type="missing_test",
|
|
168
290
|
task_id=task.id,
|
|
169
291
|
description=f"Task {task.id} has no allowed commands for execution or verification.",
|
|
170
|
-
recommended_fix="Add explicit allowed_commands
|
|
171
|
-
blocking=
|
|
292
|
+
recommended_fix="Add explicit allowed_commands or expected_tests so verification can prove the acceptance criteria.",
|
|
293
|
+
blocking=False,
|
|
172
294
|
))
|
|
173
295
|
|
|
174
296
|
if not task.expected_tests:
|
|
175
297
|
gaps.append(Gap(
|
|
176
298
|
id=f"GAP-{task.id}-NO-EXPECTED-EVIDENCE",
|
|
177
|
-
severity="
|
|
299
|
+
severity="medium",
|
|
178
300
|
gap_type="missing_test",
|
|
179
301
|
task_id=task.id,
|
|
180
302
|
description=f"Task {task.id} has no expected verification evidence.",
|
|
181
303
|
recommended_fix="Add expected_tests or targeted static/manual review commands that prove the acceptance criteria.",
|
|
182
|
-
blocking=
|
|
304
|
+
blocking=False,
|
|
183
305
|
))
|
|
184
306
|
|
|
185
|
-
# 4. Check for task dependencies (if implemented)
|
|
186
|
-
|
|
187
307
|
return GateResult(
|
|
188
|
-
passed=len([g for g in gaps if g.blocking]) == 0,
|
|
308
|
+
passed=len([g for g in gaps if g.blocking]) == 0,
|
|
189
309
|
gaps=gaps
|
|
190
310
|
)
|
|
311
|
+
|
|
312
|
+
def _validate_task_dependencies(self, tasks: List[Task]) -> List[Gap]:
|
|
313
|
+
"""Block on a malformed dependency DAG: unknown depends_on ids and cycles."""
|
|
314
|
+
gaps: List[Gap] = []
|
|
315
|
+
ids = {t.id for t in tasks}
|
|
316
|
+
for task in tasks:
|
|
317
|
+
unknown = [dep for dep in task.depends_on if dep not in ids]
|
|
318
|
+
if unknown:
|
|
319
|
+
gaps.append(Gap(
|
|
320
|
+
id=f"GAP-PLAN-{task.id}-UNKNOWN-DEP",
|
|
321
|
+
severity="high",
|
|
322
|
+
gap_type="task_not_implemented",
|
|
323
|
+
task_id=task.id,
|
|
324
|
+
description=f"Task {task.id} depends on unknown task(s): {', '.join(unknown)}.",
|
|
325
|
+
recommended_fix="Reference only task IDs that exist in this plan, or remove the dependency.",
|
|
326
|
+
blocking=True,
|
|
327
|
+
))
|
|
328
|
+
cycle = _find_dependency_cycle(tasks)
|
|
329
|
+
if cycle:
|
|
330
|
+
gaps.append(Gap(
|
|
331
|
+
id="GAP-PLAN-DEP-CYCLE",
|
|
332
|
+
severity="high",
|
|
333
|
+
gap_type="task_not_implemented",
|
|
334
|
+
description=f"Task dependency cycle detected: {' -> '.join(cycle)}.",
|
|
335
|
+
recommended_fix="Break the cycle so the tasks can be ordered and executed.",
|
|
336
|
+
blocking=True,
|
|
337
|
+
))
|
|
338
|
+
return gaps
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Host hardware detection used to size local (Ollama) models.
|
|
2
|
+
|
|
3
|
+
DevCouncil runs the council roles against whatever LLM provider is configured.
|
|
4
|
+
For the local ``ollama`` provider the model has to fit in host memory. On Apple
|
|
5
|
+
Silicon Macs that memory is *unified* (shared by CPU/GPU/OS), so total RAM is the
|
|
6
|
+
ceiling. On a host with a discrete GPU (NVIDIA), Ollama offloads to VRAM, so the
|
|
7
|
+
*VRAM* is the practical ceiling instead — a 64 GB box with an 8 GB GPU should not be
|
|
8
|
+
told to run a model that only fits in system RAM. This module exposes small, pure
|
|
9
|
+
helpers so ``dev doctor`` and ``dev setup`` can recommend a model that will actually
|
|
10
|
+
run instead of a one-size-fits-all default, on macOS, Linux and Windows.
|
|
11
|
+
|
|
12
|
+
Everything here is best-effort and stdlib-only: detection failures return
|
|
13
|
+
``None`` rather than raising, so callers degrade to the static default.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import platform
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
|
|
24
|
+
# Recommended Ollama context window for DevCouncil's large planning prompts.
|
|
25
|
+
# Kept in sync with the value surfaced by `dev doctor`.
|
|
26
|
+
RECOMMENDED_NUM_CTX = 16384
|
|
27
|
+
|
|
28
|
+
# Fallback model when the host is unknown or has little memory. Matches the
|
|
29
|
+
# static defaults in ``llm/model_defaults.yaml``.
|
|
30
|
+
DEFAULT_OLLAMA_MODEL = "qwen2.5-coder:7b"
|
|
31
|
+
|
|
32
|
+
# RAM (GiB) -> recommended qwen2.5-coder size. Highest matching tier wins.
|
|
33
|
+
# Sizes are chosen so the quantized weights plus a 16k context comfortably fit
|
|
34
|
+
# alongside the OS in Apple Silicon unified memory.
|
|
35
|
+
_OLLAMA_MODEL_TIERS: tuple[tuple[float, str], ...] = (
|
|
36
|
+
(48.0, "qwen2.5-coder:32b"),
|
|
37
|
+
(24.0, "qwen2.5-coder:14b"),
|
|
38
|
+
(0.0, "qwen2.5-coder:7b"),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def is_macos() -> bool:
|
|
43
|
+
return platform.system() == "Darwin"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def is_apple_silicon() -> bool:
|
|
47
|
+
"""True on Apple-Silicon (arm64) Macs."""
|
|
48
|
+
return is_macos() and platform.machine() == "arm64"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def mac_chip_brand() -> str | None:
|
|
52
|
+
"""Marketing CPU string on macOS (e.g. ``Apple M3 Pro``), else None."""
|
|
53
|
+
if not is_macos():
|
|
54
|
+
return None
|
|
55
|
+
try:
|
|
56
|
+
out = subprocess.check_output(
|
|
57
|
+
["sysctl", "-n", "machdep.cpu.brand_string"],
|
|
58
|
+
text=True,
|
|
59
|
+
timeout=5,
|
|
60
|
+
).strip()
|
|
61
|
+
return out or None
|
|
62
|
+
except Exception:
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def total_ram_gb() -> float | None:
|
|
67
|
+
"""Total physical RAM in GiB, or None if it cannot be determined."""
|
|
68
|
+
try:
|
|
69
|
+
if is_macos():
|
|
70
|
+
out = subprocess.check_output(
|
|
71
|
+
["sysctl", "-n", "hw.memsize"], text=True, timeout=5
|
|
72
|
+
).strip()
|
|
73
|
+
return int(out) / (1024**3)
|
|
74
|
+
# POSIX (Linux): pages * page size.
|
|
75
|
+
pages = os.sysconf("SC_PHYS_PAGES")
|
|
76
|
+
page_size = os.sysconf("SC_PAGE_SIZE")
|
|
77
|
+
return (pages * page_size) / (1024**3)
|
|
78
|
+
except (OSError, ValueError, AttributeError, subprocess.SubprocessError):
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def nvidia_vram_gb() -> float | None:
|
|
83
|
+
"""Total VRAM (GiB) of the largest NVIDIA GPU via ``nvidia-smi``, else ``None``.
|
|
84
|
+
|
|
85
|
+
On hosts with a discrete NVIDIA GPU this is the real ceiling for Ollama, since the
|
|
86
|
+
model is offloaded to VRAM. Returns ``None`` when ``nvidia-smi`` is absent (no GPU,
|
|
87
|
+
Apple Silicon, or an unsupported vendor), so callers fall back to total RAM."""
|
|
88
|
+
if not shutil.which("nvidia-smi"):
|
|
89
|
+
return None
|
|
90
|
+
try:
|
|
91
|
+
out = subprocess.check_output(
|
|
92
|
+
["nvidia-smi", "--query-gpu=memory.total", "--format=csv,noheader,nounits"],
|
|
93
|
+
text=True,
|
|
94
|
+
timeout=5,
|
|
95
|
+
stderr=subprocess.DEVNULL,
|
|
96
|
+
).strip()
|
|
97
|
+
except (OSError, subprocess.SubprocessError):
|
|
98
|
+
return None
|
|
99
|
+
sizes_mib: list[float] = []
|
|
100
|
+
for line in out.splitlines():
|
|
101
|
+
line = line.strip()
|
|
102
|
+
if not line:
|
|
103
|
+
continue
|
|
104
|
+
try:
|
|
105
|
+
sizes_mib.append(float(line))
|
|
106
|
+
except ValueError:
|
|
107
|
+
continue
|
|
108
|
+
if not sizes_mib:
|
|
109
|
+
return None
|
|
110
|
+
return max(sizes_mib) / 1024.0 # MiB -> GiB
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def recommend_ollama_model(ram_gb: float | None = None, vram_gb: float | None = None) -> str:
|
|
114
|
+
"""Largest qwen2.5-coder size expected to run on the host.
|
|
115
|
+
|
|
116
|
+
When a discrete GPU's ``vram_gb`` is known it is the ceiling (Ollama offloads to
|
|
117
|
+
VRAM); otherwise total system RAM is used (correct for unified-memory Macs and
|
|
118
|
+
CPU-only hosts)."""
|
|
119
|
+
if ram_gb is None:
|
|
120
|
+
ram_gb = total_ram_gb()
|
|
121
|
+
effective = vram_gb if vram_gb is not None else ram_gb
|
|
122
|
+
if effective is None:
|
|
123
|
+
return DEFAULT_OLLAMA_MODEL
|
|
124
|
+
for floor, model in _OLLAMA_MODEL_TIERS:
|
|
125
|
+
if effective >= floor:
|
|
126
|
+
return model
|
|
127
|
+
return DEFAULT_OLLAMA_MODEL
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True)
|
|
131
|
+
class HostSummary:
|
|
132
|
+
"""A snapshot of the host relevant to local-model sizing."""
|
|
133
|
+
|
|
134
|
+
is_macos: bool
|
|
135
|
+
is_apple_silicon: bool
|
|
136
|
+
chip: str | None
|
|
137
|
+
ram_gb: float | None
|
|
138
|
+
recommended_ollama_model: str
|
|
139
|
+
vram_gb: float | None = None
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def ram_label(self) -> str:
|
|
143
|
+
return f"{self.ram_gb:.0f} GB" if self.ram_gb is not None else "unknown RAM"
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def vram_label(self) -> str | None:
|
|
147
|
+
return f"{self.vram_gb:.0f} GB VRAM" if self.vram_gb is not None else None
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def chip_label(self) -> str:
|
|
151
|
+
if self.chip:
|
|
152
|
+
return self.chip
|
|
153
|
+
if self.is_apple_silicon:
|
|
154
|
+
return "Apple Silicon"
|
|
155
|
+
if self.vram_gb is not None:
|
|
156
|
+
return "discrete GPU host"
|
|
157
|
+
return "this host"
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def platform_label(self) -> str:
|
|
161
|
+
"""Human label for the sizing row across OSes."""
|
|
162
|
+
if self.is_macos:
|
|
163
|
+
return "Apple Silicon" if self.is_apple_silicon else "Mac (Intel)"
|
|
164
|
+
system = platform.system()
|
|
165
|
+
return system or "Host"
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
def memory_label(self) -> str:
|
|
169
|
+
"""Memory ceiling used for the recommendation (VRAM if discrete GPU, else RAM)."""
|
|
170
|
+
vram = self.vram_label
|
|
171
|
+
return f"{vram} (GPU)" if vram else self.ram_label
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def describe_host() -> HostSummary:
|
|
175
|
+
ram = total_ram_gb()
|
|
176
|
+
vram = nvidia_vram_gb()
|
|
177
|
+
return HostSummary(
|
|
178
|
+
is_macos=is_macos(),
|
|
179
|
+
is_apple_silicon=is_apple_silicon(),
|
|
180
|
+
chip=mac_chip_brand(),
|
|
181
|
+
ram_gb=ram,
|
|
182
|
+
vram_gb=vram,
|
|
183
|
+
recommended_ollama_model=recommend_ollama_model(ram, vram),
|
|
184
|
+
)
|