@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.
Files changed (128) hide show
  1. package/CLAUDE.md +19 -6
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +16 -7
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/CLAUDE.md +3 -3
  8. package/framework/MORPH.md +25 -22
  9. package/framework/agents.json +76 -27
  10. package/framework/docs/CLI.md +54 -17
  11. package/framework/evals/rubrics/code-quality.md +5 -0
  12. package/framework/evals/rubrics/condensed.md +6 -2
  13. package/framework/evals/rubrics/contracts.md +20 -0
  14. package/framework/evals/rubrics/test-coverage.md +17 -6
  15. package/framework/hooks/README.md +9 -3
  16. package/framework/hooks/claude-code/core/graph-refresh-kick.js +52 -0
  17. package/framework/hooks/claude-code/core/telemetry-log.js +172 -87
  18. package/framework/hooks/claude-code/core/validate-completion.js +16 -90
  19. package/framework/hooks/claude-code/core/worktree-context.js +10 -0
  20. package/framework/hooks/shared/command-tokens.js +96 -0
  21. package/framework/hooks/shared/git-exclude.js +44 -0
  22. package/framework/hooks/shared/git-paths.js +52 -0
  23. package/framework/hooks/shared/graph-freshness.js +380 -0
  24. package/framework/hooks/shared/graph-refresh.js +416 -0
  25. package/framework/hooks/shared/morph-cli.js +17 -5
  26. package/framework/hooks/shared/redact-secrets.js +55 -0
  27. package/framework/hooks/shared/telemetry-logger.js +25 -2
  28. package/framework/hooks/shared/transcript-usage.js +80 -0
  29. package/framework/rubrics/backend.json +2 -2
  30. package/framework/rubrics/default.json +2 -2
  31. package/framework/rubrics/frontend.json +2 -2
  32. package/framework/rules/csharp-standards.md +1 -0
  33. package/framework/rules/morph-workflow.md +1 -0
  34. package/framework/rules/nextjs-standards.md +1 -0
  35. package/framework/schemas/config.schema.json +41 -1
  36. package/framework/schemas/feature.schema.json +5 -1
  37. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  38. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  39. package/framework/skills/core/morph-apply/SKILL.md +36 -11
  40. package/framework/skills/core/morph-brainstorming/SKILL.md +16 -7
  41. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  42. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  43. package/framework/skills/core/morph-implement/SKILL.md +39 -10
  44. package/framework/skills/core/morph-media/SKILL.md +1 -1
  45. package/framework/skills/core/morph-plan/SKILL.md +72 -15
  46. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  47. package/framework/skills/core/morph-review/SKILL.md +51 -13
  48. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  49. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  50. package/framework/skills/core/morph-uiux/SKILL.md +34 -9
  51. package/framework/standards/STANDARDS.json +187 -12
  52. package/framework/standards/ai-agents/testing-ai.md +2 -1
  53. package/framework/standards/architecture/code-comments.md +64 -0
  54. package/framework/standards/backend/dotnet/testing.md +22 -0
  55. package/framework/standards/frontend/admin-app/data-table.md +562 -0
  56. package/framework/standards/frontend/admin-app/feedback.md +236 -0
  57. package/framework/standards/frontend/admin-app/forms.md +232 -0
  58. package/framework/standards/frontend/admin-app/page-structure.md +203 -0
  59. package/framework/standards/frontend/nextjs/components.md +37 -58
  60. package/framework/standards/frontend/nextjs/data-fetching.md +1 -0
  61. package/framework/standards/frontend/nextjs/forms.md +31 -30
  62. package/framework/standards/frontend/nextjs/state-management.md +2 -1
  63. package/framework/standards/frontend/nextjs/testing.md +18 -0
  64. package/framework/templates/context/design-system.md +3 -3
  65. package/framework/templates/docs/proposal.md +11 -0
  66. package/framework/templates/docs/spec.md +15 -0
  67. package/framework/templates/docs/ui-mockups.md +115 -98
  68. package/framework/templates/feature/decisions.md +124 -122
  69. package/package.json +1 -1
  70. package/src/commands/graph/affected.js +4 -3
  71. package/src/commands/graph/build.js +109 -18
  72. package/src/commands/graph/explain.js +8 -5
  73. package/src/commands/graph/status.js +14 -8
  74. package/src/commands/observability/cost.js +148 -18
  75. package/src/commands/project/doctor.js +82 -10
  76. package/src/commands/project/update.js +25 -0
  77. package/src/commands/project/worktree.js +73 -90
  78. package/src/commands/state/advance.js +18 -0
  79. package/src/commands/state/approve.js +15 -0
  80. package/src/commands/state/archive.js +62 -9
  81. package/src/commands/state/finish.js +62 -14
  82. package/src/commands/state/gate-check.js +8 -1
  83. package/src/commands/state/gate-decision.js +8 -4
  84. package/src/commands/state/report-check.js +90 -9
  85. package/src/commands/state/usecase.js +66 -0
  86. package/src/commands/tasks/dag.js +17 -2
  87. package/src/commands/validation/validate-feature.js +6 -1
  88. package/src/commands/validation/verify.js +20 -1
  89. package/src/dashboard/scanner/agents-scanner.js +5 -1
  90. package/src/dashboard/scanner/hooks-scanner.js +8 -1
  91. package/src/dashboard/scanner/standards-scanner.js +2 -1
  92. package/src/lib/ai/comment-stripper.js +101 -4
  93. package/src/lib/evaluator-report.js +25 -0
  94. package/src/lib/generators/handoff-generator.js +4 -18
  95. package/src/lib/git/default-branch.js +52 -0
  96. package/src/lib/git/tree-fingerprint.js +58 -0
  97. package/src/lib/git/tree-state.js +167 -0
  98. package/src/lib/graph/god-nodes.js +12 -3
  99. package/src/lib/graph/graph-health.js +48 -2
  100. package/src/lib/graph/graph-loader.js +99 -25
  101. package/src/lib/graph/graphifyignore.js +145 -28
  102. package/src/lib/graph/sanitize.js +117 -11
  103. package/src/lib/risk-detector.js +90 -0
  104. package/src/lib/specs/acervo-format.js +70 -0
  105. package/src/lib/specs/acervo-lifecycle.js +264 -0
  106. package/src/lib/specs/adr-index.js +169 -0
  107. package/src/lib/specs/revocations.js +156 -0
  108. package/src/lib/specs/screen-checklist.js +357 -0
  109. package/src/lib/specs/usecase-check.js +20 -16
  110. package/src/lib/specs/usecase-promoter.js +103 -38
  111. package/src/lib/specs/usecase-registry.js +7 -7
  112. package/src/lib/tasks/tasks-json-parser.js +2 -3
  113. package/src/lib/telemetry/metrics.js +386 -0
  114. package/src/lib/telemetry/telemetry-reader.js +161 -51
  115. package/src/lib/telemetry/verify-event.js +79 -0
  116. package/src/lib/validators/code-budget/code-budget-validator.js +172 -0
  117. package/src/lib/validators/code-budget/comment-scanner.js +75 -0
  118. package/src/lib/validators/code-budget/index.js +9 -0
  119. package/src/lib/validators/reuse/reuse-validator.js +4 -2
  120. package/src/lib/validators/reuse/symbol-scanner.js +27 -22
  121. package/src/lib/validators/validation-runner.js +21 -5
  122. package/src/lib/validators/verify-runner.js +11 -0
  123. package/src/lib/worktree/finish-backup.js +739 -0
  124. package/src/lib/worktree/ignored-triage.js +15 -8
  125. package/src/lib/worktree/worktree-audit.js +5 -0
  126. package/src/scripts/setup-infra.js +29 -18
  127. package/src/utils/agents-installer.js +281 -12
  128. 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)? **Cada um tem substituto em runtime** — um teste
63
- que exercita o sistema ou, em critério sobre saída de LLM, a evidência em
64
- `3-implement/evidencias/{taskId}.md` apontando um eval? Teste de texto prova a VERSÃO do
65
- arquivo, não o comportamento: quando é o único teste de uma pós-condição, **a pós-condição
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) + UserPromptSubmit
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` reconstructs the
13
- * whole path — which skills ran, which personas were dispatched (with their
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 (the enrichment lesson from the observability standard:
19
- * propagate a correlation key, a TraceId does not survive the boundary).
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, not to `process.cwd()`/`CLAUDE_PROJECT_DIR` — same
23
- * pattern as trace-autogen/state-sync (`resolveMorphRoot`). A session rooted
24
- * in the primary repo can legitimately touch a feature living in a secondary
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
- // events.jsonl grows forever across a project's lifetime (R2 rotation caps
45
- // the FILE, not any single line) — a multi-KB sub-agent prompt or a long
46
- // Bash command written verbatim on every dispatch/call is the dominant per-
47
- // line cost. Capped AT THE WRITE SITE (not in the shared logger, so every
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
- * Cap a free-text field (prompt/command) at MAX_FIELD_CHARS, always
59
- * reporting the field's true original length alongside it.
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
- if (str.length <= MAX_FIELD_CHARS) return { value: str, chars: str.length };
67
- return { value: str.slice(0, MAX_FIELD_CHARS) + TRUNCATE_SUFFIX, chars: str.length };
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
- * the observability side (src/lib/telemetry/telemetry-reader.js), so these
73
- * values are pinned, not free-form. Order matters: check the specific
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
- * @param {object} payload - parsed PostToolUse (any tool) or UserPromptSubmit payload
106
- * @returns {Promise<null>} always null — this hook is pure side-effect
107
- * (appends to events.jsonl + claim heartbeat), never prints/injects context.
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 ? resolveMorphRoot(targetFilePath, projectDir) : projectDir;
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
- // Correlation key: active feature + its phase (explicit, not ambient).
124
- // NOTE (perf): getActiveFeature → loadState hydrates every .morph/features/*/
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
- if (active) {
136
- feature = active.name;
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 (B1): this hook already fires on EVERY tool call, so it is
144
- // the natural proof-of-life for the advisory feature claim — no extra hook,
145
- // no discipline asked of the operator. Two throttles guard the cost:
146
- // 1. shouldHeartbeat() — a LOCAL, git-independent pre-check (see its own
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
- // UserPromptSubmit carries the user's prompt and no tool_name.
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
- // bytes is best-effort: statSync races a file that got deleted/moved
193
- // between the Read and this hook firing, or lacks permission — omit
194
- // the field rather than fail the whole (silent, fail-open) capture.
195
- try {
196
- event.bytes = statSync(resolveTargetPath(event.target, cwd)).size;
197
- } catch {
198
- // omit bytes
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 bashCommand = capField(input.command || '');
202
- event.command = bashCommand.value;
203
- event.commandChars = bashCommand.chars;
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" — record a
208
- // compact target so Grep/Glob/WebFetch bursts remain distinguishable.
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