@polymorphism-tech/morph-spec 8.35.0 → 8.35.1

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.
package/CLAUDE.md CHANGED
@@ -125,7 +125,7 @@ When modifying framework components, verify the full scope of related files.
125
125
  | `src/lib/git/pr-index.js` | os QUATRO leitores do mesmo fato "esta branch tem PR": `fleet-scanner.js` (coluna PR), `branch-classifier.js` (o que NÃO apagar), `worktree-audit.js` (o que NÃO sugerir remover) e `pending-prs.js` (a síntese "aguardando merge"). `null` é UNKNOWN, nunca "sem PR" |
126
126
  | `src/lib/git/branch-classifier.js` | `src/commands/project/branch.js`, `worktree-audit.js` (`unintegratedWork` reusa `classifyBranch` — o `doctor` e o `branch prune` não podem discordar sobre a mesma branch), `test/commands/branch-prune.test.js`, e `lifecycle-audit.js` (`abandoned-scaffold` conta commit próprio pelo mesmo `resolveBaseRef` + `commitsAhead`) |
127
127
  | `src/lib/git/pending-prs.js` | os TRÊS consumidores da mesma síntese "aguardando merge": `list.js` (linha sintetizada), `fleet-scanner.js` (entrada `pr-pending`) e `doctor.js` (`auditPullRequests`). Uma feature visível numa superfície e invisível na outra é o bug que este módulo existe para impedir |
128
- | `src/lib/git/gh.js` | `pr-index.js` e `finish.js` — as DUAS formas de invocar o `gh` viraram uma; uma segunda cópia de `resolveGh` volta a tornar o caminho de sucesso do índice intestável |
128
+ | `src/lib/git/gh.js` | `pr-index.js`, `pr-files.js` e `finish.js` — as DUAS formas de invocar o `gh` viraram uma; uma segunda cópia de `resolveGh` volta a tornar o caminho de sucesso do índice intestável |
129
129
  | `src/lib/worktree/feature-truth.js` | The four leaders reading the same "which tree owns this feature" predicate: `fleet-scanner.js`, `list.js`, `handoff.js`, and the mutator guard (`approve.js`/`advance.js`/`score.js`/`gate-decision.js`/`gate-check.js`) |
130
130
  | `src/lib/tasks/test-runner.js` | `verify-runner.js` (o nó `tests` repassa o `--timeout` e lê `timedOut`), `src/commands/project/worktree.js` (as constantes `WORKTREES_DIR`/`LEGACY_WORKTREES_DIR` que a descoberta ignora — duplicar as strings aqui é como o bug volta), `test/lib/test-runner.test.js` (o canário do ignore de worktrees e o do timeout escalado). Um worktree é o checkout de OUTRA feature: varrê-lo dobra o relógio e reporta sobre código fora da árvore avaliada |
131
131
  | `src/lib/tasks/linux-runner.js` | `verify-runner.js` (`runNodesInLinux` faz as MESMAS pré-decisões do laço local e julga o resultado pelo MESMO `judgeRun` — uma segunda cópia dessa regra é como container e host passam a discordar da mesma suíte), `framework/hooks/shared/e2e-check.js` (`defaultExec`/`isDockerAvailable` são reusados, nunca recriados), `src/lib/worktree/dirs.js` (nomes do contêiner de worktree nas exclusões do `tar`), `framework/schemas/config.schema.json` (`linuxImage`) e `test/lib/tasks/linux-runner.test.js` (o canário do bind mount: montar a árvore em `/work` é v9fs case-insensitive e não reproduz F35) |
@@ -137,7 +137,7 @@ When modifying framework components, verify the full scope of related files.
137
137
  | o ciclo de reuso (`reuse-map.md`) | `output-schema.js` (+ `npm run generate`), `framework/schemas/tasks.schema.json` + `tasks-json-parser.js` (campo `reuse`), `morph-plan` §1c/§5b, template de dispatch de `morph-implement`/`morph-apply`, personas de `agents.json`, `evals/rubrics/code-quality.md` + `condensed.md`, `morph-review` (insumo do avaliador), `test/framework/graph-fallback.test.js` |
138
138
  | `framework/mcp/registry.json` | mcp-installer.js, claude-config-detector.js, agents-installer.js (personas' `mcp` field), `scripts/validate-framework.js` Check 8, `test/framework/mcp-registry.test.js` |
139
139
  | Any `framework/` file that calls `morph-spec graph` | The fallback to grep must be declared in the SAME file — `test/framework/graph-fallback.test.js` enforces it (ADR-002: a skill that requires the graph with no fallback is a bug) |
140
- | `framework/hooks/claude-code/core/worktree-create.js` / `worktree-remove.js` / `block-raw-worktree-add.js` / `worktree-env-inject.js` | `hooks-installer.js` (`MORPH_HOOKS`: eventos `WorktreeCreate`/`WorktreeRemove` + a ORDEM da chain Bash — inject por último, um comando bloqueado nunca é reescrito), `hooks-scanner.js` `EVENT_KEYS`, `chain.js` (`updatedInput` só viaja com rewrite — `permissionDecision:'allow'` incondicional pularia o prompt de todo Bash), `worktree.js` (`runWorktreeProvision`, `--if-clean`, `envKeyForWorktree`), `worktree-audit.js` (`morph-task/*`, `out-of-container`), `worktree-context.js`, MORPH.md §2.2, `morph-apply` §3b-w, `test/framework/worktree-governance-prose.test.js`, `resident-size.test.js` (MORPH.md está NO teto) |
140
+ | `framework/hooks/claude-code/core/worktree-create.js` / `worktree-remove.js` / `block-raw-worktree-add.js` / `worktree-env-inject.js` | `hooks-installer.js` (`MORPH_HOOKS`: eventos `WorktreeCreate`/`WorktreeRemove` + a ORDEM da chain Bash — inject por último, um comando bloqueado nunca é reescrito), `hooks-scanner.js` `EVENT_KEYS`, `chain.js` (`updatedInput` só viaja com rewrite — `permissionDecision:'allow'` incondicional pularia o prompt de todo Bash), `worktree.js` (`runWorktreeProvision`, `--if-clean`, `envKeyForWorktree`), `worktree-audit.js` (`morph-task/*`, `out-of-container`), `worktree-context.js`, MORPH.md §2.2, `morph-apply` §3b-w, `test/framework/worktree-governance-prose.test.js`, `resident-size.test.js` (MORPH.md está NO teto). **Âncora de sessão:** `anchorSessionCommand` (`TEARDOWN_RE`) exporta `MORPH_SESSION_PROJECT_DIR`, que `sessionAnchoredInWorktree` (`worktree.js`) lê e que o `finish` (`--merge`/`--pr` adiam o desmonte) e o `worktree remove` (recusa `session-anchored-in-worktree`) consomem — mudar a chave ou o formato do comando num lado só devolve o bug de 48 hooks `MODULE_NOT_FOUND` depois do `finish` |
141
141
  | `src/lib/worktree/env-allocator.js` (`portLayout`) | os DOIS escritores do bloco — `buildEnvFileContent` (`.morph/worktree.env`) e `compose-env.js` (`buildComposeEnv`, usado por `e2e up`, pelo nó `e2e` do `verify`, por `stack-teardown.js` e por `morph-spec env`) — e os leitores `worktree-context.js`, `handoff-generator.js`, `worktree-env-inject.js`, o template `docker-compose.template.yml` (variáveis que ele publica) e o standard `local-compose-isolation.md` (tabela de offsets). Canário: "worktree.env e compose env concordam byte a byte" em `test/lib/e2e/compose-env.test.js`. Offset novo aqui sem entrar no template é porta que ninguém publica |
142
142
  | `src/lib/tasks/test-nodes.js` | os DOIS leitores de `validateTestNodes` — `verify-runner.js` (fail-closed: nó inválido avermelha o gate) e `doctor.js` (`doctor --full`, camada 3 do contrato do spec §2.2: WARN, e **pula sem imprimir nada** sem `config.json` ou sem `project.tests`, senão quebra `test/commands/doctor.test.js:46`). Uma segunda noção de "nó válido" faz o mesmo config passar num lugar e falhar no outro. `morphConfigPath` deriva de `CONFIG_FILE_NAME` (`core/state/state-manager.js`), a mesma constante de `readMorphConfig` — o JSDoc promete "one spelling" e agora é literal |
143
143
  | `src/lib/worktree/docker-audit.js` | `worktree-audit.js` (`auditWorktrees` concatena os achados e é quem PASSA as features conhecidas — um segundo scan aqui discordaria sobre o que é órfão), `doctor.js` (a classificação `info`/`warn` = não-falha em `doctorWorktreesCommand`), `env-allocator.js` (`allocatedFeatures` é read-only de propósito: `allocateEnvBlock` ESCREVE, e uma auditoria que aloca inventa o estado que reporta), `test/lib/worktree/docker-audit.test.js`. Nunca no `doctor --full` (D10): `test/commands/doctor.test.js:46` exige zero `✗` neste repo |
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  > Spec-driven development framework for multi-stack projects. Turns feature requests into implementation-ready code through structured, AI-orchestrated phases.
4
4
 
5
5
  **Package:** `@polymorphism-tech/morph-spec`
6
- **Version:** 8.35.0
6
+ **Version:** 8.35.1
7
7
  **Requires:** Node.js 22+, Claude Code
8
8
 
9
9
  ---
@@ -379,4 +379,4 @@ Code generated by morph-spec (contracts, templates, implementation output) belon
379
379
 
380
380
  ---
381
381
 
382
- *morph-spec v8.35.0 by [Polymorphism Tech](https://polymorphism.tech)*
382
+ *morph-spec v8.35.1 by [Polymorphism Tech](https://polymorphism.tech)*
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "morph-spec",
3
- "version": "8.35.0",
3
+ "version": "8.35.1",
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",
@@ -187,4 +187,4 @@ Never edited directly — go through CLI commands or `morph-spec update` instead
187
187
 
188
188
  ---
189
189
 
190
- *morph-spec v8.35.0 by Polymorphism Tech*
190
+ *morph-spec v8.35.1 by Polymorphism Tech*
@@ -249,4 +249,4 @@ morph-spec doctor
249
249
 
250
250
  ---
251
251
 
252
- *morph-spec v8.35.0 by Polymorphism Tech*
252
+ *morph-spec v8.35.1 by Polymorphism Tech*
@@ -22,10 +22,10 @@
22
22
 
23
23
  ## Worktree / Fleet
24
24
 
25
- - **`morph-spec finish <feature> --pr|--merge`** — o **encerramento único** da feature (worktree ou comum): **arquiva** commitando `features/{f} → archive/{f}` dentro de `morph/{f}` — promovendo junto, no mesmo commit, os use cases da feature para o acervo permanente `.morph/specs/usecases/` com `status: implemented` (fail-open: sem `2-plan/usecases/`, no-op silencioso) — depois integra: `--merge` na hora; `--pr` só abre o PR, sem integrar. **O payload distingue os dois** — `--merge` sai com `integrated: true`; `--pr` sai com `integrated: false`, `awaiting: "merge"`, `pr: {number, url}` e um `nextStep` apontando para o merge. **`finished: true` sai nos dois casos e marca só o comando ter terminado, nunca a integração** — `--pr` não encerra a feature, quem encerra é o merge do PR; até lá ela aparece em `morph-spec list`/`fleet` como `aguardando-merge`, sem cópia local (ver `list` acima e `doctor` abaixo). Um `UC-{nn}` cuja promoção colide com outra feature já publicada sob o mesmo número (`ucCollisions` no output; ver `usecase reserve` abaixo) **não é promovido**, mas isso nunca recusa o `finish` — só aquele use case fica de fora, com a mensagem apontando onde o arquivo ficou e como recuperar. `--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}`. **O `--merge` diz o que falta publicar:** depois do merge local a saída traz `publish: {remote, branch, ahead, pushed}` e um `nextStep` com a CONTAGEM de commits que a default local tem à frente de `origin/{default}` mais o comando concreto (`git push origin {default}`) — em **todas** as saídas do `--merge`, inclusive sob `--keep`. Sem contraparte remota para comparar (`refs/remotes/origin/{default}` ausente) `ahead` fica `null` e a frase estática de integração local permanece — nenhuma contagem é inventada. Flags: `--push` publica a default logo depois do merge (sai com `publish.pushed: true` e `publish.published: N`; sem remoto `origin` o merge NÃO é desfeito — a saída reporta `pushError: "no-origin-remote"` e mantém o aviso local) — **`--push` só existe com `--merge`: combinado com `--pr` é RECUSA, exit 1, `push-with-pr`, antes de qualquer mutação** (o `--pr` já publica `morph/{f}` e não tem merge local para publicar depois; a recusa nomeia o comando de cada intenção); `--keep` mantém o worktree **e** o registro dele na raiz (válido enquanto o diretório existir) — o output reporta `worktree: {path, branch}` e um `hint` lembrando `worktree remove <f>` para fechar depois; `--force` dispensa o Gate 3; `--discard-ignored` consente descartar gitignored no teardown. Substitui o antigo `worktree finish` + `archive` separados. **Retomável:** conflito de merge, checkout ou push falham *depois* do archive (é o commit do archive que viaja com a branch) — re-executar o `finish` retoma na integração e sai com `resumed: true`, em vez de recusar por feature ausente. Se a integração já tiver acontecido, recusa com `already-finished` — e o que conta como "já integrada" depende do modo: no `--merge`, branch contida na default; no `--pr`, PR aberto para `morph/{f}` (a branch de um `--pr` concluído fica não-mergeada de propósito, então só o teste de ancestralidade deixaria passar um segundo `gh pr create`). Sem conseguir provar nem uma coisa nem outra, recusa com `resume-undecidable` em vez de retomar às cegas. **`--dry-run` é o PRÉ-VOO:** não muta nada (nenhum commit, archive, remoção de worktree, chamada a `gh` que crie algo, nem `git fetch`) e emite `{preflight: true, finished: false}` com o que ESTE finish faria — `ignored.shieldable` (gitignored exclusivo, copiado para o backup) × `ignored.replicable` × `ignored.discardable`, os `reparsePoints` com o aviso de que apagá-los fora do morph-spec apaga o conteúdo REAL na raiz, `cwdInsideWorktree` e `baseAdvanced: {ref, commits}` — a pergunta INVERSA da do `--merge`: quantos commits `origin/{default}` tem que a base da feature não tem. `commits: null` é DESCONHECIDO, nunca 0. Rodado de DENTRO do worktree ele **avisa e completa o relatório** (sai 0); o caminho real continua recusando antes da primeira mutação.
25
+ - **`morph-spec finish <feature> --pr|--merge`** — o **encerramento único** da feature (worktree ou comum): **arquiva** commitando `features/{f} → archive/{f}` dentro de `morph/{f}` — promovendo junto, no mesmo commit, os use cases da feature para o acervo permanente `.morph/specs/usecases/` com `status: implemented` (fail-open: sem `2-plan/usecases/`, no-op silencioso) — depois integra: `--merge` na hora; `--pr` só abre o PR, sem integrar. **O payload distingue os dois** — `--merge` sai com `integrated: true`; `--pr` sai com `integrated: false`, `awaiting: "merge"`, `pr: {number, url}` e um `nextStep` apontando para o merge. **`finished: true` sai nos dois casos e marca só o comando ter terminado, nunca a integração** — `--pr` não encerra a feature, quem encerra é o merge do PR; até lá ela aparece em `morph-spec list`/`fleet` como `aguardando-merge`, sem cópia local (ver `list` acima e `doctor` abaixo). Um `UC-{nn}` cuja promoção colide com outra feature já publicada sob o mesmo número (`ucCollisions` no output; ver `usecase reserve` abaixo) **não é promovido**, mas isso nunca recusa o `finish` — só aquele use case fica de fora, com a mensagem apontando onde o arquivo ficou e como recuperar. `--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}`. **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. Desmontar ali desligava a junction `.morph/framework` pela qual TODO hook da sessão resolve (`node "$CLAUDE_PROJECT_DIR/.morph/framework/…"`) e o diretório seguia travado pela própria sessão — o `git worktree remove` apagava o conteúdo e desregistrava a árvore, falhava só no `rmdir`, e cada tool call seguinte morria com `MODULE_NOT_FOUND`. O `--dry-run` já avisa (`teardownDeferred` no pré-voo). **O `--merge` diz o que falta publicar:** depois do merge local a saída traz `publish: {remote, branch, ahead, pushed}` e um `nextStep` com a CONTAGEM de commits que a default local tem à frente de `origin/{default}` mais o comando concreto (`git push origin {default}`) — em **todas** as saídas do `--merge`, inclusive sob `--keep`. Sem contraparte remota para comparar (`refs/remotes/origin/{default}` ausente) `ahead` fica `null` e a frase estática de integração local permanece — nenhuma contagem é inventada. Flags: `--push` publica a default logo depois do merge (sai com `publish.pushed: true` e `publish.published: N`; sem remoto `origin` o merge NÃO é desfeito — a saída reporta `pushError: "no-origin-remote"` e mantém o aviso local) — **`--push` só existe com `--merge`: combinado com `--pr` é RECUSA, exit 1, `push-with-pr`, antes de qualquer mutação** (o `--pr` já publica `morph/{f}` e não tem merge local para publicar depois; a recusa nomeia o comando de cada intenção); `--keep` mantém o worktree **e** o registro dele na raiz (válido enquanto o diretório existir) — o output reporta `worktree: {path, branch}` e um `hint` lembrando `worktree remove <f>` para fechar depois; `--force` dispensa o Gate 3; `--discard-ignored` consente descartar gitignored no teardown. Substitui o antigo `worktree finish` + `archive` separados. **Retomável:** conflito de merge, checkout ou push falham *depois* do archive (é o commit do archive que viaja com a branch) — re-executar o `finish` retoma na integração e sai com `resumed: true`, em vez de recusar por feature ausente. Se a integração já tiver acontecido, recusa com `already-finished` — e o que conta como "já integrada" depende do modo: no `--merge`, branch contida na default; no `--pr`, PR aberto para `morph/{f}` (a branch de um `--pr` concluído fica não-mergeada de propósito, então só o teste de ancestralidade deixaria passar um segundo `gh pr create`). Sem conseguir provar nem uma coisa nem outra, recusa com `resume-undecidable` em vez de retomar às cegas. **`--dry-run` é o PRÉ-VOO:** não muta nada (nenhum commit, archive, remoção de worktree, chamada a `gh` que crie algo, nem `git fetch`) e emite `{preflight: true, finished: false}` com o que ESTE finish faria — `ignored.shieldable` (gitignored exclusivo, copiado para o backup) × `ignored.replicable` × `ignored.discardable`, os `reparsePoints` com o aviso de que apagá-los fora do morph-spec apaga o conteúdo REAL na raiz, `cwdInsideWorktree` e `baseAdvanced: {ref, commits}` — a pergunta INVERSA da do `--merge`: quantos commits `origin/{default}` tem que a base da feature não tem. `commits: null` é DESCONHECIDO, nunca 0. Rodado de DENTRO do worktree ele **avisa e completa o relatório** (sai 0); o caminho real continua recusando antes da primeira mutação.
26
26
  - **`morph-spec usecase reserve [--count <n>] [--feature <name>] [--json]`** — reserva atomicamente os próximos N números `UC-{nn}`, compartilhado por **todo worktree** deste repo (`<git-common-dir>/morph-spec/uc-registry.json`, mesmo mecanismo do `claim`/env-registry do fleet). Substitui o scan manual "leia o acervo + as features da árvore e some 1": um scan só enxerga a ÁRVORE CORRENTE, então dois worktrees planejando ao mesmo tempo mintavam o MESMO número — colisão que a promoção do `finish` resolvia sobrescrevendo por ID, apagando o use case do primeiro a chegar (já custou perda de dados em produção). O contador nunca regride (semeado do maior número já visto no acervo + nas features da árvore + no próprio registro). `--feature` é mais que auditoria: é o sinal que o guarda de colisão do `finish`/`archive` usa para distinguir uma correção legítima (`bug`, nunca reserva — reaproveita um número publicado) de uma colisão real (a feature reservou o número e o acervo já tem outra dona nele); sem `--feature`, infere a única feature ativa se for inequívoco. Fora de um repositório git: cai para o scan local (avisa no output, `shared: false`) — ainda evita autocolisão dentro da mesma árvore, mas sem a garantia entre worktrees.
27
27
  - **`morph-spec archive <feature>`** — arquiva-**sem-integrar** (atômico, git-aware, commita o move escopado por pathspec): reservado a features legadas (sem branch `morph/{f}`) e faxina. O fechamento normal (arquiva **e** integra num passo) é `morph-spec finish`. É o que `/morph-archive` chama.
28
- - **`morph-spec worktree setup|provision|link|remove|list`** — gerencia o ciclo do worktree (`setup <f>` cria `worktrees/{f}/` na branch `morph/{f}`, aloca o bloco de portas e escreve `.morph/worktree.env`; `provision <n> [--base <ref>]` cria `worktrees/{n}/` na branch `morph-task/{n}` — um worktree de **isolamento de task**, sem feature, com as mesmas junctions e um bloco próprio sob a chave `task/{n}`; `link --all` religa a infra compartilhada **e regenera o `worktree.env`** com o layout atual; `list` mostra fase/gate de cada worktree ativo). **Fechar a feature** continua sendo `morph-spec finish` (acima).
28
+ - **`morph-spec worktree setup|provision|link|remove|list`** — gerencia o ciclo do worktree (`setup <f>` cria `worktrees/{f}/` na branch `morph/{f}`, aloca o bloco de portas e escreve `.morph/worktree.env`; `provision <n> [--base <ref>]` cria `worktrees/{n}/` na branch `morph-task/{n}` — um worktree de **isolamento de task**, sem feature, com as mesmas junctions e um bloco próprio sob a chave `task/{n}`; `link --all` religa a infra compartilhada **e regenera o `worktree.env`** com o layout atual; `list` mostra fase/gate de cada worktree ativo). `remove` recusa, antes de tocar em qualquer junction, quando o cwd do processo está no alvo (`cwd-inside-worktree`) **ou quando a sessão do Claude Code que o rodou está ancorada nele** (`session-anchored-in-worktree` — mesmo sinal do `finish`, acima); a recusa é o adiamento: feche a sessão e rode de uma aberta na raiz. Falha de remoção reporta a CAUSA real: um diretório travado vence o eco `is not a working tree` das tentativas seguintes, e `alreadyDeregistered` avisa quando o git já desregistrou a árvore e só o diretório vazio sobrou. **Fechar a feature** continua sendo `morph-spec finish` (acima).
29
29
  **Worktrees nativos do Claude Code.** `EnterWorktree`, `claude -w <n>` e `Agent` com `isolation: "worktree"` passam pelo hook `WorktreeCreate` instalado pelo morph: nome conhecido como feature → `worktree setup`; senão → `worktree provision`. O resultado cai sempre em `worktrees/`, nunca em `.claude/worktrees/`. O hook `WorktreeRemove` chama `worktree remove --target <path> --if-clean` (só remove árvore limpa; a branch sobrevive) — mas ele **não disparou** em nenhum run headless medido, então a limpeza primária continua sendo o passo 4 de `morph-apply` §3b-w e o `doctor --worktrees`. `git worktree add` cru é bloqueado pelo hook `block-raw-worktree-add` (sem junctions, sem portas, sem identidade → invisível ao `fleet`).
30
30
  **Layout do bloco (10 portas):** `PORT` (+0), `API_PORT` (+1, `ASPNETCORE_URLS` em dotnet), `POSTGRES_PORT` (+2), `REDIS_PORT` (+3), +4..+9 livres; mais `COMPOSE_PROJECT_NAME=morph-{f}`, `MORPH_PORT_BASE/RANGE`. O mesmo layout alimenta o `worktree.env`, o `docker compose` do `verify`/`e2e` e o hook `worktree-env-inject` (que prefixa `npm run dev`/`dotnet run`/`docker compose`/`playwright test` com `export …` quando o cwd tem manifesto e o comando não escolheu porta própria).
31
31
  - **`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.
@@ -42,7 +42,7 @@
42
42
  - **`morph-spec e2e up|status|down <feature>`** — ciclo de vida do stack de verificação, compartilhando toda a lógica com o nó `e2e` do verify. `up [--fresh]` reusa um stack quente **só** quando ele provadamente serve o código atual: o carimbo compara `HEAD` + `git status --porcelain` + `git diff HEAD`, então qualquer edição, commitada ou não, invalida o reuso (a exceção é o serviço com bind mount do código, que serve o disco por construção). Na dúvida, rebuild — o layer cache do Docker faz disso barato. O motivo impresso diz **o que** decidiu o reuso, e as duas procedências nunca se confundem: `source unchanged since build` é o carimbo, `source is bind-mounted` é o disco. E a segunda só sai quando **todo** serviço com seção `build:` monta a raiz do projeto — um sidecar Playwright montando `./e2e` ao lado de uma app compilada em imagem não fala pela stack inteira; nesse caso quem decide é o carimbo. `--fresh` derruba com volumes (`down -v`), apaga o carimbo e re-roda o seed. `status [--json]` responde a pergunta que decide se um verde vale alguma coisa (no ar? saudável? construído do código atual?) e sai 0 só com os três; o `baseUrl` do payload é o endereço real da app — é dele que a `morph-review` tira a URL que vai no prompt do avaliador, em vez de deixá-lo adivinhar `localhost:3000`. `down [--volumes]` é best-effort e nunca bloqueia. Isolamento entre worktrees: `COMPOSE_PROJECT_NAME=morph-{feature}` separa os containers e o bloco de portas do fleet (`env-allocator`) separa as portas — um compose com porta fixa não pode ser isolado, e isso é avisado. O bloco vale nos **dois** caminhos (este comando e o nó do `verify`): `PORT`, `MORPH_PORT_BASE`, `ASPNETCORE_URLS` em dotnet, mais `PLAYWRIGHT_BASE_URL` apontando para a porta alocada. Por isso o `playwright.config.ts` precisa ler a base URL do ambiente em vez de cravá-la — com a URL cravada, a suíte bate numa porta vazia e o `ECONNREFUSED` se disfarça de app quebrada.
43
43
  - **`morph-spec doctor [--full] [--mcp] [--reset]`** — checagem de saúde padrão (sem `--worktrees`): instalação (versões, `.claude/`, hooks, agents, grafo de codebase, templates, `state.json`) mais duas auditorias.
44
44
  **Ciclo de vida das features:** ordem de gates, feature sem identidade, pasta órfã, entrada de índice morta, `branch-drift` (a cópia da raiz atrasada em relação ao `morph/{f}` commitado) e `abandoned-scaffold`. Este último é o esqueleto que o `create --worktree` deixa para trás: `feature.json` sozinho, `in_progress`, **0 artefatos, 0 gates aprovados** e `morph/{f}` **sem commit próprio** (a branch ainda apontando para o commit de merge de outra PR). As quatro condições valem juntas e só depois de **24h** — recém-nascida tem exatamente a mesma forma, e alarme em todo `create` é alarme que ninguém lê. Fora de repo git, ou com a default branch indeterminável, o check cala (não dá para provar). Reparo: `morph-spec delete <f> --archive`, ou retome a feature.
45
- **Pull requests:** `pr-awaiting-merge` (PR aberto há mais de 7 dias sem cópia local nesta árvore) e `pr-merged-not-landed` (PR já `MERGED` mas `.morph/archive/{f}` ainda não chegou aqui — sinal de `git pull` faltando). Offline-safe: sem `gh`, sem remoto no GitHub, ou qualquer falha, a checagem sai **não verificado** (nunca `ok`) — `[]` (perguntei, nada encontrado) e `null` (não pude perguntar) nunca se confundem, e o "não verificado" nunca deixa o `doctor` mais barulhento nem muda o exit code.
45
+ **Pull requests:** `pr-awaiting-merge` (PR aberto há mais de 7 dias sem cópia local nesta árvore) e `pr-merged-not-landed` (PR já `MERGED` mas `.morph/archive/{f}` ainda não chegou aqui — sinal de `git pull` faltando; branch `morph/*` cujo diff nunca tocou `.morph/features/{f}/` nem `.morph/archive/{f}/` — um PR só de docs — não é acusada, e diff ilegível ou truncado no teto do `gh` mantém o aviso). Offline-safe: sem `gh`, sem remoto no GitHub, ou qualquer falha, a checagem sai **não verificado** (nunca `ok`) — `[]` (perguntei, nada encontrado) e `null` (não pude perguntar) nunca se confundem, e o "não verificado" nunca deixa o `doctor` mais barulhento nem muda o exit code.
46
46
  Ver `--worktrees` abaixo para a reconciliação de worktrees (modo separado), e `--ai` para a auditoria de projeto .NET com agentes.
47
47
  - **`morph-spec doctor --ai`** — auditoria de um projeto **.NET com agentes**, em superfície própria: **não entra no `--full` nem no `doctor` default**. O renderer do `--full` manda tudo que não é `ok`/`warn` para `✗` (não tem o ramo `skip → ○`), então uma checagem de IA lá seria vermelha mesmo dizendo "não verificado"; e cinco linhas `○` permanentes no comando mais rodado do dia é ruído com custo e sem informação. Combinar `--ai` com `--full`/`--mcp`/`--worktrees` é **erro de uso**: exit 1, sem rodar nada — cada superfície tem o seu relatório e o seu código de saída.
48
48
 
@@ -16,6 +16,17 @@
16
16
  * command does not already set a layout key itself (`PORT=…`, `--port`,
17
17
  * `compose -p`), only for the Bash tool. Everything else passes untouched.
18
18
  *
19
+ * Second job, same mechanism — the SESSION ANCHOR. `morph-spec finish` and
20
+ * `morph-spec worktree remove` tear a worktree down, and the session that runs
21
+ * them may be ANCHORED in that very worktree: its CLAUDE_PROJECT_DIR is the
22
+ * worktree, and every project hook is `node "$CLAUDE_PROJECT_DIR/.morph/framework/…"`.
23
+ * The CLI cannot see that anchor (the Bash tool does not export
24
+ * CLAUDE_PROJECT_DIR, and `cd <root> && morph-spec finish` erases the cwd), so
25
+ * it unlinked the junction the session's hooks resolve through — and every hook
26
+ * of every later tool call died with MODULE_NOT_FOUND (48 in one real session).
27
+ * Hooks DO see the anchor, so this one exports it as MORPH_SESSION_PROJECT_DIR
28
+ * on those two commands; the CLI then defers the teardown.
29
+ *
19
30
  * Fail-open: no state, no manifest, unreadable manifest → null (pass).
20
31
  */
21
32
 
@@ -56,6 +67,26 @@ export function rewriteCommand(command, env) {
56
67
  return { command: prefix + command, injected: pairs.map(([k, v]) => `${k}=${v}`) };
57
68
  }
58
69
 
70
+ /** `morph-spec finish` / `morph-spec worktree remove` — incl. `node …/morph-spec.js` and `npx morph-spec`. */
71
+ const TEARDOWN_RE = /\bmorph-spec(?:\.js)?\b[^\n;&|]*?\s(?:finish|worktree\s+remove)\b/;
72
+
73
+ const ANCHOR_KEY = 'MORPH_SESSION_PROJECT_DIR';
74
+
75
+ /**
76
+ * Pure decision: export the session anchor in front of a teardown command, or
77
+ * null when nothing should change (other command, anchor already declared,
78
+ * no anchor to give).
79
+ * @param {string} command
80
+ * @param {string} anchorDir - the session's project dir (CLAUDE_PROJECT_DIR)
81
+ * @returns {{ command: string, anchor: string }|null}
82
+ */
83
+ export function anchorSessionCommand(command, anchorDir) {
84
+ if (!command || !anchorDir) return null;
85
+ if (!TEARDOWN_RE.test(command)) return null;
86
+ if (new RegExp(`(?:^|[\\s;&|])${ANCHOR_KEY}=`).test(command)) return null;
87
+ return { command: `export ${ANCHOR_KEY}=${shq(anchorDir)}; ${command}`, anchor: anchorDir };
88
+ }
89
+
59
90
  /**
60
91
  * @param {object} payload - parsed PreToolUse stdin payload (Bash matcher)
61
92
  * @returns {Promise<object|null>} an updatedInput result, or null (pass).
@@ -67,6 +98,18 @@ export async function run(payload) {
67
98
  const command = payload?.tool_input?.command || '';
68
99
  if (!command) return null;
69
100
 
101
+ // CLAUDE_PROJECT_DIR first: it is what the project hooks resolve
102
+ // `.morph/framework` against — the thing a teardown would pull out from
103
+ // under this session. The payload cwd is the fallback.
104
+ const anchored = anchorSessionCommand(command, process.env.CLAUDE_PROJECT_DIR || payload?.cwd || '');
105
+ if (anchored) {
106
+ return updatedInputResult(
107
+ { ...payload.tool_input, command: anchored.command },
108
+ 'PreToolUse',
109
+ `⚓ âncora da sessão exportada para o CLI: ${ANCHOR_KEY}=${anchored.anchor} — o finish/worktree remove adia o desmonte do worktree em que esta sessão está ancorada`,
110
+ );
111
+ }
112
+
70
113
  const dir = payload?.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
71
114
  const manifest = join(dir, '.morph', 'worktree.env');
72
115
  if (!existsSync(manifest)) return null;
@@ -5,6 +5,7 @@
5
5
  * normalizing between Write (content, file_path) and Edit (new_string, old_string, file_path).
6
6
  */
7
7
 
8
+ import { createHash } from 'crypto';
8
9
  import { existsSync } from 'fs';
9
10
  import { dirname, join, isAbsolute } from 'path';
10
11
 
@@ -165,7 +166,7 @@ const BASH_SIGNATURE_MAX = 120;
165
166
 
166
167
  /**
167
168
  * Build a normalized signature for loop detection.
168
- * - Edit/Write: "Edit:normalized/file/path"
169
+ * - Edit/Write: "Edit:normalized/file/path#<sha1[0:8] do conteúdo>" ("Edit:path" sem conteúdo)
169
170
  * - Bash: "Bash:<normalized command, truncated>"
170
171
  * - Every other tool WITH input: "Tool:<truncated JSON of tool_input>"
171
172
  * - No tool_input at all: just the tool name
@@ -201,7 +202,19 @@ export function getCommandSignature(payload) {
201
202
 
202
203
  if (tool === 'Edit' || tool === 'Write') {
203
204
  const path = (input.file_path || input.path || '').replace(/\\/g, '/');
204
- return path ? `${tool}:${path}` : tool;
205
+ if (!path) return tool;
206
+ // O caminho sozinho confundia TRABALHO com LOOP: editar regiões diferentes
207
+ // de dois arquivos (a forma normal de uma correção que cruza teste e
208
+ // fonte) dava só duas assinaturas e disparava o aviso. O conteúdo da
209
+ // edição entra na assinatura; repetir a MESMA edição continua casando, e o
210
+ // ping-pong A→B / B→A continua dando duas assinaturas — loop de verdade.
211
+ const body = tool === 'Write'
212
+ ? input.content
213
+ : (input.old_string !== undefined || input.new_string !== undefined)
214
+ ? `${input.old_string ?? ''}\0${input.new_string ?? ''}\0${input.replace_all ? 1 : 0}`
215
+ : undefined;
216
+ if (body === undefined || body === null) return `${tool}:${path}`;
217
+ return `${tool}:${path}#${createHash('sha1').update(String(body)).digest('hex').slice(0, 8)}`;
205
218
  }
206
219
 
207
220
  if (tool === 'Bash') {
@@ -4,7 +4,7 @@ description: Guia a criação de uma landing page nova ponta-a-ponta a partir do
4
4
  version: core
5
5
  when-to-invoke: ao iniciar um novo projeto de LP com o template Umbraco genérico
6
6
  user-invokable: true
7
- cliVersion: "8.35.0"
7
+ cliVersion: "8.35.1"
8
8
  stacks:
9
9
  - umbraco
10
10
  ---
@@ -4,7 +4,7 @@ description: Migra uma landing page existente (React/Vite, Next.js estático ou
4
4
  version: core
5
5
  when-to-invoke: ao receber instrução "use /migrate-lp-to-umbraco {projeto}" do usuário, ou quando o usuário descrever necessidade de migrar uma LP para Umbraco
6
6
  user-invokable: true
7
- cliVersion: "8.35.0"
7
+ cliVersion: "8.35.1"
8
8
  stacks:
9
9
  - umbraco
10
10
  ---
@@ -4,7 +4,7 @@ description: Orquestra a execução autônoma de tasks de uma feature morph-spec
4
4
  version: core
5
5
  when-to-invoke: após Gate 2 aprovado, ao receber instrução "use /morph-apply" ou "implementar feature"
6
6
  user-invokable: true
7
- cliVersion: "8.35.0"
7
+ cliVersion: "8.35.1"
8
8
  ---
9
9
 
10
10
  # morph-apply
@@ -4,7 +4,7 @@ description: Entende o negócio, os usuários e a arquitetura antes de qualquer
4
4
  version: core
5
5
  when-to-invoke: ao receber um novo request de feature, antes de morph-plan
6
6
  user-invokable: true
7
- cliVersion: "8.35.0"
7
+ cliVersion: "8.35.1"
8
8
  ---
9
9
 
10
10
  # morph-brainstorming
@@ -4,7 +4,7 @@ description: Avalia a qualidade de uma tarefa ou fase com rubricas graduadas 0-1
4
4
  version: core
5
5
  when-to-invoke: ao completar uma tarefa ou ao finalizar uma fase de implementação
6
6
  user-invokable: false
7
- cliVersion: "8.35.0"
7
+ cliVersion: "8.35.1"
8
8
  ---
9
9
 
10
10
  # morph-eval
@@ -4,7 +4,7 @@ description: ADW cirúrgico de hotfix — resolve uma queda/bug crítico de prod
4
4
  version: core
5
5
  when-to-invoke: ao receber "/morph-hotfix {feature}", quando a morph-proposal classifica o pedido como workType hotfix (Passo 0), ou o usuário diz "produção caiu / hotfix / urgente"
6
6
  user-invokable: true
7
- cliVersion: "8.35.0"
7
+ cliVersion: "8.35.1"
8
8
  ---
9
9
 
10
10
  # morph-hotfix
@@ -4,7 +4,7 @@ description: Guia a execução de cada tarefa de implementação com standard e
4
4
  version: core
5
5
  when-to-invoke: ao iniciar cada task de implementação listada em tasks.json
6
6
  user-invokable: false
7
- cliVersion: "8.35.0"
7
+ cliVersion: "8.35.1"
8
8
  ---
9
9
 
10
10
  # morph-implement
@@ -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-invoke: quando a tarefa é PRODUZIR imagem ou vídeo por IA (catálogo, marketing, asset de LP) e alguém precisa decidir modelo, prompt e aprovação
6
6
  user-invokable: true
7
- cliVersion: "8.35.0"
7
+ cliVersion: "8.35.1"
8
8
  stacks:
9
9
  - dotnet
10
10
  - nextjs
@@ -4,7 +4,7 @@ description: Guia a produção dos artefatos de planejamento de uma feature (use
4
4
  version: core
5
5
  when-to-invoke: ao iniciar a fase plan de uma feature, após o Gate 1 (proposal aprovado)
6
6
  user-invokable: true
7
- cliVersion: "8.35.0"
7
+ cliVersion: "8.35.1"
8
8
  ---
9
9
 
10
10
  # morph-plan
@@ -4,7 +4,7 @@ description: Orquestra a fase de proposta+plano de uma feature morph-spec. Encad
4
4
  version: core
5
5
  when-to-invoke: ao receber instrução "use /morph-proposal" do usuário, ou ao iniciar uma nova feature
6
6
  user-invokable: true
7
- cliVersion: "8.35.0"
7
+ cliVersion: "8.35.1"
8
8
  ---
9
9
 
10
10
  # morph-proposal
@@ -4,7 +4,7 @@ description: Conduz a fase de revisão e produz o review-report.md para o Gate 3
4
4
  version: core
5
5
  when-to-invoke: ao iniciar a fase review, após todas as tasks de tasks.json estarem com status `done`
6
6
  user-invokable: true
7
- cliVersion: "8.35.0"
7
+ cliVersion: "8.35.1"
8
8
  ---
9
9
 
10
10
  # morph-review
@@ -4,7 +4,7 @@ description: Varre o codebase existente, detecta stack atual, compara com o stac
4
4
  version: core
5
5
  when-to-invoke: ao iniciar uma feature em projeto brownfield (codebase já existente)
6
6
  user-invokable: true
7
- cliVersion: "8.35.0"
7
+ cliVersion: "8.35.1"
8
8
  ---
9
9
 
10
10
  # morph-stack-scan
@@ -4,7 +4,7 @@ description: Localiza um standard específico via busca server-side (morph-spec
4
4
  version: core
5
5
  when-to-invoke: quando precisar consultar um standard específico durante a implementação
6
6
  user-invokable: true
7
- cliVersion: "8.35.0"
7
+ cliVersion: "8.35.1"
8
8
  ---
9
9
 
10
10
  # morph-standards
@@ -4,7 +4,7 @@ description: Conduz a fase opcional de UI/UX — design system, mockups, compone
4
4
  version: core
5
5
  when-to-invoke: ao tomar o ramo uiux (morph-spec advance --design), depois do Gate 1 e antes do morph-plan
6
6
  user-invokable: true
7
- cliVersion: "8.35.0"
7
+ cliVersion: "8.35.1"
8
8
  ---
9
9
 
10
10
  # morph-uiux
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polymorphism-tech/morph-spec",
3
- "version": "8.35.0",
3
+ "version": "8.35.1",
4
4
  "description": "MORPH-SPEC: NLH (Natural Language Harness) for spec-driven development with Claude Code",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -9,6 +9,7 @@ import { pathExists, readJson } from '../../utils/file-copier.js';
9
9
  import { checkClaudeMdImport } from '../../utils/claude-md-injector.js';
10
10
  import { buildPrIndex } from '../../lib/git/pr-index.js';
11
11
  import { pendingPrFeatures, MORPH_BRANCH_RE } from '../../lib/git/pending-prs.js';
12
+ import { prCarriesFeature } from '../../lib/git/pr-files.js';
12
13
  import {
13
14
  checkCLIOutdated,
14
15
  checkProjectOutdated,
@@ -688,7 +689,10 @@ const PR_STALE_DAYS = 7;
688
689
  * revisão que nenhuma superfície local reivindica.
689
690
  * pr-merged-not-landed — o PR mergeou mas `archive/{f}` não está aqui. O
690
691
  * archive viaja no diff do PR, então ou falta um
691
- * `git pull`, ou ele não estava no diff.
692
+ * `git pull`, ou ele não estava no diff. Branch
693
+ * `morph/*` cujo diff nunca tocou
694
+ * `.morph/{features,archive}/{f}/` não conta: o nome
695
+ * não prova feature (ver pr-files.js).
692
696
  *
693
697
  * Offline-safe: índice `null` devolve `null`, NUNCA lista vazia — lista vazia
694
698
  * afirmaria "perguntei e não achei nada", que é diferente de "não pude
@@ -718,6 +722,9 @@ function auditPullRequests(root) {
718
722
  if (!m || pr.state !== 'MERGED') continue;
719
723
  const feature = m[1];
720
724
  if (existsSync(join(root, '.morph', 'archive', feature))) continue;
725
+ // Só `false` (diff lido, sem a feature) cala o aviso; `null` — não pude
726
+ // ler o diff — o mantém, ou um `git pull` realmente faltando ficaria mudo.
727
+ if (prCarriesFeature(root, pr.number, feature) === false) continue;
721
728
  issues.push({
722
729
  feature,
723
730
  type: 'pr-merged-not-landed',
@@ -1303,19 +1303,46 @@ function classifyRemovalError(stderr) {
1303
1303
  * @returns {Promise<{ removed: boolean, failure: {kind: string, detail: string}|null }>}
1304
1304
  */
1305
1305
  async function attemptWorktreeRemoval(worktreePath, root) {
1306
- let lastStderr = '';
1306
+ const stderrs = [];
1307
1307
  for (let round = 0; round < 2; round++) {
1308
1308
  for (const args of [['worktree', 'remove', worktreePath], ['worktree', 'remove', '--force', worktreePath]]) {
1309
1309
  try {
1310
1310
  execFileSync('git', args, { cwd: root, stdio: 'pipe' });
1311
1311
  return { removed: true, failure: null };
1312
1312
  } catch (err) {
1313
- lastStderr = err?.stderr ? String(err.stderr) : (err?.message || '');
1313
+ stderrs.push(err?.stderr ? String(err.stderr) : (err?.message || ''));
1314
1314
  }
1315
1315
  }
1316
1316
  if (round === 0) await new Promise((r) => setTimeout(r, 200));
1317
1317
  }
1318
- return { removed: false, failure: classifyRemovalError(lastStderr) };
1318
+ return { removed: false, failure: pickRemovalFailure(stderrs) };
1319
+ }
1320
+
1321
+ /**
1322
+ * Which of the attempts' errors the user should read.
1323
+ *
1324
+ * The LAST one used to win, and it was the worst choice: when the first
1325
+ * `git worktree remove` deletes the content and de-registers the tree but the
1326
+ * directory itself is held (a session with its cwd there), every later attempt
1327
+ * answers only `'…' is not a working tree` — an echo that hid the real cause
1328
+ * (the lock) and made the hint send the user to a command that can no longer
1329
+ * reach the tree. A lock anywhere wins; otherwise the first REAL error wins.
1330
+ *
1331
+ * @param {string[]} stderrs - one entry per failed attempt, in order
1332
+ * @returns {{ kind: string, detail: string, alreadyDeregistered: boolean }}
1333
+ */
1334
+ export function pickRemovalFailure(stderrs) {
1335
+ const list = (stderrs || []).map((s) => String(s || '')).filter((s) => s.trim());
1336
+ const notAWorktree = (s) => /is not a working tree/i.test(s);
1337
+ const classified = list.map(classifyRemovalError);
1338
+ const firstReal = list.findIndex((s) => !notAWorktree(s));
1339
+ const chosen = classified.find((c) => c.kind === 'in-use')
1340
+ || (firstReal >= 0 ? classified[firstReal] : classified[classified.length - 1])
1341
+ || { kind: 'unknown', detail: '' };
1342
+ // Git dropped the registration BETWEEN attempts: a failure for another reason
1343
+ // followed by "is not a working tree". Only the directory is left.
1344
+ const alreadyDeregistered = firstReal >= 0 && list.slice(firstReal + 1).some(notAWorktree);
1345
+ return { ...chosen, alreadyDeregistered };
1319
1346
  }
1320
1347
 
1321
1348
  /**
@@ -1451,6 +1478,10 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
1451
1478
  ? `worktree NÃO removido: o backup falhou. Estes arquivos existem só neste worktree e a remoção os apagaria: ${sample(ignored.shieldable)}. `
1452
1479
  + (ignored.replicable.length ? `(${sample(ignored.replicable)} são idênticos aos da raiz — esses não são o problema.) ` : '')
1453
1480
  + `O backup iria para \`${backupDir}\`; libere espaço/permissão ali e rode \`${removeCmd}\`, ou copie os arquivos você mesmo e rode \`${removeCmd} --discard-ignored\` para pular o backup`
1481
+ : removalFailure?.alreadyDeregistered
1482
+ ? `worktree NÃO removido por inteiro: a 1ª tentativa do git já apagou o conteúdo e desregistrou o worktree, mas o diretório "${worktreePath}" ficou`
1483
+ + (removalFailure.kind === 'in-use' ? ' — EM USO por outro processo (instância do Claude Code, VS Code, dev server ou terminal com cwd nele)' : '')
1484
+ + `${removalFailure.detail ? ` (${removalFailure.detail})` : ''}. \`${removeCmd}\` não o alcança mais — o git não o conhece. Feche o que o usa, confira que está vazio e sem junction, e apague só o diretório (sem recursão)`
1454
1485
  : removalFailure?.kind === 'in-use'
1455
1486
  ? `worktree NÃO removido: o diretório está EM USO por outro processo (instância do Claude Code, VS Code, dev server ou terminal com cwd em "${worktreePath}"). A infra compartilhada ficou DESMONTADA de propósito — feche o que está usando o diretório e rode \`${removeCmd}\`. Se preferir continuar trabalhando nele, restaure a infra com \`morph-spec worktree link --all\``
1456
1487
  : `worktree NÃO removido: a remoção falhou${removalFailure?.detail ? ` (${removalFailure.detail})` : ''}. A infra compartilhada ficou DESMONTADA de propósito — rode \`${removeCmd}\` para tentar de novo, ou \`morph-spec worktree link --all\` para voltar a trabalhar nele`;
@@ -1497,6 +1528,30 @@ export function cwdIsInsideWorktree(cwd, worktreePath) {
1497
1528
  return cwdNorm === wtNorm || cwdNorm.startsWith(`${wtNorm}/`);
1498
1529
  }
1499
1530
 
1531
+ /**
1532
+ * Is the Claude Code SESSION that ran this command anchored in the worktree?
1533
+ *
1534
+ * Not the same question as `cwdIsInsideWorktree`. That one sees the PROCESS
1535
+ * cwd, and `cd <root> && morph-spec finish` answers it "no" — while the session
1536
+ * stays anchored in the worktree: its CLAUDE_PROJECT_DIR is the worktree, and
1537
+ * every project hook runs `node "$CLAUDE_PROJECT_DIR/.morph/framework/hooks/…"`.
1538
+ * Tearing that worktree down unlinks the junction those hooks resolve through,
1539
+ * and the directory stays held by the session — so the removal fails half-way
1540
+ * and every hook of every later tool call dies with MODULE_NOT_FOUND.
1541
+ *
1542
+ * The anchor arrives as MORPH_SESSION_PROJECT_DIR, exported by the
1543
+ * `worktree-env-inject` PreToolUse hook (the Bash tool does not export
1544
+ * CLAUDE_PROJECT_DIR itself); CLAUDE_PROJECT_DIR is honoured when present.
1545
+ *
1546
+ * @param {string} worktreePath
1547
+ * @param {NodeJS.ProcessEnv} [env]
1548
+ * @returns {boolean}
1549
+ */
1550
+ export function sessionAnchoredInWorktree(worktreePath, env = process.env) {
1551
+ const anchor = env.MORPH_SESSION_PROJECT_DIR || env.CLAUDE_PROJECT_DIR || '';
1552
+ return anchor ? cwdIsInsideWorktree(anchor, worktreePath) : false;
1553
+ }
1554
+
1500
1555
  // ── remove ───────────────────────────────────────────────────────────────
1501
1556
 
1502
1557
  /**
@@ -1580,6 +1635,16 @@ export async function worktreeRemoveCommand(featureArg, options = {}) {
1580
1635
  hint: `saia do worktree antes de removê-lo — o processo trava o próprio diretório de trabalho. Rode de novo a partir de "${root}"` }, 1);
1581
1636
  }
1582
1637
 
1638
+ // The SESSION's anchor, not the process cwd (see sessionAnchoredInWorktree):
1639
+ // refusing here, before the junctions are touched, leaves the session's hooks
1640
+ // intact. Unlike finish there is nothing else to do in this command, so the
1641
+ // refusal IS the deferral.
1642
+ if (sessionAnchoredInWorktree(worktreePath)) {
1643
+ const again = featureAddressable && feature ? `morph-spec worktree remove ${feature}` : `morph-spec worktree remove --target "${worktreePath}"`;
1644
+ emit({ removed: false, error: 'session-anchored-in-worktree', path: worktreePath, root,
1645
+ hint: `a sessão do Claude Code que rodou este comando está ancorada neste worktree — os hooks dela resolvem \`.morph/framework\` por ele, e desmontá-lo quebraria todo tool call seguinte (e o diretório seguiria travado pela sessão). Feche esta sessão e rode \`${again}\` de uma sessão aberta em "${root}"` }, 1);
1646
+ }
1647
+
1583
1648
  // `--if-clean` — the WorktreeRemove hook's contract: Claude Code fires it
1584
1649
  // when it believes the tree is disposable, but "disposable" is the user's
1585
1650
  // call, not the harness's. Any uncommitted or untracked file refuses the
@@ -11,6 +11,7 @@ import {
11
11
  buildBranchName,
12
12
  branchExists,
13
13
  cwdIsInsideWorktree,
14
+ sessionAnchoredInWorktree,
14
15
  classifyIgnoredLeftovers,
15
16
  } from '../project/worktree.js';
16
17
  import { detectDefaultBranch, hasOriginRemote } from '../../lib/git/default-branch.js';
@@ -587,6 +588,14 @@ export function finishPreflight({ feature, root, worktreePath, cwd, mode, branch
587
588
 
588
589
  const inside = cwdIsInsideWorktree(cwd, worktreePath);
589
590
  out.cwdInsideWorktree = inside;
591
+ if (!inside && sessionAnchoredInWorktree(worktreePath)) {
592
+ out.teardownDeferred = 'session-anchored';
593
+ warnings.push(
594
+ `a sessão do Claude Code está ancorada em ${out.worktreePath}: o finish vai integrar, mas ADIAR o desmonte `
595
+ + '(desmontar agora quebraria os hooks desta sessão). Depois, feche a sessão e rode '
596
+ + `\`morph-spec worktree remove ${feature}\` de uma sessão aberta na raiz`,
597
+ );
598
+ }
590
599
  if (inside) {
591
600
  warnings.push(
592
601
  `você está DENTRO de ${out.worktreePath}: o finish de verdade recusa aqui, antes de qualquer mutação `
@@ -705,9 +714,16 @@ async function integrateMerge({ emit, root, feature, expectedBranch, defaultBran
705
714
  // mount and the port block all belong to that tree, and once it is gone
706
715
  // there is nothing left to address the containers by.
707
716
  const e2e = reclaimStack(feature, { root, worktreePath, options });
708
- const teardown = await teardownWorktree(worktreePath, { root, feature, branch: expectedBranch, options, deleteBranch: true });
717
+ // The session that ran this finish may be ANCHORED in the worktree (its
718
+ // hooks resolve `.morph/framework` through it): tearing it down now would
719
+ // kill every hook of that session. The integration is done; only the
720
+ // teardown waits (see sessionAnchoredInWorktree).
721
+ const deferred = sessionAnchoredInWorktree(worktreePath);
722
+ const teardown = deferred
723
+ ? null
724
+ : await teardownWorktree(worktreePath, { root, feature, branch: expectedBranch, options, deleteBranch: true });
709
725
  dropFromRootIndex(root, feature);
710
- emit({ ...mergeOutput({ feature, branch: expectedBranch, defaultBranch, publish, worktreePath, teardown, shedBackup }), ...ucNote, e2e });
726
+ emit({ ...mergeOutput({ feature, branch: expectedBranch, defaultBranch, publish, worktreePath, teardown, shedBackup, deferred }), ...ucNote, e2e });
711
727
  process.exit(0);
712
728
  }
713
729
 
@@ -787,6 +803,7 @@ async function integratePr({ emit, root, feature, expectedBranch, defaultBranch,
787
803
  const e2e = isWorktreeFlow
788
804
  ? (options.keep ? null : reclaimStack(feature, { root, worktreePath, options }))
789
805
  : reclaimStack(feature, { root, worktreePath: null, options });
806
+ let teardownDeferred = false;
790
807
  if (isWorktreeFlow && !options.keep) {
791
808
  // Shed the transient root-side scaffold BEFORE teardown — exactly as --merge
792
809
  // does. The real `create --worktree` leaves the root's .morph/features/{f}
@@ -796,7 +813,13 @@ async function integratePr({ emit, root, feature, expectedBranch, defaultBranch,
796
813
  // the feature in BOTH features/ and archive/ at once — active and archived.
797
814
  // The branch/PR owns the authoritative copy; the root scaffold is transient.
798
815
  shedBackup = shedRootFeatureScope(root, feature).shedBackup;
799
- teardown = await teardownWorktree(worktreePath, { root, feature, branch: expectedBranch, options, deleteBranch: false });
816
+ // Same deferral as --merge: never tear down the worktree the running
817
+ // session is anchored in (see sessionAnchoredInWorktree).
818
+ if (sessionAnchoredInWorktree(worktreePath)) {
819
+ teardownDeferred = true;
820
+ } else {
821
+ teardown = await teardownWorktree(worktreePath, { root, feature, branch: expectedBranch, options, deleteBranch: false });
822
+ }
800
823
  // The shed removed the root's features/{f} scaffold from disk, but runArchive
801
824
  // dropped the feature only from the WORKTREE's index (its cwd). Drop it from
802
825
  // the ROOT index too — exactly as --merge does — or `doctor`'s lifecycle audit
@@ -835,7 +858,13 @@ async function integratePr({ emit, root, feature, expectedBranch, defaultBranch,
835
858
  worktree: { path: worktreePath, branch: expectedBranch },
836
859
  hint: `worktree preservado em ${worktreePath} — feche depois com \`morph-spec worktree remove ${feature}\`.`,
837
860
  }
838
- : {}),
861
+ : teardownDeferred
862
+ ? {
863
+ teardownDeferred: 'session-anchored',
864
+ worktree: { path: worktreePath, branch: expectedBranch },
865
+ hint: deferredTeardownHint(feature, worktreePath),
866
+ }
867
+ : {}),
839
868
  }),
840
869
  };
841
870
  emit(out);
@@ -955,7 +984,7 @@ function mergeNextStep(publish) {
955
984
  * publish: ReturnType<typeof publicationState>, worktreePath?: string|null,
956
985
  * teardown?: object|null, shedBackup?: string|null, kept?: boolean }} args
957
986
  */
958
- function mergeOutput({ feature, branch, defaultBranch, publish, worktreePath = null, teardown = null, shedBackup = null, kept = false }) {
987
+ function mergeOutput({ feature, branch, defaultBranch, publish, worktreePath = null, teardown = null, shedBackup = null, kept = false, deferred = false }) {
959
988
  return {
960
989
  finished: true,
961
990
  integrated: true,
@@ -964,6 +993,7 @@ function mergeOutput({ feature, branch, defaultBranch, publish, worktreePath = n
964
993
  merged: branch,
965
994
  into: defaultBranch,
966
995
  ...(kept ? { kept: true } : {}),
996
+ ...(deferred ? { teardownDeferred: 'session-anchored', worktree: { path: worktreePath, branch }, hint: deferredTeardownHint(feature, worktreePath, { deleteBranch: true }) } : {}),
967
997
  worktreeRemoved: teardown ? teardown.worktreeRemoved : false,
968
998
  nextStep: mergeNextStep(publish),
969
999
  publish,
@@ -981,6 +1011,19 @@ function mergeOutput({ feature, branch, defaultBranch, publish, worktreePath = n
981
1011
  };
982
1012
  }
983
1013
 
1014
+ /**
1015
+ * Hint for a teardown deferred because the session is anchored in the worktree
1016
+ * (both modes). Names the safe command, never `git worktree remove`. `--merge`
1017
+ * would have deleted the (now integrated) branch, so its hint carries
1018
+ * `--delete-branch`; `--pr` keeps the branch — the PR needs it.
1019
+ */
1020
+ function deferredTeardownHint(feature, worktreePath, { deleteBranch = false } = {}) {
1021
+ return `worktree mantido em ${worktreePath}: a sessão do Claude Code que rodou o finish está ancorada nele, `
1022
+ + 'e desmontá-lo agora quebraria todos os hooks dela (eles resolvem `.morph/framework` por ele) — '
1023
+ + `o diretório, aliás, seguiria travado pela sessão. Feche esta sessão e rode \`morph-spec worktree remove ${feature}${deleteBranch ? ' --delete-branch' : ''}\` `
1024
+ + 'de uma sessão aberta na raiz do repo.';
1025
+ }
1026
+
984
1027
  /** Shared teardown reporting for both modes (kept identical to the old finish). */
985
1028
  function teardownExtras(teardown, worktreePath, branch, deleteBranch) {
986
1029
  const out = {};
@@ -0,0 +1,56 @@
1
+ /**
2
+ * pr-files.js — o diff de um PR carregou uma feature morph-spec?
3
+ *
4
+ * `morph/{f}` é a convenção de nome das branches que o framework cria, não
5
+ * prova de que a branch carregou uma feature: nada impede um PR só de docs
6
+ * numa `morph/x`. O `doctor` lia o nome como prova e acusava
7
+ * `pr-merged-not-landed` para sempre sobre PRs que nunca tiveram archive para
8
+ * pousar (medido no MORPH_GHLBrain: #5, um único `docs/*.md`, e #23, a onda 2
9
+ * de outra feature — nenhum caminho `.morph/` no diff de nenhum dos dois).
10
+ *
11
+ * A pergunta é feita ao diff, e só para o candidato: o `doctor` chama isto
12
+ * depois de já saber que o PR mergeou e que `archive/{f}` falta aqui, então o
13
+ * custo (~650 ms por `gh pr view`) não recai sobre quem não tem pendência. O
14
+ * índice (`pr-index.js`) segue sem o campo de propósito: pedir `mergeCommit` no
15
+ * `pr list` custou ~400 ms a mais em CADA chamada, paga pelos quatro leitores.
16
+ *
17
+ * Três respostas, nunca duas: `true` (o diff tocou `.morph/{features,archive}/{f}/`),
18
+ * `false` (o diff foi lido inteiro e não tocou) e `null` (DESCONHECIDO — sem
19
+ * `gh`, falha, JSON ilegível, ou o diff bateu no teto de arquivos do `gh` sem
20
+ * achar). Só `false` autoriza calar um aviso.
21
+ */
22
+
23
+ import { execFileSync } from 'child_process';
24
+ import { resolveGh } from './gh.js';
25
+
26
+ /** Quantos arquivos o `gh pr view --json files` devolve, no máximo. */
27
+ export const GH_FILES_CAP = 100;
28
+
29
+ /**
30
+ * @param {string} root - de onde o `gh` roda (resolve o repositório pelo origin)
31
+ * @param {number} number - número do PR
32
+ * @param {string} feature - o `{f}` de `morph/{f}`
33
+ * @param {{ timeout?: number }} [opts]
34
+ * @returns {boolean|null} null = DESCONHECIDO, nunca "não carregou"
35
+ */
36
+ export function prCarriesFeature(root, number, feature, opts = {}) {
37
+ const timeout = opts.timeout ?? 8000;
38
+ try {
39
+ const { bin, prefix } = resolveGh();
40
+ const out = execFileSync(
41
+ bin,
42
+ [...prefix, 'pr', 'view', String(number), '--json', 'files'],
43
+ { cwd: root, stdio: 'pipe', timeout },
44
+ ).toString();
45
+ const files = JSON.parse(out)?.files;
46
+ if (!Array.isArray(files)) return null;
47
+ // A barra final é a fronteira do slug: `vitrine` não pode casar `vitrine-lead/`.
48
+ const owned = [`.morph/features/${feature}/`, `.morph/archive/${feature}/`];
49
+ if (files.some((f) => typeof f?.path === 'string' && owned.some((p) => f.path.startsWith(p)))) return true;
50
+ // Diff truncado sem o caminho não prova ausência — o archive pode estar além do teto.
51
+ if (files.length >= GH_FILES_CAP) return null;
52
+ return false;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }