use anyhow::{anyhow, bail, Context, Result};
use chrono::{SecondsFormat, Utc};
use clap::{Args, Parser, Subcommand, ValueEnum};
use domain::discovery::{
capability_recommendations, discover_project, ArchitectureEvidence, DiscoveryReport,
ProjectManifest,
};
use domain::providers::{
load_sdd_config, provider_catalog, provider_catalog_fast, resolve_provider,
ProviderCatalogEntry, ProviderResolveOptions, ProviderSelection,
};
use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};
use std::env;
use std::ffi::OsStr;
use std::fs;
use std::io::{self, IsTerminal, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use std::thread::{self, JoinHandle};
use std::time::Duration;
use unicode_normalization::char::is_combining_mark;
use unicode_normalization::UnicodeNormalization;
use walkdir::WalkDir;
use domain::risk::RiskResult;
mod contract;
mod domain;
pub mod runtime;
mod tui;
mod embedded {
include!(concat!(env!("OUT_DIR"), "/embedded.rs"));
}
const ROOT_ITEMS: &[&str] = &[
"AGENTS.md",
"CLAUDE.md",
".agents",
".claude",
".codex",
".cursor",
".devin",
".opencode",
".trae",
"bot",
];
const LAYER_ITEMS: &[&str] = &[
"schemas",
"templates",
"docs",
"clients",
"presets",
"adapters",
"VERSION",
"CHANGELOG.md",
"sdd.config.example.yaml",
];
const FLAT_ITEMS: &[&str] = &[
"AGENTS.md",
"CLAUDE.md",
".agents",
".claude",
".codex",
".cursor",
".devin",
".opencode",
".trae",
"bot",
"schemas",
"templates",
"docs",
"clients",
"presets",
"adapters",
"VERSION",
"CHANGELOG.md",
"sdd.config.example.yaml",
];
/// Pastas dentro de `bot/` que nunca devem ser distribuídas para projetos alvo.
const BOT_EXCLUDE_DIRS: &[&str] = &["node_modules", "dist", ".git"];
const PRESET_NAMES: &[&str] = &[
"generic",
"frontend-react",
"backend-node",
"hono-api",
"python-api",
"rust-api",
"monorepo",
"infra",
];
const MANAGED_BLOCK_START: &str = "<!-- sdd-layer:start -->";
const MANAGED_BLOCK_END: &str = "<!-- sdd-layer:end -->";
#[derive(Parser)]
#[command(name = "sdd", about = "Spec-Driven Development CLI and agent harness")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Init(InitArgs),
Doctor(DoctorArgs),
Clients(ClientsArgs),
Capabilities(CapabilitiesArgs),
Mcp(McpArgs),
Trace(TraceArgs),
Context(ContextArgs),
Ci(CiArgs),
Install(InstallArgs),
#[command(alias = "upgrade")]
Update(UpdateArgs),
Providers(ProvidersArgs),
Risk(RiskArgs),
Discover(StageArgs),
Idea(StageArgs),
Prd(StageArgs),
Techspec(StageArgs),
Tasks(StageArgs),
Refinement(StageArgs),
Execution(StageArgs),
Adr(StageArgs),
Review(StageArgs),
Memory(StageArgs),
Intelligence(IntelligenceArgs),
Optimize(OptimizeArgs),
#[command(
about = "Resolve a entrada do orquestrador (card | retomada | nova) e emite o contexto-esqueleto unificado"
)]
ResolveInput(ResolveInputArgs),
#[command(alias = "orchestrator")]
Orchestration(StageArgs),
Artifact(ArtifactArgs),
ValidateArtifact(ValidateArtifactArgs),
Hook(HookArgs),
#[command(about = "Fila de demandas do orquestrador autônomo")]
Demand(DemandArgs),
#[command(about = "Motor autônomo do orquestrador (planejamento)")]
Auto(AutoArgs),
#[command(about = "Interface TUI interativa para conduzir o fluxo SDD")]
Tui(tui::TuiArgs),
#[command(about = "Gerencia worktrees locais do projeto (.worktree/)")]
Worktree(WorktreeArgs),
#[command(about = "Gerencia o bot Slack integrado ao sdd-layer")]
Bot(BotArgs),
}
#[derive(Args, Clone)]
struct InitArgs {
name: Option<String>,
#[arg(long, default_value = ".")]
root: PathBuf,
#[arg(long, default_value = "auto")]
preset: String,
#[arg(long, value_enum, default_value_t = Layout::Compact)]
layout: Layout,
#[arg(long)]
force: bool,
#[arg(long)]
dry_run: bool,
#[arg(long)]
with_ci: bool,
#[arg(long)]
with_examples: bool,
#[arg(long)]
with_claude_plugin: bool,
#[arg(long)]
cleanup_legacy: bool,
}
#[derive(Args)]
struct DoctorArgs {
#[arg(long)]
root: Option<PathBuf>,
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct ClientsArgs {
#[command(subcommand)]
command: ClientsCommand,
}
#[derive(Subcommand)]
enum ClientsCommand {
List,
Doctor(ClientDoctorArgs),
Sync(ClientSyncArgs),
}
#[derive(Args)]
struct ClientDoctorArgs {
#[arg(long)]
root: Option<PathBuf>,
#[arg(long)]
json: bool,
#[arg(long)]
strict: bool,
}
#[derive(Args)]
struct ClientSyncArgs {
#[arg(long, default_value = ".")]
root: PathBuf,
#[arg(long, default_value = "all")]
targets: String,
#[arg(long)]
force: bool,
#[arg(long)]
dry_run: bool,
}
#[derive(Args)]
struct CapabilitiesArgs {
#[command(subcommand)]
command: CapabilitiesCommand,
}
#[derive(Subcommand)]
enum CapabilitiesCommand {
List(CapabilitiesListArgs),
Doctor(CapabilitiesDoctorArgs),
Sync(CapabilitiesSyncArgs),
Recommend(CapabilitiesRecommendArgs),
}
#[derive(Args)]
struct CapabilitiesListArgs {
#[arg(long, default_value = ".")]
root: PathBuf,
#[arg(long)]
json: bool,
}
#[derive(Args, Clone)]
struct CapabilitiesDoctorArgs {
#[arg(long, default_value = ".")]
root: PathBuf,
#[arg(long, default_value = "all")]
targets: String,
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct CapabilitiesSyncArgs {
#[arg(long, default_value = ".")]
root: PathBuf,
#[arg(long, default_value = "all")]
targets: String,
#[arg(long)]
force: bool,
#[arg(long)]
dry_run: bool,
}
#[derive(Args)]
struct CapabilitiesRecommendArgs {
#[arg(long, default_value = ".")]
root: PathBuf,
#[arg(long)]
name: Option<String>,
#[arg(long)]
write: bool,
#[arg(long)]
dry_run: bool,
}
#[derive(Args)]
struct McpArgs {
#[command(subcommand)]
command: McpCommand,
}
#[derive(Subcommand)]
enum McpCommand {
Serve(McpServeArgs),
Config(McpConfigArgs),
}
#[derive(Args)]
struct McpServeArgs {
#[arg(long, default_value = ".")]
root: PathBuf,
}
#[derive(Args)]
struct McpConfigArgs {
#[arg(long, default_value = ".")]
root: PathBuf,
#[arg(long, default_value = "all")]
targets: String,
#[arg(long)]
force: bool,
#[arg(long)]
dry_run: bool,
}
#[derive(Args)]
struct TraceArgs {
#[command(subcommand)]
command: TraceCommand,
}
#[derive(Subcommand)]
enum TraceCommand {
List(TraceListArgs),
Show(TraceShowArgs),
Summary(TraceSummaryArgs),
Doctor(TraceDoctorArgs),
}
#[derive(Args)]
struct TraceListArgs {
#[arg(long, default_value = ".")]
root: PathBuf,
#[arg(long)]
orchestration: Option<String>,
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct TraceShowArgs {
run_id: String,
#[arg(long, default_value = ".")]
root: PathBuf,
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct TraceSummaryArgs {
#[arg(long, default_value = ".")]
root: PathBuf,
#[arg(long)]
orchestration: Option<String>,
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct TraceDoctorArgs {
#[arg(long, default_value = ".")]
root: PathBuf,
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct ContextArgs {
#[command(subcommand)]
command: ContextCommand,
}
#[derive(Subcommand)]
enum ContextCommand {
Build(ContextBuildArgs),
Recommend(ContextRecommendArgs),
}
#[derive(Args)]
struct ContextBuildArgs {
#[arg(long, default_value = ".")]
root: PathBuf,
#[arg(long)]
name: String,
#[arg(long)]
stage: String,
#[arg(long)]
task: Option<String>,
#[arg(long)]
write: bool,
#[arg(long)]
dry_run: bool,
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct ContextRecommendArgs {
#[arg(long, default_value = ".")]
root: PathBuf,
#[arg(long)]
name: Option<String>,
#[arg(long)]
write: bool,
#[arg(long)]
dry_run: bool,
}
#[derive(Args)]
struct CiArgs {
#[arg(long)]
root: Option<PathBuf>,
}
#[derive(Args)]
struct InstallArgs {
target: PathBuf,
#[arg(long, default_value = "generic")]
preset: String,
#[arg(long, value_enum, default_value_t = Layout::Compact)]
layout: Layout,
#[arg(long)]
force: bool,
#[arg(long)]
dry_run: bool,
#[arg(long)]
skip_docs: bool,
#[arg(long)]
with_ci: bool,
#[arg(long)]
with_examples: bool,
#[arg(long)]
with_claude_plugin: bool,
#[arg(long)]
cleanup_legacy: bool,
}
#[derive(Args)]
struct UpdateArgs {
#[arg(long)]
root: Option<PathBuf>,
#[arg(long, default_value = "auto")]
preset: String,
#[arg(long, value_enum)]
layout: Option<Layout>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
skip_docs: bool,
#[arg(long)]
with_ci: bool,
#[arg(long)]
with_examples: bool,
#[arg(long)]
with_claude_plugin: bool,
#[arg(long)]
cleanup_legacy: bool,
#[arg(long)]
update_config: bool,
}
#[derive(Clone, Copy, ValueEnum)]
enum Layout {
Compact,
Flat,
}
impl Layout {
fn as_str(self) -> &'static str {
match self {
Layout::Compact => "compact",
Layout::Flat => "flat",
}
}
}
#[derive(Args)]
struct RiskArgs {
#[arg(required = true)]
text: Vec<String>,
#[arg(long)]
name: Option<String>,
#[arg(long, default_value = ".")]
root: PathBuf,
#[arg(long)]
force: bool,
#[arg(long)]
dry_run: bool,
#[arg(long)]
json: bool,
}
#[derive(Args, Clone)]
struct StageArgs {
#[arg()]
input: Vec<String>,
#[arg(long)]
name: Option<String>,
#[arg(long, default_value = ".")]
root: PathBuf,
#[arg(long)]
force: bool,
#[arg(long)]
dry_run: bool,
#[arg(long)]
provider: Option<String>,
#[arg(long)]
model: Option<String>,
#[arg(long)]
effort: Option<String>,
#[arg(long)]
offline: bool,
#[arg(long)]
json: bool,
/// Número da tentativa atual para este stage (1–3). Usado com card ID.
#[arg(long, default_value = "1")]
attempt: u32,
/// Marca o stage como reprovado: gera refinamento rápido e redireciona para execution.
#[arg(long)]
reject: bool,
}
#[derive(Args)]
struct ResolveInputArgs {
/// Entrada bruta do orquestrador: card ID opcional no primeiro token + ideia.
#[arg()]
input: Vec<String>,
#[arg(long, default_value = ".")]
root: PathBuf,
/// Nome/slug explícito da orquestração (sobrepõe a derivação do texto/card).
#[arg(long)]
name: Option<String>,
/// Emite o resultado como JSON em vez de Markdown.
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct ProvidersArgs {
#[arg(long, default_value = ".")]
root: PathBuf,
#[command(subcommand)]
command: ProvidersCommand,
}
#[derive(Subcommand)]
enum ProvidersCommand {
List(ProvidersListArgs),
Doctor(ProvidersDoctorArgs),
}
#[derive(Args)]
struct ProvidersListArgs {
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct ProvidersDoctorArgs {
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct IntelligenceArgs {
#[arg(long, default_value = ".")]
root: PathBuf,
#[command(subcommand)]
command: IntelligenceCommand,
}
#[derive(Subcommand)]
enum IntelligenceCommand {
Learn(IntelligenceLearnArgs),
Status(IntelligenceStatusArgs),
Health(IntelligenceHealthArgs),
}
#[derive(Args)]
struct IntelligenceLearnArgs {
#[arg(long)]
name: Option<String>,
#[arg(long)]
all: bool,
#[arg(long)]
json: bool,
#[arg(long)]
dry_run: bool,
}
#[derive(Args)]
struct IntelligenceStatusArgs {
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct IntelligenceHealthArgs {
#[arg(long)]
json: bool,
#[arg(long)]
fail_on: Option<String>,
}
#[derive(Args)]
struct OptimizeArgs {
#[arg(long, default_value = ".")]
root: PathBuf,
#[command(subcommand)]
command: OptimizeCommand,
}
#[derive(Subcommand)]
enum OptimizeCommand {
Status(OptimizeStatusArgs),
Compress(OptimizeCompressArgs),
}
#[derive(Args)]
struct OptimizeStatusArgs {
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct OptimizeCompressArgs {
#[arg(long)]
kind: String,
#[arg(long)]
file: Option<PathBuf>,
}
#[derive(Args)]
struct ArtifactArgs {
#[arg(long, default_value = ".")]
root: PathBuf,
#[command(subcommand)]
command: ArtifactCommand,
}
#[derive(Subcommand)]
enum ArtifactCommand {
Init {
name: String,
#[arg(long)]
force: bool,
#[arg(long)]
dry_run: bool,
},
Save {
name: String,
stage: String,
#[arg(long)]
file: Option<PathBuf>,
#[arg(long, default_value = "approved")]
state: String,
#[arg(long)]
append: bool,
#[arg(long)]
force: bool,
#[arg(long)]
dry_run: bool,
},
Status {
name: String,
#[arg(long)]
json: bool,
},
}
#[derive(Args)]
struct ValidateArtifactArgs {
artifact_type: String,
markdown_file: PathBuf,
#[arg(long, default_value = "schemas/artifact-sections.json")]
schema: PathBuf,
}
#[derive(Args)]
struct HookArgs {
#[command(subcommand)]
command: HookCommand,
}
#[derive(Subcommand)]
enum HookCommand {
ArtifactGate,
BashGuard,
WriteGuard,
TaskGate,
TraceLog,
Notify,
SubagentAudit,
}
#[derive(Args)]
#[command(after_help = "COMANDOS INTERNOS (não aparecem acima):\n \
validate Valida se está no repo principal\n \
validate-branch-worktree-active-now Valida branch vs worktree do card\n \
text-commit-card Gera texto de commit padronizado\n\
\nUso: sdd worktree <comando-interno> --help")]
struct WorktreeArgs {
#[command(subcommand)]
command: WorktreeCommand,
}
#[derive(Subcommand)]
enum WorktreeCommand {
/// Lista todos os worktrees rastreados pelo Git e pastas físicas em .worktree/
List(WorktreeRootArgs),
/// Remove entradas órfãs do registro Git (não remove pastas físicas)
Prune(WorktreeRootArgs),
/// Cria um worktree em .worktree/<branch> para o branch informado
Add(WorktreeAddArgs),
/// Sincroniza .worktree/ com os branches remotos: adiciona ausentes e remove obsoletos
#[command(name = "add-clean")]
AddClean(WorktreeRootArgs),
/// Valida que o comando está sendo executado no repositório principal (interno)
#[command(hide = true)]
Validate(WorktreeRootArgs),
/// Valida se o branch ativo corresponde ao worktree do card ID (interno)
#[command(name = "validate-branch-worktree-active-now", hide = true)]
ValidateBranchWorktreeActiveNow(WorktreeBranchValidateArgs),
/// Gera o texto padronizado de commit para um card/stage (interno)
#[command(name = "text-commit-card", hide = true)]
TextCommitCard(WorktreeTextCommitCardArgs),
}
#[derive(Args)]
struct WorktreeRootArgs {
/// Diretório raiz do projeto (padrão: diretório atual)
#[arg(long)]
root: Option<PathBuf>,
}
#[derive(Args)]
struct WorktreeAddArgs {
/// Nome do branch a criar em .worktree/<branch>
branch: String,
/// Diretório raiz do projeto (padrão: diretório atual)
#[arg(long)]
root: Option<PathBuf>,
}
#[derive(Args)]
struct WorktreeBranchValidateArgs {
/// ID do card (ex: CIJIRA-246)
card_id: String,
/// Diretório raiz do projeto (padrão: diretório atual)
#[arg(long)]
root: Option<PathBuf>,
}
#[derive(Args)]
struct WorktreeTextCommitCardArgs {
/// ID do card (ex: CIJIRA-246)
card_id: String,
/// Nome do stage (ex: prd, techspec, execution)
stage: String,
/// Número da tentativa (padrão: 1)
#[arg(long, default_value = "1")]
attempt: u32,
/// Versão do artefato (padrão: 1)
#[arg(long, default_value = "1")]
version: u32,
}
#[derive(Args)]
#[command(
after_help = "EXEMPLOS:\n sdd bot install — configura o bot interativamente\n sdd bot start — inicia o bot (autostart no boot do SO)\n sdd bot stop — para o bot\n sdd bot status — exibe sessões ativas\n sdd bot uninstall — remove autostart e para o bot\n\nCANAIS SLACK CRIADOS:\n <prefix>-comandos — canal de entrada de comandos\n <prefix>-tasks — canais privados por card\n <prefix>-logs — auditoria (somente admins)\n\nCOMANDOS DISPONÍVEIS NO SLACK:\n /sdd bot-criar <descrição> — cria card Jira + worktree + canal privado\n /sdd card-editar <texto> — reavalia artefatos do card atual\n /aprovado — aprova card e libera próximas etapas\n\nCONFIGURAÇÃO DE TOKENS:\n\n SLACK\n Bot Token (xoxb-...):\n api.slack.com/apps → seu app → OAuth & Permissions → Bot Token\n App Token (xapp-...):\n api.slack.com/apps → seu app → Settings → Socket Mode → habilite e gere token\n Escopos obrigatórios (Bot Token): channels:manage, channels:read, chat:write,\n files:write, groups:write, groups:read, users:read, commands\n Escopos obrigatórios (App Token): connections:write\n\n JIRA\n Token: id.atlassian.com/manage-profile/security/api-tokens → Create API token\n Email: o mesmo email usado para logar no Jira (ex: usuario@empresa.com)\n\n BITBUCKET\n Token (API token com scopes):\n bitbucket.org/account/settings/api-tokens → Create API token\n Permissões necessárias:\n Repositories: Read + Write\n Pull requests: Read + Write\n Account: Read\n Email: o email da conta Bitbucket (não o username)\n Obs: App Passwords foram descontinuados em 09/06/2026 — use API token com scopes\n\nDOCUMENTAÇÃO: bot/README.md"
)]
struct BotArgs {
#[command(subcommand)]
command: BotCommand,
}
#[derive(Subcommand)]
enum BotCommand {
/// Instala e configura o bot Slack interativamente
Install(BotRootArgs),
/// Inicia o bot em background com autostart no boot do SO
Start(BotRootArgs),
/// Para o bot (autostart permanece configurado)
Stop(BotRootArgs),
/// Exibe status do bot e sessões ativas
Status(BotRootArgs),
/// Remove autostart, para o bot e limpa sessions.json
Uninstall(BotRootArgs),
}
#[derive(Args)]
struct BotRootArgs {
#[arg(long)]
root: Option<PathBuf>,
}
// ===== Orquestrador Autônomo de Demandas — CLI (SDD-OAD-012) =====
#[derive(Args)]
struct DemandArgs {
#[arg(long, default_value = ".")]
root: PathBuf,
#[command(subcommand)]
command: DemandCommand,
}
#[derive(Subcommand)]
enum DemandCommand {
/// Enfileira uma demanda nova.
Enqueue {
#[arg(long)]
id: String,
#[arg(long = "type")]
kind: String,
#[arg(long)]
title: String,
#[arg(long, default_value = "")]
desc: String,
#[arg(long, default_value = "cli")]
source: String,
#[arg(long)]
priority: Option<String>,
},
/// Lista as demandas e seus estados.
List {
#[arg(long)]
json: bool,
},
/// Mostra o estado de uma demanda (ou de todas, sem id).
Status {
id: Option<String>,
#[arg(long)]
json: bool,
},
/// Aprova um gate (decisão humana).
Approve {
id: String,
#[arg(long)]
gate: String,
#[arg(long)]
by: Option<String>,
#[arg(long)]
note: Option<String>,
},
/// Reprova um gate, reabrindo a etapa.
Reject {
id: String,
#[arg(long)]
gate: String,
#[arg(long)]
by: Option<String>,
#[arg(long)]
reason: String,
},
}
#[derive(Args)]
struct AutoArgs {
#[arg(long, default_value = ".")]
root: PathBuf,
#[command(subcommand)]
command: AutoCommand,
}
#[derive(Subcommand)]
enum AutoCommand {
/// Executa um passo do motor (uma etapa por demanda).
Tick {
#[arg(long)]
json: bool,
/// Usa o runner real (provider headless) em vez do FakeRunner stub.
#[arg(long)]
real: bool,
},
/// Roda o motor até todas as demandas pausarem num gate (ou idle).
Run {
#[arg(long)]
max_iterations: Option<u32>,
#[arg(long)]
json: bool,
/// Usa o runner real (provider headless) em vez do FakeRunner stub.
#[arg(long)]
real: bool,
},
/// Estado consolidado das demandas.
Status {
#[arg(long)]
json: bool,
},
}
fn run_demand(args: DemandArgs) -> Result<()> {
use crate::domain::orchestrator::demand::{Demand, DemandSource, DemandType};
use crate::domain::orchestrator::queue::{self, EnqueueOutcome};
let root = &args.root;
match args.command {
DemandCommand::Enqueue {
id,
kind,
title,
desc,
source,
priority,
} => {
let demand = Demand::new(
&id,
DemandType::parse(&kind)?,
&title,
&desc,
DemandSource::parse(&source)?,
priority,
now_public(),
)?;
match queue::enqueue(root, &demand)? {
EnqueueOutcome::Enqueued => {
println!("enfileirada: {} -> {}", demand.id, demand.slug)
}
EnqueueOutcome::DuplicateIgnored => {
println!("ignorada (id já existe): {}", demand.id)
}
}
}
DemandCommand::List { json } => print_demands_status(root, json)?,
DemandCommand::Status { id, json } => match id {
None => print_demands_status(root, json)?,
Some(id) => {
let demand = queue::load(root, &id)?
.ok_or_else(|| anyhow!("demanda '{id}' não encontrada na fila"))?;
let st = crate::domain::orchestrator::state::load(root, &demand.slug)?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"demand": demand,
"state": st.as_ref().map(|s| json!({
"status": s.status.to_repr(),
"cursor": s.cursor,
"approvals": s.approvals,
})),
}))?
);
} else {
println!("id: {}", demand.id);
println!("slug: {}", demand.slug);
println!("título: {}", demand.title);
match &st {
Some(s) => {
println!("status: {}", s.status.to_repr());
println!("cursor: {}", s.cursor);
println!("aprovações: {}", s.approvals.len());
}
None => println!("status: queued (não iniciada)"),
}
}
}
},
DemandCommand::Approve { id, gate, by, note } => {
decide_gate(root, &id, &gate, true, by, note)?
}
DemandCommand::Reject {
id,
gate,
by,
reason,
} => decide_gate(root, &id, &gate, false, by, Some(reason))?,
}
Ok(())
}
fn decide_gate(
root: &Path,
id: &str,
gate: &str,
approve: bool,
by: Option<String>,
reason: Option<String>,
) -> Result<()> {
use crate::domain::orchestrator::approval::{apply_decision, DecisionOutcome};
use crate::domain::orchestrator::queue;
use crate::domain::orchestrator::state::{Channel, DecisionKind};
use crate::tui::stage::Stage;
let demand =
queue::load(root, id)?.ok_or_else(|| anyhow!("demanda '{id}' não encontrada na fila"))?;
let stage = Stage::from_key(gate)
.filter(|s| matches!(s, Stage::Prd | Stage::Techspec | Stage::Refinement))
.ok_or_else(|| anyhow!("gate inválido '{gate}' (use prd|techspec|refinement)"))?;
let approvers = load_approvers(root)?;
let author = by
.filter(|a| !a.trim().is_empty())
.or_else(git_user_email)
.unwrap_or_else(|| "unknown".to_string());
let kind = if approve {
DecisionKind::Approve
} else {
DecisionKind::Reject
};
let outcome = apply_decision(
root,
&demand.slug,
stage,
kind,
&author,
Channel::Cli,
&now_public(),
reason,
&approvers,
)?;
let msg = match outcome {
DecisionOutcome::Advanced => "aprovado; avançou para a próxima etapa",
DecisionOutcome::ReadyForExec => "aprovado; pronto para execução (handoff)",
DecisionOutcome::Reopened => "reprovado; etapa reaberta para regeneração",
};
println!("{} ({}): {msg}", demand.id, author);
Ok(())
}
fn print_demands_status(root: &Path, json_output: bool) -> Result<()> {
use crate::domain::orchestrator::{queue, state};
let demands = queue::list(root)?;
if json_output {
let mut rows = Vec::new();
for d in &demands {
let st = state::load(root, &d.slug)?;
rows.push(json!({
"id": d.id,
"slug": d.slug,
"type": d.kind.as_str(),
"title": d.title,
"status": st.as_ref().map(|s| s.status.to_repr()).unwrap_or_else(|| "queued".to_string()),
"cursor": st.as_ref().map(|s| s.cursor.clone()),
}));
}
println!("{}", serde_json::to_string_pretty(&rows)?);
return Ok(());
}
if demands.is_empty() {
println!("fila vazia");
return Ok(());
}
for d in &demands {
let st = state::load(root, &d.slug)?;
let status = st
.as_ref()
.map(|s| s.status.to_repr())
.unwrap_or_else(|| "queued".to_string());
let cursor = st.as_ref().map(|s| s.cursor.as_str()).unwrap_or("-");
println!(
"{}\t{}\t{}\t{}\t{}",
d.id,
d.kind.as_str(),
status,
cursor,
d.title
);
}
Ok(())
}
/// Runner real (SDD-OAD-009): envolve `tui::runner::spawn_stage` (a mesma
/// geração headless/determinística usada pela TUI) de forma síncrona e lê o
/// artefato gerado. Provider/modelo/effort resolvidos por etapa.
struct HeadlessRunner {
root: PathBuf,
}
impl crate::domain::orchestrator::engine::StageRunner for HeadlessRunner {
fn run(
&self,
req: &crate::domain::orchestrator::engine::StageRequest,
) -> std::result::Result<
crate::domain::orchestrator::engine::StageArtifact,
crate::domain::orchestrator::engine::RunnerError,
> {
use crate::domain::orchestrator::engine::{RunnerError, StageArtifact};
use crate::domain::providers::{resolve_provider_fast, ProviderResolveOptions};
use crate::tui::runner::{self, StageEvent, StageRunOptions};
let selection = resolve_provider_fast(&self.root, &ProviderResolveOptions::default())
.map_err(|e| RunnerError::Failed(format!("resolve provider: {e}")))?;
let stage_key = req.stage.key();
let model = selection
.stage_models
.get(stage_key)
.cloned()
.or_else(|| Some(selection.model.clone()))
.filter(|m| !m.is_empty());
let effort = selection
.stage_efforts
.get(stage_key)
.cloned()
.or_else(|| Some(selection.effort.clone()))
.filter(|e| !e.is_empty());
let options = StageRunOptions {
provider: Some(selection.id.clone()),
model,
effort,
offline: selection.offline,
force: true,
artifact_dir: Some(req.artifact_dir.clone()),
context_window_tokens: None,
pricing: None,
};
// A descrição da demanda (fonte possivelmente não confiável) seed a
// geração com PII redigida (SDD-OAD-011).
let input = if req.description.trim().is_empty() {
None
} else {
Some(crate::runtime::redaction::redact_pii(&req.description))
};
let rx = runner::spawn_stage(
runner::self_exe(),
req.stage,
req.title.clone(),
self.root.clone(),
input,
options,
);
let mut result = None;
while let Ok(event) = rx.recv() {
if let StageEvent::Finished(finished) = event {
result = Some(finished);
break;
}
}
let result = result.ok_or_else(|| RunnerError::Failed("runner sem resultado".into()))?;
if result.exit_code != 0 {
return Err(RunnerError::Failed(format!(
"geração falhou (exit {}): {}",
result.exit_code,
result.stderr.trim()
)));
}
let path = req.artifact_dir.join(req.stage.filename());
let markdown = std::fs::read_to_string(&path)
.map_err(|e| RunnerError::Failed(format!("lendo {}: {e}", path.display())))?;
Ok(StageArtifact {
stage: req.stage,
markdown,
})
}
}
fn select_runner(
root: &Path,
real: bool,
) -> Box<dyn crate::domain::orchestrator::engine::StageRunner> {
if real {
Box::new(HeadlessRunner {
root: root.to_path_buf(),
})
} else {
Box::new(crate::domain::orchestrator::engine::FakeRunner)
}
}
fn run_auto(args: AutoArgs) -> Result<()> {
use crate::domain::orchestrator::engine::{run_until_idle, tick};
let root = &args.root;
let owner = format!("cli-{}", std::process::id());
match args.command {
AutoCommand::Tick { json, real } => {
let runner = select_runner(root, real);
let report = tick(root, runner.as_ref(), &owner, &now_public())?;
audit_tick(root, &report);
emit_gate_requests(root, &report);
print_tick_report(&report, json)?;
}
AutoCommand::Run {
max_iterations,
json,
real,
} => {
let runner = select_runner(root, real);
let reports = run_until_idle(
root,
runner.as_ref(),
&owner,
&now_public(),
max_iterations.unwrap_or(100),
)?;
for report in &reports {
audit_tick(root, report);
emit_gate_requests(root, report);
}
let produced: usize = reports.iter().map(|r| r.produced.len()).sum();
let paused: Vec<&String> = reports.iter().flat_map(|r| r.paused.iter()).collect();
let ready: Vec<&String> = reports.iter().flat_map(|r| r.ready.iter()).collect();
let errors: Vec<&String> = reports.iter().flat_map(|r| r.errors.iter()).collect();
if json {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"iterations": reports.len(),
"produced": produced,
"paused": paused,
"ready": ready,
"errors": errors,
}))?
);
} else {
println!("iterações: {}", reports.len());
println!("etapas produzidas: {produced}");
println!("aguardando aprovação: {paused:?}");
if !ready.is_empty() {
println!("prontas para execução: {ready:?}");
}
if !errors.is_empty() {
println!("erros: {errors:?}");
}
}
}
AutoCommand::Status { json } => print_demands_status(root, json)?,
}
Ok(())
}
fn audit_tick(root: &Path, report: &crate::domain::orchestrator::engine::TickReport) {
let _ = log_jsonl(
root,
"orchestrator.jsonl",
json!({
"ts": now_public(),
"event": "tick",
"produced": report.produced,
"paused": report.paused,
"ready": report.ready,
"skipped": report.skipped,
"errors": report
.errors
.iter()
.map(|e| crate::runtime::redaction::redact_text(e))
.collect::<Vec<_>>(),
}),
);
}
/// Emite o pedido de aprovação (notify local) para cada demanda que pausou num
/// gate: registra `gate_request` na auditoria e imprime o que decidir + o
/// comando de aprovação. O canal Slack é a SDD-OAD-014. (SDD-OAD-007)
fn emit_gate_requests(root: &Path, report: &crate::domain::orchestrator::engine::TickReport) {
use crate::domain::orchestrator::{queue, state};
if report.paused.is_empty() {
return;
}
let demands = queue::list(root).unwrap_or_default();
for slug in &report.paused {
let gate = state::load(root, slug)
.ok()
.flatten()
.map(|s| s.cursor)
.unwrap_or_default();
let file = stage_file(&gate).unwrap_or("");
let id = demands
.iter()
.find(|d| &d.slug == slug)
.map(|d| d.id.clone())
.unwrap_or_else(|| slug.clone());
let _ = log_jsonl(
root,
"orchestrator.jsonl",
json!({
"ts": now_public(),
"event": "gate_request",
"slug": slug,
"id": id,
"gate": gate,
"artifact": format!("docs/{slug}/{file}"),
}),
);
println!(
"[gate] {id}: aguardando aprovação de '{gate}' — revise docs/{slug}/{file} e rode: sdd demand approve {id} --gate {gate} --by <você>"
);
}
}
fn print_tick_report(
report: &crate::domain::orchestrator::engine::TickReport,
json: bool,
) -> Result<()> {
if json {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"produced": report.produced,
"paused": report.paused,
"ready": report.ready,
"skipped": report.skipped,
"errors": report.errors,
}))?
);
} else {
println!("produced: {:?}", report.produced);
println!("paused: {:?}", report.paused);
println!("ready: {:?}", report.ready);
println!("skipped: {:?}", report.skipped);
if !report.errors.is_empty() {
println!("errors: {:?}", report.errors);
}
}
Ok(())
}
fn git_user_email() -> Option<String> {
let out = std::process::Command::new("git")
.args(["config", "user.email"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let email = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!email.is_empty()).then_some(email)
}
fn load_approvers(root: &Path) -> Result<crate::domain::orchestrator::approval::Approvers> {
let path = root.join("sdd.config.yaml");
if !path.exists() {
return Ok(Default::default());
}
let text = fs::read_to_string(&path)?;
let doc: serde_yaml::Value = serde_yaml::from_str(&text).unwrap_or(serde_yaml::Value::Null);
let approvers = match doc.get("approvers") {
Some(value) => serde_yaml::from_value(value.clone()).unwrap_or_default(),
None => Default::default(),
};
Ok(approvers)
}
fn main() {
if let Err(error) = run() {
eprintln!("SDD error: {error:#}");
std::process::exit(1);
}
}
fn run() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Command::Init(args) => {
run_init(args)?;
}
Command::Doctor(args) => {
let root = resolve_project_root(args.root)?;
let report = doctor(&root);
if args.json {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"status": if report.failures.is_empty() { "pass" } else { "fail" },
"failures": report.failures,
"warnings": report.warnings,
}))?
);
} else {
print_doctor(&report);
}
if !report.failures.is_empty() {
bail!("doctor failed");
}
}
Command::Clients(args) => run_clients(args)?,
Command::Capabilities(args) => run_capabilities(args)?,
Command::Mcp(args) => run_mcp(args)?,
Command::Trace(args) => run_trace(args)?,
Command::Context(args) => run_context(args)?,
Command::Ci(args) => {
let root = resolve_project_root(args.root)?;
run_ci(&root)?;
}
Command::Install(args) => install(args)?,
Command::Update(args) => update(args)?,
Command::Providers(args) => run_providers(args)?,
Command::Risk(args) => run_risk(args)?,
Command::Discover(args) => run_discover(args)?,
Command::Idea(args) => run_stage("idea", args)?,
Command::Prd(args) => run_stage("prd", args)?,
Command::Techspec(args) => run_stage("techspec", args)?,
Command::Tasks(args) => run_stage("tasks", args)?,
Command::Refinement(args) => run_stage("refinement", args)?,
Command::Execution(args) => run_stage("execution", args)?,
Command::Adr(args) => run_stage("adr", args)?,
Command::Review(args) => run_stage("review", args)?,
Command::Memory(args) => run_memory(args)?,
Command::Intelligence(args) => run_intelligence(args)?,
Command::Optimize(args) => run_optimize(args)?,
Command::ResolveInput(args) => run_resolve_input(args)?,
Command::Orchestration(args) => run_orchestration(args)?,
Command::Artifact(args) => run_artifact(args)?,
Command::ValidateArtifact(args) => validate_artifact_command(args)?,
Command::Hook(args) => run_hook(args.command)?,
Command::Demand(args) => run_demand(args)?,
Command::Auto(args) => run_auto(args)?,
Command::Tui(args) => tui::run(args)?,
Command::Worktree(args) => run_worktree(args)?,
Command::Bot(args) => run_bot(args)?,
}
Ok(())
}
fn now() -> String {
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
}
/// Indica se o comando foi invocado pelo bot (SDD_BOT_CONTEXT=true).
///
/// Nesse caso o bot já gerencia a criação do arquivo, o worktree, o commit/push
/// e o cabeçalho do artefato. Para não DUPLICAR esse trabalho (ex.: gerar
/// `01-idea.md` além do `idea.md` do bot, ou commitar duas vezes), os comandos
/// de stage e o `risk` apenas geram o conteúdo e o devolvem no stdout, sem
/// escrever em `docs/` nem versionar. Fora do bot, o comportamento é o normal.
fn is_bot_context() -> bool {
env::var("SDD_BOT_CONTEXT")
.map(|value| value == "true")
.unwrap_or(false)
}
/// Timestamp ISO-8601 exposto para o módulo `tui` (telemetria).
pub(crate) fn now_public() -> String {
now()
}
fn slugify(value: &str) -> String {
let without_marks: String = value
.trim()
.to_lowercase()
.nfd()
.filter(|c| !is_combining_mark(*c))
.collect();
let mut slug = String::new();
let mut last_dash = false;
for ch in without_marks.chars() {
if ch.is_ascii_alphanumeric() {
slug.push(ch);
last_dash = false;
} else if !last_dash {
slug.push('-');
last_dash = true;
}
}
let slug = slug.trim_matches('-').to_string();
if slug.is_empty() {
"orchestration".to_string()
} else {
slug
}
}
fn normalize_heading(text: &str) -> String {
text.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.trim()
.to_lowercase()
}
fn resolve_project_root(root: Option<PathBuf>) -> Result<PathBuf> {
if let Some(root) = root {
return Ok(runtime::platform::canonicalize(&root).unwrap_or(root));
}
if let Ok(env_root) = env::var("SDD_PROJECT_ROOT").or_else(|_| env::var("CLAUDE_PROJECT_DIR")) {
return Ok(runtime::platform::canonicalize(&PathBuf::from(env_root))?);
}
detect_project_root()
}
fn detect_project_root() -> Result<PathBuf> {
let cwd = process_cwd()?;
for current in cwd.ancestors() {
if current.join(".sdd").is_dir() || current.join("sdd.config.yaml").exists() {
return Ok(current.to_path_buf());
}
}
Ok(cwd)
}
fn process_cwd() -> Result<PathBuf> {
env::current_dir()
.or_else(|_| env::var("PWD").map(PathBuf::from).map_err(io::Error::other))
.context("could not determine current directory")
}
fn layer_root(project_root: &Path) -> PathBuf {
let compact = project_root.join(".sdd");
if is_compact_layer_root(&compact) {
compact
} else {
project_root.to_path_buf()
}
}
fn is_compact_layer_root(path: &Path) -> bool {
path.join("presets").is_dir()
|| path.join("schemas").is_dir()
|| path.join("templates").is_dir()
|| path.join("adapters").is_dir()
}
struct CliLoader {
enabled: bool,
label: String,
stop: Option<Arc<AtomicBool>>,
handle: Option<JoinHandle<()>>,
}
impl CliLoader {
fn start(label: impl Into<String>) -> Self {
let label = label.into();
let enabled = io::stdout().is_terminal() && env::var_os("CI").is_none();
if !enabled {
return Self {
enabled,
label,
stop: None,
handle: None,
};
}
let stop = Arc::new(AtomicBool::new(false));
let thread_stop = Arc::clone(&stop);
let thread_label = label.clone();
let handle = thread::spawn(move || {
let frames = ["|", "/", "-", "\\"];
let mut frame = 0;
while !thread_stop.load(Ordering::Relaxed) {
print!("\r{} {}", frames[frame % frames.len()], thread_label);
let _ = io::stdout().flush();
frame += 1;
thread::sleep(Duration::from_millis(90));
}
});
Self {
enabled,
label,
stop: Some(stop),
handle: Some(handle),
}
}
fn finish(mut self, status: &str) {
if !self.enabled {
return;
}
if let Some(stop) = &self.stop {
stop.store(true, Ordering::Relaxed);
}
if let Some(handle) = self.handle.take() {
let _ = handle.join();
}
let width = self.label.chars().count() + status.chars().count() + 16;
print!("\r{}\r", " ".repeat(width));
let _ = io::stdout().flush();
}
}
#[derive(Clone, Copy)]
enum RailMarker {
Hollow,
Filled,
}
impl RailMarker {
fn symbol(self) -> &'static str {
match self {
RailMarker::Hollow => "◇",
RailMarker::Filled => "◆",
}
}
}
struct TimelineStep {
label: String,
detail: Option<String>,
marker: RailMarker,
}
impl TimelineStep {
fn new(label: impl Into<String>, detail: impl Into<Option<String>>) -> Self {
Self {
label: label.into(),
detail: detail.into(),
marker: RailMarker::Hollow,
}
}
fn filled(label: impl Into<String>, detail: impl Into<Option<String>>) -> Self {
Self {
label: label.into(),
detail: detail.into(),
marker: RailMarker::Filled,
}
}
}
struct InstallReport {
target: PathBuf,
preset: String,
dry_run: bool,
actions: Vec<String>,
bot_decision: BotBundleDecision,
}
struct ResolvedInstallOptions {
target: PathBuf,
preset: String,
layout: Layout,
force_files: bool,
force_config: bool,
dry_run: bool,
skip_docs: bool,
with_ci: bool,
with_examples: bool,
with_claude_plugin: bool,
cleanup_legacy: bool,
merge_directories: bool,
}
fn use_terminal_style() -> bool {
io::stdout().is_terminal()
&& env::var_os("NO_COLOR").is_none()
&& env::var("TERM").map_or(true, |term| term != "dumb")
}
fn muted(text: &str) -> String {
if use_terminal_style() {
format!("\x1b[2m{text}\x1b[0m")
} else {
text.to_string()
}
}
fn rail_blank() {
println!("│");
}
fn rail_detail(detail: &str) {
println!("│ {}", muted(detail));
}
fn rail_node(marker: RailMarker, label: &str, detail: Option<&str>) {
println!("{} {label}", marker.symbol());
if let Some(detail) = detail {
rail_detail(detail);
}
rail_blank();
}
fn print_install_intro(title: &str, target: &Path, preset: &str, layout: Layout, dry_run: bool) {
println!("│ {title}");
rail_blank();
rail_node(
RailMarker::Hollow,
"Project root",
Some(&runtime::platform::display_path(target)),
);
rail_node(
RailMarker::Hollow,
"Preset e layout",
Some(&format!("Preset: {preset} / Layout: {}", layout.as_str())),
);
if dry_run {
rail_node(
RailMarker::Hollow,
"Modo",
Some("dry-run; nenhum arquivo será escrito"),
);
}
}
fn compact_action(action: &str, target: &Path) -> String {
let root = runtime::platform::display_path(target);
// Normaliza para `/` para não vazar o separador `\` do Windows na saída,
// mantendo os paths das ações portáveis entre Windows, Linux e macOS.
action.replace(&root, ".").replace('\\', "/")
}
fn action_bucket(action: &str) -> &'static str {
if action.starts_with("dry-run") {
"Planejadas"
} else if action.starts_with("copy ")
|| action.starts_with("write ")
|| action.starts_with("replace ")
|| action.starts_with("update ")
{
"Criadas ou atualizadas"
} else if action.starts_with("merge ") {
"Mescladas"
} else if action.starts_with("skip existing ") {
"Preservadas"
} else if action.starts_with("cleanup legacy ") {
"Limpeza"
} else {
"Ajustes finais"
}
}
fn grouped_actions(actions: &[String], target: &Path) -> BTreeMap<&'static str, Vec<String>> {
let mut groups: BTreeMap<&'static str, Vec<String>> = BTreeMap::new();
for action in actions {
groups
.entry(action_bucket(action))
.or_default()
.push(compact_action(action, target));
}
groups
}
fn print_timeline(steps: &[TimelineStep]) {
for step in steps {
rail_node(step.marker, &step.label, step.detail.as_deref());
}
}
fn action_summary(groups: &BTreeMap<&'static str, Vec<String>>) -> String {
let mut parts = Vec::new();
for (key, singular, plural) in [
(
"Criadas ou atualizadas",
"criada/atualizada",
"criadas/atualizadas",
),
("Mescladas", "mesclada", "mescladas"),
("Limpeza", "limpeza", "limpeza"),
("Planejadas", "planejada", "planejadas"),
("Preservadas", "preservada", "preservadas"),
("Ajustes finais", "ajuste final", "ajustes finais"),
] {
if let Some(items) = groups.get(key) {
if !items.is_empty() {
let label = if items.len() == 1 { singular } else { plural };
parts.push(format!("{} {label}", items.len()));
}
}
}
parts.join(" • ")
}
fn human_action(action: &str) -> String {
if action == "gitignore: worktree entries already present" {
return "gitignore já continha entradas de worktree".to_string();
}
action
.strip_prefix("skip existing ")
.map(|path| format!("já existia {path}"))
.or_else(|| {
action
.strip_prefix("dry-run copy ")
.map(|path| format!("copiar {path}"))
})
.or_else(|| {
action
.strip_prefix("dry-run write ")
.map(|path| format!("escrever {path}"))
})
.or_else(|| {
action
.strip_prefix("dry-run merge ")
.map(|path| format!("mesclar {path}"))
})
.or_else(|| {
action
.strip_prefix("dry-run: would create ")
.map(|path| format!("criar {path}"))
})
.or_else(|| {
action
.strip_prefix("copy ")
.map(|path| format!("criado {path}"))
})
.or_else(|| {
action
.strip_prefix("write ")
.map(|path| format!("atualizado {path}"))
})
.or_else(|| {
action
.strip_prefix("merge ")
.map(|path| format!("mesclado {path}"))
})
.or_else(|| {
action
.strip_prefix("replace ")
.map(|path| format!("substituído {path}"))
})
.or_else(|| {
action
.strip_prefix("update ")
.map(|path| format!("atualizado {path}"))
})
.or_else(|| {
action
.strip_prefix("created ")
.map(|path| format!("criado {path}"))
})
.or_else(|| {
action
.strip_prefix("gitignore: added ")
.map(|path| format!("gitignore adicionou {path}"))
})
.or_else(|| {
action
.strip_prefix("gitignore: ")
.map(|message| format!("gitignore {message}"))
})
.unwrap_or_else(|| action.to_string())
}
fn print_action_summary(actions: &[String], target: &Path) {
const PREVIEW_LIMIT: usize = 5;
let groups = grouped_actions(actions, target);
if groups.is_empty() {
rail_node(
RailMarker::Hollow,
"Camada SDD",
Some("nenhuma alteração necessária"),
);
return;
}
rail_node(
RailMarker::Filled,
"Camada SDD sincronizada",
Some(&action_summary(&groups)),
);
for title in [
"Criadas ou atualizadas",
"Mescladas",
"Limpeza",
"Planejadas",
"Ajustes finais",
"Preservadas",
] {
let Some(items) = groups.get(title) else {
continue;
};
if items.is_empty() {
continue;
}
let marker = if matches!(title, "Preservadas" | "Planejadas") {
RailMarker::Hollow
} else {
RailMarker::Filled
};
println!("{} {title} ({})", marker.symbol(), items.len());
for item in items.iter().take(PREVIEW_LIMIT) {
rail_detail(&human_action(item));
}
if items.len() > PREVIEW_LIMIT {
rail_detail(&format!(
"+ {} itens adicionais",
items.len() - PREVIEW_LIMIT
));
}
rail_blank();
}
}
fn print_quick_start(steps: &[String], done: &str) {
println!("◇ Quick start ─────────────");
rail_blank();
for step in steps {
rail_detail(step);
}
rail_blank();
println!("└ {done}");
}
fn print_install_report(
title: &str,
report: &InstallReport,
dry_run_hint: Option<&str>,
timeline: Vec<TimelineStep>,
extra_notes: &[String],
next_steps: &[String],
) {
if let Some(hint) = dry_run_hint {
if report.dry_run {
rail_node(RailMarker::Hollow, "Dry run", Some(hint));
}
}
for note in extra_notes {
rail_node(RailMarker::Hollow, "Nota", Some(note));
}
print_timeline(&timeline);
print_action_summary(&report.actions, &report.target);
print_bot_install_hint(&report.target, report.dry_run, report.bot_decision);
print_quick_start(next_steps, title);
}
fn bot_decision_label(decision: BotBundleDecision, dry_run: bool) -> &'static str {
if dry_run {
return "Não perguntado (dry-run)";
}
match decision.prompt_answer {
Some(true) => "Sim",
Some(false) => "Não",
None if decision.include_bundle => "Já configurado",
None => "Não solicitado",
}
}
fn bot_timeline_step(report: &InstallReport) -> Option<TimelineStep> {
if report.bot_decision.prompt_answer.is_some() {
return None;
}
Some(TimelineStep::new(
"Bot Slack opcional",
Some(bot_decision_label(report.bot_decision, report.dry_run).to_string()),
))
}
fn run_install_at_target(
options: ResolvedInstallOptions,
bot_decision: BotBundleDecision,
) -> Result<InstallReport> {
let target = options.target;
let preset = options.preset;
let actions = install_package(PackageInstallOptions {
target: &target,
preset: &preset,
layout: options.layout,
force_files: options.force_files,
force_config: options.force_config,
dry_run: options.dry_run,
skip_docs: options.skip_docs,
with_ci: options.with_ci,
with_examples: options.with_examples,
with_claude_plugin: options.with_claude_plugin,
include_bot_bundle: bot_decision.include_bundle,
cleanup_legacy: options.cleanup_legacy,
merge_directories: options.merge_directories,
})?;
Ok(InstallReport {
target,
preset,
dry_run: options.dry_run,
actions,
bot_decision,
})
}
fn run_init(args: InitArgs) -> Result<()> {
if let Some(name) = args.name {
init_store(&args.root, &name, args.force, args.dry_run)?;
return Ok(());
}
let target = detect_init_target(&args.root)?;
let preset = if args.preset == "auto" {
detect_preset(&target)
} else {
args.preset.clone()
};
let dry_run = args.dry_run;
print_install_intro(
"SDD project init",
&target,
&preset,
args.layout,
args.dry_run,
);
let bot_decision = resolve_bot_bundle_decision(
&target,
dry_run,
"Bot Slack opcional - instalar agora? (s/N): ",
)?;
let loader = CliLoader::start("Sincronizando camada SDD");
let report = run_install_at_target(
ResolvedInstallOptions {
target: target.clone(),
preset,
layout: args.layout,
force_files: args.force,
force_config: args.force,
dry_run,
skip_docs: false,
with_ci: args.with_ci,
with_examples: args.with_examples,
with_claude_plugin: args.with_claude_plugin,
cleanup_legacy: args.cleanup_legacy,
merge_directories: false,
},
bot_decision,
)?;
loader.finish("camada instalada");
let loader = CliLoader::start("Preparando CodeGraph");
let optimization = load_sdd_config(&target)
.map(|config| config.optimization)
.unwrap_or_default();
let codegraph = runtime::optimization::auto_index_codegraph(&target, &optimization, dry_run);
loader.finish("índice verificado");
let codegraph_message = codegraph.message;
let mut timeline = Vec::new();
if let Some(step) = bot_timeline_step(&report) {
timeline.push(step);
}
timeline.push(TimelineStep::filled(
"CodeGraph",
Some(format!("CodeGraph: {codegraph_message}")),
));
let next_steps = vec![
format!("cd {}", runtime::platform::display_path(&report.target)),
"sdd doctor".to_string(),
"sdd init \"minha-orquestracao\"".to_string(),
];
print_install_report(
"Done! SDD project init complete.",
&report,
Some("DRY RUN: no files were written. Remove --dry-run to install."),
timeline,
&[],
&next_steps,
);
Ok(())
}
fn detect_init_target(root_arg: &Path) -> Result<PathBuf> {
let base = if root_arg == Path::new(".") {
process_cwd()?
} else {
runtime::platform::canonicalize(root_arg).unwrap_or_else(|_| root_arg.to_path_buf())
};
if base.join(".sdd").is_dir() || base.join("sdd.config.yaml").exists() {
return Ok(base);
}
for current in base.ancestors() {
if current.join(".sdd").is_dir()
|| current.join("sdd.config.yaml").exists()
|| current.join(".git").is_dir()
{
return Ok(current.to_path_buf());
}
}
for current in base.ancestors() {
if has_project_marker(current) {
return Ok(current.to_path_buf());
}
}
Ok(base)
}
fn has_project_marker(path: &Path) -> bool {
[
"package.json",
"pyproject.toml",
"requirements.txt",
"poetry.lock",
"uv.lock",
"Pipfile",
"Cargo.toml",
"go.mod",
"pom.xml",
"build.gradle",
"build.gradle.kts",
"settings.gradle",
"settings.gradle.kts",
"gradlew",
"Gemfile",
"composer.json",
"pubspec.yaml",
"Package.swift",
"Podfile",
"pnpm-workspace.yaml",
"turbo.json",
"nx.json",
"lerna.json",
]
.iter()
.any(|marker| path.join(marker).exists())
|| path.join("infra").is_dir()
|| path.join("terraform").is_dir()
|| path.join("k8s").is_dir()
}
fn detect_preset(root: &Path) -> String {
if is_monorepo(root) {
"monorepo".to_string()
} else if is_infra(root) {
"infra".to_string()
} else if is_rust_api(root) {
"rust-api".to_string()
} else if is_python_api(root) {
"python-api".to_string()
} else if is_hono_api(root) {
"hono-api".to_string()
} else if is_frontend_react(root) {
"frontend-react".to_string()
} else if is_node(root) {
"backend-node".to_string()
} else {
"generic".to_string()
}
}
fn is_monorepo(root: &Path) -> bool {
["pnpm-workspace.yaml", "turbo.json", "nx.json", "lerna.json"]
.iter()
.any(|marker| root.join(marker).exists())
|| read_optional(root.join("package.json"))
.is_some_and(|text| text.contains("\"workspaces\""))
|| (root.join("apps").is_dir() && root.join("packages").is_dir())
}
fn is_infra(root: &Path) -> bool {
root.join("terraform").is_dir()
|| root.join("infra").is_dir()
|| root.join("k8s").is_dir()
|| fs::read_dir(root).ok().is_some_and(|entries| {
entries.flatten().any(|entry| {
entry
.path()
.extension()
.and_then(OsStr::to_str)
.is_some_and(|extension| extension == "tf")
})
})
}
fn is_frontend_react(root: &Path) -> bool {
read_optional(root.join("package.json")).is_some_and(|text| {
["react", "next", "vite", "@vitejs/plugin-react", "remix"]
.iter()
.any(|term| text.contains(term))
})
}
fn is_node(root: &Path) -> bool {
root.join("package.json").exists()
}
fn is_hono_api(root: &Path) -> bool {
read_optional(root.join("package.json")).is_some_and(|text| {
let lowered = text.to_lowercase();
lowered.contains("\"hono\"") || lowered.contains("@hono/")
})
}
fn is_python_api(root: &Path) -> bool {
[
"pyproject.toml",
"requirements.txt",
"poetry.lock",
"uv.lock",
"Pipfile",
]
.iter()
.any(|marker| root.join(marker).exists())
}
fn is_rust_api(root: &Path) -> bool {
if !root.join("Cargo.toml").exists() {
return false;
}
let manifest = read_optional(root.join("Cargo.toml"))
.unwrap_or_default()
.to_lowercase();
[
"axum",
"actix-web",
"rocket",
"poem",
"salvo",
"warp",
"tonic",
]
.iter()
.any(|term| manifest.contains(term))
|| root.join("src/routes").is_dir()
|| root.join("src/handlers").is_dir()
|| root.join("src/controllers").is_dir()
}
fn read_optional(path: PathBuf) -> Option<String> {
fs::read_to_string(path).ok()
}
fn has_any_marker(root: &Path, markers: &[&str]) -> bool {
markers.iter().any(|marker| root.join(marker).exists())
}
fn has_any_path(root: &Path, paths: &[&str]) -> bool {
paths.iter().any(|path| root.join(path).exists())
}
fn has_root_entry_with_extension(root: &Path, extensions: &[&str]) -> bool {
fs::read_dir(root).ok().is_some_and(|entries| {
entries.flatten().any(|entry| {
entry
.path()
.extension()
.and_then(OsStr::to_str)
.is_some_and(|extension| extensions.contains(&extension))
})
})
}
fn has_nested_marker(root: &Path, markers: &[&str]) -> bool {
for entry in WalkDir::new(root)
.max_depth(4)
.into_iter()
.filter_entry(|entry| {
let name = entry.file_name().to_string_lossy();
!matches!(
name.as_ref(),
".git"
| ".sdd"
| "target"
| "node_modules"
| ".next"
| "dist"
| "build"
| "vendor"
| ".venv"
| "__pycache__"
)
})
{
let Ok(entry) = entry else {
continue;
};
let name = entry.file_name().to_string_lossy();
if markers.iter().any(|marker| name == *marker) {
return true;
}
}
false
}
fn has_nested_extension(root: &Path, extensions: &[&str]) -> bool {
for entry in WalkDir::new(root)
.max_depth(4)
.into_iter()
.filter_entry(|entry| {
let name = entry.file_name().to_string_lossy();
!matches!(
name.as_ref(),
".git"
| ".sdd"
| "target"
| "node_modules"
| ".next"
| "dist"
| "build"
| "vendor"
| ".venv"
| "__pycache__"
)
})
{
let Ok(entry) = entry else {
continue;
};
if entry
.path()
.extension()
.and_then(OsStr::to_str)
.is_some_and(|extension| extensions.contains(&extension))
{
return true;
}
}
false
}
#[derive(Clone)]
struct FsLayerSource {
project_root: PathBuf,
layer_root: PathBuf,
}
enum LayerSource {
Fs(FsLayerSource),
Embedded,
}
#[derive(Clone, Copy)]
struct CopyPolicy {
force: bool,
dry_run: bool,
include_claude_plugin: bool,
merge_directories: bool,
}
impl LayerSource {
fn detect_for_target(target: &Path) -> Self {
find_fs_layer_source(Some(target))
.map(LayerSource::Fs)
.unwrap_or(LayerSource::Embedded)
}
fn path(&self, rel: &str, root_item: bool) -> Option<PathBuf> {
match self {
LayerSource::Fs(source) => Some(if root_item {
source.project_root.join(rel)
} else {
source.layer_root.join(rel)
}),
LayerSource::Embedded => None,
}
}
fn exists(&self, rel: &str, root_item: bool) -> bool {
match self {
LayerSource::Fs(_) => self.path(rel, root_item).is_some_and(|path| path.exists()),
LayerSource::Embedded => embedded_exists(rel),
}
}
fn copy_item(
&self,
rel: &str,
dst: &Path,
root_item: bool,
policy: CopyPolicy,
) -> Result<String> {
let shown = runtime::platform::display_path(dst);
let mergeable_file = dst.is_file() && is_mergeable_root_file(rel, root_item);
let action = if dst.exists() {
if mergeable_file {
format!("merge {shown}")
} else if !policy.force {
return Ok(format!("skip existing {shown}"));
} else if policy.merge_directories && dst.is_dir() {
format!("update {shown}")
} else {
format!("replace {shown}")
}
} else {
format!("copy {shown}")
};
if policy.dry_run {
return Ok(format!("dry-run {action}"));
}
if mergeable_file {
let source = self.read_file(rel, root_item)?;
merge_managed_file(dst, rel, &source)?;
return Ok(action);
}
if dst.exists() && !(policy.merge_directories && dst.is_dir()) {
if dst.is_dir() {
fs::remove_dir_all(dst)?;
} else {
fs::remove_file(dst)?;
}
}
match self {
LayerSource::Fs(_) => {
let src = self
.path(rel, root_item)
.ok_or_else(|| anyhow!("installer source missing: {rel}"))?;
if !src.exists() {
bail!("installer source missing: {}", src.display());
}
if src.is_dir() {
copy_dir(&src, dst, policy.include_claude_plugin)?;
} else {
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(src, dst)?;
}
}
LayerSource::Embedded => copy_embedded_item(rel, dst, policy.include_claude_plugin)?,
}
Ok(action)
}
fn read_file(&self, rel: &str, root_item: bool) -> Result<Vec<u8>> {
match self {
LayerSource::Fs(_) => {
let path = self
.path(rel, root_item)
.ok_or_else(|| anyhow!("installer source missing: {rel}"))?;
fs::read(&path).with_context(|| format!("reading {}", path.display()))
}
LayerSource::Embedded => embedded_file(rel)
.map(|bytes| bytes.to_vec())
.ok_or_else(|| anyhow!("installer source missing: {rel}")),
}
}
}
fn is_mergeable_root_file(rel: &str, root_item: bool) -> bool {
root_item && matches!(rel, "AGENTS.md" | "CLAUDE.md")
}
fn merge_managed_file(dst: &Path, rel: &str, source: &[u8]) -> Result<()> {
let source = String::from_utf8(source.to_vec())
.with_context(|| format!("reading UTF-8 installer source for {rel}"))?;
let existing = fs::read_to_string(dst).with_context(|| format!("reading {}", dst.display()))?;
let next = merge_managed_text(rel, &existing, &source);
if next != existing {
fs::write(dst, next)?;
}
Ok(())
}
fn merge_managed_text(rel: &str, existing: &str, source: &str) -> String {
if is_packaged_agent_doc(rel, existing) || existing.trim() == source.trim() {
return ensure_trailing_newline(source.trim_end());
}
let block = managed_block(source);
if let Some(start) = existing.find(MANAGED_BLOCK_START) {
if let Some(end_offset) = existing[start..].find(MANAGED_BLOCK_END) {
let end = start + end_offset + MANAGED_BLOCK_END.len();
let mut next = String::new();
next.push_str(existing[..start].trim_end());
next.push_str("\n\n");
next.push_str(block.trim_end());
let suffix = existing[end..].trim_start_matches(['\r', '\n']);
if !suffix.is_empty() {
next.push_str("\n\n");
next.push_str(suffix);
}
return ensure_trailing_newline(next.trim_end());
}
}
let mut next = String::new();
next.push_str(existing.trim_end());
if !next.is_empty() {
next.push_str("\n\n");
}
next.push_str(block.trim_end());
ensure_trailing_newline(&next)
}
fn is_packaged_agent_doc(rel: &str, existing: &str) -> bool {
let expected_title = format!("# {rel} — Camada de orquestração SDLC (Spec-Driven)");
existing.trim_start().starts_with(&expected_title)
}
fn managed_block(source: &str) -> String {
format!(
"{MANAGED_BLOCK_START}\n{}\n{MANAGED_BLOCK_END}\n",
source.trim_end()
)
}
fn ensure_trailing_newline(text: &str) -> String {
let mut next = text.to_string();
if !next.ends_with('\n') {
next.push('\n');
}
next
}
fn find_fs_layer_source(excluded_target: Option<&Path>) -> Option<FsLayerSource> {
let excluded_target = excluded_target.and_then(|target| target.canonicalize().ok());
if let Ok(value) = env::var("SDD_LAYER_ROOT") {
if let Some(source) = fs_layer_from_path(PathBuf::from(value), true) {
if source_matches_target(&source, excluded_target.as_deref()) {
return None;
}
return Some(source);
}
}
let cwd = process_cwd().ok()?;
for current in cwd.ancestors() {
if let Some(source) = fs_layer_from_path(current.to_path_buf(), false) {
if source_overlaps_target(&source, excluded_target.as_deref()) {
continue;
}
return Some(source);
}
}
let exe = env::current_exe().ok()?;
for current in exe.ancestors() {
if let Some(source) = fs_layer_from_path(current.to_path_buf(), false) {
if source_overlaps_target(&source, excluded_target.as_deref()) {
continue;
}
return Some(source);
}
}
None
}
fn source_matches_target(source: &FsLayerSource, target: Option<&Path>) -> bool {
target.is_some_and(|target| source.project_root == target || source.layer_root == target)
}
fn source_overlaps_target(source: &FsLayerSource, target: Option<&Path>) -> bool {
source_matches_target(source, target)
|| target.is_some_and(|target| target.starts_with(&source.project_root))
}
/// Resolve um diretório como fonte da camada SDD.
///
/// O caso 1 (layout flat: `presets/` + `AGENTS.md` na raiz) é a distribuição
/// real do sdd-layer e é sempre aceito. Os casos 2 e 3 correspondem a formas
/// `.sdd` de um projeto-consumidor instalado; usá-los como fonte ao atualizar
/// OUTRO projeto copiaria surfaces possivelmente defasadas/customizadas do
/// consumidor para o alvo. Por isso só são aceitos quando `allow_consumer` é
/// true — o que ocorre apenas via `SDD_LAYER_ROOT` explícito, nunca na
/// descoberta automática por cwd/exe.
fn fs_layer_from_path(path: PathBuf, allow_consumer: bool) -> Option<FsLayerSource> {
let canonical = path.canonicalize().ok()?;
if canonical.join("presets").is_dir() && canonical.join("AGENTS.md").exists() {
return Some(FsLayerSource {
project_root: canonical.clone(),
layer_root: canonical,
});
}
if !allow_consumer {
return None;
}
if canonical.join("presets").is_dir()
&& canonical.file_name().and_then(OsStr::to_str) == Some(".sdd")
{
let project_root = canonical.parent()?.to_path_buf();
if project_root.join("AGENTS.md").exists() {
return Some(FsLayerSource {
project_root,
layer_root: canonical,
});
}
}
if canonical.join(".sdd/presets").is_dir() && canonical.join("AGENTS.md").exists() {
return Some(FsLayerSource {
project_root: canonical.clone(),
layer_root: canonical.join(".sdd"),
});
}
None
}
fn embedded_exists(rel: &str) -> bool {
let prefix = format!("{}/", rel.trim_end_matches('/'));
embedded::EMBEDDED_FILES
.iter()
.any(|(path, _)| *path == rel || path.starts_with(&prefix))
}
fn embedded_file(rel: &str) -> Option<&'static [u8]> {
embedded::EMBEDDED_FILES
.iter()
.find_map(|(path, bytes)| (*path == rel).then_some(*bytes))
}
fn copy_embedded_item(rel: &str, dst: &Path, include_claude_plugin: bool) -> Result<()> {
if let Some(bytes) = embedded_file(rel) {
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent)?;
}
fs::write(dst, bytes)?;
return Ok(());
}
let prefix = format!("{}/", rel.trim_end_matches('/'));
let mut copied = false;
for (path, bytes) in embedded::EMBEDDED_FILES {
if !path.starts_with(&prefix) {
continue;
}
if !include_claude_plugin && path.starts_with(".claude/skills/orchestration-plugin/") {
continue;
}
copied = true;
let child = &path[prefix.len()..];
let target = dst.join(child);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
fs::write(target, bytes)?;
}
if copied {
Ok(())
} else {
bail!("installer source missing: {rel}")
}
}
fn stage_file(stage: &str) -> Option<&'static str> {
contract::stage_file(stage)
}
fn artifact_dir(root: &Path, name: &str) -> PathBuf {
root.join("docs").join(slugify(name))
}
fn render_index(name: &str) -> String {
let mut lines = vec![
"orchestration:".to_string(),
format!(" name: \"{name}\""),
format!(" slug: \"{}\"", slugify(name)),
format!(" created_at: \"{}\"", now()),
" state: draft".to_string(),
" source: local-artifact-store".to_string(),
String::new(),
"artifacts:".to_string(),
];
for stage in contract::stages() {
lines.push(format!(" {}:", stage.key));
lines.push(format!(" file: {}", stage.filename));
lines.push(format!(" state: {}", stage.initial_state));
lines.push(" approved_at: \"\"".to_string());
if stage.optional {
lines.push(" skipped_reason: \"\"".to_string());
}
}
lines.extend([
String::new(),
"external_links:".to_string(),
" jira: []".to_string(),
" confluence: []".to_string(),
" bitbucket: []".to_string(),
" github: []".to_string(),
" gitlab: []".to_string(),
String::new(),
"notes: []".to_string(),
String::new(),
]);
lines.join("\n")
}
fn init_store(root_arg: &Path, name: &str, force: bool, dry_run: bool) -> Result<PathBuf> {
let root = runtime::platform::canonicalize(root_arg).unwrap_or_else(|_| root_arg.to_path_buf());
let dest = artifact_dir(&root, name);
if dry_run {
println!("dry-run init {}", dest.display());
return Ok(dest);
}
fs::create_dir_all(&dest)?;
let index = dest.join("traceability-map.yaml");
if index.exists() && !force {
println!("{}", dest.display());
println!("index exists: {}", index.display());
return Ok(dest);
}
fs::write(&index, render_index(name))?;
let readme = dest.join("README.md");
if !readme.exists() {
fs::write(
readme,
format!("# SDD - {name}\n\nArtefatos locais desta orquestração.\n"),
)?;
}
println!("{}", dest.display());
Ok(dest)
}
fn ensure_index(dest: &Path, name: &str) -> Result<PathBuf> {
let index = dest.join("traceability-map.yaml");
if !index.exists() {
fs::write(&index, render_index(name))?;
}
Ok(index)
}
fn update_stage_state(index: &Path, stage: &str, state: &str) -> Result<()> {
let text = fs::read_to_string(index)?;
let mut lines: Vec<String> = text.lines().map(str::to_string).collect();
let orchestration_state = if stage == "memory" {
"completed"
} else {
"in_progress"
};
for line in &mut lines {
if line == "artifacts:" {
break;
}
if line.starts_with(" state: ") {
*line = format!(" state: {orchestration_state}");
break;
}
}
let stage_header = format!(" {stage}:");
let mut in_stage = false;
let mut seen_state = false;
let mut seen_approved_at = false;
let mut insert_at = None;
let timestamp = now();
let mut idx = 0;
while idx < lines.len() {
let line = &lines[idx];
if line == &stage_header {
in_stage = true;
insert_at = Some(idx + 1);
idx += 1;
continue;
}
if in_stage && line.starts_with(" ") && !line.starts_with(" ") {
insert_at = Some(idx);
break;
}
if !in_stage {
idx += 1;
continue;
}
if line.trim_start().starts_with("state:") {
lines[idx] = format!(" state: {state}");
seen_state = true;
} else if line.trim_start().starts_with("approved_at:") {
let approved_at = if state == "approved" {
timestamp.as_str()
} else {
""
};
lines[idx] = format!(" approved_at: \"{approved_at}\"");
seen_approved_at = true;
}
idx += 1;
}
if in_stage {
let idx = insert_at.unwrap_or(lines.len());
let mut additions = Vec::new();
if !seen_state {
additions.push(format!(" state: {state}"));
}
if !seen_approved_at {
let approved_at = if state == "approved" {
timestamp.as_str()
} else {
""
};
additions.push(format!(" approved_at: \"{approved_at}\""));
}
lines.splice(idx..idx, additions);
}
fs::write(index, format!("{}\n", lines.join("\n")))?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn save_artifact(
root_arg: &Path,
name: &str,
stage: &str,
file: Option<&Path>,
state: &str,
append: bool,
force: bool,
dry_run: bool,
) -> Result<PathBuf> {
let filename = stage_file(stage).ok_or_else(|| anyhow!("unknown stage: {stage}"))?;
validate_state(state)?;
let root = runtime::platform::canonicalize(root_arg).unwrap_or_else(|_| root_arg.to_path_buf());
let dest = artifact_dir(&root, name);
let target = dest.join(filename);
if dry_run {
println!("dry-run save {}", target.display());
return Ok(target);
}
fs::create_dir_all(&dest)?;
let index = ensure_index(&dest, name)?;
if target.exists() && !force && !append {
bail!(
"refusing to overwrite existing artifact: {}",
target.display()
);
}
let mut content = match file {
Some(path) => fs::read_to_string(path)
.with_context(|| format!("reading artifact input {}", path.display()))?,
None => {
let mut buf = String::new();
io::stdin().read_to_string(&mut buf)?;
buf
}
};
if append && target.exists() {
let previous = fs::read_to_string(&target)?.trim_end().to_string();
content = format!("{previous}\n\n---\n\n{}", content.trim_start());
}
// Gate de acentuação PT-BR: bloqueia ao aprovar; apenas avisa nos demais estados.
let accent_issues = collect_accent_issues(&content);
if !accent_issues.is_empty() {
if state == "approved" {
let mut msg = String::from(
"gate de acentuação: artefato contém palavras PT-BR sem acento. Corrija antes de aprovar:",
);
for issue in &accent_issues {
msg.push_str(&format!("\n- {issue}"));
}
bail!(msg);
}
eprintln!(
"aviso (gate de acentuação): {} palavra(s) PT-BR sem acento em '{}' (estado {}):",
accent_issues.len(),
stage,
state
);
for issue in &accent_issues {
eprintln!("- {issue}");
}
}
tui::persist::write_atomic(&target, content.as_bytes())?;
update_stage_state(&index, stage, state)?;
println!("{}", target.display());
Ok(target)
}
fn validate_state(state: &str) -> Result<()> {
match state {
"approved" | "recorded" | "draft" | "skipped" | "in_progress" => Ok(()),
other => bail!("unknown artifact state: {other}"),
}
}
fn status_artifact(root_arg: &Path, name: &str, json_output: bool) -> Result<()> {
let root = runtime::platform::canonicalize(root_arg).unwrap_or_else(|_| root_arg.to_path_buf());
let dest = artifact_dir(&root, name);
if json_output {
let artifacts: BTreeMap<_, _> = contract::stages()
.iter()
.map(|stage| {
let exists = dest.join(&stage.filename).exists();
(
stage.key.as_str(),
json!({ "file": stage.filename, "exists": exists }),
)
})
.collect();
println!(
"{}",
serde_json::to_string_pretty(&json!({
"path": dest,
"exists": dest.exists(),
"artifacts": artifacts,
}))?
);
return Ok(());
}
println!("{}", dest.display());
if !dest.exists() {
println!("missing");
bail!("artifact store missing");
}
for stage in contract::stages() {
let marker = if dest.join(&stage.filename).exists() {
"ok"
} else {
"missing"
};
println!("{}: {marker} {}", stage.key, stage.filename);
}
Ok(())
}
fn run_artifact(args: ArtifactArgs) -> Result<()> {
match args.command {
ArtifactCommand::Init {
name,
force,
dry_run,
} => {
init_store(&args.root, &name, force, dry_run)?;
}
ArtifactCommand::Save {
name,
stage,
file,
state,
append,
force,
dry_run,
} => {
save_artifact(
&args.root,
&name,
&stage,
file.as_deref(),
&state,
append,
force,
dry_run,
)?;
}
ArtifactCommand::Status { name, json } => status_artifact(&args.root, &name, json)?,
}
Ok(())
}
fn required_sections() -> BTreeMap<&'static str, Vec<&'static str>> {
contract::artifact_required_section_map()
}
fn collect_headings(markdown: &str) -> Result<BTreeSet<String>> {
let re = Regex::new(r"^#{1,6}\s+(.+?)\s*$")?;
Ok(markdown
.lines()
.filter_map(|line| re.captures(line).and_then(|cap| cap.get(1)))
.map(|m| normalize_heading(m.as_str()))
.collect())
}
#[derive(Default)]
struct ArtifactValidationReport {
missing_sections: Vec<String>,
semantic_issues: Vec<String>,
}
fn collect_sections(markdown: &str) -> Result<BTreeMap<String, String>> {
let re = Regex::new(r"^#{1,6}\s+(.+?)\s*$")?;
let mut sections = BTreeMap::new();
let mut current_heading: Option<String> = None;
let mut current_body = Vec::new();
for line in markdown.lines() {
if let Some(capture) = re.captures(line) {
if let Some(heading) = current_heading.take() {
sections.insert(heading, current_body.join("\n").trim().to_string());
}
current_heading = capture.get(1).map(|item| normalize_heading(item.as_str()));
current_body.clear();
} else if current_heading.is_some() {
current_body.push(line.to_string());
}
}
if let Some(heading) = current_heading {
sections.insert(heading, current_body.join("\n").trim().to_string());
}
Ok(sections)
}
fn schema_required_sections(schema: &Value, artifact_type: &str) -> Result<Vec<String>> {
let required = schema
.get(artifact_type)
.ok_or_else(|| anyhow!("unknown artifact type: {artifact_type}"))?
.get("required_sections")
.and_then(Value::as_array)
.ok_or_else(|| anyhow!("schema missing required_sections for {artifact_type}"))?;
Ok(required
.iter()
.filter_map(Value::as_str)
.map(|item| item.to_string())
.collect::<Vec<_>>())
}
fn section_body<'a>(sections: &'a BTreeMap<String, String>, heading: &str) -> Option<&'a str> {
sections
.get(&normalize_heading(heading))
.map(String::as_str)
.filter(|body| !body.trim().is_empty())
}
fn extract_task_ids(text: &str) -> Result<BTreeSet<String>> {
let regexes = contract::artifact_prompt_id_patterns()
.into_iter()
.map(Regex::new)
.collect::<std::result::Result<Vec<_>, _>>()?;
let mut ids = BTreeSet::new();
for raw in text.split_whitespace() {
let token = raw.trim_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '-');
if regexes.iter().any(|regex| regex.is_match(token)) {
ids.insert(token.to_string());
}
}
Ok(ids)
}
fn validate_traceability_rule(
artifact_type: &str,
sections: &BTreeMap<String, String>,
) -> Vec<String> {
let mut issues = Vec::new();
let Some(body) = section_body(sections, "Rastreabilidade") else {
issues.push("seção `Rastreabilidade` vazia ou sem conteúdo verificável".to_string());
return issues;
};
let dependencies = contract::stage_traceability_from(artifact_type);
if dependencies.is_empty() {
return issues;
}
let haystack = body.to_lowercase();
let related = dependencies.iter().any(|stage| {
haystack.contains(&stage.key.to_lowercase())
|| haystack.contains(&stage.filename.to_lowercase())
|| haystack.contains(&stage.label.to_lowercase())
});
if !related {
let expected = dependencies
.iter()
.map(|stage| stage.filename.as_str())
.collect::<Vec<_>>()
.join(", ");
issues.push(format!(
"Rastreabilidade deve citar pelo menos um artefato de origem esperado: {expected}"
));
}
issues
}
fn validate_diagrams_rule(sections: &BTreeMap<String, String>) -> Vec<String> {
let mut issues = Vec::new();
let Some(body) = section_body(sections, "Diagramas") else {
issues.push("seção `Diagramas` vazia ou sem conteúdo verificável".to_string());
return issues;
};
let body_lower = body.to_lowercase();
let valid = contract::artifact_allowed_diagram_markers()
.into_iter()
.map(|marker| marker.to_lowercase())
.any(|marker| body_lower.contains(&marker));
if !valid {
issues.push(
"Diagramas deve conter Mermaid, referência `.excalidraw` ou `Não aplicável`"
.to_string(),
);
}
issues
}
fn validate_prompts_agent_rule(sections: &BTreeMap<String, String>) -> Result<Vec<String>> {
let mut issues = Vec::new();
let backlog_ids = section_body(sections, "Backlog")
.map(extract_task_ids)
.transpose()?
.unwrap_or_default();
let prompt_ids = section_body(sections, "Prompts Agent")
.map(extract_task_ids)
.transpose()?
.unwrap_or_default();
if prompt_ids.is_empty() {
issues.push(
"Prompts Agent deve declarar pelo menos um ID executável (`T-01` ou `TSK-01`)"
.to_string(),
);
}
let missing_prompts: Vec<_> = backlog_ids.difference(&prompt_ids).cloned().collect();
if !missing_prompts.is_empty() {
issues.push(format!(
"Prompts Agent está sem prompts para: {}",
missing_prompts.join(", ")
));
}
Ok(issues)
}
fn validate_checkpoint_rule(sections: &BTreeMap<String, String>) -> Vec<String> {
let mut issues = Vec::new();
for heading in [
"Artefato",
"Origem",
"Decisão necessária",
"Evidências",
"Riscos pendentes",
] {
if section_body(sections, heading).is_none() {
issues.push(format!("Checkpoint deve preencher `{heading}`"));
}
}
if let Some(decision) = section_body(sections, "Decisão necessária") {
let decision_lower = decision.to_lowercase();
let action_present = ["aprovar", "reprovar", "regener", "editar"]
.iter()
.any(|token| decision_lower.contains(token));
if !action_present {
issues.push(
"Checkpoint deve deixar explícita a decisão humana esperada (aprovar, reprovar, regenerar ou editar)"
.to_string(),
);
}
}
issues
}
/// Dicionário de acentuação PT-BR: `(forma sem acento, forma correta)`.
/// Base do gate determinístico que sinaliza palavras humanas escritas sem
/// acento em artefatos. NÃO inclui termos ambíguos (`e`/`é`, `este`/`está`,
/// `so`/`só`, `ja`/`já`) nem palavras que são ASCII por contrato (slugs,
/// paths, comandos, identificadores), ignoradas pela heurística de vizinhança
/// e pela remoção de blocos/spans de código.
static ACCENT_DICTIONARY: &[(&str, &str)] = &[
("acao", "ação"),
("acoes", "ações"),
("adocao", "adoção"),
("alem", "além"),
("alteracao", "alteração"),
("alteracoes", "alterações"),
("analise", "análise"),
("analises", "análises"),
("aplicaveis", "aplicáveis"),
("aplicavel", "aplicável"),
("apos", "após"),
("aprovacao", "aprovação"),
("aprovacoes", "aprovações"),
("area", "área"),
("areas", "áreas"),
("atraves", "através"),
("atualizacao", "atualização"),
("atualizacoes", "atualizações"),
("automatica", "automática"),
("automaticas", "automáticas"),
("automatico", "automático"),
("automaticos", "automáticos"),
("autonoma", "autônoma"),
("autonomas", "autônomas"),
("autonomo", "autônomo"),
("autonomos", "autônomos"),
("avanca", "avança"),
("avancar", "avançar"),
("avancou", "avançou"),
("basica", "básica"),
("basicas", "básicas"),
("basico", "básico"),
("basicos", "básicos"),
("canonica", "canônica"),
("canonicas", "canônicas"),
("canonico", "canônico"),
("canonicos", "canônicos"),
("cenario", "cenário"),
("cenarios", "cenários"),
("classificacao", "classificação"),
("codigo", "código"),
("codigos", "códigos"),
("comeca", "começa"),
("comecam", "começam"),
("comecar", "começar"),
("conclusao", "conclusão"),
("confiaveis", "confiáveis"),
("confiavel", "confiável"),
("configuracao", "configuração"),
("configuracoes", "configurações"),
("contrario", "contrário"),
("criterio", "critério"),
("criterios", "critérios"),
("decisao", "decisão"),
("decisoes", "decisões"),
("dependencia", "dependência"),
("dependencias", "dependências"),
("descricao", "descrição"),
("descricoes", "descrições"),
("deterministica", "determinística"),
("deterministico", "determinístico"),
("deterministicos", "determinísticos"),
("disponiveis", "disponíveis"),
("disponivel", "disponível"),
("dominio", "domínio"),
("dominios", "domínios"),
("especifica", "específica"),
("especificas", "específicas"),
("especifico", "específico"),
("especificos", "específicos"),
("estaveis", "estáveis"),
("estavel", "estável"),
("estrategia", "estratégia"),
("estrategias", "estratégias"),
("evidencia", "evidência"),
("evidencias", "evidências"),
("execucao", "execução"),
("execucoes", "execuções"),
("executaveis", "executáveis"),
("executavel", "executável"),
("experiencia", "experiência"),
("experiencias", "experiências"),
("funcao", "função"),
("funcoes", "funções"),
("generica", "genérica"),
("genericas", "genéricas"),
("generico", "genérico"),
("genericos", "genéricos"),
("geracao", "geração"),
("glossario", "glossário"),
("hipotese", "hipótese"),
("hipoteses", "hipóteses"),
("historico", "histórico"),
("historicos", "históricos"),
("implementacao", "implementação"),
("implementacoes", "implementações"),
("indisponiveis", "indisponíveis"),
("indisponivel", "indisponível"),
("instalacao", "instalação"),
("instrucao", "instrução"),
("instrucoes", "instruções"),
("interoperaveis", "interoperáveis"),
("interoperavel", "interoperável"),
("inventario", "inventário"),
("invocaveis", "invocáveis"),
("invocavel", "invocável"),
("iteracao", "iteração"),
("iteracoes", "iterações"),
("legivel", "legível"),
("logica", "lógica"),
("materializaveis", "materializáveis"),
("materializavel", "materializável"),
("maxima", "máxima"),
("maximas", "máximas"),
("maximo", "máximo"),
("maximos", "máximos"),
("memoria", "memória"),
("memorias", "memórias"),
("metodo", "método"),
("metodos", "métodos"),
("metrica", "métrica"),
("metricas", "métricas"),
("migracao", "migração"),
("migracoes", "migrações"),
("minima", "mínima"),
("minimas", "mínimas"),
("minimo", "mínimo"),
("minimos", "mínimos"),
("modulo", "módulo"),
("modulos", "módulos"),
("mudanca", "mudança"),
("mudancas", "mudanças"),
("multipla", "múltipla"),
("multiplas", "múltiplas"),
("multiplo", "múltiplo"),
("multiplos", "múltiplos"),
("nao", "não"),
("necessaria", "necessária"),
("necessarias", "necessárias"),
("necessario", "necessário"),
("necessarios", "necessários"),
("niveis", "níveis"),
("nivel", "nível"),
("notificacao", "notificação"),
("notificacoes", "notificações"),
("numero", "número"),
("numeros", "números"),
("obrigatoria", "obrigatória"),
("obrigatorias", "obrigatórias"),
("obrigatorio", "obrigatório"),
("obrigatorios", "obrigatórios"),
("observacao", "observação"),
("observacoes", "observações"),
("obvia", "óbvia"),
("obvio", "óbvio"),
("orquestracao", "orquestração"),
("orquestracoes", "orquestrações"),
("padrao", "padrão"),
("padroes", "padrões"),
("peca", "peça"),
("porem", "porém"),
("possiveis", "possíveis"),
("possivel", "possível"),
("pratica", "prática"),
("praticas", "práticas"),
("propria", "própria"),
("proprias", "próprias"),
("proprio", "próprio"),
("proprios", "próprios"),
("proxima", "próxima"),
("proximas", "próximas"),
("proximo", "próximo"),
("proximos", "próximos"),
("publica", "pública"),
("publicas", "públicas"),
("publico", "público"),
("publicos", "públicos"),
("rapida", "rápida"),
("rapidas", "rápidas"),
("rapido", "rápido"),
("rapidos", "rápidos"),
("recomendacao", "recomendação"),
("recomendacoes", "recomendações"),
("referencia", "referência"),
("referencias", "referências"),
("regeneracao", "regeneração"),
("relatorio", "relatório"),
("relatorios", "relatórios"),
("reproduziveis", "reproduzíveis"),
("reproduzivel", "reproduzível"),
("responsaveis", "responsáveis"),
("responsavel", "responsável"),
("reutilizaveis", "reutilizáveis"),
("reutilizavel", "reutilizável"),
("saida", "saída"),
("saidas", "saídas"),
("sao", "são"),
("secao", "seção"),
("secoes", "seções"),
("seguranca", "segurança"),
("sera", "será"),
("serao", "serão"),
("servico", "serviço"),
("servicos", "serviços"),
("solucao", "solução"),
("solucoes", "soluções"),
("tambem", "também"),
("tecnica", "técnica"),
("tecnicas", "técnicas"),
("tecnico", "técnico"),
("tecnicos", "técnicos"),
("tera", "terá"),
("terao", "terão"),
("testaveis", "testáveis"),
("testavel", "testável"),
("titulo", "título"),
("titulos", "títulos"),
("ultima", "última"),
("ultimas", "últimas"),
("ultimo", "último"),
("ultimos", "últimos"),
("unica", "única"),
("unicas", "únicas"),
("unico", "único"),
("unicos", "únicos"),
("usuario", "usuário"),
("usuarios", "usuários"),
("uteis", "úteis"),
("validacao", "validação"),
("validacoes", "validações"),
("variaveis", "variáveis"),
("variavel", "variável"),
("verificacao", "verificação"),
("verificacoes", "verificações"),
("versao", "versão"),
("versoes", "versões"),
("visivel", "visível"),
("voce", "você"),
];
fn accent_lookup(word_lower: &str) -> Option<&'static str> {
ACCENT_DICTIONARY
.iter()
.find(|(deacc, _)| *deacc == word_lower)
.map(|(_, acc)| *acc)
}
/// Substitui o conteúdo de spans de código inline (entre crases) por espaços,
/// preservando posições, para que paths/comandos/identificadores não sejam
/// avaliados pelo gate de acentuação.
fn mask_inline_code(line: &str) -> String {
let mut out = String::with_capacity(line.len());
let mut in_code = false;
for ch in line.chars() {
if ch == '`' {
in_code = !in_code;
out.push(' ');
} else if in_code {
out.push(' ');
} else {
out.push(ch);
}
}
out
}
/// Coleta palavras PT-BR sem acento em conteúdo humano de um artefato.
/// Ignora blocos cercados (```` ``` ````), spans inline (`` `...` ``) e tokens
/// colados a separadores de slug/path/identificador (`-`, `/`, `_`, `.`, `@`,
/// `:`, `\`). Retorna mensagens `linha N: "palavra" -> "sugestão"` (até 50).
fn collect_accent_issues(text: &str) -> Vec<String> {
let mut found: BTreeMap<String, (usize, &'static str)> = BTreeMap::new();
let mut in_fence = false;
for (idx, raw) in text.lines().enumerate() {
let trimmed = raw.trim_start();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
in_fence = !in_fence;
continue;
}
if in_fence {
continue;
}
let masked = mask_inline_code(raw);
let chars: Vec<char> = masked.chars().collect();
let n = chars.len();
let is_wordch = |c: char| c.is_alphanumeric() || c == '_';
let mut i = 0;
while i < n {
if !is_wordch(chars[i]) {
i += 1;
continue;
}
let start = i;
while i < n && is_wordch(chars[i]) {
i += 1;
}
let before = start.checked_sub(1).map(|b| chars[b]);
let after = chars.get(i).copied();
// Token colado a separador de identificador/slug/path: ignora.
// `.` final é pontuação de frase, então só conta como separador antes do token.
let glued_before = matches!(before, Some('-' | '/' | '_' | '.' | '@' | ':' | '\\'));
let glued_after = matches!(after, Some('-' | '/' | '_' | '@' | ':' | '\\'));
if glued_before || glued_after {
continue;
}
let lower: String = chars[start..i].iter().collect::<String>().to_lowercase();
if let Some(sug) = accent_lookup(&lower) {
found.entry(lower).or_insert((idx + 1, sug));
}
}
}
found
.into_iter()
.take(50)
.map(|(word, (line, sug))| format!("linha {line}: \"{word}\" -> \"{sug}\""))
.collect()
}
fn validate_semantic_rules(
artifact_type: &str,
sections: &BTreeMap<String, String>,
) -> Result<Vec<String>> {
let mut issues = Vec::new();
for rule in contract::artifact_semantic_validations(artifact_type) {
match rule {
"traceability" => issues.extend(validate_traceability_rule(artifact_type, sections)),
"diagrams" => issues.extend(validate_diagrams_rule(sections)),
"prompts-agent" => issues.extend(validate_prompts_agent_rule(sections)?),
"decision-package" => issues.extend(validate_checkpoint_rule(sections)),
_ => {}
}
}
Ok(issues)
}
fn validate_artifact_text(
artifact_type: &str,
text: &str,
schema: &Value,
) -> Result<ArtifactValidationReport> {
let required = schema_required_sections(schema, artifact_type)?;
let present = collect_headings(text)?;
let missing_sections = required
.iter()
.map(|item| normalize_heading(item))
.filter(|item| !present.contains(item))
.collect();
let sections = collect_sections(text)?;
let mut semantic_issues = validate_semantic_rules(artifact_type, §ions)?;
for issue in collect_accent_issues(text) {
semantic_issues.push(format!("acentuação PT-BR ausente — {issue}"));
}
Ok(ArtifactValidationReport {
missing_sections,
semantic_issues,
})
}
fn resolve_schema(path: &Path) -> PathBuf {
if path.exists() {
return path.to_path_buf();
}
let compact = Path::new(".sdd").join(path);
if compact.exists() {
return compact;
}
path.to_path_buf()
}
fn validate_artifact_command(args: ValidateArtifactArgs) -> Result<()> {
let schema_path = resolve_schema(&args.schema);
let schema_text = fs::read_to_string(&schema_path)
.with_context(|| format!("reading schema {}", schema_path.display()))?;
let schema: Value = serde_json::from_str(&schema_text)?;
let text = fs::read_to_string(&args.markdown_file)?;
let report = validate_artifact_text(&args.artifact_type, &text, &schema)?;
if report.missing_sections.is_empty() && report.semantic_issues.is_empty() {
println!(
"{}: valid {}",
args.markdown_file.display(),
args.artifact_type
);
Ok(())
} else {
if !report.missing_sections.is_empty() {
println!(
"{}: missing required sections for {}:",
args.markdown_file.display(),
args.artifact_type
);
for item in &report.missing_sections {
println!("- {item}");
}
}
if !report.semantic_issues.is_empty() {
println!(
"{}: semantic validation failed for {}:",
args.markdown_file.display(),
args.artifact_type
);
for item in &report.semantic_issues {
println!("- {item}");
}
}
bail!("artifact validation failed");
}
}
fn classify(text: &str) -> Result<RiskResult> {
Ok(domain::risk::classify(text))
}
fn term_matches(text: &str, term: &str) -> bool {
domain::risk::term_matches(text, term)
}
fn run_risk(args: RiskArgs) -> Result<()> {
let text = args.text.join(" ");
let result = classify(&text)?;
// Contexto de bot: devolve o artefato markdown no stdout e NÃO escreve em
// docs/ — o bot grava o `risk-classification.md` (com cabeçalho) e versiona.
if is_bot_context() {
let name = args
.name
.as_deref()
.filter(|value| !value.trim().is_empty())
.unwrap_or(&text);
print!("{}", render_risk_artifact(name, &text, &result));
return Ok(());
}
if args.json {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"risk_level": result.level,
"risk_factors": result.factors,
"required_checkpoints": result.checkpoints,
}))?
);
} else {
print_risk(&result);
}
if let Some(name) = args.name {
let content = render_risk_artifact(&name, &text, &result);
write_stage_content(
&args.root,
&name,
"risk",
&content,
"recorded",
args.force,
args.dry_run,
)?;
}
Ok(())
}
fn run_providers(args: ProvidersArgs) -> Result<()> {
let root = resolve_project_root(Some(args.root))?;
match args.command {
ProvidersCommand::List(list_args) => {
let catalog = provider_catalog_fast(&root)?;
if list_args.json {
let providers: Vec<Value> = catalog.iter().map(provider_json).collect();
println!(
"{}",
serde_json::to_string_pretty(&json!({ "providers": providers }))?
);
} else {
for provider in catalog {
let enabled = if provider.enabled {
"enabled"
} else {
"disabled"
};
let model = provider.model.as_deref().unwrap_or("default");
let effort = provider.effort.as_deref().unwrap_or("medium");
let auth = provider
.auth_methods
.into_iter()
.filter(|method| method.present)
.map(|method| method.label)
.collect::<Vec<_>>()
.join(",");
let auth = if auth.is_empty() {
"missing".to_string()
} else {
auth
};
println!(
"{}\t{}\t{model}\t{effort}\t{auth}\t{enabled}",
provider.id, provider.kind
);
}
}
}
ProvidersCommand::Doctor(doctor_args) => {
let catalog = provider_catalog(&root)?;
let statuses: Vec<Value> = catalog
.iter()
.map(|provider| {
let present = if provider.auth_methods.is_empty() {
provider
.auth_env
.as_ref()
.map(|name| env::var_os(name).is_some())
} else {
Some(provider.auth_methods.iter().any(|method| method.present))
};
json!({
"id": provider.id,
"kind": provider.kind,
"model": provider.model,
"effort": provider.effort,
"models": provider.models,
"efforts": provider.efforts,
"stage_models": provider.stage_models,
"stage_efforts": provider.stage_efforts,
"capabilities": provider.capabilities,
"token_budget": provider.token_budget,
"usage_limits": provider.usage_limits,
"enabled": provider.enabled,
"auth_env": provider.auth_env,
"present": present,
"auth_methods": provider.auth_methods,
})
})
.collect();
if doctor_args.json {
println!(
"{}",
serde_json::to_string_pretty(&json!({ "providers": statuses }))?
);
} else {
for status in statuses {
let id = status
.get("id")
.and_then(Value::as_str)
.unwrap_or("unknown");
let auth_env = status
.get("auth_env")
.and_then(Value::as_str)
.unwrap_or("none");
let present = status
.get("present")
.and_then(Value::as_bool)
.map(|value| if value { "present" } else { "missing" })
.unwrap_or("not-required");
let methods = status
.get("auth_methods")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|item| {
let label = item.get("label").and_then(Value::as_str)?;
let present = item
.get("present")
.and_then(Value::as_bool)
.unwrap_or(false);
let state = if present { "present" } else { "missing" };
let login = item
.get("login_command")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty());
let check = item
.get("check_command")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty());
let suffix = match (present, login, check) {
(false, Some(login), _) => format!(" login=`{login}`"),
(false, None, Some(check)) => {
format!(" check=`{check}`")
}
_ => String::new(),
};
Some(format!("{label}:{state}{suffix}"))
})
.collect::<Vec<_>>()
.join(",")
})
.filter(|value| !value.is_empty())
.unwrap_or_else(|| auth_env.to_string());
println!("{id}\t{methods}\t{present}");
}
}
}
}
Ok(())
}
fn provider_json(provider: &ProviderCatalogEntry) -> Value {
json!({
"id": provider.id,
"kind": provider.kind,
"auth_env": provider.auth_env,
"auth_present": provider.auth_present,
"auth_methods": provider.auth_methods,
"model": provider.model,
"effort": provider.effort,
"models": provider.models,
"efforts": provider.efforts,
"stage_models": provider.stage_models,
"stage_efforts": provider.stage_efforts,
"capabilities": provider.capabilities,
"token_budget": provider.token_budget,
"usage_limits": provider.usage_limits,
"enabled": provider.enabled,
})
}
fn provider_options(args: &StageArgs) -> ProviderResolveOptions {
ProviderResolveOptions {
provider: args.provider.clone(),
model: args.model.clone(),
effort: args.effort.clone(),
offline: args.offline,
}
}
fn print_risk(result: &RiskResult) {
println!("risk_level: {}", result.level);
println!("risk_factors:");
if result.factors.is_empty() {
println!("- none");
} else {
for item in &result.factors {
println!("- {item}");
}
}
println!("required_checkpoints:");
for item in &result.checkpoints {
println!("- {item}");
}
}
fn render_risk_artifact(name: &str, text: &str, result: &RiskResult) -> String {
let factors = if result.factors.is_empty() {
"- none".to_string()
} else {
result
.factors
.iter()
.map(|item| format!("- {item}"))
.collect::<Vec<_>>()
.join("\n")
};
let checkpoints = result
.checkpoints
.iter()
.map(|item| format!("- {item}"))
.collect::<Vec<_>>()
.join("\n");
format!(
"# Risk Classification - {name}\n\n## Rastreabilidade\n- Orquestração: {name}\n- Estado atual: recorded\n- Entrada: {text}\n\n## Classificação de risco\n- Nível: {}\n\n## Fatores\n{factors}\n\n## Checkpoints\n{checkpoints}\n\n## Evidências mínimas\n- Definir no checkpoint conforme o nível de risco.\n",
result.level
)
}
fn run_stage(stage: &str, args: StageArgs) -> Result<()> {
let root = runtime::platform::canonicalize(&args.root).unwrap_or_else(|_| args.root.clone());
let input = args.input.join(" ");
let provider = resolve_provider(&root, &provider_options(&args))?;
let name = args
.name
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| {
if input.trim().is_empty() {
"sdd-orchestration".to_string()
} else {
input.clone()
}
});
let content = render_stage_artifact(stage, &name, &input, &root, &provider)?;
// Contexto de bot: devolve apenas o conteúdo no stdout. O bot é quem grava o
// arquivo (com cabeçalho próprio), cria o worktree e faz commit/push — então
// o sdd NÃO deve escrever em docs/ nem versionar aqui (evita duplicar artefatos
// como `01-idea.md` + `idea.md`). Fora do bot, segue o fluxo completo abaixo.
if is_bot_context() {
print!("{content}");
return Ok(());
}
// Valida branch/worktree quando o primeiro token do input é um card ID,
// o stage está na lista de stages guardados e .worktree/ existe no projeto.
// Se .worktree/ não existir, o projeto não usa worktrees — sem bloqueio.
if stage_requires_card_guard(stage) && worktree_dir(&root).exists() {
let (card_id, _) = extract_card_id(&args.input);
if let Some(ref cid) = card_id {
enforce_branch_for_card(&root, cid, false)?;
}
}
let artifact_path = write_stage_content(
&root,
&name,
stage,
&content,
"draft",
args.force,
args.dry_run,
)?;
// Lifecycle com card ID: cabeçalho no DOC, rejeição, commit+push no worktree
// Guard: só executa se .worktree/ existir — projetos sem worktrees não têm card lifecycle.
if !args.dry_run && worktree_dir(&root).exists() {
let (card_id, _) = extract_card_id(&args.input);
if let Some(ref cid) = card_id {
// Prepõe cabeçalho card/tag no artefato gerado
prepend_card_header_to_artifact(&artifact_path, cid, stage)?;
if args.reject {
// Fluxo de rejeição: grava refinamento rápido e redireciona para execution
let problem = format!(
"Stage '{}' reprovado na tentativa {}. Revisar e reencaminhar para execution.",
stage, args.attempt
);
write_rejection_refinement(&root, cid, stage, args.attempt, &problem)?;
// Commit do refinamento no worktree
let version = resolve_stage_version(&root, cid, stage);
let summary = format!(
"Stage '{}' reprovado (tentativa {}/3). Refinamento rápido gerado.\nReencaminhar para: execution",
stage, args.attempt
);
commit_push_worktree_stage(&root, cid, stage, version, args.attempt, &summary)?;
bail!(
"\n⛔ Stage '{}' reprovado (tentativa {}/3).\n\
Refinamento gerado em docs/{}/\n\
Reencaminhe para: sdd execution {} --attempt 1",
stage,
args.attempt,
cid,
cid
);
} else {
// Fluxo normal: commit+push ao fim do stage
let version = resolve_stage_version(&root, cid, stage);
let summary = format!("Stage '{}' concluído — artefato gerado.", stage);
commit_push_worktree_stage(&root, cid, stage, version, args.attempt, &summary)?;
// Gate de review: quando card é usado, para obrigatoriamente após review
if stage == "review" {
println!(
"\n✋ Gate de review atingido para card '{}'.\n\
Review concluído com sucesso. Pipeline pausada aqui.\n\
Para continuar para memory/production use:\n\
\tsdd memory {} --attempt 1",
cid, cid
);
}
}
}
}
Ok(())
}
/// Deriva o nome canônico do store de `sdd orchestration`.
/// Prioridade: `--name` explícito > card ID > texto da ideia > fallback fixo.
/// Mantém a mesma identidade do `resolve_input`, evitando que um card sem texto
/// de ideia crie `docs/sdd-orchestration/` separado dos artefatos do card.
fn orchestration_store_name(
explicit_name: Option<&str>,
card_id: Option<&str>,
idea: &str,
) -> String {
if let Some(name) = explicit_name.map(str::trim).filter(|v| !v.is_empty()) {
return name.to_string();
}
if let Some(cid) = card_id {
return cid.to_string();
}
if idea.trim().is_empty() {
"sdd-orchestration".to_string()
} else {
idea.to_string()
}
}
fn run_orchestration(args: StageArgs) -> Result<()> {
let (card_id, rest_input) = extract_card_id(&args.input);
let idea = if card_id.is_some() {
rest_input.join(" ")
} else {
args.input.join(" ")
};
let root = runtime::platform::canonicalize(&args.root).unwrap_or_else(|_| args.root.clone());
// Validação de card ID: branch, worktree e artefatos pré-requisito
// Guard: só executa se .worktree/ existir — projetos sem worktrees ignoram o lifecycle.
if worktree_dir(&root).exists() {
if let Some(ref cid) = card_id {
enforce_branch_for_card(&root, cid, true)?;
assert_prerequisite_artifacts(&root, cid)?;
}
}
let provider = resolve_provider(&root, &provider_options(&args))?;
// Nome canônico do store: alinhado ao `resolve_input` para que um card
// "pelado" use `docs/<card>/` em vez de cair em `docs/sdd-orchestration/`.
let name = orchestration_store_name(args.name.as_deref(), card_id.as_deref(), &idea);
init_store(&root, &name, args.force, args.dry_run)?;
let result = if idea.trim().is_empty() {
None
} else {
Some(classify(&idea)?)
};
if let Some(result) = result {
let risk = render_risk_artifact(&name, &idea, &result);
write_stage_content(&root, &name, "risk", &risk, "recorded", true, args.dry_run)?;
}
let content = format!(
"# SDD Orchestration - {name}\n\n## Rastreabilidade\n- Orquestração: {name}\n- Slug: {}\n- Estado atual: draft\n- Provider resolvido: {}\n- Modelo resolvido: {}\n- Effort resolvido: {}\n- Offline: {}\n\n## Ideia de entrada\n{}\n\n## Fluxo\n1. Use `sdd discover --name \"{name}\"` quando não houver discovery confiável.\n2. Use `sdd risk \"<feature>\" --name \"{name}\"` para baseline determinístico.\n3. Delegue Idea, PRD, Tech Spec, Tasks, Refinement, Execution, ADR, Review e Memory aos subagents correspondentes.\n4. Após Execution, rode `sdd adr --name \"{name}\" \"<decisao>\"` para criar ou atualizar ADRs profissionais.\n5. Salve artefatos aprovados com `sdd artifact save \"{name}\" <stage> --file <arquivo> --state approved`.\n\n## Checkpoints\n- PRD\n- Tech Spec\n- Refinement quando risco exigir\n- Merge\n- Deploy quando risco exigir\n\n## Alias legado\n- `sdd orchestrator` continua aceito, mas `sdd orchestration` e `/sdd orchestration` são o nome canônico para CLI, Codex, Claude Code, opencode, Cursor, Devin, Antigravity e Trae.\n",
slugify(&name),
provider.id.as_str(),
provider.model.as_str(),
provider.effort.as_str(),
provider.offline,
if idea.trim().is_empty() { "(sem entrada)" } else { &idea }
);
println!("Provider: {}", provider.id);
println!("Model: {}", provider.model);
println!("Effort: {}", provider.effort);
println!("Offline: {}", provider.offline);
let dest = artifact_dir(&root, &name).join("README.md");
if args.dry_run {
println!("dry-run write {}", dest.display());
} else {
fs::write(&dest, content)?;
println!("{}", dest.display());
}
Ok(())
}
/// Estado por etapa exposto pelo `resolve-input`.
#[derive(Debug, Serialize)]
struct ResolvedInputStage {
order: u32,
key: String,
filename: String,
optional: bool,
exists: bool,
state: String,
}
/// Resolução determinística da entrada do orquestrador. É o contexto-esqueleto
/// unificado: independentemente da rota (card ID, bot ou texto livre), todas
/// passam a derivar o MESMO `name`/`slug`/diretório e a mesma próxima etapa.
#[derive(Debug, Serialize)]
struct ResolvedInput {
/// "card" | "resume" | "new" | "empty".
mode: String,
card_id: Option<String>,
/// `--name` canônico que TODAS as etapas devem reutilizar.
name: String,
slug: String,
/// Caminho relativo `docs/<slug>` do artifact store.
docs_dir: String,
docs_dir_exists: bool,
/// Texto da ideia (com o card ID removido quando presente).
idea: String,
/// Próxima etapa sugerida (key do contrato) ou `None` se nada a fazer.
next_stage: Option<String>,
/// `true` quando não há descrição da feature para trabalhar (ideia de
/// entrada vazia e sem `01-idea.md`): o agente deve pedir a descrição antes.
needs_description: bool,
stages: Vec<ResolvedInputStage>,
}
/// Lê o estado por etapa do `traceability-map.yaml` (best-effort).
///
/// Formato gerado por `render_index`:
/// ```yaml
/// artifacts:
/// <key>:
/// file: <arquivo>
/// state: <estado>
/// ```
fn read_stage_states(map_path: &Path) -> BTreeMap<String, String> {
let mut states = BTreeMap::new();
let Ok(text) = fs::read_to_string(map_path) else {
return states;
};
let mut in_artifacts = false;
let mut current: Option<String> = None;
for line in text.lines() {
if !in_artifacts {
if line.trim_end() == "artifacts:" {
in_artifacts = true;
}
continue;
}
// Chave de nível 0 (sem indentação) encerra a seção `artifacts:`.
if !line.starts_with(' ') && !line.trim().is_empty() {
break;
}
let Some(rest) = line.strip_prefix(" ") else {
continue;
};
if !rest.starts_with(' ') {
// Chave de etapa: 2 espaços de indentação, termina com ':'.
if let Some(key) = rest.strip_suffix(':') {
current = Some(key.trim().to_string());
}
continue;
}
// Campo dentro da etapa: 4 espaços de indentação.
if let Some(field) = rest.strip_prefix(" ") {
if let Some(value) = field.trim().strip_prefix("state:") {
if let Some(key) = ¤t {
states.insert(key.clone(), value.trim().to_string());
}
}
}
}
states
}
/// Resolve a entrada bruta do orquestrador numa identidade unificada.
fn resolve_input(root: &Path, input: &[String], name_override: Option<&str>) -> ResolvedInput {
let (card_id, rest) = extract_card_id(input);
let idea = if card_id.is_some() {
rest.join(" ")
} else {
input.join(" ")
};
let idea = idea.trim().to_string();
// Nome canônico (--name): --name explícito > card ID > slug do texto livre.
let explicit = name_override.map(str::trim).filter(|v| !v.is_empty());
let (name, is_empty) = if let Some(name) = explicit {
(name.to_string(), false)
} else if let Some(ref cid) = card_id {
(cid.clone(), false)
} else if idea.is_empty() {
(String::new(), true)
} else {
// Texto livre → slug curto e estável (slugify é idempotente).
(slugify(&idea), false)
};
let slug = if name.is_empty() {
String::new()
} else {
slugify(&name)
};
let docs_dir_rel = if slug.is_empty() {
"docs/<slug>".to_string()
} else {
format!("docs/{slug}")
};
let store = (!name.is_empty()).then(|| artifact_dir(root, &name));
let docs_dir_exists = store.as_ref().map(|d| d.exists()).unwrap_or(false);
let states = store
.as_ref()
.map(|d| read_stage_states(&d.join("traceability-map.yaml")))
.unwrap_or_default();
let mut stages = Vec::new();
let mut next_stage = None;
for stage in contract::stages() {
let exists = store
.as_ref()
.map(|d| d.join(&stage.filename).exists())
.unwrap_or(false);
let state = states.get(stage.key.as_str()).cloned().unwrap_or_else(|| {
if exists {
"present".to_string()
} else {
"pending".to_string()
}
});
if next_stage.is_none() && !is_empty && !stage.optional && !exists {
next_stage = Some(stage.key.clone());
}
stages.push(ResolvedInputStage {
order: stage.order,
key: stage.key.clone(),
filename: stage.filename.clone(),
optional: stage.optional,
exists,
state,
});
}
let mode = if is_empty {
"empty"
} else if card_id.is_some() {
"card"
} else if docs_dir_exists && stages.iter().any(|s| s.exists) {
"resume"
} else {
"new"
};
// Sem descrição para trabalhar quando a ideia de entrada está vazia e ainda
// não existe `01-idea.md`. Vale tanto para entrada vazia quanto para card
// "pelado": o agente deve pedir a descrição antes de iniciar qualquer etapa.
let idea_artifact_exists = stages.iter().any(|s| s.key == "idea" && s.exists);
let needs_description = idea.is_empty() && !idea_artifact_exists;
ResolvedInput {
mode: mode.to_string(),
card_id,
name,
slug,
docs_dir: docs_dir_rel,
docs_dir_exists,
idea,
next_stage,
needs_description,
stages,
}
}
/// Mensagem única (centralizada) para quando falta a descrição da feature.
/// Reutilizada no modo vazio e no card/novo sem ideia, para a orientação ser
/// idêntica em qualquer rota de entrada.
const ASK_DESCRIPTION_RULE: &str = "**Falta a descrição da feature** (a ideia de entrada está vazia e não há `01-idea.md`). PARE e peça ao usuário a descrição antes de iniciar qualquer etapa.";
/// Renderiza o contexto-esqueleto unificado em Markdown (PT-BR).
fn render_resolved_input_markdown(r: &ResolvedInput) -> String {
let mode_pt = match r.mode.as_str() {
"card" => "card — segue todas as etapas na íntegra",
"resume" => "retomada — a orquestração já existe",
"new" => "nova orquestração",
_ => "entrada vazia",
};
let name_display = if r.name.is_empty() {
"(defina um nome a partir da ideia antes de prosseguir)".to_string()
} else {
r.name.clone()
};
let dir_state = if r.docs_dir_exists {
"já existe"
} else {
"a criar"
};
let next_pt = match r.next_stage.as_deref() {
Some(stage) => stage.to_string(),
None if r.mode == "empty" => "peça a ideia/nome ao usuário antes de começar".to_string(),
None => "fluxo já materializado — verifique pendências de review/memory".to_string(),
};
let idea_display = if r.idea.is_empty() {
"(vazia — peça ao usuário)".to_string()
} else {
r.idea.clone()
};
let mut out = String::new();
out.push_str("## Contexto resolvido (sdd resolve-input)\n\n");
out.push_str(&format!("- **Modo**: {mode_pt}\n"));
out.push_str(&format!(
"- **Card ID**: {}\n",
r.card_id.as_deref().unwrap_or("—")
));
out.push_str(&format!("- **Nome canônico (--name)**: {name_display}\n"));
out.push_str(&format!(
"- **Slug**: {}\n",
if r.slug.is_empty() { "—" } else { &r.slug }
));
out.push_str(&format!(
"- **Diretório de artefatos**: {}/ ({dir_state})\n",
r.docs_dir
));
out.push_str(&format!("- **Próxima etapa sugerida**: {next_pt}\n"));
out.push_str(&format!("- **Ideia de entrada**: {idea_display}\n\n"));
out.push_str("### Artefatos da orquestração\n\n");
out.push_str("| Ordem | Etapa | Arquivo | Existe | Estado |\n");
out.push_str("|---|---|---|---|---|\n");
for stage in &r.stages {
let opt = if stage.optional { " (opcional)" } else { "" };
out.push_str(&format!(
"| {} | {}{} | {} | {} | {} |\n",
stage.order,
stage.key,
opt,
stage.filename,
if stage.exists { "sim" } else { "não" },
stage.state,
));
}
out.push('\n');
out.push_str("### Regras de unificação (use sempre o MESMO contexto)\n\n");
if r.needs_description {
// Mensagem centralizada (ASK_DESCRIPTION_RULE): vale para entrada vazia
// E para card/novo sem ideia — a orientação é idêntica em qualquer rota.
out.push_str(&format!("> ⚠️ {ASK_DESCRIPTION_RULE}\n\n"));
}
if r.name.is_empty() {
// Identidade ainda não resolvida (entrada totalmente vazia).
out.push_str(
"1. Defina um nome curto e, depois de obter a ideia, rode novamente `sdd resolve-input \"<ideia>\"` para obter o contexto canônico.\n",
);
return out;
}
out.push_str(&format!(
"1. Use SEMPRE `--name \"{name}\"` em todas as etapas e comandos `sdd`. Não invente outro nome.\n",
name = r.name
));
if r.docs_dir_exists {
out.push_str(&format!(
"2. O diretório `{}/` já existe — NÃO reinicialize. Carregue os artefatos existentes como contexto.\n",
r.docs_dir
));
} else {
out.push_str(&format!(
"2. O diretório `{}/` ainda não existe — inicialize uma única vez com `sdd init \"{}\"`.\n",
r.docs_dir, r.name
));
}
out.push_str(&format!(
"3. Para cada etapa, gere o esqueleto de contexto com:\n\
\n sdd context build --name \"{name}\" --stage <etapa>\n\n \
Isso garante o MESMO contexto, venha a entrada por card, bot ou texto livre.\n",
name = r.name
));
out.push_str(
"4. Comece pela \"próxima etapa sugerida\" e siga a ordem do fluxo sem pular etapas, respeitando os checkpoints.\n\
5. Não regenere etapas cujo arquivo já existe e está aprovado — carregue-as como contexto da etapa atual.\n",
);
out
}
fn run_resolve_input(args: ResolveInputArgs) -> Result<()> {
let root = runtime::platform::canonicalize(&args.root).unwrap_or_else(|_| args.root.clone());
let resolved = resolve_input(&root, &args.input, args.name.as_deref());
if args.json {
println!("{}", serde_json::to_string_pretty(&resolved)?);
} else {
print!("{}", render_resolved_input_markdown(&resolved));
}
Ok(())
}
fn run_memory(args: StageArgs) -> Result<()> {
match args.input.first().map(String::as_str) {
Some("status") => memory_status(args),
Some("learn") => memory_learn(args),
_ => run_stage("memory", args),
}
}
fn memory_status(args: StageArgs) -> Result<()> {
let root = runtime::platform::canonicalize(&args.root).unwrap_or_else(|_| args.root.clone());
let path = memory_index_path(&root)?;
let lines = if path.exists() {
fs::read_to_string(&path)?
.lines()
.filter(|line| !line.trim().is_empty())
.map(str::to_string)
.collect::<Vec<_>>()
} else {
Vec::new()
};
let mut sources = BTreeSet::new();
for line in &lines {
if let Ok(value) = serde_json::from_str::<Value>(line) {
if let Some(source) = value.get("source_artifact").and_then(Value::as_str) {
sources.insert(source.to_string());
}
}
}
if args.json {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"index_path": path,
"learnings": lines.len(),
"sources_indexed": sources.len(),
"pending_files": 0,
"last_error": Value::Null,
}))?
);
} else {
println!("memory_index: {}", path.display());
println!("learnings: {}", lines.len());
println!("sources_indexed: {}", sources.len());
println!("pending_files: 0");
}
Ok(())
}
fn memory_learn(args: StageArgs) -> Result<()> {
let root = runtime::platform::canonicalize(&args.root).unwrap_or_else(|_| args.root.clone());
let name = args
.name
.clone()
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| anyhow!("sdd memory learn requires --name <orchestration>"))?;
let config = load_sdd_config(&root)?;
if !config.memory.learning_enabled {
println!("memory learning disabled");
return Ok(());
}
let learnings = build_learning_records(&root, &name)?;
let path = memory_index_path(&root)?;
if args.dry_run {
println!("dry-run write {}", path.display());
println!("learnings: {}", learnings.len());
return Ok(());
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let slug = slugify(&name);
let mut existing = if path.exists() {
fs::read_to_string(&path)?
.lines()
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
.filter(|value| {
value.get("orchestration_slug").and_then(Value::as_str) != Some(slug.as_str())
})
.collect::<Vec<_>>()
} else {
Vec::new()
};
existing.extend(learnings);
let mut out = String::new();
for item in existing {
out.push_str(&serde_json::to_string(&item)?);
out.push('\n');
}
fs::write(&path, out)?;
println!("{}", path.display());
Ok(())
}
fn run_intelligence(args: IntelligenceArgs) -> Result<()> {
let root = runtime::platform::canonicalize(&args.root).unwrap_or_else(|_| args.root.clone());
match args.command {
IntelligenceCommand::Learn(args) => intelligence_learn(&root, args),
IntelligenceCommand::Status(args) => intelligence_status(&root, args),
IntelligenceCommand::Health(args) => intelligence_health(&root, args),
}
}
fn run_optimize(args: OptimizeArgs) -> Result<()> {
let root = runtime::platform::canonicalize(&args.root).unwrap_or_else(|_| args.root.clone());
match args.command {
OptimizeCommand::Status(args) => {
let config = load_sdd_config(&root)?;
let status = runtime::optimization::status(&root, &config.optimization);
if args.json {
println!("{}", serde_json::to_string_pretty(&status)?);
} else {
println!(
"optimization: {}",
if status.enabled {
"enabled"
} else {
"disabled"
}
);
for tool in [&status.codegraph, &status.rtk, &status.caveman] {
println!(
"{}: available={} mode={} version={}",
tool.id,
tool.available,
tool.mode,
tool.version.as_deref().unwrap_or("unknown")
);
println!(" action: {}", tool.recommended_action);
}
println!("ignored_paths: {}", status.ignored_paths.join(", "));
}
Ok(())
}
OptimizeCommand::Compress(args) => {
let kind = runtime::optimization::CompressionKind::parse(&args.kind)
.ok_or_else(|| anyhow!("invalid compression kind: {}", args.kind))?;
let input = if let Some(path) = args.file {
fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?
} else {
let mut input = String::new();
io::stdin().read_to_string(&mut input)?;
input
};
print!(
"{}",
runtime::optimization::compress_operational_text(kind, &input)
);
Ok(())
}
}
}
fn intelligence_learn(root: &Path, args: IntelligenceLearnArgs) -> Result<()> {
let config = load_sdd_config(root)?;
if !config.intelligence.enabled {
println!("intelligence disabled");
return Ok(());
}
let target = resolve_intelligence_learn_target(root, &args)?;
let sources = collect_intelligence_sources(root, target.name.as_deref())?;
let learnings = match &target.name {
Some(name) => build_learning_records(root, name)?,
None => build_all_learning_records(root)?,
};
let index_dir = intelligence_index_dir(root)?;
let sources_path = index_dir.join("sources.index.jsonl");
let learnings_path = index_dir.join("learnings.jsonl");
if args.dry_run {
println!("dry-run write {}", sources_path.display());
println!("dry-run write {}", learnings_path.display());
println!("sources: {}", sources.len());
println!("learnings: {}", learnings.len());
return Ok(());
}
let source_values = sources
.iter()
.map(serde_json::to_value)
.collect::<std::result::Result<Vec<_>, _>>()?;
if target.all {
write_jsonl_values(&sources_path, &source_values)?;
write_jsonl_replace_scopes(
&learnings_path,
&learnings,
"orchestration_slug",
&target.scopes,
)?;
} else {
write_jsonl_replace_by_key(&sources_path, &source_values, "path")?;
let Some(name) = &target.name else {
bail!("sdd intelligence learn requires --name <orchestration> or --all");
};
write_jsonl_replace_scope(
&learnings_path,
&learnings,
"orchestration_slug",
Some(&slugify(name)),
)?;
}
if args.json {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"index_dir": index_dir,
"sources_indexed": sources.len(),
"learnings": learnings.len(),
"derived": true,
"scope": if target.all { "all" } else { "name" },
}))?
);
} else {
println!("{}", learnings_path.display());
}
Ok(())
}
fn intelligence_status(root: &Path, args: IntelligenceStatusArgs) -> Result<()> {
let index_dir = intelligence_index_dir(root)?;
let sources_path = index_dir.join("sources.index.jsonl");
let learnings_path = index_dir.join("learnings.jsonl");
let sources = read_jsonl_values(&sources_path)?;
let learnings = refresh_learning_statuses(root, &learnings_path, true)?;
let stale_sources = count_stale_learnings(&learnings);
let conflicts = learnings
.iter()
.filter(|value| value.get("status").and_then(Value::as_str) == Some("conflict"))
.count();
if args.json {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"index_dir": index_dir,
"sources_indexed": sources.len(),
"learnings": learnings.len(),
"stale_sources": stale_sources,
"conflicts": conflicts,
"last_error": Value::Null,
}))?
);
} else {
println!("index_dir: {}", index_dir.display());
println!("sources_indexed: {}", sources.len());
println!("learnings: {}", learnings.len());
println!("stale_sources: {stale_sources}");
println!("conflicts: {conflicts}");
}
Ok(())
}
fn intelligence_health(root: &Path, args: IntelligenceHealthArgs) -> Result<()> {
let signals = intelligence_health_signals(root)?;
let failing_signal_count = args
.fail_on
.as_deref()
.map(|threshold| count_signals_at_or_above(&signals, threshold))
.transpose()?;
if args.json {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"signals": signals,
"signal_count": signals.len(),
"failing_signal_count": failing_signal_count,
}))?
);
} else if signals.is_empty() {
println!("health: ok");
} else {
println!("health: signals {}", signals.len());
for signal in signals {
println!(
"- {} [{}]: {}",
signal["id"].as_str().unwrap_or("signal"),
signal["severity"].as_str().unwrap_or("info"),
signal["message"].as_str().unwrap_or("")
);
}
}
if let Some(count) = failing_signal_count {
if count > 0 {
bail!("intelligence health failed: {count} signal(s) at or above --fail-on threshold");
}
}
Ok(())
}
fn memory_index_path(root: &Path) -> Result<PathBuf> {
let config = load_sdd_config(root)?;
Ok(root.join(config.memory.index_path))
}
#[derive(Clone, Debug, Serialize)]
struct IntelligenceSource {
path: String,
kind: String,
stage: Option<String>,
hash: String,
last_seen_at: String,
status: String,
orchestration_slug: Option<String>,
}
struct ContextPack {
path: PathBuf,
content: String,
sources_included: usize,
conflicts: usize,
}
struct ContextPackRender<'a> {
name: &'a str,
slug: &'a str,
stage: &'a str,
task: Option<&'a str>,
discovery: &'a DiscoveryReport,
included: &'a [(IntelligenceSource, String)],
excluded: &'a [String],
stale: &'a [String],
decision_conflicts: &'a [String],
conflicts: usize,
used_chars: usize,
optimization: &'a runtime::optimization::OptimizationStatus,
handoff: &'a runtime::optimization::ContextHandoff,
}
struct IntelligenceLearnTarget {
name: Option<String>,
all: bool,
scopes: BTreeSet<String>,
}
#[derive(Clone)]
struct ArtifactStoreSource {
name: String,
slug: String,
path: PathBuf,
}
fn resolve_intelligence_learn_target(
root: &Path,
args: &IntelligenceLearnArgs,
) -> Result<IntelligenceLearnTarget> {
match (args.all, args.name.as_deref()) {
(true, Some(_)) => bail!("use --all or --name <orchestration>, not both"),
(true, None) => {
let stores = discover_artifact_stores(root)?;
if stores.is_empty() {
bail!("no docs/<slug>/traceability-map.yaml artifact stores found");
}
let scopes = stores
.iter()
.map(|store| store.slug.clone())
.collect::<BTreeSet<_>>();
Ok(IntelligenceLearnTarget {
name: None,
all: true,
scopes,
})
}
(false, Some(name)) => Ok(IntelligenceLearnTarget {
name: Some(name.to_string()),
all: false,
scopes: BTreeSet::new(),
}),
(false, None) => bail!("sdd intelligence learn requires --name <orchestration> or --all"),
}
}
fn intelligence_index_dir(root: &Path) -> Result<PathBuf> {
let config = load_sdd_config(root)?;
Ok(root.join(config.intelligence.index_dir))
}
fn intelligence_context_pack_dir(root: &Path) -> Result<PathBuf> {
let config = load_sdd_config(root)?;
Ok(root.join(config.intelligence.context_pack_dir))
}
fn build_learning_records(root: &Path, name: &str) -> Result<Vec<Value>> {
let artifact_root = artifact_dir(root, name);
if !artifact_root.exists() {
bail!("artifact directory not found: {}", artifact_root.display());
}
build_learning_records_for_artifact(root, &artifact_root, name, &slugify(name))
}
fn build_all_learning_records(root: &Path) -> Result<Vec<Value>> {
let mut learnings = Vec::new();
for store in discover_artifact_stores(root)? {
learnings.extend(build_learning_records_for_artifact(
root,
&store.path,
&store.name,
&store.slug,
)?);
}
Ok(learnings)
}
fn build_learning_records_for_artifact(
root: &Path,
artifact_root: &Path,
name: &str,
slug: &str,
) -> Result<Vec<Value>> {
let mut learnings = Vec::new();
for stage in contract::stages() {
let path = artifact_root.join(&stage.filename);
if !path.exists() {
continue;
}
let text = fs::read_to_string(&path)?;
let source_hash = sha256_hex(text.as_bytes());
let rel = relative_path(root, &path);
let summary = text
.lines()
.find(|line| line.starts_with("# "))
.unwrap_or(stage.key.as_str())
.trim_start_matches("# ")
.trim()
.to_string();
learnings.push(json!({
"kind": learning_kind_for_stage(&stage.key),
"orchestration": name,
"orchestration_slug": slug,
"source_stage": stage.key,
"source_artifact": rel,
"source_hash": source_hash,
"summary": runtime::redaction::redact_text(&summary),
"tags": learning_tags_for_stage(&stage.key),
"confidence": "high",
"status": "active",
"learned_at": now(),
"derived": true,
}));
}
Ok(learnings)
}
fn discover_artifact_stores(root: &Path) -> Result<Vec<ArtifactStoreSource>> {
let docs = root.join("docs");
if !docs.is_dir() {
return Ok(Vec::new());
}
let mut stores = Vec::new();
for entry in fs::read_dir(&docs)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(slug) = path.file_name().and_then(OsStr::to_str) else {
continue;
};
if slug.starts_with('_') {
continue;
}
if !path.join("traceability-map.yaml").is_file() {
continue;
}
stores.push(ArtifactStoreSource {
name: artifact_store_name(&path, slug),
slug: slug.to_string(),
path,
});
}
stores.sort_by(|a, b| a.slug.cmp(&b.slug));
Ok(stores)
}
fn artifact_store_name(artifact_root: &Path, slug: &str) -> String {
let Ok(text) = fs::read_to_string(artifact_root.join("traceability-map.yaml")) else {
return slug.to_string();
};
for line in text.lines() {
let trimmed = line.trim();
if let Some(value) = trimmed.strip_prefix("name:") {
let name = value.trim().trim_matches('"').trim_matches('\'').trim();
if !name.is_empty() {
return name.to_string();
}
}
}
slug.to_string()
}
fn learning_kind_for_stage(stage: &str) -> &'static str {
match stage {
"risk" => "risk",
"execution" | "review" => "quality_signal",
"adr" => "decision",
"memory" => "artifact_summary",
"tasks" => "pattern",
_ => "artifact_summary",
}
}
fn learning_tags_for_stage(stage: &str) -> Vec<&'static str> {
match stage {
"prd" => vec!["product", "requirements"],
"techspec" => vec!["architecture", "technical-plan"],
"tasks" => vec!["delivery", "validation"],
"refinement" => vec!["grooming", "risk-control"],
"adr" => vec!["decision", "architecture"],
"review" => vec!["quality", "review"],
"memory" => vec!["memory", "context"],
_ => vec!["sdd-artifact"],
}
}
fn collect_intelligence_sources(
root: &Path,
name: Option<&str>,
) -> Result<Vec<IntelligenceSource>> {
let timestamp = now();
let mut sources = Vec::new();
let mut current_slug = None;
if let Some(name) = name {
let slug = slugify(name);
current_slug = Some(slug.clone());
let artifact_root = artifact_dir(root, name);
if !artifact_root.exists() {
bail!("artifact directory not found: {}", artifact_root.display());
}
sources.extend(collect_artifact_store_sources(
root,
&artifact_root,
&slug,
false,
×tamp,
)?);
}
for store in discover_artifact_stores(root)? {
if current_slug.as_deref() == Some(store.slug.as_str()) {
continue;
}
sources.extend(collect_artifact_store_sources(
root,
&store.path,
&store.slug,
true,
×tamp,
)?);
}
for rel in [
"AGENTS.md",
"CLAUDE.md",
"sdd.config.yaml",
".codex/rules/00-sdd-principles.md",
".codex/rules/10-traceability.md",
".codex/rules/20-output-conventions.md",
"docs/_project-intelligence/project-profile.md",
"docs/_project-intelligence/architecture-map.md",
"docs/_project-intelligence/decision-index.md",
"docs/_project-intelligence/patterns-and-conventions.md",
"docs/_project-intelligence/risk-register.md",
"docs/_project-intelligence/quality-health.md",
] {
let path = root.join(rel);
if path.exists() && path.is_file() {
sources.push(source_record(
root, &path, "global", None, None, ×tamp,
)?);
}
}
Ok(sources)
}
fn collect_artifact_store_sources(
root: &Path,
artifact_root: &Path,
slug: &str,
historical: bool,
timestamp: &str,
) -> Result<Vec<IntelligenceSource>> {
let mut sources = Vec::new();
let traceability = artifact_root.join("traceability-map.yaml");
if traceability.exists() {
sources.push(source_record(
root,
&traceability,
if historical {
"historical_traceability"
} else {
"traceability"
},
None,
Some(slug),
timestamp,
)?);
}
for stage in contract::stages() {
let path = artifact_root.join(&stage.filename);
if path.exists() {
sources.push(source_record(
root,
&path,
if historical {
"historical_artifact"
} else {
"artifact"
},
Some(stage.key.as_str()),
Some(slug),
timestamp,
)?);
}
}
Ok(sources)
}
fn source_record(
root: &Path,
path: &Path,
kind: &str,
stage: Option<&str>,
orchestration_slug: Option<&str>,
timestamp: &str,
) -> Result<IntelligenceSource> {
let text = fs::read_to_string(path)
.with_context(|| format!("reading intelligence source {}", path.display()))?;
Ok(IntelligenceSource {
path: relative_path(root, path),
kind: kind.to_string(),
stage: stage.map(str::to_string),
hash: sha256_hex(text.as_bytes()),
last_seen_at: timestamp.to_string(),
status: "active".to_string(),
orchestration_slug: orchestration_slug.map(str::to_string),
})
}
fn build_context_pack(
root: &Path,
name: &str,
stage: &str,
task: Option<&str>,
persist_status: bool,
) -> Result<ContextPack> {
let stage = validate_context_stage(stage)?;
let config = load_sdd_config(root)?;
let optimization = runtime::optimization::status(root, &config.optimization);
let sources = collect_intelligence_sources(root, Some(name))?;
let learnings = refresh_learning_statuses(
root,
&intelligence_index_dir(root)?.join("learnings.jsonl"),
persist_status,
)?;
let ranked_sources = rank_sources_for_stage(&sources, stage);
let mut included = Vec::new();
let mut excluded = Vec::new();
let mut used_chars = 0usize;
for source in ranked_sources {
let path = root.join(&source.path);
let text = fs::read_to_string(&path).unwrap_or_default();
let redacted = runtime::redaction::redact_text(&text);
let snippet = truncate_chars(&redacted, 2_000);
let next_len = snippet.len();
if used_chars + next_len > config.intelligence.max_context_chars {
excluded.push(format!("{}: limite de contexto", source.path));
continue;
}
used_chars += next_len;
included.push((source.clone(), snippet));
}
let stale = stale_learning_summaries(&learnings);
let derived_conflicts = learnings
.iter()
.filter(|value| value.get("status").and_then(Value::as_str) == Some("conflict"))
.count();
let decision_conflicts = detect_decision_conflicts(root, name, &sources)?;
let conflicts = derived_conflicts + decision_conflicts.len();
let slug = slugify(name);
let discovery = discover_project(root);
let artifact_sources = included
.iter()
.filter(|(source, _)| source.kind == "artifact")
.map(|(source, _)| source.path.clone())
.collect::<Vec<_>>();
let handoff = runtime::optimization::build_context_handoff(
root,
stage,
task,
artifact_sources,
&optimization,
);
let path = intelligence_context_pack_dir(root)?
.join(&slug)
.join(format!("{stage}.md"));
let content = render_context_pack(ContextPackRender {
name,
slug: &slug,
stage,
task,
discovery: &discovery,
included: &included,
excluded: &excluded,
stale: &stale,
decision_conflicts: &decision_conflicts,
conflicts,
used_chars,
optimization: &optimization,
handoff: &handoff,
});
Ok(ContextPack {
path,
content,
sources_included: included.len(),
conflicts,
})
}
fn rank_sources_for_stage(sources: &[IntelligenceSource], stage: &str) -> Vec<IntelligenceSource> {
let mut ranked = sources.to_vec();
ranked.sort_by(|a, b| {
source_weight(b, stage)
.cmp(&source_weight(a, stage))
.then_with(|| a.path.cmp(&b.path))
});
ranked
}
fn source_weight(source: &IntelligenceSource, target_stage: &str) -> i32 {
let mut weight = match source.kind.as_str() {
"artifact" => {
100 + source
.stage
.as_deref()
.map(stage_precedence_weight)
.unwrap_or(0)
}
"traceability" => 95,
"historical_artifact" => {
65 + source
.stage
.as_deref()
.map(|stage| stage_precedence_weight(stage) / 10)
.unwrap_or(0)
}
"historical_traceability" => 60,
"global" => 55,
_ => 35,
};
if source.stage.as_deref() == Some(target_stage) {
weight += 20;
}
if matches!(source.stage.as_deref(), Some("prd" | "techspec" | "adr")) {
weight += 10;
}
if source.stage.as_deref() == Some("memory") {
weight += 8;
}
weight
}
fn stage_precedence_weight(stage: &str) -> i32 {
match stage {
"adr" => 700,
"techspec" => 600,
"prd" => 500,
"tasks" => 400,
"execution" | "review" => 300,
"memory" => 200,
_ => 100,
}
}
#[derive(Clone)]
struct DecisionFact {
key: String,
value: String,
source: String,
stage: String,
precedence: i32,
}
fn validate_context_stage(stage: &str) -> Result<&str> {
if stage.contains("..")
|| stage.contains('/')
|| stage.contains('\\')
|| stage.trim() != stage
|| stage.is_empty()
{
bail!("invalid context stage: {stage}");
}
if contract::stage_by_key(stage).is_some() {
Ok(stage)
} else {
bail!("unknown context stage: {stage}")
}
}
fn detect_decision_conflicts(
root: &Path,
name: &str,
sources: &[IntelligenceSource],
) -> Result<Vec<String>> {
let states = artifact_stage_states(&artifact_dir(root, name));
let mut facts = Vec::new();
for source in sources {
let Some(stage) = source.stage.as_deref() else {
continue;
};
if !stage_can_contribute_decisions(stage, states.get(stage).map(String::as_str)) {
continue;
}
let path = root.join(&source.path);
let Ok(text) = fs::read_to_string(&path) else {
continue;
};
facts.extend(extract_decision_facts(&source.path, stage, &text));
}
let mut by_key: BTreeMap<String, Vec<DecisionFact>> = BTreeMap::new();
for fact in facts {
by_key.entry(fact.key.clone()).or_default().push(fact);
}
let mut conflicts = Vec::new();
for (key, mut facts) in by_key {
facts.sort_by(|a, b| {
b.precedence
.cmp(&a.precedence)
.then_with(|| a.source.cmp(&b.source))
});
let Some(winner) = facts.first().cloned() else {
continue;
};
for contender in facts.iter().skip(1) {
if contender.value != winner.value {
conflicts.push(format!(
"Conflito de decisão `{key}`: `{}` ({}) tem precedência sobre `{}` ({}).",
winner.source, winner.stage, contender.source, contender.stage
));
}
}
}
Ok(conflicts)
}
fn stage_can_contribute_decisions(stage: &str, state: Option<&str>) -> bool {
matches!(
stage,
"prd" | "techspec" | "tasks" | "execution" | "adr" | "review" | "memory" | "refinement"
) && !matches!(state, Some("pending" | "missing" | "skipped"))
}
fn extract_decision_facts(source: &str, stage: &str, text: &str) -> Vec<DecisionFact> {
let mut facts = Vec::new();
let mut in_decision_section = false;
for line in text.lines() {
let trimmed = line.trim();
if trimmed.starts_with("## ") {
let title = trimmed.trim_start_matches('#').trim().to_lowercase();
in_decision_section = title.contains("decisão")
|| title.contains("decisões")
|| title.contains("decisao")
|| title.contains("decisoes")
|| title.contains("decision");
continue;
}
let is_list_item = trimmed.starts_with("- ") || trimmed.starts_with("* ");
let is_table_row = trimmed.starts_with('|') && trimmed.ends_with('|');
let statement = trimmed
.strip_prefix("- ")
.or_else(|| trimmed.strip_prefix("* "))
.unwrap_or(trimmed)
.trim();
let adr_decision_line =
stage == "adr" && (is_list_item || is_table_row || prose_decision_candidate(statement));
if !in_decision_section && !adr_decision_line {
continue;
}
let allow_structured = in_decision_section || is_list_item || is_table_row;
facts.extend(extract_decision_facts_from_statement(
source,
stage,
statement,
allow_structured,
));
}
facts
}
fn extract_decision_facts_from_statement(
source: &str,
stage: &str,
statement: &str,
allow_structured: bool,
) -> Vec<DecisionFact> {
if statement.is_empty() {
return Vec::new();
}
if statement.starts_with('|') && statement.ends_with('|') {
return decision_fact_from_table_row(source, stage, statement)
.into_iter()
.collect();
}
if allow_structured {
if let Some((key, value)) = statement.split_once(':') {
return decision_fact(source, stage, key, value)
.into_iter()
.collect();
}
}
prose_decision_fact(source, stage, statement)
.into_iter()
.collect()
}
fn decision_fact_from_table_row(
source: &str,
stage: &str,
statement: &str,
) -> Option<DecisionFact> {
let cells = statement
.trim_matches('|')
.split('|')
.map(str::trim)
.filter(|cell| !cell.is_empty())
.collect::<Vec<_>>();
if cells.len() < 2 || cells.iter().all(|cell| is_markdown_table_separator(cell)) {
return None;
}
let key = cells[0];
let value = cells[1..].join(" | ");
if is_table_header_cell(key) {
return None;
}
decision_fact(source, stage, key, &value)
}
fn is_markdown_table_separator(cell: &str) -> bool {
cell.chars().all(|ch| matches!(ch, '-' | ':' | ' ' | '\t'))
}
fn is_table_header_cell(cell: &str) -> bool {
matches!(
slugify(cell).as_str(),
"decisao" | "decisoes" | "decision" | "decisions" | "chave" | "key" | "item"
)
}
fn prose_decision_fact(source: &str, stage: &str, statement: &str) -> Option<DecisionFact> {
let lower = statement.to_lowercase();
if let Some((value, key)) = lower
.split_once(" para ")
.or_else(|| lower.split_once(" for "))
{
if prose_decision_candidate(value) {
return decision_fact(
source,
stage,
trim_decision_punctuation(key),
trim_decision_prefixes(value),
);
}
}
if let Some((key, value)) = lower
.split_once(" será ")
.or_else(|| lower.split_once(" sera "))
.or_else(|| lower.split_once(" will be "))
.or_else(|| lower.split_once(" is "))
{
if prose_decision_candidate(&lower) {
return decision_fact(
source,
stage,
trim_decision_punctuation(key),
trim_decision_punctuation(value),
);
}
}
None
}
fn prose_decision_candidate(statement: &str) -> bool {
let lower = statement.to_lowercase();
lower.contains("decidimos")
|| lower.contains("decidido")
|| lower.contains("decisão")
|| lower.contains("decisao")
|| lower.contains("decision")
|| lower.contains("adotamos")
|| lower.contains("adopted")
|| lower.contains(" será ")
|| lower.contains(" sera ")
|| lower.contains(" will be ")
}
fn trim_decision_prefixes(value: &str) -> &str {
let mut current = trim_decision_punctuation(value);
for prefix in [
"decidimos usar ",
"decidimos ",
"foi decidido usar ",
"foi decidido ",
"decisão usar ",
"decisao usar ",
"decision to use ",
"we decided to use ",
"we decided ",
"adotamos ",
"adopted ",
"usar ",
"use ",
] {
if let Some(stripped) = current.strip_prefix(prefix) {
current = trim_decision_punctuation(stripped);
break;
}
}
current
}
fn trim_decision_punctuation(value: &str) -> &str {
value.trim_matches(|ch: char| {
ch.is_whitespace() || matches!(ch, '.' | ',' | ';' | ':' | '"' | '\'' | '`')
})
}
fn decision_fact(source: &str, stage: &str, key: &str, value: &str) -> Option<DecisionFact> {
let key = slugify(trim_decision_punctuation(key));
let value = trim_decision_punctuation(value).to_lowercase();
if key.is_empty() || value.is_empty() {
return None;
}
Some(DecisionFact {
key,
value,
source: source.to_string(),
stage: stage.to_string(),
precedence: stage_precedence_weight(stage),
})
}
fn artifact_stage_states(artifact_root: &Path) -> BTreeMap<String, String> {
let mut states = BTreeMap::new();
let Ok(text) = fs::read_to_string(artifact_root.join("traceability-map.yaml")) else {
return states;
};
let mut current_stage: Option<String> = None;
for line in text.lines() {
if line.starts_with(" ") && !line.starts_with(" ") && line.trim_end().ends_with(':') {
current_stage = Some(line.trim().trim_end_matches(':').to_string());
continue;
}
if line.trim_start().starts_with("state:") {
if let Some(stage) = ¤t_stage {
let state = line
.trim_start()
.trim_start_matches("state:")
.trim()
.trim_matches('"')
.to_string();
states.insert(stage.clone(), state);
}
}
}
states
}
fn render_context_pack(input: ContextPackRender<'_>) -> String {
let ContextPackRender {
name,
slug,
stage,
task,
discovery,
included,
excluded,
stale,
decision_conflicts,
conflicts,
used_chars,
optimization,
handoff,
} = input;
let mut out = String::new();
out.push_str(&format!("# Context Pack — {stage} — {name}\n\n"));
out.push_str("## Rastreabilidade\n");
out.push_str(&format!("- Orquestração: {name}\n"));
out.push_str(&format!("- Slug: {slug}\n"));
out.push_str(&format!("- Stage: {stage}\n"));
if let Some(task) = task {
out.push_str(&format!("- Task: {task}\n"));
}
out.push_str(&format!("- Gerado em: {}\n", now()));
out.push_str(&format!(
"- Fonte canônica: docs/{slug}/traceability-map.yaml\n\n"
));
out.push_str("## Contexto essencial\n\n");
out.push_str("- Use este pacote como contexto derivado e auditável; artefatos aprovados e ADRs continuam tendo precedência.\n");
out.push_str("- Não trate índices `.sdd/intelligence` como fonte canônica.\n\n");
out.push_str("## Artefatos obrigatórios\n\n");
for (source, _) in included
.iter()
.filter(|(source, _)| source.kind == "artifact")
{
out.push_str(&format!(
"- `{}` ({})\n",
source.path,
source.stage.as_deref().unwrap_or("artifact")
));
}
out.push_str("\n## Decisões relevantes\n\n");
for (source, snippet) in included.iter().filter(|(source, _)| {
matches!(
source.stage.as_deref(),
Some("prd" | "techspec" | "adr" | "refinement")
)
}) {
out.push_str(&format!("### `{}`\n\n{}\n\n", source.path, snippet));
}
out.push_str("## Padrões úteis\n\n");
for (source, _) in included
.iter()
.filter(|(source, _)| source.kind == "global")
{
out.push_str(&format!("- `{}`\n", source.path));
}
if included.iter().all(|(source, _)| source.kind != "global") {
out.push_str("- Nenhum padrão global incluído.\n");
}
out.push_str("\n## Riscos e conflitos\n\n");
if stale.is_empty() && conflicts == 0 {
out.push_str("- Nenhum conflito ou learning obsoleto detectado nos índices locais.\n");
} else {
for item in stale {
out.push_str(&format!("- Learning obsoleto: {item}\n"));
}
for item in decision_conflicts {
out.push_str(&format!("- {item}\n"));
}
if conflicts > 0 {
out.push_str(&format!("- Conflitos derivados detectados: {conflicts}\n"));
}
}
out.push_str("\n## Comandos e validações sugeridas\n\n");
if stage == "execution" {
out.push_str("- Antes de editar: ler Tasks/Refinement, Context Pack e handoff de code intelligence.\n");
out.push_str("- Para feature/bugfix: criar tracer bullet vertical e teste falhando quando houver seam correto.\n");
out.push_str("- Para bug/performance: criar loop reproduzível antes de hipótese.\n");
}
out.push_str("- `cargo test`\n");
out.push_str("- `cargo clippy -- -D warnings`\n");
out.push_str(&format!(
"- `./target/debug/sdd validate-artifact {stage} <arquivo>` quando houver artefato materializado para esta etapa.\n\n"
));
out.push_str(&runtime::optimization::render_context_handoff(handoff));
out.push_str("## Code intelligence\n\n");
out.push_str(&format!(
"- Optimization wrapper: enabled={}, ignored_paths={}\n",
optimization.enabled,
optimization.ignored_paths.join(", ")
));
out.push_str(&format!(
"- codegraph wrapper: available={}, indexed={}, version={}, capabilities={}\n",
optimization.codegraph.available,
optimization.codegraph.indexed.unwrap_or(false),
optimization
.codegraph
.version
.as_deref()
.unwrap_or("unknown"),
if optimization.codegraph.capabilities.is_empty() {
"none".to_string()
} else {
optimization.codegraph.capabilities.join(", ")
}
));
out.push_str(&format!(
"- rtk wrapper: available={}, mode={}\n",
optimization.rtk.available, optimization.rtk.mode
));
out.push_str(&format!(
"- caveman wrapper: available={}, mode={}\n\n",
optimization.caveman.available, optimization.caveman.mode
));
for tool in &discovery.code_intelligence {
out.push_str(&format!(
"- {}: available={}, indexed={}, marker=`{}`\n",
tool.id, tool.available, tool.indexed, tool.index_marker
));
if !tool.suggested_commands.is_empty() {
out.push_str(" - Comandos sugeridos:\n");
for command in &tool.suggested_commands {
out.push_str(&format!(" - `{command}`\n"));
}
}
out.push_str(&format!(" - Fallback: {}\n", tool.fallback));
}
out.push_str("\n## Capability handoff\n\n");
for capability in &discovery.capabilities {
if capability.stages.iter().any(|candidate| candidate == stage) {
out.push_str(&format!(
"- `{}`: {}. Skill: `{}`. Fallback: {}\n",
capability.id, capability.trigger, capability.local_skill, capability.fallback
));
}
}
if discovery
.capabilities
.iter()
.all(|capability| !capability.stages.iter().any(|candidate| candidate == stage))
{
out.push_str("- Nenhuma capability específica para este stage.\n");
}
out.push('\n');
out.push_str("## Manifesto de contexto\n");
out.push_str(&format!(
"- Fontes consideradas: {}\n",
included.len() + excluded.len()
));
out.push_str("- Fontes incluídas:\n");
for (source, _) in included {
out.push_str(&format!(" - `{}`\n", source.path));
}
out.push_str("- Fontes excluídas:\n");
if excluded.is_empty() {
out.push_str(" - nenhuma\n");
} else {
for item in excluded {
out.push_str(&format!(" - {item}\n"));
}
}
out.push_str("- Fontes obsoletas:\n");
if stale.is_empty() {
out.push_str(" - nenhuma\n");
} else {
for item in stale {
out.push_str(&format!(" - {item}\n"));
}
}
out.push_str(&format!(
"- Limite aplicado: {used_chars} caracteres usados\n"
));
out
}
fn count_signals_at_or_above(signals: &[Value], threshold: &str) -> Result<usize> {
let threshold = severity_rank(threshold)
.ok_or_else(|| anyhow!("invalid --fail-on severity `{threshold}`"))?;
Ok(signals
.iter()
.filter(|signal| {
signal
.get("severity")
.and_then(Value::as_str)
.and_then(severity_rank)
.is_some_and(|severity| severity >= threshold)
})
.count())
}
fn severity_rank(severity: &str) -> Option<u8> {
match severity.trim().to_ascii_lowercase().as_str() {
"info" => Some(1),
"warning" | "warn" => Some(2),
"high" | "error" => Some(3),
"critical" | "fatal" => Some(4),
_ => None,
}
}
fn intelligence_health_signals(root: &Path) -> Result<Vec<Value>> {
let mut signals = Vec::new();
let docs = root.join("docs");
if docs.is_dir() {
for entry in fs::read_dir(&docs)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(slug) = path.file_name().and_then(OsStr::to_str) else {
continue;
};
if slug.starts_with('_') {
continue;
}
if path.join("traceability-map.yaml").exists() && !path.join("08-memory.md").exists() {
signals.push(json!({
"id": "missing-memory",
"severity": "warning",
"orchestration_slug": slug,
"message": format!("Orquestração `{slug}` ainda não possui 08-memory.md"),
}));
}
for stage in contract::stages() {
if matches!(stage.key.as_str(), "idea" | "prd" | "techspec" | "tasks")
&& !path.join(&stage.filename).exists()
{
signals.push(json!({
"id": "missing-artifact",
"severity": "info",
"orchestration_slug": slug,
"stage": stage.key,
"message": format!("Artefato `{}` ausente em `{slug}`", stage.filename),
}));
}
}
}
}
let learnings = refresh_learning_statuses(
root,
&intelligence_index_dir(root)?.join("learnings.jsonl"),
false,
)?;
for summary in stale_learning_summaries(&learnings) {
signals.push(json!({
"id": "stale-learning",
"severity": "warning",
"message": summary,
}));
}
Ok(signals)
}
fn count_stale_learnings(learnings: &[Value]) -> usize {
learnings
.iter()
.filter(|value| value.get("status").and_then(Value::as_str) == Some("stale"))
.count()
}
fn stale_learning_summaries(learnings: &[Value]) -> Vec<String> {
let mut stale = Vec::new();
for value in learnings {
let Some(source) = value.get("source_artifact").and_then(Value::as_str) else {
continue;
};
if value.get("status").and_then(Value::as_str) == Some("stale") {
stale.push(format!("{source}: hash mudou ou origem ausente"));
}
}
stale
}
fn refresh_learning_statuses(root: &Path, path: &Path, persist: bool) -> Result<Vec<Value>> {
let mut learnings = read_jsonl_values(path)?;
let mut changed = false;
for value in &mut learnings {
let Some(status) = value.get("status").and_then(Value::as_str) else {
continue;
};
if !matches!(status, "active" | "stale") {
continue;
}
let is_stale = learning_source_is_stale(root, value);
let next_status = if is_stale { "stale" } else { "active" };
if status != next_status {
value["status"] = Value::String(next_status.to_string());
changed = true;
}
}
if changed && persist {
write_jsonl_values(path, &learnings)?;
}
Ok(learnings)
}
fn learning_source_is_stale(root: &Path, value: &Value) -> bool {
let Some(source) = value.get("source_artifact").and_then(Value::as_str) else {
return false;
};
let Some(expected_hash) = value.get("source_hash").and_then(Value::as_str) else {
return false;
};
let path = root.join(source);
let Ok(text) = fs::read_to_string(&path) else {
return true;
};
sha256_hex(text.as_bytes()) != expected_hash
}
fn read_jsonl_values(path: &Path) -> Result<Vec<Value>> {
if !path.exists() {
return Ok(Vec::new());
}
Ok(fs::read_to_string(path)?
.lines()
.filter(|line| !line.trim().is_empty())
.filter_map(|line| serde_json::from_str::<Value>(line).ok())
.collect())
}
fn write_jsonl_replace_scope(
path: &Path,
values: &[Value],
scope_key: &str,
scope_value: Option<&str>,
) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut existing = if path.exists() {
read_jsonl_values(path)?
.into_iter()
.filter(|value| value.get(scope_key).and_then(Value::as_str) != scope_value)
.collect::<Vec<_>>()
} else {
Vec::new()
};
existing.extend(values.iter().cloned());
let mut out = String::new();
for item in existing {
out.push_str(&serde_json::to_string(&item)?);
out.push('\n');
}
fs::write(path, out)?;
Ok(())
}
fn write_jsonl_replace_scopes(
path: &Path,
values: &[Value],
scope_key: &str,
scope_values: &BTreeSet<String>,
) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut existing = if path.exists() {
read_jsonl_values(path)?
.into_iter()
.filter(|value| {
value
.get(scope_key)
.and_then(Value::as_str)
.is_none_or(|current| !scope_values.contains(current))
})
.collect::<Vec<_>>()
} else {
Vec::new()
};
existing.extend(values.iter().cloned());
write_jsonl_values(path, &existing)
}
fn write_jsonl_replace_by_key(path: &Path, values: &[Value], key: &str) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let replacement_keys = values
.iter()
.filter_map(|value| value.get(key).and_then(Value::as_str))
.collect::<BTreeSet<_>>();
let mut existing = if path.exists() {
read_jsonl_values(path)?
.into_iter()
.filter(|value| {
value
.get(key)
.and_then(Value::as_str)
.is_none_or(|current| !replacement_keys.contains(current))
})
.collect::<Vec<_>>()
} else {
Vec::new()
};
existing.extend(values.iter().cloned());
write_jsonl_values(path, &existing)
}
fn write_jsonl_values(path: &Path, values: &[Value]) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let mut out = String::new();
for item in values {
out.push_str(&serde_json::to_string(item)?);
out.push('\n');
}
fs::write(path, out)?;
Ok(())
}
fn relative_path(root: &Path, path: &Path) -> String {
// Normaliza para `/` para que os caminhos no índice/jsonl sejam portáveis
// entre Windows, Linux e macOS (não vazar o separador `\` do Windows).
path.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
fn truncate_chars(input: &str, max_chars: usize) -> String {
if input.chars().count() <= max_chars {
return input.to_string();
}
let mut out = input.chars().take(max_chars).collect::<String>();
out.push_str("\n...(truncado)\n");
out
}
fn sha256_hex(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
format!("{:x}", hasher.finalize())
}
fn write_stage_content(
root_arg: &Path,
name: &str,
stage: &str,
content: &str,
state: &str,
force: bool,
dry_run: bool,
) -> Result<PathBuf> {
let root = runtime::platform::canonicalize(root_arg).unwrap_or_else(|_| root_arg.to_path_buf());
let dest = artifact_dir(&root, name);
let filename = stage_file(stage).ok_or_else(|| anyhow!("unknown stage: {stage}"))?;
let target = dest.join(filename);
if dry_run {
println!("dry-run write {}", target.display());
return Ok(target);
}
fs::create_dir_all(&dest)?;
let index = ensure_index(&dest, name)?;
if target.exists() && !force {
bail!(
"refusing to overwrite existing artifact: {}",
target.display()
);
}
tui::persist::write_atomic(&target, content.as_bytes())?;
update_stage_state(&index, stage, state)?;
println!("{}", target.display());
Ok(target)
}
fn write_stage_content_silent(
root_arg: &Path,
name: &str,
stage: &str,
content: &str,
state: &str,
force: bool,
dry_run: bool,
) -> Result<PathBuf> {
let root = runtime::platform::canonicalize(root_arg).unwrap_or_else(|_| root_arg.to_path_buf());
let dest = artifact_dir(&root, name);
let filename = stage_file(stage).ok_or_else(|| anyhow!("unknown stage: {stage}"))?;
let target = dest.join(filename);
if dry_run {
return Ok(target);
}
fs::create_dir_all(&dest)?;
let index = ensure_index(&dest, name)?;
if target.exists() && !force {
bail!(
"refusing to overwrite existing artifact: {}",
target.display()
);
}
tui::persist::write_atomic(&target, content.as_bytes())?;
update_stage_state(&index, stage, state)?;
Ok(target)
}
fn write_discovery_html_content(
root_arg: &Path,
name: &str,
content: &str,
force: bool,
dry_run: bool,
print_path: bool,
) -> Result<PathBuf> {
let root = runtime::platform::canonicalize(root_arg).unwrap_or_else(|_| root_arg.to_path_buf());
let dest = artifact_dir(&root, name);
let target = dest.join("00-project-discovery.html");
if dry_run {
if print_path {
println!("dry-run write {}", target.display());
}
return Ok(target);
}
fs::create_dir_all(&dest)?;
ensure_index(&dest, name)?;
if target.exists() && !force {
bail!(
"refusing to overwrite existing artifact: {}",
target.display()
);
}
tui::persist::write_atomic(&target, content.as_bytes())?;
if print_path {
println!("{}", target.display());
}
Ok(target)
}
fn run_discover(args: StageArgs) -> Result<()> {
let input = args.input.join(" ");
let root = runtime::platform::canonicalize(&args.root).unwrap_or_else(|_| args.root.clone());
let provider = resolve_provider(&root, &provider_options(&args))?;
let report = discover_project(&root);
let name = args
.name
.clone()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| {
if input.trim().is_empty() {
"sdd-orchestration".to_string()
} else {
input.clone()
}
});
let state = if args.json && args.name.is_some() {
"recorded"
} else {
"draft"
};
let content = render_discovery_artifact(&root, &name, &input, &report, &provider, state);
let artifact_path = if args.json {
if args.name.is_some() || !args.dry_run {
Some(write_stage_content_silent(
&root,
&name,
"project-discovery",
&content,
state,
args.force,
args.dry_run,
)?)
} else {
None
}
} else {
Some(write_stage_content(
&root,
&name,
"project-discovery",
&content,
state,
args.force,
args.dry_run,
)?)
};
let html_artifact_path = if artifact_path.is_some() {
let html = render_discovery_html_artifact(&name, &input, &report, &provider, state);
Some(write_discovery_html_content(
&root,
&name,
&html,
args.force,
args.dry_run,
!args.json,
)?)
} else {
None
};
if args.json {
let artifact_written = artifact_path.is_some() && !args.dry_run;
let html_artifact_written = html_artifact_path.is_some() && !args.dry_run;
println!(
"{}",
serde_json::to_string_pretty(&json!({
"report": report,
"artifact_path": artifact_path
.as_ref()
.map(|path| runtime::platform::display_path(path).replace('\\', "/")),
"html_artifact_path": html_artifact_path
.as_ref()
.map(|path| runtime::platform::display_path(path).replace('\\', "/")),
"artifact_written": artifact_written,
"html_artifact_written": html_artifact_written,
"dry_run": args.dry_run,
}))?
);
}
Ok(())
}
fn render_discovery_artifact(
root: &Path,
name: &str,
input: &str,
report: &DiscoveryReport,
provider: &ProviderSelection,
state: &str,
) -> String {
let mut out = format!("# Project Discovery - {name}\n\n");
out.push_str("## Rastreabilidade\n");
out.push_str(&format!("- Orquestração: {name}\n"));
out.push_str(&format!("- Slug: {}\n", slugify(name)));
out.push_str("- Stage: project-discovery\n");
out.push_str(&format!("- Estado atual: {state}\n"));
out.push_str(&format!("- Provider: {}\n", provider.id));
out.push_str(&format!("- Modelo: {}\n", provider.model));
out.push_str(&format!("- Effort: {}\n", provider.effort));
out.push_str(&format!("- Offline: {}\n", provider.offline));
out.push_str(&format!(
"- Entrada: {}\n\n",
if input.trim().is_empty() {
"(inventário geral)"
} else {
input
}
));
out.push_str("## Identidade do projeto\n");
out.push_str(&format!("- Nome detectado: {}\n", report.identity.name));
out.push_str(&format!("- Root: `{}`\n", report.project_root));
out.push_str(&format!("- Tipo: {}\n", report.identity.project_type));
out.push_str(&format!("- Lifecycle: {}\n", report.identity.lifecycle));
out.push_str(&format!(
"- Stack: {}\n\n",
report.identity.stack.join(", ")
));
out.push_str(&render_discovery_visualizations(name, report));
out.push_str("## Estrutura observada\n");
push_path_group(&mut out, "Código fonte", &report.paths.source);
push_path_group(&mut out, "Testes", &report.paths.tests);
push_path_group(&mut out, "Docs", &report.paths.docs);
push_path_group(&mut out, "Migrations", &report.paths.migrations);
push_path_group(&mut out, "Infra/deploy", &report.paths.infra);
push_path_group(&mut out, "Configurações", &report.paths.config);
push_path_group(&mut out, "Artefatos SDD", &report.paths.artifacts);
out.push('\n');
out.push_str("## Inventário técnico detalhado\n");
push_manifest_table(&mut out, &report.manifests);
push_evidence_group(
&mut out,
"Componentes principais",
&report.architecture.components,
);
push_string_group(&mut out, "CI", &report.operations.ci);
push_string_group(&mut out, "Deploy/infra", &report.operations.deploy);
push_string_group(&mut out, "Containers", &report.operations.containers);
push_string_group(
&mut out,
"Arquivos de ambiente",
&report.operations.env_files,
);
push_string_group(
&mut out,
"Variáveis de ambiente observadas",
&report.operations.env_vars,
);
out.push('\n');
out.push_str("## Comandos verificados\n");
out.push_str(
"- Nenhum comando foi executado automaticamente; discovery estruturado e read-only.\n",
);
push_command_group(&mut out, "Instalação", &report.commands.install);
push_command_group(&mut out, "Lint", &report.commands.lint);
push_command_group(&mut out, "Typecheck", &report.commands.typecheck);
push_command_group(&mut out, "Testes", &report.commands.test);
push_command_group(&mut out, "Build", &report.commands.build);
push_command_group(&mut out, "Não verificados", &report.commands.not_verified);
push_command_group(&mut out, "Ausentes", &report.commands.missing);
out.push('\n');
out.push_str("## Arquitetura e padrões\n");
push_string_group(&mut out, "Resumo inferido", &report.architecture.summary);
out.push_str(&format!(
"- Confiança da inferência: {}\n",
report.architecture.confidence
));
push_evidence_group(
&mut out,
"Entrypoints reais",
&report.architecture.entrypoints,
);
push_evidence_group(
&mut out,
"Camadas e boundaries",
&report.architecture.layers,
);
push_evidence_group(&mut out, "Rotas e comandos", &report.architecture.routing);
push_string_group(
&mut out,
"Convenções observadas",
&report.architecture.conventions,
);
push_string_group(
&mut out,
"Frameworks e estratégia de testes",
&report.quality.test_frameworks,
);
push_string_group(&mut out, "Paths de teste", &report.quality.test_paths);
push_string_group(&mut out, "Comandos de qualidade", &report.quality.commands);
push_string_group(&mut out, "Coverage", &report.quality.coverage);
push_string_group(&mut out, "Lacunas de qualidade", &report.quality.gaps);
push_string_group(&mut out, "Migrations", &report.data_contracts.migrations);
push_string_group(&mut out, "Schemas", &report.data_contracts.schemas);
push_string_group(
&mut out,
"Contratos API",
&report.data_contracts.api_contracts,
);
push_string_group(&mut out, "ORM/persistência", &report.data_contracts.orm);
push_string_group(
&mut out,
"Seeds/fixtures",
&report.data_contracts.seeds_fixtures,
);
push_string_group(
&mut out,
"Serviços externos",
&report.integrations.external_services,
);
push_string_group(&mut out, "Auth", &report.integrations.auth);
push_string_group(&mut out, "Webhooks", &report.integrations.webhooks);
push_string_group(&mut out, "Filas/jobs", &report.integrations.queues_jobs);
push_string_group(&mut out, "Storage", &report.integrations.storage);
push_string_group(
&mut out,
"Observabilidade",
&report.operations.observability,
);
out.push_str("- Code intelligence:\n");
for tool in &report.code_intelligence {
out.push_str(&format!(
" - {}: available={}, indexed={}, marker=`{}`\n",
tool.id, tool.available, tool.indexed, tool.index_marker
));
if !tool.suggested_commands.is_empty() {
out.push_str(" - Comandos sugeridos:\n");
for command in &tool.suggested_commands {
out.push_str(&format!(" - `{command}`\n"));
}
}
out.push_str(&format!(" - Fallback: {}\n", tool.fallback));
}
out.push('\n');
out.push_str("## Linguagem de domínio\n");
push_path_group(
&mut out,
"CONTEXT.md",
&report.domain_language.context_files,
);
out.push_str(&format!(
" - CONTEXT-MAP.md: {}\n",
report
.domain_language
.context_map
.as_deref()
.unwrap_or("não encontrado")
));
push_path_group(&mut out, "ADRs", &report.domain_language.adr_dirs);
out.push_str(&format!(
" - Recomendação: {}\n",
report.domain_language.recommendation
));
out.push('\n');
out.push_str("## Riscos do projeto\n");
for risk in &report.risks {
out.push_str(&format!("- {risk}\n"));
}
out.push('\n');
out.push_str("## Regras para a pipeline\n");
out.push_str("- Refinement obrigatório quando houver risco medium/high/critical, migração, dados, segurança, dependência externa, arquitetura incerta ou code intelligence indicar impacto amplo.\n");
out.push_str("- Agent Team obrigatório quando a feature cruzar frontend/backend/dados/infra, segurança, performance, data contracts ou área brownfield desconhecida.\n");
out.push_str("- Checkpoint adicional quando houver ADR nova, alteração de contrato, migração, deploy, feature flag ou decisão de arquitetura dificil de reverter.\n");
out.push_str("- Evidências mínimas de review: Context Pack, comandos executados, testes, diff, riscos residuais e fallback usado quando CodeGraph/Lexa não existirem.\n\n");
out.push_str("## Recomendações de skills e regras\n");
out.push_str(&render_context_recommendations(root, Some(name)));
out.push('\n');
out.push_str("## Contexto para próximos agentes\n");
out.push_str("- Antes de Tech Spec ou Execution, rode ou leia `sdd context build --name \"");
out.push_str(name);
out.push_str("\" --stage <stage> --write` quando houver artifact store.\n");
out.push_str("- Use `code-intelligence` se CodeGraph/Lexa estiverem disponíveis; caso contrário, use `rg --files`, `rg` e leitura focada.\n");
out.push_str("- Use `execution-discipline` para TDD vertical, diagnostico com feedback loop e verificação fresca antes de declarar conclusão.\n");
out.push_str("- Use `domain-language` quando termos do usuário divergirem do glossário ou ADRs existentes.\n");
out.push_str("- Use `architecture-deepening` quando a mudança exigir refactor de módulo raso ou seam pouco testável.\n\n");
out.push_str("## Prompt para agente\n");
out.push_str("Use o subagent `project-discovery` para validar este inventário, completar lacunas e salvar recomendações materializáveis sem alterar código.\n");
out
}
fn push_path_group(out: &mut String, label: &str, paths: &[String]) {
if paths.is_empty() {
out.push_str(&format!("- {label}: não encontrado no inventário\n"));
} else {
out.push_str(&format!("- {label}: {}\n", paths.join(", ")));
}
}
fn push_command_group(out: &mut String, label: &str, commands: &[String]) {
if commands.is_empty() {
out.push_str(&format!("- {label}: ausente\n"));
} else {
out.push_str(&format!("- {label}: {}\n", commands.join(" | ")));
}
}
fn push_string_group(out: &mut String, label: &str, items: &[String]) {
if items.is_empty() {
out.push_str(&format!("- {label}: não encontrado no inventário\n"));
} else {
out.push_str(&format!("- {label}:\n"));
for item in items {
out.push_str(&format!(" - {item}\n"));
}
}
}
fn push_manifest_table(out: &mut String, manifests: &[ProjectManifest]) {
out.push_str("- Manifests:\n");
if manifests.is_empty() {
out.push_str(" - não encontrado no inventário\n");
return;
}
out.push_str("\n| Manifest | Tipo | Nome | Versão | Gerenciador | Workspace | Sinais |\n");
out.push_str("|---|---|---|---|---|---|---|\n");
for manifest in manifests {
out.push_str(&format!(
"| `{}` | {} | {} | {} | {} | {} | {} |\n",
manifest.path,
manifest.kind,
manifest.name.as_deref().unwrap_or("não informado"),
manifest.version.as_deref().unwrap_or("não informado"),
manifest
.package_manager
.as_deref()
.unwrap_or("não informado"),
manifest.workspace,
if manifest.signals.is_empty() {
"sem sinais especializados".to_string()
} else {
manifest.signals.join(", ")
}
));
}
}
fn push_evidence_group(out: &mut String, label: &str, items: &[ArchitectureEvidence]) {
if items.is_empty() {
out.push_str(&format!("- {label}: não encontrado no inventário\n"));
return;
}
out.push_str(&format!("- {label}:\n\n"));
out.push_str("| Path | Tipo | Papel | Evidência |\n");
out.push_str("|---|---|---|---|\n");
for item in items {
out.push_str(&format!(
"| `{}` | {} | {} | {} |\n",
item.path,
item.kind,
item.role,
if item.evidence.is_empty() {
"path observado".to_string()
} else {
item.evidence.join("<br>")
}
));
}
}
fn render_discovery_visualizations(name: &str, report: &DiscoveryReport) -> String {
let slug = slugify(name);
let path_counts = discovery_path_counts(report);
let command_counts = discovery_command_counts(report);
let path_max = chart_max(&path_counts);
let command_max = chart_max(&command_counts);
let mut out = String::new();
out.push_str("## Visualizações e documentação HTML\n");
out.push_str(&format!(
"- Markdown canônico: `docs/{slug}/00-project-discovery.md`\n"
));
out.push_str(&format!(
"- HTML de onboarding: `docs/{slug}/00-project-discovery.html`\n"
));
out.push_str("- Público: desenvolvedores entrando no projeto, Tech Leads e reviewers que precisam entender stack, comandos, riscos e próximos passos rapidamente.\n");
out.push_str("- Fonte dos gráficos: `DiscoveryReport` gerado pelo inventário read-only do `sdd discover`.\n\n");
out.push_str("```mermaid\n");
out.push_str("flowchart LR\n");
out.push_str(" projeto[\"Projeto\"]\n");
for (index, stack) in report.identity.stack.iter().enumerate() {
out.push_str(&format!(
" projeto --> stack_{index}[\"{}\"]\n",
mermaid_label(stack)
));
}
out.push_str(&format!(
" projeto --> paths[\"{} grupos de paths detectados\"]\n",
path_counts.iter().filter(|(_, count)| *count > 0).count()
));
out.push_str(&format!(
" projeto --> comandos[\"{} comandos sugeridos\"]\n",
command_counts.iter().map(|(_, count)| count).sum::<usize>()
));
out.push_str(&format!(
" projeto --> riscos[\"{} riscos mapeados\"]\n",
report.risks.len()
));
out.push_str("```\n\n");
out.push_str("```mermaid\n");
out.push_str("xychart-beta\n");
out.push_str(" title \"Cobertura do inventário\"\n");
out.push_str(" x-axis [Código, Testes, Docs, Migrations, Infra, Config, SDD]\n");
out.push_str(&format!(" y-axis \"Itens\" 0 --> {path_max}\n"));
out.push_str(&format!(" bar [{}]\n", chart_values(&path_counts)));
out.push_str("```\n\n");
out.push_str("```mermaid\n");
out.push_str("xychart-beta\n");
out.push_str(" title \"Comandos derivados dos manifests\"\n");
out.push_str(" x-axis [Install, Lint, Typecheck, Testes, Build, Pendentes, Ausentes]\n");
out.push_str(&format!(" y-axis \"Comandos\" 0 --> {command_max}\n"));
out.push_str(&format!(" bar [{}]\n", chart_values(&command_counts)));
out.push_str("```\n\n");
out
}
fn render_discovery_html_artifact(
name: &str,
input: &str,
report: &DiscoveryReport,
provider: &ProviderSelection,
state: &str,
) -> String {
let slug = slugify(name);
let stack = if report.identity.stack.is_empty() {
"não detectada".to_string()
} else {
report.identity.stack.join(", ")
};
let mut out = String::new();
out.push_str("<!doctype html>\n<html lang=\"pt-BR\">\n<head>\n");
out.push_str(" <meta charset=\"utf-8\">\n");
out.push_str(" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n");
out.push_str(&format!(
" <title>Project Discovery - {}</title>\n",
html_escape(name)
));
out.push_str(
r#" <style>
:root {
color-scheme: light;
--bg: #f6f6f0;
--panel: #ffffff;
--ink: #1e2326;
--muted: #667178;
--line: #d9ddd6;
--accent: #1f7a68;
--accent-2: #315fba;
--warn: #9b6200;
--risk: #b42318;
--soft: #eef4f1;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
letter-spacing: 0;
line-height: 1.5;
}
header {
background: #20251f;
color: #fff;
padding: 32px max(24px, calc((100vw - 1180px) / 2));
border-bottom: 4px solid var(--accent);
}
header p { color: #d6ddd7; max-width: 860px; margin: 8px 0 0; }
h1, h2, h3 { letter-spacing: 0; line-height: 1.15; }
h1 { margin: 0; font-size: clamp(2rem, 4vw, 3.2rem); }
h2 { margin: 0 0 14px; font-size: 1.35rem; }
h3 { margin: 0 0 8px; font-size: 1rem; }
main { max-width: 1180px; margin: 0 auto; padding: 24px; }
section { margin: 0 0 18px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 12px; }
.panel, .card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: 0 1px 2px rgba(30, 35, 38, 0.06);
}
.panel { padding: 18px; }
.card { padding: 16px; min-height: 112px; }
.metric { display: block; font-size: 2rem; font-weight: 750; color: var(--accent); }
.label { color: var(--muted); font-size: 0.9rem; }
.badge-row { display: flex; gap: 8px; flex-wrap: wrap; }
.badge {
display: inline-flex;
align-items: center;
min-height: 28px;
border-radius: 999px;
background: var(--soft);
color: #173f36;
border: 1px solid #c8ddd5;
padding: 4px 10px;
font-size: 0.88rem;
font-weight: 650;
}
.bar-row {
display: grid;
grid-template-columns: minmax(90px, 160px) 1fr 44px;
gap: 10px;
align-items: center;
margin: 10px 0;
}
.track { height: 12px; background: #e8ebe6; border-radius: 999px; overflow: hidden; }
.fill { height: 100%; min-width: 2px; background: var(--accent-2); border-radius: 999px; }
.fill.warn { background: var(--warn); }
table { width: 100%; border-collapse: collapse; }
th, td { border-top: 1px solid var(--line); padding: 10px; text-align: left; vertical-align: top; }
th { color: #394046; font-size: 0.86rem; background: #f0f2ed; }
code {
background: #f0f2ed;
border: 1px solid #dce0d8;
border-radius: 6px;
padding: 1px 5px;
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 0.9em;
}
ul { padding-left: 20px; margin: 8px 0 0; }
.muted { color: var(--muted); }
.risk { color: var(--risk); font-weight: 650; }
.pipeline svg { width: 100%; max-width: 980px; height: auto; display: block; }
.footer { color: var(--muted); font-size: 0.9rem; margin-top: 22px; }
@media (max-width: 680px) {
main { padding: 16px; }
.bar-row { grid-template-columns: 1fr; gap: 4px; }
th, td { display: block; width: 100%; }
tr { display: block; border-top: 1px solid var(--line); padding: 8px 0; }
}
</style>
"#,
);
out.push_str("</head>\n<body>\n");
out.push_str(" <header>\n");
out.push_str(&format!(" <h1>{}</h1>\n", html_escape(name)));
out.push_str(&format!(
" <p>Project Discovery registrado para <strong>{}</strong>. Stack detectada: {}.</p>\n",
html_escape(&slug),
html_escape(&stack)
));
out.push_str(" </header>\n <main>\n");
out.push_str(" <section class=\"grid\" aria-label=\"Resumo executivo\">\n");
out.push_str(&html_metric_card(
"Tipo",
&report.identity.project_type,
"Classificação operacional do projeto.",
));
out.push_str(&html_metric_card(
"Lifecycle",
&report.identity.lifecycle,
"Sinal de greenfield/brownfield para calibrar risco.",
));
out.push_str(&html_metric_card(
"Stack",
&report.identity.stack.len().to_string(),
"Tecnologias ou contextos detectados.",
));
out.push_str(&html_metric_card(
"Riscos",
&report.risks.len().to_string(),
"Riscos recorrentes que pedem evidência ou checkpoint.",
));
out.push_str(" </section>\n");
out.push_str(" <section class=\"panel\">\n");
out.push_str(" <h2>Stack Utilizada</h2>\n");
out.push_str(" <div class=\"badge-row\">\n");
if report.identity.stack.is_empty() {
out.push_str(" <span class=\"badge\">não detectada</span>\n");
} else {
for item in &report.identity.stack {
out.push_str(&format!(
" <span class=\"badge\">{}</span>\n",
html_escape(item)
));
}
}
out.push_str(" </div>\n");
out.push_str(&format!(
" <p class=\"muted\">Root: <code>{}</code></p>\n",
html_escape(&report.project_root)
));
out.push_str(" </section>\n");
out.push_str(" <section class=\"panel\">\n");
out.push_str(" <h2>Arquitetura Inferida</h2>\n");
out.push_str(&format!(
" <p class=\"muted\">Confiança: {}</p>\n",
html_escape(&report.architecture.confidence)
));
out.push_str(&html_string_list(
&report.architecture.summary,
"Resumo não inferido",
));
out.push_str(" <h3>Entrypoints</h3>\n");
out.push_str(&html_evidence_table(
&report.architecture.entrypoints,
"Nenhum entrypoint canônico encontrado",
));
out.push_str(" <h3>Camadas e boundaries</h3>\n");
out.push_str(&html_evidence_table(
&report.architecture.layers,
"Nenhuma camada explícita encontrada",
));
out.push_str(" <h3>Rotas e comandos</h3>\n");
out.push_str(&html_evidence_table(
&report.architecture.routing,
"Nenhuma rota ou comando explícito encontrado",
));
out.push_str(" </section>\n");
out.push_str(" <section class=\"panel\">\n");
out.push_str(" <h2>Manifests e Dependências</h2>\n");
out.push_str(&html_manifest_table(&report.manifests));
out.push_str(" </section>\n");
out.push_str(" <section class=\"panel pipeline\">\n");
out.push_str(" <h2>Fluxo SDD Recomendado</h2>\n");
out.push_str(html_pipeline_svg());
out.push_str(" </section>\n");
out.push_str(" <section class=\"grid\">\n");
out.push_str(&html_bar_panel(
"Cobertura do Inventário",
&discovery_path_counts(report),
false,
));
out.push_str(&html_bar_panel(
"Comandos Derivados",
&discovery_command_counts(report),
true,
));
out.push_str(" </section>\n");
out.push_str(" <section class=\"panel\">\n");
out.push_str(" <h2>Paths Observados</h2>\n");
out.push_str(" <table><thead><tr><th>Categoria</th><th>Paths</th></tr></thead><tbody>\n");
for (label, paths) in discovery_path_groups(report) {
out.push_str(&format!(
" <tr><td>{}</td><td>{}</td></tr>\n",
html_escape(label),
html_code_list(paths, "não encontrado no inventário")
));
}
out.push_str(" </tbody></table>\n");
out.push_str(" </section>\n");
out.push_str(" <section class=\"panel\">\n");
out.push_str(" <h2>Comandos</h2>\n");
out.push_str(" <table><thead><tr><th>Grupo</th><th>Comandos</th></tr></thead><tbody>\n");
for (label, commands) in discovery_command_groups(report) {
out.push_str(&format!(
" <tr><td>{}</td><td>{}</td></tr>\n",
html_escape(label),
html_code_list(commands, "ausente")
));
}
out.push_str(" </tbody></table>\n");
out.push_str(" </section>\n");
out.push_str(" <section class=\"grid\">\n");
out.push_str(&html_list_panel(
"Qualidade",
&[
("Frameworks", report.quality.test_frameworks.as_slice()),
("Testes", report.quality.test_paths.as_slice()),
("Comandos", report.quality.commands.as_slice()),
("Gaps", report.quality.gaps.as_slice()),
],
));
out.push_str(&html_list_panel(
"Operação",
&[
("CI", report.operations.ci.as_slice()),
("Deploy", report.operations.deploy.as_slice()),
("Env vars", report.operations.env_vars.as_slice()),
(
"Observabilidade",
report.operations.observability.as_slice(),
),
],
));
out.push_str(&html_list_panel(
"Dados e Contratos",
&[
("Migrations", report.data_contracts.migrations.as_slice()),
("Schemas", report.data_contracts.schemas.as_slice()),
("API", report.data_contracts.api_contracts.as_slice()),
("ORM", report.data_contracts.orm.as_slice()),
],
));
out.push_str(&html_list_panel(
"Integrações",
&[
("Serviços", report.integrations.external_services.as_slice()),
("Auth", report.integrations.auth.as_slice()),
("Webhooks", report.integrations.webhooks.as_slice()),
("Storage", report.integrations.storage.as_slice()),
],
));
out.push_str(" </section>\n");
out.push_str(" <section class=\"panel\">\n");
out.push_str(" <h2>Code Intelligence</h2>\n");
out.push_str(" <table><thead><tr><th>Ferramenta</th><th>Status</th><th>Comandos sugeridos</th><th>Fallback</th></tr></thead><tbody>\n");
for tool in &report.code_intelligence {
out.push_str(&format!(
" <tr><td><code>{}</code></td><td>available={} · indexed={} · marker=<code>{}</code></td><td>{}</td><td>{}</td></tr>\n",
html_escape(&tool.id),
tool.available,
tool.indexed,
html_escape(&tool.index_marker),
html_code_list(&tool.suggested_commands, "sem comando sugerido"),
html_escape(&tool.fallback)
));
}
out.push_str(" </tbody></table>\n");
out.push_str(" </section>\n");
out.push_str(" <section class=\"panel\">\n");
out.push_str(" <h2>Riscos e Checkpoints</h2>\n");
if report.risks.is_empty() {
out.push_str(
" <p class=\"muted\">Nenhum risco recorrente foi detectado no inventário.</p>\n",
);
} else {
out.push_str(" <ul>\n");
for risk in &report.risks {
out.push_str(&format!(
" <li class=\"risk\">{}</li>\n",
html_escape(risk)
));
}
out.push_str(" </ul>\n");
}
out.push_str(" </section>\n");
out.push_str(" <section class=\"panel\">\n");
out.push_str(" <h2>Capability Map</h2>\n");
out.push_str(" <table><thead><tr><th>Capability</th><th>Fonte</th><th>Stages</th><th>Skill local</th><th>Risco</th></tr></thead><tbody>\n");
for item in &report.capabilities {
out.push_str(&format!(
" <tr><td>{}</td><td>{}<br><span class=\"muted\">{}</span></td><td>{}</td><td><code>{}</code></td><td>{}</td></tr>\n",
html_escape(&item.title),
html_escape(&item.source),
html_escape(&item.source_url),
html_escape(&item.stages.join(", ")),
html_escape(&item.local_skill),
html_escape(&item.risk)
));
}
out.push_str(" </tbody></table>\n");
out.push_str(" </section>\n");
out.push_str(" <section class=\"panel\">\n");
out.push_str(" <h2>Rastreabilidade</h2>\n");
out.push_str(&format!(
" <p>Estado: <strong>{}</strong> · Provider: <code>{}</code> · Modelo: <code>{}</code> · Effort: <code>{}</code> · Offline: <code>{}</code></p>\n",
html_escape(state),
html_escape(&provider.id),
html_escape(&provider.model),
html_escape(&provider.effort),
provider.offline
));
out.push_str(&format!(
" <p>Entrada: {}</p>\n",
html_escape(if input.trim().is_empty() {
"(inventário geral)"
} else {
input
})
));
out.push_str(" <p>Artefato Markdown pareado: <a href=\"00-project-discovery.md\">00-project-discovery.md</a></p>\n");
out.push_str(" </section>\n");
out.push_str(&format!(
" <p class=\"footer\">Gerado por <code>sdd discover</code> em {} a partir do inventário read-only do projeto.</p>\n",
html_escape(&now())
));
out.push_str(" </main>\n</body>\n</html>\n");
out
}
fn discovery_path_groups(report: &DiscoveryReport) -> [(&'static str, &[String]); 7] {
[
("Código fonte", report.paths.source.as_slice()),
("Testes", report.paths.tests.as_slice()),
("Docs", report.paths.docs.as_slice()),
("Migrations", report.paths.migrations.as_slice()),
("Infra/deploy", report.paths.infra.as_slice()),
("Configurações", report.paths.config.as_slice()),
("Artefatos SDD", report.paths.artifacts.as_slice()),
]
}
fn discovery_command_groups(report: &DiscoveryReport) -> [(&'static str, &[String]); 7] {
[
("Instalação", report.commands.install.as_slice()),
("Lint", report.commands.lint.as_slice()),
("Typecheck", report.commands.typecheck.as_slice()),
("Testes", report.commands.test.as_slice()),
("Build", report.commands.build.as_slice()),
("Não verificados", report.commands.not_verified.as_slice()),
("Ausentes", report.commands.missing.as_slice()),
]
}
fn discovery_path_counts(report: &DiscoveryReport) -> [(&'static str, usize); 7] {
[
("Código", report.paths.source.len()),
("Testes", report.paths.tests.len()),
("Docs", report.paths.docs.len()),
("Migrations", report.paths.migrations.len()),
("Infra", report.paths.infra.len()),
("Config", report.paths.config.len()),
("SDD", report.paths.artifacts.len()),
]
}
fn discovery_command_counts(report: &DiscoveryReport) -> [(&'static str, usize); 7] {
[
("Install", report.commands.install.len()),
("Lint", report.commands.lint.len()),
("Typecheck", report.commands.typecheck.len()),
("Testes", report.commands.test.len()),
("Build", report.commands.build.len()),
("Pendentes", report.commands.not_verified.len()),
("Ausentes", report.commands.missing.len()),
]
}
fn chart_max(items: &[(&str, usize)]) -> usize {
items
.iter()
.map(|(_, count)| *count)
.max()
.unwrap_or(1)
.max(1)
}
fn chart_values(items: &[(&str, usize)]) -> String {
items
.iter()
.map(|(_, count)| count.to_string())
.collect::<Vec<_>>()
.join(", ")
}
fn html_bar_panel(title: &str, items: &[(&str, usize)], warn: bool) -> String {
let max = chart_max(items);
let mut out = String::new();
out.push_str(" <div class=\"panel\">\n");
out.push_str(&format!(" <h2>{}</h2>\n", html_escape(title)));
for (label, count) in items {
let width = ((*count * 100) / max).max(if *count > 0 { 8 } else { 0 });
let fill_class = if warn { "fill warn" } else { "fill" };
out.push_str(&format!(
" <div class=\"bar-row\"><span>{}</span><div class=\"track\"><div class=\"{}\" style=\"width: {}%\"></div></div><strong>{}</strong></div>\n",
html_escape(label),
fill_class,
width,
count
));
}
out.push_str(" </div>\n");
out
}
fn html_metric_card(title: &str, value: &str, description: &str) -> String {
format!(
" <article class=\"card\"><span class=\"label\">{}</span><strong class=\"metric\">{}</strong><p>{}</p></article>\n",
html_escape(title),
html_escape(value),
html_escape(description)
)
}
fn html_code_list(items: &[String], empty: &str) -> String {
if items.is_empty() {
return format!("<span class=\"muted\">{}</span>", html_escape(empty));
}
items
.iter()
.map(|item| format!("<code>{}</code>", html_escape(item)))
.collect::<Vec<_>>()
.join("<br>")
}
fn html_string_list(items: &[String], empty: &str) -> String {
if items.is_empty() {
return format!(" <p class=\"muted\">{}</p>\n", html_escape(empty));
}
let mut out = String::from(" <ul>\n");
for item in items {
out.push_str(&format!(" <li>{}</li>\n", html_escape(item)));
}
out.push_str(" </ul>\n");
out
}
fn html_evidence_table(items: &[ArchitectureEvidence], empty: &str) -> String {
if items.is_empty() {
return format!(" <p class=\"muted\">{}</p>\n", html_escape(empty));
}
let mut out = String::from(
" <table><thead><tr><th>Path</th><th>Tipo</th><th>Papel</th><th>Evidência</th></tr></thead><tbody>\n",
);
for item in items {
out.push_str(&format!(
" <tr><td><code>{}</code></td><td>{}</td><td>{}</td><td>{}</td></tr>\n",
html_escape(&item.path),
html_escape(&item.kind),
html_escape(&item.role),
html_escape(&item.evidence.join(" · "))
));
}
out.push_str(" </tbody></table>\n");
out
}
fn html_manifest_table(manifests: &[ProjectManifest]) -> String {
if manifests.is_empty() {
return " <p class=\"muted\">Nenhum manifest detectado.</p>\n".to_string();
}
let mut out = String::from(
" <table><thead><tr><th>Manifest</th><th>Tipo</th><th>Nome</th><th>Gerenciador</th><th>Sinais</th><th>Scripts</th></tr></thead><tbody>\n",
);
for manifest in manifests {
out.push_str(&format!(
" <tr><td><code>{}</code></td><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>\n",
html_escape(&manifest.path),
html_escape(&manifest.kind),
html_escape(manifest.name.as_deref().unwrap_or("não informado")),
html_escape(manifest.package_manager.as_deref().unwrap_or("não informado")),
html_escape(&manifest.signals.join(", ")),
html_escape(&manifest.scripts.iter().take(8).cloned().collect::<Vec<_>>().join(" · "))
));
}
out.push_str(" </tbody></table>\n");
out
}
fn html_list_panel(title: &str, groups: &[(&str, &[String])]) -> String {
let mut out = String::new();
out.push_str(" <div class=\"panel\">\n");
out.push_str(&format!(" <h2>{}</h2>\n", html_escape(title)));
for (label, items) in groups {
out.push_str(&format!(" <h3>{}</h3>\n", html_escape(label)));
if items.is_empty() {
out.push_str(" <p class=\"muted\">não encontrado no inventário</p>\n");
} else {
out.push_str(" <ul>\n");
for item in *items {
out.push_str(&format!(" <li>{}</li>\n", html_escape(item)));
}
out.push_str(" </ul>\n");
}
}
out.push_str(" </div>\n");
out
}
fn html_pipeline_svg() -> &'static str {
r##" <svg viewBox="0 0 980 150" role="img" aria-label="Pipeline SDD: Discovery, Risk, PRD, Tech Spec, Tasks, Execution, Review e Memory">
<line x1="75" y1="74" x2="905" y2="74" stroke="#c9d0c8" stroke-width="5" stroke-linecap="round"/>
<g fill="#1f7a68" font-family="Inter, sans-serif" font-size="13" font-weight="700" text-anchor="middle">
<circle cx="75" cy="74" r="22"/><text x="75" y="116" fill="#1e2326">Discovery</text>
<circle cx="190" cy="74" r="22"/><text x="190" y="116" fill="#1e2326">Risk</text>
<circle cx="305" cy="74" r="22"/><text x="305" y="116" fill="#1e2326">PRD</text>
<circle cx="420" cy="74" r="22"/><text x="420" y="116" fill="#1e2326">Tech Spec</text>
<circle cx="535" cy="74" r="22"/><text x="535" y="116" fill="#1e2326">Tasks</text>
<circle cx="650" cy="74" r="22"/><text x="650" y="116" fill="#1e2326">Execution</text>
<circle cx="765" cy="74" r="22"/><text x="765" y="116" fill="#1e2326">Review</text>
<circle cx="880" cy="74" r="22"/><text x="880" y="116" fill="#1e2326">Memory</text>
</g>
</svg>
"##
}
fn html_escape(input: &str) -> String {
let mut out = String::with_capacity(input.len());
for ch in input.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(ch),
}
}
out
}
fn mermaid_label(input: &str) -> String {
input
.replace('\\', "\\\\")
.replace('"', "'")
.replace('\n', " ")
}
fn render_stage_artifact(
stage: &str,
name: &str,
input: &str,
root: &Path,
provider: &ProviderSelection,
) -> Result<String> {
if stage == "adr" {
return render_adr_artifact(name, input);
}
let section_map = required_sections();
let required = section_map
.get(stage)
.ok_or_else(|| anyhow!("unknown stage: {stage}"))?;
let title = contract::stage_label(stage).unwrap_or(stage);
let mut out = format!("# {title} - {name}\n\n");
for section in required.iter().copied() {
out.push_str(&format!("## {section}\n"));
match section {
"Rastreabilidade" => {
let expected_origins = contract::stage_traceability_from(stage)
.iter()
.map(|dependency| dependency.filename.as_str())
.collect::<Vec<_>>()
.join(", ");
out.push_str(&format!(
"- Orquestração: {name}\n- Slug: {}\n- Stage: {stage}\n- Estado atual: draft\n- Provider: {}\n- Modelo: {}\n- Effort: {}\n- Offline: {}\n- Entrada: {}\n",
slugify(name),
provider.id,
provider.model,
provider.effort,
provider.offline,
if input.trim().is_empty() { "(preencher)" } else { input }
));
if !expected_origins.is_empty() {
out.push_str(&format!(
"- Artefatos de origem esperados: {expected_origins}\n"
));
}
out.push('\n');
}
"Estado final" => out.push_str("- Preencher ao encerrar o ciclo.\n\n"),
"Recomendações de skills e regras" => {
out.push_str(&render_context_recommendations(root, Some(name)));
out.push('\n');
}
_ => out.push_str("- Preencher com o subagent correspondente.\n\n"),
}
}
out.push_str("## Prompt para agente\n");
out.push_str(&stage_prompt(stage, input));
out.push('\n');
Ok(out)
}
fn render_adr_artifact(name: &str, input: &str) -> Result<String> {
let slug = slugify(name);
let input = if input.trim().is_empty() {
"(descreva a decisão arquitetural tomada ou revisada após a execução)"
} else {
input
};
Ok(format!(
"# ADR - {name}\n\n\
## Rastreabilidade\n\
- Orquestração: {name}\n\
- Slug: {slug}\n\
- Stage: adr\n\
- Estado atual: draft\n\
- Origem principal: `docs/{slug}/06-execution.md`\n\
- Artefatos relacionados: `03-techspec.md`, `04-tasks.md`, `05-refinement.md` quando houver, PR/commits/testes\n\
- Entrada: {input}\n\n\
## Status\n\
- Estado: Proposto | Aceito | Substituido | Depreciado\n\
- Data da decisão: (preencher)\n\
- Dono técnico: (preencher)\n\
- Escopo: (feature, módulo, serviço ou integração afetada)\n\n\
## Contexto\n\
- Descreva o cenário técnico e de produto observado após a execução.\n\
- Cite restrições, integrações, padrões existentes, requisitos não funcionais e evidências que influenciaram a decisão.\n\n\
## Problema\n\
- Declare a pergunta arquitetural que precisou ser respondida.\n\
- Explique por que a decisão é relevante para manutenção, evolução, risco, custo ou operação.\n\n\
## Decisão\n\
- Registre a decisão final em linguagem objetiva.\n\
- Explique como ela será aplicada no código, contratos, dados, infraestrutura, operação ou processo.\n\n\
## Alternativas consideradas\n\
| Alternativa | Benefícios | Custos/Riscos | Motivo para aceitar ou rejeitar |\n\
|---|---|---|---|\n\
| (opção 1) | (preencher) | (preencher) | (preencher) |\n\
| (opção 2) | (preencher) | (preencher) | (preencher) |\n\n\
## Consequências\n\
- Positivas: (preencher)\n\
- Negativas: (preencher)\n\
- Trade-offs aceitos: (preencher)\n\
- Dívidas técnicas assumidas: (preencher)\n\n\
## Impactos e riscos\n\
- Código e arquitetura: (preencher)\n\
- Dados, contratos ou APIs: (preencher)\n\
- Segurança e privacidade: (preencher)\n\
- Performance e capacidade: (preencher)\n\
- Observabilidade e suporte: (preencher)\n\n\
## Plano de adoção\n\
- Passos para consolidar ou disseminar a decisão.\n\
- Mudanças de documentação, configuração, testes, rollout ou comunicação necessárias.\n\n\
## Plano de reversão\n\
- Como desfazer ou substituir a decisão se ela se mostrar inadequada.\n\
- Sinais que indicam necessidade de reversão.\n\n\
## Evidências\n\
- Execução: `docs/{slug}/06-execution.md`\n\
- Testes/CI: (preencher)\n\
- PR/commits/diff: (preencher)\n\
- Review: `docs/{slug}/07-review.md` quando existir\n\n\
## Histórico de revisão\n\
| Data | Autor | Alteração | Motivo |\n\
|---|---|---|---|\n\
| (preencher) | (preencher) | Criação ou atualização da ADR | Pós-execução |\n\n\
## Prompt para agente\n\
Use o subagent `adr` para criar ou atualizar ADRs profissionais a partir da execução concluída. Preserve rastreabilidade, cite evidências, compare alternativas e deixe explícitos impactos, riscos, adoção e reversão.\n"
))
}
fn stage_prompt(stage: &str, input: &str) -> String {
let input = if input.trim().is_empty() {
"<entrada ou artefato anterior>"
} else {
input
};
match stage {
"project-discovery" => format!(
"Use o subagent `project-discovery` para analisar o projeto atual. Foco: {input}."
),
"idea" => format!("Use o subagent `idea` para transformar esta ideia em visao de produto: {input}."),
"prd" => format!("Use o subagent `prd` para criar um PRD completo a partir de: {input}."),
"techspec" => {
format!("Use o subagent `techspec` e ancore a arquitetura no codebase real. Entrada: {input}.")
}
"tasks" => format!("Use o subagent `tasks` para decompor a Tech Spec em backlog atomico. Entrada: {input}."),
"refinement" => format!("Use o subagent `refinement` para condensar o backlog. Entrada: {input}."),
"execution" => format!("Use o subagent `execution` para implementar uma tarefa isolada. Tarefa: {input}."),
"adr" => format!("Use o subagent `adr` para criar ou atualizar ADRs profissionais a partir da execução concluida. Entrada: {input}."),
"review" => format!("Use o subagent `review` para revisar diff/PR/tarefa. Alvo: {input}."),
"memory" => format!("Use o subagent `memory` para consolidar decisões e contexto final. Escopo: {input}."),
_ => input.to_string(),
}
}
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
enum ClientTarget {
Cli,
Codex,
Claude,
Cursor,
Opencode,
Devin,
Antigravity,
Trae,
}
impl ClientTarget {
fn all() -> BTreeSet<Self> {
[
Self::Cli,
Self::Codex,
Self::Claude,
Self::Cursor,
Self::Opencode,
Self::Devin,
Self::Antigravity,
Self::Trae,
]
.into_iter()
.collect()
}
fn as_str(self) -> &'static str {
match self {
Self::Cli => "cli",
Self::Codex => "codex",
Self::Claude => "claude-code",
Self::Cursor => "cursor",
Self::Opencode => "opencode",
Self::Devin => "devin",
Self::Antigravity => "antigravity",
Self::Trae => "trae",
}
}
fn from_str(value: &str) -> Result<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"cli" => Ok(Self::Cli),
"codex" | "openai-codex" => Ok(Self::Codex),
"claude" | "claude-code" | "claudecode" => Ok(Self::Claude),
"cursor" => Ok(Self::Cursor),
"opencode" | "open-code" => Ok(Self::Opencode),
"devin" | "devin-desktop" | "devin-cli" => Ok(Self::Devin),
"antigravity" | "google-antigravity" => Ok(Self::Antigravity),
"trae" | "trae-ide" | "trae-solo" => Ok(Self::Trae),
other => bail!("unknown client target: {other}"),
}
}
fn required_paths(self) -> &'static [&'static str] {
match self {
Self::Cli => &["sdd.config.yaml"],
Self::Codex => &[
"AGENTS.md",
".mcp.json",
".codex/config.toml",
".codex/agents",
".agents/skills/orchestration/SKILL.md",
],
Self::Claude => &[
"CLAUDE.md",
".claude/CLAUDE.md",
".claude/settings.json",
".claude/agents",
".claude/commands/orchestration.md",
".claude/skills/orchestration/SKILL.md",
],
Self::Cursor => &[
"AGENTS.md",
".cursor/model-routing.yaml",
".cursor/mcp.json",
".cursor/rules/codegraph.mdc",
".cursor/rules/sdd.mdc",
".cursor/commands/sdd.md",
".cursor/commands/orchestration.md",
".cursor/commands/discover.md",
".cursor/commands/risk.md",
".cursor/commands/idea.md",
".cursor/commands/prd.md",
".cursor/commands/techspec.md",
".cursor/commands/tasks.md",
".cursor/commands/refinement.md",
".cursor/commands/execution.md",
".cursor/commands/adr.md",
".cursor/commands/review.md",
".cursor/commands/memory.md",
".cursor/agents/sdd-orchestrator.md",
".cursor/skills/orchestration/SKILL.md",
],
Self::Opencode => &[
"AGENTS.md",
".opencode/commands/sdd.md",
".opencode/commands/orchestration.md",
".opencode/commands/discover.md",
".opencode/commands/risk.md",
".opencode/commands/idea.md",
".opencode/commands/prd.md",
".opencode/commands/techspec.md",
".opencode/commands/tasks.md",
".opencode/commands/refinement.md",
".opencode/commands/execution.md",
".opencode/commands/adr.md",
".opencode/commands/review.md",
".opencode/commands/memory.md",
".opencode/agents/sdd-orchestrator.md",
],
Self::Devin => &[
"AGENTS.md",
".devin/config.json",
".devin/rules/sdd.md",
".devin/agents/sdd-orchestrator/AGENT.md",
".devin/skills/orchestration/SKILL.md",
".devin/skills/discover/SKILL.md",
".devin/skills/risk/SKILL.md",
".devin/skills/idea/SKILL.md",
".devin/skills/prd/SKILL.md",
".devin/skills/techspec/SKILL.md",
".devin/skills/tasks/SKILL.md",
".devin/skills/refinement/SKILL.md",
".devin/skills/execution/SKILL.md",
".devin/skills/adr/SKILL.md",
".devin/skills/review/SKILL.md",
".devin/skills/memory/SKILL.md",
".devin/workflows/sdd.md",
".devin/workflows/orchestration.md",
".devin/workflows/discover.md",
".devin/workflows/risk.md",
".devin/workflows/idea.md",
".devin/workflows/prd.md",
".devin/workflows/techspec.md",
".devin/workflows/tasks.md",
".devin/workflows/refinement.md",
".devin/workflows/execution.md",
".devin/workflows/adr.md",
".devin/workflows/review.md",
".devin/workflows/memory.md",
".agents/skills/orchestration/SKILL.md",
],
Self::Antigravity => &[
"AGENTS.md",
".agents/rules/sdd.md",
".agents/skills/orchestration/SKILL.md",
],
Self::Trae => &[
"AGENTS.md",
".trae/commands/orchestration.md",
".trae/commands/discover.md",
".trae/commands/risk.md",
".trae/commands/idea.md",
".trae/commands/prd.md",
".trae/commands/techspec.md",
".trae/commands/tasks.md",
".trae/commands/refinement.md",
".trae/commands/execution.md",
".trae/commands/adr.md",
".trae/commands/review.md",
".trae/commands/memory.md",
".trae/rules/00-sdd-principles.md",
".trae/rules/20-output-conventions.md",
".trae/skills/orchestration/SKILL.md",
],
}
}
}
struct GeneratedFile {
path: &'static str,
content: String,
}
#[derive(Debug, Deserialize, Serialize)]
struct CapabilityCatalog {
version: u32,
capabilities: Vec<CapabilitySpec>,
}
#[derive(Debug, Deserialize, Serialize)]
struct CapabilitySpec {
id: String,
category: CapabilityCategory,
title: String,
stages: Vec<String>,
trigger: String,
canonical_source: String,
adapters: BTreeMap<String, CapabilityAdapter>,
fallback: String,
validation: Vec<String>,
docs: Vec<String>,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(rename_all = "kebab-case")]
enum CapabilityCategory {
Skill,
Command,
Agent,
Hook,
Mcp,
Automation,
Routing,
Permission,
Context,
}
#[derive(Debug, Deserialize, Serialize)]
struct CapabilityAdapter {
path: Option<String>,
kind: Option<String>,
command: Option<String>,
}
#[derive(Default, Serialize)]
struct CapabilityDoctorReport {
status: &'static str,
checked: BTreeMap<String, Vec<String>>,
missing: BTreeMap<String, Vec<String>>,
invalid: BTreeMap<String, Vec<String>>,
drift: BTreeMap<String, Vec<String>>,
}
struct ClientDoctorReport {
status: &'static str,
missing: BTreeMap<String, Vec<String>>,
invalid: BTreeMap<String, Vec<String>>,
covered: BTreeMap<String, Vec<String>>,
}
fn parse_client_targets(value: &str) -> Result<BTreeSet<ClientTarget>> {
let mut targets = BTreeSet::new();
for raw in value.split(',') {
let item = raw.trim();
if item.is_empty() {
continue;
}
if item.eq_ignore_ascii_case("all") {
targets.extend(ClientTarget::all());
} else {
targets.insert(ClientTarget::from_str(item)?);
}
}
if targets.is_empty() {
targets = ClientTarget::all();
}
Ok(targets)
}
fn run_clients(args: ClientsArgs) -> Result<()> {
match args.command {
ClientsCommand::List => {
for target in ClientTarget::all() {
println!("{}", target.as_str());
}
}
ClientsCommand::Doctor(args) => {
let root = resolve_project_root(args.root)?;
let report = client_doctor(&root);
let capability_report = if args.strict {
let targets = ClientTarget::all();
Some(capability_doctor(&root, &targets)?)
} else {
None
};
if args.json {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"status": report.status,
"missing": report.missing,
"invalid": report.invalid,
"covered": report.covered,
"capabilities": capability_report,
}))?
);
} else {
print_client_doctor(&report);
if let Some(capability_report) = &capability_report {
print_capability_doctor(capability_report);
}
}
if report.status != "pass"
|| capability_report
.as_ref()
.is_some_and(|report| report.status != "pass")
{
bail!("client doctor failed");
}
}
ClientsCommand::Sync(args) => {
let targets = parse_client_targets(&args.targets)?;
sync_client_files(&args.root, &targets, args.force, args.dry_run)?;
}
}
Ok(())
}
fn run_capabilities(args: CapabilitiesArgs) -> Result<()> {
match args.command {
CapabilitiesCommand::List(args) => {
let root = runtime::platform::canonicalize(&args.root).unwrap_or(args.root);
let catalog = load_capability_catalog(&root)?;
if args.json {
println!("{}", serde_json::to_string_pretty(&catalog)?);
} else {
println!("SDD capabilities");
println!("version: {}", catalog.version);
for capability in catalog.capabilities {
let adapters = capability
.adapters
.keys()
.cloned()
.collect::<Vec<_>>()
.join(",");
println!(
"- {} [{}] stages={} adapters={}",
capability.id,
capability.category.as_str(),
capability.stages.join(","),
adapters
);
}
}
}
CapabilitiesCommand::Doctor(args) => {
let root = runtime::platform::canonicalize(&args.root).unwrap_or(args.root);
let targets = parse_client_targets(&args.targets)?;
let report = capability_doctor(&root, &targets)?;
if args.json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print_capability_doctor(&report);
}
if report.status != "pass" {
bail!("capabilities doctor failed");
}
}
CapabilitiesCommand::Sync(args) => {
let root = runtime::platform::canonicalize(&args.root).unwrap_or(args.root);
let targets = parse_client_targets(&args.targets)?;
sync_capability_files(&root, &targets, args.force, args.dry_run)?;
}
CapabilitiesCommand::Recommend(args) => {
let root = runtime::platform::canonicalize(&args.root).unwrap_or(args.root);
let content = render_capability_recommendations(&root, args.name.as_deref())?;
if args.write {
let target = if let Some(name) = &args.name {
artifact_dir(&root, name).join("00-capability-recommendations.md")
} else {
layer_root(&root).join("capability-recommendations.md")
};
if args.dry_run {
println!("dry-run write {}", target.display());
} else {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&target, content)?;
println!("{}", target.display());
}
} else {
print!("{content}");
}
}
}
Ok(())
}
fn run_mcp(args: McpArgs) -> Result<()> {
match args.command {
McpCommand::Serve(args) => {
let root =
runtime::platform::canonicalize(&args.root).unwrap_or_else(|_| args.root.clone());
let backend = SddMcpBackend { root };
runtime::mcp::serve_stdio(&backend)?;
}
McpCommand::Config(args) => {
let root =
runtime::platform::canonicalize(&args.root).unwrap_or_else(|_| args.root.clone());
let targets = parse_client_targets(&args.targets)?;
sync_mcp_config_files(&root, &targets, args.force, args.dry_run)?;
}
}
Ok(())
}
fn run_trace(args: TraceArgs) -> Result<()> {
match args.command {
TraceCommand::List(args) => {
let root =
runtime::platform::canonicalize(&args.root).unwrap_or_else(|_| args.root.clone());
let mut events = runtime::trace::read_events(&root)?;
if let Some(orchestration) = &args.orchestration {
events = runtime::trace::filter_by_orchestration(events, orchestration);
}
if args.json {
println!("{}", serde_json::to_string_pretty(&events)?);
} else {
println!("SDD traces");
for event in events {
println!(
"{}\t{}\t{}\t{}\t{}",
event.ts,
event.run_id,
event.kind,
event.status.unwrap_or_else(|| "-".to_string()),
event.target.unwrap_or_else(|| "-".to_string())
);
}
}
}
TraceCommand::Show(args) => {
let root =
runtime::platform::canonicalize(&args.root).unwrap_or_else(|_| args.root.clone());
let tree = runtime::trace::trace_tree(&root, &args.run_id)?;
if args.json {
println!("{}", serde_json::to_string_pretty(&tree)?);
} else {
print_trace_tree(&tree, 0);
}
}
TraceCommand::Summary(args) => {
let root =
runtime::platform::canonicalize(&args.root).unwrap_or_else(|_| args.root.clone());
let mut events = runtime::trace::read_events(&root)?;
if let Some(orchestration) = &args.orchestration {
events = runtime::trace::filter_by_orchestration(events, orchestration);
}
let summary = runtime::trace::summary(&events);
if args.json {
println!("{}", serde_json::to_string_pretty(&summary)?);
} else {
print_trace_summary(&summary);
}
}
TraceCommand::Doctor(args) => {
let root =
runtime::platform::canonicalize(&args.root).unwrap_or_else(|_| args.root.clone());
let report = runtime::trace::doctor(&root)?;
if args.json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
println!("SDD trace doctor");
println!("{}", report.status.to_uppercase());
for (file, count) in &report.files {
println!("- {file}: {count} events");
}
for warning in &report.warnings {
println!("warning: {warning}");
}
}
if report.status != "pass" {
bail!("trace doctor failed");
}
}
}
Ok(())
}
struct SddMcpBackend {
root: PathBuf,
}
impl runtime::mcp::McpBackend for SddMcpBackend {
fn call_tool(&self, name: &str, arguments: &Value) -> Result<Value> {
match name {
"sdd_trace_list" => {
let mut events = runtime::trace::read_events(&self.root)?;
if let Some(orchestration) = string_arg(arguments, "orchestration") {
events = runtime::trace::filter_by_orchestration(events, &orchestration);
}
Ok(json!({ "events": events }))
}
"sdd_trace_show" => {
let run_id = required_string_arg(arguments, "run_id")?;
Ok(serde_json::to_value(runtime::trace::trace_tree(
&self.root, &run_id,
)?)?)
}
"sdd_trace_summary" => {
let mut events = runtime::trace::read_events(&self.root)?;
if let Some(orchestration) = string_arg(arguments, "orchestration") {
events = runtime::trace::filter_by_orchestration(events, &orchestration);
}
Ok(serde_json::to_value(runtime::trace::summary(&events))?)
}
"sdd_artifact_status" => {
let orchestration = string_arg(arguments, "orchestration")
.unwrap_or_else(|| "sdd-orchestration".to_string());
artifact_status_value(&self.root, &orchestration)
}
"sdd_context_build" => {
let orchestration = required_string_arg(arguments, "orchestration")?;
let stage = required_string_arg(arguments, "stage")?;
let task = string_arg(arguments, "task");
let pack =
build_context_pack(&self.root, &orchestration, &stage, task.as_deref(), false)?;
Ok(json!({
"content": pack.content,
"path": pack.path,
"sources_included": pack.sources_included,
"conflicts": pack.conflicts
}))
}
"sdd_clients_doctor" => {
let report = client_doctor(&self.root);
Ok(json!({
"status": report.status,
"missing": report.missing,
"invalid": report.invalid,
"covered": report.covered,
}))
}
other => bail!("unknown SDD MCP tool `{other}`"),
}
}
fn list_resources(&self) -> Result<Vec<Value>> {
let mut resources = Vec::new();
let docs = self.root.join("docs");
if docs.exists() {
for entry in WalkDir::new(&docs)
.max_depth(2)
.into_iter()
.filter_map(|entry| entry.ok())
.filter(|entry| entry.file_type().is_file())
{
let path = entry.path();
let Some(parent) = path
.parent()
.and_then(|path| path.file_name())
.and_then(OsStr::to_str)
else {
continue;
};
let Some(stage) = path
.file_stem()
.and_then(OsStr::to_str)
.and_then(contract::stage_by_filename_stem)
else {
continue;
};
resources.push(json!({
"uri": format!("sdd://artifact/{parent}/{}", stage.key),
"name": format!("{parent}/{}", stage.filename),
"title": stage.label,
"description": "Local SDD artifact",
"mimeType": "text/markdown"
}));
}
}
for run_id in runtime::trace::summary(&runtime::trace::read_events(&self.root)?).roots {
resources.push(json!({
"uri": format!("sdd://trace/{run_id}"),
"name": run_id,
"title": "SDD trace tree",
"mimeType": "application/json"
}));
}
Ok(resources)
}
fn read_resource(&self, uri: &str) -> Result<runtime::mcp::McpResource> {
let segments = runtime::trace::validate_sdd_uri(uri)?;
match segments.as_slice() {
[kind, orchestration, artifact] if kind == "artifact" => {
let stage = contract::stage_by_key(artifact)
.or_else(|| contract::stage_by_command(artifact))
.ok_or_else(|| anyhow!("unknown artifact `{artifact}`"))?;
let path = artifact_dir(&self.root, orchestration).join(&stage.filename);
let text = fs::read_to_string(&path)
.with_context(|| format!("reading {}", path.display()))?;
Ok(runtime::mcp::McpResource {
uri: uri.to_string(),
mime_type: "text/markdown".to_string(),
text,
})
}
[kind, run_id] if kind == "trace" => {
let tree = runtime::trace::trace_tree(&self.root, run_id)?;
Ok(runtime::mcp::McpResource {
uri: uri.to_string(),
mime_type: "application/json".to_string(),
text: serde_json::to_string_pretty(&tree)?,
})
}
[kind, orchestration, stage] if kind == "context" => {
let pack = build_context_pack(&self.root, orchestration, stage, None, false)?;
Ok(runtime::mcp::McpResource {
uri: uri.to_string(),
mime_type: "text/markdown".to_string(),
text: pack.content,
})
}
_ => bail!("unsupported SDD resource URI `{uri}`"),
}
}
fn get_prompt(&self, name: &str, arguments: &Value) -> Result<Value> {
let text = match name {
"sdd_orchestration" => {
let idea = required_string_arg(arguments, "idea")?;
format!(
"Conduza a orquestração SDD para a ideia abaixo. Use `sdd orchestration`, preserve checkpoints humanos, salve artefatos em docs/<slug>/ e consulte `sdd_trace_summary` quando precisar de observabilidade.\n\nIdeia:\n{idea}"
)
}
"sdd_stage_handoff" => {
let orchestration = required_string_arg(arguments, "orchestration")?;
let stage = required_string_arg(arguments, "stage")?;
format!(
"Prepare o handoff da etapa `{stage}` da orquestração `{orchestration}`. Inclua origem, estado, evidências, riscos, próximo artefato e comandos `sdd trace` úteis."
)
}
"sdd_trace_review" => {
let run_id = required_string_arg(arguments, "run_id")?;
format!(
"Revise a árvore de trace `{run_id}`. Procure falhas, falta de evidência, eventos sem parent_run_id quando deveriam ser filhos e riscos de segredo/transcript bruto."
)
}
other => bail!("unknown SDD prompt `{other}`"),
};
Ok(json!({
"description": name,
"messages": [{ "role": "user", "content": { "type": "text", "text": text } }]
}))
}
}
fn string_arg(arguments: &Value, key: &str) -> Option<String> {
arguments
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn required_string_arg(arguments: &Value, key: &str) -> Result<String> {
string_arg(arguments, key).ok_or_else(|| anyhow!("missing required argument `{key}`"))
}
fn artifact_status_value(root: &Path, name: &str) -> Result<Value> {
let dest = artifact_dir(root, name);
let artifacts: BTreeMap<_, _> = contract::stages()
.iter()
.map(|stage| {
let exists = dest.join(&stage.filename).exists();
(
stage.key.as_str(),
json!({ "file": stage.filename, "exists": exists }),
)
})
.collect();
Ok(json!({
"path": dest,
"exists": dest.exists(),
"artifacts": artifacts,
}))
}
fn print_trace_tree(tree: &runtime::trace::TraceTree, depth: usize) {
let indent = " ".repeat(depth);
println!("{}{}", indent, tree.run_id);
for event in &tree.events {
println!(
"{}- {} {} {}",
indent,
event.ts,
event.kind,
event.status.as_deref().unwrap_or("-")
);
}
for child in &tree.children {
print_trace_tree(child, depth + 1);
}
}
fn print_trace_summary(summary: &runtime::trace::TraceSummary) {
println!("SDD trace summary");
println!("events: {}", summary.total_events);
println!("roots: {}", summary.root_count);
if !summary.roots.is_empty() {
println!("root_sample: {}", summary.roots.join(", "));
}
println!("latest: {}", summary.latest_ts.as_deref().unwrap_or("-"));
if !summary.by_kind.is_empty() {
println!("by_kind:");
for (kind, count) in &summary.by_kind {
println!("- {kind}: {count}");
}
}
if !summary.by_status.is_empty() {
println!("by_status:");
for (status, count) in &summary.by_status {
println!("- {status}: {count}");
}
}
}
fn run_context(args: ContextArgs) -> Result<()> {
match args.command {
ContextCommand::Build(args) => {
let root =
runtime::platform::canonicalize(&args.root).unwrap_or_else(|_| args.root.clone());
let stage = validate_context_stage(&args.stage)?;
let pack = build_context_pack(
&root,
&args.name,
stage,
args.task.as_deref(),
args.write && !args.dry_run,
)?;
if args.write {
if args.dry_run {
println!("dry-run write {}", pack.path.display());
} else {
if let Some(parent) = pack.path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&pack.path, pack.content.as_bytes())?;
}
if args.json {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"path": pack.path,
"sources_included": pack.sources_included,
"conflicts": pack.conflicts,
}))?
);
} else {
println!("{}", pack.path.display());
}
} else if args.json {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"content": pack.content,
"path": pack.path,
"sources_included": pack.sources_included,
"conflicts": pack.conflicts,
}))?
);
} else {
print!("{}", pack.content);
}
}
ContextCommand::Recommend(args) => {
let root =
runtime::platform::canonicalize(&args.root).unwrap_or_else(|_| args.root.clone());
let content = render_context_recommendations(&root, args.name.as_deref());
if args.write {
let target = if let Some(name) = &args.name {
artifact_dir(&root, name).join("00-context-recommendations.md")
} else {
layer_root(&root).join("context-recommendations.md")
};
if args.dry_run {
println!("dry-run write {}", target.display());
} else {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&target, content)?;
println!("{}", target.display());
}
} else {
print!("{content}");
}
}
}
Ok(())
}
impl CapabilityCategory {
fn as_str(self) -> &'static str {
match self {
Self::Skill => "skill",
Self::Command => "command",
Self::Agent => "agent",
Self::Hook => "hook",
Self::Mcp => "mcp",
Self::Automation => "automation",
Self::Routing => "routing",
Self::Permission => "permission",
Self::Context => "context",
}
}
}
fn load_capability_catalog(root: &Path) -> Result<CapabilityCatalog> {
let rel = "templates/capability-catalog.yaml";
let local = capability_path(root, rel);
let bytes = if local.exists() {
fs::read(&local).with_context(|| format!("reading {}", local.display()))?
} else {
LayerSource::detect_for_target(root).read_file(rel, false)?
};
let catalog: CapabilityCatalog = serde_yaml::from_slice(&bytes)
.with_context(|| format!("parsing {}", display_relative(root, &local)))?;
validate_capability_catalog_shape(&catalog)?;
Ok(catalog)
}
fn validate_capability_catalog_shape(catalog: &CapabilityCatalog) -> Result<()> {
if catalog.version != 2 {
bail!(
"capability-catalog version must be 2, found {}",
catalog.version
);
}
let mut ids = BTreeSet::new();
for capability in &catalog.capabilities {
if capability.id.trim().is_empty() {
bail!("capability id cannot be empty");
}
if !ids.insert(capability.id.as_str()) {
bail!("duplicated capability id: {}", capability.id);
}
if capability.canonical_source.trim().is_empty() {
bail!("capability {} missing canonical_source", capability.id);
}
if capability.adapters.is_empty() {
bail!(
"capability {} must declare at least one adapter",
capability.id
);
}
}
Ok(())
}
fn capability_path(root: &Path, rel: &str) -> PathBuf {
let rel_path = Path::new(rel);
if rel.starts_with("templates/")
|| rel.starts_with("schemas/")
|| rel.starts_with("clients/")
|| rel.starts_with("presets/")
|| rel.starts_with("adapters/")
|| rel == "VERSION"
|| rel == "CHANGELOG.md"
|| rel == "sdd.config.example.yaml"
{
layer_root(root).join(rel_path)
} else {
root.join(rel_path)
}
}
fn capability_source_is_root_item(rel: &str) -> bool {
ROOT_ITEMS.iter().any(|item| {
rel == *item
|| rel
.strip_prefix(item)
.is_some_and(|suffix| suffix.starts_with('/'))
}) || rel.starts_with(".github/")
}
fn capability_adapter_targets<'a>(
capability: &'a CapabilitySpec,
targets: &BTreeSet<ClientTarget>,
) -> Vec<(ClientTarget, &'a CapabilityAdapter)> {
targets
.iter()
.filter_map(|target| {
capability
.adapters
.get(target.as_str())
.map(|adapter| (*target, adapter))
})
.collect()
}
fn capability_doctor(
root: &Path,
targets: &BTreeSet<ClientTarget>,
) -> Result<CapabilityDoctorReport> {
let catalog = load_capability_catalog(root)?;
let mut report = CapabilityDoctorReport::default();
for capability in &catalog.capabilities {
let canonical_path = capability_path(root, &capability.canonical_source);
if !canonical_path.exists()
&& LayerSource::detect_for_target(root)
.read_file(
&capability.canonical_source,
capability_source_is_root_item(&capability.canonical_source),
)
.is_err()
{
report
.invalid
.entry(capability.id.clone())
.or_default()
.push(format!(
"missing canonical_source {}",
capability.canonical_source
));
}
let adapters = capability_adapter_targets(capability, targets);
if adapters.is_empty() {
continue;
}
for (target, adapter) in adapters {
let adapter_label =
format!("{}:{}", target.as_str(), capability_adapter_label(adapter));
report
.checked
.entry(capability.id.clone())
.or_default()
.push(adapter_label);
let Some(rel) = adapter.path.as_deref() else {
if adapter
.command
.as_deref()
.unwrap_or_default()
.trim()
.is_empty()
{
report
.invalid
.entry(capability.id.clone())
.or_default()
.push(format!(
"{} adapter has no path or command",
target.as_str()
));
}
continue;
};
let path = capability_path(root, rel);
let content = match fs::read_to_string(&path) {
Ok(content) => content,
Err(_) => {
report
.missing
.entry(capability.id.clone())
.or_default()
.push(format!("{} missing {}", target.as_str(), rel));
continue;
}
};
for issue in validate_capability_adapter(capability, target, adapter, rel, &content) {
report
.invalid
.entry(capability.id.clone())
.or_default()
.push(issue);
}
if let Some(expected) = generated_capability_content(root, target, rel)? {
if expected != content {
report
.drift
.entry(capability.id.clone())
.or_default()
.push(format!(
"{} drifted from Rust generator: {}",
target.as_str(),
rel
));
}
}
}
}
report.status =
if report.missing.is_empty() && report.invalid.is_empty() && report.drift.is_empty() {
"pass"
} else {
"fail"
};
Ok(report)
}
fn capability_adapter_label(adapter: &CapabilityAdapter) -> String {
adapter
.path
.as_deref()
.or(adapter.command.as_deref())
.unwrap_or("<adapter>")
.to_string()
}
fn generated_capability_content(
root: &Path,
target: ClientTarget,
rel: &str,
) -> Result<Option<String>> {
for file in generated_files_for(target) {
if file.path == rel {
return Ok(Some(file.content));
}
}
let path = capability_path(root, rel);
let source_path = capability_path(root, rel);
if path == source_path {
return Ok(None);
}
Ok(None)
}
fn validate_capability_adapter(
capability: &CapabilitySpec,
target: ClientTarget,
adapter: &CapabilityAdapter,
rel: &str,
content: &str,
) -> Vec<String> {
let mut issues = Vec::new();
let lower = content.to_lowercase();
for validation in &capability.validation {
match validation.as_str() {
"adapter-command" => {
if adapter
.command
.as_deref()
.unwrap_or_default()
.trim()
.is_empty()
{
issues.push(format!("{} missing adapter command", target.as_str()));
}
}
"canonical-command" => {
if !content.contains("sdd orchestration") {
issues.push(format!(
"{} must delegate to canonical `sdd orchestration`: {}",
target.as_str(),
rel
));
}
}
"legacy-alias" => {
if !lower.contains("orchestrator") || !lower.contains("alias") {
issues.push(format!(
"{} must document `orchestrator` as legacy alias: {}",
target.as_str(),
rel
));
}
}
"checkpoints" => {
if !lower.contains("checkpoint") {
issues.push(format!(
"{} missing checkpoint contract: {}",
target.as_str(),
rel
));
}
}
"artifact-store" => {
if !content.contains("sdd init")
&& !content.contains("docs/<slug")
&& !content.contains("sdd artifact save")
{
issues.push(format!(
"{} missing artifact-store contract: {}",
target.as_str(),
rel
));
}
}
"hooks" => {
if !lower.contains("hook") && !lower.contains("\"hooks\"") {
issues.push(format!(
"{} missing hooks contract: {}",
target.as_str(),
rel
));
}
}
"provider-fallback" => {
let has_provider = lower.contains("provider");
let has_fallback = lower.contains("fallback")
|| lower.contains("deslogado")
|| lower.contains("login_command")
|| lower.contains("sdd providers doctor")
|| lower.contains("provider-neutral");
if !has_provider || !has_fallback {
issues.push(format!(
"{} missing provider fallback guidance: {}",
target.as_str(),
rel
));
}
}
"preserve-pt-br" => {
if !content.chars().any(|ch| ch as u32 > 0x7f) {
issues.push(format!(
"{} must preserve PT-BR UTF-8 accents: {}",
target.as_str(),
rel
));
}
}
"skill-frontmatter" => {
if !content.trim_start().starts_with("---")
|| !content.contains("\nname:")
|| !content.contains("\ndescription:")
{
issues.push(format!(
"{} missing SKILL.md frontmatter: {}",
target.as_str(),
rel
));
}
}
"delegates-to-cli" => {
if !content.contains("sdd ") {
issues.push(format!(
"{} does not delegate to `sdd`: {}",
target.as_str(),
rel
));
}
}
"mcp-server" => {
if !content.contains("mcpServers")
&& !content.contains("mcp")
&& !content.contains("MCP")
{
issues.push(format!(
"{} missing MCP server guidance: {}",
target.as_str(),
rel
));
}
}
"read-only" => {
let text = content.to_lowercase();
if !text.contains("read-only") && !text.contains("somente leitura") {
issues.push(format!(
"{} missing read-only MCP/trace guidance: {}",
target.as_str(),
rel
));
}
}
other => issues.push(format!(
"{} declares unknown capability validation `{other}`",
capability.id
)),
}
}
issues
}
fn print_capability_doctor(report: &CapabilityDoctorReport) {
println!("SDD capabilities doctor");
println!("{}", report.status.to_uppercase());
if !report.checked.is_empty() {
println!("Checked");
for (capability, adapters) in &report.checked {
println!("- {capability}: {}", adapters.join(", "));
}
}
if !report.missing.is_empty() {
println!("Missing");
for (capability, issues) in &report.missing {
println!("- {capability}: {}", issues.join("; "));
}
}
if !report.invalid.is_empty() {
println!("Invalid");
for (capability, issues) in &report.invalid {
println!("- {capability}: {}", issues.join("; "));
}
}
if !report.drift.is_empty() {
println!("Drift");
for (capability, issues) in &report.drift {
println!("- {capability}: {}", issues.join("; "));
}
}
}
fn sync_capability_files(
root: &Path,
targets: &BTreeSet<ClientTarget>,
force: bool,
dry_run: bool,
) -> Result<()> {
let catalog = load_capability_catalog(root)?;
let mut files = BTreeMap::<String, String>::new();
for target in targets {
for file in generated_files_for(*target) {
files.insert(file.path.to_string(), file.content);
}
}
let source = LayerSource::detect_for_target(root);
for capability in &catalog.capabilities {
for (_, adapter) in capability_adapter_targets(capability, targets) {
let Some(rel) = adapter.path.as_deref() else {
continue;
};
if files.contains_key(rel) {
continue;
}
let content = if let Ok(text) = fs::read_to_string(capability_path(root, rel)) {
text
} else if let Ok(bytes) = source.read_file(rel, capability_source_is_root_item(rel)) {
String::from_utf8(bytes).with_context(|| format!("reading UTF-8 {rel}"))?
} else if let Ok(text) =
fs::read_to_string(capability_path(root, &capability.canonical_source))
{
text
} else {
let bytes = source.read_file(
&capability.canonical_source,
capability_source_is_root_item(&capability.canonical_source),
)?;
String::from_utf8(bytes)
.with_context(|| format!("reading UTF-8 {}", capability.canonical_source))?
};
files.insert(rel.to_string(), content);
}
}
println!("SDD capabilities sync");
if dry_run {
println!("DRY RUN: no files were written.");
}
for (rel, content) in files {
let target = capability_path(root, &rel);
if target.exists() && !force {
println!("- skip existing {}", target.display());
continue;
}
if dry_run {
println!("- dry-run write {}", target.display());
continue;
}
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&target, content)?;
println!("- write {}", target.display());
}
if targets.contains(&ClientTarget::Opencode) {
for action in ensure_opencode_prompt_files(root, dry_run)? {
println!("- {action}");
}
}
Ok(())
}
fn render_capability_recommendations(root: &Path, name: Option<&str>) -> Result<String> {
let catalog = load_capability_catalog(root)?;
let mut out = String::new();
out.push_str("# Capability Recommendations\n\n");
if let Some(name) = name {
out.push_str(&format!("- Orquestração: {name}\n"));
}
out.push_str("- Fonte canônica: `templates/capability-catalog.yaml` v2\n");
out.push_str("- Núcleo portável: `.agents/skills/<id>/SKILL.md`\n");
out.push_str("- Harness determinístico: `sdd`\n\n");
out.push_str("## Capability catalog\n\n");
for capability in &catalog.capabilities {
out.push_str(&format!(
"- `{}` [{}]: {}. Fallback: {}\n",
capability.id,
capability.category.as_str(),
capability.trigger,
capability.fallback
));
}
out.push_str("\n## Resource adoption map\n\n");
out.push_str(resource_adoption_map());
out.push_str("\n## Recomendações do projeto\n\n");
out.push_str(&render_context_recommendations(root, name));
Ok(out)
}
fn resource_adoption_map() -> &'static str {
r#"- Codex: `.agents/skills`, `.codex/config.toml`, plugins, hooks, MCP, automations e worktrees.
- Claude Code: skills, commands compatíveis, subagents, hooks, MCP e settings.
- OpenCode: commands, agents/subagents, skills compatíveis com `.agents`, MCP, plugins e permissions.
- Cursor: rules, skills, subagents, MCP, hooks, CLI/cloud agent.
- Devin: `AGENTS.md`, Knowledge, Playbooks, API/schedules e workflows.
- Trae/Antigravity: rules, skills e commands como adapters portáveis.
"#
}
fn client_doctor(root: &Path) -> ClientDoctorReport {
let mut missing = BTreeMap::new();
let mut invalid = BTreeMap::new();
let mut covered = BTreeMap::new();
for target in ClientTarget::all() {
let missing_paths: Vec<String> = target
.required_paths()
.iter()
.filter(|path| !root.join(path).exists())
.map(|path| (*path).to_string())
.collect();
if missing_paths.is_empty() {
covered.insert(
target.as_str().to_string(),
target
.required_paths()
.iter()
.map(|path| (*path).to_string())
.collect(),
);
} else {
missing.insert(target.as_str().to_string(), missing_paths);
}
let invalid_refs = client_invalid_references(root, target);
if !invalid_refs.is_empty() {
invalid.insert(target.as_str().to_string(), invalid_refs);
}
}
let status = if missing.is_empty() && invalid.is_empty() {
"pass"
} else {
"fail"
};
ClientDoctorReport {
status,
missing,
invalid,
covered,
}
}
fn is_layer_source_repo(root: &Path) -> bool {
root.join("src/main.rs").is_file() && root.join("plugins/orchestration").is_dir()
}
fn quoted_value(line: &str) -> Option<String> {
let (_, raw) = line.split_once('=')?;
let trimmed = raw.trim();
let stripped = trimmed.strip_prefix('"')?.strip_suffix('"')?;
Some(stripped.to_string())
}
fn cargo_toml_package_version(text: &str) -> Option<String> {
let mut in_package = false;
for line in text.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') {
in_package = trimmed == "[package]";
continue;
}
if in_package && trimmed.starts_with("version") {
return quoted_value(trimmed);
}
}
None
}
fn cargo_lock_package_version(text: &str, package: &str) -> Option<String> {
let mut in_package = false;
let mut current_name: Option<String> = None;
for line in text.lines() {
let trimmed = line.trim();
if trimmed == "[[package]]" {
in_package = true;
current_name = None;
continue;
}
if !in_package {
continue;
}
if trimmed.starts_with("name") {
current_name = quoted_value(trimmed);
continue;
}
if trimmed.starts_with("version") && current_name.as_deref() == Some(package) {
return quoted_value(trimmed);
}
}
None
}
fn version_alignment_issues(root: &Path) -> Vec<String> {
let mut issues = Vec::new();
let version_path = root.join("VERSION");
let cargo_toml_path = root.join("Cargo.toml");
let cargo_lock_path = root.join("Cargo.lock");
if !version_path.exists() && !cargo_toml_path.exists() && !cargo_lock_path.exists() {
return issues;
}
for path in [&version_path, &cargo_toml_path, &cargo_lock_path] {
if !path.exists() {
issues.push(format!("missing {}", display_relative(root, path)));
}
}
if !issues.is_empty() {
return issues;
}
let version_file = match fs::read_to_string(&version_path) {
Ok(text) => text.trim().to_string(),
Err(err) => {
issues.push(format!(
"failed reading {}: {err}",
display_relative(root, &version_path)
));
return issues;
}
};
let cargo_toml = match fs::read_to_string(&cargo_toml_path) {
Ok(text) => text,
Err(err) => {
issues.push(format!(
"failed reading {}: {err}",
display_relative(root, &cargo_toml_path)
));
return issues;
}
};
let cargo_lock = match fs::read_to_string(&cargo_lock_path) {
Ok(text) => text,
Err(err) => {
issues.push(format!(
"failed reading {}: {err}",
display_relative(root, &cargo_lock_path)
));
return issues;
}
};
let Some(cargo_toml_version) = cargo_toml_package_version(&cargo_toml) else {
issues.push("Cargo.toml missing [package].version".to_string());
return issues;
};
let package_name = root
.file_name()
.and_then(OsStr::to_str)
.unwrap_or("sdd-layer");
let Some(cargo_lock_version) = cargo_lock_package_version(&cargo_lock, package_name) else {
issues.push(format!(
"Cargo.lock missing package entry for `{package_name}`"
));
return issues;
};
if version_file != cargo_toml_version || version_file != cargo_lock_version {
issues.push(format!(
"version drift: VERSION={version_file}, Cargo.toml={cargo_toml_version}, Cargo.lock={cargo_lock_version}"
));
}
issues
}
fn collect_relative_files(base: &Path) -> Result<Vec<String>> {
if !base.exists() {
return Ok(Vec::new());
}
let mut files = WalkDir::new(base)
.into_iter()
.filter_map(|entry| entry.ok())
.filter(|entry| entry.file_type().is_file())
.map(|entry| {
entry
.path()
.strip_prefix(base)
.map(|path| path.to_string_lossy().replace('\\', "/"))
.map_err(anyhow::Error::from)
})
.collect::<Result<Vec<_>>>()?;
files.sort();
Ok(files)
}
fn directory_mirror_issues(root: &Path, source: &Path, mirror: &Path) -> Vec<String> {
let mut issues = Vec::new();
if !source.exists() {
issues.push(format!(
"missing mirror source {}",
display_relative(root, source)
));
return issues;
}
if !mirror.exists() {
issues.push(format!("missing mirror {}", display_relative(root, mirror)));
return issues;
}
let source_files = match collect_relative_files(source) {
Ok(files) => files,
Err(err) => {
issues.push(format!(
"failed reading {}: {err}",
display_relative(root, source)
));
return issues;
}
};
let mirror_files = match collect_relative_files(mirror) {
Ok(files) => files,
Err(err) => {
issues.push(format!(
"failed reading {}: {err}",
display_relative(root, mirror)
));
return issues;
}
};
let source_set = source_files.iter().cloned().collect::<BTreeSet<_>>();
let mirror_set = mirror_files.iter().cloned().collect::<BTreeSet<_>>();
let missing = source_set
.difference(&mirror_set)
.cloned()
.collect::<Vec<_>>();
if !missing.is_empty() {
issues.push(format!(
"{} missing mirrored files from {}: {}",
display_relative(root, mirror),
display_relative(root, source),
missing.join(", ")
));
}
let extra = mirror_set
.difference(&source_set)
.cloned()
.collect::<Vec<_>>();
if !extra.is_empty() {
issues.push(format!(
"{} has extra mirrored files not present in {}: {}",
display_relative(root, mirror),
display_relative(root, source),
extra.join(", ")
));
}
for rel in source_set.intersection(&mirror_set) {
let Ok(expected) = fs::read_to_string(source.join(rel)) else {
issues.push(format!(
"failed reading {}",
display_relative(root, &source.join(rel))
));
continue;
};
let Ok(actual) = fs::read_to_string(mirror.join(rel)) else {
issues.push(format!(
"failed reading {}",
display_relative(root, &mirror.join(rel))
));
continue;
};
if actual != expected {
issues.push(format!(
"mirror drift: {} differs from {}",
display_relative(root, &mirror.join(rel)),
display_relative(root, &source.join(rel))
));
}
}
issues
}
fn compare_file_to_source(root: &Path, source: &Path, mirror: &Path) -> Vec<String> {
let mut issues = Vec::new();
if !source.exists() {
issues.push(format!("missing source {}", display_relative(root, source)));
return issues;
}
if !mirror.exists() {
issues.push(format!("missing mirror {}", display_relative(root, mirror)));
return issues;
}
let Ok(expected) = fs::read_to_string(source) else {
issues.push(format!("failed reading {}", display_relative(root, source)));
return issues;
};
let Ok(actual) = fs::read_to_string(mirror) else {
issues.push(format!("failed reading {}", display_relative(root, mirror)));
return issues;
};
if actual != expected {
issues.push(format!(
"mirror drift: {} differs from {}",
display_relative(root, mirror),
display_relative(root, source)
));
}
issues
}
fn generated_client_surface_issues(root: &Path, strict_missing: bool) -> Vec<String> {
let mut issues = Vec::new();
for target in ClientTarget::all() {
for file in generated_files_for(target) {
let path = client_file_target(root, file.path);
if !path.exists() {
if strict_missing {
issues.push(format!(
"missing generated client surface {}",
display_relative(root, &path)
));
}
continue;
}
match fs::read_to_string(&path) {
Ok(actual) => {
if actual != file.content {
issues.push(format!(
"client surface drifted from Rust generator: {}",
display_relative(root, &path)
));
}
}
Err(err) => issues.push(format!(
"failed reading {}: {err}",
display_relative(root, &path)
)),
}
}
}
issues
}
fn contract_drift_issues(root: &Path) -> Vec<String> {
let mut issues = Vec::new();
let layer = layer_root(root);
let contract_path = layer.join("schemas/sdd-contract.yaml");
match fs::read_to_string(&contract_path) {
Ok(text) => {
if text != contract::embedded_contract_yaml() {
issues.push(format!(
"{} drifted from embedded Rust contract",
display_relative(root, &contract_path)
));
}
if let Err(err) = contract::parse_contract(&text) {
issues.push(format!(
"invalid {}: {err}",
display_relative(root, &contract_path)
));
}
}
Err(err) => issues.push(format!(
"failed reading {}: {err}",
display_relative(root, &contract_path)
)),
}
let contract_schema_path = layer.join("schemas/sdd-contract.schema.json");
match fs::read_to_string(&contract_schema_path) {
Ok(text) => {
if let Err(err) = serde_json::from_str::<Value>(&text) {
issues.push(format!(
"invalid JSON in {}: {err}",
display_relative(root, &contract_schema_path)
));
}
}
Err(err) => issues.push(format!(
"failed reading {}: {err}",
display_relative(root, &contract_schema_path)
)),
}
let artifact_sections_path = layer.join("schemas/artifact-sections.json");
match fs::read_to_string(&artifact_sections_path) {
Ok(text) => match contract::parse_artifact_sections_json(&text) {
Ok(parsed) => {
let expected = contract::contract()
.artifacts
.iter()
.map(|(name, artifact)| (name.clone(), artifact.required_sections.clone()))
.collect::<BTreeMap<_, _>>();
if parsed != expected {
issues.push(format!(
"{} drifted from sdd-contract.yaml",
display_relative(root, &artifact_sections_path)
));
}
}
Err(err) => issues.push(format!(
"invalid {}: {err}",
display_relative(root, &artifact_sections_path)
)),
},
Err(err) => issues.push(format!(
"failed reading {}: {err}",
display_relative(root, &artifact_sections_path)
)),
}
for rel in [
"templates/traceability-map.yaml",
"templates/artifact-index.yaml",
] {
let path = layer.join(rel);
match fs::read_to_string(&path) {
Ok(text) => match contract::parse_template_stage_pairs(&text) {
Ok(parsed) => {
if parsed != contract::template_stage_pairs() {
issues.push(format!(
"{} drifted from sdd-contract.yaml",
display_relative(root, &path)
));
}
}
Err(err) => {
issues.push(format!("invalid {}: {err}", display_relative(root, &path)))
}
},
Err(err) => issues.push(format!(
"failed reading {}: {err}",
display_relative(root, &path)
)),
}
}
issues.extend(generated_client_surface_issues(
root,
is_layer_source_repo(root),
));
if is_layer_source_repo(root) {
let plugin_root = root.join("plugins/orchestration");
issues.extend(directory_mirror_issues(
root,
&plugin_root.join("skills"),
&root.join(".agents/skills/orchestration-plugin/skills"),
));
issues.extend(directory_mirror_issues(
root,
&plugin_root.join("skills"),
&root.join(".claude/skills/orchestration-plugin/skills"),
));
issues.extend(compare_file_to_source(
root,
&plugin_root.join("commands/discover.md"),
&root.join(".agents/skills/orchestration-plugin/commands/discover.md"),
));
issues.extend(compare_file_to_source(
root,
&plugin_root.join("commands/discover.md"),
&root.join(".claude/skills/orchestration-plugin/commands/discover.md"),
));
}
issues
}
fn client_invalid_references(root: &Path, target: ClientTarget) -> Vec<String> {
match target {
ClientTarget::Opencode => opencode_invalid_references(root),
ClientTarget::Codex | ClientTarget::Cursor | ClientTarget::Claude => {
mcp_config_issues(root, target)
}
_ => Vec::new(),
}
}
fn print_client_doctor(report: &ClientDoctorReport) {
println!("SDD clients doctor");
println!("{}", report.status.to_uppercase());
if !report.covered.is_empty() {
println!("Covered");
for (client, paths) in &report.covered {
println!("- {client}: {}", paths.join(", "));
}
}
if !report.missing.is_empty() {
println!("Missing");
for (client, paths) in &report.missing {
println!("- {client}: {}", paths.join(", "));
}
}
if !report.invalid.is_empty() {
println!("Invalid");
for (client, issues) in &report.invalid {
println!("- {client}: {}", issues.join("; "));
}
}
}
fn generated_mcp_config_files(
root: &Path,
targets: &BTreeSet<ClientTarget>,
) -> BTreeMap<&'static str, String> {
let mut files = BTreeMap::new();
if targets.contains(&ClientTarget::Codex)
|| targets.contains(&ClientTarget::Cli)
|| targets.contains(&ClientTarget::Antigravity)
{
files.insert(".mcp.json", render_mcp_json(root));
}
if targets.contains(&ClientTarget::Cursor) {
files.insert(".cursor/mcp.json", render_cursor_mcp_json(root));
files.insert(
".cursor/rules/codegraph.mdc",
render_codegraph_cursor_rule(),
);
}
files
}
fn sync_mcp_config_files(
root: &Path,
targets: &BTreeSet<ClientTarget>,
force: bool,
dry_run: bool,
) -> Result<()> {
println!("SDD MCP config");
if dry_run {
println!("DRY RUN: no files were written.");
}
for action in mcp_config_actions(root, targets, force, dry_run)? {
println!("- {action}");
}
Ok(())
}
fn mcp_config_actions(
root: &Path,
targets: &BTreeSet<ClientTarget>,
force: bool,
dry_run: bool,
) -> Result<Vec<String>> {
let mut actions = Vec::new();
for (rel, content) in generated_mcp_config_files(root, targets) {
let target = root.join(rel);
if target.exists() && !force {
actions.push(format!("skip existing {}", target.display()));
continue;
}
if dry_run {
actions.push(format!("dry-run write {}", target.display()));
continue;
}
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&target, content)?;
actions.push(format!("write {}", target.display()));
}
if targets.contains(&ClientTarget::Claude) {
let target = root.join(".claude/settings.json");
if dry_run {
actions.push(format!("dry-run merge {}", target.display()));
} else {
merge_claude_settings_mcp_permissions(&target)?;
actions.push(format!("merge {}", target.display()));
}
}
Ok(actions)
}
fn render_mcp_json(root: &Path) -> String {
let root = runtime::platform::display_path(root).replace('\\', "/");
serde_json::to_string_pretty(&json!({
"mcpServers": {
"sdd": {
"type": "stdio",
"command": "sdd",
"args": ["mcp", "serve", "--root", root]
},
"codegraph": {
"type": "stdio",
"command": "codegraph",
"args": ["serve", "--mcp", "--path", root]
}
}
}))
.expect("MCP JSON must serialize")
}
fn render_cursor_mcp_json(root: &Path) -> String {
let root = runtime::platform::display_path(root).replace('\\', "/");
serde_json::to_string_pretty(&json!({
"mcpServers": {
"sdd": {
"type": "stdio",
"command": "sdd",
"args": ["mcp", "serve", "--root", root]
},
"codegraph": {
"type": "stdio",
"command": "codegraph",
"args": ["serve", "--mcp", "--path", root]
}
}
}))
.expect("Cursor MCP JSON must serialize")
}
fn render_codegraph_cursor_rule() -> String {
r#"---
description: CodeGraph MCP usage guide — when to use which tool
alwaysApply: true
---
<!-- CODEGRAPH_START -->
## CodeGraph
This project has CodeGraph exposed through MCP. Use CodeGraph for structural questions: definitions, callers, callees, impact, signatures and focused task context. Use literal search only for exact strings, comments, logs or after a specific file is already open.
| Question | Tool |
|---|---|
| "Where is X defined?" | `codegraph_search` |
| "How does this area work?" | `codegraph_context` |
| "What calls Y?" | `codegraph_callers` |
| "What does Y call?" | `codegraph_callees` |
| "What would changing Z break?" | `codegraph_impact` |
| "Show source for related symbols" | `codegraph_explore` |
| "Is the index healthy?" | `codegraph_status` |
If the MCP server reports that CodeGraph is not initialized, ask before running `codegraph init -i`.
<!-- CODEGRAPH_END -->
"#
.to_string()
}
fn merge_claude_settings_mcp_permissions(path: &Path) -> Result<()> {
let mut value: Value = if path.exists() {
serde_json::from_str(&fs::read_to_string(path)?)
.with_context(|| format!("parsing {}", path.display()))?
} else {
json!({})
};
let object = value
.as_object_mut()
.ok_or_else(|| anyhow!("{} must contain a JSON object", path.display()))?;
let permissions = object
.entry("permissions")
.or_insert_with(|| json!({}))
.as_object_mut()
.ok_or_else(|| anyhow!("permissions must be an object"))?;
let allow = permissions
.entry("allow")
.or_insert_with(|| json!([]))
.as_array_mut()
.ok_or_else(|| anyhow!("permissions.allow must be an array"))?;
for permission in mcp_claude_permissions() {
if !allow.iter().any(|item| item.as_str() == Some(permission)) {
allow.push(Value::String(permission.to_string()));
}
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, format!("{}\n", serde_json::to_string_pretty(&value)?))?;
Ok(())
}
fn mcp_claude_permissions() -> &'static [&'static str] {
&[
"mcp__sdd__sdd_trace_list",
"mcp__sdd__sdd_trace_show",
"mcp__sdd__sdd_trace_summary",
"mcp__sdd__sdd_artifact_status",
"mcp__sdd__sdd_context_build",
"mcp__sdd__sdd_clients_doctor",
"mcp__codegraph__codegraph_search",
"mcp__codegraph__codegraph_context",
"mcp__codegraph__codegraph_callers",
"mcp__codegraph__codegraph_callees",
"mcp__codegraph__codegraph_impact",
"mcp__codegraph__codegraph_node",
"mcp__codegraph__codegraph_files",
"mcp__codegraph__codegraph_explore",
"mcp__codegraph__codegraph_status",
]
}
fn mcp_config_issues(root: &Path, target: ClientTarget) -> Vec<String> {
let mut issues = Vec::new();
match target {
ClientTarget::Codex => validate_mcp_server_file(root, ".mcp.json", &mut issues),
ClientTarget::Cursor => {
validate_mcp_server_file(root, ".cursor/mcp.json", &mut issues);
if !root.join(".cursor/rules/codegraph.mdc").exists() {
issues.push("missing .cursor/rules/codegraph.mdc".to_string());
}
}
ClientTarget::Claude => {
let path = root.join(".claude/settings.json");
let text = fs::read_to_string(&path).unwrap_or_default();
for permission in [
"mcp__sdd__sdd_trace_list",
"mcp__sdd__sdd_context_build",
"mcp__codegraph__codegraph_context",
] {
if !text.contains(permission) {
issues.push(format!(
"{} missing permission `{permission}`",
display_relative(root, &path)
));
}
}
}
_ => {}
}
issues
}
fn validate_mcp_server_file(root: &Path, rel: &str, issues: &mut Vec<String>) {
let path = root.join(rel);
let Ok(text) = fs::read_to_string(&path) else {
issues.push(format!("missing {rel}"));
return;
};
let Ok(value) = serde_json::from_str::<Value>(&text) else {
issues.push(format!("{rel} is not valid JSON"));
return;
};
let servers = value.get("mcpServers").and_then(Value::as_object);
for server in ["sdd", "codegraph"] {
if servers.and_then(|items| items.get(server)).is_none() {
issues.push(format!("{rel} missing MCP server `{server}`"));
}
}
}
fn sync_client_files(
root_arg: &Path,
targets: &BTreeSet<ClientTarget>,
force: bool,
dry_run: bool,
) -> Result<()> {
let root = runtime::platform::canonicalize(root_arg).unwrap_or_else(|_| root_arg.to_path_buf());
let mut files = BTreeMap::new();
for target in targets {
for file in generated_files_for(*target) {
files.insert(file.path, file.content);
}
}
for (path, content) in generated_mcp_config_files(&root, targets) {
files.insert(path, content);
}
println!("SDD clients sync");
if dry_run {
println!("DRY RUN: no files were written.");
}
for (rel, content) in files {
let target = client_file_target(&root, rel);
if target.exists() && !force {
println!("- skip existing {}", target.display());
continue;
}
if dry_run {
println!("- dry-run write {}", target.display());
continue;
}
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&target, content)?;
println!("- write {}", target.display());
}
if targets.contains(&ClientTarget::Opencode) {
for action in ensure_opencode_prompt_files(&root, dry_run)? {
println!("- {action}");
}
}
Ok(())
}
#[derive(Clone)]
struct OpencodeFileReference {
config: PathBuf,
raw: String,
target: PathBuf,
}
struct OpencodeReferenceScan {
references: Vec<OpencodeFileReference>,
diagnostics: Vec<String>,
}
fn opencode_invalid_references(root: &Path) -> Vec<String> {
let scan = scan_opencode_file_references(root);
let mut issues = scan.diagnostics;
for reference in scan.references {
if !reference.target.exists() {
issues.push(format!(
"{} references missing {} -> {}",
display_relative(root, &reference.config),
reference.raw,
display_relative(root, &reference.target)
));
}
}
issues
}
fn ensure_opencode_prompt_files(root: &Path, dry_run: bool) -> Result<Vec<String>> {
let scan = scan_opencode_file_references(root);
let mut actions = scan
.diagnostics
.into_iter()
.map(|diagnostic| format!("skip opencode prompt repair ({diagnostic})"))
.collect::<Vec<_>>();
let mut repairs = BTreeMap::new();
for reference in scan.references {
if reference.target.exists() {
continue;
}
if is_project_opencode_agent_file(root, &reference.target) {
repairs.insert(reference.target, reference.raw);
}
}
for (target, _) in repairs {
let agent_name = target
.file_stem()
.and_then(OsStr::to_str)
.unwrap_or("opencode-agent");
if dry_run {
actions.push(format!("dry-run write {}", target.display()));
continue;
}
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&target, render_opencode_prompt_stub(agent_name))?;
actions.push(format!("write {}", target.display()));
}
Ok(actions)
}
// ─── sdd worktree ────────────────────────────────────────────────────────────
fn run_worktree(args: WorktreeArgs) -> Result<()> {
match args.command {
WorktreeCommand::List(a) => worktree_list(a),
WorktreeCommand::Prune(a) => worktree_prune(a),
WorktreeCommand::Add(a) => worktree_add(a),
WorktreeCommand::AddClean(a) => worktree_add_clean(a),
WorktreeCommand::Validate(a) => worktree_validate(a),
WorktreeCommand::ValidateBranchWorktreeActiveNow(a) => {
worktree_validate_branch_active_now(a)
}
WorktreeCommand::TextCommitCard(a) => worktree_text_commit_card_cmd(a),
}
}
fn run_bot(args: BotArgs) -> Result<()> {
let root = match &args.command {
BotCommand::Install(a)
| BotCommand::Start(a)
| BotCommand::Stop(a)
| BotCommand::Status(a)
| BotCommand::Uninstall(a) => resolve_project_root(a.root.clone())?,
};
// Check Node.js is available
let node_check = std::process::Command::new(if cfg!(windows) { "node.exe" } else { "node" })
.arg("--version")
.output();
if node_check.is_err() {
bail!("Node.js não encontrado. Instale Node.js >= 20 em nodejs.org");
}
let bot_dir = bot_bundle_dir(&root);
if !bot_dir.exists() && matches!(&args.command, BotCommand::Install(_)) {
let source = LayerSource::detect_for_target(&root);
let policy = CopyPolicy {
force: false,
dry_run: false,
include_claude_plugin: false,
merge_directories: false,
};
println!("Preparando bundle do bot em {}", bot_dir.display());
source.copy_item("bot", &bot_dir, true, policy)?;
}
if !bot_dir.exists() {
bail!(
"Diretório bot/ não encontrado em {}. Verifique a instalação do sdd-layer.",
root.display()
);
}
match args.command {
BotCommand::Install(_) => {
println!("Iniciando instalação do bot SDD-Slack...");
let status =
std::process::Command::new(if cfg!(windows) { "node.exe" } else { "node" })
.arg("install.js")
.current_dir(&bot_dir)
.status()?;
if !status.success() {
bail!("Instalação do bot falhou");
}
}
BotCommand::Start(_) => {
println!("Iniciando bot SDD-Slack via pm2...");
let status = std::process::Command::new(if cfg!(windows) { "pm2.cmd" } else { "pm2" })
.args(["start", "ecosystem.config.js"])
.current_dir(&bot_dir)
.status()
.or_else(|_| {
std::process::Command::new(if cfg!(windows) { "npx.cmd" } else { "npx" })
.args(["pm2", "start", "ecosystem.config.js"])
.current_dir(&bot_dir)
.status()
})?;
if !status.success() {
bail!("Falha ao iniciar o bot. Execute sdd bot install primeiro.");
}
let _ = std::process::Command::new(if cfg!(windows) { "pm2.cmd" } else { "pm2" })
.args(["save"])
.status();
println!("Bot iniciado. Use 'sdd bot status' para verificar.");
}
BotCommand::Stop(_) => {
println!("Parando bot SDD-Slack...");
let _ = std::process::Command::new(if cfg!(windows) { "pm2.cmd" } else { "pm2" })
.args(["stop", "sdd-bot"])
.status();
println!("Bot parado. Use 'sdd bot start' para reiniciar.");
}
BotCommand::Status(_) => {
let config_path = root.join(".sdd").join("bot").join("sdd-bot.config.yaml");
let sessions_path = root.join(".sdd").join("bot").join("sessions.json");
println!("=== SDD Bot Status ===");
println!(
"Config: {}",
if config_path.exists() {
"✓ encontrado"
} else {
"✗ não encontrado (execute sdd bot install)"
}
);
let _ = std::process::Command::new(if cfg!(windows) { "pm2.cmd" } else { "pm2" })
.args(["status", "sdd-bot"])
.status();
if sessions_path.exists() {
let content = fs::read_to_string(&sessions_path).unwrap_or_default();
println!("Sessões ativas: {}", content);
} else {
println!("Sessões ativas: nenhuma");
}
}
BotCommand::Uninstall(_) => {
println!("Desinstalando bot SDD-Slack...");
let _ = std::process::Command::new(if cfg!(windows) { "pm2.cmd" } else { "pm2" })
.args(["delete", "sdd-bot"])
.status();
let sessions_path = root.join(".sdd").join("bot").join("sessions.json");
if sessions_path.exists() {
fs::remove_file(&sessions_path)?;
}
println!("Bot desinstalado.");
}
}
Ok(())
}
/// Valida que o diretório atual é o repositório principal e não um worktree vinculado.
/// Retorna erro claro se executado de dentro de uma pasta .worktree/<branch>.
fn worktree_validate(args: WorktreeRootArgs) -> Result<()> {
let root = resolve_project_root(args.root)?;
worktree_assert_main_repo(&root)
}
/// Versão interna (sem args) usada pelos outros subcomandos antes de executar.
fn worktree_assert_main_repo(root: &Path) -> Result<()> {
// .git em worktrees vinculados é um arquivo (gitfile), não um diretório
let git_entry = root.join(".git");
if git_entry.is_file() {
bail!(
"Este comando deve ser executado no repositório principal, não dentro de um worktree.\n\
Diretório atual: {}\n\
Navegue até a raiz do projeto e tente novamente.",
root.display()
);
}
if !git_entry.exists() {
bail!(
"Nenhum repositório Git encontrado em {}.\n\
Certifique-se de estar na raiz do projeto.",
root.display()
);
}
Ok(())
}
/// Retorna o caminho da pasta de worktrees do projeto.
fn worktree_dir(root: &Path) -> PathBuf {
root.join(".worktree")
}
/// Converte um `Path` para `String` compatível com argumentos de linha de comando
/// em todas as plataformas. Remove o prefixo verbatim `\\?\` do Windows que
/// o Git não aceita como argumento.
fn path_to_arg(path: &Path) -> String {
let s = path.to_string_lossy();
// Prefixo verbatim gerado pelo Windows em caminhos absolutos longos
s.trim_start_matches(r"\\?\").to_string()
}
/// Executa um comando Git e retorna stdout como String, falhando com mensagem clara.
fn git_output(root: &Path, args: &[&str]) -> Result<String> {
let out = std::process::Command::new("git")
.args(args)
.current_dir(root)
.output()
.with_context(|| format!("falha ao executar git {}", args.join(" ")))?;
if !out.status.success() {
bail!(
"git {} falhou: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
/// Retorna branches remotos disponíveis (sem o prefixo origin/).
/// Usa `for-each-ref` em vez de `branch -r --format` para compatibilidade
/// com Git >= 2.7 em todas as plataformas (incluindo Xcode Git no macOS).
fn remote_branches(root: &Path) -> Result<Vec<String>> {
let raw = git_output(
root,
&[
"for-each-ref",
"refs/remotes/origin",
"--format=%(refname:short)",
],
)?;
Ok(raw
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.ends_with("/HEAD"))
.map(|l| l.trim_start_matches("origin/").to_string())
.collect())
}
/// Retorna os paths de worktrees já registrados no Git (exclui o worktree principal).
fn registered_worktree_paths(root: &Path) -> Result<Vec<PathBuf>> {
let raw = git_output(root, &["worktree", "list", "--porcelain"])?;
let mut paths = Vec::new();
let mut first = true;
for line in raw.lines() {
if let Some(path) = line.strip_prefix("worktree ") {
if first {
first = false; // pula o worktree principal
continue;
}
paths.push(PathBuf::from(path.trim()));
}
}
Ok(paths)
}
/// Retorna os nomes de pastas físicas presentes em .worktree/.
fn physical_worktrees(root: &Path) -> Vec<String> {
let dir = worktree_dir(root);
if !dir.exists() {
return vec![];
}
fs::read_dir(&dir)
.ok()
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
.filter_map(|e| e.file_name().into_string().ok())
.collect()
}
/// `sdd worktree list` — cruza Git registrado vs pastas físicas.
fn worktree_list(args: WorktreeRootArgs) -> Result<()> {
let root = resolve_project_root(args.root)?;
worktree_assert_main_repo(&root)?;
let registered = registered_worktree_paths(&root)?;
let physical = physical_worktrees(&root);
let wdir = worktree_dir(&root);
println!("Worktrees registrados no Git:");
if registered.is_empty() {
println!(" (nenhum)");
} else {
for p in ®istered {
println!(" {}", p.display());
}
}
println!("\nPastas físicas em {}:", wdir.display());
if physical.is_empty() {
println!(" (nenhuma)");
} else {
let registered_names: std::collections::HashSet<String> = registered
.iter()
.filter_map(|p| p.file_name()?.to_str().map(String::from))
.collect();
for name in &physical {
let tag = if registered_names.contains(name) {
""
} else {
" [ÓRFÃO - não registrado no Git]"
};
println!(" {}{}", name, tag);
}
}
Ok(())
}
/// `sdd worktree prune` — remove entradas órfãs do registro Git.
/// Não remove pastas físicas; use `worktree remove` para isso.
fn worktree_prune(args: WorktreeRootArgs) -> Result<()> {
let root = resolve_project_root(args.root)?;
worktree_assert_main_repo(&root)?;
println!("Executando git worktree prune...");
let out = git_output(&root, &["worktree", "prune", "--verbose"])?;
if out.trim().is_empty() {
println!("Nenhuma entrada órfã encontrada no registro Git.");
} else {
println!("{}", out.trim());
}
println!("Prune concluído. Pastas físicas em .worktree/ não foram removidas.");
Ok(())
}
/// `sdd worktree add <branch>` — cria worktree em .worktree/<branch>.
fn worktree_add(args: WorktreeAddArgs) -> Result<()> {
let root = resolve_project_root(args.root)?;
worktree_assert_main_repo(&root)?;
let wdir = worktree_dir(&root);
fs::create_dir_all(&wdir).with_context(|| format!("falha ao criar {}", wdir.display()))?;
let dest = wdir.join(&args.branch);
if dest.exists() {
bail!(
"pasta .worktree/{} já existe. Use `sdd worktree list` para inspecionar.",
args.branch
);
}
println!("Criando worktree para branch '{}'...", args.branch);
let dest_arg = path_to_arg(&dest);
// Verifica se a branch já existe localmente ou no remoto
let local_exists = git_output(&root, &["branch", "--list", &args.branch])
.map(|o| !o.trim().is_empty())
.unwrap_or(false);
let remote_exists = git_output(&root, &["ls-remote", "--heads", "origin", &args.branch])
.map(|o| !o.trim().is_empty())
.unwrap_or(false);
if local_exists || remote_exists {
// Branch já existe — worktree simples
git_output(&root, &["worktree", "add", &dest_arg, &args.branch])
.with_context(|| format!("falha ao criar worktree para branch '{}'", args.branch))?;
} else {
// Branch nova — cria a partir de homolog.
// Tenta atualizar o branch base a partir do remoto. Se o fetch falhar (ex.:
// credencial inválida/expirada), NÃO engole o erro: se a referência também
// não existir localmente, reporta a causa real (autenticação) em vez de
// deixar o `worktree add` falhar depois com o críptico "invalid reference".
if let Err(fetch_err) = git_output(&root, &["fetch", "origin", "homolog"]) {
let base_ref_local = git_output(
&root,
&["rev-parse", "--verify", "--quiet", "origin/homolog"],
)
.map(|o| !o.trim().is_empty())
.unwrap_or(false);
if !base_ref_local {
bail!(
"não foi possível buscar 'origin/homolog' e a referência não existe \
localmente.\nCausa provável: credencial do remoto inválida ou expirada.\n\
Verifique a autenticação do Git. No Bitbucket, use um API token com scopes \
no formato 'https://x-bitbucket-api-token-auth:<token>@bitbucket.org/...'\n\
(app passwords estão sendo descontinuados). Em seguida valide com \
'git fetch origin homolog'.\nDetalhe do erro: {fetch_err:#}"
);
}
eprintln!(
"aviso: 'git fetch origin homolog' falhou ({fetch_err}); \
usando a referência 'origin/homolog' já presente localmente."
);
}
git_output(
&root,
&[
"worktree",
"add",
"-b",
&args.branch,
&dest_arg,
"origin/homolog",
],
)
.with_context(|| format!("falha ao criar worktree para branch '{}'", args.branch))?;
}
println!("Worktree criado em: {}", dest.display());
Ok(())
}
/// `sdd worktree add-clean` — sincroniza .worktree/ com branches remotos.
/// - Adiciona worktrees para branches remotos que ainda não existem localmente.
/// - Remove (com --force) pastas físicas cujo branch não existe mais no remoto.
fn worktree_add_clean(args: WorktreeRootArgs) -> Result<()> {
let root = resolve_project_root(args.root)?;
worktree_assert_main_repo(&root)?;
let wdir = worktree_dir(&root);
fs::create_dir_all(&wdir).with_context(|| format!("falha ao criar {}", wdir.display()))?;
// Busca estado atual antes de qualquer operação
println!("Buscando estado remoto...");
git_output(&root, &["fetch", "--prune"])?;
let remote = remote_branches(&root)?;
let physical = physical_worktrees(&root);
let registered_paths = registered_worktree_paths(&root)?;
let registered_names: std::collections::HashSet<String> = registered_paths
.iter()
.filter_map(|p| p.file_name()?.to_str().map(String::from))
.collect();
let remote_set: std::collections::HashSet<&str> = remote.iter().map(String::as_str).collect();
// Remove worktrees cuja pasta existe mas o branch sumiu do remoto
let mut removed = 0usize;
for name in &physical {
if !remote_set.contains(name.as_str()) {
let dest = wdir.join(name);
println!(
"Removendo worktree obsoleto: {} (branch não existe mais no remoto)",
name
);
// Tenta remoção pelo Git (libera registro); --force ignora mudanças locais
let dest_arg = path_to_arg(&dest);
let _ = std::process::Command::new("git")
.args(["worktree", "remove", "--force", &dest_arg])
.current_dir(&root)
.output();
// Garante remoção física mesmo se o Git falhar.
// No Windows, remove_dir_all pode falhar com arquivos abertos;
// tentamos até 3 vezes com pausa para liberar handles do SO.
if dest.exists() {
let mut last_err = None;
for attempt in 0..3 {
match fs::remove_dir_all(&dest) {
Ok(()) => {
last_err = None;
break;
}
Err(e) => {
last_err = Some(e);
if attempt < 2 {
std::thread::sleep(std::time::Duration::from_millis(200));
}
}
}
}
if let Some(e) = last_err {
bail!(
"falha ao remover pasta física {}: {}\nFeche editores ou processos que possam ter arquivos abertos neste diretório.",
dest.display(),
e
);
}
}
removed += 1;
}
}
// Adiciona worktrees para branches remotos sem pasta local
let mut added = 0usize;
for branch in &remote {
let dest = wdir.join(branch);
if dest.exists() || registered_names.contains(branch.as_str()) {
continue;
}
println!("Adicionando worktree para branch '{}'...", branch);
let dest_arg = path_to_arg(&dest);
match git_output(&root, &["worktree", "add", &dest_arg, branch]) {
Ok(_) => {
println!(" ✓ {}", dest.display());
added += 1;
}
Err(e) => {
eprintln!(" ✗ falha ao adicionar '{}': {}", branch, e);
}
}
}
println!(
"\nadd-clean concluído: {} adicionado(s), {} removido(s).",
added, removed
);
Ok(())
}
// ─── card ID e validação de branch worktree ──────────────────────────────────
/// Detecta se uma string é um ID de card no formato `<TEXTO>-<NUMERO>`.
/// Exemplos válidos: CIJIRA-246, PROJ-1, ABC-99.
/// Texto livre como "adicionar login" ou "fix bug" não é detectado.
fn is_card_id(s: &str) -> bool {
let s = s.trim();
if let Some(pos) = s.rfind('-') {
let prefix = &s[..pos];
let suffix = &s[pos + 1..];
!prefix.is_empty()
&& !suffix.is_empty()
&& suffix.chars().all(|c| c.is_ascii_digit())
&& prefix.chars().all(|c| c.is_alphanumeric() || c == '_')
} else {
false
}
}
/// Extrai o card ID do início do input (primeiro token que satisfaz is_card_id).
/// Retorna `(Some(card_id), resto_do_input)` ou `(None, input_completo)`.
///
/// A entrada é achatada em tokens separados por espaço antes da detecção: os
/// clients chamam `sdd resolve-input "$ARGUMENTS"` com tudo num único argumento
/// citado (ex.: "CIJIRA-9 adicionar login"). Sem tokenizar, o card no início
/// não seria reconhecido; assim a detecção fica idêntica para argumentos
/// separados ou combinados num só.
fn extract_card_id(input: &[String]) -> (Option<String>, Vec<String>) {
let tokens: Vec<String> = input
.iter()
.flat_map(|part| part.split_whitespace())
.map(str::to_string)
.collect();
match tokens.first() {
Some(first) if is_card_id(first) => (Some(first.clone()), tokens[1..].to_vec()),
_ => (None, tokens),
}
}
/// Retorna o branch atual do Git no diretório root.
fn current_git_branch(root: &Path) -> Result<String> {
let out = git_output(root, &["rev-parse", "--abbrev-ref", "HEAD"])?;
Ok(out.trim().to_string())
}
/// Retorna true se existem arquivos com mudanças não commitadas no diretório root.
fn has_uncommitted_changes(root: &Path) -> bool {
std::process::Command::new("git")
.args(["status", "--porcelain"])
.current_dir(root)
.output()
.map(|o| !o.stdout.is_empty())
.unwrap_or(false)
}
/// Resultado da validação de branch/worktree para um card ID.
enum BranchValidation {
/// Branch correto e worktree existente — tudo ok.
Ok,
/// Worktree não existe em .worktree/<card_id>.
WorktreeMissing {
card_id: String,
expected_path: PathBuf,
},
/// Worktree existe mas o branch atual é diferente do esperado.
WrongBranch {
card_id: String,
current: String,
expected: String,
worktree_path: PathBuf,
},
}
/// Valida se o branch atual corresponde ao worktree do card ID.
/// Não faz I/O interativo — apenas retorna o resultado para quem decide a ação.
fn check_branch_for_card(root: &Path, card_id: &str) -> Result<BranchValidation> {
let worktree_path = worktree_dir(root).join(card_id);
if !worktree_path.exists() {
return Ok(BranchValidation::WorktreeMissing {
card_id: card_id.to_string(),
expected_path: worktree_path,
});
}
let current = current_git_branch(root)?;
if current == card_id {
Ok(BranchValidation::Ok)
} else {
Ok(BranchValidation::WrongBranch {
card_id: card_id.to_string(),
current,
expected: card_id.to_string(),
worktree_path,
})
}
}
/// Ponto de entrada do subcomando hidden `sdd worktree validate-branch-worktree-active-now`.
/// Valida branch/worktree para o card ID e oferece opção de mudar automaticamente.
fn worktree_validate_branch_active_now(args: WorktreeBranchValidateArgs) -> Result<()> {
let root = resolve_project_root(args.root)?;
worktree_assert_main_repo(&root)?;
enforce_branch_for_card(&root, &args.card_id, true)
}
/// Núcleo da validação: verifica branch/worktree e, quando `interactive=true`,
/// oferece ao usuário a opção de mudar de branch automaticamente.
/// Chamado por `run_orchestration` e pelos stages quando card ID é detectado.
fn enforce_branch_for_card(root: &Path, card_id: &str, interactive: bool) -> Result<()> {
match check_branch_for_card(root, card_id)? {
BranchValidation::Ok => Ok(()),
BranchValidation::WorktreeMissing {
card_id,
expected_path,
} => {
bail!(
"Worktree para '{}' não encontrado.\n\
Caminho esperado: {}\n\n\
Para criar o worktree execute:\n\
\tsdd worktree add {}\n\n\
Ou sincronize todos os branches remotos com:\n\
\tsdd worktree add-clean",
card_id,
expected_path.display(),
card_id,
)
}
BranchValidation::WrongBranch {
card_id,
current,
expected,
worktree_path,
} => {
eprintln!(
"\n⚠️ Branch incorreto para o card '{}'.\n\
Branch atual: {}\n\
Branch esperado: {}\n\
Worktree em: {}\n\n\
Para mudar manualmente:\n\
\tcd {}\n\
Ou execute o próximo comando a partir da pasta do worktree.",
card_id,
current,
expected,
worktree_path.display(),
worktree_path.display(),
);
if !interactive {
bail!(
"Branch '{}' não corresponde ao worktree do card '{}'. \
Mude para o branch correto e tente novamente.",
current,
card_id
);
}
// Pergunta interativa: mudar de branch automaticamente?
eprint!(
"\nDeseja que o sdd mude para o branch '{}' automaticamente? [s/N] ",
expected
);
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
if !matches!(
answer.trim().to_lowercase().as_str(),
"s" | "sim" | "y" | "yes"
) {
bail!(
"Operação cancelada. Mude para o branch '{}' manualmente e execute novamente.",
expected
);
}
// Verifica mudanças não commitadas antes de trocar de branch
if has_uncommitted_changes(root) {
bail!(
"Existem arquivos com mudanças não commitadas no branch atual ('{}').\n\
Resolva as pendências antes de trocar de branch:\n\
\tgit status\n\
\tgit add . && git commit -m \"wip: salvar antes de trocar de branch\"\n\
Ou descarte com:\n\
\tgit stash\n\n\
Após resolver, execute novamente.",
current
);
}
// Muda para o branch do worktree
git_output(root, &["checkout", &expected])
.with_context(|| format!("falha ao mudar para o branch '{}'", expected))?;
println!("✓ Branch alterado para '{}'.", expected);
Ok(())
}
}
}
/// Valida presença dos artefatos pré-requisito (00-risk e 01-idea) para o card ID.
/// Chamado por run_orchestration antes de continuar a partir do prd.
fn assert_prerequisite_artifacts(root: &Path, card_id: &str) -> Result<()> {
let docs_dir = root.join("docs").join(card_id);
if !docs_dir.exists() {
bail!(
"Pasta de artefatos não encontrada: {}\n\
Execute primeiro:\n\
\tsdd risk \"{}\" --name \"{}\"\n\
\tsdd idea \"{}\" --name \"{}\"",
docs_dir.display(),
card_id,
card_id,
card_id,
card_id,
);
}
let required = ["00-risk-classification.md", "01-idea.md"];
let missing: Vec<&str> = required
.iter()
.copied()
.filter(|f| !docs_dir.join(f).exists())
.collect();
if !missing.is_empty() {
bail!(
"Artefatos pré-requisito ausentes em {}:\n{}\n\n\
Gere-os antes de continuar:\n\
\tsdd risk \"{}\" --name \"{}\"\n\
\tsdd idea \"{}\" --name \"{}\"",
docs_dir.display(),
missing
.iter()
.map(|f| format!(" - {f}"))
.collect::<Vec<_>>()
.join("\n"),
card_id,
card_id,
card_id,
card_id,
);
}
Ok(())
}
// Stages que exigem validação de branch/worktree quando card ID está presente.
// risk e idea são excluídos pois são gerados em etapa separada.
const CARD_GUARDED_STAGES: &[&str] = &[
"prd",
"techspec",
"tasks",
"refinement",
"execution",
"review",
"memory",
"production",
];
/// Retorna true se o stage deve ser guardado por validação de card ID.
fn stage_requires_card_guard(stage: &str) -> bool {
CARD_GUARDED_STAGES.contains(&stage)
}
// ─── stage lifecycle com card ID ─────────────────────────────────────────────
/// Retorna o path do worktree de um card no projeto.
fn worktree_path_for_card(root: &Path, card_id: &str) -> PathBuf {
worktree_dir(root).join(card_id)
}
/// Gera o cabeçalho padronizado de commit para um card/stage.
/// Formato:
/// card: <card_id>
/// tag: #<stage>
fn card_commit_header(card_id: &str, stage: &str) -> String {
format!("card: {}\ntag: #{}", card_id, stage)
}
/// Gera o título completo do commit de um stage.
/// Formato: release <card_id> #<stage> v<version>
fn stage_commit_title(card_id: &str, stage: &str, version: u32) -> String {
format!("release {} #{} v{}", card_id, stage, version)
}
/// Gera a mensagem de commit completa para um stage com card.
/// Título + cabeçalho de rastreabilidade + descrição do que foi feito.
pub fn build_stage_commit_message(
card_id: &str,
stage: &str,
version: u32,
attempt: u32,
summary: &str,
is_retry: bool,
) -> String {
let title = stage_commit_title(card_id, stage, version);
let header = card_commit_header(card_id, stage);
let attempt_note = if is_retry {
format!(
"\n\n<<rápida correção>> tentativa {}/3\n{}",
attempt, summary
)
} else {
format!("\n\n{}", summary)
};
format!("{}\n\n{}{}", title, header, attempt_note)
}
/// Comando `sdd worktree text-commit-card` — imprime o texto formatado do commit.
fn worktree_text_commit_card_cmd(args: WorktreeTextCommitCardArgs) -> Result<()> {
let msg = build_stage_commit_message(
&args.card_id,
&args.stage,
args.version,
args.attempt,
"(descreva aqui o que foi feito nesta etapa)",
args.attempt > 1,
);
println!("{}", msg);
Ok(())
}
/// Gera o cabeçalho a ser preposto no arquivo DOC do artefato quando card é usado.
fn card_doc_header(card_id: &str, stage: &str) -> String {
format!("<!-- card: {} | tag: #{} -->\n\n", card_id, stage)
}
/// Prepõe o cabeçalho de card/tag ao arquivo de artefato, se ainda não existir.
fn prepend_card_header_to_artifact(artifact_path: &Path, card_id: &str, stage: &str) -> Result<()> {
if !artifact_path.exists() {
return Ok(());
}
let existing = fs::read_to_string(artifact_path)?;
let header = card_doc_header(card_id, stage);
if existing.starts_with("<!-- card:") {
return Ok(()); // já tem cabeçalho
}
let updated = format!("{}{}", header, existing);
tui::persist::write_atomic(artifact_path, updated.as_bytes())?;
Ok(())
}
/// Realiza git add + commit + push dentro do worktree do card.
/// Opera no diretório `.worktree/<card_id>/` que é o working tree do branch do card.
fn commit_push_worktree_stage(
root: &Path,
card_id: &str,
stage: &str,
version: u32,
attempt: u32,
summary: &str,
) -> Result<()> {
let wt = worktree_path_for_card(root, card_id);
if !wt.exists() {
bail!(
"Worktree do card '{}' não encontrado em {}.\n\
Execute `sdd worktree add {}` primeiro.",
card_id,
wt.display(),
card_id
);
}
let is_retry = attempt > 1;
let msg = build_stage_commit_message(card_id, stage, version, attempt, summary, is_retry);
// git add . dentro do worktree
git_output(&wt, &["add", "."])
.with_context(|| format!("falha ao fazer git add no worktree '{}'", card_id))?;
// Verifica se há algo para commitar
let status = git_output(&wt, &["status", "--porcelain"])?;
if status.trim().is_empty() {
println!(
" ℹ️ Nada a commitar no worktree '{}' para stage '{}'.",
card_id, stage
);
return Ok(());
}
// git commit
git_output(&wt, &["commit", "-m", &msg]).with_context(|| {
format!(
"falha ao commitar stage '{}' no worktree '{}'",
stage, card_id
)
})?;
// git push
git_output(&wt, &["push"]).with_context(|| {
format!(
"falha ao fazer push do stage '{}' no worktree '{}'",
stage, card_id
)
})?;
println!(
" ✓ commit+push worktree '{}' — stage '{}' v{} (tentativa {}).",
card_id, stage, version, attempt
);
Ok(())
}
/// Escreve um refinamento rápido em docs/<card_id>/XX-rejection-<stage>.md
/// e atualiza (append) os artefatos de prd, techspec e tasks com a problemática.
fn write_rejection_refinement(
root: &Path,
card_id: &str,
stage: &str,
attempt: u32,
problem: &str,
) -> Result<PathBuf> {
let docs_dir = root.join("docs").join(card_id);
fs::create_dir_all(&docs_dir)?;
let filename = format!("rejection-{}-attempt-{}.md", stage, attempt);
let path = docs_dir.join(&filename);
let content = format!(
"<!-- card: {} | tag: #reprovado -->\n\n\
# Refinamento Rápido — {} (tentativa {}/3)\n\n\
**Stage reprovado:** {}\n\
**Card:** {}\n\n\
## Problemática\n\n{}\n\n\
## Direcionamento\n\n\
Este refinamento deve ser lido pelo agente `execution` antes de iniciar.\n\
Consulte também os artefatos de prd, techspec e tasks em `docs/{}/`.\n",
card_id, stage, attempt, stage, card_id, problem, card_id
);
tui::persist::write_atomic(&path, content.as_bytes())?;
// Append nos artefatos existentes (prd, techspec, tasks)
for artifact in &["02-prd.md", "03-techspec.md", "04-tasks.md"] {
let artifact_path = docs_dir.join(artifact);
if artifact_path.exists() {
let existing = fs::read_to_string(&artifact_path)?;
let append = format!(
"\n\n---\n## ⚠️ Reprovação — {} tentativa {}\n\n{}\n",
stage, attempt, problem
);
let updated = format!("{}{}", existing, append);
tui::persist::write_atomic(&artifact_path, updated.as_bytes())?;
}
}
println!(" ✓ Refinamento rápido gerado: {}", path.display());
Ok(path)
}
/// Resolve a versão de commit para um stage/card a partir do número de commits
/// já existentes no branch do worktree. Cada push de stage incrementa a versão.
fn resolve_stage_version(root: &Path, card_id: &str, stage: &str) -> u32 {
let wt = worktree_path_for_card(root, card_id);
if !wt.exists() {
return 1;
}
// Conta commits cujo título contém o stage para derivar a versão
let pattern = format!("release {} #{}", card_id, stage);
std::process::Command::new("git")
.args(["log", "--oneline", "--grep", &pattern])
.current_dir(&wt)
.output()
.map(|o| {
let count = String::from_utf8_lossy(&o.stdout).lines().count() as u32;
count + 1
})
.unwrap_or(1)
}
// ─── fim sdd worktree ─────────────────────────────────────────────────────────
fn ensure_gitignore_entries(root: &Path, dry_run: bool) -> Result<Vec<String>> {
// `.mcp.json` e `.cursor/mcp.json` carregam o path absoluto da máquina
// (são gerados por projeto via render_mcp_json); nunca devem ser versionados.
const REQUIRED: &[&str] = &[
".worktrees/",
".worktree/",
".codegraph/",
".mcp.json",
".cursor/mcp.json",
];
let mut actions = Vec::new();
// Cria a pasta .worktree/ se não existir
let worktree_dir = root.join(".worktree");
if !worktree_dir.exists() {
if dry_run {
actions.push("dry-run: would create .worktree/".to_string());
} else {
fs::create_dir_all(&worktree_dir)?;
actions.push("created .worktree/".to_string());
}
}
// Garante entradas no .gitignore
let gitignore = root.join(".gitignore");
let existing = if gitignore.exists() {
fs::read_to_string(&gitignore)?
} else {
String::new()
};
let missing: Vec<&str> = REQUIRED
.iter()
.copied()
.filter(|entry| {
!existing
.lines()
.any(|line| line.trim() == *entry || line.trim() == entry.trim_end_matches('/'))
})
.collect();
if missing.is_empty() {
actions.push("gitignore: worktree entries already present".to_string());
return Ok(actions);
}
if dry_run {
actions.extend(
missing
.iter()
.map(|e| format!("dry-run gitignore: would add {e}")),
);
return Ok(actions);
}
let mut content = existing;
if !content.ends_with('\n') && !content.is_empty() {
content.push('\n');
}
if !content.contains("# Worktrees") {
content.push_str("\n# Worktrees locais\n");
}
for entry in &missing {
content.push_str(entry);
content.push('\n');
}
fs::write(&gitignore, &content)?;
actions.extend(missing.iter().map(|e| format!("gitignore: added {e}")));
Ok(actions)
}
fn scan_opencode_file_references(root: &Path) -> OpencodeReferenceScan {
let mut references = Vec::new();
let mut diagnostics = Vec::new();
let file_ref = Regex::new(r"\{\$?file:([^}]+)\}").expect("valid opencode file ref regex");
for config in opencode_config_files(root) {
let text = match fs::read_to_string(&config) {
Ok(text) => text,
Err(error) => {
diagnostics.push(format!(
"{} could not be read: {error}",
display_relative(root, &config)
));
continue;
}
};
let json_text = if config.extension().and_then(OsStr::to_str) == Some("jsonc") {
strip_json_comments(&text)
} else {
text
};
let value = match serde_json::from_str::<Value>(&json_text) {
Ok(value) => value,
Err(error) => {
diagnostics.push(format!(
"{} is not valid JSON: {error}",
display_relative(root, &config)
));
continue;
}
};
let mut raw_refs = Vec::new();
collect_opencode_file_refs(&value, &file_ref, &mut raw_refs);
let base = config.parent().unwrap_or(root);
for raw in raw_refs {
let target = normalize_path(&base.join(raw.trim()));
references.push(OpencodeFileReference {
config: config.clone(),
raw,
target,
});
}
}
OpencodeReferenceScan {
references,
diagnostics,
}
}
fn opencode_config_files(root: &Path) -> Vec<PathBuf> {
[
"opencode.json",
"opencode.jsonc",
".opencode.json",
".opencode.jsonc",
]
.iter()
.map(|rel| root.join(rel))
.filter(|path| path.exists())
.collect()
}
fn collect_opencode_file_refs(value: &Value, file_ref: &Regex, out: &mut Vec<String>) {
match value {
Value::String(text) => {
out.extend(
file_ref
.captures_iter(text)
.filter_map(|capture| capture.get(1).map(|item| item.as_str().to_string())),
);
}
Value::Array(items) => {
for item in items {
collect_opencode_file_refs(item, file_ref, out);
}
}
Value::Object(map) => {
for value in map.values() {
collect_opencode_file_refs(value, file_ref, out);
}
}
_ => {}
}
}
fn is_project_opencode_agent_file(root: &Path, target: &Path) -> bool {
let Ok(rel) = target.strip_prefix(root) else {
return false;
};
let components = rel
.components()
.map(|component| component.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>();
components.len() == 3
&& components[0] == ".opencode"
&& components[1] == "agents"
&& target.extension().and_then(OsStr::to_str) == Some("md")
}
fn render_opencode_prompt_stub(agent_name: &str) -> String {
format!(
r#"---
description: Prompt stub for `{agent_name}` referenced by opencode.json
mode: subagent
permission:
edit: ask
bash:
"*": ask
---
This file was generated by `sdd` because `opencode.json` references it and it was missing.
Use the agent configuration declared in `opencode.json` as the source of truth. Follow `AGENTS.md`, `CLAUDE.md` when present, `.sdd/` rules, and project-local conventions. Keep the task scoped, preserve local changes, and report commands/tests run.
"#
)
}
fn display_relative(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.display()
.to_string()
.replace('\\', "/")
}
fn normalize_path(path: &Path) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
normalized.pop();
}
_ => normalized.push(component.as_os_str()),
}
}
normalized
}
fn strip_json_comments(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
let mut in_string = false;
let mut escaped = false;
while let Some(ch) = chars.next() {
if in_string {
out.push(ch);
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
in_string = false;
}
continue;
}
if ch == '"' {
in_string = true;
out.push(ch);
continue;
}
if ch == '/' {
match chars.peek().copied() {
Some('/') => {
chars.next();
for next in chars.by_ref() {
if next == '\n' {
out.push('\n');
break;
}
}
}
Some('*') => {
chars.next();
let mut previous = '\0';
for next in chars.by_ref() {
if previous == '*' && next == '/' {
break;
}
previous = next;
}
}
_ => out.push(ch),
}
continue;
}
out.push(ch);
}
out
}
fn client_file_target(root: &Path, rel: &str) -> PathBuf {
if let Some(child) = rel.strip_prefix("clients/") {
layer_root(root).join("clients").join(child)
} else {
root.join(rel)
}
}
#[derive(Clone, Copy)]
enum ClientCommandSurface {
Cursor,
DevinWorkflow,
Opencode,
Trae,
}
struct StageCommandSpec {
description: &'static str,
argument_hint: &'static str,
model: Option<&'static str>,
body: &'static str,
}
fn stage_command_files(surface: ClientCommandSurface) -> Vec<GeneratedFile> {
contract::command_names()
.into_iter()
.map(|command| {
let stage_title = contract::stage_title_from_command(command)
.expect("generated stage command must be declared in sdd-contract.yaml");
debug_assert!(!stage_title.is_empty());
GeneratedFile {
path: stage_command_path(surface, command),
content: render_stage_command(command, surface),
}
})
.collect()
}
fn stage_command_path(surface: ClientCommandSurface, command: &str) -> &'static str {
match (surface, command) {
(ClientCommandSurface::Cursor, "discover") => ".cursor/commands/discover.md",
(ClientCommandSurface::Cursor, "risk") => ".cursor/commands/risk.md",
(ClientCommandSurface::Cursor, "idea") => ".cursor/commands/idea.md",
(ClientCommandSurface::Cursor, "prd") => ".cursor/commands/prd.md",
(ClientCommandSurface::Cursor, "techspec") => ".cursor/commands/techspec.md",
(ClientCommandSurface::Cursor, "tasks") => ".cursor/commands/tasks.md",
(ClientCommandSurface::Cursor, "refinement") => ".cursor/commands/refinement.md",
(ClientCommandSurface::Cursor, "execution") => ".cursor/commands/execution.md",
(ClientCommandSurface::Cursor, "adr") => ".cursor/commands/adr.md",
(ClientCommandSurface::Cursor, "review") => ".cursor/commands/review.md",
(ClientCommandSurface::Cursor, "memory") => ".cursor/commands/memory.md",
(ClientCommandSurface::DevinWorkflow, "discover") => ".devin/workflows/discover.md",
(ClientCommandSurface::DevinWorkflow, "risk") => ".devin/workflows/risk.md",
(ClientCommandSurface::DevinWorkflow, "idea") => ".devin/workflows/idea.md",
(ClientCommandSurface::DevinWorkflow, "prd") => ".devin/workflows/prd.md",
(ClientCommandSurface::DevinWorkflow, "techspec") => ".devin/workflows/techspec.md",
(ClientCommandSurface::DevinWorkflow, "tasks") => ".devin/workflows/tasks.md",
(ClientCommandSurface::DevinWorkflow, "refinement") => ".devin/workflows/refinement.md",
(ClientCommandSurface::DevinWorkflow, "execution") => ".devin/workflows/execution.md",
(ClientCommandSurface::DevinWorkflow, "adr") => ".devin/workflows/adr.md",
(ClientCommandSurface::DevinWorkflow, "review") => ".devin/workflows/review.md",
(ClientCommandSurface::DevinWorkflow, "memory") => ".devin/workflows/memory.md",
(ClientCommandSurface::Opencode, "discover") => ".opencode/commands/discover.md",
(ClientCommandSurface::Opencode, "risk") => ".opencode/commands/risk.md",
(ClientCommandSurface::Opencode, "idea") => ".opencode/commands/idea.md",
(ClientCommandSurface::Opencode, "prd") => ".opencode/commands/prd.md",
(ClientCommandSurface::Opencode, "techspec") => ".opencode/commands/techspec.md",
(ClientCommandSurface::Opencode, "tasks") => ".opencode/commands/tasks.md",
(ClientCommandSurface::Opencode, "refinement") => ".opencode/commands/refinement.md",
(ClientCommandSurface::Opencode, "execution") => ".opencode/commands/execution.md",
(ClientCommandSurface::Opencode, "adr") => ".opencode/commands/adr.md",
(ClientCommandSurface::Opencode, "review") => ".opencode/commands/review.md",
(ClientCommandSurface::Opencode, "memory") => ".opencode/commands/memory.md",
(ClientCommandSurface::Trae, "discover") => ".trae/commands/discover.md",
(ClientCommandSurface::Trae, "risk") => ".trae/commands/risk.md",
(ClientCommandSurface::Trae, "idea") => ".trae/commands/idea.md",
(ClientCommandSurface::Trae, "prd") => ".trae/commands/prd.md",
(ClientCommandSurface::Trae, "techspec") => ".trae/commands/techspec.md",
(ClientCommandSurface::Trae, "tasks") => ".trae/commands/tasks.md",
(ClientCommandSurface::Trae, "refinement") => ".trae/commands/refinement.md",
(ClientCommandSurface::Trae, "execution") => ".trae/commands/execution.md",
(ClientCommandSurface::Trae, "adr") => ".trae/commands/adr.md",
(ClientCommandSurface::Trae, "review") => ".trae/commands/review.md",
(ClientCommandSurface::Trae, "memory") => ".trae/commands/memory.md",
(_, other) => panic!("unknown stage command: {other}"),
}
}
fn stage_command_spec(command: &str) -> StageCommandSpec {
match command {
"discover" => StageCommandSpec {
description: "Etapa 0 - Project Discovery do fluxo SDD",
argument_hint: "[foco opcional]",
model: Some("claude-sonnet-4-6"),
body: r#"Rode `sdd discover $ARGUMENTS` quando houver foco suficiente para preparar o artefato local. Use `sdd discover --json --dry-run` quando precisar de inventário estruturado sem gravar artefato. Depois use o subagent `project-discovery` para analisar o projeto atual antes de rodar a pipeline SDD.
Foco opcional:
$ARGUMENTS
Saída obrigatória:
- Project Discovery no formato de `.sdd/templates/project-discovery.md`, com Markdown canônico e companion HTML visual.
- Inventário técnico detalhado e read-only de manifests, dependências, workspaces, entrypoints reais, componentes, camadas, CI, scripts, testes, docs, migrations, contratos, integrações, infra, env examples e arquivos de agente.
- Linguagem de domínio: `CONTEXT.md`, `CONTEXT-MAP.md` e ADRs quando existirem.
- Code intelligence: status de CodeGraph/Lexa, comandos sugeridos e fallback `rg`.
- Capability map por stage com Taste Skill, Superpowers, Matt Pocock Skills, CodeGraph e Lexa como fontes adaptadas e atribuidas.
- Visualizações para onboarding: Mermaid no Markdown e `00-project-discovery.html` com cards, gráficos de paths/comandos, pipeline SDD e tabelas de stack/capabilities.
- Pendencias de `sdd.config.yaml`.
- Comandos verificados, não verificados e ausentes.
- Regras de risco, Refinement, Agent Teams e checkpoints.
- Recomendações de skills/rules por tecnologia via `sdd context recommend`, seções para `AGENTS.md` e deltas específicos para `CLAUDE.md`.
- Materialize Markdown e HTML sempre que não for dry-run: com orquestração nomeada use `docs/<slug-da-orquestracao>/00-project-discovery.md` e `.html`; sem nome explícito, use o fallback `docs/sdd-orchestration/00-project-discovery.md` e `docs/sdd-orchestration/00-project-discovery.html`.
- Para salvar Markdown externo em uma orquestração nomeada, use `sdd artifact save "<nome-da-orquestracao>" project-discovery --file <arquivo> --state recorded`."#,
},
"risk" => StageCommandSpec {
description: "Etapa 0 - Risk Classification do fluxo SDD",
argument_hint: "\"<feature>\"",
model: Some("claude-sonnet-4-6"),
body: r#"Rode `sdd risk "$ARGUMENTS"` para obter o baseline determinístico. Depois use o subagent `risk-classifier`.
Entrada:
$ARGUMENTS
Se a entrada estiver vazia, peça a descrição da feature. Consulte `sdd.config.yaml` e Project Discovery quando existirem. Retorne a classificação de risco e as decisões operacionais da pipeline.
Se esta classificação fizer parte de uma orquestração nomeada, salve-a em `docs/<slug-da-orquestracao>/00-risk-classification.md` via `sdd artifact save "<nome-da-orquestracao>" risk --file <arquivo> --state recorded`."#,
},
"idea" => StageCommandSpec {
description: "Etapa 1 - Idea do fluxo SDD",
argument_hint: "\"<ideia ou contexto>\"",
model: Some("claude-sonnet-4-6"),
body: r#"Rode `sdd idea "$ARGUMENTS"` e use o subagent `idea` para transformar a entrada em descoberta de produto.
Entrada:
$ARGUMENTS
Saída obrigatória:
- Visao geral da ideia.
- Problema.
- Público-alvo.
- Proposta de valor.
- Jornada principal.
- MVP.
- Fora do MVP.
- Hipóteses.
- Riscos.
- Perguntas em aberto.
- Rastreabilidade para Project Discovery/Risk quando existirem.
Salve a etapa em `docs/<slug-da-orquestracao>/01-idea.md` via `sdd artifact save "<nome-da-orquestracao>" idea --file <arquivo> --state recorded`."#,
},
"prd" => StageCommandSpec {
description: "Etapa 2 - PRD do fluxo SDD",
argument_hint: "\"<ideia aprovada ou contexto>\"",
model: Some("claude-sonnet-4-6"),
body: r#"Rode `sdd prd "$ARGUMENTS"` e use o subagent `prd` para materializar requisitos de produto.
Entrada:
$ARGUMENTS
Saída obrigatória:
- Resumo executivo.
- Contexto.
- Objetivos.
- Não objetivos.
- Personas.
- Casos de uso.
- Fluxos.
- Requisitos funcionais e não funcionais.
- Regras de negócio.
- Estados.
- Critérios de aceite.
- Métricas.
- Riscos.
- Dependências.
- Escopo do MVP.
- Diagramas Mermaid/Excalidraw em `## Diagramas`, ou `Não aplicável` com justificativa.
- Rastreabilidade para Idea.
Pare para checkpoint humano antes de marcar como `approved`. Depois salve em `docs/<slug-da-orquestracao>/02-prd.md` via `sdd artifact save "<nome-da-orquestracao>" prd --file <arquivo> --state approved`."#,
},
"techspec" => StageCommandSpec {
description: "Etapa 3 - Tech Spec do fluxo SDD",
argument_hint: "\"<PRD aprovado ou contexto>\"",
model: Some("claude-sonnet-4-6"),
body: r#"Rode `sdd techspec "$ARGUMENTS"` e use o subagent `techspec` para criar a arquitetura aterrada no código atual.
Entrada:
$ARGUMENTS
Saída obrigatória:
- Visão técnica.
- Arquitetura.
- Stack.
- Estrutura de pastas.
- Modelagem de dados.
- Contratos de API.
- Fluxos técnicos.
- Estados/eventos.
- Autenticação/autorização.
- Validação.
- Erros.
- Observabilidade.
- Segurança.
- Performance.
- Testes.
- Riscos.
- Decisões arquiteturais.
- Plano de implementação.
- Diagramas técnicos Mermaid/Excalidraw em `## Diagramas`, ou `Não aplicável` com justificativa.
- Rastreabilidade para PRD.
Pare para checkpoint humano antes de marcar como `approved`. Depois salve em `docs/<slug-da-orquestracao>/03-techspec.md` via `sdd artifact save "<nome-da-orquestracao>" techspec --file <arquivo> --state approved`."#,
},
"tasks" => StageCommandSpec {
description: "Etapa 4 - Tasks do fluxo SDD",
argument_hint: "\"<Tech Spec aprovada ou contexto>\"",
model: Some("claude-sonnet-4-6"),
body: r#"Rode `sdd tasks "$ARGUMENTS"` e use o subagent `tasks` para transformar a Tech Spec em plano executável.
Entrada:
$ARGUMENTS
Saída obrigatória:
- Épicos.
- Histórias técnicas.
- Tarefas atômicas com IDs `T-01` ou `TSK-01`.
- Ordem sugerida.
- Dependências.
- Critérios de aceite em Gherkin.
- Definition of Done.
- Estratégia de testes.
- Arquivos prováveis.
- Prompts Agent standalone por tarefa em `## Prompts Agent`.
- Rastreabilidade para Tech Spec.
Salve a etapa em `docs/<slug-da-orquestracao>/04-tasks.md` via `sdd artifact save "<nome-da-orquestracao>" tasks --file <arquivo> --state recorded`."#,
},
"refinement" => StageCommandSpec {
description: "Etapa 5 - Refinement do fluxo SDD",
argument_hint: "\"<Tasks ou contexto>\"",
model: Some("claude-sonnet-4-6"),
body: r#"Rode `sdd refinement "$ARGUMENTS"` e use o subagent `refinement` quando o risco exigir ou quando houver grooming técnico.
Entrada:
$ARGUMENTS
Saída obrigatória:
- Documento único e enxuto.
- `Resumo`.
- `Solução proposta`.
- `Pontos de observação`.
- `Checklist macro`.
- `Subtasks` com estimativas em múltiplos de 0.5h.
- `Definition of Done`.
- `Flags, Remote Configs e Configurações`.
- Rastreabilidade para Tasks.
Quando Refinement for obrigatório, pare para checkpoint humano antes de marcar como `approved`. Se for explicitamente dispensado por baixo risco, registre a dispensa. Salve em `docs/<slug-da-orquestracao>/05-refinement.md` via `sdd artifact save "<nome-da-orquestracao>" refinement --file <arquivo> --state approved`."#,
},
"execution" => StageCommandSpec {
description: "Etapa 6 - Execution do fluxo SDD",
argument_hint: "\"<task aprovada ou contexto>\"",
model: Some("claude-sonnet-4-6"),
body: r#"Rode `sdd execution "$ARGUMENTS"` e use o subagent `execution` para implementar uma tarefa por vez.
Entrada:
$ARGUMENTS
Antes de editar:
- Gere ou leia `sdd context build --name "<orquestracao>" --stage execution --write` quando houver artifact store.
- Use `code-intelligence` se CodeGraph/Lexa estiverem disponíveis; caso contrário, use `rg --files`, `rg` e leitura focada.
- Use `execution-discipline`: tracer bullet vertical, teste falhando quando houver seam correto, loop reproduzível para bug/performance e verificação fresca antes de conclusão.
- Preserve mudanças locais, não reverta trabalho externo e limite o escopo a task indicada.
Saída obrigatória:
- Resumo do implementado.
- Arquivos criados/alterados.
- Decisões técnicas.
- Como testar.
- Impactos.
- Pendencias.
- Rastreabilidade para Tasks/Refinement.
- Code intelligence/fallback usado.
- Teste ou loop de diagnostico criado, ou justificativa quando não houver seam adequado.
- Comandos de verificação frescos com resultado.
Salve evidências em `docs/<slug-da-orquestracao>/06-execution.md` via `sdd artifact save "<nome-da-orquestracao>" execution --file <arquivo> --state recorded`. Após Execution, rode `sdd adr` quando houver decisão arquitetural criada, confirmada ou alterada."#,
},
"adr" => StageCommandSpec {
description: "Etapa 6a - ADR pos-execução do fluxo SDD",
argument_hint: "\"<decisão ou evidência de execution>\"",
model: Some("claude-sonnet-4-6"),
body: r#"Rode `sdd adr "$ARGUMENTS"` e use o subagent `adr` para registrar decisões arquiteturais pos-execução.
Entrada:
$ARGUMENTS
Saída obrigatória:
- Rastreabilidade.
- Status.
- Contexto.
- Problema.
- Decisão.
- Alternativas.
- Consequencias.
- Impactos/riscos.
- Plano de adoção.
- Plano de reversao.
- Evidências.
- Histórico de revisao.
Salve em `docs/<slug-da-orquestracao>/06-adr.md` via `sdd artifact save "<nome-da-orquestracao>" adr --file <arquivo> --state recorded`."#,
},
"review" => StageCommandSpec {
description: "Etapa 7 - Review do fluxo SDD",
argument_hint: "\"<diff, PR ou contexto>\"",
model: Some("claude-sonnet-4-6"),
body: r#"Rode `sdd review "$ARGUMENTS"` e use o subagent `review` para revisar implementação, testes e aceite.
Entrada:
$ARGUMENTS
Saída obrigatória:
- Veredito `Aprovado | Aprovado com ressalvas | Reprovado`.
- Pontos positivos.
- Problemas.
- Severidade.
- Sugestoes.
- Testes recomendados.
- Checklist de qualidade.
- Rastreabilidade para Execution/ADR.
Pare para checkpoint humano antes de merge/deploy. Salve em `docs/<slug-da-orquestracao>/07-review.md` via `sdd artifact save "<nome-da-orquestracao>" review --file <arquivo> --state approved`."#,
},
"memory" => StageCommandSpec {
description: "Etapa 8 - Memory do fluxo SDD",
argument_hint: "\"<artefatos aprovados ou contexto>\"",
model: Some("claude-sonnet-4-6"),
body: r#"Rode `sdd memory "$ARGUMENTS"` e use o subagent `memory` para compactar conhecimento do ciclo.
Entrada:
$ARGUMENTS
Saída obrigatória:
- Estado atual.
- Funcionalidades implementadas.
- Arquitetura atual.
- Decisões.
- Padrões.
- Comandos úteis.
- Variáveis de ambiente.
- Testes.
- Pendencias.
- Próximos passos.
- Contexto resumido.
- Links para artefatos em vez de copia integral.
Salve em `docs/<slug-da-orquestracao>/08-memory.md` via `sdd artifact save "<nome-da-orquestracao>" memory --file <arquivo> --state recorded`."#,
},
other => panic!("unknown stage command: {other}"),
}
}
fn render_stage_command(command: &str, surface: ClientCommandSurface) -> String {
let spec = stage_command_spec(command);
let stage_title = contract::stage_title_from_command(command).unwrap_or(command);
let mut out = String::new();
if matches!(
surface,
ClientCommandSurface::Cursor | ClientCommandSurface::DevinWorkflow
) {
out.push_str(&format!("# SDD {}\n\n", stage_title));
out.push_str(cli_resolution_note());
out.push_str("\n\n");
out.push_str(spec.body.trim());
out.push_str("\n\nAntes de persistir qualquer artefato, garanta que o artifact store exista com `sdd init \"<nome-da-orquestracao>\"` quando ainda não houver `docs/<slug-da-orquestracao>/traceability-map.yaml`.\n");
return out;
}
out.push_str("---\n");
out.push_str(&format!("description: {}\n", spec.description));
out.push_str(&format!("argument-hint: {:?}\n", spec.argument_hint));
match surface {
ClientCommandSurface::Cursor | ClientCommandSurface::DevinWorkflow => {}
ClientCommandSurface::Opencode => out.push_str("agent: sdd-orchestrator\n"),
ClientCommandSurface::Trae => {
if let Some(model) = spec.model {
out.push_str(&format!("model: {model}\n"));
}
}
}
out.push_str("---\n\n");
out.push_str(cli_resolution_note());
out.push_str("\n\n");
out.push_str(spec.body.trim());
out.push_str("\n\nAntes de persistir qualquer artefato, garanta que o artifact store exista com `sdd init \"<nome-da-orquestracao>\"` quando ainda não houver `docs/<slug-da-orquestracao>/traceability-map.yaml`.\n");
out
}
fn cli_resolution_note() -> &'static str {
"Antes de rodar comandos `sdd`, resolva o CLI uma vez: use `sdd` quando `command -v sdd` encontrar o binário; no checkout fonte `sdd-layer`, use `cargo run --bin sdd --`; se ambos falharem, reporte instalação/PATH ausente com o comando exato que precisa ser reexecutado."
}
fn stage_output_catalog() -> &'static str {
r#"## Formato de saída por etapa
Use o mesmo contrato de saída dos subagents `.codex/agents` e `.claude/agents` em qualquer cliente:
- `idea`: visão geral da ideia, problema, público-alvo, proposta de valor, jornada principal, MVP, fora do MVP, hipóteses, riscos e perguntas em aberto.
- `prd`: resumo executivo, contexto, objetivos, não objetivos, personas, casos de uso, fluxos, requisitos funcionais e não funcionais, regras de negócio, estados, critérios de aceite, métricas, riscos, dependências, escopo do MVP e `Diagramas` com Mermaid/Excalidraw ou justificativa de `Não aplicável`.
- `techspec`: visão técnica, arquitetura, stack, estrutura de pastas, modelagem de dados, contratos de API, fluxos técnicos, estados/eventos, autenticação/autorização, validação, erros, observabilidade, segurança, performance, testes, riscos, decisões arquiteturais, plano de implementação e `Diagramas` técnicos com Mermaid/Excalidraw ou justificativa de `Não aplicável`.
- `tasks`: épicos, histórias técnicas, tarefas atômicas em formato executável (`T-01` ou `TSK-01`), ordem sugerida, dependências, critérios de aceite em Gherkin, DoD, estratégia de testes, arquivos prováveis e `Prompts Agent` standalone por tarefa.
- `refinement`: documento único e enxuto com `Resumo`, `Solução proposta`, `Pontos de observação`, `Checklist macro`, `Subtasks` com estimativas em múltiplos de 0.5h, `Definition of Done` e `Flags, Remote Configs e Configurações`.
- `execution`: resumo do implementado, arquivos criados/alterados, decisões técnicas, como testar, impactos e pendências.
- `adr`: rastreabilidade, status, contexto, problema, decisão, alternativas, consequências, impactos/riscos, plano de adoção, plano de reversão, evidências e histórico de revisão.
- `review`: veredito `Aprovado | Aprovado com ressalvas | Reprovado`, pontos positivos, problemas, severidade, sugestões, testes recomendados e checklist de qualidade.
- `memory`: estado atual, funcionalidades implementadas, arquitetura atual, decisões, padrões, comandos úteis, variáveis de ambiente, testes, pendências, próximos passos e contexto resumido.
Todo artefato materializado deve ter `Rastreabilidade`, apontar para o artefato anterior e ser salvo em `docs/<slug-da-orquestracao>/` com `sdd artifact save`. Use `recorded` para etapas informativas e `approved` somente depois de checkpoint humano.
"#
}
fn append_stage_output_catalog(mut content: String) -> String {
content.push('\n');
content.push_str(stage_output_catalog());
content
}
fn generated_files_for(target: ClientTarget) -> Vec<GeneratedFile> {
match target {
ClientTarget::Cli => vec![GeneratedFile {
path: "clients/cli.md",
content: client_doc("CLI", "Use `sdd orchestration \"<ideia>\"` como fluxo completo. `sdd orchestrator` é aceito apenas como alias legado."),
}],
ClientTarget::Codex => vec![GeneratedFile {
path: ".agents/skills/orchestration/SKILL.md",
content: orchestration_skill(),
},
GeneratedFile {
path: "clients/codex.md",
content: client_doc("Codex", "Use `AGENTS.md`, `.codex/config.toml`, `.codex/agents/` e `.agents/skills/`. O comando canônico é `/sdd orchestration` quando o plugin estiver ativo, ou `sdd orchestration` via shell."),
}],
ClientTarget::Claude => vec![
GeneratedFile {
path: ".claude/CLAUDE.md",
content: claude_workspace_doc(),
},
GeneratedFile {
path: ".claude/commands/orchestration.md",
content: claude_orchestration_command(),
},
GeneratedFile {
path: ".claude/skills/orchestration/SKILL.md",
content: claude_orchestration_skill(),
},
GeneratedFile {
path: "clients/claude-code.md",
content: client_doc("Claude Code", "Use `.claude/skills/orchestration/SKILL.md` ou `.claude/commands/orchestration.md`; comandos antigos em `.claude/commands/orchestrator.md` devem delegar ao canônico."),
},
],
ClientTarget::Cursor => vec![
GeneratedFile {
path: ".cursor/model-routing.yaml",
content: cursor_model_routing(),
},
GeneratedFile {
path: ".cursor/rules/sdd.mdc",
content: cursor_rule(),
},
GeneratedFile {
path: ".cursor/commands/sdd.md",
content: cursor_sdd_command(),
},
GeneratedFile {
path: ".cursor/commands/orchestration.md",
content: cursor_orchestration_command(),
},
GeneratedFile {
path: ".cursor/agents/sdd-orchestrator.md",
content: cursor_agent(),
},
GeneratedFile {
path: ".cursor/skills/orchestration/SKILL.md",
content: orchestration_skill(),
},
GeneratedFile {
path: "clients/cursor.md",
content: client_doc("Cursor", "Use `AGENTS.md`, `.cursor/rules/`, `.cursor/commands/`, `.cursor/agents/`, `.cursor/skills/` e `.cursor/model-routing.yaml` para operar o fluxo SDD completo."),
},
]
.into_iter()
.chain(stage_command_files(ClientCommandSurface::Cursor))
.collect(),
ClientTarget::Opencode => vec![
GeneratedFile {
path: ".opencode/commands/sdd.md",
content: opencode_sdd_command(),
},
GeneratedFile {
path: ".opencode/commands/orchestration.md",
content: opencode_orchestration_command(),
},
GeneratedFile {
path: ".opencode/agents/sdd-orchestrator.md",
content: opencode_agent(),
},
GeneratedFile {
path: "clients/opencode.md",
content: client_doc("opencode", "Use `AGENTS.md` para regras, `.opencode/commands/` para slash commands e `.opencode/agents/` para agente especializado."),
},
]
.into_iter()
.chain(stage_command_files(ClientCommandSurface::Opencode))
.collect(),
ClientTarget::Devin => vec![
GeneratedFile {
path: ".devin/config.json",
content: devin_config(),
},
GeneratedFile {
path: ".devin/rules/sdd.md",
content: devin_rule(),
},
GeneratedFile {
path: ".devin/agents/sdd-orchestrator/AGENT.md",
content: devin_agent(),
},
GeneratedFile {
path: ".devin/skills/orchestration/SKILL.md",
content: orchestration_skill(),
},
GeneratedFile {
path: ".devin/workflows/sdd.md",
content: devin_sdd_workflow(),
},
GeneratedFile {
path: ".devin/workflows/orchestration.md",
content: devin_orchestration_workflow(),
},
GeneratedFile {
path: ".agents/skills/orchestration/SKILL.md",
content: orchestration_skill(),
},
GeneratedFile {
path: "clients/devin.md",
content: client_doc("Devin", "Use `AGENTS.md`, `.devin/config.json`, `.devin/rules/`, `.devin/agents/`, `.devin/skills/`, `.devin/workflows/` e `.agents/skills/` para regras, subagents e workflows SDD."),
},
]
.into_iter()
.chain(devin_stage_skill_files())
.chain(stage_command_files(ClientCommandSurface::DevinWorkflow))
.collect(),
ClientTarget::Antigravity => vec![
GeneratedFile {
path: ".agents/skills/orchestration/SKILL.md",
content: orchestration_skill(),
},
GeneratedFile {
path: ".agents/rules/sdd.md",
content: antigravity_rule(),
},
GeneratedFile {
path: "clients/antigravity.md",
content: client_doc("Antigravity", "Use `AGENTS.md` e `.agents/rules/sdd.md` como Workspace Rule. Skills compartilhadas ficam em `.agents/skills/`."),
},
],
ClientTarget::Trae => vec![GeneratedFile {
path: ".trae/commands/orchestration.md",
content: trae_orchestration_command(),
},
GeneratedFile {
path: ".trae/rules/00-sdd-principles.md",
content: trae_principles_rule(),
},
GeneratedFile {
path: ".trae/rules/20-output-conventions.md",
content: trae_output_conventions_rule(),
},
GeneratedFile {
path: ".trae/skills/orchestration/SKILL.md",
content: trae_orchestration_skill(),
},
GeneratedFile {
path: "clients/trae.md",
content: client_doc("Trae", "Use `AGENTS.md`, `.trae/commands/`, `.trae/rules/` e `.trae/skills/` como superficies nativas. O CLI `sdd orchestration` permanece o mesmo harness."),
}]
.into_iter()
.chain(stage_command_files(ClientCommandSurface::Trae))
.collect(),
}
}
fn cursor_model_routing() -> String {
r#"default:
model: composer-2.5
effort: high
stages:
idea: { model: composer-2.5-fast, effort: medium }
prd: { model: composer-2.5, effort: high }
techspec: { model: sonnet-4.6, effort: xhigh }
tasks: { model: composer-2.5, effort: high }
refinement: { model: composer-2.5, effort: high }
execution: { model: composer-2.5, effort: high }
review: { model: sonnet-4.6, effort: xhigh }
memory: { model: haiku, effort: medium }
"#
.to_string()
}
fn devin_config() -> String {
r#"{
"read_config_from": {
"cursor": true,
"windsurf": true,
"claude": true
}
}
"#
.to_string()
}
fn devin_stage_skill_files() -> Vec<GeneratedFile> {
contract::command_names()
.into_iter()
.map(|command| GeneratedFile {
path: devin_stage_skill_path(command),
content: render_devin_stage_skill(command),
})
.collect()
}
fn devin_stage_skill_path(command: &str) -> &'static str {
match command {
"discover" => ".devin/skills/discover/SKILL.md",
"risk" => ".devin/skills/risk/SKILL.md",
"idea" => ".devin/skills/idea/SKILL.md",
"prd" => ".devin/skills/prd/SKILL.md",
"techspec" => ".devin/skills/techspec/SKILL.md",
"tasks" => ".devin/skills/tasks/SKILL.md",
"refinement" => ".devin/skills/refinement/SKILL.md",
"execution" => ".devin/skills/execution/SKILL.md",
"adr" => ".devin/skills/adr/SKILL.md",
"review" => ".devin/skills/review/SKILL.md",
"memory" => ".devin/skills/memory/SKILL.md",
other => panic!("unknown Devin stage skill: {other}"),
}
}
fn render_devin_stage_skill(command: &str) -> String {
let spec = stage_command_spec(command);
let mut out = String::new();
out.push_str("---\n");
out.push_str(&format!("name: {command}\n"));
out.push_str(&format!("description: {}\n", spec.description));
out.push_str(&format!("argument-hint: {:?}\n", spec.argument_hint));
out.push_str("triggers:\n - user\n - model\n");
if let Some(model) = spec.model {
out.push_str(&format!("model: {model}\n"));
}
out.push_str("---\n\n");
out.push_str(spec.body.trim());
out.push_str("\n\nAntes de persistir qualquer artefato, garanta que o artifact store exista com `sdd init \"<nome-da-orquestracao>\"` quando ainda não houver `docs/<slug-da-orquestracao>/traceability-map.yaml`.\n");
out
}
fn devin_sdd_workflow() -> String {
r#"# SDD
Use o CLI `sdd` como harness determinístico para init, discovery, risco, artefatos, validação e guardrails.
Entrada:
$ARGUMENTS
- Para instalar/configurar a camada no projeto atual, rode `sdd init`.
- Para criar o artifact store de um ciclo, rode `sdd init "<nome-da-orquestracao>"`.
- Para fluxo completo, prefira `/orchestration` ou `sdd orchestration "<ideia>"`.
- Para validar superfícies de cliente, rode `sdd clients doctor`.
- Para regenerar Cursor/Devin/Trae/opencode, rode `sdd clients sync --targets all --force`.
Preserve acentos em conteúdo humano e use ASCII apenas para paths, slugs, branches, identificadores e contratos externos literais.
"#
.to_string()
}
fn devin_orchestration_workflow() -> String {
r#"# SDD orchestration
Primeiro, resolva o contexto-esqueleto unificado (vale para card ID, bot ou texto livre):
```bash
sdd resolve-input "$ARGUMENTS"
```
Adote o `--name` canônico retornado e use-o em TODAS as etapas e comandos `sdd`; comece pela próxima etapa sugerida sem pular etapas; monte o contexto de cada etapa com `sdd context build --name "<name>" --stage <etapa>`; e não regenere etapas já aprovadas.
Em seguida, rode:
```bash
sdd orchestration "$ARGUMENTS"
```
Antes de produzir qualquer artefato de etapa, defina o nome da orquestração e garanta o artifact store local:
```bash
sdd init "<nome-da-orquestracao>"
```
Depois conduza Project Discovery, Risk Classification, Idea, PRD, Tech Spec, Tasks, Refinement quando exigido, Execution, ADR, Review e Memory. Salve cada etapa em `docs/<slug-da-orquestracao>/` com `sdd artifact save`; use `approved` somente depois do checkpoint humano e `recorded` para etapas informativas.
`orchestrator` é apenas alias legado; use `orchestration` para comandos, docs, skills e workflows.
"#
.to_string()
}
fn cursor_sdd_command() -> String {
r#"# SDD
Use o CLI `sdd` como harness determinístico para init, discovery, risco, artefatos, validação e guardrails.
Entrada:
$ARGUMENTS
- Para instalar/configurar a camada no projeto atual, rode `sdd init`.
- Para criar o artifact store de um ciclo, rode `sdd init "<nome-da-orquestracao>"`.
- Para fluxo completo, prefira `/orchestration` ou `sdd orchestration "<ideia>"`.
- Para validar superfícies de cliente, rode `sdd clients doctor`.
- Para regenerar Cursor/Devin/Trae/opencode, rode `sdd clients sync --targets all --force`.
Preserve acentos em conteúdo humano e use ASCII apenas para paths, slugs, branches, identificadores e contratos externos literais.
"#
.to_string()
}
fn cursor_orchestration_command() -> String {
r#"# SDD orchestration
Primeiro, resolva o contexto-esqueleto unificado (vale para card ID, bot ou texto livre):
```bash
sdd resolve-input "$ARGUMENTS"
```
Adote o `--name` canônico retornado e use-o em TODAS as etapas e comandos `sdd`; comece pela próxima etapa sugerida sem pular etapas; monte o contexto de cada etapa com `sdd context build --name "<name>" --stage <etapa>`; e não regenere etapas já aprovadas.
Em seguida, rode:
```bash
sdd orchestration "$ARGUMENTS"
```
Antes de produzir qualquer artefato de etapa, defina o nome da orquestração e garanta o artifact store local:
```bash
sdd init "<nome-da-orquestracao>"
```
Depois conduza Project Discovery, Risk Classification, Idea, PRD, Tech Spec, Tasks, Refinement quando exigido, Execution, ADR, Review e Memory. Salve cada etapa em `docs/<slug-da-orquestracao>/` com `sdd artifact save`; use `approved` somente depois do checkpoint humano e `recorded` para etapas informativas.
`orchestrator` é apenas alias legado; use `orchestration` para comandos, docs e skills.
"#
.to_string()
}
fn cursor_agent() -> String {
append_stage_output_catalog(r#"---
name: sdd-orchestrator
description: Orquestra o fluxo SDD completo no Cursor com CLI determinístico, artefatos locais e checkpoints humanos.
model: sonnet-4.6
---
Você e o agente SDD principal para Cursor. Use `sdd` como harness determinístico para estado, risco, artifact store, validação e guardrails.
Regras operacionais:
- Use `orchestration` como nome canônico e trate `orchestrator` como alias legado.
- Antes de criar artefatos de etapa, rode `sdd init "<nome-da-orquestracao>"` quando o artifact store ainda não existir.
- Salve cada etapa em `docs/<slug-da-orquestracao>/` com `sdd artifact save`.
- Pare para checkpoint humano em PRD, Tech Spec, Refinement quando risco exigir, Review/merge e Deploy quando aplicável.
- Use `.cursor/commands/` para workflows invocáveis, `.cursor/skills/` para procedimentos reutilizáveis e `.cursor/rules/` para contexto sempre ativo.
- Se uma capacidade do cliente estiver indisponível, registre o comando, o artefato esperado e a evidência necessária para o harness executar.
"#
.to_string())
}
fn devin_agent() -> String {
append_stage_output_catalog(r#"---
name: sdd-orchestrator
description: Orquestra o fluxo SDD completo no Devin com CLI determinístico, skills por etapa e checkpoints humanos.
model: sonnet
allowed-tools:
- read
- grep
- glob
- exec
permissions:
allow:
- Exec(sdd *)
- Exec(git status*)
- Exec(git diff*)
- Exec(rg *)
- Exec(find *)
---
Você e o subagent SDD principal para Devin. Use `sdd` como harness determinístico para estado, risco, artifact store, validação e guardrails.
Regras operacionais:
- Use `orchestration` como nome canônico e trate `orchestrator` como alias legado.
- Antes de criar artefatos de etapa, rode `sdd init "<nome-da-orquestracao>"` quando o artifact store ainda não existir.
- Salve cada etapa em `docs/<slug-da-orquestracao>/` com `sdd artifact save`.
- Pare para checkpoint humano em PRD, Tech Spec, Refinement quando risco exigir, Review/merge e Deploy quando aplicável.
- Use `.devin/skills/` como workflows slash-command e `.devin/agents/` para subagents especializados.
- Se uma capacidade do cliente estiver indisponível, registre o comando, o artefato esperado e a evidência necessária para o harness executar.
"#
.to_string())
}
fn devin_rule() -> String {
append_stage_output_catalog(r#"# SDD Devin Rule
Use esta regra quando o usuário pedir SDD, spec-driven development, discovery, PRD, Tech Spec, tasks, execution, ADR, review ou memory.
- Nome canônico: `orchestration`.
- Alias legado: `orchestrator`.
- Preparacao: `sdd discover`, `sdd risk`, `sdd doctor` e `sdd clients doctor`.
- Fluxo completo: `sdd orchestration "<ideia>"`.
- Artifact store local: `docs/<slug-da-orquestracao>/`.
- Skills/workflows Devin: `.devin/skills/`.
- Subagents Devin: `.devin/agents/`.
- Skills compartilhadas interoperáveis: `.agents/skills/`.
- Checkpoints humanos: PRD, Tech Spec, Refinement quando risco exigir, Review/merge e Deploy quando aplicável.
- ADR: após Execution, rode `sdd adr` quando houver decisão arquitetural criada, confirmada ou alterada.
"#
.to_string())
}
fn client_doc(name: &str, body: &str) -> String {
format!(
"# SDD client - {name}\n\n{body}\n\n## Nome canônico\n\n- Use `orchestration` para comandos e skills.\n- Trate `orchestrator` como alias legado e apenas como papel humano.\n\n## Resolução do CLI\n\n- Faça uma checagem curta com `command -v sdd`. Se retornar um path, use `sdd ...`.\n- Se `sdd` não estiver no `PATH` e o projeto atual for o checkout fonte `sdd-layer` (`Cargo.toml` com `name = \"sdd-layer\"` e bin `sdd`), use `cargo run --bin sdd -- ...` a partir da raiz.\n- Em shells de editor, confirme `echo \"$PATH\"`; se faltar `$HOME/.cargo/bin`, configure `export PATH=\"$HOME/.cargo/bin:$PATH\"` no ambiente do cliente.\n- Não procure entrypoints alternativos antes desse preflight; se ambos falharem, reporte instalação/PATH ausente e o comando exato a reexecutar após instalar o binário ou ajustar o PATH.\n\n## Provider e login\n\n- Rode `sdd providers doctor` para validar provider/modelo/credenciais antes de iniciar.\n- Quando um método estiver deslogado, use o `login_command` exibido, por exemplo `agy`, `codex login`, `claude auth login`, `opencode providers login` ou `cursor agent login`.\n- Nunca copie tokens para prompts ou artefatos; use nomes de env vars e deixe o CLI redigir eventos.\n\n## Atualização de pacote\n\n- Em projeto já instalado, rode `sdd update` para refletir mudanças do pacote.\n- `sdd upgrade` é alias de `sdd update`.\n- O update preserva `sdd.config.yaml`, `docs/<orquestracao>/` e arquivos extras de skills/rules locais por padrão.\n\n## Fluxo mínimo\n\n1. Defina um `<ciclo>` estável para a orquestração.\n2. Rode `sdd init \"<ciclo>\"` antes de criar artefatos de etapa; isso cria `docs/<slug-do-ciclo>/` e `traceability-map.yaml`.\n3. `sdd discover --name \"<ciclo>\"`\n4. `sdd risk \"<feature>\" --name \"<ciclo>\"`\n5. `sdd orchestration \"<ideia>\" --name \"<ciclo>\"`\n6. Salvar cada etapa em `docs/<slug-do-ciclo>/` com `sdd artifact save` (`recorded` para etapas informativas, `approved` só depois de checkpoint humano).\n"
)
}
fn claude_workspace_doc() -> String {
r#"## CodeGraph MCP
Use CodeGraph for structural questions: definitions, callers, callees, impact, signatures and focused task context. Use literal search only for exact strings, comments, logs or after a specific file is already open.
| Need | Tool |
|---|---|
| Find definitions | `codegraph_search` |
| Focus an area | `codegraph_context` |
| Inspect callers | `codegraph_callers` |
| Inspect callees | `codegraph_callees` |
| Estimate impact | `codegraph_impact` |
| Read source | `codegraph_node` or `codegraph_explore` |
| Check index | `codegraph_status` |
If CodeGraph reports that the project is not initialized, ask before running `codegraph init -i`.
## SDD MCP
Use SDD MCP for read-only orchestration context. The server is configured through `.mcp.json`, `.cursor/mcp.json`, or `.claude/settings.json` and runs `sdd mcp serve --root <project-root>`.
| Need | Tool or Resource |
|---|---|
| List trace events | `sdd_trace_list` |
| Inspect a run tree | `sdd_trace_show` or `sdd://trace/{run_id}` |
| Summarize execution evidence | `sdd_trace_summary` |
| Check artifact coverage | `sdd_artifact_status` |
| Build stage context | `sdd_context_build` or `sdd://context/{orchestration}/{stage}` |
| Check client surfaces | `sdd_clients_doctor` |
The canonical trace store remains local JSONL in `.sdd/events.jsonl`, `.sdd/subagents.jsonl`, `.sdd/task-gates.jsonl`, and `.sdd/runs.jsonl`. MCP reads and exposes that state; it is not the source of truth.
"#
.to_string()
}
fn claude_orchestration_command() -> String {
r#"---
description: Inicia o fluxo completo SDD pelo nome canônico `orchestration`.
argument-hint: "<ideia em linguagem natural | CARD-ID>"
model: claude-sonnet-4-6
---
Primeiro, resolva o contexto-esqueleto unificado (vale para card ID, bot ou texto livre):
```bash
sdd resolve-input "$ARGUMENTS"
```
Adote o `--name` canônico retornado e use-o em TODAS as etapas e comandos `sdd`; comece pela próxima etapa sugerida sem pular etapas; monte o contexto de cada etapa com `sdd context build --name "<name>" --stage <etapa>`; e não regenere etapas já aprovadas.
Em seguida, rode:
```bash
sdd orchestration "$ARGUMENTS"
```
Depois conduza o fluxo SDD completo usando subagents, skills e checkpoints humanos. `orchestrator` é apenas alias legado; use `orchestration` para comandos, docs e skills.
"#
.to_string()
}
fn orchestration_skill() -> String {
include_str!("../templates/client-skills/orchestration.md").to_string()
}
fn claude_orchestration_skill() -> String {
r#"---
name: orchestration
description: Execute or review the full SDD flow in Claude Code using the canonical `sdd orchestration` CLI harness. Use for SDD, orchestrator, Project Discovery, PRD, Tech Spec, Tasks, Execution, ADR, Review and Memory.
---
# SDD Orchestration
Use `orchestration` as the canonical command and skill name. Treat `orchestrator` as a legacy alias only.
## CLI preflight (obrigatório antes de qualquer etapa)
Verifique se `sdd` está disponível:
```bash
command -v sdd
```
- **Encontrado** → use `sdd ...` normalmente.
- **Não encontrado + projeto é o checkout fonte** (`Cargo.toml` com `name = "sdd-layer"`) → use `cargo run --bin sdd -- ...`.
- **Ambos falham (Helmor, CI, container sem cargo)** → **fallback manual obrigatório**: antes de iniciar qualquer etapa, crie os arquivos do Passo 0 manualmente:
1. `mkdir -p docs/<slug>/`
2. Crie `docs/<slug>/README.md` com `# SDD - <nome>\n\nArtefatos locais desta orquestração.`
3. Crie `docs/<slug>/traceability-map.yaml` com o esqueleto canônico (orchestration + artifacts com todos os stages em `state: pending` + external_links vazios).
4. Crie `docs/<slug>/00-project-discovery.md` com o contexto do projeto (stack, comandos, convenções, integrações).
5. Crie `docs/<slug>/00-risk-classification.md` com nível de risco, decisões de processo e evidências mínimas.
6. Só então inicie `01-idea.md` e as etapas seguintes.
> **Nunca pule o Passo 0.** Os 5 arquivos acima são pré-condição para rastreabilidade completa, independente do CLI estar disponível.
## Fluxo principal
First, resolve the unified context skeleton (works for card ID, bot or free text):
```bash
sdd resolve-input "$ARGUMENTS"
```
Reuse the canonical `--name` it returns across every stage and `sdd` command, start from the suggested next stage, build each stage context with `sdd context build --name "<name>" --stage <stage>`, and do not regenerate approved stages.
Then run:
```bash
sdd orchestration "$ARGUMENTS"
```
Then conduct the full SDD flow with subagents and human checkpoints. The CLI owns deterministic state, risk, artifact persistence, validation and hooks.
After Execution, create or update ADRs with `sdd adr` when the implementation introduced, confirmed or changed an architectural decision.
PRD and Tech Spec artifacts must include `## Diagramas` with useful Mermaid/Excalidraw references, or `Não aplicável` with a short reason. Tasks must include `## Prompts Agent` with standalone provider-neutral prompts using executable IDs such as `T-01` or `TSK-01`.
Required checkpoints:
- PRD
- Tech Spec
- Refinement when risk requires it
- Review/merge
- Deploy when applicable
Save approved artifacts with:
```bash
sdd artifact save "<nome-da-orquestracao>" <stage> --file <arquivo> --state approved
```
"#
.to_string()
}
fn trae_orchestration_command() -> String {
r#"---
description: Inicia o fluxo completo SDD pelo nome canônico `orchestration`.
argument-hint: "<ideia em linguagem natural | CARD-ID>"
model: claude-sonnet-4-6
---
Primeiro, resolva o contexto-esqueleto unificado (vale para card ID, bot ou texto livre):
```bash
sdd resolve-input "$ARGUMENTS"
```
Adote o `--name` canônico retornado e use-o em TODAS as etapas e comandos `sdd`; comece pela próxima etapa sugerida sem pular etapas; monte o contexto de cada etapa com `sdd context build --name "<name>" --stage <etapa>`; e não regenere etapas já aprovadas.
Em seguida, rode:
```bash
sdd orchestration "$ARGUMENTS"
```
Se o shell do Trae não encontrar `sdd`, não procure outro entrypoint ainda: confirme `command -v sdd`; no checkout fonte `sdd-layer`, rode o mesmo comando via `cargo run --bin sdd -- orchestration "$ARGUMENTS"`. Fora do repo fonte, reporte que o binário não está instalado ou que `$HOME/.cargo/bin` precisa entrar no `PATH`.
Antes de produzir qualquer artefato de etapa, defina o nome da orquestração e garanta o artifact store local com:
```bash
sdd init "<nome-da-orquestracao>"
```
Depois conduza Project Discovery, Risk Classification, Idea, PRD, Tech Spec, Tasks, Refinement quando exigido, Execution, ADR, Review e Memory. Salve cada etapa em `docs/<slug-da-orquestracao>/` com `sdd artifact save`; use `approved` somente depois do checkpoint humano e `recorded` para etapas informativas.
`orchestrator` é apenas alias legado; use `orchestration` para comandos, docs e skills.
"#
.to_string()
}
fn trae_orchestration_skill() -> String {
append_stage_output_catalog(r#"---
name: orchestration
description: Execute or review the full SDD flow in Trae using the canonical `sdd orchestration` CLI harness. Use for SDD, orchestrator, Project Discovery, PRD, Tech Spec, Tasks, Execution, ADR, Review and Memory.
---
# SDD Orchestration
Use `orchestration` as the canonical command and skill name. Treat `orchestrator` as a legacy alias only.
First, resolve the unified context skeleton (works for card ID, bot or free text):
```bash
sdd resolve-input "$ARGUMENTS"
```
Reuse the canonical `--name` it returns across every stage and `sdd` command, start from the suggested next stage, build each stage context with `sdd context build --name "<name>" --stage <stage>`, and do not regenerate approved stages.
Then run:
```bash
sdd orchestration "$ARGUMENTS"
```
If Trae's shell cannot find `sdd`, do not search for another entrypoint yet. Check `command -v sdd`; in the `sdd-layer` source checkout, run the same command through `cargo run --bin sdd -- orchestration "$ARGUMENTS"`. Outside the source repo, report that the binary is not installed or `$HOME/.cargo/bin` is missing from `PATH`.
The CLI owns deterministic state, risk, artifact persistence, validation and hooks. Before creating stage artifacts, initialize the local artifact store:
```bash
sdd init "<nome-da-orquestracao>"
```
All stage artifacts must live in `docs/<slug-da-orquestracao>/` when external integrations are absent or partial. Use `sdd artifact save` with `recorded` for informational stages and `approved` only after explicit human checkpoints.
Required checkpoints:
- PRD
- Tech Spec
- Refinement when risk requires it
- Review/merge
- Deploy when applicable
After Execution, create or update ADRs with `sdd adr` when the implementation introduced, confirmed or changed an architectural decision.
"#
.to_string())
}
fn trae_principles_rule() -> String {
r#"# Principios do fluxo SDD
- Use `orchestration` como nome canônico; `orchestrator` é apenas alias legado.
- Antes de criar artefatos de etapa, rode `sdd init "<nome-da-orquestracao>"` para criar `docs/<slug-da-orquestracao>/` e `traceability-map.yaml`.
- Quando Jira, Confluence, Git provider ou outro sistema externo estiver ausente ou parcial, `docs/<slug-da-orquestracao>/` é a fonte canônica local.
- Fluxo: Project Discovery, Risk Classification, Idea, PRD, Tech Spec, Tasks, Refinement quando exigido, Execution, ADR pos-execução, Review e Memory.
- Não pule checkpoints humanos: PRD, Tech Spec, Refinement quando exigido, Review/merge e Deploy quando aplicável.
- Salve etapas informativas como `recorded`; salve PRD, Tech Spec, Refinement e Review como `approved` somente depois da aprovação humana.
"#
.to_string()
}
fn trae_output_conventions_rule() -> String {
append_stage_output_catalog(r#"# Convencoes de saída SDD
- Responda em portugues, com caminhos locais salvos e próxima ação objetiva.
- Todo artefato principal deve ter `Rastreabilidade`.
- Todo artefato aprovado deve citar o caminho local salvo em `docs/<slug-da-orquestracao>/`.
- Se o artifact store ainda não existir, inicialize com `sdd init "<nome-da-orquestracao>"` antes de prosseguir.
- Use `sdd artifact save "<nome-da-orquestracao>" <stage> --file <arquivo> --state recorded` para etapas informativas.
- Use `sdd artifact save "<nome-da-orquestracao>" <stage> --file <arquivo> --state approved` somente após checkpoint humano explicito.
- Valide artefatos materializados com `sdd validate-artifact <tipo> <arquivo.md>` quando o schema existir.
"#
.to_string())
}
fn cursor_rule() -> String {
append_stage_output_catalog(r#"---
description: Fluxo SDD canônico para Cursor Agent
globs:
alwaysApply: true
---
Use o fluxo Spec-Driven Development deste projeto.
- Nome canônico: `orchestration`.
- Alias legado: `orchestrator`, somente para retrocompatibilidade.
- Preparacao: `sdd discover`, `sdd risk`, `sdd doctor`.
- Fluxo completo: `sdd orchestration "<ideia>"`.
- ADR: após Execution, rode `sdd adr` quando houver decisão arquitetural criada, confirmada ou alterada.
- Persistencia: antes de criar artefatos de etapa, rode `sdd init "<nome-da-orquestracao>"` para criar `docs/<slug-da-orquestracao>/` e `traceability-map.yaml`.
- Salve cada etapa com `sdd artifact save`; use `approved` somente depois do checkpoint humano e `recorded` para etapas informativas.
- Checkpoints humanos obrigatórios: PRD, Tech Spec, Refinement quando risco exigir, Review/merge e Deploy quando aplicável.
- Ao trabalhar em uma tecnologia específica, consulte o Project Discovery e as recomendações de skills/rules geradas em `00-project-discovery.md`.
## Contrato de persistência no Cursor
- Use o binário `sdd` instalado no `PATH` para conduzir orquestrações; não troque por `cargo run` quando a tarefa for operar um projeto SDD já instalado.
- Defina um nome curto de ciclo e rode `sdd init "<nome-da-orquestracao>"` antes de sintetizar PRD, Tech Spec, Tasks ou qualquer artefato longo.
- Em seguida rode `sdd orchestration --name "<nome-da-orquestracao>" "<ideia>"` para registrar README e risco inicial em `docs/<slug-da-orquestracao>/`.
- Materialize cada etapa no arquivo esperado em `docs/<slug-da-orquestracao>/<NN>-<stage>.md` antes de mostrar o conteúdo final no chat.
- Depois de materializar, registre o estado com `sdd artifact save "<nome-da-orquestracao>" <stage> --file docs/<slug-da-orquestracao>/<NN>-<stage>.md --state recorded` para etapas informativas, ou `--state approved` somente após checkpoint humano explícito.
- Se o terminal, hook ou permissão do cliente bloquear `sdd` ou escrita em `docs/`, pare e informe o comando/path bloqueado; não entregue o artefato final apenas na sessão do chat.
"#
.to_string())
}
fn opencode_sdd_command() -> String {
r#"---
description: Harness principal SDD via CLI
agent: sdd-orchestrator
---
Execute o subcomando SDD solicitado em `$ARGUMENTS`.
Regras:
- Use `sdd orchestration` como nome canônico.
- Use `sdd orchestrator` somente quando precisar manter compatibilidade com instruções antigas.
- Use `sdd update` quando o projeto ja tiver `.sdd/` instalado e precisar receber mudanças do pacote.
- Depois de `execution`, use `sdd adr` quando houver decisão arquitetural criada, confirmada ou alterada.
- Preserve checkpoints humanos e artifact store local.
"#
.to_string()
}
fn opencode_orchestration_command() -> String {
r#"---
description: Roda o fluxo completo SDD
agent: sdd-orchestrator
---
Primeiro, resolva o contexto-esqueleto unificado (vale para card ID, bot ou texto livre):
```bash
sdd resolve-input "$ARGUMENTS"
```
Adote o `--name` canônico retornado e use-o em TODAS as etapas e comandos `sdd`; comece pela próxima etapa sugerida sem pular etapas; monte o contexto de cada etapa com `sdd context build --name "<name>" --stage <etapa>`; e não regenere etapas já aprovadas.
Em seguida, rode:
```bash
sdd orchestration "$ARGUMENTS"
```
Em seguida conduza Project Discovery, Risk Classification, Idea, PRD, Tech Spec, Tasks, Refinement, Execution, ADR, Review e Memory, salvando checkpoints com `sdd artifact save`.
"#
.to_string()
}
fn opencode_agent() -> String {
append_stage_output_catalog(r#"---
description: Orquestra o fluxo SDD sem pular checkpoints humanos
mode: primary
permission:
edit: ask
bash:
"*": ask
"sdd *": allow
"git status*": allow
"git diff*": allow
"rg *": allow
"find *": allow
---
Você e o agente SDD principal. Use `sdd` como harness determinístico para estado, risco, artefatos, validação e guardrails. Use `orchestration` como nome canônico do fluxo completo e trate `orchestrator` como alias legado.
"#
.to_string())
}
fn antigravity_rule() -> String {
append_stage_output_catalog(r#"# SDD Workspace Rule
Use esta regra quando o usuário pedir SDD, spec-driven development, discovery, PRD, Tech Spec, tasks, execution, ADR, review ou memory.
- Nome canônico: `orchestration`.
- Alias legado: `orchestrator`.
- Preparacao: `sdd discover`, `sdd risk`, `sdd doctor`.
- Fluxo completo: `sdd orchestration "<ideia>"`.
- ADR: após Execution, rode `sdd adr` para criar ou atualizar decisões arquiteturais com evidências.
- Checkpoints humanos: PRD, Tech Spec, Refinement quando risco exigir, merge/review e deploy.
- Persistencia local: `docs/<slug-da-orquestracao>/`.
- Skills compartilhadas: `.agents/skills/`.
"#
.to_string())
}
struct TechRecommendation {
id: &'static str,
title: &'static str,
signals: Vec<String>,
skill: &'static str,
rule: &'static str,
}
fn technology_recommendations(root: &Path) -> Vec<TechRecommendation> {
let mut items = Vec::new();
let package = read_optional(root.join("package.json")).unwrap_or_default();
let package_lower = package.to_lowercase();
let pyproject = read_optional(root.join("pyproject.toml")).unwrap_or_default();
let pyproject_lower = pyproject.to_lowercase();
let requirements = read_optional(root.join("requirements.txt")).unwrap_or_default();
let requirements_lower = requirements.to_lowercase();
let cargo = read_optional(root.join("Cargo.toml")).unwrap_or_default();
let cargo_lower = cargo.to_lowercase();
let go_mod = read_optional(root.join("go.mod")).unwrap_or_default();
let go_mod_lower = go_mod.to_lowercase();
let pom_lower = read_optional(root.join("pom.xml"))
.unwrap_or_default()
.to_lowercase();
let gradle_lower = [
read_optional(root.join("build.gradle")).unwrap_or_default(),
read_optional(root.join("build.gradle.kts")).unwrap_or_default(),
]
.join("\n")
.to_lowercase();
let gemfile_lower = read_optional(root.join("Gemfile"))
.unwrap_or_default()
.to_lowercase();
let composer_lower = read_optional(root.join("composer.json"))
.unwrap_or_default()
.to_lowercase();
let pubspec_lower = read_optional(root.join("pubspec.yaml"))
.unwrap_or_default()
.to_lowercase();
let python_lower = format!("{pyproject_lower}\n{requirements_lower}");
let jvm_lower = format!("{pom_lower}\n{gradle_lower}");
if is_monorepo(root) {
items.push(TechRecommendation {
id: "monorepo-context",
title: "Monorepo context routing",
signals: vec!["workspaces/apps-packages".to_string()],
skill: ".agents/skills/monorepo-context/SKILL.md",
rule: ".cursor/rules/monorepo-context.mdc",
});
}
if contains_any(&package_lower, &["react-native", "\"expo\"", "expo-router"]) {
items.push(TechRecommendation {
id: "react-native-mobile",
title: "React Native/Expo mobile conventions",
signals: vec!["package.json react-native/expo".to_string()],
skill: ".agents/skills/react-native-mobile/SKILL.md",
rule: ".cursor/rules/react-native-mobile.mdc",
});
}
if is_frontend_react(root) {
items.push(TechRecommendation {
id: "react-frontend",
title: "React/Next/Vite frontend conventions",
signals: vec!["package.json react/next/vite".to_string()],
skill: ".agents/skills/react-frontend/SKILL.md",
rule: ".cursor/rules/react-frontend.mdc",
});
}
if contains_any(
&package_lower,
&[
"\"vue\"",
"\"nuxt\"",
"\"svelte\"",
"\"@angular/core\"",
"\"astro\"",
"\"solid-js\"",
],
) {
items.push(TechRecommendation {
id: "js-frontend",
title: "JavaScript frontend conventions",
signals: vec!["package.json frontend framework".to_string()],
skill: ".agents/skills/js-frontend/SKILL.md",
rule: ".cursor/rules/js-frontend.mdc",
});
}
if is_hono_api(root) {
items.push(TechRecommendation {
id: "hono-api",
title: "Hono API contracts",
signals: vec!["package.json hono".to_string()],
skill: ".agents/skills/hono-api/SKILL.md",
rule: ".cursor/rules/hono-api.mdc",
});
} else if contains_any(
&package_lower,
&[
"\"express\"",
"\"fastify\"",
"\"@nestjs/",
"\"koa\"",
"\"trpc\"",
"\"apollo-server\"",
"\"graphql-yoga\"",
],
) {
items.push(TechRecommendation {
id: "node-backend",
title: "Node backend conventions",
signals: vec!["package.json backend framework".to_string()],
skill: ".agents/skills/node-backend/SKILL.md",
rule: ".cursor/rules/node-backend.mdc",
});
} else if is_node(root) {
items.push(TechRecommendation {
id: "node-tooling",
title: "Node package/tooling conventions",
signals: vec!["package.json".to_string()],
skill: ".agents/skills/node-tooling/SKILL.md",
rule: ".cursor/rules/node-tooling.mdc",
});
}
if is_python_api(root) {
if contains_any(&python_lower, &["django"]) {
items.push(TechRecommendation {
id: "python-django",
title: "Django project conventions",
signals: vec!["python manifest django".to_string()],
skill: ".agents/skills/python-django/SKILL.md",
rule: ".cursor/rules/python-django.mdc",
});
} else if contains_any(&python_lower, &["fastapi", "flask", "litestar", "starlite"]) {
let mut signals = vec!["python manifest".to_string()];
if python_lower.contains("fastapi") {
signals.push("fastapi".to_string());
}
if python_lower.contains("flask") {
signals.push("flask".to_string());
}
items.push(TechRecommendation {
id: "python-api",
title: "Python API conventions",
signals,
skill: ".agents/skills/python-api/SKILL.md",
rule: ".cursor/rules/python-api.mdc",
});
} else {
items.push(TechRecommendation {
id: "python-project",
title: "Python project conventions",
signals: vec!["python manifest".to_string()],
skill: ".agents/skills/python-project/SKILL.md",
rule: ".cursor/rules/python-project.mdc",
});
}
}
if root.join("Cargo.toml").exists() {
if is_rust_api(root) {
let signal = if cargo_lower.contains("axum") {
"cargo axum"
} else if cargo_lower.contains("actix-web") {
"cargo actix-web"
} else if cargo_lower.contains("tonic") {
"cargo tonic"
} else {
"cargo api framework"
};
items.push(TechRecommendation {
id: "rust-api",
title: "Rust API conventions",
signals: vec![signal.to_string()],
skill: ".agents/skills/rust-api/SKILL.md",
rule: ".cursor/rules/rust-api.mdc",
});
} else {
items.push(TechRecommendation {
id: "rust-project",
title: "Rust project conventions",
signals: vec!["Cargo.toml".to_string()],
skill: ".agents/skills/rust-project/SKILL.md",
rule: ".cursor/rules/rust-project.mdc",
});
}
}
if root.join("go.mod").exists() {
let mut signals = vec!["go.mod".to_string()];
if contains_any(&go_mod_lower, &["gin-gonic/gin", "gofiber/fiber", "grpc"]) {
signals.push("go api framework".to_string());
}
items.push(TechRecommendation {
id: "go-project",
title: "Go project conventions",
signals,
skill: ".agents/skills/go-project/SKILL.md",
rule: ".cursor/rules/go-project.mdc",
});
}
if has_any_marker(
root,
&["pom.xml", "build.gradle", "build.gradle.kts", "gradlew"],
) {
let title = if contains_any(&jvm_lower, &["spring-boot", "springframework.boot"]) {
"Spring/JVM service conventions"
} else {
"JVM project conventions"
};
items.push(TechRecommendation {
id: "jvm-project",
title,
signals: vec!["pom.xml/build.gradle".to_string()],
skill: ".agents/skills/jvm-project/SKILL.md",
rule: ".cursor/rules/jvm-project.mdc",
});
}
if root.join("Gemfile").exists() {
let title = if gemfile_lower.contains("rails") {
"Rails project conventions"
} else {
"Ruby project conventions"
};
items.push(TechRecommendation {
id: "ruby-project",
title,
signals: vec!["Gemfile".to_string()],
skill: ".agents/skills/ruby-project/SKILL.md",
rule: ".cursor/rules/ruby-project.mdc",
});
}
if root.join("composer.json").exists() {
let title = if composer_lower.contains("laravel") {
"Laravel/PHP conventions"
} else {
"PHP project conventions"
};
items.push(TechRecommendation {
id: "php-project",
title,
signals: vec!["composer.json".to_string()],
skill: ".agents/skills/php-project/SKILL.md",
rule: ".cursor/rules/php-project.mdc",
});
}
if has_root_entry_with_extension(root, &["sln", "csproj"])
|| has_nested_extension(root, &["csproj"])
{
items.push(TechRecommendation {
id: "dotnet-project",
title: ".NET project conventions",
signals: vec![".sln/.csproj".to_string()],
skill: ".agents/skills/dotnet-project/SKILL.md",
rule: ".cursor/rules/dotnet-project.mdc",
});
}
if pubspec_lower.contains("flutter") {
items.push(TechRecommendation {
id: "flutter-mobile",
title: "Flutter mobile conventions",
signals: vec!["pubspec.yaml flutter".to_string()],
skill: ".agents/skills/flutter-mobile/SKILL.md",
rule: ".cursor/rules/flutter-mobile.mdc",
});
}
if has_nested_marker(root, &["AndroidManifest.xml"])
|| (has_any_marker(root, &["settings.gradle", "settings.gradle.kts"])
&& has_any_path(root, &["app/src/main"]))
{
items.push(TechRecommendation {
id: "android-mobile",
title: "Android mobile conventions",
signals: vec!["AndroidManifest/settings.gradle".to_string()],
skill: ".agents/skills/android-mobile/SKILL.md",
rule: ".cursor/rules/android-mobile.mdc",
});
}
if has_root_entry_with_extension(root, &["xcodeproj", "xcworkspace"])
|| (root.join("ios").is_dir() && has_any_marker(root, &["Podfile", "Package.swift"]))
{
items.push(TechRecommendation {
id: "ios-mobile",
title: "iOS mobile conventions",
signals: vec!["Xcode/Podfile/Package.swift".to_string()],
skill: ".agents/skills/ios-mobile/SKILL.md",
rule: ".cursor/rules/ios-mobile.mdc",
});
}
if is_infra(root) {
items.push(TechRecommendation {
id: "infra-deploy",
title: "Infrastructure and deploy guardrails",
signals: vec!["infra/terraform/k8s".to_string()],
skill: ".agents/skills/infra-deploy/SKILL.md",
rule: ".cursor/rules/infra-deploy.mdc",
});
}
if has_any_path(
root,
&[
".github/workflows",
".gitlab-ci.yml",
"bitbucket-pipelines.yml",
".circleci",
"Jenkinsfile",
],
) {
items.push(TechRecommendation {
id: "ci-release",
title: "CI and release readiness",
signals: vec!["CI config".to_string()],
skill: ".agents/skills/ci-release/SKILL.md",
rule: ".cursor/rules/ci-release.mdc",
});
}
if has_any_path(
root,
&[
"migrations",
"db/migrate",
"prisma/migrations",
"alembic",
"drizzle",
],
) || contains_any(
&package_lower,
&[
"\"prisma\"",
"\"drizzle-orm\"",
"\"typeorm\"",
"\"sequelize\"",
"\"knex\"",
],
) {
items.push(TechRecommendation {
id: "data-contracts-local",
title: "Database and migration contracts",
signals: vec!["migrations/ORM".to_string()],
skill: ".agents/skills/data-contracts-local/SKILL.md",
rule: ".cursor/rules/data-contracts-local.mdc",
});
}
if has_nested_marker(
root,
&[
"openapi.yaml",
"openapi.yml",
"openapi.json",
"schema.graphql",
],
) || has_nested_extension(root, &["proto"])
|| contains_any(
&package_lower,
&["\"graphql\"", "\"@apollo/", "\"openapi\""],
)
{
items.push(TechRecommendation {
id: "api-contracts",
title: "API contract conventions",
signals: vec!["OpenAPI/GraphQL/protobuf".to_string()],
skill: ".agents/skills/api-contracts/SKILL.md",
rule: ".cursor/rules/api-contracts.mdc",
});
}
if contains_any(
&python_lower,
&[
"pandas",
"numpy",
"scikit",
"sklearn",
"tensorflow",
"torch",
"jupyter",
],
) || has_nested_extension(root, &["ipynb"])
{
items.push(TechRecommendation {
id: "data-ml",
title: "Data and ML project conventions",
signals: vec!["data/ML libraries or notebooks".to_string()],
skill: ".agents/skills/data-ml/SKILL.md",
rule: ".cursor/rules/data-ml.mdc",
});
}
if contains_any(
&package_lower,
&[
"\"storybook\"",
"\"@storybook/",
"\"tailwindcss\"",
"\"@radix-ui/",
"\"shadcn\"",
"\"@mui/",
"\"@chakra-ui/",
],
) || has_any_path(root, &["docs/design", "design-system", "src/components"])
{
items.push(TechRecommendation {
id: "design-system",
title: "Design system and UI conventions",
signals: vec!["design system/UI components".to_string()],
skill: ".agents/skills/design-system/SKILL.md",
rule: ".cursor/rules/design-system.mdc",
});
}
if has_any_marker(
root,
&[
".env.example",
".env.sample",
"appsettings.json",
"wrangler.toml",
"vercel.json",
"netlify.toml",
],
) {
items.push(TechRecommendation {
id: "secrets-config",
title: "Secrets and runtime config handling",
signals: vec!["env/config example".to_string()],
skill: ".agents/skills/secrets-config/SKILL.md",
rule: ".cursor/rules/secrets-config.mdc",
});
}
if package_lower.contains("vitest")
|| package_lower.contains("jest")
|| package_lower.contains("playwright")
|| package_lower.contains("cypress")
|| python_lower.contains("pytest")
|| cargo_lower.contains("rstest")
|| root.join("tests").is_dir()
|| root.join("e2e").is_dir()
|| root.join("playwright.config.ts").exists()
|| root.join("pytest.ini").exists()
{
items.push(TechRecommendation {
id: "test-strategy-local",
title: "Local test strategy",
signals: vec!["test framework detected".to_string()],
skill: ".agents/skills/local-test-strategy/SKILL.md",
rule: ".cursor/rules/local-test-strategy.mdc",
});
}
if items.is_empty() {
items.push(TechRecommendation {
id: "generic-project",
title: "Generic project conventions",
signals: vec!["no specialized stack signal found".to_string()],
skill: ".agents/skills/project-conventions/SKILL.md",
rule: ".cursor/rules/project-conventions.mdc",
});
}
items
}
fn render_context_recommendations(root: &Path, name: Option<&str>) -> String {
let mut out = String::new();
if let Some(name) = name {
out.push_str(&format!("- Orquestração: {name}\n"));
}
out.push_str("- Objetivo: documentar próximas skills/rules/contextos específicos do projeto depois do discovery.\n");
out.push_str("- Uso: trate cada linha como recomendação baseada em sinais do repositório; confirme no Project Discovery antes de materializar.\n\n");
out.push_str("| Tecnologia/contexto | Sinais | Skill sugerida | Rule sugerida | Contexto AGENTS/CLAUDE |\n");
out.push_str("|---|---|---|---|---|\n");
for item in technology_recommendations(root) {
out.push_str(&format!(
"| {} ({}) | {} | `{}` | `{}` | Adicionar seção curta em `AGENTS.md` e, se houver divergência Claude, em `CLAUDE.md`. |\n",
item.title,
item.id,
item.signals.join(", "),
item.skill,
item.rule
));
}
out.push_str("\n## Capability map\n");
out.push_str(
"| Capability | Fonte | Gatilho | Stages | Skill local | Modo | Risco | Fallback |\n",
);
out.push_str("|---|---|---|---|---|---|---|---|\n");
for item in capability_recommendations(root) {
out.push_str(&format!(
"| {} | [{}]({}) | {} | {} | `{}` | {} | {} | {} |\n",
item.title,
item.source,
item.source_url,
item.trigger,
item.stages.join(", "),
item.local_skill,
item.mode,
item.risk,
item.fallback
));
}
out.push_str("\n## Como materializar\n");
out.push_str("- Crie skills em `.agents/skills/<nome>/SKILL.md` para conhecimento acionado por tarefa.\n");
out.push_str("- Espelhe para `.claude/skills/<nome>/SKILL.md` quando quiser invocação direta no Claude Code.\n");
out.push_str("- Crie rules em `.cursor/rules/*.mdc` para Cursor e em `.agents/rules/*.md` para Antigravity.\n");
out.push_str("- Mantenha `AGENTS.md` como contexto portável e `CLAUDE.md` apenas para diferenças do Claude Code.\n");
out.push_str("- Em cada skill, mantenha `SKILL.md` curto: gatilho claro, procedimento, evidências esperadas e links para referências opcionais.\n");
out.push_str(
"- Não registre segredos; cite variáveis, arquivos e comandos com valores redigidos.\n",
);
out.push_str("- Reexecute `sdd context recommend --write` quando a stack ou os comandos do projeto mudarem.\n");
out
}
struct DoctorReport {
failures: Vec<String>,
warnings: Vec<String>,
}
fn doctor(root: &Path) -> DoctorReport {
let mut failures = Vec::new();
let mut warnings = Vec::new();
let strict_contract = is_layer_source_repo(root);
let required_paths = [
"AGENTS.md",
"CLAUDE.md",
"sdd.config.yaml",
".claude/agents",
".claude/commands",
".agents/skills",
".codex/agents",
];
for rel in required_paths {
if !root.join(rel).exists() {
failures.push(format!("missing {rel}"));
}
}
let layer = layer_root(root);
for rel in [
"schemas/sdd-contract.yaml",
"schemas/sdd-contract.schema.json",
"schemas/artifact-sections.json",
"schemas/provider-config.schema.json",
"templates/artifact-index.yaml",
"templates/traceability-map.yaml",
"templates/client-skills/orchestration.md",
"adapters/markdown-only.yaml",
] {
if !layer.join(rel).exists() {
failures.push(format!("missing {rel} in {}", layer.display()));
}
}
for preset in PRESET_NAMES {
let rel = format!("presets/{preset}.yaml");
if !layer.join(&rel).exists() {
failures.push(format!("missing {rel} in {}", layer.display()));
}
}
let config = fs::read_to_string(root.join("sdd.config.yaml")).unwrap_or_default();
for key in [
"project",
"systems",
"paths",
"commands",
"quality",
"risk_policy",
"adapters",
"artifact_store",
] {
if !config
.lines()
.any(|line| line.trim_start().starts_with(&format!("{key}:")))
{
failures.push(format!("sdd.config.yaml missing top-level key: {key}"));
}
}
for key in ["providers", "runtime", "memory"] {
if !config
.lines()
.any(|line| line.trim_start().starts_with(&format!("{key}:")))
{
warnings.push(format!(
"sdd.config.yaml missing optional top-level key: {key}"
));
}
}
if let Ok(parsed_config) = load_sdd_config(root) {
if parsed_config.runtime.node_effect_bridge.enabled && which("node").is_none() {
warnings.push(
"runtime.node_effect_bridge.enabled is true but node was not found in PATH"
.to_string(),
);
}
}
match fs::read_to_string(layer.join("schemas/artifact-sections.json")) {
Ok(text) => match serde_json::from_str::<Value>(&text) {
Ok(schema) => {
for artifact in contract::artifact_required_section_map().keys() {
if schema.get(*artifact).is_none() {
failures.push(format!("artifact schema missing: {artifact}"));
}
}
}
Err(_) => failures.push("invalid artifact schema JSON".to_string()),
},
Err(_) => failures.push("invalid artifact schema JSON".to_string()),
}
let contract_issues = contract_drift_issues(root);
if strict_contract {
failures.extend(contract_issues);
} else {
warnings.extend(contract_issues);
}
let version_issues = version_alignment_issues(root);
if strict_contract {
failures.extend(version_issues);
} else {
warnings.extend(version_issues);
}
if which("git").is_none() {
warnings.push("git not found in PATH".to_string());
}
if which("sdd").is_none() {
warnings.push("sdd not found in PATH; cargo run works for development, but hooks expect the installed binary".to_string());
}
// Bot checks
let bot_dir = root.join("bot");
let bot_config = root.join(".sdd").join("bot").join("sdd-bot.config.yaml");
if bot_dir.exists() {
if !bot_config.exists() {
warnings.push("bot/ encontrado mas .sdd/bot/sdd-bot.config.yaml não existe — execute: sdd bot install".into());
}
// Check Node.js
if std::process::Command::new(if cfg!(windows) { "node.exe" } else { "node" })
.arg("--version")
.output()
.is_err()
{
warnings.push("bot/ encontrado mas Node.js não está instalado (requer >= 20)".into());
}
// Check pm2
if std::process::Command::new(if cfg!(windows) { "pm2.cmd" } else { "pm2" })
.arg("--version")
.output()
.is_err()
{
warnings.push("pm2 não encontrado — execute: npm install -g pm2".into());
}
// Check sessions.json consistency
let sessions_path = root.join(".sdd").join("bot").join("sessions.json");
if sessions_path.exists() {
if let Ok(content) = fs::read_to_string(&sessions_path) {
if serde_json::from_str::<serde_json::Value>(&content).is_err() {
failures.push("sessions.json inválido — remova e reinicie o bot".into());
}
}
}
// Check git remote authentication — o bot faz fetch/push automaticamente,
// então uma credencial inválida quebra a criação de worktree silenciosamente.
if which("git").is_some() {
let has_origin = std::process::Command::new("git")
.args(["remote", "get-url", "origin"])
.current_dir(root)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if has_origin {
let auth_ok = std::process::Command::new("git")
.args(["ls-remote", "--heads", "origin"])
.current_dir(root)
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !auth_ok {
warnings.push(
"autenticação do remote git falhou — o bot não conseguirá fetch/push. \
No Bitbucket, configure um API token com scopes: git remote set-url origin \
https://x-bitbucket-api-token-auth:<token>@bitbucket.org/<workspace>/<repo>.git"
.into(),
);
}
}
}
}
DoctorReport { failures, warnings }
}
fn print_doctor(report: &DoctorReport) {
println!("SDD doctor");
if report.failures.is_empty() {
println!("PASS");
} else {
println!("FAIL");
for item in &report.failures {
println!("- {item}");
}
}
if !report.warnings.is_empty() {
println!("Warnings");
for item in &report.warnings {
println!("- {item}");
}
}
}
fn which(command: &str) -> Option<PathBuf> {
runtime::platform::find_executable(command)
}
fn run_ci(root: &Path) -> Result<()> {
let json_files = [
".claude/settings.json",
".claude/skills/orchestration-plugin/hooks/hooks.json",
".claude/skills/orchestration-plugin/.claude-plugin/plugin.json",
"plugins/design/.codex-plugin/plugin.json",
"plugins/orchestration/.codex-plugin/plugin.json",
"schemas/sdd-contract.schema.json",
"schemas/artifact-sections.json",
];
for rel in json_files {
let path = root.join(rel);
if path.exists() {
println!("+ validate json {}", path.display());
let text = fs::read_to_string(&path)?;
serde_json::from_str::<Value>(&text)
.with_context(|| format!("invalid JSON {}", path.display()))?;
}
}
println!("+ sdd doctor");
let report = doctor(root);
print_doctor(&report);
if !report.failures.is_empty() {
bail!("doctor failed");
}
println!("+ sdd clients doctor");
let client_report = client_doctor(root);
print_client_doctor(&client_report);
if client_report.status != "pass" {
bail!("client doctor failed");
}
println!("+ sdd artifact init ci-smoke --dry-run");
init_store(root, "ci-smoke", false, true)?;
let checkpoint_fixture = root.join("tests/fixtures/good/checkpoint.md");
if checkpoint_fixture.exists() {
println!("+ sdd validate-artifact checkpoint tests/fixtures/good/checkpoint.md");
validate_artifact_command(ValidateArtifactArgs {
artifact_type: "checkpoint".to_string(),
markdown_file: checkpoint_fixture,
schema: root.join("schemas/artifact-sections.json"),
})?;
}
println!("+ sdd risk smoke");
let risk = classify("login SSO com permissao e dados pessoais")?;
if risk.level != "high" || !risk.factors.contains(&"data_sensitive") {
bail!("risk classifier smoke failed");
}
println!("sdd ci passed");
Ok(())
}
struct PackageInstallOptions<'a> {
target: &'a Path,
preset: &'a str,
layout: Layout,
force_files: bool,
force_config: bool,
dry_run: bool,
skip_docs: bool,
with_ci: bool,
with_examples: bool,
with_claude_plugin: bool,
include_bot_bundle: bool,
cleanup_legacy: bool,
merge_directories: bool,
}
#[derive(Clone, Copy)]
struct BotBundleDecision {
include_bundle: bool,
prompt_answer: Option<bool>,
}
fn bot_config_path(root: &Path) -> PathBuf {
root.join(".sdd").join("bot").join("sdd-bot.config.yaml")
}
fn bot_bundle_dir(root: &Path) -> PathBuf {
root.join("bot")
}
fn resolve_bot_bundle_decision(
target: &Path,
dry_run: bool,
prompt: &str,
) -> Result<BotBundleDecision> {
if dry_run || bot_config_path(target).exists() {
return Ok(BotBundleDecision {
include_bundle: true,
prompt_answer: None,
});
}
print!("◇ {prompt}");
std::io::stdout().flush()?;
let mut answer = String::new();
std::io::stdin().read_line(&mut answer)?;
if !io::stdin().is_terminal() || !answer.ends_with('\n') {
println!();
}
let accepted = answer.trim().eq_ignore_ascii_case("s");
rail_detail(if accepted { "Sim" } else { "Não" });
rail_blank();
Ok(BotBundleDecision {
include_bundle: accepted || bot_bundle_dir(target).exists(),
prompt_answer: Some(accepted),
})
}
fn print_bot_install_hint(target: &Path, dry_run: bool, decision: BotBundleDecision) {
if dry_run || bot_config_path(target).exists() {
return;
}
match decision.prompt_answer {
Some(true) => {
println!("{} Bot Slack", RailMarker::Filled.symbol());
rail_detail("execute `sdd bot install` para concluir a configuração");
rail_detail(&format!(
"ou: cd {} && sdd bot install",
runtime::platform::display_path(target)
));
rail_blank();
}
Some(false) => {
rail_node(
RailMarker::Hollow,
"Bot Slack",
Some("opcional; quando quiser instalar, execute `sdd bot install`"),
);
}
None => {}
}
}
fn install(args: InstallArgs) -> Result<()> {
let target = ensure_existing_dir(args.target, "target")?;
print_install_intro(
"SDD install",
&target,
&args.preset,
args.layout,
args.dry_run,
);
let bot_decision = resolve_bot_bundle_decision(
&target,
args.dry_run,
"Bot Slack opcional - instalar agora? (s/N): ",
)?;
let loader = CliLoader::start("Instalando camada SDD");
let report = run_install_at_target(
ResolvedInstallOptions {
target,
preset: args.preset,
layout: args.layout,
force_files: args.force,
force_config: args.force,
dry_run: args.dry_run,
skip_docs: args.skip_docs,
with_ci: args.with_ci,
with_examples: args.with_examples,
with_claude_plugin: args.with_claude_plugin,
cleanup_legacy: args.cleanup_legacy,
merge_directories: false,
},
bot_decision,
)?;
loader.finish("instalação concluída");
let timeline = bot_timeline_step(&report).into_iter().collect::<Vec<_>>();
let next_steps = vec![
format!("cd {}", runtime::platform::display_path(&report.target)),
"sdd doctor".to_string(),
"sdd init \"minha-orquestracao\"".to_string(),
];
print_install_report(
"Done! SDD install complete.",
&report,
Some("DRY RUN: no files were written. Remove --dry-run to install."),
timeline,
&[],
&next_steps,
);
Ok(())
}
fn update(args: UpdateArgs) -> Result<()> {
let target = ensure_existing_dir(resolve_project_root(args.root)?, "project root")?;
let installed_layout = detect_installed_layout(&target).ok_or_else(|| {
anyhow!(
"could not find an existing SDD installation in {}. Run `sdd init` or `sdd install` first.",
target.display()
)
})?;
let layout = args.layout.unwrap_or(installed_layout);
let preset = if args.preset == "auto" {
detect_preset(&target)
} else {
args.preset.clone()
};
let config_exists = target.join("sdd.config.yaml").exists();
print_install_intro("SDD update", &target, &preset, layout, args.dry_run);
let bot_decision = resolve_bot_bundle_decision(
&target,
args.dry_run,
"Bot Slack não instalado - instalar agora? (s/N): ",
)?;
let loader = CliLoader::start("Atualizando camada SDD");
let report = run_install_at_target(
ResolvedInstallOptions {
target,
preset,
layout,
force_files: true,
force_config: args.update_config,
dry_run: args.dry_run,
skip_docs: args.skip_docs,
with_ci: args.with_ci,
with_examples: args.with_examples,
with_claude_plugin: args.with_claude_plugin,
cleanup_legacy: args.cleanup_legacy,
merge_directories: true,
},
bot_decision,
)?;
loader.finish("atualização concluída");
let config_note = if args.update_config {
format!(
"Config: refreshed sdd.config.yaml from preset {}",
report.preset
)
} else if config_exists {
"Config: preserved existing sdd.config.yaml (use --update-config to refresh it from a preset)"
.to_string()
} else {
format!(
"Config: created sdd.config.yaml from preset {}",
report.preset
)
};
let mut timeline = Vec::new();
if let Some(step) = bot_timeline_step(&report) {
timeline.push(step);
}
timeline.push(TimelineStep::new("Configuração", Some(config_note)));
timeline.push(TimelineStep::new(
"Preservar artefatos locais",
Some("docs/<orchestration>".to_string()),
));
let next_steps = vec!["sdd doctor".to_string(), "sdd clients doctor".to_string()];
print_install_report(
"Done! SDD update complete.",
&report,
Some("DRY RUN: no files were written. Remove --dry-run to update."),
timeline,
&[],
&next_steps,
);
Ok(())
}
fn ensure_existing_dir(path: PathBuf, label: &str) -> Result<PathBuf> {
let resolved = runtime::platform::canonicalize(&path).unwrap_or(path);
if !resolved.exists() {
bail!("{label} does not exist: {}", resolved.display());
}
if !resolved.is_dir() {
bail!("{label} is not a directory: {}", resolved.display());
}
Ok(resolved)
}
fn detect_installed_layout(root: &Path) -> Option<Layout> {
let compact_layer = root.join(".sdd");
let compact_markers = [
"schemas",
"templates",
"presets",
"adapters",
"sdd.config.example.yaml",
]
.iter()
.any(|rel| compact_layer.join(rel).exists());
if compact_layer.is_dir() && compact_markers {
return Some(Layout::Compact);
}
let flat_marker_count = ["schemas", "templates", "presets", "adapters"]
.iter()
.filter(|rel| root.join(rel).exists())
.count();
if root.join("sdd.config.yaml").exists() && flat_marker_count >= 3 {
return Some(Layout::Flat);
}
None
}
fn install_package(options: PackageInstallOptions<'_>) -> Result<Vec<String>> {
let source = LayerSource::detect_for_target(options.target);
let policy = CopyPolicy {
force: options.force_files,
dry_run: options.dry_run,
include_claude_plugin: options.with_claude_plugin,
merge_directories: options.merge_directories,
};
let mut actions = Vec::new();
match options.layout {
Layout::Flat => {
for rel in FLAT_ITEMS
.iter()
.filter(|rel| !(options.skip_docs && **rel == "docs"))
.filter(|rel| options.include_bot_bundle || **rel != "bot")
{
actions.push(source.copy_item(
rel,
&options.target.join(rel),
ROOT_ITEMS.contains(rel),
policy,
)?);
}
}
Layout::Compact => {
for rel in ROOT_ITEMS
.iter()
.filter(|rel| options.include_bot_bundle || **rel != "bot")
{
actions.push(source.copy_item(rel, &options.target.join(rel), true, policy)?);
}
let layer = options.target.join(".sdd");
for rel in LAYER_ITEMS
.iter()
.filter(|rel| !(options.skip_docs && **rel == "docs"))
{
actions.push(source.copy_item(rel, &layer.join(rel), false, policy)?);
}
}
}
if options.with_ci {
let workflow = ".github/workflows/sdd.yml";
let tests_target = options
.target
.join(if matches!(options.layout, Layout::Flat) {
"tests"
} else {
".sdd/tests"
});
actions.push(source.copy_item("tests", &tests_target, false, policy)?);
if source.exists(workflow, true) {
actions.push(source.copy_item(
workflow,
&options.target.join(workflow),
true,
policy,
)?);
}
}
if options.with_examples {
let examples_target = options
.target
.join(if matches!(options.layout, Layout::Flat) {
"examples"
} else {
".sdd/examples"
});
actions.push(source.copy_item("examples", &examples_target, false, policy)?);
}
actions.push(install_config(
&source,
options.target,
options.preset,
options.force_config,
options.dry_run,
)?);
actions.extend(ensure_opencode_prompt_files(
options.target,
options.dry_run,
)?);
actions.extend(mcp_config_actions(
options.target,
&ClientTarget::all(),
options.force_files,
options.dry_run,
)?);
actions.extend(ensure_gitignore_entries(options.target, options.dry_run)?);
if matches!(options.layout, Layout::Compact) && options.cleanup_legacy {
actions.extend(cleanup_legacy_root(options.target, options.dry_run)?);
}
Ok(actions)
}
fn copy_dir(src: &Path, dst: &Path, include_claude_plugin: bool) -> Result<()> {
fs::create_dir_all(dst)?;
let is_docs_root = src.file_name().and_then(OsStr::to_str) == Some("docs");
let is_bot_root = src.file_name().and_then(OsStr::to_str) == Some("bot");
let is_cursor_root = src.file_name().and_then(OsStr::to_str) == Some(".cursor");
for entry in WalkDir::new(src).into_iter().filter_entry(|entry| {
let name = entry.file_name().to_string_lossy();
if matches!(name.as_ref(), ".DS_Store" | ".sdd" | ".git") {
return false;
}
// Nunca distribuir artefatos gerados do bot (node_modules, dist)
if is_bot_root
&& entry.depth() == 1
&& entry.file_type().is_dir()
&& BOT_EXCLUDE_DIRS.contains(&name.as_ref())
{
return false;
}
if is_docs_root && entry.depth() == 1 && entry.file_type().is_dir() {
return false;
}
if is_cursor_root && entry.depth() == 1 && entry.file_type().is_file() && name == "mcp.json"
{
return false;
}
if !include_claude_plugin
&& entry
.path()
.to_string_lossy()
.replace('\\', "/")
.contains(".claude/skills/orchestration-plugin")
{
return false;
}
true
}) {
let entry = entry?;
let rel = entry.path().strip_prefix(src)?;
if rel.as_os_str().is_empty() {
continue;
}
let target = dst.join(rel);
if entry.file_type().is_dir() {
fs::create_dir_all(target)?;
} else {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(entry.path(), target)?;
}
}
Ok(())
}
fn install_config(
source: &LayerSource,
target: &Path,
preset: &str,
force: bool,
dry_run: bool,
) -> Result<String> {
let config = target.join("sdd.config.yaml");
let preset_rel = format!("presets/{preset}.yaml");
if !source.exists(&preset_rel, false) {
bail!(
"unknown preset: {preset}. Available presets: {}",
PRESET_NAMES.join(", ")
);
}
if config.exists() && !force {
return Ok(format!("skip existing {}", config.display()));
}
if dry_run {
return Ok(format!(
"dry-run copy {} from preset {preset}",
config.display()
));
}
fs::write(&config, source.read_file(&preset_rel, false)?)?;
Ok(format!("copy {} from preset {preset}", config.display()))
}
fn cleanup_legacy_root(target: &Path, dry_run: bool) -> Result<Vec<String>> {
let mut actions = Vec::new();
for rel in ["sdd.config.example.yaml", "VERSION", "CHANGELOG.md"] {
let path = target.join(rel);
if path.exists() {
actions.push(format!("cleanup legacy {}", path.display()));
if !dry_run {
fs::remove_file(path)?;
}
}
}
for rel in [
"adapters",
"clients",
"presets",
"schemas",
"templates",
"examples",
"tests",
] {
let path = target.join(rel);
if path.is_dir() {
actions.push(format!("cleanup legacy {}", path.display()));
if !dry_run {
fs::remove_dir_all(path)?;
}
}
}
let scripts = target.join("scripts");
if scripts.is_dir() {
actions.push(format!("cleanup legacy {}", scripts.display()));
if !dry_run {
fs::remove_dir_all(scripts)?;
}
}
Ok(actions)
}
fn run_hook(command: HookCommand) -> Result<()> {
let payload = read_json_stdin().unwrap_or(Value::Null);
let code = match command {
HookCommand::ArtifactGate => hook_artifact_gate(&payload)?,
HookCommand::BashGuard => hook_bash_guard(&payload)?,
HookCommand::WriteGuard => hook_write_guard(&payload),
HookCommand::TaskGate => hook_task_gate(&payload)?,
HookCommand::TraceLog => {
hook_trace_log(&payload)?;
0
}
HookCommand::Notify => {
hook_notify(&payload)?;
0
}
HookCommand::SubagentAudit => {
hook_subagent_audit(&payload)?;
0
}
};
if code == 0 {
Ok(())
} else {
std::process::exit(code);
}
}
fn read_json_stdin() -> Result<Value> {
let mut input = String::new();
io::stdin().read_to_string(&mut input)?;
if input.trim().is_empty() {
Ok(Value::Null)
} else {
Ok(serde_json::from_str(&input)?)
}
}
fn project_dir_for_hook() -> PathBuf {
env::var("CLAUDE_PROJECT_DIR")
.or_else(|_| env::var("SDD_PROJECT_ROOT"))
.map(PathBuf::from)
.unwrap_or_else(|_| env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
}
pub(crate) fn log_jsonl(project_dir: &Path, file: &str, event: Value) -> Result<()> {
let log_dir = project_dir.join(".sdd");
fs::create_dir_all(&log_dir)?;
let mut fh = fs::OpenOptions::new()
.create(true)
.append(true)
.open(log_dir.join(file))?;
writeln!(fh, "{}", serde_json::to_string(&event)?)?;
Ok(())
}
fn hook_artifact_gate(payload: &Value) -> Result<i32> {
let Some(path) = compact_tool_path(payload) else {
return Ok(0);
};
if path.extension() != Some(OsStr::new("md")) {
return Ok(0);
}
// Só gateia artefatos do artifact store, identificados por nome de etapa
// canônico (`NN-stage.md`, ex.: `02-prd.md`, `00-risk-classification.md`).
// Evita falso-positivo em arquivos que não são artefatos SDD — CHANGELOG.md,
// README.md (inclusive o índice de `docs/<slug>/`), docs/USAGE.md etc. — que
// a heurística por conteúdo de `infer_artifact` poderia classificar errado.
// Para validar um arquivo avulso sob demanda, use `sdd validate-artifact`.
let is_canonical_artifact = path
.file_stem()
.and_then(OsStr::to_str)
.map(str::to_lowercase)
.and_then(|stem| contract::canonical_artifact_for_stem(&stem))
.flatten()
.is_some();
if !is_canonical_artifact {
return Ok(0);
}
let project_dir = project_dir_for_hook();
let path = if path.is_absolute() {
path
} else {
project_dir.join(path)
};
if !path.exists() {
return Ok(0);
}
let text = fs::read_to_string(&path).unwrap_or_default();
let Some(artifact) = infer_artifact(&path, &text) else {
return Ok(0);
};
let Some(schema_path) = resolve_hook_schema(&project_dir) else {
let _ = log_jsonl(
&project_dir,
"artifact-gates.jsonl",
json!({"ts": now(), "artifact": artifact, "path": path, "decision": "ignored", "reason": "schema not found"}),
);
return Ok(0);
};
let schema_text = fs::read_to_string(schema_path).unwrap_or_default();
let schema: Value = match serde_json::from_str(&schema_text) {
Ok(value) => value,
Err(_) => {
let _ = log_jsonl(
&project_dir,
"artifact-gates.jsonl",
json!({"ts": now(), "artifact": artifact, "path": path, "decision": "ignored", "reason": "schema invalid"}),
);
return Ok(0);
}
};
let report = validate_artifact_text(&artifact, &text, &schema)?;
if report.missing_sections.is_empty() && report.semantic_issues.is_empty() {
let _ = log_jsonl(
&project_dir,
"artifact-gates.jsonl",
json!({"ts": now(), "artifact": artifact, "path": path, "decision": "allowed", "reason": "artifact contract passed"}),
);
Ok(0)
} else {
let mut reasons = Vec::new();
if !report.missing_sections.is_empty() {
reasons.push(format!(
"missing required sections: {}",
report.missing_sections.join(", ")
));
}
if !report.semantic_issues.is_empty() {
reasons.push(format!(
"semantic validation failed: {}",
report.semantic_issues.join(" | ")
));
}
let reason = reasons.join("; ");
let _ = log_jsonl(
&project_dir,
"artifact-gates.jsonl",
json!({"ts": now(), "artifact": artifact, "path": path, "decision": "blocked", "reason": reason}),
);
eprintln!("SDD artifact gate: {} {reason}", path.display());
Ok(2)
}
}
fn compact_tool_path(payload: &Value) -> Option<PathBuf> {
let tool_input = payload.get("tool_input")?.as_object()?;
for key in ["file_path", "path"] {
if let Some(value) = tool_input.get(key).and_then(Value::as_str) {
return Some(PathBuf::from(value));
}
}
None
}
fn infer_artifact(path: &Path, text: &str) -> Option<String> {
// Filename-first: o artifact store escreve nomes determinísticos, então um nome
// canônico é autoritativo. Inspecionar o conteúdo classifica errado sempre que o
// nome da orquestração ou o corpo contém uma palavra-pista — ex.: uma orquestração
// chamada "Backlog" faria todo artefato casar com a pista de `tasks`.
if let Some(stem) = path.file_stem().and_then(OsStr::to_str) {
let stem = stem.to_lowercase();
if let Some(artifact) = contract::canonical_artifact_for_stem(&stem) {
// Nome canônico: confie nele e não caia na heurística de conteúdo.
return artifact.map(String::from);
}
}
// Nome não canônico: usa a heurística por substring sobre nome + início do conteúdo.
let haystack = format!(
"{} {}",
path.file_name().and_then(OsStr::to_str).unwrap_or_default(),
text.chars().take(300).collect::<String>()
)
.to_lowercase();
contract::inference_hints()
.into_iter()
.find_map(|(artifact, hints)| {
hints
.iter()
.any(|hint| haystack.contains(&hint.to_lowercase()))
.then(|| artifact.to_string())
})
}
fn resolve_hook_schema(project_dir: &Path) -> Option<PathBuf> {
[
project_dir.join("schemas/artifact-sections.json"),
project_dir.join(".sdd/schemas/artifact-sections.json"),
]
.into_iter()
.find(|path| path.exists())
}
fn hook_bash_guard(payload: &Value) -> Result<i32> {
let command = payload
.get("tool_input")
.and_then(|item| item.get("command"))
.and_then(Value::as_str)
.unwrap_or_default();
if command.is_empty() {
return Ok(0);
}
let deny_patterns = [
(r"\bgit\s+reset\s+--hard\b", "destructive git reset"),
(r"\bgit\s+checkout\s+--\b", "destructive git checkout"),
(r"\bgit\s+clean\b", "destructive git clean"),
(r"\brm\s+-[^\n;]*r[^\n;]*f\s+(/|\.|\*|~)", "destructive rm"),
(r"\bgit\s+push\b", "push requires a human release gate"),
(r"\bgit\s+merge\b", "merge requires the review checkpoint"),
(
r"\bgh\s+pr\s+merge\b",
"PR merge requires the review checkpoint",
),
(
r"\bwrangler\s+deploy\b",
"deploy requires the deploy checkpoint",
),
(
r"\bvercel\s+(--prod|deploy\s+--prod)\b",
"production deploy requires a checkpoint",
),
(
r"\bnetlify\s+deploy\s+--prod\b",
"production deploy requires a checkpoint",
),
(
r"\bkubectl\s+apply\b",
"cluster changes require a checkpoint",
),
(
r"\bterraform\s+apply\b",
"infra changes require a checkpoint",
),
(r"\bpulumi\s+up\b", "infra changes require a checkpoint"),
(
r"\bnpm\s+publish\b",
"publishing requires a release checkpoint",
),
];
for (pattern, reason) in deny_patterns {
if Regex::new(pattern)?.is_match(command) {
eprintln!("SDD guard: blocked `{command}` because {reason}.");
return Ok(2);
}
}
Ok(0)
}
fn hook_write_guard(payload: &Value) -> i32 {
let read_only_agents = [
"idea",
"prd",
"techspec",
"tasks",
"refinement",
"adr",
"review",
"memory",
"project-discovery",
"risk-classifier",
"architecture-reviewer",
"security-reviewer",
"performance-reviewer",
"test-reviewer",
"data-contract-reviewer",
"qa-strategist",
];
let write_tools = ["Write", "Edit", "MultiEdit"];
let tool_name = payload
.get("tool_name")
.and_then(Value::as_str)
.unwrap_or_default();
let agent = payload
.get("agent_type")
.or_else(|| payload.get("subagent_type"))
.and_then(Value::as_str)
.unwrap_or_default();
if write_tools.contains(&tool_name) && read_only_agents.contains(&agent) {
eprintln!(
"SDD guard: only the `execution` agent should write files. The `{agent}` agent is read-only in this pipeline."
);
return 2;
}
0
}
fn hook_task_gate(payload: &Value) -> Result<i32> {
if env::var("DISABLE_SDD_TASK_GATES").ok().as_deref() == Some("1") {
return Ok(0);
}
let event = payload
.get("hook_event_name")
.and_then(Value::as_str)
.unwrap_or_default();
let text = collect_relevant(payload, "")
.join("\n")
.trim()
.to_lowercase();
if text.is_empty() {
let _ = runtime::trace::record_event(
&project_dir_for_hook(),
"task-gates.jsonl",
json!({
"ts": now(),
"kind": "task_gate",
"event": event,
"status": "ignored",
"reason": "no task text in hook payload"
}),
);
return Ok(0);
}
if event == "TaskCreated" {
if !contains_any(
&text,
&[
"origem",
"prd",
"tech spec",
"techspec",
"refinement",
"task-",
],
) {
return reject_task_gate(
payload,
"new team tasks must reference origin: PRD, Tech Spec, Refinement or TASK id.",
);
}
if !contains_any(
&text,
&[
"gherkin",
"dado",
"quando",
"então",
"entao",
"definition of done",
"dod",
],
) {
return reject_task_gate(
payload,
"new team tasks must include acceptance criteria or Definition of Done.",
);
}
}
if event == "TaskCompleted"
&& !contains_any(
&text,
&[
"test",
"teste",
"ci",
"evid",
"arquivo",
"diff",
"rastreabilidade",
],
)
{
return reject_task_gate(
payload,
"completed tasks must report test/evidence/traceability before being closed.",
);
}
if event == "TeammateIdle"
&& !contains_any(
&text,
&["achado", "evid", "entrega", "recomend", "risco", "bloqueio"],
)
{
return reject_task_gate(
payload,
"idle teammates must summarize delivery, findings, risks or blockers.",
);
}
let _ = runtime::trace::record_event(
&project_dir_for_hook(),
"task-gates.jsonl",
json!({
"ts": now(),
"kind": "task_gate",
"event": event,
"status": "allowed",
"reason": "quality gate passed"
}),
);
Ok(0)
}
fn collect_relevant(value: &Value, parent_key: &str) -> Vec<String> {
let relevant = [
"task",
"task_description",
"description",
"content",
"message",
"summary",
"result",
"completion",
"prompt",
];
match value {
Value::Object(map) => map
.iter()
.flat_map(|(key, item)| {
if relevant.contains(&key.as_str()) || item.is_object() || item.is_array() {
collect_relevant(item, key)
} else {
Vec::new()
}
})
.collect(),
Value::Array(items) => items
.iter()
.flat_map(|item| collect_relevant(item, parent_key))
.collect(),
Value::String(text) if relevant.contains(&parent_key) => vec![text.clone()],
other if relevant.contains(&parent_key) && !other.is_null() => vec![other.to_string()],
_ => Vec::new(),
}
}
fn contains_any(text: &str, terms: &[&str]) -> bool {
terms.iter().any(|term| {
if term.len() <= 3 && term.is_ascii() && term.chars().all(|ch| ch.is_ascii_alphanumeric()) {
term_matches(text, term)
} else {
text.contains(term)
}
})
}
fn reject_task_gate(payload: &Value, reason: &str) -> Result<i32> {
let _ = runtime::trace::record_event(
&project_dir_for_hook(),
"task-gates.jsonl",
json!({
"ts": now(),
"kind": "task_gate",
"event": payload.get("hook_event_name"),
"status": "blocked",
"reason": reason
}),
);
eprintln!("SDD task gate: {reason}");
Ok(2)
}
fn hook_trace_log(payload: &Value) -> Result<()> {
let tool_input = payload.get("tool_input");
let target = tool_input.and_then(compact_tool_input);
let _ = runtime::trace::record_event(
&project_dir_for_hook(),
"events.jsonl",
json!({
"ts": now(),
"kind": "tool_use",
"status": "recorded",
"event": payload.get("hook_event_name"),
"agent": payload.get("agent_type").or_else(|| payload.get("subagent_type")),
"tool": payload.get("tool_name"),
"target": target,
}),
);
Ok(())
}
fn compact_tool_input(tool_input: &Value) -> Option<String> {
let map = tool_input.as_object()?;
for key in ["file_path", "path", "command"] {
if let Some(value) = map.get(key).and_then(Value::as_str) {
return Some(
runtime::redaction::redact_text(value)
.chars()
.take(500)
.collect(),
);
}
}
None
}
fn hook_notify(payload: &Value) -> Result<()> {
let matcher = payload
.get("matcher")
.or_else(|| payload.get("notification_type"))
.or_else(|| payload.get("hook_event_name"))
.and_then(Value::as_str)
.unwrap_or("attention");
// Notificação nativa best-effort, adaptada ao SO (macOS/Windows/Linux).
let message = format!("SDD pipeline needs attention: {matcher}");
let _ = runtime::platform::notify("Claude Code", &message);
log_jsonl(
&project_dir_for_hook(),
"notifications.jsonl",
json!({"ts": now(), "event": payload.get("hook_event_name"), "matcher": matcher}),
)
.or(Ok(()))
}
fn hook_subagent_audit(payload: &Value) -> Result<()> {
let transcript_path = payload
.get("transcript_path")
.and_then(Value::as_str)
.unwrap_or_default()
.chars()
.take(500)
.collect::<String>();
let _ = runtime::trace::record_event(
&project_dir_for_hook(),
"subagents.jsonl",
json!({
"ts": now(),
"kind": "subagent",
"status": "stopped",
"event": payload.get("hook_event_name"),
"agent": payload.get("agent_type").or_else(|| payload.get("subagent_type")),
"target": transcript_path,
"metadata": { "transcript_path": transcript_path },
}),
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn slugify_normalizes_accents() {
assert_eq!(slugify("Minha Orquestração!"), "minha-orquestracao");
assert_eq!(slugify(" "), "orchestration");
}
#[test]
fn read_stage_states_parses_traceability_map() {
let dir = tempdir().unwrap();
let map = dir.path().join("traceability-map.yaml");
fs::write(&map, render_index("Ciclo X")).unwrap();
let states = read_stage_states(&map);
// Toda etapa do contrato deve aparecer com seu estado inicial.
assert_eq!(states.get("idea").map(String::as_str), Some("pending"));
assert_eq!(states.get("review").map(String::as_str), Some("pending"));
// `external_links`/`notes` não são etapas e não podem vazar.
assert!(!states.contains_key("external_links"));
assert!(!states.contains_key("jira"));
}
#[test]
fn resolve_input_detects_card_mode() {
let dir = tempdir().unwrap();
let r = resolve_input(dir.path(), &["PROJ-246".to_string()], None);
assert_eq!(r.mode, "card");
assert_eq!(r.card_id.as_deref(), Some("PROJ-246"));
assert_eq!(r.name, "PROJ-246");
assert_eq!(r.slug, "proj-246");
assert!(r.idea.is_empty());
assert!(!r.docs_dir_exists);
// Card "pelado" sem ideia e sem artefatos → precisa pedir a descrição.
assert!(r.needs_description);
}
#[test]
fn resolve_input_card_with_idea_artifact_does_not_need_description() {
let dir = tempdir().unwrap();
// Card "pelado", mas a ideia já foi materializada em docs/<card>/01-idea.md.
let store = artifact_dir(dir.path(), "PROJ-7");
fs::create_dir_all(&store).unwrap();
fs::write(store.join("01-idea.md"), "# idea\n").unwrap();
let r = resolve_input(dir.path(), &["PROJ-7".to_string()], None);
assert_eq!(r.mode, "card");
assert!(r.idea.is_empty());
assert!(!r.needs_description);
}
#[test]
fn resolve_input_card_keeps_name_and_strips_idea() {
let dir = tempdir().unwrap();
let input = vec![
"PROJ-9".to_string(),
"fazer".to_string(),
"login".to_string(),
];
let r = resolve_input(dir.path(), &input, None);
assert_eq!(r.mode, "card");
assert_eq!(r.card_id.as_deref(), Some("PROJ-9"));
assert_eq!(r.name, "PROJ-9");
assert_eq!(r.idea, "fazer login");
// Há descrição (texto após o card) → não precisa pedir nada.
assert!(!r.needs_description);
}
#[test]
fn resolve_input_detects_card_in_single_combined_arg() {
// Clients chamam `sdd resolve-input "$ARGUMENTS"`: card + ideia chegam
// num único argumento citado. A tokenização deve detectar o card.
let dir = tempdir().unwrap();
let input = vec!["CIJIRA-9 adicionar login".to_string()];
let r = resolve_input(dir.path(), &input, None);
assert_eq!(r.mode, "card");
assert_eq!(r.card_id.as_deref(), Some("CIJIRA-9"));
assert_eq!(r.name, "CIJIRA-9");
assert_eq!(r.slug, "cijira-9");
assert_eq!(r.idea, "adicionar login");
assert!(!r.needs_description);
}
#[test]
fn resolve_input_new_mode_derives_stable_slug() {
let dir = tempdir().unwrap();
let input = vec![
"Sistema".to_string(),
"de".to_string(),
"Pagamento".to_string(),
];
let r = resolve_input(dir.path(), &input, None);
assert_eq!(r.mode, "new");
assert!(r.card_id.is_none());
assert_eq!(r.name, "sistema-de-pagamento");
assert_eq!(r.slug, "sistema-de-pagamento");
assert_eq!(r.idea, "Sistema de Pagamento");
assert_eq!(r.next_stage.as_deref(), Some("project-discovery"));
}
#[test]
fn resolve_input_empty_has_no_next_stage() {
let dir = tempdir().unwrap();
let r = resolve_input(dir.path(), &[], None);
assert_eq!(r.mode, "empty");
assert!(r.name.is_empty());
assert!(r.next_stage.is_none());
assert!(r.needs_description);
}
#[test]
fn resolve_input_resume_points_to_first_missing_stage() {
let dir = tempdir().unwrap();
let store = artifact_dir(dir.path(), "Sistema de Pagamento");
fs::create_dir_all(&store).unwrap();
// Pré-flight + idea já materializados; PRD ainda não.
for file in [
"00-project-discovery.md",
"00-risk-classification.md",
"01-idea.md",
] {
fs::write(store.join(file), "# stub\n").unwrap();
}
let input = vec![
"Sistema".to_string(),
"de".to_string(),
"Pagamento".to_string(),
];
let r = resolve_input(dir.path(), &input, None);
assert_eq!(r.mode, "resume");
assert!(r.docs_dir_exists);
assert_eq!(r.next_stage.as_deref(), Some("prd"));
}
#[test]
fn resolve_input_name_override_wins() {
let dir = tempdir().unwrap();
let input = vec!["PROJ-246".to_string()];
let r = resolve_input(dir.path(), &input, Some("ciclo-custom"));
// O card ainda é detectado, mas o --name explícito define a identidade.
assert_eq!(r.name, "ciclo-custom");
assert_eq!(r.slug, "ciclo-custom");
assert_eq!(r.card_id.as_deref(), Some("PROJ-246"));
}
#[test]
fn orchestration_store_name_prefers_card_over_fallback() {
// Card "pelado" (sem ideia) deve usar a pasta do card, não o fallback.
assert_eq!(
orchestration_store_name(None, Some("PROJ-246"), ""),
"PROJ-246"
);
}
#[test]
fn orchestration_store_name_explicit_wins_over_card() {
assert_eq!(
orchestration_store_name(Some("ciclo-x"), Some("PROJ-246"), "ideia qualquer"),
"ciclo-x"
);
}
#[test]
fn orchestration_store_name_uses_idea_then_fallback() {
assert_eq!(
orchestration_store_name(None, None, "Sistema de Pagamento"),
"Sistema de Pagamento"
);
assert_eq!(
orchestration_store_name(None, None, " "),
"sdd-orchestration"
);
}
#[test]
fn accent_dictionary_is_sorted_and_has_no_noop() {
for pair in ACCENT_DICTIONARY.windows(2) {
assert!(
pair[0].0 < pair[1].0,
"dicionário não ordenado/duplicado em {:?}",
pair
);
}
for (deacc, acc) in ACCENT_DICTIONARY {
assert_ne!(deacc, acc, "par no-op inútil: {deacc}");
}
}
#[test]
fn accent_gate_flags_deaccented_human_text() {
let text = "## Classificacao de risco\n\nA execucao depende de validacao e migracao.\n";
let issues = collect_accent_issues(text);
let joined = issues.join(" | ");
assert!(joined.contains("classificacao"), "{joined}");
assert!(joined.contains("execucao"), "{joined}");
assert!(joined.contains("validacao"), "{joined}");
assert!(joined.contains("migracao"), "{joined}");
assert!(joined.contains("-> \"execução\""), "{joined}");
}
#[test]
fn accent_gate_ignores_code_slugs_and_paths() {
// Comandos/identificadores em spans inline, blocos cercados, slugs e paths
// são ASCII por contrato e não podem ser sinalizados.
let text = "Rode `sdd execucao` e veja docs/minha-orquestracao/06-execution.md.\n\
O branch e feature/criar-validacao-nova.\n\n\
```bash\nsdd validacao --state recorded\n```\n\
O `<slug-da-orquestracao>` permanece estável.\n";
let issues = collect_accent_issues(text);
// "e" aqui é conjunção (não está no dicionário) e o resto está colado a
// separadores de slug/path ou dentro de código.
assert!(issues.is_empty(), "esperado vazio, veio: {issues:?}");
}
#[test]
fn accent_gate_accepts_correctly_accented_text() {
let text = "## Classificação de risco\n\nA execução depende de validação.\n";
assert!(collect_accent_issues(text).is_empty());
}
#[test]
fn save_artifact_blocks_approved_with_deaccented_content() {
let dir = tempdir().unwrap();
let root = dir.path();
let file = root.join("in.md");
fs::write(
&file,
"# Risk\n\n## Fatores\n\nA execucao precisa de validacao.\n",
)
.unwrap();
let err = save_artifact(
root,
"Ciclo X",
"risk",
Some(&file),
"approved",
false,
true,
false,
)
.unwrap_err()
.to_string();
assert!(err.contains("gate de acentuação"), "{err}");
assert!(err.contains("execucao"), "{err}");
}
#[test]
fn save_artifact_allows_recorded_with_deaccented_content() {
let dir = tempdir().unwrap();
let root = dir.path();
let file = root.join("in.md");
fs::write(&file, "# Risk\n\n## Fatores\n\nexecucao pendente.\n").unwrap();
// estado != approved apenas avisa (stderr) e persiste normalmente.
let saved = save_artifact(
root,
"Ciclo X",
"risk",
Some(&file),
"recorded",
false,
true,
false,
);
assert!(saved.is_ok(), "{saved:?}");
}
#[test]
fn risk_classifier_matches_expected_baseline() {
let result = classify("login SSO com permissao e dados pessoais").unwrap();
assert_eq!(result.level, "high");
assert!(result.factors.contains(&"security_sensitive"));
assert!(result.factors.contains(&"data_sensitive"));
}
#[test]
fn risk_classifier_treats_provider_tokens_as_high_risk() {
let result = classify("usar provider externo com tokens").unwrap();
assert_eq!(result.level, "high");
assert!(result.factors.contains(&"security_sensitive"));
assert!(result.factors.contains(&"external_dependency"));
assert!(result.checkpoints.contains(&"Refinement"));
}
#[test]
fn compact_tool_input_redacts_secret_values() {
let input = json!({
"command": "OPENAI_API_KEY=sk-test-secret sdd providers doctor"
});
let compact = compact_tool_input(&input).unwrap();
assert!(compact.contains("OPENAI_API_KEY=[REDACTED]"));
assert!(!compact.contains("sk-test-secret"));
}
#[test]
fn layer_root_ignores_runtime_only_sdd_directory() {
let tmp = tempdir().unwrap();
let project = tmp.path();
fs::create_dir(project.join(".sdd")).unwrap();
fs::write(project.join(".sdd/events.jsonl"), "{}\n").unwrap();
assert_eq!(layer_root(project), project);
fs::create_dir(project.join(".sdd/presets")).unwrap();
assert_eq!(layer_root(project), project.join(".sdd"));
}
#[test]
fn detect_preset_matches_api_stacks() {
let tmp = tempdir().unwrap();
let project = tmp.path();
fs::write(
project.join("Cargo.toml"),
"[package]\nname = \"api\"\nversion = \"0.1.0\"\n[dependencies]\naxum = \"0.7\"\n",
)
.unwrap();
assert_eq!(detect_preset(project), "rust-api");
fs::remove_file(project.join("Cargo.toml")).unwrap();
fs::write(
project.join("pyproject.toml"),
"[project]\nname = \"api\"\ndependencies = [\"fastapi\"]\n",
)
.unwrap();
assert_eq!(detect_preset(project), "python-api");
fs::remove_file(project.join("pyproject.toml")).unwrap();
fs::write(
project.join("package.json"),
r#"{"dependencies":{"hono":"latest"}}"#,
)
.unwrap();
assert_eq!(detect_preset(project), "hono-api");
}
#[test]
fn artifact_store_init_and_save() {
let tmp = tempdir().unwrap();
init_store(tmp.path(), "Minha Orquestração", false, false).unwrap();
let input = tmp.path().join("prd.md");
fs::write(&input, "# PRD\n\n## Rastreabilidade\n- Origem\n").unwrap();
let target = save_artifact(
tmp.path(),
"Minha Orquestração",
"prd",
Some(&input),
"approved",
false,
false,
false,
)
.unwrap();
assert!(target.ends_with("docs/minha-orquestracao/02-prd.md"));
let index = fs::read_to_string(
tmp.path()
.join("docs/minha-orquestracao/traceability-map.yaml"),
)
.unwrap();
assert!(index.contains("state: approved"));
}
#[test]
fn validate_markdown_headings() {
let headings = collect_headings("# PRD\n\n## Rastreabilidade\n## Resumo\n").unwrap();
assert!(headings.contains("rastreabilidade"));
assert!(headings.contains("resumo"));
}
#[test]
fn bash_guard_blocks_destructive_command() {
let payload = json!({"tool_input": {"command": "git reset --hard HEAD"}});
assert_eq!(hook_bash_guard(&payload).unwrap(), 2);
}
#[test]
fn infer_artifact_trusts_canonical_filename_over_content() {
// Orquestração nomeada com "Backlog" vazava a pista de `tasks` no conteúdo de
// todo artefato. A detecção filename-first ainda precisa resolver refinement.
let path = Path::new("docs/backlog-de-notificações/05-refinement.md");
let text =
"# Refinement — Backlog de Notificações\n\n## Rastreabilidade\n- Origem: 04-tasks.md\n";
assert_eq!(infer_artifact(path, text).as_deref(), Some("refinement"));
}
#[test]
fn infer_artifact_trusts_canonical_risk_classification_filename() {
// Mesmo citando Project Discovery no corpo, o nome canônico resolve para risk.
let path = Path::new("docs/feature-x/00-risk-classification.md");
let text = "# Classificação de Risco\n\nBaseado no Project Discovery anterior...\n";
assert_eq!(infer_artifact(path, text).as_deref(), Some("risk"));
}
#[test]
fn infer_artifact_maps_all_canonical_filenames() {
let cases = [
("00-project-discovery.md", Some("project-discovery")),
("00-risk-classification.md", Some("risk")),
("01-idea.md", Some("idea")),
("02-prd.md", Some("prd")),
("03-techspec.md", Some("techspec")),
("04-tasks.md", Some("tasks")),
("05-refinement.md", Some("refinement")),
("06-execution.md", Some("execution")),
("06-adr.md", Some("adr")),
("07-review.md", Some("review")),
("08-memory.md", Some("memory")),
];
for (file, expected) in cases {
// Corpo vazio prova que a detecção vem só do nome do arquivo.
assert_eq!(
infer_artifact(Path::new(file), "").as_deref(),
expected,
"arquivo: {file}"
);
}
}
#[test]
fn infer_artifact_falls_back_to_hints_for_non_canonical_filename() {
// Arquivo nomeado à mão fora do artifact store ainda resolve via heurística.
let path = Path::new("notes/my-prd-draft.md");
assert_eq!(infer_artifact(path, "conteúdo").as_deref(), Some("prd"));
}
#[test]
fn cargo_version_helpers_read_package_versions() {
let cargo_toml = r#"
[package]
name = "sdd-layer"
version = "0.7.1"
[dependencies]
serde = "1"
"#;
let cargo_lock = r#"
[[package]]
name = "serde"
version = "1.0.0"
[[package]]
name = "sdd-layer"
version = "0.7.1"
"#;
assert_eq!(
cargo_toml_package_version(cargo_toml).as_deref(),
Some("0.7.1")
);
assert_eq!(
cargo_lock_package_version(cargo_lock, "sdd-layer").as_deref(),
Some("0.7.1")
);
}
#[test]
fn version_alignment_detects_triplet_drift() {
let tmp = tempdir().unwrap();
let root = tmp.path().join("sdd-layer");
fs::create_dir_all(&root).unwrap();
fs::write(root.join("VERSION"), "0.7.0\n").unwrap();
fs::write(
root.join("Cargo.toml"),
"[package]\nname = \"sdd-layer\"\nversion = \"0.7.1\"\n",
)
.unwrap();
fs::write(
root.join("Cargo.lock"),
"[[package]]\nname = \"sdd-layer\"\nversion = \"0.7.1\"\n",
)
.unwrap();
let issues = version_alignment_issues(&root);
assert_eq!(issues.len(), 1);
assert!(issues[0].contains("VERSION=0.7.0"));
assert!(issues[0].contains("Cargo.toml=0.7.1"));
assert!(issues[0].contains("Cargo.lock=0.7.1"));
}
}