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.
@@ -0,0 +1,260 @@
1
+ # Crystallization Heuristics
2
+
3
+ Phase 3 of the graduator is where the highest leverage lives: finding
4
+ every place the source skill's prose is hiding a deterministic procedure
5
+ that *should be code*. This file is the pattern library.
6
+
7
+ The guiding rule is simple: **if the skill's author already wrote down
8
+ the exact procedure, don't make the LLM re-derive it at runtime.** Every
9
+ token spent re-deriving a known-fixed procedure is waste, and every
10
+ MANDATORY check enforced only by prose is a bug waiting to happen.
11
+
12
+ ## Why crystallization is the biggest win
13
+
14
+ Skills that graduate well typically see:
15
+ - **50–70% token reduction per run** from moving formatting, batching,
16
+ and rule enforcement into code
17
+ - **Reliability wins** from making MANDATORY checks impossible to skip
18
+ - **Testability wins** from having the deterministic parts in regression
19
+ suites instead of trusting the agent to follow instructions
20
+
21
+ Crystallization is mostly a pattern-matching exercise. This file lists
22
+ the patterns you should actively hunt for.
23
+
24
+ ## The patterns
25
+
26
+ ### Pattern 1 — Literal Python or pseudocode in the prompt
27
+
28
+ **What it looks like:** the skill's markdown includes fenced code blocks
29
+ (```python` or ``` `text` ) that describe exactly how to do something.
30
+ The LLM is being asked to *read* this code and *re-execute it in its
31
+ head*.
32
+
33
+ **BDR example:**
34
+ `references/conference-enrichment.md` includes a full Python function
35
+ definition for `is_pharma(company_name)` — a keyword classifier with
36
+ hardcoded include/exclude lists. The LLM reads this file, mentally
37
+ "runs" the function on each contact, and returns a list. Absurd.
38
+
39
+ **What to do:**
40
+ - Extract the code verbatim into an `extracted/*.py` module.
41
+ - Create a `pure_function` node that calls it.
42
+ - Note the extraction in the graduation report so the human reviewer
43
+ can verify the extraction matches the original.
44
+
45
+ **How to detect:** grep for ` ```python`, ` ```py`, and `def `. Also
46
+ look for `for contact in`-style pseudocode in plain prose — it's the
47
+ same pattern without the code fence.
48
+
49
+ ---
50
+
51
+ ### Pattern 2 — Fixed constants embedded in prose
52
+
53
+ **What it looks like:** the skill says "batch of 10", "wait 30 days",
54
+ "accuracy below 85", "up to 250 per call". These numbers never change
55
+ run-over-run; they're API limits or policy thresholds.
56
+
57
+ **BDR examples:**
58
+ - `enrich_contact_batch`: `batch_size: 10` (ZoomInfo API limit)
59
+ - `hubspot_upsert`: `batch_size: 100` (HubSpot API limit)
60
+ - `hubspot_create_list` → `add_contacts_to_list`: `batch_size: 250`
61
+ - `exclusion_check_recent`: `days_back: 30`
62
+ - `vet_contact`: `min_accuracy_score: 85`
63
+
64
+ **What to do:**
65
+ - Lift the constant into the node's `constants:` field in the IR.
66
+ - In the extracted Python module, define it as a module-level constant
67
+ (so it shows up once and is trivially overridable if policy changes).
68
+ - Enforce it in code — the extracted function should *reject* inputs
69
+ that violate the constraint, not just document it.
70
+
71
+ **Why this matters:** constants that live only in prose drift when
72
+ prompts get edited. Once a limit lives in code, it stops drifting. The
73
+ BDR skill's exclusion checks say "30 days" in the prose — in the
74
+ graduated pipeline, `RECENT_EMAIL_DAYS = 30` cannot be silently
75
+ changed to 7 without a commit that shows up in review.
76
+
77
+ **How to detect:** grep for numeric literals in the prose. Most of them
78
+ are constants begging to be lifted. Questions to ask: "does this number
79
+ ever change across runs?" — if no, it's a constant.
80
+
81
+ ---
82
+
83
+ ### Pattern 3 — MANDATORY checks enforced only by prose
84
+
85
+ **What it looks like:** the skill uses the word "MANDATORY", "required",
86
+ "always", "must", or "do not skip" to describe a check that happens
87
+ before some irreversible action.
88
+
89
+ **BDR example:** Phase 5 exclusion checks. The skill's
90
+ `hubspot-operations.md` literally says "MANDATORY" in ALL CAPS for the
91
+ three exclusion checks (do-not-contact, recently emailed, active
92
+ sequence). In the agent loop, nothing actually prevents the LLM from
93
+ skipping these if prompt drift is bad.
94
+
95
+ **What to do:**
96
+ - Classify the check as a `pure_function` or `external_call` node.
97
+ - Set `mandatory: true` on the node in the IR. The IR schema enforces
98
+ that mandatory nodes cannot be made conditional.
99
+ - The adapter will emit the mandatory node as an unconditional activity
100
+ the workflow always calls in order. The prose enforcement disappears
101
+ because the code-level enforcement replaces it.
102
+
103
+ **Why this matters:** this is the single highest-leverage pattern in
104
+ graduation. A MANDATORY prose check is a *reliability bug waiting to
105
+ happen* — every prompt edit, every model upgrade, every long agent
106
+ trajectory is a chance for the check to get forgotten. Moving it to
107
+ code makes skipping impossible.
108
+
109
+ **How to detect:** grep (case-insensitive) for "mandatory", "required",
110
+ "always", "must", "never skip", "do not skip", "be sure to". Audit
111
+ every hit.
112
+
113
+ ---
114
+
115
+ ### Pattern 4 — Fixed string templates for reports and outputs
116
+
117
+ **What it looks like:** the skill shows an exact expected output
118
+ format — usually in a fenced code block or a bulleted template —
119
+ and the agent's job is to fill in the blanks.
120
+
121
+ **BDR example:** `hubspot-operations.md` shows the exact
122
+ pre-enrollment report format, counts-by-reason and all. The LLM was
123
+ generating this markdown by hand every run. Wasteful.
124
+
125
+ **What to do:**
126
+ - Create a `pure_function` node that takes typed inputs (the counts,
127
+ the contact lists) and produces the markdown string.
128
+ - The function is pure string formatting — no LLM.
129
+
130
+ **How to detect:** look for ````text` or ````markdown` blocks with
131
+ placeholder syntax (`[name]`, `{field}`, `...`). Also look for any
132
+ output spec with a fixed structure.
133
+
134
+ ---
135
+
136
+ ### Pattern 5 — Batching and rate-limiting loops with fixed semantics
137
+
138
+ **What it looks like:** the skill says "process in batches of N",
139
+ "chunk by Y per call", "wait between requests". The loop structure and
140
+ batch size are fixed.
141
+
142
+ **BDR examples:**
143
+ - Enrich contacts in batches of 10
144
+ - Upsert contacts in batches of 100
145
+ - Add to list in batches of 250
146
+
147
+ **What to do:**
148
+ - Extract the batch size as a constant (see Pattern 2).
149
+ - The batching loop lives inside the extracted function, not the
150
+ workflow — the workflow calls the function once with a list of any
151
+ size, and the function handles chunking internally.
152
+ - The adapter can set appropriate activity timeouts based on the
153
+ expected batch count.
154
+
155
+ **How to detect:** grep for "batch of", "chunk", "max ... per".
156
+
157
+ ---
158
+
159
+ ### Pattern 6 — Taxonomy / enum lookups that never change
160
+
161
+ **What it looks like:** the skill does setup lookups (ID resolution,
162
+ category mapping, static reference data) at the start of every run.
163
+ The underlying data is stable — these IDs don't change month-over-month.
164
+
165
+ **BDR example:** `taxonomy_lookup` — the ZoomInfo management level IDs
166
+ (VP, Director), industry IDs (pharma, biotech), department ID (Medical
167
+ & Health). These IDs have been stable for years.
168
+
169
+ **What to do:**
170
+ - Extract as a `pure_function` node with a `cache:` config for
171
+ aggressive caching (e.g., `persistent`, `30d`).
172
+ - On first run, the function hits the API; subsequent runs read from
173
+ cache until TTL expires.
174
+
175
+ **How to detect:** look for "look up", "resolve", "get the ID for",
176
+ "find the category", especially as setup steps at the start of a
177
+ phase.
178
+
179
+ ---
180
+
181
+ ### Pattern 7 — Numeric or enum thresholds disguised as LLM rules
182
+
183
+ **What it looks like:** the skill's rubric includes a rule like
184
+ "discard contacts with accuracy below 85" or "contacts older than 90
185
+ days are stale". These look like they belong in the `llm_judge` rubric,
186
+ but they're actually hard thresholds.
187
+
188
+ **BDR example:** `vet_contact` — the rubric says "flag contacts below
189
+ 85 accuracy". This is a numeric comparison, not a judgment call.
190
+
191
+ **What to do:**
192
+ - Keep the step as `llm_judge` (the rest of the rubric is fuzzy).
193
+ - But add a **pre-filter** in the signature that short-circuits on the
194
+ hard threshold before calling the LLM. The BDR
195
+ `signatures/vet_contact.py` does this — low-accuracy contacts never
196
+ reach the model.
197
+ - This saves tokens on the obvious cases and guarantees consistency on
198
+ the hard rules.
199
+
200
+ **How to detect:** look for numeric comparisons in rubrics ("below X",
201
+ "above Y", "at least Z"). Every one is a potential pre-filter.
202
+
203
+ ## When NOT to crystallize
204
+
205
+ The hunt for crystallization should be aggressive, but not blind. Leave
206
+ things agentic when:
207
+
208
+ 1. **The procedure genuinely varies run-over-run.** Target company
209
+ research (BDR Phase 1.5) calls different tools for different
210
+ indications, draws on different sources, produces different briefs.
211
+ That's `agent_loop`, not `pure_function`.
212
+
213
+ 2. **The inputs are unbounded prose.** Summarizing someone's employment
214
+ history to check franchise alignment isn't code-able — the input
215
+ shape is unbounded and the judgment is fuzzy. That's `llm_judge`.
216
+
217
+ 3. **The skill explicitly says "this is a judgment call".** Trust the
218
+ source. If the skill author marked something as needing human or
219
+ LLM judgment, there's usually a reason.
220
+
221
+ 4. **Crystallizing would require reimplementing an existing fuzzy
222
+ service.** Don't try to replace a vendor's search ranking with
223
+ your own code. Use the service; codify the *calling convention*,
224
+ not the service's internal logic.
225
+
226
+ ## The estimation heuristic
227
+
228
+ When producing the Phase 7 graduation report, estimate the
229
+ "% codifiable" as:
230
+
231
+ ```
232
+ codifiable_nodes = count(pure_function) + count(external_call)
233
+ total_nodes = len(all nodes except hitl_gate)
234
+ pct = codifiable_nodes / total_nodes * 100
235
+ ```
236
+
237
+ A well-graduated BDR-scale skill lands around **60–70% codifiable**.
238
+ If you're below 40%, you're probably leaving crystallization on the
239
+ table. If you're above 80%, either you have a very structured skill
240
+ or you're over-crystallizing — double-check that the remaining
241
+ `llm_judge` / `agent_loop` nodes genuinely need the LLM.
242
+
243
+ ## Scanning order
244
+
245
+ When doing Phase 3, walk through the source skill in this order:
246
+
247
+ 1. **Every `references/*.md` file first.** The reference files usually
248
+ contain the highest density of crystallizable patterns (literal code,
249
+ constants, rubrics with thresholds).
250
+ 2. **Then the main `SKILL.md`.** It's orchestration-level and usually
251
+ has fewer literal patterns, but contains the MANDATORY flags and
252
+ phase ordering.
253
+ 3. **For each file, grep for the detection terms listed in each pattern
254
+ above.** Note every hit. You'll usually find more patterns than
255
+ nodes — several patterns often apply to the same node.
256
+
257
+ Record every extraction candidate in the graduation report with
258
+ `file:line`, the current prose, and the proposed codified form. This
259
+ is the primary audit trail for the graduation and the thing the human
260
+ reviewer will check first.
@@ -0,0 +1,420 @@
1
+ # Pipeline IR Schema (`pipeline.yaml`)
2
+
3
+ Phase 5 of the graduator produces a `pipeline.yaml` file that describes
4
+ the entire graduated pipeline in a runtime-agnostic form. This file is
5
+ the reference for that YAML schema. It matches the Pydantic models in
6
+ `rote/ir.py`; when in doubt, that module is the authoritative source.
7
+
8
+ ## Top-level structure
9
+
10
+ ```yaml
11
+ name: bdr-campaign # required, kebab-case, unique
12
+ version: "0.1.0" # required, semver string
13
+ source_skill: ../../skill # optional, path to source skill bundle
14
+ description: | # optional, multi-line prose
15
+ End-to-end BDR outreach campaign workflow...
16
+
17
+ config: # optional, defaults apply if omitted
18
+ schedule: null # cron expression or null
19
+ on_failure: notify_owner # free-form adapter hook
20
+ observability:
21
+ traces: true
22
+ eval_set_dir: ./evals/
23
+ hitl:
24
+ default_timeout: 7d
25
+
26
+ input: # required, pipeline input contract
27
+ type: CampaignBrief
28
+ required:
29
+ - drug_brand
30
+ - drug_generic
31
+ optional:
32
+ - job_focus
33
+ input_schema: # strongly preferred: full JSON Schema
34
+ type: object # for the input payload (see below)
35
+ title: CampaignBrief
36
+ properties: { ... }
37
+ required: [drug_brand, drug_generic, ...]
38
+
39
+ nodes: [ ... ] # required, list of Node objects
40
+ edges: [ ... ] # required, list of Edge objects
41
+ entry_nodes: [target_research, taxonomy_lookup]
42
+ exit_nodes: [manual_enrollment_handoff]
43
+ ```
44
+
45
+ ### `input.input_schema` — promote the entry payload schema
46
+
47
+ When you design the entry nodes' `signature_spec.input_schema`, you
48
+ already produce a full JSON Schema for the pipeline's input type (the
49
+ BDR example has `CampaignBrief` with its `$defs` for `CampaignType`
50
+ and `JobFocus`). **Promote that same schema to `input.input_schema`**
51
+ so adapters can validate the pipeline input before the workflow
52
+ starts:
53
+
54
+ - Take the input type's object schema (with any enums/nested types it
55
+ needs inlined under its own `$defs`) — the same dictionaries
56
+ Pydantic emits via `model_json_schema()`.
57
+ - The schema's `required` list must match `input.required`, and its
58
+ `properties` must cover every name in `input.required` +
59
+ `input.optional`.
60
+ - `input_schema` is optional in the validator (old pipelines stay
61
+ valid), but always emit it for new graduations — it is the typed
62
+ contract everything downstream keys off.
63
+
64
+ ## Node object
65
+
66
+ Every node has a `kind` field, and the required fields depend on the
67
+ kind. The validator enforces kind-specific requirements.
68
+
69
+ ### Common fields (all kinds)
70
+
71
+ ```yaml
72
+ - id: taxonomy_lookup # required, unique, snake_case
73
+ kind: pure_function # required, one of 5 kinds
74
+ phase: "2" # optional, source skill phase (string)
75
+ description: | # required, short prose
76
+ Resolve ZoomInfo IDs for management levels...
77
+ input: # optional, field→type mapping
78
+ brief: CampaignBrief
79
+ inputs: # data-flow bindings: param → source ref
80
+ brief: pipeline.input # (see "Data-flow bindings" below)
81
+ output: TaxonomyIds # optional, type name or field mapping
82
+ timeout: 5m # optional, duration string
83
+ retry: # optional
84
+ max: 3
85
+ backoff: exponential # linear | exponential | constant
86
+ retry_on: [rate_limit, network] # optional, note: NOT 'on' — YAML parses 'on' as boolean
87
+ mandatory: false # optional, default false
88
+ constants: # optional, arbitrary key/value dict
89
+ batch_size: 10
90
+ cache: # optional
91
+ strategy: persistent
92
+ ttl: 30d
93
+ fan_out: false # optional, if true node invoked per input element
94
+ ```
95
+
96
+ ### Data-flow bindings (`inputs:`)
97
+
98
+ `input:` documents *types*; `inputs:` binds *where the values come
99
+ from at runtime*. Adapters render `inputs:` into real payloads — a
100
+ node without `inputs:` receives an empty payload, so **emit `inputs:`
101
+ for every top-level node whose upstream sources you can name.**
102
+
103
+ The grammar is exactly four forms. There is no expression language —
104
+ no arithmetic, no aggregation, no deep paths:
105
+
106
+ | Reference form | Meaning |
107
+ |------------------------------|---------------------------------------------|
108
+ | `pipeline.input` | the whole pipeline input payload |
109
+ | `pipeline.input.<field>` | one top-level field of the pipeline input |
110
+ | `<node_id>.output` | the whole output of an upstream node |
111
+ | `<node_id>.output.<field>` | one top-level field of an upstream node's output |
112
+
113
+ BDR examples:
114
+
115
+ ```yaml
116
+ - id: lead_generation_loop
117
+ inputs:
118
+ brief: pipeline.input # whole input payload
119
+ intel: target_research.output # whole upstream output
120
+ taxonomy: taxonomy_lookup.output
121
+ target_quota: pipeline.input.target_quota # one input field
122
+
123
+ - id: hubspot_upsert
124
+ inputs:
125
+ # HITL gate outputs work too — this is the reviewer's signal payload:
126
+ contacts: contact_review_gate.output.approved_contacts
127
+
128
+ - id: exclusion_check_recent
129
+ inputs:
130
+ contacts: exclusion_check_dnc.output.passed # chain node → node
131
+ ```
132
+
133
+ Rules and edge cases:
134
+
135
+ 1. **Reference only upstream nodes.** A reference to a node that runs
136
+ in a later wave — or to a loop-body sub-node, which has no
137
+ top-level result — fails at emission time.
138
+ 2. **Pipeline input fields must be declared.** `pipeline.input.<field>`
139
+ is validated against `input.required` + `input.optional` +
140
+ `input_schema.properties`.
141
+ 3. **Leave a parameter unbound when the grammar can't express it** and
142
+ say why in a comment. BDR has two deliberate examples:
143
+ `dnc_list_id` (deployment configuration, not pipeline data) and
144
+ `vetted_count` (an aggregate — `len()` of a list — which belongs in
145
+ the extracted function, not the reference syntax).
146
+ 4. **Loop-body sub-nodes:** their `inputs:` describe what the parent
147
+ loop passes per iteration. Adapters don't resolve them at the top
148
+ level, so treat them as documentation for the loop harness.
149
+ 5. **`fan_out` nodes:** bind the element parameter to the upstream
150
+ *list* (e.g. `contact: exclusion_check_sequence.output.passed`).
151
+ The runtime is responsible for per-element dispatch; v0 adapters
152
+ pass the whole list in a single invocation.
153
+ 6. **HITL gates need no `inputs:`** — a gate's "output" is the signal
154
+ payload the human sends, and downstream nodes reference it as
155
+ `<gate_id>.output[...]`.
156
+
157
+ ### `pure_function` and `external_call`
158
+
159
+ **Required:** `impl` — path to the extracted Python function in
160
+ `"extracted/foo.py:bar_func"` format.
161
+
162
+ ```yaml
163
+ - id: pre_enrollment_report
164
+ kind: pure_function
165
+ description: Render the pre-enrollment report as Markdown.
166
+ impl: extracted/report.py:generate_pre_enrollment_report
167
+ input:
168
+ campaign_name: str
169
+ vetted_count: int
170
+ passed_contacts: list[HubSpotContact]
171
+ exclusions: list[ExclusionRecord]
172
+ template_ids: list[str]
173
+ output: report_markdown str
174
+
175
+ - id: hubspot_upsert
176
+ kind: external_call
177
+ description: Batch upsert contacts to HubSpot (100 per call).
178
+ impl: extracted/hubspot.py:batch_upsert_contacts
179
+ input:
180
+ contacts: list[VettedContact]
181
+ output:
182
+ upserted: list[HubSpotContact]
183
+ constants:
184
+ batch_size: 100
185
+ retry:
186
+ max: 5
187
+ backoff: exponential
188
+ timeout: 60s
189
+ ```
190
+
191
+ ### `llm_judge`
192
+
193
+ **Required:** at least one of `signature` (legacy) or `signature_spec`
194
+ (structured). **Strongly preferred: emit both.** The Temporal adapter
195
+ prefers `signature_spec` when present and falls back to the legacy
196
+ path; the Cloudflare adapter and any future non-Python target *require*
197
+ `signature_spec` because there's no shared Python module to import.
198
+
199
+ - **`signature`** — path to a typed Python signature class in
200
+ `"signatures/foo.py:FooClass"` format. Used by the Temporal adapter
201
+ and by humans iterating on the signature with DSPy / BAML. Always
202
+ emit this for runtimes that share Python with the extracted modules.
203
+
204
+ - **`signature_spec`** — runtime-agnostic structured form: JSON Schema
205
+ for input + output (the same schemas Pydantic emits via
206
+ `model_json_schema()`), the prompt template, and the LLM client
207
+ config. This is the cross-language source of truth — adapters
208
+ derive Pydantic / Zod / Go types from the schemas as needed. See
209
+ [`llm-judge-extraction.md`](llm-judge-extraction.md) for the full
210
+ derivation procedure.
211
+
212
+ ```yaml
213
+ - id: vet_contact
214
+ kind: llm_judge
215
+ description: Apply the BDR red-flags rubric to a single contact.
216
+ signature: signatures/vet_contact.py:VetContact # legacy path (Temporal)
217
+ signature_spec: # structured form (all runtimes)
218
+ input_schema:
219
+ type: object
220
+ required: [contact, brief, intel]
221
+ properties:
222
+ contact: {$ref: "#/$defs/EnrichedContact"}
223
+ brief: {$ref: "#/$defs/CampaignBrief"}
224
+ intel: {$ref: "#/$defs/IntelBrief"}
225
+ $defs:
226
+ # ... full Pydantic model_json_schema() output
227
+ output_schema:
228
+ type: object
229
+ required: [decision, relevance_evidence]
230
+ properties:
231
+ decision: {enum: [keep, discard]}
232
+ tier:
233
+ anyOf: [{enum: [ideal, strong, good]}, {type: "null"}]
234
+ discard_reason:
235
+ anyOf: [{enum: [indication_mismatch, msl_role, ...]}, {type: "null"}]
236
+ relevance_evidence: {type: string}
237
+ prompt: |
238
+ Apply the BDR vetting rubric to this contact.
239
+ Contact: {{ contact }}
240
+ Brief: {{ brief }}
241
+ Intel: {{ intel }}
242
+ Return your decision via the structured output tool.
243
+ client: anthropic # 'anthropic' | 'openai'
244
+ model: claude-sonnet-4-6 # optional; adapter chooses default if omitted
245
+ temperature: 0.0 # optional
246
+ input:
247
+ contact: EnrichedContact
248
+ brief: CampaignBrief
249
+ intel: IntelBrief
250
+ output:
251
+ decision: VetDecision
252
+ tier: ContactTier
253
+ discard_reason: DiscardReason
254
+ relevance_evidence: str
255
+ constants:
256
+ min_accuracy_score: 85
257
+ eval_set: evals/vet_contact.jsonl
258
+ fan_out: true # one invocation per contact
259
+ ```
260
+
261
+ ### `agent_loop`
262
+
263
+ **Required:** `tools` — list of tool names the agent may call inside
264
+ the loop.
265
+ **Optional:** `loop_body` — list of sub-node IDs invoked each iteration.
266
+ **Optional:** `termination` — condition + max iterations.
267
+
268
+ ```yaml
269
+ - id: lead_generation_loop
270
+ kind: agent_loop
271
+ description: Iterative search-enrich-vet loop.
272
+ input:
273
+ brief: CampaignBrief
274
+ intel: IntelBrief
275
+ target_quota: int
276
+ output:
277
+ vetted_contacts: list[VettedContact]
278
+ tools:
279
+ - zoominfo_search_contacts
280
+ - zoominfo_search_companies
281
+ loop_body:
282
+ - enrich_contact_batch
283
+ - vet_contact
284
+ termination:
285
+ condition: vetted_count >= target_quota
286
+ max_iterations: 10
287
+ timeout: 15m
288
+ ```
289
+
290
+ **Note on `loop_body`:** sub-nodes listed here must also exist as
291
+ top-level nodes in the `nodes:` list. They're referenced from the
292
+ loop_body but also emitted as standalone activities (so they can be
293
+ tested in isolation and reused elsewhere). The adapter excludes them
294
+ from top-level execution waves to prevent double-dispatch.
295
+
296
+ ### `hitl_gate`
297
+
298
+ **Required:** `signal` — name of the signal the workflow waits for.
299
+ **Optional:** `notify` — how to alert the human reviewer.
300
+
301
+ ```yaml
302
+ - id: contact_review_gate
303
+ kind: hitl_gate
304
+ description: Present the vetted contact table to the user for approval.
305
+ input:
306
+ vetted_contacts: list[VettedContact]
307
+ output:
308
+ approved_contacts: list[VettedContact]
309
+ signal: contact_review_approved
310
+ timeout: 7d
311
+ notify:
312
+ channel: slack
313
+ target: "#bdr-reviews"
314
+ message_template: |
315
+ BDR campaign awaiting review: {drug_brand} for {condition_acronym}
316
+ ```
317
+
318
+ **Constraint:** `mandatory: true` is not allowed on `agent_loop` nodes
319
+ (meaningless) but is allowed on every other kind. For HITL gates,
320
+ `mandatory` is implicit — you can't skip a gate the workflow is
321
+ waiting on.
322
+
323
+ ## Edge object
324
+
325
+ ```yaml
326
+ edges:
327
+ - { from: target_research, to: lead_generation_loop }
328
+ - { from: lead_generation_loop, to: contact_review_gate }
329
+ - { from: contact_review_gate, to: hubspot_upsert, on_signal: approved }
330
+ - { from: exclusion_check_sequence, to: personalize_email, fan_out: true }
331
+ ```
332
+
333
+ - **`from` / `to`:** node IDs (must exist in the `nodes:` list).
334
+ - **`on_signal`:** optional. If set, the edge only activates when the
335
+ named signal fires. Used for edges exiting a `hitl_gate`.
336
+ - **`fan_out`:** optional. If true, the destination is invoked once per
337
+ element of the source's output.
338
+
339
+ **Note:** `from` is a reserved word in Python, so the IR's Pydantic
340
+ model uses the alias `from_`. In YAML, always write `from:`.
341
+
342
+ ## Validation rules the graduator must respect
343
+
344
+ The IR validator will reject any of these, so produce the YAML with
345
+ these constraints in mind:
346
+
347
+ 1. **Node IDs are unique** within a pipeline.
348
+ 2. **All `from`/`to` in `edges` reference real node IDs.**
349
+ 3. **All `loop_body` entries reference real node IDs.**
350
+ 4. **All `entry_nodes` and `exit_nodes` reference real node IDs.**
351
+ 5. **Kind-specific required fields are present:**
352
+ - `pure_function` / `external_call` → `impl`
353
+ - `llm_judge` → `signature` (legacy path) or `signature_spec`
354
+ (structured) — at least one. Strongly preferred: emit both.
355
+ - `agent_loop` → `tools`
356
+ - `hitl_gate` → `signal`. Cloudflare additionally requires the
357
+ signal name to match `[A-Za-z0-9_-]+` (no dots, spaces, etc.) —
358
+ this is enforced at adapter emission time.
359
+ 6. **`mandatory: true` is not allowed on `agent_loop` nodes.**
360
+ 7. **No YAML key named `on:`** — YAML 1.1 parses it as boolean
361
+ `True`. The IR uses `retry_on:` instead.
362
+ 8. **All `inputs:` references parse and resolve.** Each value must
363
+ match one of the four reference forms (see "Data-flow bindings"),
364
+ node references must name real nodes (and never the node itself),
365
+ and `pipeline.input.<field>` must name a declared input field.
366
+
367
+ ## Duration strings
368
+
369
+ Used in `timeout:`, `default_timeout:`, `cache.ttl:`, etc.
370
+
371
+ | Suffix | Meaning |
372
+ |---|---|
373
+ | `ms` | milliseconds |
374
+ | `s` | seconds |
375
+ | `m` | minutes |
376
+ | `h` | hours |
377
+ | `d` | days |
378
+
379
+ No suffix → seconds. Examples: `5m`, `30s`, `7d`, `250ms`.
380
+
381
+ ## Minimal skeleton for a new pipeline
382
+
383
+ When generating a `pipeline.yaml` from scratch, start with this skeleton
384
+ and fill it in:
385
+
386
+ ```yaml
387
+ name: <skill-name>
388
+ version: "0.1.0"
389
+ source_skill: <relative path to skill>
390
+ description: |
391
+ <one-paragraph summary of what the skill does>
392
+
393
+ config:
394
+ on_failure: notify_owner
395
+
396
+ input:
397
+ type: <BriefTypeName>
398
+ required: []
399
+ optional: []
400
+
401
+ nodes: []
402
+ edges: []
403
+ entry_nodes: []
404
+ exit_nodes: []
405
+ ```
406
+
407
+ Then add nodes from the source skill's entry point outward, adding
408
+ edges as you go. Use `entry_nodes` for nodes with no inbound edges
409
+ (except from the pipeline input itself) and `exit_nodes` for terminal
410
+ nodes whose completion means the workflow is done.
411
+
412
+ ## Cross-referencing the rubric
413
+
414
+ - Every node's `kind` decision is guided by `node-kinds.md`.
415
+ - Every `pure_function` / `external_call` node's `impl` and `constants`
416
+ come from applying the patterns in `crystallization-heuristics.md`.
417
+ - Every `llm_judge` node's `signature` design is guided by
418
+ `llm-judge-extraction.md`.
419
+ - Every `mandatory: true` flag comes from a Pattern 3 hit in the
420
+ crystallization scan.