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/serve/registry.py ADDED
@@ -0,0 +1,201 @@
1
+ """Manifest registry for graduated pipelines served over MCP.
2
+
3
+ The registry is a single JSON file (default ``~/.rote/registry.json``)
4
+ listing every graduated pipeline the user wants exposed as an MCP tool:
5
+ tool name, description, the pipeline.yaml it came from, a JSON Schema
6
+ for the tool's input, and the runtime trigger config (Temporal address /
7
+ task queue, Cloudflare workflow URL, …).
8
+
9
+ ``rote register`` writes entries; ``rote serve`` reads them. The file is
10
+ the contract between the two commands — same philosophy as the driver
11
+ layer's ``work_dir/pipeline.yaml`` contract.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import re
18
+ from datetime import UTC, datetime
19
+ from pathlib import Path
20
+ from typing import Annotated, Any, Literal
21
+
22
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
23
+
24
+ from rote.ir import Pipeline, PipelineInput
25
+
26
+ REGISTRY_SCHEMA_VERSION = 1
27
+
28
+ #: MCP tool names must be safe for every client; Cloudflare's signal-name
29
+ #: constraint ([A-Za-z0-9_-]+) is the strictest charset we target, so the
30
+ #: registry enforces the same one for tool names.
31
+ _TOOL_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")
32
+
33
+
34
+ def default_registry_path() -> Path:
35
+ """The registry location used when ``--registry`` is not given."""
36
+ return Path.home() / ".rote" / "registry.json"
37
+
38
+
39
+ class TemporalTrigger(BaseModel):
40
+ """How to start a graduated pipeline on a Temporal cluster."""
41
+
42
+ model_config = ConfigDict(extra="forbid")
43
+
44
+ runtime: Literal["temporal"] = "temporal"
45
+ address: str = Field(
46
+ default="localhost:7233",
47
+ description="Temporal frontend address, host:port",
48
+ )
49
+ namespace: str = Field(default="default")
50
+ task_queue: str = Field(description="Task queue the graduated worker polls")
51
+ workflow_name: str = Field(
52
+ description=(
53
+ "Registered workflow *type* name. The Temporal adapter emits a "
54
+ "versioned name (PascalCase pipeline name + pipeline hash), so "
55
+ "this must match the emitted @workflow.defn name exactly."
56
+ ),
57
+ )
58
+
59
+
60
+ class CloudflareTrigger(BaseModel):
61
+ """How to start a graduated pipeline deployed as a Cloudflare Worker.
62
+
63
+ The emitted ``src/index.ts`` fetch handler accepts a JSON POST body
64
+ (the workflow params) and responds with ``{id, status}``.
65
+ """
66
+
67
+ model_config = ConfigDict(extra="forbid")
68
+
69
+ runtime: Literal["cloudflare"] = "cloudflare"
70
+ url: str = Field(description="Deployed worker URL (the fetch trigger endpoint)")
71
+ status_url: str | None = Field(
72
+ default=None,
73
+ description=(
74
+ "Optional status endpoint template containing '{workflow_id}'. "
75
+ "The emitted worker does not expose a status route by default, "
76
+ "so without this the companion status tool directs users to "
77
+ "`wrangler workflows instances describe` instead."
78
+ ),
79
+ )
80
+
81
+
82
+ Trigger = Annotated[TemporalTrigger | CloudflareTrigger, Field(discriminator="runtime")]
83
+
84
+
85
+ class RegistryEntry(BaseModel):
86
+ """One graduated pipeline exposed as one MCP tool."""
87
+
88
+ model_config = ConfigDict(extra="forbid")
89
+
90
+ name: str = Field(description="MCP tool name (unique within the registry)")
91
+ description: str = ""
92
+ pipeline_yaml: str = Field(description="Absolute path to the graduated pipeline.yaml")
93
+ input_schema: dict[str, Any] = Field(
94
+ description="JSON Schema for the tool input (the pipeline's input contract)",
95
+ )
96
+ trigger: Trigger
97
+ registered_at: str = Field(
98
+ default_factory=lambda: datetime.now(UTC).isoformat(),
99
+ description="ISO-8601 timestamp of the last register/update",
100
+ )
101
+
102
+ @field_validator("name")
103
+ @classmethod
104
+ def _validate_tool_name(cls, v: str) -> str:
105
+ if not _TOOL_NAME_RE.match(v):
106
+ raise ValueError(
107
+ f"tool name {v!r} must match [A-Za-z0-9][A-Za-z0-9_-]* (MCP tool-name safe charset)"
108
+ )
109
+ return v
110
+
111
+
112
+ class Registry(BaseModel):
113
+ """The full registry file."""
114
+
115
+ model_config = ConfigDict(extra="forbid")
116
+
117
+ version: int = REGISTRY_SCHEMA_VERSION
118
+ entries: list[RegistryEntry] = Field(default_factory=list)
119
+
120
+ def get(self, name: str) -> RegistryEntry | None:
121
+ for entry in self.entries:
122
+ if entry.name == name:
123
+ return entry
124
+ return None
125
+
126
+ def upsert(self, entry: RegistryEntry) -> bool:
127
+ """Insert or replace by tool name. Returns True if an entry was replaced."""
128
+ for i, existing in enumerate(self.entries):
129
+ if existing.name == entry.name:
130
+ self.entries[i] = entry
131
+ return True
132
+ self.entries.append(entry)
133
+ return False
134
+
135
+ @classmethod
136
+ def load(cls, path: str | Path) -> Registry:
137
+ """Load the registry; a missing file is an empty registry."""
138
+ p = Path(path)
139
+ if not p.exists():
140
+ return cls()
141
+ raw = json.loads(p.read_text(encoding="utf-8"))
142
+ return cls.model_validate(raw)
143
+
144
+ def save(self, path: str | Path) -> None:
145
+ """Write the registry atomically (tmp file + rename)."""
146
+ p = Path(path)
147
+ p.parent.mkdir(parents=True, exist_ok=True)
148
+ tmp = p.with_suffix(p.suffix + ".tmp")
149
+ tmp.write_text(self.model_dump_json(indent=2) + "\n", encoding="utf-8")
150
+ tmp.replace(p)
151
+
152
+
153
+ def input_schema_for(pipeline_input: PipelineInput) -> dict[str, Any]:
154
+ """Derive the MCP tool's inputSchema from the pipeline's input contract.
155
+
156
+ Prefers a structured ``input_schema`` field if the loaded model carries
157
+ one (being added to :class:`rote.ir.PipelineInput` concurrently — coded
158
+ defensively via ``getattr`` so this works before and after that lands).
159
+ Otherwise synthesizes a permissive object schema from the required /
160
+ optional name lists, which are untyped in the current IR.
161
+ """
162
+ explicit = getattr(pipeline_input, "input_schema", None)
163
+ if isinstance(explicit, dict) and explicit:
164
+ return dict(explicit)
165
+
166
+ properties: dict[str, Any] = {
167
+ name: {} for name in [*pipeline_input.required, *pipeline_input.optional]
168
+ }
169
+ return {
170
+ "type": "object",
171
+ "title": pipeline_input.type,
172
+ "description": (
173
+ f"Input contract for the graduated pipeline "
174
+ f"(type {pipeline_input.type}; field types are untyped in the IR v0)"
175
+ ),
176
+ "properties": properties,
177
+ "required": list(pipeline_input.required),
178
+ "additionalProperties": True,
179
+ }
180
+
181
+
182
+ def entry_from_pipeline(
183
+ pipeline: Pipeline,
184
+ pipeline_yaml: Path,
185
+ trigger: TemporalTrigger | CloudflareTrigger,
186
+ name: str | None = None,
187
+ ) -> RegistryEntry:
188
+ """Build a registry entry from a loaded pipeline IR.
189
+
190
+ Tool name defaults to ``pipeline.name`` with any charset-unsafe
191
+ characters replaced by ``-``; description comes from
192
+ ``pipeline.description`` (first paragraph, whitespace-normalized).
193
+ """
194
+ tool_name = name if name is not None else re.sub(r"[^A-Za-z0-9_-]+", "-", pipeline.name)
195
+ return RegistryEntry(
196
+ name=tool_name,
197
+ description=pipeline.description.strip(),
198
+ pipeline_yaml=str(pipeline_yaml.resolve()),
199
+ input_schema=input_schema_for(pipeline.input),
200
+ trigger=trigger,
201
+ )
rote/serve/server.py ADDED
@@ -0,0 +1,249 @@
1
+ """The ``rote serve`` MCP server.
2
+
3
+ One FastMCP (>=3.4) server whose tools are sourced from the manifest
4
+ registry (:mod:`rote.serve.registry`) via a custom v3 ``Provider``:
5
+
6
+ * every registry entry becomes one MCP tool named ``entry.name`` whose
7
+ ``inputSchema`` is the entry's stored JSON Schema, and
8
+ * a companion ``<entry.name>_status`` tool that polls the workflow.
9
+
10
+ Freshness has two layers:
11
+
12
+ 1. The provider re-reads the registry file (cheap content-hash check)
13
+ on every ``tools/list`` / ``tools/call``, so a ``rote register``
14
+ issued while the server is running is visible on the next request.
15
+ 2. A background watcher polls the registry file and, when it changes,
16
+ sends ``notifications/tools/list_changed`` to every connected
17
+ session so clients that honor the notification (Claude Code does)
18
+ refresh without a reconnect. FastMCP 3.4 does not emit this
19
+ notification itself when providers change, so we track sessions via
20
+ middleware and use the low-level SDK's
21
+ ``ServerSession.send_tool_list_changed()`` directly.
22
+
23
+ Long-running handling: graduated pipelines run minutes to days (HITL
24
+ gates), and their durability lives in Temporal / Cloudflare — not in
25
+ this process. Trigger tools therefore return ``{workflow_id, status:
26
+ "started"}`` immediately and clients poll the ``_status`` companion.
27
+ FastMCP 3.4 does ship server-side MCP Tasks support (SEP-1686), but the
28
+ extension is still a spec RC and running the trigger as a task would
29
+ tie a multi-day workflow's observability to this process's lifetime,
30
+ so the poll-tool pattern is deliberate. Revisit once the 2026-07-28
31
+ spec lands and Claude clients request task augmentation.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import asyncio
37
+ import contextlib
38
+ import hashlib
39
+ import logging
40
+ import weakref
41
+ from collections.abc import AsyncIterator, Sequence
42
+ from contextlib import asynccontextmanager
43
+ from pathlib import Path
44
+ from typing import Any
45
+
46
+ import mcp.types as mt
47
+ from fastmcp import FastMCP
48
+ from fastmcp.exceptions import ToolError
49
+ from fastmcp.server.middleware import CallNext, Middleware, MiddlewareContext
50
+ from fastmcp.server.providers import Provider
51
+ from fastmcp.tools import Tool, ToolResult
52
+ from mcp.server.session import ServerSession
53
+
54
+ from rote.serve import backends
55
+ from rote.serve.registry import Registry, RegistryEntry
56
+
57
+ logger = logging.getLogger(__name__)
58
+
59
+ #: How often the background watcher checks the registry file for changes.
60
+ DEFAULT_POLL_INTERVAL_S = 1.0
61
+
62
+ STATUS_TOOL_SUFFIX = "_status"
63
+
64
+
65
+ # ───────── Tools ─────────
66
+
67
+
68
+ class PipelineTool(Tool):
69
+ """Triggers the graduated workflow behind one registry entry."""
70
+
71
+ entry: RegistryEntry
72
+
73
+ async def run(self, arguments: dict[str, Any]) -> ToolResult:
74
+ try:
75
+ result = await backends.start_workflow(self.entry, arguments)
76
+ except backends.BackendError as e:
77
+ raise ToolError(str(e)) from e
78
+ return ToolResult(structured_content=result)
79
+
80
+
81
+ class PipelineStatusTool(Tool):
82
+ """Polls the status of a workflow started by the companion trigger tool."""
83
+
84
+ entry: RegistryEntry
85
+
86
+ async def run(self, arguments: dict[str, Any]) -> ToolResult:
87
+ workflow_id = arguments["workflow_id"]
88
+ try:
89
+ result = await backends.workflow_status(self.entry, workflow_id)
90
+ except backends.BackendError as e:
91
+ raise ToolError(str(e)) from e
92
+ return ToolResult(structured_content=result)
93
+
94
+
95
+ def _trigger_tool(entry: RegistryEntry) -> PipelineTool:
96
+ return PipelineTool(
97
+ name=entry.name,
98
+ description=(
99
+ f"{entry.description}\n\n"
100
+ f"Starts the graduated '{entry.name}' pipeline on "
101
+ f"{entry.trigger.runtime} and returns immediately with a workflow_id. "
102
+ f"Poll progress with the {entry.name}{STATUS_TOOL_SUFFIX} tool."
103
+ ).strip(),
104
+ parameters=entry.input_schema,
105
+ entry=entry,
106
+ )
107
+
108
+
109
+ def _status_tool(entry: RegistryEntry) -> PipelineStatusTool:
110
+ return PipelineStatusTool(
111
+ name=f"{entry.name}{STATUS_TOOL_SUFFIX}",
112
+ description=(
113
+ f"Poll the status of a '{entry.name}' pipeline run previously "
114
+ f"started with the {entry.name} tool."
115
+ ),
116
+ parameters={
117
+ "type": "object",
118
+ "properties": {
119
+ "workflow_id": {
120
+ "type": "string",
121
+ "description": f"The workflow_id returned by the {entry.name} tool",
122
+ },
123
+ },
124
+ "required": ["workflow_id"],
125
+ "additionalProperties": False,
126
+ },
127
+ entry=entry,
128
+ )
129
+
130
+
131
+ # ───────── Provider ─────────
132
+
133
+
134
+ class RegistryProvider(Provider):
135
+ """Sources MCP tools from the registry file, re-reading it on change.
136
+
137
+ Also owns the background watcher (started via the provider
138
+ ``lifespan``, which FastMCP scopes to the server's lifetime) and the
139
+ set of live sessions to notify. Sessions are held weakly so closed
140
+ connections never pin memory or receive sends.
141
+ """
142
+
143
+ def __init__(
144
+ self,
145
+ registry_path: str | Path,
146
+ poll_interval: float = DEFAULT_POLL_INTERVAL_S,
147
+ ) -> None:
148
+ super().__init__()
149
+ self.registry_path = Path(registry_path)
150
+ self.poll_interval = poll_interval
151
+ self.sessions: weakref.WeakSet[ServerSession] = weakref.WeakSet()
152
+ self._digest: bytes | None = None
153
+ self._registry = Registry()
154
+ self._load_if_changed()
155
+
156
+ # ── registry loading ──
157
+
158
+ def _current_digest(self) -> bytes | None:
159
+ try:
160
+ return hashlib.sha256(self.registry_path.read_bytes()).digest()
161
+ except FileNotFoundError:
162
+ return None
163
+
164
+ def _load_if_changed(self) -> bool:
165
+ """Reload the registry if the file content changed. Returns True on change."""
166
+ digest = self._current_digest()
167
+ if digest == self._digest:
168
+ return False
169
+ self._digest = digest
170
+ self._registry = Registry.load(self.registry_path)
171
+ return True
172
+
173
+ # ── Provider hooks ──
174
+
175
+ async def _list_tools(self) -> Sequence[Tool]:
176
+ self._load_if_changed()
177
+ tools: list[Tool] = []
178
+ for entry in self._registry.entries:
179
+ tools.append(_trigger_tool(entry))
180
+ tools.append(_status_tool(entry))
181
+ return tools
182
+
183
+ @asynccontextmanager
184
+ async def lifespan(self) -> AsyncIterator[None]:
185
+ watcher = asyncio.create_task(self._watch_registry())
186
+ try:
187
+ yield
188
+ finally:
189
+ watcher.cancel()
190
+
191
+ # ── change notification ──
192
+
193
+ async def _watch_registry(self) -> None:
194
+ while True:
195
+ await asyncio.sleep(self.poll_interval)
196
+ if self._load_if_changed():
197
+ await self._notify_tool_list_changed()
198
+
199
+ async def _notify_tool_list_changed(self) -> None:
200
+ for session in list(self.sessions):
201
+ try:
202
+ await session.send_tool_list_changed()
203
+ except Exception:
204
+ # A torn-down session must not kill the watcher or block
205
+ # notifying the remaining live sessions.
206
+ logger.debug("failed to notify session of tool list change", exc_info=True)
207
+
208
+
209
+ class SessionTrackingMiddleware(Middleware):
210
+ """Records every session that talks to us so the watcher can notify it."""
211
+
212
+ def __init__(self, provider: RegistryProvider) -> None:
213
+ self._provider = provider
214
+
215
+ async def on_request(
216
+ self,
217
+ context: MiddlewareContext[mt.Request[Any, Any]],
218
+ call_next: CallNext[mt.Request[Any, Any], Any],
219
+ ) -> Any:
220
+ fastmcp_ctx = context.fastmcp_context
221
+ if fastmcp_ctx is not None:
222
+ # No live session on this context (init edge cases) — skip.
223
+ with contextlib.suppress(AttributeError, ValueError):
224
+ self._provider.sessions.add(fastmcp_ctx.session)
225
+ return await call_next(context)
226
+
227
+
228
+ # ───────── Server construction ─────────
229
+
230
+
231
+ def build_server(
232
+ registry_path: str | Path,
233
+ poll_interval: float = DEFAULT_POLL_INTERVAL_S,
234
+ ) -> FastMCP[None]:
235
+ """Build the ``rote serve`` FastMCP server for a registry file."""
236
+ provider = RegistryProvider(registry_path, poll_interval=poll_interval)
237
+ return FastMCP(
238
+ name="rote",
239
+ instructions=(
240
+ "Tools on this server trigger graduated rote pipelines — "
241
+ "deterministic workflows produced from Anthropic-style skills. "
242
+ "Each pipeline has a trigger tool (returns a workflow_id "
243
+ "immediately) and a companion <name>_status tool for polling, "
244
+ "because pipelines can run for minutes to days (human-in-the-"
245
+ "loop gates)."
246
+ ),
247
+ providers=[provider],
248
+ middleware=[SessionTrackingMiddleware(provider)],
249
+ )
@@ -0,0 +1,230 @@
1
+ ---
2
+ name: rote-graduate
3
+ description: >-
4
+ Analyze an Anthropic-style skill bundle (SKILL.md + references) and produce
5
+ a graduation plan that separates deterministic work into extracted Python
6
+ modules, LLM-as-judge work into typed DSPy/BAML signatures, agentic work
7
+ into bounded agent loops, and human-in-the-loop gates into durable suspend
8
+ points. Emit the whole pipeline as a runtime-agnostic intermediate
9
+ representation (pipeline.yaml) plus runnable code for a target workflow
10
+ engine (Temporal, Inngest, Restate, or plain async) via pluggable adapters.
11
+ Trigger phrases: "graduate this skill", "turn this skill into a workflow",
12
+ "harden this skill", "extract deterministic parts of this skill", "compile
13
+ skill to pipeline", "make this skill reliable in production", "analyze
14
+ skill for codification", "emit this skill as a temporal workflow", "rote
15
+ this skill".
16
+ ---
17
+
18
+ # Skill Graduation
19
+
20
+ You are the rote graduator. You read fuzzy AI skills and produce reliable
21
+ workflows.
22
+
23
+ **Input:** a directory containing a `SKILL.md` and an optional `references/`
24
+ folder with sub-files.
25
+
26
+ **Output:** an intermediate representation (`pipeline.yaml`) plus
27
+ runtime-specific emitted code that the user can drop into their durable
28
+ execution engine. Along the way you also produce a human-readable graduation
29
+ report that highlights what was codified, what stayed agentic, and what
30
+ judgment calls you made.
31
+
32
+ The guiding philosophy: **keep the LLM at points where the input is unbounded
33
+ or ambiguous (parsing, classifying, drafting, summarizing). Codify everything
34
+ else — control flow, retries, parallelism, idempotency, side-effecting tool
35
+ calls, fixed batching — into deterministic code.** Every token spent
36
+ re-deriving a known-fixed procedure at runtime is waste.
37
+
38
+ ## Phase Routing
39
+
40
+ | Entry Point | Start At | Example Triggers |
41
+ |---|---|---|
42
+ | Full graduation | Phase 1 → 7 | "graduate the bdr-outreach skill" |
43
+ | Report only | Phase 1 → 5, skip 6 | "analyze this skill, don't emit code" |
44
+ | Re-emit for different runtime | Phase 6 | "emit the existing pipeline.yaml for inngest" |
45
+ | Update graduation after skill changed | Phase 1 → 7, diff | "regraduate bdr-outreach, it changed" |
46
+
47
+ ## Pipeline
48
+
49
+ ### Phase 1: Intake
50
+
51
+ Read the target directory exhaustively:
52
+
53
+ 1. `SKILL.md` — parse the frontmatter (name, description, trigger phrases),
54
+ then read the full body. Note every named phase, gate, and rule.
55
+ 2. `references/` — read every sub-file. These are the rubric detail, the
56
+ API choreography, and the domain constraints.
57
+ 3. Build a mental model of: the skill's goal, its preconditions, its tools,
58
+ its explicit HITL gates ("present to user", "wait for approval"), and
59
+ any MANDATORY checklists enforced only by prose.
60
+
61
+ Do not proceed to Phase 2 until you can restate the skill's phase structure
62
+ in your own words.
63
+
64
+ ### Phase 2: Node Classification
65
+
66
+ Read `references/node-kinds.md` before starting this phase.
67
+
68
+ Walk through the skill from start to finish. For every distinct step, assign
69
+ it exactly one of the five node kinds:
70
+
71
+ | Kind | Signal |
72
+ |---|---|
73
+ | `pure_function` | Fixed logic, deterministic I/O, no LLM reasoning needed |
74
+ | `llm_judge` | Fuzzy classification against a rubric, typed input/output |
75
+ | `agent_loop` | Exploratory tool use, variable path, genuinely needs LLM orchestration |
76
+ | `hitl_gate` | Explicit human review/approval required before proceeding |
77
+ | `external_call` | Deterministic API call that needs retry/timeout semantics |
78
+
79
+ When a step could be classified two ways, prefer the more deterministic
80
+ kind. The goal is to push as much as possible into `pure_function` and
81
+ `external_call`, and reserve `agent_loop` for steps where the procedure
82
+ genuinely varies from run to run.
83
+
84
+ Output: a table mapping each step to its kind, with a one-sentence
85
+ justification per row.
86
+
87
+ ### Phase 3: Codification Scan
88
+
89
+ Read `references/crystallization-heuristics.md` before starting this phase.
90
+
91
+ For each node classified as `pure_function` or `external_call`, surface every
92
+ codification opportunity:
93
+
94
+ - **Literal pseudocode or Python pasted into the prompt.** The skill's
95
+ author already wrote it — you just need to lift it into a real module.
96
+ - **Fixed constants** (batch sizes, taxonomy IDs, thresholds, retry counts,
97
+ timeouts). If the prompt says "batch of 10", that 10 is a constant, not
98
+ a judgment call.
99
+ - **Deterministic loops** with clear termination conditions.
100
+ - **String templates** for reports, output formats, or structured
101
+ responses.
102
+ - **MANDATORY checklists** enforced by English prose. These are the highest
103
+ value extractions — moving a required check from prose into code makes it
104
+ impossible to skip accidentally.
105
+
106
+ For each extraction, produce a record of: source file and line, the current
107
+ prose, the proposed extracted function name and signature, and any constants
108
+ to lift into config.
109
+
110
+ ### Phase 4: LLM-Judge Extraction
111
+
112
+ Read `references/llm-judge-extraction.md` before starting this phase.
113
+
114
+ For each `llm_judge` node, draft a typed signature using DSPy or BAML
115
+ conventions:
116
+
117
+ - **Input schema** (Pydantic / TypedDict): every field the LLM needs,
118
+ sourced from upstream node outputs.
119
+ - **Output schema**: enum-bounded decisions plus structured rationale.
120
+ Prefer enums over free-text wherever the skill's rubric implies a
121
+ discrete choice.
122
+ - **Seed examples**: harvest 3–5 examples directly from the skill's own
123
+ rubric. If the rubric says "discard MSLs", construct an example with an
124
+ MSL contact and the `discard` decision.
125
+ - **Eval stub**: scaffold a pytest-style test file that asserts the
126
+ signature against each rule the rubric names.
127
+
128
+ Output: a `signatures/<node_name>.py` file per `llm_judge` node, plus a
129
+ matching `evals/<node_name>.jsonl` seed.
130
+
131
+ ### Phase 5: IR Assembly
132
+
133
+ Read `references/ir-schema.md` before starting this phase.
134
+
135
+ Assemble the full DAG as `pipeline.yaml`. Include:
136
+
137
+ - `input.input_schema`: the full JSON Schema for the pipeline input
138
+ payload, promoted from the entry nodes' signature design (see
139
+ `ir-schema.md`).
140
+ - `nodes`: every step from Phase 2, with its kind, input/output types,
141
+ `inputs:` data-flow bindings (param → `pipeline.input[.field]` /
142
+ `<node_id>.output[.field]`), timeout, retry policy, and (for
143
+ `pure_function` / `llm_judge`) a reference to the extracted module.
144
+ - `edges`: data flow between nodes, including fan-out for batch
145
+ processing.
146
+ - `hitl_gates`: for every `hitl_gate` node, the signal name, timeout,
147
+ and notification target.
148
+ - `config`: top-level schedule, on_failure handler, observability
149
+ settings.
150
+
151
+ The `pipeline.yaml` is the runtime-agnostic source of truth. Everything
152
+ Phase 6 emits is derived from it.
153
+
154
+ ### Phase 6: Adapter Invocation
155
+
156
+ **You do not emit runtime code yourself.** The rote project ships
157
+ runtime adapters as Python code (`rote/adapters/temporal.py`,
158
+ `rote/adapters/inngest.py`, etc.) that consume the `pipeline.yaml` you
159
+ produced in Phase 5 and emit runnable code. Your job in this phase is
160
+ to **validate that your IR is complete enough for the adapter to
161
+ succeed**, then let the adapter run.
162
+
163
+ Checklist before declaring Phase 5 done:
164
+
165
+ - Every `pure_function` / `external_call` node has an `impl:` pointing
166
+ at a real file you created in Phase 3.
167
+ - Every `llm_judge` node has a `signature:` pointing at a real file
168
+ you created in Phase 4.
169
+ - Every `agent_loop` node has `tools:` listed.
170
+ - Every `hitl_gate` node has a `signal:`.
171
+ - Every node mentioned in `entry_nodes`, `exit_nodes`, `edges.from`,
172
+ `edges.to`, and `loop_body` exists in `nodes:`.
173
+ - `rote.ir.load_pipeline(pipeline.yaml)` loads without validation
174
+ errors. If you can call this function, run it as a dry check.
175
+
176
+ The `rote emit <pipeline.yaml> --runtime temporal --out <dir>` CLI
177
+ command (or its programmatic equivalent) takes it from there. You do
178
+ not need to read the adapter's implementation — treat it as a black
179
+ box that accepts a valid IR and produces code.
180
+
181
+ **Why the split:** the IR and its validation logic are the load-bearing
182
+ parts of graduation. Code emission is template substitution and belongs
183
+ in deterministic Python. Keeping the agent out of the emission step
184
+ means the emitted output is reproducible and reviewable — every change
185
+ to what Temporal code looks like is a change to the adapter, not a new
186
+ agent trajectory.
187
+
188
+ ### Phase 7: Graduation Report
189
+
190
+ Produce a human-readable Markdown report in `graduation-report.md` covering:
191
+
192
+ - **Summary metrics**: total nodes, breakdown by kind, estimated token cost
193
+ reduction per run, list of HITL gates and what they block on.
194
+ - **Crystallization log**: every prose-to-code extraction from Phase 3,
195
+ with before/after snippets.
196
+ - **Open questions**: ambiguous judgment calls where you made a decision
197
+ but the human reviewer should verify. Be explicit about what you chose
198
+ and why.
199
+ - **Suggested next steps**: what to dogfood first, which evals to run,
200
+ which node to regraduate after the first production run reveals new
201
+ information.
202
+
203
+ ## Invariants
204
+
205
+ These rules apply across every phase:
206
+
207
+ - **Never silently drop a MANDATORY check.** If the skill says a check is
208
+ required and you can't codify it, surface it as an open question in
209
+ Phase 7 — never as a silent omission.
210
+ - **Prefer deterministic over agentic.** When in doubt, codify. Leaving
211
+ something agentic is a last resort for genuinely exploratory work.
212
+ - **The IR is the source of truth, not the emitted code.** If the IR and
213
+ a runtime-specific artifact disagree, the IR wins and Phase 6 re-runs.
214
+ - **Two adapters minimum before declaring the IR generic.** Until two
215
+ runtimes work, assume the IR is secretly shaped like the first one.
216
+ - **Dogfood yourself.** Once a stable version exists, point rote-graduate
217
+ at its own SKILL.md. The parts that can be crystallized should be; the
218
+ parts that stay agentic are the real source of judgment.
219
+
220
+ ## Related Concepts
221
+
222
+ - **"Workflow vs. agent"** — the distinction from Anthropic's *Building
223
+ effective agents* essay. This tool is fundamentally about moving steps
224
+ from the agent column to the workflow column when the data supports it.
225
+ - **"Blueprint First, Model Second"** — the EBF paper's framing. The
226
+ blueprint is the `pipeline.yaml`; the LLM is a specialized tool at
227
+ specific nodes.
228
+ - **DSPy compile** — what the `llm_judge` signatures graduate *into* once
229
+ you have enough examples. rote produces the signature; dspy.compile
230
+ optimizes the prompt and few-shots against your eval set.