devcouncil 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -201
- package/README.md +62 -543
- package/package.json +1 -1
- package/pyproject.toml +29 -26
- package/src/devcouncil/__main__.py +4 -4
- package/src/devcouncil/app/__init__.py +28 -28
- package/src/devcouncil/app/config.py +135 -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 +143 -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/cli/commands/artifacts.py +51 -48
- package/src/devcouncil/cli/commands/ast.py +22 -0
- package/src/devcouncil/cli/commands/baseline.py +35 -32
- package/src/devcouncil/cli/commands/config.py +76 -54
- package/src/devcouncil/cli/commands/dashboard.py +26 -0
- package/src/devcouncil/cli/commands/doctor.py +86 -42
- package/src/devcouncil/cli/commands/go.py +237 -0
- package/src/devcouncil/cli/commands/hook.py +96 -29
- package/src/devcouncil/cli/commands/init.py +67 -56
- package/src/devcouncil/cli/commands/integrate.py +320 -14
- package/src/devcouncil/cli/commands/lsp.py +20 -0
- package/src/devcouncil/cli/commands/map.py +25 -21
- package/src/devcouncil/cli/commands/plan.py +257 -206
- package/src/devcouncil/cli/commands/prompt.py +36 -33
- package/src/devcouncil/cli/commands/repair.py +72 -69
- package/src/devcouncil/cli/commands/report.py +112 -54
- package/src/devcouncil/cli/commands/reset_demo_state.py +31 -28
- package/src/devcouncil/cli/commands/rollback.py +49 -47
- package/src/devcouncil/cli/commands/run.py +252 -207
- package/src/devcouncil/cli/commands/setup.py +159 -18
- package/src/devcouncil/cli/commands/show.py +76 -57
- package/src/devcouncil/cli/commands/status.py +117 -105
- package/src/devcouncil/cli/commands/tasks.py +55 -41
- package/src/devcouncil/cli/commands/trace.py +2 -1
- package/src/devcouncil/cli/commands/verify.py +158 -128
- package/src/devcouncil/cli/commands/version.py +20 -20
- package/src/devcouncil/cli/commands/watch.py +574 -0
- package/src/devcouncil/cli/main.py +42 -24
- 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 +27 -27
- package/src/devcouncil/domain/gap.py +26 -26
- package/src/devcouncil/domain/requirement.py +22 -22
- package/src/devcouncil/domain/task.py +26 -26
- package/src/devcouncil/execution/__init__.py +1 -1
- package/src/devcouncil/execution/context_builder.py +54 -54
- package/src/devcouncil/execution/executor.py +15 -15
- package/src/devcouncil/execution/hook_policy.py +24 -3
- package/src/devcouncil/execution/patch.py +28 -28
- package/src/devcouncil/execution/permissions.py +44 -44
- package/src/devcouncil/execution/prompt_builder.py +23 -23
- package/src/devcouncil/execution/task_runner.py +63 -63
- package/src/devcouncil/executors/__init__.py +1 -1
- package/src/devcouncil/executors/coding_cli.py +112 -0
- package/src/devcouncil/executors/mini_swe.py +63 -63
- package/src/devcouncil/executors/native/agent.py +81 -81
- package/src/devcouncil/executors/openhands.py +56 -56
- package/src/devcouncil/gating/__init__.py +1 -1
- package/src/devcouncil/gating/checks/clean_git.py +50 -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 +34 -34
- package/src/devcouncil/gating/policy.py +157 -157
- 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 +120 -0
- package/src/devcouncil/indexing/repo_mapper.py +208 -204
- package/src/devcouncil/integrations/github.py +35 -35
- package/src/devcouncil/integrations/gitnexus.py +27 -27
- package/src/devcouncil/integrations/graphify.py +34 -34
- package/src/devcouncil/integrations/mcp/server.py +549 -96
- package/src/devcouncil/integrations/pr_comments.py +62 -0
- package/src/devcouncil/live/__init__.py +2 -0
- package/src/devcouncil/live/cards.py +207 -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 +138 -0
- package/src/devcouncil/llm/__init__.py +1 -1
- package/src/devcouncil/llm/cache.py +38 -38
- package/src/devcouncil/llm/provider.py +146 -125
- package/src/devcouncil/llm/router.py +111 -111
- package/src/devcouncil/planning/__init__.py +1 -1
- package/src/devcouncil/planning/arbiter_service.py +57 -57
- package/src/devcouncil/planning/critique_service.py +66 -66
- package/src/devcouncil/planning/plan_service.py +46 -46
- package/src/devcouncil/planning/prompt_enhancer_service.py +86 -0
- package/src/devcouncil/planning/repair_service.py +39 -39
- package/src/devcouncil/planning/spec_service.py +44 -44
- package/src/devcouncil/reporting/github_check.py +32 -32
- package/src/devcouncil/reporting/json_report.py +20 -17
- package/src/devcouncil/reporting/markdown_report.py +68 -46
- package/src/devcouncil/reporting/report_builder.py +14 -14
- package/src/devcouncil/storage/db.py +66 -66
- package/src/devcouncil/storage/models.py +83 -83
- package/src/devcouncil/storage/repositories.py +299 -222
- package/src/devcouncil/telemetry/cost.py +34 -34
- package/src/devcouncil/telemetry/tracker.py +49 -49
- package/src/devcouncil/ui/__init__.py +1 -0
- package/src/devcouncil/ui/dashboard.py +122 -0
- package/src/devcouncil/utils/__init__.py +1 -1
- package/src/devcouncil/utils/redaction.py +141 -141
- package/src/devcouncil/verification/__init__.py +1 -1
- package/src/devcouncil/verification/implementation_reviewer.py +55 -55
- package/src/devcouncil/verification/verifier.py +319 -302
- package/uv.lock +1 -1
|
@@ -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,207 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from devcouncil.live.models import AgentTurn, CardStatus, CritiqueCard
|
|
8
|
+
|
|
9
|
+
RISK_TERMS = (
|
|
10
|
+
"skip tests",
|
|
11
|
+
"no tests",
|
|
12
|
+
"untested",
|
|
13
|
+
"ignore failing",
|
|
14
|
+
"disable",
|
|
15
|
+
"workaround",
|
|
16
|
+
"quick hack",
|
|
17
|
+
"hardcode",
|
|
18
|
+
"force push",
|
|
19
|
+
"--no-verify",
|
|
20
|
+
"reset --hard",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
EVIDENCE_TERMS = (
|
|
24
|
+
"test",
|
|
25
|
+
"pytest",
|
|
26
|
+
"vitest",
|
|
27
|
+
"npm test",
|
|
28
|
+
"go test",
|
|
29
|
+
"cargo test",
|
|
30
|
+
"verification",
|
|
31
|
+
"verified",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def review_turn(turn: AgentTurn, project_root: Path, client: str | None = None) -> CritiqueCard:
|
|
36
|
+
"""Generate a deterministic critique card for an agent response."""
|
|
37
|
+
content = turn.content.strip()
|
|
38
|
+
lower = content.lower()
|
|
39
|
+
concerns: list[str] = []
|
|
40
|
+
alternatives: list[str] = []
|
|
41
|
+
evidence_requests: list[str] = []
|
|
42
|
+
|
|
43
|
+
risky_terms = [term for term in RISK_TERMS if term in lower]
|
|
44
|
+
if risky_terms:
|
|
45
|
+
concerns.append(f"Response contains risky implementation language: {', '.join(risky_terms[:4])}.")
|
|
46
|
+
alternatives.append("Replace risky shortcuts with a scoped implementation and explicit rollback or verification path.")
|
|
47
|
+
|
|
48
|
+
if _looks_like_completion_claim(lower) and not any(term in lower for term in EVIDENCE_TERMS):
|
|
49
|
+
concerns.append("The response appears to claim completion without naming verification evidence.")
|
|
50
|
+
evidence_requests.append("State the exact commands, checks, or reviewed artifacts that prove the change.")
|
|
51
|
+
|
|
52
|
+
if _mentions_broad_change(lower):
|
|
53
|
+
concerns.append("The response suggests broad codebase changes; confirm they are authorized by the active DevCouncil task.")
|
|
54
|
+
alternatives.append("Split broad work into smaller planned files and run DevCouncil gates before marking it done.")
|
|
55
|
+
|
|
56
|
+
if "todo" in lower or "follow-up" in lower or "later" in lower:
|
|
57
|
+
evidence_requests.append("List any remaining TODOs as DevCouncil gaps or repair tasks instead of burying them in chat.")
|
|
58
|
+
|
|
59
|
+
verdict = "Approved"
|
|
60
|
+
if concerns:
|
|
61
|
+
verdict = "Concerns"
|
|
62
|
+
if any(term in lower for term in ("--no-verify", "reset --hard", "force push", "ignore failing")):
|
|
63
|
+
verdict = "Critical Issues"
|
|
64
|
+
|
|
65
|
+
if not alternatives and verdict == "Approved":
|
|
66
|
+
alternatives.append("Proceed, but keep the final answer tied to changed files and verification evidence.")
|
|
67
|
+
|
|
68
|
+
summary = "No blocking critique found." if verdict == "Approved" else concerns[0]
|
|
69
|
+
message_for_agent = _message_for_agent(verdict, concerns, evidence_requests)
|
|
70
|
+
card_id = _card_id(turn)
|
|
71
|
+
return CritiqueCard(
|
|
72
|
+
id=card_id,
|
|
73
|
+
session_id=turn.session_id,
|
|
74
|
+
turn_id=turn.turn_id,
|
|
75
|
+
client=client or turn.source,
|
|
76
|
+
verdict=verdict,
|
|
77
|
+
summary=summary,
|
|
78
|
+
concerns=concerns,
|
|
79
|
+
alternatives=alternatives,
|
|
80
|
+
evidence_requests=evidence_requests,
|
|
81
|
+
message_for_agent=message_for_agent,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def save_card(project_root: Path, card: CritiqueCard) -> Path:
|
|
86
|
+
cards_dir = project_root / ".devcouncil" / "live" / "cards"
|
|
87
|
+
cards_dir.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
path = cards_dir / f"{card.id}.json"
|
|
89
|
+
path.write_text(card.model_dump_json(indent=2) + "\n", encoding="utf-8")
|
|
90
|
+
return path
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def card_path(project_root: Path, card_id: str) -> Path:
|
|
94
|
+
return project_root / ".devcouncil" / "live" / "cards" / f"{card_id}.json"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def load_cards(project_root: Path) -> list[CritiqueCard]:
|
|
98
|
+
cards_dir = project_root / ".devcouncil" / "live" / "cards"
|
|
99
|
+
if not cards_dir.exists():
|
|
100
|
+
return []
|
|
101
|
+
cards: list[CritiqueCard] = []
|
|
102
|
+
for path in sorted(cards_dir.glob("*.json"), key=lambda item: item.stat().st_mtime, reverse=True):
|
|
103
|
+
try:
|
|
104
|
+
cards.append(CritiqueCard.model_validate(json.loads(path.read_text(encoding="utf-8"))))
|
|
105
|
+
except Exception:
|
|
106
|
+
continue
|
|
107
|
+
return cards
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def filter_cards(
|
|
111
|
+
cards: list[CritiqueCard],
|
|
112
|
+
*,
|
|
113
|
+
task_id: str | None = None,
|
|
114
|
+
status: str | None = None,
|
|
115
|
+
verdict: str | None = None,
|
|
116
|
+
client: str | None = None,
|
|
117
|
+
) -> tuple[list[CritiqueCard], str | None, str | None]:
|
|
118
|
+
normalized_status = status.lower() if status else None
|
|
119
|
+
if normalized_status and normalized_status not in {"open", "resolved", "ignored"}:
|
|
120
|
+
return [], "--status must be open, resolved, or ignored.", "status"
|
|
121
|
+
|
|
122
|
+
verdict_map = {
|
|
123
|
+
"approved": "Approved",
|
|
124
|
+
"concerns": "Concerns",
|
|
125
|
+
"critical": "Critical Issues",
|
|
126
|
+
"critical issues": "Critical Issues",
|
|
127
|
+
}
|
|
128
|
+
normalized_verdict = None
|
|
129
|
+
if verdict:
|
|
130
|
+
normalized_verdict = verdict_map.get(verdict.lower())
|
|
131
|
+
if not normalized_verdict:
|
|
132
|
+
return [], "--verdict must be approved, concerns, or critical.", "verdict"
|
|
133
|
+
|
|
134
|
+
normalized_client = client.lower() if client else None
|
|
135
|
+
filtered = []
|
|
136
|
+
for card in cards:
|
|
137
|
+
if task_id and card.task_id != task_id:
|
|
138
|
+
continue
|
|
139
|
+
if normalized_status and card.status != normalized_status:
|
|
140
|
+
continue
|
|
141
|
+
if normalized_verdict and card.verdict != normalized_verdict:
|
|
142
|
+
continue
|
|
143
|
+
if normalized_client and card.client.lower() != normalized_client:
|
|
144
|
+
continue
|
|
145
|
+
filtered.append(card)
|
|
146
|
+
return filtered, None, None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def get_card(project_root: Path, card_id: str) -> CritiqueCard | None:
|
|
150
|
+
for card in load_cards(project_root):
|
|
151
|
+
if card.id == card_id:
|
|
152
|
+
return card
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def update_card_status(project_root: Path, card_id: str, status: CardStatus) -> CritiqueCard | None:
|
|
157
|
+
cards_dir = project_root / ".devcouncil" / "live" / "cards"
|
|
158
|
+
path = cards_dir / f"{card_id}.json"
|
|
159
|
+
if not path.exists():
|
|
160
|
+
return None
|
|
161
|
+
card = CritiqueCard.model_validate(json.loads(path.read_text(encoding="utf-8")))
|
|
162
|
+
updated = card.model_copy(update={"status": status})
|
|
163
|
+
path.write_text(updated.model_dump_json(indent=2) + "\n", encoding="utf-8")
|
|
164
|
+
return updated
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def unresolved_blocking_cards(project_root: Path, task_id: str | None = None) -> list[CritiqueCard]:
|
|
168
|
+
return [
|
|
169
|
+
card for card in load_cards(project_root)
|
|
170
|
+
if card.status == "open" and card.verdict == "Critical Issues"
|
|
171
|
+
and (task_id is None or card.task_id in {None, task_id})
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _card_id(turn: AgentTurn) -> str:
|
|
176
|
+
digest = hashlib.sha1(f"{turn.session_id}:{turn.turn_id}:{turn.content}".encode("utf-8")).hexdigest()
|
|
177
|
+
return f"CARD-{digest[:12]}"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _looks_like_completion_claim(lower: str) -> bool:
|
|
181
|
+
return any(phrase in lower for phrase in (
|
|
182
|
+
"done",
|
|
183
|
+
"completed",
|
|
184
|
+
"implemented",
|
|
185
|
+
"fixed",
|
|
186
|
+
"ready",
|
|
187
|
+
"all set",
|
|
188
|
+
))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _mentions_broad_change(lower: str) -> bool:
|
|
192
|
+
return any(phrase in lower for phrase in (
|
|
193
|
+
"refactor the entire",
|
|
194
|
+
"rewrite",
|
|
195
|
+
"all files",
|
|
196
|
+
"every file",
|
|
197
|
+
"across the codebase",
|
|
198
|
+
))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _message_for_agent(verdict: str, concerns: list[str], evidence_requests: list[str]) -> str:
|
|
202
|
+
if verdict == "Approved":
|
|
203
|
+
return "Continue, but keep the next response grounded in changed files and verification evidence."
|
|
204
|
+
pieces = ["Pause and address this review before proceeding."]
|
|
205
|
+
pieces.extend(concerns)
|
|
206
|
+
pieces.extend(evidence_requests)
|
|
207
|
+
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
|
+
})
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ReviewSignal(BaseModel):
|
|
12
|
+
client: str = "generic"
|
|
13
|
+
payload: dict[str, Any] = Field(default_factory=dict)
|
|
14
|
+
transcript_path: str | None = None
|
|
15
|
+
session_id: str | None = None
|
|
16
|
+
task_id: str | None = None
|
|
17
|
+
created_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
18
|
+
review_command: str | None = None
|
|
19
|
+
path: str | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def signal_dir(project_root: Path) -> Path:
|
|
23
|
+
return project_root / ".devcouncil" / "live" / "signals"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def processed_signal_dir(project_root: Path) -> Path:
|
|
27
|
+
return signal_dir(project_root) / "processed"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def write_signal(project_root: Path, client: str, payload: dict[str, Any]) -> Path:
|
|
31
|
+
directory = signal_dir(project_root)
|
|
32
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
transcript_path = extract_transcript_path(payload)
|
|
34
|
+
session_id = _string_value(payload, "session_id", "sessionId", "session", "cwd")
|
|
35
|
+
task_id = extract_task_id(payload)
|
|
36
|
+
signal = ReviewSignal(
|
|
37
|
+
client=client.lower(),
|
|
38
|
+
payload=payload,
|
|
39
|
+
transcript_path=transcript_path,
|
|
40
|
+
session_id=session_id,
|
|
41
|
+
task_id=task_id,
|
|
42
|
+
review_command=_review_command(client.lower(), transcript_path, task_id),
|
|
43
|
+
)
|
|
44
|
+
key = transcript_path or session_id or json.dumps(payload, sort_keys=True, default=str)
|
|
45
|
+
import hashlib
|
|
46
|
+
|
|
47
|
+
digest = hashlib.sha1(key.encode("utf-8", errors="replace")).hexdigest()[:12]
|
|
48
|
+
path = directory / f"{client.lower()}-{digest}.json"
|
|
49
|
+
signal.path = str(path)
|
|
50
|
+
path.write_text(signal.model_dump_json(indent=2) + "\n", encoding="utf-8")
|
|
51
|
+
return path
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_signals(project_root: Path) -> list[ReviewSignal]:
|
|
55
|
+
directory = signal_dir(project_root)
|
|
56
|
+
if not directory.exists():
|
|
57
|
+
return []
|
|
58
|
+
signals: list[ReviewSignal] = []
|
|
59
|
+
for path in sorted(directory.glob("*.json"), key=lambda item: item.stat().st_mtime, reverse=True):
|
|
60
|
+
try:
|
|
61
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
62
|
+
signal = ReviewSignal.model_validate(raw)
|
|
63
|
+
signal.path = str(path)
|
|
64
|
+
signals.append(signal)
|
|
65
|
+
except Exception:
|
|
66
|
+
continue
|
|
67
|
+
return signals
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def mark_processed(signal: ReviewSignal, project_root: Path) -> Path | None:
|
|
71
|
+
if not signal.path:
|
|
72
|
+
return None
|
|
73
|
+
source = Path(signal.path)
|
|
74
|
+
if not source.exists():
|
|
75
|
+
return None
|
|
76
|
+
target_dir = processed_signal_dir(project_root)
|
|
77
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
target = target_dir / source.name
|
|
79
|
+
if target.exists():
|
|
80
|
+
target.unlink()
|
|
81
|
+
source.replace(target)
|
|
82
|
+
return target
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def extract_transcript_path(payload: dict[str, Any]) -> str | None:
|
|
86
|
+
direct = _string_value(
|
|
87
|
+
payload,
|
|
88
|
+
"transcript_path",
|
|
89
|
+
"transcriptPath",
|
|
90
|
+
"transcript",
|
|
91
|
+
"conversation_path",
|
|
92
|
+
"conversationPath",
|
|
93
|
+
"file",
|
|
94
|
+
"path",
|
|
95
|
+
)
|
|
96
|
+
if direct:
|
|
97
|
+
return direct
|
|
98
|
+
for key in ("session", "message", "hook_event", "event"):
|
|
99
|
+
nested = payload.get(key)
|
|
100
|
+
if isinstance(nested, dict):
|
|
101
|
+
value = extract_transcript_path(nested)
|
|
102
|
+
if value:
|
|
103
|
+
return value
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def extract_task_id(payload: dict[str, Any]) -> str | None:
|
|
108
|
+
direct = _string_value(payload, "task_id", "taskId", "task", "active_task", "activeTask")
|
|
109
|
+
if direct:
|
|
110
|
+
return direct
|
|
111
|
+
for key in ("session", "message", "hook_event", "event", "metadata"):
|
|
112
|
+
nested = payload.get(key)
|
|
113
|
+
if isinstance(nested, dict):
|
|
114
|
+
value = extract_task_id(nested)
|
|
115
|
+
if value:
|
|
116
|
+
return value
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _string_value(payload: dict[str, Any], *keys: str) -> str | None:
|
|
121
|
+
for key in keys:
|
|
122
|
+
value = payload.get(key)
|
|
123
|
+
if isinstance(value, str) and value.strip():
|
|
124
|
+
return value
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _review_command(client: str, transcript_path: str | None, task_id: str | None = None) -> str:
|
|
129
|
+
if transcript_path:
|
|
130
|
+
command = f"dev watch review --client {client} --transcript {transcript_path}"
|
|
131
|
+
else:
|
|
132
|
+
command = f"dev watch pending --client {client}"
|
|
133
|
+
if task_id:
|
|
134
|
+
command += f" --task-id {task_id}"
|
|
135
|
+
return command
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from devcouncil.live.cards import load_cards, unresolved_blocking_cards
|
|
6
|
+
from devcouncil.live.signals import load_signals
|
|
7
|
+
from devcouncil.live.tasks import active_task_id
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def live_review_summary(project_root: Path, task_id: str | None = None) -> dict:
|
|
11
|
+
cards = load_cards(project_root)
|
|
12
|
+
signals = load_signals(project_root)
|
|
13
|
+
active_id = active_task_id(project_root)
|
|
14
|
+
scoped_task_id = task_id or active_id
|
|
15
|
+
blockers = unresolved_blocking_cards(project_root, task_id=scoped_task_id)
|
|
16
|
+
pending_signal_items = [signal.model_dump() for signal in signals]
|
|
17
|
+
return {
|
|
18
|
+
"active_task_id": active_id,
|
|
19
|
+
"scope_task_id": scoped_task_id,
|
|
20
|
+
"pending_signals": len(signals),
|
|
21
|
+
"pending_signal_items": pending_signal_items[:10],
|
|
22
|
+
"cards": {
|
|
23
|
+
"total": len(cards),
|
|
24
|
+
"open": len([card for card in cards if card.status == "open"]),
|
|
25
|
+
"resolved": len([card for card in cards if card.status == "resolved"]),
|
|
26
|
+
"ignored": len([card for card in cards if card.status == "ignored"]),
|
|
27
|
+
"critical_open": len([
|
|
28
|
+
card for card in cards
|
|
29
|
+
if card.status == "open" and card.verdict == "Critical Issues"
|
|
30
|
+
]),
|
|
31
|
+
},
|
|
32
|
+
"blocking_cards": [card.model_dump() for card in blockers],
|
|
33
|
+
"recent_cards": [card.model_dump() for card in cards[:5]],
|
|
34
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from devcouncil.storage.db import get_db
|
|
6
|
+
from devcouncil.storage.repositories import TaskRepository
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def active_task_id(project_root: Path) -> str | None:
|
|
10
|
+
"""Return the single running DevCouncil task ID, if one is unambiguous."""
|
|
11
|
+
db = get_db(project_root)
|
|
12
|
+
if not db:
|
|
13
|
+
return None
|
|
14
|
+
with db.get_session() as session:
|
|
15
|
+
running = [task for task in TaskRepository(session).get_all() if task.status == "running"]
|
|
16
|
+
if len(running) != 1:
|
|
17
|
+
return None
|
|
18
|
+
return running[0].id
|