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.
- package/README.md +190 -6
- package/package.json +9 -2
- package/pyproject.toml +34 -2
- package/src/devcouncil/app/config.py +167 -5
- package/src/devcouncil/artifacts/graph.py +23 -3
- package/src/devcouncil/assets/__init__.py +1 -0
- package/src/devcouncil/assets/devcouncil-logo.svg +60 -0
- package/src/devcouncil/assets/devcouncil_logo_premium.png +0 -0
- package/src/devcouncil/cli/commands/agents.py +292 -0
- package/src/devcouncil/cli/commands/artifacts.py +6 -3
- package/src/devcouncil/cli/commands/check.py +209 -0
- package/src/devcouncil/cli/commands/config.py +43 -4
- package/src/devcouncil/cli/commands/cost.py +57 -0
- package/src/devcouncil/cli/commands/dashboard.py +6 -1
- package/src/devcouncil/cli/commands/doctor.py +221 -21
- package/src/devcouncil/cli/commands/evidence.py +48 -0
- package/src/devcouncil/cli/commands/go.py +452 -33
- package/src/devcouncil/cli/commands/handoff.py +69 -0
- package/src/devcouncil/cli/commands/hook.py +124 -15
- package/src/devcouncil/cli/commands/init.py +154 -18
- package/src/devcouncil/cli/commands/integrate.py +894 -105
- package/src/devcouncil/cli/commands/map.py +80 -10
- package/src/devcouncil/cli/commands/plan.py +212 -51
- package/src/devcouncil/cli/commands/prompt.py +18 -7
- package/src/devcouncil/cli/commands/repair.py +40 -23
- package/src/devcouncil/cli/commands/report.py +8 -0
- package/src/devcouncil/cli/commands/reset_demo_state.py +4 -2
- package/src/devcouncil/cli/commands/rollback.py +27 -28
- package/src/devcouncil/cli/commands/run.py +69 -49
- package/src/devcouncil/cli/commands/runs.py +223 -0
- package/src/devcouncil/cli/commands/scaffold.py +32 -0
- package/src/devcouncil/cli/commands/semantic.py +47 -0
- package/src/devcouncil/cli/commands/setup.py +145 -6
- package/src/devcouncil/cli/commands/shell.py +73 -0
- package/src/devcouncil/cli/commands/skills.py +88 -0
- package/src/devcouncil/cli/commands/status.py +25 -1
- package/src/devcouncil/cli/commands/trace.py +47 -3
- package/src/devcouncil/cli/commands/verify.py +138 -3
- package/src/devcouncil/cli/commands/watch.py +9 -9
- package/src/devcouncil/cli/commands/watch_fs.py +40 -0
- package/src/devcouncil/cli/main.py +56 -7
- package/src/devcouncil/domain/evidence.py +22 -2
- package/src/devcouncil/domain/gap.py +27 -1
- package/src/devcouncil/domain/task.py +31 -2
- package/src/devcouncil/execution/checkpoints.py +246 -0
- package/src/devcouncil/execution/context_builder.py +1 -1
- package/src/devcouncil/execution/fs_watcher.py +180 -0
- package/src/devcouncil/execution/handoff.py +102 -0
- package/src/devcouncil/execution/hook_policy.py +162 -74
- package/src/devcouncil/execution/patch.py +59 -10
- package/src/devcouncil/execution/permissions.py +17 -24
- package/src/devcouncil/execution/policy_engine.py +343 -0
- package/src/devcouncil/execution/prompt_builder.py +633 -21
- package/src/devcouncil/execution/shell_session.py +225 -0
- package/src/devcouncil/execution/task_runner.py +6 -2
- package/src/devcouncil/executors/agent_registry.py +575 -0
- package/src/devcouncil/executors/coding_cli.py +663 -39
- package/src/devcouncil/executors/native/agent.py +121 -20
- package/src/devcouncil/gating/checks/clean_git.py +3 -1
- package/src/devcouncil/gating/checks/secret_scan_check.py +40 -21
- package/src/devcouncil/gating/policy.py +158 -10
- package/src/devcouncil/hardware.py +184 -0
- package/src/devcouncil/indexing/ast_matcher.py +1 -1
- package/src/devcouncil/indexing/lsp.py +45 -4
- package/src/devcouncil/indexing/repo_mapper.py +1256 -9
- package/src/devcouncil/indexing/semantic_index.py +205 -0
- package/src/devcouncil/integrations/actions.py +146 -0
- package/src/devcouncil/integrations/check.py +423 -0
- package/src/devcouncil/integrations/github_intent.py +142 -0
- package/src/devcouncil/integrations/gitnexus.py +35 -0
- package/src/devcouncil/integrations/mcp/server.py +1552 -29
- package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
- package/src/devcouncil/live/cards.py +161 -19
- package/src/devcouncil/live/signals.py +2 -2
- package/src/devcouncil/live/transcripts.py +9 -6
- package/src/devcouncil/llm/cache.py +10 -6
- package/src/devcouncil/llm/model_defaults.yaml +44 -0
- package/src/devcouncil/llm/provider.py +515 -34
- package/src/devcouncil/llm/router.py +231 -46
- package/src/devcouncil/optimization/__init__.py +1 -0
- package/src/devcouncil/optimization/gepa_agent.py +318 -0
- package/src/devcouncil/planning/correction_manifest.py +303 -0
- package/src/devcouncil/planning/critique_service.py +7 -2
- package/src/devcouncil/planning/plan_service.py +17 -3
- package/src/devcouncil/planning/prompt_enhancer_service.py +82 -1
- package/src/devcouncil/planning/spec_service.py +27 -1
- package/src/devcouncil/repo/ci_scaffold.py +157 -0
- package/src/devcouncil/repo/gitignore.py +123 -0
- package/src/devcouncil/repo/sca.py +374 -0
- package/src/devcouncil/reporting/json_report.py +11 -1
- package/src/devcouncil/reporting/markdown_report.py +15 -0
- package/src/devcouncil/skills/__init__.py +19 -0
- package/src/devcouncil/skills/library/README.md +46 -0
- package/src/devcouncil/skills/library/ai-training.md +50 -0
- package/src/devcouncil/skills/library/android.md +50 -0
- package/src/devcouncil/skills/library/backend.md +52 -0
- package/src/devcouncil/skills/library/core-engineering.md +95 -0
- package/src/devcouncil/skills/library/data-engineering.md +47 -0
- package/src/devcouncil/skills/library/desktop.md +46 -0
- package/src/devcouncil/skills/library/devops.md +48 -0
- package/src/devcouncil/skills/library/game-dev.md +46 -0
- package/src/devcouncil/skills/library/ios.md +48 -0
- package/src/devcouncil/skills/library/mobile-cross-platform.md +46 -0
- package/src/devcouncil/skills/library/security.md +48 -0
- package/src/devcouncil/skills/library/systems.md +48 -0
- package/src/devcouncil/skills/library/web.md +47 -0
- package/src/devcouncil/skills/library/windows.md +47 -0
- package/src/devcouncil/skills/registry.py +330 -0
- package/src/devcouncil/storage/db.py +83 -2
- package/src/devcouncil/storage/models.py +121 -0
- package/src/devcouncil/storage/native.py +557 -0
- package/src/devcouncil/storage/repositories.py +137 -75
- package/src/devcouncil/telemetry/cost.py +123 -17
- package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
- package/src/devcouncil/telemetry/pricing.py +28 -0
- package/src/devcouncil/telemetry/traces.py +62 -7
- package/src/devcouncil/telemetry/tracker.py +12 -9
- package/src/devcouncil/ui/dashboard.py +324 -23
- package/src/devcouncil/utils/redaction.py +9 -3
- package/src/devcouncil/utils/subprocess_env.py +69 -0
- package/src/devcouncil/verification/acceptance_compiler.py +125 -0
- package/src/devcouncil/verification/ad_hoc_check.py +129 -0
- package/src/devcouncil/verification/diff_coverage.py +353 -0
- package/src/devcouncil/verification/next_actions.py +189 -0
- package/src/devcouncil/verification/sandbox.py +178 -0
- package/src/devcouncil/verification/test_resolver.py +91 -0
- package/src/devcouncil/verification/verifier.py +1065 -47
- package/uv.lock +205 -64
- 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"
|