use std::fs;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::mpsc::{self, Receiver, Sender};
use std::thread;
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use serde::Serialize;
use serde_json::{json, Value};
use crate::domain::providers::ProviderModelPricing;
use crate::runtime::redaction::redact_text;
use super::execution::ExecutionTask;
use super::stage::Stage;
#[derive(Clone, Debug, Default, PartialEq)]
pub struct StageRunOptions {
pub provider: Option<String>,
pub model: Option<String>,
pub effort: Option<String>,
pub offline: bool,
pub force: bool,
pub artifact_dir: Option<PathBuf>,
pub context_window_tokens: Option<u32>,
pub pricing: Option<ProviderModelPricing>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum DirectAdapter {
Codex,
Claude,
Opencode,
Cursor,
Devin,
Trae,
Antigravity,
}
impl DirectAdapter {
fn for_provider(provider: &str) -> Option<Self> {
match provider.trim().to_ascii_lowercase().as_str() {
"codex" => Some(Self::Codex),
"claude" => Some(Self::Claude),
"opencode" => Some(Self::Opencode),
"cursor" => Some(Self::Cursor),
"devin" | "devin-cli" => Some(Self::Devin),
"trae" | "trae-cli" | "trae-agent" => Some(Self::Trae),
"agy" | "antigravity" | "gemini" | "google-antigravity" => Some(Self::Antigravity),
_ => None,
}
}
fn provider(self) -> &'static str {
match self {
Self::Codex => "codex",
Self::Claude => "claude",
Self::Opencode => "opencode",
Self::Cursor => "cursor",
Self::Devin => "devin",
Self::Trae => "trae",
Self::Antigravity => "antigravity",
}
}
fn command(self) -> &'static str {
match self {
Self::Codex => "codex",
Self::Claude => "claude",
Self::Opencode => "opencode",
Self::Cursor => "cursor",
Self::Devin => "devin",
Self::Trae => "trae-cli",
Self::Antigravity => "agy",
}
}
fn label(self) -> &'static str {
match self {
Self::Codex => "codex exec JSONL",
Self::Claude => "Claude Code print",
Self::Opencode => "opencode run",
Self::Cursor => "Cursor Agent print",
Self::Devin => "Devin CLI print",
Self::Trae => "Trae Agent isolated run",
Self::Antigravity => "Antigravity CLI print",
}
}
fn agent_id(self) -> &'static str {
match self {
Self::Codex => "codex",
Self::Claude => "claude-code",
Self::Opencode => "opencode",
Self::Cursor => "cursor-agent",
Self::Devin => "devin",
Self::Trae => "trae-agent",
Self::Antigravity => "antigravity",
}
}
fn agent_label(self) -> &'static str {
match self {
Self::Codex => "Codex CLI",
Self::Claude => "Claude Code",
Self::Opencode => "opencode",
Self::Cursor => "Cursor Agent",
Self::Devin => "Devin CLI",
Self::Trae => "Trae Agent CLI",
Self::Antigravity => "Antigravity CLI",
}
}
fn login_command(self) -> &'static str {
match self {
Self::Codex => "codex login",
Self::Claude => "claude auth login",
Self::Opencode => "opencode providers login",
Self::Cursor => "cursor agent login",
Self::Devin => "devin auth login",
Self::Trae => "trae-cli show-config",
Self::Antigravity => "agy",
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct AdapterCapabilities {
pub provider: String,
pub artifact_generation: bool,
pub workspace_execution: bool,
pub execution_fallback: String,
}
const SDD_CODEX_SKILL_FRONTMATTER_REPAIRS: &[&str] = &[
".agents/skills/design-tokens/SKILL.md",
".agents/skills/orchestration-plugin/skills/design-tokens/SKILL.md",
];
const SDD_MANAGED_SKILL_NAMES: &[&str] = &[
"artifact-diagrams",
"artifact-schemas",
"bdd-gherkin",
"checkpoints",
"code-review",
"data-contracts",
"diagram-design",
"design-flow",
"design-interview",
"design-to-tasks",
"design-tokens",
"experience-design",
"frontend-design",
"information-architecture",
"integrations",
"memory",
"orchestration",
"output-quality",
"project-discovery",
"provider-compatibility",
"release-readiness",
"repo-adapter",
"risk-classifier",
"security-privacy",
"source-command-sdd-doctor",
"test-strategy",
"traceability",
"visual-review",
];
#[derive(Clone, Debug)]
pub struct StageResult {
pub stage: Stage,
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
pub duration_ms: u128,
pub trace: Vec<String>,
pub agent: AgentProvenance,
pub model: ModelProvenance,
pub usage: TokenUsageSample,
pub context: StageContextSample,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct AgentProvenance {
pub id: String,
pub label: String,
pub provider: Option<String>,
pub command: Option<String>,
pub source: String,
pub confidence: String,
}
impl AgentProvenance {
fn from_adapter(adapter: DirectAdapter, source: impl Into<String>) -> Self {
Self {
id: adapter.agent_id().to_string(),
label: adapter.agent_label().to_string(),
provider: Some(adapter.provider().to_string()),
command: Some(adapter.command().to_string()),
source: source.into(),
confidence: "invoked".to_string(),
}
}
fn from_harness(options: &StageRunOptions, source: impl Into<String>) -> Self {
Self {
id: "sdd-harness".to_string(),
label: "SDD deterministic harness".to_string(),
provider: options.provider.clone().and_then(non_empty_string),
command: Some("sdd".to_string()),
source: source.into(),
confidence: "invoked".to_string(),
}
}
fn unsupported(options: &StageRunOptions, source: impl Into<String>) -> Self {
Self {
id: "unsupported-workspace-execution".to_string(),
label: "Unsupported workspace execution adapter".to_string(),
provider: options.provider.clone().and_then(non_empty_string),
command: None,
source: source.into(),
confidence: "blocked".to_string(),
}
}
fn agent_id_option(&self) -> Option<&str> {
if self.id.trim().is_empty() {
None
} else {
Some(self.id.as_str())
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct ModelProvenance {
pub provider: Option<String>,
pub selected_model: Option<String>,
pub observed_model: Option<String>,
pub source: String,
pub confidence: String,
}
impl ModelProvenance {
fn from_options(
options: &StageRunOptions,
observed_model: Option<String>,
source: impl Into<String>,
) -> Self {
let observed_model = observed_model.and_then(non_empty_string);
let confidence = if observed_model.is_some() {
"observed"
} else {
"unreported"
};
Self {
provider: options.provider.clone().and_then(non_empty_string),
selected_model: options.model.clone().and_then(non_empty_string),
observed_model,
source: source.into(),
confidence: confidence.to_string(),
}
}
}
#[derive(Clone, Debug)]
struct ResultProvenance {
agent: AgentProvenance,
model: ModelProvenance,
}
#[allow(clippy::large_enum_variant)]
#[derive(Clone, Debug)]
pub enum StageEvent {
Trace(String),
Usage(TokenUsageSample),
Context(StageContextSample),
Finished(StageResult),
}
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
pub struct TokenUsageSample {
pub input_tokens_estimate: usize,
pub cached_input_tokens_estimate: usize,
pub output_tokens_estimate: usize,
pub reasoning_output_tokens_estimate: usize,
pub total_tokens_estimate: usize,
pub input_bytes: usize,
pub output_bytes: usize,
pub provider_reported: bool,
pub cost: Option<TokenCostEstimate>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize)]
pub struct TokenCostEstimate {
pub currency: String,
pub input_cost: f64,
pub cached_input_cost: f64,
pub output_cost: f64,
pub total_cost: f64,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
pub struct StageContextSample {
pub prompt_tokens_estimate: usize,
pub prompt_bytes: usize,
pub artifact_count: usize,
pub artifact_chars: usize,
pub truncated: bool,
pub context_window_tokens: Option<u32>,
}
impl TokenUsageSample {
pub fn from_text(input: &str, output: &str) -> Self {
let input_tokens_estimate = estimate_tokens(input);
let output_tokens_estimate = estimate_tokens(output);
Self {
input_tokens_estimate,
cached_input_tokens_estimate: 0,
output_tokens_estimate,
reasoning_output_tokens_estimate: 0,
total_tokens_estimate: input_tokens_estimate + output_tokens_estimate,
input_bytes: input.len(),
output_bytes: output.len(),
provider_reported: false,
cost: None,
}
}
pub fn from_provider_usage(
input_tokens: usize,
cached_input_tokens: usize,
output_tokens: usize,
reasoning_output_tokens: usize,
total_tokens: usize,
) -> Self {
Self {
input_tokens_estimate: input_tokens,
cached_input_tokens_estimate: cached_input_tokens,
output_tokens_estimate: output_tokens,
reasoning_output_tokens_estimate: reasoning_output_tokens,
total_tokens_estimate: total_tokens,
input_bytes: 0,
output_bytes: 0,
provider_reported: true,
cost: None,
}
}
pub fn with_cost(mut self, pricing: Option<&ProviderModelPricing>) -> Self {
self.cost = pricing.and_then(|pricing| estimate_cost(&self, pricing));
self
}
pub fn is_empty(&self) -> bool {
self.total_tokens_estimate == 0 && self.input_bytes == 0 && self.output_bytes == 0
}
}
fn estimate_cost(
usage: &TokenUsageSample,
pricing: &ProviderModelPricing,
) -> Option<TokenCostEstimate> {
let input_rate = pricing.input_per_million?;
let output_rate = pricing.output_per_million?;
let cached_rate = pricing.cached_input_per_million.unwrap_or(input_rate);
let cached = usage
.cached_input_tokens_estimate
.min(usage.input_tokens_estimate);
let fresh_input = usage.input_tokens_estimate.saturating_sub(cached);
let input_cost = (fresh_input as f64 / 1_000_000.0) * input_rate;
let cached_input_cost = (cached as f64 / 1_000_000.0) * cached_rate;
let output_cost = (usage.output_tokens_estimate as f64 / 1_000_000.0) * output_rate;
Some(TokenCostEstimate {
currency: pricing
.currency
.clone()
.unwrap_or_else(|| "USD".to_string()),
input_cost,
cached_input_cost,
output_cost,
total_cost: input_cost + cached_input_cost + output_cost,
})
}
pub fn self_exe() -> PathBuf {
std::env::current_exe().unwrap_or_else(|_| PathBuf::from("sdd"))
}
fn build_stage_command(
exe: &Path,
stage: Stage,
name: &str,
root: &Path,
input: Option<&str>,
options: &StageRunOptions,
) -> Command {
let mut cmd = Command::new(exe);
cmd.arg(stage.key())
.arg("--name")
.arg(name)
.arg("--root")
.arg(root);
if let Some(provider) = options
.provider
.as_deref()
.filter(|value| !value.is_empty())
{
cmd.arg("--provider").arg(provider);
}
if let Some(model) = options.model.as_deref().filter(|value| !value.is_empty()) {
cmd.arg("--model").arg(model);
}
if let Some(effort) = options.effort.as_deref().filter(|value| !value.is_empty()) {
cmd.arg("--effort").arg(effort);
}
if options.offline {
cmd.arg("--offline");
}
if options.force {
cmd.arg("--force");
}
if let Some(text) = input {
if !text.is_empty() {
cmd.arg(text);
}
}
cmd.stdin(Stdio::null());
cmd
}
pub fn spawn_stage(
exe: PathBuf,
stage: Stage,
name: String,
root: PathBuf,
input: Option<String>,
options: StageRunOptions,
) -> Receiver<StageEvent> {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let start = Instant::now();
let result = if let Some(adapter) = direct_generation_adapter(&options) {
match adapter {
DirectAdapter::Codex => {
run_codex_stage(&exe, stage, &name, &root, &options, start, &tx)
}
_ => run_cli_text_stage(adapter, &exe, stage, &name, &root, &options, start, &tx),
}
} else {
run_deterministic_stage(DeterministicStageRun {
exe: &exe,
stage,
name: &name,
root: &root,
input: input.as_deref(),
options: &options,
start,
tx: &tx,
})
};
let _ = tx.send(StageEvent::Finished(result));
});
rx
}
struct DeterministicStageRun<'a> {
exe: &'a Path,
stage: Stage,
name: &'a str,
root: &'a Path,
input: Option<&'a str>,
options: &'a StageRunOptions,
start: Instant,
tx: &'a Sender<StageEvent>,
}
fn run_deterministic_stage(run: DeterministicStageRun<'_>) -> StageResult {
let DeterministicStageRun {
exe,
stage,
name,
root,
input,
options,
start,
tx,
} = run;
let mut trace = deterministic_trace(stage, options);
emit_trace_lines(tx, &trace);
let mut cmd = build_stage_command(exe, stage, name, root, input, options);
match cmd.output() {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
let output_sample = read_generated_artifact(options, stage)
.unwrap_or_else(|| format!("{stdout}\n{stderr}"));
let usage = TokenUsageSample::from_text(input.unwrap_or_default(), &output_sample)
.with_cost(options.pricing.as_ref());
push_trace(
&mut trace,
tx,
format!(
"harness sdd terminou com exit {} em {}ms",
out.status.code().unwrap_or(-1),
start.elapsed().as_millis()
),
);
emit_usage(tx, &usage);
StageResult {
stage,
exit_code: out.status.code().unwrap_or(-1),
stdout,
stderr,
duration_ms: start.elapsed().as_millis(),
trace,
agent: AgentProvenance::from_harness(options, "sdd-harness"),
model: ModelProvenance::from_options(options, None, "sdd-harness-unreported"),
usage,
context: StageContextSample::default(),
}
}
Err(e) => {
let usage = TokenUsageSample::from_text(input.unwrap_or_default(), "")
.with_cost(options.pricing.as_ref());
push_trace(&mut trace, tx, format!("falha ao iniciar harness sdd: {e}"));
emit_usage(tx, &usage);
StageResult {
stage,
exit_code: -1,
stdout: String::new(),
stderr: format!("falha ao iniciar subprocesso: {e}"),
duration_ms: start.elapsed().as_millis(),
trace,
agent: AgentProvenance::from_harness(options, "sdd-harness"),
model: ModelProvenance::from_options(options, None, "sdd-harness-unreported"),
usage,
context: StageContextSample::default(),
}
}
}
}
fn deterministic_trace(stage: Stage, options: &StageRunOptions) -> Vec<String> {
let mut trace = Vec::new();
let provider = options.provider.as_deref().unwrap_or("auto");
trace.push(format!("provider selecionado: {provider}"));
trace.push(format!("stage: {}", stage.key()));
if let Some(model) = options.model.as_deref().filter(|value| !value.is_empty()) {
trace.push(format!("modelo solicitado: {model}"));
}
if let Some(effort) = options.effort.as_deref().filter(|value| !value.is_empty()) {
trace.push(format!("effort solicitado: {effort}"));
}
if options.offline {
trace.push("offline no config: true".to_string());
}
if let Some(provider) = options.provider.as_deref() {
trace.push(format!(
"adapter direto para {provider} nao configurado; usando harness deterministico"
));
} else {
trace.push("sem adapter direto selecionado; usando harness deterministico".to_string());
}
trace
}
fn read_generated_artifact(options: &StageRunOptions, stage: Stage) -> Option<String> {
let artifact_dir = options.artifact_dir.as_ref()?;
std::fs::read_to_string(artifact_dir.join(stage.filename())).ok()
}
fn non_empty_string(value: String) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
pub fn estimate_tokens(text: &str) -> usize {
let chars = text.chars().count();
if chars == 0 {
0
} else {
chars.saturating_add(3) / 4
}
}
fn reported_usage_from_jsonl(stdout: &[u8]) -> Option<TokenUsageSample> {
let text = String::from_utf8_lossy(stdout);
text.lines().rev().find_map(reported_usage_from_json_line)
}
fn observed_model_from_json_line(line: &str) -> Option<String> {
let value = serde_json::from_str::<Value>(line).ok()?;
observed_model_from_json_value(&value)
}
fn observed_model_from_jsonl(stdout: &[u8]) -> Option<String> {
let text = String::from_utf8_lossy(stdout);
text.lines().find_map(observed_model_from_json_line)
}
fn observed_model_from_json_value(value: &Value) -> Option<String> {
match value {
Value::Object(object) => {
for key in ["model", "model_id", "resolved_model", "actual_model"] {
if let Some(model) = object
.get(key)
.and_then(Value::as_str)
.and_then(|value| non_empty_string(value.to_string()))
{
return Some(model);
}
}
for key in [
"payload", "response", "session", "turn", "item", "data", "metadata", "event",
"result", "events", "messages", "choices",
] {
if let Some(model) = object.get(key).and_then(observed_model_from_json_value) {
return Some(model);
}
}
None
}
Value::Array(items) => items.iter().find_map(observed_model_from_json_value),
_ => None,
}
}
fn reported_usage_from_json_line(line: &str) -> Option<TokenUsageSample> {
let value = serde_json::from_str::<Value>(line).ok()?;
let usage = value.get("usage").or_else(|| {
value
.get("payload")
.and_then(|payload| payload.get("usage"))
})?;
let input = usage.get("input_tokens").and_then(Value::as_u64)? as usize;
let cached_input = usage
.get("cached_input_tokens")
.and_then(Value::as_u64)
.unwrap_or(0) as usize;
let output = usage
.get("output_tokens")
.and_then(Value::as_u64)
.unwrap_or(0) as usize;
let reasoning_output = usage
.get("reasoning_output_tokens")
.and_then(Value::as_u64)
.unwrap_or(0) as usize;
let total = usage
.get("total_tokens")
.and_then(Value::as_u64)
.map(|value| value as usize)
.unwrap_or_else(|| input + output);
Some(TokenUsageSample::from_provider_usage(
input,
cached_input,
output,
reasoning_output,
total,
))
}
fn codex_event_trace_line(line: &str) -> Option<String> {
let value = serde_json::from_str::<Value>(line).ok()?;
let event_type = value
.get("type")
.or_else(|| value.get("event"))
.and_then(Value::as_str)
.unwrap_or("event");
if value.get("usage").is_some()
|| value
.get("payload")
.and_then(|payload| payload.get("usage"))
.is_some()
{
return Some(format!("codex event: {event_type} · usage"));
}
if let Some(item) = value.get("item").or_else(|| value.get("payload")) {
let item_type = item
.get("type")
.or_else(|| item.get("kind"))
.and_then(Value::as_str);
let status = item.get("status").and_then(Value::as_str);
let name = item
.get("name")
.or_else(|| item.get("title"))
.and_then(Value::as_str);
let detail = [item_type, status, name]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(" · ");
if !detail.is_empty() {
return Some(format!(
"codex event: {event_type} · {}",
truncate_trace(&detail, 96)
));
}
}
if let Some(message) = value
.get("message")
.or_else(|| value.get("delta"))
.and_then(Value::as_str)
.filter(|message| !message.trim().is_empty())
{
return Some(format!(
"codex event: {event_type} · {}",
truncate_trace(message, 96)
));
}
Some(format!("codex event: {event_type}"))
}
fn push_trace(trace: &mut Vec<String>, tx: &Sender<StageEvent>, item: impl Into<String>) {
let item = item.into();
trace.push(item.clone());
emit_trace(tx, item);
}
fn push_trace_once(trace: &mut Vec<String>, item: impl Into<String>) {
let item = item.into();
if trace.last() != Some(&item) && !trace.contains(&item) {
trace.push(item);
}
}
fn emit_trace_lines(tx: &Sender<StageEvent>, lines: &[String]) {
for line in lines {
emit_trace(tx, line.clone());
}
}
fn emit_trace(tx: &Sender<StageEvent>, item: impl Into<String>) {
let _ = tx.send(StageEvent::Trace(redact_text(&item.into())));
}
fn emit_usage(tx: &Sender<StageEvent>, usage: &TokenUsageSample) {
if !usage.is_empty() {
let _ = tx.send(StageEvent::Usage(usage.clone()));
}
}
fn emit_context(tx: &Sender<StageEvent>, context: &StageContextSample) {
if context.prompt_tokens_estimate > 0 || context.artifact_count > 0 {
let _ = tx.send(StageEvent::Context(context.clone()));
}
}
fn truncate_trace(input: &str, max: usize) -> String {
if input.chars().count() <= max {
input.to_string()
} else {
let mut out: String = input.chars().take(max.saturating_sub(1)).collect();
out.push('…');
out
}
}
fn codex_stderr_trace_line(line: &str) -> Option<String> {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed == "Reading additional input from stdin..." {
return None;
}
if let Some(warning) = codex_skill_load_warning(trimmed) {
return Some(warning);
}
let redacted = redact_text(trimmed);
if redacted.contains("ERROR") {
return Some(format!(
"erro do Codex CLI: {}",
truncate_trace(&redacted, 118)
));
}
Some(format!("codex stderr: {}", truncate_trace(&redacted, 118)))
}
fn codex_skill_load_warning(line: &str) -> Option<String> {
if !line.contains("failed to load skill") {
return None;
}
let skill = line
.split("/.agents/skills/")
.nth(1)
.and_then(|tail| tail.split("/SKILL.md").next())
.filter(|value| !value.trim().is_empty())
.unwrap_or("skill");
let reason = line
.split("/SKILL.md:")
.nth(1)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("frontmatter/YAML inválido");
let owner = if is_sdd_managed_skill_path(skill) {
"skill SDD"
} else {
"skill local"
};
Some(format!(
"aviso: {owner} {skill} ignorada pelo Codex ({})",
truncate_trace(&redact_text(reason), 84)
))
}
fn is_sdd_managed_skill_path(skill: &str) -> bool {
let name = skill.rsplit('/').next().unwrap_or(skill);
SDD_MANAGED_SKILL_NAMES.contains(&name)
}
fn prepare_codex_skill_surface(root: &Path, trace: &mut Vec<String>, tx: &Sender<StageEvent>) {
let mut sdd_repaired = Vec::new();
for rel in SDD_CODEX_SKILL_FRONTMATTER_REPAIRS {
let path = root.join(rel);
match repair_codex_skill_frontmatter(&path) {
Ok(Some(skill)) => sdd_repaired.push(skill),
Ok(None) => {}
Err(e) => push_trace(
trace,
tx,
format!(
"aviso: skill SDD {} nao foi adaptada ({})",
skill_label_from_path(&path),
truncate_trace(&redact_text(&e.to_string()), 84)
),
),
}
}
if !sdd_repaired.is_empty() {
push_trace(
trace,
tx,
format!(
"skills Codex: {} frontmatter(s) SDD adaptado(s): {}",
sdd_repaired.len(),
sdd_repaired.join(", ")
),
);
}
let local_repaired = repair_local_codex_skills(root);
if !local_repaired.is_empty() {
push_trace(
trace,
tx,
format!(
"skills Codex: {} frontmatter(s) local(is) adaptado(s): {}",
local_repaired.len(),
local_repaired.join(", ")
),
);
}
}
fn repair_codex_skill_frontmatter(path: &Path) -> std::io::Result<Option<String>> {
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
};
if skill_frontmatter_is_valid(&text) {
return Ok(None);
}
let skill = skill_label_from_path(path);
let fixed = if skill_frontmatter_text(&text).is_some() {
quote_plain_description_in_frontmatter(&text)
} else {
Some(prepend_codex_frontmatter(
&text,
&skill,
&format!("Skill SDD {skill} adaptada para Codex."),
))
};
let Some(fixed) = fixed else {
return Ok(None);
};
if !skill_frontmatter_is_valid(&fixed) {
return Ok(None);
}
fs::write(path, fixed)?;
Ok(Some(skill))
}
fn repair_local_codex_skills(root: &Path) -> Vec<String> {
let skills_dir = root.join(".agents/skills");
let Ok(entries) = fs::read_dir(skills_dir) else {
return Vec::new();
};
let mut repaired = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let skill = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default();
if skill.is_empty() || is_sdd_managed_skill_path(skill) {
continue;
}
let skill_path = path.join("SKILL.md");
if repair_local_codex_skill_frontmatter(&skill_path)
.ok()
.flatten()
.is_some()
{
repaired.push(skill.to_string());
}
}
repaired
}
fn repair_local_codex_skill_frontmatter(path: &Path) -> std::io::Result<Option<String>> {
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e),
};
if skill_frontmatter_text(&text).is_some() || !looks_like_markdown_skill(&text) {
return Ok(None);
}
let skill = skill_label_from_path(path);
let fixed = prepend_codex_frontmatter(
&text,
&skill,
&format!("Skill local {skill} adaptada para Codex."),
);
if !skill_frontmatter_is_valid(&fixed) {
return Ok(None);
}
fs::write(path, fixed)?;
Ok(Some(skill))
}
fn looks_like_markdown_skill(text: &str) -> bool {
text.lines()
.find(|line| !line.trim().is_empty())
.is_some_and(|line| line.trim_start().starts_with("# "))
}
fn skill_frontmatter_is_valid(text: &str) -> bool {
skill_frontmatter_text(text)
.as_deref()
.is_some_and(|frontmatter| serde_yaml::from_str::<serde_yaml::Value>(frontmatter).is_ok())
}
fn skill_frontmatter_text(text: &str) -> Option<String> {
let mut lines = text.lines();
if lines.next()?.trim() != "---" {
return None;
}
let mut frontmatter = String::new();
for line in lines {
if line.trim() == "---" {
return Some(frontmatter);
}
frontmatter.push_str(line);
frontmatter.push('\n');
}
None
}
fn quote_plain_description_in_frontmatter(text: &str) -> Option<String> {
let mut lines = text.lines().map(str::to_string).collect::<Vec<_>>();
if lines.first().map(|line| line.trim()) != Some("---") {
return None;
}
let end = lines
.iter()
.enumerate()
.skip(1)
.find_map(|(idx, line)| (line.trim() == "---").then_some(idx))?;
let mut changed = false;
for line in lines.iter_mut().take(end).skip(1) {
let Some(value) = line.strip_prefix("description: ") else {
continue;
};
let value = value.trim();
if value.starts_with('"')
|| value.starts_with('\'')
|| value.starts_with('|')
|| value.starts_with('>')
{
continue;
}
*line = format!("description: {}", yaml_double_quote(value));
changed = true;
}
if !changed {
return None;
}
let mut out = lines.join("\n");
if text.ends_with('\n') {
out.push('\n');
}
Some(out)
}
fn prepend_codex_frontmatter(text: &str, skill: &str, description: &str) -> String {
format!(
"---\nname: {skill}\ndescription: {}\n---\n\n{}",
yaml_double_quote(description),
text.trim_start()
)
}
fn yaml_double_quote(value: &str) -> String {
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
}
fn skill_label_from_path(path: &Path) -> String {
path.parent()
.and_then(|parent| parent.file_name())
.and_then(|name| name.to_str())
.unwrap_or("skill")
.to_string()
}
fn direct_generation_adapter(options: &StageRunOptions) -> Option<DirectAdapter> {
DirectAdapter::for_provider(options.provider.as_deref()?)
}
pub fn has_direct_generation_adapter(provider: &str) -> bool {
DirectAdapter::for_provider(provider).is_some()
}
pub fn adapter_capabilities(provider: &str) -> AdapterCapabilities {
let normalized = provider.trim().to_ascii_lowercase();
let direct = DirectAdapter::for_provider(&normalized);
let workspace_execution = matches!(direct, Some(DirectAdapter::Codex));
AdapterCapabilities {
provider: if normalized.is_empty() {
"auto".to_string()
} else {
normalized
},
artifact_generation: direct.is_some(),
workspace_execution,
execution_fallback: if workspace_execution {
"codex exec workspace-write".to_string()
} else if direct.is_some() {
"geração de artefato e handoff; execução de workspace requer provider com escrita"
.to_string()
} else {
"harness determinístico sem edição de workspace".to_string()
},
}
}
pub fn provider_supports_workspace_execution(provider: &str) -> bool {
adapter_capabilities(provider).workspace_execution
}
fn run_codex_stage(
exe: &Path,
stage: Stage,
name: &str,
root: &Path,
options: &StageRunOptions,
start: Instant,
tx: &Sender<StageEvent>,
) -> StageResult {
let mut trace = Vec::new();
for item in [
"provider selecionado: codex".to_string(),
"adapter: codex exec JSONL".to_string(),
format!("stage: {}", stage.key()),
format!("offline no config: {}", options.offline),
format!("force overwrite: {}", options.force),
] {
push_trace(&mut trace, tx, item);
}
if let Some(model) = options.model.as_deref().filter(|value| !value.is_empty()) {
push_trace(&mut trace, tx, format!("modelo solicitado: {model}"));
}
if let Some(effort) = options.effort.as_deref().filter(|value| !value.is_empty()) {
push_trace(&mut trace, tx, format!("effort solicitado: {effort}"));
}
prepare_codex_skill_surface(root, &mut trace, tx);
let output_path = temp_output_path(stage);
push_trace(
&mut trace,
tx,
format!("saida temporaria: {}", output_path.display()),
);
let (prompt, context_sample) = build_provider_prompt(stage, name, root, options);
emit_context(tx, &context_sample);
let prompt_usage = TokenUsageSample::from_text(&prompt, "").with_cost(options.pricing.as_ref());
emit_usage(tx, &prompt_usage);
push_trace(
&mut trace,
tx,
format!(
"prompt montado: ~{} tokens de input local",
prompt_usage.input_tokens_estimate
),
);
let mut cmd = build_codex_exec_command(
root,
&output_path,
&prompt,
options.model.as_deref(),
options.effort.as_deref(),
CodexSandbox::ReadOnly,
);
push_trace(
&mut trace,
tx,
redact_text(&codex_trace_command(
root,
&output_path,
options.model.as_deref(),
options.effort.as_deref(),
CodexSandbox::ReadOnly,
)),
);
let codex_start = Instant::now();
let mut child = match cmd.spawn() {
Ok(child) => child,
Err(e) => {
push_trace(&mut trace, tx, format!("falha ao iniciar codex exec: {e}"));
return StageResult {
stage,
exit_code: -1,
stdout: String::new(),
stderr: format!("falha ao iniciar codex exec: {e}"),
duration_ms: start.elapsed().as_millis(),
trace,
agent: AgentProvenance::from_adapter(DirectAdapter::Codex, "direct-adapter"),
model: ModelProvenance::from_options(options, None, "codex-jsonl-unavailable"),
usage: prompt_usage,
context: context_sample,
};
}
};
push_trace(
&mut trace,
tx,
format!("codex exec iniciado: pid {}", child.id()),
);
let stderr_handle = child.stderr.take().map(|stderr| {
let stderr_tx = tx.clone();
thread::spawn(move || {
let mut captured = String::new();
let mut trace_lines = Vec::new();
for line in BufReader::new(stderr).lines().map_while(Result::ok) {
if let Some(trace_line) = codex_stderr_trace_line(&line) {
if trace_lines.last() != Some(&trace_line) {
emit_trace(&stderr_tx, trace_line.clone());
trace_lines.push(trace_line);
}
}
captured.push_str(&line);
captured.push('\n');
}
(captured, trace_lines)
})
});
let mut stdout = String::new();
let mut reported_usage = None;
let mut observed_model = None;
if let Some(child_stdout) = child.stdout.take() {
for line in BufReader::new(child_stdout).lines().map_while(Result::ok) {
stdout.push_str(&line);
stdout.push('\n');
if observed_model.is_none() {
if let Some(model) = observed_model_from_json_line(&line) {
push_trace(&mut trace, tx, format!("modelo observado: {model}"));
observed_model = Some(model);
}
}
if let Some(event_trace) = codex_event_trace_line(&line) {
push_trace(&mut trace, tx, event_trace);
}
if let Some(usage) = reported_usage_from_json_line(&line) {
let usage = usage.with_cost(options.pricing.as_ref());
reported_usage = Some(usage.clone());
emit_usage(tx, &usage);
push_trace(
&mut trace,
tx,
format!(
"tokens reportados: input {} · output {} · total {}",
usage.input_tokens_estimate,
usage.output_tokens_estimate,
usage.total_tokens_estimate
),
);
}
}
} else {
push_trace(&mut trace, tx, "codex stdout indisponivel para streaming");
}
let status = match child.wait() {
Ok(status) => status,
Err(e) => {
push_trace(&mut trace, tx, format!("falha ao aguardar codex exec: {e}"));
return StageResult {
stage,
exit_code: -1,
stdout,
stderr: format!("falha ao aguardar codex exec: {e}"),
duration_ms: start.elapsed().as_millis(),
trace,
agent: AgentProvenance::from_adapter(DirectAdapter::Codex, "direct-adapter"),
model: ModelProvenance::from_options(
options,
observed_model.clone(),
"codex-jsonl",
),
usage: reported_usage.unwrap_or(prompt_usage),
context: context_sample,
};
}
};
let (stderr, stderr_trace) = stderr_handle
.and_then(|handle| handle.join().ok())
.unwrap_or_default();
for item in stderr_trace {
push_trace_once(&mut trace, item);
}
push_trace(
&mut trace,
tx,
format!(
"codex exec terminou com exit {} em {}ms",
status.code().unwrap_or(-1),
codex_start.elapsed().as_millis()
),
);
if !status.success() {
let usage = reported_usage.unwrap_or(prompt_usage);
let failure = format!(
"codex exec falhou ao gerar {}:\n{}",
stage.label(),
combined_output(stdout.as_bytes(), stderr.as_bytes())
);
return StageResult {
stage,
exit_code: status.code().unwrap_or(-1),
stdout,
stderr: failure,
duration_ms: start.elapsed().as_millis(),
trace,
agent: AgentProvenance::from_adapter(DirectAdapter::Codex, "direct-adapter"),
model: ModelProvenance::from_options(options, observed_model.clone(), "codex-jsonl"),
usage,
context: context_sample,
};
}
let generated = match std::fs::read_to_string(&output_path) {
Ok(content) => strip_wrapping_markdown_fence(&content),
Err(e) => {
push_trace(
&mut trace,
tx,
format!(
"codex exec nao gravou o arquivo de saida: {}",
output_path.display()
),
);
let usage = reported_usage.unwrap_or(prompt_usage);
return StageResult {
stage,
exit_code: -1,
stdout,
stderr: format!(
"codex exec terminou, mas nao foi possivel ler {}: {e}",
output_path.display()
),
duration_ms: start.elapsed().as_millis(),
trace,
agent: AgentProvenance::from_adapter(DirectAdapter::Codex, "direct-adapter"),
model: ModelProvenance::from_options(
options,
observed_model.clone(),
"codex-jsonl",
),
usage,
context: context_sample,
};
}
};
let usage = reported_usage.unwrap_or_else(|| {
TokenUsageSample::from_text(&prompt, &generated).with_cost(options.pricing.as_ref())
});
if generated.trim().is_empty() {
push_trace(&mut trace, tx, "codex exec gerou conteudo vazio");
return StageResult {
stage,
exit_code: -1,
stdout,
stderr: "codex exec terminou, mas o artefato gerado veio vazio".to_string(),
duration_ms: start.elapsed().as_millis(),
trace,
agent: AgentProvenance::from_adapter(DirectAdapter::Codex, "direct-adapter"),
model: ModelProvenance::from_options(options, observed_model.clone(), "codex-jsonl"),
usage,
context: context_sample,
};
}
if let Err(e) = std::fs::write(&output_path, ensure_trailing_newline(&generated)) {
push_trace(
&mut trace,
tx,
format!("falha ao normalizar markdown gerado: {e}"),
);
return StageResult {
stage,
exit_code: -1,
stdout,
stderr: format!("falha ao preparar artefato gerado: {e}"),
duration_ms: start.elapsed().as_millis(),
trace,
agent: AgentProvenance::from_adapter(DirectAdapter::Codex, "direct-adapter"),
model: ModelProvenance::from_options(options, observed_model.clone(), "codex-jsonl"),
usage,
context: context_sample,
};
}
push_trace(
&mut trace,
tx,
format!("markdown gerado: {} bytes", generated.len()),
);
let mut save =
build_artifact_save_command(exe, root, name, stage, &output_path, "draft", options.force);
let save_start = Instant::now();
let save_output = match save.output() {
Ok(output) => output,
Err(e) => {
push_trace(
&mut trace,
tx,
format!("falha ao iniciar artifact save: {e}"),
);
return StageResult {
stage,
exit_code: -1,
stdout,
stderr: format!("artifact save falhou ({stage:?}): {e}"),
duration_ms: start.elapsed().as_millis(),
trace,
agent: AgentProvenance::from_adapter(DirectAdapter::Codex, "direct-adapter"),
model: ModelProvenance::from_options(
options,
observed_model.clone(),
"codex-jsonl",
),
usage,
context: context_sample,
};
}
};
push_trace(
&mut trace,
tx,
format!(
"artifact save terminou com exit {} em {}ms",
save_output.status.code().unwrap_or(-1),
save_start.elapsed().as_millis()
),
);
for item in trace_output("artifact save stdout", &save_output.stdout) {
push_trace(&mut trace, tx, item);
}
for item in trace_output("artifact save stderr", &save_output.stderr) {
push_trace(&mut trace, tx, item);
}
if save_output.status.success() {
let _ = std::fs::remove_file(&output_path);
}
StageResult {
stage,
exit_code: save_output.status.code().unwrap_or(-1),
stdout: format!("{}{}", stdout, String::from_utf8_lossy(&save_output.stdout)),
stderr: if save_output.status.success() {
format!("{}{}", stderr, String::from_utf8_lossy(&save_output.stderr))
} else {
format!(
"artifact save falhou ({stage:?}):\n{}",
combined_output(&save_output.stdout, &save_output.stderr)
)
},
duration_ms: start.elapsed().as_millis(),
trace,
agent: AgentProvenance::from_adapter(DirectAdapter::Codex, "direct-adapter"),
model: ModelProvenance::from_options(options, observed_model, "codex-jsonl"),
usage,
context: context_sample,
}
}
#[allow(clippy::too_many_arguments)]
fn run_cli_text_stage(
adapter: DirectAdapter,
exe: &Path,
stage: Stage,
name: &str,
root: &Path,
options: &StageRunOptions,
start: Instant,
tx: &Sender<StageEvent>,
) -> StageResult {
let mut trace = Vec::new();
for item in [
format!("provider selecionado: {}", adapter.provider()),
format!("adapter: {}", adapter.label()),
format!("stage: {}", stage.key()),
format!("offline no config: {}", options.offline),
format!("force overwrite: {}", options.force),
] {
push_trace(&mut trace, tx, item);
}
if let Some(model) = options.model.as_deref().filter(|value| !value.is_empty()) {
push_trace(&mut trace, tx, format!("modelo solicitado: {model}"));
}
if let Some(effort) = options.effort.as_deref().filter(|value| !value.is_empty()) {
push_trace(&mut trace, tx, format!("effort solicitado: {effort}"));
}
if let Err(message) = ensure_adapter_ready(adapter) {
push_trace(&mut trace, tx, message.clone());
return StageResult {
stage,
exit_code: -1,
stdout: String::new(),
stderr: message,
duration_ms: start.elapsed().as_millis(),
trace,
agent: AgentProvenance::from_adapter(adapter, "direct-adapter"),
model: ModelProvenance::from_options(options, None, "adapter-output-unreported"),
usage: TokenUsageSample::default(),
context: StageContextSample::default(),
};
}
let output_path = temp_output_path(stage);
push_trace(
&mut trace,
tx,
format!("saida temporaria: {}", output_path.display()),
);
let (prompt, context_sample) = build_provider_prompt(stage, name, root, options);
emit_context(tx, &context_sample);
let prompt_usage = TokenUsageSample::from_text(&prompt, "").with_cost(options.pricing.as_ref());
emit_usage(tx, &prompt_usage);
push_trace(
&mut trace,
tx,
format!(
"prompt montado: ~{} tokens de input local",
prompt_usage.input_tokens_estimate
),
);
let mut cmd = build_cli_text_command(
adapter,
root,
&prompt,
options.model.as_deref(),
options.effort.as_deref(),
CliPermissionMode::ReadOnly,
);
push_trace(
&mut trace,
tx,
cli_text_trace_command(
adapter,
root,
options.model.as_deref(),
options.effort.as_deref(),
),
);
let started = Instant::now();
let output = match cmd.output() {
Ok(output) => output,
Err(e) => {
let message = format!(
"falha ao iniciar {}: {e}. Se estiver deslogado, rode `{}`.",
adapter.command(),
adapter.login_command()
);
push_trace(&mut trace, tx, message.clone());
return StageResult {
stage,
exit_code: -1,
stdout: String::new(),
stderr: message,
duration_ms: start.elapsed().as_millis(),
trace,
agent: AgentProvenance::from_adapter(adapter, "direct-adapter"),
model: ModelProvenance::from_options(options, None, "adapter-output-unreported"),
usage: prompt_usage,
context: context_sample,
};
}
};
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
push_trace(
&mut trace,
tx,
format!(
"{} terminou com exit {} em {}ms",
adapter.command(),
output.status.code().unwrap_or(-1),
started.elapsed().as_millis()
),
);
for item in trace_output(adapter.command(), &output.stderr) {
push_trace(&mut trace, tx, item);
}
if !output.status.success() {
let usage =
TokenUsageSample::from_text(&prompt, &stdout).with_cost(options.pricing.as_ref());
let failure = format!(
"{} falhou ao gerar {}. Rode `{}` se a sessão estiver expirada:\n{}",
adapter.command(),
stage.label(),
adapter.login_command(),
combined_output(output.stdout.as_slice(), output.stderr.as_slice())
);
return StageResult {
stage,
exit_code: output.status.code().unwrap_or(-1),
stdout,
stderr: failure,
duration_ms: start.elapsed().as_millis(),
trace,
agent: AgentProvenance::from_adapter(adapter, "direct-adapter"),
model: ModelProvenance::from_options(options, None, "adapter-output-unreported"),
usage,
context: context_sample,
};
}
let generated = strip_wrapping_markdown_fence(&stdout);
let usage =
TokenUsageSample::from_text(&prompt, &generated).with_cost(options.pricing.as_ref());
emit_usage(tx, &usage);
if generated.trim().is_empty() {
push_trace(&mut trace, tx, "provider gerou conteudo vazio");
return StageResult {
stage,
exit_code: -1,
stdout,
stderr: format!(
"{} terminou, mas o artefato gerado veio vazio",
adapter.command()
),
duration_ms: start.elapsed().as_millis(),
trace,
agent: AgentProvenance::from_adapter(adapter, "direct-adapter"),
model: ModelProvenance::from_options(options, None, "adapter-output-unreported"),
usage,
context: context_sample,
};
}
if let Err(e) = std::fs::write(&output_path, ensure_trailing_newline(&generated)) {
push_trace(
&mut trace,
tx,
format!("falha ao escrever markdown gerado: {e}"),
);
return StageResult {
stage,
exit_code: -1,
stdout,
stderr: format!("falha ao preparar artefato gerado: {e}"),
duration_ms: start.elapsed().as_millis(),
trace,
agent: AgentProvenance::from_adapter(adapter, "direct-adapter"),
model: ModelProvenance::from_options(options, None, "adapter-output-unreported"),
usage,
context: context_sample,
};
}
push_trace(
&mut trace,
tx,
format!("markdown gerado: {} bytes", generated.len()),
);
let mut save =
build_artifact_save_command(exe, root, name, stage, &output_path, "draft", options.force);
let save_start = Instant::now();
let save_output = match save.output() {
Ok(output) => output,
Err(e) => {
push_trace(
&mut trace,
tx,
format!("falha ao iniciar artifact save: {e}"),
);
return StageResult {
stage,
exit_code: -1,
stdout,
stderr: format!("artifact save falhou ({stage:?}): {e}"),
duration_ms: start.elapsed().as_millis(),
trace,
agent: AgentProvenance::from_adapter(adapter, "direct-adapter"),
model: ModelProvenance::from_options(options, None, "adapter-output-unreported"),
usage,
context: context_sample,
};
}
};
push_trace(
&mut trace,
tx,
format!(
"artifact save terminou com exit {} em {}ms",
save_output.status.code().unwrap_or(-1),
save_start.elapsed().as_millis()
),
);
for item in trace_output("artifact save stdout", &save_output.stdout) {
push_trace(&mut trace, tx, item);
}
for item in trace_output("artifact save stderr", &save_output.stderr) {
push_trace(&mut trace, tx, item);
}
if save_output.status.success() {
let _ = std::fs::remove_file(&output_path);
}
StageResult {
stage,
exit_code: save_output.status.code().unwrap_or(-1),
stdout: format!("{}{}", stdout, String::from_utf8_lossy(&save_output.stdout)),
stderr: if save_output.status.success() {
format!("{}{}", stderr, String::from_utf8_lossy(&save_output.stderr))
} else {
format!(
"artifact save falhou ({stage:?}):\n{}",
combined_output(&save_output.stdout, &save_output.stderr)
)
},
duration_ms: start.elapsed().as_millis(),
trace,
agent: AgentProvenance::from_adapter(adapter, "direct-adapter"),
model: ModelProvenance::from_options(options, None, "adapter-output-unreported"),
usage,
context: context_sample,
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum CodexSandbox {
ReadOnly,
WorkspaceWrite,
}
impl CodexSandbox {
fn arg(self) -> &'static str {
match self {
CodexSandbox::ReadOnly => "read-only",
CodexSandbox::WorkspaceWrite => "workspace-write",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum CliPermissionMode {
ReadOnly,
WorkspaceWrite,
}
fn build_cli_text_command(
adapter: DirectAdapter,
root: &Path,
prompt: &str,
model: Option<&str>,
effort: Option<&str>,
mode: CliPermissionMode,
) -> Command {
let command = adapter.command();
let exe = crate::runtime::platform::find_executable(command)
.unwrap_or_else(|| PathBuf::from(command));
let mut cmd = Command::new(exe);
match adapter {
DirectAdapter::Claude => {
cmd.arg("--print")
.arg("--output-format")
.arg("text")
.arg("--permission-mode")
.arg(match mode {
CliPermissionMode::ReadOnly => "plan",
CliPermissionMode::WorkspaceWrite => "acceptEdits",
})
.arg("--add-dir")
.arg(root);
if let Some(model) = model.filter(|value| !value.trim().is_empty()) {
cmd.arg("--model").arg(model);
}
if let Some(effort) = effort.filter(|value| !value.trim().is_empty()) {
cmd.arg("--effort").arg(effort);
}
cmd.arg(prompt);
}
DirectAdapter::Opencode => {
cmd.arg("run")
.arg("--format")
.arg("default")
.arg("--dir")
.arg(root);
if let Some(model) = model.filter(|value| !value.trim().is_empty()) {
cmd.arg("--model").arg(model);
}
if let Some(variant) = effort.and_then(opencode_variant) {
cmd.arg("--variant").arg(variant);
}
if mode == CliPermissionMode::WorkspaceWrite {
cmd.arg("--dangerously-skip-permissions");
}
cmd.arg(prompt);
}
DirectAdapter::Cursor => {
cmd.arg("agent")
.arg("--print")
.arg("--output-format")
.arg("text")
.arg("--trust")
.arg("--workspace")
.arg(root);
if mode == CliPermissionMode::ReadOnly {
cmd.arg("--mode").arg("plan");
} else {
cmd.arg("--force");
}
if let Some(model) = model.filter(|value| !value.trim().is_empty()) {
cmd.arg("--model").arg(model);
}
cmd.arg(prompt);
}
DirectAdapter::Devin => {
cmd.arg("--print").arg("--permission-mode").arg(match mode {
CliPermissionMode::ReadOnly => "normal",
CliPermissionMode::WorkspaceWrite => "dangerous",
});
if let Some(model) = model.filter(|value| !value.trim().is_empty()) {
cmd.arg("--model").arg(model);
}
cmd.arg("--").arg(format!("/plan\n{prompt}"));
}
DirectAdapter::Trae => {
let workdir = if mode == CliPermissionMode::ReadOnly {
temp_headless_workdir(adapter)
} else {
root.to_path_buf()
};
let _ = fs::create_dir_all(&workdir);
cmd.arg("run")
.arg(prompt)
.arg("--working-dir")
.arg(&workdir);
if let Some(model) = model.filter(|value| !value.trim().is_empty()) {
cmd.arg("--model").arg(model);
}
cmd.current_dir(workdir);
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
return cmd;
}
DirectAdapter::Antigravity => {
cmd.arg("--add-dir").arg(root);
if mode == CliPermissionMode::ReadOnly {
cmd.arg("--sandbox");
} else {
cmd.arg("--dangerously-skip-permissions");
}
if let Some(model) = model.filter(|value| !value.trim().is_empty()) {
cmd.arg("--model").arg(model);
}
cmd.arg("--prompt").arg(prompt);
}
DirectAdapter::Codex => {}
}
cmd.current_dir(root);
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
cmd
}
fn temp_headless_workdir(adapter: DirectAdapter) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
std::env::temp_dir().join(format!(
"sdd-headless-{}-{}-{nanos}",
adapter.provider(),
std::process::id()
))
}
fn cli_text_trace_command(
adapter: DirectAdapter,
root: &Path,
model: Option<&str>,
effort: Option<&str>,
) -> String {
let mut parts = vec![
format!("cmd: {}", adapter.command()),
format!("cwd={}", root.display()),
];
parts.push(format!("adapter={}", adapter.label()));
if let Some(model) = model.filter(|value| !value.trim().is_empty()) {
parts.push(format!("model={model}"));
}
if let Some(effort) = effort.filter(|value| !value.trim().is_empty()) {
parts.push(format!("effort={effort}"));
}
parts.join(" ")
}
fn opencode_variant(effort: &str) -> Option<&'static str> {
match effort.trim().to_ascii_lowercase().as_str() {
"medium" => Some("medium"),
"high" => Some("high"),
"xhigh" | "max" => Some("max"),
_ => None,
}
}
fn ensure_adapter_ready(adapter: DirectAdapter) -> Result<(), String> {
if !command_exists(adapter.command()) {
return Err(format!(
"{} não está instalado ou não está no PATH. Instale/autentique o client e tente novamente.",
adapter.command()
));
}
match adapter {
DirectAdapter::Codex => ensure_check_contains(adapter, "codex login status", "Logged in"),
DirectAdapter::Claude => {
ensure_check_contains(adapter, "claude auth status", "\"loggedIn\": true")
}
DirectAdapter::Opencode => ensure_check_regex(
adapter,
"opencode providers list",
r"\b[1-9][0-9]* credentials?\b",
),
DirectAdapter::Cursor => ensure_check_contains(adapter, "cursor agent status", "Logged in"),
DirectAdapter::Devin => ensure_check_success(adapter, "devin auth status"),
DirectAdapter::Trae => ensure_check_success(adapter, "trae-cli show-config"),
DirectAdapter::Antigravity => ensure_check_success(adapter, "agy models"),
}
}
fn ensure_check_success(adapter: DirectAdapter, check_command: &str) -> Result<(), String> {
let output = run_check_command(check_command).ok_or_else(|| {
format!(
"não foi possível rodar `{check_command}`. Se estiver deslogado, rode `{}`.",
adapter.login_command()
)
})?;
if output.status.success() {
return Ok(());
}
Err(format!(
"{} não parece autenticado. Rode `{}` e tente novamente.",
adapter.command(),
adapter.login_command()
))
}
fn ensure_check_contains(
adapter: DirectAdapter,
check_command: &str,
expected: &str,
) -> Result<(), String> {
let output = run_check_command(check_command).ok_or_else(|| {
format!(
"não foi possível rodar `{check_command}`. Se estiver deslogado, rode `{}`.",
adapter.login_command()
)
})?;
let combined = String::from_utf8_lossy(&output.stdout).to_string()
+ "\n"
+ &String::from_utf8_lossy(&output.stderr);
if output.status.success() && combined.contains(expected) {
return Ok(());
}
Err(format!(
"{} não parece autenticado. Rode `{}` e tente novamente.",
adapter.command(),
adapter.login_command()
))
}
fn ensure_check_regex(
adapter: DirectAdapter,
check_command: &str,
pattern: &str,
) -> Result<(), String> {
let output = run_check_command(check_command).ok_or_else(|| {
format!(
"não foi possível rodar `{check_command}`. Se estiver deslogado, rode `{}`.",
adapter.login_command()
)
})?;
let combined = String::from_utf8_lossy(&output.stdout).to_string()
+ "\n"
+ &String::from_utf8_lossy(&output.stderr);
let matched = regex::Regex::new(pattern)
.map(|regex| regex.is_match(&combined))
.unwrap_or(false);
if output.status.success() && matched {
return Ok(());
}
Err(format!(
"{} não tem credencial ativa detectada. Rode `{}` e tente novamente.",
adapter.command(),
adapter.login_command()
))
}
fn run_check_command(command: &str) -> Option<std::process::Output> {
let mut parts = command.split_whitespace();
let program = parts.next()?;
Command::new(program).args(parts).output().ok()
}
fn command_exists(command: &str) -> bool {
crate::runtime::platform::executable_exists(command)
}
fn build_codex_exec_command(
root: &Path,
output_path: &Path,
prompt: &str,
model: Option<&str>,
effort: Option<&str>,
sandbox: CodexSandbox,
) -> Command {
let exe = crate::runtime::platform::find_executable("codex")
.unwrap_or_else(|| PathBuf::from("codex"));
let mut cmd = Command::new(exe);
cmd.arg("--ask-for-approval")
.arg("never")
.args(codex_effort_config_args(effort))
.arg("exec")
.arg("--json")
.arg("--skip-git-repo-check")
.arg("--ephemeral")
.arg("--sandbox")
.arg(sandbox.arg())
.arg("--cd")
.arg(root)
.arg("--output-last-message")
.arg(output_path);
if let Some(model) = model.filter(|value| should_pass_model_to_codex(value)) {
cmd.arg("--model").arg(model);
}
cmd.arg(prompt);
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
cmd
}
fn build_artifact_save_command(
exe: &Path,
root: &Path,
name: &str,
stage: Stage,
file: &Path,
state: &str,
force: bool,
) -> Command {
let mut cmd = Command::new(exe);
cmd.arg("artifact")
.arg("--root")
.arg(root)
.arg("save")
.arg(name)
.arg(stage.key())
.arg("--file")
.arg(file)
.arg("--state")
.arg(state);
if force {
cmd.arg("--force");
}
cmd.stdin(Stdio::null());
cmd
}
fn should_pass_model_to_codex(model: &str) -> bool {
let normalized = model.trim();
!normalized.is_empty()
&& normalized != "default"
&& normalized != "gpt-default"
&& !normalized.ends_with("-default")
}
fn codex_trace_command(
root: &Path,
output_path: &Path,
model: Option<&str>,
effort: Option<&str>,
sandbox: CodexSandbox,
) -> String {
let mut parts = vec!["cmd: codex exec --json".to_string()];
if let Some(effort) = effort.filter(|value| !value.trim().is_empty()) {
parts.push(format!("effort={effort}"));
}
parts.push(format!("sandbox={}", sandbox.arg()));
parts.push("--ephemeral".to_string());
parts.push(format!("cwd={}", root.display()));
parts.push(format!(
"output={}",
output_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("temp.md")
));
if let Some(model) = model.filter(|value| should_pass_model_to_codex(value)) {
parts.push(format!("model={model}"));
}
parts.join(" ")
}
fn codex_effort_config_args(effort: Option<&str>) -> Vec<String> {
effort
.filter(|value| !value.trim().is_empty())
.map(|effort| {
vec![
"--config".to_string(),
format!("model_reasoning_effort=\"{effort}\""),
]
})
.unwrap_or_default()
}
fn build_provider_prompt(
stage: Stage,
name: &str,
root: &Path,
options: &StageRunOptions,
) -> (String, StageContextSample) {
let sections = required_sections_for(stage).join(", ");
let previous_context = collect_artifact_context(options.artifact_dir.as_deref(), stage);
let provider = options.provider.as_deref().unwrap_or("auto");
let provider_model = options.model.as_deref().unwrap_or("default");
let provider_effort = options.effort.as_deref().unwrap_or("medium");
let prompt = format!(
"Voce e o subagent SDD da etapa {stage_label}.\n\
Gere novamente o artefato Markdown completo para a orquestracao \"{name}\".\n\
\n\
Regras obrigatorias:\n\
- Responda somente com Markdown do artefato final, sem explicacoes fora do artefato.\n\
- Nao use cercas de codigo Markdown no entorno da resposta.\n\
- Nao altere arquivos e nao execute comandos; produza apenas o conteudo do artefato.\n\
- Use PT-BR claro, objetivo e rico em detalhes praticos.\n\
- Substitua placeholders como \"Preencher com o subagent correspondente\" por conteudo real.\n\
- Preserve rastreabilidade, decisoes, evidencias e pendencias.\n\
- Inclua obrigatoriamente estas secoes de segundo nivel: {sections}.\n\
\n\
Metadados para Rastreabilidade:\n\
- Orquestracao: {name}\n\
- Stage: {stage_key}\n\
- Provider: {provider}\n\
- Modelo: {provider_model}\n\
- Effort: {provider_effort}\n\
- Root analisado: {root}\n\
- Modo TUI: geracao direta via provider\n\
\n\
Contexto existente dos artefatos SDD:\n\
{previous_context}\n\
\n\
Produza agora o artefato final completo.",
stage_label = stage.label(),
stage_key = stage.key(),
root = root.display()
);
let context = StageContextSample {
prompt_tokens_estimate: estimate_tokens(&prompt),
prompt_bytes: prompt.len(),
artifact_count: previous_context.artifact_count,
artifact_chars: previous_context.artifact_chars,
truncated: previous_context.truncated,
context_window_tokens: options.context_window_tokens,
};
(prompt, context)
}
struct ArtifactContext {
text: String,
artifact_count: usize,
artifact_chars: usize,
truncated: bool,
}
impl ArtifactContext {
fn empty(text: impl Into<String>) -> Self {
Self {
text: text.into(),
artifact_count: 0,
artifact_chars: 0,
truncated: false,
}
}
}
impl std::fmt::Display for ArtifactContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.text)
}
}
fn collect_artifact_context(artifact_dir: Option<&Path>, stage: Stage) -> ArtifactContext {
let Some(artifact_dir) = artifact_dir else {
return ArtifactContext::empty("(sem artefatos anteriores carregados pelo TUI)");
};
let current = Stage::ALL
.iter()
.position(|item| *item == stage)
.unwrap_or(0);
let mut context = String::new();
let mut used = 0usize;
let mut artifact_count = 0usize;
let mut truncated = false;
for (idx, item) in Stage::ALL.iter().enumerate() {
if idx > current {
break;
}
let path = artifact_dir.join(item.filename());
if !path.exists() {
continue;
}
let Ok(content) = std::fs::read_to_string(&path) else {
continue;
};
let remaining = 24_000usize.saturating_sub(used);
if remaining == 0 {
truncated = true;
break;
}
let max_chars = remaining.min(6_000);
let was_clipped = content.chars().count() > max_chars;
let clipped = clip_chars(&content, max_chars);
used += clipped.chars().count();
artifact_count += 1;
truncated |= was_clipped;
context.push_str(&format!(
"\n---\nArquivo: {}\n{}\n",
item.filename(),
clipped
));
}
if context.trim().is_empty() {
ArtifactContext::empty("(sem artefatos anteriores salvos ainda)")
} else {
ArtifactContext {
text: context,
artifact_count,
artifact_chars: used,
truncated,
}
}
}
pub(crate) fn required_sections_for(stage: Stage) -> &'static [&'static str] {
match stage {
Stage::Idea => &[
"Rastreabilidade",
"Problema",
"Objetivo",
"Usuários",
"Critérios de sucesso",
],
Stage::Prd => &[
"Rastreabilidade",
"Resumo",
"Objetivos",
"Não objetivos",
"Usuários",
"Requisitos",
"Critérios de aceite",
"Métricas",
"Riscos",
],
Stage::Techspec => &[
"Rastreabilidade",
"Visão técnica",
"Arquitetura",
"Contratos",
"Dados",
"Segurança",
"Performance",
"Observabilidade",
"Testes",
"Riscos",
"Plano de implementação",
],
Stage::Tasks => &[
"Rastreabilidade",
"Backlog",
"Dependências",
"Critérios de aceite",
"Ordem sugerida",
],
Stage::Refinement => &[
"Rastreabilidade",
"Resumo",
"Solução proposta",
"Pontos de observação",
"Checklist",
"Subtasks",
"Definition of Done",
"Flags e configurações",
],
Stage::Execution => &[
"Rastreabilidade",
"Tarefa",
"Resumo da implementação",
"Arquivos alterados",
"Testes e evidências",
"Riscos e pendências",
],
Stage::Adr => &[
"Rastreabilidade",
"Status",
"Contexto",
"Problema",
"Decisão",
"Alternativas consideradas",
"Consequências",
"Impactos e riscos",
"Plano de adoção",
"Plano de reversão",
"Evidências",
"Histórico de revisão",
],
Stage::Review => &[
"Rastreabilidade",
"Escopo revisado",
"Evidências",
"Achados",
"Testes",
"Veredito",
],
Stage::Memory => &[
"Estado final",
"Rastreabilidade",
"Decisões",
"Padrões úteis",
"Testes e comandos",
"Pendências",
"Contexto para o próximo agente",
],
}
}
fn temp_output_path(stage: Stage) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_nanos())
.unwrap_or(0);
std::env::temp_dir().join(format!(
"sdd-tui-{}-{}-{nanos}.md",
std::process::id(),
stage.key()
))
}
fn strip_wrapping_markdown_fence(input: &str) -> String {
let trimmed = input.trim();
let mut lines: Vec<&str> = trimmed.lines().collect();
if lines.len() >= 2
&& lines.first().is_some_and(|line| {
let line = line.trim();
line == "```" || line.eq_ignore_ascii_case("```markdown") || line == "```md"
})
&& lines.last().is_some_and(|line| line.trim() == "```")
{
lines.remove(0);
lines.pop();
return ensure_trailing_newline(lines.join("\n").trim());
}
ensure_trailing_newline(trimmed)
}
fn ensure_trailing_newline(input: &str) -> String {
let mut out = input.to_string();
if !out.ends_with('\n') {
out.push('\n');
}
out
}
fn clip_chars(input: &str, max: usize) -> String {
if input.chars().count() <= max {
return input.to_string();
}
let mut out: String = input.chars().take(max).collect();
out.push_str("\n\n[conteudo truncado para caber no prompt]");
out
}
fn trace_output(label: &str, bytes: &[u8]) -> Vec<String> {
let text = String::from_utf8_lossy(bytes);
let mut lines = text
.lines()
.filter(|line| !line.trim().is_empty())
.take(4)
.map(|line| format!("{label}: {}", redact_text(line)))
.collect::<Vec<_>>();
if text.lines().filter(|line| !line.trim().is_empty()).count() > 4 {
lines.push(format!("{label}: ..."));
}
lines
}
fn combined_output(stdout: &[u8], stderr: &[u8]) -> String {
let stdout = String::from_utf8_lossy(stdout);
let stderr = String::from_utf8_lossy(stderr);
let combined = format!("stdout:\n{}\n\nstderr:\n{}", stdout.trim(), stderr.trim());
redact_text(&combined)
}
pub fn spawn_parallel_agent(
exe: PathBuf,
stage: Stage,
orchestration_name: String,
task: crate::tui::parallel::TaskRef,
root: PathBuf,
fragment_path: PathBuf,
options: StageRunOptions,
) -> Receiver<StageEvent> {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let start = Instant::now();
let result = if let Some(adapter) = direct_generation_adapter(&options) {
match adapter {
DirectAdapter::Codex => run_codex_parallel_fragment(
stage,
&orchestration_name,
&task,
&root,
&fragment_path,
&options,
start,
&tx,
),
_ => run_cli_parallel_fragment(
adapter,
stage,
&orchestration_name,
&task,
&root,
&fragment_path,
&options,
start,
&tx,
),
}
} else {
run_deterministic_parallel_fragment(
&exe,
stage,
&orchestration_name,
&task,
&root,
&fragment_path,
&options,
start,
&tx,
)
};
let _ = tx.send(StageEvent::Finished(result));
});
rx
}
pub fn spawn_execution_task(
stage: Stage,
orchestration_name: String,
task: ExecutionTask,
root: PathBuf,
report_path: PathBuf,
options: StageRunOptions,
) -> Receiver<StageEvent> {
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let start = Instant::now();
let provider = options.provider.as_deref().unwrap_or("auto");
let result = if provider_supports_workspace_execution(provider) {
run_codex_execution_task(
stage,
&orchestration_name,
&task,
&root,
&report_path,
&options,
start,
&tx,
)
} else {
run_unsupported_execution_task(
stage,
&orchestration_name,
&task,
&root,
&options,
start,
&tx,
)
};
let _ = tx.send(StageEvent::Finished(result));
});
rx
}
#[allow(clippy::too_many_arguments)]
fn run_codex_execution_task(
stage: Stage,
orchestration_name: &str,
task: &ExecutionTask,
root: &Path,
report_path: &Path,
options: &StageRunOptions,
start: Instant,
tx: &Sender<StageEvent>,
) -> StageResult {
let mut trace = Vec::new();
let agent = AgentProvenance::from_adapter(DirectAdapter::Codex, "workspace-execution-adapter");
let requested_model = ModelProvenance::from_options(options, None, "codex-jsonl-unreported");
record_execution_trace_event(
root,
orchestration_name,
stage,
task,
"started",
options.provider.as_deref().unwrap_or("codex"),
start.elapsed().as_millis(),
None,
None,
&agent,
&requested_model,
None,
);
for item in [
"provider selecionado: codex".to_string(),
"adapter: codex exec JSONL para execução real de task".to_string(),
format!("stage: {}", stage.key()),
format!("task: {} — {}", task.id, task.title),
"sandbox: workspace-write".to_string(),
] {
push_trace(&mut trace, tx, item);
}
prepare_codex_skill_surface(root, &mut trace, tx);
if let Some(parent) = report_path.parent() {
if let Err(e) = fs::create_dir_all(parent) {
record_execution_trace_event(
root,
orchestration_name,
stage,
task,
"error",
"codex",
start.elapsed().as_millis(),
None,
None,
&agent,
&requested_model,
Some(&format!(
"falha ao criar diretório do relatório da task: {e}"
)),
);
return execution_error_result(
stage,
start,
trace,
format!("falha ao criar diretório do relatório da task: {e}"),
TokenUsageSample::default(),
StageContextSample::default(),
ResultProvenance {
agent,
model: requested_model,
},
);
}
}
let (prompt, context) =
build_execution_task_prompt(stage, orchestration_name, task, root, options);
emit_context(tx, &context);
let prompt_usage = TokenUsageSample::from_text(&prompt, "").with_cost(options.pricing.as_ref());
emit_usage(tx, &prompt_usage);
push_trace(
&mut trace,
tx,
format!(
"prompt da task montado: ~{} tokens de input local",
prompt_usage.input_tokens_estimate
),
);
let mut cmd = build_codex_exec_command(
root,
report_path,
&prompt,
options.model.as_deref(),
options.effort.as_deref(),
CodexSandbox::WorkspaceWrite,
);
push_trace(
&mut trace,
tx,
redact_text(&codex_trace_command(
root,
report_path,
options.model.as_deref(),
options.effort.as_deref(),
CodexSandbox::WorkspaceWrite,
)),
);
let started = Instant::now();
let output = match cmd.output() {
Ok(output) => output,
Err(e) => {
record_execution_trace_event(
root,
orchestration_name,
stage,
task,
"error",
"codex",
start.elapsed().as_millis(),
Some(&prompt_usage),
Some(&context),
&agent,
&requested_model,
Some(&format!(
"falha ao iniciar codex exec para {}: {e}",
task.id
)),
);
return execution_error_result(
stage,
start,
trace,
format!("falha ao iniciar codex exec para {}: {e}", task.id),
prompt_usage,
context,
ResultProvenance {
agent,
model: requested_model,
},
);
}
};
push_trace(
&mut trace,
tx,
format!(
"codex exec da task terminou com exit {} em {}ms",
output.status.code().unwrap_or(-1),
started.elapsed().as_millis()
),
);
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
for line in stdout.lines().filter_map(codex_event_trace_line).take(24) {
push_trace_once(&mut trace, line);
}
for item in trace_output("codex stderr", &output.stderr) {
push_trace_once(&mut trace, item);
}
let usage = reported_usage_from_jsonl(output.stdout.as_slice())
.map(|usage| usage.with_cost(options.pricing.as_ref()))
.unwrap_or(prompt_usage);
let model = ModelProvenance::from_options(
options,
observed_model_from_jsonl(output.stdout.as_slice()),
"codex-jsonl",
);
emit_usage(tx, &usage);
if !output.status.success() {
record_execution_trace_event(
root,
orchestration_name,
stage,
task,
"failed",
"codex",
start.elapsed().as_millis(),
Some(&usage),
Some(&context),
&agent,
&model,
Some(&format!(
"codex exec falhou na task {} com exit {}",
task.id,
output.status.code().unwrap_or(-1)
)),
);
return StageResult {
stage,
exit_code: output.status.code().unwrap_or(-1),
stdout,
stderr: format!(
"codex exec falhou na task {}:\n{}",
task.id,
combined_output(output.stdout.as_slice(), output.stderr.as_slice())
),
duration_ms: start.elapsed().as_millis(),
trace,
agent,
model: model.clone(),
usage,
context,
};
}
let report = match fs::read_to_string(report_path) {
Ok(content) => strip_wrapping_markdown_fence(&content),
Err(e) => {
record_execution_trace_event(
root,
orchestration_name,
stage,
task,
"error",
"codex",
start.elapsed().as_millis(),
Some(&usage),
Some(&context),
&agent,
&model,
Some(&format!(
"codex exec terminou, mas não gravou relatório da task em {}: {e}",
report_path.display()
)),
);
return execution_error_result(
stage,
start,
trace,
format!(
"codex exec terminou, mas não gravou relatório da task em {}: {e}",
report_path.display()
),
usage,
context,
ResultProvenance { agent, model },
);
}
};
if report.trim().is_empty() {
record_execution_trace_event(
root,
orchestration_name,
stage,
task,
"error",
"codex",
start.elapsed().as_millis(),
Some(&usage),
Some(&context),
&agent,
&model,
Some(&format!(
"codex exec gerou relatório vazio para {}",
task.id
)),
);
return execution_error_result(
stage,
start,
trace,
format!("codex exec gerou relatório vazio para {}", task.id),
usage,
context,
ResultProvenance { agent, model },
);
}
if let Err(e) = fs::write(report_path, ensure_trailing_newline(&report)) {
record_execution_trace_event(
root,
orchestration_name,
stage,
task,
"error",
"codex",
start.elapsed().as_millis(),
Some(&usage),
Some(&context),
&agent,
&model,
Some(&format!(
"falha ao normalizar relatório da task {}: {e}",
task.id
)),
);
return execution_error_result(
stage,
start,
trace,
format!("falha ao normalizar relatório da task {}: {e}", task.id),
usage,
context,
ResultProvenance { agent, model },
);
}
push_trace(
&mut trace,
tx,
format!(
"relatório da task {} gravado: {} bytes",
task.id,
report.len()
),
);
record_execution_trace_event(
root,
orchestration_name,
stage,
task,
"succeeded",
"codex",
start.elapsed().as_millis(),
Some(&usage),
Some(&context),
&agent,
&model,
None,
);
StageResult {
stage,
exit_code: output.status.code().unwrap_or(0),
stdout,
stderr,
duration_ms: start.elapsed().as_millis(),
trace,
agent,
model,
usage,
context,
}
}
fn run_unsupported_execution_task(
stage: Stage,
orchestration_name: &str,
task: &ExecutionTask,
root: &Path,
options: &StageRunOptions,
start: Instant,
tx: &Sender<StageEvent>,
) -> StageResult {
let provider = options.provider.as_deref().unwrap_or("auto");
let message = format!(
"execução real de tasks ainda não é suportada para provider `{provider}`; selecione `codex` para editar o workspace"
);
emit_trace(tx, message.clone());
let agent = AgentProvenance::unsupported(options, "workspace-execution-blocked");
let model = ModelProvenance::from_options(options, None, "execution-adapter-unreported");
record_execution_trace_event(
root,
orchestration_name,
stage,
task,
"failed",
provider,
start.elapsed().as_millis(),
None,
None,
&agent,
&model,
Some(&message),
);
StageResult {
stage,
exit_code: -1,
stdout: String::new(),
stderr: format!("{} — task {}", message, task.id),
duration_ms: start.elapsed().as_millis(),
trace: vec![message],
agent,
model,
usage: TokenUsageSample::from_text(&execution_task_input(task), "")
.with_cost(options.pricing.as_ref()),
context: StageContextSample::default(),
}
}
#[allow(clippy::too_many_arguments)]
fn record_execution_trace_event(
root: &Path,
orchestration_name: &str,
stage: Stage,
task: &ExecutionTask,
status: &str,
provider: &str,
duration_ms: u128,
usage: Option<&TokenUsageSample>,
context: Option<&StageContextSample>,
agent_provenance: &AgentProvenance,
model: &ModelProvenance,
error: Option<&str>,
) {
let run_id = crate::runtime::trace::make_run_id(&format!(
"{}:{}:{}",
orchestration_name,
stage.key(),
task.id
));
let payload = json!({
"schema_version": crate::runtime::trace::TRACE_SCHEMA_VERSION,
"event_id": crate::runtime::trace::make_event_id(&format!(
"{}:{}:{}:{}:{}",
crate::now_public(),
orchestration_name,
stage.key(),
task.id,
status
)),
"ts": crate::now_public(),
"run_id": run_id,
"orchestration": orchestration_name,
"stage": stage.key(),
"task_id": task.id,
"provider": provider,
"agent": agent_provenance.agent_id_option(),
"kind": "execution_task",
"status": status,
"duration_ms": duration_ms,
"usage": usage,
"context": context,
"model": model,
"error": error,
"agent_provenance": agent_provenance,
"title": task.title,
"independent": task.independent,
"dependencies": task.dependencies
});
let _ = crate::runtime::trace::record_event(root, "runs.jsonl", payload);
}
#[allow(clippy::too_many_arguments)]
fn run_codex_parallel_fragment(
stage: Stage,
orchestration_name: &str,
task: &crate::tui::parallel::TaskRef,
root: &Path,
fragment_path: &Path,
options: &StageRunOptions,
start: Instant,
tx: &Sender<StageEvent>,
) -> StageResult {
let mut trace = Vec::new();
let agent = AgentProvenance::from_adapter(DirectAdapter::Codex, "parallel-direct-adapter");
let requested_model = ModelProvenance::from_options(options, None, "codex-jsonl-unreported");
for item in [
"provider selecionado: codex".to_string(),
"adapter: codex exec JSONL para fragmento paralelo".to_string(),
format!("stage: {}", stage.key()),
format!("agent slot: {}", task.id),
] {
push_trace(&mut trace, tx, item);
}
prepare_codex_skill_surface(root, &mut trace, tx);
if let Some(parent) = fragment_path.parent() {
if let Err(e) = fs::create_dir_all(parent) {
push_trace(
&mut trace,
tx,
format!("falha ao criar diretório do fragmento paralelo: {e}"),
);
return parallel_error_result(
stage,
start,
trace,
format!("falha ao criar {}: {e}", parent.display()),
TokenUsageSample::default(),
StageContextSample::default(),
ResultProvenance {
agent,
model: requested_model,
},
);
}
}
let (prompt, context) =
build_parallel_provider_prompt(stage, orchestration_name, task, root, options);
emit_context(tx, &context);
let prompt_usage = TokenUsageSample::from_text(&prompt, "").with_cost(options.pricing.as_ref());
emit_usage(tx, &prompt_usage);
push_trace(
&mut trace,
tx,
format!(
"prompt paralelo montado: ~{} tokens de input local",
prompt_usage.input_tokens_estimate
),
);
let mut cmd = build_codex_exec_command(
root,
fragment_path,
&prompt,
options.model.as_deref(),
options.effort.as_deref(),
CodexSandbox::ReadOnly,
);
push_trace(
&mut trace,
tx,
redact_text(&codex_trace_command(
root,
fragment_path,
options.model.as_deref(),
options.effort.as_deref(),
CodexSandbox::ReadOnly,
)),
);
match cmd.output() {
Ok(out) if out.status.success() => {
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
let generated = match fs::read_to_string(fragment_path) {
Ok(content) => strip_wrapping_markdown_fence(&content),
Err(e) => {
return parallel_error_result(
stage,
start,
trace,
format!(
"codex exec não gravou fragmento {}: {e}",
fragment_path.display()
),
prompt_usage,
context,
ResultProvenance {
agent,
model: requested_model,
},
);
}
};
if let Err(e) = fs::write(fragment_path, ensure_trailing_newline(&generated)) {
return parallel_error_result(
stage,
start,
trace,
format!(
"falha ao normalizar fragmento {}: {e}",
fragment_path.display()
),
prompt_usage,
context,
ResultProvenance {
agent,
model: requested_model,
},
);
}
let usage = reported_usage_from_jsonl(out.stdout.as_slice())
.map(|usage| usage.with_cost(options.pricing.as_ref()))
.unwrap_or_else(|| {
TokenUsageSample::from_text(&prompt, &generated)
.with_cost(options.pricing.as_ref())
});
let model = ModelProvenance::from_options(
options,
observed_model_from_jsonl(out.stdout.as_slice()),
"codex-jsonl",
);
emit_usage(tx, &usage);
push_trace(
&mut trace,
tx,
format!(
"fragmento paralelo {} gravado: {} bytes",
task.id,
generated.len()
),
);
StageResult {
stage,
exit_code: out.status.code().unwrap_or(0),
stdout,
stderr,
duration_ms: start.elapsed().as_millis(),
trace,
agent,
model,
usage,
context,
}
}
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let mut result = parallel_error_result(
stage,
start,
trace,
format!(
"codex exec falhou no AgentSlot {}:\n{}",
task.id,
combined_output(out.stdout.as_slice(), out.stderr.as_slice())
),
reported_usage_from_jsonl(out.stdout.as_slice())
.map(|usage| usage.with_cost(options.pricing.as_ref()))
.unwrap_or(prompt_usage),
context,
ResultProvenance {
agent,
model: ModelProvenance::from_options(options, None, "codex-jsonl-unreported"),
},
);
result.stdout = stdout;
result.exit_code = out.status.code().unwrap_or(-1);
result
}
Err(e) => parallel_error_result(
stage,
start,
trace,
format!("falha ao iniciar codex exec no AgentSlot {}: {e}", task.id),
prompt_usage,
context,
ResultProvenance {
agent,
model: requested_model,
},
),
}
}
#[allow(clippy::too_many_arguments)]
fn run_cli_parallel_fragment(
adapter: DirectAdapter,
stage: Stage,
orchestration_name: &str,
task: &crate::tui::parallel::TaskRef,
root: &Path,
fragment_path: &Path,
options: &StageRunOptions,
start: Instant,
tx: &Sender<StageEvent>,
) -> StageResult {
let mut trace = Vec::new();
let agent = AgentProvenance::from_adapter(adapter, "parallel-direct-adapter");
let requested_model = ModelProvenance::from_options(options, None, "adapter-output-unreported");
for item in [
format!("provider selecionado: {}", adapter.provider()),
format!("adapter: {} para fragmento paralelo", adapter.label()),
format!("stage: {}", stage.key()),
format!("agent slot: {}", task.id),
] {
push_trace(&mut trace, tx, item);
}
if let Err(message) = ensure_adapter_ready(adapter) {
push_trace(&mut trace, tx, message.clone());
return parallel_error_result(
stage,
start,
trace,
message,
TokenUsageSample::default(),
StageContextSample::default(),
ResultProvenance {
agent,
model: requested_model,
},
);
}
if let Some(parent) = fragment_path.parent() {
if let Err(e) = fs::create_dir_all(parent) {
return parallel_error_result(
stage,
start,
trace,
format!("falha ao criar {}: {e}", parent.display()),
TokenUsageSample::default(),
StageContextSample::default(),
ResultProvenance {
agent,
model: requested_model,
},
);
}
}
let (prompt, context) =
build_parallel_provider_prompt(stage, orchestration_name, task, root, options);
emit_context(tx, &context);
let prompt_usage = TokenUsageSample::from_text(&prompt, "").with_cost(options.pricing.as_ref());
emit_usage(tx, &prompt_usage);
push_trace(
&mut trace,
tx,
format!(
"prompt paralelo montado: ~{} tokens de input local",
prompt_usage.input_tokens_estimate
),
);
let mut cmd = build_cli_text_command(
adapter,
root,
&prompt,
options.model.as_deref(),
options.effort.as_deref(),
CliPermissionMode::ReadOnly,
);
push_trace(
&mut trace,
tx,
cli_text_trace_command(
adapter,
root,
options.model.as_deref(),
options.effort.as_deref(),
),
);
match cmd.output() {
Ok(out) if out.status.success() => {
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
let generated = strip_wrapping_markdown_fence(&stdout);
if generated.trim().is_empty() {
return parallel_error_result(
stage,
start,
trace,
format!(
"{} gerou fragmento vazio para {}",
adapter.command(),
task.id
),
prompt_usage,
context,
ResultProvenance {
agent,
model: requested_model,
},
);
}
if let Err(e) = fs::write(fragment_path, ensure_trailing_newline(&generated)) {
return parallel_error_result(
stage,
start,
trace,
format!("falha ao gravar fragmento {}: {e}", fragment_path.display()),
prompt_usage,
context,
ResultProvenance {
agent,
model: requested_model,
},
);
}
let usage = TokenUsageSample::from_text(&prompt, &generated)
.with_cost(options.pricing.as_ref());
emit_usage(tx, &usage);
push_trace(
&mut trace,
tx,
format!(
"fragmento paralelo {} gravado: {} bytes",
task.id,
generated.len()
),
);
StageResult {
stage,
exit_code: out.status.code().unwrap_or(0),
stdout,
stderr,
duration_ms: start.elapsed().as_millis(),
trace,
agent,
model: requested_model,
usage,
context,
}
}
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let mut result = parallel_error_result(
stage,
start,
trace,
format!(
"{} falhou no AgentSlot {}. Rode `{}` se a sessão estiver expirada:\n{}",
adapter.command(),
task.id,
adapter.login_command(),
combined_output(out.stdout.as_slice(), out.stderr.as_slice())
),
TokenUsageSample::from_text(&prompt, &stdout).with_cost(options.pricing.as_ref()),
context,
ResultProvenance {
agent,
model: requested_model,
},
);
result.stdout = stdout;
result.exit_code = out.status.code().unwrap_or(-1);
result
}
Err(e) => parallel_error_result(
stage,
start,
trace,
format!(
"falha ao iniciar {} no AgentSlot {}: {e}. Se estiver deslogado, rode `{}`.",
adapter.command(),
task.id,
adapter.login_command()
),
prompt_usage,
context,
ResultProvenance {
agent,
model: requested_model,
},
),
}
}
#[allow(clippy::too_many_arguments)]
fn run_deterministic_parallel_fragment(
exe: &Path,
stage: Stage,
orchestration_name: &str,
task: &crate::tui::parallel::TaskRef,
root: &Path,
fragment_path: &Path,
options: &StageRunOptions,
start: Instant,
tx: &Sender<StageEvent>,
) -> StageResult {
let mut trace = deterministic_trace(stage, options);
emit_trace_lines(tx, &trace);
push_trace(
&mut trace,
tx,
format!("agent slot paralelo: {} — {}", task.id, task.title),
);
let mut cmd = build_parallel_probe_command(exe, stage, orchestration_name, task, root, options);
let output = match cmd.output() {
Ok(output) => output,
Err(e) => {
let usage = TokenUsageSample::from_text(¶llel_task_input(task), "")
.with_cost(options.pricing.as_ref());
push_trace(
&mut trace,
tx,
format!("falha ao iniciar probe paralelo {}: {e}", task.id),
);
return StageResult {
stage,
exit_code: -1,
stdout: String::new(),
stderr: format!("falha ao iniciar subprocesso para {}: {e}", task.id),
duration_ms: start.elapsed().as_millis(),
trace,
agent: AgentProvenance::from_harness(options, "sdd-parallel-harness"),
model: ModelProvenance::from_options(options, None, "sdd-harness-unreported"),
usage,
context: StageContextSample::default(),
};
}
};
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
let exit_code = output.status.code().unwrap_or(-1);
if output.status.success() {
if let Some(parent) = fragment_path.parent() {
let _ = fs::create_dir_all(parent);
}
let fragment = deterministic_parallel_fragment(stage, task, &stdout, &stderr);
if let Err(e) = fs::write(fragment_path, fragment.as_bytes()) {
push_trace(
&mut trace,
tx,
format!("falha ao gravar fragmento paralelo {}: {e}", task.id),
);
return StageResult {
stage,
exit_code: -1,
stdout,
stderr: format!("falha ao gravar fragmento {}: {e}", fragment_path.display()),
duration_ms: start.elapsed().as_millis(),
trace,
agent: AgentProvenance::from_harness(options, "sdd-parallel-harness"),
model: ModelProvenance::from_options(options, None, "sdd-harness-unreported"),
usage: TokenUsageSample::from_text(¶llel_task_input(task), "")
.with_cost(options.pricing.as_ref()),
context: StageContextSample::default(),
};
}
push_trace(
&mut trace,
tx,
format!(
"fragmento paralelo {} gravado em {}",
task.id,
fragment_path.display()
),
);
} else {
push_trace(
&mut trace,
tx,
format!("probe paralelo {} terminou com exit {exit_code}", task.id),
);
}
let fragment_or_output = if output.status.success() {
fs::read_to_string(fragment_path).unwrap_or_default()
} else {
format!("{stdout}\n{stderr}")
};
let usage = TokenUsageSample::from_text(¶llel_task_input(task), &fragment_or_output)
.with_cost(options.pricing.as_ref());
emit_usage(tx, &usage);
StageResult {
stage,
exit_code,
stdout,
stderr,
duration_ms: start.elapsed().as_millis(),
trace,
agent: AgentProvenance::from_harness(options, "sdd-parallel-harness"),
model: ModelProvenance::from_options(options, None, "sdd-harness-unreported"),
usage,
context: StageContextSample::default(),
}
}
fn build_parallel_probe_command(
exe: &Path,
stage: Stage,
name: &str,
task: &crate::tui::parallel::TaskRef,
root: &Path,
options: &StageRunOptions,
) -> Command {
let mut cmd = Command::new(exe);
cmd.arg(stage.key())
.arg("--name")
.arg(name)
.arg("--root")
.arg(root);
if let Some(provider) = options
.provider
.as_deref()
.filter(|value| !value.is_empty())
{
cmd.arg("--provider").arg(provider);
}
if let Some(model) = options.model.as_deref().filter(|value| !value.is_empty()) {
cmd.arg("--model").arg(model);
}
if let Some(effort) = options.effort.as_deref().filter(|value| !value.is_empty()) {
cmd.arg("--effort").arg(effort);
}
if options.offline {
cmd.arg("--offline");
}
cmd.arg("--dry-run").arg(parallel_task_input(task));
cmd.stdin(Stdio::null());
cmd
}
fn parallel_task_input(task: &crate::tui::parallel::TaskRef) -> String {
format!("{} — {}", task.id, task.title)
}
fn execution_task_input(task: &ExecutionTask) -> String {
format!("{} — {}\n\n{}", task.id, task.title, task.body)
}
fn deterministic_parallel_fragment(
stage: Stage,
task: &crate::tui::parallel::TaskRef,
stdout: &str,
stderr: &str,
) -> String {
let mut out = format!(
"#### AgentSlot {}\n\n- Etapa: `{}`\n- Task: `{}` — {}\n- Modo: fallback determinístico com probe `--dry-run`.\n",
task.id,
stage.key(),
task.id,
task.title
);
let stdout = stdout.trim();
if !stdout.is_empty() {
out.push_str(&format!("- Stdout: `{}`\n", truncate_trace(stdout, 160)));
}
let stderr = stderr.trim();
if !stderr.is_empty() {
out.push_str(&format!("- Stderr: `{}`\n", truncate_trace(stderr, 160)));
}
out.push('\n');
out
}
fn build_execution_task_prompt(
stage: Stage,
name: &str,
task: &ExecutionTask,
root: &Path,
options: &StageRunOptions,
) -> (String, StageContextSample) {
let previous_context = collect_artifact_context(options.artifact_dir.as_deref(), stage);
let provider_model = options.model.as_deref().unwrap_or("default");
let provider_effort = options.effort.as_deref().unwrap_or("medium");
let dependencies = if task.dependencies.is_empty() {
"nenhuma dependência declarada".to_string()
} else {
task.dependencies.join(", ")
};
let prompt = format!(
"Você é o subagent SDD de Execution executando UMA task real no workspace.\n\
Orquestração: {name}\n\
Task ID: {task_id}\n\
Task: {task_title}\n\
Dependências declaradas: {dependencies}\n\
Root do projeto: {root}\n\
Modelo: {provider_model}\n\
Effort: {provider_effort}\n\
\n\
Contrato operacional obrigatório:\n\
- Inspecione o código real antes de editar.\n\
- Altere os arquivos necessários para concluir somente esta task.\n\
- Rode validações focadas quando fizer sentido para a task e a stack do projeto.\n\
- Preserve alterações locais existentes; não reverta trabalho que você não criou.\n\
- Não gere o artefato final `06-execution.md`; a TUI fará isso após todas as tasks.\n\
- Ao final, responda somente com um relatório Markdown da task, sem cercas no entorno.\n\
- O relatório deve listar: resumo, arquivos alterados, comandos executados, evidências, decisões e pendências.\n\
\n\
Descrição completa da task:\n\
{task_body}\n\
\n\
Contexto SDD disponível:\n\
{previous_context}\n\
\n\
Execute a task agora no workspace e termine com o relatório Markdown.",
task_id = task.id,
task_title = task.title,
task_body = if task.body.trim().is_empty() {
"(sem corpo detalhado no backlog)"
} else {
task.body.trim()
},
root = root.display(),
);
let context = StageContextSample {
prompt_tokens_estimate: estimate_tokens(&prompt),
prompt_bytes: prompt.len(),
artifact_count: previous_context.artifact_count,
artifact_chars: previous_context.artifact_chars,
truncated: previous_context.truncated,
context_window_tokens: options.context_window_tokens,
};
(prompt, context)
}
fn build_parallel_provider_prompt(
stage: Stage,
name: &str,
task: &crate::tui::parallel::TaskRef,
root: &Path,
options: &StageRunOptions,
) -> (String, StageContextSample) {
let sections = required_sections_for(stage).join(", ");
let previous_context = collect_artifact_context(options.artifact_dir.as_deref(), stage);
let prompt = format!(
"Você é um subagent SDD da etapa {stage_label}, executando apenas um AgentSlot.\n\
Orquestração: {name}\n\
Task ID: {task_id}\n\
Task: {task_title}\n\
\n\
Regras obrigatórias:\n\
- Responda somente com Markdown de fragmento, sem cercas no entorno.\n\
- Não altere arquivos e não execute comandos; produza apenas o fragmento deste AgentSlot.\n\
- Use PT-BR claro, com rastreabilidade, evidências, decisões e pendências da task.\n\
- O artefato consolidado final terá estas seções obrigatórias: {sections}.\n\
\n\
Root analisado: {root}\n\
Contexto existente dos artefatos SDD:\n\
{previous_context}\n\
\n\
Produza agora o fragmento do AgentSlot.",
stage_label = stage.label(),
task_id = task.id,
task_title = task.title,
root = root.display(),
);
let context = StageContextSample {
prompt_tokens_estimate: estimate_tokens(&prompt),
prompt_bytes: prompt.len(),
artifact_count: previous_context.artifact_count,
artifact_chars: previous_context.artifact_chars,
truncated: previous_context.truncated,
context_window_tokens: options.context_window_tokens,
};
(prompt, context)
}
fn parallel_error_result(
stage: Stage,
start: Instant,
trace: Vec<String>,
stderr: String,
usage: TokenUsageSample,
context: StageContextSample,
provenance: ResultProvenance,
) -> StageResult {
StageResult {
stage,
exit_code: -1,
stdout: String::new(),
stderr,
duration_ms: start.elapsed().as_millis(),
trace,
agent: provenance.agent,
model: provenance.model,
usage,
context,
}
}
fn execution_error_result(
stage: Stage,
start: Instant,
trace: Vec<String>,
stderr: String,
usage: TokenUsageSample,
context: StageContextSample,
provenance: ResultProvenance,
) -> StageResult {
StageResult {
stage,
exit_code: -1,
stdout: String::new(),
stderr,
duration_ms: start.elapsed().as_millis(),
trace,
agent: provenance.agent,
model: provenance.model,
usage,
context,
}
}
pub fn run_init(exe: &Path, root: &Path, idea: &str) -> std::io::Result<std::process::Output> {
Command::new(exe)
.arg("init")
.arg(idea)
.arg("--root")
.arg(root)
.stdin(Stdio::null())
.output()
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_program_is(cmd: &Command, expected: &str) {
let program = cmd.get_program();
let stem = Path::new(program)
.file_stem()
.map(|s| s.to_string_lossy().to_lowercase())
.unwrap_or_default();
assert_eq!(
stem,
expected.to_lowercase(),
"programa esperado '{expected}', obtido '{}'",
program.to_string_lossy()
);
}
#[test]
fn build_command_uses_vectorized_args_no_shell() {
let exe = PathBuf::from("/usr/bin/sdd");
let cmd = build_stage_command(
&exe,
Stage::Idea,
"Minha Feature",
Path::new("."),
Some("uma ideia; rm -rf /"),
&StageRunOptions::default(),
);
assert_eq!(cmd.get_program(), exe.as_os_str());
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert_eq!(args[0], "idea");
assert!(args.contains(&"--name".to_string()));
assert!(args.contains(&"Minha Feature".to_string()));
assert!(args.contains(&"uma ideia; rm -rf /".to_string()));
assert!(!args.iter().any(|a| a == "-c"), "não deve haver shell -c");
}
#[test]
fn build_command_passes_provider_model_offline_and_force() {
let exe = PathBuf::from("/usr/bin/sdd");
let options = StageRunOptions {
provider: Some("claude".to_string()),
model: Some("claude-test".to_string()),
effort: Some("xhigh".to_string()),
offline: true,
force: true,
artifact_dir: None,
context_window_tokens: None,
pricing: None,
};
let cmd = build_stage_command(
&exe,
Stage::Prd,
"Minha Feature",
Path::new("/tmp/proj"),
None,
&options,
);
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert!(args.windows(2).any(|w| w == ["--provider", "claude"]));
assert!(args.windows(2).any(|w| w == ["--model", "claude-test"]));
assert!(args.windows(2).any(|w| w == ["--effort", "xhigh"]));
assert!(args.contains(&"--offline".to_string()));
assert!(args.contains(&"--force".to_string()));
}
#[test]
fn codex_command_omits_placeholder_model() {
let cmd = build_codex_exec_command(
Path::new("/tmp/proj"),
Path::new("/tmp/out.md"),
"prompt",
Some("gpt-default"),
Some("xhigh"),
CodexSandbox::ReadOnly,
);
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert_program_is(&cmd, "codex");
assert_eq!(args[0], "--ask-for-approval");
assert_eq!(args[1], "never");
assert_eq!(args[2], "--config");
assert_eq!(args[3], "model_reasoning_effort=\"xhigh\"");
assert_eq!(args[4], "exec");
assert!(args.contains(&"--json".to_string()));
assert!(args.windows(2).any(|w| w == ["--cd", "/tmp/proj"]));
assert!(args.windows(2).any(|w| w == ["--sandbox", "read-only"]));
assert!(args
.windows(2)
.any(|w| w == ["--output-last-message", "/tmp/out.md"]));
assert!(!args.contains(&"--model".to_string()));
}
#[test]
fn codex_command_passes_real_model() {
let cmd = build_codex_exec_command(
Path::new("/tmp/proj"),
Path::new("/tmp/out.md"),
"prompt",
Some("gpt-5.1-codex"),
Some("high"),
CodexSandbox::ReadOnly,
);
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert!(args.windows(2).any(|w| w == ["--model", "gpt-5.1-codex"]));
}
#[test]
fn direct_generation_adapter_supports_non_codex_providers() {
for provider in [
"codex",
"claude",
"opencode",
"cursor",
"devin",
"trae",
"antigravity",
] {
assert!(
has_direct_generation_adapter(provider),
"{provider} should have a direct generation adapter"
);
}
assert!(has_direct_generation_adapter("gemini"));
assert!(has_direct_generation_adapter("agy"));
assert!(has_direct_generation_adapter("devin-cli"));
assert!(has_direct_generation_adapter("trae-cli"));
assert!(has_direct_generation_adapter("trae-agent"));
assert!(!has_direct_generation_adapter("custom"));
}
#[test]
fn workspace_execution_capability_is_explicit() {
assert!(provider_supports_workspace_execution("codex"));
for provider in [
"claude",
"opencode",
"cursor",
"devin",
"trae",
"antigravity",
"custom",
] {
let capabilities = adapter_capabilities(provider);
assert!(
!capabilities.workspace_execution,
"{provider} must not edit workspace without an explicit adapter"
);
}
}
#[test]
fn claude_text_command_uses_print_plan_mode() {
let cmd = build_cli_text_command(
DirectAdapter::Claude,
Path::new("/tmp/proj"),
"prompt",
Some("claude-sonnet-4-6"),
Some("xhigh"),
CliPermissionMode::ReadOnly,
);
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert_program_is(&cmd, "claude");
assert!(args.contains(&"--print".to_string()));
assert!(args.windows(2).any(|w| w == ["--permission-mode", "plan"]));
assert!(args
.windows(2)
.any(|w| w == ["--model", "claude-sonnet-4-6"]));
assert!(args.windows(2).any(|w| w == ["--effort", "xhigh"]));
}
#[test]
fn cursor_text_command_uses_agent_print_and_trust() {
let cmd = build_cli_text_command(
DirectAdapter::Cursor,
Path::new("/tmp/proj"),
"prompt",
Some("composer-2.5"),
None,
CliPermissionMode::ReadOnly,
);
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert_program_is(&cmd, "cursor");
assert_eq!(args[0], "agent");
assert!(args.contains(&"--print".to_string()));
assert!(args.contains(&"--trust".to_string()));
assert!(args.windows(2).any(|w| w == ["--mode", "plan"]));
assert!(args.windows(2).any(|w| w == ["--model", "composer-2.5"]));
}
#[test]
fn opencode_text_command_uses_run_without_workspace_write_flag() {
let cmd = build_cli_text_command(
DirectAdapter::Opencode,
Path::new("/tmp/proj"),
"prompt",
Some("opencode-go/deepseek-v4-pro"),
Some("xhigh"),
CliPermissionMode::ReadOnly,
);
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert_program_is(&cmd, "opencode");
assert_eq!(args[0], "run");
assert!(args.windows(2).any(|w| w == ["--format", "default"]));
assert!(args.windows(2).any(|w| w == ["--dir", "/tmp/proj"]));
assert!(args
.windows(2)
.any(|w| w == ["--model", "opencode-go/deepseek-v4-pro"]));
assert!(args.windows(2).any(|w| w == ["--variant", "max"]));
assert!(!args.contains(&"--dangerously-skip-permissions".to_string()));
}
#[test]
fn devin_text_command_uses_print_plan_prompt() {
let cmd = build_cli_text_command(
DirectAdapter::Devin,
Path::new("/tmp/proj"),
"prompt",
Some("sonnet"),
Some("xhigh"),
CliPermissionMode::ReadOnly,
);
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert_program_is(&cmd, "devin");
assert!(args.contains(&"--print".to_string()));
assert!(args
.windows(2)
.any(|w| w == ["--permission-mode", "normal"]));
assert!(args.windows(2).any(|w| w == ["--model", "sonnet"]));
assert!(args.iter().any(|arg| arg.starts_with("/plan\nprompt")));
assert_eq!(cmd.get_current_dir(), Some(Path::new("/tmp/proj")));
}
#[test]
fn trae_text_command_uses_isolated_workdir() {
let root = Path::new("/tmp/proj");
let cmd = build_cli_text_command(
DirectAdapter::Trae,
root,
"prompt",
Some("trae_agent_model"),
Some("high"),
CliPermissionMode::ReadOnly,
);
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert_program_is(&cmd, "trae-cli");
assert_eq!(args[0], "run");
assert!(args.contains(&"prompt".to_string()));
assert!(args
.windows(2)
.any(|w| w == ["--model", "trae_agent_model"]));
let workdir = cmd.get_current_dir().expect("trae workdir");
assert_ne!(workdir, root);
assert!(workdir.to_string_lossy().contains("sdd-headless-trae"));
assert!(args
.windows(2)
.any(|w| w[0] == "--working-dir" && w[1] == workdir.to_string_lossy()));
}
#[test]
fn antigravity_text_command_uses_agy_print_contract() {
let cmd = build_cli_text_command(
DirectAdapter::Antigravity,
Path::new("/tmp/proj"),
"prompt",
Some("claude-opus-4.6"),
None,
CliPermissionMode::ReadOnly,
);
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert_program_is(&cmd, "agy");
assert!(args.windows(2).any(|w| w == ["--add-dir", "/tmp/proj"]));
assert!(args.contains(&"--sandbox".to_string()));
assert!(args.windows(2).any(|w| w == ["--model", "claude-opus-4.6"]));
assert!(args.windows(2).any(|w| w == ["--prompt", "prompt"]));
}
#[test]
fn codex_execution_task_command_uses_workspace_write() {
let cmd = build_codex_exec_command(
Path::new("/tmp/proj"),
Path::new("/tmp/report.md"),
"prompt",
Some("gpt-5.1-codex"),
Some("high"),
CodexSandbox::WorkspaceWrite,
);
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert!(args
.windows(2)
.any(|w| w == ["--sandbox", "workspace-write"]));
}
#[test]
fn artifact_save_command_places_root_before_save() {
let cmd = build_artifact_save_command(
Path::new("/usr/bin/sdd"),
Path::new("/tmp/project"),
"Minha Feature",
Stage::Idea,
Path::new("/tmp/generated.md"),
"draft",
true,
);
let args: Vec<String> = cmd
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect();
assert_eq!(args[0], "artifact");
assert_eq!(args[1], "--root");
assert_eq!(args[2], "/tmp/project");
assert_eq!(args[3], "save");
assert_eq!(args[4], "Minha Feature");
assert_eq!(args[5], "idea");
assert!(args.contains(&"--force".to_string()));
}
#[test]
fn strips_wrapping_markdown_fence() {
let markdown = strip_wrapping_markdown_fence("```markdown\n# Titulo\n\ntexto\n```\n");
assert_eq!(markdown, "# Titulo\n\ntexto\n");
}
#[test]
fn token_usage_sample_estimates_input_and_output() {
let sample = TokenUsageSample::from_text("abcd efgh", "12345678");
assert_eq!(sample.input_tokens_estimate, 3);
assert_eq!(sample.output_tokens_estimate, 2);
assert_eq!(sample.total_tokens_estimate, 5);
assert_eq!(sample.input_bytes, 9);
assert_eq!(sample.output_bytes, 8);
assert!(!sample.provider_reported);
}
#[test]
fn parses_codex_jsonl_usage_when_present() {
let stdout = br#"{"type":"turn.started"}
{"type":"turn.completed","usage":{"input_tokens":1000,"cached_input_tokens":400,"output_tokens":120,"reasoning_output_tokens":30,"total_tokens":1120}}
"#;
let sample = reported_usage_from_jsonl(stdout).unwrap();
assert!(sample.provider_reported);
assert_eq!(sample.input_tokens_estimate, 1000);
assert_eq!(sample.cached_input_tokens_estimate, 400);
assert_eq!(sample.output_tokens_estimate, 120);
assert_eq!(sample.reasoning_output_tokens_estimate, 30);
assert_eq!(sample.total_tokens_estimate, 1120);
}
#[test]
fn parses_observed_model_from_codex_jsonl_top_level() {
let line = r#"{"type":"turn.started","model":"gpt-5.5"}"#;
assert_eq!(
observed_model_from_json_line(line).as_deref(),
Some("gpt-5.5")
);
}
#[test]
fn parses_observed_model_from_nested_provider_payload() {
let stdout =
br#"{"type":"session.started","payload":{"session":{"model":"claude-sonnet-4-6"}}}
{"type":"turn.completed","usage":{"input_tokens":1}}
"#;
assert_eq!(
observed_model_from_jsonl(stdout).as_deref(),
Some("claude-sonnet-4-6")
);
}
#[test]
fn parses_observed_model_from_array_metadata_payload() {
let line =
r#"{"type":"response","data":{"choices":[{"metadata":{"actual_model":"gpt-5.5"}}]}}"#;
assert_eq!(
observed_model_from_json_line(line).as_deref(),
Some("gpt-5.5")
);
}
#[test]
fn model_provenance_distinguishes_selected_from_observed() {
let options = StageRunOptions {
provider: Some("codex".to_string()),
model: Some("gpt-5.5".to_string()),
..StageRunOptions::default()
};
let provenance =
ModelProvenance::from_options(&options, Some("gpt-5.5-actual".to_string()), "jsonl");
assert_eq!(provenance.selected_model.as_deref(), Some("gpt-5.5"));
assert_eq!(provenance.observed_model.as_deref(), Some("gpt-5.5-actual"));
assert_eq!(provenance.confidence, "observed");
let unreported = ModelProvenance::from_options(&options, None, "adapter-output");
assert_eq!(unreported.selected_model.as_deref(), Some("gpt-5.5"));
assert_eq!(unreported.observed_model, None);
assert_eq!(unreported.confidence, "unreported");
}
#[test]
fn agent_provenance_uses_canonical_adapter_identity() {
for (adapter, id, label, command) in [
(DirectAdapter::Codex, "codex", "Codex CLI", "codex"),
(
DirectAdapter::Claude,
"claude-code",
"Claude Code",
"claude",
),
(DirectAdapter::Opencode, "opencode", "opencode", "opencode"),
(
DirectAdapter::Cursor,
"cursor-agent",
"Cursor Agent",
"cursor",
),
(DirectAdapter::Devin, "devin", "Devin CLI", "devin"),
(
DirectAdapter::Trae,
"trae-agent",
"Trae Agent CLI",
"trae-cli",
),
(
DirectAdapter::Antigravity,
"antigravity",
"Antigravity CLI",
"agy",
),
] {
let agent = AgentProvenance::from_adapter(adapter, "direct-adapter");
assert_eq!(agent.id, id);
assert_eq!(agent.label, label);
assert_eq!(agent.command.as_deref(), Some(command));
assert_eq!(agent.confidence, "invoked");
}
}
#[test]
fn agent_provenance_marks_deterministic_harness() {
let options = StageRunOptions {
provider: Some("custom".to_string()),
..StageRunOptions::default()
};
let agent = AgentProvenance::from_harness(&options, "sdd-harness");
assert_eq!(agent.id, "sdd-harness");
assert_eq!(agent.provider.as_deref(), Some("custom"));
assert_eq!(agent.command.as_deref(), Some("sdd"));
}
#[test]
fn error_results_preserve_agent_and_model_provenance() {
let options = StageRunOptions {
provider: Some("claude".to_string()),
model: Some("claude-sonnet-4-6".to_string()),
..StageRunOptions::default()
};
let agent = AgentProvenance::from_adapter(DirectAdapter::Claude, "test-adapter");
let model = ModelProvenance::from_options(&options, None, "test-unreported");
let result = parallel_error_result(
Stage::Tasks,
Instant::now(),
vec!["trace".to_string()],
"boom".to_string(),
TokenUsageSample::default(),
StageContextSample::default(),
ResultProvenance {
agent: agent.clone(),
model: model.clone(),
},
);
assert_eq!(result.agent, agent);
assert_eq!(result.model, model);
}
#[test]
fn execution_trace_event_uses_callsite_provenance() {
let dir = tempfile::tempdir().unwrap();
let options = StageRunOptions {
provider: Some("claude".to_string()),
model: Some("claude-sonnet-4-6".to_string()),
..StageRunOptions::default()
};
let agent = AgentProvenance::unsupported(&options, "workspace-execution-blocked");
let model = ModelProvenance::from_options(&options, None, "execution-adapter-unreported");
let task = ExecutionTask {
id: "T-01".to_string(),
title: "Task".to_string(),
body: String::new(),
dependencies: Vec::new(),
independent: false,
};
record_execution_trace_event(
dir.path(),
"Trace Event",
Stage::Execution,
&task,
"failed",
"claude",
7,
None,
None,
&agent,
&model,
Some("blocked"),
);
let log = fs::read_to_string(dir.path().join(".sdd/runs.jsonl")).unwrap();
let event: Value = serde_json::from_str(log.lines().next().unwrap()).unwrap();
assert_eq!(event["agent"], "unsupported-workspace-execution");
assert_eq!(event["metadata"]["agent_provenance"]["provider"], "claude");
assert_eq!(event["model"]["selected_model"], "claude-sonnet-4-6");
}
#[test]
fn codex_stderr_suppresses_stdin_noise() {
assert_eq!(
codex_stderr_trace_line("Reading additional input from stdin..."),
None
);
}
#[test]
fn codex_stderr_compacts_skill_load_errors() {
let line = "2026-06-06T16:08:13.215857Z ERROR codex_core::session::session: failed to load skill /Users/allan.oliveira/Documents/code/personal/test-sdd/.agents/skills/design-tokens/SKILL.md: invalid YAML: mapping values are not allowed in this context at line 2 column 71";
let trace = codex_stderr_trace_line(line).expect("skill error should be visible");
assert!(trace.starts_with("aviso: skill SDD design-tokens ignorada pelo Codex"));
assert!(trace.contains("invalid YAML"));
assert!(!trace.contains("/Users/"));
}
#[test]
fn codex_stderr_reports_missing_skill_frontmatter_as_warning() {
let line = "2026-06-06T16:08:13.216085Z ERROR codex_core::session::session: failed to load skill /Users/allan.oliveira/Documents/code/personal/test-sdd/.agents/skills/oxc-tooling/SKILL.md: missing YAML frontmatter delimited by ---";
let trace = codex_stderr_trace_line(line).expect("skill warning should be visible");
assert!(trace.contains("skill local oxc-tooling"));
assert!(trace.contains("missing YAML frontmatter"));
assert!(!trace.contains("/Users/"));
}
#[test]
fn repairs_sdd_design_tokens_frontmatter_for_codex() {
let dir = tempfile::tempdir().unwrap();
let skill = dir.path().join(".agents/skills/design-tokens/SKILL.md");
fs::create_dir_all(skill.parent().unwrap()).unwrap();
fs::write(
&skill,
"---\nname: design-tokens\ndescription: Criar tokens SDD: cores e spacing.\n---\n\n# Design Tokens\n",
)
.unwrap();
let repaired = repair_codex_skill_frontmatter(&skill).unwrap();
assert_eq!(repaired.as_deref(), Some("design-tokens"));
let fixed = fs::read_to_string(&skill).unwrap();
assert!(skill_frontmatter_is_valid(&fixed));
assert!(fixed.contains("description: \"Criar tokens SDD: cores e spacing.\""));
}
#[test]
fn prepare_codex_skill_surface_repairs_sdd_and_local_missing_frontmatter() {
let dir = tempfile::tempdir().unwrap();
let sdd_skill = dir
.path()
.join(".agents/skills/orchestration-plugin/skills/design-tokens/SKILL.md");
let local_skill = dir.path().join(".agents/skills/oxc-tooling/SKILL.md");
fs::create_dir_all(sdd_skill.parent().unwrap()).unwrap();
fs::create_dir_all(local_skill.parent().unwrap()).unwrap();
fs::write(
&sdd_skill,
"---\nname: design-tokens\ndescription: Tokens SDD: inválido para YAML.\n---\n\n# SDD\n",
)
.unwrap();
fs::write(&local_skill, "# Skill: oxc-tooling\n").unwrap();
let (tx, rx) = mpsc::channel();
let mut trace = Vec::new();
prepare_codex_skill_surface(dir.path(), &mut trace, &tx);
drop(tx);
assert!(trace
.iter()
.any(|item| item.contains("1 frontmatter(s) SDD adaptado(s): design-tokens")));
assert!(trace
.iter()
.any(|item| item.contains("1 frontmatter(s) local(is) adaptado(s): oxc-tooling")));
assert!(rx.try_iter().any(
|event| matches!(event, StageEvent::Trace(item) if item.contains("skills Codex"))
));
assert!(skill_frontmatter_is_valid(
&fs::read_to_string(&sdd_skill).unwrap()
));
let fixed_local = fs::read_to_string(&local_skill).unwrap();
assert!(skill_frontmatter_is_valid(&fixed_local));
assert!(fixed_local.contains("name: oxc-tooling"));
assert!(fixed_local.contains("# Skill: oxc-tooling"));
}
#[test]
fn token_usage_cost_uses_cached_input_rate() {
let pricing = ProviderModelPricing {
currency: Some("USD".to_string()),
input_per_million: Some(10.0),
cached_input_per_million: Some(1.0),
output_per_million: Some(20.0),
};
let sample =
TokenUsageSample::from_provider_usage(1_000_000, 250_000, 500_000, 0, 1_500_000)
.with_cost(Some(&pricing));
let cost = sample.cost.unwrap();
assert_eq!(cost.currency, "USD");
assert!((cost.input_cost - 7.5).abs() < f64::EPSILON);
assert!((cost.cached_input_cost - 0.25).abs() < f64::EPSILON);
assert!((cost.output_cost - 10.0).abs() < f64::EPSILON);
assert!((cost.total_cost - 17.75).abs() < f64::EPSILON);
}
#[test]
fn spawn_stage_runs_and_delivers() {
let exe = crate::tui::noop_success_exe();
let rx = spawn_stage(
exe,
Stage::Idea,
"x".to_string(),
PathBuf::from("."),
None,
StageRunOptions::default(),
);
let res = loop {
match rx.recv().expect("deve entregar evento") {
StageEvent::Finished(result) => break result,
StageEvent::Trace(_) | StageEvent::Usage(_) | StageEvent::Context(_) => {}
}
};
assert_eq!(res.exit_code, 0);
assert_eq!(res.stage, Stage::Idea);
}
#[test]
fn spawn_parallel_agent_delivers_finished() {
use crate::tui::parallel::TaskRef;
let task = TaskRef {
id: "T-test".into(),
title: "Test".into(),
independent: true,
};
let fragment_path =
std::env::temp_dir().join(format!("sdd-test-{}.md", std::process::id()));
let rx = spawn_parallel_agent(
crate::tui::noop_success_exe(),
Stage::Execution,
"test-orch".into(),
task,
std::env::temp_dir(),
fragment_path,
StageRunOptions::default(),
);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
match rx.try_recv() {
Ok(StageEvent::Finished(_)) => return, Ok(_) => {}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
panic!("channel disconnected")
}
Err(std::sync::mpsc::TryRecvError::Empty) => {
if std::time::Instant::now() > deadline {
panic!("timeout — Finished nao chegou em 5s");
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
}
}
}
#[test]
fn spawn_parallel_agent_reports_non_zero_exit() {
use crate::tui::parallel::TaskRef;
let task = TaskRef {
id: "T-fail".into(),
title: "Falha".into(),
independent: true,
};
let fragment_path =
std::env::temp_dir().join(format!("sdd-test-fail-{}.md", std::process::id()));
let rx = spawn_parallel_agent(
PathBuf::from("/usr/bin/false"),
Stage::Review,
"test-orch".into(),
task,
std::env::temp_dir(),
fragment_path,
StageRunOptions::default(),
);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
match rx.try_recv() {
Ok(StageEvent::Finished(result)) => {
assert_ne!(result.exit_code, 0);
return;
}
Ok(_) => {}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
panic!("channel disconnected")
}
Err(std::sync::mpsc::TryRecvError::Empty) => {
if std::time::Instant::now() > deadline {
panic!("timeout — Finished nao chegou em 5s");
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
}
}
}
}