@polymorphism-tech/morph-spec 8.41.0 → 8.43.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 (102) hide show
  1. package/CLAUDE.md +14 -4
  2. package/README.md +3 -3
  3. package/bin/morph-spec.js +48 -2
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/CLAUDE.md +6 -5
  8. package/framework/MORPH.md +10 -9
  9. package/framework/agents.json +7 -7
  10. package/framework/docs/CLI.md +10 -3
  11. package/framework/evals/proof-rubrics/mutation-proof.md +43 -13
  12. package/framework/hooks/README.md +1 -0
  13. package/framework/hooks/claude-code/core/chain.js +2 -0
  14. package/framework/hooks/claude-code/core/comment-guard.js +107 -0
  15. package/framework/hooks/claude-code/core/heavy-gate.js +72 -0
  16. package/framework/hooks/claude-code/core/heavy-release.js +34 -0
  17. package/framework/hooks/claude-code/core/worktree-context.js +21 -20
  18. package/framework/hooks/claude-code/core/worktree-create.js +4 -4
  19. package/framework/hooks/shared/agent-reap.js +89 -0
  20. package/framework/hooks/shared/comment-rules.js +171 -0
  21. package/{src/lib/validators/code-budget → framework/hooks/shared}/comment-scanner.js +2 -2
  22. package/{src/lib/ai → framework/hooks/shared}/comment-stripper.js +2 -1
  23. package/framework/hooks/shared/e2e-check.js +3 -1
  24. package/framework/hooks/shared/graph-refresh.js +1 -9
  25. package/framework/hooks/shared/heavy-command.js +128 -0
  26. package/framework/hooks/shared/heavy-config.js +65 -0
  27. package/framework/hooks/shared/heavy-holders.js +85 -0
  28. package/framework/hooks/shared/heavy-slots.js +207 -0
  29. package/framework/hooks/shared/process-alive.js +8 -0
  30. package/framework/hooks/shared/source-files.js +34 -0
  31. package/framework/hooks/shared/worktree-root.js +93 -0
  32. package/framework/schemas/config.schema.json +80 -0
  33. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  34. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  35. package/framework/skills/core/morph-apply/SKILL.md +11 -4
  36. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  37. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  38. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  39. package/framework/skills/core/morph-implement/SKILL.md +12 -5
  40. package/framework/skills/core/morph-machine/SKILL.md +46 -0
  41. package/framework/skills/core/morph-media/SKILL.md +1 -1
  42. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  43. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  44. package/framework/skills/core/morph-review/SKILL.md +45 -16
  45. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  46. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  47. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  48. package/framework/standards/PROVENANCE.json +10 -10
  49. package/framework/standards/STANDARDS.json +1 -1
  50. package/framework/standards/architecture/code-comments.md +9 -2
  51. package/framework/standards/frontend/design-system/ai-video-generation.md +2 -0
  52. package/framework/standards/frontend/scroll-driven/frame-scrub.md +11 -1
  53. package/package.json +1 -1
  54. package/src/commands/e2e/e2e.js +27 -8
  55. package/src/commands/mcp/mcp-setup.js +10 -9
  56. package/src/commands/project/doctor-procs.js +162 -0
  57. package/src/commands/project/doctor.js +42 -4
  58. package/src/commands/project/heavy.js +61 -0
  59. package/src/commands/project/init.js +1 -1
  60. package/src/commands/project/update.js +4 -0
  61. package/src/commands/project/worktree-reap.js +34 -0
  62. package/src/commands/project/worktree.js +78 -27
  63. package/src/commands/validation/mutate.js +200 -0
  64. package/src/commands/validation/verify.js +26 -1
  65. package/src/dashboard/scanner/features-scanner.js +4 -4
  66. package/src/dashboard/scanner/project-api.js +4 -4
  67. package/src/lib/ai/code-audit.js +1 -1
  68. package/src/lib/ai/registry-audit.js +1 -1
  69. package/src/lib/detectors/claude-config-detector.js +30 -1
  70. package/src/lib/heavy/cleanup.js +70 -0
  71. package/src/lib/heavy/gate.js +96 -0
  72. package/src/lib/installers/mcp-installer.js +15 -2
  73. package/src/lib/mutation/adapters.js +90 -0
  74. package/src/lib/mutation/commands.js +60 -0
  75. package/src/lib/mutation/manifest.js +138 -0
  76. package/src/lib/mutation/plan.js +36 -0
  77. package/src/lib/mutation/report.js +66 -0
  78. package/src/lib/mutation/runner.js +351 -0
  79. package/src/lib/mutation/schemata.js +60 -0
  80. package/src/lib/mutation/snapshot.js +69 -0
  81. package/src/lib/mutation/subset.js +75 -0
  82. package/src/lib/mutation/trx.js +51 -0
  83. package/src/lib/procs/classify.js +91 -0
  84. package/src/lib/procs/list.js +124 -0
  85. package/src/lib/procs/mcp-config.js +103 -0
  86. package/src/lib/procs/prune.js +25 -0
  87. package/src/lib/procs/wslconfig.js +70 -0
  88. package/src/lib/projects/registry.js +6 -2
  89. package/src/lib/tasks/dotnet-test-introspection.js +1 -1
  90. package/src/lib/tasks/linux-runner.js +30 -3
  91. package/src/lib/tasks/test-runner.js +1 -1
  92. package/src/lib/validators/code-budget/code-budget-validator.js +4 -6
  93. package/src/lib/validators/code-budget/index.js +1 -1
  94. package/src/lib/validators/reuse/symbol-scanner.js +1 -28
  95. package/src/lib/validators/verify-runner.js +26 -8
  96. package/src/lib/worktree/agent-reaper.js +247 -0
  97. package/src/lib/worktree/dirs.js +3 -32
  98. package/src/lib/worktree/vscode-settings.js +113 -0
  99. package/src/lib/worktree/worktree-audit.js +49 -5
  100. package/src/lib/worktree/worktree-include.js +46 -0
  101. package/src/scripts/setup-infra.js +4 -0
  102. package/src/utils/hooks-installer.js +14 -6
@@ -32,6 +32,10 @@
32
32
  **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).
33
33
  - **`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.
34
34
  - **`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 ou cache regenerável (`bin/`, `obj/`, `node_modules/`, `__pycache__/`, `test-results/`, `.auth/`, `.next/`, `.turbo/`, `.pytest_cache/`, `TestResults/`, `graphify-out/`, `*.tsbuildinfo`) é descartado (`discardedIgnored`) — e nunca entra no backup, nem quando está DENTRO de um diretório exclusivo que o git reporta como uma entrada só; o que existe **só ali** é copiado para `.git/morph-spec/finish-backup/` e a remoção **prossegue** (`ignoredBackup` + `backedUp`), com a retenção descrita abaixo aplicada logo depois (`backupRetention` na saída, quando podou algo). 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.
35
+ - **`morph-spec worktree reap [--dry-run] [--grace <min>] [--json]`** — remove as **sobras de sub-agent**: o worktree `agent-<hex>` (branch `morph-task/agent-<hex>`, o que o `Agent` com `isolation: "worktree"` deixa para trás) e a branch órfã `morph-task/agent-<hex>`. **Só remove se TODAS as provas fecham; na dúvida preserva e lista com a razão:** (1) é de agent — diretório `agent-<hex>` sob `worktrees/` e branch `morph-task/agent-<hex>` (ou HEAD destacado); `morph-task/<nome>`, features e `claude -w` nunca entram; (2) o agente **parou** — o último evento do `.morph/logs/events.jsonl` da árvore é o `AgentUsage` dele (`agentId` = o `<hex>`); sem esse evento só lista (`no-usage-event`); (3) **graça** — o log parou de crescer há ≥ `worktree.reap.graceMinutes` (30; `--grace`), porque um agente parado ainda pode ser retomado por `SendMessage`; (4) **árvore limpa** — `git status --porcelain --untracked-files=all`; arquivo não rastreado também bloqueia (`dirty`); (5) **sem commit exclusivo** — `git rev-list <tip> --not <refs>`, com `refs` = branches locais, remotas e tags **menos todas as `morph-task/agent-*`**. Nunca `--all`: ele conta o HEAD de cada worktree e o da própria árvore protegeria o commit; e dois agentes no mesmo commit exclusivo se protegeriam um ao outro — assim preservam os dois; (6) não é o cwd nem a âncora da sessão. Medir não deu (`null`) sempre preserva. **Como remove:** pelo núcleo do `worktree remove --target --if-clean` (desmonta as junctions antes, backup do que só existe ali em `finish-backup/task/`, retenção por namespace), nunca `rm -rf`; **falha de remoção devolve as junctions** (o `worktree remove` manual as deixa desmontadas de propósito): o agente pode estar vivo, e sem `.claude` os hooks dele morrem. Depois apaga a branch (`git branch -D`, com a contagem de commits exclusivos refeita antes). **Um lock por repositório** (`<git-common-dir>/morph-spec/agent-reap.lock`) serializa dois reapers: sem ele, o relink de um recriaria junctions no meio da remoção do outro, e o `--force` as atravessaria; lock de pid vivo → `busy: true` e nada é feito. Branch órfã sai pela mesma regra 5, com a graça medida pelo reflog da branch (o `provision` cria a branch antes do worktree). Idempotente: a 2ª rodada não acha nada. **Quem dispara:** as skills (`morph-apply` §3b-w passo 4, `morph-implement` pós-dispatch, `morph-review` depois de ler o `evaluator-report`), o SessionStart (`worktree-context` solta um worker destacado, só na raiz, no máximo 1 a cada 10 min e só se houver `agent-<hex>` no disco; `MORPH_WORKTREE_REAP=0` desliga) e o `doctor --worktrees --prune`. `--json`: `{ ok, dryRun, graceMinutes, reaped[], branchesDeleted[], wouldReap[] (só no dry-run), preserved[{path,branch,hex,reason,detail}], active, busy? }`. **Telemetria:** remover a árvore leva o `events.jsonl` dela (o custo do agent em `cost --all-trees`); o conteúdo de `.morph/` fica no backup `finish-backup/task/`, como já ficava no `worktree remove --target` do `morph-apply`.
36
+ - **`.vscode/settings.json` (editor)** — `init`, `update`, `worktree setup|provision` e `doctor --worktrees --prune` adicionam a `files.watcherExclude`, `search.exclude` e `files.exclude` os globs `**/worktrees/**` (e o legado `**/.worktrees/**`) e `**/node_modules/**`; `**/bin/**` e `**/obj/**` entram em `watcherExclude` e `search.exclude` sempre e em `files.exclude` só em projeto .NET (stack `dotnet` na config, ou `.sln`/`.slnx`/`.csproj` na raiz), porque num projeto JS o `bin/` guarda código-fonte. **Só adiciona o que falta:** chave do usuário, ordem e valor existente ficam (`"**/bin/**": false` continua `false`), e a 2ª rodada não muda um byte. **JSONC:** não há escritor que preserve comentário, então arquivo **com comentário, com vírgula sobrando, ilegível, com raiz que não é objeto, ou rastreado pelo git** não é escrito — o relatório (`editorSettings` no `--json` de `setup`/`provision`, `prune.editorSettings` no do `doctor`) traz `status: "suggest"`, a `reason` e o `snippet` só com o que falta, para colar. Legível e sem comentário, reescreve preservando BOM, EOL e indentação. **O arquivo criado** entra no `info/exclude` do common dir (`git-exclude.js`): não rastreado, contaria como sujeira da raiz, e o `finish --merge` recusa raiz suja. Desliga: `worktree.editorSettings: false` na config, ou `MORPH_EDITOR_SETTINGS=0`. O `update --dry-run` prevê a escrita (`writeFile`). **O que NÃO cobre:** as chaves cortam watcher, busca e explorer — não governam a descoberta do Git nem o language server. `git.repositoryScanMaxDepth` (padrão 1) não alcança `worktrees/<n>` (profundidade 2), e `git.detectWorktrees` vem **desligado** por padrão (`false`, `scope: resource`; lido no `package.json` da extensão Git do VS Code 1.124.2) e é um recurso de LISTAGEM: mostra as worktrees em *Source Control Repositories*, até `git.detectWorktreesLimit` (50). Ligá-lo faz o VS Code enxergar mais repositórios, não menos, e a escrita no workspace sobrepõe uma preferência global do usuário — por isso o morph-spec não o grava; quem o quer liga na própria config. Não medido: se o C# Dev Kit respeita `files.exclude`, e o antes/depois de RAM e de processos do VS Code com as exclusões.
37
+ - **`worktree.root` (worktrees fora da árvore aberta)** — chave de `.morph/config/config.json` (ou `MORPH_WORKTREE_ROOT`, por rodada): absoluta, `~/…` ou relativa à raiz primária. As worktrees **novas** (`setup`, `provision`, o hook `WorktreeCreate` e o fallback dele em git puro) nascem em `<root>/<repo>-<hash>/<nome>`; o hash é da raiz do repositório, para dois repositórios de mesmo nome dividirem o mesmo root sem colidir. **Sem a chave, nada muda.** **Só aceita root FORA da árvore:** um valor que resolve para dentro da raiz do projeto é **ignorado com aviso** (`worktreeRootWarning` no `--json` de `setup`/`provision`) e valem `worktrees/`, porque toda lista orientada por esse nome (`.gitignore`, `graphifyignore`, descoberta de testes, o `tar` do `--linux`, os globs do editor) deixaria de enxergá-lo em silêncio. Um root **acima** da raiz (`..`) é aceito: o contêiner vira um irmão do repositório. **"Está no contêiner" é a UNIÃO** do layout configurado com `worktrees/` e `.worktrees/`: a árvore que já existe continua reconhecida depois que a chave é ligada ou trocada, e uma criada em qualquer outro lugar segue acusada como `out-of-container`. O resolvedor é um só (`framework/hooks/shared/worktree-root.js`; hook não importa `src/`) e é lido por `buildWorktreePath`, pelo mkdir do pai e por `findWorktreeForFeature`; pelo `doctor --worktrees` (`inContainer` e a varredura de resíduo, que olha só `<root>/<repo>-<hash>/` — nunca a pasta de outro repositório, senão o `--prune` apagaria pastas alheias); pelo reaper; pelo SessionStart (`isOutsideContainer`); pelo registro de projetos (a worktree é detectada pelo `.git` ser um arquivo que aponta para `.git/worktrees/`, não pelo nome do diretório pai); e pelos scanners do dashboard. `finish`, `remove` e `list` andam pelo `git worktree list` e não dependem do lugar. **Não faz:** trocar a chave não move as worktrees que já existem.
38
+ - **Modelo de trabalho: o Claude Code é a porta de entrada, o morph é o dono da worktree** — `claude -w <nome>`, o `EnterWorktree` (por nome) e o `isolation: "worktree"` de sub-agent passam pelo hook `WorktreeCreate` do morph. Segundo a doc do Claude Code, o hook **substitui por inteiro** a criação nativa (o `git worktree add`, o caminho e a branch) e pode devolver **qualquer** caminho na 1ª linha do stdout (o payload traz `name`, `worktree_path` sugerido, `branch_name` e `source_repo_path`; o morph lê `name`). Por isso entram como `worktree setup <f>` (nome de feature conhecido) ou `worktree provision <n>` (o resto), já com a infra, o bloco de portas e o `morph/…`/`morph-task/…`. **O que a criação nativa daria e o morph faz por conta própria, porque o hook a substitui:** `worktree.baseRef` (o hook lê as três camadas de settings), `.worktreeinclude` (a doc: com hook ele "não é processado"; `copyWorktreeIncludes` o honra — o que casa o padrão **e** é ignorado na árvore, nunca `node_modules`/artefato de build/`.git` aninhado, nunca sobrescreve, idempotente; o `provision` continua copiando `.env*` e o resultado sai em `worktreeInclude` no `--json`; **custo medido:** 0,15 s numa raiz com 6 mil arquivos em `node_modules` e 0,5 s (1,4 s a frio) no MORPH_GHLBrain, contra o timeout de 120 s do `WorktreeCreate`, e um pathspec de exclusão não poda a caminhada; **nunca `--exclude-standard`**, que com `--ignored` devolve todo o ignorado — 57 560 caminhos no GHLBrain em vez dos 4 que casam) e a cópia de `.env*`. **Abrir uma worktree que já existe:** abra o `claude` DENTRO dela — uma feature = um worktree = uma instância. `EnterWorktree(path)` para um caminho fora de `.claude/worktrees/` pede aprovação **a cada vez** (regra de permissão e "não perguntar de novo" não a suprimem; só `bypassPermissions`), e uma sessão que entra no meio mantém `CLAUDE_PROJECT_DIR` na raiz e move só o `cwd` do payload: por isso o SessionStart do morph (`worktree-context.js`, `sessionDir`) segue o `cwd` quando ele é um worktree vinculado do MESMO repositório, e a identidade, o bloco de portas e o handoff chegam. **Limpeza:** o sweep periódico nativo mantém qualquer worktree sem o marcador que o Claude grava ("including a worktree a WorktreeCreate hook created"), então **nunca** limpa as do morph; quem limpa é o `worktree reap`, que respeita `git worktree lock` (a marca que o Claude usa em agente rodando) e devolve `locked` como razão. O `WorktreeRemove` (fim de sub-agent e saída de sessão) chama `worktree remove --if-clean`; ele não dispara em `-p` (medido), e é esse o resíduo que o reaper pega. **Isolamento nativo:** numa sessão isolada por `--worktree`/`EnterWorktree` o Claude bloqueia edição, `cwd` de comando e redirect de git (`git -C`, `GIT_DIR`, `cd` para a raiz) para o checkout principal — `finish`, `worktree remove --target` e `worktree reap` rodam da raiz, não de dentro. A remoção nativa não atravessa mais junction no Windows (v2.1.205), mas o `git worktree remove` cru continua atravessando: a regra "só o morph remove" segue valendo. **Não medido:** se o SessionStart de `claude -w` já chega com o `cwd` no worktree, e o `claude -w "#1234"` (PR): o hook recebe um `name` e cai no `--base` do morph, não no head do PR. Fontes: `code.claude.com/docs/en/worktrees` e `/hooks`.
35
39
  - **Backups do finish (`.git/morph-spec/finish-backup/`) e a retenção** — o que o `finish` (o shed da raiz, `{f}-{carimbo}/`) e o desmonte do worktree (`{f}-{carimbo}-worktree-ignored/`) copiam antes de apagar um arquivo que não existe em outro lugar; `worktree remove` de árvore de task grava em `task/{n}-{carimbo}-worktree-ignored/`. Cada pasta ganha um manifesto ao lado (`{pasta}.manifest.json`): o que foi copiado, os caches deixados de fora (`excludedCaches`) e os **nomes** dos `.env*` que entraram (`secrets`). **Um finish é UM backup**: as duas pastas dele levam o mesmo carimbo e contam juntas; pastas gravadas antes disso têm carimbos diferentes e contam uma a uma. **Retenção ligada por padrão**, também em projeto cujo `config.json` não tem a chave: os **20 backups mais novos de cada namespace** (a raiz de `finish-backup/` e `task/` têm orçamentos separados — um fan-out de sub-agents não despeja o backup da feature fechada um minuto antes) **sempre ficam, de qualquer idade** — não há corte por idade no padrão, então um período parado nunca zera os backups das últimas features fechadas —; dentro de um backup que fica, os arquivos `.env*` são apagados depois de **7 dias** (o resto fica, e o manifesto guarda os nomes e `secretsStrippedAt`). Configurável em `config.json → finishBackup.retention: { maxCount, maxAgeDays, secretMaxAgeDays }` (inteiros ≥ 1; `maxAgeDays` é um corte por idade OPCIONAL — ausente ou `null`, o padrão, não corta) ou desligada com `finishBackup.retention: false` (schema em `framework/schemas/config.schema.json`, autoridade em `resolveBackupRetention()`). **O que não dá para ler PULA a poda naquela execução, nunca cai no padrão** — é uma operação que apaga, e um `{ "enabled": false }`, um `"false"`, um `maxCount: 0`, uma chave desconhecida, um `finishBackup` que não é objeto ou um `config.json` que não parseia podem muito bem querer dizer "guarde tudo": a saída diz `backupRetention: {skipped: "<razão>"}`, o `doctor --worktrees --prune` avisa com a razão, e o `doctor`/`doctor --worktrees` acusam até alguém corrigir. Só a ausência da chave liga o padrão. **Backup sem manifesto é legado** (gravado antes desta retenção) — esse é o critério, e nenhum outro. A poda automática nunca o apaga, nunca tira os `.env*` dele e não lhe dá vaga no orçamento; ela só conta quantos esperam (`backupRetention.legacyPending: N` + `legacyHint`). **A primeira poda deles é só com `doctor --worktrees --prune` explícito**, que os ranqueia junto com os demais (contando no teto), apaga os que ficam fora e grava um manifesto (`legacy: true`, `adoptedAt`) em cada um que mantém — dali em diante são backups comuns. A poda automática roda **depois** do backup novo — no fim de todo `finish` com `finished: true` e de todo `worktree remove` que gravou backup (a saída traz `backupRetention: {removed, removedSample, secretsStripped, declined, legacyPending, legacyHint}` só com o que se aplica). **O backup que a execução acabou de gravar nunca é podado por ela** — o carimbo dela é protegido —, e backup com carimbo no FUTURO (relógio adiantado e depois corrigido) fica e não ocupa vaga na contagem. O `finish --dry-run` anuncia só o que a poda AUTOMÁTICA faria (`backupRetention: {outsideRetention, overdueSecrets, legacyPending}` + uma linha em `wouldDo`), sem apagar nada. Fail-open: uma poda que falha nunca derruba o comando. PROVE-or-DECLINE como o resto do desmonte: backup com reparse point dentro é mantido (`declined`) — mas perde os `.env*`, porque a política o queria fora —, link nunca é atravessado (nem um `task/` que seja junction), e **só `task/` é namespace**: qualquer outra pasta de primeiro nível que não tenha nome de backup nunca é visitada nem removida, nem vazia. O `.env*` idêntico ao da raiz nunca chega a ser copiado (é replicável, ver acima); o que chega é o que difere — o `.env.e2e` de cada worktree carrega o bloco de portas dela, então nenhum é igual ao da raiz, e é por isso que o prazo curto existe.
36
40
  - **`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.
37
41
  - **`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`.
@@ -41,22 +45,25 @@
41
45
  ## Validação
42
46
 
43
47
  - **`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. UC com nome fora do padrão `UC-{nn}-{slug}.md` (slug com maiúscula ou `_`) continua encontrado, com aviso: o `finish` não o promove ao acervo. `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`. Os achados de ciclo de vida do `usecase check` (task sobre UC `superseded`, revogação que o `finish` não aplicaria) saem sob `⚠ Ciclo de vida do acervo`, também só aviso, na chave `lifecycle`. **Tamanho de task:** task com mais de 8 `outputs` (`MAX_TASK_OUTPUTS`) é **erro** (exit 1) enquanto o Gate 2 não foi aprovado — fatie por pós-condição — e só aviso depois dele (chore/hotfix nascem com o plano aprovado por política); o `--json` traz `taskSize: {max, oversized, blocking}`. `--mermaid` nunca é bloqueado.
44
- - **`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`. **Onde grava:** só na árvore DONA da feature (`resolveFeatureTree`, a regra do guard dos mutadores, perguntada da raiz) — o worktree com `morph/{f}` checada, mesmo sem ponteiro `worktree.path` na raiz, ou a raiz quando nenhum worktree vivo a possui. Fora dela, `persisted: false` com `reason: 'unregistered-worktree'` (outro worktree, como o `morph-task/{n}` de um sub-agent) ou `'owner-tree-elsewhere'` (a raiz, com o dono vivo em outro lugar). **Rodada repetida:** no escopo de feature, se nada mudou fora de `.morph/` desde o verify de feature anterior (HEAD, diff, arquivos não rastreados e o `config.json`), o `--json` traz `repeatOf: { ts, status, sha }` e a saída humana avisa — é aviso, a rodada executa e carimba igual, porque só o código sabe que a árvore não mudou, não que o ambiente (Docker, banco) também não. 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. **Validador que não pôde checar:** o `reuse` sem grafo não acha nada, e um `validators: pass` dos outros validadores o esconderia — então o `feedback[]` diz `[validators/reuse] não rodou: …`, e o nó leva `skipped: [{ validator, reason, unavailable }]` (quando o nó inteiro pula, a própria linha dele já dá o motivo).
48
+ - **`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`. **Onde grava:** só na árvore DONA da feature (`resolveFeatureTree`, a regra do guard dos mutadores, perguntada da raiz) — o worktree com `morph/{f}` checada, mesmo sem ponteiro `worktree.path` na raiz, ou a raiz quando nenhum worktree vivo a possui. Fora dela, `persisted: false` com `reason: 'unregistered-worktree'` (outro worktree, como o `morph-task/{n}` de um sub-agent) ou `'owner-tree-elsewhere'` (a raiz, com o dono vivo em outro lugar). **Rodada repetida:** no escopo de feature, se nada mudou fora de `.morph/` desde o verify de feature anterior (HEAD, diff, arquivos não rastreados e o `config.json`), o `--json` traz `repeatOf: { ts, status, sha }` e a saída humana avisa — é aviso, a rodada executa e carimba igual, porque só o código sabe que a árvore não mudou, não que o ambiente (Docker, banco) também não. 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. **Validador que não pôde checar:** o `reuse` sem grafo não acha nada, e um `validators: pass` dos outros validadores o esconderia — então o `feedback[]` diz `[validators/reuse] não rodou: …`, e o nó leva `skipped: [{ validator, reason, unavailable }]` (quando o nó inteiro pula, a própria linha dele já dá o motivo). **Nós pesados (build, testes, e2e) esperam por slot** (`morph-spec heavy`): estouro da espera sai **exit 3** com `status: "busy"`, `persisted: false`, sem stamp e sem evento — não é vermelho, e o orquestrador repete a chamada.
45
49
  - **Validador `code-budget` (nó `validators` do `verify`)** — mede o que a árvore **adicionou** contra a branch default: `git diff -U0` a partir do merge-base com `origin/{default}` (ou `{default}` local), arquivo novo não rastreado conta inteiro e arquivo movido sem edição não conta. Lê `.cs`/`.ts`/`.tsx`/`.js`/`.jsx`/`.mjs`/`.cjs`, fora de `.d.ts`, de `node_modules`/`bin`/`obj`/`dist`/`.morph` e de código gerado (`*.Designer.cs`, `*.g.cs`, `*ModelSnapshot.cs`). Três medidas, cada uma **um aviso, nunca erro**, mapeado em `code_quality` da rubrica: (a) **linhas de comentário adicionadas que citam processo** — `UC-nn`, `ADR-nn`, `FR-nn`, `T{n}`, `Gate N`, `spec.md`/`mandate.md`, mockup — avisa a partir de uma, com a contagem sobre o total de linhas de comentário adicionadas e até 4 `arquivo:linha` (standard `architecture-code-comments`); comentário é o que o varredor de `src/lib/ai/comment-stripper.js` separa de string, com template e regex literal de TS/JS; (b) **linhas não vazias de teste ÷ de produção** — avisa acima de **1,5×**, e só quando o teste adicionado passa de **40 linhas** (a regressão de um bug de duas linhas cabe abaixo disso); sem linha de produção, o aviso dá as duas contagens sem razão. (c) **densidade de comentário** — linhas de comentário adicionadas ÷ linhas de código adicionadas (as não vazias menos as de comentário), doc-comment (`///`, JSDoc) incluído — avisa acima de **1 a cada 10**, e só a partir de **40 linhas** de código, com até 4 arquivos mais comentados e suas contagens (o padrão do standard `architecture-code-comments` é nenhum comentário). Teste é o que o validador `reuse` já trata como teste: arquivo `.test.` ou `.spec.` com extensão `js`/`jsx`/`ts`/`tsx`, e tudo sob `__tests__/`, `test/`, `tests/` ou pasta terminada em `Test`/`Tests` (`Foo.Tests/`, `FooTests/`). O resto conta como produção, inclusive `a.test.mjs`, `a.test.cjs` e `*.e2e.ts` fora dessas pastas. Sem base git, ou sem linha de código adicionada, pula com razão; qualquer falha de medição também vira skip, nunca erro. Fica fora do modo `light`. Excesso justificado não silencia o aviso: a justificativa mora na task (orçamento do `morph-plan` passo 5), e é lá que o avaliador a lê.
50
+ - **`morph-spec mutate [feature] [--plan <arquivo>] [--out <arquivo>] [--no-subset] [--allow-dirty] [--jobs <n>] [--timeout <ms>] [--restore] [--json]`** — a prova de mutação do Gate 3 **em lote**, para o avaliador independente: ele escolhe os mutantes, a ferramenta os roda todos numa chamada e imprime a seção `## Mutações` pronta (também em `4-review/mutation-section.md`). **Manifesto** (`--plan`, default `4-review/mutants.json`; JSON escrito com a ferramenta Write ou, só com Bash, por heredoc de delimitador entre aspas — e como o transporte do Bash reduz `\\` a `\` e decodifica escapes unicode — medido —, cada barra invertida do código vai como `{{BS}}`, que a ferramenta expande; JSON inválido sai com essa dica): `{"mutants":[{"id":"M1","file":"src/x.cs","line":42,"find":"a && b","replace":"a","mode":"schemata|rebuild","note":"…"}]}`. `find` tem de **começar na `line`** e ser único nela; `arquivo:linha`, diff e os dois md5 do relatório saem da ferramenta, não da digitação. Dois `find` que se sobrepõem no mesmo arquivo são recusados; uma cláusula de predicado composto é um mutante próprio. **Método, em ordem:** (1) recusa arquivo com alteração não commitada (o fix se commita ANTES de mutar; `--allow-dirty` assume o risco); (2) snapshot dos bytes **fora da árvore** (`<git-common-dir>/morph-spec/mutation/<run>/`) com journal — uma rodada que morreu no meio se desfaz por `mutate --restore`; (3) rebuild **forçado** da árvore limpa e md5 da DLL: a baseline binária; (4) em .NET, antes, **uma triagem de um build só** compila a forma LITERAL de todos (o schemata guarda o texto original no `else`, então aceitaria um mutante cuja forma literal não compila — o `out var` removido e usado adiante); quem falha nela vai para rebuild real, que o julga (`build-error`, nunca morto); depois todo mutante `.cs` de expressão vira uma chave `MORPH_MUTANT` (`(Sw.On("M1") ? mutado : original)`) e **um único build** compila todos — a fonte é restaurada logo depois do build, então o disco fica mutado só durante ele; o que a chave não compila (declaração, `out var`, `const`, argumento de atributo) cai **sozinho** para rebuild real (build incremental por mutante, dizendo o erro do compilador); (5) **subconjunto primeiro**: os testes que a feature alterou contra a default mais os que citam os tipos mutados — a ferramenta escolhe, o avaliador não pode escolher o teste que "sabe" que quebra; vitest/jest usam `related`; (6) **sobrevivente do subconjunto é sempre refeito com o mutante compilado de verdade e a suíte INTEIRA** — "sobreviveu" só sai desse caminho, então o escopo rápido pode errar só para o lado do custo, nunca do veredito. **Paralelismo (`--jobs <n>`, default 2):** as rodadas do escopo rápido rodam em N processos sobre o MESMO binário de schemata — a chave é uma variável de ambiente, então não há cópia da árvore nem segundo build; cada worker toma um slot `heavy` livre e, sem slot livre, a rodada degrada para sequencial em vez de esperar. A rodada-base roda em N cópias ao mesmo tempo, e falha que só aparece sob concorrência entra na base (não vira morte). A suíte inteira nunca é paralelizada (já ocupa a máquina), e suíte que compartilha banco ou arquivo entre processos pede `--jobs 1`. Suíte no Microsoft.Testing.Platform (`global.json`) é recusada (exit 2): o veredito e o motivo saem do TRX do VSTest. **Veredito por mutante:** `killed` = falha **nova** (ausente na rodada-base do mesmo escopo) com o motivo literal — nome do teste + primeira linha da mensagem, do TRX. **Na suíte inteira a falha tem de se repetir:** a ferramenta refaz só os testes que falharam (até 5, pelo método, sobre o mesmo binário) e, se nenhum falhar isolado, mais uma rodada da suíte inteira (falha que só existe com o contexto da suíte continua morte); passar nas duas é instabilidade — o mutante fica `survived` com a razão dita (medido: um teste de host sem relação com o mutante o "derrubou" uma vez em 7 rodadas); a repetição que não termina é `inconclusive`; morte no subconjunto não é repetida. `survived`; `build-error` (o mutante nem compilado de verdade compila: não é morte); `inconclusive` (testhost caiu, estourou o tempo ou nenhum teste executou: não é morte nem sobrevivência, segue para a suíte inteira). A contagem carrega o escopo: **`≥K de N (subconjunto)`** — piso, não total — ou **`K de M (suíte inteira)`**. **Reversão:** bytes do snapshot com md5 conferido por arquivo, sempre por **escrita nova** — devolver o mtime antigo (`os.utime`) deixa o MSBuild achar o binário do último mutante em dia e a rodada-base roda sobre ele; ao fim, rebuild forçado e a DLL é comparada (md5) com a construída antes do 1º mutante (projeto de build não determinístico: prova de "recompilada depois da restauração e sem a chave"), e `git status --porcelain` tem de sair igual ao de antes. **Exit:** `0` rodou (sobrevivente é achado, não erro), `1` falha (a árvore já não compilava antes da mutação), `2` uso (manifesto inválido, arquivo sujo, nenhuma suíte roda o arquivo), `3` semáforo ocupado, `4` **inseguro** (arquivo não restaurado, árvore diferente ou binário que não bate — não avalie antes de resolver). Segura **um** slot `heavy` do início ao fim.
46
51
  - **`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.
52
+ - **`morph-spec heavy status [--json]`** e **`morph-spec heavy run [--label <x>] -- <comando…>`** — semáforo de nós pesados **por usuário**: os slots vivem em `%LOCALAPPDATA%morph-specheavy` (Windows) ou `$XDG_STATE_HOME/morph-spec/heavy` (`~/.local/state/…`), compartilhados por **todas** as sessões da máquina, com `heavy.slots` (2) vagas. **Quem toma slot:** build, testes (inclusive `--linux`) e e2e do `verify` — um slot por `verify`, adquirido só imediatamente antes do primeiro spawn real (nó pulado não adquire e a espera fica fora do `durationMs`) —, o `e2e up` (só durante a subida da stack) e o `heavy run`, que roda qualquer comando sob slot e repassa o exit code (`127` se não conseguiu executá-lo; `npm`/`npx`/`pnpm`/`yarn` viram `.cmd` no Windows). `heavy status` é **somente leitura**: quem segura cada slot (pid, label, feature, nó, idade), a limpeza exclusiva do build-server se houver e a RAM livre. **Espera:** slot cheio, ou RAM livre (`os.freemem()`) abaixo de `heavy.minFreeMemGb` (4), espera até `heavy.waitTimeoutSec` (600) avisando em **stderr** a cada 30 s e nomeando quem segura (o `--json` mantém o stdout limpo). RAM baixa espera **com ou sem** holder de slot: a pressão costuma vir de fora (vmmem do WSL, IDE), e "sem holder → roda" reproduziria o "roda e morre". **Estouro = exit 3 (`busy`), nunca vermelho:** `verify --json` sai `{ status: "busy", persisted: false, busy: { reason, holders, freeGb, waitedMs } }`, **sem stamp e sem evento `Verify`** (nada foi verificado, e fila não é gate verde nem vermelho); `e2e up` e `heavy run` também saem 3. Quem orquestra repete a chamada — nas skills, exit 3 não conta como tentativa e não dispara o loop de correção. **Slot vago:** pid morto, ou idade acima de `heavy.maxHoldMinutes` (90, contra reuso de PID); Ctrl-C deixa o arquivo, que o pid morto cobre. Um filho herda o slot por `MORPH_HEAVY_LEASE` e nunca pede um segundo. **Config** (`.morph/config/config.json`, bloco `heavy`, schema em `config.schema.json`): `enabled`, `slots`, `minFreeMemGb`, `waitTimeoutSec`, `maxHoldMinutes`, `shutdownBuildServer`, `disableNodeReuse`, `gateBash`; valor ilegível cai no default **com aviso** — o bloco nunca bloqueia trabalho por estar errado. Env: `MORPH_HEAVY=off` desliga o semáforo (a suíte do próprio framework roda assim, para não enfileirar nos slots reais de quem a executa), `MORPH_HEAVY_SLOTS`, `MORPH_HEAVY_DIR`. **Fim do trabalho pesado:** no fim do `verify` de escopo **feature** (nunca no de task: cada task pagaria compilador frio), no `e2e down` e no `doctor --procs --prune`, roda `dotnet build-server shutdown` **só** se o projeto é .NET, `heavy.shutdownBuildServer` está ligado, nenhum slot está preso **e** nenhum `dotnet build|test|publish|msbuild` está vivo — sub-agents e IDE não passam pelo semáforo, então semáforo vazio não basta. Com `heavy.enabled: false` nunca roda: sem semáforo, nada prova que outra sessão não está compilando. Fail-open, timeout de 30 s, resultado em `cleanup` no `--json`. `heavy.disableNodeReuse` põe `MSBUILDDISABLENODEREUSE=1` enquanto o nó pesado roda e restaura depois. O `--linux` rotula o container (`morph-spec.verify=<runId>`, `morph-spec.root=<hash da raiz>`) e o remove **por label** no fim, nunca por nome; o `e2e down` usa `--remove-orphans`. O `verify` **não** derruba a stack e2e (`keepUp` é por desenho). **Bash cru (`heavy.gateBash`, opt-in, padrão `false`):** o `dotnet build`/`dotnet test`/`npm test` de um sub-agent pelo Bash não passa pelo `verify`, então dois elos das chains de hooks fazem o papel dele. `heavy-gate` (`PreToolUse` Bash, depois de todos os guards e antes do `worktree-env-inject`) reconhece o comando pesado — `dotnet build|test|publish|pack|msbuild|vstest`, `msbuild`, `npm|pnpm|yarn|bun test|build` e `run test|build[:x]`, `npx vitest|jest|playwright test|next build`, `vitest`, `jest`, `playwright test`, `next|vite build`, `docker build|buildx`, `docker compose|docker-compose up|build` — olhando o executável de cada segmento (`;`, `&&`, `||`, `|`, quebra de linha, fora de aspas): `git commit -m "fix build && dotnet test"` e `morph-spec heavy run -- dotnet test` não são pesados. Toma um slot e espera até `min(waitTimeoutSec, 540)` s — abaixo dos 600 s de timeout do hook, porque um `PreToolUse` que estoura o timeout **deixa a chamada rodar**; sem vaga no prazo a chamada é **recusada** (`deny`) com a causa e a instrução de repetir o mesmo comando, e não roda. `heavy-release` (`PostToolUse` e `PostToolUseFailure`) devolve a vaga pelo `tool_use_id`. **Não reescreve o comando:** a doc do Claude Code avalia as regras de permissão contra o comando **reescrito**, e o `allow` que acompanha um `updatedInput` aprovaria o que o prompt perguntaria; por isso não há reescritor (o `allow` não atropela regra `deny`/`ask`, mas a reescrita esconderia o comando delas). **O que a vaga não cobre:** ela não tem processo vivo para vigiar (o hook sai na hora), então vence por **TTL** — o `timeout` da chamada, no máximo o da plataforma, mais 30 s — e uma chamada recusada por regra de permissão ou interrompida pelo usuário, que não dispara `Post*`, segura o slot até lá. O TTL conta desde o `PreToolUse`, que roda **antes** do prompt de permissão: se a aprovação demora mais que o `timeout` + 30 s, a vaga vence com o comando ainda por rodar e outra chamada pode tomá-la (sobreassinatura, do lado seguro). Chamadas de Bash de um mesmo lote paralelo rodam **em série** no Claude Code (medido na 2.1.285: o `Pre` da 2ª só rodou depois do `Post` da 1ª), então um agente não fica esperando uma vaga que só a própria chamada anterior liberaria. `run_in_background`, PowerShell e comando fora da lista não são cobertos, e a cláusula 6 dos templates de dispatch (`heavy run`) segue valendo para eles. **Medido** com `claude -p` real e a chain instalada: fila cheia recusa após a espera e não mexe na vaga alheia; a vaga está presa durante a chamada e volta vazia depois, com exit 0 e com exit 1. Custo num Bash **leve**: ~1,5 ms na chain Pre (mediana 61,4 → 62,9 ms, medido intercalado) e ~0 na Post; os módulos do semáforo só carregam quando o comando é pesado. O núcleo (`heavy-config`, `heavy-holders`, `heavy-slots`) mora em `framework/hooks/shared`, porque hook não importa `src/`.
47
53
  - **`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. Uma linha `.git/morph-spec (<tamanho> · N backup(s) do finish)` mostra o que o morph-spec guarda dentro do `.git` (backups do finish, registros de porta e de claim), que não aparece em `git status`: `✓` normalmente, `⚠` quando há backup legado esperando `--prune`, backup fora da retenção ou `.env*` além do prazo curto (com o comando de poda) — nunca `✗`, e nenhuma linha quando não há nada guardado.
48
54
  **Personas (`.claude/agents/morph-*.md`).** São artefatos derivados de `.morph/framework/agents.json` — nunca editados à mão. O `doctor` padrão valida o `agents.json` (inclusive o `maxTurns`: inteiro ≥ 1 ou ausente — `0`, negativo, fracionário, string ou `null` é **erro** nomeando persona e valor, porque o installer recusa gerar essa persona — e o `surfaces`: `{ "<superfície>": { when, standards } }` com id kebab-case, `when` e lista não vazios, e nenhum standard repetido da lista universal `standards`; inválido é **erro** pelo mesmo motivo). Com `surfaces`, o corpo da persona ganha `## Standards by surface`: cada superfície com o seu `when`, e a instrução de ler a lista universal mais só a da superfície da tela — a que o checklist de tela do mockup nomeia em `Superfície`. O `--full` compara cada `.md` com o render atual e, no drift, diz **qual campo** divergiu por arquivo — `morph-x.md (maxTurns)` é edição à mão do teto; `(body)` depois de um upgrade da CLI é só regeneração pendente. Os grants `mcp__*` são conferidos contra o registry do **pacote**, o mesmo que o installer leu (a cópia instalada não tem `mcp/`). A linha `tools:` é allowlist estrita: toda persona recebe também `Skill` e `ToolSearch` (sem eles não invoca skill nem carrega MCP adiado), e cada MCP vira um grant por grafia de `serverNames` do registry — o mesmo servidor chama `mcp__context7` no `.mcp.json` e `mcp__plugin_context7_context7` como plugin, e um grant só casa com a própria grafia. O campo `maxTurns` é o teto de turnos do subagent gerado: por persona no `agents.json`, `20` quando ausente, e o corpo da persona o anuncia ao sub-agent junto com o turno de checkpoint (~70%) — o sub-agent não vê o próprio frontmatter. O valor vem do `agents.json` do **pacote**: `update` recopia o arquivo e regenera os `.md`, então editar a cópia em `.morph/framework/` não muda a instalação — só faz o `--full` acusar drift.
49
55
  **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.
50
56
  **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.
51
57
  Ver `--worktrees` abaixo para a reconciliação de worktrees (modo separado), e `--ai` para a auditoria de projeto .NET com agentes.
52
- - **`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.
58
+ - **`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`/`--procs` é **erro de uso**: exit 1, sem rodar nada — cada superfície tem o seu relatório e o seu código de saída.
53
59
 
54
60
  **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?).
55
61
 
56
62
  **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.
57
63
 
58
64
  **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.
59
- - **`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). O `--prune` também aplica a **retenção dos backups do finish** (ver acima) — a mesma política que todo `finish` já aplica sozinho, e a **única via que a aplica aos backups legados** (sem manifesto), contando-os no teto e dando manifesto aos que ficam; sob `--json` a saída é só o JSON, e o que o `--prune` fez vai nele: `prune` (`pruned`, `orphanRegistrations`, `removedEmptyDirs`, `clearedRegistrations`) e `backupRetention` (com `adopted`). Sem `--prune`, backup legado esperando poda, backup fora da retenção ou `.env*` além do prazo curto vira o achado `finish-backup-retention`, que descreve o que o `--prune` faria (`severity: warn` — faxina, nunca falha do comando). 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.
65
+ - **`morph-spec doctor --procs [--prune] [--json]`** — o que o morph-spec e as suas sessões deixam de pé na máquina, em superfície própria: **não entra no `--full` nem no default** (mesmo motivo do `--ai`), e combinar com `--full`/`--mcp`/`--worktrees`/`--reset`/`--ai` é **erro de uso**. Sem `--prune` é **somente leitura**. **Processos:** lista `node`, `dotnet`, `testhost`, `esbuild` e Chrome headless de automação (o seu Chrome comum não entra) **sem pai vivo**, com idade e RSS. Órfão = pai ausente da tabela **ou criado depois do filho** (PID reaproveitado). **Nunca é candidato à poda:** processo com menos de 15 min ou de idade desconhecida; linha de comando que casa com **qualquer MCP configurado** (`.mcp.json`, `~/.claude.json` do projeto e do usuário, `.mcp.json` dos plugins habilitados), mesmo órfão, porque no Windows a cadeia `claude→cmd→npx→node` perde o elo quando o `cmd` do meio morre; o worker de refresh do grafo; e os nós de build (MSBuild `/nodemode`, VBCSCompiler), compartilhados entre sessões — esses saem por `dotnet build-server shutdown` quando ociosos, nunca por kill. `--prune` encerra **por PID** só o que sobrou, relendo o início e a linha de comando daquele PID imediatamente antes (PID reaproveitado é pulado); **nunca por nome de imagem** (`taskkill /IM node.exe` derrubaria os MCPs de todas as sessões). Exit 1 só se algum encerramento falhar. **MCP duplicado:** o mesmo servidor no `.mcp.json`/`~/.claude.json` e entregue por plugin habilitado sobe dois processos (`mcp__X` e `mcp__plugin_X_X`); o relatório só sugere remover do `.mcp.json` ou desligar o plugin. O `init` e o `mcp setup --auto` deixaram de recriar a duplicata: um plugin habilitado que entrega o servidor conta como instalado. **WSL:** `%USERPROFILE%\.wslconfig` ausente, ou sem `memory=` (seção `[wsl2]`) ou sem `autoMemoryReclaim=` (seção `[experimental]`), vira sugestão com o trecho e o `wsl --shutdown`; o morph-spec **nunca escreve fora do repositório**. O `--json` traz `procs`, `mcp`, `wsl` e, com `--prune`, `prune`. Listagem indisponível (PowerShell falhou, plataforma sem listador) sai **não verificado** com razão, nunca lista vazia.
66
+ - **`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). O `--prune` também aplica a **retenção dos backups do finish** (ver acima) — a mesma política que todo `finish` já aplica sozinho, e a **única via que a aplica aos backups legados** (sem manifesto), contando-os no teto e dando manifesto aos que ficam; sob `--json` a saída é só o JSON, e o que o `--prune` fez vai nele: `prune` (`pruned`, `orphanRegistrations`, `removedEmptyDirs`, `clearedRegistrations`) e `backupRetention` (com `adopted`). Sem `--prune`, backup legado esperando poda, backup fora da retenção ou `.env*` além do prazo curto vira o achado `finish-backup-retention`, que descreve o que o `--prune` faria (`severity: warn` — faxina, nunca falha do comando). 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. **Sobras de sub-agent:** achado `agent-worktree-leftover` (`info`, nunca falha): `reapable` (fix: `morph-spec worktree reap`) ou preservada com a razão (`dirty`, `exclusive-commits`, `no-usage-event`, `directory-missing`, `unknown`); agente ativo e agente dentro da graça não geram achado, e a árvore não é reportada de novo como `unintegrated-work`. O `--prune` também roda o `worktree reap` (`prune.agentReap` no `--json`).
60
67
  - **`morph-spec report-check <feature> [task...] [--before-eval] [--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; relatório com `PARCIAL` na 1ª linha — o checkpoint a ~70% do `maxTurns` — sai `report-incomplete` com `partial: true` e ação `resume`: retome o agente, NÃO carimbe DEGRADADO. O marcador é lido de propósito, nunca inferido da falta de score: o marcador de score é frouxo e um parcial que diga "auto-score: pendente (0-10 no final)" casaria com ele). 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). Vários ids (`report-check {f} T1 T2 --json`) auditam um batch paralelo numa chamada: sem task, o modo feature só audita tasks `done`, e as de um batch recém-voltado ainda não estão. **Bloco `tree`**, em toda saída e sem mudar veredito nem exit code: `dirty`/`dirtyCount` (o que não está commitado fora de `.morph/`), `diffstat` (contra `HEAD`), `commits` (sobre a default, até 10; `null` sem default), `markers` (linhas com `MUTACAO` em arquivo rastreado ou não, fora dos ignorados), `fingerprint` (a mesma impressão da árvore que o `verify` grava), `head` e `branch`; `null` quando o git não responde (fora de repositório ou erro) — desconhecido, nunca árvore limpa. **`--before-eval`** grava em `4-review/tree-before-eval.json` a foto da árvore (HEAD e o blob de cada arquivo que difere dele, fora de `.morph/`) antes do dispatch do avaliador, e daí em diante toda saída traz `tree.sinceBeforeEval: { at, headChanged, changed, changedCount }`: os arquivos cujo conteúdo não é o da foto, inclusive o que foi commitado desde então. É assim que uma sessão que retoma depois de o avaliador morrer no meio vê a mutação que ficou; sem foto, `sinceBeforeEval` é `null`. É o que o orquestrador lê depois de um dispatch, em vez de `git status`/`git diff`/`git log`/`grep`, e o que cola no recado de retomada. Protocolo completo em MORPH.md §6.
61
68
 
62
69
  ## Suítes de teste (`project.tests[]`) e a divisão loop × suíte
@@ -10,24 +10,39 @@ Um teste verde prova que o código roda. Só a **mutação** prova que o teste M
10
10
  propósito a linha que carrega a decisão e veja quem grita. Se ninguém grita, a suíte é decoração —
11
11
  e foi assim que 11 testes verdes cobriram um artefato que o build recusava.
12
12
 
13
- A pergunta desta rubrica é sempre a mesma: **você mutou o call site que carrega a decisão, rodou a
14
- suíte inteira, leu o MOTIVO da falha, e devolveu o arquivo byte a byte?**
13
+ A pergunta desta rubrica é sempre a mesma: **você mutou o call site que carrega a decisão, provou
14
+ cada sobrevivente contra a suíte inteira, leu o MOTIVO da falha, e devolveu o arquivo byte a byte
15
+ — com o binário provado?**
16
+
17
+ O método é `morph-spec mutate` (manifesto de mutantes → uma chamada → a seção pronta). Você escolhe
18
+ os mutantes; a ferramenta escolhe o subconjunto de testes, roda o lote, refaz todo sobrevivente com o
19
+ mutante compilado de verdade contra a suíte INTEIRA e reverte por bytes.
15
20
 
16
21
  ## Bandas 0-10
17
22
 
18
23
  | 0-3 | 4-6 | 7-8 | 9-10 |
19
24
  |---|---|---|---|
20
- | Nenhuma mutação. Ou mutação declarada sem diff, sem md5 e sem testes nomeados — afirmação, não prova. Ou revert por `git checkout --`, que descarta trabalho não commitado de terceiros | Mutou, mas no lugar errado (o `if` refatorado, não o call site que decide) ou com filtro estreito ("derrubou 3" medido sobre 40 testes de um arquivo só). Reportou md5 sem ler o motivo da falha | Mutação no call site certo, suíte inteira, diff e md5 antes/depois. Falta o MOTIVO literal da falha, ou uma cláusula de um predicado composto ficou sem rodada própria | Uma rodada por cláusula, cada uma com arquivo:linha, diff, md5 antes/depois, contagem sobre a suíte INTEIRA e a mensagem literal de falha. Revert por snapshot de bytes + `os.utime`, com `git status --porcelain src/` vazio ao fim |
25
+ | Nenhuma mutação. Ou mutação declarada sem diff, sem md5 e sem testes nomeados — afirmação, não prova. Ou revert por `git checkout --`, que descarta trabalho não commitado de terceiros | Mutou, mas no lugar errado (o `if` refatorado, não o call site que decide) ou declarou "sobreviveu" / "derruba N" a partir de um filtro escolhido por você ("derrubou 3" medido sobre 40 testes de um arquivo só). Reportou md5 sem ler o motivo da falha | Mutação no call site certo, todo sobrevivente refeito contra a suíte INTEIRA, diff e md5 antes/depois. Falta o MOTIVO literal da falha, ou uma cláusula de um predicado composto ficou sem rodada própria | Uma rodada por cláusula, cada uma com arquivo:linha, diff, md5 antes/depois, contagem com o ESCOPO dito (`≥K de N` no subconjunto que a ferramenta escolheu; `K de M` na suíte inteira) e a mensagem literal de falha. Revert por snapshot de bytes com escrita nova, rebuild forçado e DLL final igual (md5) à de antes do 1º mutante, com `git status --porcelain src/` vazio ao fim |
21
26
 
22
27
  ## Anti-padrões literais
23
28
 
24
29
  - **Mutar o `if` refatorado em vez do call site que carrega a decisão** — o extract-method moveu a
25
30
  regra; o teste continua batendo no invólucro. Ancore onde o valor é DECIDIDO. (memória: guard do
26
31
  remédio, cuja checagem real vivia duas camadas acima do `if` mutado)
27
- - **Rodar com filtro e reportar "derruba N"** — "3 de 40" e "3 de 2684" são afirmações diferentes.
28
- Só a suíte **inteira** autoriza a frase "derruba N". (memória: F30, filtro no Gate 3)
32
+ - **Reportar "sobreviveu" ou "derruba N" a partir de um filtro que você escolheu** — "3 de 40" e
33
+ "3 de 2684" são afirmações diferentes. **"Sobreviveu" só vale contra a suíte INTEIRA**, com o
34
+ mutante compilado de verdade. **"Morto" pode sair de um subconjunto, se a ferramenta o escolheu**
35
+ (testes que a feature alterou + os que citam o tipo mutado): teste que falha no subconjunto falha
36
+ na suíte, então a morte é a mesma — mas a contagem passa a ser piso (`≥K de N (subconjunto)`), e
37
+ `K de M` só existe medido na suíte inteira. (memória: F30, filtro no Gate 3)
38
+ - **Contar como morto o teste instável** — morte na suíte inteira só vale se a mesma falha se repete
39
+ na segunda rodada (dos testes que falharam, isolados, ou da suíte inteira). Medido: um teste de host, sem relação com o mutante,
40
+ "derrubou" um mutante uma vez; em sete rodadas o mutante sobreviveu seis. Falha que não se
41
+ repete é sobrevivente com razão dita, não morte. (mesma rodada: `out var` removido que só compila
42
+ no schemata é `build-error`, não morto)
29
43
  - **Filtro tão estreito que a prova fica vácua** — mutar e rodar só o teste que você já sabia que
30
- quebraria não testa nada além da sua própria expectativa.
44
+ quebraria não testa nada além da sua própria expectativa. O subconjunto é da ferramenta, nunca
45
+ seu: escolhido à mão, ele volta a ser a sua expectativa.
31
46
  - **Aceitar um seed sem perguntar "que sequência real produz este seed?"** — fixture que só existe
32
47
  no teste prova o teste, não o sistema.
33
48
  - **Imprimir `diff` + md5 e parar por aí** — md5 prova que ALGO mudou, nunca O QUÊ. Sem a mensagem
@@ -36,13 +51,23 @@ suíte inteira, leu o MOTIVO da falha, e devolveu o arquivo byte a byte?**
36
51
  - **Reverter por replace reverso, ou por `git checkout --`** — o replace reverso erra em arquivo com
37
52
  ocorrências repetidas; o `git checkout --` **apaga alterações não commitadas de outra pessoa** e
38
53
  aconteceu de verdade num Gate 3 desta epic. Reverta por **snapshot/restore de bytes** (cópia feita
39
- ANTES) + `os.utime` para devolver o mtime, e confirme por `md5sum`.
54
+ ANTES), por escrita nova, e confirme por `md5sum`.
55
+ - **Devolver o mtime original com `os.utime` depois de restaurar** — os bytes voltam, mas com data
56
+ ANTIGA o arquivo fica mais velho que a DLL do último mutante: o MSBuild acha o binário em dia, não
57
+ recompila, e a rodada-base roda sobre o mutante (falso vermelho, ou pior: falso verde). Medido num
58
+ projeto .NET 10 de 697 arquivos: restore com data antiga → build de 1,9 s, nenhum `CoreCompile`,
59
+ DLL do mutante intacta; escrita nova → DLL de volta ao md5 da baseline. Restaure por **escrita
60
+ nova**, force o rebuild e **prove que o binário é o do disco** (md5 da DLL == a construída da
61
+ árvore limpa antes do 1º mutante).
40
62
  - **Escrever a mutação por heredoc do shell quando ela contém barra invertida** — o transporte come
41
63
  o escape: `\b` vira o byte `0x08`, `\n` vira quebra de linha real, e o regex mutado **não é o que
42
64
  você escreveu**. Sintoma: a mutação parece aplicada e o resultado não muda, ou o arquivo quebra
43
65
  num ponto que você não tocou. Diagnóstico em um comando: `cat -A` mostra `^H` onde devia estar
44
- `\b`. Saída: patch por **índice de linha** com o conteúdo montado sem barra invertida no fonte
45
- (`String.fromCharCode(92)`), ou ferramenta de edição estrutural — nunca heredoc.
66
+ `\b`. Medido: o transporte do Bash reduz `\\` a `\` e decodifica escapes unicode, até dentro de
67
+ heredoc de aspas simples. Saída: o manifesto de `morph-spec mutate` com a barra invertida do código
68
+ escrita como o token `{{BS}}` (a ferramenta a expande), ou patch por **índice de linha** com o
69
+ conteúdo montado sem barra invertida no fonte (`String.fromCharCode(92)`) — nunca a barra invertida
70
+ crua num heredoc.
46
71
  (memória: **três agentes** desta epic tropeçaram nisto; dois descartaram o resultado, um só
47
72
  descobriu ao conferir os bytes)
48
73
  - **Mutar antes de commitar o fix** — se a sessão morrer no meio, o que sobrevive é a mutação, não a
@@ -69,9 +94,14 @@ Na seção `## Mutações` do `evaluator-report.md`, uma linha por mutação, co
69
94
  ```markdown
70
95
  | # | Arquivo:linha | Mutação (diff) | md5 antes | md5 depois | Testes derrubados | Motivo da falha |
71
96
  |---|---|---|---|---|---|---|
72
- | M1 | src/lib/x.js:42 | `- if (a && b)` / `+ if (a)` | abc… | def… | 3 de 2684 | `expected 3 got 0` em x.test.js:88 |
97
+ | M1 | src/lib/x.js:42 | `- if (a && b)` / `+ if (a)` | abc… | def… | ≥3 de 212 (subconjunto) | `expected 3 got 0` em x.test.js:88 |
98
+ | M2 | src/lib/y.js:7 | `- return ok` / `+ return true` | 123… | 456… | 0 de 2684 (SOBREVIVEU, suíte inteira) | nenhum teste falhou na suíte inteira |
73
99
  ```
74
100
 
75
- E, ao fim da seção: **Reversão:** snapshot/restore de bytes + `os.utime`; `git status --porcelain
76
- src/` vazio. Feature sem código de produção declara a seção com a linha
77
- `não aplicável — feature sem código de produção`.
101
+ A coluna "Testes derrubados" sempre diz o escopo: `≥K de N (subconjunto)` é piso; `K de M (suíte
102
+ inteira)` é contagem; sobrevivente é sempre `0 de M (suíte inteira)`. `morph-spec mutate` imprime a
103
+ seção inteira nesse formato — cole-a, não a redigite.
104
+
105
+ E, ao fim da seção: **Reversão:** snapshot/restore de bytes por escrita nova; DLL final igual (md5)
106
+ à de antes do 1º mutante; `git status --porcelain src/` vazio. Feature sem código de produção
107
+ declara a seção com a linha `não aplicável — feature sem código de produção`.
@@ -57,6 +57,7 @@ framework/hooks/
57
57
  | **PostToolUse** (Write\|Edit) | post-edit-typecheck.js | Advisory | Debounced `tsc --noEmit` after TS/TSX edits; silent on success. Also touches `.morph/memory/build-dirty.json` on every source-file edit — the write side of validate-completion.js's Stop-hook build-dirty gate |
58
58
  | **PostToolUse** (Write\|Edit) | tasks-json-guard.js | Advisory | Validates the written tasks.json schema (status/effort enums); warns so the LLM fixes it immediately |
59
59
  | **PostToolUse** (Write\|Edit) | trace-autogen.js | State sync | Auto-generates `.morph/traces/YYYY-MM-DD-{feature}-implement.json` from tasks.json + recap.md + taskScores on every tasks.json write — the skills never write traces by hand |
60
+ | **PostToolUse** (Write\|Edit) | comment-guard.js | Blocking | Blocks when the edit ADDED a comment the `architecture-code-comments` standard never allows: process history (UC/ADR/task/gate), banner/`#region`, a block over 3 lines, or a doc-comment outside a published package. Old comments are never reported; last link of the chain |
60
61
  | **PostToolUse** (Write\|Edit\|AskUserQuestion) | state-sync.js | State sync | Autonomous state updates (feature register, output tracking, gate approval) |
61
62
  | **PostToolUse** (Write\|Edit\|AskUserQuestion) | gate-guard.js | Advisory | Warns (never blocks) when a gate action disagrees with the persisted gateDecisions; logs one `coherent`/`divergent` line per check in `.morph/logs/activity.jsonl` (issue #63) |
62
63
  | **PostToolUse** (all tools) | telemetry-log.js | Telemetry | Appends every tool call (skill, sub-agent dispatch + prompt, knowledge-base read, bash/edit) to `.morph/logs/events.jsonl`; silent, fail-open — the NLH execution-path capture read back by `morph-spec telemetry` |
@@ -101,6 +101,8 @@ async function main() {
101
101
 
102
102
  // PreToolUse: o primeiro bloqueio vence e encerra a cadeia.
103
103
  if (result.decision === 'block') {
104
+ // A later link blocking must not swallow what earlier links reported (a tsc error on the same edit).
105
+ if (contexts.length) result = { ...result, reason: [...contexts, result.reason].join('\n\n') };
104
106
  console.log(JSON.stringify(result));
105
107
  process.exit(0);
106
108
  }
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * PostToolUse Hook: comments the edit added in a shape the
5
+ * `architecture-code-comments` standard never allows.
6
+ *
7
+ * Event: PostToolUse | Matcher: Write|Edit
8
+ *
9
+ * `decision: block` makes the agent fix it in the same turn; the edit itself
10
+ * already landed. Only comments absent from `tool_response.originalFile`
11
+ * count, so editing a legacy file never reports its old comments.
12
+ *
13
+ * Fail-open: any error passes silently.
14
+ */
15
+
16
+ import { readFileSync } from 'fs';
17
+ import { isAbsolute, relative, resolve } from 'path';
18
+
19
+ import { blockResult, runStandalone } from '../../shared/hook-response.js';
20
+ import { commentFindings, docCommentsForbidden, MAX_BLOCK_LINES } from '../../shared/comment-rules.js';
21
+ import { isGenerated, isProjectSource } from '../../shared/source-files.js';
22
+
23
+ const MAX_LISTED = 10;
24
+
25
+ const RULE_LABEL = {
26
+ process: 'história de processo (UC/ADR/task/gate)',
27
+ banner: 'banner ou #region',
28
+ 'doc-comment': '///·JSDoc em código que não é pacote publicado',
29
+ 'long-block': `bloco com mais de ${MAX_BLOCK_LINES} linhas`,
30
+ };
31
+
32
+ function spliceEdit(original, oldString, newString, replaceAll) {
33
+ if (replaceAll) return original.split(oldString).join(newString);
34
+ const at = original.indexOf(oldString);
35
+ if (at < 0) return null;
36
+ return original.slice(0, at) + newString + original.slice(at + oldString.length);
37
+ }
38
+
39
+ function readOrNull(file) {
40
+ try {
41
+ return readFileSync(file, 'utf8');
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ function contentsOf(payload, absolute) {
48
+ const input = payload.tool_input ?? {};
49
+ const response = payload.tool_response ?? {};
50
+ const before = response.originalFile ?? '';
51
+ if (payload.tool_name === 'Write') {
52
+ if (response.originalFile === undefined) return null;
53
+ return { before, after: readOrNull(absolute) ?? input.content ?? '' };
54
+ }
55
+ if (typeof response.originalFile !== 'string') return null;
56
+ const after = readOrNull(absolute)
57
+ ?? spliceEdit(response.originalFile, input.old_string ?? '', input.new_string ?? '', input.replace_all === true);
58
+ return after === null ? null : { before, after };
59
+ }
60
+
61
+ // NOT_SOURCE matches `build/`, `out/` anywhere: judged on an absolute path,
62
+ // a project checked out under such a folder would be skipped whole.
63
+ function projectRelative(absolute, projectDir) {
64
+ const rel = relative(projectDir, absolute);
65
+ return rel && !rel.startsWith('..') && !isAbsolute(rel) ? rel : absolute;
66
+ }
67
+
68
+ function describe(file, findings) {
69
+ const listed = findings.slice(0, MAX_LISTED)
70
+ .map((f) => `- ${file}:${f.line} — ${RULE_LABEL[f.rule]} — ${f.text}`);
71
+ if (findings.length > MAX_LISTED) listed.push(`- (+${findings.length - MAX_LISTED})`);
72
+ return [
73
+ 'Comentários que o padrão do projeto não aceita (architecture-code-comments: o padrão é nenhum comentário):',
74
+ ...listed,
75
+ '',
76
+ 'Remova-os agora. O que o nome, o tipo ou o teste podem dizer vai para eles; o elo com a pós-condição vai no NOME do teste.',
77
+ `Só fica o porquê que o código não mostra (invariante, ordem que importa, quirk externo com link), em até ${MAX_BLOCK_LINES} linhas.`,
78
+ ].join('\n');
79
+ }
80
+
81
+ /**
82
+ * @param {object} payload - PostToolUse stdin payload
83
+ * @returns {Promise<object|null>}
84
+ */
85
+ export async function run(payload) {
86
+ try {
87
+ if (!['Write', 'Edit'].includes(payload?.tool_name)) return null;
88
+ const file = payload.tool_input?.file_path ?? '';
89
+ const projectDir = process.env.CLAUDE_PROJECT_DIR || payload.cwd || process.cwd();
90
+ const absolute = isAbsolute(file) ? file : resolve(projectDir, file);
91
+ const judged = projectRelative(absolute, projectDir);
92
+ if (!isProjectSource(judged) || isGenerated(judged)) return null;
93
+
94
+ const contents = contentsOf(payload, absolute);
95
+ if (!contents) return null;
96
+
97
+ let docForbidden = false;
98
+ try { docForbidden = docCommentsForbidden(absolute); } catch { /* unreadable project file: stay quiet */ }
99
+
100
+ const findings = commentFindings({ file, ...contents, docCommentsForbidden: docForbidden });
101
+ return findings.length ? blockResult(describe(file, findings)) : null;
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ await runStandalone(run);
@@ -0,0 +1,72 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * heavy-gate.js — PreToolUse hook (Bash matcher), link of the Bash chain before worktree-env-inject.
4
+ *
5
+ * A sub-agent that runs `dotnet build` or `npm test` straight through Bash bypasses the heavy-node
6
+ * semaphore that `morph-spec verify` respects. With `heavy.gateBash: true` this hook takes a slot
7
+ * before such a command runs and `heavy-release` gives it back on Post/PostFailure, matched by
8
+ * `tool_use_id`. It never rewrites the command: a rewrite would be judged against the user's
9
+ * permission rules in its rewritten form, and would auto-approve what the prompt would have asked.
10
+ *
11
+ * A slot taken here has no live process to watch — the hook exits at once — so it lapses by TTL
12
+ * (the call's own timeout plus slack). A call refused by a permission rule or interrupted by the
13
+ * user fires no Post hook and keeps its slot until then.
14
+ *
15
+ * Waits below the platform's 600 s hook timeout, because a timed-out PreToolUse hook lets the call
16
+ * run: the refusal has to come from here. Fail-open otherwise.
17
+ */
18
+
19
+ import { stateExists } from '../../shared/state-reader.js';
20
+ import { blockResult, runStandalone } from '../../shared/hook-response.js';
21
+ import { heavyKind, leaseTtlMs } from '../../shared/heavy-command.js';
22
+
23
+ const HOOK_BUDGET_SEC = 540;
24
+
25
+ /**
26
+ * @param {object} payload - parsed PreToolUse stdin payload (Bash matcher)
27
+ * @param {{ env?: object, acquire?: Function }} [deps] - test seams; production passes none
28
+ * @returns {Promise<object|null>} a block result when no slot came free, else null (pass)
29
+ */
30
+ export async function run(payload, deps = {}) {
31
+ try {
32
+ if (payload?.tool_name && payload.tool_name !== 'Bash') return null;
33
+ const input = payload?.tool_input ?? {};
34
+ if (input.run_in_background) return null;
35
+ const kind = heavyKind(input.command);
36
+ if (!kind || !payload?.tool_use_id) return null;
37
+
38
+ // Loaded only here: this link runs on every Bash call and almost none is heavy.
39
+ const { readHeavyConfig } = await import('../../shared/heavy-config.js');
40
+ const { acquireHeavy, describeBusy, heavyDir } = await import('../../shared/heavy-slots.js');
41
+ const env = deps.env ?? process.env;
42
+ const dir = env.CLAUDE_PROJECT_DIR || payload?.cwd || process.cwd();
43
+ if (!stateExists(dir)) return null;
44
+ const config = readHeavyConfig(dir, env);
45
+ if (!config.enabled || !config.gateBash) return null;
46
+
47
+ const result = (deps.acquire ?? acquireHeavy)({
48
+ dir: heavyDir(env),
49
+ slots: config.slots,
50
+ label: kind,
51
+ feature: null,
52
+ node: null,
53
+ cwd: dir,
54
+ minFreeMemGb: config.minFreeMemGb,
55
+ waitTimeoutMs: Math.min(config.waitTimeoutSec, HOOK_BUDGET_SEC) * 1000,
56
+ maxHoldMs: config.maxHoldMinutes * 60_000,
57
+ lease: { id: payload.tool_use_id, ttlMs: leaseTtlMs(input, env) },
58
+ env,
59
+ log: () => {},
60
+ });
61
+ if (result.status !== 'busy') return null;
62
+
63
+ return blockResult(
64
+ `⏳ \`${kind}\` não rodou: ${describeBusy(result, config)}. Não é falha do código — aguarde e repita o MESMO comando. `
65
+ + '`morph-spec heavy status` mostra quem segura os slots; `heavy.gateBash: false` desliga esta espera.',
66
+ );
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ await runStandalone(run);
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * heavy-release.js — PostToolUse / PostToolUseFailure link: gives back the slot `heavy-gate` took
4
+ * for this Bash call, matched by `tool_use_id`.
5
+ *
6
+ * Silent and fail-open. A call that fires neither event (refused by a rule, interrupted) leaves its
7
+ * slot to the TTL. Releasing is not deciding, so this link is safe in the async failure binding.
8
+ */
9
+
10
+ import { existsSync } from 'node:fs';
11
+ import { stateExists } from '../../shared/state-reader.js';
12
+ import { runStandalone } from '../../shared/hook-response.js';
13
+
14
+ /**
15
+ * @param {object} payload - PostToolUse or PostToolUseFailure stdin payload
16
+ * @param {{ env?: object }} [deps] - test seam
17
+ * @returns {Promise<null>}
18
+ */
19
+ export async function run(payload, deps = {}) {
20
+ try {
21
+ if (payload?.tool_name !== 'Bash' || !payload.tool_use_id) return null;
22
+ const env = deps.env ?? process.env;
23
+ if (!stateExists(env.CLAUDE_PROJECT_DIR || payload?.cwd || process.cwd())) return null;
24
+ // Runs after every tool call: pay for the slot module only when a semaphore directory exists.
25
+ const { heavyDir } = await import('../../shared/heavy-holders.js');
26
+ const dir = heavyDir(env);
27
+ if (!existsSync(dir)) return null;
28
+ const { releaseLeaseById } = await import('../../shared/heavy-slots.js');
29
+ releaseLeaseById(dir, payload.tool_use_id);
30
+ } catch { /* fail-open */ }
31
+ return null;
32
+ }
33
+
34
+ await runStandalone(run);