devcouncil 0.1.0 → 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/LICENSE +201 -201
- package/README.md +197 -494
- package/package.json +9 -2
- package/pyproject.toml +62 -27
- package/src/devcouncil/__main__.py +4 -4
- package/src/devcouncil/app/__init__.py +28 -28
- package/src/devcouncil/app/config.py +297 -108
- package/src/devcouncil/app/errors.py +23 -23
- package/src/devcouncil/app/events.py +44 -44
- package/src/devcouncil/app/orchestrator.py +67 -67
- package/src/devcouncil/app/project_status.py +29 -0
- package/src/devcouncil/app/run_context.py +39 -39
- package/src/devcouncil/app/state_machine.py +108 -108
- package/src/devcouncil/artifacts/__init__.py +1 -1
- package/src/devcouncil/artifacts/coverage.py +96 -96
- package/src/devcouncil/artifacts/graph.py +163 -143
- package/src/devcouncil/artifacts/migrations.py +20 -20
- package/src/devcouncil/artifacts/schemas.py +23 -23
- package/src/devcouncil/artifacts/serializer.py +21 -21
- package/src/devcouncil/artifacts/validators.py +27 -27
- 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 +54 -48
- package/src/devcouncil/cli/commands/ast.py +22 -0
- package/src/devcouncil/cli/commands/baseline.py +35 -32
- package/src/devcouncil/cli/commands/check.py +209 -0
- package/src/devcouncil/cli/commands/config.py +115 -54
- package/src/devcouncil/cli/commands/cost.py +57 -0
- package/src/devcouncil/cli/commands/dashboard.py +31 -0
- package/src/devcouncil/cli/commands/doctor.py +291 -47
- package/src/devcouncil/cli/commands/evidence.py +48 -0
- package/src/devcouncil/cli/commands/go.py +656 -0
- package/src/devcouncil/cli/commands/handoff.py +69 -0
- package/src/devcouncil/cli/commands/hook.py +209 -33
- package/src/devcouncil/cli/commands/init.py +204 -57
- package/src/devcouncil/cli/commands/integrate.py +1171 -76
- package/src/devcouncil/cli/commands/lsp.py +20 -0
- package/src/devcouncil/cli/commands/map.py +96 -22
- package/src/devcouncil/cli/commands/plan.py +422 -210
- package/src/devcouncil/cli/commands/prompt.py +48 -34
- package/src/devcouncil/cli/commands/repair.py +89 -69
- package/src/devcouncil/cli/commands/report.py +120 -54
- package/src/devcouncil/cli/commands/reset_demo_state.py +33 -28
- package/src/devcouncil/cli/commands/rollback.py +55 -54
- package/src/devcouncil/cli/commands/run.py +285 -220
- 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 +300 -20
- package/src/devcouncil/cli/commands/shell.py +73 -0
- package/src/devcouncil/cli/commands/show.py +76 -57
- package/src/devcouncil/cli/commands/skills.py +88 -0
- package/src/devcouncil/cli/commands/status.py +141 -105
- package/src/devcouncil/cli/commands/tasks.py +55 -41
- package/src/devcouncil/cli/commands/trace.py +49 -4
- package/src/devcouncil/cli/commands/verify.py +293 -128
- package/src/devcouncil/cli/commands/version.py +20 -20
- package/src/devcouncil/cli/commands/watch.py +574 -0
- package/src/devcouncil/cli/commands/watch_fs.py +40 -0
- package/src/devcouncil/cli/main.py +92 -25
- package/src/devcouncil/council/prompts/arbiter.md +19 -19
- package/src/devcouncil/council/prompts/critic_a.md +10 -10
- package/src/devcouncil/council/prompts/critic_b.md +10 -10
- package/src/devcouncil/council/prompts/implementation_reviewer.md +16 -16
- package/src/devcouncil/council/prompts/planner_a.md +16 -16
- package/src/devcouncil/council/prompts/planner_b.md +16 -16
- package/src/devcouncil/council/prompts/rebuttal.md +10 -10
- package/src/devcouncil/council/prompts/spec_writer.md +12 -12
- package/src/devcouncil/domain/assumption.py +17 -17
- package/src/devcouncil/domain/critique.py +32 -32
- package/src/devcouncil/domain/evidence.py +47 -27
- package/src/devcouncil/domain/gap.py +52 -26
- package/src/devcouncil/domain/requirement.py +22 -22
- package/src/devcouncil/domain/task.py +55 -26
- package/src/devcouncil/execution/__init__.py +1 -1
- package/src/devcouncil/execution/checkpoints.py +246 -0
- package/src/devcouncil/execution/context_builder.py +54 -54
- package/src/devcouncil/execution/executor.py +15 -15
- package/src/devcouncil/execution/fs_watcher.py +180 -0
- package/src/devcouncil/execution/handoff.py +102 -0
- package/src/devcouncil/execution/hook_policy.py +186 -77
- package/src/devcouncil/execution/patch.py +77 -28
- package/src/devcouncil/execution/permissions.py +52 -59
- package/src/devcouncil/execution/policy_engine.py +343 -0
- package/src/devcouncil/execution/prompt_builder.py +650 -38
- package/src/devcouncil/execution/shell_session.py +225 -0
- package/src/devcouncil/execution/task_runner.py +68 -64
- package/src/devcouncil/executors/__init__.py +1 -1
- package/src/devcouncil/executors/agent_registry.py +575 -0
- package/src/devcouncil/executors/coding_cli.py +736 -0
- package/src/devcouncil/executors/mini_swe.py +63 -63
- package/src/devcouncil/executors/native/agent.py +186 -85
- package/src/devcouncil/executors/openhands.py +56 -56
- package/src/devcouncil/gating/__init__.py +1 -1
- package/src/devcouncil/gating/checks/clean_git.py +52 -45
- package/src/devcouncil/gating/checks/planned_files_check.py +32 -32
- package/src/devcouncil/gating/checks/requirement_coverage.py +26 -26
- package/src/devcouncil/gating/checks/secret_scan_check.py +53 -34
- package/src/devcouncil/gating/policy.py +315 -167
- package/src/devcouncil/hardware.py +184 -0
- package/src/devcouncil/indexing/__init__.py +1 -1
- package/src/devcouncil/indexing/ast_matcher.py +168 -0
- package/src/devcouncil/indexing/graph_index.py +48 -48
- package/src/devcouncil/indexing/lsp.py +161 -0
- package/src/devcouncil/indexing/repo_mapper.py +1455 -204
- 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.py +35 -35
- package/src/devcouncil/integrations/github_intent.py +142 -0
- package/src/devcouncil/integrations/gitnexus.py +62 -27
- package/src/devcouncil/integrations/graphify.py +34 -34
- package/src/devcouncil/integrations/mcp/server.py +2072 -96
- package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
- package/src/devcouncil/integrations/pr_comments.py +62 -0
- package/src/devcouncil/live/__init__.py +2 -0
- package/src/devcouncil/live/cards.py +349 -0
- package/src/devcouncil/live/models.py +63 -0
- package/src/devcouncil/live/repair_prompt.py +83 -0
- package/src/devcouncil/live/reviewer.py +70 -0
- package/src/devcouncil/live/signals.py +135 -0
- package/src/devcouncil/live/summary.py +34 -0
- package/src/devcouncil/live/tasks.py +18 -0
- package/src/devcouncil/live/transcripts.py +141 -0
- package/src/devcouncil/llm/__init__.py +1 -1
- package/src/devcouncil/llm/cache.py +42 -38
- package/src/devcouncil/llm/model_defaults.yaml +44 -0
- package/src/devcouncil/llm/provider.py +627 -125
- package/src/devcouncil/llm/router.py +303 -118
- package/src/devcouncil/optimization/__init__.py +1 -0
- package/src/devcouncil/optimization/gepa_agent.py +318 -0
- package/src/devcouncil/planning/__init__.py +1 -1
- package/src/devcouncil/planning/arbiter_service.py +57 -57
- package/src/devcouncil/planning/correction_manifest.py +303 -0
- package/src/devcouncil/planning/critique_service.py +71 -66
- package/src/devcouncil/planning/plan_service.py +60 -46
- package/src/devcouncil/planning/prompt_enhancer_service.py +167 -0
- package/src/devcouncil/planning/repair_service.py +39 -39
- package/src/devcouncil/planning/spec_service.py +70 -44
- 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/github_check.py +32 -32
- package/src/devcouncil/reporting/json_report.py +30 -17
- package/src/devcouncil/reporting/markdown_report.py +83 -46
- package/src/devcouncil/reporting/report_builder.py +14 -14
- 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 +147 -66
- package/src/devcouncil/storage/models.py +204 -83
- package/src/devcouncil/storage/native.py +557 -0
- package/src/devcouncil/storage/repositories.py +388 -249
- package/src/devcouncil/telemetry/cost.py +140 -34
- 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 +52 -49
- package/src/devcouncil/ui/__init__.py +1 -0
- package/src/devcouncil/ui/dashboard.py +423 -0
- package/src/devcouncil/utils/__init__.py +1 -1
- package/src/devcouncil/utils/redaction.py +147 -141
- package/src/devcouncil/utils/subprocess_env.py +69 -0
- package/src/devcouncil/verification/__init__.py +1 -1
- 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/implementation_reviewer.py +55 -55
- 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 +1342 -307
- package/uv.lock +205 -64
- package/src/devcouncil/indexing/symbol_index.py +0 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
const projectRoot = process.env.DEVCOUNCIL_PROJECT_ROOT || process.cwd();
|
|
4
|
+
|
|
5
|
+
function runHook(event, payload) {
|
|
6
|
+
const args = ["hook", event, "--client", "opencode", "--project-root", projectRoot];
|
|
7
|
+
const result = spawnSync("devcouncil", args, {
|
|
8
|
+
input: JSON.stringify(payload ?? {}),
|
|
9
|
+
encoding: "utf-8",
|
|
10
|
+
env: { ...process.env, DEVCOUNCIL_PROJECT_ROOT: projectRoot },
|
|
11
|
+
});
|
|
12
|
+
if (result.status === 2) {
|
|
13
|
+
throw new Error(result.stderr || result.stdout || "DevCouncil blocked the tool call.");
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const DevCouncilOpenCodeHook = async () => ({
|
|
18
|
+
"tool.execute.before": async (input, output) => {
|
|
19
|
+
runHook("pre-tool-use", { tool: input.tool, arguments: output.args });
|
|
20
|
+
},
|
|
21
|
+
"tool.execute.after": async (input, output) => {
|
|
22
|
+
runHook("post-tool-use", { tool: input.tool, arguments: output.args });
|
|
23
|
+
},
|
|
24
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
from urllib.parse import quote
|
|
5
|
+
|
|
6
|
+
from devcouncil.artifacts.graph import ArtifactGraph
|
|
7
|
+
from devcouncil.reporting.report_builder import ReportBuilder
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PullRequestCommentError(RuntimeError):
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_pr_comment_body(graph: ArtifactGraph, live_review: dict | None = None) -> str:
|
|
15
|
+
report = ReportBuilder.build_markdown(graph, live_review=live_review)
|
|
16
|
+
return "\n".join([
|
|
17
|
+
"## DevCouncil Verification",
|
|
18
|
+
"",
|
|
19
|
+
report,
|
|
20
|
+
])
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class GitHubPRCommenter:
|
|
24
|
+
def __init__(self, token: str, repository: str, pull_number: int, *, base_url: str = "https://api.github.com"):
|
|
25
|
+
self.token = token
|
|
26
|
+
self.repository = repository
|
|
27
|
+
self.pull_number = pull_number
|
|
28
|
+
self.base_url = base_url.rstrip("/")
|
|
29
|
+
|
|
30
|
+
async def post_comment(self, body: str) -> dict:
|
|
31
|
+
url = f"{self.base_url}/repos/{self.repository}/issues/{self.pull_number}/comments"
|
|
32
|
+
headers = {
|
|
33
|
+
"Authorization": f"Bearer {self.token}",
|
|
34
|
+
"Accept": "application/vnd.github+json",
|
|
35
|
+
"Content-Type": "application/json",
|
|
36
|
+
}
|
|
37
|
+
async with httpx.AsyncClient() as client:
|
|
38
|
+
response = await client.post(url, headers=headers, json={"body": body})
|
|
39
|
+
if response.status_code >= 400:
|
|
40
|
+
raise PullRequestCommentError(f"GitHub comment failed with HTTP {response.status_code}: {response.text}")
|
|
41
|
+
return response.json() if response.content else {}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class GitLabMRCommenter:
|
|
45
|
+
def __init__(self, token: str, project_id: str, merge_request_iid: int, *, base_url: str = "https://gitlab.com/api/v4"):
|
|
46
|
+
self.token = token
|
|
47
|
+
self.project_id = project_id
|
|
48
|
+
self.merge_request_iid = merge_request_iid
|
|
49
|
+
self.base_url = base_url.rstrip("/")
|
|
50
|
+
|
|
51
|
+
async def post_comment(self, body: str) -> dict:
|
|
52
|
+
project = quote(self.project_id, safe="")
|
|
53
|
+
url = f"{self.base_url}/projects/{project}/merge_requests/{self.merge_request_iid}/notes"
|
|
54
|
+
headers = {
|
|
55
|
+
"PRIVATE-TOKEN": self.token,
|
|
56
|
+
"Content-Type": "application/json",
|
|
57
|
+
}
|
|
58
|
+
async with httpx.AsyncClient() as client:
|
|
59
|
+
response = await client.post(url, headers=headers, json={"body": body})
|
|
60
|
+
if response.status_code >= 400:
|
|
61
|
+
raise PullRequestCommentError(f"GitLab comment failed with HTTP {response.status_code}: {response.text}")
|
|
62
|
+
return response.json() if response.content else {}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from devcouncil.live.models import AgentTurn, CardStatus, CritiqueCard, Verdict
|
|
10
|
+
|
|
11
|
+
RISK_TERMS = (
|
|
12
|
+
"skip tests",
|
|
13
|
+
"no tests",
|
|
14
|
+
"untested",
|
|
15
|
+
"ignore failing",
|
|
16
|
+
"disable",
|
|
17
|
+
"workaround",
|
|
18
|
+
"quick hack",
|
|
19
|
+
"hardcode",
|
|
20
|
+
"force push",
|
|
21
|
+
"--no-verify",
|
|
22
|
+
"reset --hard",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
EVIDENCE_TERMS = (
|
|
26
|
+
"test",
|
|
27
|
+
"pytest",
|
|
28
|
+
"vitest",
|
|
29
|
+
"npm test",
|
|
30
|
+
"go test",
|
|
31
|
+
"cargo test",
|
|
32
|
+
"verification",
|
|
33
|
+
"verified",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Word-boundary matchers so "done" matches "I'm done" but not "abandoned"/"undone".
|
|
37
|
+
_COMPLETION_RE = re.compile(
|
|
38
|
+
r"\b(done|complete|completed|finished|implemented|fixed|ready|all set|"
|
|
39
|
+
r"ship it|good to go|works now|it works)\b"
|
|
40
|
+
)
|
|
41
|
+
# An agent asserting its verification actually passed (the claim we cross-check).
|
|
42
|
+
_PASS_CLAIM_RE = re.compile(
|
|
43
|
+
r"(tests?\s+(?:are\s+|now\s+)?pass(?:ing|ed|es)?"
|
|
44
|
+
r"|all\s+(?:tests?|checks?|cases?)\s+pass"
|
|
45
|
+
r"|passing\s+tests?"
|
|
46
|
+
r"|\bverified\b|verification\s+(?:pass|succeed)"
|
|
47
|
+
r"|tests?\s+green|green\s+tests?"
|
|
48
|
+
r"|(?:ran|run)\s+[^.\n]{0,40}?\bpass)"
|
|
49
|
+
)
|
|
50
|
+
# Negations that flip a nearby claim ("not done", "tests do not pass", "still failing").
|
|
51
|
+
_NEGATION_RE = re.compile(
|
|
52
|
+
r"\b(not|isn'?t|aren'?t|won'?t|can'?t|cannot|haven'?t|hasn'?t|don'?t|"
|
|
53
|
+
r"doesn'?t|didn'?t|no longer|never|yet to|still need|still failing|"
|
|
54
|
+
r"not yet|unable|fail(?:s|ing|ed)?)\b"
|
|
55
|
+
)
|
|
56
|
+
_NEGATION_WINDOW = 30
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _claim_present(pattern: re.Pattern[str], lower: str) -> bool:
|
|
60
|
+
"""True if `pattern` matches and is not negated by a word shortly before it."""
|
|
61
|
+
for match in pattern.finditer(lower):
|
|
62
|
+
prefix = lower[max(0, match.start() - _NEGATION_WINDOW):match.start()]
|
|
63
|
+
if _NEGATION_RE.search(prefix):
|
|
64
|
+
continue
|
|
65
|
+
return True
|
|
66
|
+
return False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class _TaskGrounding:
|
|
71
|
+
"""A snapshot of a task's real verification state from the artifact graph."""
|
|
72
|
+
|
|
73
|
+
task_id: str
|
|
74
|
+
status: str
|
|
75
|
+
blocking_gaps: int
|
|
76
|
+
failing_commands: int
|
|
77
|
+
acs_total: int
|
|
78
|
+
acs_passing: int
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def acs_unproven(self) -> int:
|
|
82
|
+
return max(0, self.acs_total - self.acs_passing)
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def is_satisfied(self) -> bool:
|
|
86
|
+
return (
|
|
87
|
+
self.status in ("verified", "done")
|
|
88
|
+
and self.blocking_gaps == 0
|
|
89
|
+
and (self.acs_total == 0 or self.acs_passing >= self.acs_total)
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _load_task_grounding(project_root: Path, task_id: str | None) -> _TaskGrounding | None:
|
|
94
|
+
"""Load the scoped task's real verification state so claims can be checked
|
|
95
|
+
against evidence instead of trusted on the agent's word. Best-effort: any
|
|
96
|
+
failure (no DB, unknown task) returns None and the caller falls back to the
|
|
97
|
+
pure-heuristic review."""
|
|
98
|
+
if not task_id:
|
|
99
|
+
return None
|
|
100
|
+
try:
|
|
101
|
+
from devcouncil.storage.db import get_db
|
|
102
|
+
from devcouncil.storage.repositories import ArtifactGraphRepository
|
|
103
|
+
|
|
104
|
+
db = get_db(project_root)
|
|
105
|
+
if not db:
|
|
106
|
+
return None
|
|
107
|
+
with db.get_session() as session:
|
|
108
|
+
graph = ArtifactGraphRepository(session).load_graph()
|
|
109
|
+
except Exception:
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
task = graph.tasks.get(task_id)
|
|
113
|
+
if task is None:
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
blocking = [g for g in graph.gaps.values() if g.task_id == task_id and g.blocking]
|
|
117
|
+
failing = [g for g in blocking if g.gap_type == "test_failed"]
|
|
118
|
+
ac_ids = set(task.acceptance_criterion_ids)
|
|
119
|
+
passing_ac = {
|
|
120
|
+
ev.acceptance_criterion_id
|
|
121
|
+
for ev in graph.test_evidence
|
|
122
|
+
if ev.acceptance_criterion_id in ac_ids and getattr(ev, "status", "") == "passed"
|
|
123
|
+
}
|
|
124
|
+
return _TaskGrounding(
|
|
125
|
+
task_id=task_id,
|
|
126
|
+
status=task.status,
|
|
127
|
+
blocking_gaps=len(blocking),
|
|
128
|
+
failing_commands=len(failing),
|
|
129
|
+
acs_total=len(ac_ids),
|
|
130
|
+
acs_passing=len(passing_ac),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def review_turn(
|
|
135
|
+
turn: AgentTurn,
|
|
136
|
+
project_root: Path,
|
|
137
|
+
client: str | None = None,
|
|
138
|
+
task_id: str | None = None,
|
|
139
|
+
) -> CritiqueCard:
|
|
140
|
+
"""Generate a deterministic critique card for an agent response.
|
|
141
|
+
|
|
142
|
+
When ``task_id`` resolves to a known task, completion/verification claims are
|
|
143
|
+
checked against the task's real artifact state (status, blocking gaps, passing
|
|
144
|
+
acceptance-criterion evidence) instead of being trusted by keyword alone. With
|
|
145
|
+
no task state available it falls back to the lightweight keyword heuristic.
|
|
146
|
+
"""
|
|
147
|
+
content = turn.content.strip()
|
|
148
|
+
lower = content.lower()
|
|
149
|
+
concerns: list[str] = []
|
|
150
|
+
alternatives: list[str] = []
|
|
151
|
+
evidence_requests: list[str] = []
|
|
152
|
+
verdict: Verdict = "Approved"
|
|
153
|
+
|
|
154
|
+
grounding = _load_task_grounding(project_root, task_id)
|
|
155
|
+
|
|
156
|
+
risky_terms = [term for term in RISK_TERMS if term in lower]
|
|
157
|
+
if risky_terms:
|
|
158
|
+
concerns.append(f"Response contains risky implementation language: {', '.join(risky_terms[:4])}.")
|
|
159
|
+
alternatives.append("Replace risky shortcuts with a scoped implementation and explicit rollback or verification path.")
|
|
160
|
+
|
|
161
|
+
claims_completion = _claim_present(_COMPLETION_RE, lower)
|
|
162
|
+
claims_passing = _claim_present(_PASS_CLAIM_RE, lower)
|
|
163
|
+
|
|
164
|
+
if grounding is not None:
|
|
165
|
+
# Evidence-grounded review: cross-check the agent's claims against reality.
|
|
166
|
+
if claims_passing and grounding.failing_commands > 0:
|
|
167
|
+
concerns.append(
|
|
168
|
+
f"Agent claims verification passes, but DevCouncil recorded "
|
|
169
|
+
f"{grounding.failing_commands} failing verification command(s) for "
|
|
170
|
+
f"task {grounding.task_id}."
|
|
171
|
+
)
|
|
172
|
+
evidence_requests.append(
|
|
173
|
+
f"Re-run 'dev verify {grounding.task_id}' and fix the failing command(s) "
|
|
174
|
+
"before claiming success."
|
|
175
|
+
)
|
|
176
|
+
verdict = "Critical Issues"
|
|
177
|
+
elif (claims_completion or claims_passing) and not grounding.is_satisfied:
|
|
178
|
+
details = [f"task {grounding.task_id} is '{grounding.status}'"]
|
|
179
|
+
if grounding.blocking_gaps:
|
|
180
|
+
details.append(f"{grounding.blocking_gaps} blocking gap(s)")
|
|
181
|
+
if grounding.acs_unproven:
|
|
182
|
+
details.append(
|
|
183
|
+
f"{grounding.acs_unproven}/{grounding.acs_total} acceptance "
|
|
184
|
+
"criteria still lack passing evidence"
|
|
185
|
+
)
|
|
186
|
+
concerns.append(
|
|
187
|
+
"Completion claim is not yet backed by DevCouncil evidence: "
|
|
188
|
+
+ ", ".join(details) + "."
|
|
189
|
+
)
|
|
190
|
+
evidence_requests.append(
|
|
191
|
+
f"Run 'dev verify {grounding.task_id}' and resolve the gaps so the "
|
|
192
|
+
"claim is supported by passing evidence."
|
|
193
|
+
)
|
|
194
|
+
elif claims_completion and grounding.is_satisfied:
|
|
195
|
+
alternatives.append(
|
|
196
|
+
f"Completion is corroborated by passing evidence for task {grounding.task_id}; "
|
|
197
|
+
"proceed."
|
|
198
|
+
)
|
|
199
|
+
elif claims_completion and not any(term in lower for term in EVIDENCE_TERMS):
|
|
200
|
+
# No task state to ground against: best-effort keyword heuristic.
|
|
201
|
+
concerns.append("The response appears to claim completion without naming verification evidence.")
|
|
202
|
+
evidence_requests.append("State the exact commands, checks, or reviewed artifacts that prove the change.")
|
|
203
|
+
|
|
204
|
+
if _mentions_broad_change(lower):
|
|
205
|
+
concerns.append("The response suggests broad codebase changes; confirm they are authorized by the active DevCouncil task.")
|
|
206
|
+
alternatives.append("Split broad work into smaller planned files and run DevCouncil gates before marking it done.")
|
|
207
|
+
|
|
208
|
+
if "todo" in lower or "follow-up" in lower or "later" in lower:
|
|
209
|
+
evidence_requests.append("List any remaining TODOs as DevCouncil gaps or repair tasks instead of burying them in chat.")
|
|
210
|
+
|
|
211
|
+
if concerns and verdict == "Approved":
|
|
212
|
+
verdict = "Concerns"
|
|
213
|
+
if any(term in lower for term in ("--no-verify", "reset --hard", "force push", "ignore failing")):
|
|
214
|
+
verdict = "Critical Issues"
|
|
215
|
+
|
|
216
|
+
if not alternatives and verdict == "Approved":
|
|
217
|
+
alternatives.append("Proceed, but keep the final answer tied to changed files and verification evidence.")
|
|
218
|
+
|
|
219
|
+
summary = "No blocking critique found." if verdict == "Approved" else concerns[0]
|
|
220
|
+
message_for_agent = _message_for_agent(verdict, concerns, evidence_requests)
|
|
221
|
+
card_id = _card_id(turn)
|
|
222
|
+
return CritiqueCard(
|
|
223
|
+
schema="devcouncil.critique_card.v1",
|
|
224
|
+
id=card_id,
|
|
225
|
+
session_id=turn.session_id,
|
|
226
|
+
turn_id=turn.turn_id,
|
|
227
|
+
task_id=task_id,
|
|
228
|
+
client=client or turn.source,
|
|
229
|
+
verdict=verdict,
|
|
230
|
+
summary=summary,
|
|
231
|
+
concerns=concerns,
|
|
232
|
+
alternatives=alternatives,
|
|
233
|
+
evidence_requests=evidence_requests,
|
|
234
|
+
message_for_agent=message_for_agent,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def save_card(project_root: Path, card: CritiqueCard) -> Path:
|
|
239
|
+
cards_dir = project_root / ".devcouncil" / "live" / "cards"
|
|
240
|
+
cards_dir.mkdir(parents=True, exist_ok=True)
|
|
241
|
+
path = cards_dir / f"{card.id}.json"
|
|
242
|
+
path.write_text(card.model_dump_json(indent=2) + "\n", encoding="utf-8")
|
|
243
|
+
return path
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def card_path(project_root: Path, card_id: str) -> Path:
|
|
247
|
+
return project_root / ".devcouncil" / "live" / "cards" / f"{card_id}.json"
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def load_cards(project_root: Path) -> list[CritiqueCard]:
|
|
251
|
+
cards_dir = project_root / ".devcouncil" / "live" / "cards"
|
|
252
|
+
if not cards_dir.exists():
|
|
253
|
+
return []
|
|
254
|
+
cards: list[CritiqueCard] = []
|
|
255
|
+
for path in sorted(cards_dir.glob("*.json"), key=lambda item: item.stat().st_mtime, reverse=True):
|
|
256
|
+
try:
|
|
257
|
+
cards.append(CritiqueCard.model_validate(json.loads(path.read_text(encoding="utf-8"))))
|
|
258
|
+
except Exception:
|
|
259
|
+
continue
|
|
260
|
+
return cards
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def filter_cards(
|
|
264
|
+
cards: list[CritiqueCard],
|
|
265
|
+
*,
|
|
266
|
+
task_id: str | None = None,
|
|
267
|
+
status: str | None = None,
|
|
268
|
+
verdict: str | None = None,
|
|
269
|
+
client: str | None = None,
|
|
270
|
+
) -> tuple[list[CritiqueCard], str | None, str | None]:
|
|
271
|
+
normalized_status = status.lower() if status else None
|
|
272
|
+
if normalized_status and normalized_status not in {"open", "resolved", "ignored"}:
|
|
273
|
+
return [], "--status must be open, resolved, or ignored.", "status"
|
|
274
|
+
|
|
275
|
+
verdict_map = {
|
|
276
|
+
"approved": "Approved",
|
|
277
|
+
"concerns": "Concerns",
|
|
278
|
+
"critical": "Critical Issues",
|
|
279
|
+
"critical issues": "Critical Issues",
|
|
280
|
+
}
|
|
281
|
+
normalized_verdict = None
|
|
282
|
+
if verdict:
|
|
283
|
+
normalized_verdict = verdict_map.get(verdict.lower())
|
|
284
|
+
if not normalized_verdict:
|
|
285
|
+
return [], "--verdict must be approved, concerns, or critical.", "verdict"
|
|
286
|
+
|
|
287
|
+
normalized_client = client.lower() if client else None
|
|
288
|
+
filtered = []
|
|
289
|
+
for card in cards:
|
|
290
|
+
if task_id and card.task_id != task_id:
|
|
291
|
+
continue
|
|
292
|
+
if normalized_status and card.status != normalized_status:
|
|
293
|
+
continue
|
|
294
|
+
if normalized_verdict and card.verdict != normalized_verdict:
|
|
295
|
+
continue
|
|
296
|
+
if normalized_client and card.client.lower() != normalized_client:
|
|
297
|
+
continue
|
|
298
|
+
filtered.append(card)
|
|
299
|
+
return filtered, None, None
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def get_card(project_root: Path, card_id: str) -> CritiqueCard | None:
|
|
303
|
+
for card in load_cards(project_root):
|
|
304
|
+
if card.id == card_id:
|
|
305
|
+
return card
|
|
306
|
+
return None
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def update_card_status(project_root: Path, card_id: str, status: CardStatus) -> CritiqueCard | None:
|
|
310
|
+
cards_dir = project_root / ".devcouncil" / "live" / "cards"
|
|
311
|
+
path = cards_dir / f"{card_id}.json"
|
|
312
|
+
if not path.exists():
|
|
313
|
+
return None
|
|
314
|
+
card = CritiqueCard.model_validate(json.loads(path.read_text(encoding="utf-8")))
|
|
315
|
+
updated = card.model_copy(update={"status": status})
|
|
316
|
+
path.write_text(updated.model_dump_json(indent=2) + "\n", encoding="utf-8")
|
|
317
|
+
return updated
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def unresolved_blocking_cards(project_root: Path, task_id: str | None = None) -> list[CritiqueCard]:
|
|
321
|
+
return [
|
|
322
|
+
card for card in load_cards(project_root)
|
|
323
|
+
if card.status == "open" and card.verdict == "Critical Issues"
|
|
324
|
+
and (task_id is None or card.task_id in {None, task_id})
|
|
325
|
+
]
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _card_id(turn: AgentTurn) -> str:
|
|
329
|
+
digest = hashlib.sha256(f"{turn.session_id}:{turn.turn_id}:{turn.content}".encode("utf-8")).hexdigest()
|
|
330
|
+
return f"CARD-{digest[:12]}"
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _mentions_broad_change(lower: str) -> bool:
|
|
334
|
+
return any(phrase in lower for phrase in (
|
|
335
|
+
"refactor the entire",
|
|
336
|
+
"rewrite",
|
|
337
|
+
"all files",
|
|
338
|
+
"every file",
|
|
339
|
+
"across the codebase",
|
|
340
|
+
))
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _message_for_agent(verdict: Verdict, concerns: list[str], evidence_requests: list[str]) -> str:
|
|
344
|
+
if verdict == "Approved":
|
|
345
|
+
return "Continue, but keep the next response grounded in changed files and verification evidence."
|
|
346
|
+
pieces = ["Pause and address this review before proceeding."]
|
|
347
|
+
pieces.extend(concerns)
|
|
348
|
+
pieces.extend(evidence_requests)
|
|
349
|
+
return " ".join(pieces)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
Verdict = Literal["Approved", "Concerns", "Critical Issues"]
|
|
11
|
+
CardStatus = Literal["open", "resolved", "ignored"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AgentTurn(BaseModel):
|
|
15
|
+
"""A normalized coding-agent conversation turn."""
|
|
16
|
+
|
|
17
|
+
session_id: str
|
|
18
|
+
turn_id: str
|
|
19
|
+
source: str = "generic"
|
|
20
|
+
role: Literal["user", "assistant", "system", "tool", "unknown"] = "unknown"
|
|
21
|
+
content: str = ""
|
|
22
|
+
timestamp: str | None = None
|
|
23
|
+
raw: dict[str, Any] = Field(default_factory=dict)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class AgentSession(BaseModel):
|
|
27
|
+
"""A discovered coding-agent transcript."""
|
|
28
|
+
|
|
29
|
+
id: str
|
|
30
|
+
client: str
|
|
31
|
+
transcript_path: str
|
|
32
|
+
updated_at: str | None = None
|
|
33
|
+
turns: int = 0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CritiqueCard(BaseModel):
|
|
37
|
+
"""Sage-style response review, shaped for DevCouncil gates."""
|
|
38
|
+
|
|
39
|
+
model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True)
|
|
40
|
+
|
|
41
|
+
schema_version: str = Field("devcouncil.critique_card.v1", alias="schema")
|
|
42
|
+
id: str
|
|
43
|
+
session_id: str
|
|
44
|
+
turn_id: str
|
|
45
|
+
task_id: str | None = None
|
|
46
|
+
client: str
|
|
47
|
+
verdict: Verdict
|
|
48
|
+
summary: str
|
|
49
|
+
concerns: list[str] = Field(default_factory=list)
|
|
50
|
+
alternatives: list[str] = Field(default_factory=list)
|
|
51
|
+
message_for_agent: str = ""
|
|
52
|
+
evidence_requests: list[str] = Field(default_factory=list)
|
|
53
|
+
status: CardStatus = "open"
|
|
54
|
+
created_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
55
|
+
source_path: str | None = None
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def blocks_completion(self) -> bool:
|
|
59
|
+
return self.verdict == "Critical Issues"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def session_id_from_path(path: Path) -> str:
|
|
63
|
+
return path.stem.replace(".", "-")
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from devcouncil.execution.prompt_builder import PromptBuilder
|
|
6
|
+
from devcouncil.live.models import CritiqueCard
|
|
7
|
+
from devcouncil.storage.db import get_db
|
|
8
|
+
from devcouncil.storage.repositories import RequirementRepository, TaskRepository
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_live_repair_prompt(project_root: Path, card: CritiqueCard) -> str:
|
|
12
|
+
"""Build a ready-to-paste repair prompt for a live-review critique card."""
|
|
13
|
+
prompt = [
|
|
14
|
+
f"# Repair Live Review Card {card.id}",
|
|
15
|
+
"",
|
|
16
|
+
"A DevCouncil live-review card is blocking or warning on the current coding-agent session.",
|
|
17
|
+
"Address the critique directly, then provide exact verification evidence.",
|
|
18
|
+
"",
|
|
19
|
+
"## Card",
|
|
20
|
+
f"- Verdict: {card.verdict}",
|
|
21
|
+
f"- Status: {card.status}",
|
|
22
|
+
f"- Task: {card.task_id or '(unscoped)'}",
|
|
23
|
+
f"- Summary: {card.summary}",
|
|
24
|
+
]
|
|
25
|
+
if card.concerns:
|
|
26
|
+
prompt.extend(["", "## Concerns"])
|
|
27
|
+
prompt.extend(f"- {item}" for item in card.concerns)
|
|
28
|
+
if card.alternatives:
|
|
29
|
+
prompt.extend(["", "## Safer Alternatives"])
|
|
30
|
+
prompt.extend(f"- {item}" for item in card.alternatives)
|
|
31
|
+
if card.evidence_requests:
|
|
32
|
+
prompt.extend(["", "## Required Evidence"])
|
|
33
|
+
prompt.extend(f"- {item}" for item in card.evidence_requests)
|
|
34
|
+
if card.message_for_agent:
|
|
35
|
+
prompt.extend(["", "## Message For Agent", card.message_for_agent])
|
|
36
|
+
|
|
37
|
+
task_prompt = _task_prompt(project_root, card.task_id)
|
|
38
|
+
if task_prompt:
|
|
39
|
+
prompt.extend(["", "## Original DevCouncil Task Contract", task_prompt])
|
|
40
|
+
|
|
41
|
+
prompt.extend([
|
|
42
|
+
"",
|
|
43
|
+
"## Repair Instructions",
|
|
44
|
+
"1. Do not bypass tests, hooks, or verification gates.",
|
|
45
|
+
"2. Keep changes within the DevCouncil task contract when one is present.",
|
|
46
|
+
"3. Address each concern above explicitly.",
|
|
47
|
+
"4. Run the expected verification commands and report exact results.",
|
|
48
|
+
f"5. After the repair is complete, ask the developer to run `dev watch resolve {card.id} --status resolved`.",
|
|
49
|
+
])
|
|
50
|
+
return "\n".join(prompt).rstrip() + "\n"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def build_bulk_live_repair_prompt(project_root: Path, cards: list[CritiqueCard]) -> str:
|
|
54
|
+
"""Build a combined repair prompt for multiple live-review critique cards."""
|
|
55
|
+
if not cards:
|
|
56
|
+
return "# Live Review Repair\n\nNo blocking live-review cards found for this scope.\n"
|
|
57
|
+
sections = [
|
|
58
|
+
"# Repair Blocking Live Review Cards",
|
|
59
|
+
"",
|
|
60
|
+
f"DevCouncil found {len(cards)} blocking live-review card(s). Address each card below.",
|
|
61
|
+
]
|
|
62
|
+
for index, card in enumerate(cards, start=1):
|
|
63
|
+
sections.extend([
|
|
64
|
+
"",
|
|
65
|
+
f"---\n\n## Card {index}: {card.id}",
|
|
66
|
+
"",
|
|
67
|
+
build_live_repair_prompt(project_root, card).strip(),
|
|
68
|
+
])
|
|
69
|
+
return "\n".join(sections).rstrip() + "\n"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _task_prompt(project_root: Path, task_id: str | None) -> str | None:
|
|
73
|
+
if not task_id:
|
|
74
|
+
return None
|
|
75
|
+
db = get_db(project_root)
|
|
76
|
+
if not db:
|
|
77
|
+
return None
|
|
78
|
+
with db.get_session() as session:
|
|
79
|
+
task = TaskRepository(session).get_by_id(task_id)
|
|
80
|
+
if not task:
|
|
81
|
+
return None
|
|
82
|
+
requirements = RequirementRepository(session).get_all()
|
|
83
|
+
return PromptBuilder(project_root).build_task_prompt(task, requirements)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from devcouncil.live.cards import review_turn
|
|
6
|
+
from devcouncil.live.models import AgentTurn, CritiqueCard
|
|
7
|
+
from devcouncil.llm.router import ModelRouter
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LiveReviewService:
|
|
11
|
+
"""Reviews coding-agent responses with deterministic or model-backed critique cards."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, router: ModelRouter | None = None, role: str = "live_reviewer"):
|
|
14
|
+
self.router = router
|
|
15
|
+
self.role = role
|
|
16
|
+
|
|
17
|
+
async def review(
|
|
18
|
+
self,
|
|
19
|
+
turn: AgentTurn,
|
|
20
|
+
project_root: Path,
|
|
21
|
+
client: str = "generic",
|
|
22
|
+
use_llm: bool = False,
|
|
23
|
+
) -> CritiqueCard:
|
|
24
|
+
fallback = review_turn(turn, project_root, client=client)
|
|
25
|
+
if not use_llm or self.router is None:
|
|
26
|
+
return fallback
|
|
27
|
+
|
|
28
|
+
prompt = f"""
|
|
29
|
+
You are DevCouncil's live coding-agent reviewer.
|
|
30
|
+
Review the latest assistant response before the developer follows it.
|
|
31
|
+
|
|
32
|
+
Return a critique card with:
|
|
33
|
+
- verdict: Approved, Concerns, or Critical Issues.
|
|
34
|
+
- concerns: concrete risks in the response, reasoning, plan, or proof.
|
|
35
|
+
- alternatives: safer approaches or architectures.
|
|
36
|
+
- evidence_requests: exact proof the agent should provide before claiming done.
|
|
37
|
+
- message_for_agent: a concise ready-to-paste instruction for the coding agent.
|
|
38
|
+
|
|
39
|
+
Do not praise. Do not review formatting. Focus on correctness, missing requirements, architectural drift,
|
|
40
|
+
unsafe commands, weak evidence, and premature completion claims.
|
|
41
|
+
|
|
42
|
+
Client: {client}
|
|
43
|
+
Session: {turn.session_id}
|
|
44
|
+
Turn: {turn.turn_id}
|
|
45
|
+
|
|
46
|
+
Assistant response:
|
|
47
|
+
{turn.content}
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
reviewed = await self.router.complete_structured(
|
|
51
|
+
role=self.role,
|
|
52
|
+
messages=[{"role": "user", "content": prompt}],
|
|
53
|
+
schema=CritiqueCard,
|
|
54
|
+
)
|
|
55
|
+
except ValueError:
|
|
56
|
+
reviewed = await self.router.complete_structured(
|
|
57
|
+
role="implementation_reviewer",
|
|
58
|
+
messages=[{"role": "user", "content": prompt}],
|
|
59
|
+
schema=CritiqueCard,
|
|
60
|
+
)
|
|
61
|
+
except Exception:
|
|
62
|
+
return fallback
|
|
63
|
+
|
|
64
|
+
return reviewed.model_copy(update={
|
|
65
|
+
"id": fallback.id,
|
|
66
|
+
"session_id": turn.session_id,
|
|
67
|
+
"turn_id": turn.turn_id,
|
|
68
|
+
"client": client,
|
|
69
|
+
"source_path": fallback.source_path,
|
|
70
|
+
})
|