@polymorphism-tech/morph-spec 9.5.0 → 9.6.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 (39) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +1 -0
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -1
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/docs/CLI.md +9 -5
  7. package/framework/hooks/claude-code/__pycache__/statusline.cpython-313.pyc +0 -0
  8. package/framework/hooks/claude-code/core/heavy-gate.js +5 -3
  9. package/framework/hooks/shared/feature-locator.js +7 -5
  10. package/framework/hooks/shared/heavy-command.js +12 -0
  11. package/framework/hooks/shared/heavy-config.js +28 -1
  12. package/framework/hooks/shared/heavy-slots.js +41 -5
  13. package/framework/schemas/config.schema.json +12 -1
  14. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  15. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  16. package/framework/skills/core/morph-apply/SKILL.md +1 -1
  17. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  18. package/framework/skills/core/morph-machine/SKILL.md +1 -1
  19. package/framework/skills/core/morph-media/SKILL.md +1 -1
  20. package/framework/skills/core/morph-plan/SKILL.md +2 -2
  21. package/framework/skills/core/morph-review/SKILL.md +1 -1
  22. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  23. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  24. package/framework/standards/infrastructure/docker/local-compose-isolation.md +1 -0
  25. package/package.json +1 -1
  26. package/src/commands/e2e/e2e.js +3 -0
  27. package/src/commands/project/env.js +3 -3
  28. package/src/commands/project/heavy.js +10 -3
  29. package/src/commands/project/worktree.js +2 -1
  30. package/src/commands/state/finish.js +17 -3
  31. package/src/commands/validation/mutate.js +2 -2
  32. package/src/commands/validation/verify.js +3 -0
  33. package/src/lib/e2e/compose-env.js +26 -3
  34. package/src/lib/heavy/gate.js +23 -4
  35. package/src/lib/heavy/live-stacks.js +64 -0
  36. package/src/lib/risk-detector.js +9 -1
  37. package/src/lib/worktree/docker-audit.js +7 -3
  38. package/src/lib/worktree/env-allocator.js +27 -5
  39. package/src/lib/worktree/ignored-triage.js +8 -0
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  > Spec-driven development framework for multi-stack projects. Turns feature requests into implementation-ready code through structured, AI-orchestrated phases.
4
4
 
5
5
  **Package:** `@polymorphism-tech/morph-spec`
6
- **Version:** 9.5.0
6
+ **Version:** 9.6.0
7
7
  **Requires:** Node.js 22+, Claude Code
8
8
 
9
9
  ---
@@ -380,4 +380,4 @@ Code generated by morph-spec (contracts, templates, implementation output) belon
380
380
 
381
381
  ---
382
382
 
383
- *morph-spec v9.5.0 by [Polymorphism Tech](https://polymorphism.tech)*
383
+ *morph-spec v9.6.0 by [Polymorphism Tech](https://polymorphism.tech)*
package/bin/morph-spec.js CHANGED
@@ -520,6 +520,7 @@ program
520
520
  .description('Print the worktree\'s dedicated environment (port block + COMPOSE_PROJECT_NAME), shell-loadable: eval "$(morph-spec env)" outside Claude Code; feature defaults to the morph/{f} or morph-task/{n} branch of cwd')
521
521
  .option('--json', 'Output as JSON')
522
522
  .option('--powershell', 'Emit $env:K="V" lines instead of POSIX exports')
523
+ .option('--with-secrets', 'Also emit the e2e.auth.envFile credentials (omitted by default: stdout lands in transcripts); use inside eval "$(…)"')
523
524
  .action((feature, options) => envCommand(feature, options));
524
525
 
525
526
  program
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "morph-spec",
3
- "version": "9.5.0",
3
+ "version": "9.6.0",
4
4
  "displayName": "MORPH-SPEC Framework",
5
5
  "description": "Spec-driven development with 38 agents and 8-phase workflow for .NET/Blazor/Next.js/Azure",
6
6
  "publisher": "polymorphism-tech",
@@ -185,4 +185,4 @@ Never edited directly — go through CLI commands or `morph-spec update` instead
185
185
 
186
186
  ---
187
187
 
188
- *morph-spec v9.5.0 by Polymorphism Tech*
188
+ *morph-spec v9.6.0 by Polymorphism Tech*
@@ -249,4 +249,4 @@ morph-spec doctor
249
249
 
250
250
  ---
251
251
 
252
- *morph-spec v9.5.0 by Polymorphism Tech*
252
+ *morph-spec v9.6.0 by Polymorphism Tech*
@@ -10,7 +10,7 @@
10
10
 
11
11
  **`morph-spec approve <feature> <gate> --mode auto`** — aprova um gate (`plan`/`uiux`/`review`) em `trust: auto`.
12
12
 
13
- **O Gate 3 exige os relatórios de review**: `4-review/verifier.md` e `reviewer.md`, mais `security.md` quando o diff toca caminho sensível (`securityReviewFor`) — ausente, PARCIAL ou sem `**Verdict:**`, o comando recusa (exit 1) nomeando o arquivo. A assinatura humana (`--approver`) aprova mesmo com `FIX` ou cenário não provado; a `auto` recalcula a pausa do `gate-check`, cobertura dos `Cn` incluída. `**Reviewed-SHA:**` é lido de todo relatório presente e o pior vence: o código andou depois dele → recusa; sem carimbo → aviso. Se o dispatch for impossível nesta sessão, aprove com `--self-assessed` — a degradação é gravada em `approvalGates.review.selfAssessed` e aparece marcada no `morph-spec list`. `--force` **não** dispensa esta regra (é só para ordem de gate).
13
+ **O Gate 3 exige os relatórios de review**: `4-review/verifier.md` e `reviewer.md`, mais `security.md` quando o diff toca caminho sensível fora de teste (`securityReviewFor`; spec, `e2e/` e `test-results/` não contam) — ausente, PARCIAL ou sem `**Verdict:**`, o comando recusa (exit 1) nomeando o arquivo. A assinatura humana (`--approver`) aprova mesmo com `FIX` ou cenário não provado; a `auto` recalcula a pausa do `gate-check`, cobertura dos `Cn` incluída. `**Reviewed-SHA:**` é lido de todo relatório presente e o pior vence: o código andou depois dele → recusa; sem carimbo → aviso. Se o dispatch for impossível nesta sessão, aprove com `--self-assessed` — a degradação é gravada em `approvalGates.review.selfAssessed` e aparece marcada no `morph-spec list`. `--force` **não** dispensa esta regra (é só para ordem de gate).
14
14
 
15
15
  **O plano semeia `bin/`/`obj/`** (`plan-check` que grava o `checks.json`, `approve <f> plan`, `advance` saindo do plano e `advance --design` com o plano já aprovado — o `plan-check` cobre também o gate gravado pelo hook state-sync): num projeto .NET — `project.stack` com `dotnet`, fatia de `dotnet-senior`/`ef-modeler`/`maf-expert` no `checks.json` ou `.csproj`/`.sln`/`.slnx` na árvore (fora de `worktrees/`, `node_modules/`, `bin/`, `obj/`) — as duas linhas entram no `.gitignore` da árvore dona antes do primeiro código, e a saída as nomeia para irem no mesmo commit. O `create` aplica a mesma regra (`src/lib/stack/dotnet-build-ignores.js`); no greenfield só a fatia do plano existe para decidir.
16
16
 
@@ -124,7 +124,7 @@ Feature sem `workType` (pré-v8) é tratada como `feature`.
124
124
 
125
125
  **Índice de ADRs:** no mesmo commit do archive, o `finish` lê o `2-plan/plan.md` da feature: os ADRs de `## Decisões` com `**Alcance:** projeto` entram em `.morph/specs/adrs/` (um arquivo por ADR, `{feature}--ADR-{nnn}.md` — features paralelas nunca tocam o mesmo arquivo), e a tabela `ADR | Mudança | Escopo` de `### Conflitos com o acervo` retira (total, o arquivo sai) ou estreita (parcial, `scope:` no arquivo) os ADRs revogados — forma em `morph-spec plan-check --help`; o `plan-check` avisa, enquanto o plano ainda muda, a linha que o `finish` não aplicaria. O payload traz `adrIndex: {added, revoked, narrowed}` e `revocationProblems: [{id, reason, message}]` (linha malformada ou ambígua, ADR fora do índice) — cada chave só aparece com conteúdo, e nenhum problema recusa o `finish`.
126
126
 
127
- `--merge` faz merge `--no-ff` **local** na default (sem push); `--pr` faz `push` + `gh pr create` (exige `origin` + `gh`, checados **antes** de arquivar — um `--pr` recusado não deixa meio-estado). Desmonta o worktree (PROVE-or-DECLINE) e libera o bloco de portas; no `--pr` **preserva** a branch (o PR precisa dela) e fica em `morph/{f}`.
127
+ `--merge` faz merge `--no-ff` **local** na default (sem push); `--pr` faz `push` + `gh pr create` (exige `origin` + `gh`, checados **antes** de arquivar — um `--pr` recusado não deixa meio-estado). Saída de teste não rastreada (`test-results/`, `TestResults/`, `playwright-report/`, `blob-report/`) não conta como árvore suja: na raiz só é tolerada, na worktree sai junto com o desmonte; qualquer outro arquivo não commitado recusa. Desmonta o worktree (PROVE-or-DECLINE) e libera o bloco de portas; no `--pr` **preserva** a branch (o PR precisa dela) e fica em `morph/{f}`.
128
128
 
129
129
  **Sessão ancorada no worktree → o desmonte é ADIADO** (`--merge` e `--pr`): quando a sessão do Claude Code que rodou o `finish` tem o próprio worktree como projeto (`CLAUDE_PROJECT_DIR` — exportado ao CLI como `MORPH_SESSION_PROJECT_DIR` pelo hook `worktree-env-inject`, porque o Bash tool não o exporta e `cd <raiz> && morph-spec finish` apaga o cwd), a integração acontece normalmente, mas o worktree fica: saída com `worktreeRemoved: false`, `teardownDeferred: "session-anchored"`, `worktree: {path, branch}` e um `hint` com `morph-spec worktree remove <f>` para rodar depois, de uma sessão aberta na raiz. O `--dry-run` já avisa (`teardownDeferred` no pré-voo).
130
130
 
@@ -158,7 +158,7 @@ O `.morph/state.json` do worktree é índice derivável: ele se refaz sozinho a
158
158
 
159
159
  ### env
160
160
 
161
- **`morph-spec env [feature] [--json|--powershell]`** — imprime o bloco do worktree em forma shell-loadable (`eval "$(morph-spec env)"`; PowerShell: `morph-spec env --powershell | Invoke-Expression`). Feature vem do argumento ou da branch `morph/{f}`/`morph-task/{n}` do cwd. Deriva de `buildComposeEnv`, a mesma função do nó e2e — um shell e o stack nunca discordam. É o caminho para quem roda **fora** do Claude Code (terminal humano, CI, sub-agent sem hooks). As credenciais do arquivo apontado por `e2e.auth.envFile` entram no ambiente, mas **chave de layout de portas vinda de lá é ignorada** — o bloco alocado a esta worktree prevalece (um `.env.e2e` copiado de outra árvore carrega o bloco DELA). A chave ignorada é nomeada em **stderr**, nunca em stdout, para o `eval` do shell seguir limpo.
161
+ **`morph-spec env [feature] [--json|--powershell] [--with-secrets]`** — imprime o bloco do worktree em forma shell-loadable (`eval "$(morph-spec env)"`; PowerShell: `morph-spec env --powershell | Invoke-Expression`). Feature vem do argumento ou da branch `morph/{f}`/`morph-task/{n}` do cwd. Deriva de `buildComposeEnv`, a mesma função do nó e2e — um shell e o stack nunca discordam. É o caminho para quem roda **fora** do Claude Code (terminal humano, CI, sub-agent sem hooks). As credenciais do arquivo apontado por `e2e.auth.envFile` **só saem com `--with-secrets`** (o stdout vai parar no transcript; use a flag dentro de `eval "$(…)"`), e sem ela os nomes omitidos vão para stderr. Uma chave `*_PORT` desse arquivo que não bate com nenhuma porta do bloco é avisada em stderr com a sugestão de declará-la em `e2e.portEnvNames`. E **chave de layout de portas vinda de lá é ignorada** — o bloco alocado a esta worktree prevalece (um `.env.e2e` copiado de outra árvore carrega o bloco DELA). A chave ignorada é nomeada em **stderr**, nunca em stdout, para o `eval` do shell seguir limpo.
162
162
 
163
163
  ### worktree remove
164
164
 
@@ -382,13 +382,17 @@ Isolamento entre worktrees: `COMPOSE_PROJECT_NAME=morph-{feature}` separa os con
382
382
 
383
383
  `heavy status` é **somente leitura**: quem segura cada slot (pid, label, feature, nó, idade), a limpeza exclusiva do build-server se houver e a RAM livre.
384
384
 
385
- **Espera:** slot cheio, ou RAM livre (`os.freemem()`) abaixo de `heavy.minFreeMemGb` (4), espera até `heavy.waitTimeoutSec` (600) avisando em **stderr** a cada 30 s e nomeando quem segura (o `--json` mantém o stdout limpo).
385
+ **Espera:** slot cheio, ou RAM livre (`os.freemem()`) abaixo do piso do tipo do comando, espera até `heavy.waitTimeoutSec` (600) avisando em **stderr** a cada 30 s e nomeando quem segura (o `--json` mantém o stdout limpo).
386
+
387
+ **Piso de RAM por tipo** (`heavy.minFreeMemGbByKind`, padrão `unit` 1,5 · `build` 4 · `e2e` 4 GB): `unit` = vitest/jest, `npm|pnpm|yarn test`, `npm run test[:x]` e o nó `tests` do verify; `e2e` = playwright, `docker compose up`, script com `e2e` no nome, o nó `e2e` e o `e2e up`; `build` = o resto (`dotnet build|test|publish` — o `dotnet test` compila —, `next build`, `docker build`, `mutate` e comando desconhecido). O `heavy run` e o hook do `gateBash` classificam pelo comando; o `verify` toma o slot com o piso do primeiro nó e, antes de um nó mais pesado (tests → e2e), espera de novo só pela RAM segurando o slot — estourar ali também é exit 3, sem stamp. `heavy.minFreeMemGb` (4) virou o fallback: **definido explicitamente**, vale para todo tipo que `minFreeMemGbByKind` não lista (`0` continua desligando a checagem em tudo); sem ele, cada tipo usa o seu padrão.
388
+
389
+ **Ocupado por RAM** (`verify`, `e2e up`, `heavy run`): a mensagem nomeia o piso e a chave (`heavy.minFreeMemGbByKind.<tipo>`) e lista as stacks e2e de pé deste repo (`docker compose ls`, prefixo `morph-`, compose sob a raiz principal — de todas as worktrees). Feature já arquivada (`.morph/archive/<f>` sem `.morph/features/<f>`) vem com `morph-spec e2e down <f>`; stack sem feature, com `docker compose -p <nome> down`. Docker fora ou lento (10 s) = lista vazia, nunca erro.
386
390
 
387
391
  **Estouro = exit 3 (`busy`), nunca vermelho:** `verify --json` sai `{ status: "busy", persisted: false, busy: { reason, holders, freeGb, waitedMs } }`, **sem stamp e sem evento `Verify`** (nada foi verificado, e fila não é gate verde nem vermelho); `e2e up` e `heavy run` também saem 3. Quem orquestra repete a chamada — nas skills, exit 3 não conta como tentativa e não dispara o loop de correção.
388
392
 
389
393
  **Slot vago:** pid morto, ou idade acima de `heavy.maxHoldMinutes` (90, contra reuso de PID); Ctrl-C deixa o arquivo, que o pid morto cobre. Um filho herda o slot por `MORPH_HEAVY_LEASE` e nunca pede um segundo.
390
394
 
391
- **Config** (`.morph/config/config.json`, bloco `heavy`, schema em `config.schema.json`): `enabled`, `slots`, `minFreeMemGb`, `waitTimeoutSec`, `maxHoldMinutes`, `shutdownBuildServer`, `disableNodeReuse`, `gateBash`; valor ilegível cai no default **com aviso** — o bloco nunca bloqueia trabalho por estar errado.
395
+ **Config** (`.morph/config/config.json`, bloco `heavy`, schema em `config.schema.json`): `enabled`, `slots`, `minFreeMemGb`, `minFreeMemGbByKind`, `waitTimeoutSec`, `maxHoldMinutes`, `shutdownBuildServer`, `disableNodeReuse`, `gateBash`; valor ilegível cai no default **com aviso** — o bloco nunca bloqueia trabalho por estar errado.
392
396
 
393
397
  Env: `MORPH_HEAVY=off` desliga o semáforo (a suíte do próprio framework roda assim, para não enfileirar nos slots reais de quem a executa), `MORPH_HEAVY_SLOTS`, `MORPH_HEAVY_DIR`.
394
398
 
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { stateExists } from '../../shared/state-reader.js';
7
7
  import { blockResult, runStandalone } from '../../shared/hook-response.js';
8
- import { heavyKind, leaseTtlMs } from '../../shared/heavy-command.js';
8
+ import { heavyClass, heavyKind, leaseTtlMs } from '../../shared/heavy-command.js';
9
9
 
10
10
  const HOOK_BUDGET_SEC = 540;
11
11
 
@@ -18,7 +18,7 @@ export async function run(payload, deps = {}) {
18
18
  if (!kind || !payload?.tool_use_id) return null;
19
19
 
20
20
  // Loaded only here: this link runs on every Bash call and almost none is heavy.
21
- const { readHeavyConfig } = await import('../../shared/heavy-config.js');
21
+ const { minFreeMemFor, readHeavyConfig } = await import('../../shared/heavy-config.js');
22
22
  const { acquireHeavy, describeBusy, heavyDir } = await import('../../shared/heavy-slots.js');
23
23
  const env = deps.env ?? process.env;
24
24
  const dir = env.CLAUDE_PROJECT_DIR || payload?.cwd || process.cwd();
@@ -26,6 +26,7 @@ export async function run(payload, deps = {}) {
26
26
  const config = readHeavyConfig(dir, env);
27
27
  if (!config.enabled || !config.gateBash) return null;
28
28
 
29
+ const memClass = heavyClass(kind);
29
30
  const result = (deps.acquire ?? acquireHeavy)({
30
31
  dir: heavyDir(env),
31
32
  slots: config.slots,
@@ -33,7 +34,8 @@ export async function run(payload, deps = {}) {
33
34
  feature: null,
34
35
  node: null,
35
36
  cwd: dir,
36
- minFreeMemGb: config.minFreeMemGb,
37
+ kind: memClass,
38
+ minFreeMemGb: minFreeMemFor(config, memClass),
37
39
  waitTimeoutMs: Math.min(config.waitTimeoutSec, HOOK_BUDGET_SEC) * 1000,
38
40
  maxHoldMs: config.maxHoldMinutes * 60_000,
39
41
  lease: { id: payload.tool_use_id, ttlMs: leaseTtlMs(input, env) },
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { execSync } from 'child_process';
6
6
  import { loadState } from './state-reader.js';
7
+ import { morphBranchFeature } from './git-paths.js';
7
8
 
8
9
  // `loadState`, not a directory listing: it also sees features only the index carries.
9
10
  export function featureNamesIn(root) {
@@ -55,26 +56,27 @@ export function unrecognizedSlugLike(text, names) {
55
56
  return tokens.find((t) => !known.has(t)) ?? null;
56
57
  }
57
58
 
58
- // The session root wins a feature present in both trees: it is where
59
- // `morph-spec approve` would write from that cwd.
59
+ // The tree on `morph/<f>` owns the feature and wins: the primary root only carries its index
60
+ // entry, and `morph-spec approve` refuses any other tree. Without an owner, the session root wins.
60
61
  export function locateFeatureForQuestion(text, sessionRoot) {
61
62
  const trees = allWorkTrees(sessionRoot);
62
63
  const localNames = featureNamesIn(sessionRoot);
64
+ const ownerOr = (name, root) => trees.find((t) => morphBranchFeature(t) === name) ?? root;
63
65
 
64
66
  const local = featureNamedIn(text, localNames);
65
- if (local) return { name: local, root: sessionRoot };
67
+ if (local) return { name: local, root: ownerOr(local, sessionRoot) };
66
68
 
67
69
  for (const root of trees) {
68
70
  if (root === sessionRoot) continue;
69
71
  const found = featureNamedIn(text, featureNamesIn(root));
70
- if (found) return { name: found, root };
72
+ if (found) return { name: found, root: ownerOr(found, root) };
71
73
  }
72
74
 
73
75
  const allNames = trees.flatMap(featureNamesIn);
74
76
  if (unrecognizedSlugLike(text, allNames)) return null;
75
77
 
76
78
  // With exactly ONE feature in the tree there is nothing else the answer could be about.
77
- if (localNames.length === 1) return { name: localNames[0], root: sessionRoot };
79
+ if (localNames.length === 1) return { name: localNames[0], root: ownerOr(localNames[0], sessionRoot) };
78
80
 
79
81
  return null;
80
82
  }
@@ -106,6 +106,18 @@ export function heavyKind(command) {
106
106
  return null;
107
107
  }
108
108
 
109
+ const E2E_CLASS = /\be2e\b|playwright|compose up/;
110
+ const UNIT_CLASS = /^(tests|(npx )?(vitest|jest)|(npm|pnpm|yarn|bun) (test|run test(:\S+)?))$/;
111
+
112
+ // Maps a `heavyKind` label or a verify node to the RAM class it pays. `dotnet test` and anything
113
+ // unknown stay `build`: they compile before running, and the fallback must never lower a floor.
114
+ export function heavyClass(label) {
115
+ const s = String(label ?? '').toLowerCase().trim();
116
+ if (E2E_CLASS.test(s)) return 'e2e';
117
+ if (UNIT_CLASS.test(s)) return 'unit';
118
+ return 'build';
119
+ }
120
+
109
121
  // The lease has no live process to watch: this TTL is the only thing that frees it after a
110
122
  // denied or interrupted call.
111
123
  const BASH_DEFAULT_TIMEOUT_MS = 2 * 60_000;
@@ -7,6 +7,7 @@ export const HEAVY_DEFAULTS = Object.freeze({
7
7
  enabled: true,
8
8
  slots: 2,
9
9
  minFreeMemGb: 4,
10
+ minFreeMemGbByKind: Object.freeze({ unit: 1.5, build: 4, e2e: 4 }),
10
11
  waitTimeoutSec: 600,
11
12
  maxHoldMinutes: 90,
12
13
  shutdownBuildServer: true,
@@ -14,11 +15,16 @@ export const HEAVY_DEFAULTS = Object.freeze({
14
15
  gateBash: false,
15
16
  });
16
17
 
18
+ export const HEAVY_KINDS = Object.freeze(['unit', 'build', 'e2e']);
19
+
17
20
  const isBool = (v) => typeof v === 'boolean';
21
+ const isGb = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0;
22
+ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
18
23
  const VALID = {
19
24
  enabled: isBool,
20
25
  slots: (v) => Number.isInteger(v) && v >= 1,
21
- minFreeMemGb: (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0,
26
+ minFreeMemGb: isGb,
27
+ minFreeMemGbByKind: isPlainObject,
22
28
  waitTimeoutSec: (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0,
23
29
  maxHoldMinutes: (v) => typeof v === 'number' && Number.isFinite(v) && v > 0,
24
30
  shutdownBuildServer: isBool,
@@ -40,6 +46,7 @@ export function resolveHeavyConfig(config, env = process.env) {
40
46
  const out = { ...HEAVY_DEFAULTS };
41
47
  const warnings = [];
42
48
  const raw = config?.heavy;
49
+ let byKind = {};
43
50
 
44
51
  if (raw !== undefined) {
45
52
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
@@ -47,12 +54,15 @@ export function resolveHeavyConfig(config, env = process.env) {
47
54
  } else {
48
55
  for (const [key, value] of Object.entries(raw)) {
49
56
  if (!(key in VALID)) warnings.push(`heavy.${key} não existe; ignorada`);
57
+ else if (key === 'minFreeMemGbByKind' && VALID[key](value)) byKind = value;
50
58
  else if (VALID[key](value)) out[key] = value;
51
59
  else warnings.push(`heavy.${key} inválida (${JSON.stringify(value)}); usando ${JSON.stringify(HEAVY_DEFAULTS[key])}`);
52
60
  }
53
61
  }
54
62
  }
55
63
 
64
+ out.minFreeMemGbByKind = resolveByKind(byKind, isGb(raw?.minFreeMemGb) ? raw.minFreeMemGb : null, warnings);
65
+
56
66
  if (/^(off|0|false)$/i.test(env.MORPH_HEAVY ?? '')) out.enabled = false;
57
67
  const envSlots = Number(env.MORPH_HEAVY_SLOTS);
58
68
  if (env.MORPH_HEAVY_SLOTS !== undefined && VALID.slots(envSlots)) out.slots = envSlots;
@@ -60,6 +70,23 @@ export function resolveHeavyConfig(config, env = process.env) {
60
70
  return { ...out, warnings };
61
71
  }
62
72
 
73
+ // An explicit `minFreeMemGb` without a per-kind entry still governs that kind:
74
+ // a project that set 0 (RAM check off) or a stricter floor keeps it after the split.
75
+ function resolveByKind(byKind, explicitFloor, warnings) {
76
+ const out = {};
77
+ for (const kind of HEAVY_KINDS) out[kind] = explicitFloor ?? HEAVY_DEFAULTS.minFreeMemGbByKind[kind];
78
+ for (const [kind, value] of Object.entries(byKind)) {
79
+ if (!HEAVY_KINDS.includes(kind)) warnings.push(`heavy.minFreeMemGbByKind.${kind} não existe; use ${HEAVY_KINDS.join(', ')}`);
80
+ else if (isGb(value)) out[kind] = value;
81
+ else warnings.push(`heavy.minFreeMemGbByKind.${kind} inválida (${JSON.stringify(value)}); usando ${out[kind]}`);
82
+ }
83
+ return out;
84
+ }
85
+
86
+ export function minFreeMemFor(config, kind) {
87
+ return config.minFreeMemGbByKind?.[kind] ?? config.minFreeMemGb;
88
+ }
89
+
63
90
  export function readHeavyConfig(projectPath, env = process.env) {
64
91
  return resolveHeavyConfig(readProjectConfig(projectPath), env);
65
92
  }
@@ -49,6 +49,8 @@ function liveHoldersPruningStale(dir, ctx) {
49
49
  return live;
50
50
  }
51
51
 
52
+ const kindSuffix = (kind) => (kind ? ` (${kind})` : '');
53
+
52
54
  function blockedText(blocked, slots, now) {
53
55
  const holders = blocked.holders.map((h) => describeHolder(h, now)).join('; ');
54
56
  if (blocked.reason === 'exclusive') return `aguardando limpeza do servidor de build (${describeHolder(blocked.exclusive, now)})`;
@@ -56,14 +58,14 @@ function blockedText(blocked, slots, now) {
56
58
  const cause = blocked.holders.length
57
59
  ? `slots em uso: ${holders}`
58
60
  : 'nenhum slot em uso: a pressão vem de fora do semáforo (vmmem do WSL, IDE, outros processos)';
59
- return `aguardando RAM livre: ${blocked.freeGb.toFixed(1)} GB < ${blocked.minFreeMemGb} GB mínimos — ${cause}`;
61
+ return `aguardando RAM livre${kindSuffix(blocked.kind)}: ${blocked.freeGb.toFixed(1)} GB < ${blocked.minFreeMemGb} GB mínimos — ${cause}`;
60
62
  }
61
63
  return `aguardando slot pesado (${blocked.holders.length}/${slots}): ${holders}`;
62
64
  }
63
65
 
64
66
  function tryClaim(dir, opts, ctx) {
65
67
  const {
66
- slots, label, feature, node, cwd, minFreeMemGb, freeMem, lease: leaseSpec,
68
+ slots, label, feature, node, cwd, minFreeMemGb, freeMem, kind, lease: leaseSpec,
67
69
  } = opts;
68
70
  const guarded = withClaimMutex(dir, () => {
69
71
  const holders = liveHoldersPruningStale(dir, ctx);
@@ -71,7 +73,7 @@ function tryClaim(dir, opts, ctx) {
71
73
  if (exclusive) return { blocked: { reason: 'exclusive', holders, exclusive } };
72
74
  if (holders.length >= slots) return { blocked: { reason: 'slots', holders } };
73
75
  const freeGb = freeMem() / GB;
74
- if (minFreeMemGb > 0 && freeGb < minFreeMemGb) return { blocked: { reason: 'memory', holders, freeGb, minFreeMemGb } };
76
+ if (minFreeMemGb > 0 && freeGb < minFreeMemGb) return { blocked: { reason: 'memory', holders, freeGb, minFreeMemGb, kind } };
75
77
 
76
78
  for (let i = 0; i < slots + holders.length + 1; i++) {
77
79
  const path = join(dir, `slot-${i}.lock`);
@@ -124,7 +126,13 @@ export function acquireHeavy(opts) {
124
126
  const { blocked } = attempt;
125
127
  if (waited >= waitTimeoutMs) {
126
128
  return {
127
- status: 'busy', reason: blocked.reason, holders: blocked.holders, freeGb: blocked.freeGb ?? null, waitedMs: waited,
129
+ status: 'busy',
130
+ reason: blocked.reason,
131
+ holders: blocked.holders,
132
+ freeGb: blocked.freeGb ?? null,
133
+ minFreeMemGb: claimOpts.minFreeMemGb,
134
+ kind: opts.kind ?? null,
135
+ waitedMs: waited,
128
136
  };
129
137
  }
130
138
  if (blocked.reason !== 'contended' && now() - lastReport >= REPORT_EVERY_MS) {
@@ -135,6 +143,32 @@ export function acquireHeavy(opts) {
135
143
  }
136
144
  }
137
145
 
146
+ // For a holder whose next node needs more RAM than it was admitted with: it keeps its slot and
147
+ // waits only on memory, since re-claiming would hit the re-entrant path and skip the check.
148
+ export function awaitFreeMemory(opts) {
149
+ const {
150
+ minFreeMemGb, waitTimeoutMs, kind = null,
151
+ now = Date.now, sleep = sleepSync, freeMem = freemem, log = (m) => process.stderr.write(`${m}\n`),
152
+ } = opts;
153
+ const start = now();
154
+ let lastReport = -Infinity;
155
+ for (;;) {
156
+ const freeGb = freeMem() / GB;
157
+ const waited = now() - start;
158
+ if (!(minFreeMemGb > 0) || freeGb >= minFreeMemGb) return { status: 'acquired', waitedMs: waited };
159
+ if (waited >= waitTimeoutMs) {
160
+ return {
161
+ status: 'busy', reason: 'memory', holders: [], freeGb, minFreeMemGb, kind, waitedMs: waited,
162
+ };
163
+ }
164
+ if (now() - lastReport >= REPORT_EVERY_MS) {
165
+ log(`aguardando RAM livre${kindSuffix(kind)}: ${freeGb.toFixed(1)} GB < ${minFreeMemGb} GB mínimos, segurando o slot já tomado`);
166
+ lastReport = now();
167
+ }
168
+ sleep(Math.min(POLL_MS, Math.max(1, waitTimeoutMs - waited)));
169
+ }
170
+ }
171
+
138
172
  export function updateLease(lease, patch) {
139
173
  if (!lease) return;
140
174
  try {
@@ -168,7 +202,9 @@ export function releaseLeaseById(dir, id) {
168
202
  export function describeBusy(result, config) {
169
203
  const waited = Math.round(result.waitedMs / 1000);
170
204
  if (result.reason === 'memory') {
171
- return `sem RAM livre há ${waited}s (${result.freeGb?.toFixed(1)} GB < ${config.minFreeMemGb} GB mínimos, heavy.minFreeMemGb)`;
205
+ const floor = result.minFreeMemGb ?? config.minFreeMemGb;
206
+ const key = result.kind ? `heavy.minFreeMemGbByKind.${result.kind}` : 'heavy.minFreeMemGb';
207
+ return `sem RAM livre há ${waited}s (${result.freeGb?.toFixed(1)} GB < ${floor} GB mínimos${kindSuffix(result.kind)}, ${key})`;
172
208
  }
173
209
  if (result.reason === 'exclusive') return `limpeza do servidor de build ainda em curso após ${waited}s`;
174
210
  return `${result.holders.length}/${config.slots} slots pesados ocupados por ${waited}s (heavy.waitTimeoutSec)`;
@@ -179,7 +179,18 @@
179
179
  "type": "number",
180
180
  "minimum": 0,
181
181
  "default": 4,
182
- "description": "Free RAM (os.freemem) below which a heavy node WAITS instead of starting and dying 'killed' for memory. It waits with or without a slot holder - the pressure often comes from outside (WSL vmmem, the IDE). 0 disables the check."
182
+ "description": "Free RAM (os.freemem) below which a heavy node WAITS instead of starting and dying 'killed' for memory. It waits with or without a slot holder - the pressure often comes from outside (WSL vmmem, the IDE). 0 disables the check. Since the per-kind floors exist it is the fallback: set explicitly, it applies to every kind not listed in minFreeMemGbByKind (so 0 still turns the check off everywhere)."
183
+ },
184
+ "minFreeMemGbByKind": {
185
+ "type": "object",
186
+ "additionalProperties": false,
187
+ "default": { "unit": 1.5, "build": 4, "e2e": 4 },
188
+ "description": "Free-RAM floor per kind of heavy command. unit = vitest/jest, npm|pnpm|yarn test, the verify tests node; e2e = playwright, docker compose up, the verify e2e node, e2e up; build = everything else (dotnet build|test|publish, next build, docker build, mutate, unknown commands). A verify admitted at a lighter node waits again, holding its slot, before a heavier one. A kind left out takes an explicit heavy.minFreeMemGb, else its default.",
189
+ "properties": {
190
+ "unit": { "type": "number", "minimum": 0 },
191
+ "build": { "type": "number", "minimum": 0 },
192
+ "e2e": { "type": "number", "minimum": 0 }
193
+ }
183
194
  },
184
195
  "waitTimeoutSec": {
185
196
  "type": "number",
@@ -4,7 +4,7 @@ description: Guia a criação de uma landing page nova ponta-a-ponta a partir do
4
4
  version: core
5
5
  when_to_use: Use quando for iniciar um novo projeto de LP com o template Umbraco genérico
6
6
  user-invocable: true
7
- cliVersion: "9.5.0"
7
+ cliVersion: "9.6.0"
8
8
  stacks:
9
9
  - umbraco
10
10
  ---
@@ -4,7 +4,7 @@ description: Migra uma landing page existente (React/Vite, Next.js estático ou
4
4
  version: core
5
5
  when_to_use: Use quando o usuário pedir "use /migrate-lp-to-umbraco {projeto}" ou descrever a necessidade de migrar uma LP para Umbraco
6
6
  user-invocable: true
7
- cliVersion: "9.5.0"
7
+ cliVersion: "9.6.0"
8
8
  stacks:
9
9
  - umbraco
10
10
  ---
@@ -4,7 +4,7 @@ description: Executa as fatias do plano de uma feature morph-spec — dispatch p
4
4
  version: core
5
5
  when_to_use: Use com o gate do plano aprovado, em "use /morph-apply {feature}" ou "implementar feature"
6
6
  user-invocable: true
7
- cliVersion: "9.5.0"
7
+ cliVersion: "9.6.0"
8
8
  ---
9
9
 
10
10
  # morph-apply
@@ -4,7 +4,7 @@ description: ADW cirúrgico de hotfix — resolve uma queda/bug crítico de prod
4
4
  version: core
5
5
  when_to_use: Use quando chegar "/morph-hotfix {feature}", quando a morph-plan rotear o pedido como workType hotfix, ou quando o usuário disser "produção caiu / hotfix / urgente" a respeito de um incidente
6
6
  user-invocable: true
7
- cliVersion: "9.5.0"
7
+ cliVersion: "9.6.0"
8
8
  ---
9
9
 
10
10
  # morph-hotfix
@@ -4,7 +4,7 @@ description: Cuida da RAM e da CPU da máquina enquanto o morph-spec trabalha
4
4
  version: core
5
5
  when_to_use: Use quando a máquina estiver lenta ou sem RAM, um comando pesado esperou ou foi recusado pela fila, sobrou worktree de sub-agent, ou vários sub-agents pesados vão rodar em paralelo
6
6
  user-invocable: false
7
- cliVersion: "9.5.0"
7
+ cliVersion: "9.6.0"
8
8
  ---
9
9
 
10
10
  # morph-machine
@@ -4,7 +4,7 @@ description: Conduz a AUTORIA de mídia gerada por IA — escolher o modelo pelo
4
4
  version: core
5
5
  when_to_use: Use quando a tarefa for PRODUZIR imagem ou vídeo por IA (catálogo, marketing, asset de LP) e alguém precisar decidir modelo, prompt e aprovação
6
6
  user-invocable: true
7
- cliVersion: "9.5.0"
7
+ cliVersion: "9.6.0"
8
8
  stacks:
9
9
  - dotnet
10
10
  - nextjs
@@ -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.5.0"
7
+ cliVersion: "9.6.0"
8
8
  ---
9
9
 
10
10
  # morph-plan
@@ -71,7 +71,7 @@ O `create` já escreveu o esqueleto em `2-plan/plan.md` (leia e preencha; `chore
71
71
  - **Observável**: que asserção (ou que linha de código) prova? Sem resposta, reescreva ou descarte.
72
72
  - **Artefato não é comportamento.** Cenário que cita `grep`, "contém a string", "o binário", "nenhum arquivo", "arquivo/pasta/diretório em" ou glob `**/` descreve o repositório. Tente o efeito em runtime; se o artefato É o entregável, justifique em `> Por que artefato:` logo abaixo (nunca na mesma linha, nunca `> [!note]`).
73
73
  - **Saída de LLM** (o que o modelo responde) prova-se por `eval` de runtime, nunca pelo texto do prompt: evidência em `3-implement/evidencias/{Cn}.md`, path listado em `arquivos:` da fatia; com nó `runner: "evals"` (standard `ai-agents-evals-with-cache`) o cenário mora no dataset. No máximo 1 teste de estrutura da fonte do prompt.
74
- - **"O comando de teste da raiz roda de ponta a ponta"** prova-se pelo `verify {feature}` (o nó `e2e` e o nó de teste `e2e: true` já percorrem a cadeia): nomeie essa rodada como a prova, nunca uma execução avulsa. Precisando mesmo rodar fora dele, numa linha só (o Bash não guarda o env entre chamadas): `eval "$(morph-spec env {feature})" && morph-spec heavy run -- <comando>` — o `COMPOSE_PROJECT_NAME` da feature é o que o `finish` derruba com os volumes. `prova: manual` registra passo e resultado em `3-implement/evidencias/{Cn}.md`.
74
+ - **"O comando de teste da raiz roda de ponta a ponta"** prova-se pelo `verify {feature}` (o nó `e2e` e o nó de teste `e2e: true` já percorrem a cadeia): nomeie essa rodada como a prova, nunca uma execução avulsa. Precisando mesmo rodar fora dele, numa linha só (o Bash não guarda o env entre chamadas): `eval "$(morph-spec env {feature} --with-secrets)" && morph-spec heavy run -- <comando>` — o `COMPOSE_PROJECT_NAME` da feature é o que o `finish` derruba com os volumes. `prova: manual` registra passo e resultado em `3-implement/evidencias/{Cn}.md`.
75
75
  - **Orçamento de teste**: um teste por cenário de risco — o cenário é a unidade, nunca a pós-condição nem o componente; variantes são linhas de tabela (`[Theory]`/`it.each`); acima disso, o porquê na linha do cenário. Nenhum teste que só fixa texto, prompt ou constante, nenhum de DTO/mapping, e o mesmo comportamento numa camada só (handler **ou** endpoint). Teste além do orçamento é escopo para o revisor. O `verify` avisa quando o teste passa da produção.
76
76
 
77
77
  ```markdown
@@ -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.5.0"
7
+ cliVersion: "9.6.0"
8
8
  ---
9
9
 
10
10
  # morph-review
@@ -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.5.0"
7
+ cliVersion: "9.6.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.5.0"
7
+ cliVersion: "9.6.0"
8
8
  ---
9
9
 
10
10
  # morph-uiux
@@ -96,5 +96,6 @@ volumes:
96
96
  ```bash
97
97
  # inside the worktree, outside Claude Code
98
98
  eval "$(morph-spec env)" # PowerShell: morph-spec env --powershell | Invoke-Expression
99
+ # --with-secrets also loads the e2e.auth.envFile credentials
99
100
  docker compose up -d --wait # lands in project morph-{feature}, on the block's ports
100
101
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polymorphism-tech/morph-spec",
3
- "version": "9.5.0",
3
+ "version": "9.6.0",
4
4
  "description": "MORPH-SPEC: NLH (Natural Language Harness) for spec-driven development with Claude Code",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -28,6 +28,7 @@ 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';
30
30
  import { afterHeavyRun } from '../../lib/heavy/cleanup.js';
31
+ import { memoryBusyHint } from '../../lib/heavy/live-stacks.js';
31
32
  import { readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
32
33
 
33
34
  const EXIT_USAGE = 2;
@@ -126,6 +127,8 @@ export async function e2eUpCommand(featureName, options = {}) {
126
127
  console.log(JSON.stringify({ status: 'busy', feature: featureName, busy: { reason: err.reason, waitedMs: err.waitedMs } }, null, 2));
127
128
  }
128
129
  console.error(chalk.yellow(`\n⏳ e2e up não rodou: ${err.message}. Não é vermelho — a fila já esperou o prazo dela: não repita em laço — siga sem esta rodada e avise o usuário (\`morph-spec heavy status\`).\n`));
130
+ const hint = memoryBusyHint(err, ctx.cwd);
131
+ if (hint) console.error(chalk.yellow(`${hint}\n`));
129
132
  process.exit(EXIT_BUSY);
130
133
  } finally {
131
134
  gate.release();
@@ -35,7 +35,7 @@ function gitCommonDir(cwd) {
35
35
  const shq = (v) => `'${String(v).replace(/'/g, "'\\''")}'`;
36
36
 
37
37
  /** The env map for a key, or `{ error }`. */
38
- export function resolveWorktreeEnv(featureArg, cwd = process.cwd()) {
38
+ export function resolveWorktreeEnv(featureArg, cwd = process.cwd(), { withCredentials = true } = {}) {
39
39
  const key = featureArg || envKeyFromBranch(currentBranch(cwd));
40
40
  if (!key) {
41
41
  return { error: 'no-feature', message: 'Nenhuma feature: passe o nome ou rode de uma branch morph/{f} / morph-task/{n}.' };
@@ -45,7 +45,7 @@ export function resolveWorktreeEnv(featureArg, cwd = process.cwd()) {
45
45
  return { error: 'not-a-git-repo', message: 'Fora de um repositório git não há bloco de portas.' };
46
46
  }
47
47
  const root = resolvePrimaryRoot(cwd);
48
- const env = buildComposeEnv({ cwd: root, config: readConfig(root), featureName: key, gitCommonDir: common });
48
+ const env = buildComposeEnv({ cwd: root, config: readConfig(root), featureName: key, gitCommonDir: common, withCredentials });
49
49
  return { key, env };
50
50
  }
51
51
 
@@ -57,7 +57,7 @@ export function render(env, format) {
57
57
  }
58
58
 
59
59
  export function envCommand(featureArg, options = {}) {
60
- const result = resolveWorktreeEnv(featureArg || null, process.cwd());
60
+ const result = resolveWorktreeEnv(featureArg || null, process.cwd(), { withCredentials: Boolean(options.withSecrets) });
61
61
  if (result.error) {
62
62
  if (options.json) console.log(JSON.stringify({ error: result.error, message: result.message }));
63
63
  else console.error(chalk.red(`✗ ${result.message}`));
@@ -5,6 +5,8 @@ import { readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js
5
5
  import { createHeavyGate, HeavyBusyError } from '../../lib/heavy/gate.js';
6
6
  import { describeHolder, heavyDir, heavyStatus } from '../../../framework/hooks/shared/heavy-slots.js';
7
7
  import { resolveSpawn } from '../../../framework/hooks/shared/spawn-command.js';
8
+ import { heavyClass, heavyKind } from '../../../framework/hooks/shared/heavy-command.js';
9
+ import { memoryBusyHint } from '../../lib/heavy/live-stacks.js';
8
10
 
9
11
  const EXIT_BUSY = 3;
10
12
 
@@ -15,6 +17,7 @@ export function heavyStatusCommand(options = {}) {
15
17
  enabled: config.enabled,
16
18
  slots: config.slots,
17
19
  minFreeMemGb: config.minFreeMemGb,
20
+ minFreeMemGbByKind: config.minFreeMemGbByKind,
18
21
  dir: status.dir,
19
22
  freeGb: Number(status.freeGb.toFixed(2)),
20
23
  holders: status.holders.map(({ pid, label, feature, node, cwd, since, leaseId }) => ({ pid, label, feature, node, cwd, since, ...(leaseId ? { leaseId } : {}) })),
@@ -29,8 +32,10 @@ export function heavyStatusCommand(options = {}) {
29
32
  logger.header('Slots de nós pesados (build, testes, e2e)');
30
33
  if (!config.enabled) console.log(chalk.yellow(' ⚠ semáforo desligado (heavy.enabled: false ou MORPH_HEAVY=off)'));
31
34
  console.log(chalk.gray(` diretório: ${report.dir}`));
32
- const memMark = report.freeGb < config.minFreeMemGb ? chalk.yellow('⚠') : chalk.green('✓');
33
- console.log(` ${memMark} RAM livre: ${report.freeGb.toFixed(1)} GB (mínimo ${config.minFreeMemGb} GB)`);
35
+ const floors = config.minFreeMemGbByKind;
36
+ const memMark = report.freeGb < Math.max(...Object.values(floors)) ? chalk.yellow('⚠') : chalk.green('✓');
37
+ const floorText = Object.entries(floors).map(([kind, gb]) => `${kind} ${gb}`).join(' · ');
38
+ console.log(` ${memMark} RAM livre: ${report.freeGb.toFixed(1)} GB (mínimo em GB: ${floorText})`);
34
39
  console.log(` slots em uso: ${status.holders.length}/${config.slots}`);
35
40
  for (const h of status.holders) console.log(chalk.gray(` • ${describeHolder(h)}${h.cwd ? ` — ${h.cwd}` : ''}`));
36
41
  if (status.exclusive) console.log(chalk.yellow(` ⚠ limpeza do servidor de build em curso (${describeHolder(status.exclusive)})`));
@@ -42,10 +47,12 @@ export function heavyRunCommand(command, options = {}) {
42
47
  const [file, ...args] = command;
43
48
  const gate = createHeavyGate({ projectPath: process.cwd(), label: options.label ?? 'run' });
44
49
  try {
45
- gate.enter(file);
50
+ gate.enter(file, heavyClass(heavyKind(command.join(' '))));
46
51
  } catch (err) {
47
52
  if (!(err instanceof HeavyBusyError)) throw err;
48
53
  console.error(chalk.yellow(`\n⏳ ${file} não rodou: ${err.message}. Não é vermelho — a fila já esperou o prazo dela: não repita em laço — siga sem esta rodada e avise o usuário (\`morph-spec heavy status\` mostra quem segura os slots).\n`));
54
+ const hint = memoryBusyHint(err, process.cwd());
55
+ if (hint) console.error(chalk.yellow(`${hint}\n`));
49
56
  process.exit(EXIT_BUSY);
50
57
  }
51
58
 
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, symlinkSync, readFileSync, writeFileSync, lstatS
3
3
  import { join, resolve, dirname, basename } from 'path';
4
4
  import chalk from 'chalk';
5
5
  import { detectDefaultBranch } from '../../lib/git/default-branch.js';
6
- import { triageIgnoredPaths } from '../../lib/worktree/ignored-triage.js';
6
+ import { isTestArtifact, triageIgnoredPaths } from '../../lib/worktree/ignored-triage.js';
7
7
  import {
8
8
  finishBackupDir,
9
9
  writeFeatureScopeBackup,
@@ -710,6 +710,7 @@ export function primaryRootUnexplainedDirt(root, feature, opts = {}) {
710
710
  if (!record || record.length < 4) return false;
711
711
  const p = record.slice(3).replace(/\\/g, '/');
712
712
  if (opts.exemptOwnScope && p.startsWith(ownScope)) return false;
713
+ if (record.startsWith('?? ') && isTestArtifact(p)) return false;
713
714
  return !isLiveSiblingWorktreeRegistration(p, root, feature);
714
715
  });
715
716
  return { ok: true, dirt };
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from 'child_process';
2
- import { existsSync, readFileSync, writeFileSync } from 'fs';
2
+ import { existsSync, readFileSync, rmSync, writeFileSync } from 'fs';
3
3
  import { join, resolve } from 'path';
4
4
  import {
5
5
  findWorktreeForFeature,
@@ -23,6 +23,7 @@ import { findReparsePoints } from '../../../framework/hooks/shared/reparse-scan.
23
23
  import { runArchive } from './archive.js';
24
24
  import { dropLeftoverIgnore, withoutLeftoverIgnore } from '../../../framework/hooks/shared/graph-freshness.js';
25
25
  import { newBackupStamp, enforceBackupRetention, inspectFinishBackups } from '../../lib/worktree/finish-backup.js';
26
+ import { isTestArtifact } from '../../lib/worktree/ignored-triage.js';
26
27
 
27
28
  const PR_TYPE_BY_WORK_TYPE = { feature: 'feat', bug: 'fix', hotfix: 'fix', chore: 'chore' };
28
29
 
@@ -289,7 +290,9 @@ export async function finishCommand(feature, options = {}) {
289
290
  emit({ finished: false, error: 'status-failed', feature });
290
291
  process.exit(1);
291
292
  }
292
- if (withoutLeftoverIgnore(cleanCwd, dirty.split(/\r?\n/).filter((l) => l.trim())).length) {
293
+ const dirtLines = withoutLeftoverIgnore(cleanCwd, dirty.split(/\r?\n/).filter((l) => l.trim()));
294
+ const testArtifacts = untrackedTestArtifacts(dirtLines);
295
+ if (dirtLines.length > testArtifacts.length) {
293
296
  emit({ finished: false, error: isWorktreeFlow ? 'worktree-dirty' : 'working-dir-dirty', hint: 'commit seu trabalho na branch antes do finish', feature });
294
297
  process.exit(1);
295
298
  }
@@ -338,7 +341,10 @@ export async function finishCommand(feature, options = {}) {
338
341
 
339
342
  // Every refusal is behind us: the leftover of a killed `graph refresh` goes now, never before.
340
343
  dropLeftoverIgnore(cleanCwd);
341
- const arch = runArchive(feature, { cwd: featureCwd, force: options.force, git: true });
344
+ if (isWorktreeFlow) {
345
+ for (const p of testArtifacts) rmSync(join(cleanCwd, p), { recursive: true, force: true });
346
+ }
347
+ const arch =runArchive(feature, { cwd: featureCwd, force: options.force, git: true });
342
348
  if (!arch.ok) {
343
349
  emit({ finished: false, error: `archive-${arch.error}`, feature });
344
350
  process.exit(1);
@@ -794,6 +800,14 @@ function currentBranch(cwd) {
794
800
  }
795
801
  }
796
802
 
803
+ /** Untracked Playwright/test output (`?? test-results/`): never work, so it neither blocks nor survives the teardown. */
804
+ function untrackedTestArtifacts(lines) {
805
+ return lines
806
+ .filter((l) => l.startsWith('?? '))
807
+ .map((l) => l.slice(3).replace(/^"|"$/g, '').replace(/\/$/, ''))
808
+ .filter((p) => isTestArtifact(p));
809
+ }
810
+
797
811
  function gitStatusPorcelain(cwd) {
798
812
  try {
799
813
  return execFileSync('git', ['status', '--porcelain'], { cwd, stdio: 'pipe' }).toString();
@@ -113,13 +113,13 @@ function subsetFor({ node, group, root }) {
113
113
 
114
114
  function e2eManualProtocol(feature, node) {
115
115
  const f = feature ?? '<feature>';
116
- const round = `eval "$(morph-spec env ${f})" && morph-spec e2e up ${f} && morph-spec heavy run -- ${node.command}`;
116
+ const round = `eval "$(morph-spec env ${f} --with-secrets)" && morph-spec e2e up ${f} && morph-spec heavy run -- ${node.command}`;
117
117
  return 'mutate só recompila localmente, e a suíte que alcança esse código testa o container do compose: o mutante sairia sobrevivente em falso. ' +
118
118
  `Prove à mão pelo nó "${node.id}", cada passo numa linha só e encadeado por \`&&\` (nunca \`;\`: um passo que falha não pode deixar a suíte rodar sobre o código original; ` +
119
119
  'e o env da feature se perde entre chamadas de Bash): ' +
120
120
  `(1) base verde antes do 1º mutante: \`${round}\`; ` +
121
121
  `(2) por mutante: edite a linha && \`${round}\`; ` +
122
- `(3) restaure, confirme com \`git diff --quiet -- <arquivo>\` e suba a stack de novo (\`eval "$(morph-spec env ${f})" && morph-spec e2e up ${f}\`). ` +
122
+ `(3) restaure, confirme com \`git diff --quiet -- <arquivo>\` e suba a stack de novo (\`eval "$(morph-spec env ${f} --with-secrets)" && morph-spec e2e up ${f}\`). ` +
123
123
  'Registre cada mutante em 4-review (expect, resultado, teste que o matou).';
124
124
  }
125
125
 
@@ -11,6 +11,7 @@ import { buildVerifyEvent, recordVerifyEvent, findRepeatedVerify } from '../../l
11
11
  import { treeFingerprint } from '../../lib/git/tree-fingerprint.js';
12
12
  import { HeavyBusyError } from '../../lib/heavy/gate.js';
13
13
  import { afterHeavyRun } from '../../lib/heavy/cleanup.js';
14
+ import { memoryBusyHint } from '../../lib/heavy/live-stacks.js';
14
15
  import { readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
15
16
  import { runCiVerify, CiUsageError, reusableLocalStamp } from '../../lib/ci/ci-verify.js';
16
17
 
@@ -146,6 +147,8 @@ async function runLocalVerify(featureName, options) {
146
147
  }, null, 2));
147
148
  }
148
149
  console.error(chalk.yellow(`\n⏳ verify não rodou: ${err.message}. Não é vermelho — a fila já esperou o prazo dela: não repita em laço — siga sem esta rodada e avise o usuário (\`morph-spec heavy status\` mostra quem segura os slots).\n`));
150
+ const hint = memoryBusyHint(err, process.cwd());
151
+ if (hint) console.error(chalk.yellow(`${hint}\n`));
149
152
  process.exit(3);
150
153
  }
151
154
  throw err;
@@ -11,7 +11,9 @@ import { allocateEnvBlock, portLayout, resolveFleetConfig, resolvePortAliases }
11
11
  // Ports come from `portLayout()`, the same function that renders `.morph/worktree.env`. The credentials
12
12
  // file loads after the layout but cannot outrank it: a `.env.e2e` copied from a sibling worktree carries
13
13
  // that worktree's block. Ignored keys are announced on stderr; stdout carries `morph-spec env` output.
14
- export function buildComposeEnv({ cwd, config, featureName, gitCommonDir }) {
14
+ // `withCredentials: false` keeps envFile values off stdout (`morph-spec env` lands in transcripts);
15
+ // the withheld names go to stderr.
16
+ export function buildComposeEnv({ cwd, config, featureName, gitCommonDir, withCredentials = true }) {
15
17
  const env = {};
16
18
  let layoutKeys = null;
17
19
 
@@ -38,16 +40,37 @@ export function buildComposeEnv({ cwd, config, featureName, gitCommonDir }) {
38
40
  const envFile = config?.e2e?.auth?.envFile;
39
41
  if (envFile) {
40
42
  const ignored = [];
43
+ const withheld = [];
44
+ const foreignPorts = [];
45
+ const layoutPorts = layoutKeys ? new Set([...layoutKeys].map((key) => env[key])) : null;
41
46
  for (const [key, value] of Object.entries(loadEnvFile(join(cwd, envFile)))) {
42
- if (layoutKeys?.has(key)) ignored.push(key);
43
- else env[key] = value;
47
+ if (layoutKeys?.has(key)) {
48
+ ignored.push(key);
49
+ continue;
50
+ }
51
+ if (layoutPorts && /(^|_)PORT$/.test(key) && !layoutPorts.has(value)) foreignPorts.push(`${key}=${value}`);
52
+ if (withCredentials) env[key] = value;
53
+ else withheld.push(key);
44
54
  }
45
55
  if (ignored.length) reportIgnoredLayoutKeys(envFile, ignored, env);
56
+ if (foreignPorts.length) reportForeignPorts(envFile, foreignPorts);
57
+ if (withheld.length) {
58
+ console.warn(chalk.yellow(
59
+ ` ⚠ ${envFile}: ${withheld.join(', ')} omitida(s) — credenciais não vão para o stdout; use --with-secrets dentro de eval "$(…)".`,
60
+ ));
61
+ }
46
62
  }
47
63
 
48
64
  return env;
49
65
  }
50
66
 
67
+ function reportForeignPorts(envFile, foreignPorts) {
68
+ console.warn(chalk.yellow(
69
+ ` ⚠ ${envFile}: ${foreignPorts.join(', ')} não é porta do bloco desta worktree. `
70
+ + 'Se nomeia uma das portas do layout, declare-a em e2e.portEnvNames (ex.: { "postgres": "PG_PORT" }) e o bloco passa a prevalecer.',
71
+ ));
72
+ }
73
+
51
74
  function reportIgnoredLayoutKeys(envFile, ignored, env) {
52
75
  const shown = ignored.map((key) => `${key}=${env[key]}`).join(', ');
53
76
  console.warn(chalk.yellow(
@@ -1,6 +1,7 @@
1
- import { readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
1
+ import { minFreeMemFor, readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
2
+ import { heavyClass } from '../../../framework/hooks/shared/heavy-command.js';
2
3
  import {
3
- acquireHeavy, describeBusy, heavyDir, releaseHeavy, updateLease,
4
+ acquireHeavy, awaitFreeMemory, describeBusy, heavyDir, releaseHeavy, updateLease,
4
5
  } from '../../../framework/hooks/shared/heavy-slots.js';
5
6
 
6
7
  export class HeavyBusyError extends Error {
@@ -11,6 +12,8 @@ export class HeavyBusyError extends Error {
11
12
  this.reason = result.reason;
12
13
  this.holders = result.holders;
13
14
  this.freeGb = result.freeGb;
15
+ this.minFreeMemGb = result.minFreeMemGb ?? null;
16
+ this.kind = result.kind ?? null;
14
17
  this.waitedMs = result.waitedMs;
15
18
  }
16
19
 
@@ -28,6 +31,7 @@ export function createHeavyGate({
28
31
  } = {}) {
29
32
  let entered = false;
30
33
  let lease = null;
34
+ let admittedGb = 0;
31
35
  let waitedMs = 0;
32
36
  let exitHandler = null;
33
37
  let saved = null;
@@ -50,26 +54,40 @@ export function createHeavyGate({
50
54
  entered = false;
51
55
  };
52
56
 
57
+ const raiseFloor = (kind) => {
58
+ const floor = minFreeMemFor(config, kind);
59
+ if (!lease || floor <= admittedGb) return;
60
+ const result = awaitFreeMemory({
61
+ minFreeMemGb: floor, waitTimeoutMs: config.waitTimeoutSec * 1000, kind, log, ...deps,
62
+ });
63
+ if (result.status === 'busy') throw new HeavyBusyError(result, config);
64
+ waitedMs += result.waitedMs;
65
+ admittedGb = floor;
66
+ };
67
+
53
68
  return {
54
69
  config,
55
70
  get waitedMs() { return waitedMs; },
56
71
 
57
- enter(node) {
72
+ enter(node, kind = heavyClass(node)) {
58
73
  if (entered) {
74
+ raiseFloor(kind);
59
75
  updateLease(lease, { node });
60
76
  return;
61
77
  }
62
78
  for (const warning of config.warnings ?? []) log(`heavy: ${warning}`);
63
79
 
64
80
  if (config.enabled) {
81
+ const minFreeMemGb = minFreeMemFor(config, kind);
65
82
  const result = acquireHeavy({
66
83
  dir: heavyDir(env),
67
84
  slots: config.slots,
68
85
  label,
69
86
  feature,
70
87
  node,
88
+ kind,
71
89
  cwd: projectPath,
72
- minFreeMemGb: config.minFreeMemGb,
90
+ minFreeMemGb,
73
91
  waitTimeoutMs: config.waitTimeoutSec * 1000,
74
92
  maxHoldMs: config.maxHoldMinutes * 60_000,
75
93
  env,
@@ -78,6 +96,7 @@ export function createHeavyGate({
78
96
  });
79
97
  if (result.status === 'busy') throw new HeavyBusyError(result, config);
80
98
  lease = result.lease;
99
+ admittedGb = minFreeMemGb;
81
100
  waitedMs = result.waitedMs;
82
101
  }
83
102
 
@@ -0,0 +1,64 @@
1
+ import { readdirSync } from 'node:fs';
2
+ import { basename, dirname, join } from 'node:path';
3
+ import { composeProjectName, defaultExec } from '../../../framework/hooks/shared/e2e-check.js';
4
+ import {
5
+ MORPH_PROJECT_PREFIX, parseDockerJson, resolveGitCommonDir, stackBelongsTo,
6
+ } from '../worktree/docker-audit.js';
7
+
8
+ // Runs right after a full queue wait: a hung daemon must not add another one.
9
+ const LS_TIMEOUT_MS = 10_000;
10
+
11
+ // Stacks of every worktree live under the main checkout, and so does `.morph/archive`.
12
+ export function repoMainRoot(cwd) {
13
+ const common = resolveGitCommonDir(cwd);
14
+ return common && basename(common) === '.git' ? dirname(common) : cwd;
15
+ }
16
+
17
+ function dirNames(dir) {
18
+ try {
19
+ return readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
20
+ } catch {
21
+ return [];
22
+ }
23
+ }
24
+
25
+ export function liveE2eStacks(cwd, { exec = defaultExec, root = repoMainRoot(cwd) } = {}) {
26
+ try {
27
+ const ls = exec('docker', ['compose', 'ls', '--format', 'json'], { cwd: root, timeout: LS_TIMEOUT_MS });
28
+ if (ls.code !== 0) return [];
29
+ const projects = parseDockerJson(ls.output);
30
+ if (!projects) return [];
31
+
32
+ const active = dirNames(join(root, '.morph', 'features'));
33
+ const owners = new Map(active.map((f) => [composeProjectName(f), { feature: f, state: 'active' }]));
34
+ for (const f of dirNames(join(root, '.morph', 'archive'))) {
35
+ const project = composeProjectName(f);
36
+ if (!owners.has(project)) owners.set(project, { feature: f, state: 'archived' });
37
+ }
38
+
39
+ return projects
40
+ .filter((p) => String(p?.Name ?? '').startsWith(MORPH_PROJECT_PREFIX) && stackBelongsTo(p, root))
41
+ .map((p) => {
42
+ const project = String(p.Name);
43
+ return { project, status: p.Status ?? null, ...(owners.get(project) ?? { feature: null, state: 'unknown' }) };
44
+ });
45
+ } catch {
46
+ return [];
47
+ }
48
+ }
49
+
50
+ function stackLine(s) {
51
+ if (s.state === 'archived') return ` • ${s.project}: feature ${s.feature} já arquivada — \`morph-spec e2e down ${s.feature}\` devolve a RAM`;
52
+ if (s.state === 'unknown') return ` • ${s.project}: nenhuma feature deste repo responde por ela — \`docker compose -p ${s.project} down\``;
53
+ return ` • ${s.project}: feature ${s.feature} em andamento`;
54
+ }
55
+
56
+ export function describeLiveStacks(stacks) {
57
+ if (!stacks.length) return '';
58
+ return `stacks e2e de pé neste repo (seguram RAM):\n${stacks.map(stackLine).join('\n')}`;
59
+ }
60
+
61
+ export function memoryBusyHint(err, cwd, deps = {}) {
62
+ if (err?.reason !== 'memory') return '';
63
+ return describeLiveStacks(liveE2eStacks(cwd, deps));
64
+ }
@@ -1,5 +1,7 @@
1
1
  import { MAX_SLICES } from './plan/checks.js';
2
2
  import { BLOCKER_TYPES, reviewFileName } from './review-reports.js';
3
+ import { isTestPath } from './graph/sanitize.js';
4
+ import { isTestArtifact } from './worktree/ignored-triage.js';
3
5
 
4
6
  const SECURITY_PATH_RE = /(^|[/._-])(auth|login|senha|password|token|secret|crypto|permiss|rbac|acl|lgpd|pii|privacy|payment|pagamento|billing|cobranca|invoice|asaas|stripe|migrations?)([/._-]|$)/i;
5
7
 
@@ -135,8 +137,14 @@ export function verifierModelFor({ promptModelTouched = false, generatesMedia =
135
137
  return { model: reasons.length ? 'opus' : 'sonnet', reasons };
136
138
  }
137
139
 
140
+ // Teste e artefato de teste não são superfície de ataque: `e2e/login.spec.ts` não pede a persona security.
141
+ const E2E_DIR_RE = /(^|\/)e2e\//;
142
+ const isTestOnly = (f) => isTestPath(f) || E2E_DIR_RE.test(f) || isTestArtifact(f);
143
+
138
144
  export function securityReviewFor(changedFiles = []) {
139
- const risky = changedFiles.filter((f) => SECURITY_PATH_RE.test(f.replaceAll('\\', '/')));
145
+ const risky = changedFiles
146
+ .map((f) => f.replaceAll('\\', '/'))
147
+ .filter((f) => SECURITY_PATH_RE.test(f) && !isTestOnly(f));
140
148
  return { required: risky.length > 0, reasons: risky.length ? [`caminho sensível: ${firstOf(risky)}`] : [] };
141
149
  }
142
150
 
@@ -12,9 +12,9 @@ import {
12
12
  import { allocateEnvBlock, allocatedFeatures, portLayout, resolveFleetConfig } from './env-allocator.js';
13
13
 
14
14
  const PROJECT_PROBE = 'probe';
15
- const MORPH_PROJECT_PREFIX = composeProjectName(PROJECT_PROBE).slice(0, -PROJECT_PROBE.length);
15
+ export const MORPH_PROJECT_PREFIX = composeProjectName(PROJECT_PROBE).slice(0, -PROJECT_PROBE.length);
16
16
 
17
- function resolveGitCommonDir(cwd) {
17
+ export function resolveGitCommonDir(cwd) {
18
18
  try {
19
19
  const common = execFileSync('git', ['rev-parse', '--git-common-dir'], {
20
20
  cwd, stdio: 'pipe', encoding: 'utf-8', windowsHide: true,
@@ -57,6 +57,10 @@ function publishedPorts(entry) {
57
57
  return ports;
58
58
  }
59
59
 
60
+ export function stackBelongsTo(entry, root) {
61
+ return belongsToRoot(entry, norm(root));
62
+ }
63
+
60
64
  function composeProjectOf(entry) {
61
65
  const labels = String(entry?.Labels ?? '');
62
66
  for (const part of labels.split(',')) {
@@ -73,7 +77,7 @@ function containerNameOf(entry) {
73
77
  }
74
78
 
75
79
  // `null` = unreadable, distinct from `[]` = docker answered with nothing running.
76
- function parseDockerJson(raw) {
80
+ export function parseDockerJson(raw) {
77
81
  const text = String(raw ?? '').trim();
78
82
  if (!text) return [];
79
83
  const parsed = parseComposePs(text);
@@ -9,6 +9,22 @@ const DEFAULT_BASE_PORT = 4000;
9
9
  const DEFAULT_BLOCK_SIZE = 10;
10
10
  const MAX_PORT = 65535;
11
11
 
12
+ // Chromium's kRestrictedPorts (net/base/port_util.cc): the browser refuses them with
13
+ // ERR_UNSAFE_PORT, so a block holding one is a stack Playwright and the verifier cannot open.
14
+ const BROWSER_UNSAFE_PORTS = new Set([
15
+ 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 87, 95, 101, 102, 103,
16
+ 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 139, 143, 161, 179, 389, 427, 465, 512, 513,
17
+ 514, 515, 526, 530, 531, 532, 540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993, 995, 1719, 1720,
18
+ 1723, 2049, 3659, 4045, 4190, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6679, 6697, 10080,
19
+ ]);
20
+
21
+ export function blockHasUnsafePort(base, blockSize) {
22
+ for (let port = base; port < base + blockSize; port += 1) {
23
+ if (BROWSER_UNSAFE_PORTS.has(port)) return true;
24
+ }
25
+ return false;
26
+ }
27
+
12
28
  export function registryPath(gitCommonDir) {
13
29
  return join(gitCommonDir, 'morph-spec', 'env-registry.json');
14
30
  }
@@ -47,23 +63,29 @@ export function resolveFleetConfig(rootPath) {
47
63
  }
48
64
  }
49
65
 
50
- /** First free index wins, so released slots are reused instead of creeping toward the port ceiling. */
66
+ /**
67
+ * First free index wins, so released slots are reused instead of creeping toward the port ceiling.
68
+ * A block with a browser-unsafe port is never handed out, and one recorded before this rule is moved.
69
+ */
51
70
  export function allocateEnvBlock(feature, { gitCommonDir, basePort = DEFAULT_BASE_PORT, blockSize = DEFAULT_BLOCK_SIZE }) {
52
71
  const registry = readRegistry(gitCommonDir);
53
72
 
54
73
  const existing = registry.allocations[feature];
55
74
  if (existing && Number.isInteger(existing.index)) {
56
75
  const base = basePort + existing.index * blockSize;
57
- return { index: existing.index, base, range: [base, base + blockSize - 1], created: false };
76
+ if (!blockHasUnsafePort(base, blockSize)) {
77
+ return { index: existing.index, base, range: [base, base + blockSize - 1], created: false };
78
+ }
58
79
  }
59
80
 
60
81
  const used = new Set(
61
- Object.values(registry.allocations)
62
- .map((a) => a?.index)
82
+ Object.entries(registry.allocations)
83
+ .filter(([name]) => name !== feature)
84
+ .map(([, a]) => a?.index)
63
85
  .filter((i) => Number.isInteger(i)),
64
86
  );
65
87
  let index = 0;
66
- while (used.has(index)) index += 1;
88
+ while (used.has(index) || blockHasUnsafePort(basePort + index * blockSize, blockSize)) index += 1;
67
89
 
68
90
  const base = basePort + index * blockSize;
69
91
  if (base + blockSize - 1 > MAX_PORT) {
@@ -18,6 +18,14 @@ const DISPOSABLE_FILE_RE = /\.tsbuildinfo$/;
18
18
  const REPLICABLE_MAX_FILES = 500;
19
19
  const REPLICABLE_MAX_BYTES = 8 * 1024 * 1024;
20
20
 
21
+ // Narrower than the build set on purpose: an untracked `bin/` may be new code, a `test-results/` never is.
22
+ const TEST_ARTIFACT_DIR_NAMES = new Set(['test-results', 'TestResults', 'playwright-report', 'blob-report']);
23
+
24
+ /** @param {string} p - forward-slash relative path */
25
+ export function isTestArtifact(p) {
26
+ return p.split('/').filter(Boolean).some((seg) => TEST_ARTIFACT_DIR_NAMES.has(seg));
27
+ }
28
+
21
29
  /** @param {string} p - forward-slash relative path */
22
30
  export function isDisposableBuildArtifact(p) {
23
31
  if (DISPOSABLE_FILE_RE.test(p)) return true;