orkestra-runtime 0.1.2__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 (63) hide show
  1. orkestra/__init__.py +1 -0
  2. orkestra/adapters/__init__.py +12 -0
  3. orkestra/adapters/antigravity_cli.py +231 -0
  4. orkestra/adapters/base.py +85 -0
  5. orkestra/adapters/claude_code.py +211 -0
  6. orkestra/adapters/codex_cli.py +219 -0
  7. orkestra/adapters/external.py +160 -0
  8. orkestra/adapters/fake.py +30 -0
  9. orkestra/adapters/fake_worker.py +195 -0
  10. orkestra/adapters/gemini_cli.py +232 -0
  11. orkestra/adapters/jsonl.py +68 -0
  12. orkestra/adapters/registry.py +42 -0
  13. orkestra/adapters/runner.py +216 -0
  14. orkestra/adapters/testkit.py +148 -0
  15. orkestra/app.py +87 -0
  16. orkestra/capabilities/__init__.py +6 -0
  17. orkestra/capabilities/ledger.py +48 -0
  18. orkestra/capabilities/matrix.py +87 -0
  19. orkestra/capabilities/probes.py +164 -0
  20. orkestra/cli/__init__.py +1 -0
  21. orkestra/cli/main.py +685 -0
  22. orkestra/cli/template.py +109 -0
  23. orkestra/director/__init__.py +5 -0
  24. orkestra/director/heuristic.py +112 -0
  25. orkestra/director/prompts.py +147 -0
  26. orkestra/director/service.py +253 -0
  27. orkestra/errors.py +43 -0
  28. orkestra/ids.py +57 -0
  29. orkestra/kernel/__init__.py +1 -0
  30. orkestra/kernel/dag.py +126 -0
  31. orkestra/kernel/prepare.py +158 -0
  32. orkestra/kernel/retry.py +46 -0
  33. orkestra/kernel/scheduler.py +902 -0
  34. orkestra/kernel/states.py +81 -0
  35. orkestra/policy/__init__.py +5 -0
  36. orkestra/policy/engine.py +117 -0
  37. orkestra/py.typed +0 -0
  38. orkestra/redact.py +139 -0
  39. orkestra/report/__init__.py +5 -0
  40. orkestra/report/final.py +144 -0
  41. orkestra/schemas/__init__.py +84 -0
  42. orkestra/schemas/agent.py +112 -0
  43. orkestra/schemas/capability.py +62 -0
  44. orkestra/schemas/common.py +65 -0
  45. orkestra/schemas/config.py +145 -0
  46. orkestra/schemas/decision.py +41 -0
  47. orkestra/schemas/director.py +79 -0
  48. orkestra/schemas/task.py +48 -0
  49. orkestra/store/__init__.py +6 -0
  50. orkestra/store/db.py +92 -0
  51. orkestra/store/migrations.py +126 -0
  52. orkestra/store/repo.py +532 -0
  53. orkestra/verify/__init__.py +5 -0
  54. orkestra/verify/runner.py +130 -0
  55. orkestra/workspace/__init__.py +6 -0
  56. orkestra/workspace/git.py +193 -0
  57. orkestra/workspace/worktrees.py +155 -0
  58. orkestra_runtime-0.1.2.dist-info/METADATA +290 -0
  59. orkestra_runtime-0.1.2.dist-info/RECORD +63 -0
  60. orkestra_runtime-0.1.2.dist-info/WHEEL +4 -0
  61. orkestra_runtime-0.1.2.dist-info/entry_points.txt +2 -0
  62. orkestra_runtime-0.1.2.dist-info/licenses/LICENSE +202 -0
  63. orkestra_runtime-0.1.2.dist-info/licenses/NOTICE +10 -0
orkestra/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.2"
@@ -0,0 +1,12 @@
1
+ """Agent adapter layer."""
2
+
3
+ from orkestra.adapters.base import AdapterInfo, AgentAdapter, InvocationSpec
4
+ from orkestra.adapters.registry import build_adapter, builtin_adapter_ids
5
+
6
+ __all__ = [
7
+ "AdapterInfo",
8
+ "AgentAdapter",
9
+ "InvocationSpec",
10
+ "build_adapter",
11
+ "builtin_adapter_ids",
12
+ ]
@@ -0,0 +1,231 @@
1
+ """Antigravity CLI adapter (`agy`) — first-party Google adapter.
2
+
3
+ Surface verified live 2026-07-24 against agy 1.1.6 (samples/):
4
+
5
+ - ``agy -p --output-format stream-json`` emits JSONL: ``init``
6
+ (conversation_id, tools, permission_mode), ``step_update``
7
+ (``step_type: agent_response`` carries ``text_delta`` + usage), and a
8
+ terminal ``result`` envelope (``conversation_id``, ``status``,
9
+ ``response``, ``usage``).
10
+ - ``--output-format`` is accepted although undocumented in ``--help``;
11
+ the parser therefore also handles plain-text output as a fallback.
12
+ - No schema-constrained output: structured requests use prompt-level
13
+ JSON plus :func:`extract_json_object` with kernel-side repair retries.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Iterable
19
+ from typing import Any
20
+
21
+ from orkestra.adapters.base import AdapterInfo, AgentAdapter, InvocationSpec, StreamParser
22
+ from orkestra.adapters.jsonl import extract_json_object, try_parse_json
23
+ from orkestra.adapters.runner import run_capture
24
+ from orkestra.schemas.agent import (
25
+ AgentEvent,
26
+ AgentResult,
27
+ AuthStatus,
28
+ ErrorKind,
29
+ EventKind,
30
+ ResultStatus,
31
+ SessionRef,
32
+ Usage,
33
+ )
34
+ from orkestra.schemas.task import TaskBrief
35
+
36
+
37
+ class AntigravityParser(StreamParser):
38
+ def __init__(self, expect_structured: bool) -> None:
39
+ self.expect_structured = expect_structured
40
+ self.conversation_id = ""
41
+ self.final: dict[str, Any] | None = None
42
+ self.plain_lines: list[str] = []
43
+ self.stderr_tail: list[str] = []
44
+
45
+ def feed_line(self, line: str, *, is_stderr: bool) -> Iterable[AgentEvent]:
46
+ if is_stderr:
47
+ if line.strip():
48
+ self.stderr_tail = [*self.stderr_tail, line][-20:]
49
+ return
50
+ obj = try_parse_json(line)
51
+ if obj is None:
52
+ # Plain-text fallback (older versions / flag removed upstream).
53
+ self.plain_lines.append(line)
54
+ if line.strip():
55
+ yield AgentEvent(kind=EventKind.TEXT, text=line[:2000])
56
+ return
57
+ event = obj.get("event")
58
+ if event == "init":
59
+ init = obj.get("init") or {}
60
+ self.conversation_id = str(obj.get("conversation_id") or "")
61
+ yield AgentEvent(
62
+ kind=EventKind.STARTED,
63
+ text="antigravity session initialized",
64
+ data={
65
+ "conversation_id": self.conversation_id,
66
+ "permission_mode": init.get("permission_mode", ""),
67
+ },
68
+ )
69
+ elif event == "step_update":
70
+ step = obj.get("step_update") or {}
71
+ step_type = step.get("step_type")
72
+ if step_type == "agent_response" and step.get("text_delta"):
73
+ yield AgentEvent(kind=EventKind.TEXT, text=str(step["text_delta"])[:4000])
74
+ elif step_type not in (None, "user_input", "checkpoint", "unknown"):
75
+ yield AgentEvent(kind=EventKind.TOOL, text=str(step_type))
76
+ elif event == "result":
77
+ self.final = obj.get("result") or {}
78
+
79
+ def result(self, exit_code: int | None, duration_s: float, cwd: str) -> AgentResult:
80
+ stderr = "\n".join(self.stderr_tail)
81
+ if self.final is not None:
82
+ usage_raw = self.final.get("usage") or {}
83
+ usage = Usage(
84
+ input_tokens=int(usage_raw.get("input_tokens") or 0),
85
+ output_tokens=int(usage_raw.get("output_tokens") or 0),
86
+ )
87
+ conversation_id = str(self.final.get("conversation_id") or self.conversation_id)
88
+ status = str(self.final.get("status") or "")
89
+ text = str(self.final.get("response") or "")
90
+ ok = status.upper() == "SUCCESS" and (exit_code in (0, None))
91
+ structured = extract_json_object(text) if self.expect_structured else None
92
+ if ok and self.expect_structured and structured is None:
93
+ return AgentResult(
94
+ status=ResultStatus.ERROR,
95
+ error_kind=ErrorKind.INVALID_OUTPUT,
96
+ error_detail="expected JSON in response, none found",
97
+ final_text=text,
98
+ exit_code=exit_code,
99
+ duration_s=duration_s,
100
+ )
101
+ return AgentResult(
102
+ status=ResultStatus.OK if ok else ResultStatus.ERROR,
103
+ error_kind=ErrorKind.NONE if ok else ErrorKind.UNKNOWN,
104
+ error_detail="" if ok else f"status={status}",
105
+ final_text=text,
106
+ structured=structured,
107
+ session=(
108
+ SessionRef(session_id=conversation_id, cwd=cwd) if conversation_id else None
109
+ ),
110
+ usage=usage,
111
+ exit_code=exit_code,
112
+ duration_s=duration_s,
113
+ )
114
+ # Plain-text fallback path.
115
+ text = "\n".join(self.plain_lines).strip()
116
+ lower = (stderr + text).lower()
117
+ if exit_code == 0 and text:
118
+ structured = extract_json_object(text) if self.expect_structured else None
119
+ if self.expect_structured and structured is None:
120
+ return AgentResult(
121
+ status=ResultStatus.ERROR,
122
+ error_kind=ErrorKind.INVALID_OUTPUT,
123
+ error_detail="expected JSON in response, none found",
124
+ final_text=text,
125
+ exit_code=exit_code,
126
+ duration_s=duration_s,
127
+ )
128
+ return AgentResult(
129
+ status=ResultStatus.OK,
130
+ final_text=text,
131
+ structured=structured,
132
+ exit_code=exit_code,
133
+ duration_s=duration_s,
134
+ )
135
+ if "auth" in lower or "sign in" in lower or "log in" in lower:
136
+ kind = ErrorKind.AUTH
137
+ elif "rate limit" in lower or "quota" in lower:
138
+ kind = ErrorKind.RATE_LIMIT
139
+ else:
140
+ kind = ErrorKind.CRASH if exit_code else ErrorKind.INVALID_OUTPUT
141
+ return AgentResult(
142
+ status=ResultStatus.ERROR,
143
+ error_kind=kind,
144
+ error_detail=(stderr or text or f"exit {exit_code}")[:2000],
145
+ exit_code=exit_code,
146
+ duration_s=duration_s,
147
+ )
148
+
149
+
150
+ class AntigravityCliAdapter(AgentAdapter):
151
+ adapter_id = "antigravity-cli"
152
+ executable = "agy"
153
+
154
+ def __init__(self, model: str | None = None, autonomy: str = "safe") -> None:
155
+ self.model = model
156
+ self.autonomy = autonomy
157
+
158
+ async def detect(self) -> AdapterInfo:
159
+ path = self.which()
160
+ if not path:
161
+ return AdapterInfo(self.adapter_id, available=False, detail="`agy` not found on PATH")
162
+ code, out, err = await run_capture([path, "--version"])
163
+ if code != 0:
164
+ return AdapterInfo(
165
+ self.adapter_id,
166
+ available=False,
167
+ executable=path,
168
+ detail=f"--version failed: {err.strip()[:200]}",
169
+ )
170
+ return AdapterInfo(
171
+ self.adapter_id,
172
+ available=True,
173
+ version=out.strip(),
174
+ executable=path,
175
+ features=frozenset({"resume", "stream", "os_sandbox"}),
176
+ detail=(
177
+ "note: Antigravity ToS restricts third-party access to the "
178
+ "service; Orkestra only launches the official binary under "
179
+ "your own login — review antigravity.google/terms"
180
+ ),
181
+ )
182
+
183
+ async def check_auth(self) -> AuthStatus:
184
+ path = self.which()
185
+ if not path:
186
+ return AuthStatus(ready=False, detail="`agy` not found on PATH")
187
+ # `agy models` succeeds only when authenticated and consumes no
188
+ # model quota (verified locally 2026-07-24).
189
+ code, out, err = await run_capture([path, "models"], timeout_s=30)
190
+ if code == 0 and out.strip():
191
+ return AuthStatus(ready=True, detail="authenticated (models listed)")
192
+ return AuthStatus(
193
+ ready=False,
194
+ detail=(err or out).strip()[:200] or "run `agy` interactively to sign in",
195
+ )
196
+
197
+ def build_invocation(self, brief: TaskBrief) -> InvocationSpec:
198
+ timeout_arg = f"{max(brief.timeout_s, 60)}s"
199
+ instructions = brief.instructions
200
+ if brief.json_schema is not None:
201
+ # No native schema flag: embed the schema in the prompt and rely
202
+ # on extract_json_object + kernel-side validation/retries.
203
+ import json as _json
204
+
205
+ instructions += (
206
+ "\n\nRespond with ONLY one JSON object (no prose, no code "
207
+ "fences) conforming to this JSON Schema:\n" + _json.dumps(brief.json_schema)
208
+ )
209
+ argv = [
210
+ self.which() or self.executable,
211
+ "-p",
212
+ instructions,
213
+ "--output-format",
214
+ "stream-json",
215
+ "--print-timeout",
216
+ timeout_arg,
217
+ ]
218
+ if self.autonomy == "unsafe-full":
219
+ argv += ["--dangerously-skip-permissions"]
220
+ elif brief.kind.value in ("research", "plan", "review"):
221
+ argv += ["--mode", "plan"]
222
+ else:
223
+ argv += ["--mode", "accept-edits"]
224
+ if self.model:
225
+ argv += ["--model", self.model]
226
+ if brief.resume_session_id:
227
+ argv += ["--conversation", brief.resume_session_id]
228
+ return InvocationSpec(argv=argv, cwd=brief.cwd, timeout_s=brief.timeout_s + 30)
229
+
230
+ def make_parser(self, brief: TaskBrief) -> StreamParser:
231
+ return AntigravityParser(expect_structured=brief.json_schema is not None)
@@ -0,0 +1,85 @@
1
+ """Adapter contract.
2
+
3
+ An adapter is a pure translator between Orkestra's task brief and one
4
+ agent CLI: it builds an argv (never a shell string), parses the CLI's
5
+ output stream into normalized ``AgentEvent`` objects, and produces a
6
+ structured ``AgentResult``. Process supervision (spawn, stream, timeout,
7
+ cancel, kill) lives in :mod:`orkestra.adapters.runner`, shared by all
8
+ adapters.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import shutil
14
+ from abc import ABC, abstractmethod
15
+ from collections.abc import Iterable
16
+ from dataclasses import dataclass, field
17
+ from typing import TYPE_CHECKING
18
+
19
+ from orkestra.schemas.agent import AgentEvent, AgentResult, AuthStatus
20
+
21
+ if TYPE_CHECKING:
22
+ from orkestra.schemas.task import TaskBrief
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class AdapterInfo:
27
+ """Detection result for one adapter."""
28
+
29
+ adapter_id: str
30
+ available: bool
31
+ version: str = ""
32
+ executable: str = ""
33
+ detail: str = ""
34
+ features: frozenset[str] = frozenset()
35
+ """Feature flags, e.g. {"structured_output", "resume", "structured_director"}."""
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class InvocationSpec:
40
+ """Everything the runner needs to launch one attempt."""
41
+
42
+ argv: list[str]
43
+ cwd: str
44
+ env_extra: dict[str, str] = field(default_factory=dict)
45
+ stdin_data: bytes | None = None
46
+ timeout_s: int = 1800
47
+
48
+
49
+ class StreamParser(ABC):
50
+ """Incremental parser turning raw CLI output lines into events."""
51
+
52
+ @abstractmethod
53
+ def feed_line(self, line: str, *, is_stderr: bool) -> Iterable[AgentEvent]:
54
+ """Parse one output line into zero or more normalized events."""
55
+
56
+ @abstractmethod
57
+ def result(self, exit_code: int | None, duration_s: float, cwd: str) -> AgentResult:
58
+ """Produce the final structured result after process exit."""
59
+
60
+
61
+ class AgentAdapter(ABC):
62
+ """Stable contract implemented by all adapters (first- and third-party)."""
63
+
64
+ adapter_id: str
65
+ executable: str
66
+
67
+ @abstractmethod
68
+ async def detect(self) -> AdapterInfo:
69
+ """Report availability, version, and feature flags. Never raises."""
70
+
71
+ @abstractmethod
72
+ async def check_auth(self) -> AuthStatus:
73
+ """Non-invasive auth readiness check (never reads credential stores)."""
74
+
75
+ @abstractmethod
76
+ def build_invocation(self, brief: TaskBrief) -> InvocationSpec:
77
+ """Translate a task brief into a concrete CLI invocation."""
78
+
79
+ @abstractmethod
80
+ def make_parser(self, brief: TaskBrief) -> StreamParser:
81
+ """Fresh parser for one attempt's output stream."""
82
+
83
+ def which(self) -> str | None:
84
+ """Locate the executable on PATH (helper for detect())."""
85
+ return shutil.which(self.executable)
@@ -0,0 +1,211 @@
1
+ """Claude Code adapter (`claude`).
2
+
3
+ Surface verified 2026-07-24 against claude 2.1.219 (see
4
+ docs/research/AGENT_INTEGRATION_RESEARCH.md and samples/):
5
+
6
+ - ``claude -p --output-format stream-json --verbose`` emits JSONL:
7
+ ``system/init``, ``assistant``/``user`` messages, ``system/api_retry``
8
+ (rate-limit/auth categories), and a terminal ``type:"result"`` object
9
+ with ``result``, ``session_id``, ``is_error``, ``usage``,
10
+ ``total_cost_usd``.
11
+ - ``--json-schema`` yields ``structured_output`` in the result envelope.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from collections.abc import Iterable
18
+ from typing import Any
19
+
20
+ from orkestra.adapters.base import AdapterInfo, AgentAdapter, InvocationSpec, StreamParser
21
+ from orkestra.adapters.jsonl import try_parse_json
22
+ from orkestra.adapters.runner import run_capture
23
+ from orkestra.schemas.agent import (
24
+ AgentEvent,
25
+ AgentResult,
26
+ AuthStatus,
27
+ ErrorKind,
28
+ EventKind,
29
+ ResultStatus,
30
+ SessionRef,
31
+ Usage,
32
+ )
33
+ from orkestra.schemas.task import TaskBrief
34
+
35
+ _API_ERROR_TO_KIND = {
36
+ "authentication_failed": ErrorKind.AUTH,
37
+ "oauth_org_not_allowed": ErrorKind.AUTH,
38
+ "billing_error": ErrorKind.AUTH,
39
+ "rate_limit": ErrorKind.RATE_LIMIT,
40
+ "overloaded": ErrorKind.RATE_LIMIT,
41
+ }
42
+
43
+
44
+ class ClaudeParser(StreamParser):
45
+ def __init__(self) -> None:
46
+ self.final: dict[str, Any] | None = None
47
+ self.last_error_kind = ErrorKind.NONE
48
+ self.stderr_tail: list[str] = []
49
+
50
+ def feed_line(self, line: str, *, is_stderr: bool) -> Iterable[AgentEvent]:
51
+ if is_stderr:
52
+ if line.strip():
53
+ self.stderr_tail = [*self.stderr_tail, line][-20:]
54
+ return
55
+ obj = try_parse_json(line)
56
+ if obj is None:
57
+ if line.strip():
58
+ yield AgentEvent(kind=EventKind.RAW, text=line[:2000])
59
+ return
60
+ kind = obj.get("type")
61
+ if kind == "system":
62
+ subtype = obj.get("subtype")
63
+ if subtype == "init":
64
+ yield AgentEvent(
65
+ kind=EventKind.STARTED,
66
+ text="claude session initialized",
67
+ data={"session_id": obj.get("session_id", "")},
68
+ )
69
+ elif subtype == "api_retry":
70
+ category = str(obj.get("error", "unknown"))
71
+ self.last_error_kind = _API_ERROR_TO_KIND.get(category, ErrorKind.UNKNOWN)
72
+ yield AgentEvent(
73
+ kind=EventKind.WARNING,
74
+ text=f"api retry ({category})",
75
+ data={"category": category},
76
+ )
77
+ elif kind == "assistant":
78
+ message = obj.get("message") or {}
79
+ for block in message.get("content") or []:
80
+ if isinstance(block, dict):
81
+ if block.get("type") == "text" and block.get("text"):
82
+ yield AgentEvent(kind=EventKind.TEXT, text=str(block["text"])[:4000])
83
+ elif block.get("type") == "tool_use":
84
+ yield AgentEvent(
85
+ kind=EventKind.TOOL,
86
+ text=str(block.get("name", "tool")),
87
+ )
88
+ elif kind == "result":
89
+ self.final = obj
90
+
91
+ def result(self, exit_code: int | None, duration_s: float, cwd: str) -> AgentResult:
92
+ if self.final is not None:
93
+ usage_raw = self.final.get("usage") or {}
94
+ usage = Usage(
95
+ input_tokens=int(usage_raw.get("input_tokens") or 0),
96
+ output_tokens=int(usage_raw.get("output_tokens") or 0),
97
+ cached_input_tokens=int(usage_raw.get("cache_read_input_tokens") or 0),
98
+ total_cost_usd=self.final.get("total_cost_usd"),
99
+ )
100
+ session_id = str(self.final.get("session_id") or "")
101
+ structured = self.final.get("structured_output")
102
+ is_error = bool(self.final.get("is_error"))
103
+ error_kind = ErrorKind.NONE
104
+ detail = ""
105
+ if is_error:
106
+ error_kind = (
107
+ self.last_error_kind
108
+ if self.last_error_kind is not ErrorKind.NONE
109
+ else ErrorKind.UNKNOWN
110
+ )
111
+ detail = str(self.final.get("subtype") or "error")
112
+ return AgentResult(
113
+ status=ResultStatus.ERROR if is_error else ResultStatus.OK,
114
+ error_kind=error_kind,
115
+ error_detail=detail,
116
+ final_text=str(self.final.get("result") or ""),
117
+ structured=structured if isinstance(structured, dict) else None,
118
+ session=SessionRef(session_id=session_id, cwd=cwd) if session_id else None,
119
+ usage=usage,
120
+ exit_code=exit_code,
121
+ duration_s=duration_s,
122
+ )
123
+ stderr = "\n".join(self.stderr_tail)
124
+ lower = stderr.lower()
125
+ if "log in" in lower or "authentication" in lower or "api key" in lower:
126
+ kind = ErrorKind.AUTH
127
+ elif self.last_error_kind is not ErrorKind.NONE:
128
+ kind = self.last_error_kind
129
+ else:
130
+ kind = ErrorKind.CRASH if exit_code else ErrorKind.INVALID_OUTPUT
131
+ return AgentResult(
132
+ status=ResultStatus.ERROR,
133
+ error_kind=kind,
134
+ error_detail=(stderr or "no result envelope received")[:2000],
135
+ exit_code=exit_code,
136
+ duration_s=duration_s,
137
+ )
138
+
139
+
140
+ class ClaudeCodeAdapter(AgentAdapter):
141
+ adapter_id = "claude-code"
142
+ executable = "claude"
143
+
144
+ def __init__(self, model: str | None = None, autonomy: str = "safe") -> None:
145
+ self.model = model
146
+ self.autonomy = autonomy
147
+
148
+ async def detect(self) -> AdapterInfo:
149
+ path = self.which()
150
+ if not path:
151
+ return AdapterInfo(
152
+ self.adapter_id, available=False, detail="`claude` not found on PATH"
153
+ )
154
+ code, out, err = await run_capture([path, "--version"])
155
+ if code != 0:
156
+ return AdapterInfo(
157
+ self.adapter_id,
158
+ available=False,
159
+ executable=path,
160
+ detail=f"--version failed: {err.strip()[:200]}",
161
+ )
162
+ return AdapterInfo(
163
+ self.adapter_id,
164
+ available=True,
165
+ version=out.strip().split()[0] if out.strip() else "",
166
+ executable=path,
167
+ features=frozenset({"structured_output", "resume", "structured_director", "stream"}),
168
+ )
169
+
170
+ async def check_auth(self) -> AuthStatus:
171
+ # `claude -p` with a trivial prompt would consume quota; instead rely
172
+ # on detection plus the documented failure envelope at run time. A
173
+ # cheap deterministic signal: `claude auth status` isn't universally
174
+ # available, so report ready-if-installed with a caveat.
175
+ info = await self.detect()
176
+ if not info.available:
177
+ return AuthStatus(ready=False, detail=info.detail)
178
+ return AuthStatus(
179
+ ready=True,
180
+ detail="installed; auth verified lazily on first invocation",
181
+ )
182
+
183
+ def build_invocation(self, brief: TaskBrief) -> InvocationSpec:
184
+ argv = [
185
+ self.which() or self.executable,
186
+ "-p",
187
+ "--output-format",
188
+ "stream-json",
189
+ "--verbose",
190
+ "--setting-sources",
191
+ "user",
192
+ ]
193
+ if self.autonomy == "unsafe-full":
194
+ argv += ["--permission-mode", "bypassPermissions"]
195
+ else:
196
+ argv += ["--permission-mode", "acceptEdits"]
197
+ if self.model:
198
+ argv += ["--model", self.model]
199
+ if brief.json_schema is not None:
200
+ # --json-schema requires --output-format json; the single result
201
+ # envelope is still parsed by ClaudeParser.
202
+ argv[argv.index("stream-json")] = "json"
203
+ argv.remove("--verbose")
204
+ argv += ["--json-schema", json.dumps(brief.json_schema)]
205
+ if brief.resume_session_id:
206
+ argv += ["--resume", brief.resume_session_id]
207
+ argv.append(brief.instructions)
208
+ return InvocationSpec(argv=argv, cwd=brief.cwd, timeout_s=brief.timeout_s)
209
+
210
+ def make_parser(self, brief: TaskBrief) -> StreamParser:
211
+ return ClaudeParser()