@polymorphism-tech/morph-spec 8.37.1 → 8.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (317) hide show
  1. package/CLAUDE.md +37 -13
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +75 -49
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/CLAUDE.md +6 -5
  8. package/framework/MORPH.md +25 -22
  9. package/framework/agents.json +96 -36
  10. package/framework/docs/CLI.md +54 -17
  11. package/framework/evals/rubrics/code-quality.md +5 -0
  12. package/framework/evals/rubrics/condensed.md +6 -2
  13. package/framework/evals/rubrics/contracts.md +20 -0
  14. package/framework/evals/rubrics/test-coverage.md +17 -6
  15. package/framework/hooks/README.md +9 -3
  16. package/framework/hooks/claude-code/core/graph-refresh-kick.js +52 -0
  17. package/framework/hooks/claude-code/core/telemetry-log.js +172 -87
  18. package/framework/hooks/claude-code/core/validate-completion.js +16 -90
  19. package/framework/hooks/claude-code/core/worktree-context.js +10 -0
  20. package/framework/hooks/shared/command-tokens.js +96 -0
  21. package/framework/hooks/shared/git-exclude.js +44 -0
  22. package/framework/hooks/shared/git-paths.js +52 -0
  23. package/framework/hooks/shared/graph-freshness.js +380 -0
  24. package/framework/hooks/shared/graph-refresh.js +416 -0
  25. package/framework/hooks/shared/morph-cli.js +17 -5
  26. package/framework/hooks/shared/redact-secrets.js +55 -0
  27. package/framework/hooks/shared/telemetry-logger.js +25 -2
  28. package/framework/hooks/shared/transcript-usage.js +80 -0
  29. package/framework/mcp/registry.json +33 -2
  30. package/framework/rubrics/backend.json +2 -2
  31. package/framework/rubrics/default.json +2 -2
  32. package/framework/rubrics/frontend.json +2 -2
  33. package/framework/rules/csharp-standards.md +1 -0
  34. package/framework/rules/infrastructure-standards.md +0 -1
  35. package/framework/rules/morph-workflow.md +10 -4
  36. package/framework/rules/nextjs-standards.md +1 -0
  37. package/framework/schemas/config.schema.json +41 -1
  38. package/framework/schemas/feature.schema.json +5 -1
  39. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  40. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  41. package/framework/skills/core/morph-apply/SKILL.md +40 -11
  42. package/framework/skills/core/morph-brainstorming/SKILL.md +16 -7
  43. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  44. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  45. package/framework/skills/core/morph-implement/SKILL.md +44 -10
  46. package/framework/skills/core/morph-media/SKILL.md +1 -1
  47. package/framework/skills/core/morph-plan/SKILL.md +73 -15
  48. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  49. package/framework/skills/core/morph-review/SKILL.md +51 -13
  50. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  51. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  52. package/framework/skills/core/morph-uiux/SKILL.md +39 -13
  53. package/framework/standards/STANDARDS.json +189 -14
  54. package/framework/standards/ai-agents/context-providers.md +16 -26
  55. package/framework/standards/ai-agents/durable-workflows-hitl.md +15 -21
  56. package/framework/standards/ai-agents/modalities-image-gen.md +21 -25
  57. package/framework/standards/ai-agents/modalities-speech-to-text.md +4 -15
  58. package/framework/standards/ai-agents/modalities-text-to-speech.md +2 -11
  59. package/framework/standards/ai-agents/modalities-vision.md +1 -18
  60. package/framework/standards/ai-agents/prompt-sources.md +0 -4
  61. package/framework/standards/ai-agents/testing-ai.md +2 -1
  62. package/framework/standards/architecture/code-comments.md +100 -0
  63. package/framework/standards/backend/dotnet/testing.md +22 -0
  64. package/framework/standards/backend/integrations/neon-auth/neon-auth.md +63 -50
  65. package/framework/standards/frontend/admin-app/data-table.md +562 -0
  66. package/framework/standards/frontend/admin-app/feedback.md +236 -0
  67. package/framework/standards/frontend/admin-app/forms.md +232 -0
  68. package/framework/standards/frontend/admin-app/page-structure.md +203 -0
  69. package/framework/standards/frontend/design-system/ai-image-generation.md +5 -5
  70. package/framework/standards/frontend/nextjs/components.md +37 -58
  71. package/framework/standards/frontend/nextjs/data-fetching.md +1 -0
  72. package/framework/standards/frontend/nextjs/forms.md +31 -30
  73. package/framework/standards/frontend/nextjs/state-management.md +2 -1
  74. package/framework/standards/frontend/nextjs/testing.md +18 -0
  75. package/framework/standards/integration/mcp/mcp-tools.md +48 -117
  76. package/framework/templates/code/dotnet/abstractions/result.cs +0 -2
  77. package/framework/templates/code/dotnet/ai-agents/AgentBootstrap.cs.template +26 -39
  78. package/framework/templates/code/dotnet/ai-agents/EmbeddingService.cs.template +28 -68
  79. package/framework/templates/code/dotnet/ai-agents/ModelRegistry.cs.template +99 -117
  80. package/framework/templates/code/dotnet/ai-agents/PromptRepository.cs.template +2 -44
  81. package/framework/templates/code/dotnet/ai-agents/ResilientToolMiddleware.cs.template +40 -61
  82. package/framework/templates/code/dotnet/ai-agents/VectorSearchTool.cs.template +54 -107
  83. package/framework/templates/code/dotnet/ai-agents/model-registry.json.template +53 -58
  84. package/framework/templates/code/dotnet/contracts/contracts-vsa.cs +192 -281
  85. package/framework/templates/code/dotnet/database/migration.cs +49 -83
  86. package/framework/templates/code/dotnet/database/vector-entity.cs +2 -46
  87. package/framework/templates/code/dotnet/extensions/result-extensions.cs +0 -2
  88. package/framework/templates/code/dotnet/feature/validator.cs.hbs +11 -15
  89. package/framework/templates/code/dotnet/jobs/agent.cs +3 -51
  90. package/framework/templates/code/dotnet/jobs/job.cs +2 -97
  91. package/framework/templates/code/dotnet/test.cs +2 -87
  92. package/framework/templates/code/typescript/contracts.ts +140 -167
  93. package/framework/templates/context/design-system.md +3 -3
  94. package/framework/templates/docs/proposal.md +11 -0
  95. package/framework/templates/docs/spec.md +15 -0
  96. package/framework/templates/docs/ui-mockups.md +115 -98
  97. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentApi.cs +1 -48
  98. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentFactory.cs +3 -79
  99. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentFactoryOptions.cs +1 -70
  100. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentSpec.cs +1 -47
  101. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentSpecOptions.cs +2 -99
  102. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/AiKitAssemblyMarker.cs +0 -6
  103. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Conversation/ConversationStore.cs +3 -88
  104. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Conversation/IConversationStore.cs +1 -41
  105. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Conversation/InMemoryConversationStore.cs +0 -21
  106. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/CountingChatClient.cs +1 -25
  107. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalAssertion.cs +2 -63
  108. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalCacheMissException.cs +0 -30
  109. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalCacheOptions.cs +1 -52
  110. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalCacheStamp.cs +3 -78
  111. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalFixture.cs +4 -109
  112. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalMode.cs +0 -22
  113. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalQualityEvaluators.cs +1 -36
  114. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalScenario.cs +2 -57
  115. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalSummary.cs +0 -75
  116. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/OfflineChatClient.cs +2 -59
  117. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/ShortPathResponseCache.cs +4 -111
  118. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/IMediaGenerator.cs +0 -34
  119. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/IMediaProvider.cs +1 -57
  120. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaArtifact.cs +0 -19
  121. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaDisclosure.cs +2 -74
  122. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaGenerator.cs +2 -33
  123. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaIdempotencyKey.cs +1 -43
  124. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaJob.cs +1 -96
  125. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaJobId.cs +0 -13
  126. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaJobState.cs +0 -27
  127. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaPricing.cs +0 -33
  128. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaProvenance.cs +0 -31
  129. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaRequest.cs +3 -75
  130. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaResult.cs +0 -42
  131. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaReview.cs +0 -39
  132. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaSeams.cs +1 -55
  133. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaStore.cs +3 -59
  134. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MorphMedia.cs +0 -46
  135. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/Providers/GoogleImageProvider.cs +3 -28
  136. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/Providers/OpenAIImageProvider.cs +7 -61
  137. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/Providers/VeoVideoProvider.cs +2 -37
  138. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Middleware/IUsageCostSink.cs +1 -23
  139. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Middleware/UsageCostMiddleware.cs +2 -82
  140. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Middleware/UsageCostRecord.cs +1 -68
  141. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Observability/MorphOpenTelemetryExtensions.cs +1 -62
  142. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Observability/MorphTelemetryOptions.cs +2 -62
  143. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/IProviderClientFactory.cs +1 -25
  144. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/ModelAlias.cs +0 -124
  145. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/ModelRegistry.cs +1 -73
  146. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Rag/DeltaIngest.cs +1 -66
  147. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Rag/DeltaPlan.cs +0 -41
  148. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/AgentFactoryTests.cs +2 -112
  149. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/AgentSpecTests.cs +2 -43
  150. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/ConversationStoreTests.cs +5 -110
  151. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/DeltaIngestTests.cs +7 -103
  152. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalAssertionTests.cs +1 -42
  153. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalCacheMissTests.cs +1 -30
  154. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalCacheStampTests.cs +1 -19
  155. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalFailuresTests.cs +0 -48
  156. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalFixtureTests.cs +1 -29
  157. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalRefreshTests.cs +2 -38
  158. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalWorkspace.cs +1 -14
  159. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/KitEvals.cs +4 -69
  160. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/OfflineChatClientTests.cs +1 -10
  161. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeChatClient.cs +1 -57
  162. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeChatClientTests.cs +0 -26
  163. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeClock.cs +1 -19
  164. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeMediaProvider.cs +0 -32
  165. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeProviderClientFactory.cs +0 -14
  166. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeUsageCostSink.cs +0 -24
  167. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/InMemoryMediaStore.cs +0 -32
  168. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaAccessibilityTests.cs +0 -37
  169. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaAliasIsNotAnAgentTests.cs +0 -24
  170. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaDisclosureTests.cs +0 -78
  171. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaGeneratorTests.cs +0 -53
  172. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaIdempotencyKeyTests.cs +0 -44
  173. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaJobStateTests.cs +0 -44
  174. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaReviewTests.cs +0 -19
  175. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaStoreTests.cs +0 -24
  176. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/ModelRegistryTests.cs +6 -48
  177. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/SkeletonSmokeTests.cs +1 -13
  178. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/TelemetryTests.cs +2 -71
  179. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/UsageCostMiddlewareTests.cs +5 -100
  180. package/framework/templates/feature/decisions.md +124 -122
  181. package/framework/templates/frontend/nextjs/Dockerfile.nextjs.hbs +39 -43
  182. package/framework/templates/frontend/nextjs/env.mjs.hbs +29 -32
  183. package/framework/templates/frontend/nextjs/page.tsx.hbs +21 -22
  184. package/framework/templates/frontend/nextjs/use-feature.ts.hbs +42 -54
  185. package/framework/templates/integrations/asaas-client.cs +332 -387
  186. package/framework/templates/integrations/asaas-webhook.cs +290 -351
  187. package/framework/templates/integrations/neon-auth-config.cs +9 -105
  188. package/framework/templates/project/validate.js +97 -120
  189. package/package.json +2 -2
  190. package/src/commands/dashboard/dashboard.js +80 -53
  191. package/src/commands/graph/affected.js +4 -3
  192. package/src/commands/graph/build.js +109 -18
  193. package/src/commands/graph/explain.js +8 -5
  194. package/src/commands/graph/status.js +14 -8
  195. package/src/commands/mcp/mcp-setup.js +37 -9
  196. package/src/commands/observability/cost.js +148 -18
  197. package/src/commands/project/doctor.js +85 -15
  198. package/src/commands/project/init.js +6 -3
  199. package/src/commands/project/install-plugin-cmd.js +1 -1
  200. package/src/commands/project/update.js +30 -0
  201. package/src/commands/project/worktree.js +73 -90
  202. package/src/commands/state/advance.js +18 -0
  203. package/src/commands/state/approve.js +15 -0
  204. package/src/commands/state/archive.js +62 -9
  205. package/src/commands/state/finish.js +62 -14
  206. package/src/commands/state/gate-check.js +8 -1
  207. package/src/commands/state/gate-decision.js +8 -4
  208. package/src/commands/state/report-check.js +90 -9
  209. package/src/commands/state/usecase.js +66 -0
  210. package/src/commands/tasks/dag.js +17 -2
  211. package/src/commands/validation/validate-feature.js +6 -1
  212. package/src/commands/validation/verify.js +20 -1
  213. package/src/dashboard/app/App.jsx +80 -16
  214. package/src/dashboard/app/components/ActivityFeed.jsx +172 -0
  215. package/src/dashboard/app/components/CommandPalette.jsx +155 -0
  216. package/src/dashboard/app/components/DetailPanel.jsx +47 -11
  217. package/src/dashboard/app/components/EntityLink.jsx +55 -0
  218. package/src/dashboard/app/components/ErrorBoundary.jsx +54 -0
  219. package/src/dashboard/app/components/FlowGraph.jsx +19 -17
  220. package/src/dashboard/app/components/KnowledgeMarkdown.jsx +53 -0
  221. package/src/dashboard/app/components/Markdown.jsx +47 -0
  222. package/src/dashboard/app/components/PhaseRail.jsx +111 -0
  223. package/src/dashboard/app/components/SearchFilter.jsx +40 -22
  224. package/src/dashboard/app/components/Sidebar.jsx +146 -37
  225. package/src/dashboard/app/hooks/useMorphQuery.js +54 -0
  226. package/src/dashboard/app/hooks/usePref.js +38 -0
  227. package/src/dashboard/app/lib/format.js +69 -0
  228. package/src/dashboard/app/lib/phases.js +56 -0
  229. package/src/dashboard/app/lib/project.js +50 -0
  230. package/src/dashboard/app/pages/Activity.jsx +24 -0
  231. package/src/dashboard/app/pages/AgentMap.jsx +371 -370
  232. package/src/dashboard/app/pages/FeatureDetail.jsx +604 -0
  233. package/src/dashboard/app/pages/Features.jsx +400 -0
  234. package/src/dashboard/app/pages/Guide.jsx +146 -0
  235. package/src/dashboard/app/pages/HookInspector.jsx +357 -322
  236. package/src/dashboard/app/pages/PhasePipeline.jsx +277 -408
  237. package/src/dashboard/app/pages/Portfolio.jsx +156 -0
  238. package/src/dashboard/app/pages/Rubrics.jsx +101 -0
  239. package/src/dashboard/app/pages/RulesMap.jsx +300 -273
  240. package/src/dashboard/app/pages/SkillsLibrary.jsx +483 -366
  241. package/src/dashboard/app/pages/Standards.jsx +407 -332
  242. package/src/dashboard/app/styles/index.css +187 -47
  243. package/src/dashboard/index.html +14 -12
  244. package/src/dashboard/package.json +32 -24
  245. package/src/dashboard/scanner/activity-scanner.js +351 -0
  246. package/src/dashboard/scanner/agents-scanner.js +63 -1
  247. package/src/dashboard/scanner/docs-scanner.js +59 -0
  248. package/src/dashboard/scanner/feature-status.js +10 -0
  249. package/src/dashboard/scanner/features-scanner.js +360 -0
  250. package/src/dashboard/scanner/hooks-scanner.js +8 -1
  251. package/src/dashboard/scanner/index.js +14 -7
  252. package/src/dashboard/scanner/phases-scanner.js +14 -15
  253. package/src/dashboard/scanner/portfolio.js +153 -0
  254. package/src/dashboard/scanner/project-api.js +195 -0
  255. package/src/dashboard/scanner/rules-scanner.js +42 -37
  256. package/src/dashboard/scanner/skills-scanner.js +105 -43
  257. package/src/dashboard/scanner/standards-scanner.js +4 -1
  258. package/src/dashboard/vite-plugin-morph-data.js +134 -17
  259. package/src/lib/ai/comment-stripper.js +101 -4
  260. package/src/lib/detectors/claude-config-detector.js +2 -7
  261. package/src/lib/detectors/index.js +5 -1
  262. package/src/lib/evaluator-report.js +47 -0
  263. package/src/lib/generators/handoff-generator.js +4 -18
  264. package/src/lib/generators/settings-generator.js +208 -209
  265. package/src/lib/git/default-branch.js +52 -0
  266. package/src/lib/git/tree-fingerprint.js +58 -0
  267. package/src/lib/git/tree-state.js +167 -0
  268. package/src/lib/graph/god-nodes.js +12 -3
  269. package/src/lib/graph/graph-health.js +48 -2
  270. package/src/lib/graph/graph-loader.js +99 -25
  271. package/src/lib/graph/graphifyignore.js +145 -28
  272. package/src/lib/graph/sanitize.js +117 -11
  273. package/src/lib/installers/mcp-installer.js +724 -719
  274. package/src/lib/installers/memory-installer.js +1 -2
  275. package/src/lib/projects/registry.js +154 -0
  276. package/src/lib/risk-detector.js +90 -0
  277. package/src/lib/specs/acervo-format.js +70 -0
  278. package/src/lib/specs/acervo-lifecycle.js +264 -0
  279. package/src/lib/specs/adr-index.js +169 -0
  280. package/src/lib/specs/revocations.js +156 -0
  281. package/src/lib/specs/screen-checklist.js +357 -0
  282. package/src/lib/specs/usecase-check.js +20 -16
  283. package/src/lib/specs/usecase-promoter.js +103 -38
  284. package/src/lib/specs/usecase-registry.js +7 -7
  285. package/src/lib/tasks/tasks-json-parser.js +2 -3
  286. package/src/lib/telemetry/metrics.js +386 -0
  287. package/src/lib/telemetry/telemetry-reader.js +161 -51
  288. package/src/lib/telemetry/verify-event.js +79 -0
  289. package/src/lib/validators/code-budget/code-budget-validator.js +200 -0
  290. package/src/lib/validators/code-budget/comment-scanner.js +75 -0
  291. package/src/lib/validators/code-budget/index.js +9 -0
  292. package/src/lib/validators/reuse/reuse-validator.js +4 -2
  293. package/src/lib/validators/reuse/symbol-scanner.js +27 -22
  294. package/src/lib/validators/validation-runner.js +21 -5
  295. package/src/lib/validators/verify-runner.js +11 -0
  296. package/src/lib/worktree/finish-backup.js +739 -0
  297. package/src/lib/worktree/ignored-triage.js +15 -8
  298. package/src/lib/worktree/worktree-audit.js +5 -0
  299. package/src/scripts/install-plugin.js +1 -1
  300. package/src/scripts/setup-infra.js +31 -22
  301. package/src/utils/agents-installer.js +338 -24
  302. package/src/utils/banner.js +1 -1
  303. package/src/utils/hooks-installer.js +75 -15
  304. package/src/commands/index.js +0 -13
  305. package/src/commands/project/checkpoint.js +0 -209
  306. package/src/core/state/phase-state-machine.js +0 -197
  307. package/src/dashboard/app/hooks/useDetailPanel.js +0 -15
  308. package/src/dashboard/app/pages/LiveState.jsx +0 -206
  309. package/src/dashboard/scanner/squads-scanner.js +0 -13
  310. package/src/dashboard/scanner/state-scanner.js +0 -85
  311. package/src/lib/generators/index.js +0 -10
  312. package/src/lib/hooks/hook-executor.js +0 -167
  313. package/src/lib/validators/checkpoint-persistence.js +0 -39
  314. package/src/lib/validators/shared/emit-validator-dispatch.js +0 -14
  315. package/src/lib/validators/shared/index.js +0 -12
  316. package/src/lib/validators/shared/issue-counter.js +0 -14
  317. package/src/lib/validators/shared/result-formatter.js +0 -124
@@ -0,0 +1,739 @@
1
+ /**
2
+ * finish-backup.js — the backups `finish` and `worktree remove` leave under
3
+ * `.git/morph-spec/finish-backup/`: how one is written, how they are listed,
4
+ * and how long each one lives.
5
+ *
6
+ * A backup exists so that removing a worktree (or shedding the root-side
7
+ * scaffold) never destroys the only exemplar of a file. That is a reason to keep
8
+ * the last ones, not all of them, and a worktree's `.env.e2e` — the e2e stack's
9
+ * credentials — has no business living inside `.git/` indefinitely. So every
10
+ * backup is subject to a retention policy, on by default:
11
+ *
12
+ * maxCount — the newest this many backups per namespace always stay
13
+ * maxAgeDays — no age cut unless configured: a quiet month must never
14
+ * empty the backups of the last features closed
15
+ * secretMaxAgeDays — `.env*` files inside a backup go away after this
16
+ *
17
+ * `config.json → finishBackup.retention` can change the numbers or turn the
18
+ * whole thing off (`false`); a project without the key gets the defaults, and a
19
+ * value that cannot be read skips the prune for that run.
20
+ *
21
+ * A backup without its manifest was written before this retention existed. The
22
+ * automatic prune (end of `finish`, end of `worktree remove`) never deletes it,
23
+ * never strips its `.env*` and does not count it against the budget — it only
24
+ * reports how many wait (`legacyPending`). The first prune of those is the
25
+ * explicit `doctor --worktrees --prune`, which ranks them with the rest and
26
+ * gives each one it keeps a manifest, so from then on they are ordinary backups.
27
+ *
28
+ * Layout, and what counts as ONE backup:
29
+ *
30
+ * {feature}-{stamp}/ the feature scope the shed discards
31
+ * {feature}-{stamp}-worktree-ignored/ the worktree's exclusive gitignored
32
+ * {folder}.manifest.json sidecar: what was copied, the secrets
33
+ * task/{name}-{stamp}-worktree-ignored/ a namespace (task isolation trees)
34
+ *
35
+ * One finish writes both folders with the SAME stamp, so they are one backup —
36
+ * the unit the count applies to; a folder with no same-stamp sibling counts
37
+ * alone. The `task/` namespace has its own budget: a fan-out of twenty
38
+ * sub-agent worktrees must not evict the backup of the feature finished a
39
+ * minute earlier. The backup the calling run just wrote is never pruned by it.
40
+ *
41
+ * Deletion is PROVE-or-DECLINE, as everywhere else near a worktree: a backup
42
+ * holding a reparse point is never removed, links are never descended, and
43
+ * nothing outside the backup directory is touched. Not because `rmSync` would
44
+ * follow a junction — it unlinks the link itself; what once emptied a primary
45
+ * root was `git worktree remove` — but because a link inside a backup is not
46
+ * something this code put there. A first-level entry that is neither a backup
47
+ * nor `task/` is left alone, even empty — it is not ours to judge.
48
+ */
49
+
50
+ import {
51
+ cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync,
52
+ rmdirSync, rmSync, unlinkSync,
53
+ } from 'fs';
54
+ import { dirname, join, relative, isAbsolute } from 'path';
55
+ import { isDisposableBuildArtifact } from './ignored-triage.js';
56
+ import { atomicWriteJson, CONFIG_FILE_NAME } from '../../core/state/state-manager.js';
57
+ import { scanForBlockers } from '../../../framework/hooks/shared/reparse-scan.js';
58
+
59
+ export const FINISH_BACKUP_RETENTION_DEFAULTS = Object.freeze({
60
+ maxCount: 20,
61
+ maxAgeDays: null,
62
+ secretMaxAgeDays: 7,
63
+ });
64
+
65
+ /** The keys whose `null` means "no limit" rather than an invalid value. */
66
+ const NULLABLE_RETENTION_KEYS = new Set(['maxAgeDays']);
67
+
68
+ const DAY_MS = 86_400_000;
69
+ const WORKTREE_IGNORED_SUFFIX = '-worktree-ignored';
70
+ const MANIFEST_SUFFIX = '.manifest.json';
71
+ const NAME_RE = /^(.+)-(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z(-worktree-ignored)?$/;
72
+ /** How many names a payload carries; the counts are always complete. */
73
+ const SAMPLE = 5;
74
+
75
+ /** @param {string} root - the primary root */
76
+ export function morphSpecStoreDir(root) {
77
+ return join(root, '.git', 'morph-spec');
78
+ }
79
+
80
+ /** @param {string} root - the primary root */
81
+ export function finishBackupDir(root) {
82
+ return join(morphSpecStoreDir(root), 'finish-backup');
83
+ }
84
+
85
+ /** The stamp in a backup's name — filesystem-safe ISO-8601. */
86
+ export function newBackupStamp(date = new Date()) {
87
+ return date.toISOString().replace(/[:.]/g, '-');
88
+ }
89
+
90
+ /** `.env`, `.env.e2e`, `.env.local`… — the files that hold a stack's secrets. */
91
+ export function isSecretFileName(name) {
92
+ return name.startsWith('.env');
93
+ }
94
+
95
+ /**
96
+ * @param {string} name - one directory name under finish-backup/ (or under a namespace)
97
+ * @returns {{ base: string, feature: string, stamp: string,
98
+ * kind: 'feature-scope'|'worktree-ignored', createdAt: number }|null}
99
+ */
100
+ export function parseBackupName(name) {
101
+ const m = NAME_RE.exec(name);
102
+ if (!m) return null;
103
+ const [, feature, date, hh, mm, ss, ms, suffix] = m;
104
+ const createdAt = Date.parse(`${date}T${hh}:${mm}:${ss}.${ms}Z`);
105
+ if (Number.isNaN(createdAt)) return null;
106
+ return {
107
+ base: suffix ? name.slice(0, -WORKTREE_IGNORED_SUFFIX.length) : name,
108
+ feature,
109
+ stamp: `${date}T${hh}-${mm}-${ss}-${ms}Z`,
110
+ kind: suffix ? 'worktree-ignored' : 'feature-scope',
111
+ createdAt,
112
+ };
113
+ }
114
+
115
+ function stampToIso(stamp) {
116
+ const parsed = parseBackupName(`x-${stamp}`);
117
+ return new Date(parsed ? parsed.createdAt : Date.now()).toISOString();
118
+ }
119
+
120
+ const RETENTION_KEYS = Object.keys(FINISH_BACKUP_RETENTION_DEFAULTS);
121
+ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
122
+ const shown = (v) => JSON.stringify(v) ?? String(v);
123
+ const skippedPolicy = (reason) => ({ enabled: false, skipped: reason, ...FINISH_BACKUP_RETENTION_DEFAULTS });
124
+
125
+ /**
126
+ * The policy in force, from a parsed config.json. A config without the key
127
+ * gets the defaults (retention on) and an explicit `false` turns it off. What
128
+ * this cannot recognise SKIPS the prune for the run instead: the operation
129
+ * deletes, and a value nobody can read — `{ enabled: false }`, `"false"`, a
130
+ * `maxCount` of `0` — may well have been meant as "keep everything".
131
+ *
132
+ * @param {object|null} config - null when there is no config.json at all
133
+ * @returns {{ enabled: boolean, skipped?: string, maxCount: number, maxAgeDays: number|null, secretMaxAgeDays: number }}
134
+ */
135
+ export function resolveBackupRetention(config) {
136
+ const d = FINISH_BACKUP_RETENTION_DEFAULTS;
137
+ const skip = skippedPolicy;
138
+ const section = config?.finishBackup;
139
+ if (section === undefined) return { enabled: true, ...d };
140
+ if (!isPlainObject(section)) return skip(`finishBackup não é um objeto (${shown(section)})`);
141
+ const raw = section.retention;
142
+ if (raw === undefined) return { enabled: true, ...d };
143
+ if (raw === false) return { enabled: false, ...d };
144
+ if (!isPlainObject(raw)) {
145
+ return skip(`finishBackup.retention não reconhecida (${shown(raw)}) — use false ou { ${RETENTION_KEYS.join(', ')} }`);
146
+ }
147
+ const policy = { enabled: true, ...d };
148
+ for (const [key, value] of Object.entries(raw)) {
149
+ if (!RETENTION_KEYS.includes(key)) return skip(`finishBackup.retention.${key} não existe — as chaves são ${RETENTION_KEYS.join(', ')}`);
150
+ if (value === null && NULLABLE_RETENTION_KEYS.has(key)) { policy[key] = null; continue; }
151
+ if (!Number.isInteger(value) || value < 1) {
152
+ return skip(`finishBackup.retention.${key} inválido (${shown(value)}) — inteiro ≥ 1${NULLABLE_RETENTION_KEYS.has(key) ? ' ou null (sem limite)' : ''}`);
153
+ }
154
+ policy[key] = value;
155
+ }
156
+ return policy;
157
+ }
158
+
159
+ /**
160
+ * Unlike `readMorphConfig`, a config.json that exists and does not parse is
161
+ * not "no config": the defaults would turn on a prune its author may have
162
+ * switched off in the part that failed to parse.
163
+ *
164
+ * @param {string} root
165
+ */
166
+ export function readBackupRetention(root) {
167
+ const path = join(root, CONFIG_FILE_NAME);
168
+ if (!existsSync(path)) return resolveBackupRetention(null);
169
+ let config;
170
+ try {
171
+ config = JSON.parse(readFileSync(path, 'utf8').replace(/^\uFEFF/, ''));
172
+ } catch (err) {
173
+ return skippedPolicy(`config.json ilegível (${String(err?.message || err).slice(0, 80)})`);
174
+ }
175
+ return resolveBackupRetention(config);
176
+ }
177
+
178
+ function isRealDir(path) {
179
+ try {
180
+ return lstatSync(path).isDirectory();
181
+ } catch {
182
+ return false;
183
+ }
184
+ }
185
+
186
+ function isRealFile(path) {
187
+ try {
188
+ return lstatSync(path).isFile();
189
+ } catch {
190
+ return false;
191
+ }
192
+ }
193
+
194
+ function isStrictlyInside(parent, child) {
195
+ const rel = relative(parent, child);
196
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
197
+ }
198
+
199
+ /**
200
+ * Every `.env*` REAL file under `dir`, relative to it. Links are neither
201
+ * followed nor reported: a link is not bytes this backup holds.
202
+ *
203
+ * @param {string} dir
204
+ * @returns {string[]}
205
+ */
206
+ export function secretFilesIn(dir) {
207
+ const out = [];
208
+ const stack = [''];
209
+ while (stack.length) {
210
+ const rel = stack.pop();
211
+ let entries;
212
+ try {
213
+ entries = readdirSync(join(dir, rel), { withFileTypes: true });
214
+ } catch {
215
+ continue;
216
+ }
217
+ for (const e of entries) {
218
+ const childRel = rel ? `${rel}/${e.name}` : e.name;
219
+ if (e.isSymbolicLink()) continue;
220
+ if (e.isDirectory()) stack.push(childRel);
221
+ else if (e.isFile() && isSecretFileName(e.name)) out.push(childRel);
222
+ }
223
+ }
224
+ return out.sort();
225
+ }
226
+
227
+ /**
228
+ * Bytes and files under `dir`, by lstat — a link counts as itself, never as
229
+ * what it points at. `null` when the walk could not start.
230
+ *
231
+ * @param {string} dir
232
+ * @returns {{ bytes: number, files: number }|null}
233
+ */
234
+ export function measureDir(dir) {
235
+ if (!isRealDir(dir)) return null;
236
+ let bytes = 0;
237
+ let files = 0;
238
+ const stack = [dir];
239
+ while (stack.length) {
240
+ const current = stack.pop();
241
+ let entries;
242
+ try {
243
+ entries = readdirSync(current, { withFileTypes: true });
244
+ } catch {
245
+ continue;
246
+ }
247
+ for (const e of entries) {
248
+ const p = join(current, e.name);
249
+ if (e.isDirectory() && !e.isSymbolicLink()) { stack.push(p); continue; }
250
+ try { bytes += lstatSync(p).size; files += 1; } catch { /* vanished */ }
251
+ }
252
+ }
253
+ return { bytes, files };
254
+ }
255
+
256
+ export function manifestPathFor(backupPath) {
257
+ return `${backupPath}${MANIFEST_SUFFIX}`;
258
+ }
259
+
260
+ function readManifest(backupPath) {
261
+ try {
262
+ return JSON.parse(readFileSync(manifestPathFor(backupPath), 'utf8'));
263
+ } catch {
264
+ return null;
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Bookkeeping, never a precondition: a backup whose bytes are on disk is a
270
+ * backup, with or without its manifest. One whose manifest failed to be written
271
+ * reads as legacy, which is the safe side — the automatic prune leaves it alone.
272
+ */
273
+ function writeManifest(backupPath, manifest) {
274
+ try {
275
+ atomicWriteJson(manifestPathFor(backupPath), { version: 1, ...manifest });
276
+ } catch { /* fail-open */ }
277
+ }
278
+
279
+ /**
280
+ * Copy the worktree's exclusive gitignored leftovers out before the removal
281
+ * deletes them. Caches are excluded at every depth, not only as top-level
282
+ * entries: git reports an ignored directory as ONE entry when the directory
283
+ * itself matches a pattern, so an exclusive `apps/x/` would otherwise drag its
284
+ * `node_modules/` and `.next/` along. They are regenerable, and the removal
285
+ * deletes them either way.
286
+ *
287
+ * @param {{ worktreePath: string, root: string, feature: string, rels: string[], stamp?: string }} args
288
+ * @returns {string|null} the backup path, or null if ANY copy failed (the caller
289
+ * must then decline the removal — a partial backup authorizes nothing) or if
290
+ * none of `rels` exists anymore
291
+ */
292
+ export function writeWorktreeIgnoredBackup({ worktreePath, root, feature, rels, stamp = newBackupStamp() }) {
293
+ const dest = join(finishBackupDir(root), `${feature}-${stamp}${WORKTREE_IGNORED_SUFFIX}`);
294
+ const copied = [];
295
+ const excludedCaches = [];
296
+ const relOf = (abs) => relative(worktreePath, abs).replace(/\\/g, '/');
297
+ try {
298
+ for (const rel of rels) {
299
+ const src = join(worktreePath, rel);
300
+ if (!existsSync(src)) continue; // a directory entry already covered by its children
301
+ if (isDisposableBuildArtifact(relOf(src))) {
302
+ excludedCaches.push(relOf(src));
303
+ continue;
304
+ }
305
+ const target = join(dest, rel);
306
+ mkdirSync(dirname(target), { recursive: true });
307
+ cpSync(src, target, {
308
+ recursive: true,
309
+ filter: (from) => {
310
+ const r = relOf(from);
311
+ if (!isDisposableBuildArtifact(r)) return true;
312
+ excludedCaches.push(r);
313
+ return false;
314
+ },
315
+ });
316
+ copied.push(rel);
317
+ }
318
+ if (!copied.length && !excludedCaches.length) return null;
319
+ mkdirSync(dest, { recursive: true });
320
+ } catch {
321
+ return null;
322
+ }
323
+ writeManifest(dest, {
324
+ kind: 'worktree-ignored',
325
+ feature,
326
+ createdAt: stampToIso(stamp),
327
+ source: worktreePath.replace(/\\/g, '/'),
328
+ copied,
329
+ excludedCaches,
330
+ secrets: secretFilesIn(dest),
331
+ });
332
+ return dest;
333
+ }
334
+
335
+ /**
336
+ * Copy the feature's root-side scope to a backup OUTSIDE the working tree, so
337
+ * the shed can never be the reason something is lost.
338
+ *
339
+ * Lives under `.git/` on purpose: it is not a working-tree path, so it can never
340
+ * dirty `git status` (which the very next validation reads) and is never merged,
341
+ * committed, or cleaned by `git clean`.
342
+ *
343
+ * @param {{ root: string, feature: string, stamp?: string }} args
344
+ * @returns {string|null} absolute backup path, or null if the copy failed
345
+ */
346
+ export function writeFeatureScopeBackup({ root, feature, stamp = newBackupStamp() }) {
347
+ const src = join(root, '.morph', 'features', feature);
348
+ if (!existsSync(src)) return null;
349
+ const dest = join(finishBackupDir(root), `${feature}-${stamp}`);
350
+ try {
351
+ mkdirSync(dirname(dest), { recursive: true });
352
+ cpSync(src, dest, { recursive: true });
353
+ } catch {
354
+ return null; // caller MUST treat this as "do not shed"
355
+ }
356
+ writeManifest(dest, {
357
+ kind: 'feature-scope',
358
+ feature,
359
+ createdAt: stampToIso(stamp),
360
+ source: src.replace(/\\/g, '/'),
361
+ secrets: secretFilesIn(dest),
362
+ });
363
+ return dest;
364
+ }
365
+
366
+ /**
367
+ * The first-level directories that hold backups instead of being one: `task/`
368
+ * is where the `task/{name}` key of a task isolation tree (see
369
+ * `envKeyForWorktree` in worktree.js) lands. Any other first-level directory
370
+ * that is not a backup was put there by someone else — never listed, never
371
+ * removed, not even empty.
372
+ */
373
+ const BACKUP_NAMESPACES = new Set(['task']);
374
+
375
+ /**
376
+ * Every backup folder on disk: the first level, plus one level down inside a
377
+ * namespace of BACKUP_NAMESPACES. A link is never descended, at either level.
378
+ *
379
+ * @param {string} root
380
+ * `managed` is whether the folder has its manifest — the one criterion that
381
+ * tells a backup of this retention from one written before it.
382
+ *
383
+ * @returns {Array<{ name: string, path: string, namespace: string, set: string,
384
+ * feature: string, stamp: string, kind: string, createdAt: number, managed: boolean }>}
385
+ */
386
+ export function listFinishBackups(root) {
387
+ const base = finishBackupDir(root);
388
+ if (!isRealDir(base)) return [];
389
+ const out = [];
390
+ const visit = (dir, namespace) => {
391
+ let entries;
392
+ try {
393
+ entries = readdirSync(dir, { withFileTypes: true });
394
+ } catch {
395
+ return;
396
+ }
397
+ for (const e of entries) {
398
+ const path = join(dir, e.name);
399
+ if (!isRealDir(path)) continue; // manifests, temp files and links
400
+ const parsed = parseBackupName(e.name);
401
+ if (parsed) {
402
+ const prefix = namespace ? `${namespace}/` : '';
403
+ out.push({
404
+ name: `${prefix}${e.name}`,
405
+ path,
406
+ namespace,
407
+ set: `${prefix}${parsed.base}`,
408
+ feature: `${prefix}${parsed.feature}`,
409
+ stamp: parsed.stamp,
410
+ kind: parsed.kind,
411
+ createdAt: parsed.createdAt,
412
+ managed: isRealFile(manifestPathFor(path)),
413
+ });
414
+ } else if (!namespace && BACKUP_NAMESPACES.has(e.name)) {
415
+ visit(path, e.name);
416
+ }
417
+ }
418
+ };
419
+ visit(base, '');
420
+ return out;
421
+ }
422
+
423
+ /**
424
+ * Which backups the policy removes, and which it keeps but strips of `.env*`.
425
+ * Pure: the caller supplies the listing and the clock.
426
+ *
427
+ * Two kinds of set are never removed. The ones stamped with `protectStamps` —
428
+ * the backup the calling run just wrote: a retention that runs after a backup
429
+ * exists to make room for it, not to take it. And the ones stamped AFTER `now`
430
+ * (a clock that ran ahead and was corrected): their age is unknowable, so they
431
+ * stay and do not take a place in the count — otherwise a handful of them
432
+ * would push every new backup out the moment it is written.
433
+ *
434
+ * A set is legacy unless EVERY folder in it has its manifest. Without
435
+ * `includeLegacy` (the automatic prune) legacy sets are out of the plan
436
+ * altogether — no removal, no strip, no place in the count — and only counted
437
+ * in `legacy`. With it (the explicit prune) they are ranked like the rest, and
438
+ * the ones kept are listed in `adopt`.
439
+ *
440
+ * @param {ReturnType<typeof listFinishBackups>} backups
441
+ * @param {ReturnType<typeof resolveBackupRetention>} retention
442
+ * @param {number} now - epoch ms
443
+ * @param {{ protectStamps?: string[], includeLegacy?: boolean }} [opts]
444
+ * @returns {{ sets: number, legacy: number, remove: object[], stripSecrets: object[], adopt: object[] }}
445
+ * each entry a `{ set, namespace, stamp, createdAt, managed, members }`
446
+ */
447
+ export function planBackupRetention(backups, retention, now, { protectStamps = [], includeLegacy = false } = {}) {
448
+ const sets = new Map();
449
+ for (const b of backups) {
450
+ const s = sets.get(b.set) ?? { set: b.set, namespace: b.namespace, stamp: b.stamp, createdAt: b.createdAt, managed: true, members: [] };
451
+ s.createdAt = Math.min(s.createdAt, b.createdAt);
452
+ s.managed = s.managed && b.managed;
453
+ s.members.push(b);
454
+ sets.set(b.set, s);
455
+ }
456
+ const all = [...sets.values()];
457
+ const plan = { sets: sets.size, legacy: all.filter((s) => !s.managed).length, remove: [], stripSecrets: [], adopt: [] };
458
+ if (!retention.enabled) return plan;
459
+
460
+ const isProtected = (s) => protectStamps.includes(s.stamp);
461
+ const byNamespace = new Map();
462
+ for (const s of all) {
463
+ if (!s.managed && !includeLegacy) continue;
464
+ if (s.createdAt > now) {
465
+ if (!s.managed) plan.adopt.push(s);
466
+ continue;
467
+ }
468
+ const list = byNamespace.get(s.namespace) ?? [];
469
+ list.push(s);
470
+ byNamespace.set(s.namespace, list);
471
+ }
472
+ const maxAgeMs = retention.maxAgeDays == null ? Infinity : retention.maxAgeDays * DAY_MS;
473
+ for (const list of byNamespace.values()) {
474
+ list.sort((a, b) => b.createdAt - a.createdAt || (a.set < b.set ? 1 : -1));
475
+ list.forEach((s, index) => {
476
+ const age = now - s.createdAt;
477
+ const dropped = index >= retention.maxCount || age > maxAgeMs;
478
+ if (dropped && !isProtected(s)) {
479
+ plan.remove.push(s);
480
+ return;
481
+ }
482
+ if (age > retention.secretMaxAgeDays * DAY_MS) plan.stripSecrets.push(s);
483
+ if (!s.managed) plan.adopt.push(s);
484
+ });
485
+ }
486
+ return plan;
487
+ }
488
+
489
+ /**
490
+ * Apply the policy: remove what it drops, strip `.env*` from what it keeps past
491
+ * the short retention, and record the stripped names in the manifest. A backup
492
+ * whose removal is declined (a reparse point inside, a locked file) is stripped
493
+ * instead: the policy wanted it gone, and its secrets are the part that must not
494
+ * linger. A policy that could not be read (`skipped`) prunes nothing. Never
495
+ * throws — a retention that fails must never be the reason a `finish` fails.
496
+ *
497
+ * Legacy backups (no manifest) are touched only with `includeLegacy` — the
498
+ * explicit `doctor --worktrees --prune`; otherwise they are counted in
499
+ * `legacyPending`. The explicit prune writes a manifest into every legacy
500
+ * backup it keeps (`adopted`), which makes it an ordinary backup from then on.
501
+ *
502
+ * @param {string} root
503
+ * @param {{ now?: number, retention?: ReturnType<typeof resolveBackupRetention>,
504
+ * protectStamps?: string[], includeLegacy?: boolean }} [opts]
505
+ * `protectStamps`: the stamp(s) the calling run wrote its backup under
506
+ * @returns {{ enabled: boolean, skipped?: string, removed: string[],
507
+ * secretsStripped: Array<{ backup: string, files: string[] }>, declined: Array<{ backup: string, reason: string }>,
508
+ * legacyPending: number, adopted: string[] }}
509
+ */
510
+ export function applyBackupRetention(root, { now = Date.now(), retention, protectStamps = [], includeLegacy = false } = {}) {
511
+ const result = { enabled: true, removed: [], secretsStripped: [], declined: [], legacyPending: 0, adopted: [] };
512
+ try {
513
+ const policy = retention ?? readBackupRetention(root);
514
+ result.enabled = policy.enabled;
515
+ if (policy.skipped) result.skipped = policy.skipped;
516
+ if (!policy.enabled) return result;
517
+ const base = finishBackupDir(root);
518
+ if (!isRealDir(base)) return result;
519
+ const plan = planBackupRetention(listFinishBackups(root), policy, now, { protectStamps, includeLegacy });
520
+ if (!includeLegacy) result.legacyPending = plan.legacy;
521
+ const toStrip = plan.stripSecrets.flatMap((s) => s.members);
522
+
523
+ for (const s of plan.remove) {
524
+ for (const m of s.members) {
525
+ if (!isStrictlyInside(base, m.path)) {
526
+ result.declined.push({ backup: m.name, reason: 'outside-backup-dir' });
527
+ continue;
528
+ }
529
+ const scan = scanForBlockers(m.path);
530
+ if (!scan.safe) {
531
+ result.declined.push({ backup: m.name, reason: `reparse-point: ${scan.blockers.slice(0, SAMPLE).join(', ')}` });
532
+ toStrip.push(m);
533
+ continue;
534
+ }
535
+ try {
536
+ rmSync(m.path, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
537
+ } catch (err) {
538
+ result.declined.push({ backup: m.name, reason: (err?.code || err?.message || 'rm-failed').toString().slice(0, 120) });
539
+ toStrip.push(m);
540
+ continue;
541
+ }
542
+ try { rmSync(manifestPathFor(m.path), { force: true }); } catch { /* the sidecar is bookkeeping */ }
543
+ result.removed.push(m.name);
544
+ }
545
+ }
546
+
547
+ for (const m of toStrip) {
548
+ const stripped = [];
549
+ for (const rel of secretFilesIn(m.path)) {
550
+ try {
551
+ unlinkSync(join(m.path, rel));
552
+ stripped.push(rel);
553
+ } catch { /* locked — the next run tries again */ }
554
+ }
555
+ if (!stripped.length) continue;
556
+ const manifest = readManifest(m.path) ?? { kind: m.kind, feature: m.feature, createdAt: new Date(m.createdAt).toISOString() };
557
+ const secrets = [...new Set([...(manifest.secrets || []), ...stripped])].sort();
558
+ writeManifest(m.path, { ...manifest, secrets, secretsStrippedAt: new Date(now).toISOString() });
559
+ result.secretsStripped.push({ backup: m.name, files: stripped });
560
+ }
561
+
562
+ for (const m of plan.adopt.flatMap((s) => s.members)) {
563
+ if (m.managed) continue;
564
+ const manifest = readManifest(m.path)
565
+ ?? { kind: m.kind, feature: m.feature, createdAt: new Date(m.createdAt).toISOString(), secrets: secretFilesIn(m.path) };
566
+ writeManifest(m.path, { ...manifest, legacy: true, adoptedAt: new Date(now).toISOString() });
567
+ result.adopted.push(m.name);
568
+ }
569
+
570
+ // A namespace emptied by the removals goes too; rmdir refuses anything else.
571
+ for (const name of BACKUP_NAMESPACES) {
572
+ const p = join(base, name);
573
+ if (isRealDir(p)) {
574
+ try { rmdirSync(p); } catch { /* not empty */ }
575
+ }
576
+ }
577
+ } catch { /* fail-open */ }
578
+ return result;
579
+ }
580
+
581
+ /**
582
+ * What a prune WOULD do now, without doing it. `includeLegacy` picks which
583
+ * prune: without it, the automatic one (what `finish --dry-run` announces);
584
+ * with it, the explicit `doctor --worktrees --prune` (what the doctor reports).
585
+ *
586
+ * @param {string} root
587
+ * @param {{ now?: number, retention?: ReturnType<typeof resolveBackupRetention>, includeLegacy?: boolean }} [opts]
588
+ * @returns {{ policy: object, sets: number, folders: number, legacy: number,
589
+ * outsideRetention: number, overdueSecrets: string[] }}
590
+ */
591
+ export function inspectFinishBackups(root, { now = Date.now(), retention, includeLegacy = false } = {}) {
592
+ const policy = retention ?? readBackupRetention(root);
593
+ const backups = listFinishBackups(root);
594
+ const plan = planBackupRetention(backups, policy, now, { includeLegacy });
595
+ const overdueSecrets = plan.stripSecrets.flatMap((s) => s.members.flatMap((m) => secretFilesIn(m.path).map((f) => `${m.name}/${f}`)));
596
+ return {
597
+ policy,
598
+ sets: plan.sets,
599
+ folders: backups.length,
600
+ legacy: plan.legacy,
601
+ outsideRetention: plan.remove.length,
602
+ overdueSecrets,
603
+ };
604
+ }
605
+
606
+ /** The sentence both the audit and the store line say about what `--prune` would do. */
607
+ function pendingParts(report, { long = false } = {}) {
608
+ const { maxCount, maxAgeDays, secretMaxAgeDays } = report.policy;
609
+ const parts = [];
610
+ if (report.legacy) {
611
+ parts.push(long
612
+ ? `${report.legacy} backup(s) sem manifesto (anteriores à retenção) — a poda automática não os toca`
613
+ : `${report.legacy} sem manifesto aguardando --prune`);
614
+ }
615
+ if (report.outsideRetention) {
616
+ const age = maxAgeDays == null ? '' : ` ou com mais de ${maxAgeDays} dias`;
617
+ parts.push(long
618
+ ? `--prune apagaria ${report.outsideRetention} backup(s) fora do teto de ${maxCount} por namespace${age}`
619
+ : `${report.outsideRetention} fora da retenção`);
620
+ }
621
+ if (report.overdueSecrets.length) {
622
+ parts.push(long
623
+ ? `${report.overdueSecrets.length} arquivo(s) .env* com mais de ${secretMaxAgeDays} dias (${report.overdueSecrets.slice(0, SAMPLE).join(', ')})`
624
+ : `${report.overdueSecrets.length} .env* com mais de ${secretMaxAgeDays} dias`);
625
+ }
626
+ return parts;
627
+ }
628
+
629
+ function formatSize(bytes) {
630
+ if (bytes < 1024) return `${bytes} B`;
631
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
632
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
633
+ }
634
+
635
+ /**
636
+ * The default `doctor`'s line about `.git/morph-spec/`: its size and the
637
+ * backups in it, in the check shape the doctor renders. The size rides in the
638
+ * NAME because an `ok` line prints only its name. `ok` or `warn`, never a
639
+ * failure; `null` — no line — when nothing was ever stored there.
640
+ *
641
+ * @param {string} root - the primary root
642
+ * @param {{ now?: number }} [opts]
643
+ * @returns {{ name: string, status: 'ok'|'warn', msg?: string }|null}
644
+ */
645
+ export function morphStoreCheck(root, { now = Date.now() } = {}) {
646
+ let store;
647
+ let report;
648
+ try {
649
+ store = measureDir(morphSpecStoreDir(root));
650
+ if (!store) return null;
651
+ report = inspectFinishBackups(root, { now, includeLegacy: true });
652
+ } catch {
653
+ return null;
654
+ }
655
+ const name = `.git/morph-spec (${formatSize(store.bytes)} · ${report.sets} backup(s) do finish)`;
656
+ if (report.policy.skipped) return { name, status: 'warn', msg: `retenção pulada: ${report.policy.skipped}` };
657
+ const pending = pendingParts(report);
658
+ if (!report.policy.enabled || !pending.length) return { name, status: 'ok' };
659
+ return { name, status: 'warn', msg: `${pending.join(', ')} — rode morph-spec doctor --worktrees --prune` };
660
+ }
661
+
662
+ /**
663
+ * The retention as an audit finding, in the shape `auditWorktrees` returns:
664
+ * what the explicit `--prune` would do, including the legacy backups only it
665
+ * prunes. `warn`, never a failure — backups waiting for a prune are
666
+ * housekeeping. A policy that could not be read is a finding too: until it is
667
+ * fixed, nothing is ever pruned.
668
+ *
669
+ * @param {string} root
670
+ * @param {{ now?: number }} [opts]
671
+ * @returns {Array<{ type: string, severity: string, detail: string, fix: string }>}
672
+ */
673
+ export function auditFinishBackups(root, opts = {}) {
674
+ let report;
675
+ try {
676
+ report = inspectFinishBackups(root, { ...opts, includeLegacy: true });
677
+ } catch {
678
+ return [];
679
+ }
680
+ const detailOf = (text) => `${finishBackupDir(root).replace(/\\/g, '/')}: ${text}`;
681
+ if (report.policy.skipped) {
682
+ if (!report.folders) return [];
683
+ return [{
684
+ type: 'finish-backup-retention',
685
+ severity: 'warn',
686
+ detail: detailOf(`retenção pulada — ${report.policy.skipped}`),
687
+ fix: 'corrija `finishBackup.retention` no config.json (false desliga; { maxCount, maxAgeDays, secretMaxAgeDays } ajusta)',
688
+ }];
689
+ }
690
+ const parts = report.policy.enabled ? pendingParts(report, { long: true }) : [];
691
+ if (!parts.length) return [];
692
+ return [{
693
+ type: 'finish-backup-retention',
694
+ severity: 'warn',
695
+ detail: detailOf(parts.join('; ')),
696
+ fix: 'rode `morph-spec doctor --worktrees --prune` — a única via que poda os backups sem manifesto; os com manifesto o próximo `finish` já poda',
697
+ }];
698
+ }
699
+
700
+ /**
701
+ * A payload-sized account of one retention run: complete counts, a short
702
+ * sample of names; `{ skipped }` when the policy could not be read. `null` when
703
+ * the run changed nothing and nothing waits for the explicit prune.
704
+ *
705
+ * @param {ReturnType<typeof applyBackupRetention>} result
706
+ * @returns {object|null}
707
+ */
708
+ export function summarizeRetention(result) {
709
+ if (!result) return null;
710
+ if (result.skipped) return { skipped: result.skipped };
711
+ const files = result.secretsStripped.reduce((n, s) => n + s.files.length, 0);
712
+ const out = {
713
+ ...(result.removed.length ? { removed: result.removed.length, removedSample: result.removed.slice(0, SAMPLE) } : {}),
714
+ ...(files ? { secretsStripped: files } : {}),
715
+ ...(result.declined.length ? { declined: result.declined.slice(0, SAMPLE) } : {}),
716
+ ...(result.adopted?.length ? { adopted: result.adopted.length } : {}),
717
+ ...(result.legacyPending
718
+ ? { legacyPending: result.legacyPending, legacyHint: 'backups sem manifesto (anteriores à retenção) só são podados por `morph-spec doctor --worktrees --prune`' }
719
+ : {}),
720
+ };
721
+ return Object.keys(out).length ? out : null;
722
+ }
723
+
724
+ /**
725
+ * The retention step every writer of backups runs after its own backup:
726
+ * apply + summarize, fail-open. `protectStamps` is the stamp that backup was
727
+ * written under — never pruned by this run.
728
+ *
729
+ * @param {string} root
730
+ * @param {{ protectStamps?: string[] }} [opts]
731
+ * @returns {object|null}
732
+ */
733
+ export function enforceBackupRetention(root, { protectStamps = [] } = {}) {
734
+ try {
735
+ return summarizeRetention(applyBackupRetention(root, { protectStamps }));
736
+ } catch {
737
+ return null;
738
+ }
739
+ }