@polymorphism-tech/morph-spec 8.22.0 → 8.24.1

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 (42) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +27 -1
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -1
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/CLAUDE.md +1 -1
  7. package/framework/MORPH.md +2 -2
  8. package/framework/agents.json +1 -0
  9. package/framework/docs/CLI.md +5 -3
  10. package/framework/hooks/claude-code/core/block-destructive-worktree-removal.js +118 -0
  11. package/framework/hooks/shared/reparse-scan.js +100 -0
  12. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  13. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  14. package/framework/skills/core/morph-apply/SKILL.md +2 -2
  15. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  16. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  17. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  18. package/framework/skills/core/morph-implement/SKILL.md +14 -2
  19. package/framework/skills/core/morph-plan/SKILL.md +6 -4
  20. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  21. package/framework/skills/core/morph-review/SKILL.md +29 -3
  22. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  23. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  24. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  25. package/framework/standards/STANDARDS.json +28 -0
  26. package/framework/standards/backend/dotnet/fluent-validation-vsa.md +5 -2
  27. package/framework/standards/backend/dotnet/testing.md +127 -0
  28. package/package.json +1 -1
  29. package/src/commands/project/worktree.js +250 -86
  30. package/src/commands/state/approve.js +11 -0
  31. package/src/commands/state/archive.js +12 -3
  32. package/src/commands/state/finish.js +30 -16
  33. package/src/commands/state/handoff.js +3 -2
  34. package/src/commands/state/usecase.js +77 -0
  35. package/src/commands/validation/verify.js +6 -0
  36. package/src/lib/generators/handoff-generator.js +2 -2
  37. package/src/lib/specs/usecase-promoter.js +135 -19
  38. package/src/lib/specs/usecase-registry.js +237 -0
  39. package/src/lib/validators/traceability-check.js +198 -0
  40. package/src/lib/validators/verify-runner.js +14 -0
  41. package/src/lib/worktree/worktree-audit.js +6 -2
  42. package/src/utils/hooks-installer.js +12 -8
@@ -4,7 +4,7 @@ description: Localiza um standard específico via busca server-side (morph-spec
4
4
  version: core
5
5
  when-to-invoke: quando precisar consultar um standard específico durante a implementação
6
6
  user-invokable: true
7
- cliVersion: "8.22.0"
7
+ cliVersion: "8.24.1"
8
8
  ---
9
9
 
10
10
  # morph-standards
@@ -4,7 +4,7 @@ description: Conduz a fase opcional de UI/UX — design system, mockups, compone
4
4
  version: core
5
5
  when-to-invoke: ao tomar o ramo uiux (morph-spec advance --design), depois do Gate 1 e antes do morph-plan
6
6
  user-invokable: true
7
- cliVersion: "8.22.0"
7
+ cliVersion: "8.24.1"
8
8
  ---
9
9
 
10
10
  # morph-uiux
@@ -1779,6 +1779,34 @@
1779
1779
  "data-ingestion": "#microsoftextensionsdataingestion-preview--pipeline-pattern-para-adoção-futura"
1780
1780
  },
1781
1781
  "digest": "Microsoft.Extensions.VectorData.Abstractions (GA) is the provider-agnostic vector-store abstraction; Microsoft.Extensions.DataIngestion (preview) is the companion ETL pipeline. Concrete connectors (Postgres, Qdrant, SQL Server...) ship under Microsoft.SemanticKernel.Connectors.* package names despite having nothing to do with Semantic Kernel — an explicit exception to ai-agents-setup's SK ban. Default RAG storage stays ai-agents-rag-custom-pgvector; adopt VectorData only for genuine multi-backend portability, since the Postgres connector is still prerelease."
1782
+ },
1783
+ {
1784
+ "id": "backend-dotnet-testing",
1785
+ "name": "testing",
1786
+ "path": "backend/dotnet/testing.md",
1787
+ "category": "backend",
1788
+ "subcategory": "dotnet",
1789
+ "tags": [
1790
+ "backend",
1791
+ "dotnet"
1792
+ ],
1793
+ "stacks": [
1794
+ "dotnet"
1795
+ ],
1796
+ "aliases": [
1797
+ "xunit",
1798
+ "TimeProvider",
1799
+ "FakeTimeProvider",
1800
+ "fake clock",
1801
+ "integration test",
1802
+ "ProblemDetails",
1803
+ "error body assertion"
1804
+ ],
1805
+ "anchors": {
1806
+ "clock": "#rule-1--inject-timeprovider-fake-it-with-faketimeprovider-in-tests",
1807
+ "errorbody": "#rule-2--error-path-tests-assert-the-response-body-not-just-the-status-code"
1808
+ },
1809
+ "digest": "Inject TimeProvider (never DateTime.UtcNow / DateOnly.FromDateTime(DateTime.UtcNow) directly in a handler or validator) and fake it with FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing) in integration tests — deterministic, no midnight-UTC flake, boundary dates (today vs. yesterday) become facts about the fake clock instead of when the suite happens to run. xUnit + WebApplicationFactory error-path tests (400/404/409) must assert the ProblemDetails response BODY (title/detail/violated field or error code), never just the HTTP status code — a status-only assertion lets the wrong validation rule or the wrong error branch pass silently as long as the status matches."
1782
1810
  }
1783
1811
  ],
1784
1812
  "sharedAliases": {
@@ -23,7 +23,10 @@ One validator per request DTO. Lives in the same feature folder as the handler:
23
23
  // Features/BookFeature/CreateBook/CreateBookValidator.cs
24
24
  public class CreateBookValidator : AbstractValidator<CreateBookRequest>
25
25
  {
26
- public CreateBookValidator()
26
+ // TimeProvider injected, never DateTime.UtcNow read directly — see
27
+ // backend-dotnet-testing (Rule 1): a validator that reads the real clock
28
+ // cannot be tested deterministically with FakeTimeProvider.
29
+ public CreateBookValidator(TimeProvider timeProvider)
27
30
  {
28
31
  RuleFor(c => c.Title)
29
32
  .NotEmpty().WithMessage("Title is required")
@@ -41,7 +44,7 @@ public class CreateBookValidator : AbstractValidator<CreateBookRequest>
41
44
 
42
45
  RuleFor(c => c.PublishedYear)
43
46
  .GreaterThan(1000).WithMessage("Published year must be a valid year")
44
- .LessThanOrEqualTo(DateTime.UtcNow.Year)
47
+ .LessThanOrEqualTo(timeProvider.GetUtcNow().Year)
45
48
  .WithMessage("Published year cannot be in the future");
46
49
  }
47
50
  }
@@ -0,0 +1,127 @@
1
+ # .NET Integration Testing — VSA
2
+
3
+ > **Scope:** dotnet-vsa
4
+ > **Layer:** 0 (always load)
5
+ > **Keywords:** testing, xunit, integration test, TimeProvider, FakeTimeProvider, WebApplicationFactory, ProblemDetails, error body assertion
6
+ > **Load When:** writing or reviewing tests for a dotnet VSA project
7
+
8
+ **Verified against:** .NET 10 + xUnit 2 + `Microsoft.Extensions.TimeProvider.Testing`. Last-verified: 2026-08-12.
9
+
10
+ ---
11
+
12
+ ## Overview
13
+
14
+ Integration tests exercise the real ASP.NET Core pipeline (`WebApplicationFactory`) against
15
+ an in-memory/test database — the pattern used across `backend-dotnet-vsa-handler-patterns`'
16
+ handler examples. Two failure modes recur in generated test suites and each has a dedicated
17
+ rule below: tests that read the real system clock instead of an injected, fakeable one; and
18
+ error-path tests that assert only the HTTP status code, leaving the actual failure contract
19
+ (the response body) unverified.
20
+
21
+ ---
22
+
23
+ ## Rule 1 — Inject `TimeProvider`, fake it with `FakeTimeProvider` in tests
24
+
25
+ Any logic that compares against "now" (a past-date validation, an expiry check, a due-date
26
+ default) MUST read the clock through an injected `TimeProvider` — never `DateTime.UtcNow` or
27
+ `DateOnly.FromDateTime(DateTime.UtcNow)` directly in a handler or validator. A validator that
28
+ reads the real clock makes "today" a moving target: a test asserting "today is valid, yesterday
29
+ is rejected" flakes for real around UTC midnight, and is untestable for the "exactly at the
30
+ boundary" case at all.
31
+
32
+ ```csharp
33
+ // Features/VisitFeature/CloseVisit/CloseVisitValidator.cs
34
+ public sealed class CloseVisitValidator : AbstractValidator<CloseVisitRequest>
35
+ {
36
+ public CloseVisitValidator(TimeProvider timeProvider)
37
+ {
38
+ RuleFor(c => c.FollowUpDate)
39
+ .Must(date => date!.Value >= Today(timeProvider))
40
+ .WithErrorCode(VisitErrors.FollowUpDateInPast.Code)
41
+ .WithMessage(VisitErrors.FollowUpDateInPast.Description)
42
+ .When(c => c.FollowUpDate.HasValue);
43
+ }
44
+
45
+ private static DateOnly Today(TimeProvider timeProvider) =>
46
+ DateOnly.FromDateTime(timeProvider.GetUtcNow().UtcDateTime);
47
+ }
48
+ ```
49
+
50
+ Register the real clock once in `Program.cs`:
51
+
52
+ ```csharp
53
+ builder.Services.AddSingleton(TimeProvider.System);
54
+ ```
55
+
56
+ In tests, swap it for `Microsoft.Extensions.TimeProvider.Testing`'s `FakeTimeProvider` —
57
+ deterministic, no dependency on when the suite happens to run:
58
+
59
+ ```csharp
60
+ // tests/.../Infrastructure/VetClinicApiFactory.cs
61
+ public sealed class VetClinicApiFactory : WebApplicationFactory<Program>
62
+ {
63
+ public FakeTimeProvider TimeProvider { get; } = new(new DateTimeOffset(2026, 6, 15, 0, 0, 0, TimeSpan.Zero));
64
+
65
+ protected override void ConfigureWebHost(IWebHostBuilder builder) =>
66
+ builder.ConfigureServices(services =>
67
+ {
68
+ services.RemoveAll<TimeProvider>();
69
+ services.AddSingleton<TimeProvider>(TimeProvider);
70
+ });
71
+ }
72
+ ```
73
+
74
+ ```csharp
75
+ // The boundary case ("today is accepted, yesterday is not") becomes a fact about
76
+ // the fake clock, not about when CI happens to run.
77
+ DateOnly today = DateOnly.FromDateTime(factory.TimeProvider.GetUtcNow().UtcDateTime);
78
+ DateOnly yesterday = today.AddDays(-1);
79
+ ```
80
+
81
+ ---
82
+
83
+ ## Rule 2 — Error-path tests assert the response BODY, not just the status code
84
+
85
+ A test that only checks `response.StatusCode` proves the endpoint returns *a* 400/404/409 — it
86
+ proves nothing about *why*, and lets the wrong validation rule or the wrong error code pass
87
+ silently as long as the status happens to match. Every error-path test MUST also assert the
88
+ `ProblemDetails` body: at minimum `title` (or `type`) and the specific field/reason that
89
+ triggered the failure.
90
+
91
+ ```csharp
92
+ // Wrong — proves only that SOMETHING was rejected, not what:
93
+ HttpResponseMessage response = await client.PostAsJsonAsync($"visits/{id}/close", body);
94
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
95
+ ```
96
+
97
+ ```csharp
98
+ // Right — the body is the actual contract a client integrates against:
99
+ HttpResponseMessage response = await client.PostAsJsonAsync($"visits/{id}/close", body);
100
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
101
+
102
+ ProblemDetails? problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
103
+ Assert.NotNull(problem);
104
+ Assert.Contains("FollowUpDate", problem!.Extensions["errors"]!.ToString());
105
+ Assert.Equal(VisitErrors.FollowUpDateInPast.Code, GetErrorCode(problem));
106
+ ```
107
+
108
+ The same discipline applies to 404 (`NotFound` — assert the entity/id named in the problem
109
+ detail) and 409 (`Conflict` — assert the conflict reason, e.g. `"Visit.AlreadyClosed"`, not just
110
+ that a conflict happened). A status-only assertion is a regression magnet: swapping
111
+ `VisitErrors.NotFound` for `VisitErrors.AlreadyClosed` in the wrong branch still returns 404/409
112
+ respectively and the test suite stays green.
113
+
114
+ ---
115
+
116
+ ## Anti-Patterns
117
+
118
+ | Do NOT | Do Instead |
119
+ |--------|------------|
120
+ | Read `DateTime.UtcNow` / `DateOnly.FromDateTime(DateTime.UtcNow)` directly in a handler or validator | Inject `TimeProvider`, read `timeProvider.GetUtcNow()` |
121
+ | Leave a test's "past date" derived from the real system clock (`DateTime.UtcNow.AddDays(-1)`) | Swap `TimeProvider` for `FakeTimeProvider` in the test factory and derive dates from it |
122
+ | Assert only `response.StatusCode` on an error path | Also deserialize and assert the `ProblemDetails` body (title/detail/violated field or error code) |
123
+ | Treat a passing status-only test as proof the error message/code is correct | Assert the specific error code/field — a wrong branch can still return the right status |
124
+
125
+ ---
126
+
127
+ *MORPH-SPEC by Polymorphism Tech*
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polymorphism-tech/morph-spec",
3
- "version": "8.22.0",
3
+ "version": "8.24.1",
4
4
  "description": "MORPH-SPEC: NLH (Natural Language Harness) for spec-driven development with Claude Code",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -31,10 +31,13 @@
31
31
  */
32
32
 
33
33
  import { execSync, execFileSync } from 'child_process';
34
- import { existsSync, mkdirSync, symlinkSync, readFileSync, writeFileSync, appendFileSync, lstatSync, unlinkSync, rmdirSync, readdirSync, cpSync } from 'fs';
35
- import { join, resolve, dirname, relative } from 'path';
34
+ import { existsSync, mkdirSync, symlinkSync, readFileSync, writeFileSync, appendFileSync, lstatSync, unlinkSync, rmdirSync, cpSync } from 'fs';
35
+ import { join, resolve, dirname } from 'path';
36
36
  import chalk from 'chalk';
37
37
  import { detectDefaultBranch } from '../../lib/git/default-branch.js';
38
+ // The reparse scan lives in hooks/shared because the PreToolUse guardrail needs
39
+ // the SAME answer this file does, and hooks can never import from `src/`.
40
+ import { findReparsePoints } from '../../../framework/hooks/shared/reparse-scan.js';
38
41
 
39
42
  /**
40
43
  * The directory new worktrees are created under, relative to the primary root.
@@ -740,64 +743,6 @@ export function unlinkWorktreeInfraLinks(worktreePath, mainCwd) {
740
743
  return removed;
741
744
  }
742
745
 
743
- /**
744
- * Every reparse point anywhere under `worktreePath`, as relative paths.
745
- *
746
- * CRITICAL — this is the gate that keeps `morph-spec finish` (worktree teardown)
747
- * from destroying the project, and it must be exhaustive. BOTH `git worktree remove` and its
748
- * `--force` fallback FOLLOW reparse points out of the worktree and delete the
749
- * PRIMARY ROOT's real files. The plain removal is not the safe one: it refuses
750
- * only when it sees modified/untracked *tracked-scope* content, so a GITIGNORED
751
- * junction (`node_modules`, `.claude`) leaves `git status` empty, the removal
752
- * proceeds, exits 0 — and the target is gone.
753
- *
754
- * So the check cannot live in the `catch` of the plain removal (unreachable on
755
- * exactly the destructive path), and it cannot be a fixed list of the links
756
- * `setup` creates: a nested link inside a real `node_modules` (npm link, npm
757
- * workspaces) is invisible to any top-level inventory. We scan the whole tree
758
- * and NEVER recurse INTO a reparse point — descending one would walk the target
759
- * we are trying to protect (and could not terminate on a cycle).
760
- *
761
- * An empty result is NOT the same as a safe one: a directory we could not read
762
- * or an entry we could not lstat might BE (or contain) a reparse point. Those
763
- * answer `unprovable: true`, and the caller must treat that exactly like a found
764
- * link — decline. "I saw nothing" and "I proved there is nothing" are different
765
- * claims, and only the second one may authorize a removal.
766
- *
767
- * @param {string} dir
768
- * @param {string} [base] - root of the scan, for relative reporting
769
- * @param {{points: string[], unprovable: string[]}} [acc]
770
- * @returns {{points: string[], unprovable: string[]}} both empty = provably clean
771
- */
772
- function findReparsePoints(dir, base = dir, acc = { points: [], unprovable: [] }) {
773
- let entries;
774
- try {
775
- entries = readdirSync(dir, { withFileTypes: true });
776
- } catch {
777
- // Unreadable (ACL, lock, EMFILE): we cannot prove what is in here.
778
- acc.unprovable.push((relative(base, dir) || '.').replace(/\\/g, '/'));
779
- return acc;
780
- }
781
- for (const entry of entries) {
782
- const p = join(dir, entry.name);
783
- let st;
784
- try {
785
- st = lstatSync(p);
786
- } catch {
787
- acc.unprovable.push(relative(base, p).replace(/\\/g, '/'));
788
- continue;
789
- }
790
- if (st.isSymbolicLink()) {
791
- // Forward slashes: every other path this command reports is normalized,
792
- // and these end up in JSON the caller may compare or display.
793
- acc.points.push(relative(base, p).replace(/\\/g, '/'));
794
- continue; // do NOT descend — that is the target, not the worktree
795
- }
796
- if (st.isDirectory() && entry.name !== '.git') findReparsePoints(p, base, acc);
797
- }
798
- return acc;
799
- }
800
-
801
746
  /**
802
747
  * Gitignored paths a morph worktree owns and that are safe to remove — but ONLY
803
748
  * once backed up (see backupWorktreeIgnored). `.morph/state.json` is provably
@@ -1085,6 +1030,60 @@ async function clearRootWorktreeRegistration(root, feature) {
1085
1030
  } catch { /* fail-open — a dirty root is a nuisance, not a reason to fail */ }
1086
1031
  }
1087
1032
 
1033
+ /**
1034
+ * Why `git worktree remove` failed, from its stderr.
1035
+ *
1036
+ * The distinction that matters is `in-use` — on Windows a process whose cwd is
1037
+ * inside the worktree (another Claude Code instance, VS Code, a dev server, a
1038
+ * stray terminal) pins the directory and the removal cannot proceed. That is a
1039
+ * human-actionable cause with a specific fix ("close it"), and printing it is
1040
+ * the difference between a message the user can act on and one that just says
1041
+ * "failed" — which is what made a raw `git worktree remove --force` look like
1042
+ * the only way forward.
1043
+ *
1044
+ * @param {string} stderr
1045
+ * @returns {{ kind: 'in-use'|'unknown', detail: string }}
1046
+ */
1047
+ function classifyRemovalError(stderr) {
1048
+ const text = String(stderr || '').trim();
1049
+ const inUse = /permission denied|EPERM|EBUSY|being used by another process|resource busy|access is denied|is locked/i.test(text);
1050
+ // First stderr line only: the rest is git's usual advice block, and this lands
1051
+ // in a JSON field a human reads.
1052
+ const detail = text.split(/\r?\n/).find((l) => l.trim()) || '';
1053
+ return { kind: inUse ? 'in-use' : 'unknown', detail: detail.slice(0, 200) };
1054
+ }
1055
+
1056
+ /**
1057
+ * Run the removal, with one retry round after a short pause.
1058
+ *
1059
+ * The retry exists for the transient Windows handle (antivirus/indexer touching
1060
+ * a file mid-removal) — the same class the repo already retries in
1061
+ * `atomicWriteJson`. It deliberately does NOT help the `in-use` case: a held
1062
+ * directory stays held, and that one needs the human, not another attempt.
1063
+ *
1064
+ * `--force` is safe HERE and only here: the caller proved `blockers.length ===
1065
+ * 0` before calling, so there is nothing left for it to traverse out of.
1066
+ *
1067
+ * @param {string} worktreePath
1068
+ * @param {string} root
1069
+ * @returns {Promise<{ removed: boolean, failure: {kind: string, detail: string}|null }>}
1070
+ */
1071
+ async function attemptWorktreeRemoval(worktreePath, root) {
1072
+ let lastStderr = '';
1073
+ for (let round = 0; round < 2; round++) {
1074
+ for (const args of [['worktree', 'remove', worktreePath], ['worktree', 'remove', '--force', worktreePath]]) {
1075
+ try {
1076
+ execFileSync('git', args, { cwd: root, stdio: 'pipe' });
1077
+ return { removed: true, failure: null };
1078
+ } catch (err) {
1079
+ lastStderr = err?.stderr ? String(err.stderr) : (err?.message || '');
1080
+ }
1081
+ }
1082
+ if (round === 0) await new Promise((r) => setTimeout(r, 200));
1083
+ }
1084
+ return { removed: false, failure: classifyRemovalError(lastStderr) };
1085
+ }
1086
+
1088
1087
  /**
1089
1088
  * Remove a feature's worktree after its branch is integrated — PROVE-or-DECLINE.
1090
1089
  * The single most dangerous step in the lifecycle, so it declines whenever it
@@ -1095,15 +1094,22 @@ async function clearRootWorktreeRegistration(root, feature) {
1095
1094
  * 2. it deletes GITIGNORED files outright — a user's `.env` is not disposable,
1096
1095
  * and the clean-check cannot see them.
1097
1096
  *
1097
+ * A non-removal comes in two flavours the caller must not conflate — see the
1098
+ * rollback comment below. `refused` (we proved it unsafe) restores the infra
1099
+ * junctions; a FAILURE (git errored) deliberately leaves them down, reported in
1100
+ * `infraLeftUnlinked`.
1101
+ *
1098
1102
  * `deleteBranch` decouples `git branch -d` from removal: true for `--merge` (the
1099
1103
  * branch is integrated), false for `--pr` (the open PR still needs the branch).
1100
1104
  *
1101
1105
  * @param {string} worktreePath
1102
1106
  * @param {{ root: string, feature: string, branch: string, options?: object, deleteBranch?: boolean }} ctx
1103
1107
  * @returns {Promise<{ worktreeRemoved: boolean, ignoredBackup: string|null, discarded: string[],
1104
- * blockers: string[], ignoredBlocking: string[], shieldable: string[], declineReason: string|null }>}
1108
+ * blockers: string[], ignoredBlocking: string[], shieldable: string[], declineReason: string|null,
1109
+ * relinked: boolean, refused: boolean, infraLeftUnlinked: string[],
1110
+ * removalFailure: {kind: string, detail: string}|null }>}
1105
1111
  */
1106
- export async function teardownWorktree(worktreePath, { root, feature, branch, options = {}, deleteBranch = true }) {
1112
+ export async function teardownWorktree(worktreePath, { root, feature, branch, options = {}, deleteBranch = true, addressByTarget = false }) {
1107
1113
  // Drop the infra junctions FIRST, without following them — this is what makes
1108
1114
  // a removal survivable (see unlinkWorktreeInfraLinks). Runs unconditionally.
1109
1115
  //
@@ -1130,18 +1136,11 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
1130
1136
  const canRemove = clearedToRemove && (!ignored.shieldable.length || ignoredBackup !== null);
1131
1137
 
1132
1138
  let worktreeRemoved = false;
1139
+ let removalFailure = null;
1133
1140
  if (canRemove) {
1134
- try {
1135
- execFileSync('git', ['worktree', 'remove', worktreePath], { cwd: root, stdio: 'pipe' });
1136
- worktreeRemoved = true;
1137
- } catch {
1138
- // Nothing is left to traverse (proven above), so --force can only act
1139
- // inside the worktree.
1140
- try {
1141
- execFileSync('git', ['worktree', 'remove', '--force', worktreePath], { cwd: root, stdio: 'pipe' });
1142
- worktreeRemoved = true;
1143
- } catch { /* fail-open — cleanup is best-effort once integration landed */ }
1144
- }
1141
+ const attempt = await attemptWorktreeRemoval(worktreePath, root);
1142
+ worktreeRemoved = attempt.removed;
1143
+ removalFailure = attempt.failure; // null once removed
1145
1144
  }
1146
1145
 
1147
1146
  if (worktreeRemoved) {
@@ -1161,30 +1160,58 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
1161
1160
  await clearRootWorktreeRegistration(root, feature);
1162
1161
  }
1163
1162
 
1164
- // Rollback (A4): the removal was refused or failed, so the worktree is still
1165
- // there — but without the junctions this function dropped up front, which is
1166
- // strictly worse than never having tried (the observed complaint: "um estado
1167
- // pior que não ter tentado"). Put them back so the worktree stays usable and
1168
- // the user can just re-run finish from the primary root. Fail-open: a failed
1169
- // re-link reports `relinked: false` and the caller falls back to telling the
1170
- // user to run `morph-spec worktree link --all`.
1163
+ // Rollback (A4) — ONLY on a REFUSAL, never on a FAILURE. The two look alike
1164
+ // (worktree still there, junctions dropped) and must be treated as opposites:
1165
+ //
1166
+ // REFUSED (`!canRemove`): we proved removal unsafe, so the user keeps
1167
+ // working in this worktree. Leaving the junctions down is "um estado pior
1168
+ // que não ter tentado" — put them back.
1169
+ // FAILED (`canRemove` but git errored): removal WAS the user's intent and
1170
+ // the only obstacle is a lock. Re-linking here would restore the exact
1171
+ // hazard that destroyed installations — the retry (ours, theirs, or a raw
1172
+ // `git worktree remove --force` pasted from an old transcript) would then
1173
+ // follow the junctions out. Leaving them down makes EVERY subsequent
1174
+ // attempt safe, because there is nothing left to traverse.
1175
+ //
1176
+ // Fail-open on the refusal path: a failed re-link reports `relinked: false`
1177
+ // and the caller falls back to telling the user to run `worktree link --all`.
1178
+ const refused = !canRemove;
1171
1179
  let relinked = false;
1172
- if (!worktreeRemoved && unlinked.length && existsSync(worktreePath)) {
1180
+ if (refused && unlinked.length && existsSync(worktreePath)) {
1173
1181
  try {
1174
1182
  setupInfraLinks(worktreePath, root);
1175
1183
  relinked = true;
1176
1184
  } catch { relinked = false; }
1177
1185
  }
1178
-
1186
+ // What the caller must tell the user about on the FAILURE path: the worktree
1187
+ // survived without its shared infra, on purpose.
1188
+ const infraLeftUnlinked = (!worktreeRemoved && !refused) ? unlinked : [];
1189
+
1190
+ // Every branch names `morph-spec worktree remove` and NEVER `git worktree
1191
+ // remove`. The raw command follows the junctions this function manages, and a
1192
+ // decline is exactly the moment a frustrated reader copies whatever command
1193
+ // the tool just printed — that copy is what emptied a primary root's
1194
+ // `.claude/` and `.morph/framework/`. `worktree remove` re-runs this same
1195
+ // PROVE-or-DECLINE path and accepts the same `--discard-ignored` consent, so
1196
+ // there is no decline left where the raw command would be the right answer.
1197
+ // A worktree with no feature identity (the ones `isolation: "worktree"`
1198
+ // creates) can only be re-addressed by path — naming a feature that does not
1199
+ // resolve would hand the reader a command that FAILS, and a failing safe
1200
+ // command is exactly how people end up back at the raw one.
1201
+ const removeCmd = (addressByTarget || !isValidFeatureName(feature))
1202
+ ? `morph-spec worktree remove --target "${worktreePath}"`
1203
+ : `morph-spec worktree remove ${feature}`;
1179
1204
  const declineReason = worktreeRemoved
1180
1205
  ? null
1181
1206
  : ignored.blocking.length
1182
- ? `worktree NÃO removido: contém arquivo(s) gitignored que existem só ali e o \`git worktree remove\` apagaria (${ignored.blocking.slice(0, 5).join(', ')}${ignored.blocking.length > 5 ? `, +${ignored.blocking.length - 5}` : ''}). Salve o que importa e rode \`git worktree remove --force "${worktreePath}"\`${deleteBranch ? ` + \`git branch -d ${branch}\`` : ''}, ou re-execute o finish com \`--discard-ignored\` para descartá-los deliberadamente`
1207
+ ? `worktree NÃO removido: contém arquivo(s) gitignored que existem só ali e a remoção apagaria (${ignored.blocking.slice(0, 5).join(', ')}${ignored.blocking.length > 5 ? `, +${ignored.blocking.length - 5}` : ''}). Salve o que importa e rode \`${removeCmd}\`, ou \`${removeCmd} --discard-ignored\` para descartá-los deliberadamente`
1183
1208
  : blockers.length
1184
- ? `worktree NÃO removido: ainda contém link(s)/caminho(s) não verificáveis (${blockers.join(', ')}) que o \`git worktree remove\` seguiria para fora, apagando o alvo na raiz. Remova-os e rode \`git worktree remove "${worktreePath}"\`${deleteBranch ? ` + \`git branch -d ${branch}\`` : ''}`
1209
+ ? `worktree NÃO removido: ainda contém link(s)/caminho(s) não verificáveis (${blockers.join(', ')}) que a remoção seguiria para fora, apagando o alvo na raiz. Remova-os e rode \`${removeCmd}\``
1185
1210
  : ignored.shieldable.length && ignoredBackup === null
1186
- ? `worktree NÃO removido: o backup do gitignored do worktree falhou, e sem ele a remoção seria destrutiva. Rode \`git worktree remove --force "${worktreePath}"\` manualmente se não precisar desse conteúdo`
1187
- : `o \`git worktree remove\` falhou. Rode \`git worktree remove "${worktreePath}"\` manualmente`;
1211
+ ? `worktree NÃO removido: o backup do gitignored do worktree falhou, e sem ele a remoção seria destrutiva. Libere espaço/permissão em \`.git/morph-spec/\` e rode \`${removeCmd}\`, ou \`${removeCmd} --discard-ignored\` se não precisar desse conteúdo`
1212
+ : removalFailure?.kind === 'in-use'
1213
+ ? `worktree NÃO removido: o diretório está EM USO por outro processo (instância do Claude Code, VS Code, dev server ou terminal com cwd em "${worktreePath}"). A infra compartilhada ficou DESMONTADA de propósito — feche o que está usando o diretório e rode \`${removeCmd}\`. Se preferir continuar trabalhando nele, restaure a infra com \`morph-spec worktree link --all\``
1214
+ : `worktree NÃO removido: a remoção falhou${removalFailure?.detail ? ` (${removalFailure.detail})` : ''}. A infra compartilhada ficou DESMONTADA de propósito — rode \`${removeCmd}\` para tentar de novo, ou \`morph-spec worktree link --all\` para voltar a trabalhar nele`;
1188
1215
 
1189
1216
  return {
1190
1217
  worktreeRemoved,
@@ -1195,7 +1222,144 @@ export async function teardownWorktree(worktreePath, { root, feature, branch, op
1195
1222
  shieldable: ignored.shieldable,
1196
1223
  declineReason,
1197
1224
  relinked,
1225
+ refused,
1226
+ infraLeftUnlinked,
1227
+ removalFailure,
1228
+ };
1229
+ }
1230
+
1231
+ /**
1232
+ * Is `cwd` the worktree we are about to remove, or inside it?
1233
+ *
1234
+ * Hoisted out of `finish` so BOTH callers share one answer. Windows holds a
1235
+ * lock on a process's working directory, so a removal issued from inside the
1236
+ * target fails — and in `finish`'s case it fails AFTER the archive is committed
1237
+ * and the branch merged, leaving an orphan directory and a de-registered
1238
+ * worktree. Refusing up front costs one `cd`.
1239
+ *
1240
+ * @param {string} cwd
1241
+ * @param {string} worktreePath
1242
+ * @returns {boolean}
1243
+ */
1244
+ export function cwdIsInsideWorktree(cwd, worktreePath) {
1245
+ const cwdNorm = resolve(cwd).replace(/\\/g, '/');
1246
+ const wtNorm = resolve(worktreePath).replace(/\\/g, '/');
1247
+ return cwdNorm === wtNorm || cwdNorm.startsWith(`${wtNorm}/`);
1248
+ }
1249
+
1250
+ // ── remove ───────────────────────────────────────────────────────────────
1251
+
1252
+ /**
1253
+ * Command handler for `morph-spec worktree remove <feature>` /
1254
+ * `morph-spec worktree remove --target <path>`.
1255
+ *
1256
+ * WHY THIS EXISTS: `teardownWorktree` — the only removal that drops the infra
1257
+ * junctions before letting git near the directory — used to be reachable ONLY
1258
+ * through `morph-spec finish`. So every message that had to tell a user how to
1259
+ * remove a worktree pointed at raw `git worktree remove`, which follows those
1260
+ * junctions out and empties the primary root's `.claude/` and
1261
+ * `.morph/framework/`. This command is the safe answer those messages now give.
1262
+ *
1263
+ * Two ways to address the target, because there are two kinds of worktree:
1264
+ * <feature> — a morph worktree, resolved through `findWorktreeForFeature`
1265
+ * --target <path> — any registered worktree, including the ones the Agent
1266
+ * tool's `isolation: "worktree"` creates, which have no
1267
+ * feature registration for the lookup to find.
1268
+ *
1269
+ * It removes ONLY. No archive, no merge, and the branch survives by default —
1270
+ * closing a feature is still `morph-spec finish`.
1271
+ *
1272
+ * @param {string|undefined} featureArg
1273
+ * @param {{ target?: string, discardIgnored?: boolean, deleteBranch?: boolean, cwd?: string }} [options]
1274
+ */
1275
+ export async function worktreeRemoveCommand(featureArg, options = {}) {
1276
+ const cwdArg = options.cwd || process.cwd();
1277
+ const emit = (obj, code) => { console.log(JSON.stringify(obj)); process.exit(code); };
1278
+
1279
+ if (!isGitRepo(cwdArg)) emit({ removed: false, error: 'not-a-git-repo' }, 1);
1280
+ const root = resolvePrimaryRoot(cwdArg);
1281
+
1282
+ let worktreePath = null;
1283
+ let feature = featureArg || null;
1284
+ let branch = null;
1285
+ // Can a re-run be addressed as `remove <feature>`? Only when the feature name
1286
+ // is what resolved the worktree in the first place.
1287
+ let featureAddressable = !options.target;
1288
+
1289
+ if (options.target) {
1290
+ worktreePath = resolve(options.target).replace(/\\/g, '/');
1291
+ // The target MUST be a registered worktree. Without this the command would
1292
+ // hand an arbitrary path to `git worktree remove` — and `--target ../..`
1293
+ // is the same path-traversal class already fixed in delete/archive.
1294
+ const entry = parseWorktreeList(root).find((e) => e.path === worktreePath);
1295
+ if (!entry) {
1296
+ emit({ removed: false, error: 'not-a-registered-worktree', target: worktreePath, root,
1297
+ hint: 'o path precisa ser um worktree registrado neste repo — confira `morph-spec worktree list`' }, 1);
1298
+ }
1299
+ if (worktreePath === root) {
1300
+ emit({ removed: false, error: 'refuses-primary-root', path: worktreePath,
1301
+ hint: 'a raiz primária nunca é removível por este comando' }, 1);
1302
+ }
1303
+ branch = entry.branch ? entry.branch.replace(/^refs\/heads\//, '') : null;
1304
+ // Best-effort feature identity — used for the backup/env-release bookkeeping
1305
+ // inside the teardown. A `morph/{f}` branch means `remove <feature>` would
1306
+ // resolve too, so re-run advice may name it; anything else stays --target.
1307
+ if (!feature && branch?.startsWith('morph/')) {
1308
+ feature = branch.slice('morph/'.length);
1309
+ featureAddressable = isValidFeatureName(feature);
1310
+ }
1311
+ } else {
1312
+ if (!isValidFeatureName(feature)) {
1313
+ emit({ removed: false, error: 'invalid-feature-name', feature: feature ?? null,
1314
+ hint: 'informe <feature> (kebab-case) ou use --target <path>' }, 1);
1315
+ }
1316
+ const found = findWorktreeForFeature(feature, root);
1317
+ if (!found || found.path.replace(/\\/g, '/') === root) {
1318
+ emit({ removed: false, error: 'no-worktree-for-feature', feature, root,
1319
+ hint: `nenhum worktree separado para "${feature}" — confira \`morph-spec worktree list\`` }, 1);
1320
+ }
1321
+ worktreePath = found.path;
1322
+ branch = found.branch;
1323
+ }
1324
+
1325
+ // Same guard finish applies, for the same Windows reason — and this command
1326
+ // is MORE likely to hit it: the natural moment to run it is right after
1327
+ // working inside the worktree.
1328
+ if (cwdIsInsideWorktree(cwdArg, worktreePath)) {
1329
+ emit({ removed: false, error: 'cwd-inside-worktree', path: worktreePath, root,
1330
+ 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);
1331
+ }
1332
+
1333
+ const teardown = await teardownWorktree(worktreePath, {
1334
+ root,
1335
+ feature: feature || 'worktree',
1336
+ branch,
1337
+ options: { discardIgnored: options.discardIgnored === true },
1338
+ // Removing a worktree is not closing a feature: the branch is the work, and
1339
+ // deleting it here would discard unintegrated commits. Opt-in only.
1340
+ deleteBranch: options.deleteBranch === true,
1341
+ // Re-run advice must address the worktree the way it was addressable here:
1342
+ // a `--target` worktree has no feature name that would resolve.
1343
+ addressByTarget: !featureAddressable,
1344
+ });
1345
+
1346
+ const out = {
1347
+ removed: teardown.worktreeRemoved,
1348
+ path: worktreePath,
1349
+ ...(branch ? { branch } : {}),
1350
+ ...(feature ? { feature } : {}),
1198
1351
  };
1352
+ if (teardown.worktreeRemoved) {
1353
+ if (teardown.discarded.length) out.discardedIgnored = teardown.discarded;
1354
+ if (teardown.ignoredBackup) out.ignoredBackup = teardown.ignoredBackup;
1355
+ } else {
1356
+ out.refused = teardown.refused;
1357
+ if (teardown.blockers.length) out.reparsePoints = teardown.blockers;
1358
+ if (teardown.ignoredBlocking.length) out.ignoredFiles = teardown.ignoredBlocking;
1359
+ if (teardown.infraLeftUnlinked.length) out.infraLeftUnlinked = teardown.infraLeftUnlinked;
1360
+ out.hint = teardown.declineReason;
1361
+ }
1362
+ emit(out, teardown.worktreeRemoved ? 0 : 1);
1199
1363
  }
1200
1364
 
1201
1365
  // ── list ─────────────────────────────────────────────────────────────────
@@ -325,6 +325,17 @@ export async function approveCommand(featureName, gateName, options = {}) {
325
325
  ...(selfAssessed ? { selfAssessed: true } : {}),
326
326
  });
327
327
 
328
+ // Fleet handoff (mirrors advance.js): approving a gate is exactly when a
329
+ // feature hands over between sessions too — `approve` is the real path
330
+ // Gate 2 took in the field (`approve <f> plan --mode auto`), not just
331
+ // `advance`, so a handoff.md left stale after approve was the common
332
+ // case, not the edge case. Same helper, same fail-open contract: a
333
+ // regeneration failure must never fail the approval itself.
334
+ try {
335
+ const { writeHandoff } = await import('../../lib/generators/handoff-generator.js');
336
+ writeHandoff(featureName, process.cwd());
337
+ } catch { /* fail-open */ }
338
+
328
339
  if (selfAssessed) {
329
340
  console.log(chalk.yellow('\n⚠️ Gate 3 aprovado SEM avaliador independente (--self-assessed).'));
330
341
  console.log(chalk.gray(' Registrado em feature.json → approvalGates.review.selfAssessed.'));