@polymorphism-tech/morph-spec 8.37.1 → 8.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +19 -6
- package/README.md +2 -2
- package/bin/morph-spec.js +16 -7
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +3 -3
- package/framework/MORPH.md +25 -22
- package/framework/agents.json +76 -27
- package/framework/docs/CLI.md +54 -17
- package/framework/evals/rubrics/code-quality.md +5 -0
- package/framework/evals/rubrics/condensed.md +6 -2
- package/framework/evals/rubrics/contracts.md +20 -0
- package/framework/evals/rubrics/test-coverage.md +17 -6
- package/framework/hooks/README.md +9 -3
- package/framework/hooks/claude-code/core/graph-refresh-kick.js +52 -0
- package/framework/hooks/claude-code/core/telemetry-log.js +172 -87
- package/framework/hooks/claude-code/core/validate-completion.js +16 -90
- package/framework/hooks/claude-code/core/worktree-context.js +10 -0
- package/framework/hooks/shared/command-tokens.js +96 -0
- package/framework/hooks/shared/git-exclude.js +44 -0
- package/framework/hooks/shared/git-paths.js +52 -0
- package/framework/hooks/shared/graph-freshness.js +380 -0
- package/framework/hooks/shared/graph-refresh.js +416 -0
- package/framework/hooks/shared/morph-cli.js +17 -5
- package/framework/hooks/shared/redact-secrets.js +55 -0
- package/framework/hooks/shared/telemetry-logger.js +25 -2
- package/framework/hooks/shared/transcript-usage.js +80 -0
- package/framework/rubrics/backend.json +2 -2
- package/framework/rubrics/default.json +2 -2
- package/framework/rubrics/frontend.json +2 -2
- package/framework/rules/csharp-standards.md +1 -0
- package/framework/rules/morph-workflow.md +1 -0
- package/framework/rules/nextjs-standards.md +1 -0
- package/framework/schemas/config.schema.json +41 -1
- package/framework/schemas/feature.schema.json +5 -1
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +36 -11
- package/framework/skills/core/morph-brainstorming/SKILL.md +16 -7
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +39 -10
- package/framework/skills/core/morph-media/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +72 -15
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +51 -13
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +34 -9
- package/framework/standards/STANDARDS.json +187 -12
- package/framework/standards/ai-agents/testing-ai.md +2 -1
- package/framework/standards/architecture/code-comments.md +64 -0
- package/framework/standards/backend/dotnet/testing.md +22 -0
- package/framework/standards/frontend/admin-app/data-table.md +562 -0
- package/framework/standards/frontend/admin-app/feedback.md +236 -0
- package/framework/standards/frontend/admin-app/forms.md +232 -0
- package/framework/standards/frontend/admin-app/page-structure.md +203 -0
- package/framework/standards/frontend/nextjs/components.md +37 -58
- package/framework/standards/frontend/nextjs/data-fetching.md +1 -0
- package/framework/standards/frontend/nextjs/forms.md +31 -30
- package/framework/standards/frontend/nextjs/state-management.md +2 -1
- package/framework/standards/frontend/nextjs/testing.md +18 -0
- package/framework/templates/context/design-system.md +3 -3
- package/framework/templates/docs/proposal.md +11 -0
- package/framework/templates/docs/spec.md +15 -0
- package/framework/templates/docs/ui-mockups.md +115 -98
- package/framework/templates/feature/decisions.md +124 -122
- package/package.json +1 -1
- package/src/commands/graph/affected.js +4 -3
- package/src/commands/graph/build.js +109 -18
- package/src/commands/graph/explain.js +8 -5
- package/src/commands/graph/status.js +14 -8
- package/src/commands/observability/cost.js +148 -18
- package/src/commands/project/doctor.js +82 -10
- package/src/commands/project/update.js +25 -0
- package/src/commands/project/worktree.js +73 -90
- package/src/commands/state/advance.js +18 -0
- package/src/commands/state/approve.js +15 -0
- package/src/commands/state/archive.js +62 -9
- package/src/commands/state/finish.js +62 -14
- package/src/commands/state/gate-check.js +8 -1
- package/src/commands/state/gate-decision.js +8 -4
- package/src/commands/state/report-check.js +90 -9
- package/src/commands/state/usecase.js +66 -0
- package/src/commands/tasks/dag.js +17 -2
- package/src/commands/validation/validate-feature.js +6 -1
- package/src/commands/validation/verify.js +20 -1
- package/src/dashboard/scanner/agents-scanner.js +5 -1
- package/src/dashboard/scanner/hooks-scanner.js +8 -1
- package/src/dashboard/scanner/standards-scanner.js +2 -1
- package/src/lib/ai/comment-stripper.js +101 -4
- package/src/lib/evaluator-report.js +25 -0
- package/src/lib/generators/handoff-generator.js +4 -18
- package/src/lib/git/default-branch.js +52 -0
- package/src/lib/git/tree-fingerprint.js +58 -0
- package/src/lib/git/tree-state.js +167 -0
- package/src/lib/graph/god-nodes.js +12 -3
- package/src/lib/graph/graph-health.js +48 -2
- package/src/lib/graph/graph-loader.js +99 -25
- package/src/lib/graph/graphifyignore.js +145 -28
- package/src/lib/graph/sanitize.js +117 -11
- package/src/lib/risk-detector.js +90 -0
- package/src/lib/specs/acervo-format.js +70 -0
- package/src/lib/specs/acervo-lifecycle.js +264 -0
- package/src/lib/specs/adr-index.js +169 -0
- package/src/lib/specs/revocations.js +156 -0
- package/src/lib/specs/screen-checklist.js +357 -0
- package/src/lib/specs/usecase-check.js +20 -16
- package/src/lib/specs/usecase-promoter.js +103 -38
- package/src/lib/specs/usecase-registry.js +7 -7
- package/src/lib/tasks/tasks-json-parser.js +2 -3
- package/src/lib/telemetry/metrics.js +386 -0
- package/src/lib/telemetry/telemetry-reader.js +161 -51
- package/src/lib/telemetry/verify-event.js +79 -0
- package/src/lib/validators/code-budget/code-budget-validator.js +172 -0
- package/src/lib/validators/code-budget/comment-scanner.js +75 -0
- package/src/lib/validators/code-budget/index.js +9 -0
- package/src/lib/validators/reuse/reuse-validator.js +4 -2
- package/src/lib/validators/reuse/symbol-scanner.js +27 -22
- package/src/lib/validators/validation-runner.js +21 -5
- package/src/lib/validators/verify-runner.js +11 -0
- package/src/lib/worktree/finish-backup.js +739 -0
- package/src/lib/worktree/ignored-triage.js +15 -8
- package/src/lib/worktree/worktree-audit.js +5 -0
- package/src/scripts/setup-infra.js +29 -18
- package/src/utils/agents-installer.js +281 -12
- package/src/utils/hooks-installer.js +75 -15
|
@@ -11,6 +11,7 @@ Avalia naming, ausência de code smells e aderência aos padrões do stack do pr
|
|
|
11
11
|
- **Nada do que foi criado aqui já existia no projeto**: os conceitos novos batem com o veredito do `reuse-map.md`, e cada `CRIAR` tem justificativa que se sustenta.
|
|
12
12
|
- Segue os padrões do stack conforme os standards do projeto (ex: Result pattern para .NET, hooks pattern para React).
|
|
13
13
|
- Sem "dead code" — sem variáveis não usadas, sem blocos comentados, sem imports desnecessários.
|
|
14
|
+
- Comentário só com o porquê que o código não mostra (`architecture-code-comments`): nenhum conta história de task, UC, ADR, Gate ou avaliador.
|
|
14
15
|
|
|
15
16
|
## Score 7-8 — Bom
|
|
16
17
|
|
|
@@ -20,6 +21,7 @@ Avalia naming, ausência de code smells e aderência aos padrões do stack do pr
|
|
|
20
21
|
- No máximo um conceito recriado que já existia no codebase, com impacto pequeno e localizado.
|
|
21
22
|
- Segue padrões do stack com desvios isolados e justificáveis.
|
|
22
23
|
- Sem dead code significativo.
|
|
24
|
+
- Um ou outro comentário que repete o código; nenhum narra o processo.
|
|
23
25
|
|
|
24
26
|
## Score 5-6 — Aceitável com ressalvas
|
|
25
27
|
|
|
@@ -29,6 +31,7 @@ Avalia naming, ausência de code smells e aderência aos padrões do stack do pr
|
|
|
29
31
|
- Um ou dois tipos/componentes recriados que já existiam em outro arquivo do projeto.
|
|
30
32
|
- Desvios dos padrões do stack sem justificativa.
|
|
31
33
|
- Dead code pontual.
|
|
34
|
+
- Comentário de processo (UC, ADR, `T{n}`, Gate, `spec.md`, mockup) ou comentário afirmando comportamento que nenhum teste cobra.
|
|
32
35
|
|
|
33
36
|
## Score 3-4 — Problemático
|
|
34
37
|
|
|
@@ -37,6 +40,7 @@ Avalia naming, ausência de code smells e aderência aos padrões do stack do pr
|
|
|
37
40
|
- Duplicação significativa: mesma lógica em múltiplos lugares.
|
|
38
41
|
- Conceito central do domínio redeclarado na MESMA feature (o caso `SimpleBuilding`: cinco cópias na mesma pasta, uma já divergindo do resto).
|
|
39
42
|
- Não segue os padrões do stack — código parece escrito para outro contexto.
|
|
43
|
+
- Blocos longos de comentário contando a história da implementação, em vários arquivos.
|
|
40
44
|
|
|
41
45
|
## Score 1-2 — Reprovado
|
|
42
46
|
|
|
@@ -91,3 +95,4 @@ chave `obs` no `**Proof-Scores:**`, também não há teto a aplicar.
|
|
|
91
95
|
3b. **O que foi criado aqui já existia no projeto?** Confira os tipos, componentes, hooks e serviços novos contra o `reuse-map.md` e contra o grafo — e diga no relatório o que você conferiu.
|
|
92
96
|
4. O código segue os padrões definidos nos standards do projeto?
|
|
93
97
|
5. Há variáveis não usadas, imports desnecessários ou blocos comentados?
|
|
98
|
+
6. Algum comentário conta história de processo, repete o código ou passa de ~3 linhas sem justificar? O validador `code-budget` do nó `validators` já conta, nas linhas adicionadas, os comentários que citam processo; leia os citados antes de pontuar.
|
|
@@ -30,8 +30,8 @@ Se o diff toca endpoint minimal API ou fetch cross-camada, independente do resto
|
|
|
30
30
|
|---|---|---|---|---|
|
|
31
31
|
| **Arquitetura** | Ignora a arquitetura do spec.md; sem feature folder; deps circulares; ou violação mecânica acima | Pastas corretas, responsabilidades se misturam; deps cross-feature sem justificativa; domain acoplado a infra | Desvios menores justificados; deps cross-feature via abstração; impurezas de infra pontuais | Feature folder rigoroso; zero deps cross-feature diretas; domain isolado; DI onde exige |
|
|
32
32
|
| **Contratos** | Sem tipos ou `any` generalizado; zero validação; sem tratamento de erro; naming arbitrário | `any`/genérico visível; validação parcial; mix exceptions/Result; naming inconsistente | Tipos bem definidos, `any` raro e justificado; validação na maioria; erro consistente | Zero `any`; validação em todos endpoints; Result pattern/discriminated union; naming consistente |
|
|
33
|
-
| **Qualidade** | Nomes arbitrários; god objects; duplicação massiva; recria o que o `reuse-map` mandou REUSAR; fora do padrão do stack | Nomes genéricos (`data`,`temp`); funções >30 linhas; duplicação perceptível; conceito da MESMA feature redeclarado | Nomes claros, raras exceções; bem dimensionado; duplicação mínima; no máximo um conceito recriado | Nomes comunicam intenção; funções ≤20-25 linhas, 1 responsabilidade; zero duplicação/dead code; nada criado que já existia no projeto |
|
|
34
|
-
| **Testes** | Sem testes ou sem asserts reais; zero edge case; nomes genéricos | Happy path OK, edge cases parciais; erros só óbvios; testes interdependentes; teste de texto de arquivo sem substituto em runtime | Happy path completo; maioria dos edge cases; erros nos fluxos principais | Toda pós-condição do `usecase` tem teste; A1/A2 cobertos; edge+erro cobertos; isolados |
|
|
33
|
+
| **Qualidade** | Nomes arbitrários; god objects; duplicação massiva; recria o que o `reuse-map` mandou REUSAR; fora do padrão do stack | Nomes genéricos (`data`,`temp`); funções >30 linhas; duplicação perceptível; conceito da MESMA feature redeclarado; comentário que narra task/UC/ADR | Nomes claros, raras exceções; bem dimensionado; duplicação mínima; no máximo um conceito recriado | Nomes comunicam intenção; funções ≤20-25 linhas, 1 responsabilidade; zero duplicação/dead code; nada criado que já existia no projeto; comentário só com o porquê |
|
|
34
|
+
| **Testes** | Sem testes ou sem asserts reais; zero edge case; nomes genéricos | Happy path OK, edge cases parciais; erros só óbvios; testes interdependentes; teste de texto de arquivo sem substituto em runtime; cópias em vez de tabela ou excesso sem justificativa | Happy path completo; maioria dos edge cases; erros nos fluxos principais | Toda pós-condição do `usecase` tem teste; A1/A2 cobertos; edge+erro cobertos, em tabela; isolados; dentro do orçamento |
|
|
35
35
|
|
|
36
36
|
> **Qualidade olha o CODEBASE, não só o diff.** Símbolo novo cujo nome já existe no projeto não aparece no diff — é preciso perguntar de fora dele. O nó `validators` do `morph-spec verify` já traz os achados do validador `reuse`; o `2-plan/reuse-map.md` diz o que o plano decidiu por conceito.
|
|
37
37
|
|
|
@@ -39,6 +39,10 @@ Se o diff toca endpoint minimal API ou fetch cross-camada, independente do resto
|
|
|
39
39
|
|
|
40
40
|
> Teste que só lê texto de arquivo não cobre pós-condição de comportamento: sem substituto em runtime, ela conta como sem teste (herdado de `test-coverage.md`, pergunta 7).
|
|
41
41
|
|
|
42
|
+
> Orçamento por task: um teste por pós-condição + um de regressão por bug, cenários do mesmo comportamento numa tabela; teste que só fixa copy é achado (herdado de `test-coverage.md`, pergunta 8). O validador `code-budget` do nó `validators` avisa sobre teste acima de 1,5× a produção e sobre comentário que cita processo.
|
|
43
|
+
|
|
44
|
+
> Task de UI cuja tela tem `### Checklist de tela` no `1-design/mockups.md`: item divergente sem justificativa em `decisions.md` → teto 8 em Contratos; paginação, ordenação ou filtro que o checklist põe no servidor entregue em memória (ou ausente), ou estado vazio/carregando/erro decidido e ausente → teto 6 (herdado de `contracts.md`, "Contrato de tela").
|
|
45
|
+
|
|
42
46
|
## Score composto
|
|
43
47
|
|
|
44
48
|
```
|
|
@@ -38,6 +38,25 @@ Avalia a qualidade dos tipos, validações, tratamento de erros e naming dos con
|
|
|
38
38
|
- Sem tratamento de erro — falhas em runtime sem diagnóstico útil.
|
|
39
39
|
- Naming arbitrário ou genérico (`data`, `obj`, `result` sem qualificador).
|
|
40
40
|
|
|
41
|
+
## Contrato de tela (feature com UI)
|
|
42
|
+
|
|
43
|
+
Quando o `1-design/mockups.md` declara telas (`## Tela:`) com `### Checklist de tela`, o checklist é
|
|
44
|
+
um contrato como os tipos da spec: cada item decidido (volume, paginação, ordenação, filtro, os três
|
|
45
|
+
estados, mobile, abas) é conferido contra a tela **entregue** e registrado na seção `## Checklist de
|
|
46
|
+
tela` do `evaluator-report.md` — uma linha por item, com o planejado, o observado e o veredito
|
|
47
|
+
(`cumpre` / `diverge` / `não verificável`, com o motivo).
|
|
48
|
+
|
|
49
|
+
| Achado | Teto em Contratos |
|
|
50
|
+
|---|---|
|
|
51
|
+
| Item `diverge` sem justificativa registrada em `decisions.md` | 8 |
|
|
52
|
+
| Paginação, ordenação ou filtro que o checklist põe no servidor, entregue em memória no cliente ou ausente | 6 |
|
|
53
|
+
| Estado vazio, carregando ou de erro que o checklist decide e a tela não tem | 6 |
|
|
54
|
+
|
|
55
|
+
`não verificável` (stack fora do ar) não é divergência: o avaliador lê o código — a query recebe
|
|
56
|
+
página e ordenação? o estado vazio existe? — e diz no veredito por onde conferiu. Itens "não se
|
|
57
|
+
aplica" e a superfície não precisam de linha. Sem a seção, ou com item decidido sem linha, o
|
|
58
|
+
`morph-spec gate-check` pausa o Gate 3 por conta própria, antes de qualquer nota.
|
|
59
|
+
|
|
41
60
|
## Perguntas de avaliação
|
|
42
61
|
|
|
43
62
|
1. Há `any` ou tipos genéricos onde um tipo específico seria possível?
|
|
@@ -45,3 +64,4 @@ Avalia a qualidade dos tipos, validações, tratamento de erros e naming dos con
|
|
|
45
64
|
3. O tratamento de erros segue o padrão do projeto (Result, exceções, etc.)?
|
|
46
65
|
4. Os nomes dos tipos e propriedades comunicam claramente o que representam?
|
|
47
66
|
5. Campos opcionais fazem sentido semântico, ou são opcionais por comodidade?
|
|
67
|
+
6. Em feature com UI: cada item decidido do checklist de tela foi conferido contra a tela entregue, e toda divergência tem justificativa?
|
|
@@ -7,7 +7,8 @@ Avalia se os testes cobrem os comportamentos esperados, são isolados e têm nom
|
|
|
7
7
|
- **Cada pós-condição do use case referenciado tem teste correspondente.** Se a task traz `usecase: "UC-07"`, abra `2-plan/usecases/UC-07-*.md` e percorra as pós-condições (P1, P2…): cada uma precisa de ao menos um teste que a verifique. Pós-condição sem teste **impede a faixa 9-10**, por mais completa que a suíte pareça no resto.
|
|
8
8
|
- Happy path coberto para todos os casos de uso relevantes — "relevantes" são os use cases referenciados pelas tasks da feature, não uma lista implícita.
|
|
9
9
|
- Fluxos alternativos do use case (A1, A2…) cobertos: são os edge cases de **comportamento** e foram declarados justamente para serem testados.
|
|
10
|
-
- Edge cases críticos cobertos: null/undefined, string vazia, boundary values, coleção vazia.
|
|
10
|
+
- Edge cases críticos cobertos: null/undefined, string vazia, boundary values, coleção vazia — como linhas da tabela do comportamento (`[Theory]`/`it.each`), não como cópias do mesmo teste.
|
|
11
|
+
- **Dentro do orçamento da task**: um teste por pós-condição, mais um de regressão por bug; o que passa disso tem justificativa escrita na task.
|
|
11
12
|
- Casos de erro cobertos: o que acontece quando a operação falha, quando a dependência retorna erro.
|
|
12
13
|
- Testes completamente isolados — cada teste pode rodar sozinho em qualquer ordem.
|
|
13
14
|
- Nomes de teste descrevem o comportamento esperado: `"deve retornar erro quando email já existe"` (não `"testEmail"`).
|
|
@@ -28,6 +29,7 @@ Avalia se os testes cobrem os comportamentos esperados, são isolados e têm nom
|
|
|
28
29
|
- Alguns testes com dependências entre si (ordem importa).
|
|
29
30
|
- Nomes misturados: alguns descritivos, outros genéricos.
|
|
30
31
|
- Testes que testam implementação (dependentes de detalhes internos).
|
|
32
|
+
- Cenários do mesmo comportamento copiados em N testes em vez de uma tabela, ou testes além do orçamento da task sem justificativa.
|
|
31
33
|
|
|
32
34
|
## Score 3-4 — Problemático
|
|
33
35
|
|
|
@@ -35,6 +37,7 @@ Avalia se os testes cobrem os comportamentos esperados, são isolados e têm nom
|
|
|
35
37
|
- Testes interdependentes ou com estado compartilhado.
|
|
36
38
|
- Nomes que não descrevem o comportamento testado.
|
|
37
39
|
- Cobertura presente mas superficial — testes passam mas não protegem contra regressão.
|
|
40
|
+
- Suíte inflada: a maior parte dos testes novos fixa texto ou repete o mesmo comportamento.
|
|
38
41
|
|
|
39
42
|
## Score 1-2 — Reprovado
|
|
40
43
|
|
|
@@ -59,13 +62,21 @@ Avalia se os testes cobrem os comportamentos esperados, são isolados e têm nom
|
|
|
59
62
|
apareceu no score, porque score não pergunta isso. **Sem resposta a esta pergunta numa correção,
|
|
60
63
|
o teto de Cobertura é 8.**
|
|
61
64
|
7. **Quantos testes desta task asseram sobre TEXTO DE ARQUIVO** (prompt, migration, config,
|
|
62
|
-
markdown, código-fonte lido como string
|
|
63
|
-
que exercita o sistema ou, em critério sobre saída de LLM,
|
|
64
|
-
`3-implement/evidencias/{taskId}.md` apontando um eval? Teste de texto prova a
|
|
65
|
-
arquivo, não o comportamento: quando é o único teste de uma pós-condição, **a
|
|
66
|
-
conta como sem teste** (pergunta 0 — teto 8). Exceções: pós-condição com
|
|
65
|
+
markdown, código-fonte lido como string, copy de UI, config de tenant)? **Cada um tem
|
|
66
|
+
substituto em runtime** — um teste que exercita o sistema ou, em critério sobre saída de LLM,
|
|
67
|
+
a evidência em `3-implement/evidencias/{taskId}.md` apontando um eval? Teste de texto prova a
|
|
68
|
+
VERSÃO do arquivo, não o comportamento: quando é o único teste de uma pós-condição, **a
|
|
69
|
+
pós-condição conta como sem teste** (pergunta 0 — teto 8). Exceções: pós-condição com
|
|
67
70
|
`> [!note] Por que artefato:` e o único teste de estrutura por fonte de prompt
|
|
68
71
|
(`ai-agents-testing-ai`). Medido: 12 suítes, 106 testes sobre versões superadas do prompt.
|
|
72
|
+
Teste cuja asserção é só a presença ou ausência de uma string de copy é **achado** mesmo quando
|
|
73
|
+
há teste de runtime ao lado: essa checagem é analyzer, lint ou validação no publish.
|
|
74
|
+
8. **A task cabe no orçamento?** Um teste por pós-condição, mais um de regressão por bug; o
|
|
75
|
+
excesso tem justificativa na task? Cenários do mesmo comportamento estão numa tabela, e a
|
|
76
|
+
fixture é a compartilhada, não uma refeita? O validador `code-budget` do nó `validators` avisa
|
|
77
|
+
quando as linhas de teste adicionadas passam de 1,5× as de produção. Mutante sobrevivente da
|
|
78
|
+
prova de mutação se fecha apertando asserção ou somando linha de tabela: um teste novo por
|
|
79
|
+
mutante é excesso, não cobertura.
|
|
69
80
|
|
|
70
81
|
## Tetos de prova (Gate 3)
|
|
71
82
|
|
|
@@ -45,7 +45,7 @@ framework/hooks/
|
|
|
45
45
|
|
|
46
46
|
| Event | Hook | Type | Purpose |
|
|
47
47
|
|-------|------|------|---------|
|
|
48
|
-
| **SessionStart** | worktree-context.js | Context inject | Secondary worktree → feature identity (name/branch/phase) + missing-junction warning; primary root → active-feature summary (phase, task stats, pending gates), silent if none |
|
|
48
|
+
| **SessionStart** | worktree-context.js | Context inject | Secondary worktree → feature identity (name/branch/phase) + missing-junction warning; primary root → active-feature summary (phase, task stats, pending gates), silent if none. Also seeds a worktree that has no codebase graph from the root's, and launches the background refresh of the ROOT's graph (`shared/graph-refresh.js`) |
|
|
49
49
|
| **PreToolUse** (Write\|Edit) | _(native permissions.deny)_ | Block | Blocks edits to state.json and .morph/framework/ |
|
|
50
50
|
| **PreToolUse** (Write\|Edit) | protect-spec-files.js | Block | Blocks edits to spec files after approval; for tasks.json validates the mutable-field diff, the status enum, and requires a `notes` when `outputs` diverge from the plan |
|
|
51
51
|
| **PreToolUse** (Write\|Edit) | enforce-phase-writes.js | Block | Ensures writes go to current phase directory |
|
|
@@ -61,6 +61,10 @@ framework/hooks/
|
|
|
61
61
|
| **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.jsonl` (issue #63) |
|
|
62
62
|
| **PostToolUse** (all tools) | telemetry-log.js | Telemetry | Appends every tool call (skill, sub-agent dispatch + prompt, knowledge-base read, bash/edit) to `.morph/logs/events.jsonl`; silent, fail-open — the NLH execution-path capture read back by `morph-spec telemetry` |
|
|
63
63
|
| **PostToolUse** (all tools) | loop-detect.js | Advisory | Warns when the last 8 actions collapse to ≤2 unique signatures |
|
|
64
|
+
| **PostToolUse** (all tools) | graph-refresh-kick.js | Background | After a Bash or PowerShell command that runs git/gh/morph-spec, launches the DETACHED refresh of the ROOT's codebase graph (`shared/graph-refresh.js`: cwd in the git common dir, never a worktree; one lock per repository; staleness by `shared/graph-freshness.js` — stamp plus the per-tree run record in `<git-common-dir>/morph-spec/graph-refresh-state.json`); never waits, never injects |
|
|
65
|
+
| **PostToolUseFailure** (all tools) | telemetry-log.js | Telemetry | The failed call (a Bash that exits non-zero fires only this event): exit code parsed from the `Exit code N` line, outcome and duration — never the error body |
|
|
66
|
+
| **PostToolBatch** | telemetry-log.js | Telemetry | One `ToolBatch` event per model turn that used tools (the turn counter of `cost --metrics`) |
|
|
67
|
+
| **SubagentStop** | telemetry-log.js | Telemetry | `AgentUsage`: the sub-agent's token usage read from its own transcript, as a delta per agent; never blocks the stop |
|
|
64
68
|
| **UserPromptSubmit** | telemetry-log.js | Telemetry | Appends the user's prompt to the same `events.jsonl` stream; silent, fail-open |
|
|
65
69
|
| **Stop** | validate-completion.js | Advisory | Warns about incomplete tasks/missing outputs/recap drift (hash-deduped, only re-injects when the warning set changes); dirty-flag-gated build check |
|
|
66
70
|
|
|
@@ -122,9 +126,11 @@ All Claude Code hooks import from `framework/hooks/shared/`:
|
|
|
122
126
|
| `phase-utils.js` | Phase constants, path extraction, file classification |
|
|
123
127
|
| `hook-response.js` | `block(reason)`, `approve(context)`, `injectContext(text)`, `pass()` |
|
|
124
128
|
| `stdin-reader.js` | `readStdin()` — Promise-based stdin JSON reader |
|
|
125
|
-
| `telemetry-logger.js` | `appendTelemetryEvent()`, `readTelemetryEvents()` — append-only JSONL event stream (`.morph/logs/events.jsonl`) |
|
|
129
|
+
| `telemetry-logger.js` | `appendTelemetryEvent()`, `readTelemetryEvents()` — append-only JSONL event stream (`.morph/logs/events.jsonl`); stamps `schemaVersion`/`eventId` and redacts secrets |
|
|
130
|
+
| `redact-secrets.js` | `redactSecrets()` — pattern redaction of obvious secrets in persisted free text |
|
|
131
|
+
| `transcript-usage.js` | `readTranscriptUsage()`, `usageDelta()` — token usage of a transcript, deduplicated by message id, and the reset-safe delta between samples |
|
|
126
132
|
| `morph-cli.js` | `runMorphSpec(args, { cwd, timeoutMs, env })` — spawns the morph-spec CLI from a hook (`MORPH_SPEC_BIN` → `morph-spec` on PATH → `npx --no-install`); `unavailable: true` when none resolves, never a throw. Hooks may never import from `src/` |
|
|
127
|
-
| `command-tokens.js` | `tokenize(command)` — quote-aware shell tokenizer shared by the Bash guards (a hook must never import a sibling hook: its standalone tail would fire) |
|
|
133
|
+
| `command-tokens.js` | `tokenize(command)` — quote-aware shell tokenizer shared by the Bash guards (a hook must never import a sibling hook: its standalone tail would fire); `exitCodeMasked()`/`producerOf()` — whether a pipe hides the exit code, and the command without its output filter |
|
|
128
134
|
|
|
129
135
|
`injectContext(text)` is how `worktree-context.js` (and any other SessionStart hook) delivers context: it emits `{ "hookSpecificOutput": { "hookEventName": "SessionStart", "additionalContext": text } }` on stdout. Claude Code only honors additional context nested under `hookSpecificOutput` — a bare top-level field is ignored.
|
|
130
136
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PostToolUse Hook: dispara o refresh do grafo de codebase
|
|
5
|
+
*
|
|
6
|
+
* Event: PostToolUse | Matcher: none (elo da cadeia de todos os tools)
|
|
7
|
+
*
|
|
8
|
+
* Depois de um comando de shell — Bash ou PowerShell, que no Windows é o shell
|
|
9
|
+
* principal do Claude Code — que chama git, gh ou morph-spec, solta o refresh
|
|
10
|
+
* do grafo da RAIZ em background (framework/hooks/shared/graph-refresh.js) e
|
|
11
|
+
* volta na hora. O filtro é largo de propósito: o refresh só roda quando o
|
|
12
|
+
* grafo da raiz está atrás do HEAD, então um `git status` custa um processo
|
|
13
|
+
* que sai sem fazer nada — e um comando que move o HEAD sem ser reconhecido é
|
|
14
|
+
* exatamente como o grafo ficou 85 commits atrás.
|
|
15
|
+
*
|
|
16
|
+
* Silencioso e fail-open: nunca injeta contexto, nunca bloqueia.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { stateExists } from '../../shared/state-reader.js';
|
|
20
|
+
import { runStandalone } from '../../shared/hook-response.js';
|
|
21
|
+
import { tokenize } from '../../shared/command-tokens.js';
|
|
22
|
+
import { kickGraphRefresh } from '../../shared/graph-refresh.js';
|
|
23
|
+
|
|
24
|
+
const SHELL_TOOLS = new Set(['Bash', 'PowerShell']);
|
|
25
|
+
const GIT_TOOL = /(^|[\\/])(git|gh|morph-spec)(\.exe|\.cmd)?$/i;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {string} command
|
|
29
|
+
* @returns {boolean} o comando chama git, gh ou morph-spec em algum ponto
|
|
30
|
+
*/
|
|
31
|
+
export function touchesGit(command) {
|
|
32
|
+
return tokenize(command)
|
|
33
|
+
.flatMap((t) => t.split(/[;&|()]+/))
|
|
34
|
+
.some((t) => GIT_TOOL.test(t));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* @param {object} payload - PostToolUse stdin payload
|
|
39
|
+
* @returns {Promise<null>}
|
|
40
|
+
*/
|
|
41
|
+
export async function run(payload) {
|
|
42
|
+
try {
|
|
43
|
+
if (!SHELL_TOOLS.has(payload?.tool_name)) return null;
|
|
44
|
+
if (!touchesGit(payload?.tool_input?.command)) return null;
|
|
45
|
+
const dir = payload?.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
46
|
+
if (!stateExists(dir)) return null;
|
|
47
|
+
kickGraphRefresh(dir);
|
|
48
|
+
} catch { /* fail-open */ }
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
await runStandalone(run);
|
|
@@ -3,76 +3,80 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* Telemetry Hook: harness execution-path capture
|
|
5
5
|
*
|
|
6
|
-
* Events: PostToolUse (all tools)
|
|
6
|
+
* Events: PostToolUse + PostToolUseFailure (all tools), PostToolBatch,
|
|
7
|
+
* SubagentStop, UserPromptSubmit
|
|
7
8
|
*
|
|
8
9
|
* morph-spec is a Natural Language Harness: there is no central runtime to
|
|
9
10
|
* instrument — Claude Code executes the skills and sub-agents. The only
|
|
10
11
|
* vantage points are hooks. This one is the generic capture point: every tool
|
|
11
12
|
* call and every user prompt is appended (one JSON line) to
|
|
12
|
-
* .morph/logs/events.jsonl, from which `morph-spec telemetry`
|
|
13
|
-
*
|
|
14
|
-
* prompts), which standards/knowledge-base files were read.
|
|
13
|
+
* .morph/logs/events.jsonl, from which `morph-spec telemetry` and
|
|
14
|
+
* `morph-spec cost` reconstruct the path and measure it.
|
|
15
15
|
*
|
|
16
16
|
* Correlation is by an EXPLICIT key (feature + phase) written into each event,
|
|
17
17
|
* not by ambient span context — hooks fire as separate node processes with no
|
|
18
|
-
* shared trace context
|
|
19
|
-
*
|
|
18
|
+
* shared trace context. `sessionId` and `agentId` (present only inside a
|
|
19
|
+
* sub-agent) separate the orchestrator from the sub-agents it dispatched.
|
|
20
|
+
*
|
|
21
|
+
* PostToolUse fires only when a tool SUCCEEDS; a Bash command that exits
|
|
22
|
+
* non-zero fires PostToolUseFailure instead. Only the exit code parsed from
|
|
23
|
+
* its `error` first line is kept — the rest of `error` is the command's
|
|
24
|
+
* output, where a secret value can appear with no name beside it.
|
|
20
25
|
*
|
|
21
26
|
* SINK RESOLUTION: the event is appended to the `.morph/` tree that OWNS the
|
|
22
|
-
* tool's target file
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* git worktree; sinking on the session's cwd put those events in the WRONG
|
|
26
|
-
* tree's events.jsonl, the mirror of the trace-autogen worktree defect. Tools
|
|
27
|
-
* that carry no file_path (Bash, Skill, Agent…) have no target to resolve —
|
|
28
|
-
* the session cwd stays the reference for those, same as before.
|
|
27
|
+
* tool's target file (`resolveMorphRoot`); tools with no file path sink on the
|
|
28
|
+
* nearest morph tree above the payload's `cwd`, so a sub-agent working in a
|
|
29
|
+
* worktree logs its Bash calls next to its Reads and Writes.
|
|
29
30
|
*
|
|
30
31
|
* SILENT: never blocks, never injects context — only appends. Fail-open on any
|
|
31
32
|
* error (exit 0). Only active inside a morph project (stateExists gate).
|
|
32
33
|
*/
|
|
33
34
|
|
|
34
|
-
import { statSync } from 'fs';
|
|
35
|
+
import { statSync, readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
36
|
+
import { dirname, join } from 'path';
|
|
35
37
|
import { stateExists, getActiveFeature, derivePhaseForFeature } from '../../shared/state-reader.js';
|
|
36
38
|
import { appendTelemetryEvent } from '../../shared/telemetry-logger.js';
|
|
37
39
|
import { runStandalone } from '../../shared/hook-response.js';
|
|
38
40
|
import { getFilePath, resolveMorphRoot, resolveTargetPath } from '../../shared/payload-utils.js';
|
|
41
|
+
import { redactSecrets } from '../../shared/redact-secrets.js';
|
|
42
|
+
import { exitCodeMasked } from '../../shared/command-tokens.js';
|
|
43
|
+
import { readTranscriptUsage, usageDelta } from '../../shared/transcript-usage.js';
|
|
39
44
|
|
|
40
45
|
function normalize(p) {
|
|
41
46
|
return (p || '').replace(/\\/g, '/');
|
|
42
47
|
}
|
|
43
48
|
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
// OTHER event type stays exactly as-is) to `MAX_FIELD_CHARS`, with the
|
|
49
|
-
// ORIGINAL length preserved in a sibling `<field>Chars` key — `morph-spec
|
|
50
|
-
// cost` sums prompt chars for its dispatch-cost view, and must prefer that
|
|
51
|
-
// field over `prompt.length` once it's present (a truncated string's own
|
|
52
|
-
// length would silently undercount every dispatch over the cap). This shape
|
|
53
|
-
// is a CONTRACT with the telemetry reader — do not rename either half.
|
|
49
|
+
// A multi-KB prompt or command written verbatim on every call dominates the
|
|
50
|
+
// per-line cost. The ORIGINAL length travels in a sibling `<field>Chars` key,
|
|
51
|
+
// which `morph-spec cost` prefers over the truncated string's own length —
|
|
52
|
+
// a contract with the telemetry reader: do not rename either half.
|
|
54
53
|
const MAX_FIELD_CHARS = 2000;
|
|
55
54
|
const TRUNCATE_SUFFIX = '…[truncado]';
|
|
56
55
|
|
|
56
|
+
// Read returns this many lines when the call passes no `limit`.
|
|
57
|
+
const DEFAULT_READ_LINES = 2000;
|
|
58
|
+
// Above this, measuring the slice would cost more than the hook budget allows.
|
|
59
|
+
const MAX_SLICE_BYTES = 10 * 1024 * 1024;
|
|
60
|
+
const BINARY_READ = /\.(png|jpe?g|gif|webp|bmp|ico|pdf)$/i;
|
|
61
|
+
|
|
57
62
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
63
|
+
* Redact, then cap a free-text field, reporting its original length. Redacting
|
|
64
|
+
* first keeps a token cut at the cap from surviving as a recognizable prefix.
|
|
60
65
|
*
|
|
61
66
|
* @param {string} value
|
|
62
67
|
* @returns {{ value: string, chars: number }}
|
|
63
68
|
*/
|
|
64
69
|
function capField(value) {
|
|
65
70
|
const str = typeof value === 'string' ? value : '';
|
|
66
|
-
|
|
67
|
-
return { value:
|
|
71
|
+
const clean = redactSecrets(str);
|
|
72
|
+
if (clean.length <= MAX_FIELD_CHARS) return { value: clean, chars: str.length };
|
|
73
|
+
return { value: clean.slice(0, MAX_FIELD_CHARS) + TRUNCATE_SUFFIX, chars: str.length };
|
|
68
74
|
}
|
|
69
75
|
|
|
70
76
|
/**
|
|
71
77
|
* Classify a Read target into the telemetry `kind` taxonomy — a contract with
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
* categories before falling back to 'code'. Case-insensitive; `/` and `\`
|
|
75
|
-
* both normalize to `/` upstream via `normalize()`.
|
|
78
|
+
* src/lib/telemetry/telemetry-reader.js, so these values are pinned. Specific
|
|
79
|
+
* categories first, 'code' last.
|
|
76
80
|
*/
|
|
77
81
|
function classifyReadKind(target) {
|
|
78
82
|
const t = (target || '').toLowerCase();
|
|
@@ -82,9 +86,6 @@ function classifyReadKind(target) {
|
|
|
82
86
|
if (t.endsWith('skill.md') || t.includes('/skills/')) return 'skill';
|
|
83
87
|
if (t.split('/').pop() === 'agents.json') return 'agents';
|
|
84
88
|
|
|
85
|
-
// Inside a feature folder AND one of the named plan artifacts (or under a
|
|
86
|
-
// usecases/ or reports/ subfolder) — everything else in a feature folder
|
|
87
|
-
// (source files a task produced, say) is 'code'.
|
|
88
89
|
const inFeature = t.match(/\.morph\/features\/[^/]+\/(.+)$/);
|
|
89
90
|
if (inFeature) {
|
|
90
91
|
const rel = inFeature[1];
|
|
@@ -101,60 +102,118 @@ function classifyReadKind(target) {
|
|
|
101
102
|
return 'code';
|
|
102
103
|
}
|
|
103
104
|
|
|
105
|
+
/** Nearest directory at or above `dir` that is a morph project; `fallback` when none. */
|
|
106
|
+
function morphRootAbove(dir, fallback) {
|
|
107
|
+
if (!dir) return fallback;
|
|
108
|
+
let current = normalize(dir);
|
|
109
|
+
for (let i = 0; i < 32; i++) {
|
|
110
|
+
if (stateExists(current)) return current;
|
|
111
|
+
const parent = normalize(dirname(current));
|
|
112
|
+
if (parent === current) break;
|
|
113
|
+
current = parent;
|
|
114
|
+
}
|
|
115
|
+
return fallback;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function featureFromPath(target) {
|
|
119
|
+
const m = /\/\.morph\/features\/([^/]+)\//.exec(`/${normalize(target)}`);
|
|
120
|
+
return m ? m[1] : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
104
123
|
/**
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
|
|
124
|
+
* What a Read actually put in context: the requested line window, not the
|
|
125
|
+
* whole file. Binary reads (images, PDFs) go in whole.
|
|
126
|
+
*/
|
|
127
|
+
function readSliceBytes(absPath, input, size) {
|
|
128
|
+
if (BINARY_READ.test(absPath)) return size;
|
|
129
|
+
if (size > MAX_SLICE_BYTES) return undefined;
|
|
130
|
+
const offset = Number.isInteger(input.offset) && input.offset > 1 ? input.offset : 1;
|
|
131
|
+
const limit = Number.isInteger(input.limit) && input.limit > 0 ? input.limit : DEFAULT_READ_LINES;
|
|
132
|
+
const lines = readFileSync(absPath, 'utf-8').split('\n');
|
|
133
|
+
if (offset === 1 && limit >= lines.length) return size;
|
|
134
|
+
return Buffer.byteLength(lines.slice(offset - 1, offset - 1 + limit).join('\n'), 'utf-8');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Exit code and outcome of a finished tool call, without keeping its output. */
|
|
138
|
+
function outcomeOf(payload, input) {
|
|
139
|
+
if (payload.hook_event_name === 'PostToolUseFailure') {
|
|
140
|
+
const firstLine = String(payload.error ?? '').split('\n', 1)[0];
|
|
141
|
+
const code = /^exit code (\d+)/i.exec(firstLine);
|
|
142
|
+
const outcome = payload.is_interrupt === true ? 'interrupted'
|
|
143
|
+
: /timed? ?out/i.test(firstLine) ? 'timeout'
|
|
144
|
+
: 'error';
|
|
145
|
+
return { outcome, exitCode: code ? Number(code[1]) : null };
|
|
146
|
+
}
|
|
147
|
+
if (input.run_in_background === true) return { outcome: 'background', exitCode: null };
|
|
148
|
+
if (payload.tool_response?.interrupted === true) return { outcome: 'interrupted', exitCode: null };
|
|
149
|
+
return { outcome: 'ok', exitCode: 0 };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function countByName(calls) {
|
|
153
|
+
const out = {};
|
|
154
|
+
for (const call of calls) {
|
|
155
|
+
const name = call?.tool_name || 'unknown';
|
|
156
|
+
out[name] = (out[name] || 0) + 1;
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Sub-agent tokens live in their own transcript; each stop emits the delta.
|
|
163
|
+
* One file per agent: two sub-agents stopping together never share a
|
|
164
|
+
* read-modify-write, and one agent never stops twice at once.
|
|
165
|
+
*/
|
|
166
|
+
function agentUsageEvent(payload, cwd) {
|
|
167
|
+
const usage = readTranscriptUsage(payload.agent_transcript_path);
|
|
168
|
+
if (!usage || !payload.agent_id) return null;
|
|
169
|
+
const dir = join(cwd, '.morph', 'memory', 'agent-usage');
|
|
170
|
+
const storePath = join(dir, `${String(payload.agent_id).replace(/[^A-Za-z0-9_-]/g, '_')}.json`);
|
|
171
|
+
let previous = null;
|
|
172
|
+
try {
|
|
173
|
+
if (existsSync(storePath)) previous = JSON.parse(readFileSync(storePath, 'utf-8'));
|
|
174
|
+
} catch { previous = null; }
|
|
175
|
+
const { delta, changed } = usageDelta(previous, usage);
|
|
176
|
+
if (!changed) return null;
|
|
177
|
+
try {
|
|
178
|
+
mkdirSync(dir, { recursive: true });
|
|
179
|
+
writeFileSync(storePath, JSON.stringify(usage), 'utf-8');
|
|
180
|
+
} catch { /* the event still carries the delta */ }
|
|
181
|
+
return { event: 'AgentUsage', ...usage, delta };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* @param {object} payload - parsed hook payload (PostToolUse, PostToolUseFailure,
|
|
186
|
+
* PostToolBatch, SubagentStop or UserPromptSubmit)
|
|
187
|
+
* @returns {Promise<null>} always null — pure side effect (append + claim heartbeat)
|
|
108
188
|
*/
|
|
109
189
|
export async function run(payload) {
|
|
110
190
|
try {
|
|
111
191
|
if (!payload) return null;
|
|
112
192
|
|
|
113
|
-
// Resolve the sink tree from the tool's target file (when it has one)
|
|
114
|
-
// BEFORE the stateExists gate below — otherwise a root session writing
|
|
115
|
-
// into a worktree would be gated on the ROOT's state instead of the
|
|
116
|
-
// worktree's.
|
|
117
193
|
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
118
194
|
const targetFilePath = getFilePath(payload);
|
|
119
|
-
const cwd = targetFilePath
|
|
195
|
+
const cwd = targetFilePath
|
|
196
|
+
? resolveMorphRoot(targetFilePath, projectDir)
|
|
197
|
+
: morphRootAbove(payload.cwd, projectDir);
|
|
120
198
|
|
|
121
199
|
if (!stateExists(cwd)) return null;
|
|
122
200
|
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
// feature.json, so enrichment is O(active-feature-count) per tool call. That
|
|
126
|
-
// is fine for realistic projects (archive removes finished features, so the
|
|
127
|
-
// dir holds only the few in-flight ones) but would grow on a project that
|
|
128
|
-
// keeps many un-archived features. If that ever bites, resolve the active
|
|
129
|
-
// feature from the thin state.json index alone. Kept accurate-over-cheap for
|
|
130
|
-
// now because phase is not stored in feature.json — it needs derivation.
|
|
201
|
+
// getActiveFeature → loadState hydrates every feature.json: O(active
|
|
202
|
+
// features) per call, fine while archive keeps that set small.
|
|
131
203
|
let feature = null;
|
|
132
204
|
let phase = null;
|
|
133
205
|
try {
|
|
134
206
|
const active = getActiveFeature(cwd);
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
phase = active.feature?.phase || derivePhaseForFeature(active.name, cwd);
|
|
138
|
-
}
|
|
207
|
+
feature = active?.name ?? featureFromPath(targetFilePath);
|
|
208
|
+
if (feature) phase = active?.feature?.phase || derivePhaseForFeature(feature, cwd);
|
|
139
209
|
} catch {
|
|
140
210
|
// enrichment is best-effort — a missing feature never blocks capture
|
|
141
211
|
}
|
|
142
212
|
|
|
143
|
-
// Claim heartbeat
|
|
144
|
-
//
|
|
145
|
-
// no
|
|
146
|
-
//
|
|
147
|
-
// doc in claim-registry.js) that answers "is a heartbeat even due"
|
|
148
|
-
// from a marker beside this project's own `.morph/logs/`. On ~99% of
|
|
149
|
-
// calls it says no and NOTHING git-related happens — not even the
|
|
150
|
-
// pure-FS walk below, let alone a subprocess.
|
|
151
|
-
// 2. Only when (1) says yes do we resolve the git common dir — via
|
|
152
|
-
// git-paths.js, pure filesystem, no `git` subprocess spawn (this used
|
|
153
|
-
// to be `execSync('git rev-parse --git-common-dir')`, ~34ms wasted on
|
|
154
|
-
// ~99% of calls because the write below was ALREADY throttled) — and
|
|
155
|
-
// call heartbeatClaim(), which has its own authoritative throttle
|
|
156
|
-
// keyed to claims.json's stored heartbeatAt.
|
|
157
|
-
// Silent and fail-open, like everything else here.
|
|
213
|
+
// Claim heartbeat: this hook fires on every tool call, so it is the proof
|
|
214
|
+
// of life for the advisory feature claim. shouldHeartbeat() is a local,
|
|
215
|
+
// git-free pre-check that says no on ~99% of calls; only then is the git
|
|
216
|
+
// common dir resolved (pure filesystem, no subprocess).
|
|
158
217
|
if (feature && payload.session_id) {
|
|
159
218
|
try {
|
|
160
219
|
const { shouldHeartbeat, heartbeatClaim } = await import('../../shared/claim-registry.js');
|
|
@@ -166,51 +225,77 @@ export async function run(payload) {
|
|
|
166
225
|
} catch { /* fail-open — a missed heartbeat only shortens a claim's life */ }
|
|
167
226
|
}
|
|
168
227
|
|
|
228
|
+
const hookEvent = payload.hook_event_name;
|
|
169
229
|
let event;
|
|
170
230
|
|
|
171
|
-
|
|
172
|
-
if (payload.hook_event_name === 'UserPromptSubmit' || (!payload.tool_name && typeof payload.prompt === 'string')) {
|
|
231
|
+
if (hookEvent === 'UserPromptSubmit' || (!payload.tool_name && typeof payload.prompt === 'string' && !hookEvent)) {
|
|
173
232
|
const userPrompt = capField(payload.prompt || '');
|
|
174
233
|
event = { event: 'UserPromptSubmit', tool: 'UserPrompt', prompt: userPrompt.value, promptChars: userPrompt.chars };
|
|
234
|
+
} else if (hookEvent === 'PostToolBatch') {
|
|
235
|
+
const calls = Array.isArray(payload.tool_calls) ? payload.tool_calls : [];
|
|
236
|
+
event = { event: 'ToolBatch', size: calls.length, tools: countByName(calls) };
|
|
237
|
+
} else if (hookEvent === 'SubagentStop') {
|
|
238
|
+
event = agentUsageEvent(payload, cwd);
|
|
239
|
+
if (!event) return null;
|
|
175
240
|
} else {
|
|
176
241
|
const tool = payload.tool_name;
|
|
177
242
|
if (!tool) return null;
|
|
178
243
|
const input = payload.tool_input || {};
|
|
179
|
-
event = { event: 'PostToolUse', tool };
|
|
244
|
+
event = { event: hookEvent === 'PostToolUseFailure' ? 'PostToolUseFailure' : 'PostToolUse', tool };
|
|
180
245
|
|
|
181
246
|
if (tool === 'Skill') {
|
|
182
247
|
event.skill = input.skill || '';
|
|
183
248
|
} else if (tool === 'Agent' || tool === 'Task') {
|
|
184
249
|
event.persona = input.subagent_type || '';
|
|
185
|
-
if (input.description) event.desc = input.description;
|
|
250
|
+
if (input.description) event.desc = redactSecrets(input.description);
|
|
186
251
|
const dispatchPrompt = capField(input.prompt || '');
|
|
187
252
|
event.prompt = dispatchPrompt.value;
|
|
188
253
|
event.promptChars = dispatchPrompt.chars;
|
|
254
|
+
} else if (tool === 'SendMessage') {
|
|
255
|
+
if (input.to) event.target = String(input.to);
|
|
256
|
+
event.messageChars = typeof input.message === 'string' ? input.message.length : 0;
|
|
189
257
|
} else if (tool === 'Read') {
|
|
190
258
|
event.target = targetFilePath || normalize(input.file_path || input.path);
|
|
191
259
|
event.kind = classifyReadKind(event.target);
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
260
|
+
if (Number.isInteger(input.offset)) event.offset = input.offset;
|
|
261
|
+
if (Number.isInteger(input.limit)) event.limit = input.limit;
|
|
262
|
+
// A failed Read ingested nothing. A stat/read race with a file deleted
|
|
263
|
+
// between the Read and this hook omits the numbers rather than fail.
|
|
264
|
+
if (hookEvent !== 'PostToolUseFailure') {
|
|
265
|
+
try {
|
|
266
|
+
const abs = resolveTargetPath(event.target, cwd);
|
|
267
|
+
event.bytes = statSync(abs).size;
|
|
268
|
+
const read = readSliceBytes(abs, input, event.bytes);
|
|
269
|
+
if (read !== undefined) event.bytesRead = read;
|
|
270
|
+
} catch {
|
|
271
|
+
// omit bytes
|
|
272
|
+
}
|
|
199
273
|
}
|
|
200
|
-
} else if (tool === 'Bash') {
|
|
201
|
-
const
|
|
202
|
-
event.command =
|
|
203
|
-
event.commandChars =
|
|
274
|
+
} else if (tool === 'Bash' || tool === 'PowerShell') {
|
|
275
|
+
const command = capField(input.command || '');
|
|
276
|
+
event.command = command.value;
|
|
277
|
+
event.commandChars = command.chars;
|
|
278
|
+
Object.assign(event, outcomeOf(payload, input));
|
|
279
|
+
if (exitCodeMasked(input.command)) event.exitMasked = true;
|
|
204
280
|
} else if (tool === 'Write' || tool === 'Edit') {
|
|
205
281
|
event.target = normalize(input.file_path || input.path);
|
|
206
282
|
} else {
|
|
207
|
-
// Every other tool still counts toward "the whole path" —
|
|
208
|
-
//
|
|
283
|
+
// Every other tool still counts toward "the whole path" — a compact
|
|
284
|
+
// target keeps Grep/Glob/WebFetch bursts distinguishable.
|
|
209
285
|
const target = input.file_path || input.path || input.pattern || input.query || input.url || '';
|
|
210
286
|
if (target) event.target = normalize(String(target));
|
|
211
287
|
}
|
|
288
|
+
|
|
289
|
+
if (hookEvent === 'PostToolUseFailure' && event.outcome === undefined) event.outcome = 'error';
|
|
290
|
+
if (payload.tool_use_id) event.toolUseId = payload.tool_use_id;
|
|
291
|
+
if (Number.isFinite(payload.duration_ms)) event.durationMs = payload.duration_ms;
|
|
212
292
|
}
|
|
213
293
|
|
|
294
|
+
if (payload.session_id) event.sessionId = payload.session_id;
|
|
295
|
+
if (payload.prompt_id) event.promptId = payload.prompt_id;
|
|
296
|
+
if (payload.agent_id) event.agentId = payload.agent_id;
|
|
297
|
+
if (payload.agent_type) event.agentType = payload.agent_type;
|
|
298
|
+
if (payload.cwd) event.cwd = normalize(payload.cwd);
|
|
214
299
|
event.feature = feature;
|
|
215
300
|
event.phase = phase;
|
|
216
301
|
|