devcouncil 0.1.1 → 0.2.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/README.md +218 -96
- package/bin/devcouncil.js +130 -32
- package/package.json +7 -11
- package/pyproject.toml +0 -34
- package/src/devcouncil/__init__.py +0 -0
- package/src/devcouncil/__main__.py +0 -4
- package/src/devcouncil/app/__init__.py +0 -28
- package/src/devcouncil/app/config.py +0 -158
- package/src/devcouncil/app/errors.py +0 -23
- package/src/devcouncil/app/events.py +0 -44
- package/src/devcouncil/app/orchestrator.py +0 -92
- package/src/devcouncil/app/project_status.py +0 -29
- package/src/devcouncil/app/run_context.py +0 -39
- package/src/devcouncil/app/state_machine.py +0 -108
- package/src/devcouncil/artifacts/__init__.py +0 -1
- package/src/devcouncil/artifacts/coverage.py +0 -96
- package/src/devcouncil/artifacts/graph.py +0 -143
- package/src/devcouncil/artifacts/migrations.py +0 -20
- package/src/devcouncil/artifacts/schemas.py +0 -23
- package/src/devcouncil/artifacts/serializer.py +0 -21
- package/src/devcouncil/artifacts/validators.py +0 -27
- package/src/devcouncil/cli/__init__.py +0 -0
- package/src/devcouncil/cli/commands/__init__.py +0 -0
- package/src/devcouncil/cli/commands/artifacts.py +0 -51
- package/src/devcouncil/cli/commands/ast.py +0 -22
- package/src/devcouncil/cli/commands/baseline.py +0 -35
- package/src/devcouncil/cli/commands/config.py +0 -76
- package/src/devcouncil/cli/commands/dashboard.py +0 -26
- package/src/devcouncil/cli/commands/doctor.py +0 -140
- package/src/devcouncil/cli/commands/go.py +0 -237
- package/src/devcouncil/cli/commands/hook.py +0 -128
- package/src/devcouncil/cli/commands/init.py +0 -153
- package/src/devcouncil/cli/commands/integrate.py +0 -726
- package/src/devcouncil/cli/commands/lsp.py +0 -20
- package/src/devcouncil/cli/commands/map.py +0 -42
- package/src/devcouncil/cli/commands/mcp_server.py +0 -18
- package/src/devcouncil/cli/commands/plan.py +0 -327
- package/src/devcouncil/cli/commands/prompt.py +0 -50
- package/src/devcouncil/cli/commands/repair.py +0 -72
- package/src/devcouncil/cli/commands/report.py +0 -129
- package/src/devcouncil/cli/commands/reset_demo_state.py +0 -31
- package/src/devcouncil/cli/commands/rollback.py +0 -60
- package/src/devcouncil/cli/commands/run.py +0 -269
- package/src/devcouncil/cli/commands/setup.py +0 -223
- package/src/devcouncil/cli/commands/show.py +0 -76
- package/src/devcouncil/cli/commands/status.py +0 -117
- package/src/devcouncil/cli/commands/tasks.py +0 -55
- package/src/devcouncil/cli/commands/trace.py +0 -44
- package/src/devcouncil/cli/commands/verify.py +0 -193
- package/src/devcouncil/cli/commands/version.py +0 -20
- package/src/devcouncil/cli/commands/watch.py +0 -574
- package/src/devcouncil/cli/main.py +0 -88
- package/src/devcouncil/council/__init__.py +0 -0
- package/src/devcouncil/council/prompts/__init__.py +0 -0
- package/src/devcouncil/council/prompts/arbiter.md +0 -19
- package/src/devcouncil/council/prompts/critic_a.md +0 -10
- package/src/devcouncil/council/prompts/critic_b.md +0 -10
- package/src/devcouncil/council/prompts/implementation_reviewer.md +0 -16
- package/src/devcouncil/council/prompts/planner_a.md +0 -16
- package/src/devcouncil/council/prompts/planner_b.md +0 -16
- package/src/devcouncil/council/prompts/rebuttal.md +0 -10
- package/src/devcouncil/council/prompts/spec_writer.md +0 -12
- package/src/devcouncil/domain/__init__.py +0 -0
- package/src/devcouncil/domain/assumption.py +0 -17
- package/src/devcouncil/domain/critique.py +0 -32
- package/src/devcouncil/domain/evidence.py +0 -27
- package/src/devcouncil/domain/gap.py +0 -26
- package/src/devcouncil/domain/requirement.py +0 -22
- package/src/devcouncil/domain/task.py +0 -26
- package/src/devcouncil/execution/__init__.py +0 -1
- package/src/devcouncil/execution/context_builder.py +0 -60
- package/src/devcouncil/execution/executor.py +0 -15
- package/src/devcouncil/execution/hook_policy.py +0 -165
- package/src/devcouncil/execution/patch.py +0 -28
- package/src/devcouncil/execution/paths.py +0 -14
- package/src/devcouncil/execution/permissions.py +0 -92
- package/src/devcouncil/execution/prompt_builder.py +0 -59
- package/src/devcouncil/execution/task_runner.py +0 -166
- package/src/devcouncil/executors/__init__.py +0 -1
- package/src/devcouncil/executors/coding_cli.py +0 -112
- package/src/devcouncil/executors/mini_swe.py +0 -73
- package/src/devcouncil/executors/native/__init__.py +0 -0
- package/src/devcouncil/executors/native/agent.py +0 -107
- package/src/devcouncil/executors/openhands.py +0 -71
- package/src/devcouncil/gating/__init__.py +0 -1
- package/src/devcouncil/gating/checks/__init__.py +0 -0
- package/src/devcouncil/gating/checks/clean_git.py +0 -50
- package/src/devcouncil/gating/checks/planned_files_check.py +0 -32
- package/src/devcouncil/gating/checks/requirement_coverage.py +0 -26
- package/src/devcouncil/gating/checks/secret_scan_check.py +0 -34
- package/src/devcouncil/gating/policy.py +0 -190
- package/src/devcouncil/indexing/__init__.py +0 -1
- package/src/devcouncil/indexing/ast_matcher.py +0 -168
- package/src/devcouncil/indexing/graph_index.py +0 -48
- package/src/devcouncil/indexing/lsp.py +0 -120
- package/src/devcouncil/indexing/repo_mapper.py +0 -208
- package/src/devcouncil/indexing/symbol_index.py +0 -0
- package/src/devcouncil/integrations/code_review_graph.py +0 -163
- package/src/devcouncil/integrations/github.py +0 -39
- package/src/devcouncil/integrations/gitnexus.py +0 -27
- package/src/devcouncil/integrations/graphify.py +0 -34
- package/src/devcouncil/integrations/mcp/__init__.py +0 -0
- package/src/devcouncil/integrations/mcp/server.py +0 -599
- package/src/devcouncil/integrations/pr_comments.py +0 -62
- package/src/devcouncil/live/__init__.py +0 -2
- package/src/devcouncil/live/cards.py +0 -207
- package/src/devcouncil/live/models.py +0 -63
- package/src/devcouncil/live/repair_prompt.py +0 -83
- package/src/devcouncil/live/reviewer.py +0 -70
- package/src/devcouncil/live/signals.py +0 -135
- package/src/devcouncil/live/summary.py +0 -34
- package/src/devcouncil/live/tasks.py +0 -18
- package/src/devcouncil/live/transcripts.py +0 -138
- package/src/devcouncil/llm/__init__.py +0 -1
- package/src/devcouncil/llm/cache.py +0 -38
- package/src/devcouncil/llm/provider.py +0 -146
- package/src/devcouncil/llm/router.py +0 -125
- package/src/devcouncil/planning/__init__.py +0 -1
- package/src/devcouncil/planning/arbiter_service.py +0 -57
- package/src/devcouncil/planning/critique_service.py +0 -66
- package/src/devcouncil/planning/plan_service.py +0 -46
- package/src/devcouncil/planning/prompt_enhancer_service.py +0 -86
- package/src/devcouncil/planning/repair_service.py +0 -39
- package/src/devcouncil/planning/spec_service.py +0 -44
- package/src/devcouncil/repo/__init__.py +0 -0
- package/src/devcouncil/reporting/__init__.py +0 -0
- package/src/devcouncil/reporting/github_check.py +0 -32
- package/src/devcouncil/reporting/json_report.py +0 -20
- package/src/devcouncil/reporting/markdown_report.py +0 -68
- package/src/devcouncil/reporting/report_builder.py +0 -14
- package/src/devcouncil/storage/__init__.py +0 -0
- package/src/devcouncil/storage/db.py +0 -66
- package/src/devcouncil/storage/models.py +0 -83
- package/src/devcouncil/storage/repositories.py +0 -423
- package/src/devcouncil/telemetry/__init__.py +0 -0
- package/src/devcouncil/telemetry/cost.py +0 -34
- package/src/devcouncil/telemetry/traces.py +0 -91
- package/src/devcouncil/telemetry/tracker.py +0 -49
- package/src/devcouncil/ui/__init__.py +0 -1
- package/src/devcouncil/ui/dashboard.py +0 -122
- package/src/devcouncil/utils/__init__.py +0 -1
- package/src/devcouncil/utils/redaction.py +0 -141
- package/src/devcouncil/verification/__init__.py +0 -1
- package/src/devcouncil/verification/implementation_reviewer.py +0 -55
- package/src/devcouncil/verification/verifier.py +0 -530
- package/uv.lock +0 -1085
|
@@ -1,530 +0,0 @@
|
|
|
1
|
-
import hashlib
|
|
2
|
-
import subprocess
|
|
3
|
-
import logging
|
|
4
|
-
import uuid
|
|
5
|
-
import fnmatch
|
|
6
|
-
import json
|
|
7
|
-
import shlex
|
|
8
|
-
from pathlib import Path
|
|
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
|
|
17
|
-
from devcouncil.gating.checks.secret_scan_check import SecretScanner
|
|
18
|
-
from devcouncil.verification.implementation_reviewer import ImplementationReviewer
|
|
19
|
-
from devcouncil.llm.router import ModelRouter
|
|
20
|
-
from devcouncil.utils.redaction import redact_string
|
|
21
|
-
from devcouncil.live.cards import unresolved_blocking_cards
|
|
22
|
-
|
|
23
|
-
logger = logging.getLogger(__name__)
|
|
24
|
-
|
|
25
|
-
IGNORED_CHANGE_PATTERNS = (
|
|
26
|
-
"__pycache__/*",
|
|
27
|
-
"*/__pycache__/*",
|
|
28
|
-
"*.pyc",
|
|
29
|
-
"*.pyo",
|
|
30
|
-
".pytest_cache/*",
|
|
31
|
-
".mypy_cache/*",
|
|
32
|
-
".ruff_cache/*",
|
|
33
|
-
".devcouncil/*",
|
|
34
|
-
)
|
|
35
|
-
|
|
36
|
-
MAX_UNTRACKED_DIFF_BYTES = 256_000
|
|
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
|
-
|
|
50
|
-
def get_diff(self) -> str:
|
|
51
|
-
try:
|
|
52
|
-
if not self._has_head():
|
|
53
|
-
return self._get_initial_repo_diff()
|
|
54
|
-
tracked_diff = subprocess.check_output(
|
|
55
|
-
["git", "diff", "HEAD"], cwd=self.project_root
|
|
56
|
-
).decode("utf-8", errors="replace")
|
|
57
|
-
untracked_diff = self._get_untracked_files_diff()
|
|
58
|
-
return "\n".join(part for part in [tracked_diff, untracked_diff] if part)
|
|
59
|
-
except Exception as e:
|
|
60
|
-
logger.warning("Failed to get git diff: %s", e)
|
|
61
|
-
return ""
|
|
62
|
-
|
|
63
|
-
def get_changed_files(self) -> List[str]:
|
|
64
|
-
try:
|
|
65
|
-
if not self._has_head():
|
|
66
|
-
return self._get_status_files()
|
|
67
|
-
output = subprocess.check_output(
|
|
68
|
-
["git", "diff", "HEAD", "--name-only"], cwd=self.project_root
|
|
69
|
-
).decode("utf-8", errors="replace").splitlines()
|
|
70
|
-
files = set(output)
|
|
71
|
-
files.update(self._get_untracked_files())
|
|
72
|
-
return self._filter_change_paths(sorted(files))
|
|
73
|
-
except Exception as e:
|
|
74
|
-
logger.warning("Failed to get changed files: %s", e)
|
|
75
|
-
return []
|
|
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
|
-
|
|
91
|
-
def _get_initial_repo_diff(self) -> str:
|
|
92
|
-
parts: List[str] = []
|
|
93
|
-
for cmd in (["git", "diff", "--cached"], ["git", "diff"]):
|
|
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
|
-
)
|
|
102
|
-
if result.returncode == 0 and result.stdout:
|
|
103
|
-
parts.append(result.stdout)
|
|
104
|
-
untracked_diff = self._get_untracked_files_diff()
|
|
105
|
-
if untracked_diff:
|
|
106
|
-
parts.append(untracked_diff)
|
|
107
|
-
return "\n".join(parts)
|
|
108
|
-
|
|
109
|
-
def _get_status_files(self) -> List[str]:
|
|
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
|
|
126
|
-
if not files:
|
|
127
|
-
files.update(self._walk_project_files())
|
|
128
|
-
return self._filter_change_paths(sorted(files))
|
|
129
|
-
|
|
130
|
-
def _get_untracked_files(self) -> List[str]:
|
|
131
|
-
try:
|
|
132
|
-
output = subprocess.check_output(
|
|
133
|
-
["git", "ls-files", "--others", "--exclude-standard"],
|
|
134
|
-
cwd=self.project_root,
|
|
135
|
-
stderr=subprocess.DEVNULL,
|
|
136
|
-
).decode("utf-8", errors="replace").splitlines()
|
|
137
|
-
return self._filter_change_paths(output)
|
|
138
|
-
except Exception as e:
|
|
139
|
-
logger.debug("Failed to list untracked files: %s", e)
|
|
140
|
-
return []
|
|
141
|
-
|
|
142
|
-
def _get_untracked_files_diff(self) -> str:
|
|
143
|
-
parts: List[str] = []
|
|
144
|
-
for rel_path in self._get_untracked_files():
|
|
145
|
-
full_path = self.project_root / rel_path
|
|
146
|
-
if not full_path.is_file():
|
|
147
|
-
continue
|
|
148
|
-
parts.append(self._format_new_file_diff(rel_path, full_path))
|
|
149
|
-
return "\n".join(part for part in parts if part)
|
|
150
|
-
|
|
151
|
-
def _format_new_file_diff(self, rel_path: str, full_path: Path) -> str:
|
|
152
|
-
try:
|
|
153
|
-
raw = full_path.read_bytes()
|
|
154
|
-
except Exception as e:
|
|
155
|
-
logger.debug("Failed to read untracked file %s: %s", rel_path, e)
|
|
156
|
-
return ""
|
|
157
|
-
|
|
158
|
-
header = [
|
|
159
|
-
f"diff --git a/{rel_path} b/{rel_path}",
|
|
160
|
-
"new file mode 100644",
|
|
161
|
-
"--- /dev/null",
|
|
162
|
-
f"+++ b/{rel_path}",
|
|
163
|
-
]
|
|
164
|
-
if b"\0" in raw[:8192]:
|
|
165
|
-
return "\n".join([*header, f"Binary files /dev/null and b/{rel_path} differ"])
|
|
166
|
-
|
|
167
|
-
truncated = len(raw) > MAX_UNTRACKED_DIFF_BYTES
|
|
168
|
-
if truncated:
|
|
169
|
-
raw = raw[:MAX_UNTRACKED_DIFF_BYTES]
|
|
170
|
-
text = raw.decode("utf-8", errors="replace")
|
|
171
|
-
lines = text.splitlines()
|
|
172
|
-
if text.endswith(("\n", "\r")):
|
|
173
|
-
line_count = len(lines)
|
|
174
|
-
else:
|
|
175
|
-
line_count = max(len(lines), 1 if text else 0)
|
|
176
|
-
|
|
177
|
-
diff_lines = [*header, f"@@ -0,0 +1,{line_count} @@"]
|
|
178
|
-
if not text:
|
|
179
|
-
return "\n".join(header) + "\n"
|
|
180
|
-
|
|
181
|
-
diff_lines.extend(f"+{line}" for line in lines)
|
|
182
|
-
if truncated:
|
|
183
|
-
diff_lines.append("+[devcouncil: untracked file diff truncated]")
|
|
184
|
-
return "\n".join(diff_lines)
|
|
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"
|
|
247
|
-
log_path = log_dir / filename
|
|
248
|
-
log_path.write_text(redact_string(content), encoding="utf-8")
|
|
249
|
-
return str(log_path)
|
|
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
|
-
)
|
|
269
|
-
stdout = result.stdout or ""
|
|
270
|
-
stderr = result.stderr or ""
|
|
271
|
-
stdout_path = self._save_log(task_id, command, "stdout", stdout)
|
|
272
|
-
stderr_path = self._save_log(task_id, command, "stderr", stderr)
|
|
273
|
-
stdout_summary = redact_string(stdout[-500:] if stdout else "(empty)")
|
|
274
|
-
stderr_summary = redact_string(stderr[-500:] if stderr else "(empty)")
|
|
275
|
-
return CommandResult(
|
|
276
|
-
command=command,
|
|
277
|
-
exit_code=result.returncode,
|
|
278
|
-
stdout_path=stdout_path,
|
|
279
|
-
stderr_path=stderr_path,
|
|
280
|
-
summary=(
|
|
281
|
-
f"Exit code {result.returncode}. "
|
|
282
|
-
f"stdout: {stdout_summary}. "
|
|
283
|
-
f"stderr: {stderr_summary}"
|
|
284
|
-
),
|
|
285
|
-
)
|
|
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
|
-
|
|
298
|
-
def _check_dependency_changes(self, changed_files: List[str]) -> List[str]:
|
|
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",
|
|
303
|
-
}
|
|
304
|
-
return [f for f in changed_files if Path(f).name in dep_files]
|
|
305
|
-
|
|
306
|
-
def _classify_change_paths(self, changed_files: List[str]) -> Tuple[List[str], List[str]]:
|
|
307
|
-
changed_set = set(changed_files)
|
|
308
|
-
added = set(self._get_untracked_files())
|
|
309
|
-
deleted: set[str] = set()
|
|
310
|
-
try:
|
|
311
|
-
output = subprocess.check_output(
|
|
312
|
-
["git", "diff", "HEAD", "--name-status"],
|
|
313
|
-
cwd=self.project_root,
|
|
314
|
-
stderr=subprocess.DEVNULL,
|
|
315
|
-
).decode("utf-8", errors="replace").splitlines()
|
|
316
|
-
for line in output:
|
|
317
|
-
parts = line.split("\t")
|
|
318
|
-
if len(parts) < 2:
|
|
319
|
-
continue
|
|
320
|
-
status = parts[0]
|
|
321
|
-
path = parts[-1].replace("\\", "/")
|
|
322
|
-
if status.startswith("A"):
|
|
323
|
-
added.add(path)
|
|
324
|
-
elif status.startswith("D"):
|
|
325
|
-
deleted.add(path)
|
|
326
|
-
except Exception as e:
|
|
327
|
-
logger.debug("Failed to classify changed files: %s", e)
|
|
328
|
-
return sorted(added & changed_set), sorted(deleted & changed_set)
|
|
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
|
-
|
|
337
|
-
if diff_content:
|
|
338
|
-
added_files, deleted_files = self._classify_change_paths(changed_files)
|
|
339
|
-
diff_ev = DiffEvidence(
|
|
340
|
-
task_id=task.id,
|
|
341
|
-
changed_files=changed_files,
|
|
342
|
-
added_files=added_files,
|
|
343
|
-
deleted_files=deleted_files,
|
|
344
|
-
diff_summary=f"Diff captured for {len(changed_files)} files."
|
|
345
|
-
)
|
|
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
|
-
|
|
392
|
-
# 4. Run verification commands
|
|
393
|
-
command_results: List[CommandResult] = []
|
|
394
|
-
evidence_results: List[CommandResult] = []
|
|
395
|
-
for cmd_type, cmds in self._commands_for_task(task).items():
|
|
396
|
-
for cmd in cmds:
|
|
397
|
-
result = self._run_command(cmd, task_id=task.id)
|
|
398
|
-
command_results.append(result)
|
|
399
|
-
evidence_to_save.append(result)
|
|
400
|
-
if self._command_can_prove_acceptance(cmd_type, cmd):
|
|
401
|
-
evidence_results.append(result)
|
|
402
|
-
if result.exit_code != 0:
|
|
403
|
-
gaps.append(Gap(
|
|
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
|
|
415
|
-
successful_commands = [result for result in evidence_results if result.exit_code == 0]
|
|
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
|
-
|
|
497
|
-
def _commands_for_task(self, task: Task) -> Dict[str, List[str]]:
|
|
498
|
-
if task.expected_tests:
|
|
499
|
-
return {"test": task.expected_tests}
|
|
500
|
-
if task.allowed_commands:
|
|
501
|
-
return {"allowed": task.allowed_commands}
|
|
502
|
-
return self._load_commands()
|
|
503
|
-
|
|
504
|
-
def _command_can_prove_acceptance(self, cmd_type: str, command: str) -> bool:
|
|
505
|
-
if cmd_type == "test":
|
|
506
|
-
return True
|
|
507
|
-
lowered = command.lower()
|
|
508
|
-
evidence_keywords = (
|
|
509
|
-
"test",
|
|
510
|
-
"pytest",
|
|
511
|
-
"vitest",
|
|
512
|
-
"jest",
|
|
513
|
-
"unittest",
|
|
514
|
-
"cargo test",
|
|
515
|
-
"go test",
|
|
516
|
-
"mvn test",
|
|
517
|
-
"gradle test",
|
|
518
|
-
"ruff check",
|
|
519
|
-
"mypy",
|
|
520
|
-
"tsc",
|
|
521
|
-
"typecheck",
|
|
522
|
-
"type-check",
|
|
523
|
-
)
|
|
524
|
-
return any(keyword in lowered for keyword in evidence_keywords)
|
|
525
|
-
|
|
526
|
-
def _requirement_id_for_ac(self, requirements: List[Requirement], ac_id: str) -> Optional[str]:
|
|
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
|