@polymorphism-tech/morph-spec 8.6.0 → 8.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/bin/morph-spec.js +23 -1
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +2 -2
- package/docs/QUICKSTART.md +2 -2
- package/framework/CLAUDE.md +6 -3
- package/framework/MORPH.md +28 -3
- package/framework/hooks/claude-code/core/telemetry-log.js +15 -0
- package/framework/hooks/claude-code/core/worktree-context.js +41 -5
- package/framework/hooks/shared/claim-registry.js +175 -0
- package/framework/hooks/shared/state-reader.js +68 -25
- package/framework/rules/morph-task-tracking.md +14 -8
- package/framework/rules/morph-workflow.md +34 -1
- package/framework/schemas/feature.schema.json +2 -1
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +1 -1
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +1 -1
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +7 -1
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/package.json +1 -1
- package/src/commands/project/claim.js +91 -0
- package/src/commands/project/doctor.js +65 -0
- package/src/commands/project/fleet.js +37 -2
- package/src/commands/project/worktree.js +70 -7
- package/src/commands/state/advance.js +60 -1
- package/src/commands/state/approve.js +125 -2
- package/src/commands/state/create.js +3 -3
- package/src/commands/state/finish.js +88 -3
- package/src/commands/state/list.js +9 -1
- package/src/commands/state/report-check.js +202 -0
- package/src/core/state/lifecycle-audit.js +57 -0
- package/src/dashboard/scanner/state-scanner.js +11 -3
- package/src/lib/generators/architecture-scanner.js +1 -1
- package/src/lib/tasks/tasks-json-parser.js +8 -0
- package/src/lib/worktree/fleet-scanner.js +224 -15
- package/src/lib/worktree/worktree-audit.js +181 -0
- package/src/utils/file-copier.js +5 -1
|
@@ -33,11 +33,18 @@ TaskUpdate(taskId, subject="Round 3 — score 82/100")
|
|
|
33
33
|
|
|
34
34
|
## Persisting Progress to `tasks.json`
|
|
35
35
|
|
|
36
|
+
> **Dois sistemas, dois vocabulários.** Tudo acima desta linha é sobre as *session
|
|
37
|
+
> tasks* nativas do Claude Code (`TaskCreate`/`TaskUpdate`), cujo status terminal é
|
|
38
|
+
> `completed`. Tudo abaixo é sobre o `tasks.json` do morph-spec, governado por
|
|
39
|
+
> `.morph/framework/schemas/tasks.schema.json`, cujo status terminal é `done` —
|
|
40
|
+
> **`completed` não existe nesse enum** e o hook `protect-spec-files` rejeita a
|
|
41
|
+
> gravação. Não misture os dois.
|
|
42
|
+
|
|
36
43
|
Native session tasks (`TaskCreate`/`TaskUpdate`) track your live work. To persist
|
|
37
44
|
task progress for the feature, write `.morph/features/{feature}/2-plan/tasks.json`,
|
|
38
45
|
touching ONLY the mutable fields:
|
|
39
46
|
|
|
40
|
-
- `status` — `pending` → `in_progress` → `
|
|
47
|
+
- `status` — `pending` → `in_progress` → `done` (também: `skipped`, `blocked`)
|
|
41
48
|
- `outputs` — the files the task produced
|
|
42
49
|
- `notes` — free-text record (deviations, context, resolved blockers)
|
|
43
50
|
|
|
@@ -53,17 +60,16 @@ output.
|
|
|
53
60
|
## Deviation Tracking
|
|
54
61
|
|
|
55
62
|
When completing a task differently than originally planned, record the reason in the
|
|
56
|
-
task's `notes` field.
|
|
57
|
-
|
|
63
|
+
task's `notes` field. `notes` is **uma única string** (o schema declara
|
|
64
|
+
`"notes": { "type": "string" }`) — para registrar mais de um desvio, separe-os com
|
|
65
|
+
`;` ou `\n` dentro da mesma string. Um array é **rejeitado** pelo validador, com a
|
|
66
|
+
mensagem `'notes' must be a string`.
|
|
58
67
|
|
|
59
68
|
```json
|
|
60
69
|
{
|
|
61
70
|
"id": "T3",
|
|
62
|
-
"status": "
|
|
71
|
+
"status": "done",
|
|
63
72
|
"outputs": ["Features/User/Update/Handler.cs"],
|
|
64
|
-
"notes":
|
|
65
|
-
"deviation: split validation into a separate validator for testability",
|
|
66
|
-
"blocker-resolved: EF migration conflict fixed by regenerating the snapshot"
|
|
67
|
-
]
|
|
73
|
+
"notes": "deviation: split validation into a separate validator for testability; blocker-resolved: EF migration conflict fixed by regenerating the snapshot"
|
|
68
74
|
}
|
|
69
75
|
```
|
|
@@ -72,6 +72,39 @@ All outputs go in `.morph/features/{feature}/`:
|
|
|
72
72
|
|
|
73
73
|
---
|
|
74
74
|
|
|
75
|
+
## Formato obrigatório do `3-implement/recap.md`
|
|
76
|
+
|
|
77
|
+
> Esta exigência é **cobrada por hook** (`validate-completion`, no Stop). Ela está aqui, na regra
|
|
78
|
+
> sempre-ativa, e não só na skill `morph-implement`: descobrir o formato quando o hook dispara
|
|
79
|
+
> significa reescrever o recap depois de a fase já estar dada por encerrada.
|
|
80
|
+
|
|
81
|
+
**Para CADA task com `status: "done"` em `tasks.json`**, o recap precisa de um bloco próprio:
|
|
82
|
+
|
|
83
|
+
1. um heading `## T{N}` (o hook casa `^## T\d+` — uma linha de tabela `| T1 | … | done |` conta
|
|
84
|
+
como menção, **não** como bloco);
|
|
85
|
+
2. uma linha `**Standard:** {standard-id}` **dentro de 12 linhas** do heading. Sem standard que
|
|
86
|
+
cubra o caso, registre a decisão em `decisions.md` e cite
|
|
87
|
+
`**Standard:** ad-hoc (see decisions.md ADR-{N})`.
|
|
88
|
+
|
|
89
|
+
```markdown
|
|
90
|
+
## T{N} — {title}
|
|
91
|
+
|
|
92
|
+
**Standard:** {standard-id} (`.morph/framework/standards/{path}.md`)
|
|
93
|
+
**Persona:** {mecanismo real usado}
|
|
94
|
+
**Files:**
|
|
95
|
+
- `{path}`
|
|
96
|
+
|
|
97
|
+
{2-3 frases sobre o que foi feito}
|
|
98
|
+
|
|
99
|
+
**Issues encontrados:** {breve descrição ou "nenhum"}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
O `validate-completion` também acusa **drift**: uma task que aparece como concluída no recap mas
|
|
103
|
+
ainda está `pending`/`in_progress` no `tasks.json`. Template completo e regras de exceção em
|
|
104
|
+
`morph-implement` §6.
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
75
108
|
## Checkpoints
|
|
76
109
|
|
|
77
110
|
Run a checkpoint every 3 completed tasks:
|
|
@@ -158,7 +191,7 @@ During implementation, these skills MUST be invoked at the specified triggers:
|
|
|
158
191
|
| Trigger | Skill | When |
|
|
159
192
|
|---------|-------|------|
|
|
160
193
|
| Before each task | `superpowers:test-driven-development` | Invoke BEFORE writing any code for a task |
|
|
161
|
-
| Before marking a task complete | `morph-verification-before-completion` | Invoke BEFORE setting a task's `status` to `
|
|
194
|
+
| Before marking a task complete | `morph-verification-before-completion` | Invoke BEFORE setting a task's `status` to `done` in `tasks.json` |
|
|
162
195
|
| On bug/unexpected | `superpowers:systematic-debugging` | Invoke IF a bug or unexpected behavior is encountered |
|
|
163
196
|
| After all tasks | `morph-post-implementation` | Invoke AFTER all tasks completed |
|
|
164
197
|
| Before PR | `morph-checklist` | Invoke BEFORE creating pull request |
|
|
@@ -222,7 +222,8 @@
|
|
|
222
222
|
"properties": {
|
|
223
223
|
"approved": { "type": "boolean" },
|
|
224
224
|
"timestamp": { "type": ["string", "null"], "description": "ISO timestamp of the approval/revocation, null while never evaluated." },
|
|
225
|
-
"approvedBy": { "type": ["string", "null"], "description": "\"auto\" (approve/advance --mode auto), \"manual\" (human in the loop), \"policy:{workType}\" (gate stamped at birth by `morph-spec create` per the work type's skippedGates — see src/core/work-types.js; the gate-guard hook only special-cases \"auto\", so policy-stamped gates stay silent to it), a --approver name, or null while never evaluated." }
|
|
225
|
+
"approvedBy": { "type": ["string", "null"], "description": "\"auto\" (approve/advance --mode auto), \"manual\" (human in the loop), \"policy:{workType}\" (gate stamped at birth by `morph-spec create` per the work type's skippedGates — see src/core/work-types.js; the gate-guard hook only special-cases \"auto\", so policy-stamped gates stay silent to it), a --approver name, or null while never evaluated." },
|
|
226
|
+
"selfAssessed": { "type": "boolean", "description": "Gate `review` SOMENTE. `true` quando o Gate 3 foi aprovado sem `4-review/evaluator-report.md`, via `morph-spec approve <f> review --self-assessed`. O score composto deveria vir do avaliador independente; esta flag registra a degradação em vez de deixá-la silenciosa, e aparece no `morph-spec list`." }
|
|
226
227
|
},
|
|
227
228
|
"additionalProperties": true
|
|
228
229
|
}
|
|
@@ -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.7.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# create-umbraco-lp
|
|
@@ -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.7.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# migrate-lp-to-umbraco
|
|
@@ -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.7.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-apply
|
|
@@ -4,7 +4,7 @@ description: Entende o negócio, os usuários e a arquitetura antes de qualquer
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao receber um novo request de feature, antes de morph-plan
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.7.0"
|
|
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.7.0"
|
|
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.7.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-hotfix
|
|
@@ -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.7.0"
|
|
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.7.0"
|
|
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.7.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-review
|
|
@@ -74,6 +74,12 @@ e o `recap.md` estar atualizado. Normalmente invocada pela skill orquestradora `
|
|
|
74
74
|
- **Leia primeiro** `.morph/features/{feature}/4-review/evaluator-report.md` — é o valor de retorno autoritativo, não a mensagem de texto do dispatch (que é cortesia, útil só quando chega inteira). Os scores por dimensão e o composto do review-report vêm **desse arquivo**, não da sua auto-avaliação. Se o arquivo vier ausente, oco, ou sem os scores por dimensão, registre a falha de protocolo na seção de método do review-report e re-despache.
|
|
75
75
|
|
|
76
76
|
5. Produza `review-report.md` em `.morph/features/{feature}/4-review/` com:
|
|
77
|
+
- **`**Reviewed-SHA:** {git rev-parse HEAD}`** — o commit que você revisou, **obrigatório**.
|
|
78
|
+
Um review revisa uma ÁRVORE, e a árvore anda: sem esse carimbo, um relatório escrito antes
|
|
79
|
+
da última task fica no repo afirmando números e cobertura que já não valem. O
|
|
80
|
+
`morph-spec approve {feature} review` compara o carimbo com o HEAD e **recusa** se divergirem
|
|
81
|
+
(aconteceu de verdade: relatório de `b30ac6e` sobrevivendo à T3, que inverteu o default de
|
|
82
|
+
uma regra de negócio, e ainda dizendo `976/976` quando eram 982).
|
|
77
83
|
- Scores por dimensão (arquitetura, contratos, qualidade, cobertura)
|
|
78
84
|
- Score composto final
|
|
79
85
|
- Lista de itens bloqueantes (se houver) — com descrição e critério de resolução
|
|
@@ -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.7.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-stack-scan
|
|
@@ -4,7 +4,7 @@ description: Localiza e carrega um standard específico do STANDARDS.json sob de
|
|
|
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.7.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-standards
|
package/package.json
CHANGED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `morph-spec claim [feature] [--release] [--list]` — the manual half of
|
|
3
|
+
* advisory feature ownership (B1).
|
|
4
|
+
*
|
|
5
|
+
* Claims are acquired and heartbeaten AUTOMATICALLY (SessionStart hook +
|
|
6
|
+
* telemetry hook), so the operator never has to remember anything. This command
|
|
7
|
+
* exists for the case automation cannot cover: a session that died without
|
|
8
|
+
* releasing, leaving a claim that keeps warning everyone until its TTL expires.
|
|
9
|
+
*
|
|
10
|
+
* Read-only by default (`--list`); the only mutation is `--release`.
|
|
11
|
+
* Always exits 0 except on a genuine misuse — the claim system is advisory and
|
|
12
|
+
* must never become a source of hard failures.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import chalk from 'chalk';
|
|
16
|
+
import { execSync } from 'child_process';
|
|
17
|
+
import { resolve } from 'path';
|
|
18
|
+
import { isGitRepo, resolvePrimaryRoot } from './worktree.js';
|
|
19
|
+
import { listClaims, releaseClaim, claimAge } from '../../../framework/hooks/shared/claim-registry.js';
|
|
20
|
+
|
|
21
|
+
/** Resolve the repo's shared git common dir — where claims live. */
|
|
22
|
+
function gitCommonDirOf(cwd) {
|
|
23
|
+
const root = resolvePrimaryRoot(cwd);
|
|
24
|
+
const common = execSync('git rev-parse --git-common-dir', { cwd: root, stdio: 'pipe' }).toString().trim();
|
|
25
|
+
return common ? resolve(root, common) : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {string|undefined} feature
|
|
30
|
+
* @param {{ release?: boolean, list?: boolean, json?: boolean, cwd?: string }} [options]
|
|
31
|
+
*/
|
|
32
|
+
export function claimCommand(feature, options = {}) {
|
|
33
|
+
const cwd = options.cwd || process.cwd();
|
|
34
|
+
if (!isGitRepo(cwd)) {
|
|
35
|
+
console.log(chalk.gray('Fora de um repositório git — não há registro de claims.'));
|
|
36
|
+
process.exit(0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let gitCommonDir = null;
|
|
40
|
+
try {
|
|
41
|
+
gitCommonDir = gitCommonDirOf(cwd);
|
|
42
|
+
} catch { gitCommonDir = null; }
|
|
43
|
+
if (!gitCommonDir) {
|
|
44
|
+
console.log(chalk.gray('Não foi possível resolver o git common dir — nenhum claim a mostrar.'));
|
|
45
|
+
process.exit(0);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (options.release) {
|
|
49
|
+
if (!feature) {
|
|
50
|
+
console.error(chalk.red('\n❌ --release exige o nome da feature: morph-spec claim <feature> --release\n'));
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
const released = releaseClaim(feature, { gitCommonDir });
|
|
54
|
+
if (options.json) {
|
|
55
|
+
console.log(JSON.stringify({ feature, released }));
|
|
56
|
+
process.exit(0);
|
|
57
|
+
}
|
|
58
|
+
console.log(released
|
|
59
|
+
? chalk.green(`\n✅ Claim de "${feature}" liberado.\n`)
|
|
60
|
+
: chalk.yellow(`\n⚠️ Nenhum claim registrado para "${feature}" — nada a liberar.\n`));
|
|
61
|
+
process.exit(0);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Default and --list are the same read: the whole board, or one feature.
|
|
65
|
+
const all = listClaims(gitCommonDir);
|
|
66
|
+
const rows = feature ? all.filter((c) => c.feature === feature) : all;
|
|
67
|
+
|
|
68
|
+
if (options.json) {
|
|
69
|
+
console.log(JSON.stringify(rows));
|
|
70
|
+
process.exit(0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (!rows.length) {
|
|
74
|
+
console.log(chalk.gray(feature
|
|
75
|
+
? `Nenhum claim para "${feature}".`
|
|
76
|
+
: 'Nenhuma feature reclamada — nenhuma sessão registrada.'));
|
|
77
|
+
process.exit(0);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const col = (s, w) => String(s ?? '-').padEnd(w);
|
|
81
|
+
console.log(chalk.white.bold(col('FEATURE', 24) + col('ESTADO', 10) + col('VISTO HÁ', 12) + col('PID', 9) + 'ONDE'));
|
|
82
|
+
for (const c of rows) {
|
|
83
|
+
const state = c.live ? chalk.yellow('viva') : chalk.gray('morta');
|
|
84
|
+
console.log(
|
|
85
|
+
col(c.feature, 24) + state + ' '.repeat(Math.max(1, 10 - (c.live ? 4 : 5)))
|
|
86
|
+
+ col(claimAge(c), 12) + col(c.pid, 9) + (c.path || '-'),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
console.log(chalk.gray('\nClaims são AVISOS, nunca bloqueios. Claim órfão: `morph-spec claim <f> --release`'));
|
|
90
|
+
process.exit(0);
|
|
91
|
+
}
|
|
@@ -428,7 +428,67 @@ async function doctorMcpCommand(targetPath) {
|
|
|
428
428
|
logger.blank();
|
|
429
429
|
}
|
|
430
430
|
|
|
431
|
+
/**
|
|
432
|
+
* `morph-spec doctor --worktrees [--prune]` — reconcile git's worktree registry
|
|
433
|
+
* with the directories on disk and the features morph knows (C2).
|
|
434
|
+
*
|
|
435
|
+
* Orphan worktrees used to accumulate invisibly: `fleet` only reports worktrees
|
|
436
|
+
* on a `morph/*` branch, so anything else never showed up anywhere.
|
|
437
|
+
*
|
|
438
|
+
* `--prune` runs the one safe repair (`git worktree prune`: admin records for
|
|
439
|
+
* directories that are already gone). Deleting a directory that still exists is
|
|
440
|
+
* reported with the exact command, never performed — same PROVE-or-DECLINE
|
|
441
|
+
* discipline as the finish teardown.
|
|
442
|
+
*/
|
|
443
|
+
export async function doctorWorktreesCommand(targetPath, options = {}) {
|
|
444
|
+
const { auditWorktrees, pruneWorktrees } = await import('../../lib/worktree/worktree-audit.js');
|
|
445
|
+
|
|
446
|
+
if (options.prune) {
|
|
447
|
+
const before = auditWorktrees(targetPath).filter((i) => i.type === 'registered-missing').length;
|
|
448
|
+
const result = pruneWorktrees(targetPath);
|
|
449
|
+
if (result.pruned) logger.success(`git worktree prune: ${before} registro(s) órfão(s) removido(s).`);
|
|
450
|
+
else logger.warn(`prune falhou: ${result.error}`);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const issues = auditWorktrees(targetPath);
|
|
454
|
+
|
|
455
|
+
if (options.json) {
|
|
456
|
+
console.log(JSON.stringify({ issues }, null, 2));
|
|
457
|
+
return issues.length === 0;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
logger.header('MORPH-SPEC Worktrees');
|
|
461
|
+
if (issues.length === 0) {
|
|
462
|
+
logger.success('Worktrees reconciliados — git, disco e features batem.');
|
|
463
|
+
return true;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
logger.warn(`${issues.length} inconsistência(s):`);
|
|
467
|
+
for (const i of issues) {
|
|
468
|
+
console.log(` ⚠ ${i.type}`);
|
|
469
|
+
console.log(` ${i.detail}`);
|
|
470
|
+
console.log(` → ${i.fix}`);
|
|
471
|
+
}
|
|
472
|
+
if (issues.some((i) => i.type === 'registered-missing') && !options.prune) {
|
|
473
|
+
logger.dim('\n Rode `morph-spec doctor --worktrees --prune` para limpar os registros órfãos.');
|
|
474
|
+
}
|
|
475
|
+
return false;
|
|
476
|
+
}
|
|
477
|
+
|
|
431
478
|
export async function doctorCommand(options = {}) {
|
|
479
|
+
// `--json` and `--prune` only mean something under `--worktrees`. Falling
|
|
480
|
+
// through to the human health check would hand a script a banner and a table
|
|
481
|
+
// where it asked for JSON — a caller has no way to notice a flag that was
|
|
482
|
+
// dropped in silence, so refuse instead of pretending.
|
|
483
|
+
const scoped = ['json', 'prune'].filter((f) => options[f]);
|
|
484
|
+
if (scoped.length && !options.worktrees) {
|
|
485
|
+
const flags = scoped.map((f) => `--${f}`).join(' e ');
|
|
486
|
+
logger.error(`${flags} só se aplica${scoped.length > 1 ? 'm' : ''} a \`morph-spec doctor --worktrees\`.`);
|
|
487
|
+
logger.dim(` Rode: morph-spec doctor --worktrees ${scoped.map((f) => `--${f}`).join(' ')}`);
|
|
488
|
+
process.exitCode = 1;
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
491
|
+
|
|
432
492
|
// Reset mode: remove morph-managed settings entries
|
|
433
493
|
if (options.reset) {
|
|
434
494
|
const targetPath = process.cwd();
|
|
@@ -465,6 +525,11 @@ export async function doctorCommand(options = {}) {
|
|
|
465
525
|
return doctorMcpCommand(process.cwd());
|
|
466
526
|
}
|
|
467
527
|
|
|
528
|
+
// Worktree reconciliation mode (C2)
|
|
529
|
+
if (options.worktrees) {
|
|
530
|
+
return doctorWorktreesCommand(process.cwd(), options);
|
|
531
|
+
}
|
|
532
|
+
|
|
468
533
|
const targetPath = process.cwd();
|
|
469
534
|
|
|
470
535
|
logger.header('MORPH-SPEC Health Check');
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import chalk from 'chalk';
|
|
14
14
|
import { scanFleet } from '../../lib/worktree/fleet-scanner.js';
|
|
15
|
+
import { claimAge } from '../../../framework/hooks/shared/claim-registry.js';
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* @param {{ json?: boolean, cwd?: string }} [options]
|
|
@@ -30,9 +31,33 @@ export function fleetCommand(options = {}) {
|
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
const col = (s, w) => String(s ?? '-').padEnd(w);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The REMOTO cell. `-` means "unknown" (no origin, or the probe failed) —
|
|
37
|
+
* deliberately the same glyph as "nothing there", because both mean "do not
|
|
38
|
+
* trust the local view alone". `local` is the confident negative.
|
|
39
|
+
*/
|
|
40
|
+
const remoteCell = (r) => {
|
|
41
|
+
if (!r) return '-';
|
|
42
|
+
if (r.pr) return `PR #${r.pr.number}${r.pr.state && r.pr.state !== 'OPEN' ? ` (${r.pr.state.toLowerCase()})` : ''}`;
|
|
43
|
+
return r.pushed ? 'pushed' : 'local';
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The CLAIM cell — who is on this feature right now (B1). A stale claim is
|
|
48
|
+
* shown as stale, not hidden: "nobody here" and "somebody died here" are
|
|
49
|
+
* different facts for the operator.
|
|
50
|
+
*/
|
|
51
|
+
const claimCell = (c) => {
|
|
52
|
+
if (!c) return '-';
|
|
53
|
+
const age = claimAge(c);
|
|
54
|
+
return c.live ? chalk.yellow(`sessão (${age})`) : chalk.gray(`morta (${age})`);
|
|
55
|
+
};
|
|
56
|
+
|
|
33
57
|
console.log(chalk.white.bold(
|
|
34
58
|
col('FEATURE', 20) + col('TYPE', 9) + col('PHASE', 11) + col('TASKS', 8)
|
|
35
|
-
+ col('GATES PEND.', 20) + col('WHERE', 24) + col('
|
|
59
|
+
+ col('GATES PEND.', 20) + col('WHERE', 24) + col('REMOTO', 16) + col('CLAIM', 16)
|
|
60
|
+
+ col('PORTS', 12) + col('HANDOFF', 9) + 'ATIVIDADE',
|
|
36
61
|
));
|
|
37
62
|
for (const e of fleet.entries) {
|
|
38
63
|
const tasks = e.tasks ? `${e.tasks.done}/${e.tasks.total}` : '-';
|
|
@@ -44,11 +69,21 @@ export function fleetCommand(options = {}) {
|
|
|
44
69
|
e.lastCommit ? `commit ${e.lastCommit}` : null,
|
|
45
70
|
Number.isInteger(e.dirty) && e.dirty > 0 ? chalk.yellow(`${e.dirty} dirty`) : null,
|
|
46
71
|
].filter(Boolean).join(' · ') || '-';
|
|
72
|
+
// padEnd counts invisible color codes, so pad the claim cell on its plain
|
|
73
|
+
// length and append the padding by hand.
|
|
74
|
+
const claimPlain = e.claim ? `${e.claim.live ? 'sessão' : 'morta'} (${claimAge(e.claim)})` : '-';
|
|
47
75
|
console.log(
|
|
48
76
|
col(e.feature, 20) + col(e.workType || '-', 9) + col(e.phase, 11) + col(tasks, 8)
|
|
49
|
-
+ col(gates, 20) + col(where, 24) + col(
|
|
77
|
+
+ col(gates, 20) + col(where, 24) + col(remoteCell(e.remote), 16)
|
|
78
|
+
+ claimCell(e.claim) + ' '.repeat(Math.max(1, 16 - claimPlain.length))
|
|
79
|
+
+ col(ports, 12) + col(handoff, 9) + activity,
|
|
50
80
|
);
|
|
51
81
|
}
|
|
82
|
+
const claimed = fleet.entries.filter((e) => e.claim?.live);
|
|
83
|
+
if (claimed.length) {
|
|
84
|
+
console.log(chalk.yellow(`\n⚠ ${claimed.length} feature(s) com sessão ativa — confirme antes de finish/archive/delete.`));
|
|
85
|
+
console.log(chalk.gray(" Claim órfão de sessão morta: `morph-spec claim <f> --release`"));
|
|
86
|
+
}
|
|
52
87
|
console.log(chalk.gray(`\nRaiz primária: ${fleet.root}`));
|
|
53
88
|
process.exit(0);
|
|
54
89
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MORPH-SPEC Worktree Command Suite
|
|
3
3
|
*
|
|
4
|
-
* Isolates feature work into per-feature git worktrees at
|
|
4
|
+
* Isolates feature work into per-feature git worktrees at worktrees/{feature}/
|
|
5
5
|
* on branch morph/{feature}, so each feature can run its own Claude Code
|
|
6
6
|
* instance (v8 multi-instance, parallel-feature-development flow).
|
|
7
7
|
*
|
|
@@ -34,6 +34,31 @@ import { execSync, execFileSync } from 'child_process';
|
|
|
34
34
|
import { existsSync, mkdirSync, symlinkSync, readFileSync, writeFileSync, appendFileSync, lstatSync, unlinkSync, rmdirSync, readdirSync, cpSync } from 'fs';
|
|
35
35
|
import { join, resolve, dirname, relative } from 'path';
|
|
36
36
|
import chalk from 'chalk';
|
|
37
|
+
import { detectDefaultBranch } from '../../lib/git/default-branch.js';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The directory new worktrees are created under, relative to the primary root.
|
|
41
|
+
*
|
|
42
|
+
* NO leading dot, deliberately (C1). Jest escapes the `.` when it interpolates
|
|
43
|
+
* an absolute `rootDir` into the `testMatch` glob, so `<root>/.worktrees/...`
|
|
44
|
+
* stops matching and an entire frontend suite reports **0 tests without an
|
|
45
|
+
* error** — the suite "passes" having tested nothing. The dot bought nothing:
|
|
46
|
+
* the directory is gitignored either way.
|
|
47
|
+
*/
|
|
48
|
+
export const WORKTREES_DIR = 'worktrees';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The pre-C1 directory name. New worktrees never land here, but every reader
|
|
52
|
+
* that resolves worktrees BY CONVENTION must still accept it, so a machine
|
|
53
|
+
* that already had `.worktrees/{f}` keeps working with no manual migration.
|
|
54
|
+
*
|
|
55
|
+
* Readers that go through `git worktree list` (fleet, finish, worktree list)
|
|
56
|
+
* were never affected — git records absolute paths, not this convention.
|
|
57
|
+
*/
|
|
58
|
+
export const LEGACY_WORKTREES_DIR = '.worktrees';
|
|
59
|
+
|
|
60
|
+
/** Both names, new first — the read order for convention-based lookups. */
|
|
61
|
+
export const WORKTREE_DIR_NAMES = [WORKTREES_DIR, LEGACY_WORKTREES_DIR];
|
|
37
62
|
|
|
38
63
|
// ── Pure helpers (exported for tests) ──────────────────────────────────────
|
|
39
64
|
|
|
@@ -47,7 +72,7 @@ import chalk from 'chalk';
|
|
|
47
72
|
*
|
|
48
73
|
* Mirrors the validation `morph-spec create` applies, and every worktree
|
|
49
74
|
* subcommand enforces it for the same reason plus a sharper one: a feature name
|
|
50
|
-
* flows into
|
|
75
|
+
* flows into `worktrees/{name}`, the branch `morph/{name}`, and from there into
|
|
51
76
|
* git invocations. Characters like `& | ; > ^` are legal in a git refname but
|
|
52
77
|
* are cmd.exe metacharacters — on Windows an unvalidated name turns any
|
|
53
78
|
* string-interpolated git call into command injection, and makes a `finish`
|
|
@@ -121,7 +146,7 @@ export function worktreeExists(worktreePath, cwd = process.cwd()) {
|
|
|
121
146
|
export function buildWorktreePath(feature, cwd = process.cwd(), options = {}) {
|
|
122
147
|
const suffix = options.fresh ? `-${getDateSuffix()}` : '';
|
|
123
148
|
const normalizedCwd = cwd.replace(/\\/g, '/');
|
|
124
|
-
return `${normalizedCwd}
|
|
149
|
+
return `${normalizedCwd}/${WORKTREES_DIR}/${feature}${suffix}`;
|
|
125
150
|
}
|
|
126
151
|
|
|
127
152
|
/**
|
|
@@ -421,14 +446,29 @@ export async function runWorktreeSetup(feature, options = {}) {
|
|
|
421
446
|
}
|
|
422
447
|
} catch { /* fail-open — malformed feature.json must never block setup */ }
|
|
423
448
|
|
|
449
|
+
let branchedFrom = null;
|
|
424
450
|
try {
|
|
425
|
-
// Create branch if needed
|
|
451
|
+
// Create branch if needed — off the DEFAULT branch, never the current HEAD.
|
|
452
|
+
// Branching from HEAD meant a root parked on some other `morph/{other}`
|
|
453
|
+
// gave the new feature that feature's commits, and every PR opened from
|
|
454
|
+
// this worktree carried the sibling's diff (A1). Symmetric with the common
|
|
455
|
+
// flow in create.js → branchCommonFlow.
|
|
456
|
+
//
|
|
457
|
+
// Fail-open, same as create: with no resolvable default (a repo whose
|
|
458
|
+
// default is neither `main`/`master` nor declared via origin/HEAD) the
|
|
459
|
+
// branch is still created — off HEAD — and `branchedFrom: null` reports it.
|
|
426
460
|
if (!branchExists(branch, cwd)) {
|
|
427
|
-
|
|
461
|
+
const defaultBranch = detectDefaultBranch(cwd);
|
|
462
|
+
if (defaultBranch) {
|
|
463
|
+
execFileSync('git', ['branch', branch, defaultBranch], { cwd, stdio: 'pipe' });
|
|
464
|
+
branchedFrom = defaultBranch;
|
|
465
|
+
} else {
|
|
466
|
+
execFileSync('git', ['branch', branch], { cwd, stdio: 'pipe' });
|
|
467
|
+
}
|
|
428
468
|
}
|
|
429
469
|
|
|
430
470
|
// Create worktree directory parent if needed
|
|
431
|
-
const parentDir = join(cwd,
|
|
471
|
+
const parentDir = join(cwd, WORKTREES_DIR);
|
|
432
472
|
if (!existsSync(parentDir)) {
|
|
433
473
|
mkdirSync(parentDir, { recursive: true });
|
|
434
474
|
}
|
|
@@ -500,6 +540,7 @@ export async function runWorktreeSetup(feature, options = {}) {
|
|
|
500
540
|
created: true,
|
|
501
541
|
path: worktreePath,
|
|
502
542
|
branch,
|
|
543
|
+
branchedFrom,
|
|
503
544
|
feature,
|
|
504
545
|
materialized: materialized.materialized,
|
|
505
546
|
...(envBlock ? { env: { base: envBlock.base, range: envBlock.range } } : {}),
|
|
@@ -1065,7 +1106,13 @@ async function clearRootWorktreeRegistration(root, feature) {
|
|
|
1065
1106
|
export async function teardownWorktree(worktreePath, { root, feature, branch, options = {}, deleteBranch = true }) {
|
|
1066
1107
|
// Drop the infra junctions FIRST, without following them — this is what makes
|
|
1067
1108
|
// a removal survivable (see unlinkWorktreeInfraLinks). Runs unconditionally.
|
|
1068
|
-
|
|
1109
|
+
//
|
|
1110
|
+
// The ORDER IS LOAD-BEARING and must not be flipped: `git worktree remove`
|
|
1111
|
+
// (and its --force fallback) follows reparse points OUT of the worktree and
|
|
1112
|
+
// deletes the primary root's real `.claude/` and `.morph/framework/`.
|
|
1113
|
+
// Whatever this unlink costs on a declined path is repaired by the rollback
|
|
1114
|
+
// below — the reverse ordering has no repair, it destroys the installation.
|
|
1115
|
+
const unlinked = unlinkWorktreeInfraLinks(worktreePath, root);
|
|
1069
1116
|
|
|
1070
1117
|
const scan = findReparsePoints(worktreePath);
|
|
1071
1118
|
const ignored = classifyIgnoredLeftovers(worktreePath, options);
|
|
@@ -1114,6 +1161,21 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
|
|
|
1114
1161
|
await clearRootWorktreeRegistration(root, feature);
|
|
1115
1162
|
}
|
|
1116
1163
|
|
|
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`.
|
|
1171
|
+
let relinked = false;
|
|
1172
|
+
if (!worktreeRemoved && unlinked.length && existsSync(worktreePath)) {
|
|
1173
|
+
try {
|
|
1174
|
+
setupInfraLinks(worktreePath, root);
|
|
1175
|
+
relinked = true;
|
|
1176
|
+
} catch { relinked = false; }
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1117
1179
|
const declineReason = worktreeRemoved
|
|
1118
1180
|
? null
|
|
1119
1181
|
: ignored.blocking.length
|
|
@@ -1132,6 +1194,7 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
|
|
|
1132
1194
|
ignoredBlocking: ignored.blocking,
|
|
1133
1195
|
shieldable: ignored.shieldable,
|
|
1134
1196
|
declineReason,
|
|
1197
|
+
relinked,
|
|
1135
1198
|
};
|
|
1136
1199
|
}
|
|
1137
1200
|
|