devcouncil 0.1.0 → 0.1.1
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 -201
- package/README.md +62 -543
- package/package.json +1 -1
- package/pyproject.toml +29 -26
- package/src/devcouncil/__main__.py +4 -4
- package/src/devcouncil/app/__init__.py +28 -28
- package/src/devcouncil/app/config.py +135 -108
- package/src/devcouncil/app/errors.py +23 -23
- package/src/devcouncil/app/events.py +44 -44
- package/src/devcouncil/app/orchestrator.py +67 -67
- package/src/devcouncil/app/project_status.py +29 -0
- package/src/devcouncil/app/run_context.py +39 -39
- package/src/devcouncil/app/state_machine.py +108 -108
- package/src/devcouncil/artifacts/__init__.py +1 -1
- package/src/devcouncil/artifacts/coverage.py +96 -96
- package/src/devcouncil/artifacts/graph.py +143 -143
- package/src/devcouncil/artifacts/migrations.py +20 -20
- package/src/devcouncil/artifacts/schemas.py +23 -23
- package/src/devcouncil/artifacts/serializer.py +21 -21
- package/src/devcouncil/artifacts/validators.py +27 -27
- package/src/devcouncil/cli/commands/artifacts.py +51 -48
- package/src/devcouncil/cli/commands/ast.py +22 -0
- package/src/devcouncil/cli/commands/baseline.py +35 -32
- package/src/devcouncil/cli/commands/config.py +76 -54
- package/src/devcouncil/cli/commands/dashboard.py +26 -0
- package/src/devcouncil/cli/commands/doctor.py +86 -42
- package/src/devcouncil/cli/commands/go.py +237 -0
- package/src/devcouncil/cli/commands/hook.py +96 -29
- package/src/devcouncil/cli/commands/init.py +67 -56
- package/src/devcouncil/cli/commands/integrate.py +320 -14
- package/src/devcouncil/cli/commands/lsp.py +20 -0
- package/src/devcouncil/cli/commands/map.py +25 -21
- package/src/devcouncil/cli/commands/plan.py +257 -206
- package/src/devcouncil/cli/commands/prompt.py +36 -33
- package/src/devcouncil/cli/commands/repair.py +72 -69
- package/src/devcouncil/cli/commands/report.py +112 -54
- package/src/devcouncil/cli/commands/reset_demo_state.py +31 -28
- package/src/devcouncil/cli/commands/rollback.py +49 -47
- package/src/devcouncil/cli/commands/run.py +252 -207
- package/src/devcouncil/cli/commands/setup.py +159 -18
- package/src/devcouncil/cli/commands/show.py +76 -57
- package/src/devcouncil/cli/commands/status.py +117 -105
- package/src/devcouncil/cli/commands/tasks.py +55 -41
- package/src/devcouncil/cli/commands/trace.py +2 -1
- package/src/devcouncil/cli/commands/verify.py +158 -128
- package/src/devcouncil/cli/commands/version.py +20 -20
- package/src/devcouncil/cli/commands/watch.py +574 -0
- package/src/devcouncil/cli/main.py +42 -24
- package/src/devcouncil/council/prompts/arbiter.md +19 -19
- package/src/devcouncil/council/prompts/critic_a.md +10 -10
- package/src/devcouncil/council/prompts/critic_b.md +10 -10
- package/src/devcouncil/council/prompts/implementation_reviewer.md +16 -16
- package/src/devcouncil/council/prompts/planner_a.md +16 -16
- package/src/devcouncil/council/prompts/planner_b.md +16 -16
- package/src/devcouncil/council/prompts/rebuttal.md +10 -10
- package/src/devcouncil/council/prompts/spec_writer.md +12 -12
- package/src/devcouncil/domain/assumption.py +17 -17
- package/src/devcouncil/domain/critique.py +32 -32
- package/src/devcouncil/domain/evidence.py +27 -27
- package/src/devcouncil/domain/gap.py +26 -26
- package/src/devcouncil/domain/requirement.py +22 -22
- package/src/devcouncil/domain/task.py +26 -26
- package/src/devcouncil/execution/__init__.py +1 -1
- package/src/devcouncil/execution/context_builder.py +54 -54
- package/src/devcouncil/execution/executor.py +15 -15
- package/src/devcouncil/execution/hook_policy.py +24 -3
- package/src/devcouncil/execution/patch.py +28 -28
- package/src/devcouncil/execution/permissions.py +44 -44
- package/src/devcouncil/execution/prompt_builder.py +23 -23
- package/src/devcouncil/execution/task_runner.py +63 -63
- package/src/devcouncil/executors/__init__.py +1 -1
- package/src/devcouncil/executors/coding_cli.py +112 -0
- package/src/devcouncil/executors/mini_swe.py +63 -63
- package/src/devcouncil/executors/native/agent.py +81 -81
- package/src/devcouncil/executors/openhands.py +56 -56
- package/src/devcouncil/gating/__init__.py +1 -1
- package/src/devcouncil/gating/checks/clean_git.py +50 -45
- package/src/devcouncil/gating/checks/planned_files_check.py +32 -32
- package/src/devcouncil/gating/checks/requirement_coverage.py +26 -26
- package/src/devcouncil/gating/checks/secret_scan_check.py +34 -34
- package/src/devcouncil/gating/policy.py +157 -157
- package/src/devcouncil/indexing/__init__.py +1 -1
- package/src/devcouncil/indexing/ast_matcher.py +168 -0
- package/src/devcouncil/indexing/graph_index.py +48 -48
- package/src/devcouncil/indexing/lsp.py +120 -0
- package/src/devcouncil/indexing/repo_mapper.py +208 -204
- package/src/devcouncil/integrations/github.py +35 -35
- package/src/devcouncil/integrations/gitnexus.py +27 -27
- package/src/devcouncil/integrations/graphify.py +34 -34
- package/src/devcouncil/integrations/mcp/server.py +549 -96
- package/src/devcouncil/integrations/pr_comments.py +62 -0
- package/src/devcouncil/live/__init__.py +2 -0
- package/src/devcouncil/live/cards.py +207 -0
- package/src/devcouncil/live/models.py +63 -0
- package/src/devcouncil/live/repair_prompt.py +83 -0
- package/src/devcouncil/live/reviewer.py +70 -0
- package/src/devcouncil/live/signals.py +135 -0
- package/src/devcouncil/live/summary.py +34 -0
- package/src/devcouncil/live/tasks.py +18 -0
- package/src/devcouncil/live/transcripts.py +138 -0
- package/src/devcouncil/llm/__init__.py +1 -1
- package/src/devcouncil/llm/cache.py +38 -38
- package/src/devcouncil/llm/provider.py +146 -125
- package/src/devcouncil/llm/router.py +111 -111
- package/src/devcouncil/planning/__init__.py +1 -1
- package/src/devcouncil/planning/arbiter_service.py +57 -57
- package/src/devcouncil/planning/critique_service.py +66 -66
- package/src/devcouncil/planning/plan_service.py +46 -46
- package/src/devcouncil/planning/prompt_enhancer_service.py +86 -0
- package/src/devcouncil/planning/repair_service.py +39 -39
- package/src/devcouncil/planning/spec_service.py +44 -44
- package/src/devcouncil/reporting/github_check.py +32 -32
- package/src/devcouncil/reporting/json_report.py +20 -17
- package/src/devcouncil/reporting/markdown_report.py +68 -46
- package/src/devcouncil/reporting/report_builder.py +14 -14
- package/src/devcouncil/storage/db.py +66 -66
- package/src/devcouncil/storage/models.py +83 -83
- package/src/devcouncil/storage/repositories.py +299 -222
- package/src/devcouncil/telemetry/cost.py +34 -34
- package/src/devcouncil/telemetry/tracker.py +49 -49
- package/src/devcouncil/ui/__init__.py +1 -0
- package/src/devcouncil/ui/dashboard.py +122 -0
- package/src/devcouncil/utils/__init__.py +1 -1
- package/src/devcouncil/utils/redaction.py +141 -141
- package/src/devcouncil/verification/__init__.py +1 -1
- package/src/devcouncil/verification/implementation_reviewer.py +55 -55
- package/src/devcouncil/verification/verifier.py +319 -302
- package/uv.lock +1 -1
|
@@ -7,45 +7,46 @@ import json
|
|
|
7
7
|
import shlex
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from typing import List, Dict, Any, Optional, Tuple
|
|
10
|
-
|
|
11
|
-
from devcouncil.app.config import load_config
|
|
12
|
-
|
|
13
|
-
from devcouncil.domain.task import Task
|
|
14
|
-
from devcouncil.domain.requirement import Requirement
|
|
15
|
-
from devcouncil.domain.gap import Gap
|
|
16
|
-
from devcouncil.domain.evidence import TestEvidence, DiffEvidence, CommandResult
|
|
10
|
+
|
|
11
|
+
from devcouncil.app.config import load_config
|
|
12
|
+
|
|
13
|
+
from devcouncil.domain.task import Task
|
|
14
|
+
from devcouncil.domain.requirement import Requirement
|
|
15
|
+
from devcouncil.domain.gap import Gap
|
|
16
|
+
from devcouncil.domain.evidence import TestEvidence, DiffEvidence, CommandResult
|
|
17
17
|
from devcouncil.gating.checks.secret_scan_check import SecretScanner
|
|
18
18
|
from devcouncil.verification.implementation_reviewer import ImplementationReviewer
|
|
19
19
|
from devcouncil.llm.router import ModelRouter
|
|
20
20
|
from devcouncil.utils.redaction import redact_string
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
from devcouncil.live.cards import unresolved_blocking_cards
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
24
25
|
IGNORED_CHANGE_PATTERNS = (
|
|
25
|
-
"__pycache__/*",
|
|
26
|
-
"*/__pycache__/*",
|
|
27
|
-
"*.pyc",
|
|
28
|
-
"*.pyo",
|
|
29
|
-
".pytest_cache/*",
|
|
30
|
-
".mypy_cache/*",
|
|
31
|
-
".ruff_cache/*",
|
|
26
|
+
"__pycache__/*",
|
|
27
|
+
"*/__pycache__/*",
|
|
28
|
+
"*.pyc",
|
|
29
|
+
"*.pyo",
|
|
30
|
+
".pytest_cache/*",
|
|
31
|
+
".mypy_cache/*",
|
|
32
|
+
".ruff_cache/*",
|
|
32
33
|
".devcouncil/*",
|
|
33
34
|
)
|
|
34
35
|
|
|
35
36
|
MAX_UNTRACKED_DIFF_BYTES = 256_000
|
|
36
|
-
|
|
37
|
-
class Verifier:
|
|
38
|
-
def __init__(self, project_root: Path, router: Optional[ModelRouter] = None):
|
|
39
|
-
self.project_root = project_root
|
|
40
|
-
self._gap_counter = 0
|
|
41
|
-
self.secret_scanner = SecretScanner()
|
|
42
|
-
self.reviewer = ImplementationReviewer(router) if router else None
|
|
43
|
-
|
|
44
|
-
def _next_gap_id(self, task_id: str, suffix: str) -> str:
|
|
45
|
-
"""Generate unique gap IDs to prevent SQLite overwrites."""
|
|
46
|
-
self._gap_counter += 1
|
|
47
|
-
return f"GAP-{task_id}-{suffix}-{uuid.uuid4().hex[:6]}-{self._gap_counter:03d}"
|
|
48
|
-
|
|
37
|
+
|
|
38
|
+
class Verifier:
|
|
39
|
+
def __init__(self, project_root: Path, router: Optional[ModelRouter] = None):
|
|
40
|
+
self.project_root = project_root
|
|
41
|
+
self._gap_counter = 0
|
|
42
|
+
self.secret_scanner = SecretScanner()
|
|
43
|
+
self.reviewer = ImplementationReviewer(router) if router else None
|
|
44
|
+
|
|
45
|
+
def _next_gap_id(self, task_id: str, suffix: str) -> str:
|
|
46
|
+
"""Generate unique gap IDs to prevent SQLite overwrites."""
|
|
47
|
+
self._gap_counter += 1
|
|
48
|
+
return f"GAP-{task_id}-{suffix}-{uuid.uuid4().hex[:6]}-{self._gap_counter:03d}"
|
|
49
|
+
|
|
49
50
|
def get_diff(self) -> str:
|
|
50
51
|
try:
|
|
51
52
|
if not self._has_head():
|
|
@@ -60,7 +61,7 @@ class Verifier:
|
|
|
60
61
|
return ""
|
|
61
62
|
|
|
62
63
|
def get_changed_files(self) -> List[str]:
|
|
63
|
-
try:
|
|
64
|
+
try:
|
|
64
65
|
if not self._has_head():
|
|
65
66
|
return self._get_status_files()
|
|
66
67
|
output = subprocess.check_output(
|
|
@@ -72,56 +73,56 @@ class Verifier:
|
|
|
72
73
|
except Exception as e:
|
|
73
74
|
logger.warning("Failed to get changed files: %s", e)
|
|
74
75
|
return []
|
|
75
|
-
|
|
76
|
-
def get_task_changed_files(self, task_id: str) -> List[str]:
|
|
77
|
-
changed = set(self.get_changed_files())
|
|
78
|
-
changed.difference_update(self._load_baseline_files())
|
|
79
|
-
changed.difference_update(self._load_task_snapshot_files(task_id))
|
|
80
|
-
return sorted(changed)
|
|
81
|
-
|
|
82
|
-
def _has_head(self) -> bool:
|
|
83
|
-
return subprocess.run(
|
|
84
|
-
["git", "rev-parse", "--verify", "HEAD"],
|
|
85
|
-
cwd=self.project_root,
|
|
86
|
-
stdout=subprocess.DEVNULL,
|
|
87
|
-
stderr=subprocess.DEVNULL,
|
|
88
|
-
).returncode == 0
|
|
89
|
-
|
|
76
|
+
|
|
77
|
+
def get_task_changed_files(self, task_id: str) -> List[str]:
|
|
78
|
+
changed = set(self.get_changed_files())
|
|
79
|
+
changed.difference_update(self._load_baseline_files())
|
|
80
|
+
changed.difference_update(self._load_task_snapshot_files(task_id))
|
|
81
|
+
return sorted(changed)
|
|
82
|
+
|
|
83
|
+
def _has_head(self) -> bool:
|
|
84
|
+
return subprocess.run(
|
|
85
|
+
["git", "rev-parse", "--verify", "HEAD"],
|
|
86
|
+
cwd=self.project_root,
|
|
87
|
+
stdout=subprocess.DEVNULL,
|
|
88
|
+
stderr=subprocess.DEVNULL,
|
|
89
|
+
).returncode == 0
|
|
90
|
+
|
|
90
91
|
def _get_initial_repo_diff(self) -> str:
|
|
91
92
|
parts: List[str] = []
|
|
92
93
|
for cmd in (["git", "diff", "--cached"], ["git", "diff"]):
|
|
93
|
-
result = subprocess.run(
|
|
94
|
-
cmd,
|
|
95
|
-
cwd=self.project_root,
|
|
96
|
-
capture_output=True,
|
|
97
|
-
text=True,
|
|
98
|
-
encoding="utf-8",
|
|
99
|
-
errors="replace",
|
|
100
|
-
)
|
|
94
|
+
result = subprocess.run(
|
|
95
|
+
cmd,
|
|
96
|
+
cwd=self.project_root,
|
|
97
|
+
capture_output=True,
|
|
98
|
+
text=True,
|
|
99
|
+
encoding="utf-8",
|
|
100
|
+
errors="replace",
|
|
101
|
+
)
|
|
101
102
|
if result.returncode == 0 and result.stdout:
|
|
102
103
|
parts.append(result.stdout)
|
|
103
104
|
untracked_diff = self._get_untracked_files_diff()
|
|
104
105
|
if untracked_diff:
|
|
105
106
|
parts.append(untracked_diff)
|
|
106
107
|
return "\n".join(parts)
|
|
107
|
-
|
|
108
|
+
|
|
108
109
|
def _get_status_files(self) -> List[str]:
|
|
109
|
-
files: set[str] = set()
|
|
110
|
-
commands = (
|
|
111
|
-
["git", "diff", "--cached", "--name-only"],
|
|
112
|
-
["git", "diff", "--name-only"],
|
|
113
|
-
["git", "ls-files", "--others", "--exclude-standard"],
|
|
114
|
-
)
|
|
115
|
-
for cmd in commands:
|
|
116
|
-
try:
|
|
117
|
-
output = subprocess.check_output(
|
|
118
|
-
cmd,
|
|
119
|
-
cwd=self.project_root,
|
|
120
|
-
stderr=subprocess.DEVNULL,
|
|
121
|
-
).decode("utf-8", errors="replace").splitlines()
|
|
122
|
-
files.update(path.replace("\\", "/") for path in output if path.strip())
|
|
123
|
-
except subprocess.CalledProcessError:
|
|
124
|
-
continue
|
|
110
|
+
files: set[str] = set()
|
|
111
|
+
commands = (
|
|
112
|
+
["git", "diff", "--cached", "--name-only"],
|
|
113
|
+
["git", "diff", "--name-only"],
|
|
114
|
+
["git", "ls-files", "--others", "--exclude-standard"],
|
|
115
|
+
)
|
|
116
|
+
for cmd in commands:
|
|
117
|
+
try:
|
|
118
|
+
output = subprocess.check_output(
|
|
119
|
+
cmd,
|
|
120
|
+
cwd=self.project_root,
|
|
121
|
+
stderr=subprocess.DEVNULL,
|
|
122
|
+
).decode("utf-8", errors="replace").splitlines()
|
|
123
|
+
files.update(path.replace("\\", "/") for path in output if path.strip())
|
|
124
|
+
except subprocess.CalledProcessError:
|
|
125
|
+
continue
|
|
125
126
|
if not files:
|
|
126
127
|
files.update(self._walk_project_files())
|
|
127
128
|
return self._filter_change_paths(sorted(files))
|
|
@@ -181,90 +182,90 @@ class Verifier:
|
|
|
181
182
|
if truncated:
|
|
182
183
|
diff_lines.append("+[devcouncil: untracked file diff truncated]")
|
|
183
184
|
return "\n".join(diff_lines)
|
|
184
|
-
|
|
185
|
-
def _walk_project_files(self) -> List[str]:
|
|
186
|
-
files: List[str] = []
|
|
187
|
-
for path in self.project_root.rglob("*"):
|
|
188
|
-
if not path.is_file():
|
|
189
|
-
continue
|
|
190
|
-
rel = path.relative_to(self.project_root).as_posix()
|
|
191
|
-
if rel.startswith(".git/"):
|
|
192
|
-
continue
|
|
193
|
-
files.append(rel)
|
|
194
|
-
return files
|
|
195
|
-
|
|
196
|
-
def _filter_change_paths(self, paths: List[str]) -> List[str]:
|
|
197
|
-
return [
|
|
198
|
-
path
|
|
199
|
-
for path in (p.strip().replace("\\", "/") for p in paths)
|
|
200
|
-
if path and not self._is_ignored_change(path)
|
|
201
|
-
]
|
|
202
|
-
|
|
203
|
-
def _is_ignored_change(self, path: str) -> bool:
|
|
204
|
-
return any(fnmatch.fnmatch(path, pattern) for pattern in IGNORED_CHANGE_PATTERNS)
|
|
205
|
-
|
|
206
|
-
def _load_baseline_files(self) -> set[str]:
|
|
207
|
-
return self._load_snapshot_files(self.project_root / ".devcouncil" / "baseline.json")
|
|
208
|
-
|
|
209
|
-
def _load_task_snapshot_files(self, task_id: str) -> set[str]:
|
|
210
|
-
return self._load_snapshot_files(
|
|
211
|
-
self.project_root / ".devcouncil" / "checkpoints" / f"{task_id}-before.json"
|
|
212
|
-
)
|
|
213
|
-
|
|
214
|
-
def _load_snapshot_files(self, path: Path) -> set[str]:
|
|
215
|
-
if not path.exists():
|
|
216
|
-
return set()
|
|
217
|
-
try:
|
|
218
|
-
data = json.loads(path.read_text(encoding="utf-8"))
|
|
219
|
-
return {
|
|
220
|
-
item.replace("\\", "/")
|
|
221
|
-
for item in data.get("changed_files", [])
|
|
222
|
-
if isinstance(item, str)
|
|
223
|
-
}
|
|
224
|
-
except Exception as e:
|
|
225
|
-
logger.warning("Failed to load verification snapshot %s: %s", path, e)
|
|
226
|
-
return set()
|
|
227
|
-
|
|
228
|
-
def _load_commands(self) -> Dict[str, List[str]]:
|
|
229
|
-
try:
|
|
230
|
-
config = load_config(self.project_root)
|
|
231
|
-
return {
|
|
232
|
-
"test": config.commands.test,
|
|
233
|
-
"lint": config.commands.lint,
|
|
234
|
-
"typecheck": config.commands.typecheck,
|
|
235
|
-
}
|
|
236
|
-
except Exception as e:
|
|
237
|
-
logger.warning("Failed to load config commands: %s", e)
|
|
238
|
-
return {}
|
|
239
|
-
|
|
240
|
-
def _save_log(self, label: str, command: str, stream: str, content: str) -> str:
|
|
241
|
-
"""Save command output to a log file and return the path."""
|
|
242
|
-
log_dir = self.project_root / ".devcouncil" / "logs"
|
|
243
|
-
log_dir.mkdir(parents=True, exist_ok=True)
|
|
244
|
-
cmd_hash = hashlib.sha256(command.encode()).hexdigest()[:8]
|
|
245
|
-
filename = f"{label}-{cmd_hash}-{stream}.log"
|
|
185
|
+
|
|
186
|
+
def _walk_project_files(self) -> List[str]:
|
|
187
|
+
files: List[str] = []
|
|
188
|
+
for path in self.project_root.rglob("*"):
|
|
189
|
+
if not path.is_file():
|
|
190
|
+
continue
|
|
191
|
+
rel = path.relative_to(self.project_root).as_posix()
|
|
192
|
+
if rel.startswith(".git/"):
|
|
193
|
+
continue
|
|
194
|
+
files.append(rel)
|
|
195
|
+
return files
|
|
196
|
+
|
|
197
|
+
def _filter_change_paths(self, paths: List[str]) -> List[str]:
|
|
198
|
+
return [
|
|
199
|
+
path
|
|
200
|
+
for path in (p.strip().replace("\\", "/") for p in paths)
|
|
201
|
+
if path and not self._is_ignored_change(path)
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
def _is_ignored_change(self, path: str) -> bool:
|
|
205
|
+
return any(fnmatch.fnmatch(path, pattern) for pattern in IGNORED_CHANGE_PATTERNS)
|
|
206
|
+
|
|
207
|
+
def _load_baseline_files(self) -> set[str]:
|
|
208
|
+
return self._load_snapshot_files(self.project_root / ".devcouncil" / "baseline.json")
|
|
209
|
+
|
|
210
|
+
def _load_task_snapshot_files(self, task_id: str) -> set[str]:
|
|
211
|
+
return self._load_snapshot_files(
|
|
212
|
+
self.project_root / ".devcouncil" / "checkpoints" / f"{task_id}-before.json"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def _load_snapshot_files(self, path: Path) -> set[str]:
|
|
216
|
+
if not path.exists():
|
|
217
|
+
return set()
|
|
218
|
+
try:
|
|
219
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
220
|
+
return {
|
|
221
|
+
item.replace("\\", "/")
|
|
222
|
+
for item in data.get("changed_files", [])
|
|
223
|
+
if isinstance(item, str)
|
|
224
|
+
}
|
|
225
|
+
except Exception as e:
|
|
226
|
+
logger.warning("Failed to load verification snapshot %s: %s", path, e)
|
|
227
|
+
return set()
|
|
228
|
+
|
|
229
|
+
def _load_commands(self) -> Dict[str, List[str]]:
|
|
230
|
+
try:
|
|
231
|
+
config = load_config(self.project_root)
|
|
232
|
+
return {
|
|
233
|
+
"test": config.commands.test,
|
|
234
|
+
"lint": config.commands.lint,
|
|
235
|
+
"typecheck": config.commands.typecheck,
|
|
236
|
+
}
|
|
237
|
+
except Exception as e:
|
|
238
|
+
logger.warning("Failed to load config commands: %s", e)
|
|
239
|
+
return {}
|
|
240
|
+
|
|
241
|
+
def _save_log(self, label: str, command: str, stream: str, content: str) -> str:
|
|
242
|
+
"""Save command output to a log file and return the path."""
|
|
243
|
+
log_dir = self.project_root / ".devcouncil" / "logs"
|
|
244
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
245
|
+
cmd_hash = hashlib.sha256(command.encode()).hexdigest()[:8]
|
|
246
|
+
filename = f"{label}-{cmd_hash}-{stream}.log"
|
|
246
247
|
log_path = log_dir / filename
|
|
247
248
|
log_path.write_text(redact_string(content), encoding="utf-8")
|
|
248
249
|
return str(log_path)
|
|
249
|
-
|
|
250
|
-
def _run_command(self, command: str, task_id: str = "verify") -> CommandResult:
|
|
251
|
-
try:
|
|
252
|
-
config = load_config(self.project_root)
|
|
253
|
-
timeout = config.execution.command_timeout
|
|
254
|
-
except Exception:
|
|
255
|
-
timeout = 300
|
|
256
|
-
|
|
257
|
-
try:
|
|
258
|
-
result = subprocess.run(
|
|
259
|
-
self._split_command(command),
|
|
260
|
-
shell=False,
|
|
261
|
-
capture_output=True,
|
|
262
|
-
text=True,
|
|
263
|
-
encoding="utf-8",
|
|
264
|
-
errors="replace",
|
|
265
|
-
cwd=self.project_root,
|
|
266
|
-
timeout=timeout,
|
|
267
|
-
)
|
|
250
|
+
|
|
251
|
+
def _run_command(self, command: str, task_id: str = "verify") -> CommandResult:
|
|
252
|
+
try:
|
|
253
|
+
config = load_config(self.project_root)
|
|
254
|
+
timeout = config.execution.command_timeout
|
|
255
|
+
except Exception:
|
|
256
|
+
timeout = 300
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
result = subprocess.run(
|
|
260
|
+
self._split_command(command),
|
|
261
|
+
shell=False,
|
|
262
|
+
capture_output=True,
|
|
263
|
+
text=True,
|
|
264
|
+
encoding="utf-8",
|
|
265
|
+
errors="replace",
|
|
266
|
+
cwd=self.project_root,
|
|
267
|
+
timeout=timeout,
|
|
268
|
+
)
|
|
268
269
|
stdout = result.stdout or ""
|
|
269
270
|
stderr = result.stderr or ""
|
|
270
271
|
stdout_path = self._save_log(task_id, command, "stdout", stdout)
|
|
@@ -282,23 +283,23 @@ class Verifier:
|
|
|
282
283
|
f"stderr: {stderr_summary}"
|
|
283
284
|
),
|
|
284
285
|
)
|
|
285
|
-
except Exception as e:
|
|
286
|
-
return CommandResult(
|
|
287
|
-
command=command,
|
|
288
|
-
exit_code=-1,
|
|
289
|
-
stdout_path="",
|
|
290
|
-
stderr_path="",
|
|
291
|
-
summary=f"Failed to run command: {e}",
|
|
292
|
-
)
|
|
293
|
-
|
|
294
|
-
def _split_command(self, command: str) -> List[str]:
|
|
295
|
-
return shlex.split(command, posix=False)
|
|
296
|
-
|
|
286
|
+
except Exception as e:
|
|
287
|
+
return CommandResult(
|
|
288
|
+
command=command,
|
|
289
|
+
exit_code=-1,
|
|
290
|
+
stdout_path="",
|
|
291
|
+
stderr_path="",
|
|
292
|
+
summary=f"Failed to run command: {e}",
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
def _split_command(self, command: str) -> List[str]:
|
|
296
|
+
return shlex.split(command, posix=False)
|
|
297
|
+
|
|
297
298
|
def _check_dependency_changes(self, changed_files: List[str]) -> List[str]:
|
|
298
|
-
dep_files = {
|
|
299
|
-
"package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml",
|
|
300
|
-
"requirements.txt", "pyproject.toml", "uv.lock", "Pipfile.lock",
|
|
301
|
-
"go.mod", "go.sum", "Cargo.toml", "Cargo.lock",
|
|
299
|
+
dep_files = {
|
|
300
|
+
"package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml",
|
|
301
|
+
"requirements.txt", "pyproject.toml", "uv.lock", "Pipfile.lock",
|
|
302
|
+
"go.mod", "go.sum", "Cargo.toml", "Cargo.lock",
|
|
302
303
|
}
|
|
303
304
|
return [f for f in changed_files if Path(f).name in dep_files]
|
|
304
305
|
|
|
@@ -325,14 +326,14 @@ class Verifier:
|
|
|
325
326
|
except Exception as e:
|
|
326
327
|
logger.debug("Failed to classify changed files: %s", e)
|
|
327
328
|
return sorted(added & changed_set), sorted(deleted & changed_set)
|
|
328
|
-
|
|
329
|
-
async def verify_task(self, task: Task, requirements: List[Requirement]) -> Tuple[List[Gap], List[Any]]:
|
|
330
|
-
self._gap_counter = 0
|
|
331
|
-
gaps: List[Gap] = []
|
|
332
|
-
evidence_to_save: List[Any] = []
|
|
333
|
-
changed_files = self.get_task_changed_files(task.id)
|
|
334
|
-
diff_content = self.get_diff()
|
|
335
|
-
|
|
329
|
+
|
|
330
|
+
async def verify_task(self, task: Task, requirements: List[Requirement]) -> Tuple[List[Gap], List[Any]]:
|
|
331
|
+
self._gap_counter = 0
|
|
332
|
+
gaps: List[Gap] = []
|
|
333
|
+
evidence_to_save: List[Any] = []
|
|
334
|
+
changed_files = self.get_task_changed_files(task.id)
|
|
335
|
+
diff_content = self.get_diff()
|
|
336
|
+
|
|
336
337
|
if diff_content:
|
|
337
338
|
added_files, deleted_files = self._classify_change_paths(changed_files)
|
|
338
339
|
diff_ev = DiffEvidence(
|
|
@@ -342,52 +343,52 @@ class Verifier:
|
|
|
342
343
|
deleted_files=deleted_files,
|
|
343
344
|
diff_summary=f"Diff captured for {len(changed_files)} files."
|
|
344
345
|
)
|
|
345
|
-
evidence_to_save.append(diff_ev)
|
|
346
|
-
|
|
347
|
-
# 1. Planned-file coverage check
|
|
348
|
-
planned_paths = {pf.path for pf in task.planned_files}
|
|
349
|
-
changed_set = set(changed_files)
|
|
350
|
-
for pf in task.planned_files:
|
|
351
|
-
if pf.path not in changed_set and pf.allowed_change != "read_only":
|
|
352
|
-
gaps.append(Gap(
|
|
353
|
-
id=self._next_gap_id(task.id, "FILE"),
|
|
354
|
-
severity="medium",
|
|
355
|
-
gap_type="planned_file_not_changed",
|
|
356
|
-
task_id=task.id,
|
|
357
|
-
description=f"Planned file {pf.path} was not modified.",
|
|
358
|
-
recommended_fix=f"Modify {pf.path} as planned or update the task.",
|
|
359
|
-
blocking=False,
|
|
360
|
-
))
|
|
361
|
-
|
|
362
|
-
# 2. Orphan-diff detection
|
|
363
|
-
for cf in changed_files:
|
|
364
|
-
if cf not in planned_paths:
|
|
365
|
-
gaps.append(Gap(
|
|
366
|
-
id=self._next_gap_id(task.id, "ORPHAN"),
|
|
367
|
-
severity="high",
|
|
368
|
-
gap_type="orphan_diff",
|
|
369
|
-
task_id=task.id,
|
|
370
|
-
description=f"File {cf} was modified but not planned for this task.",
|
|
371
|
-
evidence=[cf],
|
|
372
|
-
recommended_fix=f"Revert changes to {cf} or add it to the task's planned files.",
|
|
373
|
-
blocking=True,
|
|
374
|
-
))
|
|
375
|
-
|
|
376
|
-
# 3. Dependency change detection
|
|
377
|
-
dep_changes = self._check_dependency_changes(changed_files)
|
|
378
|
-
for dep_file in dep_changes:
|
|
379
|
-
if dep_file not in planned_paths:
|
|
380
|
-
gaps.append(Gap(
|
|
381
|
-
id=self._next_gap_id(task.id, "DEP"),
|
|
382
|
-
severity="high",
|
|
383
|
-
gap_type="dependency_risk",
|
|
384
|
-
task_id=task.id,
|
|
385
|
-
description=f"Dependency file {dep_file} was modified without being in planned files.",
|
|
386
|
-
evidence=[dep_file],
|
|
387
|
-
recommended_fix=f"Justify the dependency change or revert {dep_file}.",
|
|
388
|
-
blocking=True,
|
|
389
|
-
))
|
|
390
|
-
|
|
346
|
+
evidence_to_save.append(diff_ev)
|
|
347
|
+
|
|
348
|
+
# 1. Planned-file coverage check
|
|
349
|
+
planned_paths = {pf.path for pf in task.planned_files}
|
|
350
|
+
changed_set = set(changed_files)
|
|
351
|
+
for pf in task.planned_files:
|
|
352
|
+
if pf.path not in changed_set and pf.allowed_change != "read_only":
|
|
353
|
+
gaps.append(Gap(
|
|
354
|
+
id=self._next_gap_id(task.id, "FILE"),
|
|
355
|
+
severity="medium",
|
|
356
|
+
gap_type="planned_file_not_changed",
|
|
357
|
+
task_id=task.id,
|
|
358
|
+
description=f"Planned file {pf.path} was not modified.",
|
|
359
|
+
recommended_fix=f"Modify {pf.path} as planned or update the task.",
|
|
360
|
+
blocking=False,
|
|
361
|
+
))
|
|
362
|
+
|
|
363
|
+
# 2. Orphan-diff detection
|
|
364
|
+
for cf in changed_files:
|
|
365
|
+
if cf not in planned_paths:
|
|
366
|
+
gaps.append(Gap(
|
|
367
|
+
id=self._next_gap_id(task.id, "ORPHAN"),
|
|
368
|
+
severity="high",
|
|
369
|
+
gap_type="orphan_diff",
|
|
370
|
+
task_id=task.id,
|
|
371
|
+
description=f"File {cf} was modified but not planned for this task.",
|
|
372
|
+
evidence=[cf],
|
|
373
|
+
recommended_fix=f"Revert changes to {cf} or add it to the task's planned files.",
|
|
374
|
+
blocking=True,
|
|
375
|
+
))
|
|
376
|
+
|
|
377
|
+
# 3. Dependency change detection
|
|
378
|
+
dep_changes = self._check_dependency_changes(changed_files)
|
|
379
|
+
for dep_file in dep_changes:
|
|
380
|
+
if dep_file not in planned_paths:
|
|
381
|
+
gaps.append(Gap(
|
|
382
|
+
id=self._next_gap_id(task.id, "DEP"),
|
|
383
|
+
severity="high",
|
|
384
|
+
gap_type="dependency_risk",
|
|
385
|
+
task_id=task.id,
|
|
386
|
+
description=f"Dependency file {dep_file} was modified without being in planned files.",
|
|
387
|
+
evidence=[dep_file],
|
|
388
|
+
recommended_fix=f"Justify the dependency change or revert {dep_file}.",
|
|
389
|
+
blocking=True,
|
|
390
|
+
))
|
|
391
|
+
|
|
391
392
|
# 4. Run verification commands
|
|
392
393
|
command_results: List[CommandResult] = []
|
|
393
394
|
evidence_results: List[CommandResult] = []
|
|
@@ -400,83 +401,99 @@ class Verifier:
|
|
|
400
401
|
evidence_results.append(result)
|
|
401
402
|
if result.exit_code != 0:
|
|
402
403
|
gaps.append(Gap(
|
|
403
|
-
id=self._next_gap_id(task.id, cmd_type.upper()),
|
|
404
|
-
severity="high",
|
|
405
|
-
gap_type="test_failed",
|
|
406
|
-
task_id=task.id,
|
|
407
|
-
description=f"Command '{cmd}' failed with exit code {result.exit_code}.",
|
|
408
|
-
evidence=[result.summary[:500]],
|
|
409
|
-
recommended_fix=f"Fix the issues reported by '{cmd}'.",
|
|
410
|
-
blocking=True,
|
|
411
|
-
))
|
|
412
|
-
|
|
413
|
-
# 5. Acceptance-criteria evidence mapping
|
|
404
|
+
id=self._next_gap_id(task.id, cmd_type.upper()),
|
|
405
|
+
severity="high",
|
|
406
|
+
gap_type="test_failed",
|
|
407
|
+
task_id=task.id,
|
|
408
|
+
description=f"Command '{cmd}' failed with exit code {result.exit_code}.",
|
|
409
|
+
evidence=[result.summary[:500]],
|
|
410
|
+
recommended_fix=f"Fix the issues reported by '{cmd}'.",
|
|
411
|
+
blocking=True,
|
|
412
|
+
))
|
|
413
|
+
|
|
414
|
+
# 5. Acceptance-criteria evidence mapping
|
|
414
415
|
successful_commands = [result for result in evidence_results if result.exit_code == 0]
|
|
415
|
-
if task.acceptance_criterion_ids:
|
|
416
|
-
if successful_commands:
|
|
417
|
-
req_by_ac = {
|
|
418
|
-
ac.id: req.id
|
|
419
|
-
for req in requirements
|
|
420
|
-
for ac in req.acceptance_criteria
|
|
421
|
-
}
|
|
422
|
-
evidence_command = ", ".join(result.command for result in successful_commands)
|
|
423
|
-
for ac_id in task.acceptance_criterion_ids:
|
|
424
|
-
evidence_to_save.append(TestEvidence(
|
|
425
|
-
requirement_id=req_by_ac.get(ac_id, task.requirement_ids[0] if task.requirement_ids else ""),
|
|
426
|
-
acceptance_criterion_id=ac_id,
|
|
427
|
-
command=evidence_command,
|
|
428
|
-
status="passed",
|
|
429
|
-
evidence_summary=(
|
|
430
|
-
"Acceptance criterion linked to successful verification command(s): "
|
|
431
|
-
f"{evidence_command}"
|
|
432
|
-
),
|
|
433
|
-
))
|
|
434
|
-
else:
|
|
435
|
-
for ac_id in task.acceptance_criterion_ids:
|
|
436
|
-
gaps.append(Gap(
|
|
437
|
-
id=self._next_gap_id(task.id, "AC"),
|
|
438
|
-
severity="high",
|
|
439
|
-
gap_type="acceptance_criteria_unproven",
|
|
440
|
-
requirement_id=self._requirement_id_for_ac(requirements, ac_id),
|
|
441
|
-
task_id=task.id,
|
|
442
|
-
description=(
|
|
443
|
-
f"Acceptance criterion {ac_id} has no passing verification evidence "
|
|
444
|
-
f"for task {task.id}."
|
|
445
|
-
),
|
|
446
|
-
evidence=[result.summary[:500] for result in command_results] if command_results else [],
|
|
447
|
-
recommended_fix=(
|
|
448
|
-
"Run or add an allowed verification command that proves this acceptance criterion."
|
|
449
|
-
),
|
|
450
|
-
blocking=True,
|
|
451
|
-
))
|
|
452
|
-
elif task.requirement_ids:
|
|
453
|
-
gaps.append(Gap(
|
|
454
|
-
id=self._next_gap_id(task.id, "NOAC"),
|
|
455
|
-
severity="high",
|
|
456
|
-
gap_type="acceptance_criteria_unproven",
|
|
457
|
-
requirement_id=task.requirement_ids[0],
|
|
458
|
-
task_id=task.id,
|
|
459
|
-
description=f"Task {task.id} is linked to requirements but no acceptance criteria.",
|
|
460
|
-
recommended_fix="Link the task to specific acceptance_criterion_ids before verification.",
|
|
461
|
-
blocking=True,
|
|
462
|
-
))
|
|
463
|
-
|
|
464
|
-
# 6. Secret scan
|
|
465
|
-
if diff_content:
|
|
466
|
-
gaps.extend(self.secret_scanner.scan_diff(diff_content, task.id))
|
|
467
|
-
|
|
468
|
-
# 7. LLM Implementation Review
|
|
469
|
-
if self.reviewer and diff_content:
|
|
470
|
-
try:
|
|
471
|
-
review_result = await self.reviewer.review_changes(task, requirements, diff_content)
|
|
472
|
-
for finding in review_result.findings:
|
|
473
|
-
finding.id = self._next_gap_id(task.id, "REVIEW")
|
|
474
|
-
gaps.append(finding)
|
|
475
|
-
except Exception as e:
|
|
476
|
-
logger.error("Implementation review failed: %s", e)
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
416
|
+
if task.acceptance_criterion_ids:
|
|
417
|
+
if successful_commands:
|
|
418
|
+
req_by_ac = {
|
|
419
|
+
ac.id: req.id
|
|
420
|
+
for req in requirements
|
|
421
|
+
for ac in req.acceptance_criteria
|
|
422
|
+
}
|
|
423
|
+
evidence_command = ", ".join(result.command for result in successful_commands)
|
|
424
|
+
for ac_id in task.acceptance_criterion_ids:
|
|
425
|
+
evidence_to_save.append(TestEvidence(
|
|
426
|
+
requirement_id=req_by_ac.get(ac_id, task.requirement_ids[0] if task.requirement_ids else ""),
|
|
427
|
+
acceptance_criterion_id=ac_id,
|
|
428
|
+
command=evidence_command,
|
|
429
|
+
status="passed",
|
|
430
|
+
evidence_summary=(
|
|
431
|
+
"Acceptance criterion linked to successful verification command(s): "
|
|
432
|
+
f"{evidence_command}"
|
|
433
|
+
),
|
|
434
|
+
))
|
|
435
|
+
else:
|
|
436
|
+
for ac_id in task.acceptance_criterion_ids:
|
|
437
|
+
gaps.append(Gap(
|
|
438
|
+
id=self._next_gap_id(task.id, "AC"),
|
|
439
|
+
severity="high",
|
|
440
|
+
gap_type="acceptance_criteria_unproven",
|
|
441
|
+
requirement_id=self._requirement_id_for_ac(requirements, ac_id),
|
|
442
|
+
task_id=task.id,
|
|
443
|
+
description=(
|
|
444
|
+
f"Acceptance criterion {ac_id} has no passing verification evidence "
|
|
445
|
+
f"for task {task.id}."
|
|
446
|
+
),
|
|
447
|
+
evidence=[result.summary[:500] for result in command_results] if command_results else [],
|
|
448
|
+
recommended_fix=(
|
|
449
|
+
"Run or add an allowed verification command that proves this acceptance criterion."
|
|
450
|
+
),
|
|
451
|
+
blocking=True,
|
|
452
|
+
))
|
|
453
|
+
elif task.requirement_ids:
|
|
454
|
+
gaps.append(Gap(
|
|
455
|
+
id=self._next_gap_id(task.id, "NOAC"),
|
|
456
|
+
severity="high",
|
|
457
|
+
gap_type="acceptance_criteria_unproven",
|
|
458
|
+
requirement_id=task.requirement_ids[0],
|
|
459
|
+
task_id=task.id,
|
|
460
|
+
description=f"Task {task.id} is linked to requirements but no acceptance criteria.",
|
|
461
|
+
recommended_fix="Link the task to specific acceptance_criterion_ids before verification.",
|
|
462
|
+
blocking=True,
|
|
463
|
+
))
|
|
464
|
+
|
|
465
|
+
# 6. Secret scan
|
|
466
|
+
if diff_content:
|
|
467
|
+
gaps.extend(self.secret_scanner.scan_diff(diff_content, task.id))
|
|
468
|
+
|
|
469
|
+
# 7. LLM Implementation Review
|
|
470
|
+
if self.reviewer and diff_content:
|
|
471
|
+
try:
|
|
472
|
+
review_result = await self.reviewer.review_changes(task, requirements, diff_content)
|
|
473
|
+
for finding in review_result.findings:
|
|
474
|
+
finding.id = self._next_gap_id(task.id, "REVIEW")
|
|
475
|
+
gaps.append(finding)
|
|
476
|
+
except Exception as e:
|
|
477
|
+
logger.error("Implementation review failed: %s", e)
|
|
478
|
+
|
|
479
|
+
# 8. Open live-review cards
|
|
480
|
+
for card in unresolved_blocking_cards(self.project_root, task_id=task.id):
|
|
481
|
+
gaps.append(Gap(
|
|
482
|
+
id=self._next_gap_id(task.id, "LIVE"),
|
|
483
|
+
severity="critical",
|
|
484
|
+
gap_type="architecture_drift",
|
|
485
|
+
task_id=task.id,
|
|
486
|
+
description=f"Open critical live-review card remains: {card.summary}",
|
|
487
|
+
evidence=[card.id, card.message_for_agent],
|
|
488
|
+
recommended_fix=(
|
|
489
|
+
f"Address the critique card, then run `dev watch resolve {card.id}` "
|
|
490
|
+
"or mark it ignored with justification outside the verification gate."
|
|
491
|
+
),
|
|
492
|
+
blocking=True,
|
|
493
|
+
))
|
|
494
|
+
|
|
495
|
+
return gaps, evidence_to_save
|
|
496
|
+
|
|
480
497
|
def _commands_for_task(self, task: Task) -> Dict[str, List[str]]:
|
|
481
498
|
if task.expected_tests:
|
|
482
499
|
return {"test": task.expected_tests}
|
|
@@ -507,7 +524,7 @@ class Verifier:
|
|
|
507
524
|
return any(keyword in lowered for keyword in evidence_keywords)
|
|
508
525
|
|
|
509
526
|
def _requirement_id_for_ac(self, requirements: List[Requirement], ac_id: str) -> Optional[str]:
|
|
510
|
-
for req in requirements:
|
|
511
|
-
if any(ac.id == ac_id for ac in req.acceptance_criteria):
|
|
512
|
-
return req.id
|
|
513
|
-
return None
|
|
527
|
+
for req in requirements:
|
|
528
|
+
if any(ac.id == ac_id for ac in req.acceptance_criteria):
|
|
529
|
+
return req.id
|
|
530
|
+
return None
|