@polymorphism-tech/morph-spec 8.23.0 → 8.24.2
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 +2 -2
- package/bin/morph-spec.js +13 -1
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +1 -1
- package/framework/MORPH.md +1 -1
- package/framework/docs/CLI.md +2 -1
- package/framework/hooks/claude-code/core/block-destructive-worktree-removal.js +118 -0
- package/framework/hooks/shared/reparse-scan.js +100 -0
- 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 +2 -2
- 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-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/worktree.js +250 -86
- package/src/commands/state/finish.js +21 -12
- package/src/lib/worktree/worktree-audit.js +6 -2
- package/src/utils/hooks-installer.js +12 -8
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.
|
|
6
|
+
**Version:** 8.24.2
|
|
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.
|
|
371
|
+
*morph-spec v8.24.2 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')
|
package/claude-plugin.json
CHANGED
package/docs/CHEATSHEET.md
CHANGED
package/docs/QUICKSTART.md
CHANGED
package/framework/CLAUDE.md
CHANGED
|
@@ -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
|
|
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 |
|
package/framework/MORPH.md
CHANGED
|
@@ -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
|
-
- **
|
|
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.
|
package/framework/docs/CLI.md
CHANGED
|
@@ -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).
|
|
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.
|
|
7
|
+
cliVersion: "8.24.2"
|
|
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.
|
|
7
|
+
cliVersion: "8.24.2"
|
|
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.
|
|
7
|
+
cliVersion: "8.24.2"
|
|
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.
|
|
7
|
+
cliVersion: "8.24.2"
|
|
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.
|
|
7
|
+
cliVersion: "8.24.2"
|
|
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.
|
|
7
|
+
cliVersion: "8.24.2"
|
|
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.
|
|
7
|
+
cliVersion: "8.24.2"
|
|
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.
|
|
7
|
+
cliVersion: "8.24.2"
|
|
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.
|
|
7
|
+
cliVersion: "8.24.2"
|
|
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.
|
|
7
|
+
cliVersion: "8.24.2"
|
|
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.
|
|
7
|
+
cliVersion: "8.24.2"
|
|
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.
|
|
7
|
+
cliVersion: "8.24.2"
|
|
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.
|
|
7
|
+
cliVersion: "8.24.2"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-uiux
|
package/package.json
CHANGED
|
@@ -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,
|
|
35
|
-
import { join, resolve, dirname
|
|
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
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
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)
|
|
1165
|
-
//
|
|
1166
|
-
//
|
|
1167
|
-
//
|
|
1168
|
-
//
|
|
1169
|
-
//
|
|
1170
|
-
//
|
|
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 (
|
|
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
|
|
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
|
|
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.
|
|
1187
|
-
:
|
|
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
|
-
|
|
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 —
|
|
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
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
//
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
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: `
|
|
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.
|
|
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 —
|
|
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
|
-
|
|
142
|
-
|
|
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) +
|