@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.
Files changed (44) hide show
  1. package/README.md +4 -4
  2. package/bin/morph-spec.js +23 -1
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +2 -2
  5. package/docs/QUICKSTART.md +2 -2
  6. package/framework/CLAUDE.md +6 -3
  7. package/framework/MORPH.md +28 -3
  8. package/framework/hooks/claude-code/core/telemetry-log.js +15 -0
  9. package/framework/hooks/claude-code/core/worktree-context.js +41 -5
  10. package/framework/hooks/shared/claim-registry.js +175 -0
  11. package/framework/hooks/shared/state-reader.js +68 -25
  12. package/framework/rules/morph-task-tracking.md +14 -8
  13. package/framework/rules/morph-workflow.md +34 -1
  14. package/framework/schemas/feature.schema.json +2 -1
  15. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  16. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  17. package/framework/skills/core/morph-apply/SKILL.md +1 -1
  18. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  19. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  20. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  21. package/framework/skills/core/morph-implement/SKILL.md +1 -1
  22. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  23. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  24. package/framework/skills/core/morph-review/SKILL.md +7 -1
  25. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  26. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  27. package/package.json +1 -1
  28. package/src/commands/project/claim.js +91 -0
  29. package/src/commands/project/doctor.js +65 -0
  30. package/src/commands/project/fleet.js +37 -2
  31. package/src/commands/project/worktree.js +70 -7
  32. package/src/commands/state/advance.js +60 -1
  33. package/src/commands/state/approve.js +125 -2
  34. package/src/commands/state/create.js +3 -3
  35. package/src/commands/state/finish.js +88 -3
  36. package/src/commands/state/list.js +9 -1
  37. package/src/commands/state/report-check.js +202 -0
  38. package/src/core/state/lifecycle-audit.js +57 -0
  39. package/src/dashboard/scanner/state-scanner.js +11 -3
  40. package/src/lib/generators/architecture-scanner.js +1 -1
  41. package/src/lib/tasks/tasks-json-parser.js +8 -0
  42. package/src/lib/worktree/fleet-scanner.js +224 -15
  43. package/src/lib/worktree/worktree-audit.js +181 -0
  44. 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.0
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 `.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.
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 .worktrees/{feature}/ on branch morph/{feature}
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.6.0 by [Polymorphism Tech](https://polymorphism.tech)*
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 .worktrees/{feature}/ on branch morph/{feature}')
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')
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "morph-spec",
3
- "version": "8.6.0",
3
+ "version": "8.7.0",
4
4
  "displayName": "MORPH-SPEC Framework",
5
5
  "description": "Spec-driven development with 38 agents and 8-phase workflow for .NET/Blazor/Next.js/Azure",
6
6
  "publisher": "polymorphism-tech",
@@ -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 .worktrees/{feature}/ on branch morph/{feature}
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.6.0 by Polymorphism Tech*
188
+ *morph-spec v8.7.0 by Polymorphism Tech*
@@ -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 .worktrees/my-feature/ on branch morph/my-feature
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.6.0 by Polymorphism Tech*
252
+ *morph-spec v8.7.0 by Polymorphism Tech*
@@ -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 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).
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/`)
@@ -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 `.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`.
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 `.worktrees/{f}/` na branch `morph/{f}`. Contrato:
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 (`.worktrees/{feature}/` on
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
- readStdin(); // consume the SessionStart payload (source/session_id) — unused here
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
- injectContext(summary, 'SessionStart');
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
- if (isGateApproved(featureName, 'review', projectPath)) return 'review';
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
- const phaseMap = [
204
- ['4-review', 'review', 'review-report.md'],
205
- ['3-implement', 'implement', 'recap.md'],
206
- ['2-plan', 'plan', 'spec.md'],
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 getPendingGates(featureName, projectPath) {
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?.approvals || feature?.approvalGates;
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