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.
Files changed (146) hide show
  1. package/README.md +218 -96
  2. package/bin/devcouncil.js +130 -32
  3. package/package.json +7 -11
  4. package/pyproject.toml +0 -34
  5. package/src/devcouncil/__init__.py +0 -0
  6. package/src/devcouncil/__main__.py +0 -4
  7. package/src/devcouncil/app/__init__.py +0 -28
  8. package/src/devcouncil/app/config.py +0 -158
  9. package/src/devcouncil/app/errors.py +0 -23
  10. package/src/devcouncil/app/events.py +0 -44
  11. package/src/devcouncil/app/orchestrator.py +0 -92
  12. package/src/devcouncil/app/project_status.py +0 -29
  13. package/src/devcouncil/app/run_context.py +0 -39
  14. package/src/devcouncil/app/state_machine.py +0 -108
  15. package/src/devcouncil/artifacts/__init__.py +0 -1
  16. package/src/devcouncil/artifacts/coverage.py +0 -96
  17. package/src/devcouncil/artifacts/graph.py +0 -143
  18. package/src/devcouncil/artifacts/migrations.py +0 -20
  19. package/src/devcouncil/artifacts/schemas.py +0 -23
  20. package/src/devcouncil/artifacts/serializer.py +0 -21
  21. package/src/devcouncil/artifacts/validators.py +0 -27
  22. package/src/devcouncil/cli/__init__.py +0 -0
  23. package/src/devcouncil/cli/commands/__init__.py +0 -0
  24. package/src/devcouncil/cli/commands/artifacts.py +0 -51
  25. package/src/devcouncil/cli/commands/ast.py +0 -22
  26. package/src/devcouncil/cli/commands/baseline.py +0 -35
  27. package/src/devcouncil/cli/commands/config.py +0 -76
  28. package/src/devcouncil/cli/commands/dashboard.py +0 -26
  29. package/src/devcouncil/cli/commands/doctor.py +0 -140
  30. package/src/devcouncil/cli/commands/go.py +0 -237
  31. package/src/devcouncil/cli/commands/hook.py +0 -128
  32. package/src/devcouncil/cli/commands/init.py +0 -153
  33. package/src/devcouncil/cli/commands/integrate.py +0 -726
  34. package/src/devcouncil/cli/commands/lsp.py +0 -20
  35. package/src/devcouncil/cli/commands/map.py +0 -42
  36. package/src/devcouncil/cli/commands/mcp_server.py +0 -18
  37. package/src/devcouncil/cli/commands/plan.py +0 -327
  38. package/src/devcouncil/cli/commands/prompt.py +0 -50
  39. package/src/devcouncil/cli/commands/repair.py +0 -72
  40. package/src/devcouncil/cli/commands/report.py +0 -129
  41. package/src/devcouncil/cli/commands/reset_demo_state.py +0 -31
  42. package/src/devcouncil/cli/commands/rollback.py +0 -60
  43. package/src/devcouncil/cli/commands/run.py +0 -269
  44. package/src/devcouncil/cli/commands/setup.py +0 -223
  45. package/src/devcouncil/cli/commands/show.py +0 -76
  46. package/src/devcouncil/cli/commands/status.py +0 -117
  47. package/src/devcouncil/cli/commands/tasks.py +0 -55
  48. package/src/devcouncil/cli/commands/trace.py +0 -44
  49. package/src/devcouncil/cli/commands/verify.py +0 -193
  50. package/src/devcouncil/cli/commands/version.py +0 -20
  51. package/src/devcouncil/cli/commands/watch.py +0 -574
  52. package/src/devcouncil/cli/main.py +0 -88
  53. package/src/devcouncil/council/__init__.py +0 -0
  54. package/src/devcouncil/council/prompts/__init__.py +0 -0
  55. package/src/devcouncil/council/prompts/arbiter.md +0 -19
  56. package/src/devcouncil/council/prompts/critic_a.md +0 -10
  57. package/src/devcouncil/council/prompts/critic_b.md +0 -10
  58. package/src/devcouncil/council/prompts/implementation_reviewer.md +0 -16
  59. package/src/devcouncil/council/prompts/planner_a.md +0 -16
  60. package/src/devcouncil/council/prompts/planner_b.md +0 -16
  61. package/src/devcouncil/council/prompts/rebuttal.md +0 -10
  62. package/src/devcouncil/council/prompts/spec_writer.md +0 -12
  63. package/src/devcouncil/domain/__init__.py +0 -0
  64. package/src/devcouncil/domain/assumption.py +0 -17
  65. package/src/devcouncil/domain/critique.py +0 -32
  66. package/src/devcouncil/domain/evidence.py +0 -27
  67. package/src/devcouncil/domain/gap.py +0 -26
  68. package/src/devcouncil/domain/requirement.py +0 -22
  69. package/src/devcouncil/domain/task.py +0 -26
  70. package/src/devcouncil/execution/__init__.py +0 -1
  71. package/src/devcouncil/execution/context_builder.py +0 -60
  72. package/src/devcouncil/execution/executor.py +0 -15
  73. package/src/devcouncil/execution/hook_policy.py +0 -165
  74. package/src/devcouncil/execution/patch.py +0 -28
  75. package/src/devcouncil/execution/paths.py +0 -14
  76. package/src/devcouncil/execution/permissions.py +0 -92
  77. package/src/devcouncil/execution/prompt_builder.py +0 -59
  78. package/src/devcouncil/execution/task_runner.py +0 -166
  79. package/src/devcouncil/executors/__init__.py +0 -1
  80. package/src/devcouncil/executors/coding_cli.py +0 -112
  81. package/src/devcouncil/executors/mini_swe.py +0 -73
  82. package/src/devcouncil/executors/native/__init__.py +0 -0
  83. package/src/devcouncil/executors/native/agent.py +0 -107
  84. package/src/devcouncil/executors/openhands.py +0 -71
  85. package/src/devcouncil/gating/__init__.py +0 -1
  86. package/src/devcouncil/gating/checks/__init__.py +0 -0
  87. package/src/devcouncil/gating/checks/clean_git.py +0 -50
  88. package/src/devcouncil/gating/checks/planned_files_check.py +0 -32
  89. package/src/devcouncil/gating/checks/requirement_coverage.py +0 -26
  90. package/src/devcouncil/gating/checks/secret_scan_check.py +0 -34
  91. package/src/devcouncil/gating/policy.py +0 -190
  92. package/src/devcouncil/indexing/__init__.py +0 -1
  93. package/src/devcouncil/indexing/ast_matcher.py +0 -168
  94. package/src/devcouncil/indexing/graph_index.py +0 -48
  95. package/src/devcouncil/indexing/lsp.py +0 -120
  96. package/src/devcouncil/indexing/repo_mapper.py +0 -208
  97. package/src/devcouncil/indexing/symbol_index.py +0 -0
  98. package/src/devcouncil/integrations/code_review_graph.py +0 -163
  99. package/src/devcouncil/integrations/github.py +0 -39
  100. package/src/devcouncil/integrations/gitnexus.py +0 -27
  101. package/src/devcouncil/integrations/graphify.py +0 -34
  102. package/src/devcouncil/integrations/mcp/__init__.py +0 -0
  103. package/src/devcouncil/integrations/mcp/server.py +0 -599
  104. package/src/devcouncil/integrations/pr_comments.py +0 -62
  105. package/src/devcouncil/live/__init__.py +0 -2
  106. package/src/devcouncil/live/cards.py +0 -207
  107. package/src/devcouncil/live/models.py +0 -63
  108. package/src/devcouncil/live/repair_prompt.py +0 -83
  109. package/src/devcouncil/live/reviewer.py +0 -70
  110. package/src/devcouncil/live/signals.py +0 -135
  111. package/src/devcouncil/live/summary.py +0 -34
  112. package/src/devcouncil/live/tasks.py +0 -18
  113. package/src/devcouncil/live/transcripts.py +0 -138
  114. package/src/devcouncil/llm/__init__.py +0 -1
  115. package/src/devcouncil/llm/cache.py +0 -38
  116. package/src/devcouncil/llm/provider.py +0 -146
  117. package/src/devcouncil/llm/router.py +0 -125
  118. package/src/devcouncil/planning/__init__.py +0 -1
  119. package/src/devcouncil/planning/arbiter_service.py +0 -57
  120. package/src/devcouncil/planning/critique_service.py +0 -66
  121. package/src/devcouncil/planning/plan_service.py +0 -46
  122. package/src/devcouncil/planning/prompt_enhancer_service.py +0 -86
  123. package/src/devcouncil/planning/repair_service.py +0 -39
  124. package/src/devcouncil/planning/spec_service.py +0 -44
  125. package/src/devcouncil/repo/__init__.py +0 -0
  126. package/src/devcouncil/reporting/__init__.py +0 -0
  127. package/src/devcouncil/reporting/github_check.py +0 -32
  128. package/src/devcouncil/reporting/json_report.py +0 -20
  129. package/src/devcouncil/reporting/markdown_report.py +0 -68
  130. package/src/devcouncil/reporting/report_builder.py +0 -14
  131. package/src/devcouncil/storage/__init__.py +0 -0
  132. package/src/devcouncil/storage/db.py +0 -66
  133. package/src/devcouncil/storage/models.py +0 -83
  134. package/src/devcouncil/storage/repositories.py +0 -423
  135. package/src/devcouncil/telemetry/__init__.py +0 -0
  136. package/src/devcouncil/telemetry/cost.py +0 -34
  137. package/src/devcouncil/telemetry/traces.py +0 -91
  138. package/src/devcouncil/telemetry/tracker.py +0 -49
  139. package/src/devcouncil/ui/__init__.py +0 -1
  140. package/src/devcouncil/ui/dashboard.py +0 -122
  141. package/src/devcouncil/utils/__init__.py +0 -1
  142. package/src/devcouncil/utils/redaction.py +0 -141
  143. package/src/devcouncil/verification/__init__.py +0 -1
  144. package/src/devcouncil/verification/implementation_reviewer.py +0 -55
  145. package/src/devcouncil/verification/verifier.py +0 -530
  146. 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