@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
@@ -0,0 +1,32 @@
1
+ /**
2
+ * dirs.js — the ONE place the worktree container directory is named.
3
+ *
4
+ * Every reader that resolves worktrees BY CONVENTION (test discovery, graph
5
+ * ignore list, the installed .gitignore, the architecture scanner, the
6
+ * dashboard scanner) imports from here. Duplicating the string is how the
7
+ * C1 bug comes back: a reader keeps the old name and silently goes blind.
8
+ *
9
+ * Readers that go through `git worktree list` (fleet, finish, worktree list)
10
+ * never depended on this — git records absolute paths.
11
+ */
12
+
13
+ /**
14
+ * The directory new worktrees are created under, relative to the primary root.
15
+ *
16
+ * NO leading dot, deliberately (C1). Jest escapes the `.` when it interpolates
17
+ * an absolute `rootDir` into the `testMatch` glob, so `<root>/.worktrees/...`
18
+ * stops matching and an entire frontend suite reports **0 tests without an
19
+ * error** — the suite "passes" having tested nothing. The dot bought nothing:
20
+ * the directory is gitignored either way.
21
+ */
22
+ export const WORKTREES_DIR = 'worktrees';
23
+
24
+ /**
25
+ * The pre-C1 directory name. New worktrees never land here, but every reader
26
+ * that resolves worktrees BY CONVENTION must still accept it, so a machine
27
+ * that already had `.worktrees/{f}` keeps working with no manual migration.
28
+ */
29
+ export const LEGACY_WORKTREES_DIR = '.worktrees';
30
+
31
+ /** Both names, new first — the read order for convention-based lookups. */
32
+ export const WORKTREE_DIR_NAMES = [WORKTREES_DIR, LEGACY_WORKTREES_DIR];
@@ -0,0 +1,287 @@
1
+ /**
2
+ * docker-audit.js — the part of "which worktrees exist" that the filesystem
3
+ * cannot answer: the docker stacks still running for them.
4
+ *
5
+ * `worktree-audit.js` reconciles git's registry, the disk and morph's features.
6
+ * None of the three can see a CONTAINER. A stack survives everything they look
7
+ * at: `finish` removes the worktree and the branch, the directory disappears,
8
+ * git forgets the registration — and `morph-{feature}` keeps running, holding
9
+ * the feature's whole port block (and its named volumes) against the next
10
+ * worktree that lands on the same block. The failure surfaces later, somewhere
11
+ * else, as `port is already allocated` during an `e2e up` that has nothing to
12
+ * do with the stack that is actually holding the port.
13
+ *
14
+ * Two findings, both `severity: 'warn'` — reported, never a failure of
15
+ * `doctor --worktrees` (`doctor.js` treats `info`/`warn` as non-problems):
16
+ *
17
+ * • docker-stack-orphan — a `morph-*` compose project of THIS repository
18
+ * running for a feature this tree does not know
19
+ * • docker-port-held — a port from a feature's allocated block published
20
+ * by a container belonging to a DIFFERENT compose project
21
+ *
22
+ * This module does not go into `doctor --full`, on purpose (D10): every check
23
+ * there must be satisfiable in the framework repo itself, and a docker check in
24
+ * a repo with no compose file could only be permanently yellow. A check that
25
+ * can never pass where it runs teaches nothing.
26
+ *
27
+ * Nothing here mutates anything — not the registry it reads (see
28
+ * `allocatedFeatures`), and certainly not docker. The `fix` strings name the
29
+ * destructive command; a human runs it.
30
+ *
31
+ * @module lib/worktree/docker-audit
32
+ */
33
+
34
+ import { execFileSync } from 'child_process';
35
+ import { resolve } from 'path';
36
+
37
+ // src/ → framework/hooks/shared/ is the allowed direction (hooks are installed
38
+ // standalone into target projects, where src/ does not exist).
39
+ import {
40
+ composeProjectName,
41
+ defaultExec,
42
+ isDockerAvailable,
43
+ parseComposePs,
44
+ } from '../../../framework/hooks/shared/e2e-check.js';
45
+ import { allocateEnvBlock, allocatedFeatures, portLayout, resolveFleetConfig } from './env-allocator.js';
46
+
47
+ /**
48
+ * The prefix `composeProjectName()` stamps on every stack morph brings up,
49
+ * derived from the function itself rather than retyped — the string `morph-`
50
+ * living in two places is how the two stop agreeing.
51
+ */
52
+ const PROJECT_PROBE = 'probe';
53
+ const MORPH_PROJECT_PREFIX = composeProjectName(PROJECT_PROBE).slice(0, -PROJECT_PROBE.length);
54
+
55
+ /**
56
+ * Same fail-open resolution the other callers use: the COMMON git dir is the
57
+ * one location every linked worktree shares, and it is where the port-block
58
+ * registry lives.
59
+ */
60
+ function resolveGitCommonDir(cwd) {
61
+ try {
62
+ const common = execFileSync('git', ['rev-parse', '--git-common-dir'], {
63
+ cwd, stdio: 'pipe', encoding: 'utf-8', windowsHide: true,
64
+ }).trim();
65
+ return common ? resolve(cwd, common) : null;
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Path comparison that matches the host's own rules. Windows paths differ in
73
+ * drive-letter case between what git reports and what docker echoes back;
74
+ * treating those as different paths would make every stack look foreign.
75
+ */
76
+ const samePathCase = process.platform === 'win32' ? (s) => s.toLowerCase() : (s) => s;
77
+ const norm = (p) => samePathCase(resolve(String(p)).replace(/\\/g, '/'));
78
+
79
+ /**
80
+ * Is this compose project OURS to talk about?
81
+ *
82
+ * The `morph-` prefix is global to the host, not to a repository: two different
83
+ * repos that use morph-spec and both have a feature named `triagem` produce the
84
+ * SAME compose project name. Without this test, `doctor` in repo A would report
85
+ * repo B's live stack as an orphan and hand the reader a `down -v` — volumes
86
+ * included — for somebody else's running database. The compose file's path is
87
+ * what ties a stack to a repository, and it keeps pointing inside the root even
88
+ * after the worktree directory it named is gone.
89
+ *
90
+ * Unattributable (no config file in the listing) counts as NOT ours: an
91
+ * unknown owner is not evidence of ownership.
92
+ */
93
+ function belongsToRoot(entry, rootNorm) {
94
+ const configFiles = String(entry?.ConfigFiles ?? '').split(',').map((s) => s.trim()).filter(Boolean);
95
+ if (configFiles.length === 0) return false;
96
+ return configFiles.some((f) => {
97
+ try {
98
+ return norm(f).startsWith(`${rootNorm}/`);
99
+ } catch {
100
+ return false;
101
+ }
102
+ });
103
+ }
104
+
105
+ /**
106
+ * Host ports a `docker ps`/`docker compose ps` entry publishes.
107
+ * Both output shapes are accepted rather than pinning a docker version:
108
+ * `Ports` as the human string (`0.0.0.0:4002->5432/tcp`) and `Publishers` as
109
+ * the structured array compose emits.
110
+ *
111
+ * @returns {Set<number>}
112
+ */
113
+ function publishedPorts(entry) {
114
+ const ports = new Set();
115
+ for (const pub of Array.isArray(entry?.Publishers) ? entry.Publishers : []) {
116
+ const p = Number(pub?.PublishedPort);
117
+ if (Number.isInteger(p) && p > 0) ports.add(p);
118
+ }
119
+ const raw = String(entry?.Ports ?? '');
120
+ for (const m of raw.matchAll(/:(\d{1,5})->/g)) {
121
+ const p = Number(m[1]);
122
+ if (Number.isInteger(p) && p > 0) ports.add(p);
123
+ }
124
+ return ports;
125
+ }
126
+
127
+ /** The compose project a container belongs to, from its labels. `null` = unknown. */
128
+ function composeProjectOf(entry) {
129
+ const labels = String(entry?.Labels ?? '');
130
+ for (const part of labels.split(',')) {
131
+ const idx = part.indexOf('=');
132
+ if (idx < 0) continue;
133
+ if (part.slice(0, idx).trim() === 'com.docker.compose.project') return part.slice(idx + 1).trim() || null;
134
+ }
135
+ return null;
136
+ }
137
+
138
+ /** Readable container name for a `docker ps` entry (Names may be a list). */
139
+ function containerNameOf(entry) {
140
+ const names = String(entry?.Names ?? entry?.Name ?? '').split(',').map((s) => s.trim()).filter(Boolean);
141
+ return names[0] || String(entry?.ID ?? 'container desconhecido');
142
+ }
143
+
144
+ /**
145
+ * Parse a docker JSON listing that may be an array or NDJSON.
146
+ * `null` means the output could not be read — DISTINCT from `[]`, which means
147
+ * docker answered and there was nothing running. A caller that conflated the
148
+ * two would turn "could not ask" into "nothing to report", or worse, into a
149
+ * finding.
150
+ *
151
+ * @returns {Array<object>|null}
152
+ */
153
+ function parseDockerJson(raw) {
154
+ const text = String(raw ?? '').trim();
155
+ if (!text) return [];
156
+ const parsed = parseComposePs(text);
157
+ if (parsed.length > 0) return parsed;
158
+ // parseComposePs (the ONE docker-JSON parser in this codebase) collapses two
159
+ // very different answers into `[]`: "docker listed nothing" and "this is not
160
+ // JSON". Only a literal empty array is the first one; everything else that
161
+ // parsed to nothing is garbage, and garbage must not read as "all clear".
162
+ return /^\[\s*\]$/.test(text) ? [] : null;
163
+ }
164
+
165
+ /**
166
+ * Audit the docker stacks around this repository.
167
+ *
168
+ * Returns `[]` — asking docker nothing at all — in three situations, and the
169
+ * silence is the point: this module is loaded by `doctor --worktrees` in every
170
+ * repo, including ones that never containerize anything.
171
+ *
172
+ * 1. no feature ever allocated a port block (no registry): there is no
173
+ * isolation contract to compare a running stack against;
174
+ * 2. the docker CLI is not usable here;
175
+ * 3. any docker call exits non-zero or answers with something that is not
176
+ * JSON — fail-open, the standing rule of this repo's diagnostics.
177
+ *
178
+ * `[]` here reads as "nothing to report", not as "unknown": with no docker or
179
+ * no blocks, nothing CAN be colliding. That is why this is not the `null`-is-
180
+ * UNKNOWN shape `pr-index.js` uses — there the question stays open, here the
181
+ * absence is itself the answer.
182
+ *
183
+ * @param {string} root - primary root of the repository
184
+ * @param {object} [opts]
185
+ * @param {(file: string, args: string[], o?: object) => {code: number, output: string}} [opts.exec]
186
+ * injectable executor, exactly like `e2e-check.js` — the CI runners have no
187
+ * guaranteed daemon, so every path here must be testable without docker
188
+ * @param {Iterable<string>} [opts.features] - feature names this tree knows.
189
+ * Passed IN rather than re-derived: `auditWorktrees` already read
190
+ * `.morph/features/` and `git worktree list`, and two scans of the same fact
191
+ * are two chances to disagree about whether a stack is orphaned
192
+ * @param {string|null} [opts.gitCommonDir]
193
+ * @returns {Array<{type: string, severity: string, detail: string, fix: string, feature?: string}>}
194
+ */
195
+ export function auditDockerStacks(root, { exec = defaultExec, features = [], gitCommonDir } = {}) {
196
+ const commonDir = gitCommonDir === undefined ? resolveGitCommonDir(root) : gitCommonDir;
197
+ if (!commonDir) return [];
198
+
199
+ // (1) No port block was ever allocated → no fleet isolation to protect.
200
+ const allocated = allocatedFeatures(commonDir);
201
+ if (allocated.length === 0) return [];
202
+
203
+ // (2) No docker → nothing can be holding anything.
204
+ if (!isDockerAvailable(root, exec)) return [];
205
+
206
+ const known = new Set(features);
207
+ const rootNorm = norm(root);
208
+ const issues = [];
209
+
210
+ // ---- docker-stack-orphan ----------------------------------------------
211
+ const lsResult = exec('docker', ['compose', 'ls', '--format', 'json'], { cwd: root });
212
+ if (lsResult.code !== 0) return []; // (3) fail-open
213
+ const projects = parseDockerJson(lsResult.output);
214
+ if (projects === null) return []; // (3) unreadable answer is not a finding
215
+
216
+ const knownProjects = new Set([...known].map((f) => composeProjectName(f)));
217
+ for (const entry of projects) {
218
+ const name = String(entry?.Name ?? '').trim();
219
+ if (!name.startsWith(MORPH_PROJECT_PREFIX)) continue;
220
+ if (knownProjects.has(name)) continue;
221
+ if (!belongsToRoot(entry, rootNorm)) continue;
222
+
223
+ const slug = name.slice(MORPH_PROJECT_PREFIX.length);
224
+ issues.push({
225
+ type: 'docker-stack-orphan',
226
+ severity: 'warn',
227
+ feature: slug || null,
228
+ detail: `stack docker "${name}" está de pé (${String(entry?.Status ?? 'estado desconhecido')}) e não corresponde a nenhuma feature desta árvore — sobra de outra sessão, segurando o bloco de portas e os volumes de "${slug}"`,
229
+ // NEVER `morph-spec e2e down {slug}`: that command resolves the feature's
230
+ // compose file, and this stack's feature no longer exists here. The
231
+ // project name is the only handle that still works.
232
+ fix: `docker compose -p ${name} down -v`,
233
+ });
234
+ }
235
+
236
+ // ---- docker-port-held --------------------------------------------------
237
+ const psResult = exec('docker', ['ps', '--format', 'json'], { cwd: root });
238
+ if (psResult.code !== 0) return []; // (3) fail-open
239
+ const containers = parseDockerJson(psResult.output);
240
+ if (containers === null) return [];
241
+
242
+ const { basePort, blockSize } = resolveFleetConfig(root);
243
+ for (const feature of allocated) {
244
+ // A block allocated by a feature this tree does not know belongs to another
245
+ // worktree of the repo; its collisions are that worktree's to report.
246
+ if (!known.has(feature)) continue;
247
+
248
+ let layout;
249
+ try {
250
+ // `allocateEnvBlock` for a feature that is ALREADY in the registry takes
251
+ // the early-return path and writes nothing. The expected ports are
252
+ // DERIVED from portLayout — the same function that writes worktree.env
253
+ // and the compose env — never recomputed here.
254
+ const block = allocateEnvBlock(feature, { gitCommonDir: commonDir, basePort, blockSize });
255
+ layout = portLayout(block, { feature });
256
+ } catch {
257
+ continue; // block out of range / corrupt entry — nothing to compare
258
+ }
259
+
260
+ const ownProject = layout.COMPOSE_PROJECT_NAME;
261
+ const expected = new Map();
262
+ for (const key of ['PORT', 'API_PORT', 'POSTGRES_PORT', 'REDIS_PORT']) {
263
+ const port = Number(layout[key]);
264
+ if (Number.isInteger(port)) expected.set(port, key);
265
+ }
266
+
267
+ for (const container of containers) {
268
+ const project = composeProjectOf(container);
269
+ if (project === ownProject) continue; // the feature's own stack, as intended
270
+ for (const port of publishedPorts(container)) {
271
+ const key = expected.get(port);
272
+ if (!key) continue;
273
+ issues.push({
274
+ type: 'docker-port-held',
275
+ severity: 'warn',
276
+ feature,
277
+ detail: `a porta ${port} (${key} do bloco de "${feature}") está publicada pelo container ${containerNameOf(container)}${project ? ` do projeto compose "${project}"` : ' (sem projeto compose)'}, não por "${ownProject}" — a stack de "${feature}" não sobe enquanto ela estiver presa`,
278
+ fix: project
279
+ ? `docker compose -p ${project} down (ou pare o container ${containerNameOf(container)}) antes de subir a stack de "${feature}"`
280
+ : `docker stop ${containerNameOf(container)} antes de subir a stack de "${feature}"`,
281
+ });
282
+ }
283
+ }
284
+ }
285
+
286
+ return issues;
287
+ }
@@ -21,6 +21,8 @@
21
21
 
22
22
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
23
23
  import { join } from 'path';
24
+ // src/ → hooks/shared/ is the allowed direction (hooks are installed standalone).
25
+ import { composeProjectName } from '../../../framework/hooks/shared/e2e-check.js';
24
26
 
25
27
  const DEFAULT_BASE_PORT = 4000;
26
28
  const DEFAULT_BLOCK_SIZE = 10;
@@ -108,6 +110,30 @@ export function allocateEnvBlock(feature, { gitCommonDir, basePort = DEFAULT_BAS
108
110
  return { index, base, range: [base, base + blockSize - 1], created: true };
109
111
  }
110
112
 
113
+ /**
114
+ * The features that ALREADY own a block — read-only, allocating nothing.
115
+ *
116
+ * `allocateEnvBlock` is the only sanctioned way to get a block, and it WRITES
117
+ * when the feature has none. A diagnostic that has to ask "who owns a block?"
118
+ * must not answer by handing one out: `doctor` is read-only, and an audit that
119
+ * mutates the registry it audits invents the very state it then reports. So
120
+ * this reader exists to bound the question, and the block itself still comes
121
+ * from `allocateEnvBlock` (which, for a feature listed here, takes the
122
+ * early-return path and writes nothing).
123
+ *
124
+ * An absent or corrupt registry yields `[]` — "nobody allocated anything",
125
+ * which for every caller is the same as "nothing to compare against".
126
+ *
127
+ * @param {string} gitCommonDir
128
+ * @returns {string[]} feature names with an allocated block
129
+ */
130
+ export function allocatedFeatures(gitCommonDir) {
131
+ const registry = readRegistry(gitCommonDir);
132
+ return Object.entries(registry.allocations)
133
+ .filter(([, a]) => Number.isInteger(a?.index))
134
+ .map(([feature]) => feature);
135
+ }
136
+
111
137
  /**
112
138
  * Release a feature's block. Returns false when nothing was allocated.
113
139
  *
@@ -124,9 +150,46 @@ export function releaseEnvBlock(feature, { gitCommonDir }) {
124
150
  }
125
151
 
126
152
  /**
127
- * Render the `.morph/worktree.env` contents for a block, stack-aware.
128
- * `PORT` covers next/vite/node dev servers; dotnet gets its own URL on base+1
129
- * so a full-stack worktree runs web and api side by side inside one block.
153
+ * Fixed offsets inside a block. The block has 10 ports; four are named, the
154
+ * rest (+4..+9) are the project's to use. A new offset added here MUST also be
155
+ * published by the compose template (`${VAR:-default}:container`), or it is a
156
+ * port nobody listens on.
157
+ */
158
+ export const PORT_OFFSETS = Object.freeze({ web: 0, api: 1, postgres: 2, redis: 3 });
159
+
160
+ /**
161
+ * The ONE port-layout contract. Two writers derive from it — the
162
+ * `.morph/worktree.env` manifest (`buildEnvFileContent`) and the env handed to
163
+ * `docker compose` (`src/lib/e2e/compose-env.js`) — so a worktree's file and
164
+ * its running stack can never describe different worlds.
165
+ *
166
+ * `COMPOSE_PROJECT_NAME` is part of the layout on purpose: a hand-run
167
+ * `docker compose up` inside the worktree must land in the isolated project,
168
+ * not in the root's. Every value is a string (env-safe); key order is stable.
169
+ *
170
+ * @param {{ base: number, range: [number, number] }} block
171
+ * @param {{ stack?: string, feature?: string }} [opts]
172
+ * @returns {Record<string,string>}
173
+ */
174
+ export function portLayout(block, { stack = '', feature = '' } = {}) {
175
+ const env = {};
176
+ if (feature) env.COMPOSE_PROJECT_NAME = composeProjectName(feature);
177
+ env.MORPH_PORT_BASE = String(block.base);
178
+ env.MORPH_PORT_RANGE = `${block.range[0]}-${block.range[1]}`;
179
+ env.PORT = String(block.base + PORT_OFFSETS.web);
180
+ env.API_PORT = String(block.base + PORT_OFFSETS.api);
181
+ env.POSTGRES_PORT = String(block.base + PORT_OFFSETS.postgres);
182
+ env.REDIS_PORT = String(block.base + PORT_OFFSETS.redis);
183
+ if (/\bdotnet\b/i.test(String(stack))) {
184
+ env.ASPNETCORE_URLS = `http://localhost:${block.base + PORT_OFFSETS.api}`;
185
+ }
186
+ return env;
187
+ }
188
+
189
+ /**
190
+ * Render the `.morph/worktree.env` contents for a block: a header, the
191
+ * feature, and exactly the `portLayout()` pairs — nothing else, so the file
192
+ * cannot drift from what compose receives.
130
193
  *
131
194
  * @param {string} feature
132
195
  * @param {{ base: number, range: [number, number] }} block
@@ -135,15 +198,10 @@ export function releaseEnvBlock(feature, { gitCommonDir }) {
135
198
  */
136
199
  export function buildEnvFileContent(feature, block, stack = '') {
137
200
  const lines = [
138
- `# Managed by morph-spec — fleet env desta worktree (feature: ${feature}).`,
139
- '# Derivado e local: nunca commitar, nunca editar à mão (regenerado pelo worktree setup).',
201
+ `# Managed by morph-spec — ambiente dedicado desta worktree (feature: ${feature}).`,
202
+ '# Derivado, git-invisível, regenerado por `worktree setup|link`; nunca editar nem commitar.',
140
203
  `MORPH_FEATURE=${feature}`,
141
- `MORPH_PORT_BASE=${block.base}`,
142
- `MORPH_PORT_RANGE=${block.range[0]}-${block.range[1]}`,
143
- `PORT=${block.base}`,
204
+ ...Object.entries(portLayout(block, { stack, feature })).map(([k, v]) => `${k}=${v}`),
144
205
  ];
145
- if (/\bdotnet\b/i.test(String(stack))) {
146
- lines.push(`ASPNETCORE_URLS=http://localhost:${block.base + 1}`);
147
- }
148
206
  return `${lines.join('\n')}\n`;
149
207
  }
@@ -36,6 +36,7 @@ import {
36
36
  import { detectDefaultBranch } from '../git/default-branch.js';
37
37
  import { buildPrIndex } from '../git/pr-index.js';
38
38
  import { classifyBranch, commitsAhead, resolveBaseRef } from '../git/branch-classifier.js';
39
+ import { auditDockerStacks } from './docker-audit.js';
39
40
 
40
41
  const norm = (p) => resolve(p).replace(/\\/g, '/');
41
42
 
@@ -212,10 +213,24 @@ function dirsOnDisk(root) {
212
213
  * directory that exists but git's own
213
214
  * registry does not know as a worktree
214
215
  *
216
+ * • docker-stack-orphan (warn) — a `morph-*` docker stack of this repository
217
+ * still running for a feature this tree does
218
+ * not know, and
219
+ * • docker-port-held (warn) — a port from a feature's block published by
220
+ * another compose project. Both come from
221
+ * `docker-audit.js`: the three sources above
222
+ * cannot see a CONTAINER, and a stack outlives
223
+ * every one of them
224
+ *
215
225
  * @param {string} [cwd]
226
+ * @param {object} [opts]
227
+ * @param {Function} [opts.exec] - injectable executor handed to the docker
228
+ * audit, so the docker paths are testable with no daemon (the CI runners
229
+ * have none). Absent, the real `docker` CLI is used — and answers `[]` when
230
+ * it is not installed.
216
231
  * @returns {Array<{ type: string, detail: string, path?: string, feature?: string, fix: string }>}
217
232
  */
218
- export function auditWorktrees(cwd = process.cwd()) {
233
+ export function auditWorktrees(cwd = process.cwd(), { exec } = {}) {
219
234
  if (!isGitRepo(cwd)) return [];
220
235
  const root = resolvePrimaryRoot(cwd);
221
236
  const rootNorm = norm(root);
@@ -247,9 +262,47 @@ export function auditWorktrees(cwd = process.cwd()) {
247
262
 
248
263
  const feature = entry.branch?.match(/^refs\/heads\/morph\/(.+)$/)?.[1] || null;
249
264
  if (!feature) {
250
- // Only worth reporting when it sits in morph's own worktree container —
251
- // a worktree the user keeps elsewhere is none of morph's business.
252
265
  const inContainer = WORKTREE_DIR_NAMES.some((d) => path.startsWith(`${rootNorm}/${d}/`));
266
+ const task = entry.branch?.match(/^refs\/heads\/morph-task\/(.+)$/)?.[1] || null;
267
+
268
+ if (!inContainer) {
269
+ // A worktree OUTSIDE morph's container was created behind morph's back
270
+ // (a native Claude Code run before the WorktreeCreate hook, a raw
271
+ // `git worktree add ../x`, another skill). It has no junctions, no port
272
+ // block, and nothing morph runs there — the exact shape that let a
273
+ // root's `.claude/` be emptied. Reported as `warn` (never a failure,
274
+ // NEVER pruned): the work inside may be the only copy.
275
+ const work = unintegratedWork(root, entry.branch, prIndex);
276
+ issues.push({
277
+ type: 'out-of-container',
278
+ severity: 'warn',
279
+ path,
280
+ detail: `${path} é um worktree fora de worktrees/ (${entry.branch || 'HEAD destacado'}) — criado sem o morph: sem junctions, sem portas, invisível ao \`fleet\`${work.unintegrated ? `; carrega trabalho NÃO integrado (${work.evidence})` : ''}`,
281
+ fix: work.unintegrated
282
+ ? `integre a branch primeiro; depois \`morph-spec worktree remove --target "${path}"\`. Novos worktrees: \`morph-spec worktree setup <f>\` ou \`worktree provision <n>\``
283
+ : `morph-spec worktree remove --target "${path}" — e crie os próximos por \`morph-spec worktree setup <f>\` / \`worktree provision <n>\``,
284
+ });
285
+ continue;
286
+ }
287
+
288
+ if (task) {
289
+ // `morph-task/*`: a task-isolation tree provisioned by the WorktreeCreate
290
+ // hook. Clean → nothing to say (the orchestrator removes it after the
291
+ // merge). With commits the default does not have → report, never
292
+ // suggest removal.
293
+ const work = unintegratedWork(root, entry.branch, prIndex);
294
+ if (work.unintegrated) {
295
+ issues.push({
296
+ type: 'unintegrated-work',
297
+ severity: 'info',
298
+ path,
299
+ detail: `${path} é um worktree de isolamento de task (${entry.branch}) com trabalho NÃO integrado (${work.evidence})`,
300
+ fix: `integre a branch (\`git merge --no-ff morph-task/${task}\` no orquestrador) e depois \`morph-spec worktree remove --target "${path}"\``,
301
+ });
302
+ }
303
+ continue;
304
+ }
305
+
253
306
  if (inContainer) {
254
307
  // Same hazard as `no-feature` below, and it needs the same guard:
255
308
  // "invisible to fleet" says nothing about whether the WORK is safe
@@ -356,6 +409,20 @@ export function auditWorktrees(cwd = process.cwd()) {
356
409
  }
357
410
  }
358
411
 
412
+ // The fourth source, and the only one that is not a file: the docker stacks.
413
+ // A container survives the removal of the worktree, of the branch and of the
414
+ // feature — none of the three reconciliations above can see it, and it keeps
415
+ // holding the feature's port block. The feature names come from HERE (the
416
+ // ones morph knows plus the ones the registered `morph/*` worktrees carry)
417
+ // rather than from a second scan inside docker-audit: two readings of the
418
+ // same fact are two chances to disagree about which stack is orphaned.
419
+ const treeFeatures = new Set(features);
420
+ for (const entry of registered) {
421
+ const f = entry.branch?.match(/^refs\/heads\/morph\/(.+)$/)?.[1];
422
+ if (f) treeFeatures.add(f);
423
+ }
424
+ issues.push(...auditDockerStacks(root, { exec, features: treeFeatures }));
425
+
359
426
  return issues;
360
427
  }
361
428
 
@@ -14,7 +14,7 @@
14
14
  import { join, dirname, resolve } from 'path';
15
15
  import { fileURLToPath } from 'url';
16
16
  import { execSync } from 'child_process';
17
- import { readFileSync, readdirSync, copyFileSync, unlinkSync } from 'fs';
17
+ import { readFileSync, readdirSync } from 'fs';
18
18
  import {
19
19
  copyDirectory,
20
20
  copyFile,
@@ -33,6 +33,7 @@ import { installSkills } from '../utils/skills-installer.js';
33
33
  import { installAgents, installDomainAgents } from '../utils/agents-installer.js';
34
34
  import { resolvePrimaryRoot } from '../commands/project/worktree.js';
35
35
  import { reconcileLegacyTemplatesCopy } from '../lib/installers/templates-reconcile.js';
36
+ import { realEffects } from '../lib/installers/effects.js';
36
37
 
37
38
  const FRAMEWORK_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'framework');
38
39
 
@@ -61,7 +62,7 @@ async function installRequiredPlugins(log, exec) {
61
62
  * @param {string} rulesDest - Destination rules directory (.claude/rules/)
62
63
  * @param {string[]} projectTags - Stack tags from config (e.g., ['dotnet', 'blazor'])
63
64
  */
64
- export function installRulesForStack(rulesSrc, rulesDest, projectTags) {
65
+ export function installRulesForStack(rulesSrc, rulesDest, projectTags, fx = realEffects()) {
65
66
  const sourceFiles = readdirSync(rulesSrc).filter(f => f.endsWith('.md'));
66
67
  const installed = new Set();
67
68
 
@@ -69,15 +70,19 @@ export function installRulesForStack(rulesSrc, rulesDest, projectTags) {
69
70
  const content = readFileSync(join(rulesSrc, file), 'utf-8');
70
71
  const stacks = parseStacks(content);
71
72
  if (shouldInstall(stacks, projectTags)) {
72
- copyFileSync(join(rulesSrc, file), join(rulesDest, file));
73
+ fx.copyFile(join(rulesSrc, file), join(rulesDest, file));
73
74
  installed.add(file);
74
75
  }
75
76
  }
76
77
 
77
- // Cleanup orphans
78
- for (const file of readdirSync(rulesDest).filter(f => f.endsWith('.md'))) {
79
- if (!installed.has(file)) {
80
- unlinkSync(join(rulesDest, file));
78
+ // Cleanup orphans — calado em `dryRun` pelo mesmo motivo dos laços gêmeos em
79
+ // skills-installer.js e agents-installer.js (D6): `cleanFrameworkDirs()` já
80
+ // apagou `.claude/rules/` inteiro antes de este código rodar de verdade.
81
+ if (!fx.dryRun) {
82
+ for (const file of readdirSync(rulesDest).filter(f => f.endsWith('.md'))) {
83
+ if (!installed.has(file)) {
84
+ fx.remove(join(rulesDest, file));
85
+ }
81
86
  }
82
87
  }
83
88
 
@@ -134,12 +139,20 @@ async function installStackAwareAssets(targetPath, log) {
134
139
  *
135
140
  * @param {string} claudeMdPath - Absolute path to the installed .claude/CLAUDE.md
136
141
  * @param {Array<{ name: string, title: string, domains: string[] }>} agents
142
+ * @param {object} [options]
143
+ * @param {ReturnType<import('../lib/installers/effects.js').createEffects>} [options.fx]
144
+ * @param {string} [options.readFrom] - De onde LER o conteúdo-base.
145
+ * Num `--dry-run` o arquivo de destino ainda não recebeu a cópia que o passo
146
+ * anterior previu, então ler o destino diria "sem marcadores" e o efeito de
147
+ * escrita sumiria da previsão — divergindo da execução real. Lendo a MESMA
148
+ * origem que seria copiada, previsão e execução decidem igual.
137
149
  */
138
- export async function updateClaudeMdAgentsBlock(claudeMdPath, agents = []) {
139
- if (!(await pathExists(claudeMdPath))) return;
150
+ export async function updateClaudeMdAgentsBlock(claudeMdPath, agents = [], options = {}) {
151
+ const { fx = realEffects(), readFrom = claudeMdPath } = options;
152
+ if (!(await pathExists(readFrom))) return;
140
153
  const START = '<!-- morph-agents:start -->';
141
154
  const END = '<!-- morph-agents:end -->';
142
- const content = readFileSync(claudeMdPath, 'utf-8');
155
+ const content = readFileSync(readFrom, 'utf-8');
143
156
  if (!content.includes(START) || !content.includes(END)) return;
144
157
 
145
158
  const list = agents.length > 0
@@ -151,7 +164,7 @@ export async function updateClaudeMdAgentsBlock(claudeMdPath, agents = []) {
151
164
  new RegExp(`${START}[\\s\\S]*?${END}`),
152
165
  block
153
166
  );
154
- await writeFile(claudeMdPath, updated);
167
+ fx.writeFile(claudeMdPath, updated);
155
168
  }
156
169
 
157
170
  /**