mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
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:
parent
55b6000374
commit
5932b500e7
42 changed files with 1434 additions and 66 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,2 +1,3 @@
|
|||
target
|
||||
.env
|
||||
attractor-run-*
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
378
docs/agent/reviews/coding-agent-loop-simplifications.md
Normal file
378
docs/agent/reviews/coding-agent-loop-simplifications.md
Normal 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(¤t_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(¤t_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
|
||||
|
|
@ -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 | — |
|
||||
|
|
|
|||
|
|
@ -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
311
docs/designs/cli-design.md
Normal 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
156
pipelines/factory/README.md
Normal 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.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.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.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.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.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.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.
|
||||
18
pipelines/factory/architect.dot
Normal file
18
pipelines/factory/architect.dot
Normal 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"]
|
||||
}
|
||||
BIN
pipelines/factory/architect.png
Normal file
BIN
pipelines/factory/architect.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
19
pipelines/factory/expand.dot
Normal file
19
pipelines/factory/expand.dot
Normal 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
|
||||
}
|
||||
BIN
pipelines/factory/expand.png
Normal file
BIN
pipelines/factory/expand.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
33
pipelines/factory/implement.dot
Normal file
33
pipelines/factory/implement.dot
Normal 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
|
||||
}
|
||||
BIN
pipelines/factory/implement.png
Normal file
BIN
pipelines/factory/implement.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
37
pipelines/factory/prompts/architect/blueprint.md
Normal file
37
pipelines/factory/prompts/architect/blueprint.md
Normal 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
|
||||
8
pipelines/factory/prompts/architect/diagram.md
Normal file
8
pipelines/factory/prompts/architect/diagram.md
Normal 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.
|
||||
8
pipelines/factory/prompts/expand/execute.md
Normal file
8
pipelines/factory/prompts/expand/execute.md
Normal 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.
|
||||
16
pipelines/factory/prompts/expand/propose.md
Normal file
16
pipelines/factory/prompts/expand/propose.md
Normal 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.
|
||||
8
pipelines/factory/prompts/implement/fix.md
Normal file
8
pipelines/factory/prompts/implement/fix.md
Normal 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.
|
||||
14
pipelines/factory/prompts/implement/implement.md
Normal file
14
pipelines/factory/prompts/implement/implement.md
Normal 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
|
||||
17
pipelines/factory/prompts/implement/plan.md
Normal file
17
pipelines/factory/prompts/implement/plan.md
Normal 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.
|
||||
16
pipelines/factory/prompts/implement/review.md
Normal file
16
pipelines/factory/prompts/implement/review.md
Normal 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.
|
||||
13
pipelines/factory/prompts/implement/validate.md
Normal file
13
pipelines/factory/prompts/implement/validate.md
Normal 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.
|
||||
10
pipelines/factory/prompts/seed/draft.md
Normal file
10
pipelines/factory/prompts/seed/draft.md
Normal 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.
|
||||
10
pipelines/factory/prompts/seed/ingest.md
Normal file
10
pipelines/factory/prompts/seed/ingest.md
Normal 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.
|
||||
12
pipelines/factory/prompts/specify/clarify.md
Normal file
12
pipelines/factory/prompts/specify/clarify.md
Normal 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.
|
||||
26
pipelines/factory/prompts/specify/require.md
Normal file
26
pipelines/factory/prompts/specify/require.md
Normal 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.
|
||||
7
pipelines/factory/prompts/sync/apply.md
Normal file
7
pipelines/factory/prompts/sync/apply.md
Normal 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
|
||||
18
pipelines/factory/prompts/sync/detect.md
Normal file
18
pipelines/factory/prompts/sync/detect.md
Normal 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.
|
||||
8
pipelines/factory/prompts/sync/propose.md
Normal file
8
pipelines/factory/prompts/sync/propose.md
Normal 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.
|
||||
18
pipelines/factory/seed.dot
Normal file
18
pipelines/factory/seed.dot
Normal 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
BIN
pipelines/factory/seed.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
18
pipelines/factory/specify.dot
Normal file
18
pipelines/factory/specify.dot
Normal 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"]
|
||||
}
|
||||
BIN
pipelines/factory/specify.png
Normal file
BIN
pipelines/factory/specify.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
23
pipelines/factory/sync.dot
Normal file
23
pipelines/factory/sync.dot
Normal 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
BIN
pipelines/factory/sync.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
16
test/branching.dot
Normal file
16
test/branching.dot
Normal 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
16
test/conditions.dot
Normal 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
6
test/invalid.dot
Normal 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
19
test/parallel.dot
Normal 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
12
test/simple.dot
Normal 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
19
test/styled.dot
Normal 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue