"""
Project Together — powered by AI: CONFERENCIAS DE COMPLETUDE, passo a passo.

A REVISAO do plano e' responsabilidade do ALUNO — sempre. O que este modulo faz e'
AJUDAR: cada passo fecha CONFERINDO o que ele acabou de escrever, e o Passo 5 (aprovar)
junta as conferencias dos passos anteriores. O que e' MECANICO (ha entrega para cada KR?
todo risco tem prob/impacto/resposta/dono? todo pacote tem data e responsavel? o
orcamento fecha?) o python confere melhor que uma IA — uma IA que so' recebe os TITULOS
dos cartoes acusa buracos que nao existem (datas/donos que estao la) e inventa numeros;
o python le o cartao inteiro e responde com certeza, sem alucinar.

A REGRA (Eduardo): so' entra aqui o que da' para conferir DE FORMA DETERMINISTICA. O que
depende de julgamento (a resposta a um risco e' boa o bastante? o prazo e' realista?
falta algo que NINGUEM pos no quadro?) e as ACOES fora do quadro (agendar o kick-off,
enviar o e-mail a cada membro) continuam sendo do aluno — a maquina AJUDA, nao substitui.
Fingir uma conferencia que o quadro nao sustenta seria pior que nao conferir (vira ruido).

As verificacoes reusam os MESMOS leitores que geram os documentos (verificar_okrs,
arvore_da_eap, riscos_do_quadro, orcamento_do_quadro) — a auditoria concorda, cartao a
cartao, com o que o Plano de Gerenciamento mostra.

Niveis:
  ✗ LACUNA  — resolva antes de aprovar (algo que deveria estar no plano e nao esta);
  • ATENCAO — vale rever, mas nao trava a aprovacao (ex.: horas de pessoas nao estimadas).

Dois pontos de entrada:
  checar(n, ids, abordagem)  -> as conferencias do PASSO n (cada script de passo a roda);
  auditar_plano(ids, abordagem) -> "todos os checklists anteriores" (Passos 1 a 4) — o que
                                   o Passo 5 revisa e o verificar_projeto mostra.
"""
import os
import re

import entradas
import quadro
import team_check
import verificar_okrs
import vocabulario
from pa_client import list_cards
from quadro import nivel_1a5
from idioma import T, Tf

LACUNA, ATENCAO, OK = "lacuna", "atencao", "ok"

# A maquina AJUDA, a responsabilidade e' do aluno (ver feedback_machine_assists_student_
# responsible): toda lista de conferencias automatica leva um convite de feedback. Nao
# hard-codamos link/numero — toda pagina do site ja tem o balao de WhatsApp.
CONVITE = T("Achou algo que ainda não conferimos — ou que dava para conferir melhor? "
            "Fale com a gente pelo WhatsApp (o balão no canto do site) e a gente inclui.")

PASSO_ROTULO = {
    1: T("OKR, entregas e requisitos"),
    2: T("escopo (EAP)"),
    3: T("riscos e premissas"),
    4: T("cronograma e orçamento"),
    5: T("revisão do plano (checklists 1 a 4 + linha de base)"),
    6: T("engajamento das partes"),
    7: T("execução e kick-off"),
    8: T("status report"),
    9: T("mudanças"),
    10: T("encerramento e valor"),
}


def _achado(nivel, resumo, acao="", cartoes=None, lista=None):
    """Um achado. `cartoes` = os IDs dos cartões AOS QUAIS o achado se refere (para a
    auditoria MARCAR no quadro); `lista` = a chave da lista onde eles moram (RISCOS,
    ESCOPO, ORCAMENTO, PARTES, MUDANCAS). Achado sem cartão (ex.: OKR mal-formado, que
    mora num cartão só do termo) fica com `cartoes` vazio — vira texto, não marca."""
    return {"nivel": nivel, "resumo": resumo, "acao": acao,
            "cartoes": [str(c) for c in (cartoes or []) if c], "lista": lista}


def _curto(texto, n=70):
    t = re.sub(r"\s+", " ", (texto or "").strip())
    return t if len(t) <= n else t[: n - 1].rstrip() + "…"


# Teto de itens quando o que se lista sao CODIGOS da EAP ("1.4.7"): cabem muitos numa
# linha, e o aluno precisa dos codigos TODOS para corrigir todos. Para nomes (entrega,
# risco) o teto continua sendo seis — la' cada item e' uma frase.
_CODIGOS_EAP = 40


def _lista(nomes, n=6):
    """'a, b, c…' com teto, para o achado nao virar um paragrafo."""
    nomes = [x for x in nomes if x]
    corte = ", ".join(nomes[:n]) + ("…" if len(nomes) > n else "")
    return corte


def _links(cartoes, n=3):
    """Os LINKS diretos dos cartões do achado — abre o cartão certo em um clique.

    Nasceu de um defeito: em 23/07 o filtro por etiqueta do quadro não mostrava nada
    (Eduardo: 'eu filtro e não aparece nada') porque a listagem de cartões da API vinha sem
    etiqueta — o Henry corrigiu isso em 30/07 (item 0e). O link FICA, e não por desconfiança
    do filtro: ele leva ao cartão exato do achado em um clique, enquanto o filtro devolve
    um monte para procurar dentro."""
    ids = [str(c) for c in (cartoes or []) if c]
    if not ids:
        return ""
    try:
        from env import cfg
        board = (cfg().get("BOARD_ID") or "").strip()
    except Exception:
        board = ""
    if not board:
        return ""
    url = f"https://projectadm.com/quadros/{board}?cartao="
    return "  ·  ".join(url + i for i in ids[:n]) + ("  ·  …" if len(ids) > n else "")


# ─────────────────────────────────────────────────────────────────────────────
# CACHE por auditoria: a arvore da EAP custa 1 GET por cartao. Quando o Passo 5
# junta P2 + P4, lê a arvore UMA vez so' (nao duas).
# ─────────────────────────────────────────────────────────────────────────────
def _arvore(ids, dados):
    if dados.get("arvore") is None:
        dados["arvore"] = quadro.arvore_da_eap(ids)
    return dados["arvore"]


def _avaliar(ids, dados):
    if dados.get("okr") is None:
        dados["okr"] = verificar_okrs.avaliar(ids)
    return dados["okr"]


def _equipe_nomes(ids, dados):
    """Os nomes dos membros da EQUIPE (a lista fechada de quem pode ser responsavel por
    trabalho), lidos UMA vez por auditoria. Mesma leitura que o Passo 4 usa na entrada."""
    if dados.get("equipe") is None:
        dados["equipe"] = team_check.roster(ids)
    return dados["equipe"]


# UMA DEFINICAO SO' de "ser da equipe", e ela mora no team_check — o guarda que o Passo 4
# roda na ENTRADA, antes de gravar. Duas definicoes que discordassem seriam pior que
# nenhuma: o passo aceitaria um dono que o relatorio depois cobra (ou o contrario).
# Aqui ficam so' os apelidos, para o resto do arquivo continuar lendo como sempre leu.
_resp_pendente = team_check.e_pendente
_e_do_time = team_check.e_da_equipe


def _mapa_entregas_escopo(ids, dados):
    """{nome_da_entrega (sem acento) -> id do cartão no ESCOPO}. Para MARCAR a 'entrega'
    (o cartão que o aluno vê) quando ela é órfã ou está sem requisito. Vazio antes do
    Passo 2 (a entrega ainda não virou cartão)."""
    if not ids.get("ESCOPO"):
        return {}
    nos, _ = _arvore(ids, dados)
    return {entradas._sem_acento(n["titulo"]): n["id"] for n in nos if n["tipo"] == "entrega"}


def _termo_cartoes(ids, dados):
    """{prefixo do 5W2H (ex.: '1.2', '2.1') -> id do cartão na lista INPUTS}.

    Antes do Passo 2 a entrega e o OKR ainda NÃO são cartões próprios — moram cada um
    num cartão do Termo de Abertura (1.2 o OKR, 2.1 as entregas). Quando uma conferência
    do Passo 1 aponta um buraco neles, o cartão A REVISAR é esse cartão do termo — senão
    a auditoria acha o problema e não marca lugar nenhum (Eduardo, board 266, 2026-07-24:
    'encontrou os erros e não marcou nenhum cartão')."""
    if dados.get("termo") is None:
        m = {}
        if ids.get("INPUTS"):
            for titulo, cid in quadro.cards_do_termo_ids(ids).items():
                mt = re.match(r"^(\d+(?:\.\d+)?)", (titulo or "").strip())
                if mt:
                    m[mt.group(1)] = cid
        dados["termo"] = m
    return dados["termo"]


def _alvo_entregas(nomes, ids, dados):
    """Os cartões a MARCAR quando a queixa é sobre entregas — e em que lista eles moram.

    Passo 2+: os itens do ESCOPO (o cartão que o aluno mexe). Passo 1 (ESCOPO ainda
    vazio): o próprio cartão 2.1 do Termo de Abertura. Assim a marca acompanha onde a
    entrega vive em cada etapa, sem ficar presa no 2.1 depois que ela vira backlog."""
    cards = _cartoes_por_nome(nomes, _mapa_entregas_escopo(ids, dados))
    if cards:
        return cards, "ESCOPO"
    c21 = _termo_cartoes(ids, dados).get("2.1")
    return ([c21] if c21 else []), "INPUTS"


def _cartoes_por_nome(nomes, mapa):
    """Os ids de cartão que casam com uma lista de nomes (exato, depois por conteúdo)."""
    out = []
    for nm in nomes:
        k = entradas._sem_acento(nm or "")
        cid = mapa.get(k) or next((v for kk, v in mapa.items()
                                   if kk and len(kk) >= 4 and (kk in k or k in kk)), None)
        if cid:
            out.append(cid)
    return out


# ─────────────────────────────────────────────────────────────────────────────
# PASSO 1 — OKR, entregas e requisitos
# ─────────────────────────────────────────────────────────────────────────────
def _p1(ids, abordagem, dados):
    achados = []
    av = _avaliar(ids, dados)
    # O OKR e as entregas do Passo 1 moram em cartões do Termo de Abertura (1.2 e 2.1).
    # É neles que a marca 🔎 Revisar tem de cair — o cartão que o aluno abre para corrigir.
    termo = _termo_cartoes(ids, dados)
    c12 = [termo["1.2"]] if termo.get("1.2") else []
    c21 = [termo["2.1"]] if termo.get("2.1") else []
    if not av["tem_okr"]:
        achados.append(_achado(LACUNA, T("Não há OKR no cartão 1.2 (POR QUÊ)."),
                               T("Rode o Passo 1 — é ele que transforma a justificativa em OKR."),
                               cartoes=c12, lista="INPUTS"))
        return achados

    # Todo objetivo com 2 a 5 KRs (forma). Um objetivo sem KR nenhum tambem cai aqui.
    for i, o in enumerate(av["objetivos"], 1):
        if o["forma"]:
            achados.append(_achado(LACUNA, Tf("Objetivo {0}: {1}.", i, o['forma']),
                                   T("Ajuste o cartão 1.2 (2 a 5 Key Results por objetivo)."),
                                   cartoes=c12, lista="INPUTS"))
    # Todo KR com meta mensuravel (numero + unidade).
    sem_meta = [k["numero"] for o in av["objetivos"] for k in o["krs"]
                if "sem meta" in k["faltas"] or "meta sem numero" in k["faltas"]]
    if sem_meta:
        achados.append(_achado(LACUNA, Tf("KR sem meta mensurável (número + unidade): {0}.", _lista(sem_meta)),
                               T("Sem número, ninguém prova o KR no Passo 10."),
                               cartoes=c12, lista="INPUTS"))
    # Todo KR com ao menos uma entrega que o alcanca (cobre "todo objetivo tem entrega":
    # um objetivo sem entrega e' um objetivo com todos os KRs orfaos). O buraco pode estar
    # no 1.2 (KR que sobra) OU no 2.1 (entrega que falta) — marca os dois.
    orfaos = [k["numero"] for o in av["objetivos"] for k in o["krs"]
              if "sem entrega que o alcance" in k["faltas"]]
    if orfaos and av["tem_entregas"]:
        achados.append(_achado(LACUNA, Tf("Key Result sem nenhuma entrega que o alcance: {0}.", _lista(orfaos)),
                               T("Ou falta entrega (cartão 2.1), ou o KR não era deste projeto."),
                               cartoes=c12 + c21, lista="INPUTS"))
    if av["orfas"]:
        nomes = [e.get("nome") for e in av["orfas"]]
        cartoes, lista = _alvo_entregas(nomes, ids, dados)
        achados.append(_achado(ATENCAO, Tf("{0} entrega(s) não servem a nenhum KR.", len(av['orfas'])),
                               T("Diga a que KR cada uma serve (cartão 2.1) ou tire-as do escopo."),
                               cartoes=cartoes, lista=lista))
    prov = [k["numero"] for o in av["objetivos"] for k in o["krs"] if k.get("provisoria")]
    if prov:
        achados.append(_achado(ATENCAO, Tf("Meta(s) provisória(s) a confirmar: {0}.", _lista(prov)),
                               T("Troque 'meta provisória' por 'meta' quando tiver o dado real."),
                               cartoes=c12, lista="INPUTS"))

    # Cada entrega com pelo menos um requisito. O requisito mora no cartao 2.2 (o 2.1 so'
    # guarda nome + 'serve ao KR:'); cruzamos as entregas por NOME. Sem isto a conferencia
    # acusava TODA entrega como "sem requisito" mesmo com o 2.2 preenchido, porque lia o
    # requisito do cartao errado (Eduardo, board 266, 2026-07-24: '9 entregas sem requisito'
    # depois de ele ja ter preenchido o 2.2).
    respostas = quadro.respostas_do_termo(ids)
    entregas = entradas.entregas(respostas.get("2.1", "")) if respostas.get("2.1") else []
    req22 = {entradas._sem_acento(e2.get("nome", "")): (e2.get("requisito") or "").strip()
             for e2 in entradas.entregas(respostas.get("2.2", ""))} if respostas.get("2.2") else {}

    def _tem_requisito(e):
        if (e.get("requisito") or "").strip():
            return True                                    # requisito inline no proprio 2.1
        chave = entradas._sem_acento(e.get("nome", ""))
        if req22.get(chave):
            return True                                    # requisito no 2.2, casado por nome
        return any(v and len(k) >= 4 and (k in chave or chave in k)   # nome aproximado
                   for k, v in req22.items())

    sem_req = [e["nome"] for e in entregas if not _tem_requisito(e)]
    if sem_req:
        cartoes, lista = _alvo_entregas(sem_req, ids, dados)
        achados.append(_achado(ATENCAO, Tf("{0} entrega(s) sem requisito: {1}.", len(sem_req), _lista([_curto(x, 32) for x in sem_req])),
                               T("Cada entrega atende a pelo menos um requisito (cartão 2.2)."),
                               cartoes=cartoes, lista=lista))
    return achados


# ─────────────────────────────────────────────────────────────────────────────
# PASSO 2 — escopo (EAP na preditiva)
# ─────────────────────────────────────────────────────────────────────────────
def _p2(ids, abordagem, dados):
    achados = []
    if (abordagem or "").upper() == "AGIL":
        # Agil: o escopo e' o Product Backlog (plano, sem EAP). Basta existir — mas o
        # backlog ESVAZIA conforme o trabalho anda: a Sprint Planning (Passo 5) move itens
        # para o Sprint Backlog e a Review (Passo 8) os move para o Incremento. Contar so'
        # a lista de origem acusaria "backlog vazio" justamente no projeto que esta indo
        # bem. O escopo do agil e' a soma das tres listas do fluxo.
        escopo = []
        for chave in ("ESCOPO", "EXECUCAO", "PRONTO"):
            lid = ids.get(chave)
            if not lid:
                continue
            escopo += [c for c in list_cards(lid)
                       if (c.get("titulo") or "").strip()
                       and not quadro.e_guia(c.get("titulo"))
                       and not (c.get("titulo") or "").startswith(
                           (quadro.META_PREFIXO, quadro.META_PRODUTO_PREFIXO))]
        if not escopo:
            achados.append(_achado(LACUNA, T("O Product Backlog ainda está vazio."),
                                   T("Rode o Passo 2 para escrever os itens do backlog.")))
        # O COMPROMISSO do Product Backlog. Pelo Guia Scrum cada artefato tem o seu:
        # Product Backlog -> Meta do Produto, Sprint Backlog -> Meta da Sprint,
        # Incremento -> Definicao de Pronto. O curso tinha os dois ultimos e nao tinha
        # este (Eduardo, 2026-08-09) — um backlog sem meta nao tem contra o que ser
        # ordenado, e o Passo 10 nao tem contra o que declarar o projeto encerrado.
        if not quadro.cartao_da_meta_do_produto(ids):
            achados.append(_achado(
                LACUNA, T("O Product Backlog não tem META DO PRODUTO."),
                T("A Meta do Produto é o compromisso do Product Backlog (Guia Scrum): o "
                  "estado que, atingido, encerra o projeto. Rode o Passo 2 e cole a "
                  "resposta com a linha META DO PRODUTO:, ou escreva o cartão você.")))
        return achados

    nos, tem_fases = _arvore(ids, dados)
    if not nos:
        achados.append(_achado(LACUNA, T("O escopo (EAP) ainda não existe."),
                               T("Rode o Passo 2 para decompor as entregas em pacotes.")))
        return achados

    # Todas as entregas do cartao 2.1 estao distribuidas na EAP.
    respostas = quadro.respostas_do_termo(ids)
    entregas = entradas.entregas(respostas.get("2.1", "")) if respostas.get("2.1") else []
    nos_norm = [entradas._sem_acento(n["titulo"]) for n in nos]

    def _na_eap(nome):
        a = entradas._sem_acento(nome)
        return bool(a) and any(a == n or a in n or n in a for n in nos_norm)

    fora = [e["nome"] for e in entregas if e["nome"] and not _na_eap(e["nome"])]
    if fora:
        achados.append(_achado(LACUNA, Tf("{0} entrega(s) do cartão 2.1 não aparecem na EAP: "
                                          "{1}.", len(fora), _lista([_curto(x, 32) for x in fora])),
                               T("100% do escopo: toda entrega do termo é decomposta na EAP (Passo 2).")))

    # ELEMENTO SOLTO na raiz da EAP: um nó de 1º nível SEM NADA DENTRO. Ele escapa de
    # TODAS as outras conferências, porque elas cobram quem é 'pacote' (data, dono,
    # critério de aceitação) — e um cartão sem pai é lido como fase/entrega. Foi assim que
    # o pacote esquecido, criado pelo Passo 4, sumia da auditoria (Eduardo, 2026-07-29).
    # Só acusamos quando é MESMO anomalia: numa EAP com fases, uma "fase" vazia; ou um
    # cartão marcado como descoberto no Passo 4. Numa EAP de dois níveis, uma entrega sem
    # pacotes é outra história — é decomposição que falta, e a conferência seguinte já diz.
    codigos = [n["codigo"] for n in nos]

    def _sem_filhos(n):
        return not any(c.startswith(n["codigo"] + ".") for c in codigos)

    soltos = [n for n in nos
              if n["nivel"] == 1 and _sem_filhos(n) and (tem_fases or n.get("novo_p4"))]
    if soltos:
        do_p4 = sum(1 for n in soltos if n.get("novo_p4"))
        achados.append(_achado(
            ATENCAO,
            Tf("{0} elemento(s) solto(s) na raiz da EAP: {1}.", len(soltos), _lista([_curto(n['titulo'], 32) for n in soltos])),
            T("Todo trabalho mora DENTRO de uma entrega — no ProjectAdm, defina o cartão-pai "
              "(a entrega a que ele pertence). Na raiz, sem nada dentro, ele é um pacote de "
              "trabalho pendurado no topo da EAP: aparece no cronograma sem pertencer a "
              "entrega nenhuma, e ninguém sabe a que resultado ele serve.")
            + (T(" Apareceu(ram) no Passo 4, ao estimar o cronograma.")
               if do_p4 else ""),
            cartoes=[n["id"] for n in soltos], lista="ESCOPO"))

    # Cada entrega da EAP decomposta em pacotes (desejado); e cada pacote com aceitacao.
    soltos_ids = {str(n["id"]) for n in soltos}
    entregas_nos = [n for n in nos
                    if n["tipo"] == "entrega" and str(n["id"]) not in soltos_ids]
    # uma entrega "tem pacote" se algum pacote a tem como pai — reconstroi pela ordem
    # (o codigo do pacote comeca com o codigo da entrega): 1.2 -> 1.2.1
    codigos_pacote = [n["codigo"] for n in nos if n["tipo"] == "pacote"]
    sem_pacote = [n for n in entregas_nos
                  if not any(cod.startswith(n["codigo"] + ".") for cod in codigos_pacote)]
    if sem_pacote:
        achados.append(_achado(ATENCAO, Tf("{0} entrega(s) da EAP sem pacotes de trabalho: {1}.", len(sem_pacote), _lista([_curto(n['titulo'], 32) for n in sem_pacote])),
                               T("Decomponha cada entrega em pacotes — o menor pedaço que dá para estimar e verificar."),
                               cartoes=[n["id"] for n in sem_pacote], lista="ESCOPO"))
    sem_aceit = [n for n in nos
                 if n["tipo"] == "pacote" and not (n.get("aceitacao") or "").strip()]
    if sem_aceit:
        achados.append(_achado(ATENCAO, Tf("{0} pacote(s) sem critério de aceitação: {1}.", len(sem_aceit), _lista([n['codigo'] for n in sem_aceit])),
                               T("Sem critério de aceitação não dá para dizer que o pacote ficou pronto (Passo 2)."),
                               cartoes=[n["id"] for n in sem_aceit], lista="ESCOPO"))
    return achados


# ─────────────────────────────────────────────────────────────────────────────
# PASSO 3 — riscos e premissas
# ─────────────────────────────────────────────────────────────────────────────
def _p3(ids, abordagem, dados):
    achados = []
    riscos = quadro.riscos_do_quadro(ids)
    if not riscos:
        achados.append(_achado(LACUNA, T("Ainda não há riscos nem premissas no quadro."),
                               T("Rode o Passo 3 — todo projeto tem incerteza a registrar.")))
        return achados
    sem_aval, sem_resp_dono, prem_sem_dono = [], [], []
    for r in riscos:
        if vocabulario.avaliavel(r["tipo"]):
            if nivel_1a5(r["prob_ou_urgencia"]) is None or nivel_1a5(r["impacto"]) is None:
                sem_aval.append(r["id"])
            if not ((r["resposta"] or "").strip() and (r["dono"] or "").strip()):
                sem_resp_dono.append(r["id"])
        elif r["tipo"] == "Premissa":
            dono = (r["dono"] or "").strip().lower()
            if not dono or "confirmar" in dono:
                prem_sem_dono.append(r["id"])
    if sem_aval:
        achados.append(_achado(LACUNA, Tf("{0} risco(s)/oportunidade(s) sem avaliação "
                                          "(probabilidade × impacto → sem prioridade).", len(sem_aval)),
                               T("Atribua probabilidade e impacto a cada um (Passo 3) — inclusive às oportunidades."),
                               cartoes=sem_aval, lista="RISCOS"))
    if sem_resp_dono:
        achados.append(_achado(LACUNA, Tf("{0} risco(s) sem resposta e/ou dono.", len(sem_resp_dono)),
                               T("Todo risco grave tem uma resposta e um responsável (Passo 3)."),
                               cartoes=sem_resp_dono, lista="RISCOS"))
    if prem_sem_dono:
        achados.append(_achado(ATENCAO, Tf("{0} premissa(s) sem quem as confirme.", len(prem_sem_dono)),
                               T("Vale nomear quem monitora cada premissa — se ela cair, alguém precisa perceber."),
                               cartoes=prem_sem_dono, lista="RISCOS"))
    return achados


# ─────────────────────────────────────────────────────────────────────────────
# PASSO 4 — cronograma e orçamento
# ─────────────────────────────────────────────────────────────────────────────
def _p4(ids, abordagem, dados):
    achados = []
    if (abordagem or "").upper() != "AGIL":
        nos, _tf = _arvore(ids, dados)
        if not nos:
            achados.append(_achado(LACUNA, T("O escopo (EAP) ainda não existe — sem ele não há cronograma."),
                                   T("Rode o Passo 2 e depois o Passo 4.")))
        else:
            pacotes = [n for n in nos if n["tipo"] == "pacote"]
            # data no PACOTE (a folha datada no Passo 4). Entrega/fase herdam por rollup —
            # checar todos os nós acusaria a entrega e o pacote pela mesma falta (redundante).
            sem_data = [n for n in pacotes if not (n["ini"] and n["fim"])]
            # Dono do pacote = um membro ATRIBUIDO (o sinal real) ou o texto "responsável:".
            # '[definir]'/'[to define]' conta como SEM dono (nao como dono estranho) — senao
            # o pacote escapava dos dois checklists e ficava orfao em silencio.
            sem_resp = [n for n in pacotes
                        if not n.get("membros") and _resp_pendente(n.get("responsavel"))]
            equipe = _equipe_nomes(ids, dados)
            fora_time = [n for n in pacotes
                         if not n.get("membros") and not _resp_pendente(n.get("responsavel"))
                         and equipe and not _e_do_time(n.get("responsavel"), equipe)]
            # OS CODIGOS DA EAP SAIEM TODOS (`_CODIGOS_EAP`), nao os seis primeiros. O teto
            # de seis existe para nome de entrega (texto longo); para "1.4.7" ele so' escondia
            # trabalho: com 13 pacotes acusados o aluno lia "1.1.4, 1.2.3… " e nao tinha como
            # saber quais eram os outros sete (Eduardo, quadro 324, 06/08/2026).
            if sem_data:
                achados.append(_achado(LACUNA, Tf("{0} item(ns) da EAP sem data início/fim: "
                                                  "{1}.", len(sem_data), _lista([n['codigo'] for n in sem_data], _CODIGOS_EAP)),
                                       T("Rode o Passo 4 para datar o cronograma."),
                                       cartoes=[n["id"] for n in sem_data], lista="ESCOPO"))
            if sem_resp:
                achados.append(_achado(LACUNA, Tf("{0} pacote(s) sem responsável: {1}.", len(sem_resp), _lista([n['codigo'] for n in sem_resp], _CODIGOS_EAP)),
                                       T("Todo pacote tem um dono — um membro da equipe (Passo 4)."),
                                       cartoes=[n["id"] for n in sem_resp], lista="ESCOPO"))
            if fora_time:
                # QUEM E' O ESTRANHO, e nao so' quantos pacotes. Sem o nome, o aluno le' "13
                # pacotes com responsável fora da equipe" e nao sabe se corrigiu ou nao — foi
                # exatamente o que aconteceu no quadro 324: a IA inventou 'Ana', o Eduardo
                # arrumou OUTROS pacotes e o aviso continuou igual, sem nunca dizer 'Ana'.
                estranhos = _lista(sorted({(n.get("responsavel") or "").strip()
                                           for n in fora_time if (n.get("responsavel") or "").strip()}), 4)
                achados.append(_achado(ATENCAO, Tf("{0} pacote(s) com responsável fora da "
                                                   "equipe — {1}: {2}.", len(fora_time), estranhos,
                                                   _lista([n['codigo'] for n in fora_time], _CODIGOS_EAP)),
                                       T("O responsável de um pacote é sempre um membro da equipe (uma parte interessada do grupo 'equipe')."),
                                       cartoes=[n["id"] for n in fora_time], lista="ESCOPO"))

    # Orcamento (as duas abordagens): total, reserva, custo de pessoas.
    orc = [o for o in quadro.orcamento_do_quadro(ids)
           if not entradas._sem_acento(o["nome"]).startswith("analise comparativa")]
    if not orc:
        achados.append(_achado(LACUNA, T("O orçamento ainda não existe."),
                               T("Rode o Passo 4 para estimar os custos.")))
    else:
        tem_reserva = any(x in (o["tipo"] or "").lower() for o in orc
                          for x in ("reserva", "reserve", "conting"))
        pessoas_zero = [o for o in orc
                        if o["valor_num"] == 0
                        and any(x in (o["tipo"] or "").lower()
                                for x in ("pessoas", "people", "staff"))]
        if not tem_reserva:
            achados.append(_achado(ATENCAO, T("Orçamento sem reserva de contingência."),
                                   T("Reserve uma verba para o que der errado (Passo 4).")))
        if pessoas_zero:
            achados.append(_achado(ATENCAO, Tf("{0} linha(s) de pessoas a R$ 0 — horas não "
                                               "estimadas (custo de oportunidade).", len(pessoas_zero)),
                                   T("Mesmo sem desembolso, estime as horas: é o esforço real do projeto."),
                                   cartoes=[o["id"] for o in pessoas_zero], lista="ORCAMENTO"))
    return achados


# ─────────────────────────────────────────────────────────────────────────────
# PASSO 5 — baseline (o resto do Passo 5 = auditar_plano, os checklists anteriores)
# ─────────────────────────────────────────────────────────────────────────────
def _baseline_ok(ids):
    for c in list_cards(ids.get("CONCLUIDO", 0) or 0) if ids.get("CONCLUIDO") else []:
        t = (c.get("titulo") or "")
        if t.startswith("📌 Baseline do plano") or t.startswith("📌 Linha de base"):
            return True
    return False


def _p5(ids, abordagem, dados):
    # "Todos os checklists anteriores" + a linha de base congelada.
    achados = list(auditar_plano(ids, abordagem, dados))
    if not _baseline_ok(ids):
        achados.append(_achado(ATENCAO, T("Linha de base (baseline) ainda não capturada."),
                               T("Aprove o plano e capture a baseline (Passo 5) — é o marco zero do prazo e do custo.")))
    return achados


# ─────────────────────────────────────────────────────────────────────────────
# PASSO 6 — engajamento das partes
# ─────────────────────────────────────────────────────────────────────────────
_GRUPOS = (T("CLIENTES"), T("EQUIPE"), T("DEMAIS"))


def _partes_de_trabalho(ids):
    """Os cartoes de parte interessada (sem o guia, os cabecalhos de grupo, nem a caixa).

    O filtro dos cabecalhos de grupo e' case-SENSITIVE (como no 06_engajar_partes.py): os
    cabecalhos sao MAIUSCULOS ('EQUIPE — quem faz'), entao uma parte real de nome misto
    ('Equipe de suporte') NAO e' confundida com o cabecalho e nao se perde (o bug do board
    270, 2026-07-20)."""
    out = []
    for c in list_cards(ids.get("PARTES", 0) or 0) if ids.get("PARTES") else []:
        t = (c.get("titulo") or "").strip()
        if not t or quadro.e_guia(t) or t.startswith(("0.",) + _GRUPOS):
            continue
        out.append(c)
    return out


def _p6(ids, abordagem, dados):
    achados = []
    partes = _partes_de_trabalho(ids)
    if not partes:
        return achados
    sem_plano, sem_desejado, sem_estrategia = [], [], []
    for c in partes:
        nome = _curto(re.sub(r"\s*\[[^\]]*\]\s*$", "", (c.get("titulo") or "").strip()), 32)
        cid = c.get("id")
        txt = entradas.texto_do_cartao(c.get("descricao")) or ""
        low = txt.lower()
        # NOS DOIS IDIOMAS. O Passo 6 grava este bloco no idioma do pacote ("Engagement
        # plan (Step 6)" no ingles), e procurar so' o portugues acusava TODA parte de um
        # quadro em ingles como "sem plano de engajamento" — com o plano ali, escrito,
        # no cartao (Eduardo, quadro 324/FIRE, 06/08/2026). O check nao pode ser mais
        # monoglota do que o passo que ele audita.
        if not re.search(vocabulario.PLANO_ENGAJAMENTO, low):
            sem_plano.append((nome, cid))
            continue
        if not re.search(rf"(?:{vocabulario.ROTULOS_PARTE['desejado']})\s*:", low):
            sem_desejado.append((nome, cid))
        if not re.search(rf"(?:{vocabulario.ROTULOS_PARTE['estrategia']})\s*:", low):
            sem_estrategia.append((nome, cid))
    if sem_plano:
        achados.append(_achado(ATENCAO, Tf("{0} parte(s) sem plano de engajamento: {1}.", len(sem_plano), _lista([n for n, _ in sem_plano])),
                               T("Toda parte do Passo 1 entra no plano de engajamento (Passo 6)."),
                               cartoes=[c for _, c in sem_plano], lista="PARTES"))
    if sem_desejado:
        achados.append(_achado(ATENCAO, Tf("{0} parte(s) sem o nível de engajamento desejado: "
                                           "{1}.", len(sem_desejado), _lista([n for n, _ in sem_desejado])),
                               T("Diga aonde cada parte precisa chegar (resistente → apoiador → líder)."),
                               cartoes=[c for _, c in sem_desejado], lista="PARTES"))
    if sem_estrategia:
        achados.append(_achado(ATENCAO, Tf("{0} parte(s) sem estratégia de engajamento: {1}.", len(sem_estrategia), _lista([n for n, _ in sem_estrategia])),
                               T("Cada parte tem ao menos uma estratégia concreta para levá-la aonde precisa estar."),
                               cartoes=[c for _, c in sem_estrategia], lista="PARTES"))
    return achados


# ─────────────────────────────────────────────────────────────────────────────
# PASSO 7 — execução e kick-off
# ─────────────────────────────────────────────────────────────────────────────
def _tem_documento(prefixo):
    """Algum arquivo gerado em _saida/documentos começa com `prefixo`? (o artefato existe)."""
    try:
        import artefatos
        pasta = artefatos._saida_dir()
        return any(f.lower().startswith(prefixo.lower()) for f in os.listdir(pasta))
    except Exception:
        return False


def _dirigiu_a_execucao():
    """A execucao foi DIRIGIDA? (as planilhas por dono que o Passo 7 escreve em
    _saida/execucao/). Desde 19/08/2026 a IA e' opcional no Passo 7: quem so' dirigiu
    pelo cronograma nao tem tarefa `MARCA_EXEC_P7` no quadro — e nao esta' devendo nada.
    Sem isto, a conferencia mandava rodar de novo o passo que o aluno acabou de rodar."""
    try:
        import execution
        pasta = execution._saida_dir()
        return any(f.lower().endswith(".xlsx") for f in os.listdir(pasta))
    except Exception:
        return False


def _p7(ids, abordagem, dados):
    achados = []
    # Ha tarefas de execucao no quadro? (preditiva: subtarefas com a marca de execucao)
    if (abordagem or "").upper() != "AGIL":
        tem_exec = any(quadro.MARCA_EXEC_P7 in (c.get("descricao") or "")
                       for c in list_cards(ids["ESCOPO"])) if ids.get("ESCOPO") else False
        # Na preditiva, EXECUTAR e' mudar o status do cartao do cronograma — a tarefa de
        # execucao e' o extra que a IA traz. Dirigir a execucao (planilha por dono,
        # etiqueta, e-mail) ja' e' o passo cumprido.
        tem_exec = tem_exec or _dirigiu_a_execucao()
    else:
        tem_exec = bool([c for c in (list_cards(ids["EXECUCAO"]) if ids.get("EXECUCAO") else [])
                         if (c.get("titulo") or "").strip()
                         and not quadro.e_guia((c.get("titulo") or ""))])
    if not tem_exec:
        achados.append(_achado(ATENCAO, T("Nenhuma tarefa de execução no quadro ainda."),
                               T("Rode o Passo 7 para trazer as próximas ações (caminho crítico ou Sprint).")))
    # O kick-off (.pptx) foi gerado? O AGENDAR e CONVIDAR sao acoes suas (nao dao para
    # conferir no quadro) — a conferencia so' confirma que o material existe.
    if not _tem_documento("Kick-Off"):
        achados.append(_achado(ATENCAO, T("Kick-Off do Projeto (.pptx) ainda não gerado."),
                               T("Rode o Passo 7 (ele gera o Kick-Off) e depois agende-o no cronograma, "
                               "com a equipe e os principais stakeholders.")))
    return achados


# ─────────────────────────────────────────────────────────────────────────────
# PASSO 8 — status report
# ─────────────────────────────────────────────────────────────────────────────
def _p8(ids, abordagem, dados):
    achados = []
    reports = [c for c in (list_cards(ids["CONCLUIDO"]) if ids.get("CONCLUIDO") else [])
               if (c.get("titulo") or "").startswith("📊 Status Report")]
    if not reports:
        achados.append(_achado(ATENCAO, T("Nenhum Status Report emitido ainda."),
                               T("Rode o Passo 8 para comparar planejado × realizado e comunicar quem decide.")))
    return achados


# ─────────────────────────────────────────────────────────────────────────────
# PASSO 9 — mudanças
# ─────────────────────────────────────────────────────────────────────────────
def _p9(ids, abordagem, dados):
    achados = []
    mud = [c for c in (list_cards(ids["MUDANCAS"]) if ids.get("MUDANCAS") else [])
           if (c.get("titulo") or "").strip()
           and not quadro.e_guia((c.get("titulo") or ""))]
    sem_impacto = []
    for c in mud:
        txt = (entradas.texto_do_cartao(c.get("descricao")) or "").lower()
        if "impacto" not in txt:
            sem_impacto.append(c.get("id"))
    if sem_impacto:
        achados.append(_achado(ATENCAO, Tf("{0} mudança(s) sem análise de impacto registrada.", len(sem_impacto)),
                               T("Toda solicitação de mudança é avaliada (impacto) e registrada na lista Mudanças (Passo 9)."),
                               cartoes=sem_impacto, lista="MUDANCAS"))
    return achados


# ─────────────────────────────────────────────────────────────────────────────
# PASSO 10 — encerramento e valor
# ─────────────────────────────────────────────────────────────────────────────
def _p10(ids, abordagem, dados):
    achados = []
    concl = list_cards(ids["CONCLUIDO"]) if ids.get("CONCLUIDO") else []
    tem_resultado = any((c.get("titulo") or "").startswith("🎯 Resultado do OKR") for c in concl)
    tem_licoes = any((c.get("titulo") or "").startswith("📚 Lições aprendidas") for c in concl)
    if not tem_resultado:
        achados.append(_achado(ATENCAO, T("Resultado do OKR (verificação de valor) ainda não registrado."),
                               T("Rode o Passo 10: compare cada Key Result com o que de fato aconteceu.")))
    if not tem_licoes:
        achados.append(_achado(ATENCAO, T("Lições aprendidas ainda não registradas."),
                               T("Rode o Passo 10 com os principais membros da equipe — o que repetir e o que mudar.")))
    return achados


# ─────────────────────────────────────────────────────────────────────────────
# DISPATCHER + AGREGADO
# ─────────────────────────────────────────────────────────────────────────────
_CHECKERS = {1: _p1, 2: _p2, 3: _p3, 4: _p4, 5: _p5,
             6: _p6, 7: _p7, 8: _p8, 9: _p9, 10: _p10}


def checar(n, ids, abordagem=None):
    """As conferencias determinsticas do PASSO n. So' LE o quadro — nenhum efeito
    colateral. Devolve a lista de achados (dicts nivel/resumo/acao), do mais critico ao
    mais leve. Passo sem conferencia mecanica devolve []."""
    fn = _CHECKERS.get(n)
    if not fn:
        return []
    abordagem = abordagem or quadro.abordagem_do_env()
    return fn(ids, abordagem, {})


def auditar_plano(ids, abordagem=None, dados=None):
    """"Todos os checklists anteriores" — os Passos 1 a 4 (o que o Passo 5 revisa antes de
    aprovar e o verificar_projeto mostra). So' LE. A arvore da EAP e' lida uma vez so'."""
    dados = dados if dados is not None else {}
    abordagem = abordagem or quadro.abordagem_do_env()
    achados = []
    for fn in (_p1, _p2, _p3, _p4):
        achados += fn(ids, abordagem, dados)
    return achados


def contagem(achados):
    """(nº de lacunas, nº de atenções)."""
    return (sum(1 for a in achados if a["nivel"] == LACUNA),
            sum(1 for a in achados if a["nivel"] == ATENCAO))


# ─────────────────────────────────────────────────────────────────────────────
# MARCAR NO QUADRO — deixar a revisão fácil para o aluno
#
# Além de LISTAR os buracos em texto, a auditoria MARCA com UMA etiqueta os cartões que
# precisam de revisão — o risco sem avaliação, o pacote sem data, a parte sem plano.
# Assim o aluno FILTRA o quadro pela etiqueta e vai direto ao que falta. Escolhemos
# ETIQUETA (não o status 'Revisão') porque a API do ProjectAdm expõe etiquetas com CRUD
# completo, mas NÃO expõe setar o status do cartão (só `concluido` bool); e a etiqueta é
# aditiva/reversível (não mexe em status/prioridade/datas) e filtrável em qualquer visão.
# UMA etiqueta só ("🔎 Revisar"): o cartão precisa (ou não) de atenção — se é lacuna ou
# ponto de atenção, e COMO corrigir, fica no relatório e nos campos do cartão. (A
# gravidade não vira uma segunda etiqueta — decisão do Eduardo, 2026-07-23.)
# É IDEMPOTENTE e RECONCILIA: some a etiqueta do cartão que foi corrigido. Para uma etapa
# não apagar a marca de outra, a reconciliação é POR LISTA — ao marcar uma lista, a
# auditoria recomputa TODAS as conferências que apontam cartões DAQUELA lista.
_ETIQUETA_REVISAR = ("🔎 Revisar", ["#E53935", "vermelho", "red", "#e53935"])

# Que conferências apontam cartões de cada lista (para reconciliar a lista inteira).
# INPUTS entra porque no Passo 1 o OKR (1.2) e as entregas (2.1) ainda são cartões do
# Termo de Abertura — a marca 🔎 Revisar cai neles até virarem cartões do ESCOPO (Passo 2).
_LISTA_CHECKS = {
    "INPUTS": (_p1,),
    "RISCOS": (_p3,),
    "ESCOPO": (_p1, _p2, _p4),
    "ORCAMENTO": (_p4,),
    "PARTES": (_p6,),
    "MUDANCAS": (_p9,),
}

# Que listas cada passo pode marcar quando fecha (o conferir do passo). Passos sem cartão
# marcável (7, 8, 10 — presença/ação) não entram. O Passo 5 marca o plano inteiro.
# INPUTS nos passos 1 e 2 (onde o OKR/entregas do termo são o cartão a revisar).
STEP_LISTAS = {
    1: ("INPUTS", "ESCOPO"), 2: ("INPUTS", "ESCOPO"), 3: ("RISCOS",),
    4: ("ESCOPO", "ORCAMENTO"),
    5: ("INPUTS", "ESCOPO", "RISCOS", "ORCAMENTO"), 6: ("PARTES",), 9: ("MUDANCAS",),
}


def marcar_no_quadro(ids, abordagem, escopo_listas, log=lambda _m: None):
    """MARCA (e desmarca) no quadro os cartões que precisam de revisão, nas `escopo_listas`
    dadas, com a etiqueta "🔎 Revisar". Idempotente e reconciliador: para cada lista,
    recomputa TODAS as conferências que apontam cartões dela; o cartão com pendência ganha
    a etiqueta, o que foi corrigido a perde. Só toca a NOSSA etiqueta.

    Falha SEGURA: qualquer erro de API (etiqueta não criou, anexar falhou) é engolido —
    marcar é um bônus da revisão, nunca pode derrubar o passo.

    Devolve (marcados_agora, desmarcados_agora, com_etiqueta_no_fim). O TERCEIRO número é
    o que o aluno precisa ver: numa segunda rodada nada muda (os cartões já estavam
    marcados) e um relatório que só sabe falar de mudança fica mudo — parecendo que a
    máquina não marcou nada (Eduardo, 23/07).

    Quem chama: os passos que ESCREVEM (o conferir de cada passo e o Passo 5) e — desde
    2026-07-27, sob CONFIRMAÇÃO do aluno — o painel verificar_projeto, que é o único lugar
    que vê as lacunas dos Passos 1 a 4 de uma vez. O `diagnostico()` do painel continua
    só-leitura (o Passo 8 o usa para o Status Report, e relatório não mexe no quadro).

    TODAS as saídas devolvem 3 valores. As três saídas de desistência devolviam (0, 0) e
    quem desempacota 3 (passos.conferir) levantava ValueError — engolido pelo except de
    lá e mostrado ao aluno como "não consegui rodar as conferências automáticas", que
    esconde a causa real (não havia lista no escopo, ou a etiqueta não subiu)."""
    escopo_listas = [l for l in escopo_listas if ids.get(l)]
    if not escopo_listas:
        return (0, 0, 0)
    try:
        from pa_client import (garantir_etiqueta, anexar_etiqueta, desanexar_etiqueta,
                               list_cards, etiquetas_do_cartao)
    except Exception:
        return (0, 0, 0)

    # 1) a etiqueta (reusa/cria) — sem ela, não há como marcar (sai limpo)
    nome, cores = _ETIQUETA_REVISAR
    try:
        eid = garantir_etiqueta(nome, cores)
    except Exception as e:
        log(Tf("  [aviso] não consegui garantir a etiqueta {0}: {1}", nome, e))
        eid = None
    if not eid:
        return (0, 0, 0)
    eid = str(eid)

    # 2) recomputa o que cada lista pede (a verdade completa daquela lista)
    dados = {}
    checks = []
    for l in escopo_listas:
        for fn in _LISTA_CHECKS.get(l, ()):
            if fn not in checks:
                checks.append(fn)
    achados = []
    for fn in checks:
        try:
            achados += fn(ids, abordagem, dados)
        except Exception as e:
            log(Tf("  [aviso] conferência falhou ao marcar: {0}", e))

    # 3) TODO cartão apontado por um achado precisa de revisão — inclusive o que mora numa
    # lista fora do escopo desta etapa. Apontar e não marcar é o pior dos mundos: o
    # relatório cobra e o quadro não mostra onde (Eduardo, 23/07: "quando ele aponta uma
    # correção necessária, ele deve marcar como revisar").
    pendentes = set()
    for a in achados:
        pendentes.update(str(c) for c in (a.get("cartoes") or []))

    # 4) reconcilia: percorre os cartões das listas do escopo e acerta a NOSSA etiqueta.
    # DESMARCAR só acontece nas listas do escopo — quem tira a marca de um cartão é a
    # etapa DONA daquela lista, que recomputou todas as conferências dela. Cartão apontado
    # de fora do escopo entra só para ser marcado.
    # As etiquetas vêm NA LISTAGEM (desde 2026-07-30 — o `GET /listas/{id}/cartoes`
    # devolvia `etiquetas: []` para todo cartão, e o curso pagava 1 GET por cartão só para
    # saber se a marca estava lá; item 0e do NEEDS_FOR_HENRY, corrigido pelo Henry). Agora
    # a leitura sai de graça do que já lemos. O GET individual fica como reserva: para o
    # cartão apontado de FORA das listas do escopo (que não está nesta leitura) e para o
    # caso de um payload sem a chave.
    alvo = {}                                  # cid -> pode desmarcar?
    etq_lidas = {}                             # cid -> {ids das etiquetas} (da listagem)
    for l in escopo_listas:
        for c in list_cards(ids[l]):
            t = (c.get("titulo") or "").strip()
            if t and not quadro.e_guia(t):
                cid = str(c["id"])
                alvo[cid] = True
                if isinstance(c.get("etiquetas"), list):
                    etq_lidas[cid] = {str(e.get("id")) for e in c["etiquetas"]}
    for cid in pendentes:
        alvo.setdefault(cid, False)

    def _sync(item):
        cid, pode_desmarcar = item
        try:
            if cid in etq_lidas:
                tem = eid in etq_lidas[cid]
            else:
                tem = eid in {str(e.get("id")) for e in etiquetas_do_cartao(cid)}
        except Exception:
            return (0, 0, 0)
        deve_ter = cid in pendentes
        if deve_ter and not tem:
            try:
                anexar_etiqueta(cid, eid)
                return (1, 0, 1)
            except Exception:
                return (0, 0, 0)
        if tem and not deve_ter:
            if pode_desmarcar:
                return (0, 1, 0) if desanexar_etiqueta(cid, eid) else (0, 0, 1)
            return (0, 0, 1)                   # marca de outra etapa: fica
        return (0, 0, 1 if tem else 0)

    from concurrent.futures import ThreadPoolExecutor
    n_add = n_rm = n_total = 0
    with ThreadPoolExecutor(max_workers=6) as ex:
        for add, rm, tem in ex.map(_sync, alvo.items()):
            n_add += add
            n_rm += rm
            n_total += tem
    return (n_add, n_rm, n_total)


# ─────────────────────────────────────────────────────────────────────────────
# FORMATADORES (terminal / _saida / cartão)
# ─────────────────────────────────────────────────────────────────────────────
def formatar(achados, largura=64, convite=True):
    """O relatório da auditoria do PLANO (Passo 5) em texto — serve ao terminal, ao
    _saida/ e ao cartão. `convite`=True acrescenta o convite de feedback (WhatsApp)."""
    lac, ate = contagem(achados)
    L = []
    if not achados:
        L.append(T("✅ As conferências automáticas passaram — nada incompleto que a máquina"))
        L.append(T("   consiga ver. Mas a revisão final é SUA: se as respostas aos riscos são"))
        L.append(T("   boas, se o prazo é realista, se falta algo que ninguém pôs no quadro —"))
        L.append(T("   isso é o seu julgamento antes de aprovar."))
    else:
        if lac:
            L.append(Tf("❌ {0} LACUNA(S) — resolva antes de aprovar o plano:", lac))
            for a in achados:
                if a["nivel"] == LACUNA:
                    L.append(f"   ✗ {a['resumo']}")
                    if a["acao"]:
                        L.append(f"       → {a['acao']}")
        if ate:
            if lac:
                L.append("")
            L.append(Tf("• {0} PONTO(S) DE ATENÇÃO — vale rever (não travam a aprovação):", ate))
            for a in achados:
                if a["nivel"] == ATENCAO:
                    L.append(f"   • {a['resumo']}")
                    if a["acao"]:
                        L.append(f"       → {a['acao']}")
        L.append("")
        L.append(T("O que o python NÃO decide por você — é o seu julgamento: se a resposta a um"))
        L.append(T("risco é boa o bastante, se o prazo é realista, e se falta algo que NINGUÉM"))
        L.append(T("pôs no quadro (como um termo de uso de imagem dos participantes)."))
    if convite:
        L.append("")
        L.append(CONVITE)
    return "\n".join(L)


def formatar_passo(n, achados, convite=True):
    """Bloco compacto das conferências de UM passo — o que cada script de passo imprime
    ao fechar. Nunca trava o passo: apenas ajuda a ver o que ainda falta."""
    lac, ate = contagem(achados)
    rot = PASSO_ROTULO.get(n, "")
    L = [Tf("── Conferências automáticas do Passo {0} ({1}) ──", n, rot) if rot
         else Tf("── Conferências automáticas do Passo {0} ──", n)]
    if not achados:
        L.append(T("✅ Nada incompleto que a máquina consiga ver. A revisão final é sua."))
    else:
        for a in achados:
            marca = "✗" if a["nivel"] == LACUNA else "•"
            L.append(f"  {marca} {a['resumo']}")
            if a["acao"]:
                L.append(f"      → {a['acao']}")
            elos = _links(a.get("cartoes"))
            if elos:
                L.append(f"      ↳ {elos}")
        if lac:
            L.append(Tf("  ({0} lacuna(s) para resolver · {1} ponto(s) de atenção — você "
                        "decide o que falta.)", lac, ate))
        else:
            L.append(T("  (pontos de atenção — não travam o passo; você decide o que falta.)"))
    if convite:
        L.append("  " + CONVITE)
    return "\n".join(L)
