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.
Files changed (136) hide show
  1. misterdev/__init__.py +3 -0
  2. misterdev/agent.py +2166 -0
  3. misterdev/agent_helpers.py +194 -0
  4. misterdev/analyzers/__init__.py +0 -0
  5. misterdev/analyzers/project_analyzer/__init__.py +246 -0
  6. misterdev/analyzers/project_analyzer/detection.py +146 -0
  7. misterdev/analyzers/project_analyzer/merge.py +137 -0
  8. misterdev/analyzers/project_analyzer/overview.py +312 -0
  9. misterdev/analyzers/project_analyzer/prompts.py +83 -0
  10. misterdev/cli.py +370 -0
  11. misterdev/config.py +521 -0
  12. misterdev/core/__init__.py +0 -0
  13. misterdev/core/audit.py +88 -0
  14. misterdev/core/config.py +10 -0
  15. misterdev/core/context/__init__.py +0 -0
  16. misterdev/core/context/change_tracker.py +218 -0
  17. misterdev/core/context/contracts/__init__.py +63 -0
  18. misterdev/core/context/contracts/_log.py +9 -0
  19. misterdev/core/context/contracts/_text.py +12 -0
  20. misterdev/core/context/contracts/extraction.py +30 -0
  21. misterdev/core/context/contracts/python_generic.py +42 -0
  22. misterdev/core/context/contracts/registry.py +127 -0
  23. misterdev/core/context/contracts/rust_line.py +225 -0
  24. misterdev/core/context/contracts/rust_tree_sitter.py +141 -0
  25. misterdev/core/context/lsp.py +174 -0
  26. misterdev/core/context/scratchpad.py +111 -0
  27. misterdev/core/context/topography/__init__.py +43 -0
  28. misterdev/core/context/topography/_log.py +10 -0
  29. misterdev/core/context/topography/cache.py +91 -0
  30. misterdev/core/context/topography/engine.py +172 -0
  31. misterdev/core/context/topography/graph.py +675 -0
  32. misterdev/core/context/topography/nodes.py +55 -0
  33. misterdev/core/context/topography/parsers.py +95 -0
  34. misterdev/core/context/topography/syntax.py +54 -0
  35. misterdev/core/economics/__init__.py +0 -0
  36. misterdev/core/economics/context_budget.py +175 -0
  37. misterdev/core/economics/embeddings.py +232 -0
  38. misterdev/core/economics/free_models.py +108 -0
  39. misterdev/core/economics/llm_cache.py +105 -0
  40. misterdev/core/economics/model_catalog.py +79 -0
  41. misterdev/core/economics/model_ledger.py +331 -0
  42. misterdev/core/economics/model_selector.py +281 -0
  43. misterdev/core/execution/__init__.py +0 -0
  44. misterdev/core/execution/bounded.py +50 -0
  45. misterdev/core/execution/container.py +221 -0
  46. misterdev/core/execution/error_classifier.py +366 -0
  47. misterdev/core/execution/error_resolver.py +201 -0
  48. misterdev/core/execution/governance.py +283 -0
  49. misterdev/core/execution/outcomes.py +50 -0
  50. misterdev/core/execution/progress.py +120 -0
  51. misterdev/core/execution/project.py +231 -0
  52. misterdev/core/execution/registry.py +97 -0
  53. misterdev/core/execution/runtime.py +279 -0
  54. misterdev/core/gitcmd.py +39 -0
  55. misterdev/core/integration/__init__.py +0 -0
  56. misterdev/core/integration/mcp.py +368 -0
  57. misterdev/core/integration/mcp_gather.py +186 -0
  58. misterdev/core/models.py +35 -0
  59. misterdev/core/modes.py +184 -0
  60. misterdev/core/planning/__init__.py +0 -0
  61. misterdev/core/planning/advisor.py +89 -0
  62. misterdev/core/planning/assessment.py +135 -0
  63. misterdev/core/planning/decomposer.py +387 -0
  64. misterdev/core/planning/metacognition.py +103 -0
  65. misterdev/core/planning/sovereign.py +308 -0
  66. misterdev/core/planning/targets.py +201 -0
  67. misterdev/core/reporting/__init__.py +0 -0
  68. misterdev/core/reporting/report.py +377 -0
  69. misterdev/core/reporting/report_view.py +151 -0
  70. misterdev/core/task.py +163 -0
  71. misterdev/core/verification/__init__.py +0 -0
  72. misterdev/core/verification/claim_verifier.py +210 -0
  73. misterdev/core/verification/critic.py +324 -0
  74. misterdev/core/verification/gatekeeper/__init__.py +631 -0
  75. misterdev/core/verification/gatekeeper/constants.py +138 -0
  76. misterdev/core/verification/gatekeeper/helpers.py +28 -0
  77. misterdev/core/verification/goal_check.py +219 -0
  78. misterdev/core/verification/independent.py +68 -0
  79. misterdev/core/verification/mutation_gate.py +221 -0
  80. misterdev/core/verification/preflight.py +95 -0
  81. misterdev/core/verification/spec_tests.py +175 -0
  82. misterdev/core/verification/validator.py +495 -0
  83. misterdev/core/verification/vision_verify.py +185 -0
  84. misterdev/core/verification/web_verify.py +408 -0
  85. misterdev/environments/__init__.py +0 -0
  86. misterdev/environments/base_env.py +18 -0
  87. misterdev/environments/container_env.py +87 -0
  88. misterdev/environments/venv_env.py +42 -0
  89. misterdev/llm/__init__.py +0 -0
  90. misterdev/llm/client/__init__.py +152 -0
  91. misterdev/llm/client/base.py +382 -0
  92. misterdev/llm/client/edits.py +70 -0
  93. misterdev/llm/client/embeddings.py +121 -0
  94. misterdev/llm/client/errors.py +134 -0
  95. misterdev/llm/client/providers.py +535 -0
  96. misterdev/llm/client/response.py +24 -0
  97. misterdev/llm/prompt_manager.py +82 -0
  98. misterdev/llm/responses/__init__.py +34 -0
  99. misterdev/llm/responses/apply.py +131 -0
  100. misterdev/llm/responses/json_extract.py +80 -0
  101. misterdev/llm/responses/models.py +43 -0
  102. misterdev/llm/responses/parsing.py +494 -0
  103. misterdev/logging_setup.py +20 -0
  104. misterdev/mcp_server.py +208 -0
  105. misterdev/nl_cli.py +149 -0
  106. misterdev/plugins.py +115 -0
  107. misterdev/py.typed +0 -0
  108. misterdev/task_executors/__init__.py +0 -0
  109. misterdev/task_executors/base_executor.py +10 -0
  110. misterdev/task_executors/markdown_plan_executor/__init__.py +90 -0
  111. misterdev/task_executors/markdown_plan_executor/commands_mixin.py +82 -0
  112. misterdev/task_executors/markdown_plan_executor/context_mixin.py +221 -0
  113. misterdev/task_executors/markdown_plan_executor/critic_spec_mixin.py +174 -0
  114. misterdev/task_executors/markdown_plan_executor/edits_mixin.py +251 -0
  115. misterdev/task_executors/markdown_plan_executor/execute_mixin.py +727 -0
  116. misterdev/task_executors/markdown_plan_executor/gates_mixin.py +203 -0
  117. misterdev/task_executors/markdown_plan_executor/git_mixin.py +219 -0
  118. misterdev/task_executors/markdown_plan_executor/helpers.py +521 -0
  119. misterdev/task_executors/markdown_plan_executor/llm_mixin.py +238 -0
  120. misterdev/task_executors/markdown_plan_executor/results_mixin.py +23 -0
  121. misterdev/tools/__init__.py +19 -0
  122. misterdev/tools/base_tool.py +14 -0
  123. misterdev/tools/command.py +75 -0
  124. misterdev/tools/file_io.py +69 -0
  125. misterdev/tools/formatter.py +26 -0
  126. misterdev/tools/git_tool.py +90 -0
  127. misterdev/utils/__init__.py +0 -0
  128. misterdev/utils/file_utils.py +169 -0
  129. misterdev/utils/process.py +23 -0
  130. misterdev-0.2.0.dist-info/METADATA +326 -0
  131. misterdev-0.2.0.dist-info/RECORD +136 -0
  132. misterdev-0.2.0.dist-info/WHEEL +5 -0
  133. misterdev-0.2.0.dist-info/entry_points.txt +3 -0
  134. misterdev-0.2.0.dist-info/licenses/COMMERCIAL_LICENSE.md +34 -0
  135. misterdev-0.2.0.dist-info/licenses/LICENSE +661 -0
  136. 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
+ )