# morph-spec CLI — Referência Completa

> Referência completa (flags, semântica, exemplos) dos comandos `morph-spec` usados pela LLM em vez de editar o estado à mão. Resumo rápido em `.morph/framework/CLAUDE.md`; racional dos gates em `.morph/framework/MORPH.md` §5, dispatch de sub-agents em §6, worktree/fleet em §2.2.

---

## Gates e estado

- **`morph-spec approve <feature> <gate> --mode auto`** — aprova um gate (`proposal`/`uiux`/`plan`/`review`) em `trust: auto`. **`uiux`** é o único gate cujo `AskUserQuestion` o hook `state-sync.js` não reconhece por texto (só casa "Gate 1/2/3") — aprove-o sempre explicitamente por este comando ou por `advance`, nunca contando com o hook. **O Gate 3 exige `4-review/evaluator-report.md`**: sem o relatório do avaliador independente o comando recusa (exit 1). Se o dispatch do avaliador for impossível nesta sessão, aprove com `--self-assessed` — a degradação é gravada em `approvalGates.review.selfAssessed` e aparece marcada no `morph-spec list`. `--force` **não** dispensa esta regra (é só para ordem de gate). **Exceção `hotfix`:** esse tipo nunca despacha o avaliador (`morph-hotfix` §7); o Gate 3 exige `4-review/review-report.md` no lugar do `evaluator-report.md`, e a assinatura humana continua obrigatória via `--approver` (o backstop de `--mode auto` acima permanece recusando).
- **`morph-spec advance <feature>`** — aprova o gate da fase corrente **e** cria a pasta da próxima fase num passo só (`--design` toma o ramo opcional de uiux a partir da proposta).
- **`morph-spec score <feature> <task> [score] [--dims a,c,q,t]`** — grava `taskScores` no feature.json com timestamp server-side; **nunca edite taskScores à mão**. **Duas recusas duras (exit 1), nunca um registro silencioso:** `<score>` em branco ou ausente é **recusado, jamais gravado como `0`** (o `Number('')` que valia `0` gravava um zero fabricado — e era esse zero que o Gate 3 lia depois, como `evalScore: 0 / evalScoreSource: taskScores-proxy`); e `<task>` fora de `^T\d+$` é **recusado** em vez de aninhado (`T3.2` virava `taskScores.T3["2"]` — inválido contra `feature.schema.json` e invisível ao proxy). O `[score]` aparece opcional só para que o valor ausente chegue ao comando e receba uma mensagem que nomeia o argumento e a ordem posicional — ele continua obrigatório. Em PowerShell, cite o valor de `--dims` entre aspas (`--dims "9.5,9,9,9"`) — sem aspas, a lista pode chegar como tokens separados; o parser aceita ambas as formas, mas citar evita qualquer ambiguidade.
- **`morph-spec gate-check <feature> <gate> [--signals <json>] [--trust <mode>] [--json]`** — o caminho **default**: coleta do disco os sinais que `shouldPauseGate` (risk-detector.js) já sabe avaliar (`workType`, `taskCount`, verificação, sha/diffstat), decide a pausa e persiste `gateDecisions.{gate}`. `--signals` aceita só as chaves que o disco genuinamente não expõe sozinho — `designSystemDivergence`, `standardsViolations`, `evalScore` — qualquer outra chave é ignorada com aviso. No **review**, o `evalScore` real vem do token `**Composite-Score:** {n.n}` do `evaluator-report.md` (o avaliador o grava por contrato). **Score que não venha desse token PAUSA o Gate 3**, qualquer que seja o valor: o proxy (média das `taskScores`) e o override de `--signals` medem auto-avaliação de quem implementou, não qualidade da feature — em campo, 7 tasks com auto-score 9.0-9.3 auto-passaram um produto que o avaliador independente pontuou 7.2 e reprovou com 3 P1. `evalScoreSource` registra a procedência (`evaluator-report` / `taskScores-proxy` / `signals-override`). Para forçar a passagem mesmo assim, use `gate-decision --pause false` — explícito e registrado. Fora do review a regra não vale: antes de haver implementação não existe avaliador independente. Um stamp de `verification` com status `fail` (último `morph-spec verify` vermelho) **força pausa** em qualquer gate. Um stamp `pass` mas **stale** (sha diferente do HEAD atual — código mudou depois do último verify) força pausa **só no ship** (`review`); em `plan`/`uiux`/`proposal` a staleness é computada e reportada em `signals`, mas não pausa nada — é regra de ship, não de gate genérico. Stamp **ausente** nunca pausa por isso (features legadas sem stamp). **Dispensa de e2e:** o stamp carrega `nodes.e2e` e a razão literal `e2eReason`, porque um nó e2e que **pula** não reprova o rollup — sem eles o gate lê `pass` tanto quando o e2e rodou e passou quanto quando ele nunca rodou. No **ship** (`review`), um `skip` é classificado pela razão (`classifyE2eSkip`, em `risk-detector.js` — fonte única) e o veredito fica registrado em `gateDecisions.review.signals` (`e2eStatus`, `e2eReason`, `e2eSkipClass`): `project` ("project does not containerize") e `scope` ("task group … does not touch runtime") são **dispensa legítima** — passam, com o motivo gravado, sem precisar de `gate-decision` manual; `environment` ("docker is not available…"), `requested` (`--skip-e2e`) e `unknown` (qualquer outra razão, **inclusive razão nenhuma**) **pausam** — a máquina ou o operador não são evidência sobre o código, e uma dispensa sem motivo é o mesmo no-op silencioso que a regra existe para eliminar. Fora do review a regra não vale (pausar o Gate 1/2 numa máquina sem docker travaria planejamento legítimo). **A dispensa também depende de QUEM carimbou.** O stamp `verification` é singular e qualquer `verify` o sobrescreve, de qualquer escopo — então um stamp de escopo `task:{id}` no momento do ship significa que o último `verify` **não foi o da feature**. A dispensa (`project`/`scope`) só conta vinda de `scope: 'feature'`; de um stamp de task ela pausa, nomeando o escopo que carimbou e mandando rodar `morph-spec verify {feature}` sem task. Sem isso, um `morph-spec verify {f} {task}` de uma task cujo grupo não toca runtime (a última task de uma feature é tipicamente `group: docs`) compra o Gate 3 da feature inteira numa máquina onde a app nunca subiu. Escopo ausente cai do mesmo lado seguro que razão nenhuma. **E o escopo de task pausa o ship por si só**, antes de qualquer leitura do e2e: se o último stamp `verification` for de escopo `task:{id}`, o `review` pausa com `último morph-spec verify foi de escopo "task:{id}" (recorte parcial de task)`, mesmo com todos os nós verdes — build, testes e validadores daquele verify rodaram só para a task, e desde o recorte automático (ver `verify` abaixo) todo `verify {f} {task}` roda menos que a suíte. O caminho de volta é rodar `morph-spec verify {feature}` antes do ship. Stamp sem `scope` (feature legada) não pausa por esta regra, e fora do `review` ela não vale. `e2eStatus`/`e2eReason`/`e2eSkipClass`/`verificationScope` **não** são sobrescrevíveis por `--signals`: descrevem o que aconteceu no disco, como `gate`/`workType`. **Prova de mutação:** no ship, o `gate-check` lê do MESMO `evaluator-report.md` se existe a seção `## Mutações` (`evaluatorProofState`, em `src/lib/evaluator-report.js` — leitor único) e grava `signals.mutationProof = {section, min, productionCode}`. Relatório **sem** a seção numa feature com código de produção (ao menos uma task com `group` diferente de `docs`) **pausa o Gate 3**, qualquer que seja o score: um teste verde prova que o código roda, não que o teste morde. Feature 100% `docs` é isenta, e `tasks.json` ausente também (sem plano não há como afirmar que existe código de produção). `min` é o menor `**Proof-Scores:**` do relatório e impõe teto em `test-coverage` (rubricas em `framework/evals/proof-rubrics/`); `null` é **sem teto**, jamais teto 0. `mutationProof` **não** é sobrescrevível por `--signals`, pela mesma razão anti-spoof dos sinais de e2e. **O token vigente é o ÚLTIMO:** o `evaluator-report.md` é append-only, então numa reavaliação o `**Composite-Score:**` que decide o gate é o do FIM do arquivo — ler o primeiro auto-passaria o Gate 3 com a nota superada quando uma correção BAIXA a nota. **Prova de mídia:** do MESMO relatório e pelo MESMO leitor, o `gate-check` grava `signals.mediaClaims = {section, score, featureGeneratesMedia}`. `featureGeneratesMedia` é determinístico do disco — existe ao menos uma task de `tasks.json` cujo array `standards` cita `standards/ai-agents/modalities-image-gen.md`, `standards/ai-agents/media-video.md` ou `standards/ai-agents/media-pipeline.md` — e a grafia do disco (`framework/standards/…`) conta igual, porque um plano escrito com o prefixo do disco desarmaria o guardrail sem uma linha de erro (`featureGeneratesMedia`, função exportada de `gate-check.js`, é onde essa normalização mora e é testada). Nessa condição, relatório **sem** a seção `## Mídia gerada` **pausa o Gate 3**: as quatro afirmações de mídia (custo, revisão humana, marcação, hot path não bloqueado) não foram provadas (rubrica `framework/evals/proof-rubrics/media-claims.md`). Feature que não pina standard de mídia é **isenta automaticamente** — a regra não pede cerimônia de quem não gera mídia. `score` é a chave `media=` dos `**Proof-Scores:**` e impõe teto em `architecture`, **não** em `test-coverage`: ela fica FORA do `min` do `mutationProof`, senão um `media` baixo derrubaria o teto de teste por um motivo que não é de teste. `mediaClaims` **não** é sobrescrevível por `--signals`, pela mesma razão anti-spoof dos demais sinais de disco.
- **`morph-spec gate-decision <feature> <gate> --pause <bool> [--reason <txt>] [--signals <json>]`** — o **escape manual**: registra uma decisão já tomada pela sessão (ex.: `discoveryMode: "interview"`, sinal que `shouldPauseGate` nem representa). Mesmo write path do `gate-check` — grava `gateDecisions` no feature.json com timestamp server-side (shape em `framework/schemas/feature.schema.json`); **nunca edite gateDecisions à mão** — o Edit manual colide com o hook state-sync.
- **Guarda de árvore dona:** `approve`/`unapprove`/`advance`/`score`/`gate-decision`/`gate-check` recusam (exit 1) quando um worktree vivo é dono da feature e a sessão está em outra árvore — sem isso o comando carimbaria o gate/score/decisão na cópia congelada da raiz, criando o drift em vez de só herdá-lo. A recusa nomeia o worktree e o comando exato para rodar lá; `--force` **não** dispensa (é sobre gate order, não sobre árvore). Rodando de dentro do worktree dono, ou numa feature sem worktree algum, nada muda.

## Features

- **`morph-spec create <feature> --description "<o quê/porquê>" --type <feature|bug|chore|hotfix> [--request "<txt>"] [--name "<nome>"] [--worktree] [--no-checkout]`** — cria a estrutura da feature + `feature.json` canônico explicitamente (nascimento explícito no início da proposal; o hook state-sync continua cobrindo o caminho implícito como fallback). **Identidade no nascimento:** `name` (default = slug) e `description` são carimbados; a `--description` é **obrigatória** numa feature nova (ou `--request`, que a semeia) — sem nenhuma das duas o `create` recusa (fim dos scaffolds anônimos). `--type` classifica direto; `--request "<pedido>"` roda o detector determinístico (empate/sem sinal sai com exit 2 e candidatos — a LLM decide e re-roda com `--type` + `--request`/`--description`); `--worktree` já nasce a feature no seu worktree próprio. **Toda feature nasce numa branch `morph/{f}`** (ramificada da default) mesmo sem `--worktree` — por isso o fechamento por `morph-spec finish` é idêntico nos dois fluxos. **E sem `--worktree` o checkout PRINCIPAL se move para essa branch**: o comando avisa em amarelo, nomeando a branch anterior e a nova (sob `--json` o aviso vai para **stderr**, para sobreviver ao redirecionamento do stdout), porque trabalho não commitado vai junto e todo commit seguinte naquela árvore cai na branch da feature. `--no-checkout` cria a branch **sem** mover a árvore (a branch existe de todo jeito, para o `finish --pr|--merge` continuar simétrico); a saída estruturada distingue os dois casos em `branch.checkedOut` (+ `branch.movedFrom` / `branch.stayedOn`). Para trabalho em paralelo o caminho recomendado continua sendo `--worktree`, que não toca no checkout da raiz.
- **`morph-spec list [--all] [--json] [--no-branch]`** — o quadro de auditoria: uma linha por feature × workType × status × fase × gates G1/G2/G3 × descrição, com `⚠` para inconsistências (sem descrição, ordem de gate). Uma feature com worktree/branch ativos tem a cópia da RAIZ congelada no instante do nascimento (`create --worktree` grava lá um esqueleto completo e nunca mais atualiza) — por padrão o `list` lê a verdade commitada em `morph/{f}` quando ela diverge do disco desta árvore (mesma leitura do `fleet`), reportando `source: 'branch'|'disk'` e uma coluna **WHERE** (`raiz` / `branch morph/{f}` / `worktree {dir}` / `⚠ worktree ausente`). `--no-branch` volta ao disco puro, sem custo de git — **e também pula a consulta de PR** (nenhuma coluna, nenhuma linha sintetizada, ver abaixo): não é só o modo disk-only de antes, é o modo offline completo. Um ponteiro `worktree.path` que não existe mais aparece com o flag `worktree-missing` (repare com `doctor --worktrees --prune`). Uma feature já arquivada em outra árvore/branch, cujo esqueleto sobrevive aqui, ganha o flag `archived-elsewhere` em vez de sumir da lista (read-only: nunca ressuscita nem poda). Coluna **PR** mostra `#{n} {estado}` (`-` sem PR para a branch da feature). Uma feature cujo `finish --pr` rodou mas cujo PR segue aberto — sem cópia local nesta árvore — aparece como linha **sintetizada**: fase `aguardando-merge`, flag `pr-pending`, WHERE `branch morph/{f}`, descrição "aguardando merge há Nd — sem cópia local" (mesma síntese que `fleet` e `doctor` usam, de `src/lib/git/pending-prs.js` — uma feature visível numa dessas telas e invisível na outra é o bug que esse módulo compartilhado existe para impedir). `--all` inclui as arquivadas; `--json` para scripting. É o complemento do `delete` (enxergar o que está morto antes de remover).
- **`morph-spec delete <feature> [--archive] [--force] [--with-worktree]`** — remove uma feature. Hard por padrão (pasta apagada, git-aware, + entrada podada do índice raiz para não ressuscitar); `--archive` move para `.morph/archive` com status `abandoned` em vez de apagar. Recusa se algum gate estiver aprovado, a menos que `--force`. Recusa TAMBÉM se um worktree vivo estiver em outra árvore (`feature-has-worktree` — apagar o registro da raiz órfão o worktree+branch em silêncio) — `--force` **não** dispensa isto (é consentimento sobre gate, não sobre filesystem); feche primeiro com `finish`/`worktree remove`, ou passe `--with-worktree` para desmontar o worktree (PROVE-or-DECLINE) e apagar num passo só — a branch sobrevive por padrão (`--delete-branch` para descartá-la também). Use para faxina de scaffolds mortos e drafts abandonados (`archive` é para trabalho concluído; `delete` é para trabalho morto).
- **`morph-spec retype <feature> <newType>`** — muda o tipo de trabalho mid-flight; desfaz só os gates carimbados por política, preserva aprovações humanas; bloqueado após o Gate 3.

## Worktree / Fleet

- **`morph-spec finish <feature> --pr|--merge`** — o **encerramento único** da feature (worktree ou comum): **arquiva** commitando `features/{f} → archive/{f}` dentro de `morph/{f}` — promovendo junto, no mesmo commit, os use cases da feature para o acervo permanente `.morph/specs/usecases/` com `status: implemented` (fail-open: sem `2-plan/usecases/`, no-op silencioso) — depois integra: `--merge` na hora; `--pr` só abre o PR, sem integrar. **O payload distingue os dois** — `--merge` sai com `integrated: true`; `--pr` sai com `integrated: false`, `awaiting: "merge"`, `pr: {number, url}` e um `nextStep` apontando para o merge. **`finished: true` sai nos dois casos e marca só o comando ter terminado, nunca a integração** — `--pr` não encerra a feature, quem encerra é o merge do PR; até lá ela aparece em `morph-spec list`/`fleet` como `aguardando-merge`, sem cópia local (ver `list` acima e `doctor` abaixo). Um `UC-{nn}` cuja promoção colide com outra feature já publicada sob o mesmo número (`ucCollisions` no output; ver `usecase reserve` abaixo) **não é promovido**, mas isso nunca recusa o `finish` — só aquele use case fica de fora, com a mensagem apontando onde o arquivo ficou e como recuperar. `--merge` faz merge `--no-ff` **local** na default (sem push); `--pr` faz `push` + `gh pr create` (exige `origin` + `gh`, checados **antes** de arquivar — um `--pr` recusado não deixa meio-estado). Desmonta o worktree (PROVE-or-DECLINE) e libera o bloco de portas; no `--pr` **preserva** a branch (o PR precisa dela) e fica em `morph/{f}`. **Sessão ancorada no worktree → o desmonte é ADIADO** (`--merge` e `--pr`): quando a sessão do Claude Code que rodou o `finish` tem o próprio worktree como projeto (`CLAUDE_PROJECT_DIR` — exportado ao CLI como `MORPH_SESSION_PROJECT_DIR` pelo hook `worktree-env-inject`, porque o Bash tool não o exporta e `cd <raiz> && morph-spec finish` apaga o cwd), a integração acontece normalmente, mas o worktree fica: saída com `worktreeRemoved: false`, `teardownDeferred: "session-anchored"`, `worktree: {path, branch}` e um `hint` com `morph-spec worktree remove <f>` para rodar depois, de uma sessão aberta na raiz. Desmontar ali desligava a junction `.morph/framework` pela qual TODO hook da sessão resolve (`node "$CLAUDE_PROJECT_DIR/.morph/framework/…"`) e o diretório seguia travado pela própria sessão — o `git worktree remove` apagava o conteúdo e desregistrava a árvore, falhava só no `rmdir`, e cada tool call seguinte morria com `MODULE_NOT_FOUND`. O `--dry-run` já avisa (`teardownDeferred` no pré-voo). **O `--merge` diz o que falta publicar:** depois do merge local a saída traz `publish: {remote, branch, ahead, pushed}` e um `nextStep` com a CONTAGEM de commits que a default local tem à frente de `origin/{default}` mais o comando concreto (`git push origin {default}`) — em **todas** as saídas do `--merge`, inclusive sob `--keep`. Sem contraparte remota para comparar (`refs/remotes/origin/{default}` ausente) `ahead` fica `null` e a frase estática de integração local permanece — nenhuma contagem é inventada. Flags: `--push` publica a default logo depois do merge (sai com `publish.pushed: true` e `publish.published: N`; sem remoto `origin` o merge NÃO é desfeito — a saída reporta `pushError: "no-origin-remote"` e mantém o aviso local) — **`--push` só existe com `--merge`: combinado com `--pr` é RECUSA, exit 1, `push-with-pr`, antes de qualquer mutação** (o `--pr` já publica `morph/{f}` e não tem merge local para publicar depois; a recusa nomeia o comando de cada intenção); `--keep` mantém o worktree **e** o registro dele na raiz (válido enquanto o diretório existir) — o output reporta `worktree: {path, branch}` e um `hint` lembrando `worktree remove <f>` para fechar depois; `--force` dispensa o Gate 3; `--discard-ignored` consente descartar gitignored no teardown. Substitui o antigo `worktree finish` + `archive` separados. **Retomável:** conflito de merge, checkout ou push falham *depois* do archive (é o commit do archive que viaja com a branch) — re-executar o `finish` retoma na integração e sai com `resumed: true`, em vez de recusar por feature ausente. Se a integração já tiver acontecido, recusa com `already-finished` — e o que conta como "já integrada" depende do modo: no `--merge`, branch contida na default; no `--pr`, PR aberto para `morph/{f}` (a branch de um `--pr` concluído fica não-mergeada de propósito, então só o teste de ancestralidade deixaria passar um segundo `gh pr create`). Sem conseguir provar nem uma coisa nem outra, recusa com `resume-undecidable` em vez de retomar às cegas. **`--dry-run` é o PRÉ-VOO:** não muta nada (nenhum commit, archive, remoção de worktree, chamada a `gh` que crie algo, nem `git fetch`) e emite `{preflight: true, finished: false}` com o que ESTE finish faria — `ignored.shieldable` (gitignored exclusivo, copiado para o backup) × `ignored.replicable` × `ignored.discardable`, os `reparsePoints` com o aviso de que apagá-los fora do morph-spec apaga o conteúdo REAL na raiz, `cwdInsideWorktree` e `baseAdvanced: {ref, commits}` — a pergunta INVERSA da do `--merge`: quantos commits `origin/{default}` tem que a base da feature não tem. `commits: null` é DESCONHECIDO, nunca 0. Rodado de DENTRO do worktree ele **avisa e completa o relatório** (sai 0); o caminho real continua recusando antes da primeira mutação.
- **`morph-spec usecase reserve [--count <n>] [--feature <name>] [--json]`** — reserva atomicamente os próximos N números `UC-{nn}`, compartilhado por **todo worktree** deste repo (`<git-common-dir>/morph-spec/uc-registry.json`, mesmo mecanismo do `claim`/env-registry do fleet). Substitui o scan manual "leia o acervo + as features da árvore e some 1": um scan só enxerga a ÁRVORE CORRENTE, então dois worktrees planejando ao mesmo tempo mintavam o MESMO número — colisão que a promoção do `finish` resolvia sobrescrevendo por ID, apagando o use case do primeiro a chegar (já custou perda de dados em produção). O contador nunca regride (semeado do maior número já visto no acervo + nas features da árvore + no próprio registro). `--feature` é mais que auditoria: é o sinal que o guarda de colisão do `finish`/`archive` usa para distinguir uma correção legítima (`bug`, nunca reserva — reaproveita um número publicado) de uma colisão real (a feature reservou o número e o acervo já tem outra dona nele); sem `--feature`, infere a única feature ativa se for inequívoco. Fora de um repositório git: cai para o scan local (avisa no output, `shared: false`) — ainda evita autocolisão dentro da mesma árvore, mas sem a garantia entre worktrees.
- **`morph-spec archive <feature>`** — arquiva-**sem-integrar** (atômico, git-aware, commita o move escopado por pathspec): reservado a features legadas (sem branch `morph/{f}`) e faxina. O fechamento normal (arquiva **e** integra num passo) é `morph-spec finish`. É o que `/morph-archive` chama.
- **`morph-spec worktree setup|provision|link|remove|list`** — gerencia o ciclo do worktree (`setup <f>` cria `worktrees/{f}/` na branch `morph/{f}`, aloca o bloco de portas e escreve `.morph/worktree.env`; `provision <n> [--base <ref>]` cria `worktrees/{n}/` na branch `morph-task/{n}` — um worktree de **isolamento de task**, sem feature, com as mesmas junctions e um bloco próprio sob a chave `task/{n}`; `link --all` religa a infra compartilhada **e regenera o `worktree.env`** com o layout atual; `list` mostra fase/gate de cada worktree ativo). `remove` recusa, antes de tocar em qualquer junction, quando o cwd do processo está no alvo (`cwd-inside-worktree`) **ou quando a sessão do Claude Code que o rodou está ancorada nele** (`session-anchored-in-worktree` — mesmo sinal do `finish`, acima); a recusa é o adiamento: feche a sessão e rode de uma aberta na raiz. Falha de remoção reporta a CAUSA real: um diretório travado vence o eco `is not a working tree` das tentativas seguintes, e `alreadyDeregistered` avisa quando o git já desregistrou a árvore e só o diretório vazio sobrou. **Fechar a feature** continua sendo `morph-spec finish` (acima).
  **Worktrees nativos do Claude Code.** `EnterWorktree`, `claude -w <n>` e `Agent` com `isolation: "worktree"` passam pelo hook `WorktreeCreate` instalado pelo morph: nome conhecido como feature → `worktree setup`; senão → `worktree provision`. O resultado cai sempre em `worktrees/`, nunca em `.claude/worktrees/`. O hook `WorktreeRemove` chama `worktree remove --target <path> --if-clean` (só remove árvore limpa; a branch sobrevive) — mas ele **não disparou** em nenhum run headless medido, então a limpeza primária continua sendo o passo 4 de `morph-apply` §3b-w e o `doctor --worktrees`. `git worktree add` cru é bloqueado pelo hook `block-raw-worktree-add` (sem junctions, sem portas, sem identidade → invisível ao `fleet`).
  **Layout do bloco (10 portas):** `PORT` (+0), `API_PORT` (+1, `ASPNETCORE_URLS` em dotnet), `POSTGRES_PORT` (+2), `REDIS_PORT` (+3), +4..+9 livres; mais `COMPOSE_PROJECT_NAME=morph-{f}`, `MORPH_PORT_BASE/RANGE`. O mesmo layout alimenta o `worktree.env`, o `docker compose` do `verify`/`e2e` e o hook `worktree-env-inject` (que prefixa `npm run dev`/`dotnet run`/`docker compose`/`playwright test` com `export …` quando o cwd tem manifesto e o comando não escolheu porta própria).
- **`morph-spec env [feature] [--json|--powershell]`** — imprime o bloco do worktree em forma shell-loadable (`eval "$(morph-spec env)"`; PowerShell: `morph-spec env --powershell | Invoke-Expression`). Feature vem do argumento ou da branch `morph/{f}`/`morph-task/{n}` do cwd. Deriva de `buildComposeEnv`, a mesma função do nó e2e — um shell e o stack nunca discordam. É o caminho para quem roda **fora** do Claude Code (terminal humano, CI, sub-agent sem hooks). As credenciais do arquivo apontado por `e2e.auth.envFile` entram no ambiente, mas **chave de layout de portas vinda de lá é ignorada** — o bloco alocado a esta worktree prevalece (um `.env.e2e` copiado de outra árvore carrega o bloco DELA). A chave ignorada é nomeada em **stderr**, nunca em stdout, para o `eval` do shell seguir limpo.
- **`morph-spec worktree remove [feature] [--target <path>] [--if-clean] [--discard-ignored] [--delete-branch]`** — a remoção **segura** de um worktree, sem fechar a feature. `--if-clean` recusa (`worktree-dirty`, exit 1, junctions intactas) se a árvore tiver qualquer mudança não commitada — é o que o hook `WorktreeRemove` usa, porque um sub-agent que não commitou ainda é o único exemplar do trabalho. Derruba o stack compose da árvore **sem** apagar volumes (a feature segue aberta) e libera o bloco de portas pela chave da branch (`<f>` ou `task/<n>`). Desmonta as junctions de infra ANTES de deixar o git chegar no diretório e recusa se não conseguir **provar** que é seguro (PROVE-or-DECLINE) — `git worktree remove` cru, com ou sem `--force`, atravessa a junction e apaga o `.claude/`/`.morph/framework/`/`node_modules/` **reais da raiz**, saindo com sucesso. Use `--target` para worktree sem feature registrada (os que o `isolation: "worktree"` cria). A branch **sobrevive** por padrão: remover o worktree não é descartar o trabalho. **Os gitignored são triados um a um**, nunca com um veredito só para o conjunto: o que existe na raiz no mesmo path com o **mesmo SHA-256** é replicável e some sem cerimônia (`replicatedInRoot`); artefato de build regenerável (`bin/`, `obj/`, `node_modules/`, `__pycache__/`, `test-results/`, `.auth/`, `*.tsbuildinfo`) é descartado (`discardedIgnored`); o que existe **só ali** é copiado para `.git/morph-spec/finish-backup/` e a remoção **prossegue** (`ignoredBackup` + `backedUp`). A única recusa por gitignored que sobrou é o **backup ter falhado** — e aí o `hint` diz o que é exclusivo, o que era replicável e para onde o backup ia, em vez de mandar rodar o comando que acabou de falhar. `--discard-ignored` deixou de significar "apague o exclusivo junto": agora significa **pular o backup**. As junctions desmontadas saem em `unlinked` — a prova de que o `.claude/` da raiz foi desligado, nunca seguido.
- **`morph-spec branch prune [--remote] [--dry-run] [--json]`** — apaga as branches locais cujo trabalho **já está** na default, **incluindo as squash-merged que o git não enxerga**. O problema que resolve: num repo que faz squash-merge, o commit de merge entra em `origin/master` mas os SHAs da branch não — `git branch --merged` não a lista, `git branch -d` recusa, e sobra o `-D`, indistinguível de descartar trabalho de verdade. Cada branch é classificada com evidência impressa: `merged-ancestral` (o git prova), `merged-squash` (PR `MERGED` pelo `gh`, **ou** árvore idêntica à da default), `open` (PR OPEN — nunca apagada), `in-worktree` (em uso — nunca apagada) e `unmerged` (**não foi possível provar** integração: sem `gh`, sem default resolvível ou trabalho de verdade — todas tratadas igual, de propósito). Só as duas primeiras são apagadas. Branch remota só com `--remote` explícito.
- **`morph-spec handoff <feature> [--here]`** — regenera o documento de retomada (`.morph/features/{f}/handoff.md`): comprimido, derivado, aponta para os artefatos em vez de copiá-los. **Resolve a árvore dona** antes de gerar (mesma lógica do `list`/`fleet`) e escreve LÁ, não necessariamente no cwd — rodar da raiz com o worktree vivo é seguro: o handoff sai correto e no lugar certo, em vez do esqueleto de nascimento da raiz. Sem árvore nenhuma materializada (só existe commitado em `morph/{f}`) recusa com `feature-not-in-tree`, nomeando a branch, em vez de gerar um documento apontando para arquivos inexistentes. `--here` força construir/escrever no cwd mesmo assim. Gerado automaticamente em `setup`/`create --worktree`/`advance`/`approve`.
- **`morph-spec fleet`** — visão da frota: toda feature ativa (raiz + worktrees) com fase, gates pendentes, tasks, **remoto** (`local`/`pushed`/`PR #N`), **claim** (que sessão está na feature), portas, handoff e atividade. Use entre sessões para decidir onde atuar.
- **`morph-spec claim [feature] [--release] [--list] [--all] [--json]`** — posse **advisory** de feature. Cada sessão reclama sua feature ativa automaticamente (hook SessionStart) e renova o heartbeat a cada tool call; o registro vive em `<git-common-dir>/morph-spec/claims.json`, compartilhado por todos os worktrees. **`morph-spec claim <feature>` (sem flags) ADQUIRE** — se esta árvore já tinha claim vivo em outra feature, ele é liberado no mesmo passo (o CLI reporta "movido de X para Y"); é o jeito de trocar de feature no meio da tarefa sem a dança de dois passos `--release` + reclamar. `--list` força a leitura sem adquirir mesmo passando uma feature. **Claims avisam, nunca bloqueiam** — mas se o SessionStart avisar que outra sessão tem claim vivo, **confirme antes de `finish`/`archive`/`delete`**: duas sessões na mesma feature já custaram um recap.md inteiro e um sub-agent (o worktree sumiu debaixo dele). Claim órfão de sessão morta: `morph-spec claim <f> --release`. **O board mostra só claims VIVOS** (dentro do TTL de 30 min): um claim morto não avisa mais nada, e deixá-lo listado transformava o quadro em ruído (features encerradas há 20h e 73h ainda aparecendo). `--all` mostra o registro inteiro; a contagem dos omitidos sai no rodapé, nunca em silêncio.

## Validação

- **`morph-spec dag <feature>`** — análise do DAG de tasks.json com decisão sequencial vs paralelo **ponderada por effort** (raízes S vão inline, nunca ganham agente próprio). Também cruza `tasks.json` com os use cases: `usecase` apontando para um `UC-{nn}` inexistente é **erro** (exit 1, não despacha); pós-condição órfã e `doneCriteria` que parafraseou em vez de copiar são avisos. `doneCriteria` com vocabulário de artefato (`grep`, "contém a string", "nenhum arquivo", glob `**/`…) sem `Por que artefato:` também é aviso, sob `⚠ Critérios de artefato` — o exit não muda, e o `--json` traz a chave irmã `artifactCriteria`. `--mermaid` nunca é bloqueado.
- **`morph-spec verify <feature> [task] [--json] [--timeout <ms>] [--filter <ids>] [--full] [--record-baseline] [--refresh-cache] [--linux] [--linux-image <img>] [--skip-tests] [--skip-build] [--skip-e2e] [--fresh-e2e] [--no-persist]`** — cadeia determinística build+testes+validadores+**e2e** (código antes do juiz LLM); escopo de task quando informado, senão a feature inteira. O nó `e2e` é o único que **executa** a aplicação: sobe o `docker compose` do projeto, espera todo serviço ficar saudável e roda as specs Playwright quando existem (sem specs, ainda vale como smoke — "a app sobe" já pega o serviço não registrado que derruba o startup e passa batido no build). Aplicabilidade: escopo de feature (Gate 3) **sempre** roda; escopo de task roda para `group` `frontend`/`backend`/`infra` e dispensa `tests`/`docs`. **Dispensa legítima × setup faltando:** projeto sem compose e sem a integração `docker` é `skip` com razão; projeto que **declara** `docker` e não tem compose é `fail` — é verificação que nunca foi montada, não projeto que não containeriza. **Descoberta de testes e relógio:** a varredura por `*Tests.csproj` **não enxerga worktrees** — nem os convencionais (`worktrees/`, `.worktrees/`) nem os registrados em `git worktree list` fora da convenção. Um worktree é o checkout de OUTRA feature do mesmo repo: incluí-lo dobrava o relógio e reportava sobre código que não está sob verificação (em campo isso deu `tests: fail` no timeout exato, com o sumário da 1ª execução dizendo "Com falha: 0"). O timeout do nó `tests` deixou de ser fixo: `--timeout` (que agora cobre build **e** tests) > `project.testTimeoutMs` no `config.json` > **120 s por projeto de teste encadeado** — um encadeamento nunca nasce condenado pelo tamanho. Quando o relógio estoura, o nó diz isso com nome (`timeout após N ms na execução X de Y — nenhum teste falhou`) em vez de devolver log truncado. **`--fresh-e2e` é destrutivo por definição:** derruba a stack anterior **com os volumes nomeados** (`down -v`), apaga o carimbo de frescor, sobe com `--build` e força o seed de auth a rodar de novo. Sem o `down -v`, "fresco" reconstruiria a imagem e preservaria o banco velho — e um `storageState` capturado contra aquele banco continuaria autenticando um usuário que não existe mais (403 que parece app quebrada). **No escopo de task o recorte é automático:** `verify {f} {task}` deixa o `group` da task escolher os nós e os arquivos de teste de `outputs` estreitarem o comando dentro de cada nó — o relógio de uma task não paga a suíte inteira (regras, veredito e limites em *O recorte por task*, na seção **Suítes de teste**). **`--filter <ids>` força a escolha de nós** (lista separada por vírgula, casando `id` exato ou substring; desliga a escolha pelo `group`, e o estreitamento dentro dos nós continua) e **`--full` desliga o recorte** (o `group` não escolhe nós e nenhum comando é estreitado; os nós com `e2e: true` e os de `runner: "evals"` continuam só no escopo de feature; no escopo de feature `--full` é no-op, porque ali nada é recortado). O Gate 3 continua sendo `verify {f} --json` **sem filtro**, porque a prova que o avaliador lê tem de cobrir todos os nós (o CI idem) — e um `verify {f} --filter …` de escopo de **feature** não persiste o stamp (`persisted: false`, `reason: 'filtered'`), nem um de escopo de feature com `--skip-tests` (`reason: 'skip-tests'`) ou `--skip-build` (`reason: 'skip-build'`): um nó pulado não reprova o rollup, e rodada parcial nunca fica no lugar da prova do Gate 3. No escopo de task as três gravam normalmente — o stamp já diz `task:{id}`, e o Gate 3 pausa nele. O nó não casado **aparece** em `runs[]` como `skip` nomeando o filtro, em vez de sumir — suíte que some do relatório é verde sobre código não testado. E `--filter` que não casa nenhum nó é **erro de uso (exit 2)**, checado antes do build: filtrar tudo e reportar verde seria a forma mais barata de recriar o defeito que a lista de nós existe para matar. **`--record-baseline` é a ÚNICA via de escrita da baseline:** mede cada nó numa execução real e grava `project.tests[].expectedCount` de volta no `config.json` (escrita atômica `tmp.{pid}`+rename, preservando todo o resto do arquivo). A regra do `morph-plan` — *baseline é um COMANDO, nunca um número* — sobrevive porque ninguém digita o número: um valor escrito à mão descreve o que alguém acreditou, e a divergência entre a crença e a suíte é o defeito que a regra existe para matar. Recusa alto e claro em vez de improvisar: execução **vermelha** não grava (congelaria a regressão), execução **parcial** não grava (com `--filter` alguns nós ficam sem medida, e baseline de meia frota é afirmação sobre a outra metade que ninguém fez), contagem **desconhecida** não grava (`null` nunca vira `0`), e **sem `config.json` é erro de uso (exit 2)** — o arquivo nunca é criado implicitamente. **Com `{task}` também é erro de uso (exit 2)**, recusado antes do build: no escopo de task o verify recorta nós e testes, e baseline de recorte não é baseline — o gravador ainda recusa, uma segunda vez, qualquer medição que carregue `selection`. Também **não** persiste o stamp `verification`: é manutenção de config, não prova de gate. **`--linux` roda as suítes dentro de um container Linux** (`mcr.microsoft.com/dotnet/sdk:10.0` por default; sobrescrevível por `--linux-image` ou por `project.tests[].linuxImage`), e a árvore sob teste é **copiada** para o filesystem do container, não montada: o bind mount do Docker Desktop no Windows atravessa 9p/gRPC-FUSE, que é **case-insensitive** — rodar em cima do mount devolveria o mesmo verde do Windows e não provaria nada sobre defeitos de caixa (`Link=`, `Uri.TryCreate`), que são exatamente os que só aparecem no Linux. A raiz é montada **read-only** em `/src` só como origem do `tar` (com exclusão de `node_modules`, `bin`, `obj`, `.git` e os diretórios-contêiner de worktree), a cópia vive em `/work`, e cada nó roda de `/work/{cwd}`. Sem Docker, **erro de uso (exit 2)**, jamais um `skip`: "não deu para checar no Linux" lido como verde é o próprio defeito. `--linux` com `--skip-tests` se anulam (erro de uso), e um nó `e2e: true` é pulado no container com motivo (docker-em-docker fora de escopo). O resultado marca `nodes.tests.environment = { kind: 'linux-container', image }` — sem isso, um verde de container e um verde de host viram indistinguíveis no `feature.json` e a próxima pessoa não sabe o que foi provado. **`--no-persist` não grava nada no `feature.json`** — nem o histórico `verifications.*` nem o stamp `verification` que o gate lê: a execução vira sonda, nunca prova para gate algum, qualquer que seja a regra de carimbo do escopo. E o `--filter` só estreita o nó de testes (build, validadores e e2e rodam igual). Por isso a execução de evidência de eval do `morph-implement` §5 é `verify {f} --filter {id-do-nó-evals} --skip-e2e --no-persist`: só o eval, sem stack, sem escrever no `feature.json`. Forma de `project.tests[]`, prefixo `[tests/{id}]` do `feedback[]`, regra de contagem/baseline e a divisão loop × suíte: seção **Suítes de teste** abaixo.
- **`morph-spec e2e up|status|down <feature>`** — ciclo de vida do stack de verificação, compartilhando toda a lógica com o nó `e2e` do verify. `up [--fresh]` reusa um stack quente **só** quando ele provadamente serve o código atual: o carimbo compara `HEAD` + `git status --porcelain` + `git diff HEAD`, então qualquer edição, commitada ou não, invalida o reuso (a exceção é o serviço com bind mount do código, que serve o disco por construção). Na dúvida, rebuild — o layer cache do Docker faz disso barato. O motivo impresso diz **o que** decidiu o reuso, e as duas procedências nunca se confundem: `source unchanged since build` é o carimbo, `source is bind-mounted` é o disco. E a segunda só sai quando **todo** serviço com seção `build:` monta a raiz do projeto — um sidecar Playwright montando `./e2e` ao lado de uma app compilada em imagem não fala pela stack inteira; nesse caso quem decide é o carimbo. `--fresh` derruba com volumes (`down -v`), apaga o carimbo e re-roda o seed. `status [--json]` responde a pergunta que decide se um verde vale alguma coisa (no ar? saudável? construído do código atual?) e sai 0 só com os três; o `baseUrl` do payload é o endereço real da app — é dele que a `morph-review` tira a URL que vai no prompt do avaliador, em vez de deixá-lo adivinhar `localhost:3000`. `down [--volumes]` é best-effort e nunca bloqueia. Isolamento entre worktrees: `COMPOSE_PROJECT_NAME=morph-{feature}` separa os containers e o bloco de portas do fleet (`env-allocator`) separa as portas — um compose com porta fixa não pode ser isolado, e isso é avisado. O bloco vale nos **dois** caminhos (este comando e o nó do `verify`): `PORT`, `MORPH_PORT_BASE`, `ASPNETCORE_URLS` em dotnet, mais `PLAYWRIGHT_BASE_URL` apontando para a porta alocada. Por isso o `playwright.config.ts` precisa ler a base URL do ambiente em vez de cravá-la — com a URL cravada, a suíte bate numa porta vazia e o `ECONNREFUSED` se disfarça de app quebrada.
- **`morph-spec doctor [--full] [--mcp] [--reset]`** — checagem de saúde padrão (sem `--worktrees`): instalação (versões, `.claude/`, hooks, agents, grafo de codebase, templates, `state.json`) mais duas auditorias.
  **Ciclo de vida das features:** ordem de gates, feature sem identidade, pasta órfã, entrada de índice morta, `branch-drift` (a cópia da raiz atrasada em relação ao `morph/{f}` commitado) e `abandoned-scaffold`. Este último é o esqueleto que o `create --worktree` deixa para trás: `feature.json` sozinho, `in_progress`, **0 artefatos, 0 gates aprovados** e `morph/{f}` **sem commit próprio** (a branch ainda apontando para o commit de merge de outra PR). As quatro condições valem juntas e só depois de **24h** — recém-nascida tem exatamente a mesma forma, e alarme em todo `create` é alarme que ninguém lê. Fora de repo git, ou com a default branch indeterminável, o check cala (não dá para provar). Reparo: `morph-spec delete <f> --archive`, ou retome a feature.
  **Pull requests:** `pr-awaiting-merge` (PR aberto há mais de 7 dias sem cópia local nesta árvore) e `pr-merged-not-landed` (PR já `MERGED` mas `.morph/archive/{f}` ainda não chegou aqui — sinal de `git pull` faltando; branch `morph/*` cujo diff nunca tocou `.morph/features/{f}/` nem `.morph/archive/{f}/` — um PR só de docs — não é acusada, e diff ilegível ou truncado no teto do `gh` mantém o aviso). Offline-safe: sem `gh`, sem remoto no GitHub, ou qualquer falha, a checagem sai **não verificado** (nunca `ok`) — `[]` (perguntei, nada encontrado) e `null` (não pude perguntar) nunca se confundem, e o "não verificado" nunca deixa o `doctor` mais barulhento nem muda o exit code.
  Ver `--worktrees` abaixo para a reconciliação de worktrees (modo separado), e `--ai` para a auditoria de projeto .NET com agentes.
- **`morph-spec doctor --ai`** — auditoria de um projeto **.NET com agentes**, em superfície própria: **não entra no `--full` nem no `doctor` default**. O renderer do `--full` manda tudo que não é `ok`/`warn` para `✗` (não tem o ramo `skip → ○`), então uma checagem de IA lá seria vermelha mesmo dizendo "não verificado"; e cinco linhas `○` permanentes no comando mais rodado do dia é ruído com custo e sem informação. Combinar `--ai` com `--full`/`--mcp`/`--worktrees` é **erro de uso**: exit 1, sem rodar nada — cada superfície tem o seu relatório e o seu código de saída.

  **Cinco checagens.** `pin-drift` (o que o `.csproj` declara × o que `framework/ai-pin.json` fixa), `removed-api` (símbolos renomeados antes do 1.0 GA do `Microsoft.Agents.AI`), `pricing-missing` (todo alias do registry de modelos tem tarifa?), `api-per-alias` (todo alias declara `api`?) e `prompt-source` (as `Instructions` do agente têm fonte declarada?).

  **Só `pin-drift` produz `error`, e só na faixa flutuante** — é a única classe com prejuízo medido: uma faixa `2.*` sobe sozinha para uma versão com assinatura mudada e o `MissingMethodException` chega **depois** de a chamada ao modelo ser paga. Versão fixa ≠ pin é `warn` nomeando as duas versões; pacote de IA presente no projeto e ausente do pin é `warn` descrito como **lacuna do pin**, nunca como defeito do projeto. As outras quatro checagens informam e nunca travam. **Exit code:** 1 com ao menos um `error`; 0 caso contrário — `warn` e `skip` nunca mudam o código de saída.

  **Quatro condições de silêncio**, todas com razão obrigatória (um "não verificado" sem motivo é ruído): (1) nenhum `.csproj` alcançável — e aí **o pin nem é lido**; (2) nenhum `ai-pin.json` resolvível — falta de régua é ausência de dado, nunca defeito do projeto; (3) `.csproj` presentes e nenhum pacote de IA entre eles — um projeto .NET sem MAF não é um projeto MAF quebrado; (4) `.csproj` ilegível devolve **não verificado** para aquele arquivo, nunca "sem drift". Quando NENHUMA checagem teve insumo, o relatório sai em **uma linha** em vez de cinco `○` vazios. O gate é o **arquivo em disco**, nunca `project.stack` do `config.json`: o campo declara intenção, o arquivo é fato. Nada alcançável por `--ai` escreve — `readAiPin()` sim (leitura pura); os geradores que escrevem por padrão, jamais.
- **`morph-spec doctor --worktrees [--prune] [--json]`** — reconcilia o registro do git × as pastas em disco × as features conhecidas. Worktrees órfãos acumulavam **invisíveis**: o `fleet` só reporta worktrees em branch `morph/*`, então um worktree em outra branch (resíduo de finish anterior) não aparecia em lugar nenhum. `--prune` roda os reparos seguros: `git worktree prune` (registros de pastas que já sumiram), **remove os diretórios VAZIOS** em `worktrees/`/`.worktrees/` que o git não registra (`unregistered-dir` com `empty: true` — resíduo de finish; pasta com zero itens não tem o que perder, então some sem perguntar) **e** limpa o registro `worktree` de qualquer feature cujo `.path` aponte para um diretório já sumido (`dangling-registration` — a direção simétrica do `no-feature`: não é o worktree sem feature, é a feature com um ponteiro morto na raiz; a ordem é carregada: a pasta vazia sai primeiro, e o ponteiro que a nomeava é limpo no MESMO run). Apagar pasta que ainda **guarda** alguma coisa continua só reportado com o comando exato, nunca executado. **Nunca sugere remover worktree com trabalho não integrado:** antes de emitir `no-feature` **ou** `non-morph-branch` (os dois diagnósticos que sugeriam remoção), checa commits fora da default e PR aberto — havendo qualquer um, o diagnóstico vira `unintegrated-work` (severidade **info**, não conta como inconsistência e não faz o comando sair 1) e o `fix` diz para integrar, não para apagar. "Sem feature conhecida" é fato de bookkeeping (o `finish` arquivou a feature), não veredito sobre o trabalho — a versão anterior recomendou `worktree remove --target` no worktree que carregava o único exemplar de um PR aberto. **A quarta fonte não é um arquivo: as stacks docker.** Um container sobrevive à remoção do worktree, da branch e da feature — nenhuma das três reconciliações acima consegue vê-lo, e ele segue segurando o bloco de portas. Dois achados novos, ambos `severity: warn` (reportados, nunca falha do comando): `docker-stack-orphan` — projeto compose `morph-*` **deste repositório** (o arquivo compose fica sob a raiz; o prefixo `morph-` é global no host, e reportar a stack de outro repo seria sugerir `down -v`, com volumes, na stack viva do vizinho) de pé para uma feature que esta árvore não conhece, com o teardown exato (`docker compose -p morph-{slug} down -v`); e `docker-port-held` — porta do bloco de uma feature (derivada de `portLayout`, nunca recalculada) publicada por um container de **outro** projeto compose, nomeando o container que a segura. A auditoria devolve **nada** sem tentar coisa alguma em três situações: nenhuma feature alocou bloco de porta (sem registro em `<git-common-dir>/morph-spec/`), docker indisponível, ou qualquer chamada ao docker saindo != 0 / respondendo algo que não é JSON. Por isso ela é estruturalmente silenciosa num repo que não containeriza — e por isso ela **não** entra no `doctor --full`, onde toda checagem tem de ser satisfazível.
- **`morph-spec report-check <feature> [task] [--json]`** — audita os relatórios de sub-agent contra o trabalho que realmente entrou. **Relatório ausente NÃO quer dizer que o agente falhou** — ele pode ter feito tudo e perdido o meio de gravar. E relatório PRESENTE não basta: `report-stale` é o relatório completo porém gravado ANTES do fim do trabalho (o agente relatou cedo e continuou escrevendo) — completo e mentindo. O comando responde a pergunta certa (`work-landed-report-missing` → aceite degradado, NÃO re-despache; `no-work-no-report` → re-despache uma vez; `report-stale` → atualize a partir do campo `observed`, NÃO re-despache). A evidência de `observed` vem do `events.jsonl` (todo `Write`/`Edit` observado na janela da task, cruzada com `task-times.json`), não de `outputs`, que é auto-declarado pelo próprio agente — writes do orquestrador sob `.morph/` e o do próprio relatório são excluídos. Sem telemetria no projeto, o comando volta ao comportamento anterior (fail-open). Protocolo completo em MORPH.md §6.

## Suítes de teste (`project.tests[]`) e a divisão loop × suíte

O nó `tests` do `verify` é **plural**: um projeto pode declarar quantas suítes tiver em
`.morph/config/config.json → project.tests[]`, e cada uma vira um nó com comando, diretório, relógio e
baseline próprios. Contrato declarativo em `.morph/framework/schemas/config.schema.json`; a autoridade
em runtime é `validateTestNodes()` — schema que ninguém chama é decoração.

```json
{
  "project": {
    "tests": [
      { "id": "dotnet", "runner": "dotnet", "cwd": ".",
        "command": "dotnet test tests/App.Tests/App.Tests.csproj --nologo -v q --no-build",
        "build": ["tests/App.Tests/App.Tests.csproj"], "expectedCount": 3057, "timeoutMs": 600000 },
      { "id": "vitest", "runner": "vitest", "cwd": "catalog-admin",
        "command": "npx vitest run --reporter=default", "expectedCount": 3082 },
      { "id": "playwright", "runner": "playwright", "cwd": "catalog-admin",
        "command": "npx playwright test", "e2e": true }
    ]
  }
}
```

| Campo | Obrigatório | Semântica |
| --- | --- | --- |
| `id` | **sim** | `^[a-z0-9][a-z0-9-]*$`, único no array. É o que `--filter` casa e a chave de `runs[]`. `id` duplicado é erro de config, não empate silencioso — **os dois** lados falham |
| `command` | **sim** | Comando literal, executado por shell a partir de `cwd`. Nunca é reescrito pelo verify — **com uma exceção:** o recorte do escopo de task (*O recorte por task*, abaixo) estreita o comando e o registra: `runs[].command` é o que rodou, `runs[].selection.declaredCommand` o declarado |
| `cwd` | não (`"."`) | Relativo à raiz. Path que resolve **fora** da raiz é recusado (endereça outra árvore); `cwd` inexistente é `fail`, não `skip` — um skip esconderia erro de plano atrás de gate verde |
| `runner` | não (inferido) | `dotnet` \| `vitest` \| `jest` \| `node-test` \| `playwright` \| `none` \| `evals`. Escolhe o extrator de contagem e se há prebuild. `none` = contagem DESCONHECIDA, nunca zero. `evals` é o único que **nunca é inferido** e o único que exige um bloco próprio — ver a subseção abaixo |
| `expectedCount` | não | Baseline. **Gravada só por `verify --record-baseline`**, nunca digitada. Ausente = `null` (sem baseline); `0` **declarado** é uma baseline real |
| `timeoutMs` | não | Teto deste nó. Existe porque o default derivado (120 s × projetos encadeados) é desenhado para `dotnet test` e é hostil a uma suíte Node de doze minutos |
| `build` | não | Projetos a compilar antes (só `runner: "dotnet"`). Declarado em nó não-dotnet é erro de config, não campo silenciosamente ignorado |
| `e2e` | não (`false`) | Exige a stack de pé. Roda em escopo de feature; é pulado **com motivo** no loop por task e dentro do `--linux` |
| `linuxImage` | não | Imagem do container deste nó sob `--linux` |
| `groups` | não (default pelo `runner`) | A que `group` de task o nó responde no recorte por task: subconjunto não vazio, sem repetição, de `backend` \| `frontend` \| `tests` \| `infra` \| `docs` (o mesmo enum do `group` de `tasks.json`). Ausente: `dotnet`/`node-test` → `backend`; `vitest`/`jest`/`playwright` → `frontend`; `none`/`evals` não têm default — nó sem grupo nunca sai do escopo pelo `group`. Valor inválido é erro de config (`invalid-test-node`), como nos demais campos |


### O runner `evals`: regressão de prompt e de modelo com cache commitada

Um eval **é** `dotnet test` com um filtro de categoria — mesmo comando, mesmo `cwd`, mesmo relógio,
mesmo código de saída. Por isso ele entra como um **sétimo valor de `runner`** dentro de
`project.tests[]`, e não como um sexto nó do `verify`: assim herda `--filter`, `--linux`,
`expectedCount`, o prefixo `[tests/{id}]` e o rollup sem uma segunda cópia de nada.

```json
{
  "project": {
    "tests": [
      { "id": "unit", "command": "dotnet test tests/App.Tests/App.Tests.csproj" },
      { "id": "evals", "runner": "evals",
        "command": "dotnet test tests/App.Tests/App.Tests.csproj --filter-trait Category=Eval",
        "evals": { "cachePath": "tests/App.Tests/eval-cache",
                   "datasetPath": "tests/App.Tests/eval-datasets",
                   "envFile": ".env.evals" },
        "timeoutMs": 900000 }
    ],
    "evals": { "watch": ["tenants/**", "product/**", "config/model-registry.json"] }
  }
}
```

| Campo | Obrigatório | Semântica |
| --- | --- | --- |
| `runner: "evals"` | **sim** | O único valor de `runner` que **nunca é inferido**: um comando de eval é literalmente `dotnet test`, e farejá-lo classificaria toda suíte dotnet como eval — pior, o erro inverso (eval declarado e lido como dotnet) derrubaria o pré-voo da cache em silêncio |
| `evals.cachePath` | **sim** | Raiz da árvore de cache de respostas, relativa à raiz do projeto, e **commitada**. Fora da raiz é recusado, mesma regra do `cwd`. **Sem default**: um default silencioso criaria árvore de cache num lugar que ninguém declarou. **Prefira um caminho raso**: as entradas ficam em `cache/<cenário>/<iteração>/<chave-curta>/`, e no Windows um caminho absoluto acima de 260 chars faz o `git` avisar `Filename too long` **no stderr**, commitar parte dos arquivos e sair com código 0 — medido: 1 de 9 arquivos no commit, com todo canário sobre a árvore reportando limpo por nunca tê-la enxergado. A árvore também precisa de `<cachePath>/** -text` no `.gitattributes`, senão o `text=auto` normaliza os payloads opacos e os bytes não sobrevivem ao clone |
| `evals.datasetPath` | não | Dataset versionado, em `{agente}/{tenant}/{cenário}.json`. Default: `eval-datasets` ao lado de `cachePath` |
| `evals.envFile` | não (`.env.evals`) | Arquivo **gitignored** com a credencial, lido **só** por `--refresh-cache`. Mesmo padrão de `e2e.auth.envFile` — credencial nunca viaja no config commitado |
| `project.evals.watch[]` | não | Globs que significam "isto é prompt ou modelo". Casados contra o que a feature mudou (diff contra a default **mais** arquivos novos não rastreados) e lidos pela regra de Gate 3 |

**O que o CLI dita, e por quê.** O nó recebe `MORPH_EVAL_CACHE`, `MORPH_EVAL_DATASET`,
`MORPH_EVAL_RESULTS`, `MORPH_EVAL_MODE`, `MORPH_EVAL_SUMMARY`, `MORPH_EVAL_ENVFILE` e
`MORPH_EVAL_PIN`. O caminho do sumário é escolhido **pelo CLI** (arquivo temporário) para que dois nós
de eval no mesmo `verify` não colidam, e `MORPH_EVAL_PIN` leva só os pacotes que decidem o **formato**
da cache — a família `Microsoft.Extensions.AI` — para que subir um pacote de provider não invalide uma
árvore por nada.

**Os estados da cache são julgados ANTES de o comando rodar**, e todos os quatro são `fail` nomeado,
**nunca** `skip`:

| Estado | Motivo |
| --- | --- |
| árvore ausente | `eval-cache-missing: {caminho}` |
| árvore sem nenhuma entrada | `eval-cache-empty: {caminho}` — distinto de ausente: diretório commitado vazio é mais provavelmente engano de `.gitignore` |
| sem carimbo `.morph-eval-cache.json` | `eval-cache-unstamped: …` — a árvore não diz sob quais versões foi gravada |
| carimbo divergente do `ai-pin.json` | `eval-cache-stale-pin: {gravada} vs {pin}` — nomeia **as duas** versões |
| `--refresh-cache` sem o `envFile` | `eval-refresh-no-credential: …`, e a árvore fica **intacta** |

Pagar o relógio de uma suíte inteira para descobrir no fim que a cache não servia é a pior ordem
possível. E um `skip` aqui seria gate verde sobre verificação que não aconteceu — o defeito que a
lista plural de nós existe para matar.

**Escopo.** O nó de eval **não roda no `verify` por task**: é pulado com
`eval node runs at feature scope only`, o mesmo tratamento de `e2e: true`. O relógio de uma task não
paga uma suíte de eval, e o resultado que o Gate 3 lê é o de escopo de feature.

**Contagem.** A contagem do nó é o `scenarios` do sumário — e só ele: sem sumário legível ela é
**`null`, DESCONHECIDA, jamais zero**, e um sumário ilegível não pode fabricar uma queda contra
`expectedCount` nem virar baseline. E um sumário dizendo
`scenarios: 0` com saída 0 é `fail` (`eval-empty-run`): nada falhou porque nada rodou. A unidade do
nó é o **cenário**: a contagem que o extrator lê da saída conta `[Fact]`, e nunca é comparada com o
`expectedCount` (antes era, e um eval 24/24 saía `count-drop: esperado 24, executado 2`). O arquivo do
sumário é apagado antes de cada rodada: um sumário herdado de outra rodada nunca é lido como desta.

**Cache fria avisa, não reprova.** Um run que foi ao provider marca `cache: "cold"` e emite warning de
que a árvore mudou e precisa ser commitada. Gastar dinheiro sem pedir é ruim; mentir sobre o resultado
é pior — e sem o aviso a cache nova ficaria só no disco de quem rodou.

**`--refresh-cache` regrava a árvore contra o provider real, e nunca é implícito** — regravação
automática é chamada paga que ninguém pediu. Três recusas, todas **erro de uso (exit 2)** julgadas
**antes** de qualquer mutação (a mesma disciplina do `finish --merge`, que valida a raiz antes de
arquivar): nenhum nó `runner: "evals"` declarado (sucesso silencioso ensinaria a rodar uma flag que não
faz nada), junto com `--skip-tests` (as duas se anulam), e um `--filter` que exclui todos os nós de
eval. A ausência de credencial é julgada **por nó** no pré-voo, ainda antes do comando.

**`--linux` com nó de eval declarado é erro de uso (exit 2).** O canal `MORPH_EVAL_*` carrega caminhos
**absolutos do host**, e o container roda uma **cópia** da árvore em `/work`: rodar assim mesmo falharia
por um motivo que não tem nada a ver com o eval, e pular seria verde sobre nada. A saída é nomeada —
rode o eval fora do container, ou exclua-o com `--filter`.

**O stamp ganha `nodes.evals`** (`pass` | `fail` | `skip` | `null`), derivado das entradas de `runs[]`
com `runner: "evals"`. `null` é "este projeto não declara nó de eval" e é **distinto** de `skip`
("declara, e esta corrida não o executou") — o `gate-check` pausa o Gate 3 por razões diferentes nos
dois casos, porque as ações são diferentes. Quem lê `verification.nodes.tests` continua lendo a mesma
string.

**A regra de Gate 3 PAUSA, nunca bloqueia.** Feature que tocou caminho de `project.evals.watch[]` sem
eval verde carimbado no mesmo commit faz o gate de `review` pausar, com a razão nomeando o caminho
tocado. `evalsStatus` e `promptModel` são **fatos de disco** e **não** entram em
`OVERRIDABLE_SIGNAL_KEYS`: um `--signals` capaz de afirmar "o eval rodou" derrotaria a política que a
regra existe para tornar determinística. E nada disso produz número — a procedência do score composto
continua sendo, exclusivamente, o token gravado pelo avaliador independente.

Conteúdo (o que é um bom dataset, o que entra na chave de cache, TTL, o que se commita):
`ai-agents/evals-with-cache.md`.

**Fail-closed, de propósito.** Um nó malformado não é ignorado: vira `status: 'fail'` com motivo
`invalid-test-node: {id}: {problema}`, e reprova o gate **mesmo filtrado** por `--filter` (filtrar é
"não gaste relógio com esta suíte", não "não me conte que meu config está quebrado"). O regime antigo —
`JSON.parse` + optional chaining fail-open — fazia um typo em `tests` sumir sem aviso e devolvia o
projeto ao comportamento anterior, que é exatamente o defeito.

**`tests: []` ≠ `tests` ausente.** O array vazio é declaração explícita de "não há suítes" (um nó
`skip`); a chave **ausente** cai no fallback de quatro passos, que continua íntegro e é o caminho de
todo projeto criado antes desta chave: `project.testCommand` → `.sln`/`.csproj` na raiz → varredura de
`*Tests.csproj` (que **não enxerga worktrees**) → `package.json` com `scripts.test`, que dá o nó `default`. **Ao lado dele**, quando nada foi declarado
(nem `project.tests[]` nem `project.testCommand`), entra um nó por `package.json` que cite `vitest` ou
`jest` em `dependencies`/`devDependencies`, procurado em `.`, `src/frontend`, `src/web`, `frontend`,
`apps/web`, `packages/app` e `project.frontendPath` (quando fica dentro da raiz): id `vitest`/`jest` na
raiz e `vitest-src-web`/`jest-frontend` fora dela, comando `npx vitest run` / `npx jest --ci`. É palpite,
não declaração: um `package.json` ilegível é pulado em silêncio, e o da raiz não entra quando o próprio
`default` já é o `npm test`. Um `project.testCommand` declarado vence por inteiro — nada é somado a ele.
Sem `config.json` nenhum vale a mesma regra (o repositório-fonte do morph-spec resolve só o `default`,
`npm test`), e nada aqui depende do arquivo existir. Com algo declarado, o `doctor --full` avisa (WARN,
nunca `✗`) sobre um `package.json` de frontend que nenhum nó — ou o `testCommand` — cobre.

**Forma do resultado.** `nodes.tests` continua um objeto com `status` (o rollup) e ganha `runs[]` — uma
entrada por nó, com `id`, `command`, `cwd`, `status`, `durationMs`, `count`, `expectedCount` e
`warnings[]` — mais `source` e `environment`. **Um verde nunca esconde um vermelho:** o rollup reprova
se qualquer run reprovar, e um nó que estoura o relógio não interrompe os seguintes. As linhas de
`feedback[]` são prefixadas por **`[tests/{id}]`** (era `[tests]`): com N suítes, uma linha que não diz
**qual** falhou manda o leitor para a metade errada do repositório.

**Contagem e a regra assimétrica.** O extrator lê o sumário do runner (somando sumários encadeados no
`dotnet` — a linha do VSTest ou o bloco do Microsoft.Testing.Platform, este em pt-BR e en-US; um bloco
MTP por invocação, mesmo numa solução de vários projetos —, e exigindo a linha `Tests` — não
`Test Files` — no vitest). Contra `expectedCount`: **queda
reprova** (mesmo com exit 0 — é regressão silenciosa), **alta apenas avisa** e pede regravação
(reprovar a alta ensinaria o agente a apagar testes), e contagem **desconhecida** é `null`, jamais `0`
— um zero fabricaria queda a partir de "não deu para ler o output". O aviso de alta sai **mesmo com o
nó verde**, que é o único lugar onde ele pode existir.

**Prebuild e `--no-build` (por que um `dotnet test` verde podia mentir).** Num nó `runner: "dotnet"`,
os alvos de `build[]` — ou, na falta, os `.csproj` extraídos do próprio `command` — são compilados
antes da execução, com `dotnet build "<alvo>" --nologo -v q`. Compilação que falha reprova o nó com
`test-build-failed` e a suíte **não roda**. E um `command` com `--no-build` que **não** foi precedido de
build nesta invocação reprova com `stale-binary`, também sem executar: o predicado é "houve build
aqui", nunca "a flag está ausente". Sem isso, um projeto de teste que deixou de compilar era executado
a partir do binário do último build bom e reportava verde. `--no-build` continua legítimo — com
`build[]` declarado ele roda normalmente, e o comando do usuário nunca é reescrito para isso. O prebuild é
**condicional** (só com `build[]` declarado ou com `--no-build` no comando): um `dotnet test X.csproj`
sem `--no-build` já compila sozinho, e pré-compilar ali só dobraria o relógio do caminho quente.

**O ciclo da baseline, e o número que só sai à mão.** `verify --record-baseline` é a única via de
escrita: mede cada nó numa execução real e grava `expectedCount` de volta no `config.json` (escrita
atômica `tmp.{pid}` + rename, preservando todo o resto do arquivo). Recusa **quatro** vezes — execução
vermelha, execução **parcial** (sob `--filter`, mesmo com o verify verde: baseline de meia frota é
afirmação sobre a outra metade que ninguém fez), contagem desconhecida, e **sem `config.json` ou sem
`project.tests[]`** (erro de uso, exit 2; o arquivo nunca é criado implicitamente — gravar os nós que o
fallback sintetiza exigiria inventar a declaração que o comando veio manter). E, antes das quatro, `{task}` na
linha de comando é erro de uso (exit 2): baseline é da frota inteira, e o escopo de task recorta nós e testes. Verde com gravação
recusada sai **exit 1**, com a razão em **stderr** (sobrevive ao `--json`). A gravação é governada
pelos **nós de teste**, não pelos quatro nós do verify: um `validators` vermelho não muda quantos
testes rodaram. E o comando **não** persiste o stamp `verification` — é manutenção de config, não prova
de gate; não o use como atalho de Gate 3. **Consequência a conhecer:** um `expectedCount` digitado à
mão **alto demais** (9 onde a suíte roda 7) deixa o verify vermelho por `count-drop`, e então
`--record-baseline` recusa — preservando o 9. **Não há flag de regravação forçada:** rebaixe ou apague
o número à mão e então re-meça pelo comando. O caso oposto se conserta sozinho — número baixo demais
passa com aviso de alta, e o comando grava.

**A divisão loop × suíte.** É a resposta ao "a suíte inteira não cabe numa chamada":

| Momento | Comando | Por quê |
| --- | --- | --- |
| Loop por task | `verify {f} {task}` (recorte automático: o `group` escolhe os nós, os `outputs` estreitam os testes — ver *O recorte por task*; `--filter {stack-da-task}` só para forçar os nós, `--full` para desligar) | O relógio de uma task não paga a suíte inteira; a task tocou **um** stack |
| Gate 3 | `verify {f} --json` (sem filtro) | A prova que o avaliador lê tem de cobrir **todos** os nós |
| CI | a suíte inteira, todos os nós | idem |
| Evidência de eval (task com `evidencias/` em `outputs`) | `verify {f} --filter {id-do-nó-evals} --skip-e2e --no-persist` | Só o nó `evals` (pulado no escopo de task), sem stack e sem carimbo — não é a verificação da task, é a prova que o `morph-implement` §5 grava |

> **Não existe constante de 600 s neste CLI** (`grep -rn "600000" src/ framework/` → zero). O teto que
> corta uma suíte longa é o do **Bash do harness**, externo ao morph-spec, e não há o que "subir". Os
> relógios que o CLI de fato tem: build **120 000 ms**, tests **120 000 ms por projeto de teste
> encadeado**, health do e2e **180 000 ms**, specs Playwright **300 000 ms**, mais `timeoutMs` por nó,
> que substitui o default do `tests` quando ele não serve. `--timeout <ms>` cobre build **e** tests.
> **A suíte inteira de um projeto grande não cabe numa invocação de Bash do harness:** rode-a em
> background, ou por partes com `--filter`, e deixe a execução completa para o Gate 3 e para o CI.
> Quando o relógio estoura, o nó diz isso com nome (`timeout após N ms na execução X de Y — nenhum
> teste falhou`) em vez de devolver log truncado.

### O recorte por task: `verify {f} {task}`

Sem task, o `verify` roda tudo e **nunca recorta** — é a prova do Gate 3. Com `{task}`, ele lê de
`tasks.json` o `group` e os `outputs` da task e toma duas decisões independentes:

1. **Quais nós.** O `group` da task escolhe os nós pelo campo `groups` de cada um (ou pelo default do
   `runner`, na tabela de campos acima). Um nó que serve outro grupo sai do escopo e **aparece** em
   `runs[]` como `skip` com o motivo (`out of task scope: …`) — a menos que seja dono de um arquivo de
   teste da task (rede para task mal agrupada) ou não tenha grupo algum. Grupo que nenhum nó serve (por
   default, `tests` e `infra`) ou task sem `group` → todos os nós. Task `docs` roda só os nós donos de
   algum arquivo dos `outputs` — dentro do `cwd` do nó, ou compilado pelo `.csproj` de um link do
   comando; um nó de `cwd` `.` é dono de tudo, então no layout de nó único a task `docs` roda o nó inteiro.
   Arquivo dos `outputs` que ainda não existe em disco não conta: uma task `docs` cujos `outputs` ainda
   não existem tira **todos** os nós do escopo, e o rollup `tests` sai `skip` (`every test node was
   skipped`).
2. **Quais testes dentro do nó.** Os arquivos de teste que estão nos `outputs` estreitam o comando
   declarado — **só** quando ele tem uma forma reconhecida. Qualquer outra coisa roda o nó **inteiro**,
   com o motivo em `runs[].selection.reason`.

| Runner | Forma reconhecida do comando | O que o recorte acrescenta | Prova de que algum teste rodou |
| --- | --- | --- | --- |
| `vitest` | um comando só, com `vitest` e `run` (sem `;`, `&`, `\|` nem `npm`/`pnpm`/`yarn`/`bun run`) | os caminhos dos arquivos, relativos ao `cwd` | contagem > 0 |
| `jest` | um comando só, com `jest` | `--runTestsByPath "<caminho>" …` | contagem > 0 |
| `dotnet`, VSTest | links `dotnet test …` encadeados só por `&&` | `--filter "FullyQualifiedName~<Tipo>\|…"`; link sem projeto, ou com `.sln`, é re-apontado para o `.csproj` dono (`dotnet test "<rel.csproj>" …`) | contagem > 0 **do nó** — numa cadeia, a soma dos links (limite (h)); o VSTest sai 0 com filtro que não casa nada |
| `dotnet`, MTP + xunit.v3 | idem, com `global.json` → `test.runner: Microsoft.Testing.Platform` | `--filter-class "*<Tipo>*"` por tipo; link sem projeto ganha `--project "<rel.csproj>"` | contagem > 0 do sumário MTP (pt-BR e en-US); sem sumário legível, exit 0 (o MTP sai 8 com zero testes) |
| `dotnet`, MTP + MSTest/NUnit | idem | `--filter "FullyQualifiedName~<Tipo>\|…"` | idem |

`node-test`, `playwright` e `none` não têm recorte dentro do nó: rodam inteiros (nós `e2e: true` e
`evals` seguem pulados no escopo de task, como antes). **Piso de projeto no MTP:** `--minimum-expected-tests
1` na linha de comando **não** sobrescreve o piso do projeto — um recorte verde sairia 9 —, mas a
propriedade global do MSBuild sim (medido). Por isso, quando o piso vem de **um** lugar só, o recorte
acrescenta `"-p:TestingPlatformCommandLineArguments=<o mesmo valor, com o piso em 1>"`. Um lugar só é: um
único `<TestingPlatformCommandLineArguments>` literal no próprio `.csproj`, sem atributo, dentro de um
`<PropertyGroup>` sem atributo direto sob `<Project>` (nunca num `<Target>`, num `<Choose>`/`<When>` ou num
grupo com `Condition=`), sem `TreatAsLocalProperty` e sem `<Import>` no `.csproj`; sem `$(…)`, `;`, `,`,
aspas, crase, `\`, `!`, `@`, `%` ou entidade no valor; a propriedade ausente do `.csproj.user` e de todo
`Directory.Build.props/.targets/.rsp` e `Directory.Packages.props` até a raiz do **disco** (o MSBuild não
para no repositório); nenhum piso no `*testconfig.json` ao lado; e o comando sem declarar a propriedade ele
mesmo. O `-p:` troca o valor **inteiro**, então os outros argumentos vão junto, literais; verde sai 0, zero
testes segue saindo 8, e nada é recompilado (medido). É o caso do `ai-kit` do framework e dos projetos
copiados dele. Piso em qualquer outra forma, sob VSTest ou num link que roda mais de um projeto roda o nó
**inteiro**. **O que fica fora:** um pacote NuGet cujo `build/*.targets` acrescente argumentos à
propriedade não é lido, e esses argumentos caem durante o recorte; um `--explicit` do xunit.v3 chegando por
aí muda **quais** testes rodam. `<VSTestTestCaseFilter>` declarado também roda o nó inteiro (um `--filter`
acrescentado o substituiria em silêncio).

**O seletor .NET é o TIPO, sem namespace.** Cada `class`/`record`/`struct`/`interface` declarado nos
arquivos de teste da task, em qualquer profundidade, vira um seletor com o nome **nu**. Medido no MTP +
xunit.v3: `--filter-class "*<Tipo>*"` roda o tipo em qualquer namespace, os aninhados `Outer+Inner`,
arquivo com BOM e namespace global, e todo tipo cujo nome contém o seletor; `FullyQualifiedName~<Tipo>`
é substring, mesma semântica. O recorte pode **acrescentar** testes, nunca tirar os do arquivo. (Sem o
curinga à esquerda, `<Tipo>*` não roda nada — medido, exit 8.)

**Falha para o lado seguro (.NET).** O `.cs` é lido por uma varredura que não é parser, e o recorte não
depende de ela acertar: toda palavra-chave de tipo contada no **texto cru** do arquivo tem de ser uma
declaração que a varredura nomeou, senão o nó roda inteiro. Também rodam o nó inteiro, cada caso com o
seu motivo:

- arquivo que declara uma classe `abstract` (os testes rodam sob o nome das derivadas, talvez em
  arquivos que a task não tocou);
- arquivo do projeto de teste **sem** atributo de teste — helper, fake, fixture: quem o usa está em
  outro arquivo. Atributo de teste é o **nome inteiro** (`Fact`, `Theory`, `Test`, `TestMethod`,
  `DataTestMethod`, `TestCase`, `TestCaseSource`, com ou sem `Attribute`, qualificado ou não) numa
  seção de atributo: o `, TestContext ctx` ou `, Factory f` do construtor de um helper não conta;
- **qualquer** arquivo que não é C# e que o nó pode rodar (dentro do `cwd`, ou cujo projeto mais
  próximo é o de um link) quando o projeto mais próximo dele (`.csproj`/`.fsproj`/`.vbproj`) é de teste —
  `.razor` do bUnit, `.feature` do Reqnroll/SpecFlow, snapshot `.verified.txt` do Verify, dado de teste —
  ou, fora de projeto de teste reconhecido, `.razor`/`.cshtml`/`.fs`/`.fsi`/`.fsx`/`.vb` que lê como
  teste: a varredura só lê C#, então ele não tem seletor, e os testes que ele alimenta podem estar em
  arquivos que a task não tocou. Arquivo de projeto que não é de teste (componente ou `appsettings.json`
  da app) não custa nada;
- arquivo tocado que parece teste mas o reconhecedor não classifica como tal (nunca é deixado de fora);
- literal ou comentário sem fim, chave desbalanceada, buraco de interpolação cortado por aspas,
  diretiva de pré-processador entre a palavra-chave e o nome;
- link cujo projeto não resolve para um `.csproj` existente dentro da raiz (`--project=X.csproj`,
  `--project:X.csproj`, `.csproj` fora da raiz);
- projeto que faz o MTP **ignorar um exit code** — `--ignore-exit-code` ou
  `TESTINGPLATFORM_EXITCODE_IGNORE` citado em qualquer ponto do `.csproj` ou de um
  `Directory.Build.props/.targets` até a raiz (em `<TestingPlatformCommandLineArguments>` ou numa
  propriedade que ela referencia, como `$(MtpExtraArgs)`), ou num `*testconfig.json` ao lado — e
  `TESTINGPLATFORM_EXITCODE_IGNORE` não vazia no ambiente do verify: sem sumário legível, o exit 0 é a
  prova de que o recorte rodou teste, e com o 8 ignorado zero testes saem 0 (roda inteiro mesmo que o
  sumário fosse ser legível — isso é decidido antes da rodada);
- comando que já carrega `--filter`/`--treenode-filter`, argumentos depois de `--`,
  `--ignore-exit-code` ou `--minimum-expected-tests` (inclusive dentro de `-p:`); `global.json`
  ilegível; MTP com framework sem filtro de classe conhecido; alvo MTP de solução ou pasta, alvo VSTest
  de pasta; `--solution`, `--directory`, `--test-modules` e `--project` que não aponta um `.csproj` —
  em token separado **ou** com o valor depois de `=`/`:` num token só (`--solution=X.sln`,
  `--test-modules:…`, `--project=<pasta>`); nome de tipo fora de `[A-Za-z0-9_.]`.

O dono de um arquivo tocado é o `.csproj` do link, nunca o `cwd` do nó: num `dotnet test --project
../B.Tests/B.Tests.csproj`, os arquivos de `B.Tests` são desse link. Link cujo projeto não compila
nenhum arquivo da task sai da cadeia. **O custo, dito:** texto com cara de declaração num comentário ou
string (`// the class under test`), restrição genérica seguida de nome (`where T : class where U : …`),
`: class // nota`, `record with`, `$"{d["k"]}"`, atributo de teste por alias
(`using F = Xunit.FactAttribute;`) — tudo isso roda o nó inteiro. Custa relógio, não um teste pulado.

**O veredito da rodada recortada** é próprio:

- **Nunca compara com `expectedCount`**: a baseline é da suíte inteira — um recorte que roda 12 de
  3.082 não é queda, e um superconjunto não é alta. Nó rodado **inteiro** no escopo de task continua
  com a regra de contagem de sempre.
- Verde exige prova de que rodou teste: contagem > 0 **do nó** (numa cadeia VSTest, a soma dos links —
  limite (h)). No MTP a contagem vem do sumário (lido em pt-BR e en-US); sem sumário legível, a prova é
  o exit 0 (por isso o que faz o MTP ignorar exit code roda inteiro — acima; o que o varredor não vê é
  o limite (f)). Contagem 0 → `narrowed-zero-tests: o recorte executou 0 testes` — no MTP inclusive
  com exit 0, que é o que um exit 8 ignorado produz (medido). Como o MTP imprime um bloco de sumário por
  invocação, numa cadeia `&&` **cada** bloco é julgado: um bloco com `total: 0` é vermelho
  (`narrowed-zero-tests: um link do recorte executou 0 testes …`) mesmo que a soma dos links dê mais que
  zero. Verde sem contagem legível (e sem o exit 0
  do MTP) → `narrowed-count-unknown` — desconhecido nunca vira verde.
- Vermelho com a assinatura de zero testes do runner (dotnet: exit 8; VSTest: `No test matches the
  given testcase filter` / `Nenhum teste corresponde ao filtro de testcase`; vitest: `No test files
  found`; jest: `No tests found`) e sem contagem > 0 → `narrowed-zero-tests (<runner>, exit <n>)`.
  Qualquer outro vermelho, exit 9 inclusive, mantém a razão crua do executor.
- As contagens de vitest, jest e dotnet são lidas depois de tirar os códigos ANSI da saída.

**O que fica registrado.** Cada entrada de `runs[]` ganha `selection` — `null` no escopo de feature; no
de task, `mode` (`narrowed` / `whole` / `out-of-scope` / `full`), `reason`, `declaredCommand`, `files`,
`selectors` e `zeroGuard`. `runs[].command` é o que rodou. `nodes.tests.scope` (`scope`, `derivation`,
`group`, `testFiles`) sai só no `--json`, e nada da `selection` vai para o `feature.json`: o stamp grava
`scope: 'task:{id}'`, e o `gate-check` do `review` pausa sobre ele (seção **Gates e estado**). A saída
humana imprime uma linha `↳ {id} {mode} {detalhe}` por nó.

**Os `outputs` têm de ser os reais.** O recorte lê os `outputs` do `tasks.json` na hora do verify, e
caminho que não existe em disco é ignorado. Por isso o `morph-apply` grava os `outputs` reais **antes**
do verify do loop: com os planejados, um arquivo de teste tocado e ausente deles fica fora do recorte.
Cada caminho é lido na **grafia do disco**: `web/Src/Foo.test.ts` nos `outputs` e `web/src/foo.test.ts`
no disco → o comando recebe `src/foo.test.ts` (o contêiner do `--linux` diferencia maiúsculas, e o jest
pula em silêncio o caminho que não acha). Só a **caixa** vem do disco: caminho que passa por um link
(symlink ou junction) fica como escrito, e o arquivo segue no nó cujo `cwd` o contém.
E uma **pasta** nos `outputs` não estreita nada — quais
arquivos dela a task tocou não se sabe: todo nó cujo `cwd`, ou a pasta do projeto de um link, se
sobrepõe a ela (uma contém a outra) roda **inteiro**, e nunca sai do escopo por ela.

**`--filter` e `--full`.** `--filter <ids>` força a escolha de nós: desliga a escolha pelo `group`, e o
estreitamento dentro dos nós continua. `--full` desliga as duas decisões — cada nó roda o comando
declarado, com `mode: 'full'` (o `--filter`, se vier junto, continua escolhendo os nós). No escopo de
feature, `--full` é no-op.

**Limites conhecidos do recorte** — o que ele **não** garante hoje:

- (a) classe base de teste **não** abstrata cujos testes são herdados por derivadas em arquivos que a
  task não tocou: roda a classe tocada; as derivadas, só se o nome delas contiver o da base
  (`*ContractTests*` casa `SqlContractTests`, não `SqlTests`);
- (b) `partial class` que é `abstract` só na parte de **outro** arquivo: não é detectada, e o arquivo
  tocado estreita;
- (c) link VSTest para `.sln` num nó cujo `cwd` é mais estreito que a solução: é re-apontado só para os
  projetos dos arquivos **dentro** do `cwd` — arquivo de teste da task em outro projeto da solução não
  roda nesse nó. O mesmo vale para a **escolha** dos nós: a rede da task mal agrupada (e a pasta tocada)
  só alcança o nó cujo `cwd`, ou o `.csproj` de um link, contém o arquivo — o `.sln` não é lido, e um nó
  que só chega ao arquivo pela solução sai do escopo pelo `group`;
- (d) o atributo de teste é reconhecido pelo nome, sem compilar. A seção tem de **fechar** como seção
  de atributo (`]` seguido de declaração ou do fim do arquivo), então coleção em várias linhas com
  `Env.Test`, linha `[Env.Test, Env.Dev],` e `Test("a")` não enganam mais. O que ainda engana, sondado:
  um **padrão de lista** que abre a linha (ou vem depois de `;` `{` `}` `]`), cujo elemento se chama
  exatamente `Test`, `Fact`, … (qualificado ou não) e cujo `]` é seguido de um combinador —
  `[Env.Test] or [Env.Dev] => true` num helper faz dele arquivo de teste, que estreita para o nome do
  helper. Se um teste real
  tiver nome que contém o do helper (`*FakeClock*` casa `FakeClockTests`), esse teste roda e a rodada
  pode sair **verde sem rodar os testes que usam o helper**, que estão em arquivos que a task não tocou.
  Sem teste com nome assim, o filtro não casa nada: no MTP sai 8 (vermelho); no VSTest é vermelho num nó
  de link único, mas numa cadeia de links cai no limite (h). Atributo customizado (`[SkippableFact]`) ou
  por alias não é visto: o arquivo conta como helper e o nó roda inteiro (custo, não risco);
- (e) arquivo de teste JS/TS com nome fora de `*.test.*` / `*.spec.*` / `__tests__/` e sem `describe(`
  / `it(` / `test(` no início de uma linha não é reconhecido: o nó estreita para os outros arquivos da
  task;
- (f) no MTP com o sumário **ilegível** (o `dotnet` num idioma que não é pt-BR nem en-US) a prova volta
  a ser só o exit code, e o recorte só vê o que o varredor lê (o comando, o `.csproj`, os
  `Directory.Build.props/.targets` até a raiz, o `*testconfig.json` ao lado e o ambiente do verify):
  `--ignore-exit-code` ou `TESTINGPLATFORM_EXITCODE_IGNORE` que chegue por um `<Import>` (não é
  seguido), por um `Directory.Build.rsp`, por um `.runsettings` ou pela imagem do contêiner do `--linux`
  não é visto — o nó estreita, e zero testes podem sair 0: verde. Com o sumário legível o mesmo caso sai
  `total: 0` e é vermelho (medido) — numa cadeia `&&` também, porque cada link imprime o próprio bloco;
- (g) dois `.csproj` na mesma pasta: o dono é o primeiro em ordem alfabética;
- (h) numa **cadeia** VSTest (vários links `&&`), a contagem é a **soma** dos links: um link cujo filtro
  não casou nada (o VSTest sai 0) fica verde ao lado de um link que contou. "Contagem > 0" é prova por
  nó, não por link. No MTP isso não acontece: o link vazio sai 8 e derruba a cadeia — e, se esse 8 for
  ignorado, o bloco de sumário dele diz `total: 0`, que derruba o recorte (com o sumário legível).

A saída de todos é a mesma: `--full`.

## Instalação e atualização

- **`morph-spec update [--templates] [--standards] [--dry-run] [--force]`** — reinstala os payloads do framework no projeto (standards, hooks, schemas, rubrics, evals, `.claude/{commands,rules,skills,agents}`, `.claude/CLAUDE.md`, `.gitignore`/`.gitattributes`, statusline global).

  **`--dry-run` prevê sem tocar em nada.** Previsão e execução são o **mesmo percurso**, com os **mesmos filtros** e o **mesmo coletor de efeitos**: em modo normal cada verbo executa *e* registra, em dry apenas registra. Não há travessia paralela — o resumo de uma execução real também sai do coletor, de modo que um ponto de emissão esquecido some das duas saídas ao mesmo tempo, em vez de mentir só no `--dry-run`.

  O relatório agrupa por natureza do efeito (apagar, criar diretório, copiar, escrever, mesclar, executar, índice do git) e **nomeia separadamente três grupos que ninguém adivinha da palavra "update"**:
  - **apagados por inteiro antes da reinstalação** — `.claude/{commands,rules,skills,agents}` e `.morph/{framework/standards,framework/hooks,framework/schemas,framework/rubrics,framework/evals,config}`, com a contagem de arquivos que hoje moram em cada um;
  - **fora do projeto** — `~/.claude/statusline.sh`, `~/.claude/statusline.py` e a mesclagem de `~/.claude/settings.json`;
  - **dentro do pacote da CLI** — `generate-refs.js` escrevendo `framework/hooks/shared/phase-utils.js` do pacote, que num `npm link` de desenvolvimento é o próprio repositório do framework. Em dry roda a variante `--check`, que verifica e não escreve.

  O `--dry-run` **passa por cima** da saída antecipada de "CLI desatualizada": bloquear o relatório justamente quando o usuário está decidindo se atualiza seria inútil — o aviso vira a primeira linha do relatório. `init` delega a `updateCommand` e **nunca** passa `dryRun`.

  **Freio de major.** Um salto de **dois majors ou mais** é recusado sem `--force`, **antes de qualquer mutação** — recusar depois de mutar é meio-estado. A versão instalada vem de `.morph/.morphversion → morphVersion`, o mesmo registro que o `update` já lê; **nunca** de `config.json → frameworkVersion`, que o `update` escreve e jamais lê para decidir (medido: os dois divergem por um major inteiro num projeto real). Quando os dois discordam, a mensagem imprime **os dois** e diz qual decidiu — escolher em silêncio é acertar metade das vezes e nunca saber qual metade. `.morphversion` ausente ou ilegível é tratado como salto grande, com motivo próprio (`versão instalada indeterminável`) distinto do salto medido: "não sei quão velho isto é" é precisamente quando sobrescrever às cegas é mais perigoso. `--force` dispensa o freio e o relatório final **registra a dispensa**. `--dry-run` combinado com uma recusa **imprime o relatório**, com a recusa como primeiro item.

## Grafo de codebase

Grafo de símbolos extraído localmente pelo [graphify](https://pypi.org/project/graphifyy/) (versão pinada **0.9.50**, AST via tree-sitter, **zero token**), servido atrás de uma camada de saneamento própria. **Tudo é fail-open:** sem Python, sem grafo, com grafo velho ou corrompido, o framework segue por grep — é assim que ele sempre funcionou, e nenhuma skill pode exigir o grafo (ADR-002).

O ganho medido **não é recall**: contra o grep, o grafo não achou um único arquivo a mais. É precisão — em 12 símbolos do ProspectPRO, o grep aponta 1.092 arquivos onde existem 266 usos reais (58% de ruído), e o grafo ainda diz *que tipo* de relação é cada um. `IHandler` aparece em 193 arquivos no grep e tem **2** usos reais: é implementada por 98.

**Por que não consumir o graphify cru.** O resolvedor C# dele não implementa escopo hierárquico de namespace, então um tipo usado por um namespace filho sem `using` explícito vira um *stub* por arquivo — e o blast radius do símbolo real devolve **zero** consumidores onde existem dois. Falso negativo silencioso é o pior modo de falha possível antes de um hotfix. A camada de saneamento coalesce esses stubs (+91,9% de arestas de uso recuperadas no MORPH_GHLBrain), trata como externo só o que **não tem nenhum** nó com arquivo (BCL/NuGet), e devolve as opções quando o símbolo é genuinamente ambíguo em vez de escolher por conta própria.

- **`morph-spec graph status [-p <path>] [--json]`** — existência, tamanho, **frescor** e saúde do saneamento. Grafo ausente **sai 0**, não 1: não ter grafo é um estado válido do projeto. O frescor compara o `built_at_commit` gravado no `graph.json` com o `HEAD` — e importa porque o hook `post-commit` do graphify rebuilda **em background**: logo depois de um commit, o grafo ainda é o do commit anterior. `--json` devolve `{ available, path, nodes, links, freshness: {state}, version: {installed, pinned, matches}, sanitize: {coalescible, external, ambiguous} }`.
- **`morph-spec graph build [-p <path>] [--json]`** — extração completa (`graphify extract --code-only`). Gera o `.graphifyignore` stack-aware antes de extrair, porque um grafo que nasce com o ruído dentro não se conserta depois. Projeto **sem nenhum arquivo de código** (o caso de um `init` recém-feito) sai **0** com aviso, não erro. Sem graphify no PATH: exit 1 com `reason: graphify-unavailable` e o comando de instalação — nunca stack trace.
- **`morph-spec graph refresh [-p <path>] [--json]`** — atualização incremental (`graphify update`), 2 a 3× mais rápida que o build graças ao cache por hash de conteúdo.
- **`morph-spec graph affected <símbolo> [--prod] [--uses-only] [-p <path>] [--json]`** — blast radius saneado. `--uses-only` exclui `inherits`/`implements`/`extends`, que é o que separa *"20 implementam"* de *"2 usam"*; `--prod` descarta consumidores em projeto de teste. Erros são estados, não exceções: `no-graph`, `not-found` e `ambiguous` (este devolvendo as opções com path e id) saem 1 com a dica de fallback para grep.
- **`morph-spec graph explain <símbolo> [-p <path>] [--json]`** — vizinhança com citação `arquivo:linha`, separando quem depende dele de do que ele depende. **Prefira `explain` a `query`** nas personas: no eval, `explain` foi consistentemente mais preciso, enquanto `query` trunca por budget e traz ruído. Cada aresta traz a procedência em `via` (`direct`, `coalesced`, `language-disambiguated`), então dá para ver exatamente o que o saneamento recuperou.

**O que o resultado significa muda com a stack.** Em backend, blast radius é *quem chama*. Em frontend, o grafo é estrutura de módulos e não call graph (`imports: 78` contra `calls: 3` no LP_Polymorphism), então ali ele responde *quem importa*. Ler um como o outro leva a conclusão errada, e é por isso que as personas dizem isso no próprio texto.

O servidor MCP (`graphify-mcp`, 10 tools, todas aceitando `project_path`) está registrado em `framework/mcp/registry.json` para exploração interativa e consulta cross-repo — mas ele devolve o grafo **cru**, sem saneamento. Para qualquer decisão, prefira os comandos acima.

## Observabilidade

- **`morph-spec telemetry [--json] [--feature <f>] [--limit <n>]`** — reconstrói o caminho de execução da sessão a partir de `.morph/logs/events.jsonl` (skills invocadas, personas despachadas + prompts, standards/base de conhecimento lidos, mix de tools, prompts do usuário). Capturado silenciosamente pelo hook `telemetry-log` em todo tool call e todo UserPromptSubmit.
- **`morph-spec cost [feature] [--by task|phase] [--all-trees] [--json]`** — custo da execução a partir do mesmo `events.jsonl`: duração por task (TaskStart→TaskEnd), bytes de contexto re-ingerido por tipo (`rubric`/`standard`/`skill`/`agents`/`plan-artifact`/`code`), dispatches + tamanho de prompt e mix de tools. Bytes re-ingeridos são **proxy determinística** de custo de token (tokens reais não eram observáveis por hook até o evento `SessionUsage` — quando presente no stream, `inputTokens`/`outputTokens`/`cacheReadTokens`/`cacheCreationTokens` são somados e exibidos à parte como "tokens reais", nunca misturados com a proxy de bytes). `--all-trees` agrega `.morph/logs/events.jsonl` de **toda árvore git** que tiver um (não só a corrente) — reusa a mesma enumeração de worktree do `doctor --worktrees`/`fleet`; mostra um resumo por árvore + o total combinado (`--by` aplica ao agregado combinado). Use antes/depois de mudar skill ou fluxo para medir o efeito.
