rote-cli 0.1.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.
- rote/__init__.py +3 -0
- rote/adapters/__init__.py +70 -0
- rote/adapters/_common.py +149 -0
- rote/adapters/cloudflare.py +978 -0
- rote/adapters/dbos.py +1235 -0
- rote/adapters/temporal.py +568 -0
- rote/cli.py +520 -0
- rote/graduator/__init__.py +259 -0
- rote/graduator/drivers/__init__.py +226 -0
- rote/graduator/drivers/anthropic_api.py +423 -0
- rote/graduator/drivers/claude.py +312 -0
- rote/graduator/drivers/codex.py +83 -0
- rote/ir.py +495 -0
- rote/serve/__init__.py +14 -0
- rote/serve/backends.py +162 -0
- rote/serve/registry.py +201 -0
- rote/serve/server.py +249 -0
- rote/skills/rote-graduate/SKILL.md +230 -0
- rote/skills/rote-graduate/references/crystallization-heuristics.md +260 -0
- rote/skills/rote-graduate/references/ir-schema.md +420 -0
- rote/skills/rote-graduate/references/llm-judge-extraction.md +341 -0
- rote/skills/rote-graduate/references/node-kinds.md +223 -0
- rote_cli-0.1.0.dist-info/METADATA +546 -0
- rote_cli-0.1.0.dist-info/RECORD +27 -0
- rote_cli-0.1.0.dist-info/WHEEL +4 -0
- rote_cli-0.1.0.dist-info/entry_points.txt +2 -0
- rote_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""Claude Code CLI driver — spawns ``claude -p`` in subscription mode.
|
|
2
|
+
|
|
3
|
+
This driver is the primary subscription path. It shells out to the
|
|
4
|
+
``claude`` CLI (Claude Code) in non-interactive "print" mode and
|
|
5
|
+
requires only that the user has run ``claude login`` interactively at
|
|
6
|
+
least once (or set ``CLAUDE_CODE_OAUTH_TOKEN`` for automation).
|
|
7
|
+
|
|
8
|
+
# The env-var gotcha
|
|
9
|
+
|
|
10
|
+
Claude Code's print mode (``claude -p``) has a documented behavior
|
|
11
|
+
where ``ANTHROPIC_API_KEY`` and ``ANTHROPIC_AUTH_TOKEN`` *always win*
|
|
12
|
+
over any active OAuth session. To force subscription auth, the
|
|
13
|
+
subprocess environment must not contain those variables.
|
|
14
|
+
|
|
15
|
+
This driver always scrubs both env vars from the child environment.
|
|
16
|
+
If the user wants API-key auth to Anthropic's servers, they should
|
|
17
|
+
use the ``api`` driver (which uses the ``anthropic`` SDK directly);
|
|
18
|
+
``ClaudeDriver`` is specifically the subscription path.
|
|
19
|
+
|
|
20
|
+
``CLAUDE_CODE_OAUTH_TOKEN`` — a long-lived token Anthropic issues for
|
|
21
|
+
automation — is always passed through if set. That's the cleanest
|
|
22
|
+
way to use ``ClaudeDriver`` in CI, where no interactive ``claude login``
|
|
23
|
+
has been run.
|
|
24
|
+
|
|
25
|
+
See ``docs/agent-runtime.md`` for the full rationale.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import asyncio
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from shutil import which
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from rote.graduator.drivers import DriverError, DriverResult, GraduatorDriver
|
|
38
|
+
|
|
39
|
+
# ───────── Defaults ─────────
|
|
40
|
+
|
|
41
|
+
DEFAULT_MODEL = "claude-sonnet-4-6"
|
|
42
|
+
"""Default model for the graduator agent.
|
|
43
|
+
|
|
44
|
+
**Why Sonnet, not Opus:** Claude Code defaults to Opus 4.6, which is
|
|
45
|
+
overkill for the graduator's task. Sonnet 4.6 follows structured
|
|
46
|
+
rubrics just as reliably for this kind of work and is ~5× cheaper on
|
|
47
|
+
both the Anthropic API and the Claude Max/Pro subscription budget.
|
|
48
|
+
|
|
49
|
+
**Measured on BDR outreach (2026-04-07):** two runs with Opus burned
|
|
50
|
+
~$3.50 each in subscription accounting (2.6M cache-read tokens, 31K
|
|
51
|
+
output tokens, 31 turns). At that rate a single Max/Pro user's "extra
|
|
52
|
+
usage" allowance is exhausted in 2-3 runs. The Sonnet switch brings
|
|
53
|
+
per-run cost to ~$0.70, which makes iterative rubric tuning actually
|
|
54
|
+
feasible.
|
|
55
|
+
|
|
56
|
+
Users can override with ``ClaudeDriver(model=...)`` or the CLI flag
|
|
57
|
+
``rote graduate --model claude-opus-4-6`` for complex skills where
|
|
58
|
+
Opus's extra reasoning ability is worth the 5× cost.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
DEFAULT_MAX_TURNS = 60
|
|
62
|
+
"""Agents need a fairly generous turn budget for complex skills.
|
|
63
|
+
|
|
64
|
+
Measured on the BDR outreach skill (7 phases, 6 reference files,
|
|
65
|
+
~14 nodes, ~8 extracted modules, ~2 signatures): a clean run needs
|
|
66
|
+
roughly 25 tool calls minimum (reads + writes), realistically 40–50
|
|
67
|
+
with exploration, list_directory calls, and between-phase thinking.
|
|
68
|
+
30 is not enough; we observed an ``error_max_turns`` failure at that
|
|
69
|
+
limit with BDR. 60 leaves headroom for more complex skills and for
|
|
70
|
+
the agent to recover from tool errors."""
|
|
71
|
+
|
|
72
|
+
DEFAULT_ALLOWED_TOOLS = "Read,Write,Edit,Glob,Grep"
|
|
73
|
+
"""Conservative tool allowlist for the graduator. No Bash (no shell
|
|
74
|
+
access needed), no WebFetch / WebSearch (no external network), no
|
|
75
|
+
TodoWrite. Read + Write + Edit cover file I/O; Glob + Grep cover
|
|
76
|
+
discovery of source skill structure and reference content."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class ClaudeDriver(GraduatorDriver):
|
|
80
|
+
name: str = "claude"
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
model: str = DEFAULT_MODEL,
|
|
85
|
+
max_turns: int = DEFAULT_MAX_TURNS,
|
|
86
|
+
allowed_tools: str = DEFAULT_ALLOWED_TOOLS,
|
|
87
|
+
claude_executable: str = "claude",
|
|
88
|
+
) -> None:
|
|
89
|
+
self.model = model
|
|
90
|
+
self.max_turns = max_turns
|
|
91
|
+
self.allowed_tools = allowed_tools
|
|
92
|
+
self.claude_executable = claude_executable
|
|
93
|
+
|
|
94
|
+
def is_available(self) -> tuple[bool, str]:
|
|
95
|
+
"""Check whether ``claude`` CLI is installed on PATH.
|
|
96
|
+
|
|
97
|
+
We intentionally do **not** check auth here — doing so would
|
|
98
|
+
require spawning a ``claude`` process, which is expensive. Auth
|
|
99
|
+
errors surface at ``run()`` time with their own clear messages.
|
|
100
|
+
"""
|
|
101
|
+
if which(self.claude_executable) is None:
|
|
102
|
+
return (
|
|
103
|
+
False,
|
|
104
|
+
"The `claude` CLI is not installed. "
|
|
105
|
+
"Install Claude Code from https://code.claude.com/download "
|
|
106
|
+
"and run `claude login` once to enable subscription auth.",
|
|
107
|
+
)
|
|
108
|
+
return (True, "")
|
|
109
|
+
|
|
110
|
+
async def run(
|
|
111
|
+
self,
|
|
112
|
+
skill_dir: Path,
|
|
113
|
+
graduator_skill_dir: Path,
|
|
114
|
+
work_dir: Path,
|
|
115
|
+
) -> DriverResult:
|
|
116
|
+
"""Spawn ``claude -p`` and wait for it to produce pipeline.yaml."""
|
|
117
|
+
skill_dir = skill_dir.resolve()
|
|
118
|
+
graduator_skill_dir = graduator_skill_dir.resolve()
|
|
119
|
+
work_dir = work_dir.resolve()
|
|
120
|
+
work_dir.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
|
|
122
|
+
skill_md = graduator_skill_dir / "SKILL.md"
|
|
123
|
+
if not skill_md.is_file():
|
|
124
|
+
raise DriverError(
|
|
125
|
+
f"rote-graduate SKILL.md not found at {skill_md}. "
|
|
126
|
+
f"Pass an explicit graduator_skill_dir to the orchestrator."
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
system_prompt = self._build_system_prompt(skill_md.read_text(encoding="utf-8"))
|
|
130
|
+
user_prompt = self._build_user_prompt(skill_dir, graduator_skill_dir, work_dir)
|
|
131
|
+
|
|
132
|
+
env = self._build_child_env()
|
|
133
|
+
|
|
134
|
+
args = [
|
|
135
|
+
self.claude_executable,
|
|
136
|
+
"-p",
|
|
137
|
+
user_prompt,
|
|
138
|
+
"--model",
|
|
139
|
+
self.model,
|
|
140
|
+
"--append-system-prompt",
|
|
141
|
+
system_prompt,
|
|
142
|
+
"--add-dir",
|
|
143
|
+
str(skill_dir),
|
|
144
|
+
"--add-dir",
|
|
145
|
+
str(graduator_skill_dir),
|
|
146
|
+
"--add-dir",
|
|
147
|
+
str(work_dir),
|
|
148
|
+
"--allowedTools",
|
|
149
|
+
self.allowed_tools,
|
|
150
|
+
"--output-format",
|
|
151
|
+
"json",
|
|
152
|
+
"--max-turns",
|
|
153
|
+
str(self.max_turns),
|
|
154
|
+
]
|
|
155
|
+
|
|
156
|
+
proc = await asyncio.create_subprocess_exec(
|
|
157
|
+
*args,
|
|
158
|
+
stdout=asyncio.subprocess.PIPE,
|
|
159
|
+
stderr=asyncio.subprocess.PIPE,
|
|
160
|
+
env=env,
|
|
161
|
+
)
|
|
162
|
+
stdout_bytes, stderr_bytes = await proc.communicate()
|
|
163
|
+
|
|
164
|
+
stdout = stdout_bytes.decode("utf-8", errors="replace") if stdout_bytes else ""
|
|
165
|
+
stderr = stderr_bytes.decode("utf-8", errors="replace") if stderr_bytes else ""
|
|
166
|
+
|
|
167
|
+
pipeline_yaml = work_dir / "pipeline.yaml"
|
|
168
|
+
|
|
169
|
+
# The agent's deliverable is a file on disk, not a clean exit
|
|
170
|
+
# code. A real-world failure mode: the agent writes a complete
|
|
171
|
+
# pipeline.yaml at turn N, then runs an extra validation step at
|
|
172
|
+
# turn N+1 that hits a transient API error (ECONNRESET, rate
|
|
173
|
+
# limit, etc.) — the subprocess returns nonzero but the work is
|
|
174
|
+
# done. Treating that as fatal would discard a $2+ run over a
|
|
175
|
+
# blip, so we check for the file FIRST and only fail if it's
|
|
176
|
+
# missing.
|
|
177
|
+
if not pipeline_yaml.is_file():
|
|
178
|
+
if proc.returncode != 0:
|
|
179
|
+
raise DriverError(
|
|
180
|
+
f"claude CLI exited with code {proc.returncode}",
|
|
181
|
+
details=stderr or stdout or "(no output)",
|
|
182
|
+
)
|
|
183
|
+
raise DriverError(
|
|
184
|
+
f"claude CLI finished successfully but did not produce {pipeline_yaml}.",
|
|
185
|
+
details=stdout,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
metadata = self._parse_metadata(stdout)
|
|
189
|
+
if proc.returncode != 0:
|
|
190
|
+
metadata["subprocess_warning"] = (
|
|
191
|
+
f"claude CLI exited with code {proc.returncode} but "
|
|
192
|
+
f"pipeline.yaml was produced. Treating as success."
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
return DriverResult(
|
|
196
|
+
pipeline_yaml_path=pipeline_yaml,
|
|
197
|
+
work_dir=work_dir,
|
|
198
|
+
driver_name=self.name,
|
|
199
|
+
metadata=metadata,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# ───────── Private helpers ─────────
|
|
203
|
+
|
|
204
|
+
def _build_child_env(self) -> dict[str, str]:
|
|
205
|
+
"""Build the environment for the subprocess.
|
|
206
|
+
|
|
207
|
+
Critical: scrub ``ANTHROPIC_API_KEY`` and ``ANTHROPIC_AUTH_TOKEN``.
|
|
208
|
+
In ``claude -p`` mode these env vars always win over an active
|
|
209
|
+
OAuth session, which defeats the whole point of the subscription
|
|
210
|
+
path. The user who wants API-key auth should use the ``api``
|
|
211
|
+
driver; ``ClaudeDriver`` is specifically about reusing the
|
|
212
|
+
user's Claude Max/Pro subscription.
|
|
213
|
+
"""
|
|
214
|
+
env = os.environ.copy()
|
|
215
|
+
env.pop("ANTHROPIC_API_KEY", None)
|
|
216
|
+
env.pop("ANTHROPIC_AUTH_TOKEN", None)
|
|
217
|
+
# Silence spinners in non-interactive output so stdout is just
|
|
218
|
+
# the JSON result and stderr is just the error text (if any).
|
|
219
|
+
env["CLAUDE_CODE_DISABLE_NONINTERACTIVE_ANIMATIONS"] = "1"
|
|
220
|
+
# CLAUDE_CODE_OAUTH_TOKEN, if set in the parent env, is
|
|
221
|
+
# preserved by env.copy() — this is the automation-friendly
|
|
222
|
+
# path for CI environments.
|
|
223
|
+
return env
|
|
224
|
+
|
|
225
|
+
def _build_system_prompt(self, skill_md_text: str) -> str:
|
|
226
|
+
"""Build the ``--append-system-prompt`` content.
|
|
227
|
+
|
|
228
|
+
Claude Code has a default coding-focused system prompt that
|
|
229
|
+
we're happy to keep — we just *append* the rote-graduate
|
|
230
|
+
SKILL.md content on top of it. The agent reads reference
|
|
231
|
+
files via its Read tool; we don't inline them here to keep
|
|
232
|
+
the command-line argument small and to let Claude cache
|
|
233
|
+
reference reads.
|
|
234
|
+
"""
|
|
235
|
+
return (
|
|
236
|
+
"You are the rote graduator. Follow the procedure and rubric "
|
|
237
|
+
"below to graduate the skill identified in the user prompt.\n\n"
|
|
238
|
+
"When asked to read reference files, use the Read tool. When "
|
|
239
|
+
"producing the pipeline.yaml and any extracted Python modules "
|
|
240
|
+
"or signature stubs, use the Write tool.\n\n"
|
|
241
|
+
"================== ROTE GRADUATE SKILL ==================\n\n"
|
|
242
|
+
f"{skill_md_text}"
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
def _build_user_prompt(
|
|
246
|
+
self,
|
|
247
|
+
skill_dir: Path,
|
|
248
|
+
graduator_skill_dir: Path,
|
|
249
|
+
work_dir: Path,
|
|
250
|
+
) -> str:
|
|
251
|
+
"""Build the ``-p`` prompt.
|
|
252
|
+
|
|
253
|
+
Short and task-oriented; the heavy lifting is in the system
|
|
254
|
+
prompt. The goal is to tell the agent which specific paths to
|
|
255
|
+
read from and write to, and what the final deliverable is.
|
|
256
|
+
"""
|
|
257
|
+
return (
|
|
258
|
+
f"Graduate the skill at {skill_dir}.\n\n"
|
|
259
|
+
f"Paths for this run:\n"
|
|
260
|
+
f" - Source skill (read): {skill_dir}\n"
|
|
261
|
+
f" - Rote-graduate rubric (read): {graduator_skill_dir}\n"
|
|
262
|
+
f" - Work directory (write): {work_dir}\n\n"
|
|
263
|
+
f"Begin by reading {graduator_skill_dir}/SKILL.md and its "
|
|
264
|
+
f"reference files under {graduator_skill_dir}/references/, "
|
|
265
|
+
f"then follow the procedure it describes.\n\n"
|
|
266
|
+
f"Your final deliverable is {work_dir}/pipeline.yaml. Write "
|
|
267
|
+
f"any extracted Python modules to {work_dir}/extracted/ and "
|
|
268
|
+
f"any signature stubs to {work_dir}/signatures/, as the "
|
|
269
|
+
f"rubric instructs."
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
def _parse_metadata(self, stdout: str) -> dict[str, Any]:
|
|
273
|
+
"""Parse ``claude -p``'s ``--output-format json`` stdout.
|
|
274
|
+
|
|
275
|
+
Per the Claude Code headless docs, the output is one JSON
|
|
276
|
+
object with fields ``result``, ``cost_usd``, ``duration_ms``,
|
|
277
|
+
``num_turns``, ``session_id``. We strip ``result`` from the
|
|
278
|
+
metadata (it can be large) and keep the numeric/id fields.
|
|
279
|
+
|
|
280
|
+
If the stdout isn't parseable JSON — e.g. because there's a
|
|
281
|
+
banner before it, or animations slipped through — we fall back
|
|
282
|
+
to parsing the last non-empty line, then finally to returning
|
|
283
|
+
a truncated raw output for debugging.
|
|
284
|
+
"""
|
|
285
|
+
if not stdout.strip():
|
|
286
|
+
return {"driver": self.name}
|
|
287
|
+
|
|
288
|
+
data: Any = None
|
|
289
|
+
try:
|
|
290
|
+
data = json.loads(stdout)
|
|
291
|
+
except json.JSONDecodeError:
|
|
292
|
+
# Try the last non-empty line — sometimes there's preamble
|
|
293
|
+
for line in reversed([line for line in stdout.split("\n") if line.strip()]):
|
|
294
|
+
try:
|
|
295
|
+
data = json.loads(line)
|
|
296
|
+
break
|
|
297
|
+
except json.JSONDecodeError:
|
|
298
|
+
continue
|
|
299
|
+
|
|
300
|
+
if not isinstance(data, dict):
|
|
301
|
+
return {
|
|
302
|
+
"driver": self.name,
|
|
303
|
+
"raw_output": stdout[:500],
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
"driver": self.name,
|
|
308
|
+
"cost_usd": data.get("cost_usd"),
|
|
309
|
+
"duration_ms": data.get("duration_ms"),
|
|
310
|
+
"num_turns": data.get("num_turns"),
|
|
311
|
+
"session_id": data.get("session_id"),
|
|
312
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Codex CLI driver — spawns ``codex exec`` for ChatGPT subscription users.
|
|
2
|
+
|
|
3
|
+
This is the ChatGPT Plus/Pro subscription path. It shells out to the
|
|
4
|
+
``codex`` CLI in its non-interactive ``exec`` subcommand.
|
|
5
|
+
|
|
6
|
+
# Auth
|
|
7
|
+
|
|
8
|
+
``codex exec`` reuses the cached login from ``~/.codex/auth.json``.
|
|
9
|
+
The user runs ``codex login`` once interactively with their ChatGPT
|
|
10
|
+
account; tokens auto-refresh. No env scrubbing is needed — unlike
|
|
11
|
+
Claude Code, Codex defaults to the cached OAuth session as long as
|
|
12
|
+
the user hasn't overridden it with ``OPENAI_API_KEY``.
|
|
13
|
+
|
|
14
|
+
# Gotchas
|
|
15
|
+
|
|
16
|
+
* Codex insists the workspace directory be a git repo by default;
|
|
17
|
+
rote's scratch work dir is not, so we always pass
|
|
18
|
+
``--skip-git-repo-check``.
|
|
19
|
+
* An active ChatGPT login *blocks* ``OPENAI_API_KEY``. rote does not
|
|
20
|
+
try to support both auth modes through this driver; if the user
|
|
21
|
+
wants API-key auth, they should use the ``api`` driver (which is
|
|
22
|
+
Anthropic, not OpenAI).
|
|
23
|
+
* Progress goes to stderr; final agent message goes to stdout. With
|
|
24
|
+
``--json``, stdout is NDJSON event stream instead.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from shutil import which
|
|
31
|
+
|
|
32
|
+
from rote.graduator.drivers import DriverResult, GraduatorDriver
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class CodexDriver(GraduatorDriver):
|
|
36
|
+
name: str = "codex"
|
|
37
|
+
|
|
38
|
+
def is_available(self) -> tuple[bool, str]:
|
|
39
|
+
"""Check whether ``codex`` CLI is installed on PATH.
|
|
40
|
+
|
|
41
|
+
Auth is not checked here — that surfaces at ``run()`` time.
|
|
42
|
+
"""
|
|
43
|
+
if which("codex") is None:
|
|
44
|
+
return (
|
|
45
|
+
False,
|
|
46
|
+
"The `codex` CLI is not installed. "
|
|
47
|
+
"Install from https://developers.openai.com/codex/ "
|
|
48
|
+
"and run `codex login` once to enable subscription auth.",
|
|
49
|
+
)
|
|
50
|
+
return (True, "")
|
|
51
|
+
|
|
52
|
+
async def run(
|
|
53
|
+
self,
|
|
54
|
+
skill_dir: Path,
|
|
55
|
+
graduator_skill_dir: Path,
|
|
56
|
+
work_dir: Path,
|
|
57
|
+
) -> DriverResult:
|
|
58
|
+
"""Spawn ``codex exec`` and wait for it to produce a pipeline.yaml.
|
|
59
|
+
|
|
60
|
+
Not yet implemented. Task #13.
|
|
61
|
+
|
|
62
|
+
Planned invocation (per ``docs/agent-runtime.md``)::
|
|
63
|
+
|
|
64
|
+
codex exec \\
|
|
65
|
+
--cd "<work_dir>" \\
|
|
66
|
+
--add-dir "<skill_dir>" \\
|
|
67
|
+
--add-dir "<graduator_skill_dir>" \\
|
|
68
|
+
--sandbox workspace-write \\
|
|
69
|
+
--ask-for-approval never \\
|
|
70
|
+
--skip-git-repo-check \\
|
|
71
|
+
--json \\
|
|
72
|
+
"<prompt with inlined graduator SKILL.md>"
|
|
73
|
+
|
|
74
|
+
Notes:
|
|
75
|
+
|
|
76
|
+
* No ``--append-system-prompt`` flag exists for ``codex exec``;
|
|
77
|
+
the graduator's SKILL.md contents are inlined into the prompt
|
|
78
|
+
argument directly.
|
|
79
|
+
* ``--sandbox workspace-write`` confines writes to ``work_dir``
|
|
80
|
+
+ ``/tmp`` and disables network access (consistent with our
|
|
81
|
+
read-the-skill / write-pipeline.yaml contract).
|
|
82
|
+
"""
|
|
83
|
+
raise NotImplementedError("CodexDriver.run: to be implemented in task #13")
|