@polymorphism-tech/morph-spec 8.37.1 → 8.40.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 (317) hide show
  1. package/CLAUDE.md +37 -13
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +75 -49
  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 +6 -5
  8. package/framework/MORPH.md +25 -22
  9. package/framework/agents.json +96 -36
  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/mcp/registry.json +33 -2
  30. package/framework/rubrics/backend.json +2 -2
  31. package/framework/rubrics/default.json +2 -2
  32. package/framework/rubrics/frontend.json +2 -2
  33. package/framework/rules/csharp-standards.md +1 -0
  34. package/framework/rules/infrastructure-standards.md +0 -1
  35. package/framework/rules/morph-workflow.md +10 -4
  36. package/framework/rules/nextjs-standards.md +1 -0
  37. package/framework/schemas/config.schema.json +41 -1
  38. package/framework/schemas/feature.schema.json +5 -1
  39. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  40. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  41. package/framework/skills/core/morph-apply/SKILL.md +40 -11
  42. package/framework/skills/core/morph-brainstorming/SKILL.md +16 -7
  43. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  44. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  45. package/framework/skills/core/morph-implement/SKILL.md +44 -10
  46. package/framework/skills/core/morph-media/SKILL.md +1 -1
  47. package/framework/skills/core/morph-plan/SKILL.md +73 -15
  48. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  49. package/framework/skills/core/morph-review/SKILL.md +51 -13
  50. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  51. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  52. package/framework/skills/core/morph-uiux/SKILL.md +39 -13
  53. package/framework/standards/STANDARDS.json +189 -14
  54. package/framework/standards/ai-agents/context-providers.md +16 -26
  55. package/framework/standards/ai-agents/durable-workflows-hitl.md +15 -21
  56. package/framework/standards/ai-agents/modalities-image-gen.md +21 -25
  57. package/framework/standards/ai-agents/modalities-speech-to-text.md +4 -15
  58. package/framework/standards/ai-agents/modalities-text-to-speech.md +2 -11
  59. package/framework/standards/ai-agents/modalities-vision.md +1 -18
  60. package/framework/standards/ai-agents/prompt-sources.md +0 -4
  61. package/framework/standards/ai-agents/testing-ai.md +2 -1
  62. package/framework/standards/architecture/code-comments.md +100 -0
  63. package/framework/standards/backend/dotnet/testing.md +22 -0
  64. package/framework/standards/backend/integrations/neon-auth/neon-auth.md +63 -50
  65. package/framework/standards/frontend/admin-app/data-table.md +562 -0
  66. package/framework/standards/frontend/admin-app/feedback.md +236 -0
  67. package/framework/standards/frontend/admin-app/forms.md +232 -0
  68. package/framework/standards/frontend/admin-app/page-structure.md +203 -0
  69. package/framework/standards/frontend/design-system/ai-image-generation.md +5 -5
  70. package/framework/standards/frontend/nextjs/components.md +37 -58
  71. package/framework/standards/frontend/nextjs/data-fetching.md +1 -0
  72. package/framework/standards/frontend/nextjs/forms.md +31 -30
  73. package/framework/standards/frontend/nextjs/state-management.md +2 -1
  74. package/framework/standards/frontend/nextjs/testing.md +18 -0
  75. package/framework/standards/integration/mcp/mcp-tools.md +48 -117
  76. package/framework/templates/code/dotnet/abstractions/result.cs +0 -2
  77. package/framework/templates/code/dotnet/ai-agents/AgentBootstrap.cs.template +26 -39
  78. package/framework/templates/code/dotnet/ai-agents/EmbeddingService.cs.template +28 -68
  79. package/framework/templates/code/dotnet/ai-agents/ModelRegistry.cs.template +99 -117
  80. package/framework/templates/code/dotnet/ai-agents/PromptRepository.cs.template +2 -44
  81. package/framework/templates/code/dotnet/ai-agents/ResilientToolMiddleware.cs.template +40 -61
  82. package/framework/templates/code/dotnet/ai-agents/VectorSearchTool.cs.template +54 -107
  83. package/framework/templates/code/dotnet/ai-agents/model-registry.json.template +53 -58
  84. package/framework/templates/code/dotnet/contracts/contracts-vsa.cs +192 -281
  85. package/framework/templates/code/dotnet/database/migration.cs +49 -83
  86. package/framework/templates/code/dotnet/database/vector-entity.cs +2 -46
  87. package/framework/templates/code/dotnet/extensions/result-extensions.cs +0 -2
  88. package/framework/templates/code/dotnet/feature/validator.cs.hbs +11 -15
  89. package/framework/templates/code/dotnet/jobs/agent.cs +3 -51
  90. package/framework/templates/code/dotnet/jobs/job.cs +2 -97
  91. package/framework/templates/code/dotnet/test.cs +2 -87
  92. package/framework/templates/code/typescript/contracts.ts +140 -167
  93. package/framework/templates/context/design-system.md +3 -3
  94. package/framework/templates/docs/proposal.md +11 -0
  95. package/framework/templates/docs/spec.md +15 -0
  96. package/framework/templates/docs/ui-mockups.md +115 -98
  97. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentApi.cs +1 -48
  98. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentFactory.cs +3 -79
  99. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentFactoryOptions.cs +1 -70
  100. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentSpec.cs +1 -47
  101. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentSpecOptions.cs +2 -99
  102. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/AiKitAssemblyMarker.cs +0 -6
  103. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Conversation/ConversationStore.cs +3 -88
  104. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Conversation/IConversationStore.cs +1 -41
  105. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Conversation/InMemoryConversationStore.cs +0 -21
  106. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/CountingChatClient.cs +1 -25
  107. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalAssertion.cs +2 -63
  108. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalCacheMissException.cs +0 -30
  109. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalCacheOptions.cs +1 -52
  110. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalCacheStamp.cs +3 -78
  111. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalFixture.cs +4 -109
  112. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalMode.cs +0 -22
  113. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalQualityEvaluators.cs +1 -36
  114. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalScenario.cs +2 -57
  115. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalSummary.cs +0 -75
  116. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/OfflineChatClient.cs +2 -59
  117. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/ShortPathResponseCache.cs +4 -111
  118. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/IMediaGenerator.cs +0 -34
  119. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/IMediaProvider.cs +1 -57
  120. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaArtifact.cs +0 -19
  121. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaDisclosure.cs +2 -74
  122. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaGenerator.cs +2 -33
  123. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaIdempotencyKey.cs +1 -43
  124. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaJob.cs +1 -96
  125. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaJobId.cs +0 -13
  126. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaJobState.cs +0 -27
  127. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaPricing.cs +0 -33
  128. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaProvenance.cs +0 -31
  129. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaRequest.cs +3 -75
  130. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaResult.cs +0 -42
  131. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaReview.cs +0 -39
  132. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaSeams.cs +1 -55
  133. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaStore.cs +3 -59
  134. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MorphMedia.cs +0 -46
  135. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/Providers/GoogleImageProvider.cs +3 -28
  136. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/Providers/OpenAIImageProvider.cs +7 -61
  137. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/Providers/VeoVideoProvider.cs +2 -37
  138. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Middleware/IUsageCostSink.cs +1 -23
  139. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Middleware/UsageCostMiddleware.cs +2 -82
  140. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Middleware/UsageCostRecord.cs +1 -68
  141. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Observability/MorphOpenTelemetryExtensions.cs +1 -62
  142. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Observability/MorphTelemetryOptions.cs +2 -62
  143. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/IProviderClientFactory.cs +1 -25
  144. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/ModelAlias.cs +0 -124
  145. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/ModelRegistry.cs +1 -73
  146. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Rag/DeltaIngest.cs +1 -66
  147. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Rag/DeltaPlan.cs +0 -41
  148. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/AgentFactoryTests.cs +2 -112
  149. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/AgentSpecTests.cs +2 -43
  150. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/ConversationStoreTests.cs +5 -110
  151. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/DeltaIngestTests.cs +7 -103
  152. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalAssertionTests.cs +1 -42
  153. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalCacheMissTests.cs +1 -30
  154. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalCacheStampTests.cs +1 -19
  155. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalFailuresTests.cs +0 -48
  156. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalFixtureTests.cs +1 -29
  157. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalRefreshTests.cs +2 -38
  158. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalWorkspace.cs +1 -14
  159. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/KitEvals.cs +4 -69
  160. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/OfflineChatClientTests.cs +1 -10
  161. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeChatClient.cs +1 -57
  162. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeChatClientTests.cs +0 -26
  163. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeClock.cs +1 -19
  164. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeMediaProvider.cs +0 -32
  165. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeProviderClientFactory.cs +0 -14
  166. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeUsageCostSink.cs +0 -24
  167. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/InMemoryMediaStore.cs +0 -32
  168. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaAccessibilityTests.cs +0 -37
  169. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaAliasIsNotAnAgentTests.cs +0 -24
  170. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaDisclosureTests.cs +0 -78
  171. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaGeneratorTests.cs +0 -53
  172. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaIdempotencyKeyTests.cs +0 -44
  173. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaJobStateTests.cs +0 -44
  174. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaReviewTests.cs +0 -19
  175. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaStoreTests.cs +0 -24
  176. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/ModelRegistryTests.cs +6 -48
  177. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/SkeletonSmokeTests.cs +1 -13
  178. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/TelemetryTests.cs +2 -71
  179. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/UsageCostMiddlewareTests.cs +5 -100
  180. package/framework/templates/feature/decisions.md +124 -122
  181. package/framework/templates/frontend/nextjs/Dockerfile.nextjs.hbs +39 -43
  182. package/framework/templates/frontend/nextjs/env.mjs.hbs +29 -32
  183. package/framework/templates/frontend/nextjs/page.tsx.hbs +21 -22
  184. package/framework/templates/frontend/nextjs/use-feature.ts.hbs +42 -54
  185. package/framework/templates/integrations/asaas-client.cs +332 -387
  186. package/framework/templates/integrations/asaas-webhook.cs +290 -351
  187. package/framework/templates/integrations/neon-auth-config.cs +9 -105
  188. package/framework/templates/project/validate.js +97 -120
  189. package/package.json +2 -2
  190. package/src/commands/dashboard/dashboard.js +80 -53
  191. package/src/commands/graph/affected.js +4 -3
  192. package/src/commands/graph/build.js +109 -18
  193. package/src/commands/graph/explain.js +8 -5
  194. package/src/commands/graph/status.js +14 -8
  195. package/src/commands/mcp/mcp-setup.js +37 -9
  196. package/src/commands/observability/cost.js +148 -18
  197. package/src/commands/project/doctor.js +85 -15
  198. package/src/commands/project/init.js +6 -3
  199. package/src/commands/project/install-plugin-cmd.js +1 -1
  200. package/src/commands/project/update.js +30 -0
  201. package/src/commands/project/worktree.js +73 -90
  202. package/src/commands/state/advance.js +18 -0
  203. package/src/commands/state/approve.js +15 -0
  204. package/src/commands/state/archive.js +62 -9
  205. package/src/commands/state/finish.js +62 -14
  206. package/src/commands/state/gate-check.js +8 -1
  207. package/src/commands/state/gate-decision.js +8 -4
  208. package/src/commands/state/report-check.js +90 -9
  209. package/src/commands/state/usecase.js +66 -0
  210. package/src/commands/tasks/dag.js +17 -2
  211. package/src/commands/validation/validate-feature.js +6 -1
  212. package/src/commands/validation/verify.js +20 -1
  213. package/src/dashboard/app/App.jsx +80 -16
  214. package/src/dashboard/app/components/ActivityFeed.jsx +172 -0
  215. package/src/dashboard/app/components/CommandPalette.jsx +155 -0
  216. package/src/dashboard/app/components/DetailPanel.jsx +47 -11
  217. package/src/dashboard/app/components/EntityLink.jsx +55 -0
  218. package/src/dashboard/app/components/ErrorBoundary.jsx +54 -0
  219. package/src/dashboard/app/components/FlowGraph.jsx +19 -17
  220. package/src/dashboard/app/components/KnowledgeMarkdown.jsx +53 -0
  221. package/src/dashboard/app/components/Markdown.jsx +47 -0
  222. package/src/dashboard/app/components/PhaseRail.jsx +111 -0
  223. package/src/dashboard/app/components/SearchFilter.jsx +40 -22
  224. package/src/dashboard/app/components/Sidebar.jsx +146 -37
  225. package/src/dashboard/app/hooks/useMorphQuery.js +54 -0
  226. package/src/dashboard/app/hooks/usePref.js +38 -0
  227. package/src/dashboard/app/lib/format.js +69 -0
  228. package/src/dashboard/app/lib/phases.js +56 -0
  229. package/src/dashboard/app/lib/project.js +50 -0
  230. package/src/dashboard/app/pages/Activity.jsx +24 -0
  231. package/src/dashboard/app/pages/AgentMap.jsx +371 -370
  232. package/src/dashboard/app/pages/FeatureDetail.jsx +604 -0
  233. package/src/dashboard/app/pages/Features.jsx +400 -0
  234. package/src/dashboard/app/pages/Guide.jsx +146 -0
  235. package/src/dashboard/app/pages/HookInspector.jsx +357 -322
  236. package/src/dashboard/app/pages/PhasePipeline.jsx +277 -408
  237. package/src/dashboard/app/pages/Portfolio.jsx +156 -0
  238. package/src/dashboard/app/pages/Rubrics.jsx +101 -0
  239. package/src/dashboard/app/pages/RulesMap.jsx +300 -273
  240. package/src/dashboard/app/pages/SkillsLibrary.jsx +483 -366
  241. package/src/dashboard/app/pages/Standards.jsx +407 -332
  242. package/src/dashboard/app/styles/index.css +187 -47
  243. package/src/dashboard/index.html +14 -12
  244. package/src/dashboard/package.json +32 -24
  245. package/src/dashboard/scanner/activity-scanner.js +351 -0
  246. package/src/dashboard/scanner/agents-scanner.js +63 -1
  247. package/src/dashboard/scanner/docs-scanner.js +59 -0
  248. package/src/dashboard/scanner/feature-status.js +10 -0
  249. package/src/dashboard/scanner/features-scanner.js +360 -0
  250. package/src/dashboard/scanner/hooks-scanner.js +8 -1
  251. package/src/dashboard/scanner/index.js +14 -7
  252. package/src/dashboard/scanner/phases-scanner.js +14 -15
  253. package/src/dashboard/scanner/portfolio.js +153 -0
  254. package/src/dashboard/scanner/project-api.js +195 -0
  255. package/src/dashboard/scanner/rules-scanner.js +42 -37
  256. package/src/dashboard/scanner/skills-scanner.js +105 -43
  257. package/src/dashboard/scanner/standards-scanner.js +4 -1
  258. package/src/dashboard/vite-plugin-morph-data.js +134 -17
  259. package/src/lib/ai/comment-stripper.js +101 -4
  260. package/src/lib/detectors/claude-config-detector.js +2 -7
  261. package/src/lib/detectors/index.js +5 -1
  262. package/src/lib/evaluator-report.js +47 -0
  263. package/src/lib/generators/handoff-generator.js +4 -18
  264. package/src/lib/generators/settings-generator.js +208 -209
  265. package/src/lib/git/default-branch.js +52 -0
  266. package/src/lib/git/tree-fingerprint.js +58 -0
  267. package/src/lib/git/tree-state.js +167 -0
  268. package/src/lib/graph/god-nodes.js +12 -3
  269. package/src/lib/graph/graph-health.js +48 -2
  270. package/src/lib/graph/graph-loader.js +99 -25
  271. package/src/lib/graph/graphifyignore.js +145 -28
  272. package/src/lib/graph/sanitize.js +117 -11
  273. package/src/lib/installers/mcp-installer.js +724 -719
  274. package/src/lib/installers/memory-installer.js +1 -2
  275. package/src/lib/projects/registry.js +154 -0
  276. package/src/lib/risk-detector.js +90 -0
  277. package/src/lib/specs/acervo-format.js +70 -0
  278. package/src/lib/specs/acervo-lifecycle.js +264 -0
  279. package/src/lib/specs/adr-index.js +169 -0
  280. package/src/lib/specs/revocations.js +156 -0
  281. package/src/lib/specs/screen-checklist.js +357 -0
  282. package/src/lib/specs/usecase-check.js +20 -16
  283. package/src/lib/specs/usecase-promoter.js +103 -38
  284. package/src/lib/specs/usecase-registry.js +7 -7
  285. package/src/lib/tasks/tasks-json-parser.js +2 -3
  286. package/src/lib/telemetry/metrics.js +386 -0
  287. package/src/lib/telemetry/telemetry-reader.js +161 -51
  288. package/src/lib/telemetry/verify-event.js +79 -0
  289. package/src/lib/validators/code-budget/code-budget-validator.js +200 -0
  290. package/src/lib/validators/code-budget/comment-scanner.js +75 -0
  291. package/src/lib/validators/code-budget/index.js +9 -0
  292. package/src/lib/validators/reuse/reuse-validator.js +4 -2
  293. package/src/lib/validators/reuse/symbol-scanner.js +27 -22
  294. package/src/lib/validators/validation-runner.js +21 -5
  295. package/src/lib/validators/verify-runner.js +11 -0
  296. package/src/lib/worktree/finish-backup.js +739 -0
  297. package/src/lib/worktree/ignored-triage.js +15 -8
  298. package/src/lib/worktree/worktree-audit.js +5 -0
  299. package/src/scripts/install-plugin.js +1 -1
  300. package/src/scripts/setup-infra.js +31 -22
  301. package/src/utils/agents-installer.js +338 -24
  302. package/src/utils/banner.js +1 -1
  303. package/src/utils/hooks-installer.js +75 -15
  304. package/src/commands/index.js +0 -13
  305. package/src/commands/project/checkpoint.js +0 -209
  306. package/src/core/state/phase-state-machine.js +0 -197
  307. package/src/dashboard/app/hooks/useDetailPanel.js +0 -15
  308. package/src/dashboard/app/pages/LiveState.jsx +0 -206
  309. package/src/dashboard/scanner/squads-scanner.js +0 -13
  310. package/src/dashboard/scanner/state-scanner.js +0 -85
  311. package/src/lib/generators/index.js +0 -10
  312. package/src/lib/hooks/hook-executor.js +0 -167
  313. package/src/lib/validators/checkpoint-persistence.js +0 -39
  314. package/src/lib/validators/shared/emit-validator-dispatch.js +0 -14
  315. package/src/lib/validators/shared/index.js +0 -12
  316. package/src/lib/validators/shared/issue-counter.js +0 -14
  317. package/src/lib/validators/shared/result-formatter.js +0 -124
@@ -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
 
@@ -21,7 +21,7 @@
21
21
  * Fail-open: exits 0 on any error.
22
22
  */
23
23
 
24
- import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync, statSync } from 'fs';
24
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'fs';
25
25
  import { join } from 'path';
26
26
  import { createHash } from 'crypto';
27
27
  import { runBuildCheck } from '../../shared/build-check.js';
@@ -31,6 +31,7 @@ import {
31
31
  import { contextResult, runStandalone } from '../../shared/hook-response.js';
32
32
  import { logHookActivity, logEvent } from '../../shared/activity-logger.js';
33
33
  import { appendTelemetryEvent } from '../../shared/telemetry-logger.js';
34
+ import { readTranscriptUsage, usageDelta } from '../../shared/transcript-usage.js';
34
35
 
35
36
  /**
36
37
  * Audit recap.md against tasks.json + standard-citation rule.
@@ -122,80 +123,6 @@ function auditRecapAndTasks(featurePath) {
122
123
  return warnings;
123
124
  }
124
125
 
125
- // Safety valve for a pathological outlier transcript — NOT needed for the
126
- // normal case: a real 45 MB / ~16k-line transcript (inspected directly while
127
- // building this) parses+sums in ~350ms locally, well inside a Stop hook's
128
- // budget. Above this, skip rather than risk an unbounded read.
129
- const MAX_TRANSCRIPT_BYTES = 50 * 1024 * 1024; // 50 MB
130
-
131
- /**
132
- * Sum real token usage from a Claude Code session transcript (JSONL, the
133
- * `transcript_path` the Stop payload carries).
134
- *
135
- * Each assistant TURN is written to the transcript as MULTIPLE JSONL lines —
136
- * one per content block (thinking/text/tool_use) — every one of them
137
- * repeating the exact SAME turn-level `message.usage` object under the same
138
- * `message.id`. Verified against a real transcript: 5982 assistant lines but
139
- * only 3315 unique message ids, with byte-identical usage across duplicates
140
- * of the same id — summing every line naively double/triple-counts (measured
141
- * ~2x on output_tokens on that file). This is why a per-line sum is wrong and
142
- * a dedup-by-id sum is required; there is no running/cumulative total field
143
- * to lean on instead — each turn's `usage` is that turn's own cost.
144
- *
145
- * Tolerant to shape: any missing usage field reads as 0. A corrupt/truncated
146
- * line is skipped, not fatal — same discipline as telemetry-logger's reader.
147
- *
148
- * @param {string} [transcriptPath]
149
- * @returns {{inputTokens:number,outputTokens:number,cacheReadTokens:number,cacheCreationTokens:number}|null}
150
- * null when there is nothing to report (no path, missing/oversized/
151
- * unreadable file, or zero assistant messages carrying usage).
152
- */
153
- function computeSessionUsage(transcriptPath) {
154
- if (!transcriptPath || typeof transcriptPath !== 'string') return null;
155
- try {
156
- if (!existsSync(transcriptPath)) return null;
157
- if (statSync(transcriptPath).size > MAX_TRANSCRIPT_BYTES) return null;
158
-
159
- const raw = readFileSync(transcriptPath, 'utf-8');
160
- let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheCreationTokens = 0;
161
- const seenMessageIds = new Set();
162
- let sawAny = false;
163
-
164
- for (const line of raw.split('\n')) {
165
- const trimmed = line.trim();
166
- if (!trimmed) continue;
167
- let entry;
168
- try {
169
- entry = JSON.parse(trimmed);
170
- } catch {
171
- continue; // one corrupt/truncated line must not lose the whole transcript
172
- }
173
- if (entry?.type !== 'assistant') continue;
174
- const usage = entry?.message?.usage;
175
- if (!usage || typeof usage !== 'object') continue;
176
-
177
- // Dedup by message.id (fallback to the line's own uuid when a message
178
- // carries none) — first occurrence wins, duplicates carry identical
179
- // values so which one wins never matters.
180
- const dedupKey = entry?.message?.id || entry?.uuid;
181
- if (dedupKey) {
182
- if (seenMessageIds.has(dedupKey)) continue;
183
- seenMessageIds.add(dedupKey);
184
- }
185
-
186
- sawAny = true;
187
- inputTokens += Number(usage.input_tokens) || 0;
188
- outputTokens += Number(usage.output_tokens) || 0;
189
- cacheReadTokens += Number(usage.cache_read_input_tokens) || 0;
190
- cacheCreationTokens += Number(usage.cache_creation_input_tokens) || 0;
191
- }
192
-
193
- return sawAny ? { inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens } : null;
194
- } catch {
195
- return null; // fail-open
196
- }
197
- }
198
-
199
126
  // Phases where compaction is strongly recommended before starting
200
127
  const COMPACT_TRIGGER_PHASES = new Set(['implement']);
201
128
 
@@ -343,13 +270,11 @@ export async function run(payload) {
343
270
  // ────────────────────────────────────────────────────────────────────────
344
271
 
345
272
  // ── Session usage (real tokens, from the transcript) ──────────────────────
346
- // Silent side effect — never contributes to `warnings`/additionalContext,
347
- // same as telemetry-log.js's capture. Throttled: only re-emitted when the
348
- // computed totals actually changed since the last SessionUsage recorded
349
- // for this session_id, so an unchanged transcript across consecutive
350
- // Stops (no new assistant turn) does not re-append an identical event.
273
+ // Silent side effect. The transcript total is cumulative, so each event
274
+ // also carries the `delta` since the previous sample of the same session:
275
+ // summing deltas per sessionId is the only sum that does not double-count.
351
276
  try {
352
- const usage = computeSessionUsage(payload?.transcript_path);
277
+ const usage = readTranscriptUsage(payload?.transcript_path);
353
278
  if (usage) {
354
279
  const usagePath = join(memDir, 'session-usage.json');
355
280
  let stored = {};
@@ -358,15 +283,16 @@ export async function run(payload) {
358
283
  } catch { /* corrupt file — start fresh */ }
359
284
 
360
285
  const sessionKey = payload?.session_id || 'default';
361
- const last = stored[sessionKey];
362
- const unchanged = last
363
- && last.inputTokens === usage.inputTokens
364
- && last.outputTokens === usage.outputTokens
365
- && last.cacheReadTokens === usage.cacheReadTokens
366
- && last.cacheCreationTokens === usage.cacheCreationTokens;
367
-
368
- if (!unchanged) {
369
- appendTelemetryEvent({ event: 'SessionUsage', ...usage }, cwd);
286
+ const { delta, changed } = usageDelta(stored[sessionKey], usage);
287
+ if (changed) {
288
+ appendTelemetryEvent({
289
+ event: 'SessionUsage',
290
+ sessionId: payload?.session_id ?? null,
291
+ feature: name,
292
+ phase: currentPhase,
293
+ ...usage,
294
+ delta,
295
+ }, cwd);
370
296
  stored[sessionKey] = usage;
371
297
  try { writeFileSync(usagePath, JSON.stringify(stored, null, 2), 'utf-8'); } catch { /* non-blocking */ }
372
298
  }
@@ -50,6 +50,7 @@ import { contextResult, runStandalone } from '../../shared/hook-response.js';
50
50
  import { acquireClaim, foreignClaim, claimAge } from '../../shared/claim-registry.js';
51
51
  import { rotateActivityLogIfLarge } from '../../shared/activity-logger.js';
52
52
  import { rotateEventsLogIfLarge } from '../../shared/telemetry-logger.js';
53
+ import { kickGraphRefresh, seedFromPrimary } from '../../shared/graph-refresh.js';
53
54
 
54
55
  function projectDir() {
55
56
  return process.env.CLAUDE_PROJECT_DIR || process.cwd();
@@ -293,6 +294,15 @@ export async function run(payload) {
293
294
  // helpers are themselves fail-open (return false on any error).
294
295
  try { rotateActivityLogIfLarge(dir); } catch { /* non-blocking */ }
295
296
  try { rotateEventsLogIfLarge(dir); } catch { /* non-blocking */ }
297
+ // Between sessions is where commits made outside the agent (a terminal, an
298
+ // IDE, a `git pull`) are caught: the detached refresh brings the ROOT's
299
+ // graph to HEAD — never a worktree's (a detached process holding a
300
+ // worktree directory made `finish` fail on win32). A worktree that has no
301
+ // graph yet (created before seeding existed) gets the root's copy here;
302
+ // from then on it is refreshed by hand (`morph-spec graph refresh`).
303
+ // Returns immediately — nothing here waits on graphify.
304
+ try { seedFromPrimary(dir); } catch { /* non-blocking — a no-op on the root */ }
305
+ try { kickGraphRefresh(dir); } catch { /* non-blocking */ }
296
306
 
297
307
  if (isSecondaryWorktree(dir)) {
298
308
  const task = taskFromBranch(dir);
@@ -21,3 +21,99 @@ export function tokenize(command) {
21
21
  while ((m = re.exec(String(command || ''))) !== null) tokens.push(m[1] ?? m[2] ?? m[3]);
22
22
  return tokens;
23
23
  }
24
+
25
+ /** Index of the first pipe (`|`, not `||` nor `>|`) outside quotes, or -1. */
26
+ function firstUnquotedPipe(command) {
27
+ const s = String(command || '');
28
+ let quote = null;
29
+ for (let i = 0; i < s.length; i++) {
30
+ const ch = s[i];
31
+ if (quote) {
32
+ if (ch === quote) quote = null;
33
+ else if (ch === '\\' && quote === '"') i++;
34
+ continue;
35
+ }
36
+ if (ch === '\\') { i++; continue; }
37
+ if (ch === '"' || ch === "'") { quote = ch; continue; }
38
+ if (ch !== '|') continue;
39
+ if (s[i + 1] === '|') { i++; continue; }
40
+ if (s[i - 1] === '>') continue;
41
+ return i;
42
+ }
43
+ return -1;
44
+ }
45
+
46
+ /**
47
+ * Does the command pipe one process into another outside quotes? Without
48
+ * `pipefail` the shell reports the LAST command's status, so the exit code of
49
+ * `dotnet test … | grep …` is grep's, not the test run's.
50
+ * @param {string} command
51
+ * @returns {boolean}
52
+ */
53
+ export function hasUnquotedPipe(command) {
54
+ return firstUnquotedPipe(command) >= 0;
55
+ }
56
+
57
+ /**
58
+ * The work a command does, without the filter that shapes its output:
59
+ * `dotnet test 2>&1 | tail -25` and `dotnet test | tail -20` run the same
60
+ * tests. Whitespace collapsed, stderr redirections dropped.
61
+ * @param {string} command
62
+ * @returns {string}
63
+ */
64
+ export function producerOf(command) {
65
+ const s = String(command || '');
66
+ const pipe = firstUnquotedPipe(s);
67
+ return (pipe >= 0 ? s.slice(0, pipe) : s)
68
+ .replace(/\s2>(&1|\/dev\/null|nul)\b/gi, ' ')
69
+ .replace(/\s+/g, ' ')
70
+ .trim();
71
+ }
72
+
73
+ /**
74
+ * The command split at `;`, newline and background `&` outside quotes, plus
75
+ * whether a background `&` was seen. `&&`, `||` and `>&`/`&>` redirections
76
+ * stay inside their segment.
77
+ */
78
+ function topLevelSegments(command) {
79
+ const s = String(command || '');
80
+ const segments = [];
81
+ let start = 0;
82
+ let background = false;
83
+ let quote = null;
84
+ for (let i = 0; i < s.length; i++) {
85
+ const ch = s[i];
86
+ if (quote) {
87
+ if (ch === quote) quote = null;
88
+ else if (ch === '\\' && quote === '"') i++;
89
+ continue;
90
+ }
91
+ if (ch === '\\') { i++; continue; }
92
+ if (ch === '"' || ch === "'") { quote = ch; continue; }
93
+ const isAmp = ch === '&' && s[i + 1] !== '&' && s[i - 1] !== '&' && s[i - 1] !== '>' && s[i + 1] !== '>';
94
+ if (ch !== ';' && ch !== '\n' && !isAmp) continue;
95
+ if (isAmp) background = true;
96
+ segments.push(s.slice(start, i));
97
+ start = i + 1;
98
+ }
99
+ segments.push(s.slice(start));
100
+ return { segments: segments.map((x) => x.trim()).filter(Boolean), background };
101
+ }
102
+
103
+ const REPORTER = /^(?:echo|printf|true|:|exit\s+0)\b/;
104
+
105
+ /**
106
+ * Is the exit code of this command the exit code of the work it ran? The
107
+ * shell reports the last segment of a `;` sequence: `dotnet test > log;
108
+ * echo "exit=$?"` always exits 0, and so does `dotnet test || true`. A pipe
109
+ * without `pipefail` reports the filter, and a background `&` reports 0.
110
+ * @param {string} command
111
+ * @returns {boolean}
112
+ */
113
+ export function exitCodeMasked(command) {
114
+ const { segments, background } = topLevelSegments(command);
115
+ if (background) return true;
116
+ if (hasUnquotedPipe(command) && !/\bpipefail\b/.test(String(command || ''))) return true;
117
+ const last = segments[segments.length - 1] || '';
118
+ return (segments.length > 1 && REPORTER.test(last)) || /\|\|/.test(last.replace(/"[^"]*"|'[^']*'/g, ''));
119
+ }