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, ProviderModelPricing, 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, Instant};
use unicode_normalization::char::is_combining_mark;
use unicode_normalization::UnicodeNormalization;
use walkdir::WalkDir;
use domain::risk::RiskResult;
use tui::runner::TokenUsageSample;
mod cli;
mod contract;
mod domain;
pub mod runtime;
mod tui;
use cli::style::{
banner, banner_close, item, item_labeled, plural, quick_start, rail_blank, rail_detail,
rail_node, section, section_none, status_icon, status_line, status_signal, summary_line, tree,
RailMarker,
};
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",
"plugins",
];
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",
"plugins",
"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 MAX_ARTIFACT_SLUG_LEN: usize = 96;
const ARTIFACT_SLUG_HASH_LEN: usize = 12;
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),
Skills(SkillsArgs),
Workflow(WorkflowArgs),
Mcp(McpArgs),
Acp(AcpArgs),
Trace(TraceArgs),
Context(ContextArgs),
Ci(CiArgs),
Maintenance(MaintenanceArgs),
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),
Eval(EvalArgs),
Intelligence(IntelligenceArgs),
Optimize(OptimizeArgs),
Quality(QualityArgs),
Health(HealthArgs),
Diagram(DiagramArgs),
#[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 SkillsArgs {
#[command(subcommand)]
command: SkillsCommand,
}
#[derive(Subcommand)]
enum SkillsCommand {
List(SkillsListArgs),
Doctor(SkillsDoctorArgs),
Sync(SkillsSyncArgs),
Recommend(SkillsRecommendArgs),
Validate(SkillsValidateArgs),
}
#[derive(Args)]
struct SkillsListArgs {
#[arg(long, default_value = ".")]
root: PathBuf,
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct SkillsDoctorArgs {
#[arg(long, default_value = ".")]
root: PathBuf,
#[arg(long)]
strict: bool,
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct SkillsSyncArgs {
#[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 SkillsRecommendArgs {
#[arg(long, default_value = ".")]
root: PathBuf,
#[arg(long)]
write: bool,
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct SkillsValidateArgs {
path: PathBuf,
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct WorkflowArgs {
#[arg(long, default_value = ".")]
root: PathBuf,
#[command(subcommand)]
command: WorkflowCommand,
}
#[derive(Subcommand)]
enum WorkflowCommand {
List {
#[arg(long)]
json: bool,
},
Validate {
workflow: String,
#[arg(long)]
json: bool,
},
Run {
workflow: String,
#[arg(long, default_value = "")]
input: String,
#[arg(long)]
max_iterations: Option<u32>,
#[arg(long)]
json: bool,
#[arg(long)]
real: bool,
},
Status {
workflow: Option<String>,
#[arg(long)]
json: bool,
},
}
#[derive(Args)]
struct McpArgs {
#[command(subcommand)]
command: McpCommand,
}
#[derive(Subcommand)]
enum McpCommand {
Serve(McpServeArgs),
Config(McpConfigArgs),
Doctor(McpDoctorArgs),
}
#[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 McpDoctorArgs {
#[arg(long, default_value = ".")]
root: PathBuf,
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct AcpArgs {
#[command(subcommand)]
command: AcpCommand,
}
#[derive(Subcommand)]
enum AcpCommand {
Serve(AcpServeArgs),
Doctor(AcpDoctorArgs),
Config(AcpConfigArgs),
}
#[derive(Args)]
struct AcpServeArgs {
#[arg(long, default_value = ".")]
root: PathBuf,
}
#[derive(Args)]
struct AcpDoctorArgs {
#[arg(long, default_value = ".")]
root: PathBuf,
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct AcpConfigArgs {
#[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),
Materialize(ContextMaterializeArgs),
}
#[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,
#[arg(long)]
metadata_only: 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 ContextMaterializeArgs {
#[arg(long, default_value = ".")]
root: PathBuf,
#[arg(long)]
name: String,
#[arg(long)]
write: bool,
#[arg(long)]
dry_run: bool,
#[arg(long)]
json: bool,
#[arg(long)]
local_agents: bool,
#[arg(long)]
max_boundaries: Option<usize>,
}
#[derive(Args)]
struct CiArgs {
#[arg(long)]
root: Option<PathBuf>,
/// Executa o gate único de release com checks obrigatórios, docs, bot e advisory.
#[arg(long)]
strict_release: bool,
/// Emite o relatório do gate strict/release como JSON.
#[arg(long)]
json: bool,
/// Mostra o plano do gate sem executar comandos caros.
#[arg(long)]
dry_run: bool,
/// Nome/slug da orquestração para health/diagram doctor quando aplicável.
#[arg(long)]
name: Option<String>,
}
#[derive(Args)]
struct MaintenanceArgs {
#[arg(long, default_value = ".")]
root: PathBuf,
#[command(subcommand)]
command: MaintenanceCommand,
}
#[derive(Subcommand)]
enum MaintenanceCommand {
/// Gera um relatório rápido por área sem executar builds caros.
Report(MaintenanceReportArgs),
}
#[derive(Args)]
struct MaintenanceReportArgs {
#[arg(long)]
name: Option<String>,
#[arg(long)]
json: bool,
#[arg(long)]
markdown: bool,
}
#[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 EvalArgs {
#[arg(long, default_value = ".")]
root: PathBuf,
#[command(subcommand)]
command: EvalCommand,
}
#[derive(Subcommand)]
enum EvalCommand {
/// Avalia um artefato/stage materializado em docs/<slug>/.
Stage {
#[arg(long)]
name: String,
#[arg(long)]
stage: String,
#[arg(long)]
json: bool,
},
/// Avalia todos os stages materializados de uma orquestração.
Orchestration {
#[arg(long)]
name: String,
#[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),
Repair(IntelligenceRepairArgs),
}
#[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 IntelligenceRepairArgs {
#[arg(long)]
name: Option<String>,
#[arg(long)]
all: bool,
#[arg(long)]
json: bool,
#[arg(long)]
dry_run: bool,
}
#[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 QualityArgs {
#[arg(long, default_value = ".")]
root: PathBuf,
#[command(subcommand)]
command: QualityCommand,
}
#[derive(Subcommand)]
enum QualityCommand {
/// Consolida avaliação, ferramentas opcionais e recomendações de eficácia.
Report {
#[arg(long)]
name: String,
#[arg(long)]
json: bool,
},
}
#[derive(Args)]
struct HealthArgs {
#[arg(long, default_value = ".")]
root: PathBuf,
#[arg(long)]
name: Option<String>,
#[arg(long)]
json: bool,
#[arg(long)]
markdown: bool,
/// Retorna erro também quando o health ficar em warn.
#[arg(long)]
strict: bool,
}
#[derive(Args)]
struct DiagramArgs {
#[arg(long, default_value = ".")]
root: PathBuf,
#[command(subcommand)]
command: DiagramCommand,
}
#[derive(Subcommand)]
enum DiagramCommand {
/// Anexa um companion visual à seção `## Diagramas` de IDEA, PRD ou Tech Spec.
Attach(DiagramAttachArgs),
/// Verifica skill `diagram-design`, assets e estilo visual da orquestração.
Doctor(DiagramDoctorArgs),
}
#[derive(Args)]
struct DiagramAttachArgs {
#[arg(long)]
name: String,
#[arg(long)]
stage: String,
#[arg(long)]
file: PathBuf,
#[arg(long)]
title: String,
#[arg(long)]
source: String,
#[arg(long)]
force: bool,
#[arg(long)]
dry_run: bool,
}
#[derive(Args)]
struct DiagramDoctorArgs {
#[arg(long)]
name: String,
#[arg(long)]
json: bool,
}
#[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,
},
Link {
name: String,
system: String,
#[arg(long)]
url: String,
#[arg(long)]
kind: Option<String>,
#[arg(long)]
id: Option<String>,
#[arg(long)]
title: Option<String>,
#[arg(long)]
stage: Option<String>,
#[arg(long)]
dry_run: 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>,
/// Continua demandas já prontas para execução até execution|adr|review|memory.
#[arg(long)]
through: Option<String>,
/// Aceita automaticamente gates de planejamento e, por padrão, segue até memory.
#[arg(long)]
unattended: bool,
#[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 demand_status_rows(root: &Path) -> Result<Vec<Value>> {
use crate::domain::orchestrator::{queue, state};
let demands = queue::list(root)?;
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()),
}));
}
Ok(rows)
}
fn print_demands_status(root: &Path, json_output: bool) -> Result<()> {
let rows = demand_status_rows(root)?;
if json_output {
println!("{}", serde_json::to_string_pretty(&rows)?);
return Ok(());
}
if rows.is_empty() {
println!("fila vazia");
return Ok(());
}
for row in &rows {
let cursor = row.get("cursor").and_then(Value::as_str).unwrap_or("-");
println!(
"{}\t{}\t{}\t{}\t{}",
row.get("id").and_then(Value::as_str).unwrap_or("-"),
row.get("type").and_then(Value::as_str).unwrap_or("-"),
row.get("status").and_then(Value::as_str).unwrap_or("-"),
cursor,
row.get("title").and_then(Value::as_str).unwrap_or("-")
);
}
Ok(())
}
fn auto_status_report(root: &Path) -> Result<Value> {
let demands = demand_status_rows(root)?;
Ok(json!({
"status": auto_readiness_status(&demands),
"count": demands.len(),
"demands": demands,
}))
}
fn auto_readiness_status(demands: &[Value]) -> &'static str {
let mut warned = false;
for demand in demands {
let status = demand
.get("status")
.and_then(Value::as_str)
.unwrap_or("queued");
if status == "error" {
return "fail";
}
if status == "awaiting_approval" || status == "paused" || status.starts_with("manual:") {
warned = true;
}
}
if warned {
"warn"
} else {
"pass"
}
}
/// 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,
through,
unattended,
json,
real,
} => {
let runner = select_runner(root, real);
let mut reports = Vec::new();
let mut unattended_report = UnattendedGateReport::default();
let max_iterations = max_iterations.unwrap_or(100);
loop {
if reports.len() as u32 >= max_iterations {
break;
}
let remaining = max_iterations.saturating_sub(reports.len() as u32);
let batch =
run_until_idle(root, runner.as_ref(), &owner, &now_public(), remaining)?;
for report in &batch {
audit_tick(root, report);
if !unattended {
emit_gate_requests(root, report);
}
}
reports.extend(batch);
if !unattended {
break;
}
let gate_report = accept_unattended_gates(root, &now_public())?;
let accepted_any = !gate_report.accepted.is_empty();
unattended_report.accepted.extend(gate_report.accepted);
unattended_report.skipped.extend(gate_report.skipped);
unattended_report.errors.extend(gate_report.errors);
if !accepted_any {
break;
}
}
let effective_through = if unattended {
through.as_deref().or(Some("memory"))
} else {
through.as_deref()
};
let post_report = if let Some(through) = effective_through {
Some(run_post_planning(root, runner.as_ref(), &owner, through)?)
} else {
None
};
let produced: usize = reports.iter().map(|r| r.produced.len()).sum();
let paused: Vec<String> = if unattended {
Vec::new()
} else {
reports
.iter()
.flat_map(|r| r.paused.iter().cloned())
.collect()
};
let ready: Vec<String> = reports
.iter()
.flat_map(|r| r.ready.iter().cloned())
.collect();
let mut errors: Vec<String> = reports
.iter()
.flat_map(|r| r.errors.iter().cloned())
.collect();
errors.extend(unattended_report.errors.clone());
if json {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"iterations": reports.len(),
"produced": produced,
"unattended": unattended,
"auto_accepted": unattended_report.accepted,
"auto_accept_skipped": unattended_report.skipped,
"paused": paused,
"ready": ready,
"errors": errors,
"post_planning": post_report,
}))?
);
} else {
println!("iterações: {}", reports.len());
println!("etapas produzidas: {produced}");
if unattended {
println!("modo unattended: ativo");
println!(
"gates aceitos automaticamente: {:?}",
unattended_report.accepted
);
if !unattended_report.skipped.is_empty() {
println!("gates unattended pulados: {:?}", unattended_report.skipped);
}
} else {
println!("aguardando aprovação: {paused:?}");
}
if !ready.is_empty() {
println!("prontas para execução: {ready:?}");
}
if !errors.is_empty() {
println!("erros: {errors:?}");
}
if let Some(post_report) = post_report {
println!("pós-planejamento produzido: {:?}", post_report.produced);
if !post_report.skipped.is_empty() {
println!("pós-planejamento pulado: {:?}", post_report.skipped);
}
if !post_report.errors.is_empty() {
println!("pós-planejamento erros: {:?}", post_report.errors);
}
}
}
}
AutoCommand::Status { json } => print_demands_status(root, json)?,
}
Ok(())
}
#[derive(Clone, Debug, Default, Serialize)]
struct PostPlanningReport {
produced: Vec<(String, String)>,
skipped: Vec<String>,
errors: Vec<String>,
}
#[derive(Clone, Debug, Default, Serialize)]
struct UnattendedGateReport {
accepted: Vec<(String, String)>,
skipped: Vec<String>,
errors: Vec<String>,
}
fn accept_unattended_gates(root: &Path, ts: &str) -> Result<UnattendedGateReport> {
use crate::domain::orchestrator::approval::apply_automatic_acceptance;
use crate::domain::orchestrator::state::EngineStatus;
use crate::domain::orchestrator::{queue, state};
use crate::tui::stage::Stage;
let mut report = UnattendedGateReport::default();
for demand in queue::list(root)? {
let Some(st) = state::load(root, &demand.slug)? else {
continue;
};
if st.status != EngineStatus::AwaitingApproval {
continue;
}
let Some(gate) = Stage::from_key(&st.cursor) else {
report
.errors
.push(format!("{}: gate inválido '{}'", demand.slug, st.cursor));
continue;
};
if !matches!(gate, Stage::Prd | Stage::Techspec | Stage::Refinement) {
report.skipped.push(format!(
"{}: '{}' não é gate de planejamento unattended",
demand.slug,
gate.key()
));
continue;
}
let reason = format!(
"sdd auto run --unattended aceitou automaticamente o gate '{}'",
gate.key()
);
match apply_automatic_acceptance(root, &demand.slug, gate, ts, Some(reason.clone())) {
Ok(outcome) => {
report
.accepted
.push((demand.slug.clone(), gate.key().to_string()));
let file = stage_file(gate.key()).unwrap_or("");
let _ = log_jsonl(
root,
"orchestrator.jsonl",
json!({
"ts": ts,
"event": "gate_auto_accept",
"slug": demand.slug,
"id": demand.id,
"gate": gate.key(),
"artifact": format!("docs/{}/{file}", demand.slug),
"author": "sdd-auto",
"channel": "automation",
"outcome": format!("{outcome:?}"),
"reason": reason,
}),
);
}
Err(error) => report.errors.push(format!(
"{}: falha ao autoaceitar '{}': {error:#}",
demand.slug,
gate.key()
)),
}
}
Ok(report)
}
fn run_post_planning(
root: &Path,
runner: &dyn crate::domain::orchestrator::engine::StageRunner,
owner: &str,
through: &str,
) -> Result<PostPlanningReport> {
use crate::domain::orchestrator::engine::{RunnerError, StageRequest};
use crate::domain::orchestrator::state::EngineStatus;
use crate::domain::orchestrator::{queue, state};
use crate::tui::stage::Stage;
let stages = post_planning_stages(through)?;
let mut report = PostPlanningReport::default();
for demand in queue::list(root)? {
let Some(mut st) = state::load(root, &demand.slug)? else {
report.skipped.push(format!("{}: sem estado", demand.slug));
continue;
};
if st.status != EngineStatus::ReadyForExec && st.status != EngineStatus::Paused {
report.skipped.push(format!(
"{}: status {} não está pronto para pós-planejamento",
demand.slug,
st.status.to_repr()
));
continue;
}
if !state::acquire_lock(root, &mut st, owner, now_public())? {
report.skipped.push(format!("{}: lock ativo", demand.slug));
continue;
}
let demand_result = (|| -> Result<()> {
st.last_error = None;
let dest = artifact_dir(root, &demand.title);
fs::create_dir_all(&dest)?;
let index = ensure_index(&dest, &demand.title)?;
seed_task_execution_state(&mut st, &dest);
let mut local_error = None;
for stage in &stages {
let file = stage_file(stage.key()).expect("stage canônica tem arquivo");
let path = dest.join(file);
if path.exists() {
report
.skipped
.push(format!("{}: {} já existe", demand.slug, stage.key()));
st.cursor = stage.key().to_string();
continue;
}
let request = StageRequest {
slug: demand.slug.clone(),
stage: *stage,
title: demand.title.clone(),
description: demand.description.clone(),
artifact_dir: dest.clone(),
};
match runner.run(&request) {
Ok(artifact) => {
crate::domain::orchestrator::write_atomic(
&path,
artifact.markdown.as_bytes(),
)?;
update_stage_state(&index, stage.key(), "recorded")?;
let evaluation =
domain::evaluation::evaluate_stage(root, &demand.title, stage.key())?;
log_jsonl(
root,
"evaluations.jsonl",
domain::evaluation::evaluation_event(
"auto_eval_post_stage",
&evaluation,
),
)?;
if evaluation.has_critical() {
local_error = Some(format!(
"{}: avaliação crítica bloqueou {}",
demand.slug,
stage.key()
));
break;
}
mark_task_state_for_stage(&mut st, *stage);
st.cursor = stage.key().to_string();
report
.produced
.push((demand.slug.clone(), stage.key().to_string()));
log_jsonl(
root,
"execution-runs.jsonl",
json!({
"ts": now_public(),
"slug": demand.slug.clone(),
"stage": stage.key(),
"status": "recorded",
"provider": "stage-runner",
}),
)?;
}
Err(RunnerError::NotHeadless) => {
st.status = EngineStatus::Manual(stage.key().to_string());
report
.skipped
.push(format!("{}: manual {}", demand.slug, stage.key()));
break;
}
Err(RunnerError::Failed(message)) => {
local_error = Some(format!(
"{}: {} falhou: {message}",
demand.slug,
stage.key()
));
break;
}
}
}
if let Some(error) = local_error {
st.status = EngineStatus::Error;
st.last_error = Some(crate::runtime::redaction::redact_text(&error));
report.errors.push(error);
} else if matches!(&st.status, EngineStatus::Manual(_)) {
st.last_error = None;
} else if stages.last() == Some(&Stage::Memory) {
st.status = EngineStatus::Completed;
} else if stages.last() == Some(&Stage::Review) {
st.status = EngineStatus::Paused;
} else {
st.status = EngineStatus::ReadyForExec;
}
Ok(())
})();
if let Err(error) = demand_result {
let message = format!("{}: pós-planejamento falhou: {error:#}", demand.slug);
st.status = EngineStatus::Error;
st.last_error = Some(crate::runtime::redaction::redact_text(&message));
report.errors.push(message);
}
state::release_lock(root, &mut st)?;
}
Ok(report)
}
fn post_planning_stages(value: &str) -> Result<Vec<crate::tui::stage::Stage>> {
use crate::tui::stage::Stage;
let stages = match value.trim().to_ascii_lowercase().as_str() {
"execution" => vec![Stage::Execution],
"adr" => vec![Stage::Execution, Stage::Adr],
"review" => vec![Stage::Execution, Stage::Adr, Stage::Review],
"memory" => vec![Stage::Execution, Stage::Adr, Stage::Review, Stage::Memory],
other => bail!("--through inválido: {other} (use execution|adr|review|memory)"),
};
Ok(stages)
}
fn seed_task_execution_state(
st: &mut crate::domain::orchestrator::state::EngineState,
artifact_dir: &Path,
) {
let tasks_path = artifact_dir.join("04-tasks.md");
let Ok(tasks_md) = fs::read_to_string(tasks_path) else {
return;
};
for task in tui::execution::parse_execution_tasks(&tasks_md) {
st.tasks.entry(task.id).or_insert_with(|| {
crate::domain::orchestrator::state::TaskExecutionState {
status: "planned".to_string(),
..Default::default()
}
});
}
}
fn mark_task_state_for_stage(
st: &mut crate::domain::orchestrator::state::EngineState,
stage: crate::tui::stage::Stage,
) {
if stage != crate::tui::stage::Stage::Execution {
return;
}
for task in st.tasks.values_mut() {
task.status = "recorded".to_string();
task.attempts = task.attempts.saturating_add(1);
task.evidence
.push("Execution artifact recorded by auto --through".to_string());
}
}
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::Skills(args) => run_skills(args)?,
Command::Workflow(args) => run_workflow_cli(args)?,
Command::Mcp(args) => run_mcp(args)?,
Command::Acp(args) => run_acp(args)?,
Command::Trace(args) => run_trace(args)?,
Command::Context(args) => run_context(args)?,
Command::Ci(args) => {
let root = resolve_project_root(args.root.clone())?;
run_ci(&root, &args)?;
}
Command::Maintenance(args) => run_maintenance(args)?,
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::Eval(args) => run_eval(args)?,
Command::Intelligence(args) => run_intelligence(args)?,
Command::Optimize(args) => run_optimize(args)?,
Command::Quality(args) => run_quality(args)?,
Command::Health(args) => run_health(args)?,
Command::Diagram(args) => run_diagram(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
}
}
pub(crate) fn artifact_slug(value: &str) -> String {
let slug = slugify(value);
if slug.len() <= MAX_ARTIFACT_SLUG_LEN {
return slug;
}
let digest = sha256_hex(slug.as_bytes());
let suffix = &digest[..ARTIFACT_SLUG_HASH_LEN];
let prefix_len = MAX_ARTIFACT_SLUG_LEN - ARTIFACT_SLUG_HASH_LEN - 1;
let prefix = slug[..prefix_len].trim_matches('-');
if prefix.is_empty() {
format!("orchestration-{suffix}")
} else {
format!("{prefix}-{suffix}")
}
}
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();
}
}
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,
}
}
}
#[allow(dead_code)]
fn _style_helpers_in_scope() {
let _ = (status_icon)("pass");
}
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 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) {
print_action_summary_with_limit(actions, target, 5);
}
fn print_action_summary_with_limit(actions: &[String], target: &Path, preview_limit: usize) {
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_sync_report(title: &str, actions: &[String], target: &Path, dry_run: bool) {
println!("│ SDD {title}");
rail_blank();
if dry_run {
rail_node(
RailMarker::Hollow,
"Modo",
Some("dry-run; nenhum arquivo será escrito"),
);
}
// Sync é uma ação explícita: mostra o plano completo em vez de preview
// curto, para que o usuário veja exatamente o que será escrito/preservado.
print_action_summary_with_limit(actions, target, usize::MAX);
let done = if dry_run {
format!("Done! SDD {title} preparado (dry-run).")
} else {
format!("Done! SDD {title} complete.")
};
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);
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,
);
// TASK-12: bot não é mais provisionado interativamente aqui.
// Use `sdd bot install` para instalar o bot Slack.
let bot_decision = BotBundleDecision {
include_bundle: bot_bundle_dir(&target).exists(),
prompt_answer: None,
};
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)
}
pub(crate) fn artifact_dir(root: &Path, name: &str) -> PathBuf {
root.join("docs").join(artifact_slug(name))
}
/// Diretório de artefatos consciente do modo:
/// - card-id → <root>/.worktree/<card_id>/docs/<slug>
/// - sem card → <root>/docs/<slug> (delega para artifact_dir)
fn artifact_dir_for(root: &Path, name: &str, card_id: Option<&str>) -> PathBuf {
match card_id {
Some(cid) => worktree_path_for_card(root, cid)
.join("docs")
.join(artifact_slug(name)),
None => artifact_dir(root, name),
}
}
fn render_index(name: &str) -> String {
let mut lines = vec![
"orchestration:".to_string(),
format!(" name: \"{name}\""),
format!(" slug: \"{}\"", artifact_slug(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);
let index = dest.join("traceability-map.yaml");
let readme = dest.join("README.md");
println!("│ SDD orchestration init");
rail_blank();
rail_node(RailMarker::Hollow, "Orquestração", Some(name));
rail_node(
RailMarker::Hollow,
"Destino",
Some(&runtime::platform::display_path(&dest)),
);
if dry_run {
rail_node(
RailMarker::Hollow,
"Modo",
Some("dry-run; nenhum arquivo será escrito"),
);
println!("└ Done! orchestration init preparado (dry-run).");
return Ok(dest);
}
fs::create_dir_all(&dest)?;
let index_existed = index.exists() && !force;
if !index_existed {
fs::write(&index, render_index(name))?;
}
if !readme.exists() {
fs::write(
&readme,
format!("# SDD - {name}\n\nArtefatos locais desta orquestração.\n"),
)?;
}
let timeline = vec![
TimelineStep::filled("Pasta docs/", Some(format!("{}", dest.display()))),
TimelineStep::filled(
"traceability-map.yaml",
Some(if index_existed {
"preservado (já existia)".to_string()
} else {
"criado".to_string()
}),
),
TimelineStep::filled(
"README.md",
Some(
if readme.exists() {
"preservado"
} else {
"criado"
}
.to_string(),
),
),
];
print_timeline(&timeline);
let next_steps = vec![
format!("sdd orchestration --name \"{name}\" \"<sua ideia>\""),
"sdd artifact save <nome> <stage> --file ... --state recorded".to_string(),
];
quick_start(&next_steps, "Done! SDD orchestration init complete.");
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());
}
let reported_usage = provider_reported_usage_from_env();
let reported_timing = provider_reported_timing_from_env();
if reported_usage.is_some() || reported_timing.is_some() {
content = inject_traceability_telemetry(
&content,
reported_usage.as_ref(),
reported_timing.as_ref(),
);
}
// 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(())
}
#[allow(clippy::too_many_arguments)]
fn add_external_link(
root_arg: &Path,
name: &str,
system: &str,
url: &str,
kind: Option<&str>,
id: Option<&str>,
title: Option<&str>,
stage: Option<&str>,
dry_run: bool,
) -> Result<PathBuf> {
match system {
"jira" | "confluence" | "bitbucket" | "github" | "gitlab" => {}
other => bail!("external link system desconhecido: {other}"),
}
let root = runtime::platform::canonicalize(root_arg).unwrap_or_else(|_| root_arg.to_path_buf());
let dest = artifact_dir(&root, name);
let index = dest.join("traceability-map.yaml");
if dry_run {
println!("dry-run link {} {}", system, url);
return Ok(index);
}
fs::create_dir_all(&dest)?;
let index = ensure_index(&dest, name)?;
let text = fs::read_to_string(&index)?;
let mut doc: serde_yaml::Value = serde_yaml::from_str(&text)?;
let root_map = doc
.as_mapping_mut()
.ok_or_else(|| anyhow!("traceability-map inválido: raiz YAML não é mapping"))?;
let external_key = serde_yaml::Value::String("external_links".to_string());
if !root_map.contains_key(&external_key) {
root_map.insert(
external_key.clone(),
serde_yaml::Value::Mapping(serde_yaml::Mapping::new()),
);
}
let external = root_map
.get_mut(&external_key)
.and_then(serde_yaml::Value::as_mapping_mut)
.ok_or_else(|| anyhow!("traceability-map inválido: external_links não é mapping"))?;
let system_key = serde_yaml::Value::String(system.to_string());
if !external.contains_key(&system_key) {
external.insert(system_key.clone(), serde_yaml::Value::Sequence(Vec::new()));
}
let links = external
.get_mut(&system_key)
.and_then(serde_yaml::Value::as_sequence_mut)
.ok_or_else(|| anyhow!("traceability-map inválido: external_links.{system} não é lista"))?;
let mut link = serde_yaml::Mapping::new();
link.insert(
serde_yaml::Value::String("url".to_string()),
serde_yaml::Value::String(url.to_string()),
);
link.insert(
serde_yaml::Value::String("recorded_at".to_string()),
serde_yaml::Value::String(now()),
);
if let Some(value) = kind {
link.insert(
serde_yaml::Value::String("kind".to_string()),
serde_yaml::Value::String(value.to_string()),
);
}
if let Some(value) = id {
link.insert(
serde_yaml::Value::String("id".to_string()),
serde_yaml::Value::String(value.to_string()),
);
}
if let Some(value) = title {
link.insert(
serde_yaml::Value::String("title".to_string()),
serde_yaml::Value::String(value.to_string()),
);
}
if let Some(value) = stage {
link.insert(
serde_yaml::Value::String("stage".to_string()),
serde_yaml::Value::String(value.to_string()),
);
}
links.push(serde_yaml::Value::Mapping(link));
fs::write(&index, serde_yaml::to_string(&doc)?)?;
println!("{}", index.display());
Ok(index)
}
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)?,
ArtifactCommand::Link {
name,
system,
url,
kind,
id,
title,
stage,
dry_run,
} => {
add_external_link(
&args.root,
&name,
&system,
&url,
kind.as_deref(),
id.as_deref(),
title.as_deref(),
stage.as_deref(),
dry_run,
)?;
}
}
Ok(())
}
#[derive(Serialize)]
struct DiagramDoctorReport {
status: &'static str,
orchestration: String,
artifact_dir: String,
artifact_dir_exists: bool,
assets_dir: String,
assets_dir_exists: bool,
skill_path: Option<String>,
skill_status: String,
style_guide_status: String,
auto_markdown: bool,
visual: &'static str,
visual_stage_allowlist: Vec<&'static str>,
warnings: Vec<String>,
}
fn run_diagram(args: DiagramArgs) -> Result<()> {
match args.command {
DiagramCommand::Attach(attach) => run_diagram_attach(&args.root, attach),
DiagramCommand::Doctor(doctor) => run_diagram_doctor(&args.root, doctor),
}
}
fn run_diagram_attach(root_arg: &Path, args: DiagramAttachArgs) -> Result<()> {
let root = runtime::platform::canonicalize(root_arg).unwrap_or_else(|_| root_arg.to_path_buf());
let stage = args.stage.trim().to_ascii_lowercase();
if !matches!(stage.as_str(), "idea" | "prd" | "techspec") {
bail!("diagram attach supports only stages: idea, prd, techspec");
}
let source_file = if args.file.is_absolute() {
args.file.clone()
} else {
root.join(&args.file)
};
if !source_file.is_file() {
bail!("diagram file not found: {}", source_file.display());
}
let ext = source_file
.extension()
.and_then(OsStr::to_str)
.map(|value| value.to_ascii_lowercase())
.unwrap_or_default();
if !matches!(ext.as_str(), "html" | "svg" | "excalidraw") {
bail!("diagram attach accepts only .html, .svg or .excalidraw files");
}
let card_id = is_card_id(&args.name).then_some(args.name.as_str());
let artifact_root = artifact_dir_for(&root, &args.name, card_id);
let filename = stage_file(&stage).ok_or_else(|| anyhow!("unknown stage: {stage}"))?;
let artifact_path = artifact_root.join(filename);
if !artifact_path.is_file() {
bail!(
"artifact not found for stage `{}`: {}",
stage,
artifact_path.display()
);
}
let title_slug = artifact_slug(&args.title);
let file_stem = source_file
.file_stem()
.and_then(OsStr::to_str)
.unwrap_or("diagram");
let target_stem = if title_slug.is_empty() {
artifact_slug(file_stem)
} else {
title_slug
};
let target_name = format!("{stage}-{target_stem}.{ext}");
let assets_dir = artifact_root.join("assets").join("diagrams");
let target_file = assets_dir.join(target_name);
let same_file = same_existing_path(&source_file, &target_file);
if target_file.exists() && !args.force && !same_file {
bail!(
"refusing to overwrite existing diagram asset: {}",
target_file.display()
);
}
let rel_target = target_file
.strip_prefix(&artifact_root)
.unwrap_or(&target_file)
.to_string_lossy()
.replace('\\', "/");
let block = render_diagram_markdown_block(&args.title, &args.source, &stage, &rel_target, &ext);
let content = fs::read_to_string(&artifact_path)
.with_context(|| format!("reading {}", artifact_path.display()))?;
let next = insert_or_update_diagram_block(&content, &args.title, &block);
if args.dry_run {
println!("dry-run copy {}", target_file.display());
println!("dry-run update {}", artifact_path.display());
return Ok(());
}
fs::create_dir_all(&assets_dir)?;
if !same_file {
fs::copy(&source_file, &target_file).with_context(|| {
format!(
"copying diagram asset {} -> {}",
source_file.display(),
target_file.display()
)
})?;
}
tui::persist::write_atomic(&artifact_path, next.as_bytes())?;
println!("{}", artifact_path.display());
println!("{}", target_file.display());
Ok(())
}
fn render_diagram_markdown_block(
title: &str,
source: &str,
stage: &str,
rel_target: &str,
ext: &str,
) -> String {
let label = match ext {
"excalidraw" => "Excalidraw",
"svg" => "Visual SVG",
_ => "Visual HTML",
};
format!(
"### {title}\n\n\
**Pergunta respondida:** {title}\n\
**Fonte:** {source}\n\
**Escopo:** stage `{stage}`\n\
**Limitações:** companion visual derivado; Mermaid/Excalidraw continua sendo o contrato versionável quando aplicável.\n\n\
**{label}:** `{rel_target}`"
)
}
fn insert_or_update_diagram_block(markdown: &str, title: &str, block: &str) -> String {
let slug = artifact_slug(title);
let marker_start = format!("<!-- sdd-diagram:{slug} -->");
let marker_end = format!("<!-- /sdd-diagram:{slug} -->");
let managed_block = format!("{marker_start}\n{}\n{marker_end}", block.trim());
let mut lines = markdown.lines().map(str::to_string).collect::<Vec<_>>();
let Some((section_start, section_end)) = find_top_section(&lines, "Diagramas") else {
let mut next = markdown.trim_end().to_string();
next.push_str("\n\n## Diagramas\n");
next.push_str(&managed_block);
next.push('\n');
return next;
};
let body_start = section_start + 1;
let body = lines[body_start..section_end].join("\n");
let body_lower = body.to_lowercase();
let next_body = if let Some(start) = body.find(&marker_start) {
if let Some(relative_end) = body[start..].find(&marker_end) {
let end = start + relative_end + marker_end.len();
format!("{}{}{}", &body[..start], managed_block, &body[end..])
} else {
format!("{}\n\n{}", body.trim_end(), managed_block)
}
} else if body.trim().is_empty()
|| body_lower.contains("não aplicável")
|| body_lower.contains("nao aplicavel")
{
managed_block
} else {
format!("{}\n\n{}", body.trim_end(), managed_block)
};
lines.splice(
body_start..section_end,
next_body.lines().map(str::to_string).collect::<Vec<_>>(),
);
format!("{}\n", lines.join("\n"))
}
fn find_top_section(lines: &[String], heading: &str) -> Option<(usize, usize)> {
let target = normalize_heading(heading);
let start = lines
.iter()
.position(|line| top_heading_name(line).is_some_and(|name| name == target))?;
let end = lines
.iter()
.enumerate()
.skip(start + 1)
.find_map(|(idx, line)| top_heading_name(line).map(|_| idx))
.unwrap_or(lines.len());
Some((start, end))
}
fn top_heading_name(line: &str) -> Option<String> {
let trimmed = line.trim_start();
let hashes = trimmed.chars().take_while(|ch| *ch == '#').count();
if hashes == 0 || hashes > 2 {
return None;
}
let rest = trimmed.get(hashes..)?;
if !rest.starts_with(' ') {
return None;
}
Some(normalize_heading(rest.trim()))
}
fn run_diagram_doctor(root_arg: &Path, args: DiagramDoctorArgs) -> Result<()> {
let root = runtime::platform::canonicalize(root_arg).unwrap_or_else(|_| root_arg.to_path_buf());
let artifact_root = artifact_dir(&root, &args.name);
let assets_dir = artifact_root.join("assets").join("diagrams");
let skill_path = find_diagram_design_skill(&root);
let style_guide_status = skill_path
.as_ref()
.map(|path| style_guide_status(path.parent().unwrap_or(path)))
.unwrap_or_else(|| "missing".to_string());
let mut warnings = Vec::new();
if !artifact_root.exists() {
warnings.push(format!(
"artifact store missing: {}",
artifact_root.display()
));
}
if !assets_dir.exists() {
warnings.push(format!(
"assets dir will be created on first attach: {}",
assets_dir.display()
));
}
if style_guide_status == "default" {
warnings.push(
"diagram-design style guide still uses default neutral tokens; run onboarding when branded visuals are needed"
.to_string(),
);
}
let status = if skill_path.is_none() {
"fail"
} else if !artifact_root.exists() {
"warn"
} else {
"pass"
};
if skill_path.is_none() {
warnings.push("diagram-design skill bundle not found; run `sdd skills sync`".to_string());
}
let report = DiagramDoctorReport {
status,
orchestration: args.name,
artifact_dir: artifact_root.display().to_string(),
artifact_dir_exists: artifact_root.exists(),
assets_dir: assets_dir.display().to_string(),
assets_dir_exists: assets_dir.exists(),
skill_path: skill_path.map(|path| path.display().to_string()),
skill_status: if status == "pass" {
"installed".to_string()
} else {
"missing".to_string()
},
style_guide_status,
auto_markdown: true,
visual: "explicit",
visual_stage_allowlist: vec!["idea", "prd", "techspec"],
warnings,
};
if args.json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print_diagram_doctor(&report);
}
if report.status != "pass" {
bail!("diagram doctor failed");
}
Ok(())
}
fn find_diagram_design_skill(root: &Path) -> Option<PathBuf> {
[
".agents/skills/diagram-design/SKILL.md",
".claude/skills/diagram-design/SKILL.md",
".cursor/skills/diagram-design/SKILL.md",
".opencode/skills/diagram-design/SKILL.md",
".devin/skills/diagram-design/SKILL.md",
".trae/skills/diagram-design/SKILL.md",
"plugins/orchestration/skills/diagram-design/SKILL.md",
]
.into_iter()
.map(|rel| root.join(rel))
.find(|path| path.is_file())
}
fn style_guide_status(skill_root: &Path) -> String {
let path = skill_root.join("references").join("style-guide.md");
let Ok(text) = fs::read_to_string(path) else {
return "missing".to_string();
};
let text = text.to_ascii_lowercase();
let current_default_tokens = ["#f5f5f5", "#2d3142", "#eb6c36", "#4f5d75"];
if text.contains("#b5523a")
|| current_default_tokens
.iter()
.all(|token| text.contains(token))
{
"default".to_string()
} else {
"custom".to_string()
}
}
fn print_diagram_doctor(report: &DiagramDoctorReport) {
println!("SDD diagram doctor");
println!("{}", report.status.to_uppercase());
println!("Artifact store: {}", report.artifact_dir);
println!(
"Assets: {} ({})",
report.assets_dir,
if report.assets_dir_exists {
"exists"
} else {
"will be created"
}
);
println!("diagram-design: {}", report.skill_status);
if let Some(path) = &report.skill_path {
println!("Skill path: {path}");
}
println!("Style guide: {}", report.style_guide_status);
println!("auto_markdown: {}", report.auto_markdown);
println!("visual: {}", report.visual);
println!(
"visual_stage_allowlist: {}",
report.visual_stage_allowlist.join(",")
);
if !report.warnings.is_empty() {
println!("Warnings");
for warning in &report.warnings {
println!("- {warning}");
}
}
}
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,2}\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_marker = contract::artifact_allowed_diagram_markers()
.into_iter()
.map(|marker| marker.to_lowercase())
.any(|marker| body_lower.contains(&marker));
let valid_visual_companion = Regex::new(r#"(?i)assets/diagrams/[^\s`)'"<>]+\.(html|svg)"#)
.map(|regex| regex.is_match(body))
.unwrap_or(false);
if !valid_marker && !valid_visual_companion {
issues.push(
"Diagramas deve conter Mermaid, referência `.excalidraw`, companion `assets/diagrams/*.html|*.svg` 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 {
print_providers_list(&catalog);
}
}
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 {
print_providers_doctor(&statuses);
}
}
}
Ok(())
}
fn provider_auth_methods(status: &Value) -> Vec<(String, bool, Option<String>, Option<String>)> {
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)?.to_string();
let present = item
.get("present")
.and_then(Value::as_bool)
.unwrap_or(false);
let login = item
.get("login_command")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(str::to_string);
let check = item
.get("check_command")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(str::to_string);
Some((label, present, login, check))
})
.collect()
})
.unwrap_or_default()
}
fn print_providers_doctor(statuses: &[Value]) {
let total = statuses.len();
let missing: Vec<&Value> = statuses
.iter()
.filter(|status| {
!status
.get("present")
.and_then(Value::as_bool)
.unwrap_or(true)
})
.collect();
let status = if missing.is_empty() { "pass" } else { "fail" };
let signal = status_signal(status, false, !missing.is_empty());
banner("providers doctor");
status_line(status, signal);
summary_line(
"Providers",
&format!(
"{} checked · {} missing · {} ok",
total,
missing.len(),
total - missing.len()
),
);
banner_close();
section("Missing", missing.len());
if missing.is_empty() {
section_none();
} else {
for status in &missing {
print_provider_card(status, "✕");
}
}
let ok: Vec<&Value> = statuses
.iter()
.filter(|status| {
status
.get("present")
.and_then(Value::as_bool)
.unwrap_or(true)
})
.collect();
section("Checked", ok.len());
if ok.is_empty() {
section_none();
} else {
for status in &ok {
print_provider_card(status, "✓");
}
}
}
fn print_providers_list(catalog: &[ProviderCatalogEntry]) {
let count = catalog.len();
banner("providers");
summary_line("Catalog", &format!("{count} providers"));
banner_close();
section("Providers", count);
if count == 0 {
section_none();
return;
}
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
.iter()
.filter(|method| method.present)
.map(|method| method.label.as_str())
.collect::<Vec<_>>()
.join(", ");
let auth = if auth.is_empty() {
"missing".to_string()
} else {
auth
};
item_labeled(
"◇",
&provider.id,
14,
&format!(
"{} · {model} · {effort} · {auth} · {enabled}",
provider.kind
),
);
}
}
fn print_provider_card(status: &Value, icon: &str) {
let id = status
.get("id")
.and_then(Value::as_str)
.unwrap_or("unknown");
let kind = status.get("kind").and_then(Value::as_str).unwrap_or("?");
let model = status
.get("model")
.and_then(Value::as_str)
.unwrap_or("default");
let effort = status
.get("effort")
.and_then(Value::as_str)
.unwrap_or("medium");
let enabled = status
.get("enabled")
.and_then(Value::as_bool)
.unwrap_or(true);
let enabled_label = if enabled { "enabled" } else { "disabled" };
item_labeled(
icon,
id,
14,
&format!("{kind} · {model} · {effort} · {enabled_label}"),
);
let methods = provider_auth_methods(status);
if methods.is_empty() {
let auth_env = status
.get("auth_env")
.and_then(Value::as_str)
.unwrap_or("none");
println!(" └─ auth: {auth_env}");
} else {
let details: Vec<String> = methods
.iter()
.map(|(label, present, login, check)| {
let state = if *present { "present" } else { "missing" };
match (*present, login, check) {
(false, Some(login), _) => format!("{label}:{state} login=`{login}`"),
(false, None, Some(check)) => format!("{label}:{state} check=`{check}`"),
_ => format!("{label}:{state}"),
}
})
.collect();
tree(&details, Some(6));
}
}
fn run_eval(args: EvalArgs) -> Result<()> {
let root = runtime::platform::canonicalize(&args.root).unwrap_or_else(|_| args.root.clone());
match args.command {
EvalCommand::Stage { name, stage, json } => {
let report = domain::evaluation::evaluate_stage(&root, &name, &stage)?;
log_jsonl(
&root,
"evaluations.jsonl",
domain::evaluation::evaluation_event("eval_stage", &report),
)?;
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print_stage_evaluation(&report);
}
if report.has_critical() {
bail!("stage evaluation failed");
}
}
EvalCommand::Orchestration { name, json } => {
let report = domain::evaluation::evaluate_orchestration(&root, &name)?;
log_jsonl(
&root,
"evaluations.jsonl",
domain::evaluation::evaluation_event("eval_orchestration", &report),
)?;
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print_orchestration_evaluation(&report);
}
if report.critical_count > 0 {
bail!("orchestration evaluation failed");
}
}
}
Ok(())
}
fn run_quality(args: QualityArgs) -> Result<()> {
let root = runtime::platform::canonicalize(&args.root).unwrap_or_else(|_| args.root.clone());
match args.command {
QualityCommand::Report { name, json } => {
let report = domain::evaluation::quality_report(&root, &name)?;
log_jsonl(
&root,
"evaluations.jsonl",
domain::evaluation::evaluation_event("quality_report", &report),
)?;
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print_quality_report(&report);
}
if report.status != "pass" {
bail!("quality report failed");
}
}
}
Ok(())
}
fn run_health(args: HealthArgs) -> Result<()> {
if args.json && args.markdown {
bail!("use apenas um formato: --json ou --markdown");
}
let root = runtime::platform::canonicalize(&args.root).unwrap_or_else(|_| args.root.clone());
let report = health_report(&root, args.name.as_deref())?;
if args.json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else if args.markdown {
print!("{}", render_health_markdown(&report));
} else {
print_health_terminal(&report);
}
if report.get("status").and_then(Value::as_str) == Some("fail") {
bail!("health failed");
}
if args.strict && report.get("status").and_then(Value::as_str) != Some("pass") {
bail!("health strict failed");
}
Ok(())
}
fn print_health_terminal(report: &Value) {
let status = report
.get("status")
.and_then(Value::as_str)
.unwrap_or("unknown");
let signal = match status {
"pass" => "clean",
"warn" => "attention",
"fail" => "blocked",
_ => "unknown",
};
let components = report.get("components").and_then(Value::as_object);
let component_list = [
"optimization",
"intelligence",
"workflow",
"clients",
"capabilities",
"skills",
"quality",
];
let failed = component_list
.iter()
.filter_map(|key| components.and_then(|map| map.get(*key)))
.filter(|value| component_status(value) == Some("fail"))
.count();
let warned = component_list
.iter()
.filter_map(|key| components.and_then(|map| map.get(*key)))
.filter(|value| component_status(value) == Some("warn"))
.count();
banner("health");
status_line(status, signal);
summary_line(
"Components",
&format!(
"{} checked · {} failed · {} warned",
component_list.len(),
failed,
warned
),
);
if let Some(root) = report.get("root").and_then(Value::as_str) {
summary_line("Root", root);
}
if let Some(name) = report.get("orchestration").and_then(Value::as_str) {
summary_line("Orquestração", name);
}
banner_close();
section("Components", component_list.len());
for key in component_list {
let Some(value) = components.and_then(|map| map.get(key)) else {
continue;
};
let comp_status = component_status(value).unwrap_or("info");
let icon = cli::style::colored_status_icon(comp_status);
let colored_status = cli::style::colored_status_label(comp_status);
let note = health_component_note(value);
item_labeled(&icon, key, 14, &format!("{colored_status} · {note}"));
}
}
fn health_report(root: &Path, name: Option<&str>) -> Result<Value> {
let config = load_sdd_config(root)?;
let optimization =
serde_json::to_value(runtime::optimization::status(root, &config.optimization))?;
let intelligence_signals = scoped_intelligence_health_signals(root, name)?;
let workflow = validate_workflow(root, "agentic-sdd-loop");
let clients = client_doctor(root);
let clients_value = json!({
"status": clients.status,
"missing": clients.missing,
"invalid": clients.invalid,
"covered": clients.covered,
});
let targets = ClientTarget::all();
let capabilities_value = match capability_doctor(root, &targets) {
Ok(report) => serde_json::to_value(report)?,
Err(error) => json!({
"status": "fail",
"error": format!("{error:#}"),
}),
};
let skills_value = match skills_doctor(root, true) {
Ok(report) => serde_json::to_value(report)?,
Err(error) => json!({
"status": "fail",
"error": format!("{error:#}"),
}),
};
let quality_value = match name {
Some(name) => match domain::evaluation::quality_report(root, name) {
Ok(report) => serde_json::to_value(report)?,
Err(error) => json!({
"status": "fail",
"error": format!("{error:#}"),
"orchestration": name,
}),
},
None => json!({
"status": "skipped",
"reason": "--name não informado; quality report exige uma orquestração",
}),
};
let intelligence_warning_count = intelligence_signals
.iter()
.filter(|signal| {
signal
.get("severity")
.and_then(Value::as_str)
.and_then(severity_rank)
.is_some_and(|severity| severity >= 2)
})
.count();
let intelligence_repair_command = match name {
Some(name) => format!("sdd intelligence repair --name \"{name}\" --json"),
None => "sdd intelligence repair --all --json".to_string(),
};
let components = json!({
"optimization": optimization,
"intelligence": {
"status": if intelligence_warning_count == 0 { "pass" } else { "warn" },
"signal_count": intelligence_signals.len(),
"warning_count": intelligence_warning_count,
"signals": intelligence_signals,
"repair_command": intelligence_repair_command,
},
"workflow": workflow,
"clients": clients_value,
"capabilities": capabilities_value,
"skills": skills_value,
"quality": quality_value,
});
let failed = ["workflow", "clients", "capabilities", "skills", "quality"]
.iter()
.any(|key| component_status(&components[*key]) == Some("fail"));
let warned = component_status(&components["intelligence"]) == Some("warn");
let status = if failed {
"fail"
} else if warned {
"warn"
} else {
"pass"
};
Ok(json!({
"status": status,
"generated_at": now(),
"root": root,
"orchestration": name,
"components": components,
}))
}
fn scoped_intelligence_health_signals(root: &Path, name: Option<&str>) -> Result<Vec<Value>> {
let signals = intelligence_health_signals(root)?;
let Some(name) = name else {
return Ok(signals);
};
let docs_prefix = format!("docs/{}/", artifact_slug(name));
Ok(signals
.into_iter()
.filter(|signal| {
signal
.get("orchestration_slug")
.and_then(Value::as_str)
.is_some_and(|slug| slug == name || slug == artifact_slug(name))
|| signal
.get("message")
.and_then(Value::as_str)
.is_some_and(|message| message.contains(name) || message.contains(&docs_prefix))
})
.collect())
}
fn component_status(value: &Value) -> Option<&str> {
value.get("status").and_then(Value::as_str)
}
fn render_health_markdown(report: &Value) -> String {
let mut out = String::new();
let status = report
.get("status")
.and_then(Value::as_str)
.unwrap_or("unknown");
let status_emoji = match status {
"pass" => "🟢 **PASS**",
"warn" => "🟡 **WARN**",
"fail" => "🔴 **FAIL**",
"skipped" | "skip" => "⚪ **SKIPPED**",
_ => "⚪ **UNKNOWN**",
};
out.push_str("# 🩺 SDD Health\n\n");
out.push_str(&format!("**Status:** {status_emoji} \n"));
if let Some(root) = report.get("root").and_then(Value::as_str) {
out.push_str(&format!("**Root:** `{root}` \n"));
}
if let Some(name) = report.get("orchestration").and_then(Value::as_str) {
out.push_str(&format!("**Orquestração:** `{name}` \n"));
}
out.push_str("\n---\n\n## 🧱 Componentes\n\n");
out.push_str("| Componente | Status | Observação |\n");
out.push_str("| :--- | :---: | :--- |\n");
let mut recommendations = Vec::new();
if let Some(components) = report.get("components").and_then(Value::as_object) {
for key in [
"optimization",
"intelligence",
"workflow",
"clients",
"capabilities",
"skills",
"quality",
] {
if let Some(value) = components.get(key) {
let comp_status = component_status(value).unwrap_or("info");
let icon = match comp_status {
"pass" => "🟢",
"warn" => "🟡",
"fail" => "🔴",
"skipped" | "skip" => "⚪",
"info" => "⚪",
_ => "⚪",
};
let note = health_component_note(value);
out.push_str(&format!(
"| `{}` | {} `{}` | {} |\n",
key, icon, comp_status, note
));
if comp_status == "warn" || comp_status == "fail" {
let repair_cmd = value.get("repair_command").and_then(Value::as_str);
recommendations.push((key, comp_status, note, repair_cmd));
}
}
}
}
if !recommendations.is_empty() {
out.push_str("\n---\n\n## ⚠️ Ações Recomendadas\n\n");
for (comp, status, note, repair_cmd) in recommendations {
let alert_type = match status {
"fail" => "IMPORTANT",
_ => "WARNING",
};
out.push_str(&format!("> [!{alert_type}]\n"));
out.push_str(&format!(
"> **Componente `{comp}`** está em estado `{status}`\n"
));
let clean_note = if let Some(idx) = note.find("; repare com") {
note[..idx].trim().to_string()
} else {
note.clone()
};
out.push_str(&format!("> Observação: {clean_note}\n"));
if let Some(cmd) = repair_cmd {
out.push_str(&format!("> 👉 Para reparar: `{}`\n", cmd));
}
out.push('\n');
}
}
out.push('\n');
out
}
fn health_component_note(value: &Value) -> String {
if let Some(error) = value.get("error").and_then(Value::as_str) {
return error.replace('|', "\\|");
}
if let Some(count) = value.get("signal_count").and_then(Value::as_u64) {
let warnings = value
.get("warning_count")
.and_then(Value::as_u64)
.unwrap_or(count);
let mut note = if warnings == count {
format!("{count} sinal(is)")
} else {
format!("{count} sinal(is), {warnings} warning(s)")
};
if let Some(command) = value.get("repair_command").and_then(Value::as_str) {
note.push_str(&format!("; repare com `{}`", command.replace('|', "\\|")));
}
return note;
}
if let Some(reason) = value.get("reason").and_then(Value::as_str) {
return reason.replace('|', "\\|");
}
if let Some(issues) = value.get("issues").and_then(Value::as_array) {
return format!("{} issue(s)", issues.len());
}
"ok".to_string()
}
fn run_maintenance(args: MaintenanceArgs) -> Result<()> {
let root = resolve_project_root(Some(args.root))?;
match args.command {
MaintenanceCommand::Report(report_args) => {
if report_args.json && report_args.markdown {
bail!("use apenas um formato: --json ou --markdown");
}
let report = maintenance_report(&root, report_args.name.as_deref())?;
if report_args.json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else if report_args.markdown {
print!("{}", render_maintenance_markdown(&report));
} else {
print_maintenance_terminal(&report);
}
if report.get("status").and_then(Value::as_str) == Some("fail") {
bail!("maintenance report failed");
}
}
}
Ok(())
}
fn maintenance_report(root: &Path, name: Option<&str>) -> Result<Value> {
let started = Instant::now();
let health = health_report(root, name)?;
let components = health
.get("components")
.and_then(Value::as_object)
.ok_or_else(|| anyhow!("health report sem components"))?;
let mut areas = Vec::new();
areas.push(maintenance_area(
"core-rust",
"Core Rust",
"skipped",
vec![
format!(
"Cargo.toml detectado={}; checks caros não executados por maintenance report",
root.join("Cargo.toml").is_file()
),
"Validação real fica no gate: cargo fmt, clippy all-targets all-features e cargo test --locked"
.to_string(),
],
"rode `sdd ci --strict-release` antes de merge/release",
));
areas.push(maintenance_area(
"docs",
"Docs",
"skipped",
vec![
format!(
"docs-site/package.json detectado={}; geração, links e build não executados por maintenance report",
root.join("docs-site/package.json").is_file()
),
"Validação real fica no gate: docs:generate --check, docs:links e build".to_string(),
],
"rode o gate strict/release para validar geração, links e build",
));
areas.push(maintenance_area(
"clients",
"Clients",
component_status(&components["clients"]).unwrap_or("info"),
vec![health_component_note(&components["clients"])],
"rode `sdd clients doctor --strict --json` quando houver drift",
));
areas.push(maintenance_area(
"bot",
"Bot",
"skipped",
vec![
format!(
"bot/package.json detectado={}; build/test não executados por maintenance report",
root.join("bot/package.json").is_file()
),
"Validação real fica no gate: npm run build e npm test em bot/".to_string(),
],
"rode `cd bot && npm run build && npm test` ou o gate strict/release",
));
areas.push(maintenance_providers_area(root));
areas.push(maintenance_area(
"intelligence",
"Intelligence",
component_status(&components["intelligence"]).unwrap_or("info"),
vec![health_component_note(&components["intelligence"])],
"gere memória dos ciclos concluídos ou rode `sdd intelligence health --json`",
));
areas.push(maintenance_area(
"workflows",
"Workflows",
component_status(&components["workflow"]).unwrap_or("info"),
vec![health_component_note(&components["workflow"])],
"rode `sdd workflow validate agentic-sdd-loop --json`",
));
areas.push(maintenance_area(
"release",
"Release",
"skipped",
vec![
"gate disponível em `sdd ci --strict-release`, mas não executado por maintenance report"
.to_string(),
],
"use `sdd ci --strict-release --name <slug>` como contrato local/CI",
));
areas.push(maintenance_diagram_area(root, name));
areas.push(maintenance_advisory_area(root));
let status = aggregate_area_status(&areas);
Ok(json!({
"status": status,
"generated_at": now(),
"duration_ms": started.elapsed().as_millis(),
"root": root,
"orchestration": name,
"areas": areas,
"health": health,
}))
}
fn maintenance_area(
id: &str,
label: &str,
status: &str,
evidence: Vec<String>,
action: &str,
) -> Value {
json!({
"id": id,
"label": label,
"status": status,
"evidence": evidence,
"recommended_action": action,
})
}
fn maintenance_providers_area(root: &Path) -> Value {
match provider_catalog_fast(root) {
Ok(catalog) => {
let enabled = catalog.iter().filter(|provider| provider.enabled).count();
maintenance_area(
"providers",
"Providers",
"pass",
vec![format!(
"{} provider(s) no catálogo rápido; {} enabled",
catalog.len(),
enabled
)],
"rode `sdd providers doctor --json` para validar credenciais/modelos",
)
}
Err(error) => maintenance_area(
"providers",
"Providers",
"fail",
vec![format!("{error:#}")],
"corrija `sdd.config.yaml` ou adapters de providers",
),
}
}
fn maintenance_diagram_area(root: &Path, name: Option<&str>) -> Value {
let Some(name) = name else {
return maintenance_area(
"diagrams",
"Diagrams",
"skipped",
vec!["--name não informado; diagram doctor exige uma orquestração".to_string()],
"rode `sdd diagram doctor --name <slug>` quando houver companion visual",
);
};
let artifact_root = artifact_dir(root, name);
let skill_path = find_diagram_design_skill(root);
let assets_dir = artifact_root.join("assets").join("diagrams");
let mut evidence = vec![
format!("artifact_dir_exists={}", artifact_root.exists()),
format!("assets_dir_exists={}", assets_dir.exists()),
];
if let Some(path) = skill_path.as_ref() {
evidence.push(format!("skill={}", path.display()));
}
let status = if skill_path.is_some() { "pass" } else { "fail" };
maintenance_area(
"diagrams",
"Diagrams",
status,
evidence,
"rode `sdd diagram doctor --name <slug>` para detalhes de assets e style guide",
)
}
fn maintenance_advisory_area(root: &Path) -> Value {
let checks = advisory_gate_checks(root);
let missing = checks
.iter()
.filter(|check| required_tool_missing(check))
.map(|check| check.display.clone())
.collect::<Vec<_>>();
let available = checks.len().saturating_sub(missing.len());
let status = if checks.is_empty() || available == 0 {
"skipped"
} else if !missing.is_empty() {
"warn"
} else {
"skipped"
};
maintenance_area(
"advisory",
"Advisory",
status,
vec![
format!("{available} advisory check(s) com ferramenta disponível, não executados no report rápido"),
format!("{} advisory check(s) sem ferramenta local", missing.len()),
],
"instale cargo-audit, cargo-deny, gitleaks ou rode os advisory checks em CI",
)
}
fn aggregate_area_status(areas: &[Value]) -> &'static str {
if areas
.iter()
.any(|area| component_status(area) == Some("fail"))
{
"fail"
} else if areas
.iter()
.any(|area| matches!(component_status(area), Some("warn")))
{
"warn"
} else {
"pass"
}
}
fn print_maintenance_terminal(report: &Value) {
let status = report
.get("status")
.and_then(Value::as_str)
.unwrap_or("unknown");
let signal = match status {
"pass" => "clean",
"warn" => "attention",
"fail" => "blocked",
_ => "unknown",
};
let areas = report.get("areas").and_then(Value::as_array);
let area_count = areas.map(Vec::len).unwrap_or(0);
let failed = areas
.iter()
.flat_map(|list| list.iter())
.filter(|area| component_status(area) == Some("fail"))
.count();
let warned = areas
.iter()
.flat_map(|list| list.iter())
.filter(|area| component_status(area) == Some("warn"))
.count();
banner("maintenance report");
status_line(status, signal);
summary_line(
"Areas",
&format!(
"{area_count} checked · {} failed · {} warned",
failed, warned
),
);
if let Some(root) = report.get("root").and_then(Value::as_str) {
summary_line("Root", root);
}
if let Some(name) = report.get("orchestration").and_then(Value::as_str) {
summary_line("Orquestração", name);
}
banner_close();
section("Areas", area_count);
if let Some(areas) = areas {
for area in areas {
let label = area.get("label").and_then(Value::as_str).unwrap_or("area");
let area_status = component_status(area).unwrap_or("info");
let icon = status_icon(area_status);
let action = area
.get("recommended_action")
.and_then(Value::as_str)
.unwrap_or("ok");
item_labeled(icon, label, 14, &format!("{area_status} · next: {action}"));
if let Some(evidence) = area.get("evidence").and_then(Value::as_array) {
let details: Vec<String> = evidence
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect();
if !details.is_empty() {
tree(&details, Some(3));
}
}
}
}
}
fn render_maintenance_markdown(report: &Value) -> String {
let mut out = String::new();
let status = report
.get("status")
.and_then(Value::as_str)
.unwrap_or("unknown");
out.push_str("# SDD Maintenance Report\n\n");
out.push_str(&format!("- Status: `{status}`\n"));
if let Some(root) = report.get("root").and_then(Value::as_str) {
out.push_str(&format!("- Root: `{root}`\n"));
}
if let Some(name) = report.get("orchestration").and_then(Value::as_str) {
out.push_str(&format!("- Orquestração: `{name}`\n"));
}
out.push_str("\n## Áreas\n\n");
out.push_str("| Área | Status | Evidência | Próxima ação |\n");
out.push_str("|---|---|---|---|\n");
if let Some(areas) = report.get("areas").and_then(Value::as_array) {
for area in areas {
let label = area.get("label").and_then(Value::as_str).unwrap_or("area");
let status = component_status(area).unwrap_or("info");
let evidence = area
.get("evidence")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(|item| item.replace('|', "\\|"))
.collect::<Vec<_>>()
.join("<br>")
})
.unwrap_or_default();
let action = area
.get("recommended_action")
.and_then(Value::as_str)
.unwrap_or("ok")
.replace('|', "\\|");
out.push_str(&format!(
"| {label} | `{status}` | {evidence} | {action} |\n"
));
}
}
out.push('\n');
out
}
fn print_quality_report(report: &domain::evaluation::QualityReport) {
let status = report.status.as_str();
let signal = status_signal(status, false, false);
banner("quality report");
status_line(status, signal);
summary_line("Orquestração", &report.orchestration);
summary_line(
"Evaluation",
&format!(
"{} stages · {} critical",
report.evaluation.stages.len(),
report.evaluation.critical_count
),
);
summary_line("Tools", &format!("{} configured", report.tools.len()));
banner_close();
if !report.tools.is_empty() {
section("Tools", report.tools.len());
for tool in &report.tools {
let icon = if tool.available { "✓" } else { "✕" };
let state = if tool.available {
"available"
} else if tool.required {
"missing (required)"
} else {
"missing (optional)"
};
item_labeled(icon, &tool.id, 14, &format!("{state} · `{}`", tool.command));
}
}
if !report.recommendations.is_empty() {
section("Recommendations", report.recommendations.len());
for rec in &report.recommendations {
item("◇", rec);
}
}
}
fn print_stage_evaluation(report: &domain::evaluation::StageEvaluation) {
let status = report.status.as_str();
let signal = status_signal(status, false, false);
let critical = report
.issues
.iter()
.filter(|issue| issue.severity == domain::evaluation::EvaluationSeverity::Critical)
.count();
banner("eval stage");
status_line(status, signal);
summary_line("Stage", &report.stage);
summary_line(
"Issues",
&format!("{} total · {} critical", report.issues.len(), critical),
);
banner_close();
section("Issues", report.issues.len());
if report.issues.is_empty() {
section_none();
} else {
for issue in &report.issues {
let icon = match issue.severity {
domain::evaluation::EvaluationSeverity::Critical => "✕",
domain::evaluation::EvaluationSeverity::Warning => "⚠",
};
item(icon, &format!("{}: {}", issue.check, issue.message));
}
}
}
fn print_orchestration_evaluation(report: &domain::evaluation::OrchestrationEvaluation) {
let status = report.status.as_str();
let signal = status_signal(status, false, false);
banner("eval orchestration");
status_line(status, signal);
summary_line("Orquestração", &report.orchestration);
summary_line(
"Stages",
&format!(
"{} stages · {} issues · {} critical",
report.stages.len(),
report.issue_count,
report.critical_count
),
);
banner_close();
section("Stages", report.stages.len());
if report.stages.is_empty() {
section_none();
} else {
for stage in &report.stages {
let icon = status_icon(stage.status.as_str());
item_labeled(
icon,
&stage.stage,
14,
&format!("{} · {} issues", stage.status, stage.issues.len()),
);
}
}
}
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 provider_pricing_for_stage<'a>(
provider: &'a ProviderSelection,
stage: &str,
) -> Option<&'a ProviderModelPricing> {
let model = provider.effective_model_for_stage(stage);
provider
.models
.iter()
.find(|entry| entry.id == model)
.map(|entry| &entry.pricing)
}
fn usage_env_value(name: &str) -> Option<usize> {
let raw = env::var(name).ok()?;
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
trimmed.parse::<usize>().ok()
}
fn provider_reported_usage_from_env() -> Option<TokenUsageSample> {
let input = usage_env_value("SDD_USAGE_INPUT_TOKENS")?;
let cached = usage_env_value("SDD_USAGE_CACHED_INPUT_TOKENS").unwrap_or(0);
let output = usage_env_value("SDD_USAGE_OUTPUT_TOKENS").unwrap_or(0);
let reasoning = usage_env_value("SDD_USAGE_REASONING_OUTPUT_TOKENS").unwrap_or(0);
let total = usage_env_value("SDD_USAGE_TOTAL_TOKENS").unwrap_or(input + output + reasoning);
Some(TokenUsageSample::from_provider_usage(
input, cached, output, reasoning, total,
))
}
fn token_usage_for_artifact(
provider: &ProviderSelection,
stage: &str,
input: &str,
output: &str,
) -> TokenUsageSample {
provider_reported_usage_from_env()
.unwrap_or_else(|| TokenUsageSample::from_text(input, output))
.with_cost(provider_pricing_for_stage(provider, stage))
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct GenerationTimingSample {
started_at: Option<String>,
execution_started_at: Option<String>,
finished_at: Option<String>,
duration_ms: Option<u128>,
reasoning_duration_ms: Option<u128>,
execution_duration_ms: Option<u128>,
provider_reported: bool,
}
#[derive(Clone, Copy, Default)]
struct ArtifactTelemetry<'a> {
usage: Option<&'a TokenUsageSample>,
timing: Option<&'a GenerationTimingSample>,
}
struct GenerationTimer {
started_at: String,
instant: Instant,
}
impl GenerationTimer {
fn start() -> Self {
Self {
started_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
instant: Instant::now(),
}
}
fn finish(self) -> GenerationTimingSample {
GenerationTimingSample {
started_at: Some(self.started_at),
execution_started_at: None,
finished_at: Some(Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)),
duration_ms: Some(self.instant.elapsed().as_millis()),
reasoning_duration_ms: None,
execution_duration_ms: None,
provider_reported: false,
}
}
}
fn env_non_empty(name: &str) -> Option<String> {
env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
fn timing_env_ms(name: &str) -> Option<u128> {
env_non_empty(name)?.parse::<u128>().ok()
}
fn provider_reported_timing_from_env() -> Option<GenerationTimingSample> {
let started_at = env_non_empty("SDD_GENERATION_STARTED_AT");
let execution_started_at = env_non_empty("SDD_GENERATION_EXECUTION_STARTED_AT");
let finished_at = env_non_empty("SDD_GENERATION_FINISHED_AT");
let duration_ms = timing_env_ms("SDD_GENERATION_DURATION_MS");
let reasoning_duration_ms = timing_env_ms("SDD_GENERATION_REASONING_DURATION_MS");
let execution_duration_ms = timing_env_ms("SDD_GENERATION_EXECUTION_DURATION_MS");
if started_at.is_none()
&& execution_started_at.is_none()
&& finished_at.is_none()
&& duration_ms.is_none()
&& reasoning_duration_ms.is_none()
&& execution_duration_ms.is_none()
{
return None;
}
Some(GenerationTimingSample {
started_at,
execution_started_at,
finished_at,
duration_ms,
reasoning_duration_ms,
execution_duration_ms,
provider_reported: true,
})
}
fn generation_timing_for_artifact(timer: GenerationTimer) -> GenerationTimingSample {
provider_reported_timing_from_env().unwrap_or_else(|| timer.finish())
}
fn traceability_usage_lines(usage: &TokenUsageSample) -> String {
let input_cached = if usage.cached_input_tokens_estimate > 0 {
format!(" (cache ~{})", usage.cached_input_tokens_estimate)
} else {
String::new()
};
let output_reasoning = if usage.reasoning_output_tokens_estimate > 0 {
format!(" (reasoning ~{})", usage.reasoning_output_tokens_estimate)
} else {
String::new()
};
let source = if usage.provider_reported {
"provider-reported"
} else {
"estimated"
};
let mut out = format!(
"- Tokens: input ~{}{input_cached}, output ~{}{output_reasoning}, total ~{}\n- Tokens source: {source}\n",
usage.input_tokens_estimate,
usage.output_tokens_estimate,
usage.total_tokens_estimate
);
if let Some(cost) = &usage.cost {
out.push_str(&format!(
"- Custo estimado: {} {:.6}\n",
cost.currency, cost.total_cost
));
}
out
}
fn traceability_timing_lines(timing: &GenerationTimingSample) -> String {
let period = match (&timing.started_at, &timing.finished_at) {
(Some(started), Some(finished)) => format!("{started} -> {finished}"),
(Some(started), None) => format!("started {started}"),
(None, Some(finished)) => format!("finished {finished}"),
(None, None) => "period unreported".to_string(),
};
let duration = timing
.duration_ms
.map(|value| format!(" (~{value} ms)"))
.unwrap_or_default();
let source = if timing.provider_reported {
"provider-reported"
} else {
"local"
};
let mut out = format!("- Tempo de geração: {period}{duration}\n- Tempo source: {source}\n");
if let Some(started) = &timing.execution_started_at {
out.push_str(&format!("- Execução iniciada: {started}\n"));
}
if timing.reasoning_duration_ms.is_some() || timing.execution_duration_ms.is_some() {
let reasoning = timing
.reasoning_duration_ms
.map(|value| format!("reasoning ~{value} ms"))
.unwrap_or_else(|| "reasoning não reportado".to_string());
let execution = timing
.execution_duration_ms
.map(|value| format!("execution ~{value} ms"))
.unwrap_or_else(|| "execution não reportado".to_string());
out.push_str(&format!("- Tempo fases: {reasoning}, {execution}\n"));
}
out
}
fn html_traceability_usage(usage: &TokenUsageSample) -> String {
let input_cached = if usage.cached_input_tokens_estimate > 0 {
format!(" (cache ~{})", usage.cached_input_tokens_estimate)
} else {
String::new()
};
let output_reasoning = if usage.reasoning_output_tokens_estimate > 0 {
format!(" (reasoning ~{})", usage.reasoning_output_tokens_estimate)
} else {
String::new()
};
let source = if usage.provider_reported {
"provider-reported"
} else {
"estimated"
};
let mut out = format!(
"Tokens: <code>input ~{}{}</code> · <code>output ~{}{}</code> · <code>total ~{}</code>. Tokens source: <code>{}</code>.",
usage.input_tokens_estimate,
html_escape(&input_cached),
usage.output_tokens_estimate,
html_escape(&output_reasoning),
usage.total_tokens_estimate,
source
);
if let Some(cost) = &usage.cost {
out.push_str(&format!(
" Custo estimado: <code>{} {:.6}</code>.",
html_escape(&cost.currency),
cost.total_cost
));
}
out
}
fn html_traceability_timing(timing: &GenerationTimingSample) -> String {
let period = match (&timing.started_at, &timing.finished_at) {
(Some(started), Some(finished)) => format!(
"<code>{}</code> -> <code>{}</code>",
html_escape(started),
html_escape(finished)
),
(Some(started), None) => format!("started <code>{}</code>", html_escape(started)),
(None, Some(finished)) => format!("finished <code>{}</code>", html_escape(finished)),
(None, None) => "period unreported".to_string(),
};
let duration = timing
.duration_ms
.map(|value| format!(" (<code>~{value} ms</code>)"))
.unwrap_or_default();
let source = if timing.provider_reported {
"provider-reported"
} else {
"local"
};
let mut out =
format!("Tempo de geração: {period}{duration}. Tempo source: <code>{source}</code>.");
if let Some(started) = &timing.execution_started_at {
out.push_str(&format!(
" Execução iniciada: <code>{}</code>.",
html_escape(started)
));
}
if timing.reasoning_duration_ms.is_some() || timing.execution_duration_ms.is_some() {
let reasoning = timing
.reasoning_duration_ms
.map(|value| format!("reasoning ~{value} ms"))
.unwrap_or_else(|| "reasoning não reportado".to_string());
let execution = timing
.execution_duration_ms
.map(|value| format!("execution ~{value} ms"))
.unwrap_or_else(|| "execution não reportado".to_string());
out.push_str(&format!(
" Tempo fases: <code>{}</code> · <code>{}</code>.",
html_escape(&reasoning),
html_escape(&execution)
));
}
out
}
fn traceability_telemetry_lines(
usage: Option<&TokenUsageSample>,
timing: Option<&GenerationTimingSample>,
) -> String {
let mut out = String::new();
if let Some(usage) = usage {
out.push_str(&traceability_usage_lines(usage));
}
if let Some(timing) = timing {
out.push_str(&traceability_timing_lines(timing));
}
out
}
fn is_traceability_telemetry_line(line: &str) -> bool {
let trimmed = line.trim_start();
trimmed.starts_with("- Tokens:")
|| trimmed.starts_with("- Tokens source:")
|| trimmed.starts_with("- Custo estimado:")
|| trimmed.starts_with("- Tempo de geração:")
|| trimmed.starts_with("- Tempo source:")
|| trimmed.starts_with("- Execução iniciada:")
|| trimmed.starts_with("- Tempo fases:")
}
fn inject_traceability_telemetry(
content: &str,
usage: Option<&TokenUsageSample>,
timing: Option<&GenerationTimingSample>,
) -> String {
let telemetry = traceability_telemetry_lines(usage, timing);
if telemetry.is_empty() {
return content.to_string();
}
let mut out = String::new();
let mut inserted = false;
let mut in_traceability = false;
for line in content.lines() {
if line.trim() == "## Rastreabilidade" {
in_traceability = true;
inserted = true;
out.push_str(line);
out.push('\n');
out.push_str(&telemetry);
continue;
}
if in_traceability && line.starts_with("## ") {
in_traceability = false;
}
if in_traceability && is_traceability_telemetry_line(line) {
continue;
}
out.push_str(line);
out.push('\n');
}
if !inserted {
let trimmed = out.trim_end();
if trimmed.is_empty() {
out.clear();
out.push_str("## Rastreabilidade\n");
out.push_str(&telemetry);
} else {
out = format!("{trimmed}\n\n## Rastreabilidade\n{telemetry}");
}
} else if !content.ends_with('\n') {
while out.ends_with('\n') {
out.pop();
}
}
out
}
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 {
artifact_slug(idea)
}
}
fn render_orchestration_readme(name: &str, idea: &str, provider: &ProviderSelection) -> String {
let timer = GenerationTimer::start();
let base = render_orchestration_readme_body(name, idea, provider, None, None);
let usage = token_usage_for_artifact(provider, "orchestration", idea, &base);
let timing = generation_timing_for_artifact(timer);
render_orchestration_readme_body(name, idea, provider, Some(&usage), Some(&timing))
}
fn render_orchestration_readme_body(
name: &str,
idea: &str,
provider: &ProviderSelection,
usage: Option<&TokenUsageSample>,
timing: Option<&GenerationTimingSample>,
) -> String {
let model = provider.effective_model_for_stage("orchestration");
let effort = provider.effective_effort_for_stage("orchestration");
let usage_lines = usage.map(traceability_usage_lines).unwrap_or_default();
let timing_lines = timing.map(traceability_timing_lines).unwrap_or_default();
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",
artifact_slug(name),
provider.id.as_str(),
model,
effort,
provider.offline,
usage_lines,
timing_lines,
if idea.trim().is_empty() {
"(sem entrada)"
} else {
idea
}
)
}
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)?;
// Gate de bloqueio (AD-5, CP-2): consulta o último comentário Jira do card.
// Se for "/bloqueado", retorna Err e propaga via `?`, resultando em exit code 1.
// O bot TypeScript usa `exitCode !== 0` (não verifica == 2 especificamente),
// portanto exit code 1 é compatível com o contrato do bot.
crate::domain::orchestrator::block_gate::ensure_not_blocked(&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 model = provider.effective_model_for_stage("orchestration");
let effort = provider.effective_effort_for_stage("orchestration");
let content = render_orchestration_readme(&name, &idea, &provider);
println!("Provider: {}", provider.id);
println!("Model: {}", model);
println!("Effort: {}", 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, estável e seguro para path.
(artifact_slug(&idea), false)
};
let slug = if name.is_empty() {
String::new()
} else {
artifact_slug(&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_for(root, &name, card_id.as_deref()));
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 = artifact_slug(&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),
IntelligenceCommand::Repair(args) => intelligence_repair(&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 report = write_intelligence_learning_indexes(root, &target, args.dry_run)?;
if args.dry_run {
println!("dry-run write {}", report.sources_path.display());
println!("dry-run write {}", report.learnings_path.display());
println!("sources: {}", report.sources_indexed);
println!("learnings: {}", report.learnings);
return Ok(());
}
if args.json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
println!("{}", report.learnings_path.display());
}
Ok(())
}
#[derive(Debug, Serialize)]
struct IntelligenceLearnWriteReport {
index_dir: PathBuf,
sources_path: PathBuf,
learnings_path: PathBuf,
sources_indexed: usize,
learnings: usize,
derived: bool,
scope: String,
dry_run: bool,
}
fn write_intelligence_learning_indexes(
root: &Path,
target: &IntelligenceLearnTarget,
dry_run: bool,
) -> Result<IntelligenceLearnWriteReport> {
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");
let report = IntelligenceLearnWriteReport {
index_dir,
sources_path,
learnings_path,
sources_indexed: sources.len(),
learnings: learnings.len(),
derived: true,
scope: if target.all { "all" } else { "name" }.to_string(),
dry_run,
};
if dry_run {
return Ok(report);
}
let source_values = sources
.iter()
.map(serde_json::to_value)
.collect::<std::result::Result<Vec<_>, _>>()?;
if target.all {
write_jsonl_values(&report.sources_path, &source_values)?;
write_jsonl_replace_scopes(
&report.learnings_path,
&learnings,
"orchestration_slug",
&target.scopes,
)?;
} else {
write_jsonl_replace_by_key(&report.sources_path, &source_values, "path")?;
let Some(name) = &target.name else {
bail!("sdd intelligence learn requires --name <orchestration> or --all");
};
write_jsonl_replace_scope(
&report.learnings_path,
&learnings,
"orchestration_slug",
Some(&artifact_slug(name)),
)?;
}
Ok(report)
}
fn intelligence_repair(root: &Path, args: IntelligenceRepairArgs) -> Result<()> {
let config = load_sdd_config(root)?;
if !config.intelligence.enabled {
println!("intelligence disabled");
return Ok(());
}
let learn_args = IntelligenceLearnArgs {
name: args.name.clone(),
all: args.all,
json: false,
dry_run: args.dry_run,
};
let target = resolve_intelligence_learn_target(root, &learn_args)?;
let stores = intelligence_target_artifact_stores(root, &target)?;
let signals_before = scoped_intelligence_health_signals(root, target.name.as_deref())?;
let mut memory_written = Vec::new();
let mut memory_existing = Vec::new();
let mut memory_refreshed = Vec::new();
for store in &stores {
let memory_path = store.path.join("08-memory.md");
let rel = relative_path(root, &memory_path);
let refresh_generated =
memory_path.exists() && operational_memory_was_generated(&memory_path);
if memory_path.exists() && !refresh_generated {
memory_existing.push(rel);
continue;
}
let content = render_operational_memory(root, store)?;
if !args.dry_run {
fs::write(&memory_path, content)
.with_context(|| format!("writing {}", memory_path.display()))?;
}
if refresh_generated {
memory_refreshed.push(rel);
} else {
memory_written.push(rel);
}
}
let learn_report = write_intelligence_learning_indexes(root, &target, args.dry_run)?;
let signals_after = if args.dry_run {
signals_before.clone()
} else {
scoped_intelligence_health_signals(root, target.name.as_deref())?
};
let remaining_operational = signals_after
.iter()
.filter(|signal| operational_memory_signal(signal))
.count();
let before_operational = signals_before
.iter()
.filter(|signal| operational_memory_signal(signal))
.count();
let report = json!({
"status": if remaining_operational == 0 { "pass" } else { "warn" },
"scope": if target.all { "all" } else { "name" },
"dry_run": args.dry_run,
"memory_written": memory_written,
"memory_existing": memory_existing,
"memory_refreshed": memory_refreshed,
"closed_operational_signals": before_operational.saturating_sub(remaining_operational),
"remaining_operational_signals": remaining_operational,
"learn": learn_report,
"signals_before": signals_before,
"signals_after": signals_after,
"recommended_next": [
"sdd health --json",
"sdd intelligence health --json",
"sdd context build --name \"<orquestracao>\" --stage execution --json --metadata-only"
],
});
if args.json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
println!(
"intelligence repair: {}",
report
.get("status")
.and_then(Value::as_str)
.unwrap_or("unknown")
);
println!(
"memory_written: {}",
report["memory_written"]
.as_array()
.map(Vec::len)
.unwrap_or(0)
);
println!(
"memory_existing: {}",
report["memory_existing"]
.as_array()
.map(Vec::len)
.unwrap_or(0)
);
println!(
"memory_refreshed: {}",
report["memory_refreshed"]
.as_array()
.map(Vec::len)
.unwrap_or(0)
);
println!(
"closed_operational_signals: {}",
report["closed_operational_signals"].as_u64().unwrap_or(0)
);
println!(
"remaining_operational_signals: {}",
report["remaining_operational_signals"]
.as_u64()
.unwrap_or(0)
);
}
Ok(())
}
fn operational_memory_signal(signal: &Value) -> bool {
matches!(
signal.get("id").and_then(Value::as_str),
Some("missing-memory" | "stale-learning")
)
}
fn operational_memory_was_generated(path: &Path) -> bool {
fs::read_to_string(path)
.is_ok_and(|content| content.contains("Memória operacional gerada de forma determinística"))
}
fn intelligence_target_artifact_stores(
root: &Path,
target: &IntelligenceLearnTarget,
) -> Result<Vec<ArtifactStoreSource>> {
if target.all {
return discover_artifact_stores(root);
}
let Some(name) = &target.name else {
bail!("sdd intelligence repair requires --name <orchestration> or --all");
};
let slug = artifact_slug(name);
let path = artifact_dir(root, name);
if !path.exists() {
bail!("artifact directory not found: {}", path.display());
}
Ok(vec![ArtifactStoreSource {
name: artifact_store_name(&path, &slug),
slug,
path,
}])
}
struct OperationalArtifact {
stage: String,
label: String,
rel: String,
text: String,
title: String,
}
fn render_operational_memory(root: &Path, store: &ArtifactStoreSource) -> Result<String> {
let artifacts = collect_operational_memory_artifacts(root, store)?;
let decisions = collect_operational_items(
&artifacts,
&["decis", "decision", "adr"],
&["decid", "decis", "decision", "escolh", "adot"],
12,
);
let failures = collect_operational_items(
&artifacts,
&[
"falha", "failure", "problema", "risco", "ressalva", "pend", "bloque",
],
&[
"falh", "erro", "problema", "risco", "ressalva", "pend", "bloque", "stale", "drift",
],
12,
);
let commands = collect_operational_commands(&artifacts, 12);
let patterns = collect_operational_items(
&artifacts,
&[
"padr", "conven", "contrato", "arquitet", "contexto", "teste", "valid",
],
&[
"padr",
"conven",
"contrato",
"traceability",
"context pack",
"valid",
"teste",
],
12,
);
let architecture = collect_operational_items(
&artifacts,
&["arquitet", "técnic", "tecnic", "contrato", "estrutura"],
&[
"arquitet",
"contrato",
"determin",
"artifact store",
"docs/<slug>",
],
8,
);
let implemented = artifacts
.iter()
.map(|artifact| {
format!(
"{}: {} (fonte: `{}`)",
artifact.label, artifact.title, artifact.rel
)
})
.take(10)
.collect::<Vec<_>>();
let mut out = String::new();
let display_name = operational_memory_text(&store.name);
out.push_str(&format!("# Memória Operacional - {}\n\n", display_name));
out.push_str("## Estado final\n");
out.push_str("- Memória operacional gerada de forma determinística a partir dos artefatos locais existentes.\n");
out.push_str("- O índice de intelligence deve ser reconstruído após alterações nos artefatos canônicos.\n\n");
render_memory_section(
&mut out,
"Funcionalidades implementadas",
&implemented,
"Nenhum artefato de execução explícito encontrado; revisar o traceability map.",
);
render_memory_section(
&mut out,
"Arquitetura atual",
&architecture,
"Nenhuma decisão arquitetural explícita encontrada nos artefatos lidos.",
);
render_memory_section(
&mut out,
"Decisões",
&decisions,
"Nenhuma decisão explícita encontrada; consultar PRD, Tech Spec, ADR e Review.",
);
render_memory_section(
&mut out,
"Falhas recorrentes",
&failures,
"Nenhuma falha recorrente explícita encontrada nos artefatos lidos.",
);
render_memory_section(
&mut out,
"Comandos confiáveis",
&commands,
"Nenhum comando explícito encontrado; descobrir validações pelo manifesto do projeto.",
);
render_memory_section(
&mut out,
"Padrões úteis",
&patterns,
"Nenhum padrão por repo explícito encontrado; seguir AGENTS.md, sdd.config.yaml e traceability-map.yaml.",
);
out.push_str("## Testes e comandos\n");
if commands.is_empty() {
out.push_str("- Descobrir testes a partir de `sdd context build --json --metadata-only` e do manifesto do projeto.\n\n");
} else {
out.push_str("- Comandos confiáveis para começar a validação:\n");
for command in &commands {
out.push_str(&format!(" - {command}\n"));
}
out.push('\n');
}
out.push_str("## Pendências\n");
out.push_str("- Revisar esta memória em checkpoint humano quando houver decisões de produto ou arquitetura que não aparecem nos artefatos atuais.\n\n");
out.push_str("## Próximos passos\n");
out.push_str("- Rodar `sdd intelligence learn --all` após qualquer alteração relevante neste diretório de artefatos.\n\n");
out.push_str("## Contexto para o próximo agente\n");
out.push_str(&format!(
"- Orquestração: `{}`\n- Slug: `{}`\n- Gerado em: `{}`\n\n",
display_name,
store.slug,
now()
));
out.push_str("## Rastreabilidade\n");
out.push_str(&format!(
"- Origem local: `docs/{}/traceability-map.yaml`\n",
store.slug
));
out.push_str("- Artefatos lidos:\n");
if artifacts.is_empty() {
out.push_str(" - nenhum artefato materializado encontrado\n");
} else {
for artifact in &artifacts {
out.push_str(&format!(" - `{}` ({})\n", artifact.rel, artifact.stage));
}
}
out.push_str("- Artefatos esperados para fechamento da memória:\n");
out.push_str(&format!(" - `docs/{}/06-adr.md`\n", store.slug));
out.push_str(&format!(" - `docs/{}/07-review.md`\n", store.slug));
Ok(out)
}
fn collect_operational_memory_artifacts(
root: &Path,
store: &ArtifactStoreSource,
) -> Result<Vec<OperationalArtifact>> {
let mut artifacts = Vec::new();
for stage in contract::stages() {
if stage.key == "memory" {
continue;
}
let path = store.path.join(&stage.filename);
if !path.is_file() {
continue;
}
let text =
fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
let title = artifact_title_for_memory(&text, &stage.key);
artifacts.push(OperationalArtifact {
stage: stage.key.clone(),
label: stage.label.clone(),
rel: relative_path(root, &path),
text,
title,
});
}
Ok(artifacts)
}
fn artifact_title_for_memory(text: &str, fallback: &str) -> String {
text.lines()
.find(|line| line.trim_start().starts_with("# "))
.map(|line| {
line.trim_start()
.trim_start_matches("# ")
.trim()
.to_string()
})
.filter(|title| !title.is_empty())
.unwrap_or_else(|| fallback.to_string())
}
fn collect_operational_items(
artifacts: &[OperationalArtifact],
heading_keywords: &[&str],
line_keywords: &[&str],
max_items: usize,
) -> Vec<String> {
let mut items = Vec::new();
let mut seen = BTreeSet::new();
for artifact in artifacts {
let mut in_matching_section = false;
for line in artifact.text.lines() {
let trimmed = line.trim();
if trimmed.starts_with('#') {
let heading = trimmed.trim_start_matches('#').trim().to_lowercase();
in_matching_section = contains_any_keyword(&heading, heading_keywords);
continue;
}
let Some(statement) = clean_operational_statement(trimmed) else {
continue;
};
let lower = statement.to_lowercase();
if !in_matching_section && !contains_any_keyword(&lower, line_keywords) {
continue;
}
let key = slugify(&statement);
if key.is_empty() || !seen.insert(key) {
continue;
}
items.push(format!(
"{} (fonte: `{}`)",
truncate_chars(&operational_memory_text(&statement), 240),
artifact.rel
));
if items.len() >= max_items {
return items;
}
}
}
items
}
fn collect_operational_commands(
artifacts: &[OperationalArtifact],
max_items: usize,
) -> Vec<String> {
let mut items = Vec::new();
let mut seen = BTreeSet::new();
for artifact in artifacts {
for line in artifact.text.lines() {
let Some(statement) = clean_operational_statement(line.trim()) else {
continue;
};
if !operational_command_candidate(&statement) {
continue;
}
let key = slugify(&statement);
if key.is_empty() || !seen.insert(key) {
continue;
}
items.push(format!(
"{} (fonte: `{}`)",
truncate_chars(&operational_memory_text(&statement), 240),
artifact.rel
));
if items.len() >= max_items {
return items;
}
}
}
items
}
fn clean_operational_statement(line: &str) -> Option<String> {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with("```") {
return None;
}
if trimmed.starts_with('|') && trimmed.ends_with('|') {
let cells = trimmed
.trim_matches('|')
.split('|')
.map(str::trim)
.collect::<Vec<_>>();
if cells.iter().all(|cell| is_markdown_table_separator(cell)) {
return None;
}
return Some(cells.join(": "));
}
let statement = trimmed
.strip_prefix("- ")
.or_else(|| trimmed.strip_prefix("* "))
.unwrap_or(trimmed)
.trim();
let statement = if let Some((prefix, rest)) = statement.split_once(". ") {
if prefix.chars().all(|ch| ch.is_ascii_digit()) {
rest
} else {
statement
}
} else {
statement
};
if statement.is_empty() {
None
} else {
Some(statement.to_string())
}
}
fn contains_any_keyword(text: &str, keywords: &[&str]) -> bool {
keywords.iter().any(|keyword| text.contains(keyword))
}
fn operational_command_candidate(statement: &str) -> bool {
let lower = statement.to_ascii_lowercase();
[
"sdd ",
"cargo ",
"pnpm ",
"npm ",
"yarn ",
"git ",
"pytest",
"go test",
"go run",
"python ",
"node ",
"deno ",
"uv ",
"ruff ",
"mypy",
"terraform ",
"kubectl ",
]
.iter()
.any(|marker| lower.contains(marker))
}
fn operational_memory_text(statement: &str) -> String {
runtime::redaction::redact_text(statement)
.replace("experiencias", "experiências")
.replace("experiencia", "experiência")
.replace("iteracoes", "iterações")
.replace("iteracao", "iteração")
.replace("nao", "não")
.replace("orquestracoes", "orquestrações")
.replace("orquestracao", "orquestração")
.replace("Experiencias", "Experiências")
.replace("Experiencia", "Experiência")
.replace("Iteracoes", "Iterações")
.replace("Iteracao", "Iteração")
.replace("Nao", "Não")
.replace("Orquestracoes", "Orquestrações")
.replace("Orquestracao", "Orquestração")
}
fn render_memory_section(out: &mut String, title: &str, items: &[String], fallback: &str) {
out.push_str(&format!("## {title}\n"));
if items.is_empty() {
out.push_str(&format!("- {fallback}\n\n"));
} else {
for item in items {
out.push_str(&format!("- {item}\n"));
}
out.push('\n');
}
}
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,
used_chars: usize,
stale: Vec<String>,
decision_conflicts: Vec<String>,
handoff: runtime::optimization::ContextHandoff,
}
type ContextBoundary = runtime::agent_context::AgentContextProfile;
#[derive(Clone, Debug, Serialize)]
struct ContextPattern {
id: String,
title: String,
body: String,
source: String,
scope: String,
target: String,
}
#[derive(Clone, Debug, Serialize)]
struct ContextMaterializationAction {
action: String,
target: String,
block_id: String,
reason: String,
boundary: Option<String>,
changed: bool,
}
#[derive(Clone, Debug, Serialize)]
struct ContextMaterializationPlan {
orchestration: String,
slug: String,
mode: String,
write: bool,
dry_run: bool,
artifact_path: String,
agent_context_policy: String,
context_profiles: Vec<ContextBoundary>,
file_budget: runtime::agent_context::AgentContextFileBudget,
boundaries: Vec<ContextBoundary>,
local_agent_boundaries: Vec<ContextBoundary>,
patterns: Vec<ContextPattern>,
actions: Vec<ContextMaterializationAction>,
conflicts: Vec<String>,
}
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, &artifact_slug(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 = artifact_slug(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, context_snippet_limit(&source, stage));
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 = artifact_slug(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,
used_chars,
stale,
decision_conflicts,
handoff,
})
}
fn context_snippet_limit(source: &IntelligenceSource, target_stage: &str) -> usize {
if source.kind != "artifact" {
return 800;
}
match source.stage.as_deref() {
Some(stage) if stage == target_stage => 1_800,
Some("tasks" | "techspec" | "prd") => 1_400,
Some("adr" | "memory" | "review") => 900,
Some("project-discovery" | "risk" | "idea" | "refinement" | "execution") => 1_000,
_ => 800,
}
}
fn context_pack_metadata_value(pack: &ContextPack) -> Value {
json!({
"path": pack.path,
"sources_included": pack.sources_included,
"conflicts": pack.conflicts,
"used_chars": pack.used_chars,
"handoff": pack.handoff,
"stale": pack.stale,
"decision_conflicts": pack.decision_conflicts,
})
}
fn context_pack_json_value(pack: &ContextPack, metadata_only: bool) -> Value {
let mut value = context_pack_metadata_value(pack);
if !metadata_only {
if let Some(map) = value.as_object_mut() {
map.insert("content".to_string(), Value::String(pack.content.clone()));
}
}
value
}
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("## Resumo operacional\n\n");
out.push_str(&format!("- Fontes incluídas: {}\n", included.len()));
out.push_str(&format!(
"- Limite aplicado: {used_chars} caracteres usados\n"
));
out.push_str(&format!("- Estratégia: `{}`\n", handoff.strategy));
push_context_pack_inline_list(
&mut out,
"Arquivos prováveis",
&handoff.probable_files,
"nenhum arquivo alterado detectado por Git",
8,
);
push_context_pack_inline_list(
&mut out,
"Testes sugeridos",
&handoff.suggested_tests,
"descobrir pelo manifesto do projeto",
5,
);
out.push('\n');
out.push_str("## 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('\n');
out.push_str(&runtime::optimization::render_context_handoff(handoff));
out.push_str("## 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");
}
if handoff.suggested_tests.is_empty() {
out.push_str("- Descobrir comando de teste via manifests/docs antes de executar.\n");
} else {
for command in &handoff.suggested_tests {
out.push_str(&format!("- `{command}`\n"));
}
}
out.push_str("- Fallback amplo somente quando o handoff não cobrir o risco: use os comandos declarados em `sdd.config.yaml` ou manifests.\n");
out.push_str(&format!(
"- `./target/debug/sdd validate-artifact {stage} <arquivo>` quando houver artefato materializado para esta etapa.\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("## 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());
// When name is a card-id (e.g. "DROPCHAT-123"), route artifacts into the worktree.
let card_id = is_card_id(name).then_some(name);
let dest = artifact_dir_for(&root, name, card_id);
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 card_id = is_card_id(name).then_some(name);
let dest = artifact_dir_for(&root, name, card_id);
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, usage, timing) =
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, &usage, &timing,
);
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, TokenUsageSample, GenerationTimingSample) {
let timer = GenerationTimer::start();
let base = render_discovery_artifact_body(
root,
name,
input,
report,
provider,
state,
ArtifactTelemetry::default(),
);
let usage = token_usage_for_artifact(provider, "project-discovery", input, &base);
let timing = generation_timing_for_artifact(timer);
let content = render_discovery_artifact_body(
root,
name,
input,
report,
provider,
state,
ArtifactTelemetry {
usage: Some(&usage),
timing: Some(&timing),
},
);
(content, usage, timing)
}
fn render_discovery_artifact_body(
root: &Path,
name: &str,
input: &str,
report: &DiscoveryReport,
provider: &ProviderSelection,
state: &str,
telemetry: ArtifactTelemetry<'_>,
) -> String {
let mut out = format!("# Project Discovery - {name}\n\n");
let model = provider.effective_model_for_stage("project-discovery");
let effort = provider.effective_effort_for_stage("project-discovery");
out.push_str("## Rastreabilidade\n");
out.push_str(&format!("- Orquestração: {name}\n"));
out.push_str(&format!("- Slug: {}\n", artifact_slug(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", model));
out.push_str(&format!("- Effort: {}\n", effort));
out.push_str(&format!("- Offline: {}\n", provider.offline));
if let Some(usage) = telemetry.usage {
out.push_str(&traceability_usage_lines(usage));
}
if let Some(timing) = telemetry.timing {
out.push_str(&traceability_timing_lines(timing));
}
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, diagnóstico 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 = artifact_slug(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 push_context_pack_inline_list(
out: &mut String,
label: &str,
items: &[String],
empty: &str,
limit: usize,
) {
if items.is_empty() {
out.push_str(&format!("- {label}: {empty}\n"));
return;
}
let shown = items.iter().take(limit).cloned().collect::<Vec<_>>();
let suffix = items
.len()
.checked_sub(shown.len())
.filter(|remaining| *remaining > 0)
.map(|remaining| format!(" (+{remaining})"))
.unwrap_or_default();
out.push_str(&format!("- {label}: {}{}\n", shown.join(", "), suffix));
}
fn render_discovery_html_artifact(
name: &str,
input: &str,
report: &DiscoveryReport,
provider: &ProviderSelection,
state: &str,
usage: &TokenUsageSample,
timing: &GenerationTimingSample,
) -> String {
let slug = artifact_slug(name);
let model = provider.effective_model_for_stage("project-discovery");
let effort = provider.effective_effort_for_stage("project-discovery");
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>Rastreabilidade</h2>\n");
out.push_str(&format!(
" <p>Estado: <code>{}</code>. Provider: <code>{}</code>. Modelo: <code>{}</code>. Effort: <code>{}</code>. Offline: <code>{}</code>.</p>\n",
html_escape(state),
html_escape(&provider.id),
html_escape(model),
html_escape(effort),
provider.offline
));
out.push_str(&format!(
" <p>{}</p>\n",
html_traceability_usage(usage)
));
out.push_str(&format!(
" <p>{}</p>\n",
html_traceability_timing(timing)
));
out.push_str(&format!(
" <p class=\"muted\">Entrada: <code>{}</code></p>\n",
html_escape(if input.trim().is_empty() {
"(inventário geral)"
} else {
input
})
));
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");
let model = provider.effective_model_for_stage("project-discovery");
let effort = provider.effective_effort_for_stage("project-discovery");
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(model),
html_escape(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, provider);
}
let timer = GenerationTimer::start();
let base = render_stage_artifact_body(stage, name, input, root, provider, None, None)?;
let usage = token_usage_for_artifact(provider, stage, input, &base);
let timing = generation_timing_for_artifact(timer);
render_stage_artifact_body(
stage,
name,
input,
root,
provider,
Some(&usage),
Some(&timing),
)
}
fn render_stage_artifact_body(
stage: &str,
name: &str,
input: &str,
root: &Path,
provider: &ProviderSelection,
usage: Option<&TokenUsageSample>,
timing: Option<&GenerationTimingSample>,
) -> Result<String> {
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 model = provider.effective_model_for_stage(stage);
let effort = provider.effective_effort_for_stage(stage);
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",
artifact_slug(name),
provider.id,
model,
effort,
provider.offline,
if input.trim().is_empty() { "(preencher)" } else { input }
));
if let Some(usage) = usage {
out.push_str(&traceability_usage_lines(usage));
}
if let Some(timing) = timing {
out.push_str(&traceability_timing_lines(timing));
}
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');
}
"Backlog" if stage == "tasks" => {
out.push_str("| ID | Título | Estimativa | Dependências |\n");
out.push_str("|---|---|---|---|\n");
out.push_str("| T-01 | Implementar menor fatia verificável | 1h | — |\n\n");
}
"Estratégia de testes" if stage == "tasks" => {
out.push_str("- Antes da execução, rode `sdd context build --name \"");
out.push_str(name);
out.push_str("\" --stage execution --json --metadata-only` e use `handoff.suggested_tests` como primeira escolha.\n");
out.push_str("- Se o handoff não cobrir o risco, descubra o comando pelo manifesto do projeto e registre o fallback.\n\n");
}
"Prompts Agent" if stage == "tasks" => {
out.push_str(&render_tasks_agent_prompt(name, input));
out.push('\n');
}
"Tarefa" if stage == "execution" => {
out.push_str("- Task ID: T-01\n");
out.push_str("- Origem: `04-tasks.md` e Context Handoff de execution.\n");
out.push_str("- Escopo: executar uma fatia vertical e verificável, preservando mudanças locais.\n\n");
}
"Resumo da implementação" if stage == "execution" => {
out.push_str("- Registrar somente o que foi implementado nesta task, sem recontar PRD/Tech Spec.\n");
out.push_str(
"- Registrar o motivo do escopo escolhido e qualquer fallback usado.\n\n",
);
}
"Arquivos alterados" if stage == "execution" => {
out.push_str("- Preencher com caminhos alterados e motivo de cada alteração.\n");
out.push_str("- Usar `handoff.probable_files` como ponto de partida, não como lista obrigatória.\n\n");
}
"Testes e evidências" if stage == "execution" => {
out.push_str("- Handoff: `sdd context build --name \"");
out.push_str(name);
out.push_str("\" --stage execution --json --metadata-only`.\n");
out.push_str("- Teste escolhido: usar primeiro `handoff.suggested_tests`; registrar comando, resultado e por que ele cobre a task.\n");
out.push_str("- Se não houver seam de teste confiável, registrar validação manual/integradora e lacuna residual.\n\n");
}
"Riscos e pendências" if stage == "execution" => {
out.push_str("- Registrar risco residual, pendências reais e sinais de loop improdutivo se houver repetição de erro, diff, comando ou busca.\n\n");
}
_ => out.push_str("- Preencher com o subagent correspondente.\n\n"),
}
}
if stage == "idea" {
out.push_str("## Diagramas\n");
out.push_str("- Opcional: use Mermaid/Excalidraw quando jornada, decisão, estado ou dependência ficarem mais claros visualmente. Use `sdd diagram attach` para companion HTML/SVG em `assets/diagrams/` quando houver contexto específico.\n\n");
}
out.push_str("## Prompt para agente\n");
out.push_str(&stage_prompt(stage, input));
out.push('\n');
Ok(out)
}
fn render_tasks_agent_prompt(name: &str, input: &str) -> String {
let mut out = String::new();
out.push_str("- Task ID: T-01\n");
out.push_str("- Título: Implementar menor fatia verificável\n");
out.push_str(&format!(
"- Origem: `docs/{}/03-techspec.md` e `docs/{}/04-tasks.md`\n",
artifact_slug(name),
artifact_slug(name)
));
out.push_str("- Objetivo verificável: entregar uma mudança pequena que prove o comportamento principal da task.\n");
out.push_str("- Escopo: limitar alterações aos arquivos indicados pelo Context Handoff ou justificar qualquer desvio.\n");
out.push_str("- Antes de editar: rode `sdd context build --name \"");
out.push_str(name);
out.push_str("\" --stage execution --json --metadata-only`.\n");
out.push_str("- Validação: execute primeiro `handoff.suggested_tests`; se não servir, use o comando real do manifesto e registre o fallback.\n");
out.push_str("- Definition of Done: diff focado, evidência fresca, riscos residuais e relatório final com caminhos alterados.\n");
if !input.trim().is_empty() {
out.push_str(&format!(
"- Entrada resumida: {}\n",
truncate_chars(input.trim(), 240)
));
}
out
}
fn render_adr_artifact(name: &str, input: &str, provider: &ProviderSelection) -> Result<String> {
let timer = GenerationTimer::start();
let base = render_adr_artifact_body(name, input, provider, None, None)?;
let usage = token_usage_for_artifact(provider, "adr", input, &base);
let timing = generation_timing_for_artifact(timer);
render_adr_artifact_body(name, input, provider, Some(&usage), Some(&timing))
}
fn render_adr_artifact_body(
name: &str,
input: &str,
provider: &ProviderSelection,
usage: Option<&TokenUsageSample>,
timing: Option<&GenerationTimingSample>,
) -> Result<String> {
let slug = artifact_slug(name);
let model = provider.effective_model_for_stage("adr");
let effort = provider.effective_effort_for_stage("adr");
let input = if input.trim().is_empty() {
"(descreva a decisão arquitetural tomada ou revisada após a execução)"
} else {
input
};
let usage_lines = usage.map(traceability_usage_lines).unwrap_or_default();
let timing_lines = timing.map(traceability_timing_lines).unwrap_or_default();
Ok(format!(
"# ADR - {name}\n\n\
## Rastreabilidade\n\
- Orquestração: {name}\n\
- Slug: {slug}\n\
- Stage: adr\n\
- Estado atual: draft\n\
- Provider: {}\n\
- Modelo: {}\n\
- Effort: {}\n\
- Offline: {}\n\
{usage_lines}\
{timing_lines}\
- 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"
,
provider.id,
model,
effort,
provider.offline
))
}
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 visão de produto. Inclua `## Diagramas` quando jornada, decisão ou estado ficarem mais claros visualmente. Entrada: {input}."),
"prd" => format!("Use o subagent `prd` para criar um PRD completo a partir de: {input}. Inclua Mermaid/Excalidraw em `## Diagramas`; se houver companion visual HTML/SVG, referencie `assets/diagrams/` e preserve Mermaid como contrato versionável."),
"techspec" => {
format!("Use o subagent `techspec` e ancore a arquitetura no codebase real. Inclua Mermaid/Excalidraw técnico em `## Diagramas`; use `diagram-design` apenas como companion visual em `assets/diagrams/` quando pedido ou configurado. Entrada: {input}.")
}
"tasks" => format!("Use o subagent `tasks` para decompor a Tech Spec em backlog atômico. 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 concluída. 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) -> Vec<&'static str> {
let mut paths = match self {
Self::Cli => vec!["sdd.config.yaml"],
Self::Codex => vec![
"AGENTS.md",
".codex/config.toml",
".codex/agents",
".codex/commands/agentic-sdd-loop.md",
".agents/agents/sdd-orchestrator/AGENT.md",
".agents/skills/orchestration/SKILL.md",
],
Self::Claude => vec![
"CLAUDE.md",
".claude/CLAUDE.md",
".claude/settings.json",
".claude/agents",
".claude/commands/orchestration.md",
".claude/commands/agentic-sdd-loop.md",
".claude/skills/orchestration/SKILL.md",
],
Self::Cursor => vec![
"AGENTS.md",
".cursor/model-routing.yaml",
".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 => vec![
"AGENTS.md",
".opencode/model-routing.yaml",
".opencode/commands/sdd.md",
".opencode/commands/orchestration.md",
".opencode/commands/agentic-sdd-loop.md",
".opencode/commands/verify-changes.md",
".opencode/commands/team-review.md",
".opencode/commands/team-execution.md",
".opencode/commands/security-audit.md",
".opencode/commands/test-design.md",
".opencode/commands/fast-lane.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",
".opencode/skills/code-review/SKILL.md",
".opencode/skills/security-privacy/SKILL.md",
".opencode/skills/test-strategy/SKILL.md",
".opencode/skills/architecture-deepening/SKILL.md",
".opencode/skills/execution-discipline/SKILL.md",
".opencode/skills/release-readiness/SKILL.md",
".opencode/plugins/sdd-security-scan.ts",
".opencode/plugins/sdd-verification.ts",
".opencode/plugins/sdd-parallel-guard.ts",
],
Self::Devin => vec![
"AGENTS.md",
".devin/config.json",
".devin/rules/sdd.md",
".devin/agents/sdd-orchestrator/AGENT.md",
".devin/skills/orchestration/SKILL.md",
".devin/workflows/sdd.md",
".devin/workflows/orchestration.md",
".devin/workflows/agentic-sdd-loop.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/agents/sdd-orchestrator/AGENT.md",
".agents/skills/orchestration/SKILL.md",
],
Self::Antigravity => vec![
"AGENTS.md",
".agents/agents/sdd-orchestrator/AGENT.md",
".agents/rules/sdd.md",
".agents/skills/orchestration/SKILL.md",
],
Self::Trae => vec![
"AGENTS.md",
".trae/commands/orchestration.md",
".trae/commands/agentic-sdd-loop.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/10-traceability.md",
".trae/rules/20-output-conventions.md",
".trae/skills/orchestration/SKILL.md",
],
};
paths.extend(stage_skill_paths(self));
paths.sort_unstable();
paths.dedup();
paths
}
}
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>>,
}
#[derive(Debug, Deserialize, Serialize)]
struct SkillCatalog {
version: u32,
skills: Vec<SkillSpec>,
}
#[derive(Debug, Deserialize, Serialize)]
struct SkillSpec {
id: String,
title: String,
description: String,
category: String,
status: String,
local_path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
bundle_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
plugin_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
plugin_bundle_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
source_repository: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
source_commit: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
license: Option<String>,
#[serde(default)]
signals: Vec<String>,
#[serde(default)]
tags: Vec<String>,
}
#[derive(Default, Serialize)]
struct SkillsDoctorReport {
status: &'static str,
checked: Vec<String>,
missing: Vec<String>,
invalid: Vec<String>,
planned: 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 load_skill_catalog(root: &Path) -> Result<SkillCatalog> {
let rel = "templates/skill-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: SkillCatalog = serde_yaml::from_slice(&bytes)
.with_context(|| format!("parsing {}", display_relative(root, &local)))?;
validate_skill_catalog_shape(&catalog)?;
Ok(catalog)
}
fn validate_skill_catalog_shape(catalog: &SkillCatalog) -> Result<()> {
if catalog.version != 1 {
bail!("skill-catalog version must be 1, found {}", catalog.version);
}
let mut ids = BTreeSet::new();
for skill in &catalog.skills {
if !ids.insert(skill.id.as_str()) {
bail!("duplicated skill id: {}", skill.id);
}
if skill.title.trim().is_empty()
|| skill.description.trim().is_empty()
|| skill.category.trim().is_empty()
|| skill.local_path.trim().is_empty()
{
bail!("skill {} has an empty required field", skill.id);
}
if skill
.bundle_path
.as_deref()
.is_some_and(|value| value.trim().is_empty())
|| skill
.plugin_bundle_path
.as_deref()
.is_some_and(|value| value.trim().is_empty())
{
bail!("skill {} has an empty bundle path", skill.id);
}
if !matches!(skill.status.as_str(), "available" | "planned") {
bail!(
"skill {} has invalid status {} (use available|planned)",
skill.id,
skill.status
);
}
}
Ok(())
}
fn skill_catalog_by_id(catalog: &SkillCatalog) -> BTreeMap<&str, &SkillSpec> {
catalog
.skills
.iter()
.map(|skill| (skill.id.as_str(), skill))
.collect()
}
fn skill_source_text(root: &Path, skill: &SkillSpec) -> Result<String> {
if let Ok(text) = fs::read_to_string(root.join(&skill.local_path)) {
return Ok(text);
}
let bytes = LayerSource::detect_for_target(root)
.read_file(
&skill.local_path,
capability_source_is_root_item(&skill.local_path),
)
.with_context(|| format!("reading source skill {}", skill.local_path))?;
String::from_utf8(bytes).with_context(|| format!("reading UTF-8 {}", skill.local_path))
}
fn rel_join(base: &str, child: &str) -> String {
format!(
"{}/{}",
base.trim_end_matches('/'),
child.trim_start_matches('/')
)
}
fn source_rel_exists(root: &Path, rel: &str) -> bool {
root.join(rel).exists()
|| LayerSource::detect_for_target(root).exists(rel, capability_source_is_root_item(rel))
}
fn skill_bundle_required_files(skill: &SkillSpec) -> Vec<&'static str> {
if skill.id == "diagram-design" {
vec![
"SKILL.md",
"LICENSE",
"references/style-guide.md",
"references/onboarding.md",
"assets/template.html",
"assets/template-full.html",
]
} else {
vec!["SKILL.md"]
}
}
fn collect_skill_bundle_issues(root: &Path, skill: &SkillSpec, bundle_path: &str) -> Vec<String> {
skill_bundle_required_files(skill)
.into_iter()
.map(|file| rel_join(bundle_path, file))
.filter(|rel| !source_rel_exists(root, rel))
.map(|rel| format!("{} missing bundle file {}", skill.id, rel))
.collect()
}
fn skill_validation_issues(text: &str) -> Vec<String> {
let trimmed = text.trim_start();
if !trimmed.starts_with("---") {
return vec!["missing YAML frontmatter delimited by ---".to_string()];
}
let mut parts = trimmed.splitn(3, "---");
parts.next();
let frontmatter = parts.next().unwrap_or_default();
let body = parts.next().unwrap_or_default();
let mut issues = Vec::new();
match serde_yaml::from_str::<serde_yaml::Value>(frontmatter) {
Ok(serde_yaml::Value::Mapping(map)) => {
for required in ["name", "description"] {
let key = serde_yaml::Value::String(required.to_string());
match map.get(&key).and_then(serde_yaml::Value::as_str) {
Some(value) if !value.trim().is_empty() => {}
Some(_) => issues.push(format!("empty frontmatter {required}")),
None => issues.push(format!("missing frontmatter {required}")),
}
}
}
Ok(_) => issues.push("frontmatter must be a YAML mapping".to_string()),
Err(err) => {
issues.push(format!("invalid YAML frontmatter: {err}"));
}
}
if body.trim().is_empty() {
issues.push("empty skill body".to_string());
}
issues
}
fn validate_skill_file(path: &Path) -> Result<Value> {
let text = fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
let issues = skill_validation_issues(&text);
Ok(json!({
"path": path,
"status": if issues.is_empty() { "pass" } else { "fail" },
"issues": issues,
}))
}
fn skills_doctor(root: &Path, strict: bool) -> Result<SkillsDoctorReport> {
let catalog = load_skill_catalog(root)?;
let by_id = skill_catalog_by_id(&catalog);
let mut report = SkillsDoctorReport::default();
for skill in &catalog.skills {
if skill.status == "planned" {
report.planned.push(skill.id.clone());
continue;
}
report.checked.push(skill.id.clone());
let source = skill_source_text(root, skill);
let Ok(text) = source else {
report
.missing
.push(format!("{} missing {}", skill.id, skill.local_path));
continue;
};
for issue in skill_validation_issues(&text) {
report.invalid.push(format!("{}: {issue}", skill.id));
}
if let Some(bundle_path) = &skill.bundle_path {
report
.missing
.extend(collect_skill_bundle_issues(root, skill, bundle_path));
}
if let Some(plugin_path) = &skill.plugin_path {
if !root.join(plugin_path).exists()
&& LayerSource::detect_for_target(root)
.read_file(plugin_path, capability_source_is_root_item(plugin_path))
.is_err()
{
report.missing.push(format!(
"{} missing plugin mirror {}",
skill.id, plugin_path
));
}
}
if let Some(plugin_bundle_path) = &skill.plugin_bundle_path {
report
.missing
.extend(collect_skill_bundle_issues(root, skill, plugin_bundle_path));
}
}
if strict {
for recommendation in technology_recommendations(root) {
if !by_id.contains_key(recommendation.id) {
report.missing.push(format!(
"recommended skill {} is absent from templates/skill-catalog.yaml",
recommendation.id
));
}
}
validate_generated_stage_skills_for_skills_doctor(root, &mut report);
}
report.status = if report.missing.is_empty() && report.invalid.is_empty() {
"pass"
} else {
"fail"
};
Ok(report)
}
fn validate_generated_stage_skills_for_skills_doctor(root: &Path, report: &mut SkillsDoctorReport) {
let mut seen = BTreeSet::new();
for target in ClientTarget::all() {
for rel in stage_skill_paths(target) {
if !seen.insert(rel) {
continue;
}
report.checked.push(format!("stage-skill:{rel}"));
let path = client_file_target(root, rel);
let Ok(content) = fs::read_to_string(&path) else {
report.missing.push(format!("stage skill missing {rel}"));
continue;
};
for issue in skill_validation_issues(&content) {
report.invalid.push(format!("stage skill {rel}: {issue}"));
}
if !content.contains("sdd ") {
report
.invalid
.push(format!("stage skill {rel}: missing CLI delegation"));
}
if !content.contains("sdd init")
&& !content.contains("docs/<slug")
&& !content.contains("sdd artifact save")
{
report.invalid.push(format!(
"stage skill {rel}: missing artifact-store contract"
));
}
}
}
}
fn print_skill_validate(path: &Path, report: &Value) {
let status = report
.get("status")
.and_then(Value::as_str)
.unwrap_or("fail");
let issues = report.get("issues").and_then(Value::as_array);
let issue_count = issues.map(Vec::len).unwrap_or(0);
let signal = status_signal(status, false, false);
banner("skill validate");
status_line(status, signal);
summary_line("File", &runtime::platform::display_path(path));
summary_line("Issues", &format!("{issue_count} found"));
banner_close();
if let Some(issues) = issues {
section("Issues", issues.len());
if issues.is_empty() {
section_none();
} else {
for issue in issues {
item("✕", issue.as_str().unwrap_or("invalid issue"));
}
}
}
}
fn print_skills_list(catalog: &SkillCatalog) {
let count = catalog.skills.len();
banner("skills");
summary_line("Catalog", &format!("{count} skills · v{}", catalog.version));
banner_close();
section("Skills", count);
if count == 0 {
section_none();
return;
}
for skill in &catalog.skills {
item_labeled(
"◇",
&skill.id,
26,
&format!(
"[{}] {} · {}",
skill.category, skill.title, skill.local_path
),
);
}
}
fn print_skills_doctor(report: &SkillsDoctorReport) {
let signal = status_signal(
report.status,
!report.invalid.is_empty(),
!report.missing.is_empty(),
);
banner("skills doctor");
status_line(report.status, signal);
summary_line(
"Catalog",
&format!(
"{} checked · {} planned",
report.checked.len(),
report.planned.len()
),
);
summary_line(
"Issues",
&format!(
"{} missing · {} invalid",
report.missing.len(),
report.invalid.len()
),
);
banner_close();
print_skills_issue_section("Missing", "◇", &report.missing);
print_skills_issue_section("Invalid", "✕", &report.invalid);
print_skills_inventory_section("Planned", "◌", &report.planned, None);
print_skills_inventory_section("Checked", "✓", &report.checked, Some(12));
}
fn print_skills_issue_section(title: &str, icon: &str, items: &[String]) {
section(title, items.len());
if items.is_empty() {
section_none();
return;
}
for group in skills_issue_groups(items) {
println!(" {icon} {}", group.summary);
tree(&group.details, None);
}
}
fn print_skills_inventory_section(
title: &str,
icon: &str,
items: &[String],
preview_limit: Option<usize>,
) {
section(title, items.len());
if items.is_empty() {
section_none();
return;
}
let visible = preview_limit
.map(|limit| items.len().min(limit))
.unwrap_or(items.len());
for inventory_item in items.iter().take(visible) {
item(icon, inventory_item);
}
if items.len() > visible {
println!(" └─ +{} more", items.len() - visible);
}
}
fn skills_issue_groups(items: &[String]) -> Vec<DoctorIssueGroup> {
let mut groups: Vec<DoctorIssueGroup> = Vec::new();
for item in items {
let (summary, detail) = skills_issue_parts(item);
if let Some(group) = groups.iter_mut().find(|group| group.summary == summary) {
group.details.push(detail);
} else {
groups.push(DoctorIssueGroup {
summary,
details: vec![detail],
});
}
}
groups
}
fn skills_issue_parts(item: &str) -> (String, String) {
if let Some((summary, detail)) = item.split_once(": ") {
return (summary.trim().to_string(), detail.trim().to_string());
}
if let Some(rest) = item.strip_prefix("stage skill ") {
return ("stage skill".to_string(), rest.trim().to_string());
}
if let Some((summary, detail)) = item.split_once(' ') {
return (summary.trim().to_string(), detail.trim().to_string());
}
(item.to_string(), "issue".to_string())
}
fn skill_target_root(target: ClientTarget, skill_id: &str) -> Option<String> {
match target {
ClientTarget::Cli => None,
ClientTarget::Codex | ClientTarget::Antigravity => {
Some(format!(".agents/skills/{skill_id}"))
}
ClientTarget::Claude => Some(format!(".claude/skills/{skill_id}")),
ClientTarget::Cursor => Some(format!(".cursor/skills/{skill_id}")),
ClientTarget::Opencode => Some(format!(".opencode/skills/{skill_id}")),
ClientTarget::Devin => Some(format!(".devin/skills/{skill_id}")),
ClientTarget::Trae => Some(format!(".trae/skills/{skill_id}")),
}
}
fn skill_target_path(target: ClientTarget, skill_id: &str) -> Option<String> {
skill_target_root(target, skill_id).map(|root| format!("{root}/SKILL.md"))
}
fn same_existing_path(left: &Path, right: &Path) -> bool {
match (
runtime::platform::canonicalize(left),
runtime::platform::canonicalize(right),
) {
(Ok(left), Ok(right)) => left == right,
_ => left == right,
}
}
fn sync_skill_files(
root: &Path,
targets: &BTreeSet<ClientTarget>,
force: bool,
dry_run: bool,
) -> Result<()> {
let catalog = load_skill_catalog(root)?;
let mut actions: Vec<String> = Vec::new();
for skill in &catalog.skills {
if skill.status != "available" {
actions.push(format!("skip planned {}", skill.id));
continue;
}
let content = if skill.bundle_path.is_none() {
Some(skill_source_text(root, skill)?)
} else {
None
};
if is_layer_source_repo(root) {
for action in sync_plugin_skill_mirror(root, skill, force, dry_run)? {
actions.push(action);
}
}
for target in targets {
if let Some(bundle_path) = &skill.bundle_path {
let Some(rel) = skill_target_root(*target, &skill.id) else {
continue;
};
let dest = root.join(&rel);
let source = LayerSource::detect_for_target(root);
if let Some(source_path) =
source.path(bundle_path, capability_source_is_root_item(bundle_path))
{
if same_existing_path(&source_path, &dest) {
actions.push(format!("skip source equals destination {}", dest.display()));
continue;
}
}
let action = source.copy_item(
bundle_path,
&dest,
capability_source_is_root_item(bundle_path),
CopyPolicy {
force,
dry_run,
include_claude_plugin: true,
merge_directories: false,
},
)?;
actions.push(action);
continue;
}
let Some(rel) = skill_target_path(*target, &skill.id) else {
continue;
};
let dest = root.join(&rel);
if dest.exists() && !force {
actions.push(format!("skip existing {}", dest.display()));
continue;
}
if dry_run {
actions.push(format!("dry-run write {}", dest.display()));
continue;
}
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&dest, content.as_deref().unwrap_or_default())?;
actions.push(format!("write {}", dest.display()));
}
}
print_sync_report("skills sync", &actions, root, dry_run);
Ok(())
}
fn sync_plugin_skill_mirror(
root: &Path,
skill: &SkillSpec,
force: bool,
dry_run: bool,
) -> Result<Vec<String>> {
let mut actions = Vec::new();
if let (Some(source_rel), Some(dest_rel)) = (&skill.bundle_path, &skill.plugin_bundle_path) {
let dest = root.join(dest_rel);
let source = LayerSource::detect_for_target(root);
if let Some(source_path) =
source.path(source_rel, capability_source_is_root_item(source_rel))
{
if same_existing_path(&source_path, &dest) {
actions.push(format!("skip source equals destination {}", dest.display()));
return Ok(actions);
}
}
let action = source.copy_item(
source_rel,
&dest,
capability_source_is_root_item(source_rel),
CopyPolicy {
force: force || dest.exists(),
dry_run,
include_claude_plugin: true,
merge_directories: true,
},
)?;
actions.push(action);
} else if let Some(dest_rel) = &skill.plugin_path {
let dest = root.join(dest_rel);
if dest.exists() && !force {
actions.push(format!("skip existing {}", dest.display()));
return Ok(actions);
}
let source = LayerSource::detect_for_target(root);
if let Some(source_path) = source.path(
&skill.local_path,
capability_source_is_root_item(&skill.local_path),
) {
if same_existing_path(&source_path, &dest) {
actions.push(format!("skip source equals destination {}", dest.display()));
return Ok(actions);
}
}
let action = source.copy_item(
&skill.local_path,
&dest,
capability_source_is_root_item(&skill.local_path),
CopyPolicy {
force,
dry_run,
include_claude_plugin: true,
merge_directories: false,
},
)?;
actions.push(action);
}
Ok(actions)
}
fn render_skill_recommendations(catalog: &SkillCatalog) -> String {
let mut out = String::new();
out.push_str("# Skill Recommendations\n\n");
out.push_str("- Fonte canônica: `templates/skill-catalog.yaml` v1\n");
out.push_str("- Use `sdd skills sync` para materializar skills `available`; trate `planned` como backlog.\n\n");
out.push_str("| Skill | Status | Categoria | Caminho |\n");
out.push_str("|---|---|---|---|\n");
for skill in &catalog.skills {
out.push_str(&format!(
"| {} (`{}`) | {} | {} | `{}` |\n",
skill.title, skill.id, skill.status, skill.category, skill.local_path
));
}
out
}
fn run_clients(args: ClientsArgs) -> Result<()> {
match args.command {
ClientsCommand::List => {
let targets = ClientTarget::all();
let count = targets.len();
banner("clients");
summary_line("Targets", &format!("{count} clients"));
banner_close();
section("Clients", count);
for target in &targets {
item("◇", 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 {
print_capability_list(&catalog);
}
}
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_skills(args: SkillsArgs) -> Result<()> {
match args.command {
SkillsCommand::List(args) => {
let root = runtime::platform::canonicalize(&args.root).unwrap_or(args.root);
let catalog = load_skill_catalog(&root)?;
if args.json {
println!("{}", serde_json::to_string_pretty(&catalog)?);
} else {
print_skills_list(&catalog);
}
}
SkillsCommand::Doctor(args) => {
let root = runtime::platform::canonicalize(&args.root).unwrap_or(args.root);
let report = skills_doctor(&root, args.strict)?;
if args.json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print_skills_doctor(&report);
}
if report.status != "pass" {
bail!("skills doctor failed");
}
}
SkillsCommand::Sync(args) => {
let root = runtime::platform::canonicalize(&args.root).unwrap_or(args.root);
let targets = parse_client_targets(&args.targets)?;
sync_skill_files(&root, &targets, args.force, args.dry_run)?;
}
SkillsCommand::Recommend(args) => {
let root = runtime::platform::canonicalize(&args.root).unwrap_or(args.root);
let catalog = load_skill_catalog(&root)?;
let content = render_skill_recommendations(&catalog);
if args.json {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"status": "pass",
"skills": catalog.skills,
}))?
);
} else if args.write {
let target = layer_root(&root).join("skill-recommendations.md");
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&target, content)?;
println!("{}", target.display());
} else {
print!("{content}");
}
}
SkillsCommand::Validate(args) => {
let report = validate_skill_file(&args.path)?;
if args.json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print_skill_validate(&args.path, &report);
}
if report["status"] != "pass" {
bail!("skill validation failed");
}
}
}
Ok(())
}
fn run_workflow_cli(args: WorkflowArgs) -> Result<()> {
let root = runtime::platform::canonicalize(&args.root).unwrap_or(args.root);
match args.command {
WorkflowCommand::List { json: as_json } => {
let workflows = workflow_files(&root);
if as_json {
println!("{}", serde_json::to_string_pretty(&workflows)?);
} else {
print_workflow_list(&workflows);
}
}
WorkflowCommand::Validate {
workflow,
json: as_json,
} => {
let report = validate_workflow(&root, &workflow);
if as_json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print_workflow_validate(&workflow, &report);
}
if report["status"] != "pass" {
bail!("workflow validation failed");
}
}
WorkflowCommand::Run {
workflow,
input,
max_iterations,
json: as_json,
real,
} => {
let report = run_workflow_definition(&root, &workflow, &input, max_iterations, real)?;
save_workflow_report(&root, &report)?;
if as_json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print_workflow_run(&report);
}
}
WorkflowCommand::Status {
workflow,
json: as_json,
} => {
let report = workflow_status_report(&root, workflow.as_deref())?;
if as_json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print_workflow_status(&report);
}
if report["status"] != "pass" {
bail!("workflow status failed");
}
}
}
Ok(())
}
fn workflow_dir(root: &Path) -> PathBuf {
root.join(".sdd").join("workflows")
}
fn print_workflow_validate(workflow: &str, report: &Value) {
let status = report
.get("status")
.and_then(Value::as_str)
.unwrap_or("fail");
let signal = status_signal(status, false, false);
let paths = report.get("paths").and_then(Value::as_array);
let issues = report.get("issues").and_then(Value::as_array);
banner("workflow validate");
status_line(status, signal);
summary_line("Workflow", workflow);
summary_line(
"Result",
&format!(
"{} paths · {} issues",
paths.map(Vec::len).unwrap_or(0),
issues.map(Vec::len).unwrap_or(0)
),
);
banner_close();
if let Some(paths) = paths {
section("Paths", paths.len());
if paths.is_empty() {
section_none();
} else {
for path in paths {
item("◇", path.as_str().unwrap_or("unknown"));
}
}
}
if let Some(issues) = issues {
if !issues.is_empty() {
section("Issues", issues.len());
for issue in issues {
item("✕", issue.as_str().unwrap_or("invalid issue"));
}
}
}
}
fn print_workflow_run(report: &crate::domain::workflow::WorkflowRunReport) {
let status = report.status.as_str();
let signal = status_signal(status, false, false);
banner("workflow run");
status_line(status, signal);
summary_line("Workflow", &report.workflow_id);
summary_line("Iterations", &format!("{} iterations", report.iterations));
summary_line(
"Result",
&format!(
"{} produced · {} paused · {} ready · {} errors",
report.produced.len(),
report.paused.len(),
report.ready.len(),
report.errors.len()
),
);
banner_close();
if !report.produced.is_empty() {
section("Produced", report.produced.len());
for (key, value) in &report.produced {
item_labeled("◆", key, 18, value);
}
}
if !report.paused.is_empty() {
section("Paused", report.paused.len());
for paused_item in &report.paused {
item("◇", paused_item);
}
}
if !report.ready.is_empty() {
section("Ready", report.ready.len());
for ready_item in &report.ready {
item("◇", ready_item);
}
}
if !report.errors.is_empty() {
section("Errors", report.errors.len());
for error in &report.errors {
item("✕", error);
}
}
}
fn print_workflow_status(report: &Value) {
let status = report
.get("status")
.and_then(Value::as_str)
.unwrap_or("fail");
let signal = status_signal(status, false, false);
let workflow = report
.get("workflow")
.and_then(Value::as_str)
.unwrap_or("-");
banner("workflow status");
status_line(status, signal);
summary_line("Workflow", workflow);
if let Some(path) = report.get("path").and_then(Value::as_str) {
summary_line("Path", path);
}
banner_close();
}
fn print_workflow_list(workflows: &BTreeMap<String, Vec<String>>) {
let count = workflows.len();
banner("workflows");
summary_line("Catalog", &format!("{count} workflows"));
banner_close();
section("Workflows", count);
if count == 0 {
section_none();
return;
}
for (workflow, paths) in workflows {
item_labeled("◇", workflow, 26, &paths.join(", "));
}
}
fn workflow_files(root: &Path) -> BTreeMap<String, Vec<String>> {
let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
let mut seen_paths = BTreeSet::new();
for base in [
layer_root(root).join("templates/workflows"),
root.join("templates/workflows"),
] {
let Ok(entries) = fs::read_dir(base) else {
continue;
};
for path in entries.flatten().map(|entry| entry.path()) {
if path.extension().and_then(OsStr::to_str) != Some("yaml") {
continue;
}
let Some(id) = path.file_stem().and_then(OsStr::to_str) else {
continue;
};
let path_text = path.display().to_string();
if !seen_paths.insert(path_text.clone()) {
continue;
}
out.entry(id.to_string()).or_default().push(path_text);
}
}
out
}
fn workflow_path(root: &Path, workflow: &str) -> PathBuf {
let explicit = PathBuf::from(workflow);
if explicit.exists() {
return explicit;
}
let local = root
.join("templates")
.join("workflows")
.join(format!("{workflow}.yaml"));
if local.exists() {
return local;
}
layer_root(root)
.join("templates")
.join("workflows")
.join(format!("{workflow}.yaml"))
}
fn load_workflow_definition(
root: &Path,
workflow: &str,
) -> Result<crate::domain::workflow::WorkflowDefinition> {
let path = workflow_path(root, workflow);
let text = fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
crate::domain::workflow::parse_workflow(&text)
.with_context(|| format!("validating {}", path.display()))
}
fn validate_workflow(root: &Path, workflow: &str) -> Value {
let path = workflow_path(root, workflow);
match fs::read_to_string(&path) {
Ok(text) => match crate::domain::workflow::parse_workflow(&text) {
Ok(definition) => json!({
"status": "pass",
"workflow": definition.id,
"paths": [path],
"issues": [],
}),
Err(err) => json!({
"status": "fail",
"workflow": workflow,
"paths": [path],
"issues": [format!("{err:#}")],
}),
},
Err(err) => json!({
"status": "fail",
"workflow": workflow,
"paths": [path],
"issues": [format!("reading workflow: {err}")],
}),
}
}
struct CliWorkflowExecutor {
root: PathBuf,
input: String,
}
impl CliWorkflowExecutor {
fn orchestration_name(&self, workflow: &crate::domain::workflow::WorkflowDefinition) -> String {
let normalized = self.input.split_whitespace().collect::<Vec<_>>().join(" ");
if normalized.is_empty() {
workflow.id.clone()
} else {
normalized
}
}
fn execute_stage(
&mut self,
workflow: &crate::domain::workflow::WorkflowDefinition,
node: &crate::domain::workflow::WorkflowNode,
) -> crate::domain::workflow::WorkflowActionOutcome {
use crate::domain::workflow::WorkflowActionOutcome;
let Some(stage) = node.stage.as_deref() else {
return WorkflowActionOutcome::Failed(format!("node {} stage sem stage", node.id));
};
let Some(command) = workflow_stage_command(stage) else {
return WorkflowActionOutcome::Failed(format!(
"stage `{stage}` ainda não tem executor CLI em workflow"
));
};
let name = self.orchestration_name(workflow);
let mut args = vec![
command.to_string(),
"--root".to_string(),
self.root.display().to_string(),
"--name".to_string(),
name,
"--force".to_string(),
];
if !self.input.trim().is_empty() {
args.push(self.input.clone());
}
self.run_sdd_args(node, "workflow_stage", args)
}
fn execute_eval(
&mut self,
workflow: &crate::domain::workflow::WorkflowDefinition,
node: &crate::domain::workflow::WorkflowNode,
) -> crate::domain::workflow::WorkflowActionOutcome {
use crate::domain::workflow::WorkflowActionOutcome;
let Some(stage) = node.stage.as_deref() else {
return WorkflowActionOutcome::Failed(format!("node {} eval sem stage", node.id));
};
let args = vec![
"eval".to_string(),
"--root".to_string(),
self.root.display().to_string(),
"stage".to_string(),
"--name".to_string(),
self.orchestration_name(workflow),
"--stage".to_string(),
stage.to_string(),
"--json".to_string(),
];
self.run_sdd_args(node, "workflow_eval", args)
}
fn execute_command(
&mut self,
node: &crate::domain::workflow::WorkflowNode,
command: &str,
) -> crate::domain::workflow::WorkflowActionOutcome {
use crate::domain::workflow::WorkflowActionOutcome;
let parts = match split_workflow_command(command) {
Ok(parts) if !parts.is_empty() => parts
.into_iter()
.map(|part| expand_workflow_token(&part, &self.input, &self.root))
.collect::<Vec<_>>(),
Ok(_) => {
return WorkflowActionOutcome::Failed(format!("node {} command vazio", node.id));
}
Err(error) => {
return WorkflowActionOutcome::Failed(format!(
"node {} command inválido: {error}",
node.id
));
}
};
self.run_process(node, "workflow_command", parts)
}
fn run_sdd_args(
&self,
node: &crate::domain::workflow::WorkflowNode,
event: &str,
args: Vec<String>,
) -> crate::domain::workflow::WorkflowActionOutcome {
let mut parts = vec!["sdd".to_string()];
parts.extend(args);
self.run_process(node, event, parts)
}
fn run_process(
&self,
node: &crate::domain::workflow::WorkflowNode,
event: &str,
parts: Vec<String>,
) -> crate::domain::workflow::WorkflowActionOutcome {
use crate::domain::workflow::WorkflowActionOutcome;
let Some((program, args)) = parts.split_first() else {
return WorkflowActionOutcome::Failed(format!("node {} command vazio", node.id));
};
let (program_path, program_label) = if program == "sdd" {
match env::current_exe() {
Ok(path) => (path, "sdd".to_string()),
Err(error) => {
return WorkflowActionOutcome::Failed(format!(
"node {} não resolveu binário sdd: {error}",
node.id
));
}
}
} else {
(PathBuf::from(program), program.clone())
};
let output = std::process::Command::new(&program_path)
.args(args)
.current_dir(&self.root)
.output();
let output = match output {
Ok(output) => output,
Err(error) => {
let _ = log_jsonl(
&self.root,
"workflows.jsonl",
json!({
"ts": now_public(),
"event": event,
"node": node.id,
"program": program_label,
"args": args,
"status": "spawn_error",
"error": runtime::redaction::redact_text(&error.to_string()),
}),
);
return WorkflowActionOutcome::Failed(format!(
"node {} falhou ao executar `{program}`: {error}",
node.id
));
}
};
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = workflow_log_text(&stdout);
let stderr = workflow_log_text(&stderr);
let status = if output.status.success() {
"ok"
} else {
"error"
};
let _ = log_jsonl(
&self.root,
"workflows.jsonl",
json!({
"ts": now_public(),
"event": event,
"node": node.id,
"program": program_label,
"args": args,
"status": status,
"code": output.status.code(),
"stdout": stdout,
"stderr": stderr,
"input": self.input,
}),
);
if output.status.success() {
WorkflowActionOutcome::Continue
} else {
WorkflowActionOutcome::Failed(format!(
"node {} `{}` saiu com status {:?}: {}",
node.id,
program,
output.status.code(),
if stderr.trim().is_empty() {
stdout.trim()
} else {
stderr.trim()
}
))
}
}
}
impl crate::domain::workflow::WorkflowActionExecutor for CliWorkflowExecutor {
fn execute(
&mut self,
workflow: &crate::domain::workflow::WorkflowDefinition,
node: &crate::domain::workflow::WorkflowNode,
) -> crate::domain::workflow::WorkflowActionOutcome {
use crate::domain::workflow::WorkflowActionOutcome;
match node.action.as_str() {
"checkpoint" => WorkflowActionOutcome::Paused(format!(
"checkpoint humano aguardando {}",
node.stage.as_deref().unwrap_or("gate")
)),
"command" => match node.command.as_deref() {
Some(command) => self.execute_command(node, command),
None => {
WorkflowActionOutcome::Failed(format!("node {} command sem command", node.id))
}
},
"stage" => self.execute_stage(workflow, node),
"eval" => self.execute_eval(workflow, node),
"skill" | "parallel" | "notify" => WorkflowActionOutcome::Failed(format!(
"action `{}` ainda não tem executor real no CLI workflow",
node.action
)),
other => WorkflowActionOutcome::Failed(format!("action desconhecida: {other}")),
}
}
}
fn workflow_stage_command(stage: &str) -> Option<&'static str> {
match stage {
"project-discovery" | "discover" => Some("discover"),
"risk" => Some("risk"),
"idea" => Some("idea"),
"prd" => Some("prd"),
"techspec" | "tech-spec" => Some("techspec"),
"tasks" => Some("tasks"),
"refinement" => Some("refinement"),
"execution" => Some("execution"),
"adr" => Some("adr"),
"review" => Some("review"),
"memory" => Some("memory"),
_ => None,
}
}
fn split_workflow_command(command: &str) -> std::result::Result<Vec<String>, String> {
let mut out = Vec::new();
let mut current = String::new();
let mut quote: Option<char> = None;
let mut escaped = false;
for ch in command.chars() {
if escaped {
current.push(ch);
escaped = false;
continue;
}
if ch == '\\' {
escaped = true;
continue;
}
if let Some(open) = quote {
if ch == open {
quote = None;
} else {
current.push(ch);
}
continue;
}
if ch == '\'' || ch == '"' {
quote = Some(ch);
} else if ch.is_whitespace() {
if !current.is_empty() {
out.push(std::mem::take(&mut current));
}
} else {
current.push(ch);
}
}
if escaped {
current.push('\\');
}
if let Some(open) = quote {
return Err(format!("aspas `{open}` não fechadas"));
}
if !current.is_empty() {
out.push(current);
}
Ok(out)
}
fn expand_workflow_token(token: &str, input: &str, root: &Path) -> String {
token
.replace("{input}", input)
.replace("{root}", &root.display().to_string())
}
fn workflow_log_text(text: &str) -> String {
const MAX_LOG_CHARS: usize = 4000;
runtime::redaction::redact_text(text)
.chars()
.take(MAX_LOG_CHARS)
.collect()
}
fn run_workflow_definition(
root: &Path,
workflow: &str,
input: &str,
max_iterations: Option<u32>,
real: bool,
) -> Result<crate::domain::workflow::WorkflowRunReport> {
let definition = load_workflow_definition(root, workflow)?;
if definition.id == "standard-sdd" {
return run_standard_sdd_workflow(root, &definition, input, max_iterations, real);
}
let mut executor = CliWorkflowExecutor {
root: root.to_path_buf(),
input: input.to_string(),
};
crate::domain::workflow::run_workflow(&definition, max_iterations, &mut executor)
}
fn run_standard_sdd_workflow(
root: &Path,
definition: &crate::domain::workflow::WorkflowDefinition,
input: &str,
max_iterations: Option<u32>,
real: bool,
) -> Result<crate::domain::workflow::WorkflowRunReport> {
use crate::domain::orchestrator::demand::{Demand, DemandSource, DemandType};
use crate::domain::orchestrator::engine::run_until_idle;
use crate::domain::orchestrator::queue;
if !input.trim().is_empty() {
let digest = sha256_hex(format!("{}:{input}", definition.id).as_bytes());
let id = format!("WF-{}", &digest[..12]);
let demand = Demand::new(
id,
DemandType::Story,
input,
"",
DemandSource::Cli,
None,
now_public(),
)?;
let _ = queue::enqueue(root, &demand)?;
}
let owner = format!("workflow-{}", std::process::id());
let runner = select_runner(root, real);
let reports = run_until_idle(
root,
runner.as_ref(),
&owner,
&now_public(),
max_iterations.or(definition.max_iterations).unwrap_or(100),
)?;
for report in &reports {
audit_tick(root, report);
}
let mut out = crate::domain::workflow::WorkflowRunReport {
workflow_id: definition.id.clone(),
status: "completed".to_string(),
iterations: reports.len() as u32,
..Default::default()
};
for report in reports {
out.produced.extend(report.produced);
out.paused.extend(report.paused);
out.ready.extend(report.ready);
out.errors.extend(report.errors);
}
if !out.errors.is_empty() {
out.status = "error".to_string();
} else if !out.paused.is_empty() {
out.status = "awaiting_checkpoint".to_string();
} else if !out.ready.is_empty() {
out.status = "ready_for_exec".to_string();
}
Ok(out)
}
fn save_workflow_report(
root: &Path,
report: &crate::domain::workflow::WorkflowRunReport,
) -> Result<()> {
let path = workflow_dir(root).join(format!("{}.json", report.workflow_id));
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(
&path,
format!("{}\n", serde_json::to_string_pretty(report)?),
)?;
Ok(())
}
fn workflow_status_report(root: &Path, workflow: Option<&str>) -> Result<Value> {
let dir = workflow_dir(root);
if let Some(workflow) = workflow {
let path = dir.join(format!("{workflow}.json"));
if !path.exists() {
return Ok(json!({
"status": "missing",
"workflow": workflow,
"path": path,
}));
}
let text = fs::read_to_string(&path)?;
let value: Value = serde_json::from_str(&text)?;
return Ok(json!({
"status": "pass",
"workflow": workflow,
"path": path,
"run": value,
}));
}
let mut runs = Vec::new();
if let Ok(entries) = fs::read_dir(&dir) {
for path in entries.flatten().map(|entry| entry.path()) {
if path.extension().and_then(OsStr::to_str) == Some("json") {
if let Ok(text) = fs::read_to_string(&path) {
if let Ok(value) = serde_json::from_str::<Value>(&text) {
runs.push(value);
}
}
}
}
}
Ok(json!({
"status": "pass",
"count": runs.len(),
"runs": runs,
}))
}
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 = runtime::sdd_mcp::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)?;
}
McpCommand::Doctor(args) => {
let root =
runtime::platform::canonicalize(&args.root).unwrap_or_else(|_| args.root.clone());
let report = sdd_mcp_doctor_value(&root);
if args.json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
println!("SDD MCP doctor");
println!(
"{}",
report["status"]
.as_str()
.unwrap_or("unknown")
.to_uppercase()
);
println!(
"cache: {}",
report["cache"]["status"].as_str().unwrap_or("-")
);
println!(
"clients: {}",
report["clients"]["status"].as_str().unwrap_or("-")
);
println!(
"agent_contexts: {}",
report["agent_contexts"]["status"].as_str().unwrap_or("-")
);
println!(
"mcp_config: {}",
report["mcp_config"]["status"].as_str().unwrap_or("-")
);
}
if report["status"].as_str() == Some("fail") {
bail!("MCP doctor failed");
}
}
}
Ok(())
}
fn run_acp(args: AcpArgs) -> Result<()> {
match args.command {
AcpCommand::Serve(args) => {
let root =
runtime::platform::canonicalize(&args.root).unwrap_or_else(|_| args.root.clone());
runtime::acp::serve_stdio(&root)?;
}
AcpCommand::Doctor(args) => {
let root =
runtime::platform::canonicalize(&args.root).unwrap_or_else(|_| args.root.clone());
let report = runtime::acp::doctor(&root);
if args.json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
println!("SDD ACP doctor");
println!(
"{}",
report["status"]
.as_str()
.unwrap_or("unknown")
.to_uppercase()
);
println!("protocol: {}", report["protocolVersion"]);
println!(
"sessions_dir: {}",
report["sessionsDir"].as_str().unwrap_or("-")
);
}
if report["status"].as_str() != Some("pass") {
bail!("ACP doctor failed");
}
}
AcpCommand::Config(args) => {
let root =
runtime::platform::canonicalize(&args.root).unwrap_or_else(|_| args.root.clone());
println!("SDD ACP config");
if args.dry_run {
println!("DRY RUN: no files were written.");
}
for action in
runtime::acp::config_actions(&root, &args.targets, args.force, args.dry_run)?
{
println!("- {action}");
}
}
}
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 {
print_trace_list(&events);
}
}
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 {
print_trace_doctor(&report);
}
if report.status != "pass" {
bail!("trace doctor failed");
}
}
}
Ok(())
}
pub(crate) 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,
}))
}
pub(crate) fn sdd_mcp_context_build_value(
root: &Path,
orchestration: &str,
stage: &str,
task: Option<&str>,
) -> Result<Value> {
let pack = build_context_pack(root, orchestration, stage, task, false)?;
let mut value = context_pack_json_value(&pack, false);
if let Some(map) = value.as_object_mut() {
map.insert("derived".to_string(), Value::Bool(true));
}
Ok(value)
}
pub(crate) fn sdd_mcp_context_bundle_value(
root: &Path,
orchestration: &str,
stage: &str,
task: Option<&str>,
query: Option<&str>,
) -> Result<Value> {
let stage = validate_context_stage(stage)?;
let pack = build_context_pack(root, orchestration, stage, task, false)?;
let slug = artifact_slug(orchestration);
let project = warning_value(sdd_mcp_project_status_value(root));
let artifacts = warning_value(artifact_status_value(root, orchestration));
let capabilities = warning_value(sdd_mcp_capabilities_status_value(root));
let optimization = warning_value(sdd_mcp_optimize_status_value(root));
let runtime_adapters = warning_value(runtime_adapters_status_value());
let trace_summary = warning_value((|| -> Result<Value> {
let mut events = runtime::trace::read_events(root)?;
events = runtime::trace::filter_by_orchestration(events, orchestration);
Ok(serde_json::to_value(runtime::trace::summary(&events))?)
})());
let search_query = query.or(task).unwrap_or(stage).trim().to_string();
let search = warning_value(sdd_mcp_search_value(root, &search_query, 10));
let codegraph_status = optimization
.get("codegraph")
.cloned()
.unwrap_or_else(|| json!({ "status": "warn", "error": "optimization status unavailable" }));
let status = context_bundle_status(
&[
&project,
&artifacts,
&trace_summary,
&search,
&capabilities,
&optimization,
&runtime_adapters,
],
&pack,
);
Ok(json!({
"status": status,
"generated_at": now(),
"orchestration": orchestration,
"orchestration_slug": slug,
"stage": stage,
"task": task,
"mcp_policy": {
"role": "primary_read_surface",
"read_only": true,
"purpose": "Entregar o pacote de contexto SDD mais completo possível para geração, execução e revisão sem gravar artefatos canônicos.",
"canonical_precedence": [
"docs/<slug>/06-adr.md",
"docs/<slug>/03-techspec.md",
"docs/<slug>/02-prd.md",
"docs/<slug>/04-tasks.md",
"docs/<slug>/06-execution.md",
"docs/<slug>/07-review.md",
"docs/<slug>/08-memory.md",
".sdd/intelligence/*.jsonl"
],
"canonical_sources": [
format!("docs/{slug}/traceability-map.yaml"),
format!("docs/{slug}/<stage>.md"),
".sdd/events.jsonl",
".sdd/subagents.jsonl",
".sdd/task-gates.jsonl",
"templates/capability-catalog.yaml",
".agents/agents/sdd-orchestrator/AGENT.md"
],
"checkpoint_boundary": "MCP consulta e prepara contexto; aprovações humanas e artefatos aprovados continuam fora do MCP."
},
"project": project,
"artifacts": artifacts,
"context_pack": context_pack_json_value(&pack, false),
"trace_summary": trace_summary,
"search": search,
"capabilities": capabilities,
"optimization": optimization,
"runtime_adapters": runtime_adapters,
"codegraph_companion": {
"server": "codegraph",
"status": codegraph_status,
"usage_contract": "Use primeiro este bundle para contrato SDD, artifacts, checkpoints e contexto operacional; use CodeGraph em seguida para mapa estrutural de código, callers/callees/impacto e fonte de símbolos.",
"recommended_mcp_calls": [
{
"tool": "codegraph_status",
"when": "Antes de depender de contexto estrutural do código.",
"arguments": {}
},
{
"tool": "codegraph_context",
"when": "Depois de ler o bundle SDD e antes de editar código brownfield.",
"arguments": {
"task": format!("{} — stage {} — {}", orchestration, stage, task.unwrap_or("sem task específica")),
"includeCode": true,
"maxNodes": 20
}
},
{
"tool": "codegraph_explore",
"when": "Quando codegraph_context retornar símbolos/arquivos relevantes que precisam de fonte agrupada.",
"arguments": {
"query": "<symbols and files returned by codegraph_context>",
"maxFiles": 8
}
}
]
},
"next_best_actions": [
"Leia context_pack.content e mcp_policy antes de gerar ou executar.",
"Use codegraph_companion.recommended_mcp_calls para mapear impacto estrutural quando houver código envolvido.",
"Valide artefatos materializados com sdd validate-artifact e registre evidências no artifact store local.",
"Pare em checkpoint humano quando PRD, Tech Spec, Refinement, merge ou deploy exigirem aprovação."
],
"derived": true,
}))
}
fn runtime_adapters_status_value() -> Result<Value> {
Ok(json!({
"adapters": runtime::mcp::runtime_adapters(),
"rmcp_server_info": runtime::mcp::rmcp_server_info_json()?,
}))
}
fn warning_value(result: Result<Value>) -> Value {
match result {
Ok(value) => value,
Err(error) => json!({
"status": "warn",
"error": error.to_string(),
}),
}
}
fn context_bundle_status(blocks: &[&Value], pack: &ContextPack) -> &'static str {
if pack.conflicts > 0
|| !pack.stale.is_empty()
|| blocks.iter().any(|value| value_has_warning_status(value))
{
"warn"
} else {
"pass"
}
}
fn value_has_warning_status(value: &Value) -> bool {
matches!(
value.get("status").and_then(Value::as_str),
Some("warn" | "warning" | "fail" | "failed" | "error")
)
}
pub(crate) fn sdd_mcp_context_resource_text(
root: &Path,
orchestration: &str,
stage: &str,
) -> Result<String> {
Ok(build_context_pack(root, orchestration, stage, None, false)?.content)
}
pub(crate) fn sdd_mcp_clients_doctor_value(root: &Path) -> Value {
let report = client_doctor(root);
json!({
"status": report.status,
"missing": report.missing,
"invalid": report.invalid,
"covered": report.covered,
})
}
pub(crate) fn sdd_agent_context_status_value(root: &Path) -> Value {
match load_sdd_config(root) {
Ok(config) => {
let materialization = &config.intelligence.context_materialization;
let max_boundaries = materialization.max_boundaries.max(1);
let discovery = discover_project(root);
let profiles =
detect_context_profiles(root, &discovery, materialization, max_boundaries);
let planned = if materialization.local_agents {
runtime::agent_context::filter_materializable_profiles(&profiles, max_boundaries)
} else {
Vec::new()
};
let policy = runtime::agent_context::policy(materialization.local_agents, false);
let status =
runtime::agent_context::status(root, policy, &profiles, &planned, max_boundaries);
let file_budget = runtime::agent_context::file_budget(max_boundaries, planned.len());
json!({
"status": if status.warnings.is_empty() { "pass" } else { "warn" },
"policy": status.policy,
"profile_count": status.profile_count,
"profiles": profiles,
"file_budget": file_budget,
"local_agents_existing": status.local_agents_existing,
"local_agents_planned": status.local_agents_planned,
"warnings": status.warnings,
})
}
Err(error) => json!({
"status": "warn",
"error": error.to_string(),
}),
}
}
pub(crate) fn sdd_mcp_project_status_value(root: &Path) -> Result<Value> {
let cache = match runtime::sdd_cache::ensure_fresh(root) {
Ok(status) => json!({
"status": "pass",
"cache": status,
}),
Err(error) => json!({
"status": "warn",
"error": error.to_string(),
"cache": runtime::sdd_cache::status(root).ok(),
}),
};
let clients = sdd_mcp_clients_doctor_value(root);
let agent_contexts = sdd_agent_context_status_value(root);
let optimization = match load_sdd_config(root) {
Ok(config) => {
let status = runtime::optimization::status(root, &config.optimization);
json!({
"status": "pass",
"codegraph": status.codegraph,
"ignored_paths": status.ignored_paths,
})
}
Err(error) => json!({
"status": "warn",
"error": error.to_string(),
}),
};
let orchestrations = runtime::sdd_cache::orchestration_slugs(root)
.unwrap_or_else(|_| docs_orchestration_slugs(root));
let status = if clients["status"].as_str() == Some("fail") {
"fail"
} else if cache["status"].as_str() == Some("warn")
|| agent_contexts["status"].as_str() == Some("warn")
|| optimization["status"].as_str() == Some("warn")
{
"warn"
} else {
"pass"
};
Ok(json!({
"status": status,
"root": root,
"cache": cache,
"clients": clients,
"agent_contexts": agent_contexts,
"codegraph": optimization,
"orchestrations": orchestrations,
"canonical_sources": ["docs/<slug>/", "docs/<slug>/traceability-map.yaml", ".sdd/*.jsonl"],
"derived_cache": ".sdd/cache/sdd.sqlite",
}))
}
pub(crate) fn sdd_mcp_search_value(root: &Path, query: &str, limit: usize) -> Result<Value> {
runtime::sdd_cache::search_cached_or_direct(root, query, limit.clamp(1, 50))
}
pub(crate) fn sdd_mcp_doctor_value(root: &Path) -> Value {
let clients = sdd_mcp_clients_doctor_value(root);
let cache = runtime::sdd_cache::doctor_value(root);
let agent_contexts = sdd_agent_context_status_value(root);
let mcp_config = mcp_config_status_value(root);
let codegraph = match load_sdd_config(root) {
Ok(config) => {
let status = runtime::optimization::status(root, &config.optimization);
json!({
"status": if status.codegraph.available { "pass" } else { "warn" },
"codegraph": status.codegraph,
})
}
Err(error) => json!({
"status": "warn",
"error": error.to_string(),
}),
};
let status = if clients["status"].as_str() == Some("fail") {
"fail"
} else if cache["status"].as_str() == Some("warn")
|| agent_contexts["status"].as_str() == Some("warn")
|| mcp_config["status"].as_str() == Some("warn")
|| codegraph["status"].as_str() == Some("warn")
{
"warn"
} else {
"pass"
};
json!({
"status": status,
"root": root,
"cache": cache,
"agent_contexts": agent_contexts,
"mcp_config": mcp_config,
"codegraph": codegraph,
"clients": clients,
})
}
pub(crate) fn sdd_readiness_summary_value(
root: &Path,
orchestration: Option<&str>,
workflow: Option<&str>,
) -> Result<Value> {
let workflow = workflow.unwrap_or("agentic-sdd-loop");
let mcp_doctor = sdd_mcp_doctor_value(root);
let health = readiness_result_value(health_report(root, orchestration), "sdd health");
let auto_status = readiness_result_value(auto_status_report(root), "sdd auto status");
let workflow_status =
readiness_workflow_status_value(workflow_status_report(root, Some(workflow)));
let cache_freshness = runtime::sdd_cache::doctor_value(root);
let capabilities = readiness_capabilities_value(root);
let quality_report = readiness_quality_report_value(root, orchestration);
let components = json!({
"mcp_doctor": mcp_doctor,
"health": health,
"auto_status": auto_status,
"workflow_status": workflow_status,
"cache_freshness": cache_freshness,
"capabilities": capabilities,
"quality_report": quality_report,
});
let verdict = readiness_summary_verdict(&components);
let recommended_commands =
readiness_recommended_commands(root, orchestration, workflow, &components);
Ok(json!({
"status": verdict,
"verdict": verdict,
"generated_at": now(),
"root": root,
"orchestration": orchestration,
"workflow": workflow,
"components": components,
"recommended_commands": recommended_commands,
"derived": true,
"mcp_policy": {
"read_only": true,
"purpose": "Agrega sinais locais de prontidão para cockpit MCP sem aprovar checkpoints nem gravar artefatos canônicos.",
"canonical_sources": [
"sdd mcp doctor",
"sdd health",
"sdd auto status",
"sdd workflow status",
".sdd/cache/sdd.sqlite",
"templates/capability-catalog.yaml",
"sdd quality report"
],
},
}))
}
fn readiness_workflow_status_value(result: Result<Value>) -> Value {
let value = readiness_result_value(result, "sdd workflow status");
let status = readiness_component_status(&value)
.unwrap_or("warn")
.to_string();
readiness_with_status(value, readiness_level(&status))
}
fn readiness_capabilities_value(root: &Path) -> Value {
let value = readiness_result_value(
sdd_mcp_capabilities_status_value(root),
"sdd capabilities doctor",
);
let status = value
.get("doctor")
.and_then(component_status)
.or_else(|| readiness_component_status(&value))
.unwrap_or("fail")
.to_string();
readiness_with_status(value, readiness_level(&status))
}
fn readiness_quality_report_value(root: &Path, orchestration: Option<&str>) -> Value {
match orchestration {
Some(name) => readiness_result_value(
(|| -> Result<Value> {
Ok(serde_json::to_value(domain::evaluation::quality_report(
root, name,
)?)?)
})(),
"sdd quality report",
),
None => readiness_with_status(
json!({
"status": "skipped",
"reason": "orchestration não informado; quality report exige `orchestration`",
}),
"warn",
),
}
}
fn readiness_result_value(result: Result<Value>, source: &str) -> Value {
match result {
Ok(value) => value,
Err(error) => json!({
"status": "fail",
"source": source,
"error": format!("{error:#}"),
}),
}
}
fn readiness_with_status(mut value: Value, status: &'static str) -> Value {
if let Some(map) = value.as_object_mut() {
map.insert("readiness_status".to_string(), json!(status));
}
value
}
fn readiness_summary_verdict(components: &Value) -> &'static str {
let Some(components) = components.as_object() else {
return "blocked";
};
let mut warned = false;
for component in components.values() {
match readiness_level(readiness_component_status(component).unwrap_or("warn")) {
"fail" => return "blocked",
"warn" => warned = true,
_ => {}
}
}
if warned {
"warn"
} else {
"ready"
}
}
fn readiness_component_status(value: &Value) -> Option<&str> {
value
.get("readiness_status")
.and_then(Value::as_str)
.or_else(|| value.get("status").and_then(Value::as_str))
}
fn readiness_level(status: &str) -> &'static str {
match status {
"pass" | "ready" | "completed" | "ready_for_exec" => "pass",
"fail" | "failed" | "error" | "blocked" => "fail",
"warn" | "warning" | "missing" | "skipped" | "paused" | "awaiting_approval" => "warn",
status if status.starts_with("manual:") => "warn",
_ => "warn",
}
}
fn readiness_recommended_commands(
root: &Path,
orchestration: Option<&str>,
workflow: &str,
components: &Value,
) -> Vec<Value> {
let root_arg = shell_arg(&root.display().to_string());
let workflow_arg = shell_arg(workflow);
let mut commands = Vec::new();
if readiness_level(readiness_component_status(&components["mcp_doctor"]).unwrap_or("warn"))
!= "pass"
{
push_readiness_command(
&mut commands,
format!("sdd mcp doctor --root {root_arg} --json"),
"Detalhar problemas de MCP doctor, cache, configs locais, CodeGraph e clients.",
);
}
if components["mcp_doctor"]["mcp_config"]["status"].as_str() == Some("warn") {
push_readiness_command(
&mut commands,
format!("sdd mcp config --targets all --root {root_arg} --force"),
"Regenerar configs MCP locais quando `.mcp.json`, Cursor ou Claude estiverem ausentes.",
);
}
if readiness_level(readiness_component_status(&components["health"]).unwrap_or("warn"))
!= "pass"
{
push_readiness_command(
&mut commands,
health_command(&root_arg, orchestration),
"Rever o health agregado antes de avançar a execução.",
);
}
if readiness_level(readiness_component_status(&components["cache_freshness"]).unwrap_or("warn"))
!= "pass"
{
push_readiness_command(
&mut commands,
format!("sdd mcp doctor --root {root_arg} --json"),
"Confirmar freshness do cache derivado e reconstruir via superfície MCP quando necessário.",
);
}
if readiness_level(readiness_component_status(&components["capabilities"]).unwrap_or("warn"))
!= "pass"
{
push_readiness_command(
&mut commands,
format!("sdd capabilities doctor --root {root_arg} --targets all --json"),
"Validar capability catalog, cobertura por target e drift gerado.",
);
}
if readiness_level(readiness_component_status(&components["workflow_status"]).unwrap_or("warn"))
!= "pass"
{
push_readiness_command(
&mut commands,
format!("sdd workflow --root {root_arg} status {workflow_arg} --json"),
"Ler o status persistido do workflow monitorado pelo cockpit.",
);
if components["workflow_status"]["status"].as_str() == Some("missing") {
push_readiness_command(
&mut commands,
format!(
"sdd workflow --root {root_arg} run {workflow_arg} --input '<demanda>' --max-iterations 3 --json"
),
"Criar o primeiro status do workflow quando ainda não houver execução persistida.",
);
}
}
if readiness_level(readiness_component_status(&components["auto_status"]).unwrap_or("warn"))
!= "pass"
{
push_readiness_command(
&mut commands,
format!("sdd auto --root {root_arg} status --json"),
"Inspecionar demandas pausadas, manuais ou com erro no motor autônomo.",
);
}
if let Some(demands) = components["auto_status"]["demands"].as_array() {
for demand in demands {
let status = demand.get("status").and_then(Value::as_str).unwrap_or("");
let id = demand.get("id").and_then(Value::as_str).unwrap_or("");
if status == "awaiting_approval" {
if let Some(gate) = demand.get("cursor").and_then(Value::as_str) {
push_readiness_command(
&mut commands,
format!(
"sdd demand --root {root_arg} approve {} --gate {} --by '<operador>'",
shell_arg(id),
shell_arg(gate)
),
"Resolver checkpoint humano antes de continuar a demanda.",
);
}
} else if status == "ready_for_exec" {
let name = demand
.get("slug")
.and_then(Value::as_str)
.or(orchestration)
.unwrap_or("<ciclo>");
push_readiness_command(
&mut commands,
format!(
"sdd context build --root {root_arg} --name {} --stage execution --json --metadata-only",
shell_arg(name)
),
"Preparar handoff baixo-token para execução da demanda pronta.",
);
}
}
}
if readiness_level(readiness_component_status(&components["quality_report"]).unwrap_or("warn"))
!= "pass"
{
push_readiness_command(
&mut commands,
quality_command(&root_arg, orchestration),
"Rodar quality report com a orquestração correta para fechar a leitura de prontidão.",
);
}
if commands.is_empty() {
if let Some(name) = orchestration {
push_readiness_command(
&mut commands,
format!(
"sdd context build --root {root_arg} --name {} --stage execution --json --metadata-only",
shell_arg(name)
),
"Sistema pronto; começar pelo handoff baixo-token antes de editar ou revisar código.",
);
} else {
push_readiness_command(
&mut commands,
format!("sdd workflow --root {root_arg} status {workflow_arg} --json"),
"Sistema pronto; acompanhar o workflow padrão pelo status persistido.",
);
}
}
commands
}
fn health_command(root_arg: &str, orchestration: Option<&str>) -> String {
match orchestration {
Some(name) => format!(
"sdd health --root {root_arg} --name {} --json",
shell_arg(name)
),
None => format!("sdd health --root {root_arg} --json"),
}
}
fn quality_command(root_arg: &str, orchestration: Option<&str>) -> String {
let name = orchestration.unwrap_or("<ciclo>");
format!(
"sdd quality --root {root_arg} report --name {} --json",
shell_arg(name)
)
}
fn push_readiness_command(commands: &mut Vec<Value>, command: String, reason: &str) {
if commands
.iter()
.any(|item| item.get("command").and_then(Value::as_str) == Some(command.as_str()))
{
return;
}
commands.push(json!({
"command": command,
"reason": reason,
}));
}
fn shell_arg(value: &str) -> String {
if !value.is_empty()
&& value.chars().all(|ch| {
ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | ':' | '=' | '+')
})
{
return value.to_string();
}
format!("'{}'", value.replace('\'', "'\\''"))
}
fn mcp_config_status_value(root: &Path) -> Value {
let mut issues = Vec::new();
validate_mcp_server_file(root, ".mcp.json", &mut issues);
validate_mcp_server_file(root, ".cursor/mcp.json", &mut issues);
if !root.join(".claude/settings.json").exists() {
issues.push("missing .claude/settings.json".to_string());
}
json!({
"status": if issues.is_empty() { "pass" } else { "warn" },
"servers": ["sdd", "codegraph"],
"files": {
".mcp.json": root.join(".mcp.json").exists(),
".cursor/mcp.json": root.join(".cursor/mcp.json").exists(),
".claude/settings.json": root.join(".claude/settings.json").exists(),
},
"issues": issues,
})
}
fn docs_orchestration_slugs(root: &Path) -> Vec<String> {
let mut slugs = fs::read_dir(root.join("docs"))
.ok()
.into_iter()
.flat_map(|entries| entries.filter_map(|entry| entry.ok()))
.filter(|entry| entry.file_type().map(|kind| kind.is_dir()).unwrap_or(false))
.filter_map(|entry| entry.file_name().to_str().map(str::to_string))
.collect::<Vec<_>>();
slugs.sort();
slugs
}
pub(crate) fn sdd_mcp_optimize_status_value(root: &Path) -> Result<Value> {
let config = load_sdd_config(root)?;
Ok(serde_json::to_value(runtime::optimization::status(
root,
&config.optimization,
))?)
}
pub(crate) fn sdd_mcp_context_handoff_value(
root: &Path,
orchestration: &str,
stage: &str,
task: Option<&str>,
) -> Result<Value> {
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(orchestration))?;
let artifact_sources = sources
.iter()
.filter(|source| source.kind == "artifact")
.filter(|source| source.stage.as_deref().is_some())
.map(|source| source.path.clone())
.collect::<Vec<_>>();
let handoff = runtime::optimization::build_context_handoff(
root,
stage,
task,
artifact_sources,
&optimization,
);
Ok(json!({
"handoff": handoff,
"rendered": runtime::optimization::render_context_handoff(&handoff),
"optimization": optimization,
"derived": true,
"canonical_source": format!("docs/{}/traceability-map.yaml", artifact_slug(orchestration)),
}))
}
pub(crate) fn sdd_mcp_capabilities_status_value(root: &Path) -> Result<Value> {
let catalog = load_capability_catalog(root)?;
let targets = ClientTarget::all();
let doctor = capability_doctor(root, &targets)?;
Ok(json!({
"catalog": catalog,
"doctor": doctor,
"derived": true,
"canonical_source": "templates/capability-catalog.yaml",
}))
}
pub(crate) fn sdd_mcp_agents_manifest_value(agent_id: &str) -> Result<Value> {
let manifest = runtime::agents::agent_manifest(agent_id)?;
let markdown = runtime::agents::render_agent_manifest_markdown(&manifest);
Ok(json!({
"manifest": manifest,
"markdown": markdown,
"canonical_source": ".agents/agents/sdd-orchestrator/AGENT.md",
"derived": true,
}))
}
fn print_trace_list(events: &[runtime::trace::TraceEvent]) {
let count = events.len();
banner("traces");
summary_line("Events", &format!("{count} events"));
banner_close();
section("Events", count);
if count == 0 {
section_none();
return;
}
for event in events {
let icon = status_icon(event.status.as_deref().unwrap_or("-"));
item_labeled(
icon,
&event.run_id,
14,
&format!(
"{} · {} · {}",
event.ts,
event.kind,
event.target.as_deref().unwrap_or("-")
),
);
}
}
fn print_trace_doctor(report: &runtime::trace::TraceDoctorReport) {
let signal = status_signal(report.status, false, false);
let total_events: usize = report.files.values().sum();
banner("trace doctor");
status_line(report.status, signal);
summary_line(
"Traces",
&format!(
"{} files · {} events · {} warnings",
report.files.len(),
total_events,
report.warnings.len()
),
);
banner_close();
section("Files", report.files.len());
if report.files.is_empty() {
section_none();
} else {
for (file, count) in &report.files {
item("◇", &format!("{file} · {count} events"));
}
}
if !report.warnings.is_empty() {
section("Warnings", report.warnings.len());
for warning in &report.warnings {
item("⚠", warning);
}
}
}
fn print_trace_tree(tree: &runtime::trace::TraceTree, depth: usize) {
let indent = " ".repeat(depth);
let marker = if depth == 0 { "◆" } else { "◇" };
println!("{indent}{marker} {}", tree.run_id);
for event in &tree.events {
let status_icon = match event.status.as_deref() {
Some("pass") | Some("ok") | Some("completed") => "✓",
Some("fail") | Some("error") => "✕",
_ => "·",
};
println!(
"{indent} {status_icon} {} {} {}",
event.ts,
event.kind,
event.target.as_deref().unwrap_or("-")
);
}
for child in &tree.children {
print_trace_tree(child, depth + 1);
}
}
fn print_trace_summary(summary: &runtime::trace::TraceSummary) {
banner("trace summary");
status_line("pass", "clean");
summary_line(
"Events",
&format!(
"{} total · {} roots",
summary.total_events, summary.root_count
),
);
summary_line("Latest", summary.latest_ts.as_deref().unwrap_or("-"));
banner_close();
if !summary.roots.is_empty() {
section("Roots", summary.roots.len());
for root in &summary.roots {
item("◆", root);
}
}
if !summary.by_kind.is_empty() {
section("By kind", summary.by_kind.len());
for (kind, count) in &summary.by_kind {
item_labeled("◇", kind, 14, &format!("{count} events"));
}
}
if !summary.by_status.is_empty() {
section("By status", summary.by_status.len());
for (status, count) in &summary.by_status {
let icon = status_icon(status);
item_labeled(icon, status, 14, &format!("{count} events"));
}
}
}
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(&context_pack_json_value(
&pack,
args.metadata_only || args.write
))?
);
} else {
println!("{}", pack.path.display());
}
} else if args.json {
println!(
"{}",
serde_json::to_string_pretty(&context_pack_json_value(
&pack,
args.metadata_only
))?
);
} 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}");
}
}
ContextCommand::Materialize(args) => {
let root =
runtime::platform::canonicalize(&args.root).unwrap_or_else(|_| args.root.clone());
let mut plan = build_context_materialization_plan(
&root,
&args.name,
args.write,
args.dry_run,
args.local_agents,
args.max_boundaries,
)?;
if args.write && !args.dry_run {
apply_context_materialization_plan(&root, &mut plan)?;
}
if args.json {
println!("{}", serde_json::to_string_pretty(&plan)?);
} else {
print!("{}", render_context_materialization_plan(&plan));
}
}
}
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 target == ClientTarget::Opencode
&& capability
.validation
.iter()
.any(|validation| validation == "opencode-skill-collection")
{
validate_opencode_skill_collection(root, capability, rel, &mut report);
}
if capability
.validation
.iter()
.any(|validation| validation == "stage-skill-collection")
{
validate_stage_skill_collection(root, capability, target, rel, &mut report);
}
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_opencode_skill_collection(
root: &Path,
capability: &CapabilitySpec,
primary_rel: &str,
report: &mut CapabilityDoctorReport,
) {
for file in opencode_selected_skill_files() {
if file.path == primary_rel {
continue;
}
report
.checked
.entry(capability.id.clone())
.or_default()
.push(format!("opencode:{}", file.path));
let path = capability_path(root, file.path);
let content = match fs::read_to_string(&path) {
Ok(content) => content,
Err(_) => {
report
.missing
.entry(capability.id.clone())
.or_default()
.push(format!("opencode missing {}", file.path));
continue;
}
};
if !content.trim_start().starts_with("---")
|| !content.contains("\nname:")
|| !content.contains("\ndescription:")
{
report
.invalid
.entry(capability.id.clone())
.or_default()
.push(format!(
"opencode missing SKILL.md frontmatter: {}",
file.path
));
}
if !content.chars().any(|ch| ch as u32 > 0x7f) {
report
.invalid
.entry(capability.id.clone())
.or_default()
.push(format!(
"opencode must preserve PT-BR UTF-8 accents: {}",
file.path
));
}
if content != file.content {
report
.drift
.entry(capability.id.clone())
.or_default()
.push(format!(
"opencode drifted from Rust generator: {}",
file.path
));
}
}
}
fn validate_stage_skill_collection(
root: &Path,
capability: &CapabilitySpec,
target: ClientTarget,
primary_rel: &str,
report: &mut CapabilityDoctorReport,
) {
let expected_by_path = stage_skill_files(target)
.into_iter()
.map(|file| (file.path, file.content))
.collect::<BTreeMap<_, _>>();
for rel in stage_skill_paths(target) {
if rel != primary_rel {
report
.checked
.entry(capability.id.clone())
.or_default()
.push(format!("{}:{}", target.as_str(), rel));
}
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 skill_validation_issues(&content) {
report
.invalid
.entry(capability.id.clone())
.or_default()
.push(format!("{} {}: {issue}", target.as_str(), rel));
}
if !content.contains("sdd ") {
report
.invalid
.entry(capability.id.clone())
.or_default()
.push(format!(
"{} does not delegate to `sdd`: {}",
target.as_str(),
rel
));
}
if !content.contains("sdd init")
&& !content.contains("docs/<slug")
&& !content.contains("sdd artifact save")
{
report
.invalid
.entry(capability.id.clone())
.or_default()
.push(format!(
"{} missing artifact-store contract: {}",
target.as_str(),
rel
));
}
if !content.chars().any(|ch| ch as u32 > 0x7f) {
report
.invalid
.entry(capability.id.clone())
.or_default()
.push(format!(
"{} must preserve PT-BR UTF-8 accents: {}",
target.as_str(),
rel
));
}
if let Some(expected) = expected_by_path.get(rel) {
if &content != expected {
report
.drift
.entry(capability.id.clone())
.or_default()
.push(format!(
"{} drifted from Rust generator: {}",
target.as_str(),
rel
));
}
}
}
}
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" => {
let is_opencode_plugin = adapter.kind.as_deref() == Some("plugin")
|| rel.starts_with(".opencode/plugins/");
let has_generic_hook = lower.contains("hook") || lower.contains("\"hooks\"");
let has_opencode_hook = content.contains("export default")
&& content.contains("return {")
&& (content.contains("\"tool.execute.before\"") || content.contains("event:"));
if is_opencode_plugin && !has_opencode_hook {
issues.push(format!(
"{} missing OpenCode plugin hook export: {}",
target.as_str(),
rel
));
} else if !is_opencode_plugin && !has_generic_hook {
issues.push(format!(
"{} missing hooks contract: {}",
target.as_str(),
rel
));
}
}
"opencode-skill-collection" => {}
"stage-skill-collection" => {}
"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_list(catalog: &CapabilityCatalog) {
let count = catalog.capabilities.len();
banner("capabilities");
summary_line(
"Catalog",
&format!("{count} capabilities · v{}", catalog.version),
);
banner_close();
println!("version: {}", catalog.version);
section("Capabilities", count);
if count == 0 {
section_none();
return;
}
for capability in &catalog.capabilities {
let adapters = capability
.adapters
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ");
item_labeled(
"◇",
&capability.id,
22,
&format!(
"{} · stages={} · adapters={}",
capability.category.as_str(),
capability.stages.join(","),
adapters
),
);
}
}
fn print_capability_doctor(report: &CapabilityDoctorReport) {
let signal = status_signal(
report.status,
!report.invalid.is_empty(),
!report.missing.is_empty(),
);
let checked_paths = capability_detail_count(&report.checked);
let missing_paths = capability_detail_count(&report.missing);
let invalid_paths = capability_detail_count(&report.invalid);
let drift_paths = capability_detail_count(&report.drift);
banner("capabilities doctor");
status_line(report.status, signal);
summary_line(
"Capabilities",
&format!(
"{} checked · {} missing · {} invalid · {} drift",
report.checked.len(),
report.missing.len(),
report.invalid.len(),
report.drift.len()
),
);
summary_line(
"Surfaces",
&format!(
"{checked_paths} ok · {missing_paths} missing · {invalid_paths} invalid · {drift_paths} drift"
),
);
banner_close();
print_capability_doctor_section("Invalid", "✕", &report.invalid, "issue", "issues");
print_capability_doctor_section("Missing", "◇", &report.missing, "path", "paths");
print_capability_doctor_section("Drift", "⚠", &report.drift, "issue", "issues");
print_capability_doctor_section("Checked", "✓", &report.checked, "adapter", "adapters");
}
fn capability_detail_count(items: &BTreeMap<String, Vec<String>>) -> usize {
items.values().map(Vec::len).sum()
}
fn print_capability_doctor_section(
title: &str,
icon: &str,
items: &BTreeMap<String, Vec<String>>,
detail_singular: &str,
detail_plural: &str,
) {
let caps = items.len();
let details = capability_detail_count(items);
println!();
println!(
"◇ {title} ({caps} {} · {details} {})",
plural(caps, "capability", "capabilities"),
plural(details, detail_singular, detail_plural)
);
if items.is_empty() {
section_none();
return;
}
for (capability, details) in items {
item_labeled(
icon,
capability,
22,
&format!(
"{} {}",
details.len(),
plural(details.len(), detail_singular, detail_plural)
),
);
tree(details, Some(4));
}
}
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);
}
}
let mut actions: Vec<String> = Vec::new();
for (rel, content) in files {
let target = capability_path(root, &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::Opencode) {
for action in ensure_opencode_prompt_files(root, dry_run)? {
actions.push(action);
}
}
print_sync_report("capabilities sync", &actions, root, dry_run);
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 required_paths = target.required_paths();
let missing_paths: Vec<String> = 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(),
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::Claude => mcp_config_issues(root, target),
ClientTarget::Codex | ClientTarget::Cursor => {
// .mcp.json e .cursor/mcp.json são gerados por máquina (path absoluto)
// e estão no .gitignore — não validar como required no client doctor.
Vec::new()
}
_ => Vec::new(),
}
}
fn print_client_doctor(report: &ClientDoctorReport) {
let signal = status_signal(
report.status,
!report.invalid.is_empty(),
!report.missing.is_empty(),
);
let covered_paths = client_doctor_detail_count(&report.covered);
let missing_paths = client_doctor_detail_count(&report.missing);
let invalid_issues = client_doctor_detail_count(&report.invalid);
banner("clients doctor");
status_line(report.status, signal);
summary_line(
"Clients",
&format!(
"{} covered · {} missing · {} invalid",
report.covered.len(),
report.missing.len(),
report.invalid.len()
),
);
summary_line(
"Surfaces",
&format!("{covered_paths} ok · {missing_paths} missing · {invalid_issues} issues"),
);
banner_close();
print_client_doctor_section("Invalid", "✕", &report.invalid, "issue", "issues", None);
print_client_doctor_section("Missing", "◇", &report.missing, "path", "paths", None);
print_client_doctor_section("Covered", "✓", &report.covered, "path", "paths", Some(3));
}
fn client_doctor_detail_count(items: &BTreeMap<String, Vec<String>>) -> usize {
items.values().map(Vec::len).sum()
}
fn print_client_doctor_section(
title: &str,
icon: &str,
items: &BTreeMap<String, Vec<String>>,
detail_singular: &str,
detail_plural: &str,
preview_limit: Option<usize>,
) {
let clients = items.len();
let details = client_doctor_detail_count(items);
println!();
println!(
"◇ {title} ({clients} {} · {details} {})",
plural(clients, "client", "clients"),
plural(details, detail_singular, detail_plural)
);
if items.is_empty() {
section_none();
return;
}
for (client, details) in items {
println!(
" {icon} {client:<14} {} {}",
details.len(),
plural(details.len(), detail_singular, detail_plural)
);
tree(details, preview_limit);
}
}
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_context_bundle",
"mcp__sdd__sdd_clients_doctor",
"mcp__sdd__sdd_project_status",
"mcp__sdd__sdd_search",
"mcp__sdd__sdd_runtime_adapters",
"mcp__sdd__sdd_optimize_status",
"mcp__sdd__sdd_context_handoff",
"mcp__sdd__sdd_capabilities_status",
"mcp__sdd__sdd_agents_manifest",
"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__sdd__sdd_context_bundle",
"mcp__sdd__sdd_project_status",
"mcp__sdd__sdd_search",
"mcp__sdd__sdd_runtime_adapters",
"mcp__sdd__sdd_context_handoff",
"mcp__sdd__sdd_optimize_status",
"mcp__sdd__sdd_capabilities_status",
"mcp__sdd__sdd_agents_manifest",
"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);
}
let mut actions: Vec<String> = Vec::new();
for (rel, content) in files {
let target = client_file_target(&root, 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::Opencode) {
for action in ensure_opencode_prompt_files(&root, dry_run)? {
actions.push(action);
}
}
print_sync_report("clients sync", &actions, &root, dry_run);
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(_) => {
// ── TASK-11: sdd bot install é o único ponto de provisionamento ────────
// 1. Verificar .gitignore
let gitignore_path = root.join(".gitignore");
let gitignore_entries = [
("sdd-bot.config.yaml", "sdd-bot.config.yaml"),
(".sdd/bot/sdd-bot.db", ".sdd/bot/sdd-bot.db"),
];
let gitignore_content = if gitignore_path.exists() {
fs::read_to_string(&gitignore_path).unwrap_or_default()
} else {
String::new()
};
let mut gitignore_additions: Vec<&str> = Vec::new();
for (entry, _label) in &gitignore_entries {
if !gitignore_content.lines().any(|l| l.trim() == *entry) {
gitignore_additions.push(entry);
}
}
if !gitignore_additions.is_empty() {
let mut new_content = gitignore_content.clone();
if !new_content.ends_with('\n') && !new_content.is_empty() {
new_content.push('\n');
}
new_content.push_str("\n# Bot — credenciais e banco de dados (nunca versionar)\n");
for entry in &gitignore_additions {
new_content.push_str(entry);
new_content.push('\n');
}
fs::write(&gitignore_path, &new_content)?;
for entry in &gitignore_additions {
println!("✓ Adicionado ao .gitignore: {entry}");
}
}
// 2. Criar sdd-bot.config.yaml com permissão 0600
let config_path = root.join("sdd-bot.config.yaml");
if config_path.exists() {
println!("◇ sdd-bot.config.yaml já existe — preservado (edite manualmente se necessário).");
} else {
let template = r#"# sdd-bot.config.yaml — NÃO COMMITAR (está no .gitignore)
slack:
bot_token: "xoxb-..." # SLACK_BOT_TOKEN
app_token: "xapp-..." # SLACK_APP_TOKEN
jira:
base_url: "https://SEU-SITE.atlassian.net"
user: "seu@email.com"
api_token: "ATATT3x..."
llm:
provider: "anthropic" # anthropic | openai | ollama
api_key: "sk-ant-..."
db_path: ".sdd/bot/sdd-bot.db"
"#;
fs::write(&config_path, template)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o600))?;
println!("✓ sdd-bot.config.yaml criado com permissão 0600.");
}
#[cfg(not(unix))]
{
println!("✓ sdd-bot.config.yaml criado.");
println!(" ⚠ Windows: configure manualmente as permissões do arquivo (ACL) para restringir acesso.");
}
}
// 3. Executar npm rebuild (garante binário nativo do better-sqlite3)
println!("Executando npm rebuild em bot/...");
let npm_cmd = if cfg!(windows) { "npm.cmd" } else { "npm" };
let rebuild_status = std::process::Command::new(npm_cmd)
.arg("rebuild")
.current_dir(&bot_dir)
.status();
match rebuild_status {
Ok(s) if s.success() => println!("✓ npm rebuild concluído."),
Ok(_) => eprintln!("⚠ npm rebuild retornou erro — verifique dependências nativas."),
Err(e) => eprintln!("⚠ npm não encontrado ou falhou: {e}"),
}
// 4. Instalar dependências via install.js
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");
}
// 5. Verificar sdd no PATH
let sdd_check = if cfg!(windows) {
std::process::Command::new("where").arg("sdd").output()
} else {
std::process::Command::new("which").arg("sdd").output()
};
match sdd_check {
Ok(o) if o.status.success() => {
let path = String::from_utf8_lossy(&o.stdout);
println!("✓ sdd encontrado em: {}", path.trim());
}
_ => {
eprintln!("⚠ sdd não encontrado no PATH — o bot chama `sdd` internamente.");
eprintln!(
" Instale o binário e garanta que está no PATH antes de iniciar o bot."
);
}
}
// 6. Instruções finais
println!();
println!("╔══════════════════════════════════════════════════════════════╗");
println!("║ Bot SDD-Slack instalado! Próximos passos: ║");
println!("╠══════════════════════════════════════════════════════════════╣");
println!("║ 1. Edite sdd-bot.config.yaml com suas credenciais ║");
println!("║ 2. sdd bot start — inicia o bot via pm2 ║");
println!("║ 3. sdd bot status — verifica status ║");
println!("║ Ou: pm2 start bot/ecosystem.config.js ║");
println!("╚══════════════════════════════════════════════════════════════╝");
}
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)
}
/// Verifica disponibilidade do Mermaid CLI de forma multiplataforma.
/// Não usa `which` (inexistente no Windows): tenta executar `mmdc --version`.
#[allow(dead_code)]
fn mmdc_available() -> bool {
std::process::Command::new("mmdc")
.arg("--version")
.output()
.map(|o| o.status.success())
.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/",
".sdd/cache/",
".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_skill_paths(target: ClientTarget) -> Vec<&'static str> {
contract::command_names()
.into_iter()
.filter_map(|command| stage_skill_path(target, command))
.collect()
}
fn stage_skill_files(target: ClientTarget) -> Vec<GeneratedFile> {
contract::command_names()
.into_iter()
.filter_map(|command| {
stage_skill_path(target, command).map(|path| GeneratedFile {
path,
content: render_stage_skill(command, target),
})
})
.collect()
}
fn stage_skill_path(target: ClientTarget, command: &str) -> Option<&'static str> {
match (target, command) {
(ClientTarget::Cli, _) => None,
(ClientTarget::Codex | ClientTarget::Antigravity, "discover") => {
Some(".agents/skills/discover/SKILL.md")
}
(ClientTarget::Codex | ClientTarget::Antigravity, "risk") => {
Some(".agents/skills/risk/SKILL.md")
}
(ClientTarget::Codex | ClientTarget::Antigravity, "idea") => {
Some(".agents/skills/idea/SKILL.md")
}
(ClientTarget::Codex | ClientTarget::Antigravity, "prd") => {
Some(".agents/skills/prd/SKILL.md")
}
(ClientTarget::Codex | ClientTarget::Antigravity, "techspec") => {
Some(".agents/skills/techspec/SKILL.md")
}
(ClientTarget::Codex | ClientTarget::Antigravity, "tasks") => {
Some(".agents/skills/tasks/SKILL.md")
}
(ClientTarget::Codex | ClientTarget::Antigravity, "refinement") => {
Some(".agents/skills/refinement/SKILL.md")
}
(ClientTarget::Codex | ClientTarget::Antigravity, "execution") => {
Some(".agents/skills/execution/SKILL.md")
}
(ClientTarget::Codex | ClientTarget::Antigravity, "adr") => {
Some(".agents/skills/adr/SKILL.md")
}
(ClientTarget::Codex | ClientTarget::Antigravity, "review") => {
Some(".agents/skills/review/SKILL.md")
}
(ClientTarget::Codex | ClientTarget::Antigravity, "memory") => {
Some(".agents/skills/memory/SKILL.md")
}
(ClientTarget::Claude, "discover") => Some(".claude/skills/discover/SKILL.md"),
(ClientTarget::Claude, "risk") => Some(".claude/skills/risk/SKILL.md"),
(ClientTarget::Claude, "idea") => Some(".claude/skills/idea/SKILL.md"),
(ClientTarget::Claude, "prd") => Some(".claude/skills/prd/SKILL.md"),
(ClientTarget::Claude, "techspec") => Some(".claude/skills/techspec/SKILL.md"),
(ClientTarget::Claude, "tasks") => Some(".claude/skills/tasks/SKILL.md"),
(ClientTarget::Claude, "refinement") => Some(".claude/skills/refinement/SKILL.md"),
(ClientTarget::Claude, "execution") => Some(".claude/skills/execution/SKILL.md"),
(ClientTarget::Claude, "adr") => Some(".claude/skills/adr/SKILL.md"),
(ClientTarget::Claude, "review") => Some(".claude/skills/review/SKILL.md"),
(ClientTarget::Claude, "memory") => Some(".claude/skills/memory/SKILL.md"),
(ClientTarget::Cursor, "discover") => Some(".cursor/skills/discover/SKILL.md"),
(ClientTarget::Cursor, "risk") => Some(".cursor/skills/risk/SKILL.md"),
(ClientTarget::Cursor, "idea") => Some(".cursor/skills/idea/SKILL.md"),
(ClientTarget::Cursor, "prd") => Some(".cursor/skills/prd/SKILL.md"),
(ClientTarget::Cursor, "techspec") => Some(".cursor/skills/techspec/SKILL.md"),
(ClientTarget::Cursor, "tasks") => Some(".cursor/skills/tasks/SKILL.md"),
(ClientTarget::Cursor, "refinement") => Some(".cursor/skills/refinement/SKILL.md"),
(ClientTarget::Cursor, "execution") => Some(".cursor/skills/execution/SKILL.md"),
(ClientTarget::Cursor, "adr") => Some(".cursor/skills/adr/SKILL.md"),
(ClientTarget::Cursor, "review") => Some(".cursor/skills/review/SKILL.md"),
(ClientTarget::Cursor, "memory") => Some(".cursor/skills/memory/SKILL.md"),
(ClientTarget::Opencode, "discover") => Some(".opencode/skills/discover/SKILL.md"),
(ClientTarget::Opencode, "risk") => Some(".opencode/skills/risk/SKILL.md"),
(ClientTarget::Opencode, "idea") => Some(".opencode/skills/idea/SKILL.md"),
(ClientTarget::Opencode, "prd") => Some(".opencode/skills/prd/SKILL.md"),
(ClientTarget::Opencode, "techspec") => Some(".opencode/skills/techspec/SKILL.md"),
(ClientTarget::Opencode, "tasks") => Some(".opencode/skills/tasks/SKILL.md"),
(ClientTarget::Opencode, "refinement") => Some(".opencode/skills/refinement/SKILL.md"),
(ClientTarget::Opencode, "execution") => Some(".opencode/skills/execution/SKILL.md"),
(ClientTarget::Opencode, "adr") => Some(".opencode/skills/adr/SKILL.md"),
(ClientTarget::Opencode, "review") => Some(".opencode/skills/review/SKILL.md"),
(ClientTarget::Opencode, "memory") => Some(".opencode/skills/memory/SKILL.md"),
(ClientTarget::Devin, "discover") => Some(".devin/skills/discover/SKILL.md"),
(ClientTarget::Devin, "risk") => Some(".devin/skills/risk/SKILL.md"),
(ClientTarget::Devin, "idea") => Some(".devin/skills/idea/SKILL.md"),
(ClientTarget::Devin, "prd") => Some(".devin/skills/prd/SKILL.md"),
(ClientTarget::Devin, "techspec") => Some(".devin/skills/techspec/SKILL.md"),
(ClientTarget::Devin, "tasks") => Some(".devin/skills/tasks/SKILL.md"),
(ClientTarget::Devin, "refinement") => Some(".devin/skills/refinement/SKILL.md"),
(ClientTarget::Devin, "execution") => Some(".devin/skills/execution/SKILL.md"),
(ClientTarget::Devin, "adr") => Some(".devin/skills/adr/SKILL.md"),
(ClientTarget::Devin, "review") => Some(".devin/skills/review/SKILL.md"),
(ClientTarget::Devin, "memory") => Some(".devin/skills/memory/SKILL.md"),
(ClientTarget::Trae, "discover") => Some(".trae/skills/discover/SKILL.md"),
(ClientTarget::Trae, "risk") => Some(".trae/skills/risk/SKILL.md"),
(ClientTarget::Trae, "idea") => Some(".trae/skills/idea/SKILL.md"),
(ClientTarget::Trae, "prd") => Some(".trae/skills/prd/SKILL.md"),
(ClientTarget::Trae, "techspec") => Some(".trae/skills/techspec/SKILL.md"),
(ClientTarget::Trae, "tasks") => Some(".trae/skills/tasks/SKILL.md"),
(ClientTarget::Trae, "refinement") => Some(".trae/skills/refinement/SKILL.md"),
(ClientTarget::Trae, "execution") => Some(".trae/skills/execution/SKILL.md"),
(ClientTarget::Trae, "adr") => Some(".trae/skills/adr/SKILL.md"),
(ClientTarget::Trae, "review") => Some(".trae/skills/review/SKILL.md"),
(ClientTarget::Trae, "memory") => Some(".trae/skills/memory/SKILL.md"),
(_, other) => panic!("unknown stage skill: {other}"),
}
}
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 atribuídas.
- 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.
- Pendências 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.
- `## Diagramas` opcional quando jornada, decisão, estado ou dependência ficarem mais claros visualmente.
- 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. Quando houver companion visual do `diagram-design`, referencie `assets/diagrams/*.html|*.svg` sem substituir o Mermaid/Excalidraw canônico.
- 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. Quando houver companion visual do `diagram-design`, referencie `assets/diagrams/*.html|*.svg` como derivado revisável.
- 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.
- Pendências.
- Rastreabilidade para Tasks/Refinement.
- Code intelligence/fallback usado.
- Teste ou loop de diagnóstico 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 pós-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 pós-execução.
Entrada:
$ARGUMENTS
Saída obrigatória:
- Rastreabilidade.
- Status.
- Contexto.
- Problema.
- Decisão.
- Alternativas.
- Consequências.
- Impactos/riscos.
- Plano de adoção.
- Plano de reversão.
- Evidências.
- Histórico de revisão.
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.
- Pendências.
- Próximos passos.
- Contexto resumido.
- Links para artefatos em vez de cópia integral.
- Rastreabilidade: artifact store, Idea, PRD, Tech Spec, Tasks, Refinement quando houver, PRs e reviews.
- Decisões técnicas e de produto que continuam válidas, sempre com fonte.
- Padrões de código ou arquitetura observados.
Não inclua cópias integrais de documentos, logs extensos, discussões encerradas sem impacto futuro ou suposições sem fonte. A memória deve ser curta o bastante para caber em contexto futuro e específica o bastante para evitar redescoberta.
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 body = if matches!(surface, ClientCommandSurface::Opencode) {
opencode_stage_command_body(command, spec.body)
} else {
spec.body.to_string()
};
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(stage_adapter_fallback_note());
out.push_str("\n\n");
out.push_str(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(stage_adapter_fallback_note());
out.push_str("\n\n");
if matches!(surface, ClientCommandSurface::Opencode) {
out.push_str("Nesta superfície OpenCode, propague o provider real para o harness com `SDD_PROVIDER=opencode`. Se a sessão estiver usando um modelo específico fora do roteamento, exporte também `SDD_MODEL=<modelo>` e, se aplicável, `SDD_EFFORT=<medium|high|xhigh>`.\n\n");
}
out.push_str(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 stage_adapter_fallback_note() -> &'static str {
"Use a skill ou subagent desta etapa se o client a expuser. Se a skill/subagent não existir ou não carregar, este adapter é o fallback executável: siga as instruções abaixo e delegue estado, persistência e validação ao CLI `sdd`."
}
fn render_stage_skill(command: &str, target: ClientTarget) -> String {
let spec = stage_command_spec(command);
let body = if matches!(target, ClientTarget::Opencode) {
opencode_stage_command_body(command, spec.body)
} else {
spec.body.to_string()
};
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));
if matches!(target, ClientTarget::Devin) {
out.push_str("triggers:\n - user\n - model\n");
}
if matches!(target, ClientTarget::Devin | ClientTarget::Trae) {
if let Some(model) = spec.model {
out.push_str(&format!("model: {model}\n"));
}
}
out.push_str("---\n\n");
out.push_str("# SDD Stage Skill\n\n");
out.push_str("Esta skill executa uma etapa SDD de forma autocontida. Se o client também expuser subagent com o mesmo papel, use-o como apoio; se não expuser, siga esta skill diretamente e delegue estado, persistência e validação ao CLI `sdd`.\n\n");
out.push_str(cli_resolution_note());
out.push_str("\n\n");
if matches!(target, ClientTarget::Opencode) {
out.push_str("Nesta superfície OpenCode, propague o provider real para o harness com `SDD_PROVIDER=opencode`. Se a sessão estiver usando um modelo específico fora do roteamento, exporte também `SDD_MODEL=<modelo>` e, se aplicável, `SDD_EFFORT=<medium|high|xhigh>`.\n\n");
}
out.push_str(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 opencode_stage_command_body(_command: &str, body: &str) -> String {
body.replace(
"`sdd discover $ARGUMENTS`",
"`SDD_PROVIDER=opencode sdd discover $ARGUMENTS`",
)
.replace(
"`sdd discover --json --dry-run`",
"`SDD_PROVIDER=opencode sdd discover --json --dry-run`",
)
.replace(
"`sdd risk \"$ARGUMENTS\"`",
"`SDD_PROVIDER=opencode sdd risk \"$ARGUMENTS\"`",
)
.replace(
"`sdd idea \"$ARGUMENTS\"`",
"`SDD_PROVIDER=opencode sdd idea \"$ARGUMENTS\"`",
)
.replace(
"`sdd prd \"$ARGUMENTS\"`",
"`SDD_PROVIDER=opencode sdd prd \"$ARGUMENTS\"`",
)
.replace(
"`sdd techspec \"$ARGUMENTS\"`",
"`SDD_PROVIDER=opencode sdd techspec \"$ARGUMENTS\"`",
)
.replace(
"`sdd tasks \"$ARGUMENTS\"`",
"`SDD_PROVIDER=opencode sdd tasks \"$ARGUMENTS\"`",
)
.replace(
"`sdd refinement \"$ARGUMENTS\"`",
"`SDD_PROVIDER=opencode sdd refinement \"$ARGUMENTS\"`",
)
.replace(
"`sdd execution \"$ARGUMENTS\"`",
"`SDD_PROVIDER=opencode sdd execution \"$ARGUMENTS\"`",
)
.replace(
"`sdd adr \"$ARGUMENTS\"`",
"`SDD_PROVIDER=opencode sdd adr \"$ARGUMENTS\"`",
)
.replace(
"`sdd review \"$ARGUMENTS\"`",
"`SDD_PROVIDER=opencode sdd review \"$ARGUMENTS\"`",
)
.replace(
"`sdd memory \"$ARGUMENTS\"`",
"`SDD_PROVIDER=opencode sdd memory \"$ARGUMENTS\"`",
)
.replace("`sdd adr`", "`SDD_PROVIDER=opencode sdd adr`")
}
fn render_diagram_command(surface: ClientCommandSurface) -> String {
let body = r#"Use este comando quando precisar gerar, revisar ou anexar diagramas para IDEA, PRD ou Tech Spec.
Entrada:
`$ARGUMENTS`
## Fluxo obrigatório
1. Use Skill: `artifact-diagrams` para decidir o formato canônico mínimo no artefato Markdown.
2. Use Skill: `diagram-design` para gerar companion visual editorial em HTML/SVG quando o pedido exigir leitura mais polida.
3. Preserve Mermaid/Excalidraw no Markdown como contrato versionável; `diagram-design` nunca substitui o diagrama canônico do artefato.
## Gate de estilo
Antes de gerar o primeiro companion visual em um projeto, abra `references/style-guide.md` da skill `diagram-design`.
Se o style guide ainda estiver com a pele default do próprio arquivo e sem customização explícita, pause e pergunte ao usuário:
> "Este é o primeiro diagrama visual deste projeto. O style guide ainda está no default. Quer customizar antes? Opções: (a) onboarding por URL, (b) colar tokens manualmente, (c) seguir com o default por enquanto."
Se o usuário escolher:
- `(a)`: siga `references/onboarding.md`, colete cores e tipografia do site e proponha diff antes de escrever.
- `(b)`: registre os tokens em `style-guide.md` sob uma seção de customização.
- `(c)`: prossiga e registre que o default foi aceito para esta rodada.
## Coleta automática
Quando `$ARGUMENTS` for contexto livre, não desenhe por suposição. Colete o sistema alvo automaticamente:
1. Resolva a orquestração com `sdd resolve-input` ou reuse o `--name` explícito quando ele já vier na entrada.
2. Rode `sdd diagram doctor --name "<nome-da-orquestracao>"` para checar artifact store, contexto e configuração.
3. Localize e leia os artefatos fonte relevantes em `docs/<slug>/`, especialmente `01-idea.md`, `02-prd.md`, `03-techspec.md`, `04-tasks.md` e `traceability-map.yaml`.
4. Confirme nomes reais no código com busca dirigida:
- rotas, handlers e middlewares
- eventos, filas, cron jobs e webhooks
- entidades, tabelas, schemas e contratos
- serviços externos, buckets, caches e bancos
- telas, estados, permissões e pontos de entrada do usuário
5. Para diagramas completos e detalhados, cubra:
- componentes e boundaries
- fluxos principais e assíncronos
- relações entre entidades e persistências
- workflow operacional e checkpoints humanos
## Escolha do formato
- Use Mermaid no Markdown para o resumo revisável em diff.
- Use Excalidraw quando o resultado precisar de canvas editável.
- Use `diagram-design` para o companion HTML/SVG em `docs/<slug-da-orquestracao>/assets/diagrams/`.
- Quando o panorama exceder o budget visual da skill, divida em `overview + detail` em vez de produzir um diagrama ilegível.
## Validação
Antes de concluir, valide que:
- não foi fabricado endpoint, evento, entidade, tabela, estado ou integração;
- cada relação importante está ancorada em arquivo real, artefato real ou comando real;
- o diagrama distingue fluxo síncrono, assíncrono, persistência e integração externa;
- limitações e hipóteses não verificadas foram explicitadas;
- o companion visual continua coerente com o Mermaid/Excalidraw do Markdown.
Quando houver drift entre documentação e código, priorize o código e registre a divergência no artefato.
## Saída esperada
Para geração nova:
1. Atualize ou crie a seção `## Diagramas` no artefato de IDEA, PRD ou Tech Spec.
2. Gere Mermaid/Excalidraw quando útil ao entendimento.
3. Gere companion self-contained em HTML ou SVG com `diagram-design`.
4. Salve em:
```bash
docs/<slug-da-orquestracao>/assets/diagrams/<stage>-<nome>.html
docs/<slug-da-orquestracao>/assets/diagrams/<stage>-<nome>.svg
```
5. Anexe ao artifact store:
```bash
sdd diagram attach --name "<nome-da-orquestracao>" --stage idea|prd|techspec --file <arquivo.html|svg|excalidraw> --title "<título>" --source "<artefato ou contexto>"
```
6. Se houver schema para o artefato, rode `sdd validate-artifact <tipo> <arquivo.md>`.
Para `attach` e `doctor`, apenas delegue ao CLI correto sem regenerar diagramas.
## Fallback provider-neutral
Se o provider, client ou harness não conseguir gerar companion visual:
- mantenha Mermaid/Excalidraw no Markdown;
- registre a lacuna no artefato;
- não bloqueie o fluxo por ausência de HTML/SVG.
## Regra de acessibilidade
Prefira HTML com SVG inline ou SVG puro, porque são legíveis, versionáveis e podem ser revisitados por times técnicos e stakeholders. PNG é derivado opcional, não formato principal."#;
if matches!(
surface,
ClientCommandSurface::Cursor | ClientCommandSurface::DevinWorkflow
) {
return format!(
"# SDD Diagram\n\n{}\n\n{}\n",
cli_resolution_note(),
body.trim()
);
}
let mut out = String::from("---\n");
out.push_str("description: Gera ou anexa diagramas visuais ao artifact store SDD.\n");
out.push_str("argument-hint: \"<contexto | attach|doctor ...>\"\n");
match surface {
ClientCommandSurface::Cursor | ClientCommandSurface::DevinWorkflow => {}
ClientCommandSurface::Opencode => out.push_str("agent: sdd-orchestrator\n"),
ClientCommandSurface::Trae => out.push_str("model: claude-sonnet-4-6\n"),
}
out.push_str("---\n\n");
out.push_str(cli_resolution_note());
out.push_str("\n\n");
out.push_str(body.trim());
out.push('\n');
out
}
fn agentic_sdd_loop_body() -> &'static str {
r#"Use este módulo quando uma demanda vier de texto livre, card, bot, webhook ou agent externo e precisar entrar no loop SDD durável.
Entrada:
$ARGUMENTS
Passos:
1. Resolva o CLI uma vez. Use `sdd` quando disponível; no checkout fonte `sdd-layer`, use `cargo run --bin sdd --`; fora disso, reporte PATH/instalação ausente.
2. Valide o workflow quando houver dúvida sobre instalação:
```bash
sdd workflow validate agentic-sdd-loop --json
```
3. Rode o recipe de entrada:
```bash
sdd workflow run agentic-sdd-loop --input "$ARGUMENTS" --max-iterations 3 --json
```
4. Leia o status persistido:
```bash
sdd workflow status agentic-sdd-loop --json
```
5. Quando o PRD estiver em checkpoint, pare. A aprovação deve vir de decisão humana explícita via `sdd demand approve` ou canal verificado.
6. Depois do gate aprovado, continue com o motor SDD conforme a fronteira autorizada:
```bash
sdd auto run --real
sdd auto run --real --through review
sdd auto run --real --through memory
sdd auto run --real --unattended
```
Regras:
- Artifact store: antes de criar artefatos, garanta `sdd init "<nome-da-orquestracao>"`; persista Markdown em `docs/<slug-da-orquestracao>/` e registre estados com `sdd artifact save`.
- Provider fallback: quando o provider, client ou adapter não suportar execução, use artifact store local, registre o comando necessário e deixe a decisão humana continuar o ciclo.
- Use `--unattended` somente quando a intenção for uma execução 100% automática: ele autoaceita PRD, Tech Spec e Refinement com `channel: automation`, registra `gate_auto_accept` e segue até `memory` quando `--through` não for informado.
- Não trate `--unattended` como aprovação humana e não autoaprove merge, release ou deploy.
- Não execute escrita no workspace sem adapter autorizado.
- Se o provider/client não suportar comando, registre o comando necessário e produza handoff Markdown com caminhos de artefatos.
- Continue o loop somente quando houver nova hipótese, fonte, escopo menor ou evidência nova.
- Pare se repetir o mesmo erro, diff, comando ou busca sem progresso observável.
- Preserve acentos em conteúdo humano PT-BR; use ASCII apenas para slugs, paths, branches, comandos, schemas e identificadores.
Documentação completa: `docs/AGENTIC-SDD-LOOP.md`."#
}
fn claude_agentic_sdd_loop_command() -> String {
format!(
r#"---
description: Inicia o Agentic SDD Loop com planejamento durável e checkpoint humano.
argument-hint: "<demanda | CARD-ID | payload>"
model: claude-sonnet-4-6
---
{}
"#,
agentic_sdd_loop_body()
)
}
fn codex_agentic_sdd_loop_command() -> String {
format!(
r#"---
description: Inicia o Agentic SDD Loop no Codex com planejamento durável e checkpoint humano.
argument-hint: "<demanda | CARD-ID | payload>"
agent: sdd-orchestrator
---
# Agentic SDD Loop
Use este comando no Codex para demandas vindas de texto livre, card, bot, webhook ou agent externo. Se a aplicação não expuser slash commands para `.codex/commands`, trate este arquivo como playbook e execute os comandos pelo terminal do workspace.
{}
"#,
agentic_sdd_loop_body()
)
}
fn devin_agentic_sdd_loop_workflow() -> String {
format!(
r#"# Agentic SDD Loop
{}
"#,
agentic_sdd_loop_body()
)
}
fn opencode_agentic_sdd_loop_command() -> String {
format!(
r#"---
description: Inicia o Agentic SDD Loop com workflow durável
agent: sdd-orchestrator
---
{}
"#,
opencode_agentic_sdd_loop_body()
)
}
fn opencode_agentic_sdd_loop_body() -> String {
agentic_sdd_loop_body()
.replace(
"sdd workflow validate agentic-sdd-loop --json",
"SDD_PROVIDER=opencode sdd workflow validate agentic-sdd-loop --json",
)
.replace(
"sdd workflow run agentic-sdd-loop --input \"$ARGUMENTS\" --max-iterations 3 --json",
"SDD_PROVIDER=opencode sdd workflow run agentic-sdd-loop --input \"$ARGUMENTS\" --max-iterations 3 --json",
)
.replace(
"sdd workflow status agentic-sdd-loop --json",
"SDD_PROVIDER=opencode sdd workflow status agentic-sdd-loop --json",
)
}
fn opencode_verify_changes_command() -> String {
r#"---
description: Verifica mudanças com gates SDD fail-fast
agent: sdd-orchestrator
---
Use este comando para validar mudanças recentes antes de review, commit, merge ou handoff.
Alvo:
$ARGUMENTS
Protocolo fail-fast:
1. Levante o estado local sem alterar arquivos:
```bash
git status --short
git diff --name-only
```
2. Rode os checks determinísticos baratos primeiro:
```bash
sdd ci
```
3. Se houver `<ciclo>` conhecido, rode avaliação e qualidade:
```bash
sdd eval orchestration --name "<ciclo>" --json
sdd quality report --name "<ciclo>" --json
sdd trace summary --orchestration "<ciclo>" --json
```
4. Se o ciclo ainda não estiver identificado, registre essa lacuna e use o diff atual como escopo de review.
5. Pare no primeiro erro crítico, reporte comando, saída essencial, arquivos afetados e correção sugerida.
6. Quando os checks passarem, encaminhe para `/team-review` ou `/review` conforme o risco.
Regras:
- Não marque Review, merge, release ou deploy como aprovados sem checkpoint humano.
- Não substitua `docs/<slug-da-orquestracao>/traceability-map.yaml` por resumo de chat.
- Preserve artefatos em `docs/<slug-da-orquestracao>/` com `sdd artifact save`.
- Se uma ferramenta opcional estiver ausente, registre o fallback usado em vez de inventar evidência.
"#
.to_string()
}
fn opencode_team_review_command() -> String {
r#"---
description: Review paralela/adversarial com lentes SDD
agent: sdd-orchestrator
---
Use este comando para PR, diff ou implementação não trivial.
Alvo:
$ARGUMENTS
Protocolo:
1. Identifique o `<ciclo>`, critérios de aceite e artefatos de origem.
2. Faça preflight read-only:
```bash
git status --short
git diff --name-only
sdd eval orchestration --name "<ciclo>" --json
sdd quality report --name "<ciclo>" --json
```
3. Se OpenCode disponibilizar subagents, rode as lentes independentes em paralelo na mesma mensagem. Se não disponibilizar, use fallback sequencial e rode o checklist no agente atual.
4. Lentes obrigatórias:
- `code-review`: correção, legibilidade, arquitetura e manutenção.
- `security-privacy`: segurança, permissões, secrets e dados sensíveis.
- `test-strategy`: cobertura por critério de aceite e lacunas.
- `architecture-deepening`: boundaries, adapters e seams reais.
- `release-readiness`: risco de merge, release, rollback e rollout.
5. Consolide achados duplicados, severidade, evidência, arquivo/linha quando possível e correção recomendada.
Regras:
- Todos os reviewers operam em read-only.
- Não aprove com achados Críticos ou Altos sem correção ou plano explícito.
- Relacione o veredito aos critérios de aceite e à rastreabilidade.
- Pare no checkpoint humano de Review/merge/deploy.
"#
.to_string()
}
fn opencode_team_execution_command() -> String {
r#"---
description: Execução paralela de tasks independentes com ownership SDD
agent: sdd-orchestrator
---
Use este comando somente quando o backlog/refinement já separou tasks independentes.
Entrada:
$ARGUMENTS
Protocolo:
1. Resolva o ciclo e leia o Context Pack de execução:
```bash
sdd context build --name "<ciclo>" --stage execution --write
```
2. Confirme que cada task tem ownership de arquivos claro e que duas tasks não editam o mesmo arquivo.
3. Classifique risco antes da escrita quando houver migração, auth, pagamento, dados sensíveis, contrato externo, feature flag ou deploy:
```bash
sdd risk "$ARGUMENTS" --name "<ciclo>"
```
4. Se o client suportar subagents, dispare apenas tasks independentes em paralelo. Se não suportar, execute uma task por vez.
5. Cada task deve registrar: arquivos alterados, testes, evidências, decisões e pendências.
6. Ao final, rode:
```bash
sdd eval orchestration --name "<ciclo>" --json
sdd quality report --name "<ciclo>" --json
```
Regras:
- Execution não aprova PRD, Tech Spec, Refinement, Review, merge ou deploy.
- Escrita real no workspace exige adapter autorizado; sem adapter, gere handoff Markdown.
- Preserve mudanças locais e não reverta trabalho externo.
- Salve `06-execution.md` em `docs/<slug-da-orquestracao>/` com `sdd artifact save`.
"#
.to_string()
}
fn opencode_security_audit_command() -> String {
r#"---
description: Auditoria de segurança e privacidade com skill SDD
agent: sdd-orchestrator
---
Audite segurança, privacidade e superfície pública do alvo informado.
Alvo:
$ARGUMENTS
Use a skill `security-privacy` e produza achados com severidade, evidência e remediação.
Checklist mínimo:
- Autenticação e autorização.
- Escalada de privilégio.
- Validação de entrada e saída.
- Secrets e tokens fora de código, logs e artefatos.
- Dados sensíveis minimizados e mascarados.
- Webhooks assinados ou autenticados.
- Erros sem vazamento de informação.
- Testes negativos para permissões e inputs maliciosos.
Comandos de apoio quando houver ciclo:
```bash
sdd risk "$ARGUMENTS" --name "<ciclo>"
sdd quality report --name "<ciclo>" --json
```
Registre lacunas de ferramenta opcional como fallback. Não gere exploit operacional.
"#
.to_string()
}
fn opencode_test_design_command() -> String {
r#"---
description: Desenha estratégia de testes por critérios de aceite SDD
agent: sdd-orchestrator
---
Planeje testes para o alvo informado usando a skill `test-strategy`.
Alvo:
$ARGUMENTS
Saída esperada:
- Critérios de aceite cobertos por teste.
- Testes unitários, integração, contrato, E2E ou regressão necessários.
- Comandos a executar.
- Evidência esperada.
- Riscos não cobertos por automação.
Quando houver artefatos SDD, leia o contexto:
```bash
sdd context build --name "<ciclo>" --stage execution --write
sdd eval orchestration --name "<ciclo>" --json
```
Regras:
- Para bugfix, inclua teste de regressão quando houver seam viável.
- Para segurança/autorização, inclua teste negativo.
- Não use "sem testes" sem justificar risco baixo e validação manual objetiva.
"#
.to_string()
}
fn opencode_fast_lane_command() -> String {
r#"---
description: Fast lane SDD para mudanças pequenas e baixo risco
agent: sdd-orchestrator
---
Use esta fast lane para correções pequenas, reversíveis e de baixo risco. Ela adapta a ideia de iteração rápida sem pular contrato SDD.
Entrada:
$ARGUMENTS
Protocolo obrigatório:
1. Classifique risco antes de editar:
```bash
sdd risk "$ARGUMENTS" --name "<ciclo>"
```
2. Continue somente se a mudança for baixo risco, sem migração, auth, pagamento, dado sensível, contrato externo, feature flag, release ou deploy.
3. Faça a menor alteração viável, preservando padrões existentes e mudanças locais.
4. Rode verificação focada:
```bash
sdd ci
sdd eval orchestration --name "<ciclo>" --json
sdd quality report --name "<ciclo>" --json
```
5. Registre evidência em `docs/<slug-da-orquestracao>/` quando a mudança pertencer a um ciclo SDD.
Regras:
- Não autoaprove nenhum checkpoint humano.
- Se o risco subir, saia da fast lane e volte ao fluxo SDD normal.
- Não use este comando para arquitetura, segurança crítica, refactor amplo ou execução com arquivos compartilhados entre tasks.
"#
.to_string()
}
fn opencode_selected_skill_files() -> Vec<GeneratedFile> {
vec![
GeneratedFile {
path: ".opencode/skills/code-review/SKILL.md",
content: include_str!("../.agents/skills/code-review/SKILL.md").to_string(),
},
GeneratedFile {
path: ".opencode/skills/security-privacy/SKILL.md",
content: include_str!("../.agents/skills/security-privacy/SKILL.md").to_string(),
},
GeneratedFile {
path: ".opencode/skills/test-strategy/SKILL.md",
content: include_str!("../.agents/skills/test-strategy/SKILL.md").to_string(),
},
GeneratedFile {
path: ".opencode/skills/architecture-deepening/SKILL.md",
content: include_str!("../.agents/skills/architecture-deepening/SKILL.md").to_string(),
},
GeneratedFile {
path: ".opencode/skills/execution-discipline/SKILL.md",
content: include_str!("../.agents/skills/execution-discipline/SKILL.md").to_string(),
},
GeneratedFile {
path: ".opencode/skills/release-readiness/SKILL.md",
content: include_str!("../.agents/skills/release-readiness/SKILL.md").to_string(),
},
]
}
fn opencode_plugin_files() -> Vec<GeneratedFile> {
vec![
GeneratedFile {
path: ".opencode/plugins/sdd-security-scan.ts",
content: opencode_security_scan_plugin(),
},
GeneratedFile {
path: ".opencode/plugins/sdd-verification.ts",
content: opencode_verification_plugin(),
},
GeneratedFile {
path: ".opencode/plugins/sdd-parallel-guard.ts",
content: opencode_parallel_guard_plugin(),
},
]
}
fn opencode_security_scan_plugin() -> String {
r#"/**
* SDD Security Scan Plugin
*
* Hook opt-in para OpenCode: bloqueia edições diretas em arquivos sensíveis.
* O contrato SDD continua no CLI; este plugin só reduz risco operacional.
*/
const SENSITIVE_FILES: RegExp[] = [
/(^|\/)\.env$/,
/(^|\/)\.env\./,
/credentials/i,
/secrets/i,
/private[_-]?key/i,
/\.pem$/,
/\.key$/,
/id_rsa/,
/id_ed25519/,
]
function isSensitiveFile(filePath: string): boolean {
return SENSITIVE_FILES.some((pattern) => pattern.test(filePath))
}
export const SddSecurityScanPlugin = async () => {
return {
"tool.execute.before": async (input: any) => {
if (!["write", "edit", "patch"].includes(input.tool)) return
const filePath =
input.args?.file_path || input.args?.filePath || input.args?.path || ""
if (filePath && isSensitiveFile(filePath)) {
throw new Error(
`SDD SECURITY BLOCK: edição direta de arquivo sensível bloqueada (${filePath}). Use fluxo manual seguro e registre evidência.`
)
}
},
}
}
export default SddSecurityScanPlugin
"#
.to_string()
}
fn opencode_verification_plugin() -> String {
r#"/**
* SDD Verification Plugin
*
* Hook opt-in para OpenCode: sugere verificação SDD depois de mudanças relevantes.
* Não executa formatador nem altera arquivos automaticamente.
*/
const editedFiles = new Set<string>()
let lastSuggestion = 0
export const SddVerificationPlugin = async () => {
return {
event: async ({ event }: any) => {
if (event.type !== "file.edited") return
const filePath = event.path || ""
if (filePath && !filePath.includes("node_modules")) {
editedFiles.add(filePath)
}
const now = Date.now()
if (editedFiles.size >= 3 && now - lastSuggestion > 60000) {
lastSuggestion = now
console.log(
`[sdd-verification] ${editedFiles.size} arquivos editados. Rode /verify-changes ou sdd ci antes do handoff.`
)
editedFiles.clear()
}
},
}
}
export default SddVerificationPlugin
"#
.to_string()
}
fn opencode_parallel_guard_plugin() -> String {
r#"/**
* SDD Parallel Guard Plugin
*
* Hook opt-in para OpenCode: educa sobre paralelização real de tasks independentes.
*/
let recentTaskCalls: number[] = []
export const SddParallelGuardPlugin = async () => {
return {
"tool.execute.before": async (input: any) => {
if (input.tool !== "task" && input.tool !== "Task") return
const now = Date.now()
recentTaskCalls = recentTaskCalls.filter((timestamp) => now - timestamp < 5000)
recentTaskCalls.push(now)
if (recentTaskCalls.length === 2) {
console.log(
"[sdd-parallel-guard] Tasks independentes devem ser disparadas juntas e depois sintetizadas pelo lead SDD."
)
}
},
}
}
export default SddParallelGuardPlugin
"#
.to_string()
}
fn trae_agentic_sdd_loop_command() -> String {
format!(
r#"---
description: Inicia o Agentic SDD Loop com planejamento durável e checkpoint humano.
argument-hint: "<demanda | CARD-ID | payload>"
model: claude-sonnet-4-6
---
{}
"#,
agentic_sdd_loop_body()
)
}
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, perguntas em aberto e `Diagramas` opcional quando o visual melhorar entendimento.
- `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`; companion `diagram-design` entra como referência `assets/diagrams/*.html|*.svg`.
- `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`; companion `diagram-design` entra como derivado visual.
- `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/agents/sdd-orchestrator/AGENT.md",
content: runtime::agents::sdd_orchestrator_agent_markdown(),
},
GeneratedFile {
path: ".codex/commands/agentic-sdd-loop.md",
content: codex_agentic_sdd_loop_command(),
},
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/`, `.codex/commands/agentic-sdd-loop.md` e `.agents/skills/`. Para demandas de agent/card/webhook, prefira `/agentic-sdd-loop` quando disponível ou rode `sdd workflow run agentic-sdd-loop --input \"<demanda>\" --max-iterations 3 --json` via shell."),
}]
.into_iter()
.chain(stage_skill_files(ClientTarget::Codex))
.collect(),
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/commands/agentic-sdd-loop.md",
content: claude_agentic_sdd_loop_command(),
},
GeneratedFile {
path: ".claude/commands/diagram.md",
content: render_diagram_command(ClientCommandSurface::Trae),
},
GeneratedFile {
path: ".claude/skills/orchestration/SKILL.md",
content: claude_orchestration_skill(),
},
GeneratedFile {
path: "clients/claude-code.md",
content: client_doc("Claude Code", "Use `.claude/commands/agentic-sdd-loop.md` para demandas de agent/card/webhook e `.claude/commands/orchestration.md` para fluxo direto. A skill `.claude/skills/orchestration/SKILL.md` mantém o contrato portável; comandos antigos em `.claude/commands/orchestrator.md` devem delegar ao canônico."),
},
]
.into_iter()
.chain(stage_skill_files(ClientTarget::Claude))
.collect(),
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/commands/diagram.md",
content: render_diagram_command(ClientCommandSurface::Cursor),
},
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_skill_files(ClientTarget::Cursor))
.chain(stage_command_files(ClientCommandSurface::Cursor))
.collect(),
ClientTarget::Opencode => vec![
GeneratedFile {
path: ".opencode/model-routing.yaml",
content: opencode_model_routing(),
},
GeneratedFile {
path: ".opencode/commands/sdd.md",
content: opencode_sdd_command(),
},
GeneratedFile {
path: ".opencode/commands/orchestration.md",
content: opencode_orchestration_command(),
},
GeneratedFile {
path: ".opencode/commands/agentic-sdd-loop.md",
content: opencode_agentic_sdd_loop_command(),
},
GeneratedFile {
path: ".opencode/commands/diagram.md",
content: render_diagram_command(ClientCommandSurface::Opencode),
},
GeneratedFile {
path: ".opencode/commands/verify-changes.md",
content: opencode_verify_changes_command(),
},
GeneratedFile {
path: ".opencode/commands/team-review.md",
content: opencode_team_review_command(),
},
GeneratedFile {
path: ".opencode/commands/team-execution.md",
content: opencode_team_execution_command(),
},
GeneratedFile {
path: ".opencode/commands/security-audit.md",
content: opencode_security_audit_command(),
},
GeneratedFile {
path: ".opencode/commands/test-design.md",
content: opencode_test_design_command(),
},
GeneratedFile {
path: ".opencode/commands/fast-lane.md",
content: opencode_fast_lane_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, `.opencode/agents/` para agente especializado, `.opencode/skills/` para rubricas SDD selecionadas e `.opencode/plugins/` para guardrails opt-in de sessão. Comandos OpenCode devem propagar `SDD_PROVIDER=opencode`; use `SDD_MODEL=<modelo>` quando a sessão selecionar um modelo específico fora de `.opencode/model-routing.yaml`."),
},
]
.into_iter()
.chain(opencode_selected_skill_files())
.chain(stage_skill_files(ClientTarget::Opencode))
.chain(opencode_plugin_files())
.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: ".devin/workflows/agentic-sdd-loop.md",
content: devin_agentic_sdd_loop_workflow(),
},
GeneratedFile {
path: ".devin/workflows/diagram.md",
content: render_diagram_command(ClientCommandSurface::DevinWorkflow),
},
GeneratedFile {
path: ".agents/agents/sdd-orchestrator/AGENT.md",
content: runtime::agents::sdd_orchestrator_agent_markdown(),
},
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(stage_skill_files(ClientTarget::Devin))
.chain(stage_command_files(ClientCommandSurface::DevinWorkflow))
.collect(),
ClientTarget::Antigravity => vec![
GeneratedFile {
path: ".agents/agents/sdd-orchestrator/AGENT.md",
content: runtime::agents::sdd_orchestrator_agent_markdown(),
},
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/`."),
},
]
.into_iter()
.chain(stage_skill_files(ClientTarget::Antigravity))
.collect(),
ClientTarget::Trae => vec![GeneratedFile {
path: ".trae/commands/orchestration.md",
content: trae_orchestration_command(),
},
GeneratedFile {
path: ".trae/commands/agentic-sdd-loop.md",
content: trae_agentic_sdd_loop_command(),
},
GeneratedFile {
path: ".trae/commands/diagram.md",
content: render_diagram_command(ClientCommandSurface::Trae),
},
GeneratedFile {
path: ".trae/rules/00-sdd-principles.md",
content: trae_principles_rule(),
},
GeneratedFile {
path: ".trae/rules/10-traceability.md",
content: trae_traceability_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 superfícies nativas. O CLI `sdd orchestration` permanece o mesmo harness."),
}]
.into_iter()
.chain(stage_skill_files(ClientTarget::Trae))
.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 opencode_model_routing() -> String {
r#"default:
model: opencode-go/deepseek-v4-pro
effort: xhigh
stages:
project-discovery: { model: opencode-go/kimi-k2.7, effort: xhigh }
idea: { model: opencode-go/deepseek-v4-flash, effort: medium }
prd: { model: opencode-go/deepseek-v4-pro, effort: high }
techspec: { model: opencode-go/kimi-k2.7, effort: xhigh }
tasks: { model: opencode-go/qwen3.7-plus, effort: high }
refinement: { model: opencode-go/qwen3.7-plus, effort: high }
execution: { model: opencode-go/kimi-k2.7, effort: high }
review: { model: opencode-go/glm-5.1, effort: xhigh }
memory: { model: opencode-go/minimax-m3, effort: medium }
"#
.to_string()
}
fn devin_config() -> String {
r#"{
"read_config_from": {
"cursor": true,
"windsurf": true,
"claude": true
}
}
"#
.to_string()
}
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 entrada agentic durável, rode `sdd workflow run agentic-sdd-loop --input "<demanda>" --max-iterations 3 --json`.
- 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"
```
O core SDD governa o contrato SDLC; Rig, Flue, LangGraph, Agno e runtimes futuros são engines/adapters opcionais. Se estiverem ausentes, continue com Markdown, comandos `sdd`, artifact store local e validações determinísticas.
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.
Para demandas vindas de card, bot, webhook ou agent externo, prefira o recipe durável:
```bash
sdd workflow run agentic-sdd-loop --input "$ARGUMENTS" --max-iterations 3 --json
```
Depois consulte `docs/AGENTIC-SDD-LOOP.md` para continuar por gates, execution/review/memory supervisionados e política de parada sem progresso.
`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 entrada agentic durável, rode `sdd workflow run agentic-sdd-loop --input "<demanda>" --max-iterations 3 --json`.
- 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"
```
O core SDD governa o contrato SDLC; Rig, Flue, LangGraph, Agno e runtimes futuros são engines/adapters opcionais. Se estiverem ausentes, continue com Markdown, comandos `sdd`, artifact store local e validações determinísticas.
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.
Para demandas vindas de card, bot, webhook ou agent externo, prefira o recipe durável:
```bash
sdd workflow run agentic-sdd-loop --input "$ARGUMENTS" --max-iterations 3 --json
```
Depois consulte `docs/AGENTIC-SDD-LOOP.md` para continuar por gates, execution/review/memory supervisionados e política de parada sem progresso.
`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.
- Para demandas vindas de card, bot, webhook ou agent externo, rode `.devin/workflows/agentic-sdd-loop.md` ou `sdd workflow run agentic-sdd-loop --input "<demanda>" --max-iterations 3 --json`.
- 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`.
- Preparação: `sdd discover`, `sdd risk`, `sdd doctor` e `sdd clients doctor`.
- Fluxo completo: `sdd orchestration "<ideia>"`.
- Loop agentic: `sdd workflow run agentic-sdd-loop --input "<demanda>" --max-iterations 3 --json`.
- 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!(
r#"# SDD client - {name}
{body}
## Nome canônico
- Use `orchestration` para comandos e skills.
- Trate `orchestrator` como alias legado e apenas como papel humano.
## Resolução do CLI
- Faça uma checagem curta com `command -v sdd`. Se retornar um path, use `sdd ...`.
- 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.
- Em shells de editor, confirme `echo "$PATH"`; se faltar `$HOME/.cargo/bin`, configure `export PATH="$HOME/.cargo/bin:$PATH"` no ambiente do cliente.
- 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.
## Provider e login
- Rode `sdd providers doctor` para validar provider/modelo/credenciais antes de iniciar.
- Quando um método estiver deslogado, use o `login_command` exibido, por exemplo `agy`, `codex login`, `claude auth login`, `devin auth login`, `trae-cli show-config`, `opencode providers login` ou `cursor agent login`.
- Nunca copie tokens para prompts ou artefatos; use nomes de env vars e deixe o CLI redigir eventos.
## Avaliação e observabilidade
- Rode `sdd eval stage --name "<ciclo>" --stage <stage> --json`, `sdd eval orchestration --name "<ciclo>" --json` e `sdd quality report --name "<ciclo>" --json` antes de avançar execução, review ou memória.
- Use `sdd trace summary --orchestration "<ciclo>" --json` para auditar eventos e evidências.
- Eventos registram `agent`, `selected_model`, `observed_model` quando o provider reporta metadata confiável e `confidence`.
- Não trate `selected_model` como modelo observado. Quando o provider só retorna texto, deixe `observed_model` não reportado.
- Quando o adapter/editor tiver contagem real de uso, chame `sdd <stage>` ou `sdd artifact save` propagando `SDD_USAGE_INPUT_TOKENS`, `SDD_USAGE_CACHED_INPUT_TOKENS`, `SDD_USAGE_OUTPUT_TOKENS`, `SDD_USAGE_REASONING_OUTPUT_TOKENS` e `SDD_USAGE_TOTAL_TOKENS`; o artefato grava `Tokens source: provider-reported`. Para tempo real de geração, propague `SDD_GENERATION_STARTED_AT`, `SDD_GENERATION_EXECUTION_STARTED_AT`, `SDD_GENERATION_FINISHED_AT`, `SDD_GENERATION_DURATION_MS`, `SDD_GENERATION_REASONING_DURATION_MS` e `SDD_GENERATION_EXECUTION_DURATION_MS`; o artefato grava `Tempo source: provider-reported`. Sem esses envs, o CLI grava estimativa local de tokens e tempo local do CLI com source explícito.
- Execução real com escrita no workspace exige adapter autorizado. Codex, Claude Code, opencode, Cursor Agent, Devin, Trae e Antigravity têm adapters diretos; provider desconhecido deve gerar handoff ou falhar antes de mutar arquivos.
## Atualização de pacote
- Em projeto já instalado, rode `sdd update` para refletir mudanças do pacote.
- `sdd upgrade` é alias de `sdd update`.
- O update preserva `sdd.config.yaml`, `docs/<orquestracao>/` e arquivos extras de skills/rules locais por padrão.
## Skills e workflows
- Valide o catálogo híbrido com `sdd skills doctor --strict`.
- Revise sync sem escrever com `sdd skills sync --targets codex,claude,cursor,devin,opencode,trae,antigravity --dry-run`.
- Valide workflows com `sdd workflow validate standard-sdd --json` e `sdd workflow validate agentic-sdd-loop --json`.
- Execute recipes determinísticas com `sdd workflow run <id> --input "<texto>" --max-iterations N --json`.
- Para demandas vindas de agent, card, bot ou webhook, use `sdd workflow run agentic-sdd-loop --input "<demanda>" --max-iterations 3 --json`.
- Consulte `docs/SKILLS.md`, `docs/WORKFLOWS.md` e `docs/AGENTIC-SDD-LOOP.md`.
## Fluxo mínimo
1. Defina um `<ciclo>` estável para a orquestração.
2. Rode `sdd init "<ciclo>"` antes de criar artefatos de etapa; isso cria `docs/<slug-do-ciclo>/` e `traceability-map.yaml`.
3. `sdd discover --name "<ciclo>"`
4. `sdd risk "<feature>" --name "<ciclo>"`
5. Para entrada agentic durável, rode `sdd workflow run agentic-sdd-loop --input "<demanda>" --max-iterations 3 --json`; para fluxo direto, rode `sdd orchestration "<ideia>" --name "<ciclo>"`.
6. Salvar cada etapa em `docs/<slug-do-ciclo>/` com `sdd artifact save` (`recorded` para etapas informativas, `approved` só depois de checkpoint humano).
"#
)
}
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}` |
| Read complete SDD context bundle | `sdd_context_bundle` or `sdd://context-bundle/{orchestration}/{stage}` |
| Check client surfaces | `sdd_clients_doctor` |
| Check project status | `sdd_project_status` |
| Search SDD context | `sdd_search` |
| Audit quality gates | `sdd eval stage`, `sdd eval orchestration`, `sdd quality report` |
The canonical trace store remains local JSONL in `.sdd/events.jsonl`, `.sdd/subagents.jsonl`, `.sdd/task-gates.jsonl`, `.sdd/runs.jsonl`, `.sdd/evaluations.jsonl`, and `.sdd/execution-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"
```
O core SDD governa o contrato SDLC; Rig, Flue, LangGraph, Agno e runtimes futuros são engines/adapters opcionais. Se estiverem ausentes, continue com Markdown, comandos `sdd`, artifact store local e validações determinísticas.
Depois conduza o fluxo SDD completo usando subagents, skills e checkpoints humanos seguindo as regras completas do `orchestrator`. `orchestrator` é o documento canônico de regras; `orchestration` é o ponto de entrada — ambos seguem o mesmo fluxo e as mesmas regras (card-id, worktree, Jira, commit/PR).
Para demandas vindas de card, bot, webhook ou agent externo, use o recipe durável:
```bash
sdd workflow run agentic-sdd-loop --input "$ARGUMENTS" --max-iterations 3 --json
```
Consulte `docs/AGENTIC-SDD-LOOP.md` antes de continuar por pós-planejamento supervisionado.
"#
.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.
## Agentic SDD Loop
For demand intake from a card, bot, webhook or external agent, prefer the durable loop recipe:
```bash
sdd workflow run agentic-sdd-loop --input "$ARGUMENTS" --max-iterations 3 --json
```
It resolves input, runs the planning loop through `standard-sdd`, persists workflow status and pauses at the PRD checkpoint. After human approval, continue with `sdd auto run --real`, `sdd auto run --real --through review` or `sdd auto run --real --through memory` according to the authorized boundary. For a fully automatic run without human gates, use `sdd auto run --real --unattended`; it records PRD, Tech Spec and Refinement acceptances as `channel: automation`, defaults to `--through memory`, and must not be represented as human approval.
Stop instead of retrying when the same error, diff, command or search repeats without a new hypothesis, source, narrower scope or evidence. Full guide: `docs/AGENTIC-SDD-LOOP.md`.
## Runtime-agnostic contract
The SDD core owns the SDLC contract: stages, artifact store, checkpoints, validation, traceability, provider registry and deterministic CLI behavior. Runtime engines such as Rig, Flue, LangGraph, Agno or future frameworks are optional adapters for execution, routing, tools or composition. They must not replace `.agents` as the canonical contract, `docs/<slug>/traceability-map.yaml` as the local source of truth, or MCP/trace as read-only observability. If a runtime adapter is unavailable, continue with Markdown, `sdd` CLI commands and local artifact persistence.
## Quality Gates And Observability
Before autonomous execution, review or memory, run:
```bash
sdd eval stage --name "<orchestration>" --stage <stage> --json
sdd eval orchestration --name "<orchestration>" --json
sdd quality report --name "<orchestration>" --json
sdd trace summary --orchestration "<orchestration>" --json
```
Evaluations validate required sections, traceability, diagrams, `Prompts Agent`, Context Pack freshness and `traceability-map.yaml` state. Critical findings block automatic advancement. `sdd quality report` records optional tools and fallbacks in `.sdd/evaluations.jsonl`.
Events must preserve `agent`, `selected_model`, `observed_model` when the provider/CLI reports trustworthy metadata and `confidence`. Do not infer `observed_model` from configuration.
When an adapter/editor has real token usage, call `sdd <stage>` or `sdd artifact save` with `SDD_USAGE_INPUT_TOKENS`, `SDD_USAGE_CACHED_INPUT_TOKENS`, `SDD_USAGE_OUTPUT_TOKENS`, `SDD_USAGE_REASONING_OUTPUT_TOKENS` and `SDD_USAGE_TOTAL_TOKENS`; artifacts record `Tokens source: provider-reported`. For real generation timing, pass `SDD_GENERATION_STARTED_AT`, `SDD_GENERATION_EXECUTION_STARTED_AT`, `SDD_GENERATION_FINISHED_AT`, `SDD_GENERATION_DURATION_MS`, `SDD_GENERATION_REASONING_DURATION_MS` and `SDD_GENERATION_EXECUTION_DURATION_MS`; artifacts record `Tempo source: provider-reported`. Without those env vars, the CLI records a local token estimate and local CLI timing with explicit source.
After Execution, create or update ADRs with `sdd adr` when the implementation introduced, confirmed or changed an architectural decision.
IDEA may include `## Diagramas` when the visual improves understanding. PRD and Tech Spec artifacts must include `## Diagramas` with useful Mermaid/Excalidraw references, `assets/diagrams/*.html|*.svg` visual companions, 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
```
Workspace execution must use an authorized adapter. In this version, providers without write support must fail explicitly before mutating files.
"#
.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"
```
O core SDD governa o contrato SDLC; Rig, Flue, LangGraph, Agno e runtimes futuros são engines/adapters opcionais. Se estiverem ausentes, continue com Markdown, comandos `sdd`, artifact store local e validações determinísticas.
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.
Para demandas vindas de card, bot, webhook ou agent externo, prefira:
```bash
sdd workflow run agentic-sdd-loop --input "$ARGUMENTS" --max-iterations 3 --json
```
Use `docs/AGENTIC-SDD-LOOP.md` como guia de gates, pós-planejamento supervisionado e política de parada sem progresso.
`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"
```
For demand intake from card, bot, webhook or external agent, prefer:
```bash
sdd workflow run agentic-sdd-loop --input "$ARGUMENTS" --max-iterations 3 --json
```
Read `docs/AGENTIC-SDD-LOOP.md` before moving into supervised execution/review/memory. For a fully automatic run without human gates, use `sdd auto run --real --unattended`; it records PRD, Tech Spec and Refinement acceptances as `channel: automation`, defaults to `--through memory`, and must not be represented as human approval.
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.
## Runtime-agnostic contract
The SDD core owns the SDLC contract: stages, artifact store, checkpoints, validation, traceability, provider registry and deterministic CLI behavior. Runtime engines such as Rig, Flue, LangGraph, Agno or future frameworks are optional adapters for execution, routing, tools or composition. They must not replace `.agents` as the canonical contract, `docs/<slug>/traceability-map.yaml` as the local source of truth, or MCP/trace as read-only observability. If a runtime adapter is unavailable, continue with Markdown, `sdd` CLI commands and local artifact persistence.
## Quality Gates And Observability
Before autonomous execution, review or memory, run:
```bash
sdd eval stage --name "<orchestration>" --stage <stage> --json
sdd eval orchestration --name "<orchestration>" --json
sdd quality report --name "<orchestration>" --json
sdd trace summary --orchestration "<orchestration>" --json
```
Evaluations validate required sections, traceability, diagrams, `Prompts Agent`, Context Pack freshness and `traceability-map.yaml` state. Critical findings block automatic advancement. `sdd quality report` records optional tools and fallbacks in `.sdd/evaluations.jsonl`.
Events must preserve `agent`, `selected_model`, `observed_model` when the provider/CLI reports trustworthy metadata and `confidence`. Do not infer `observed_model` from configuration.
When an adapter/editor has real token usage, call `sdd <stage>` or `sdd artifact save` with `SDD_USAGE_INPUT_TOKENS`, `SDD_USAGE_CACHED_INPUT_TOKENS`, `SDD_USAGE_OUTPUT_TOKENS`, `SDD_USAGE_REASONING_OUTPUT_TOKENS` and `SDD_USAGE_TOTAL_TOKENS`; artifacts record `Tokens source: provider-reported`. For real generation timing, pass `SDD_GENERATION_STARTED_AT`, `SDD_GENERATION_EXECUTION_STARTED_AT`, `SDD_GENERATION_FINISHED_AT`, `SDD_GENERATION_DURATION_MS`, `SDD_GENERATION_REASONING_DURATION_MS` and `SDD_GENERATION_EXECUTION_DURATION_MS`; artifacts record `Tempo source: provider-reported`. Without those env vars, the CLI records a local token estimate and local CLI timing with explicit source.
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.
Workspace execution must use an authorized adapter. In this version, providers without write support must fail explicitly before mutating files.
"#
.to_string())
}
fn trae_principles_rule() -> String {
r#"# Princípios 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 pós-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_traceability_rule() -> String {
r#"# Rastreabilidade SDD
- Tudo aponta de volta para a feature: Idea, PRD, Tech Spec, Tasks, Refinement, Execution, ADR, Review e Memory.
- Toda orquestração mantém `docs/<slug-da-orquestracao>/traceability-map.yaml` como mapa local de artefatos, estados e links externos.
- Cada artefato gerado deve citar o artefato de origem da etapa anterior.
- Quando Jira, Confluence, Git provider ou outra integração não estiver completa, o arquivo local em `docs/<slug-da-orquestracao>/` é a fonte canônica.
- Não duplique conteúdo entre sistemas; preserve ponteiros para documentos, cards, PRs, CI e evidências.
- PR, Review e Memory devem apontar para critérios de aceite, testes executados e evidências verificadas.
"#
.to_string()
}
fn trae_output_conventions_rule() -> String {
append_stage_output_catalog(r#"# Convenções de saída SDD
- Responda em português, 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 explícito.
- 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.
- Preparação: `sdd discover`, `sdd risk`, `sdd doctor`.
- Fluxo completo: `sdd orchestration "<ideia>"`.
- Loop agentic: `sdd workflow run agentic-sdd-loop --input "<demanda>" --max-iterations 3 --json`.
- ADR: após Execution, rode `sdd adr` quando houver decisão arquitetural criada, confirmada ou alterada.
- Persistência: 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 já tiver `.sdd/` instalado e precisar receber mudanças do pacote.
- Para entrada agentic durável, use `SDD_PROVIDER=opencode sdd workflow run agentic-sdd-loop --input "$ARGUMENTS" --max-iterations 3 --json`.
- 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_PROVIDER=opencode 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_PROVIDER=opencode sdd orchestration "$ARGUMENTS"
```
O core SDD governa o contrato SDLC; Rig, Flue, LangGraph, Agno e runtimes futuros são engines/adapters opcionais. Se estiverem ausentes, continue com Markdown, comandos `sdd`, artifact store local e validações determinísticas.
Em seguida conduza Project Discovery, Risk Classification, Idea, PRD, Tech Spec, Tasks, Refinement, Execution, ADR, Review e Memory, salvando checkpoints com `sdd artifact save`.
Para demandas vindas de card, bot, webhook ou agent externo, prefira:
```bash
SDD_PROVIDER=opencode sdd workflow run agentic-sdd-loop --input "$ARGUMENTS" --max-iterations 3 --json
```
Use `docs/AGENTIC-SDD-LOOP.md` como guia de gates, pós-planejamento supervisionado e política de parada sem progresso.
"#
.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.
Para demandas vindas de card, bot, webhook ou agent externo, inicie pelo recipe:
```bash
SDD_PROVIDER=opencode sdd workflow run agentic-sdd-loop --input "$ARGUMENTS" --max-iterations 3 --json
```
Leia `docs/AGENTIC-SDD-LOOP.md` antes de avançar para execution/review/memory supervisionados.
"#
.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`.
- Preparação: `sdd discover`, `sdd risk`, `sdd doctor`.
- Fluxo completo: `sdd orchestration "<ideia>"`.
- Loop agentic: `sdd workflow run agentic-sdd-loop --input "<demanda>" --max-iterations 3 --json`.
- 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.
- Persistência local: `docs/<slug-da-orquestracao>/`.
- Skills compartilhadas: `.agents/skills/`.
- Provider fallback: quando o provider ou client não suportar execução, use artifact store local e registre o comando necessário para o harness.
- Guia completo: `docs/AGENTIC-SDD-LOOP.md`.
"#
.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 build_context_materialization_plan(
root: &Path,
name: &str,
write: bool,
dry_run: bool,
local_agents_opt_in: bool,
max_boundaries_override: Option<usize>,
) -> Result<ContextMaterializationPlan> {
let config = load_sdd_config(root)?;
let materialization = &config.intelligence.context_materialization;
let max_boundaries = max_boundaries_override
.unwrap_or(materialization.max_boundaries)
.max(1);
let local_agents_enabled = materialization.local_agents || local_agents_opt_in;
let agent_context_policy =
runtime::agent_context::policy(materialization.local_agents, local_agents_opt_in);
let slug = artifact_slug(name);
let artifact_path = format!("docs/{slug}/00-context-recommendations.md");
let mut conflicts = Vec::new();
let materialization_disabled = !config.intelligence.enabled || !materialization.enabled;
if materialization_disabled {
conflicts.push("context materialization disabled in sdd.config.yaml".to_string());
}
let discovery = discover_project(root);
let context_profiles =
detect_context_profiles(root, &discovery, materialization, max_boundaries);
let local_agent_boundaries = if local_agents_enabled {
runtime::agent_context::filter_materializable_profiles(&context_profiles, max_boundaries)
} else {
Vec::new()
};
let boundaries = context_profiles.clone();
let file_budget =
runtime::agent_context::file_budget(max_boundaries, local_agent_boundaries.len());
let (patterns, learning_conflicts) = context_materialization_patterns(root, &discovery)?;
conflicts.extend(learning_conflicts);
conflicts.extend(context_decision_conflicts(root, name)?);
let mut plan = ContextMaterializationPlan {
orchestration: name.to_string(),
slug,
mode: materialization.mode.clone(),
write,
dry_run,
artifact_path: artifact_path.clone(),
agent_context_policy,
context_profiles,
file_budget,
boundaries,
local_agent_boundaries,
patterns,
actions: Vec::new(),
conflicts,
};
if materialization_disabled {
return Ok(plan);
}
if context_materialization_target_enabled(materialization, "agents") {
plan.actions.push(context_block_action(
root,
"AGENTS.md",
"root-agents",
&render_root_agents_context(&plan),
"contexto portátil raiz",
None,
));
}
if context_materialization_target_enabled(materialization, "claude") {
plan.actions.push(context_block_action(
root,
"CLAUDE.md",
"root-claude",
&render_root_claude_context(&plan),
"delta específico para Claude Code",
None,
));
}
if local_agents_enabled {
for boundary in &plan.local_agent_boundaries {
let target = format!("{}/AGENTS.md", boundary.path);
plan.actions.push(context_block_action(
root,
&target,
&boundary.id,
&render_boundary_agents_context(boundary, &plan),
"contexto local por boundary",
Some(boundary.id.clone()),
));
}
}
let artifact_content = render_context_materialization_artifact(&plan);
plan.actions.insert(
0,
context_artifact_action(
root,
&artifact_path,
&artifact_content,
"context-recommendations",
"artefato rastreável de recomendações contextuais",
),
);
Ok(plan)
}
fn context_materialization_target_enabled(
config: &domain::providers::ContextMaterializationConfig,
target: &str,
) -> bool {
config.targets.is_empty()
|| config
.targets
.iter()
.any(|item| item == "all" || item == target)
}
fn detect_context_profiles(
root: &Path,
report: &DiscoveryReport,
config: &domain::providers::ContextMaterializationConfig,
max_boundaries: usize,
) -> Vec<ContextBoundary> {
let mut seen = BTreeSet::new();
let mut profiles = Vec::new();
let excludes = runtime::agent_context::merged_excludes(config);
for (path, kind) in [
("src", "cli-core"),
("src/domain", "domain"),
("src/runtime", "runtime"),
("src/tui", "presentation"),
("bot/src", "integration-bot"),
("templates", "artifact-templates"),
("clients", "client-surfaces"),
("plugins", "plugins"),
("schemas", "contracts"),
] {
push_context_profile(
root,
report,
&excludes,
&mut seen,
&mut profiles,
path,
kind,
);
}
for base in ["apps", "packages", "crates"] {
let base_path = root.join(base);
if !base_path.is_dir() {
continue;
}
let Ok(entries) = fs::read_dir(&base_path) else {
continue;
};
let mut entries = entries
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.collect::<Vec<_>>();
entries.sort();
for path in entries {
let rel = relative_path(root, &path);
if runtime::agent_context::path_excluded(&rel, &excludes) {
continue;
}
if runtime::agent_context::has_context_manifest(&path) || path.join("src").is_dir() {
push_context_profile(
root,
report,
&excludes,
&mut seen,
&mut profiles,
&rel,
"workspace-unit",
);
}
}
}
for entry in WalkDir::new(root)
.max_depth(4)
.into_iter()
.filter_entry(|entry| {
let rel = relative_path(root, entry.path());
!runtime::agent_context::path_excluded(&rel, &excludes)
})
.filter_map(|entry| entry.ok())
.filter(|entry| entry.file_type().is_file())
{
let Some(name) = entry.file_name().to_str() else {
continue;
};
if !matches!(
name,
"package.json" | "Cargo.toml" | "pyproject.toml" | "go.mod" | "pom.xml"
) {
continue;
}
let Some(parent) = entry.path().parent() else {
continue;
};
if parent == root {
continue;
}
let rel = relative_path(root, parent);
if runtime::agent_context::path_excluded(&rel, &excludes) {
continue;
}
push_context_profile(
root,
report,
&excludes,
&mut seen,
&mut profiles,
&rel,
"manifest-boundary",
);
}
profiles.sort_by(|a, b| {
b.path
.split('/')
.count()
.cmp(&a.path.split('/').count())
.then_with(|| a.path.cmp(&b.path))
});
profiles.truncate(max_boundaries.max(1));
profiles
}
fn push_context_profile(
root: &Path,
report: &DiscoveryReport,
excludes: &[String],
seen: &mut BTreeSet<String>,
out: &mut Vec<ContextBoundary>,
path: &str,
kind: &str,
) {
if runtime::agent_context::path_excluded(path, excludes) || !root.join(path).exists() {
return;
}
if !seen.insert(path.to_string()) {
return;
}
let evidence = context_boundary_evidence(report, path);
if let Some(profile) = runtime::agent_context::candidate_profile(root, path, kind, evidence) {
out.push(profile);
}
}
fn context_boundary_evidence(report: &DiscoveryReport, path: &str) -> Vec<String> {
let mut evidence = Vec::new();
for item in report
.architecture
.entrypoints
.iter()
.chain(report.architecture.components.iter())
.chain(report.architecture.layers.iter())
.chain(report.architecture.routing.iter())
.filter(|item| item.path == path)
{
evidence.push(format!("{}: {}", item.kind, item.role));
evidence.extend(item.evidence.iter().cloned());
}
evidence.sort();
evidence.dedup();
evidence
}
fn context_materialization_patterns(
root: &Path,
report: &DiscoveryReport,
) -> Result<(Vec<ContextPattern>, Vec<String>)> {
let mut patterns = Vec::new();
for item in technology_recommendations(root) {
patterns.push(ContextPattern {
id: item.id.to_string(),
title: item.title.to_string(),
body: runtime::redaction::redact_text(&format!(
"Sinais: {}. Skill sugerida: `{}`. Rule sugerida: `{}`.",
item.signals.join(", "),
item.skill,
item.rule
)),
source: "technology_recommendations".to_string(),
scope: "global".to_string(),
target: "AGENTS.md".to_string(),
});
}
for item in &report.capabilities {
patterns.push(ContextPattern {
id: item.id.clone(),
title: item.title.clone(),
body: runtime::redaction::redact_text(&format!(
"{} Fallback: {}",
item.trigger, item.fallback
)),
source: item.source.clone(),
scope: item.stages.join(", "),
target: item.local_skill.clone(),
});
}
let learnings = refresh_learning_statuses(
root,
&intelligence_index_dir(root)?.join("learnings.jsonl"),
false,
)?;
let mut conflicts = Vec::new();
for value in learnings {
let source = value
.get("source_artifact")
.and_then(Value::as_str)
.unwrap_or("learning sem origem");
if value.get("status").and_then(Value::as_str) == Some("stale") {
conflicts.push(format!(
"Learning obsoleto bloqueado para materialização: {source}"
));
continue;
}
let Some(summary) = value.get("summary").and_then(Value::as_str) else {
continue;
};
let stage = value
.get("source_stage")
.and_then(Value::as_str)
.unwrap_or("learning");
if !matches!(stage, "tasks" | "review" | "memory" | "adr" | "techspec") {
continue;
}
patterns.push(ContextPattern {
id: format!("learning-{}-{}", slugify(stage), patterns.len() + 1),
title: format!("Learning {stage}"),
body: runtime::redaction::redact_text(summary),
source: source.to_string(),
scope: stage.to_string(),
target: "00-context-recommendations.md".to_string(),
});
}
Ok((patterns, conflicts))
}
fn context_decision_conflicts(root: &Path, name: &str) -> Result<Vec<String>> {
if !artifact_dir(root, name).exists() {
return Ok(Vec::new());
}
let sources = collect_intelligence_sources(root, Some(name))?;
detect_decision_conflicts(root, name, &sources)
}
fn context_artifact_action(
root: &Path,
target: &str,
content: &str,
block_id: &str,
reason: &str,
) -> ContextMaterializationAction {
let path = root.join(target);
let action = if !path.exists() {
"create"
} else if fs::read_to_string(&path).is_ok_and(|existing| existing == content) {
"skip"
} else {
"update_block"
};
ContextMaterializationAction {
action: action.to_string(),
target: target.to_string(),
block_id: block_id.to_string(),
reason: reason.to_string(),
boundary: None,
changed: matches!(action, "create" | "update_block"),
}
}
fn context_block_action(
root: &Path,
target: &str,
block_id: &str,
content: &str,
reason: &str,
boundary: Option<String>,
) -> ContextMaterializationAction {
let path = root.join(target);
if !path.exists() {
return ContextMaterializationAction {
action: "create".to_string(),
target: target.to_string(),
block_id: block_id.to_string(),
reason: reason.to_string(),
boundary,
changed: true,
};
}
let Ok(existing) = fs::read_to_string(&path) else {
return ContextMaterializationAction {
action: "conflict".to_string(),
target: target.to_string(),
block_id: block_id.to_string(),
reason: "arquivo não textual ou indisponível".to_string(),
boundary,
changed: false,
};
};
match merge_context_block_text(&existing, block_id, content) {
Ok(next) if next == existing => ContextMaterializationAction {
action: "skip".to_string(),
target: target.to_string(),
block_id: block_id.to_string(),
reason: "bloco gerenciado já está atualizado".to_string(),
boundary,
changed: false,
},
Ok(_) => ContextMaterializationAction {
action: "update_block".to_string(),
target: target.to_string(),
block_id: block_id.to_string(),
reason: reason.to_string(),
boundary,
changed: true,
},
Err(err) => ContextMaterializationAction {
action: "conflict".to_string(),
target: target.to_string(),
block_id: block_id.to_string(),
reason: err.to_string(),
boundary,
changed: false,
},
}
}
fn apply_context_materialization_plan(
root: &Path,
plan: &mut ContextMaterializationPlan,
) -> Result<()> {
for action in plan.actions.clone() {
if !matches!(action.action.as_str(), "create" | "update_block") {
continue;
}
let target = root.join(&action.target);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
if action.block_id == "context-recommendations" {
let content = render_context_materialization_artifact(plan);
tui::persist::write_atomic(&target, content.as_bytes())?;
continue;
}
let Some(content) = context_materialization_block_content(plan, &action.block_id) else {
continue;
};
let existing = fs::read_to_string(&target).unwrap_or_default();
let next = merge_context_block_text(&existing, &action.block_id, &content)?;
tui::persist::write_atomic(&target, next.as_bytes())?;
}
Ok(())
}
fn context_materialization_block_content(
plan: &ContextMaterializationPlan,
block_id: &str,
) -> Option<String> {
if block_id == "root-agents" {
return Some(render_root_agents_context(plan));
}
if block_id == "root-claude" {
return Some(render_root_claude_context(plan));
}
plan.local_agent_boundaries
.iter()
.find(|boundary| boundary.id == block_id)
.map(|boundary| render_boundary_agents_context(boundary, plan))
}
fn merge_context_block_text(existing: &str, block_id: &str, content: &str) -> Result<String> {
let (start_marker, end_marker) = context_block_markers(block_id);
let block = managed_context_block(block_id, content);
if let Some(start) = existing.find(&start_marker) {
let Some(end_offset) = existing[start..].find(&end_marker) else {
bail!("bloco sdd-context `{block_id}` tem início sem fim");
};
let end = start + end_offset + end_marker.len();
let mut next = String::new();
next.push_str(existing[..start].trim_end());
if !next.is_empty() {
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 Ok(ensure_trailing_newline(next.trim_end()));
}
if existing.contains("<!-- sdd-context:start") && existing.contains(block_id) {
bail!("marcador sdd-context `{block_id}` inconsistente");
}
let mut next = existing.trim_end().to_string();
if !next.is_empty() {
next.push_str("\n\n");
}
next.push_str(block.trim_end());
Ok(ensure_trailing_newline(&next))
}
fn managed_context_block(block_id: &str, content: &str) -> String {
let (start_marker, end_marker) = context_block_markers(block_id);
format!(
"{start_marker}\n{}\n{end_marker}\n",
runtime::redaction::redact_text(content).trim_end()
)
}
fn context_block_markers(block_id: &str) -> (String, String) {
(
format!("<!-- sdd-context:start id=\"{block_id}\" -->"),
format!("<!-- sdd-context:end id=\"{block_id}\" -->"),
)
}
fn render_context_materialization_plan(plan: &ContextMaterializationPlan) -> String {
let mut out = String::new();
out.push_str(&format!(
"# Context Materialization - {}\n\n",
plan.orchestration
));
out.push_str(&format!("- Slug: `{}`\n", plan.slug));
out.push_str(&format!("- Modo: `{}`\n", plan.mode));
out.push_str(&format!("- Write: {}\n", plan.write));
out.push_str(&format!("- Dry-run: {}\n", plan.dry_run));
out.push_str(&format!(
"- Política AGENTS contextuais: `{}`\n",
plan.agent_context_policy
));
out.push_str(&format!(
"- Orçamento de arquivos locais: {}/{}\n\n",
plan.file_budget.planned_local_agents, plan.file_budget.max_boundaries
));
out.push_str("## Perfis de contexto detectados\n");
push_context_boundaries(&mut out, &plan.context_profiles);
out.push_str("\n## AGENTS locais planejados\n");
push_context_boundaries(&mut out, &plan.local_agent_boundaries);
out.push_str("\n## Ações planejadas\n");
push_context_actions(&mut out, &plan.actions);
out.push_str("\n## Preview de blocos gerenciados\n");
push_context_block_previews(&mut out, plan);
out.push_str("\n## Conflitos\n");
if plan.conflicts.is_empty() {
out.push_str("- nenhum\n");
} else {
for conflict in &plan.conflicts {
out.push_str(&format!("- {conflict}\n"));
}
}
out
}
fn render_context_materialization_artifact(plan: &ContextMaterializationPlan) -> String {
let mut out = String::new();
out.push_str(&format!(
"# Context Recommendations - {}\n\n",
plan.orchestration
));
out.push_str("## Rastreabilidade\n");
out.push_str(&format!("- Orquestração: {}\n", plan.orchestration));
out.push_str(&format!("- Slug: {}\n", plan.slug));
out.push_str("- Origem: `sdd context materialize`\n");
out.push_str("- Fonte canônica: artifact store local, Project Discovery e índices `.sdd/intelligence` derivados.\n");
out.push_str("- Estado: proposta materializável; aplicação exige `--write`.\n\n");
out.push_str("## Política de AGENTS contextuais\n");
out.push_str(&format!("- Política: `{}`\n", plan.agent_context_policy));
out.push_str(&format!(
"- AGENTS locais planejados: {}/{}\n",
plan.file_budget.planned_local_agents, plan.file_budget.max_boundaries
));
out.push_str("- `AGENTS.md` raiz continua sendo o contexto portátil principal; AGENTS locais são derivados revisáveis.\n\n");
out.push_str("## Perfis de contexto detectados\n");
push_context_boundaries(&mut out, &plan.context_profiles);
out.push_str("\n## AGENTS locais planejados\n");
push_context_boundaries(&mut out, &plan.local_agent_boundaries);
out.push_str("\n## Padrões recomendados\n");
if plan.patterns.is_empty() {
out.push_str("- nenhum padrão específico detectado\n");
} else {
for pattern in &plan.patterns {
out.push_str(&format!(
"- `{}` [{}] {} — {} Fonte: `{}`. Alvo: `{}`.\n",
pattern.id,
pattern.scope,
pattern.title,
pattern.body,
pattern.source,
pattern.target
));
}
}
out.push_str("\n## Ações planejadas\n");
let materialized_actions = plan
.actions
.iter()
.filter(|action| action.block_id != "context-recommendations")
.cloned()
.collect::<Vec<_>>();
push_context_actions(&mut out, &materialized_actions);
out.push_str("\n## Conflitos e bloqueios\n");
if plan.conflicts.is_empty() {
out.push_str("- nenhum conflito detectado\n");
} else {
for conflict in &plan.conflicts {
out.push_str(&format!("- {conflict}\n"));
}
}
out.push_str("\n## Como aplicar\n");
out.push_str("- Revise este artefato e o plano impresso pelo CLI.\n");
out.push_str("- Aplique com `sdd context materialize --name \"");
out.push_str(&plan.orchestration);
out.push_str("\" --write`.\n");
out.push_str("- O comando só altera blocos `sdd-context` e preserva conteúdo humano fora desses marcadores.\n");
runtime::redaction::redact_text(&out)
}
fn push_context_block_previews(out: &mut String, plan: &ContextMaterializationPlan) {
let preview_actions = plan
.actions
.iter()
.filter(|action| matches!(action.action.as_str(), "create" | "update_block"))
.collect::<Vec<_>>();
if preview_actions.is_empty() {
out.push_str("- nenhuma alteração de bloco prevista\n");
return;
}
for action in preview_actions {
out.push_str(&format!(
"### `{}` / `{}`\n\n",
action.target, action.block_id
));
let content = if action.block_id == "context-recommendations" {
render_context_materialization_artifact(plan)
} else if let Some(content) = context_materialization_block_content(plan, &action.block_id)
{
managed_context_block(&action.block_id, &content)
} else {
"- preview indisponível para este bloco\n".to_string()
};
out.push_str("```md\n");
out.push_str(runtime::redaction::redact_text(&content).trim_end());
out.push_str("\n```\n\n");
}
}
fn push_context_boundaries(out: &mut String, boundaries: &[ContextBoundary]) {
if boundaries.is_empty() {
out.push_str("- nenhum boundary materializável detectado\n");
return;
}
out.push_str("| ID | Path | Tipo | Confiança | Evidências |\n");
out.push_str("|---|---|---|---|---|\n");
for boundary in boundaries {
out.push_str(&format!(
"| `{}` | `{}` | {} | {} | {} |\n",
boundary.id,
boundary.path,
boundary.kind,
boundary.confidence,
boundary.evidence.join("<br>")
));
}
}
fn push_context_actions(out: &mut String, actions: &[ContextMaterializationAction]) {
if actions.is_empty() {
out.push_str("- nenhuma ação planejada\n");
return;
}
out.push_str("| Ação | Arquivo | Bloco | Motivo |\n");
out.push_str("|---|---|---|---|\n");
for action in actions {
out.push_str(&format!(
"| {} | `{}` | `{}` | {} |\n",
action.action, action.target, action.block_id, action.reason
));
}
}
fn render_root_agents_context(plan: &ContextMaterializationPlan) -> String {
let mut out = String::new();
out.push_str("## Roteamento de Contexto SDD\n\n");
out.push_str(&format!(
"- Orquestração de origem: `{}`.\n",
plan.orchestration
));
out.push_str(&format!(
"- Recomendações rastreáveis: `{}`.\n",
plan.artifact_path
));
out.push_str(&format!(
"- Política de AGENTS contextuais: `{}`; arquivos locais planejados: {}/{}.\n",
plan.agent_context_policy,
plan.file_budget.planned_local_agents,
plan.file_budget.max_boundaries
));
out.push_str("- Use este `AGENTS.md` raiz como contexto portátil principal; leia AGENTS local apenas quando o diretório já tiver esse arquivo.\n");
out.push_str("- Não copie segredos para artefatos, prompts, traces ou memória; cite apenas nomes de variáveis e arquivos redigidos.\n\n");
out.push_str("### Perfis detectados\n");
for boundary in plan.context_profiles.iter().take(8) {
out.push_str(&format!(
"- `{}` [{}; confiança={}]: {}.\n",
boundary.path,
boundary.kind,
boundary.confidence,
boundary.evidence.join("; ")
));
}
out.push_str("\n### Validações recomendadas\n");
out.push_str(
"- Antes de concluir mudanças de contexto, rode `sdd context materialize --name \"",
);
out.push_str(&plan.orchestration);
out.push_str("\" --json` para revisar o plano.\n");
out.push_str("- Para criar AGENTS locais, use `sdd context materialize --name \"");
out.push_str(&plan.orchestration);
out.push_str("\" --local-agents --max-boundaries <n> --write`.\n");
out.push_str("\n### Padrões ativos\n");
for pattern in plan.patterns.iter().take(10) {
out.push_str(&format!(
"- `{}`: {} ({})\n",
pattern.id, pattern.body, pattern.source
));
}
if !plan.conflicts.is_empty() {
out.push_str("\n### Bloqueios\n");
for conflict in &plan.conflicts {
out.push_str(&format!("- {conflict}\n"));
}
}
out
}
fn render_root_claude_context(plan: &ContextMaterializationPlan) -> String {
let mut out = String::new();
out.push_str("## Deltas Claude Code para contextos SDD\n\n");
out.push_str("- Use este arquivo apenas para diferenças do Claude Code; regras portáveis continuam em `AGENTS.md`.\n");
out.push_str("- Quando uma área tiver `AGENTS.md` local, carregue-o junto com o contexto Claude antes de editar.\n");
out.push_str("- Prefira skills `.claude/skills/*` somente quando a invocação direta no Claude Code for útil; mantenha `.agents/skills/*` como fonte portátil.\n");
out.push_str(&format!(
"- Recomendações rastreáveis: `{}`.\n",
plan.artifact_path
));
out
}
fn render_boundary_agents_context(
boundary: &ContextBoundary,
plan: &ContextMaterializationPlan,
) -> String {
let mut out = String::new();
out.push_str(&format!("# Contexto SDD - {}\n\n", boundary.path));
out.push_str("## Escopo\n");
out.push_str(&format!("- Boundary: `{}`\n", boundary.path));
out.push_str(&format!("- Tipo: {}\n", boundary.kind));
out.push_str(&format!("- Confiança: {}\n", boundary.confidence));
out.push_str(&format!(
"- Recomendações globais: `{}`\n\n",
plan.artifact_path
));
out.push_str("## Evidências\n");
for evidence in &boundary.evidence {
out.push_str(&format!("- {evidence}\n"));
}
out.push_str("\n## Regras locais\n");
out.push_str("- Declare esta área como escopo quando uma task tocar seus arquivos.\n");
out.push_str("- Preserve boundaries existentes; não mova responsabilidade para outro módulo sem Tech Spec ou ADR.\n");
out.push_str("- Antes de concluir, rode a menor verificação que cubra esta área e registre a evidência no artefato da etapa.\n");
out.push_str("- Se a mudança cruzar outro boundary, promova o risco para Tech Spec/Refinement ou Agent Team.\n");
out
}
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## Plano de materialização contextual\n");
if let Some(name) = name {
match build_context_materialization_plan(root, name, false, true, false, None) {
Ok(plan) => {
out.push_str("- Comando de revisão: `sdd context materialize --name \"");
out.push_str(name);
out.push_str("\"`\n");
out.push_str("- Comando de aplicação: `sdd context materialize --name \"");
out.push_str(name);
out.push_str("\" --write`\n");
out.push_str("- Boundaries detectados:\n");
for boundary in &plan.boundaries {
out.push_str(&format!(
" - `{}` [{}] confiança={}\n",
boundary.path, boundary.kind, boundary.confidence
));
}
out.push_str("- Ações planejadas:\n");
for action in &plan.actions {
out.push_str(&format!(
" - {} `{}` bloco `{}`\n",
action.action, action.target, action.block_id
));
}
if !plan.conflicts.is_empty() {
out.push_str("- Bloqueios:\n");
for conflict in &plan.conflicts {
out.push_str(&format!(" - {conflict}\n"));
}
}
}
Err(err) => {
out.push_str(&format!(
"- Plano indisponível nesta execução: {err}. Rode `sdd context materialize --name \"{name}\"` para diagnóstico.\n"
));
}
}
} else {
out.push_str("- Use `sdd context materialize --name \"<orquestracao>\"` para revisar boundaries, arquivos alvo e diffs antes de aplicar.\n");
}
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.push_str("- Aplique blocos gerenciados com `sdd context materialize --name \"<orquestracao>\" --write` somente depois de revisar o plano.\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();
let config_yaml = serde_yaml::from_str::<serde_yaml::Value>(&config).ok();
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(),
);
}
}
let ci_config = yaml_path_string(config_yaml.as_ref(), &["systems", "ci"]);
let has_github_actions = root.join(".github/workflows").is_dir();
if has_github_actions && ci_config.as_deref() == Some("none") {
warnings.push(
"sdd.config.yaml declara systems.ci: none, mas .github/workflows existe".to_string(),
);
}
if !has_github_actions
&& ci_config
.as_deref()
.is_some_and(|value| !matches!(value, "none" | ""))
{
warnings
.push("sdd.config.yaml declara CI, mas .github/workflows não foi encontrado".into());
}
if has_github_actions {
let workflow_text = read_workflow_text(root);
for required in ["cargo test", "cargo clippy", "sdd ci"] {
if !workflow_text.contains(required) {
warnings.push(format!(
"CI detectado, mas workflow não contém check esperado: `{required}`"
));
}
}
}
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 external clients and explicit `sdd hook ...` calls 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() {
let chat_config = yaml_path_string(config_yaml.as_ref(), &["systems", "chat"]);
if !bot_config.exists() && chat_config.as_deref().is_some_and(|value| value != "none") {
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) {
let status = if report.failures.is_empty() {
"pass"
} else {
"fail"
};
let signal = match (report.failures.is_empty(), report.warnings.is_empty()) {
(true, true) => "clean",
(true, false) => "attention",
(false, _) => "blocked",
};
banner("doctor");
status_line(status, signal);
summary_line(
"Checks",
&format!(
"{} failures · {} warnings",
report.failures.len(),
report.warnings.len()
),
);
banner_close();
if report.failures.is_empty() {
section("Failures", 0);
section_none();
} else {
print_doctor_issue_section("Failures", "✕", &report.failures);
}
if report.warnings.is_empty() {
section("Warnings", 0);
section_none();
} else {
print_doctor_issue_section("Warnings", "⚠", &report.warnings);
}
}
struct DoctorIssueGroup {
summary: String,
details: Vec<String>,
}
fn print_doctor_issue_section(title: &str, icon: &str, items: &[String]) {
section(title, items.len());
for group in doctor_issue_groups(items) {
println!(" {icon} {}", group.summary);
tree(&group.details, None);
}
}
fn doctor_issue_groups(items: &[String]) -> Vec<DoctorIssueGroup> {
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
let parsed: Vec<Option<(String, String)>> = items
.iter()
.map(|item| {
let (summary, detail) = doctor_issue_parts(item)?;
*counts.entry(summary.clone()).or_insert(0) += 1;
Some((summary, detail))
})
.collect();
let mut groups: Vec<DoctorIssueGroup> = Vec::new();
for (item, parsed) in items.iter().zip(parsed.into_iter()) {
if let Some((summary, detail)) =
parsed.filter(|(summary, _)| counts.get(summary).copied().unwrap_or(0) > 1)
{
if let Some(group) = groups.iter_mut().find(|group| group.summary == summary) {
group.details.push(detail);
} else {
groups.push(DoctorIssueGroup {
summary,
details: vec![detail],
});
}
} else {
groups.push(DoctorIssueGroup {
summary: item.clone(),
details: Vec::new(),
});
}
}
groups
}
fn doctor_issue_parts(item: &str) -> Option<(String, String)> {
let (summary, detail) = item.split_once(": ")?;
let summary = summary.trim();
let detail = detail.trim();
if summary.is_empty() || detail.is_empty() {
return None;
}
Some((summary.to_string(), detail.to_string()))
}
fn yaml_path_string(value: Option<&serde_yaml::Value>, path: &[&str]) -> Option<String> {
let mut current = value?;
for key in path {
current = current.get(*key)?;
}
current.as_str().map(str::to_string)
}
fn read_workflow_text(root: &Path) -> String {
let workflows = root.join(".github/workflows");
let Ok(entries) = fs::read_dir(workflows) else {
return String::new();
};
let mut out = String::new();
for path in entries.flatten().map(|entry| entry.path()) {
if path.is_file() {
if let Ok(text) = fs::read_to_string(path) {
out.push_str(&text);
out.push('\n');
}
}
}
out
}
fn which(command: &str) -> Option<PathBuf> {
runtime::platform::find_executable(command)
}
#[derive(Clone)]
struct ReleaseGateCommand {
area: &'static str,
display: String,
program: PathBuf,
args: Vec<String>,
cwd: PathBuf,
required: bool,
tool_probe: Option<&'static str>,
}
#[derive(Serialize)]
struct ReleaseGateCheck {
area: &'static str,
command: String,
cwd: String,
required: bool,
status: &'static str,
duration_ms: u128,
exit_code: Option<i32>,
summary: String,
}
fn run_ci(root: &Path, args: &CiArgs) -> Result<()> {
if args.strict_release {
return run_strict_release_gate(root, args);
}
if args.json || args.dry_run || args.name.is_some() {
bail!("--json, --dry-run e --name exigem --strict-release em `sdd ci`");
}
run_legacy_ci(root)
}
fn run_legacy_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(())
}
fn run_strict_release_gate(root: &Path, args: &CiArgs) -> Result<()> {
let started = Instant::now();
let release_root = release_gate_source_root(root);
let mut checks = Vec::new();
let mut plan = required_release_gate_checks(root, &release_root, args.name.as_deref())?;
plan.extend(advisory_gate_checks(&release_root));
for check in &plan {
checks.push(run_release_gate_check(check, args.dry_run));
}
let failed_required = checks
.iter()
.any(|check| check.required && check.status == "fail");
let warned = checks
.iter()
.any(|check| matches!(check.status, "warn" | "skipped"));
let status = if failed_required {
"fail"
} else if warned {
"warn"
} else {
"pass"
};
let report = json!({
"status": status,
"generated_at": now(),
"duration_ms": started.elapsed().as_millis(),
"root": root,
"release_root": release_root,
"orchestration": args.name,
"dry_run": args.dry_run,
"checks": checks,
});
if args.json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print!("{}", render_release_gate_markdown(&report));
}
if failed_required {
bail!("strict release gate failed");
}
Ok(())
}
fn required_release_gate_checks(
root: &Path,
release_root: &Path,
name: Option<&str>,
) -> Result<Vec<ReleaseGateCommand>> {
let sdd_exe = env::current_exe().context("resolving current sdd executable")?;
let mut checks = vec![
gate_command(
release_root,
"core-rust",
"cargo fmt --check",
"cargo",
&["fmt", "--check"],
true,
),
gate_command(
release_root,
"core-rust",
"cargo clippy --all-targets --all-features -- -D warnings",
"cargo",
&[
"clippy",
"--all-targets",
"--all-features",
"--",
"-D",
"warnings",
],
true,
),
gate_command(
release_root,
"core-rust",
"cargo test --locked --all-features",
"cargo",
&["test", "--locked", "--all-features"],
true,
),
internal_sdd_gate_command(
root,
&sdd_exe,
"health",
"sdd health --strict --json",
&["health", "--strict", "--json"],
true,
),
internal_sdd_gate_command(
root,
&sdd_exe,
"workflows",
"sdd workflow validate agentic-sdd-loop --json",
&["workflow", "validate", "agentic-sdd-loop", "--json"],
true,
),
internal_sdd_gate_command(
root,
&sdd_exe,
"clients",
"sdd clients doctor --strict --json",
&["clients", "doctor", "--strict", "--json"],
true,
),
internal_sdd_gate_command(
root,
&sdd_exe,
"clients",
"sdd capabilities doctor --targets all --json",
&["capabilities", "doctor", "--targets", "all", "--json"],
true,
),
internal_sdd_gate_command(
root,
&sdd_exe,
"clients",
"sdd skills doctor --strict --json",
&["skills", "doctor", "--strict", "--json"],
true,
),
];
if let Some(name) = name {
for check in checks
.iter_mut()
.filter(|check| check.display == "sdd health --strict --json")
{
check.display = format!("sdd health --strict --json --name {name}");
check.args.push("--name".to_string());
check.args.push(name.to_string());
}
checks.push(internal_sdd_gate_command(
root,
&sdd_exe,
"diagrams",
&format!("sdd diagram doctor --name {name} --json"),
&["diagram", "doctor", "--name", name, "--json"],
true,
));
}
let bot_dir = release_root.join("bot");
if bot_dir.join("package.json").is_file() {
checks.push(gate_command(
&bot_dir,
"bot",
"npm run build",
npm_bin(),
&["run", "build"],
true,
));
checks.push(gate_command(
&bot_dir,
"bot",
"npm test",
npm_bin(),
&["test"],
true,
));
}
let docs_dir = release_root.join("docs-site");
if docs_dir.join("package.json").is_file() {
checks.push(gate_command(
&docs_dir,
"docs",
"npm run docs:generate -- --check",
npm_bin(),
&["run", "docs:generate", "--", "--check"],
true,
));
checks.push(gate_command(
&docs_dir,
"docs",
"npm run docs:links",
npm_bin(),
&["run", "docs:links"],
true,
));
checks.push(gate_command(
&docs_dir,
"docs",
"npm run build",
npm_bin(),
&["run", "build"],
true,
));
}
Ok(checks)
}
fn release_gate_source_root(project_root: &Path) -> PathBuf {
if is_sdd_layer_source_root(project_root) {
return canonical_or_original(project_root);
}
if let Ok(value) = env::var("SDD_LAYER_ROOT") {
let candidate = PathBuf::from(value);
if is_sdd_layer_source_root(&candidate) {
return canonical_or_original(&candidate);
}
}
if let Ok(exe) = env::current_exe() {
for current in exe.ancestors() {
if is_sdd_layer_source_root(current) {
return canonical_or_original(current);
}
}
}
let manifest_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
if is_sdd_layer_source_root(&manifest_root) {
return canonical_or_original(&manifest_root);
}
canonical_or_original(project_root)
}
fn is_sdd_layer_source_root(path: &Path) -> bool {
path.join("Cargo.toml").is_file()
&& path.join("src/main.rs").is_file()
&& path.join("presets").is_dir()
&& path.join("AGENTS.md").is_file()
}
fn canonical_or_original(path: &Path) -> PathBuf {
runtime::platform::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
fn advisory_gate_checks(root: &Path) -> Vec<ReleaseGateCommand> {
let bot_dir = root.join("bot");
let mut checks = vec![
advisory_gate_command(root, "cargo audit", "cargo", &["audit"], "cargo-audit"),
advisory_gate_command(
root,
"cargo deny check",
"cargo",
&["deny", "check"],
"cargo-deny",
),
advisory_gate_command(
root,
"gitleaks detect --no-banner",
"gitleaks",
&["detect", "--no-banner"],
"gitleaks",
),
];
if bot_dir.join("package.json").is_file() {
checks.push(advisory_gate_command(
&bot_dir,
"npm audit --audit-level=high",
npm_bin(),
&["audit", "--audit-level=high"],
npm_bin(),
));
checks.push(advisory_gate_command(
&bot_dir,
"npm sbom --sbom-format=cyclonedx",
npm_bin(),
&["sbom", "--sbom-format=cyclonedx"],
npm_bin(),
));
}
checks
}
fn gate_command(
cwd: &Path,
area: &'static str,
display: &str,
program: impl AsRef<Path>,
args: &[&str],
required: bool,
) -> ReleaseGateCommand {
ReleaseGateCommand {
area,
display: display.to_string(),
program: program.as_ref().to_path_buf(),
args: args.iter().map(|arg| (*arg).to_string()).collect(),
cwd: cwd.to_path_buf(),
required,
tool_probe: None,
}
}
fn internal_sdd_gate_command(
cwd: &Path,
sdd_exe: &Path,
area: &'static str,
display: &str,
args: &[&str],
required: bool,
) -> ReleaseGateCommand {
gate_command(cwd, area, display, sdd_exe, args, required)
}
fn advisory_gate_command(
cwd: &Path,
display: &str,
program: impl AsRef<Path>,
args: &[&str],
tool_probe: &'static str,
) -> ReleaseGateCommand {
let mut command = gate_command(cwd, "advisory", display, program, args, false);
command.tool_probe = Some(tool_probe);
command
}
fn run_release_gate_check(check: &ReleaseGateCommand, dry_run: bool) -> ReleaseGateCheck {
let started = Instant::now();
if dry_run {
return release_gate_result(
check,
"skipped",
started.elapsed().as_millis(),
None,
"dry-run: comando planejado".to_string(),
);
}
if required_tool_missing(check) {
return release_gate_result(
check,
if check.required { "fail" } else { "skipped" },
started.elapsed().as_millis(),
None,
format!(
"ferramenta ausente: {}",
check
.tool_probe
.unwrap_or_else(|| check.program.to_str().unwrap_or("comando"))
),
);
}
let output = std::process::Command::new(&check.program)
.args(&check.args)
.current_dir(&check.cwd)
.output();
match output {
Ok(output) => {
let status = if output.status.success() {
"pass"
} else if check.required {
"fail"
} else {
"warn"
};
let summary = command_output_summary(&output.stdout, &output.stderr);
release_gate_result(
check,
status,
started.elapsed().as_millis(),
output.status.code(),
summary,
)
}
Err(error) => release_gate_result(
check,
if check.required { "fail" } else { "skipped" },
started.elapsed().as_millis(),
None,
format!("{error:#}"),
),
}
}
fn release_gate_result(
check: &ReleaseGateCommand,
status: &'static str,
duration_ms: u128,
exit_code: Option<i32>,
summary: String,
) -> ReleaseGateCheck {
ReleaseGateCheck {
area: check.area,
command: check.display.clone(),
cwd: check.cwd.display().to_string(),
required: check.required,
status,
duration_ms,
exit_code,
summary,
}
}
fn required_tool_missing(check: &ReleaseGateCommand) -> bool {
if check.tool_probe.is_none()
&& (check.program.is_absolute()
|| check
.program
.components()
.any(|component| matches!(component, std::path::Component::Normal(_)))
&& check.program.components().count() > 1)
{
return !check.program.is_file();
}
let probe = check
.tool_probe
.or_else(|| check.program.to_str())
.unwrap_or_default();
which(probe).is_none()
}
fn command_output_summary(stdout: &[u8], stderr: &[u8]) -> String {
let mut text = String::new();
text.push_str(&String::from_utf8_lossy(stdout));
if !stderr.is_empty() {
if !text.is_empty() {
text.push('\n');
}
text.push_str(&String::from_utf8_lossy(stderr));
}
if let Some(summary) = structured_command_output_summary(&text) {
return summary;
}
let compact = text
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.take(24)
.collect::<Vec<_>>()
.join(" | ");
if compact.len() > 1800 {
format!("{}...", compact.chars().take(1800).collect::<String>())
} else if compact.is_empty() {
"ok".to_string()
} else {
compact
}
}
fn structured_command_output_summary(text: &str) -> Option<String> {
let json = extract_json_object(text)?;
let mut parts = Vec::new();
if let Some(status) = json.get("status").and_then(Value::as_str) {
parts.push(format!("status={status}"));
}
if let Some(workflow) = json.get("workflow").and_then(Value::as_str) {
parts.push(format!("workflow={workflow}"));
}
if let Some(format) = json.get("bomFormat").and_then(Value::as_str) {
parts.push(format!("bomFormat={format}"));
}
if let Some(spec) = json.get("specVersion").and_then(Value::as_str) {
parts.push(format!("specVersion={spec}"));
}
if let Some(issues) = json.get("issues").and_then(Value::as_array) {
parts.push(format!("issues={}", issues.len()));
}
if let Some(checked) = json.get("checked").and_then(Value::as_array) {
parts.push(format!("checked={}", checked.len()));
}
if let Some(paths) = json.get("paths").and_then(Value::as_array) {
parts.push(format!("paths={}", paths.len()));
}
if let Some(components) = json.get("components").and_then(Value::as_object) {
let attention = components
.iter()
.filter_map(|(name, component)| {
let status = component_status(component)?;
(status != "pass").then(|| {
let mut detail = format!("{name}={status}");
if let Some(count) = component.get("warning_count").and_then(Value::as_u64) {
detail.push_str(&format!(" ({count} warning(s))"));
} else if let Some(reason) = component.get("reason").and_then(Value::as_str) {
detail.push_str(&format!(" ({reason})"));
}
detail
})
})
.collect::<Vec<_>>();
if !attention.is_empty() {
parts.push(format!("componentes: {}", attention.join(", ")));
}
}
if let Some(command) = json
.pointer("/components/intelligence/repair_command")
.and_then(Value::as_str)
.or_else(|| json.get("repair_command").and_then(Value::as_str))
{
parts.push(format!("reparo: `{command}`"));
}
(!parts.is_empty()).then(|| parts.join("; "))
}
fn extract_json_object(text: &str) -> Option<Value> {
let start = text.find('{')?;
let end = text.rfind('}')?;
if end <= start {
return None;
}
serde_json::from_str::<Value>(&text[start..=end]).ok()
}
fn render_release_gate_markdown(report: &Value) -> String {
let mut out = String::new();
let status = report
.get("status")
.and_then(Value::as_str)
.unwrap_or("unknown");
let checks = report
.get("checks")
.and_then(Value::as_array)
.map(Vec::as_slice)
.unwrap_or(&[]);
let dry_run = report
.get("dry_run")
.and_then(Value::as_bool)
.unwrap_or(false);
let failed_required = checks
.iter()
.filter(|check| {
check
.get("required")
.and_then(Value::as_bool)
.unwrap_or(false)
&& component_status(check) == Some("fail")
})
.count();
let optional_attention = checks
.iter()
.filter(|check| {
!check
.get("required")
.and_then(Value::as_bool)
.unwrap_or(false)
&& matches!(component_status(check), Some("warn" | "skipped"))
})
.count();
out.push_str("# SDD Strict Release Gate\n\n");
out.push_str(&format!("- Status: `{status}`\n"));
out.push_str(&format!(
"- Leitura: {}\n",
release_gate_status_note(status, dry_run, failed_required, optional_attention)
));
if dry_run {
out.push_str("- Dry-run: `true`\n");
}
if let Some(name) = report.get("orchestration").and_then(Value::as_str) {
out.push_str(&format!("- Orquestração: `{name}`\n"));
}
if let Some(root) = report.get("root").and_then(Value::as_str) {
out.push_str(&format!("- Projeto auditado: `{root}`\n"));
}
if let Some(release_root) = report.get("release_root").and_then(Value::as_str) {
out.push_str(&format!("- Pacote SDD: `{release_root}`\n"));
}
out.push_str("\n## Resumo\n\n");
out.push_str("| Área | Obrigatórios | Opcionais | Sinal |\n");
out.push_str("|---|---:|---:|---|\n");
for (area, summary) in release_gate_area_summaries(checks) {
out.push_str(&format!(
"| {} | {} | {} | {} |\n",
markdown_cell(&area),
markdown_cell(&format_status_counts(&summary.required)),
markdown_cell(&format_status_counts(&summary.optional)),
markdown_cell(&release_gate_area_signal(&summary))
));
}
out.push_str("\n## Pendências\n\n");
if dry_run {
out.push_str("- Dry-run apenas planejou os checks; execute sem `--dry-run` para validar de verdade.\n");
} else {
let pending = release_gate_pending_items(checks);
if pending.is_empty() {
out.push_str("- Nenhuma pendência encontrada.\n");
} else {
for item in pending {
out.push_str(&format!("- {item}\n"));
}
}
}
out.push_str("\n<details>\n<summary>Ver matriz completa de checks</summary>\n\n");
out.push_str("| Área | Obrigatório | Status | Comando | Resumo curto |\n");
out.push_str("|---|---:|---|---|---|\n");
for check in checks {
let area = check.get("area").and_then(Value::as_str).unwrap_or("area");
let required = check
.get("required")
.and_then(Value::as_bool)
.unwrap_or(false);
let status = component_status(check).unwrap_or("info");
let command = check
.get("command")
.and_then(Value::as_str)
.unwrap_or("comando");
let summary = check.get("summary").and_then(Value::as_str).unwrap_or("ok");
out.push_str(&format!(
"| {} | {} | `{}` | `{}` | {} |\n",
markdown_cell(area),
if required { "sim" } else { "não" },
status,
markdown_cell(command),
markdown_cell(&short_markdown_summary(summary, 220))
));
}
out.push_str("\n</details>\n\n");
out
}
#[derive(Default)]
struct ReleaseGateAreaSummary {
required: BTreeMap<String, usize>,
optional: BTreeMap<String, usize>,
}
fn release_gate_area_summaries(checks: &[Value]) -> BTreeMap<String, ReleaseGateAreaSummary> {
let mut summaries = BTreeMap::new();
for check in checks {
let area = check
.get("area")
.and_then(Value::as_str)
.unwrap_or("area")
.to_string();
let status = component_status(check).unwrap_or("info").to_string();
let summary = summaries
.entry(area)
.or_insert_with(ReleaseGateAreaSummary::default);
let target = if check
.get("required")
.and_then(Value::as_bool)
.unwrap_or(false)
{
&mut summary.required
} else {
&mut summary.optional
};
*target.entry(status).or_insert(0) += 1;
}
summaries
}
fn format_status_counts(counts: &BTreeMap<String, usize>) -> String {
if counts.is_empty() {
return "nenhum".to_string();
}
counts
.iter()
.map(|(status, count)| format!("{count} {status}"))
.collect::<Vec<_>>()
.join(", ")
}
fn release_gate_area_signal(summary: &ReleaseGateAreaSummary) -> String {
if summary.required.get("fail").copied().unwrap_or(0) > 0 {
"bloqueia release".to_string()
} else if summary.required.iter().any(|(status, _)| status != "pass") {
"obrigatório pendente".to_string()
} else if summary.optional.get("warn").copied().unwrap_or(0) > 0 {
"opcional com aviso".to_string()
} else if summary.optional.get("skipped").copied().unwrap_or(0) > 0 {
"opcional não executado".to_string()
} else {
"ok".to_string()
}
}
fn release_gate_pending_items(checks: &[Value]) -> Vec<String> {
let mut items = Vec::new();
for check in checks {
let status = component_status(check).unwrap_or("info");
if status == "pass" {
continue;
}
let required = check
.get("required")
.and_then(Value::as_bool)
.unwrap_or(false);
let command = check
.get("command")
.and_then(Value::as_str)
.unwrap_or("comando");
let summary = check.get("summary").and_then(Value::as_str).unwrap_or("ok");
let label = if required { "Obrigatório" } else { "Opcional" };
items.push(format!(
"{label}: `{command}` ficou `{status}` — {}",
short_markdown_summary(summary, 180)
));
}
items
}
fn release_gate_status_note(
status: &str,
dry_run: bool,
failed_required: usize,
optional_attention: usize,
) -> &'static str {
if dry_run {
"dry-run: os comandos foram planejados, mas não executados."
} else if failed_required > 0 {
"há check obrigatório falhando; corrija antes de publicar."
} else if optional_attention > 0 {
"todos os checks obrigatórios passaram; o aviso vem de checks opcionais/advisory."
} else if status == "pass" {
"todos os checks executados passaram."
} else {
"verifique as pendências abaixo."
}
}
fn short_markdown_summary(value: &str, max_chars: usize) -> String {
let compact = value.split_whitespace().collect::<Vec<_>>().join(" ");
if compact.chars().count() <= max_chars {
compact
} else {
format!(
"{}...",
compact
.chars()
.take(max_chars.saturating_sub(3))
.collect::<String>()
)
}
}
fn markdown_cell(value: &str) -> String {
value.replace('|', "\\|")
}
fn npm_bin() -> &'static str {
if cfg!(windows) {
"npm.cmd"
} else {
"npm"
}
}
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")
}
// TASK-12: mantida para compatibilidade; provisionamento interativo foi removido dos
// comandos install/update/init. Usada apenas internamente se necessário.
#[allow(dead_code)]
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) {
// TASK-12: exibe apenas dica passiva; provisionamento interativo removido.
if dry_run || bot_config_path(target).exists() {
return;
}
rail_node(
RailMarker::Hollow,
"Bot Slack",
Some("componente opcional — execute `sdd bot install` para instalar"),
);
}
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,
);
// TASK-12: bot não é mais provisionado interativamente aqui.
// Use `sdd bot install` para instalar o bot Slack.
let bot_decision = BotBundleDecision {
include_bundle: bot_bundle_dir(&target).exists(),
prompt_answer: None,
};
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);
// TASK-12: bot não é mais provisionado interativamente aqui.
// Use `sdd bot install` para instalar o bot Slack.
let bot_decision = BotBundleDecision {
include_bundle: bot_bundle_dir(&target).exists(),
prompt_answer: None,
};
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> {
hook_bash_guard_decision(
payload,
env::var("SDD_GUARD_APPROVED").ok().as_deref() == Some("1"),
)
}
fn hook_bash_guard_decision(payload: &Value, approved: bool) -> 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);
}
if approved {
return Ok(0);
}
let destructive_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"),
];
for (pattern, reason) in destructive_patterns {
if Regex::new(pattern)?.is_match(command) {
eprintln!("SDD guard: blocked `{command}` because {reason}.");
return Ok(2);
}
}
if Regex::new(r"(^|\s)--dry-run\b")?.is_match(command) {
return Ok(0);
}
let gated_patterns = [
(
r"\bgit\s+push\b[^\n;&|]*(--force(?:-with-lease|-if-includes)?|-f|--delete|--mirror)\b",
"destructive git 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 gated_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) {
if is_allowed_stage_artifact_write(agent, payload) {
return 0;
}
eprintln!(
"SDD guard: only the `execution` agent should write files. The `{agent}` agent is read-only in this pipeline."
);
return 2;
}
0
}
fn is_allowed_stage_artifact_write(agent: &str, payload: &Value) -> bool {
let Some(allowed_files) = canonical_artifact_files_for_agent(agent) else {
return false;
};
let Some(path) = compact_tool_path(payload) else {
return false;
};
let project_dir = project_dir_for_hook();
let absolute_path = if path.is_absolute() {
path
} else {
project_dir.join(path)
};
let relative_path = absolute_path
.strip_prefix(&project_dir)
.unwrap_or(absolute_path.as_path());
is_docs_artifact_path(relative_path, allowed_files)
}
fn canonical_artifact_files_for_agent(agent: &str) -> Option<&'static [&'static str]> {
match agent {
"project-discovery" => Some(&["00-project-discovery.md"]),
"risk-classifier" => Some(&["00-risk-classification.md"]),
"idea" => Some(&["01-idea.md"]),
"prd" => Some(&["02-prd.md"]),
"techspec" => Some(&["03-techspec.md"]),
"tasks" => Some(&["04-tasks.md"]),
"refinement" => Some(&["05-refinement.md"]),
"execution" => Some(&["06-execution.md"]),
"adr" => Some(&["06-adr.md"]),
"review" => Some(&["07-review.md"]),
"memory" => Some(&["08-memory.md"]),
_ => None,
}
}
fn is_docs_artifact_path(path: &Path, allowed_files: &[&str]) -> bool {
let mut parts = Vec::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::Normal(part) => {
let Some(part) = part.to_str() else {
return false;
};
parts.push(part);
}
_ => return false,
}
}
parts.len() == 3
&& parts[0] == "docs"
&& !parts[1].is_empty()
&& allowed_files.contains(&parts[2])
}
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 embedded_bundle_includes_orchestration_plugin_mirrors() {
let skill = "plugins/orchestration/skills/diagram-design/SKILL.md";
assert!(capability_source_is_root_item(skill));
assert!(embedded_exists(skill));
assert!(embedded_exists(
"plugins/orchestration/skills/diagram-design/assets/template.html"
));
}
#[test]
fn artifact_slug_shortens_long_values_with_stable_hash() {
assert_eq!(artifact_slug("Minha Orquestração!"), "minha-orquestracao");
let input = "A jogada mais limpa: SDD continua runtime-agnostic. O core segue dono do contrato SDLC; Rig/Flue/LangGraph/Agno viram engines/adapters. Isso preserva exatamente a direção que o projeto já vem seguindo: .agents como contrato canônico, MCP/trace como observabilidade e client adapters gerados.";
let full_slug = slugify(input);
let shortened = artifact_slug(input);
let digest = sha256_hex(full_slug.as_bytes());
assert!(shortened.len() <= MAX_ARTIFACT_SLUG_LEN);
assert!(shortened.starts_with("a-jogada-mais-limpa"));
assert!(shortened.ends_with(&digest[..ARTIFACT_SLUG_HASH_LEN]));
}
#[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 .worktree/<card>/docs/<slug>/01-idea.md.
// Em modo card-id, artifact_dir_for aponta para o worktree — não para docs/<slug> na raiz.
let store = artifact_dir_for(dir.path(), "PROJ-7", Some("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_new_mode_uses_path_safe_slug_for_long_ideas() {
let dir = tempdir().unwrap();
let input = vec!["A jogada mais limpa: SDD continua runtime-agnostic. O core segue dono do contrato SDLC; Rig/Flue/LangGraph/Agno viram engines/adapters. Isso preserva exatamente a direção que o projeto já vem seguindo: .agents como contrato canônico, MCP/trace como observabilidade e client adapters gerados.".to_string()];
let r = resolve_input(dir.path(), &input, None);
let store = artifact_dir(dir.path(), &r.name);
let component = store.file_name().and_then(OsStr::to_str).unwrap();
assert_eq!(r.mode, "new");
assert_eq!(r.name, r.slug);
assert!(r.slug.len() <= MAX_ARTIFACT_SLUG_LEN);
assert_eq!(r.docs_dir, format!("docs/{}", r.slug));
assert_eq!(component, r.slug);
}
#[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 post_planning_error_releases_lock_and_records_last_error() {
use crate::domain::orchestrator::demand::{Demand, DemandSource, DemandType};
use crate::domain::orchestrator::engine::FakeRunner;
use crate::domain::orchestrator::queue;
use crate::domain::orchestrator::state::{self, EngineState, EngineStatus};
let dir = tempdir().unwrap();
let root = dir.path();
let demand = Demand::new(
"DEM-LOCK",
DemandType::Story,
"Broken Store",
"desc",
DemandSource::Cli,
None,
"2026-06-08T00:00:00Z",
)
.unwrap();
queue::enqueue(root, &demand).unwrap();
let mut st = EngineState::new(&demand.slug).unwrap();
st.status = EngineStatus::ReadyForExec;
state::save(root, &st).unwrap();
fs::create_dir_all(root.join("docs")).unwrap();
fs::write(root.join("docs").join(&demand.slug), "not a directory").unwrap();
let report = run_post_planning(root, &FakeRunner, "owner-a", "review").unwrap();
assert!(
report
.errors
.iter()
.any(|error| error.contains(&demand.slug)),
"{:?}",
report.errors
);
let saved = state::load(root, &demand.slug).unwrap().unwrap();
assert_eq!(saved.status, EngineStatus::Error);
assert!(saved.lock.is_none(), "lock must be released after failure");
assert!(
saved
.last_error
.as_deref()
.is_some_and(|error| error.contains(&demand.slug)),
"{:?}",
saved.last_error
);
}
#[test]
fn orchestration_store_name_uses_path_safe_slug_for_long_ideas() {
let name = orchestration_store_name(
None,
None,
"A jogada mais limpa: SDD continua runtime-agnostic. O core segue dono do contrato SDLC; Rig/Flue/LangGraph/Agno viram engines/adapters. Isso preserva exatamente a direção que o projeto já vem seguindo: .agents como contrato canônico, MCP/trace como observabilidade e client adapters gerados.",
);
assert!(name.len() <= MAX_ARTIFACT_SLUG_LEN);
assert!(name.starts_with("a-jogada-mais-limpa"));
}
#[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 command_output_summary_compacts_json_health_warnings() {
let stdout = br#"{
"status": "warn",
"components": {
"clients": {"status": "pass"},
"intelligence": {
"status": "warn",
"warning_count": 1,
"repair_command": "sdd intelligence repair --all --json"
},
"quality": {
"status": "skipped",
"reason": "--name nao informado"
}
}
}"#;
let summary = command_output_summary(stdout, b"SDD error: health strict failed");
assert!(summary.contains("status=warn"), "{summary}");
assert!(
summary.contains("intelligence=warn (1 warning(s))"),
"{summary}"
);
assert!(
summary.contains("quality=skipped (--name nao informado)"),
"{summary}"
);
assert!(
summary.contains("reparo: `sdd intelligence repair --all --json`"),
"{summary}"
);
assert!(!summary.contains("\"components\""), "{summary}");
}
#[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 bash_guard_allows_regular_push() {
let payload = json!({"tool_input": {"command": "git push origin feature-x"}});
assert_eq!(hook_bash_guard_decision(&payload, false).unwrap(), 0);
}
#[test]
fn bash_guard_blocks_force_push_without_approval() {
let payload =
json!({"tool_input": {"command": "git push --force-with-lease origin feature-x"}});
assert_eq!(hook_bash_guard_decision(&payload, false).unwrap(), 2);
}
#[test]
fn bash_guard_allows_dry_run_release_commands() {
let payload = json!({"tool_input": {"command": "npm publish --dry-run"}});
assert_eq!(hook_bash_guard_decision(&payload, false).unwrap(), 0);
}
#[test]
fn bash_guard_allows_guard_approved_command() {
let payload =
json!({"tool_input": {"command": "git push --force-with-lease origin feature-x"}});
assert_eq!(hook_bash_guard_decision(&payload, true).unwrap(), 0);
}
#[test]
fn write_guard_allows_stage_agent_to_write_own_artifact() {
let payload = json!({
"tool_name": "Write",
"agent_type": "prd",
"tool_input": {"file_path": "docs/minha-orquestracao/02-prd.md"}
});
assert_eq!(hook_write_guard(&payload), 0);
}
#[test]
fn write_guard_blocks_stage_agent_from_editing_code() {
let payload = json!({
"tool_name": "Edit",
"agent_type": "prd",
"tool_input": {"file_path": "src/main.rs"}
});
assert_eq!(hook_write_guard(&payload), 2);
}
#[test]
fn write_guard_blocks_stage_agent_from_wrong_artifact() {
let payload = json!({
"tool_name": "Write",
"agent_type": "prd",
"tool_input": {"file_path": "docs/minha-orquestracao/03-techspec.md"}
});
assert_eq!(hook_write_guard(&payload), 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"));
}
#[test]
fn artifact_dir_for_card_uses_worktree_path() {
let tmp = tempdir().unwrap();
let root = tmp.path();
let result = artifact_dir_for(root, "SSDL-1", Some("SSDL-1"));
let expected = root
.join(".worktree")
.join("SSDL-1")
.join("docs")
.join("ssdl-1");
assert_eq!(result, expected);
}
#[test]
fn artifact_dir_for_no_card_delegates_to_artifact_dir() {
let tmp = tempdir().unwrap();
let root = tmp.path();
let result = artifact_dir_for(root, "minha-feature", None);
let expected = artifact_dir(root, "minha-feature");
assert_eq!(result, expected);
}
#[test]
fn mmdc_available_returns_false_when_absent() {
// Agnóstico ao ambiente: verifica apenas que a função não entra em panic.
let _ = mmdc_available();
}
}