misterdev 0.2.0__py3-none-any.whl
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.
- misterdev/__init__.py +3 -0
- misterdev/agent.py +2166 -0
- misterdev/agent_helpers.py +194 -0
- misterdev/analyzers/__init__.py +0 -0
- misterdev/analyzers/project_analyzer/__init__.py +246 -0
- misterdev/analyzers/project_analyzer/detection.py +146 -0
- misterdev/analyzers/project_analyzer/merge.py +137 -0
- misterdev/analyzers/project_analyzer/overview.py +312 -0
- misterdev/analyzers/project_analyzer/prompts.py +83 -0
- misterdev/cli.py +370 -0
- misterdev/config.py +521 -0
- misterdev/core/__init__.py +0 -0
- misterdev/core/audit.py +88 -0
- misterdev/core/config.py +10 -0
- misterdev/core/context/__init__.py +0 -0
- misterdev/core/context/change_tracker.py +218 -0
- misterdev/core/context/contracts/__init__.py +63 -0
- misterdev/core/context/contracts/_log.py +9 -0
- misterdev/core/context/contracts/_text.py +12 -0
- misterdev/core/context/contracts/extraction.py +30 -0
- misterdev/core/context/contracts/python_generic.py +42 -0
- misterdev/core/context/contracts/registry.py +127 -0
- misterdev/core/context/contracts/rust_line.py +225 -0
- misterdev/core/context/contracts/rust_tree_sitter.py +141 -0
- misterdev/core/context/lsp.py +174 -0
- misterdev/core/context/scratchpad.py +111 -0
- misterdev/core/context/topography/__init__.py +43 -0
- misterdev/core/context/topography/_log.py +10 -0
- misterdev/core/context/topography/cache.py +91 -0
- misterdev/core/context/topography/engine.py +172 -0
- misterdev/core/context/topography/graph.py +675 -0
- misterdev/core/context/topography/nodes.py +55 -0
- misterdev/core/context/topography/parsers.py +95 -0
- misterdev/core/context/topography/syntax.py +54 -0
- misterdev/core/economics/__init__.py +0 -0
- misterdev/core/economics/context_budget.py +175 -0
- misterdev/core/economics/embeddings.py +232 -0
- misterdev/core/economics/free_models.py +108 -0
- misterdev/core/economics/llm_cache.py +105 -0
- misterdev/core/economics/model_catalog.py +79 -0
- misterdev/core/economics/model_ledger.py +331 -0
- misterdev/core/economics/model_selector.py +281 -0
- misterdev/core/execution/__init__.py +0 -0
- misterdev/core/execution/bounded.py +50 -0
- misterdev/core/execution/container.py +221 -0
- misterdev/core/execution/error_classifier.py +366 -0
- misterdev/core/execution/error_resolver.py +201 -0
- misterdev/core/execution/governance.py +283 -0
- misterdev/core/execution/outcomes.py +50 -0
- misterdev/core/execution/progress.py +120 -0
- misterdev/core/execution/project.py +231 -0
- misterdev/core/execution/registry.py +97 -0
- misterdev/core/execution/runtime.py +279 -0
- misterdev/core/gitcmd.py +39 -0
- misterdev/core/integration/__init__.py +0 -0
- misterdev/core/integration/mcp.py +368 -0
- misterdev/core/integration/mcp_gather.py +186 -0
- misterdev/core/models.py +35 -0
- misterdev/core/modes.py +184 -0
- misterdev/core/planning/__init__.py +0 -0
- misterdev/core/planning/advisor.py +89 -0
- misterdev/core/planning/assessment.py +135 -0
- misterdev/core/planning/decomposer.py +387 -0
- misterdev/core/planning/metacognition.py +103 -0
- misterdev/core/planning/sovereign.py +308 -0
- misterdev/core/planning/targets.py +201 -0
- misterdev/core/reporting/__init__.py +0 -0
- misterdev/core/reporting/report.py +377 -0
- misterdev/core/reporting/report_view.py +151 -0
- misterdev/core/task.py +163 -0
- misterdev/core/verification/__init__.py +0 -0
- misterdev/core/verification/claim_verifier.py +210 -0
- misterdev/core/verification/critic.py +324 -0
- misterdev/core/verification/gatekeeper/__init__.py +631 -0
- misterdev/core/verification/gatekeeper/constants.py +138 -0
- misterdev/core/verification/gatekeeper/helpers.py +28 -0
- misterdev/core/verification/goal_check.py +219 -0
- misterdev/core/verification/independent.py +68 -0
- misterdev/core/verification/mutation_gate.py +221 -0
- misterdev/core/verification/preflight.py +95 -0
- misterdev/core/verification/spec_tests.py +175 -0
- misterdev/core/verification/validator.py +495 -0
- misterdev/core/verification/vision_verify.py +185 -0
- misterdev/core/verification/web_verify.py +408 -0
- misterdev/environments/__init__.py +0 -0
- misterdev/environments/base_env.py +18 -0
- misterdev/environments/container_env.py +87 -0
- misterdev/environments/venv_env.py +42 -0
- misterdev/llm/__init__.py +0 -0
- misterdev/llm/client/__init__.py +152 -0
- misterdev/llm/client/base.py +382 -0
- misterdev/llm/client/edits.py +70 -0
- misterdev/llm/client/embeddings.py +121 -0
- misterdev/llm/client/errors.py +134 -0
- misterdev/llm/client/providers.py +535 -0
- misterdev/llm/client/response.py +24 -0
- misterdev/llm/prompt_manager.py +82 -0
- misterdev/llm/responses/__init__.py +34 -0
- misterdev/llm/responses/apply.py +131 -0
- misterdev/llm/responses/json_extract.py +80 -0
- misterdev/llm/responses/models.py +43 -0
- misterdev/llm/responses/parsing.py +494 -0
- misterdev/logging_setup.py +20 -0
- misterdev/mcp_server.py +208 -0
- misterdev/nl_cli.py +149 -0
- misterdev/plugins.py +115 -0
- misterdev/py.typed +0 -0
- misterdev/task_executors/__init__.py +0 -0
- misterdev/task_executors/base_executor.py +10 -0
- misterdev/task_executors/markdown_plan_executor/__init__.py +90 -0
- misterdev/task_executors/markdown_plan_executor/commands_mixin.py +82 -0
- misterdev/task_executors/markdown_plan_executor/context_mixin.py +221 -0
- misterdev/task_executors/markdown_plan_executor/critic_spec_mixin.py +174 -0
- misterdev/task_executors/markdown_plan_executor/edits_mixin.py +251 -0
- misterdev/task_executors/markdown_plan_executor/execute_mixin.py +727 -0
- misterdev/task_executors/markdown_plan_executor/gates_mixin.py +203 -0
- misterdev/task_executors/markdown_plan_executor/git_mixin.py +219 -0
- misterdev/task_executors/markdown_plan_executor/helpers.py +521 -0
- misterdev/task_executors/markdown_plan_executor/llm_mixin.py +238 -0
- misterdev/task_executors/markdown_plan_executor/results_mixin.py +23 -0
- misterdev/tools/__init__.py +19 -0
- misterdev/tools/base_tool.py +14 -0
- misterdev/tools/command.py +75 -0
- misterdev/tools/file_io.py +69 -0
- misterdev/tools/formatter.py +26 -0
- misterdev/tools/git_tool.py +90 -0
- misterdev/utils/__init__.py +0 -0
- misterdev/utils/file_utils.py +169 -0
- misterdev/utils/process.py +23 -0
- misterdev-0.2.0.dist-info/METADATA +326 -0
- misterdev-0.2.0.dist-info/RECORD +136 -0
- misterdev-0.2.0.dist-info/WHEEL +5 -0
- misterdev-0.2.0.dist-info/entry_points.txt +3 -0
- misterdev-0.2.0.dist-info/licenses/COMMERCIAL_LICENSE.md +34 -0
- misterdev-0.2.0.dist-info/licenses/LICENSE +661 -0
- misterdev-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Independent verification of completeness claims before they become tasks.
|
|
2
|
+
|
|
3
|
+
The completeness analyzer flags features as "incomplete" or files as "stubs"
|
|
4
|
+
from a lossy project overview, so it can mislabel deliberate design — a
|
|
5
|
+
graceful-degradation path, a platform-gated no-op, a parity shim, a documented
|
|
6
|
+
fallback — as unfinished work. Acting on that wastes budget and risks rewriting
|
|
7
|
+
code that was correct by design (observed: a documented wasm degrade-to-empty
|
|
8
|
+
backend planned as a "fix the stub" task).
|
|
9
|
+
|
|
10
|
+
This gate gives each such CLAIM a second, INDEPENDENT look at the REAL file (and
|
|
11
|
+
whatever evidence the caller assembled — the file body, tests that exercise it,
|
|
12
|
+
the verified build/test state) and drops only the claims it can REFUTE with
|
|
13
|
+
evidence. Anything it confirms, or is unsure about, is KEPT — the burden of proof
|
|
14
|
+
is on dropping, so genuine work is never silently lost on a guess. It mirrors the
|
|
15
|
+
edit-time adversarial critic and the goal-completion judge: advisory and
|
|
16
|
+
best-effort, routed through an independent model when one is configured, and a
|
|
17
|
+
SKIP (keep the claim) on no client, an unparseable verdict, any error, or the
|
|
18
|
+
hard timeout. The decomposer and gates remain the ground truth.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Callable, List, Optional
|
|
24
|
+
|
|
25
|
+
from misterdev.core.execution.bounded import run_bounded
|
|
26
|
+
from misterdev.core.verification.independent import build_independent_call
|
|
27
|
+
from misterdev.llm.responses import (
|
|
28
|
+
extract_json_object as _extract_json_object,
|
|
29
|
+
)
|
|
30
|
+
from misterdev.logging_setup import setup_logger
|
|
31
|
+
|
|
32
|
+
logger = setup_logger(__name__)
|
|
33
|
+
|
|
34
|
+
# A claim is KEPT (acted on) unless the verifier REFUTES it with evidence.
|
|
35
|
+
CONFIRMED = "confirmed"
|
|
36
|
+
REFUTED = "refuted"
|
|
37
|
+
KEPT = "kept"
|
|
38
|
+
|
|
39
|
+
# A verifier call takes the assembled prompt text and returns the model's text.
|
|
40
|
+
# Injected in tests; defaulted to the project client's generate_code path.
|
|
41
|
+
VerifyCall = Callable[[str], str]
|
|
42
|
+
|
|
43
|
+
_VERIFY_PROMPT = """A project analyzer claimed the following is INCOMPLETE work \
|
|
44
|
+
that needs implementing. Decide whether that claim is REAL or a FALSE POSITIVE.
|
|
45
|
+
|
|
46
|
+
It is a FALSE POSITIVE (refute it) when the code is intentional and complete:
|
|
47
|
+
- a documented graceful-degradation or fallback path (returns empty/default BY
|
|
48
|
+
DESIGN, e.g. a missing-model or no-network contract),
|
|
49
|
+
- a platform-gated no-op (e.g. a wasm/no-filesystem backend), a parity shim, or
|
|
50
|
+
a deliberate placeholder the design says is correct,
|
|
51
|
+
- a capability that already builds and is covered by passing tests.
|
|
52
|
+
|
|
53
|
+
It is REAL (confirm it) only when there is a concrete, unmet behavior the code is
|
|
54
|
+
supposed to provide but does not. Judge the SOURCE and tests, not names or docs
|
|
55
|
+
plans. If the evidence does not let you decide, say unsure — do not guess "real".
|
|
56
|
+
|
|
57
|
+
## Claim ({kind})
|
|
58
|
+
{label}: {description}
|
|
59
|
+
|
|
60
|
+
## Evidence (real source / tests / verified build+test state)
|
|
61
|
+
{evidence}
|
|
62
|
+
|
|
63
|
+
Return ONLY a JSON object, no prose or fences:
|
|
64
|
+
{{"real": true|false|null, "reason": "<one sentence citing the evidence>"}}
|
|
65
|
+
where false = false positive (intentional/complete), true = genuinely incomplete,
|
|
66
|
+
null = unsure."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class Claim:
|
|
71
|
+
"""One completeness claim to verify.
|
|
72
|
+
|
|
73
|
+
``kind`` is "incomplete" or "stub"; ``label`` is its identity (feature name
|
|
74
|
+
or file path) used to drop it from the assessment; ``description`` and
|
|
75
|
+
``evidence`` are the text shown to the verifier (real file body, tests, and
|
|
76
|
+
the verified build/test state — assembled by the caller).
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
kind: str
|
|
80
|
+
label: str
|
|
81
|
+
description: str = ""
|
|
82
|
+
evidence: str = ""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class ClaimVerdict:
|
|
87
|
+
"""Outcome for one claim. ``status`` is CONFIRMED / REFUTED / KEPT.
|
|
88
|
+
|
|
89
|
+
REFUTED means drop the claim (a false positive). CONFIRMED and KEPT both keep
|
|
90
|
+
it; KEPT covers "unsure" and every skip/error path, so uncertainty never
|
|
91
|
+
drops real work. ``reason`` is the model's one-line justification (or the
|
|
92
|
+
skip reason); ``raw`` is its text.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
claim: Claim
|
|
96
|
+
status: str
|
|
97
|
+
reason: str = ""
|
|
98
|
+
raw: str = ""
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def refuted(self) -> bool:
|
|
102
|
+
return self.status == REFUTED
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# Evidence cap per claim so a huge file can't blow up the prompt or cost; the
|
|
106
|
+
# head carries the most signal (module docs, signatures, the flagged symbols).
|
|
107
|
+
_MAX_EVIDENCE_CHARS = 12000
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def verify_claims(
|
|
111
|
+
claims: List[Claim],
|
|
112
|
+
verify_call: Optional[VerifyCall] = None,
|
|
113
|
+
llm_client=None,
|
|
114
|
+
model: Optional[str] = None,
|
|
115
|
+
timeout: float = 45,
|
|
116
|
+
) -> List[ClaimVerdict]:
|
|
117
|
+
"""Verify each claim independently; REFUTE only false positives, else KEEP.
|
|
118
|
+
|
|
119
|
+
The model call is performed by ``verify_call`` when supplied (the test seam);
|
|
120
|
+
otherwise one is built from ``llm_client``. ``model``, when given, routes the
|
|
121
|
+
judgment through an INDEPENDENT model so it does not share the generator's
|
|
122
|
+
blind spots (its absence is logged — the same-model verifier is weaker).
|
|
123
|
+
|
|
124
|
+
With no callable and no client, every claim is KEPT (no verifier available),
|
|
125
|
+
so the behavior is byte-identical to not running the gate. ``timeout`` is the
|
|
126
|
+
hard ceiling PER claim; an unparseable verdict, any error, or a timeout KEEPS
|
|
127
|
+
that claim. Independent claims fan out across a small thread pool (the
|
|
128
|
+
analyzers do the same) EXCEPT when an independent ``model`` is configured:
|
|
129
|
+
routing through it uses ``with_model``, which mutates shared client state and
|
|
130
|
+
is not thread-safe, so that path stays sequential. Returns one verdict per
|
|
131
|
+
input claim, in order.
|
|
132
|
+
"""
|
|
133
|
+
if not claims:
|
|
134
|
+
return []
|
|
135
|
+
|
|
136
|
+
call = verify_call or build_independent_call(
|
|
137
|
+
llm_client,
|
|
138
|
+
"You are a precise code reviewer. Return only valid JSON.",
|
|
139
|
+
model,
|
|
140
|
+
"Completeness-claim verifier",
|
|
141
|
+
)
|
|
142
|
+
if call is None:
|
|
143
|
+
return [
|
|
144
|
+
ClaimVerdict(c, KEPT, reason="no LLM verifier available") for c in claims
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
def judge(claim: Claim) -> ClaimVerdict:
|
|
148
|
+
prompt = _VERIFY_PROMPT.format(
|
|
149
|
+
kind=claim.kind,
|
|
150
|
+
label=claim.label or "(unnamed)",
|
|
151
|
+
description=(claim.description or "(no description)").strip(),
|
|
152
|
+
evidence=(claim.evidence or "(no evidence assembled)").strip()[
|
|
153
|
+
:_MAX_EVIDENCE_CHARS
|
|
154
|
+
],
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def _work() -> ClaimVerdict:
|
|
158
|
+
try:
|
|
159
|
+
return _parse_verdict(claim, call(prompt) or "")
|
|
160
|
+
except Exception as e: # any model/IO failure is non-fatal -> keep
|
|
161
|
+
logger.debug(f"Claim verifier unavailable for {claim.label!r}: {e}")
|
|
162
|
+
return ClaimVerdict(claim, KEPT, reason=f"error: {e}")
|
|
163
|
+
|
|
164
|
+
return run_bounded(
|
|
165
|
+
_work,
|
|
166
|
+
timeout,
|
|
167
|
+
ClaimVerdict(claim, KEPT, reason="timed out"),
|
|
168
|
+
"Completeness-claim verifier",
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# `model` set -> per-call with_model() is not thread-safe -> sequential.
|
|
172
|
+
if model or len(claims) == 1:
|
|
173
|
+
return [judge(c) for c in claims]
|
|
174
|
+
with ThreadPoolExecutor(max_workers=min(4, len(claims))) as pool:
|
|
175
|
+
return list(pool.map(judge, claims))
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _parse_verdict(claim: Claim, text: str) -> ClaimVerdict:
|
|
179
|
+
"""Parse the verifier's JSON verdict; default to KEEP on any ambiguity.
|
|
180
|
+
|
|
181
|
+
Only an explicit ``{"real": false}`` REFUTES (drops) the claim. ``real`` true
|
|
182
|
+
CONFIRMS it; ``real`` null, a missing/invalid object, or a missing/non-bool
|
|
183
|
+
``real`` all KEEP it — the claim is acted on unless the verifier positively
|
|
184
|
+
refutes it, so uncertainty never silently removes real work.
|
|
185
|
+
"""
|
|
186
|
+
if not text or not text.strip():
|
|
187
|
+
return ClaimVerdict(claim, KEPT, reason="empty verdict", raw=text)
|
|
188
|
+
|
|
189
|
+
obj = _extract_json_object(text)
|
|
190
|
+
if obj is None or "real" not in obj:
|
|
191
|
+
return ClaimVerdict(claim, KEPT, reason="unparseable verdict", raw=text)
|
|
192
|
+
|
|
193
|
+
real = obj.get("real")
|
|
194
|
+
reason = obj.get("reason")
|
|
195
|
+
reason = reason.strip() if isinstance(reason, str) and reason.strip() else ""
|
|
196
|
+
if real is False:
|
|
197
|
+
# Surface a content-free refute honestly (it dropped a claim with no stated
|
|
198
|
+
# justification) instead of fabricating a confident-sounding rationale.
|
|
199
|
+
return ClaimVerdict(
|
|
200
|
+
claim,
|
|
201
|
+
REFUTED,
|
|
202
|
+
reason=reason or "refuted without a stated reason",
|
|
203
|
+
raw=text,
|
|
204
|
+
)
|
|
205
|
+
if real is True:
|
|
206
|
+
return ClaimVerdict(
|
|
207
|
+
claim, CONFIRMED, reason=reason or "genuinely incomplete", raw=text
|
|
208
|
+
)
|
|
209
|
+
# real is null / non-boolean / anything else -> keep (unsure).
|
|
210
|
+
return ClaimVerdict(claim, KEPT, reason=reason or "unsure", raw=text)
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""Optional adversarial edit critic (independent second component).
|
|
2
|
+
|
|
3
|
+
A single generator shares its own blind spots: a model that misreads a
|
|
4
|
+
requirement, misses an edge case, or "fixes" a symptom instead of the cause will
|
|
5
|
+
not catch that by re-reading its own output — the error and the reviewer are the
|
|
6
|
+
same component. This gate adds a SECOND, deliberately independent component that
|
|
7
|
+
reviews a CANDIDATE edit *before it is applied* and either APPROVES it or returns
|
|
8
|
+
concrete, actionable objections. Objections are fed back to the generator as the
|
|
9
|
+
next attempt's error context, forming a generate -> critique -> regenerate loop.
|
|
10
|
+
|
|
11
|
+
Independence is the whole point. A critic that shares the generator's model,
|
|
12
|
+
prompt, and training mostly rationalizes the first answer (self-critique is
|
|
13
|
+
weakest exactly when the model is confidently wrong). Configure ``critic.model``
|
|
14
|
+
to a DIFFERENT model for the real benefit; without it the gate still runs on the
|
|
15
|
+
generator's own model with adversarial framing, but the signal is weaker — and
|
|
16
|
+
that weakness is logged so it is never silently assumed away.
|
|
17
|
+
|
|
18
|
+
The critic is ADVISORY, never authoritative. The deterministic build/test/lint
|
|
19
|
+
gates remain the ground truth; this only catches semantic blind spots earlier
|
|
20
|
+
and more cheaply than a full gate run, and it defers to the gates after
|
|
21
|
+
``max_rejections`` regenerations so an over-zealous critic can't starve the loop.
|
|
22
|
+
|
|
23
|
+
Mirrors :mod:`misterdev.core.verification.goal_check`: best-effort and run in a
|
|
24
|
+
daemon worker thread with a hard timeout so a slow or unreachable model can NEVER
|
|
25
|
+
block a build. No client, no candidate edit, an unparseable verdict, or a timeout
|
|
26
|
+
is a SKIP (no opinion) that lets the edit proceed untouched.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
30
|
+
from typing import Callable, Dict, List, Optional
|
|
31
|
+
|
|
32
|
+
from misterdev.core.execution.bounded import run_bounded
|
|
33
|
+
from misterdev.core.verification.goal_check import _extract_json_object
|
|
34
|
+
from misterdev.core.verification.independent import build_independent_call
|
|
35
|
+
from misterdev.logging_setup import setup_logger
|
|
36
|
+
|
|
37
|
+
logger = setup_logger(__name__)
|
|
38
|
+
|
|
39
|
+
# Perspective lenses for a multi-member panel. Each member reviews the same
|
|
40
|
+
# change through a different lens so the panel catches failure modes a single
|
|
41
|
+
# reviewer (or N identical reviewers) would share — diversity, not redundancy.
|
|
42
|
+
_LENSES = (
|
|
43
|
+
"Focus above all on CORRECTNESS: does the logic actually do what the task asks?",
|
|
44
|
+
"Focus above all on EDGE CASES: empty/null/zero/maximum inputs and concurrency.",
|
|
45
|
+
"Focus above all on SAFETY: resource leaks, swallowed errors, and security holes.",
|
|
46
|
+
"Focus above all on REQUIREMENTS: is any acceptance criterion unmet or misread?",
|
|
47
|
+
"Focus above all on ROOT CAUSE: does this fix the underlying defect, or only "
|
|
48
|
+
"patch a symptom (a localized workaround that leaves the real cause in place)?",
|
|
49
|
+
"Focus above all on DUPLICATION (DRY): does this re-implement logic that already "
|
|
50
|
+
"exists instead of reusing an existing function/type/module?",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Outcome constants. SKIP means "no opinion" (no client/edit, an unparseable
|
|
54
|
+
# verdict, or a timeout) and must let the edit proceed — never a rejection.
|
|
55
|
+
SKIP = "skip"
|
|
56
|
+
APPROVED = "approved"
|
|
57
|
+
REJECTED = "rejected"
|
|
58
|
+
|
|
59
|
+
_PROMPT = (
|
|
60
|
+
"You are an adversarial code reviewer. Your job is to find what is WRONG with "
|
|
61
|
+
"a candidate change BEFORE it is applied — assume it is flawed and try to "
|
|
62
|
+
"prove it. Judge only the change shown against the task and acceptance "
|
|
63
|
+
"criteria; do not rewrite it.\n\n"
|
|
64
|
+
"## Task\n{task}\n\n"
|
|
65
|
+
"## Acceptance Criteria\n{criteria}\n\n"
|
|
66
|
+
"## Candidate Change\n{candidate}\n\n"
|
|
67
|
+
"Look specifically for: misread or unmet requirements, logic errors, "
|
|
68
|
+
"unhandled edge cases (empty/null/zero/max, concurrency), resource leaks, "
|
|
69
|
+
"swallowed errors, and security holes. Also reject if the change fixes a "
|
|
70
|
+
"SYMPTOM rather than the ROOT CAUSE (a localized patch that leaves the "
|
|
71
|
+
"underlying defect in place), or if it DUPLICATES logic that already exists "
|
|
72
|
+
"instead of reusing it (violates DRY). Ignore pure style.\n\n"
|
|
73
|
+
"Reply with a single JSON object on the first line and nothing else:\n"
|
|
74
|
+
'{{"approved": true|false, "objections": ["one concrete, actionable problem '
|
|
75
|
+
'per item"]}}\n'
|
|
76
|
+
"If approved is true, objections must be an empty list. Only reject for a "
|
|
77
|
+
"concrete defect you can name — not a vague preference. Keep each objection to "
|
|
78
|
+
"one sentence."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Cap the candidate evidence so a large edit can't blow up the prompt or cost;
|
|
82
|
+
# the head of each file carries the signatures and new logic that matter most.
|
|
83
|
+
_MAX_CANDIDATE_CHARS = 16000
|
|
84
|
+
|
|
85
|
+
# A critic call takes the assembled prompt text and returns the model's text.
|
|
86
|
+
# Injected in tests; defaulted to the project client's generate_code path.
|
|
87
|
+
CriticCall = Callable[[str], str]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class CritiqueVerdict:
|
|
91
|
+
"""Outcome of an adversarial edit critique.
|
|
92
|
+
|
|
93
|
+
``status`` is SKIP / APPROVED / REJECTED. ``objections`` lists the concrete
|
|
94
|
+
problems (empty unless REJECTED). ``raw`` is the model's text (evidence);
|
|
95
|
+
``reason`` explains a SKIP.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
status: str,
|
|
101
|
+
objections: Optional[List[str]] = None,
|
|
102
|
+
raw: str = "",
|
|
103
|
+
reason: str = "",
|
|
104
|
+
):
|
|
105
|
+
self.status = status
|
|
106
|
+
self.objections = list(objections or [])
|
|
107
|
+
self.raw = raw
|
|
108
|
+
self.reason = reason
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def approved(self) -> bool:
|
|
112
|
+
return self.status == APPROVED
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def rejected(self) -> bool:
|
|
116
|
+
return self.status == REJECTED
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def skipped(self) -> bool:
|
|
120
|
+
return self.status == SKIP
|
|
121
|
+
|
|
122
|
+
def __repr__(self) -> str:
|
|
123
|
+
return (
|
|
124
|
+
f"CritiqueVerdict(status={self.status!r}, "
|
|
125
|
+
f"objections={len(self.objections)})"
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def run_edit_critic(
|
|
130
|
+
task_description: Optional[str],
|
|
131
|
+
acceptance_criteria: Optional[str],
|
|
132
|
+
candidate_edits: Optional[Dict[str, str]],
|
|
133
|
+
critic_call: Optional[CriticCall] = None,
|
|
134
|
+
llm_client=None,
|
|
135
|
+
critic_model: Optional[str] = None,
|
|
136
|
+
candidate_diffs: Optional[Dict[str, str]] = None,
|
|
137
|
+
panel: int = 1,
|
|
138
|
+
timeout: float = 60,
|
|
139
|
+
) -> CritiqueVerdict:
|
|
140
|
+
"""Critique ``candidate_edits`` against the task and acceptance criteria.
|
|
141
|
+
|
|
142
|
+
The model call is performed by ``critic_call`` when supplied (the test seam);
|
|
143
|
+
otherwise one is built from ``llm_client``. ``critic_model``, when given,
|
|
144
|
+
selects an INDEPENDENT model so the critic does not share the generator's
|
|
145
|
+
blind spots (its absence is logged — the same-model critic is weaker).
|
|
146
|
+
|
|
147
|
+
When ``candidate_diffs`` is given, the critic reviews the unified diff of each
|
|
148
|
+
change (what actually changed, with a little context) instead of whole files —
|
|
149
|
+
sharper signal and far less prompt for a small edit to a large file.
|
|
150
|
+
|
|
151
|
+
``panel`` > 1 runs that many reviewers concurrently, each through a different
|
|
152
|
+
perspective lens, and the change is rejected only on a MAJORITY of decisive
|
|
153
|
+
votes (ties approve), which suppresses a lone false rejection.
|
|
154
|
+
|
|
155
|
+
With no callable and no client, or with no candidate edit, the critic SKIPs.
|
|
156
|
+
SKIP also on an unparseable verdict, any critic error, or the hard timeout
|
|
157
|
+
(never blocks). Only a parsed rejection returns objections.
|
|
158
|
+
"""
|
|
159
|
+
if not candidate_edits:
|
|
160
|
+
return CritiqueVerdict(SKIP, reason="no candidate edit to review")
|
|
161
|
+
|
|
162
|
+
call = critic_call or _default_critic_call(llm_client, critic_model)
|
|
163
|
+
if call is None:
|
|
164
|
+
return CritiqueVerdict(SKIP, reason="no LLM critic available")
|
|
165
|
+
|
|
166
|
+
prompt = _PROMPT.format(
|
|
167
|
+
task=(task_description or "(none given)").strip(),
|
|
168
|
+
criteria=(acceptance_criteria or "(none given)").strip(),
|
|
169
|
+
candidate=_render_candidate(candidate_edits, candidate_diffs),
|
|
170
|
+
)
|
|
171
|
+
members = max(1, int(panel))
|
|
172
|
+
|
|
173
|
+
def _work() -> CritiqueVerdict:
|
|
174
|
+
try:
|
|
175
|
+
if members == 1:
|
|
176
|
+
return _parse_verdict(call(prompt) or "")
|
|
177
|
+
return _aggregate_panel(_run_panel(call, prompt, members))
|
|
178
|
+
except Exception as e: # any model/IO failure is non-fatal -> skip
|
|
179
|
+
logger.debug(f"Adversarial critic unavailable: {e}")
|
|
180
|
+
return CritiqueVerdict(SKIP, reason=f"error: {e}")
|
|
181
|
+
|
|
182
|
+
return run_bounded(
|
|
183
|
+
_work, timeout, CritiqueVerdict(SKIP, reason="timed out"), "Adversarial critic"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _run_panel(
|
|
188
|
+
call: CriticCall, base_prompt: str, members: int
|
|
189
|
+
) -> List["CritiqueVerdict"]:
|
|
190
|
+
"""Run ``members`` lens-diversified critiques concurrently; return verdicts.
|
|
191
|
+
|
|
192
|
+
A member whose call fails contributes a SKIP (abstention) rather than sinking
|
|
193
|
+
the panel, so one flaky reviewer can't force a decision either way.
|
|
194
|
+
"""
|
|
195
|
+
prompts = [f"{base_prompt}\n{_LENSES[i % len(_LENSES)]}\n" for i in range(members)]
|
|
196
|
+
|
|
197
|
+
def _one(p: str) -> "CritiqueVerdict":
|
|
198
|
+
try:
|
|
199
|
+
return _parse_verdict(call(p) or "")
|
|
200
|
+
except Exception as e:
|
|
201
|
+
logger.debug(f"Critic panel member failed: {e}")
|
|
202
|
+
return CritiqueVerdict(SKIP, reason=f"member error: {e}")
|
|
203
|
+
|
|
204
|
+
with ThreadPoolExecutor(max_workers=members) as pool:
|
|
205
|
+
return list(pool.map(_one, prompts))
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _aggregate_panel(verdicts: List["CritiqueVerdict"]) -> "CritiqueVerdict":
|
|
209
|
+
"""Combine panel verdicts: reject only on a strict majority of decisive votes.
|
|
210
|
+
|
|
211
|
+
Abstentions (SKIP) don't count. A tie approves — the critic is advisory and
|
|
212
|
+
defers to the real gates, so consensus is required to block. A rejection
|
|
213
|
+
unions the objections of the rejecting members (deduplicated, order-stable).
|
|
214
|
+
"""
|
|
215
|
+
decisive = [v for v in verdicts if v.status != SKIP]
|
|
216
|
+
if not decisive:
|
|
217
|
+
return CritiqueVerdict(SKIP, reason="panel reached no verdict")
|
|
218
|
+
rejects = [v for v in decisive if v.status == REJECTED]
|
|
219
|
+
approves = [v for v in decisive if v.status == APPROVED]
|
|
220
|
+
if len(rejects) > len(approves):
|
|
221
|
+
objections: List[str] = []
|
|
222
|
+
seen = set()
|
|
223
|
+
for v in rejects:
|
|
224
|
+
for o in v.objections:
|
|
225
|
+
if o not in seen:
|
|
226
|
+
seen.add(o)
|
|
227
|
+
objections.append(o)
|
|
228
|
+
return CritiqueVerdict(
|
|
229
|
+
REJECTED,
|
|
230
|
+
objections=objections,
|
|
231
|
+
reason=f"panel rejected {len(rejects)}/{len(decisive)}",
|
|
232
|
+
)
|
|
233
|
+
return CritiqueVerdict(
|
|
234
|
+
APPROVED, reason=f"panel approved {len(approves)}/{len(decisive)}"
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _render_candidate(
|
|
239
|
+
edits: Dict[str, str], diffs: Optional[Dict[str, str]] = None
|
|
240
|
+
) -> str:
|
|
241
|
+
"""Render the candidate as labeled, size-bounded blocks.
|
|
242
|
+
|
|
243
|
+
Shows unified diffs when ``diffs`` is provided (sharper, smaller signal —
|
|
244
|
+
just what changed), otherwise full file contents. Files are shown in a stable
|
|
245
|
+
(sorted) order and the total is capped so a huge edit can't blow up the
|
|
246
|
+
prompt; truncation is marked so the critic knows it saw a head, not all.
|
|
247
|
+
"""
|
|
248
|
+
source = diffs if diffs else edits
|
|
249
|
+
note = (
|
|
250
|
+
"(shown as unified diffs — unchanged lines omitted)"
|
|
251
|
+
if diffs
|
|
252
|
+
else "(full content of each edited/created file)"
|
|
253
|
+
)
|
|
254
|
+
parts: List[str] = [note]
|
|
255
|
+
budget = _MAX_CANDIDATE_CHARS
|
|
256
|
+
for path in sorted(source):
|
|
257
|
+
if budget <= 0:
|
|
258
|
+
parts.append("... (further files omitted; candidate too large)")
|
|
259
|
+
break
|
|
260
|
+
body = source[path] or ""
|
|
261
|
+
shown = body[:budget]
|
|
262
|
+
if len(body) > len(shown):
|
|
263
|
+
shown += "\n... (truncated)"
|
|
264
|
+
budget -= len(shown)
|
|
265
|
+
parts.append(f"### {path}\n{shown}")
|
|
266
|
+
return "\n\n".join(parts)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _parse_verdict(text: str) -> CritiqueVerdict:
|
|
270
|
+
"""Deterministically parse the critic's JSON verdict.
|
|
271
|
+
|
|
272
|
+
Tolerates surrounding prose / markdown fences via the balanced-object
|
|
273
|
+
extractor shared with the goal check. A missing/invalid object or a missing
|
|
274
|
+
``approved`` boolean is a SKIP (no opinion), not a rejection — the critic must
|
|
275
|
+
never block on its own malformed output. A rejection with no listed objection
|
|
276
|
+
still records one generic objection so the loop never regenerates without
|
|
277
|
+
telling the generator why.
|
|
278
|
+
"""
|
|
279
|
+
if not text or not text.strip():
|
|
280
|
+
return CritiqueVerdict(SKIP, raw=text, reason="empty verdict")
|
|
281
|
+
|
|
282
|
+
obj = _extract_json_object(text)
|
|
283
|
+
if obj is None or "approved" not in obj:
|
|
284
|
+
return CritiqueVerdict(SKIP, raw=text, reason="unparseable verdict")
|
|
285
|
+
|
|
286
|
+
approved = obj.get("approved")
|
|
287
|
+
if not isinstance(approved, bool):
|
|
288
|
+
return CritiqueVerdict(SKIP, raw=text, reason="non-boolean 'approved'")
|
|
289
|
+
|
|
290
|
+
if approved:
|
|
291
|
+
return CritiqueVerdict(APPROVED, objections=[], raw=text)
|
|
292
|
+
|
|
293
|
+
raw_objections = obj.get("objections")
|
|
294
|
+
objections: List[str] = []
|
|
295
|
+
if isinstance(raw_objections, list):
|
|
296
|
+
for o in raw_objections:
|
|
297
|
+
if isinstance(o, str) and o.strip():
|
|
298
|
+
objections.append(o.strip())
|
|
299
|
+
elif isinstance(raw_objections, str) and raw_objections.strip():
|
|
300
|
+
objections.append(raw_objections.strip())
|
|
301
|
+
if not objections:
|
|
302
|
+
objections = ["change rejected (no specific objection reported)"]
|
|
303
|
+
return CritiqueVerdict(REJECTED, objections=objections, raw=text)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _default_critic_call(
|
|
307
|
+
llm_client, critic_model: Optional[str]
|
|
308
|
+
) -> Optional[CriticCall]:
|
|
309
|
+
"""Build a critic call from the project's LLM client, or None if unusable.
|
|
310
|
+
|
|
311
|
+
Uses the client's ``generate_code(prompt, system)`` text interface. When
|
|
312
|
+
``critic_model`` is given and the client supports ``with_model``, the call is
|
|
313
|
+
routed through that INDEPENDENT model; otherwise it runs on the generator's
|
|
314
|
+
own model and the weaker independence is logged once. Tolerant of client
|
|
315
|
+
shape so an absent/limited client degrades to SKIP rather than raising. No
|
|
316
|
+
network is touched until the returned callable is invoked in the worker.
|
|
317
|
+
"""
|
|
318
|
+
system = (
|
|
319
|
+
"You are a precise adversarial code reviewer. Output only the requested "
|
|
320
|
+
"JSON object. Reject only for a concrete, nameable defect."
|
|
321
|
+
)
|
|
322
|
+
return build_independent_call(
|
|
323
|
+
llm_client, system, critic_model, "Adversarial critic"
|
|
324
|
+
)
|