@polymorphism-tech/morph-spec 8.23.0 → 8.24.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/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.23.0
6
+ **Version:** 8.24.1
7
7
  **Requires:** Node.js 22+, Claude Code
8
8
 
9
9
  ---
@@ -368,4 +368,4 @@ Code generated by morph-spec (contracts, templates, implementation output) belon
368
368
 
369
369
  ---
370
370
 
371
- *morph-spec v8.23.0 by [Polymorphism Tech](https://polymorphism.tech)*
371
+ *morph-spec v8.24.1 by [Polymorphism Tech](https://polymorphism.tech)*
package/bin/morph-spec.js CHANGED
@@ -23,7 +23,7 @@ import { installPluginCommand } from '../src/commands/project/install-plugin-cmd
23
23
  import { updateCommand } from '../src/commands/project/update.js';
24
24
  import { doctorCommand } from '../src/commands/project/doctor.js';
25
25
  import { statusCommand } from '../src/commands/project/status.js';
26
- import { worktreeSetupCommand, worktreeLinkCommand, worktreeListCommand } from '../src/commands/project/worktree.js';
26
+ import { worktreeSetupCommand, worktreeLinkCommand, worktreeListCommand, worktreeRemoveCommand } from '../src/commands/project/worktree.js';
27
27
  import { fleetCommand } from '../src/commands/project/fleet.js';
28
28
  import { claimCommand } from '../src/commands/project/claim.js';
29
29
  import { standardsCommand } from '../src/commands/project/standards.js';
@@ -411,6 +411,18 @@ worktreeCommand
411
411
  .option('--all', 'Link all shared dirs, not just node_modules')
412
412
  .action((options) => worktreeLinkCommand(options));
413
413
 
414
+ // The SAFE removal — it drops the infra junctions before letting git near the
415
+ // directory. Raw `git worktree remove` follows them out and empties the primary
416
+ // root's .claude/ and .morph/framework/, so nothing in this repo (message, doc,
417
+ // or skill) may point at it; they all point here.
418
+ worktreeCommand
419
+ .command('remove [feature]')
420
+ .description('Safely tear down a worktree (drops infra junctions first, PROVE-or-DECLINE) — does NOT close the feature; that is `finish`')
421
+ .option('--target <path>', 'Remove the worktree at this path (for worktrees with no feature registration)')
422
+ .option('--discard-ignored', 'Allow removing even though it holds gitignored files that exist nowhere else (e.g. .env) — they are destroyed')
423
+ .option('--delete-branch', 'Also delete the branch (default: kept — unintegrated commits live there)')
424
+ .action((feature, options) => worktreeRemoveCommand(feature, options));
425
+
414
426
  worktreeCommand
415
427
  .command('list')
416
428
  .description('List active feature worktrees with their phase and gate status')
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "morph-spec",
3
- "version": "8.23.0",
3
+ "version": "8.24.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",
@@ -185,4 +185,4 @@ Never edited directly — go through CLI commands or `morph-spec update` instead
185
185
 
186
186
  ---
187
187
 
188
- *morph-spec v8.23.0 by Polymorphism Tech*
188
+ *morph-spec v8.24.1 by Polymorphism Tech*
@@ -249,4 +249,4 @@ morph-spec doctor
249
249
 
250
250
  ---
251
251
 
252
- *morph-spec v8.23.0 by Polymorphism Tech*
252
+ *morph-spec v8.24.1 by Polymorphism Tech*
@@ -60,7 +60,7 @@ Se não houver feature ativa, pergunte ao usuário qual feature trabalhar.
60
60
  | `retype <f> <tipo>` | Muda o workType mid-flight; bloqueado após o Gate 3 |
61
61
  | `finish <f> --pr\|--merge` | Encerramento único: arquiva, promove use cases, integra, desmonta worktree |
62
62
  | `archive <f>` | Arquiva sem integrar (legadas/faxina) |
63
- | `worktree setup\|link\|list` | Ciclo do worktree (fechamento é `finish`, não aqui) |
63
+ | `worktree setup\|link\|remove\|list` | Ciclo do worktree; `remove` é a única remoção segura |
64
64
  | `handoff <f>` | Regenera o documento de retomada da feature |
65
65
  | `fleet` | Visão agregada de toda feature ativa (raiz + worktrees) |
66
66
  | `claim [f] [--release] [--list]` | Posse advisory — confirme antes de `finish`/`archive`/`delete` se outra sessão tiver claim vivo |
@@ -118,7 +118,7 @@ Features independentes podem ser desenvolvidas **em paralelo, cada uma no seu gi
118
118
  - **Uma feature = um worktree = uma instância.** Nunca abra duas instâncias na mesma feature (setup recusa, exit 3).
119
119
  - `feature.json` viaja **commitado na branch** `morph/{f}`. `state.json` local do worktree é índice derivável — materializado por self-heal, nunca sincronizado à mão.
120
120
  - Infra compartilhada (`node_modules`, `.claude`, `.morph/framework`) linkada por junction; `memory/`/`logs/` são locais de cada worktree. Faltou infra (worktree aberto com `git worktree add` puro) → `morph-spec worktree link --all`.
121
- - **Nunca apague um worktree à mão** (`rm -rf`, `Remove-Item -Recurse -Force`, explorador/IDE). `morph-spec finish`/`worktree remove` desmonta a junction ANTES de remover (PROVE-or-DECLINE); uma remoção genérica não sabe disso e, em várias implementações (inclusive `Remove-Item -Recurse` no Windows), **atravessa a junction** e apaga o conteúdo real compartilhado (`.claude`/`node_modules`/`.morph/framework`), não só o link. Use sempre `morph-spec finish <f> --pr|--merge` (ou `git worktree remove`, sem fechar a feature).
121
+ - **Só o morph remove worktree.** Remoção genérica — `git worktree remove` (com OU sem `--force`), `rm -rf`, `Remove-Item -Recurse`, explorador/IDE — **atravessa a junction** e apaga o conteúdo real compartilhado da raiz (`.claude`/`node_modules`/`.morph/framework`), não só o link, saindo com sucesso. Foi assim que uma raiz teve `.claude/` e `.morph/framework/` esvaziados. Só `morph-spec` desmonta a junction ANTES de remover (PROVE-or-DECLINE): **fechar a feature** = `morph-spec finish <f> --pr|--merge`; **só desmontar o worktree** (feature segue aberta) = `morph-spec worktree remove <f>` — ou `--target <path>` para worktree sem feature. O hook `block-destructive-worktree-removal` barra a versão crua, mas a regra vale antes dele.
122
122
  - **Ambiente separado (portas):** o setup aloca um **bloco de portas determinístico** por feature em `.morph/worktree.env` (`PORT`, `MORPH_PORT_RANGE`; `ASPNETCORE_URLS` se dotnet). **Suba dev servers SEMPRE nas portas do bloco** — as portas padrão (3000/5000) pertencem à raiz. Arquivo local, git-invisível; SessionStart injeta as portas. Overrides em `config.json → fleet.basePort/blockSize`.
123
123
  - **Banco de dados:** compartilhado por padrão. Isolamento real (Neon MCP): branch de DB `morph/{f}` + connection string no `.morph/worktree.env`; sem Neon, prefixe schema/DB local pelo nome da feature. Migration destrutiva compartilhando banco → integre antes de abrir worktrees irmãos.
124
124
  - **Limitação conhecida:** `node_modules` compartilhado (junction) — mudança de dependência afeta os irmãos. Mudou dependência: conclua e integre (`finish`) antes de seguir em paralelo.
@@ -24,7 +24,8 @@
24
24
  - **`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. 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}`. Flags: `--keep` mantém o worktree, `--force` dispensa o Gate 3, `--discard-ignored` consente descartar gitignored no teardown. Substitui o antigo `worktree finish` + `archive` separados.
25
25
  - **`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.
26
26
  - **`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.
27
- - **`morph-spec worktree setup|link|list`** — gerencia o ciclo do worktree por feature (`setup` cria `worktrees/{f}/` na branch `morph/{f}`, aloca o bloco de portas e escreve `.morph/worktree.env`; `link` religa infra compartilhada; `list` mostra fase/gate de cada worktree ativo). O fechamento saiu daqui — use `morph-spec finish` (acima).
27
+ - **`morph-spec worktree setup|link|remove|list`** — gerencia o ciclo do worktree por feature (`setup` cria `worktrees/{f}/` na branch `morph/{f}`, aloca o bloco de portas e escreve `.morph/worktree.env`; `link` religa infra compartilhada; `list` mostra fase/gate de cada worktree ativo). **Fechar a feature** continua sendo `morph-spec finish` (acima).
28
+ - **`morph-spec worktree remove [feature] [--target <path>] [--discard-ignored] [--delete-branch]`** — a remoção **segura** de um worktree, sem fechar a feature. Desmonta as junctions de infra ANTES de deixar o git chegar no diretório e recusa se não conseguir **provar** que é seguro (PROVE-or-DECLINE) — `git worktree remove` cru, com ou sem `--force`, atravessa a junction e apaga o `.claude/`/`.morph/framework/`/`node_modules/` **reais da raiz**, saindo com sucesso. Use `--target` para worktree sem feature registrada (os que o `isolation: "worktree"` cria). A branch **sobrevive** por padrão: remover o worktree não é descartar o trabalho.
28
29
  - **`morph-spec handoff <feature>`** — regenera o documento de retomada (`.morph/features/{f}/handoff.md`): comprimido, derivado, aponta para os artefatos em vez de copiá-los. Gerado automaticamente em `setup`/`create --worktree`/`advance`/`approve`.
29
30
  - **`morph-spec fleet`** — visão da frota: toda feature ativa (raiz + worktrees) com fase, gates pendentes, tasks, **remoto** (`local`/`pushed`/`PR #N`), **claim** (que sessão está na feature), portas, handoff e atividade. Use entre sessões para decidir onde atuar.
30
31
  - **`morph-spec claim [feature] [--release] [--list] [--json]`** — posse **advisory** de feature. Cada sessão reclama sua feature ativa automaticamente (hook SessionStart) e renova o heartbeat a cada tool call; o registro vive em `<git-common-dir>/morph-spec/claims.json`, compartilhado por todos os worktrees. **`morph-spec claim <feature>` (sem flags) ADQUIRE** — se esta árvore já tinha claim vivo em outra feature, ele é liberado no mesmo passo (o CLI reporta "movido de X para Y"); é o jeito de trocar de feature no meio da tarefa sem a dança de dois passos `--release` + reclamar. `--list` força a leitura sem adquirir mesmo passando uma feature. **Claims avisam, nunca bloqueiam** — mas se o SessionStart avisar que outra sessão tem claim vivo, **confirme antes de `finish`/`archive`/`delete`**: duas sessões na mesma feature já custaram um recap.md inteiro e um sub-agent (o worktree sumiu debaixo dele). Claim órfão de sessão morta: `morph-spec claim <f> --release`.
@@ -0,0 +1,118 @@
1
+ #!/usr/bin/env node
2
+ import { existsSync } from 'fs';
3
+ import { resolve, isAbsolute } from 'path';
4
+ import { blockResult, runStandalone } from '../../shared/hook-response.js';
5
+ import { stateExists } from '../../shared/state-reader.js';
6
+ import { scanForBlockers } from '../../shared/reparse-scan.js';
7
+
8
+ /**
9
+ * block-destructive-worktree-removal — the only defense that does not depend on
10
+ * someone reading documentation.
11
+ *
12
+ * A morph worktree carries JUNCTIONS to the primary root's shared infra
13
+ * (`node_modules`, `.claude`, `.morph/framework`). Every generic recursive
14
+ * removal — `git worktree remove` (plain AND `--force`), `rm -rf`,
15
+ * `Remove-Item -Recurse`, `rmdir /s` — FOLLOWS those links out of the worktree
16
+ * and deletes what they point at, leaving the primary root's `.claude/` and
17
+ * `.morph/framework/` as empty directories. That is the incident this hook
18
+ * exists for, and it is not hypothetical: it happened, and the command was
19
+ * copied from a message the CLI itself printed.
20
+ *
21
+ * `morph-spec worktree remove` is safe because it drops the junctions BEFORE
22
+ * letting git near the directory. This hook is what stops the unsafe path from
23
+ * being reachable by accident.
24
+ *
25
+ * DELIBERATELY NARROW — an inert or over-eager guardrail is worse than none:
26
+ * - only recursive-removal shapes, only when a target path can be extracted;
27
+ * - only when that path exists AND the scan finds reparse points under it
28
+ * (an already-unlinked worktree, or one that never had junctions, passes);
29
+ * - anything unparseable passes.
30
+ * The scan is the authority, not the path shape: a worktree kept outside
31
+ * `worktrees/` is protected just the same, and a `worktrees/` directory with
32
+ * nothing to traverse is not blocked for looking dangerous.
33
+ */
34
+
35
+ /** Split a shell-ish command into tokens, honoring single and double quotes. */
36
+ function tokenize(command) {
37
+ const tokens = [];
38
+ const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
39
+ let m;
40
+ while ((m = re.exec(command)) !== null) tokens.push(m[1] ?? m[2] ?? m[3]);
41
+ return tokens;
42
+ }
43
+
44
+ /**
45
+ * The path a recursive-removal command would destroy, or null when this is not
46
+ * such a command (or the target cannot be read off it).
47
+ *
48
+ * @param {string} command
49
+ * @returns {string|null}
50
+ */
51
+ export function extractRemovalTarget(command) {
52
+ const tokens = tokenize(command);
53
+ if (!tokens.length) return null;
54
+ const lower = tokens.map((t) => t.toLowerCase());
55
+
56
+ // `git worktree remove [--force] <path>` — the path is always the last
57
+ // non-flag token; git has no form of this command without one.
58
+ const gi = lower.indexOf('git');
59
+ if (gi !== -1 && lower[gi + 1] === 'worktree' && lower[gi + 2] === 'remove') {
60
+ const rest = tokens.slice(gi + 3).filter((t) => !t.startsWith('-'));
61
+ return rest.length ? rest[rest.length - 1] : null;
62
+ }
63
+
64
+ // Generic recursive deletes. Each needs BOTH its command word and a recursive
65
+ // flag — a plain `rm file` or `Remove-Item file` cannot traverse a junction's
66
+ // contents and is none of this hook's business.
67
+ const recursive = tokens.some((t) => /^(-[a-z]*r[a-z]*|--recursive|\/s)$/i.test(t));
68
+ const removalCmd = lower.find((t) => /^(rm|remove-item|ri|rd|rmdir|del)$/.test(t));
69
+ if (!removalCmd || !recursive) return null;
70
+
71
+ const idx = lower.indexOf(removalCmd);
72
+ // A cmd.exe switch is a slash plus a letter or two (`rmdir /s /q`). Anything
73
+ // longer that starts with `/` is a POSIX ABSOLUTE PATH — filtering on the
74
+ // bare `/` made every Linux target look like a flag, so `rm -rf /tmp/wt`
75
+ // extracted nothing and the hook passed exactly where it had to block.
76
+ const isCmdSwitch = (t) => /^\/[a-z]{1,2}$/i.test(t);
77
+ const operands = tokens.slice(idx + 1).filter((t) => !t.startsWith('-') && !isCmdSwitch(t));
78
+ // Named-parameter form: `Remove-Item -Path <p> -Recurse`.
79
+ const pathFlag = lower.findIndex((t) => t === '-path' || t === '-literalpath');
80
+ if (pathFlag !== -1 && tokens[pathFlag + 1]) return tokens[pathFlag + 1];
81
+ return operands.length ? operands[0] : null;
82
+ }
83
+
84
+ /**
85
+ * @param {object} payload - parsed PreToolUse stdin payload (Bash matcher)
86
+ * @returns {Promise<object|null>} `{decision:'block',reason}` or null (pass).
87
+ */
88
+ export async function run(payload) {
89
+ try {
90
+ if (!stateExists()) return null;
91
+
92
+ const command = payload?.tool_input?.command || '';
93
+ if (!command) return null;
94
+
95
+ const raw = extractRemovalTarget(command);
96
+ if (!raw) return null;
97
+
98
+ const cwd = payload?.cwd || process.cwd();
99
+ const target = isAbsolute(raw) ? resolve(raw) : resolve(cwd, raw);
100
+ if (!existsSync(target)) return null;
101
+
102
+ const { safe, blockers } = scanForBlockers(target);
103
+ if (safe) return null;
104
+
105
+ const shown = blockers.slice(0, 5).join(', ');
106
+ const more = blockers.length > 5 ? `, +${blockers.length - 5}` : '';
107
+ return blockResult(
108
+ `Remoção bloqueada: "${target}" contém link(s) que este comando SEGUIRIA para fora, apagando o alvo real na raiz do projeto (${shown}${more}).\n\n` +
109
+ `Isto é o que esvazia o \`.claude/\` e o \`.morph/framework/\` da raiz.\n\n` +
110
+ `Use: \`morph-spec worktree remove --target "${target}"\` — ele desmonta as junctions ANTES de remover, e recusa se não conseguir provar que é seguro.\n` +
111
+ `Para fechar a feature de vez: \`morph-spec finish <feature> --pr|--merge\`.`
112
+ );
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+
118
+ await runStandalone(run);
@@ -0,0 +1,100 @@
1
+ /**
2
+ * reparse-scan.js — exhaustive, fail-closed reparse-point (symlink/junction)
3
+ * scan of a directory tree.
4
+ *
5
+ * This lives in `framework/hooks/shared/` rather than `src/` because BOTH sides
6
+ * need it and the dependency direction in this repo only runs one way: `src/`
7
+ * imports from `framework/hooks/shared/` (see `finish.js`'s `claim-registry`
8
+ * and `e2e-check` imports), never the reverse — a hook shipped into a target
9
+ * project has the package, not this repo's `src/` layout.
10
+ *
11
+ * Two callers, one question:
12
+ * - `teardownWorktree` (src/commands/project/worktree.js) — may this worktree
13
+ * be handed to `git worktree remove`?
14
+ * - the `block-destructive-worktree-removal` PreToolUse hook — is the command
15
+ * about to run going to traverse a link out of the worktree?
16
+ *
17
+ * WHY IT MUST BE EXHAUSTIVE — both `git worktree remove` and its `--force`
18
+ * fallback FOLLOW reparse points out of the worktree and delete the PRIMARY
19
+ * ROOT's real files. The plain removal is not the safe one: it refuses only
20
+ * when it sees modified/untracked *tracked-scope* content, so a GITIGNORED
21
+ * junction (`node_modules`, `.claude`) leaves `git status` empty, the removal
22
+ * proceeds, exits 0 — and the target is gone.
23
+ *
24
+ * So the check cannot live in the `catch` of the plain removal (unreachable on
25
+ * exactly the destructive path), and it cannot be a fixed list of the links
26
+ * `setup` creates: a nested link inside a real `node_modules` (npm link, npm
27
+ * workspaces) is invisible to any top-level inventory. We scan the whole tree
28
+ * and NEVER recurse INTO a reparse point — descending one would walk the target
29
+ * we are trying to protect (and could not terminate on a cycle).
30
+ *
31
+ * FAIL-CLOSED, deliberately: an empty result is NOT the same as a safe one. A
32
+ * directory we could not read or an entry we could not lstat might BE (or
33
+ * contain) a reparse point. Those answer `unprovable`, and every caller must
34
+ * treat that exactly like a found link — decline. "I saw nothing" and "I proved
35
+ * there is nothing" are different claims, and only the second may authorize a
36
+ * removal. This is the one module in the shared/ directory that must NOT be
37
+ * fail-open; the whole point is that uncertainty blocks.
38
+ *
39
+ * `lstat` reports a POSIX symlink and a Windows junction alike as a symbolic
40
+ * link, so the scan behaves identically on both platforms — as does the
41
+ * destruction it prevents.
42
+ */
43
+
44
+ import { readdirSync, lstatSync } from 'fs';
45
+ import { join, relative } from 'path';
46
+
47
+ /**
48
+ * Every reparse point anywhere under `dir`, as relative paths.
49
+ *
50
+ * @param {string} dir
51
+ * @param {string} [base] - root of the scan, for relative reporting
52
+ * @param {{points: string[], unprovable: string[]}} [acc]
53
+ * @returns {{points: string[], unprovable: string[]}} both empty = provably clean
54
+ */
55
+ export function findReparsePoints(dir, base = dir, acc = { points: [], unprovable: [] }) {
56
+ let entries;
57
+ try {
58
+ entries = readdirSync(dir, { withFileTypes: true });
59
+ } catch {
60
+ // Unreadable (ACL, lock, EMFILE): we cannot prove what is in here.
61
+ acc.unprovable.push((relative(base, dir) || '.').replace(/\\/g, '/'));
62
+ return acc;
63
+ }
64
+ for (const entry of entries) {
65
+ const p = join(dir, entry.name);
66
+ let st;
67
+ try {
68
+ st = lstatSync(p);
69
+ } catch {
70
+ acc.unprovable.push(relative(base, p).replace(/\\/g, '/'));
71
+ continue;
72
+ }
73
+ if (st.isSymbolicLink()) {
74
+ // Forward slashes: every other path this command reports is normalized,
75
+ // and these end up in JSON the caller may compare or display.
76
+ acc.points.push(relative(base, p).replace(/\\/g, '/'));
77
+ continue; // do NOT descend — that is the target, not the worktree
78
+ }
79
+ if (st.isDirectory() && entry.name !== '.git') findReparsePoints(p, base, acc);
80
+ }
81
+ return acc;
82
+ }
83
+
84
+ /**
85
+ * The single question both callers actually ask: may this tree be handed to a
86
+ * command that follows links?
87
+ *
88
+ * Collapses `points` and `unprovable` into one list precisely because callers
89
+ * must not weigh them differently — `teardownWorktree` already concatenated
90
+ * them, and a hook that treated `unprovable` as "probably fine" would reopen
91
+ * the hole this module exists to close.
92
+ *
93
+ * @param {string} dir
94
+ * @returns {{ safe: boolean, blockers: string[] }}
95
+ */
96
+ export function scanForBlockers(dir) {
97
+ const { points, unprovable } = findReparsePoints(dir);
98
+ const blockers = [...points, ...unprovable];
99
+ return { safe: blockers.length === 0, blockers };
100
+ }
@@ -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.23.0"
7
+ cliVersion: "8.24.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.23.0"
7
+ cliVersion: "8.24.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.23.0"
7
+ cliVersion: "8.24.1"
8
8
  ---
9
9
 
10
10
  # morph-apply
@@ -196,7 +196,7 @@ Quando o `morph-spec dag {feature}` reporta `isolation.recommended: "worktree"`
196
196
  1. **Dispatch:** mesmo `subagent_type: "morph-{id}"` e standards de §3b, mais `isolation: "worktree"`. Se a stack for Node e faltar `node_modules` no worktree, o agente roda `morph-spec worktree link` (fail-open) antes de testar; se ainda impossível, reporta "verificação: não executada" (o verify pós-merge cobre). Se o agente precisar subir um dev server e existir `.morph/worktree.env` no seu cwd, usa as portas de lá (nunca as padrão — MORPH.md §2.2). O agente commita na branch do worktree e reporta a branch.
197
197
  2. **Integração (orquestrador, após todos os relatórios):** faça o merge de cada branch reportada, uma por vez, na ordem de chegada — `git merge --no-ff <branch>`. Conflito → `git merge --abort`, marque a task de volta a `pending` com nota "merge conflict — reexecutar sequencial", siga para a próxima branch. Os paths em conflito são exatamente os de `isolation.collisions` do dag.
198
198
  3. **Pós-merge:** rode `morph-spec verify {feature} --json` UMA vez (o merge é o novo ponto de integração — verificação compartilhada obrigatória), depois `morph-eval` + `morph-spec score` por relatório, como em §3b.
199
- 4. **Cleanup:** `git worktree remove` + `git branch -d` das branches integradas + `git worktree prune` (fail-open). Branches não integradas ficam e são registradas no recap com o motivo.
199
+ 4. **Cleanup:** `morph-spec worktree remove --target <path>` por worktree (NUNCA `git worktree remove` cru — o passo 1 pode ter criado junction de `node_modules`, e a remoção crua a atravessa e apaga o `node_modules` da raiz) + `git branch -d` das branches integradas + `git worktree prune` (fail-open). Branches não integradas ficam e são registradas no recap com o motivo.
200
200
  5. **Agente morto no meio:** se commitou, a branch sobrevive (retomável); senão, a task volta a `pending` na lane sequencial. Registre a falha de protocolo no recap.
201
201
 
202
202
  > Anti-padrões: não misture unidades isoladas e não-isoladas no mesmo batch; o subagent nunca faz merge (integração é exclusiva do orquestrador).
@@ -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.23.0"
7
+ cliVersion: "8.24.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.23.0"
7
+ cliVersion: "8.24.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.23.0"
7
+ cliVersion: "8.24.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.23.0"
7
+ cliVersion: "8.24.1"
8
8
  ---
9
9
 
10
10
  # morph-implement
@@ -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.23.0"
7
+ cliVersion: "8.24.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.23.0"
7
+ cliVersion: "8.24.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.23.0"
7
+ cliVersion: "8.24.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.23.0"
7
+ cliVersion: "8.24.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.23.0"
7
+ cliVersion: "8.24.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.23.0"
7
+ cliVersion: "8.24.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.23.0",
3
+ "version": "8.24.1",
4
4
  "description": "MORPH-SPEC: NLH (Natural Language Harness) for spec-driven development with Claude Code",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -31,10 +31,13 @@
31
31
  */
32
32
 
33
33
  import { execSync, execFileSync } from 'child_process';
34
- import { existsSync, mkdirSync, symlinkSync, readFileSync, writeFileSync, appendFileSync, lstatSync, unlinkSync, rmdirSync, readdirSync, cpSync } from 'fs';
35
- import { join, resolve, dirname, relative } from 'path';
34
+ import { existsSync, mkdirSync, symlinkSync, readFileSync, writeFileSync, appendFileSync, lstatSync, unlinkSync, rmdirSync, cpSync } from 'fs';
35
+ import { join, resolve, dirname } from 'path';
36
36
  import chalk from 'chalk';
37
37
  import { detectDefaultBranch } from '../../lib/git/default-branch.js';
38
+ // The reparse scan lives in hooks/shared because the PreToolUse guardrail needs
39
+ // the SAME answer this file does, and hooks can never import from `src/`.
40
+ import { findReparsePoints } from '../../../framework/hooks/shared/reparse-scan.js';
38
41
 
39
42
  /**
40
43
  * The directory new worktrees are created under, relative to the primary root.
@@ -740,64 +743,6 @@ export function unlinkWorktreeInfraLinks(worktreePath, mainCwd) {
740
743
  return removed;
741
744
  }
742
745
 
743
- /**
744
- * Every reparse point anywhere under `worktreePath`, as relative paths.
745
- *
746
- * CRITICAL — this is the gate that keeps `morph-spec finish` (worktree teardown)
747
- * from destroying the project, and it must be exhaustive. BOTH `git worktree remove` and its
748
- * `--force` fallback FOLLOW reparse points out of the worktree and delete the
749
- * PRIMARY ROOT's real files. The plain removal is not the safe one: it refuses
750
- * only when it sees modified/untracked *tracked-scope* content, so a GITIGNORED
751
- * junction (`node_modules`, `.claude`) leaves `git status` empty, the removal
752
- * proceeds, exits 0 — and the target is gone.
753
- *
754
- * So the check cannot live in the `catch` of the plain removal (unreachable on
755
- * exactly the destructive path), and it cannot be a fixed list of the links
756
- * `setup` creates: a nested link inside a real `node_modules` (npm link, npm
757
- * workspaces) is invisible to any top-level inventory. We scan the whole tree
758
- * and NEVER recurse INTO a reparse point — descending one would walk the target
759
- * we are trying to protect (and could not terminate on a cycle).
760
- *
761
- * An empty result is NOT the same as a safe one: a directory we could not read
762
- * or an entry we could not lstat might BE (or contain) a reparse point. Those
763
- * answer `unprovable: true`, and the caller must treat that exactly like a found
764
- * link — decline. "I saw nothing" and "I proved there is nothing" are different
765
- * claims, and only the second one may authorize a removal.
766
- *
767
- * @param {string} dir
768
- * @param {string} [base] - root of the scan, for relative reporting
769
- * @param {{points: string[], unprovable: string[]}} [acc]
770
- * @returns {{points: string[], unprovable: string[]}} both empty = provably clean
771
- */
772
- function findReparsePoints(dir, base = dir, acc = { points: [], unprovable: [] }) {
773
- let entries;
774
- try {
775
- entries = readdirSync(dir, { withFileTypes: true });
776
- } catch {
777
- // Unreadable (ACL, lock, EMFILE): we cannot prove what is in here.
778
- acc.unprovable.push((relative(base, dir) || '.').replace(/\\/g, '/'));
779
- return acc;
780
- }
781
- for (const entry of entries) {
782
- const p = join(dir, entry.name);
783
- let st;
784
- try {
785
- st = lstatSync(p);
786
- } catch {
787
- acc.unprovable.push(relative(base, p).replace(/\\/g, '/'));
788
- continue;
789
- }
790
- if (st.isSymbolicLink()) {
791
- // Forward slashes: every other path this command reports is normalized,
792
- // and these end up in JSON the caller may compare or display.
793
- acc.points.push(relative(base, p).replace(/\\/g, '/'));
794
- continue; // do NOT descend — that is the target, not the worktree
795
- }
796
- if (st.isDirectory() && entry.name !== '.git') findReparsePoints(p, base, acc);
797
- }
798
- return acc;
799
- }
800
-
801
746
  /**
802
747
  * Gitignored paths a morph worktree owns and that are safe to remove — but ONLY
803
748
  * once backed up (see backupWorktreeIgnored). `.morph/state.json` is provably
@@ -1085,6 +1030,60 @@ async function clearRootWorktreeRegistration(root, feature) {
1085
1030
  } catch { /* fail-open — a dirty root is a nuisance, not a reason to fail */ }
1086
1031
  }
1087
1032
 
1033
+ /**
1034
+ * Why `git worktree remove` failed, from its stderr.
1035
+ *
1036
+ * The distinction that matters is `in-use` — on Windows a process whose cwd is
1037
+ * inside the worktree (another Claude Code instance, VS Code, a dev server, a
1038
+ * stray terminal) pins the directory and the removal cannot proceed. That is a
1039
+ * human-actionable cause with a specific fix ("close it"), and printing it is
1040
+ * the difference between a message the user can act on and one that just says
1041
+ * "failed" — which is what made a raw `git worktree remove --force` look like
1042
+ * the only way forward.
1043
+ *
1044
+ * @param {string} stderr
1045
+ * @returns {{ kind: 'in-use'|'unknown', detail: string }}
1046
+ */
1047
+ function classifyRemovalError(stderr) {
1048
+ const text = String(stderr || '').trim();
1049
+ const inUse = /permission denied|EPERM|EBUSY|being used by another process|resource busy|access is denied|is locked/i.test(text);
1050
+ // First stderr line only: the rest is git's usual advice block, and this lands
1051
+ // in a JSON field a human reads.
1052
+ const detail = text.split(/\r?\n/).find((l) => l.trim()) || '';
1053
+ return { kind: inUse ? 'in-use' : 'unknown', detail: detail.slice(0, 200) };
1054
+ }
1055
+
1056
+ /**
1057
+ * Run the removal, with one retry round after a short pause.
1058
+ *
1059
+ * The retry exists for the transient Windows handle (antivirus/indexer touching
1060
+ * a file mid-removal) — the same class the repo already retries in
1061
+ * `atomicWriteJson`. It deliberately does NOT help the `in-use` case: a held
1062
+ * directory stays held, and that one needs the human, not another attempt.
1063
+ *
1064
+ * `--force` is safe HERE and only here: the caller proved `blockers.length ===
1065
+ * 0` before calling, so there is nothing left for it to traverse out of.
1066
+ *
1067
+ * @param {string} worktreePath
1068
+ * @param {string} root
1069
+ * @returns {Promise<{ removed: boolean, failure: {kind: string, detail: string}|null }>}
1070
+ */
1071
+ async function attemptWorktreeRemoval(worktreePath, root) {
1072
+ let lastStderr = '';
1073
+ for (let round = 0; round < 2; round++) {
1074
+ for (const args of [['worktree', 'remove', worktreePath], ['worktree', 'remove', '--force', worktreePath]]) {
1075
+ try {
1076
+ execFileSync('git', args, { cwd: root, stdio: 'pipe' });
1077
+ return { removed: true, failure: null };
1078
+ } catch (err) {
1079
+ lastStderr = err?.stderr ? String(err.stderr) : (err?.message || '');
1080
+ }
1081
+ }
1082
+ if (round === 0) await new Promise((r) => setTimeout(r, 200));
1083
+ }
1084
+ return { removed: false, failure: classifyRemovalError(lastStderr) };
1085
+ }
1086
+
1088
1087
  /**
1089
1088
  * Remove a feature's worktree after its branch is integrated — PROVE-or-DECLINE.
1090
1089
  * The single most dangerous step in the lifecycle, so it declines whenever it
@@ -1095,15 +1094,22 @@ async function clearRootWorktreeRegistration(root, feature) {
1095
1094
  * 2. it deletes GITIGNORED files outright — a user's `.env` is not disposable,
1096
1095
  * and the clean-check cannot see them.
1097
1096
  *
1097
+ * A non-removal comes in two flavours the caller must not conflate — see the
1098
+ * rollback comment below. `refused` (we proved it unsafe) restores the infra
1099
+ * junctions; a FAILURE (git errored) deliberately leaves them down, reported in
1100
+ * `infraLeftUnlinked`.
1101
+ *
1098
1102
  * `deleteBranch` decouples `git branch -d` from removal: true for `--merge` (the
1099
1103
  * branch is integrated), false for `--pr` (the open PR still needs the branch).
1100
1104
  *
1101
1105
  * @param {string} worktreePath
1102
1106
  * @param {{ root: string, feature: string, branch: string, options?: object, deleteBranch?: boolean }} ctx
1103
1107
  * @returns {Promise<{ worktreeRemoved: boolean, ignoredBackup: string|null, discarded: string[],
1104
- * blockers: string[], ignoredBlocking: string[], shieldable: string[], declineReason: string|null }>}
1108
+ * blockers: string[], ignoredBlocking: string[], shieldable: string[], declineReason: string|null,
1109
+ * relinked: boolean, refused: boolean, infraLeftUnlinked: string[],
1110
+ * removalFailure: {kind: string, detail: string}|null }>}
1105
1111
  */
1106
- export async function teardownWorktree(worktreePath, { root, feature, branch, options = {}, deleteBranch = true }) {
1112
+ export async function teardownWorktree(worktreePath, { root, feature, branch, options = {}, deleteBranch = true, addressByTarget = false }) {
1107
1113
  // Drop the infra junctions FIRST, without following them — this is what makes
1108
1114
  // a removal survivable (see unlinkWorktreeInfraLinks). Runs unconditionally.
1109
1115
  //
@@ -1130,18 +1136,11 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
1130
1136
  const canRemove = clearedToRemove && (!ignored.shieldable.length || ignoredBackup !== null);
1131
1137
 
1132
1138
  let worktreeRemoved = false;
1139
+ let removalFailure = null;
1133
1140
  if (canRemove) {
1134
- try {
1135
- execFileSync('git', ['worktree', 'remove', worktreePath], { cwd: root, stdio: 'pipe' });
1136
- worktreeRemoved = true;
1137
- } catch {
1138
- // Nothing is left to traverse (proven above), so --force can only act
1139
- // inside the worktree.
1140
- try {
1141
- execFileSync('git', ['worktree', 'remove', '--force', worktreePath], { cwd: root, stdio: 'pipe' });
1142
- worktreeRemoved = true;
1143
- } catch { /* fail-open — cleanup is best-effort once integration landed */ }
1144
- }
1141
+ const attempt = await attemptWorktreeRemoval(worktreePath, root);
1142
+ worktreeRemoved = attempt.removed;
1143
+ removalFailure = attempt.failure; // null once removed
1145
1144
  }
1146
1145
 
1147
1146
  if (worktreeRemoved) {
@@ -1161,30 +1160,58 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
1161
1160
  await clearRootWorktreeRegistration(root, feature);
1162
1161
  }
1163
1162
 
1164
- // Rollback (A4): the removal was refused or failed, so the worktree is still
1165
- // there — but without the junctions this function dropped up front, which is
1166
- // strictly worse than never having tried (the observed complaint: "um estado
1167
- // pior que não ter tentado"). Put them back so the worktree stays usable and
1168
- // the user can just re-run finish from the primary root. Fail-open: a failed
1169
- // re-link reports `relinked: false` and the caller falls back to telling the
1170
- // user to run `morph-spec worktree link --all`.
1163
+ // Rollback (A4) — ONLY on a REFUSAL, never on a FAILURE. The two look alike
1164
+ // (worktree still there, junctions dropped) and must be treated as opposites:
1165
+ //
1166
+ // REFUSED (`!canRemove`): we proved removal unsafe, so the user keeps
1167
+ // working in this worktree. Leaving the junctions down is "um estado pior
1168
+ // que não ter tentado" — put them back.
1169
+ // FAILED (`canRemove` but git errored): removal WAS the user's intent and
1170
+ // the only obstacle is a lock. Re-linking here would restore the exact
1171
+ // hazard that destroyed installations — the retry (ours, theirs, or a raw
1172
+ // `git worktree remove --force` pasted from an old transcript) would then
1173
+ // follow the junctions out. Leaving them down makes EVERY subsequent
1174
+ // attempt safe, because there is nothing left to traverse.
1175
+ //
1176
+ // Fail-open on the refusal path: a failed re-link reports `relinked: false`
1177
+ // and the caller falls back to telling the user to run `worktree link --all`.
1178
+ const refused = !canRemove;
1171
1179
  let relinked = false;
1172
- if (!worktreeRemoved && unlinked.length && existsSync(worktreePath)) {
1180
+ if (refused && unlinked.length && existsSync(worktreePath)) {
1173
1181
  try {
1174
1182
  setupInfraLinks(worktreePath, root);
1175
1183
  relinked = true;
1176
1184
  } catch { relinked = false; }
1177
1185
  }
1178
-
1186
+ // What the caller must tell the user about on the FAILURE path: the worktree
1187
+ // survived without its shared infra, on purpose.
1188
+ const infraLeftUnlinked = (!worktreeRemoved && !refused) ? unlinked : [];
1189
+
1190
+ // Every branch names `morph-spec worktree remove` and NEVER `git worktree
1191
+ // remove`. The raw command follows the junctions this function manages, and a
1192
+ // decline is exactly the moment a frustrated reader copies whatever command
1193
+ // the tool just printed — that copy is what emptied a primary root's
1194
+ // `.claude/` and `.morph/framework/`. `worktree remove` re-runs this same
1195
+ // PROVE-or-DECLINE path and accepts the same `--discard-ignored` consent, so
1196
+ // there is no decline left where the raw command would be the right answer.
1197
+ // A worktree with no feature identity (the ones `isolation: "worktree"`
1198
+ // creates) can only be re-addressed by path — naming a feature that does not
1199
+ // resolve would hand the reader a command that FAILS, and a failing safe
1200
+ // command is exactly how people end up back at the raw one.
1201
+ const removeCmd = (addressByTarget || !isValidFeatureName(feature))
1202
+ ? `morph-spec worktree remove --target "${worktreePath}"`
1203
+ : `morph-spec worktree remove ${feature}`;
1179
1204
  const declineReason = worktreeRemoved
1180
1205
  ? null
1181
1206
  : ignored.blocking.length
1182
- ? `worktree NÃO removido: contém arquivo(s) gitignored que existem só ali e o \`git worktree remove\` apagaria (${ignored.blocking.slice(0, 5).join(', ')}${ignored.blocking.length > 5 ? `, +${ignored.blocking.length - 5}` : ''}). Salve o que importa e rode \`git worktree remove --force "${worktreePath}"\`${deleteBranch ? ` + \`git branch -d ${branch}\`` : ''}, ou re-execute o finish com \`--discard-ignored\` para descartá-los deliberadamente`
1207
+ ? `worktree NÃO removido: contém arquivo(s) gitignored que existem só ali e a remoção apagaria (${ignored.blocking.slice(0, 5).join(', ')}${ignored.blocking.length > 5 ? `, +${ignored.blocking.length - 5}` : ''}). Salve o que importa e rode \`${removeCmd}\`, ou \`${removeCmd} --discard-ignored\` para descartá-los deliberadamente`
1183
1208
  : blockers.length
1184
- ? `worktree NÃO removido: ainda contém link(s)/caminho(s) não verificáveis (${blockers.join(', ')}) que o \`git worktree remove\` seguiria para fora, apagando o alvo na raiz. Remova-os e rode \`git worktree remove "${worktreePath}"\`${deleteBranch ? ` + \`git branch -d ${branch}\`` : ''}`
1209
+ ? `worktree NÃO removido: ainda contém link(s)/caminho(s) não verificáveis (${blockers.join(', ')}) que a remoção seguiria para fora, apagando o alvo na raiz. Remova-os e rode \`${removeCmd}\``
1185
1210
  : ignored.shieldable.length && ignoredBackup === null
1186
- ? `worktree NÃO removido: o backup do gitignored do worktree falhou, e sem ele a remoção seria destrutiva. Rode \`git worktree remove --force "${worktreePath}"\` manualmente se não precisar desse conteúdo`
1187
- : `o \`git worktree remove\` falhou. Rode \`git worktree remove "${worktreePath}"\` manualmente`;
1211
+ ? `worktree NÃO removido: o backup do gitignored do worktree falhou, e sem ele a remoção seria destrutiva. Libere espaço/permissão em \`.git/morph-spec/\` e rode \`${removeCmd}\`, ou \`${removeCmd} --discard-ignored\` se não precisar desse conteúdo`
1212
+ : removalFailure?.kind === 'in-use'
1213
+ ? `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\``
1214
+ : `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`;
1188
1215
 
1189
1216
  return {
1190
1217
  worktreeRemoved,
@@ -1195,7 +1222,144 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
1195
1222
  shieldable: ignored.shieldable,
1196
1223
  declineReason,
1197
1224
  relinked,
1225
+ refused,
1226
+ infraLeftUnlinked,
1227
+ removalFailure,
1228
+ };
1229
+ }
1230
+
1231
+ /**
1232
+ * Is `cwd` the worktree we are about to remove, or inside it?
1233
+ *
1234
+ * Hoisted out of `finish` so BOTH callers share one answer. Windows holds a
1235
+ * lock on a process's working directory, so a removal issued from inside the
1236
+ * target fails — and in `finish`'s case it fails AFTER the archive is committed
1237
+ * and the branch merged, leaving an orphan directory and a de-registered
1238
+ * worktree. Refusing up front costs one `cd`.
1239
+ *
1240
+ * @param {string} cwd
1241
+ * @param {string} worktreePath
1242
+ * @returns {boolean}
1243
+ */
1244
+ export function cwdIsInsideWorktree(cwd, worktreePath) {
1245
+ const cwdNorm = resolve(cwd).replace(/\\/g, '/');
1246
+ const wtNorm = resolve(worktreePath).replace(/\\/g, '/');
1247
+ return cwdNorm === wtNorm || cwdNorm.startsWith(`${wtNorm}/`);
1248
+ }
1249
+
1250
+ // ── remove ───────────────────────────────────────────────────────────────
1251
+
1252
+ /**
1253
+ * Command handler for `morph-spec worktree remove <feature>` /
1254
+ * `morph-spec worktree remove --target <path>`.
1255
+ *
1256
+ * WHY THIS EXISTS: `teardownWorktree` — the only removal that drops the infra
1257
+ * junctions before letting git near the directory — used to be reachable ONLY
1258
+ * through `morph-spec finish`. So every message that had to tell a user how to
1259
+ * remove a worktree pointed at raw `git worktree remove`, which follows those
1260
+ * junctions out and empties the primary root's `.claude/` and
1261
+ * `.morph/framework/`. This command is the safe answer those messages now give.
1262
+ *
1263
+ * Two ways to address the target, because there are two kinds of worktree:
1264
+ * <feature> — a morph worktree, resolved through `findWorktreeForFeature`
1265
+ * --target <path> — any registered worktree, including the ones the Agent
1266
+ * tool's `isolation: "worktree"` creates, which have no
1267
+ * feature registration for the lookup to find.
1268
+ *
1269
+ * It removes ONLY. No archive, no merge, and the branch survives by default —
1270
+ * closing a feature is still `morph-spec finish`.
1271
+ *
1272
+ * @param {string|undefined} featureArg
1273
+ * @param {{ target?: string, discardIgnored?: boolean, deleteBranch?: boolean, cwd?: string }} [options]
1274
+ */
1275
+ export async function worktreeRemoveCommand(featureArg, options = {}) {
1276
+ const cwdArg = options.cwd || process.cwd();
1277
+ const emit = (obj, code) => { console.log(JSON.stringify(obj)); process.exit(code); };
1278
+
1279
+ if (!isGitRepo(cwdArg)) emit({ removed: false, error: 'not-a-git-repo' }, 1);
1280
+ const root = resolvePrimaryRoot(cwdArg);
1281
+
1282
+ let worktreePath = null;
1283
+ let feature = featureArg || null;
1284
+ let branch = null;
1285
+ // Can a re-run be addressed as `remove <feature>`? Only when the feature name
1286
+ // is what resolved the worktree in the first place.
1287
+ let featureAddressable = !options.target;
1288
+
1289
+ if (options.target) {
1290
+ worktreePath = resolve(options.target).replace(/\\/g, '/');
1291
+ // The target MUST be a registered worktree. Without this the command would
1292
+ // hand an arbitrary path to `git worktree remove` — and `--target ../..`
1293
+ // is the same path-traversal class already fixed in delete/archive.
1294
+ const entry = parseWorktreeList(root).find((e) => e.path === worktreePath);
1295
+ if (!entry) {
1296
+ emit({ removed: false, error: 'not-a-registered-worktree', target: worktreePath, root,
1297
+ hint: 'o path precisa ser um worktree registrado neste repo — confira `morph-spec worktree list`' }, 1);
1298
+ }
1299
+ if (worktreePath === root) {
1300
+ emit({ removed: false, error: 'refuses-primary-root', path: worktreePath,
1301
+ hint: 'a raiz primária nunca é removível por este comando' }, 1);
1302
+ }
1303
+ branch = entry.branch ? entry.branch.replace(/^refs\/heads\//, '') : null;
1304
+ // Best-effort feature identity — used for the backup/env-release bookkeeping
1305
+ // inside the teardown. A `morph/{f}` branch means `remove <feature>` would
1306
+ // resolve too, so re-run advice may name it; anything else stays --target.
1307
+ if (!feature && branch?.startsWith('morph/')) {
1308
+ feature = branch.slice('morph/'.length);
1309
+ featureAddressable = isValidFeatureName(feature);
1310
+ }
1311
+ } else {
1312
+ if (!isValidFeatureName(feature)) {
1313
+ emit({ removed: false, error: 'invalid-feature-name', feature: feature ?? null,
1314
+ hint: 'informe <feature> (kebab-case) ou use --target <path>' }, 1);
1315
+ }
1316
+ const found = findWorktreeForFeature(feature, root);
1317
+ if (!found || found.path.replace(/\\/g, '/') === root) {
1318
+ emit({ removed: false, error: 'no-worktree-for-feature', feature, root,
1319
+ hint: `nenhum worktree separado para "${feature}" — confira \`morph-spec worktree list\`` }, 1);
1320
+ }
1321
+ worktreePath = found.path;
1322
+ branch = found.branch;
1323
+ }
1324
+
1325
+ // Same guard finish applies, for the same Windows reason — and this command
1326
+ // is MORE likely to hit it: the natural moment to run it is right after
1327
+ // working inside the worktree.
1328
+ if (cwdIsInsideWorktree(cwdArg, worktreePath)) {
1329
+ emit({ removed: false, error: 'cwd-inside-worktree', path: worktreePath, root,
1330
+ 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);
1331
+ }
1332
+
1333
+ const teardown = await teardownWorktree(worktreePath, {
1334
+ root,
1335
+ feature: feature || 'worktree',
1336
+ branch,
1337
+ options: { discardIgnored: options.discardIgnored === true },
1338
+ // Removing a worktree is not closing a feature: the branch is the work, and
1339
+ // deleting it here would discard unintegrated commits. Opt-in only.
1340
+ deleteBranch: options.deleteBranch === true,
1341
+ // Re-run advice must address the worktree the way it was addressable here:
1342
+ // a `--target` worktree has no feature name that would resolve.
1343
+ addressByTarget: !featureAddressable,
1344
+ });
1345
+
1346
+ const out = {
1347
+ removed: teardown.worktreeRemoved,
1348
+ path: worktreePath,
1349
+ ...(branch ? { branch } : {}),
1350
+ ...(feature ? { feature } : {}),
1198
1351
  };
1352
+ if (teardown.worktreeRemoved) {
1353
+ if (teardown.discarded.length) out.discardedIgnored = teardown.discarded;
1354
+ if (teardown.ignoredBackup) out.ignoredBackup = teardown.ignoredBackup;
1355
+ } else {
1356
+ out.refused = teardown.refused;
1357
+ if (teardown.blockers.length) out.reparsePoints = teardown.blockers;
1358
+ if (teardown.ignoredBlocking.length) out.ignoredFiles = teardown.ignoredBlocking;
1359
+ if (teardown.infraLeftUnlinked.length) out.infraLeftUnlinked = teardown.infraLeftUnlinked;
1360
+ out.hint = teardown.declineReason;
1361
+ }
1362
+ emit(out, teardown.worktreeRemoved ? 0 : 1);
1199
1363
  }
1200
1364
 
1201
1365
  // ── list ─────────────────────────────────────────────────────────────────
@@ -10,6 +10,7 @@ import {
10
10
  isValidFeatureName,
11
11
  buildBranchName,
12
12
  branchExists,
13
+ cwdIsInsideWorktree,
13
14
  } from '../project/worktree.js';
14
15
  import { detectDefaultBranch, hasOriginRemote } from '../../lib/git/default-branch.js';
15
16
  import { releaseClaim } from '../../../framework/hooks/shared/claim-registry.js';
@@ -162,16 +163,14 @@ export async function finishCommand(feature, options = {}) {
162
163
  // a de-registered worktree, and a `relinkHint` telling the user to repair
163
164
  // by hand. Refuse HERE, before the first mutation — the cost is one
164
165
  // `cd`, and it is the exact failure observed in the ProspectPRO session.
165
- const cwdNorm = resolve(cwd).replace(/\\/g, '/');
166
- const wtNorm = resolve(worktreePath).replace(/\\/g, '/');
167
- if (cwdNorm === wtNorm || cwdNorm.startsWith(`${wtNorm}/`)) {
166
+ if (cwdIsInsideWorktree(cwd, worktreePath)) {
168
167
  emit({
169
168
  finished: false,
170
169
  error: 'cwd-inside-worktree',
171
170
  feature,
172
171
  path: worktreePath,
173
172
  root,
174
- hint: `saia do worktree antes do finish — o \`git worktree remove\` não consegue apagar o diretório de trabalho do próprio processo. Rode de novo a partir de "${root}"`,
173
+ hint: `saia do worktree antes do finish — a remoção não consegue apagar o diretório de trabalho do próprio processo. Rode de novo a partir de "${root}"`,
175
174
  });
176
175
  process.exit(1);
177
176
  }
@@ -441,15 +440,25 @@ function teardownExtras(teardown, worktreePath, branch, deleteBranch) {
441
440
  if (teardown.blockers.length) out.reparsePoints = teardown.blockers;
442
441
  if (teardown.ignoredBlocking.length) out.ignoredFiles = teardown.ignoredBlocking;
443
442
  out.cleanupHint = teardown.declineReason;
444
- // The unlink runs before the removal by design (it is what keeps `git
445
- // worktree remove` from following the junctions out into the primary root),
446
- // so a declined removal always passes through a moment with the infra
447
- // loose. The teardown now re-links on that path — report which of the two
448
- // actually happened instead of always announcing the worse one (A4).
443
+ // The unlink runs before the removal by design (it is what keeps the removal
444
+ // from following the junctions out into the primary root), so every
445
+ // non-removal passes through a moment with the infra loose. What happens
446
+ // next depends on WHY, and the two must not be reported alike:
447
+ // REFUSED — we proved removal unsafe, the user keeps working here, so the
448
+ // teardown re-links (A4).
449
+ // FAILED — removal was the intent and a lock got in the way. The infra
450
+ // stays down ON PURPOSE, so the retry (or anything pasted from an old
451
+ // transcript) has nothing to traverse. Say so explicitly, or the user
452
+ // reads a bare "failed" and reaches for the destructive command.
449
453
  out.relinked = teardown.relinked === true;
450
- out.relinkHint = teardown.relinked
451
- ? 'as junctions de infra foram soltas antes da tentativa e RESTAURADAS após a recusa — o worktree segue utilizável'
452
- : `as junctions de infra foram soltas antes desta recusa e não puderam ser restauradas — para continuar no worktree, rode \`morph-spec worktree link --all --target "${worktreePath}"\``;
454
+ if (!teardown.refused) {
455
+ if (teardown.infraLeftUnlinked?.length) out.infraLeftUnlinked = teardown.infraLeftUnlinked;
456
+ out.relinkHint = `a remoção falhou e as junctions de infra foram deixadas DESMONTADAS de propósito — qualquer nova tentativa é segura. Para voltar a trabalhar no worktree, rode \`morph-spec worktree link --all --target "${worktreePath}"\``;
457
+ } else {
458
+ out.relinkHint = teardown.relinked
459
+ ? 'as junctions de infra foram soltas antes da tentativa e RESTAURADAS após a recusa — o worktree segue utilizável'
460
+ : `as junctions de infra foram soltas antes desta recusa e não puderam ser restauradas — para continuar no worktree, rode \`morph-spec worktree link --all --target "${worktreePath}"\``;
461
+ }
453
462
  }
454
463
  return out;
455
464
  }
@@ -131,7 +131,11 @@ export function auditWorktrees(cwd = process.cwd()) {
131
131
  type: 'non-morph-branch',
132
132
  path,
133
133
  detail: `${path} está em ${entry.branch || 'HEAD destacado'}, não numa branch morph/* — o \`fleet\` não o enxerga`,
134
- fix: `git worktree remove "${path}" (se já não serve)`,
134
+ // NEVER raw `git worktree remove` here: it follows the infra junctions
135
+ // out and empties the primary root's .claude/ and .morph/framework/.
136
+ // `worktree remove` drops them first (PROVE-or-DECLINE) and takes
137
+ // --target precisely for worktrees like this one, with no feature.
138
+ fix: `morph-spec worktree remove --target "${path}" (se já não serve)`,
135
139
  });
136
140
  }
137
141
  continue;
@@ -142,7 +146,7 @@ export function auditWorktrees(cwd = process.cwd()) {
142
146
  type: 'no-feature',
143
147
  path,
144
148
  detail: `worktree de "${feature}" sem feature conhecida (nem em .morph/features/, nem na branch)`,
145
- fix: `git worktree remove "${path}"`,
149
+ fix: `morph-spec worktree remove --target "${path}"`,
146
150
  });
147
151
  }
148
152
  }
@@ -28,7 +28,7 @@ import { execSync } from 'child_process';
28
28
  * delivery — it makes `morph-spec update` report the change honestly instead
29
29
  * of "already current".
30
30
  */
31
- const HOOKS_VERSION = '3.6.0';
31
+ const HOOKS_VERSION = '3.7.0';
32
32
 
33
33
  /** Marker for old dispatch.js (v1) */
34
34
  const OLD_DISPATCH_COMMAND = 'node framework/hooks/agent-teams/dispatch.js';
@@ -132,16 +132,20 @@ const MORPH_HOOKS = [
132
132
  }]
133
133
  },
134
134
 
135
- // === PreToolUse: Bash — denylist (irreversible commands only). Solo. ===
135
+ // === PreToolUse: Bash — irreversible-command guards, chained (block-first-
136
+ // wins, one process). pre-bash-denylist (static pattern list) runs before
137
+ // block-destructive-worktree-removal (dynamic: scans the target for reparse
138
+ // points), so the cheap check short-circuits the filesystem walk. The second
139
+ // one is the only defense against the raw-removal vector that does not depend
140
+ // on someone having read MORPH.md. ===
136
141
  {
137
142
  event: 'PreToolUse',
138
143
  matcher: 'Bash',
139
- hooks: [
140
- {
141
- type: 'command',
142
- command: 'node framework/hooks/claude-code/core/pre-bash-denylist.js'
143
- }
144
- ]
144
+ hooks: [{
145
+ type: 'command',
146
+ command: 'node framework/hooks/claude-code/core/chain.js',
147
+ args: ['pre-bash-denylist', 'block-destructive-worktree-removal']
148
+ }]
145
149
  },
146
150
 
147
151
  // === PostToolUse: Write|Edit — typecheck (debounced, silent on success) +