@polymorphism-tech/morph-spec 8.33.0 → 8.34.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/CLAUDE.md +3 -0
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +25 -1
  4. package/bin/validate.js +379 -379
  5. package/claude-plugin.json +1 -1
  6. package/docs/CHEATSHEET.md +1 -1
  7. package/docs/QUICKSTART.md +1 -1
  8. package/framework/CLAUDE.md +2 -2
  9. package/framework/MORPH.md +4 -5
  10. package/framework/agents.json +1 -0
  11. package/framework/docs/CLI.md +5 -2
  12. package/framework/hooks/README.md +11 -1
  13. package/framework/hooks/claude-code/core/block-destructive-worktree-removal.js +5 -8
  14. package/framework/hooks/claude-code/core/block-raw-worktree-add.js +79 -0
  15. package/framework/hooks/claude-code/core/chain.js +27 -7
  16. package/framework/hooks/claude-code/core/worktree-context.js +83 -14
  17. package/framework/hooks/claude-code/core/worktree-create.js +206 -0
  18. package/framework/hooks/claude-code/core/worktree-env-inject.js +87 -0
  19. package/framework/hooks/claude-code/core/worktree-remove.js +81 -0
  20. package/framework/hooks/claude-code/statusline.sh +11 -11
  21. package/framework/hooks/git/commit-msg/conventional-commits.sh +33 -33
  22. package/framework/hooks/git/pre-commit/agents.sh +25 -25
  23. package/framework/hooks/git/pre-commit/orchestrator.sh +64 -64
  24. package/framework/hooks/git/pre-commit/specs.sh +50 -50
  25. package/framework/hooks/git/pre-push/run-tests.sh +44 -44
  26. package/framework/hooks/shared/command-tokens.js +23 -0
  27. package/framework/hooks/shared/e2e-check.js +0 -0
  28. package/framework/hooks/shared/git-paths.js +25 -0
  29. package/framework/hooks/shared/hook-response.js +17 -0
  30. package/framework/hooks/shared/morph-cli.js +134 -0
  31. package/framework/hooks/shared/stdin-reader.js +26 -26
  32. package/framework/rules/infrastructure-standards.md +3 -1
  33. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  34. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  35. package/framework/skills/core/morph-apply/SKILL.md +3 -3
  36. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  37. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  38. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  39. package/framework/skills/core/morph-implement/SKILL.md +3 -1
  40. package/framework/skills/core/morph-plan/SKILL.md +6 -3
  41. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  42. package/framework/skills/core/morph-review/SKILL.md +1 -1
  43. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  44. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  45. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  46. package/framework/standards/STANDARDS.json +30 -0
  47. package/framework/standards/ai-agents/agent-session.md +160 -160
  48. package/framework/standards/ai-agents/batching.md +193 -193
  49. package/framework/standards/ai-agents/context-providers.md +188 -188
  50. package/framework/standards/ai-agents/mcp-server.md +250 -250
  51. package/framework/standards/ai-agents/mcp-tools.md +197 -197
  52. package/framework/standards/ai-agents/middleware-patterns.md +207 -207
  53. package/framework/standards/ai-agents/modalities-image-gen.md +173 -173
  54. package/framework/standards/ai-agents/modalities-speech-to-text.md +171 -171
  55. package/framework/standards/ai-agents/modalities-text-to-speech.md +181 -181
  56. package/framework/standards/ai-agents/modalities-vision.md +201 -201
  57. package/framework/standards/ai-agents/multi-agent-patterns.md +155 -155
  58. package/framework/standards/ai-agents/production.md +231 -231
  59. package/framework/standards/ai-agents/prompts-in-db.md +214 -214
  60. package/framework/standards/ai-agents/providers/model-registry.md +158 -158
  61. package/framework/standards/ai-agents/service-tier-flex.md +135 -135
  62. package/framework/standards/ai-agents/structured-output.md +249 -249
  63. package/framework/standards/ai-agents/sweet-spot.md +109 -109
  64. package/framework/standards/infrastructure/docker/local-compose-isolation.md +88 -0
  65. package/framework/templates/IDE-SNIPPETS.md +262 -262
  66. package/framework/templates/REGISTRY.json +1 -1
  67. package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
  68. package/framework/templates/code/dotnet/ai-agents/AgentBootstrap.cs.template +39 -39
  69. package/framework/templates/code/dotnet/ai-agents/EmbeddingService.cs.template +68 -68
  70. package/framework/templates/code/dotnet/ai-agents/ModelRegistry.cs.template +117 -117
  71. package/framework/templates/code/dotnet/ai-agents/PromptRepository.cs.template +95 -95
  72. package/framework/templates/code/dotnet/ai-agents/ResilientToolMiddleware.cs.template +61 -61
  73. package/framework/templates/code/dotnet/ai-agents/VectorSearchTool.cs.template +107 -107
  74. package/framework/templates/code/dotnet/ai-agents/model-registry.json.template +58 -58
  75. package/framework/templates/code/dotnet/contracts/contracts-vsa.cs +281 -281
  76. package/framework/templates/code/dotnet/database/migration.cs +83 -83
  77. package/framework/templates/code/dotnet/feature/endpoint.cs.hbs +21 -21
  78. package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
  79. package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
  80. package/framework/templates/code/dotnet/feature/validator.cs.hbs +15 -15
  81. package/framework/templates/code/sql/ai-agents/agent-prompts-migration.sql.template +58 -58
  82. package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
  83. package/framework/templates/code/typescript/contracts.ts +167 -167
  84. package/framework/templates/docs/clarifications.md +253 -253
  85. package/framework/templates/docs/schema-analysis.md +119 -119
  86. package/framework/templates/docs/ui-components.md +52 -52
  87. package/framework/templates/docs/ui-design-system.md +76 -76
  88. package/framework/templates/docs/ui-flows.md +167 -167
  89. package/framework/templates/docs/ui-mockups.md +98 -98
  90. package/framework/templates/feature/decisions.md +122 -122
  91. package/framework/templates/frontend/nextjs/Dockerfile.nextjs.hbs +43 -43
  92. package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
  93. package/framework/templates/frontend/nextjs/env.mjs.hbs +32 -32
  94. package/framework/templates/frontend/nextjs/feature-form.tsx.hbs +56 -56
  95. package/framework/templates/frontend/nextjs/page.tsx.hbs +22 -22
  96. package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
  97. package/framework/templates/frontend/nextjs/use-feature.ts.hbs +54 -54
  98. package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
  99. package/framework/templates/infrastructure/docker/docker-compose.template.yml +24 -14
  100. package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
  101. package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
  102. package/framework/templates/infrastructure/github/README.md +56 -56
  103. package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
  104. package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
  105. package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
  106. package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
  107. package/framework/templates/integrations/asaas-client.cs +387 -387
  108. package/framework/templates/integrations/asaas-webhook.cs +351 -351
  109. package/framework/templates/meta-prompts/fusion/fusion-agent.md +76 -76
  110. package/framework/templates/meta-prompts/fusion/fusion-aggregator.md +100 -100
  111. package/framework/templates/meta-prompts/validators/pre-commit-validator.md +95 -95
  112. package/framework/templates/project/validate.js +120 -120
  113. package/framework/templates/state.template.json +17 -17
  114. package/framework/templates/ui/design-system.css +226 -226
  115. package/package.json +1 -1
  116. package/src/commands/dashboard/dashboard.js +53 -53
  117. package/src/commands/e2e/e2e.js +77 -39
  118. package/src/commands/index.js +13 -13
  119. package/src/commands/project/checkpoint.js +209 -209
  120. package/src/commands/project/doctor.js +8 -6
  121. package/src/commands/project/env.js +95 -0
  122. package/src/commands/project/worktree.js +266 -52
  123. package/src/commands/state/delete.js +8 -0
  124. package/src/commands/state/finish.js +29 -34
  125. package/src/core/state/phase-state-machine.js +197 -197
  126. package/src/core/templates/template-renderer.js +477 -477
  127. package/src/dashboard/app/components/DetailPanel.jsx +11 -11
  128. package/src/dashboard/app/components/FlowGraph.jsx +17 -17
  129. package/src/dashboard/app/components/SearchFilter.jsx +22 -22
  130. package/src/dashboard/app/hooks/useDetailPanel.js +15 -15
  131. package/src/dashboard/app/main.jsx +13 -13
  132. package/src/dashboard/app/pages/AgentMap.jsx +370 -370
  133. package/src/dashboard/app/pages/HookInspector.jsx +430 -418
  134. package/src/dashboard/app/pages/PhasePipeline.jsx +408 -408
  135. package/src/dashboard/app/pages/RulesMap.jsx +273 -273
  136. package/src/dashboard/app/pages/SkillsLibrary.jsx +366 -366
  137. package/src/dashboard/app/pages/Standards.jsx +332 -332
  138. package/src/dashboard/app/styles/index.css +47 -47
  139. package/src/dashboard/index.html +12 -12
  140. package/src/dashboard/package.json +24 -24
  141. package/src/dashboard/scanner/hooks-scanner.js +5 -1
  142. package/src/dashboard/scanner/phases-scanner.js +15 -15
  143. package/src/dashboard/scanner/rubrics-scanner.js +12 -12
  144. package/src/dashboard/scanner/rules-scanner.js +37 -37
  145. package/src/dashboard/scanner/skills-scanner.js +43 -43
  146. package/src/dashboard/scanner/squads-scanner.js +13 -13
  147. package/src/dashboard/vite-plugin-morph-data.js +17 -17
  148. package/src/dashboard/vite.config.js +24 -24
  149. package/src/lib/detectors/design-system-detector.js +188 -188
  150. package/src/lib/detectors/index.js +217 -217
  151. package/src/lib/e2e/compose-env.js +6 -11
  152. package/src/lib/e2e/stack-teardown.js +115 -0
  153. package/src/lib/generators/architecture-scanner.js +2 -1
  154. package/src/lib/generators/design-system-generator.js +508 -508
  155. package/src/lib/generators/settings-generator.js +209 -209
  156. package/src/lib/graph/graphifyignore.js +2 -1
  157. package/src/lib/hooks/hook-executor.js +167 -167
  158. package/src/lib/installers/mcp-installer.js +719 -719
  159. package/src/lib/tasks/task-classifier.js +30 -30
  160. package/src/lib/tasks/task-parser.js +209 -209
  161. package/src/lib/tasks/test-runner.js +21 -4
  162. package/src/lib/validators/checkpoint-persistence.js +39 -39
  163. package/src/lib/validators/css/css-validator.js +340 -340
  164. package/src/lib/validators/shared/emit-validator-dispatch.js +14 -14
  165. package/src/lib/validators/shared/issue-counter.js +14 -14
  166. package/src/lib/validators/verify-runner.js +7 -0
  167. package/src/lib/worktree/dirs.js +32 -0
  168. package/src/lib/worktree/env-allocator.js +45 -11
  169. package/src/lib/worktree/worktree-audit.js +40 -2
  170. package/src/utils/agent-id-mapper.js +11 -11
  171. package/src/utils/claude-settings-manager.js +300 -300
  172. package/src/utils/file-copier.js +18 -13
  173. package/src/utils/hooks-installer.js +46 -3
  174. package/src/utils/skills-installer.js +187 -187
  175. package/framework/templates/.idea/morph-templates.xml +0 -92
  176. package/framework/templates/.vscode/morph-templates.code-snippets +0 -186
@@ -13,7 +13,11 @@
13
13
  *
14
14
  * Subcommands:
15
15
  * morph-spec worktree setup <feature> [--fresh] — create + materialize state
16
+ * morph-spec worktree provision <name> [--base <ref>] — task-isolation tree
17
+ * (branch morph-task/{name}, same infra, NO feature identity) — what the
18
+ * WorktreeCreate hook calls for `claude -w` / EnterWorktree / Agent isolation
16
19
  * morph-spec worktree link [--target <path>] [--all] — (re)link shared infra
20
+ * morph-spec worktree remove [<feature>|--target <path>] [--if-clean] — safe teardown
17
21
  * morph-spec worktree list [--json] — list active feature worktrees
18
22
  *
19
23
  * Closing the lifecycle (integrate + teardown) lives in `morph-spec finish`
@@ -32,7 +36,7 @@
32
36
 
33
37
  import { execSync, execFileSync } from 'child_process';
34
38
  import { existsSync, mkdirSync, symlinkSync, readFileSync, writeFileSync, appendFileSync, lstatSync, unlinkSync, rmdirSync, cpSync } from 'fs';
35
- import { join, resolve, dirname } from 'path';
39
+ import { join, resolve, dirname, basename } from 'path';
36
40
  import chalk from 'chalk';
37
41
  import { detectDefaultBranch } from '../../lib/git/default-branch.js';
38
42
  import { triageIgnoredPaths } from '../../lib/worktree/ignored-triage.js';
@@ -40,29 +44,11 @@ import { triageIgnoredPaths } from '../../lib/worktree/ignored-triage.js';
40
44
  // the SAME answer this file does, and hooks can never import from `src/`.
41
45
  import { findReparsePoints } from '../../../framework/hooks/shared/reparse-scan.js';
42
46
 
43
- /**
44
- * The directory new worktrees are created under, relative to the primary root.
45
- *
46
- * NO leading dot, deliberately (C1). Jest escapes the `.` when it interpolates
47
- * an absolute `rootDir` into the `testMatch` glob, so `<root>/.worktrees/...`
48
- * stops matching and an entire frontend suite reports **0 tests without an
49
- * error** — the suite "passes" having tested nothing. The dot bought nothing:
50
- * the directory is gitignored either way.
51
- */
52
- export const WORKTREES_DIR = 'worktrees';
53
-
54
- /**
55
- * The pre-C1 directory name. New worktrees never land here, but every reader
56
- * that resolves worktrees BY CONVENTION must still accept it, so a machine
57
- * that already had `.worktrees/{f}` keeps working with no manual migration.
58
- *
59
- * Readers that go through `git worktree list` (fleet, finish, worktree list)
60
- * were never affected — git records absolute paths, not this convention.
61
- */
62
- export const LEGACY_WORKTREES_DIR = '.worktrees';
63
-
64
- /** Both names, new first — the read order for convention-based lookups. */
65
- export const WORKTREE_DIR_NAMES = [WORKTREES_DIR, LEGACY_WORKTREES_DIR];
47
+ // The container name lives in lib/worktree/dirs.js (single source, C1
48
+ // rationale documented there); re-exported here so existing importers and the
49
+ // dependency-matrix row keep pointing at this file.
50
+ import { WORKTREES_DIR, LEGACY_WORKTREES_DIR, WORKTREE_DIR_NAMES } from '../../lib/worktree/dirs.js';
51
+ export { WORKTREES_DIR, LEGACY_WORKTREES_DIR, WORKTREE_DIR_NAMES };
66
52
 
67
53
  // ── Pure helpers (exported for tests) ──────────────────────────────────────
68
54
 
@@ -362,6 +348,43 @@ export function resolvePrimaryRoot(cwd = process.cwd()) {
362
348
  * @param {string} worktreePath - Absolute path to the worktree
363
349
  * @returns {Promise<{ materialized: boolean, reason?: string }>}
364
350
  */
351
+ /**
352
+ * Guarantee the worktree owns a local `.morph/state.json`, hydrated from its
353
+ * committed features when there are any, and an EMPTY index otherwise.
354
+ *
355
+ * Why the empty index matters: every core hook fail-opens on `stateExists()`,
356
+ * which is true only with a `state.json` or a feature dir in the tree. A
357
+ * task-isolation worktree of a project with no committed features (measured
358
+ * in the sandbox e2e) had neither — so the env-inject hook, the removal guard
359
+ * and every other guardrail were silently dark inside it. `state.json` is
360
+ * gitignored and derivable, so an empty one costs nothing and can never dirty
361
+ * the tree the finish gates read.
362
+ *
363
+ * @param {string} worktreePath
364
+ * @returns {Promise<{ materialized: boolean, reason?: string, seeded?: boolean }>}
365
+ */
366
+ export async function ensureLocalState(worktreePath) {
367
+ const result = await materializeState(worktreePath);
368
+ if (result.materialized) return result;
369
+ const statePath = join(worktreePath, '.morph', 'state.json');
370
+ if (existsSync(statePath)) return { ...result, materialized: true };
371
+ try {
372
+ const { atomicWriteJson } = await import('../../core/state/state-manager.js');
373
+ const now = new Date().toISOString();
374
+ mkdirSync(join(worktreePath, '.morph'), { recursive: true });
375
+ atomicWriteJson(statePath, {
376
+ version: '5.0.0',
377
+ project: { name: basename(worktreePath), type: 'unknown', createdAt: now, updatedAt: now },
378
+ features: {},
379
+ threads: {},
380
+ metadata: { totalFeatures: 0, completedFeatures: 0, totalTimeSpent: 0, lastUpdated: now },
381
+ });
382
+ return { materialized: true, seeded: true, reason: result.reason };
383
+ } catch (err) {
384
+ return { materialized: false, reason: (err && err.message) || result.reason };
385
+ }
386
+ }
387
+
365
388
  export async function materializeState(worktreePath) {
366
389
  const featuresDir = join(worktreePath, '.morph', 'features');
367
390
  if (!existsSync(featuresDir)) {
@@ -490,33 +513,13 @@ export async function runWorktreeSetup(feature, options = {}) {
490
513
  setupInfraLinks(worktreePath, cwd);
491
514
 
492
515
  // Fleet env: allocate this feature's deterministic port block and write the
493
- // worktree-local manifest (.morph/worktree.env), excluded from git via the
494
- // same common-dir mechanism as the infra links (it must be invisible to the
495
- // finish clean-checks). Under `.morph/` on purpose: that prefix is already
496
- // shieldable at finish (backed up, never a removal blocker). Fail-open — a
497
- // worktree without ports is degraded, never broken.
498
- let envBlock = null;
499
- try {
500
- const { allocateEnvBlock, resolveFleetConfig, buildEnvFileContent } =
501
- await import('../../lib/worktree/env-allocator.js');
502
- const common = execSync('git rev-parse --git-common-dir', { cwd, stdio: 'pipe' }).toString().trim();
503
- if (common) {
504
- const gitCommonDir = resolve(cwd, common);
505
- envBlock = allocateEnvBlock(feature, { gitCommonDir, ...resolveFleetConfig(cwd) });
506
- let stack = '';
507
- try {
508
- const cfg = JSON.parse(readFileSync(join(cwd, '.morph', 'config', 'config.json'), 'utf8').replace(/^/, ''));
509
- stack = cfg?.project?.stack || '';
510
- } catch { /* fail-open — stackless env still carries PORT */ }
511
- mkdirSync(join(worktreePath, '.morph'), { recursive: true });
512
- writeFileSync(join(worktreePath, '.morph', 'worktree.env'), buildEnvFileContent(feature, envBlock, stack), 'utf8');
513
- excludeWorktreePaths(cwd, [join('.morph', 'worktree.env')]);
514
- }
515
- } catch { envBlock = null; /* fail-open */ }
516
+ // worktree-local manifest (.morph/worktree.env). Fail-open — a worktree
517
+ // without ports is degraded, never broken.
518
+ const envBlock = await allocateWorktreeEnv(worktreePath, cwd, feature);
516
519
 
517
520
  // Materialize a local, real state.json in the worktree (self-heal from the
518
521
  // committed feature.json files) so hooks work from the very first session.
519
- const materialized = await materializeState(worktreePath);
522
+ const materialized = await ensureLocalState(worktreePath);
520
523
 
521
524
  // Fleet handoff: the fresh instance that opens this worktree reads
522
525
  // handoff.md before anything else (SessionStart hook points at it). The doc
@@ -578,6 +581,109 @@ export async function worktreeSetupCommand(feature, options = {}) {
578
581
  process.exit(exitCode);
579
582
  }
580
583
 
584
+ // ── provision (task isolation — a worktree that is NOT a feature) ──────────
585
+
586
+ /**
587
+ * Branch name for a task-isolation worktree: `morph-task/<name>`. A separate
588
+ * namespace from `morph/<feature>` on purpose — `worktree list`, `fleet`,
589
+ * `finish` and the SessionStart hook all read `morph/<f>` as "this IS feature
590
+ * f", and a scratch tree named after a feature must never impersonate it.
591
+ *
592
+ * @param {string} name
593
+ * @returns {string}
594
+ */
595
+ export function buildTaskBranchName(name) {
596
+ return `morph-task/${name}`;
597
+ }
598
+
599
+ /**
600
+ * Non-exiting core of `worktree provision` — the tree `claude -w`,
601
+ * `EnterWorktree` and `Agent isolation:"worktree"` ask for when the name is
602
+ * not a feature. Everything `setup` gives a feature worktree EXCEPT the
603
+ * feature identity: it lands under `<primary root>/worktrees/<name>` (never
604
+ * nested under a secondary worktree, whatever the cwd), on `morph-task/<name>`
605
+ * off the default branch (or `--base`), with the infra junctions, a git-
606
+ * invisible `.morph/worktree.env` keyed `task/<name>`, and a materialized
607
+ * local state.json. No feature registration, no handoff.
608
+ *
609
+ * Exit codes mirror `setup`: 0 created (or fail-open), 1 refused (bad name),
610
+ * 2 already exists at this exact path.
611
+ *
612
+ * @param {string} name
613
+ * @param {{ base?: string, cwd?: string }} [options]
614
+ * @returns {Promise<{ ok: boolean, exitCode: 0|1|2, created: boolean, alreadyExists?: boolean, path?: string, branch?: string, branchedFrom?: string|null, kind?: 'task-isolation', name: string, env?: object, error?: string }>}
615
+ */
616
+ export async function runWorktreeProvision(name, options = {}) {
617
+ const cwd = options.cwd || process.cwd();
618
+
619
+ if (!isValidFeatureName(name)) {
620
+ return { ok: false, exitCode: 1, created: false, error: 'invalid-name', name };
621
+ }
622
+ if (!isGitRepo(cwd)) {
623
+ return { ok: true, exitCode: 0, created: false, error: 'not-a-git-repo', name };
624
+ }
625
+
626
+ // Always the PRIMARY root: a provision issued from inside a feature worktree
627
+ // must not nest a worktree under it — that tree would be invisible to the
628
+ // fleet and to doctor, which enumerate `<root>/worktrees/`.
629
+ const root = resolvePrimaryRoot(cwd);
630
+ const branch = buildTaskBranchName(name);
631
+ const worktreePath = buildWorktreePath(name, root);
632
+
633
+ if (worktreeExists(worktreePath, root)) {
634
+ return { ok: true, exitCode: 2, created: false, alreadyExists: true, path: worktreePath, branch, kind: 'task-isolation', name };
635
+ }
636
+
637
+ let branchedFrom = null;
638
+ try {
639
+ if (!branchExists(branch, root)) {
640
+ const base = options.base || detectDefaultBranch(root);
641
+ if (base) {
642
+ execFileSync('git', ['branch', branch, base], { cwd: root, stdio: 'pipe' });
643
+ branchedFrom = base;
644
+ } else {
645
+ execFileSync('git', ['branch', branch], { cwd: root, stdio: 'pipe' });
646
+ }
647
+ }
648
+
649
+ const parentDir = join(root, WORKTREES_DIR);
650
+ if (!existsSync(parentDir)) mkdirSync(parentDir, { recursive: true });
651
+
652
+ execFileSync('git', ['worktree', 'add', worktreePath, branch], { cwd: root, stdio: 'pipe' });
653
+
654
+ setupInfraLinks(worktreePath, root);
655
+ const envBlock = await allocateWorktreeEnv(worktreePath, root, `task/${name}`);
656
+ const materialized = await ensureLocalState(worktreePath);
657
+
658
+ return {
659
+ ok: true,
660
+ exitCode: 0,
661
+ created: true,
662
+ path: worktreePath,
663
+ branch,
664
+ branchedFrom,
665
+ kind: 'task-isolation',
666
+ name,
667
+ materialized: materialized.materialized,
668
+ ...(envBlock ? { env: { base: envBlock.base, range: envBlock.range } } : {}),
669
+ };
670
+ } catch (err) {
671
+ return { ok: true, exitCode: 0, created: false, error: (err.message || 'unknown').slice(0, 200), name };
672
+ }
673
+ }
674
+
675
+ /**
676
+ * `morph-spec worktree provision <name> [--base <ref>]` — thin wrapper around
677
+ * `runWorktreeProvision`, same JSON-and-exit contract as `setup`.
678
+ * @param {string} name
679
+ * @param {{ base?: string, cwd?: string }} [options]
680
+ */
681
+ export async function worktreeProvisionCommand(name, options = {}) {
682
+ const { ok, exitCode, ...result } = await runWorktreeProvision(name, options);
683
+ console.log(JSON.stringify(result));
684
+ process.exit(exitCode);
685
+ }
686
+
581
687
  /**
582
688
  * Command handler for `morph-spec worktree link [--target <path>] [--all]`.
583
689
  *
@@ -588,7 +694,7 @@ export async function worktreeSetupCommand(feature, options = {}) {
588
694
  *
589
695
  * @param {{ target?: string, all?: boolean, cwd?: string }} [options]
590
696
  */
591
- export function worktreeLinkCommand(options = {}) {
697
+ export async function worktreeLinkCommand(options = {}) {
592
698
  const cwdArg = options.cwd || process.cwd();
593
699
  const root = resolvePrimaryRoot(cwdArg);
594
700
  const target = resolve(options.target || options.cwd || process.cwd()).replace(/\\/g, '/');
@@ -627,10 +733,79 @@ export function worktreeLinkCommand(options = {}) {
627
733
  }
628
734
  }
629
735
 
630
- console.log(JSON.stringify({ linked, skipped, root, target }));
736
+ // `link --all` is the documented repair for a worktree missing its infra —
737
+ // that includes a `.morph/worktree.env` written by an older layout. The
738
+ // allocation is idempotent (same key → same block), so regenerating the
739
+ // manifest never moves a worktree's ports; it only adds the keys it lacks.
740
+ let env = null;
741
+ if (options.all) {
742
+ const key = envKeyForWorktree(target, root);
743
+ const block = key ? await allocateWorktreeEnv(target, root, key) : null;
744
+ if (block) env = { key, base: block.base, range: block.range };
745
+ }
746
+
747
+ console.log(JSON.stringify({ linked, skipped, root, target, ...(env ? { env } : {}) }));
631
748
  process.exit(0);
632
749
  }
633
750
 
751
+ /**
752
+ * The env-registry key of a worktree, derived from its branch: `morph/<f>` →
753
+ * `<f>` (feature), `morph-task/<n>` → `task/<n>` (task isolation). `null` for
754
+ * a branch morph does not own — no block is allocated for those.
755
+ *
756
+ * @param {string} worktreePath
757
+ * @param {string} root
758
+ * @returns {string|null}
759
+ */
760
+ export function envKeyForWorktree(worktreePath, root) {
761
+ const norm = resolve(worktreePath).replace(/\\/g, '/');
762
+ const entry = parseWorktreeList(root).find((e) => e.path === norm);
763
+ const branch = entry?.branch?.replace(/^refs\/heads\//, '') ?? null;
764
+ if (!branch) return null;
765
+ const feature = branch.match(/^morph\/(.+)$/)?.[1];
766
+ if (feature) return feature;
767
+ const task = branch.match(/^morph-task\/(.+)$/)?.[1];
768
+ return task ? `task/${task}` : null;
769
+ }
770
+
771
+ /**
772
+ * Allocate (idempotently) the port block for `envKey` and write the worktree's
773
+ * `.morph/worktree.env`, excluded from git via the same common-dir mechanism as
774
+ * the infra links (it must be invisible to the finish clean-checks). Under
775
+ * `.morph/` on purpose: that prefix is already shieldable at finish (backed up,
776
+ * never a removal blocker). Fail-open: returns `null` when no block could be
777
+ * written — a worktree without ports is degraded, never broken.
778
+ *
779
+ * Shared by `setup` (feature key), `provision` (task key) and `link --all`
780
+ * (regeneration), so the manifest has exactly one writer.
781
+ *
782
+ * @param {string} worktreePath
783
+ * @param {string} root - primary root
784
+ * @param {string} envKey - `<feature>` or `task/<name>`
785
+ * @returns {Promise<{ index: number, base: number, range: [number, number] }|null>}
786
+ */
787
+ export async function allocateWorktreeEnv(worktreePath, root, envKey) {
788
+ try {
789
+ const { allocateEnvBlock, resolveFleetConfig, buildEnvFileContent } =
790
+ await import('../../lib/worktree/env-allocator.js');
791
+ const common = execSync('git rev-parse --git-common-dir', { cwd: root, stdio: 'pipe' }).toString().trim();
792
+ if (!common) return null;
793
+ const gitCommonDir = resolve(root, common);
794
+ const block = allocateEnvBlock(envKey, { gitCommonDir, ...resolveFleetConfig(root) });
795
+ let stack = '';
796
+ try {
797
+ const cfg = JSON.parse(readFileSync(join(root, '.morph', 'config', 'config.json'), 'utf8').replace(/^/, ''));
798
+ stack = cfg?.project?.stack || '';
799
+ } catch { /* fail-open — stackless env still carries PORT */ }
800
+ mkdirSync(join(worktreePath, '.morph'), { recursive: true });
801
+ writeFileSync(join(worktreePath, '.morph', 'worktree.env'), buildEnvFileContent(envKey, block, stack), 'utf8');
802
+ excludeWorktreePaths(root, [join('.morph', 'worktree.env')]);
803
+ return block;
804
+ } catch {
805
+ return null;
806
+ }
807
+ }
808
+
634
809
  // ── finish / list shared helpers ────────────────────────────────────────────
635
810
 
636
811
  /**
@@ -1338,7 +1513,7 @@ export function cwdIsInsideWorktree(cwd, worktreePath) {
1338
1513
  * closing a feature is still `morph-spec finish`.
1339
1514
  *
1340
1515
  * @param {string|undefined} featureArg
1341
- * @param {{ target?: string, discardIgnored?: boolean, deleteBranch?: boolean, cwd?: string }} [options]
1516
+ * @param {{ target?: string, discardIgnored?: boolean, deleteBranch?: boolean, ifClean?: boolean, cwd?: string }} [options]
1342
1517
  */
1343
1518
  export async function worktreeRemoveCommand(featureArg, options = {}) {
1344
1519
  const cwdArg = options.cwd || process.cwd();
@@ -1398,9 +1573,47 @@ export async function worktreeRemoveCommand(featureArg, options = {}) {
1398
1573
  hint: `saia do worktree antes de removê-lo — o processo trava o próprio diretório de trabalho. Rode de novo a partir de "${root}"` }, 1);
1399
1574
  }
1400
1575
 
1576
+ // `--if-clean` — the WorktreeRemove hook's contract: Claude Code fires it
1577
+ // when it believes the tree is disposable, but "disposable" is the user's
1578
+ // call, not the harness's. Any uncommitted or untracked file refuses the
1579
+ // removal BEFORE the junctions are touched, so a refusal leaves the tree
1580
+ // exactly as it was (no rollback needed, nothing to relink). The infra
1581
+ // links and the env manifest are excluded from git, so they never count.
1582
+ if (options.ifClean) {
1583
+ let status;
1584
+ try {
1585
+ status = execFileSync('git', ['status', '--porcelain', '--untracked-files=all'], { cwd: worktreePath, stdio: 'pipe' }).toString();
1586
+ } catch {
1587
+ emit({ removed: false, error: 'status-failed', path: worktreePath, root,
1588
+ hint: 'não foi possível provar que o worktree está limpo — nada foi removido' }, 1);
1589
+ }
1590
+ const dirt = status.split(/\r?\n/).filter((l) => l.trim());
1591
+ if (dirt.length) {
1592
+ emit({ removed: false, error: 'worktree-dirty', path: worktreePath, root, dirt: dirt.slice(0, 20),
1593
+ hint: `o worktree tem trabalho não commitado — commit ou descarte antes, ou rode \`morph-spec worktree remove --target "${worktreePath}"\` sem --if-clean para remover com backup` }, 1);
1594
+ }
1595
+ }
1596
+
1597
+ // The env-registry key this tree allocated under: `<f>` for morph/<f>,
1598
+ // `task/<n>` for morph-task/<n>. Read BEFORE the teardown (the entry is gone
1599
+ // from `git worktree list` afterwards). The old `'worktree'` placeholder
1600
+ // meant every --target removal released a slot nobody held and leaked the
1601
+ // one the tree actually used.
1602
+ const envKey = envKeyForWorktree(worktreePath, root);
1603
+
1604
+ // The tree's compose stack goes down BEFORE the tree: a running bind-mounted
1605
+ // container holds the directory (Windows) and turns a clean removal into a
1606
+ // removalFailure. Volumes survive — removing a worktree is not closing the
1607
+ // feature, and a re-`setup` should find its seeded database intact.
1608
+ let e2e = null;
1609
+ try {
1610
+ const { tearDownFeatureStack } = await import('../../lib/e2e/stack-teardown.js');
1611
+ e2e = tearDownFeatureStack(envKey || feature || basename(worktreePath), { root, worktreePath, volumes: false });
1612
+ } catch { e2e = null; /* fail-open */ }
1613
+
1401
1614
  const teardown = await teardownWorktree(worktreePath, {
1402
1615
  root,
1403
- feature: feature || 'worktree',
1616
+ feature: feature || envKey || 'worktree',
1404
1617
  branch,
1405
1618
  options: { discardIgnored: options.discardIgnored === true },
1406
1619
  // Removing a worktree is not closing a feature: the branch is the work, and
@@ -1421,6 +1634,7 @@ export async function worktreeRemoveCommand(featureArg, options = {}) {
1421
1634
  // real `.claude/`/`.morph/framework/` were unlinked rather than followed; on
1422
1635
  // a refusal it is what `relinked` did or did not put back.
1423
1636
  if (teardown.unlinked.length) out.unlinked = teardown.unlinked;
1637
+ if (e2e?.attempted) out.e2e = { status: e2e.status, cwd: e2e.cwd, volumes: false };
1424
1638
  if (teardown.worktreeRemoved) {
1425
1639
  if (teardown.discarded.length) out.discardedIgnored = teardown.discarded;
1426
1640
  // Broken out of `discardedIgnored` on purpose: "deleted" and "deleted
@@ -4,6 +4,7 @@ import { join, resolve, sep } from 'path';
4
4
  import { execSync } from 'child_process';
5
5
  import { isValidFeatureName, cwdIsInsideWorktree, teardownWorktree, resolvePrimaryRoot } from '../project/worktree.js';
6
6
  import { resolveFeatureTree } from '../../lib/worktree/feature-truth.js';
7
+ import { tearDownFeatureStack } from '../../lib/e2e/stack-teardown.js';
7
8
 
8
9
  /**
9
10
  * `morph-spec delete <feature>` — remove a feature. Hard by default (folder is
@@ -144,6 +145,13 @@ export async function deleteCommand(featureName, options = {}) {
144
145
  // worktree actually came down. A refusal here must leave EVERYTHING
145
146
  // untouched, same contract as `worktree remove`.
146
147
  const root = resolvePrimaryRoot(cwd);
148
+ // The feature's verification stack (containers, ports, volumes, stamp)
149
+ // comes down BEFORE its worktree: the compose file and the bind mount
150
+ // live there, and a deleted feature has nothing to keep a volume for.
151
+ // Fail-open by contract — a stubborn container never blocks the delete.
152
+ try {
153
+ tearDownFeatureStack(featureName, { root, worktreePath: owner.tree, volumes: true });
154
+ } catch { /* fail-open */ }
147
155
  const teardown = await teardownWorktree(owner.tree, {
148
156
  root,
149
157
  feature: featureName,
@@ -15,11 +15,7 @@ import {
15
15
  import { detectDefaultBranch, hasOriginRemote } from '../../lib/git/default-branch.js';
16
16
  import { resolveGh } from '../../lib/git/gh.js';
17
17
  import { releaseClaim } from '../../../framework/hooks/shared/claim-registry.js';
18
- import {
19
- composeProjectName,
20
- resolveComposeFile,
21
- tearDownStack,
22
- } from '../../../framework/hooks/shared/e2e-check.js';
18
+ import { tearDownFeatureStack } from '../../lib/e2e/stack-teardown.js';
23
19
  import { runArchive } from './archive.js';
24
20
 
25
21
  /** workType → conventional-commit type. `hotfix` IS a fix; anything else is a chore. */
@@ -101,12 +97,11 @@ export async function finishCommand(feature, options = {}) {
101
97
  // is the single point that covers merge/PR/--keep alike. Fail-open: a claim
102
98
  // that outlives its feature only expires on its own TTL.
103
99
  //
104
- // The e2e stack rides along for the same reason: `morph-spec verify` leaves it
105
- // UP on purpose (cold-starting a container per task is what makes the loop
106
- // unusable), so something has to be the one place that reclaims the
107
- // containers and their ports when the feature closes. Best-effort by
108
- // contract — a stack that refuses to come down must never fail a finish that
109
- // already merged.
100
+ // The e2e stack is NOT reclaimed here: by the time a success is emitted the
101
+ // worktree that owns the compose file may already be gone. Each integration
102
+ // path calls `reclaimStack` immediately BEFORE its worktree teardown (or
103
+ // before its final emit in the common flow) — see stack-teardown.js.
104
+ //
110
105
  // Set by Validation 0c when this run picks up an already-archived feature.
111
106
  // Stamped onto EVERY payload emitted from that point on, success or refusal:
112
107
  // a resume merges a branch the user did not archive in this run, and that has
@@ -121,7 +116,6 @@ export async function finishCommand(feature, options = {}) {
121
116
  const common = execFileSync('git', ['rev-parse', '--git-common-dir'], { cwd: root, stdio: 'pipe' }).toString().trim();
122
117
  if (common) releaseClaim(feature, { gitCommonDir: resolve(root, common) });
123
118
  } catch { /* fail-open */ }
124
- tearDownE2eStack(feature, root);
125
119
  }
126
120
  console.log(JSON.stringify(obj));
127
121
  };
@@ -496,9 +490,13 @@ async function integrateMerge({ emit, root, feature, expectedBranch, defaultBran
496
490
  process.exit(0);
497
491
  }
498
492
 
493
+ // The stack comes down BEFORE the worktree: the compose file, the bind
494
+ // mount and the port block all belong to that tree, and once it is gone
495
+ // there is nothing left to address the containers by.
496
+ const e2e = reclaimStack(feature, { root, worktreePath, options });
499
497
  const teardown = await teardownWorktree(worktreePath, { root, feature, branch: expectedBranch, options, deleteBranch: true });
500
498
  dropFromRootIndex(root, feature);
501
- emit({ ...mergeOutput({ feature, branch: expectedBranch, worktreePath, teardown, shedBackup }), ...ucNote });
499
+ emit({ ...mergeOutput({ feature, branch: expectedBranch, worktreePath, teardown, shedBackup }), ...ucNote, e2e });
502
500
  process.exit(0);
503
501
  }
504
502
 
@@ -526,8 +524,9 @@ async function integrateMerge({ emit, root, feature, expectedBranch, defaultBran
526
524
  process.exit(1);
527
525
  }
528
526
  tryGit(['branch', '-d', expectedBranch], root);
527
+ const e2e = reclaimStack(feature, { root, worktreePath: null, options });
529
528
  // The root index (gitignored) was already updated by the archive (same cwd).
530
- emit({ finished: true, integrated: true, mode: 'merge', feature, merged: expectedBranch, into: defaultBranch, worktreeRemoved: false, nextStep: MERGE_NEXT_STEP, ...ucNote });
529
+ emit({ finished: true, integrated: true, mode: 'merge', feature, merged: expectedBranch, into: defaultBranch, worktreeRemoved: false, nextStep: MERGE_NEXT_STEP, ...ucNote, e2e });
531
530
  process.exit(0);
532
531
  }
533
532
 
@@ -571,6 +570,11 @@ async function integratePr({ emit, root, feature, expectedBranch, defaultBranch,
571
570
  // feature yet — honest until the PR merges).
572
571
  let teardown = null;
573
572
  let shedBackup = null;
573
+ // Reclaimed from the owning tree, before that tree goes away; skipped on
574
+ // --keep, where the worktree (and its stack) stays alive on purpose.
575
+ const e2e = isWorktreeFlow
576
+ ? (options.keep ? null : reclaimStack(feature, { root, worktreePath, options }))
577
+ : reclaimStack(feature, { root, worktreePath: null, options });
574
578
  if (isWorktreeFlow && !options.keep) {
575
579
  // Shed the transient root-side scaffold BEFORE teardown — exactly as --merge
576
580
  // does. The real `create --worktree` leaves the root's .morph/features/{f}
@@ -607,6 +611,7 @@ async function integratePr({ emit, root, feature, expectedBranch, defaultBranch,
607
611
  nextStep: `mergeie ${prLabel} — a feature só estará encerrada quando ${defaultBranch} receber o archive pelo merge`,
608
612
  ...promotedNote(arch),
609
613
  ...(shedBackup ? { shedBackup } : {}),
614
+ ...(e2e ? { e2e } : {}),
610
615
  ...(teardown
611
616
  ? { worktreeRemoved: teardown.worktreeRemoved, ...teardownExtras(teardown, worktreePath, expectedBranch, false) }
612
617
  : {
@@ -856,28 +861,18 @@ function openPrFor(branch, cwd) {
856
861
  }
857
862
 
858
863
  /**
859
- * Reclaim the feature's verification stack, if the project has one.
860
- *
861
- * Runs from the ROOT rather than the worktree: by the time a finish emits
862
- * success the worktree may already be gone, and the compose file is versioned
863
- * so the root's copy is equivalent. Entirely fail-open — no compose, no docker,
864
- * or a stubborn container are all non-events here.
864
+ * Reclaim the feature's verification stack (containers, ports, named volumes,
865
+ * freshness stamp) from the tree that owns it. `--keep-volumes` keeps the
866
+ * named volumes (e.g. a seeded database) for the next feature. Best-effort by
867
+ * contract — a stack that refuses to come down must never fail a finish that
868
+ * already merged, so this never throws.
865
869
  */
866
- function tearDownE2eStack(feature, root) {
870
+ function reclaimStack(feature, { root, worktreePath, options }) {
867
871
  try {
868
- const config = JSON.parse(
869
- readFileSync(join(root, '.morph', 'config', 'config.json'), 'utf8').replace(/^/, '')
870
- );
871
- const composeFile = resolveComposeFile(root, config);
872
- if (!composeFile) return;
873
-
874
- const projectName = composeProjectName(feature);
875
- tearDownStack(root, {
876
- composeFile,
877
- projectName,
878
- env: { COMPOSE_PROJECT_NAME: projectName },
879
- });
880
- } catch { /* fail-open — teardown must never fail a completed finish */ }
872
+ return tearDownFeatureStack(feature, { root, worktreePath, volumes: !options.keepVolumes });
873
+ } catch {
874
+ return { attempted: false, cwd: worktreePath || root, status: 'fail', stampDeleted: false };
875
+ }
881
876
  }
882
877
 
883
878
  /** Drop a feature from the thin, gitignored root index (never via saveState). */