@polymorphism-tech/morph-spec 9.5.0 → 9.7.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 (80) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +13 -5
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -1
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/agents.json +3 -3
  7. package/framework/docs/CLI.md +43 -11
  8. package/framework/evals/review-checklist.md +2 -2
  9. package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
  10. package/framework/hooks/claude-code/core/README.md +4 -0
  11. package/framework/hooks/claude-code/core/heavy-gate.js +5 -3
  12. package/framework/hooks/claude-code/core/test-budget.js +89 -0
  13. package/framework/hooks/claude-code/statusline.sh +11 -11
  14. package/framework/hooks/shared/e2e-check.js +0 -0
  15. package/framework/hooks/shared/feature-locator.js +7 -5
  16. package/framework/hooks/shared/heavy-command.js +72 -1
  17. package/framework/hooks/shared/heavy-config.js +28 -1
  18. package/framework/hooks/shared/heavy-slots.js +41 -5
  19. package/framework/hooks/shared/host-services.js +433 -0
  20. package/framework/schemas/config.schema.json +65 -2
  21. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  22. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  23. package/framework/skills/core/morph-apply/SKILL.md +1 -1
  24. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  25. package/framework/skills/core/morph-machine/SKILL.md +1 -1
  26. package/framework/skills/core/morph-media/SKILL.md +1 -1
  27. package/framework/skills/core/morph-plan/SKILL.md +3 -3
  28. package/framework/skills/core/morph-review/SKILL.md +3 -2
  29. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  30. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  31. package/framework/standards/STANDARDS.json +2 -1
  32. package/framework/standards/infrastructure/docker/local-compose-isolation.md +15 -1
  33. package/framework/templates/IDE-SNIPPETS.md +262 -262
  34. package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
  35. package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
  36. package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
  37. package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
  38. package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
  39. package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
  40. package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
  41. package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
  42. package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
  43. package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
  44. package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
  45. package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
  46. package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
  47. package/framework/templates/state.template.json +17 -17
  48. package/framework/templates/ui/design-system.css +226 -226
  49. package/package.json +1 -1
  50. package/src/commands/e2e/e2e.js +50 -24
  51. package/src/commands/observability/cost.js +12 -0
  52. package/src/commands/project/env.js +3 -3
  53. package/src/commands/project/heavy.js +10 -3
  54. package/src/commands/project/worktree.js +9 -4
  55. package/src/commands/state/advance.js +3 -0
  56. package/src/commands/state/create.js +3 -3
  57. package/src/commands/state/delete.js +1 -1
  58. package/src/commands/state/finish.js +20 -4
  59. package/src/commands/validation/mutate.js +2 -2
  60. package/src/commands/validation/plan-check.js +1 -0
  61. package/src/commands/validation/verify.js +3 -0
  62. package/src/dashboard/app/main.jsx +13 -13
  63. package/src/dashboard/vite.config.js +24 -24
  64. package/src/lib/e2e/compose-env.js +26 -3
  65. package/src/lib/e2e/stack-teardown.js +19 -4
  66. package/src/lib/heavy/gate.js +23 -4
  67. package/src/lib/heavy/live-stacks.js +64 -0
  68. package/src/lib/review-reports.js +21 -10
  69. package/src/lib/risk-detector.js +9 -1
  70. package/src/lib/tasks/test-runner.js +3 -0
  71. package/src/lib/telemetry/metrics.js +47 -3
  72. package/src/lib/validators/packages/package-validator.js +2 -2
  73. package/src/lib/worktree/docker-audit.js +7 -3
  74. package/src/lib/worktree/env-allocator.js +27 -5
  75. package/src/lib/worktree/ignored-triage.js +8 -0
  76. package/src/utils/agents-installer.js +4 -1
  77. package/src/utils/file-copier.js +26 -4
  78. package/src/utils/hooks-installer.js +4 -4
  79. package/framework/templates/.idea/morph-templates.xml +0 -92
  80. package/framework/templates/.vscode/morph-templates.code-snippets +0 -186
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  > Spec-driven development framework for multi-stack projects. Turns feature requests into implementation-ready code through structured, AI-orchestrated phases.
4
4
 
5
5
  **Package:** `@polymorphism-tech/morph-spec`
6
- **Version:** 9.5.0
6
+ **Version:** 9.7.0
7
7
  **Requires:** Node.js 22+, Claude Code
8
8
 
9
9
  ---
@@ -380,4 +380,4 @@ Code generated by morph-spec (contracts, templates, implementation output) belon
380
380
 
381
381
  ---
382
382
 
383
- *morph-spec v9.5.0 by [Polymorphism Tech](https://polymorphism.tech)*
383
+ *morph-spec v9.7.0 by [Polymorphism Tech](https://polymorphism.tech)*
package/bin/morph-spec.js CHANGED
@@ -414,24 +414,31 @@ contextCommand
414
414
 
415
415
  const e2eCommand = program
416
416
  .command('e2e')
417
- .description("Lifecycle of the feature's verification stack (docker compose)");
417
+ .description("Lifecycle of the feature's verification stack: docker compose, then the host processes of e2e.hostServices")
418
+ .addHelpText('after', `
419
+ e2e.hostServices (.morph/config/config.json): apps that run as host processes instead of containers.
420
+ [{ "name", "start", "cwd": ".", "build"?, "env"?, "health"?, "timeoutMs": 180000 }] name and start required
421
+ Order on up and on the verify e2e node: compose up --wait -> each service in array order
422
+ (build -> start detached -> health) -> auth seed. \${VAR} in env/health comes from the stack env
423
+ (port block + e2e.auth.envFile) or process.env; an unset one refuses before any build.
424
+ They stay up until e2e down, finish, delete or worktree remove; e2e status --json lists them.`);
418
425
 
419
426
  e2eCommand
420
427
  .command('up <feature>')
421
- .description('Bring the stack up (reusing a warm one when it still matches the working tree)')
422
- .option('--fresh', 'Recreate from scratch: down -v (named volumes included), drop the stamp, rebuild, re-seed auth')
428
+ .description('Bring the stack up, then e2e.hostServices, then the seed (reusing what still matches the working tree)')
429
+ .option('--fresh', 'Recreate from scratch: down -v (named volumes included), drop the stamp, rebuild and restart host services, re-seed auth')
423
430
  .option('--json', 'Output as JSON')
424
431
  .action((feature, options) => e2eUpCommand(feature, options));
425
432
 
426
433
  e2eCommand
427
434
  .command('status <feature>')
428
- .description('Is the stack up, healthy, and built from the current source? Exit 0 only if all three')
435
+ .description('Is the stack up, healthy, and built from the current source? Exit 0 only if all three; --json adds hostServices [{name, pid, alive, health}]')
429
436
  .option('--json', 'Output as JSON')
430
437
  .action((feature, options) => e2eStatusCommand(feature, options));
431
438
 
432
439
  e2eCommand
433
440
  .command('down <feature>')
434
- .description('Tear the stack down (best-effort — never blocks)')
441
+ .description('Tear the stack and the e2e.hostServices processes down (best-effort — never blocks)')
435
442
  .option('--volumes', 'Also remove named volumes')
436
443
  .option('--json', 'Output as JSON')
437
444
  .action((feature, options) => e2eDownCommand(feature, options));
@@ -520,6 +527,7 @@ program
520
527
  .description('Print the worktree\'s dedicated environment (port block + COMPOSE_PROJECT_NAME), shell-loadable: eval "$(morph-spec env)" outside Claude Code; feature defaults to the morph/{f} or morph-task/{n} branch of cwd')
521
528
  .option('--json', 'Output as JSON')
522
529
  .option('--powershell', 'Emit $env:K="V" lines instead of POSIX exports')
530
+ .option('--with-secrets', 'Also emit the e2e.auth.envFile credentials (omitted by default: stdout lands in transcripts); use inside eval "$(…)"')
523
531
  .action((feature, options) => envCommand(feature, options));
524
532
 
525
533
  program
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "morph-spec",
3
- "version": "9.5.0",
3
+ "version": "9.7.0",
4
4
  "displayName": "MORPH-SPEC Framework",
5
5
  "description": "Spec-driven development with 38 agents and 8-phase workflow for .NET/Blazor/Next.js/Azure",
6
6
  "publisher": "polymorphism-tech",
@@ -185,4 +185,4 @@ Never edited directly — go through CLI commands or `morph-spec update` instead
185
185
 
186
186
  ---
187
187
 
188
- *morph-spec v9.5.0 by Polymorphism Tech*
188
+ *morph-spec v9.7.0 by Polymorphism Tech*
@@ -249,4 +249,4 @@ morph-spec doctor
249
249
 
250
250
  ---
251
251
 
252
- *morph-spec v9.5.0 by Polymorphism Tech*
252
+ *morph-spec v9.7.0 by Polymorphism Tech*
@@ -367,7 +367,7 @@
367
367
  ],
368
368
  "model": "sonnet",
369
369
  "maxTurns": 90,
370
- "persona": "Você é o verificador independente do Gate 3. Não participou da implementação. Sua missão é PROVAR cada cenário `C{n}` do `2-plan/checks.json` — e o default de todo cenário é NÃO PROVADO: só vira PROVADO com a evidência na mão. Para cada Cn: onde ele vale no código (`arquivo:linha`), qual teste o prova, e se esse teste MORDE — fiação (existe caminho da entrada real ao efeito: a camada pura testada não prova que o handler a chama; inventarie quem consome o eixo novo), teste que nasceu verde e afirma o que já era verdade, que só testa a recusa e nunca o caminho de escrita, guard cujos dois lados passam pelo mesmo serializador, asserção sobre texto que mede vocabulário e não sentido. Verificações mecânicas (.NET minimal API / Next): todo serviço injetado numa lambda de endpoint tem registro em `Program.cs`; prefixo de rota server-direct e de proxy são constantes distintas, cada uma do seu lado. Com o stack no ar (URL no dispatch), navegue o fluxo do cenário com Playwright e confira console e network. Stack fora do ar é achado de infra, não defeito seu a provar — não suba nem derrube o stack. Cenário que você não conseguiu verificar fica NÃO PROVADO com o motivo, nunca PROVADO por inferência. Siga a seção \"Verificador\" de `.morph/framework/evals/review-checklist.md` e grave `4-review/verifier.md` com `## Checks` (`| Cn | PROVADO|NÃO PROVADO|FALHA | evidência |`, uma linha por cenário) — mais `## Checklist de tela`, `## Mídia gerada` e `## Mutações` quando o checklist pede. Cenário NÃO PROVADO ou FALHA vira bloqueador `check` (ou `teste`). A prova é a do `prova:` do Cn: `leitura` se prova por `arquivo:linha` e fiação, sem teste — nunca peça teste para ele; só Cn com `risco:` exige teste que morde. A suíte inteira roda fora de você (o verify do orquestrador, no CI ou local): nada de `morph-spec verify`, suíte completa ou build. Pode rodar UM filtro dos testes de um cenário, em primeiro plano, por `morph-spec heavy run -- <comando>`, e no máximo UMA rodada de `morph-spec mutate {feature}` (até 3 mutantes, cada um com `expect`) — e só depois que o dispatch disser que revisor e segurança terminaram: eles leem o mesmo repositório. Sem subconjunto (exit 2) ou mutante `inconclusive`: prove por leitura, nunca a suíte inteira. A reversão do mutante é da ferramenta; rodada morta no meio: `morph-spec mutate --restore`. Arquivo você lê com Read, nunca `sed`/`cat`/`head`. NUNCA modifique código, plan.md, checks.json ou feature.json, nunca `git checkout --`/`git stash`, nada em segundo plano nem laço de espera (`sleep`, `until`). O MCP `graphify` dá o grafo cru; a conclusão sai do `morph-spec graph`, e sem grafo (`no-graph`) caia para grep."
370
+ "persona": "Você é o verificador independente do Gate 3. Não participou da implementação. Sua missão é PROVAR cada cenário `C{n}` do `2-plan/checks.json` — e o default de todo cenário é NÃO PROVADO: só vira PROVADO com a evidência na mão. Para cada Cn: onde ele vale no código (`arquivo:linha`), qual teste o prova, e se esse teste MORDE — fiação (existe caminho da entrada real ao efeito: a camada pura testada não prova que o handler a chama; inventarie quem consome o eixo novo), teste que nasceu verde e afirma o que já era verdade, que só testa a recusa e nunca o caminho de escrita, guard cujos dois lados passam pelo mesmo serializador, asserção sobre texto que mede vocabulário e não sentido. Verificações mecânicas (.NET minimal API / Next): todo serviço injetado numa lambda de endpoint tem registro em `Program.cs`; prefixo de rota server-direct e de proxy são constantes distintas, cada uma do seu lado. Com o stack no ar (URL no dispatch), navegue o fluxo do cenário com Playwright e confira console e network. Stack fora do ar é achado de infra, não defeito seu a provar — não suba nem derrube o stack. Cenário que você não conseguiu verificar fica NÃO PROVADO com o motivo, nunca PROVADO por inferência. Siga a seção \"Verificador\" de `.morph/framework/evals/review-checklist.md` e grave `4-review/verifier.md` com `## Checks` (`| Cn | PROVADO|NÃO PROVADO|FALHA | evidência |`, uma linha por cenário) — mais `## Checklist de tela`, `## Mídia gerada` e `## Mutações` quando o checklist pede. Cenário NÃO PROVADO ou FALHA vira bloqueador `check` (ou `teste`). A prova é a do `prova:` do Cn: `leitura` se prova por `arquivo:linha` e fiação, sem teste — nunca peça teste para ele; só Cn com `risco:` exige teste que morde. A suíte inteira roda fora de você (o verify do orquestrador, no CI ou local): nada de `morph-spec verify`, suíte completa ou build. Você não roda teste nem `verify` (o hook `test-budget` nega): a única execução que prova que um teste morde é no máximo UMA rodada de `morph-spec mutate {feature}` (até 3 mutantes, cada um com `expect`), o resto se prova por leitura — e só depois que o dispatch disser que revisor e segurança terminaram: eles leem o mesmo repositório. Sem subconjunto (exit 2) ou mutante `inconclusive`: prove por leitura, nunca a suíte inteira. A reversão do mutante é da ferramenta; rodada morta no meio: `morph-spec mutate --restore`. Arquivo você lê com Read, nunca `sed`/`cat`/`head`. NUNCA modifique código, plan.md, checks.json ou feature.json, nunca `git checkout --`/`git stash`, nada em segundo plano nem laço de espera (`sleep`, `until`). O MCP `graphify` dá o grafo cru; a conclusão sai do `morph-spec graph`, e sem grafo (`no-graph`) caia para grep."
371
371
  },
372
372
  "reviewer": {
373
373
  "title": "Gate 3 Reviewer",
@@ -398,7 +398,7 @@
398
398
  ],
399
399
  "model": "sonnet",
400
400
  "maxTurns": 60,
401
- "persona": "Você é o revisor independente do Gate 3, read-only. Leia como o arquiteto que herda este repo amanhã: simples, legível, fácil de estender. Não participou da implementação e não prova cenários (é o verificador). Você lê SÓ o commit revisado: `git diff {base}..{sha}`, `git show {sha}:<caminho>`, `git log {base}..{sha}` — nunca a árvore de trabalho (Read/Glob/Grep sobre `src/` leem o disco, onde o verificador pode estar com um mutante aplicado). `base` e `sha` vêm no dispatch. Julga o diff em cinco dimensões, na seção \"Revisor\" de `.morph/framework/evals/review-checklist.md`: ESCOPO (o diff contra as fatias e o `## Contrato de implementação` do plano — arquivo fora de toda fatia, comportamento que nenhum cenário pediu, teste além do orçamento — teste para Cn `prova: leitura`, mais de um por Cn de risco sem porquê no plano, teste que só fixa texto/constante/DTO; `expectedCount` menor no `config.json` sem teste apagado no mesmo diff é `regressao`); ARQUITETURA (padrão ou abstração nova contra o stack do projeto e os standards — camada que o projeto não usa, repositório sobre o DbContext, dependência nova sem decisão no plano — e o que o DIFF introduziu da lista fechada `architecture-engineering-lens#review-blockers`; o que já existia no arquivo é nota); REGRESSÃO (para cada símbolo público alterado, `morph-spec graph affected <símbolo> --prod --json`; sem grafo — `no-graph`, `not-found`, `ambiguous` — caia para `git grep -n <símbolo> {sha}` e siga, a ausência de grafo nunca bloqueia; ausência de consumidor nunca sai do grafo); O QUE A CORREÇÃO ABRIU (revisando uma correção de rodada anterior, releia o comportamento vizinho); PERFORMANCE só com evidência (lista sem paginação no servidor sobre volume real, consulta N+1 visível no diff) e RASTREABILIDADE de chamada a LLM (dá para reconstruir o que foi ao provedor e o que voltou?). Tipos de bloqueador: `escopo`, `arquitetura`, `regressao`, `performance`. Seu default é SHIP: dúvida vira nota. Arquivo do framework ou da feature (o checklist, o plan.md) você lê com Read, nunca `sed`/`cat`/`head`; código do commit, com `git show {sha}:<caminho>`. NUNCA modifique código, plan.md, checks.json ou feature.json, nunca `git checkout --`/`git stash`, nada em segundo plano nem laço de espera (`sleep`, `until`)."
401
+ "persona": "Você é o revisor independente do Gate 3, read-only. Leia como o arquiteto que herda este repo amanhã: simples, legível, fácil de estender. Não participou da implementação e não prova cenários (é o verificador). Não roda teste nem `verify` (o hook `test-budget` nega). Você lê SÓ o commit revisado: `git diff {base}..{sha}`, `git show {sha}:<caminho>`, `git log {base}..{sha}` — nunca a árvore de trabalho (Read/Glob/Grep sobre `src/` leem o disco, onde o verificador pode estar com um mutante aplicado). `base` e `sha` vêm no dispatch. Julga o diff em cinco dimensões, na seção \"Revisor\" de `.morph/framework/evals/review-checklist.md`: ESCOPO (o diff contra as fatias e o `## Contrato de implementação` do plano — arquivo fora de toda fatia, comportamento que nenhum cenário pediu, teste além do orçamento — teste para Cn `prova: leitura`, mais de um por Cn de risco sem porquê no plano, teste que só fixa texto/constante/DTO; `expectedCount` menor no `config.json` sem teste apagado no mesmo diff é `regressao`); ARQUITETURA (padrão ou abstração nova contra o stack do projeto e os standards — camada que o projeto não usa, repositório sobre o DbContext, dependência nova sem decisão no plano — e o que o DIFF introduziu da lista fechada `architecture-engineering-lens#review-blockers`; o que já existia no arquivo é nota); REGRESSÃO (para cada símbolo público alterado, `morph-spec graph affected <símbolo> --prod --json`; sem grafo — `no-graph`, `not-found`, `ambiguous` — caia para `git grep -n <símbolo> {sha}` e siga, a ausência de grafo nunca bloqueia; ausência de consumidor nunca sai do grafo); O QUE A CORREÇÃO ABRIU (revisando uma correção de rodada anterior, releia o comportamento vizinho); PERFORMANCE só com evidência (lista sem paginação no servidor sobre volume real, consulta N+1 visível no diff) e RASTREABILIDADE de chamada a LLM (dá para reconstruir o que foi ao provedor e o que voltou?). Tipos de bloqueador: `escopo`, `arquitetura`, `regressao`, `performance`. Seu default é SHIP: dúvida vira nota. Arquivo do framework ou da feature (o checklist, o plan.md) você lê com Read, nunca `sed`/`cat`/`head`; código do commit, com `git show {sha}:<caminho>`. NUNCA modifique código, plan.md, checks.json ou feature.json, nunca `git checkout --`/`git stash`, nada em segundo plano nem laço de espera (`sleep`, `until`)."
402
402
  },
403
403
  "security": {
404
404
  "title": "Gate 3 Security Reviewer",
@@ -425,7 +425,7 @@
425
425
  "mcp": [],
426
426
  "model": "opus",
427
427
  "maxTurns": 60,
428
- "persona": "Você é a revisão de segurança do Gate 3, read-only, despachada só quando o diff toca auth, segredo, permissão, dado pessoal, pagamento ou migration. Você lê SÓ o commit revisado: `git diff {base}..{sha}`, `git show {sha}:<caminho>`, `git log {base}..{sha}` — nunca a árvore de trabalho (Read/Glob/Grep sobre `src/` leem o disco, onde o verificador pode estar com um mutante aplicado). `base` e `sha` vêm no dispatch. Procure, na seção \"Segurança\" de `.morph/framework/evals/review-checklist.md`: authz faltando ou checada do lado errado, segredo no código, no log ou no cliente, injeção (SQL, comando, prompt), dado pessoal vazando para log, resposta ou provedor, custo sem teto (chamada paga em laço, sem limite por usuário), mídia gerada publicada sem revisão humana, migration destrutiva (DROP/rename de coluna com dado, branch Neon de produção). Bloqueador é tipo `seguranca` e exige o CAMINHO DE EXPLORAÇÃO: quem, por qual entrada, chega a quê. Sem caminho, é nota. Arquivo do framework ou da feature (o checklist, o plan.md) você lê com Read, nunca `sed`/`cat`/`head`; código do commit, com `git show {sha}:<caminho>`. NUNCA modifique código, plan.md, checks.json ou feature.json, nunca `git checkout --`/`git stash`, nada em segundo plano nem laço de espera (`sleep`, `until`)."
428
+ "persona": "Você é a revisão de segurança do Gate 3, read-only, despachada só quando o diff toca auth, segredo, permissão, dado pessoal, pagamento ou migration. Você lê SÓ o commit revisado: `git diff {base}..{sha}`, `git show {sha}:<caminho>`, `git log {base}..{sha}` — nunca a árvore de trabalho (Read/Glob/Grep sobre `src/` leem o disco, onde o verificador pode estar com um mutante aplicado). `base` e `sha` vêm no dispatch. Não roda teste nem `verify` (o hook `test-budget` nega). Procure, na seção \"Segurança\" de `.morph/framework/evals/review-checklist.md`: authz faltando ou checada do lado errado, segredo no código, no log ou no cliente, injeção (SQL, comando, prompt), dado pessoal vazando para log, resposta ou provedor, custo sem teto (chamada paga em laço, sem limite por usuário), mídia gerada publicada sem revisão humana, migration destrutiva (DROP/rename de coluna com dado, branch Neon de produção). Bloqueador é tipo `seguranca` e exige o CAMINHO DE EXPLORAÇÃO: quem, por qual entrada, chega a quê. Sem caminho, é nota. Arquivo do framework ou da feature (o checklist, o plan.md) você lê com Read, nunca `sed`/`cat`/`head`; código do commit, com `git show {sha}:<caminho>`. NUNCA modifique código, plan.md, checks.json ou feature.json, nunca `git checkout --`/`git stash`, nada em segundo plano nem laço de espera (`sleep`, `until`)."
429
429
  },
430
430
  "scout": {
431
431
  "title": "Codebase Scout",
@@ -10,7 +10,7 @@
10
10
 
11
11
  **`morph-spec approve <feature> <gate> --mode auto`** — aprova um gate (`plan`/`uiux`/`review`) em `trust: auto`.
12
12
 
13
- **O Gate 3 exige os relatórios de review**: `4-review/verifier.md` e `reviewer.md`, mais `security.md` quando o diff toca caminho sensível (`securityReviewFor`) — ausente, PARCIAL ou sem `**Verdict:**`, o comando recusa (exit 1) nomeando o arquivo. A assinatura humana (`--approver`) aprova mesmo com `FIX` ou cenário não provado; a `auto` recalcula a pausa do `gate-check`, cobertura dos `Cn` incluída. `**Reviewed-SHA:**` é lido de todo relatório presente e o pior vence: o código andou depois dele → recusa; sem carimbo → aviso. Se o dispatch for impossível nesta sessão, aprove com `--self-assessed` — a degradação é gravada em `approvalGates.review.selfAssessed` e aparece marcada no `morph-spec list`. `--force` **não** dispensa esta regra (é só para ordem de gate).
13
+ **O Gate 3 exige os relatórios de review**: `4-review/verifier.md` e `reviewer.md`, mais `security.md` quando o diff toca caminho sensível fora de teste (`securityReviewFor`; spec, `e2e/` e `test-results/` não contam) — ausente, PARCIAL ou sem `**Verdict:**`, o comando recusa (exit 1) nomeando o arquivo. A assinatura humana (`--approver`) aprova mesmo com `FIX` ou cenário não provado; a `auto` recalcula a pausa do `gate-check`, cobertura dos `Cn` incluída. `**Reviewed-SHA:**` é lido de todo relatório presente e o pior vence: o código andou depois dele → recusa; sem carimbo → aviso. Se o dispatch for impossível nesta sessão, aprove com `--self-assessed` — a degradação é gravada em `approvalGates.review.selfAssessed` e aparece marcada no `morph-spec list`. `--force` **não** dispensa esta regra (é só para ordem de gate).
14
14
 
15
15
  **O plano semeia `bin/`/`obj/`** (`plan-check` que grava o `checks.json`, `approve <f> plan`, `advance` saindo do plano e `advance --design` com o plano já aprovado — o `plan-check` cobre também o gate gravado pelo hook state-sync): num projeto .NET — `project.stack` com `dotnet`, fatia de `dotnet-senior`/`ef-modeler`/`maf-expert` no `checks.json` ou `.csproj`/`.sln`/`.slnx` na árvore (fora de `worktrees/`, `node_modules/`, `bin/`, `obj/`) — as duas linhas entram no `.gitignore` da árvore dona antes do primeiro código, e a saída as nomeia para irem no mesmo commit. O `create` aplica a mesma regra (`src/lib/stack/dotnet-build-ignores.js`); no greenfield só a fatia do plano existe para decidir.
16
16
 
@@ -108,7 +108,7 @@ Coluna **PR** mostra `#{n} {estado}` (`-` sem PR para a branch da feature). Uma
108
108
 
109
109
  ### delete
110
110
 
111
- **`morph-spec delete <feature> [--archive] [--force] [--with-worktree]`** — remove uma feature. Hard por padrão (pasta apagada, git-aware, + entrada podada do índice raiz para não ressuscitar); `--archive` move para `.morph/archive` com status `abandoned` em vez de apagar. Recusa se algum gate estiver aprovado, a menos que `--force`. Recusa TAMBÉM se um worktree vivo estiver em outra árvore (`feature-has-worktree` — apagar o registro da raiz órfão o worktree+branch em silêncio) — `--force` **não** dispensa isto (é consentimento sobre gate, não sobre filesystem); feche primeiro com `finish`/`worktree remove`, ou passe `--with-worktree` para desmontar o worktree (PROVE-or-DECLINE) e apagar num passo só — a branch sobrevive por padrão (`--delete-branch` para descartá-la também). Use para faxina de scaffolds mortos e drafts abandonados (`archive` é para trabalho concluído; `delete` é para trabalho morto).
111
+ **`morph-spec delete <feature> [--archive] [--force] [--with-worktree]`** — remove uma feature. Hard por padrão (pasta apagada, git-aware, + entrada podada do índice raiz para não ressuscitar); `--archive` move para `.morph/archive` com status `abandoned` em vez de apagar. Recusa se algum gate estiver aprovado, a menos que `--force`. Recusa TAMBÉM se um worktree vivo estiver em outra árvore (`feature-has-worktree` — apagar o registro da raiz órfão o worktree+branch em silêncio) — `--force` **não** dispensa isto (é consentimento sobre gate, não sobre filesystem); feche primeiro com `finish`/`worktree remove`, ou passe `--with-worktree` para desmontar o worktree (PROVE-or-DECLINE) e apagar num passo só — a branch sobrevive por padrão (`--delete-branch` para descartá-la também). Use para faxina de scaffolds mortos e drafts abandonados (`archive` é para trabalho concluído; `delete` é para trabalho morto). A stack e2e da feature cai como no `finish`: `down --remove-orphans --rmi local -v`, mais os `e2e.hostServices`.
112
112
 
113
113
  ### retype
114
114
 
@@ -124,7 +124,7 @@ Feature sem `workType` (pré-v8) é tratada como `feature`.
124
124
 
125
125
  **Índice de ADRs:** no mesmo commit do archive, o `finish` lê o `2-plan/plan.md` da feature: os ADRs de `## Decisões` com `**Alcance:** projeto` entram em `.morph/specs/adrs/` (um arquivo por ADR, `{feature}--ADR-{nnn}.md` — features paralelas nunca tocam o mesmo arquivo), e a tabela `ADR | Mudança | Escopo` de `### Conflitos com o acervo` retira (total, o arquivo sai) ou estreita (parcial, `scope:` no arquivo) os ADRs revogados — forma em `morph-spec plan-check --help`; o `plan-check` avisa, enquanto o plano ainda muda, a linha que o `finish` não aplicaria. O payload traz `adrIndex: {added, revoked, narrowed}` e `revocationProblems: [{id, reason, message}]` (linha malformada ou ambígua, ADR fora do índice) — cada chave só aparece com conteúdo, e nenhum problema recusa o `finish`.
126
126
 
127
- `--merge` faz merge `--no-ff` **local** na default (sem push); `--pr` faz `push` + `gh pr create` (exige `origin` + `gh`, checados **antes** de arquivar — um `--pr` recusado não deixa meio-estado). Desmonta o worktree (PROVE-or-DECLINE) e libera o bloco de portas; no `--pr` **preserva** a branch (o PR precisa dela) e fica em `morph/{f}`.
127
+ `--merge` faz merge `--no-ff` **local** na default (sem push); `--pr` faz `push` + `gh pr create` (exige `origin` + `gh`, checados **antes** de arquivar — um `--pr` recusado não deixa meio-estado). Saída de teste não rastreada (`test-results/`, `TestResults/`, `playwright-report/`, `blob-report/`) não conta como árvore suja: na raiz só é tolerada, na worktree sai junto com o desmonte; qualquer outro arquivo não commitado recusa. Desmonta o worktree (PROVE-or-DECLINE) e libera o bloco de portas; derruba a stack e2e com `down --remove-orphans --rmi local -v` (`--keep-volumes` tira o `-v`) e os `e2e.hostServices` — `--rmi local` leva só as imagens que o compose construiu, nunca a puxada (`postgres`), que a próxima feature baixaria de novo; `docker builder prune` nunca roda (o cache é global da máquina). No `--pr` **preserva** a branch (o PR precisa dela) e fica em `morph/{f}`.
128
128
 
129
129
  **Sessão ancorada no worktree → o desmonte é ADIADO** (`--merge` e `--pr`): quando a sessão do Claude Code que rodou o `finish` tem o próprio worktree como projeto (`CLAUDE_PROJECT_DIR` — exportado ao CLI como `MORPH_SESSION_PROJECT_DIR` pelo hook `worktree-env-inject`, porque o Bash tool não o exporta e `cd <raiz> && morph-spec finish` apaga o cwd), a integração acontece normalmente, mas o worktree fica: saída com `worktreeRemoved: false`, `teardownDeferred: "session-anchored"`, `worktree: {path, branch}` e um `hint` com `morph-spec worktree remove <f>` para rodar depois, de uma sessão aberta na raiz. O `--dry-run` já avisa (`teardownDeferred` no pré-voo).
130
130
 
@@ -158,13 +158,13 @@ O `.morph/state.json` do worktree é índice derivável: ele se refaz sozinho a
158
158
 
159
159
  ### env
160
160
 
161
- **`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.
161
+ **`morph-spec env [feature] [--json|--powershell] [--with-secrets]`** — 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` **só saem com `--with-secrets`** (o stdout vai parar no transcript; use a flag dentro de `eval "$(…)"`), e sem ela os nomes omitidos vão para stderr. Uma chave `*_PORT` desse arquivo que não bate com nenhuma porta do bloco é avisada em stderr com a sugestão de declará-la em `e2e.portEnvNames`. E **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.
162
162
 
163
163
  ### worktree remove
164
164
 
165
165
  **`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.
166
166
 
167
- 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).
167
+ Derruba o stack compose da árvore e os `e2e.hostServices`: de branch `morph/{f}` **sem** apagar volumes (a feature segue aberta, `down --remove-orphans`); de branch `morph-task/*` **com** volumes (`down --remove-orphans -v`: a task é descartável, e o `worktree reap` herda por passar por aqui). 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).
168
168
 
169
169
  **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.
170
170
 
@@ -280,7 +280,7 @@ Fail-open: uma poda que falha nunca derruba o comando. PROVE-or-DECLINE como o r
280
280
 
281
281
  **`morph-spec verify <feature> [--json] [--timeout <ms>] [--filter <ids>] [--record-baseline] [--refresh-cache] [--linux] [--linux-image <img>] [--skip-tests] [--skip-build] [--skip-e2e] [--fresh-e2e] [--no-persist] [--ci]`** — cadeia determinística build+testes+validadores+**e2e** (código antes do juiz LLM), sempre sobre a feature inteira.
282
282
 
283
- 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).
283
+ O nó `e2e` é o único que **executa** a aplicação: sobe o `docker compose` do projeto, espera todo serviço ficar saudável, sobe os `e2e.hostServices` (ver `e2e` abaixo) 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).
284
284
 
285
285
  **Onde o nó `build` compila** (chaves de `project` em `.morph/config/config.json`, forma em `config.schema.json`): `project.stack` (tags unidas por `+`, ex. `dotnet+nextjs`) escolhe os passos; `project.backendPath` é a pasta da solução .NET e `project.frontendPath` a do frontend (`tsc --noEmit` e, com `eslint.config.*` e o pacote `eslint` instalados, `eslint --quiet` só nos arquivos que a feature mudou contra a base), ambas relativas à raiz. Sem `backendPath` e sem `.sln`/`.slnx`/`.csproj` na raiz, o build compila a **única** `.sln`/`.slnx` do repositório (senão o único `.csproj`), fora de `worktrees/`, `node_modules/`, `bin/` e `obj/` (`discoverDotnetSolution`, `test-runner.js`); nenhuma ou várias → o build falha e o `feedback[]` lista as candidatas e manda declarar `project.backendPath`.
286
286
 
@@ -292,7 +292,7 @@ As specs são procuradas na raiz, em `project.frontendPath` e, sem ele, nas past
292
292
 
293
293
  **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. 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.
294
294
 
295
- **`--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.
295
+ **`--fresh-e2e` é destrutivo por definição:** derruba a stack anterior **com os volumes nomeados** (`down -v`), apaga o carimbo de frescor, sobe com `--build`, reconstrói e reinicia os `e2e.hostServices` e força o seed de auth a rodar de novo.
296
296
 
297
297
  **`--filter <ids>` escolhe os nós** (lista separada por vírgula, casando `id` exato ou substring). O Gate 3 é `verify {f} --json` **sem filtro**, porque a prova que o verificador lê tem de cobrir todos os nós (o CI idem) — e um `verify {f} --filter …` não persiste o stamp (`persisted: false`, `reason: 'filtered'`), nem um 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. 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.
298
298
 
@@ -364,11 +364,36 @@ Sem base git, ou sem linha de código adicionada, pula com razão; qualquer falh
364
364
 
365
365
  **`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). 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.
366
366
 
367
- `--fresh` derruba com volumes (`down -v`), apaga o carimbo e re-roda o seed.
367
+ `--fresh` derruba com volumes (`down -v`), apaga o carimbo, reconstrói e reinicia os `e2e.hostServices` e re-roda o seed.
368
368
 
369
369
  `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 verificador, em vez de deixá-lo adivinhar `localhost:3000`.
370
370
 
371
- `down [--volumes]` é best-effort e nunca bloqueia.
371
+ `down [--volumes]` é best-effort e nunca bloqueia; derruba também os `e2e.hostServices`.
372
+
373
+ **Serviços no host (`e2e.hostServices`).** App cujo build é caro dentro da VM do Docker roda como processo do host, nas portas do bloco, e o compose fica só com as dependências. Declaração em `.morph/config/config.json` (forma em `config.schema.json`):
374
+
375
+ ```json
376
+ "e2e": { "hostServices": [
377
+ { "name": "api", "cwd": "src/Api", "build": "dotnet build -c Debug",
378
+ "start": "dotnet bin/Debug/net10.0/Api.dll",
379
+ "env": { "ASPNETCORE_URLS": "http://localhost:${API_PORT}" },
380
+ "health": "http://localhost:${API_PORT}/health" }
381
+ ] }
382
+ ```
383
+
384
+ | Campo | Obrigatório | Significado |
385
+ | --- | --- | --- |
386
+ | `name` | sim | `[A-Za-z0-9._-]`, único no array; nomeia o log e as mensagens |
387
+ | `start` | sim | Comando do processo, iniciado destacado; saída em `<git-common-dir>/morph-spec/e2e-host-<projeto>-<name>.log` |
388
+ | `cwd` | não (`.`) | Relativo à raiz do projeto; inexistente recusa |
389
+ | `build` | não | Roda antes do `start`; exit ≠ 0 é falha |
390
+ | `env` | não | Somado ao env da stack (bloco de portas + `e2e.auth.envFile`) e ao `process.env` |
391
+ | `health` | não | URL; verde = resposta HTTP < 500. Sem ela, processo vivo 2 s depois do `start` conta como pronto |
392
+ | `timeoutMs` | não (180 000) | Limita build + start + health **juntos** |
393
+
394
+ `${VAR}` em `env` e `health` vem do env da stack, depois do `process.env`; variável sem valor (ou vazia) recusa **antes** do build, nomeando a variável e o serviço.
395
+
396
+ **Ordem** (`bringUpE2e`, a mesma no `e2e up` e no nó `e2e` do `verify`): `compose up --wait` → cada serviço na ordem do array (build → start → health) → seed (`e2e.auth`) → specs (só no verify). **Reuso:** PIDs, hora de criação e carimbo do código (o mesmo `HEAD` + status + diff da stack) ficam em `<git-common-dir>/morph-spec/e2e-host-<projeto>.json`; carimbo igual e health verde → `reused`; carimbo divergente, conjunto de serviços mudado ou `--fresh`/`--fresh-e2e` → todos são mortos, reconstruídos e reiniciados; health morto com carimbo igual → só aquele reinicia. **Falha** (build ≠ 0, timeout, processo que morre, health que nunca fica verde) → `e2e up` sai 1 e o nó `e2e` falha, com o nome do serviço e a cauda do log; os serviços subidos nesta rodada são mortos e o seed não roda. Os serviços **ficam no ar** depois do `up` e do `verify`, até `e2e down`, `finish`, `delete` ou `worktree remove`. Um processo só é morto se a hora de criação do PID vivo bate com a gravada: PID reciclado não é morto, só avisado. `e2e status --json` traz `hostServices: [{ name, pid, alive, health }]`.
372
397
 
373
398
  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.
374
399
 
@@ -382,13 +407,17 @@ Isolamento entre worktrees: `COMPOSE_PROJECT_NAME=morph-{feature}` separa os con
382
407
 
383
408
  `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.
384
409
 
385
- **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).
410
+ **Espera:** slot cheio, ou RAM livre (`os.freemem()`) abaixo do piso do tipo do comando, espera até `heavy.waitTimeoutSec` (600) avisando em **stderr** a cada 30 s e nomeando quem segura (o `--json` mantém o stdout limpo).
411
+
412
+ **Piso de RAM por tipo** (`heavy.minFreeMemGbByKind`, padrão `unit` 1,5 · `build` 4 · `e2e` 4 GB): `unit` = vitest/jest, `npm|pnpm|yarn test`, `npm run test[:x]` e o nó `tests` do verify; `e2e` = playwright, `docker compose up`, script com `e2e` no nome, o nó `e2e` e o `e2e up`; `build` = o resto (`dotnet build|test|publish` — o `dotnet test` compila —, `next build`, `docker build`, `mutate` e comando desconhecido). O `heavy run` e o hook do `gateBash` classificam pelo comando; o `verify` toma o slot com o piso do primeiro nó e, antes de um nó mais pesado (tests → e2e), espera de novo só pela RAM segurando o slot — estourar ali também é exit 3, sem stamp. `heavy.minFreeMemGb` (4) virou o fallback: **definido explicitamente**, vale para todo tipo que `minFreeMemGbByKind` não lista (`0` continua desligando a checagem em tudo); sem ele, cada tipo usa o seu padrão.
413
+
414
+ **Ocupado por RAM** (`verify`, `e2e up`, `heavy run`): a mensagem nomeia o piso e a chave (`heavy.minFreeMemGbByKind.<tipo>`) e lista as stacks e2e de pé deste repo (`docker compose ls`, prefixo `morph-`, compose sob a raiz principal — de todas as worktrees). Feature já arquivada (`.morph/archive/<f>` sem `.morph/features/<f>`) vem com `morph-spec e2e down <f>`; stack sem feature, com `docker compose -p <nome> down`. Docker fora ou lento (10 s) = lista vazia, nunca erro.
386
415
 
387
416
  **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.
388
417
 
389
418
  **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.
390
419
 
391
- **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.
420
+ **Config** (`.morph/config/config.json`, bloco `heavy`, schema em `config.schema.json`): `enabled`, `slots`, `minFreeMemGb`, `minFreeMemGbByKind`, `waitTimeoutSec`, `maxHoldMinutes`, `shutdownBuildServer`, `disableNodeReuse`, `gateBash`; valor ilegível cai no default **com aviso** — o bloco nunca bloqueia trabalho por estar errado.
392
421
 
393
422
  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`.
394
423
 
@@ -406,6 +435,8 @@ Env: `MORPH_HEAVY=off` desliga o semáforo (a suíte do próprio framework roda
406
435
 
407
436
  `run_in_background`, PowerShell e comando fora da lista não são cobertos, e a cláusula PROCESSOS do contrato de dispatch (`heavy run`) segue valendo para eles.
408
437
 
438
+ **Teto de teste do sub-agent (`test-budget`, sempre ligado, antes do `heavy-gate`):** só Bash com `agent_id` (o orquestrador nunca é contado) e com `.morph/` presente. Rodada = comando de teste (`testRunKind`: `dotnet test|vstest`, `vitest`, `jest`, `playwright test`, `npm|pnpm|yarn|bun test`, inclusive dentro de `heavy run --`) ou `morph-spec verify`; listagem (`--list`) não conta. Nega `verify` sem `--filter` (é do orquestrador), a 4ª rodada do mesmo sub-agent (`verify --filter` conta; a que sai com exit 3, fila cheia, não) com a instrução de gravar `PARCIAL` e devolver (a retomada pelo orquestrador, por `SendMessage` com o id do agente, devolve as 3 rodadas), e qualquer teste ou `verify` de `morph-verifier`/`morph-reviewer`/`morph-security` — `morph-spec mutate` passa. O contador mora em `.morph/memory/test-runs/<agent_id>.json`, gravado no `PostToolUse` e no `PostToolUseFailure`; qualquer erro é fail-open.
439
+
409
440
  **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.
410
441
 
411
442
  **Por quê.** 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". Com `heavy.enabled: false` nunca roda: sem semáforo, nada prova que outra sessão não está compilando. O núcleo (`heavy-config`, `heavy-holders`, `heavy-slots`) mora em `framework/hooks/shared`, porque hook não importa `src/`.
@@ -819,6 +850,7 @@ O servidor MCP (`graphify-mcp`, 10 tools, todas aceitando `project_path`) está
819
850
  | rerun | o mesmo comando **produtor** (antes do primeiro `\|` fora de aspas, sem `2>&1`) na mesma feature até 10 min depois do anterior; `rate` conta as repetições, `burstRate` toda execução que cai numa rajada, a primeira incluída. `rerunClasses`: `fix-loop` (falhou, houve edição), `flake` (falhou, sem edição, passou), `retry` (falhou de novo sem edição), `re-verify` (passou, houve edição), `redundant` (passou, sem edição), `timeout`, `refilter` (mesmo produtor, outro filtro de saída, sem edição: rodou de novo para ver outras linhas), `unknown-after-edit`/`unknown-no-edit` (exit mascarado por pipe ou evento v1) |
820
851
  | releitura | bytes da mesma janela (arquivo + `offset`/`limit`) lida de novo na mesma feature, com os arquivos de maior releitura; `windowed` conta as leituras com `bytesRead`. As outras (v1) contam o arquivo inteiro, e a taxa vira um teto |
821
852
  | verify de feature | `morph-spec verify <f>` executado (fora de aspas e de heredoc) por feature, com a mediana; `verify.unchangedRepeats` conta, por feature, o evento `Verify` cujo `fingerprint` é igual ao do anterior |
853
+ | teste fora do verify | `testOutsideVerify`: sub-agente rodando `morph-spec verify` sem `--filter`, rodadas de teste de implementador da 3ª em diante (por agente) e teste/verify de agente de review, em execuções e minutos sobre o total cronometrado; exit 3 (fila cheia) não conta. Review por persona `morph-*` aparece à parte: é o que o hook `test-budget` nega |
822
854
  | suíte .NET que recompila | `dotnetFullSuite`: `dotnet test` sem `--filter` nem `--list-tests`, quantos sem `--no-build`, por fase |
823
855
  | git | ocorrências de `git status` e `git diff` na janela |
824
856
  | reuse-validator | por árvore (raiz × worktree), dos eventos `Verify`: rodou, pulou (e por quê) ou ausente |
@@ -71,8 +71,8 @@ sem linha ou não PROVADO. Numa confirmação, uma `## Checks` nova só com os C
71
71
  5. **Runtime**: com o stack no ar (URL no dispatch), navegue o fluxo do cenário e confira console e
72
72
  network. Stack fora do ar é achado de infra, não defeito seu a provar.
73
73
 
74
- A suíte inteira é do orquestrador. Você roda no máximo um filtro de testes de um cenário, em primeiro
75
- plano, por `morph-spec heavy run -- <comando>`.
74
+ A suíte inteira é do orquestrador. Você não roda teste nem `verify` (o hook `test-budget` nega): a
75
+ execução que prova mordida é a mutação abaixo; o resto, por leitura.
76
76
 
77
77
  **Prova por mutação — opcional, no máximo 3, só depois que revisor e segurança terminaram.** Só
78
78
  quando o diff tem uma linha de guarda ou regra que um cenário exige e você não achou o teste que a
@@ -1,3 +1,7 @@
1
1
  # hooks/claude-code/core
2
2
  Part of morph-spec NLH.
3
3
  See `framework/MORPH.md` for full documentation.
4
+
5
+ ## test-budget
6
+
7
+ `PreToolUse` Bash (antes do `heavy-gate`), `PostToolUse` e `PostToolUseFailure`. Só age em sub-agent (`agent_id`) com `.morph/` presente. Nega: `morph-spec verify` sem `--filter`; a 4ª rodada de teste do mesmo sub-agent (rodada = `testRunKind` ou `verify --filter`; exit 3 não conta), mandando gravar `PARCIAL` e devolver; todo teste ou `verify` de `morph-verifier`/`morph-reviewer`/`morph-security` (`morph-spec mutate` passa). Contador em `.morph/memory/test-runs/<agent_id>.json`; o `SendMessage` do orquestrador para o agente (a retomada depois do `PARCIAL`) zera o dele. Fail-open.
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { stateExists } from '../../shared/state-reader.js';
7
7
  import { blockResult, runStandalone } from '../../shared/hook-response.js';
8
- import { heavyKind, leaseTtlMs } from '../../shared/heavy-command.js';
8
+ import { heavyClass, heavyKind, leaseTtlMs } from '../../shared/heavy-command.js';
9
9
 
10
10
  const HOOK_BUDGET_SEC = 540;
11
11
 
@@ -18,7 +18,7 @@ export async function run(payload, deps = {}) {
18
18
  if (!kind || !payload?.tool_use_id) return null;
19
19
 
20
20
  // Loaded only here: this link runs on every Bash call and almost none is heavy.
21
- const { readHeavyConfig } = await import('../../shared/heavy-config.js');
21
+ const { minFreeMemFor, readHeavyConfig } = await import('../../shared/heavy-config.js');
22
22
  const { acquireHeavy, describeBusy, heavyDir } = await import('../../shared/heavy-slots.js');
23
23
  const env = deps.env ?? process.env;
24
24
  const dir = env.CLAUDE_PROJECT_DIR || payload?.cwd || process.cwd();
@@ -26,6 +26,7 @@ export async function run(payload, deps = {}) {
26
26
  const config = readHeavyConfig(dir, env);
27
27
  if (!config.enabled || !config.gateBash) return null;
28
28
 
29
+ const memClass = heavyClass(kind);
29
30
  const result = (deps.acquire ?? acquireHeavy)({
30
31
  dir: heavyDir(env),
31
32
  slots: config.slots,
@@ -33,7 +34,8 @@ export async function run(payload, deps = {}) {
33
34
  feature: null,
34
35
  node: null,
35
36
  cwd: dir,
36
- minFreeMemGb: config.minFreeMemGb,
37
+ kind: memClass,
38
+ minFreeMemGb: minFreeMemFor(config, memClass),
37
39
  waitTimeoutMs: Math.min(config.waitTimeoutSec, HOOK_BUDGET_SEC) * 1000,
38
40
  maxHoldMs: config.maxHoldMinutes * 60_000,
39
41
  lease: { id: payload.tool_use_id, ttlMs: leaseTtlMs(input, env) },
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+ import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { blockResult, runStandalone } from '../../shared/hook-response.js';
5
+ import { stateExists } from '../../shared/state-reader.js';
6
+ import { QUEUE_FULL_EXIT, REVIEW_AGENT_TYPES, morphVerifyCall, testRunKind } from '../../shared/heavy-command.js';
7
+
8
+ export const TEST_RUN_BUDGET = 3;
9
+
10
+ export const REASONS = {
11
+ verify: '`morph-spec verify` cheio é do orquestrador, depois do seu retorno. Rode só o filtro dos seus testes.',
12
+ budget: '4ª rodada de teste nesta fatia. Grave `PARCIAL` na 1ª linha do relatório com o que ainda falha e devolva: o orquestrador decide.',
13
+ review: 'Agente de review não roda teste: a prova é o verify do orquestrador. Para provar que um teste morde, `morph-spec mutate`.',
14
+ };
15
+
16
+ // Pre and Post must land on the same counter: a sub-agent's cwd drifts into subfolders.
17
+ const projectRoot = (payload, env) => env.CLAUDE_PROJECT_DIR || payload?.cwd || process.cwd();
18
+
19
+ const counterPath = (root, agentId) =>
20
+ join(root, '.morph', 'memory', 'test-runs', `${String(agentId).replace(/[^A-Za-z0-9_-]/g, '_')}.json`);
21
+
22
+ function readCounter(path) {
23
+ try {
24
+ return JSON.parse(readFileSync(path, 'utf-8')) ?? {};
25
+ } catch {
26
+ return {};
27
+ }
28
+ }
29
+
30
+ const readRuns = (path) => Number(readCounter(path).runs) || 0;
31
+
32
+ function recordRun(path, name) {
33
+ mkdirSync(join(path, '..'), { recursive: true });
34
+ const tmp = `${path}.tmp.${process.pid}`;
35
+ writeFileSync(tmp, JSON.stringify({ runs: readRuns(path) + 1, ...(name ? { name } : {}) }));
36
+ renameSync(tmp, path);
37
+ }
38
+
39
+ function exitCodeOf(payload) {
40
+ const code = /^exit code (\d+)/i.exec(String(payload.error ?? '').split('\n', 1)[0]);
41
+ return code ? Number(code[1]) : null;
42
+ }
43
+
44
+ // The orchestrator resuming an agent is the decision the budget hands back to it: a fresh budget.
45
+ function resetOnResume(payload, env) {
46
+ const to = payload.tool_input?.to;
47
+ if (payload.agent_id || payload.hook_event_name !== 'PostToolUse' || !to) return;
48
+ const root = projectRoot(payload, env);
49
+ if (!stateExists(root)) return;
50
+ // `to` may be the dispatch name instead of the id: the counter records the name it ran under.
51
+ const byId = counterPath(root, to);
52
+ const dir = join(byId, '..');
53
+ for (const file of readdirSync(dir)) {
54
+ const path = join(dir, file);
55
+ if (path === byId || readCounter(path).name === to) rmSync(path, { force: true });
56
+ }
57
+ }
58
+
59
+ export async function run(payload, deps = {}) {
60
+ try {
61
+ if (payload?.tool_name === 'SendMessage') {
62
+ resetOnResume(payload, deps.env ?? process.env);
63
+ return null;
64
+ }
65
+ if (payload?.tool_name !== 'Bash' || !payload.agent_id) return null;
66
+ const command = payload.tool_input?.command || '';
67
+ const kind = testRunKind(command);
68
+ const verify = morphVerifyCall(command);
69
+ if (!kind && !verify) return null;
70
+
71
+ const env = deps.env ?? process.env;
72
+ const root = projectRoot(payload, env);
73
+ if (!stateExists(root)) return null;
74
+ const path = counterPath(root, payload.agent_id);
75
+
76
+ if (payload.hook_event_name === 'PreToolUse') {
77
+ if (REVIEW_AGENT_TYPES.has(payload.agent_type)) return blockResult(REASONS.review);
78
+ if (verify && !verify.filtered) return blockResult(REASONS.verify);
79
+ if (readRuns(path) >= TEST_RUN_BUDGET) return blockResult(REASONS.budget);
80
+ return null;
81
+ }
82
+
83
+ if (payload.hook_event_name === 'PostToolUseFailure' && exitCodeOf(payload) === QUEUE_FULL_EXIT) return null;
84
+ if (payload.hook_event_name === 'PostToolUse' || payload.hook_event_name === 'PostToolUseFailure') recordRun(path, payload.agent_type);
85
+ } catch {}
86
+ return null;
87
+ }
88
+
89
+ await runStandalone(run);
@@ -1,11 +1,11 @@
1
- #!/usr/bin/env bash
2
- # morph-spec statusline — installed globally to ~/.claude/statusline.sh
3
- # Claude Code invokes this with JSON via stdin after each response.
4
- # Requires: Python 3 available as `python3` or `python` on PATH.
5
-
6
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7
- if command -v python3 &>/dev/null; then
8
- python3 "${SCRIPT_DIR}/statusline.py" 2>/dev/null || true
9
- elif command -v python &>/dev/null; then
10
- python "${SCRIPT_DIR}/statusline.py" 2>/dev/null || true
11
- fi
1
+ #!/usr/bin/env bash
2
+ # morph-spec statusline — installed globally to ~/.claude/statusline.sh
3
+ # Claude Code invokes this with JSON via stdin after each response.
4
+ # Requires: Python 3 available as `python3` or `python` on PATH.
5
+
6
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7
+ if command -v python3 &>/dev/null; then
8
+ python3 "${SCRIPT_DIR}/statusline.py" 2>/dev/null || true
9
+ elif command -v python &>/dev/null; then
10
+ python "${SCRIPT_DIR}/statusline.py" 2>/dev/null || true
11
+ fi
Binary file
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { execSync } from 'child_process';
6
6
  import { loadState } from './state-reader.js';
7
+ import { morphBranchFeature } from './git-paths.js';
7
8
 
8
9
  // `loadState`, not a directory listing: it also sees features only the index carries.
9
10
  export function featureNamesIn(root) {
@@ -55,26 +56,27 @@ export function unrecognizedSlugLike(text, names) {
55
56
  return tokens.find((t) => !known.has(t)) ?? null;
56
57
  }
57
58
 
58
- // The session root wins a feature present in both trees: it is where
59
- // `morph-spec approve` would write from that cwd.
59
+ // The tree on `morph/<f>` owns the feature and wins: the primary root only carries its index
60
+ // entry, and `morph-spec approve` refuses any other tree. Without an owner, the session root wins.
60
61
  export function locateFeatureForQuestion(text, sessionRoot) {
61
62
  const trees = allWorkTrees(sessionRoot);
62
63
  const localNames = featureNamesIn(sessionRoot);
64
+ const ownerOr = (name, root) => trees.find((t) => morphBranchFeature(t) === name) ?? root;
63
65
 
64
66
  const local = featureNamedIn(text, localNames);
65
- if (local) return { name: local, root: sessionRoot };
67
+ if (local) return { name: local, root: ownerOr(local, sessionRoot) };
66
68
 
67
69
  for (const root of trees) {
68
70
  if (root === sessionRoot) continue;
69
71
  const found = featureNamedIn(text, featureNamesIn(root));
70
- if (found) return { name: found, root };
72
+ if (found) return { name: found, root: ownerOr(found, root) };
71
73
  }
72
74
 
73
75
  const allNames = trees.flatMap(featureNamesIn);
74
76
  if (unrecognizedSlugLike(text, allNames)) return null;
75
77
 
76
78
  // With exactly ONE feature in the tree there is nothing else the answer could be about.
77
- if (localNames.length === 1) return { name: localNames[0], root: sessionRoot };
79
+ if (localNames.length === 1) return { name: localNames[0], root: ownerOr(localNames[0], sessionRoot) };
78
80
 
79
81
  return null;
80
82
  }
@@ -95,17 +95,88 @@ function kindOfTokens(tokens) {
95
95
  return null;
96
96
  }
97
97
 
98
+ const segmentTokens = (segment) => skipWrappers(tokenize(segment.replace(/^[\s({]+/, '').replace(/[\s)}]+$/, '')));
99
+
98
100
  // Judges only each segment's executable: `morph-spec heavy run -- dotnet test` already holds its
99
101
  // own slot, and `echo "dotnet build"` runs nothing.
100
102
  export function heavyKind(command) {
101
103
  for (const segment of splitSegments(command)) {
102
- const tokens = skipWrappers(tokenize(segment.replace(/^[\s({]+/, '').replace(/[\s)}]+$/, '')));
104
+ const tokens = segmentTokens(segment);
103
105
  const kind = kindOfTokens(tokens);
104
106
  if (kind) return kind;
105
107
  }
106
108
  return null;
107
109
  }
108
110
 
111
+ export const REVIEW_AGENT_TYPES = new Set(['morph-verifier', 'morph-reviewer', 'morph-security']);
112
+ export const QUEUE_FULL_EXIT = 3;
113
+
114
+ const LIST_FLAGS = new Set(['--list', '--listTests', '--list-tests']);
115
+ const TEST_LABEL = /^(dotnet (test|vstest)|(npx )?(vitest|jest|playwright test)|(npm|pnpm|yarn|bun) (test|run test(:\S+)?))$/;
116
+ const NODE_RUNNERS = new Set(['vitest', 'jest']);
117
+
118
+ function morphArgs(tokens) {
119
+ if (executable(tokens[0] ?? '') === 'npx' && executable(tokens[1] ?? '') === 'morph-spec') return tokens.slice(2);
120
+ if (executable(tokens[0] ?? '') === 'morph-spec') return tokens.slice(1);
121
+ return null;
122
+ }
123
+
124
+ // `morph-spec heavy run -- <cmd>` is peeled here, unlike in heavyKind: the slot is already held,
125
+ // but the run still spends a test round.
126
+ function* commandTokens(command) {
127
+ for (const segment of splitSegments(command)) {
128
+ const tokens = segmentTokens(segment);
129
+ const args = morphArgs(tokens);
130
+ if (args?.[0] === 'heavy' && args[1] === 'run') {
131
+ const rest = args.slice(2);
132
+ const dashes = rest.indexOf('--');
133
+ yield skipWrappers(dashes >= 0 ? rest.slice(dashes + 1) : rest);
134
+ } else {
135
+ yield tokens;
136
+ }
137
+ }
138
+ }
139
+
140
+ function testKindOfTokens(tokens) {
141
+ const [head, ...args] = tokens;
142
+ if (!head || args.some((a) => LIST_FLAGS.has(a) || INFO_FLAGS.has(a))) return null;
143
+ if (executable(head) === 'node') {
144
+ const runner = args.map((a) => executable(a).replace(/\.[cm]?js$/, '')).find((a) => NODE_RUNNERS.has(a));
145
+ return runner ?? null;
146
+ }
147
+ const kind = kindOfTokens(tokens);
148
+ return kind && TEST_LABEL.test(kind) ? kind : null;
149
+ }
150
+
151
+ export function testRunKind(command) {
152
+ for (const tokens of commandTokens(command)) {
153
+ const kind = testKindOfTokens(tokens);
154
+ if (kind) return kind;
155
+ }
156
+ return null;
157
+ }
158
+
159
+ export function morphVerifyCall(command) {
160
+ for (const tokens of commandTokens(command)) {
161
+ const args = morphArgs(tokens);
162
+ if (!args || firstArg(args) !== 'verify' || args.some((a) => INFO_FLAGS.has(a))) continue;
163
+ return { filtered: args.some((a) => a === '--filter' || a.startsWith('--filter=')) };
164
+ }
165
+ return null;
166
+ }
167
+
168
+ const E2E_CLASS = /\be2e\b|playwright|compose up/;
169
+ const UNIT_CLASS = /^(tests|(npx )?(vitest|jest)|(npm|pnpm|yarn|bun) (test|run test(:\S+)?))$/;
170
+
171
+ // Maps a `heavyKind` label or a verify node to the RAM class it pays. `dotnet test` and anything
172
+ // unknown stay `build`: they compile before running, and the fallback must never lower a floor.
173
+ export function heavyClass(label) {
174
+ const s = String(label ?? '').toLowerCase().trim();
175
+ if (E2E_CLASS.test(s)) return 'e2e';
176
+ if (UNIT_CLASS.test(s)) return 'unit';
177
+ return 'build';
178
+ }
179
+
109
180
  // The lease has no live process to watch: this TTL is the only thing that frees it after a
110
181
  // denied or interrupted call.
111
182
  const BASH_DEFAULT_TIMEOUT_MS = 2 * 60_000;