@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.
- package/CLAUDE.md +3 -0
- package/README.md +2 -2
- package/bin/morph-spec.js +25 -1
- package/bin/validate.js +379 -379
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +2 -2
- package/framework/MORPH.md +4 -5
- package/framework/agents.json +1 -0
- package/framework/docs/CLI.md +5 -2
- 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/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/claude-code/statusline.sh +11 -11
- package/framework/hooks/git/commit-msg/conventional-commits.sh +33 -33
- package/framework/hooks/git/pre-commit/agents.sh +25 -25
- package/framework/hooks/git/pre-commit/orchestrator.sh +64 -64
- package/framework/hooks/git/pre-commit/specs.sh +50 -50
- package/framework/hooks/git/pre-push/run-tests.sh +44 -44
- 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/hooks/shared/stdin-reader.js +26 -26
- package/framework/rules/infrastructure-standards.md +3 -1
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +3 -3
- 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 +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +3 -1
- package/framework/skills/core/morph-plan/SKILL.md +6 -3
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +1 -1
- 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/STANDARDS.json +30 -0
- package/framework/standards/ai-agents/agent-session.md +160 -160
- package/framework/standards/ai-agents/batching.md +193 -193
- package/framework/standards/ai-agents/context-providers.md +188 -188
- package/framework/standards/ai-agents/mcp-server.md +250 -250
- package/framework/standards/ai-agents/mcp-tools.md +197 -197
- package/framework/standards/ai-agents/middleware-patterns.md +207 -207
- package/framework/standards/ai-agents/modalities-image-gen.md +173 -173
- package/framework/standards/ai-agents/modalities-speech-to-text.md +171 -171
- package/framework/standards/ai-agents/modalities-text-to-speech.md +181 -181
- package/framework/standards/ai-agents/modalities-vision.md +201 -201
- package/framework/standards/ai-agents/multi-agent-patterns.md +155 -155
- package/framework/standards/ai-agents/production.md +231 -231
- package/framework/standards/ai-agents/prompts-in-db.md +214 -214
- package/framework/standards/ai-agents/providers/model-registry.md +158 -158
- package/framework/standards/ai-agents/service-tier-flex.md +135 -135
- package/framework/standards/ai-agents/structured-output.md +249 -249
- package/framework/standards/ai-agents/sweet-spot.md +109 -109
- package/framework/standards/infrastructure/docker/local-compose-isolation.md +88 -0
- package/framework/templates/IDE-SNIPPETS.md +262 -262
- package/framework/templates/REGISTRY.json +1 -1
- package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
- package/framework/templates/code/dotnet/ai-agents/AgentBootstrap.cs.template +39 -39
- package/framework/templates/code/dotnet/ai-agents/EmbeddingService.cs.template +68 -68
- package/framework/templates/code/dotnet/ai-agents/ModelRegistry.cs.template +117 -117
- package/framework/templates/code/dotnet/ai-agents/PromptRepository.cs.template +95 -95
- package/framework/templates/code/dotnet/ai-agents/ResilientToolMiddleware.cs.template +61 -61
- package/framework/templates/code/dotnet/ai-agents/VectorSearchTool.cs.template +107 -107
- package/framework/templates/code/dotnet/ai-agents/model-registry.json.template +58 -58
- package/framework/templates/code/dotnet/contracts/contracts-vsa.cs +281 -281
- package/framework/templates/code/dotnet/database/migration.cs +83 -83
- package/framework/templates/code/dotnet/feature/endpoint.cs.hbs +21 -21
- package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
- package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
- package/framework/templates/code/dotnet/feature/validator.cs.hbs +15 -15
- package/framework/templates/code/sql/ai-agents/agent-prompts-migration.sql.template +58 -58
- package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
- package/framework/templates/code/typescript/contracts.ts +167 -167
- package/framework/templates/docs/clarifications.md +253 -253
- package/framework/templates/docs/schema-analysis.md +119 -119
- package/framework/templates/docs/ui-components.md +52 -52
- package/framework/templates/docs/ui-design-system.md +76 -76
- package/framework/templates/docs/ui-flows.md +167 -167
- package/framework/templates/docs/ui-mockups.md +98 -98
- package/framework/templates/feature/decisions.md +122 -122
- package/framework/templates/frontend/nextjs/Dockerfile.nextjs.hbs +43 -43
- package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
- package/framework/templates/frontend/nextjs/env.mjs.hbs +32 -32
- package/framework/templates/frontend/nextjs/feature-form.tsx.hbs +56 -56
- package/framework/templates/frontend/nextjs/page.tsx.hbs +22 -22
- package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
- package/framework/templates/frontend/nextjs/use-feature.ts.hbs +54 -54
- package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
- package/framework/templates/infrastructure/docker/docker-compose.template.yml +24 -14
- package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
- package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
- package/framework/templates/infrastructure/github/README.md +56 -56
- package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
- package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
- package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
- package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
- package/framework/templates/integrations/asaas-client.cs +387 -387
- package/framework/templates/integrations/asaas-webhook.cs +351 -351
- package/framework/templates/meta-prompts/fusion/fusion-agent.md +76 -76
- package/framework/templates/meta-prompts/fusion/fusion-aggregator.md +100 -100
- package/framework/templates/meta-prompts/validators/pre-commit-validator.md +95 -95
- package/framework/templates/project/validate.js +120 -120
- package/framework/templates/state.template.json +17 -17
- package/framework/templates/ui/design-system.css +226 -226
- package/package.json +1 -1
- package/src/commands/dashboard/dashboard.js +53 -53
- package/src/commands/e2e/e2e.js +77 -39
- package/src/commands/index.js +13 -13
- package/src/commands/project/checkpoint.js +209 -209
- package/src/commands/project/doctor.js +8 -6
- package/src/commands/project/env.js +95 -0
- package/src/commands/project/worktree.js +266 -52
- package/src/commands/state/delete.js +8 -0
- package/src/commands/state/finish.js +29 -34
- package/src/core/state/phase-state-machine.js +197 -197
- package/src/core/templates/template-renderer.js +477 -477
- package/src/dashboard/app/components/DetailPanel.jsx +11 -11
- package/src/dashboard/app/components/FlowGraph.jsx +17 -17
- package/src/dashboard/app/components/SearchFilter.jsx +22 -22
- package/src/dashboard/app/hooks/useDetailPanel.js +15 -15
- package/src/dashboard/app/main.jsx +13 -13
- package/src/dashboard/app/pages/AgentMap.jsx +370 -370
- package/src/dashboard/app/pages/HookInspector.jsx +430 -418
- package/src/dashboard/app/pages/PhasePipeline.jsx +408 -408
- package/src/dashboard/app/pages/RulesMap.jsx +273 -273
- package/src/dashboard/app/pages/SkillsLibrary.jsx +366 -366
- package/src/dashboard/app/pages/Standards.jsx +332 -332
- package/src/dashboard/app/styles/index.css +47 -47
- package/src/dashboard/index.html +12 -12
- package/src/dashboard/package.json +24 -24
- package/src/dashboard/scanner/hooks-scanner.js +5 -1
- package/src/dashboard/scanner/phases-scanner.js +15 -15
- package/src/dashboard/scanner/rubrics-scanner.js +12 -12
- package/src/dashboard/scanner/rules-scanner.js +37 -37
- package/src/dashboard/scanner/skills-scanner.js +43 -43
- package/src/dashboard/scanner/squads-scanner.js +13 -13
- package/src/dashboard/vite-plugin-morph-data.js +17 -17
- package/src/dashboard/vite.config.js +24 -24
- package/src/lib/detectors/design-system-detector.js +188 -188
- package/src/lib/detectors/index.js +217 -217
- package/src/lib/e2e/compose-env.js +6 -11
- package/src/lib/e2e/stack-teardown.js +115 -0
- package/src/lib/generators/architecture-scanner.js +2 -1
- package/src/lib/generators/design-system-generator.js +508 -508
- package/src/lib/generators/settings-generator.js +209 -209
- package/src/lib/graph/graphifyignore.js +2 -1
- package/src/lib/hooks/hook-executor.js +167 -167
- package/src/lib/installers/mcp-installer.js +719 -719
- package/src/lib/tasks/task-classifier.js +30 -30
- package/src/lib/tasks/task-parser.js +209 -209
- package/src/lib/tasks/test-runner.js +21 -4
- package/src/lib/validators/checkpoint-persistence.js +39 -39
- package/src/lib/validators/css/css-validator.js +340 -340
- package/src/lib/validators/shared/emit-validator-dispatch.js +14 -14
- package/src/lib/validators/shared/issue-counter.js +14 -14
- package/src/lib/validators/verify-runner.js +7 -0
- package/src/lib/worktree/dirs.js +32 -0
- package/src/lib/worktree/env-allocator.js +45 -11
- package/src/lib/worktree/worktree-audit.js +40 -2
- package/src/utils/agent-id-mapper.js +11 -11
- package/src/utils/claude-settings-manager.js +300 -300
- package/src/utils/file-copier.js +18 -13
- package/src/utils/hooks-installer.js +46 -3
- package/src/utils/skills-installer.js +187 -187
- package/framework/templates/.idea/morph-templates.xml +0 -92
- 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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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)
|
|
494
|
-
//
|
|
495
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
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,
|
|
860
|
-
*
|
|
861
|
-
*
|
|
862
|
-
*
|
|
863
|
-
*
|
|
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
|
|
870
|
+
function reclaimStack(feature, { root, worktreePath, options }) {
|
|
867
871
|
try {
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
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). */
|