@polymorphism-tech/morph-spec 8.5.0 → 8.6.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 +2 -2
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +6 -4
- package/framework/MORPH.md +21 -3
- package/framework/evals/rubrics/test-coverage.md +4 -1
- package/framework/hooks/shared/phase-utils.js +1 -0
- package/framework/rules/morph-workflow.md +2 -2
- 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 +1 -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/state/archive.js +44 -5
- package/src/commands/state/finish.js +15 -4
- 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/lib/agents/output-collector.js +5 -0
- 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 +8 -0
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.6.0
|
|
7
7
|
**Requires:** Node.js 22+, Claude Code
|
|
8
8
|
|
|
9
9
|
---
|
|
@@ -368,4 +368,4 @@ Code generated by morph-spec (contracts, templates, implementation output) belon
|
|
|
368
368
|
|
|
369
369
|
---
|
|
370
370
|
|
|
371
|
-
*morph-spec v8.
|
|
371
|
+
*morph-spec v8.6.0 by [Polymorphism Tech](https://polymorphism.tech)*
|
package/claude-plugin.json
CHANGED
package/docs/CHEATSHEET.md
CHANGED
package/docs/QUICKSTART.md
CHANGED
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.
|
|
@@ -44,8 +46,8 @@ Se não houver feature ativa, pergunte ao usuário qual feature trabalhar.
|
|
|
44
46
|
- `morph-spec approve <feature> <gate> --mode auto` — aprova um gate (`proposal`/`plan`/`review`) em `trust: auto`.
|
|
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.
|
|
@@ -64,7 +66,7 @@ Se não houver feature ativa, pergunte ao usuário qual feature trabalhar.
|
|
|
64
66
|
|-------|-------------|
|
|
65
67
|
| `morph-brainstorming` | Entender negócio, escanear stack, produzir proposal.md |
|
|
66
68
|
| `morph-stack-scan` | Detectar desvios de stack em projeto brownfield |
|
|
67
|
-
| `morph-plan` |
|
|
69
|
+
| `morph-plan` | Destilar os use cases (comportamento) e então produzir spec.md + mandate.md + tasks.json |
|
|
68
70
|
| `morph-implement` | Executar uma tarefa com auto-advance entre tasks |
|
|
69
71
|
| `morph-eval` | Aplicar rubricas e produzir score 0-10 |
|
|
70
72
|
| `morph-review` | Produzir review-report.md (Gate 3) |
|
package/framework/MORPH.md
CHANGED
|
@@ -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`.
|
|
@@ -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?
|
|
@@ -13,9 +13,9 @@ stacks:
|
|
|
13
13
|
|
|
14
14
|
**NEVER skip to code without a specification.** Every feature must progress through phases:
|
|
15
15
|
|
|
16
|
-
1. **Proposal** — Problem statement +
|
|
16
|
+
1. **Proposal** — Problem statement + scope + risks (`0-proposal/proposal.md`). No acceptance criteria: verifiable behaviour lives in the use cases.
|
|
17
17
|
2. **Design** (optional, UI-heavy) — Design system + mockups + flows (`1-design/`)
|
|
18
|
-
3. **Plan** —
|
|
18
|
+
3. **Plan** — Use cases first, then technical spec + mandate + tasks (`2-plan/usecases/UC-{nn}-{slug}.md`, `spec.md`, `mandate.md`, `tasks.json`). Each use case post-condition must be observable in a test and reaches the task's `doneCriteria` verbatim.
|
|
19
19
|
4. **Implement** — Code + recap (`3-implement/recap.md`)
|
|
20
20
|
5. **Review** — Review report + Gate 3 (`4-review/review-report.md`)
|
|
21
21
|
|
|
@@ -25,7 +25,12 @@
|
|
|
25
25
|
"status": { "type": "string", "enum": ["pending", "in_progress", "done", "skipped", "blocked"] },
|
|
26
26
|
"outputs": { "type": "array", "items": { "type": "string" } },
|
|
27
27
|
"notes": { "type": "string" },
|
|
28
|
-
"group": { "type": "string", "enum": ["backend", "frontend", "tests", "infra", "docs"] }
|
|
28
|
+
"group": { "type": "string", "enum": ["backend", "frontend", "tests", "infra", "docs"] },
|
|
29
|
+
"usecase": {
|
|
30
|
+
"type": "string",
|
|
31
|
+
"pattern": "^UC-\\d+$",
|
|
32
|
+
"description": "ID of the use case this task realizes (2-plan/usecases/UC-{nn}-{slug}.md). Optional: technical tasks with no observable behaviour have none."
|
|
33
|
+
}
|
|
29
34
|
},
|
|
30
35
|
"additionalProperties": false
|
|
31
36
|
}
|
|
@@ -4,7 +4,7 @@ description: Guia a criação de uma landing page nova ponta-a-ponta a partir do
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao iniciar um novo projeto de LP com o template Umbraco genérico
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.6.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# create-umbraco-lp
|
|
@@ -4,7 +4,7 @@ description: Migra uma landing page existente (React/Vite, Next.js estático ou
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao receber instrução "use /migrate-lp-to-umbraco {projeto}" do usuário, ou quando o usuário descrever necessidade de migrar uma LP para Umbraco
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.6.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# migrate-lp-to-umbraco
|
|
@@ -4,7 +4,7 @@ description: Orquestra a execução autônoma de tasks de uma feature morph-spec
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: após Gate 2 aprovado, ao receber instrução "use /morph-apply" ou "implementar feature"
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.6.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-apply
|
|
@@ -4,7 +4,7 @@ description: Entende o negócio, os usuários e a arquitetura antes de qualquer
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao receber um novo request de feature, antes de morph-plan
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.6.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-brainstorming
|
|
@@ -72,12 +72,13 @@ Para `workType: feature` (ou features sem `workType` — compat, ver MORPH.md §
|
|
|
72
72
|
- **Problema:** o que está sendo resolvido e para quem (máx. 2 parágrafos)
|
|
73
73
|
- **Escopo:** o que está incluído e o que está explicitamente excluído
|
|
74
74
|
- **Usuários e fluxos críticos:** quem usa e qual é o caminho principal
|
|
75
|
-
- **Critérios de aceite:** condições verificáveis que indicam que a feature está pronta
|
|
76
75
|
- **Stack:** confirme o stack padrão ou documente desvios aprovados
|
|
77
76
|
- **Riscos e dependências:** o que pode dar errado, o que esta feature depende
|
|
78
77
|
- **Compliance de stack:** "Em compliance" ou lista de desvios detectados e plano de resolução
|
|
79
78
|
|
|
80
|
-
**
|
|
79
|
+
**Não escreva critérios de aceite aqui.** O comportamento verificável mora nos **use cases**, destilados pela `morph-plan` na fase seguinte (`2-plan/usecases/UC-{nn}-{slug}.md`), onde cada pós-condição precisa ser observável em teste e chega literal ao `doneCriteria` das tasks. O `proposal.md` responde **o quê e por quê**; os use cases respondem **como o sistema se comporta**. Duplicar aqui produz duas fontes que divergem no primeiro ajuste.
|
|
80
|
+
|
|
81
|
+
**Se `workType: bug`** (modo bug-lean, ver nota no início da skill), substitua a lista acima pela estrutura enxuta: **Repro** (passos para reproduzir), **Diagnóstico** (o que foi observado/investigado), **Root cause provável** e **Escopo do fix** (o que muda, o que fica de fora) — sem as seções de negócio completas do fluxo `feature`. A condição verificável de que o bug foi corrigido vira a **pós-condição** que faltava no use case do acervo (`.morph/specs/usecases/`), corrigida ou acrescentada na fase plan.
|
|
81
82
|
|
|
82
83
|
Um bom proposal.md tem no máximo 2 páginas. Se está crescendo além disso, o escopo precisa ser cortado.
|
|
83
84
|
|
|
@@ -4,7 +4,7 @@ description: Avalia a qualidade de uma tarefa ou fase com rubricas graduadas 0-1
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao completar uma tarefa ou ao finalizar uma fase de implementação
|
|
6
6
|
user-invokable: false
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.6.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-eval
|
|
@@ -4,7 +4,7 @@ description: ADW cirúrgico de hotfix — resolve uma queda/bug crítico de prod
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao receber "/morph-hotfix {feature}", quando a morph-proposal classifica o pedido como workType hotfix (Passo 0), ou o usuário diz "produção caiu / hotfix / urgente"
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.6.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-hotfix
|
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: morph-plan
|
|
3
|
-
description: Guia a produção dos
|
|
3
|
+
description: Guia a produção dos artefatos de planejamento de uma feature (use cases, spec.md, mandate.md, tasks.json) e apresenta o Gate 2 trust-aware.
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao iniciar a fase plan de uma feature, após o Gate 1 (proposal aprovado)
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.6.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-plan
|
|
11
11
|
|
|
12
12
|
## Objetivo
|
|
13
13
|
|
|
14
|
-
Produzir `spec.md`, `mandate.md` e `tasks.json` com qualidade suficiente para abrir o Gate 2.
|
|
15
|
-
Os três artefatos
|
|
14
|
+
Produzir os **use cases**, `spec.md`, `mandate.md` e `tasks.json` com qualidade suficiente para abrir o Gate 2.
|
|
15
|
+
Os use cases definem o **comportamento**; os três artefatos seguintes definem o como e o quanto da feature.
|
|
16
|
+
|
|
17
|
+
A ordem importa e não é negociável: **comportamento antes de solução técnica**. Um `spec.md` escrito antes dos use cases resolve um problema que ainda não foi enunciado.
|
|
16
18
|
|
|
17
19
|
## Quando invocar
|
|
18
20
|
|
|
@@ -20,14 +22,70 @@ Ao iniciar a fase plan — após o `proposal.md` ser aprovado e antes de qualque
|
|
|
20
22
|
|
|
21
23
|
## O que fazer
|
|
22
24
|
|
|
23
|
-
1. **Garanta que `proposal.md` e `stack-scan.md` estão no seu contexto antes de planejar.** Se não tem o conteúdo atual em contexto (sessão fresca, ou conteúdo possivelmente desatualizado), **use a tool Read para carregá-los** — não confie em cache de turnos anteriores. Na dúvida, Read. `.morph/features/{feature}/0-proposal/proposal.md` (escopo,
|
|
25
|
+
1. **Garanta que `proposal.md` e `stack-scan.md` estão no seu contexto antes de planejar.** Se não tem o conteúdo atual em contexto (sessão fresca, ou conteúdo possivelmente desatualizado), **use a tool Read para carregá-los** — não confie em cache de turnos anteriores. Na dúvida, Read. `.morph/features/{feature}/0-proposal/proposal.md` (problema, escopo, riscos — o comportamento é destilado aqui no passo 2, não vem pronto do proposal) e `.morph/features/{feature}/0-proposal/stack-scan.md` (contexto técnico detectado: greenfield vs brownfield, stack atual, drift). Em brownfield, o stack-scan informa decisões de spec/mandate — não invente padrões que conflitam com o detectado.
|
|
26
|
+
|
|
27
|
+
2. **Destile os use cases — ANTES de escrever `spec.md` e `tasks.json`.**
|
|
28
|
+
|
|
29
|
+
Um arquivo por use case em `.morph/features/{feature}/2-plan/usecases/UC-{nn}-{slug}.md`:
|
|
30
|
+
|
|
31
|
+
```markdown
|
|
32
|
+
---
|
|
33
|
+
id: UC-07
|
|
34
|
+
title: Listar veterinários com suas especialidades
|
|
35
|
+
actor: Visitante
|
|
36
|
+
status: draft
|
|
37
|
+
entities: [Vet, Specialty]
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Pré-condições
|
|
41
|
+
- Existe ao menos um veterinário cadastrado
|
|
42
|
+
|
|
43
|
+
## Cenário principal
|
|
44
|
+
1. Visitante acessa a listagem de veterinários
|
|
45
|
+
2. Sistema retorna os veterinários ordenados por sobrenome
|
|
46
|
+
3. Sistema exibe, para cada um, nome e especialidades
|
|
47
|
+
|
|
48
|
+
## Fluxos alternativos
|
|
49
|
+
- **A1** — Nenhum veterinário cadastrado → sistema exibe estado vazio
|
|
50
|
+
- **A2** — Veterinário sem especialidade → sistema exibe "sem especialidade"
|
|
51
|
+
|
|
52
|
+
## Pós-condições
|
|
53
|
+
- **P1** — A resposta contém todos os veterinários não arquivados
|
|
54
|
+
- **P2** — Cada veterinário traz suas especialidades em lista, nunca string concatenada
|
|
55
|
+
- **P3** — A ordenação por sobrenome é estável para nomes repetidos
|
|
56
|
+
|
|
57
|
+
## Delta de domínio
|
|
58
|
+
- `Vet.archivedAt` (novo, nullable) — habilita P1
|
|
59
|
+
- Nenhuma relação nova
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
**A regra que sustenta a camada inteira: pós-condição precisa ser observável em teste.** "O sistema fica consistente" não passa. "A resposta contém todos os veterinários não arquivados" passa. Antes de escrever cada pós-condição, pergunte: *que asserção eu escreveria para verificar isto?* Se não há resposta, a pós-condição está mal formulada — reescreva ou descarte. Sem esse filtro a camada vira prosa e não chega ao `doneCriteria`.
|
|
24
63
|
|
|
25
|
-
|
|
64
|
+
**Identificador sequencial no projeto, não na feature.** Antes de numerar, leia o acervo `.morph/specs/usecases/` (se existir) mais os use cases das features ativas em `.morph/features/*/2-plan/usecases/`; tome o maior `UC-{nn}` e some um. Numerar por feature garante colisão no momento da promoção.
|
|
65
|
+
|
|
66
|
+
**Seções:** `Pré-condições` (o que já é verdade antes), `Cenário principal` (o caminho feliz, numerado), `Fluxos alternativos` (**A1**, **A2**… — cada alternativa aproxima uma user story; edge case de *comportamento* mora aqui, edge case *técnico* fica no `spec.md`), `Pós-condições` (**P1**, **P2**… — o que passa a ser verdade, cada uma testável) e `Delta de domínio` (**só o que muda**: campo novo, relação nova, invariante nova — o modelo completo continua sendo o código EF Core mais as migrations; se a feature não altera o domínio, escreva "nenhum").
|
|
67
|
+
|
|
68
|
+
**Quando é obrigatório** (ver MORPH.md §2.1 para o roteamento por tipo):
|
|
69
|
+
|
|
70
|
+
| Tipo | Use case |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `feature` | Obrigatório quando há comportamento observável. Feature puramente técnica (refactor, infra, migração) dispensa — **registre a dispensa em `decisions.md`**. |
|
|
73
|
+
| `bug` | Não cria novo: **copie** o `UC-{nn}-*.md` do acervo para `2-plan/usecases/` e **corrija ou adicione a pós-condição** lá — o bug é a pós-condição que faltava. |
|
|
74
|
+
| `chore` | Nenhum (já pula a fase plan por política). |
|
|
75
|
+
| `hotfix` | Não bloqueia. Referencia se existir; o pós-mortem pode virar pós-condição numa feature posterior. |
|
|
76
|
+
|
|
77
|
+
**Nunca edite `.morph/specs/usecases/` à mão.** A única via de escrita no acervo é a promoção do `finish`, que lê **só** de `2-plan/usecases/`: é ela que carimba `status: implemented`, sobrescreve por ID (mesmo com slug novo) e leva o arquivo no commit de arquivamento. Editar o acervo direto pula os três e cria uma segunda verdade fora do mecanismo. Mudar um UC existente = copiar para o `2-plan/usecases/` da feature, corrigir lá, e deixar o `finish` sobrescrever.
|
|
78
|
+
|
|
79
|
+
O use case fica **editável depois do Gate 2**, por design — é o que permite mudar o comportamento e re-rodar `implement`. A trilha de auditoria fica no git.
|
|
80
|
+
|
|
81
|
+
3. Produza `spec.md` em `.morph/features/{feature}/2-plan/`:
|
|
26
82
|
- Arquitetura da solução: quais componentes, como se conectam, fluxos de dados principais.
|
|
27
|
-
- Edge cases
|
|
83
|
+
- Edge cases **técnicos** e como serão tratados (os de comportamento estão nos fluxos alternativos dos use cases).
|
|
28
84
|
- Integrações externas e contratos esperados.
|
|
29
85
|
|
|
30
|
-
|
|
86
|
+
O `spec.md` responde **como** os use cases do passo 2 serão realizados. Não repita o comportamento aqui.
|
|
87
|
+
|
|
88
|
+
4. Produza `mandate.md` em `.morph/features/{feature}/2-plan/`:
|
|
31
89
|
- Stack exata do projeto (leia de `.morph/config/config.json` → `project.stack` ou do CLAUDE.md).
|
|
32
90
|
- Standards obrigatórios: liste os IDs relevantes de `.morph/framework/standards/STANDARDS.json`.
|
|
33
91
|
- Patterns proibidos — inclua sempre os defaults abaixo mais qualquer específico da feature:
|
|
@@ -41,7 +99,7 @@ Ao iniciar a fase plan — após o `proposal.md` ser aprovado e antes de qualque
|
|
|
41
99
|
- **Features com agentes de IA (MAF):** se a feature envolve um agente (Microsoft Agent Framework), **use a tool Read** nos standards relevantes de `framework/standards/ai-agents/*.md` (ex.: `setup`, `agent-session`, `middleware-patterns`, `mcp-server`, `mcp-tools`, `multi-agent-patterns`, `production`) — filtre pelo que a feature exige via STANDARDS.json. Esses standards carregam as **convenções de projeto** (single-agent por padrão — Workflows só para fan-in/out real; provider, caching, observability, A2A) que uma fonte externa de docs (ex.: context7) não conhece. Padrão de divisão: **superfície da API** pode vir de docs ao vivo/context7 (mais frescas para um SDK em evolução), mas **as convenções vêm dos standards** — registre ambas no mandate. Não pule os `ai-agents/*.md` só porque o STANDARDS.json index lista que eles existem.
|
|
42
100
|
- Checklist de done com critérios verificáveis por tarefa.
|
|
43
101
|
|
|
44
|
-
|
|
102
|
+
5. Produza `tasks.json` em `.morph/features/{feature}/2-plan/` seguindo o schema em `.morph/framework/schemas/tasks.schema.json`.
|
|
45
103
|
|
|
46
104
|
**Formato obrigatório** (schema em `.morph/framework/schemas/tasks.schema.json` — o hook `tasks-json-guard` valida cada write):
|
|
47
105
|
|
|
@@ -56,10 +114,11 @@ Ao iniciar a fase plan — após o `proposal.md` ser aprovado e antes de qualque
|
|
|
56
114
|
"description": "Detalhes — o que esta task realiza e por quê",
|
|
57
115
|
"dependencies": [],
|
|
58
116
|
"effort": "S",
|
|
59
|
-
"doneCriteria": "
|
|
117
|
+
"doneCriteria": "P2 — Cada veterinário traz suas especialidades em lista, nunca string concatenada",
|
|
60
118
|
"status": "pending",
|
|
61
119
|
"outputs": [],
|
|
62
|
-
"group": "backend"
|
|
120
|
+
"group": "backend",
|
|
121
|
+
"usecase": "UC-07"
|
|
63
122
|
}
|
|
64
123
|
]
|
|
65
124
|
}
|
|
@@ -72,12 +131,19 @@ Ao iniciar a fase plan — após o `proposal.md` ser aprovado e antes de qualque
|
|
|
72
131
|
- `status` inicia sempre como `pending`.
|
|
73
132
|
- `group` (opcional): backend, frontend, tests, infra, docs. Default backend.
|
|
74
133
|
- `outputs` (opcional): array de paths que serão criados/modificados.
|
|
134
|
+
- `usecase` (opcional): o **ID** do use case que a task realiza — `UC-07`, nunca o nome do arquivo. Task puramente técnica (build, config, migração) não tem.
|
|
135
|
+
|
|
136
|
+
**O `doneCriteria` carrega a pós-condição LITERAL.** Este é o elo que amarra a cadeia inteira: a pós-condição escrita em linguagem de negócio chega **sem tradução** ao critério que o `morph-spec verify` e o `morph-eval` cobram. Copie o texto da pós-condição, prefixado pelo seu rótulo (`"P2 — Cada veterinário traz..."`). Reescrever com outras palavras aqui é exatamente o ponto em que o requisito se perde hoje.
|
|
137
|
+
|
|
138
|
+
**`morph-spec dag {feature}` cobra o encanamento.** Ele lê os use cases junto com o `tasks.json` e reporta: referência a um `UC-{nn}` inexistente (**erro** — trava), pós-condição que nenhuma task cita (aviso) e task que referencia um UC mas parafraseou em vez de copiar (aviso). A comparação normaliza caixa e espaço, então prefixar o rótulo não quebra o casamento — mas a redação e os acentos precisam bater. O que o `dag` **não** julga é se a pós-condição é observável em teste: isso é semântico e continua sendo trabalho seu no Gate 2 e do evaluator.
|
|
139
|
+
|
|
140
|
+
Cada pós-condição de cada use case deve estar coberta por **pelo menos uma** task. Antes de fechar o `tasks.json`, percorra os `UC-*.md` e confira: sobrou pós-condição sem task? Ou o plano está incompleto, ou a pós-condição não era necessária — resolva antes do Gate 2, não depois.
|
|
75
141
|
|
|
76
142
|
**Preencha `outputs` (paths planejados) em cada task** — o `morph-spec dag` usa os outputs para detectar colisão de arquivos entre fluxos paralelos e recomendar `isolation: "worktree"`. Task sem `outputs` entra como footprint desconhecido (não força isolamento, mas o dag não consegue protegê-la de colisão).
|
|
77
143
|
|
|
78
144
|
Antes de retornar, valide rodando `morph-spec dag {feature}` — o comando parseia e valida o schema do tasks.json (ids únicos `T\d+`, enums de `status`/`effort`, `dependencies` válidas) e já imprime a análise de DAG que a `/morph-apply` vai usar. Erros de schema abortam com a lista do que corrigir.
|
|
79
145
|
|
|
80
|
-
|
|
146
|
+
6. Apresente o Gate 2 segundo trust mode.
|
|
81
147
|
|
|
82
148
|
Você pode embutir o diagrama do DAG do plano via `morph-spec dag {feature} --mermaid` (flowchart Mermaid) para visualização no gate.
|
|
83
149
|
|
|
@@ -124,16 +190,20 @@ Ao iniciar a fase plan — após o `proposal.md` ser aprovado e antes de qualque
|
|
|
124
190
|
|
|
125
191
|
## Output esperado
|
|
126
192
|
|
|
193
|
+
- `.morph/features/{feature}/2-plan/usecases/UC-{nn}-{slug}.md` — um arquivo por use case, com pós-condições testáveis
|
|
127
194
|
- `.morph/features/{feature}/2-plan/spec.md` — especificação técnica completa
|
|
128
195
|
- `.morph/features/{feature}/2-plan/mandate.md` — contrato de implementação
|
|
129
|
-
- `.morph/features/{feature}/2-plan/tasks.json` — task list estruturada válida contra schema
|
|
196
|
+
- `.morph/features/{feature}/2-plan/tasks.json` — task list estruturada válida contra schema, com o `doneCriteria` carregando a pós-condição literal
|
|
130
197
|
- Gate 2 registrado aprovado em `.morph/features/{feature}/feature.json` (autoritativo)
|
|
131
198
|
|
|
199
|
+
Ao registrar a aprovação do Gate 2, atualize o frontmatter de cada use case de `status: draft` para `status: approved`. O ciclo fecha na integração: `morph-spec finish` promove os use cases para o acervo permanente `.morph/specs/usecases/` já com `status: implemented`. O status é **descritivo** — quem trava fase é o gate, não este campo.
|
|
200
|
+
|
|
132
201
|
## Referências
|
|
133
202
|
|
|
134
203
|
- `.morph/framework/MORPH.md` — seções 2 (Fases), 3 (Contratos de Output), 5 (Gates), Trust Mode
|
|
135
204
|
- `.morph/framework/standards/STANDARDS.json` — para identificar standards relevantes ao mandate
|
|
136
205
|
- `.morph/framework/schemas/tasks.schema.json` — schema obrigatório de tasks.json
|
|
206
|
+
- `.morph/specs/usecases/` — acervo permanente de use cases do projeto (leia antes de numerar; em `bug`, é onde mora o UC a corrigir)
|
|
137
207
|
- `.morph/features/{feature}/0-proposal/proposal.md` — escopo da feature
|
|
138
208
|
- `.morph/features/{feature}/0-proposal/stack-scan.md` — contexto técnico detectado
|
|
139
209
|
- `.morph/framework/schemas/tasks.schema.json` — schema e enums de tasks.json
|
|
@@ -4,7 +4,7 @@ description: Orquestra a fase de proposta+plano de uma feature morph-spec. Encad
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao receber instrução "use /morph-proposal" do usuário, ou ao iniciar uma nova feature
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.6.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-proposal
|
|
@@ -4,7 +4,7 @@ description: Conduz a fase de revisão e produz o review-report.md para o Gate 3
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao iniciar a fase review, após todas as tasks de tasks.json estarem com status `done`
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.6.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-review
|
|
@@ -4,7 +4,7 @@ description: Varre o codebase existente, detecta stack atual, compara com o stac
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: ao iniciar uma feature em projeto brownfield (codebase já existente)
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.6.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-stack-scan
|
|
@@ -4,7 +4,7 @@ description: Localiza e carrega um standard específico do STANDARDS.json sob de
|
|
|
4
4
|
version: core
|
|
5
5
|
when-to-invoke: quando precisar consultar um standard específico durante a implementação
|
|
6
6
|
user-invokable: true
|
|
7
|
-
cliVersion: "8.
|
|
7
|
+
cliVersion: "8.6.0"
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# morph-standards
|
package/package.json
CHANGED
|
@@ -2,6 +2,7 @@ import chalk from 'chalk';
|
|
|
2
2
|
import { existsSync, renameSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import { execFileSync } from 'child_process';
|
|
5
|
+
import { promoteUseCases, SPECS_USECASES_PATHSPEC } from '../../lib/specs/usecase-promoter.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* `morph-spec archive <feature>` — atomic, git-aware archive of a completed
|
|
@@ -52,7 +53,7 @@ function readReviewApproved(fjPath) {
|
|
|
52
53
|
* @param {string} feature
|
|
53
54
|
* @param {{ cwd?: string, force?: boolean, git?: boolean }} [opts]
|
|
54
55
|
* @returns {{ ok: boolean, error?: string, archived?: boolean, alreadyArchived?: boolean,
|
|
55
|
-
* committed?: boolean, staged?: boolean, archivePath?: string }}
|
|
56
|
+
* committed?: boolean, staged?: boolean, archivePath?: string, promotedUseCases?: string[] }}
|
|
56
57
|
*/
|
|
57
58
|
export function runArchive(feature, opts = {}) {
|
|
58
59
|
const cwd = opts.cwd || process.cwd();
|
|
@@ -64,6 +65,14 @@ export function runArchive(feature, opts = {}) {
|
|
|
64
65
|
|
|
65
66
|
// Idempotent short-circuit: already archived here. Checked FIRST so a re-run
|
|
66
67
|
// after a partial finish reports "already done", never "not found".
|
|
68
|
+
//
|
|
69
|
+
// Consequence, deliberate: this short-circuits BEFORE the use-case promotion
|
|
70
|
+
// below, so a promotion that degraded fail-open is NOT retried by re-running
|
|
71
|
+
// `finish`. That is the price of "closing a feature never fails" — an archived
|
|
72
|
+
// feature stays archived, and the only signal is an absent `promotedUseCases`
|
|
73
|
+
// in the output. Recovering means copying the UCs into `.morph/specs/usecases/`
|
|
74
|
+
// by hand; making this retry would mean re-entering the archive path for an
|
|
75
|
+
// already-closed feature, which is a worse trade.
|
|
67
76
|
if (existsSync(archivePath)) {
|
|
68
77
|
return { ok: true, alreadyArchived: true, archived: false, archivePath };
|
|
69
78
|
}
|
|
@@ -117,18 +126,45 @@ export function runArchive(feature, opts = {}) {
|
|
|
117
126
|
} catch { /* non-fatal — the move is what matters */ }
|
|
118
127
|
}
|
|
119
128
|
|
|
120
|
-
// 3)
|
|
129
|
+
// 3) Promote the feature's use cases into the project's permanent archive
|
|
130
|
+
// (.morph/specs/usecases/). Read from the ALREADY-MOVED folder so there is
|
|
131
|
+
// a single source path, and land inside the SAME scoped commit below — the
|
|
132
|
+
// archive travels with the integration, exactly like the archived state
|
|
133
|
+
// (MORPH.md §2.2). Fail-open: any failure degrades to "promoted nothing",
|
|
134
|
+
// never to a refused close.
|
|
135
|
+
let promotedUseCases = [];
|
|
136
|
+
try {
|
|
137
|
+
const { promoted } = promoteUseCases(cwd, archivePath);
|
|
138
|
+
promotedUseCases = promoted;
|
|
139
|
+
if (promoted.length && useGit && staged) {
|
|
140
|
+
// This staging exists for the NEW files: promoted use cases are untracked,
|
|
141
|
+
// and the scoped `commit -- <pathspec>` below only knows paths git already
|
|
142
|
+
// tracks. The replaced-by-ID deletions are tracked, so that same commit
|
|
143
|
+
// would carry them even without this line (verified by mutating it away) —
|
|
144
|
+
// `-A` is belt-and-braces, but dropping the `git add` entirely would
|
|
145
|
+
// silently promote nothing on a first delivery.
|
|
146
|
+
try { execFileSync('git', ['add', '-A', '--', SPECS_USECASES_PATHSPEC], { cwd, stdio: 'pipe' }); } catch { /* non-fatal */ }
|
|
147
|
+
}
|
|
148
|
+
} catch { /* promotion must never block archiving */ }
|
|
149
|
+
|
|
150
|
+
// 4) Commit the move — SCOPED to this feature's pathspec, NEVER `commit -a`
|
|
121
151
|
// (same anti-sweep discipline as `finish`'s worktree teardown). This is what
|
|
122
152
|
// lets the archived state travel with the branch on `finish` (archive-before-merge).
|
|
153
|
+
// The acervo pathspec joins only when something was promoted: git refuses a
|
|
154
|
+
// pathspec that matches nothing, and that refusal would leave the tree dirty
|
|
155
|
+
// and break the checkout/merge that follows.
|
|
123
156
|
let committed = false;
|
|
124
157
|
if (useGit && staged) {
|
|
158
|
+
const pathspecs = promotedUseCases.length
|
|
159
|
+
? [featPathspec, archivePathspec, SPECS_USECASES_PATHSPEC]
|
|
160
|
+
: [featPathspec, archivePathspec];
|
|
125
161
|
try {
|
|
126
|
-
execFileSync('git', ['commit', '-m', `morph-spec archive: ${feature}`, '--',
|
|
162
|
+
execFileSync('git', ['commit', '-m', `morph-spec archive: ${feature}`, '--', ...pathspecs], { cwd, stdio: 'pipe' });
|
|
127
163
|
committed = true;
|
|
128
164
|
} catch { /* nothing to commit / no identity — not fatal, the move + stage stand */ }
|
|
129
165
|
}
|
|
130
166
|
|
|
131
|
-
//
|
|
167
|
+
// 5) Drop the feature from the thin root index. Edit the index file directly
|
|
132
168
|
// rather than calling saveState — saveState would re-create
|
|
133
169
|
// .morph/features/{name}/feature.json and undo the archive.
|
|
134
170
|
const rootPath = join(cwd, '.morph', 'state.json');
|
|
@@ -144,7 +180,7 @@ export function runArchive(feature, opts = {}) {
|
|
|
144
180
|
} catch { /* index is derivable — non-fatal */ }
|
|
145
181
|
}
|
|
146
182
|
|
|
147
|
-
return { ok: true, archived: true, committed, staged, archivePath };
|
|
183
|
+
return { ok: true, archived: true, committed, staged, archivePath, promotedUseCases };
|
|
148
184
|
}
|
|
149
185
|
|
|
150
186
|
/**
|
|
@@ -179,6 +215,9 @@ export async function archiveCommand(featureName, options = {}) {
|
|
|
179
215
|
|
|
180
216
|
console.log(chalk.green(`\n📦 Archived "${featureName}" → .morph/archive/${featureName}/`));
|
|
181
217
|
console.log(chalk.gray(` status: completed, archivedAt stamped in feature.json.`));
|
|
218
|
+
if (result.promotedUseCases?.length) {
|
|
219
|
+
console.log(chalk.gray(` ${result.promotedUseCases.length} use case(s) promoted → .morph/specs/usecases/ (status: implemented).`));
|
|
220
|
+
}
|
|
182
221
|
if (result.committed) console.log(chalk.gray(` Move committed on the current branch.\n`));
|
|
183
222
|
else if (result.staged) console.log(chalk.gray(` Move staged in git; commit it when ready.\n`));
|
|
184
223
|
else console.log('');
|
|
@@ -187,6 +187,7 @@ export async function finishCommand(feature, options = {}) {
|
|
|
187
187
|
|
|
188
188
|
async function integrateMerge({ emit, root, feature, expectedBranch, defaultBranch, isWorktreeFlow, worktreePath, options, arch }) {
|
|
189
189
|
const mergeMsg = `morph-spec finish: merge ${expectedBranch}`;
|
|
190
|
+
const ucNote = promotedNote(arch);
|
|
190
191
|
|
|
191
192
|
if (isWorktreeFlow) {
|
|
192
193
|
// The root stays on the default; shed this feature's own root-side scaffold,
|
|
@@ -214,13 +215,13 @@ async function integrateMerge({ emit, root, feature, expectedBranch, defaultBran
|
|
|
214
215
|
|
|
215
216
|
if (options.keep) {
|
|
216
217
|
dropFromRootIndex(root, feature);
|
|
217
|
-
emit({ finished: true, mode: 'merge', feature, merged: expectedBranch, kept: true, worktreeRemoved: false, ...backupNote });
|
|
218
|
+
emit({ finished: true, mode: 'merge', feature, merged: expectedBranch, kept: true, worktreeRemoved: false, ...backupNote, ...ucNote });
|
|
218
219
|
process.exit(0);
|
|
219
220
|
}
|
|
220
221
|
|
|
221
222
|
const teardown = await teardownWorktree(worktreePath, { root, feature, branch: expectedBranch, options, deleteBranch: true });
|
|
222
223
|
dropFromRootIndex(root, feature);
|
|
223
|
-
emit(mergeOutput({ feature, branch: expectedBranch, worktreePath, teardown, shedBackup }));
|
|
224
|
+
emit({ ...mergeOutput({ feature, branch: expectedBranch, worktreePath, teardown, shedBackup }), ...ucNote });
|
|
224
225
|
process.exit(0);
|
|
225
226
|
}
|
|
226
227
|
|
|
@@ -249,13 +250,13 @@ async function integrateMerge({ emit, root, feature, expectedBranch, defaultBran
|
|
|
249
250
|
}
|
|
250
251
|
tryGit(['branch', '-d', expectedBranch], root);
|
|
251
252
|
// The root index (gitignored) was already updated by the archive (same cwd).
|
|
252
|
-
emit({ finished: true, mode: 'merge', feature, merged: expectedBranch, into: defaultBranch, worktreeRemoved: false, nextStep: null });
|
|
253
|
+
emit({ finished: true, mode: 'merge', feature, merged: expectedBranch, into: defaultBranch, worktreeRemoved: false, nextStep: null, ...ucNote });
|
|
253
254
|
process.exit(0);
|
|
254
255
|
}
|
|
255
256
|
|
|
256
257
|
// ── PR integration (push + gh, no local merge) ───────────────────────────────
|
|
257
258
|
|
|
258
|
-
async function integratePr({ emit, root, feature, expectedBranch, defaultBranch, isWorktreeFlow, worktreePath, options, prTitle, prBody }) {
|
|
259
|
+
async function integratePr({ emit, root, feature, expectedBranch, defaultBranch, isWorktreeFlow, worktreePath, options, prTitle, prBody, arch }) {
|
|
259
260
|
try {
|
|
260
261
|
execFileSync('git', ['push', '-u', 'origin', expectedBranch], { cwd: root, stdio: 'pipe' });
|
|
261
262
|
} catch (err) {
|
|
@@ -301,6 +302,7 @@ async function integratePr({ emit, root, feature, expectedBranch, defaultBranch,
|
|
|
301
302
|
pushed: expectedBranch,
|
|
302
303
|
base: defaultBranch,
|
|
303
304
|
pr: prUrl,
|
|
305
|
+
...promotedNote(arch),
|
|
304
306
|
...(shedBackup ? { shedBackup } : {}),
|
|
305
307
|
...(teardown
|
|
306
308
|
? { worktreeRemoved: teardown.worktreeRemoved, ...teardownExtras(teardown, worktreePath, expectedBranch, false) }
|
|
@@ -312,6 +314,15 @@ async function integratePr({ emit, root, feature, expectedBranch, defaultBranch,
|
|
|
312
314
|
|
|
313
315
|
// ── output helpers ────────────────────────────────────────────────────────
|
|
314
316
|
|
|
317
|
+
/**
|
|
318
|
+
* Report the use cases the archive promoted into `.morph/specs/usecases/`.
|
|
319
|
+
* Absent when nothing was promoted, so a feature with no use cases keeps the
|
|
320
|
+
* exact output shape it had before this existed.
|
|
321
|
+
*/
|
|
322
|
+
function promotedNote(arch) {
|
|
323
|
+
return arch?.promotedUseCases?.length ? { promotedUseCases: arch.promotedUseCases } : {};
|
|
324
|
+
}
|
|
325
|
+
|
|
315
326
|
function mergeOutput({ feature, branch, worktreePath, teardown, shedBackup }) {
|
|
316
327
|
return {
|
|
317
328
|
finished: true,
|
|
@@ -4,6 +4,7 @@ import { join } from 'path';
|
|
|
4
4
|
import { parseTasksJsonLenient } from '../../lib/tasks/tasks-json-parser.js';
|
|
5
5
|
import { planDispatch } from '../../lib/tasks/dispatch-planner.js';
|
|
6
6
|
import { renderMermaidFlowchart } from '../../lib/tasks/mermaid-renderer.js';
|
|
7
|
+
import { checkUseCases } from '../../lib/specs/usecase-check.js';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* `morph-spec dag <feature>` — analyze the tasks.json DAG and print the
|
|
@@ -17,7 +18,8 @@ import { renderMermaidFlowchart } from '../../lib/tasks/mermaid-renderer.js';
|
|
|
17
18
|
*/
|
|
18
19
|
export async function dagCommand(featureName, options = {}) {
|
|
19
20
|
try {
|
|
20
|
-
const
|
|
21
|
+
const planDir = join(process.cwd(), '.morph', 'features', featureName, '2-plan');
|
|
22
|
+
const tasksPath = join(planDir, 'tasks.json');
|
|
21
23
|
if (!existsSync(tasksPath)) {
|
|
22
24
|
console.error(chalk.red(`\n❌ tasks.json not found for feature "${featureName}"`));
|
|
23
25
|
console.error(chalk.gray(`Expected at: .morph/features/${featureName}/2-plan/tasks.json\n`));
|
|
@@ -42,13 +44,34 @@ export async function dagCommand(featureName, options = {}) {
|
|
|
42
44
|
return;
|
|
43
45
|
}
|
|
44
46
|
|
|
47
|
+
// Use-case cross-check. Runs AFTER the --mermaid short-circuit on purpose:
|
|
48
|
+
// rendering the DAG is pure visualization and must never be blocked by the
|
|
49
|
+
// state of the spec. A broken `usecase` reference IS fatal here — the file
|
|
50
|
+
// either exists or it does not, so there is no false positive to weigh
|
|
51
|
+
// against a plan that points at a use case nobody can read.
|
|
52
|
+
const ucCheck = checkUseCases(data, join(planDir, 'usecases'));
|
|
53
|
+
|
|
45
54
|
const plan = planDispatch(data);
|
|
46
55
|
|
|
47
56
|
if (options.json) {
|
|
48
|
-
console.log(JSON.stringify(plan, null, 2));
|
|
57
|
+
console.log(JSON.stringify({ ...plan, useCases: ucCheck }, null, 2));
|
|
58
|
+
if (ucCheck.errors.length) process.exit(1);
|
|
49
59
|
return;
|
|
50
60
|
}
|
|
51
61
|
|
|
62
|
+
const plural = (n, one, many) => `${n} ${n === 1 ? one : many}`;
|
|
63
|
+
|
|
64
|
+
if (ucCheck.errors.length) {
|
|
65
|
+
console.error(chalk.red(`\n❌ Use cases — ${plural(ucCheck.errors.length, 'erro', 'erros')}\n`));
|
|
66
|
+
for (const e of ucCheck.errors) console.error(chalk.red(` ${e}`));
|
|
67
|
+
console.error(chalk.gray('\n Corrija a referência no tasks.json ou restaure o use case.\n'));
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
if (ucCheck.warnings.length) {
|
|
71
|
+
console.error(chalk.yellow(`\n⚠ Use cases — ${plural(ucCheck.warnings.length, 'aviso', 'avisos')}`));
|
|
72
|
+
for (const w of ucCheck.warnings) console.error(chalk.yellow(` ${w}`));
|
|
73
|
+
}
|
|
74
|
+
|
|
52
75
|
console.log(chalk.bold(`\n📊 DAG — ${featureName}`));
|
|
53
76
|
console.log('━'.repeat(60));
|
|
54
77
|
|
|
@@ -36,17 +36,24 @@ import { validateTasksJson } from '../../lib/tasks/tasks-json-parser.js';
|
|
|
36
36
|
*/
|
|
37
37
|
const OUTPUT_SECTIONS = {
|
|
38
38
|
// morph-brainstorming emits: Problema, Escopo, Usuários e fluxos críticos,
|
|
39
|
-
//
|
|
39
|
+
// Stack, Riscos e dependências, Compliance de stack.
|
|
40
|
+
//
|
|
41
|
+
// "Critérios de aceite" is now OPTIONAL, not required: the behaviour layer
|
|
42
|
+
// moved to the use cases (2-plan/usecases/), whose post-conditions must be
|
|
43
|
+
// observable in a test, and morph-brainstorming no longer emits the section.
|
|
44
|
+
// `optional` — not `recommended` — precisely because the skill never emits it
|
|
45
|
+
// anymore: a warning on every new proposal would be systematic noise. Legacy
|
|
46
|
+
// proposals that DO carry the section keep validating exactly as before.
|
|
40
47
|
'proposal.md': {
|
|
41
48
|
required: [
|
|
42
49
|
['Problema', 'Problem'],
|
|
43
50
|
['Escopo', 'Scope'],
|
|
44
|
-
['Critérios de aceite', 'Critério', 'Aceite', 'Acceptance Criteria', 'Acceptance', 'Success Criteria', 'Success Metrics'],
|
|
45
51
|
],
|
|
46
52
|
recommended: [
|
|
47
53
|
['Riscos', 'Risks'],
|
|
48
54
|
],
|
|
49
55
|
optional: [
|
|
56
|
+
['Critérios de aceite', 'Critério', 'Aceite', 'Acceptance Criteria', 'Acceptance', 'Success Criteria', 'Success Metrics'],
|
|
50
57
|
'Stack',
|
|
51
58
|
['Usuários', 'Usuarios', 'Users', 'User Journey', 'User Stories', 'Fluxos'],
|
|
52
59
|
['Dependências', 'Dependencias', 'Dependencies'],
|
|
@@ -15,7 +15,7 @@ import { join } from 'path';
|
|
|
15
15
|
* Schema for output types produced by morph-spec NLH phases.
|
|
16
16
|
*
|
|
17
17
|
* Fields:
|
|
18
|
-
* filename - The file's basename (e.g. 'spec.md')
|
|
18
|
+
* filename - The file's basename (e.g. 'spec.md'). Single-file types only.
|
|
19
19
|
* phaseDir - Subdirectory within .morph/features/{feature}/
|
|
20
20
|
* phase - The morph-spec phase that produces this output
|
|
21
21
|
* protected - Whether the file requires an approval gate before editing
|
|
@@ -25,6 +25,13 @@ import { join } from 'path';
|
|
|
25
25
|
* Lets the implement phase update task progress without
|
|
26
26
|
* unfreezing the plan contract. When present, the protect
|
|
27
27
|
* hook does a semantic diff instead of a blanket block.
|
|
28
|
+
*
|
|
29
|
+
* Multi-file types (`multi: true`) hold N files in a directory instead of one
|
|
30
|
+
* fixed basename. They carry `dirname` + `filenamePattern` + `displayFilename`
|
|
31
|
+
* INSTEAD of `filename`, and every derivation keyed by basename
|
|
32
|
+
* (FILENAME_TO_OUTPUT, PROTECTED_SPEC_FILES, MUTABLE_SPEC_FIELDS) skips them —
|
|
33
|
+
* there is no single basename to key on. `getOutputPath` returns the DIRECTORY
|
|
34
|
+
* for these types.
|
|
28
35
|
*/
|
|
29
36
|
export const OUTPUT_SCHEMA = {
|
|
30
37
|
// 0-proposal/
|
|
@@ -43,6 +50,19 @@ export const OUTPUT_SCHEMA = {
|
|
|
43
50
|
mandate: { filename: 'mandate.md', phaseDir: '2-plan', phase: 'plan', protected: true, approvalGate: 'plan' },
|
|
44
51
|
tasks: { filename: 'tasks.json', phaseDir: '2-plan', phase: 'plan', protected: true, approvalGate: 'plan', mutableFields: ['status', 'outputs', 'notes'] },
|
|
45
52
|
decisions: { filename: 'decisions.md', phaseDir: '2-plan', phase: 'plan', protected: false },
|
|
53
|
+
// N files, one per system use case — the behaviour layer the plan distils
|
|
54
|
+
// BEFORE spec.md/tasks.json. Deliberately NOT protected: editing a use case
|
|
55
|
+
// after Gate 2 is the supported way to change behaviour and re-run implement;
|
|
56
|
+
// the audit trail lives in git.
|
|
57
|
+
usecases: {
|
|
58
|
+
dirname: 'usecases',
|
|
59
|
+
filenamePattern: '^UC-\\d+-[a-z0-9-]+\\.md$',
|
|
60
|
+
displayFilename: 'UC-{nn}-{slug}.md',
|
|
61
|
+
phaseDir: '2-plan',
|
|
62
|
+
phase: 'plan',
|
|
63
|
+
protected: false,
|
|
64
|
+
multi: true,
|
|
65
|
+
},
|
|
46
66
|
|
|
47
67
|
// 3-implement/
|
|
48
68
|
recap: { filename: 'recap.md', phaseDir: '3-implement', phase: 'implement', protected: false },
|
|
@@ -60,9 +80,21 @@ export const OUTPUT_SCHEMA = {
|
|
|
60
80
|
// Builder Functions (Windows-safe via path.join)
|
|
61
81
|
// ============================================================================
|
|
62
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Leaf segment of a schema entry: the basename for single-file types, the
|
|
85
|
+
* directory name for multi-file ones. Keeps every path builder from having to
|
|
86
|
+
* know about `multi` on its own.
|
|
87
|
+
*/
|
|
88
|
+
function leafSegment(entry) {
|
|
89
|
+
return entry.multi ? entry.dirname : entry.filename;
|
|
90
|
+
}
|
|
91
|
+
|
|
63
92
|
/**
|
|
64
93
|
* Get the morph-relative path for a specific output type of a feature.
|
|
65
94
|
*
|
|
95
|
+
* For `multi` types this is the DIRECTORY holding the N files
|
|
96
|
+
* (".morph/features/my-feature/2-plan/usecases"), not a single file.
|
|
97
|
+
*
|
|
66
98
|
* @param {string} featureName
|
|
67
99
|
* @param {string} outputType - camelCase output type key from OUTPUT_SCHEMA
|
|
68
100
|
* @returns {string} e.g. ".morph/features/my-feature/2-plan/spec.md"
|
|
@@ -72,19 +104,38 @@ export function getOutputPath(featureName, outputType) {
|
|
|
72
104
|
if (!entry) {
|
|
73
105
|
throw new Error(`Unknown output type: "${outputType}". Valid types: ${Object.keys(OUTPUT_SCHEMA).join(', ')}`);
|
|
74
106
|
}
|
|
75
|
-
return join('.morph', 'features', featureName, entry.phaseDir, entry
|
|
107
|
+
return join('.morph', 'features', featureName, entry.phaseDir, leafSegment(entry))
|
|
76
108
|
.replace(/\\/g, '/');
|
|
77
109
|
}
|
|
78
110
|
|
|
79
111
|
/**
|
|
80
112
|
* Get the absolute filesystem path for a specific output type.
|
|
113
|
+
* For `multi` types this is the directory — see {@link getOutputPath}.
|
|
81
114
|
*/
|
|
82
115
|
export function getAbsoluteOutputPath(projectRoot, featureName, outputType) {
|
|
83
116
|
const entry = OUTPUT_SCHEMA[outputType];
|
|
84
117
|
if (!entry) {
|
|
85
118
|
throw new Error(`Unknown output type: "${outputType}". Valid types: ${Object.keys(OUTPUT_SCHEMA).join(', ')}`);
|
|
86
119
|
}
|
|
87
|
-
return join(projectRoot, '.morph', 'features', featureName, entry.phaseDir, entry
|
|
120
|
+
return join(projectRoot, '.morph', 'features', featureName, entry.phaseDir, leafSegment(entry));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Human-readable path of a schema entry, relative to the feature directory.
|
|
125
|
+
*
|
|
126
|
+
* SINGLE source for the `morph-outputs` tables: `scripts/generate-refs.js`
|
|
127
|
+
* writes them and `test/framework/refs.test.js` verifies them, and if the two
|
|
128
|
+
* rebuilt the string independently they would drift apart the first time the
|
|
129
|
+
* schema grew a new shape. Multi-file types render the filename PATTERN, so a
|
|
130
|
+
* reader sees it's a folder of N files.
|
|
131
|
+
*
|
|
132
|
+
* @param {Object} entry - an OUTPUT_SCHEMA value
|
|
133
|
+
* @returns {string} e.g. "2-plan/spec.md" or "2-plan/usecases/UC-{nn}-{slug}.md"
|
|
134
|
+
*/
|
|
135
|
+
export function getSchemaDisplayPath(entry) {
|
|
136
|
+
return entry.multi
|
|
137
|
+
? `${entry.phaseDir}/${entry.dirname}/${entry.displayFilename}`
|
|
138
|
+
: `${entry.phaseDir}/${entry.filename}`;
|
|
88
139
|
}
|
|
89
140
|
|
|
90
141
|
/**
|
|
@@ -119,6 +170,9 @@ export function getAbsoluteTaskReportPath(projectRoot, featureName, taskId) {
|
|
|
119
170
|
|
|
120
171
|
/**
|
|
121
172
|
* Get all output paths for a feature, ready for state.json `outputs` block.
|
|
173
|
+
*
|
|
174
|
+
* Multi-file types contribute their DIRECTORY, so `created` for them means
|
|
175
|
+
* "the folder exists" — the state index tracks presence, not file count.
|
|
122
176
|
*/
|
|
123
177
|
export function getAllOutputPaths(featureName) {
|
|
124
178
|
const outputs = {};
|
|
@@ -135,8 +189,14 @@ export function getAllOutputPaths(featureName) {
|
|
|
135
189
|
// Derived Constants
|
|
136
190
|
// ============================================================================
|
|
137
191
|
|
|
192
|
+
/**
|
|
193
|
+
* filename → output type. Multi-file types are absent by construction: they
|
|
194
|
+
* have N basenames, so there is nothing to key on.
|
|
195
|
+
*/
|
|
138
196
|
export const FILENAME_TO_OUTPUT = Object.fromEntries(
|
|
139
|
-
Object.entries(OUTPUT_SCHEMA)
|
|
197
|
+
Object.entries(OUTPUT_SCHEMA)
|
|
198
|
+
.filter(([, entry]) => !entry.multi)
|
|
199
|
+
.map(([key, entry]) => [entry.filename, key])
|
|
140
200
|
);
|
|
141
201
|
|
|
142
202
|
export const OUTPUT_PHASE_MAP = Object.fromEntries(
|
|
@@ -148,7 +208,7 @@ export const OUTPUT_PHASE_MAP = Object.fromEntries(
|
|
|
148
208
|
*/
|
|
149
209
|
export const PROTECTED_SPEC_FILES = Object.fromEntries(
|
|
150
210
|
Object.entries(OUTPUT_SCHEMA)
|
|
151
|
-
.filter(([, entry]) => entry.protected && entry.approvalGate)
|
|
211
|
+
.filter(([, entry]) => entry.protected && entry.approvalGate && !entry.multi)
|
|
152
212
|
.map(([, entry]) => [entry.filename, entry.approvalGate])
|
|
153
213
|
);
|
|
154
214
|
|
|
@@ -160,7 +220,7 @@ export const PROTECTED_SPEC_FILES = Object.fromEntries(
|
|
|
160
220
|
*/
|
|
161
221
|
export const MUTABLE_SPEC_FIELDS = Object.fromEntries(
|
|
162
222
|
Object.entries(OUTPUT_SCHEMA)
|
|
163
|
-
.filter(([, entry]) => entry.protected && entry.approvalGate && entry.mutableFields)
|
|
223
|
+
.filter(([, entry]) => entry.protected && entry.approvalGate && entry.mutableFields && !entry.multi)
|
|
164
224
|
.map(([, entry]) => [entry.filename, entry.mutableFields])
|
|
165
225
|
);
|
|
166
226
|
|
|
@@ -42,6 +42,11 @@ export function collectPhaseOutputs(projectRoot, featureName) {
|
|
|
42
42
|
const results = [];
|
|
43
43
|
|
|
44
44
|
for (const [key, entry] of Object.entries(OUTPUT_SCHEMA)) {
|
|
45
|
+
// Multi-file types resolve to a DIRECTORY — there is no single document to
|
|
46
|
+
// weigh as one source. Skipped explicitly rather than left to fail as an
|
|
47
|
+
// EISDIR swallowed by the catch below.
|
|
48
|
+
if (entry.multi) continue;
|
|
49
|
+
|
|
45
50
|
const absPath = getAbsoluteOutputPath(projectRoot, featureName, key);
|
|
46
51
|
if (!existsSync(absPath)) continue;
|
|
47
52
|
|
|
@@ -29,9 +29,11 @@ const PHASE_DIRS = {
|
|
|
29
29
|
review: '4-review',
|
|
30
30
|
};
|
|
31
31
|
|
|
32
|
-
/** Ordered pointer candidates — only existing
|
|
32
|
+
/** Ordered pointer candidates — only existing paths make it into the doc. */
|
|
33
33
|
const POINTER_CANDIDATES = [
|
|
34
|
-
['0-proposal/proposal.md', 'proposta
|
|
34
|
+
['0-proposal/proposal.md', 'proposta: problema, escopo e riscos'],
|
|
35
|
+
// Behaviour before solution — the same order morph-plan writes them in.
|
|
36
|
+
['2-plan/usecases', 'use cases: comportamento e pós-condições testáveis (pasta)'],
|
|
35
37
|
['2-plan/spec.md', 'especificação técnica'],
|
|
36
38
|
['2-plan/mandate.md', 'contrato de implementação (leia antes de cada task)'],
|
|
37
39
|
['2-plan/tasks.json', 'DAG de tasks (status vivo)'],
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-check between a feature's use cases and its `tasks.json`.
|
|
3
|
+
*
|
|
4
|
+
* This is the mechanical half of the use-case layer. Whether a post-condition is
|
|
5
|
+
* *observable in a test* is semantic and stays with Gate 2 and the evaluator —
|
|
6
|
+
* no checker can decide it. What IS decidable is the plumbing, and the plumbing
|
|
7
|
+
* is where the requirement actually leaked before:
|
|
8
|
+
*
|
|
9
|
+
* - a task pointing at a use case that does not exist → ERROR (a broken plan)
|
|
10
|
+
* - a post-condition no task ever mentions → warning (orphan)
|
|
11
|
+
* - a task on a use case whose `doneCriteria` carries → warning (paraphrased
|
|
12
|
+
* none of its post-conditions verbatim instead of copied)
|
|
13
|
+
*
|
|
14
|
+
* Two deliberate asymmetries:
|
|
15
|
+
*
|
|
16
|
+
* **Coverage is computed from the post-condition's side, never the task's.**
|
|
17
|
+
* `usecase` is optional by design — a technical task with no observable
|
|
18
|
+
* behaviour has none — so "task without a use case" is never a finding.
|
|
19
|
+
*
|
|
20
|
+
* **Matching normalizes case and whitespace.** The rule is "copy the text
|
|
21
|
+
* verbatim", but a plan that prefixes the label (`"P2 — cada veterinário…"`)
|
|
22
|
+
* naturally lowercases the first letter. A raw `includes()` would reject the
|
|
23
|
+
* very example the `morph-plan` skill teaches. Accents and wording still have
|
|
24
|
+
* to match: that is the part worth being strict about.
|
|
25
|
+
*
|
|
26
|
+
* Fail-open like the promoter: no `usecases/` folder means nothing to check.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { existsSync, readdirSync, readFileSync } from 'fs';
|
|
30
|
+
import { join } from 'path';
|
|
31
|
+
import { useCaseId } from './usecase-promoter.js';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A post-condition line: `- **P2** — Cada veterinário traz suas especialidades`.
|
|
35
|
+
* The separator is tolerant (em dash, en dash or hyphen) because the character
|
|
36
|
+
* is a typing accident, not a decision.
|
|
37
|
+
*/
|
|
38
|
+
const POSTCONDITION_RE = /^[ \t]*[-*][ \t]*\*\*(P\d+)\*\*[ \t]*[—–-][ \t]*(.+?)[ \t]*$/gm;
|
|
39
|
+
|
|
40
|
+
/** Case-folded, whitespace-collapsed form used for containment tests. */
|
|
41
|
+
function normalize(text) {
|
|
42
|
+
return String(text).toLowerCase().replace(/\s+/g, ' ').trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Parse one use-case document into its post-conditions.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} content
|
|
49
|
+
* @returns {Array<{ label: string, text: string }>}
|
|
50
|
+
*/
|
|
51
|
+
export function parsePostConditions(content) {
|
|
52
|
+
const out = [];
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
for (const m of String(content).matchAll(POSTCONDITION_RE)) {
|
|
55
|
+
const [, label, text] = m;
|
|
56
|
+
if (seen.has(label)) continue; // first wins — a repeated label is one post-condition
|
|
57
|
+
seen.add(label);
|
|
58
|
+
out.push({ label, text: text.trim() });
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Read every use case of a feature, keyed by ID.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} usecasesDir - absolute path to `2-plan/usecases`
|
|
67
|
+
* @returns {Map<string, { id: string, file: string, postConditions: Array<{label: string, text: string}> }>}
|
|
68
|
+
*/
|
|
69
|
+
export function loadUseCases(usecasesDir) {
|
|
70
|
+
const byId = new Map();
|
|
71
|
+
if (!existsSync(usecasesDir)) return byId;
|
|
72
|
+
|
|
73
|
+
let names;
|
|
74
|
+
try {
|
|
75
|
+
names = readdirSync(usecasesDir).sort();
|
|
76
|
+
} catch {
|
|
77
|
+
return byId; // unreadable folder is "no use cases", never a crash
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const file of names) {
|
|
81
|
+
const id = useCaseId(file);
|
|
82
|
+
if (!id || !file.endsWith('.md')) continue;
|
|
83
|
+
try {
|
|
84
|
+
const content = readFileSync(join(usecasesDir, file), 'utf8');
|
|
85
|
+
byId.set(id, { id, file, postConditions: parsePostConditions(content) });
|
|
86
|
+
} catch { /* one unreadable use case must not sink the rest */ }
|
|
87
|
+
}
|
|
88
|
+
return byId;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Cross-check `tasks.json` against the feature's use cases.
|
|
93
|
+
*
|
|
94
|
+
* @param {Object} tasksData - parsed tasks.json (already schema-valid)
|
|
95
|
+
* @param {string} usecasesDir - absolute path to `2-plan/usecases`
|
|
96
|
+
* @returns {{ errors: string[], warnings: string[], checked: boolean }}
|
|
97
|
+
* `checked: false` means there were no use cases to check against.
|
|
98
|
+
*/
|
|
99
|
+
export function checkUseCases(tasksData, usecasesDir) {
|
|
100
|
+
const errors = [];
|
|
101
|
+
const warnings = [];
|
|
102
|
+
const byId = loadUseCases(usecasesDir);
|
|
103
|
+
const tasks = Array.isArray(tasksData?.tasks) ? tasksData.tasks : [];
|
|
104
|
+
|
|
105
|
+
// A task referencing a use case that does not exist is a broken plan, and the
|
|
106
|
+
// check is exact: the file is there or it is not. This is the only finding
|
|
107
|
+
// that earns an error — everything below is advisory by construction.
|
|
108
|
+
for (const task of tasks) {
|
|
109
|
+
if (!task?.usecase) continue;
|
|
110
|
+
if (!byId.has(task.usecase)) {
|
|
111
|
+
const known = [...byId.keys()].sort().join(', ') || 'nenhum';
|
|
112
|
+
errors.push(
|
|
113
|
+
`${task.id}.usecase → ${task.usecase} não existe em 2-plan/usecases/ (encontrados: ${known})`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (byId.size === 0) return { errors, warnings, checked: false };
|
|
119
|
+
|
|
120
|
+
// Pre-normalize every doneCriteria once: this is O(tasks × postConditions)
|
|
121
|
+
// and both are small, but the normalization itself is not free.
|
|
122
|
+
const criteria = tasks.map((t) => ({ id: t?.id, text: normalize(t?.doneCriteria || ''), usecase: t?.usecase }));
|
|
123
|
+
|
|
124
|
+
// Orphan post-conditions — computed from the post-condition's side, so a task
|
|
125
|
+
// without a `usecase` never counts against anyone.
|
|
126
|
+
//
|
|
127
|
+
// A use case NOBODY touches collapses into a single aggregated warning. The
|
|
128
|
+
// per-post-condition list is the useful shape while a plan is being closed
|
|
129
|
+
// (three of four covered → fix the fourth), but `dag` also runs on every
|
|
130
|
+
// implement cycle, where an untouched use case would repeat N identical lines
|
|
131
|
+
// forever. Losing the finding entirely would be worse: a use case written and
|
|
132
|
+
// then ignored by the whole plan is the failure this layer exists to catch.
|
|
133
|
+
for (const uc of byId.values()) {
|
|
134
|
+
const withText = uc.postConditions.filter((pc) => normalize(pc.text));
|
|
135
|
+
if (withText.length === 0) continue;
|
|
136
|
+
|
|
137
|
+
const orphans = withText.filter((pc) => !criteria.some((c) => c.text.includes(normalize(pc.text))));
|
|
138
|
+
if (orphans.length === 0) continue;
|
|
139
|
+
|
|
140
|
+
const untouched = orphans.length === withText.length && !criteria.some((c) => c.usecase === uc.id);
|
|
141
|
+
if (untouched) {
|
|
142
|
+
warnings.push(
|
|
143
|
+
`${uc.id} não é referenciado por nenhuma task — ${withText.length} pós-condição(ões) sem cobertura ` +
|
|
144
|
+
`(${withText.map((p) => p.label).join('/')})`
|
|
145
|
+
);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
for (const pc of orphans) {
|
|
149
|
+
warnings.push(
|
|
150
|
+
`${uc.id} ${pc.label} não é coberta por nenhuma task — nenhum doneCriteria carrega "${pc.text}"`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Tasks that claim a use case but paraphrased it instead of copying it.
|
|
156
|
+
for (const c of criteria) {
|
|
157
|
+
if (!c.usecase) continue;
|
|
158
|
+
const uc = byId.get(c.usecase);
|
|
159
|
+
if (!uc || uc.postConditions.length === 0) continue;
|
|
160
|
+
const carries = uc.postConditions.some((pc) => {
|
|
161
|
+
const needle = normalize(pc.text);
|
|
162
|
+
return needle && c.text.includes(needle);
|
|
163
|
+
});
|
|
164
|
+
if (!carries) {
|
|
165
|
+
warnings.push(
|
|
166
|
+
`${c.id}.doneCriteria não carrega nenhuma pós-condição de ${c.usecase} literalmente ` +
|
|
167
|
+
`(esperado o texto de ${uc.postConditions.map((p) => p.label).join('/')})`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { errors, warnings, checked: true };
|
|
173
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Promotion of a feature's use cases into the project's permanent archive.
|
|
3
|
+
*
|
|
4
|
+
* A use case is born inside a feature (`2-plan/usecases/UC-{nn}-{slug}.md`) and
|
|
5
|
+
* is promoted, at archive time, into `.morph/specs/usecases/` — the living
|
|
6
|
+
* description of the system's behaviour, which outlives the feature that
|
|
7
|
+
* introduced it. The archive is COMMITTED (no gitignore rule covers it).
|
|
8
|
+
*
|
|
9
|
+
* Contract, in the order it matters:
|
|
10
|
+
* - **Fail-open.** Every failure degrades to "nothing promoted". Promotion may
|
|
11
|
+
* never block the close of a feature — that is why the caller treats a throw
|
|
12
|
+
* as a no-op rather than an error.
|
|
13
|
+
* - **No-op without the folder.** A feature with no use cases promotes nothing.
|
|
14
|
+
* - **Idempotent.** Re-promoting the same use case rewrites the same content.
|
|
15
|
+
* - **Overwrite by ID, not by filename.** `UC-07-old-slug.md` in the archive is
|
|
16
|
+
* REPLACED by an incoming `UC-07-new-slug.md`. Keying on the filename would
|
|
17
|
+
* leave two files claiming UC-07 the first time a slug is reworded — and the
|
|
18
|
+
* `bug` work type exists precisely to correct an existing use case.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, rmSync } from 'fs';
|
|
22
|
+
import { join } from 'path';
|
|
23
|
+
import { OUTPUT_SCHEMA } from '../../core/paths/output-schema.js';
|
|
24
|
+
|
|
25
|
+
const UC = OUTPUT_SCHEMA.usecases;
|
|
26
|
+
const UC_FILENAME_RE = new RegExp(UC.filenamePattern);
|
|
27
|
+
|
|
28
|
+
/** Project-relative path of the permanent use-case archive (git pathspec-ready). */
|
|
29
|
+
export const SPECS_USECASES_PATHSPEC = join('.morph', 'specs', 'usecases');
|
|
30
|
+
|
|
31
|
+
/** `UC-07-listar-veterinarios.md` → `UC-07`. Null when the name isn't a use case. */
|
|
32
|
+
export function useCaseId(filename) {
|
|
33
|
+
const m = /^(UC-\d+)-/.exec(filename);
|
|
34
|
+
return m ? m[1] : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Rewrite the frontmatter `status:` to `implemented`.
|
|
39
|
+
*
|
|
40
|
+
* A file with no frontmatter is copied verbatim: the use case belongs to the
|
|
41
|
+
* author, and inventing a header would be a silent edit of their document.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} content
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
export function stampImplemented(content) {
|
|
47
|
+
const m = /^(?---\r?\n)([\s\S]*?)(\r?\n---(?:\r?\n|$))/.exec(content);
|
|
48
|
+
if (!m || m.index !== 0) return content;
|
|
49
|
+
|
|
50
|
+
const [full, open, body, close] = m;
|
|
51
|
+
const eol = full.includes('\r\n') ? '\r\n' : '\n';
|
|
52
|
+
const statusLine = /^status:[ \t]*.*$/m;
|
|
53
|
+
const next = statusLine.test(body)
|
|
54
|
+
? body.replace(statusLine, 'status: implemented')
|
|
55
|
+
: `${body}${eol}status: implemented`;
|
|
56
|
+
|
|
57
|
+
return open + next + close + content.slice(full.length);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Copy every use case of a feature into the project's permanent archive.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} cwd - project root that owns `.morph/specs/`
|
|
64
|
+
* @param {string} featureDir - the feature folder holding `2-plan/usecases/`
|
|
65
|
+
* @returns {{ promoted: string[], replaced: string[] }} filenames, always arrays
|
|
66
|
+
*/
|
|
67
|
+
export function promoteUseCases(cwd, featureDir) {
|
|
68
|
+
const empty = { promoted: [], replaced: [] };
|
|
69
|
+
const srcDir = join(featureDir, UC.phaseDir, UC.dirname);
|
|
70
|
+
|
|
71
|
+
let names;
|
|
72
|
+
try {
|
|
73
|
+
if (!existsSync(srcDir)) return empty;
|
|
74
|
+
names = readdirSync(srcDir).filter((n) => UC_FILENAME_RE.test(n)).sort();
|
|
75
|
+
} catch {
|
|
76
|
+
return empty;
|
|
77
|
+
}
|
|
78
|
+
if (!names.length) return empty;
|
|
79
|
+
|
|
80
|
+
const destDir = join(cwd, '.morph', 'specs', 'usecases');
|
|
81
|
+
const promoted = [];
|
|
82
|
+
const replaced = [];
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
mkdirSync(destDir, { recursive: true });
|
|
86
|
+
} catch {
|
|
87
|
+
return empty;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Existing archive entries, indexed by use-case ID — the key the overwrite
|
|
91
|
+
// rule is written against.
|
|
92
|
+
const existingById = new Map();
|
|
93
|
+
try {
|
|
94
|
+
for (const name of readdirSync(destDir)) {
|
|
95
|
+
const id = UC_FILENAME_RE.test(name) ? useCaseId(name) : null;
|
|
96
|
+
if (id) existingById.set(id, name);
|
|
97
|
+
}
|
|
98
|
+
} catch { /* unreadable archive — treat as empty, never fatal */ }
|
|
99
|
+
|
|
100
|
+
for (const name of names) {
|
|
101
|
+
try {
|
|
102
|
+
const content = stampImplemented(readFileSync(join(srcDir, name), 'utf8'));
|
|
103
|
+
const id = useCaseId(name);
|
|
104
|
+
const stale = id ? existingById.get(id) : null;
|
|
105
|
+
|
|
106
|
+
// Same ID under a different filename: drop the old file, or the archive
|
|
107
|
+
// would end up with two documents claiming the same UC number.
|
|
108
|
+
if (stale && stale !== name) {
|
|
109
|
+
rmSync(join(destDir, stale), { force: true });
|
|
110
|
+
replaced.push(stale);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
writeFileSync(join(destDir, name), content, 'utf8');
|
|
114
|
+
promoted.push(name);
|
|
115
|
+
if (id) existingById.set(id, name);
|
|
116
|
+
} catch { /* one unreadable use case must not sink the rest */ }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { promoted, replaced };
|
|
120
|
+
}
|
|
@@ -26,6 +26,8 @@ export function getSchema() {
|
|
|
26
26
|
const VALID_STATUS = ['pending', 'in_progress', 'done', 'skipped', 'blocked'];
|
|
27
27
|
const VALID_EFFORT = ['S', 'M', 'L', 'XL'];
|
|
28
28
|
const VALID_GROUP = ['backend', 'frontend', 'tests', 'infra', 'docs'];
|
|
29
|
+
/** Use-case reference: the ID only (`UC-07`), never the filename. */
|
|
30
|
+
const USECASE_RE = /^UC-\d+$/;
|
|
29
31
|
|
|
30
32
|
/**
|
|
31
33
|
* Validate a tasks.json structure.
|
|
@@ -79,6 +81,12 @@ export function validateTasksJson(data) {
|
|
|
79
81
|
if (task.group !== undefined && !VALID_GROUP.includes(task.group)) {
|
|
80
82
|
errors.push(`${ctx}.group must be one of ${VALID_GROUP.join(', ')}`);
|
|
81
83
|
}
|
|
84
|
+
// Optional: a task with no observable behaviour references no use case.
|
|
85
|
+
// When present it must be the bare ID — `morph-spec dag` is the gate that
|
|
86
|
+
// catches "UC-07-listar.md" or "UC7" before the plan reaches Gate 2.
|
|
87
|
+
if (task.usecase !== undefined && (typeof task.usecase !== 'string' || !USECASE_RE.test(task.usecase))) {
|
|
88
|
+
errors.push(`${ctx}.usecase must match /^UC-\\d+$/ (the ID alone, e.g. "UC-07")`);
|
|
89
|
+
}
|
|
82
90
|
}
|
|
83
91
|
|
|
84
92
|
return { valid: errors.length === 0, errors };
|