devcouncil 0.1.1 → 0.3.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 +201 -6
- package/package.json +9 -2
- package/pyproject.toml +34 -2
- package/src/devcouncil/app/config.py +348 -12
- package/src/devcouncil/app/orchestrator.py +10 -6
- package/src/devcouncil/app/state_machine.py +4 -0
- package/src/devcouncil/artifacts/graph.py +32 -5
- 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 +220 -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/design.py +186 -0
- package/src/devcouncil/cli/commands/doctor.py +379 -22
- package/src/devcouncil/cli/commands/evidence.py +48 -0
- package/src/devcouncil/cli/commands/go.py +532 -33
- package/src/devcouncil/cli/commands/handoff.py +69 -0
- package/src/devcouncil/cli/commands/hook.py +296 -15
- package/src/devcouncil/cli/commands/init.py +161 -20
- package/src/devcouncil/cli/commands/integrate.py +1371 -124
- package/src/devcouncil/cli/commands/logs.py +106 -0
- package/src/devcouncil/cli/commands/map.py +80 -10
- package/src/devcouncil/cli/commands/okf.py +245 -0
- package/src/devcouncil/cli/commands/plan.py +256 -55
- package/src/devcouncil/cli/commands/prompt.py +18 -7
- package/src/devcouncil/cli/commands/repair.py +50 -24
- 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 +195 -54
- 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 +267 -0
- package/src/devcouncil/cli/commands/status.py +30 -15
- package/src/devcouncil/cli/commands/trace.py +47 -3
- package/src/devcouncil/cli/commands/verify.py +144 -3
- package/src/devcouncil/cli/commands/watch.py +32 -12
- package/src/devcouncil/cli/commands/watch_fs.py +40 -0
- package/src/devcouncil/cli/main.py +91 -7
- package/src/devcouncil/domain/evidence.py +29 -2
- package/src/devcouncil/domain/gap.py +27 -1
- package/src/devcouncil/domain/task.py +31 -2
- package/src/devcouncil/execution/checkpoints.py +256 -0
- package/src/devcouncil/execution/context_builder.py +1 -1
- package/src/devcouncil/execution/fs_watcher.py +205 -0
- package/src/devcouncil/execution/handoff.py +102 -0
- package/src/devcouncil/execution/hook_policy.py +162 -74
- package/src/devcouncil/execution/patch.py +65 -10
- package/src/devcouncil/execution/permissions.py +24 -24
- package/src/devcouncil/execution/policy_engine.py +350 -0
- package/src/devcouncil/execution/prompt_builder.py +751 -23
- package/src/devcouncil/execution/shell_session.py +231 -0
- package/src/devcouncil/execution/task_runner.py +24 -9
- package/src/devcouncil/executors/agent_registry.py +596 -0
- package/src/devcouncil/executors/coding_cli.py +791 -39
- package/src/devcouncil/executors/mini_swe.py +6 -0
- package/src/devcouncil/executors/native/agent.py +135 -19
- package/src/devcouncil/executors/openhands.py +6 -0
- package/src/devcouncil/gating/checks/clean_git.py +3 -1
- package/src/devcouncil/gating/checks/secret_scan_check.py +47 -21
- package/src/devcouncil/gating/policy.py +190 -11
- package/src/devcouncil/hardware.py +184 -0
- package/src/devcouncil/indexing/ast_matcher.py +17 -7
- package/src/devcouncil/indexing/lsp.py +45 -4
- package/src/devcouncil/indexing/repo_mapper.py +1284 -15
- package/src/devcouncil/indexing/semantic_index.py +221 -0
- package/src/devcouncil/integrations/actions.py +166 -0
- package/src/devcouncil/integrations/check.py +426 -0
- package/src/devcouncil/integrations/claude_assets.py +444 -0
- package/src/devcouncil/integrations/code_review_graph.py +13 -2
- package/src/devcouncil/integrations/github_intent.py +149 -0
- package/src/devcouncil/integrations/gitnexus.py +45 -2
- package/src/devcouncil/integrations/mcp/server.py +1944 -32
- package/src/devcouncil/integrations/opencode_devcouncil_plugin.mjs +24 -0
- package/src/devcouncil/integrations/pr_comments.py +9 -0
- package/src/devcouncil/knowledge/__init__.py +23 -0
- package/src/devcouncil/knowledge/design.py +374 -0
- package/src/devcouncil/knowledge/design_conformance.py +317 -0
- package/src/devcouncil/knowledge/fetch.py +223 -0
- package/src/devcouncil/knowledge/frontmatter.py +51 -0
- package/src/devcouncil/knowledge/okf.py +202 -0
- package/src/devcouncil/knowledge/skill_bridge.py +96 -0
- package/src/devcouncil/knowledge/sources.py +239 -0
- package/src/devcouncil/live/cards.py +181 -25
- package/src/devcouncil/live/repair_prompt.py +29 -6
- package/src/devcouncil/live/reviewer.py +72 -13
- package/src/devcouncil/live/signals.py +2 -2
- package/src/devcouncil/live/summary.py +18 -8
- package/src/devcouncil/live/transcripts.py +47 -11
- package/src/devcouncil/llm/cache.py +20 -8
- package/src/devcouncil/llm/model_defaults.yaml +44 -0
- package/src/devcouncil/llm/provider.py +617 -49
- package/src/devcouncil/llm/router.py +337 -53
- package/src/devcouncil/optimization/__init__.py +1 -0
- package/src/devcouncil/optimization/gepa_agent.py +318 -0
- package/src/devcouncil/optimization/skillopt.py +673 -0
- package/src/devcouncil/planning/arbiter_service.py +10 -2
- package/src/devcouncil/planning/correction_manifest.py +346 -0
- package/src/devcouncil/planning/critique_service.py +16 -4
- package/src/devcouncil/planning/plan_service.py +86 -6
- package/src/devcouncil/planning/prompt_enhancer_service.py +206 -1
- package/src/devcouncil/planning/repair_service.py +8 -2
- package/src/devcouncil/planning/spec_service.py +37 -3
- package/src/devcouncil/repo/ci_scaffold.py +165 -0
- package/src/devcouncil/repo/gitignore.py +123 -0
- package/src/devcouncil/repo/sca.py +384 -0
- package/src/devcouncil/reporting/json_report.py +22 -1
- package/src/devcouncil/reporting/markdown_report.py +29 -1
- package/src/devcouncil/reporting/okf_bundle_writer.py +364 -0
- package/src/devcouncil/reporting/okf_html.py +323 -0
- package/src/devcouncil/reporting/report_builder.py +18 -1
- 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 +408 -0
- package/src/devcouncil/storage/db.py +140 -3
- package/src/devcouncil/storage/models.py +125 -0
- package/src/devcouncil/storage/native.py +559 -0
- package/src/devcouncil/storage/repositories.py +157 -78
- package/src/devcouncil/telemetry/cost.py +123 -17
- package/src/devcouncil/telemetry/logging_setup.py +244 -0
- package/src/devcouncil/telemetry/model_pricing.yaml +48 -0
- package/src/devcouncil/telemetry/pricing.py +28 -0
- package/src/devcouncil/telemetry/stages.py +141 -0
- package/src/devcouncil/telemetry/traces.py +62 -7
- package/src/devcouncil/telemetry/tracker.py +24 -10
- package/src/devcouncil/ui/dashboard.py +393 -28
- package/src/devcouncil/utils/redaction.py +9 -3
- package/src/devcouncil/utils/subprocess_env.py +69 -0
- package/src/devcouncil/verification/acceptance_compiler.py +253 -0
- package/src/devcouncil/verification/ad_hoc_check.py +135 -0
- package/src/devcouncil/verification/diff_coverage.py +353 -0
- package/src/devcouncil/verification/implementation_reviewer.py +11 -2
- package/src/devcouncil/verification/next_actions.py +189 -0
- package/src/devcouncil/verification/sandbox.py +181 -0
- package/src/devcouncil/verification/test_resolver.py +91 -0
- package/src/devcouncil/verification/verifier.py +1549 -143
- package/uv.lock +205 -64
- package/src/devcouncil/indexing/symbol_index.py +0 -0
|
@@ -2,9 +2,11 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import hashlib
|
|
4
4
|
import json
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
5
7
|
from pathlib import Path
|
|
6
8
|
|
|
7
|
-
from devcouncil.live.models import AgentTurn, CardStatus, CritiqueCard
|
|
9
|
+
from devcouncil.live.models import AgentTurn, CardStatus, CritiqueCard, Verdict
|
|
8
10
|
|
|
9
11
|
RISK_TERMS = (
|
|
10
12
|
"skip tests",
|
|
@@ -31,21 +33,171 @@ EVIDENCE_TERMS = (
|
|
|
31
33
|
"verified",
|
|
32
34
|
)
|
|
33
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
|
+
|
|
34
133
|
|
|
35
|
-
def review_turn(
|
|
36
|
-
|
|
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
|
+
"""
|
|
37
147
|
content = turn.content.strip()
|
|
38
148
|
lower = content.lower()
|
|
39
149
|
concerns: list[str] = []
|
|
40
150
|
alternatives: list[str] = []
|
|
41
151
|
evidence_requests: list[str] = []
|
|
152
|
+
verdict: Verdict = "Approved"
|
|
153
|
+
|
|
154
|
+
grounding = _load_task_grounding(project_root, task_id)
|
|
42
155
|
|
|
43
156
|
risky_terms = [term for term in RISK_TERMS if term in lower]
|
|
44
157
|
if risky_terms:
|
|
45
158
|
concerns.append(f"Response contains risky implementation language: {', '.join(risky_terms[:4])}.")
|
|
46
159
|
alternatives.append("Replace risky shortcuts with a scoped implementation and explicit rollback or verification path.")
|
|
47
160
|
|
|
48
|
-
|
|
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.
|
|
49
201
|
concerns.append("The response appears to claim completion without naming verification evidence.")
|
|
50
202
|
evidence_requests.append("State the exact commands, checks, or reviewed artifacts that prove the change.")
|
|
51
203
|
|
|
@@ -56,8 +208,7 @@ def review_turn(turn: AgentTurn, project_root: Path, client: str | None = None)
|
|
|
56
208
|
if "todo" in lower or "follow-up" in lower or "later" in lower:
|
|
57
209
|
evidence_requests.append("List any remaining TODOs as DevCouncil gaps or repair tasks instead of burying them in chat.")
|
|
58
210
|
|
|
59
|
-
verdict
|
|
60
|
-
if concerns:
|
|
211
|
+
if concerns and verdict == "Approved":
|
|
61
212
|
verdict = "Concerns"
|
|
62
213
|
if any(term in lower for term in ("--no-verify", "reset --hard", "force push", "ignore failing")):
|
|
63
214
|
verdict = "Critical Issues"
|
|
@@ -69,9 +220,11 @@ def review_turn(turn: AgentTurn, project_root: Path, client: str | None = None)
|
|
|
69
220
|
message_for_agent = _message_for_agent(verdict, concerns, evidence_requests)
|
|
70
221
|
card_id = _card_id(turn)
|
|
71
222
|
return CritiqueCard(
|
|
223
|
+
schema="devcouncil.critique_card.v1",
|
|
72
224
|
id=card_id,
|
|
73
225
|
session_id=turn.session_id,
|
|
74
226
|
turn_id=turn.turn_id,
|
|
227
|
+
task_id=task_id,
|
|
75
228
|
client=client or turn.source,
|
|
76
229
|
verdict=verdict,
|
|
77
230
|
summary=summary,
|
|
@@ -146,11 +299,19 @@ def filter_cards(
|
|
|
146
299
|
return filtered, None, None
|
|
147
300
|
|
|
148
301
|
|
|
302
|
+
def load_card_by_id(project_root: Path, card_id: str) -> CritiqueCard | None:
|
|
303
|
+
"""Read a single card directly by id, avoiding a scan of every card file."""
|
|
304
|
+
path = card_path(project_root, card_id)
|
|
305
|
+
if not path.exists():
|
|
306
|
+
return None
|
|
307
|
+
try:
|
|
308
|
+
return CritiqueCard.model_validate(json.loads(path.read_text(encoding="utf-8")))
|
|
309
|
+
except Exception:
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
|
|
149
313
|
def get_card(project_root: Path, card_id: str) -> CritiqueCard | None:
|
|
150
|
-
|
|
151
|
-
if card.id == card_id:
|
|
152
|
-
return card
|
|
153
|
-
return None
|
|
314
|
+
return load_card_by_id(project_root, card_id)
|
|
154
315
|
|
|
155
316
|
|
|
156
317
|
def update_card_status(project_root: Path, card_id: str, status: CardStatus) -> CritiqueCard | None:
|
|
@@ -164,30 +325,25 @@ def update_card_status(project_root: Path, card_id: str, status: CardStatus) ->
|
|
|
164
325
|
return updated
|
|
165
326
|
|
|
166
327
|
|
|
167
|
-
def unresolved_blocking_cards(
|
|
328
|
+
def unresolved_blocking_cards(
|
|
329
|
+
project_root: Path,
|
|
330
|
+
task_id: str | None = None,
|
|
331
|
+
*,
|
|
332
|
+
cards: list[CritiqueCard] | None = None,
|
|
333
|
+
) -> list[CritiqueCard]:
|
|
334
|
+
source = cards if cards is not None else load_cards(project_root)
|
|
168
335
|
return [
|
|
169
|
-
card for card in
|
|
336
|
+
card for card in source
|
|
170
337
|
if card.status == "open" and card.verdict == "Critical Issues"
|
|
171
338
|
and (task_id is None or card.task_id in {None, task_id})
|
|
172
339
|
]
|
|
173
340
|
|
|
174
341
|
|
|
175
342
|
def _card_id(turn: AgentTurn) -> str:
|
|
176
|
-
digest = hashlib.
|
|
343
|
+
digest = hashlib.sha256(f"{turn.session_id}:{turn.turn_id}:{turn.content}".encode("utf-8")).hexdigest()
|
|
177
344
|
return f"CARD-{digest[:12]}"
|
|
178
345
|
|
|
179
346
|
|
|
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
347
|
def _mentions_broad_change(lower: str) -> bool:
|
|
192
348
|
return any(phrase in lower for phrase in (
|
|
193
349
|
"refactor the entire",
|
|
@@ -198,7 +354,7 @@ def _mentions_broad_change(lower: str) -> bool:
|
|
|
198
354
|
))
|
|
199
355
|
|
|
200
356
|
|
|
201
|
-
def _message_for_agent(verdict:
|
|
357
|
+
def _message_for_agent(verdict: Verdict, concerns: list[str], evidence_requests: list[str]) -> str:
|
|
202
358
|
if verdict == "Approved":
|
|
203
359
|
return "Continue, but keep the next response grounded in changed files and verification evidence."
|
|
204
360
|
pieces = ["Pause and address this review before proceeding."]
|
|
@@ -8,8 +8,16 @@ from devcouncil.storage.db import get_db
|
|
|
8
8
|
from devcouncil.storage.repositories import RequirementRepository, TaskRepository
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
def build_live_repair_prompt(
|
|
12
|
-
|
|
11
|
+
def build_live_repair_prompt(
|
|
12
|
+
project_root: Path,
|
|
13
|
+
card: CritiqueCard,
|
|
14
|
+
requirements: list | None = None,
|
|
15
|
+
) -> str:
|
|
16
|
+
"""Build a ready-to-paste repair prompt for a live-review critique card.
|
|
17
|
+
|
|
18
|
+
``requirements`` may be pre-fetched (e.g. by the bulk builder) to avoid
|
|
19
|
+
re-querying every requirement once per card. ``None`` fetches them as before.
|
|
20
|
+
"""
|
|
13
21
|
prompt = [
|
|
14
22
|
f"# Repair Live Review Card {card.id}",
|
|
15
23
|
"",
|
|
@@ -34,7 +42,7 @@ def build_live_repair_prompt(project_root: Path, card: CritiqueCard) -> str:
|
|
|
34
42
|
if card.message_for_agent:
|
|
35
43
|
prompt.extend(["", "## Message For Agent", card.message_for_agent])
|
|
36
44
|
|
|
37
|
-
task_prompt = _task_prompt(project_root, card.task_id)
|
|
45
|
+
task_prompt = _task_prompt(project_root, card.task_id, requirements=requirements)
|
|
38
46
|
if task_prompt:
|
|
39
47
|
prompt.extend(["", "## Original DevCouncil Task Contract", task_prompt])
|
|
40
48
|
|
|
@@ -59,17 +67,31 @@ def build_bulk_live_repair_prompt(project_root: Path, cards: list[CritiqueCard])
|
|
|
59
67
|
"",
|
|
60
68
|
f"DevCouncil found {len(cards)} blocking live-review card(s). Address each card below.",
|
|
61
69
|
]
|
|
70
|
+
requirements = _load_all_requirements(project_root)
|
|
62
71
|
for index, card in enumerate(cards, start=1):
|
|
63
72
|
sections.extend([
|
|
64
73
|
"",
|
|
65
74
|
f"---\n\n## Card {index}: {card.id}",
|
|
66
75
|
"",
|
|
67
|
-
build_live_repair_prompt(project_root, card).strip(),
|
|
76
|
+
build_live_repair_prompt(project_root, card, requirements=requirements).strip(),
|
|
68
77
|
])
|
|
69
78
|
return "\n".join(sections).rstrip() + "\n"
|
|
70
79
|
|
|
71
80
|
|
|
72
|
-
def
|
|
81
|
+
def _load_all_requirements(project_root: Path) -> list | None:
|
|
82
|
+
"""Fetch all requirements once; returns None when no DB is available."""
|
|
83
|
+
db = get_db(project_root)
|
|
84
|
+
if not db:
|
|
85
|
+
return None
|
|
86
|
+
with db.get_session() as session:
|
|
87
|
+
return RequirementRepository(session).get_all()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _task_prompt(
|
|
91
|
+
project_root: Path,
|
|
92
|
+
task_id: str | None,
|
|
93
|
+
requirements: list | None = None,
|
|
94
|
+
) -> str | None:
|
|
73
95
|
if not task_id:
|
|
74
96
|
return None
|
|
75
97
|
db = get_db(project_root)
|
|
@@ -79,5 +101,6 @@ def _task_prompt(project_root: Path, task_id: str | None) -> str | None:
|
|
|
79
101
|
task = TaskRepository(session).get_by_id(task_id)
|
|
80
102
|
if not task:
|
|
81
103
|
return None
|
|
82
|
-
requirements
|
|
104
|
+
if requirements is None:
|
|
105
|
+
requirements = RequirementRepository(session).get_all()
|
|
83
106
|
return PromptBuilder(project_root).build_task_prompt(task, requirements)
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import logging
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
|
|
5
6
|
from devcouncil.live.cards import review_turn
|
|
6
7
|
from devcouncil.live.models import AgentTurn, CritiqueCard
|
|
7
8
|
from devcouncil.llm.router import ModelRouter
|
|
8
9
|
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
9
12
|
|
|
10
13
|
class LiveReviewService:
|
|
11
14
|
"""Reviews coding-agent responses with deterministic or model-backed critique cards."""
|
|
@@ -23,6 +26,7 @@ class LiveReviewService:
|
|
|
23
26
|
) -> CritiqueCard:
|
|
24
27
|
fallback = review_turn(turn, project_root, client=client)
|
|
25
28
|
if not use_llm or self.router is None:
|
|
29
|
+
logger.debug("Live review (deterministic) for %s turn=%s: %s", client, turn.turn_id, fallback.verdict)
|
|
26
30
|
return fallback
|
|
27
31
|
|
|
28
32
|
prompt = f"""
|
|
@@ -46,21 +50,41 @@ Turn: {turn.turn_id}
|
|
|
46
50
|
Assistant response:
|
|
47
51
|
{turn.content}
|
|
48
52
|
"""
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
53
|
+
samples = self._samples(project_root)
|
|
54
|
+
cards: list[CritiqueCard] = []
|
|
55
|
+
for attempt in range(samples):
|
|
56
|
+
# Vary temperature so independent samples actually differ (and so the router
|
|
57
|
+
# cache returns distinct generations). Attempt 0 stays deterministic.
|
|
58
|
+
temperature = 0.0 if attempt == 0 else min(0.8, 0.3 + 0.2 * attempt)
|
|
59
|
+
try:
|
|
60
|
+
cards.append(await self.router.complete_structured(
|
|
61
|
+
role=self.role,
|
|
62
|
+
messages=[{"role": "user", "content": prompt}],
|
|
63
|
+
schema=CritiqueCard,
|
|
64
|
+
temperature=temperature,
|
|
65
|
+
))
|
|
66
|
+
except ValueError:
|
|
67
|
+
try:
|
|
68
|
+
cards.append(await self.router.complete_structured(
|
|
69
|
+
role="implementation_reviewer",
|
|
70
|
+
messages=[{"role": "user", "content": prompt}],
|
|
71
|
+
schema=CritiqueCard,
|
|
72
|
+
temperature=temperature,
|
|
73
|
+
))
|
|
74
|
+
except Exception:
|
|
75
|
+
continue
|
|
76
|
+
except Exception:
|
|
77
|
+
continue
|
|
78
|
+
|
|
79
|
+
if not cards:
|
|
80
|
+
logger.warning("Live review produced no cards for %s turn=%s; using deterministic fallback", client, turn.turn_id)
|
|
62
81
|
return fallback
|
|
63
82
|
|
|
83
|
+
reviewed = self._vote(cards)
|
|
84
|
+
logger.info(
|
|
85
|
+
"Live review (LLM, %d sample(s)) for %s turn=%s: %s",
|
|
86
|
+
len(cards), client, turn.turn_id, reviewed.verdict,
|
|
87
|
+
)
|
|
64
88
|
return reviewed.model_copy(update={
|
|
65
89
|
"id": fallback.id,
|
|
66
90
|
"session_id": turn.session_id,
|
|
@@ -68,3 +92,38 @@ Assistant response:
|
|
|
68
92
|
"client": client,
|
|
69
93
|
"source_path": fallback.source_path,
|
|
70
94
|
})
|
|
95
|
+
|
|
96
|
+
def _samples(self, project_root: Path) -> int:
|
|
97
|
+
try:
|
|
98
|
+
from devcouncil.app.config import load_config
|
|
99
|
+
return max(1, load_config(project_root).verification.reviewer_checks.samples)
|
|
100
|
+
except Exception:
|
|
101
|
+
return 1
|
|
102
|
+
|
|
103
|
+
@staticmethod
|
|
104
|
+
def _vote(cards: list[CritiqueCard]) -> CritiqueCard:
|
|
105
|
+
"""Majority-vote the verdict across independent reviews, then return a card whose
|
|
106
|
+
verdict matches the vote (so its concerns/evidence are consistent).
|
|
107
|
+
|
|
108
|
+
A single review is returned as-is. With several, the BLOCKING verdict
|
|
109
|
+
("Critical Issues") is chosen only on a strict majority, and "Approved" likewise;
|
|
110
|
+
anything else de-escalates to the non-blocking "Concerns". This prevents a lone
|
|
111
|
+
mis-calibrated reviewer from blocking, without ever auto-approving a real concern."""
|
|
112
|
+
if len(cards) == 1:
|
|
113
|
+
return cards[0]
|
|
114
|
+
from collections import Counter
|
|
115
|
+
|
|
116
|
+
counts = Counter(card.verdict for card in cards)
|
|
117
|
+
threshold = len(cards) / 2
|
|
118
|
+
if counts.get("Critical Issues", 0) > threshold:
|
|
119
|
+
verdict = "Critical Issues"
|
|
120
|
+
elif counts.get("Approved", 0) > threshold:
|
|
121
|
+
verdict = "Approved"
|
|
122
|
+
else:
|
|
123
|
+
verdict = "Concerns"
|
|
124
|
+
# Return a representative card with the voted verdict so concerns/evidence align;
|
|
125
|
+
# fall back to the first card if none matches (then override just the verdict).
|
|
126
|
+
for card in cards:
|
|
127
|
+
if card.verdict == verdict:
|
|
128
|
+
return card
|
|
129
|
+
return cards[0].model_copy(update={"verdict": verdict})
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import hashlib
|
|
3
4
|
import json
|
|
4
5
|
from datetime import datetime, timezone
|
|
5
6
|
from pathlib import Path
|
|
@@ -42,9 +43,8 @@ def write_signal(project_root: Path, client: str, payload: dict[str, Any]) -> Pa
|
|
|
42
43
|
review_command=_review_command(client.lower(), transcript_path, task_id),
|
|
43
44
|
)
|
|
44
45
|
key = transcript_path or session_id or json.dumps(payload, sort_keys=True, default=str)
|
|
45
|
-
import hashlib
|
|
46
46
|
|
|
47
|
-
digest = hashlib.
|
|
47
|
+
digest = hashlib.sha256(key.encode("utf-8", errors="replace")).hexdigest()[:12]
|
|
48
48
|
path = directory / f"{client.lower()}-{digest}.json"
|
|
49
49
|
signal.path = str(path)
|
|
50
50
|
path.write_text(signal.model_dump_json(indent=2) + "\n", encoding="utf-8")
|
|
@@ -12,8 +12,21 @@ def live_review_summary(project_root: Path, task_id: str | None = None) -> dict:
|
|
|
12
12
|
signals = load_signals(project_root)
|
|
13
13
|
active_id = active_task_id(project_root)
|
|
14
14
|
scoped_task_id = task_id or active_id
|
|
15
|
-
blockers = unresolved_blocking_cards(project_root, task_id=scoped_task_id)
|
|
15
|
+
blockers = unresolved_blocking_cards(project_root, task_id=scoped_task_id, cards=cards)
|
|
16
16
|
pending_signal_items = [signal.model_dump() for signal in signals]
|
|
17
|
+
open_count = 0
|
|
18
|
+
resolved_count = 0
|
|
19
|
+
ignored_count = 0
|
|
20
|
+
critical_open_count = 0
|
|
21
|
+
for card in cards:
|
|
22
|
+
if card.status == "open":
|
|
23
|
+
open_count += 1
|
|
24
|
+
if card.verdict == "Critical Issues":
|
|
25
|
+
critical_open_count += 1
|
|
26
|
+
elif card.status == "resolved":
|
|
27
|
+
resolved_count += 1
|
|
28
|
+
elif card.status == "ignored":
|
|
29
|
+
ignored_count += 1
|
|
17
30
|
return {
|
|
18
31
|
"active_task_id": active_id,
|
|
19
32
|
"scope_task_id": scoped_task_id,
|
|
@@ -21,13 +34,10 @@ def live_review_summary(project_root: Path, task_id: str | None = None) -> dict:
|
|
|
21
34
|
"pending_signal_items": pending_signal_items[:10],
|
|
22
35
|
"cards": {
|
|
23
36
|
"total": len(cards),
|
|
24
|
-
"open":
|
|
25
|
-
"resolved":
|
|
26
|
-
"ignored":
|
|
27
|
-
"critical_open":
|
|
28
|
-
card for card in cards
|
|
29
|
-
if card.status == "open" and card.verdict == "Critical Issues"
|
|
30
|
-
]),
|
|
37
|
+
"open": open_count,
|
|
38
|
+
"resolved": resolved_count,
|
|
39
|
+
"ignored": ignored_count,
|
|
40
|
+
"critical_open": critical_open_count,
|
|
31
41
|
},
|
|
32
42
|
"blocking_cards": [card.model_dump() for card in blockers],
|
|
33
43
|
"recent_cards": [card.model_dump() for card in cards[:5]],
|
|
@@ -2,10 +2,13 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import json
|
|
4
4
|
from pathlib import Path
|
|
5
|
-
from typing import Any, Iterable
|
|
5
|
+
from typing import Any, Iterable, Literal
|
|
6
6
|
|
|
7
7
|
from devcouncil.live.models import AgentSession, AgentTurn, session_id_from_path
|
|
8
8
|
|
|
9
|
+
RoleName = Literal["user", "assistant", "system", "tool", "unknown"]
|
|
10
|
+
KNOWN_ROLES: set[RoleName] = {"user", "assistant", "system", "tool"}
|
|
11
|
+
|
|
9
12
|
|
|
10
13
|
CLAUDE_TRANSCRIPT_ROOT = Path.home() / ".claude" / "projects"
|
|
11
14
|
|
|
@@ -28,9 +31,19 @@ def discover_sessions(project_root: Path, client: str = "claude") -> list[AgentS
|
|
|
28
31
|
client=client,
|
|
29
32
|
transcript_path=str(path),
|
|
30
33
|
updated_at=str(stat.st_mtime),
|
|
31
|
-
|
|
34
|
+
# len() over the already-materialized splitlines list avoids a
|
|
35
|
+
# second Python-level pass and matches the previous line count.
|
|
36
|
+
turns=len(_safe_lines(path)),
|
|
32
37
|
))
|
|
33
|
-
|
|
38
|
+
def _updated_key(item: AgentSession) -> float:
|
|
39
|
+
# updated_at is a stringified mtime; sort numerically so timestamps with
|
|
40
|
+
# different digit counts (string sort would misorder them) compare correctly.
|
|
41
|
+
try:
|
|
42
|
+
return float(item.updated_at or 0.0)
|
|
43
|
+
except (TypeError, ValueError):
|
|
44
|
+
return 0.0
|
|
45
|
+
|
|
46
|
+
return sorted(sessions, key=_updated_key, reverse=True)
|
|
34
47
|
|
|
35
48
|
|
|
36
49
|
def load_turns(path: Path, client: str = "generic") -> list[AgentTurn]:
|
|
@@ -47,22 +60,45 @@ def load_turns(path: Path, client: str = "generic") -> list[AgentTurn]:
|
|
|
47
60
|
return turns
|
|
48
61
|
|
|
49
62
|
|
|
50
|
-
|
|
63
|
+
# mtime-keyed cache: reloading and reversing every turn just to find the last
|
|
64
|
+
# assistant message is wasteful when the transcript hasn't changed.
|
|
65
|
+
_LATEST_ASSISTANT_CACHE: dict[str, tuple[float, AgentTurn | None]] = {}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _scan_latest_assistant_turn(path: Path, client: str) -> AgentTurn | None:
|
|
51
69
|
for turn in reversed(load_turns(path, client=client)):
|
|
52
70
|
if turn.role == "assistant":
|
|
53
71
|
return turn
|
|
54
72
|
return None
|
|
55
73
|
|
|
56
74
|
|
|
75
|
+
def latest_assistant_turn(path: Path, client: str = "generic") -> AgentTurn | None:
|
|
76
|
+
try:
|
|
77
|
+
mtime = path.stat().st_mtime
|
|
78
|
+
except OSError:
|
|
79
|
+
return _scan_latest_assistant_turn(path, client)
|
|
80
|
+
key = f"{path}\x00{client}"
|
|
81
|
+
cached = _LATEST_ASSISTANT_CACHE.get(key)
|
|
82
|
+
if cached is not None and cached[0] == mtime:
|
|
83
|
+
return cached[1]
|
|
84
|
+
result = _scan_latest_assistant_turn(path, client)
|
|
85
|
+
_LATEST_ASSISTANT_CACHE[key] = (mtime, result)
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
|
|
57
89
|
def _claude_transcript_candidates(project_root: Path) -> list[Path]:
|
|
58
90
|
local_runtime = project_root / ".devcouncil" / "live" / "claude"
|
|
59
91
|
candidates = list(local_runtime.glob("*.jsonl"))
|
|
60
92
|
if CLAUDE_TRANSCRIPT_ROOT.exists():
|
|
61
93
|
candidates.extend(CLAUDE_TRANSCRIPT_ROOT.rglob("*.jsonl"))
|
|
62
|
-
|
|
94
|
+
# Dedup, then sort by path for a deterministic, stat-free order. discover_sessions
|
|
95
|
+
# re-sorts the resulting sessions by mtime; because that sort is stable, giving it a
|
|
96
|
+
# deterministic input keeps the tie-break (equal-mtime sessions) stable across runs —
|
|
97
|
+
# whereas an unordered set would let ties reorder run to run.
|
|
98
|
+
return sorted(set(candidates))
|
|
63
99
|
|
|
64
100
|
|
|
65
|
-
def _safe_lines(path: Path) ->
|
|
101
|
+
def _safe_lines(path: Path) -> list[str]:
|
|
66
102
|
try:
|
|
67
103
|
return path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
68
104
|
except OSError:
|
|
@@ -98,18 +134,18 @@ def _turn_from_record(raw: dict[str, Any], session_id: str, turn_index: int, cli
|
|
|
98
134
|
)
|
|
99
135
|
|
|
100
136
|
|
|
101
|
-
def _role(raw: dict[str, Any]) ->
|
|
137
|
+
def _role(raw: dict[str, Any]) -> RoleName:
|
|
102
138
|
role = raw.get("role")
|
|
103
139
|
if isinstance(role, str):
|
|
104
|
-
return role if role in
|
|
140
|
+
return role if role in KNOWN_ROLES else "unknown"
|
|
105
141
|
message = raw.get("message")
|
|
106
142
|
if isinstance(message, dict):
|
|
107
143
|
nested = message.get("role")
|
|
108
144
|
if isinstance(nested, str):
|
|
109
|
-
return nested if nested in
|
|
145
|
+
return nested if nested in KNOWN_ROLES else "unknown"
|
|
110
146
|
record_type = raw.get("type")
|
|
111
|
-
if record_type in
|
|
112
|
-
return
|
|
147
|
+
if record_type in KNOWN_ROLES:
|
|
148
|
+
return record_type
|
|
113
149
|
return "unknown"
|
|
114
150
|
|
|
115
151
|
|