sdd-layer 0.25.0

Spec-Driven Development CLI and agent harness
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
//! Driver interativo do orquestrador — o loop `next`/`done`/`approve`/`reject`
//! que torna `/orchestrator` determinístico.
//!
//! Diferente do motor autônomo (`engine::tick`), que chama um `StageRunner`
//! headless e para no handoff de execução, este driver é conduzido turno-a-turno
//! pelo modelo do Claude Code: a CADA turno o modelo pergunta `next` (o que fazer
//! agora), executa SÓ aquilo, e reporta `done`. O cursor vive em
//! `.sdd/state/<slug>.json` — nunca na cabeça do modelo —, então um desvio de
//! conversa ("Conversar" no checkpoint) jamais faz o fluxo perder o lugar.
//!
//! Funções puras e testáveis (sem IO de git/MCP/arquivo); o handler da CLI faz o
//! IO (checagem de arquivo, `evaluate_stage`, git) e chama estas transições.

use crate::tui::stage::Stage;

use super::state::{
    Approval, CardMeta, Channel, DecisionKind, EngineState, EngineStatus, OrchestrationMode,
};

/// Próxima etapa na sequência canônica. Card e texto-livre compartilham
/// `Stage::CARD_GUIDED` (10 etapas: discovery → risk → idea → … → memory) —
/// o preflight (Project Discovery + Risk Classification) é obrigatório nos
/// dois modos, conforme os princípios SDD ("não pule etapas").
fn guided_next(stage: Stage, _mode: OrchestrationMode) -> Option<Stage> {
    let all: &[Stage] = &Stage::CARD_GUIDED;
    let idx = all.iter().position(|s| *s == stage)?;
    all.get(idx + 1).copied()
}

/// Etapas que pausam para checkpoint humano no fluxo interativo. Inclui Review
/// (checkpoint pré-merge), além dos gates de planejamento PRD/TechSpec/Refinement.
pub fn is_interactive_gate(stage: Stage) -> bool {
    matches!(
        stage,
        Stage::Prd | Stage::Techspec | Stage::Refinement | Stage::Review | Stage::Qa
    )
}

/// Coluna do board (índice 1..6) em que o card fica enquanto a etapa é o cursor.
/// Mapa autoritativo: discovery/risk/idea/prd=1; techspec/tasks/refinement=2;
/// execution=3; review=4; qa=5; memory=6.
pub fn stage_entry_column(stage: Stage) -> u8 {
    match stage {
        Stage::ProjectDiscovery
        | Stage::RiskClassification
        | Stage::Idea
        | Stage::Prd
        | Stage::Adr => 1,
        Stage::Techspec | Stage::Tasks | Stage::Refinement => 2,
        Stage::Execution => 3,
        Stage::Review => 4,
        Stage::Qa => 5,
        Stage::Memory => 6,
    }
}

/// Evidências obrigatórias no `done` de uma etapa (modo card) — side-effects que
/// ocorrem DURANTE a etapa. Vazio em texto-livre.
pub fn required_done_evidence(stage: Stage, mode: OrchestrationMode) -> Vec<&'static str> {
    if mode != OrchestrationMode::Card {
        return Vec::new();
    }
    match stage {
        // Idea: troca a descrição (original→comentário), atribui o card e define datas.
        Stage::Idea => vec!["description", "assignee", "dates"],
        _ => Vec::new(),
    }
}

/// Obrigações criadas ao CONCLUIR (`done` não-gate) ou APROVAR (`approve` gate) a
/// etapa `stage`, resultando em avanço para `next` (None = terminal). Modo card.
/// Retorna `(chaves de obrigação, coluna-alvo)`. As chaves entram em
/// `pending_obligations` e a coluna em `pending_column`; ambas exigidas por `confirm`.
pub fn transition_obligations(
    stage: Stage,
    next: Option<Stage>,
    mode: OrchestrationMode,
) -> (Vec<String>, Option<u8>) {
    if mode != OrchestrationMode::Card {
        return (Vec::new(), None);
    }
    let mut keys: Vec<&'static str> = Vec::new();
    match stage {
        // Publica a sub-página detalhada no Confluence (diagramas como imagem via mmdc).
        Stage::Prd | Stage::Techspec => keys.push("confluence"),
        // Review aprovado → QA (coluna 5) é a etapa real de testes; sem obrigação
        // sintética aqui (era placeholder antes do Stage::Qa existir).
        // Memory (modo card): publica no Confluence e faz o merge dos worktrees
        // no branch card-id. O PR só é criado quando o usuário pedir explicitamente
        // após validar manualmente o branch card-id (ciclo de ajuste ou PR final).
        Stage::Memory => {
            keys.push("confluence");
            keys.push("merge");
        }
        _ => {}
    }
    // Coluna-alvo: casos especiais (idea→col1 inicial, memory→col6); senão
    // move só quando o avanço cruza fronteira de coluna (review→qa cruza pra
    // col5 automaticamente pela lógica genérica abaixo).
    let column = match stage {
        Stage::Idea => Some(1),
        Stage::Memory => Some(6),
        _ => match next {
            Some(n) if stage_entry_column(stage) != stage_entry_column(n) => {
                Some(stage_entry_column(n))
            }
            _ => None,
        },
    };
    if column.is_some() {
        keys.push("column");
    }
    (keys.into_iter().map(str::to_string).collect(), column)
}

/// Subagent responsável por gerar o artefato da etapa.
pub fn stage_subagent(stage: Stage) -> &'static str {
    match stage {
        Stage::ProjectDiscovery => "project-discovery",
        Stage::RiskClassification => "risk-classifier",
        Stage::Idea => "idea",
        Stage::Prd => "prd",
        Stage::Techspec => "techspec",
        Stage::Tasks => "tasks",
        Stage::Refinement => "refinement",
        Stage::Execution => "execution",
        Stage::Adr => "adr",
        Stage::Review => "review",
        Stage::Qa => "qa",
        Stage::Memory => "memory",
    }
}

/// Tipo de diretiva que `next` devolve ao modelo.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DirectiveKind {
    /// Cumprir obrigações pós-aprovação pendentes antes de qualquer outra coisa.
    Obligations,
    /// Há checkpoint pendente: apresentar as 4 opções e aguardar decisão humana.
    Checkpoint,
    /// Gerar o artefato da etapa do cursor (e seus side-effects).
    GenerateStage,
    /// Fluxo concluído.
    Completed,
    /// Modo card: Memory concluída e worktrees mergeados no branch card-id.
    /// Aguarda decisão do usuário: pedir ajustes (rework para execution) ou
    /// criar PR final para um branch de destino (com versão n.n.n+1).
    TestingFeedback,
}

/// Diretiva estruturada para o turno atual. O handler da CLI a renderiza em
/// Markdown/JSON; o modelo executa SÓ o que ela diz.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Directive {
    pub kind: DirectiveKind,
    pub mode: OrchestrationMode,
    pub slug: String,
    pub cursor: String,
    pub stage_label: String,
    pub subagent: String,
    pub is_gate: bool,
    pub pending_obligations: Vec<String>,
    pub pending_column: Option<u8>,
    pub required_evidence: Vec<String>,
}

/// Calcula a diretiva do turno a partir do estado durável. Função pura.
pub fn next_directive(st: &EngineState) -> Directive {
    let stage = Stage::from_key(&st.cursor).unwrap_or(Stage::Idea);
    let base = Directive {
        kind: DirectiveKind::GenerateStage,
        mode: st.mode,
        slug: st.slug.clone(),
        cursor: st.cursor.clone(),
        stage_label: stage.label().to_string(),
        subagent: stage_subagent(stage).to_string(),
        is_gate: is_interactive_gate(stage),
        pending_obligations: st.pending_obligations.clone(),
        pending_column: st.pending_column,
        required_evidence: required_done_evidence(stage, st.mode)
            .into_iter()
            .map(str::to_string)
            .collect(),
    };

    if !st.pending_obligations.is_empty() {
        return Directive {
            kind: DirectiveKind::Obligations,
            ..base
        };
    }
    match st.status {
        EngineStatus::AwaitingApproval => Directive {
            kind: DirectiveKind::Checkpoint,
            ..base
        },
        EngineStatus::Completed => Directive {
            kind: DirectiveKind::Completed,
            ..base
        },
        EngineStatus::ReadyForTesting => Directive {
            kind: DirectiveKind::TestingFeedback,
            ..base
        },
        _ => base,
    }
}

/// Erros de transição do driver (mapeados para `bail!` no handler da CLI).
#[derive(Debug, PartialEq, Eq)]
pub enum DriverError {
    /// `done`/`approve` para uma etapa que não é o cursor atual.
    StageMismatch { expected: String, got: String },
    /// Estado incompatível com a operação (ex.: aprovar sem checkpoint).
    WrongStatus(String),
    /// Faltam evidências de side-effects obrigatórios (bloquear-e-refazer).
    MissingEvidence(Vec<String>),
    /// Há obrigações pós-aprovação pendentes — cumpra `confirm` antes.
    PendingObligations(Vec<String>),
}

impl std::fmt::Display for DriverError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::StageMismatch { expected, got } => {
                write!(f, "etapa '{got}' não é o cursor atual '{expected}'")
            }
            Self::WrongStatus(s) => write!(f, "operação inválida no status '{s}'"),
            Self::MissingEvidence(keys) => write!(
                f,
                "side-effects obrigatórios não comprovados (refaça e informe via --evidence): {}",
                keys.join(", ")
            ),
            Self::PendingObligations(keys) => write!(
                f,
                "há obrigações pós-aprovação pendentes — cumpra-as e rode `sdd orchestration confirm`: {}",
                keys.join(", ")
            ),
        }
    }
}

impl std::error::Error for DriverError {}

/// Verifica que todas as chaves exigidas estão presentes nas evidências (`k=v`).
fn missing_keys(required: &[String], evidence_keys: &[String]) -> Vec<String> {
    required
        .iter()
        .filter(|k| !evidence_keys.iter().any(|e| e == *k))
        .cloned()
        .collect()
}

/// Aplica o `done` de uma etapa: valida cursor, evidências obrigatórias e
/// avança o cursor (gate → AwaitingApproval; senão próxima etapa ou Completed).
/// O handler da CLI valida arquivo + `evaluate_stage` ANTES de chamar isto.
pub fn apply_done(
    st: &mut EngineState,
    stage: Stage,
    evidence_keys: &[String],
) -> Result<(), DriverError> {
    if !st.pending_obligations.is_empty() {
        return Err(DriverError::PendingObligations(
            st.pending_obligations.clone(),
        ));
    }
    if st.cursor != stage.key() {
        return Err(DriverError::StageMismatch {
            expected: st.cursor.clone(),
            got: stage.key().to_string(),
        });
    }
    if !matches!(
        st.status,
        EngineStatus::Orchestrating | EngineStatus::Queued
    ) {
        return Err(DriverError::WrongStatus(st.status.to_repr()));
    }
    let required: Vec<String> = required_done_evidence(stage, st.mode)
        .into_iter()
        .map(str::to_string)
        .collect();
    let missing = missing_keys(&required, evidence_keys);
    if !missing.is_empty() {
        return Err(DriverError::MissingEvidence(missing));
    }

    if is_interactive_gate(stage) {
        // Pausa no gate; cursor permanece até a decisão humana. Obrigações vêm no approve.
        st.status = EngineStatus::AwaitingApproval;
    } else {
        let next = guided_next(stage, st.mode);
        let (obl, col) = transition_obligations(stage, next, st.mode);
        st.pending_obligations = obl;
        st.pending_column = col;
        match next {
            Some(n) => {
                st.cursor = n.key().to_string();
                st.status = EngineStatus::Orchestrating;
            }
            None => {
                // No modo card, após memory concluída e obrigações cumpridas, entra
                // em ReadyForTesting: worktrees mergeados no branch card-id, aguardando
                // validação manual → ajuste (rework) ou PR final.
                if st.mode == OrchestrationMode::Card {
                    st.status = EngineStatus::ReadyForTesting;
                } else {
                    st.status = EngineStatus::Completed;
                }
            }
        }
    }
    Ok(())
}

/// Registra a aprovação de um gate e avança o cursor. Cria obrigações
/// pós-aprovação (publicar Confluence, mover coluna) no modo card.
pub fn apply_approve(
    st: &mut EngineState,
    gate: Stage,
    author: &str,
    ts: &str,
    reason: Option<String>,
) -> Result<(), DriverError> {
    if st.status != EngineStatus::AwaitingApproval {
        return Err(DriverError::WrongStatus(st.status.to_repr()));
    }
    if st.cursor != gate.key() {
        return Err(DriverError::StageMismatch {
            expected: st.cursor.clone(),
            got: gate.key().to_string(),
        });
    }
    st.approvals.push(Approval {
        slug: st.slug.clone(),
        gate: gate.key().to_string(),
        decision: DecisionKind::Approve,
        author: author.to_string(),
        channel: Channel::Cli,
        ts: ts.to_string(),
        reason,
    });
    let next = guided_next(gate, st.mode);
    let (obl, col) = transition_obligations(gate, next, st.mode);
    st.pending_obligations = obl;
    st.pending_column = col;
    match next {
        Some(n) => {
            st.cursor = n.key().to_string();
            st.status = EngineStatus::Orchestrating;
        }
        None => {
            if st.mode == OrchestrationMode::Card {
                st.status = EngineStatus::ReadyForTesting;
            } else {
                st.status = EngineStatus::Completed;
            }
        }
    }
    Ok(())
}

/// Registra a reprovação de um gate. Stage-aware:
/// - `reject(review)` → caminho de RETRABALHO: cursor volta para `execution`
///   (col3), com obrigação de documentar erro+solução (mini-refinement).
/// - `reject(prd|techspec|refinement)` → reabre o gate (cursor permanece) para
///   regeneração e limpa obrigações pendentes.
pub fn apply_reject(
    st: &mut EngineState,
    gate: Stage,
    author: &str,
    ts: &str,
    reason: Option<String>,
) -> Result<(), DriverError> {
    if st.status != EngineStatus::AwaitingApproval {
        return Err(DriverError::WrongStatus(st.status.to_repr()));
    }
    if st.cursor != gate.key() {
        return Err(DriverError::StageMismatch {
            expected: st.cursor.clone(),
            got: gate.key().to_string(),
        });
    }
    st.approvals.push(Approval {
        slug: st.slug.clone(),
        gate: gate.key().to_string(),
        decision: DecisionKind::Reject,
        author: author.to_string(),
        channel: Channel::Cli,
        ts: ts.to_string(),
        reason: reason.clone(),
    });
    if gate == Stage::Review {
        // Review reprovada → retrabalho a partir da execução.
        enter_rework(st, reason);
    } else {
        // Gate de planejamento → reabre a etapa para regenerar.
        st.pending_obligations.clear();
        st.pending_column = None;
        st.status = EngineStatus::Orchestrating;
    }
    Ok(())
}

/// Coloca o estado em modo de RETRABALHO: cursor → `execution` (col3), com
/// obrigação de documentar erro+solução antes de re-executar (modo card).
fn enter_rework(st: &mut EngineState, reason: Option<String>) {
    st.last_error = reason;
    st.cursor = Stage::Execution.key().to_string();
    st.status = EngineStatus::Orchestrating;
    if st.mode == OrchestrationMode::Card {
        st.pending_obligations = vec!["doc".to_string(), "column".to_string()];
        st.pending_column = Some(3);
    } else {
        st.pending_obligations.clear();
        st.pending_column = None;
    }
}

/// Dispara o retrabalho a partir de um erro detectado em qualquer etapa ACIMA da
/// coluna 2 (execution/review/memory). Volta o cursor para `execution`.
pub fn apply_rework(st: &mut EngineState, reason: Option<String>) -> Result<(), DriverError> {
    // Permite retrabalho também no status ReadyForTesting (usuário pediu ajustes
    // após validação manual no branch card-id).
    if st.status == EngineStatus::ReadyForTesting {
        enter_rework(st, reason);
        return Ok(());
    }
    let stage = Stage::from_key(&st.cursor)
        .ok_or_else(|| DriverError::WrongStatus(format!("cursor inválido: {}", st.cursor)))?;
    if stage_entry_column(stage) < 3 {
        return Err(DriverError::WrongStatus(format!(
            "rework só é válido acima da coluna 2; cursor está em '{}' (col {})",
            st.cursor,
            stage_entry_column(stage)
        )));
    }
    enter_rework(st, reason);
    Ok(())
}

/// Cumpre as obrigações pós-aprovação: exige evidência de todas as chaves
/// pendentes e as limpa. Bloqueia (block-and-redo) se faltar alguma.
pub fn apply_confirm(st: &mut EngineState, evidence_keys: &[String]) -> Result<(), DriverError> {
    let missing = missing_keys(&st.pending_obligations, evidence_keys);
    if !missing.is_empty() {
        return Err(DriverError::MissingEvidence(missing));
    }
    st.pending_obligations.clear();
    st.pending_column = None;
    Ok(())
}

/// Garante o modo/identidade no estado recém-criado (chamado pelo setup lazy).
/// No modo card, o cursor começa em `project-discovery` (não em `idea`): as etapas
/// Project Discovery e Risk Classification são obrigatórias antes da Idea.
pub fn seed_mode(
    st: &mut EngineState,
    mode: OrchestrationMode,
    branch_raiz: Option<String>,
    card_branch: Option<String>,
) {
    st.mode = mode;
    st.branch_raiz = branch_raiz;
    st.card_branch = card_branch;
    if mode == OrchestrationMode::Card && st.card.is_none() {
        st.card = Some(CardMeta::default());
    }
    // Cursor começa no preflight obrigatório — nos dois modos.
    if st.cursor == "idea" {
        st.cursor = Stage::ProjectDiscovery.key().to_string();
    }
    if st.status == EngineStatus::Queued {
        st.status = EngineStatus::Orchestrating;
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::orchestrator::state::EngineState;

    fn new_state(mode: OrchestrationMode) -> EngineState {
        let mut st = EngineState::new("dem-1").unwrap();
        let card_branch = if mode == OrchestrationMode::Card {
            Some("dem-1".to_string())
        } else {
            None
        };
        seed_mode(&mut st, mode, Some("main".to_string()), card_branch);
        st
    }

    fn ev(keys: &[&str]) -> Vec<String> {
        keys.iter().map(|s| s.to_string()).collect()
    }

    #[test]
    fn free_text_walks_idea_to_memory_pausing_at_gates() {
        let mut st = new_state(OrchestrationMode::FreeText);
        // Preflight obrigatório também em texto-livre: discovery → risk → idea.
        assert_eq!(st.cursor, "project-discovery");
        apply_done(&mut st, Stage::ProjectDiscovery, &[]).unwrap();
        assert_eq!(st.cursor, "risk-classification");
        apply_done(&mut st, Stage::RiskClassification, &[]).unwrap();
        assert_eq!(st.cursor, "idea");
        // idea (não gate) → avança para prd.
        apply_done(&mut st, Stage::Idea, &[]).unwrap();
        assert_eq!(st.cursor, "prd");
        // prd (gate) → awaiting.
        apply_done(&mut st, Stage::Prd, &[]).unwrap();
        assert_eq!(st.status, EngineStatus::AwaitingApproval);
        // texto-livre não cria obrigações pós-aprovação.
        apply_approve(&mut st, Stage::Prd, "cli", "t0", None).unwrap();
        assert!(st.pending_obligations.is_empty());
        assert_eq!(st.cursor, "techspec");
    }

    #[test]
    fn full_free_text_flow_reaches_completed() {
        let mut st = new_state(OrchestrationMode::FreeText);
        let gates = ["prd", "techspec", "refinement", "review", "qa"];
        loop {
            let d = next_directive(&st);
            match d.kind {
                DirectiveKind::Completed => break,
                DirectiveKind::Checkpoint => {
                    let stage = Stage::from_key(&st.cursor).unwrap();
                    apply_approve(&mut st, stage, "cli", "t0", None).unwrap();
                }
                DirectiveKind::GenerateStage => {
                    let stage = Stage::from_key(&st.cursor).unwrap();
                    apply_done(&mut st, stage, &[]).unwrap();
                }
                DirectiveKind::Obligations => unreachable!("texto-livre não tem obrigações"),
                DirectiveKind::TestingFeedback => {
                    unreachable!("texto-livre não tem testing feedback")
                }
            }
        }
        assert_eq!(st.status, EngineStatus::Completed);
        // Quatro aprovações nos quatro gates.
        assert_eq!(st.approvals.len(), gates.len());
    }

    /// Avança pelo preflight obrigatório (project-discovery → risk-classification → idea)
    /// e confirma a obrigação de coluna (col1). Deixa o cursor em `prd`.
    fn card_through_idea(st: &mut EngineState) {
        // project-discovery e risk-classification não têm evidência obrigatória.
        assert_eq!(st.cursor, "project-discovery");
        apply_done(st, Stage::ProjectDiscovery, &[]).unwrap();
        assert_eq!(st.cursor, "risk-classification");
        apply_done(st, Stage::RiskClassification, &[]).unwrap();
        assert_eq!(st.cursor, "idea");
        apply_done(st, Stage::Idea, &ev(&["description", "assignee", "dates"])).unwrap();
        // Idea cria obrigação de mover para a col1.
        assert_eq!(st.pending_column, Some(1));
        apply_confirm(st, &ev(&["column"])).unwrap();
        assert_eq!(st.cursor, "prd");
    }

    #[test]
    fn card_idea_requires_description_assignee_dates() {
        let mut st = new_state(OrchestrationMode::Card);
        // Avança pelo preflight obrigatório antes da Idea.
        apply_done(&mut st, Stage::ProjectDiscovery, &[]).unwrap();
        apply_done(&mut st, Stage::RiskClassification, &[]).unwrap();
        assert_eq!(st.cursor, "idea");
        // Sem todas as evidências → bloqueia.
        let err = apply_done(&mut st, Stage::Idea, &ev(&["description"])).unwrap_err();
        assert_eq!(
            err,
            DriverError::MissingEvidence(ev(&["assignee", "dates"]))
        );
        // Com todas → avança e exige mover col1.
        apply_done(
            &mut st,
            Stage::Idea,
            &ev(&["description", "assignee", "dates"]),
        )
        .unwrap();
        assert_eq!(st.cursor, "prd");
        assert_eq!(st.pending_column, Some(1));
    }

    #[test]
    fn card_columns_follow_stage_map() {
        let mut st = new_state(OrchestrationMode::Card);
        card_through_idea(&mut st); // cursor=prd, col1 confirmado
        apply_done(&mut st, Stage::Prd, &[]).unwrap(); // prd é gate → awaiting
        apply_approve(&mut st, Stage::Prd, "cli", "t0", None).unwrap();
        // PRD aprovado: publica Confluence + move para col2 (techspec).
        assert_eq!(st.pending_obligations, ev(&["confluence", "column"]));
        assert_eq!(st.pending_column, Some(2));
        // Bloqueia o próximo done enquanto houver obrigação.
        assert!(matches!(
            apply_done(&mut st, Stage::Techspec, &[]).unwrap_err(),
            DriverError::PendingObligations(_)
        ));
        apply_confirm(&mut st, &ev(&["confluence", "column"])).unwrap();
        assert!(st.pending_column.is_none());
        // TechSpec aprovado: publica Confluence; col2 mantém (techspec→tasks).
        apply_done(&mut st, Stage::Techspec, &[]).unwrap();
        apply_approve(&mut st, Stage::Techspec, "cli", "t1", None).unwrap();
        assert_eq!(st.pending_obligations, ev(&["confluence"]));
        assert!(st.pending_column.is_none());
        apply_confirm(&mut st, &ev(&["confluence"])).unwrap();
        // Tasks (não gate) → refinement, ambos col2: sem move.
        apply_done(&mut st, Stage::Tasks, &[]).unwrap();
        assert!(st.pending_column.is_none());
        assert_eq!(st.cursor, "refinement");
        // Refinement é gate: done pausa, approve avança → execution: move para col3.
        apply_done(&mut st, Stage::Refinement, &[]).unwrap();
        apply_approve(&mut st, Stage::Refinement, "cli", "t2", None).unwrap();
        assert_eq!(st.pending_column, Some(3));
        apply_confirm(&mut st, &ev(&["column"])).unwrap();
        // Execution (não gate) → review: move para col4.
        apply_done(&mut st, Stage::Execution, &[]).unwrap();
        assert_eq!(st.pending_column, Some(4));
        apply_confirm(&mut st, &ev(&["column"])).unwrap();
        // Review é gate: done pausa, approve → qa: move para col5 (testes).
        apply_done(&mut st, Stage::Review, &[]).unwrap();
        apply_approve(&mut st, Stage::Review, "cli", "t3", None).unwrap();
        assert_eq!(st.pending_obligations, ev(&["column"]));
        assert_eq!(st.pending_column, Some(5));
        apply_confirm(&mut st, &ev(&["column"])).unwrap();
        assert_eq!(st.cursor, "qa");
        // QA é gate: done pausa, approve → memory: move para col6.
        apply_done(&mut st, Stage::Qa, &[]).unwrap();
        apply_approve(&mut st, Stage::Qa, "cli", "t4", None).unwrap();
        assert_eq!(st.pending_obligations, ev(&["column"]));
        assert_eq!(st.pending_column, Some(6));
        apply_confirm(&mut st, &ev(&["column"])).unwrap();
        assert_eq!(st.cursor, "memory");
        // Memory done → publica Confluence + merge worktrees + col6 (terminal).
        // Após obrigações cumpridas, status = ReadyForTesting (não Completed) no modo card.
        apply_done(&mut st, Stage::Memory, &[]).unwrap();
        assert_eq!(
            st.pending_obligations,
            ev(&["confluence", "merge", "column"])
        );
        assert_eq!(st.pending_column, Some(6));
    }

    #[test]
    fn reject_reopens_planning_gate_and_clears_obligations() {
        let mut st = new_state(OrchestrationMode::Card);
        card_through_idea(&mut st); // percorre discovery → risk → idea → prd
        apply_done(&mut st, Stage::Prd, &[]).unwrap();
        apply_reject(
            &mut st,
            Stage::Prd,
            "cli",
            "t0",
            Some("escopo amplo".into()),
        )
        .unwrap();
        assert_eq!(st.status, EngineStatus::Orchestrating);
        assert_eq!(st.cursor, "prd");
        assert!(st.pending_obligations.is_empty());
        assert!(st.pending_column.is_none());
    }

    #[test]
    fn rework_only_above_column_2() {
        let mut st = new_state(OrchestrationMode::Card);
        // No cursor inicial (idea, col1) o rework é inválido.
        assert!(matches!(
            apply_rework(&mut st, Some("erro".into())).unwrap_err(),
            DriverError::WrongStatus(_)
        ));
        // Em execution (col3) é válido e mantém o cursor lá com obrigação de doc.
        st.cursor = "execution".to_string();
        apply_rework(&mut st, Some("teste falhou".into())).unwrap();
        assert_eq!(st.cursor, "execution");
        assert_eq!(st.pending_obligations, ev(&["doc", "column"]));
        assert_eq!(st.pending_column, Some(3));
        assert_eq!(st.last_error.as_deref(), Some("teste falhou"));
    }

    #[test]
    fn reject_review_enters_rework_at_execution() {
        let mut st = new_state(OrchestrationMode::Card);
        st.cursor = "review".to_string();
        st.status = EngineStatus::AwaitingApproval;
        apply_reject(
            &mut st,
            Stage::Review,
            "cli",
            "t0",
            Some("bug na borda".into()),
        )
        .unwrap();
        assert_eq!(st.cursor, "execution");
        assert_eq!(st.status, EngineStatus::Orchestrating);
        assert_eq!(st.pending_obligations, ev(&["doc", "column"]));
        assert_eq!(st.pending_column, Some(3));
    }

    #[test]
    fn done_rejects_wrong_stage() {
        let mut st = new_state(OrchestrationMode::FreeText);
        let err = apply_done(&mut st, Stage::Prd, &[]).unwrap_err();
        assert!(matches!(err, DriverError::StageMismatch { .. }));
    }

    #[test]
    fn approve_requires_awaiting_status() {
        let mut st = new_state(OrchestrationMode::FreeText);
        let err = apply_approve(&mut st, Stage::Idea, "cli", "t0", None).unwrap_err();
        assert!(matches!(err, DriverError::WrongStatus(_)));
    }
}