devcouncil 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/README.md +190 -6
  2. package/package.json +9 -2
  3. package/pyproject.toml +34 -2
  4. package/src/devcouncil/app/config.py +167 -5
  5. package/src/devcouncil/artifacts/graph.py +23 -3
  6. package/src/devcouncil/assets/__init__.py +1 -0
  7. package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
  8. package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
  9. package/src/devcouncil/cli/commands/agents.py +292 -0
  10. package/src/devcouncil/cli/commands/artifacts.py +6 -3
  11. package/src/devcouncil/cli/commands/check.py +209 -0
  12. package/src/devcouncil/cli/commands/config.py +43 -4
  13. package/src/devcouncil/cli/commands/cost.py +57 -0
  14. package/src/devcouncil/cli/commands/dashboard.py +6 -1
  15. package/src/devcouncil/cli/commands/doctor.py +221 -21
  16. package/src/devcouncil/cli/commands/evidence.py +48 -0
  17. package/src/devcouncil/cli/commands/go.py +452 -33
  18. package/src/devcouncil/cli/commands/handoff.py +69 -0
  19. package/src/devcouncil/cli/commands/hook.py +124 -15
  20. package/src/devcouncil/cli/commands/init.py +154 -18
  21. package/src/devcouncil/cli/commands/integrate.py +894 -105
  22. package/src/devcouncil/cli/commands/map.py +80 -10
  23. package/src/devcouncil/cli/commands/plan.py +212 -51
  24. package/src/devcouncil/cli/commands/prompt.py +18 -7
  25. package/src/devcouncil/cli/commands/repair.py +40 -23
  26. package/src/devcouncil/cli/commands/report.py +8 -0
  27. package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
  28. package/src/devcouncil/cli/commands/rollback.py +27 -28
  29. package/src/devcouncil/cli/commands/run.py +69 -49
  30. package/src/devcouncil/cli/commands/runs.py +223 -0
  31. package/src/devcouncil/cli/commands/scaffold.py +32 -0
  32. package/src/devcouncil/cli/commands/semantic.py +47 -0
  33. package/src/devcouncil/cli/commands/setup.py +145 -6
  34. package/src/devcouncil/cli/commands/shell.py +73 -0
  35. package/src/devcouncil/cli/commands/skills.py +88 -0
  36. package/src/devcouncil/cli/commands/status.py +25 -1
  37. package/src/devcouncil/cli/commands/trace.py +47 -3
  38. package/src/devcouncil/cli/commands/verify.py +138 -3
  39. package/src/devcouncil/cli/commands/watch.py +9 -9
  40. package/src/devcouncil/cli/commands/watch_fs.py +40 -0
  41. package/src/devcouncil/cli/main.py +56 -7
  42. package/src/devcouncil/domain/evidence.py +22 -2
  43. package/src/devcouncil/domain/gap.py +27 -1
  44. package/src/devcouncil/domain/task.py +31 -2
  45. package/src/devcouncil/execution/checkpoints.py +246 -0
  46. package/src/devcouncil/execution/context_builder.py +1 -1
  47. package/src/devcouncil/execution/fs_watcher.py +180 -0
  48. package/src/devcouncil/execution/handoff.py +102 -0
  49. package/src/devcouncil/execution/hook_policy.py +162 -74
  50. package/src/devcouncil/execution/patch.py +59 -10
  51. package/src/devcouncil/execution/permissions.py +17 -24
  52. package/src/devcouncil/execution/policy_engine.py +343 -0
  53. package/src/devcouncil/execution/prompt_builder.py +633 -21
  54. package/src/devcouncil/execution/shell_session.py +225 -0
  55. package/src/devcouncil/execution/task_runner.py +6 -2
  56. package/src/devcouncil/executors/agent_registry.py +575 -0
  57. package/src/devcouncil/executors/coding_cli.py +663 -39
  58. package/src/devcouncil/executors/native/agent.py +121 -20
  59. package/src/devcouncil/gating/checks/clean_git.py +3 -1
  60. package/src/devcouncil/gating/checks/secret_scan_check.py +40 -21
  61. package/src/devcouncil/gating/policy.py +158 -10
  62. package/src/devcouncil/hardware.py +184 -0
  63. package/src/devcouncil/indexing/ast_matcher.py +1 -1
  64. package/src/devcouncil/indexing/lsp.py +45 -4
  65. package/src/devcouncil/indexing/repo_mapper.py +1256 -9
  66. package/src/devcouncil/indexing/semantic_index.py +205 -0
  67. package/src/devcouncil/integrations/actions.py +146 -0
  68. package/src/devcouncil/integrations/check.py +423 -0
  69. package/src/devcouncil/integrations/github_intent.py +142 -0
  70. package/src/devcouncil/integrations/gitnexus.py +35 -0
  71. package/src/devcouncil/integrations/mcp/server.py +1552 -29
  72. package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
  73. package/src/devcouncil/live/cards.py +161 -19
  74. package/src/devcouncil/live/signals.py +2 -2
  75. package/src/devcouncil/live/transcripts.py +9 -6
  76. package/src/devcouncil/llm/cache.py +10 -6
  77. package/src/devcouncil/llm/model_defaults.yaml +44 -0
  78. package/src/devcouncil/llm/provider.py +515 -34
  79. package/src/devcouncil/llm/router.py +231 -46
  80. package/src/devcouncil/optimization/__init__.py +1 -0
  81. package/src/devcouncil/optimization/gepa_agent.py +318 -0
  82. package/src/devcouncil/planning/correction_manifest.py +303 -0
  83. package/src/devcouncil/planning/critique_service.py +7 -2
  84. package/src/devcouncil/planning/plan_service.py +17 -3
  85. package/src/devcouncil/planning/prompt_enhancer_service.py +82 -1
  86. package/src/devcouncil/planning/spec_service.py +27 -1
  87. package/src/devcouncil/repo/ci_scaffold.py +157 -0
  88. package/src/devcouncil/repo/gitignore.py +123 -0
  89. package/src/devcouncil/repo/sca.py +374 -0
  90. package/src/devcouncil/reporting/json_report.py +11 -1
  91. package/src/devcouncil/reporting/markdown_report.py +15 -0
  92. package/src/devcouncil/skills/__init__.py +19 -0
  93. package/src/devcouncil/skills/library/README.md +46 -0
  94. package/src/devcouncil/skills/library/ai-training.md +50 -0
  95. package/src/devcouncil/skills/library/android.md +50 -0
  96. package/src/devcouncil/skills/library/backend.md +52 -0
  97. package/src/devcouncil/skills/library/core-engineering.md +95 -0
  98. package/src/devcouncil/skills/library/data-engineering.md +47 -0
  99. package/src/devcouncil/skills/library/desktop.md +46 -0
  100. package/src/devcouncil/skills/library/devops.md +48 -0
  101. package/src/devcouncil/skills/library/game-dev.md +46 -0
  102. package/src/devcouncil/skills/library/ios.md +48 -0
  103. package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
  104. package/src/devcouncil/skills/library/security.md +48 -0
  105. package/src/devcouncil/skills/library/systems.md +48 -0
  106. package/src/devcouncil/skills/library/web.md +47 -0
  107. package/src/devcouncil/skills/library/windows.md +47 -0
  108. package/src/devcouncil/skills/registry.py +330 -0
  109. package/src/devcouncil/storage/db.py +83 -2
  110. package/src/devcouncil/storage/models.py +121 -0
  111. package/src/devcouncil/storage/native.py +557 -0
  112. package/src/devcouncil/storage/repositories.py +137 -75
  113. package/src/devcouncil/telemetry/cost.py +123 -17
  114. package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
  115. package/src/devcouncil/telemetry/pricing.py +28 -0
  116. package/src/devcouncil/telemetry/traces.py +62 -7
  117. package/src/devcouncil/telemetry/tracker.py +12 -9
  118. package/src/devcouncil/ui/dashboard.py +324 -23
  119. package/src/devcouncil/utils/redaction.py +9 -3
  120. package/src/devcouncil/utils/subprocess_env.py +69 -0
  121. package/src/devcouncil/verification/acceptance_compiler.py +125 -0
  122. package/src/devcouncil/verification/ad_hoc_check.py +129 -0
  123. package/src/devcouncil/verification/diff_coverage.py +353 -0
  124. package/src/devcouncil/verification/next_actions.py +189 -0
  125. package/src/devcouncil/verification/sandbox.py +178 -0
  126. package/src/devcouncil/verification/test_resolver.py +91 -0
  127. package/src/devcouncil/verification/verifier.py +1065 -47
  128. package/uv.lock +205 -64
  129. package/src/devcouncil/indexing/symbol_index.py +0 -0
@@ -1,10 +1,15 @@
1
1
  import hashlib
2
+ import os
3
+ import shutil
2
4
  import subprocess
5
+ import sys
3
6
  import logging
4
7
  import uuid
5
8
  import fnmatch
6
9
  import json
10
+ import re
7
11
  import shlex
12
+ from dataclasses import dataclass, asdict
8
13
  from pathlib import Path
9
14
  from typing import List, Dict, Any, Optional, Tuple
10
15
 
@@ -13,9 +18,11 @@ from devcouncil.app.config import load_config
13
18
  from devcouncil.domain.task import Task
14
19
  from devcouncil.domain.requirement import Requirement
15
20
  from devcouncil.domain.gap import Gap
16
- from devcouncil.domain.evidence import TestEvidence, DiffEvidence, CommandResult
21
+ from devcouncil.domain.evidence import TestEvidence, DiffEvidence, DiffCoverageEvidence, CommandResult
22
+ from devcouncil.verification import diff_coverage as dc
17
23
  from devcouncil.gating.checks.secret_scan_check import SecretScanner
18
24
  from devcouncil.verification.implementation_reviewer import ImplementationReviewer
25
+ from devcouncil.verification.acceptance_compiler import AcceptanceTestCompiler
19
26
  from devcouncil.llm.router import ModelRouter
20
27
  from devcouncil.utils.redaction import redact_string
21
28
  from devcouncil.live.cards import unresolved_blocking_cards
@@ -31,16 +38,54 @@ IGNORED_CHANGE_PATTERNS = (
31
38
  ".mypy_cache/*",
32
39
  ".ruff_cache/*",
33
40
  ".devcouncil/*",
41
+ # DevCouncil manages the root .gitignore itself (ensure_gitignore runs on
42
+ # init and before every task), so its drift is not task work.
43
+ ".gitignore",
34
44
  )
35
45
 
36
46
  MAX_UNTRACKED_DIFF_BYTES = 256_000
37
47
 
48
+
49
+ @dataclass
50
+ class VerificationOutcome:
51
+ """Non-gap metadata about HOW a verification run executed.
52
+
53
+ The pass/fail verdict lives in the gaps; this records the *rigor* of the run so
54
+ an autonomous agent never mistakes ``passed`` for ``proven`` when the gate could
55
+ not actually check. ``mode`` is ``"compiled"`` when DevCouncil's per-criterion
56
+ acceptance checks were available (a model router was supplied) and ``"coarse"``
57
+ on the keyless fallback path. ``diff_empty`` flags a run with nothing to verify,
58
+ and the coverage fields say whether the diff↔coverage gate measured anything.
59
+ """
60
+
61
+ mode: str = "coarse"
62
+ compiler_active: bool = False
63
+ diff_empty: bool = True
64
+ coverage_measured: bool = False
65
+ coverage_skipped_reason: Optional[str] = None
66
+
67
+ def as_dict(self) -> Dict[str, Any]:
68
+ return asdict(self)
69
+
70
+
38
71
  class Verifier:
39
72
  def __init__(self, project_root: Path, router: Optional[ModelRouter] = None):
40
73
  self.project_root = project_root
41
74
  self._gap_counter = 0
42
75
  self.secret_scanner = SecretScanner()
43
76
  self.reviewer = ImplementationReviewer(router) if router else None
77
+ self.acceptance_compiler = AcceptanceTestCompiler(router) if router else None
78
+ # Metadata about the most recent verify_task run (rigor mode, diff/coverage
79
+ # status). Populated at the end of verify_task; read by the MCP/CLI surfaces
80
+ # so the agent knows whether the strong checks actually ran.
81
+ self.last_outcome: Optional[VerificationOutcome] = None
82
+ # Interpreter used to run diff-coverage instrumentation. None -> resolve the
83
+ # target repo's ``python`` from the cleaned PATH (falling back to the current
84
+ # interpreter). Overridable as a seam for deterministic tests.
85
+ self._coverage_python: Optional[str] = None
86
+ # When set, overrides the (measure, enforce, min_ratio) diff-coverage settings
87
+ # that would otherwise come from config. Used by ad-hoc checks and tests.
88
+ self._diff_coverage_override: Optional[Tuple[bool, bool, float]] = None
44
89
 
45
90
  def _next_gap_id(self, task_id: str, suffix: str) -> str:
46
91
  """Generate unique gap IDs to prevent SQLite overwrites."""
@@ -80,6 +125,42 @@ class Verifier:
80
125
  changed.difference_update(self._load_task_snapshot_files(task_id))
81
126
  return sorted(changed)
82
127
 
128
+ def _task_produced_changes(self, task_id: str) -> bool:
129
+ """True when the task has a footprint beyond the current working-tree diff.
130
+
131
+ Used so the empty-diff guard does not misfire on already-committed work: in
132
+ ``dev go`` each task is committed and then re-verified by the reconciliation
133
+ pass, at which point ``git diff HEAD`` is empty even though the task was fully
134
+ implemented. We detect that via the task's ``before`` checkpoint ref (work
135
+ committed since the task started) and a non-empty ``after`` patch. A genuine
136
+ no-op run has neither, so it is still correctly flagged as empty.
137
+ """
138
+ # Literal of CheckpointService.REF_BEFORE (kept inline to avoid a circular
139
+ # import: checkpoints.py imports Verifier).
140
+ before_ref = f"refs/devcouncil/tasks/{task_id}/before"
141
+ try:
142
+ has_ref = subprocess.run(
143
+ ["git", "rev-parse", "--verify", before_ref],
144
+ cwd=self.project_root,
145
+ stdout=subprocess.DEVNULL,
146
+ stderr=subprocess.DEVNULL,
147
+ ).returncode == 0
148
+ if has_ref:
149
+ diff = subprocess.check_output(
150
+ ["git", "diff", before_ref],
151
+ cwd=self.project_root,
152
+ stderr=subprocess.DEVNULL,
153
+ ).decode("utf-8", errors="replace")
154
+ if diff.strip():
155
+ return True
156
+ except Exception:
157
+ pass
158
+ after_patch = self.project_root / ".devcouncil" / "checkpoints" / f"{task_id}-after.patch"
159
+ try:
160
+ return after_patch.exists() and bool(after_patch.read_text(encoding="utf-8", errors="replace").strip())
161
+ except Exception:
162
+ return False
163
+
83
164
  def _has_head(self) -> bool:
84
165
  return subprocess.run(
85
166
  ["git", "rev-parse", "--verify", "HEAD"],
@@ -248,6 +329,91 @@ class Verifier:
248
329
  log_path.write_text(redact_string(content), encoding="utf-8")
249
330
  return str(log_path)
250
331
 
332
+ def _verification_env(self) -> Dict[str, str]:
333
+ """Environment for verification commands that does not leak DevCouncil's
334
+ own virtualenv into the target repository.
335
+
336
+ When DevCouncil is installed/run from a venv (e.g. ``uv tool install`` or
337
+ a project ``.venv``), a bare ``python``/``pytest`` in a task's evidence
338
+ command would otherwise resolve to DevCouncil's interpreter, which lacks
339
+ the target project's dependencies — producing false ``No module named
340
+ pytest`` style failures. Strip DevCouncil's venv from ``PATH`` and unset
341
+ the virtualenv markers so commands resolve the project/system interpreter,
342
+ exactly as they would in a plain terminal at the repo root.
343
+ """
344
+ env = dict(os.environ)
345
+ venv_prefix = Path(sys.prefix).resolve()
346
+ base_prefix = Path(getattr(sys, "base_prefix", sys.prefix)).resolve()
347
+ if venv_prefix == base_prefix:
348
+ return env # Not running inside a venv; nothing to strip.
349
+
350
+ venv_dirs = {
351
+ str(venv_prefix).lower(),
352
+ str((venv_prefix / "Scripts").resolve()).lower(),
353
+ str((venv_prefix / "bin").resolve()).lower(),
354
+ }
355
+ path = env.get("PATH", "")
356
+ kept = []
357
+ for entry in path.split(os.pathsep):
358
+ if not entry:
359
+ continue
360
+ try:
361
+ normalized = str(Path(entry).resolve()).lower()
362
+ except Exception:
363
+ normalized = entry.lower()
364
+ if normalized in venv_dirs:
365
+ continue
366
+ kept.append(entry)
367
+ env["PATH"] = os.pathsep.join(kept)
368
+
369
+ # Drop the virtualenv-activation markers that would pin a freshly-resolved
370
+ # child ``python`` back to DevCouncil's interpreter. VIRTUAL_ENV points at
371
+ # the venv (sys.prefix); PYTHONHOME — set by uv-managed interpreters — points
372
+ # at the base interpreter (sys.base_prefix) and forcibly overrides the stdlib
373
+ # / site-packages location of ANY python the child invokes, which is what
374
+ # makes ``python -m pytest`` fail with "No module named pytest" even when the
375
+ # project's interpreter has pytest installed.
376
+ own_prefixes = {str(venv_prefix), str(base_prefix)}
377
+ for marker in ("VIRTUAL_ENV", "PYTHONHOME"):
378
+ value = env.get(marker)
379
+ if not value:
380
+ continue
381
+ try:
382
+ resolved = str(Path(value).resolve())
383
+ except Exception:
384
+ resolved = value
385
+ if resolved in own_prefixes:
386
+ env.pop(marker, None)
387
+ # uv stashes the same path here and re-applies it to child pythons.
388
+ env.pop("UV_INTERNAL__PYTHONHOME", None)
389
+ return env
390
+
391
+ @staticmethod
392
+ def _summarize_stream(content: str, budget: int = 360) -> str:
393
+ """Condense a command's stdout/stderr for the evidence summary so the ACTUAL
394
+ error survives downstream truncation.
395
+
396
+ Plain ``content[-500:]`` kept the tail but the combined summary is later clipped
397
+ to its first 500 chars at the gap-evidence sites, which dropped the exception
398
+ line entirely. We hoist the salient error line (the last non-indented line, where
399
+ Python prints the exception) to the front, then append bounded context."""
400
+ if not content or not content.strip():
401
+ return "(empty)"
402
+ lines = [ln.rstrip() for ln in content.splitlines() if ln.strip()]
403
+ markers = ("error", "exception", "assert", "traceback", "failed", "not found", "no module named")
404
+ salient = ""
405
+ for ln in reversed(lines):
406
+ low = ln.lower()
407
+ if any(m in low for m in markers):
408
+ salient = ln.strip()
409
+ break
410
+ if not salient:
411
+ salient = lines[-1].strip()
412
+ salient = salient[:240] # cap a single huge (e.g. minified) line
413
+ tail = content.strip()[-budget:]
414
+ summary = f"{salient} | {tail}" if salient not in tail[: len(salient) + 5] else tail
415
+ return summary[: budget + len(salient) + 8]
416
+
251
417
  def _run_command(self, command: str, task_id: str = "verify") -> CommandResult:
252
418
  try:
253
419
  config = load_config(self.project_root)
@@ -255,9 +421,21 @@ class Verifier:
255
421
  except Exception:
256
422
  timeout = 300
257
423
 
424
+ env = self._verification_env()
425
+ argv = self._split_command(command)
426
+ # Resolve the program to an absolute path against the (cleaned) PATH.
427
+ # On Windows, CreateProcess searches the launching executable's own
428
+ # directory before PATH, so a bare ``python`` would otherwise pick up
429
+ # DevCouncil's bundled interpreter (in .venv\Scripts) regardless of PATH.
430
+ # Resolving here pins the command to the project/system interpreter.
431
+ if argv:
432
+ resolved = shutil.which(argv[0], path=env.get("PATH"))
433
+ if resolved:
434
+ argv = [resolved, *argv[1:]]
435
+
258
436
  try:
259
437
  result = subprocess.run(
260
- self._split_command(command),
438
+ argv,
261
439
  shell=False,
262
440
  capture_output=True,
263
441
  text=True,
@@ -265,22 +443,25 @@ class Verifier:
265
443
  errors="replace",
266
444
  cwd=self.project_root,
267
445
  timeout=timeout,
446
+ env=env,
268
447
  )
269
448
  stdout = result.stdout or ""
270
449
  stderr = result.stderr or ""
271
450
  stdout_path = self._save_log(task_id, command, "stdout", stdout)
272
451
  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)")
452
+ stdout_summary = redact_string(self._summarize_stream(stdout))
453
+ stderr_summary = redact_string(self._summarize_stream(stderr))
275
454
  return CommandResult(
276
455
  command=command,
277
456
  exit_code=result.returncode,
278
457
  stdout_path=stdout_path,
279
458
  stderr_path=stderr_path,
459
+ # stderr first: downstream evidence clips summary[:500], so the error
460
+ # line must land in the first 500 chars to stay diagnosable.
280
461
  summary=(
281
462
  f"Exit code {result.returncode}. "
282
- f"stdout: {stdout_summary}. "
283
- f"stderr: {stderr_summary}"
463
+ f"stderr: {stderr_summary}. "
464
+ f"stdout: {stdout_summary}"
284
465
  ),
285
466
  )
286
467
  except Exception as e:
@@ -293,7 +474,14 @@ class Verifier:
293
474
  )
294
475
 
295
476
  def _split_command(self, command: str) -> List[str]:
296
- return shlex.split(command, posix=False)
477
+ # Use POSIX splitting so quotes are interpreted, not preserved. With
478
+ # posix=False, `python -c "assert x"` keeps the surrounding quotes, so the
479
+ # interpreter receives the literal string `"assert x"` and treats it as a
480
+ # no-op string expression that exits 0 — every quoted-argument evidence
481
+ # command would then silently "pass" without running, producing false
482
+ # verification. posix=True strips the quotes correctly; planner-generated
483
+ # commands use forward-slash paths, which the interpreter accepts on Windows.
484
+ return shlex.split(command, posix=True)
297
485
 
298
486
  def _check_dependency_changes(self, changed_files: List[str]) -> List[str]:
299
487
  dep_files = {
@@ -327,12 +515,192 @@ class Verifier:
327
515
  logger.debug("Failed to classify changed files: %s", e)
328
516
  return sorted(added & changed_set), sorted(deleted & changed_set)
329
517
 
518
+ def _diff_coverage_settings(self) -> Tuple[bool, bool, float]:
519
+ """Return (measure, enforce, min_ratio) with safe defaults when unconfigured."""
520
+ if self._diff_coverage_override is not None:
521
+ return self._diff_coverage_override
522
+ try:
523
+ cfg = load_config(self.project_root).verification.diff_coverage
524
+ return bool(cfg.measure), bool(cfg.enforce), float(cfg.min_ratio)
525
+ except Exception:
526
+ return True, False, 0.0
527
+
528
+ def _resolve_coverage_python(self, env: Dict[str, str]) -> str:
529
+ if self._coverage_python:
530
+ return self._coverage_python
531
+ for name in ("python", "python3", "py"):
532
+ found = shutil.which(name, path=env.get("PATH"))
533
+ if found:
534
+ return found
535
+ return sys.executable
536
+
537
+ def _coverage_available(self, python: str, env: Dict[str, str]) -> bool:
538
+ try:
539
+ result = subprocess.run(
540
+ [python, "-m", "coverage", "--version"],
541
+ cwd=self.project_root,
542
+ capture_output=True,
543
+ text=True,
544
+ encoding="utf-8",
545
+ errors="replace",
546
+ timeout=30,
547
+ env=env,
548
+ )
549
+ return result.returncode == 0
550
+ except Exception:
551
+ return False
552
+
553
+ def _coverage_target_commands(self, task: Task) -> List[str]:
554
+ """The test command(s) to instrument — the ones that purport to prove the ACs."""
555
+ if task.expected_tests:
556
+ return list(task.expected_tests)
557
+ test_like = [c for c in task.allowed_commands if self._command_can_prove_acceptance("allowed", c)]
558
+ if test_like:
559
+ return test_like
560
+ return list(self._load_commands().get("test", []))
561
+
562
+ def measure_diff_coverage(self, task: Task, diff_content: str) -> dc.DiffCoverageResult:
563
+ """Run the task's test command(s) under coverage and intersect with the diff.
564
+
565
+ Returns an *unmeasured* result (never a false positive) whenever reliable
566
+ data is unavailable: no measurable Python changes, no instrumentable test
567
+ command, or no coverage tool in the target environment.
568
+ """
569
+ changed = dc.measurable_python_changes(dc.parse_changed_lines(diff_content))
570
+ if not changed:
571
+ return dc.DiffCoverageResult(measured=False, reason="no measurable Python changes in diff")
572
+ commands = self._coverage_target_commands(task)
573
+ if not commands:
574
+ return dc.DiffCoverageResult(measured=False, reason="no test command to instrument")
575
+
576
+ env = self._verification_env()
577
+ python = self._resolve_coverage_python(env)
578
+ if not self._coverage_available(python, env):
579
+ return dc.DiffCoverageResult(measured=False, reason="coverage tool not available in target environment")
580
+
581
+ try:
582
+ timeout = load_config(self.project_root).execution.command_timeout
583
+ except Exception:
584
+ timeout = 300
585
+
586
+ tmp_dir = self.project_root / ".devcouncil" / "tmp"
587
+ tmp_dir.mkdir(parents=True, exist_ok=True)
588
+ data_file = tmp_dir / f"diffcov-{task.id}.coverage"
589
+ json_file = tmp_dir / f"diffcov-{task.id}.json"
590
+ for stale in (data_file, json_file):
591
+ try:
592
+ stale.unlink()
593
+ except FileNotFoundError:
594
+ pass
595
+
596
+ ran_any = False
597
+ append = False
598
+ inline_scripts: List[Path] = []
599
+ try:
600
+ for idx, cmd in enumerate(commands):
601
+ argv = self._split_command(cmd)
602
+ inline = dc.inline_python_code(argv)
603
+ if inline is not None:
604
+ # Materialise `python -c "CODE"` as a temp script so coverage can
605
+ # instrument it (coverage cannot run a bare -c snippet).
606
+ script = tmp_dir / f"diffcov-inline-{task.id}-{idx}.py"
607
+ try:
608
+ script.write_text(dc.inline_script_content(inline, self.project_root), encoding="utf-8")
609
+ except Exception as exc:
610
+ logger.warning("Diff-coverage inline script write failed for %s: %s", task.id, exc)
611
+ continue
612
+ inline_scripts.append(script)
613
+ cov_argv: Optional[List[str]] = dc.coverage_run_script_argv(
614
+ str(script), python, append=append, data_file=str(data_file)
615
+ )
616
+ else:
617
+ cov_argv = dc.coverage_run_argv(argv, python, append=append, data_file=str(data_file))
618
+ if cov_argv is None:
619
+ continue
620
+ try:
621
+ subprocess.run(
622
+ cov_argv,
623
+ cwd=self.project_root,
624
+ capture_output=True,
625
+ text=True,
626
+ encoding="utf-8",
627
+ errors="replace",
628
+ timeout=timeout,
629
+ env=env,
630
+ )
631
+ except Exception as exc:
632
+ logger.warning("Diff-coverage run failed for %s: %s", task.id, exc)
633
+ continue
634
+ ran_any = True
635
+ append = True
636
+
637
+ if not ran_any:
638
+ return dc.DiffCoverageResult(measured=False, reason="no instrumentable test command")
639
+ if not data_file.exists():
640
+ return dc.DiffCoverageResult(measured=False, reason="coverage produced no data")
641
+
642
+ try:
643
+ subprocess.run(
644
+ [python, "-m", "coverage", "json", f"--data-file={data_file}", "-o", str(json_file)],
645
+ cwd=self.project_root,
646
+ capture_output=True,
647
+ text=True,
648
+ encoding="utf-8",
649
+ errors="replace",
650
+ timeout=120,
651
+ env=env,
652
+ )
653
+ data = json.loads(json_file.read_text(encoding="utf-8"))
654
+ except Exception as exc:
655
+ return dc.DiffCoverageResult(measured=False, reason=f"coverage report unreadable: {exc}")
656
+
657
+ coverage = dc.parse_coverage_json(data, self.project_root)
658
+ return dc.intersect(changed, coverage, tool="coverage.py")
659
+ finally:
660
+ for path in [data_file, json_file, *inline_scripts]:
661
+ try:
662
+ path.unlink()
663
+ except OSError:
664
+ pass
665
+
330
666
  async def verify_task(self, task: Task, requirements: List[Requirement]) -> Tuple[List[Gap], List[Any]]:
331
667
  self._gap_counter = 0
332
668
  gaps: List[Gap] = []
333
669
  evidence_to_save: List[Any] = []
334
670
  changed_files = self.get_task_changed_files(task.id)
335
671
  diff_content = self.get_diff()
672
+ diff_empty = not bool(diff_content.strip())
673
+ # "Work present" is broader than the current working-tree diff: a task whose
674
+ # changes were already committed (e.g. `dev go`'s per-task commit, then the
675
+ # final reconciliation pass where `git diff HEAD` is empty) still counts as
676
+ # implemented. A genuine no-op run has neither a working diff nor committed
677
+ # changes since the task's checkpoint.
678
+ work_present = (not diff_empty) or self._task_produced_changes(task.id)
679
+
680
+ # Empty-diff guard. If the task declares files to create or modify but produced
681
+ # NO work at all, there is nothing to prove — an agent must not be able to
682
+ # declare victory having written nothing (or after a transient git error that
683
+ # degraded the diff to ""). This is the single most dangerous false-pass for
684
+ # autonomy, so it blocks regardless of which commands ran.
685
+ expects_change = any(pf.allowed_change != "read_only" for pf in task.planned_files)
686
+ if not work_present and expects_change:
687
+ gaps.append(Gap(
688
+ id=self._next_gap_id(task.id, "NODIFF"),
689
+ severity="high",
690
+ gap_type="task_not_implemented",
691
+ task_id=task.id,
692
+ description=(
693
+ f"Task {task.id} declares files to create or modify, but produced no "
694
+ "changes. Verification cannot prove work that does not exist."
695
+ ),
696
+ evidence=[f"planned files expecting change: {sorted(p.path for p in task.planned_files if p.allowed_change != 'read_only')}"],
697
+ recommended_fix=(
698
+ "Implement the planned changes so the diff is non-empty, then re-verify. "
699
+ "If you did make changes, ensure they are saved and visible to git "
700
+ "(not reverted, stashed, or written outside the project root)."
701
+ ),
702
+ blocking=True,
703
+ ))
336
704
 
337
705
  if diff_content:
338
706
  added_files, deleted_files = self._classify_change_paths(changed_files)
@@ -358,6 +726,7 @@ class Verifier:
358
726
  description=f"Planned file {pf.path} was not modified.",
359
727
  recommended_fix=f"Modify {pf.path} as planned or update the task.",
360
728
  blocking=False,
729
+ file=pf.path,
361
730
  ))
362
731
 
363
732
  # 2. Orphan-diff detection
@@ -372,8 +741,11 @@ class Verifier:
372
741
  evidence=[cf],
373
742
  recommended_fix=f"Revert changes to {cf} or add it to the task's planned files.",
374
743
  blocking=True,
744
+ file=cf,
375
745
  ))
376
746
 
747
+ gaps.extend(self._check_semantic_diff(task))
748
+
377
749
  # 3. Dependency change detection
378
750
  dep_changes = self._check_dependency_changes(changed_files)
379
751
  for dep_file in dep_changes:
@@ -387,68 +759,331 @@ class Verifier:
387
759
  evidence=[dep_file],
388
760
  recommended_fix=f"Justify the dependency change or revert {dep_file}.",
389
761
  blocking=True,
762
+ file=dep_file,
390
763
  ))
391
764
 
765
+ # When DevCouncil can compile its own per-criterion checks, THOSE are the
766
+ # authority and the planner's expected_tests are demoted to advisory — so a
767
+ # bogus planner command (irrelevant linters, npm on a Python project, tests
768
+ # that reference missing files) can no longer block correct work.
769
+ compiler_active = bool(self.acceptance_compiler and diff_content and task.acceptance_criterion_ids)
770
+
392
771
  # 4. Run verification commands
393
772
  command_results: List[CommandResult] = []
394
773
  evidence_results: List[CommandResult] = []
774
+ genuine_failure = False # a command that actually ran and failed (real defect signal)
775
+ had_unrunnable = False # a command that could not run (missing tool / missing tests)
776
+ # Genuine test failures demoted to non-blocking only because a compiler is active.
777
+ # That demotion is legitimate ONLY if the compiler actually produces per-criterion
778
+ # checks to take authority; re-promoted below if it produces none.
779
+ demoted_failures: List[Gap] = []
395
780
  for cmd_type, cmds in self._commands_for_task(task).items():
396
781
  for cmd in cmds:
782
+ applicable, skip_reason = self._command_applicable(cmd)
783
+ if not applicable:
784
+ # Wrong-stack command (e.g. `npm test` on a Python repo): skip it
785
+ # entirely rather than running and failing for a stack reason — an
786
+ # advisory note so the skip is visible (no silent drop).
787
+ gaps.append(Gap(
788
+ id=self._next_gap_id(task.id, "SKIP"),
789
+ severity="low",
790
+ gap_type="skipped_verification_command",
791
+ task_id=task.id,
792
+ description=f"Skipped verification command '{cmd}': {skip_reason}.",
793
+ evidence=[skip_reason],
794
+ recommended_fix=(
795
+ "Replace it with a command for this repo's stack, or remove it "
796
+ "from .devcouncil/config.yaml / the task's expected_tests."
797
+ ),
798
+ blocking=False,
799
+ suggested_command=cmd,
800
+ ))
801
+ continue
397
802
  result = self._run_command(cmd, task_id=task.id)
398
803
  command_results.append(result)
399
804
  evidence_to_save.append(result)
400
805
  if self._command_can_prove_acceptance(cmd_type, cmd):
401
806
  evidence_results.append(result)
402
807
  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
- ))
808
+ if self._command_is_malformed(result):
809
+ had_unrunnable = True
810
+ # The verification command itself could not run (e.g. a
811
+ # SyntaxError in a `python -c` one-liner, or a missing test
812
+ # tool). This proves nothing about the implementation, so do
813
+ # not report it as a code failure — surface it as a plan/
814
+ # command defect the user can regenerate instead.
815
+ gaps.append(Gap(
816
+ id=self._next_gap_id(task.id, "BADCMD"),
817
+ severity="medium",
818
+ gap_type="invalid_verification_command",
819
+ task_id=task.id,
820
+ description=(
821
+ f"Verification command could not run (not a code failure): '{cmd}'. "
822
+ "It appears malformed or its tooling is unavailable, so this command "
823
+ "proves nothing either way."
824
+ ),
825
+ evidence=[result.summary[:500]],
826
+ recommended_fix=(
827
+ "Regenerate the task's verification commands with 'dev repair', or edit "
828
+ "them to be a single runnable command (e.g. 'python -m pytest <file>')."
829
+ ),
830
+ # Non-blocking: a command that cannot run is not evidence of a
831
+ # defect. If it was the *only* check for an acceptance criterion,
832
+ # that criterion is independently caught as unproven (blocking).
833
+ blocking=False,
834
+ suggested_command=cmd,
835
+ stdout_path=result.stdout_path or None,
836
+ stderr_path=result.stderr_path or None,
837
+ ))
838
+ else:
839
+ # A verification command that genuinely failed. Lint/typecheck
840
+ # commands (from the config fallback) report style/type opinion,
841
+ # not a correctness defect, so they are ADVISORY — blocking a
842
+ # behaviorally-correct task on `flake8`/`mypy`/`ruff` is the
843
+ # false-block the benchmark surfaced. A real test failure still
844
+ # gates (unless compiled checks supersede it).
845
+ is_quality_gate = cmd_type in {"lint", "typecheck"} or self._is_quality_only_command(cmd)
846
+ blocking = (not compiler_active) and not is_quality_gate
847
+ if blocking:
848
+ genuine_failure = True
849
+ fail_file, fail_line = self._failure_location(result)
850
+ gap = Gap(
851
+ id=self._next_gap_id(task.id, cmd_type.upper()),
852
+ severity="high" if blocking else "medium",
853
+ gap_type="quality_gate_failed" if is_quality_gate else "test_failed",
854
+ task_id=task.id,
855
+ description=(
856
+ f"{'Quality gate' if is_quality_gate else 'Command'} '{cmd}' "
857
+ f"failed with exit code {result.exit_code}"
858
+ + (" (advisory: style/type, not a correctness gate)." if is_quality_gate else ".")
859
+ ),
860
+ evidence=[result.summary[:500]],
861
+ recommended_fix=f"Fix the issues reported by '{cmd}'.",
862
+ blocking=blocking,
863
+ suggested_command=cmd,
864
+ file=fail_file,
865
+ line=fail_line,
866
+ stdout_path=result.stdout_path or None,
867
+ stderr_path=result.stderr_path or None,
868
+ )
869
+ gaps.append(gap)
870
+ # A real test failure demoted only because the compiler is active:
871
+ # remember it so we can re-promote if the compiler yields no checks.
872
+ if compiler_active and not is_quality_gate and not blocking:
873
+ demoted_failures.append(gap)
413
874
 
414
- # 5. Acceptance-criteria evidence mapping
415
- successful_commands = [result for result in evidence_results if result.exit_code == 0]
875
+ # 4b. Compiled acceptance checks — precise, DevCouncil-owned per-criterion
876
+ # evidence. Derive one runnable check per acceptance criterion from the
877
+ # criterion text + the diff, instead of trusting planner-authored
878
+ # expected_tests (which the benchmark showed often reference absent tools or
879
+ # test files). Each check maps 1:1 to its criterion, replacing the coarse
880
+ # "any command passed -> every criterion proven" mapping.
881
+ compiled_pass: Dict[str, bool] = {}
882
+ # Per-AC bookkeeping so the unproven-AC gap can attach ONLY the check(s) that
883
+ # targeted that criterion (and the specific failing result), instead of dumping
884
+ # every command summary. Keys are AC ids; values track the compiled command(s)
885
+ # and any failing CommandResults for that AC.
886
+ compiled_cmds_by_ac: Dict[str, List[str]] = {}
887
+ failing_results_by_ac: Dict[str, List[CommandResult]] = {}
888
+ if self.acceptance_compiler and diff_content and task.acceptance_criterion_ids:
889
+ try:
890
+ compiled = await self.acceptance_compiler.compile(task, requirements, diff_content)
891
+ except Exception as exc: # pragma: no cover - best effort
892
+ logger.warning("Acceptance compiler failed for %s: %s", task.id, exc)
893
+ compiled = {}
894
+ for ac_id, cmds in compiled.items():
895
+ # Defensive: drop any wrong-stack compiled check so it can't fail an AC
896
+ # for a stack reason (the compiler is told not to emit these).
897
+ cmds = [c for c in cmds if self._command_applicable(c)[0]]
898
+ ac_ok = bool(cmds)
899
+ compiled_cmds_by_ac[ac_id] = list(cmds)
900
+ for cmd in cmds:
901
+ result = self._run_command(cmd, task_id=task.id)
902
+ command_results.append(result)
903
+ evidence_to_save.append(result)
904
+ if result.exit_code != 0:
905
+ ac_ok = False
906
+ failing_results_by_ac.setdefault(ac_id, []).append(result)
907
+ if self._command_is_malformed(result):
908
+ had_unrunnable = True
909
+ else:
910
+ genuine_failure = True
911
+ fail_file, fail_line = self._failure_location(result)
912
+ gaps.append(Gap(
913
+ id=self._next_gap_id(task.id, "ACCHK"),
914
+ severity="high",
915
+ gap_type="test_failed",
916
+ task_id=task.id,
917
+ description=f"Acceptance check for {ac_id} failed: '{cmd}' (exit {result.exit_code}).",
918
+ evidence=[result.summary[:500]],
919
+ recommended_fix=f"Fix the implementation so acceptance criterion {ac_id} holds.",
920
+ blocking=True,
921
+ acceptance_criterion_id=ac_id,
922
+ suggested_command=cmd,
923
+ file=fail_file,
924
+ line=fail_line,
925
+ stdout_path=result.stdout_path or None,
926
+ stderr_path=result.stderr_path or None,
927
+ ))
928
+ compiled_pass[ac_id] = ac_ok
929
+
930
+ # The compiler only earns the authority to demote a genuinely-failing planner
931
+ # test if it produced a per-criterion check for EVERY targeted AC. A partial
932
+ # compile is not enough: the uncovered ACs fall back to the coarse signal, so a
933
+ # demoted real failure + coarse-proven remainder would otherwise slip past the
934
+ # gate. If coverage is incomplete (or zero — empty compile / all-wrong-stack /
935
+ # a compile exception swallowed to {}), re-promote the demoted failures.
936
+ compiler_covered_all = bool(task.acceptance_criterion_ids) and all(
937
+ compiled_cmds_by_ac.get(ac_id) for ac_id in task.acceptance_criterion_ids
938
+ )
939
+ if compiler_active and not compiler_covered_all and demoted_failures:
940
+ for gap in demoted_failures:
941
+ gap.blocking = True
942
+ gap.severity = "high"
943
+ genuine_failure = True
944
+ logger.info(
945
+ "Re-promoted demoted test failure %s to blocking: acceptance compiler "
946
+ "did not produce a check for every criterion of task %s.",
947
+ gap.id, task.id,
948
+ )
949
+
950
+ # 5. Acceptance-criteria evidence mapping (precise, per criterion).
951
+ # Quality-only commands (lint/typecheck) are excluded: a passing `mypy`/`ruff
952
+ # check`/`tsc` exercises no behavior, so it must not coarse-prove a behavioral AC
953
+ # — the same false-confidence the per-criterion checks exist to prevent.
954
+ successful_commands = [
955
+ result for result in evidence_results
956
+ if result.exit_code == 0 and not self._is_quality_only_command(result.command)
957
+ ]
958
+ # Coarse fallback (used only when no compiled per-criterion check exists for an
959
+ # AC): a criterion may be marked proven by a passing acceptance-capable command
960
+ # ONLY when the task actually produced work. Without this guard a no-op run
961
+ # whose unrelated command happens to pass would "prove" every criterion against
962
+ # zero changes.
963
+ coarse_proof_available = work_present and bool(successful_commands)
416
964
  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:
965
+ req_by_ac = {ac.id: req.id for req in requirements for ac in req.acceptance_criteria}
966
+ unproven_acs: List[str] = []
967
+ coarse_proven_acs: List[str] = []
968
+ for ac_id in task.acceptance_criterion_ids:
969
+ # An AC is proven if its compiled check passed; if no compiled check
970
+ # exists for it, fall back to the coarse signal (any expected_test passed).
971
+ proven = compiled_pass.get(ac_id)
972
+ coarse = False
973
+ if proven is None:
974
+ proven = coarse_proof_available
975
+ coarse = proven # proven only by the coarse, not-AC-specific signal
976
+ if proven:
977
+ if coarse:
978
+ coarse_proven_acs.append(ac_id)
979
+ # Don't persist a "passed" record for a coarse-proven criterion during a
980
+ # run that also has a genuine blocking failure — the gate already fails,
981
+ # and a stored "passed" would mislead audits that read evidence directly.
982
+ if not (coarse and genuine_failure):
983
+ evidence_to_save.append(TestEvidence(
984
+ requirement_id=req_by_ac.get(ac_id, task.requirement_ids[0] if task.requirement_ids else ""),
985
+ acceptance_criterion_id=ac_id,
986
+ command="(devcouncil acceptance check)",
987
+ status="passed",
988
+ evidence_summary=(
989
+ "Acceptance criterion proven only by a COARSE signal (a passing "
990
+ "acceptance-capable command, not a per-criterion check); behavior "
991
+ "not precisely verified."
992
+ if coarse else
993
+ "Acceptance criterion proven by a per-criterion compiled check."
994
+ ),
995
+ ))
996
+ else:
997
+ unproven_acs.append(ac_id)
998
+ # Surface coarse proof as a first-class advisory: these criteria passed only
999
+ # because some acceptance-capable command exited 0, not because a check tied
1000
+ # to the criterion passed. Non-blocking, but no longer invisible.
1001
+ if coarse_proven_acs:
1002
+ gaps.append(Gap(
1003
+ id=self._next_gap_id(task.id, "COARSE"),
1004
+ severity="low",
1005
+ gap_type="coarse_acceptance_proof",
1006
+ task_id=task.id,
1007
+ description=(
1008
+ "Verification mode = COARSE for "
1009
+ f"{', '.join(coarse_proven_acs)}: proven by a passing acceptance-capable "
1010
+ "command, not a per-criterion check. Behavior is not precisely verified."
1011
+ ),
1012
+ evidence=[f"coarse-proven: {', '.join(coarse_proven_acs)}"],
1013
+ recommended_fix=(
1014
+ "Add a verification command (or test) that exercises each listed criterion "
1015
+ "specifically, so DevCouncil can compile a per-criterion check instead of "
1016
+ "relying on the coarse fallback."
1017
+ ),
1018
+ blocking=False,
1019
+ ))
1020
+ if unproven_acs:
1021
+ # Block only on positive evidence of a problem. If verification was
1022
+ # attempted but every failure was unrunnable (missing tooling / tests)
1023
+ # and nothing genuinely failed, that is a verification defect, not a
1024
+ # code defect — surface it as a non-blocking "could not verify".
1025
+ couldnt_verify = had_unrunnable and not genuine_failure and work_present
1026
+ ac_by_id = {ac.id: ac for req in requirements for ac in req.acceptance_criteria}
1027
+ # Methods that can be proven by running code; only these block the gate
1028
+ # when unproven. Inherently-manual criteria (manual/llm_review) and
1029
+ # optional ones are surfaced for human review instead of false-blocking
1030
+ # the autonomous loop — the gate still demands evidence for BEHAVIOR.
1031
+ automatable_methods = {"unit_test", "integration_test", "static_check"}
1032
+ for ac_id in unproven_acs:
1033
+ ac = ac_by_id.get(ac_id)
1034
+ method = ac.verification_method if ac else "unit_test"
1035
+ is_automatable = (ac.required if ac else True) and method in automatable_methods
1036
+ if not is_automatable:
1037
+ blocks = False
1038
+ optional = "" if (ac is None or ac.required) else " optional"
1039
+ fix = (
1040
+ f"This{optional} criterion's verification method is '{method}'; it cannot be "
1041
+ "proven by running code. Review it manually (it does not block the gate)."
1042
+ )
1043
+ suffix = f" (non-blocking: {method})"
1044
+ elif couldnt_verify:
1045
+ blocks = False
1046
+ fix = ("Could not verify this criterion: the verification commands did not run "
1047
+ "(missing tooling or tests). Regenerate them with 'dev repair' to confirm the work.")
1048
+ suffix = " (verification commands could not run)"
1049
+ else:
1050
+ blocks = True
1051
+ fix = "Add or fix a verification command that proves this acceptance criterion."
1052
+ suffix = ""
1053
+ # Concrete, AC-scoped evidence instead of "all command summaries":
1054
+ # * if a compiled check targeted this AC, attach its command(s) and
1055
+ # the specific failing result;
1056
+ # * otherwise an explicit "no check compiled" marker so the agent
1057
+ # knows it must author one, not hunt through unrelated output.
1058
+ ac_compiled = compiled_cmds_by_ac.get(ac_id, [])
1059
+ ac_failures = failing_results_by_ac.get(ac_id, [])
1060
+ ac_evidence: List[str] = []
1061
+ suggested_cmd: Optional[str] = None
1062
+ if ac_compiled:
1063
+ suggested_cmd = ac_compiled[0]
1064
+ ac_evidence.extend(f"compiled check: {c}" for c in ac_compiled)
1065
+ ac_evidence.extend(r.summary[:500] for r in ac_failures)
1066
+ else:
1067
+ ac_evidence.append(
1068
+ f"no DevCouncil check compiled for {ac_id} "
1069
+ f"(expected verification method: {method})"
1070
+ )
437
1071
  gaps.append(Gap(
438
1072
  id=self._next_gap_id(task.id, "AC"),
439
- severity="high",
1073
+ severity="high" if blocks else "medium",
440
1074
  gap_type="acceptance_criteria_unproven",
441
1075
  requirement_id=self._requirement_id_for_ac(requirements, ac_id),
442
1076
  task_id=task.id,
443
1077
  description=(
444
1078
  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."
1079
+ f"for task {task.id}.{suffix}"
450
1080
  ),
451
- blocking=True,
1081
+ evidence=ac_evidence,
1082
+ recommended_fix=fix,
1083
+ blocking=blocks,
1084
+ acceptance_criterion_id=ac_id,
1085
+ expected_verification_method=method,
1086
+ suggested_command=suggested_cmd,
452
1087
  ))
453
1088
  elif task.requirement_ids:
454
1089
  gaps.append(Gap(
@@ -462,16 +1097,90 @@ class Verifier:
462
1097
  blocking=True,
463
1098
  ))
464
1099
 
1100
+ # 5b. Diff↔coverage gate. A green suite is only acceptance evidence if it
1101
+ # exercised the lines the diff changed. This catches the failure the README
1102
+ # promises to stop: tests "pass" while the new logic is never run (unrelated
1103
+ # suite, code never imported, untouched branch). Measured only when the target
1104
+ # repo has coverage tooling and the diff has measurable Python changes; absent
1105
+ # that, it degrades silently rather than blocking correct work.
1106
+ measure_cov, enforce_cov, min_ratio = self._diff_coverage_settings()
1107
+ any_passing = bool(successful_commands) or any(compiled_pass.values())
1108
+ coverage_measured = False
1109
+ coverage_skipped_reason: Optional[str] = None
1110
+ if not measure_cov:
1111
+ coverage_skipped_reason = "diff coverage disabled in config"
1112
+ elif not diff_content:
1113
+ coverage_skipped_reason = "no diff to measure"
1114
+ elif not task.acceptance_criterion_ids:
1115
+ coverage_skipped_reason = "task has no acceptance criteria"
1116
+ elif not any_passing:
1117
+ coverage_skipped_reason = "no passing verification command to instrument"
1118
+ if measure_cov and diff_content and task.acceptance_criterion_ids and any_passing:
1119
+ cov = self.measure_diff_coverage(task, diff_content)
1120
+ if not cov.measured:
1121
+ coverage_skipped_reason = cov.reason or "diff coverage could not be measured"
1122
+ if cov.measured:
1123
+ coverage_measured = True
1124
+ coverage_skipped_reason = None
1125
+ evidence_to_save.append(DiffCoverageEvidence(
1126
+ task_id=task.id,
1127
+ tool=cov.tool,
1128
+ measured=True,
1129
+ changed_lines=cov.changed_executable_lines,
1130
+ covered_lines=cov.covered_changed_lines,
1131
+ coverage_ratio=cov.ratio,
1132
+ uncovered_by_file=cov.uncovered_by_file,
1133
+ absent_files=cov.absent_files,
1134
+ summary=cov.summary(),
1135
+ ))
1136
+ failing = cov.covered_changed_lines == 0 if min_ratio <= 0 else cov.ratio < min_ratio
1137
+ if failing:
1138
+ first_file = next(iter(cov.uncovered_by_file), None)
1139
+ first_lines = cov.uncovered_by_file.get(first_file or "", [])
1140
+ target_cmds = self._coverage_target_commands(task)
1141
+ gaps.append(Gap(
1142
+ id=self._next_gap_id(task.id, "DIFFCOV"),
1143
+ severity="high" if enforce_cov else "medium",
1144
+ gap_type="diff_not_exercised",
1145
+ task_id=task.id,
1146
+ description=(
1147
+ f"Verification commands passed but exercised "
1148
+ f"{cov.covered_changed_lines}/{cov.changed_executable_lines} changed line(s): "
1149
+ f"{cov.summary()}. The acceptance criteria are not proven because the new "
1150
+ "logic was never executed by the tests."
1151
+ ),
1152
+ evidence=[cov.summary()] + [
1153
+ f"{path}: lines {lines}" for path, lines in list(cov.uncovered_by_file.items())[:5]
1154
+ ],
1155
+ recommended_fix=(
1156
+ "Add or extend a test that executes the changed lines, then re-verify. "
1157
+ "A passing suite that does not run the new code is not acceptance evidence."
1158
+ ),
1159
+ # Off by default (signal first); teams opt into blocking via
1160
+ # verification.diff_coverage.enforce.
1161
+ blocking=enforce_cov,
1162
+ file=first_file,
1163
+ line=first_lines[0] if first_lines else None,
1164
+ suggested_command=target_cmds[0] if target_cmds else None,
1165
+ ))
1166
+
465
1167
  # 6. Secret scan
466
1168
  if diff_content:
467
1169
  gaps.extend(self.secret_scanner.scan_diff(diff_content, task.id))
468
1170
 
469
- # 7. LLM Implementation Review
1171
+ # 7. LLM Implementation Review (ADVISORY ONLY).
1172
+ # DevCouncil's authority is executable evidence, not model confidence — so
1173
+ # an LLM reviewer must never block on its own say-so. Subjective reviewers
1174
+ # over-flag correct code (false negatives that erode trust in "blocked"),
1175
+ # so review findings are surfaced as non-blocking signals. A genuine
1176
+ # requirement gap is caught by the acceptance-criteria evidence checks
1177
+ # above; the review just adds human-facing context.
470
1178
  if self.reviewer and diff_content:
471
1179
  try:
472
1180
  review_result = await self.reviewer.review_changes(task, requirements, diff_content)
473
1181
  for finding in review_result.findings:
474
1182
  finding.id = self._next_gap_id(task.id, "REVIEW")
1183
+ finding.blocking = False
475
1184
  gaps.append(finding)
476
1185
  except Exception as e:
477
1186
  logger.error("Implementation review failed: %s", e)
@@ -492,8 +1201,258 @@ class Verifier:
492
1201
  blocking=True,
493
1202
  ))
494
1203
 
1204
+ self.last_outcome = VerificationOutcome(
1205
+ mode="compiled" if self.acceptance_compiler else "coarse",
1206
+ compiler_active=compiler_active,
1207
+ diff_empty=diff_empty,
1208
+ coverage_measured=coverage_measured,
1209
+ coverage_skipped_reason=coverage_skipped_reason,
1210
+ )
495
1211
  return gaps, evidence_to_save
496
1212
 
1213
+ def _check_semantic_diff(self, task: Task) -> List[Gap]:
1214
+ gaps: List[Gap] = []
1215
+ semantic_path = self.project_root / ".devcouncil" / "semantic" / task.id
1216
+ after_path = semantic_path / "after.json"
1217
+ if not after_path.exists():
1218
+ return gaps
1219
+ try:
1220
+ from devcouncil.indexing.semantic_index import SemanticIndex
1221
+
1222
+ result = SemanticIndex(self.project_root).diff(task.id)
1223
+ except Exception as e:
1224
+ logger.warning("Semantic diff check failed for %s; skipping semantic gaps: %s", task.id, e)
1225
+ return gaps
1226
+
1227
+ planned_paths = {pf.path for pf in task.planned_files}
1228
+ for item in result.get("classifications", []):
1229
+ change_type = item.get("type", "")
1230
+ path = item.get("path", "")
1231
+ if change_type == "public_api_change" and path not in planned_paths:
1232
+ gaps.append(Gap(
1233
+ id=self._next_gap_id(task.id, "SEM"),
1234
+ severity="high",
1235
+ gap_type="architecture_drift",
1236
+ task_id=task.id,
1237
+ description=f"Unplanned public API change detected in {path}.",
1238
+ evidence=[path],
1239
+ recommended_fix="Add file to planned_files and document acceptance criteria.",
1240
+ blocking=not bool(task.acceptance_criterion_ids),
1241
+ ))
1242
+ elif change_type == "import_dependency_change" and path not in planned_paths:
1243
+ gaps.append(Gap(
1244
+ id=self._next_gap_id(task.id, "IMP"),
1245
+ severity="medium",
1246
+ gap_type="dependency_risk",
1247
+ task_id=task.id,
1248
+ description=f"Import dependency change in {path}.",
1249
+ evidence=[path],
1250
+ recommended_fix="Confirm dependency change is intentional.",
1251
+ blocking=False,
1252
+ ))
1253
+ elif change_type == "config_schema_dependency_change" and path not in planned_paths:
1254
+ gaps.append(Gap(
1255
+ id=self._next_gap_id(task.id, "CFG"),
1256
+ severity="high",
1257
+ gap_type="dependency_risk",
1258
+ task_id=task.id,
1259
+ description=f"Config/schema change detected in {path}.",
1260
+ evidence=[path],
1261
+ recommended_fix="Plan the config change or revert it.",
1262
+ blocking=True,
1263
+ ))
1264
+ return gaps
1265
+
1266
+ # Signatures that mean the verification command itself could not run (or had
1267
+ # nothing to run), so its non-zero exit says nothing about whether the
1268
+ # implementation is correct — a tooling/plan defect, not a code defect.
1269
+ _MALFORMED_COMMAND_SIGNATURES = (
1270
+ "syntaxerror",
1271
+ "invalid syntax",
1272
+ "indentationerror",
1273
+ "no module named", # any tool not installed (pytest, flake8, mypy, ...)
1274
+ "can't open file",
1275
+ "no such file or directory",
1276
+ "file or directory not found", # pytest: target path missing
1277
+ "no tests ran", # pytest -k matched nothing / empty file
1278
+ "no tests collected",
1279
+ "error: not found", # pytest: test node id does not exist
1280
+ "is not recognized as an internal or external command",
1281
+ "command not found",
1282
+ "executable file not found",
1283
+ "failed to run command",
1284
+ "importerror", # the verification harness itself failed to import
1285
+ "modulenotfounderror",
1286
+ )
1287
+ # Compile-/launch-time signatures that mean the code NEVER executed — these are
1288
+ # always authoritative regardless of any ``File "<string>", line N`` marker (a
1289
+ # SyntaxError prints that marker even though nothing ran). They must not be subject
1290
+ # to the "signature must precede a traceback frame" rule that distinguishes a real
1291
+ # in-test traceback from a launcher error.
1292
+ _UNCONDITIONAL_UNRUNNABLE_SIGNATURES = (
1293
+ "syntaxerror",
1294
+ "invalid syntax",
1295
+ "indentationerror",
1296
+ "can't open file",
1297
+ "is not recognized as an internal or external command",
1298
+ "command not found",
1299
+ "executable file not found",
1300
+ "failed to run command",
1301
+ "no tests ran",
1302
+ "no tests collected",
1303
+ "error: not found",
1304
+ )
1305
+ # pytest exit codes that mean "could not run / collect", not "tests failed":
1306
+ # 4 = usage/collection error, 5 = no tests collected.
1307
+ _PYTEST_NONRUN_EXIT_CODES = {4, 5}
1308
+
1309
+ @staticmethod
1310
+ def _is_traceback_frame(line: str) -> bool:
1311
+ """True for a Python traceback frame line: `` File "...", line N``."""
1312
+ stripped = line.strip()
1313
+ return stripped.startswith('File "') and ", line " in stripped
1314
+
1315
+ def _malformed_signature_precedes_traceback(self, text: str) -> bool:
1316
+ """Decide whether an unrunnable-launcher signature is authoritative.
1317
+
1318
+ A launcher/collection failure prints its error WITHOUT a Python traceback that
1319
+ executed the code under test (e.g. ``ModuleNotFoundError: No module named
1320
+ pytest`` straight from the interpreter, or pytest's collection error banner).
1321
+ A genuine in-test failure, by contrast, raises from inside a traceback whose
1322
+ frames point at the test/source files; the same signature words can appear
1323
+ there (``ImportError`` re-raised inside a test) but that is a real defect, not
1324
+ an unrunnable command.
1325
+
1326
+ So a signature only proves "unrunnable" when it appears BEFORE the first
1327
+ traceback frame (or there is no traceback frame at all). If a traceback frame
1328
+ appears at or before the signature, the code under test ran and failed — keep
1329
+ it a blocking test failure."""
1330
+ if not text:
1331
+ return False
1332
+ low_all = text.lower()
1333
+ # Compile-/launch-time failures: the code never executed, so a ``File ...``
1334
+ # marker (printed by SyntaxError) is not a real frame. Authoritative outright.
1335
+ if any(sig in low_all for sig in self._UNCONDITIONAL_UNRUNNABLE_SIGNATURES):
1336
+ return True
1337
+ lines = text.splitlines()
1338
+ lowered_lines = [ln.lower() for ln in lines]
1339
+ first_frame_idx: Optional[int] = None
1340
+ for idx, line in enumerate(lines):
1341
+ if self._is_traceback_frame(line):
1342
+ first_frame_idx = idx
1343
+ break
1344
+ for idx, low in enumerate(lowered_lines):
1345
+ if any(sig in low for sig in self._MALFORMED_COMMAND_SIGNATURES):
1346
+ # Signature found; it is only authoritative if no traceback frame
1347
+ # precedes it (i.e. the failure is from the launcher, not from code
1348
+ # that actually executed under a traceback).
1349
+ if first_frame_idx is None or idx < first_frame_idx:
1350
+ return True
1351
+ return False
1352
+ return False
1353
+
1354
+ def _launcher_text(self, result: CommandResult) -> str:
1355
+ """Captured output for launcher-vs-test analysis, ordered stderr then stdout.
1356
+
1357
+ The traceback-precedence discriminator
1358
+ (:meth:`_malformed_signature_precedes_traceback`) needs to see BOTH streams:
1359
+ an interpreter "cannot run" error lands on stderr (with no traceback frame),
1360
+ while a genuine in-test failure's traceback lands on stdout (frame first, then
1361
+ the exception). We therefore concatenate stderr+stdout so the relative ordering
1362
+ of any signature vs the first traceback frame is preserved.
1363
+
1364
+ Reading the merged ``result.summary`` alone is unsafe: it hoists the salient
1365
+ error line to the FRONT, which would place an in-test ``ImportError`` before its
1366
+ own traceback frame and misclassify a real failure as unrunnable. So prefer the
1367
+ raw logs; only fall back to the summary when no log path is available (e.g. unit
1368
+ tests that stub ``_run_command``). Never raises."""
1369
+ parts: List[str] = []
1370
+ for path in (result.stderr_path, result.stdout_path):
1371
+ if not path:
1372
+ continue
1373
+ try:
1374
+ content = Path(path).read_text(encoding="utf-8", errors="replace")
1375
+ if content.strip():
1376
+ parts.append(content)
1377
+ except Exception:
1378
+ pass
1379
+ if parts:
1380
+ return "\n".join(parts)
1381
+ return result.summary or ""
1382
+
1383
+ # Matches a Python traceback frame: `` File "path/to/x.py", line 42, in foo``.
1384
+ _TRACEBACK_FRAME_RE = re.compile(r'File "(?P<file>[^"]+)", line (?P<line>\d+)')
1385
+
1386
+ def _failure_location(self, result: CommandResult) -> Tuple[Optional[str], Optional[int]]:
1387
+ """Best-effort (file, line) of a failing command's deepest traceback frame.
1388
+
1389
+ The LAST frame in a Python traceback is the actual raise site, so we scan all
1390
+ frames and keep the last one that points at a real-looking source file (not the
1391
+ ``<string>`` of a ``python -c`` snippet). Returns repo-relative posix paths when
1392
+ the frame is inside the project root. Reads the captured logs (stdout has the
1393
+ test traceback; stderr has interpreter errors). Never raises."""
1394
+ sources = []
1395
+ for path in (result.stdout_path, result.stderr_path):
1396
+ if path:
1397
+ try:
1398
+ content = Path(path).read_text(encoding="utf-8", errors="replace")
1399
+ if content.strip():
1400
+ sources.append(content)
1401
+ except Exception:
1402
+ pass
1403
+ sources.append(result.summary or "")
1404
+ best_file: Optional[str] = None
1405
+ best_line: Optional[int] = None
1406
+ for text in sources:
1407
+ for match in self._TRACEBACK_FRAME_RE.finditer(text):
1408
+ raw_file = match.group("file")
1409
+ if not raw_file or raw_file.startswith("<"):
1410
+ continue # e.g. "<string>" from python -c
1411
+ best_file = self._relativize(raw_file)
1412
+ try:
1413
+ best_line = int(match.group("line"))
1414
+ except ValueError:
1415
+ best_line = None
1416
+ if best_file is not None:
1417
+ return best_file, best_line
1418
+ return best_file, best_line
1419
+
1420
+ def _relativize(self, raw_path: str) -> str:
1421
+ """Normalize a traceback file path to a repo-relative posix path when possible."""
1422
+ normalized = raw_path.replace("\\", "/")
1423
+ try:
1424
+ candidate = Path(raw_path)
1425
+ if candidate.is_absolute():
1426
+ rel = candidate.resolve().relative_to(self.project_root.resolve())
1427
+ return rel.as_posix()
1428
+ except Exception:
1429
+ pass
1430
+ return normalized
1431
+
1432
+ def _command_is_malformed(self, result: CommandResult) -> bool:
1433
+ """True when a non-zero exit reflects a broken/unrunnable command rather
1434
+ than a genuine assertion or test failure of the code under verification.
1435
+
1436
+ Authoritative signals (in priority order):
1437
+ 1. pytest exit 4/5 -> collection/usage error -> unrunnable.
1438
+ 2. The launcher error text: an unrunnable signature only counts when it
1439
+ appears BEFORE any Python traceback frame. This stops a genuinely failing
1440
+ test whose traceback contains ``ImportError``/``ModuleNotFoundError`` from
1441
+ being downgraded to a non-blocking "invalid command" (which would let
1442
+ verification falsely PASS)."""
1443
+ is_pytest = "pytest" in (result.command or "")
1444
+ if is_pytest and result.exit_code in self._PYTEST_NONRUN_EXIT_CODES:
1445
+ return True
1446
+ # Otherwise the exit code alone is ambiguous: pytest exit 1 is "tests ran and
1447
+ # FAILED" (a real defect), but a missing pytest module also exits 1 from the
1448
+ # interpreter (``No module named pytest``). The launcher error text is the
1449
+ # authoritative discriminator — a signature only means "unrunnable" when it
1450
+ # appears BEFORE any Python traceback frame. A genuine test failure whose
1451
+ # traceback merely mentions ``ImportError`` keeps a traceback frame first and so
1452
+ # stays a blocking test failure (preventing a false PASS).
1453
+ text = self._launcher_text(result)
1454
+ return self._malformed_signature_precedes_traceback(text)
1455
+
497
1456
  def _commands_for_task(self, task: Task) -> Dict[str, List[str]]:
498
1457
  if task.expected_tests:
499
1458
  return {"test": task.expected_tests}
@@ -501,6 +1460,65 @@ class Verifier:
501
1460
  return {"allowed": task.allowed_commands}
502
1461
  return self._load_commands()
503
1462
 
1463
+ def _command_applicable(self, command: str) -> tuple[bool, str]:
1464
+ """Stack-aware gate for a verification command.
1465
+
1466
+ A planner- or config-supplied command must not BLOCK a task when it targets a
1467
+ language stack the repository does not have (e.g. ``npm test``/``eslint``/
1468
+ ``tsc`` on a Python-only repo). Those fail for stack reasons, not real defects —
1469
+ the false-block the benchmark surfaced. Returns ``(applicable, reason)``; an
1470
+ inapplicable command is skipped and recorded as advisory rather than run."""
1471
+ cmd = (command or "").strip()
1472
+ if not cmd:
1473
+ return True, ""
1474
+ try:
1475
+ from devcouncil.repo.ci_scaffold import _command_stack, detect_stacks
1476
+
1477
+ stacks = detect_stacks(self.project_root)
1478
+ stack = _command_stack(cmd)
1479
+ except Exception:
1480
+ return True, ""
1481
+ if stack is not None and stacks and stack not in stacks:
1482
+ detected = ", ".join(sorted(stacks)) or "none"
1483
+ return False, f"command targets the '{stack}' stack not present in this repo (detected: {detected})"
1484
+ return True, ""
1485
+
1486
+ # Linters / formatters / type checkers: a non-zero exit is a style/type OPINION,
1487
+ # not proof of a behavioral defect. Blocking a behaviorally-correct task on these is
1488
+ # the false-block the benchmark surfaced (the planner even spawns dedicated
1489
+ # "add flake8 check" / "run black --check" tasks). Their failures are advisory.
1490
+ _QUALITY_TOOLS = {
1491
+ "black", "flake8", "ruff", "isort", "pylint", "mypy", "pyright", "autopep8",
1492
+ "yapf", "pyflakes", "pycodestyle", "bandit", "eslint", "tsc", "prettier",
1493
+ "stylelint", "standard", "biome",
1494
+ }
1495
+
1496
+ def _is_quality_only_command(self, command: str) -> bool:
1497
+ """True when the command's executable is purely a linter/formatter/type checker.
1498
+
1499
+ Handles common wrappers (``python -m mypy``, ``npx eslint``, ``poetry run black``,
1500
+ ``npm run lint``). A behavioral check like ``pytest`` or ``python -c 'assert ...'``
1501
+ is NOT a quality-only command and still gates."""
1502
+ tokens = command.split()
1503
+ i = 0
1504
+ while i < len(tokens):
1505
+ tok = tokens[i]
1506
+ if tok in {"python", "python3", "py"} and i + 1 < len(tokens) and tokens[i + 1] == "-m":
1507
+ i += 2
1508
+ continue
1509
+ if tok in {"npx", "poetry", "uv", "pdm", "hatch", "rye"}:
1510
+ i += 1
1511
+ if i < len(tokens) and tokens[i] == "run":
1512
+ i += 1
1513
+ continue
1514
+ if tok in {"npm", "pnpm", "yarn"}:
1515
+ return any(word in tokens for word in ("lint", "format", "eslint", "prettier", "stylelint", "biome"))
1516
+ break
1517
+ if i >= len(tokens):
1518
+ return False
1519
+ tool = tokens[i].replace("\\", "/").split("/")[-1].split("==")[0].lower()
1520
+ return tool in self._QUALITY_TOOLS
1521
+
504
1522
  def _command_can_prove_acceptance(self, cmd_type: str, command: str) -> bool:
505
1523
  if cmd_type == "test":
506
1524
  return True