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,204 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Dict
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
class RepoMap(BaseModel):
|
|
13
|
+
languages: List[str]
|
|
14
|
+
frameworks: List[str]
|
|
15
|
+
package_managers: List[str]
|
|
16
|
+
test_commands: List[str]
|
|
17
|
+
important_files: List[str]
|
|
18
|
+
candidate_files: List[Dict[str, str]]
|
|
19
|
+
|
|
20
|
+
class RepoMapper:
|
|
21
|
+
def __init__(self, project_root: Path):
|
|
22
|
+
self.project_root = project_root
|
|
23
|
+
|
|
24
|
+
def _is_runtime_or_generated_file(self, path: str) -> bool:
|
|
25
|
+
normalized = path.replace("\\", "/")
|
|
26
|
+
parts = set(normalized.split("/"))
|
|
27
|
+
if "__pycache__" in parts or normalized.endswith(".pyc"):
|
|
28
|
+
return True
|
|
29
|
+
if parts.intersection({".git", ".devcouncil", ".pytest_cache", ".ruff_cache", ".mypy_cache", ".venv"}):
|
|
30
|
+
return True
|
|
31
|
+
if normalized.startswith("dist/") or normalized.startswith("build/"):
|
|
32
|
+
return True
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
def get_git_files(self) -> List[str]:
|
|
36
|
+
try:
|
|
37
|
+
output = subprocess.check_output(
|
|
38
|
+
["git", "ls-files", "--cached", "--others", "--exclude-standard"],
|
|
39
|
+
cwd=self.project_root,
|
|
40
|
+
stderr=subprocess.DEVNULL
|
|
41
|
+
).decode().splitlines()
|
|
42
|
+
return [path for path in output if not self._is_runtime_or_generated_file(path)]
|
|
43
|
+
except Exception:
|
|
44
|
+
# Fallback to os.walk if not a git repo or git missing
|
|
45
|
+
files = []
|
|
46
|
+
for root, _, filenames in os.walk(self.project_root):
|
|
47
|
+
for f in filenames:
|
|
48
|
+
rel_path = os.path.relpath(os.path.join(root, f), self.project_root)
|
|
49
|
+
if not rel_path.startswith(".") and not self._is_runtime_or_generated_file(rel_path):
|
|
50
|
+
files.append(rel_path)
|
|
51
|
+
return files
|
|
52
|
+
|
|
53
|
+
def detect_languages(self, files: List[str]) -> List[str]:
|
|
54
|
+
exts = {os.path.splitext(f)[1] for f in files}
|
|
55
|
+
lang_map = {
|
|
56
|
+
".py": "python",
|
|
57
|
+
".ts": "typescript",
|
|
58
|
+
".tsx": "typescript",
|
|
59
|
+
".js": "javascript",
|
|
60
|
+
".jsx": "javascript",
|
|
61
|
+
".go": "go",
|
|
62
|
+
".rs": "rust",
|
|
63
|
+
".java": "java",
|
|
64
|
+
".c": "c",
|
|
65
|
+
".cpp": "cpp",
|
|
66
|
+
}
|
|
67
|
+
return sorted(list({lang_map[ext] for ext in exts if ext in lang_map}))
|
|
68
|
+
|
|
69
|
+
def detect_frameworks(self, files: List[str]) -> List[str]:
|
|
70
|
+
frameworks = []
|
|
71
|
+
file_set = set(files)
|
|
72
|
+
if "package.json" in file_set:
|
|
73
|
+
content = (self.project_root / "package.json").read_text()
|
|
74
|
+
if "next" in content:
|
|
75
|
+
frameworks.append("nextjs")
|
|
76
|
+
if "react" in content:
|
|
77
|
+
frameworks.append("react")
|
|
78
|
+
if "vue" in content:
|
|
79
|
+
frameworks.append("vue")
|
|
80
|
+
if "express" in content:
|
|
81
|
+
frameworks.append("express")
|
|
82
|
+
|
|
83
|
+
if "requirements.txt" in file_set or "pyproject.toml" in file_set:
|
|
84
|
+
try:
|
|
85
|
+
content = ""
|
|
86
|
+
if "requirements.txt" in file_set:
|
|
87
|
+
content += (self.project_root / "requirements.txt").read_text()
|
|
88
|
+
if "pyproject.toml" in file_set:
|
|
89
|
+
content += (self.project_root / "pyproject.toml").read_text()
|
|
90
|
+
|
|
91
|
+
if "fastapi" in content.lower():
|
|
92
|
+
frameworks.append("fastapi")
|
|
93
|
+
if "flask" in content.lower():
|
|
94
|
+
frameworks.append("flask")
|
|
95
|
+
if "django" in content.lower():
|
|
96
|
+
frameworks.append("django")
|
|
97
|
+
except Exception as e:
|
|
98
|
+
logger.debug("Failed to read Python config files: %s", e)
|
|
99
|
+
return frameworks
|
|
100
|
+
|
|
101
|
+
def detect_package_managers(self, files: List[str]) -> List[str]:
|
|
102
|
+
managers = []
|
|
103
|
+
file_set = set(files)
|
|
104
|
+
if "package-lock.json" in file_set:
|
|
105
|
+
managers.append("npm")
|
|
106
|
+
elif "package.json" in file_set:
|
|
107
|
+
managers.append("npm")
|
|
108
|
+
if "yarn.lock" in file_set:
|
|
109
|
+
managers.append("yarn")
|
|
110
|
+
if "pnpm-lock.yaml" in file_set:
|
|
111
|
+
managers.append("pnpm")
|
|
112
|
+
if "requirements.txt" in file_set:
|
|
113
|
+
managers.append("pip")
|
|
114
|
+
if "uv.lock" in file_set:
|
|
115
|
+
managers.append("uv")
|
|
116
|
+
if "go.sum" in file_set:
|
|
117
|
+
managers.append("go mod")
|
|
118
|
+
return managers
|
|
119
|
+
|
|
120
|
+
def detect_test_commands(self, files: List[str]) -> List[str]:
|
|
121
|
+
"""Detect test, lint, and typecheck commands from project config."""
|
|
122
|
+
commands: List[str] = []
|
|
123
|
+
file_set = set(files)
|
|
124
|
+
|
|
125
|
+
# Node.js projects: read scripts from package.json
|
|
126
|
+
if "package.json" in file_set:
|
|
127
|
+
try:
|
|
128
|
+
pkg = json.loads((self.project_root / "package.json").read_text())
|
|
129
|
+
scripts = pkg.get("scripts", {})
|
|
130
|
+
pm = "pnpm" if "pnpm-lock.yaml" in file_set else (
|
|
131
|
+
"yarn" if "yarn.lock" in file_set else "npm"
|
|
132
|
+
)
|
|
133
|
+
for key in ["test", "lint", "typecheck", "check", "type-check"]:
|
|
134
|
+
if key in scripts:
|
|
135
|
+
if pm == "npm" and key != "test":
|
|
136
|
+
commands.append(f"npm run {key}")
|
|
137
|
+
else:
|
|
138
|
+
commands.append(f"{pm} {key}")
|
|
139
|
+
except Exception as e:
|
|
140
|
+
logger.debug("Failed to parse package.json scripts: %s", e)
|
|
141
|
+
|
|
142
|
+
# Python projects
|
|
143
|
+
if "pyproject.toml" in file_set or "setup.py" in file_set:
|
|
144
|
+
if any(f.startswith("tests/") or f.startswith("test_") for f in files):
|
|
145
|
+
commands.append("pytest")
|
|
146
|
+
commands.append("ruff check .")
|
|
147
|
+
commands.append("mypy .")
|
|
148
|
+
|
|
149
|
+
# Go projects
|
|
150
|
+
if "go.mod" in file_set:
|
|
151
|
+
commands.append("go test ./...")
|
|
152
|
+
commands.append("go vet ./...")
|
|
153
|
+
|
|
154
|
+
# Rust projects
|
|
155
|
+
if "Cargo.toml" in file_set:
|
|
156
|
+
commands.append("cargo test")
|
|
157
|
+
commands.append("cargo clippy")
|
|
158
|
+
|
|
159
|
+
return commands
|
|
160
|
+
|
|
161
|
+
def _ripgrep_search(self, goal: str, files: List[str]) -> List[Dict[str, str]]:
|
|
162
|
+
"""Use ripgrep for goal-keyword search if available, else fall back to naive matching."""
|
|
163
|
+
candidates: List[Dict[str, str]] = []
|
|
164
|
+
try:
|
|
165
|
+
# Try ripgrep first for better matching
|
|
166
|
+
result = subprocess.run(
|
|
167
|
+
["rg", "--files-with-matches", "--ignore-case", "--glob", "!.git", goal],
|
|
168
|
+
capture_output=True, text=True, cwd=self.project_root, timeout=10,
|
|
169
|
+
)
|
|
170
|
+
if result.returncode == 0:
|
|
171
|
+
for line in result.stdout.strip().splitlines()[:10]:
|
|
172
|
+
candidates.append({"path": line.strip(), "reason": f"ripgrep match for '{goal}'"})
|
|
173
|
+
return candidates
|
|
174
|
+
except Exception:
|
|
175
|
+
pass # Fall back to naive matching
|
|
176
|
+
|
|
177
|
+
# Naive keyword matching fallback
|
|
178
|
+
goal_words = set(goal.lower().split())
|
|
179
|
+
for f in files:
|
|
180
|
+
f_lower = f.lower()
|
|
181
|
+
score = sum(1 for word in goal_words if word in f_lower)
|
|
182
|
+
if score > 0:
|
|
183
|
+
candidates.append({"path": f, "reason": f"Matches goal keywords (score: {score})"})
|
|
184
|
+
candidates = sorted(candidates, key=lambda x: x.get("reason", ""), reverse=True)[:10]
|
|
185
|
+
return candidates
|
|
186
|
+
|
|
187
|
+
def map_repo(self, goal: str = "") -> RepoMap:
|
|
188
|
+
files = self.get_git_files()
|
|
189
|
+
|
|
190
|
+
candidates: List[Dict[str, str]] = []
|
|
191
|
+
if goal:
|
|
192
|
+
candidates = self._ripgrep_search(goal, files)
|
|
193
|
+
|
|
194
|
+
return RepoMap(
|
|
195
|
+
languages=self.detect_languages(files),
|
|
196
|
+
frameworks=self.detect_frameworks(files),
|
|
197
|
+
package_managers=self.detect_package_managers(files),
|
|
198
|
+
test_commands=self.detect_test_commands(files),
|
|
199
|
+
important_files=[f for f in files if f in [
|
|
200
|
+
"package.json", "pyproject.toml", "README.md", "go.mod",
|
|
201
|
+
"Cargo.toml", "Makefile", "Dockerfile", ".github/workflows",
|
|
202
|
+
]],
|
|
203
|
+
candidate_files=candidates,
|
|
204
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import shutil
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Iterable
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from devcouncil.app.config import load_config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CodeReviewGraphContext(BaseModel):
|
|
13
|
+
available: bool
|
|
14
|
+
summary: str
|
|
15
|
+
command: str = "code-review-graph"
|
|
16
|
+
changed_files: list[str] = Field(default_factory=list)
|
|
17
|
+
impacted_files: list[str] = Field(default_factory=list)
|
|
18
|
+
related_tests: list[str] = Field(default_factory=list)
|
|
19
|
+
raw: str = ""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CodeReviewGraphAdapter:
|
|
23
|
+
"""Optional shell adapter for code-review-graph without a hard dependency."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, project_root: Path, command: str | None = None):
|
|
26
|
+
self.project_root = project_root
|
|
27
|
+
self.command = command or self._configured_command()
|
|
28
|
+
|
|
29
|
+
def is_enabled(self) -> bool:
|
|
30
|
+
try:
|
|
31
|
+
return load_config(self.project_root).integrations.code_review_graph.enabled
|
|
32
|
+
except Exception:
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
def is_available(self) -> bool:
|
|
36
|
+
return shutil.which(self.command) is not None
|
|
37
|
+
|
|
38
|
+
def get_context(self, files: Iterable[str] = ()) -> CodeReviewGraphContext:
|
|
39
|
+
changed_files = [file for file in files if file]
|
|
40
|
+
if not self.is_enabled():
|
|
41
|
+
return CodeReviewGraphContext(
|
|
42
|
+
available=False,
|
|
43
|
+
summary="code-review-graph integration is disabled.",
|
|
44
|
+
command=self.command,
|
|
45
|
+
changed_files=changed_files,
|
|
46
|
+
)
|
|
47
|
+
if not self.is_available():
|
|
48
|
+
return CodeReviewGraphContext(
|
|
49
|
+
available=False,
|
|
50
|
+
summary="code-review-graph command was not found on PATH.",
|
|
51
|
+
command=self.command,
|
|
52
|
+
changed_files=changed_files,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
commands = self._candidate_commands(changed_files)
|
|
56
|
+
errors: list[str] = []
|
|
57
|
+
for command in commands:
|
|
58
|
+
result = self._run(command)
|
|
59
|
+
if result.returncode == 0 and result.output.strip():
|
|
60
|
+
return self._parse_context(result.output, changed_files)
|
|
61
|
+
errors.append(result.output.strip() or f"exit {result.returncode}")
|
|
62
|
+
|
|
63
|
+
return CodeReviewGraphContext(
|
|
64
|
+
available=True,
|
|
65
|
+
summary="code-review-graph ran but did not return context.",
|
|
66
|
+
command=self.command,
|
|
67
|
+
changed_files=changed_files,
|
|
68
|
+
raw="\n".join(errors),
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def prompt_section(self, files: Iterable[str] = ()) -> str:
|
|
72
|
+
context = self.get_context(files)
|
|
73
|
+
if not context.available:
|
|
74
|
+
return ""
|
|
75
|
+
|
|
76
|
+
lines = ["## Structural graph context", context.summary]
|
|
77
|
+
if context.impacted_files:
|
|
78
|
+
lines.append("\nImpacted files:")
|
|
79
|
+
lines.extend(f"- `{path}`" for path in context.impacted_files[:20])
|
|
80
|
+
if context.related_tests:
|
|
81
|
+
lines.append("\nRelated tests:")
|
|
82
|
+
lines.extend(f"- `{path}`" for path in context.related_tests[:20])
|
|
83
|
+
return "\n".join(lines).strip() + "\n"
|
|
84
|
+
|
|
85
|
+
def _configured_command(self) -> str:
|
|
86
|
+
try:
|
|
87
|
+
return load_config(self.project_root).integrations.code_review_graph.command
|
|
88
|
+
except Exception:
|
|
89
|
+
return "code-review-graph"
|
|
90
|
+
|
|
91
|
+
def _candidate_commands(self, files: list[str]) -> list[list[str]]:
|
|
92
|
+
commands = [[self.command, "detect-changes", "--json"]]
|
|
93
|
+
if files:
|
|
94
|
+
commands.append([self.command, "get-review-context", "--json", *files])
|
|
95
|
+
commands.append([self.command, "status", "--json"])
|
|
96
|
+
return commands
|
|
97
|
+
|
|
98
|
+
def _parse_context(self, output: str, changed_files: list[str]) -> CodeReviewGraphContext:
|
|
99
|
+
data: dict[str, Any] = {}
|
|
100
|
+
try:
|
|
101
|
+
parsed = json.loads(output)
|
|
102
|
+
if isinstance(parsed, dict):
|
|
103
|
+
data = parsed
|
|
104
|
+
except json.JSONDecodeError:
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
impacted = self._string_list(
|
|
108
|
+
data.get("impacted_files")
|
|
109
|
+
or data.get("impact_radius")
|
|
110
|
+
or data.get("files")
|
|
111
|
+
or []
|
|
112
|
+
)
|
|
113
|
+
tests = self._string_list(data.get("related_tests") or data.get("tests") or [])
|
|
114
|
+
summary = str(data.get("summary") or "code-review-graph context available.")
|
|
115
|
+
return CodeReviewGraphContext(
|
|
116
|
+
available=True,
|
|
117
|
+
summary=summary,
|
|
118
|
+
command=self.command,
|
|
119
|
+
changed_files=changed_files,
|
|
120
|
+
impacted_files=impacted,
|
|
121
|
+
related_tests=tests,
|
|
122
|
+
raw=output,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def _string_list(self, value: Any) -> list[str]:
|
|
126
|
+
if isinstance(value, list):
|
|
127
|
+
results: list[str] = []
|
|
128
|
+
for item in value:
|
|
129
|
+
if isinstance(item, (str, int)):
|
|
130
|
+
results.append(str(item))
|
|
131
|
+
elif isinstance(item, dict):
|
|
132
|
+
path = item.get("path") or item.get("file") or item.get("name") or item.get("id")
|
|
133
|
+
if path:
|
|
134
|
+
results.append(str(path))
|
|
135
|
+
return results
|
|
136
|
+
if isinstance(value, dict):
|
|
137
|
+
if "path" in value or "file" in value:
|
|
138
|
+
return [str(value.get("path") or value.get("file"))]
|
|
139
|
+
return [str(key) for key in value.keys()]
|
|
140
|
+
return []
|
|
141
|
+
|
|
142
|
+
def _run(self, command: list[str]) -> "_RunResult":
|
|
143
|
+
try:
|
|
144
|
+
result = subprocess.run(
|
|
145
|
+
command,
|
|
146
|
+
cwd=self.project_root,
|
|
147
|
+
capture_output=True,
|
|
148
|
+
text=True,
|
|
149
|
+
encoding="utf-8",
|
|
150
|
+
errors="replace",
|
|
151
|
+
timeout=20,
|
|
152
|
+
)
|
|
153
|
+
return _RunResult(
|
|
154
|
+
returncode=result.returncode,
|
|
155
|
+
output=(result.stdout or "") + (result.stderr or ""),
|
|
156
|
+
)
|
|
157
|
+
except Exception as exc:
|
|
158
|
+
return _RunResult(returncode=1, output=str(exc))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class _RunResult(BaseModel):
|
|
162
|
+
returncode: int
|
|
163
|
+
output: str
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import httpx
|
|
2
|
+
import logging
|
|
3
|
+
from devcouncil.artifacts.graph import ArtifactGraph
|
|
4
|
+
from devcouncil.reporting.github_check import GitHubCheckGenerator
|
|
5
|
+
|
|
6
|
+
logger = logging.getLogger(__name__)
|
|
7
|
+
|
|
8
|
+
class GitHubIntegration:
|
|
9
|
+
"""Manages interactions with GitHub API, specifically PR Checks."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, github_token: str, repository: str, commit_sha: str):
|
|
12
|
+
self.github_token = github_token
|
|
13
|
+
self.repository = repository
|
|
14
|
+
self.commit_sha = commit_sha
|
|
15
|
+
self.base_url = f"https://api.github.com/repos/{repository}"
|
|
16
|
+
|
|
17
|
+
async def report_verification(self, graph: ArtifactGraph):
|
|
18
|
+
"""Creates or updates a GitHub Check Run with the current verification status."""
|
|
19
|
+
payload = GitHubCheckGenerator.generate(graph)
|
|
20
|
+
payload["head_sha"] = self.commit_sha
|
|
21
|
+
|
|
22
|
+
headers = {
|
|
23
|
+
"Authorization": f"Bearer {self.github_token}",
|
|
24
|
+
"Accept": "application/vnd.github.v3+json",
|
|
25
|
+
"Content-Type": "application/json"
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async with httpx.AsyncClient() as client:
|
|
29
|
+
try:
|
|
30
|
+
response = await client.post(
|
|
31
|
+
f"{self.base_url}/check-runs",
|
|
32
|
+
headers=headers,
|
|
33
|
+
json=payload
|
|
34
|
+
)
|
|
35
|
+
response.raise_for_status()
|
|
36
|
+
logger.info(f"GitHub PR Check updated for {self.repository} at {self.commit_sha}")
|
|
37
|
+
except Exception as e:
|
|
38
|
+
logger.error(f"Failed to report to GitHub: {e}")
|
|
39
|
+
raise
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from devcouncil.indexing.graph_index import GraphIndex
|
|
4
|
+
|
|
5
|
+
console = Console()
|
|
6
|
+
|
|
7
|
+
class GitNexusIntegration:
|
|
8
|
+
"""
|
|
9
|
+
Integration for GitNexus: Codebase knowledge graph and structural awareness.
|
|
10
|
+
"""
|
|
11
|
+
def __init__(self, project_root: Path):
|
|
12
|
+
self.project_root = project_root
|
|
13
|
+
|
|
14
|
+
def initialize(self):
|
|
15
|
+
console.print("[cyan]Initializing GitNexus context...[/cyan]")
|
|
16
|
+
nexus_dir = self.project_root / ".devcouncil" / "nexus"
|
|
17
|
+
nexus_dir.mkdir(exist_ok=True)
|
|
18
|
+
# Mock initialization logic
|
|
19
|
+
(nexus_dir / "index_config.json").write_text('{"mode": "structural", "version": "1.0"}')
|
|
20
|
+
console.print(" - GitNexus structural awareness active.")
|
|
21
|
+
|
|
22
|
+
def sync_graph(self, graph_index: GraphIndex):
|
|
23
|
+
"""
|
|
24
|
+
Export DevCouncil artifact graph to GitNexus.
|
|
25
|
+
"""
|
|
26
|
+
console.print(" - Syncing DevCouncil artifacts to GitNexus...")
|
|
27
|
+
pass
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
|
|
4
|
+
console = Console()
|
|
5
|
+
|
|
6
|
+
class GraphifyIntegration:
|
|
7
|
+
"""
|
|
8
|
+
Integration for graphify: Always-on graph context and multi-agent integration.
|
|
9
|
+
"""
|
|
10
|
+
def __init__(self, project_root: Path):
|
|
11
|
+
self.project_root = project_root
|
|
12
|
+
|
|
13
|
+
def initialize(self):
|
|
14
|
+
console.print("[magenta]Initializing Graphify engine...[/magenta]")
|
|
15
|
+
graphify_config = self.project_root / ".devcouncil" / "graphify.yaml"
|
|
16
|
+
# Create a default graphify config
|
|
17
|
+
content = """
|
|
18
|
+
graph:
|
|
19
|
+
engine: internal
|
|
20
|
+
persist: true
|
|
21
|
+
agents:
|
|
22
|
+
shared_context: true
|
|
23
|
+
hooks:
|
|
24
|
+
enabled: true
|
|
25
|
+
"""
|
|
26
|
+
graphify_config.write_text(content.strip())
|
|
27
|
+
console.print(" - Graphify engine configured and ready.")
|
|
28
|
+
|
|
29
|
+
def apply_rules(self):
|
|
30
|
+
"""
|
|
31
|
+
Apply graph-based rules to implementation plans.
|
|
32
|
+
"""
|
|
33
|
+
console.print(" - Graphify checking architectural rules...")
|
|
34
|
+
pass
|
|
File without changes
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from mcp.server import Server
|
|
5
|
+
from mcp.server.stdio import stdio_server
|
|
6
|
+
from mcp.types import Tool, TextContent
|
|
7
|
+
from devcouncil.storage.db import get_db
|
|
8
|
+
from devcouncil.storage.repositories import TaskRepository, ArtifactGraphRepository, StateRepository
|
|
9
|
+
from devcouncil.reporting.report_builder import ReportBuilder
|
|
10
|
+
from devcouncil.integrations.code_review_graph import CodeReviewGraphAdapter
|
|
11
|
+
|
|
12
|
+
app = Server("devcouncil")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _project_root() -> Path:
|
|
16
|
+
configured = os.environ.get("DEVCOUNCIL_PROJECT_ROOT")
|
|
17
|
+
return Path(configured).expanduser().resolve() if configured else Path(".")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _computed_phase(graph) -> str:
|
|
21
|
+
reqs = list(graph.requirements.values())
|
|
22
|
+
tasks = list(graph.tasks.values())
|
|
23
|
+
blocking_gaps = graph.blocking_gaps()
|
|
24
|
+
if not reqs and not tasks:
|
|
25
|
+
return "NEW"
|
|
26
|
+
if reqs and not tasks:
|
|
27
|
+
return "REQUIREMENTS_DRAFTED"
|
|
28
|
+
if blocking_gaps:
|
|
29
|
+
return "TASK_BLOCKED"
|
|
30
|
+
if tasks:
|
|
31
|
+
statuses = {task.status for task in tasks}
|
|
32
|
+
if "running" in statuses:
|
|
33
|
+
return "TASK_EXECUTING"
|
|
34
|
+
if "blocked" in statuses:
|
|
35
|
+
return "TASK_BLOCKED"
|
|
36
|
+
if all(status in {"verified", "done"} for status in statuses):
|
|
37
|
+
return "PROJECT_DONE"
|
|
38
|
+
return "PLAN_APPROVED"
|
|
39
|
+
return "NEW"
|
|
40
|
+
|
|
41
|
+
@app.list_tools()
|
|
42
|
+
async def list_tools() -> list[Tool]:
|
|
43
|
+
return [
|
|
44
|
+
Tool(
|
|
45
|
+
name="devcouncil_status",
|
|
46
|
+
description="Get the current status of the DevCouncil project, including phase, tasks, and gaps.",
|
|
47
|
+
inputSchema={
|
|
48
|
+
"type": "object",
|
|
49
|
+
"properties": {}
|
|
50
|
+
}
|
|
51
|
+
),
|
|
52
|
+
Tool(
|
|
53
|
+
name="devcouncil_report",
|
|
54
|
+
description="Get the full coverage report and a list of all requirements and blocking gaps.",
|
|
55
|
+
inputSchema={
|
|
56
|
+
"type": "object",
|
|
57
|
+
"properties": {}
|
|
58
|
+
}
|
|
59
|
+
),
|
|
60
|
+
Tool(
|
|
61
|
+
name="devcouncil_get_task",
|
|
62
|
+
description="Get details, constraints, and requirements for a specific implementation task.",
|
|
63
|
+
inputSchema={
|
|
64
|
+
"type": "object",
|
|
65
|
+
"properties": {
|
|
66
|
+
"task_id": {
|
|
67
|
+
"type": "string",
|
|
68
|
+
"description": "The ID of the task, e.g. TASK-001"
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
"required": ["task_id"]
|
|
72
|
+
}
|
|
73
|
+
),
|
|
74
|
+
Tool(
|
|
75
|
+
name="devcouncil_graph_context",
|
|
76
|
+
description="Get optional code-review-graph structural context for changed or planned files.",
|
|
77
|
+
inputSchema={
|
|
78
|
+
"type": "object",
|
|
79
|
+
"properties": {
|
|
80
|
+
"files": {
|
|
81
|
+
"type": "array",
|
|
82
|
+
"items": {"type": "string"},
|
|
83
|
+
"description": "Repository-relative files to contextualize.",
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
),
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
@app.call_tool()
|
|
91
|
+
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|
92
|
+
db = get_db(_project_root())
|
|
93
|
+
if not db:
|
|
94
|
+
return [TextContent(type="text", text="Error: DevCouncil not initialized in this directory.")]
|
|
95
|
+
|
|
96
|
+
if name == "devcouncil_status":
|
|
97
|
+
with db.get_session() as session:
|
|
98
|
+
graph_repo = ArtifactGraphRepository(session)
|
|
99
|
+
graph = graph_repo.load_graph()
|
|
100
|
+
summary = graph.coverage_summary()
|
|
101
|
+
state = StateRepository(session).get_state()
|
|
102
|
+
phase = state.current_phase if state else _computed_phase(graph)
|
|
103
|
+
|
|
104
|
+
status_str = f"Phase: {phase}\n"
|
|
105
|
+
status_str += f"Requirements: {summary['total_requirements']} ({summary['requirements_without_tasks']} unmapped)\n"
|
|
106
|
+
status_str += f"Tasks: {summary['total_tasks']} ({summary['tasks_without_requirements']} orphaned)\n"
|
|
107
|
+
status_str += f"Gaps: {summary['total_gaps']} ({summary['blocking_gaps']} blocking)\n"
|
|
108
|
+
|
|
109
|
+
return [TextContent(type="text", text=status_str)]
|
|
110
|
+
|
|
111
|
+
elif name == "devcouncil_report":
|
|
112
|
+
with db.get_session() as session:
|
|
113
|
+
graph_repo = ArtifactGraphRepository(session)
|
|
114
|
+
graph = graph_repo.load_graph()
|
|
115
|
+
markdown_report = ReportBuilder.build_markdown(graph)
|
|
116
|
+
return [TextContent(type="text", text=markdown_report)]
|
|
117
|
+
|
|
118
|
+
elif name == "devcouncil_get_task":
|
|
119
|
+
task_id = arguments.get("task_id")
|
|
120
|
+
if not task_id:
|
|
121
|
+
return [TextContent(type="text", text="Error: Missing task_id")]
|
|
122
|
+
|
|
123
|
+
with db.get_session() as session:
|
|
124
|
+
task_repo = TaskRepository(session)
|
|
125
|
+
task = task_repo.get_by_id(task_id)
|
|
126
|
+
if not task:
|
|
127
|
+
return [TextContent(type="text", text=f"Error: Task {task_id} not found.")]
|
|
128
|
+
|
|
129
|
+
return [TextContent(type="text", text=task.model_dump_json(indent=2))]
|
|
130
|
+
|
|
131
|
+
elif name == "devcouncil_graph_context":
|
|
132
|
+
files = arguments.get("files", [])
|
|
133
|
+
if not isinstance(files, list):
|
|
134
|
+
files = []
|
|
135
|
+
context = CodeReviewGraphAdapter(_project_root()).get_context([str(file) for file in files])
|
|
136
|
+
return [TextContent(type="text", text=context.model_dump_json(indent=2))]
|
|
137
|
+
|
|
138
|
+
return [TextContent(type="text", text=f"Unknown tool: {name}")]
|
|
139
|
+
|
|
140
|
+
async def run():
|
|
141
|
+
# Use stdio to communicate
|
|
142
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
143
|
+
await app.run(read_stream, write_stream, app.create_initialization_options())
|
|
144
|
+
|
|
145
|
+
if __name__ == "__main__":
|
|
146
|
+
asyncio.run(run())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import hashlib
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional
|
|
5
|
+
from devcouncil.llm.provider import LLMResponse
|
|
6
|
+
|
|
7
|
+
class LLMCache:
|
|
8
|
+
def __init__(self, project_root: Path):
|
|
9
|
+
self.cache_dir = project_root / ".devcouncil" / "cache" / "llm"
|
|
10
|
+
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
|
11
|
+
|
|
12
|
+
def _get_key(self, model: str, messages: list, temp: float, json_mode: bool) -> str:
|
|
13
|
+
data = {
|
|
14
|
+
"model": model,
|
|
15
|
+
"messages": messages,
|
|
16
|
+
"temp": temp,
|
|
17
|
+
"json_mode": json_mode
|
|
18
|
+
}
|
|
19
|
+
s = json.dumps(data, sort_keys=True)
|
|
20
|
+
return hashlib.sha256(s.encode("utf-8")).hexdigest()
|
|
21
|
+
|
|
22
|
+
def get(self, model: str, messages: list, temp: float, json_mode: bool) -> Optional[LLMResponse]:
|
|
23
|
+
key = self._get_key(model, messages, temp, json_mode)
|
|
24
|
+
cache_file = self.cache_dir / f"{key}.json"
|
|
25
|
+
if cache_file.exists():
|
|
26
|
+
try:
|
|
27
|
+
with open(cache_file, "r") as f:
|
|
28
|
+
data = json.load(f)
|
|
29
|
+
return LLMResponse(**data)
|
|
30
|
+
except Exception:
|
|
31
|
+
pass
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
def set(self, model: str, messages: list, temp: float, json_mode: bool, response: LLMResponse):
|
|
35
|
+
key = self._get_key(model, messages, temp, json_mode)
|
|
36
|
+
cache_file = self.cache_dir / f"{key}.json"
|
|
37
|
+
with open(cache_file, "w") as f:
|
|
38
|
+
json.dump(response.model_dump(), f)
|