@polymorphism-tech/morph-spec 10.1.1 → 10.2.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 (48) hide show
  1. package/README.md +3 -7
  2. package/bin/morph-spec.js +1 -41
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -4
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/agents.json +0 -1
  7. package/framework/commands/morph-archive.md +1 -1
  8. package/framework/commands/morph-preflight.md +3 -4
  9. package/framework/docs/CLI.md +23 -20
  10. package/framework/hooks/README.md +2 -2
  11. package/framework/hooks/claude-code/core/validate-completion.js +0 -35
  12. package/framework/hooks/shared/e2e-check.js +44 -7
  13. package/framework/hooks/shared/heavy-command.js +12 -1
  14. package/framework/hooks/shared/next-step.js +2 -2
  15. package/framework/phases.json +4 -4
  16. package/framework/schemas/config.schema.json +20 -1
  17. package/framework/schemas/feature.schema.json +1 -0
  18. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  19. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  20. package/framework/skills/core/morph-apply/SKILL.md +5 -5
  21. package/framework/skills/core/morph-apply/references/hotfix.md +1 -1
  22. package/framework/skills/core/morph-apply/references/review.md +5 -5
  23. package/framework/skills/core/morph-plan/SKILL.md +4 -4
  24. package/framework/skills/core/morph-uiux/SKILL.md +2 -2
  25. package/framework/templates/examples/design-system-examples.md +0 -3
  26. package/package.json +1 -1
  27. package/src/commands/project/doctor.js +0 -1
  28. package/src/commands/state/approve.js +9 -109
  29. package/src/commands/state/report-check.js +44 -2
  30. package/src/commands/validation/verify.js +7 -3
  31. package/src/core/state/state-manager.js +0 -24
  32. package/src/lib/heavy/cleanup.js +8 -2
  33. package/src/lib/plan/critique.js +23 -3
  34. package/src/lib/review-confirmation.js +67 -0
  35. package/src/lib/review-reports.js +1 -0
  36. package/src/lib/risk-detector.js +2 -2
  37. package/src/lib/tasks/test-nodes.js +5 -0
  38. package/src/lib/tasks/test-runner.js +37 -43
  39. package/src/lib/telemetry/telemetry-reader.js +1 -2
  40. package/src/lib/telemetry/verify-event.js +7 -0
  41. package/src/lib/validators/verify-cache.js +63 -0
  42. package/src/lib/validators/verify-runner.js +98 -23
  43. package/src/utils/agents-installer.js +2 -2
  44. package/src/utils/hooks-installer.js +0 -20
  45. package/bin/validate.js +0 -212
  46. package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
  47. package/src/commands/observability/telemetry.js +0 -60
  48. package/src/commands/validation/validate-feature.js +0 -337
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:** 10.1.1
6
+ **Version:** 10.2.0
7
7
  **Requires:** Node.js 22+, Claude Code
8
8
 
9
9
  ---
@@ -115,7 +115,7 @@ In `trust: "auto"` mode (default), gates auto-pass without risk signals. See `fr
115
115
  Check gate status:
116
116
 
117
117
  ```bash
118
- morph-spec approval-status {feature}
118
+ morph-spec status {feature}
119
119
  ```
120
120
 
121
121
  ---
@@ -165,7 +165,6 @@ morph-spec fleet # Every active feature (root + wor
165
165
  morph-spec status {feature} # Feature status dashboard
166
166
  morph-spec approve {feature} {gate} # Approve a phase gate (plan, uiux, review)
167
167
  morph-spec advance {feature} # Approve the current gate and scaffold the next phase folder
168
- morph-spec approval-status {feature} # Show approval status for all gates
169
168
  morph-spec finish {feature} --pr|--merge # Close a feature: archive + integrate (PR or local merge) + teardown
170
169
  morph-spec archive {feature} # Archive WITHOUT integrating (legacy feature / cleanup)
171
170
  ```
@@ -177,9 +176,6 @@ Progress within a feature is the slice reports `3-implement/reports/F{n}.md`: ab
177
176
  ### Validation
178
177
 
179
178
  ```bash
180
- morph-spec validate # Run all validators
181
- morph-spec validate {validator} # Run specific validator
182
- morph-spec validate-feature {feature} # Content-aware feature validation
183
179
  morph-spec verify {feature} # Deterministic build+tests+validators chain, before the LLM judge
184
180
  ```
185
181
 
@@ -380,4 +376,4 @@ Code generated by morph-spec (contracts, templates, implementation output) belon
380
376
 
381
377
  ---
382
378
 
383
- *morph-spec v10.1.1 by [Polymorphism Tech](https://polymorphism.tech)*
379
+ *morph-spec v10.2.0 by [Polymorphism Tech](https://polymorphism.tech)*
package/bin/morph-spec.js CHANGED
@@ -35,7 +35,6 @@ const stackSetCommand = lazy('../src/commands/project/stack-set.js', 'stackSetCo
35
35
  const testsAuditCommand = lazy('../src/commands/validation/tests-audit.js', 'testsAuditCommand');
36
36
 
37
37
  const approveCommand = lazy('../src/commands/state/approve.js', 'approveCommand');
38
- const approvalStatusCommand = lazy('../src/commands/state/approve.js', 'approvalStatusCommand');
39
38
  const unapproveCommand = lazy('../src/commands/state/approve.js', 'unapproveCommand');
40
39
  const advanceCommand = lazy('../src/commands/state/advance.js', 'advanceCommand');
41
40
  const archiveCommand = lazy('../src/commands/state/archive.js', 'archiveCommand');
@@ -55,8 +54,6 @@ const graphAffectedCommand = lazy('../src/commands/graph/affected.js', 'graphAff
55
54
  const graphExplainCommand = lazy('../src/commands/graph/explain.js', 'graphExplainCommand');
56
55
  const graphDiffCommand = lazy('../src/commands/graph/diff.js', 'graphDiffCommand');
57
56
 
58
- const validateCommand = lazy('./validate.js', 'validateCommand');
59
- const validateFeatureCommand = lazy('../src/commands/validation/validate-feature.js', 'validateFeatureCommand');
60
57
  const verifyCommand = lazy('../src/commands/validation/verify.js', 'verifyCommand');
61
58
  const e2eUpCommand = lazy('../src/commands/e2e/e2e.js', 'e2eUpCommand');
62
59
  const e2eStatusCommand = lazy('../src/commands/e2e/e2e.js', 'e2eStatusCommand');
@@ -72,7 +69,6 @@ const contextRefreshCommand = lazy('../src/commands/context/context-refresh.js',
72
69
 
73
70
  const dashboardCommand = lazy('../src/commands/dashboard/dashboard.js', 'dashboardCommand');
74
71
 
75
- const telemetryCommand = lazy('../src/commands/observability/telemetry.js', 'telemetryCommand');
76
72
  const costCommand = lazy('../src/commands/observability/cost.js', 'costCommand');
77
73
 
78
74
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -216,12 +212,6 @@ program
216
212
  .option('--json', 'Output as JSON')
217
213
  .action((feature, options) => deleteCommand(feature, options));
218
214
 
219
- program
220
- .command('approval-status <feature>')
221
- .description('Show approval status for all gates')
222
- .option('--json', 'Output as JSON')
223
- .action((feature, options) => approvalStatusCommand(feature, options));
224
-
225
215
  program
226
216
  .command('unapprove <feature> <gate>')
227
217
  .description('Revoke a phase gate approval (recovery only)')
@@ -324,29 +314,6 @@ graphCommand
324
314
  .option('--json', 'Output as JSON')
325
315
  .action((options) => graphDiffCommand(options));
326
316
 
327
- program
328
- .command('validate [validator]')
329
- .description('Run project validations (all | packages | next-components | spec-tasks)')
330
- .option('-v, --verbose', 'Show detailed output')
331
- .option('--auto-fix, --fix', 'Auto-fix issues where possible')
332
- .option('--no-fail', 'Don\'t exit with error code')
333
- .action((validator, options) => {
334
- const args = validator ? [validator] : ['all'];
335
- if (options.verbose) args.push('--verbose');
336
- if (options.autoFix) args.push('--auto-fix');
337
- if (options.noFail) args.push('--no-fail');
338
- validateCommand(args);
339
- });
340
-
341
- program
342
- .command('validate-feature <feature>')
343
- .alias('vf')
344
- .description('Content-aware validation of feature outputs (sections, schema, contracts)')
345
- .option('--phase <phase>', 'Validate outputs for a specific phase')
346
- .option('--json', 'Output as JSON')
347
- .option('-v, --verbose', 'Show detailed output')
348
- .action(validateFeatureCommand);
349
-
350
317
  program
351
318
  .command('verify <feature>')
352
319
  .description('Run the deterministic verification chain (build + tests + validators + e2e) — code before the LLM judge')
@@ -361,6 +328,7 @@ program
361
328
  .option('--skip-build', 'Skip the build node. The run is partial, so the verification stamp is NOT written (reason "skip-build")')
362
329
  .option('--skip-e2e', 'Skip the e2e node (Docker bring-up + Playwright)')
363
330
  .option('--fresh-e2e', 'Recreate the e2e stack from scratch: down -v (named volumes included), drop the freshness stamp, rebuild, re-seed auth')
331
+ .option('--fresh', 'Run every node again, ignoring the green nodes of an earlier run on this same tree. Use it when something outside the tree changed (.env, database, tool version)')
364
332
  .option('--no-persist', 'Do not persist the result to feature.json')
365
333
  .option('--ci', 'Push morph/<feature> and take the proof from the project CI (creates .github/workflows/morph-verify.yml when no workflow runs verify). Same stamp, source "ci". No GitHub/gh/workflow = local verify with the reason; uncommitted code = exit 2; CI still running after 50 min = exit 3, no stamp')
366
334
  .action((feature, options) =>
@@ -593,14 +561,6 @@ program
593
561
  .option('--no-open', 'Don\'t auto-open browser')
594
562
  .action(dashboardCommand);
595
563
 
596
- program
597
- .command('telemetry')
598
- .description('Summarize the harness execution path from .morph/logs/events.jsonl (skills, personas, standards read, tool mix, prompts)')
599
- .option('--json', 'Output the aggregate as JSON')
600
- .option('--feature <name>', 'Narrow the summary to a single feature')
601
- .option('--limit <n>', 'How many recent prompts to show (default 10)', (v) => parseInt(v, 10))
602
- .action((options) => telemetryCommand(options));
603
-
604
564
  program
605
565
  .command('cost [feature]')
606
566
  .description('Cost-facing view over .morph/logs/events.jsonl: bytes re-ingested by kind (proxy, not tokens), dispatch counts, real tokens when observed')
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "morph-spec",
3
- "version": "10.1.1",
3
+ "version": "10.2.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",
@@ -56,7 +56,6 @@ plan → [uiux] → implement → review
56
56
  ```bash
57
57
  morph-spec approve {feature} {gate} [--mode auto] [--approver name] [--notes text]
58
58
  morph-spec advance {feature} [--design] # approve current gate + scaffold next phase folder in one step
59
- morph-spec approval-status {feature} [--json]
60
59
  morph-spec unapprove {feature} {gate} [--reason text] # recovery only
61
60
  morph-spec finish {feature} --pr|--merge [--push] [--keep] [--force] [--discard-ignored] # normal close: archive + integrate + teardown
62
61
  morph-spec archive {feature} [--force] [--no-git] # archive WITHOUT integrating (legacy feature / cleanup)
@@ -97,8 +96,6 @@ Never hand-edit `gateDecisions`/`approvalGates` in `feature.json` — always go
97
96
 
98
97
  ```bash
99
98
  morph-spec plan-check {feature} [--json] # validate plan.md, write checks.json
100
- morph-spec validate [packages|next-components]
101
- morph-spec validate-feature {feature} [--phase {phase}] [--json] # alias: vf
102
99
  morph-spec verify {feature} [--json] [--filter <ids>] [--skip-tests] [--skip-build] [--skip-e2e] # deterministic build+test+validator+e2e chain; the gate reads the unfiltered run
103
100
  morph-spec report-check {feature} [--before-eval] [--json] # state of the implement and of the Gate 3 reports
104
101
  ```
@@ -177,4 +174,4 @@ Never edited directly — go through CLI commands or `morph-spec update` instead
177
174
 
178
175
  ---
179
176
 
180
- *morph-spec v10.1.1 by Polymorphism Tech*
177
+ *morph-spec v10.2.0 by Polymorphism Tech*
@@ -89,4 +89,4 @@ morph-spec doctor
89
89
 
90
90
  ---
91
91
 
92
- *morph-spec v10.1.1 by Polymorphism Tech*
92
+ *morph-spec v10.2.0 by Polymorphism Tech*
@@ -18,7 +18,6 @@
18
18
  "prove",
19
19
  "scenario",
20
20
  "check",
21
- "mutation",
22
21
  "verdict"
23
22
  ],
24
23
  "stack": [
@@ -23,7 +23,7 @@ is idempotent: an already-archived feature is reported, not re-moved.
23
23
 
24
24
  1. **Verify** the feature cleared Gate 3:
25
25
  ```
26
- morph-spec approval-status {feature}
26
+ morph-spec status {feature} --verbose
27
27
  ```
28
28
  Gate `review` must be `approved`. If not, stop and report what's missing (don't force it without
29
29
  the user's say-so).
@@ -26,15 +26,14 @@ These checks verify that the morph-spec workflow is complete for the feature bei
26
26
 
27
27
  ```bash
28
28
  # Verify implementation is complete
29
- morph-spec status {feature}
30
- morph-spec approval-status {feature}
29
+ morph-spec status {feature} --verbose
31
30
  morph-spec verify {feature}
32
31
  ```
33
32
 
34
33
  | Check | Command | Pass Criteria |
35
34
  |-------|---------|---------------|
36
35
  | Phase is implement or later | `status` | phase derived as `implement` or `review` |
37
- | All gates approved | `approval-status` | plan (and uiux, when the feature has design) and review gates approved |
36
+ | All gates approved | `status --verbose` | plan (and uiux, when the feature has design) and review gates approved |
38
37
  | Build, tests and validators pass | `verify` | every node green |
39
38
  | All slices reported | `status` → Fatias | 0 slices pending or partial |
40
39
 
@@ -42,7 +41,7 @@ morph-spec verify {feature}
42
41
 
43
42
  ---
44
43
 
45
- **Tests:** run them only through `morph-spec verify {feature}` — never a bare `dotnet test`/`npm test`, which bypasses the heavy-job queue and leaves no proof the gates read. Any red node blocks deployment.
44
+ **Tests:** the proof the gates read is `morph-spec verify {feature}` without `--filter`. A bare `dotnet test`/`npm test` is fine for checking something (it goes through the heavy-job queue), but it leaves no proof. Any red node blocks deployment.
46
45
 
47
46
  ---
48
47
 
@@ -64,7 +64,7 @@ A tabela é a mesma régua que `shouldPauseGate` aplica e que o `gate-check` imp
64
64
  | `screenChecklist` | item em aberto | **uiux**: tela sem checklist completo, nenhuma `## Tela:`, ou tela sem protótipo, prints 1280/390, ou seletor e print de cada estado. **ship**: item decidido sem linha em `## Checklist de tela` |
65
65
  | `planChecks` | ≠ `fresh` | `checks.json` ausente ou de outra versão do `plan.md`: rode `plan-check`. Só no **plano**; `approve`/`advance` automáticos recusam |
66
66
  | `workType` | === 'hotfix' | Gate 3 pausa SEMPRE (ver "Gates por tipo de trabalho") |
67
- | `planCritique` | `missing`/`no-hash`/`stale` | `2-plan/critica.md` ausente, sem `**Plan-Hash:**`, ou de outra versão do plano sem `**Aplicado-em:**` que cubra a atual (`signal: plan-critique`). Só no **plano**, `chore`/`hotfix` isentos (`exempt`); `approve`/`advance` automáticos recusam |
67
+ | `planCritique` | `missing`/`no-hash`/`stale` | `2-plan/critica.md` ausente, sem `**Plan-Hash:**`, ou de outra versão do plano sem `**Aplicado-em:**` que cubra a atual (`signal: plan-critique`). Só no **plano**, `chore`/`hotfix` e plano pequeno isentos (`exempt`: até 3 cenários, todos `prova: teste` ou `leitura`, sem degrau e sem mídia gerada); `approve`/`advance` automáticos recusam |
68
68
  | `rungs` | `Cn` com `degrau: migrar` ou `irreversível` (`checks.json`) | **plano**: pausa nomeando os `Cn` (`signal: rung`), e `approve`/`advance` automáticos recusam. **ship**: pausa se o plano não teve aprovação humana (chore, policy) |
69
69
  | `migrationsTouched` | caminho `migrations/` no diff, `rungs` vazio | Feature mexeu em dado sem declarar o degrau (`signal: rung-undeclared`). Só no **ship** |
70
70
  | `contractsTouched` | `openapi.{yaml,yml,json}`, `swagger.{…}` ou `.proto` no diff, sem `Cn` `irreversível` | O arquivo de contrato mudou sem o degrau (`signal: rung-undeclared`). Webhook e endpoint ficam com o bloqueador `irreversivel` do revisor. Só no **ship** |
@@ -125,7 +125,7 @@ Feature sem `workType` (pré-v8) é tratada como `feature`.
125
125
 
126
126
  ### finish
127
127
 
128
- **`morph-spec finish <feature> --pr|--merge`** — o **encerramento único** da feature (worktree ou comum): **arquiva** commitando `features/{f} → archive/{f}` dentro de `morph/{f}` — depois integra: `--merge` na hora; `--pr` só abre o PR, sem integrar. **O payload distingue os dois** — `--merge` sai com `integrated: true`; `--pr` sai com `integrated: false`, `awaiting: "merge"`, `pr: {number, url}` e um `nextStep` apontando para o merge. **`finished: true` sai nos dois casos e marca só o comando ter terminado, nunca a integração** — `--pr` não encerra a feature, quem encerra é o merge do PR; até lá ela aparece em `morph-spec list`/`fleet` como `aguardando-merge`, sem cópia local (ver `list` acima e `doctor` abaixo).
128
+ **`morph-spec finish <feature> --pr|--merge`** — o **encerramento único** da feature (worktree ou comum; a sessão escolhe por `finish.mode` do `config.json`, padrão `pr`): **arquiva** commitando `features/{f} → archive/{f}` dentro de `morph/{f}` — depois integra: `--merge` na hora; `--pr` só abre o PR, sem integrar. **O payload distingue os dois** — `--merge` sai com `integrated: true`; `--pr` sai com `integrated: false`, `awaiting: "merge"`, `pr: {number, url}` e um `nextStep` apontando para o merge. **`finished: true` sai nos dois casos e marca só o comando ter terminado, nunca a integração** — `--pr` não encerra a feature, quem encerra é o merge do PR; até lá ela aparece em `morph-spec list`/`fleet` como `aguardando-merge`, sem cópia local (ver `list` acima e `doctor` abaixo).
129
129
 
130
130
  **Índice de ADRs:** no mesmo commit do archive, o `finish` lê o `2-plan/plan.md` da feature: os ADRs de `## Decisões` com `**Alcance:** projeto` entram em `.morph/specs/adrs/` (um arquivo por ADR, `{feature}--ADR-{nnn}.md` — features paralelas nunca tocam o mesmo arquivo), e a tabela `ADR | Mudança | Escopo` de `### Conflitos com o acervo` retira (total, o arquivo sai) ou estreita (parcial, `scope:` no arquivo) os ADRs revogados — forma em `morph-spec plan-check --help`; o `plan-check` avisa, enquanto o plano ainda muda, a linha que o `finish` não aplicaria. O payload traz `adrIndex: {added, revoked, narrowed}` e `revocationProblems: [{id, reason, message}]` (linha malformada ou ambígua, ADR fora do índice) — cada chave só aparece com conteúdo, e nenhum problema recusa o `finish`.
131
131
 
@@ -277,7 +277,11 @@ Fail-open: uma poda que falha nunca derruba o comando. PROVE-or-DECLINE como o r
277
277
 
278
278
  ### verify
279
279
 
280
- **`morph-spec verify <feature> [--json] [--timeout <ms>] [--filter <ids>] [--record-baseline] [--refresh-cache] [--linux] [--linux-image <img>] [--skip-tests] [--skip-build] [--skip-e2e] [--fresh-e2e] [--no-persist] [--ci]`** — cadeia determinística build+testes+validadores+**e2e** (código antes do juiz LLM), sempre sobre a feature inteira. Antes da cadeia, instala em `.claude/rules/` (fora do git) a regra de stack que falta, pela stack do `config.json` somada à que os arquivos mostram (em stderr; nunca muda o veredito). Nunca grava o `config.json`: arquivo versionado mudado pelo próprio verify tornaria a prova velha e a árvore suja para o `--ci`; a tag nova entra pelo `plan-check` ou pelo `update`.
280
+ **`morph-spec verify <feature> [--json] [--timeout <ms>] [--filter <ids>] [--record-baseline] [--refresh-cache] [--linux] [--linux-image <img>] [--skip-tests] [--skip-build] [--skip-e2e] [--fresh-e2e] [--fresh] [--no-persist] [--ci]`** — cadeia determinística build+testes+validadores+**e2e** (código antes do juiz LLM), sempre sobre a feature inteira. Antes da cadeia, instala em `.claude/rules/` (fora do git) a regra de stack que falta, pela stack do `config.json` somada à que os arquivos mostram (em stderr; nunca muda o veredito). Nunca grava o `config.json`: arquivo versionado mudado pelo próprio verify tornaria a prova velha e a árvore suja para o `--ci`; a tag nova entra pelo `plan-check` ou pelo `update`.
281
+
282
+ **Reaproveitamento entre rodadas:** uma rodada completa grava o resultado por nó em `.git/morph-spec/verify-nodes-<feature>.json`, chaveado pela impressão da árvore (a régua de `treeFingerprint`). Mesma árvore: build, suíte e e2e verdes não rodam de novo (`reused[]`; os validadores sempre rodam), e o vermelho que passa agora entra em `flaky[]`, que o stamp guarda em `verification.flaky` para o verificador. Árvore mudada: nada é aproveitado; as suítes vermelhas da rodada anterior rodam primeiro e, se uma seguir vermelha, as outras ficam `skip` (`adiado: …`) e a rodada sai vermelha. O stamp verde sempre exige todos os nós verdes na árvore atual. Rodada parcial (`--filter`, `--skip-*`, `--linux`, `--record-baseline`, `--refresh-cache`, `--fresh-e2e`) não lê nem grava. O que a impressão não vê (`.env`, banco, versão de ferramenta): `--fresh` roda tudo.
283
+
284
+ **Reexecução de spec que falhou:** com Playwright (`npx playwright test`), specs vermelhas rodam mais uma vez com `--last-failed` (exige Playwright 1.44+). Passaram: nó verde, com os nomes em `flaky[]`. Falharam: nó vermelho. `e2e.command` próprio sem `playwright` como argumento não reexecuta. Cada rodada de specs tem teto de `e2e.specTimeoutMs` (padrão 300 000 ms).
281
285
 
282
286
  O nó `e2e` é o único que **executa** a aplicação: sobe o `docker compose` do projeto, espera todo serviço ficar saudável, sobe os `e2e.hostServices` (ver `e2e` abaixo) e roda as specs Playwright quando existem (sem specs, ainda vale como smoke — "a app sobe" já pega o serviço não registrado que derruba o startup e passa batido no build). Sem compose mas com `e2e.hostServices`, a stack é **só de host**: sobem só os serviços do host, sem lint de isolamento do compose nem checagem de Docker, e as specs rodam contra eles. Sem compose e sem `e2e.hostServices`, o nó pula com `project does not containerize (no compose file)`; com a integração `docker` declarada e sem compose, falha.
283
287
 
@@ -311,7 +315,7 @@ Forma de `project.tests[]`, prefixo `[tests/{id}]` do `feedback[]`, regra de con
311
315
 
312
316
  **Validador que não pôde checar:** o `reuse` sem grafo não acha nada, e um `validators: pass` dos outros validadores o esconderia — então o `feedback[]` diz `[validators/reuse] não rodou: …`, e o nó leva `skipped: [{ validator, reason, unavailable }]` (quando o nó inteiro pula, a própria linha dele já dá o motivo).
313
317
 
314
- **Nós pesados (build, testes, e2e) esperam por slot** (`morph-spec heavy`): estouro da espera sai **exit 3** com `status: "busy"`, `persisted: false`, sem stamp e sem evento — não é vermelho, e o orquestrador repete a chamada.
318
+ **Nós pesados (build, testes, e2e) esperam por slot** (`morph-spec heavy`): estouro da espera sai **exit 3** com `status: "busy"`, `persisted: false`, sem stamp e sem evento — não é vermelho: `morph-spec heavy status` mostra quem segura a vaga, e cabe no máximo uma chamada nova.
315
319
 
316
320
  **Por quê.** Um worktree é o checkout de OUTRA feature do mesmo repo: incluí-lo dobrava o relógio e reportava sobre código que não está sob verificação (em campo isso deu `tests: fail` no timeout exato, com o sumário da 1ª execução dizendo "Com falha: 0"). Sem o `down -v`, "fresco" reconstruiria a imagem e preservaria o banco velho — e um `storageState` capturado contra aquele banco continuaria autenticando um usuário que não existe mais (403 que parece app quebrada). A regra do `morph-plan` — *baseline é um COMANDO, nunca um número* — sobrevive porque ninguém digita o número: um valor escrito à mão descreve o que alguém acreditou, e a divergência entre a crença e a suíte é o defeito que a regra existe para matar.
317
321
 
@@ -325,7 +329,7 @@ Nenhuma regra de gate muda: e2e pulado, escopo, evals e stale valem igual.
325
329
 
326
330
  **Sem CI cai para o verify local, com a razão** (`project.ci: false`, origin fora do GitHub, `gh` ausente ou sem login, branch que não é `morph/<feature>`, token HTTPS sem o escopo `workflow` para criar o arquivo, push recusado, workflow que não disparou nem com `gh workflow run`, run sem o artefato). **Antes de tudo — com ou sem CI — reaproveita o stamp fresco:** último stamp `pass` com `sha`, nada fora de commit e `treeMovedSince(stamp.sha, HEAD)` falso (a mesma régua do `verificationStale`: só `.morph/` andou, ou o `config.json` mudou só `expectedCount`) → não empurra, não roda nem regrava, imprime "stamp verde e fresco — o CI não roda de novo" e o `--json` traz `reason: "stamp-reused"`, `status`, `nodes` e `reused: { sha, ts, source }`, exit 0.
327
331
 
328
- Código não commitado é **erro de uso (exit 2)**: o CI só vê commits. CI rodando depois de 50 min é **exit 3** (`status: "pending"`), sem stamp — não é vermelho. Com task, `--filter`, `--skip-*`, `--fresh-e2e`, `--linux`, `--record-baseline`, `--refresh-cache` ou `--no-persist`, erro de uso. Vermelho imprime `gh run view <id> --log-failed`.
332
+ Código não commitado é **erro de uso (exit 2)**: o CI só vê commits. CI rodando depois de 50 min é **exit 3** (`status: "pending"`), sem stamp — não é vermelho. Com `--filter`, `--skip-*`, `--fresh-e2e`, `--linux`, `--record-baseline`, `--refresh-cache` ou `--no-persist`, erro de uso. Vermelho imprime `gh run view <id> --log-failed`.
329
333
 
330
334
  **O runner só tem o que está commitado:** o que testes e e2e leem de arquivo gitignored (`.env`, `appsettings.*.json` local, connection string do Neon, `storageState`) não existe lá, e o run sai vermelho sem defeito de código. Declare o valor como secret do repositório e exponha-o no `env:` do job em `morph-verify.yml` (o arquivo é do projeto: editar à mão é o esperado, e o `findCiWorkflow` continua aceitando enquanto ele chamar `morph-spec verify` publicando `morph-verify.json`), ou desligue com `project.ci: false`.
331
335
 
@@ -341,7 +345,7 @@ Teste é o que o validador `reuse` já trata como teste: arquivo `.test.` ou `.s
341
345
 
342
346
  Sem base git, ou sem linha de código adicionada, pula com razão; qualquer falha de medição também vira skip, nunca erro.
343
347
 
344
- **Por quê.** Excesso justificado não silencia o aviso: a justificativa mora na task (orçamento do `morph-plan` passo 5), e é lá que o avaliador a lê.
348
+ **Por quê.** Excesso justificado não silencia o aviso: a justificativa mora no plano, junto do `Cn`, e é lá que o revisor a lê.
345
349
 
346
350
  ### e2e
347
351
 
@@ -408,11 +412,11 @@ Isolamento entre worktrees: `COMPOSE_PROJECT_NAME=morph-{feature}` separa os con
408
412
 
409
413
  Env: `MORPH_HEAVY=off` desliga o semáforo (a suíte do próprio framework roda assim, para não enfileirar nos slots reais de quem a executa), `MORPH_HEAVY_SLOTS`, `MORPH_HEAVY_DIR`.
410
414
 
411
- **Fim do trabalho pesado:** no fim do `verify` de escopo **feature** (nunca no de task: cada task pagaria compilador frio), no `e2e down` e no `doctor --procs --prune`, roda `dotnet build-server shutdown` **só** se o projeto é .NET, `heavy.shutdownBuildServer` está ligado, nenhum slot está preso **e** nenhum `dotnet build|test|publish|msbuild` está vivo — sub-agents e IDE não passam pelo semáforo, então semáforo vazio não basta. Fail-open, timeout de 30 s, resultado em `cleanup` no `--json`.
415
+ **Fim do trabalho pesado:** no `e2e down`, no `doctor --procs --prune` e no fim do `verify` quando a RAM livre está abaixo do dobro de `heavy.minFreeMemGb` (com folga, o compilador fica quente para a próxima rodada), roda `dotnet build-server shutdown` **só** se o projeto é .NET, `heavy.shutdownBuildServer` está ligado, nenhum slot está preso **e** nenhum `dotnet build|test|publish|msbuild` está vivo — sub-agents e IDE não passam pelo semáforo, então semáforo vazio não basta. Fail-open, timeout de 30 s, resultado em `cleanup` no `--json`.
412
416
 
413
417
  `heavy.disableNodeReuse` põe `MSBUILDDISABLENODEREUSE=1` enquanto o nó pesado roda e restaura depois. O `--linux` rotula o container (`morph-spec.verify=<runId>`, `morph-spec.root=<hash da raiz>`) e o remove **por label** no fim, nunca por nome; o `e2e down` usa `--remove-orphans`. O `verify` **não** derruba a stack e2e (`keepUp` é por desenho).
414
418
 
415
- **Bash cru (`heavy.gateBash`, padrão `true`):** a sessão roda build e teste quando quer, e um `dotnet build`/`dotnet test`/`npm test` pelo Bash não passa pelo `verify`, então dois elos das chains de hooks fazem o papel dele. `heavy-gate` (`PreToolUse` Bash, depois de todos os guards e antes do `worktree-env-inject`) reconhece o comando pesado — `dotnet build|test|publish|pack|msbuild|vstest`, `msbuild`, `npm|pnpm|yarn|bun test|build` e `run test|build[:x]`, `npx vitest|jest|playwright test|next build`, `vitest`, `jest`, `playwright test`, `next|vite build`, `docker build|buildx`, `docker compose|docker-compose up|build` — olhando o executável de cada segmento (`;`, `&&`, `||`, `|`, quebra de linha, fora de aspas): `git commit -m "fix build && dotnet test"` e `morph-spec heavy run -- dotnet test` não são pesados. Toma um slot e espera até `min(waitTimeoutSec, 540)` s — abaixo dos 600 s de timeout do hook, porque um `PreToolUse` que estoura o timeout **deixa a chamada rodar**; sem vaga no prazo a chamada é **recusada** (`deny`) com a causa e a instrução de repetir o mesmo comando, e não roda.
419
+ **Bash cru (`heavy.gateBash`, padrão `true`):** a sessão roda build e teste quando quer, e um `dotnet build`/`dotnet test`/`npm test` pelo Bash não passa pelo `verify`, então dois elos das chains de hooks fazem o papel dele. `heavy-gate` (`PreToolUse` Bash, depois de todos os guards e antes do `worktree-env-inject`) reconhece o comando pesado — `dotnet build|test|publish|pack|msbuild|vstest`, `msbuild`, `npm|pnpm|yarn|bun test|build` e `run test|build[:x]`, `npx vitest|jest|playwright test|next build`, `vitest`, `jest`, `playwright test`, `next|vite build`, `docker build|buildx`, `docker compose|docker-compose up|build`, `powershell|pwsh` com um `.ps1` cujo nome contém `test` — olhando o executável de cada segmento (`;`, `&&`, `||`, `|`, quebra de linha, fora de aspas): `git commit -m "fix build && dotnet test"` e `morph-spec heavy run -- dotnet test` não são pesados. Toma um slot e espera até `min(waitTimeoutSec, 540)` s — abaixo dos 600 s de timeout do hook, porque um `PreToolUse` que estoura o timeout **deixa a chamada rodar**; sem vaga no prazo a chamada é **recusada** (`deny`) com a causa e a instrução de repetir o mesmo comando, e não roda.
416
420
 
417
421
  `heavy-release` (`PostToolUse` e `PostToolUseFailure`) devolve a vaga pelo `tool_use_id`.
418
422
 
@@ -496,7 +500,9 @@ A auditoria devolve **nada** sem tentar coisa alguma em três situações: nenhu
496
500
 
497
501
  **Bloco `tree`**, em toda saída e sem mudar o exit code: `dirty`/`dirtyCount` (o que não está commitado fora de `.morph/`), `diffstat` (contra `HEAD`), `commits` (sobre a default, até 10; `null` sem default), `fingerprint` (a mesma impressão da árvore que o `verify` grava), `head` e `branch`; `null` quando o git não responde — desconhecido, nunca árvore limpa.
498
502
 
499
- **`--before-eval`** grava em `4-review/tree-before-eval.json` a foto da árvore (HEAD e o blob de cada arquivo que difere dele, fora de `.morph/`) antes do dispatch da review, e daí em diante toda saída traz `tree.sinceBeforeEval: { at, headChanged, changed, changedCount }`; sem foto, `null`. Traz também `reviewModels`: `verifier.model` (`sonnet` por padrão, `opus` com prompt/modelo tocado ou plano que cita standard de mídia, com `reasons`), `reviewer.model` (`sonnet`), `security: { required, model: 'opus', reasons }` (exigida quando o diff toca auth, segredo, permissão, dado pessoal, pagamento ou migration) e `required`, a lista de papéis que o Gate 3 cobra — a mesma regra do `gate-check` e do `approve`.
503
+ **`--before-eval`** grava em `4-review/tree-before-eval.json` a foto da árvore (HEAD e o blob de cada arquivo que difere dele, fora de `.morph/`) antes do dispatch da review, e daí em diante toda saída traz `tree.sinceBeforeEval: { at, headChanged, changed, changedCount }`; sem foto, `null`. Traz também `reviewModels`: `verifier.model` (`sonnet` por padrão, `opus` com prompt/modelo tocado ou plano que cita standard de mídia, com `reasons`), `reviewer.model` (`sonnet`), `security: { required, model: 'opus', reasons }` (exigida quando o diff toca auth, segredo, permissão, dado pessoal, pagamento ou migration) e `required`, a lista de papéis que o Gate 3 cobra — a mesma regra do `gate-check` e do `approve`. Traz ainda `gate3: { base, sha, flaky, e2eChecks, redispatch }`: `base` é o merge-base com a default, `flaky` vem do stamp do verify, `e2eChecks` lista os `Cn` de `prova: e2e`, e `redispatch` diz quem volta depois de uma correção (`null` sem relatório: todos os exigidos vão).
504
+
505
+ **Quem volta depois de uma correção** (`reviewConfirmation`, a mesma regra no `approve`): papel com relatório atrás do `HEAD` volta, salvo SHIP limpo com delta estreito: arquivos citados nos bloqueadores (caminho ou nome) ou de teste; para a segurança, nenhum caminho sensível. O `approve` só dispensa esse papel com um papel que abriu bloqueador revisando o `HEAD`.
500
506
 
501
507
  ### plan-check
502
508
 
@@ -545,6 +551,7 @@ em runtime é `validateTestNodes()` — schema que ninguém chama é decoração
545
551
  | `command` | **sim** | Comando literal, executado por shell a partir de `cwd`. Nunca é reescrito pelo verify |
546
552
  | `cwd` | não (`"."`) | Relativo à raiz. Path que resolve **fora** da raiz é recusado (endereça outra árvore); `cwd` inexistente é `fail`, não `skip` — um skip esconderia erro de plano atrás de gate verde |
547
553
  | `runner` | não (inferido) | `dotnet` \| `vitest` \| `jest` \| `node-test` \| `playwright` \| `none` \| `evals`. Escolhe o extrator de contagem e se há prebuild. `none` = contagem DESCONHECIDA, nunca zero. `evals` é o único que **nunca é inferido** e o único que exige um bloco próprio — ver a subseção abaixo |
554
+ | `parallel` | não | `true` roda o nó junto dos outros nós com `parallel: true`. Declare só em suítes que não dividem banco, porta nem pasta de build: o harness não tem como conferir. Ignorado em nó `e2e: true` e numa rodada que persegue suíte vermelha |
548
555
  | `expectedCount` | não | Baseline, só nos runners de contagem estável (`node-test`, `evals`); nos outros é ignorada e o `--record-baseline` a apaga. **Gravada só por `verify --record-baseline`**, nunca digitada. Ausente = `null` (sem baseline); `0` **declarado** é uma baseline real |
549
556
  | `timeoutMs` | não | Teto deste nó. Sem ele vale `project.testTimeoutMs` e, por fim, o default de 30 min × projetos encadeados |
550
557
  | `build` | não | Projetos a compilar antes (só `runner: "dotnet"`). Declarado em nó não-dotnet é erro de config, não campo silenciosamente ignorado |
@@ -691,8 +698,8 @@ testes reprova com `suíte verde sem nenhum teste executado` (suíte que parou d
691
698
  **Prebuild e `--no-build` (por que um `dotnet test` verde podia mentir).** Num nó `runner: "dotnet"`,
692
699
  os alvos de `build[]` — ou, na falta, os `.csproj` extraídos do próprio `command` — são compilados
693
700
  antes da execução, com `dotnet build "<alvo>" --nologo -v q`. Compilação que falha reprova o nó com
694
- `test-build-failed` e a suíte **não roda**. E um `command` com `--no-build` que **não** foi precedido de
695
- build nesta invocação reprova com `stale-binary`, também sem executar: o predicado é "houve build
701
+ `test-build-failed` e a suíte **não roda**. E um `command` `dotnet` com `--no-build` que **não** foi precedido de
702
+ build nesta invocação (prebuild do nó ou `dotnet build` verde do nó `build`) reprova com `stale-binary`, também sem executar: o predicado é "houve build
696
703
  aqui", nunca "a flag está ausente". Sem isso, um projeto de teste que deixou de compilar era executado
697
704
  a partir do binário do último build bom e reportava verde. `--no-build` continua legítimo — com
698
705
  `build[]` declarado ele roda normalmente, e o comando do usuário nunca é reescrito para isso. O prebuild é
@@ -721,10 +728,10 @@ nomeia o nó que impediu. **Apagar teste é legítimo:** a única cor vermelha q
721
728
  | CI | a suíte inteira, todos os nós (`morph-verify.yml` roda o mesmo `verify {f}`) | idem — e o artefato dele é o stamp do Gate 3 |
722
729
  | Evidência de eval (cenário `Cn` com `prova: eval`) | `verify {f} --filter {id-do-nó-evals} --skip-e2e --no-persist` | Só o nó `evals`, sem stack e sem carimbo — é a evidência de runtime do cenário, não a verificação da feature |
723
730
 
724
- > **Não existe constante de 600 s neste CLI** (`grep -rn "600000" src/ framework/` → zero). O teto que
725
- > corta uma suíte longa é o do **Bash do harness**, externo ao morph-spec, e não há o que "subir". Os
726
- > relógios que o CLI de fato tem: build **1 800 000 ms** (30 min), tests **1 800 000 ms por projeto de teste
727
- > encadeado**, health do e2e **180 000 ms**, specs Playwright **300 000 ms**, mais `timeoutMs` por nó,
731
+ > **Nenhum relógio do `verify` é de 600 s.** Os 600 s que existem são a espera da fila
732
+ > (`heavy.waitTimeoutSec`) e o teto do **Bash do harness**, externo ao morph-spec: é ele que corta uma
733
+ > suíte longa, e não há o que "subir". Os relógios que o `verify` de fato tem: build **1 800 000 ms** (30 min), tests **1 800 000 ms por projeto de teste
734
+ > encadeado**, `up --build` e health do e2e **180 000 ms**, specs Playwright **300 000 ms** (`e2e.specTimeoutMs`), mais `timeoutMs` por nó,
728
735
  > que substitui o default do `tests` quando ele não serve. `--timeout <ms>` cobre build **e** tests.
729
736
  > **A suíte inteira de um projeto grande não cabe numa invocação de Bash do harness:** rode
730
737
  > `verify {feature}` com `run_in_background` e
@@ -828,10 +835,6 @@ O servidor MCP (`graphify-mcp`, 10 tools, todas aceitando `project_path`) está
828
835
 
829
836
  ## Observabilidade
830
837
 
831
- ### telemetry
832
-
833
- **`morph-spec telemetry [--json] [--feature <f>] [--limit <n>]`** — reconstrói o caminho de execução da sessão a partir de `.morph/logs/events.jsonl` (skills invocadas, sub-agents despachados + prompts, standards/base de conhecimento lidos, mix de tools, prompts do usuário). Capturado silenciosamente pelo hook `telemetry-log`. Lê também os arquivos rotacionados em `.morph/logs/sessions/`, sem contar duas vezes a mesma linha.
834
-
835
838
  ### cost
836
839
 
837
840
  **`morph-spec cost [feature] [--by phase] [--all-trees] [--json]`** — custo da execução a partir do mesmo `events.jsonl`: bytes de contexto lidos por tipo (`checklist`/`standard`/`skill`/`agents`/`plan-artifact`/`code` — a janela `offset`/`limit` que o Read trouxe, quando o evento a tem), dispatches + tamanho de prompt e mix de tools. Bytes são **proxy determinística** de custo de token; os tokens reais vêm à parte: `SessionUsage` (sessão principal) e `AgentUsage` (cada sub-agent, lido do transcript dele), somados **só pelo `delta`** de cada amostra — o total de topo é acumulado e se repete a cada Stop, e amostra antiga sem `delta` fica fora da soma e é contada como legada. `--all-trees` agrega **toda árvore git** com log, mais os logs que o `finish` guardou em `.git/morph-spec/finish-backup/` antes de desmontar a worktree, deduplicados por `eventId`.
@@ -866,6 +869,6 @@ Os eventos v1 (sem `schemaVersion`) nunca contam como zero: sem agente, exit cod
866
869
  | `ToolBatch` | `size`, `tools` (contagem por ferramenta) — um por turno do modelo |
867
870
  | `SessionUsage` / `AgentUsage` | totais acumulados do transcript + `delta` desde a amostra anterior |
868
871
  | `Gate` | `gate`, `step` (`decision`/`approve`/`unapprove`); na `decision`: `pause`, `signal` (a regra que pausou, `null` sem pausa, `manual` pelo `gate-decision`) e `source`; nas outras: `approvedBy`. Gravado por `persistGateDecision` e pelos dois escritores de aprovação (CLI e hook `state-sync`) |
869
- | `Verify` | `status`, `sha`, `fingerprint` (a árvore fora de `.morph/`), `tree` (`root`/`worktree`), `persisted`, `reason`, `source` (`local`/`ci`: a rodada do `--ci` registra os nós e durações do run), `nodes.{build,tests,validators,e2e}.{status,durationMs}`, `nodes.validators.{ran,skipped}` |
872
+ | `Verify` | `status`, `sha`, `fingerprint` (a árvore fora de `.morph/`), `tree` (`root`/`worktree`), `persisted`, `reason`, `source` (`local`/`ci`: a rodada do `--ci` registra os nós e durações do run), `nodes.{build,tests,validators,e2e}.{status,durationMs}`, `nodes.tests.runs[].{id,status,durationMs}` (qual suíte custou o quê), `reused[]`, `flaky[]`, `nodes.validators.{ran,skipped}` |
870
873
 
871
874
  Nada da saída de um comando é gravado — nem o corpo de erro do `PostToolUseFailure`, onde um valor de `.env` pode aparecer sem nome ao lado. `command`, `prompt`, `desc` e `target` passam por redação de segredos óbvios antes de gravar: valor ao lado de um nome de segredo (`API_KEY=…`, `*_KEY`/`*_PASS=…`, `--password …`, `user-secrets set <k> <v>`, `AccountKey=…`), `Authorization: …`, `user:pass@` (senha com `/` inclusive) e `?sig=`/`?access_token=` em URL, e formatos de token (GitHub, `sk-`, `sk_live_`, `npm_`, Slack, AWS, Google, JWT). É por padrão, não por garantia: um valor sem nome ao lado passa.
@@ -33,7 +33,7 @@ typechecks or runs tests: that is `morph-spec verify`.
33
33
  | **PostToolUse** (Write\|Edit) | comment-guard.js | Blocking | Blocks when the edit ADDED a comment the `architecture-code-comments` standard never allows: process history (ADR, scenario/slice id, gate, plan.md), banner/`#region`, a block over 3 lines, or a doc-comment outside a published package. Old comments are never reported; last link of the chain |
34
34
  | **PostToolUse** (Write\|Edit\|AskUserQuestion) | state-sync.js | State sync | Autonomous state updates (feature register, output tracking, gate approval) |
35
35
  | **PostToolUse** (Write\|Edit\|AskUserQuestion) | gate-guard.js | Advisory | Warns (never blocks) when a gate action disagrees with the persisted gateDecisions |
36
- | **PostToolUse** (all tools) | telemetry-log.js | Telemetry | Appends every tool call (skill, sub-agent dispatch + prompt, knowledge-base read, bash/edit) to `.morph/logs/events.jsonl`; silent, fail-open — the NLH execution-path capture read back by `morph-spec telemetry` |
36
+ | **PostToolUse** (all tools) | telemetry-log.js | Telemetry | Appends every tool call (skill, sub-agent dispatch + prompt, knowledge-base read, bash/edit) to `.morph/logs/events.jsonl`; silent, fail-open — the NLH execution-path capture read back by `morph-spec cost` |
37
37
  | **PostToolUse** (all tools) | loop-detect.js | Advisory | Warns when the last 8 actions collapse to ≤2 unique signatures |
38
38
  | **PostToolUse** (all tools) | graph-refresh-kick.js | Background | After a Bash or PowerShell command that runs git/gh/morph-spec, launches the DETACHED refresh of the ROOT's codebase graph (`shared/graph-refresh.js`: cwd in the git common dir, never a worktree; one lock per repository; staleness by `shared/graph-freshness.js` — stamp plus the per-tree run record in `<git-common-dir>/morph-spec/graph-refresh-state.json`); never waits, never injects |
39
39
  | **PostToolUse** (all tools) | masked-exit.js | Advisory | After a Bash test/build/verify run (`testRunKind`/`morphVerifyCall`/`heavyKind` of the producer) whose exit `exitCodeMasked` hides (`\| tail`, `; echo`, `\|\| true`, `&`), names the form and says to write the output to `.morph/logs/test-last.log` and read it; silent when the command prints `$?`/`PIPESTATUS` |
@@ -41,7 +41,7 @@ typechecks or runs tests: that is `morph-spec verify`.
41
41
  | **PostToolBatch** | telemetry-log.js | Telemetry | One `ToolBatch` event per model turn that used tools (the turn counter of `cost --metrics`) |
42
42
  | **SubagentStop** | telemetry-log.js | Telemetry | `AgentUsage`: the sub-agent's token usage read from its own transcript, as a delta per agent; never blocks the stop |
43
43
  | **UserPromptSubmit** | telemetry-log.js | Telemetry | Appends the user's prompt to the same `events.jsonl` stream; silent, fail-open |
44
- | **Stop** | validate-completion.js | Advisory | Warns about incomplete slice reports (hash-deduped, only re-injects when the warning set changes) |
44
+ | **Stop** | validate-completion.js | Advisory | Warns about scenarios marked `feito` without evidence in `3-implement/report.md` (hash-deduped, only re-injects when the warning set changes) |
45
45
 
46
46
  ## Design Principles
47
47
 
@@ -20,10 +20,6 @@ function auditReport(featurePath) {
20
20
  : [];
21
21
  }
22
22
 
23
- const COMPACT_TRIGGER_PHASES = new Set(['implement']);
24
-
25
- const HEAVY_SOURCE_PHASES = new Set(['plan', 'uiux']);
26
-
27
23
  export async function run(payload) {
28
24
  try {
29
25
  if (process.env.MORPH_STOP_HOOK_ACTIVE === '1') return null;
@@ -42,37 +38,6 @@ export async function run(payload) {
42
38
 
43
39
  const currentPhase = feature.phase || derivePhaseForFeature(name, cwd);
44
40
 
45
- const watchPath = join(memDir, 'phase-watch.json');
46
- let phaseWatch = {};
47
- try {
48
- if (existsSync(watchPath)) phaseWatch = JSON.parse(readFileSync(watchPath, 'utf-8'));
49
- } catch { /* corrupt file — start fresh */ }
50
-
51
- const lastPhase = phaseWatch[name];
52
-
53
- // Always stored, so the alert fires once per transition.
54
- try {
55
- phaseWatch[name] = currentPhase;
56
- writeFileSync(watchPath, JSON.stringify(phaseWatch, null, 2), 'utf-8');
57
- } catch {}
58
-
59
- const isPhaseTransition = lastPhase && lastPhase !== currentPhase;
60
- const enteringImplement = COMPACT_TRIGGER_PHASES.has(currentPhase);
61
- const comingFromHeavyPhase = HEAVY_SOURCE_PHASES.has(lastPhase);
62
-
63
- if (isPhaseTransition && enteringImplement && comingFromHeavyPhase) {
64
- warnings.push(
65
- `⚡ Phase advanced: ${lastPhase} → implement`,
66
- ``,
67
- `💡 Recommended before starting implementation:`,
68
- ` Run /compact to free up context — plan.md and the design`,
69
- ` loaded during planning take significant space. A compact now gives`,
70
- ` Claude a clean slate focused on writing code.`,
71
- ``,
72
- ` After /compact, resume with: /morph-apply ${name}`,
73
- );
74
- }
75
-
76
41
  // Hash-gated: the audit re-runs every Stop, so only a changed warning set is injected; resolving clears the hash.
77
42
  try {
78
43
  const featurePath = join(cwd, '.morph', 'features', name);
@@ -187,23 +187,40 @@ export function decideIsolation({ findings = [], insideWorktree = false } = {})
187
187
  }
188
188
 
189
189
  // HEAD alone would let a stack built before an uncommitted edit read as fresh: status and diff
190
- // make any edit move the stamp. `null` outside a git repo.
190
+ // make any edit move the stamp. `.morph/` stays out, config.json aside: the verify's own commits
191
+ // must not rebuild a stack whose source did not change. `null` outside a git repo.
191
192
  export function computeSourceStamp(cwd, exec = defaultExec) {
192
193
  const head = exec('git', ['rev-parse', 'HEAD'], { cwd });
193
194
  if (head.code !== 0) return null;
194
195
 
195
- const status = exec('git', ['status', '--porcelain'], { cwd });
196
- const diff = exec('git', ['diff', 'HEAD'], { cwd });
196
+ const tree = exec('git', ['ls-tree', 'HEAD'], { cwd });
197
+ const status = exec('git', ['status', '--porcelain', ...OUTSIDE_MORPH], { cwd });
198
+ const diff = exec('git', ['diff', 'HEAD', ...OUTSIDE_MORPH], { cwd });
199
+ const entries = tree.code === 0
200
+ ? tree.output.split('\n').filter((line) => line.split('\t')[1] !== '.morph').join('\n')
201
+ : head.output.trim();
197
202
 
198
203
  return createHash('sha1')
199
- .update(head.output.trim())
204
+ .update(entries)
200
205
  .update('\0')
201
206
  .update(status.code === 0 ? status.output : '')
202
207
  .update('\0')
203
208
  .update(diff.code === 0 ? diff.output : '')
209
+ .update('\0')
210
+ .update(readConfigText(cwd))
204
211
  .digest('hex');
205
212
  }
206
213
 
214
+ const OUTSIDE_MORPH = ['--', '.', ':(exclude).morph'];
215
+
216
+ function readConfigText(cwd) {
217
+ try {
218
+ return readFileSync(join(cwd, '.morph', 'config', 'config.json'), 'utf8');
219
+ } catch {
220
+ return '';
221
+ }
222
+ }
223
+
207
224
  export function stampPath(gitCommonDir, projectName) {
208
225
  return join(gitCommonDir, 'morph-spec', `e2e-stamp-${projectName}.json`);
209
226
  }
@@ -775,6 +792,11 @@ export function bringUpE2e(
775
792
  return result({ status: 'pass', stage: null, reason: stack.reason, hostServices: host, auth });
776
793
  }
777
794
 
795
+ function specTimeoutFor(config) {
796
+ const configured = config?.e2e?.specTimeoutMs;
797
+ return Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_SPEC_TIMEOUT;
798
+ }
799
+
778
800
  /** @returns {{ status: 'pass'|'fail'|'skip', command: string|null, output: string, reason: string|null, warnings: string[] }} */
779
801
  export function runE2eCheck(cwd, options = {}) {
780
802
  const {
@@ -787,7 +809,7 @@ export function runE2eCheck(cwd, options = {}) {
787
809
  forceFresh = false,
788
810
  insideWorktree = isSecondaryWorktree(cwd),
789
811
  healthTimeout = DEFAULT_HEALTH_TIMEOUT,
790
- specTimeout = DEFAULT_SPEC_TIMEOUT,
812
+ specTimeout = specTimeoutFor(config),
791
813
  beforeHeavy = null,
792
814
  countSpecs = null,
793
815
  _exec: exec = defaultExec,
@@ -858,6 +880,12 @@ export function runE2eCheck(cwd, options = {}) {
858
880
  }
859
881
  }
860
882
 
883
+ // Playwright's list reporter names a failure as ` 1) [chromium] › e2e/a.spec.ts:3:5 › title`.
884
+ function failedSpecNames(output) {
885
+ const names = [...String(output).matchAll(/^\s+\d+\)\s+(.+›.+)$/gm)].map((match) => match[1].trim());
886
+ return [...new Set(names)];
887
+ }
888
+
861
889
  // The suite gets the stack's env (with PLAYWRIGHT_BASE_URL): without it the config hits a hardcoded
862
890
  // localhost:3000 while the app sits on the feature's port, an ECONNREFUSED that looks like a broken app.
863
891
  function runSpecs(cwd, { config, composeFile, up, auth, specTimeout, composeEnv, countSpecs }, exec) {
@@ -885,18 +913,27 @@ function runSpecs(cwd, { config, composeFile, up, auth, specTimeout, composeEnv,
885
913
  // Counted from the FULL output, before the tail cut; unparseable is null, never 0.
886
914
  const count = typeof countSpecs === 'function' ? (countSpecs(run.output) ?? null) : null;
887
915
 
888
- if (run.code !== 0) {
916
+ // One rerun of the failed specs only: a flake must not cost a whole verify. `--last-failed`
917
+ // needs Playwright 1.44+; an older one exits non-zero, and the node stays red.
918
+ const retry = run.code !== 0 && spec.args.includes('playwright')
919
+ ? exec(spec.command, [...spec.args, '--last-failed'], { cwd: spec.cwd, timeout: specTimeout, env: composeEnv })
920
+ : null;
921
+
922
+ if (run.code !== 0 && retry?.code !== 0) {
889
923
  return {
890
924
  status: 'fail',
891
925
  command: commandLine,
892
- output: tailLines(run.output, OUTPUT_TAIL_LINES),
926
+ output: tailLines(retry?.output ?? run.output, OUTPUT_TAIL_LINES),
893
927
  reason: 'e2e specs failed against the running stack',
894
928
  count,
895
929
  };
896
930
  }
897
931
 
932
+ const flakySpecs = retry ? failedSpecNames(run.output) : [];
933
+
898
934
  return {
899
935
  status: 'pass',
936
+ flakySpecs,
900
937
  command: commandLine,
901
938
  output: '',
902
939
  reason: notes.length ? notes.join('; ') : null,
@@ -57,6 +57,14 @@ function firstArg(args, valueFlags = new Set()) {
57
57
  return undefined;
58
58
  }
59
59
 
60
+ const POWERSHELL_HOSTS = new Set(['powershell', 'pwsh']);
61
+ const TEST_SCRIPT_SUFFIX = 'test-script';
62
+
63
+ function runsTestScript(args) {
64
+ const script = args.find((a) => /\.ps1$/i.test(a));
65
+ return Boolean(script) && /test/i.test(script.replace(/^.*[\\/]/, ''));
66
+ }
67
+
60
68
  function kindOfTokens(tokens) {
61
69
  const [head, ...args] = tokens;
62
70
  if (!head || args.some((a) => INFO_FLAGS.has(a))) return null;
@@ -64,6 +72,7 @@ function kindOfTokens(tokens) {
64
72
  const sub = firstArg(args, exe === 'docker' || exe === 'docker-compose' ? DOCKER_VALUE_FLAGS
65
73
  : PACKAGE_MANAGERS.has(exe) ? PM_DIR_FLAGS : undefined);
66
74
 
75
+ if (POWERSHELL_HOSTS.has(exe)) return runsTestScript(args) ? `${exe} ${TEST_SCRIPT_SUFFIX}` : null;
67
76
  if (exe === 'dotnet') return DOTNET_HEAVY.has(sub) ? `dotnet ${sub}` : null;
68
77
  if (exe === 'msbuild') return 'msbuild';
69
78
  if (exe === 'vitest' || exe === 'jest') return exe;
@@ -115,7 +124,7 @@ export const REVIEW_AGENT_TYPES = new Set(['morph-verifier', 'morph-reviewer', '
115
124
  export const QUEUE_FULL_EXIT = 3;
116
125
 
117
126
  const LIST_FLAGS = new Set(['--list', '--listTests', '--list-tests']);
118
- const TEST_LABEL = /^(dotnet (test|vstest)|(npx )?(vitest|jest|playwright test)|(npm|pnpm|yarn|bun) (test|run test(:\S+)?))$/;
127
+ const TEST_LABEL = /^(dotnet (test|vstest)|(npx )?(vitest|jest|playwright test)|(npm|pnpm|yarn|bun) (test|run test(:\S+)?)|(powershell|pwsh) test-script)$/;
119
128
  const NODE_RUNNERS = new Set(['vitest', 'jest']);
120
129
 
121
130
  function morphArgs(tokens) {
@@ -158,6 +167,8 @@ const TEST_PATH = /[\\/]|\.(spec|test)\b|\.[cm]?[jt]sx?$/;
158
167
  const PATH_VALUE_FLAGS = new Set(['-c', '--config', '--project', '--root', '--workspace', '--outputFile', '--reporter', ...PM_DIR_FLAGS]);
159
168
 
160
169
  function isFilteredRun(kind, tokens) {
170
+ // A script's arguments are its own contract: no flag here proves it narrowed the run.
171
+ if (kind.endsWith(TEST_SCRIPT_SUFFIX)) return false;
161
172
  const runnerAt = executable(tokens[0]) === 'node'
162
173
  ? tokens.findIndex((t, i) => i > 0 && executable(t).replace(/\.[cm]?js$/, '') === kind)
163
174
  : 0;
@@ -33,7 +33,7 @@ const HOW = {
33
33
  `\`morph-spec verify ${f} --json\` sem filtro; passou de 10 min, run_in_background.`,
34
34
  ],
35
35
  fix: (f) => [
36
- `Leia o \`feedback\` do último verify, corrija a causa e rode \`morph-spec verify ${f} --json\` de novo, sem filtro.`,
36
+ `Leia o \`feedback\` do último verify, corrija a causa e rode \`morph-spec verify ${f} --json\` de novo, sem filtro: ele começa pelas suítes vermelhas e só segue se passarem.`,
37
37
  ],
38
38
  escalate: () => [
39
39
  'AskUserQuestion com o texto do reason (aceitar/continuar/parar) e siga a resposta; não rode outro verify antes dela.',
@@ -42,7 +42,7 @@ const HOW = {
42
42
  'Siga .claude/skills/morph-apply/references/review.md.',
43
43
  ],
44
44
  finish: (f) => [
45
- `AskUserQuestion header "Integração" (PR ou merge); depois \`morph-spec finish ${f} --pr\` ou \`--merge\`.`,
45
+ `\`morph-spec context refresh\`, commit, e \`morph-spec finish ${f} --pr\` (\`--merge\` com \`finish.mode: "merge"\` no config), sem perguntar.`,
46
46
  ],
47
47
  status: (f) => [
48
48
  `Rode \`morph-spec status ${f}\` e confira a fase em .morph/features/${f}/feature.json.`,