@polymorphism-tech/morph-spec 8.21.0 → 8.22.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 (32) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +29 -1
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -1
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/CLAUDE.md +2 -1
  7. package/framework/MORPH.md +6 -2
  8. package/framework/agents.json +2 -1
  9. package/framework/docs/CLI.md +2 -1
  10. package/framework/hooks/shared/e2e-check.js +0 -0
  11. package/framework/schemas/feature.schema.json +5 -3
  12. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  13. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  14. package/framework/skills/core/morph-apply/SKILL.md +2 -2
  15. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  16. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  17. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  18. package/framework/skills/core/morph-implement/SKILL.md +1 -1
  19. package/framework/skills/core/morph-plan/SKILL.md +22 -1
  20. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  21. package/framework/skills/core/morph-review/SKILL.md +32 -6
  22. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  23. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  24. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  25. package/framework/templates/infrastructure/docker/docker-compose.template.yml +29 -5
  26. package/package.json +1 -1
  27. package/src/commands/e2e/e2e.js +318 -0
  28. package/src/commands/state/finish.js +38 -0
  29. package/src/commands/validation/validate-feature.js +7 -1
  30. package/src/commands/validation/verify.js +7 -0
  31. package/src/lib/e2e/compose-env.js +74 -0
  32. package/src/lib/validators/verify-runner.js +121 -2
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:** 8.21.0
6
+ **Version:** 8.22.0
7
7
  **Requires:** Node.js 22+, Claude Code
8
8
 
9
9
  ---
@@ -368,4 +368,4 @@ Code generated by morph-spec (contracts, templates, implementation output) belon
368
368
 
369
369
  ---
370
370
 
371
- *morph-spec v8.21.0 by [Polymorphism Tech](https://polymorphism.tech)*
371
+ *morph-spec v8.22.0 by [Polymorphism Tech](https://polymorphism.tech)*
package/bin/morph-spec.js CHANGED
@@ -50,6 +50,7 @@ import { dagCommand } from '../src/commands/tasks/dag.js';
50
50
  import { validateCommand } from './validate.js';
51
51
  import { validateFeatureCommand } from '../src/commands/validation/validate-feature.js';
52
52
  import { verifyCommand } from '../src/commands/validation/verify.js';
53
+ import { e2eUpCommand, e2eStatusCommand, e2eDownCommand } from '../src/commands/e2e/e2e.js';
53
54
 
54
55
  // === Scaffolding ===
55
56
  import { scaffoldDotnetCommand, scaffoldNextjsCommand } from '../src/commands/scaffold/scaffold.js';
@@ -293,12 +294,14 @@ program
293
294
 
294
295
  program
295
296
  .command('verify <feature> [task]')
296
- .description('Run the deterministic verification chain (build + tests + validators) — code before the LLM judge')
297
+ .description('Run the deterministic verification chain (build + tests + validators + e2e) — code before the LLM judge')
297
298
  .option('--json', 'Output as JSON')
298
299
  .option('--rubric <id>', 'Force a rubric (default: by task group → config → default)')
299
300
  .option('--timeout <ms>', 'Build timeout in ms (default 120000)', (v) => parseInt(v, 10))
300
301
  .option('--skip-tests', 'Skip the test node')
301
302
  .option('--skip-build', 'Skip the build node')
303
+ .option('--skip-e2e', 'Skip the e2e node (Docker bring-up + Playwright)')
304
+ .option('--fresh-e2e', 'Rebuild the e2e stack instead of reusing a warm one')
302
305
  .option('--no-persist', 'Do not persist the result to feature.json')
303
306
  .action((feature, task, options) =>
304
307
  verifyCommand(feature, task, { ...options, noPersist: options.persist === false })
@@ -351,6 +354,31 @@ contextCommand
351
354
  .option('--json', 'Output scan result as JSON')
352
355
  .action((options) => contextRefreshCommand(options));
353
356
 
357
+ // ─── E2E stack (Docker bring-up for runtime verification) ─────────────────
358
+ const e2eCommand = program
359
+ .command('e2e')
360
+ .description("Lifecycle of the feature's verification stack (docker compose)");
361
+
362
+ e2eCommand
363
+ .command('up <feature>')
364
+ .description('Bring the stack up (reusing a warm one when it still matches the working tree)')
365
+ .option('--fresh', 'Rebuild instead of reusing a warm stack')
366
+ .option('--json', 'Output as JSON')
367
+ .action((feature, options) => e2eUpCommand(feature, options));
368
+
369
+ e2eCommand
370
+ .command('status <feature>')
371
+ .description('Is the stack up, healthy, and built from the current source? Exit 0 only if all three')
372
+ .option('--json', 'Output as JSON')
373
+ .action((feature, options) => e2eStatusCommand(feature, options));
374
+
375
+ e2eCommand
376
+ .command('down <feature>')
377
+ .description('Tear the stack down (best-effort — never blocks)')
378
+ .option('--volumes', 'Also remove named volumes')
379
+ .option('--json', 'Output as JSON')
380
+ .action((feature, options) => e2eDownCommand(feature, options));
381
+
354
382
  // ─── Worktree (optional, isolation) ───────────────────────────────────────
355
383
  const worktreeCommand = program
356
384
  .command('worktree')
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "morph-spec",
3
- "version": "8.21.0",
3
+ "version": "8.22.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 v8.21.0 by Polymorphism Tech*
188
+ *morph-spec v8.22.0 by Polymorphism Tech*
@@ -249,4 +249,4 @@ morph-spec doctor
249
249
 
250
250
  ---
251
251
 
252
- *morph-spec v8.21.0 by Polymorphism Tech*
252
+ *morph-spec v8.22.0 by Polymorphism Tech*
@@ -65,7 +65,8 @@ Se não houver feature ativa, pergunte ao usuário qual feature trabalhar.
65
65
  | `fleet` | Visão agregada de toda feature ativa (raiz + worktrees) |
66
66
  | `claim [f] [--release] [--list]` | Posse advisory — confirme antes de `finish`/`archive`/`delete` se outra sessão tiver claim vivo |
67
67
  | `dag <f>` | DAG de `tasks.json`: sequencial/paralelo, recomenda isolamento |
68
- | `verify <f> [task] [--json]` | Build+testes+validadores, antes do juiz LLM |
68
+ | `verify <f> [task] [--json]` | Build+testes+validadores+e2e, antes do juiz LLM |
69
+ | `e2e up\|status\|down <f>` | Stack de verificação (docker compose) |
69
70
  | `doctor --worktrees [--prune]` | Reconcilia git × disco × features; só `--prune` remove algo |
70
71
  | `report-check <f> [task] [--json]` | Audita relatório de sub-agent vs. trabalho entregue |
71
72
  | `telemetry [--feature] [--limit]` | Caminho de execução da sessão a partir de `events.jsonl` |
@@ -148,7 +148,7 @@ Não invente caminhos alternativos.
148
148
 
149
149
  - `spec.md` — especificação técnica: arquitetura, componentes, integrações, fluxos de dados, edge cases **técnicos**. Responde **como** os use cases serão realizados — não repete o comportamento. Deve bastar para implementar sem perguntas básicas: tecnologia listada sem explicar como os componentes se conectam é um spec.md fraco.
150
150
 
151
- - `mandate.md` — contrato de implementação: stack exata, standards obrigatórios (com links), patterns proibidos, checklist de done verificável. Lido no início de cada task — específico o bastante para resolver ambiguidades sem consultar o Orchestrator.
151
+ - `mandate.md` — contrato de implementação: stack exata, standards obrigatórios (com links), patterns proibidos, **verificação de runtime** (como o stack sobe, que fluxos são exercitados, quais viram spec Playwright, setup de auth) e checklist de done verificável. Lido no início de cada task — específico o bastante para resolver ambiguidades sem consultar o Orchestrator.
152
152
 
153
153
  - `tasks.json` — validada contra `framework/schemas/tasks.schema.json`. Cada task: `id` (T1, T2...), `title`, `description`, `dependencies` (IDs), `effort` (S/M/L/XL), `doneCriteria`, `status` (pending/in_progress/done/skipped/blocked), `outputs` (paths esperados), `usecase` (opcional — **ID** do use case, `UC-07`, nunca o nome do arquivo). `doneCriteria` de task com `usecase` carrega a **pós-condição literal**, com rótulo (`"P2 — cada veterinário traz..."`). Toda entrada do recap.md referencia um ID do tasks.json.
154
154
 
@@ -218,6 +218,8 @@ Gates são pontos de **pausa potencial**. Em `trust: "auto"` (default), auto-pas
218
218
  | `designSystemDivergence` | true | Brainstorming detecta fontes conflitantes (spec antigo vs bundle local) |
219
219
  | `standardsViolations` | array.length > 0 | Mandate ou implementation viola padrões proibidos |
220
220
  | `evalScore` | < 9 | score composto da feature (via morph-review) abaixo do mínimo |
221
+ | `verificationStatus` | === 'fail' | `morph-spec verify` vermelho — build, testes, validadores **ou e2e** (a app não sobe / as specs não passam). Vale em **qualquer** gate |
222
+ | `verificationStale` | true | Stamp do `verify` com sha != HEAD: o verde não cobre mais o código. Só no **ship** (Gate 3) |
221
223
  | `taskCount` | > 20 | Escopo grande — provavelmente vale revisar antes de implementar |
222
224
  | `workType` | === 'hotfix' | Gate 3 pausa SEMPRE (detalhe abaixo, "Gates por tipo de trabalho") |
223
225
 
@@ -225,7 +227,9 @@ Gates são pontos de **pausa potencial**. Em `trust: "auto"` (default), auto-pas
225
227
  | ------ | -------- | -------------------- |
226
228
  | 1 — Proposta | `designSystemDivergence`, `standardsViolations` (improvável) | `morph-brainstorming` |
227
229
  | 2 — Plano | Gate 1 + `taskCount`, `standardsViolations` em mandate | `morph-plan` |
228
- | 3 — Review | `evalScore` composite, `standardsViolations` em implementation | `morph-review` |
230
+ | 3 — Review | `evalScore` composite, `standardsViolations` em implementation, `verificationStale` | `morph-review` |
231
+
232
+ `verificationStatus` não está numa linha só porque vale em todas — verify vermelho pausa qualquer gate.
229
233
 
230
234
  **Gates por tipo de trabalho:** `chore`/`hotfix` pulam Gates 1 e 2 — carimbados por política no nascimento (`approvedBy: "policy:{tipo}"`, nunca `"auto"`, para não colidir com o gate-guard); a barra do morph-eval e o Gate 3 valem para todos os tipos. `hotfix`: Gate 3 **sempre humano** (mesmo em `trust: auto` — `morph-hotfix` pausa via `AskUserQuestion`, o `risk-detector` força, o backstop server-side recusa `approve ... review --mode auto`) e exige `4-review/review-report.md` em vez de `evaluator-report.md`. Demais tipos exigem `evaluator-report.md`, **enforced server-side** (`morph-spec approve {feature} review` recusa exit 1 sem ele; `--force` não dispensa) — ou `--self-assessed` registrado (`approvalGates.review.selfAssessed`, marcado no `morph-spec list`).
231
235
 
@@ -144,8 +144,9 @@
144
144
  "stack": ["*"],
145
145
  "standards": [],
146
146
  "tools": "Read, Glob, Grep, Bash",
147
+ "mcp": ["playwright"],
147
148
  "model": "opus",
148
- "persona": "Você aplica as rubricas em .morph/framework/evals/rubrics/. Você não participou da implementação — avalia o mandate, o diff e a saída real dos testes, com contexto fresco. O orquestrador anexa o resultado do `morph-spec verify`, mas você tem Bash para conferir por conta própria: rode `morph-spec verify {feature} [task]`, testes/build do stack, `git log/diff/show` — nunca confie cegamente no que foi reportado. Bash é só para verificação: NUNCA para modificar código-fonte, tasks.json, feature.json ou o board de sessão — quem faz isso é o loop de correção, não você. Não reescreve código — produz scores 0-10 por dimensão (architecture, contracts, code-quality, test-coverage), feedback priorizado acionável, e ação clara (aprovado em >=9 / feedback para o loop de correção em <9). Silêncio em scores ≥ 9 é o sinal correto."
149
+ "persona": "Você aplica as rubricas em .morph/framework/evals/rubrics/. Você não participou da implementação — avalia o mandate, o diff e a saída real dos testes, com contexto fresco. O orquestrador anexa o resultado do `morph-spec verify`, mas você tem Bash para conferir por conta própria: rode `morph-spec verify {feature} [task]`, testes/build do stack, `git log/diff/show` — nunca confie cegamente no que foi reportado. Bash é só para verificação: NUNCA para modificar código-fonte, tasks.json, feature.json ou o board de sessão — quem faz isso é o loop de correção, não você. Você também tem Playwright: quando o nó `e2e` do verify subiu o stack, NAVEGUE a aplicação como um usuário faria e percorra os fluxos da feature — ler o diff prova que o código existe, usar a aplicação prova que ela funciona. Confira console e network atrás de erro que nenhum teste cobre. Quem sobe e derruba o stack é o `morph-spec verify`/`morph-spec e2e`, não você: se ele não subiu, isso é um achado de infra a reportar, não um defeito de implementação a pontuar. Se um fluxo que você exercitou merece virar regressão, recomende a spec Playwright no relatório — recomendar é seu papel, escrever não. Não reescreve código — produz scores 0-10 por dimensão (architecture, contracts, code-quality, test-coverage), feedback priorizado acionável, e ação clara (aprovado em >=9 / feedback para o loop de correção em <9). Silêncio em scores ≥ 9 é o sinal correto."
149
150
  },
150
151
  "scout": {
151
152
  "title": "Codebase Scout",
@@ -31,7 +31,8 @@
31
31
  ## Validação
32
32
 
33
33
  - **`morph-spec dag <feature>`** — análise do DAG de tasks.json com decisão sequencial vs paralelo **ponderada por effort** (raízes S vão inline, nunca ganham agente próprio). Também cruza `tasks.json` com os use cases: `usecase` apontando para um `UC-{nn}` inexistente é **erro** (exit 1, não despacha); pós-condição órfã e `doneCriteria` que parafraseou em vez de copiar são avisos. `--mermaid` nunca é bloqueado.
34
- - **`morph-spec verify <feature> [task] [--json]`** — cadeia determinística build+testes+validadores (código antes do juiz LLM); escopo de task quando informado, senão a feature inteira.
34
+ - **`morph-spec verify <feature> [task] [--json] [--skip-e2e] [--fresh-e2e]`** — cadeia determinística build+testes+validadores+**e2e** (código antes do juiz LLM); escopo de task quando informado, senão a feature inteira. O nó `e2e` é o único que **executa** a aplicação: sobe o `docker compose` do projeto, espera todo serviço ficar saudável e roda as specs Playwright quando existem (sem specs, ainda vale como smoke — "a app sobe" já pega o serviço não registrado que derruba o startup e passa batido no build). Aplicabilidade: escopo de feature (Gate 3) **sempre** roda; escopo de task roda para `group` `frontend`/`backend`/`infra` e dispensa `tests`/`docs`. **Dispensa legítima × setup faltando:** projeto sem compose e sem a integração `docker` é `skip` com razão; projeto que **declara** `docker` e não tem compose é `fail` — é verificação que nunca foi montada, não projeto que não containeriza.
35
+ - **`morph-spec e2e up|status|down <feature>`** — ciclo de vida do stack de verificação, compartilhando toda a lógica com o nó `e2e` do verify. `up [--fresh]` reusa um stack quente **só** quando ele provadamente serve o código atual: o carimbo compara `HEAD` + `git status --porcelain` + `git diff HEAD`, então qualquer edição, commitada ou não, invalida o reuso (a exceção é o serviço com bind mount do código, que serve o disco por construção). Na dúvida, rebuild — o layer cache do Docker faz disso barato. `status [--json]` responde a pergunta que decide se um verde vale alguma coisa (no ar? saudável? construído do código atual?) e sai 0 só com os três; o `baseUrl` do payload é o endereço real da app — é dele que a `morph-review` tira a URL que vai no prompt do avaliador, em vez de deixá-lo adivinhar `localhost:3000`. `down [--volumes]` é best-effort e nunca bloqueia. Isolamento entre worktrees: `COMPOSE_PROJECT_NAME=morph-{feature}` separa os containers e o bloco de portas do fleet (`env-allocator`) separa as portas — um compose com porta fixa não pode ser isolado, e isso é avisado. O bloco vale nos **dois** caminhos (este comando e o nó do `verify`): `PORT`, `MORPH_PORT_BASE`, `ASPNETCORE_URLS` em dotnet, mais `PLAYWRIGHT_BASE_URL` apontando para a porta alocada. Por isso o `playwright.config.ts` precisa ler a base URL do ambiente em vez de cravá-la — com a URL cravada, a suíte bate numa porta vazia e o `ECONNREFUSED` se disfarça de app quebrada.
35
36
  - **`morph-spec doctor --worktrees [--prune] [--json]`** — reconcilia o registro do git × as pastas em disco × as features conhecidas. Worktrees órfãos acumulavam **invisíveis**: o `fleet` só reporta worktrees em branch `morph/*`, então um worktree em outra branch (resíduo de finish anterior) não aparecia em lugar nenhum. `--prune` roda o único reparo seguro (`git worktree prune` — registros de pastas que já sumiram); apagar pasta que ainda existe é reportado com o comando exato, nunca executado.
36
37
  - **`morph-spec report-check <feature> [task] [--json]`** — audita os relatórios de sub-agent contra o trabalho que realmente entrou. **Relatório ausente NÃO quer dizer que o agente falhou** — ele pode ter feito tudo e perdido o meio de gravar. O comando responde a pergunta certa (`work-landed-report-missing` → aceite degradado, NÃO re-despache; `no-work-no-report` → re-despache uma vez). Protocolo completo em MORPH.md §6.
37
38
 
@@ -106,7 +106,8 @@
106
106
  "properties": {
107
107
  "build": { "type": "string", "enum": ["pass", "fail", "skip"] },
108
108
  "tests": { "type": "string", "enum": ["pass", "fail", "skip"] },
109
- "validators": { "type": "string", "enum": ["pass", "fail", "skip"] }
109
+ "validators": { "type": "string", "enum": ["pass", "fail", "skip"] },
110
+ "e2e": { "type": "string", "enum": ["pass", "fail", "skip"], "description": "Docker bring-up + Playwright specs against the running stack. Absent on entries written before the node existed — which is why it is NOT in `required`." }
110
111
  },
111
112
  "additionalProperties": false
112
113
  },
@@ -132,7 +133,8 @@
132
133
  "properties": {
133
134
  "build": { "type": "string", "enum": ["pass", "fail", "skip"] },
134
135
  "tests": { "type": "string", "enum": ["pass", "fail", "skip"] },
135
- "validators": { "type": "string", "enum": ["pass", "fail", "skip"] }
136
+ "validators": { "type": "string", "enum": ["pass", "fail", "skip"] },
137
+ "e2e": { "type": "string", "enum": ["pass", "fail", "skip"], "description": "Docker bring-up + Playwright specs against the running stack. Absent on entries written before the node existed — which is why it is NOT in `required`." }
136
138
  },
137
139
  "additionalProperties": false
138
140
  },
@@ -147,7 +149,7 @@
147
149
  },
148
150
  "verification": {
149
151
  "type": "object",
150
- "description": "The single latest deterministic verify stamp (any scope), written ONLY by `morph-spec verify`. Distinct from `verifications`/`taskVerifications` above (per-scope history with attempts/nodes): this is overwritten by every verify call regardless of scope, carrying just enough to correlate a pass/fail with the exact commit it covers. Primary consumer: `morph-spec gate-check`, which surfaces it as a signal (informational — no shouldPauseGate rule keys on it directly).",
152
+ "description": "The single latest deterministic verify stamp (any scope), written ONLY by `morph-spec verify`. Distinct from `verifications`/`taskVerifications` above (per-scope history with attempts/nodes): this is overwritten by every verify call regardless of scope, carrying just enough to correlate a pass/fail with the exact commit it covers. Primary consumer: `morph-spec gate-check`, which feeds `status` to shouldPauseGate as `verificationStatus` — a red verify pauses the gate (risk-detector.js). The remaining fields (sha/ts/scope, and the derived `verificationStale`) are audit context, not pause rules.",
151
153
  "required": ["status", "sha", "ts", "scope"],
152
154
  "properties": {
153
155
  "status": { "type": "string", "enum": ["pass", "fail"] },
@@ -4,7 +4,7 @@ description: Guia a criação de uma landing page nova ponta-a-ponta a partir do
4
4
  version: core
5
5
  when-to-invoke: ao iniciar um novo projeto de LP com o template Umbraco genérico
6
6
  user-invokable: true
7
- cliVersion: "8.21.0"
7
+ cliVersion: "8.22.0"
8
8
  stacks:
9
9
  - umbraco
10
10
  ---
@@ -4,7 +4,7 @@ description: Migra uma landing page existente (React/Vite, Next.js estático ou
4
4
  version: core
5
5
  when-to-invoke: ao receber instrução "use /migrate-lp-to-umbraco {projeto}" do usuário, ou quando o usuário descrever necessidade de migrar uma LP para Umbraco
6
6
  user-invokable: true
7
- cliVersion: "8.21.0"
7
+ cliVersion: "8.22.0"
8
8
  stacks:
9
9
  - umbraco
10
10
  ---
@@ -4,7 +4,7 @@ description: Orquestra a execução autônoma de tasks de uma feature morph-spec
4
4
  version: core
5
5
  when-to-invoke: após Gate 2 aprovado, ao receber instrução "use /morph-apply" ou "implementar feature"
6
6
  user-invokable: true
7
- cliVersion: "8.21.0"
7
+ cliVersion: "8.22.0"
8
8
  ---
9
9
 
10
10
  # morph-apply
@@ -246,7 +246,7 @@ re-apresente o gate**: siga direto para o encerramento (§6) conforme a decisão
246
246
  - `framework/skills/core/morph-review/SKILL.md`
247
247
  - `framework/skills/core/morph-plan/SKILL.md` §5a — resolve e carimba `task.agent`/`task.standards` no plano; fonte primária deste §3b
248
248
  - `morph-spec dag {feature}` — análise de DAG e decisão sequencial vs paralelo (ponderada por effort), incluindo recomendação de isolamento (`isolation.recommended: "worktree"`)
249
- - `morph-spec verify {feature} [task] --json` — cadeia determinística build+testes+validadores (código antes do juiz)
249
+ - `morph-spec verify {feature} [task] --json` — cadeia determinística build+testes+validadores+e2e (código antes do juiz)
250
250
  - `morph-spec score {feature} {task} {score} [--dims a,c,q,t]` — grava taskScores no feature.json com timestamp server-side
251
251
  - `morph-spec gate-check {feature} {gate} [--json]` — default: coleta sinais do disco, decide via `shouldPauseGate`, persiste `gateDecisions`
252
252
  - `morph-spec gate-decision {feature} {gate} --pause {bool} [--reason ...] [--signals ...]` — escape manual: risco que o `gate-check` não vê no disco (shape em `.morph/framework/schemas/feature.schema.json`)
@@ -4,7 +4,7 @@ description: Entende o negócio, os usuários e a arquitetura antes de qualquer
4
4
  version: core
5
5
  when-to-invoke: ao receber um novo request de feature, antes de morph-plan
6
6
  user-invokable: true
7
- cliVersion: "8.21.0"
7
+ cliVersion: "8.22.0"
8
8
  ---
9
9
 
10
10
  # morph-brainstorming
@@ -4,7 +4,7 @@ description: Avalia a qualidade de uma tarefa ou fase com rubricas graduadas 0-1
4
4
  version: core
5
5
  when-to-invoke: ao completar uma tarefa ou ao finalizar uma fase de implementação
6
6
  user-invokable: false
7
- cliVersion: "8.21.0"
7
+ cliVersion: "8.22.0"
8
8
  ---
9
9
 
10
10
  # morph-eval
@@ -4,7 +4,7 @@ description: ADW cirúrgico de hotfix — resolve uma queda/bug crítico de prod
4
4
  version: core
5
5
  when-to-invoke: ao receber "/morph-hotfix {feature}", quando a morph-proposal classifica o pedido como workType hotfix (Passo 0), ou o usuário diz "produção caiu / hotfix / urgente"
6
6
  user-invokable: true
7
- cliVersion: "8.21.0"
7
+ cliVersion: "8.22.0"
8
8
  ---
9
9
 
10
10
  # morph-hotfix
@@ -4,7 +4,7 @@ description: Guia a execução de cada tarefa de implementação com standard e
4
4
  version: core
5
5
  when-to-invoke: ao iniciar cada task de implementação listada em tasks.json
6
6
  user-invokable: false
7
- cliVersion: "8.21.0"
7
+ cliVersion: "8.22.0"
8
8
  ---
9
9
 
10
10
  # morph-implement
@@ -4,7 +4,7 @@ description: Guia a produção dos artefatos de planejamento de uma feature (use
4
4
  version: core
5
5
  when-to-invoke: ao iniciar a fase plan de uma feature, após o Gate 1 (proposal aprovado)
6
6
  user-invokable: true
7
- cliVersion: "8.21.0"
7
+ cliVersion: "8.22.0"
8
8
  ---
9
9
 
10
10
  # morph-plan
@@ -88,6 +88,27 @@ Ao iniciar a fase plan — após o `proposal.md` ser aprovado e antes de qualque
88
88
  - Patterns proibidos — **copie por extenso no mandate.md** a lista canônica de `MORPH.md` § "Stack baked-in" (`Guid.NewGuid()`→`Guid.CreateVersion7()`, classes não-sealed sem justificativa, exceptions como fluxo de controle, camada de Service/Manager entre handler e dados, repository genérico `IRepository<T>`/`IUnitOfWork`, SQL cru sem justificativa) mais qualquer proibição específica da feature. Copie o texto completo — o mandate.md é lido por implementadores em contexto fresco, sem `MORPH.md` carregado, então um ponteiro sozinho não basta aqui.
89
89
  - Política de acesso a dados (registre no mandate): `ApplicationDbContext` injetado direto no slice — para CRUD e para queries específicas (joins, projeção, paginação). Para concorrência ou SQL específico, store dedicado hand-written com justificativa em `decisions.md`.
90
90
  - **Features com agentes de IA (MAF):** se a feature envolve um agente (Microsoft Agent Framework), **use a tool Read** nos standards relevantes de `framework/standards/ai-agents/*.md` (ex.: `setup`, `agent-session`, `middleware-patterns`, `mcp-server`, `mcp-tools`, `multi-agent-patterns`, `production`) — filtre pelo que a feature exige via `morph-spec standards --find "<tema>" --category ai-agents`. Esses standards carregam as **convenções de projeto** (single-agent por padrão — Workflows só para fan-in/out real; provider, caching, observability, A2A) que uma fonte externa de docs (ex.: context7) não conhece. Padrão de divisão: **superfície da API** pode vir de docs ao vivo/context7 (mais frescas para um SDK em evolução), mas **as convenções vêm dos standards** — registre ambas no mandate. Não pule os `ai-agents/*.md` só porque o STANDARDS.json index lista que eles existem.
91
+ - **Verificação de runtime** — como se PROVA que a feature funciona rodando, não só compilando.
92
+ Compilar não é subir: um serviço injetado sem registro em `Program.cs` passa no build e derruba
93
+ a aplicação no startup. Registre no mandate:
94
+ - **Como o stack sobe:** o `docker compose` do projeto (`morph-spec e2e up {feature}`). Se o
95
+ projeto containeriza mas não tem compose, **isso é uma task** — sem ele o nó `e2e` do verify
96
+ reprova (não dispensa).
97
+ - **O que precisa ser exercitado:** os fluxos desta feature que o avaliador vai navegar, em
98
+ linguagem de comportamento — os mesmos verbos das pós-condições dos use cases.
99
+ - **Quais viram spec Playwright versionada:** caminho crítico e bug corrigido merecem regressão;
100
+ exploração pontual não. O critério vai aqui; escrever a spec é tarefa da implementação.
101
+ - **Base URL vinda do ambiente:** o harness dá a cada feature um bloco de portas próprio (para
102
+ duas worktrees não brigarem pela porta 3000), então o `playwright.config.ts` **não pode**
103
+ cravar a URL — leia `process.env.PLAYWRIGHT_BASE_URL ?? \`http://localhost:${process.env.PORT ?? 3000}\``
104
+ e faça o compose publicar `"${PORT:-3000}:3000"`. Config cravada = suíte batendo em porta
105
+ vazia e `ECONNREFUSED` com cara de app quebrada. Se estiver cravado, **isso é uma task**.
106
+ - **Setup de autenticação**, se a aplicação tem login: `e2e.auth` em `config.json`
107
+ (`mode: seed|storageState|bypass|none`, `seedCommand`, `storageStatePath`, `envFile`), com as
108
+ credenciais num `.env.e2e` **gitignored** — nunca no config commitado. **Se a app tem login e
109
+ isso não existe, crie a task de setup**: sem ela a navegação para na tela de login e a
110
+ verificação de runtime vira teatro. É gap de Gate 2, não dispensa de Gate 3.
111
+ - **Quando não se aplica** (biblioteca pura, chore sem runtime), registre a dispensa e o porquê.
91
112
  - Checklist de done com critérios verificáveis por tarefa.
92
113
 
93
114
  5. Produza `tasks.json` em `.morph/features/{feature}/2-plan/` seguindo o schema em `.morph/framework/schemas/tasks.schema.json`.
@@ -4,7 +4,7 @@ description: Orquestra a fase de proposta+plano de uma feature morph-spec. Encad
4
4
  version: core
5
5
  when-to-invoke: ao receber instrução "use /morph-proposal" do usuário, ou ao iniciar uma nova feature
6
6
  user-invokable: true
7
- cliVersion: "8.21.0"
7
+ cliVersion: "8.22.0"
8
8
  ---
9
9
 
10
10
  # morph-proposal
@@ -4,7 +4,7 @@ description: Conduz a fase de revisão e produz o review-report.md para o Gate 3
4
4
  version: core
5
5
  when-to-invoke: ao iniciar a fase review, após todas as tasks de tasks.json estarem com status `done`
6
6
  user-invokable: true
7
- cliVersion: "8.21.0"
7
+ cliVersion: "8.22.0"
8
8
  ---
9
9
 
10
10
  # morph-review
@@ -43,12 +43,22 @@ e o `recap.md` estar atualizado. Normalmente invocada pela skill orquestradora `
43
43
  outro propósito. Falha aqui é bloqueante — quebra silenciosa em runtime, não em build.
44
44
 
45
45
  4. **Despache o avaliador independente** — o score do Gate 3 nunca é auto-atribuído por quem implementou:
46
- - Rode `morph-spec verify {feature} --json` (escopo feature) e capture o JSON — é a saída real de build+testes+validadores que você anexa ao prompt do avaliador independente (`morph-evaluator`).
46
+ - Rode `morph-spec verify {feature} --json` (escopo feature) e capture o JSON — é a saída real de build+testes+validadores+e2e que você anexa ao prompt do avaliador independente (`morph-evaluator`).
47
+ - **Confira o nó `e2e` do verify antes de despachar.** Ele carrega o estado do stack:
48
+ - `pass` → a aplicação subiu e está saudável; o avaliador pode navegá-la. **Passe a URL no prompt**
49
+ — cada feature roda no seu bloco de portas, não em `localhost:3000`; pegue de
50
+ `morph-spec e2e status {feature} --json` (`baseUrl`) e não deixe o avaliador adivinhar.
51
+ - `fail` → **não abra o Gate 3.** Volte ao loop de correção: a app não sobe, ou as specs quebraram.
52
+ - `skip` → leia a razão e decida se a dispensa é legítima. "não containeriza" num projeto sem UI é
53
+ legítimo; "docker indisponível no ambiente" numa feature de UI **não** é — registre você mesmo
54
+ `morph-spec gate-decision {feature} review --pause true --reason "e2e não verificado: {razão}"`.
55
+ O disco não distingue dispensa legítima de verificação que faltou; esse julgamento é seu.
47
56
  - Gere o **diff da implementação** (`git diff` do range da feature, ou a lista de arquivos do recap.md com seus conteúdos atuais).
48
- - Despache o subagent registrado `morph-evaluator` (gerado de `agents.json` — a persona do avaliador é o system prompt dele, com Read/Glob/Grep + Bash somente para verificação, sem Write/Edit), com contexto fresco:
57
+ - Despache o subagent registrado `morph-evaluator` (gerado de `agents.json` — a persona do avaliador é o system prompt dele, com Read/Glob/Grep + Bash somente para verificação + Playwright para navegar, sem Write/Edit), com contexto fresco:
49
58
  ```
50
59
  dispatch Agent (subagent_type: "morph-evaluator") with prompt:
51
60
  contexto: mandate.md + spec.md + diff da implementação + saída real dos testes
61
+ + nó e2e do verify (stack no ar? em qual URL?)
52
62
  instrução: |
53
63
  Você NÃO participou da implementação — avalie o que está no diff,
54
64
  contra o mandate. Você tem Bash: use `morph-spec verify`, testes/build
@@ -56,6 +66,16 @@ e o `recap.md` estar atualizado. Normalmente invocada pela skill orquestradora `
56
66
  foi reportado, em vez de confiar cegamente. Aplique as rubricas em
57
67
  .morph/framework/evals/rubrics/ (architecture 30%, contracts 25%,
58
68
  code-quality 25%, test-coverage 20%) seguindo as bandas 0-10 de cada uma.
69
+ Se o nó e2e subiu o stack, NAVEGUE a aplicação com Playwright e
70
+ percorra os fluxos desta feature como um usuário faria — ler o diff
71
+ prova que o código existe, usar a aplicação prova que ela funciona.
72
+ Confira console e network atrás de erro que teste nenhum cobriu.
73
+ Registre no relatório o que você navegou e o que viu. Se um fluxo que
74
+ você exercitou merece virar regressão, RECOMENDE a spec Playwright —
75
+ recomendar é seu papel, escrever a spec não é.
76
+ NÃO suba nem derrube o stack por conta própria (`morph-spec e2e` e o
77
+ nó de verify fazem isso): stack que não sobe é achado de INFRA a
78
+ reportar, não defeito de implementação a pontuar.
59
79
  NÃO reescreva código. NÃO toque em tasks.json, feature.json nem no board
60
80
  de sessão — Bash é só para verificação, nunca para mudar estado.
61
81
  Contrato do relatório final: GRAVE seu relatório em
@@ -71,8 +91,9 @@ e o `recap.md` estar atualizado. Normalmente invocada pela skill orquestradora `
71
91
  contracts×0.25 + quality×0.25 + tests×0.20) gravado TAMBÉM como a
72
92
  linha literal `**Composite-Score:** {n.n}` (machine-readable — o
73
93
  `morph-spec gate-check` lê o score real daí em vez do proxy de
74
- taskScores), e feedback priorizado (bloqueantes primeiro) para
75
- qualquer dimensão < 9. Relatório ausente, oco, ou sem esses itens,
94
+ taskScores), feedback priorizado (bloqueantes primeiro) para
95
+ qualquer dimensão < 9, e — quando o stack estava no ar — o que você
96
+ navegou e o que observou. Relatório ausente, oco, ou sem esses itens,
76
97
  é falha de protocolo.
77
98
  ```
78
99
  - **Fallback (tipo não registrado):** se `morph-evaluator` não estiver na sua lista de subagent types, despache `general-purpose` colando o campo `persona` do agente `evaluator` (Read em `.morph/framework/agents.json`) no topo do prompt — e **registre o fallback no review-report** (seção de método).
@@ -87,6 +108,10 @@ e o `recap.md` estar atualizado. Normalmente invocada pela skill orquestradora `
87
108
  uma regra de negócio, e ainda dizendo `976/976` quando eram 982).
88
109
  - Scores por dimensão (arquitetura, contratos, qualidade, cobertura)
89
110
  - Score composto final
111
+ - **Verificação de runtime** — o resultado do nó `e2e` (`pass`/`fail`/`skip` + razão), e o que o
112
+ avaliador navegou. Dispensa (`skip`) entra aqui **por extenso**: o valor de um gate está tanto
113
+ no que ele provou quanto no que ele deixou de provar, e uma dispensa silenciosa se lê como
114
+ aprovação. Se houver spec Playwright recomendada pelo avaliador, liste-a como melhoria.
90
115
  - Lista de itens bloqueantes (se houver) — com descrição e critério de resolução
91
116
  - Lista de melhorias recomendadas (não bloqueantes)
92
117
  - Recomendação final: Aprovado / Rejeitar com condições / Reprovar
@@ -130,6 +155,7 @@ e o `recap.md` estar atualizado. Normalmente invocada pela skill orquestradora `
130
155
 
131
156
  **Composite score:** {score}/10
132
157
  **Dimensões:** arquitetura={X} contratos={Y} qualidade={Z} cobertura={W}
158
+ **Runtime:** e2e={pass|skip} {razão, quando skip}
133
159
  **Bloqueantes:** {N (zero esperado em auto-aprovação)}
134
160
  **Recomendação:** Aprovado
135
161
  ```
@@ -147,7 +173,7 @@ e o `recap.md` estar atualizado. Normalmente invocada pela skill orquestradora `
147
173
  - Gate 3 registrado aprovado em `.morph/features/{feature}/feature.json` (autoritativo), na forma canônica `{ approved, timestamp, approvedBy }` — via `morph-spec approve` em auto, ou pelo hook state-sync no caminho manual
148
174
 
149
175
  ## Referências
150
- - `morph-spec verify {feature} [task] --json` — cadeia determinística build+testes+validadores anexada ao prompt do avaliador (passo 4)
176
+ - `morph-spec verify {feature} [task] --json` — cadeia determinística build+testes+validadores+e2e anexada ao prompt do avaliador (passo 4)
151
177
  - `.morph/framework/MORPH.md` — seção 5 (Gate 3 + regras de pausa), seção 1 (papel Evaluator), Trust Mode
152
178
  - `.claude/agents/morph-evaluator.md` — subagent do avaliador (gerado de `.morph/framework/agents.json`), despachado no passo 4
153
179
  - `framework/skills/core/morph-eval/SKILL.md` — rubricas e bandas que o avaliador aplica
@@ -4,7 +4,7 @@ description: Varre o codebase existente, detecta stack atual, compara com o stac
4
4
  version: core
5
5
  when-to-invoke: ao iniciar uma feature em projeto brownfield (codebase já existente)
6
6
  user-invokable: true
7
- cliVersion: "8.21.0"
7
+ cliVersion: "8.22.0"
8
8
  ---
9
9
 
10
10
  # morph-stack-scan
@@ -4,7 +4,7 @@ description: Localiza um standard específico via busca server-side (morph-spec
4
4
  version: core
5
5
  when-to-invoke: quando precisar consultar um standard específico durante a implementação
6
6
  user-invokable: true
7
- cliVersion: "8.21.0"
7
+ cliVersion: "8.22.0"
8
8
  ---
9
9
 
10
10
  # morph-standards
@@ -4,7 +4,7 @@ description: Conduz a fase opcional de UI/UX — design system, mockups, compone
4
4
  version: core
5
5
  when-to-invoke: ao tomar o ramo uiux (morph-spec advance --design), depois do Gate 1 e antes do morph-plan
6
6
  user-invokable: true
7
- cliVersion: "8.21.0"
7
+ cliVersion: "8.22.0"
8
8
  ---
9
9
 
10
10
  # morph-uiux
@@ -1,7 +1,19 @@
1
1
  # Docker Compose for Local Development
2
2
  # Next.js + Neon Stack
3
-
4
- version: '3.8'
3
+ #
4
+ # Ports come from variables with sane defaults so `morph-spec e2e up` can hand
5
+ # each feature worktree its own port block (see env-allocator.js). Hardcode
6
+ # "3000:3000" here and two features running side by side collide on the host,
7
+ # no matter what the harness injects — the container names would be distinct
8
+ # and the ports would not.
9
+ #
10
+ # Healthchecks are what make `up --wait` meaningful: without them Compose
11
+ # reports "started" the moment the process launches, and the e2e node would
12
+ # navigate an application that is not serving yet.
13
+ #
14
+ # The harness also exports PLAYWRIGHT_BASE_URL for the same allocated port, so
15
+ # `playwright.config.ts` must read it instead of hardcoding a URL:
16
+ # baseURL: process.env.PLAYWRIGHT_BASE_URL ?? `http://localhost:${process.env.PORT ?? 3000}`
5
17
 
6
18
  services:
7
19
  # Next.js Application
@@ -11,7 +23,7 @@ services:
11
23
  dockerfile: Dockerfile.dev
12
24
  container_name: {{kebabCase FEATURE_NAME}}-app
13
25
  ports:
14
- - "3000:3000"
26
+ - "${PORT:-3000}:3000"
15
27
  environment:
16
28
  - NODE_ENV=development
17
29
  - DATABASE_URL=${DATABASE_URL}
@@ -20,7 +32,14 @@ services:
20
32
  - /app/node_modules
21
33
  - /app/.next
22
34
  depends_on:
23
- - postgres
35
+ postgres:
36
+ condition: service_healthy
37
+ healthcheck:
38
+ test: ["CMD-SHELL", "node -e \"fetch('http://localhost:3000/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
39
+ interval: 10s
40
+ timeout: 5s
41
+ retries: 12
42
+ start_period: 20s
24
43
  networks:
25
44
  - app-network
26
45
 
@@ -30,7 +49,7 @@ services:
30
49
  container_name: {{kebabCase FEATURE_NAME}}-postgres
31
50
  restart: unless-stopped
32
51
  ports:
33
- - "5432:5432"
52
+ - "${POSTGRES_PORT:-5432}:5432"
34
53
  environment:
35
54
  POSTGRES_USER: postgres
36
55
  POSTGRES_PASSWORD: postgres
@@ -38,6 +57,11 @@ services:
38
57
  volumes:
39
58
  - postgres-data:/var/lib/postgresql/data
40
59
  - ./migrations:/docker-entrypoint-initdb.d
60
+ healthcheck:
61
+ test: ["CMD-SHELL", "pg_isready -U postgres -d {{snakeCase FEATURE_NAME}}"]
62
+ interval: 5s
63
+ timeout: 5s
64
+ retries: 10
41
65
  networks:
42
66
  - app-network
43
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polymorphism-tech/morph-spec",
3
- "version": "8.21.0",
3
+ "version": "8.22.0",
4
4
  "description": "MORPH-SPEC: NLH (Natural Language Harness) for spec-driven development with Claude Code",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -0,0 +1,318 @@
1
+ /**
2
+ * `morph-spec e2e up|status|down` — lifecycle of the verification stack.
3
+ *
4
+ * The stack is deliberately NOT torn down between verifies: cold-starting a
5
+ * .NET container per task is the difference between a loop people run and one
6
+ * they disable. `up` brings it (or reuses it), `status` says whether what is
7
+ * running still matches the working tree, and `down` releases it — which
8
+ * `morph-spec finish` also does as part of teardown.
9
+ *
10
+ * All the real logic lives in framework/hooks/shared/e2e-check.js, shared with
11
+ * the `verify` e2e node so the two can never drift.
12
+ *
13
+ * @module commands/e2e
14
+ */
15
+
16
+ import chalk from 'chalk';
17
+ import { execFileSync } from 'child_process';
18
+ import { existsSync, readFileSync } from 'fs';
19
+ import { join, resolve } from 'path';
20
+
21
+ import {
22
+ bringUpStack,
23
+ composeProjectName,
24
+ computeSourceStamp,
25
+ defaultExec,
26
+ getStackStatus,
27
+ isDockerAvailable,
28
+ prepareAuth,
29
+ readStamp,
30
+ resolveComposeFile,
31
+ resolveSpecCommand,
32
+ tearDownStack,
33
+ } from '../../../framework/hooks/shared/e2e-check.js';
34
+ import { buildComposeEnv } from '../../lib/e2e/compose-env.js';
35
+
36
+ /** Exit code for a usage error, matching `verify`'s convention. */
37
+ const EXIT_USAGE = 2;
38
+
39
+ // ============================================================================
40
+ // Shared context
41
+ // ============================================================================
42
+
43
+ /**
44
+ * Everything the three subcommands need, resolved once.
45
+ * Returns null (after printing) when the project has no compose file — that is
46
+ * a usage error here, unlike in the verify node where it is a graded skip.
47
+ */
48
+ function resolveContext(featureName, { json = false } = {}) {
49
+ const cwd = process.cwd();
50
+ const config = readConfig(cwd);
51
+ const composeFile = resolveComposeFile(cwd, config);
52
+
53
+ if (!composeFile) {
54
+ const message =
55
+ 'No compose file found (compose.yaml, docker-compose.yml, …). ' +
56
+ 'Set project.e2e.composeFile in .morph/config/config.json if it lives elsewhere.';
57
+ if (json) console.log(JSON.stringify({ error: message }, null, 2));
58
+ else console.error(chalk.red(`\n❌ ${message}\n`));
59
+ return null;
60
+ }
61
+
62
+ const projectName = composeProjectName(featureName);
63
+ const gitCommonDir = resolveGitCommonDir(cwd);
64
+
65
+ return {
66
+ cwd,
67
+ config,
68
+ composeFile,
69
+ projectName,
70
+ gitCommonDir,
71
+ env: buildEnv({ cwd, config, featureName, projectName, gitCommonDir }),
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Compose environment: the port block this feature owns, plus any credentials
77
+ * from the gitignored auth env file.
78
+ *
79
+ * The port block comes from `buildComposeEnv` rather than being assembled here
80
+ * so this command and the `e2e` node inside `verify` agree by construction —
81
+ * isolation that exists only in the command nobody runs is no isolation at all.
82
+ * `COMPOSE_PROJECT_NAME` isolates container NAMES but not host ports; a compose
83
+ * that hardcodes ports instead of reading `${PORT:-3000}` cannot be isolated at
84
+ * all, which is surfaced as a warning rather than silently accepted.
85
+ */
86
+ function buildEnv({ cwd, config, featureName, projectName, gitCommonDir }) {
87
+ return {
88
+ COMPOSE_PROJECT_NAME: projectName,
89
+ ...buildComposeEnv({ cwd, config, featureName, gitCommonDir }),
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Warn once when the compose file publishes fixed host ports: with a hardcoded
95
+ * `"3000:3000"` the fleet port block cannot take effect, so two features
96
+ * running simultaneously will collide no matter what we inject.
97
+ */
98
+ function warnOnFixedPorts(cwd, composeFile) {
99
+ try {
100
+ const raw = readFileSync(join(cwd, composeFile), 'utf8');
101
+ const publishesFixed = /^\s*-\s*["']?\d+:\d+["']?\s*$/m.test(raw);
102
+ if (publishesFixed) {
103
+ console.log(
104
+ chalk.yellow(
105
+ '⚠️ Compose publishes fixed host ports. Parallel worktrees will collide — ' +
106
+ 'use ${PORT:-3000}:3000 so the fleet port block applies.'
107
+ )
108
+ );
109
+ }
110
+ } catch {
111
+ // Unreadable compose is the caller's problem, not this warning's.
112
+ }
113
+ }
114
+
115
+ // ============================================================================
116
+ // up
117
+ // ============================================================================
118
+
119
+ /**
120
+ * `morph-spec e2e up <feature> [--fresh]`
121
+ *
122
+ * Exit codes: 0 = stack healthy · 1 = failed to come up (or auth broke) ·
123
+ * 2 = usage (no compose file / no docker).
124
+ */
125
+ export async function e2eUpCommand(featureName, options = {}) {
126
+ const ctx = resolveContext(featureName, options);
127
+ if (!ctx) process.exit(EXIT_USAGE);
128
+
129
+ if (!isDockerAvailable(ctx.cwd)) {
130
+ console.error(chalk.red('\n❌ Docker is not available in this environment.\n'));
131
+ process.exit(EXIT_USAGE);
132
+ }
133
+
134
+ if (!options.json) warnOnFixedPorts(ctx.cwd, ctx.composeFile);
135
+
136
+ const result = bringUpStack(ctx.cwd, {
137
+ composeFile: ctx.composeFile,
138
+ projectName: ctx.projectName,
139
+ gitCommonDir: ctx.gitCommonDir,
140
+ env: ctx.env,
141
+ forceFresh: Boolean(options.fresh),
142
+ });
143
+
144
+ const auth =
145
+ result.status === 'pass'
146
+ ? prepareAuth(ctx.cwd, { config: ctx.config, env: ctx.env })
147
+ : { status: 'skip', reason: null };
148
+
149
+ const failed = result.status === 'fail' || auth.status === 'fail';
150
+ const payload = {
151
+ feature: featureName,
152
+ project: ctx.projectName,
153
+ composeFile: ctx.composeFile,
154
+ status: failed ? 'fail' : 'pass',
155
+ reused: result.reused,
156
+ reason: result.reason,
157
+ auth: { status: auth.status, reason: auth.reason },
158
+ };
159
+
160
+ if (options.json) {
161
+ console.log(JSON.stringify(payload, null, 2));
162
+ } else if (failed) {
163
+ console.error(chalk.red(`\n✗ ${result.reason || auth.reason}\n`));
164
+ if (result.output) console.error(result.output);
165
+ } else {
166
+ const how = result.reused ? chalk.gray(`(${result.reason})`) : chalk.gray('(built)');
167
+ console.log(chalk.green(`\n✓ Stack up — ${ctx.projectName} ${how}`));
168
+ if (auth.reason) console.log(chalk.gray(` auth: ${auth.reason}`));
169
+ console.log('');
170
+ }
171
+
172
+ process.exit(failed ? 1 : 0);
173
+ }
174
+
175
+ // ============================================================================
176
+ // status
177
+ // ============================================================================
178
+
179
+ /**
180
+ * Where the app actually landed.
181
+ *
182
+ * The reviewer hands this to the evaluator instead of letting it guess
183
+ * `localhost:3000` — each feature owns a port block, so the default port is the
184
+ * one address most likely to be wrong. Mirrors what `playwright.config.ts` is
185
+ * asked to do, deliberately: one rule, two readers.
186
+ *
187
+ * @param {Record<string,string>} env
188
+ * @returns {string}
189
+ */
190
+ export function resolveBaseUrl(env = {}) {
191
+ return env.PLAYWRIGHT_BASE_URL ?? `http://localhost:${env.PORT ?? 3000}`;
192
+ }
193
+
194
+ /**
195
+ * `morph-spec e2e status <feature> [--json]`
196
+ *
197
+ * Answers the question that matters before trusting a green run: is what is
198
+ * running still built from the code on disk? Exit 0 when up AND fresh, 1
199
+ * otherwise — so it can gate a script.
200
+ */
201
+ export async function e2eStatusCommand(featureName, options = {}) {
202
+ const ctx = resolveContext(featureName, options);
203
+ if (!ctx) process.exit(EXIT_USAGE);
204
+
205
+ const status = getStackStatus(ctx.cwd, {
206
+ composeFile: ctx.composeFile,
207
+ projectName: ctx.projectName,
208
+ env: ctx.env,
209
+ });
210
+
211
+ const stamp = computeSourceStamp(ctx.cwd);
212
+ const previous = ctx.gitCommonDir ? readStamp(ctx.gitCommonDir, ctx.projectName) : null;
213
+ const fresh = Boolean(stamp && previous?.stamp === stamp);
214
+
215
+ const spec = resolveSpecCommand(ctx.cwd, ctx.config, ctx.config?.project?.frontendPath);
216
+
217
+ const payload = {
218
+ feature: featureName,
219
+ project: ctx.projectName,
220
+ composeFile: ctx.composeFile,
221
+ up: status.up,
222
+ healthy: status.healthy,
223
+ fresh,
224
+ baseUrl: resolveBaseUrl(ctx.env),
225
+ builtAt: previous?.builtAt ?? null,
226
+ specs: spec ? `${spec.command} ${spec.args.join(' ')}` : null,
227
+ services: status.services.map((s) => ({
228
+ name: s.Service ?? s.Name ?? null,
229
+ state: s.State ?? null,
230
+ health: s.Health || null,
231
+ })),
232
+ };
233
+
234
+ if (options.json) {
235
+ console.log(JSON.stringify(payload, null, 2));
236
+ } else {
237
+ console.log('');
238
+ console.log(chalk.bold(`E2E stack — ${ctx.projectName}`));
239
+ console.log(chalk.gray(`compose: ${ctx.composeFile}`));
240
+ console.log(chalk.gray(`url: ${payload.baseUrl}`));
241
+ console.log('');
242
+ console.log(` up ${status.up ? chalk.green('yes') : chalk.gray('no')}`);
243
+ console.log(` healthy ${status.healthy ? chalk.green('yes') : chalk.gray('no')}`);
244
+ console.log(
245
+ ` fresh ${fresh ? chalk.green('yes') : chalk.yellow('no — source changed since build')}`
246
+ );
247
+ console.log(` specs ${spec ? payload.specs : chalk.gray('none (smoke only)')}`);
248
+ for (const s of payload.services) {
249
+ console.log(chalk.gray(` · ${s.name}: ${s.state}${s.health ? ` / ${s.health}` : ''}`));
250
+ }
251
+ console.log('');
252
+ }
253
+
254
+ process.exit(status.up && status.healthy && fresh ? 0 : 1);
255
+ }
256
+
257
+ // ============================================================================
258
+ // down
259
+ // ============================================================================
260
+
261
+ /**
262
+ * `morph-spec e2e down <feature> [--volumes]`
263
+ *
264
+ * Teardown is best-effort by design — a stack that will not come down must
265
+ * never block `finish`.
266
+ */
267
+ export async function e2eDownCommand(featureName, options = {}) {
268
+ const ctx = resolveContext(featureName, options);
269
+ if (!ctx) process.exit(EXIT_USAGE);
270
+
271
+ const result = tearDownStack(ctx.cwd, {
272
+ composeFile: ctx.composeFile,
273
+ projectName: ctx.projectName,
274
+ env: ctx.env,
275
+ volumes: Boolean(options.volumes),
276
+ });
277
+
278
+ if (options.json) {
279
+ console.log(JSON.stringify({ feature: featureName, project: ctx.projectName, status: result.status }, null, 2));
280
+ } else if (result.status === 'pass') {
281
+ console.log(chalk.green(`\n✓ Stack down — ${ctx.projectName}\n`));
282
+ } else {
283
+ console.log(chalk.yellow(`\n⚠️ Teardown reported a problem (not fatal):\n${result.output}\n`));
284
+ }
285
+
286
+ process.exit(0);
287
+ }
288
+
289
+ // ============================================================================
290
+ // Shared helpers
291
+ // ============================================================================
292
+
293
+ function readConfig(cwd) {
294
+ try {
295
+ const path = join(cwd, '.morph', 'config', 'config.json');
296
+ if (!existsSync(path)) return null;
297
+ return JSON.parse(readFileSync(path, 'utf8').replace(/^/, ''));
298
+ } catch {
299
+ return null;
300
+ }
301
+ }
302
+
303
+ /** Same resolution (and same fail-open) as verify-runner's. */
304
+ function resolveGitCommonDir(cwd) {
305
+ try {
306
+ const common = execFileSync('git', ['rev-parse', '--git-common-dir'], {
307
+ cwd,
308
+ stdio: 'pipe',
309
+ encoding: 'utf-8',
310
+ windowsHide: true,
311
+ }).trim();
312
+ return common ? resolve(cwd, common) : null;
313
+ } catch {
314
+ return null;
315
+ }
316
+ }
317
+
318
+ export { defaultExec };
@@ -13,6 +13,11 @@ import {
13
13
  } from '../project/worktree.js';
14
14
  import { detectDefaultBranch, hasOriginRemote } from '../../lib/git/default-branch.js';
15
15
  import { releaseClaim } from '../../../framework/hooks/shared/claim-registry.js';
16
+ import {
17
+ composeProjectName,
18
+ resolveComposeFile,
19
+ tearDownStack,
20
+ } from '../../../framework/hooks/shared/e2e-check.js';
16
21
  import { runArchive } from './archive.js';
17
22
 
18
23
  /** workType → conventional-commit type. `hotfix` IS a fix; anything else is a chore. */
@@ -85,12 +90,20 @@ export async function finishCommand(feature, options = {}) {
85
90
  // working on it (B1). Released on EVERY success path — hooking it into emit
86
91
  // is the single point that covers merge/PR/--keep alike. Fail-open: a claim
87
92
  // that outlives its feature only expires on its own TTL.
93
+ //
94
+ // The e2e stack rides along for the same reason: `morph-spec verify` leaves it
95
+ // UP on purpose (cold-starting a container per task is what makes the loop
96
+ // unusable), so something has to be the one place that reclaims the
97
+ // containers and their ports when the feature closes. Best-effort by
98
+ // contract — a stack that refuses to come down must never fail a finish that
99
+ // already merged.
88
100
  const emit = (obj) => {
89
101
  if (obj?.finished === true) {
90
102
  try {
91
103
  const common = execFileSync('git', ['rev-parse', '--git-common-dir'], { cwd: root, stdio: 'pipe' }).toString().trim();
92
104
  if (common) releaseClaim(feature, { gitCommonDir: resolve(root, common) });
93
105
  } catch { /* fail-open */ }
106
+ tearDownE2eStack(feature, root);
94
107
  }
95
108
  console.log(JSON.stringify(obj));
96
109
  };
@@ -502,6 +515,31 @@ function readFeatureJson(cwd, feature) {
502
515
  }
503
516
  }
504
517
 
518
+ /**
519
+ * Reclaim the feature's verification stack, if the project has one.
520
+ *
521
+ * Runs from the ROOT rather than the worktree: by the time a finish emits
522
+ * success the worktree may already be gone, and the compose file is versioned
523
+ * so the root's copy is equivalent. Entirely fail-open — no compose, no docker,
524
+ * or a stubborn container are all non-events here.
525
+ */
526
+ function tearDownE2eStack(feature, root) {
527
+ try {
528
+ const config = JSON.parse(
529
+ readFileSync(join(root, '.morph', 'config', 'config.json'), 'utf8').replace(/^/, '')
530
+ );
531
+ const composeFile = resolveComposeFile(root, config);
532
+ if (!composeFile) return;
533
+
534
+ const projectName = composeProjectName(feature);
535
+ tearDownStack(root, {
536
+ composeFile,
537
+ projectName,
538
+ env: { COMPOSE_PROJECT_NAME: projectName },
539
+ });
540
+ } catch { /* fail-open — teardown must never fail a completed finish */ }
541
+ }
542
+
505
543
  /** Drop a feature from the thin, gitignored root index (never via saveState). */
506
544
  function dropFromRootIndex(root, feature) {
507
545
  const rootPath = join(root, '.morph', 'state.json');
@@ -80,7 +80,7 @@ const OUTPUT_SECTIONS = {
80
80
  ],
81
81
  },
82
82
  // morph-plan (mandate.md) emits: Stack, Standards obrigatórios,
83
- // Patterns proibidos, Checklist de done.
83
+ // Patterns proibidos, Verificação de runtime, Checklist de done.
84
84
  'mandate.md': {
85
85
  required: [
86
86
  'Stack',
@@ -89,6 +89,12 @@ const OUTPUT_SECTIONS = {
89
89
  ],
90
90
  recommended: [
91
91
  ['Patterns proibidos', 'Padrões proibidos', 'Padroes proibidos', 'Forbidden Patterns', 'Forbidden', 'Proibido'],
92
+ // Recommended rather than required on purpose: mandate.md freezes after
93
+ // Gate 2 (protect-spec-files blocks Write AND Edit), so promoting this to
94
+ // `required` would fail every in-flight feature with no way to fix it.
95
+ // New mandates get the section from morph-plan §4; the enforcement that
96
+ // actually matters is verify's e2e node, not this heading check.
97
+ ['Verificação de runtime', 'Verificacao de runtime', 'Runtime', 'E2E'],
92
98
  ],
93
99
  optional: [
94
100
  ['Riscos', 'Risks'],
@@ -37,6 +37,10 @@ function formatVerifyResult(result) {
37
37
  detail = node.reason || '';
38
38
  } else if (name === 'validators' && node.score != null) {
39
39
  detail = `score ${node.score}/10`;
40
+ } else if (node.reason) {
41
+ // A green e2e still carries meaning worth showing: smoke-only vs specs,
42
+ // warm reuse vs rebuild. Without it, "pass" hides how much was proven.
43
+ detail = `${node.durationMs}ms — ${node.reason}`;
40
44
  } else {
41
45
  detail = `${node.durationMs}ms`;
42
46
  }
@@ -149,6 +153,8 @@ export async function verifyCommand(featureName, taskId, options = {}) {
149
153
  timeout: options.timeout,
150
154
  skipTests: options.skipTests,
151
155
  skipBuild: options.skipBuild,
156
+ skipE2e: options.skipE2e,
157
+ freshE2e: options.freshE2e,
152
158
  });
153
159
 
154
160
  const scopeKey = taskId ? `taskVerifications.${taskId}` : 'verifications.feature';
@@ -168,6 +174,7 @@ export async function verifyCommand(featureName, taskId, options = {}) {
168
174
  build: result.nodes.build.status,
169
175
  tests: result.nodes.tests.status,
170
176
  validators: result.nodes.validators.status,
177
+ e2e: result.nodes.e2e.status,
171
178
  },
172
179
  score: result.nodes.validators.score ?? null,
173
180
  attempts: (prevEntry?.attempts ?? 0) + 1,
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Compose environment for the e2e stack — the port block a feature owns, plus
3
+ * the credentials its auth setup needs.
4
+ *
5
+ * This lives in ONE place because it has two callers that must never disagree:
6
+ * `morph-spec e2e up` (manual) and the `e2e` node inside `morph-spec verify`
7
+ * (the one that actually runs in the loop). When only the manual path built the
8
+ * port block, the isolation was real in the command nobody runs and absent in
9
+ * the one everybody runs — two worktrees would get distinct container names via
10
+ * COMPOSE_PROJECT_NAME and then collide on host port 3000 anyway, surfacing as
11
+ * "port is already allocated" and pausing a gate over an infrastructure clash
12
+ * rather than a defect.
13
+ *
14
+ * It sits in src/lib rather than framework/hooks/shared because it depends on
15
+ * env-allocator.js: hooks are installed standalone into target projects, where
16
+ * `src/` does not exist, so the dependency may only point this way.
17
+ *
18
+ * @module lib/e2e/compose-env
19
+ */
20
+
21
+ import { join } from 'path';
22
+
23
+ import { loadEnvFile } from '../../../framework/hooks/shared/e2e-check.js';
24
+ import { allocateEnvBlock, resolveFleetConfig } from '../worktree/env-allocator.js';
25
+
26
+ /**
27
+ * Build the environment a `docker compose` invocation should carry.
28
+ *
29
+ * Port variables mirror `buildEnvFileContent()` in env-allocator.js so a
30
+ * compose file and a `.morph/worktree.env` describe the same world: `PORT` for
31
+ * the web tier, `ASPNETCORE_URLS` on base+1 for a .NET backend.
32
+ *
33
+ * `PLAYWRIGHT_BASE_URL` is derived here rather than left to the spec config —
34
+ * the suite has to be told where the app actually landed, otherwise a feature
35
+ * on port 4020 gets tested against a hardcoded localhost:3000 and fails with
36
+ * ECONNREFUSED that looks exactly like a broken app.
37
+ *
38
+ * Fail-open throughout: with no git dir (or a corrupt registry) the caller gets
39
+ * default ports, which only matters when two stacks run at once.
40
+ *
41
+ * @param {object} params
42
+ * @param {string} params.cwd - project root
43
+ * @param {object|null} [params.config] - .morph/config/config.json contents
44
+ * @param {string} params.featureName
45
+ * @param {string|null} [params.gitCommonDir]
46
+ * @returns {Record<string,string>}
47
+ */
48
+ export function buildComposeEnv({ cwd, config, featureName, gitCommonDir }) {
49
+ const env = {};
50
+
51
+ if (gitCommonDir) {
52
+ try {
53
+ const { basePort, blockSize } = resolveFleetConfig(cwd);
54
+ const block = allocateEnvBlock(featureName, { gitCommonDir, basePort, blockSize });
55
+
56
+ env.MORPH_PORT_BASE = String(block.base);
57
+ env.MORPH_PORT_RANGE = `${block.range[0]}-${block.range[1]}`;
58
+ env.PORT = String(block.base);
59
+ env.PLAYWRIGHT_BASE_URL = `http://localhost:${block.base}`;
60
+
61
+ if (/\bdotnet\b/i.test(String(config?.project?.stack ?? ''))) {
62
+ env.ASPNETCORE_URLS = `http://localhost:${block.base + 1}`;
63
+ }
64
+ } catch {
65
+ // No block → default ports. Surfaces later as a port conflict, never here.
66
+ }
67
+ }
68
+
69
+ // Credentials live in a gitignored env file, never in the committed config.
70
+ const envFile = config?.e2e?.auth?.envFile;
71
+ if (envFile) Object.assign(env, loadEnvFile(join(cwd, envFile)));
72
+
73
+ return env;
74
+ }
@@ -1,24 +1,33 @@
1
1
  /**
2
- * Verify Runner — deterministic build+tests+validators cadence.
2
+ * Verify Runner — deterministic build+tests+validators+e2e cadence.
3
3
  *
4
4
  * v8 adds a DETERMINISTIC verification node that runs BEFORE the LLM judge
5
5
  * in the per-task loop: build + tests + validators (code, free) first; the
6
6
  * LLM judge is only invoked once verify passes. This is a BINARY gate
7
7
  * (pass/fail per node), NOT a second "bar 9" — the bar 9 remains morph-eval's.
8
8
  *
9
+ * The `e2e` node closes the last gap in that chain: build proves the code
10
+ * COMPILES, tests prove units behave, validators read structure — none of
11
+ * them prove the application RUNS. See e2e-check.js for why that distinction
12
+ * is load-bearing rather than academic.
13
+ *
9
14
  * Pure orchestration — reuses (never reimplements):
10
15
  * - runBuildCheck() from framework/hooks/shared/build-check.js
11
16
  * - runTestSuite() from src/lib/tasks/test-runner.js
12
17
  * - runValidation() from ./validation-runner.js
18
+ * - runE2eCheck() from framework/hooks/shared/e2e-check.js
13
19
  * - loadRubric() + aggregateScores() from ./score-aggregator.js
14
20
  *
15
21
  * @module verify-runner
16
22
  */
17
23
 
24
+ import { execFileSync } from 'child_process';
18
25
  import { existsSync, readFileSync } from 'fs';
19
- import { join } from 'path';
26
+ import { join, resolve } from 'path';
20
27
 
21
28
  import { runBuildCheck, getBuildSteps } from '../../../framework/hooks/shared/build-check.js';
29
+ import { runE2eCheck } from '../../../framework/hooks/shared/e2e-check.js';
30
+ import { buildComposeEnv } from '../e2e/compose-env.js';
22
31
  import { runTestSuite, resolveTestCommand } from '../tasks/test-runner.js';
23
32
  import { runValidation } from './validation-runner.js';
24
33
  import { loadRubric, aggregateScores } from './score-aggregator.js';
@@ -41,9 +50,12 @@ const OUTPUT_TAIL_LINES = 20;
41
50
  * @param {number} [options.timeout] - Build timeout in ms (default 120000)
42
51
  * @param {boolean} [options.skipTests] - Skip the tests node
43
52
  * @param {boolean} [options.skipBuild] - Skip the build node
53
+ * @param {boolean} [options.skipE2e] - Skip the e2e node
54
+ * @param {boolean} [options.freshE2e] - Force a stack rebuild instead of reusing a warm one
44
55
  * @param {Function} [options._runBuildCheck] - Injectable override for runBuildCheck (testing)
45
56
  * @param {Function} [options._runTestSuite] - Injectable override for runTestSuite (testing)
46
57
  * @param {Function} [options._runValidation] - Injectable override for runValidation (testing)
58
+ * @param {Function} [options._runE2eCheck] - Injectable override for runE2eCheck (testing)
47
59
  * @returns {Promise<Object>} Aggregated verify result — see module docs for shape
48
60
  */
49
61
  export async function runVerify(projectPath, featureName, taskId, options = {}) {
@@ -52,9 +64,12 @@ export async function runVerify(projectPath, featureName, taskId, options = {})
52
64
  timeout = DEFAULT_BUILD_TIMEOUT,
53
65
  skipTests = false,
54
66
  skipBuild = false,
67
+ skipE2e = false,
68
+ freshE2e = false,
55
69
  _runBuildCheck = runBuildCheck,
56
70
  _runTestSuite = runTestSuite,
57
71
  _runValidation = runValidation,
72
+ _runE2eCheck = runE2eCheck,
58
73
  } = options;
59
74
 
60
75
  const stack = readStack(projectPath);
@@ -76,6 +91,20 @@ export async function runVerify(projectPath, featureName, taskId, options = {})
76
91
  nodes.validators = await runValidatorsNode({ projectPath, featureName, rubricId, _runValidation });
77
92
  appendValidatorsFeedback(feedback, nodes.validators);
78
93
 
94
+ // 4. e2e — the only node that runs the application. Like tests, it is not
95
+ // invoked at all on a red build: a stack that cannot compile cannot come up,
96
+ // and the docker output would bury the actual compile error.
97
+ nodes.e2e = await runE2eNode({
98
+ projectPath,
99
+ featureName,
100
+ taskId,
101
+ skipE2e,
102
+ freshE2e,
103
+ buildNode: nodes.build,
104
+ _runE2eCheck,
105
+ });
106
+ appendE2eFeedback(feedback, nodes.e2e);
107
+
79
108
  const status = Object.values(nodes).some((n) => n.status === 'fail') ? 'fail' : 'pass';
80
109
 
81
110
  return {
@@ -239,6 +268,63 @@ function appendValidatorsFeedback(feedback, validatorsNode) {
239
268
  }
240
269
  }
241
270
 
271
+ // ============================================================================
272
+ // E2E node
273
+ // ============================================================================
274
+
275
+ async function runE2eNode({ projectPath, featureName, taskId, skipE2e, freshE2e, buildNode, _runE2eCheck }) {
276
+ if (buildNode.status === 'fail') {
277
+ return {
278
+ status: 'skip',
279
+ command: null,
280
+ durationMs: 0,
281
+ reason: 'build failed — the stack could not come up anyway',
282
+ output: '',
283
+ };
284
+ }
285
+
286
+ const config = readConfig(projectPath);
287
+ const taskGroup = taskId ? readTaskGroup(projectPath, featureName, taskId) : null;
288
+ const gitCommonDir = resolveGitCommonDir(projectPath);
289
+
290
+ const start = Date.now();
291
+ const result = _runE2eCheck(projectPath, {
292
+ config,
293
+ featureName,
294
+ taskGroup,
295
+ scope: taskId ? 'task' : 'feature',
296
+ gitCommonDir,
297
+ // The port block matters MORE here than in `e2e up`: this is the path that
298
+ // runs in the loop, and two worktrees verifying at once would otherwise get
299
+ // distinct container names and then collide on host port 3000 — a red gate
300
+ // over an infrastructure clash instead of a defect.
301
+ env: buildComposeEnv({ cwd: projectPath, config, featureName, gitCommonDir }),
302
+ skipRequested: skipE2e,
303
+ forceFresh: freshE2e,
304
+ // The stack stays up between verifies on purpose: cold-starting a .NET
305
+ // container per task is the difference between a usable loop and one
306
+ // nobody runs. `morph-spec e2e down` (and `finish`) tear it down.
307
+ keepUp: true,
308
+ });
309
+ const durationMs = Date.now() - start;
310
+
311
+ return {
312
+ status: result.status,
313
+ command: result.command ?? null,
314
+ durationMs,
315
+ reason: result.reason ?? null,
316
+ output: result.output || '',
317
+ };
318
+ }
319
+
320
+ function appendE2eFeedback(feedback, e2eNode) {
321
+ if (e2eNode.status !== 'fail') return;
322
+ if (e2eNode.reason) feedback.push(`[e2e] ${e2eNode.reason}`);
323
+ for (const line of linesOf(e2eNode.output)) {
324
+ feedback.push(`[e2e] ${line}`);
325
+ }
326
+ }
327
+
242
328
  // ============================================================================
243
329
  // Rubric selection — flag > task group > config > default
244
330
  // ============================================================================
@@ -328,6 +414,39 @@ function readProjectPaths(projectPath) {
328
414
  }
329
415
  }
330
416
 
417
+ /** Read the whole config.json — the e2e node needs `e2e` and `project` together. */
418
+ function readConfig(projectPath) {
419
+ try {
420
+ const configPath = join(projectPath, '.morph', 'config', 'config.json');
421
+ if (!existsSync(configPath)) return null;
422
+ return JSON.parse(readFileSync(configPath, 'utf8').replace(/^/, ''));
423
+ } catch {
424
+ return null;
425
+ }
426
+ }
427
+
428
+ /**
429
+ * Resolve the shared git dir, where the e2e freshness stamp lives — the same
430
+ * location and rationale as the fleet env-registry (see env-allocator.js): it
431
+ * is the one place every linked worktree of a repo shares, and writing there
432
+ * can never dirty the `git status` the worktree-finish gates read.
433
+ *
434
+ * Fail-open: no git, no stamp — the cost is a rebuild, never a crash.
435
+ */
436
+ function resolveGitCommonDir(projectPath) {
437
+ try {
438
+ const common = execFileSync('git', ['rev-parse', '--git-common-dir'], {
439
+ cwd: projectPath,
440
+ stdio: 'pipe',
441
+ encoding: 'utf-8',
442
+ windowsHide: true,
443
+ }).trim();
444
+ return common ? resolve(projectPath, common) : null;
445
+ } catch {
446
+ return null;
447
+ }
448
+ }
449
+
331
450
  function linesOf(text) {
332
451
  const trimmed = (text || '').trim();
333
452
  return trimmed ? trimmed.split('\n') : [];