@polymorphism-tech/morph-spec 8.43.0 → 8.44.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 (38) hide show
  1. package/CLAUDE.md +6 -4
  2. package/README.md +2 -2
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -1
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/docs/CLI.md +13 -8
  7. package/framework/hooks/claude-code/core/worktree-env-inject.js +1 -4
  8. package/framework/rules/ai-agents-standards.md +1 -0
  9. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  10. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  11. package/framework/skills/core/morph-apply/SKILL.md +1 -1
  12. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  13. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  14. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  15. package/framework/skills/core/morph-implement/SKILL.md +1 -1
  16. package/framework/skills/core/morph-machine/SKILL.md +1 -1
  17. package/framework/skills/core/morph-media/SKILL.md +1 -1
  18. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  19. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  20. package/framework/skills/core/morph-review/SKILL.md +1 -1
  21. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  22. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  23. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  24. package/framework/standards/STANDARDS.json +45 -1
  25. package/framework/standards/ai-agents/tool-adapters.md +226 -0
  26. package/framework/standards/infrastructure/docker/local-compose-isolation.md +11 -1
  27. package/package.json +1 -1
  28. package/src/commands/project/worktree.js +34 -16
  29. package/src/commands/state/approve.js +2 -1
  30. package/src/commands/state/finish.js +4 -0
  31. package/src/commands/state/gate-check.js +2 -1
  32. package/src/commands/validation/verify.js +13 -21
  33. package/src/lib/e2e/compose-env.js +5 -2
  34. package/src/lib/git/evidence-staleness.js +39 -0
  35. package/src/lib/risk-detector.js +3 -3
  36. package/src/lib/tasks/eval-node.js +4 -34
  37. package/src/lib/validators/contracts/contract-compliance-validator.js +4 -42
  38. package/src/lib/worktree/env-allocator.js +35 -5
@@ -2566,15 +2566,18 @@
2566
2566
  "POSTGRES_PORT",
2567
2567
  "COMPOSE_PROJECT_NAME",
2568
2568
  "port block",
2569
+ "PG_PORT",
2570
+ "portEnvNames",
2569
2571
  "local docker compose"
2570
2572
  ],
2571
2573
  "anchors": {
2572
2574
  "layout": "#port-layout-one-contract-everywhere",
2575
+ "portNames": "#project-named-port-variables",
2573
2576
  "rules": "#core-rules",
2574
2577
  "enforcement": "#what-the-harness-enforces",
2575
2578
  "skeleton": "#minimal-compose-skeleton"
2576
2579
  },
2577
- "digest": "Every worktree runs its OWN compose stack (project morph-{feature}, own postgres, own volumes). Ports come from the worktree block: PORT +0, API_PORT +1 (ASPNETCORE_URLS on .NET), POSTGRES_PORT +2, REDIS_PORT +3 — publish via ${VAR:-default}:container, never a fixed host port, never container_name (not prefixed by the project → collides). Stable POSTGRES_DB, healthcheck on every service, DATABASE_URL with an in-network default, playwright.config reads PLAYWRIGHT_BASE_URL. A non-isolable compose fails the verify e2e node inside a worktree. finish tears down with volumes (--keep-volumes opts out). Cloud DB credentials go in e2e.auth.envFile, never in .morph/worktree.env.",
2580
+ "digest": "Every worktree runs its OWN compose stack (project morph-{feature}, own postgres, own volumes). Ports come from the worktree block: PORT +0, API_PORT +1 (ASPNETCORE_URLS on .NET), POSTGRES_PORT +2, REDIS_PORT +3 — publish via ${VAR:-default}:container, never a fixed host port, never container_name (not prefixed by the project → collides). Stable POSTGRES_DB, healthcheck on every service, DATABASE_URL with an in-network default, playwright.config reads PLAYWRIGHT_BASE_URL. A non-isolable compose fails the verify e2e node inside a worktree. finish tears down with volumes (--keep-volumes opts out). Cloud DB credentials go in e2e.auth.envFile, never in .morph/worktree.env. A compose that reads a port by ANOTHER name (PG_PORT) is not fed by the block: declare it in config.json e2e.portEnvNames ({\"postgres\": \"PG_PORT\"}) and the harness exports it with the same value in worktree.env, the compose env and the hook.",
2578
2581
  "verifiedAgainst": "Docker Compose v2 + morph-spec 8.34 (dedicated stack per worktree).",
2579
2582
  "provedBy": []
2580
2583
  },
@@ -2851,6 +2854,47 @@
2851
2854
  "digest": "Default is NO comment: code explains itself through names, types and tests. Before commenting, rename, extract a function or named constant, encode the state in the type, or write a test; comment only what none of these can express: an invariant the type does not express, an order that matters, an external quirk (third-party API, library bug with its link), or the contract of a package published for consumers outside the repo. Never process history (task, UC, ADR, FR, Gate, evaluator, spec.md, mockup: commit message and decisions.md; in a test the link to the postcondition goes in the test NAME), never restate the code, no XML doc/JSDoc on application members that repeats the signature, no file headers, section banners, #region, commented-out code or loose TODOs, no claims about behavior (make them test assertions). One line is normal, ~3 is the ceiling; more than one comment line per ten code lines means the names are not doing the work. A heavily commented neighbour file is not a model. The comment-guard hook blocks an edit that ADDS process history, a banner/#region, a block over 3 lines or a doc-comment outside a published package; the verify validators node (code-budget) warns on added comments that cite process and on added comment density.",
2852
2855
  "verifiedAgainst": "C#, TypeScript e JavaScript (convenção de linguagem, sem pacote externo).",
2853
2856
  "provedBy": []
2857
+ },
2858
+ {
2859
+ "id": "ai-agents-tool-adapters",
2860
+ "name": "tool adapters",
2861
+ "path": "ai-agents/tool-adapters.md",
2862
+ "category": "ai-agents",
2863
+ "subcategory": null,
2864
+ "tags": [
2865
+ "ai-agents",
2866
+ "maf",
2867
+ "tools",
2868
+ "testing"
2869
+ ],
2870
+ "stacks": [
2871
+ "dotnet"
2872
+ ],
2873
+ "aliases": [
2874
+ "tool fina",
2875
+ "adaptador de tool",
2876
+ "Ops",
2877
+ "Desfecho",
2878
+ "Render",
2879
+ "regra de negocio em tool",
2880
+ "descricao da tool",
2881
+ "golden de tool",
2882
+ "refactor neutro",
2883
+ "migracao de teste"
2884
+ ],
2885
+ "anchors": {
2886
+ "fronteira": "#fronteira-com-os-outros-standards",
2887
+ "problema": "#o-problema",
2888
+ "padrao": "#o-padrão",
2889
+ "exemplo": "#exemplo-c-completo",
2890
+ "testes": "#os-testes-das-duas-camadas",
2891
+ "migracao": "#migrar-uma-tool-que-já-tem-a-regra-no-adaptador",
2892
+ "reais": "#exemplos-reais",
2893
+ "regras": "#regras"
2894
+ },
2895
+ "digest": "A tool that carries business rule keeps apart three things that change for different reasons: the RULE (sealed class XOps(...), primary constructor, explicit AddScoped; never *Service, which the mandate bans as a layer between handler and data), the sentence the MODEL reads, and the EFFECT a human sees. Each Ops method returns a typed outcome (abstract record XDesfecho, one sealed record per case); the model-facing text (description, refusal, self-correction, success, the ToolUnavailable decision) lives in the adapter, in public static object Render(XDesfecho), while human text delivered by code (task body, note, ack) stays in Ops because it is an effect. Checkable by grep: Ops names no tool and instructs no model. The adapter (AIFunctionFactory.Create) only builds the typed request, calls Ops and returns Render: zero rule, not a line count, and a long dynamic description stays byte for byte. Two test layers: Ops tests assert outcome, calls and order on the fake, never text; contract tests assert Render's exact literal and .Description, with a golden of name, description and schema per real tenant. A neutral refactor is proven before any test moves: a proof commit with the old suite untouched and green (offline evals included), then the migration, with mutation run only against the new tests. \"Already covered\" needs proof assertion by assertion (11 of 22 such claims had lost one); the tests left at the origin must be identical to the base; mutation on the new tests found rules the old suite never protected. The C# blocks were compiled against the pin and the three xunit tests run.",
2896
+ "verifiedAgainst": "Microsoft.Extensions.AI 10.9.0 + Microsoft.Agents.AI 1.20.0 (ai-pin 2026-09-08); os dois blocos C# abaixo foram extraídos com `scripts/extract-doc-csharp.mjs` e compilados contra o pin com `TreatWarningsAsErrors`, e os três testes do segundo bloco foram executados e passam. **A separação em si e a migração de teste são verificação documental:** vêm da feature `tools-finas` do MORPH_GHLBrain e o ai-kit não as traz.",
2897
+ "provedBy": []
2854
2898
  }
2855
2899
  ],
2856
2900
  "sharedAliases": {
@@ -0,0 +1,226 @@
1
+ # Tool Adapters — a regra em `*Ops`, o texto do modelo no adaptador
2
+
3
+ > **Scope:** stacks=["dotnet"]
4
+ > **Layer:** 2 (on-keyword)
5
+ > **Keywords:** tool, AIFunctionFactory, adaptador de tool, tool fina, Ops, Desfecho, Render, regra de negócio em tool, descrição da tool, golden de tool, refactor neutro, migração de teste
6
+ > **Read by Claude in:** implement (ao escrever ou migrar uma tool de agente que carrega regra de negócio)
7
+
8
+ **Verified against:** Microsoft.Extensions.AI 10.9.0 + Microsoft.Agents.AI 1.20.0 (ai-pin 2026-09-08); os dois blocos C# abaixo foram extraídos com `scripts/extract-doc-csharp.mjs` e compilados contra o pin com `TreatWarningsAsErrors`, e os três testes do segundo bloco foram executados e passam. **A separação em si e a migração de teste são verificação documental:** vêm da feature `tools-finas` do MORPH_GHLBrain e o ai-kit não as traz. Last-verified: 2026-09-08.
9
+
10
+ ---
11
+
12
+ ## Fronteira com os outros standards
13
+
14
+ `ai-agents-mcp-tools` cobre **consumir** tools de um MCP server. `ai-agents-testing-ai` cobre **testar** um agente sem rede. `backend-dotnet-testing` é dono de xUnit, relógio falso e orçamento de teste. **Nada disso se repete aqui.**
15
+
16
+ Este standard cobre uma coisa só: a tool que o agente chama **e que carrega regra**. Onde mora a regra, onde mora a frase que o modelo lê, e como se testa cada uma sem que uma mascare a outra.
17
+
18
+ ## O problema
19
+
20
+ Uma tool nasce como uma lambda dentro do `AIFunctionFactory.Create`. Enquanto é só "chame a API e devolva", serve. Quando ganha regra, a lambda passa a misturar três coisas que mudam por motivos diferentes:
21
+
22
+ | O que é | Muda quando | Quem lê |
23
+ |---|---|---|
24
+ | a **regra** (validar, decidir, gravar) | o negócio muda | o código e o teste |
25
+ | a **frase para o modelo** (descrição, recusa, instrução de correção) | o prompt é ajustado | o modelo |
26
+ | o **efeito** (task, nota, mensagem entregue por código) | o processo muda | um humano |
27
+
28
+ Misturadas, o único teste possível é por string: qualquer ajuste de redação quebra o teste da regra, e qualquer refactor da regra arrisca mudar, sem ninguém ver, o que o modelo lê.
29
+
30
+ ## O padrão
31
+
32
+ ```
33
+ o modelo chama o modelo lê
34
+ ┌──────────────┐ pedido tipado ┌───────────┐ desfecho ┌────────────────┐
35
+ │ Adaptador │ ──────────────────► │ *Ops │ ─────────► │ Render(desfecho)│
36
+ │ (tool fina) │ │ (a regra) │ tipado │ (frase p/ modelo)│
37
+ └──────────────┘ ◄───────────────────────────────────────────└────────────────┘
38
+ ```
39
+
40
+ 1. **`Ops`, não `Service`.** Regra determinística com dependências injetadas vira `sealed class XOps(...)`, com construtor primário e um `AddScoped` explícito. `*Service` colide com a proibição de camada de Service/Manager entre handler e dados; `*Ops` é outra coisa: a regra de uma tool, não um intermediário de persistência.
41
+ 2. **Desfecho tipado, e a fronteira é quem lê o texto.** Cada método de `Ops` devolve um `abstract record XDesfecho` com um `sealed record` por caso. A frase para o **modelo** (descrição, recusa, instrução de autocorreção, sucesso, a decisão de "tool indisponível") mora no adaptador, em `public static object Render(XDesfecho)`. O texto para **humano** entregue por código (corpo de task, nota, ack) mora na `Ops`, porque é efeito. É verificável por `grep`: a `Ops` não cita o nome da tool nem dá instrução ao modelo.
42
+ 3. **Adaptador fino.** `AIFunctionFactory.Create` com a lambda montando o pedido tipado, chamando a `Ops` e devolvendo `Render(desfecho)`. A régua é **zero regra no adaptador**, não um número de linhas: uma descrição dinâmica pode ser longa e tem de continuar byte a byte.
43
+ 4. **Testes em duas camadas.** O teste da `Ops` asserta desfecho, chamadas e ordem no fake, **nunca texto**. O teste de contrato asserta o `Render` com o literal exato e a `.Description`. Um golden de nome, descrição e schema, por tenant real, protege a superfície que o modelo lê.
44
+ 5. **Refactor neutro se prova antes de migrar teste.** Primeiro um commit de prova, com a suíte antiga intocada e verde (inclusive os evals offline); só depois a migração dos testes, com mutação rodando **só** contra os testes novos.
45
+
46
+ ## Exemplo C# completo
47
+
48
+ A regra, o desfecho, o adaptador e o registro. O pedido é tipado, então a lambda não decide nada: monta, chama, renderiza.
49
+
50
+ ```csharp
51
+ using System.ComponentModel;
52
+ using Microsoft.Extensions.AI;
53
+ using Microsoft.Extensions.DependencyInjection;
54
+
55
+ namespace Exemplo.Tools;
56
+
57
+ public interface IAgendaExterna
58
+ {
59
+ Task<int> CapacidadeAsync(string salaId, CancellationToken ct);
60
+ Task<string> GravarAsync(ReservaPedido pedido, CancellationToken ct);
61
+ }
62
+
63
+ public sealed record ReservaPedido(string SalaId, DateOnly Dia, int Pessoas);
64
+
65
+ public abstract record ReservaDesfecho
66
+ {
67
+ public sealed record Reservada(string ConfirmacaoId) : ReservaDesfecho;
68
+ public sealed record SalaCheia(int Capacidade) : ReservaDesfecho;
69
+ public sealed record DiaSemExpediente(DateOnly Dia) : ReservaDesfecho;
70
+ }
71
+
72
+ public sealed class ReservaOps(IAgendaExterna agenda)
73
+ {
74
+ public async Task<ReservaDesfecho> ReservarAsync(ReservaPedido pedido, CancellationToken ct)
75
+ {
76
+ if (pedido.Dia.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday)
77
+ return new ReservaDesfecho.DiaSemExpediente(pedido.Dia);
78
+
79
+ var capacidade = await agenda.CapacidadeAsync(pedido.SalaId, ct);
80
+ if (pedido.Pessoas > capacidade)
81
+ return new ReservaDesfecho.SalaCheia(capacidade);
82
+
83
+ return new ReservaDesfecho.Reservada(await agenda.GravarAsync(pedido, ct));
84
+ }
85
+ }
86
+
87
+ public static class ReservarSalaTool
88
+ {
89
+ public const string Name = "reservar_sala";
90
+
91
+ public static AIFunction Create(ReservaOps ops) =>
92
+ AIFunctionFactory.Create(
93
+ async (
94
+ [Description("Identificador da sala, como listado por consultar_salas")] string salaId,
95
+ [Description("Dia da reserva, AAAA-MM-DD")] DateOnly dia,
96
+ [Description("Quantidade de pessoas")] int pessoas,
97
+ CancellationToken ct) =>
98
+ Render(await ops.ReservarAsync(new ReservaPedido(salaId, dia, pessoas), ct)),
99
+ Name,
100
+ "Reserva uma sala para um dia. Só chame depois de confirmar sala, dia e número de pessoas com o cliente.");
101
+
102
+ public static object Render(ReservaDesfecho desfecho) => desfecho switch
103
+ {
104
+ ReservaDesfecho.Reservada r => new { ok = true, confirmacao = r.ConfirmacaoId },
105
+ ReservaDesfecho.SalaCheia c => new { ok = false, erro = $"A sala comporta no máximo {c.Capacidade} pessoas. Ofereça outra sala ou reduza o grupo." },
106
+ ReservaDesfecho.DiaSemExpediente d => new { ok = false, erro = $"{d.Dia:yyyy-MM-dd} não tem expediente. Peça outro dia." },
107
+ _ => throw new ArgumentOutOfRangeException(nameof(desfecho)),
108
+ };
109
+ }
110
+
111
+ public static class ReservaToolsRegistration
112
+ {
113
+ public static IServiceCollection AddReservaTools(this IServiceCollection services) =>
114
+ services.AddScoped<ReservaOps>();
115
+ }
116
+ ```
117
+
118
+ A checagem de fronteira é um `grep`, e tem de sair vazio: a `Ops` não conhece a tool nem fala com o modelo.
119
+
120
+ ```bash
121
+ grep -rnE "reservar_sala|Ofereça|Peça|AIFunction" Features/Reservas/Ops/
122
+ ```
123
+
124
+ ## Os testes das duas camadas
125
+
126
+ O teste da `Ops` compara **tipos e chamadas**; o de contrato compara **o texto que o modelo lê**. Trocar a redação de uma recusa quebra só o segundo.
127
+
128
+ ```csharp
129
+ using System.Text.Encodings.Web;
130
+ using System.Text.Json;
131
+ using Exemplo.Tools;
132
+ using Xunit;
133
+
134
+ namespace Exemplo.Tools.Tests;
135
+
136
+ internal sealed class AgendaFake(int capacidade) : IAgendaExterna
137
+ {
138
+ public List<string> Chamadas { get; } = [];
139
+
140
+ public Task<int> CapacidadeAsync(string salaId, CancellationToken ct)
141
+ {
142
+ Chamadas.Add($"capacidade:{salaId}");
143
+ return Task.FromResult(capacidade);
144
+ }
145
+
146
+ public Task<string> GravarAsync(ReservaPedido pedido, CancellationToken ct)
147
+ {
148
+ Chamadas.Add($"gravar:{pedido.SalaId}");
149
+ return Task.FromResult("R-1");
150
+ }
151
+ }
152
+
153
+ public sealed class ReservaOpsTests
154
+ {
155
+ [Fact]
156
+ public async Task Sala_cheia_nao_grava_e_devolve_a_capacidade()
157
+ {
158
+ var agenda = new AgendaFake(capacidade: 4);
159
+ var ops = new ReservaOps(agenda);
160
+
161
+ var desfecho = await ops.ReservarAsync(new ReservaPedido("s1", new DateOnly(2026, 10, 5), 6), CancellationToken.None);
162
+
163
+ Assert.Equal(new ReservaDesfecho.SalaCheia(4), desfecho);
164
+ Assert.Equal(["capacidade:s1"], agenda.Chamadas);
165
+ }
166
+ }
167
+
168
+ public sealed class ReservarSalaToolTests
169
+ {
170
+ private static readonly JsonSerializerOptions Literal = new() { Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
171
+
172
+ [Fact]
173
+ public void Render_da_sala_cheia_diz_ao_modelo_o_que_fazer()
174
+ {
175
+ var json = JsonSerializer.Serialize(ReservarSalaTool.Render(new ReservaDesfecho.SalaCheia(4)), Literal);
176
+
177
+ Assert.Equal("{\"ok\":false,\"erro\":\"A sala comporta no máximo 4 pessoas. Ofereça outra sala ou reduza o grupo.\"}", json);
178
+ }
179
+
180
+ [Fact]
181
+ public void A_superficie_que_o_modelo_le_nao_mudou()
182
+ {
183
+ var tool = ReservarSalaTool.Create(new ReservaOps(new AgendaFake(capacidade: 10)));
184
+
185
+ Assert.Equal("reservar_sala", tool.Name);
186
+ Assert.StartsWith("Reserva uma sala para um dia.", tool.Description);
187
+ Assert.Contains("\"salaId\"", tool.JsonSchema.GetRawText());
188
+ }
189
+ }
190
+ ```
191
+
192
+ ## Migrar uma tool que já tem a regra no adaptador
193
+
194
+ A migração é um refactor que **não pode mudar o que o modelo lê**. Por isso ela tem ordem:
195
+
196
+ 1. **Commit de prova.** Extraia a `Ops` e o `Render` sem tocar nenhum teste. A suíte antiga tem de continuar verde, inclusive os evals offline. Esse commit é a âncora: prova que o refactor é neutro.
197
+ 2. **Migre os testes**, e só depois de o commit de prova estar verde.
198
+ 3. **Matriz de migração.** Liste cada asserção antiga e a asserção nova que a cobre. "Já está coberto pelo teste X" precisa de prova **asserção por asserção**: na migração que originou este standard, 11 de 22 alegações desse tipo tinham perdido alguma asserção do teste antigo.
199
+ 4. **Os testes que ficam na origem têm de ser idênticos à base.** A migração só apaga. Uma checagem byte a byte dos métodos restantes contra o commit de prova pegou (e provou corrigidos) `Edit`s que casaram no método errado.
200
+ 5. **Mutação só contra os testes novos.** Ela acha lacunas que a suíte antiga nunca protegeu: em duas das três `Ops` migradas apareceram regras sem teste algum (valor digitado pelo comercial sobrescrito pelo catálogo, campos reafirmados sumindo da falha do `PUT`, corpo de update reenviando valor igual).
201
+
202
+ ## Exemplos reais
203
+
204
+ MORPH_GHLBrain, `src/Morph.GHLBrain/Features/Conversation/Operator/`:
205
+
206
+ | Tool | Adaptador | `Ops` | Desfecho |
207
+ |---|---|---|---|
208
+ | `finalizar_handoff` | `Tools/FinalizarHandoffTool.cs` | `Ops/HandoffOps.cs` | `HandoffDesfecho` |
209
+ | `atualizar_dossie` | `Tools/AtualizarDossieTool.cs` | `Ops/DossieOps.cs` | `DossieDesfecho` |
210
+ | `set_lead_fields` | `Tools/SetLeadFieldsTool.cs` | `Ops/DossieOps.cs` | `CamposDesfecho` |
211
+ | `route_lead` | `Tools/RouteLeadTool.cs` | `Ops/TriagemOps.cs` | `TriagemDesfecho` |
212
+
213
+ As decisões estão em `.morph/features/tools-finas/2-plan/decisions.md` (ADR-1 e ADR-2) daquele repositório.
214
+
215
+ ## Regras
216
+
217
+ - **ALWAYS** a regra de uma tool vai numa `sealed class XOps(...)` com construtor primário e `AddScoped` explícito
218
+ - **NEVER** chame essa classe de `*Service` ou `*Manager`: o mandate proíbe essa camada entre handler e dados, e `*Ops` é a forma da casa
219
+ - **ALWAYS** cada método de `Ops` devolve um `abstract record XDesfecho`, com um `sealed record` por caso que a regra distingue
220
+ - **NEVER** ponha na `Ops` uma frase para o modelo, o nome da tool ou uma instrução de autocorreção. Verifique por `grep`
221
+ - **ALWAYS** o texto para humano entregue por código (task, nota, ack) fica na `Ops`: é efeito
222
+ - **ALWAYS** o adaptador só monta o pedido tipado, chama a `Ops` e devolve `Render(desfecho)`. Zero regra ali; descrição dinâmica longa é permitida e segue byte a byte
223
+ - **ALWAYS** o teste da `Ops` asserta desfecho, chamadas e ordem no fake, nunca texto
224
+ - **ALWAYS** o teste de contrato asserta o `Render` com o literal exato e a `.Description`, e há um golden de nome, descrição e schema por tenant real
225
+ - **NEVER** declare "já coberto" sem a asserção nova que cobre cada asserção antiga
226
+ - **NEVER** migre o teste antes do commit de prova verde, nem deixe na origem um teste diferente da base
@@ -27,6 +27,16 @@ The harness gives each worktree a **block of 10 ports** (`.morph/worktree.env`,
27
27
 
28
28
  Also exported: `COMPOSE_PROJECT_NAME=morph-{feature}` (isolates container, network and volume names), `MORPH_PORT_BASE`, `MORPH_PORT_RANGE`, `PLAYWRIGHT_BASE_URL=http://localhost:<+0>`.
29
29
 
30
+ ## Project-named port variables
31
+
32
+ The harness exports only the four names above. A compose file that reads a port by another name (`"${PG_PORT:-5433}:5432"`) is **not** fed by the block: it publishes its own default, or whatever a `.env.e2e` copied from a sibling worktree carries, while `worktree.env` announces a different port. Hand-run specs then hit a closed port, or worse, seed one postgres and read another. Declare the name the project reads:
33
+
34
+ ```json
35
+ { "e2e": { "portEnvNames": { "postgres": "PG_PORT" } } }
36
+ ```
37
+
38
+ Keys are `web`, `api`, `postgres` and `redis`. Each declared name carries the **same value** as the standard variable, everywhere the layout goes: `worktree.env`, the compose env of `verify`/`e2e up`/`morph-spec env`, and the `PreToolUse:Bash` hook. Being layout, it is ignored (and named on stderr) if the credentials file carries it. An entry the harness cannot honour — unknown port, invalid or reserved name, a name used twice — is dropped and named on stderr.
39
+
30
40
  ## Core Rules
31
41
 
32
42
  - NEVER set `container_name` — Compose already names containers `<project>-<service>-1`, and an explicit name is shared by every worktree
@@ -36,7 +46,7 @@ Also exported: `COMPOSE_PROJECT_NAME=morph-{feature}` (isolates container, netwo
36
46
  - ALWAYS give `DATABASE_URL` an in-network default so the stack comes up with no `.env`: `postgresql://postgres:postgres@postgres:5432/${POSTGRES_DB:-app}`
37
47
  - ALWAYS read the base URL from the environment in `playwright.config.ts`: `process.env.PLAYWRIGHT_BASE_URL ?? \`http://localhost:${process.env.PORT ?? 3000}\``
38
48
  - NEVER put a cloud connection string (Neon branch etc.) in `.morph/worktree.env` — it is regenerated; credentials go in the gitignored file named by `config.json → e2e.auth.envFile`
39
- - NEVER put a variable from the port layout table above (nor `PLAYWRIGHT_BASE_URL`) in that credentials file — the harness **ignores** it and says which key it ignored on stderr. Ports come from the allocated block; the file carries credentials. A `.env.e2e` copied from a sibling worktree carries THAT worktree's block, and before the filter existed it silently repointed this stack at the neighbour's postgres
49
+ - NEVER put a variable from the port layout table above (nor `PLAYWRIGHT_BASE_URL`, nor a name declared in `e2e.portEnvNames`) in that credentials file — the harness **ignores** it and says which key it ignored on stderr. Ports come from the allocated block; the file carries credentials. A `.env.e2e` copied from a sibling worktree carries THAT worktree's block, and before the filter existed it silently repointed this stack at the neighbour's postgres
40
50
 
41
51
  ## What the harness enforces
42
52
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polymorphism-tech/morph-spec",
3
- "version": "8.43.0",
3
+ "version": "8.44.0",
4
4
  "description": "MORPH-SPEC: NLH (Natural Language Harness) for spec-driven development with Claude Code",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -882,19 +882,21 @@ export async function allocateWorktreeEnv(worktreePath, root, envKey, { morphFea
882
882
  } catch { morphFeature = null; }
883
883
  }
884
884
  try {
885
- const { allocateEnvBlock, resolveFleetConfig, buildEnvFileContent } =
885
+ const { allocateEnvBlock, resolveFleetConfig, buildEnvFileContent, resolvePortAliases } =
886
886
  await import('../../lib/worktree/env-allocator.js');
887
887
  const common = execSync('git rev-parse --git-common-dir', { cwd: root, stdio: 'pipe' }).toString().trim();
888
888
  if (!common) return null;
889
889
  const gitCommonDir = resolve(root, common);
890
890
  const block = allocateEnvBlock(envKey, { gitCommonDir, ...resolveFleetConfig(root) });
891
891
  let stack = '';
892
+ let aliases = {};
892
893
  try {
893
894
  const cfg = JSON.parse(readFileSync(join(root, '.morph', 'config', 'config.json'), 'utf8').replace(/^/, ''));
894
895
  stack = cfg?.project?.stack || '';
896
+ aliases = resolvePortAliases(cfg).aliases;
895
897
  } catch { /* fail-open — stackless env still carries PORT */ }
896
898
  mkdirSync(join(worktreePath, '.morph'), { recursive: true });
897
- writeFileSync(envPath, buildEnvFileContent(envKey, block, stack, { morphFeature }), 'utf8');
899
+ writeFileSync(envPath, buildEnvFileContent(envKey, block, stack, { morphFeature, aliases }), 'utf8');
898
900
  excludeWorktreePaths(root, [join('.morph', 'worktree.env')]);
899
901
  return block;
900
902
  } catch {
@@ -1238,10 +1240,17 @@ export function primaryRootUnexplainedDirt(root, feature, opts = {}) {
1238
1240
  }
1239
1241
 
1240
1242
  /**
1241
- * Clear the root-side `.worktree` registration for a finished feature and commit
1242
- * that cleanup scoped to its own pathspec. Runs whenever the worktree is
1243
- * REMOVED — including `--pr`, where no merge landed — so a stale registration
1244
- * never trips the anti-dupe guard or the next finish's root-clean.
1243
+ * Clear the root-side `.worktree` registration for a finished feature. Runs
1244
+ * whenever the worktree is REMOVED — including `--pr`, where no merge landed —
1245
+ * so a stale registration never trips the anti-dupe guard or the next finish's
1246
+ * root-clean.
1247
+ *
1248
+ * A registration that was committed on the default branch is cleared by a commit
1249
+ * scoped to the feature's own pathspec. An untracked scaffold (what
1250
+ * `create --worktree` leaves at the root) is never committed: the branch owns
1251
+ * the feature, and committing the skeleton would put it on the default next to
1252
+ * the archived copy. When the feature is already archived the skeleton is shed,
1253
+ * backup first, by the same primitive `finish` uses.
1245
1254
  *
1246
1255
  * Written DIRECTLY (never saveState, which re-persists every sibling's
1247
1256
  * feature.json and can dirty the root outside this command's pathspec). The thin
@@ -1250,9 +1259,12 @@ export function primaryRootUnexplainedDirt(root, feature, opts = {}) {
1250
1259
  *
1251
1260
  * @param {string} root
1252
1261
  * @param {string} feature
1262
+ * @param {{ stamp?: string }} [opts] - the caller's backup stamp, for the shed
1263
+ * @returns {Promise<{ shedBackup: string|null }>}
1253
1264
  */
1254
- export async function clearRootWorktreeRegistration(root, feature) {
1265
+ export async function clearRootWorktreeRegistration(root, feature, { stamp } = {}) {
1255
1266
  const featPathspec = `.morph/features/${feature}`;
1267
+ const gitOpts = { cwd: root, stdio: 'pipe' };
1256
1268
  try {
1257
1269
  const rootFjPath = join(root, '.morph', 'features', feature, 'feature.json');
1258
1270
  if (existsSync(rootFjPath)) {
@@ -1265,16 +1277,19 @@ export async function clearRootWorktreeRegistration(root, feature) {
1265
1277
  }
1266
1278
  } catch { /* fail-open */ }
1267
1279
 
1268
- // Clearing that field can itself dirty the root (a committed registration
1269
- // survives the shed's checkout and the merge). Committing is in contract —
1270
- // finish already writes commits — and scoped to this feature's path only.
1271
1280
  try {
1272
- const dirtyAfter = execFileSync('git', ['status', '--porcelain', '--', featPathspec], { cwd: root, stdio: 'pipe' }).toString();
1273
- if (dirtyAfter.trim() !== '') {
1274
- execFileSync('git', ['add', '--', featPathspec], { cwd: root, stdio: 'pipe' });
1275
- execFileSync('git', ['commit', '-m', `morph-spec finish: clear ${feature} worktree registration`, '--', featPathspec], { cwd: root, stdio: 'pipe' });
1281
+ const tracked = execFileSync('git', ['ls-files', '--', featPathspec], gitOpts).toString().trim() !== '';
1282
+ if (!tracked) {
1283
+ if (!existsSync(join(root, '.morph', 'archive', feature))) return { shedBackup: null };
1284
+ return shedRootFeatureScope(root, feature, { stamp });
1285
+ }
1286
+ let unchanged = true;
1287
+ try { execFileSync('git', ['diff', '--quiet', 'HEAD', '--', featPathspec], gitOpts); } catch { unchanged = false; }
1288
+ if (!unchanged) {
1289
+ execFileSync('git', ['commit', '-m', `morph-spec finish: clear ${feature} worktree registration`, '--', featPathspec], gitOpts);
1276
1290
  }
1277
1291
  } catch { /* fail-open — a dirty root is a nuisance, not a reason to fail */ }
1292
+ return { shedBackup: null };
1278
1293
  }
1279
1294
 
1280
1295
  /**
@@ -1423,7 +1438,7 @@ export function pickRemovalFailure(stderrs) {
1423
1438
  * @returns {Promise<{ worktreeRemoved: boolean, ignoredBackup: string|null, discarded: string[],
1424
1439
  * blockers: string[], ignoredBlocking: string[], shieldable: string[], declineReason: string|null,
1425
1440
  * relinked: boolean, refused: boolean, infraLeftUnlinked: string[],
1426
- * removalFailure: {kind: string, detail: string}|null }>}
1441
+ * removalFailure: {kind: string, detail: string}|null, rootShedBackup: string|null }>}
1427
1442
  */
1428
1443
  export async function teardownWorktree(worktreePath, { root, feature, branch, options = {}, deleteBranch = true, addressByTarget = false, relinkOnFailure = false, stamp }) {
1429
1444
  // Drop the infra junctions FIRST, without following them — this is what makes
@@ -1453,6 +1468,7 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
1453
1468
 
1454
1469
  let worktreeRemoved = false;
1455
1470
  let removalFailure = null;
1471
+ let rootShedBackup = null;
1456
1472
  if (canRemove) {
1457
1473
  const attempt = await attemptWorktreeRemoval(worktreePath, root);
1458
1474
  worktreeRemoved = attempt.removed;
@@ -1473,7 +1489,7 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
1473
1489
  const common = execSync('git rev-parse --git-common-dir', { cwd: root, stdio: 'pipe' }).toString().trim();
1474
1490
  if (common) releaseEnvBlock(feature, { gitCommonDir: resolve(root, common) });
1475
1491
  } catch { /* fail-open */ }
1476
- await clearRootWorktreeRegistration(root, feature);
1492
+ ({ shedBackup: rootShedBackup } = await clearRootWorktreeRegistration(root, feature, { stamp }));
1477
1493
  }
1478
1494
 
1479
1495
  // Rollback (A4) — ONLY on a REFUSAL, never on a FAILURE. The two look alike
@@ -1568,6 +1584,7 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
1568
1584
  refused,
1569
1585
  infraLeftUnlinked,
1570
1586
  removalFailure,
1587
+ rootShedBackup,
1571
1588
  };
1572
1589
  }
1573
1590
 
@@ -1789,6 +1806,7 @@ export async function runWorktreeRemove(featureArg, options = {}) {
1789
1806
  if (teardown.replicable.length) out.replicatedInRoot = teardown.replicable;
1790
1807
  if (teardown.ignoredBackup) out.ignoredBackup = teardown.ignoredBackup;
1791
1808
  if (teardown.shieldable.length) out.backedUp = teardown.shieldable;
1809
+ if (teardown.rootShedBackup) out.shedBackup = teardown.rootShedBackup;
1792
1810
  } else {
1793
1811
  out.refused = teardown.refused;
1794
1812
  if (teardown.blockers.length) out.reparsePoints = teardown.blockers;
@@ -8,6 +8,7 @@ import { unmetPrereqs } from '../../core/state/gate-order.js';
8
8
  import { evaluatorReportState, COMPOSITE_SCORE_TOKEN } from '../../lib/evaluator-report.js';
9
9
  import { assertOwnerTree, ownerTreeRefusalLines } from '../../lib/worktree/feature-truth.js';
10
10
  import { uiuxAutoApprovalBlock } from '../../lib/specs/screen-checklist.js';
11
+ import { treeMovedSince } from '../../lib/git/evidence-staleness.js';
11
12
 
12
13
  /**
13
14
  * The literal token `review-report.md` carries to declare which tree it
@@ -57,7 +58,7 @@ function reviewStaleness(featureName) {
57
58
  // of staleness — say "unstamped" rather than block on a guess.
58
59
  return { status: 'unstamped', head };
59
60
  }
60
- if (reviewed === head) return { status: 'ok', reviewed, head };
61
+ if (!treeMovedSince(cwd, reviewed, head)) return { status: 'ok', reviewed, head };
61
62
 
62
63
  let ahead = 0;
63
64
  try {
@@ -65,6 +65,8 @@ export function buildPrTitle(feature, fj) {
65
65
  return prefix + (raw.length <= room ? raw : `${raw.slice(0, room - 1)}…`);
66
66
  }
67
67
 
68
+ const MEMORY_NOTE = 'o fechamento desta feature já está registrado no repositório (commit de archive, `decisions.md`, use cases promovidos, `git log`) — memória de sessão só para o que NENHUM deles guarda: decisão do usuário, pendência a observar em produção, custo medido.';
69
+
68
70
  /**
69
71
  * `morph-spec finish <feature> --pr|--merge` — the single, symmetric close of a
70
72
  * feature's lifecycle, for BOTH the worktree flow and the common flow.
@@ -132,6 +134,8 @@ export async function finishCommand(feature, options = {}) {
132
134
  const emit = (obj) => {
133
135
  if (resumed) obj = { ...obj, resumed: true };
134
136
  if (obj?.finished === true) {
137
+ // The one path every finished:true takes; the --pr payload is built by hand, outside mergeOutput.
138
+ obj = { ...obj, memoryNote: MEMORY_NOTE };
135
139
  try {
136
140
  const common = execFileSync('git', ['rev-parse', '--git-common-dir'], { cwd: root, stdio: 'pipe' }).toString().trim();
137
141
  if (common) releaseClaim(feature, { gitCommonDir: resolve(root, common) });
@@ -8,6 +8,7 @@ import { collectScreenChecklist } from '../../lib/specs/screen-checklist.js';
8
8
  import { persistGateDecision, VALID_GATES, readTrustFromConfig } from './gate-decision.js';
9
9
  import { minimatch } from 'minimatch';
10
10
  import { detectDefaultBranch, getHeadSha, getDiffStat, changedPathsAgainst } from '../../lib/git/default-branch.js';
11
+ import { treeMovedSince } from '../../lib/git/evidence-staleness.js';
11
12
  import { readMorphConfig } from '../../lib/tasks/test-runner.js';
12
13
  import { oversizedTasks } from '../../lib/tasks/tasks-json-parser.js';
13
14
  import { assertOwnerTree, ownerTreeRefusalLines } from '../../lib/worktree/feature-truth.js';
@@ -283,7 +284,7 @@ function collectSignals(featureName, feature, cwd, gateName) {
283
284
  // stamp com sha null (checkout sem git) ou stamp ausente lê como NOT stale.
284
285
  signals.verification = verification;
285
286
  if (verification?.status) signals.verificationStatus = verification.status;
286
- signals.verificationStale = Boolean(verification?.sha && sha && verification.sha !== sha);
287
+ signals.verificationStale = treeMovedSince(cwd, verification?.sha, sha);
287
288
 
288
289
  // O nó e2e, separado do rollup de propósito: um `skip` NÃO reprova
289
290
  // `verification.status`, então sem estas duas chaves o gate lê 'pass' tanto
@@ -129,6 +129,13 @@ function decidePersistence({ cwd, featureName, noPersist }) {
129
129
  return { persist: false, reason: samePath(root, cwd) ? 'owner-tree-elsewhere' : 'unregistered-worktree' };
130
130
  }
131
131
 
132
+ function baselineStampBlock(baseline) {
133
+ if (!baseline?.recorded) return baseline?.reason ? `record-baseline: ${baseline.reason}` : 'record-baseline';
134
+ const fell = baseline.updates.filter((u) => u.from !== null && u.to < u.from);
135
+ if (fell.length === 0) return null;
136
+ return `record-baseline: a contagem caiu em ${fell.map((u) => `${u.id} (${u.from} → ${u.to})`).join(', ')}`;
137
+ }
138
+
132
139
  /**
133
140
  * The per-node status map, in the shape `feature.schema.json` fixes for BOTH
134
141
  * writers of it — the per-scope history entry (`verifications.*`) and the
@@ -277,30 +284,15 @@ export async function verifyCommand(featureName, taskId, options = {}) {
277
284
  });
278
285
 
279
286
  const scopeKey = taskId ? `taskVerifications.${taskId}` : 'verifications.feature';
280
- // `--record-baseline` is config maintenance, not gate evidence: it measures
281
- // the suite in order to write `expectedCount`. Persisting a `verification`
282
- // stamp from it would let a maintenance run stand in for the proof a gate
283
- // asks for — the same class of confusion as a hand-typed baseline.
284
- //
285
- // `--filter` at FEATURE scope is the other side of the same hole (#87): the
286
- // run is narrowed to a subset of nodes, but the stamp it would write carries
287
- // `scope: 'feature'` — the one scope risk-detector.js trusts unconditionally.
288
- // A partial run must not stand in for the proof a gate asks for, same as
289
- // `--record-baseline` above. `--skip-tests` and `--skip-build` are the same
290
- // partial run through another door (#113): a skipped node never reddens the
291
- // rollup, so the stamp said `pass` over a suite (or a build) that never ran,
292
- // and no gate rule reads a skipped `tests`/`build`. Task scope is unaffected:
293
- // that stamp already says `scope: 'task:{id}'` (which pauses the ship gate on
294
- // its own), and `taskVerifications` never claimed to cover more than the
295
- // task. Checked AFTER `--no-persist` (via the `!options.noPersist` guard) so
296
- // an explicit `--no-persist` keeps its own reason instead of being reported
297
- // as one of these — the flags are all "don't write", but decidePersistence()
298
- // already owns the former's reason.
287
+ // A run that measured only part of the suite must not stand in for the proof a gate asks for:
288
+ // --filter and --skip-* at feature scope, or a --record-baseline that recorded nothing. Task scope
289
+ // is unaffected (its stamp already says scope 'task:{id}'); --no-persist keeps its own reason.
299
290
  const partialFeatureRun = (!options.noPersist && !taskId)
300
291
  ? PARTIAL_RUN_FLAGS.find(([flag]) => options[flag])?.[1] ?? null
301
292
  : null;
302
- const { persist, reason } = options.recordBaseline
303
- ? { persist: false, reason: 'record-baseline' }
293
+ const baselineBlock = options.recordBaseline ? baselineStampBlock(result.baseline) : null;
294
+ const { persist, reason } = baselineBlock
295
+ ? { persist: false, reason: baselineBlock }
304
296
  : partialFeatureRun
305
297
  ? { persist: false, reason: partialFeatureRun }
306
298
  : decidePersistence({ cwd: process.cwd(), featureName, noPersist: options.noPersist });
@@ -23,7 +23,7 @@ import { join } from 'path';
23
23
  import chalk from 'chalk';
24
24
 
25
25
  import { loadEnvFile } from '../../../framework/hooks/shared/e2e-check.js';
26
- import { allocateEnvBlock, portLayout, resolveFleetConfig } from '../worktree/env-allocator.js';
26
+ import { allocateEnvBlock, portLayout, resolveFleetConfig, resolvePortAliases } from '../worktree/env-allocator.js';
27
27
 
28
28
  /**
29
29
  * Build the environment a `docker compose` invocation should carry.
@@ -69,8 +69,11 @@ export function buildComposeEnv({ cwd, config, featureName, gitCommonDir }) {
69
69
  const { basePort, blockSize } = resolveFleetConfig(cwd);
70
70
  const block = allocateEnvBlock(featureName, { gitCommonDir, basePort, blockSize });
71
71
 
72
+ const { aliases, problems } = resolvePortAliases(config);
73
+ for (const problem of problems) console.warn(chalk.yellow(` ⚠ ${problem} — ignorado`));
74
+
72
75
  const layout = {
73
- ...portLayout(block, { stack: config?.project?.stack ?? '', feature: featureName }),
76
+ ...portLayout(block, { stack: config?.project?.stack ?? '', feature: featureName, aliases }),
74
77
  PLAYWRIGHT_BASE_URL: `http://localhost:${block.base}`,
75
78
  };
76
79
  Object.assign(env, layout);
@@ -0,0 +1,39 @@
1
+ import { execFileSync } from 'child_process';
2
+ import { isDeepStrictEqual } from 'node:util';
3
+ import { getHeadSha } from './default-branch.js';
4
+
5
+ const MORPH_DIR = '.morph/';
6
+ const CONFIG_PATH = '.morph/config/config.json';
7
+
8
+ const gitOut = (cwd, args) => execFileSync('git', args, { cwd, stdio: 'pipe', maxBuffer: 64 * 1024 * 1024 }).toString();
9
+
10
+ function withoutBaseline(text) {
11
+ const config = JSON.parse(text);
12
+ if (Array.isArray(config?.project?.tests)) {
13
+ for (const node of config.project.tests) delete node?.expectedCount;
14
+ }
15
+ return config;
16
+ }
17
+
18
+ function configMoved(cwd, sha, head) {
19
+ try {
20
+ const before = withoutBaseline(gitOut(cwd, ['show', `${sha}:${CONFIG_PATH}`]));
21
+ const after = withoutBaseline(gitOut(cwd, ['show', `${head}:${CONFIG_PATH}`]));
22
+ return !isDeepStrictEqual(before, after);
23
+ } catch {
24
+ return true;
25
+ }
26
+ }
27
+
28
+ // `.morph/` is bookkeeping, except the config: it names the test commands, and only its
29
+ // expectedCount is something a baseline run writes.
30
+ export function treeMovedSince(cwd, sha, head = getHeadSha(cwd)) {
31
+ if (!sha || !head || sha === head) return false;
32
+ let changed;
33
+ try {
34
+ changed = gitOut(cwd, ['diff', '--name-only', '--no-renames', '-z', sha, head]).split('\0').filter(Boolean);
35
+ } catch {
36
+ return true;
37
+ }
38
+ return changed.some((path) => !path.startsWith(MORPH_DIR) || (path === CONFIG_PATH && configMoved(cwd, sha, head)));
39
+ }