@polymorphism-tech/morph-spec 8.33.0 → 8.35.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 (253) hide show
  1. package/CLAUDE.md +22 -2
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +43 -5
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +5 -4
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/CLAUDE.md +2 -2
  8. package/framework/MORPH.md +20 -22
  9. package/framework/agents.json +198 -44
  10. package/framework/ai-pin.json +22 -0
  11. package/framework/commands/morph-media.md +45 -0
  12. package/framework/docs/CLI.md +238 -10
  13. package/framework/evals/proof-rubrics/README.md +76 -0
  14. package/framework/evals/proof-rubrics/eval-de-modelo.md +64 -0
  15. package/framework/evals/proof-rubrics/fiacao.md +54 -0
  16. package/framework/evals/proof-rubrics/guard-cego.md +52 -0
  17. package/framework/evals/proof-rubrics/media-claims.md +109 -0
  18. package/framework/evals/proof-rubrics/mutation-proof.md +77 -0
  19. package/framework/evals/proof-rubrics/observabilidade-genai.md +119 -0
  20. package/framework/evals/proof-rubrics/verde-nao-e-coberto.md +48 -0
  21. package/framework/evals/rubrics/architecture.md +30 -0
  22. package/framework/evals/rubrics/code-quality.md +26 -0
  23. package/framework/evals/rubrics/test-coverage.md +20 -0
  24. package/framework/hooks/README.md +11 -1
  25. package/framework/hooks/claude-code/core/block-destructive-worktree-removal.js +5 -8
  26. package/framework/hooks/claude-code/core/block-raw-worktree-add.js +79 -0
  27. package/framework/hooks/claude-code/core/chain.js +27 -7
  28. package/framework/hooks/claude-code/core/protect-spec-files.js +41 -4
  29. package/framework/hooks/claude-code/core/worktree-context.js +83 -14
  30. package/framework/hooks/claude-code/core/worktree-create.js +206 -0
  31. package/framework/hooks/claude-code/core/worktree-env-inject.js +87 -0
  32. package/framework/hooks/claude-code/core/worktree-remove.js +81 -0
  33. package/framework/hooks/shared/command-tokens.js +23 -0
  34. package/framework/hooks/shared/e2e-check.js +0 -0
  35. package/framework/hooks/shared/git-paths.js +25 -0
  36. package/framework/hooks/shared/hook-response.js +17 -0
  37. package/framework/hooks/shared/morph-cli.js +134 -0
  38. package/framework/rules/ai-agents-standards.md +14 -2
  39. package/framework/rules/infrastructure-standards.md +3 -1
  40. package/framework/schemas/ai-pin.schema.json +97 -0
  41. package/framework/schemas/config.schema.json +107 -0
  42. package/framework/schemas/feature.schema.json +23 -4
  43. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  44. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  45. package/framework/skills/core/morph-apply/SKILL.md +28 -7
  46. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  47. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  48. package/framework/skills/core/morph-hotfix/SKILL.md +2 -2
  49. package/framework/skills/core/morph-implement/SKILL.md +20 -2
  50. package/framework/skills/core/morph-media/SKILL.md +215 -0
  51. package/framework/skills/core/morph-plan/SKILL.md +38 -4
  52. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  53. package/framework/skills/core/morph-review/SKILL.md +166 -9
  54. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  55. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  56. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  57. package/framework/standards/PROVENANCE.json +81 -3
  58. package/framework/standards/STANDARDS.json +1504 -830
  59. package/framework/standards/ai-agents/agent-archetypes.md +160 -0
  60. package/framework/standards/ai-agents/agent-session.md +256 -160
  61. package/framework/standards/ai-agents/agent-spec.md +196 -0
  62. package/framework/standards/ai-agents/batching.md +217 -193
  63. package/framework/standards/ai-agents/context-providers.md +261 -188
  64. package/framework/standards/ai-agents/conversational-agent-with-phases.md +239 -0
  65. package/framework/standards/ai-agents/cost-and-budget.md +256 -0
  66. package/framework/standards/ai-agents/durable-workflows-hitl.md +98 -5
  67. package/framework/standards/ai-agents/evals-with-cache.md +289 -0
  68. package/framework/standards/ai-agents/guardrails.md +222 -0
  69. package/framework/standards/ai-agents/llm-runtime-defaults.md +226 -0
  70. package/framework/standards/ai-agents/mcp-server.md +351 -250
  71. package/framework/standards/ai-agents/mcp-tools.md +250 -197
  72. package/framework/standards/ai-agents/media-pipeline.md +352 -0
  73. package/framework/standards/ai-agents/media-video.md +267 -0
  74. package/framework/standards/ai-agents/middleware-patterns.md +241 -207
  75. package/framework/standards/ai-agents/modalities-image-gen.md +422 -173
  76. package/framework/standards/ai-agents/modalities-speech-to-text.md +177 -171
  77. package/framework/standards/ai-agents/modalities-text-to-speech.md +189 -181
  78. package/framework/standards/ai-agents/modalities-vision.md +205 -201
  79. package/framework/standards/ai-agents/multi-agent-patterns.md +211 -155
  80. package/framework/standards/ai-agents/observability-patterns.md +227 -17
  81. package/framework/standards/ai-agents/production.md +260 -231
  82. package/framework/standards/ai-agents/prompt-sources.md +389 -0
  83. package/framework/standards/ai-agents/providers/model-registry.md +293 -158
  84. package/framework/standards/ai-agents/rag-custom-pgvector.md +69 -4
  85. package/framework/standards/ai-agents/service-tier-flex.md +143 -135
  86. package/framework/standards/ai-agents/setup.md +166 -50
  87. package/framework/standards/ai-agents/structured-output.md +280 -249
  88. package/framework/standards/ai-agents/sweet-spot.md +129 -109
  89. package/framework/standards/ai-agents/testing-ai.md +236 -0
  90. package/framework/standards/ai-agents/vector-data-extensions.md +50 -20
  91. package/framework/standards/ai-agents/workflows.md +27 -7
  92. package/framework/standards/frontend/design-system/ai-image-generation.md +12 -0
  93. package/framework/standards/frontend/design-system/ai-video-generation.md +13 -0
  94. package/framework/standards/infrastructure/docker/local-compose-isolation.md +90 -0
  95. package/framework/templates/REGISTRY.json +14 -1
  96. package/framework/templates/code/dotnet/ai-agents/PromptRepository.cs.template +95 -95
  97. package/framework/templates/code/sql/ai-agents/agent-prompts-migration.sql.template +58 -58
  98. package/framework/templates/dotnet/ai-kit/Directory.Build.props +25 -0
  99. package/framework/templates/dotnet/ai-kit/Directory.Packages.props +26 -0
  100. package/framework/templates/dotnet/ai-kit/Morph.AiKit.sln +56 -0
  101. package/framework/templates/dotnet/ai-kit/NuGet.config +12 -0
  102. package/framework/templates/dotnet/ai-kit/README.md +128 -0
  103. package/framework/templates/dotnet/ai-kit/global.json +9 -0
  104. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentApi.cs +65 -0
  105. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentFactory.cs +289 -0
  106. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentFactoryOptions.cs +91 -0
  107. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentSpec.cs +91 -0
  108. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentSpecOptions.cs +181 -0
  109. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/AiKitAssemblyMarker.cs +14 -0
  110. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Conversation/ConversationStore.cs +174 -0
  111. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Conversation/IConversationStore.cs +54 -0
  112. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Conversation/InMemoryConversationStore.cs +75 -0
  113. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/CountingChatClient.cs +71 -0
  114. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalAssertion.cs +246 -0
  115. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalCacheMissException.cs +57 -0
  116. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalCacheOptions.cs +78 -0
  117. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalCacheStamp.cs +225 -0
  118. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalFixture.cs +463 -0
  119. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalMode.cs +32 -0
  120. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalQualityEvaluators.cs +49 -0
  121. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalScenario.cs +214 -0
  122. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalSummary.cs +168 -0
  123. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/OfflineChatClient.cs +142 -0
  124. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/ShortPathResponseCache.cs +349 -0
  125. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/IMediaGenerator.cs +42 -0
  126. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/IMediaProvider.cs +77 -0
  127. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaArtifact.cs +28 -0
  128. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaDisclosure.cs +107 -0
  129. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaGenerator.cs +253 -0
  130. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaIdempotencyKey.cs +103 -0
  131. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaJob.cs +259 -0
  132. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaJobId.cs +21 -0
  133. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaJobState.cs +47 -0
  134. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaPricing.cs +102 -0
  135. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaProvenance.cs +44 -0
  136. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaRequest.cs +192 -0
  137. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaResult.cs +94 -0
  138. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaReview.cs +99 -0
  139. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaSeams.cs +97 -0
  140. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaStore.cs +133 -0
  141. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MorphMedia.cs +93 -0
  142. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/Providers/GoogleImageProvider.cs +112 -0
  143. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/Providers/OpenAIImageProvider.cs +288 -0
  144. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/Providers/VeoVideoProvider.cs +158 -0
  145. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Middleware/IUsageCostSink.cs +29 -0
  146. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Middleware/UsageCostMiddleware.cs +208 -0
  147. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Middleware/UsageCostRecord.cs +95 -0
  148. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Morph.AiKit.csproj +38 -0
  149. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Observability/MorphOpenTelemetryExtensions.cs +119 -0
  150. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Observability/MorphTelemetryOptions.cs +82 -0
  151. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/IProviderClientFactory.cs +33 -0
  152. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/ModelAlias.cs +267 -0
  153. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/ModelRegistry.cs +474 -0
  154. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/model-registry.v2.json +87 -0
  155. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Rag/DeltaIngest.cs +169 -0
  156. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Rag/DeltaPlan.cs +49 -0
  157. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/AgentFactoryTests.cs +495 -0
  158. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/AgentSpecTests.cs +260 -0
  159. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/ConversationStoreTests.cs +405 -0
  160. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/DeltaIngestTests.cs +379 -0
  161. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalAssertionTests.cs +197 -0
  162. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalCacheMissTests.cs +139 -0
  163. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalCacheStampTests.cs +157 -0
  164. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalFailuresTests.cs +178 -0
  165. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalFixtureTests.cs +153 -0
  166. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalRefreshTests.cs +194 -0
  167. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalWorkspace.cs +86 -0
  168. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/KitEvals.cs +213 -0
  169. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/OfflineChatClientTests.cs +95 -0
  170. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeChatClient.cs +225 -0
  171. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeChatClientTests.cs +215 -0
  172. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeClock.cs +33 -0
  173. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeMediaProvider.cs +89 -0
  174. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeProviderClientFactory.cs +55 -0
  175. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeUsageCostSink.cs +60 -0
  176. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/InMemoryMediaStore.cs +131 -0
  177. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaAccessibilityTests.cs +133 -0
  178. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaAliasIsNotAnAgentTests.cs +127 -0
  179. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaDisclosureTests.cs +216 -0
  180. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaGeneratorTests.cs +264 -0
  181. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaIdempotencyKeyTests.cs +148 -0
  182. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaJobStateTests.cs +184 -0
  183. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaReviewTests.cs +89 -0
  184. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaStoreTests.cs +98 -0
  185. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/ModelRegistryTests.cs +451 -0
  186. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Morph.AiKit.Tests.csproj +76 -0
  187. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/SkeletonSmokeTests.cs +33 -0
  188. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/TelemetryTests.cs +261 -0
  189. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/UsageCostMiddlewareTests.cs +374 -0
  190. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/.morph-eval-cache.json +12 -0
  191. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/orcamento.acme.a-resposta-estruturada/1/CF70A7CE27BBD66A/contents.data +13 -0
  192. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/orcamento.acme.a-resposta-estruturada/1/CF70A7CE27BBD66A/entry.json +7 -0
  193. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/triagem.acme.a-saudacao/1/19F631228C6E00BA/contents.data +13 -0
  194. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/triagem.acme.a-saudacao/1/19F631228C6E00BA/entry.json +7 -0
  195. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/triagem.acme.b-escalonamento/1/A24A7C9BDD8239D8/contents.data +13 -0
  196. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/triagem.acme.b-escalonamento/1/A24A7C9BDD8239D8/entry.json +7 -0
  197. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/triagem.globex.a-saudacao/1/E38CD37DF003EDB7/contents.data +13 -0
  198. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/triagem.globex.a-saudacao/1/E38CD37DF003EDB7/entry.json +7 -0
  199. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-datasets/orcamento/acme/a-resposta-estruturada.json +24 -0
  200. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-datasets/triagem/acme/a-saudacao.json +23 -0
  201. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-datasets/triagem/acme/b-escalonamento.json +24 -0
  202. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-datasets/triagem/globex/a-saudacao.json +23 -0
  203. package/framework/templates/infrastructure/docker/docker-compose.template.yml +24 -14
  204. package/package.json +3 -3
  205. package/src/commands/e2e/e2e.js +91 -40
  206. package/src/commands/project/doctor.js +143 -6
  207. package/src/commands/project/env.js +95 -0
  208. package/src/commands/project/update.js +469 -153
  209. package/src/commands/project/worktree.js +274 -53
  210. package/src/commands/state/advance.js +24 -2
  211. package/src/commands/state/approve.js +33 -1
  212. package/src/commands/state/create.js +72 -11
  213. package/src/commands/state/delete.js +8 -0
  214. package/src/commands/state/finish.js +360 -53
  215. package/src/commands/state/gate-check.js +190 -6
  216. package/src/commands/state/score.js +36 -1
  217. package/src/commands/validation/verify.js +134 -25
  218. package/src/core/state/state-manager.js +9 -1
  219. package/src/dashboard/app/pages/HookInspector.jsx +430 -418
  220. package/src/dashboard/scanner/hooks-scanner.js +5 -1
  221. package/src/lib/ai/ai-checks.js +182 -0
  222. package/src/lib/ai/ai-project-scan.js +347 -0
  223. package/src/lib/ai/code-audit.js +181 -0
  224. package/src/lib/ai/comment-stripper.js +184 -0
  225. package/src/lib/ai/pin-drift.js +138 -0
  226. package/src/lib/ai/registry-audit.js +285 -0
  227. package/src/lib/e2e/compose-env.js +55 -14
  228. package/src/lib/e2e/stack-teardown.js +115 -0
  229. package/src/lib/evaluator-report.js +269 -15
  230. package/src/lib/generators/architecture-scanner.js +2 -1
  231. package/src/lib/git/default-branch.js +92 -4
  232. package/src/lib/graph/graphifyignore.js +2 -1
  233. package/src/lib/installers/effects.js +193 -0
  234. package/src/lib/installers/templates-reconcile.js +3 -3
  235. package/src/lib/risk-detector.js +284 -0
  236. package/src/lib/standards/verified-against.js +276 -0
  237. package/src/lib/tasks/eval-node.js +485 -0
  238. package/src/lib/tasks/linux-runner.js +297 -0
  239. package/src/lib/tasks/test-nodes.js +486 -0
  240. package/src/lib/tasks/test-runner.js +431 -37
  241. package/src/lib/validators/verify-runner.js +590 -28
  242. package/src/lib/worktree/dirs.js +32 -0
  243. package/src/lib/worktree/docker-audit.js +287 -0
  244. package/src/lib/worktree/env-allocator.js +69 -11
  245. package/src/lib/worktree/worktree-audit.js +70 -3
  246. package/src/scripts/setup-infra.js +24 -11
  247. package/src/utils/agents-installer.js +18 -8
  248. package/src/utils/claude-md-injector.js +10 -5
  249. package/src/utils/file-copier.js +28 -16
  250. package/src/utils/hooks-installer.js +75 -29
  251. package/src/utils/skills-installer.js +194 -187
  252. package/src/utils/version-checker.js +68 -3
  253. package/framework/standards/ai-agents/prompts-in-db.md +0 -214
@@ -1,21 +1,17 @@
1
- import { join, dirname } from 'path';
1
+ import { join, dirname, relative, isAbsolute } from 'path';
2
2
  import { fileURLToPath } from 'url';
3
3
  import { spawnSync } from 'child_process';
4
- import { existsSync } from 'fs';
4
+ import { existsSync, readdirSync } from 'fs';
5
+ import { homedir } from 'os';
5
6
 
6
7
  const __dirname = dirname(fileURLToPath(import.meta.url));
7
- import fs from 'fs-extra';
8
8
  import ora from 'ora';
9
9
  import chalk from 'chalk';
10
10
  import { logger } from '../../utils/logger.js';
11
11
  import {
12
12
  getContentDir,
13
- copyDirectory,
14
- copyFile,
15
13
  pathExists,
16
- ensureDir,
17
14
  readJson,
18
- writeJson,
19
15
  updateGitignore,
20
16
  ensureGitattributes
21
17
  } from '../../utils/file-copier.js';
@@ -24,7 +20,10 @@ import {
24
20
  checkProjectOutdated,
25
21
  saveProjectMorphVersion,
26
22
  getUpdateInstructions,
27
- detectInstallMethod
23
+ detectInstallMethod,
24
+ getInstalledCLIVersion,
25
+ majorJump,
26
+ MAJOR_JUMP_LIMIT
28
27
  } from '../../utils/version-checker.js';
29
28
  import { installClaudeHooks, installGlobalStatusline } from '../../utils/claude-settings-manager.js';
30
29
  import { installSkills } from '../../utils/skills-installer.js';
@@ -33,6 +32,7 @@ import { injectMorphImport } from '../../utils/claude-md-injector.js';
33
32
  import { getProjectTags } from '../../lib/stack-filter.js';
34
33
  import { updateClaudeMdAgentsBlock, installRulesForStack } from '../../scripts/setup-infra.js';
35
34
  import { reconcileLegacyTemplatesCopy } from '../../lib/installers/templates-reconcile.js';
35
+ import { createEffects } from '../../lib/installers/effects.js';
36
36
 
37
37
  /**
38
38
  * Backup user's config.json before cleaning
@@ -53,13 +53,23 @@ async function backupUserConfig(morphPath) {
53
53
  }
54
54
 
55
55
  /**
56
- * Clean framework directories but preserve .morph/project/
57
- * @param {string} morphPath - Path to .morph directory
58
- * @param {string} targetPath - Project root path
56
+ * Os DEZ diretórios que `cleanFrameworkDirs` apaga por inteiro, sem condição.
57
+ *
58
+ * O número está pinado à mão em `test/commands/update-dry-run.test.js`
59
+ * (`DIRETORIOS_APAGADOS_POR_INTEIRO`), com os dez caminhos escritos: derivar a
60
+ * expectativa desta função deixaria a suíte cega a uma entrada removida.
61
+ *
62
+ * Exportado porque o relatório do `--dry-run` é obrigado a nomeá-los: "update"
63
+ * não sugere a ninguém que `.claude/skills/` e `.claude/agents/` são apagados
64
+ * inteiros antes da reinstalação.
65
+ *
66
+ * @param {string} morphPath
67
+ * @param {string} targetPath
68
+ * @returns {string[]}
59
69
  */
60
- async function cleanFrameworkDirs(morphPath, targetPath) {
70
+ export function frameworkDirsToClean(morphPath, targetPath) {
61
71
  const claudeDir = join(targetPath, '.claude');
62
- const dirsToClean = [
72
+ return [
63
73
  // NOTE: framework/templates is intentionally NOT in this list (R3 perf
64
74
  // plan, Task 2). It's no longer copied/refreshed here — see
65
75
  // reconcileLegacyTemplatesCopy() below, which decides per-run whether a
@@ -77,16 +87,68 @@ async function cleanFrameworkDirs(morphPath, targetPath) {
77
87
  join(claudeDir, 'skills'),
78
88
  join(claudeDir, 'agents'),
79
89
  ];
90
+ }
91
+
92
+ /**
93
+ * Emite (e, fora do dry, executa) a remoção recursiva de um diretório.
94
+ *
95
+ * Um efeito por arquivo, mais um pelo diretório. É a varredura que a §5.2 do
96
+ * spec exige, e é dela que sai a contagem "N arquivos" do relatório.
97
+ *
98
+ * @returns {number} arquivos varridos
99
+ */
100
+ function sweepRemove(dir, fx) {
101
+ let entries;
102
+ try {
103
+ entries = readdirSync(dir, { withFileTypes: true });
104
+ } catch {
105
+ return 0;
106
+ }
107
+ let files = 0;
108
+ for (const entry of entries) {
109
+ const full = join(dir, entry.name);
110
+ if (entry.isDirectory() && !entry.isSymbolicLink()) {
111
+ files += sweepRemove(full, fx);
112
+ } else {
113
+ fx.remove(full);
114
+ files += 1;
115
+ }
116
+ }
117
+ fx.remove(dir);
118
+ return files;
119
+ }
80
120
 
81
- for (const dir of dirsToClean) {
121
+ /**
122
+ * Clean framework directories but preserve .morph/project/.
123
+ *
124
+ * **A armadilha que esta função resolve (decisions.md D6).** Ela apaga
125
+ * exatamente os destinos que os laços de limpeza de órfão DEPOIS varrem
126
+ * (`skills-installer.js`, `agents-installer.js`, `installRulesForStack`).
127
+ * Numa execução real esses laços sempre encontram diretório vazio e não
128
+ * apagam nada. Num `--dry-run` ingênuo nada foi apagado, eles encontrariam o
129
+ * diretório POPULADO e o relatório preveria remoções que a execução real
130
+ * nunca faz — errado na direção que assusta o usuário. Como o clean é
131
+ * incondicional e total, a previsão correta é "tudo que hoje está sob estes
132
+ * dez é apagado", e os laços de órfão ficam calados em dry: são provadamente
133
+ * subsumidos.
134
+ *
135
+ * @param {string} morphPath - Path to .morph directory
136
+ * @param {string} targetPath - Project root path
137
+ * @param {ReturnType<import('../../lib/installers/effects.js').createEffects>} fx
138
+ * @returns {Array<{ dir: string, files: number }>} o que foi (ou seria) apagado
139
+ */
140
+ function cleanFrameworkDirs(morphPath, targetPath, fx) {
141
+ const cleaned = [];
142
+ for (const dir of frameworkDirsToClean(morphPath, targetPath)) {
82
143
  try {
83
- if (await pathExists(dir)) {
84
- await fs.remove(dir);
144
+ if (existsSync(dir)) {
145
+ cleaned.push({ dir, files: sweepRemove(dir, fx) });
85
146
  }
86
147
  } catch (error) {
87
148
  // Continue even if cleanup fails for one directory
88
149
  }
89
150
  }
151
+ return cleaned;
90
152
  }
91
153
 
92
154
  /**
@@ -95,16 +157,17 @@ async function cleanFrameworkDirs(morphPath, targetPath) {
95
157
  * @param {string} targetPath - Project root path
96
158
  * @param {object|null} configBackup - Backup of config.json
97
159
  * @param {string} currentVersion - Current CLI version
160
+ * @param {ReturnType<import('../../lib/installers/effects.js').createEffects>} fx
98
161
  */
99
- async function restoreUserConfig(morphPath, targetPath, configBackup, currentVersion) {
162
+ function restoreUserConfig(morphPath, targetPath, configBackup, currentVersion, fx) {
100
163
  const configDir = join(morphPath, 'config');
101
- await ensureDir(configDir);
164
+ fx.ensureDir(configDir);
102
165
  const configPath = join(configDir, 'config.json');
103
166
 
104
167
  if (configBackup) {
105
168
  // Preserve user data but update framework version
106
169
  configBackup.frameworkVersion = currentVersion;
107
- await writeJson(configPath, configBackup);
170
+ fx.writeJson(configPath, configBackup);
108
171
  } else {
109
172
  // Create basic config if none existed
110
173
  const dirName = targetPath.split(/[\\/]/).pop();
@@ -116,7 +179,32 @@ async function restoreUserConfig(morphPath, targetPath, configBackup, currentVer
116
179
  architecture: 'unknown'
117
180
  }
118
181
  };
119
- await writeJson(configPath, defaultConfig);
182
+ fx.writeJson(configPath, defaultConfig);
183
+ }
184
+ }
185
+
186
+ /**
187
+ * `.morph/state.json` já está no índice do git?
188
+ *
189
+ * SONDA, separada da mutação de propósito: sondar é ler, e ler acontece nos
190
+ * dois modos. É o que faz previsão e execução concordarem sobre este efeito.
191
+ *
192
+ * @param {string} targetPath
193
+ * @returns {boolean}
194
+ */
195
+ export function stateJsonIsTracked(targetPath) {
196
+ try {
197
+ const isRepo = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], {
198
+ cwd: targetPath, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore']
199
+ });
200
+ if (isRepo.status !== 0) return false;
201
+
202
+ const tracked = spawnSync('git', ['ls-files', '--error-unmatch', '.morph/state.json'], {
203
+ cwd: targetPath, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore']
204
+ });
205
+ return tracked.status === 0;
206
+ } catch {
207
+ return false;
120
208
  }
121
209
  }
122
210
 
@@ -133,56 +221,227 @@ async function restoreUserConfig(morphPath, targetPath, configBackup, currentVer
133
221
  * derivable index. Fail-open — a non-git project or any git error is a silent no-op.
134
222
  *
135
223
  * @param {string} targetPath - Project root
224
+ * @param {ReturnType<import('../../lib/installers/effects.js').createEffects>} fx
136
225
  * @returns {boolean} true if state.json was untracked, false otherwise
137
226
  */
138
- export function untrackCommittedStateJson(targetPath) {
227
+ export function untrackCommittedStateJson(targetPath, fx = createEffects({ dryRun: false })) {
228
+ if (!stateJsonIsTracked(targetPath)) return false; // not tracked — nothing to migrate
229
+ return fx.gitIndex('rm --cached --quiet', '.morph/state.json', { cwd: targetPath });
230
+ }
231
+
232
+ /**
233
+ * Os dois registros de versão que um projeto morph-spec carrega.
234
+ *
235
+ * `.morph/.morphversion → morphVersion` é o que o `update` LÊ para decidir.
236
+ * `config.json → frameworkVersion` é o que o `update` ESCREVE e nunca lê para
237
+ * decidir coisa alguma — e no MORPH_OS os dois divergem por um major inteiro
238
+ * (7.5.0 contra 6.0.0). Um freio calibrado no campo errado recusaria (ou
239
+ * liberaria) com base num número que ninguém mantém.
240
+ *
241
+ * @param {string} targetPath
242
+ * @returns {Promise<{ morphVersion: string|null, frameworkVersion: string|null, diverged: boolean }>}
243
+ */
244
+ export async function readVersionRegisters(targetPath) {
245
+ let morphVersion = null;
246
+ let frameworkVersion = null;
139
247
  try {
140
- const isRepo = spawnSync('git', ['rev-parse', '--is-inside-work-tree'], {
141
- cwd: targetPath, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore']
142
- });
143
- if (isRepo.status !== 0) return false;
248
+ const v = await readJson(join(targetPath, '.morph', '.morphversion'));
249
+ morphVersion = typeof v?.morphVersion === 'string' ? v.morphVersion : null;
250
+ } catch { /* ausente ou ilegível — indeterminável, e isso é um dado */ }
251
+ try {
252
+ const c = await readJson(join(targetPath, '.morph', 'config', 'config.json'));
253
+ frameworkVersion = typeof c?.frameworkVersion === 'string' ? c.frameworkVersion : null;
254
+ } catch { /* idem */ }
144
255
 
145
- // Is .morph/state.json currently tracked?
146
- const tracked = spawnSync('git', ['ls-files', '--error-unmatch', '.morph/state.json'], {
147
- cwd: targetPath, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore']
148
- });
149
- if (tracked.status !== 0) return false; // not tracked — nothing to migrate
256
+ const diverged = Boolean(morphVersion && frameworkVersion && morphVersion !== frameworkVersion);
257
+ return { morphVersion, frameworkVersion, diverged };
258
+ }
150
259
 
151
- const rm = spawnSync('git', ['rm', '--cached', '--quiet', '.morph/state.json'], {
152
- cwd: targetPath, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore']
153
- });
154
- return rm.status === 0;
155
- } catch {
156
- return false;
260
+ /**
261
+ * As linhas da recusa do freio de major.
262
+ *
263
+ * Separada do comando para ser testável sem rodar o `update`, e porque a
264
+ * mensagem carrega três obrigações: a versão instalada, a versão de destino e
265
+ * **o registro de onde a versão instalada foi lida**. Quando os dois registros
266
+ * discordam, imprime OS DOIS e diz qual decidiu — escolher em silêncio é
267
+ * acertar metade das vezes e nunca saber qual metade.
268
+ *
269
+ * @param {{ refuse: boolean, reason: string|null, jump: number|null }} verdict
270
+ * @param {{ morphVersion: string|null, frameworkVersion: string|null, diverged: boolean }} registers
271
+ * @param {string} targetVersion
272
+ * @returns {string[]}
273
+ */
274
+ export function majorGuardLines(verdict, registers, targetVersion) {
275
+ const lines = [];
276
+ if (verdict.reason === 'unknown-installed') {
277
+ lines.push(`Recusado: versão instalada indeterminável (\`.morph/.morphversion\` ausente ou ilegível).`);
278
+ lines.push(` Tratado como salto grande, com motivo próprio: "não sei quão velho isto é" é `
279
+ + `precisamente quando sobrescrever às cegas é mais perigoso.`);
280
+ } else {
281
+ lines.push(`Recusado: salto de ${verdict.jump} majors (${registers.morphVersion} → ${targetVersion}); `
282
+ + `o limite sem \`--force\` é ${MAJOR_JUMP_LIMIT}.`);
157
283
  }
284
+ lines.push(` Registro que decidiu: \`.morph/.morphversion → morphVersion\` = `
285
+ + `${registers.morphVersion ?? 'ausente'}.`);
286
+ if (registers.diverged) {
287
+ lines.push(` Divergência: \`config.json → frameworkVersion\` diz ${registers.frameworkVersion}, `
288
+ + `e NÃO foi ele que decidiu — o \`update\` escreve esse campo e nunca o lê para decidir.`);
289
+ }
290
+ lines.push(' Para prosseguir mesmo assim: `morph-spec update --force`.');
291
+ lines.push(' Para ver o que aconteceria antes de decidir: `morph-spec update --dry-run`.');
292
+ return lines;
158
293
  }
159
294
 
160
- export async function updateCommand(options) {
161
- const targetPath = process.cwd();
162
- const contentDir = getContentDir();
163
- const morphPath = join(targetPath, '.morph');
295
+ /** Rótulo humano de cada verbo, para agrupar o relatório por natureza do efeito. */
296
+ const VERB_GROUPS = {
297
+ remove: 'Apagar',
298
+ ensureDir: 'Criar diretório',
299
+ copyDir: 'Copiar diretório',
300
+ copyFile: 'Copiar arquivo',
301
+ writeFile: 'Escrever',
302
+ writeJson: 'Escrever (JSON)',
303
+ mergeJson: 'Mesclar (JSON)',
304
+ spawn: 'Executar',
305
+ gitIndex: 'Índice do git',
306
+ };
307
+
308
+ function isUnder(path, root) {
309
+ if (!path || !root) return false;
310
+ const rel = relative(root, path);
311
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
312
+ }
164
313
 
165
- logger.header('MORPH-SPEC Update');
314
+ /**
315
+ * ONDE um efeito age.
316
+ *
317
+ * Para quase todo verbo é o próprio `target`. Para `spawn` NÃO é: o `target`
318
+ * de um spawn é o binário (`node`), e classificar por ele esconderia
319
+ * exatamente o efeito que a §5.3 obriga a nomear — `generate-refs.js`
320
+ * escrevendo `framework/hooks/shared/phase-utils.js` DENTRO do pacote da CLI.
321
+ *
322
+ * O script sai de `effect.args`, o **array**, nunca de um split do `detail`
323
+ * por espaço: o caminho real deste pacote é
324
+ * `R:\Polymorphism Tech\repos\...` — um espaço no meio, e o split devolvia
325
+ * `Tech\repos\...\generate-refs.js`, que não é subcaminho de nada. O efeito ia
326
+ * calado para o grupo errado. Medido rodando o comando de verdade.
327
+ */
328
+ function effectLocation(effect) {
329
+ if (effect.verb !== 'spawn') return effect.target;
330
+ const args = Array.isArray(effect.args) ? effect.args : [];
331
+ const script = args.find((a) => /\.(js|mjs|cjs)$/.test(String(a)));
332
+ return script || effect.target;
333
+ }
166
334
 
167
- // Check if MORPH is initialized
168
- let needsInitialization = false;
169
- if (!(await pathExists(morphPath))) {
170
- logger.warn('MORPH not fully initialized. Creating basic structure...');
171
- needsInitialization = true;
335
+ /**
336
+ * Imprime o relatório de efeitos.
337
+ *
338
+ * Três grupos ganham nome próprio, porque o usuário não os adivinharia da
339
+ * palavra "update": o que é apagado por INTEIRO antes da reinstalação, o que
340
+ * acontece FORA do projeto, e o que é escrito DENTRO do pacote da CLI (que num
341
+ * `npm link` de desenvolvimento é o próprio repositório do framework).
342
+ *
343
+ * @param {Array} effects - saída de `fx.list()`
344
+ * @param {object} context
345
+ */
346
+ export function renderEffectsReport(effects, context) {
347
+ const { targetPath, packageRoot, dryRun, cleaned = [], notices = [] } = context;
348
+ const globalClaudeDir = join(homedir(), '.claude');
172
349
 
173
- // Create basic .morph structure
174
- await ensureDir(join(morphPath, 'context'));
175
- await ensureDir(join(morphPath, 'features'));
176
- await ensureDir(join(morphPath, 'framework'));
177
- await ensureDir(join(morphPath, 'config'));
178
- logger.dim(' ✓ Created .morph directory structure');
350
+ logger.blank();
351
+ // O cabeçalho não pode dizer "nem executado": em dry o `spawn` do
352
+ // `generate-refs.js` RODA, na variante `--check`, que verifica e não escreve.
353
+ // Prometer silêncio total e rodar um processo é mentir num detalhe pequeno —
354
+ // e é o tipo de detalhe pequeno que faz o usuário parar de acreditar no resto.
355
+ logger.info(dryRun
356
+ ? 'Relatório do --dry-run — nada foi escrito, apagado nem removido '
357
+ + '(o único processo executado é a variante `--check` do gerador, que só verifica):'
358
+ : 'Efeitos desta execução:');
359
+ logger.blank();
360
+
361
+ for (const notice of notices) logger.warn(notice);
362
+ if (notices.length) logger.blank();
363
+
364
+ if (cleaned.length) {
365
+ logger.info('Apagados por inteiro antes da reinstalação:');
366
+ for (const { dir, files } of cleaned) {
367
+ logger.dim(` − ${relative(targetPath, dir) || dir} (${files} arquivo(s))`);
368
+ }
369
+ logger.blank();
179
370
  }
180
371
 
181
- // Check CLI version
372
+ const cleanedRoots = cleaned.map((c) => c.dir);
373
+ const insideCleaned = (e) => e.verb === 'remove'
374
+ && cleanedRoots.some((root) => e.target === root || isUnder(e.target, root));
375
+
376
+ const outside = [];
377
+ const inPackage = [];
378
+ const rest = [];
379
+ for (const e of effects) {
380
+ if (insideCleaned(e)) continue; // já contabilizado acima, e são centenas
381
+ const where = effectLocation(e);
382
+ if (isUnder(where, globalClaudeDir) || where === globalClaudeDir) outside.push(e);
383
+ else if (isUnder(where, packageRoot) && !isUnder(where, targetPath)) inPackage.push(e);
384
+ else rest.push(e);
385
+ }
386
+
387
+ const line = (e) => {
388
+ const label = VERB_GROUPS[e.verb] ?? e.verb;
389
+ const target = isUnder(e.target, targetPath) ? relative(targetPath, e.target) : e.target;
390
+ const suffix = e.detail ? ` ${e.detail}` : '';
391
+ return ` ${label}: ${target}${suffix}`;
392
+ };
393
+
394
+ if (rest.length) {
395
+ logger.info('No projeto:');
396
+ for (const e of rest) logger.dim(line(e));
397
+ logger.blank();
398
+ }
399
+
400
+ if (outside.length) {
401
+ logger.info('FORA do projeto (diretório pessoal do usuário):');
402
+ for (const e of outside) logger.dim(line(e));
403
+ logger.blank();
404
+ }
405
+
406
+ if (inPackage.length) {
407
+ logger.info('DENTRO do pacote da CLI (num `npm link` isto é o repositório do framework):');
408
+ for (const e of inPackage) logger.dim(line(e));
409
+ logger.blank();
410
+ }
411
+
412
+ logger.dim(`Total: ${effects.length} efeito(s) — ${cleaned.length} diretório(s) apagado(s) por inteiro, `
413
+ + `${outside.length} fora do projeto, ${inPackage.length} dentro do pacote da CLI.`);
414
+ logger.blank();
415
+
416
+ // Risco conhecido, reportado em vez de escondido (decisions.md D9): o backup
417
+ // de `config.json` só existe em memória durante a execução.
418
+ logger.dim('Nota: durante uma execução real o backup de `.morph/config/config.json` existe apenas '
419
+ + 'em memória — uma falha no meio do caminho deixa o arquivo apagado. Follow-up nomeado.');
420
+ logger.blank();
421
+ }
422
+
423
+ export async function updateCommand(options = {}) {
424
+ const targetPath = process.cwd();
425
+ const contentDir = getContentDir();
426
+ const morphPath = join(targetPath, '.morph');
427
+ const dryRun = Boolean(options.dryRun);
428
+ const force = Boolean(options.force);
429
+ const fx = options.effects ?? createEffects({ dryRun });
430
+ const notices = [];
431
+
432
+ logger.header(dryRun ? 'MORPH-SPEC Update (--dry-run)' : 'MORPH-SPEC Update');
433
+
434
+ // ── Versões primeiro, e NADA de mutação antes do freio ───────────────────
435
+ // O passo de criação de diretórios ficava aqui em cima e criava `.morph/`
436
+ // ANTES de qualquer guarda. Recusar depois de mutar é meio-estado — o mesmo
437
+ // defeito que o `finish --merge` pagou uma vez na v8.24.3 ("valida a raiz
438
+ // ANTES de arquivar").
182
439
  const spinner = ora('Checking versions...').start();
183
- const cliCheck = await checkCLIOutdated();
440
+ // `cliCheck` é injetável para que o canário de igualdade dry × real não
441
+ // dependa do registro npm; a CLI nunca o passa.
442
+ const cliCheck = options.cliCheck ?? await checkCLIOutdated();
184
443
  const projectCheck = await checkProjectOutdated(targetPath);
185
-
444
+ const registers = await readVersionRegisters(targetPath);
186
445
  spinner.stop();
187
446
 
188
447
  // Display version info
@@ -202,33 +461,83 @@ export async function updateCommand(options) {
202
461
  }
203
462
  logger.blank();
204
463
 
205
- // If CLI is outdated, stop and instruct user
464
+ // If CLI is outdated, stop and instruct user.
465
+ // O `--dry-run` PASSA POR CIMA desta saída antecipada: bloquear o relatório
466
+ // justamente quando o usuário está decidindo se atualiza seria inútil. O
467
+ // aviso vira a primeira linha do relatório.
206
468
  if (cliCheck.isOutdated && cliCheck.latest) {
207
- logger.warn(`Your CLI is outdated (${cliCheck.current} → ${cliCheck.latest})`);
208
- logger.blank();
209
- logger.info('Please update the CLI first:');
210
- logger.blank();
469
+ if (dryRun) {
470
+ notices.push(`A CLI local está desatualizada (${cliCheck.current} → ${cliCheck.latest}). `
471
+ + 'O relatório abaixo é o do que a CLI ATUAL faria.');
472
+ } else {
473
+ logger.warn(`Your CLI is outdated (${cliCheck.current} → ${cliCheck.latest})`);
474
+ logger.blank();
475
+ logger.info('Please update the CLI first:');
476
+ logger.blank();
477
+
478
+ const method = detectInstallMethod();
479
+ const instructions = getUpdateInstructions(method);
480
+
481
+ instructions.forEach(line => {
482
+ if (line === '') {
483
+ logger.blank();
484
+ } else if (line.startsWith('Or ')) {
485
+ logger.dim(line);
486
+ } else {
487
+ logger.box([line]);
488
+ }
489
+ });
211
490
 
212
- const method = detectInstallMethod();
213
- const instructions = getUpdateInstructions(method);
491
+ logger.blank();
492
+ logger.dim('Then run "morph-spec update" again.');
493
+ process.exit(0);
494
+ }
495
+ }
214
496
 
215
- instructions.forEach(line => {
216
- if (line === '') {
217
- logger.blank();
218
- } else if (line.startsWith('Or ')) {
219
- logger.dim(line);
220
- } else {
221
- logger.box([line]);
222
- }
223
- });
497
+ // ── Freio de major, ANTES de qualquer mutação ────────────────────────────
498
+ const needsInitialization = !(await pathExists(morphPath));
499
+ const targetVersion = cliCheck.current ?? getInstalledCLIVersion();
500
+ const verdict = needsInitialization
501
+ // Instalação nova: não há versão anterior a saltar. O freio existe para
502
+ // proteger uma instalação EXISTENTE de ser sobrescrita às cegas.
503
+ ? { refuse: false, reason: null, jump: null }
504
+ : majorJump(registers.morphVersion, targetVersion);
505
+
506
+ if (verdict.refuse) {
507
+ const lines = majorGuardLines(verdict, registers, targetVersion);
508
+ if (force) {
509
+ notices.push(`Freio de major DISPENSADO por \`--force\`: ${lines[0]}`);
510
+ } else if (dryRun) {
511
+ // UC-27 A1: em dry ninguém precisa ser protegido de nada, e esconder o
512
+ // relatório seria esconder justamente a informação que motivou o comando.
513
+ notices.push(...lines);
514
+ } else {
515
+ logger.error(lines[0]);
516
+ for (const l of lines.slice(1)) logger.dim(l);
517
+ logger.blank();
518
+ process.exitCode = 1;
519
+ return { refused: true, reason: verdict.reason, effects: fx.list() };
520
+ }
521
+ } else if (registers.diverged) {
522
+ notices.push(`Os dois registros de versão do projeto discordam: `
523
+ + `\`.morph/.morphversion\` = ${registers.morphVersion}, `
524
+ + `\`config.json → frameworkVersion\` = ${registers.frameworkVersion}. `
525
+ + `Quem decide é o primeiro.`);
526
+ }
224
527
 
225
- logger.blank();
226
- logger.dim('Then run "morph-spec update" again.');
227
- process.exit(0);
528
+ // ── A partir daqui, tudo passa pelo coletor ──────────────────────────────
529
+ if (needsInitialization) {
530
+ logger.warn('MORPH not fully initialized. Creating basic structure...');
531
+ fx.ensureDir(join(morphPath, 'context'));
532
+ fx.ensureDir(join(morphPath, 'features'));
533
+ fx.ensureDir(join(morphPath, 'framework'));
534
+ fx.ensureDir(join(morphPath, 'config'));
535
+ logger.dim(' ✓ Created .morph directory structure');
228
536
  }
229
537
 
230
538
  // Proceed with update
231
- const updateSpinner = ora('Updating MORPH-SPEC...').start();
539
+ const updateSpinner = ora(dryRun ? 'Prevendo efeitos...' : 'Updating MORPH-SPEC...').start();
540
+ let cleaned = [];
232
541
 
233
542
  try {
234
543
  // Backup user config before cleaning
@@ -240,7 +549,7 @@ export async function updateCommand(options) {
240
549
 
241
550
  // Clean framework directories (preserve .morph/project/)
242
551
  updateSpinner.text = 'Cleaning old framework files...';
243
- await cleanFrameworkDirs(morphPath, targetPath);
552
+ cleaned = cleanFrameworkDirs(morphPath, targetPath, fx);
244
553
  logger.dim(' ✓ Old framework files removed');
245
554
 
246
555
  const updateTemplates = !options.standards || options.templates;
@@ -253,7 +562,7 @@ export async function updateCommand(options) {
253
562
  if (updateTemplates) {
254
563
  updateSpinner.text = 'Checking for a legacy templates copy...';
255
564
  const templatesDest = join(morphPath, 'framework', 'templates');
256
- templatesReconcile = reconcileLegacyTemplatesCopy(templatesDest, projectCheck.current);
565
+ templatesReconcile = reconcileLegacyTemplatesCopy(templatesDest, projectCheck.current, fx);
257
566
  }
258
567
 
259
568
  // Update standards
@@ -261,7 +570,7 @@ export async function updateCommand(options) {
261
570
  updateSpinner.text = 'Updating standards...';
262
571
  const standardsSrc = join(contentDir, 'framework', 'standards');
263
572
  const standardsDest = join(morphPath, 'framework', 'standards');
264
- await copyDirectory(standardsSrc, standardsDest);
573
+ fx.copyDir(standardsSrc, standardsDest);
265
574
  }
266
575
 
267
576
  // Update hooks (runtime hook scripts under .morph/framework/hooks/)
@@ -269,7 +578,7 @@ export async function updateCommand(options) {
269
578
  const hooksSrc = join(__dirname, '..', '..', '..', 'framework', 'hooks');
270
579
  const hooksDest = join(morphPath, 'framework', 'hooks');
271
580
  if (await pathExists(hooksSrc)) {
272
- await copyDirectory(hooksSrc, hooksDest);
581
+ fx.copyDir(hooksSrc, hooksDest);
273
582
  }
274
583
 
275
584
  // Update agents.json (sourced from framework/ — canonical single source of truth)
@@ -277,7 +586,7 @@ export async function updateCommand(options) {
277
586
  const agentsSrc = join(__dirname, '..', '..', '..', 'framework', 'agents.json');
278
587
  const agentsDest = join(morphPath, 'framework', 'agents.json');
279
588
  if (await pathExists(agentsSrc)) {
280
- await copyFile(agentsSrc, agentsDest);
589
+ fx.copyFile(agentsSrc, agentsDest);
281
590
  }
282
591
 
283
592
  // Update the remaining framework payloads (parity with setup-infra 7b/7c/8b/8c/8d) —
@@ -288,13 +597,13 @@ export async function updateCommand(options) {
288
597
  for (const file of ['MORPH.md', 'phases.json']) {
289
598
  const src = join(canonicalFrameworkDir, file);
290
599
  if (await pathExists(src)) {
291
- await copyFile(src, join(morphPath, 'framework', file));
600
+ fx.copyFile(src, join(morphPath, 'framework', file));
292
601
  }
293
602
  }
294
603
  for (const dir of ['schemas', 'rubrics', 'evals']) {
295
604
  const src = join(canonicalFrameworkDir, dir);
296
605
  if (await pathExists(src)) {
297
- await copyDirectory(src, join(morphPath, 'framework', dir));
606
+ fx.copyDir(src, join(morphPath, 'framework', dir));
298
607
  }
299
608
  }
300
609
  // CLI.md vive em framework/docs/ na fonte mas instala achatado em
@@ -302,17 +611,21 @@ export async function updateCommand(options) {
302
611
  // por isso não entra no loop de nomes acima.
303
612
  const cliMdSrc = join(canonicalFrameworkDir, 'docs', 'CLI.md');
304
613
  if (await pathExists(cliMdSrc)) {
305
- await copyFile(cliMdSrc, join(morphPath, 'framework', 'CLI.md'));
614
+ fx.copyFile(cliMdSrc, join(morphPath, 'framework', 'CLI.md'));
306
615
  }
307
616
 
308
- // Regenerate derived files (phase-utils.js, skill output tables) from output-schema.js
309
- // Only runs when the generator script is present (framework dev installs via npm link)
617
+ // Regenerate derived files (phase-utils.js, skill output tables) from output-schema.js.
618
+ // Escreve DENTRO do pacote da CLI — num `npm link` de desenvolvimento, no
619
+ // próprio repositório do framework. Em dry a variante `--check` roda no
620
+ // lugar: ela verifica e não escreve.
310
621
  updateSpinner.text = 'Syncing generated refs...';
311
622
  try {
312
623
  const generateScript = join(__dirname, '..', '..', '..', 'scripts', 'generate-refs.js');
313
624
  if (existsSync(generateScript)) {
314
- const result = spawnSync(process.execPath, [generateScript], { encoding: 'utf8' });
315
- if (result.status === 0) {
625
+ const result = fx.spawn(process.execPath, [generateScript], {
626
+ dryRunArgs: [generateScript, '--check'],
627
+ });
628
+ if (result && result.status === 0 && !dryRun) {
316
629
  logger.dim(' ✓ Generated refs synced (phase-utils.js, skill outputs)');
317
630
  }
318
631
  }
@@ -332,7 +645,7 @@ export async function updateCommand(options) {
332
645
  const commandsSrc = join(frameworkDir, 'commands');
333
646
  const commandsDest = join(claudeDest, 'commands');
334
647
  if (await pathExists(commandsSrc)) {
335
- await copyDirectory(commandsSrc, commandsDest);
648
+ fx.copyDir(commandsSrc, commandsDest);
336
649
  commandsCopied = true;
337
650
  } else {
338
651
  logger.warn(' ⚠ framework/commands/ source missing — commands not updated');
@@ -350,12 +663,12 @@ export async function updateCommand(options) {
350
663
 
351
664
  // Sync morph skills to .claude/skills/ for native Claude Code discovery
352
665
  updateSpinner.text = 'Syncing morph skills to .claude/skills/...';
353
- const skillsResult = await installSkills(targetPath, { projectTags });
666
+ const skillsResult = await installSkills(targetPath, { projectTags, fx });
354
667
 
355
668
  // Sync native subagents to .claude/agents/
356
669
  updateSpinner.text = 'Syncing agents to .claude/agents/...';
357
- const agentsResult = await installAgents(targetPath, frameworkDir, { projectTags });
358
- await installDomainAgents(targetPath, frameworkDir, { projectTags });
670
+ const agentsResult = await installAgents(targetPath, frameworkDir, { projectTags, fx });
671
+ await installDomainAgents(targetPath, frameworkDir, { projectTags, fx });
359
672
 
360
673
  // Sync path-scoped rules to .claude/rules/ — stack-filtered with orphan
361
674
  // cleanup (parity with setup-infra), so `update` is a true superset of
@@ -365,8 +678,8 @@ export async function updateCommand(options) {
365
678
  const rulesDest = join(claudeDest, 'rules');
366
679
  let rulesResult = null;
367
680
  if (await pathExists(rulesSrc)) {
368
- await ensureDir(rulesDest);
369
- rulesResult = installRulesForStack(rulesSrc, rulesDest, projectTags);
681
+ fx.ensureDir(rulesDest);
682
+ rulesResult = installRulesForStack(rulesSrc, rulesDest, projectTags, fx);
370
683
  }
371
684
 
372
685
  // Sync runtime CLAUDE.md to .claude/CLAUDE.md and derive the personas
@@ -375,15 +688,19 @@ export async function updateCommand(options) {
375
688
  const runtimeSrc = join(frameworkDir, 'CLAUDE.md');
376
689
  const runtimeDest = join(claudeDest, 'CLAUDE.md');
377
690
  if (await pathExists(runtimeSrc)) {
378
- await copyFile(runtimeSrc, runtimeDest);
379
- await updateClaudeMdAgentsBlock(runtimeDest, agentsResult?.agents ?? []);
691
+ fx.copyFile(runtimeSrc, runtimeDest);
692
+ // Em dry o destino ainda não recebeu a cópia acima; ler a ORIGEM é o que
693
+ // faz previsão e execução decidirem igual sobre os marcadores.
694
+ await updateClaudeMdAgentsBlock(runtimeDest, agentsResult?.agents ?? [], {
695
+ fx, readFrom: dryRun ? runtimeSrc : runtimeDest,
696
+ });
380
697
  }
381
698
 
382
699
  // Sync statusline globally to ~/.claude/
383
700
  updateSpinner.text = 'Syncing statusline to ~/.claude/...';
384
701
  const HOOKS_SRC = join(__dirname, '..', '..', '..', 'framework', 'hooks', 'claude-code');
385
702
  try {
386
- await installGlobalStatusline(HOOKS_SRC);
703
+ await installGlobalStatusline(HOOKS_SRC, null, { fx });
387
704
  } catch {
388
705
  // Non-critical: global dir may not be writable in all environments
389
706
  logger.dim(' ⚠ Could not install statusline globally (non-critical)');
@@ -391,88 +708,87 @@ export async function updateCommand(options) {
391
708
 
392
709
  // Update Claude Code hooks in .claude/settings.local.json
393
710
  updateSpinner.text = 'Updating Claude Code hooks...';
394
- const hooksResult = await installClaudeHooks(targetPath);
711
+ const hooksResult = await installClaudeHooks(targetPath, { fx });
395
712
 
396
713
  // Ensure root CLAUDE.md has morph-spec @import (preserves user content)
397
714
  updateSpinner.text = 'Checking CLAUDE.md import...';
398
715
  const claudeMdDest = join(targetPath, 'CLAUDE.md');
399
- await injectMorphImport(claudeMdDest);
716
+ await injectMorphImport(claudeMdDest, fx);
400
717
 
401
718
  // Restore user config after framework reinstallation
402
719
  updateSpinner.text = 'Restoring user configuration...';
403
- await restoreUserConfig(morphPath, targetPath, configBackup, cliCheck.current);
720
+ restoreUserConfig(morphPath, targetPath, configBackup, cliCheck.current, fx);
404
721
  logger.dim(' ✓ User config restored with updated framework version');
405
722
 
406
723
  // Update .gitignore + .gitattributes with current morph rules
407
724
  updateSpinner.text = 'Updating .gitignore + .gitattributes...';
408
- await updateGitignore(targetPath);
409
- await ensureGitattributes(targetPath);
725
+ await updateGitignore(targetPath, fx);
726
+ await ensureGitattributes(targetPath, fx);
410
727
 
411
728
  // Migration: untrack a pre-v5 committed state.json so it stops being swept
412
729
  // into feature commits. Gitignore alone can't do this for an already-tracked file.
413
- const untracked = untrackCommittedStateJson(targetPath);
730
+ const untracked = untrackCommittedStateJson(targetPath, fx);
414
731
 
415
732
  // Update .morphversion
416
733
  updateSpinner.text = 'Saving version info...';
417
- await saveProjectMorphVersion(targetPath, cliCheck.current);
734
+ await saveProjectMorphVersion(targetPath, cliCheck.current, fx);
418
735
 
419
- updateSpinner.succeed('MORPH-SPEC updated successfully!');
736
+ updateSpinner.succeed(dryRun ? 'Previsão concluída (nada foi tocado).' : 'MORPH-SPEC updated successfully!');
420
737
  logger.blank();
421
738
 
422
- // Show version change
423
- if (projectCheck.current && projectCheck.current !== cliCheck.current) {
424
- logger.success(`Updated: ${projectCheck.current} → ${cliCheck.current}`);
425
- } else if (!projectCheck.current) {
426
- logger.success(`Updated to v${cliCheck.current}`);
427
- } else {
428
- logger.success(`Already up to date (v${cliCheck.current})`);
429
- }
430
-
431
- logger.blank();
432
- logger.info('Updated files:');
433
- if (updateTemplates) {
434
- if (templatesReconcile === 'removed-legacy') {
435
- logger.dim(' ✓ .morph/framework/templates/ (legacy installer copy removed — resolved from package)');
436
- } else if (templatesReconcile === 'preserved-override') {
437
- logger.dim(' ⚠ .morph/framework/templates/ (local override present — preserved untouched, not updated)');
739
+ if (!dryRun) {
740
+ // Show version change
741
+ if (projectCheck.current && projectCheck.current !== cliCheck.current) {
742
+ logger.success(`Updated: ${projectCheck.current} → ${cliCheck.current}`);
743
+ } else if (!projectCheck.current) {
744
+ logger.success(`Updated to v${cliCheck.current}`);
438
745
  } else {
439
- logger.dim(' · .morph/framework/templates/ (not present — resolved from package, no local override)');
746
+ logger.success(`Already up to date (v${cliCheck.current})`);
440
747
  }
748
+
749
+ logger.blank();
750
+ logger.info('Updated files:');
751
+ if (updateTemplates) {
752
+ if (templatesReconcile === 'removed-legacy') {
753
+ logger.dim(' ✓ .morph/framework/templates/ (legacy installer copy removed — resolved from package)');
754
+ } else if (templatesReconcile === 'preserved-override') {
755
+ logger.dim(' ⚠ .morph/framework/templates/ (local override present — preserved untouched, not updated)');
756
+ } else {
757
+ logger.dim(' · .morph/framework/templates/ (not present — resolved from package, no local override)');
758
+ }
759
+ }
760
+
761
+ // Counts make a silent stack-filter downgrade visible at a glance —
762
+ // a dotnet project reporting "1 persona" is a red flag, not a success.
763
+ // Display only compact tags: project.integrations entries are long
764
+ // free-text descriptions and would flood the summary line.
765
+ const compactTags = projectTags.filter(t => !t.includes(' '));
766
+ const tagsLabel = compactTags.length > 0 ? compactTags.join('+') : 'universal only';
767
+ logger.dim(` ✓ .claude/settings.local.json (${hooksResult.installed} hooks installed)`);
768
+ logger.dim(` ✓ .claude/skills/ (${skillsResult?.installed ?? '?'} skills — stack: ${tagsLabel})`);
769
+ logger.dim(` ✓ .claude/agents/ (${agentsResult?.agents?.length ?? '?'} personas — stack: ${tagsLabel})`);
770
+ logger.dim(` ✓ .claude/rules/ (${rulesResult?.installed ?? '?'} stack-filtered rules)`);
771
+ if (commandsCopied) logger.dim(' ✓ .claude/commands/');
772
+ if (untracked) {
773
+ logger.dim(' ✓ .morph/state.json untracked (git rm --cached) — commit to finalize');
774
+ logger.warn('Migration: .morph/state.json was removed from git tracking. The');
775
+ logger.dim(' working file stays (rebuilt from feature.json); commit the deletion');
776
+ logger.dim(' so it stops being swept into per-feature commits.');
777
+ }
778
+ logger.blank();
779
+ logger.info('Your config.json was preserved.');
441
780
  }
442
- if (updateStandards) logger.dim(' ✓ .morph/framework/standards/');
443
- logger.dim(' ✓ .morph/framework/hooks/');
444
- logger.dim(' ✓ .morph/framework/agents.json');
445
- logger.dim(' ✓ .morph/framework/MORPH.md + phases.json (NLH charter)');
446
- logger.dim(' ✓ .morph/framework/schemas/ + rubrics/ + evals/ (bar 9 + LLM rubrics)');
447
- if (commandsCopied) logger.dim(' ✓ .claude/commands/');
448
-
449
- // Counts make a silent stack-filter downgrade visible at a glance —
450
- // a dotnet project reporting "1 persona" is a red flag, not a success.
451
- // Display only compact tags: project.integrations entries are long
452
- // free-text descriptions and would flood the summary line.
453
- const compactTags = projectTags.filter(t => !t.includes(' '));
454
- const tagsLabel = compactTags.length > 0 ? compactTags.join('+') : 'universal only';
455
- logger.dim(` ✓ .claude/settings.local.json (${hooksResult.installed} hooks installed)`);
456
- logger.dim(` ✓ .claude/skills/ (${skillsResult?.installed ?? '?'} skills — stack: ${tagsLabel})`);
457
- logger.dim(` ✓ .claude/agents/ (${agentsResult?.agents?.length ?? '?'} personas — stack: ${tagsLabel})`);
458
- logger.dim(` ✓ .claude/rules/ (${rulesResult?.installed ?? '?'} stack-filtered rules)`);
459
- logger.dim(' ✓ .claude/CLAUDE.md (runtime quick reference)');
460
- logger.dim(' ✓ ~/.claude/statusline.sh (global statusline synced)');
461
- logger.dim(' ✓ CLAUDE.md');
462
- logger.dim(' ✓ .gitignore (morph rules updated)');
463
- if (untracked) {
464
- logger.dim(' ✓ .morph/state.json untracked (git rm --cached) — commit to finalize');
465
- logger.warn('Migration: .morph/state.json was removed from git tracking. The');
466
- logger.dim(' working file stays (rebuilt from feature.json); commit the deletion');
467
- logger.dim(' so it stops being swept into per-feature commits.');
468
- }
469
- logger.blank();
470
- logger.info('Your config.json was preserved.');
471
- logger.dim('Review the updated files for any new features.');
472
- logger.blank();
473
781
 
782
+ // O resumo sai de `list()` NOS DOIS MODOS — é essa escolha que faz um
783
+ // ponto de emissão esquecido sumir das duas saídas ao mesmo tempo, em vez
784
+ // de só do `--dry-run`.
785
+ renderEffectsReport(fx.list(), {
786
+ targetPath, packageRoot: contentDir, dryRun, cleaned, notices,
787
+ });
788
+
789
+ return { refused: false, dryRun, effects: fx.list() };
474
790
  } catch (error) {
475
- updateSpinner.fail('Update failed');
791
+ updateSpinner.fail(dryRun ? 'Previsão falhou' : 'Update failed');
476
792
  logger.error(error.message);
477
793
  process.exit(1);
478
794
  }