@polymorphism-tech/morph-spec 8.35.0 → 8.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CLAUDE.md +11 -9
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +2 -1
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/MORPH.md +24 -23
  8. package/framework/docs/CLI.md +187 -13
  9. package/framework/evals/proof-rubrics/eval-de-modelo.md +16 -0
  10. package/framework/evals/rubrics/condensed.md +3 -1
  11. package/framework/evals/rubrics/test-coverage.md +8 -0
  12. package/framework/hooks/claude-code/core/worktree-env-inject.js +43 -0
  13. package/framework/hooks/shared/payload-utils.js +15 -2
  14. package/framework/hooks/shared/phase-utils.js +1 -0
  15. package/framework/phases.json +1 -1
  16. package/framework/rules/morph-workflow.md +1 -1
  17. package/framework/schemas/config.schema.json +7 -0
  18. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  19. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  20. package/framework/skills/core/morph-apply/SKILL.md +21 -10
  21. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  22. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  23. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  24. package/framework/skills/core/morph-implement/SKILL.md +20 -3
  25. package/framework/skills/core/morph-media/SKILL.md +1 -1
  26. package/framework/skills/core/morph-plan/SKILL.md +41 -3
  27. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  28. package/framework/skills/core/morph-review/SKILL.md +11 -2
  29. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  30. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  31. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  32. package/framework/standards/STANDARDS.json +2 -2
  33. package/framework/standards/ai-agents/prompt-sources.md +4 -3
  34. package/framework/standards/ai-agents/testing-ai.md +14 -13
  35. package/framework/templates/dotnet/ai-kit/README.md +14 -8
  36. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Morph.AiKit.Tests.csproj +11 -3
  37. package/package.json +1 -1
  38. package/src/commands/project/doctor.js +59 -3
  39. package/src/commands/project/worktree.js +68 -3
  40. package/src/commands/state/finish.js +48 -5
  41. package/src/commands/tasks/dag.js +13 -1
  42. package/src/commands/validation/verify.js +47 -18
  43. package/src/core/paths/output-schema.js +7 -0
  44. package/src/lib/detectors/index.js +225 -217
  45. package/src/lib/git/pr-files.js +56 -0
  46. package/src/lib/risk-detector.js +38 -18
  47. package/src/lib/specs/done-criteria-check.js +200 -0
  48. package/src/lib/specs/usecase-check.js +129 -5
  49. package/src/lib/tasks/dotnet-test-introspection.js +517 -0
  50. package/src/lib/tasks/test-nodes.js +190 -20
  51. package/src/lib/tasks/test-runner.js +122 -2
  52. package/src/lib/tasks/test-scope.js +567 -0
  53. package/src/lib/validators/traceability-check.js +9 -2
  54. package/src/lib/validators/verify-runner.js +150 -18
  55. package/src/lib/worktree/docker-audit.js +5 -1
@@ -9,7 +9,7 @@
9
9
  - **`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).
10
10
  - **`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).
11
11
  - **`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.
12
- - **`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. A regra governa a **dispensa**, não a prova: um e2e que rodou e passou vale de qualquer escopo. `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.
12
+ - **`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.
13
13
  - **`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.
14
14
  - **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.
15
15
 
@@ -22,10 +22,10 @@
22
22
 
23
23
  ## Worktree / Fleet
24
24
 
25
- - **`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}`. **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.
25
+ - **`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.
26
26
  - **`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.
27
27
  - **`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.
28
- - **`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). **Fechar a feature** continua sendo `morph-spec finish` (acima).
28
+ - **`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).
29
29
  **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`).
30
30
  **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).
31
31
  - **`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.
@@ -37,12 +37,12 @@
37
37
 
38
38
  ## Validação
39
39
 
40
- - **`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. `--mermaid` nunca é bloqueado.
41
- - **`morph-spec verify <feature> [task] [--json] [--timeout <ms>] [--filter <ids>] [--record-baseline] [--refresh-cache] [--linux] [--linux-image <img>] [--skip-e2e] [--fresh-e2e]`** — 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). **`--filter <ids>` escolhe quais suítes rodam** (lista separada por vírgula, casando `id` exato ou substring): o loop por task usa `verify {f} {task} --filter {stack-da-task}` porque o relógio de uma task não paga a suíte inteira, e o Gate 3 usa `verify {f} --json` **sem filtro** porque a prova que o avaliador lê tem de cobrir todos os nós (o CI idem). 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. 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. 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.
40
+ - **`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.
41
+ - **`morph-spec verify <feature> [task] [--json] [--timeout <ms>] [--filter <ids>] [--full] [--record-baseline] [--refresh-cache] [--linux] [--linux-image <img>] [--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'`): rodada parcial nunca fica no lugar da prova do Gate 3. 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.
42
42
  - **`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.
43
43
  - **`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.
44
44
  **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.
45
- **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). 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.
45
+ **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.
46
46
  Ver `--worktrees` abaixo para a reconciliação de worktrees (modo separado), e `--ai` para a auditoria de projeto .NET com agentes.
47
47
  - **`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.
48
48
 
@@ -80,7 +80,7 @@ em runtime é `validateTestNodes()` — schema que ninguém chama é decoração
80
80
  | Campo | Obrigatório | Semântica |
81
81
  | --- | --- | --- |
82
82
  | `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 |
83
- | `command` | **sim** | Comando literal, executado por shell a partir de `cwd`. Nunca é reescrito pelo verify |
83
+ | `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 |
84
84
  | `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 |
85
85
  | `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 |
86
86
  | `expectedCount` | não | Baseline. **Gravada só por `verify --record-baseline`**, nunca digitada. Ausente = `null` (sem baseline); `0` **declarado** é uma baseline real |
@@ -88,6 +88,7 @@ em runtime é `validateTestNodes()` — schema que ninguém chama é decoração
88
88
  | `build` | não | Projetos a compilar antes (só `runner: "dotnet"`). Declarado em nó não-dotnet é erro de config, não campo silenciosamente ignorado |
89
89
  | `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` |
90
90
  | `linuxImage` | não | Imagem do container deste nó sob `--linux` |
91
+ | `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 |
91
92
 
92
93
 
93
94
  ### O runner `evals`: regressão de prompt e de modelo com cache commitada
@@ -194,9 +195,16 @@ projeto ao comportamento anterior, que é exatamente o defeito.
194
195
  **`tests: []` ≠ `tests` ausente.** O array vazio é declaração explícita de "não há suítes" (um nó
195
196
  `skip`); a chave **ausente** cai no fallback de quatro passos, que continua íntegro e é o caminho de
196
197
  todo projeto criado antes desta chave: `project.testCommand` → `.sln`/`.csproj` na raiz → varredura de
197
- `*Tests.csproj` (que **não enxerga worktrees**) → `package.json` com `scripts.test`. Sem `config.json`
198
- nenhum, o `verify` resolve exatamente **um** nó `default` — o repositório-fonte do morph-spec roda
199
- assim, e nada aqui depende do arquivo existir.
198
+ `*Tests.csproj` (que **não enxerga worktrees**) → `package.json` com `scripts.test`, que dá o nó `default`. **Ao lado dele**, quando nada foi declarado
199
+ (nem `project.tests[]` nem `project.testCommand`), entra um nó por `package.json` que cite `vitest` ou
200
+ `jest` em `dependencies`/`devDependencies`, procurado em `.`, `src/frontend`, `src/web`, `frontend`,
201
+ `apps/web`, `packages/app` e `project.frontendPath` (quando fica dentro da raiz): id `vitest`/`jest` na
202
+ raiz e `vitest-src-web`/`jest-frontend` fora dela, comando `npx vitest run` / `npx jest --ci`. É palpite,
203
+ não declaração: um `package.json` ilegível é pulado em silêncio, e o da raiz não entra quando o próprio
204
+ `default` já é o `npm test`. Um `project.testCommand` declarado vence por inteiro — nada é somado a ele.
205
+ Sem `config.json` nenhum vale a mesma regra (o repositório-fonte do morph-spec resolve só o `default`,
206
+ `npm test`), e nada aqui depende do arquivo existir. Com algo declarado, o `doctor --full` avisa (WARN,
207
+ nunca `✗`) sobre um `package.json` de frontend que nenhum nó — ou o `testCommand` — cobre.
200
208
 
201
209
  **Forma do resultado.** `nodes.tests` continua um objeto com `status` (o rollup) e ganha `runs[]` — uma
202
210
  entrada por nó, com `id`, `command`, `cwd`, `status`, `durationMs`, `count`, `expectedCount` e
@@ -219,7 +227,7 @@ antes da execução, com `dotnet build "<alvo>" --nologo -v q`. Compilação que
219
227
  build nesta invocação reprova com `stale-binary`, também sem executar: o predicado é "houve build
220
228
  aqui", nunca "a flag está ausente". Sem isso, um projeto de teste que deixou de compilar era executado
221
229
  a partir do binário do último build bom e reportava verde. `--no-build` continua legítimo — com
222
- `build[]` declarado ele roda normalmente, e o comando do usuário nunca é reescrito. O prebuild é
230
+ `build[]` declarado ele roda normalmente, e o comando do usuário nunca é reescrito para isso. O prebuild é
223
231
  **condicional** (só com `build[]` declarado ou com `--no-build` no comando): um `dotnet test X.csproj`
224
232
  sem `--no-build` já compila sozinho, e pré-compilar ali só dobraria o relógio do caminho quente.
225
233
 
@@ -229,7 +237,8 @@ atômica `tmp.{pid}` + rename, preservando todo o resto do arquivo). Recusa **qu
229
237
  vermelha, execução **parcial** (sob `--filter`, mesmo com o verify verde: baseline de meia frota é
230
238
  afirmação sobre a outra metade que ninguém fez), contagem desconhecida, e **sem `config.json` ou sem
231
239
  `project.tests[]`** (erro de uso, exit 2; o arquivo nunca é criado implicitamente — gravar os nós que o
232
- fallback sintetiza exigiria inventar a declaração que o comando veio manter). Verde com gravação
240
+ fallback sintetiza exigiria inventar a declaração que o comando veio manter). E, antes das quatro, `{task}` na
241
+ 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
233
242
  recusada sai **exit 1**, com a razão em **stderr** (sobrevive ao `--json`). A gravação é governada
234
243
  pelos **nós de teste**, não pelos quatro nós do verify: um `validators` vermelho não muda quantos
235
244
  testes rodaram. E o comando **não** persiste o stamp `verification` — é manutenção de config, não prova
@@ -243,9 +252,10 @@ passa com aviso de alta, e o comando grava.
243
252
 
244
253
  | Momento | Comando | Por quê |
245
254
  | --- | --- | --- |
246
- | Loop por task | `verify {f} {task} --filter {stack-da-task}` | O relógio de uma task não paga a suíte inteira; a task tocou **um** stack |
255
+ | 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 |
247
256
  | Gate 3 | `verify {f} --json` (sem filtro) | A prova que o avaliador lê tem de cobrir **todos** os nós |
248
257
  | CI | a suíte inteira, todos os nós | idem — e é onde as duas plataformas rodam |
258
+ | 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 |
249
259
 
250
260
  > **Não existe constante de 600 s neste CLI** (`grep -rn "600000" src/ framework/` → zero). O teto que
251
261
  > corta uma suíte longa é o do **Bash do harness**, externo ao morph-spec, e não há o que "subir". Os
@@ -257,6 +267,170 @@ passa com aviso de alta, e o comando grava.
257
267
  > Quando o relógio estoura, o nó diz isso com nome (`timeout após N ms na execução X de Y — nenhum
258
268
  > teste falhou`) em vez de devolver log truncado.
259
269
 
270
+ ### O recorte por task: `verify {f} {task}`
271
+
272
+ Sem task, o `verify` roda tudo e **nunca recorta** — é a prova do Gate 3. Com `{task}`, ele lê de
273
+ `tasks.json` o `group` e os `outputs` da task e toma duas decisões independentes:
274
+
275
+ 1. **Quais nós.** O `group` da task escolhe os nós pelo campo `groups` de cada um (ou pelo default do
276
+ `runner`, na tabela de campos acima). Um nó que serve outro grupo sai do escopo e **aparece** em
277
+ `runs[]` como `skip` com o motivo (`out of task scope: …`) — a menos que seja dono de um arquivo de
278
+ teste da task (rede para task mal agrupada) ou não tenha grupo algum. Grupo que nenhum nó serve (por
279
+ default, `tests` e `infra`) ou task sem `group` → todos os nós. Task `docs` roda só os nós donos de
280
+ algum arquivo dos `outputs` — dentro do `cwd` do nó, ou compilado pelo `.csproj` de um link do
281
+ comando; um nó de `cwd` `.` é dono de tudo, então no layout de nó único a task `docs` roda o nó inteiro.
282
+ Arquivo dos `outputs` que ainda não existe em disco não conta: uma task `docs` cujos `outputs` ainda
283
+ não existem tira **todos** os nós do escopo, e o rollup `tests` sai `skip` (`every test node was
284
+ skipped`).
285
+ 2. **Quais testes dentro do nó.** Os arquivos de teste que estão nos `outputs` estreitam o comando
286
+ declarado — **só** quando ele tem uma forma reconhecida. Qualquer outra coisa roda o nó **inteiro**,
287
+ com o motivo em `runs[].selection.reason`.
288
+
289
+ | Runner | Forma reconhecida do comando | O que o recorte acrescenta | Prova de que algum teste rodou |
290
+ | --- | --- | --- | --- |
291
+ | `vitest` | um comando só, com `vitest` e `run` (sem `;`, `&`, `\|` nem `npm`/`pnpm`/`yarn`/`bun run`) | os caminhos dos arquivos, relativos ao `cwd` | contagem > 0 |
292
+ | `jest` | um comando só, com `jest` | `--runTestsByPath "<caminho>" …` | contagem > 0 |
293
+ | `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 |
294
+ | `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>"` | exit 0 (o MTP sai 8 com zero testes) |
295
+ | `dotnet`, MTP + MSTest/NUnit | idem | `--filter "FullyQualifiedName~<Tipo>\|…"` | exit 0 |
296
+
297
+ `node-test`, `playwright` e `none` não têm recorte dentro do nó: rodam inteiros (nós `e2e: true` e
298
+ `evals` seguem pulados no escopo de task, como antes). **Piso de projeto no MTP:** projeto que impõe
299
+ `--minimum-expected-tests` — no `.csproj`, num `Directory.Build.props/.targets` até a raiz (com
300
+ `Condition=` inclusive) ou num `*testconfig.json` ao lado — roda o nó **inteiro**. Medido:
301
+ `--minimum-expected-tests 1` na linha de comando **não** sobrescreve o valor do projeto, e um recorte
302
+ verde sairia 9. É o caso de um projeto copiado do `ai-kit` do framework, que declara um piso: nele o
303
+ loop por task não estreita o nó. O mesmo vale para `<VSTestTestCaseFilter>` declarado (um `--filter`
304
+ acrescentado o substituiria em silêncio).
305
+
306
+ **O seletor .NET é o TIPO, sem namespace.** Cada `class`/`record`/`struct`/`interface` declarado nos
307
+ arquivos de teste da task, em qualquer profundidade, vira um seletor com o nome **nu**. Medido no MTP +
308
+ xunit.v3: `--filter-class "*<Tipo>*"` roda o tipo em qualquer namespace, os aninhados `Outer+Inner`,
309
+ arquivo com BOM e namespace global, e todo tipo cujo nome contém o seletor; `FullyQualifiedName~<Tipo>`
310
+ é substring, mesma semântica. O recorte pode **acrescentar** testes, nunca tirar os do arquivo. (Sem o
311
+ curinga à esquerda, `<Tipo>*` não roda nada — medido, exit 8.)
312
+
313
+ **Falha para o lado seguro (.NET).** O `.cs` é lido por uma varredura que não é parser, e o recorte não
314
+ depende de ela acertar: toda palavra-chave de tipo contada no **texto cru** do arquivo tem de ser uma
315
+ declaração que a varredura nomeou, senão o nó roda inteiro. Também rodam o nó inteiro, cada caso com o
316
+ seu motivo:
317
+
318
+ - arquivo que declara uma classe `abstract` (os testes rodam sob o nome das derivadas, talvez em
319
+ arquivos que a task não tocou);
320
+ - arquivo do projeto de teste **sem** atributo de teste — helper, fake, fixture: quem o usa está em
321
+ outro arquivo. Atributo de teste é o **nome inteiro** (`Fact`, `Theory`, `Test`, `TestMethod`,
322
+ `DataTestMethod`, `TestCase`, `TestCaseSource`, com ou sem `Attribute`, qualificado ou não) numa
323
+ seção de atributo: o `, TestContext ctx` ou `, Factory f` do construtor de um helper não conta;
324
+ - **qualquer** arquivo que não é C# e que o nó pode rodar (dentro do `cwd`, ou cujo projeto mais
325
+ próximo é o de um link) quando o projeto mais próximo dele (`.csproj`/`.fsproj`/`.vbproj`) é de teste —
326
+ `.razor` do bUnit, `.feature` do Reqnroll/SpecFlow, snapshot `.verified.txt` do Verify, dado de teste —
327
+ ou, fora de projeto de teste reconhecido, `.razor`/`.cshtml`/`.fs`/`.fsi`/`.fsx`/`.vb` que lê como
328
+ teste: a varredura só lê C#, então ele não tem seletor, e os testes que ele alimenta podem estar em
329
+ arquivos que a task não tocou. Arquivo de projeto que não é de teste (componente ou `appsettings.json`
330
+ da app) não custa nada;
331
+ - arquivo tocado que parece teste mas o reconhecedor não classifica como tal (nunca é deixado de fora);
332
+ - literal ou comentário sem fim, chave desbalanceada, buraco de interpolação cortado por aspas,
333
+ diretiva de pré-processador entre a palavra-chave e o nome;
334
+ - link cujo projeto não resolve para um `.csproj` existente dentro da raiz (`--project=X.csproj`,
335
+ `--project:X.csproj`, `.csproj` fora da raiz);
336
+ - projeto que faz o MTP **ignorar um exit code** — `--ignore-exit-code` ou
337
+ `TESTINGPLATFORM_EXITCODE_IGNORE` citado em qualquer ponto do `.csproj` ou de um
338
+ `Directory.Build.props/.targets` até a raiz (em `<TestingPlatformCommandLineArguments>` ou numa
339
+ propriedade que ela referencia, como `$(MtpExtraArgs)`), ou num `*testconfig.json` ao lado — e
340
+ `TESTINGPLATFORM_EXITCODE_IGNORE` não vazia no ambiente do verify: no MTP o exit 0 é a prova de que o
341
+ recorte rodou teste, e com o 8 ignorado zero testes saem 0;
342
+ - comando que já carrega `--filter`/`--treenode-filter`, argumentos depois de `--`,
343
+ `--ignore-exit-code` ou `--minimum-expected-tests` (inclusive dentro de `-p:`); `global.json`
344
+ ilegível; MTP com framework sem filtro de classe conhecido; alvo MTP de solução ou pasta, alvo VSTest
345
+ de pasta; `--solution`, `--directory`, `--test-modules` e `--project` que não aponta um `.csproj` —
346
+ em token separado **ou** com o valor depois de `=`/`:` num token só (`--solution=X.sln`,
347
+ `--test-modules:…`, `--project=<pasta>`); nome de tipo fora de `[A-Za-z0-9_.]`.
348
+
349
+ O dono de um arquivo tocado é o `.csproj` do link, nunca o `cwd` do nó: num `dotnet test --project
350
+ ../B.Tests/B.Tests.csproj`, os arquivos de `B.Tests` são desse link. Link cujo projeto não compila
351
+ nenhum arquivo da task sai da cadeia. **O custo, dito:** texto com cara de declaração num comentário ou
352
+ string (`// the class under test`), restrição genérica seguida de nome (`where T : class where U : …`),
353
+ `: class // nota`, `record with`, `$"{d["k"]}"`, atributo de teste por alias
354
+ (`using F = Xunit.FactAttribute;`) — tudo isso roda o nó inteiro. Custa relógio, não um teste pulado.
355
+
356
+ **O veredito da rodada recortada** é próprio:
357
+
358
+ - **Nunca compara com `expectedCount`**: a baseline é da suíte inteira — um recorte que roda 12 de
359
+ 3.082 não é queda, e um superconjunto não é alta. Nó rodado **inteiro** no escopo de task continua
360
+ com a regra de contagem de sempre.
361
+ - Verde exige prova de que rodou teste: contagem > 0 **do nó** (numa cadeia VSTest, a soma dos links —
362
+ limite (h)) ou, no MTP, exit 0 (por isso o que faz o MTP ignorar exit code roda inteiro — acima; o
363
+ que o varredor não vê é o limite (f)). Contagem 0 →
364
+ `narrowed-zero-tests: o recorte executou 0 testes`; verde sem contagem legível (e sem o exit 0 do MTP)
365
+ → `narrowed-count-unknown` — desconhecido nunca vira verde.
366
+ - Vermelho com a assinatura de zero testes do runner (dotnet: exit 8; VSTest: `No test matches the
367
+ given testcase filter` / `Nenhum teste corresponde ao filtro de testcase`; vitest: `No test files
368
+ found`; jest: `No tests found`) e sem contagem > 0 → `narrowed-zero-tests (<runner>, exit <n>)`.
369
+ Qualquer outro vermelho, exit 9 inclusive, mantém a razão crua do executor.
370
+ - As contagens de vitest, jest e dotnet são lidas depois de tirar os códigos ANSI da saída.
371
+
372
+ **O que fica registrado.** Cada entrada de `runs[]` ganha `selection` — `null` no escopo de feature; no
373
+ de task, `mode` (`narrowed` / `whole` / `out-of-scope` / `full`), `reason`, `declaredCommand`, `files`,
374
+ `selectors` e `zeroGuard`. `runs[].command` é o que rodou. `nodes.tests.scope` (`scope`, `derivation`,
375
+ `group`, `testFiles`) sai só no `--json`, e nada da `selection` vai para o `feature.json`: o stamp grava
376
+ `scope: 'task:{id}'`, e o `gate-check` do `review` pausa sobre ele (seção **Gates e estado**). A saída
377
+ humana imprime uma linha `↳ {id} {mode} {detalhe}` por nó.
378
+
379
+ **Os `outputs` têm de ser os reais.** O recorte lê os `outputs` do `tasks.json` na hora do verify, e
380
+ caminho que não existe em disco é ignorado. Por isso o `morph-apply` grava os `outputs` reais **antes**
381
+ do verify do loop: com os planejados, um arquivo de teste tocado e ausente deles fica fora do recorte.
382
+ Cada caminho é lido na **grafia do disco**: `web/Src/Foo.test.ts` nos `outputs` e `web/src/foo.test.ts`
383
+ no disco → o comando recebe `src/foo.test.ts` (o contêiner do `--linux` diferencia maiúsculas, e o jest
384
+ pula em silêncio o caminho que não acha). Só a **caixa** vem do disco: caminho que passa por um link
385
+ (symlink ou junction) fica como escrito, e o arquivo segue no nó cujo `cwd` o contém.
386
+ E uma **pasta** nos `outputs` não estreita nada — quais
387
+ arquivos dela a task tocou não se sabe: todo nó cujo `cwd`, ou a pasta do projeto de um link, se
388
+ sobrepõe a ela (uma contém a outra) roda **inteiro**, e nunca sai do escopo por ela.
389
+
390
+ **`--filter` e `--full`.** `--filter <ids>` força a escolha de nós: desliga a escolha pelo `group`, e o
391
+ estreitamento dentro dos nós continua. `--full` desliga as duas decisões — cada nó roda o comando
392
+ declarado, com `mode: 'full'` (o `--filter`, se vier junto, continua escolhendo os nós). No escopo de
393
+ feature, `--full` é no-op.
394
+
395
+ **Limites conhecidos do recorte** — o que ele **não** garante hoje:
396
+
397
+ - (a) classe base de teste **não** abstrata cujos testes são herdados por derivadas em arquivos que a
398
+ task não tocou: roda a classe tocada; as derivadas, só se o nome delas contiver o da base
399
+ (`*ContractTests*` casa `SqlContractTests`, não `SqlTests`);
400
+ - (b) `partial class` que é `abstract` só na parte de **outro** arquivo: não é detectada, e o arquivo
401
+ tocado estreita;
402
+ - (c) link VSTest para `.sln` num nó cujo `cwd` é mais estreito que a solução: é re-apontado só para os
403
+ projetos dos arquivos **dentro** do `cwd` — arquivo de teste da task em outro projeto da solução não
404
+ roda nesse nó. O mesmo vale para a **escolha** dos nós: a rede da task mal agrupada (e a pasta tocada)
405
+ 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ó
406
+ que só chega ao arquivo pela solução sai do escopo pelo `group`;
407
+ - (d) o atributo de teste é reconhecido pelo nome, sem compilar. A seção tem de **fechar** como seção
408
+ de atributo (`]` seguido de declaração ou do fim do arquivo), então coleção em várias linhas com
409
+ `Env.Test`, linha `[Env.Test, Env.Dev],` e `Test("a")` não enganam mais. O que ainda engana, sondado:
410
+ um **padrão de lista** que abre a linha (ou vem depois de `;` `{` `}` `]`), cujo elemento se chama
411
+ exatamente `Test`, `Fact`, … (qualificado ou não) e cujo `]` é seguido de um combinador —
412
+ `[Env.Test] or [Env.Dev] => true` num helper faz dele arquivo de teste, que estreita para o nome do
413
+ helper. Se um teste real
414
+ tiver nome que contém o do helper (`*FakeClock*` casa `FakeClockTests`), esse teste roda e a rodada
415
+ pode sair **verde sem rodar os testes que usam o helper**, que estão em arquivos que a task não tocou.
416
+ Sem teste com nome assim, o filtro não casa nada: no MTP sai 8 (vermelho); no VSTest é vermelho num nó
417
+ de link único, mas numa cadeia de links cai no limite (h). Atributo customizado (`[SkippableFact]`) ou
418
+ por alias não é visto: o arquivo conta como helper e o nó roda inteiro (custo, não risco);
419
+ - (e) arquivo de teste JS/TS com nome fora de `*.test.*` / `*.spec.*` / `__tests__/` e sem `describe(`
420
+ / `it(` / `test(` no início de uma linha não é reconhecido: o nó estreita para os outros arquivos da
421
+ task;
422
+ - (f) no MTP a prova é o exit code, e o recorte só vê o que o varredor lê (o comando, o `.csproj`, os
423
+ `Directory.Build.props/.targets` até a raiz, o `*testconfig.json` ao lado e o ambiente do verify):
424
+ `--ignore-exit-code` ou `TESTINGPLATFORM_EXITCODE_IGNORE` que chegue por um `<Import>` (não é
425
+ seguido), por um `Directory.Build.rsp`, por um `.runsettings` ou pela imagem do contêiner do `--linux`
426
+ não é visto — o nó estreita, e zero testes podem sair 0: verde;
427
+ - (g) dois `.csproj` na mesma pasta: o dono é o primeiro em ordem alfabética;
428
+ - (h) numa **cadeia** VSTest (vários links `&&`), a contagem é a **soma** dos links: um link cujo filtro
429
+ não casou nada (o VSTest sai 0) fica verde ao lado de um link que contou. "Contagem > 0" é prova por
430
+ nó, não por link. No MTP isso não acontece: o link vazio sai 8 e derruba a cadeia.
431
+
432
+ A saída de todos é a mesma: `--full`.
433
+
260
434
  ## Instalação e atualização
261
435
 
262
436
  - **`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).
@@ -47,6 +47,22 @@ duas é o defeito inteiro que esta classe de rubrica persegue.
47
47
  errada quando **a asserção começou a reprovar**. As duas situações produzem o mesmo comando e
48
48
  vereditos opostos: diga qual das duas era, e como sabe.
49
49
 
50
+ ## Insumo: 3-implement/evidencias/
51
+
52
+ Task cujo `doneCriteria` julga saída de LLM grava `.morph/features/{feature}/3-implement/evidencias/{taskId}.md`
53
+ (`morph-plan` §5): critério, nó `evals` e cenário, modelo, versão do prompt, entrada, saída e
54
+ veredito. Não é o `3-implement/reports/{taskId}.md` — aquele é o que o sub-agente diz que fez. É
55
+ insumo, não veredito: leia antes de pontuar e confronte o veredito com a saída registrada.
56
+
57
+ - **Evidência atada a um nó `evals`** (`runner: "evals"`): as bandas acima valem como estão, e a
58
+ degradação desta rubrica usa **o cenário que a evidência aponta** — é ele que tem de cair, pelo
59
+ nome.
60
+ - **Evidência sem nó** ("nenhum nó — execução manual"): prova que o prompt rodou **uma vez**, não que
61
+ algo pegaria a regressão — não há cenário a degradar. Banda **4-6** no máximo, por melhor que a
62
+ saída registrada pareça.
63
+ - **Evidência devida e ausente** (o path está em `outputs` e o arquivo não existe): superfície de
64
+ prompt/modelo mexida sem eval nenhum — banda 0-3.
65
+
50
66
  ## Como registrar no relatório
51
67
 
52
68
  Uma linha por cláusula degradada, dizendo o que foi degradado e qual cenário caiu **pelo nome**:
@@ -31,12 +31,14 @@ Se o diff toca endpoint minimal API ou fetch cross-camada, independente do resto
31
31
  | **Arquitetura** | Ignora a arquitetura do spec.md; sem feature folder; deps circulares; ou violação mecânica acima | Pastas corretas, responsabilidades se misturam; deps cross-feature sem justificativa; domain acoplado a infra | Desvios menores justificados; deps cross-feature via abstração; impurezas de infra pontuais | Feature folder rigoroso; zero deps cross-feature diretas; domain isolado; DI onde exige |
32
32
  | **Contratos** | Sem tipos ou `any` generalizado; zero validação; sem tratamento de erro; naming arbitrário | `any`/genérico visível; validação parcial; mix exceptions/Result; naming inconsistente | Tipos bem definidos, `any` raro e justificado; validação na maioria; erro consistente | Zero `any`; validação em todos endpoints; Result pattern/discriminated union; naming consistente |
33
33
  | **Qualidade** | Nomes arbitrários; god objects; duplicação massiva; recria o que o `reuse-map` mandou REUSAR; fora do padrão do stack | Nomes genéricos (`data`,`temp`); funções >30 linhas; duplicação perceptível; conceito da MESMA feature redeclarado | Nomes claros, raras exceções; bem dimensionado; duplicação mínima; no máximo um conceito recriado | Nomes comunicam intenção; funções ≤20-25 linhas, 1 responsabilidade; zero duplicação/dead code; nada criado que já existia no projeto |
34
- | **Testes** | Sem testes ou sem asserts reais; zero edge case; nomes genéricos | Happy path OK, edge cases parciais; erros só óbvios; testes interdependentes | Happy path completo; maioria dos edge cases; erros nos fluxos principais | Toda pós-condição do `usecase` tem teste; A1/A2 cobertos; edge+erro cobertos; isolados |
34
+ | **Testes** | Sem testes ou sem asserts reais; zero edge case; nomes genéricos | Happy path OK, edge cases parciais; erros só óbvios; testes interdependentes; teste de texto de arquivo sem substituto em runtime | Happy path completo; maioria dos edge cases; erros nos fluxos principais | Toda pós-condição do `usecase` tem teste; A1/A2 cobertos; edge+erro cobertos; isolados |
35
35
 
36
36
  > **Qualidade olha o CODEBASE, não só o diff.** Símbolo novo cujo nome já existe no projeto não aparece no diff — é preciso perguntar de fora dele. O nó `validators` do `morph-spec verify` já traz os achados do validador `reuse`; o `2-plan/reuse-map.md` diz o que o plano decidiu por conceito.
37
37
 
38
38
  > Task com `usecase: UC-nn`: pós-condição sem teste **impede a faixa 9-10** em Testes, mesmo com o resto da suíte completo (herdado de `test-coverage.md`).
39
39
 
40
+ > Teste que só lê texto de arquivo não cobre pós-condição de comportamento: sem substituto em runtime, ela conta como sem teste (herdado de `test-coverage.md`, pergunta 7).
41
+
40
42
  ## Score composto
41
43
 
42
44
  ```
@@ -58,6 +58,14 @@ Avalia se os testes cobrem os comportamentos esperados, são isolados e têm nom
58
58
  min; o fecho de um fail-open abriu um P1; a correção desse P1 saiu com meio predicado. Nenhuma
59
59
  apareceu no score, porque score não pergunta isso. **Sem resposta a esta pergunta numa correção,
60
60
  o teto de Cobertura é 8.**
61
+ 7. **Quantos testes desta task asseram sobre TEXTO DE ARQUIVO** (prompt, migration, config,
62
+ markdown, código-fonte lido como string)? **Cada um tem substituto em runtime** — um teste
63
+ que exercita o sistema ou, em critério sobre saída de LLM, a evidência em
64
+ `3-implement/evidencias/{taskId}.md` apontando um eval? Teste de texto prova a VERSÃO do
65
+ arquivo, não o comportamento: quando é o único teste de uma pós-condição, **a pós-condição
66
+ conta como sem teste** (pergunta 0 — teto 8). Exceções: pós-condição com
67
+ `> [!note] Por que artefato:` e o único teste de estrutura por fonte de prompt
68
+ (`ai-agents-testing-ai`). Medido: 12 suítes, 106 testes sobre versões superadas do prompt.
61
69
 
62
70
  ## Tetos de prova (Gate 3)
63
71
 
@@ -16,6 +16,17 @@
16
16
  * command does not already set a layout key itself (`PORT=…`, `--port`,
17
17
  * `compose -p`), only for the Bash tool. Everything else passes untouched.
18
18
  *
19
+ * Second job, same mechanism — the SESSION ANCHOR. `morph-spec finish` and
20
+ * `morph-spec worktree remove` tear a worktree down, and the session that runs
21
+ * them may be ANCHORED in that very worktree: its CLAUDE_PROJECT_DIR is the
22
+ * worktree, and every project hook is `node "$CLAUDE_PROJECT_DIR/.morph/framework/…"`.
23
+ * The CLI cannot see that anchor (the Bash tool does not export
24
+ * CLAUDE_PROJECT_DIR, and `cd <root> && morph-spec finish` erases the cwd), so
25
+ * it unlinked the junction the session's hooks resolve through — and every hook
26
+ * of every later tool call died with MODULE_NOT_FOUND (48 in one real session).
27
+ * Hooks DO see the anchor, so this one exports it as MORPH_SESSION_PROJECT_DIR
28
+ * on those two commands; the CLI then defers the teardown.
29
+ *
19
30
  * Fail-open: no state, no manifest, unreadable manifest → null (pass).
20
31
  */
21
32
 
@@ -56,6 +67,26 @@ export function rewriteCommand(command, env) {
56
67
  return { command: prefix + command, injected: pairs.map(([k, v]) => `${k}=${v}`) };
57
68
  }
58
69
 
70
+ /** `morph-spec finish` / `morph-spec worktree remove` — incl. `node …/morph-spec.js` and `npx morph-spec`. */
71
+ const TEARDOWN_RE = /\bmorph-spec(?:\.js)?\b[^\n;&|]*?\s(?:finish|worktree\s+remove)\b/;
72
+
73
+ const ANCHOR_KEY = 'MORPH_SESSION_PROJECT_DIR';
74
+
75
+ /**
76
+ * Pure decision: export the session anchor in front of a teardown command, or
77
+ * null when nothing should change (other command, anchor already declared,
78
+ * no anchor to give).
79
+ * @param {string} command
80
+ * @param {string} anchorDir - the session's project dir (CLAUDE_PROJECT_DIR)
81
+ * @returns {{ command: string, anchor: string }|null}
82
+ */
83
+ export function anchorSessionCommand(command, anchorDir) {
84
+ if (!command || !anchorDir) return null;
85
+ if (!TEARDOWN_RE.test(command)) return null;
86
+ if (new RegExp(`(?:^|[\\s;&|])${ANCHOR_KEY}=`).test(command)) return null;
87
+ return { command: `export ${ANCHOR_KEY}=${shq(anchorDir)}; ${command}`, anchor: anchorDir };
88
+ }
89
+
59
90
  /**
60
91
  * @param {object} payload - parsed PreToolUse stdin payload (Bash matcher)
61
92
  * @returns {Promise<object|null>} an updatedInput result, or null (pass).
@@ -67,6 +98,18 @@ export async function run(payload) {
67
98
  const command = payload?.tool_input?.command || '';
68
99
  if (!command) return null;
69
100
 
101
+ // CLAUDE_PROJECT_DIR first: it is what the project hooks resolve
102
+ // `.morph/framework` against — the thing a teardown would pull out from
103
+ // under this session. The payload cwd is the fallback.
104
+ const anchored = anchorSessionCommand(command, process.env.CLAUDE_PROJECT_DIR || payload?.cwd || '');
105
+ if (anchored) {
106
+ return updatedInputResult(
107
+ { ...payload.tool_input, command: anchored.command },
108
+ 'PreToolUse',
109
+ `⚓ âncora da sessão exportada para o CLI: ${ANCHOR_KEY}=${anchored.anchor} — o finish/worktree remove adia o desmonte do worktree em que esta sessão está ancorada`,
110
+ );
111
+ }
112
+
70
113
  const dir = payload?.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
71
114
  const manifest = join(dir, '.morph', 'worktree.env');
72
115
  if (!existsSync(manifest)) return null;
@@ -5,6 +5,7 @@
5
5
  * normalizing between Write (content, file_path) and Edit (new_string, old_string, file_path).
6
6
  */
7
7
 
8
+ import { createHash } from 'crypto';
8
9
  import { existsSync } from 'fs';
9
10
  import { dirname, join, isAbsolute } from 'path';
10
11
 
@@ -165,7 +166,7 @@ const BASH_SIGNATURE_MAX = 120;
165
166
 
166
167
  /**
167
168
  * Build a normalized signature for loop detection.
168
- * - Edit/Write: "Edit:normalized/file/path"
169
+ * - Edit/Write: "Edit:normalized/file/path#<sha1[0:8] do conteúdo>" ("Edit:path" sem conteúdo)
169
170
  * - Bash: "Bash:<normalized command, truncated>"
170
171
  * - Every other tool WITH input: "Tool:<truncated JSON of tool_input>"
171
172
  * - No tool_input at all: just the tool name
@@ -201,7 +202,19 @@ export function getCommandSignature(payload) {
201
202
 
202
203
  if (tool === 'Edit' || tool === 'Write') {
203
204
  const path = (input.file_path || input.path || '').replace(/\\/g, '/');
204
- return path ? `${tool}:${path}` : tool;
205
+ if (!path) return tool;
206
+ // O caminho sozinho confundia TRABALHO com LOOP: editar regiões diferentes
207
+ // de dois arquivos (a forma normal de uma correção que cruza teste e
208
+ // fonte) dava só duas assinaturas e disparava o aviso. O conteúdo da
209
+ // edição entra na assinatura; repetir a MESMA edição continua casando, e o
210
+ // ping-pong A→B / B→A continua dando duas assinaturas — loop de verdade.
211
+ const body = tool === 'Write'
212
+ ? input.content
213
+ : (input.old_string !== undefined || input.new_string !== undefined)
214
+ ? `${input.old_string ?? ''}\0${input.new_string ?? ''}\0${input.replace_all ? 1 : 0}`
215
+ : undefined;
216
+ if (body === undefined || body === null) return `${tool}:${path}`;
217
+ return `${tool}:${path}#${createHash('sha1').update(String(body)).digest('hex').slice(0, 8)}`;
205
218
  }
206
219
 
207
220
  if (tool === 'Bash') {
@@ -35,6 +35,7 @@ export const OUTPUT_PHASE_MAP = {
35
35
  reuseMap: 'plan',
36
36
  usecases: 'plan',
37
37
  recap: 'implement',
38
+ evidencias: 'implement',
38
39
  reviewReport: 'review',
39
40
  evaluatorReport: 'review',
40
41
  };
@@ -63,7 +63,7 @@
63
63
  "Maintain recap.md and execution trace"
64
64
  ],
65
65
  "requiredOutputs": ["recap"],
66
- "optionalOutputs": [],
66
+ "optionalOutputs": ["evidencias"],
67
67
  "pausePoints": [],
68
68
  "skill": "morph-implement"
69
69
  },
@@ -73,7 +73,7 @@ All outputs go in `.morph/features/{feature}/`:
73
73
  | Proposal | `0-proposal/proposal.md`, `stack-scan.md` |
74
74
  | Design (optional) | `1-design/design-system.md`, `mockups.md`, `components.md`, `flows.md` |
75
75
  | Plan | `2-plan/spec.md`, `mandate.md`, `tasks.json`, `decisions.md` |
76
- | Implement | `3-implement/recap.md` + source code |
76
+ | Implement | `3-implement/recap.md` + source code; `3-implement/evidencias/{taskId}.md` (condicional — task cujo `doneCriteria` julga saída de LLM) |
77
77
  | Review | `4-review/review-report.md` |
78
78
 
79
79
  ---
@@ -63,6 +63,13 @@
63
63
  "minLength": 1,
64
64
  "description": "Overrides the container image used by `verify --linux` for this node."
65
65
  },
66
+ "groups": {
67
+ "type": "array",
68
+ "minItems": 1,
69
+ "uniqueItems": true,
70
+ "items": { "type": "string", "enum": ["backend", "frontend", "tests", "infra", "docs"] },
71
+ "description": "Which task group(s) (tasks.json `group`) this node verifies. Absent means the runner's own default applies (RUNNER_DEFAULT_GROUPS in src/lib/tasks/test-nodes.js) — `none`/`evals` have no default and are unscoped unless declared here."
72
+ },
66
73
  "evals": {
67
74
  "type": "object",
68
75
  "additionalProperties": false,
@@ -4,7 +4,7 @@ description: Guia a criação de uma landing page nova ponta-a-ponta a partir do
4
4
  version: core
5
5
  when-to-invoke: ao iniciar um novo projeto de LP com o template Umbraco genérico
6
6
  user-invokable: true
7
- cliVersion: "8.35.0"
7
+ cliVersion: "8.36.0"
8
8
  stacks:
9
9
  - umbraco
10
10
  ---
@@ -4,7 +4,7 @@ description: Migra uma landing page existente (React/Vite, Next.js estático ou
4
4
  version: core
5
5
  when-to-invoke: ao receber instrução "use /migrate-lp-to-umbraco {projeto}" do usuário, ou quando o usuário descrever necessidade de migrar uma LP para Umbraco
6
6
  user-invokable: true
7
- cliVersion: "8.35.0"
7
+ cliVersion: "8.36.0"
8
8
  stacks:
9
9
  - umbraco
10
10
  ---