@polymorphism-tech/morph-spec 8.2.0 → 8.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +1 -1
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -1
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/CLAUDE.md +2 -2
  7. package/framework/MORPH.md +20 -2
  8. package/framework/hooks/claude-code/core/protect-spec-files.js +7 -1
  9. package/framework/hooks/shared/build-check.js +8 -1
  10. package/framework/hooks/shared/payload-utils.js +16 -2
  11. package/framework/hooks/shared/phase-utils.js +2 -0
  12. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  13. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  14. package/framework/skills/core/morph-apply/SKILL.md +12 -7
  15. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  16. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  17. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  18. package/framework/skills/core/morph-implement/SKILL.md +22 -10
  19. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  20. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  21. package/framework/skills/core/morph-review/SKILL.md +19 -12
  22. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  23. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  24. package/package.json +1 -1
  25. package/src/commands/project/worktree.js +150 -36
  26. package/src/commands/state/score.js +13 -2
  27. package/src/commands/tasks/dag.js +11 -3
  28. package/src/core/paths/output-schema.js +28 -0
  29. package/src/lib/stack/stack-profile.js +40 -9
  30. package/src/lib/tasks/tasks-json-parser.js +21 -0
  31. package/src/lib/tasks/test-runner.js +26 -4
  32. package/src/lib/validators/validation-runner.js +4 -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.2.0
6
+ **Version:** 8.2.1
7
7
  **Requires:** Node.js 22+, Claude Code
8
8
 
9
9
  ---
@@ -394,4 +394,4 @@ Code generated by morph-spec (contracts, templates, implementation output) belon
394
394
 
395
395
  ---
396
396
 
397
- *morph-spec v8.2.0 by [Polymorphism Tech](https://polymorphism.tech)*
397
+ *morph-spec v8.2.1 by [Polymorphism Tech](https://polymorphism.tech)*
package/bin/morph-spec.js CHANGED
@@ -186,7 +186,7 @@ program
186
186
  program
187
187
  .command('score <feature> <task> <score>')
188
188
  .description('Record a task eval score in feature.json (taskScores, server-side timestamp)')
189
- .option('--dims <dims>', 'Four comma-separated dimension scores in order a,c,q,t (architecture,contracts,code-quality,test-coverage)')
189
+ .option('--dims <dims...>', 'Four dimension scores in order a,c,q,t (architecture,contracts,code-quality,test-coverage) — comma- or space-separated; quote under PowerShell: --dims "9.5,9,9,9"')
190
190
  .option('--json', 'Output as JSON')
191
191
  .action((feature, task, score, options) => scoreCommand(feature, task, score, options));
192
192
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "morph-spec",
3
- "version": "8.2.0",
3
+ "version": "8.2.1",
4
4
  "displayName": "MORPH-SPEC Framework",
5
5
  "description": "Spec-driven development with 38 agents and 8-phase workflow for .NET/Blazor/Next.js/Azure",
6
6
  "publisher": "polymorphism-tech",
@@ -239,4 +239,4 @@ These files are never edited directly. Use CLI commands or `morph-spec update` i
239
239
 
240
240
  ---
241
241
 
242
- *morph-spec v8.2.0 by Polymorphism Tech*
242
+ *morph-spec v8.2.1 by Polymorphism Tech*
@@ -256,4 +256,4 @@ morph-spec doctor
256
256
 
257
257
  ---
258
258
 
259
- *morph-spec v8.2.0 by Polymorphism Tech*
259
+ *morph-spec v8.2.1 by Polymorphism Tech*
@@ -26,7 +26,7 @@ Se não houver feature ativa, pergunte ao usuário qual feature trabalhar.
26
26
  - Apresentar gates com o template fixo definido em MORPH.md / morph-plan.
27
27
  - Registrar decisões não triviais em `decisions.md`.
28
28
  - Aplicar `morph-eval` após cada tarefa; silêncio em score ≥ 9 é o sinal correto. Score < 9 dispara o loop de correção autônomo (re-implementa, sem pausa), não escala ao humano entre tarefas. No Gate 3, o score composto da feature vem do **avaliador independente** despachado pela `morph-review` (persona `evaluator`, contexto fresco) — nunca de auto-avaliação de quem implementou.
29
- - Incluir as 3 cláusulas do contrato do relatório final em todo dispatch de sub-agent (**ato final = o relatório é a ÚLTIMA MENSAGEM DE TEXTO do sub-agent, com os 4 itens obrigatórios (o quê / arquivos / problemas / auto-score 0-10) — ela é o valor de retorno e chega inteira ao orquestrador; não chamar `SendMessage`, que gera um evento a mais e custa um turno inútil**; não tocar tasks.json/feature.json/board; sem build/test da suíte compartilhada em batch paralelo) — templates em `morph-implement` §4 e `morph-apply` §3b.
29
+ - Incluir as 3 cláusulas do contrato do relatório final em todo dispatch de sub-agent (**o sub-agent GRAVA o relatório em arquivo canônico ANTES de encerrar — `.morph/features/{f}/3-implement/reports/{taskId}.md`, ou `4-review/evaluator-report.md` para o avaliador — com os 4 itens obrigatórios (o quê / arquivos / problemas / auto-score 0-10); esse arquivo é o valor de retorno autoritativo, a última mensagem de texto é cortesia best-effort e pode não chegar inteira; ainda assim não chamar `SendMessage`, que gera um evento a mais e custa um turno inútil**; não tocar tasks.json/feature.json/board; sem build/test da suíte compartilhada em batch paralelo) — templates em `morph-implement` §4 e `morph-apply` §3b. **O orquestrador lê o arquivo ao retomar controle** — nunca confia só na mensagem de retorno da tool.
30
30
 
31
31
  ## Slash commands
32
32
 
@@ -45,7 +45,7 @@ Se não houver feature ativa, pergunte ao usuário qual feature trabalhar.
45
45
  - `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
46
  - `morph-spec archive <feature>` — arquiva a feature (atômico, git-aware); é o que `/morph-archive` chama.
47
47
  - `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).
48
- - `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**.
48
+ - `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.
49
49
  - `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.
50
50
  - `morph-spec create <feature> --type <feature|bug|chore|hotfix> [--request "<txt>"] [--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). `--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`); `--worktree` já nasce a feature no seu worktree próprio.
51
51
  - `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.
@@ -269,14 +269,32 @@ Inline (sem dispatch) é aceitável para tasks curtas (effort S/M) **dentro do d
269
269
  - Contexto de outras tarefas não relacionadas
270
270
  - Outputs de ferramentas não pertinentes à tarefa
271
271
 
272
- **O que receber de volta:**
272
+ **O ARQUIVO é o retorno, a última mensagem é cortesia.** Em uso real (feature `config-tenant-split`,
273
+ MORPH_GHLBrain, 2026-07-22 — overnight com dispatch de sub-agents), o evento recebido de volta do
274
+ sub-agent foi só um `idle_notification`, sem o texto — o orquestrador teve que reconstruir o
275
+ relatório do disco e pedir via `SendMessage` que o avaliador o persistisse. A última mensagem de
276
+ texto **pode não chegar** inteira ao orquestrador; por isso ela deixou de ser o canal
277
+ autoritativo. O sub-agent **sempre** grava o relatório também em um arquivo canônico:
278
+
279
+ - Task de implementação: `.morph/features/{feature}/3-implement/reports/{taskId}.md` (um arquivo
280
+ por task concluída, mesmo quando uma única sessão de sub-agent cobre várias tasks de um fluxo).
281
+ - Avaliador do Gate 3 (`morph-evaluator`): `.morph/features/{feature}/4-review/evaluator-report.md`
282
+ — o relatório BRUTO do avaliador; a `morph-review` o lê e sintetiza o `review-report.md`
283
+ apresentado no Gate 3 (os dois nunca colidem: um é saída crua do sub-agent, o outro é a curadoria
284
+ do orquestrador).
285
+
286
+ O orquestrador **lê o arquivo** ao retomar controle após o dispatch — nunca confia apenas na
287
+ mensagem de retorno da tool. Se a mensagem de texto final chegou inteira, ótimo (economiza uma
288
+ leitura); se não chegou, o arquivo já está lá.
289
+
290
+ **O que receber de volta (no arquivo, e na mensagem final quando ela chega):**
273
291
  - O que foi feito (2-3 frases)
274
292
  - Arquivos modificados (lista)
275
293
  - Problemas encontrados e como foram resolvidos
276
294
  - Auto-score 0-10 com justificativa em uma linha
277
295
 
278
296
  **Contrato do relatório final** (cláusulas que todo prompt de dispatch inclui — ver templates em `morph-implement` §4 e `morph-apply` §3b):
279
- 1. **ATO FINAL: o seu relatório é a sua ÚLTIMA MENSAGEM DE TEXTO** — ela é o valor de retorno do sub-agent e chega inteira ao orquestrador. **Não chame `SendMessage`**: ele gera um evento a mais (o teammate-message) além da notificação de término, custando um turno inútil ao orquestrador. Conteúdo obrigatório: (1) o que foi feito em 2-3 frases, (2) arquivos modificados, (3) problemas encontrados e como foram resolvidos, (4) auto-score 0-10 com justificativa em uma linha. Relatório oco ou sem os 4 itens é **falha de protocolo** — registrada no recap.
297
+ 1. **GRAVE o relatório no arquivo canônico do seu dispatch ANTES de encerrar** — é o valor de retorno autoritativo desta sessão, não a sua última mensagem de texto (que é cortesia best-effort e pode não chegar inteira ao orquestrador). Conteúdo obrigatório, idêntico no arquivo: (1) o que foi feito em 2-3 frases, (2) arquivos modificados, (3) problemas encontrados e como foram resolvidos, (4) auto-score 0-10 com justificativa em uma linha. Relatório ausente, oco, ou sem os 4 itens é **falha de protocolo** — registrada no recap. Ainda assim **não chame `SendMessage`**: ele gera um evento a mais (o teammate-message) além da notificação de término, custando um turno inútil ao orquestrador — a mensagem de texto final (cortesia) já é entregue automaticamente.
280
298
  2. Não tocar em `tasks.json`, `feature.json`, `state.json` nem no board de sessão (TaskCreate/TaskUpdate) — todo estado é gravado exclusivamente pelo orquestrador.
281
299
  3. **Verificação em batch paralelo — condicional ao isolamento:** em batch **sem isolamento** (agentes na mesma working tree), não rode build/test da suíte compartilhada — a verificação (`morph-spec verify`) é do orquestrador, uma vez, pós-batch. Em batch **com `isolation: "worktree"`** (quando o `morph-spec dag` recomenda, por colisão de `outputs` entre fluxos), a regra se inverte: rode build/test **dentro do seu worktree**, commite o trabalho na branch do worktree como último ato antes do relatório, reporte a branch + status de build, e **nunca** faça merge nem toque na árvore principal — a integração é do orquestrador.
282
300
 
@@ -52,7 +52,13 @@ function diffPlanContract(oldDoc, newDoc, mutableFields) {
52
52
  const violations = [];
53
53
  const mutable = new Set(mutableFields);
54
54
 
55
- if (oldDoc.version !== newDoc.version) {
55
+ // Backfilling a previously-absent `version` (undefined → any value) is a
56
+ // benign schema-tooling update, not a plan-contract change — it's what
57
+ // lets legacy tasks.json adopt the field so `morph-spec dag` can analyze
58
+ // it without a lenient fallback. An actual change between two SET
59
+ // versions is still a real contract change and stays blocked.
60
+ const versionBackfilled = oldDoc.version === undefined && newDoc.version !== undefined;
61
+ if (!versionBackfilled && oldDoc.version !== newDoc.version) {
56
62
  violations.push(`'version' changed (${oldDoc.version} → ${newDoc.version})`);
57
63
  }
58
64
  if (oldDoc.feature !== newDoc.feature) {
@@ -84,7 +84,14 @@ export function getBuildSteps(stack, { cwd, frontendPath, backendPath } = {}) {
84
84
 
85
85
  if (isFrontendStack(s)) {
86
86
  const frontendDir = frontendPath ? join(cwd, frontendPath) : cwd;
87
- if (existsSync(join(frontendDir, 'tsconfig.json'))) {
87
+ // node_modules guard (same fail-open as post-edit-typecheck.js): a fresh worktree before
88
+ // `morph-spec worktree link` has tsconfig.json (versioned) but no toolchain — running
89
+ // `npx tsc` there fetches an unrelated "tsc" package and fails noisily. No toolchain →
90
+ // no tsc step; the real typecheck runs wherever node_modules exists (root/CI).
91
+ if (
92
+ existsSync(join(frontendDir, 'tsconfig.json')) &&
93
+ existsSync(join(frontendDir, 'node_modules'))
94
+ ) {
88
95
  steps.push({ command: TSC_BUILD_CMD, cwd: frontendDir });
89
96
  }
90
97
  }
@@ -93,7 +93,16 @@ export function getToolName(payload) {
93
93
  * Build a normalized signature for loop detection.
94
94
  * - Edit/Write: "Edit:normalized/file/path"
95
95
  * - Bash: "Bash:first two words" (e.g., "Bash:npm test")
96
- * - Other: just the tool name
96
+ * - Every other tool WITH input: "Tool:<truncated JSON of tool_input>"
97
+ * - No tool_input at all: just the tool name
98
+ *
99
+ * The general (non-Edit/Write/Bash) case matters: a burst of `Read`/`Skill`/
100
+ * `Grep`/`Agent` calls against DIFFERENT targets (phase-context loading,
101
+ * post-task evaluation reads, parallel subagent dispatch) is normal work, not
102
+ * a loop. Collapsing every one of them to the bare tool name is what made
103
+ * loop-detect fire on exactly that legitimate traffic — a real, reported
104
+ * false positive. Folding in the actual input (rather than hand-listing a
105
+ * target field per tool) fixes it uniformly for every tool at once.
97
106
  *
98
107
  * @param {Object} payload - Hook payload from stdin
99
108
  * @returns {string} Signature string
@@ -116,5 +125,10 @@ export function getCommandSignature(payload) {
116
125
  return words ? `Bash:${words}` : 'Bash';
117
126
  }
118
127
 
119
- return tool;
128
+ try {
129
+ const json = JSON.stringify(input).replace(/\\/g, '/');
130
+ return json ? `${tool}:${json.slice(0, 200)}` : tool;
131
+ } catch {
132
+ return tool;
133
+ }
120
134
  }
@@ -35,6 +35,7 @@ export const OUTPUT_PHASE_MAP = {
35
35
  decisions: 'plan',
36
36
  recap: 'implement',
37
37
  reviewReport: 'review',
38
+ evaluatorReport: 'review',
38
39
  };
39
40
 
40
41
  /** Map filename → output type (camelCase) */
@@ -52,6 +53,7 @@ export const FILENAME_TO_OUTPUT = {
52
53
  'decisions.md': 'decisions',
53
54
  'recap.md': 'recap',
54
55
  'review-report.md': 'reviewReport',
56
+ 'evaluator-report.md': 'evaluatorReport',
55
57
  };
56
58
 
57
59
  /** Protected spec files and the approval gate that locks them */
@@ -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.2.0"
7
+ cliVersion: "8.2.1"
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.2.0"
7
+ cliVersion: "8.2.1"
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.2.0"
7
+ cliVersion: "8.2.1"
8
8
  ---
9
9
 
10
10
  # morph-apply
@@ -135,11 +135,16 @@ dispatch Agent (subagent_type: "morph-{id}") with prompt:
135
135
  Não modifique spec.md, mandate.md.
136
136
 
137
137
  Contrato do relatório final:
138
- 1. ATO FINAL — o seu relatório é a sua ÚLTIMA MENSAGEM DE TEXTO: ela é o
139
- valor de retorno desta sessão e chega inteira ao orquestrador. NÃO chame
140
- SendMessage: ele gera um evento a mais além da notificação de término,
141
- custando um turno inútil ao orquestrador. Escreva o relatório e encerre.
142
- Relatório oco, ou sem os itens abaixo, é falha de protocolo.
138
+ 1. GRAVE um relatório por task concluída em
139
+ `.morph/features/{feature}/3-implement/reports/{taskId}.md` ANTES de
140
+ encerrar — mesmo quando esta sessão cobre várias tasks do fluxo, um
141
+ arquivo por task. Esse arquivo é o valor de retorno autoritativo; a sua
142
+ última mensagem de texto é cortesia best-effort e pode não chegar
143
+ inteira ao orquestrador (o evento recebido às vezes é só um
144
+ idle_notification, sem texto). Ainda assim NÃO chame SendMessage: ele
145
+ gera um evento a mais além da notificação de término, custando um turno
146
+ inútil ao orquestrador. Relatório ausente, oco, ou sem os itens abaixo,
147
+ é falha de protocolo.
143
148
  2. NÃO toque em tasks.json, feature.json, state.json nem no board de sessão
144
149
  (TaskCreate/TaskUpdate) — todo estado (status de task, scores, gates) é
145
150
  gravado exclusivamente pelo orquestrador.
@@ -163,7 +168,7 @@ dispatch Agent (subagent_type: "morph-{id}") with prompt:
163
168
 
164
169
  > **Alternativa quando os fluxos tocam os mesmos projetos/arquivos** (ex.: o mesmo csproj de testes compartilhado): siga a §3b-w abaixo em vez desta seção — despache com `isolation: "worktree"`, cada agente trabalha numa working tree própria e o orquestrador integra ao final.
165
170
 
166
- Aguardar todos os agentes terminarem — o relatório de cada um chega como o resultado do seu dispatch. Se um relatório vier oco ou sem os itens da cláusula 1, registre a falha de protocolo no recap. Depois, sequencialmente:
171
+ Aguardar todos os agentes terminarem, depois **ler primeiro** `.morph/features/{feature}/3-implement/reports/{taskId}.md` de cada task — é o valor de retorno autoritativo; a mensagem de texto do dispatch, se chegou inteira, é só um atalho que economiza essa leitura. Se um arquivo vier ausente, oco, ou sem os itens da cláusula 1, registre a falha de protocolo no recap. Depois, sequencialmente:
167
172
  - **Rodar `morph-spec verify {feature} --json` uma única vez, pós-batch** (escopo feature) — os agentes não a rodaram (cláusula 3). Vermelho: use `verify.feedback` para corrigir/redespachar ANTES de qualquer morph-eval. (Com isolamento por worktree, cada agente já rodou o verify dentro do seu worktree; o orquestrador roda o verify compartilhado UMA vez após o merge — ver §3b-w.)
168
173
  - Para cada relatório retornado:
169
174
  - Aplicar `Skill(morph-eval)`.
@@ -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.2.0"
7
+ cliVersion: "8.2.1"
8
8
  ---
9
9
 
10
10
  # morph-brainstorming
@@ -4,7 +4,7 @@ description: Avalia a qualidade de uma tarefa ou fase com rubricas graduadas 0-1
4
4
  version: core
5
5
  when-to-invoke: ao completar uma tarefa ou ao finalizar uma fase de implementação
6
6
  user-invokable: false
7
- cliVersion: "8.2.0"
7
+ cliVersion: "8.2.1"
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.2.0"
7
+ cliVersion: "8.2.1"
8
8
  ---
9
9
 
10
10
  # morph-hotfix
@@ -4,7 +4,7 @@ description: Guia a execução de cada tarefa de implementação com standard e
4
4
  version: core
5
5
  when-to-invoke: ao iniciar cada task de implementação listada em tasks.json
6
6
  user-invokable: false
7
- cliVersion: "8.2.0"
7
+ cliVersion: "8.2.1"
8
8
  ---
9
9
 
10
10
  # morph-implement
@@ -59,11 +59,14 @@ Ao iniciar cada task de `tasks.json` — antes de escrever qualquer código. Nor
59
59
  Done quando: [doneCriteria do tasks.json]
60
60
 
61
61
  Contrato do relatório final:
62
- 1. ATO FINAL — o seu relatório é a sua ÚLTIMA MENSAGEM DE TEXTO: ela é o
63
- valor de retorno desta sessão e chega inteira ao orquestrador. NÃO chame
64
- SendMessage: ele gera um evento a mais além da notificação de término,
65
- custando um turno inútil ao orquestrador. Escreva o relatório e encerre.
66
- Relatório oco, ou sem os 4 itens abaixo, é falha de protocolo.
62
+ 1. GRAVE seu relatório em `.morph/features/{feature}/3-implement/reports/[ID].md`
63
+ ANTES de encerrar — esse arquivo é o valor de retorno autoritativo desta
64
+ sessão. A sua última mensagem de texto é cortesia best-effort: pode não
65
+ chegar inteira ao orquestrador (o evento recebido às vezes é só um
66
+ idle_notification, sem texto). Ainda assim NÃO chame SendMessage: ele
67
+ gera um evento a mais além da notificação de término, custando um turno
68
+ inútil ao orquestrador. Relatório ausente, oco, ou sem os 4 itens abaixo,
69
+ é falha de protocolo.
67
70
  2. NÃO toque em tasks.json, feature.json, state.json nem no board de sessão
68
71
  (TaskCreate/TaskUpdate) — todo estado é gravado exclusivamente pelo
69
72
  orquestrador.
@@ -74,11 +77,18 @@ Ao iniciar cada task de `tasks.json` — antes de escrever qualquer código. Nor
74
77
  commite na branch do worktree como último ato antes do relatório
75
78
  e reporte a branch — nunca faça merge.
76
79
 
77
- Seu relatório final deve conter APENAS: (1) o que foi feito em 2-3 frases,
78
- (2) arquivos modificados, (3) problemas encontrados e como foram resolvidos,
79
- (4) auto-score 0-10.
80
+ Seu relatório (no arquivo, e na mensagem final quando ela chega) deve conter
81
+ APENAS: (1) o que foi feito em 2-3 frases, (2) arquivos modificados,
82
+ (3) problemas encontrados e como foram resolvidos, (4) auto-score 0-10.
80
83
  ```
81
- Após receber o retorno: o auto-score é **triagem, não aprovação** — se < 7, re-despache com o feedback específico (ou corrija inline) antes de prosseguir. Em qualquer caso, a task só avança quando `morph-eval` der score ≥ 9 (barra única do MORPH.md §0). Integre o relatório ao recap.md e vá para o passo 7.
84
+ Após o retorno do dispatch: **leia primeiro** `.morph/features/{feature}/3-implement/reports/[ID].md`
85
+ — é o valor de retorno autoritativo. Trate a mensagem de texto do sub-agent, se chegou inteira,
86
+ como um atalho que economiza essa leitura — nunca como a única fonte. Arquivo ausente é falha de
87
+ protocolo (registre no recap e re-despache/complete a partir do que o sub-agent já fez, se
88
+ recuperável). O auto-score é **triagem, não aprovação** — se < 7, re-despache com o feedback
89
+ específico (ou corrija inline) antes de prosseguir. Em qualquer caso, a task só avança quando
90
+ `morph-eval` der score ≥ 9 (barra única do MORPH.md §0). Integre o relatório ao recap.md e vá
91
+ para o passo 7.
82
92
 
83
93
  **Se não — siga para o passo 5.**
84
94
 
@@ -136,6 +146,8 @@ Ao iniciar cada task de `tasks.json` — antes de escrever qualquer código. Nor
136
146
 
137
147
  ## Output esperado
138
148
  - Código implementado conforme o standard consultado (direto ou via sub-agent)
149
+ - Quando despachada: `.morph/features/{feature}/3-implement/reports/{taskId}.md` — o relatório
150
+ autoritativo do sub-agent, canal durável (passo 4)
139
151
  - `.morph/features/{feature}/3-implement/recap.md` atualizado com entrada da task concluída
140
152
  - `.morph/features/{feature}/2-plan/tasks.json` com status da task atualizado para `done`
141
153
  - `.morph/features/{feature}/2-plan/decisions.md` atualizado se houver decisão não trivial
@@ -4,7 +4,7 @@ description: Guia a produção dos três artefatos de planejamento de uma featur
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.2.0"
7
+ cliVersion: "8.2.1"
8
8
  ---
9
9
 
10
10
  # morph-plan
@@ -4,7 +4,7 @@ description: Orquestra a fase de proposta+plano de uma feature morph-spec. Encad
4
4
  version: core
5
5
  when-to-invoke: ao receber instrução "use /morph-proposal" do usuário, ou ao iniciar uma nova feature
6
6
  user-invokable: true
7
- cliVersion: "8.2.0"
7
+ cliVersion: "8.2.1"
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.2.0"
7
+ cliVersion: "8.2.1"
8
8
  ---
9
9
 
10
10
  # morph-review
@@ -56,18 +56,22 @@ e o `recap.md` estar atualizado. Normalmente invocada pela skill orquestradora `
56
56
  seguindo as bandas 0-10 de cada uma.
57
57
  NÃO reescreva código. NÃO toque em tasks.json, feature.json nem no board
58
58
  de sessão.
59
- Contrato do relatório final — ATO FINAL: o seu relatório é a sua
60
- ÚLTIMA MENSAGEM DE TEXTO; ela é o valor de retorno desta sessão e
61
- chega inteira ao orquestrador. NÃO chame SendMessage: ele gera um
62
- evento a mais além da notificação de término, custando um turno
63
- inútil ao orquestrador. Escreva o relatório e encerre.
64
- Seu relatório final deve conter: score 0-10 por dimensão, score
65
- composto (arch×0.30 + contracts×0.25 + quality×0.25 + tests×0.20), e
66
- feedback priorizado (bloqueantes primeiro) para qualquer dimensão < 9.
67
- Relatório oco, ou sem esses itens, é falha de protocolo.
59
+ Contrato do relatório final: GRAVE seu relatório em
60
+ `.morph/features/{feature}/4-review/evaluator-report.md` ANTES de
61
+ encerrar — esse arquivo é o valor de retorno autoritativo desta
62
+ sessão. A sua última mensagem de texto é cortesia best-effort: pode
63
+ não chegar inteira ao orquestrador (o evento recebido às vezes é só
64
+ um idle_notification, sem texto). Ainda assim NÃO chame SendMessage:
65
+ ele gera um evento a mais além da notificação de término, custando
66
+ um turno inútil ao orquestrador.
67
+ Seu relatório (no arquivo, e na mensagem final quando ela chega)
68
+ deve conter: score 0-10 por dimensão, score composto (arch×0.30 +
69
+ contracts×0.25 + quality×0.25 + tests×0.20), e feedback priorizado
70
+ (bloqueantes primeiro) para qualquer dimensão < 9. Relatório
71
+ ausente, oco, ou sem esses itens, é falha de protocolo.
68
72
  ```
69
73
  - **Fallback (tipo não registrado):** se `morph-evaluator` não estiver na sua lista de subagent types, despache `general-purpose` colando o campo `persona` do agente `evaluator` (Read em `.morph/framework/agents.json`) no topo do prompt — e **registre o fallback no review-report** (seção de método).
70
- - Os scores por dimensão e o composto do review-report vêm **desse retorno**, não da sua auto-avaliação. Se o relatório vier oco ou sem os scores por dimensão, registre a falha de protocolo na seção de método do review-report e re-despache.
74
+ - **Leia primeiro** `.morph/features/{feature}/4-review/evaluator-report.md` — é o valor de retorno autoritativo, não a mensagem de texto do dispatch (que é cortesia, útil só quando chega inteira). Os scores por dimensão e o composto do review-report vêm **desse arquivo**, não da sua auto-avaliação. Se o arquivo vier ausente, oco, ou sem os scores por dimensão, registre a falha de protocolo na seção de método do review-report e re-despache.
71
75
 
72
76
  5. Produza `review-report.md` em `.morph/features/{feature}/4-review/` com:
73
77
  - Scores por dimensão (arquitetura, contratos, qualidade, cobertura)
@@ -117,7 +121,10 @@ e o `recap.md` estar atualizado. Normalmente invocada pela skill orquestradora `
117
121
  No caminho manual, o hook state-sync detecta a aprovação em answers de AskUserQuestion e grava `approvalGates.review = { approved: true, timestamp, approvedBy: "manual" }` no `feature.json` autoritativo da feature. Em auto sem AskUserQuestion, aprove via `morph-spec approve {feature} review --mode auto` — nunca via Edit à mão de state.json.
118
122
 
119
123
  ## Output esperado
120
- - `.morph/features/{feature}/4-review/review-report.md` com scores e recomendação
124
+ - `.morph/features/{feature}/4-review/evaluator-report.md` — relatório bruto e autoritativo do
125
+ `morph-evaluator` despachado no passo 4 (canal durável, ver MORPH.md §6)
126
+ - `.morph/features/{feature}/4-review/review-report.md` — síntese do orquestrador a partir do
127
+ evaluator-report, com scores e recomendação
121
128
  - Trace da fase com `phase_summary` preenchido
122
129
  - Gate 3 registrado aprovado em `.morph/features/{feature}/feature.json` (autoritativo), na forma canônica `{ approved, timestamp, approvedBy }` — via `morph-spec approve` em auto, ou pelo hook state-sync no caminho manual
123
130
 
@@ -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.2.0"
7
+ cliVersion: "8.2.1"
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.2.0"
7
+ cliVersion: "8.2.1"
8
8
  ---
9
9
 
10
10
  # morph-standards
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polymorphism-tech/morph-spec",
3
- "version": "8.2.0",
3
+ "version": "8.2.1",
4
4
  "description": "MORPH-SPEC: NLH (Natural Language Harness) for spec-driven development with Claude Code",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -145,6 +145,38 @@ function getDateSuffix() {
145
145
 
146
146
  // ── Infrastructure linking ───────────────────────────────────────────────────
147
147
 
148
+ /**
149
+ * The infra directories morph itself links into a worktree — the ONLY ones
150
+ * `setupInfraLinks`/`worktreeLinkCommand --all`/`unlinkWorktreeInfraLinks` treat
151
+ * as KNOWN, and therefore the only ones `finish` may auto-remove via the
152
+ * already-proven-safe unlink mechanism (never by widening the reparse-point
153
+ * scan — see `findReparsePoints`).
154
+ *
155
+ * Always: `node_modules`, `.claude`. Conditionally: `.morph/framework` (client
156
+ * projects only) and `{project.frontendPath}/node_modules` — a monorepo
157
+ * frontend (e.g. a Next.js app under `apps/catalog-admin`) commonly has its OWN
158
+ * node_modules, a separate `npm install` from the root's, invisible to a static
159
+ * list. Fail-open: a missing/unreadable config.json just means no frontendPath.
160
+ *
161
+ * @param {string} mainCwd - Absolute path to the main project root
162
+ * @returns {string[]} relative paths, root-first
163
+ */
164
+ function resolveInfraLinkDirs(mainCwd) {
165
+ const dirs = ['node_modules', '.claude'];
166
+ if (existsSync(join(mainCwd, '.morph', 'framework'))) {
167
+ dirs.push(join('.morph', 'framework'));
168
+ }
169
+ try {
170
+ const configPath = join(mainCwd, '.morph', 'config', 'config.json');
171
+ if (existsSync(configPath)) {
172
+ const config = JSON.parse(readFileSync(configPath, 'utf8'));
173
+ const frontendPath = config?.project?.frontendPath;
174
+ if (frontendPath) dirs.push(join(frontendPath, 'node_modules'));
175
+ }
176
+ } catch { /* fail-open — no frontendPath to add */ }
177
+ return dirs;
178
+ }
179
+
148
180
  /**
149
181
  * Create junctions in the worktree for shared infrastructure directories
150
182
  * (node_modules/, .claude/, .morph/framework/).
@@ -169,16 +201,9 @@ export function setupInfraLinks(worktreePath, mainCwd, opts = {}) {
169
201
  const mainAbs = resolve(mainCwd);
170
202
  const wtAbs = resolve(worktreePath);
171
203
 
172
- let dirLinks;
173
- if (Array.isArray(opts.dirs) && opts.dirs.length) {
174
- dirLinks = opts.dirs;
175
- } else {
176
- dirLinks = ['node_modules', '.claude'];
177
- // .morph/framework/ — client projects (in framework repo, framework/ is tracked)
178
- if (existsSync(join(mainAbs, '.morph', 'framework'))) {
179
- dirLinks.push(join('.morph', 'framework'));
180
- }
181
- }
204
+ const dirLinks = (Array.isArray(opts.dirs) && opts.dirs.length)
205
+ ? opts.dirs
206
+ : resolveInfraLinkDirs(mainAbs);
182
207
 
183
208
  for (const rel of dirLinks) {
184
209
  const target = join(mainAbs, rel);
@@ -530,7 +555,7 @@ export function worktreeLinkCommand(options = {}) {
530
555
  }
531
556
 
532
557
  const dirs = options.all
533
- ? ['node_modules', '.claude', join('.morph', 'framework')]
558
+ ? resolveInfraLinkDirs(root)
534
559
  : ['node_modules'];
535
560
 
536
561
  const alreadyPresent = {};
@@ -624,12 +649,10 @@ function findWorktreeForFeature(feature, root) {
624
649
  return { path: match.path, branch: actual, expectedBranch };
625
650
  }
626
651
 
627
- /** The infra paths `setupInfraLinks` junctions into a worktree. */
628
- const INFRA_LINK_RELS = ['node_modules', '.claude', join('.morph', 'framework')];
629
-
630
652
  /**
631
653
  * Drop the infra junctions a worktree carries (`node_modules`, `.claude`,
632
- * `.morph/framework`) WITHOUT following them.
654
+ * `.morph/framework`, and the `project.frontendPath` node_modules if
655
+ * configured) WITHOUT following them.
633
656
  *
634
657
  * CRITICAL — this is what keeps `worktree finish` from destroying the project.
635
658
  * `git worktree remove` refuses a worktree still holding these ("contains
@@ -646,12 +669,19 @@ const INFRA_LINK_RELS = ['node_modules', '.claude', join('.morph', 'framework')]
646
669
  * target's contents, and `rmdirSync` fails on a real non-empty directory — the
647
670
  * safety that stops this from ever deleting genuine files.
648
671
  *
672
+ * This is deliberately narrow to the KNOWN inventory from `resolveInfraLinkDirs`
673
+ * — never widened to auto-remove any reparse point `findReparsePoints` might
674
+ * discover elsewhere. An unknown/nested link (npm link, npm workspaces) must
675
+ * still decline the removal; see the CANARY tests pinning that in worktree.test.js.
676
+ *
649
677
  * @param {string} worktreePath
678
+ * @param {string} mainCwd - Absolute path to the main project root, to resolve
679
+ * `project.frontendPath` from config.json
650
680
  * @returns {string[]} relative paths actually unlinked
651
681
  */
652
- export function unlinkWorktreeInfraLinks(worktreePath) {
682
+ export function unlinkWorktreeInfraLinks(worktreePath, mainCwd) {
653
683
  const removed = [];
654
- for (const rel of INFRA_LINK_RELS) {
684
+ for (const rel of resolveInfraLinkDirs(mainCwd)) {
655
685
  const p = join(worktreePath, rel);
656
686
  let st;
657
687
  try {
@@ -746,9 +776,60 @@ function findReparsePoints(dir, base = dir, acc = { points: [], unprovable: [] }
746
776
  */
747
777
  const IGNORED_SHIELDABLE_PREFIXES = ['.morph/'];
748
778
 
779
+ /**
780
+ * Well-known, regenerable build artifacts — `bin/`, `obj/` (dotnet), a real
781
+ * `node_modules` left behind outside the setup-created junction, and
782
+ * `*.tsbuildinfo` (tsc incremental cache). These are the everyday leftovers of
783
+ * running a build/test cycle inside a worktree, not user data: they are fully
784
+ * reproducible from source, and requiring `--discard-ignored` for every finish
785
+ * that ever ran a build turns the flag from "explicit consent for a real risk"
786
+ * into a rubber stamp nobody reads anymore. Auto-discarding them (like any
787
+ * `discarded` entry, WITHOUT a backup — see the note below) is safe precisely
788
+ * because "regenerable" is a stronger guarantee than "backed up".
789
+ *
790
+ * Deliberately narrow — do NOT add speculative patterns (`dist/`, `.next/`)
791
+ * beyond what's proven to be pure noise; anything else stays in `blocking` by
792
+ * default, requiring the explicit `--discard-ignored` consent.
793
+ *
794
+ * @param {string} p - forward-slash relative path from `git status --ignored`
795
+ */
796
+ /**
797
+ * Whether a dirty root-side path is EXACTLY a sibling feature's live worktree
798
+ * registration — see the call site (Validation 3 in worktreeFinishCommand)
799
+ * for why this is safe to exempt from the whole-root clean gate rather than a
800
+ * widened hole.
801
+ *
802
+ * PROVES it rather than assuming: the path must be exactly that sibling's
803
+ * `feature.json`, must parse, and its `worktree.path` must point at a
804
+ * currently-existing directory. A parse failure, a missing registration, or a
805
+ * STALE pointer (worktree already removed) is NOT exempted.
806
+ *
807
+ * @param {string} relPath - forward-slash path relative to `root`
808
+ * @param {string} root - absolute path to the primary root
809
+ * @param {string} feature - the feature THIS finish is acting on (never exempted — its own scope is the shed's job)
810
+ */
811
+ function isLiveSiblingWorktreeRegistration(relPath, root, feature) {
812
+ const m = relPath.match(/^\.morph\/features\/([^/]+)\/feature\.json$/);
813
+ if (!m || m[1] === feature) return false;
814
+ try {
815
+ const fj = JSON.parse(readFileSync(join(root, relPath), 'utf8'));
816
+ const wtPath = fj?.worktree?.path;
817
+ return typeof wtPath === 'string' && wtPath.length > 0 && existsSync(wtPath);
818
+ } catch {
819
+ return false;
820
+ }
821
+ }
822
+
823
+ function isDisposableBuildArtifact(p) {
824
+ if (/\.tsbuildinfo$/.test(p)) return true;
825
+ const segments = p.split('/').filter(Boolean);
826
+ return segments.some((s) => s === 'bin' || s === 'obj' || s === 'node_modules');
827
+ }
828
+
749
829
  /**
750
830
  * Split the worktree's GITIGNORED leftovers into what this command may remove
751
- * (morph's own, after a backup) and what it must refuse to destroy.
831
+ * (morph's own, after a backup, or a known disposable build artifact, without
832
+ * one) and what it must refuse to destroy.
752
833
  *
753
834
  * Why this exists: `git worktree remove` deletes gitignored files outright, and
754
835
  * the clean-check that gates it uses `git status --porcelain`, which omits them
@@ -762,34 +843,46 @@ const IGNORED_SHIELDABLE_PREFIXES = ['.morph/'];
762
843
  *
763
844
  * @param {string} worktreePath
764
845
  * @param {{ discardIgnored?: boolean }} options
765
- * @returns {{ shieldable: string[], blocking: string[] }}
846
+ * @returns {{ shieldable: string[], blocking: string[], discarded: string[] }}
766
847
  */
767
848
  function classifyIgnoredLeftovers(worktreePath, options = {}) {
768
849
  let out;
769
850
  try {
770
851
  // `-z` + execFile: NUL-delimited, no shell, no C-style quoting to undo —
771
852
  // paths with spaces or non-ASCII (a pt-BR codebase has both) stay intact.
772
- out = execFileSync('git', ['status', '--porcelain', '--ignored', '-z'], { cwd: worktreePath, stdio: 'pipe' }).toString();
853
+ // `=matching` (not the traditional default): an ignored directory whose own
854
+ // name matches no pattern — e.g. `MyProj/`, ignored only because EVERYTHING
855
+ // inside it (`bin/`, `obj/`) does — otherwise collapses into one opaque
856
+ // `MyProj/` entry, hiding the actual disposable-artifact names from
857
+ // isDisposableBuildArtifact. A directory that IS itself the matched pattern
858
+ // (`node_modules/`) still reports as one entry either way — verified
859
+ // empirically, not assumed.
860
+ out = execFileSync('git', ['status', '--porcelain', '--ignored=matching', '-z'], { cwd: worktreePath, stdio: 'pipe' }).toString();
773
861
  } catch {
774
- return { shieldable: [], blocking: ['<git status --ignored falhou — não foi possível enumerar o que seria destruído>'] };
862
+ return { shieldable: [], blocking: ['<git status --ignored falhou — não foi possível enumerar o que seria destruído>'], discarded: [] };
775
863
  }
776
864
 
777
865
  const shieldable = [];
778
- const blocking = [];
866
+ const blockingRest = [];
867
+ const disposable = [];
779
868
  for (const record of out.split('\0')) {
780
869
  if (!record || record.length < 4) continue;
781
870
  if (record.slice(0, 2) !== '!!') continue; // only ignored entries; the rest is the clean-check's job
782
871
  const p = record.slice(3).replace(/\\/g, '/');
783
872
  if (IGNORED_SHIELDABLE_PREFIXES.some((prefix) => p.startsWith(prefix))) shieldable.push(p);
784
- else blocking.push(p);
873
+ else if (isDisposableBuildArtifact(p)) disposable.push(p);
874
+ else blockingRest.push(p);
785
875
  }
786
- // Explicit consent clears the block — but the list is NOT thrown away. These
787
- // are the files that exist nowhere else, and they are the ones about to die;
788
- // reporting them is the least this can do. (Backing them up instead is not an
789
- // option: this bucket is where `node_modules`/`dist`/`.next` land, so a copy
790
- // is unbounded. The user declared them disposable — they still get told.)
791
- if (options.discardIgnored) return { shieldable, blocking: [], discarded: blocking };
792
- return { shieldable, blocking, discarded: [] };
876
+ // Explicit consent clears the rest of the block too — but the list is NOT
877
+ // thrown away. These are the files that exist nowhere else, and they are the
878
+ // ones about to die; reporting them is the least this can do. (Backing them
879
+ // up instead is not an option: this bucket is where arbitrarily large
880
+ // leftovers land, so a copy is unbounded. The user declared them
881
+ // disposable — they still get told.)
882
+ if (options.discardIgnored) return { shieldable, blocking: [], discarded: [...disposable, ...blockingRest] };
883
+ // Known build artifacts are discarded by default, no flag required — they
884
+ // are reproducible from source, unlike anything else in `blockingRest`.
885
+ return { shieldable, blocking: blockingRest, discarded: disposable };
793
886
  }
794
887
 
795
888
  /**
@@ -1015,15 +1108,34 @@ export async function worktreeFinishCommand(feature, options = {}) {
1015
1108
  }
1016
1109
  : {};
1017
1110
 
1018
- // Validation 3: primary root must be clean.
1019
- let rootStatus;
1111
+ // Validation 3: primary root must be clean — except a SIBLING feature's own
1112
+ // LIVE worktree registration (see isLiveSiblingWorktreeRegistration). That
1113
+ // artifact is how `runWorktreeSetup`/`create --worktree` are DESIGNED to
1114
+ // leave the root for that sibling's entire life (decidePersistence in
1115
+ // verify.js and the anti-dupe guard above both read the root's copy for
1116
+ // `.worktree.path`), so refusing THIS feature's finish over it would block
1117
+ // every OTHER feature for as long as any sibling stays open in its own
1118
+ // worktree — the exact "orphaned scaffold collides with finish" friction
1119
+ // from real multi-feature use. Anything else is still unexplained dirt and
1120
+ // still blocks, fail-closed, exactly as before.
1121
+ let rootStatusRaw;
1020
1122
  try {
1021
- rootStatus = execFileSync('git', ['status', '--porcelain'], { cwd: root, stdio: 'pipe' }).toString();
1123
+ // `--untracked-files=all`, not the default `normal`: the default collapses
1124
+ // an entirely-untracked directory into one entry (e.g. `.morph/features/
1125
+ // sibling-feat/`, verified empirically) — which would never match the
1126
+ // exact `.../feature.json` path isLiveSiblingWorktreeRegistration expects,
1127
+ // silently defeating the exemption for the very shape it exists to allow.
1128
+ rootStatusRaw = execFileSync('git', ['status', '--porcelain', '--untracked-files=all', '-z'], { cwd: root, stdio: 'pipe' }).toString();
1022
1129
  } catch (err) {
1023
1130
  console.log(JSON.stringify({ finished: false, error: 'primary-root-status-failed', detail: (err.message || '').slice(0, 200), feature, ...backupNote }));
1024
1131
  process.exit(1);
1025
1132
  }
1026
- if (rootStatus.trim() !== '') {
1133
+ const unexplainedDirt = rootStatusRaw.split('\0').filter((record) => {
1134
+ if (!record || record.length < 4) return false;
1135
+ const p = record.slice(3).replace(/\\/g, '/');
1136
+ return !isLiveSiblingWorktreeRegistration(p, root, feature);
1137
+ });
1138
+ if (unexplainedDirt.length) {
1027
1139
  console.log(JSON.stringify({
1028
1140
  finished: false,
1029
1141
  error: 'primary-root-dirty',
@@ -1075,7 +1187,7 @@ export async function worktreeFinishCommand(feature, options = {}) {
1075
1187
  // Both gates below therefore PROVE-or-DECLINE. Declining costs a stale
1076
1188
  // directory the user can delete in one command; being wrong costs data that
1077
1189
  // exists nowhere else.
1078
- unlinkWorktreeInfraLinks(worktreePath);
1190
+ unlinkWorktreeInfraLinks(worktreePath, root);
1079
1191
 
1080
1192
  const scan = findReparsePoints(worktreePath);
1081
1193
  const ignored = classifyIgnoredLeftovers(worktreePath, options);
@@ -1202,7 +1314,9 @@ export async function worktreeFinishCommand(feature, options = {}) {
1202
1314
  ...(worktreeRemoved && ignored.discarded.length
1203
1315
  ? {
1204
1316
  discardedIgnored: ignored.discarded,
1205
- discardedNote: `--discard-ignored: os caminhos acima eram gitignored, existiam só no worktree e foram DESTRUÍDOS sem backup (não são copiados por poderem ser arbitrariamente grandes)`,
1317
+ discardedNote: options.discardIgnored
1318
+ ? `--discard-ignored: os caminhos acima eram gitignored, existiam só no worktree e foram DESTRUÍDOS sem backup (não são copiados por poderem ser arbitrariamente grandes)`
1319
+ : `artefatos de build reconhecidos (bin/, obj/, node_modules/, *.tsbuildinfo) — descartados por padrão, sem backup (regeneráveis a partir do source)`,
1206
1320
  }
1207
1321
  : {}),
1208
1322
  ...(worktreeRemoved
@@ -39,9 +39,20 @@ export async function scoreCommand(featureName, taskId, scoreArg, options = {})
39
39
 
40
40
  let dimensions;
41
41
  if (options.dims) {
42
- const parts = String(options.dims).split(',').map(s => s.trim());
42
+ // Commander captures --dims as variadic (`<dims...>`), so an UNQUOTED
43
+ // comma list typed in PowerShell — `--dims 9.5,9,9,9` — that arrives as
44
+ // 4 separate argv tokens (confirmed: Commander binds only the first
45
+ // token to a non-variadic option and silently drops the rest) is not
46
+ // lost. Accept either shape: one comma-joined string (the documented,
47
+ // quoted form) or N separate tokens — split on both separators so a
48
+ // mix of the two never matters either.
49
+ const raw = Array.isArray(options.dims) ? options.dims.join(' ') : String(options.dims);
50
+ const parts = raw.split(/[,\s]+/).filter(Boolean);
43
51
  if (parts.length !== DIM_KEYS.length) {
44
- console.error(chalk.red(`\n❌ --dims expects ${DIM_KEYS.length} comma-separated values in order a,c,q,t (architecture,contracts,code-quality,test-coverage)\n`));
52
+ console.error(chalk.red(
53
+ `\n❌ --dims expects ${DIM_KEYS.length} values in order a,c,q,t (architecture,contracts,code-quality,test-coverage) — got ${parts.length} (${JSON.stringify(parts)}).\n` +
54
+ ` Quote the value under PowerShell to keep it as one argument: --dims "9.5,9,9,9"\n`
55
+ ));
45
56
  process.exit(1);
46
57
  }
47
58
  dimensions = {};
@@ -1,7 +1,7 @@
1
1
  import chalk from 'chalk';
2
2
  import { existsSync } from 'fs';
3
3
  import { join } from 'path';
4
- import { parseTasksJson } from '../../lib/tasks/tasks-json-parser.js';
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
7
 
@@ -24,8 +24,16 @@ export async function dagCommand(featureName, options = {}) {
24
24
  process.exit(1);
25
25
  }
26
26
 
27
- // parseTasksJson validates the schema and throws with the error list.
28
- const data = await parseTasksJson(tasksPath);
27
+ // parseTasksJsonLenient validates the schema and throws with the error
28
+ // list, but defaults a missing `version` (legacy plans predating the
29
+ // field) instead of failing — the DAG analysis doesn't depend on it.
30
+ const { data, versionDefaulted } = await parseTasksJsonLenient(tasksPath);
31
+ if (versionDefaulted) {
32
+ console.error(chalk.yellow(
33
+ `⚠ tasks.json has no 'version' field — defaulting to "1.0" for this analysis.\n` +
34
+ ` Consider adding it back explicitly (safe: protect-spec-files allows backfilling it).`
35
+ ));
36
+ }
29
37
 
30
38
  // --mermaid short-circuits before the dispatch analysis: visualizing the
31
39
  // plan's DAG doesn't need a sequential-vs-parallel decision.
@@ -49,6 +49,11 @@ export const OUTPUT_SCHEMA = {
49
49
 
50
50
  // 4-review/
51
51
  reviewReport: { filename: 'review-report.md', phaseDir: '4-review', phase: 'review', protected: false },
52
+ // Durable report the dispatched morph-evaluator sub-agent writes itself
53
+ // (MORPH.md §6) — the orchestrator reads it and synthesizes reviewReport
54
+ // from it; the two never collide because one is raw sub-agent output and
55
+ // the other is the orchestrator's curated Gate 3 presentation.
56
+ evaluatorReport:{ filename: 'evaluator-report.md', phaseDir: '4-review', phase: 'review', protected: false },
52
57
  };
53
58
 
54
59
  // ============================================================================
@@ -89,6 +94,29 @@ export function getFeatureDir(featureName) {
89
94
  return join('.morph', 'features', featureName).replace(/\\/g, '/');
90
95
  }
91
96
 
97
+ /**
98
+ * Per-task sub-agent report — the durable "return value" channel for a
99
+ * dispatched implementation task (MORPH.md §6). A sub-agent's final text
100
+ * message may never reach the orchestrator (idle_notification with no text
101
+ * is an observed failure mode); the file it writes here always does. Doesn't
102
+ * fit OUTPUT_SCHEMA's one-fixed-filename-per-type model since the filename
103
+ * is parameterized by taskId, so it's a sibling helper instead of a schema
104
+ * entry.
105
+ *
106
+ * @param {string} featureName
107
+ * @param {string} taskId - e.g. "T7"
108
+ * @returns {string} morph-relative, forward-slash path
109
+ */
110
+ export function getTaskReportPath(featureName, taskId) {
111
+ return join('.morph', 'features', featureName, '3-implement', 'reports', `${taskId}.md`)
112
+ .replace(/\\/g, '/');
113
+ }
114
+
115
+ /** Absolute filesystem variant of {@link getTaskReportPath}. */
116
+ export function getAbsoluteTaskReportPath(projectRoot, featureName, taskId) {
117
+ return join(projectRoot, '.morph', 'features', featureName, '3-implement', 'reports', `${taskId}.md`);
118
+ }
119
+
92
120
  /**
93
121
  * Get all output paths for a feature, ready for state.json `outputs` block.
94
122
  */
@@ -51,24 +51,55 @@ function readStack(cwd) {
51
51
  }
52
52
  }
53
53
 
54
+ /** Merge two or more profiles' validator lists (deduped) and boolean flags. */
55
+ function mergeProfiles(profiles) {
56
+ return {
57
+ isDotnet: profiles.some((p) => p.isDotnet),
58
+ isNextjs: profiles.some((p) => p.isNextjs),
59
+ defaultValidators: [...new Set(profiles.flatMap((p) => p.defaultValidators))],
60
+ checkpointValidators: [...new Set(profiles.flatMap((p) => p.checkpointValidators))],
61
+ buildCommand: profiles.map((p) => p.buildCommand).find(Boolean) ?? null,
62
+ };
63
+ }
64
+
54
65
  /**
55
66
  * Get the StackProfile for the current project.
67
+ *
68
+ * `stack` may be a compound value (e.g. "dotnet-nextjs", "dotnet+nextjs") —
69
+ * the framework's own convention for monorepos (see stack-filter.js's
70
+ * getProjectTags, which splits project.stack the same way). Each segment is
71
+ * resolved independently and matching profiles are merged, instead of the
72
+ * whole compound string failing every exact-match check and silently
73
+ * collapsing to the empty profile (0 default validators).
56
74
  */
57
75
  export function getStackProfile(cwd, stackOverride) {
58
76
  const root = cwd ?? process.cwd();
59
77
  const raw = stackOverride ?? readStack(root);
60
78
  const stack = raw?.toLowerCase() ?? 'unknown';
61
79
 
62
- let profile;
63
- if (NEXTJS_STACKS.has(stack)) {
64
- profile = PROFILES.nextjs;
65
- } else if (NO_VALIDATOR_STACKS.has(stack)) {
66
- profile = PROFILES.empty;
67
- } else if (DOTNET_STACKS.has(stack) || stack === 'unknown') {
68
- profile = PROFILES.dotnet;
69
- } else {
70
- profile = PROFILES.empty;
80
+ const segments = stack.split(/[-+]/).filter(Boolean);
81
+
82
+ // Single-segment stack (the overwhelming common case): resolve exactly as
83
+ // before, byte-for-byte, including the 'unknown' → dotnet fallback.
84
+ if (segments.length <= 1) {
85
+ let profile;
86
+ if (NEXTJS_STACKS.has(stack)) {
87
+ profile = PROFILES.nextjs;
88
+ } else if (NO_VALIDATOR_STACKS.has(stack)) {
89
+ profile = PROFILES.empty;
90
+ } else if (DOTNET_STACKS.has(stack) || stack === 'unknown') {
91
+ profile = PROFILES.dotnet;
92
+ } else {
93
+ profile = PROFILES.empty;
94
+ }
95
+ return { stack, ...profile };
71
96
  }
72
97
 
98
+ // Compound stack: resolve each recognized segment and merge.
99
+ const matched = [];
100
+ if (segments.some((s) => DOTNET_STACKS.has(s))) matched.push(PROFILES.dotnet);
101
+ if (segments.some((s) => NEXTJS_STACKS.has(s))) matched.push(PROFILES.nextjs);
102
+
103
+ const profile = matched.length > 0 ? mergeProfiles(matched) : PROFILES.empty;
73
104
  return { stack, ...profile };
74
105
  }
@@ -98,6 +98,27 @@ export async function parseTasksJson(filePath) {
98
98
  return data;
99
99
  }
100
100
 
101
+ /**
102
+ * Lenient variant for read-only analysis commands (e.g. `morph-spec dag`):
103
+ * a missing `version` field is defaulted to "1.0" instead of failing
104
+ * validation — legacy tasks.json predating the field is still analyzable.
105
+ * All other schema errors still throw exactly like `parseTasksJson`.
106
+ *
107
+ * @param {string} filePath - Absolute path to tasks.json
108
+ * @returns {Promise<{ data: object, versionDefaulted: boolean }>}
109
+ */
110
+ export async function parseTasksJsonLenient(filePath) {
111
+ const data = await fs.readJson(filePath);
112
+ const versionDefaulted = data.version === undefined;
113
+ if (versionDefaulted) data.version = '1.0';
114
+
115
+ const { valid, errors } = validateTasksJson(data);
116
+ if (!valid) {
117
+ throw new Error(`Invalid tasks.json:\n ${errors.join('\n ')}`);
118
+ }
119
+ return { data, versionDefaulted };
120
+ }
121
+
101
122
  /**
102
123
  * Synchronous variant — used by hooks.
103
124
  */
@@ -4,13 +4,16 @@
4
4
  * Resolution order for test command:
5
5
  * 1. .morph/config/config.json → project.testCommand
6
6
  * 2. .sln or .csproj present at project root → "dotnet test"
7
- * 3. package.json present AND has scripts.test → "npm test"
8
- * 4. null (no test suite detected)
7
+ * 3. Nested *Tests.csproj anywhere in the tree (monorepo without a root
8
+ * .sln) → "dotnet test <proj> --nologo -v q" for each match
9
+ * 4. package.json present AND has scripts.test → "npm test"
10
+ * 5. null (no test suite detected)
9
11
  */
10
12
 
11
13
  import { execSync } from 'child_process';
12
14
  import { existsSync, readFileSync, readdirSync } from 'fs';
13
15
  import { join } from 'path';
16
+ import { globSync } from 'glob';
14
17
 
15
18
  /**
16
19
  * Resolve the test command for a project.
@@ -46,7 +49,26 @@ export function resolveTestCommand(projectPath = '.') {
46
49
  // Unreadable project directory — fall through to next detection
47
50
  }
48
51
 
49
- // 3. package.json present AND has scripts.test → npm test
52
+ // 3. No root-level project file — look for a nested test project
53
+ // (e.g. tests/MyApp.Tests/MyApp.Tests.csproj), common in monorepos that
54
+ // have no root .sln. Run each matched project explicitly rather than a
55
+ // bare `dotnet test` (which finds nothing to build at the root).
56
+ try {
57
+ const testProjects = globSync('**/*Tests.csproj', {
58
+ cwd: projectPath,
59
+ ignore: ['**/bin/**', '**/obj/**', '**/node_modules/**'],
60
+ });
61
+ if (testProjects.length > 0) {
62
+ return testProjects
63
+ .sort()
64
+ .map((p) => `dotnet test "${p}" --nologo -v q`)
65
+ .join(' && ');
66
+ }
67
+ } catch {
68
+ // Glob failure — fall through to next detection
69
+ }
70
+
71
+ // 4. package.json present AND has scripts.test → npm test
50
72
  const packageJsonPath = join(projectPath, 'package.json');
51
73
  if (existsSync(packageJsonPath)) {
52
74
  try {
@@ -59,7 +81,7 @@ export function resolveTestCommand(projectPath = '.') {
59
81
  }
60
82
  }
61
83
 
62
- // 4. Nothing found
84
+ // 5. Nothing found
63
85
  return null;
64
86
  }
65
87
 
@@ -124,7 +124,10 @@ export async function runValidation(projectPath, featureName, options = {}) {
124
124
  const _profileForContracts = getStackProfile(projectPath);
125
125
  const contractsType = resolveContractsOutputType(_profileForContracts.stack);
126
126
  const contractsPath = getAbsoluteOutputPath(projectPath, featureName, contractsType);
127
- if (existsSync(contractsPath) && !_profileForContracts.isNextjs) {
127
+ // Positive check on isDotnet (contract-compliance is a VSA/.NET concern) —
128
+ // NOT `!isNextjs`, which used to wrongly suppress it for a compound
129
+ // "dotnet-nextjs" stack that has a .NET backend but is ALSO isNextjs.
130
+ if (existsSync(contractsPath) && _profileForContracts.isDotnet) {
128
131
  validatorIds.push('contract-compliance');
129
132
  }
130
133
  }