@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
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.7.0
|
|
7
7
|
**Requires:** Node.js 22+, Claude Code
|
|
8
8
|
|
|
9
9
|
---
|
|
@@ -125,7 +125,7 @@ morph-spec approval-status {feature}
|
|
|
125
125
|
|
|
126
126
|
Not every change deserves the full pipeline. Every feature is born with a **work type** — `feature`, `bug`, `chore`, or `hotfix` — set via `morph-spec create {feature} --type <type>` or classified from free text with `--request "<text>"`. The type dimensions the pipeline: `feature` runs the full proposal → [uiux] → plan → implement → review sequence; `bug` runs a lean proposal/plan; `chore` skips straight to implement (Gates 1/2 stamped by policy at birth); `hotfix` runs scout (read-only root-cause) → surgical fix → test loop, and its Gate 3 always pauses for a human even under `trust: auto`. Change the type mid-flight with `morph-spec retype {feature} {newType}` (undoes only policy-stamped gates, preserves human approvals, blocked after Gate 3).
|
|
127
127
|
|
|
128
|
-
Independent features can also be developed **in parallel, each isolated in its own git worktree** — one feature, one worktree, one Claude Code instance. `morph-spec create {feature} --worktree` (or `morph-spec worktree setup {feature}`) creates
|
|
128
|
+
Independent features can also be developed **in parallel, each isolated in its own git worktree** — one feature, one worktree, one Claude Code instance. `morph-spec create {feature} --worktree` (or `morph-spec worktree setup {feature}`) creates `worktrees/{feature}/` on branch `morph/{feature}`. The authoritative `feature.json` travels committed on the branch; shared infra (`node_modules`, `.claude`, `.morph/framework`) is linked via junctions (`morph-spec worktree link --all` repairs it if missing). Close out with `morph-spec worktree finish {feature}` (merges `--no-ff` back into the clean primary root) followed by `morph-spec archive {feature}` at the root; `morph-spec worktree list` shows every active worktree with its phase and gate status.
|
|
129
129
|
|
|
130
130
|
---
|
|
131
131
|
|
|
@@ -197,7 +197,7 @@ morph-spec dag {feature} [--mermaid] # DAG dispatch d
|
|
|
197
197
|
|
|
198
198
|
```bash
|
|
199
199
|
morph-spec create {feature} --description "<what/why>" --worktree # Birth the feature already isolated in its own worktree
|
|
200
|
-
morph-spec worktree setup {feature} # Create
|
|
200
|
+
morph-spec worktree setup {feature} # Create worktrees/{feature}/ on branch morph/{feature}
|
|
201
201
|
morph-spec worktree link [--all] # Relink shared infra (node_modules, .claude, .morph/framework)
|
|
202
202
|
morph-spec worktree list [--json] # List active feature worktrees with phase/gate status
|
|
203
203
|
# Close a worktree feature with `morph-spec finish {feature} --pr|--merge` (see Feature workflow) — it absorbed `worktree finish`.
|
|
@@ -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.7.0 by [Polymorphism Tech](https://polymorphism.tech)*
|
package/bin/morph-spec.js
CHANGED
|
@@ -25,6 +25,8 @@ import { doctorCommand } from '../src/commands/project/doctor.js';
|
|
|
25
25
|
import { statusCommand } from '../src/commands/project/status.js';
|
|
26
26
|
import { worktreeSetupCommand, worktreeLinkCommand, worktreeListCommand } from '../src/commands/project/worktree.js';
|
|
27
27
|
import { fleetCommand } from '../src/commands/project/fleet.js';
|
|
28
|
+
import { claimCommand } from '../src/commands/project/claim.js';
|
|
29
|
+
import { reportCheckCommand } from '../src/commands/state/report-check.js';
|
|
28
30
|
|
|
29
31
|
// === Approval gates (human-only) ===
|
|
30
32
|
import { approveCommand, approvalStatusCommand, unapproveCommand } from '../src/commands/state/approve.js';
|
|
@@ -118,6 +120,9 @@ program
|
|
|
118
120
|
.description('Check MORPH installation health')
|
|
119
121
|
.option('--full', 'Run full health check')
|
|
120
122
|
.option('--mcp', 'Check MCP server configuration')
|
|
123
|
+
.option('--worktrees', "Reconcile git's worktree registry × the directories on disk × known features")
|
|
124
|
+
.option('--prune', 'With --worktrees: drop git records for worktree directories that are already gone')
|
|
125
|
+
.option('--json', 'With --worktrees: output as JSON')
|
|
121
126
|
.option('--reset', 'Remove morph-managed entries from .claude/settings.local.json')
|
|
122
127
|
.action(doctorCommand);
|
|
123
128
|
|
|
@@ -145,6 +150,7 @@ program
|
|
|
145
150
|
.option('--mode <mode>', 'Approval mode: "user" or "auto" (default: detected from TTY)')
|
|
146
151
|
.option('--notes <notes>', 'Approval notes')
|
|
147
152
|
.option('--force', 'Approve out of order (bypass the gate-monotonicity check)')
|
|
153
|
+
.option('--self-assessed', 'Gate 3 only: approve without 4-review/evaluator-report.md, recording the degradation in feature.json')
|
|
148
154
|
.action((feature, gate, options) => approveCommand(feature, gate, options));
|
|
149
155
|
|
|
150
156
|
program
|
|
@@ -341,7 +347,7 @@ const worktreeCommand = program
|
|
|
341
347
|
|
|
342
348
|
worktreeCommand
|
|
343
349
|
.command('setup <feature>')
|
|
344
|
-
.description('Create a worktree at
|
|
350
|
+
.description('Create a worktree at worktrees/{feature}/ on branch morph/{feature}')
|
|
345
351
|
.option('--fresh', 'Create a new dated branch even if one already exists')
|
|
346
352
|
.action((feature, options) => worktreeSetupCommand(feature, options));
|
|
347
353
|
|
|
@@ -365,6 +371,22 @@ program
|
|
|
365
371
|
.option('--json', 'Output as JSON')
|
|
366
372
|
.action((options) => fleetCommand(options));
|
|
367
373
|
|
|
374
|
+
// ─── Claims (advisory feature ownership) ──────────────────────────────────
|
|
375
|
+
program
|
|
376
|
+
.command('claim [feature]')
|
|
377
|
+
.description('Show which session holds each feature (claims are advisory warnings, never locks). --release frees an orphan claim left by a dead session')
|
|
378
|
+
.option('--release', 'Release the claim on <feature> (recovery: the owning session died)')
|
|
379
|
+
.option('--list', 'List every claim on record (the default)')
|
|
380
|
+
.option('--json', 'Output as JSON')
|
|
381
|
+
.action((feature, options) => claimCommand(feature, options));
|
|
382
|
+
|
|
383
|
+
// ─── Sub-agent report protocol ────────────────────────────────────────────
|
|
384
|
+
program
|
|
385
|
+
.command('report-check <feature> [task]')
|
|
386
|
+
.description('Audit sub-agent report files against the work that actually landed. Answers "did the WORK land even though the REPORT did not" — exits 0 only when everything is intact')
|
|
387
|
+
.option('--json', 'Output as JSON')
|
|
388
|
+
.action((feature, task, options) => reportCheckCommand(feature, task, options));
|
|
389
|
+
|
|
368
390
|
// ─── Dashboard ────────────────────────────────────────────────────────────
|
|
369
391
|
program
|
|
370
392
|
.command('dashboard')
|
package/claude-plugin.json
CHANGED
package/docs/CHEATSHEET.md
CHANGED
|
@@ -117,7 +117,7 @@ One feature = one git worktree = one Claude Code instance. Each worktree gets it
|
|
|
117
117
|
|
|
118
118
|
```bash
|
|
119
119
|
morph-spec create {feature} --description "<what/why>" --worktree # birth the feature already isolated in its own worktree
|
|
120
|
-
morph-spec worktree setup {feature} # create
|
|
120
|
+
morph-spec worktree setup {feature} # create worktrees/{feature}/ on branch morph/{feature}
|
|
121
121
|
morph-spec worktree link [--all] # relink shared infra (node_modules[, .claude, .morph/framework])
|
|
122
122
|
morph-spec worktree list [--json] # active feature worktrees with phase/gate status
|
|
123
123
|
# Close a worktree feature with `morph-spec finish {feature} --pr|--merge` (see above) — it absorbed `worktree finish`.
|
|
@@ -185,4 +185,4 @@ Never edited directly — go through CLI commands or `morph-spec update` instead
|
|
|
185
185
|
|
|
186
186
|
---
|
|
187
187
|
|
|
188
|
-
*morph-spec v8.
|
|
188
|
+
*morph-spec v8.7.0 by Polymorphism Tech*
|
package/docs/QUICKSTART.md
CHANGED
|
@@ -165,7 +165,7 @@ Independent features can be developed in parallel, each isolated in its own git
|
|
|
165
165
|
```bash
|
|
166
166
|
morph-spec create my-feature --description "what & why" --worktree # birth the feature already isolated
|
|
167
167
|
# or, for an existing feature:
|
|
168
|
-
morph-spec worktree setup my-feature # creates
|
|
168
|
+
morph-spec worktree setup my-feature # creates worktrees/my-feature/ on branch morph/my-feature
|
|
169
169
|
```
|
|
170
170
|
|
|
171
171
|
Open the new worktree directory in its own Claude Code instance and work normally — a SessionStart hook injects the worktree's feature identity into context automatically. When the feature is done (Gate 3 approved), close it in one step:
|
|
@@ -249,4 +249,4 @@ morph-spec doctor
|
|
|
249
249
|
|
|
250
250
|
---
|
|
251
251
|
|
|
252
|
-
*morph-spec v8.
|
|
252
|
+
*morph-spec v8.7.0 by Polymorphism Tech*
|
package/framework/CLAUDE.md
CHANGED
|
@@ -43,7 +43,7 @@ Se não houver feature ativa, pergunte ao usuário qual feature trabalhar.
|
|
|
43
43
|
| `/morph-troubleshoot [erro]` | Diagnóstico de erros .NET/Next.js com root-cause analysis |
|
|
44
44
|
|
|
45
45
|
**Comandos CLI de gate e de estado** (a LLM os usa em vez de editar o estado à mão):
|
|
46
|
-
- `morph-spec approve <feature> <gate> --mode auto` — aprova um gate (`proposal`/`plan`/`review`) em `trust: auto`.
|
|
46
|
+
- `morph-spec approve <feature> <gate> --mode auto` — aprova um gate (`proposal`/`plan`/`review`) em `trust: auto`. **O Gate 3 exige `4-review/evaluator-report.md`**: sem o relatório do avaliador independente o comando recusa (exit 1). Se o dispatch do avaliador for impossível nesta sessão, aprove com `--self-assessed` — a degradação é gravada em `approvalGates.review.selfAssessed` e aparece marcada no `morph-spec list`. `--force` **não** dispensa esta regra (é só para ordem de gate).
|
|
47
47
|
- `morph-spec advance <feature>` — aprova o gate da fase corrente **e** cria a pasta da próxima fase num passo só (`--design` toma o ramo opcional de uiux a partir da proposta).
|
|
48
48
|
- `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.
|
|
49
49
|
- `morph-spec finish <feature> --pr|--merge` — o **encerramento único** da feature (worktree ou comum): **arquiva** commitando `features/{f} → archive/{f}` dentro de `morph/{f}` — promovendo junto, no mesmo commit, os use cases da feature para o acervo permanente `.morph/specs/usecases/` com `status: implemented` (fail-open: sem `2-plan/usecases/`, no-op silencioso) — depois integra. `--merge` 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.
|
|
@@ -55,9 +55,12 @@ Se não houver feature ativa, pergunte ao usuário qual feature trabalhar.
|
|
|
55
55
|
- `morph-spec delete <feature> [--archive] [--force]` — remove uma feature. Hard por padrão (pasta apagada, git-aware, + entrada podada do índice raiz para não ressuscitar); `--archive` move para `.morph/archive` com status `abandoned` em vez de apagar. Recusa se algum gate estiver aprovado, a menos que `--force`. Use para faxina de scaffolds mortos e drafts abandonados (`archive` é para trabalho concluído; `delete` é para trabalho morto).
|
|
56
56
|
- `morph-spec retype <feature> <newType>` — muda o tipo de trabalho mid-flight; desfaz só os gates carimbados por política, preserva aprovações humanas; bloqueado após o Gate 3.
|
|
57
57
|
- `morph-spec verify <feature> [task] [--json]` — cadeia determinística build+testes+validadores (código antes do juiz LLM); escopo de task quando informado, senão a feature inteira.
|
|
58
|
-
- `morph-spec
|
|
58
|
+
- `morph-spec doctor --worktrees [--prune] [--json]` — reconcilia o registro do git × as pastas em disco × as features conhecidas. Worktrees órfãos acumulavam **invisíveis**: o `fleet` só reporta worktrees em branch `morph/*`, então um worktree em outra branch (resíduo de finish anterior) não aparecia em lugar nenhum. `--prune` roda o único reparo seguro (`git worktree prune` — registros de pastas que já sumiram); apagar pasta que ainda existe é reportado com o comando exato, nunca executado.
|
|
59
|
+
- `morph-spec worktree setup|link|list` — gerencia o ciclo do worktree por feature (`setup` cria `worktrees/{f}/` na branch `morph/{f}`, aloca o bloco de portas e escreve `.morph/worktree.env`; `link` religa infra compartilhada; `list` mostra fase/gate de cada worktree ativo). O fechamento saiu daqui — use `morph-spec finish` (acima).
|
|
59
60
|
- `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`.
|
|
60
|
-
- `morph-spec fleet` — visão da frota: toda feature ativa (raiz + worktrees) com fase, gates pendentes, tasks, portas, handoff e atividade. Use entre sessões para decidir onde atuar.
|
|
61
|
+
- `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.
|
|
62
|
+
- `morph-spec report-check <feature> [task] [--json]` — audita os relatórios de sub-agent contra o trabalho que realmente entrou. **Relatório ausente NÃO quer dizer que o agente falhou** — ele pode ter feito tudo e perdido o meio de gravar. O comando responde a pergunta certa (`work-landed-report-missing` → aceite degradado, NÃO re-despache; `no-work-no-report` → re-despache uma vez). Protocolo completo em MORPH.md §6.
|
|
63
|
+
- `morph-spec claim [feature] [--release] [--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. **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`.
|
|
61
64
|
- `morph-spec telemetry [--json] [--feature <f>] [--limit <n>]` — reconstrói o caminho de execução da sessão a partir de `.morph/logs/events.jsonl` (skills invocadas, personas despachadas + prompts, standards/base de conhecimento lidos, mix de tools, prompts do usuário). Capturado silenciosamente pelo hook `telemetry-log` em todo tool call e todo UserPromptSubmit.
|
|
62
65
|
|
|
63
66
|
## Skills core (em `.claude/skills/`)
|
package/framework/MORPH.md
CHANGED
|
@@ -73,7 +73,7 @@ Anti-padrão: o Specialist não cria novas tarefas. Descobertas de escopo durant
|
|
|
73
73
|
|
|
74
74
|
**Evaluator** — ativo após uma tarefa ou fase ser concluída.
|
|
75
75
|
Responsabilidades: aplicar as rubricas em `.morph/framework/evals/rubrics/`, produzir score 0-10, emitir feedback priorizado quando score < 9 (que alimenta o loop de correção autônomo do morph-apply), escalar para revisão humana apenas quando o loop de correção se esgotar (3 tentativas sem atingir 9).
|
|
76
|
-
No **Gate 3** o Evaluator é obrigatoriamente um sub-agent independente: a `morph-review` despacha o subagent registrado `morph-evaluator` (gerado de `agents.json`, tools read-only) com contexto fresco — mandate + diff da implementação + saída real dos testes — e o score composto da feature vem desse retorno, nunca de auto-avaliação de quem implementou.
|
|
76
|
+
No **Gate 3** o Evaluator é obrigatoriamente um sub-agent independente: a `morph-review` despacha o subagent registrado `morph-evaluator` (gerado de `agents.json`, tools read-only) com contexto fresco — mandate + diff da implementação + saída real dos testes — e o score composto da feature vem desse retorno, nunca de auto-avaliação de quem implementou. **A regra é enforced server-side:** `morph-spec approve {feature} review` recusa (exit 1) enquanto `4-review/evaluator-report.md` não existir. Se o dispatch for genuinamente impossível nesta sessão, aprove com `--self-assessed` — a degradação é gravada em `approvalGates.review.selfAssessed` e aparece marcada no `morph-spec list`. `--force` não dispensa a regra (é só para ordem de gate).
|
|
77
77
|
Anti-padrão: o Evaluator não reescreve o código que avaliou. Se o score está abaixo de 6, o relatório vai para o humano — não para uma segunda rodada de tentativa automática.
|
|
78
78
|
|
|
79
79
|
Os papéis não são exclusivos — numa sessão longa você transita entre Orchestrator e Specialist conforme as tarefas avançam. A transição deve ser explícita: antes de mudar de papel, conclua o que estava fazendo ou registre o estado corrente no recap.md.
|
|
@@ -112,11 +112,11 @@ Nem todo trabalho merece o pipeline completo. Cada feature nasce com **identidad
|
|
|
112
112
|
|
|
113
113
|
**Compat:** uma feature sem `workType` (criada antes do v8) é tratada como `feature`. Mudança de tipo mid-flight: `morph-spec retype <f> <tipo>` (desfaz só carimbos de política, preserva aprovações humanas; bloqueado após o Gate 3).
|
|
114
114
|
|
|
115
|
-
**Nascimento numa branch:** toda feature — **com ou sem `--worktree`** — nasce numa branch `morph/{f}` ramificada da default. O fluxo comum (sem worktree) desenvolve direto em `morph/{f}` na raiz; o fluxo worktree isola em
|
|
115
|
+
**Nascimento numa branch:** toda feature — **com ou sem `--worktree`** — nasce numa branch `morph/{f}` ramificada da default. O fluxo comum (sem worktree) desenvolve direto em `morph/{f}` na raiz; o fluxo worktree isola em `worktrees/{f}`. Por construção o fechamento é simétrico: `morph-spec finish <f> --pr|--merge` (§2.2) fecha os dois do mesmo jeito. Features criadas **antes** deste modelo (sem branch `morph/{f}`) fecham por `morph-spec archive <f>` (arquivar-sem-integrar), não por `finish`.
|
|
116
116
|
|
|
117
117
|
### 2.2 Desenvolvimento paralelo por worktree (frota)
|
|
118
118
|
|
|
119
|
-
Features independentes podem ser desenvolvidas **em paralelo, cada uma no seu git worktree**, com uma instância de Claude Code por worktree. `morph-spec create <f> --description "<o quê/porquê>" --worktree` (ou `morph-spec worktree setup <f>`) cria
|
|
119
|
+
Features independentes podem ser desenvolvidas **em paralelo, cada uma no seu git worktree**, com uma instância de Claude Code por worktree. `morph-spec create <f> --description "<o quê/porquê>" --worktree` (ou `morph-spec worktree setup <f>`) cria `worktrees/{f}/` na branch `morph/{f}`. Contrato:
|
|
120
120
|
|
|
121
121
|
- **Uma feature = um worktree = uma instância.** Nunca abra duas instâncias na mesma feature (o setup recusa com exit 3).
|
|
122
122
|
- O estado autoritativo (`feature.json`) viaja **commitado na branch** `morph/{f}`. O `state.json` local do worktree é um índice derivável — materializado por self-heal, nunca sincronizado à mão.
|
|
@@ -307,6 +307,31 @@ O orquestrador **lê o arquivo** ao retomar controle após o dispatch — nunca
|
|
|
307
307
|
mensagem de retorno da tool. Se a mensagem de texto final chegou inteira, ótimo (economiza uma
|
|
308
308
|
leitura); se não chegou, o arquivo já está lá.
|
|
309
309
|
|
|
310
|
+
### Protocolo de ausência de relatório
|
|
311
|
+
|
|
312
|
+
**"Relatório ausente = o agente falhou" é a leitura errada.** O sub-agent pode ter feito todo o
|
|
313
|
+
trabalho e perdido o meio de gravá-lo — foi o que aconteceu quando um worktree sumiu debaixo de um
|
|
314
|
+
avaliador em execução. Tratar isso como falha joga fora trabalho real.
|
|
315
|
+
|
|
316
|
+
A pergunta que decide não é "o relatório chegou?", é **"o TRABALHO entrou?"** — e ela se responde
|
|
317
|
+
por evidência, não pela palavra do agente:
|
|
318
|
+
|
|
319
|
+
```
|
|
320
|
+
morph-spec report-check <feature> [task] --json
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
| veredito | o que significa | ação |
|
|
324
|
+
|---|---|---|
|
|
325
|
+
| `ok` | relatório presente e com os 4 itens | siga o fluxo |
|
|
326
|
+
| `report-incomplete` | relatório existe, faltam itens (ele lista quais) | complete a partir da mensagem de retorno, carimbe **DEGRADADO** |
|
|
327
|
+
| `work-landed-report-missing` | sem relatório, mas os `outputs` da task estão no disco | **não re-despache** — grave você mesmo o relatório canônico a partir da mensagem de retorno, carimbado **DEGRADADO** |
|
|
328
|
+
| `no-work-no-report` | sem relatório e sem output | re-despache **uma** vez com o mesmo prompt |
|
|
329
|
+
| `report-missing-evidence-unknown` | sem relatório e a task não declarou `outputs` | re-despache (sem evidência, o conservador é o certo) |
|
|
330
|
+
|
|
331
|
+
Sai 0 só em `ok`. Em qualquer caminho degradado a degradação é **registrada** — no recap e nas
|
|
332
|
+
`notes` da task —, nunca aceita em silêncio. Segunda ausência sem trabalho entregue é falha real:
|
|
333
|
+
escale ao humano.
|
|
334
|
+
|
|
310
335
|
**O que receber de volta (no arquivo, e na mensagem final quando ela chega):**
|
|
311
336
|
- O que foi feito (2-3 frases)
|
|
312
337
|
- Arquivos modificados (lista)
|
|
@@ -59,6 +59,21 @@ try {
|
|
|
59
59
|
// enrichment is best-effort — a missing feature never blocks capture
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
// Claim heartbeat (B1): this hook already fires on EVERY tool call, so it is
|
|
63
|
+
// the natural proof-of-life for the advisory feature claim — no extra hook,
|
|
64
|
+
// no discipline asked of the operator. The registry throttles the actual
|
|
65
|
+
// write (HEARTBEAT_THROTTLE_MS), so this is a small read on most calls.
|
|
66
|
+
// Silent and fail-open, like everything else here.
|
|
67
|
+
if (feature && payload.session_id) {
|
|
68
|
+
try {
|
|
69
|
+
const { execSync } = await import('child_process');
|
|
70
|
+
const { resolve } = await import('path');
|
|
71
|
+
const { heartbeatClaim } = await import('../../shared/claim-registry.js');
|
|
72
|
+
const common = execSync('git rev-parse --git-common-dir', { cwd, stdio: 'pipe' }).toString().trim();
|
|
73
|
+
if (common) heartbeatClaim(feature, { gitCommonDir: resolve(cwd, common), sessionId: payload.session_id });
|
|
74
|
+
} catch { /* fail-open — a missed heartbeat only shortens a claim's life */ }
|
|
75
|
+
}
|
|
76
|
+
|
|
62
77
|
let event;
|
|
63
78
|
|
|
64
79
|
// UserPromptSubmit carries the user's prompt and no tool_name.
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Event: SessionStart
|
|
6
6
|
*
|
|
7
|
-
* v8 runs each feature in its own git worktree (
|
|
7
|
+
* v8 runs each feature in its own git worktree (`worktrees/{feature}/` on
|
|
8
8
|
* branch `morph/{feature}`), each with its own Claude Code instance. When a
|
|
9
9
|
* session starts inside one of those worktrees, Claude has no built-in way to
|
|
10
10
|
* know which feature it belongs to — this hook injects that identity so the
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
*/
|
|
30
30
|
|
|
31
31
|
import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
32
|
-
import { join } from 'path';
|
|
32
|
+
import { join, resolve } from 'path';
|
|
33
33
|
import { execSync } from 'child_process';
|
|
34
34
|
import {
|
|
35
35
|
stateExists,
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
getTaskStats,
|
|
40
40
|
} from '../../shared/state-reader.js';
|
|
41
41
|
import { injectContext, pass } from '../../shared/hook-response.js';
|
|
42
|
+
import { acquireClaim, foreignClaim, claimAge } from '../../shared/claim-registry.js';
|
|
42
43
|
|
|
43
44
|
function readStdin() {
|
|
44
45
|
try {
|
|
@@ -168,10 +169,44 @@ function buildActiveFeatureSummary(dir) {
|
|
|
168
169
|
return text;
|
|
169
170
|
}
|
|
170
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Advisory feature ownership (B1): claim the feature this session is about to
|
|
174
|
+
* work on, and — if another session already holds a LIVE claim — say so loudly
|
|
175
|
+
* before any work starts.
|
|
176
|
+
*
|
|
177
|
+
* Never blocks: the claim is a warning, not a lock (see claim-registry.js).
|
|
178
|
+
* The claim is taken regardless, so the registry reflects reality; the operator
|
|
179
|
+
* decides what to do with the collision.
|
|
180
|
+
*
|
|
181
|
+
* Fail-open per FIELD: no git common dir, or any registry error, just means no
|
|
182
|
+
* claim line — never a failed session start.
|
|
183
|
+
*
|
|
184
|
+
* @returns {string} a block to append to the injected context ('' when quiet)
|
|
185
|
+
*/
|
|
186
|
+
function claimBlock(dir, feature, sessionId) {
|
|
187
|
+
try {
|
|
188
|
+
const gitCommonDir = resolve(dir, gitRevParse('--git-common-dir', dir));
|
|
189
|
+
const other = foreignClaim(feature, { gitCommonDir, sessionId });
|
|
190
|
+
acquireClaim(feature, { gitCommonDir, sessionId, pid: process.pid, path: dir });
|
|
191
|
+
if (!other) return '';
|
|
192
|
+
return `\n⚠️ ATENÇÃO — a feature ${feature} tem claim VIVO de OUTRA sessão`
|
|
193
|
+
+ `\n pid ${other.pid}${other.path ? ` · ${other.path}` : ''} · visto há ${claimAge(other)}`
|
|
194
|
+
+ '\n Duas sessões na mesma feature já custaram um recap.md inteiro e um sub-agent'
|
|
195
|
+
+ '\n (o worktree sumiu debaixo dele). Confirme com a outra sessão ANTES de agir —'
|
|
196
|
+
+ '\n principalmente antes de `finish`, `archive` ou `delete`.'
|
|
197
|
+
+ `\n Se aquela sessão morreu: morph-spec claim ${feature} --release`;
|
|
198
|
+
} catch {
|
|
199
|
+
return '';
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
171
203
|
async function main() {
|
|
172
204
|
try {
|
|
173
205
|
const dir = projectDir();
|
|
174
|
-
|
|
206
|
+
// The SessionStart payload carries session_id — the identity the claim
|
|
207
|
+
// registry uses to tell "me" from "another session".
|
|
208
|
+
const payload = readStdin();
|
|
209
|
+
const sessionId = payload?.session_id || `pid-${process.pid}`;
|
|
175
210
|
|
|
176
211
|
if (!stateExists(dir)) {
|
|
177
212
|
pass();
|
|
@@ -184,7 +219,7 @@ async function main() {
|
|
|
184
219
|
pass();
|
|
185
220
|
return;
|
|
186
221
|
}
|
|
187
|
-
injectContext(buildWorktreeContext(dir, feature), 'SessionStart');
|
|
222
|
+
injectContext(buildWorktreeContext(dir, feature) + claimBlock(dir, feature, sessionId), 'SessionStart');
|
|
188
223
|
return;
|
|
189
224
|
}
|
|
190
225
|
|
|
@@ -194,7 +229,8 @@ async function main() {
|
|
|
194
229
|
pass();
|
|
195
230
|
return;
|
|
196
231
|
}
|
|
197
|
-
|
|
232
|
+
const active = getActiveFeature(dir);
|
|
233
|
+
injectContext(summary + (active?.name ? claimBlock(dir, active.name, sessionId) : ''), 'SessionStart');
|
|
198
234
|
} catch {
|
|
199
235
|
pass();
|
|
200
236
|
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* claim-registry.js — advisory feature ownership across parallel sessions.
|
|
3
|
+
*
|
|
4
|
+
* Two Claude Code sessions worked the SAME feature at the same time without
|
|
5
|
+
* seeing each other: one finished it and removed the worktree out from under
|
|
6
|
+
* the other's running sub-agent. A complete recap.md was discarded, a sub-agent
|
|
7
|
+
* wasted, and a decision taken on state that was already obsolete. There was no
|
|
8
|
+
* lock, no claim, no heartbeat — not even a warning.
|
|
9
|
+
*
|
|
10
|
+
* DELIBERATELY ADVISORY. This is not a lock: nothing here ever blocks a
|
|
11
|
+
* command. The framework is fail-open by principle, and a strong lock left
|
|
12
|
+
* behind by a crashed session would strand a feature for no gain. Being
|
|
13
|
+
* VISIBLE is the whole fix.
|
|
14
|
+
*
|
|
15
|
+
* Location: `<git-common-dir>/morph-spec/claims.json`, beside the fleet's
|
|
16
|
+
* env-registry, for the same three reasons — it can never dirty the `git
|
|
17
|
+
* status` the finish gates read, it survives `git clean -fdx`, and the COMMON
|
|
18
|
+
* dir is the one place every linked worktree of the repo shares. That last one
|
|
19
|
+
* is decisive here: in the observed incident one session sat inside the
|
|
20
|
+
* worktree and the other in the primary root, so a claim written to the
|
|
21
|
+
* branch-local `feature.json` would have been invisible to exactly the session
|
|
22
|
+
* that needed to read it.
|
|
23
|
+
*
|
|
24
|
+
* Writes are tmp+rename (torn file impossible); a corrupt registry reads as
|
|
25
|
+
* empty — the cost is a lost warning, never a crash.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
|
|
29
|
+
import { join } from 'path';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* A claim with no heartbeat for this long is considered dead. The heartbeat
|
|
33
|
+
* fires on tool activity, so this is really "how long a session may sit idle
|
|
34
|
+
* before its claim stops warning others".
|
|
35
|
+
*/
|
|
36
|
+
export const CLAIM_TTL_MS = 30 * 60 * 1000;
|
|
37
|
+
|
|
38
|
+
/** Minimum gap between heartbeat WRITES. The heartbeat runs on every tool call. */
|
|
39
|
+
export const HEARTBEAT_THROTTLE_MS = 60 * 1000;
|
|
40
|
+
|
|
41
|
+
/** Claim file path for a repo's common git dir. */
|
|
42
|
+
export function claimsPath(gitCommonDir) {
|
|
43
|
+
return join(gitCommonDir, 'morph-spec', 'claims.json');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readRegistry(gitCommonDir) {
|
|
47
|
+
try {
|
|
48
|
+
const raw = readFileSync(claimsPath(gitCommonDir), 'utf8').replace(/^/, '');
|
|
49
|
+
const parsed = JSON.parse(raw);
|
|
50
|
+
if (parsed && typeof parsed.claims === 'object' && parsed.claims !== null) return parsed;
|
|
51
|
+
} catch { /* fail-open: absent or corrupt registry restarts empty */ }
|
|
52
|
+
return { version: 1, claims: {} };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function writeRegistry(gitCommonDir, registry) {
|
|
56
|
+
const path = claimsPath(gitCommonDir);
|
|
57
|
+
mkdirSync(join(gitCommonDir, 'morph-spec'), { recursive: true });
|
|
58
|
+
const tmp = `${path}.tmp.${process.pid}`;
|
|
59
|
+
writeFileSync(tmp, `${JSON.stringify(registry, null, 2)}\n`, 'utf8');
|
|
60
|
+
renameSync(tmp, path);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Is this claim still within its TTL? A missing/unparseable heartbeat is dead. */
|
|
64
|
+
export function isLive(claim, now = Date.now()) {
|
|
65
|
+
const beat = Date.parse(claim?.heartbeatAt ?? '');
|
|
66
|
+
return Number.isFinite(beat) && now - beat < CLAIM_TTL_MS;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Claim a feature for this session, or refresh an existing claim of ours.
|
|
71
|
+
*
|
|
72
|
+
* A claim held by ANOTHER session is TAKEN OVER, not refused — advisory by
|
|
73
|
+
* design, and the caller has already been warned via `foreignClaim`. The
|
|
74
|
+
* takeover is recorded in `previousSessionId` so it is never silent.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} feature
|
|
77
|
+
* @param {{ gitCommonDir: string, sessionId: string, pid?: number, path?: string }} opts
|
|
78
|
+
* @returns {object} the stored claim
|
|
79
|
+
*/
|
|
80
|
+
export function acquireClaim(feature, { gitCommonDir, sessionId, pid = process.pid, path = null }) {
|
|
81
|
+
const registry = readRegistry(gitCommonDir);
|
|
82
|
+
const previous = registry.claims[feature];
|
|
83
|
+
const now = new Date().toISOString();
|
|
84
|
+
const claim = {
|
|
85
|
+
feature,
|
|
86
|
+
sessionId,
|
|
87
|
+
pid,
|
|
88
|
+
path: path ? String(path).replace(/\\/g, '/') : null,
|
|
89
|
+
claimedAt: previous?.sessionId === sessionId ? (previous.claimedAt || now) : now,
|
|
90
|
+
heartbeatAt: now,
|
|
91
|
+
...(previous && previous.sessionId !== sessionId ? { previousSessionId: previous.sessionId } : {}),
|
|
92
|
+
};
|
|
93
|
+
registry.claims[feature] = claim;
|
|
94
|
+
writeRegistry(gitCommonDir, registry);
|
|
95
|
+
return claim;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Refresh our own claim's heartbeat. Throttled — this runs on every tool call,
|
|
100
|
+
* and a write per call would be pure I/O waste.
|
|
101
|
+
*
|
|
102
|
+
* Never touches a claim owned by another session: a heartbeat is proof of life,
|
|
103
|
+
* and forging someone else's would defeat the TTL.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} feature
|
|
106
|
+
* @param {{ gitCommonDir: string, sessionId: string }} opts
|
|
107
|
+
* @returns {boolean} true when a write actually happened
|
|
108
|
+
*/
|
|
109
|
+
export function heartbeatClaim(feature, { gitCommonDir, sessionId }) {
|
|
110
|
+
const registry = readRegistry(gitCommonDir);
|
|
111
|
+
const claim = registry.claims[feature];
|
|
112
|
+
if (!claim || claim.sessionId !== sessionId) return false;
|
|
113
|
+
|
|
114
|
+
const beat = Date.parse(claim.heartbeatAt ?? '');
|
|
115
|
+
if (Number.isFinite(beat) && Date.now() - beat < HEARTBEAT_THROTTLE_MS) return false;
|
|
116
|
+
|
|
117
|
+
claim.heartbeatAt = new Date().toISOString();
|
|
118
|
+
writeRegistry(gitCommonDir, registry);
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The LIVE claim on this feature held by a session that is not us — the thing
|
|
124
|
+
* worth warning about. Returns null when the feature is unclaimed, claimed by
|
|
125
|
+
* us, or claimed by a session whose heartbeat went stale.
|
|
126
|
+
*
|
|
127
|
+
* @param {string} feature
|
|
128
|
+
* @param {{ gitCommonDir: string, sessionId?: string }} opts
|
|
129
|
+
* @returns {object|null}
|
|
130
|
+
*/
|
|
131
|
+
export function foreignClaim(feature, { gitCommonDir, sessionId }) {
|
|
132
|
+
const claim = readRegistry(gitCommonDir).claims[feature];
|
|
133
|
+
if (!claim) return null;
|
|
134
|
+
if (sessionId && claim.sessionId === sessionId) return null;
|
|
135
|
+
return isLive(claim) ? claim : null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Drop a feature's claim. Called by the manual `morph-spec claim <f> --release`
|
|
140
|
+
* (recovering from a session that died without releasing) and by `finish`.
|
|
141
|
+
*
|
|
142
|
+
* @param {string} feature
|
|
143
|
+
* @param {{ gitCommonDir: string }} opts
|
|
144
|
+
* @returns {boolean} false when there was nothing to release
|
|
145
|
+
*/
|
|
146
|
+
export function releaseClaim(feature, { gitCommonDir }) {
|
|
147
|
+
const registry = readRegistry(gitCommonDir);
|
|
148
|
+
if (!registry.claims[feature]) return false;
|
|
149
|
+
delete registry.claims[feature];
|
|
150
|
+
writeRegistry(gitCommonDir, registry);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Every claim on record, live and stale, newest heartbeat first. `live` is
|
|
156
|
+
* computed, never stored — a stored flag would go wrong the moment nobody
|
|
157
|
+
* rewrites the file.
|
|
158
|
+
*
|
|
159
|
+
* @param {string} gitCommonDir
|
|
160
|
+
* @returns {object[]}
|
|
161
|
+
*/
|
|
162
|
+
export function listClaims(gitCommonDir) {
|
|
163
|
+
const now = Date.now();
|
|
164
|
+
return Object.values(readRegistry(gitCommonDir).claims)
|
|
165
|
+
.map((c) => ({ ...c, live: isLive(c, now) }))
|
|
166
|
+
.sort((a, b) => String(b.heartbeatAt).localeCompare(String(a.heartbeatAt)));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Human-readable age of a claim's last heartbeat ("3 min", "2 h", "—"). */
|
|
170
|
+
export function claimAge(claim, now = Date.now()) {
|
|
171
|
+
const beat = Date.parse(claim?.heartbeatAt ?? '');
|
|
172
|
+
if (!Number.isFinite(beat)) return '—';
|
|
173
|
+
const min = Math.max(0, Math.round((now - beat) / 60000));
|
|
174
|
+
return min < 60 ? `${min} min` : `${Math.round(min / 60)} h`;
|
|
175
|
+
}
|
|
@@ -181,6 +181,63 @@ export function getFeature(featureName, projectPath) {
|
|
|
181
181
|
*/
|
|
182
182
|
export const PHASE_INDEX = { proposal: 0, uiux: 1, plan: 2, implement: 3, review: 4 };
|
|
183
183
|
|
|
184
|
+
/**
|
|
185
|
+
* The canonical artifact of each phase, descending. Exported so a caller that
|
|
186
|
+
* holds the file list from somewhere OTHER than the local filesystem (a git
|
|
187
|
+
* tree, say) can answer the same question without re-encoding the map.
|
|
188
|
+
*/
|
|
189
|
+
export const PHASE_ARTIFACTS = [
|
|
190
|
+
['4-review', 'review', 'review-report.md'],
|
|
191
|
+
['3-implement', 'implement', 'recap.md'],
|
|
192
|
+
['2-plan', 'plan', 'spec.md'],
|
|
193
|
+
['1-design', 'uiux', 'design-system.md'],
|
|
194
|
+
['0-proposal', 'proposal', 'proposal.md'],
|
|
195
|
+
];
|
|
196
|
+
|
|
197
|
+
const gatesOf = (feature) => feature?.approvals || feature?.approvalGates;
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Gate-implied phase from an ALREADY-LOADED feature object — no disk access.
|
|
201
|
+
*
|
|
202
|
+
* Split out of `gateImpliedPhase` so callers holding a feature.json that did
|
|
203
|
+
* not come from the working tree (e.g. `git show morph/{f}:…`) can reuse the
|
|
204
|
+
* rule instead of copying it. The path-based wrapper below is unchanged.
|
|
205
|
+
*
|
|
206
|
+
* @param {Object|null} feature
|
|
207
|
+
* @returns {string} phase name
|
|
208
|
+
*/
|
|
209
|
+
export function gateImpliedPhaseFrom(feature) {
|
|
210
|
+
const gates = gatesOf(feature);
|
|
211
|
+
const ok = (g) => gates?.[g]?.approved === true;
|
|
212
|
+
if (ok('review')) return 'review';
|
|
213
|
+
if (ok('plan')) return 'implement';
|
|
214
|
+
if (ok('uiux')) return 'plan';
|
|
215
|
+
return 'proposal';
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Phase from an already-loaded feature plus an artifact predicate — no disk
|
|
220
|
+
* access of its own, so the artifact universe can be a git tree.
|
|
221
|
+
*
|
|
222
|
+
* @param {Object|null} feature
|
|
223
|
+
* @param {(relPath: string) => boolean} hasArtifact - receives POSIX-style
|
|
224
|
+
* paths relative to the feature dir, e.g. `2-plan/spec.md`
|
|
225
|
+
* @returns {string} phase name
|
|
226
|
+
*/
|
|
227
|
+
export function derivePhaseFrom(feature, hasArtifact) {
|
|
228
|
+
let artifactPhase = 'proposal';
|
|
229
|
+
for (const [folder, phase, canonical] of PHASE_ARTIFACTS) {
|
|
230
|
+
if (hasArtifact(`${folder}/${canonical}`)) { artifactPhase = phase; break; }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Gate-aware: an approved gate advances the phase even before the next
|
|
234
|
+
// phase's first canonical artifact exists. This breaks the chicken-and-egg
|
|
235
|
+
// where that first artifact could never be written because the phase never
|
|
236
|
+
// advanced to permit it. Take whichever of (artifact, gate-implied) is later.
|
|
237
|
+
const implied = gateImpliedPhaseFrom(feature);
|
|
238
|
+
return PHASE_INDEX[implied] > PHASE_INDEX[artifactPhase] ? implied : artifactPhase;
|
|
239
|
+
}
|
|
240
|
+
|
|
184
241
|
/**
|
|
185
242
|
* The phase implied purely by approved gates, independent of which artifacts
|
|
186
243
|
* exist on disk. An approved gate authorizes the NEXT phase, so:
|
|
@@ -192,32 +249,15 @@ export const PHASE_INDEX = { proposal: 0, uiux: 1, plan: 2, implement: 3, review
|
|
|
192
249
|
* @returns {string} phase name
|
|
193
250
|
*/
|
|
194
251
|
export function gateImpliedPhase(featureName, projectPath) {
|
|
195
|
-
|
|
196
|
-
if (isGateApproved(featureName, 'plan', projectPath)) return 'implement';
|
|
197
|
-
if (isGateApproved(featureName, 'uiux', projectPath)) return 'plan';
|
|
198
|
-
return 'proposal';
|
|
252
|
+
return gateImpliedPhaseFrom(getFeature(featureName, projectPath));
|
|
199
253
|
}
|
|
200
254
|
|
|
201
255
|
export function derivePhaseForFeature(featureName, projectPath) {
|
|
202
256
|
const basePath = join(projectPath || process.cwd(), '.morph/features', featureName);
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
['1-design', 'uiux', 'design-system.md'],
|
|
208
|
-
['0-proposal', 'proposal', 'proposal.md'],
|
|
209
|
-
];
|
|
210
|
-
let artifactPhase = 'proposal';
|
|
211
|
-
for (const [folder, phase, canonical] of phaseMap) {
|
|
212
|
-
if (existsSync(join(basePath, folder, canonical))) { artifactPhase = phase; break; }
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// Gate-aware: an approved gate advances the phase even before the next
|
|
216
|
-
// phase's first canonical artifact exists. This breaks the chicken-and-egg
|
|
217
|
-
// where that first artifact could never be written because the phase never
|
|
218
|
-
// advanced to permit it. Take whichever of (artifact, gate-implied) is later.
|
|
219
|
-
const implied = gateImpliedPhase(featureName, projectPath);
|
|
220
|
-
return PHASE_INDEX[implied] > PHASE_INDEX[artifactPhase] ? implied : artifactPhase;
|
|
257
|
+
return derivePhaseFrom(
|
|
258
|
+
getFeature(featureName, projectPath),
|
|
259
|
+
(rel) => existsSync(join(basePath, rel)),
|
|
260
|
+
);
|
|
221
261
|
}
|
|
222
262
|
|
|
223
263
|
/**
|
|
@@ -253,10 +293,9 @@ export function isGateApproved(featureName, gate, projectPath) {
|
|
|
253
293
|
* @param {string} [projectPath]
|
|
254
294
|
* @returns {string[]} List of gate names that are not yet approved
|
|
255
295
|
*/
|
|
256
|
-
export function
|
|
257
|
-
const feature = getFeature(featureName, projectPath);
|
|
296
|
+
export function pendingGatesFrom(feature) {
|
|
258
297
|
// Support both field names: 'approvals' (v5 state) and 'approvalGates' (legacy)
|
|
259
|
-
const gates = feature
|
|
298
|
+
const gates = gatesOf(feature);
|
|
260
299
|
if (!gates) return [];
|
|
261
300
|
|
|
262
301
|
return Object.entries(gates)
|
|
@@ -264,6 +303,10 @@ export function getPendingGates(featureName, projectPath) {
|
|
|
264
303
|
.map(([name]) => name);
|
|
265
304
|
}
|
|
266
305
|
|
|
306
|
+
export function getPendingGates(featureName, projectPath) {
|
|
307
|
+
return pendingGatesFrom(getFeature(featureName, projectPath));
|
|
308
|
+
}
|
|
309
|
+
|
|
267
310
|
/**
|
|
268
311
|
* Get feature outputs status.
|
|
269
312
|
* @param {string} featureName
|