@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.
- package/CLAUDE.md +37 -13
- package/README.md +2 -2
- package/bin/morph-spec.js +75 -49
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +6 -5
- package/framework/MORPH.md +25 -22
- package/framework/agents.json +96 -36
- package/framework/docs/CLI.md +54 -17
- package/framework/evals/rubrics/code-quality.md +5 -0
- package/framework/evals/rubrics/condensed.md +6 -2
- package/framework/evals/rubrics/contracts.md +20 -0
- package/framework/evals/rubrics/test-coverage.md +17 -6
- package/framework/hooks/README.md +9 -3
- package/framework/hooks/claude-code/core/graph-refresh-kick.js +52 -0
- package/framework/hooks/claude-code/core/telemetry-log.js +172 -87
- package/framework/hooks/claude-code/core/validate-completion.js +16 -90
- package/framework/hooks/claude-code/core/worktree-context.js +10 -0
- package/framework/hooks/shared/command-tokens.js +96 -0
- package/framework/hooks/shared/git-exclude.js +44 -0
- package/framework/hooks/shared/git-paths.js +52 -0
- package/framework/hooks/shared/graph-freshness.js +380 -0
- package/framework/hooks/shared/graph-refresh.js +416 -0
- package/framework/hooks/shared/morph-cli.js +17 -5
- package/framework/hooks/shared/redact-secrets.js +55 -0
- package/framework/hooks/shared/telemetry-logger.js +25 -2
- package/framework/hooks/shared/transcript-usage.js +80 -0
- package/framework/mcp/registry.json +33 -2
- package/framework/rubrics/backend.json +2 -2
- package/framework/rubrics/default.json +2 -2
- package/framework/rubrics/frontend.json +2 -2
- package/framework/rules/csharp-standards.md +1 -0
- package/framework/rules/infrastructure-standards.md +0 -1
- package/framework/rules/morph-workflow.md +10 -4
- package/framework/rules/nextjs-standards.md +1 -0
- package/framework/schemas/config.schema.json +41 -1
- package/framework/schemas/feature.schema.json +5 -1
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +40 -11
- package/framework/skills/core/morph-brainstorming/SKILL.md +16 -7
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +44 -10
- package/framework/skills/core/morph-media/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +73 -15
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +51 -13
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +39 -13
- package/framework/standards/STANDARDS.json +189 -14
- package/framework/standards/ai-agents/context-providers.md +16 -26
- package/framework/standards/ai-agents/durable-workflows-hitl.md +15 -21
- package/framework/standards/ai-agents/modalities-image-gen.md +21 -25
- package/framework/standards/ai-agents/modalities-speech-to-text.md +4 -15
- package/framework/standards/ai-agents/modalities-text-to-speech.md +2 -11
- package/framework/standards/ai-agents/modalities-vision.md +1 -18
- package/framework/standards/ai-agents/prompt-sources.md +0 -4
- package/framework/standards/ai-agents/testing-ai.md +2 -1
- package/framework/standards/architecture/code-comments.md +100 -0
- package/framework/standards/backend/dotnet/testing.md +22 -0
- package/framework/standards/backend/integrations/neon-auth/neon-auth.md +63 -50
- package/framework/standards/frontend/admin-app/data-table.md +562 -0
- package/framework/standards/frontend/admin-app/feedback.md +236 -0
- package/framework/standards/frontend/admin-app/forms.md +232 -0
- package/framework/standards/frontend/admin-app/page-structure.md +203 -0
- package/framework/standards/frontend/design-system/ai-image-generation.md +5 -5
- package/framework/standards/frontend/nextjs/components.md +37 -58
- package/framework/standards/frontend/nextjs/data-fetching.md +1 -0
- package/framework/standards/frontend/nextjs/forms.md +31 -30
- package/framework/standards/frontend/nextjs/state-management.md +2 -1
- package/framework/standards/frontend/nextjs/testing.md +18 -0
- package/framework/standards/integration/mcp/mcp-tools.md +48 -117
- package/framework/templates/code/dotnet/abstractions/result.cs +0 -2
- package/framework/templates/code/dotnet/ai-agents/AgentBootstrap.cs.template +26 -39
- package/framework/templates/code/dotnet/ai-agents/EmbeddingService.cs.template +28 -68
- package/framework/templates/code/dotnet/ai-agents/ModelRegistry.cs.template +99 -117
- package/framework/templates/code/dotnet/ai-agents/PromptRepository.cs.template +2 -44
- package/framework/templates/code/dotnet/ai-agents/ResilientToolMiddleware.cs.template +40 -61
- package/framework/templates/code/dotnet/ai-agents/VectorSearchTool.cs.template +54 -107
- package/framework/templates/code/dotnet/ai-agents/model-registry.json.template +53 -58
- package/framework/templates/code/dotnet/contracts/contracts-vsa.cs +192 -281
- package/framework/templates/code/dotnet/database/migration.cs +49 -83
- package/framework/templates/code/dotnet/database/vector-entity.cs +2 -46
- package/framework/templates/code/dotnet/extensions/result-extensions.cs +0 -2
- package/framework/templates/code/dotnet/feature/validator.cs.hbs +11 -15
- package/framework/templates/code/dotnet/jobs/agent.cs +3 -51
- package/framework/templates/code/dotnet/jobs/job.cs +2 -97
- package/framework/templates/code/dotnet/test.cs +2 -87
- package/framework/templates/code/typescript/contracts.ts +140 -167
- package/framework/templates/context/design-system.md +3 -3
- package/framework/templates/docs/proposal.md +11 -0
- package/framework/templates/docs/spec.md +15 -0
- package/framework/templates/docs/ui-mockups.md +115 -98
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentApi.cs +1 -48
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentFactory.cs +3 -79
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentFactoryOptions.cs +1 -70
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentSpec.cs +1 -47
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentSpecOptions.cs +2 -99
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/AiKitAssemblyMarker.cs +0 -6
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Conversation/ConversationStore.cs +3 -88
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Conversation/IConversationStore.cs +1 -41
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Conversation/InMemoryConversationStore.cs +0 -21
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/CountingChatClient.cs +1 -25
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalAssertion.cs +2 -63
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalCacheMissException.cs +0 -30
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalCacheOptions.cs +1 -52
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalCacheStamp.cs +3 -78
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalFixture.cs +4 -109
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalMode.cs +0 -22
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalQualityEvaluators.cs +1 -36
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalScenario.cs +2 -57
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalSummary.cs +0 -75
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/OfflineChatClient.cs +2 -59
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/ShortPathResponseCache.cs +4 -111
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/IMediaGenerator.cs +0 -34
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/IMediaProvider.cs +1 -57
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaArtifact.cs +0 -19
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaDisclosure.cs +2 -74
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaGenerator.cs +2 -33
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaIdempotencyKey.cs +1 -43
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaJob.cs +1 -96
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaJobId.cs +0 -13
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaJobState.cs +0 -27
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaPricing.cs +0 -33
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaProvenance.cs +0 -31
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaRequest.cs +3 -75
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaResult.cs +0 -42
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaReview.cs +0 -39
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaSeams.cs +1 -55
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaStore.cs +3 -59
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MorphMedia.cs +0 -46
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/Providers/GoogleImageProvider.cs +3 -28
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/Providers/OpenAIImageProvider.cs +7 -61
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/Providers/VeoVideoProvider.cs +2 -37
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Middleware/IUsageCostSink.cs +1 -23
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Middleware/UsageCostMiddleware.cs +2 -82
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Middleware/UsageCostRecord.cs +1 -68
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Observability/MorphOpenTelemetryExtensions.cs +1 -62
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Observability/MorphTelemetryOptions.cs +2 -62
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/IProviderClientFactory.cs +1 -25
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/ModelAlias.cs +0 -124
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/ModelRegistry.cs +1 -73
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Rag/DeltaIngest.cs +1 -66
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Rag/DeltaPlan.cs +0 -41
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/AgentFactoryTests.cs +2 -112
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/AgentSpecTests.cs +2 -43
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/ConversationStoreTests.cs +5 -110
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/DeltaIngestTests.cs +7 -103
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalAssertionTests.cs +1 -42
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalCacheMissTests.cs +1 -30
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalCacheStampTests.cs +1 -19
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalFailuresTests.cs +0 -48
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalFixtureTests.cs +1 -29
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalRefreshTests.cs +2 -38
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalWorkspace.cs +1 -14
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/KitEvals.cs +4 -69
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/OfflineChatClientTests.cs +1 -10
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeChatClient.cs +1 -57
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeChatClientTests.cs +0 -26
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeClock.cs +1 -19
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeMediaProvider.cs +0 -32
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeProviderClientFactory.cs +0 -14
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeUsageCostSink.cs +0 -24
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/InMemoryMediaStore.cs +0 -32
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaAccessibilityTests.cs +0 -37
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaAliasIsNotAnAgentTests.cs +0 -24
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaDisclosureTests.cs +0 -78
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaGeneratorTests.cs +0 -53
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaIdempotencyKeyTests.cs +0 -44
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaJobStateTests.cs +0 -44
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaReviewTests.cs +0 -19
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaStoreTests.cs +0 -24
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/ModelRegistryTests.cs +6 -48
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/SkeletonSmokeTests.cs +1 -13
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/TelemetryTests.cs +2 -71
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/UsageCostMiddlewareTests.cs +5 -100
- package/framework/templates/feature/decisions.md +124 -122
- package/framework/templates/frontend/nextjs/Dockerfile.nextjs.hbs +39 -43
- package/framework/templates/frontend/nextjs/env.mjs.hbs +29 -32
- package/framework/templates/frontend/nextjs/page.tsx.hbs +21 -22
- package/framework/templates/frontend/nextjs/use-feature.ts.hbs +42 -54
- package/framework/templates/integrations/asaas-client.cs +332 -387
- package/framework/templates/integrations/asaas-webhook.cs +290 -351
- package/framework/templates/integrations/neon-auth-config.cs +9 -105
- package/framework/templates/project/validate.js +97 -120
- package/package.json +2 -2
- package/src/commands/dashboard/dashboard.js +80 -53
- package/src/commands/graph/affected.js +4 -3
- package/src/commands/graph/build.js +109 -18
- package/src/commands/graph/explain.js +8 -5
- package/src/commands/graph/status.js +14 -8
- package/src/commands/mcp/mcp-setup.js +37 -9
- package/src/commands/observability/cost.js +148 -18
- package/src/commands/project/doctor.js +85 -15
- package/src/commands/project/init.js +6 -3
- package/src/commands/project/install-plugin-cmd.js +1 -1
- package/src/commands/project/update.js +30 -0
- package/src/commands/project/worktree.js +73 -90
- package/src/commands/state/advance.js +18 -0
- package/src/commands/state/approve.js +15 -0
- package/src/commands/state/archive.js +62 -9
- package/src/commands/state/finish.js +62 -14
- package/src/commands/state/gate-check.js +8 -1
- package/src/commands/state/gate-decision.js +8 -4
- package/src/commands/state/report-check.js +90 -9
- package/src/commands/state/usecase.js +66 -0
- package/src/commands/tasks/dag.js +17 -2
- package/src/commands/validation/validate-feature.js +6 -1
- package/src/commands/validation/verify.js +20 -1
- package/src/dashboard/app/App.jsx +80 -16
- package/src/dashboard/app/components/ActivityFeed.jsx +172 -0
- package/src/dashboard/app/components/CommandPalette.jsx +155 -0
- package/src/dashboard/app/components/DetailPanel.jsx +47 -11
- package/src/dashboard/app/components/EntityLink.jsx +55 -0
- package/src/dashboard/app/components/ErrorBoundary.jsx +54 -0
- package/src/dashboard/app/components/FlowGraph.jsx +19 -17
- package/src/dashboard/app/components/KnowledgeMarkdown.jsx +53 -0
- package/src/dashboard/app/components/Markdown.jsx +47 -0
- package/src/dashboard/app/components/PhaseRail.jsx +111 -0
- package/src/dashboard/app/components/SearchFilter.jsx +40 -22
- package/src/dashboard/app/components/Sidebar.jsx +146 -37
- package/src/dashboard/app/hooks/useMorphQuery.js +54 -0
- package/src/dashboard/app/hooks/usePref.js +38 -0
- package/src/dashboard/app/lib/format.js +69 -0
- package/src/dashboard/app/lib/phases.js +56 -0
- package/src/dashboard/app/lib/project.js +50 -0
- package/src/dashboard/app/pages/Activity.jsx +24 -0
- package/src/dashboard/app/pages/AgentMap.jsx +371 -370
- package/src/dashboard/app/pages/FeatureDetail.jsx +604 -0
- package/src/dashboard/app/pages/Features.jsx +400 -0
- package/src/dashboard/app/pages/Guide.jsx +146 -0
- package/src/dashboard/app/pages/HookInspector.jsx +357 -322
- package/src/dashboard/app/pages/PhasePipeline.jsx +277 -408
- package/src/dashboard/app/pages/Portfolio.jsx +156 -0
- package/src/dashboard/app/pages/Rubrics.jsx +101 -0
- package/src/dashboard/app/pages/RulesMap.jsx +300 -273
- package/src/dashboard/app/pages/SkillsLibrary.jsx +483 -366
- package/src/dashboard/app/pages/Standards.jsx +407 -332
- package/src/dashboard/app/styles/index.css +187 -47
- package/src/dashboard/index.html +14 -12
- package/src/dashboard/package.json +32 -24
- package/src/dashboard/scanner/activity-scanner.js +351 -0
- package/src/dashboard/scanner/agents-scanner.js +63 -1
- package/src/dashboard/scanner/docs-scanner.js +59 -0
- package/src/dashboard/scanner/feature-status.js +10 -0
- package/src/dashboard/scanner/features-scanner.js +360 -0
- package/src/dashboard/scanner/hooks-scanner.js +8 -1
- package/src/dashboard/scanner/index.js +14 -7
- package/src/dashboard/scanner/phases-scanner.js +14 -15
- package/src/dashboard/scanner/portfolio.js +153 -0
- package/src/dashboard/scanner/project-api.js +195 -0
- package/src/dashboard/scanner/rules-scanner.js +42 -37
- package/src/dashboard/scanner/skills-scanner.js +105 -43
- package/src/dashboard/scanner/standards-scanner.js +4 -1
- package/src/dashboard/vite-plugin-morph-data.js +134 -17
- package/src/lib/ai/comment-stripper.js +101 -4
- package/src/lib/detectors/claude-config-detector.js +2 -7
- package/src/lib/detectors/index.js +5 -1
- package/src/lib/evaluator-report.js +47 -0
- package/src/lib/generators/handoff-generator.js +4 -18
- package/src/lib/generators/settings-generator.js +208 -209
- package/src/lib/git/default-branch.js +52 -0
- package/src/lib/git/tree-fingerprint.js +58 -0
- package/src/lib/git/tree-state.js +167 -0
- package/src/lib/graph/god-nodes.js +12 -3
- package/src/lib/graph/graph-health.js +48 -2
- package/src/lib/graph/graph-loader.js +99 -25
- package/src/lib/graph/graphifyignore.js +145 -28
- package/src/lib/graph/sanitize.js +117 -11
- package/src/lib/installers/mcp-installer.js +724 -719
- package/src/lib/installers/memory-installer.js +1 -2
- package/src/lib/projects/registry.js +154 -0
- package/src/lib/risk-detector.js +90 -0
- package/src/lib/specs/acervo-format.js +70 -0
- package/src/lib/specs/acervo-lifecycle.js +264 -0
- package/src/lib/specs/adr-index.js +169 -0
- package/src/lib/specs/revocations.js +156 -0
- package/src/lib/specs/screen-checklist.js +357 -0
- package/src/lib/specs/usecase-check.js +20 -16
- package/src/lib/specs/usecase-promoter.js +103 -38
- package/src/lib/specs/usecase-registry.js +7 -7
- package/src/lib/tasks/tasks-json-parser.js +2 -3
- package/src/lib/telemetry/metrics.js +386 -0
- package/src/lib/telemetry/telemetry-reader.js +161 -51
- package/src/lib/telemetry/verify-event.js +79 -0
- package/src/lib/validators/code-budget/code-budget-validator.js +200 -0
- package/src/lib/validators/code-budget/comment-scanner.js +75 -0
- package/src/lib/validators/code-budget/index.js +9 -0
- package/src/lib/validators/reuse/reuse-validator.js +4 -2
- package/src/lib/validators/reuse/symbol-scanner.js +27 -22
- package/src/lib/validators/validation-runner.js +21 -5
- package/src/lib/validators/verify-runner.js +11 -0
- package/src/lib/worktree/finish-backup.js +739 -0
- package/src/lib/worktree/ignored-triage.js +15 -8
- package/src/lib/worktree/worktree-audit.js +5 -0
- package/src/scripts/install-plugin.js +1 -1
- package/src/scripts/setup-infra.js +31 -22
- package/src/utils/agents-installer.js +338 -24
- package/src/utils/banner.js +1 -1
- package/src/utils/hooks-installer.js +75 -15
- package/src/commands/index.js +0 -13
- package/src/commands/project/checkpoint.js +0 -209
- package/src/core/state/phase-state-machine.js +0 -197
- package/src/dashboard/app/hooks/useDetailPanel.js +0 -15
- package/src/dashboard/app/pages/LiveState.jsx +0 -206
- package/src/dashboard/scanner/squads-scanner.js +0 -13
- package/src/dashboard/scanner/state-scanner.js +0 -85
- package/src/lib/generators/index.js +0 -10
- package/src/lib/hooks/hook-executor.js +0 -167
- package/src/lib/validators/checkpoint-persistence.js +0 -39
- package/src/lib/validators/shared/emit-validator-dispatch.js +0 -14
- package/src/lib/validators/shared/index.js +0 -12
- package/src/lib/validators/shared/issue-counter.js +0 -14
- 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
|
+
}
|