@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 +2 -2
- package/README.md +2 -2
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/docs/CLI.md +3 -3
- package/framework/hooks/claude-code/core/worktree-env-inject.js +43 -0
- package/framework/hooks/shared/payload-utils.js +15 -2
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +1 -1
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +1 -1
- package/framework/skills/core/morph-media/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +1 -1
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +1 -1
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +1 -1
- package/package.json +1 -1
- package/src/commands/project/doctor.js +8 -1
- package/src/commands/project/worktree.js +68 -3
- package/src/commands/state/finish.js +48 -5
- package/src/lib/git/pr-files.js +56 -0
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.
|
|
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.
|
|
382
|
+
*morph-spec v8.35.1 by [Polymorphism Tech](https://polymorphism.tech)*
|
package/claude-plugin.json
CHANGED
package/docs/CHEATSHEET.md
CHANGED
package/docs/QUICKSTART.md
CHANGED
package/framework/docs/CLI.md
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
7
|
+
cliVersion: "8.35.1"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-uiux
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|