@polymorphism-tech/morph-spec 8.5.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 +12 -7
- package/framework/MORPH.md +49 -6
- package/framework/evals/rubrics/test-coverage.md +4 -1
- 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/phase-utils.js +1 -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 +36 -3
- package/framework/schemas/feature.schema.json +2 -1
- package/framework/schemas/tasks.schema.json +6 -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 +4 -3
- 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 +83 -13
- 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/archive.js +44 -5
- package/src/commands/state/create.js +3 -3
- package/src/commands/state/finish.js +103 -7
- package/src/commands/state/list.js +9 -1
- package/src/commands/state/report-check.js +202 -0
- package/src/commands/tasks/dag.js +25 -2
- package/src/commands/validation/validate-feature.js +9 -2
- package/src/core/paths/output-schema.js +66 -6
- package/src/core/state/lifecycle-audit.js +57 -0
- package/src/dashboard/scanner/state-scanner.js +11 -3
- package/src/lib/agents/output-collector.js +5 -0
- package/src/lib/generators/architecture-scanner.js +1 -1
- package/src/lib/generators/handoff-generator.js +4 -2
- package/src/lib/specs/usecase-check.js +173 -0
- package/src/lib/specs/usecase-promoter.js +120 -0
- package/src/lib/tasks/tasks-json-parser.js +16 -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
|
@@ -15,7 +15,9 @@ Se não houver feature ativa, pergunte ao usuário qual feature trabalhar.
|
|
|
15
15
|
|
|
16
16
|
**NUNCA:**
|
|
17
17
|
- Pular para código sem proposal aprovado (Gate 1) e plano aprovado (Gate 2).
|
|
18
|
-
- Modificar o contrato do plano depois do Gate 2: `spec.md`, `mandate.md`, e os campos de plano de `tasks.json` (`title`, `description`, `dependencies`, `effort`, `doneCriteria`, `group`) estão congelados.
|
|
18
|
+
- Modificar o contrato do plano depois do Gate 2: `spec.md`, `mandate.md`, e os campos de plano de `tasks.json` (`title`, `description`, `dependencies`, `effort`, `doneCriteria`, `group`) estão congelados. **Os use cases (`2-plan/usecases/UC-*.md`) NÃO estão** — são editáveis por design, é assim que se muda o comportamento e se re-roda `implement`; a auditoria fica no git.
|
|
19
|
+
- Escrever critérios de aceite no `proposal.md`. O comportamento verificável mora nos use cases, cuja pós-condição precisa ser **observável em teste** e chega **literal** ao `doneCriteria` da task.
|
|
20
|
+
- Editar `.morph/specs/usecases/` à mão. O acervo só se escreve pela promoção do `finish`, que lê de `2-plan/usecases/` — é ela que carimba `status: implemented` e sobrescreve por ID. Para mudar um UC existente (o caso `bug`), copie-o para o `2-plan/usecases/` da feature e corrija lá.
|
|
19
21
|
- Tocar os campos de contrato do plano em `tasks.json` após o Gate 2 (`title`, `description`, `dependencies`, `effort`, `doneCriteria`, `group`) — o hook `protect-spec-files` faz diff semântico e bloqueia mudanças neles, seja via `Write` ou `Edit`. (Os campos mutáveis `status`/`outputs`/`notes` continuam graváveis por `Write` ou `Edit` — ver SEMPRE.)
|
|
20
22
|
- Listar perguntas em texto simples — sempre use a tool `AskUserQuestion` (1-4 perguntas por chamada).
|
|
21
23
|
- Usar `Guid.NewGuid()`, classes não-sealed sem justificativa, exceptions como fluxo de controle, camada de Service/Manager entre handler e dados, repository genérico (`IRepository<T>`/`IUnitOfWork`), ou SQL cru sem justificativa.
|
|
@@ -41,11 +43,11 @@ Se não houver feature ativa, pergunte ao usuário qual feature trabalhar.
|
|
|
41
43
|
| `/morph-troubleshoot [erro]` | Diagnóstico de erros .NET/Next.js com root-cause analysis |
|
|
42
44
|
|
|
43
45
|
**Comandos CLI de gate e de estado** (a LLM os usa em vez de editar o estado à mão):
|
|
44
|
-
- `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).
|
|
45
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).
|
|
46
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.
|
|
47
|
-
- `morph-spec finish <feature> --pr|--merge` — o **encerramento único** da feature (worktree ou comum): **arquiva** commitando `features/{f} → archive/{f}` dentro de `morph/{f}
|
|
48
|
-
- `morph-spec dag <feature>` — análise do DAG de tasks.json com decisão sequencial vs paralelo **ponderada por effort** (raízes S vão inline, nunca ganham agente próprio).
|
|
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.
|
|
50
|
+
- `morph-spec dag <feature>` — análise do DAG de tasks.json com decisão sequencial vs paralelo **ponderada por effort** (raízes S vão inline, nunca ganham agente próprio). Também cruza `tasks.json` com os use cases: `usecase` apontando para um `UC-{nn}` inexistente é **erro** (exit 1, não despacha); pós-condição órfã e `doneCriteria` que parafraseou em vez de copiar são avisos. `--mermaid` nunca é bloqueado.
|
|
49
51
|
- `morph-spec score <feature> <task> <score> [--dims a,c,q,t]` — grava `taskScores` no feature.json com timestamp server-side; **nunca edite taskScores à mão**. Em PowerShell, cite o valor de `--dims` entre aspas (`--dims "9.5,9,9,9"`) — sem aspas, a lista pode chegar como tokens separados; o parser aceita ambas as formas, mas citar evita qualquer ambiguidade.
|
|
50
52
|
- `morph-spec gate-decision <feature> <gate> --pause <bool> [--reason <txt>] [--signals <json>]` — grava `gateDecisions` no feature.json com timestamp server-side (shape em `framework/schemas/feature.schema.json`); **nunca edite gateDecisions à mão** — o Edit manual colide com o hook state-sync.
|
|
51
53
|
- `morph-spec create <feature> --description "<o quê/porquê>" --type <feature|bug|chore|hotfix> [--request "<txt>"] [--name "<nome>"] [--worktree]` — cria a estrutura da feature + `feature.json` canônico explicitamente (nascimento explícito no início da proposal; o hook state-sync continua cobrindo o caminho implícito como fallback). **Identidade no nascimento:** `name` (default = slug) e `description` são carimbados; a `--description` é **obrigatória** numa feature nova (ou `--request`, que a semeia) — sem nenhuma das duas o `create` recusa (fim dos scaffolds anônimos). `--type` classifica direto; `--request "<pedido>"` roda o detector determinístico (empate/sem sinal sai com exit 2 e candidatos — a LLM decide e re-roda com `--type` + `--request`/`--description`); `--worktree` já nasce a feature no seu worktree próprio. **Toda feature nasce numa branch `morph/{f}`** (ramificada da default) mesmo sem `--worktree` — por isso o fechamento por `morph-spec finish` é idêntico nos dois fluxos.
|
|
@@ -53,9 +55,12 @@ Se não houver feature ativa, pergunte ao usuário qual feature trabalhar.
|
|
|
53
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).
|
|
54
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.
|
|
55
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.
|
|
56
|
-
- `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).
|
|
57
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`.
|
|
58
|
-
- `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`.
|
|
59
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.
|
|
60
65
|
|
|
61
66
|
## Skills core (em `.claude/skills/`)
|
|
@@ -64,7 +69,7 @@ Se não houver feature ativa, pergunte ao usuário qual feature trabalhar.
|
|
|
64
69
|
|-------|-------------|
|
|
65
70
|
| `morph-brainstorming` | Entender negócio, escanear stack, produzir proposal.md |
|
|
66
71
|
| `morph-stack-scan` | Detectar desvios de stack em projeto brownfield |
|
|
67
|
-
| `morph-plan` |
|
|
72
|
+
| `morph-plan` | Destilar os use cases (comportamento) e então produzir spec.md + mandate.md + tasks.json |
|
|
68
73
|
| `morph-implement` | Executar uma tarefa com auto-advance entre tasks |
|
|
69
74
|
| `morph-eval` | Aplicar rubricas e produzir score 0-10 |
|
|
70
75
|
| `morph-review` | Produzir review-report.md (Gate 3) |
|
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.
|
|
@@ -138,15 +138,21 @@ Não invente caminhos alternativos.
|
|
|
138
138
|
|
|
139
139
|
### Proposal (`0-proposal/`)
|
|
140
140
|
|
|
141
|
-
- `proposal.md` — descrição da feature, problema que resolve, escopo, critérios de aceite,
|
|
141
|
+
- `proposal.md` — descrição da feature, problema que resolve, escopo, riscos. **Sem critérios de aceite:** o comportamento verificável mora nos use cases (`2-plan/usecases/`), destilados na fase plan. Duplicar aqui produz duas fontes que divergem no primeiro ajuste. Um bom proposal.md tem no máximo 2 páginas: se está crescendo além disso, o escopo provavelmente precisa ser cortado antes de avançar.
|
|
142
142
|
|
|
143
143
|
### Plan (`2-plan/`)
|
|
144
144
|
|
|
145
|
-
- `
|
|
145
|
+
- `usecases/UC-{nn}-{slug}.md` — **um arquivo por use case**, o artefato de comportamento da feature. Frontmatter (`id`, `title`, `actor`, `status`, `entities`) mais as seções `Pré-condições`, `Cenário principal`, `Fluxos alternativos` (A1, A2… — cada alternativa aproxima uma user story), `Pós-condições` (P1, P2…) e `Delta de domínio` (**só o que muda** no modelo; o domínio completo continua sendo o código EF Core mais as migrations).
|
|
146
|
+
|
|
147
|
+
**Pós-condição precisa ser observável em teste** — esta é a regra que sustenta a camada. "O sistema fica consistente" não passa; "a resposta contém todos os veterinários não arquivados" passa. A pós-condição chega **literal** ao `doneCriteria` da task que a realiza: é a tradução manual nesse ponto que hoje perde o requisito.
|
|
148
|
+
|
|
149
|
+
O `id` é sequencial **no projeto**, não na feature — atribuído lendo o acervo `.morph/specs/usecases/` mais os use cases das features ativas, tomando o maior mais um. Ciclo de status: `draft` (nasce) → `approved` (Gate 2) → `implemented` (promoção no `finish`); é descritivo, quem trava fase é o gate. O use case fica **editável depois do Gate 2**, por design — é o que permite mudar o comportamento e re-rodar `implement`, com a auditoria no git. Obrigatoriedade por tipo de trabalho: ver `morph-plan`.
|
|
150
|
+
|
|
151
|
+
- `spec.md` — especificação técnica detalhada: arquitetura, componentes, integrações, fluxos de dados, edge cases **técnicos**. Responde **como** os use cases serão realizados — não repete o comportamento. Um bom spec.md permite que qualquer desenvolvedor comece a implementar sem fazer perguntas básicas. Um spec.md fraco lista tecnologias mas não explica como os componentes se conectam.
|
|
146
152
|
|
|
147
153
|
- `mandate.md` — contrato de implementação: stack exata, standards obrigatórios (com links), patterns explicitamente proibidos, checklist de done com critérios verificáveis. O mandate.md é lido no início de cada tarefa de implementação — deve ser específico o suficiente para resolver ambiguidades sem consultar o Orchestrator.
|
|
148
154
|
|
|
149
|
-
- `tasks.json` — task list estruturada validada contra `framework/schemas/tasks.schema.json`. Cada task tem `id` (T1, T2...), `title`, `description`, `dependencies` (array de IDs), `effort` (S/M/L/XL), `doneCriteria`, `status` (pending/in_progress/done/skipped/blocked)
|
|
155
|
+
- `tasks.json` — task list estruturada validada contra `framework/schemas/tasks.schema.json`. Cada task tem `id` (T1, T2...), `title`, `description`, `dependencies` (array de IDs), `effort` (S/M/L/XL), `doneCriteria`, `status` (pending/in_progress/done/skipped/blocked), `outputs` (paths esperados) e `usecase` (opcional — o **ID** do use case que a task realiza, `UC-07`, nunca o nome do arquivo). O `doneCriteria` de uma task com `usecase` carrega a **pós-condição literal**, com o rótulo (`"P2 — cada veterinário traz..."`). Cada entrada no recap.md deve referenciar um ID do tasks.json.
|
|
150
156
|
|
|
151
157
|
- `decisions.md` — log de decisões técnicas: qual era a alternativa, por que foi descartada, quem decidiu. Criado quando surgir a primeira decisão não trivial. Não é obrigatório — só existe se houver decisões que precisam de rastreabilidade.
|
|
152
158
|
|
|
@@ -158,6 +164,18 @@ Não invente caminhos alternativos.
|
|
|
158
164
|
|
|
159
165
|
- `review-report.md` — relatório de revisão: scores por dimensão (arquitetura, contratos, qualidade, cobertura), feedback priorizado (bloqueantes primeiro, melhorias depois), aprovação ou rejeição com critérios explícitos. Um review-report.md que só diz "aprovado" sem scores não satisfaz o gate.
|
|
160
166
|
|
|
167
|
+
### Acervo de use cases (`.morph/specs/usecases/`)
|
|
168
|
+
|
|
169
|
+
O acervo permanente do projeto: `UC-{nn}-{slug}.md`, **commitado** (nenhuma regra do `.gitignore` o cobre). É a descrição viva do comportamento do sistema e sobrevive ao arquivamento das features — diferente dos traces, ele **faz parte da entrega**.
|
|
170
|
+
|
|
171
|
+
- **Quem escreve:** `morph-spec finish` promove os use cases da feature para cá **dentro do commit de arquivamento**, na branch `morph/{f}`, com `status: implemented`. O acervo viaja na própria integração, como o estado arquivado (§2.2) — não é um passo pós-merge.
|
|
172
|
+
- **O que é cobrado mecanicamente:** `morph-spec dag {feature}` cruza `tasks.json` com os use cases — referência a um `UC-{nn}` inexistente é **erro** (não despacha), pós-condição órfã e `doneCriteria` parafraseado são avisos. Isso cobre o *encanamento*. Se a pós-condição é **observável em teste** nenhum checker decide: continua sendo critério do Gate 2 e da rubrica `test-coverage`.
|
|
173
|
+
- **Via única de escrita:** a promoção lê **só** de `{feature}/2-plan/usecases/`. Nunca edite `.morph/specs/usecases/` à mão — a edição direta pula o `status: implemented`, a sobrescrita por ID e o commit escopado, e o acervo passa a ter duas verdades. Para mudar um UC existente (o caso `bug`), copie-o para o `2-plan/usecases/` da feature, corrija lá, e deixe o `finish` sobrescrever.
|
|
174
|
+
- **Quando chega na default:** em `--merge`, no merge `--no-ff`; em `--pr`, só quando o PR for mergeado. Correto por construção: o acervo descreve o que foi **integrado**, não o que foi proposto.
|
|
175
|
+
- **Sobrescrita por ID:** um `UC-07` que já existe é substituído pela versão que está sendo integrada, mesmo que o slug tenha mudado. Exatamente um arquivo por ID.
|
|
176
|
+
- **Fail-open:** feature sem `2-plan/usecases/` promove nada, em silêncio. A promoção nunca pode impedir o fechamento de uma feature.
|
|
177
|
+
- **Colisão entre features paralelas:** dois worktrees podem alocar `UC-08` sem se enxergar, porque cada `feature.json` viaja na sua branch. A colisão é **aceita e resolvida no merge**, como conflito de arquivo — coerente com o ownership de §2.2 ("conflito ali é o sinal honesto de escopo sobreposto"). Renumere o UC perdedor e atualize as referências `usecase` no `tasks.json` arquivado daquela feature. Não há alocador central, e não deve haver: um lock global de id reintroduziria entre worktrees a coordenação que a arquitetura evita de propósito.
|
|
178
|
+
|
|
161
179
|
### Traces (`.morph/traces/`)
|
|
162
180
|
|
|
163
181
|
Cada fase de implementação produz um trace JSON em `.morph/traces/YYYY-MM-DD-{feature}-{phase}.json`.
|
|
@@ -289,6 +307,31 @@ O orquestrador **lê o arquivo** ao retomar controle após o dispatch — nunca
|
|
|
289
307
|
mensagem de retorno da tool. Se a mensagem de texto final chegou inteira, ótimo (economiza uma
|
|
290
308
|
leitura); se não chegou, o arquivo já está lá.
|
|
291
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
|
+
|
|
292
335
|
**O que receber de volta (no arquivo, e na mensagem final quando ela chega):**
|
|
293
336
|
- O que foi feito (2-3 frases)
|
|
294
337
|
- Arquivos modificados (lista)
|
|
@@ -4,7 +4,9 @@ Avalia se os testes cobrem os comportamentos esperados, são isolados e têm nom
|
|
|
4
4
|
|
|
5
5
|
## Score 9-10 — Exemplar
|
|
6
6
|
|
|
7
|
-
-
|
|
7
|
+
- **Cada pós-condição do use case referenciado tem teste correspondente.** Se a task traz `usecase: "UC-07"`, abra `2-plan/usecases/UC-07-*.md` e percorra as pós-condições (P1, P2…): cada uma precisa de ao menos um teste que a verifique. Pós-condição sem teste **impede a faixa 9-10**, por mais completa que a suíte pareça no resto.
|
|
8
|
+
- Happy path coberto para todos os casos de uso relevantes — "relevantes" são os use cases referenciados pelas tasks da feature, não uma lista implícita.
|
|
9
|
+
- Fluxos alternativos do use case (A1, A2…) cobertos: são os edge cases de **comportamento** e foram declarados justamente para serem testados.
|
|
8
10
|
- Edge cases críticos cobertos: null/undefined, string vazia, boundary values, coleção vazia.
|
|
9
11
|
- Casos de erro cobertos: o que acontece quando a operação falha, quando a dependência retorna erro.
|
|
10
12
|
- Testes completamente isolados — cada teste pode rodar sozinho em qualquer ordem.
|
|
@@ -43,6 +45,7 @@ Avalia se os testes cobrem os comportamentos esperados, são isolados e têm nom
|
|
|
43
45
|
|
|
44
46
|
## Perguntas de avaliação
|
|
45
47
|
|
|
48
|
+
0. A task referencia um `usecase`? Se sim: **toda** pós-condição daquele use case tem teste correspondente? (Sem isso, o teto é 8.)
|
|
46
49
|
1. O happy path está coberto para todos os casos de uso da tarefa?
|
|
47
50
|
2. Os edge cases críticos (null, vazio, boundary) estão cobertos?
|
|
48
51
|
3. O que acontece quando uma dependência falha — isso está testado?
|
|
@@ -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
|
+
}
|