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