@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
|
@@ -3,76 +3,80 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* Telemetry Hook: harness execution-path capture
|
|
5
5
|
*
|
|
6
|
-
* Events: PostToolUse (all tools)
|
|
6
|
+
* Events: PostToolUse + PostToolUseFailure (all tools), PostToolBatch,
|
|
7
|
+
* SubagentStop, UserPromptSubmit
|
|
7
8
|
*
|
|
8
9
|
* morph-spec is a Natural Language Harness: there is no central runtime to
|
|
9
10
|
* instrument — Claude Code executes the skills and sub-agents. The only
|
|
10
11
|
* vantage points are hooks. This one is the generic capture point: every tool
|
|
11
12
|
* call and every user prompt is appended (one JSON line) to
|
|
12
|
-
* .morph/logs/events.jsonl, from which `morph-spec telemetry`
|
|
13
|
-
*
|
|
14
|
-
* prompts), which standards/knowledge-base files were read.
|
|
13
|
+
* .morph/logs/events.jsonl, from which `morph-spec telemetry` and
|
|
14
|
+
* `morph-spec cost` reconstruct the path and measure it.
|
|
15
15
|
*
|
|
16
16
|
* Correlation is by an EXPLICIT key (feature + phase) written into each event,
|
|
17
17
|
* not by ambient span context — hooks fire as separate node processes with no
|
|
18
|
-
* shared trace context
|
|
19
|
-
*
|
|
18
|
+
* shared trace context. `sessionId` and `agentId` (present only inside a
|
|
19
|
+
* sub-agent) separate the orchestrator from the sub-agents it dispatched.
|
|
20
|
+
*
|
|
21
|
+
* PostToolUse fires only when a tool SUCCEEDS; a Bash command that exits
|
|
22
|
+
* non-zero fires PostToolUseFailure instead. Only the exit code parsed from
|
|
23
|
+
* its `error` first line is kept — the rest of `error` is the command's
|
|
24
|
+
* output, where a secret value can appear with no name beside it.
|
|
20
25
|
*
|
|
21
26
|
* SINK RESOLUTION: the event is appended to the `.morph/` tree that OWNS the
|
|
22
|
-
* tool's target file
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* git worktree; sinking on the session's cwd put those events in the WRONG
|
|
26
|
-
* tree's events.jsonl, the mirror of the trace-autogen worktree defect. Tools
|
|
27
|
-
* that carry no file_path (Bash, Skill, Agent…) have no target to resolve —
|
|
28
|
-
* the session cwd stays the reference for those, same as before.
|
|
27
|
+
* tool's target file (`resolveMorphRoot`); tools with no file path sink on the
|
|
28
|
+
* nearest morph tree above the payload's `cwd`, so a sub-agent working in a
|
|
29
|
+
* worktree logs its Bash calls next to its Reads and Writes.
|
|
29
30
|
*
|
|
30
31
|
* SILENT: never blocks, never injects context — only appends. Fail-open on any
|
|
31
32
|
* error (exit 0). Only active inside a morph project (stateExists gate).
|
|
32
33
|
*/
|
|
33
34
|
|
|
34
|
-
import { statSync } from 'fs';
|
|
35
|
+
import { statSync, readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
36
|
+
import { dirname, join } from 'path';
|
|
35
37
|
import { stateExists, getActiveFeature, derivePhaseForFeature } from '../../shared/state-reader.js';
|
|
36
38
|
import { appendTelemetryEvent } from '../../shared/telemetry-logger.js';
|
|
37
39
|
import { runStandalone } from '../../shared/hook-response.js';
|
|
38
40
|
import { getFilePath, resolveMorphRoot, resolveTargetPath } from '../../shared/payload-utils.js';
|
|
41
|
+
import { redactSecrets } from '../../shared/redact-secrets.js';
|
|
42
|
+
import { exitCodeMasked } from '../../shared/command-tokens.js';
|
|
43
|
+
import { readTranscriptUsage, usageDelta } from '../../shared/transcript-usage.js';
|
|
39
44
|
|
|
40
45
|
function normalize(p) {
|
|
41
46
|
return (p || '').replace(/\\/g, '/');
|
|
42
47
|
}
|
|
43
48
|
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
// OTHER event type stays exactly as-is) to `MAX_FIELD_CHARS`, with the
|
|
49
|
-
// ORIGINAL length preserved in a sibling `<field>Chars` key — `morph-spec
|
|
50
|
-
// cost` sums prompt chars for its dispatch-cost view, and must prefer that
|
|
51
|
-
// field over `prompt.length` once it's present (a truncated string's own
|
|
52
|
-
// length would silently undercount every dispatch over the cap). This shape
|
|
53
|
-
// is a CONTRACT with the telemetry reader — do not rename either half.
|
|
49
|
+
// A multi-KB prompt or command written verbatim on every call dominates the
|
|
50
|
+
// per-line cost. The ORIGINAL length travels in a sibling `<field>Chars` key,
|
|
51
|
+
// which `morph-spec cost` prefers over the truncated string's own length —
|
|
52
|
+
// a contract with the telemetry reader: do not rename either half.
|
|
54
53
|
const MAX_FIELD_CHARS = 2000;
|
|
55
54
|
const TRUNCATE_SUFFIX = '…[truncado]';
|
|
56
55
|
|
|
56
|
+
// Read returns this many lines when the call passes no `limit`.
|
|
57
|
+
const DEFAULT_READ_LINES = 2000;
|
|
58
|
+
// Above this, measuring the slice would cost more than the hook budget allows.
|
|
59
|
+
const MAX_SLICE_BYTES = 10 * 1024 * 1024;
|
|
60
|
+
const BINARY_READ = /\.(png|jpe?g|gif|webp|bmp|ico|pdf)$/i;
|
|
61
|
+
|
|
57
62
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
63
|
+
* Redact, then cap a free-text field, reporting its original length. Redacting
|
|
64
|
+
* first keeps a token cut at the cap from surviving as a recognizable prefix.
|
|
60
65
|
*
|
|
61
66
|
* @param {string} value
|
|
62
67
|
* @returns {{ value: string, chars: number }}
|
|
63
68
|
*/
|
|
64
69
|
function capField(value) {
|
|
65
70
|
const str = typeof value === 'string' ? value : '';
|
|
66
|
-
|
|
67
|
-
return { value:
|
|
71
|
+
const clean = redactSecrets(str);
|
|
72
|
+
if (clean.length <= MAX_FIELD_CHARS) return { value: clean, chars: str.length };
|
|
73
|
+
return { value: clean.slice(0, MAX_FIELD_CHARS) + TRUNCATE_SUFFIX, chars: str.length };
|
|
68
74
|
}
|
|
69
75
|
|
|
70
76
|
/**
|
|
71
77
|
* Classify a Read target into the telemetry `kind` taxonomy — a contract with
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
* categories before falling back to 'code'. Case-insensitive; `/` and `\`
|
|
75
|
-
* both normalize to `/` upstream via `normalize()`.
|
|
78
|
+
* src/lib/telemetry/telemetry-reader.js, so these values are pinned. Specific
|
|
79
|
+
* categories first, 'code' last.
|
|
76
80
|
*/
|
|
77
81
|
function classifyReadKind(target) {
|
|
78
82
|
const t = (target || '').toLowerCase();
|
|
@@ -82,9 +86,6 @@ function classifyReadKind(target) {
|
|
|
82
86
|
if (t.endsWith('skill.md') || t.includes('/skills/')) return 'skill';
|
|
83
87
|
if (t.split('/').pop() === 'agents.json') return 'agents';
|
|
84
88
|
|
|
85
|
-
// Inside a feature folder AND one of the named plan artifacts (or under a
|
|
86
|
-
// usecases/ or reports/ subfolder) — everything else in a feature folder
|
|
87
|
-
// (source files a task produced, say) is 'code'.
|
|
88
89
|
const inFeature = t.match(/\.morph\/features\/[^/]+\/(.+)$/);
|
|
89
90
|
if (inFeature) {
|
|
90
91
|
const rel = inFeature[1];
|
|
@@ -101,60 +102,118 @@ function classifyReadKind(target) {
|
|
|
101
102
|
return 'code';
|
|
102
103
|
}
|
|
103
104
|
|
|
105
|
+
/** Nearest directory at or above `dir` that is a morph project; `fallback` when none. */
|
|
106
|
+
function morphRootAbove(dir, fallback) {
|
|
107
|
+
if (!dir) return fallback;
|
|
108
|
+
let current = normalize(dir);
|
|
109
|
+
for (let i = 0; i < 32; i++) {
|
|
110
|
+
if (stateExists(current)) return current;
|
|
111
|
+
const parent = normalize(dirname(current));
|
|
112
|
+
if (parent === current) break;
|
|
113
|
+
current = parent;
|
|
114
|
+
}
|
|
115
|
+
return fallback;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function featureFromPath(target) {
|
|
119
|
+
const m = /\/\.morph\/features\/([^/]+)\//.exec(`/${normalize(target)}`);
|
|
120
|
+
return m ? m[1] : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
104
123
|
/**
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
|
|
124
|
+
* What a Read actually put in context: the requested line window, not the
|
|
125
|
+
* whole file. Binary reads (images, PDFs) go in whole.
|
|
126
|
+
*/
|
|
127
|
+
function readSliceBytes(absPath, input, size) {
|
|
128
|
+
if (BINARY_READ.test(absPath)) return size;
|
|
129
|
+
if (size > MAX_SLICE_BYTES) return undefined;
|
|
130
|
+
const offset = Number.isInteger(input.offset) && input.offset > 1 ? input.offset : 1;
|
|
131
|
+
const limit = Number.isInteger(input.limit) && input.limit > 0 ? input.limit : DEFAULT_READ_LINES;
|
|
132
|
+
const lines = readFileSync(absPath, 'utf-8').split('\n');
|
|
133
|
+
if (offset === 1 && limit >= lines.length) return size;
|
|
134
|
+
return Buffer.byteLength(lines.slice(offset - 1, offset - 1 + limit).join('\n'), 'utf-8');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Exit code and outcome of a finished tool call, without keeping its output. */
|
|
138
|
+
function outcomeOf(payload, input) {
|
|
139
|
+
if (payload.hook_event_name === 'PostToolUseFailure') {
|
|
140
|
+
const firstLine = String(payload.error ?? '').split('\n', 1)[0];
|
|
141
|
+
const code = /^exit code (\d+)/i.exec(firstLine);
|
|
142
|
+
const outcome = payload.is_interrupt === true ? 'interrupted'
|
|
143
|
+
: /timed? ?out/i.test(firstLine) ? 'timeout'
|
|
144
|
+
: 'error';
|
|
145
|
+
return { outcome, exitCode: code ? Number(code[1]) : null };
|
|
146
|
+
}
|
|
147
|
+
if (input.run_in_background === true) return { outcome: 'background', exitCode: null };
|
|
148
|
+
if (payload.tool_response?.interrupted === true) return { outcome: 'interrupted', exitCode: null };
|
|
149
|
+
return { outcome: 'ok', exitCode: 0 };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function countByName(calls) {
|
|
153
|
+
const out = {};
|
|
154
|
+
for (const call of calls) {
|
|
155
|
+
const name = call?.tool_name || 'unknown';
|
|
156
|
+
out[name] = (out[name] || 0) + 1;
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Sub-agent tokens live in their own transcript; each stop emits the delta.
|
|
163
|
+
* One file per agent: two sub-agents stopping together never share a
|
|
164
|
+
* read-modify-write, and one agent never stops twice at once.
|
|
165
|
+
*/
|
|
166
|
+
function agentUsageEvent(payload, cwd) {
|
|
167
|
+
const usage = readTranscriptUsage(payload.agent_transcript_path);
|
|
168
|
+
if (!usage || !payload.agent_id) return null;
|
|
169
|
+
const dir = join(cwd, '.morph', 'memory', 'agent-usage');
|
|
170
|
+
const storePath = join(dir, `${String(payload.agent_id).replace(/[^A-Za-z0-9_-]/g, '_')}.json`);
|
|
171
|
+
let previous = null;
|
|
172
|
+
try {
|
|
173
|
+
if (existsSync(storePath)) previous = JSON.parse(readFileSync(storePath, 'utf-8'));
|
|
174
|
+
} catch { previous = null; }
|
|
175
|
+
const { delta, changed } = usageDelta(previous, usage);
|
|
176
|
+
if (!changed) return null;
|
|
177
|
+
try {
|
|
178
|
+
mkdirSync(dir, { recursive: true });
|
|
179
|
+
writeFileSync(storePath, JSON.stringify(usage), 'utf-8');
|
|
180
|
+
} catch { /* the event still carries the delta */ }
|
|
181
|
+
return { event: 'AgentUsage', ...usage, delta };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* @param {object} payload - parsed hook payload (PostToolUse, PostToolUseFailure,
|
|
186
|
+
* PostToolBatch, SubagentStop or UserPromptSubmit)
|
|
187
|
+
* @returns {Promise<null>} always null — pure side effect (append + claim heartbeat)
|
|
108
188
|
*/
|
|
109
189
|
export async function run(payload) {
|
|
110
190
|
try {
|
|
111
191
|
if (!payload) return null;
|
|
112
192
|
|
|
113
|
-
// Resolve the sink tree from the tool's target file (when it has one)
|
|
114
|
-
// BEFORE the stateExists gate below — otherwise a root session writing
|
|
115
|
-
// into a worktree would be gated on the ROOT's state instead of the
|
|
116
|
-
// worktree's.
|
|
117
193
|
const projectDir = process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
118
194
|
const targetFilePath = getFilePath(payload);
|
|
119
|
-
const cwd = targetFilePath
|
|
195
|
+
const cwd = targetFilePath
|
|
196
|
+
? resolveMorphRoot(targetFilePath, projectDir)
|
|
197
|
+
: morphRootAbove(payload.cwd, projectDir);
|
|
120
198
|
|
|
121
199
|
if (!stateExists(cwd)) return null;
|
|
122
200
|
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
// feature.json, so enrichment is O(active-feature-count) per tool call. That
|
|
126
|
-
// is fine for realistic projects (archive removes finished features, so the
|
|
127
|
-
// dir holds only the few in-flight ones) but would grow on a project that
|
|
128
|
-
// keeps many un-archived features. If that ever bites, resolve the active
|
|
129
|
-
// feature from the thin state.json index alone. Kept accurate-over-cheap for
|
|
130
|
-
// now because phase is not stored in feature.json — it needs derivation.
|
|
201
|
+
// getActiveFeature → loadState hydrates every feature.json: O(active
|
|
202
|
+
// features) per call, fine while archive keeps that set small.
|
|
131
203
|
let feature = null;
|
|
132
204
|
let phase = null;
|
|
133
205
|
try {
|
|
134
206
|
const active = getActiveFeature(cwd);
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
phase = active.feature?.phase || derivePhaseForFeature(active.name, cwd);
|
|
138
|
-
}
|
|
207
|
+
feature = active?.name ?? featureFromPath(targetFilePath);
|
|
208
|
+
if (feature) phase = active?.feature?.phase || derivePhaseForFeature(feature, cwd);
|
|
139
209
|
} catch {
|
|
140
210
|
// enrichment is best-effort — a missing feature never blocks capture
|
|
141
211
|
}
|
|
142
212
|
|
|
143
|
-
// Claim heartbeat
|
|
144
|
-
//
|
|
145
|
-
// no
|
|
146
|
-
//
|
|
147
|
-
// doc in claim-registry.js) that answers "is a heartbeat even due"
|
|
148
|
-
// from a marker beside this project's own `.morph/logs/`. On ~99% of
|
|
149
|
-
// calls it says no and NOTHING git-related happens — not even the
|
|
150
|
-
// pure-FS walk below, let alone a subprocess.
|
|
151
|
-
// 2. Only when (1) says yes do we resolve the git common dir — via
|
|
152
|
-
// git-paths.js, pure filesystem, no `git` subprocess spawn (this used
|
|
153
|
-
// to be `execSync('git rev-parse --git-common-dir')`, ~34ms wasted on
|
|
154
|
-
// ~99% of calls because the write below was ALREADY throttled) — and
|
|
155
|
-
// call heartbeatClaim(), which has its own authoritative throttle
|
|
156
|
-
// keyed to claims.json's stored heartbeatAt.
|
|
157
|
-
// Silent and fail-open, like everything else here.
|
|
213
|
+
// Claim heartbeat: this hook fires on every tool call, so it is the proof
|
|
214
|
+
// of life for the advisory feature claim. shouldHeartbeat() is a local,
|
|
215
|
+
// git-free pre-check that says no on ~99% of calls; only then is the git
|
|
216
|
+
// common dir resolved (pure filesystem, no subprocess).
|
|
158
217
|
if (feature && payload.session_id) {
|
|
159
218
|
try {
|
|
160
219
|
const { shouldHeartbeat, heartbeatClaim } = await import('../../shared/claim-registry.js');
|
|
@@ -166,51 +225,77 @@ export async function run(payload) {
|
|
|
166
225
|
} catch { /* fail-open — a missed heartbeat only shortens a claim's life */ }
|
|
167
226
|
}
|
|
168
227
|
|
|
228
|
+
const hookEvent = payload.hook_event_name;
|
|
169
229
|
let event;
|
|
170
230
|
|
|
171
|
-
|
|
172
|
-
if (payload.hook_event_name === 'UserPromptSubmit' || (!payload.tool_name && typeof payload.prompt === 'string')) {
|
|
231
|
+
if (hookEvent === 'UserPromptSubmit' || (!payload.tool_name && typeof payload.prompt === 'string' && !hookEvent)) {
|
|
173
232
|
const userPrompt = capField(payload.prompt || '');
|
|
174
233
|
event = { event: 'UserPromptSubmit', tool: 'UserPrompt', prompt: userPrompt.value, promptChars: userPrompt.chars };
|
|
234
|
+
} else if (hookEvent === 'PostToolBatch') {
|
|
235
|
+
const calls = Array.isArray(payload.tool_calls) ? payload.tool_calls : [];
|
|
236
|
+
event = { event: 'ToolBatch', size: calls.length, tools: countByName(calls) };
|
|
237
|
+
} else if (hookEvent === 'SubagentStop') {
|
|
238
|
+
event = agentUsageEvent(payload, cwd);
|
|
239
|
+
if (!event) return null;
|
|
175
240
|
} else {
|
|
176
241
|
const tool = payload.tool_name;
|
|
177
242
|
if (!tool) return null;
|
|
178
243
|
const input = payload.tool_input || {};
|
|
179
|
-
event = { event: 'PostToolUse', tool };
|
|
244
|
+
event = { event: hookEvent === 'PostToolUseFailure' ? 'PostToolUseFailure' : 'PostToolUse', tool };
|
|
180
245
|
|
|
181
246
|
if (tool === 'Skill') {
|
|
182
247
|
event.skill = input.skill || '';
|
|
183
248
|
} else if (tool === 'Agent' || tool === 'Task') {
|
|
184
249
|
event.persona = input.subagent_type || '';
|
|
185
|
-
if (input.description) event.desc = input.description;
|
|
250
|
+
if (input.description) event.desc = redactSecrets(input.description);
|
|
186
251
|
const dispatchPrompt = capField(input.prompt || '');
|
|
187
252
|
event.prompt = dispatchPrompt.value;
|
|
188
253
|
event.promptChars = dispatchPrompt.chars;
|
|
254
|
+
} else if (tool === 'SendMessage') {
|
|
255
|
+
if (input.to) event.target = String(input.to);
|
|
256
|
+
event.messageChars = typeof input.message === 'string' ? input.message.length : 0;
|
|
189
257
|
} else if (tool === 'Read') {
|
|
190
258
|
event.target = targetFilePath || normalize(input.file_path || input.path);
|
|
191
259
|
event.kind = classifyReadKind(event.target);
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
260
|
+
if (Number.isInteger(input.offset)) event.offset = input.offset;
|
|
261
|
+
if (Number.isInteger(input.limit)) event.limit = input.limit;
|
|
262
|
+
// A failed Read ingested nothing. A stat/read race with a file deleted
|
|
263
|
+
// between the Read and this hook omits the numbers rather than fail.
|
|
264
|
+
if (hookEvent !== 'PostToolUseFailure') {
|
|
265
|
+
try {
|
|
266
|
+
const abs = resolveTargetPath(event.target, cwd);
|
|
267
|
+
event.bytes = statSync(abs).size;
|
|
268
|
+
const read = readSliceBytes(abs, input, event.bytes);
|
|
269
|
+
if (read !== undefined) event.bytesRead = read;
|
|
270
|
+
} catch {
|
|
271
|
+
// omit bytes
|
|
272
|
+
}
|
|
199
273
|
}
|
|
200
|
-
} else if (tool === 'Bash') {
|
|
201
|
-
const
|
|
202
|
-
event.command =
|
|
203
|
-
event.commandChars =
|
|
274
|
+
} else if (tool === 'Bash' || tool === 'PowerShell') {
|
|
275
|
+
const command = capField(input.command || '');
|
|
276
|
+
event.command = command.value;
|
|
277
|
+
event.commandChars = command.chars;
|
|
278
|
+
Object.assign(event, outcomeOf(payload, input));
|
|
279
|
+
if (exitCodeMasked(input.command)) event.exitMasked = true;
|
|
204
280
|
} else if (tool === 'Write' || tool === 'Edit') {
|
|
205
281
|
event.target = normalize(input.file_path || input.path);
|
|
206
282
|
} else {
|
|
207
|
-
// Every other tool still counts toward "the whole path" —
|
|
208
|
-
//
|
|
283
|
+
// Every other tool still counts toward "the whole path" — a compact
|
|
284
|
+
// target keeps Grep/Glob/WebFetch bursts distinguishable.
|
|
209
285
|
const target = input.file_path || input.path || input.pattern || input.query || input.url || '';
|
|
210
286
|
if (target) event.target = normalize(String(target));
|
|
211
287
|
}
|
|
288
|
+
|
|
289
|
+
if (hookEvent === 'PostToolUseFailure' && event.outcome === undefined) event.outcome = 'error';
|
|
290
|
+
if (payload.tool_use_id) event.toolUseId = payload.tool_use_id;
|
|
291
|
+
if (Number.isFinite(payload.duration_ms)) event.durationMs = payload.duration_ms;
|
|
212
292
|
}
|
|
213
293
|
|
|
294
|
+
if (payload.session_id) event.sessionId = payload.session_id;
|
|
295
|
+
if (payload.prompt_id) event.promptId = payload.prompt_id;
|
|
296
|
+
if (payload.agent_id) event.agentId = payload.agent_id;
|
|
297
|
+
if (payload.agent_type) event.agentType = payload.agent_type;
|
|
298
|
+
if (payload.cwd) event.cwd = normalize(payload.cwd);
|
|
214
299
|
event.feature = feature;
|
|
215
300
|
event.phase = phase;
|
|
216
301
|
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* Fail-open: exits 0 on any error.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync
|
|
24
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'fs';
|
|
25
25
|
import { join } from 'path';
|
|
26
26
|
import { createHash } from 'crypto';
|
|
27
27
|
import { runBuildCheck } from '../../shared/build-check.js';
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
import { contextResult, runStandalone } from '../../shared/hook-response.js';
|
|
32
32
|
import { logHookActivity, logEvent } from '../../shared/activity-logger.js';
|
|
33
33
|
import { appendTelemetryEvent } from '../../shared/telemetry-logger.js';
|
|
34
|
+
import { readTranscriptUsage, usageDelta } from '../../shared/transcript-usage.js';
|
|
34
35
|
|
|
35
36
|
/**
|
|
36
37
|
* Audit recap.md against tasks.json + standard-citation rule.
|
|
@@ -122,80 +123,6 @@ function auditRecapAndTasks(featurePath) {
|
|
|
122
123
|
return warnings;
|
|
123
124
|
}
|
|
124
125
|
|
|
125
|
-
// Safety valve for a pathological outlier transcript — NOT needed for the
|
|
126
|
-
// normal case: a real 45 MB / ~16k-line transcript (inspected directly while
|
|
127
|
-
// building this) parses+sums in ~350ms locally, well inside a Stop hook's
|
|
128
|
-
// budget. Above this, skip rather than risk an unbounded read.
|
|
129
|
-
const MAX_TRANSCRIPT_BYTES = 50 * 1024 * 1024; // 50 MB
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Sum real token usage from a Claude Code session transcript (JSONL, the
|
|
133
|
-
* `transcript_path` the Stop payload carries).
|
|
134
|
-
*
|
|
135
|
-
* Each assistant TURN is written to the transcript as MULTIPLE JSONL lines —
|
|
136
|
-
* one per content block (thinking/text/tool_use) — every one of them
|
|
137
|
-
* repeating the exact SAME turn-level `message.usage` object under the same
|
|
138
|
-
* `message.id`. Verified against a real transcript: 5982 assistant lines but
|
|
139
|
-
* only 3315 unique message ids, with byte-identical usage across duplicates
|
|
140
|
-
* of the same id — summing every line naively double/triple-counts (measured
|
|
141
|
-
* ~2x on output_tokens on that file). This is why a per-line sum is wrong and
|
|
142
|
-
* a dedup-by-id sum is required; there is no running/cumulative total field
|
|
143
|
-
* to lean on instead — each turn's `usage` is that turn's own cost.
|
|
144
|
-
*
|
|
145
|
-
* Tolerant to shape: any missing usage field reads as 0. A corrupt/truncated
|
|
146
|
-
* line is skipped, not fatal — same discipline as telemetry-logger's reader.
|
|
147
|
-
*
|
|
148
|
-
* @param {string} [transcriptPath]
|
|
149
|
-
* @returns {{inputTokens:number,outputTokens:number,cacheReadTokens:number,cacheCreationTokens:number}|null}
|
|
150
|
-
* null when there is nothing to report (no path, missing/oversized/
|
|
151
|
-
* unreadable file, or zero assistant messages carrying usage).
|
|
152
|
-
*/
|
|
153
|
-
function computeSessionUsage(transcriptPath) {
|
|
154
|
-
if (!transcriptPath || typeof transcriptPath !== 'string') return null;
|
|
155
|
-
try {
|
|
156
|
-
if (!existsSync(transcriptPath)) return null;
|
|
157
|
-
if (statSync(transcriptPath).size > MAX_TRANSCRIPT_BYTES) return null;
|
|
158
|
-
|
|
159
|
-
const raw = readFileSync(transcriptPath, 'utf-8');
|
|
160
|
-
let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheCreationTokens = 0;
|
|
161
|
-
const seenMessageIds = new Set();
|
|
162
|
-
let sawAny = false;
|
|
163
|
-
|
|
164
|
-
for (const line of raw.split('\n')) {
|
|
165
|
-
const trimmed = line.trim();
|
|
166
|
-
if (!trimmed) continue;
|
|
167
|
-
let entry;
|
|
168
|
-
try {
|
|
169
|
-
entry = JSON.parse(trimmed);
|
|
170
|
-
} catch {
|
|
171
|
-
continue; // one corrupt/truncated line must not lose the whole transcript
|
|
172
|
-
}
|
|
173
|
-
if (entry?.type !== 'assistant') continue;
|
|
174
|
-
const usage = entry?.message?.usage;
|
|
175
|
-
if (!usage || typeof usage !== 'object') continue;
|
|
176
|
-
|
|
177
|
-
// Dedup by message.id (fallback to the line's own uuid when a message
|
|
178
|
-
// carries none) — first occurrence wins, duplicates carry identical
|
|
179
|
-
// values so which one wins never matters.
|
|
180
|
-
const dedupKey = entry?.message?.id || entry?.uuid;
|
|
181
|
-
if (dedupKey) {
|
|
182
|
-
if (seenMessageIds.has(dedupKey)) continue;
|
|
183
|
-
seenMessageIds.add(dedupKey);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
sawAny = true;
|
|
187
|
-
inputTokens += Number(usage.input_tokens) || 0;
|
|
188
|
-
outputTokens += Number(usage.output_tokens) || 0;
|
|
189
|
-
cacheReadTokens += Number(usage.cache_read_input_tokens) || 0;
|
|
190
|
-
cacheCreationTokens += Number(usage.cache_creation_input_tokens) || 0;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
return sawAny ? { inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens } : null;
|
|
194
|
-
} catch {
|
|
195
|
-
return null; // fail-open
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
|
|
199
126
|
// Phases where compaction is strongly recommended before starting
|
|
200
127
|
const COMPACT_TRIGGER_PHASES = new Set(['implement']);
|
|
201
128
|
|
|
@@ -343,13 +270,11 @@ export async function run(payload) {
|
|
|
343
270
|
// ────────────────────────────────────────────────────────────────────────
|
|
344
271
|
|
|
345
272
|
// ── Session usage (real tokens, from the transcript) ──────────────────────
|
|
346
|
-
// Silent side effect
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
// for this session_id, so an unchanged transcript across consecutive
|
|
350
|
-
// Stops (no new assistant turn) does not re-append an identical event.
|
|
273
|
+
// Silent side effect. The transcript total is cumulative, so each event
|
|
274
|
+
// also carries the `delta` since the previous sample of the same session:
|
|
275
|
+
// summing deltas per sessionId is the only sum that does not double-count.
|
|
351
276
|
try {
|
|
352
|
-
const usage =
|
|
277
|
+
const usage = readTranscriptUsage(payload?.transcript_path);
|
|
353
278
|
if (usage) {
|
|
354
279
|
const usagePath = join(memDir, 'session-usage.json');
|
|
355
280
|
let stored = {};
|
|
@@ -358,15 +283,16 @@ export async function run(payload) {
|
|
|
358
283
|
} catch { /* corrupt file — start fresh */ }
|
|
359
284
|
|
|
360
285
|
const sessionKey = payload?.session_id || 'default';
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
286
|
+
const { delta, changed } = usageDelta(stored[sessionKey], usage);
|
|
287
|
+
if (changed) {
|
|
288
|
+
appendTelemetryEvent({
|
|
289
|
+
event: 'SessionUsage',
|
|
290
|
+
sessionId: payload?.session_id ?? null,
|
|
291
|
+
feature: name,
|
|
292
|
+
phase: currentPhase,
|
|
293
|
+
...usage,
|
|
294
|
+
delta,
|
|
295
|
+
}, cwd);
|
|
370
296
|
stored[sessionKey] = usage;
|
|
371
297
|
try { writeFileSync(usagePath, JSON.stringify(stored, null, 2), 'utf-8'); } catch { /* non-blocking */ }
|
|
372
298
|
}
|
|
@@ -50,6 +50,7 @@ import { contextResult, runStandalone } from '../../shared/hook-response.js';
|
|
|
50
50
|
import { acquireClaim, foreignClaim, claimAge } from '../../shared/claim-registry.js';
|
|
51
51
|
import { rotateActivityLogIfLarge } from '../../shared/activity-logger.js';
|
|
52
52
|
import { rotateEventsLogIfLarge } from '../../shared/telemetry-logger.js';
|
|
53
|
+
import { kickGraphRefresh, seedFromPrimary } from '../../shared/graph-refresh.js';
|
|
53
54
|
|
|
54
55
|
function projectDir() {
|
|
55
56
|
return process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
@@ -293,6 +294,15 @@ export async function run(payload) {
|
|
|
293
294
|
// helpers are themselves fail-open (return false on any error).
|
|
294
295
|
try { rotateActivityLogIfLarge(dir); } catch { /* non-blocking */ }
|
|
295
296
|
try { rotateEventsLogIfLarge(dir); } catch { /* non-blocking */ }
|
|
297
|
+
// Between sessions is where commits made outside the agent (a terminal, an
|
|
298
|
+
// IDE, a `git pull`) are caught: the detached refresh brings the ROOT's
|
|
299
|
+
// graph to HEAD — never a worktree's (a detached process holding a
|
|
300
|
+
// worktree directory made `finish` fail on win32). A worktree that has no
|
|
301
|
+
// graph yet (created before seeding existed) gets the root's copy here;
|
|
302
|
+
// from then on it is refreshed by hand (`morph-spec graph refresh`).
|
|
303
|
+
// Returns immediately — nothing here waits on graphify.
|
|
304
|
+
try { seedFromPrimary(dir); } catch { /* non-blocking — a no-op on the root */ }
|
|
305
|
+
try { kickGraphRefresh(dir); } catch { /* non-blocking */ }
|
|
296
306
|
|
|
297
307
|
if (isSecondaryWorktree(dir)) {
|
|
298
308
|
const task = taskFromBranch(dir);
|
|
@@ -21,3 +21,99 @@ export function tokenize(command) {
|
|
|
21
21
|
while ((m = re.exec(String(command || ''))) !== null) tokens.push(m[1] ?? m[2] ?? m[3]);
|
|
22
22
|
return tokens;
|
|
23
23
|
}
|
|
24
|
+
|
|
25
|
+
/** Index of the first pipe (`|`, not `||` nor `>|`) outside quotes, or -1. */
|
|
26
|
+
function firstUnquotedPipe(command) {
|
|
27
|
+
const s = String(command || '');
|
|
28
|
+
let quote = null;
|
|
29
|
+
for (let i = 0; i < s.length; i++) {
|
|
30
|
+
const ch = s[i];
|
|
31
|
+
if (quote) {
|
|
32
|
+
if (ch === quote) quote = null;
|
|
33
|
+
else if (ch === '\\' && quote === '"') i++;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (ch === '\\') { i++; continue; }
|
|
37
|
+
if (ch === '"' || ch === "'") { quote = ch; continue; }
|
|
38
|
+
if (ch !== '|') continue;
|
|
39
|
+
if (s[i + 1] === '|') { i++; continue; }
|
|
40
|
+
if (s[i - 1] === '>') continue;
|
|
41
|
+
return i;
|
|
42
|
+
}
|
|
43
|
+
return -1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Does the command pipe one process into another outside quotes? Without
|
|
48
|
+
* `pipefail` the shell reports the LAST command's status, so the exit code of
|
|
49
|
+
* `dotnet test … | grep …` is grep's, not the test run's.
|
|
50
|
+
* @param {string} command
|
|
51
|
+
* @returns {boolean}
|
|
52
|
+
*/
|
|
53
|
+
export function hasUnquotedPipe(command) {
|
|
54
|
+
return firstUnquotedPipe(command) >= 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The work a command does, without the filter that shapes its output:
|
|
59
|
+
* `dotnet test 2>&1 | tail -25` and `dotnet test | tail -20` run the same
|
|
60
|
+
* tests. Whitespace collapsed, stderr redirections dropped.
|
|
61
|
+
* @param {string} command
|
|
62
|
+
* @returns {string}
|
|
63
|
+
*/
|
|
64
|
+
export function producerOf(command) {
|
|
65
|
+
const s = String(command || '');
|
|
66
|
+
const pipe = firstUnquotedPipe(s);
|
|
67
|
+
return (pipe >= 0 ? s.slice(0, pipe) : s)
|
|
68
|
+
.replace(/\s2>(&1|\/dev\/null|nul)\b/gi, ' ')
|
|
69
|
+
.replace(/\s+/g, ' ')
|
|
70
|
+
.trim();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The command split at `;`, newline and background `&` outside quotes, plus
|
|
75
|
+
* whether a background `&` was seen. `&&`, `||` and `>&`/`&>` redirections
|
|
76
|
+
* stay inside their segment.
|
|
77
|
+
*/
|
|
78
|
+
function topLevelSegments(command) {
|
|
79
|
+
const s = String(command || '');
|
|
80
|
+
const segments = [];
|
|
81
|
+
let start = 0;
|
|
82
|
+
let background = false;
|
|
83
|
+
let quote = null;
|
|
84
|
+
for (let i = 0; i < s.length; i++) {
|
|
85
|
+
const ch = s[i];
|
|
86
|
+
if (quote) {
|
|
87
|
+
if (ch === quote) quote = null;
|
|
88
|
+
else if (ch === '\\' && quote === '"') i++;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (ch === '\\') { i++; continue; }
|
|
92
|
+
if (ch === '"' || ch === "'") { quote = ch; continue; }
|
|
93
|
+
const isAmp = ch === '&' && s[i + 1] !== '&' && s[i - 1] !== '&' && s[i - 1] !== '>' && s[i + 1] !== '>';
|
|
94
|
+
if (ch !== ';' && ch !== '\n' && !isAmp) continue;
|
|
95
|
+
if (isAmp) background = true;
|
|
96
|
+
segments.push(s.slice(start, i));
|
|
97
|
+
start = i + 1;
|
|
98
|
+
}
|
|
99
|
+
segments.push(s.slice(start));
|
|
100
|
+
return { segments: segments.map((x) => x.trim()).filter(Boolean), background };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const REPORTER = /^(?:echo|printf|true|:|exit\s+0)\b/;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Is the exit code of this command the exit code of the work it ran? The
|
|
107
|
+
* shell reports the last segment of a `;` sequence: `dotnet test > log;
|
|
108
|
+
* echo "exit=$?"` always exits 0, and so does `dotnet test || true`. A pipe
|
|
109
|
+
* without `pipefail` reports the filter, and a background `&` reports 0.
|
|
110
|
+
* @param {string} command
|
|
111
|
+
* @returns {boolean}
|
|
112
|
+
*/
|
|
113
|
+
export function exitCodeMasked(command) {
|
|
114
|
+
const { segments, background } = topLevelSegments(command);
|
|
115
|
+
if (background) return true;
|
|
116
|
+
if (hasUnquotedPipe(command) && !/\bpipefail\b/.test(String(command || ''))) return true;
|
|
117
|
+
const last = segments[segments.length - 1] || '';
|
|
118
|
+
return (segments.length > 1 && REPORTER.test(last)) || /\|\|/.test(last.replace(/"[^"]*"|'[^']*'/g, ''));
|
|
119
|
+
}
|