Implement GET /pipelines/{id}/graph endpoint for SVG rendering

Stores DOT source in ManagedPipeline and pipes it through `dot -Tsvg`
on request. Returns image/svg+xml on success, 502 if graphviz is
unavailable, 404 if pipeline not found. Resolves spec gap #1.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-02-23 09:13:36 -05:00
parent 55b6000374
commit 5932b500e7
42 changed files with 1434 additions and 66 deletions

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
target
.env
attractor-run-*

View file

@ -79,31 +79,11 @@ impl BackoffConfig {
}
}
/// Predicate that determines whether an error is retryable.
/// Returns `true` if the error should be retried, `false` to fail immediately.
pub type ShouldRetryFn = std::sync::Arc<dyn Fn(&AttractorError) -> bool + Send + Sync>;
/// Retry policy for node execution.
#[derive(Clone)]
#[derive(Clone, Debug)]
pub struct RetryPolicy {
pub max_attempts: u32,
pub backoff: BackoffConfig,
pub should_retry: ShouldRetryFn,
}
impl std::fmt::Debug for RetryPolicy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RetryPolicy")
.field("max_attempts", &self.max_attempts)
.field("backoff", &self.backoff)
.field("should_retry", &"<fn>")
.finish()
}
}
/// Default should_retry predicate: retries transient errors only.
fn default_should_retry() -> ShouldRetryFn {
std::sync::Arc::new(|err| err.is_retryable())
}
impl RetryPolicy {
@ -113,7 +93,6 @@ impl RetryPolicy {
Self {
max_attempts: 1,
backoff: BackoffConfig::default(),
should_retry: default_should_retry(),
}
}
@ -128,7 +107,6 @@ impl RetryPolicy {
max_delay_ms: 60_000,
jitter: true,
},
should_retry: default_should_retry(),
}
}
@ -143,7 +121,6 @@ impl RetryPolicy {
max_delay_ms: 60_000,
jitter: true,
},
should_retry: default_should_retry(),
}
}
@ -158,7 +135,6 @@ impl RetryPolicy {
max_delay_ms: 60_000,
jitter: true,
},
should_retry: default_should_retry(),
}
}
@ -173,7 +149,6 @@ impl RetryPolicy {
max_delay_ms: 60_000,
jitter: true,
},
should_retry: default_should_retry(),
}
}
}
@ -200,7 +175,6 @@ fn build_retry_policy(node: &Node, graph: &Graph) -> RetryPolicy {
RetryPolicy {
max_attempts,
backoff: BackoffConfig::default(),
should_retry: default_should_retry(),
}
}
@ -585,7 +559,7 @@ impl PipelineEngine {
Ok(o) => o,
Err(e) => {
// Gap #7: Check should_retry predicate before retrying
if attempt < policy.max_attempts && (policy.should_retry)(&e) {
if attempt < policy.max_attempts && handler.should_retry(&e) {
let delay = policy.backoff.delay_for_attempt(attempt);
self.emitter.emit(&PipelineEvent::StageFailed {
name: node.label().to_string(),
@ -2026,25 +2000,6 @@ mod tests {
assert_eq!(resolve_thread_id(None, &node, &graph, None), None);
}
// --- default_should_retry tests ---
#[test]
fn default_should_retry_retries_transient_errors() {
let should_retry = default_should_retry();
assert!(should_retry(&AttractorError::Handler("timeout".to_string())));
assert!(should_retry(&AttractorError::Engine("transient".to_string())));
assert!(should_retry(&AttractorError::Io("connection reset".to_string())));
}
#[test]
fn default_should_retry_rejects_terminal_errors() {
let should_retry = default_should_retry();
assert!(!should_retry(&AttractorError::Parse("bad syntax".to_string())));
assert!(!should_retry(&AttractorError::Validation("invalid".to_string())));
assert!(!should_retry(&AttractorError::Stylesheet("bad rule".to_string())));
assert!(!should_retry(&AttractorError::Checkpoint("corrupt".to_string())));
}
// --- Gap #15: Manifest goal field test ---
#[tokio::test]

View file

@ -41,6 +41,7 @@ pub struct ApiQuestion {
/// Snapshot of a managed pipeline.
struct ManagedPipeline {
dot_source: String,
status: PipelineStatus,
error: Option<String>,
interviewer: Arc<WebInterviewer>,
@ -104,6 +105,7 @@ pub fn build_router(state: Arc<AppState>) -> Router {
.route("/pipelines/{id}/checkpoint", get(get_checkpoint))
.route("/pipelines/{id}/context", get(get_context))
.route("/pipelines/{id}/cancel", post(cancel_pipeline))
.route("/pipelines/{id}/graph", get(get_graph))
.with_state(state)
}
@ -160,6 +162,7 @@ async fn start_pipeline(
pipelines.insert(
pipeline_id.clone(),
ManagedPipeline {
dot_source: req.dot_source.clone(),
status: PipelineStatus::Running,
error: None,
interviewer: Arc::clone(&interviewer),
@ -358,6 +361,64 @@ async fn cancel_pipeline(
}
}
async fn get_graph(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let dot_source = {
let pipelines = state.pipelines.lock().expect("pipelines lock poisoned");
match pipelines.get(&id) {
Some(pipeline) => pipeline.dot_source.clone(),
None => return StatusCode::NOT_FOUND.into_response(),
}
};
let mut child = match tokio::process::Command::new("dot")
.arg("-Tsvg")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
{
Ok(child) => child,
Err(_) => {
return (
StatusCode::BAD_GATEWAY,
Json(serde_json::json!({"error": "graphviz dot command not available"})),
)
.into_response();
}
};
if let Some(mut stdin) = child.stdin.take() {
use tokio::io::AsyncWriteExt;
let _ = stdin.write_all(dot_source.as_bytes()).await;
// stdin is dropped here, closing the pipe
}
match child.wait_with_output().await {
Ok(output) if output.status.success() => (
StatusCode::OK,
[("content-type", "image/svg+xml")],
output.stdout,
)
.into_response(),
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
(
StatusCode::BAD_GATEWAY,
Json(serde_json::json!({"error": format!("dot failed: {stderr}")})),
)
.into_response()
}
Err(e) => (
StatusCode::BAD_GATEWAY,
Json(serde_json::json!({"error": format!("dot process error: {e}")})),
)
.into_response(),
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -739,4 +800,72 @@ mod tests {
let body = body_json(response.into_body()).await;
assert_eq!(body["status"].as_str().unwrap(), "completed");
}
#[tokio::test]
async fn get_graph_returns_svg() {
let state = create_app_state(test_registry);
let app = build_router(Arc::clone(&state));
// Start a pipeline
let req = Request::builder()
.method("POST")
.uri("/pipelines")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_string(&serde_json::json!({"dot_source": MINIMAL_DOT})).unwrap(),
))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
let pipeline_id = body["id"].as_str().unwrap().to_string();
// Request graph SVG
let req = Request::builder()
.method("GET")
.uri(format!("/pipelines/{pipeline_id}/graph"))
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
// If graphviz is not installed, we get 502 — skip assertion
if response.status() == StatusCode::BAD_GATEWAY {
return;
}
assert_eq!(response.status(), StatusCode::OK);
let content_type = response
.headers()
.get("content-type")
.expect("content-type header should be present")
.to_str()
.unwrap();
assert_eq!(content_type, "image/svg+xml");
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let svg = String::from_utf8_lossy(&bytes);
assert!(
svg.contains("<?xml") || svg.contains("<svg"),
"expected SVG content, got: {}",
&svg[..svg.len().min(200)]
);
}
#[tokio::test]
async fn get_graph_not_found() {
let app = test_app();
let req = Request::builder()
.method("GET")
.uri("/pipelines/nonexistent/graph")
.body(Body::empty())
.unwrap();
let response = app.oneshot(req).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
}

View file

@ -0,0 +1,378 @@
# coding-agent-loop Simplification Proposals
## 1. Duplicated Mock ExecutionEnvironment Implementations
### 1.1 Duplicate full-trait mocks in tools.rs tests
**File:** `crates/coding-agent-loop/src/tools.rs`, lines 419-644
**Current:** Three separate structs (`ReadFileEnv`, `WriteFileEnv`, `EditFileEnv`, `ShellCapturingEnv`) each implement the full `ExecutionEnvironment` trait with 13 methods, where only 1-2 methods differ from the defaults. Each mock is ~60 lines of boilerplate.
**Simplification:** Extract a `DelegatingMockEnv` that wraps `MockExecutionEnvironment` and allows overriding specific methods via closures or composition. Alternatively, use the existing `MockExecutionEnvironment` with additional optional fields (like `files` for read, `written` capture for write, `captured_timeout` for shell). The `MockExecutionEnvironment` in `test_support.rs` already supports `files` and `exec_result` -- extending it with `Mutex<Option<(String,String)>>` for write captures would eliminate `WriteFileEnv`, `EditFileEnv`, and `ShellCapturingEnv` entirely.
**Why:** ~220 lines of near-identical boilerplate across four structs. Every time the `ExecutionEnvironment` trait changes, four mocks must be updated in addition to the ones in `test_support.rs` and `openai.rs`.
### 1.2 Duplicate MockFileEnv in openai.rs tests
**File:** `crates/coding-agent-loop/src/profiles/openai.rs`, lines 436-516
**Current:** `MockFileEnv` reimplements the full `ExecutionEnvironment` trait to support `Mutex<HashMap>` for write/delete operations in apply_patch tests.
**Simplification:** Consolidate into a single shared mock in `test_support.rs` that supports mutable file operations. The existing `MockExecutionEnvironment` already has a `files: HashMap` field -- wrapping it in `Mutex` (or making a `MutableMockEnv` variant) would replace `MockFileEnv`.
**Why:** Another ~80 lines of duplicated trait implementation. This is the same problem as 1.1 but in a different file.
---
## 2. Duplicated Profile Boilerplate
### 2.1 linux_env() helper duplicated across 4 test modules
**Files:**
- `crates/coding-agent-loop/src/profiles/mod.rs`, lines 96-103
- `crates/coding-agent-loop/src/profiles/anthropic.rs`, lines 164-170
- `crates/coding-agent-loop/src/profiles/gemini.rs`, lines 215-220
- `crates/coding-agent-loop/src/profiles/openai.rs`, lines 425-432
**Current:** Each test module defines an identical `linux_env()` function that creates a `MockExecutionEnvironment` with `working_dir: "/home/test"`, `platform_str: "linux"`, `os_version_str: "Linux 6.1.0"`.
**Simplification:** Add `MockExecutionEnvironment::linux()` as a named constructor in `test_support.rs`.
**Why:** Four identical copies of the same 6-line function. If the mock struct changes, all four must be updated.
### 2.2 Repetitive ProviderProfile implementations across three profiles
**Files:**
- `crates/coding-agent-loop/src/profiles/anthropic.rs`, lines 41-57
- `crates/coding-agent-loop/src/profiles/gemini.rs`, lines 41-56
- `crates/coding-agent-loop/src/profiles/openai.rs`, lines 43-57
**Current:** `id()`, `model()`, `tool_registry()`, and `tool_registry_mut()` have identical implementations in all three profile structs. The only differences are the string returned by `id()`.
**Simplification:** Introduce a `BaseProfile` struct containing the common `model: String` and `registry: ToolRegistry` fields, then each profile delegates to it. This could be done with a macro or simple struct composition. For example:
```rust
struct BaseProfile {
id: &'static str,
model: String,
registry: ToolRegistry,
}
```
Each profile wraps `BaseProfile` and the four boilerplate methods delegate to it.
**Why:** Removes ~15 lines of identical code per profile (45 lines total) and makes it impossible for them to drift.
### 2.3 ParallelTestProfile vs TestProfile duplication
**File:** `crates/coding-agent-loop/src/test_support.rs`, lines 126-420
**Current:** `ParallelTestProfile` and `TestProfile` are nearly identical `ProviderProfile` implementations. The only difference is `supports_parallel_tool_calls` (false vs true) and an optional `context_window` field.
**Simplification:** Merge into a single `TestProfile` with configurable fields:
```rust
pub(crate) struct TestProfile {
pub registry: ToolRegistry,
pub parallel_tool_calls: bool,
pub context_window: usize,
}
```
The `with_tools` constructor defaults `parallel_tool_calls` to `false` and `context_window` to `200_000`. A `.with_parallel()` builder method or a `TestProfileBuilder` enables the parallel variant.
**Why:** Eliminates ~60 lines of duplicated trait implementation and makes the test intention clearer.
---
## 3. Redundant or Unused Code
### 3.1 History::new() is redundant with Default derive
**File:** `crates/coding-agent-loop/src/history.rs`, lines 10-12
**Current:** `History::new()` manually creates `Self { turns: Vec::new() }`, and `#[derive(Default)]` is on the struct.
**Simplification:** Remove the manual `new()` method entirely and use `History::default()` everywhere, or keep `new()` but implement it as `Self::default()`. Currently both exist and do the same thing.
**Why:** Two ways to do the same thing is confusing. Pick one.
### 3.2 EventEmitter::new() is redundant with Default impl
**File:** `crates/coding-agent-loop/src/event.rs`, lines 12-15 and 34-38
**Current:** `new()` and `default()` are both defined, `default()` just calls `new()`.
**Simplification:** This is a standard Rust pattern and is fine, but `#[must_use]` on `new()` but not on `Default::default()` is inconsistent. Consider removing the manual `Default` impl and adding `#[must_use]` consistently, or just keep one constructor.
**Why:** Minor, but reduces cognitive load.
---
## 4. Control Flow Simplifications
### 4.1 Simplify followup loop in process_input
**File:** `crates/coding-agent-loop/src/session.rs`, lines 193-208
**Current:**
```rust
loop {
self.run_single_input(&current_input).await?;
let next_followup = self.followup_queue.lock()
.expect("followup queue lock poisoned")
.pop_front();
match next_followup {
Some(followup) => { current_input = followup; }
None => break,
}
}
```
**Simplification:** Use `while let`:
```rust
self.run_single_input(&current_input).await?;
while let Some(followup) = self.followup_queue.lock()
.expect("followup queue lock poisoned")
.pop_front()
{
self.run_single_input(&followup).await?;
}
```
**Why:** Eliminates the mutable `current_input` variable and the `loop`/`match`/`break` pattern. The intent is clearer: process the initial input, then process followups until the queue is empty.
### 4.2 Simplify SubAgentManager::spawn success path
**File:** `crates/coding-agent-loop/src/subagent.rs`, lines 67-87
**Current:**
```rust
let task = tokio::spawn(async move {
let result = session.process_input(&task_prompt).await;
let turns = session.history().turns();
let turns_used = turns.len();
let last_text = turns.iter().rev().find_map(|t| {
if let Turn::Assistant { content, .. } = t {
Some(content.clone())
} else {
None
}
});
let success = result.is_ok();
if let Err(e) = result {
return Err(e);
}
Ok(SubAgentResult {
output: last_text.unwrap_or_default(),
success,
turns_used,
})
});
```
**Simplification:** The `success` variable is computed from `result.is_ok()`, then `result` is checked for `Err` immediately after. Since `success` is always `true` when reaching the `Ok` path:
```rust
let task = tokio::spawn(async move {
session.process_input(&task_prompt).await?;
let turns = session.history().turns();
let last_text = turns.iter().rev().find_map(|t| match t {
Turn::Assistant { content, .. } => Some(content.clone()),
_ => None,
});
Ok(SubAgentResult {
output: last_text.unwrap_or_default(),
success: true,
turns_used: turns.len(),
})
});
```
**Why:** Eliminates the unnecessary `success` variable (always `true` on the Ok path) and the redundant `if let Err(e) = result { return Err(e) }` pattern which is just `result?`.
### 4.3 validate_tool_args empty schema check is overly complex
**File:** `crates/coding-agent-loop/src/session.rs`, lines 654-658
**Current:**
```rust
if schema.is_null()
|| (schema.is_object() && schema.as_object().map_or(true, |o| o.is_empty()))
{
return Ok(());
}
```
**Simplification:** The `map_or(true, |o| o.is_empty())` is confusing because `as_object()` returns `None` when `is_object()` is false, but we already checked `is_object()`. So the `map_or(true, ...)` default of `true` is dead code. Simplify to:
```rust
if schema.is_null() {
return Ok(());
}
if let Some(obj) = schema.as_object() {
if obj.is_empty() {
return Ok(());
}
}
```
**Why:** The original combines null check and empty-object check with boolean operators in a way that requires careful reading. The separated version is immediately clear.
---
## 5. Structural / Architectural Simplifications
### 5.1 extract_signatures_from_assistant should use if-let instead of match
**File:** `crates/coding-agent-loop/src/loop_detection.rs`, lines 14-22
**Current:**
```rust
fn extract_signatures_from_assistant(turn: &Turn) -> Vec<u64> {
match turn {
Turn::Assistant { tool_calls, .. } => tool_calls
.iter()
.map(|tc| tool_call_signature(&tc.name, &tc.arguments))
.collect(),
_ => vec![],
}
}
```
**Simplification:** This function is only called in one place (line 35), where the result is immediately checked with `if !sigs.is_empty()`. The function could be inlined, but even if kept, consider using `if let`:
```rust
fn extract_signatures_from_assistant(turn: &Turn) -> Vec<u64> {
let Turn::Assistant { tool_calls, .. } = turn else {
return vec![];
};
tool_calls
.iter()
.map(|tc| tool_call_signature(&tc.name, &tc.arguments))
.collect()
}
```
**Why:** The `let-else` pattern makes the happy path less indented and immediately shows the function's purpose.
### 5.2 build_request constructs system prompt on every call
**File:** `crates/coding-agent-loop/src/session.rs`, lines 371-403
**Current:** `build_request()` calls `build_system_prompt()` every iteration of the tool-call loop (called from `run_single_input` inside the `loop` at line 237). The system prompt, project docs, and environment context do not change during a single input processing cycle.
**Simplification:** Compute the system prompt once at the start of `run_single_input` and pass it into `build_request`, or cache it as a field that's rebuilt only when `initialize()` or `process_input()` is called.
**Why:** Avoids redundant string allocation and concatenation on every LLM round-trip. For sessions with many tool rounds, this is significant wasted work.
### 5.3 estimate_token_count also rebuilds the system prompt
**File:** `crates/coding-agent-loop/src/session.rs`, lines 514-553
**Current:** `estimate_token_count()` calls `build_system_prompt()` again to get its length, duplicating the work already done in `build_request()` on the same iteration.
**Simplification:** If the system prompt is cached per proposal 5.2, `estimate_token_count` can read from the cache. Alternatively, pass the already-built system prompt length to `check_context_usage`.
**Why:** Double construction of the system prompt per LLM call is wasteful.
### 5.4 ProviderProfile trait returns owned Strings unnecessarily
**File:** `crates/coding-agent-loop/src/provider_profile.rs`, lines 20-21
**Current:** `fn id(&self) -> String` and `fn model(&self) -> String` return owned `String` values. Every call allocates.
**Simplification:** Return `&str` instead:
```rust
fn id(&self) -> &str;
fn model(&self) -> &str;
```
All implementations store the model as a `String` field and the id as a string literal, so returning `&str` is straightforward.
**Why:** Eliminates unnecessary heap allocation on every call. These methods are called frequently (every `build_request` call).
---
## 6. Naming and Clarity
### 6.1 EnvContext fields lack consistent naming
**File:** `crates/coding-agent-loop/src/profiles/mod.rs`, lines 13-21
**Current:** The struct has fields `date`, `model_name`, `knowledge_cutoff` alongside `git_branch`, `is_git_repo`. The non-git fields use varying naming conventions -- `date` is vague (what date?), `model_name` is redundant (just `model` would match `ProviderProfile::model()`).
**Simplification:** Rename `date` to `today` or `current_date`, and `model_name` to `model` for consistency with the trait method name.
**Why:** Clearer intent and consistent naming.
### 6.2 Turn::Steering and Turn::System are semantically close
**File:** `crates/coding-agent-loop/src/types.rs`, lines 5-29
**Current:** `Turn::System` and `Turn::Steering` both represent injected content. `System` maps to `Role::System` in the LLM message, while `Steering` maps to `Role::User`.
**Simplification:** No code change needed, but adding a brief doc comment to each variant clarifying the distinction would help. Currently there's no documentation explaining when to use which.
**Why:** A reader must trace through `convert_to_messages()` to understand the difference.
---
## 7. Tool Construction Boilerplate
### 7.1 Repeated parameter extraction pattern in tool executors
**Files:** `crates/coding-agent-loop/src/tools.rs` (lines 26-28, 61-66, 94-106, 168-175, 216-237, 263-269) and `crates/coding-agent-loop/src/subagent.rs` (lines 189-193, 237-244, 275-278, 312-315)
**Current:** Every tool executor manually extracts parameters with the same pattern:
```rust
let param = args.get("param")
.and_then(|v| v.as_str())
.ok_or_else(|| "Missing required parameter: param".to_string())?;
```
This pattern repeats ~15 times across the codebase with slight variations.
**Simplification:** Introduce a small helper:
```rust
fn required_str<'a>(args: &'a serde_json::Value, key: &str) -> Result<&'a str, String> {
args.get(key)
.and_then(|v| v.as_str())
.ok_or_else(|| format!("Missing required parameter: {key}"))
}
```
**Why:** Reduces boilerplate and ensures consistent error messages across all tools.
---
## 8. Dead or Near-Dead Code
### 8.1 build_env_context_block (no-context variant) has limited use
**File:** `crates/coding-agent-loop/src/profiles/mod.rs`, lines 50-53
**Current:** `build_env_context_block` wraps `build_env_context_block_with` with a default `EnvContext`. It's only used in one test.
**Simplification:** Inline the default at the one test call site. Or keep it as a convenience but make it `#[cfg(test)]`.
**Why:** Public API surface should be intentional. If this is only for tests, mark it as such.
### 8.2 SubAgent::id() and SubAgent::depth() are only used in tests
**File:** `crates/coding-agent-loop/src/subagent.rs`, lines 28-35
**Current:** `SubAgent` has `id()` and `depth()` accessor methods.
**Simplification:** Verify these are used outside tests. If they are only used in the test at line 348 (`manager.get(&agent_id).unwrap().depth()`), consider whether the `get()` method on the manager (and these accessors) serve a real purpose, or if they exist only to test internals.
**Why:** Exposing internal state for testing purposes adds API surface that must be maintained.
### 8.3 Unused import: std::sync::Arc in profiles/openai.rs
**File:** `crates/coding-agent-loop/src/profiles/openai.rs`, line 9
**Current:** `use std::sync::Arc;` is imported at the module level. It's used only in `make_apply_patch_tool()` for the executor closure.
**Simplification:** This is fine for production code. Just noting it's not used in the profile implementation itself, only in the private tool factory.
**Why:** Minor observation, no action needed.
---
## 9. Error Handling
### 9.1 Inconsistent error types: String vs AgentError
**Files:** Throughout the crate
**Current:** The `ExecutionEnvironment` trait uses `Result<T, String>`, tool executors return `Result<String, String>`, while `Session` methods return `Result<(), AgentError>`. The `SubAgentManager` also uses `Result<T, String>`.
**Simplification:** Consider using `AgentError` (or a dedicated `ToolError`) throughout instead of raw `String` errors. At minimum, `SubAgentManager` methods that return user-facing errors should use a typed error.
**Why:** `String` errors lose the ability to match on error variants, making programmatic error handling impossible. This is a larger refactor but would significantly improve the API.
### 9.2 Lock poisoning panics could be handled
**Files:** `crates/coding-agent-loop/src/session.rs` (lines 143, 149, 199, 354) and `crates/coding-agent-loop/src/subagent.rs` (line 112)
**Current:** `.expect("...lock poisoned")` is used on every mutex lock.
**Simplification:** This is actually fine for most use cases -- a poisoned lock indicates a panic occurred while the lock was held, which is a serious bug. No change recommended, but documenting the deliberate choice would help.
**Why:** No action needed. This is the standard Rust approach.
---
## 10. Test Organization
### 10.1 ProviderTestProfile in provider_profile.rs duplicates TestProfile
**File:** `crates/coding-agent-loop/src/provider_profile.rs`, lines 86-139
**Current:** A `ProviderTestProfile` struct is defined with a custom `build_system_prompt` that includes platform info. It exists alongside `TestProfile` in `test_support.rs`.
**Simplification:** If the test-specific behavior (including platform in prompt) matters, add a flag to `TestProfile` to enable it. If not, use `TestProfile` directly.
**Why:** The comment on line 85 says "uses distinct id/model and a custom build_system_prompt", but examining the tests, the custom prompt is only checked for containing "linux" and "1" (docs count). These tests could use the shared `TestProfile` with a more flexible assertion.
### 10.2 Session tests define CapturingProvider inline
**File:** `crates/coding-agent-loop/src/session.rs`, lines 1225-1254 and 1411-1432
**Current:** Two separate inline `CapturingProvider` structs are defined within test functions in the same module. Both capture request data for assertion.
**Simplification:** Extract a single `CapturingLlmProvider` into `test_support.rs` that captures the full request, then tests can assert on whichever field they need. The first one (line 1225) captures `reasoning_effort`, the second (line 1411) captures messages. A single mock that captures the full `Request` would serve both.
**Why:** Two near-identical mock providers in the same test module is unnecessary duplication.
---
## Summary
| Category | Count | Estimated Lines Saved |
|----------|-------|-----------------------|
| Duplicated mock implementations | 3 | ~350 |
| Duplicated profile boilerplate | 3 | ~120 |
| Redundant code | 2 | ~15 |
| Control flow simplifications | 3 | ~20 |
| Structural improvements | 4 | ~50 (plus perf gains) |
| Naming and clarity | 2 | 0 (documentation) |
| Tool construction boilerplate | 1 | ~30 |
| Dead or near-dead code | 3 | ~20 |
| Error handling | 2 | 0 (design decision) |
| Test organization | 2 | ~80 |
| **Total** | **25** | **~685** |
The highest-impact simplifications are:
1. **Consolidating mock ExecutionEnvironment implementations** (proposals 1.1, 1.2) -- eliminates the most duplicated code
2. **Merging TestProfile and ParallelTestProfile** (proposal 2.3) -- reduces test infrastructure
3. **Caching system prompt per input cycle** (proposals 5.2, 5.3) -- both a clarity and performance win
4. **Returning &str from ProviderProfile::id() and ::model()** (proposal 5.4) -- cleaner API
5. **Helper for parameter extraction** (proposal 7.1) -- reduces the most pervasive boilerplate pattern

View file

@ -53,11 +53,9 @@ The spec (lines 1196-1206) defines 5-step thread resolution for `full` fidelity.
The spec (line 1165) says: "If the previous node used `full` fidelity, degrade to `summary:high` for the first resumed node." A grep for `fidelity.*degrad|summary.high.*resume` across all sources returns zero matches. The resume code at `engine.rs:591-608` performs no fidelity degradation.
### 7. Retry Policy `should_retry` — too coarse
### 7. ~~Retry Policy `should_retry` — too coarse~~ — RESOLVED
**CONFIRMED**
`default_should_retry()` at `engine.rs:100-103`: `Arc::new(|_| true)` — retries ALL errors. The spec (line 556) requires: retry on 429/5xx/network errors, no retry on 401/403/400/validation/config errors. `build_retry_policy` at lines 178-189 always uses the default. `AttractorError` has no `is_retryable()` method.
`AttractorError::is_retryable()` classifies errors by variant (`Handler`/`Engine`/`Io` = retryable; `Parse`/`Validation`/`Stylesheet`/`Checkpoint`/`Cancelled` = terminal). The `Handler` trait now has a `should_retry(&self, err: &AttractorError) -> bool` method (default delegates to `is_retryable()`). The engine's `execute_with_retry` calls the handler method directly. Handlers can override to customize retry behavior.
### 8. Direction type not validated
@ -107,11 +105,9 @@ The spec (line 1693): "non-zero means skip the tool call." `codergen.rs:98-103`
The spec (line 1260): "manifest.json -- Pipeline metadata (name, goal, start time)." `engine.rs:217-233` `write_manifest()` writes `pipeline_name`, `start_time`, `node_count`, `edge_count` — no `goal` field despite `graph.goal()` being available. Integration test at lines 1527-1532 confirms the four fields without `goal`.
### 16. Error categories — no retryable/terminal classification
### 16. ~~Error categories — no retryable/terminal classification~~ — RESOLVED
**CONFIRMED**
The spec (Appendix D, lines 2115-2123) defines Retryable, Terminal, and Pipeline error categories. `error.rs:1-33` has 7 variants (`Parse`, `Validation`, `Engine`, `Handler`, `Checkpoint`, `Stylesheet`, `Io`) with no retryability metadata, no `is_retryable()` method, no `ErrorCategory` enum.
`AttractorError::is_retryable()` at `error.rs:38` classifies variants as retryable (`Handler`, `Engine`, `Io`) or terminal (`Parse`, `Validation`, `Stylesheet`, `Checkpoint`, `Cancelled`). The `Handler` trait's `should_retry` default impl delegates to this method. No `ErrorCategory` enum, but the classification is functionally equivalent.
### 17. Spec self-contradicts on `default_max_retry`
@ -169,7 +165,7 @@ Minor sub-gap: the engine-level schema uses key `"status"` while the spec's Appe
| 4 | Thread ID resolution 1/5 steps | CONFIRMED | Moderate |
| 5 | Checkpoint retry counters not persisted | CONFIRMED | Moderate |
| 6 | Checkpoint fidelity degradation missing | CONFIRMED | Moderate |
| 7 | should_retry retries all errors | CONFIRMED | Moderate |
| 7 | ~~should_retry retries all errors~~ | RESOLVED | ~~Moderate~~ |
| 8 | Direction values not validated | CONFIRMED | Low |
| 9 | Stylesheet lint brace-balance only | CONFIRMED | Low |
| 10 | Undocumented Shape selector | CONFIRMED | Low |
@ -178,7 +174,7 @@ Minor sub-gap: the engine-level schema uses key `"status"` while the spec's Appe
| 13 | Pre-hook fail instead of skip | CONFIRMED | Low |
| 14 | No parallel integration test | CONFIRMED | Low |
| 15 | Manifest missing goal field | CONFIRMED | Low |
| 16 | No error retryable/terminal classification | CONFIRMED | Moderate |
| 16 | ~~No error retryable/terminal classification~~ | RESOLVED | ~~Moderate~~ |
| 17 | Spec contradicts itself on default_max_retry | CONFIRMED | Low (spec bug) |
| P1 | Checkpoint resume incomplete | PARTIAL | Moderate |
| R1 | Missing variable handling | REFUTED | — |

View file

@ -4,7 +4,7 @@ Comparison of the implementation in `crates/attractor/` against `docs/specs/attr
## Summary
The core pipeline engine, DOT parsing, edge selection, condition evaluation, retry logic, checkpoint/resume, validation, and all 10 handler types are implemented. The HTTP server with SSE is implemented. Context fidelity preamble synthesis, thread ID plumbing to backends, engine cancellation, recording/replay, and preset retry policies are all implemented. The remaining gaps are **SVG graph rendering** and **no DOT-level mechanism for custom retry predicates**.
The core pipeline engine, DOT parsing, edge selection, condition evaluation, retry logic, checkpoint/resume, validation, and all 10 handler types are implemented. The HTTP server with SSE is implemented. Context fidelity preamble synthesis, thread ID plumbing to backends, engine cancellation, recording/replay, and preset retry policies are all implemented. The `should_retry` predicate is customizable via the `Handler` trait. SVG graph rendering via `GET /pipelines/{id}/graph` is implemented. **No remaining gaps.**
---
@ -19,6 +19,7 @@ The core pipeline engine, DOT parsing, edge selection, condition evaluation, ret
| 3.4 | Goal gate enforcement with retry target fallback chain | Done |
| 3.5-3.6 | Retry logic with backoff, jitter, preset policies, allow_partial | Done |
| 3.6 | Preset retry policies selectable by name from DOT (`retry_policy` attr) | Done |
| 3.6 | `should_retry` predicate customizable via `Handler` trait method | Done |
| 3.7 | Failure routing (fail edge, retry_target, fallback, termination) | Done |
| 3.8 | Single-threaded traversal with parallel handler isolation | Done |
| 4.1-4.2 | Handler interface and registry (explicit type > shape > default) | Done |
@ -64,17 +65,13 @@ The core pipeline engine, DOT parsing, edge selection, condition evaluation, ret
## Gaps
### 1. GET /pipelines/{id}/graph (SVG Rendering) (Spec 9.5)
### ~~1. GET /pipelines/{id}/graph (SVG Rendering) (Spec 9.5)~~ — RESOLVED
**Status: Not implemented**
The endpoint is implemented. The DOT source is stored in `ManagedPipeline` and piped through `dot -Tsvg` on request, returning `image/svg+xml`. Returns 502 if graphviz is unavailable, 404 if pipeline not found.
The spec lists `GET /pipelines/{id}/graph` to return a rendered graph visualization (SVG). The HTTP server does not implement this endpoint. No graphviz dependency exists, and the original DOT source is not stored in `ManagedPipeline` after parsing.
### ~~2. `should_retry` Predicate Customization (Spec 3.6)~~ — RESOLVED
### 2. `should_retry` Predicate Customization (Spec 3.6)
**Status: Default predicate only**
The `RetryPolicy` struct has a `should_retry: ShouldRetryFn` field and the Rust API supports custom predicates. However, all preset policies (`none`, `standard`, `aggressive`, `linear`, `patient`) and `build_retry_policy()` use the same `default_should_retry()` predicate. There's no DOT-level mechanism for per-node retry predicate customization. The spec defines `should_retry` in the `RetryPolicy` struct but only describes a default predicate — it's unclear whether per-node customization is required.
Handlers can now override `should_retry(&self, err: &AttractorError) -> bool` on the `Handler` trait. The default impl delegates to `err.is_retryable()`. The engine's `execute_with_retry` calls the handler method directly. The `ShouldRetryFn` type and `RetryPolicy.should_retry` field have been removed. There's no DOT-level mechanism for per-node retry predicate customization, which matches the spec (no DOT syntax defined for this).
Note: the spec's default predicate description references HTTP status codes (429, 5xx, 401, 403, 400) but the implementation classifies retryability by `AttractorError` variant (`Handler`/`Engine`/`Io` = retryable). Reasonable for Rust but not a 1:1 mapping.

311
docs/designs/cli-design.md Normal file
View file

@ -0,0 +1,311 @@
# CLI Design
## Binary
The `attractor` crate (`crates/attractor/`) gains a `[[bin]]` target named `attractor` alongside its existing library. No separate CLI crate.
New files: `src/main.rs`, `src/cli/mod.rs`, `src/cli/run.rs`, `src/cli/validate.rs`.
New dependencies added to the `attractor` crate: `clap`, `anyhow`, `dotenvy`, `chrono` (all already in workspace).
## Command structure
```
attractor run [OPTIONS] <pipeline.dot>
attractor validate [OPTIONS] <pipeline.dot>
attractor --version
attractor --help
```
## `attractor run`
```
Usage: attractor run [OPTIONS] <PIPELINE>
Arguments:
<PIPELINE> Path to a .dot pipeline file
Options:
--logs-dir <DIR> Log/artifact directory [default: ./attractor-run-<YYYYMMDD-HHMMSS>]
--dry-run Execute with a simulated LLM backend (no API calls)
--auto-approve Auto-approve all human-in-the-loop gates
--resume <CHECKPOINT> Resume from a checkpoint JSON file
--model <MODEL> Override default LLM model for all nodes
--provider <PROVIDER> Override default LLM provider (anthropic, openai, gemini)
-v, --verbose... Verbosity level (-v summary, -vv full details)
-h, --help Show help
```
## `attractor validate`
```
Usage: attractor validate [OPTIONS] <PIPELINE>
Arguments:
<PIPELINE> Path to a .dot pipeline file
Options:
-h, --help Show help
```
Parse, transform, and validate only. Prints errors and warnings, exits 0 if no errors.
### Environment variables
| Variable | Purpose |
|----------|---------|
| `ANTHROPIC_API_KEY` | Anthropic API key |
| `OPENAI_API_KEY` | OpenAI API key |
| `GEMINI_API_KEY` | Google Gemini API key |
Loaded via `dotenvy::dotenv().ok()` at startup (`.env` file support, non-fatal if missing).
## Execution flow
### `attractor validate`
```
validate_command(args):
1. Read .dot file
2. PipelineBuilder::new().prepare(&source) -> (Graph, Vec<Diagnostic>)
3. Print "Parsed pipeline: {name} ({n} nodes, {m} edges)"
4. Print errors (Severity::Error) to stderr
5. Print warnings (Severity::Warning) to stderr
6. If errors -> exit 1
7. Print "Validation: OK", exit 0
```
### `attractor run`
```
main()
1. dotenvy::dotenv()
2. parse CLI args (clap)
3. dispatch to run_command(args) or validate_command(args)
run_command(args):
1. Read .dot file from args.pipeline
2. Prepare pipeline
PipelineBuilder::new().prepare(&source)
-> (Graph, Vec<Diagnostic>)
3. Print parsed summary to stdout
"Parsed pipeline: {name} ({n} nodes, {m} edges)"
"Goal: {goal}"
4. Check for validation errors (Severity::Error)
If any -> print to stderr, exit 1
5. Print warnings (Severity::Warning) to stderr
6. Create logs directory
args.logs_dir or generate ./attractor-run-<YYYYMMDD-HHMMSS>
fs::create_dir_all()
7. Build LLM client
If --dry-run -> skip (no backend set on engine, handlers use dry-run stubs)
Else -> unified_llm::Client::from_env()
If no providers configured -> warn to stderr, continue as dry-run
8. Resolve model/provider
CLI --model/--provider override > graph-level defaults > auto-detect from available providers
9. Build CodergenBackend
Wire up LLM client + coding-agent-loop with ExecutionEnv rooted at cwd
10. Build PipelineEngine
Create HandlerRegistry, register built-in handlers
Set backend on codergen handler
Create EventEmitter
If -v or -vv -> attach stderr logging callback (level determines format)
Set interviewer:
--auto-approve -> AutoApproveInterviewer
else -> ConsoleInterviewer
11. Execute or resume
If --resume -> load Checkpoint from file, engine.run_from_checkpoint()
Else -> engine.run()
12. Print result
"=== Pipeline Result ==="
"Status: {SUCCESS|FAIL|PARTIAL_SUCCESS|...}"
"Notes: ..." (if present)
"Failure: ..." (if present)
"Logs: {logs_dir}"
13. Exit code
0 if SUCCESS or PARTIAL_SUCCESS
1 otherwise
```
## Clap types
```rust
#[derive(Parser)]
#[command(name = "attractor", version, about = "DOT-based pipeline runner for AI workflows")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Launch a pipeline from a .dot file
Run(RunArgs),
/// Parse and validate a pipeline without executing
Validate(ValidateArgs),
}
#[derive(Args)]
struct RunArgs {
/// Path to the .dot pipeline file
pipeline: PathBuf,
/// Log/artifact directory
#[arg(long)]
logs_dir: Option<PathBuf>,
/// Execute with simulated LLM backend
#[arg(long)]
dry_run: bool,
/// Auto-approve all human gates
#[arg(long)]
auto_approve: bool,
/// Resume from a checkpoint file
#[arg(long)]
resume: Option<PathBuf>,
/// Override default LLM model
#[arg(long)]
model: Option<String>,
/// Override default LLM provider
#[arg(long)]
provider: Option<String>,
/// Verbosity level (-v summary, -vv full details)
#[arg(short, long, action = clap::ArgAction::Count)]
verbose: u8,
}
#[derive(Args)]
struct ValidateArgs {
/// Path to the .dot pipeline file
pipeline: PathBuf,
}
```
## Verbosity levels
The `-v` flag uses `clap::ArgAction::Count` to support two levels. Both levels write to stderr.
### `-v` (verbose) -- one-line summary per event
Prints the event kind and key identifying fields on a single line. Matches the style of the C reference implementation (kind + node + attempt + optional data), extended with the richer fields available in the Rust `PipelineEvent` enum.
```
[PIPELINE_STARTED] name=Deploy id=abc-123
[STAGE_STARTED] name=build index=1
[STAGE_COMPLETED] name=build index=1 duration=4523ms
[STAGE_RETRYING] name=test index=2 attempt=2 delay=200ms
[STAGE_FAILED] name=test index=2 error="assertion failed" will_retry=true
[PARALLEL_STARTED] branches=3
[PARALLEL_BRANCH_STARTED] branch=lint index=0
[PARALLEL_BRANCH_COMPLETED] branch=lint index=0 duration=1200ms success=true
[PARALLEL_COMPLETED] duration=5100ms succeeded=2 failed=1
[INTERVIEW_STARTED] stage=review_gate question="Approve changes?"
[INTERVIEW_COMPLETED] question="Approve changes?" answer="Approve" duration=12340ms
[INTERVIEW_TIMEOUT] stage=review_gate duration=30000ms
[CHECKPOINT_SAVED] node=test
[PIPELINE_COMPLETED] duration=45230ms artifacts=3
[PIPELINE_FAILED] error="goal gate unsatisfied" duration=32100ms
```
No JSON, no multi-line output. Suitable for tailing in a terminal alongside normal output.
### `-vv` (very verbose) -- full event details
Prints every event as nicely formatted, multi-line output with all fields. Uses indented key-value pairs under a header line.
```
── STAGE_COMPLETED ──────────────────────────
name: build
index: 1
duration_ms: 4523
── STAGE_FAILED ─────────────────────────────
name: test
index: 2
error: assertion failed
will_retry: true
── PARALLEL_COMPLETED ───────────────────────
duration_ms: 5100
success_count: 2
failure_count: 1
── INTERVIEW_COMPLETED ──────────────────────
question: Approve changes?
answer: Approve
duration_ms: 12340
```
Every field on the event variant is printed. This is the "dump everything" mode for debugging pipeline behavior.
## Output conventions
- **stdout**: Pipeline summary, result status.
- **stderr**: Warnings, verbose events, progress messages, LLM provider warnings.
## Error handling
| Condition | Behavior |
|-----------|----------|
| .dot file not found / unreadable | Print error to stderr, exit 1 |
| Parse failure | Print parse error to stderr, exit 1 |
| Validation errors | Print each error to stderr, exit 1 |
| No LLM providers (without --dry-run) | Warn to stderr, continue in dry-run mode |
| Engine execution error | Print error to stderr, exit 1 |
| Pipeline completes with FAIL | Print result, exit 1 |
| Pipeline completes with SUCCESS/PARTIAL_SUCCESS | Print result, exit 0 |
All errors go through `anyhow` at the CLI boundary. The `main` function catches the result and formats it.
## File layout
```
crates/attractor/
Cargo.toml -- add [[bin]], clap, anyhow, dotenvy, chrono deps
src/
main.rs -- entry point: dotenvy, clap parse, dispatch
cli/
mod.rs -- re-exports
run.rs -- run_command()
validate.rs -- validate_command()
lib.rs -- existing library (unchanged)
...
```
## Cargo.toml changes
Add to `crates/attractor/Cargo.toml`:
```toml
[[bin]]
name = "attractor"
path = "src/main.rs"
[dependencies]
# ... existing deps ...
clap.workspace = true
anyhow.workspace = true
dotenvy.workspace = true
chrono.workspace = true
```
Add `anyhow` to workspace root `Cargo.toml` `[workspace.dependencies]`:
```toml
anyhow = "1"
```
(Already present in workspace deps.)
## What this design does NOT cover
- Web/SSE server mode (`attractor serve`) -- separate subcommand later
- JSON/structured output mode (`--output json`) -- can be added later
- Signal handling (Ctrl-C graceful shutdown) -- defer to follow-up
- Config file support (e.g., `~/.config/attractor/config.toml`) -- not needed yet

156
pipelines/factory/README.md Normal file
View file

@ -0,0 +1,156 @@
# Software Factory Pipelines
Six Attractor pipelines that implement a spec-driven software factory. Markdown docs are the source of truth — code is a derived artifact.
## Document Structure
The factory operates on three layers:
1. **Product** — what to build (requirements, acceptance criteria)
2. **Architecture** — how to build it (blueprints, diagrams, stack decisions)
3. **Code** — the implementation (derived from the first two)
The first two layers are markdown docs. The third is generated from them.
```
docs-internal/
product/ Layer 1: WHAT to build
business-problem.md
personas.md
product-description.md
current-state.md
success-metrics.md
technical-requirements.md
features/
NNN-feature.md per-feature requirements + acceptance criteria
architecture/ Layer 2: HOW to build it
foundation-blueprints/
backend.md stack-wide backend decisions
data-layer.md stack-wide data decisions
frontend.md stack-wide frontend decisions
system-diagrams/
entity-relationship-diagram.md
sequence-diagram.md
system-architecture.md
features/
NNN-feature.md per-feature blueprint (data model, API, UI)
src/ Layer 3: the implementation (generated)
```
## Pipelines
### 1. Seed
Bootstrap product context from raw inputs. Run once per product.
**Input:** Conversations, notes, designs, existing code — anything unstructured.
**Output:** Filled `product/` docs.
![Seed pipeline](seed.png)
[seed.dot](seed.dot) | Prompts: [ingest](prompts/seed/ingest.md), [draft](prompts/seed/draft.md)
---
### 2. Specify
Define what a feature does in implementation-agnostic terms. Run once per feature.
**Input:** Product context docs + a feature idea (passed as `goal`).
**Output:** `product/features/NNN-feature.md` with user stories and acceptance criteria.
![Specify pipeline](specify.png)
[specify.dot](specify.dot) | Prompts: [clarify](prompts/specify/clarify.md), [require](prompts/specify/require.md)
---
### 3. Architect
Translate approved requirements into a technical blueprint. Run once per feature, after Specify.
**Input:** `product/features/NNN-feature.md` + foundation blueprints + codebase.
**Output:** `architecture/features/NNN-feature.md` + updated system diagrams.
![Architect pipeline](architect.png)
[architect.dot](architect.dot) | Prompts: [blueprint](prompts/architect/blueprint.md), [diagram](prompts/architect/diagram.md)
---
### 4. Implement
Generate working code from a feature blueprint. Run once per feature, after Architect. No human gate — the blueprint is the approved plan, and the validate/fix loop converges autonomously.
**Input:** Feature blueprint + foundation blueprints + codebase.
**Output:** Committed code (migrations, models, API, UI, tests).
![Implement pipeline](implement.png)
[implement.dot](implement.dot) | Prompts: [plan](prompts/implement/plan.md), [implement](prompts/implement/implement.md), [validate](prompts/implement/validate.md), [fix](prompts/implement/fix.md)
All nodes share `fidelity="full"` with `thread_id="impl"` so the agent maintains full context across the loop. `goal_gate=true` on Validate ensures the pipeline cannot exit until all acceptance criteria pass.
---
### 5. Sync
Detect and resolve drift between the three layers. Run continuously (after merges, on schedule, or on demand).
**Input:** A change to any layer (code, product docs, or architecture docs).
**Output:** Updated docs or code that restore alignment.
![Sync pipeline](sync.png)
[sync.dot](sync.dot) | Prompts: [detect](prompts/sync/detect.md), [propose](prompts/sync/propose.md), [apply](prompts/sync/apply.md)
Short-circuits to Exit when no drift is detected, avoiding unnecessary human interaction.
---
### 6. Expand
Evolve the product by adding, modifying, or removing features. Run as needed.
**Input:** Human intent (passed as `goal`), e.g. "split feature X" or "add Y".
**Output:** Updated document tree, ready for Implement.
![Expand pipeline](expand.png)
[expand.dot](expand.dot) | Prompts: [propose](prompts/expand/propose.md), [execute](prompts/expand/execute.md)
---
## How They Compose
```
Seed ──→ Specify ──→ Architect ──→ Implement
(1x) (1x per (1x per (1x per
feature) feature) feature)
↕ ↕ ↕
Sync ←────── Sync ←────── Sync
(continuous)
Expand
(as needed)
```
- **Seed** runs once to bootstrap product context
- **Specify** and **Architect** run once per feature to produce requirements and blueprints
- **Implement** runs once per feature to produce code
- **Sync** runs continuously to keep all three layers aligned
- **Expand** runs when the product evolves (new features, splits, removals)
The docs are always the source of truth. If you delete all code and run Implement for every feature, you get the system back.
## Prompt References
DOT files reference prompts with `@`-style paths relative to the DOT file:
```dot
plan [label="Plan Implementation", prompt="@prompts/implement/plan.md"]
```
The `@` prefix tells the engine to read the file contents as the prompt. Prompts support `$goal` variable expansion.

View file

@ -0,0 +1,18 @@
digraph architect {
graph [
goal="",
label="Architect"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
blueprint [label="Write Blueprint", prompt="@prompts/architect/blueprint.md", reasoning_effort="high"]
diagram [label="Update Diagrams", prompt="@prompts/architect/diagram.md"]
approve [shape=hexagon, label="Approve Architecture"]
start -> blueprint -> diagram -> approve
approve -> exit [label="[A] Accept"]
approve -> blueprint [label="[R] Revise"]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View file

@ -0,0 +1,19 @@
digraph expand {
graph [
goal="",
label="Expand"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
propose [label="Propose Changes", prompt="@prompts/expand/propose.md", reasoning_effort="high"]
approve [shape=hexagon, label="Approve Changes"]
execute [label="Execute Changes", prompt="@prompts/expand/execute.md"]
start -> propose -> approve
approve -> execute [label="[A] Accept"]
approve -> propose [label="[R] Revise"]
execute -> exit
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View file

@ -0,0 +1,33 @@
digraph implement {
graph [
goal="",
label="Implement"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
strategy [shape=hexagon, label="Choose decomposition strategy:"]
subgraph cluster_impl {
label="Implementation Loop"
node [fidelity="full", thread_id="impl"]
plan [label="Plan Implementation", prompt="@prompts/implement/plan.md", reasoning_effort="high"]
implement [label="Implement", prompt="@prompts/implement/implement.md"]
review [label="Review", prompt="@prompts/implement/review.md"]
validate [label="Validate", prompt="@prompts/implement/validate.md", goal_gate=true]
fix [label="Fix Failures", prompt="@prompts/implement/fix.md", max_visits=3]
}
start -> strategy
strategy -> plan [label="[L] Layer-by-layer"]
strategy -> plan [label="[F] Feature slice"]
strategy -> plan [label="[P] Embarrassingly parallel"]
strategy -> plan [label="[S] Sequential / linear"]
plan -> implement -> review -> validate
validate -> exit [condition="outcome=success"]
validate -> fix [condition="outcome!=success", label="Fix"]
fix -> validate
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View file

@ -0,0 +1,37 @@
Read the feature requirements doc matching $goal under docs-internal/product/features/.
Read the foundation blueprints under docs-internal/architecture/foundation-blueprints/ (backend.md, data-layer.md, frontend.md).
Read all existing feature blueprints under docs-internal/architecture/features/.
Read the current codebase structure.
Write the feature blueprint at docs-internal/architecture/features/ with:
## Solution Design
High-level technical architecture for this feature.
## Key Design Decisions
Rationale for approach choices, referencing foundation blueprint conventions.
## Data Model
Entities, fields, types, relationships. Reference existing tables from other blueprints. Define new tables where needed.
## API Implementation
Endpoints, HTTP methods, request/response models. Follow foundation backend conventions (FastAPI, SQLModel, dependency injection).
## UI Implementation
Key components, states, interactions. Use foundation frontend stack (React, shadcn/ui, Tailwind).
## Out of Scope
Adjacent concerns, optimizations, or features explicitly excluded from this blueprint. The implementing agent must not address anything listed here.
The blueprint must be detailed enough for an agent to implement without further clarification.
Before completing, self-verify:
- Every acceptance criterion from the feature requirements is addressed by at least one section
- Nothing in the blueprint contradicts the foundation blueprints
- The Out of Scope section explicitly excludes adjacent concerns that an implementing agent might drift into

View file

@ -0,0 +1,8 @@
Read the feature blueprint just written and the current system diagrams under docs-internal/architecture/system-diagrams/.
Update only the diagrams that need changes:
- **entity-relationship-diagram.md**: Add new entities and relationships from the data model section
- **sequence-diagram.md**: Add new API flows from the API implementation section
- **system-architecture.md**: Add new components from the solution design section
Preserve all existing diagram content. Only add or modify what this feature requires. If no diagram changes are needed, leave them unchanged.

View file

@ -0,0 +1,8 @@
Execute the approved change manifest:
- For new features: create the product feature doc and architecture feature blueprint following the standard templates under docs-internal/product/features/ and docs-internal/architecture/features/
- For modified features: update the existing product and architecture docs with the specified changes
- For removed features: delete the product and architecture docs, note affected code for cleanup
- For diagram changes: update the system diagrams under docs-internal/architecture/system-diagrams/
Ensure all docs remain internally consistent after changes.

View file

@ -0,0 +1,16 @@
Read all product docs under docs-internal/product/, all architecture docs under docs-internal/architecture/, and the current codebase.
The requested change: $goal
Analyze how this change affects the existing product, then propose a concrete change manifest:
For each affected feature:
- **NEW:** Feature docs to create (product/features/ and architecture/features/) with a one-paragraph description
- **MODIFY:** Existing docs to update with specific changes described
- **REMOVE:** Features to deprecate or remove with cleanup plan
- **DIAGRAM:** System diagrams to update
For each affected feature, also note:
- **OUT OF SCOPE:** Related changes that should not be made as part of this expansion
Present the manifest as an ordered list of actions.

View file

@ -0,0 +1,8 @@
Read the validation failures from the previous step.
For each failing acceptance criterion:
- Identify the root cause in the implementation
- Fix the code to satisfy the criterion
- Re-run the relevant tests
Do not change the feature requirements or blueprint. Only fix the implementation.

View file

@ -0,0 +1,14 @@
Execute the implementation plan step by step.
For each step:
- Create or modify the specified files
- Follow the conventions in the foundation blueprints
- Write tests alongside implementation
- Ensure each step builds on the previous one
Do not deviate from the feature blueprint. Do not implement anything listed in the blueprint's Out of Scope section. If the blueprint is ambiguous, make the simplest choice that satisfies the acceptance criteria.
Before completing, self-verify:
- Every acceptance criterion has corresponding implementation and tests
- No code was added for anything in the blueprint's Out of Scope section
- New code follows the conventions in the foundation blueprints

View file

@ -0,0 +1,17 @@
Read the feature blueprint matching $goal under docs-internal/architecture/features/.
Read the foundation blueprints under docs-internal/architecture/foundation-blueprints/.
Read the acceptance criteria from the matching doc under docs-internal/product/features/.
Read the relevant existing source code.
Decomposition strategy: $context.human.gate.label
Decompose the blueprint into ordered implementation steps using the chosen strategy:
- **Layer-by-layer:** Group steps by technical layer — database migrations, then data models, then API endpoints, then UI components, then tests for each layer.
- **Feature slice:** Group steps by user-facing capability — each step delivers a vertical slice from database through UI for one piece of functionality.
- **Embarrassingly parallel:** Identify steps with no dependencies on each other and group them for concurrent implementation. Mark dependency ordering explicitly.
- **Sequential / linear:** One step per logical change, strictly ordered, each building on the previous.
For each step, specify the exact files to create or modify and what changes to make.
Do not plan work for anything listed in the blueprint's Out of Scope section. Every step must trace to an acceptance criterion.

View file

@ -0,0 +1,16 @@
Review the implementation with fresh eyes. You did not write this code.
Read the feature blueprint matching $goal under docs-internal/architecture/features/.
Read the implementation files created or modified by the previous step.
Evaluate the code for:
- **Correctness:** Logic errors, off-by-one mistakes, unhandled edge cases
- **Security:** Injection risks, unsafe input handling, exposed secrets
- **Consistency:** Does it follow the patterns in the foundation blueprints and surrounding codebase?
- **Duplication:** Unnecessary copy-paste that should be extracted
- **Simplicity:** Overly complex code that could be simpler without losing clarity
Do not rewrite the code. Report specific issues with file and line references.
If no significant issues are found, return SUCCESS.
If issues are found, return FAIL with a numbered list of issues to fix.

View file

@ -0,0 +1,13 @@
Read the acceptance criteria from the feature requirements doc matching $goal under docs-internal/product/features/.
For each acceptance criterion (AC-NNN-XXX.N):
- Verify the implementation satisfies "When [condition], the system shall [behavior]"
- Run relevant tests
- Check that the code matches the feature blueprint's data model, API, and UI specs
Report:
- PASS or FAIL for each acceptance criterion
- Overall satisfaction score (passed / total)
- Specific gaps or failures with file and line references
Return SUCCESS only if all acceptance criteria pass.

View file

@ -0,0 +1,10 @@
Using the ingested artifacts, write the following product documents under docs-internal/product/:
1. **business-problem.md** — The problem this product solves, who it affects, why existing solutions fall short
2. **personas.md** — Target user types with goals, pain points, and usage patterns
3. **product-description.md** — What the product is, core value proposition, key capabilities
4. **current-state.md** — What exists today, what works, what doesn't
5. **success-metrics.md** — How we measure whether the product is succeeding
6. **technical-requirements.md** — Cross-cutting constraints: security, performance, integrations, compliance
Be specific and concrete. Where information is missing, state assumptions clearly. Every requirement must be testable.

View file

@ -0,0 +1,10 @@
Read all provided artifacts in the working directory: notes, transcripts, designs, existing source code, README files, and any other documentation.
Extract:
- Core themes and product concepts
- Contradictions or ambiguities
- Gaps in understanding
- Technical constraints mentioned
- User types referenced
Summarize findings. Identify what product context is still missing.

View file

@ -0,0 +1,12 @@
Read all product context docs under docs-internal/product/ and all existing feature docs under docs-internal/product/features/.
The feature to specify: $goal
Analyze the feature idea against existing product context. Identify:
- Which personas this feature serves
- How it relates to existing features (dependencies, overlaps)
- Scope boundaries: what is in and what is out
- Edge cases and constraints from technical-requirements.md
- Open questions that need answers before writing requirements
Prepare a scope summary for the next step.

View file

@ -0,0 +1,26 @@
Write the feature requirements document at docs-internal/product/features/ following the template structure:
## Overview
Clear summary of what the feature does and the value it delivers.
## Terminology
Key terms with definitions.
## Requirements
For each requirement:
- **REQ-NNN-XXX:** Named requirement
- **User Story:** As a [persona], I want to [action], so that I can [outcome]
- **Acceptance Criteria:** AC-NNN-XXX.N: When [condition], the system shall [behavior]
## Out of Scope
Adjacent capabilities, integrations, or behaviors explicitly excluded from this feature. Downstream agents must not implement anything listed here.
## Feature Behavior & Rules
Cross-requirement interactions, defaults, constraints, edge conditions.
Keep requirements implementation-agnostic. No data models, API shapes, or UI components. Focus only on observable behavior that a user or test can verify.

View file

@ -0,0 +1,7 @@
Apply the approved changes. Update the specified files in the specified layers.
Ensure all three layers are consistent after changes are applied:
- Product feature docs match observable behavior
- Feature blueprints match actual data models, APIs, and UI
- System diagrams reflect current entities, flows, and components
- Foundation blueprints reflect actual stack conventions

View file

@ -0,0 +1,18 @@
Read all three layers and compare for alignment:
1. **Product docs:** docs-internal/product/ (all feature requirements and acceptance criteria)
2. **Architecture docs:** docs-internal/architecture/ (foundation blueprints, feature blueprints, system diagrams)
3. **Source code:** the actual implementation
For each feature, check:
- Do acceptance criteria in product/features/ match what the code actually does?
- Does the feature blueprint match the current code structure (data model, API, UI)?
- Do system diagrams reflect the current entities, flows, and components?
- Are foundation blueprints consistent with actual tech stack usage?
Report all mismatches with:
- Which layer is the source of truth (most recently intentionally changed)
- What needs to be updated
- The specific files and sections affected
Set context.drift_found to true if any drift detected, false otherwise.

View file

@ -0,0 +1,8 @@
Based on the drift detected, propose specific changes to restore alignment.
For each mismatch:
- Identify which layer to update: prefer updating docs to match intentional code changes, prefer updating code to match intentional doc changes
- Write the exact changes: file path, what to add, modify, or remove
- Explain why this direction of sync was chosen
Group changes by layer (product docs, architecture docs, code) for review.

View file

@ -0,0 +1,18 @@
digraph seed {
graph [
goal="Bootstrap product context documentation from raw inputs",
label="Seed"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
ingest [label="Ingest Artifacts", prompt="@prompts/seed/ingest.md", reasoning_effort="high"]
draft [label="Draft Product Docs", prompt="@prompts/seed/draft.md"]
review [shape=hexagon, label="Review Product Docs"]
start -> ingest -> draft -> review
review -> exit [label="[A] Accept"]
review -> draft [label="[R] Revise"]
}

BIN
pipelines/factory/seed.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View file

@ -0,0 +1,18 @@
digraph specify {
graph [
goal="",
label="Specify"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
clarify [label="Clarify Scope", prompt="@prompts/specify/clarify.md", reasoning_effort="high"]
require [label="Write Requirements", prompt="@prompts/specify/require.md", reasoning_effort="high"]
approve [shape=hexagon, label="Approve Requirements"]
start -> clarify -> require -> approve
approve -> exit [label="[A] Accept"]
approve -> require [label="[R] Revise"]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View file

@ -0,0 +1,23 @@
digraph sync {
graph [
goal="Detect and resolve drift between product docs, architecture docs, and code",
label="Sync"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
detect [label="Detect Drift", prompt="@prompts/sync/detect.md", reasoning_effort="high"]
propose [label="Propose Changes", prompt="@prompts/sync/propose.md"]
review [shape=hexagon, label="Review Changes"]
apply [label="Apply Changes", prompt="@prompts/sync/apply.md"]
start -> detect
detect -> exit [condition="context.drift_found=false", label="No drift"]
detect -> propose [condition="context.drift_found=true", label="Drift found"]
propose -> review
review -> apply [label="[A] Accept"]
review -> propose [label="[R] Revise"]
apply -> exit
}

BIN
pipelines/factory/sync.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

16
test/branching.dot Normal file
View file

@ -0,0 +1,16 @@
digraph Branch {
graph [goal="Implement and validate a feature"]
rankdir=LR
node [shape=box, timeout="900s"]
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
plan [label="Plan", prompt="Plan the implementation for: $goal"]
implement [label="Implement", prompt="Implement the plan", goal_gate=true]
validate [label="Validate", prompt="Run tests"]
gate [shape=diamond, label="Tests passing?"]
start -> plan -> implement -> validate -> gate
gate -> exit [label="Yes", condition="outcome=success"]
gate -> implement [label="No", condition="outcome!=success"]
}

16
test/conditions.dot Normal file
View file

@ -0,0 +1,16 @@
digraph Conditions {
graph [goal="Test condition evaluation with OR and parentheses"]
start [shape=Mdiamond]
exit [shape=Msquare]
decide [label="Decide", shape=diamond]
path_a [label="Path A"]
path_b [label="Path B"]
start -> decide
decide -> path_a [condition="outcome=success && context.mode=fast"]
decide -> path_b [condition="outcome=success || context.fallback=true"]
path_a -> exit
path_b -> exit
}

6
test/invalid.dot Normal file
View file

@ -0,0 +1,6 @@
digraph Invalid {
/* Missing start node */
exit [shape=Msquare]
orphan [label="Orphan node"]
exit -> orphan
}

19
test/parallel.dot Normal file
View file

@ -0,0 +1,19 @@
digraph Parallel {
graph [goal="Test parallel and fan-in execution"]
start [shape=Mdiamond]
exit [shape=Msquare]
fork [label="Fork Work", shape=component, join_policy="wait_all", error_policy="continue"]
branch1 [label="Branch 1"]
branch2 [label="Branch 2"]
merge [label="Merge Results", shape=tripleoctagon]
review [label="Review"]
start -> fork
fork -> branch1
fork -> branch2
branch1 -> merge
branch2 -> merge
merge -> review -> exit
}

12
test/simple.dot Normal file
View file

@ -0,0 +1,12 @@
digraph Simple {
graph [goal="Run tests and report results"]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
run_tests [label="Run Tests", prompt="Run the test suite and report results"]
report [label="Report", prompt="Summarize the test results"]
start -> run_tests -> report -> exit
}

19
test/styled.dot Normal file
View file

@ -0,0 +1,19 @@
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; }
"
]
start [shape=Mdiamond]
exit [shape=Msquare]
plan [label="Plan", class="planning"]
implement [label="Implement", class="code"]
critical_review [label="Critical Review", class="code"]
start -> plan -> implement -> critical_review -> exit
}