@polymorphism-tech/morph-spec 9.6.0 → 9.8.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 (50) hide show
  1. package/CLAUDE.md +1 -1
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +13 -6
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/agents.json +3 -3
  8. package/framework/docs/CLI.md +53 -16
  9. package/framework/evals/review-checklist.md +5 -5
  10. package/framework/hooks/claude-code/core/README.md +4 -0
  11. package/framework/hooks/claude-code/core/test-budget.js +89 -0
  12. package/framework/hooks/shared/e2e-check.js +0 -0
  13. package/framework/hooks/shared/heavy-command.js +60 -1
  14. package/framework/hooks/shared/heavy-slots.js +41 -4
  15. package/framework/hooks/shared/host-services.js +433 -0
  16. package/framework/schemas/config.schema.json +54 -2
  17. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  18. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  19. package/framework/skills/core/morph-apply/SKILL.md +2 -2
  20. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  21. package/framework/skills/core/morph-machine/SKILL.md +1 -1
  22. package/framework/skills/core/morph-media/SKILL.md +1 -1
  23. package/framework/skills/core/morph-plan/SKILL.md +3 -3
  24. package/framework/skills/core/morph-review/SKILL.md +3 -2
  25. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  26. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  27. package/framework/standards/STANDARDS.json +2 -1
  28. package/framework/standards/infrastructure/docker/local-compose-isolation.md +14 -1
  29. package/package.json +1 -1
  30. package/src/commands/e2e/e2e.js +47 -24
  31. package/src/commands/observability/cost.js +12 -0
  32. package/src/commands/project/worktree.js +31 -5
  33. package/src/commands/state/advance.js +3 -0
  34. package/src/commands/state/create.js +3 -3
  35. package/src/commands/state/delete.js +1 -1
  36. package/src/commands/state/finish.js +46 -2
  37. package/src/commands/state/report-check.js +36 -1
  38. package/src/commands/validation/plan-check.js +1 -0
  39. package/src/commands/validation/verify.js +3 -0
  40. package/src/lib/e2e/stack-teardown.js +19 -4
  41. package/src/lib/heavy/gate.js +22 -1
  42. package/src/lib/review-reports.js +23 -10
  43. package/src/lib/tasks/test-nodes.js +24 -6
  44. package/src/lib/tasks/test-runner.js +43 -1
  45. package/src/lib/telemetry/metrics.js +47 -3
  46. package/src/lib/validators/packages/package-validator.js +2 -2
  47. package/src/lib/validators/verify-runner.js +50 -12
  48. package/src/utils/agents-installer.js +5 -2
  49. package/src/utils/file-copier.js +26 -4
  50. package/src/utils/hooks-installer.js +4 -4
@@ -4,7 +4,7 @@ description: Leva uma feature do pedido ao plano aprovado — roteia o tipo de t
4
4
  version: core
5
5
  when_to_use: Use quando uma feature, bug ou chore começar ("/morph-plan {feature}", "vamos planejar X"), ou ao retomar uma feature cujo gate plan ainda não foi aprovado
6
6
  user-invocable: true
7
- cliVersion: "9.6.0"
7
+ cliVersion: "9.8.0"
8
8
  ---
9
9
 
10
10
  # morph-plan
@@ -105,8 +105,8 @@ O `create` já escreveu o esqueleto em `2-plan/plan.md` (leia e preencha; `chore
105
105
  - Cenário que se observa por um artefato de outra fatia (rota, endpoint, componente que ela cria) faz da fatia que o cobre um `depende:` daquela — senão as duas caem na mesma onda e o teste do cenário não tem por onde provar. O `plan-check` não vê de quem é a rota: a regra é sua.
106
106
 
107
107
  **Contrato de implementação**: o que quem implementa precisa e o código não diz.
108
- - **Verificação de runtime:** o stack sobe por `morph-spec e2e up {feature}` (compose do projeto; sem compose, isso é uma fatia); os fluxos que o verificador navega, nos verbos dos cenários; base URL `process.env.PLAYWRIGHT_BASE_URL ?? \`http://localhost:${process.env.PORT ?? 3000}\``, compose com `"${PORT:-3000}:3000"` e sem `container_name` (standard `infrastructure/docker/local-compose-isolation`). App com login: `e2e.auth` em `config.json` (`mode: seed|storageState|bypass|none`, `seedCommand`, `storageStatePath`, `envFile`), credenciais num `.env.e2e` gitignored — **`mode` é obrigatório sempre que houver `seedCommand`**, sem ele o seed fica inerte. Biblioteca pura: registre a dispensa e o porquê.
109
- - **Baseline é um COMANDO, nunca um número.** O plano congela e é lido por dias: um número cravado está errado na primeira fatia, e quem confia nele "conserta" o que não quebrou. Registre como medir (`npm test`, `dotnet test`); o `expectedCount` sai de `morph-spec verify {feature} --record-baseline`, nunca digitado. Sem `project.stack`/`project.tests` no `config.json`, a `morph-apply` os declara a partir destes comandos antes do primeiro verify.
108
+ - **Verificação de runtime:** o stack sobe por `morph-spec e2e up {feature}` (compose do projeto; sem compose, isso é uma fatia); os fluxos que o verificador navega, nos verbos dos cenários; base URL `process.env.PLAYWRIGHT_BASE_URL ?? \`http://localhost:${process.env.PORT ?? 3000}\``, compose com `"${PORT:-3000}:3000"` e sem `container_name` (standard `infrastructure/docker/local-compose-isolation`). App cujo build é caro dentro do Docker sobe como processo do host por `e2e.hostServices` (compose só com as dependências; campos em `morph-spec e2e --help`). App com login: `e2e.auth` em `config.json` (`mode: seed|storageState|bypass|none`, `seedCommand`, `storageStatePath`, `envFile`), credenciais num `.env.e2e` gitignored — **`mode` é obrigatório sempre que houver `seedCommand`**, sem ele o seed fica inerte. Biblioteca pura: registre a dispensa e o porquê.
109
+ - **Baseline é um COMANDO, nunca um número.** O plano congela e é lido por dias: um número cravado está errado na primeira fatia, e quem confia nele "conserta" o que não quebrou. Registre como medir (`npm test`, `dotnet test`); o `expectedCount` (só `node-test`/`evals`; nos outros runners vale o piso zero) sai de `morph-spec verify {feature} --record-baseline`, nunca digitado. Sem `project.stack`/`project.tests` no `config.json`, a `morph-apply` os declara a partir destes comandos antes do primeiro verify.
110
110
  - **Pré-requisito é um PREDICADO, nunca um SHA literal:** "`git merge-base --is-ancestor origin/main HEAD` sai 0" — o SHA muda no primeiro squash.
111
111
 
112
112
  Plano bom cabe em duas páginas; crescendo além, corte escopo. Feche com `morph-spec plan-check {feature}` até zero erro. Ele também recusa veredito de reuso fora do enum, id `C`/`F`/`ADR` duplicado, `## Errata` fora do fim, `## Cenários` vazio e fatia sem `persona:`; avisa chave de fatia desconhecida, superfície fora da `ui-designer`, ADR sem **Alcance**, fatia sem `cobre:` e mais de 12 fatias.
@@ -4,7 +4,7 @@ description: Conduz o Gate 3 — despacha verificador ∥ revisor (∥ seguranç
4
4
  version: core
5
5
  when_to_use: Use quando a fase review começar, com todas as fatias com relatório final e o verify da feature verde
6
6
  user-invocable: true
7
- cliVersion: "9.6.0"
7
+ cliVersion: "9.8.0"
8
8
  ---
9
9
 
10
10
  # morph-review
@@ -51,7 +51,8 @@ símbolo público do diff, `morph-spec graph affected <símbolo> --prod --json`;
51
51
  (`no-graph`/`not-found`/`ambiguous`), siga sem o blast radius — o revisor cai para `git grep` — e
52
52
  registre a ausência no recado a ele. Cada agente nasce com contexto fresco e **nunca recebe relatório anterior** (ancoragem).
53
53
  `chore` tem review leve: só o revisor (∥ segurança, quando exigida) — sem verificador, sem cobertura de
54
- `Cn`, sem mutação. Despache todos na mesma resposta:
54
+ `Cn`, sem mutação. Despache todos na mesma resposta, **sem nome no dispatch**: com nome, o `agent_type` que o hook
55
+ `test-budget` lê vira o nome, e o guard de review não reconhece o papel.
55
56
  ```
56
57
  dispatch Agent (subagent_type: "morph-verifier", model: {reviewModels.verifier.model}) with prompt:
57
58
  feature, base, sha, JSON do verify (nós, e2e, URL) se já terminou, 1-design/mockups.md e
@@ -4,7 +4,7 @@ description: Localiza um standard específico via busca server-side (morph-spec
4
4
  version: core
5
5
  when_to_use: Use quando precisar consultar um standard específico durante a implementação
6
6
  user-invocable: true
7
- cliVersion: "9.6.0"
7
+ cliVersion: "9.8.0"
8
8
  ---
9
9
 
10
10
  # morph-standards
@@ -4,7 +4,7 @@ description: Conduz a fase opcional de UI/UX de uma feature com plano aprovado
4
4
  version: core
5
5
  when_to_use: Use quando o ramo de design for tomado (morph-spec advance {feature} --design, depois do gate do plano) e antes de /morph-apply
6
6
  user-invocable: true
7
- cliVersion: "9.6.0"
7
+ cliVersion: "9.8.0"
8
8
  ---
9
9
 
10
10
  # morph-uiux
@@ -1629,9 +1629,10 @@
1629
1629
  "portNames": "#project-named-port-variables",
1630
1630
  "rules": "#core-rules",
1631
1631
  "enforcement": "#what-the-harness-enforces",
1632
+ "hostServices": "#services-on-the-host",
1632
1633
  "skeleton": "#minimal-compose-skeleton"
1633
1634
  },
1634
- "digest": "Every worktree runs its OWN compose stack (project morph-{feature}, own postgres, own volumes). Ports come from the worktree block: PORT +0, API_PORT +1 (ASPNETCORE_URLS on .NET), POSTGRES_PORT +2, REDIS_PORT +3 — publish via ${VAR:-default}:container, never a fixed host port, never container_name (not prefixed by the project → collides). Stable POSTGRES_DB, healthcheck on every service, DATABASE_URL with an in-network default, playwright.config reads PLAYWRIGHT_BASE_URL. A non-isolable compose fails the verify e2e node inside a worktree. finish tears down with volumes (--keep-volumes opts out). Cloud DB credentials go in e2e.auth.envFile, never in .morph/worktree.env. A compose that reads a port by ANOTHER name (PG_PORT) is not fed by the block: declare it in config.json e2e.portEnvNames ({\"postgres\": \"PG_PORT\"}) and the harness exports it with the same value in worktree.env, the compose env and the hook."
1635
+ "digest": "Every worktree runs its OWN compose stack (project morph-{feature}, own postgres, own volumes). Ports come from the worktree block: PORT +0, API_PORT +1 (ASPNETCORE_URLS on .NET), POSTGRES_PORT +2, REDIS_PORT +3 — publish via ${VAR:-default}:container, never a fixed host port, never container_name (not prefixed by the project → collides). Stable POSTGRES_DB, healthcheck on every service, DATABASE_URL with an in-network default, playwright.config reads PLAYWRIGHT_BASE_URL. A non-isolable compose fails the verify e2e node inside a worktree. finish/delete tear down with --rmi local -v (--keep-volumes drops -v on finish); worktree remove of a morph-task/* branch with -v, of morph/{f} without. An app whose build is expensive in the Docker VM runs as a host process via e2e.hostServices (compose keeps only the dependencies; ports from the block). Cloud DB credentials go in e2e.auth.envFile, never in .morph/worktree.env. A compose that reads a port by ANOTHER name (PG_PORT) is not fed by the block: declare it in config.json e2e.portEnvNames ({\"postgres\": \"PG_PORT\"}) and the harness exports it with the same value in worktree.env, the compose env and the hook."
1635
1636
  },
1636
1637
  {
1637
1638
  "id": "infrastructure-neon-pgrag",
@@ -51,10 +51,23 @@ Keys are `web`, `api`, `postgres` and `redis`. Each declared name carries the **
51
51
  ## What the harness enforces
52
52
 
53
53
  - Inside a secondary worktree, a compose with a fixed host port or a `container_name` **fails the `verify` e2e node** (`compose-not-isolable`) — Gate 3 cannot pass on a stack that cannot run beside its siblings. At the primary root the same finding is a warning.
54
- - `morph-spec finish` tears the stack down **with volumes** (`--keep-volumes` opts out) and deletes the freshness stamp; `morph-spec worktree remove` tears it down without volumes (the feature is still open).
54
+ - `morph-spec finish` and `morph-spec delete` tear the stack down with `down --remove-orphans --rmi local -v` (`--keep-volumes` on `finish` drops the `-v`) and delete the freshness stamp. `--rmi local` removes only the images compose built, never a pulled one (`postgres`) that the next feature would download again; `docker builder prune` never runs, because the build cache is global to the machine. `morph-spec worktree remove` (and `worktree reap`, which goes through it) tears down a `morph/{f}` branch without volumes (the feature is still open) and a `morph-task/*` branch with `-v` (the task is disposable). Every one of these also stops the `e2e.hostServices` processes.
55
55
  - Two worktrees never share `POSTGRES_PORT`: the block is allocated first-free-index per feature and released only when the worktree is removed.
56
56
  - The credentials env file is read **after** the block but cannot outrank it. All four surfaces (`e2e up`, the `verify` e2e node, the teardown, `morph-spec env`) build their environment through the same `buildComposeEnv`, which drops the layout keys coming from that file and names them on **stderr** — stdout stays eval-safe for `eval "$(morph-spec env)"`. With no block allocated (outside a worktree) nothing is filtered: there the file is the only port source.
57
57
 
58
+ ## Services on the host
59
+
60
+ When building an app inside the Docker VM is expensive (a `dotnet publish` or `next build` repeating the build the `verify` already did on the host), run it as a host process through `config.json → e2e.hostServices` and keep only the dependencies (postgres, redis) in compose. The app listens on the ports of the worktree block — `${API_PORT}` / `${PORT}` in its `env` and `health` — so two worktrees still never collide.
61
+
62
+ ```json
63
+ "e2e": { "hostServices": [
64
+ { "name": "web", "build": "npm run build", "start": "node .next/standalone/server.js",
65
+ "env": { "PORT": "${PORT}" }, "health": "http://localhost:${PORT}/" }
66
+ ] }
67
+ ```
68
+
69
+ `e2e up` and the `verify` e2e node run `compose up --wait`, then each host service in array order (build → start → health), then the auth seed. They stay up until `e2e down`, `finish`, `delete` or `worktree remove`. Fields, reuse and failure rules: `morph-spec e2e --help` and the `e2e` section of `.morph/framework/CLI.md`.
70
+
58
71
  ## Minimal compose skeleton
59
72
 
60
73
  ```yaml
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polymorphism-tech/morph-spec",
3
- "version": "9.6.0",
3
+ "version": "9.8.0",
4
4
  "description": "MORPH-SPEC: NLH (Natural Language Harness) for spec-driven development with Claude Code",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -8,7 +8,7 @@ import { existsSync, readFileSync } from 'fs';
8
8
  import { join, resolve } from 'path';
9
9
 
10
10
  import {
11
- bringUpStack,
11
+ bringUpE2e,
12
12
  composeProjectName,
13
13
  computeSourceStamp,
14
14
  decideIsolation,
@@ -17,13 +17,13 @@ import {
17
17
  getStackStatus,
18
18
  isDockerAvailable,
19
19
  lintComposeIsolation,
20
- prepareAuth,
21
20
  readStamp,
22
21
  resolveComposeFile,
23
22
  resolveSpecCommand,
24
23
  tearDownStack,
25
24
  } from '../../../framework/hooks/shared/e2e-check.js';
26
25
  import { isSecondaryWorktree } from '../../../framework/hooks/shared/git-paths.js';
26
+ import { hostServicesStatus, stopHostServices } from '../../../framework/hooks/shared/host-services.js';
27
27
  import { buildComposeEnv } from '../../lib/e2e/compose-env.js';
28
28
  import { findWorktreeForFeature, resolvePrimaryRoot } from '../project/worktree.js';
29
29
  import { createHeavyGate, HeavyBusyError } from '../../lib/heavy/gate.js';
@@ -105,7 +105,8 @@ export async function e2eUpCommand(featureName, options = {}) {
105
105
  console.log(chalk.yellow(`⚠️ ${isolation.reason}`));
106
106
  }
107
107
 
108
- if (!isDockerAvailable(ctx.cwd)) {
108
+ const exec = options._exec ?? defaultExec;
109
+ if (!isDockerAvailable(ctx.cwd, exec)) {
109
110
  console.error(chalk.red('\n❌ Docker is not available in this environment.\n'));
110
111
  process.exit(EXIT_USAGE);
111
112
  }
@@ -114,13 +115,14 @@ export async function e2eUpCommand(featureName, options = {}) {
114
115
  let result;
115
116
  try {
116
117
  gate.enter('e2e');
117
- result = bringUpStack(ctx.cwd, {
118
+ result = bringUpE2e(ctx.cwd, {
119
+ config: ctx.config,
118
120
  composeFile: ctx.composeFile,
119
121
  projectName: ctx.projectName,
120
122
  gitCommonDir: ctx.gitCommonDir,
121
123
  env: ctx.env,
122
124
  forceFresh: Boolean(options.fresh),
123
- });
125
+ }, exec);
124
126
  } catch (err) {
125
127
  if (!(err instanceof HeavyBusyError)) throw err;
126
128
  if (options.json) {
@@ -134,40 +136,42 @@ export async function e2eUpCommand(featureName, options = {}) {
134
136
  gate.release();
135
137
  }
136
138
 
137
- const auth =
138
- result.status === 'pass'
139
- ? prepareAuth(ctx.cwd, { config: ctx.config, env: ctx.env, forceSeed: Boolean(options.fresh) })
140
- : { status: 'skip', reason: null };
141
-
139
+ const { auth, hostServices } = result;
142
140
  // A seedCommand that `mode` switches off is reported, never swallowed.
143
141
  const warnings = [];
144
142
  if (isolation.status === 'warn') warnings.push(isolation.reason);
145
- if (auth.warning) warnings.push(auth.warning);
143
+ warnings.push(...result.warnings);
146
144
 
147
- const failed = result.status === 'fail' || auth.status === 'fail';
145
+ const failed = result.status === 'fail';
148
146
  const payload = {
149
147
  feature: featureName,
150
148
  project: ctx.projectName,
151
149
  tree: ctx.tree,
152
150
  composeFile: ctx.composeFile,
153
151
  status: failed ? 'fail' : 'pass',
154
- reused: result.reused,
152
+ reused: result.stack.reused,
155
153
  reason: result.reason,
156
154
  auth: { status: auth.status, reason: auth.reason },
155
+ ...(hostServices.status === 'skip' ? {} : { hostServices: hostServices.services }),
157
156
  ...(warnings.length ? { warnings } : {}),
157
+ ...(failed && result.output ? { output: result.output } : {}),
158
158
  };
159
159
 
160
160
  if (options.json) {
161
161
  console.log(JSON.stringify(payload, null, 2));
162
162
  } else if (failed) {
163
- console.error(chalk.red(`\n✗ ${result.reason || auth.reason}\n`));
163
+ console.error(chalk.red(`\n✗ ${result.reason}\n`));
164
164
  if (result.output) console.error(result.output);
165
+ for (const w of result.warnings) console.error(chalk.yellow(`⚠️ ${w}`));
165
166
  } else {
166
- const how = result.reused ? chalk.gray(`(${result.reason})`) : chalk.gray('(built)');
167
+ const how = result.stack.reused ? chalk.gray(`(${result.stack.reason})`) : chalk.gray('(built)');
167
168
  console.log(chalk.green(`\n✓ Stack up — ${ctx.projectName} ${how}`));
168
169
  console.log(chalk.gray(` tree: ${ctx.tree}`));
170
+ for (const s of hostServices.services) {
171
+ console.log(chalk.gray(` host: ${s.name} pid ${s.pid} (${s.reused ? 'reused' : 'started'}) — log ${s.log}`));
172
+ }
169
173
  if (auth.reason) console.log(chalk.gray(` auth: ${auth.reason}`));
170
- if (auth.warning) console.log(chalk.yellow(`⚠️ ${auth.warning}`));
174
+ for (const w of result.warnings) console.log(chalk.yellow(`⚠️ ${w}`));
171
175
  console.log('');
172
176
  }
173
177
 
@@ -185,17 +189,19 @@ export async function e2eStatusCommand(featureName, options = {}) {
185
189
  const ctx = resolveContext(featureName, options);
186
190
  if (!ctx) process.exit(EXIT_USAGE);
187
191
 
192
+ const exec = options._exec ?? defaultExec;
188
193
  const status = getStackStatus(ctx.cwd, {
189
194
  composeFile: ctx.composeFile,
190
195
  projectName: ctx.projectName,
191
196
  env: ctx.env,
192
- });
197
+ }, exec);
193
198
 
194
- const stamp = computeSourceStamp(ctx.cwd);
199
+ const stamp = computeSourceStamp(ctx.cwd, exec);
195
200
  const previous = ctx.gitCommonDir ? readStamp(ctx.gitCommonDir, ctx.projectName) : null;
196
201
  const fresh = Boolean(stamp && previous?.stamp === stamp);
197
202
 
198
203
  const spec = resolveSpecCommand(ctx.cwd, ctx.config, ctx.config?.project?.frontendPath);
204
+ const hostServices = ctx.gitCommonDir ? hostServicesStatus(ctx.gitCommonDir, ctx.projectName) : [];
199
205
 
200
206
  const payload = {
201
207
  feature: featureName,
@@ -213,6 +219,7 @@ export async function e2eStatusCommand(featureName, options = {}) {
213
219
  state: s.State ?? null,
214
220
  health: s.Health || null,
215
221
  })),
222
+ ...(hostServices.length ? { hostServices } : {}),
216
223
  };
217
224
 
218
225
  if (options.json) {
@@ -233,6 +240,10 @@ export async function e2eStatusCommand(featureName, options = {}) {
233
240
  for (const s of payload.services) {
234
241
  console.log(chalk.gray(` · ${s.name}: ${s.state}${s.health ? ` / ${s.health}` : ''}`));
235
242
  }
243
+ for (const h of hostServices) {
244
+ const health = h.health === null ? 'no health check' : h.health ? 'healthy' : 'unhealthy';
245
+ console.log(chalk.gray(` · host ${h.name}: pid ${h.pid} ${h.alive ? 'alive' : 'dead'} / ${health}`));
246
+ }
236
247
  console.log('');
237
248
  }
238
249
 
@@ -245,30 +256,42 @@ export async function e2eDownCommand(featureName, options = {}) {
245
256
  const ctx = resolveContext(featureName, options);
246
257
  if (!ctx) process.exit(EXIT_USAGE);
247
258
 
259
+ const hostServices = stopHostServices(ctx.gitCommonDir, ctx.projectName);
248
260
  const result = tearDownStack(ctx.cwd, {
249
261
  composeFile: ctx.composeFile,
250
262
  projectName: ctx.projectName,
251
263
  env: ctx.env,
252
264
  volumes: Boolean(options.volumes),
253
- });
265
+ }, options._exec ?? defaultExec);
254
266
  const stampDeleted = deleteStamp(ctx.gitCommonDir, ctx.projectName);
255
267
  const cleanup = afterHeavyRun({ projectPath: ctx.cwd, config: readHeavyConfig(ctx.cwd) });
256
268
 
257
269
  if (options.json) {
258
270
  console.log(JSON.stringify(
259
- { feature: featureName, project: ctx.projectName, tree: ctx.tree, status: result.status, stampDeleted, cleanup },
271
+ {
272
+ feature: featureName, project: ctx.projectName, tree: ctx.tree, status: result.status, stampDeleted, cleanup,
273
+ ...(stoppedAnything(hostServices) ? { hostServices } : {}),
274
+ },
260
275
  null, 2,
261
276
  ));
262
- } else if (result.status === 'pass') {
263
- console.log(chalk.green(`\n✓ Stack down — ${ctx.projectName}`));
264
- console.log(chalk.gray(` tree: ${ctx.tree}\n`));
265
277
  } else {
266
- console.log(chalk.yellow(`\n⚠️ Teardown reported a problem (not fatal):\n${result.output}\n`));
278
+ if (result.status === 'pass') {
279
+ console.log(chalk.green(`\n✓ Stack down — ${ctx.projectName}`));
280
+ console.log(chalk.gray(` tree: ${ctx.tree}\n`));
281
+ } else {
282
+ console.log(chalk.yellow(`\n⚠️ Teardown reported a problem (not fatal):\n${result.output}\n`));
283
+ }
284
+ if (hostServices.killed.length) console.log(chalk.gray(` host services stopped: ${hostServices.killed.join(', ')}`));
285
+ for (const w of hostServices.warnings) console.log(chalk.yellow(`⚠️ ${w}`));
267
286
  }
268
287
 
269
288
  process.exit(0);
270
289
  }
271
290
 
291
+ function stoppedAnything({ killed, skipped, warnings }) {
292
+ return killed.length + skipped.length + warnings.length > 0;
293
+ }
294
+
272
295
  function readConfig(cwd) {
273
296
  try {
274
297
  const path = join(cwd, '.morph', 'config', 'config.json');
@@ -108,6 +108,18 @@ function printMetricsBody(m) {
108
108
  console.log(` ${klass.padEnd(12)} ${fmtDuration(t.measuredMs / 1000).padStart(8)} ${t.runs} execução(ões)${unmeasured}`);
109
109
  }
110
110
 
111
+ const tov = m.testOutsideVerify;
112
+ const timed = (t) => `${String(t.runs).padStart(4)}× ${fmtDuration(t.measuredMs / 1000).padStart(8)}`
113
+ + (t.unmeasured ? chalk.gray(` (${t.unmeasured} sem duração)`) : '');
114
+ console.log('\n' + chalk.white.bold('Teste fora do verify (sub-agentes)')
115
+ + chalk.gray(` · de ${tov.total.runs} testes+verifies, ${fmtDuration(tov.total.measuredMs / 1000)} cronometrados`
116
+ + (tov.total.unmeasured ? `, ${tov.total.unmeasured} sem duração` : '')));
117
+ console.log(` sub-agente rodando verify cheio ${timed(tov.subagentVerify)}`);
118
+ console.log(` implementador, 3ª rodada em diante ${timed(tov.implementerRound3Plus)}`);
119
+ console.log(` agente de review rodando teste ${timed(tov.reviewTests)}`);
120
+ console.log(chalk.gray(` ${tov.reviewTests.byPersona.runs}× pela persona morph-* (o que o hook test-budget reconhece);`
121
+ + ' o resto veio de um nome de dispatch (ver-, rev-, gate3-…)'));
122
+
111
123
  console.log('\n' + chalk.white.bold('Rerun do mesmo comando em ≤10 min (mesma feature; o filtro de saída não conta)'));
112
124
  console.log(` total: ${pct(m.reruns.rate)} repetidas (${m.reruns.reruns}/${m.reruns.runs}) · ${pct(m.reruns.burstRate)} em rajada — testes, builds e verifies`);
113
125
  for (const [kind, r] of Object.entries(m.rerunsByKind).sort((a, b) => b[1].runs - a[1].runs)) {
@@ -85,8 +85,27 @@ function getDateSuffix() {
85
85
  return new Date().toISOString().slice(0, 10).replace(/-/g, '');
86
86
  }
87
87
 
88
- // The ONLY links `finish` may auto-remove (never widened to any reparse point found), plus
89
- // `{project.frontendPath}/node_modules`: a monorepo frontend has its own install.
88
+ function trackedPackageNodeModules(mainCwd) {
89
+ let out;
90
+ try {
91
+ out = execFileSync('git', ['ls-files', '-z', '--', '*package.json'], { cwd: mainCwd, stdio: 'pipe' }).toString();
92
+ } catch {
93
+ return [];
94
+ }
95
+ const dirs = [];
96
+ for (const file of out.split('\0')) {
97
+ const parts = file.split('/');
98
+ if (parts.length < 2 || parts.at(-1) !== 'package.json') continue;
99
+ const dirParts = parts.slice(0, -1);
100
+ if (dirParts.includes('node_modules') || WORKTREE_DIR_NAMES.includes(dirParts[0])) continue;
101
+ const rel = join(...dirParts, 'node_modules');
102
+ if (existsSync(join(mainCwd, rel))) dirs.push(rel);
103
+ }
104
+ return dirs;
105
+ }
106
+
107
+ // The ONLY links `finish` may auto-remove (never widened to any reparse point found), plus the
108
+ // `node_modules` of each tracked nested package: a monorepo package has its own install.
90
109
  function resolveInfraLinkDirs(mainCwd) {
91
110
  const dirs = ['node_modules', '.claude'];
92
111
  if (existsSync(join(mainCwd, '.morph', 'framework'))) {
@@ -100,6 +119,9 @@ function resolveInfraLinkDirs(mainCwd) {
100
119
  if (frontendPath) dirs.push(join(frontendPath, 'node_modules'));
101
120
  }
102
121
  } catch {}
122
+ for (const rel of trackedPackageNodeModules(mainCwd)) {
123
+ if (!dirs.includes(rel)) dirs.push(rel);
124
+ }
103
125
  return dirs;
104
126
  }
105
127
 
@@ -1013,11 +1035,13 @@ export async function runWorktreeRemove(featureArg, options = {}) {
1013
1035
  // Read BEFORE the teardown: the entry is gone from `git worktree list` afterwards.
1014
1036
  const envKey = envKeyForWorktree(worktreePath, root);
1015
1037
 
1016
- // Compose stack down BEFORE the tree: a bind-mounted container holds the dir on Windows. Volumes survive.
1038
+ // Compose stack down BEFORE the tree: a bind-mounted container holds the dir on Windows.
1039
+ // A `morph/{f}` volume survives for the finish that reclaims it; a `morph-task/*` one has no later owner.
1040
+ const volumes = /^morph-task\//.test(branch ?? '');
1017
1041
  let e2e = null;
1018
1042
  try {
1019
1043
  const { tearDownFeatureStack } = await import('../../lib/e2e/stack-teardown.js');
1020
- e2e = tearDownFeatureStack(envKey || feature || basename(worktreePath), { root, worktreePath, volumes: false });
1044
+ e2e = tearDownFeatureStack(envKey || feature || basename(worktreePath), { root, worktreePath, volumes, _exec: options._exec });
1021
1045
  } catch { e2e = null; }
1022
1046
 
1023
1047
  // The stamp this removal's backup is written under — protected from the
@@ -1044,7 +1068,9 @@ export async function runWorktreeRemove(featureArg, options = {}) {
1044
1068
  ...(feature ? { feature } : {}),
1045
1069
  };
1046
1070
  if (teardown.unlinked.length) out.unlinked = teardown.unlinked;
1047
- if (e2e?.attempted) out.e2e = { status: e2e.status, cwd: e2e.cwd, volumes: false };
1071
+ if (e2e?.attempted || e2e?.hostServices) {
1072
+ out.e2e = { status: e2e.status, cwd: e2e.cwd, volumes, ...(e2e.hostServices ? { hostServices: e2e.hostServices } : {}) };
1073
+ }
1048
1074
  if (teardown.worktreeRemoved) {
1049
1075
  if (teardown.discarded.length) out.discardedIgnored = teardown.discarded;
1050
1076
  if (teardown.replicable.length) out.replicatedInRoot = teardown.replicable;
@@ -76,6 +76,9 @@ function resolveApprovedBy(options) {
76
76
 
77
77
  export function reportDotnetIgnores(added) {
78
78
  if (added.length) console.log(chalk.gray(` .gitignore: ${added.join(' ')} adicionados (projeto .NET) — commite antes do primeiro build.`));
79
+ for (const { entry, file } of added.skipped || []) {
80
+ console.log(chalk.yellow(` .gitignore: ${entry} não adicionado — esconderia fonte versionada (${file}); ignore a saída de build pelo caminho do projeto.`));
81
+ }
79
82
  }
80
83
 
81
84
  function reportDesignSeed({ mockups, designSystem }) {
@@ -9,6 +9,7 @@ import { runWorktreeSetup, materializeState, isGitRepo, branchExists, buildBranc
9
9
  import { detectDefaultBranch } from '../../lib/git/default-branch.js';
10
10
  import { writePlanSkeleton } from '../../lib/plan/plan-template.js';
11
11
  import { seedDotnetBuildIgnores } from '../../lib/stack/dotnet-build-ignores.js';
12
+ import { reportDotnetIgnores } from './advance.js';
12
13
 
13
14
  // Branches from the DEFAULT branch, never HEAD, so it never inherits another feature's commits.
14
15
  // `checkout: false` still CREATES the branch, which `finish` depends on. Fail-open: any git error
@@ -221,6 +222,7 @@ export async function createCommand(featureName, options = {}) {
221
222
  if (branchResult) out.branch = branchResult;
222
223
  if (planSkeleton) out.planSkeleton = planSkeleton;
223
224
  if (gitignoreAdded.length) out.gitignoreAdded = gitignoreAdded;
225
+ if (gitignoreAdded.skipped) out.gitignoreSkipped = gitignoreAdded.skipped;
224
226
  // Under --json the move goes to stderr, so it survives `... --json > out.json`.
225
227
  if (branchResult?.movedFrom) {
226
228
  console.error(chalk.yellow(branchMoveWarning(branchResult).join('\n')));
@@ -234,9 +236,7 @@ export async function createCommand(featureName, options = {}) {
234
236
  console.log(chalk.gray(` .morph/features/${featureName}/feature.json (canonical shape)`));
235
237
  console.log(chalk.gray(` status: ${finalFeature.status} | createdAt: ${finalFeature.createdAt}`));
236
238
  console.log(chalk.gray(` scaffolded: ${wt.scaffold.join(', ')}`));
237
- if (gitignoreAdded.length) {
238
- console.log(chalk.gray(` .gitignore: ${gitignoreAdded.join(' ')} adicionados (projeto .NET)`));
239
- }
239
+ reportDotnetIgnores(gitignoreAdded);
240
240
  if (planSkeleton?.written) {
241
241
  console.log(chalk.gray(` 2-plan/plan.md: esqueleto escrito (gramática: morph-spec plan-check --help)`));
242
242
  }
@@ -109,7 +109,7 @@ export async function deleteCommand(featureName, options = {}) {
109
109
  const root = resolvePrimaryRoot(cwd);
110
110
  // The stack comes down BEFORE the worktree, where its compose file and bind mount live. Fail-open.
111
111
  try {
112
- tearDownFeatureStack(featureName, { root, worktreePath: owner.tree, volumes: true });
112
+ tearDownFeatureStack(featureName, { root, worktreePath: owner.tree, volumes: true, images: 'local', _exec: options._exec });
113
113
  } catch { /* fail-open */ }
114
114
  const teardown = await teardownWorktree(owner.tree, {
115
115
  root,
@@ -292,7 +292,8 @@ export async function finishCommand(feature, options = {}) {
292
292
  }
293
293
  const dirtLines = withoutLeftoverIgnore(cleanCwd, dirty.split(/\r?\n/).filter((l) => l.trim()));
294
294
  const testArtifacts = untrackedTestArtifacts(dirtLines);
295
- if (dirtLines.length > testArtifacts.length) {
295
+ const commitState = dirtLines.length > testArtifacts.length && onlyFeatureStateDirty(cleanCwd, feature);
296
+ if (dirtLines.length > testArtifacts.length && !commitState) {
296
297
  emit({ finished: false, error: isWorktreeFlow ? 'worktree-dirty' : 'working-dir-dirty', hint: 'commit seu trabalho na branch antes do finish', feature });
297
298
  process.exit(1);
298
299
  }
@@ -341,6 +342,10 @@ export async function finishCommand(feature, options = {}) {
341
342
 
342
343
  // Every refusal is behind us: the leftover of a killed `graph refresh` goes now, never before.
343
344
  dropLeftoverIgnore(cleanCwd);
345
+ if (commitState && !commitFeatureState(cleanCwd, feature)) {
346
+ emit({ finished: false, error: 'state-commit-failed', hint: `não foi possível commitar .morph/features/${feature} (identidade git ausente?) — commite o estado na branch e rode o finish de novo`, feature });
347
+ process.exit(1);
348
+ }
344
349
  if (isWorktreeFlow) {
345
350
  for (const p of testArtifacts) rmSync(join(cleanCwd, p), { recursive: true, force: true });
346
351
  }
@@ -409,6 +414,15 @@ export function finishPreflight({ feature, root, worktreePath, cwd, mode, branch
409
414
 
410
415
  if (defaultBranch) out.defaultBranch = defaultBranch;
411
416
 
417
+ try {
418
+ const tree = worktreePath || root;
419
+ const dirt = gitStatusPorcelain(tree);
420
+ const work = dirt === null ? [] : withoutLeftoverIgnore(tree, dirt.split(/\r?\n/).filter((l) => l.trim()));
421
+ if (work.length > untrackedTestArtifacts(work).length && onlyFeatureStateDirty(tree, feature)) {
422
+ out.wouldDo.unshift(`commitar o estado sujo de .morph/features/${feature} (${stateCommitMessage(feature)}) na branch ${branch}`);
423
+ }
424
+ } catch { /* o pré-voo nunca lança */ }
425
+
412
426
  // A retenção automática também é mutação do finish real; a contagem é a de AGORA, antes do
413
427
  // backup que este finish gravar.
414
428
  try {
@@ -808,6 +822,34 @@ function untrackedTestArtifacts(lines) {
808
822
  .filter((p) => isTestArtifact(p));
809
823
  }
810
824
 
825
+ const stateCommitMessage = (feature) => `chore(morph): estado da feature ${feature}`;
826
+
827
+ // `--untracked-files=all`: o porcelain padrão colapsa `?? .morph/` e esconderia sujeira fora do escopo.
828
+ function onlyFeatureStateDirty(cwd, feature) {
829
+ let out;
830
+ try {
831
+ out = execFileSync('git', ['status', '--porcelain', '--untracked-files=all'], { cwd, stdio: 'pipe' }).toString();
832
+ } catch {
833
+ return false;
834
+ }
835
+ const scope = `.morph/features/${feature}/`;
836
+ const paths = out.split(/\r?\n/).filter((l) => l.trim())
837
+ .flatMap((l) => l.slice(3).split(' -> '))
838
+ .map((p) => p.replace(/^"|"$/g, ''));
839
+ return paths.length > 0 && paths.every((p) => p.startsWith(scope));
840
+ }
841
+
842
+ function commitFeatureState(cwd, feature) {
843
+ const scope = `.morph/features/${feature}`;
844
+ try {
845
+ execFileSync('git', ['add', '-A', '--', scope], { cwd, stdio: 'pipe' });
846
+ execFileSync('git', ['commit', '-q', '-m', stateCommitMessage(feature), '--', scope], { cwd, stdio: 'pipe' });
847
+ return true;
848
+ } catch {
849
+ return false;
850
+ }
851
+ }
852
+
811
853
  function gitStatusPorcelain(cwd) {
812
854
  try {
813
855
  return execFileSync('git', ['status', '--porcelain'], { cwd, stdio: 'pipe' }).toString();
@@ -901,7 +943,9 @@ function openPrFor(branch, cwd) {
901
943
  // Never throws: a stack that refuses to come down must not fail a finish that already merged.
902
944
  function reclaimStack(feature, { root, worktreePath, options }) {
903
945
  try {
904
- return tearDownFeatureStack(feature, { root, worktreePath, volumes: !options.keepVolumes });
946
+ return tearDownFeatureStack(feature, {
947
+ root, worktreePath, volumes: !options.keepVolumes, images: 'local', _exec: options._exec,
948
+ });
905
949
  } catch {
906
950
  return { attempted: false, cwd: worktreePath || root, status: 'fail', stampDeleted: false };
907
951
  }
@@ -15,6 +15,7 @@ import { verifierModelFor } from '../../lib/risk-detector.js';
15
15
  import { changedPathsAgainst, detectDefaultBranch } from '../../lib/git/default-branch.js';
16
16
  import { readMorphConfig } from '../../lib/tasks/test-runner.js';
17
17
  import { readPlanChecks } from '../../lib/plan/checks.js';
18
+ import { isTestPath } from '../../lib/graph/sanitize.js';
18
19
  import { featureGeneratesMedia, collectReviewRequirement } from './gate-check.js';
19
20
 
20
21
  export { inspectReport, REPORT_MARKERS };
@@ -139,12 +140,45 @@ function decide(report, observed, stale) {
139
140
  : { verdict: 'no-work-no-report', action: 'redispatch' };
140
141
  }
141
142
 
143
+ const isTestRef = (p) => isTestPath(p) || /\.(test|spec)\.[a-z]+$|Tests?\.cs$/i.test(p);
144
+
145
+ /** Cn whose `**Checks:**` entry cites tests in more than one file, as `{ id, files }`. */
146
+ export function checksSpread(body) {
147
+ const lines = String(body ?? '').split(/\r?\n/);
148
+ const start = lines.findIndex((l) => /^\s*(?:[-*]\s*)?\*\*checks\s*:?\*\*/i.test(l));
149
+ if (start < 0) return [];
150
+ const block = [lines[start].replace(/^.*?\*\*checks\s*:?\*\*\s*:?/i, '')];
151
+ for (const l of lines.slice(start + 1)) {
152
+ if (/^\s*(\*\*|#)/.test(l)) break;
153
+ block.push(l);
154
+ }
155
+ const filesByCn = new Map();
156
+ let current = null;
157
+ for (const piece of block.join('\n').split(/[;\n]/)) {
158
+ const lead = /^\s*(?:[-*]\s*)?\(?(C\d+)\b/.exec(piece);
159
+ if (lead) current = lead[1];
160
+ if (!current) continue;
161
+ const refs = (piece.match(/[\w@.\/\\-]+\.[A-Za-z]+/g) ?? []).map(slash).filter(isTestRef);
162
+ if (!filesByCn.has(current)) filesByCn.set(current, new Set());
163
+ for (const f of refs) filesByCn.get(current).add(f);
164
+ }
165
+ return [...filesByCn].filter(([, files]) => files.size > 1).map(([id, files]) => ({ id, files: [...files] }));
166
+ }
167
+
168
+ function spreadWarnings(fsPath) {
169
+ let body = '';
170
+ try { body = readFileSync(fsPath, 'utf8'); } catch { return []; }
171
+ return checksSpread(body).map(({ id, files }) =>
172
+ `${id} cita testes em ${files.length} arquivos (${files.join(', ')}): um teste por Cn, na camada mais baixa que o prova`);
173
+ }
174
+
142
175
  function checkSlice(cwd, feature, slice, telemetry) {
143
176
  const fsPath = sliceReportPath(join(cwd, '.morph', 'features', feature), slice.id);
144
177
  const report = inspectReport(fsPath, { covers: slice.covers ?? [] });
145
178
  const observed = observedEvidence(telemetry, feature, slice);
146
179
  const stale = report.exists && isReportStale(fsPath, observed);
147
- return { slice: slice.id, report, observed, ...decide(report, observed, stale) };
180
+ const warnings = report.exists ? spreadWarnings(fsPath) : [];
181
+ return { slice: slice.id, report, observed, warnings, ...decide(report, observed, stale) };
148
182
  }
149
183
 
150
184
  const ACTION_HINT = {
@@ -220,6 +254,7 @@ function printHuman(obj) {
220
254
  }
221
255
  console.log(chalk.gray(` → ${r.hint}`));
222
256
  }
257
+ for (const w of r.warnings ?? []) console.log(chalk.yellow(` ⚠ ${w}`));
223
258
  }
224
259
  for (const [role, r] of Object.entries(obj.reviews ?? {})) {
225
260
  if (!r.exists && role === 'security') continue;
@@ -57,6 +57,7 @@ export function planCheckCommand(featureName, options = {}) {
57
57
  const gitignoreAdded = result.written ? seedDotnetBuildIgnores(process.cwd(), { featureName }) : [];
58
58
  if (options.json) {
59
59
  if (gitignoreAdded.length) result.gitignoreAdded = gitignoreAdded;
60
+ if (gitignoreAdded.skipped) result.gitignoreSkipped = gitignoreAdded.skipped;
60
61
  console.log(JSON.stringify(result, null, 2));
61
62
  process.exit(result.exit);
62
63
  }
@@ -309,6 +309,9 @@ export async function verifyCommand(featureName, options = {}) {
309
309
  for (const u of result.baseline.updates) {
310
310
  console.log(chalk.green(` baseline ${u.id}: ${u.from ?? '—'} → ${u.to}`));
311
311
  }
312
+ for (const id of result.baseline.removed ?? []) {
313
+ console.log(chalk.gray(` baseline ${id}: removida (runner sem baseline, só o piso zero)`));
314
+ }
312
315
  console.log(chalk.gray(`Gravada em ${result.baseline.path}
313
316
  `));
314
317
  }