@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,416 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* graph-refresh.js — refresh em background do grafo da árvore PRINCIPAL.
|
|
3
|
+
*
|
|
4
|
+
* Quem dispara: o elo `graph-refresh-kick` (PostToolUse, depois de um comando
|
|
5
|
+
* Bash ou PowerShell com git/gh/morph-spec) e o SessionStart
|
|
6
|
+
* (`worktree-context`). Os dois chamam `kickGraphRefresh`, que só olha o disco
|
|
7
|
+
* e solta este mesmo arquivo como processo DESTACADO (`--run <raiz>`): quem
|
|
8
|
+
* dispara nunca espera o graphify, que custa ~80 s no MORPH_GHLBrain mesmo sem
|
|
9
|
+
* mudança nenhuma.
|
|
10
|
+
*
|
|
11
|
+
* Só a raiz. O processo destacado segura o diretório em que roda, e no win32
|
|
12
|
+
* um diretório em uso não se apaga: com o refresh de uma worktree correndo, o
|
|
13
|
+
* `finish` e o `worktree remove` falhavam com "Permission denied" e deixavam a
|
|
14
|
+
* worktree meio removida. Por isso o worker nasce com `cwd` no git common dir,
|
|
15
|
+
* nunca recebe o caminho de uma worktree, e o `graphify update` roda só na
|
|
16
|
+
* raiz — que nenhum `finish` remove. A worktree nasce com o grafo semeado da
|
|
17
|
+
* raiz (`seedGraphFromRoot`) e, dali em diante, é atualizada por
|
|
18
|
+
* `morph-spec graph refresh`, em primeiro plano.
|
|
19
|
+
*
|
|
20
|
+
* Por que não o hook git do próprio graphify (`graphify hook install`), medido
|
|
21
|
+
* no 0.9.50:
|
|
22
|
+
* - ele sai sem fazer nada numa worktree vinculada (compara `--git-dir` com
|
|
23
|
+
* `--git-common-dir`);
|
|
24
|
+
* - não instala `post-merge`: merge, fast-forward e `git pull` — o caminho
|
|
25
|
+
* pelo qual toda feature volta para a raiz — nunca rebuildam;
|
|
26
|
+
* - o rebuild dele só re-extrai `git diff HEAD~1 HEAD`; um commit depois de
|
|
27
|
+
* um fast-forward carimba `built_at_commit` = HEAD num grafo SEM os
|
|
28
|
+
* arquivos que o merge trouxe — "fresh" mentiroso.
|
|
29
|
+
* Aqui o critério é o da regra única (graph-freshness.js): o carimbo e o
|
|
30
|
+
* registro da última rodada contra o HEAD, e as regras do ignore por conteúdo.
|
|
31
|
+
*
|
|
32
|
+
* Um lock por REPOSITÓRIO (no git common dir) serializa o refresh em
|
|
33
|
+
* background com o `graph build|refresh` manual — cada `graphify update` usa
|
|
34
|
+
* todos os núcleos e os dois escreveriam o mesmo graph.json. O dono renova o
|
|
35
|
+
* carimbo do lock antes de cada rodada, e a checagem é refeita depois de
|
|
36
|
+
* soltá-lo, para não perder o commit que chegou enquanto ele era segurado.
|
|
37
|
+
*
|
|
38
|
+
* Fail-open em tudo: sem git, sem graphify ou sem grafo, nada acontece.
|
|
39
|
+
*
|
|
40
|
+
* @module graph-refresh
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { spawn, spawnSync } from 'child_process';
|
|
44
|
+
import {
|
|
45
|
+
appendFileSync, closeSync, copyFileSync, existsSync, mkdirSync, openSync,
|
|
46
|
+
readFileSync, renameSync, statSync, unlinkSync, writeFileSync, writeSync,
|
|
47
|
+
} from 'fs';
|
|
48
|
+
import { dirname, join, resolve } from 'path';
|
|
49
|
+
import { fileURLToPath } from 'url';
|
|
50
|
+
import { readPrimaryHead, resolveGitCommonDir, workTreeTop } from './git-paths.js';
|
|
51
|
+
import { findFirstOnPath, quoteForCmd } from './morph-cli.js';
|
|
52
|
+
import { ensureCommonExclude } from './git-exclude.js';
|
|
53
|
+
import {
|
|
54
|
+
GRAPH_DIR, graphFile, graphFreshness, ignoreRulesHash, primaryTree, readRefreshRecord,
|
|
55
|
+
recordRefreshRun, refreshPending, treeKey, writeRefreshRecord,
|
|
56
|
+
} from './graph-freshness.js';
|
|
57
|
+
|
|
58
|
+
export { primaryTree };
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* O que uma worktree herda do grafo da raiz. Fora da lista, de propósito:
|
|
62
|
+
* `.graphify_root` guarda o caminho ABSOLUTO da raiz, e `cache/` é indexado
|
|
63
|
+
* pelo mtime da árvore de origem — medido: copiado, não poupou re-extração.
|
|
64
|
+
* O `.graphifyignore` também não vem: a worktree lê o do próprio checkout, e
|
|
65
|
+
* pôr `/.graphifyignore` no exclude do common dir escondia também o da RAIZ
|
|
66
|
+
* ainda não versionado (o `git add` dele passava a ser recusado).
|
|
67
|
+
*/
|
|
68
|
+
const SEED_FILES = ['manifest.json', '.graphify_analysis.json', '.graphify_labels.json', 'graph.json'];
|
|
69
|
+
|
|
70
|
+
const EXCLUDE_MARKER = '# morph-spec: codebase graph inside worktrees (managed — see graph-refresh)';
|
|
71
|
+
|
|
72
|
+
const WORKER = fileURLToPath(import.meta.url);
|
|
73
|
+
const LOCK_MAX_AGE_MS = 30 * 60 * 1000;
|
|
74
|
+
const LOCK_WRITE_GRACE_MS = 5000;
|
|
75
|
+
const GRAPHIFY_TIMEOUT_MS = 20 * 60 * 1000;
|
|
76
|
+
const MAX_ROUNDS = 3;
|
|
77
|
+
const LOG_MAX_BYTES = 256 * 1024;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {string} dir
|
|
81
|
+
* @returns {boolean} `dir` está na árvore principal (o refresh automático a cobre)
|
|
82
|
+
*/
|
|
83
|
+
export function isPrimaryTree(dir) {
|
|
84
|
+
const top = dir ? workTreeTop(dir) : null;
|
|
85
|
+
const primary = primaryTree(dir);
|
|
86
|
+
return Boolean(top && primary && treeKey(top) === treeKey(primary));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function stateDir(dir) {
|
|
90
|
+
const common = resolveGitCommonDir(dir);
|
|
91
|
+
return common ? join(common, 'morph-spec') : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** @param {string} dir */
|
|
95
|
+
export function refreshLockPath(dir) {
|
|
96
|
+
const d = stateDir(dir);
|
|
97
|
+
return d ? join(d, 'graph-refresh.lock') : null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function refreshLogPath(dir) {
|
|
101
|
+
const d = stateDir(dir);
|
|
102
|
+
return d ? join(d, 'graph-refresh.log') : null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function mtimeOf(path) {
|
|
106
|
+
try {
|
|
107
|
+
return statSync(path).mtimeMs;
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function pidAlive(pid) {
|
|
114
|
+
try {
|
|
115
|
+
process.kill(pid, 0);
|
|
116
|
+
return true;
|
|
117
|
+
} catch (err) {
|
|
118
|
+
return err?.code === 'EPERM';
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Estado do lock do repositório — o que `graph status` mostra como "refresh em
|
|
124
|
+
* andamento". A idade conta da última renovação: um lock de processo morto,
|
|
125
|
+
* ou sem renovação há mais que o teto, é `live: false`, e o próximo refresh o
|
|
126
|
+
* recupera.
|
|
127
|
+
*
|
|
128
|
+
* @param {string} dir
|
|
129
|
+
* @param {{now?: number, isAlive?: (pid: number) => boolean}} [deps]
|
|
130
|
+
* @returns {{pid: number, since: string, renewed?: string, live: boolean}|null}
|
|
131
|
+
*/
|
|
132
|
+
export function readRefreshLock(dir, { now = Date.now(), isAlive = pidAlive } = {}) {
|
|
133
|
+
const path = refreshLockPath(dir);
|
|
134
|
+
if (!path) return null;
|
|
135
|
+
try {
|
|
136
|
+
const lock = JSON.parse(readFileSync(path, 'utf8'));
|
|
137
|
+
const age = now - Date.parse(lock.renewed ?? lock.since);
|
|
138
|
+
return { ...lock, live: Boolean(isAlive(lock.pid)) && age >= 0 && age < LOCK_MAX_AGE_MS };
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* @param {string} dir
|
|
146
|
+
* @param {{isAlive?: (pid: number) => boolean}} [deps]
|
|
147
|
+
* @returns {string|null} o caminho do lock adquirido, ou null (ocupado ou sem git)
|
|
148
|
+
*/
|
|
149
|
+
export function acquireRefreshLock(dir, deps = {}) {
|
|
150
|
+
const path = refreshLockPath(dir);
|
|
151
|
+
if (!path) return null;
|
|
152
|
+
try { mkdirSync(dirname(path), { recursive: true }); } catch { return null; }
|
|
153
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
154
|
+
try {
|
|
155
|
+
const fd = openSync(path, 'wx');
|
|
156
|
+
writeSync(fd, JSON.stringify({ pid: process.pid, since: new Date().toISOString() }));
|
|
157
|
+
closeSync(fd);
|
|
158
|
+
return path;
|
|
159
|
+
} catch (err) {
|
|
160
|
+
if (err?.code !== 'EEXIST') return null;
|
|
161
|
+
const current = readRefreshLock(dir, deps);
|
|
162
|
+
if (current?.live) return null;
|
|
163
|
+
// Ilegível e recém-criado é o dono ainda escrevendo entre o `wx` e o
|
|
164
|
+
// conteúdo — roubá-lo aqui poria dois graphify no mesmo graph.json.
|
|
165
|
+
const age = Date.now() - (mtimeOf(path) ?? 0);
|
|
166
|
+
if (!current && age < LOCK_WRITE_GRACE_MS) return null;
|
|
167
|
+
try { unlinkSync(path); } catch { return null; }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Renova o carimbo do lock, só se ainda for deste processo. Chamado antes de
|
|
175
|
+
* cada rodada do graphify: uma posse que atravessa várias rodadas passaria do
|
|
176
|
+
* teto de idade e seria roubada no meio de uma escrita.
|
|
177
|
+
*
|
|
178
|
+
* @param {string|null} path
|
|
179
|
+
*/
|
|
180
|
+
export function renewRefreshLock(path) {
|
|
181
|
+
if (!path) return;
|
|
182
|
+
try {
|
|
183
|
+
const lock = JSON.parse(readFileSync(path, 'utf8'));
|
|
184
|
+
if (lock.pid !== process.pid) return;
|
|
185
|
+
writeFileSync(path, JSON.stringify({ ...lock, renewed: new Date().toISOString() }));
|
|
186
|
+
} catch { /* solto por outro — o release confere de novo */ }
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Solta o lock só se ainda for deste processo. */
|
|
190
|
+
export function releaseRefreshLock(path) {
|
|
191
|
+
if (!path) return;
|
|
192
|
+
try {
|
|
193
|
+
if (JSON.parse(readFileSync(path, 'utf8')).pid === process.pid) unlinkSync(path);
|
|
194
|
+
} catch { /* já solto */ }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function appendRefreshLog(dir, entry) {
|
|
198
|
+
const path = refreshLogPath(dir);
|
|
199
|
+
if (!path) return;
|
|
200
|
+
try {
|
|
201
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
202
|
+
if (existsSync(path) && statSync(path).size > LOG_MAX_BYTES) {
|
|
203
|
+
const lines = readFileSync(path, 'utf8').split('\n').filter(Boolean);
|
|
204
|
+
writeFileSync(path, `${lines.slice(-200).join('\n')}\n`);
|
|
205
|
+
}
|
|
206
|
+
appendFileSync(path, `${JSON.stringify({ ts: new Date().toISOString(), ...entry })}\n`);
|
|
207
|
+
} catch { /* log é diagnóstico, nunca bloqueia */ }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* `graphify` no PATH. No win32 o libuv só acha `.exe` sem shell; `.cmd`/`.bat`
|
|
212
|
+
* (o que um shim ou um dublê de teste instala) passa pelo shell.
|
|
213
|
+
*
|
|
214
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
215
|
+
* @returns {{file: string, shell: boolean}|null}
|
|
216
|
+
*/
|
|
217
|
+
export function resolveGraphify(env = process.env) {
|
|
218
|
+
const names = process.platform === 'win32'
|
|
219
|
+
? ['graphify.exe', 'graphify.cmd', 'graphify.bat']
|
|
220
|
+
: ['graphify'];
|
|
221
|
+
const file = findFirstOnPath(names, env);
|
|
222
|
+
return file ? { file, shell: /\.(cmd|bat)$/i.test(file) } : null;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* `graphify <args>` síncrono, com `cwd` em `tree`. É o runner do refresh em
|
|
227
|
+
* background E do `graph build|refresh` manual: o mesmo binário resolvido e o
|
|
228
|
+
* mesmo `PYTHONHASHSEED=0` — é o que o hook do próprio graphify fixa para o
|
|
229
|
+
* clustering não trocar as comunidades de lugar a cada rodada.
|
|
230
|
+
*
|
|
231
|
+
* @param {string} tree
|
|
232
|
+
* @param {string[]} args
|
|
233
|
+
* @param {{env?: NodeJS.ProcessEnv, timeout?: number|null}} [opts] `timeout: null` = sem teto
|
|
234
|
+
* @returns {{status: number|null, output: string, unavailable?: boolean, error?: string}}
|
|
235
|
+
*/
|
|
236
|
+
export function runGraphify(tree, args, { env = process.env, timeout = GRAPHIFY_TIMEOUT_MS } = {}) {
|
|
237
|
+
const bin = resolveGraphify(env);
|
|
238
|
+
if (!bin) return { status: null, unavailable: true, output: '' };
|
|
239
|
+
// Nunca com cwd na árvore: o graphify lê o alvo pelo argumento (o ignore e o
|
|
240
|
+
// git dele, medido no 0.9.50), e um graphify órfão com cwd numa worktree a
|
|
241
|
+
// prende no win32 até terminar — o finish e o worktree remove falhavam.
|
|
242
|
+
const opts = {
|
|
243
|
+
cwd: resolveGitCommonDir(tree) ?? tree,
|
|
244
|
+
env: { ...env, PYTHONHASHSEED: '0' },
|
|
245
|
+
encoding: 'utf8',
|
|
246
|
+
windowsHide: true,
|
|
247
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
248
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
249
|
+
...(timeout ? { timeout } : {}),
|
|
250
|
+
};
|
|
251
|
+
const r = bin.shell
|
|
252
|
+
? spawnSync(quoteForCmd(bin.file), args.map(quoteForCmd), { ...opts, shell: true })
|
|
253
|
+
: spawnSync(bin.file, args, opts);
|
|
254
|
+
const output = `${r.stdout ?? ''}${r.stderr ?? ''}`;
|
|
255
|
+
return { status: r.status, output, ...(r.error ? { error: r.error.code } : {}) };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Semeia uma worktree com o grafo da raiz. A worktree nasce de um commit da
|
|
260
|
+
* raiz, então o grafo copiado ou já é o do HEAD dela ou fica a um
|
|
261
|
+
* `morph-spec graph refresh` de distância — nunca a uma extração do zero.
|
|
262
|
+
*
|
|
263
|
+
* Só `graphify-out/` (no exclude do common dir, senão a worktree fica suja
|
|
264
|
+
* para o `finish`). O registro da worktree copia o que se SABE do grafo da
|
|
265
|
+
* raiz: o commit em que ele está em dia e as regras com que foi extraído —
|
|
266
|
+
* nada inventado. Raiz sem registro das regras deixa a semente sem elas.
|
|
267
|
+
*
|
|
268
|
+
* @param {string} tree
|
|
269
|
+
* @param {string} root
|
|
270
|
+
* @returns {{seeded: boolean, reason?: string}}
|
|
271
|
+
*/
|
|
272
|
+
export function seedGraphFromRoot(tree, root) {
|
|
273
|
+
if (!tree || !root || treeKey(tree) === treeKey(root)) return { seeded: false, reason: 'is-root' };
|
|
274
|
+
if (existsSync(graphFile(tree))) return { seeded: false, reason: 'present' };
|
|
275
|
+
if (!existsSync(graphFile(root))) return { seeded: false, reason: 'no-root-graph' };
|
|
276
|
+
try {
|
|
277
|
+
const rootNow = graphFreshness(root);
|
|
278
|
+
const dst = join(tree, GRAPH_DIR);
|
|
279
|
+
mkdirSync(dst, { recursive: true });
|
|
280
|
+
for (const name of SEED_FILES) {
|
|
281
|
+
const src = join(root, GRAPH_DIR, name);
|
|
282
|
+
if (!existsSync(src)) continue;
|
|
283
|
+
// Por rename, e o graph.json por último: a presença dele é o sinal de
|
|
284
|
+
// semeadura completa, e quem lê no meio nunca vê meio arquivo.
|
|
285
|
+
const tmp = join(dst, `${name}.seed.${process.pid}`);
|
|
286
|
+
copyFileSync(src, tmp);
|
|
287
|
+
renameSync(tmp, join(dst, name));
|
|
288
|
+
}
|
|
289
|
+
ensureCommonExclude(tree, [`/${GRAPH_DIR}`], EXCLUDE_MARKER);
|
|
290
|
+
const rules = rootNow.record && 'ignoreHash' in rootNow.record
|
|
291
|
+
? { ignoreHash: rootNow.record.ignoreHash }
|
|
292
|
+
: {};
|
|
293
|
+
writeRefreshRecord(tree, {
|
|
294
|
+
head: rootNow.state === 'fresh' ? rootNow.head : rootNow.built,
|
|
295
|
+
exit: 0,
|
|
296
|
+
...rules,
|
|
297
|
+
seeded: true,
|
|
298
|
+
});
|
|
299
|
+
return { seeded: true };
|
|
300
|
+
} catch (err) {
|
|
301
|
+
return { seeded: false, reason: err?.code || 'copy-failed' };
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Semeia a árvore de `dir` a partir da raiz, se ela for uma worktree sem
|
|
307
|
+
* grafo — o que o SessionStart faz por uma worktree criada antes da semeadura
|
|
308
|
+
* existir.
|
|
309
|
+
*
|
|
310
|
+
* @param {string} dir
|
|
311
|
+
*/
|
|
312
|
+
export function seedFromPrimary(dir) {
|
|
313
|
+
const tree = dir ? workTreeTop(dir) : null;
|
|
314
|
+
const root = primaryTree(dir);
|
|
315
|
+
if (!tree || !root) return { seeded: false, reason: 'not-a-repo' };
|
|
316
|
+
return seedGraphFromRoot(tree, root);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* O trabalho do processo destacado, em primeiro plano (é o que os testes
|
|
321
|
+
* chamam). Roda `graphify update` na raiz do repositório de `dir` enquanto o
|
|
322
|
+
* grafo dela precisar (`refreshPending`), até `MAX_ROUNDS` rodadas — um
|
|
323
|
+
* commit que chega no meio pede outra. Cada rodada vai para o registro
|
|
324
|
+
* (graph-freshness.js) e para o log.
|
|
325
|
+
*
|
|
326
|
+
* @param {string} dir - qualquer diretório do repositório; o alvo é sempre a raiz
|
|
327
|
+
* @param {{env?: NodeJS.ProcessEnv, runUpdate?: (tree: string) => {status: number|null, unavailable?: boolean, output?: string}}} [deps]
|
|
328
|
+
* @returns {{ran: boolean, locked?: boolean, reason?: string, results: object[]}}
|
|
329
|
+
*/
|
|
330
|
+
export function runRefresh(dir, { env = process.env, runUpdate = (tree) => runGraphify(tree, ['update', tree], { env }) } = {}) {
|
|
331
|
+
const root = primaryTree(dir);
|
|
332
|
+
if (!root) return { ran: false, reason: 'no-primary-tree', results: [] };
|
|
333
|
+
const results = [];
|
|
334
|
+
let rounds = 0;
|
|
335
|
+
|
|
336
|
+
while (rounds < MAX_ROUNDS && refreshPending(graphFreshness(root))) {
|
|
337
|
+
const lock = acquireRefreshLock(root);
|
|
338
|
+
if (!lock) return { ran: results.length > 0, locked: true, results };
|
|
339
|
+
try {
|
|
340
|
+
while (rounds < MAX_ROUNDS) {
|
|
341
|
+
const before = graphFreshness(root);
|
|
342
|
+
if (!refreshPending(before)) break;
|
|
343
|
+
rounds += 1;
|
|
344
|
+
renewRefreshLock(lock);
|
|
345
|
+
const ignoreHash = ignoreRulesHash(root);
|
|
346
|
+
const started = Date.now();
|
|
347
|
+
const r = runUpdate(root);
|
|
348
|
+
const exit = r.status ?? null;
|
|
349
|
+
recordRefreshRun(root, { head: before.head, exit, ignoreHash, unavailable: Boolean(r.unavailable) });
|
|
350
|
+
const after = graphFreshness(root);
|
|
351
|
+
const entry = {
|
|
352
|
+
tree: root, from: before.built, head: before.head, to: after.built,
|
|
353
|
+
ok: exit === 0, fresh: after.state === 'fresh', exit, ms: Date.now() - started,
|
|
354
|
+
...(r.unavailable ? { unavailable: true } : {}),
|
|
355
|
+
...(exit !== 0 && r.output ? { output: r.output.slice(-400) } : {}),
|
|
356
|
+
};
|
|
357
|
+
appendRefreshLog(root, entry);
|
|
358
|
+
results.push(entry);
|
|
359
|
+
if (r.unavailable) return { ran: true, reason: 'graphify-unavailable', results };
|
|
360
|
+
}
|
|
361
|
+
} finally {
|
|
362
|
+
releaseRefreshLock(lock);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return { ran: results.length > 0, results };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Dispara o refresh da RAIZ em background e volta na hora — de qualquer
|
|
370
|
+
* árvore do repositório. Só olha o disco, sem subprocesso: sem grafo na raiz
|
|
371
|
+
* não há o que refrescar; grafo que não pede refresh no HEAD lido do disco não
|
|
372
|
+
* paga um processo por comando git; com o graphify registrado como ausente e
|
|
373
|
+
* ainda fora do PATH, não há quem rode. HEAD que o disco não responde (reftable,
|
|
374
|
+
* branch sem commit) fica para o worker, que confere de novo sob o lock.
|
|
375
|
+
*
|
|
376
|
+
* O worker nasce com `cwd` no git common dir e só recebe a raiz: nenhum
|
|
377
|
+
* processo destacado segura o diretório de uma worktree.
|
|
378
|
+
*
|
|
379
|
+
* `MORPH_GRAPH_AUTO_REFRESH=0` desliga (CI, máquina sem folga de CPU).
|
|
380
|
+
*
|
|
381
|
+
* @param {string} dir
|
|
382
|
+
* @param {{spawnFn?: typeof spawn, env?: NodeJS.ProcessEnv}} [deps]
|
|
383
|
+
* @returns {{kicked: boolean, reason?: string, pid?: number}}
|
|
384
|
+
*/
|
|
385
|
+
export function kickGraphRefresh(dir, { spawnFn = spawn, env = process.env } = {}) {
|
|
386
|
+
if (env.MORPH_GRAPH_AUTO_REFRESH === '0') return { kicked: false, reason: 'disabled' };
|
|
387
|
+
const root = primaryTree(dir);
|
|
388
|
+
if (!root) return { kicked: false, reason: 'not-a-repo' };
|
|
389
|
+
if (!existsSync(graphFile(root))) return { kicked: false, reason: 'no-graph' };
|
|
390
|
+
const head = readPrimaryHead(root);
|
|
391
|
+
if (head && !refreshPending(graphFreshness(root, { head }))) return { kicked: false, reason: 'not-pending' };
|
|
392
|
+
if (readRefreshRecord(root)?.unavailable && !resolveGraphify(env)) {
|
|
393
|
+
return { kicked: false, reason: 'graphify-unavailable' };
|
|
394
|
+
}
|
|
395
|
+
try {
|
|
396
|
+
const child = spawnFn(process.execPath, [WORKER, '--run', root], {
|
|
397
|
+
cwd: resolveGitCommonDir(root), env, detached: true, stdio: 'ignore', windowsHide: true,
|
|
398
|
+
});
|
|
399
|
+
child.on?.('error', () => {});
|
|
400
|
+
child.unref?.();
|
|
401
|
+
return { kicked: true, pid: child.pid };
|
|
402
|
+
} catch {
|
|
403
|
+
return { kicked: false, reason: 'spawn-failed' };
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const isMain = (() => {
|
|
408
|
+
const argv1 = process.argv[1];
|
|
409
|
+
if (!argv1) return false;
|
|
410
|
+
const norm = (p) => (process.platform === 'win32' ? resolve(p).toLowerCase() : resolve(p));
|
|
411
|
+
return norm(argv1) === norm(WORKER);
|
|
412
|
+
})();
|
|
413
|
+
|
|
414
|
+
if (isMain && process.argv[2] === '--run' && process.argv[3]) {
|
|
415
|
+
try { runRefresh(process.argv[3]); } catch { /* fail-open */ }
|
|
416
|
+
}
|
|
@@ -40,23 +40,35 @@ function splitCommand(command) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/** Quote one argument for cmd.exe (the only shell path this module uses). */
|
|
43
|
-
function quoteForCmd(arg) {
|
|
43
|
+
export function quoteForCmd(arg) {
|
|
44
44
|
if (arg === '') return '""';
|
|
45
45
|
if (!/[\s"&|<>^()]/.test(arg)) return arg;
|
|
46
46
|
return `"${arg.replace(/"/g, '""')}"`;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
/**
|
|
50
|
-
|
|
49
|
+
/**
|
|
50
|
+
* First PATH entry holding any of `files`, or null. The entries are walked in
|
|
51
|
+
* PATH order and every name is tried inside each one, the way cmd.exe applies
|
|
52
|
+
* PATHEXT: a `graphify.cmd` early in PATH wins over a `graphify.exe` later.
|
|
53
|
+
*
|
|
54
|
+
* @param {string[]} files
|
|
55
|
+
* @param {NodeJS.ProcessEnv} env
|
|
56
|
+
* @returns {string|null}
|
|
57
|
+
*/
|
|
58
|
+
export function findFirstOnPath(files, env) {
|
|
51
59
|
const raw = env.PATH || env.Path || env.path || '';
|
|
52
60
|
for (const dir of raw.split(delimiter)) {
|
|
53
61
|
if (!dir) continue;
|
|
54
|
-
const
|
|
55
|
-
|
|
62
|
+
for (const file of files) {
|
|
63
|
+
const candidate = join(dir, file);
|
|
64
|
+
try { if (existsSync(candidate)) return candidate; } catch { /* unreadable entry */ }
|
|
65
|
+
}
|
|
56
66
|
}
|
|
57
67
|
return null;
|
|
58
68
|
}
|
|
59
69
|
|
|
70
|
+
const findOnPath = (file, env) => findFirstOnPath([file], env);
|
|
71
|
+
|
|
60
72
|
/** Last stdout line that parses as a JSON object, or null. */
|
|
61
73
|
export function lastJsonLine(stdout) {
|
|
62
74
|
const lines = String(stdout || '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redaction of obvious secrets in free text that telemetry persists (Bash
|
|
3
|
+
* commands, dispatch prompts). events.jsonl is copied into finish backups
|
|
4
|
+
* under `.git/`, so whatever lands there outlives the session.
|
|
5
|
+
*
|
|
6
|
+
* Pattern-based by design: it catches a secret written NEXT TO its name
|
|
7
|
+
* (`KEY=…`, `Authorization: …`, `user:pass@`) or in a well-known token shape.
|
|
8
|
+
* A bare value with no name beside it is invisible to any pattern — which is
|
|
9
|
+
* why tool OUTPUT (stdout/stderr, a failure's `error` body) is never persisted
|
|
10
|
+
* at all instead of being redacted.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const REDACTED = '[REDACTED]';
|
|
14
|
+
|
|
15
|
+
const SECRET_KEY =
|
|
16
|
+
'[A-Za-z0-9_.-]*(?:secret|token|passw(?:or)?d|pwd|api[_-]?key|access[_-]?key|private[_-]?key|account[_-]?key|sharedaccesskey|credentials?|auth[_-]?key)[A-Za-z0-9_.-]*';
|
|
17
|
+
|
|
18
|
+
const VALUE = `("[^"]*"|'[^']*'|[^\\s'",;&|}]+)`;
|
|
19
|
+
|
|
20
|
+
const RULES = [
|
|
21
|
+
// A port (`host:8080/…`) is not a password; a password may contain `/`.
|
|
22
|
+
[/\b([a-z][a-z0-9+.-]*:\/\/[^\s:@/?#]+:)(?!\d+(?:[/?#\s]|$))[^\s@]+@/gi, `$1${REDACTED}@`],
|
|
23
|
+
[/\b(authorization\s*[:=]\s*)(?:(bearer|basic|token|digest)\s+)?[^\s'",;]+/gi,
|
|
24
|
+
(_, head, scheme) => `${head}${scheme ? `${scheme} ` : ''}${REDACTED}`],
|
|
25
|
+
// A token carries a digit; "Bearer authentication" is prose.
|
|
26
|
+
[/\b(bearer\s+)(?=[A-Za-z0-9._~+/=-]*\d)[A-Za-z0-9._~+/=-]{8,}/gi, `$1${REDACTED}`],
|
|
27
|
+
// `-u 1000:1000` is a uid:gid, not credentials.
|
|
28
|
+
[/(\s-u\s+|--user[=\s]+)(['"]?)(?!\d+:\d+\b)[^\s:'"]+:[^\s'"]+\2/g, `$1${REDACTED}`],
|
|
29
|
+
[new RegExp(`(["']?)(${SECRET_KEY})\\1(\\s*[:=]\\s*)${VALUE}`, 'gi'),
|
|
30
|
+
(_, quote, key, sep) => `${quote}${key}${quote}${sep}${REDACTED}`],
|
|
31
|
+
[new RegExp(`\\b([A-Z][A-Z0-9_]*_(?:KEY|PASS|PAT|SIG))(\\s*=\\s*)${VALUE}`, 'g'), `$1$2${REDACTED}`],
|
|
32
|
+
[new RegExp(`(--(?:password|passwd|pass|token|secret|api-?key|access-key|auth-token)(?:\\s+|=))${VALUE}`, 'gi'),
|
|
33
|
+
`$1${REDACTED}`],
|
|
34
|
+
[new RegExp(`(\\buser-secrets\\s+set\\s+(?:"[^"]*"|'[^']*'|\\S+)\\s+)${VALUE}`, 'gi'), `$1${REDACTED}`],
|
|
35
|
+
[/([?&](?:sig|access_token|token|api[_-]?key|key|password|secret|code)=)[^&\s'"#]+/gi, `$1${REDACTED}`],
|
|
36
|
+
[/\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})\b/g, REDACTED],
|
|
37
|
+
[/\bsk-(?:ant-)?[A-Za-z0-9_-]{16,}/g, REDACTED],
|
|
38
|
+
[/\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{10,}/g, REDACTED],
|
|
39
|
+
[/\bnpm_[A-Za-z0-9]{36}\b/g, REDACTED],
|
|
40
|
+
[/\bxox[abposr]-[A-Za-z0-9-]{10,}/g, REDACTED],
|
|
41
|
+
[/\bAKIA[0-9A-Z]{16}\b/g, REDACTED],
|
|
42
|
+
[/\bAIza[0-9A-Za-z_-]{35}\b/g, REDACTED],
|
|
43
|
+
[/\beyJ[A-Za-z0-9_-]{5,}\.eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]+/g, REDACTED],
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {*} text
|
|
48
|
+
* @returns {*} the text with secrets replaced by `[REDACTED]`; non-strings unchanged
|
|
49
|
+
*/
|
|
50
|
+
export function redactSecrets(text) {
|
|
51
|
+
if (typeof text !== 'string' || text === '') return text;
|
|
52
|
+
let out = text;
|
|
53
|
+
for (const [pattern, replacement] of RULES) out = out.replace(pattern, replacement);
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
@@ -20,6 +20,8 @@ import {
|
|
|
20
20
|
appendFileSync, readFileSync, mkdirSync, existsSync, statSync, renameSync,
|
|
21
21
|
} from 'fs';
|
|
22
22
|
import { join, dirname } from 'path';
|
|
23
|
+
import { randomUUID } from 'crypto';
|
|
24
|
+
import { redactSecrets } from './redact-secrets.js';
|
|
23
25
|
|
|
24
26
|
const LOGS_DIR = '.morph/logs';
|
|
25
27
|
export const EVENTS_FILE = join(LOGS_DIR, 'events.jsonl');
|
|
@@ -32,13 +34,26 @@ export const EVENTS_FILE = join(LOGS_DIR, 'events.jsonl');
|
|
|
32
34
|
// this it grows forever the way activity.jsonl used to before R1.2.
|
|
33
35
|
export const ROTATE_THRESHOLD_BYTES = 5 * 1024 * 1024; // 5 MB
|
|
34
36
|
|
|
37
|
+
// Bumped when a field changes meaning. Readers key legacy handling off its
|
|
38
|
+
// absence (v1 events carry no schemaVersion and no eventId).
|
|
39
|
+
export const TELEMETRY_SCHEMA_VERSION = 2;
|
|
40
|
+
|
|
41
|
+
const FREE_TEXT_FIELDS = ['command', 'prompt', 'desc', 'target'];
|
|
42
|
+
|
|
43
|
+
/** A tool call that failed read nothing and changed nothing. */
|
|
44
|
+
export function isFailedToolCall(event) {
|
|
45
|
+
return event?.event === 'PostToolUseFailure';
|
|
46
|
+
}
|
|
47
|
+
|
|
35
48
|
function getEventsPath(projectPath) {
|
|
36
49
|
return join(projectPath || process.cwd(), EVENTS_FILE);
|
|
37
50
|
}
|
|
38
51
|
|
|
39
52
|
/**
|
|
40
53
|
* Append a single telemetry event as one JSON line.
|
|
41
|
-
* Stamps
|
|
54
|
+
* Stamps `ts` (unless given), `schemaVersion` and a unique `eventId` — the
|
|
55
|
+
* dedupe key when the same line is read back from a rotated archive or a
|
|
56
|
+
* finish backup — and redacts secrets in the free-text fields.
|
|
42
57
|
*
|
|
43
58
|
* @param {Object} event - Event payload (tool, skill/persona/target/prompt, feature, phase…)
|
|
44
59
|
* @param {string} [projectPath] - Project root (defaults to cwd)
|
|
@@ -49,7 +64,15 @@ export function appendTelemetryEvent(event, projectPath) {
|
|
|
49
64
|
const dir = dirname(eventsPath);
|
|
50
65
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
51
66
|
|
|
52
|
-
const stamped = {
|
|
67
|
+
const stamped = {
|
|
68
|
+
ts: event?.ts || new Date().toISOString(),
|
|
69
|
+
schemaVersion: TELEMETRY_SCHEMA_VERSION,
|
|
70
|
+
eventId: randomUUID(),
|
|
71
|
+
...event,
|
|
72
|
+
};
|
|
73
|
+
for (const field of FREE_TEXT_FIELDS) {
|
|
74
|
+
if (typeof stamped[field] === 'string') stamped[field] = redactSecrets(stamped[field]);
|
|
75
|
+
}
|
|
53
76
|
appendFileSync(eventsPath, JSON.stringify(stamped) + '\n', 'utf-8');
|
|
54
77
|
} catch {
|
|
55
78
|
// Fail-silent — never block hooks
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real token usage read from a Claude Code transcript (JSONL), shared by the
|
|
3
|
+
* Stop hook (the main session's transcript) and SubagentStop (each
|
|
4
|
+
* sub-agent's own transcript, which the main one does not include).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFileSync, existsSync, statSync } from 'fs';
|
|
8
|
+
|
|
9
|
+
// A 45 MB / ~16k-line transcript parses in ~350 ms; above this, skip rather
|
|
10
|
+
// than risk an unbounded read inside a hook.
|
|
11
|
+
const MAX_TRANSCRIPT_BYTES = 50 * 1024 * 1024;
|
|
12
|
+
|
|
13
|
+
export const USAGE_KEYS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheCreationTokens'];
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Sum `message.usage` over the assistant turns of a transcript.
|
|
17
|
+
*
|
|
18
|
+
* One turn is written as several lines (one per content block), each repeating
|
|
19
|
+
* the same usage under the same `message.id`: summing lines double-counts, so
|
|
20
|
+
* the sum is deduplicated by message id.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} [transcriptPath]
|
|
23
|
+
* @returns {{inputTokens:number,outputTokens:number,cacheReadTokens:number,cacheCreationTokens:number}|null}
|
|
24
|
+
* null when there is nothing to report (no/oversized/unreadable file, no usage)
|
|
25
|
+
*/
|
|
26
|
+
export function readTranscriptUsage(transcriptPath) {
|
|
27
|
+
if (!transcriptPath || typeof transcriptPath !== 'string') return null;
|
|
28
|
+
try {
|
|
29
|
+
if (!existsSync(transcriptPath)) return null;
|
|
30
|
+
if (statSync(transcriptPath).size > MAX_TRANSCRIPT_BYTES) return null;
|
|
31
|
+
|
|
32
|
+
const totals = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0 };
|
|
33
|
+
const seen = new Set();
|
|
34
|
+
let sawAny = false;
|
|
35
|
+
|
|
36
|
+
for (const line of readFileSync(transcriptPath, 'utf-8').split('\n')) {
|
|
37
|
+
const trimmed = line.trim();
|
|
38
|
+
if (!trimmed) continue;
|
|
39
|
+
let entry;
|
|
40
|
+
try { entry = JSON.parse(trimmed); } catch { continue; }
|
|
41
|
+
if (entry?.type !== 'assistant') continue;
|
|
42
|
+
const usage = entry?.message?.usage;
|
|
43
|
+
if (!usage || typeof usage !== 'object') continue;
|
|
44
|
+
|
|
45
|
+
const key = entry?.message?.id || entry?.uuid;
|
|
46
|
+
if (key) {
|
|
47
|
+
if (seen.has(key)) continue;
|
|
48
|
+
seen.add(key);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
sawAny = true;
|
|
52
|
+
totals.inputTokens += Number(usage.input_tokens) || 0;
|
|
53
|
+
totals.outputTokens += Number(usage.output_tokens) || 0;
|
|
54
|
+
totals.cacheReadTokens += Number(usage.cache_read_input_tokens) || 0;
|
|
55
|
+
totals.cacheCreationTokens += Number(usage.cache_creation_input_tokens) || 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return sawAny ? totals : null;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The usage added since the previous sample of the same transcript.
|
|
66
|
+
*
|
|
67
|
+
* A counter going DOWN means the transcript restarted (a new file under the
|
|
68
|
+
* same key), so the whole current total is new usage — subtracting would
|
|
69
|
+
* produce negative spend.
|
|
70
|
+
*
|
|
71
|
+
* @param {object|null|undefined} previous - last recorded totals for this key
|
|
72
|
+
* @param {object} current - totals just read
|
|
73
|
+
* @returns {{delta: object, changed: boolean}}
|
|
74
|
+
*/
|
|
75
|
+
export function usageDelta(previous, current) {
|
|
76
|
+
const reset = !previous || USAGE_KEYS.some((k) => (current[k] ?? 0) < (previous[k] ?? 0));
|
|
77
|
+
const delta = {};
|
|
78
|
+
for (const k of USAGE_KEYS) delta[k] = reset ? (current[k] ?? 0) : (current[k] ?? 0) - (previous[k] ?? 0);
|
|
79
|
+
return { delta, changed: USAGE_KEYS.some((k) => delta[k] !== 0) };
|
|
80
|
+
}
|