@polymorphism-tech/morph-spec 7.8.1 → 7.9.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 (35) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +30 -1
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +5 -1
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/CLAUDE.md +3 -1
  7. package/framework/MORPH.md +4 -3
  8. package/framework/evals/trace-schema.md +22 -12
  9. package/framework/hooks/README.md +2 -0
  10. package/framework/hooks/claude-code/core/subagent-report-guard.js +95 -0
  11. package/framework/hooks/claude-code/core/trace-autogen.js +183 -0
  12. package/framework/hooks/shared/tasks-validator.js +47 -4
  13. package/framework/schemas/feature.schema.json +128 -0
  14. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  15. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  16. package/framework/skills/core/morph-apply/SKILL.md +23 -10
  17. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  18. package/framework/skills/core/morph-eval/SKILL.md +3 -6
  19. package/framework/skills/core/morph-implement/SKILL.md +28 -15
  20. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  21. package/framework/skills/core/morph-proposal/SKILL.md +2 -2
  22. package/framework/skills/core/morph-review/SKILL.md +2 -6
  23. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  24. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  25. package/package.json +2 -1
  26. package/src/commands/project/doctor.js +23 -18
  27. package/src/commands/project/update.js +19 -9
  28. package/src/commands/state/create.js +58 -0
  29. package/src/commands/state/gate-decision.js +99 -0
  30. package/src/core/state/state-manager.js +55 -0
  31. package/src/scripts/setup-infra.js +2 -0
  32. package/src/utils/agents-installer.js +25 -10
  33. package/src/utils/banner.js +37 -4
  34. package/src/utils/hooks-installer.js +32 -4
  35. package/src/utils/skills-installer.js +2 -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:** 7.8.1
6
+ **Version:** 7.9.1
7
7
  **Requires:** Node.js 22+, Claude Code
8
8
 
9
9
  ---
@@ -365,4 +365,4 @@ Code generated by morph-spec (contracts, templates, implementation output) belon
365
365
 
366
366
  ---
367
367
 
368
- *morph-spec v7.8.1 by [Polymorphism Tech](https://polymorphism.tech)*
368
+ *morph-spec v7.9.1 by [Polymorphism Tech](https://polymorphism.tech)*
package/bin/morph-spec.js CHANGED
@@ -14,7 +14,7 @@ import chalk from 'chalk';
14
14
  import { fileURLToPath } from 'url';
15
15
  import { dirname, join } from 'path';
16
16
  import { readFileSync } from 'fs';
17
- import { renderLogo } from '../src/utils/banner.js';
17
+ import { renderLogo, maybePrintFirstRunBanner } from '../src/utils/banner.js';
18
18
 
19
19
  // === Project / install ===
20
20
  import { initCommand } from '../src/commands/project/init.js';
@@ -30,6 +30,8 @@ import { approveCommand, approvalStatusCommand, unapproveCommand } from '../src/
30
30
  import { advanceCommand } from '../src/commands/state/advance.js';
31
31
  import { archiveCommand } from '../src/commands/state/archive.js';
32
32
  import { scoreCommand } from '../src/commands/state/score.js';
33
+ import { gateDecisionCommand } from '../src/commands/state/gate-decision.js';
34
+ import { createCommand } from '../src/commands/state/create.js';
33
35
 
34
36
  // === Tasks ===
35
37
  import { dagCommand } from '../src/commands/tasks/dag.js';
@@ -57,6 +59,16 @@ const banner = `${renderLogo(pkg.version)}
57
59
  ${chalk.dim('Spec-driven development harness for Claude Code — by Polymorphism Tech')}
58
60
  `;
59
61
 
62
+ // First run after an install/upgrade: show the install banner (npm ≥7 hides
63
+ // postinstall output). Skipped for --version/--help (parsed/already-bannered)
64
+ // and for non-TTY output — see maybePrintFirstRunBanner.
65
+ const argvRest = process.argv.slice(2);
66
+ const skipFirstRun = argvRest.length === 0 ||
67
+ argvRest.some(a => ['-V', '--version', '-h', '--help'].includes(a));
68
+ if (!skipFirstRun) {
69
+ maybePrintFirstRunBanner({ version: pkg.version, nodeVersion: process.version });
70
+ }
71
+
60
72
  program
61
73
  .name('morph-spec')
62
74
  .description('MORPH-SPEC Framework CLI')
@@ -146,6 +158,23 @@ program
146
158
  .option('--reason <reason>', 'Reason for revocation')
147
159
  .action((feature, gate, options) => unapproveCommand(feature, gate, options));
148
160
 
161
+ program
162
+ .command('gate-decision <feature> <gate>')
163
+ .description('Record the trust decision for a gate in feature.json (gateDecisions, server-side timestamp)')
164
+ .option('--pause <bool>', 'Whether the gate must pause for a human (true|false) — required')
165
+ .option('--reason <text>', 'Why this decision was taken (defaults by pause value)')
166
+ .option('--signals <json>', 'Risk signals as a JSON object, e.g. \'{"taskCount":12,"evalScore":9.2}\'')
167
+ .option('--trust <mode>', 'Trust mode used for the decision (default: config.json → trust, or "auto")')
168
+ .option('--json', 'Output as JSON')
169
+ .action((feature, gateArg, options) => gateDecisionCommand(feature, gateArg, options));
170
+
171
+ program
172
+ .command('create <feature>')
173
+ .description('Create the feature folder structure + canonical feature.json (explicit birth — see framework/schemas/feature.schema.json)')
174
+ .option('--status <status>', 'Initial feature status (default: in_progress)')
175
+ .option('--json', 'Output as JSON')
176
+ .action((feature, options) => createCommand(feature, options));
177
+
149
178
  program
150
179
  .command('score <feature> <task> <score>')
151
180
  .description('Record a task eval score in feature.json (taskScores, server-side timestamp)')
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "morph-spec",
3
- "version": "7.8.1",
3
+ "version": "7.9.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",
@@ -12,6 +12,10 @@ morph-spec doctor # health check
12
12
  morph-spec --version
13
13
  ```
14
14
 
15
+ > `npm i -g ...@latest` deu `ETARGET` logo após uma release? O CDN do npm
16
+ > leva alguns minutos para propagar o `latest` — instale com a versão
17
+ > explícita (`npm i -g @polymorphism-tech/morph-spec@X.Y.Z`) ou aguarde.
18
+
15
19
  ---
16
20
 
17
21
  ## Phase workflow
@@ -196,4 +200,4 @@ These files are never edited directly. Use CLI commands or `morph-spec update` i
196
200
 
197
201
  ---
198
202
 
199
- *morph-spec v7.8.1 by Polymorphism Tech*
203
+ *morph-spec v7.9.1 by Polymorphism Tech*
@@ -197,4 +197,4 @@ morph-spec doctor
197
197
 
198
198
  ---
199
199
 
200
- *morph-spec v7.8.1 by Polymorphism Tech*
200
+ *morph-spec v7.9.1 by Polymorphism Tech*
@@ -25,7 +25,7 @@ Se não houver feature ativa, pergunte ao usuário qual feature trabalhar.
25
25
  - Apresentar gates com o template fixo definido em MORPH.md / morph-plan.
26
26
  - Registrar decisões não triviais em `decisions.md`.
27
27
  - 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.
28
- - Incluir as 3 cláusulas de protocolo em todo dispatch de sub-agent (relatório final obrigatório; 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.
28
+ - Incluir as 3 cláusulas de protocolo em todo dispatch de sub-agent (**ato final = relatório via `SendMessage` para `main`** — última mensagem em texto não chega ao orquestrador; não tocar tasks.json/feature.json/board; sem build/test da suíte compartilhada em batch paralelo) — templates com exemplo literal em `morph-implement` §4 e `morph-apply` §3b. O hook `subagent-report-guard` bloqueia uma vez o encerramento de subagent despachado sem SendMessage.
29
29
 
30
30
  ## Slash commands
31
31
 
@@ -44,6 +44,8 @@ Se não houver feature ativa, pergunte ao usuário qual feature trabalhar.
44
44
  - `morph-spec archive <feature>` — arquiva a feature (atômico, git-aware); é o que `/morph-archive` chama.
45
45
  - `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).
46
46
  - `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**.
47
+ - `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.
48
+ - `morph-spec create <feature>` — 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).
47
49
 
48
50
  ## Skills core (em `.claude/skills/`)
49
51
 
@@ -123,9 +123,10 @@ Não invente caminhos alternativos.
123
123
 
124
124
  ### Traces (`.morph/traces/`)
125
125
 
126
- Cada sessão de trabalho produz um trace JSON em `.morph/traces/YYYY-MM-DD-{feature}-{phase}.json`.
126
+ Cada fase de implementação produz um trace JSON em `.morph/traces/YYYY-MM-DD-{feature}-{phase}.json`.
127
127
  O trace registra tarefas, scores de eval, standards consultados e sub-agents despachados.
128
128
  O formato canônico está em `.morph/framework/evals/trace-schema.md` (criado na issue #17).
129
+ **O trace é gerado automaticamente pelo hook `trace-autogen`** a cada write de `tasks.json`, a partir de tasks.json + recap.md + `taskScores` do feature.json — as skills não o escrevem à mão (a escrita manual foi removida da cerimônia por task).
129
130
  Traces são ignorados pelo git (`.gitignore`) e servem para análise do harness — não fazem parte da entrega da feature.
130
131
 
131
132
  ---
@@ -196,7 +197,7 @@ Para alterar o modo: editar `.morph/config/config.json → trust` (`"auto"` ou `
196
197
 
197
198
  Isso mantém o audit trail honesto justamente nos contextos headless (CI, `claude -p`) onde ele mais importa.
198
199
 
199
- **O hook gate-guard.js** confere, sem bloquear, se a ação tomada num gate (pausar via `AskUserQuestion` vs auto-aprovar) bateu com a decisão de trust que a skill computou via `shouldPauseGate`. Para isso, a skill **persiste** a decisão no `feature.json` autoritativo da feature antes de agir: `gateDecisions[gate] = { pause, reason, signals, trust, decidedAt }` (escrito com a tool `Edit` — a allowlist permite `Edit(.morph/features/**)`). Se a ação observada divergir do registro (pausou um gate auto-aprovável, ou auto-aprovou um gate que exigia pausa), o guard injeta um aviso no contexto — a LLM corrige. É um guardrail que nunca bloqueia: a cada conferência ele registra uma linha no activity log (`.morph/logs/activity.json`) com o resultado — `coherent` ou `divergent` — e só injeta aviso quando diverge; sem registro ou erro → silêncio.
200
+ **O hook gate-guard.js** confere, sem bloquear, se a ação tomada num gate (pausar via `AskUserQuestion` vs auto-aprovar) bateu com a decisão de trust que a skill computou via `shouldPauseGate`. Para isso, a skill **persiste** a decisão no `feature.json` autoritativo da feature antes de agir — via `morph-spec gate-decision <feature> <gate> --pause <bool> [--reason <txt>] [--signals <json>]`, que grava `gateDecisions[gate] = { pause, reason, signals, trust, decidedAt }` com timestamp server-side (shape canônico em `framework/schemas/feature.schema.json`). **Nunca edite `gateDecisions` à mão:** o Edit manual de feature.json colide com o hook state-sync, que reescreve o arquivo entre o Read e o Edit ("file modified since read"). Se a ação observada divergir do registro (pausou um gate auto-aprovável, ou auto-aprovou um gate que exigia pausa), o guard injeta um aviso no contexto — a LLM corrige. É um guardrail que nunca bloqueia: a cada conferência ele registra uma linha no activity log (`.morph/logs/activity.json`) com o resultado — `coherent` ou `divergent` — e só injeta aviso quando diverge; sem registro ou erro → silêncio.
200
201
 
201
202
  ---
202
203
 
@@ -235,7 +236,7 @@ Inline (sem dispatch) é aceitável para tasks curtas (effort S/M) **dentro do d
235
236
  - Auto-score 0-10 com justificativa em uma linha
236
237
 
237
238
  **Protocolo obrigatório do sub-agent** (cláusulas que todo prompt de dispatch inclui — ver templates em `morph-implement` §4 e `morph-apply` §3b):
238
- 1. Ao concluir, entregar o relatório final (última mensagem, ou SendMessage para main se a sessão permanecer viva) — terminar idle sem reportar é **falha de protocolo**; o orquestrador cobra via SendMessage e registra a falha no recap.
239
+ 1. **ATO FINAL: enviar o relatório completo via `SendMessage` para `main` antes de encerrar** — a última mensagem em texto NÃO chega ao orquestrador; só SendMessage chega. Terminar idle sem SendMessage é **falha de protocolo**: o hook `subagent-report-guard` bloqueia o encerramento uma vez com a instrução, e a cobrança do orquestrador via SendMessage é apenas o fallback (registrada como falha no recap). Os templates trazem o exemplo literal da chamada.
239
240
  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.
240
241
  3. Em batch paralelo, não rodar build/test da suíte compartilhada — a verificação é do orquestrador, uma vez, pós-batch (alternativa: dispatch com `isolation: "worktree"`, e aí o build roda dentro do worktree).
241
242
 
@@ -45,22 +45,32 @@ Um arquivo por fase. Se a fase for executada em múltiplas sessões, o mesmo arq
45
45
  }
46
46
  ```
47
47
 
48
- ## Quando escrever
48
+ ## Como o trace é gerado (automático — não escreva à mão)
49
49
 
50
- | Evento | O que logar |
51
- |--------|-------------|
52
- | Task completada | Adicionar entrada em `tasks[]` com todos os campos preenchidos |
53
- | Eval executada (`morph-eval`) | Preencher `eval_score` e `eval_dimensions` da task correspondente |
54
- | Fase concluída | Preencher `completed_at` e `phase_summary` |
50
+ O trace é **gerado pelo hook `trace-autogen`** (PostToolUse em Write|Edit) a cada
51
+ write de `2-plan/tasks.json` — o último write da cerimônia de cada task. As skills
52
+ (`morph-implement`, `morph-eval`, `morph-review`) **não escrevem o trace**.
55
53
 
56
- ## Regras de escrita
54
+ | Fonte | O que alimenta |
55
+ |-------|----------------|
56
+ | `tasks.json` | conjunto de tasks, `status` (done→completed, skipped→skipped, blocked→failed; pending/in_progress ficam fora), `outputs` → `files_modified` |
57
+ | `recap.md` | `**Standard:**` → `standards_consulted`; `**Persona:**` → `agent_persona` + `sub_agent_dispatched` (`morph-{id}`/`general-purpose` = true) |
58
+ | `feature.json → taskScores` (gravado por `morph-spec score`) | `eval_score` (lastScore) e `eval_dimensions` (camelCase → snake_case) |
57
59
 
58
- - O modelo escreve o trace como arquivo JSON ao final de cada task e ao final da fase.
59
- - Se um campo não tiver valor disponível, usar `null` — nunca omitir o campo.
60
- - Manter a estrutura completa para compatibilidade com análise de meta-harness.
61
- - `session_id` usa o formato `YYYY-MM-DD-HH-MM-SS` do início da sessão atual.
60
+ `completed_at` e `phase_summary` são preenchidos quando nenhuma task resta
61
+ `pending`/`in_progress`. O arquivo existente da mesma feature+fase é atualizado
62
+ in place (`session_id`/`started_at` preservados entre sessões).
63
+
64
+ **Se o trace parecer incompleto, corrija a FONTE** (um `morph-spec score`
65
+ faltante, um recap sem o bloco `## T{N}`), nunca o trace.
66
+
67
+ ## Regras de geração
68
+
69
+ - Se um campo não tiver valor disponível, o hook usa `null` — nunca omite o campo.
70
+ - Estrutura completa mantida para compatibilidade com análise de meta-harness.
71
+ - `session_id` usa o formato `YYYY-MM-DD-HH-MM-SS` da primeira geração.
62
72
  - `standards_consulted` contém os `id`s do STANDARDS.json, não os paths.
63
- - `files_modified` usa paths relativos à raiz do repositório.
73
+ - `files_modified` usa paths relativos à raiz do repositório (vem de `outputs`).
64
74
 
65
75
  ## Motivação
66
76
 
@@ -47,10 +47,12 @@ framework/hooks/
47
47
  | **PreToolUse** (Bash) | pre-bash-denylist.js | Block | Blocks `rm -rf .morph/` and direct state edits |
48
48
  | **PostToolUse** (Write\|Edit) | post-edit-typecheck.js | Advisory | Debounced `tsc --noEmit` after TS/TSX edits; silent on success |
49
49
  | **PostToolUse** (Write\|Edit) | tasks-json-guard.js | Advisory | Validates the written tasks.json schema (status/effort enums); warns so the LLM fixes it immediately |
50
+ | **PostToolUse** (Write\|Edit) | trace-autogen.js | State sync | Auto-generates `.morph/traces/YYYY-MM-DD-{feature}-implement.json` from tasks.json + recap.md + taskScores on every tasks.json write — the skills never write traces by hand |
50
51
  | **PostToolUse** (Write\|Edit\|AskUserQuestion) | state-sync.js | State sync | Autonomous state updates (feature register, output tracking, gate approval) |
51
52
  | **PostToolUse** (Write\|Edit\|AskUserQuestion) | gate-guard.js | Advisory | Warns (never blocks) when a gate action disagrees with the persisted gateDecisions; logs one `coherent`/`divergent` line per check in `.morph/logs/activity.json` (issue #63) |
52
53
  | **PostToolUse** (all tools) | loop-detect.js | Advisory | Warns when the last 8 actions collapse to ≤2 unique signatures |
53
54
  | **Stop** | validate-completion.js | Advisory | Warns about incomplete tasks/missing outputs/recap drift; build check; session snapshot |
55
+ | **Stop** / **SubagentStop** | subagent-report-guard.js | Block (once) | Blocks a dispatched sub-agent stopping without SendMessage (clause 1 of the dispatch protocol) and injects the exact instruction; `stop_hook_active` prevents loops; inert in the orchestrator's session |
54
56
 
55
57
  ## Design Principles
56
58
 
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * subagent-report-guard.js — Stop / SubagentStop hook
4
+ *
5
+ * Enforces clause 1 of the morph-spec dispatch protocol: a dispatched
6
+ * sub-agent's FINAL act is SendMessage to the orchestrator (main). Plain text
7
+ * in the last message never reaches the orchestrator, and an idle agent forces
8
+ * the costly nag-and-wait cycle (observed 2/2 in real apply sessions —
9
+ * MORPH_GHLBrain 2026-07-11).
10
+ *
11
+ * Detection: the session transcript (JSONL) contains the dispatch-protocol
12
+ * marker ("Protocolo obrigatório") inside a USER message — that is how the
13
+ * dispatch prompt arrives in the sub-agent's own transcript. In the
14
+ * ORCHESTRATOR's transcript the marker only appears inside assistant tool_use
15
+ * input (the Agent call that did the dispatch), so the orchestrator is never
16
+ * blocked by this guard. A SendMessage tool_use anywhere in an assistant
17
+ * message counts as the report.
18
+ *
19
+ * Action: block the stop ONCE ({"decision":"block"}) with the exact
20
+ * instruction to call SendMessage — the agent continues and reports.
21
+ * stop_hook_active guards against blocking loops. Fail-open on any error.
22
+ */
23
+
24
+ import { readFileSync, existsSync } from 'fs';
25
+ import { stateExists } from '../../shared/state-reader.js';
26
+ import { block, pass } from '../../shared/hook-response.js';
27
+ import { logHookActivity } from '../../shared/activity-logger.js';
28
+
29
+ const PROTOCOL_MARKER = 'Protocolo obrigatório';
30
+
31
+ function readStdin() {
32
+ try {
33
+ return JSON.parse(readFileSync(0, 'utf8') || '{}');
34
+ } catch {
35
+ return {};
36
+ }
37
+ }
38
+
39
+ function projectDir() {
40
+ return process.env.CLAUDE_PROJECT_DIR || process.cwd();
41
+ }
42
+
43
+ /**
44
+ * Scan a transcript for the dispatch-protocol marker (user messages only) and
45
+ * for a SendMessage tool call (assistant messages).
46
+ *
47
+ * @param {string} path - JSONL transcript path
48
+ * @returns {{ dispatched: boolean, reported: boolean }}
49
+ */
50
+ function scanTranscript(path) {
51
+ const raw = readFileSync(path, 'utf8');
52
+ let dispatched = false;
53
+ let reported = false;
54
+ for (const line of raw.split('\n')) {
55
+ if (!line.trim()) continue;
56
+ let role = null;
57
+ try {
58
+ const entry = JSON.parse(line);
59
+ role = entry?.message?.role ?? entry?.type ?? null;
60
+ } catch {
61
+ continue;
62
+ }
63
+ if (!dispatched && role === 'user' && line.includes(PROTOCOL_MARKER)) dispatched = true;
64
+ if (!reported && role === 'assistant' && line.includes('"name":"SendMessage"')) reported = true;
65
+ }
66
+ return { dispatched, reported };
67
+ }
68
+
69
+ try {
70
+ if (!stateExists(projectDir())) pass();
71
+
72
+ const input = readStdin();
73
+
74
+ // Never loop: a previous block already forced one continuation — let this
75
+ // stop through even if the agent still failed to report.
76
+ if (input.stop_hook_active) pass();
77
+
78
+ const transcriptPath = input.transcript_path;
79
+ if (!transcriptPath || !existsSync(transcriptPath)) pass();
80
+
81
+ const { dispatched, reported } = scanTranscript(transcriptPath);
82
+ if (!dispatched || reported) pass();
83
+
84
+ logHookActivity('subagent-report-guard', input.hook_event_name || 'Stop', 'blocked', projectDir());
85
+ block(
86
+ '[morph-spec] PROTOCOLO DE RETORNO — você foi despachado com o protocolo obrigatório ' +
87
+ 'mas ainda não chamou SendMessage. Sua última mensagem em texto NÃO chega ao orquestrador. ' +
88
+ 'Antes de encerrar, chame a tool SendMessage com to:"main" e o relatório completo no content: ' +
89
+ '(1) o que foi feito em 2-3 frases, (2) arquivos modificados, (3) problemas encontrados e ' +
90
+ 'como foram resolvidos, (4) auto-score 0-10. O SendMessage é o seu ato final.'
91
+ );
92
+ } catch {
93
+ // Fail-open
94
+ process.exit(0);
95
+ }
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * trace-autogen.js — PostToolUse hook (Write|Edit)
4
+ *
5
+ * Auto-generates the execution trace
6
+ * `.morph/traces/YYYY-MM-DD-{feature}-implement.json` from tasks.json +
7
+ * recap.md + feature.json taskScores whenever tasks.json is written during
8
+ * the implement phase.
9
+ *
10
+ * This removes the manual trace-writing step from the skills (friction item
11
+ * observed in MORPH_GHLBrain 2026-07-11: 5 writes per task). The tasks.json
12
+ * status flip — the last write of every task's ceremony — now produces the
13
+ * trace for free: deterministic, idempotent, always in sync with the real
14
+ * on-disk state. The skills must NOT write traces by hand anymore.
15
+ *
16
+ * Sources:
17
+ * - tasks.json → task set, status (done→completed, skipped→skipped,
18
+ * blocked→failed; pending/in_progress stay out), outputs
19
+ * - recap.md → per-task `**Standard:**` id and `**Persona:**` line
20
+ * - feature.json → taskScores (lastScore → eval_score, dimensions →
21
+ * eval_dimensions, camelCase → snake_case)
22
+ *
23
+ * The most recent existing `*-{feature}-implement.json` is updated in place
24
+ * (session_id/started_at preserved); a new dated file is created otherwise.
25
+ * `completed_at` + phase_summary are stamped when no task is left
26
+ * pending/in_progress. Fail-open: any error exits 0 silently.
27
+ */
28
+
29
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'fs';
30
+ import { join } from 'path';
31
+ import { readStdin } from '../../shared/stdin-reader.js';
32
+ import { stateExists } from '../../shared/state-reader.js';
33
+ import { getFilePath } from '../../shared/payload-utils.js';
34
+
35
+ function projectDir() {
36
+ return process.env.CLAUDE_PROJECT_DIR || process.cwd();
37
+ }
38
+
39
+ function extractFeature(filePath) {
40
+ const normalized = String(filePath).replace(/\\/g, '/');
41
+ const match = normalized.match(/\.morph\/features\/([^/]+)\/2-plan\/tasks\.json$/);
42
+ return match ? match[1] : null;
43
+ }
44
+
45
+ /**
46
+ * Parse recap.md task blocks (`## T{N} — ...`) into
47
+ * { [id]: { standard, persona } }. Best-effort: missing fields stay null.
48
+ */
49
+ function parseRecap(raw) {
50
+ const map = {};
51
+ const blocks = String(raw).split(/^## (?=T\d+)/m).slice(1);
52
+ for (const block of blocks) {
53
+ const id = (block.match(/^(T\d+)/) ?? [])[1];
54
+ if (!id) continue;
55
+ const standard = (block.match(/\*\*Standard:\*\*\s*([^\s(]+)/) ?? [])[1] ?? null;
56
+ const persona = (block.match(/\*\*Persona:\*\*\s*(.+)$/m) ?? [])[1]?.trim() ?? null;
57
+ map[id] = { standard, persona };
58
+ }
59
+ return map;
60
+ }
61
+
62
+ /** session_id in the trace-schema format YYYY-MM-DD-HH-MM-SS. */
63
+ function sessionId(d) {
64
+ const p = (n) => String(n).padStart(2, '0');
65
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}-${p(d.getHours())}-${p(d.getMinutes())}-${p(d.getSeconds())}`;
66
+ }
67
+
68
+ /** tasks.json status → trace-schema status. Unsettled statuses are omitted. */
69
+ const STATUS_MAP = { done: 'completed', skipped: 'skipped', blocked: 'failed' };
70
+
71
+ async function main() {
72
+ const cwd = projectDir();
73
+ if (!stateExists(cwd)) return;
74
+
75
+ const payload = await readStdin();
76
+ if (!payload) return;
77
+
78
+ const filePath = getFilePath(payload);
79
+ const feature = filePath ? extractFeature(filePath) : null;
80
+ if (!feature) return;
81
+
82
+ const featureDir = join(cwd, '.morph', 'features', feature);
83
+ const tasksPath = join(featureDir, '2-plan', 'tasks.json');
84
+ if (!existsSync(tasksPath)) return;
85
+ const tasksDoc = JSON.parse(readFileSync(tasksPath, 'utf8'));
86
+ const tasks = Array.isArray(tasksDoc.tasks) ? tasksDoc.tasks : [];
87
+
88
+ let recap = {};
89
+ const recapPath = join(featureDir, '3-implement', 'recap.md');
90
+ if (existsSync(recapPath)) {
91
+ try { recap = parseRecap(readFileSync(recapPath, 'utf8')); } catch { recap = {}; }
92
+ }
93
+
94
+ let taskScores = {};
95
+ const featureJsonPath = join(featureDir, 'feature.json');
96
+ if (existsSync(featureJsonPath)) {
97
+ try { taskScores = JSON.parse(readFileSync(featureJsonPath, 'utf8')).taskScores ?? {}; } catch { taskScores = {}; }
98
+ }
99
+
100
+ const tracesDir = join(cwd, '.morph', 'traces');
101
+ mkdirSync(tracesDir, { recursive: true });
102
+
103
+ // Multi-session phases update the same file incrementally (trace-schema):
104
+ // reuse the most recent trace for this feature+phase if one exists.
105
+ const suffix = `-${feature}-implement.json`;
106
+ const existing = readdirSync(tracesDir)
107
+ .filter(f => /^\d{4}-\d{2}-\d{2}-/.test(f) && f.endsWith(suffix))
108
+ .sort()
109
+ .pop();
110
+
111
+ let previous = null;
112
+ if (existing) {
113
+ try { previous = JSON.parse(readFileSync(join(tracesDir, existing), 'utf8')); } catch { previous = null; }
114
+ }
115
+
116
+ const now = new Date();
117
+ const fileName = existing ?? `${now.toISOString().slice(0, 10)}${suffix}`;
118
+
119
+ const entries = tasks
120
+ .filter(t => STATUS_MAP[t.status])
121
+ .map(t => {
122
+ const recapEntry = recap[t.id] ?? {};
123
+ const score = taskScores[t.id];
124
+ const dims = score?.dimensions;
125
+ return {
126
+ id: t.id,
127
+ title: t.title ?? null,
128
+ status: STATUS_MAP[t.status],
129
+ standards_consulted: recapEntry.standard ? [recapEntry.standard] : [],
130
+ agent_persona: recapEntry.persona ?? null,
131
+ sub_agent_dispatched: recapEntry.persona
132
+ ? /^morph-|general-purpose/.test(recapEntry.persona)
133
+ : false,
134
+ eval_score: score?.lastScore ?? null,
135
+ eval_dimensions: dims ? {
136
+ architecture: dims.architecture ?? null,
137
+ contracts: dims.contracts ?? null,
138
+ code_quality: dims.codeQuality ?? null,
139
+ test_coverage: dims.testCoverage ?? null,
140
+ } : null,
141
+ files_modified: Array.isArray(t.outputs) ? t.outputs.filter(o => typeof o === 'string') : [],
142
+ duration_seconds: null,
143
+ };
144
+ });
145
+
146
+ const allSettled = tasks.length > 0 &&
147
+ tasks.every(t => t.status !== 'pending' && t.status !== 'in_progress');
148
+ const scores = entries.map(e => e.eval_score).filter(s => typeof s === 'number');
149
+
150
+ const standardCounts = {};
151
+ for (const entry of entries) {
152
+ for (const std of entry.standards_consulted) {
153
+ standardCounts[std] = (standardCounts[std] ?? 0) + 1;
154
+ }
155
+ }
156
+ const mostUsed = Object.entries(standardCounts)
157
+ .sort((a, b) => b[1] - a[1])
158
+ .slice(0, 3)
159
+ .map(([std]) => std);
160
+
161
+ const trace = {
162
+ schema_version: '1.0',
163
+ session_id: previous?.session_id ?? sessionId(now),
164
+ feature,
165
+ phase: 'implement',
166
+ started_at: previous?.started_at ?? now.toISOString(),
167
+ completed_at: allSettled ? (previous?.completed_at ?? now.toISOString()) : null,
168
+ tasks: entries,
169
+ phase_summary: {
170
+ tasks_completed: entries.filter(e => e.status === 'completed').length,
171
+ tasks_failed: entries.filter(e => e.status === 'failed').length,
172
+ avg_eval_score: scores.length
173
+ ? Math.round((scores.reduce((a, b) => a + b, 0) / scores.length) * 10) / 10
174
+ : null,
175
+ standards_most_used: mostUsed,
176
+ sub_agents_dispatched: entries.filter(e => e.sub_agent_dispatched).length,
177
+ },
178
+ };
179
+
180
+ writeFileSync(join(tracesDir, fileName), JSON.stringify(trace, null, 2), 'utf8');
181
+ }
182
+
183
+ main().catch(() => { /* fail-open */ }).finally(() => process.exit(0));
@@ -19,6 +19,11 @@ export const VALID_TASK_EFFORT = ['S', 'M', 'L', 'XL'];
19
19
  * @param {object} doc - parsed tasks.json
20
20
  * @returns {{ valid: boolean, errors: string[] }}
21
21
  */
22
+ /** Human-readable type name for error messages ('array', not 'object'). */
23
+ function typeName(value) {
24
+ return Array.isArray(value) ? 'array' : typeof value;
25
+ }
26
+
22
27
  export function validateTasksDoc(doc) {
23
28
  if (!doc || typeof doc !== 'object' || !Array.isArray(doc.tasks)) {
24
29
  return { valid: false, errors: ['tasks.json must be an object with a tasks[] array'] };
@@ -33,6 +38,23 @@ export function validateTasksDoc(doc) {
33
38
  if (task?.effort !== undefined && !VALID_TASK_EFFORT.includes(task.effort)) {
34
39
  errors.push(`task '${id}': invalid effort '${task.effort}' (valid: ${VALID_TASK_EFFORT.join(', ')})`);
35
40
  }
41
+ // Mutable-field type checks with the exact field/value in the message —
42
+ // a malformed notes/outputs used to surface as the misleading generic
43
+ // "notes is empty" downstream (MORPH_GHLBrain 2026-07-11).
44
+ if (task?.notes !== undefined && typeof task.notes !== 'string') {
45
+ errors.push(`task '${id}': 'notes' must be a string (got ${typeName(task.notes)})`);
46
+ }
47
+ if (task?.outputs !== undefined) {
48
+ if (!Array.isArray(task.outputs)) {
49
+ errors.push(`task '${id}': 'outputs' must be an array of path strings (got ${typeName(task.outputs)})`);
50
+ } else {
51
+ task.outputs.forEach((entry, i) => {
52
+ if (typeof entry !== 'string') {
53
+ errors.push(`task '${id}': outputs[${i}] must be a string path (got ${typeName(entry)})`);
54
+ }
55
+ });
56
+ }
57
+ }
36
58
  }
37
59
  return { valid: errors.length === 0, errors };
38
60
  }
@@ -47,6 +69,12 @@ export function validateTasksDoc(doc) {
47
69
  * @param {object} newDoc - parsed proposed tasks.json
48
70
  * @returns {string[]} human-readable violation messages (empty = OK)
49
71
  */
72
+ /** Compact outputs rendering for messages, capped so huge arrays stay readable. */
73
+ function fmtOutputs(outputs) {
74
+ const s = JSON.stringify(outputs ?? []);
75
+ return s.length > 120 ? s.slice(0, 117) + '...' : s;
76
+ }
77
+
50
78
  export function outputsDivergenceViolations(oldDoc, newDoc) {
51
79
  const violations = [];
52
80
  const oldById = new Map((oldDoc?.tasks ?? []).map(t => [t.id, t]));
@@ -56,13 +84,28 @@ export function outputsDivergenceViolations(oldDoc, newDoc) {
56
84
  if (!old) continue;
57
85
  const outputsChanged =
58
86
  JSON.stringify(old.outputs ?? []) !== JSON.stringify(task.outputs ?? []);
59
- const hasNote = typeof task.notes === 'string' && task.notes.trim().length > 0;
60
- if (outputsChanged && !hasNote) {
87
+ if (!outputsChanged) continue;
88
+
89
+ // A filled string note explains the divergence — done.
90
+ if (typeof task.notes === 'string' && task.notes.trim().length > 0) continue;
91
+
92
+ // Root-cause specific messages: a malformed notes value is a DIFFERENT
93
+ // failure than a missing one — claiming "notes is empty" when the agent
94
+ // just wrote an array reads as a false positive and burns a retry cycle.
95
+ if (task.notes !== undefined && typeof task.notes !== 'string') {
61
96
  violations.push(
62
- `task '${task.id}': outputs diverged from the plan but 'notes' is empty — ` +
63
- `record why in 'notes' in the same edit`
97
+ `task '${task.id}': outputs diverged from the plan and 'notes' is not a string ` +
98
+ `(got ${typeName(task.notes)}) — write the explanation as a single string in 'notes'`
64
99
  );
100
+ continue;
65
101
  }
102
+
103
+ violations.push(
104
+ `task '${task.id}': outputs diverged from the plan ` +
105
+ `(${fmtOutputs(old.outputs)} → ${fmtOutputs(task.outputs)}) but 'notes' is empty — ` +
106
+ `record why in 'notes' in the same edit. ` +
107
+ `Deletions and annotations belong in 'notes', not in 'outputs' (outputs = produced paths only).`
108
+ );
66
109
  }
67
110
  return violations;
68
111
  }