cc-plugin-codex 0.1.4__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.
@@ -0,0 +1,243 @@
1
+ """Build per-tool prompts and normalize claude's JSON envelope into the contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any, cast
7
+
8
+ from cc_plugin_codex import cli_contract
9
+ from cc_plugin_codex.claude import contract_changed_error
10
+ from cc_plugin_codex.schemas import (
11
+ Confidence,
12
+ ContextSummary,
13
+ ErrorInfo,
14
+ ErrorResult,
15
+ Finding,
16
+ Meta,
17
+ RawResponse,
18
+ Severity,
19
+ SuccessResult,
20
+ Usage,
21
+ Verdict,
22
+ )
23
+
24
+ _SCHEMA_INSTRUCTION = (
25
+ "Respond with ONLY a single JSON object (no prose, no code fence) with keys: "
26
+ '"summary" (string), "verdict" (one of pass|concerns|fail|unknown), '
27
+ '"confidence" (one of low|medium|high), "findings" (array of objects with '
28
+ "severity[critical|high|medium|low|nit], title, file, line, line_end (optional "
29
+ "end line for multi-line findings), evidence, risk, recommendation), "
30
+ '"questions" (array of strings), "assumptions" (array of strings), '
31
+ '"next_steps" (array of strings: concrete actions to take next).'
32
+ )
33
+
34
+ _LEAD = {
35
+ "claude_ask": "Give an independent second opinion on the following question.",
36
+ "claude_review_changes": "Review the following code changes for correctness, "
37
+ "regressions, security, and missing tests.",
38
+ "claude_adversarial_review": "Attack the following plan/claim. Find the strongest "
39
+ "counterarguments, failure modes, and risks.",
40
+ }
41
+
42
+ _VALID_VERDICT = {"pass", "concerns", "fail", "unknown"}
43
+ _VALID_CONFIDENCE = {"low", "medium", "high"}
44
+ _VALID_SEVERITY = {"critical", "high", "medium", "low", "nit"}
45
+
46
+
47
+ def _str_list(value: Any) -> list[str]:
48
+ return [str(x) for x in value if x] if isinstance(value, list) else []
49
+
50
+
51
+ def build_prompt(tool: str, payload: dict[str, Any], context_text: str) -> str:
52
+ parts = [_LEAD.get(tool, _LEAD["claude_ask"])]
53
+ if tool == "claude_ask":
54
+ parts.append(payload["prompt"])
55
+ if payload.get("context"):
56
+ parts.append(f"\nAdditional context:\n{payload['context']}")
57
+ elif tool == "claude_review_changes":
58
+ if payload.get("focus"):
59
+ parts.append(f"Focus especially on: {payload['focus']}.")
60
+ parts.append(f"\nChanges (scope={payload.get('scope')}):\n{context_text}")
61
+ elif tool == "claude_adversarial_review":
62
+ parts.append(f"\nTarget:\n{payload['target']}")
63
+ if payload.get("evidence"):
64
+ parts.append(f"\nEvidence:\n{payload['evidence']}")
65
+ if context_text:
66
+ parts.append(f"\nRelated changes:\n{context_text}")
67
+ parts.append("\n" + _SCHEMA_INSTRUCTION)
68
+ return "\n".join(parts)
69
+
70
+
71
+ def extract_json(text: str) -> dict | None:
72
+ decoder = json.JSONDecoder()
73
+
74
+ def scan(candidate: str) -> dict | None:
75
+ for idx, char in enumerate(candidate):
76
+ if char != "{":
77
+ continue
78
+ try:
79
+ parsed, _ = decoder.raw_decode(candidate[idx:])
80
+ except json.JSONDecodeError:
81
+ continue
82
+ if isinstance(parsed, dict):
83
+ return parsed
84
+ return None
85
+
86
+ fence_start = 0
87
+ while True:
88
+ start = text.find("```", fence_start)
89
+ if start < 0:
90
+ break
91
+ body_start = text.find("\n", start + 3)
92
+ if body_start < 0:
93
+ break
94
+ end = text.find("```", body_start + 1)
95
+ if end < 0:
96
+ break
97
+ parsed = scan(text[body_start + 1 : end])
98
+ if parsed is not None:
99
+ return parsed
100
+ fence_start = end + 3
101
+
102
+ return scan(text)
103
+
104
+
105
+ def _clamp(value: Any, allowed: set[str], default: str) -> str:
106
+ return value if value in allowed else default
107
+
108
+
109
+ def _clean_findings(raw: Any) -> list[Finding]:
110
+ findings: list[Finding] = []
111
+ if not isinstance(raw, list):
112
+ return findings
113
+ for f in raw:
114
+ if not isinstance(f, dict):
115
+ continue
116
+ if not all(f.get(k) for k in ("title", "evidence", "risk", "recommendation")):
117
+ continue # drop incomplete findings rather than fabricate fields
118
+ line = f.get("line")
119
+ line_end = f.get("line_end")
120
+ findings.append(
121
+ Finding(
122
+ severity=cast("Severity", _clamp(f.get("severity"), _VALID_SEVERITY, "low")),
123
+ title=str(f["title"]),
124
+ file=str(f["file"]) if f.get("file") else None,
125
+ line=line if isinstance(line, int) else None,
126
+ line_end=line_end if isinstance(line_end, int) else None,
127
+ evidence=str(f["evidence"]),
128
+ risk=str(f["risk"]),
129
+ recommendation=str(f["recommendation"]),
130
+ )
131
+ )
132
+ return findings
133
+
134
+
135
+ def _error(info: ErrorInfo, meta: Meta) -> dict:
136
+ return ErrorResult(error=info, meta=meta).model_dump(mode="json", exclude_none=True)
137
+
138
+
139
+ def apply_cost_usage(meta: Meta, env: dict) -> None:
140
+ """Plumb total_cost_usd / usage from a claude JSON envelope onto meta.
141
+
142
+ Used on both the success path and the non-zero-exit error path, so a failed
143
+ paid call (e.g. budget_exceeded) still reports what it spent when available."""
144
+ cost = env.get("total_cost_usd")
145
+ if isinstance(cost, (int, float)):
146
+ meta.cost_usd = float(cost)
147
+ raw_usage = env.get("usage")
148
+ if isinstance(raw_usage, dict):
149
+ meta.usage = Usage(
150
+ input_tokens=raw_usage.get("input_tokens"),
151
+ output_tokens=raw_usage.get("output_tokens"),
152
+ cache_read_input_tokens=raw_usage.get("cache_read_input_tokens"),
153
+ cache_creation_input_tokens=raw_usage.get("cache_creation_input_tokens"),
154
+ )
155
+
156
+
157
+ def normalize_envelope(
158
+ tool: str,
159
+ stdout: str,
160
+ meta: Meta,
161
+ detail: str,
162
+ context_summary: ContextSummary | None = None,
163
+ ) -> dict:
164
+ try:
165
+ env = json.loads(stdout)
166
+ except json.JSONDecodeError:
167
+ return _error(
168
+ ErrorInfo(
169
+ code="invalid_json",
170
+ message="claude did not return valid JSON.",
171
+ repair="Retry; if it persists, reduce context size.",
172
+ ),
173
+ meta,
174
+ )
175
+
176
+ # Plumb cost and usage onto meta regardless of success/error path.
177
+ apply_cost_usage(meta, env)
178
+
179
+ if env.get("is_error") or env.get("subtype") not in cli_contract.SUCCESS_SUBTYPES:
180
+ detail = (env.get("result") or "").strip() or (env.get("subtype") or "unknown error")
181
+ # A drift signature can arrive as a zero-exit is_error envelope (not just a
182
+ # nonzero exit), so classify it the same way here.
183
+ if cli_contract.is_contract_drift(env.get("result"), env.get("subtype")):
184
+ return _error(contract_changed_error(), meta)
185
+ return _error(
186
+ ErrorInfo(
187
+ code="nonzero_exit",
188
+ message=f"claude reported an error: {detail[:200]}",
189
+ repair="Inspect the error; retry with a smaller or corrected request.",
190
+ ),
191
+ meta,
192
+ )
193
+
194
+ text = env.get("result", "") or ""
195
+ raw = RawResponse(
196
+ text=text if detail == "full" else None,
197
+ session_id=env.get("session_id"),
198
+ model=next(iter(env.get("modelUsage") or {}), None),
199
+ )
200
+ inner = extract_json(text)
201
+
202
+ # If Claude was blocked by denied tools AND produced nothing usable, surface it.
203
+ denials = env.get("permission_denials") or []
204
+ if denials and (inner is None and not text.strip()):
205
+ return _error(
206
+ ErrorInfo(
207
+ code="claude_permission_error",
208
+ message=f"claude was denied required tools: {str(denials)[:160]}",
209
+ repair="Use access=toolless, or allow the needed read-only tools.",
210
+ ),
211
+ meta,
212
+ )
213
+
214
+ if inner is None:
215
+ result = SuccessResult(
216
+ tool=tool,
217
+ summary=text.strip()[:500] or "(no content)",
218
+ verdict="unknown",
219
+ confidence="low",
220
+ raw_response=raw,
221
+ context_summary=context_summary if detail == "full" else None,
222
+ meta=meta,
223
+ )
224
+ if denials:
225
+ result.meta.permission_denials = denials
226
+ return result.model_dump(mode="json", exclude_none=True)
227
+
228
+ result = SuccessResult(
229
+ tool=tool,
230
+ summary=str(inner.get("summary", "")),
231
+ verdict=cast("Verdict", _clamp(inner.get("verdict"), _VALID_VERDICT, "unknown")),
232
+ confidence=cast("Confidence", _clamp(inner.get("confidence"), _VALID_CONFIDENCE, "low")),
233
+ findings=_clean_findings(inner.get("findings", [])),
234
+ questions=_str_list(inner.get("questions")),
235
+ assumptions=_str_list(inner.get("assumptions")),
236
+ next_steps=_str_list(inner.get("next_steps")),
237
+ raw_response=raw,
238
+ context_summary=context_summary if detail == "full" else None,
239
+ meta=meta,
240
+ )
241
+ if denials:
242
+ result.meta.permission_denials = denials
243
+ return result.model_dump(mode="json", exclude_none=True)
@@ -0,0 +1,94 @@
1
+ """Feature-detect which `claude` flags exist, by parsing `claude --help` once.
2
+
3
+ Only the HELP_GATED flags (depth/cosmetic) are gated on this probe: dropping one
4
+ when absent keeps the server working across a minor upstream change. The
5
+ guarantee-bearing ALWAYS_SEND flags are never gated here — their removal is caught
6
+ loudly at run time (cli_contract_changed), not silently pre-empted, because
7
+ `--help` parsing is fuzzy and a false negative must never drop a safety/cost flag.
8
+
9
+ Everything degrades, nothing crashes: any probe failure yields help_parsed=False,
10
+ which makes is_supported() return True for every flag (fail open == today's
11
+ behavior)."""
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ import subprocess
17
+ import time
18
+ from dataclasses import dataclass
19
+
20
+ from cc_plugin_codex import cli_contract
21
+
22
+ _LONG_FLAG_RE = re.compile(r"--[a-z][a-z0-9-]+")
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class FlagSupport:
27
+ supported: frozenset[str]
28
+ help_parsed: bool # False => probe failed; callers must fail open
29
+
30
+
31
+ # Process-level cache: (monotonic_timestamp, FlagSupport). A long-lived MCP server
32
+ # re-probes after HELP_CACHE_TTL_SECONDS so an in-place `claude` upgrade is noticed.
33
+ _cache: tuple[float, FlagSupport] | None = None
34
+
35
+
36
+ def _probe_help() -> str:
37
+ """Return the combined `claude --help` text, or "" on any failure. Never raises."""
38
+ try:
39
+ proc = subprocess.run(
40
+ [cli_contract.CLAUDE_BIN, *cli_contract.HELP_ARGS],
41
+ capture_output=True,
42
+ text=True,
43
+ timeout=10,
44
+ check=False,
45
+ )
46
+ except (OSError, subprocess.SubprocessError):
47
+ return ""
48
+ return f"{proc.stdout}\n{proc.stderr}"
49
+
50
+
51
+ def _parse_supported(help_text: str) -> frozenset[str]:
52
+ """Extract long-flag names from help text. Deliberately tolerant: this only
53
+ governs HELP_GATED flags, where a stray/missing match drops a harmless flag."""
54
+ return frozenset(_LONG_FLAG_RE.findall(help_text))
55
+
56
+
57
+ def flag_support(force: bool = False) -> FlagSupport:
58
+ """Cached FlagSupport for the installed `claude`. force=True bypasses the cache
59
+ (used by tests / diagnostics)."""
60
+ global _cache # noqa: PLW0603 — intentional process-level memoization of the help probe
61
+ now = time.monotonic()
62
+ if not force and _cache is not None:
63
+ stamped, value = _cache
64
+ if now - stamped < cli_contract.HELP_CACHE_TTL_SECONDS:
65
+ return value
66
+ help_text = _probe_help()
67
+ if not help_text.strip():
68
+ value = FlagSupport(supported=frozenset(), help_parsed=False)
69
+ else:
70
+ value = FlagSupport(supported=_parse_supported(help_text), help_parsed=True)
71
+ _cache = (now, value)
72
+ return value
73
+
74
+
75
+ def reset_cache() -> None:
76
+ """Drop the cached probe (used by tests)."""
77
+ global _cache # noqa: PLW0603 — resets the intentional module-level cache
78
+ _cache = None
79
+
80
+
81
+ def is_supported(flag: str, fs: FlagSupport) -> bool:
82
+ """Whether `flag` may be sent. Fails OPEN: when the probe could not run
83
+ (help_parsed=False) every flag is treated as supported, preserving today's
84
+ behavior."""
85
+ return (not fs.help_parsed) or (flag in fs.supported)
86
+
87
+
88
+ def missing_expected_flags(fs: FlagSupport) -> list[str]:
89
+ """Guarantee-bearing ALWAYS_SEND flags that `--help` did not list. Empty when
90
+ the probe could not run (so we never warn on a failed probe). Diagnostic only —
91
+ surfaced by claude_status, it does NOT gate execution."""
92
+ if not fs.help_parsed:
93
+ return []
94
+ return sorted(f for f in cli_contract.ALWAYS_SEND_FLAGS if f not in fs.supported)
File without changes
@@ -0,0 +1,344 @@
1
+ """Pydantic models for the normalized tool result contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+ from uuid import uuid4
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
9
+
10
+ # Bump this whenever the agent-visible surface changes: tool names, input or
11
+ # output schemas, the ErrorCode set, the config_mode/access/scope/detail value
12
+ # sets, or the capability guarantees in CAPABILITY_SUMMARY. Clients cache by it.
13
+ FINGERPRINT = "cc-plugin-codex/0.1/schema-12"
14
+
15
+ Severity = Literal["critical", "high", "medium", "low", "nit"]
16
+ Verdict = Literal["pass", "concerns", "fail", "unknown"]
17
+ Confidence = Literal["low", "medium", "high"]
18
+ ConfigMode = Literal["inherit", "scoped", "bare"]
19
+ Access = Literal["toolless", "readonly"]
20
+ Scope = Literal["working_tree", "staged", "branch"]
21
+ Detail = Literal["summary", "full"]
22
+ Effort = Literal["low", "medium", "high", "xhigh", "max"]
23
+ # Lifecycle states for a background job. Terminal: done|failed|cancelled|timeout.
24
+ # (TTL-expired records are deleted and reported as job_not_found, not a state.)
25
+ JobState = Literal["running", "done", "failed", "cancelled", "timeout"]
26
+
27
+
28
+ def workspace_warning_for(source: str | None, cwd: str) -> str | None:
29
+ """Warning when the workspace was resolved from the server's own cwd.
30
+
31
+ The MCP server process launches from its install directory, so a cwd-resolved
32
+ workspace silently reviews the wrong repo. Surfacing this (rather than failing)
33
+ lets agents notice and pass workspace_root without breaking existing callers.
34
+ Shared by the sync meta builder and the background-job meta rebuild so the two
35
+ paths cannot drift."""
36
+ if source == "cwd":
37
+ return (
38
+ f"workspace resolved from the server's own cwd ({cwd}); pass "
39
+ "workspace_root (or configure an MCP root) to be sure the review "
40
+ "targets the intended repository"
41
+ )
42
+ return None
43
+
44
+
45
+ ErrorCode = Literal[
46
+ "claude_not_found",
47
+ "claude_auth_required",
48
+ "api_key_missing",
49
+ "api_key_invalid",
50
+ "unsupported_config_mode",
51
+ "unsupported_access",
52
+ "invalid_scope",
53
+ "invalid_base",
54
+ "invalid_workspace_root",
55
+ "workspace_outside_roots",
56
+ "context_too_large",
57
+ "timeout",
58
+ "budget_exceeded",
59
+ "claude_permission_error",
60
+ "nonzero_exit",
61
+ "invalid_json",
62
+ "internal_error",
63
+ # The installed `claude` rejected a flag/value this plugin sends — its CLI
64
+ # contract drifted and the plugin likely needs an update.
65
+ "cli_contract_changed",
66
+ # Background-job lifecycle errors (claude_job_result for a non-done job):
67
+ "job_not_found",
68
+ "job_running",
69
+ "job_cancelled",
70
+ "job_timeout",
71
+ "job_failed",
72
+ ]
73
+
74
+
75
+ class Usage(BaseModel):
76
+ model_config = ConfigDict(extra="forbid")
77
+ input_tokens: int | None = None
78
+ output_tokens: int | None = None
79
+ cache_read_input_tokens: int | None = None
80
+ cache_creation_input_tokens: int | None = None
81
+
82
+
83
+ class Finding(BaseModel):
84
+ model_config = ConfigDict(extra="forbid")
85
+ severity: Severity
86
+ title: str
87
+ file: str | None = None
88
+ line: int | None = None
89
+ line_end: int | None = None # end line when the finding spans a range (line = start)
90
+ evidence: str
91
+ risk: str
92
+ recommendation: str
93
+
94
+
95
+ class RawResponse(BaseModel):
96
+ model_config = ConfigDict(extra="forbid")
97
+ text: str | None = None
98
+ session_id: str | None = None
99
+ model: str | None = None
100
+
101
+
102
+ class ContextSummary(BaseModel):
103
+ model_config = ConfigDict(extra="forbid")
104
+ files_changed: int = 0
105
+ lines_added: int = 0
106
+ lines_removed: int = 0
107
+
108
+
109
+ class Meta(BaseModel):
110
+ model_config = ConfigDict(extra="forbid")
111
+ cwd: str
112
+ workspace_source: str | None = None # how cwd was resolved: param|roots|cwd
113
+ workspace_warning: str | None = None # set when cwd was resolved from server cwd
114
+ config_mode: ConfigMode
115
+ access: Access
116
+ scope: str | None = None
117
+ base: str | None = None
118
+ timeout_seconds: int
119
+ elapsed_ms: int
120
+ # The effective (env-defaulted + clamped) value passed to claude as
121
+ # --max-budget-usd. It is a best-effort stop threshold, not a hard cap; compare
122
+ # against cost_usd to see how close actual spend came.
123
+ requested_max_budget_usd: float | None = None
124
+ truncated: bool = False
125
+ truncation_hint: str | None = None
126
+ command_exit_code: int | None = None
127
+ permission_denials: list | None = None
128
+ # Optional `claude` flags this server dropped because the installed CLI did not
129
+ # advertise them in --help (e.g. ["--effort"]). Empty in the common case;
130
+ # informational — guarantee-bearing flags are never dropped, only depth/cosmetic ones.
131
+ compat_warnings: list[str] = Field(default_factory=list)
132
+ redacted_paths: list[str] = Field(default_factory=list)
133
+ cost_usd: float | None = None
134
+ usage: Usage | None = None
135
+ job_id: str | None = None # set on background-job results; None for sync calls
136
+ request_id: str = Field(default_factory=lambda: uuid4().hex)
137
+ fingerprint: str = FINGERPRINT
138
+
139
+
140
+ class SuccessResult(BaseModel):
141
+ model_config = ConfigDict(extra="forbid")
142
+ ok: Literal[True] = True
143
+ tool: str
144
+ summary: str
145
+ verdict: Verdict
146
+ confidence: Confidence
147
+ findings: list[Finding] = Field(default_factory=list)
148
+ questions: list[str] = Field(default_factory=list)
149
+ assumptions: list[str] = Field(default_factory=list)
150
+ next_steps: list[str] = Field(default_factory=list)
151
+ raw_response: RawResponse = Field(default_factory=RawResponse)
152
+ context_summary: ContextSummary | None = None
153
+ meta: Meta
154
+
155
+
156
+ class ErrorInfo(BaseModel):
157
+ model_config = ConfigDict(extra="forbid")
158
+ code: ErrorCode
159
+ message: str
160
+ repair: str
161
+ offending_param: str | None = None
162
+ retryable: bool = False
163
+
164
+
165
+ class ErrorResult(BaseModel):
166
+ model_config = ConfigDict(extra="forbid")
167
+ ok: Literal[False] = False
168
+ error: ErrorInfo
169
+ meta: Meta
170
+
171
+
172
+ class ResolvedDefaults(BaseModel):
173
+ model_config = ConfigDict(extra="forbid")
174
+ config_mode: ConfigMode
175
+ access: Access
176
+ model: str | None = None
177
+ effort: Effort
178
+ max_budget_usd: float
179
+ timeout_seconds: int
180
+ budget_bounds: list[float] # [min, max] clamp range for max_budget_usd
181
+ timeout_bounds: list[int] # [min, max] clamp range for timeout_seconds
182
+ practical_min_budget_hint: str
183
+
184
+
185
+ class StatusResult(BaseModel):
186
+ model_config = ConfigDict(extra="forbid")
187
+ ok: Literal[True] = True
188
+ claude_found: bool
189
+ claude_version: str | None = None
190
+ # Readiness probes (all free — no paid Claude call):
191
+ claude_authenticated: bool | None = None # None = could not determine
192
+ auth_detail: str | None = None
193
+ version_supported: bool | None = None # major is in supported_majors()
194
+ # Set when version_supported is False: a major outside the tested range is
195
+ # advisory, not fatal — tools may still work, so we warn instead of blocking.
196
+ version_warning: str | None = None
197
+ # Set when `claude --help` did not list a guarantee-bearing flag this plugin
198
+ # sends — an early, free signal that the CLI contract drifted.
199
+ flags_warning: str | None = None
200
+ ready: bool = False # found AND authenticated (version is advisory, not gating)
201
+ config_modes_available: dict
202
+ resolved_defaults: ResolvedDefaults
203
+ caveat: str
204
+ fingerprint: str = FINGERPRINT
205
+
206
+
207
+ class ToolCapability(BaseModel):
208
+ model_config = ConfigDict(extra="forbid")
209
+ name: str
210
+ cost: Literal["free", "paid"]
211
+ use_when: str
212
+ required_params: list[str] = Field(default_factory=list)
213
+ key_optional_params: list[str] = Field(default_factory=list)
214
+ returns: str
215
+
216
+
217
+ class CapabilitiesResult(BaseModel):
218
+ model_config = ConfigDict(extra="forbid")
219
+ ok: Literal[True] = True
220
+ name: str
221
+ version: str
222
+ fingerprint: str = FINGERPRINT
223
+ transport: str
224
+ stability: str
225
+ paid_tools: list[str]
226
+ free_tools: list[str]
227
+ tool_details: list[ToolCapability] = Field(default_factory=list)
228
+ config_modes: list[str]
229
+ access_modes: list[str]
230
+ scope: list[str] # what this server is for
231
+ negative_scope: list[str] # what it deliberately does NOT do
232
+ prerequisites: list[str]
233
+ deprecation_policy: str
234
+
235
+
236
+ class JobStarted(BaseModel):
237
+ """Returned by the *_async tools: a handle to poll, not a result."""
238
+
239
+ model_config = ConfigDict(extra="forbid")
240
+ ok: Literal[True] = True
241
+ job_id: str
242
+ kind: str # the tool the job runs, e.g. claude_review_changes
243
+ status: JobState = "running"
244
+ started_at: str # ISO-8601 UTC
245
+ deadline_seconds: int # wall-clock cap after which a poll reaps the job
246
+ poll_after_ms: int = 1000
247
+ ttl_seconds: int
248
+ expires_at: str | None = None
249
+ meta: Meta
250
+ fingerprint: str = FINGERPRINT
251
+
252
+
253
+ class JobStatus(BaseModel):
254
+ """Returned by claude_job_status: lifecycle state without the full result."""
255
+
256
+ model_config = ConfigDict(extra="forbid")
257
+ ok: Literal[True] = True
258
+ job_id: str
259
+ kind: str
260
+ status: JobState
261
+ started_at: str
262
+ elapsed_ms: int
263
+ deadline_seconds: int
264
+ poll_after_ms: int = 1000
265
+ ttl_seconds: int
266
+ expires_at: str | None = None
267
+ result_available: bool = False # true once status == done
268
+ cost_usd: float | None = None # populated for terminal jobs that spent
269
+ detail: str | None = None # short human hint (e.g. failure reason)
270
+ fingerprint: str = FINGERPRINT
271
+
272
+
273
+ class DryRunResult(BaseModel):
274
+ """Free preview of what a diff review WOULD send — no Claude call, no spend."""
275
+
276
+ model_config = ConfigDict(extra="forbid")
277
+ ok: Literal[True] = True
278
+ tool: Literal["claude_review_dry_run"] = "claude_review_dry_run"
279
+ cwd: str
280
+ workspace_source: str | None = None
281
+ workspace_warning: str | None = None
282
+ scope: str
283
+ base: str | None = None
284
+ context_summary: ContextSummary
285
+ diff_bytes: int # full UTF-8 size of the redacted diff that would be sent
286
+ max_diff_bytes: int # the server's truncation threshold
287
+ truncated: bool = False # true when diff_bytes > max_diff_bytes
288
+ truncation_hint: str | None = None
289
+ redacted_paths_count: int = 0
290
+ redacted_paths: list[str] = Field(default_factory=list)
291
+ fingerprint: str = FINGERPRINT
292
+
293
+
294
+ class JobSummary(BaseModel):
295
+ model_config = ConfigDict(extra="forbid")
296
+ job_id: str
297
+ kind: str
298
+ status: JobState
299
+ started_at: str
300
+ elapsed_ms: int
301
+ result_available: bool = False
302
+ expires_at: str | None = None
303
+ cost_usd: float | None = None
304
+
305
+
306
+ class JobListResult(BaseModel):
307
+ """Returned by claude_job_list: the workspace's known jobs, newest first."""
308
+
309
+ model_config = ConfigDict(extra="forbid")
310
+ ok: Literal[True] = True
311
+ jobs: list[JobSummary] = Field(default_factory=list)
312
+ fingerprint: str = FINGERPRINT
313
+
314
+
315
+ def _object_union_schema(adapter: TypeAdapter) -> dict:
316
+ """Wrap a model union's anyOf in a top-level object schema.
317
+
318
+ MCP/FastMCP require an output schema whose top level is ``type: object``;
319
+ a bare ``anyOf`` is rejected. We keep the discriminating ``ok`` key visible
320
+ at the top and carry the full branch schemas (and their $defs) underneath.
321
+ """
322
+ union = adapter.json_schema()
323
+ return {
324
+ "type": "object",
325
+ "properties": {
326
+ "ok": {"type": "boolean", "description": "true = success result, false = error result"},
327
+ },
328
+ "required": ["ok"],
329
+ "anyOf": union["anyOf"],
330
+ "$defs": union.get("$defs", {}),
331
+ }
332
+
333
+
334
+ # Advertised output schemas (convention: a discriminated ok:true|false union).
335
+ RESULT_SCHEMA = _object_union_schema(TypeAdapter(SuccessResult | ErrorResult))
336
+ STATUS_SCHEMA = StatusResult.model_json_schema()
337
+ CAPABILITIES_SCHEMA = CapabilitiesResult.model_json_schema()
338
+ # A failed *_async launch returns the error envelope; an empty diff returns a
339
+ # SuccessResult without starting a job.
340
+ JOB_STARTED_SCHEMA = _object_union_schema(TypeAdapter(JobStarted | SuccessResult | ErrorResult))
341
+ JOB_STATUS_SCHEMA = _object_union_schema(TypeAdapter(JobStatus | ErrorResult))
342
+ # Dry-run and job-list can fail (bad scope/base/workspace), so advertise the union.
343
+ DRY_RUN_SCHEMA = _object_union_schema(TypeAdapter(DryRunResult | ErrorResult))
344
+ JOB_LIST_SCHEMA = _object_union_schema(TypeAdapter(JobListResult | ErrorResult))