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
@@ -0,0 +1,189 @@
1
+ """Typed, machine-actionable next actions derived from verification gaps.
2
+
3
+ A human-readable "here is what's wrong" report is fine for a person, but an agent
4
+ in a closed loop needs a structured contract it can route on without parsing
5
+ prose. ``build_next_actions`` turns the gaps from a verification run into a list of
6
+ :class:`NextAction` records — ``{category, action, file, line, missing_evidence,
7
+ suggested_command}`` — so a coding agent (over MCP) can self-repair and re-verify
8
+ without a human pasting anything.
9
+
10
+ The mapping is deterministic. Where a gap was created with explicit hints
11
+ (``gap.file``/``gap.line``/``gap.suggested_command``) those are used directly; when
12
+ a gap is reloaded from the database (which does not persist those hint columns) the
13
+ fields are reconstructed best-effort from the gap's evidence and description.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import List, Optional
19
+
20
+ from pydantic import BaseModel, Field
21
+
22
+ from devcouncil.domain.gap import Gap
23
+
24
+ # Machine-routable buckets an agent can branch on. Kept small and stable so the
25
+ # contract is predictable across releases.
26
+ Category = str
27
+
28
+ _CATEGORY_BY_GAP_TYPE = {
29
+ "orphan_diff": "scope",
30
+ "planned_file_not_changed": "scope",
31
+ "dependency_risk": "scope",
32
+ "test_failed": "fix_code",
33
+ "invalid_verification_command": "fix_verification",
34
+ "acceptance_criteria_unproven": "add_test",
35
+ "diff_not_exercised": "add_test",
36
+ "missing_test": "add_test",
37
+ "security_risk": "security",
38
+ "architecture_drift": "review",
39
+ "assumption_violated": "review",
40
+ "migration_gap": "fix_code",
41
+ "requirement_not_planned": "plan",
42
+ "task_not_implemented": "plan",
43
+ }
44
+
45
+
46
+ class NextAction(BaseModel):
47
+ """One concrete, routable step the agent can take to clear a gap."""
48
+
49
+ gap_id: str
50
+ gap_type: str
51
+ category: Category
52
+ severity: str
53
+ blocking: bool
54
+ action: str
55
+ file: Optional[str] = None
56
+ line: Optional[int] = None
57
+ acceptance_criterion_id: Optional[str] = None
58
+ expected_verification_method: Optional[str] = None
59
+ missing_evidence: Optional[str] = None
60
+ suggested_command: Optional[str] = None
61
+ evidence: List[str] = Field(default_factory=list)
62
+ # Paths to the captured stdout/stderr logs for the failing command behind this
63
+ # gap, so the agent can open the full failure output without re-running. Present
64
+ # only on a fresh verify run (the gap store does not persist them).
65
+ stdout_path: Optional[str] = None
66
+ stderr_path: Optional[str] = None
67
+
68
+
69
+ def _looks_like_path(value: str) -> bool:
70
+ value = value.strip()
71
+ if not value or " " in value or "\n" in value:
72
+ return False
73
+ return "/" in value or "." in value
74
+
75
+
76
+ def _derive_file(gap: Gap) -> Optional[str]:
77
+ if gap.file:
78
+ return gap.file
79
+ for item in gap.evidence:
80
+ if isinstance(item, str) and _looks_like_path(item):
81
+ return item
82
+ return None
83
+
84
+
85
+ def _action_text(gap: Gap, file: Optional[str]) -> str:
86
+ target = file or "the affected file"
87
+ if gap.gap_type == "orphan_diff":
88
+ return f"Revert changes to {target} or add it to the task's planned files."
89
+ if gap.gap_type == "planned_file_not_changed":
90
+ return f"Modify {target} as planned, or remove it from the task's planned files."
91
+ if gap.gap_type == "dependency_risk":
92
+ return f"Justify or revert the unplanned dependency/config change in {target}."
93
+ if gap.gap_type == "test_failed":
94
+ cmd = gap.suggested_command
95
+ return f"Fix the failing check, then re-run: {cmd}" if cmd else "Fix the failing verification check, then re-verify."
96
+ if gap.gap_type == "invalid_verification_command":
97
+ return "Replace the unrunnable verification command with a single runnable command, then re-verify."
98
+ if gap.gap_type == "diff_not_exercised":
99
+ loc = f" ({target}{':' + str(gap.line) if gap.line else ''})" if file else ""
100
+ return f"Add or extend a test that executes the changed lines{loc}, then re-verify."
101
+ if gap.gap_type in {"acceptance_criteria_unproven", "missing_test"}:
102
+ return "Provide a passing verification command that proves this acceptance criterion."
103
+ if gap.gap_type == "security_risk":
104
+ return "Remove the detected secret/finding from the diff and rotate any exposed credential."
105
+ if gap.gap_type == "architecture_drift":
106
+ return "Address the flagged change or resolve the open critique card, then re-verify."
107
+ # Fall back to the gap's own recommended fix for anything unmapped.
108
+ return gap.recommended_fix
109
+
110
+
111
+ def _missing_evidence(gap: Gap) -> Optional[str]:
112
+ """A concrete description of WHAT evidence is missing — not a restatement of the
113
+ description.
114
+
115
+ For an unproven acceptance criterion we name the criterion and the expected
116
+ verification method (and, where the verifier knew one, the expected check command
117
+ via ``gap.suggested_command``) so the agent can author the right proof rather than
118
+ re-reading prose. Falls back to the gap description for the other add-test gap
119
+ types (diff_not_exercised, missing_test) which already carry concrete locations."""
120
+ if gap.gap_type == "acceptance_criteria_unproven":
121
+ parts: List[str] = []
122
+ ac = gap.acceptance_criterion_id
123
+ method = gap.expected_verification_method
124
+ if ac:
125
+ parts.append(f"No passing evidence for acceptance criterion {ac}")
126
+ else:
127
+ parts.append("No passing acceptance evidence")
128
+ if method:
129
+ parts.append(f"expected verification method: {method}")
130
+ if gap.suggested_command:
131
+ parts.append(f"run/repair check: {gap.suggested_command}")
132
+ elif gap.file:
133
+ loc = f"{gap.file}:{gap.line}" if gap.line else gap.file
134
+ parts.append(f"uncovered: {loc}")
135
+ return "; ".join(parts)
136
+ if gap.gap_type in {"diff_not_exercised", "missing_test"}:
137
+ return gap.description
138
+ return None
139
+
140
+
141
+ def next_action_for(gap: Gap) -> NextAction:
142
+ file = _derive_file(gap)
143
+ return NextAction(
144
+ gap_id=gap.id,
145
+ gap_type=gap.gap_type,
146
+ category=_CATEGORY_BY_GAP_TYPE.get(gap.gap_type, "review"),
147
+ severity=gap.severity,
148
+ blocking=gap.blocking,
149
+ action=_action_text(gap, file),
150
+ file=file,
151
+ line=gap.line,
152
+ acceptance_criterion_id=gap.acceptance_criterion_id,
153
+ expected_verification_method=gap.expected_verification_method,
154
+ missing_evidence=_missing_evidence(gap),
155
+ suggested_command=gap.suggested_command,
156
+ evidence=list(gap.evidence),
157
+ stdout_path=gap.stdout_path,
158
+ stderr_path=gap.stderr_path,
159
+ )
160
+
161
+
162
+ def build_next_actions(gaps: List[Gap], *, blocking_only: bool = True) -> List[NextAction]:
163
+ """Build the next-actions contract from verification gaps.
164
+
165
+ By default only *blocking* gaps become next actions — those are what the agent
166
+ must clear to pass verification. Pass ``blocking_only=False`` to include
167
+ advisory signals (non-blocking gaps) as well. Blocking actions are ordered
168
+ first, then by severity.
169
+ """
170
+ selected = [g for g in gaps if g.blocking] if blocking_only else list(gaps)
171
+ severity_rank = {"critical": 0, "high": 1, "medium": 2, "low": 3}
172
+ selected.sort(key=lambda g: (not g.blocking, severity_rank.get(g.severity, 4)))
173
+ return [next_action_for(gap) for gap in selected]
174
+
175
+
176
+ def split_next_actions(gaps: List[Gap]) -> tuple[List[NextAction], List[NextAction]]:
177
+ """Return ``(blocking_actions, advisory_actions)``.
178
+
179
+ Blocking actions are what the agent MUST clear to pass the gate. Advisory
180
+ actions are non-blocking signals worth acting on — most importantly the
181
+ diff↔coverage ``diff_not_exercised`` finding ("tests passed but the new code
182
+ was never run") and security/add-test hints, which ``build_next_actions``
183
+ filters out by default. Surfacing them as a distinct array lets an autonomous
184
+ agent improve quality without confusing them with the pass/fail gate.
185
+ """
186
+ all_actions = build_next_actions(gaps, blocking_only=False)
187
+ blocking = [a for a in all_actions if a.blocking]
188
+ advisory = [a for a in all_actions if not a.blocking]
189
+ return blocking, advisory
@@ -0,0 +1,178 @@
1
+ """Verification sandbox abstraction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import platform
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Literal
11
+
12
+ from pydantic import BaseModel
13
+
14
+ from devcouncil.app.config import DevCouncilConfig, load_config
15
+ from devcouncil.domain.task import Task
16
+ from devcouncil.storage.db import get_db
17
+ from devcouncil.storage.native import VerificationRunRepository
18
+ from devcouncil.verification.verifier import Verifier
19
+
20
+
21
+ class SandboxResult(BaseModel):
22
+ sandbox: str
23
+ status: Literal["passed", "failed", "unsupported"]
24
+ environment: dict[str, str]
25
+ commands: list[dict]
26
+
27
+
28
+ class VerificationSandbox:
29
+ def run(self, task: Task, commands: list[str], requirements: list) -> SandboxResult:
30
+ raise NotImplementedError
31
+
32
+
33
+ def _save_run(
34
+ project_root: Path,
35
+ task: Task,
36
+ sandbox: str,
37
+ environment: dict[str, str],
38
+ commands: list[dict],
39
+ status: Literal["passed", "failed", "unsupported"],
40
+ ) -> None:
41
+ db = get_db(project_root)
42
+ if not db:
43
+ return
44
+ with db.get_session() as session:
45
+ VerificationRunRepository(session).save(
46
+ task.id,
47
+ sandbox,
48
+ environment,
49
+ commands,
50
+ status,
51
+ )
52
+
53
+
54
+ class LocalSandbox(VerificationSandbox):
55
+ def __init__(self, project_root: Path):
56
+ self.project_root = project_root
57
+
58
+ def run(self, task: Task, commands: list[str], requirements: list) -> SandboxResult:
59
+ import asyncio
60
+
61
+ gaps, _ = asyncio.run(Verifier(self.project_root).verify_task(task, requirements))
62
+ status: Literal["passed", "failed", "unsupported"] = (
63
+ "failed" if any(g.blocking for g in gaps) else "passed"
64
+ )
65
+ env = _environment_metadata(self.project_root)
66
+ command_results = [{"command": cmd, "status": status} for cmd in commands]
67
+ _save_run(self.project_root, task, "local", env, command_results, status)
68
+ return SandboxResult(sandbox="local", status=status, environment=env, commands=command_results)
69
+
70
+
71
+ class DockerSandbox(VerificationSandbox):
72
+ def __init__(self, project_root: Path, config: DevCouncilConfig):
73
+ self.project_root = project_root
74
+ self.config = config
75
+
76
+ def run(self, task: Task, commands: list[str], requirements: list) -> SandboxResult:
77
+ if not shutil_which("docker"):
78
+ result = SandboxResult(
79
+ sandbox="docker",
80
+ status="unsupported",
81
+ environment={},
82
+ commands=[{"reason": "docker not available"}],
83
+ )
84
+ _save_run(self.project_root, task, "docker", result.environment, result.commands, result.status)
85
+ return result
86
+ image = self.config.verification.sandbox.docker_image
87
+ setup = self.config.verification.sandbox.docker_setup_commands
88
+ results: list[dict] = []
89
+ for setup_cmd in setup:
90
+ proc = subprocess.run(
91
+ ["docker", "run", "--rm", "-v", f"{self.project_root}:/work", "-w", "/work", image, "sh", "-c", setup_cmd],
92
+ capture_output=True,
93
+ text=True,
94
+ )
95
+ results.append({"command": setup_cmd, "exit_code": proc.returncode})
96
+ if proc.returncode != 0:
97
+ result = SandboxResult(sandbox="docker", status="failed", environment={"image": image}, commands=results)
98
+ _save_run(self.project_root, task, "docker", result.environment, result.commands, result.status)
99
+ return result
100
+ for cmd in commands:
101
+ proc = subprocess.run(
102
+ ["docker", "run", "--rm", "-v", f"{self.project_root}:/work", "-w", "/work", image, "sh", "-c", cmd],
103
+ capture_output=True,
104
+ text=True,
105
+ )
106
+ results.append({"command": cmd, "exit_code": proc.returncode})
107
+ if proc.returncode != 0:
108
+ result = SandboxResult(sandbox="docker", status="failed", environment={"image": image}, commands=results)
109
+ _save_run(self.project_root, task, "docker", result.environment, result.commands, result.status)
110
+ return result
111
+ result = SandboxResult(sandbox="docker", status="passed", environment={"image": image}, commands=results)
112
+ _save_run(self.project_root, task, "docker", result.environment, result.commands, result.status)
113
+ return result
114
+
115
+
116
+ class NixSandbox(VerificationSandbox):
117
+ def __init__(self, project_root: Path, config: DevCouncilConfig):
118
+ self.project_root = project_root
119
+ self.config = config
120
+
121
+ def run(self, task: Task, commands: list[str], requirements: list) -> SandboxResult:
122
+ if not (self.project_root / "flake.nix").exists() or not shutil_which("nix"):
123
+ result = SandboxResult(
124
+ sandbox="nix",
125
+ status="unsupported",
126
+ environment={},
127
+ commands=[{"reason": "nix or flake.nix unavailable"}],
128
+ )
129
+ _save_run(self.project_root, task, "nix", result.environment, result.commands, result.status)
130
+ return result
131
+ attr = self.config.verification.sandbox.nix_flake_attr or "devShells.default"
132
+ results: list[dict] = []
133
+ for cmd in commands:
134
+ proc = subprocess.run(
135
+ ["nix", "develop", f".#{attr}", "-c", "sh", "-c", cmd],
136
+ cwd=self.project_root,
137
+ capture_output=True,
138
+ text=True,
139
+ )
140
+ results.append({"command": cmd, "exit_code": proc.returncode})
141
+ if proc.returncode != 0:
142
+ result = SandboxResult(sandbox="nix", status="failed", environment={"attr": attr}, commands=results)
143
+ _save_run(self.project_root, task, "nix", result.environment, result.commands, result.status)
144
+ return result
145
+ result = SandboxResult(sandbox="nix", status="passed", environment={"attr": attr}, commands=results)
146
+ _save_run(self.project_root, task, "nix", result.environment, result.commands, result.status)
147
+ return result
148
+
149
+
150
+ def shutil_which(name: str) -> str | None:
151
+ import shutil
152
+
153
+ return shutil.which(name)
154
+
155
+
156
+ def _environment_metadata(project_root: Path) -> dict[str, str]:
157
+ env = {
158
+ "python": sys.version.split()[0],
159
+ "platform": platform.platform(),
160
+ }
161
+ try:
162
+ uv = subprocess.check_output(["uv", "--version"], text=True).strip()
163
+ env["uv"] = uv
164
+ except (FileNotFoundError, subprocess.CalledProcessError, OSError):
165
+ pass
166
+ lock = project_root / "uv.lock"
167
+ if lock.exists():
168
+ env["uv_lock_hash"] = hashlib.sha256(lock.read_bytes()).hexdigest()[:16]
169
+ return env
170
+
171
+
172
+ def get_sandbox(name: str, project_root: Path) -> VerificationSandbox:
173
+ config = load_config(project_root)
174
+ if name == "docker":
175
+ return DockerSandbox(project_root, config)
176
+ if name == "nix":
177
+ return NixSandbox(project_root, config)
178
+ return LocalSandbox(project_root)
@@ -0,0 +1,91 @@
1
+ """Evidence suggestion from changed files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Literal
7
+
8
+ from pydantic import BaseModel
9
+
10
+ from devcouncil.domain.task import Task
11
+
12
+
13
+ class EvidenceSuggestion(BaseModel):
14
+ command: str
15
+ confidence: Literal["high", "medium", "low"]
16
+ reason: str
17
+ paths: list[str] = []
18
+
19
+
20
+ class TestResolver:
21
+ __test__ = False
22
+
23
+ def __init__(self, project_root: Path):
24
+ self.project_root = project_root.resolve()
25
+
26
+ def suggest_for_task(
27
+ self,
28
+ task: Task,
29
+ changed_files: list[str] | None = None,
30
+ ) -> list[EvidenceSuggestion]:
31
+ files = changed_files or [pf.path for pf in task.planned_files]
32
+ suggestions: list[EvidenceSuggestion] = []
33
+ seen: set[str] = set()
34
+
35
+ for path in files:
36
+ normalized = path.replace("\\", "/")
37
+ for candidate in self._candidates_for_path(normalized):
38
+ if candidate in seen:
39
+ continue
40
+ seen.add(candidate)
41
+ confidence, reason = self._confidence_for(normalized, candidate)
42
+ suggestions.append(
43
+ EvidenceSuggestion(
44
+ command=candidate,
45
+ confidence=confidence,
46
+ reason=reason,
47
+ paths=[normalized],
48
+ )
49
+ )
50
+ return suggestions
51
+
52
+ def _candidates_for_path(self, path: str) -> list[str]:
53
+ candidates: list[str] = []
54
+ if path.startswith("src/") and path.endswith(".py"):
55
+ module = path.removeprefix("src/").removesuffix(".py")
56
+ parts = module.split("/")
57
+ if len(parts) >= 2:
58
+ nested = self.project_root / "tests" / parts[0] / f"test_{parts[-1]}.py"
59
+ if nested.exists():
60
+ candidates.append(f"pytest {nested.relative_to(self.project_root).as_posix()}")
61
+ flat = self.project_root / "tests" / f"test_{parts[-1]}.py"
62
+ if flat.exists():
63
+ candidates.append(f"pytest {flat.relative_to(self.project_root).as_posix()}")
64
+ if "cli/commands/" in path:
65
+ unit = self.project_root / "tests/unit/test_cli_commands.py"
66
+ if unit.exists():
67
+ candidates.append(f"pytest {unit.relative_to(self.project_root).as_posix()}")
68
+ if path.endswith("policy_engine.py"):
69
+ unit = self.project_root / "tests/unit/test_task_policy_engine.py"
70
+ if unit.exists():
71
+ candidates.append(f"pytest {unit.relative_to(self.project_root).as_posix()}")
72
+ if path == "src/auth.py":
73
+ auth_test = self.project_root / "tests/test_auth.py"
74
+ if auth_test.exists():
75
+ candidates.append("pytest tests/test_auth.py")
76
+ if not candidates:
77
+ candidates.append("pytest tests/unit")
78
+ return candidates
79
+
80
+ def _confidence_for(self, path: str, command: str) -> tuple[Literal["high", "medium", "low"], str]:
81
+ if "test_auth.py" in command and path.endswith("auth.py"):
82
+ return "high", "Direct auth module mapping"
83
+ if path.endswith("policy_engine.py") and "test_task_policy_engine.py" in command:
84
+ return "high", "Policy engine unit test exists"
85
+ if "cli/commands" in path and "test_cli_commands.py" in command:
86
+ return "high", "CLI command module maps to cli command tests"
87
+ if command.startswith("pytest tests/") and command != "pytest tests/unit":
88
+ return "high", "Nested module test path exists"
89
+ if "test_" in command:
90
+ return "medium", "Related test file match"
91
+ return "low", "Package-level fallback"