@polymorphism-tech/morph-spec 8.33.0 → 8.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +22 -2
- package/README.md +2 -2
- package/bin/morph-spec.js +43 -5
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +5 -4
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +2 -2
- package/framework/MORPH.md +20 -22
- package/framework/agents.json +198 -44
- package/framework/ai-pin.json +22 -0
- package/framework/commands/morph-media.md +45 -0
- package/framework/docs/CLI.md +238 -10
- package/framework/evals/proof-rubrics/README.md +76 -0
- package/framework/evals/proof-rubrics/eval-de-modelo.md +64 -0
- package/framework/evals/proof-rubrics/fiacao.md +54 -0
- package/framework/evals/proof-rubrics/guard-cego.md +52 -0
- package/framework/evals/proof-rubrics/media-claims.md +109 -0
- package/framework/evals/proof-rubrics/mutation-proof.md +77 -0
- package/framework/evals/proof-rubrics/observabilidade-genai.md +119 -0
- package/framework/evals/proof-rubrics/verde-nao-e-coberto.md +48 -0
- package/framework/evals/rubrics/architecture.md +30 -0
- package/framework/evals/rubrics/code-quality.md +26 -0
- package/framework/evals/rubrics/test-coverage.md +20 -0
- package/framework/hooks/README.md +11 -1
- package/framework/hooks/claude-code/core/block-destructive-worktree-removal.js +5 -8
- package/framework/hooks/claude-code/core/block-raw-worktree-add.js +79 -0
- package/framework/hooks/claude-code/core/chain.js +27 -7
- package/framework/hooks/claude-code/core/protect-spec-files.js +41 -4
- package/framework/hooks/claude-code/core/worktree-context.js +83 -14
- package/framework/hooks/claude-code/core/worktree-create.js +206 -0
- package/framework/hooks/claude-code/core/worktree-env-inject.js +87 -0
- package/framework/hooks/claude-code/core/worktree-remove.js +81 -0
- package/framework/hooks/shared/command-tokens.js +23 -0
- package/framework/hooks/shared/e2e-check.js +0 -0
- package/framework/hooks/shared/git-paths.js +25 -0
- package/framework/hooks/shared/hook-response.js +17 -0
- package/framework/hooks/shared/morph-cli.js +134 -0
- package/framework/rules/ai-agents-standards.md +14 -2
- package/framework/rules/infrastructure-standards.md +3 -1
- package/framework/schemas/ai-pin.schema.json +97 -0
- package/framework/schemas/config.schema.json +107 -0
- package/framework/schemas/feature.schema.json +23 -4
- 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 +28 -7
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +2 -2
- package/framework/skills/core/morph-implement/SKILL.md +20 -2
- package/framework/skills/core/morph-media/SKILL.md +215 -0
- package/framework/skills/core/morph-plan/SKILL.md +38 -4
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +166 -9
- 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 +1 -1
- package/framework/standards/PROVENANCE.json +81 -3
- package/framework/standards/STANDARDS.json +1504 -830
- package/framework/standards/ai-agents/agent-archetypes.md +160 -0
- package/framework/standards/ai-agents/agent-session.md +256 -160
- package/framework/standards/ai-agents/agent-spec.md +196 -0
- package/framework/standards/ai-agents/batching.md +217 -193
- package/framework/standards/ai-agents/context-providers.md +261 -188
- package/framework/standards/ai-agents/conversational-agent-with-phases.md +239 -0
- package/framework/standards/ai-agents/cost-and-budget.md +256 -0
- package/framework/standards/ai-agents/durable-workflows-hitl.md +98 -5
- package/framework/standards/ai-agents/evals-with-cache.md +289 -0
- package/framework/standards/ai-agents/guardrails.md +222 -0
- package/framework/standards/ai-agents/llm-runtime-defaults.md +226 -0
- package/framework/standards/ai-agents/mcp-server.md +351 -250
- package/framework/standards/ai-agents/mcp-tools.md +250 -197
- package/framework/standards/ai-agents/media-pipeline.md +352 -0
- package/framework/standards/ai-agents/media-video.md +267 -0
- package/framework/standards/ai-agents/middleware-patterns.md +241 -207
- package/framework/standards/ai-agents/modalities-image-gen.md +422 -173
- package/framework/standards/ai-agents/modalities-speech-to-text.md +177 -171
- package/framework/standards/ai-agents/modalities-text-to-speech.md +189 -181
- package/framework/standards/ai-agents/modalities-vision.md +205 -201
- package/framework/standards/ai-agents/multi-agent-patterns.md +211 -155
- package/framework/standards/ai-agents/observability-patterns.md +227 -17
- package/framework/standards/ai-agents/production.md +260 -231
- package/framework/standards/ai-agents/prompt-sources.md +389 -0
- package/framework/standards/ai-agents/providers/model-registry.md +293 -158
- package/framework/standards/ai-agents/rag-custom-pgvector.md +69 -4
- package/framework/standards/ai-agents/service-tier-flex.md +143 -135
- package/framework/standards/ai-agents/setup.md +166 -50
- package/framework/standards/ai-agents/structured-output.md +280 -249
- package/framework/standards/ai-agents/sweet-spot.md +129 -109
- package/framework/standards/ai-agents/testing-ai.md +236 -0
- package/framework/standards/ai-agents/vector-data-extensions.md +50 -20
- package/framework/standards/ai-agents/workflows.md +27 -7
- package/framework/standards/frontend/design-system/ai-image-generation.md +12 -0
- package/framework/standards/frontend/design-system/ai-video-generation.md +13 -0
- package/framework/standards/infrastructure/docker/local-compose-isolation.md +90 -0
- package/framework/templates/REGISTRY.json +14 -1
- package/framework/templates/code/dotnet/ai-agents/PromptRepository.cs.template +95 -95
- package/framework/templates/code/sql/ai-agents/agent-prompts-migration.sql.template +58 -58
- package/framework/templates/dotnet/ai-kit/Directory.Build.props +25 -0
- package/framework/templates/dotnet/ai-kit/Directory.Packages.props +26 -0
- package/framework/templates/dotnet/ai-kit/Morph.AiKit.sln +56 -0
- package/framework/templates/dotnet/ai-kit/NuGet.config +12 -0
- package/framework/templates/dotnet/ai-kit/README.md +128 -0
- package/framework/templates/dotnet/ai-kit/global.json +9 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentApi.cs +65 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentFactory.cs +289 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentFactoryOptions.cs +91 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentSpec.cs +91 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentSpecOptions.cs +181 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/AiKitAssemblyMarker.cs +14 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Conversation/ConversationStore.cs +174 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Conversation/IConversationStore.cs +54 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Conversation/InMemoryConversationStore.cs +75 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/CountingChatClient.cs +71 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalAssertion.cs +246 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalCacheMissException.cs +57 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalCacheOptions.cs +78 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalCacheStamp.cs +225 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalFixture.cs +463 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalMode.cs +32 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalQualityEvaluators.cs +49 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalScenario.cs +214 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalSummary.cs +168 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/OfflineChatClient.cs +142 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/ShortPathResponseCache.cs +349 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/IMediaGenerator.cs +42 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/IMediaProvider.cs +77 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaArtifact.cs +28 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaDisclosure.cs +107 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaGenerator.cs +253 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaIdempotencyKey.cs +103 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaJob.cs +259 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaJobId.cs +21 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaJobState.cs +47 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaPricing.cs +102 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaProvenance.cs +44 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaRequest.cs +192 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaResult.cs +94 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaReview.cs +99 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaSeams.cs +97 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaStore.cs +133 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MorphMedia.cs +93 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/Providers/GoogleImageProvider.cs +112 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/Providers/OpenAIImageProvider.cs +288 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/Providers/VeoVideoProvider.cs +158 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Middleware/IUsageCostSink.cs +29 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Middleware/UsageCostMiddleware.cs +208 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Middleware/UsageCostRecord.cs +95 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Morph.AiKit.csproj +38 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Observability/MorphOpenTelemetryExtensions.cs +119 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Observability/MorphTelemetryOptions.cs +82 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/IProviderClientFactory.cs +33 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/ModelAlias.cs +267 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/ModelRegistry.cs +474 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/model-registry.v2.json +87 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Rag/DeltaIngest.cs +169 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Rag/DeltaPlan.cs +49 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/AgentFactoryTests.cs +495 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/AgentSpecTests.cs +260 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/ConversationStoreTests.cs +405 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/DeltaIngestTests.cs +379 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalAssertionTests.cs +197 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalCacheMissTests.cs +139 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalCacheStampTests.cs +157 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalFailuresTests.cs +178 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalFixtureTests.cs +153 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalRefreshTests.cs +194 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalWorkspace.cs +86 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/KitEvals.cs +213 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/OfflineChatClientTests.cs +95 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeChatClient.cs +225 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeChatClientTests.cs +215 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeClock.cs +33 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeMediaProvider.cs +89 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeProviderClientFactory.cs +55 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeUsageCostSink.cs +60 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/InMemoryMediaStore.cs +131 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaAccessibilityTests.cs +133 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaAliasIsNotAnAgentTests.cs +127 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaDisclosureTests.cs +216 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaGeneratorTests.cs +264 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaIdempotencyKeyTests.cs +148 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaJobStateTests.cs +184 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaReviewTests.cs +89 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaStoreTests.cs +98 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/ModelRegistryTests.cs +451 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Morph.AiKit.Tests.csproj +76 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/SkeletonSmokeTests.cs +33 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/TelemetryTests.cs +261 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/UsageCostMiddlewareTests.cs +374 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/.morph-eval-cache.json +12 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/orcamento.acme.a-resposta-estruturada/1/CF70A7CE27BBD66A/contents.data +13 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/orcamento.acme.a-resposta-estruturada/1/CF70A7CE27BBD66A/entry.json +7 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/triagem.acme.a-saudacao/1/19F631228C6E00BA/contents.data +13 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/triagem.acme.a-saudacao/1/19F631228C6E00BA/entry.json +7 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/triagem.acme.b-escalonamento/1/A24A7C9BDD8239D8/contents.data +13 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/triagem.acme.b-escalonamento/1/A24A7C9BDD8239D8/entry.json +7 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/triagem.globex.a-saudacao/1/E38CD37DF003EDB7/contents.data +13 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/triagem.globex.a-saudacao/1/E38CD37DF003EDB7/entry.json +7 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-datasets/orcamento/acme/a-resposta-estruturada.json +24 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-datasets/triagem/acme/a-saudacao.json +23 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-datasets/triagem/acme/b-escalonamento.json +24 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-datasets/triagem/globex/a-saudacao.json +23 -0
- package/framework/templates/infrastructure/docker/docker-compose.template.yml +24 -14
- package/package.json +3 -3
- package/src/commands/e2e/e2e.js +91 -40
- package/src/commands/project/doctor.js +143 -6
- package/src/commands/project/env.js +95 -0
- package/src/commands/project/update.js +469 -153
- package/src/commands/project/worktree.js +274 -53
- package/src/commands/state/advance.js +24 -2
- package/src/commands/state/approve.js +33 -1
- package/src/commands/state/create.js +72 -11
- package/src/commands/state/delete.js +8 -0
- package/src/commands/state/finish.js +360 -53
- package/src/commands/state/gate-check.js +190 -6
- package/src/commands/state/score.js +36 -1
- package/src/commands/validation/verify.js +134 -25
- package/src/core/state/state-manager.js +9 -1
- package/src/dashboard/app/pages/HookInspector.jsx +430 -418
- package/src/dashboard/scanner/hooks-scanner.js +5 -1
- package/src/lib/ai/ai-checks.js +182 -0
- package/src/lib/ai/ai-project-scan.js +347 -0
- package/src/lib/ai/code-audit.js +181 -0
- package/src/lib/ai/comment-stripper.js +184 -0
- package/src/lib/ai/pin-drift.js +138 -0
- package/src/lib/ai/registry-audit.js +285 -0
- package/src/lib/e2e/compose-env.js +55 -14
- package/src/lib/e2e/stack-teardown.js +115 -0
- package/src/lib/evaluator-report.js +269 -15
- package/src/lib/generators/architecture-scanner.js +2 -1
- package/src/lib/git/default-branch.js +92 -4
- package/src/lib/graph/graphifyignore.js +2 -1
- package/src/lib/installers/effects.js +193 -0
- package/src/lib/installers/templates-reconcile.js +3 -3
- package/src/lib/risk-detector.js +284 -0
- package/src/lib/standards/verified-against.js +276 -0
- package/src/lib/tasks/eval-node.js +485 -0
- package/src/lib/tasks/linux-runner.js +297 -0
- package/src/lib/tasks/test-nodes.js +486 -0
- package/src/lib/tasks/test-runner.js +431 -37
- package/src/lib/validators/verify-runner.js +590 -28
- package/src/lib/worktree/dirs.js +32 -0
- package/src/lib/worktree/docker-audit.js +287 -0
- package/src/lib/worktree/env-allocator.js +69 -11
- package/src/lib/worktree/worktree-audit.js +70 -3
- package/src/scripts/setup-infra.js +24 -11
- package/src/utils/agents-installer.js +18 -8
- package/src/utils/claude-md-injector.js +10 -5
- package/src/utils/file-copier.js +28 -16
- package/src/utils/hooks-installer.js +75 -29
- package/src/utils/skills-installer.js +194 -187
- package/src/utils/version-checker.js +68 -3
- package/framework/standards/ai-agents/prompts-in-db.md +0 -214
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { blockResult, runStandalone } from '../../shared/hook-response.js';
|
|
3
|
+
import { stateExists } from '../../shared/state-reader.js';
|
|
4
|
+
// From shared/, never from the sibling hook: importing a hook module fires its
|
|
5
|
+
// standalone tail (stdin read + exit) inside THIS process.
|
|
6
|
+
import { tokenize } from '../../shared/command-tokens.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* block-raw-worktree-add — PreToolUse (Bash) guard against `git worktree add`.
|
|
10
|
+
*
|
|
11
|
+
* A worktree created by raw git is a worktree morph cannot see: no infra
|
|
12
|
+
* junctions (`.claude`, `node_modules`, `.morph/framework` — so NO hooks run
|
|
13
|
+
* in there), no port block (its dev server collides with the root's), no
|
|
14
|
+
* feature/task identity (invisible to `fleet`, `doctor --worktrees`,
|
|
15
|
+
* `worktree list`), and a location git chose, not `<root>/worktrees/`. The
|
|
16
|
+
* sibling guard (block-destructive-worktree-removal) protects the way OUT;
|
|
17
|
+
* this one protects the way IN, by naming the two commands that build a
|
|
18
|
+
* governed tree:
|
|
19
|
+
*
|
|
20
|
+
* morph-spec worktree setup <feature> — a feature's tree
|
|
21
|
+
* morph-spec worktree provision <name> [--base <ref>] — a task-isolation tree
|
|
22
|
+
*
|
|
23
|
+
* DELIBERATELY NARROW: only the `add` subcommand. `list`, `prune`, `remove`
|
|
24
|
+
* (the sibling's business), `lock`, `unlock`, `move`, `repair` all pass. The
|
|
25
|
+
* `git` token may be followed by global options (`-C <dir>`, `--git-dir=<d>`,
|
|
26
|
+
* `-c k=v`) before `worktree`, and may sit anywhere in a chained command
|
|
27
|
+
* (`cd a && git worktree add b`) — every `git` occurrence is checked.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/** Global `git` options that take a separate value: `git -C <dir> worktree add`. */
|
|
31
|
+
const GIT_GLOBAL_WITH_VALUE = new Set(['-c', '-C', '--git-dir', '--work-tree', '--namespace', '--exec-path', '--config-env']);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Does this command line invoke `git worktree add` anywhere?
|
|
35
|
+
* @param {string} command
|
|
36
|
+
* @returns {boolean}
|
|
37
|
+
*/
|
|
38
|
+
export function isRawWorktreeAdd(command) {
|
|
39
|
+
const tokens = tokenize(command);
|
|
40
|
+
const lower = tokens.map((t) => t.toLowerCase());
|
|
41
|
+
for (let i = 0; i < lower.length; i++) {
|
|
42
|
+
if (lower[i] !== 'git' && !/(^|[\\/])git(\.exe)?$/.test(lower[i])) continue;
|
|
43
|
+
let j = i + 1;
|
|
44
|
+
// Skip global options between `git` and the subcommand.
|
|
45
|
+
while (j < lower.length && lower[j].startsWith('-')) {
|
|
46
|
+
if (GIT_GLOBAL_WITH_VALUE.has(tokens[j])) j += 2; // `-C dir` consumes its value
|
|
47
|
+
else j += 1; // `--git-dir=x`, `--no-pager`, …
|
|
48
|
+
}
|
|
49
|
+
if (lower[j] === 'worktree' && lower[j + 1] === 'add') return true;
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {object} payload - parsed PreToolUse stdin payload (Bash matcher)
|
|
56
|
+
* @returns {Promise<object|null>} `{decision:'block',reason}` or null (pass).
|
|
57
|
+
*/
|
|
58
|
+
export async function run(payload) {
|
|
59
|
+
try {
|
|
60
|
+
if (!stateExists()) return null;
|
|
61
|
+
|
|
62
|
+
const command = payload?.tool_input?.command || '';
|
|
63
|
+
if (!command || !isRawWorktreeAdd(command)) return null;
|
|
64
|
+
|
|
65
|
+
return blockResult(
|
|
66
|
+
'Bloqueado: `git worktree add` cru cria um worktree que o morph-spec NÃO enxerga — sem as junctions de infra ' +
|
|
67
|
+
'(`.claude`, `node_modules`, `.morph/framework` → nenhum hook roda lá dentro), sem bloco de portas (o dev server ' +
|
|
68
|
+
'colide com o da raiz), sem identidade (invisível para `fleet`, `doctor --worktrees` e `worktree list`) e fora de `<raiz>/worktrees/`.\n\n' +
|
|
69
|
+
'Use o comando que constrói a árvore governada:\n' +
|
|
70
|
+
' - feature: `morph-spec worktree setup <feature>`\n' +
|
|
71
|
+
' - tarefa isolada (sem feature): `morph-spec worktree provision <nome> --base <ref>`\n\n' +
|
|
72
|
+
'Os dois deixam a árvore em `<raiz>/worktrees/<nome>` com a infra completa; `list`/`prune`/`remove` do git não são afetados por este guard.'
|
|
73
|
+
);
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
await runStandalone(run);
|
|
@@ -37,6 +37,13 @@
|
|
|
37
37
|
* mesmo grupo observam o MESMO evento real do Claude Code, então isto só
|
|
38
38
|
* importa como desempate determinístico); na ausência de qualquer elo
|
|
39
39
|
* declarando, cai para `payload.hook_event_name`; por último, `'PostToolUse'`.
|
|
40
|
+
* - updatedInput (PreToolUse): um elo pode devolver
|
|
41
|
+
* `hookSpecificOutput.updatedInput` (ver `updatedInputResult` em
|
|
42
|
+
* hook-response.js) para reescrever o tool_input. O ÚLTIMO elo a devolver um
|
|
43
|
+
* vence (só um elo do grupo reescreve, por construção). `permissionDecision:
|
|
44
|
+
* 'allow'` + `updatedInput` só entram no resultado final quando algum elo
|
|
45
|
+
* reescreveu — um `allow` incondicional pularia o prompt de permissão de
|
|
46
|
+
* TODO Bash, que é exatamente o contrário de um guardrail.
|
|
40
47
|
* - Fail-open POR ELO: uma exceção não tratada por um hook individual (import
|
|
41
48
|
* falhou, run() não existe, etc.) não derruba a cadeia — os elos seguintes
|
|
42
49
|
* ainda rodam. Cada hook já fail-opens sozinho dentro do seu próprio
|
|
@@ -78,6 +85,7 @@ async function main() {
|
|
|
78
85
|
|
|
79
86
|
const contexts = [];
|
|
80
87
|
let eventName = null;
|
|
88
|
+
let updatedInput = null;
|
|
81
89
|
|
|
82
90
|
for (const name of names) {
|
|
83
91
|
let result = null;
|
|
@@ -97,6 +105,12 @@ async function main() {
|
|
|
97
105
|
process.exit(0);
|
|
98
106
|
}
|
|
99
107
|
|
|
108
|
+
const rewrite = result?.hookSpecificOutput?.updatedInput;
|
|
109
|
+
if (rewrite && typeof rewrite === 'object') {
|
|
110
|
+
updatedInput = rewrite; // last writer wins
|
|
111
|
+
eventName = eventName || result?.hookSpecificOutput?.hookEventName;
|
|
112
|
+
}
|
|
113
|
+
|
|
100
114
|
const ctx = contextOf(result);
|
|
101
115
|
if (ctx) {
|
|
102
116
|
contexts.push(ctx);
|
|
@@ -107,16 +121,22 @@ async function main() {
|
|
|
107
121
|
}
|
|
108
122
|
}
|
|
109
123
|
|
|
110
|
-
if (contexts.length === 0) {
|
|
124
|
+
if (contexts.length === 0 && !updatedInput) {
|
|
111
125
|
process.exit(0);
|
|
112
126
|
}
|
|
113
127
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
128
|
+
const hookSpecificOutput = {
|
|
129
|
+
hookEventName: eventName || payload?.hook_event_name || 'PostToolUse',
|
|
130
|
+
};
|
|
131
|
+
// `permissionDecision:'allow'` ONLY travels with a rewrite — an unconditional
|
|
132
|
+
// allow would skip the permission prompt for every command the chain sees.
|
|
133
|
+
if (updatedInput) {
|
|
134
|
+
hookSpecificOutput.permissionDecision = 'allow';
|
|
135
|
+
hookSpecificOutput.updatedInput = updatedInput;
|
|
136
|
+
}
|
|
137
|
+
if (contexts.length) hookSpecificOutput.additionalContext = contexts.join('\n\n');
|
|
138
|
+
|
|
139
|
+
console.log(JSON.stringify({ hookSpecificOutput }));
|
|
120
140
|
process.exit(0);
|
|
121
141
|
}
|
|
122
142
|
|
|
@@ -20,10 +20,14 @@
|
|
|
20
20
|
* This is the sanctioned progress path — the agent never has to rewrite the
|
|
21
21
|
* whole file nor reach for a Node script to record task status.
|
|
22
22
|
*
|
|
23
|
+
* A gate freezes the APPROVED ARTIFACT, so a protected file that does not yet
|
|
24
|
+
* exist on disk is a CREATION and passes — the criterion is the file's
|
|
25
|
+
* existence, never who signed the gate (see the carve-out in run()).
|
|
26
|
+
*
|
|
23
27
|
* Fail-open: exits 0 on any error.
|
|
24
28
|
*/
|
|
25
29
|
|
|
26
|
-
import { readFileSync } from 'fs';
|
|
30
|
+
import { existsSync, readFileSync } from 'fs';
|
|
27
31
|
import { stateExists, isGateApproved } from '../../shared/state-reader.js';
|
|
28
32
|
import {
|
|
29
33
|
isFeaturePath,
|
|
@@ -238,6 +242,35 @@ export async function run(payload) {
|
|
|
238
242
|
}
|
|
239
243
|
|
|
240
244
|
// ── Gate approved — file is locked ──────────────────────────────────────
|
|
245
|
+
const absPath = resolveTargetPath(filePath, root);
|
|
246
|
+
|
|
247
|
+
// Um gate aprovado congela o ARTEFATO APROVADO. Se o arquivo ainda não
|
|
248
|
+
// existe no disco, não há artefato aprovado a proteger: isto é a CRIAÇÃO
|
|
249
|
+
// inicial, não a edição de uma spec fechada. O caso é real e não é raro —
|
|
250
|
+
// `chore`/`hotfix` nascem com `proposal`+`plan` carimbados `policy:{tipo}`
|
|
251
|
+
// (create.js → work-types.js skippedGates), então o primeiro `Write` de
|
|
252
|
+
// `2-plan/spec.md` já chega aqui com o gate "aprovado" sem que humano
|
|
253
|
+
// nenhum tenha aprovado coisa alguma — e era barrado com uma mensagem
|
|
254
|
+
// mandando revogar o gate de um arquivo inexistente.
|
|
255
|
+
//
|
|
256
|
+
// O critério é a EXISTÊNCIA, nunca o `approvedBy`. Liberar por assinatura
|
|
257
|
+
// (`policy:` passa, `lucas` não) transformaria este carve-out em licença
|
|
258
|
+
// para reescrever qualquer spec aprovada: bastaria apagá-la antes. Por ser
|
|
259
|
+
// sobre o disco, a janela fecha sozinha no instante em que o arquivo
|
|
260
|
+
// existe — os contra-canários de test/hooks/protect-spec-files.test.js
|
|
261
|
+
// pinam exatamente isso.
|
|
262
|
+
//
|
|
263
|
+
// A decisão é explícita e vale para as DUAS metades do hook. Antes, o ramo
|
|
264
|
+
// de campos mutáveis (tasks.json) já deixava a primeira criação passar, mas
|
|
265
|
+
// por acidente — o ENOENT caía no catch fail-open da leitura — enquanto o
|
|
266
|
+
// ramo travado bloqueava. Duas metades respondendo à mesma pergunta em
|
|
267
|
+
// direções opostas; agora respondem aqui, uma vez só. O `result` distinto
|
|
268
|
+
// no log é o que torna essa unificação observável.
|
|
269
|
+
if (!existsSync(absPath)) {
|
|
270
|
+
logHookActivity('protect-spec-files', 'PreToolUse', 'ok:absent');
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
|
|
241
274
|
const mutableFields = MUTABLE_SPEC_FIELDS[filename];
|
|
242
275
|
|
|
243
276
|
// Files with mutable progress fields (e.g. tasks.json): allow a full Write
|
|
@@ -247,8 +280,9 @@ export async function run(payload) {
|
|
|
247
280
|
|
|
248
281
|
// Build the proposed document from either a full Write or a targeted Edit,
|
|
249
282
|
// then semantic-diff it against the current file on disk. Any failure to
|
|
250
|
-
// read / parse / apply → fail-open (pass), never a false block.
|
|
251
|
-
|
|
283
|
+
// read / parse / apply → fail-open (pass), never a false block. O arquivo
|
|
284
|
+
// EXISTE aqui (a inexistência já foi decidida acima), então este catch
|
|
285
|
+
// volta a significar só o que dizia originalmente: não deu para diffar.
|
|
252
286
|
let oldDoc, newDoc;
|
|
253
287
|
try {
|
|
254
288
|
const original = readFileSync(absPath, 'utf-8');
|
|
@@ -312,8 +346,11 @@ export async function run(payload) {
|
|
|
312
346
|
// de leitura/reconstrução cai no bloqueio (o conservador aqui é BLOQUEAR —
|
|
313
347
|
// ao contrário do ramo do tasks.json, onde não conseguir diffar significa
|
|
314
348
|
// não ter como provar violação; aqui a regra base é "não se edita").
|
|
349
|
+
//
|
|
350
|
+
// O arquivo EXISTE neste ponto — a inexistência foi decidida lá em cima, e
|
|
351
|
+
// é a única coisa que este catch NÃO significa mais. ENOENT tratado como
|
|
352
|
+
// "ilegível" era o defeito F14; "ilegível de verdade" continua bloqueando.
|
|
315
353
|
const toolName = getToolName(payload);
|
|
316
|
-
const absPath = resolveTargetPath(filePath, root);
|
|
317
354
|
let errataCheck = null;
|
|
318
355
|
try {
|
|
319
356
|
const original = readFileSync(absPath, 'utf-8');
|
|
@@ -89,6 +89,59 @@ function featureFromBranch(dir) {
|
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Task-isolation worktree (`morph-task/{name}`): the tree a `WorktreeCreate`
|
|
94
|
+
* hook provisions for a parallel sub-agent batch (morph-apply §3b-w) or for a
|
|
95
|
+
* `claude -w` session with no feature behind it. Not a feature — no
|
|
96
|
+
* feature.json, no gates — but it owns a port block and the same junctions.
|
|
97
|
+
*/
|
|
98
|
+
function taskFromBranch(dir) {
|
|
99
|
+
try {
|
|
100
|
+
const branch = gitRevParse('--abbrev-ref HEAD', dir);
|
|
101
|
+
const match = branch.match(/^morph-task\/(.+)$/);
|
|
102
|
+
return match ? match[1] : null;
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Is this secondary worktree inside the primary root's `worktrees/` (or the
|
|
110
|
+
* legacy `.worktrees/`) container? A tree kept anywhere else — `.claude/
|
|
111
|
+
* worktrees/` from a native Claude Code run before the hook existed, a
|
|
112
|
+
* sibling `../repo-x`, superpowers' `~/.config/...` — was created without
|
|
113
|
+
* morph: no junctions, no ports, invisible to `fleet`. Fail-open: any error
|
|
114
|
+
* reads as "in container" so a healthy tree is never nagged by mistake.
|
|
115
|
+
*/
|
|
116
|
+
function isOutsideContainer(dir) {
|
|
117
|
+
try {
|
|
118
|
+
const common = resolve(dir, gitRevParse('--git-common-dir', dir)).replace(/\\/g, '/');
|
|
119
|
+
const root = common.replace(/\/\.git$/, '');
|
|
120
|
+
const norm = resolve(dir).replace(/\\/g, '/');
|
|
121
|
+
return !['worktrees', '.worktrees'].some((d) => norm.startsWith(`${root}/${d}/`));
|
|
122
|
+
} catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function buildOutOfContainerWarning(dir) {
|
|
128
|
+
return '⚠ Este worktree está FORA do container do morph (worktrees/): foi criado sem o morph-spec — ' +
|
|
129
|
+
'sem junctions de infra, sem bloco de portas, invisível ao `fleet`. ' +
|
|
130
|
+
'Integre a branch e remova com `morph-spec worktree remove --target "<path>"`; ' +
|
|
131
|
+
'crie worktrees por `morph-spec worktree setup <f>` ou `worktree provision <n>` (o hook WorktreeCreate faz isso sozinho). ' +
|
|
132
|
+
'Diagnóstico: `morph-spec doctor --worktrees`.';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function buildTaskContext(dir, task) {
|
|
136
|
+
let text = `🧩 Worktree de isolamento de task: ${task} (branch morph-task/${task}). ` +
|
|
137
|
+
'Não é uma feature: trabalhe só na task despachada, commite na branch como último ato e NUNCA faça merge — ' +
|
|
138
|
+
'o orquestrador integra e remove este worktree.';
|
|
139
|
+
const envLine = buildEnvLine(dir);
|
|
140
|
+
if (envLine) text += `\n${envLine}`;
|
|
141
|
+
text += buildMissingInfraWarning(dir);
|
|
142
|
+
return text;
|
|
143
|
+
}
|
|
144
|
+
|
|
92
145
|
/** Fallback: if exactly one feature directory exists, assume that's the one. */
|
|
93
146
|
function featureFromSingleDir(dir) {
|
|
94
147
|
try {
|
|
@@ -114,8 +167,11 @@ function buildEnvLine(dir) {
|
|
|
114
167
|
const port = content.match(/^PORT=(\d+)$/m)?.[1];
|
|
115
168
|
if (!port) return null;
|
|
116
169
|
const range = content.match(/^MORPH_PORT_RANGE=([\d-]+)$/m)?.[1];
|
|
117
|
-
|
|
118
|
-
|
|
170
|
+
const pg = content.match(/^POSTGRES_PORT=(\d+)$/m)?.[1];
|
|
171
|
+
const project = content.match(/^COMPOSE_PROJECT_NAME=(\S+)$/m)?.[1];
|
|
172
|
+
const extras = [pg ? `POSTGRES_PORT=${pg}` : null, project ? `COMPOSE_PROJECT_NAME=${project}` : null].filter(Boolean);
|
|
173
|
+
return `🔌 Ambiente desta worktree: PORT=${port}${range ? ` (bloco ${range})` : ''}${extras.length ? `, ${extras.join(', ')}` : ''}. ` +
|
|
174
|
+
'Suba dev servers SEMPRE nessas portas (fonte: .morph/worktree.env; o hook PreToolUse injeta em npm run dev/dotnet run/docker compose) — as portas padrão pertencem à raiz.';
|
|
119
175
|
} catch {
|
|
120
176
|
return null;
|
|
121
177
|
}
|
|
@@ -148,20 +204,25 @@ function buildWorktreeContext(dir, feature) {
|
|
|
148
204
|
const handoffLine = buildHandoffLine(dir, feature);
|
|
149
205
|
if (handoffLine) text += `\n${handoffLine}`;
|
|
150
206
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
207
|
+
text += buildMissingInfraWarning(dir);
|
|
208
|
+
return text;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* `.morph/framework` is checked alongside the other two because the hook
|
|
213
|
+
* commands resolve to `$CLAUDE_PROJECT_DIR/.morph/framework/hooks/...`: if
|
|
214
|
+
* that junction is missing, `node` finds no file and EVERY guardrail in this
|
|
215
|
+
* worktree is silently dark — the worst failure mode of the lot, and the one
|
|
216
|
+
* the old two-item check could not see.
|
|
217
|
+
*/
|
|
218
|
+
function buildMissingInfraWarning(dir) {
|
|
156
219
|
const missing = ['node_modules', '.claude', join('.morph', 'framework')]
|
|
157
220
|
.filter((rel) => !existsSync(join(dir, rel)));
|
|
158
|
-
if (missing.length)
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
221
|
+
if (!missing.length) return '';
|
|
222
|
+
let text = `\n⚠ Infra compartilhada ausente (${missing.join(', ').replace(/\\/g, '/')}). Rode: morph-spec worktree link --all`;
|
|
223
|
+
if (missing.includes(join('.morph', 'framework'))) {
|
|
224
|
+
text += `\n Sem .morph/framework os hooks do morph-spec NÃO executam neste worktree (nenhum guardrail ativo).`;
|
|
163
225
|
}
|
|
164
|
-
|
|
165
226
|
return text;
|
|
166
227
|
}
|
|
167
228
|
|
|
@@ -234,9 +295,17 @@ export async function run(payload) {
|
|
|
234
295
|
try { rotateEventsLogIfLarge(dir); } catch { /* non-blocking */ }
|
|
235
296
|
|
|
236
297
|
if (isSecondaryWorktree(dir)) {
|
|
298
|
+
const task = taskFromBranch(dir);
|
|
299
|
+
if (task) {
|
|
300
|
+
return contextResult(buildTaskContext(dir, task), 'SessionStart');
|
|
301
|
+
}
|
|
237
302
|
const feature = featureFromBranch(dir) || featureFromSingleDir(dir);
|
|
238
303
|
if (!feature) {
|
|
239
|
-
|
|
304
|
+
// A tree with no morph identity at all: worth a warning ONLY when it
|
|
305
|
+
// also lives outside the container — that is the signature of a
|
|
306
|
+
// worktree created behind morph's back (native tool, raw git, another
|
|
307
|
+
// skill). Inside the container with no identity → stay silent.
|
|
308
|
+
return isOutsideContainer(dir) ? contextResult(buildOutOfContainerWarning(dir), 'SessionStart') : null;
|
|
240
309
|
}
|
|
241
310
|
return contextResult(buildWorktreeContext(dir, feature) + claimBlock(dir, feature, sessionId), 'SessionStart');
|
|
242
311
|
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* worktree-create.js — WorktreeCreate hook
|
|
4
|
+
*
|
|
5
|
+
* Event: WorktreeCreate (fires for `claude -w`, the EnterWorktree tool, and
|
|
6
|
+
* `Agent isolation:"worktree"` — measured, not assumed).
|
|
7
|
+
*
|
|
8
|
+
* Claude Code's native worktree is a bare `git worktree add` somewhere under
|
|
9
|
+
* `.claude/worktrees/`: no infra junctions (so no hooks run in there), no port
|
|
10
|
+
* block, no identity, invisible to the fleet. When this hook is registered it
|
|
11
|
+
* REPLACES that logic entirely: it must print the path of the tree it created
|
|
12
|
+
* as the last non-empty stdout line and exit 0; any non-zero exit fails the
|
|
13
|
+
* creation. It cannot return JSON — the standalone tail below is therefore
|
|
14
|
+
* NOT `runStandalone` (which prints a JSON verdict). Diagnostics go to stderr.
|
|
15
|
+
*
|
|
16
|
+
* What it does:
|
|
17
|
+
* 1. root = the PRIMARY root of the repo `payload.cwd` is in (never a
|
|
18
|
+
* secondary worktree — a tree under a tree is the fleet-invisible shape).
|
|
19
|
+
* 2. name → kebab slug (`Bold_Oak` → `bold-oak`, `agent-a1b2` stays).
|
|
20
|
+
* 3. feature or task? `<root>/.morph/features/<n>/feature.json` exists, or
|
|
21
|
+
* branch `morph/<n>` exists → `morph-spec worktree setup <n>`; otherwise
|
|
22
|
+
* `morph-spec worktree provision <n> [--base <ref>]`.
|
|
23
|
+
* 4. `worktree.baseRef` from the merged settings (`~/.claude/settings.json`
|
|
24
|
+
* < `<root>/.claude/settings.json` < `<root>/.claude/settings.local.json`):
|
|
25
|
+
* `"head"` → the current branch (or sha) of `payload.cwd`; anything else
|
|
26
|
+
* → the repo's default branch (the CLI's own default).
|
|
27
|
+
* 5. CLI exit 0 or 2 (already exists) → its `path`. Exit 1/3 → error.
|
|
28
|
+
* CLI unavailable → in-hook git fallback that still lands the tree under
|
|
29
|
+
* `<root>/worktrees/<n>` on `morph-task/<n>` (degraded: no junctions, no
|
|
30
|
+
* ports — but never outside the governed directory).
|
|
31
|
+
*
|
|
32
|
+
* `stateExists(root)` gate: outside a morph project (no `.morph/state.json`
|
|
33
|
+
* and no feature.json) there is nothing to govern — the same in-hook git
|
|
34
|
+
* fallback runs, so a non-morph repo with this hook installed still gets a
|
|
35
|
+
* worktree instead of a failed creation.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { existsSync, mkdirSync, readFileSync } from 'fs';
|
|
39
|
+
import { execFileSync } from 'child_process';
|
|
40
|
+
import { dirname, join, resolve } from 'path';
|
|
41
|
+
import { homedir } from 'os';
|
|
42
|
+
import { stateExists } from '../../shared/state-reader.js';
|
|
43
|
+
import { resolveGitCommonDir } from '../../shared/git-paths.js';
|
|
44
|
+
import { runMorphSpec } from '../../shared/morph-cli.js';
|
|
45
|
+
|
|
46
|
+
const WORKTREES_DIR = 'worktrees';
|
|
47
|
+
|
|
48
|
+
const fwd = (p) => String(p).replace(/\\/g, '/');
|
|
49
|
+
|
|
50
|
+
function git(args, cwd) {
|
|
51
|
+
return execFileSync('git', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The primary root of the repo containing `startDir`. `git worktree list`'s
|
|
56
|
+
* first entry is the primary checkout, from any worktree; the pure-filesystem
|
|
57
|
+
* common-dir resolver is the no-subprocess fallback. Null outside a repo.
|
|
58
|
+
*/
|
|
59
|
+
export function resolvePrimaryRoot(startDir) {
|
|
60
|
+
try {
|
|
61
|
+
const first = git(['worktree', 'list', '--porcelain'], startDir).split('\n').find((l) => l.startsWith('worktree '));
|
|
62
|
+
if (first) return fwd(first.slice('worktree '.length).trim());
|
|
63
|
+
} catch { /* fall through */ }
|
|
64
|
+
const common = resolveGitCommonDir(startDir);
|
|
65
|
+
return common ? fwd(dirname(common)) : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `Bold_Oak` → `bold-oak`; empty → a timestamped slug. */
|
|
69
|
+
export function slugify(name) {
|
|
70
|
+
const slug = String(name || '')
|
|
71
|
+
.toLowerCase()
|
|
72
|
+
.replace(/[^a-z0-9-]+/g, '-')
|
|
73
|
+
.replace(/-{2,}/g, '-')
|
|
74
|
+
.replace(/^-+|-+$/g, '');
|
|
75
|
+
return slug || `wt-${Date.now().toString(36)}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function readJsonSafe(path) {
|
|
79
|
+
try { return JSON.parse(readFileSync(path, 'utf8').replace(/^/, '')); } catch { return null; }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* `worktree.baseRef` across the three settings layers, last one wins.
|
|
84
|
+
* Fail-open per file. Returns `'fresh'` (the documented default) when unset.
|
|
85
|
+
*/
|
|
86
|
+
export function readBaseRefSetting(root, home = homedir()) {
|
|
87
|
+
let value = 'fresh';
|
|
88
|
+
for (const p of [
|
|
89
|
+
join(home, '.claude', 'settings.json'),
|
|
90
|
+
join(root, '.claude', 'settings.json'),
|
|
91
|
+
join(root, '.claude', 'settings.local.json'),
|
|
92
|
+
]) {
|
|
93
|
+
const s = readJsonSafe(p);
|
|
94
|
+
const v = s?.worktree?.baseRef;
|
|
95
|
+
if (typeof v === 'string' && v) value = v;
|
|
96
|
+
}
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The ref `baseRef:"head"` means: the branch checked out in `cwd`, or its sha. */
|
|
101
|
+
function headRef(cwd) {
|
|
102
|
+
try {
|
|
103
|
+
const name = git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
|
|
104
|
+
if (name && name !== 'HEAD') return name;
|
|
105
|
+
return git(['rev-parse', 'HEAD'], cwd) || null;
|
|
106
|
+
} catch {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isKnownFeature(root, name) {
|
|
112
|
+
if (existsSync(join(root, '.morph', 'features', name, 'feature.json'))) return true;
|
|
113
|
+
try {
|
|
114
|
+
git(['show-ref', '--verify', '--quiet', `refs/heads/morph/${name}`], root);
|
|
115
|
+
return true;
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function defaultBranch(root) {
|
|
122
|
+
try {
|
|
123
|
+
const m = git(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], root).match(/^refs\/remotes\/origin\/(.+)$/);
|
|
124
|
+
if (m) return m[1];
|
|
125
|
+
} catch { /* no origin/HEAD */ }
|
|
126
|
+
for (const c of ['main', 'master']) {
|
|
127
|
+
try { git(['show-ref', '--verify', '--quiet', `refs/heads/${c}`], root); return c; } catch { /* next */ }
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Degraded path — the CLI is not reachable, but the tree still has to land
|
|
134
|
+
* in the governed directory on the governed branch namespace.
|
|
135
|
+
*/
|
|
136
|
+
function gitFallback(root, name, base) {
|
|
137
|
+
const branch = `morph-task/${name}`;
|
|
138
|
+
const path = `${root}/${WORKTREES_DIR}/${name}`;
|
|
139
|
+
if (existsSync(path)) return { path };
|
|
140
|
+
try { git(['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], root); }
|
|
141
|
+
catch {
|
|
142
|
+
const from = base || defaultBranch(root);
|
|
143
|
+
git(from ? ['branch', branch, from] : ['branch', branch], root);
|
|
144
|
+
}
|
|
145
|
+
mkdirSync(join(root, WORKTREES_DIR), { recursive: true });
|
|
146
|
+
git(['worktree', 'add', path, branch], root);
|
|
147
|
+
return { path };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* @param {object} payload - parsed WorktreeCreate stdin payload
|
|
152
|
+
* ({ session_id, cwd, hook_event_name, name })
|
|
153
|
+
* @returns {Promise<{ path: string }|{ error: string }>}
|
|
154
|
+
*/
|
|
155
|
+
export async function run(payload) {
|
|
156
|
+
const start = payload?.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
157
|
+
const root = resolvePrimaryRoot(start) || (process.env.CLAUDE_PROJECT_DIR ? fwd(resolve(process.env.CLAUDE_PROJECT_DIR)) : null);
|
|
158
|
+
if (!root) return { error: `worktree-create: "${start}" is not inside a git repository` };
|
|
159
|
+
|
|
160
|
+
const name = slugify(payload?.name);
|
|
161
|
+
const baseSetting = readBaseRefSetting(root);
|
|
162
|
+
const base = baseSetting === 'head' ? headRef(start) : null;
|
|
163
|
+
|
|
164
|
+
// Outside a morph project there is nothing to govern beyond WHERE the tree
|
|
165
|
+
// lands — plain git, under <root>/worktrees/.
|
|
166
|
+
if (!stateExists(root)) {
|
|
167
|
+
try { return gitFallback(root, name, base); }
|
|
168
|
+
catch (err) { return { error: `worktree-create: ${err?.message || err}` }; }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const args = isKnownFeature(root, name)
|
|
172
|
+
? ['worktree', 'setup', name, '--json']
|
|
173
|
+
: ['worktree', 'provision', name, '--json', ...(base ? ['--base', base] : [])];
|
|
174
|
+
|
|
175
|
+
const r = runMorphSpec(args, { cwd: root });
|
|
176
|
+
if (r.unavailable) {
|
|
177
|
+
process.stderr.write('worktree-create: morph-spec CLI not found — creating the tree with plain git under worktrees/ (no infra links, no port block). Run `morph-spec worktree link --all` inside it.\n');
|
|
178
|
+
try { return gitFallback(root, name, base); }
|
|
179
|
+
catch (err) { return { error: `worktree-create: ${err?.message || err}` }; }
|
|
180
|
+
}
|
|
181
|
+
if (r.stderr?.trim()) process.stderr.write(r.stderr);
|
|
182
|
+
|
|
183
|
+
const ok = r.code === 0 || r.code === 2;
|
|
184
|
+
const path = r.json?.path;
|
|
185
|
+
if (ok && path && !r.json?.error) return { path: fwd(path) };
|
|
186
|
+
const why = r.json?.error || r.json?.hint || r.stderr?.trim() || `exit ${r.code}`;
|
|
187
|
+
return { error: `worktree-create: morph-spec ${args.slice(0, 2).join(' ')} ${name} failed — ${why}` };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Standalone tail — NOT runStandalone(): this event's contract is "path on
|
|
191
|
+
// stdout, exit 0" / "non-zero exit", never a JSON verdict.
|
|
192
|
+
if (!process.env.MORPH_HOOK_CHAINED) {
|
|
193
|
+
const { readStdin } = await import('../../shared/stdin-reader.js');
|
|
194
|
+
let result;
|
|
195
|
+
try {
|
|
196
|
+
result = await run(await readStdin());
|
|
197
|
+
} catch (err) {
|
|
198
|
+
result = { error: `worktree-create: ${err?.message || err}` };
|
|
199
|
+
}
|
|
200
|
+
if (result?.path) {
|
|
201
|
+
process.stdout.write(`${result.path}\n`);
|
|
202
|
+
process.exit(0);
|
|
203
|
+
}
|
|
204
|
+
process.stderr.write(`${result?.error || 'worktree-create: no path produced'}\n`);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* worktree-env-inject.js — PreToolUse hook (Bash matcher), last link of the
|
|
4
|
+
* Bash chain.
|
|
5
|
+
*
|
|
6
|
+
* `.morph/worktree.env` used to be a manifest nobody executed: SessionStart
|
|
7
|
+
* printed the port block and the model was expected to pass `PORT` by hand
|
|
8
|
+
* every time it started a dev server. Two worktrees whose sessions forgot
|
|
9
|
+
* collided on 3000 — a runtime clash dressed as a broken app. This hook makes
|
|
10
|
+
* the block reach the process deterministically: when the command is a
|
|
11
|
+
* dev-server / compose / spec launcher and the cwd is a worktree that owns a
|
|
12
|
+
* manifest, the command is rewritten to `export K='V' …; <command>` via
|
|
13
|
+
* `updatedInput`, and the rewrite is named in `additionalContext`.
|
|
14
|
+
*
|
|
15
|
+
* Scope is deliberately narrow: only launcher-shaped commands, only when the
|
|
16
|
+
* command does not already set a layout key itself (`PORT=…`, `--port`,
|
|
17
|
+
* `compose -p`), only for the Bash tool. Everything else passes untouched.
|
|
18
|
+
*
|
|
19
|
+
* Fail-open: no state, no manifest, unreadable manifest → null (pass).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync } from 'fs';
|
|
23
|
+
import { join } from 'path';
|
|
24
|
+
import { stateExists } from '../../shared/state-reader.js';
|
|
25
|
+
import { updatedInputResult, runStandalone } from '../../shared/hook-response.js';
|
|
26
|
+
import { loadEnvFile } from '../../shared/e2e-check.js';
|
|
27
|
+
|
|
28
|
+
/** Commands that start a server or a stack and therefore need the block. */
|
|
29
|
+
const LAUNCHER_RE = /\b(?:(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev|start)\b|next\s+(?:dev|start)\b|vite\b|dotnet\s+(?:run|watch)\b|docker(?:\s+|-)compose\b|playwright\s+test\b)/;
|
|
30
|
+
|
|
31
|
+
/** The command already chose its own port/project — respect it. */
|
|
32
|
+
const SELF_CONFIGURED_RE = /(?:^|[\s;&|])(?:PORT|API_PORT|POSTGRES_PORT|COMPOSE_PROJECT_NAME|ASPNETCORE_URLS)=|--port\b|(?:^|\s)-p\s+\d|compose\b[^|;&]*\s-p\s/;
|
|
33
|
+
|
|
34
|
+
/** Keys the layout owns; anything else in the manifest is left alone. */
|
|
35
|
+
const LAYOUT_KEYS = ['COMPOSE_PROJECT_NAME', 'MORPH_PORT_BASE', 'MORPH_PORT_RANGE', 'PORT', 'API_PORT', 'POSTGRES_PORT', 'REDIS_PORT', 'ASPNETCORE_URLS', 'PLAYWRIGHT_BASE_URL'];
|
|
36
|
+
|
|
37
|
+
/** POSIX single-quote, safe for Git Bash on Windows too. */
|
|
38
|
+
function shq(v) {
|
|
39
|
+
return `'${String(v).replace(/'/g, "'\\''")}'`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Pure decision: the rewritten command, or null when nothing should change.
|
|
44
|
+
* @param {string} command
|
|
45
|
+
* @param {Record<string,string>} env - parsed manifest
|
|
46
|
+
* @returns {{ command: string, injected: string[] }|null}
|
|
47
|
+
*/
|
|
48
|
+
export function rewriteCommand(command, env) {
|
|
49
|
+
if (!command || !LAUNCHER_RE.test(command)) return null;
|
|
50
|
+
if (SELF_CONFIGURED_RE.test(command)) return null;
|
|
51
|
+
const pairs = LAYOUT_KEYS.filter((k) => env[k]).map((k) => [k, env[k]]);
|
|
52
|
+
if (!pairs.some(([k]) => k === 'PORT')) return null;
|
|
53
|
+
// The web tier's URL is what the spec suite needs; derive it once here.
|
|
54
|
+
if (!env.PLAYWRIGHT_BASE_URL) pairs.push(['PLAYWRIGHT_BASE_URL', `http://localhost:${env.PORT}`]);
|
|
55
|
+
const prefix = `export ${pairs.map(([k, v]) => `${k}=${shq(v)}`).join(' ')}; `;
|
|
56
|
+
return { command: prefix + command, injected: pairs.map(([k, v]) => `${k}=${v}`) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {object} payload - parsed PreToolUse stdin payload (Bash matcher)
|
|
61
|
+
* @returns {Promise<object|null>} an updatedInput result, or null (pass).
|
|
62
|
+
*/
|
|
63
|
+
export async function run(payload) {
|
|
64
|
+
try {
|
|
65
|
+
if (payload?.tool_name && payload.tool_name !== 'Bash') return null;
|
|
66
|
+
if (!stateExists()) return null;
|
|
67
|
+
const command = payload?.tool_input?.command || '';
|
|
68
|
+
if (!command) return null;
|
|
69
|
+
|
|
70
|
+
const dir = payload?.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
71
|
+
const manifest = join(dir, '.morph', 'worktree.env');
|
|
72
|
+
if (!existsSync(manifest)) return null;
|
|
73
|
+
|
|
74
|
+
const rewritten = rewriteCommand(command, loadEnvFile(manifest));
|
|
75
|
+
if (!rewritten) return null;
|
|
76
|
+
|
|
77
|
+
return updatedInputResult(
|
|
78
|
+
{ ...payload.tool_input, command: rewritten.command },
|
|
79
|
+
'PreToolUse',
|
|
80
|
+
`🔌 worktree env injetado (.morph/worktree.env): ${rewritten.injected.join(' ')}`,
|
|
81
|
+
);
|
|
82
|
+
} catch {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
await runStandalone(run);
|