simplify model stylesheet: rename llm_model/llm_provider, add provider inference

Rename `llm_model` → `model` and `llm_provider` → `provider` in stylesheet
properties, accessor methods, and all DOT/doc references. Add
ProviderInferenceTransform that automatically infers provider from the model
catalog, eliminating redundant provider declarations in stylesheets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-12 22:27:18 -04:00
parent 5538ae4cd7
commit d37fb9a9b4
62 changed files with 418 additions and 324 deletions

View file

@ -58,8 +58,8 @@ digraph PlanImplement {
graph [
goal="Plan, approve, implement, and simplify a change"
model_stylesheet="
* { llm_model: claude-haiku-4-5; reasoning_effort: low; }
.coding { llm_model: claude-sonnet-4-5; reasoning_effort: high; }
* { model: claude-haiku-4-5; reasoning_effort: low; }
.coding { model: claude-sonnet-4-5; reasoning_effort: high; }
"
]

View file

@ -1,7 +1,7 @@
digraph GoldenGate {
graph [
goal="Create a hyperrealistic interactive 3D Golden Gate Bridge flight experience",
model_stylesheet="* { llm_model: gpt-5.4; llm_provider: openai }"
model_stylesheet="* { model: gpt-5.4;}"
]
rankdir=LR

View file

@ -2,7 +2,7 @@ digraph ImplementAndSimplify {
graph [
goal="Implement and simplify",
model_stylesheet="
* { backend: api; llm_model: claude-opus-4-6; llm_provider: anthropic; }
* { backend: api; model: claude-opus-4-6;}
"
]
rankdir=LR

View file

@ -1,5 +1,5 @@
digraph PlaywrightDemo {
graph [goal="Use Playwright MCP to browse Hacker News and take screenshots", model_stylesheet="* { llm_model: claude-sonnet-4-6; llm_provider: anthropic }"]
graph [goal="Use Playwright MCP to browse Hacker News and take screenshots", model_stylesheet="* { model: claude-sonnet-4-6;}"]
rankdir=LR
start [shape=Mdiamond, label="Start"]

View file

@ -6,9 +6,9 @@ digraph BuildSolitaire {
retry_target="impl_setup",
fallback_retry_target="impl_logic",
model_stylesheet="
* { llm_model: claude-sonnet; llm_provider: anthropic; }
.hard { llm_model: claude-opus; llm_provider: anthropic; }
.verify { llm_model: claude-haiku; llm_provider: anthropic; }
* { model: claude-sonnet;}
.hard { model: claude-opus; }
.verify { model: claude-haiku; }
"
]

View file

@ -5,11 +5,11 @@ digraph SpecDoDMultiModel {
retry_target="triage_merge",
default_fidelity="full",
model_stylesheet="
* { llm_model: claude-opus-4-6; llm_provider: anthropic; }
.opus { llm_model: claude-opus-4-6; llm_provider: anthropic; reasoning_effort: high; }
.gpt { llm_model: gpt-5.2; llm_provider: openai; reasoning_effort: high; }
.codex { llm_model: gpt-5.2-codex; llm_provider: openai; reasoning_effort: high; }
.merge { llm_model: claude-opus-4-6; llm_provider: anthropic; reasoning_effort: high; }
* { model: claude-opus-4-6;}
.opus { model: claude-opus-4-6;reasoning_effort: high; }
.gpt { model: gpt-5.2; reasoning_effort: high; }
.codex { model: gpt-5.2-codex; reasoning_effort: high; }
.merge { model: claude-opus-4-6; reasoning_effort: high; }
"
]

View file

@ -4,9 +4,9 @@ digraph SpecDoD {
default_max_retry="3",
retry_target="triage",
model_stylesheet="
* { llm_model: claude-opus-4-6; llm_provider: anthropic; }
* { model: claude-opus-4-6;}
.audit { reasoning_effort: high; }
.fix { llm_model: claude-opus-4-6; reasoning_effort: high; }
.fix { model: claude-opus-4-6; reasoning_effort: high; }
#final_audit { reasoning_effort: high; }
"
]

View file

@ -1,7 +1,7 @@
digraph VNCDemo {
graph [
goal="Browse the web with Playwright while user watches via VNC",
model_stylesheet="* { llm_model: claude-sonnet-4-6; llm_provider: anthropic }"
model_stylesheet="* { model: claude-sonnet-4-6;}"
]
rankdir=LR

View file

@ -1,7 +1,7 @@
digraph WebGameDemo {
graph [
goal="Verify develop-web-game skill setup with asset files",
model_stylesheet="* { llm_model: claude-sonnet-4-6; llm_provider: anthropic }"
model_stylesheet="* { model: claude-sonnet-4-6;}"
]
rankdir=LR

View file

@ -44,7 +44,7 @@ The CLI is selected automatically based on the node's provider:
Set the CLI backend on a node with `backend="cli"` or via a [model stylesheet](/workflows/stylesheets):
```dot
implement [label="Implement", backend="cli", llm_provider="anthropic"]
implement [label="Implement", backend="cli"]
```
```

View file

@ -56,9 +56,9 @@ Assign models to workflow nodes using [model stylesheets](/workflows/stylesheets
digraph Example {
graph [
model_stylesheet="
* { llm_model: claude-haiku-4-5; }
.coding { llm_model: claude-sonnet-4-5; reasoning_effort: high; }
#review { llm_model: gemini-3.1-pro-preview; }
* { model: claude-haiku-4-5; }
.coding { model: claude-sonnet-4-5; reasoning_effort: high; }
#review { model: gemini-3.1-pro-preview; }
"
]

View file

@ -47,11 +47,11 @@ GitHub to Railway validated by code review only — no live deployment execution
retry_target="plan_fanout",
fallback_retry_target="plan_fanout",
model_stylesheet="
* { llm_model: claude-opus-4-6; llm_provider: anthropic; }
.hard { llm_model: gpt-5.3-codex; llm_provider: openai; }
.verify { llm_model: claude-opus-4-6; llm_provider: anthropic; }
.branch-a { llm_model: claude-opus-4-6; llm_provider: anthropic; }
.branch-b { llm_model: gemini-3-flash-preview; llm_provider: gemini; }
* { model: claude-opus-4-6; }
.hard { model: gpt-5.3-codex; }
.verify { model: claude-opus-4-6; }
.branch-a { model: claude-opus-4-6; }
.branch-b { model: gemini-3-flash-preview;}
"
]
@ -693,11 +693,11 @@ check_toolchain -> postmortem [condition="outcome=fail && context.failure_
The stylesheet assigns models based on task difficulty:
```
* { llm_model: claude-opus-4-6; } // Default: spec expansion, debate, postmortem
.hard { llm_model: gpt-5.3-codex; } // Implementation: optimized for code generation
.verify { llm_model: claude-opus-4-6; } // Fidelity verification: careful analysis
.branch-a { llm_model: claude-opus-4-6; } // Plan A, Review A: Anthropic perspective
.branch-b { llm_model: gemini-3-flash-preview; } // Plan B, Review B: Google perspective
* { model: claude-opus-4-6; } // Default: spec expansion, debate, postmortem
.hard { model: gpt-5.3-codex; } // Implementation: optimized for code generation
.verify { model: claude-opus-4-6; } // Fidelity verification: careful analysis
.branch-a { model: claude-opus-4-6; } // Plan A, Review A: Anthropic perspective
.branch-b { model: gemini-3-flash-preview; } // Plan B, Review B: Google perspective
```
The `.branch-b` class uses a different provider for both planning and review. This ensures the second opinion is genuinely independent — not just a second run of the same model. The `.hard` class routes implementation to OpenAI's Codex, which is optimized for high-throughput code generation.

View file

@ -29,9 +29,9 @@ digraph SpecDoD {
default_max_retry="3",
retry_target="triage",
model_stylesheet="
* { llm_model: claude-opus-4-6; llm_provider: anthropic; }
* { model: claude-opus-4-6;}
.audit { reasoning_effort: high; }
.fix { llm_model: claude-opus-4-6; reasoning_effort: high; }
.fix { model: claude-opus-4-6; reasoning_effort: high; }
#final_audit { reasoning_effort: high; }
"
]
@ -286,11 +286,11 @@ digraph SpecDoDMultiModel {
retry_target="triage_merge",
default_fidelity="full",
model_stylesheet="
* { llm_model: claude-opus-4-6; llm_provider: anthropic; }
.opus { llm_model: claude-opus-4-6; llm_provider: anthropic; reasoning_effort: high; }
.gpt { llm_model: gpt-5.2; llm_provider: openai; reasoning_effort: high; }
.codex { llm_model: gpt-5.2-codex; llm_provider: openai; reasoning_effort: high; }
.merge { llm_model: claude-opus-4-6; llm_provider: anthropic; reasoning_effort: high; }
* { model: claude-opus-4-6;}
.opus { model: claude-opus-4-6;reasoning_effort: high; }
.gpt { model: gpt-5.2; reasoning_effort: high; }
.codex { model: gpt-5.2-codex; reasoning_effort: high; }
.merge { model: claude-opus-4-6; reasoning_effort: high; }
"
]

View file

@ -22,8 +22,8 @@ digraph NLSpecConformance {
graph [
goal="Implement a conformant system from a natural language specification",
model_stylesheet="
* { llm_model: claude-haiku-4-5; llm_provider: anthropic; }
.impl { llm_model: claude-sonnet-4-5; reasoning_effort: high; }
* { model: claude-haiku-4-5;}
.impl { model: claude-sonnet-4-5; reasoning_effort: high; }
"
]
rankdir=LR
@ -135,8 +135,8 @@ The `model_stylesheet` assigns a cheaper model as the default and routes impleme
```dot
graph [model_stylesheet="
* { llm_model: claude-haiku-4-5; llm_provider: anthropic; }
.impl { llm_model: claude-sonnet-4-5; reasoning_effort: high; }
* { model: claude-haiku-4-5;}
.impl { model: claude-sonnet-4-5; reasoning_effort: high; }
"]
```

View file

@ -20,9 +20,9 @@ digraph SemanticPort {
rankdir=LR,
default_max_retry=3,
model_stylesheet="
* { llm_model: claude-sonnet-4-5; llm_provider: anthropic; }
.hard { llm_model: claude-opus-4-6; llm_provider: anthropic; }
.analyze { llm_model: gemini-3.1-pro-preview; llm_provider: gemini; }
* { model: claude-sonnet-4-5;}
.hard { model: claude-opus-4-6; }
.analyze { model: gemini-3.1-pro-preview;}
"
]
@ -170,7 +170,7 @@ The `analyze` node is the decision point. It examines each upstream commit for *
This distinction is critical — routing a different model (Gemini) to the analysis node via the `.analyze` class brings a fresh perspective to the port/skip decision:
```
.analyze { llm_model: gemini-3.1-pro-preview; llm_provider: gemini; }
.analyze { model: gemini-3.1-pro-preview;}
```
### The fix loop
@ -200,9 +200,9 @@ When the agent decides a commit is irrelevant, it updates the ledger, commits th
The stylesheet assigns three tiers of models:
```
* { llm_model: claude-sonnet-4-5; } // Default: plan, finalize
.hard { llm_model: claude-opus-4-6; } // Implementation, fixing
.analyze { llm_model: gemini-3.1-pro-preview; } // Analysis: fresh eyes
* { model: claude-sonnet-4-5; } // Default: plan, finalize
.hard { model: claude-opus-4-6; } // Implementation, fixing
.analyze { model: gemini-3.1-pro-preview; } // Analysis: fresh eyes
```
- **Sonnet** handles routine tasks: fetching commits, finalizing plans, updating the ledger

View file

@ -22,9 +22,9 @@ digraph BuildSolitaire {
retry_target="impl_setup",
fallback_retry_target="impl_logic",
model_stylesheet="
* { llm_model: claude-sonnet; llm_provider: anthropic; }
.hard { llm_model: claude-opus; llm_provider: anthropic; }
.verify { llm_model: claude-haiku; llm_provider: anthropic; }
* { model: claude-sonnet;}
.hard { model: claude-opus; }
.verify { model: claude-haiku; }
"
]
@ -218,9 +218,9 @@ If a node fails and has no local retry target, Fabro jumps back to `impl_setup`
The stylesheet assigns models by role:
```
* { llm_model: claude-sonnet-4-5; } // Default: spec, setup, integration
.hard { llm_model: claude-opus-4-6; } // Hard work: game logic, UI, review
.verify { llm_model: claude-haiku-4-5; } // Verification: fast, cheap checks
* { model: claude-sonnet-4-5; } // Default: spec, setup, integration
.hard { model: claude-opus-4-6; } // Hard work: game logic, UI, review
.verify { model: claude-haiku-4-5; } // Verification: fast, cheap checks
```
- **Sonnet** handles routine phases: expanding the spec, setting up the project, wiring integration

View file

@ -65,7 +65,7 @@ Set workflow-level configuration:
```dot
// Block syntax
graph [goal="Build a feature", model_stylesheet="* { llm_model: claude-haiku-4-5; }"]
graph [goal="Build a feature", model_stylesheet="* { model: claude-haiku-4-5; }"]
// Declaration syntax
rankdir=LR
@ -199,8 +199,8 @@ Start nodes can also be identified by ID (`start` or `Start`). Exit nodes can be
| `max_tokens` | Integer | Maximum output tokens |
| `fidelity` | String | How much prior context is passed: `compact`, `full`, `summary:high`, `summary:medium`, `summary:low`, `truncate` |
| `thread_id` | String | Groups nodes into a shared conversation thread |
| `llm_model` | String | Explicit model ID (overrides stylesheet) |
| `llm_provider` | String | Explicit provider name (overrides stylesheet) |
| `model` | String | Explicit model ID (overrides stylesheet) |
| `provider` | String | Explicit provider name (overrides stylesheet) |
| `project_memory` | Boolean | When `true` (default), prompt nodes discover and include project docs (`AGENTS.md`, `CLAUDE.md`, etc.) as a system prompt. Set to `false` to disable. |
| `backend` | String | Agent execution backend. `api` (default): Fabro calls the LLM API directly and runs its own tool loop. `cli`: Fabro delegates to an external CLI tool (`claude`, `codex`, or `gemini` based on provider). See [Agents — Backends](/core-concepts/agents#backends). |
@ -350,9 +350,9 @@ digraph ImplementFeature {
graph [
goal="Implement a feature with tests and code review",
model_stylesheet="
* { llm_model: claude-haiku-4-5; llm_provider: anthropic; reasoning_effort: low; }
.coding { llm_model: claude-sonnet-4-5; llm_provider: anthropic; reasoning_effort: high; }
#review { llm_model: claude-sonnet-4-5; llm_provider: anthropic; reasoning_effort: high; }
* { model: claude-haiku-4-5;reasoning_effort: low; }
.coding { model: claude-sonnet-4-5;reasoning_effort: high; }
#review { model: claude-sonnet-4-5;reasoning_effort: high; }
"
]
rankdir=LR

View file

@ -16,11 +16,11 @@ digraph Ensemble {
graph [
goal="Get independent opinions from multiple providers, then synthesize",
model_stylesheet="
#opus { llm_model: claude-opus-4-6; llm_provider: anthropic; }
#gemini { llm_model: gemini-3.1-pro-preview; llm_provider: gemini; }
#codex { llm_model: gpt-5.3-codex; llm_provider: openai; }
#mercury { llm_model: mercury-2; llm_provider: inception; }
#synth { llm_model: claude-opus-4-6; llm_provider: anthropic; reasoning_effort: high; }
#opus { model: claude-opus-4-6; }
#gemini { model: gemini-3.1-pro-preview;}
#codex { model: gpt-5.3-codex; }
#mercury { model: mercury-2; provider: inception; }
#synth { model: claude-opus-4-6; reasoning_effort: high; }
"
]
rankdir=LR
@ -68,10 +68,10 @@ The workflow has three phases:
The `fork` node spawns four parallel branches, each assigned to a different provider via the stylesheet:
```
#opus { llm_model: claude-opus-4-6; llm_provider: anthropic; }
#gemini { llm_model: gemini-3.1-pro-preview; llm_provider: gemini; }
#codex { llm_model: gpt-5.3-codex; llm_provider: openai; }
#mercury { llm_model: mercury-2; llm_provider: inception; }
#opus { model: claude-opus-4-6; }
#gemini { model: gemini-3.1-pro-preview;}
#codex { model: gpt-5.3-codex; }
#mercury { model: mercury-2; provider: inception; }
```
Each branch receives the same prompt but runs on a completely different model. The branches execute concurrently and have no knowledge of each other's responses.

View file

@ -16,9 +16,9 @@ digraph MultiModel {
graph [
goal="Build and review a utility function using multiple models",
model_stylesheet="
* { llm_model: claude-haiku-4-5; llm_provider: anthropic; reasoning_effort: low; }
.coding { llm_model: claude-sonnet-4-5; llm_provider: anthropic; reasoning_effort: high; }
#review { llm_model: claude-sonnet-4-5; llm_provider: anthropic; reasoning_effort: high; }
* { model: claude-haiku-4-5;reasoning_effort: low; }
.coding { model: claude-sonnet-4-5;reasoning_effort: high; }
#review { model: claude-sonnet-4-5;reasoning_effort: high; }
"
]
rankdir=LR
@ -44,9 +44,9 @@ fabro run files-internal/demo/08-multi-model.dot
The `model_stylesheet` graph attribute contains CSS-like rules that assign models to nodes:
```
* { llm_model: claude-haiku-4-5; llm_provider: anthropic; reasoning_effort: low; }
.coding { llm_model: claude-sonnet-4-5; llm_provider: anthropic; reasoning_effort: high; }
#review { llm_model: claude-sonnet-4-5; llm_provider: anthropic; reasoning_effort: high; }
* { model: claude-haiku-4-5;reasoning_effort: low; }
.coding { model: claude-sonnet-4-5;reasoning_effort: high; }
#review { model: claude-sonnet-4-5;reasoning_effort: high; }
```
### Selectors
@ -85,8 +85,8 @@ Stylesheets support four properties:
| Property | Description |
|---|---|
| `llm_model` | Model ID or alias (e.g. `claude-sonnet-4-5`, `opus`, `gemini-pro`) |
| `llm_provider` | Provider name (`anthropic`, `openai`, `gemini`, etc.) |
| `model` | Model ID or alias (e.g. `claude-sonnet-4-5`, `opus`, `gemini-pro`) |
| `provider` | Provider name (`anthropic`, `openai`, `gemini`, etc.) |
| `reasoning_effort` | `low`, `medium`, or `high` |
| `backend` | `api` (default) or `cli` |
@ -105,7 +105,7 @@ Model routing lets you optimize cost and latency without changing the workflow s
A model set directly on a node attribute always beats the stylesheet:
```dot
implement [label="Implement", class="coding", llm_model="claude-opus-4-6"]
implement [label="Implement", class="coding", model="claude-opus-4-6"]
```
This node uses Opus regardless of what `.coding` says.

View file

@ -14,9 +14,9 @@ digraph Example {
graph [
goal="Build and review a utility function",
model_stylesheet="
* { llm_model: claude-haiku-4-5; llm_provider: anthropic; }
.coding { llm_model: claude-sonnet-4-5; reasoning_effort: high; }
#review { llm_model: gemini-3.1-pro-preview; llm_provider: gemini; }
* { model: claude-haiku-4-5;}
.coding { model: claude-sonnet-4-5; reasoning_effort: high; }
#review { model: gemini-3.1-pro-preview;}
"
]
@ -64,8 +64,8 @@ Stylesheets support four properties:
| Property | Description | Example |
|---|---|---|
| `llm_model` | Model ID or alias | `claude-sonnet-4-5`, `opus`, `gemini-pro` |
| `llm_provider` | Provider name | `anthropic`, `openai`, `gemini` |
| `model` | Model ID or alias | `claude-sonnet-4-5`, `opus`, `gemini-pro` |
| `provider` | Provider name | `anthropic`, `openai`, `gemini` |
| `reasoning_effort` | Reasoning effort level | `low`, `medium`, `high` |
| `backend` | Agent execution backend — `api` (default) runs Fabro's own tool loop, `cli` delegates to an external CLI tool. See [Backends](/core-concepts/agents#backends). | `cli`, `api` |
@ -82,9 +82,9 @@ When multiple rules match the same node, the rule with the **highest specificity
For example:
```
* { llm_model: claude-haiku-4-5; }
.coding { llm_model: claude-sonnet-4-5; }
#review { llm_model: gpt-5.2; }
* { model: claude-haiku-4-5; }
.coding { model: claude-sonnet-4-5; }
#review { model: gpt-5.2; }
```
A node with `id="review"` and `class="coding"` gets `gpt-5.2` because `#id` (specificity 3) beats `.class` (specificity 2).
@ -96,10 +96,10 @@ If two rules have the same specificity, the **last one** in the stylesheet wins.
A model set directly on a node attribute always takes precedence over stylesheets, regardless of specificity:
```dot
implement [label="Implement", class="coding", llm_model="claude-opus-4-6"]
implement [label="Implement", class="coding", model="claude-opus-4-6"]
```
Even if `.coding` sets `llm_model: claude-sonnet-4-5`, this node uses Opus because the explicit attribute wins.
Even if `.coding` sets `model: claude-sonnet-4-5`, this node uses Opus because the explicit attribute wins.
## Syntax reference
@ -118,12 +118,12 @@ selector { property: value; property: value; }
### Full example
```
* { llm_model: claude-haiku-4-5; llm_provider: anthropic; reasoning_effort: low; }
* { model: claude-haiku-4-5;reasoning_effort: low; }
box { reasoning_effort: high; }
tab { reasoning_effort: low; }
.coding { llm_model: claude-sonnet-4-5; llm_provider: anthropic; reasoning_effort: high; }
.review { llm_model: gemini-3.1-pro-preview; llm_provider: gemini; }
#final_check { llm_model: claude-opus-4-6; llm_provider: anthropic; reasoning_effort: high; }
.coding { model: claude-sonnet-4-5;reasoning_effort: high; }
.review { model: gemini-3.1-pro-preview;}
#final_check { model: claude-opus-4-6;reasoning_effort: high; }
```
This stylesheet:

View file

@ -2,9 +2,9 @@ digraph MultiModel {
graph [
goal="Build and review a utility function using multiple models",
model_stylesheet="
* { llm_model: claude-haiku-4-5; llm_provider: anthropic; reasoning_effort: low; }
.coding { llm_model: claude-sonnet-4-5; llm_provider: anthropic; reasoning_effort: high; }
#review { llm_model: claude-sonnet-4-5; llm_provider: anthropic; reasoning_effort: high; }
* { model: claude-haiku-4-5;reasoning_effort: low; }
.coding { model: claude-sonnet-4-5;reasoning_effort: high; }
#review { model: claude-sonnet-4-5;reasoning_effort: high; }
"
]
rankdir=LR

View file

@ -2,11 +2,11 @@ digraph Ensemble {
graph [
goal="Get independent opinions from multiple providers, then synthesize",
model_stylesheet="
#opus { llm_model: claude-opus-4-6; llm_provider: anthropic; }
#gemini { llm_model: gemini-3.1-pro-preview; llm_provider: gemini; }
#codex { llm_model: gpt-5.3-codex; llm_provider: openai; }
#mercury { llm_model: mercury-2; llm_provider: inception; }
#synth { llm_model: claude-opus-4-6; llm_provider: anthropic; reasoning_effort: high; }
#opus { model: claude-opus-4-6; }
#gemini { model: gemini-3.1-pro-preview;}
#codex { model: gpt-5.3-codex; }
#mercury { model: mercury-2; provider: inception; }
#synth { model: claude-opus-4-6; reasoning_effort: high; }
"
]
rankdir=LR

View file

@ -124,9 +124,9 @@ digraph Styled {
graph [
goal="Build feature",
model_stylesheet="
* { llm_model: claude-sonnet-4-5; llm_provider: anthropic; }
.code { llm_model: claude-opus-4-6; }
#critical_review { llm_model: gpt-5.2; llm_provider: openai; }
* { model: claude-sonnet-4-5;}
.code { model: claude-opus-4-6; }
#critical_review { model: gpt-5.2;}
"
]
// ...

View file

@ -242,9 +242,9 @@ impl CodergenBackend for AgentApiBackend {
.await
.map_err(|e| FabroError::handler(format!("Failed to create LLM client: {e}")))?;
let model = node.llm_model().unwrap_or(&self.model);
let model = node.model().unwrap_or(&self.model);
let provider = node
.llm_provider()
.provider()
.map(String::from)
.or_else(|| Some(self.provider.as_str().to_string()));
@ -281,7 +281,7 @@ impl CodergenBackend for AgentApiBackend {
// Build per-request fallback chain: if the node overrides the provider,
// no failover is available; otherwise use the backend's.
let fallback_chain: &[FallbackTarget] = if node.llm_provider().is_some() {
let fallback_chain: &[FallbackTarget] = if node.provider().is_some() {
&[]
} else {
&self.fallback_chain

View file

@ -463,9 +463,9 @@ impl CodergenBackend for AgentCliBackend {
.map_err(|e| FabroError::handler(format!("Failed to write prompt file: {e}")))?;
// 3. Build CLI command
let model = node.llm_model().unwrap_or(&self.model);
let model = node.model().unwrap_or(&self.model);
let provider = node
.llm_provider()
.provider()
.and_then(|s| s.parse::<Provider>().ok())
.unwrap_or(self.provider);
@ -733,7 +733,7 @@ impl BackendRouter {
}
// CLI-only model on the node
if let Some(model) = node.llm_model() {
if let Some(model) = node.model() {
if is_cli_only_model(model) {
return true;
}
@ -1164,7 +1164,7 @@ mod tests {
fn router_uses_api_for_non_cli_model() {
let mut node = Node::new("test");
node.attrs.insert(
"llm_model".to_string(),
"model".to_string(),
AttrValue::String("claude-opus-4-6".to_string()),
);

View file

@ -2094,8 +2094,8 @@ async fn run_preflight(
if !crate::graph::types::is_llm_handler_type(node.handler_type()) {
continue;
}
let node_model = node.llm_model().unwrap_or(&model);
let node_provider = node.llm_provider().unwrap_or(default_provider);
let node_model = node.model().unwrap_or(&model);
let node_provider = node.provider().unwrap_or(default_provider);
// Resolve through catalog to get canonical model ID and provider
let (resolved_model, resolved_provider) =
@ -2106,7 +2106,7 @@ async fn run_preflight(
};
// Use node-level provider override if explicitly set, otherwise catalog provider
let final_provider = if node.llm_provider().is_some() {
let final_provider = if node.provider().is_some() {
node_provider.to_string()
} else {
resolved_provider

View file

@ -191,13 +191,13 @@ impl Node {
}
#[must_use]
pub fn llm_model(&self) -> Option<&str> {
self.str_attr("llm_model")
pub fn model(&self) -> Option<&str> {
self.str_attr("model")
}
#[must_use]
pub fn llm_provider(&self) -> Option<&str> {
self.str_attr("llm_provider")
pub fn provider(&self) -> Option<&str> {
self.str_attr("provider")
}
#[must_use]
@ -526,8 +526,8 @@ mod tests {
assert_eq!(node.thread_id(), None);
assert_eq!(node.class(), None);
assert_eq!(node.timeout(), None);
assert_eq!(node.llm_model(), None);
assert_eq!(node.llm_provider(), None);
assert_eq!(node.model(), None);
assert_eq!(node.provider(), None);
assert_eq!(node.reasoning_effort(), "high");
assert!(!node.auto_status());
assert!(!node.allow_partial());

View file

@ -54,7 +54,7 @@ impl Handler for PromptHandler {
let system_prompt = if node.project_memory() {
let working_dir = services.sandbox.working_directory();
let provider = node
.llm_provider()
.provider()
.and_then(|s| s.parse::<Provider>().ok())
.unwrap_or(Provider::Anthropic);
let docs = fabro_agent::discover_project_docs(

View file

@ -177,8 +177,7 @@ fn parse_declarations(remaining: &mut &str) -> Result<Vec<Declaration>, FabroErr
}
/// Recognized stylesheet properties.
const STYLESHEET_PROPERTIES: &[&str] =
&["llm_model", "llm_provider", "reasoning_effort", "backend"];
const STYLESHEET_PROPERTIES: &[&str] = &["model", "provider", "reasoning_effort", "backend"];
/// Apply a stylesheet to a graph. Rules are applied by specificity order;
/// higher specificity wins. Explicit node attributes are never overridden.
@ -246,26 +245,24 @@ mod tests {
#[test]
fn parse_universal_rule() {
let ss = parse_stylesheet("* { llm_model: claude-sonnet-4-5; llm_provider: anthropic; }")
.unwrap();
let ss = parse_stylesheet("* { model: claude-sonnet-4-5; provider: anthropic; }").unwrap();
assert_eq!(ss.rules.len(), 1);
assert_eq!(ss.rules[0].selector, Selector::Universal);
assert_eq!(ss.rules[0].declarations.len(), 2);
assert_eq!(ss.rules[0].declarations[0].property, "llm_model");
assert_eq!(ss.rules[0].declarations[0].property, "model");
assert_eq!(ss.rules[0].declarations[0].value, "claude-sonnet-4-5");
}
#[test]
fn parse_class_rule() {
let ss = parse_stylesheet(".code { llm_model: claude-opus-4-6; }").unwrap();
let ss = parse_stylesheet(".code { model: claude-opus-4-6; }").unwrap();
assert_eq!(ss.rules[0].selector, Selector::Class("code".into()));
}
#[test]
fn parse_id_rule() {
let ss =
parse_stylesheet("#critical_review { llm_model: gpt-5.2; reasoning_effort: high; }")
.unwrap();
let ss = parse_stylesheet("#critical_review { model: gpt-5.2; reasoning_effort: high; }")
.unwrap();
assert_eq!(ss.rules[0].selector, Selector::Id("critical_review".into()));
assert_eq!(ss.rules[0].declarations.len(), 2);
}
@ -273,9 +270,9 @@ mod tests {
#[test]
fn parse_multiple_rules() {
let input = r"
* { llm_model: claude-sonnet-4-5; llm_provider: anthropic; }
.code { llm_model: claude-opus-4-6; llm_provider: anthropic; }
#critical_review { llm_model: gpt-5.2; llm_provider: openai; reasoning_effort: high; }
* { model: claude-sonnet-4-5; provider: anthropic; }
.code { model: claude-opus-4-6; provider: anthropic; }
#critical_review { model: gpt-5.2; provider: openai; reasoning_effort: high; }
";
let ss = parse_stylesheet(input).unwrap();
assert_eq!(ss.rules.len(), 3);
@ -283,37 +280,37 @@ mod tests {
#[test]
fn parse_error_missing_brace() {
let result = parse_stylesheet("* llm_model: test; }");
let result = parse_stylesheet("* model: test; }");
assert!(result.is_err());
}
#[test]
fn parse_error_missing_selector() {
let result = parse_stylesheet("{ llm_model: test; }");
let result = parse_stylesheet("{ model: test; }");
assert!(result.is_err());
}
#[test]
fn apply_universal_to_all_nodes() {
let ss = parse_stylesheet("* { llm_model: sonnet; }").unwrap();
let ss = parse_stylesheet("* { model: sonnet; }").unwrap();
let mut graph = Graph::new("test");
graph.nodes.insert("a".into(), Node::new("a"));
graph.nodes.insert("b".into(), Node::new("b"));
apply_stylesheet(&ss, &mut graph);
assert_eq!(
graph.nodes["a"].attrs.get("llm_model"),
graph.nodes["a"].attrs.get("model"),
Some(&AttrValue::String("sonnet".into()))
);
assert_eq!(
graph.nodes["b"].attrs.get("llm_model"),
graph.nodes["b"].attrs.get("model"),
Some(&AttrValue::String("sonnet".into()))
);
}
#[test]
fn apply_class_overrides_universal() {
let ss = parse_stylesheet("* { llm_model: sonnet; } .code { llm_model: opus; }").unwrap();
let ss = parse_stylesheet("* { model: sonnet; } .code { model: opus; }").unwrap();
let mut graph = Graph::new("test");
let mut code_node = Node::new("impl");
@ -326,19 +323,18 @@ mod tests {
apply_stylesheet(&ss, &mut graph);
assert_eq!(
graph.nodes["impl"].attrs.get("llm_model"),
graph.nodes["impl"].attrs.get("model"),
Some(&AttrValue::String("opus".into()))
);
assert_eq!(
graph.nodes["plan"].attrs.get("llm_model"),
graph.nodes["plan"].attrs.get("model"),
Some(&AttrValue::String("sonnet".into()))
);
}
#[test]
fn apply_id_overrides_class() {
let ss =
parse_stylesheet(".code { llm_model: opus; } #special { llm_model: gpt; }").unwrap();
let ss = parse_stylesheet(".code { model: opus; } #special { model: gpt; }").unwrap();
let mut graph = Graph::new("test");
let mut node = Node::new("special");
@ -348,25 +344,25 @@ mod tests {
apply_stylesheet(&ss, &mut graph);
assert_eq!(
graph.nodes["special"].attrs.get("llm_model"),
graph.nodes["special"].attrs.get("model"),
Some(&AttrValue::String("gpt".into()))
);
}
#[test]
fn explicit_attrs_not_overridden() {
let ss = parse_stylesheet("* { llm_model: sonnet; }").unwrap();
let ss = parse_stylesheet("* { model: sonnet; }").unwrap();
let mut graph = Graph::new("test");
let mut node = Node::new("a");
node.attrs
.insert("llm_model".into(), AttrValue::String("explicit".into()));
.insert("model".into(), AttrValue::String("explicit".into()));
graph.nodes.insert("a".into(), node);
apply_stylesheet(&ss, &mut graph);
assert_eq!(
graph.nodes["a"].attrs.get("llm_model"),
graph.nodes["a"].attrs.get("model"),
Some(&AttrValue::String("explicit".into()))
);
}
@ -382,9 +378,9 @@ mod tests {
#[test]
fn spec_section_86_example() {
let input = r"
* { llm_model: claude-sonnet-4-5; llm_provider: anthropic; }
.code { llm_model: claude-opus-4-6; llm_provider: anthropic; }
#critical_review { llm_model: gpt-5.2; llm_provider: openai; reasoning_effort: high; }
* { model: claude-sonnet-4-5; provider: anthropic; }
.code { model: claude-opus-4-6; provider: anthropic; }
#critical_review { model: gpt-5.2; provider: openai; reasoning_effort: high; }
";
let ss = parse_stylesheet(input).unwrap();
let mut graph = Graph::new("test");
@ -404,21 +400,21 @@ mod tests {
apply_stylesheet(&ss, &mut graph);
assert_eq!(
graph.nodes["plan"].attrs.get("llm_model"),
graph.nodes["plan"].attrs.get("model"),
Some(&AttrValue::String("claude-sonnet-4-5".into()))
);
assert_eq!(
graph.nodes["implement"].attrs.get("llm_model"),
graph.nodes["implement"].attrs.get("model"),
Some(&AttrValue::String("claude-opus-4-6".into()))
);
assert_eq!(
graph.nodes["critical_review"].attrs.get("llm_model"),
graph.nodes["critical_review"].attrs.get("model"),
Some(&AttrValue::String("gpt-5.2".into()))
);
assert_eq!(
graph.nodes["critical_review"].attrs.get("llm_provider"),
graph.nodes["critical_review"].attrs.get("provider"),
Some(&AttrValue::String("openai".into()))
);
assert_eq!(
@ -429,7 +425,7 @@ mod tests {
#[test]
fn parse_shape_selector() {
let ss = parse_stylesheet("box { llm_model: opus; }").unwrap();
let ss = parse_stylesheet("box { model: opus; }").unwrap();
assert_eq!(ss.rules.len(), 1);
assert_eq!(ss.rules[0].selector, Selector::Shape("box".into()));
assert_eq!(ss.rules[0].declarations[0].value, "opus");
@ -437,7 +433,7 @@ mod tests {
#[test]
fn apply_shape_selector_to_matching_nodes() {
let ss = parse_stylesheet("box { llm_model: opus; }").unwrap();
let ss = parse_stylesheet("box { model: opus; }").unwrap();
let mut graph = Graph::new("test");
// Default shape is "box"
@ -453,42 +449,42 @@ mod tests {
apply_stylesheet(&ss, &mut graph);
assert_eq!(
graph.nodes["a"].attrs.get("llm_model"),
graph.nodes["a"].attrs.get("model"),
Some(&AttrValue::String("opus".into()))
);
// Mdiamond node should NOT get the box rule
assert_eq!(graph.nodes["b"].attrs.get("llm_model"), None);
assert_eq!(graph.nodes["b"].attrs.get("model"), None);
}
#[test]
fn shape_overrides_universal_specificity() {
let ss = parse_stylesheet("* { llm_model: sonnet; } box { llm_model: opus; }").unwrap();
let ss = parse_stylesheet("* { model: sonnet; } box { model: opus; }").unwrap();
let mut graph = Graph::new("test");
graph.nodes.insert("a".into(), Node::new("a")); // default shape = box
apply_stylesheet(&ss, &mut graph);
assert_eq!(
graph.nodes["a"].attrs.get("llm_model"),
graph.nodes["a"].attrs.get("model"),
Some(&AttrValue::String("opus".into()))
);
}
#[test]
fn class_overrides_shape_specificity() {
let ss = parse_stylesheet("box { llm_model: opus; } .fast { llm_model: flash; }").unwrap();
let ss = parse_stylesheet("box { model: opus; } .fast { model: flash; }").unwrap();
let mut graph = Graph::new("test");
let mut node = Node::new("a");
node.classes.push("fast".into());
graph.nodes.insert("a".into(), node);
apply_stylesheet(&ss, &mut graph);
assert_eq!(
graph.nodes["a"].attrs.get("llm_model"),
graph.nodes["a"].attrs.get("model"),
Some(&AttrValue::String("flash".into()))
);
}
#[test]
fn class_overrides_universal_specificity() {
let ss = parse_stylesheet("* { llm_model: sonnet; } .special { llm_model: gpt; }").unwrap();
let ss = parse_stylesheet("* { model: sonnet; } .special { model: gpt; }").unwrap();
let mut graph = Graph::new("test");
let mut node_a = Node::new("a");
@ -502,12 +498,12 @@ mod tests {
// .special (specificity 1) overrides * (specificity 0)
assert_eq!(
graph.nodes["a"].attrs.get("llm_model"),
graph.nodes["a"].attrs.get("model"),
Some(&AttrValue::String("gpt".into()))
);
// No class, gets universal
assert_eq!(
graph.nodes["b"].attrs.get("llm_model"),
graph.nodes["b"].attrs.get("model"),
Some(&AttrValue::String("sonnet".into()))
);
}

View file

@ -112,6 +112,29 @@ impl Transform for StylesheetApplicationTransform {
}
}
/// For nodes with `model` but no `provider`, infer the provider from the model catalog.
pub struct ProviderInferenceTransform;
impl Transform for ProviderInferenceTransform {
fn apply(&self, graph: &mut Graph) {
for node in graph.nodes.values_mut() {
let model = node
.attrs
.get("model")
.and_then(AttrValue::as_str)
.map(String::from);
if let Some(model) = model {
if !node.attrs.contains_key("provider") {
if let Some(info) = fabro_llm::catalog::get_model_info(&model) {
node.attrs
.insert("provider".to_string(), AttrValue::String(info.provider));
}
}
}
}
}
}
/// Resolve a potential `@path` file reference.
///
/// If `value` starts with `@` and the referenced file exists locally, the file
@ -479,7 +502,7 @@ mod tests {
);
primary.attrs.insert(
"model_stylesheet".to_string(),
AttrValue::String("* { llm_model: sonnet; }".to_string()),
AttrValue::String("* { model: sonnet; }".to_string()),
);
let mut secondary = Graph::new("sub");
@ -493,7 +516,7 @@ mod tests {
transform.apply(&mut primary);
assert_eq!(primary.goal(), "Build feature");
assert_eq!(primary.model_stylesheet(), "* { llm_model: sonnet; }");
assert_eq!(primary.model_stylesheet(), "* { model: sonnet; }");
}
#[test]
@ -592,6 +615,82 @@ mod tests {
);
}
// -----------------------------------------------------------------------
// ProviderInferenceTransform tests
// -----------------------------------------------------------------------
#[test]
fn provider_inference_sets_provider_from_catalog() {
let mut graph = Graph::new("test");
let mut node = Node::new("a");
node.attrs.insert(
"model".to_string(),
AttrValue::String("claude-sonnet-4-5".to_string()),
);
graph.nodes.insert("a".to_string(), node);
ProviderInferenceTransform.apply(&mut graph);
assert_eq!(
graph.nodes["a"]
.attrs
.get("provider")
.and_then(AttrValue::as_str),
Some("anthropic")
);
}
#[test]
fn provider_inference_does_not_override_explicit_provider() {
let mut graph = Graph::new("test");
let mut node = Node::new("a");
node.attrs.insert(
"model".to_string(),
AttrValue::String("claude-sonnet-4-5".to_string()),
);
node.attrs.insert(
"provider".to_string(),
AttrValue::String("custom".to_string()),
);
graph.nodes.insert("a".to_string(), node);
ProviderInferenceTransform.apply(&mut graph);
assert_eq!(
graph.nodes["a"]
.attrs
.get("provider")
.and_then(AttrValue::as_str),
Some("custom")
);
}
#[test]
fn provider_inference_unknown_model_leaves_no_provider() {
let mut graph = Graph::new("test");
let mut node = Node::new("a");
node.attrs.insert(
"model".to_string(),
AttrValue::String("unknown-model-xyz".to_string()),
);
graph.nodes.insert("a".to_string(), node);
ProviderInferenceTransform.apply(&mut graph);
assert_eq!(graph.nodes["a"].attrs.get("provider"), None);
}
#[test]
fn provider_inference_no_model_no_change() {
let mut graph = Graph::new("test");
let node = Node::new("a");
graph.nodes.insert("a".to_string(), node);
ProviderInferenceTransform.apply(&mut graph);
assert_eq!(graph.nodes["a"].attrs.get("provider"), None);
}
// -----------------------------------------------------------------------
// resolve_file_ref tests
// -----------------------------------------------------------------------

View file

@ -929,7 +929,7 @@ impl LintRule for StylesheetModelKnownRule {
let label = Self::selector_label(&rule.selector);
for decl in &rule.declarations {
match decl.property.as_str() {
"llm_model" => {
"model" => {
if fabro_llm::catalog::get_model_info(&decl.value).is_none() {
diagnostics.push(Diagnostic {
rule: self.name().to_string(),
@ -944,7 +944,7 @@ impl LintRule for StylesheetModelKnownRule {
});
}
}
"llm_provider" => {
"provider" => {
if fabro_llm::Provider::from_str(&decl.value).is_err() {
let valid: Vec<&str> = fabro_llm::Provider::ALL
.iter()
@ -1432,7 +1432,7 @@ mod tests {
let mut g = minimal_graph();
g.attrs.insert(
"model_stylesheet".to_string(),
AttrValue::String("* { llm_model: foo;".to_string()),
AttrValue::String("* { model: foo;".to_string()),
);
let rule = StylesheetSyntaxRule;
let d = rule.apply(&g);
@ -1445,7 +1445,7 @@ mod tests {
let mut g = minimal_graph();
g.attrs.insert(
"model_stylesheet".to_string(),
AttrValue::String("* { llm_model: foo; }".to_string()),
AttrValue::String("* { model: foo; }".to_string()),
);
let rule = StylesheetSyntaxRule;
let d = rule.apply(&g);
@ -2228,8 +2228,7 @@ mod tests {
g.attrs.insert(
"model_stylesheet".to_string(),
AttrValue::String(
"* { llm_model: gpt-4; } .fast { llm_model: gpt-3.5; reasoning_effort: low; }"
.to_string(),
"* { model: gpt-4; } .fast { model: gpt-3.5; reasoning_effort: low; }".to_string(),
),
);
let rule = StylesheetSyntaxRule;
@ -2836,9 +2835,7 @@ mod tests {
let mut g = minimal_graph();
g.attrs.insert(
"model_stylesheet".to_string(),
AttrValue::String(
"* { llm_model: claude-sonnet-4-5; llm_provider: anthropic; }".to_string(),
),
AttrValue::String("* { model: claude-sonnet-4-5; provider: anthropic; }".to_string()),
);
let rule = StylesheetModelKnownRule;
let d = rule.apply(&g);
@ -2850,7 +2847,7 @@ mod tests {
let mut g = minimal_graph();
g.attrs.insert(
"model_stylesheet".to_string(),
AttrValue::String("#opus { llm_model: claude-opus-4-5; }".to_string()),
AttrValue::String("#opus { model: claude-opus-4-5; }".to_string()),
);
let rule = StylesheetModelKnownRule;
let d = rule.apply(&g);
@ -2865,7 +2862,7 @@ mod tests {
let mut g = minimal_graph();
g.attrs.insert(
"model_stylesheet".to_string(),
AttrValue::String("* { llm_provider: google; }".to_string()),
AttrValue::String("* { provider: google; }".to_string()),
);
let rule = StylesheetModelKnownRule;
let d = rule.apply(&g);
@ -2879,7 +2876,7 @@ mod tests {
let mut g = minimal_graph();
g.attrs.insert(
"model_stylesheet".to_string(),
AttrValue::String("* { llm_model: opus; }".to_string()),
AttrValue::String("* { model: opus; }".to_string()),
);
let rule = StylesheetModelKnownRule;
let d = rule.apply(&g);

View file

@ -3,7 +3,8 @@ use std::path::Path;
use crate::error::FabroError;
use crate::graph::Graph;
use crate::transform::{
FileInliningTransform, StylesheetApplicationTransform, Transform, VariableExpansionTransform,
FileInliningTransform, ProviderInferenceTransform, StylesheetApplicationTransform, Transform,
VariableExpansionTransform,
};
use crate::validation::{self, Diagnostic};
@ -60,6 +61,7 @@ impl WorkflowBuilder {
// Built-in transforms (PreambleTransform moved to engine execution time)
VariableExpansionTransform.apply(&mut graph);
StylesheetApplicationTransform.apply(&mut graph);
ProviderInferenceTransform.apply(&mut graph);
// File inlining when base_dir is provided
if let Some(dir) = base_dir {
@ -148,7 +150,7 @@ mod tests {
#[test]
fn prepare_from_source_applies_stylesheet() {
let dot = r#"digraph Test {
graph [goal="Test", model_stylesheet="* { llm_model: sonnet; }"]
graph [goal="Test", model_stylesheet="* { model: sonnet; }"]
start [shape=Mdiamond]
work [label="Work"]
exit [shape=Msquare]
@ -156,7 +158,7 @@ mod tests {
}"#;
let graph = prepare_from_source(dot).unwrap();
assert_eq!(
graph.nodes["work"].attrs.get("llm_model"),
graph.nodes["work"].attrs.get("model"),
Some(&AttrValue::String("sonnet".into()))
);
}

View file

@ -37,7 +37,7 @@ fn parse_attractor_batch_clean() {
#[test]
fn parse_attractor_batch_has_errors() {
// This file is intentionally missing llm_provider on the work node.
// This file is intentionally missing provider on the work node.
// It should still parse successfully — validation is separate from parsing.
let graph = parse_attractor_dot("batch_has_errors.dot").unwrap();
assert_eq!(graph.nodes.len(), 3);

View file

@ -809,9 +809,9 @@ fn variable_expansion_replaces_goal_in_prompts() {
#[test]
fn stylesheet_application_by_specificity() {
let stylesheet_text = r"
* { llm_model: claude-sonnet-4-5; llm_provider: anthropic; }
.code { llm_model: claude-opus-4-6; llm_provider: anthropic; }
#critical_review { llm_model: gpt-5.2; llm_provider: openai; reasoning_effort: high; }
* { model: claude-sonnet-4-5; provider: anthropic; }
.code { model: claude-opus-4-6; provider: anthropic; }
#critical_review { model: gpt-5.2; provider: openai; reasoning_effort: high; }
";
let mut graph = Graph::new("test");
@ -834,10 +834,10 @@ fn stylesheet_application_by_specificity() {
critical.classes.push("code".to_string());
graph.nodes.insert("critical_review".to_string(), critical);
// explicit node: has explicit llm_model, should NOT be overridden
// explicit node: has explicit model, should NOT be overridden
let mut explicit = Node::new("explicit_node");
explicit.attrs.insert(
"llm_model".to_string(),
"model".to_string(),
AttrValue::String("my-custom-model".to_string()),
);
graph.nodes.insert("explicit_node".to_string(), explicit);
@ -847,31 +847,31 @@ fn stylesheet_application_by_specificity() {
// plan: universal -> claude-sonnet-4-5
assert_eq!(
graph.nodes["plan"].attrs.get("llm_model"),
graph.nodes["plan"].attrs.get("model"),
Some(&AttrValue::String("claude-sonnet-4-5".to_string()))
);
assert_eq!(
graph.nodes["plan"].attrs.get("llm_provider"),
graph.nodes["plan"].attrs.get("provider"),
Some(&AttrValue::String("anthropic".to_string()))
);
// implement: .code -> claude-opus-4-6
assert_eq!(
graph.nodes["implement"].attrs.get("llm_model"),
graph.nodes["implement"].attrs.get("model"),
Some(&AttrValue::String("claude-opus-4-6".to_string()))
);
assert_eq!(
graph.nodes["implement"].attrs.get("llm_provider"),
graph.nodes["implement"].attrs.get("provider"),
Some(&AttrValue::String("anthropic".to_string()))
);
// critical_review: #critical_review -> gpt-5.2 (id overrides class)
assert_eq!(
graph.nodes["critical_review"].attrs.get("llm_model"),
graph.nodes["critical_review"].attrs.get("model"),
Some(&AttrValue::String("gpt-5.2".to_string()))
);
assert_eq!(
graph.nodes["critical_review"].attrs.get("llm_provider"),
graph.nodes["critical_review"].attrs.get("provider"),
Some(&AttrValue::String("openai".to_string()))
);
assert_eq!(
@ -881,7 +881,7 @@ fn stylesheet_application_by_specificity() {
// explicit_node: explicit attr NOT overridden by universal
assert_eq!(
graph.nodes["explicit_node"].attrs.get("llm_model"),
graph.nodes["explicit_node"].attrs.get("model"),
Some(&AttrValue::String("my-custom-model".to_string()))
);
}
@ -891,7 +891,7 @@ fn stylesheet_application_via_parsed_graph() {
let input = r#"digraph StyleTest {
graph [
goal="Test stylesheet",
model_stylesheet="* { llm_model: sonnet; }"
model_stylesheet="* { model: sonnet; }"
]
start [shape=Mdiamond]
exit [shape=Msquare]
@ -905,24 +905,24 @@ fn stylesheet_application_via_parsed_graph() {
let transform = StylesheetApplicationTransform;
transform.apply(&mut graph);
// All nodes without explicit llm_model should get "sonnet"
// All nodes without explicit model should get "sonnet"
assert_eq!(
graph.nodes["work"].attrs.get("llm_model"),
graph.nodes["work"].attrs.get("model"),
Some(&AttrValue::String("sonnet".to_string()))
);
assert_eq!(
graph.nodes["start"].attrs.get("llm_model"),
graph.nodes["start"].attrs.get("model"),
Some(&AttrValue::String("sonnet".to_string()))
);
assert_eq!(
graph.nodes["exit"].attrs.get("llm_model"),
graph.nodes["exit"].attrs.get("model"),
Some(&AttrValue::String("sonnet".to_string()))
);
}
#[test]
fn stylesheet_parse_and_apply_directly() {
let stylesheet_text = "* { llm_model: base; } .fast { llm_model: turbo; }";
let stylesheet_text = "* { model: base; } .fast { model: turbo; }";
let stylesheet = parse_stylesheet(stylesheet_text).expect("stylesheet parse should succeed");
assert_eq!(stylesheet.rules.len(), 2);
@ -937,11 +937,11 @@ fn stylesheet_parse_and_apply_directly() {
apply_stylesheet(&stylesheet, &mut graph);
assert_eq!(
graph.nodes["a"].attrs.get("llm_model"),
graph.nodes["a"].attrs.get("model"),
Some(&AttrValue::String("base".to_string()))
);
assert_eq!(
graph.nodes["b"].attrs.get("llm_model"),
graph.nodes["b"].attrs.get("model"),
Some(&AttrValue::String("turbo".to_string()))
);
}
@ -3341,7 +3341,7 @@ async fn stylesheet_applies_model_override() {
let input = r#"digraph StylesheetTest {
graph [
goal="Test stylesheet",
model_stylesheet="* { llm_model: custom-model; }"
model_stylesheet="* { model: custom-model; }"
]
start [shape=Mdiamond]
exit [shape=Msquare]
@ -3351,7 +3351,7 @@ async fn stylesheet_applies_model_override() {
let mut graph = parse(input).expect("parse");
validate_or_raise(&graph, &[]).expect("validate");
StylesheetApplicationTransform.apply(&mut graph);
assert_eq!(graph.nodes["work"].llm_model(), Some("custom-model"));
assert_eq!(graph.nodes["work"].model(), Some("custom-model"));
let dir = tempfile::tempdir().unwrap();
let engine = WorkflowRunEngine::new(
@ -3453,7 +3453,7 @@ async fn integration_smoke_plan_implement_review_done() {
let dot = r#"digraph SmokeIntegration {
graph [
goal="Build the feature",
model_stylesheet="* { llm_model: test-model; }"
model_stylesheet="* { model: test-model; }"
]
rankdir=LR
start [shape=Mdiamond]
@ -3484,7 +3484,7 @@ async fn integration_smoke_plan_implement_review_done() {
graph.nodes["plan"].prompt().unwrap(),
"Plan: Build the feature"
);
assert_eq!(graph.nodes["plan"].llm_model(), Some("test-model"));
assert_eq!(graph.nodes["plan"].model(), Some("test-model"));
// Run pipeline
let interviewer = Arc::new(AutoApproveInterviewer);
@ -6530,7 +6530,7 @@ mod real_llm {
),
);
classify.attrs.insert(
"llm_model".to_string(),
"model".to_string(),
AttrValue::String("claude-haiku-4-5".to_string()),
);
graph.nodes.insert("classify".to_string(), classify);
@ -9910,7 +9910,7 @@ async fn cli_backend_run_uses_node_model_override() {
let mut node = Node::new("step");
node.attrs.insert(
"llm_model".to_string(),
"model".to_string(),
AttrValue::String("claude-sonnet-4-5".to_string()),
);
@ -9956,11 +9956,11 @@ async fn cli_backend_run_uses_node_provider_override() {
let mut node = Node::new("step");
node.attrs.insert(
"llm_provider".to_string(),
"provider".to_string(),
AttrValue::String("openai".to_string()),
);
node.attrs.insert(
"llm_model".to_string(),
"model".to_string(),
AttrValue::String("gpt-5.3-codex".to_string()),
);
@ -10133,7 +10133,7 @@ async fn backend_router_delegates_to_cli_for_backend_attr() {
node.attrs
.insert("backend".to_string(), AttrValue::String("cli".to_string()));
node.attrs.insert(
"llm_provider".to_string(),
"provider".to_string(),
AttrValue::String("openai".to_string()),
);

View file

@ -81,9 +81,9 @@ Use `model_stylesheet` for model assignment rather than per-node attributes:
```dot
graph [model_stylesheet="
* { llm_model: claude-sonnet-4-6; llm_provider: anthropic; }
.coding { llm_model: claude-opus-4-6; llm_provider: anthropic; }
.review { llm_model: gemini-3.1-pro-preview; llm_provider: gemini; }
* { model: claude-sonnet-4-6;}
.coding { model: claude-opus-4-6;}
.review { model: gemini-3.1-pro-preview;}
"]
```

View file

@ -73,7 +73,7 @@ Comments: `//` line and `/* */` block.
Multi-turn LLM with tools (shell, read_file, write_file, grep, glob, web_search, web_fetch, edit_file).
Key attributes: `prompt`, `reasoning_effort` (low/medium/high), `max_tokens`, `fidelity`, `thread_id`, `timeout`, `backend` (api or cli), `llm_model`, `llm_provider`, `project_memory`.
Key attributes: `prompt`, `reasoning_effort` (low/medium/high), `max_tokens`, `fidelity`, `thread_id`, `timeout`, `backend` (api or cli), `model`, `provider`, `project_memory`.
### Prompt Nodes (tab)
@ -156,17 +156,17 @@ CSS-like syntax for assigning models to nodes:
```dot
graph [model_stylesheet="
* { llm_model: claude-haiku-4-5; llm_provider: anthropic; }
.coding { llm_model: claude-sonnet-4-6; llm_provider: anthropic; }
#review { llm_model: gemini-3.1-pro-preview; llm_provider: gemini; }
* { model: claude-haiku-4-5;}
.coding { model: claude-sonnet-4-6;}
#review { model: gemini-3.1-pro-preview;}
"]
```
Selectors by specificity (low to high): `*` (universal, 0), shape name (1), `.class` (2), `#nodeid` (3). Higher specificity wins. Same specificity: last rule wins. Explicit node attributes override stylesheets.
Properties: `llm_model`, `llm_provider`, `reasoning_effort`, `backend`.
Properties: `model`, `provider`, `reasoning_effort`, `backend`.
**Critical:** Use semicolons between properties (e.g. `llm_model: foo; llm_provider: bar;`).
**Critical:** Use semicolons between properties (e.g. `model: foo; provider: bar;`).
## Variables

View file

@ -151,9 +151,9 @@ digraph MultiModel {
graph [
goal="Build and review a utility function using multiple models",
model_stylesheet="
* { llm_model: claude-haiku-4-5; llm_provider: anthropic; reasoning_effort: low; }
.coding { llm_model: claude-sonnet-4-6; llm_provider: anthropic; reasoning_effort: high; }
#review { llm_model: claude-sonnet-4-6; llm_provider: anthropic; reasoning_effort: high; }
* { model: claude-haiku-4-5;reasoning_effort: low; }
.coding { model: claude-sonnet-4-6;reasoning_effort: high; }
#review { model: claude-sonnet-4-6;reasoning_effort: high; }
"
]
rankdir=LR
@ -179,10 +179,10 @@ digraph Ensemble {
graph [
goal="Get independent opinions from multiple providers, then synthesize",
model_stylesheet="
#opus { llm_model: claude-opus-4-6; llm_provider: anthropic; }
#gemini { llm_model: gemini-3.1-pro-preview; llm_provider: gemini; }
#codex { llm_model: gpt-5.3-codex; llm_provider: openai; }
#synth { llm_model: claude-opus-4-6; llm_provider: anthropic; reasoning_effort: high; }
#opus { model: claude-opus-4-6; }
#gemini { model: gemini-3.1-pro-preview;}
#codex { model: gpt-5.3-codex; }
#synth { model: claude-opus-4-6; reasoning_effort: high; }
"
]
rankdir=LR
@ -219,7 +219,7 @@ digraph ImplementAndSimplify {
graph [
goal="Implement and simplify",
model_stylesheet="
* { backend: api; llm_model: claude-opus-4-6; llm_provider: anthropic; }
* { backend: api; model: claude-opus-4-6;}
"
]
rankdir=LR

View file

@ -1,6 +1,6 @@
digraph G {
start [shape=Mdiamond]
exit [shape=Msquare]
work [shape=box, llm_provider=openai, llm_model=gpt-5.2, prompt="Do the work."]
work [shape=box, model=gpt-5.2, prompt="Do the work."]
start -> work -> exit
}

View file

@ -1,6 +1,6 @@
digraph G {
start [shape=Mdiamond]
exit [shape=Msquare]
work [shape=box, llm_model=gpt-5.2, prompt="Do the work."]
work [shape=box, model=gpt-5.2, prompt="Do the work."]
start -> work -> exit
}

View file

@ -1,6 +1,6 @@
digraph G {
start [shape=Mdiamond]
exit [shape=Msquare]
work [shape=box, llm_provider=openai, llm_model=gpt-5.2]
work [shape=box, model=gpt-5.2]
start -> work -> exit
}

View file

@ -12,118 +12,118 @@ digraph Workflow {
Start [
node_type="start", label="Start", shape="Mdiamond", style="rounded,filled",
is_codergen="true", llm_provider="openai", llm_model="gpt-5.2-codex",
is_codergen="true", model="gpt-5.2-codex",
max_agent_turns="4", timeout="300"
];
CheckDoD [
node_type="stack.steer", label="Check DoD", shape="box", style="rounded,filled",
is_codergen="true", llm_provider="openai", llm_model="gpt-5.2-codex",
is_codergen="true", model="gpt-5.2-codex",
max_agent_turns="2", timeout="120",
llm_prompt="Check if definition of done provided.\n\nTASK: $task\nDOD: $definition_of_done\n\nWrite status.json with outcome=needs_dod if DOD is empty or just a placeholder, else outcome=has_dod if a real DOD was provided."
];
DefineDoD_Gemini [
node_type="stack.observe", label="Define DoD (Gemini)", shape="box", style="rounded,filled",
is_codergen="true", llm_provider="openai", llm_model="gpt-5.2-codex",
is_codergen="true", model="gpt-5.2-codex",
max_agent_turns="6", timeout="300",
llm_prompt="Propose definition of done.\n\nTASK: $task\n\nWrite to .ai/dod_gemini.md"
];
DefineDoD_GPT [
node_type="stack.observe", label="Define DoD (GPT)", shape="box", style="rounded,filled",
is_codergen="true", llm_provider="openai", llm_model="gpt-5.2-codex",
is_codergen="true", model="gpt-5.2-codex",
max_agent_turns="6", timeout="300", reasoning_effort="high",
llm_prompt="Propose definition of done.\n\nTASK: $task\n\nWrite to .ai/dod_gpt.md"
];
DefineDoD_Opus [
node_type="stack.observe", label="Define DoD (Opus)", shape="box", style="rounded,filled",
is_codergen="true", llm_provider="openai", llm_model="gpt-5.2-codex",
is_codergen="true", model="gpt-5.2-codex",
max_agent_turns="6", timeout="300",
llm_prompt="Propose definition of done.\n\nTASK: $task\n\nWrite to .ai/dod_opus.md"
];
ConsolidateDoD [
node_type="stack.observe", label="Consolidate DoD", shape="box", style="rounded,filled",
is_codergen="true", llm_provider="openai", llm_model="gpt-5.2-codex",
is_codergen="true", model="gpt-5.2-codex",
max_agent_turns="8", timeout="420",
llm_prompt="Synthesize three DoD proposals.\n\nTASK: $task\n\nRead .ai/dod_*.md, write consensus to .ai/definition_of_done.md"
];
PlanGemini [
node_type="stack.observe", label="Plan (Gemini)", shape="box", style="rounded,filled",
is_codergen="true", llm_provider="openai", llm_model="gpt-5.2-codex",
is_codergen="true", model="gpt-5.2-codex",
max_agent_turns="8", timeout="420",
llm_prompt="Create implementation plan.\n\nTASK: $task\nDOD: .ai/definition_of_done.md or $definition_of_done\n\nWrite to .ai/plan_gemini.md"
];
PlanGPT [
node_type="stack.observe", label="Plan (GPT)", shape="box", style="rounded,filled",
is_codergen="true", llm_provider="openai", llm_model="gpt-5.2-codex",
is_codergen="true", model="gpt-5.2-codex",
max_agent_turns="8", timeout="420", reasoning_effort="high",
llm_prompt="Create implementation plan.\n\nTASK: $task\nDOD: .ai/definition_of_done.md or $definition_of_done\n\nWrite to .ai/plan_gpt.md"
];
PlanOpus [
node_type="stack.observe", label="Plan (Opus)", shape="box", style="rounded,filled",
is_codergen="true", llm_provider="openai", llm_model="gpt-5.2-codex",
is_codergen="true", model="gpt-5.2-codex",
max_agent_turns="8", timeout="420",
llm_prompt="Create implementation plan.\n\nTASK: $task\nDOD: .ai/definition_of_done.md or $definition_of_done\n\nWrite to .ai/plan_opus.md"
];
DebateConsolidate [
node_type="stack.observe", label="Debate & Consolidate", shape="box", style="rounded,filled",
is_codergen="true", llm_provider="openai", llm_model="gpt-5.2-codex",
is_codergen="true", model="gpt-5.2-codex",
max_agent_turns="10", timeout="600",
llm_prompt="Synthesize three plans.\n\nTASK: $task\n\nRead .ai/plan_*.md, write final to .ai/plan_final.md"
];
Implement [
node_type="stack.observe", label="Implement (Opus)", shape="box", style="rounded,filled",
is_codergen="true", allow_partial="true", llm_provider="openai", llm_model="gpt-5.2-codex",
is_codergen="true", allow_partial="true", model="gpt-5.2-codex",
max_agent_turns="25", timeout="1200",
llm_prompt="Execute plan.\n\nTASK: $task\n\nFollow .ai/plan_final.md. Log to .ai/implementation_log.md"
];
ReviewGemini [
node_type="stack.observe", label="Review (Gemini)", shape="box", style="rounded,filled",
is_codergen="true", llm_provider="openai", llm_model="gpt-5.2-codex",
is_codergen="true", model="gpt-5.2-codex",
max_agent_turns="6", timeout="300",
llm_prompt="Review implementation.\n\nTASK: $task\n\nWrite to .ai/review_gemini.md with PASS/FAIL"
];
ReviewGPT [
node_type="stack.observe", label="Review (GPT)", shape="box", style="rounded,filled",
is_codergen="true", llm_provider="openai", llm_model="gpt-5.2-codex",
is_codergen="true", model="gpt-5.2-codex",
max_agent_turns="6", timeout="300", reasoning_effort="high",
llm_prompt="Review implementation.\n\nTASK: $task\n\nWrite to .ai/review_gpt.md with PASS/FAIL"
];
ReviewOpus [
node_type="stack.observe", label="Review (Opus)", shape="box", style="rounded,filled",
is_codergen="true", llm_provider="openai", llm_model="gpt-5.2-codex",
is_codergen="true", model="gpt-5.2-codex",
max_agent_turns="6", timeout="300",
llm_prompt="Review implementation.\n\nTASK: $task\n\nWrite to .ai/review_opus.md with PASS/FAIL"
];
ReviewConsensus [
node_type="stack.steer", label="Review Consensus", shape="box", style="rounded,filled",
is_codergen="true", llm_provider="openai", llm_model="gpt-5.2-codex",
is_codergen="true", model="gpt-5.2-codex",
max_agent_turns="6", timeout="300",
llm_prompt="Reach consensus.\n\nRead .ai/review_*.md\nWrite to .ai/review_consensus.md\n\noutcome=yes if PASS, outcome=retry if FAIL. Set preferred_next_label to \"\" (empty) or to \"yes\"/\"retry\"; do not use \"No\"."
];
Postmortem [
node_type="stack.observe", label="Postmortem", shape="box", style="rounded,filled",
is_codergen="true", llm_provider="openai", llm_model="gpt-5.2-codex",
is_codergen="true", model="gpt-5.2-codex",
max_agent_turns="8", timeout="420",
llm_prompt="Failure postmortem.\n\nTASK: $task\n\nWrite to .ai/postmortem_NN.md"
];
Exit [
node_type="exit", label="Exit", shape="Msquare", style="rounded,filled",
is_codergen="true", llm_provider="openai", llm_model="gpt-5.2-codex",
is_codergen="true", model="gpt-5.2-codex",
max_agent_turns="4", timeout="120"
];

View file

@ -5,10 +5,10 @@ digraph dttf {
default_max_retry=3,
retry_target="impl_setup",
model_stylesheet="
* { llm_model: gpt-5.2-codex; llm_provider: openai; reasoning_effort: medium; }
.hard { llm_model: gpt-5.2-codex; llm_provider: openai; reasoning_effort: high; }
.verify { llm_model: gpt-5.2-codex; llm_provider: openai; reasoning_effort: medium; }
.review { llm_model: gpt-5.2-codex; llm_provider: openai; reasoning_effort: high; }
* { model: gpt-5.2-codex;reasoning_effort: medium; }
.hard { model: gpt-5.2-codex;reasoning_effort: high; }
.verify { model: gpt-5.2-codex;reasoning_effort: medium; }
.review { model: gpt-5.2-codex;reasoning_effort: high; }
"
]

View file

@ -5,10 +5,10 @@ digraph linkcheck {
default_max_retry=3,
retry_target="impl_setup",
model_stylesheet="
* { llm_model: gpt-5.2-codex; llm_provider: openai; reasoning_effort: medium; }
.hard { llm_model: gpt-5.2-codex; llm_provider: openai; reasoning_effort: high; }
.verify { llm_model: gpt-5.2-codex; llm_provider: openai; reasoning_effort: medium; }
.review { llm_model: gpt-5.2-codex; llm_provider: openai; reasoning_effort: high; }
* { model: gpt-5.2-codex;reasoning_effort: medium; }
.hard { model: gpt-5.2-codex;reasoning_effort: high; }
.verify { model: gpt-5.2-codex;reasoning_effort: medium; }
.review { model: gpt-5.2-codex;reasoning_effort: high; }
"
]

View file

@ -5,10 +5,10 @@ digraph solitaire {
default_max_retry=3,
retry_target="impl_setup",
model_stylesheet="
* { llm_model: gpt-5.2-codex; llm_provider: openai; reasoning_effort: medium; }
.hard { llm_model: gpt-5.2-codex; llm_provider: openai; reasoning_effort: high; }
.verify { llm_model: gpt-5.2-codex; llm_provider: openai; reasoning_effort: medium; }
.review { llm_model: gpt-5.2-codex; llm_provider: openai; reasoning_effort: high; }
* { model: gpt-5.2-codex;reasoning_effort: medium; }
.hard { model: gpt-5.2-codex;reasoning_effort: high; }
.verify { model: gpt-5.2-codex;reasoning_effort: medium; }
.review { model: gpt-5.2-codex;reasoning_effort: high; }
"
]

View file

@ -6,10 +6,10 @@ digraph dttf {
retry_target="impl_setup",
fallback_retry_target="impl_loader",
model_stylesheet="
* { llm_model: gemini-3-flash-preview; llm_provider: google; reasoning_effort: medium; }
.hard { llm_model: gemini-3-flash-preview; llm_provider: google; reasoning_effort: high; }
.verify { llm_model: gemini-3-flash-preview; llm_provider: google; reasoning_effort: medium; }
.review { llm_model: gemini-3-flash-preview; llm_provider: google; reasoning_effort: high; }
* { model: gemini-3-flash-preview; provider: google; reasoning_effort: medium; }
.hard { model: gemini-3-flash-preview; provider: google; reasoning_effort: high; }
.verify { model: gemini-3-flash-preview; provider: google; reasoning_effort: medium; }
.review { model: gemini-3-flash-preview; provider: google; reasoning_effort: high; }
"
]

View file

@ -6,10 +6,10 @@ digraph linkcheck {
retry_target="impl_setup",
fallback_retry_target="impl_crawler",
model_stylesheet="
* { llm_model: gemini-3-flash-preview; llm_provider: google; reasoning_effort: medium; }
.hard { llm_model: gemini-3-flash-preview; llm_provider: google; reasoning_effort: high; }
.verify { llm_model: gemini-3-flash-preview; llm_provider: google; reasoning_effort: medium; }
.review { llm_model: gemini-3-flash-preview; llm_provider: google; reasoning_effort: high; }
* { model: gemini-3-flash-preview; provider: google; reasoning_effort: medium; }
.hard { model: gemini-3-flash-preview; provider: google; reasoning_effort: high; }
.verify { model: gemini-3-flash-preview; provider: google; reasoning_effort: medium; }
.review { model: gemini-3-flash-preview; provider: google; reasoning_effort: high; }
"
]

View file

@ -6,7 +6,7 @@ digraph solitaire {
retry_target="impl_setup",
fallback_retry_target="impl_game_logic",
model_stylesheet="
* { llm_model: gemini-3-flash-preview; llm_provider: google; }
* { model: gemini-3-flash-preview; provider: google; }
"
]

View file

@ -7,12 +7,12 @@ digraph reference_template {
fallback_retry_target="debate_consolidate",
provenance_version="1",
model_stylesheet="
* { llm_model: DEFAULT_MODEL; llm_provider: DEFAULT_PROVIDER; }
.hard { llm_model: HARD_MODEL; llm_provider: HARD_PROVIDER; }
.verify { llm_model: VERIFY_MODEL; llm_provider: VERIFY_PROVIDER; }
.branch-a { llm_model: BRANCH_A_MODEL; llm_provider: BRANCH_A_PROVIDER; }
.branch-b { llm_model: BRANCH_B_MODEL; llm_provider: BRANCH_B_PROVIDER; }
.branch-c { llm_model: BRANCH_C_MODEL; llm_provider: BRANCH_C_PROVIDER; }
* { model: DEFAULT_MODEL; provider: DEFAULT_PROVIDER; }
.hard { model: HARD_MODEL; provider: HARD_PROVIDER; }
.verify { model: VERIFY_MODEL; provider: VERIFY_PROVIDER; }
.branch-a { model: BRANCH_A_MODEL; provider: BRANCH_A_PROVIDER; }
.branch-b { model: BRANCH_B_MODEL; provider: BRANCH_B_PROVIDER; }
.branch-c { model: BRANCH_C_MODEL; provider: BRANCH_C_PROVIDER; }
"
]

View file

@ -1,23 +1,23 @@
digraph Workflow {
graph [ label="Semantic Port Tracking Loop", goal="We want to intelligently track and port semantic changes from the upstream openai-agents-python repository to our Go implementation.\n\nWe want to fetch the latest commits from inspiration/openai-agents-python, analyze each new commit for semantic changes (not just syntax), and intelligently coalesce/merge those changes into our Go codebase while respecting Go idioms and our existing architecture.\n\nWe want to track the disposition of each upstream commit in semport/ledger.tsv with three states: 'new' (unprocessed), 'implemented' (changes made), or 'acknowledged' (reviewed but no changes needed).\n\nWe want to make sure we are surgical in this monorepo and follow pre-existing coding conventions and standards.", rankdir="LR", context_fidelity_default="truncate", context_thread_default="semport-tracking", default_max_retry="4" ];
FinalizeAndUpdateLedger [allow_partial="false", color="#94a3b8", fillcolor="white", fontname="Helvetica", fontsize="12", is_codergen="true", label="6) Finalize & update ledger", llm_model="gpt-5.2-codex", llm_prompt="**Finalize implementation and update ledger in one step.**\\n\\n1. Synthesize the port plan from .ai/semport_plan_sonnet.md and implementation results into .ai/semport_implementation_summary.md. List which upstream commits were processed, what changes were made (with file:line references), and the disposition ('implemented').\\n\\n2. Update the ledger using:\\n```\\npython3 semport/ledger.py update <shortsha> implemented\\npython3 semport/ledger.py sort\\n```\\n\\n3. Verify with `python3 semport/ledger.py stats` to see progress.\\n\\n4. **Commit all changes** (implementation + ledger update) with a clear message:\\n ```\\n git add -A\\n git commit -m \"semport: implement <shortsha> - <brief description of what was ported>\"\\n ```\\n Example: `git commit -m \"semport: implement a776d80 - nest handoff history by default\"`\\n\\nKeep our goal $goal in mind. Then loop back to process the next commit.", llm_provider="openai", margin="0.1,0.08", max_agent_turns="8", node_type="stack.observe", penwidth="1.2", reasoning_effort="high", shape="box", style="rounded,filled", timeout="1200"];
FinalizeAndUpdateLedger [allow_partial="false", color="#94a3b8", fillcolor="white", fontname="Helvetica", fontsize="12", is_codergen="true", label="6) Finalize & update ledger", model="gpt-5.2-codex", llm_prompt="**Finalize implementation and update ledger in one step.**\\n\\n1. Synthesize the port plan from .ai/semport_plan_sonnet.md and implementation results into .ai/semport_implementation_summary.md. List which upstream commits were processed, what changes were made (with file:line references), and the disposition ('implemented').\\n\\n2. Update the ledger using:\\n```\\npython3 semport/ledger.py update <shortsha> implemented\\npython3 semport/ledger.py sort\\n```\\n\\n3. Verify with `python3 semport/ledger.py stats` to see progress.\\n\\n4. **Commit all changes** (implementation + ledger update) with a clear message:\\n ```\\n git add -A\\n git commit -m \"semport: implement <shortsha> - <brief description of what was ported>\"\\n ```\\n Example: `git commit -m \"semport: implement a776d80 - nest handoff history by default\"`\\n\\nKeep our goal $goal in mind. Then loop back to process the next commit.", margin="0.1,0.08", max_agent_turns="8", node_type="stack.observe", penwidth="1.2", reasoning_effort="high", shape="box", style="rounded,filled", timeout="1200"];
TestValidate [allow_partial="true", color="#94a3b8", fillcolor="white", fontname="Helvetica", fontsize="12", is_codergen="true", label="5) Test/Validate changes", llm_model="gpt-5.2-codex", llm_prompt="Keeping our goal in mind: $goal. From repo root, validate that all ported changes work correctly. Run relevant tests (go test ./...), ensure compilation succeeds, and verify the ported functionality matches the upstream semantic intent (not necessarily syntax). Write validation results to .ai/semport_validation_report_NN.md. Use outcome=yes if all tests pass and changes are semantically correct; otherwise use outcome=retry with concrete failure details.", llm_provider="openai", margin="0.1,0.08", max_agent_turns="8", node_type="stack.steer", penwidth="1.2", reasoning_effort="high", shape="box", style="rounded,filled", timeout="1800"];
TestValidate [allow_partial="true", color="#94a3b8", fillcolor="white", fontname="Helvetica", fontsize="12", is_codergen="true", label="5) Test/Validate changes", model="gpt-5.2-codex", llm_prompt="Keeping our goal in mind: $goal. From repo root, validate that all ported changes work correctly. Run relevant tests (go test ./...), ensure compilation succeeds, and verify the ported functionality matches the upstream semantic intent (not necessarily syntax). Write validation results to .ai/semport_validation_report_NN.md. Use outcome=yes if all tests pass and changes are semantically correct; otherwise use outcome=retry with concrete failure details.", margin="0.1,0.08", max_agent_turns="8", node_type="stack.steer", penwidth="1.2", reasoning_effort="high", shape="box", style="rounded,filled", timeout="1800"];
AnalyzeFailureSonnet [allow_partial="false", color="#94a3b8", fillcolor="white", fontname="Helvetica", fontsize="12", is_codergen="true", label="6a) Analyze failure (sonnet)", llm_model="gpt-5.2-codex", llm_prompt="When tests or validation fail, inspect .ai/semport_validation_report_*.md, logs, diffs, and error messages. Write .ai/semport_failure_sonnet.md summarizing root causes, impacted files (with line references), and what needs to be fixed. Clearly note where failure artifacts are located. Keep our goal $goal in mind and be subjective.", llm_provider="openai", margin="0.1,0.08", max_agent_turns="8", node_type="stack.observe", penwidth="1.2", reasoning_effort="high", shape="box", style="rounded,filled", timeout="1200"];
AnalyzeFailureSonnet [allow_partial="false", color="#94a3b8", fillcolor="white", fontname="Helvetica", fontsize="12", is_codergen="true", label="6a) Analyze failure (sonnet)", model="gpt-5.2-codex", llm_prompt="When tests or validation fail, inspect .ai/semport_validation_report_*.md, logs, diffs, and error messages. Write .ai/semport_failure_sonnet.md summarizing root causes, impacted files (with line references), and what needs to be fixed. Clearly note where failure artifacts are located. Keep our goal $goal in mind and be subjective.", margin="0.1,0.08", max_agent_turns="8", node_type="stack.observe", penwidth="1.2", reasoning_effort="high", shape="box", style="rounded,filled", timeout="1200"];
FetchUpstreamSonnet [allow_partial="false", color="#94a3b8", fillcolor="white", fontname="Helvetica", fontsize="12", is_codergen="true", label="1) Fetch upstream & identify next commit (sonnet)", llm_model="gpt-5.2-codex", llm_prompt="Our goal is: $goal\\n\\n---\\n\\n**CRITICAL: Use semport/ledger.py for all ledger operations to ensure proper chronological ordering. Ledger entries MUST always use short git hashes (7 characters, e.g. from `git rev-parse --short`).**\\n\\n1. Run `python3 semport/ledger.py earliest` to get the chronologically earliest commit with disposition='new'\\n2. If a commit is found, write ONLY that single commit (shortsha, iso8601, and full commit message from git show) to .ai/semport_new_commits.md and use outcome=process\\n3. If NO 'new' commits exist:\\n a. Ensure inspiration/openai-agents-python exists (clone if missing)\\n b. Run git fetch && git pull in that directory\\n c. Use git log to find commits newer than the latest in ledger.tsv, capturing a short hash for each commit (e.g. `git log --format='%h %cI' ...`)\\n d. Add new commits using `python3 semport/ledger.py add <shortsha> <timestamp>`\\n e. Run `python3 semport/ledger.py sort` to maintain chronological order\\n f. Then run `python3 semport/ledger.py earliest` to get the first new commit\\n g. If a new commit is found after fetching, write it to .ai/semport_new_commits.md and use outcome=process\\n h. If still no new commits after fetching, write a completion report to .ai/semport_completion.md and use outcome=done\\n\\n**IMPORTANT**: You MUST end with exactly one of these outcomes:\\n- outcome=process (when there is a commit to process)\\n- outcome=done (when fully caught up with no new commits)", llm_provider="openai", margin="0.1,0.08", max_agent_turns="8", node_type="stack.steer", penwidth="1.2", reasoning_effort="high", shape="box", style="rounded,filled", timeout="1200"];
FetchUpstreamSonnet [allow_partial="false", color="#94a3b8", fillcolor="white", fontname="Helvetica", fontsize="12", is_codergen="true", label="1) Fetch upstream & identify next commit (sonnet)", model="gpt-5.2-codex", llm_prompt="Our goal is: $goal\\n\\n---\\n\\n**CRITICAL: Use semport/ledger.py for all ledger operations to ensure proper chronological ordering. Ledger entries MUST always use short git hashes (7 characters, e.g. from `git rev-parse --short`).**\\n\\n1. Run `python3 semport/ledger.py earliest` to get the chronologically earliest commit with disposition='new'\\n2. If a commit is found, write ONLY that single commit (shortsha, iso8601, and full commit message from git show) to .ai/semport_new_commits.md and use outcome=process\\n3. If NO 'new' commits exist:\\n a. Ensure inspiration/openai-agents-python exists (clone if missing)\\n b. Run git fetch && git pull in that directory\\n c. Use git log to find commits newer than the latest in ledger.tsv, capturing a short hash for each commit (e.g. `git log --format='%h %cI' ...`)\\n d. Add new commits using `python3 semport/ledger.py add <shortsha> <timestamp>`\\n e. Run `python3 semport/ledger.py sort` to maintain chronological order\\n f. Then run `python3 semport/ledger.py earliest` to get the first new commit\\n g. If a new commit is found after fetching, write it to .ai/semport_new_commits.md and use outcome=process\\n h. If still no new commits after fetching, write a completion report to .ai/semport_completion.md and use outcome=done\\n\\n**IMPORTANT**: You MUST end with exactly one of these outcomes:\\n- outcome=process (when there is a commit to process)\\n- outcome=done (when fully caught up with no new commits)", margin="0.1,0.08", max_agent_turns="8", node_type="stack.steer", penwidth="1.2", reasoning_effort="high", shape="box", style="rounded,filled", timeout="1200"];
ImplementPort [allow_partial="true", color="#94a3b8", fillcolor="white", fontname="Helvetica", fontsize="12", is_codergen="true", label="4) Implement port (gpt-5.1)", llm_model="gpt-5.2-codex", llm_prompt="Follow the port plan in .ai/semport_plan_finalized.md. For each upstream commit, port the semantic changes to the Go codebase. Focus on semantic equivalence, not literal translation. Use Go idioms, respect existing architecture, and reference specific files/line ranges. Log all changes and commands to .ai/semport_impl.log.", llm_provider="openai", margin="0.1,0.08", max_agent_turns="8", node_type="stack.observe", penwidth="1.2", reasoning_effort="high", shape="box", style="rounded,filled", timeout="2400"];
ImplementPort [allow_partial="true", color="#94a3b8", fillcolor="white", fontname="Helvetica", fontsize="12", is_codergen="true", label="4) Implement port (gpt-5.1)", model="gpt-5.2-codex", llm_prompt="Follow the port plan in .ai/semport_plan_finalized.md. For each upstream commit, port the semantic changes to the Go codebase. Focus on semantic equivalence, not literal translation. Use Go idioms, respect existing architecture, and reference specific files/line ranges. Log all changes and commands to .ai/semport_impl.log.", margin="0.1,0.08", max_agent_turns="8", node_type="stack.observe", penwidth="1.2", reasoning_effort="high", shape="box", style="rounded,filled", timeout="2400"];
FinalizePlanGPT [allow_partial="false", color="#94a3b8", fillcolor="white", fontname="Helvetica", fontsize="12", is_codergen="true", label="3) Finalize port plan (gpt-5.1)", llm_model="gpt-5.2-codex", llm_prompt="Keeping our goal $goal in mind. Perform a final editorial pass over .ai/semport_plan_sonnet.md and write .ai/semport_plan_finalized.md. Ensure each port task has concrete file:line references, clear acceptance criteria, and is directly executable. Remove vague language and ensure the plan is actionable.", llm_provider="openai", margin="0.1,0.08", max_agent_turns="8", node_type="stack.observe", penwidth="1.2", reasoning_effort="high", shape="box", style="rounded,filled", timeout="1200"];
FinalizePlanGPT [allow_partial="false", color="#94a3b8", fillcolor="white", fontname="Helvetica", fontsize="12", is_codergen="true", label="3) Finalize port plan (gpt-5.1)", model="gpt-5.2-codex", llm_prompt="Keeping our goal $goal in mind. Perform a final editorial pass over .ai/semport_plan_sonnet.md and write .ai/semport_plan_finalized.md. Ensure each port task has concrete file:line references, clear acceptance criteria, and is directly executable. Remove vague language and ensure the plan is actionable.", margin="0.1,0.08", max_agent_turns="8", node_type="stack.observe", penwidth="1.2", reasoning_effort="high", shape="box", style="rounded,filled", timeout="1200"];
Exit [allow_partial="false", color="#94a3b8", fillcolor="white", fontname="Helvetica", fontsize="12", is_codergen="true", label="Exit", llm_model="gpt-5.2-codex", llm_provider="openai", margin="0.1,0.08", max_agent_turns="8", node_type="exit", penwidth="1.2", reasoning_effort="high", shape="doublecircle", style="rounded,filled", timeout="1200"];
Exit [allow_partial="false", color="#94a3b8", fillcolor="white", fontname="Helvetica", fontsize="12", is_codergen="true", label="Exit", model="gpt-5.2-codex", margin="0.1,0.08", max_agent_turns="8", node_type="exit", penwidth="1.2", reasoning_effort="high", shape="doublecircle", style="rounded,filled", timeout="1200"];
Start [allow_partial="false", color="#94a3b8", fillcolor="white", fontname="Helvetica", fontsize="12", is_codergen="true", label="Start", llm_model="gpt-5.2-codex", llm_provider="openai", margin="0.1,0.08", max_agent_turns="8", node_type="start", penwidth="1.2", reasoning_effort="high", shape="circle", style="rounded,filled", timeout="1200"];
Start [allow_partial="false", color="#94a3b8", fillcolor="white", fontname="Helvetica", fontsize="12", is_codergen="true", label="Start", model="gpt-5.2-codex", margin="0.1,0.08", max_agent_turns="8", node_type="start", penwidth="1.2", reasoning_effort="high", shape="circle", style="rounded,filled", timeout="1200"];
AnalyzePlanSonnet [allow_partial="false", color="#94a3b8", fillcolor="white", fontname="Helvetica", fontsize="12", is_codergen="true", label="2) Analyze & plan port (sonnet)", llm_model="gpt-5.2-codex", llm_prompt="Keeping our goal $goal in mind. Read .ai/semport_new_commits.md which contains a SINGLE commit to process. Examine this one commit in inspiration/openai-agents-python using git show. Analyze the semantic changes (what functionality changed, not just syntax). Decide if this change is relevant to our Go implementation or if it's Python-specific/docs-only/not-applicable.\\n\\nWrite .ai/semport_plan_sonnet.md with sections: Commit Being Processed (shortsha and summary), Semantic Analysis (what changed functionally), DECISION (port or acknowledge with clear reasoning), Port Plan (if porting: concrete tasks with file:line references for Go code), and Disposition Recommendation.\\n\\n**If decision is to ACKNOWLEDGE (skip):**\\n1. Update the ledger: `python3 semport/ledger.py update <shortsha> acknowledged && python3 semport/ledger.py sort`\\n2. Verify with `python3 semport/ledger.py stats`\\n3. **Commit the ledger change** with a clear message summarizing why this commit was acknowledged:\\n ```\\n git add semport/ledger.tsv\\n git commit -m \"semport: acknowledge <shortsha> - <brief reason>\"\\n ```\\n Example: `git commit -m \"semport: acknowledge e3fe4f4 - docs typo fix, no Go changes needed\"`\\n4. Use outcome=skip to loop back for next commit\\n\\n**If decision is to PORT:**\\nUse outcome=port to proceed to implementation.", llm_provider="openai", margin="0.1,0.08", max_agent_turns="8", node_type="stack.steer", penwidth="1.2", reasoning_effort="high", shape="box", style="rounded,filled", timeout="1200"];
AnalyzePlanSonnet [allow_partial="false", color="#94a3b8", fillcolor="white", fontname="Helvetica", fontsize="12", is_codergen="true", label="2) Analyze & plan port (sonnet)", model="gpt-5.2-codex", llm_prompt="Keeping our goal $goal in mind. Read .ai/semport_new_commits.md which contains a SINGLE commit to process. Examine this one commit in inspiration/openai-agents-python using git show. Analyze the semantic changes (what functionality changed, not just syntax). Decide if this change is relevant to our Go implementation or if it's Python-specific/docs-only/not-applicable.\\n\\nWrite .ai/semport_plan_sonnet.md with sections: Commit Being Processed (shortsha and summary), Semantic Analysis (what changed functionally), DECISION (port or acknowledge with clear reasoning), Port Plan (if porting: concrete tasks with file:line references for Go code), and Disposition Recommendation.\\n\\n**If decision is to ACKNOWLEDGE (skip):**\\n1. Update the ledger: `python3 semport/ledger.py update <shortsha> acknowledged && python3 semport/ledger.py sort`\\n2. Verify with `python3 semport/ledger.py stats`\\n3. **Commit the ledger change** with a clear message summarizing why this commit was acknowledged:\\n ```\\n git add semport/ledger.tsv\\n git commit -m \"semport: acknowledge <shortsha> - <brief reason>\"\\n ```\\n Example: `git commit -m \"semport: acknowledge e3fe4f4 - docs typo fix, no Go changes needed\"`\\n4. Use outcome=skip to loop back for next commit\\n\\n**If decision is to PORT:**\\nUse outcome=port to proceed to implementation.", margin="0.1,0.08", max_agent_turns="8", node_type="stack.steer", penwidth="1.2", reasoning_effort="high", shape="box", style="rounded,filled", timeout="1200"];
FinalizeAndUpdateLedger -> FetchUpstreamSonnet [loop_restart="true"];
Start -> FetchUpstreamSonnet;

View file

@ -3,7 +3,7 @@ digraph Simple {
goal="Run tests and report",
rankdir=LR,
model_stylesheet="
* { llm_model: gpt-5.2-codex; llm_provider: openai; }
* { model: gpt-5.2-codex;}
"
]

View file

@ -6,7 +6,7 @@ digraph solitaire {
retry_target="impl_setup",
fallback_retry_target="impl_game_logic",
model_stylesheet="
* { llm_model: gpt-5.3-codex-spark; llm_provider: openai; }
* { model: gpt-5.3-codex-spark;}
"
]

View file

@ -1,9 +1,9 @@
digraph Example {
graph [
model_stylesheet="
* { llm_model: claude-haiku-4-5; }
.coding { llm_model: claude-sonnet-4-5; reasoning_effort: high; }
#review { llm_model: gemini-3.1-pro-preview; }
* { model: claude-haiku-4-5; }
.coding { model: claude-sonnet-4-5; reasoning_effort: high; }
#review { model: gemini-3.1-pro-preview; }
"
]

View file

@ -24,11 +24,11 @@ GitHub to Railway validated by code review only — no live deployment execution
retry_target="plan_fanout",
fallback_retry_target="plan_fanout",
model_stylesheet="
* { llm_model: claude-opus-4-6; llm_provider: anthropic; }
.hard { llm_model: gpt-5.3-codex; llm_provider: openai; }
.verify { llm_model: claude-opus-4-6; llm_provider: anthropic; }
.branch-a { llm_model: claude-opus-4-6; llm_provider: anthropic; }
.branch-b { llm_model: gemini-3-flash-preview; llm_provider: gemini; }
* { model: claude-opus-4-6; }
.hard { model: gpt-5.3-codex; }
.verify { model: claude-opus-4-6; }
.branch-a { model: claude-opus-4-6; }
.branch-b { model: gemini-3-flash-preview;}
"
]

View file

@ -5,11 +5,11 @@ digraph SpecDoDMultiModel {
retry_target="triage_merge",
default_fidelity="full",
model_stylesheet="
* { llm_model: claude-opus-4-6; llm_provider: anthropic; }
.opus { llm_model: claude-opus-4-6; llm_provider: anthropic; reasoning_effort: high; }
.gpt { llm_model: gpt-5.2; llm_provider: openai; reasoning_effort: high; }
.codex { llm_model: gpt-5.2-codex; llm_provider: openai; reasoning_effort: high; }
.merge { llm_model: claude-opus-4-6; llm_provider: anthropic; reasoning_effort: high; }
* { model: claude-opus-4-6;}
.opus { model: claude-opus-4-6;reasoning_effort: high; }
.gpt { model: gpt-5.2; reasoning_effort: high; }
.codex { model: gpt-5.2-codex; reasoning_effort: high; }
.merge { model: claude-opus-4-6; reasoning_effort: high; }
"
]

View file

@ -4,9 +4,9 @@ digraph SpecDoD {
default_max_retry="3",
retry_target="triage",
model_stylesheet="
* { llm_model: claude-opus-4-6; llm_provider: anthropic; }
* { model: claude-opus-4-6;}
.audit { reasoning_effort: high; }
.fix { llm_model: claude-opus-4-6; reasoning_effort: high; }
.fix { model: claude-opus-4-6; reasoning_effort: high; }
#final_audit { reasoning_effort: high; }
"
]

View file

@ -2,8 +2,8 @@ digraph NLSpecConformance {
graph [
goal="Implement a conformant system from a natural language specification",
model_stylesheet="
* { llm_model: claude-haiku-4-5; llm_provider: anthropic; }
.impl { llm_model: claude-sonnet-4-5; reasoning_effort: high; }
* { model: claude-haiku-4-5;}
.impl { model: claude-sonnet-4-5; reasoning_effort: high; }
"
]
rankdir=LR

View file

@ -4,9 +4,9 @@ digraph SemanticPort {
rankdir=LR,
default_max_retry=3,
model_stylesheet="
* { llm_model: claude-sonnet-4-5; llm_provider: anthropic; }
.hard { llm_model: claude-opus-4-6; llm_provider: anthropic; }
.analyze { llm_model: gemini-3.1-pro-preview; llm_provider: gemini; }
* { model: claude-sonnet-4-5;}
.hard { model: claude-opus-4-6; }
.analyze { model: gemini-3.1-pro-preview;}
"
]

View file

@ -2,9 +2,9 @@ digraph ImplementFeature {
graph [
goal="Implement a feature with tests and code review",
model_stylesheet="
* { llm_model: claude-haiku-4-5; llm_provider: anthropic; reasoning_effort: low; }
.coding { llm_model: claude-sonnet-4-5; llm_provider: anthropic; reasoning_effort: high; }
#review { llm_model: claude-sonnet-4-5; llm_provider: anthropic; reasoning_effort: high; }
* { model: claude-haiku-4-5;reasoning_effort: low; }
.coding { model: claude-sonnet-4-5;reasoning_effort: high; }
#review { model: claude-sonnet-4-5;reasoning_effort: high; }
"
]
rankdir=LR

View file

@ -2,11 +2,11 @@ digraph Ensemble {
graph [
goal="Get independent opinions from multiple providers, then synthesize",
model_stylesheet="
#opus { llm_model: claude-opus-4-6; llm_provider: anthropic; }
#gemini { llm_model: gemini-3.1-pro-preview; llm_provider: gemini; }
#codex { llm_model: gpt-5.3-codex; llm_provider: openai; }
#mercury { llm_model: mercury-2; llm_provider: inception; }
#synth { llm_model: claude-opus-4-6; llm_provider: anthropic; reasoning_effort: high; }
#opus { model: claude-opus-4-6; }
#gemini { model: gemini-3.1-pro-preview;}
#codex { model: gpt-5.3-codex; }
#mercury { model: mercury-2; provider: inception; }
#synth { model: claude-opus-4-6; reasoning_effort: high; }
"
]
rankdir=LR

View file

@ -2,9 +2,9 @@ digraph MultiModel {
graph [
goal="Build and review a utility function using multiple models",
model_stylesheet="
* { llm_model: claude-haiku-4-5; llm_provider: anthropic; reasoning_effort: low; }
.coding { llm_model: claude-sonnet-4-5; llm_provider: anthropic; reasoning_effort: high; }
#review { llm_model: claude-sonnet-4-5; llm_provider: anthropic; reasoning_effort: high; }
* { model: claude-haiku-4-5;reasoning_effort: low; }
.coding { model: claude-sonnet-4-5;reasoning_effort: high; }
#review { model: claude-sonnet-4-5;reasoning_effort: high; }
"
]
rankdir=LR

View file

@ -2,9 +2,9 @@ digraph Example {
graph [
goal="Build and review a utility function",
model_stylesheet="
* { llm_model: claude-haiku-4-5; llm_provider: anthropic; }
.coding { llm_model: claude-sonnet-4-5; reasoning_effort: high; }
#review { llm_model: gemini-3.1-pro-preview; llm_provider: gemini; }
* { model: claude-haiku-4-5;}
.coding { model: claude-sonnet-4-5; reasoning_effort: high; }
#review { model: gemini-3.1-pro-preview;}
"
]

View file

@ -2,9 +2,9 @@ digraph Styled {
graph [
goal="Build a styled pipeline",
model_stylesheet="
* { llm_model: claude-sonnet-4-5; llm_provider: anthropic; }
.code { llm_model: claude-opus-4-6; }
#critical_review { llm_model: gpt-5.2; llm_provider: openai; reasoning_effort: high; }
* { model: claude-sonnet-4-5;}
.code { model: claude-opus-4-6; }
#critical_review { model: gpt-5.2;reasoning_effort: high; }
"
]