devcouncil 0.1.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/LICENSE +201 -0
- package/README.md +643 -0
- package/bin/devcouncil.js +62 -0
- package/package.json +47 -0
- package/pyproject.toml +31 -0
- package/src/devcouncil/__init__.py +0 -0
- package/src/devcouncil/__main__.py +4 -0
- package/src/devcouncil/app/__init__.py +28 -0
- package/src/devcouncil/app/config.py +131 -0
- package/src/devcouncil/app/errors.py +23 -0
- package/src/devcouncil/app/events.py +44 -0
- package/src/devcouncil/app/orchestrator.py +92 -0
- package/src/devcouncil/app/run_context.py +39 -0
- package/src/devcouncil/app/state_machine.py +108 -0
- package/src/devcouncil/artifacts/__init__.py +1 -0
- package/src/devcouncil/artifacts/coverage.py +96 -0
- package/src/devcouncil/artifacts/graph.py +143 -0
- package/src/devcouncil/artifacts/migrations.py +20 -0
- package/src/devcouncil/artifacts/schemas.py +23 -0
- package/src/devcouncil/artifacts/serializer.py +21 -0
- package/src/devcouncil/artifacts/validators.py +27 -0
- package/src/devcouncil/cli/__init__.py +0 -0
- package/src/devcouncil/cli/commands/__init__.py +0 -0
- package/src/devcouncil/cli/commands/artifacts.py +48 -0
- package/src/devcouncil/cli/commands/baseline.py +32 -0
- package/src/devcouncil/cli/commands/config.py +54 -0
- package/src/devcouncil/cli/commands/doctor.py +96 -0
- package/src/devcouncil/cli/commands/hook.py +61 -0
- package/src/devcouncil/cli/commands/init.py +142 -0
- package/src/devcouncil/cli/commands/integrate.py +420 -0
- package/src/devcouncil/cli/commands/map.py +38 -0
- package/src/devcouncil/cli/commands/mcp_server.py +18 -0
- package/src/devcouncil/cli/commands/plan.py +276 -0
- package/src/devcouncil/cli/commands/prompt.py +47 -0
- package/src/devcouncil/cli/commands/repair.py +69 -0
- package/src/devcouncil/cli/commands/report.py +71 -0
- package/src/devcouncil/cli/commands/reset_demo_state.py +28 -0
- package/src/devcouncil/cli/commands/rollback.py +58 -0
- package/src/devcouncil/cli/commands/run.py +224 -0
- package/src/devcouncil/cli/commands/setup.py +82 -0
- package/src/devcouncil/cli/commands/show.py +57 -0
- package/src/devcouncil/cli/commands/status.py +105 -0
- package/src/devcouncil/cli/commands/tasks.py +41 -0
- package/src/devcouncil/cli/commands/trace.py +43 -0
- package/src/devcouncil/cli/commands/verify.py +163 -0
- package/src/devcouncil/cli/commands/version.py +20 -0
- package/src/devcouncil/cli/main.py +70 -0
- package/src/devcouncil/council/__init__.py +0 -0
- package/src/devcouncil/council/prompts/__init__.py +0 -0
- package/src/devcouncil/council/prompts/arbiter.md +19 -0
- package/src/devcouncil/council/prompts/critic_a.md +10 -0
- package/src/devcouncil/council/prompts/critic_b.md +10 -0
- package/src/devcouncil/council/prompts/implementation_reviewer.md +16 -0
- package/src/devcouncil/council/prompts/planner_a.md +16 -0
- package/src/devcouncil/council/prompts/planner_b.md +16 -0
- package/src/devcouncil/council/prompts/rebuttal.md +10 -0
- package/src/devcouncil/council/prompts/spec_writer.md +12 -0
- package/src/devcouncil/domain/__init__.py +0 -0
- package/src/devcouncil/domain/assumption.py +17 -0
- package/src/devcouncil/domain/critique.py +32 -0
- package/src/devcouncil/domain/evidence.py +27 -0
- package/src/devcouncil/domain/gap.py +26 -0
- package/src/devcouncil/domain/requirement.py +22 -0
- package/src/devcouncil/domain/task.py +26 -0
- package/src/devcouncil/execution/__init__.py +1 -0
- package/src/devcouncil/execution/context_builder.py +60 -0
- package/src/devcouncil/execution/executor.py +15 -0
- package/src/devcouncil/execution/hook_policy.py +144 -0
- package/src/devcouncil/execution/patch.py +28 -0
- package/src/devcouncil/execution/paths.py +14 -0
- package/src/devcouncil/execution/permissions.py +92 -0
- package/src/devcouncil/execution/prompt_builder.py +59 -0
- package/src/devcouncil/execution/task_runner.py +166 -0
- package/src/devcouncil/executors/__init__.py +1 -0
- package/src/devcouncil/executors/mini_swe.py +73 -0
- package/src/devcouncil/executors/native/__init__.py +0 -0
- package/src/devcouncil/executors/native/agent.py +107 -0
- package/src/devcouncil/executors/openhands.py +71 -0
- package/src/devcouncil/gating/__init__.py +1 -0
- package/src/devcouncil/gating/checks/__init__.py +0 -0
- package/src/devcouncil/gating/checks/clean_git.py +45 -0
- package/src/devcouncil/gating/checks/planned_files_check.py +32 -0
- package/src/devcouncil/gating/checks/requirement_coverage.py +26 -0
- package/src/devcouncil/gating/checks/secret_scan_check.py +34 -0
- package/src/devcouncil/gating/policy.py +190 -0
- package/src/devcouncil/indexing/__init__.py +1 -0
- package/src/devcouncil/indexing/graph_index.py +48 -0
- package/src/devcouncil/indexing/repo_mapper.py +204 -0
- package/src/devcouncil/indexing/symbol_index.py +0 -0
- package/src/devcouncil/integrations/code_review_graph.py +163 -0
- package/src/devcouncil/integrations/github.py +39 -0
- package/src/devcouncil/integrations/gitnexus.py +27 -0
- package/src/devcouncil/integrations/graphify.py +34 -0
- package/src/devcouncil/integrations/mcp/__init__.py +0 -0
- package/src/devcouncil/integrations/mcp/server.py +146 -0
- package/src/devcouncil/llm/__init__.py +1 -0
- package/src/devcouncil/llm/cache.py +38 -0
- package/src/devcouncil/llm/provider.py +125 -0
- package/src/devcouncil/llm/router.py +125 -0
- package/src/devcouncil/planning/__init__.py +1 -0
- package/src/devcouncil/planning/arbiter_service.py +57 -0
- package/src/devcouncil/planning/critique_service.py +66 -0
- package/src/devcouncil/planning/plan_service.py +46 -0
- package/src/devcouncil/planning/repair_service.py +39 -0
- package/src/devcouncil/planning/spec_service.py +44 -0
- package/src/devcouncil/repo/__init__.py +0 -0
- package/src/devcouncil/reporting/__init__.py +0 -0
- package/src/devcouncil/reporting/github_check.py +32 -0
- package/src/devcouncil/reporting/json_report.py +17 -0
- package/src/devcouncil/reporting/markdown_report.py +46 -0
- package/src/devcouncil/reporting/report_builder.py +14 -0
- package/src/devcouncil/storage/__init__.py +0 -0
- package/src/devcouncil/storage/db.py +66 -0
- package/src/devcouncil/storage/models.py +83 -0
- package/src/devcouncil/storage/repositories.py +346 -0
- package/src/devcouncil/telemetry/__init__.py +0 -0
- package/src/devcouncil/telemetry/cost.py +34 -0
- package/src/devcouncil/telemetry/traces.py +91 -0
- package/src/devcouncil/telemetry/tracker.py +49 -0
- package/src/devcouncil/utils/__init__.py +1 -0
- package/src/devcouncil/utils/redaction.py +141 -0
- package/src/devcouncil/verification/__init__.py +1 -0
- package/src/devcouncil/verification/implementation_reviewer.py +55 -0
- package/src/devcouncil/verification/verifier.py +513 -0
- package/uv.lock +1085 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from typing import List, Dict, Any
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from pydantic import BaseModel
|
|
4
|
+
from devcouncil.domain.task import Task
|
|
5
|
+
from devcouncil.domain.requirement import Requirement
|
|
6
|
+
from devcouncil.execution.executor import Executor, ExecutionResult
|
|
7
|
+
from devcouncil.llm.router import ModelRouter
|
|
8
|
+
from devcouncil.execution.task_runner import TaskRunner
|
|
9
|
+
from devcouncil.execution.context_builder import ContextBuilder
|
|
10
|
+
from devcouncil.execution.paths import resolve_project_path
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
class ToolCall(BaseModel):
|
|
15
|
+
tool: str
|
|
16
|
+
args: Dict[str, Any]
|
|
17
|
+
|
|
18
|
+
class AgentAction(BaseModel):
|
|
19
|
+
thought: str
|
|
20
|
+
tool_calls: List[ToolCall] = []
|
|
21
|
+
finish: bool = False
|
|
22
|
+
|
|
23
|
+
class NativeAgent(Executor):
|
|
24
|
+
def __init__(self, router: ModelRouter, task_runner: TaskRunner):
|
|
25
|
+
self.router = router
|
|
26
|
+
self.task_runner = task_runner
|
|
27
|
+
self.context_builder = ContextBuilder(task_runner.project_root)
|
|
28
|
+
|
|
29
|
+
async def run_task(self, task: Task, requirements: List[Requirement]) -> ExecutionResult:
|
|
30
|
+
console.print(f"Starting [bold]Native Executor[/bold] for task {task.id}...")
|
|
31
|
+
|
|
32
|
+
# 1. Gather rich context
|
|
33
|
+
context_json = self.context_builder.build_task_context(task, requirements)
|
|
34
|
+
|
|
35
|
+
system_prompt = f"""
|
|
36
|
+
You are the DevCouncil Native Agent. Your goal is to implement the provided task.
|
|
37
|
+
Current Project Context:
|
|
38
|
+
{context_json}
|
|
39
|
+
|
|
40
|
+
You have access to the following tools:
|
|
41
|
+
- read_file(path: str)
|
|
42
|
+
- list_files()
|
|
43
|
+
- apply_patch(patch: str)
|
|
44
|
+
- run_command(command: str)
|
|
45
|
+
|
|
46
|
+
Rules:
|
|
47
|
+
1. You can only write to files or apply patches to files listed in the task's 'planned_files'.
|
|
48
|
+
2. You can only run commands listed in the task's 'allowed_commands'.
|
|
49
|
+
3. Use 'thought' to explain your reasoning.
|
|
50
|
+
4. Set 'finish' to true when you believe the task is complete and verified.
|
|
51
|
+
"""
|
|
52
|
+
messages = [{"role": "system", "content": system_prompt}]
|
|
53
|
+
|
|
54
|
+
# Initial task prompt
|
|
55
|
+
messages.append({"role": "user", "content": f"Begin implementing task {task.id} based on the context provided."})
|
|
56
|
+
|
|
57
|
+
# Basic tool loop (Max 10 steps for safety in MVP)
|
|
58
|
+
for step in range(10):
|
|
59
|
+
action = await self.router.complete_structured(
|
|
60
|
+
role="native_agent",
|
|
61
|
+
messages=messages,
|
|
62
|
+
schema=AgentAction
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
console.print(f"\n[bold]Step {step+1}:[/bold] {action.thought}")
|
|
66
|
+
|
|
67
|
+
if action.finish:
|
|
68
|
+
console.print("[green]Native agent signaled completion.[/green]")
|
|
69
|
+
return ExecutionResult(success=True, message="Agent signaled completion")
|
|
70
|
+
|
|
71
|
+
for tool_call in action.tool_calls:
|
|
72
|
+
result_summary = ""
|
|
73
|
+
try:
|
|
74
|
+
if tool_call.tool == "read_file":
|
|
75
|
+
path = tool_call.args["path"]
|
|
76
|
+
resolved = resolve_project_path(self.task_runner.project_root, path)
|
|
77
|
+
# Security: block reading sensitive files
|
|
78
|
+
sensitive_patterns = {".env", ".pem", ".key", "credentials", "secrets"}
|
|
79
|
+
path_lower = path.lower()
|
|
80
|
+
if any(s in path_lower for s in sensitive_patterns):
|
|
81
|
+
raise PermissionError(f"Reading sensitive file blocked: {path}")
|
|
82
|
+
content = resolved.read_text(encoding="utf-8")
|
|
83
|
+
if len(content) > 8000:
|
|
84
|
+
content = content[:8000] + "\n[truncated]"
|
|
85
|
+
result_summary = f"File content of {path}:\n{content}"
|
|
86
|
+
elif tool_call.tool == "list_files":
|
|
87
|
+
# We use the internal helper but return limited list
|
|
88
|
+
files = self.context_builder.get_structure_summary()
|
|
89
|
+
result_summary = f"Found {len(files)} files in repository."
|
|
90
|
+
elif tool_call.tool == "write_file":
|
|
91
|
+
raise PermissionError("write_file is disabled for the native executor; use apply_patch.")
|
|
92
|
+
elif tool_call.tool == "apply_patch":
|
|
93
|
+
self.task_runner.apply_patch(tool_call.args["patch"], task)
|
|
94
|
+
result_summary = "Successfully applied patch."
|
|
95
|
+
elif tool_call.tool == "run_command":
|
|
96
|
+
cmd_result = self.task_runner.run_command(tool_call.args["command"], task)
|
|
97
|
+
result_summary = f"Command finished with exit code {cmd_result.exit_code}."
|
|
98
|
+
else:
|
|
99
|
+
raise ValueError(f"Unknown tool: {tool_call.tool}")
|
|
100
|
+
|
|
101
|
+
messages.append({"role": "user", "content": f"[Tool Result] '{tool_call.tool}': {result_summary}"})
|
|
102
|
+
except Exception as e:
|
|
103
|
+
console.print(f"[red]Error executing tool {tool_call.tool}: {e}[/red]")
|
|
104
|
+
messages.append({"role": "user", "content": f"[Tool Error] '{tool_call.tool}' failed: {e}"})
|
|
105
|
+
|
|
106
|
+
console.print("[red]Native agent reached maximum step limit.[/red]")
|
|
107
|
+
return ExecutionResult(success=False, message="Reached maximum step limit")
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from devcouncil.domain.task import Task
|
|
5
|
+
from devcouncil.domain.requirement import Requirement
|
|
6
|
+
from devcouncil.execution.executor import Executor, ExecutionResult
|
|
7
|
+
from devcouncil.execution.prompt_builder import PromptBuilder
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
class OpenHandsExecutor(Executor):
|
|
12
|
+
def __init__(self, project_root: Path):
|
|
13
|
+
self.project_root = project_root
|
|
14
|
+
|
|
15
|
+
def run_task(self, task: Task, requirements: list[Requirement]) -> ExecutionResult:
|
|
16
|
+
builder = PromptBuilder(self.project_root)
|
|
17
|
+
task_prompt = builder.build_task_prompt(task, requirements)
|
|
18
|
+
|
|
19
|
+
console.print(f"Starting [bold]OpenHands[/bold] for task {task.id}...")
|
|
20
|
+
|
|
21
|
+
# OpenHands often expects a workspace mount and an instruction.
|
|
22
|
+
# Keep the full prompt out of argv so Windows command-line limits and
|
|
23
|
+
# terminal logs do not become part of the execution boundary.
|
|
24
|
+
# Reference: https://github.com/All-Hands-AI/OpenHands
|
|
25
|
+
instruction_file = self.project_root / ".devcouncil" / f"{task.id}-openhands-task.md"
|
|
26
|
+
instruction_file.parent.mkdir(parents=True, exist_ok=True)
|
|
27
|
+
instruction_file.write_text(task_prompt, encoding="utf-8")
|
|
28
|
+
|
|
29
|
+
cmd = [
|
|
30
|
+
"openhands", "run",
|
|
31
|
+
"--workspace-base", str(self.project_root),
|
|
32
|
+
"--task-file", str(instruction_file),
|
|
33
|
+
"--headless"
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
console.print(f"Command: [dim]{' '.join(cmd)}[/dim]")
|
|
37
|
+
console.print("[yellow]Note: OpenHands must be installed and configured in the environment.[/yellow]")
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
result = subprocess.run(
|
|
41
|
+
cmd,
|
|
42
|
+
capture_output=True,
|
|
43
|
+
text=True,
|
|
44
|
+
encoding="utf-8",
|
|
45
|
+
errors="replace",
|
|
46
|
+
cwd=self.project_root,
|
|
47
|
+
timeout=1800,
|
|
48
|
+
)
|
|
49
|
+
self._write_log(task.id, result)
|
|
50
|
+
if result.returncode != 0:
|
|
51
|
+
console.print(f"[red]OpenHands exited with {result.returncode}.[/red]")
|
|
52
|
+
return ExecutionResult(success=False, message=f"Exited with code {result.returncode}")
|
|
53
|
+
return ExecutionResult(success=True, message="Completed successfully")
|
|
54
|
+
except Exception as e:
|
|
55
|
+
console.print(f"[red]Error running OpenHands: {e}[/red]")
|
|
56
|
+
return ExecutionResult(success=False, message=str(e))
|
|
57
|
+
|
|
58
|
+
def _write_log(self, task_id: str, result: subprocess.CompletedProcess[str]) -> None:
|
|
59
|
+
log_dir = self.project_root / ".devcouncil" / "logs"
|
|
60
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
log_path = log_dir / f"{task_id}-openhands.log"
|
|
62
|
+
log_path.write_text(
|
|
63
|
+
"\n".join([
|
|
64
|
+
f"command_returncode={result.returncode}",
|
|
65
|
+
"=== stdout ===",
|
|
66
|
+
result.stdout or "",
|
|
67
|
+
"=== stderr ===",
|
|
68
|
+
result.stderr or "",
|
|
69
|
+
]),
|
|
70
|
+
encoding="utf-8",
|
|
71
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
File without changes
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import logging
|
|
3
|
+
from devcouncil.domain.gap import Gap
|
|
4
|
+
|
|
5
|
+
logger = logging.getLogger(__name__)
|
|
6
|
+
|
|
7
|
+
class CleanGitCheck:
|
|
8
|
+
"""Ensures the working tree is clean before a task starts."""
|
|
9
|
+
|
|
10
|
+
def check(self, project_root, task_id: str) -> list[Gap]:
|
|
11
|
+
try:
|
|
12
|
+
status = subprocess.check_output(["git", "status", "--porcelain"], cwd=project_root).decode()
|
|
13
|
+
if status.strip():
|
|
14
|
+
return [Gap(
|
|
15
|
+
id=f"GAP-{task_id}-DIRTY-GIT",
|
|
16
|
+
severity="high",
|
|
17
|
+
gap_type="architecture_drift",
|
|
18
|
+
task_id=task_id,
|
|
19
|
+
description="Git working tree is dirty. Execution requires a clean state for checkpointing.",
|
|
20
|
+
recommended_fix="Commit or stash your current changes before running the task.",
|
|
21
|
+
blocking=True
|
|
22
|
+
)]
|
|
23
|
+
except FileNotFoundError:
|
|
24
|
+
logger.error("Git is not installed or not in PATH.")
|
|
25
|
+
return [Gap(
|
|
26
|
+
id=f"GAP-{task_id}-NO-GIT",
|
|
27
|
+
severity="high",
|
|
28
|
+
gap_type="architecture_drift",
|
|
29
|
+
task_id=task_id,
|
|
30
|
+
description="Git is not available. Cannot verify working tree cleanliness.",
|
|
31
|
+
recommended_fix="Install git and ensure it is in your PATH.",
|
|
32
|
+
blocking=True
|
|
33
|
+
)]
|
|
34
|
+
except subprocess.CalledProcessError as e:
|
|
35
|
+
logger.warning("Git status check failed: %s", e)
|
|
36
|
+
return [Gap(
|
|
37
|
+
id=f"GAP-{task_id}-GIT-ERROR",
|
|
38
|
+
severity="medium",
|
|
39
|
+
gap_type="architecture_drift",
|
|
40
|
+
task_id=task_id,
|
|
41
|
+
description=f"Git status check failed: {e}. Directory may not be a git repository.",
|
|
42
|
+
recommended_fix="Initialize a git repository with 'git init' before running tasks.",
|
|
43
|
+
blocking=True
|
|
44
|
+
)]
|
|
45
|
+
return []
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from devcouncil.domain.task import Task
|
|
2
|
+
from devcouncil.domain.gap import Gap
|
|
3
|
+
|
|
4
|
+
class PlannedFilesCheck:
|
|
5
|
+
"""Ensures a task has legitimate files planned for modification."""
|
|
6
|
+
|
|
7
|
+
def check(self, task: Task) -> list[Gap]:
|
|
8
|
+
gaps = []
|
|
9
|
+
if not task.planned_files:
|
|
10
|
+
gaps.append(Gap(
|
|
11
|
+
id=f"GAP-{task.id}-NO-FILES",
|
|
12
|
+
severity="high",
|
|
13
|
+
gap_type="task_not_implemented",
|
|
14
|
+
task_id=task.id,
|
|
15
|
+
description=f"Task {task.id} has no planned files. Agents won't know where to write code.",
|
|
16
|
+
recommended_fix="Update the task to include at least one planned file path.",
|
|
17
|
+
blocking=True
|
|
18
|
+
))
|
|
19
|
+
|
|
20
|
+
has_modify = any(pf.allowed_change in ["create", "modify", "delete"] for pf in task.planned_files)
|
|
21
|
+
if task.planned_files and not has_modify:
|
|
22
|
+
gaps.append(Gap(
|
|
23
|
+
id=f"GAP-{task.id}-READ-ONLY",
|
|
24
|
+
severity="medium",
|
|
25
|
+
gap_type="task_not_implemented",
|
|
26
|
+
task_id=task.id,
|
|
27
|
+
description=f"Task {task.id} only has read-only files. No changes can be made.",
|
|
28
|
+
recommended_fix="Grant 'modify' or 'create' permissions to at least one file.",
|
|
29
|
+
blocking=True
|
|
30
|
+
))
|
|
31
|
+
|
|
32
|
+
return gaps
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from devcouncil.domain.requirement import Requirement
|
|
2
|
+
from devcouncil.domain.task import Task
|
|
3
|
+
from devcouncil.domain.gap import Gap
|
|
4
|
+
from typing import List
|
|
5
|
+
|
|
6
|
+
class RequirementCoverageCheck:
|
|
7
|
+
"""Detects requirements that are not mapped to any tasks."""
|
|
8
|
+
|
|
9
|
+
def check(self, requirements: List[Requirement], tasks: List[Task]) -> List[Gap]:
|
|
10
|
+
task_req_ids = set()
|
|
11
|
+
for t in tasks:
|
|
12
|
+
task_req_ids.update(t.requirement_ids)
|
|
13
|
+
|
|
14
|
+
gaps = []
|
|
15
|
+
for req in requirements:
|
|
16
|
+
if req.id not in task_req_ids:
|
|
17
|
+
gaps.append(Gap(
|
|
18
|
+
id=f"GAP-PLAN-{req.id}-UNMAPPED",
|
|
19
|
+
severity="high",
|
|
20
|
+
gap_type="requirement_not_planned",
|
|
21
|
+
requirement_id=req.id,
|
|
22
|
+
description=f"Requirement '{req.title}' is not covered by any task.",
|
|
23
|
+
recommended_fix="Decompose this requirement into one or more implementation tasks.",
|
|
24
|
+
blocking=True
|
|
25
|
+
))
|
|
26
|
+
return gaps
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from typing import List
|
|
2
|
+
from devcouncil.domain.gap import Gap
|
|
3
|
+
from devcouncil.utils.redaction import SECRET_PATTERNS, redact_string
|
|
4
|
+
|
|
5
|
+
class SecretScanner:
|
|
6
|
+
"""Scans code diffs for potential secrets (API keys, tokens, etc.)."""
|
|
7
|
+
|
|
8
|
+
def scan_diff(self, diff_content: str, task_id: str) -> List[Gap]:
|
|
9
|
+
gaps = []
|
|
10
|
+
lines = diff_content.splitlines()
|
|
11
|
+
current_file = "unknown_file"
|
|
12
|
+
|
|
13
|
+
for i, line in enumerate(lines):
|
|
14
|
+
if line.startswith("+++ b/"):
|
|
15
|
+
current_file = line[6:]
|
|
16
|
+
continue
|
|
17
|
+
|
|
18
|
+
# Only scan added lines in diff
|
|
19
|
+
if not line.startswith("+") or line.startswith("+++"):
|
|
20
|
+
continue
|
|
21
|
+
|
|
22
|
+
for key_type, pattern in SECRET_PATTERNS.items():
|
|
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
|
+
))
|
|
34
|
+
return gaps
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from typing import Any, List, Optional
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from devcouncil.domain.requirement import Requirement
|
|
6
|
+
from devcouncil.domain.task import Task
|
|
7
|
+
from devcouncil.domain.gap import Gap
|
|
8
|
+
from devcouncil.domain.assumption import Assumption
|
|
9
|
+
from devcouncil.domain.critique import CritiqueFinding
|
|
10
|
+
from devcouncil.gating.checks.requirement_coverage import RequirementCoverageCheck
|
|
11
|
+
from devcouncil.gating.checks.planned_files_check import PlannedFilesCheck
|
|
12
|
+
from devcouncil.gating.checks.clean_git import CleanGitCheck
|
|
13
|
+
|
|
14
|
+
class GateResult(BaseModel):
|
|
15
|
+
passed: bool
|
|
16
|
+
gaps: List[Gap]
|
|
17
|
+
|
|
18
|
+
class GatePolicy:
|
|
19
|
+
"""Central engine for executing project and task level quality gates."""
|
|
20
|
+
|
|
21
|
+
def __init__(self):
|
|
22
|
+
self.req_coverage = RequirementCoverageCheck()
|
|
23
|
+
self.planned_files = PlannedFilesCheck()
|
|
24
|
+
self.clean_git = CleanGitCheck()
|
|
25
|
+
|
|
26
|
+
def check_plan_approval(
|
|
27
|
+
self,
|
|
28
|
+
requirements: List[Requirement],
|
|
29
|
+
tasks: List[Task],
|
|
30
|
+
assumptions: Optional[List[Assumption]] = None,
|
|
31
|
+
findings: Optional[List[CritiqueFinding]] = None,
|
|
32
|
+
blocking_questions: Optional[List[Any]] = None,
|
|
33
|
+
) -> GateResult:
|
|
34
|
+
"""Determines if the overall project plan is ready for execution."""
|
|
35
|
+
gaps = []
|
|
36
|
+
known_req_ids = {req.id for req in requirements}
|
|
37
|
+
known_ac_ids = {
|
|
38
|
+
ac.id
|
|
39
|
+
for req in requirements
|
|
40
|
+
for ac in req.acceptance_criteria
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# 1. Check requirement coverage
|
|
44
|
+
gaps.extend(self.req_coverage.check(requirements, tasks))
|
|
45
|
+
|
|
46
|
+
# 2. Check for acceptance criteria presence
|
|
47
|
+
for req in requirements:
|
|
48
|
+
if not req.acceptance_criteria:
|
|
49
|
+
gaps.append(Gap(
|
|
50
|
+
id=f"GAP-PLAN-{req.id}-NO-AC",
|
|
51
|
+
severity="high",
|
|
52
|
+
gap_type="requirement_not_planned",
|
|
53
|
+
requirement_id=req.id,
|
|
54
|
+
description=f"Requirement {req.id} has no acceptance criteria.",
|
|
55
|
+
recommended_fix="Define at least one measurable AC.",
|
|
56
|
+
blocking=True
|
|
57
|
+
))
|
|
58
|
+
|
|
59
|
+
for ac in req.acceptance_criteria:
|
|
60
|
+
if not ac.verification_method:
|
|
61
|
+
gaps.append(Gap(
|
|
62
|
+
id=f"GAP-PLAN-{ac.id}-NO-VERIFY",
|
|
63
|
+
severity="high",
|
|
64
|
+
gap_type="acceptance_criteria_unproven",
|
|
65
|
+
requirement_id=req.id,
|
|
66
|
+
description=f"Acceptance criterion {ac.id} has no verification method.",
|
|
67
|
+
recommended_fix="Define a deterministic verification method for this AC.",
|
|
68
|
+
blocking=True,
|
|
69
|
+
))
|
|
70
|
+
|
|
71
|
+
for task in tasks:
|
|
72
|
+
if not task.requirement_ids:
|
|
73
|
+
gaps.append(Gap(
|
|
74
|
+
id=f"GAP-PLAN-{task.id}-NO-REQ",
|
|
75
|
+
severity="high",
|
|
76
|
+
gap_type="requirement_not_planned",
|
|
77
|
+
task_id=task.id,
|
|
78
|
+
description=f"Task {task.id} is not mapped to any requirement.",
|
|
79
|
+
recommended_fix="Map each task to at least one requirement.",
|
|
80
|
+
blocking=True,
|
|
81
|
+
))
|
|
82
|
+
|
|
83
|
+
unknown_req_ids = [req_id for req_id in task.requirement_ids if req_id not in known_req_ids]
|
|
84
|
+
if unknown_req_ids:
|
|
85
|
+
gaps.append(Gap(
|
|
86
|
+
id=f"GAP-PLAN-{task.id}-UNKNOWN-REQ",
|
|
87
|
+
severity="high",
|
|
88
|
+
gap_type="requirement_not_planned",
|
|
89
|
+
task_id=task.id,
|
|
90
|
+
description=f"Task {task.id} references unknown requirement(s): {', '.join(unknown_req_ids)}.",
|
|
91
|
+
recommended_fix="Remove invalid requirement links or add the missing requirements.",
|
|
92
|
+
blocking=True,
|
|
93
|
+
))
|
|
94
|
+
|
|
95
|
+
unknown_ac_ids = [ac_id for ac_id in task.acceptance_criterion_ids if ac_id not in known_ac_ids]
|
|
96
|
+
if unknown_ac_ids:
|
|
97
|
+
gaps.append(Gap(
|
|
98
|
+
id=f"GAP-PLAN-{task.id}-UNKNOWN-AC",
|
|
99
|
+
severity="high",
|
|
100
|
+
gap_type="acceptance_criteria_unproven",
|
|
101
|
+
task_id=task.id,
|
|
102
|
+
description=f"Task {task.id} references unknown acceptance criteria: {', '.join(unknown_ac_ids)}.",
|
|
103
|
+
recommended_fix="Link tasks only to acceptance criteria declared by requirements.",
|
|
104
|
+
blocking=True,
|
|
105
|
+
))
|
|
106
|
+
|
|
107
|
+
for assumption in assumptions or []:
|
|
108
|
+
if (
|
|
109
|
+
assumption.impact == "high"
|
|
110
|
+
and assumption.status == "open"
|
|
111
|
+
and assumption.requires_user_confirmation
|
|
112
|
+
):
|
|
113
|
+
gaps.append(Gap(
|
|
114
|
+
id=f"GAP-PLAN-{assumption.id}-OPEN",
|
|
115
|
+
severity="high",
|
|
116
|
+
gap_type="assumption_violated",
|
|
117
|
+
requirement_id=assumption.linked_requirement_ids[0] if assumption.linked_requirement_ids else None,
|
|
118
|
+
description=f"High-impact assumption {assumption.id} is still open: {assumption.statement}",
|
|
119
|
+
recommended_fix="Confirm, reject, or convert this assumption before approving the plan.",
|
|
120
|
+
blocking=True,
|
|
121
|
+
))
|
|
122
|
+
|
|
123
|
+
for finding in findings or []:
|
|
124
|
+
if finding.severity in {"high", "critical"} and finding.status == "open":
|
|
125
|
+
gaps.append(Gap(
|
|
126
|
+
id=f"GAP-PLAN-{finding.id}-OPEN",
|
|
127
|
+
severity=finding.severity,
|
|
128
|
+
gap_type="architecture_drift",
|
|
129
|
+
requirement_id=finding.linked_requirement_id,
|
|
130
|
+
description=f"Open {finding.severity} critique finding remains: {finding.claim}",
|
|
131
|
+
recommended_fix="Convert, rebut with evidence, or mark this finding resolved before approval.",
|
|
132
|
+
blocking=True,
|
|
133
|
+
))
|
|
134
|
+
|
|
135
|
+
for question in blocking_questions or []:
|
|
136
|
+
question_id = getattr(question, "id", "QUESTION")
|
|
137
|
+
question_text = getattr(question, "question", str(question))
|
|
138
|
+
gaps.append(Gap(
|
|
139
|
+
id=f"GAP-PLAN-{question_id}-BLOCKING",
|
|
140
|
+
severity="high",
|
|
141
|
+
gap_type="requirement_not_planned",
|
|
142
|
+
description=f"Blocking question remains unanswered: {question_text}",
|
|
143
|
+
recommended_fix="Answer or convert the blocking question before approving the plan.",
|
|
144
|
+
blocking=True,
|
|
145
|
+
))
|
|
146
|
+
|
|
147
|
+
return GateResult(
|
|
148
|
+
passed=len([g for g in gaps if g.blocking]) == 0,
|
|
149
|
+
gaps=gaps
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def check_task_ready(self, task: Task, project_root: Path) -> GateResult:
|
|
153
|
+
"""Determines if a specific task can begin execution."""
|
|
154
|
+
gaps = []
|
|
155
|
+
|
|
156
|
+
# 1. Check Git state
|
|
157
|
+
gaps.extend(self.clean_git.check(project_root, task.id))
|
|
158
|
+
|
|
159
|
+
# 2. Check planned files
|
|
160
|
+
gaps.extend(self.planned_files.check(task))
|
|
161
|
+
|
|
162
|
+
# 3. Check execution/verification evidence contract
|
|
163
|
+
if not task.allowed_commands:
|
|
164
|
+
gaps.append(Gap(
|
|
165
|
+
id=f"GAP-{task.id}-NO-COMMANDS",
|
|
166
|
+
severity="high",
|
|
167
|
+
gap_type="missing_test",
|
|
168
|
+
task_id=task.id,
|
|
169
|
+
description=f"Task {task.id} has no allowed commands for execution or verification.",
|
|
170
|
+
recommended_fix="Add explicit allowed_commands for the task before execution.",
|
|
171
|
+
blocking=True,
|
|
172
|
+
))
|
|
173
|
+
|
|
174
|
+
if not task.expected_tests:
|
|
175
|
+
gaps.append(Gap(
|
|
176
|
+
id=f"GAP-{task.id}-NO-EXPECTED-EVIDENCE",
|
|
177
|
+
severity="high",
|
|
178
|
+
gap_type="missing_test",
|
|
179
|
+
task_id=task.id,
|
|
180
|
+
description=f"Task {task.id} has no expected verification evidence.",
|
|
181
|
+
recommended_fix="Add expected_tests or targeted static/manual review commands that prove the acceptance criteria.",
|
|
182
|
+
blocking=True,
|
|
183
|
+
))
|
|
184
|
+
|
|
185
|
+
# 4. Check for task dependencies (if implemented)
|
|
186
|
+
|
|
187
|
+
return GateResult(
|
|
188
|
+
passed=len([g for g in gaps if g.blocking]) == 0,
|
|
189
|
+
gaps=gaps
|
|
190
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
from typing import List, Dict, Any, Set
|
|
2
|
+
from pydantic import BaseModel, Field
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
class GraphNode(BaseModel):
|
|
6
|
+
id: str
|
|
7
|
+
type: str # "file", "symbol", "requirement", "task"
|
|
8
|
+
metadata: Dict[str, Any] = Field(default_factory=dict)
|
|
9
|
+
|
|
10
|
+
class GraphEdge(BaseModel):
|
|
11
|
+
source: str
|
|
12
|
+
target: str
|
|
13
|
+
relation: str # "imports", "implements", "validates", "contains"
|
|
14
|
+
|
|
15
|
+
class KnowledgeGraph(BaseModel):
|
|
16
|
+
nodes: List[GraphNode] = []
|
|
17
|
+
edges: List[GraphEdge] = []
|
|
18
|
+
|
|
19
|
+
class GraphIndex:
|
|
20
|
+
def __init__(self, project_root: Path):
|
|
21
|
+
self.project_root = project_root
|
|
22
|
+
self.graph = KnowledgeGraph()
|
|
23
|
+
|
|
24
|
+
def build_initial_graph(self, files: List[str]):
|
|
25
|
+
"""
|
|
26
|
+
Bootstrap the graph from file list.
|
|
27
|
+
"""
|
|
28
|
+
for f in files:
|
|
29
|
+
self.graph.nodes.append(GraphNode(
|
|
30
|
+
id=f,
|
|
31
|
+
type="file",
|
|
32
|
+
metadata={"extension": Path(f).suffix}
|
|
33
|
+
))
|
|
34
|
+
|
|
35
|
+
def add_relation(self, source: str, target: str, relation: str):
|
|
36
|
+
self.graph.edges.append(GraphEdge(source=source, target=target, relation=relation))
|
|
37
|
+
|
|
38
|
+
def get_context_for_file(self, file_path: str) -> Set[str]:
|
|
39
|
+
"""
|
|
40
|
+
Retrieve related paths for a given file.
|
|
41
|
+
"""
|
|
42
|
+
related = {file_path}
|
|
43
|
+
for edge in self.graph.edges:
|
|
44
|
+
if edge.source == file_path:
|
|
45
|
+
related.add(edge.target)
|
|
46
|
+
if edge.target == file_path:
|
|
47
|
+
related.add(edge.source)
|
|
48
|
+
return related
|